diff --git a/api/openapi3.yml b/api/openapi3.yml index 435e855..e15dcd2 100644 --- a/api/openapi3.yml +++ b/api/openapi3.yml @@ -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: diff --git a/internal/app/service_village.go b/internal/app/service_village.go index 7fedfa7..c6e0f75 100644 --- a/internal/app/service_village.go +++ b/internal/app/service_village.go @@ -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 +} diff --git a/internal/domain/village.go b/internal/domain/village.go index ac16ae4..bd3d78b 100644 --- a/internal/domain/village.go +++ b/internal/domain/village.go @@ -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, + } +} diff --git a/internal/port/handler_http_api.go b/internal/port/handler_http_api.go index cfa98d1..2c416d6 100644 --- a/internal/port/handler_http_api.go +++ b/internal/port/handler_http_api.go @@ -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) diff --git a/internal/port/handler_http_api_village.go b/internal/port/handler_http_api_village.go index 8c8bb23..1fd0140 100644 --- a/internal/port/handler_http_api_village.go +++ b/internal/port/handler_http_api_village.go @@ -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 + } +} diff --git a/internal/port/handler_http_api_village_test.go b/internal/port/handler_http_api_village_test.go index 7cb2630..225f43b 100644 --- a/internal/port/handler_http_api_village_test.go +++ b/internal/port/handler_http_api_village_test.go @@ -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)] +} diff --git a/internal/port/internal/apimodel/village.go b/internal/port/internal/apimodel/village.go index 1a3f5d3..0bd6699 100644 --- a/internal/port/internal/apimodel/village.go +++ b/internal/port/internal/apimodel/village.go @@ -38,3 +38,9 @@ func NewListVillagesResponse(res domain.ListVillagesWithRelationsResult) ListVil return resp } + +func NewGetVillageResponse(v domain.VillageWithRelations) GetVillageResponse { + return GetVillageResponse{ + Data: NewVillage(v), + } +}