From ac732635747828dbe20c657362eba10840a039d2 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 28 Jul 2024 13:41:11 +0900 Subject: refactor: change JwtPayload type --- backend/api/generated.go | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) (limited to 'backend/api/generated.go') diff --git a/backend/api/generated.go b/backend/api/generated.go index 1abdf73..21aefd2 100644 --- a/backend/api/generated.go +++ b/backend/api/generated.go @@ -23,9 +23,9 @@ import ( // JwtPayload defines model for JwtPayload. type JwtPayload struct { DisplayName string `json:"display_name"` - IconPath *string `json:"icon_path"` + IconPath *string `json:"icon_path,omitempty"` IsAdmin bool `json:"is_admin"` - UserId float32 `json:"user_id"` + UserId int `json:"user_id"` Username string `json:"username"` } @@ -172,15 +172,14 @@ func (sh *strictHandler) PostApiLogin(ctx echo.Context) error { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/6RSwY7aQAz9lcjnKITdnnKj6oVVD0hVT1WFnMTA0Ik9HU+WjVb592qSEkhB2kpwAGbm", - "+dnv+b1DJY0TJg4KxTtodaAGh78vp7DBzgrW8eS8OPLB0PBWG3UWuy1jQ/FMb9g4S1DAixw4+SIEKYTO", - "xRsN3vAe+hRMJbx1GA7zkoVpcE+6OMqBs6PbQwrcWotlfA2+pXtUusW6MTxj2qHVC7gUsYQc0a2S35p6", - "Bl4+PU9QbpuS/Bl5KypOdiuoT8HT79Z4qqH4MTW5IknnRl2N/XNik/JIVYA+0hneSewcTBj6rmyJwYtq", - "YjhETpucqExWmzWk8EpejTAUkGfLLI/TiyNGZ6CA5yzPckghuj1sbIHOLKzsR8ucaIi/cacYjPC6hgI2", - "omHlzNcBNYojDZ+l7iK2Eg7EQxk6Z001FC6OKnxJzm1UHKqexM/Nn26XT8/3kvLgGv66PbW+7/alKoZs", - "uFAnrOPcT3n+gOogv2geTniLn+zq+0MpI8n94WvSyhsXxgR8a6uKVHettV2CbTgQhzgq1dHNT/nyASkN", - "qeL+n1Ws+RWtqZPKUx17odUP5ZyJ/kfQmf+8zUR8Mq0zwrVtGvQdFPBdySdjsvu+7/8EAAD//3hNTqrS", - "BAAA", + "H4sIAAAAAAAC/6RSzY7aQAx+lcjnKISlp9yoemHVA1LVU1Uhk5gwdDKejp1lo1XevZqkZElB2kpwIJnJ", + "Z/v78RuU3Hh25FSgeAMpj9Tg8Pp81i12lrGKJx/YU1BDw7fKiLfY7Rw2FM/0io23BAU889ElX5ggBe18", + "vBENxtXQp2BKdjuPepyXLEyDNcnixEeXnXx9t1R2WDXGzSoPaIUm8J7ZErqIboXCzlQz8PJpNUGNU6op", + "XKC3KiKVWxp9CoF+tyZQBcWPacpVk3TuzBXvn1M33p+oVOhjO+MOHCer0WHu2u5RA4skkWJwaJMz7ZP1", + "dgMpvFAQww4KyLNllkf27MmhN1DAKsuzHFKI9g4RLdCbheV69MyzaHzGEFENu00FBWxZdO3N1wE1iiPR", + "z1x1EVuyU3JDGXpvTTkULk7C7n1VbnfDo8iZw9z96Xb5tLqX74Mx/HV7Gn3f7fcqDS0NF+LZycj7Kc8f", + "UK38i+bbCa/xl139fyhlbHKffEVSBuN13IBvbVmSyKG1tkuw1SM5jVSpim5+ypcPSGlIBOt/oti4F7Sm", + "SspAVZyFVj6Uc2n0P4Iu/S9pJhySKc4Il7ZpMHRQwHehkIyb3fd9/ycAAP//0SNLNsMEAAA=", } // GetSwagger returns the content of the embedded swagger specification file -- cgit v1.2.3-70-g09d2 From 0dd94cbea6e857896c46d17493725f97369d99f9 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 28 Jul 2024 16:19:52 +0900 Subject: refactor: remove /api/ prefix from openapi.yaml --- backend/api/generated.go | 70 ++++++++++++++++++------------------ backend/api/handlers.go | 12 +++---- backend/api/workaround.go | 22 ++++++++++++ backend/main.go | 2 +- frontend/app/.server/api/client.ts | 4 +-- frontend/app/.server/api/schema.d.ts | 2 +- frontend/app/.server/auth.ts | 2 +- openapi.yaml | 2 +- 8 files changed, 69 insertions(+), 47 deletions(-) create mode 100644 backend/api/workaround.go (limited to 'backend/api/generated.go') diff --git a/backend/api/generated.go b/backend/api/generated.go index 21aefd2..4455723 100644 --- a/backend/api/generated.go +++ b/backend/api/generated.go @@ -29,20 +29,20 @@ type JwtPayload struct { Username string `json:"username"` } -// PostApiLoginJSONBody defines parameters for PostApiLogin. -type PostApiLoginJSONBody struct { +// PostLoginJSONBody defines parameters for PostLogin. +type PostLoginJSONBody struct { Password string `json:"password"` Username string `json:"username"` } -// PostApiLoginJSONRequestBody defines body for PostApiLogin for application/json ContentType. -type PostApiLoginJSONRequestBody PostApiLoginJSONBody +// PostLoginJSONRequestBody defines body for PostLogin for application/json ContentType. +type PostLoginJSONRequestBody PostLoginJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { // User login - // (POST /api/login) - PostApiLogin(ctx echo.Context) error + // (POST /login) + PostLogin(ctx echo.Context) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -50,12 +50,12 @@ type ServerInterfaceWrapper struct { Handler ServerInterface } -// PostApiLogin converts echo context to params. -func (w *ServerInterfaceWrapper) PostApiLogin(ctx echo.Context) error { +// PostLogin converts echo context to params. +func (w *ServerInterfaceWrapper) PostLogin(ctx echo.Context) error { var err error // Invoke the callback with all the unmarshaled arguments - err = w.Handler.PostApiLogin(ctx) + err = w.Handler.PostLogin(ctx) return err } @@ -87,34 +87,34 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL Handler: si, } - router.POST(baseURL+"/api/login", wrapper.PostApiLogin) + router.POST(baseURL+"/login", wrapper.PostLogin) } -type PostApiLoginRequestObject struct { - Body *PostApiLoginJSONRequestBody +type PostLoginRequestObject struct { + Body *PostLoginJSONRequestBody } -type PostApiLoginResponseObject interface { - VisitPostApiLoginResponse(w http.ResponseWriter) error +type PostLoginResponseObject interface { + VisitPostLoginResponse(w http.ResponseWriter) error } -type PostApiLogin200JSONResponse struct { +type PostLogin200JSONResponse struct { Token string `json:"token"` } -func (response PostApiLogin200JSONResponse) VisitPostApiLoginResponse(w http.ResponseWriter) error { +func (response PostLogin200JSONResponse) VisitPostLoginResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type PostApiLogin401JSONResponse struct { +type PostLogin401JSONResponse struct { Message string `json:"message"` } -func (response PostApiLogin401JSONResponse) VisitPostApiLoginResponse(w http.ResponseWriter) error { +func (response PostLogin401JSONResponse) VisitPostLoginResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(401) @@ -124,8 +124,8 @@ func (response PostApiLogin401JSONResponse) VisitPostApiLoginResponse(w http.Res // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // User login - // (POST /api/login) - PostApiLogin(ctx context.Context, request PostApiLoginRequestObject) (PostApiLoginResponseObject, error) + // (POST /login) + PostLogin(ctx context.Context, request PostLoginRequestObject) (PostLoginResponseObject, error) } type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc @@ -140,29 +140,29 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } -// PostApiLogin operation middleware -func (sh *strictHandler) PostApiLogin(ctx echo.Context) error { - var request PostApiLoginRequestObject +// PostLogin operation middleware +func (sh *strictHandler) PostLogin(ctx echo.Context) error { + var request PostLoginRequestObject - var body PostApiLoginJSONRequestBody + var body PostLoginJSONRequestBody if err := ctx.Bind(&body); err != nil { return err } request.Body = &body handler := func(ctx echo.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostApiLogin(ctx.Request().Context(), request.(PostApiLoginRequestObject)) + return sh.ssi.PostLogin(ctx.Request().Context(), request.(PostLoginRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostApiLogin") + handler = middleware(handler, "PostLogin") } response, err := handler(ctx, request) if err != nil { return err - } else if validResponse, ok := response.(PostApiLoginResponseObject); ok { - return validResponse.VisitPostApiLoginResponse(ctx.Response()) + } else if validResponse, ok := response.(PostLoginResponseObject); ok { + return validResponse.VisitPostLoginResponse(ctx.Response()) } else if response != nil { return fmt.Errorf("unexpected response type: %T", response) } @@ -172,14 +172,14 @@ func (sh *strictHandler) PostApiLogin(ctx echo.Context) error { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/6RSzY7aQAx+lcjnKISlp9yoemHVA1LVU1Uhk5gwdDKejp1lo1XevZqkZElB2kpwIJnJ", + "H4sIAAAAAAAC/6RSzY7aQAx+lcjnKAToKbetemHVA1LVU1Uhk5gwdDKejp1lo1XevZqkZElB2kpwIJnJ", "Z/v78RuU3Hh25FSgeAMpj9Tg8Pp81i12lrGKJx/YU1BDw7fKiLfY7Rw2FM/0io23BAU889ElX5ggBe18", - "vBENxtXQp2BKdjuPepyXLEyDNcnixEeXnXx9t1R2WDXGzSoPaIUm8J7ZErqIboXCzlQz8PJpNUGNU6op", - "XKC3KiKVWxp9CoF+tyZQBcWPacpVk3TuzBXvn1M33p+oVOhjO+MOHCer0WHu2u5RA4skkWJwaJMz7ZP1", - "dgMpvFAQww4KyLNllkf27MmhN1DAKsuzHFKI9g4RLdCbheV69MyzaHzGEFENu00FBWxZdO3N1wE1iiPR", - "z1x1EVuyU3JDGXpvTTkULk7C7n1VbnfDo8iZw9z96Xb5tLqX74Mx/HV7Gn3f7fcqDS0NF+LZycj7Kc8f", - "UK38i+bbCa/xl139fyhlbHKffEVSBuN13IBvbVmSyKG1tkuw1SM5jVSpim5+ypcPSGlIBOt/oti4F7Sm", - "SspAVZyFVj6Uc2n0P4Iu/S9pJhySKc4Il7ZpMHRQwHehkIyb3fd9/ycAAP//0SNLNsMEAAA=", + "vBENxtXQp2BKdjuPepyXLEyDNcnixEeXnXx9t1R2WDXGzSoPaIUm8J7ZErqIboXCzlQz8HK1nqDGKdUU", + "LtBbFZHKLY0+hUC/WxOoguLHNOWqSTp35or3z6kb709UKvSxnXEHjpPV6DD3ye5RA4skkWJwaJMz7ZOn", + "7QZSeKEghh0UkGfLLI/s2ZNDb6CAdZZnOaQQ7R0iWliuR788i8ZnDBDVsNtUUMCWRb8OkFEViX7mqovA", + "kp2SG2rQe2vKoWpxEnbvO3K7FB5Fzhzmtk+3y9X6XrAP+v/X5mn0fZvfqzS0NFyIZycj71WeP6Ba+RfN", + "1xJe4y+7+v9QytjkPvmKpAzG6xj9t7YsSeTQWtsl2OqRnEaqVEU3P+XLB6Q0JIL1P1Fs3AtaUyVloCrO", + "Qisfyrk0+h9Bl/6XNBMOyRRnhEvbNBg6KOC7UEjGte77vv8TAAD//y1cIMC8BAAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/backend/api/handlers.go b/backend/api/handlers.go index ee0a97a..162e30a 100644 --- a/backend/api/handlers.go +++ b/backend/api/handlers.go @@ -21,19 +21,19 @@ func NewHandler(queries *db.Queries) *ApiHandler { } } -func (h *ApiHandler) PostApiLogin(ctx context.Context, request PostApiLoginRequestObject) (PostApiLoginResponseObject, error) { +func (h *ApiHandler) PostLogin(ctx context.Context, request PostLoginRequestObject) (PostLoginResponseObject, error) { username := request.Body.Username password := request.Body.Password userId, err := auth.Login(ctx, h.q, username, password) if err != nil { - return PostApiLogin401JSONResponse{ + return PostLogin401JSONResponse{ Message: "Invalid username or password", }, echo.NewHTTPError(http.StatusUnauthorized, "Invalid username or password") } user, err := h.q.GetUserById(ctx, int32(userId)) if err != nil { - return PostApiLogin401JSONResponse{ + return PostLogin401JSONResponse{ Message: "Invalid username or password", }, echo.NewHTTPError(http.StatusUnauthorized, "Invalid username or password") } @@ -41,12 +41,12 @@ func (h *ApiHandler) PostApiLogin(ctx context.Context, request PostApiLoginReque jwt, err := auth.NewJWT(&user) if err != nil { // TODO - return PostApiLogin401JSONResponse{ + return PostLogin401JSONResponse{ Message: "Internal Server Error", }, echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error") } - return PostApiLogin200JSONResponse{ + return PostLogin200JSONResponse{ Token: jwt, }, nil } @@ -64,7 +64,7 @@ func _assertJwtPayloadIsCompatibleWithJWTClaims() { func NewJWTMiddleware() StrictMiddlewareFunc { return func(handler StrictHandlerFunc, operationID string) StrictHandlerFunc { - if operationID == "PostApiLogin" { + if operationID == "PostLogin" { return handler } else { return func(c echo.Context, request interface{}) (response interface{}, err error) { diff --git a/backend/api/workaround.go b/backend/api/workaround.go new file mode 100644 index 0000000..a3c47d7 --- /dev/null +++ b/backend/api/workaround.go @@ -0,0 +1,22 @@ +package api + +import ( + "github.com/getkin/kin-openapi/openapi3" +) + +// Work-around for this issue: +// https://stackoverflow.com/questions/70087465/echo-groups-not-working-with-openapi-generated-code-using-oapi-codegen +func GetSwaggerWithPrefix(prefix string) (*openapi3.T, error) { + spec, err := GetSwagger() + if err != nil { + return nil, err + } + + var prefixedPaths openapi3.Paths = openapi3.Paths{} + for key, value := range spec.Paths.Map() { + prefixedPaths.Set(prefix+key, value) + } + + spec.Paths = &prefixedPaths + return spec, nil +} diff --git a/backend/main.go b/backend/main.go index 1787e3c..7f87bb4 100644 --- a/backend/main.go +++ b/backend/main.go @@ -127,7 +127,7 @@ func main() { return } - openApiSpec, err := api.GetSwagger() + openApiSpec, err := api.GetSwaggerWithPrefix("/api") if err != nil { fmt.Printf("Error loading OpenAPI spec\n: %s", err) return diff --git a/frontend/app/.server/api/client.ts b/frontend/app/.server/api/client.ts index 93a49f6..8e50b7e 100644 --- a/frontend/app/.server/api/client.ts +++ b/frontend/app/.server/api/client.ts @@ -4,6 +4,6 @@ import type { paths } from "./schema"; export const apiClient = createClient({ baseUrl: process.env.NODE_ENV === "development" - ? "http://localhost:8002/" - : "http://api-server/", + ? "http://localhost:8002/api/" + : "http://api-server/api/", }); diff --git a/frontend/app/.server/api/schema.d.ts b/frontend/app/.server/api/schema.d.ts index 5027bb0..d9ce187 100644 --- a/frontend/app/.server/api/schema.d.ts +++ b/frontend/app/.server/api/schema.d.ts @@ -4,7 +4,7 @@ */ export interface paths { - "/api/login": { + "/login": { parameters: { query?: never; header?: never; diff --git a/frontend/app/.server/auth.ts b/frontend/app/.server/auth.ts index 394a8b0..822d4b9 100644 --- a/frontend/app/.server/auth.ts +++ b/frontend/app/.server/auth.ts @@ -9,7 +9,7 @@ import { components } from "./api/schema"; export const authenticator = new Authenticator(sessionStorage); async function login(username: string, password: string): Promise { - const { data, error } = await apiClient.POST("/api/login", { + const { data, error } = await apiClient.POST("/login", { body: { username, password, diff --git a/openapi.yaml b/openapi.yaml index a3f5776..786e11e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3,7 +3,7 @@ info: title: Albatross internal web API version: 0.1.0 paths: - /api/login: + /login: post: summary: User login requestBody: -- cgit v1.2.3-70-g09d2 From 2d5f913a431c4223a16c88551ffff4100ac483c4 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 28 Jul 2024 16:01:41 +0900 Subject: feat: implement game entry --- Makefile | 14 ++- backend/api/generated.go | 159 +++++++++++++++++++++++++++++++++-- backend/api/handlers.go | 93 ++++++++++++++++++-- backend/db/query.sql.go | 104 +++++++++++++++++++++++ backend/go.mod | 1 + backend/go.sum | 6 ++ backend/query.sql | 10 +++ frontend/app/.server/api/schema.d.ts | 78 +++++++++++++++++ frontend/app/.server/auth.ts | 12 ++- frontend/app/routes/dashboard.tsx | 44 ++++++++-- openapi.yaml | 89 ++++++++++++++++++++ 11 files changed, 585 insertions(+), 25 deletions(-) (limited to 'backend/api/generated.go') diff --git a/Makefile b/Makefile index 023932c..07f682f 100644 --- a/Makefile +++ b/Makefile @@ -10,12 +10,20 @@ up: down: docker compose down -.PHONY: api-server-only -api-server-only: +.PHONY: api-server-only-build +api-server-only-build: + docker compose build api-server-only + +.PHONY: api-server-only-up +api-server-only-up: docker compose up -d api-server-only +.PHONY: api-server-only-down +api-server-only-down: + docker compose down api-server-only db + .PHONY: psql -psql: up +psql: docker compose exec db psql --user=postgres albatross .PHONY: sqldef-dryrun diff --git a/backend/api/generated.go b/backend/api/generated.go index 4455723..921f4b3 100644 --- a/backend/api/generated.go +++ b/backend/api/generated.go @@ -17,9 +17,34 @@ import ( "github.com/getkin/kin-openapi/openapi3" "github.com/labstack/echo/v4" + "github.com/oapi-codegen/runtime" strictecho "github.com/oapi-codegen/runtime/strictmiddleware/echo" ) +// Defines values for GameState. +const ( + Closed GameState = "closed" + Finished GameState = "finished" + Gaming GameState = "gaming" + Prepare GameState = "prepare" + Starting GameState = "starting" + WaitingEntries GameState = "waiting_entries" + WaitingStart GameState = "waiting_start" +) + +// Game defines model for Game. +type Game struct { + DisplayName string `json:"display_name"` + DurationSeconds int `json:"duration_seconds"` + GameId int `json:"game_id"` + Problem *Problem `json:"problem,omitempty"` + StartedAt *int `json:"started_at,omitempty"` + State GameState `json:"state"` +} + +// GameState defines model for Game.State. +type GameState string + // JwtPayload defines model for JwtPayload. type JwtPayload struct { DisplayName string `json:"display_name"` @@ -29,6 +54,19 @@ type JwtPayload struct { Username string `json:"username"` } +// Problem defines model for Problem. +type Problem struct { + Description string `json:"description"` + ProblemId int `json:"problem_id"` + Title string `json:"title"` +} + +// GetGamesParams defines parameters for GetGames. +type GetGamesParams struct { + PlayerId *int `form:"player_id,omitempty" json:"player_id,omitempty"` + Authorization string `json:"Authorization"` +} + // PostLoginJSONBody defines parameters for PostLogin. type PostLoginJSONBody struct { Password string `json:"password"` @@ -40,6 +78,9 @@ type PostLoginJSONRequestBody PostLoginJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { + // List games + // (GET /games) + GetGames(ctx echo.Context, params GetGamesParams) error // User login // (POST /login) PostLogin(ctx echo.Context) error @@ -50,6 +91,43 @@ type ServerInterfaceWrapper struct { Handler ServerInterface } +// GetGames converts echo context to params. +func (w *ServerInterfaceWrapper) GetGames(ctx echo.Context) error { + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetGamesParams + // ------------- Optional query parameter "player_id" ------------- + + err = runtime.BindQueryParameter("form", true, false, "player_id", ctx.QueryParams(), ¶ms.PlayerId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter player_id: %s", err)) + } + + headers := ctx.Request().Header + // ------------- Required header parameter "Authorization" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("Authorization")]; found { + var Authorization string + n := len(valueList) + if n != 1 { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Expected one value for Authorization, got %d", n)) + } + + err = runtime.BindStyledParameterWithOptions("simple", "Authorization", valueList[0], &Authorization, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter Authorization: %s", err)) + } + + params.Authorization = Authorization + } else { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Header parameter Authorization is required, but not found")) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetGames(ctx, params) + return err +} + // PostLogin converts echo context to params. func (w *ServerInterfaceWrapper) PostLogin(ctx echo.Context) error { var err error @@ -87,10 +165,41 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL Handler: si, } + router.GET(baseURL+"/games", wrapper.GetGames) router.POST(baseURL+"/login", wrapper.PostLogin) } +type GetGamesRequestObject struct { + Params GetGamesParams +} + +type GetGamesResponseObject interface { + VisitGetGamesResponse(w http.ResponseWriter) error +} + +type GetGames200JSONResponse struct { + Games []Game `json:"games"` +} + +func (response GetGames200JSONResponse) VisitGetGamesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetGames403JSONResponse struct { + Message string `json:"message"` +} + +func (response GetGames403JSONResponse) VisitGetGamesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type PostLoginRequestObject struct { Body *PostLoginJSONRequestBody } @@ -123,6 +232,9 @@ func (response PostLogin401JSONResponse) VisitPostLoginResponse(w http.ResponseW // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // List games + // (GET /games) + GetGames(ctx context.Context, request GetGamesRequestObject) (GetGamesResponseObject, error) // User login // (POST /login) PostLogin(ctx context.Context, request PostLoginRequestObject) (PostLoginResponseObject, error) @@ -140,6 +252,31 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// GetGames operation middleware +func (sh *strictHandler) GetGames(ctx echo.Context, params GetGamesParams) error { + var request GetGamesRequestObject + + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetGames(ctx.Request().Context(), request.(GetGamesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetGames") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetGamesResponseObject); ok { + return validResponse.VisitGetGamesResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // PostLogin operation middleware func (sh *strictHandler) PostLogin(ctx echo.Context) error { var request PostLoginRequestObject @@ -172,14 +309,20 @@ func (sh *strictHandler) PostLogin(ctx echo.Context) error { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/6RSzY7aQAx+lcjnKAToKbetemHVA1LVU1Uhk5gwdDKejp1lo1XevZqkZElB2kpwIJnJ", - "Z/v78RuU3Hh25FSgeAMpj9Tg8Pp81i12lrGKJx/YU1BDw7fKiLfY7Rw2FM/0io23BAU889ElX5ggBe18", - "vBENxtXQp2BKdjuPepyXLEyDNcnixEeXnXx9t1R2WDXGzSoPaIUm8J7ZErqIboXCzlQz8HK1nqDGKdUU", - "LtBbFZHKLY0+hUC/WxOoguLHNOWqSTp35or3z6kb709UKvSxnXEHjpPV6DD3ye5RA4skkWJwaJMz7ZOn", - "7QZSeKEghh0UkGfLLI/s2ZNDb6CAdZZnOaQQ7R0iWliuR788i8ZnDBDVsNtUUMCWRb8OkFEViX7mqovA", - "kp2SG2rQe2vKoWpxEnbvO3K7FB5Fzhzmtk+3y9X6XrAP+v/X5mn0fZvfqzS0NFyIZycj71WeP6Ba+RfN", - "1xJe4y+7+v9QytjkPvmKpAzG6xj9t7YsSeTQWtsl2OqRnEaqVEU3P+XLB6Q0JIL1P1Fs3AtaUyVloCrO", - "Qisfyrk0+h9Bl/6XNBMOyRRnhEvbNBg6KOC7UEjGte77vv8TAAD//y1cIMC8BAAA", + "H4sIAAAAAAAC/6xUTW/jNhD9K8K0R8FW4iDY+pai6CKLPRhoe1oExlgcS0wlkssZJesG+u8FqQ9btoqk", + "SHKIZGo+3nvzOC+Q29pZQ0YY1i/AeUk1xtfPWFN4Om8dedEUT5VmV+Fha/qv9ANrVxGsY3xyBSnIwYXf", + "LF6bAtoUVONRtDVbptwaxZO81W02pmgjVJAPOQXWtNVqEno1F+i83VVUh8CfPe1hDT8tj5yWPaHlpg9r", + "U2BBL6S2KJPqv9zc3n66+ZTNwmFB6fiapob1N8gry6QghWfUok2xJSM+aHQ8iX0gICSHnqDvHESJ/LqX", + "vTaaS1LwkJ6IibnoJ7oUs03B0/dGe1IBxaDSADCdzmdG+oexpN09Ui6B3Jdn2eChsqj+z7y/2NIkv1ma", + "m7jOrdk6lHKastQ1FsTLR1uaxaMrZlN5i6rWZpK5x4ppDN5ZWxGaEN0w+QubXK/mRhhCL1kEKK/KPHQ5", + "KXKh9Ih7TuHN0aRn8hLnXrswoimuP0vNieYEk8HgM1r1n950T0RLdca9RzV3ac8EOGk0VEon2C9JhxLa", + "7G1o2feGu2qH4i1zEoB5g1XyTLvkbnMPKTyR5ygDZIurRRYwW0cGnYY1rBbZIgt3CaWMwi2D9eNbQfEe", + "B1Wj1e9VWEYkn2NASPFYk5BnWH97geAs+N6QP0AKnR8gzHGYcLcwIuozDdu0zy4JFflj+l0jpfX6n9ge", + "TpUT39BMyVHlhxDMzhruuFxnWXjk1giZSAudq3QeKy8fuXPJsd7UTKMkWqjm1zZi3O/tODf0Hg+zC4b/", + "Y7oT78JXzZLYfdJltCncZKt3cKmJGYszw/5u/U4rRSYZp/2qdYdCb+Ew1o9VuKlr9IeBW0+sTWFZ2aJb", + "UM7yjPk2luVrDOmgEMuvVh3eoYZD5mfrp9d8PL26Xs1th3cuvH6vja3nBZx6vf1QP4v9m87W4o/wtzj5", + "/yqVrshbpv9Hk+fEvG+q6pBgIyUZCVBJdXa++mg735snrLRKck8q9MKKP9TOQ/1hmon1yTjOqcP/YvJJ", + "Z+u2bdt/AwAA//+RpToKFwoAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/backend/api/handlers.go b/backend/api/handlers.go index 162e30a..9856ce9 100644 --- a/backend/api/handlers.go +++ b/backend/api/handlers.go @@ -11,6 +11,8 @@ import ( "github.com/nsfisis/iosdc-2024-albatross-backend/db" ) +var _ StrictServerInterface = (*ApiHandler)(nil) + type ApiHandler struct { q *db.Queries } @@ -28,22 +30,20 @@ func (h *ApiHandler) PostLogin(ctx context.Context, request PostLoginRequestObje if err != nil { return PostLogin401JSONResponse{ Message: "Invalid username or password", - }, echo.NewHTTPError(http.StatusUnauthorized, "Invalid username or password") + }, nil } user, err := h.q.GetUserById(ctx, int32(userId)) if err != nil { return PostLogin401JSONResponse{ Message: "Invalid username or password", - }, echo.NewHTTPError(http.StatusUnauthorized, "Invalid username or password") + }, nil } jwt, err := auth.NewJWT(&user) if err != nil { // TODO - return PostLogin401JSONResponse{ - Message: "Internal Server Error", - }, echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error") + return nil, echo.NewHTTPError(http.StatusInternalServerError) } return PostLogin200JSONResponse{ @@ -51,6 +51,89 @@ func (h *ApiHandler) PostLogin(ctx context.Context, request PostLoginRequestObje }, nil } +func (h *ApiHandler) GetGames(ctx context.Context, request GetGamesRequestObject) (GetGamesResponseObject, error) { + user := ctx.Value("user").(*auth.JWTClaims) + playerId := request.Params.PlayerId + if !user.IsAdmin { + if playerId == nil || *playerId != user.UserID { + return GetGames403JSONResponse{ + Message: "Forbidden", + }, nil + } + } + if playerId == nil { + gameRows, err := h.q.ListGames(ctx) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError) + } + games := make([]Game, len(gameRows)) + for i, row := range gameRows { + var startedAt *int + if row.StartedAt.Valid { + startedAtTimestamp := int(row.StartedAt.Time.Unix()) + startedAt = &startedAtTimestamp + } + var problem *Problem + if row.ProblemID.Valid { + if !row.Title.Valid || !row.Description.Valid { + panic("inconsistent data") + } + problem = &Problem{ + ProblemId: int(row.ProblemID.Int32), + Title: row.Title.String, + Description: row.Description.String, + } + } + games[i] = Game{ + GameId: int(row.GameID), + State: GameState(row.State), + DisplayName: row.DisplayName, + DurationSeconds: int(row.DurationSeconds), + StartedAt: startedAt, + Problem: problem, + } + } + return GetGames200JSONResponse{ + Games: games, + }, nil + } else { + gameRows, err := h.q.ListGamesForPlayer(ctx, int32(*playerId)) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError) + } + games := make([]Game, len(gameRows)) + for i, row := range gameRows { + var startedAt *int + if row.StartedAt.Valid { + startedAtTimestamp := int(row.StartedAt.Time.Unix()) + startedAt = &startedAtTimestamp + } + var problem *Problem + if row.ProblemID.Valid { + if !row.Title.Valid || !row.Description.Valid { + panic("inconsistent data") + } + problem = &Problem{ + ProblemId: int(row.ProblemID.Int32), + Title: row.Title.String, + Description: row.Description.String, + } + } + games[i] = Game{ + GameId: int(row.GameID), + State: GameState(row.State), + DisplayName: row.DisplayName, + DurationSeconds: int(row.DurationSeconds), + StartedAt: startedAt, + Problem: problem, + } + } + return GetGames200JSONResponse{ + Games: games, + }, nil + } +} + func _assertJwtPayloadIsCompatibleWithJWTClaims() { var c auth.JWTClaims var p JwtPayload diff --git a/backend/db/query.sql.go b/backend/db/query.sql.go index 12651d2..20a7dc1 100644 --- a/backend/db/query.sql.go +++ b/backend/db/query.sql.go @@ -68,3 +68,107 @@ func (q *Queries) GetUserById(ctx context.Context, userID int32) (User, error) { ) return i, err } + +const listGames = `-- name: ListGames :many +SELECT game_id, state, display_name, duration_seconds, created_at, started_at, games.problem_id, problems.problem_id, title, description FROM games +LEFT JOIN problems ON games.problem_id = problems.problem_id +` + +type ListGamesRow struct { + GameID int32 + State string + DisplayName string + DurationSeconds int32 + CreatedAt pgtype.Timestamp + StartedAt pgtype.Timestamp + ProblemID pgtype.Int4 + ProblemID_2 pgtype.Int4 + Title pgtype.Text + Description pgtype.Text +} + +func (q *Queries) ListGames(ctx context.Context) ([]ListGamesRow, error) { + rows, err := q.db.Query(ctx, listGames) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListGamesRow + for rows.Next() { + var i ListGamesRow + if err := rows.Scan( + &i.GameID, + &i.State, + &i.DisplayName, + &i.DurationSeconds, + &i.CreatedAt, + &i.StartedAt, + &i.ProblemID, + &i.ProblemID_2, + &i.Title, + &i.Description, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listGamesForPlayer = `-- name: ListGamesForPlayer :many +SELECT games.game_id, state, display_name, duration_seconds, created_at, started_at, games.problem_id, problems.problem_id, title, description, game_players.game_id, user_id FROM games +LEFT JOIN problems ON games.problem_id = problems.problem_id +JOIN game_players ON games.game_id = game_players.game_id +WHERE game_players.user_id = $1 +` + +type ListGamesForPlayerRow struct { + GameID int32 + State string + DisplayName string + DurationSeconds int32 + CreatedAt pgtype.Timestamp + StartedAt pgtype.Timestamp + ProblemID pgtype.Int4 + ProblemID_2 pgtype.Int4 + Title pgtype.Text + Description pgtype.Text + GameID_2 int32 + UserID int32 +} + +func (q *Queries) ListGamesForPlayer(ctx context.Context, userID int32) ([]ListGamesForPlayerRow, error) { + rows, err := q.db.Query(ctx, listGamesForPlayer, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListGamesForPlayerRow + for rows.Next() { + var i ListGamesForPlayerRow + if err := rows.Scan( + &i.GameID, + &i.State, + &i.DisplayName, + &i.DurationSeconds, + &i.CreatedAt, + &i.StartedAt, + &i.ProblemID, + &i.ProblemID_2, + &i.Title, + &i.Description, + &i.GameID_2, + &i.UserID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/backend/go.mod b/backend/go.mod index e19bfc3..7e47d35 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -17,6 +17,7 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/cubicdaiya/gonp v1.0.4 // indirect github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index c4c4e16..bc38c89 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,9 +1,13 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I= @@ -64,6 +68,7 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -124,6 +129,7 @@ github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/sqlc-dev/sqlc v1.26.0 h1:bW6TA1vVdi2lfqsEddN5tSznRMYcWez7hf+AOqSiEp8= github.com/sqlc-dev/sqlc v1.26.0/go.mod h1:k2F3RWilLCup3D0XufrzZENCyXjtplALmHDmOt4v5bs= github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= diff --git a/backend/query.sql b/backend/query.sql index 165c2c9..9b038a5 100644 --- a/backend/query.sql +++ b/backend/query.sql @@ -8,3 +8,13 @@ SELECT * FROM users JOIN user_auths ON users.user_id = user_auths.user_id WHERE users.username = $1 LIMIT 1; + +-- name: ListGames :many +SELECT * FROM games +LEFT JOIN problems ON games.problem_id = problems.problem_id; + +-- name: ListGamesForPlayer :many +SELECT * FROM games +LEFT JOIN problems ON games.problem_id = problems.problem_id +JOIN game_players ON games.game_id = game_players.game_id +WHERE game_players.user_id = $1; diff --git a/frontend/app/.server/api/schema.d.ts b/frontend/app/.server/api/schema.d.ts index d9ce187..cd87705 100644 --- a/frontend/app/.server/api/schema.d.ts +++ b/frontend/app/.server/api/schema.d.ts @@ -64,6 +64,60 @@ export interface paths { patch?: never; trace?: never; }; + "/games": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List games */ + get: { + parameters: { + query?: { + player_id?: number; + }; + header: { + Authorization: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of games */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + games: components["schemas"]["Game"][]; + }; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example Forbidden operation */ + message: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -80,6 +134,30 @@ export interface components { /** @example false */ is_admin: boolean; }; + Game: { + /** @example 1 */ + game_id: number; + /** + * @example active + * @enum {string} + */ + state: "closed" | "waiting_entries" | "waiting_start" | "prepare" | "starting" | "gaming" | "finished"; + /** @example Game 1 */ + display_name: string; + /** @example 360 */ + duration_seconds: number; + /** @example 946684800 */ + started_at?: number; + problem?: components["schemas"]["Problem"]; + }; + Problem: { + /** @example 1 */ + problem_id: number; + /** @example Problem 1 */ + title: string; + /** @example This is a problem */ + description: string; + }; }; responses: never; parameters: never; diff --git a/frontend/app/.server/auth.ts b/frontend/app/.server/auth.ts index 822d4b9..988b30c 100644 --- a/frontend/app/.server/auth.ts +++ b/frontend/app/.server/auth.ts @@ -39,7 +39,7 @@ export async function isAuthenticated( failureRedirect?: never; headers?: never; }, -): Promise; +): Promise<{ user: User; token: string } | null>; export async function isAuthenticated( request: Request | Session, options: { @@ -55,7 +55,7 @@ export async function isAuthenticated( failureRedirect: string; headers?: HeadersInit; }, -): Promise; +): Promise<{ user: User; token: string }>; export async function isAuthenticated( request: Request | Session, options: { @@ -87,7 +87,7 @@ export async function isAuthenticated( failureRedirect: string; headers?: HeadersInit; } = {}, -): Promise { +): Promise<{ user: User; token: string } | null> { // This function's signature should be compatible with `authenticator.isAuthenticated` but TypeScript does not infer it correctly. let jwt; const { successRedirect, failureRedirect, headers } = options; @@ -114,5 +114,9 @@ export async function isAuthenticated( if (!jwt) { return null; } - return jwtDecode(jwt); + const user = jwtDecode(jwt); + return { + user, + token: jwt, + }; } diff --git a/frontend/app/routes/dashboard.tsx b/frontend/app/routes/dashboard.tsx index 3ad465f..9836d1b 100644 --- a/frontend/app/routes/dashboard.tsx +++ b/frontend/app/routes/dashboard.tsx @@ -1,15 +1,33 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; +import { Link, useLoaderData } from "@remix-run/react"; import { isAuthenticated } from "../.server/auth"; -import { useLoaderData } from "@remix-run/react"; +import { apiClient } from "../.server/api/client"; export async function loader({ request }: LoaderFunctionArgs) { - return await isAuthenticated(request, { + const { user, token } = await isAuthenticated(request, { failureRedirect: "/login", }); + const { data, error } = await apiClient.GET("/games", { + params: { + query: { + player_id: user.user_id, + }, + header: { + Authorization: `Bearer ${token}`, + }, + }, + }); + if (error) { + throw new Error(error.message); + } + return { + user, + games: data.games, + }; } export default function Dashboard() { - const user = useLoaderData()!; + const { user, games } = useLoaderData()!; return (
@@ -24,10 +42,26 @@ export default function Dashboard() {
  • Name: {user.display_name}
  • -

    Game

    +

    Games

      -
    • TODO
    • + {games.map((game) => ( +
    • + {game.display_name}{" "} + {game.state === "closed" || game.state === "finished" ? ( + + Entry + + ) : ( + + Entry + + )} +
    • + ))}
    diff --git a/openapi.yaml b/openapi.yaml index 786e11e..002b229 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -47,6 +47,46 @@ paths: example: "Invalid credentials" required: - message + /games: + get: + summary: List games + parameters: + - in: query + name: player_id + schema: + type: integer + required: false + - in: header + name: Authorization + schema: + type: string + required: true + responses: + '200': + description: List of games + content: + application/json: + schema: + type: object + properties: + games: + type: array + items: + $ref: '#/components/schemas/Game' + required: + - games + '403': + description: Forbidden + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: "Forbidden operation" + required: + - message components: schemas: JwtPayload: @@ -72,3 +112,52 @@ components: - username - display_name - is_admin + Game: + type: object + properties: + game_id: + type: integer + example: 1 + state: + type: string + example: "active" + enum: + - closed + - waiting_entries + - waiting_start + - prepare + - starting + - gaming + - finished + display_name: + type: string + example: "Game 1" + duration_seconds: + type: integer + example: 360 + started_at: + type: integer + example: 946684800 + problem: + $ref: '#/components/schemas/Problem' + required: + - game_id + - state + - display_name + - duration_seconds + Problem: + type: object + properties: + problem_id: + type: integer + example: 1 + title: + type: string + example: "Problem 1" + description: + type: string + example: "This is a problem" + required: + - problem_id + - title + - description -- cgit v1.2.3-70-g09d2