aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--Makefile14
-rw-r--r--backend/api/generated.go220
-rw-r--r--backend/api/handlers.go128
-rw-r--r--backend/api/workaround.go22
-rw-r--r--backend/auth/jwt.go26
-rw-r--r--backend/db/models.go22
-rw-r--r--backend/db/query.sql.go104
-rw-r--r--backend/go.mod2
-rw-r--r--backend/go.sum8
-rw-r--r--backend/main.go10
-rw-r--r--backend/query.sql10
-rw-r--r--backend/schema.sql26
-rw-r--r--compose.yaml18
-rw-r--r--frontend/app/.server/api/client.ts7
-rw-r--r--frontend/app/.server/api/schema.d.ts82
-rw-r--r--frontend/app/.server/auth.ts27
-rw-r--r--frontend/app/routes/dashboard.tsx48
-rw-r--r--openapi.yaml94
18 files changed, 759 insertions, 109 deletions
diff --git a/Makefile b/Makefile
index 17b3b1e..07f682f 100644
--- a/Makefile
+++ b/Makefile
@@ -10,8 +10,20 @@ up:
down:
docker compose down
+.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 1abdf73..921f4b3 100644
--- a/backend/api/generated.go
+++ b/backend/api/generated.go
@@ -17,32 +17,73 @@ 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"`
- 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"`
}
-// PostApiLoginJSONBody defines parameters for PostApiLogin.
-type PostApiLoginJSONBody struct {
+// 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"`
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 {
+ // List games
+ // (GET /games)
+ GetGames(ctx echo.Context, params GetGamesParams) error
// 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 +91,49 @@ type ServerInterfaceWrapper struct {
Handler ServerInterface
}
-// PostApiLogin converts echo context to params.
-func (w *ServerInterfaceWrapper) PostApiLogin(ctx echo.Context) error {
+// 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(), &params.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.PostApiLogin(ctx)
+ 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
+
+ // Invoke the callback with all the unmarshaled arguments
+ err = w.Handler.PostLogin(ctx)
return err
}
@@ -87,34 +165,65 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
Handler: si,
}
- router.POST(baseURL+"/api/login", wrapper.PostApiLogin)
+ 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 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)
@@ -123,9 +232,12 @@ func (response PostApiLogin401JSONResponse) VisitPostApiLoginResponse(w http.Res
// StrictServerInterface represents all server handlers.
type StrictServerInterface interface {
+ // List games
+ // (GET /games)
+ GetGames(ctx context.Context, request GetGamesRequestObject) (GetGamesResponseObject, error)
// 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 +252,54 @@ type strictHandler struct {
middlewares []StrictMiddlewareFunc
}
-// PostApiLogin operation middleware
-func (sh *strictHandler) PostApiLogin(ctx echo.Context) error {
- var request PostApiLoginRequestObject
+// 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
- 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,15 +309,20 @@ 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/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 c435d72..9856ce9 100644
--- a/backend/api/handlers.go
+++ b/backend/api/handlers.go
@@ -3,6 +3,7 @@ package api
import (
"context"
"net/http"
+ "strings"
"github.com/labstack/echo/v4"
@@ -10,6 +11,8 @@ import (
"github.com/nsfisis/iosdc-2024-albatross-backend/db"
)
+var _ StrictServerInterface = (*ApiHandler)(nil)
+
type ApiHandler struct {
q *db.Queries
}
@@ -20,43 +23,148 @@ 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")
+ }, nil
}
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")
+ }, nil
}
jwt, err := auth.NewJWT(&user)
if err != nil {
// TODO
- return PostApiLogin401JSONResponse{
- Message: "Internal Server Error",
- }, echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error")
+ return nil, echo.NewHTTPError(http.StatusInternalServerError)
}
- return PostApiLogin200JSONResponse{
+ return PostLogin200JSONResponse{
Token: jwt,
}, 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
- p.UserId = float32(c.UserID)
+ p.UserId = c.UserID
p.Username = c.Username
p.DisplayName = c.DisplayName
p.IconPath = c.IconPath
p.IsAdmin = c.IsAdmin
_ = p
}
+
+func NewJWTMiddleware() StrictMiddlewareFunc {
+ return func(handler StrictHandlerFunc, operationID string) StrictHandlerFunc {
+ if operationID == "PostLogin" {
+ return handler
+ } else {
+ return func(c echo.Context, request interface{}) (response interface{}, err error) {
+ authorization := c.Request().Header.Get("Authorization")
+ const prefix = "Bearer "
+ if !strings.HasPrefix(authorization, prefix) {
+ return nil, echo.NewHTTPError(http.StatusUnauthorized)
+ }
+ token := authorization[len(prefix):]
+
+ claims, err := auth.ParseJWT(token)
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusUnauthorized)
+ }
+ c.SetRequest(c.Request().WithContext(context.WithValue(c.Request().Context(), "user", claims)))
+ return handler(c, request)
+ }
+ }
+ }
+}
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/auth/jwt.go b/backend/auth/jwt.go
index 1b153fe..c750531 100644
--- a/backend/auth/jwt.go
+++ b/backend/auth/jwt.go
@@ -1,11 +1,10 @@
package auth
import (
+ "errors"
"time"
"github.com/golang-jwt/jwt/v5"
- echojwt "github.com/labstack/echo-jwt/v4"
- "github.com/labstack/echo/v4"
"github.com/nsfisis/iosdc-2024-albatross-backend/db"
)
@@ -38,17 +37,16 @@ func NewJWT(user *db.User) (string, error) {
return token.SignedString([]byte("TODO"))
}
-func NewJWTMiddleware() echo.MiddlewareFunc {
- return echojwt.WithConfig(echojwt.Config{
- NewClaimsFunc: func(c echo.Context) jwt.Claims {
- return new(JWTClaims)
- },
- SigningKey: []byte("TODO"),
+func ParseJWT(token string) (*JWTClaims, error) {
+ claims := new(JWTClaims)
+ t, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
+ return []byte("TODO"), nil
})
-}
-
-func GetJWTClaimsFromEchoContext(c echo.Context) *JWTClaims {
- user := c.Get("user").(*jwt.Token)
- claims := user.Claims.(*JWTClaims)
- return claims
+ if err != nil {
+ return nil, err
+ }
+ if !t.Valid {
+ return nil, errors.New("invalid token")
+ }
+ return claims, nil
}
diff --git a/backend/db/models.go b/backend/db/models.go
index a54fd8c..cc08817 100644
--- a/backend/db/models.go
+++ b/backend/db/models.go
@@ -9,10 +9,24 @@ import (
)
type Game struct {
- GameID int32
- Type string
- CreatedAt pgtype.Timestamp
- State string
+ GameID int32
+ State string
+ DisplayName string
+ DurationSeconds int32
+ CreatedAt pgtype.Timestamp
+ StartedAt pgtype.Timestamp
+ ProblemID pgtype.Int4
+}
+
+type GamePlayer struct {
+ GameID int32
+ UserID int32
+}
+
+type Problem struct {
+ ProblemID int32
+ Title string
+ Description string
}
type User struct {
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 8e3e387..7e47d35 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -7,7 +7,6 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.5.5
- github.com/labstack/echo-jwt/v4 v4.2.0
github.com/labstack/echo/v4 v4.12.0
github.com/oapi-codegen/echo-middleware v1.0.2
github.com/oapi-codegen/oapi-codegen/v2 v2.3.0
@@ -18,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 6973c16..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=
@@ -71,8 +76,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/labstack/echo-jwt/v4 v4.2.0 h1:odSISV9JgcSCuhgQSV/6Io3i7nUmfM/QkBeR5GVJj5c=
-github.com/labstack/echo-jwt/v4 v4.2.0/go.mod h1:MA2RqdXdEn4/uEglx0HcUOgQSyBaTh5JcaHIan3biwU=
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@@ -126,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/main.go b/backend/main.go
index fa5c079..7f87bb4 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -10,6 +10,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/labstack/echo/v4"
+ "github.com/labstack/echo/v4/middleware"
oapimiddleware "github.com/oapi-codegen/echo-middleware"
"github.com/nsfisis/iosdc-2024-albatross-backend/api"
@@ -126,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
@@ -144,11 +145,16 @@ func main() {
e := echo.New()
+ e.Use(middleware.Logger())
+ e.Use(middleware.Recover())
+
{
apiGroup := e.Group("/api")
apiGroup.Use(oapimiddleware.OapiRequestValidator(openApiSpec))
apiHandler := api.NewHandler(queries)
- api.RegisterHandlers(apiGroup, api.NewStrictHandler(apiHandler, nil))
+ api.RegisterHandlers(apiGroup, api.NewStrictHandler(apiHandler, []api.StrictMiddlewareFunc{
+ api.NewJWTMiddleware(),
+ }))
}
e.GET("/sock/golf/:gameId/watch", func(c echo.Context) error {
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/backend/schema.sql b/backend/schema.sql
index 6696242..f0b5b37 100644
--- a/backend/schema.sql
+++ b/backend/schema.sql
@@ -16,8 +16,26 @@ CREATE TABLE user_auths (
);
CREATE TABLE games (
- game_id SERIAL PRIMARY KEY,
- type VARCHAR(255) NOT NULL,
- created_at TIMESTAMP NOT NULL DEFAULT NOW(),
- state VARCHAR(255) NOT NULL
+ game_id SERIAL PRIMARY KEY,
+ state VARCHAR(32) NOT NULL,
+ display_name VARCHAR(255) NOT NULL,
+ duration_seconds INT NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ started_at TIMESTAMP,
+ problem_id INT,
+ CONSTRAINT fk_problem_id FOREIGN KEY(problem_id) REFERENCES problems(problem_id)
+);
+
+CREATE TABLE game_players (
+ game_id INT NOT NULL,
+ user_id INT NOT NULL,
+ PRIMARY KEY (game_id, user_id),
+ CONSTRAINT fk_game_id FOREIGN KEY(game_id) REFERENCES games(game_id),
+ CONSTRAINT fk_user_id FOREIGN KEY(user_id) REFERENCES users(user_id)
+);
+
+CREATE TABLE problems (
+ problem_id SERIAL PRIMARY KEY,
+ title VARCHAR(255) NOT NULL,
+ description TEXT NOT NULL
);
diff --git a/compose.yaml b/compose.yaml
index 4714568..00bbd0b 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -26,6 +26,24 @@ services:
ALBATROSS_DB_NAME: albatross
restart: always
+ api-server-only:
+ build:
+ context: ./backend
+ ports:
+ - '127.0.0.1:8002:80'
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ ALBATROSS_DB_HOST: db
+ ALBATROSS_DB_PORT: 5432
+ ALBATROSS_DB_USER: postgres
+ ALBATROSS_DB_PASSWORD: eepei5reesoo0ov2ceelahd4Emi0au8ahJa6oochohheiquahweihoovahsee1oo
+ ALBATROSS_DB_NAME: albatross
+ restart: always
+ profiles:
+ - api-server-only
+
app-server:
build:
context: ./frontend
diff --git a/frontend/app/.server/api/client.ts b/frontend/app/.server/api/client.ts
index 12f2fc6..8e50b7e 100644
--- a/frontend/app/.server/api/client.ts
+++ b/frontend/app/.server/api/client.ts
@@ -1,4 +1,9 @@
import createClient from "openapi-fetch";
import type { paths } from "./schema";
-export const apiClient = createClient<paths>({ baseUrl: "http://api-server/" });
+export const apiClient = createClient<paths>({
+ baseUrl:
+ process.env.NODE_ENV === "development"
+ ? "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 5219ac8..cd87705 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;
@@ -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<string, never>;
export interface components {
@@ -76,10 +130,34 @@ export interface components {
/** @example John Doe */
display_name: string;
/** @example /images/john.jpg */
- icon_path?: string | null;
+ icon_path?: string;
/** @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 a3496af..988b30c 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<string>(sessionStorage);
async function login(username: string, password: string): Promise<string> {
- const { data, error } = await apiClient.POST("/api/login", {
+ const { data, error } = await apiClient.POST("/login", {
body: {
username,
password,
@@ -30,15 +30,7 @@ authenticator.use(
"default",
);
-type JwtPayload = components["schemas"]["JwtPayload"];
-
-export type User = {
- userId: number;
- username: string;
- displayName: string;
- iconPath: string | null;
- isAdmin: boolean;
-};
+export type User = components["schemas"]["JwtPayload"];
export async function isAuthenticated(
request: Request | Session,
@@ -47,7 +39,7 @@ export async function isAuthenticated(
failureRedirect?: never;
headers?: never;
},
-): Promise<User | null>;
+): Promise<{ user: User; token: string } | null>;
export async function isAuthenticated(
request: Request | Session,
options: {
@@ -63,7 +55,7 @@ export async function isAuthenticated(
failureRedirect: string;
headers?: HeadersInit;
},
-): Promise<User>;
+): Promise<{ user: User; token: string }>;
export async function isAuthenticated(
request: Request | Session,
options: {
@@ -95,7 +87,7 @@ export async function isAuthenticated(
failureRedirect: string;
headers?: HeadersInit;
} = {},
-): Promise<User | null> {
+): 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;
@@ -122,12 +114,9 @@ export async function isAuthenticated(
if (!jwt) {
return null;
}
- const payload = jwtDecode<JwtPayload>(jwt);
+ const user = jwtDecode<User>(jwt);
return {
- userId: payload.user_id,
- username: payload.username,
- displayName: payload.display_name,
- iconPath: payload.icon_path ?? null,
- isAdmin: payload.is_admin,
+ user,
+ token: jwt,
};
}
diff --git a/frontend/app/routes/dashboard.tsx b/frontend/app/routes/dashboard.tsx
index 407dda6..9836d1b 100644
--- a/frontend/app/routes/dashboard.tsx
+++ b/frontend/app/routes/dashboard.tsx
@@ -1,33 +1,67 @@
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<typeof loader>()!;
+ const { user, games } = useLoaderData<typeof loader>()!;
return (
<div className="min-h-screen p-8">
<div className="p-6 rounded shadow-md max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-4">
{user.username}{" "}
- {user.isAdmin && <span className="text-red-500 text-lg">admin</span>}
+ {user.is_admin && <span className="text-red-500 text-lg">admin</span>}
</h1>
<h2 className="text-2xl font-semibold mb-2">User</h2>
<div className="mb-6">
<ul className="list-disc list-inside">
- <li>Name: {user.displayName}</li>
+ <li>Name: {user.display_name}</li>
</ul>
</div>
- <h2 className="text-2xl font-semibold mb-2">Game</h2>
+ <h2 className="text-2xl font-semibold mb-2">Games</h2>
<div>
<ul className="list-disc list-inside">
- <li>TODO</li>
+ {games.map((game) => (
+ <li key={game.game_id}>
+ {game.display_name}{" "}
+ {game.state === "closed" || game.state === "finished" ? (
+ <span className="inline-block px-6 py-2 text-gray-400 bg-gray-200 cursor-not-allowed rounded">
+ Entry
+ </span>
+ ) : (
+ <Link
+ to={`/game/${game.game_id}/play`}
+ className="inline-block px-6 py-2 text-white bg-blue-500 hover:bg-blue-700 rounded"
+ >
+ Entry
+ </Link>
+ )}
+ </li>
+ ))}
</ul>
</div>
</div>
diff --git a/openapi.yaml b/openapi.yaml
index a0348d9..002b229 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:
@@ -47,13 +47,53 @@ 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:
type: object
properties:
user_id:
- type: number
+ type: integer
example: 123
username:
type: string
@@ -63,7 +103,6 @@ components:
example: "John Doe"
icon_path:
type: string
- nullable: true
example: "/images/john.jpg"
is_admin:
type: boolean
@@ -73,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