feat: new API endpoint - GET /api/v2/versions/{versionCode}/servers/{serverKey}/players/{playerId} (#10)
ci/woodpecker/push/govulncheck Pipeline was successful Details
ci/woodpecker/push/test Pipeline was successful Details

Reviewed-on: twhelp/corev3#10
This commit is contained in:
Dawid Wysokiński 2024-02-29 06:25:01 +00:00
parent bf2b3b178c
commit 572a85bc3c
7 changed files with 363 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

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"
)
//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
}
}

View File

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

View File

@ -63,3 +63,9 @@ func NewListPlayersResponse(res domain.ListPlayersWithRelationsResult) ListPlaye
return resp
}
func NewGetPlayerResponse(p domain.PlayerWithRelations) GetPlayerResponse {
return GetPlayerResponse{
Data: NewPlayer(p),
}
}