From 79337cd60a72eda8699f595819a8c371d2e95012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wysoki=C5=84ski?= Date: Fri, 9 Feb 2024 11:30:01 +0000 Subject: [PATCH] feat: api - add a new endpoint - GET /api/v2/versions/{versionCode}/servers (#59) Reviewed-on: https://gitea.dwysokinski.me/twhelp/corev3/pulls/59 --- api/openapi3.yml | 90 ++++++++++++++ cmd/twhelp/cmd_serve.go | 3 + internal/adapter/adapter.go | 8 ++ .../adapter/repository_bun_ennoblement.go | 2 +- internal/adapter/repository_bun_player.go | 2 +- .../adapter/repository_bun_player_snapshot.go | 2 +- internal/adapter/repository_bun_server.go | 110 ++++++++++++++---- internal/adapter/repository_bun_tribe.go | 2 +- .../adapter/repository_bun_tribe_change.go | 2 +- .../adapter/repository_bun_tribe_snapshot.go | 2 +- internal/adapter/repository_bun_version.go | 2 +- internal/adapter/repository_bun_village.go | 2 +- internal/adapter/repository_server_test.go | 34 ++++++ internal/app/service_server.go | 7 ++ internal/port/handler_http_api.go | 8 +- internal/port/handler_http_api_error.go | 10 +- internal/port/handler_http_api_server.go | 80 +++++++++++++ internal/port/handler_http_api_test.go | 6 +- internal/port/handler_http_api_version.go | 6 +- internal/port/internal/apimodel/server.go | 56 +++++++++ 20 files changed, 393 insertions(+), 41 deletions(-) create mode 100644 internal/adapter/adapter.go create mode 100644 internal/port/handler_http_api_server.go create mode 100644 internal/port/internal/apimodel/server.go diff --git a/api/openapi3.yml b/api/openapi3.yml index 8cea9c8..84727e1 100644 --- a/api/openapi3.yml +++ b/api/openapi3.yml @@ -11,6 +11,7 @@ info: name: MIT tags: - name: versions + - name: servers servers: - url: "{scheme}://{hostname}/api" variables: @@ -49,6 +50,23 @@ paths: $ref: "#/components/responses/GetVersionResponse" default: $ref: "#/components/responses/ErrorResponse" + /v2/versions/{versionCode}/servers: + get: + operationId: listServers + tags: + - versions + - servers + description: List servers + parameters: + - $ref: "#/components/parameters/VersionCodePathParam" + - $ref: "#/components/parameters/CursorQueryParam" + - $ref: "#/components/parameters/LimitQueryParam" + - $ref: "#/components/parameters/ServerOpenQueryParam" + responses: + 200: + $ref: "#/components/responses/ListServersResponse" + default: + $ref: "#/components/responses/ErrorResponse" components: schemas: Error: @@ -94,6 +112,55 @@ components: timezone: type: string example: Europe/Warsaw + Server: + type: object + required: + - key + - open + - url + - numPlayers + - numTribes + - numVillages + - numBarbarianVillages + - numBonusVillages + - numPlayerVillages + - createdAt + properties: + key: + type: string + example: pl151 + open: + type: boolean + url: + type: string + example: https://pl151.plemiona.pl + numPlayers: + type: integer + playerDataSyncedAt: + type: string + format: date-time + numTribes: + type: integer + tribeDataSyncedAt: + type: string + format: date-time + numVillages: + type: integer + numBarbarianVillages: + type: integer + numBonusVillages: + type: integer + numPlayerVillages: + type: integer + villageDataSyncedAt: + type: string + format: date-time + ennoblementDataSyncedAt: + type: string + format: date-time + createdAt: + type: string + format: date-time Cursor: type: object x-go-type-skip-optional-pointer: true @@ -124,6 +191,14 @@ components: schema: type: integer required: false + ServerOpenQueryParam: + name: open + in: query + description: true=only open servers, false=only closed servers, + by default both open and closed servers are returned + schema: + type: boolean + required: false VersionCodePathParam: in: path name: versionCode @@ -157,6 +232,21 @@ components: properties: data: $ref: "#/components/schemas/Version" + ListServersResponse: + description: "" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/PaginationResponse" + - type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/Server" ErrorResponse: description: Default error response. content: diff --git a/cmd/twhelp/cmd_serve.go b/cmd/twhelp/cmd_serve.go index 03c703b..c16bc36 100644 --- a/cmd/twhelp/cmd_serve.go +++ b/cmd/twhelp/cmd_serve.go @@ -105,9 +105,11 @@ var cmdServe = &cli.Command{ // adapters versionRepo := adapter.NewVersionBunRepository(bunDB) + serverRepo := adapter.NewServerBunRepository(bunDB) // services versionSvc := app.NewVersionService(versionRepo) + serverSvc := app.NewServerService(serverRepo, nil, nil) // health h := health.New() @@ -132,6 +134,7 @@ var cmdServe = &cli.Command{ r.Mount(apiBasePath, port.NewAPIHTTPHandler( versionSvc, + serverSvc, port.WithOpenAPIConfig(oapiCfg), )) diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go new file mode 100644 index 0000000..c511335 --- /dev/null +++ b/internal/adapter/adapter.go @@ -0,0 +1,8 @@ +package adapter + +import "errors" + +var ( + errUnsupportedSortValue = errors.New("unsupported sort value") + errSortNoUniqueField = errors.New("no unique field used for sorting") +) diff --git a/internal/adapter/repository_bun_ennoblement.go b/internal/adapter/repository_bun_ennoblement.go index 8819d13..408122e 100644 --- a/internal/adapter/repository_bun_ennoblement.go +++ b/internal/adapter/repository_bun_ennoblement.go @@ -92,7 +92,7 @@ func (a listEnnoblementsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuer case domain.EnnoblementSortServerKeyDESC: q = q.Order("ennoblement.server_key DESC") default: - return q.Err(errors.New("unsupported sort value")) + return q.Err(errUnsupportedSortValue) } } diff --git a/internal/adapter/repository_bun_player.go b/internal/adapter/repository_bun_player.go index 44619ed..76ea71b 100644 --- a/internal/adapter/repository_bun_player.go +++ b/internal/adapter/repository_bun_player.go @@ -159,7 +159,7 @@ func (a listPlayersParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { case domain.PlayerSortServerKeyDESC: q = q.Order("player.server_key DESC") default: - return q.Err(errors.New("unsupported sort value")) + return q.Err(errUnsupportedSortValue) } } diff --git a/internal/adapter/repository_bun_player_snapshot.go b/internal/adapter/repository_bun_player_snapshot.go index a331977..64a954e 100644 --- a/internal/adapter/repository_bun_player_snapshot.go +++ b/internal/adapter/repository_bun_player_snapshot.go @@ -97,7 +97,7 @@ func (a listPlayerSnapshotsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQ case domain.PlayerSnapshotSortServerKeyDESC: q = q.Order("ps.server_key DESC") default: - return q.Err(errors.New("unsupported sort value")) + return q.Err(errUnsupportedSortValue) } } diff --git a/internal/adapter/repository_bun_server.go b/internal/adapter/repository_bun_server.go index 9812f9b..6c26973 100644 --- a/internal/adapter/repository_bun_server.go +++ b/internal/adapter/repository_bun_server.go @@ -228,33 +228,97 @@ func (a listServersParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { case domain.ServerSortOpenDESC: q = q.Order("server.open DESC") default: - return q.Err(errors.New("unsupported sort value")) + return q.Err(errUnsupportedSortValue) } } - if !a.params.Cursor().IsZero() { - q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { - cursorKey := a.params.Cursor().Key() - cursorOpen := a.params.Cursor().Open() + return q.Apply(a.applyCursor).Limit(a.params.Limit() + 1) +} - for _, s := range a.params.Sort() { - switch s { - case domain.ServerSortKeyASC: - q = q.Where("server.key >= ?", cursorKey) - case domain.ServerSortKeyDESC: - q = q.Where("server.key <= ?", cursorKey) - case domain.ServerSortOpenASC: - q = q.Where("server.open >= ?", cursorOpen) - case domain.ServerSortOpenDESC: - q = q.Where("server.open <= ?", cursorOpen) - default: - return q.Err(errors.New("unsupported sort value")) - } - } - - return q - }) +//nolint:gocyclo +func (a listServersParamsApplier) applyCursor(q *bun.SelectQuery) *bun.SelectQuery { + if a.params.Cursor().IsZero() { + return q } - return q.Limit(a.params.Limit() + 1) + q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { + cursorKey := a.params.Cursor().Key() + cursorOpen := a.params.Cursor().Open() + + sort := a.params.Sort() + sortLen := len(sort) + + // based on https://github.com/prisma/prisma/issues/19159#issuecomment-1713389245 + + switch { + case sortLen == 1: + switch sort[0] { + case domain.ServerSortKeyASC: + q = q.Where("server.key >= ?", cursorKey) + case domain.ServerSortKeyDESC: + q = q.Where("server.key <= ?", cursorKey) + case domain.ServerSortOpenASC, domain.ServerSortOpenDESC: + return q.Err(errSortNoUniqueField) + default: + return q.Err(errUnsupportedSortValue) + } + case sortLen > 1: + q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery { + switch sort[0] { + case domain.ServerSortKeyASC: + q = q.Where("server.key > ?", cursorKey) + case domain.ServerSortKeyDESC: + q = q.Where("server.key < ?", cursorKey) + case domain.ServerSortOpenASC: + q = q.Where("server.open > ?", cursorOpen) + case domain.ServerSortOpenDESC: + q = q.Where("server.open < ?", cursorOpen) + default: + return q.Err(errUnsupportedSortValue) + } + + for i := 1; i < sortLen; i++ { + q.WhereGroup(" OR ", func(q *bun.SelectQuery) *bun.SelectQuery { + current := sort[i] + + for j := 0; j < i; j++ { + s := sort[j] + + switch s { + case domain.ServerSortKeyASC, + domain.ServerSortKeyDESC: + q = q.Where("server.key = ?", cursorKey) + case domain.ServerSortOpenASC, + domain.ServerSortOpenDESC: + q = q.Where("server.open = ?", cursorOpen) + default: + return q.Err(errUnsupportedSortValue) + } + } + + switch current { + case domain.ServerSortKeyASC: + q = q.Where("server.key >= ?", cursorKey) + case domain.ServerSortKeyDESC: + q = q.Where("server.key <= ?", cursorKey) + case domain.ServerSortOpenASC: + q = q.Where("server.open >= ?", cursorOpen) + case domain.ServerSortOpenDESC: + q = q.Where("server.open <= ?", cursorOpen) + default: + return q.Err(errUnsupportedSortValue) + } + + return q + }) + } + + return q + }) + } + + return q + }) + + return q } diff --git a/internal/adapter/repository_bun_tribe.go b/internal/adapter/repository_bun_tribe.go index f6d0f44..1196528 100644 --- a/internal/adapter/repository_bun_tribe.go +++ b/internal/adapter/repository_bun_tribe.go @@ -180,7 +180,7 @@ func (a listTribesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { case domain.TribeSortServerKeyDESC: q = q.Order("tribe.server_key DESC") default: - return q.Err(errors.New("unsupported sort value")) + return q.Err(errUnsupportedSortValue) } } diff --git a/internal/adapter/repository_bun_tribe_change.go b/internal/adapter/repository_bun_tribe_change.go index def68fd..db8c00b 100644 --- a/internal/adapter/repository_bun_tribe_change.go +++ b/internal/adapter/repository_bun_tribe_change.go @@ -90,7 +90,7 @@ func (a listTribeChangesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuer case domain.TribeChangeSortServerKeyDESC: q = q.Order("tc.server_key DESC") default: - return q.Err(errors.New("unsupported sort value")) + return q.Err(errUnsupportedSortValue) } } diff --git a/internal/adapter/repository_bun_tribe_snapshot.go b/internal/adapter/repository_bun_tribe_snapshot.go index 45e9489..d42402d 100644 --- a/internal/adapter/repository_bun_tribe_snapshot.go +++ b/internal/adapter/repository_bun_tribe_snapshot.go @@ -96,7 +96,7 @@ func (a listTribeSnapshotsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQu case domain.TribeSnapshotSortServerKeyDESC: q = q.Order("ts.server_key DESC") default: - return q.Err(errors.New("unsupported sort value")) + return q.Err(errUnsupportedSortValue) } } diff --git a/internal/adapter/repository_bun_version.go b/internal/adapter/repository_bun_version.go index 7a4effd..646c438 100644 --- a/internal/adapter/repository_bun_version.go +++ b/internal/adapter/repository_bun_version.go @@ -67,7 +67,7 @@ func (a listVersionsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { q = q.Order("version.code DESC") default: - return q.Err(errors.New("unsupported sort value")) + return q.Err(errUnsupportedSortValue) } } diff --git a/internal/adapter/repository_bun_village.go b/internal/adapter/repository_bun_village.go index 0ce2b94..0cd0b6c 100644 --- a/internal/adapter/repository_bun_village.go +++ b/internal/adapter/repository_bun_village.go @@ -135,7 +135,7 @@ func (a listVillagesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { case domain.VillageSortServerKeyDESC: q = q.Order("village.server_key DESC") default: - return q.Err(errors.New("unsupported sort value")) + return q.Err(errUnsupportedSortValue) } } diff --git a/internal/adapter/repository_server_test.go b/internal/adapter/repository_server_test.go index 066fcbf..b5924bb 100644 --- a/internal/adapter/repository_server_test.go +++ b/internal/adapter/repository_server_test.go @@ -355,6 +355,40 @@ func testServerRepository(t *testing.T, newRepos func(t *testing.T) repositories require.NoError(t, err) }, }, + { + name: "OK: cursor sort=[key ASC]", + params: func(t *testing.T) domain.ListServersParams { + t.Helper() + + params := domain.NewListServersParams() + require.NoError(t, params.SetSort([]domain.ServerSort{domain.ServerSortKeyASC})) + + res, err := repos.server.List(ctx, params) + require.NoError(t, err) + require.Greater(t, len(res.Servers()), 2) + + require.NoError(t, params.SetCursor(domaintest.NewServerCursor(t, func(cfg *domaintest.ServerCursorConfig) { + cfg.Key = res.Servers()[1].Key() + }))) + + return params + }, + assertResult: func(t *testing.T, params domain.ListServersParams, res domain.ListServersResult) { + t.Helper() + servers := res.Servers() + assert.NotEmpty(t, len(servers)) + assert.True(t, slices.IsSortedFunc(servers, func(a, b domain.Server) int { + return cmp.Compare(a.Key(), b.Key()) + })) + for _, s := range res.Servers() { + assert.GreaterOrEqual(t, s.Key(), params.Cursor().Key(), s.Key()) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + }, { name: "OK: cursor sort=[open ASC, key ASC]", params: func(t *testing.T) domain.ListServersParams { diff --git a/internal/app/service_server.go b/internal/app/service_server.go index 5e18f5e..6e770ae 100644 --- a/internal/app/service_server.go +++ b/internal/app/service_server.go @@ -109,6 +109,9 @@ func (svc *ServerService) ListAll(ctx context.Context, params domain.ListServers if err := params.SetSort([]domain.ServerSort{domain.ServerSortKeyASC}); err != nil { return nil, err } + if err := params.SetCursor(domain.ServerCursor{}); err != nil { + return nil, err + } var servers domain.Servers @@ -130,6 +133,10 @@ func (svc *ServerService) ListAll(ctx context.Context, params domain.ListServers } } +func (svc *ServerService) List(ctx context.Context, params domain.ListServersParams) (domain.ListServersResult, error) { + return svc.repo.List(ctx, params) +} + func (svc *ServerService) SyncConfigAndInfo(ctx context.Context, payload domain.ServerSyncedEventPayload) error { key := payload.Key() u := payload.URL() diff --git a/internal/port/handler_http_api.go b/internal/port/handler_http_api.go index 28aa11c..d0e3090 100644 --- a/internal/port/handler_http_api.go +++ b/internal/port/handler_http_api.go @@ -14,6 +14,7 @@ import ( type apiHTTPHandler struct { versionSvc *app.VersionService + serverSvc *app.ServerService openAPISchema func() (*openapi3.T, error) } @@ -23,11 +24,16 @@ func WithOpenAPIConfig(oapiCfg OpenAPIConfig) APIHTTPHandlerOption { } } -func NewAPIHTTPHandler(versionSvc *app.VersionService, opts ...APIHTTPHandlerOption) http.Handler { +func NewAPIHTTPHandler( + versionSvc *app.VersionService, + serverSvc *app.ServerService, + opts ...APIHTTPHandlerOption, +) http.Handler { cfg := newAPIHTTPHandlerConfig(opts...) h := &apiHTTPHandler{ versionSvc: versionSvc, + serverSvc: serverSvc, openAPISchema: sync.OnceValues(func() (*openapi3.T, error) { return getOpenAPISchema(cfg.openAPI) }), diff --git a/internal/port/handler_http_api_error.go b/internal/port/handler_http_api_error.go index 0297c15..9218b8d 100644 --- a/internal/port/handler_http_api_error.go +++ b/internal/port/handler_http_api_error.go @@ -64,7 +64,7 @@ func (es apiErrors) toResponse() apimodel.ErrorResponse { return resp } -type errorPathElement struct { +type errorPathSegment struct { model string field string index int // index may be <0 and this means that it is unset @@ -75,7 +75,7 @@ type apiErrorRenderer struct { // formatErrorPath allows to override the default path formatter // for domain.ValidationError and domain.SliceElementValidationError. // If formatErrorPath returns an empty slice, an internal server error is rendered. - formatErrorPath func(elems []errorPathElement) []string + formatErrorPath func(segments []errorPathSegment) []string } var errInternalServerError = apiError{ @@ -115,7 +115,7 @@ func (re apiErrorRenderer) render(w http.ResponseWriter, r *http.Request) { func (re apiErrorRenderer) domainErrorToAPIError(domainErr domain.Error) apiError { message := domainErr.Error() - var pathElems []errorPathElement + var pathElems []errorPathSegment var err error = domainErr for err != nil { @@ -124,7 +124,7 @@ func (re apiErrorRenderer) domainErrorToAPIError(domainErr domain.Error) apiErro switch { case errors.As(err, &validationErr): - pathElems = append(pathElems, errorPathElement{ + pathElems = append(pathElems, errorPathSegment{ model: validationErr.Model, field: validationErr.Field, index: -1, @@ -132,7 +132,7 @@ func (re apiErrorRenderer) domainErrorToAPIError(domainErr domain.Error) apiErro err = validationErr.Unwrap() message = err.Error() case errors.As(err, &sliceElementValidationErr): - pathElems = append(pathElems, errorPathElement{ + pathElems = append(pathElems, errorPathSegment{ model: sliceElementValidationErr.Model, field: sliceElementValidationErr.Field, index: sliceElementValidationErr.Index, diff --git a/internal/port/handler_http_api_server.go b/internal/port/handler_http_api_server.go new file mode 100644 index 0000000..1d74e3b --- /dev/null +++ b/internal/port/handler_http_api_server.go @@ -0,0 +1,80 @@ +package port + +import ( + "net/http" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "gitea.dwysokinski.me/twhelp/corev3/internal/port/internal/apimodel" +) + +func (h *apiHTTPHandler) ListServers( + w http.ResponseWriter, + r *http.Request, + versionCode apimodel.VersionCodePathParam, + params apimodel.ListServersParams, +) { + domainParams := domain.NewListServersParams() + + if err := domainParams.SetSort([]domain.ServerSort{domain.ServerSortOpenDESC, domain.ServerSortKeyASC}); err != nil { + apiErrorRenderer{errors: []error{err}, formatErrorPath: formatListServersParamsErrorPath}.render(w, r) + return + } + + if err := domainParams.SetVersionCodes([]string{versionCode}); err != nil { + apiErrorRenderer{errors: []error{err}, formatErrorPath: formatListServersParamsErrorPath}.render(w, r) + return + } + + if params.Open != nil { + if err := domainParams.SetOpen(domain.NullBool{ + Value: *params.Open, + Valid: true, + }); err != nil { + apiErrorRenderer{errors: []error{err}, formatErrorPath: formatListServersParamsErrorPath}.render(w, r) + return + } + } + + if params.Limit != nil { + if err := domainParams.SetLimit(*params.Limit); err != nil { + apiErrorRenderer{errors: []error{err}, formatErrorPath: formatListServersParamsErrorPath}.render(w, r) + return + } + } + + if params.Cursor != nil { + if err := domainParams.SetEncodedCursor(*params.Cursor); err != nil { + apiErrorRenderer{errors: []error{err}, formatErrorPath: formatListServersParamsErrorPath}.render(w, r) + return + } + } + + res, err := h.serverSvc.List(r.Context(), domainParams) + if err != nil { + apiErrorRenderer{errors: []error{err}}.render(w, r) + return + } + + renderJSON(w, r, http.StatusOK, apimodel.NewListServersResponse(res)) +} + +func formatListServersParamsErrorPath(segments []errorPathSegment) []string { + if segments[0].model != "ListServersParams" { + return nil + } + + switch segments[0].field { + case "cursor": + return []string{"$query", "cursor"} + case "limit": + return []string{"$query", "limit"} + case "open": + return []string{"$query", "open"} + case "sort": + return []string{"$query", "sort"} + case "versionCodes": + return []string{"$path", "versionCode"} + default: + return nil + } +} diff --git a/internal/port/handler_http_api_test.go b/internal/port/handler_http_api_test.go index 2de01b2..cfaf355 100644 --- a/internal/port/handler_http_api_test.go +++ b/internal/port/handler_http_api_test.go @@ -25,10 +25,14 @@ func newAPIHTTPHandler(tb testing.TB, opts ...func(cfg *apiHTTPHandlerConfig)) h opt(cfg) } - versionRepo := adapter.NewVersionBunRepository(buntest.NewSQLiteDB(tb)) + db := buntest.NewSQLiteDB(tb) + + versionRepo := adapter.NewVersionBunRepository(db) + serverRepo := adapter.NewServerBunRepository(db) return port.NewAPIHTTPHandler( app.NewVersionService(versionRepo), + app.NewServerService(serverRepo, nil, nil), cfg.options..., ) } diff --git a/internal/port/handler_http_api_version.go b/internal/port/handler_http_api_version.go index 37dedbe..af540bf 100644 --- a/internal/port/handler_http_api_version.go +++ b/internal/port/handler_http_api_version.go @@ -43,12 +43,12 @@ func (h *apiHTTPHandler) GetVersion(w http.ResponseWriter, r *http.Request, vers renderJSON(w, r, http.StatusOK, apimodel.NewGetVersionResponse(version)) } -func formatListVersionsParamsErrorPath(elems []errorPathElement) []string { - if elems[0].model != "ListVersionsParams" { +func formatListVersionsParamsErrorPath(segments []errorPathSegment) []string { + if segments[0].model != "ListVersionsParams" { return nil } - switch elems[0].field { + switch segments[0].field { case "cursor": return []string{"$query", "cursor"} case "limit": diff --git a/internal/port/internal/apimodel/server.go b/internal/port/internal/apimodel/server.go new file mode 100644 index 0000000..8c0d701 --- /dev/null +++ b/internal/port/internal/apimodel/server.go @@ -0,0 +1,56 @@ +package apimodel + +import ( + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" +) + +func NewServer(s domain.Server) Server { + converted := Server{ + Key: s.Key(), + CreatedAt: s.CreatedAt(), + NumBarbarianVillages: s.NumBarbarianVillages(), + NumBonusVillages: s.NumBonusVillages(), + NumPlayerVillages: s.NumPlayerVillages(), + NumPlayers: s.NumPlayers(), + NumTribes: s.NumTribes(), + NumVillages: s.NumVillages(), + Open: s.Open(), + Url: s.URL().String(), + } + + if v := s.PlayerDataSyncedAt(); !v.IsZero() { + converted.PlayerDataSyncedAt = &v + } + + if v := s.TribeDataSyncedAt(); !v.IsZero() { + converted.TribeDataSyncedAt = &v + } + + if v := s.VillageDataSyncedAt(); !v.IsZero() { + converted.VillageDataSyncedAt = &v + } + + if v := s.EnnoblementDataSyncedAt(); !v.IsZero() { + converted.EnnoblementDataSyncedAt = &v + } + + return converted +} + +func NewListServersResponse(res domain.ListServersResult) ListServersResponse { + servers := res.Servers() + + resp := ListServersResponse{ + Data: make([]Server, 0, len(servers)), + Cursor: Cursor{ + Next: res.Next().Encode(), + Self: res.Self().Encode(), + }, + } + + for _, s := range servers { + resp.Data = append(resp.Data, NewServer(s)) + } + + return resp +}