feat: api - add a new endpoint - GET /api/v2/versions/{versionCode}/servers/{serverKey}/villages (#17)
ci/woodpecker/push/govulncheck Pipeline was successful Details
ci/woodpecker/push/test Pipeline was successful Details

Reviewed-on: twhelp/corev3#17
This commit is contained in:
Dawid Wysokiński 2024-03-05 06:42:37 +00:00
parent 847cf220da
commit 34a5385224
8 changed files with 620 additions and 1 deletions

View File

@ -14,6 +14,7 @@ tags:
- name: servers - name: servers
- name: tribes - name: tribes
- name: players - name: players
- name: villages
servers: servers:
- url: "{scheme}://{hostname}/api" - url: "{scheme}://{hostname}/api"
variables: variables:
@ -226,7 +227,24 @@ paths:
$ref: "#/components/responses/ListPlayersResponse" $ref: "#/components/responses/ListPlayersResponse"
default: default:
$ref: "#/components/responses/ErrorResponse" $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: components:
schemas: schemas:
Error: Error:
@ -1079,6 +1097,58 @@ components:
deletedAt: deletedAt:
type: string type: string
format: date-time 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: Cursor:
type: object type: object
x-go-type-skip-optional-pointer: true x-go-type-skip-optional-pointer: true
@ -1364,6 +1434,21 @@ components:
properties: properties:
data: data:
$ref: "#/components/schemas/Player" $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: ErrorResponse:
description: Default error response. description: Default error response.
content: content:

View File

@ -108,12 +108,14 @@ var cmdServe = &cli.Command{
serverRepo := adapter.NewServerBunRepository(bunDB) serverRepo := adapter.NewServerBunRepository(bunDB)
tribeRepo := adapter.NewTribeBunRepository(bunDB) tribeRepo := adapter.NewTribeBunRepository(bunDB)
playerRepo := adapter.NewPlayerBunRepository(bunDB) playerRepo := adapter.NewPlayerBunRepository(bunDB)
villageRepo := adapter.NewVillageBunRepository(bunDB)
// services // services
versionSvc := app.NewVersionService(versionRepo) versionSvc := app.NewVersionService(versionRepo)
serverSvc := app.NewServerService(serverRepo, nil, nil) serverSvc := app.NewServerService(serverRepo, nil, nil)
tribeSvc := app.NewTribeService(tribeRepo, nil, nil) tribeSvc := app.NewTribeService(tribeRepo, nil, nil)
playerSvc := app.NewPlayerService(playerRepo, nil, nil, nil) playerSvc := app.NewPlayerService(playerRepo, nil, nil, nil)
villageSvc := app.NewVillageService(villageRepo, nil, nil)
// health // health
h := health.New() h := health.New()
@ -141,6 +143,7 @@ var cmdServe = &cli.Command{
serverSvc, serverSvc,
tribeSvc, tribeSvc,
playerSvc, playerSvc,
villageSvc,
port.WithOpenAPIConfig(oapiCfg), port.WithOpenAPIConfig(oapiCfg),
)) ))

View File

@ -101,6 +101,10 @@ func (v Village) Name() string {
return v.name 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 { func (v Village) Points() int {
return v.points return v.points
} }

View File

@ -17,6 +17,7 @@ type apiHTTPHandler struct {
serverSvc *app.ServerService serverSvc *app.ServerService
tribeSvc *app.TribeService tribeSvc *app.TribeService
playerSvc *app.PlayerService playerSvc *app.PlayerService
villageSvc *app.VillageService
errorRenderer apiErrorRenderer errorRenderer apiErrorRenderer
openAPISchema func() (*openapi3.T, error) openAPISchema func() (*openapi3.T, error)
} }
@ -32,6 +33,7 @@ func NewAPIHTTPHandler(
serverSvc *app.ServerService, serverSvc *app.ServerService,
tribeSvc *app.TribeService, tribeSvc *app.TribeService,
playerSvc *app.PlayerService, playerSvc *app.PlayerService,
villageSvc *app.VillageService,
opts ...APIHTTPHandlerOption, opts ...APIHTTPHandlerOption,
) http.Handler { ) http.Handler {
cfg := newAPIHTTPHandlerConfig(opts...) cfg := newAPIHTTPHandlerConfig(opts...)
@ -41,6 +43,7 @@ func NewAPIHTTPHandler(
serverSvc: serverSvc, serverSvc: serverSvc,
tribeSvc: tribeSvc, tribeSvc: tribeSvc,
playerSvc: playerSvc, playerSvc: playerSvc,
villageSvc: villageSvc,
openAPISchema: sync.OnceValues(func() (*openapi3.T, error) { openAPISchema: sync.OnceValues(func() (*openapi3.T, error) {
return getOpenAPISchema(cfg.openAPI) return getOpenAPISchema(cfg.openAPI)
}), }),

View File

@ -36,12 +36,14 @@ func newAPIHTTPHandler(tb testing.TB, opts ...func(cfg *apiHTTPHandlerConfig)) h
serverRepo := adapter.NewServerBunRepository(bunDB) serverRepo := adapter.NewServerBunRepository(bunDB)
tribeRepo := adapter.NewTribeBunRepository(bunDB) tribeRepo := adapter.NewTribeBunRepository(bunDB)
playerRepo := adapter.NewPlayerBunRepository(bunDB) playerRepo := adapter.NewPlayerBunRepository(bunDB)
villageRepo := adapter.NewVillageBunRepository(bunDB)
return port.NewAPIHTTPHandler( return port.NewAPIHTTPHandler(
app.NewVersionService(versionRepo), app.NewVersionService(versionRepo),
app.NewServerService(serverRepo, nil, nil), app.NewServerService(serverRepo, nil, nil),
app.NewTribeService(tribeRepo, nil, nil), app.NewTribeService(tribeRepo, nil, nil),
app.NewPlayerService(playerRepo, nil, nil, nil), app.NewPlayerService(playerRepo, nil, nil, nil),
app.NewVillageService(villageRepo, nil, nil),
cfg.options..., cfg.options...,
) )
} }

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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
}