feat: api - add a new endpoint - GET /api/v2/versions/{versionCode}/servers (#59)

Reviewed-on: twhelp/corev3#59
This commit is contained in:
Dawid Wysokiński 2024-02-09 11:30:01 +00:00
parent 8087f5ba7b
commit 79337cd60a
20 changed files with 393 additions and 41 deletions

View File

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

View File

@ -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),
))

View File

@ -0,0 +1,8 @@
package adapter
import "errors"
var (
errUnsupportedSortValue = errors.New("unsupported sort value")
errSortNoUniqueField = errors.New("no unique field used for sorting")
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()

View File

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

View File

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

View File

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

View File

@ -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...,
)
}

View File

@ -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":

View File

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