diff --git a/api/openapi3.yml b/api/openapi3.yml index 3bcaa34..b97a629 100644 --- a/api/openapi3.yml +++ b/api/openapi3.yml @@ -14,6 +14,7 @@ tags: - name: servers - name: tribes - name: players + - name: villages servers: - url: "{scheme}://{hostname}/api" variables: @@ -226,7 +227,24 @@ paths: $ref: "#/components/responses/ListPlayersResponse" default: $ref: "#/components/responses/ErrorResponse" - + /v2/versions/{versionCode}/servers/{serverKey}/villages: + get: + operationId: listVillages + tags: + - versions + - servers + - villages + description: List villages + parameters: + - $ref: "#/components/parameters/VersionCodePathParam" + - $ref: "#/components/parameters/ServerKeyPathParam" + - $ref: "#/components/parameters/CursorQueryParam" + - $ref: "#/components/parameters/LimitQueryParam" + responses: + 200: + $ref: "#/components/responses/ListVillagesResponse" + default: + $ref: "#/components/responses/ErrorResponse" components: schemas: Error: @@ -1079,6 +1097,58 @@ components: deletedAt: type: string format: date-time + Village: + type: object + required: + - id + - name + - fullName + - x + - y + - points + - profileUrl + - continent + - bonus + - createdAt + properties: + id: + $ref: "#/components/schemas/IntId" + name: + type: string + example: Village + fullName: + type: string + example: Village (450|450) K44 + x: + type: integer + example: 450 + y: + type: integer + example: 450 + points: + type: integer + profileUrl: + type: string + format: uri + continent: + type: string + example: K44 + bonus: + type: integer + description: | + Some of the bonuses: + 1 - 100% higher wood production + 2 - 100% higher clay production + 3 - 100% higher iron production + 4 - 10% more population + 5 - 33% faster recruitment in the Barracks + 6 - 33% faster recruitment in the Stable + 7 - 50% faster recruitment in the Workshop + 8 - 30% more resources are produced (all resource types) + 9 - 50% more storage capacity and merchants + createdAt: + type: string + format: date-time Cursor: type: object x-go-type-skip-optional-pointer: true @@ -1364,6 +1434,21 @@ components: properties: data: $ref: "#/components/schemas/Player" + ListVillagesResponse: + description: "" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/PaginationResponse" + - type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/Village" ErrorResponse: description: Default error response. content: diff --git a/cmd/twhelp/cmd_serve.go b/cmd/twhelp/cmd_serve.go index ac4fdc2..39592d7 100644 --- a/cmd/twhelp/cmd_serve.go +++ b/cmd/twhelp/cmd_serve.go @@ -108,12 +108,14 @@ var cmdServe = &cli.Command{ serverRepo := adapter.NewServerBunRepository(bunDB) tribeRepo := adapter.NewTribeBunRepository(bunDB) playerRepo := adapter.NewPlayerBunRepository(bunDB) + villageRepo := adapter.NewVillageBunRepository(bunDB) // services versionSvc := app.NewVersionService(versionRepo) serverSvc := app.NewServerService(serverRepo, nil, nil) tribeSvc := app.NewTribeService(tribeRepo, nil, nil) playerSvc := app.NewPlayerService(playerRepo, nil, nil, nil) + villageSvc := app.NewVillageService(villageRepo, nil, nil) // health h := health.New() @@ -141,6 +143,7 @@ var cmdServe = &cli.Command{ serverSvc, tribeSvc, playerSvc, + villageSvc, port.WithOpenAPIConfig(oapiCfg), )) diff --git a/internal/domain/village.go b/internal/domain/village.go index 05fa168..ae41e1c 100644 --- a/internal/domain/village.go +++ b/internal/domain/village.go @@ -101,6 +101,10 @@ func (v Village) Name() string { return v.name } +func (v Village) FullName() string { + return fmt.Sprintf("%s (%d|%d) %s", v.name, v.x, v.y, v.continent) +} + func (v Village) Points() int { return v.points } diff --git a/internal/port/handler_http_api.go b/internal/port/handler_http_api.go index 27c48c7..cfa98d1 100644 --- a/internal/port/handler_http_api.go +++ b/internal/port/handler_http_api.go @@ -17,6 +17,7 @@ type apiHTTPHandler struct { serverSvc *app.ServerService tribeSvc *app.TribeService playerSvc *app.PlayerService + villageSvc *app.VillageService errorRenderer apiErrorRenderer openAPISchema func() (*openapi3.T, error) } @@ -32,6 +33,7 @@ func NewAPIHTTPHandler( serverSvc *app.ServerService, tribeSvc *app.TribeService, playerSvc *app.PlayerService, + villageSvc *app.VillageService, opts ...APIHTTPHandlerOption, ) http.Handler { cfg := newAPIHTTPHandlerConfig(opts...) @@ -41,6 +43,7 @@ func NewAPIHTTPHandler( serverSvc: serverSvc, tribeSvc: tribeSvc, playerSvc: playerSvc, + villageSvc: villageSvc, openAPISchema: sync.OnceValues(func() (*openapi3.T, error) { return getOpenAPISchema(cfg.openAPI) }), diff --git a/internal/port/handler_http_api_test.go b/internal/port/handler_http_api_test.go index c2e5c6d..26f2044 100644 --- a/internal/port/handler_http_api_test.go +++ b/internal/port/handler_http_api_test.go @@ -36,12 +36,14 @@ func newAPIHTTPHandler(tb testing.TB, opts ...func(cfg *apiHTTPHandlerConfig)) h serverRepo := adapter.NewServerBunRepository(bunDB) tribeRepo := adapter.NewTribeBunRepository(bunDB) playerRepo := adapter.NewPlayerBunRepository(bunDB) + villageRepo := adapter.NewVillageBunRepository(bunDB) return port.NewAPIHTTPHandler( app.NewVersionService(versionRepo), app.NewServerService(serverRepo, nil, nil), app.NewTribeService(tribeRepo, nil, nil), app.NewPlayerService(playerRepo, nil, nil, nil), + app.NewVillageService(villageRepo, nil, nil), cfg.options..., ) } diff --git a/internal/port/handler_http_api_village.go b/internal/port/handler_http_api_village.go new file mode 100644 index 0000000..b5af161 --- /dev/null +++ b/internal/port/handler_http_api_village.go @@ -0,0 +1,65 @@ +package port + +import ( + "net/http" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "gitea.dwysokinski.me/twhelp/corev3/internal/port/internal/apimodel" +) + +func (h *apiHTTPHandler) ListVillages( + w http.ResponseWriter, + r *http.Request, + _ apimodel.VersionCodePathParam, + serverKey apimodel.ServerKeyPathParam, + params apimodel.ListVillagesParams, +) { + domainParams := domain.NewListVillagesParams() + + if err := domainParams.SetSort([]domain.VillageSort{domain.VillageSortIDASC}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListVillagesErrorPath).render(w, r, err) + return + } + + if err := domainParams.SetServerKeys([]string{serverKey}); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListVillagesErrorPath).render(w, r, err) + return + } + + if params.Limit != nil { + if err := domainParams.SetLimit(*params.Limit); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListVillagesErrorPath).render(w, r, err) + return + } + } + + if params.Cursor != nil { + if err := domainParams.SetEncodedCursor(*params.Cursor); err != nil { + h.errorRenderer.withErrorPathFormatter(formatListVillagesErrorPath).render(w, r, err) + return + } + } + + res, err := h.villageSvc.List(r.Context(), domainParams) + if err != nil { + h.errorRenderer.render(w, r, err) + return + } + + renderJSON(w, r, http.StatusOK, apimodel.NewListVillagesResponse(res)) +} + +func formatListVillagesErrorPath(segments []domain.ErrorPathSegment) []string { + if segments[0].Model != "ListVillagesParams" { + return nil + } + + switch segments[0].Field { + case "cursor": + return []string{"$query", "cursor"} + case "limit": + return []string{"$query", "limit"} + default: + return nil + } +} diff --git a/internal/port/handler_http_api_village_test.go b/internal/port/handler_http_api_village_test.go new file mode 100644 index 0000000..59d99f0 --- /dev/null +++ b/internal/port/handler_http_api_village_test.go @@ -0,0 +1,419 @@ +package port_test + +import ( + "cmp" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "strings" + "testing" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "gitea.dwysokinski.me/twhelp/corev3/internal/domain/domaintest" + "gitea.dwysokinski.me/twhelp/corev3/internal/port/internal/apimodel" + "github.com/brianvoe/gofakeit/v7" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const endpointListVillages = "/v2/versions/%s/servers/%s/villages" + +func TestListVillages(t *testing.T) { + t.Parallel() + + handler := newAPIHTTPHandler(t) + villages := getAllVillages(t, handler) + server := villages[0].Server + + tests := []struct { + name string + reqModifier func(t *testing.T, req *http.Request) + assertResp func(t *testing.T, req *http.Request, resp *http.Response) + }{ + { + name: "OK: without params", + assertResp: func(t *testing.T, _ *http.Request, resp *http.Response) { + t.Helper() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // body + body := decodeJSON[apimodel.ListVillagesResponse](t, resp.Body) + assert.Zero(t, body.Cursor.Next) + assert.NotZero(t, body.Cursor.Self) + assert.NotZero(t, body.Data) + assert.True(t, slices.IsSortedFunc(body.Data, func(a, b apimodel.Village) int { + return cmp.Compare(a.Id, b.Id) + })) + }, + }, + { + name: "OK: limit=1", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("limit", "1") + req.URL.RawQuery = q.Encode() + }, + assertResp: func(t *testing.T, req *http.Request, resp *http.Response) { + t.Helper() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // body + body := decodeJSON[apimodel.ListVillagesResponse](t, resp.Body) + assert.NotZero(t, body.Cursor.Next) + assert.NotZero(t, body.Cursor.Self) + assert.NotZero(t, body.Data) + limit, err := strconv.Atoi(req.URL.Query().Get("limit")) + require.NoError(t, err) + assert.Len(t, body.Data, limit) + }, + }, + { + name: "OK: limit=1 cursor", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + + q := req.URL.Query() + q.Set("limit", "1") + req.URL.RawQuery = q.Encode() + + resp := doCustomRequest(handler, req.Clone(req.Context())) + defer resp.Body.Close() + body := decodeJSON[apimodel.ListVillagesResponse](t, resp.Body) + require.NotEmpty(t, body.Cursor.Next) + + q.Set("cursor", body.Cursor.Next) + req.URL.RawQuery = q.Encode() + }, + assertResp: func(t *testing.T, req *http.Request, resp *http.Response) { + t.Helper() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // body + body := decodeJSON[apimodel.ListVillagesResponse](t, resp.Body) + assert.Equal(t, req.URL.Query().Get("cursor"), body.Cursor.Self) + limit, err := strconv.Atoi(req.URL.Query().Get("limit")) + require.NoError(t, err) + assert.Len(t, body.Data, limit) + }, + }, + { + name: "ERR: limit is not a string", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("limit", "asd") + req.URL.RawQuery = q.Encode() + }, + assertResp: func(t *testing.T, req *http.Request, resp *http.Response) { + t.Helper() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + // body + body := decodeJSON[apimodel.ErrorResponse](t, resp.Body) + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: "invalid-param-format", + Message: fmt.Sprintf( + "error binding string parameter: strconv.ParseInt: parsing \"%s\": invalid syntax", + req.URL.Query().Get("limit"), + ), + Path: []string{"$query", "limit"}, + }, + }, + }, body) + }, + }, + { + name: "ERR: limit < 1", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("limit", "0") + req.URL.RawQuery = q.Encode() + }, + assertResp: func(t *testing.T, req *http.Request, resp *http.Response) { + t.Helper() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + // body + body := decodeJSON[apimodel.ErrorResponse](t, resp.Body) + limit, err := strconv.Atoi(req.URL.Query().Get("limit")) + require.NoError(t, err) + domainErr := domain.MinGreaterEqualError{ + Min: 1, + Current: limit, + } + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "current": float64(domainErr.Current), + "min": float64(domainErr.Min), + }, + Path: []string{"$query", "limit"}, + }, + }, + }, body) + }, + }, + { + name: "ERR: limit > 500", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("limit", "501") + req.URL.RawQuery = q.Encode() + }, + assertResp: func(t *testing.T, req *http.Request, resp *http.Response) { + t.Helper() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + // body + body := decodeJSON[apimodel.ErrorResponse](t, resp.Body) + limit, err := strconv.Atoi(req.URL.Query().Get("limit")) + require.NoError(t, err) + domainErr := domain.MaxLessEqualError{ + Max: 500, + Current: limit, + } + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "current": float64(domainErr.Current), + "max": float64(domainErr.Max), + }, + Path: []string{"$query", "limit"}, + }, + }, + }, body) + }, + }, + { + name: "ERR: len(cursor) < 1", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("cursor", "") + req.URL.RawQuery = q.Encode() + }, + assertResp: func(t *testing.T, req *http.Request, resp *http.Response) { + t.Helper() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + // body + body := decodeJSON[apimodel.ErrorResponse](t, resp.Body) + domainErr := domain.LenOutOfRangeError{ + Min: 1, + Max: 1000, + Current: len(req.URL.Query().Get("cursor")), + } + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "current": float64(domainErr.Current), + "max": float64(domainErr.Max), + "min": float64(domainErr.Min), + }, + Path: []string{"$query", "cursor"}, + }, + }, + }, body) + }, + }, + { + name: "ERR: len(cursor) > 1000", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("cursor", gofakeit.LetterN(1001)) + req.URL.RawQuery = q.Encode() + }, + assertResp: func(t *testing.T, req *http.Request, resp *http.Response) { + t.Helper() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + // body + body := decodeJSON[apimodel.ErrorResponse](t, resp.Body) + domainErr := domain.LenOutOfRangeError{ + Min: 1, + Max: 1000, + Current: len(req.URL.Query().Get("cursor")), + } + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "current": float64(domainErr.Current), + "max": float64(domainErr.Max), + "min": float64(domainErr.Min), + }, + Path: []string{"$query", "cursor"}, + }, + }, + }, body) + }, + }, + { + name: "ERR: invalid cursor", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + q := req.URL.Query() + q.Set("cursor", gofakeit.LetterN(100)) + req.URL.RawQuery = q.Encode() + }, + assertResp: func(t *testing.T, _ *http.Request, resp *http.Response) { + t.Helper() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + // body + body := decodeJSON[apimodel.ErrorResponse](t, resp.Body) + var domainErr domain.Error + require.ErrorAs(t, domain.ErrInvalidCursor, &domainErr) + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: domainErr.Code(), + Message: domainErr.Error(), + Path: []string{"$query", "cursor"}, + }, + }, + }, body) + }, + }, + { + name: "ERR: version not found", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + req.URL.Path = fmt.Sprintf(endpointListVillages, domaintest.RandVersionCode(), server.Key) + }, + assertResp: func(t *testing.T, req *http.Request, resp *http.Response) { + t.Helper() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + // body + body := decodeJSON[apimodel.ErrorResponse](t, resp.Body) + pathSegments := strings.Split(req.URL.Path, "/") + require.Len(t, pathSegments, 7) + domainErr := domain.VersionNotFoundError{ + VersionCode: pathSegments[3], + } + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "code": domainErr.VersionCode, + }, + }, + }, + }, body) + }, + }, + { + name: "ERR: server not found", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + req.URL.Path = fmt.Sprintf(endpointListVillages, server.Version.Code, domaintest.RandServerKey()) + }, + assertResp: func(t *testing.T, req *http.Request, resp *http.Response) { + t.Helper() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + // body + body := decodeJSON[apimodel.ErrorResponse](t, resp.Body) + pathSegments := strings.Split(req.URL.Path, "/") + require.Len(t, pathSegments, 7) + domainErr := domain.ServerNotFoundError{ + Key: pathSegments[5], + } + assert.Equal(t, apimodel.ErrorResponse{ + Errors: []apimodel.Error{ + { + Code: domainErr.Code(), + Message: domainErr.Error(), + Params: map[string]any{ + "key": domainErr.Key, + }, + }, + }, + }, body) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest( + http.MethodGet, + fmt.Sprintf(endpointListVillages, server.Version.Code, server.Key), + nil, + ) + if tt.reqModifier != nil { + tt.reqModifier(t, req) + } + + resp := doCustomRequest(handler, req) + defer resp.Body.Close() + tt.assertResp(t, req, resp) + }) + } +} + +type villageWithServer struct { + apimodel.Village + Server serverWithVersion +} + +func getAllVillages(tb testing.TB, h http.Handler) []villageWithServer { + tb.Helper() + + servers := getAllServers(tb, h) + + var villages []villageWithServer + + for _, s := range servers { + resp := doRequest(h, http.MethodGet, fmt.Sprintf(endpointListVillages, s.Version.Code, s.Key), nil) + require.Equal(tb, http.StatusOK, resp.StatusCode) + + for _, v := range decodeJSON[apimodel.ListVillagesResponse](tb, resp.Body).Data { + villages = append(villages, villageWithServer{ + Village: v, + Server: s, + }) + } + + _ = resp.Body.Close() + } + + require.NotZero(tb, villages) + + return villages +} diff --git a/internal/port/internal/apimodel/village.go b/internal/port/internal/apimodel/village.go new file mode 100644 index 0000000..bd3884f --- /dev/null +++ b/internal/port/internal/apimodel/village.go @@ -0,0 +1,38 @@ +package apimodel + +import ( + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" +) + +func NewVillage(v domain.Village) Village { + return Village{ + Bonus: v.Bonus(), + Continent: v.Continent(), + CreatedAt: v.CreatedAt(), + FullName: v.FullName(), + Id: v.ID(), + Name: v.Name(), + Points: v.Points(), + ProfileUrl: v.ProfileURL().String(), + X: v.X(), + Y: v.Y(), + } +} + +func NewListVillagesResponse(res domain.ListVillagesResult) ListVillagesResponse { + versions := res.Villages() + + resp := ListVillagesResponse{ + Data: make([]Village, 0, len(versions)), + Cursor: Cursor{ + Next: res.Next().Encode(), + Self: res.Self().Encode(), + }, + } + + for _, v := range versions { + resp.Data = append(resp.Data, NewVillage(v)) + } + + return resp +}