feat: new API endpoint - GET /api/v2/versions/{versionCode}/servers/{serverKey}/village/{villageId}
ci/woodpecker/push/govulncheck Pipeline was successful Details
ci/woodpecker/push/test Pipeline was successful Details

This commit is contained in:
Dawid Wysokiński 2024-03-07 06:52:03 +01:00
parent b0f1a3d1bd
commit eb2c19cc8a
Signed by: Kichiyaki
GPG Key ID: B5445E357FB8B892
7 changed files with 362 additions and 0 deletions

View File

@ -246,6 +246,24 @@ paths:
$ref: "#/components/responses/ListVillagesResponse"
default:
$ref: "#/components/responses/ErrorResponse"
/v2/versions/{versionCode}/servers/{serverKey}/villages/{villageId}:
get:
operationId: getVillage
tags:
- versions
- servers
- villages
description: Get a village
parameters:
- $ref: "#/components/parameters/VersionCodePathParam"
- $ref: "#/components/parameters/ServerKeyPathParam"
- $ref: "#/components/parameters/VillageIdPathParam"
responses:
200:
$ref: "#/components/responses/GetVillageResponse"
default:
$ref: "#/components/responses/ErrorResponse"
components:
schemas:
Error:
@ -1324,6 +1342,12 @@ components:
required: true
schema:
$ref: "#/components/schemas/IntId"
VillageIdPathParam:
in: path
name: villageId
required: true
schema:
$ref: "#/components/schemas/IntId"
responses:
ListVersionsResponse:
description: ""
@ -1477,6 +1501,17 @@ components:
type: array
items:
$ref: "#/components/schemas/Village"
GetVillageResponse:
description: ""
content:
application/json:
schema:
type: object
required:
- data
properties:
data:
$ref: "#/components/schemas/Village"
ErrorResponse:
description: Default error response.
content:

View File

@ -130,3 +130,32 @@ func (svc *VillageService) ListWithRelations(
) (domain.ListVillagesWithRelationsResult, error) {
return svc.repo.ListWithRelations(ctx, params)
}
func (svc *VillageService) GetWithRelations(
ctx context.Context,
id int,
serverKey string,
) (domain.VillageWithRelations, error) {
params := domain.NewListVillagesParams()
if err := params.SetIDs([]int{id}); err != nil {
return domain.VillageWithRelations{}, err
}
if err := params.SetServerKeys([]string{serverKey}); err != nil {
return domain.VillageWithRelations{}, err
}
res, err := svc.repo.ListWithRelations(ctx, params)
if err != nil {
return domain.VillageWithRelations{}, err
}
villages := res.Villages()
if len(villages) == 0 {
return domain.VillageWithRelations{}, domain.VillageNotFoundError{
ID: id,
ServerKey: serverKey,
}
}
return villages[0], nil
}

View File

