diff --git a/api/openapi3.yml b/api/openapi3.yml index f542af4..a6fe665 100644 --- a/api/openapi3.yml +++ b/api/openapi3.yml @@ -188,6 +188,24 @@ paths: $ref: "#/components/responses/ListPlayersResponse" default: $ref: "#/components/responses/ErrorResponse" + /v2/versions/{versionCode}/servers/{serverKey}/players/{playerId}: + get: + operationId: getPlayer + tags: + - versions + - servers + - players + description: Get a player + parameters: + - $ref: "#/components/parameters/VersionCodePathParam" + - $ref: "#/components/parameters/ServerKeyPathParam" + - $ref: "#/components/parameters/PlayerIdPathParam" + responses: + 200: + $ref: "#/components/responses/GetPlayerResponse" + default: + $ref: "#/components/responses/ErrorResponse" + components: schemas: Error: @@ -1171,6 +1189,13 @@ components: schema: type: integer minimum: 1 + PlayerIdPathParam: + in: path + name: playerId + required: true + schema: + type: integer + minimum: 1 responses: ListVersionsResponse: description: "" @@ -1298,6 +1323,17 @@ components: type: array items: $ref: "#/components/schemas/Player" + GetPlayerResponse: + description: "" + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: "#/components/schemas/Player" ErrorResponse: description: Default error response. content: diff --git a/internal/app/service_player.go b/internal/app/service_player.go index 6d30a03..ed7896b 100644 --- a/internal/app/service_player.go +++ b/internal/app/service_player.go @@ -190,3 +190,32 @@ func (svc *PlayerService) ListWithRelations( ) (domain.ListPlayersWithRelationsResult, error) { return svc.repo.ListWithRelations(ctx, params) } + +func (svc *PlayerService) GetWithRelations( + ctx context.Context, + id int, + serverKey string, +) (domain.PlayerWithRelations, error) { + params := domain.NewListPlayersParams() + if err := params.SetIDs([]int{id}); err != nil { + return domain.PlayerWithRelations{}, err + } + if err := params.SetServerKeys([]string{serverKey}); err != nil { + return domain.PlayerWithRelations{}, err + } + + res, err := svc.repo.ListWithRelations(ctx, params) + if err != nil { + return domain.PlayerWithRelations{}, err + } + + players := res.Players() + if len(players) == 0 { + return domain.PlayerWithRelations{}, domain.PlayerNotFoundError{ + ID: id, + ServerKey: serverKey, + } + } + + return players[0], nil +} diff --git a/internal/domain/player.go b/internal/domain/player.go index 810a917..77f223b 100644 --- a/internal/domain/player.go +++ b/internal/domain/player.go @@ -957,3 +957,29 @@ func (res ListPlayersWithRelationsResult) Self() PlayerCursor { func (res ListPlayersWithRelationsResult) Next() PlayerCursor { return res.next } + +type PlayerNotFoundError struct { + ID int + ServerKey string +} + +var _ ErrorWithParams = PlayerNotFoundError{} + +func (e PlayerNotFoundError) Error() string { + return fmt.Sprintf("player with id %d and server key %s not found", e.ID, e.ServerKey) +} + +func (e PlayerNotFoundError) Type() ErrorType { + return ErrorTypeNotFound +} + +func (e PlayerNotFoundError) Code() string { + return "player-not-found" +} + +func (e PlayerNotFoundError) 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 f51c4c9..27c48c7 100644 --- a/internal/port/handler_http_api.go +++ b/internal/port/handler_http_api.go @@ -75,6 +75,7 @@ func NewAPIHTTPHandler( h.versionMiddleware, h.serverMiddleware, h.tribeMiddleware, + h.playerMiddleware, }, ErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { h.errorRenderer.render(w, r, err) diff --git a/internal/port/handler_http_api_player.go b/internal/port/handler_http_api_player.go index 8db6e39..d7b5b21 100644 --- a/internal/port/handler_http_api_player.go +++ b/internal/port/handler_http_api_player.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" ) //nolint:gocyclo @@ -75,6 +78,59 @@ func (h *apiHTTPHandler) ListPlayers( renderJSON(w, r, http.StatusOK, apimodel.NewListPlayersResponse(res)) } +func (h *apiHTTPHandler) GetPlayer( + w http.ResponseWriter, + r *http.Request, + _ apimodel.VersionCodePathParam, + _ apimodel.ServerKeyPathParam, + _ apimodel.PlayerIdPathParam, +) { + player, _ := playerFromContext(r.Context()) + renderJSON(w, r, http.StatusOK, apimodel.NewGetPlayerResponse(player)) +} + +func (h *apiHTTPHandler) playerMiddleware(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) + playerIDIdx := slices.Index(routeCtx.URLParams.Keys, "playerId") + + if !serverOK || playerIDIdx < 0 { + next.ServeHTTP(w, r) + return + } + + playerID, err := strconv.Atoi(routeCtx.URLParams.Values[playerIDIdx]) + if err != nil { + return + } + + player, err := h.playerSvc.GetWithRelations( + ctx, + playerID, + server.Key(), + ) + if err != nil { + h.errorRenderer.withErrorPathFormatter(formatGetPlayerErrorPath).render(w, r, err) + return + } + + next.ServeHTTP(w, r.WithContext(playerToContext(ctx, player))) + }) +} + +type playerCtxKey struct{} + +func playerToContext(ctx context.Context, p domain.PlayerWithRelations) context.Context { + return context.WithValue(ctx, playerCtxKey{}, p) +} + +func playerFromContext(ctx context.Context) (domain.PlayerWithRelations, bool) { + p, ok := ctx.Value(playerCtxKey{}).(domain.PlayerWithRelations) + return p, ok +} + func formatListPlayersErrorPath(segments []errorPathSegment) []string { if segments[0].model != "ListPlayersParams" { return nil @@ -103,3 +159,16 @@ func formatListPlayersErrorPath(segments []errorPathSegment) []string { return nil } } + +func formatGetPlayerErrorPath(segments []errorPathSegment) []string { + if segments[0].model != "ListPlayersParams" { + return nil + } + + switch segments[0].field { + case "ids": + return []string{"$path", "playerId"} + default: + return nil + } +} diff --git a/internal/port/handler_http_api_player_test.go b/internal/port/handler_http_api_player_test.go index d0ebf00..e50b536 100644 --- a/internal/port/handler_http_api_player_test.go +++ b/internal/port/handler_http_api_player_test.go @@ -752,6 +752,196 @@ func TestListPlayers(t *testing.T) { } } +const endpointGetPlayer = "/v2/versions/%s/servers/%s/players/%d" + +func TestGetPlayer(t *testing.T) { + t.Parallel() + + handler := newAPIHTTPHandler(t) + player := randPlayer(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.GetPlayerResponse](t, resp.Body) + assert.Equal(t, player.Player, body.Data) + }, + }, + { + name: "ERR: id < 1", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + req.URL.Path = fmt.Sprintf( + endpointGetPlayer, + player.Server.Version.Code, + player.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", "playerId"}, + }, + }, + }, body) + }, + }, + { + name: "ERR: version not found", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + req.URL.Path = fmt.Sprintf(endpointGetPlayer, domaintest.RandVersionCode(), player.Server.Key, player.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(endpointGetPlayer, player.Server.Version.Code, domaintest.RandServerKey(), player.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: player not found", + reqModifier: func(t *testing.T, req *http.Request) { + t.Helper() + req.URL.Path = fmt.Sprintf( + endpointGetPlayer, + player.Server.Version.Code, + player.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.PlayerNotFoundError{ + 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(endpointGetPlayer, player.Server.Version.Code, player.Server.Key, player.Id), + nil, + ) + if tt.reqModifier != nil { + tt.reqModifier(t, req) + } + + resp := doCustomRequest(handler, req) + defer resp.Body.Close() + tt.assertResp(t, req, resp) + }) + } +} + type playerWithServer struct { apimodel.Player Server serverWithVersion @@ -782,3 +972,9 @@ func getAllPlayers(tb testing.TB, h http.Handler) []playerWithServer { return players } + +func randPlayer(tb testing.TB, h http.Handler) playerWithServer { + tb.Helper() + players := getAllPlayers(tb, h) + return players[gofakeit.IntRange(0, len(players)-1)] +} diff --git a/internal/port/internal/apimodel/player.go b/internal/port/internal/apimodel/player.go index 007f3fd..acea881 100644 --- a/internal/port/internal/apimodel/player.go +++ b/internal/port/internal/apimodel/player.go @@ -63,3 +63,9 @@ func NewListPlayersResponse(res domain.ListPlayersWithRelationsResult) ListPlaye return resp } + +func NewGetPlayerResponse(p domain.PlayerWithRelations) GetPlayerResponse { + return GetPlayerResponse{ + Data: NewPlayer(p), + } +}