@ -662,3 +662,29 @@ func (res ListVillagesWithRelationsResult) Self() VillageCursor {
func (res ListVillagesWithRelationsResult) Next() VillageCursor {
return res.next
}
type VillageNotFoundError struct {
ID int
ServerKey string
}
var _ ErrorWithParams = VillageNotFoundError{}
func (e VillageNotFoundError) Error() string {
return fmt.Sprintf("village with id %d and server key %s not found", e.ID, e.ServerKey)
}
func (e VillageNotFoundError) Type() ErrorType {
return ErrorTypeNotFound
}
func (e VillageNotFoundError) Code() string {
return "village-not-found"
}
func (e VillageNotFoundError) Params() map[string]any {
return map[string]any{
"ID": e.ID,
"ServerKey": e.ServerKey,
}
}

View File

@ -79,6 +79,7 @@ func NewAPIHTTPHandler(
h.serverMiddleware,
h.tribeMiddleware,
h.playerMiddleware,
h.villageMiddleware,
},
ErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) {
h.errorRenderer.render(w, r, err)

View File

@ -1,11 +1,14 @@
package port
import (
"context"
"net/http"
"slices"
"strconv"
"gitea.dwysokinski.me/twhelp/corev3/internal/domain"
"gitea.dwysokinski.me/twhelp/corev3/internal/port/internal/apimodel"
"github.com/go-chi/chi/v5"
)
func (h *apiHTTPHandler) ListVillages(
@ -57,6 +60,59 @@ func (h *apiHTTPHandler) ListVillages(
renderJSON(w, r, http.StatusOK, apimodel.NewListVillagesResponse(res))
}
func (h *apiHTTPHandler) GetVillage(
w http.ResponseWriter,
r *http.Request,
_ apimodel.VersionCodePathParam,
_ apimodel.ServerKeyPathParam,
_ apimodel.VillageIdPathParam,
) {
village, _ := villageFromContext(r.Context())
renderJSON(w, r, http.StatusOK, apimodel.NewGetVillageResponse(village))
}
func (h *apiHTTPHandler) villageMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
routeCtx := chi.RouteContext(ctx)
server, serverOK := serverFromContext(ctx)
villageIDIdx := slices.Index(routeCtx.URLParams.Keys, "villageId")
if !serverOK || villageIDIdx < 0 {
next.ServeHTTP(w, r)
return
}
villageID, err := strconv.Atoi(routeCtx.URLParams.Values[villageIDIdx])
if err != nil {
return
}
village, err := h.villageSvc.GetWithRelations(
ctx,
villageID,
server.Key(),
)
if err != nil {
h.errorRenderer.withErrorPathFormatter(formatGetVillageErrorPath).render(w, r, err)
return
}
next.ServeHTTP(w, r.WithContext(villageToContext(ctx, village)))
})
}
type villageCtxKey struct{}
func villageToContext(ctx context.Context, v domain.VillageWithRelations) context.Context {
return context.WithValue(ctx, villageCtxKey{}, v)
}
func villageFromContext(ctx context.Context) (domain.VillageWithRelations, bool) {
v, ok := ctx.Value(villageCtxKey{}).(domain.VillageWithRelations)
return v, ok
}
func formatListVillagesErrorPath(segments []domain.ErrorPathSegment) []string {
if segments[0].Model != "ListVillagesParams" {
return nil
@ -77,3 +133,16 @@ func formatListVillagesErrorPath(segments []domain.ErrorPathSegment) []string {
return nil
}
}
func formatGetVillageErrorPath(segments []domain.ErrorPathSegment) []string {
if segments[0].Model != "ListVillagesParams" {
return nil
}
switch segments[0].Field {
case "ids":
return []string{"$path", "villageId"}
default:
return nil
}
}

View File

@ -490,6 +490,196 @@ func TestListVillages(t *testing.T) {
}
}
const endpointGetVillage = "/v2/versions/%s/servers/%s/villages/%d"
func TestGetVillage(t *testing.T) {
t.Parallel()
handler := newAPIHTTPHandler(t)
village := randVillage(t, handler)
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",
assertResp: func(t *testing.T, _ *http.Request, resp *http.Response) {
t.Helper()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// body
body := decodeJSON[apimodel.GetVillageResponse](t, resp.Body)
assert.Equal(t, village.Village, body.Data)
},
},
{
name: "ERR: id < 1",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
req.URL.Path = fmt.Sprintf(
endpointGetVillage,
village.Server.Version.Code,
village.Server.Key,
domaintest.RandID()*-1,
)
},
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)
pathSegments := strings.Split(req.URL.Path, "/")
require.Len(t, pathSegments, 8)
id, err := strconv.Atoi(pathSegments[7])
require.NoError(t, err)
domainErr := domain.MinGreaterEqualError{
Min: 1,
Current: id,
}
assert.Equal(t, apimodel.ErrorResponse{
Errors: []apimodel.Error{
{
Code: domainErr.Code(),
Message: domainErr.Error(),
Params: map[string]any{
"min": float64(domainErr.Min),
"current": float64(domainErr.Current),
},
Path: []string{"$path", "villageId"},
},
},
}, body)
},
},
{
name: "ERR: version not found",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
req.URL.Path = fmt.Sprintf(endpointGetVillage, domaintest.RandVersionCode(), village.Server.Key, village.Id)
},
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, 8)
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(endpointGetVillage, village.Server.Version.Code, domaintest.RandServerKey(), village.Id)
},
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, 8)
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)
},
},
{
name: "ERR: village not found",
reqModifier: func(t *testing.T, req *http.Request) {
t.Helper()
req.URL.Path = fmt.Sprintf(
endpointGetVillage,
village.Server.Version.Code,
village.Server.Key,
domaintest.RandID(),
)
},
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, 8)
id, err := strconv.Atoi(pathSegments[7])
require.NoError(t, err)
domainErr := domain.VillageNotFoundError{
ID: id,
ServerKey: pathSegments[5],
}
assert.Equal(t, apimodel.ErrorResponse{
Errors: []apimodel.Error{
{
Code: domainErr.Code(),
Message: domainErr.Error(),
Params: map[string]any{
"id": float64(domainErr.ID),
"serverKey": domainErr.ServerKey,
},
},
},
}, body)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(
http.MethodGet,
fmt.Sprintf(endpointGetVillage, village.Server.Version.Code, village.Server.Key, village.Id),
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
@ -520,3 +710,9 @@ func getAllVillages(tb testing.TB, h http.Handler) []villageWithServer {
return villages
}
func randVillage(tb testing.TB, h http.Handler) villageWithServer {
tb.Helper()
villages := getAllVillages(tb, h)
return villages[gofakeit.IntRange(0, len(villages)-1)]
}

View File

@ -38,3 +38,9 @@ func NewListVillagesResponse(res domain.ListVillagesWithRelationsResult) ListVil
return resp
}
func NewGetVillageResponse(v domain.VillageWithRelations) GetVillageResponse {
return GetVillageResponse{
Data: NewVillage(v),
}
}