internal/client (new): a typed Go HTTP client for the API - login, register, logout, me, and full CRUD for games/platforms - shared by the TUI and the Fyne GUI so request-building, auth and error handling (an APIError with the HTTP status) live in one tested place instead of being duplicated. 8 tests against an httptest server covering success, 401, 403 and that the token is stored/cleared at the right times. cmd/tui (rewrite): drops the old unauthenticated read-only viewer. Now a login screen (bubbles/textinput) -> a scrollable games table (bubbles/table) with 'r' to refresh, built as an explicit state machine (screenLogin/screenLoading/screenGames) so Update's transitions are testable without a real terminal. 9 tests driving Update() directly. cmd/gui (rewrite): drops raylib for Fyne. Same shape as the TUI - a login form, then a games table, with networking on a goroutine and fyne.Do for the UI updates. The table-cell and login-validation logic are plain functions with no Fyne/display dependency, so they're tested directly (7 tests) rather than needing Fyne's headless test driver. internal/api: tagged every operation (Auth/Platforms/Games/Misc) so the generated OpenAPI spec groups into named services instead of one bucket; api/openapi.json regenerated. Added CORS middleware (wildcard origin, no credentials needed since clients use a bearer token, not the cookie) - needed for the new web client's browser requests to the API's a different origin. web/ (new): an Angular 22 + Material app, API client generated from api/openapi.json via ng-openapi-gen (services: true for per-resource classes - AuthService/GamesService/PlatformsService - instead of one generic Api facade). A Session service holds the token/user as signals, persists the token to localStorage, and is the only thing that calls the generated AuthService; an HttpInterceptorFn attaches the bearer token to every request; a CanActivateFn guard protects /games. Login and Games are standalone, lazy-loaded route components. 21 tests (Vitest, Angular's new default - no browser/Karma needed) covering the service, interceptor, guard and both components, run via `npm test` exactly as CI will call it. Justfile: fixed run-tui/run-gui, which used `go run .../main.go` - broken now that both packages span multiple files, since that only compiles the one named file. Added web-install/web-dev/web-build/web-test. .gitea/workflows/ci.yml (moved from .github, which Gitea doesn't read): on push to main/develop - gofmt, go vet, go build, go test, go mod tidy diff (with the Fyne build's system deps installed), sqlc diff, and the web app's build+test. .gitea/workflows/release.yml (new): on a vX.Y.Z tag, cross-compiles api+tui for linux/darwin/windows (amd64+arm64, pure Go, no cgo), builds the Fyne GUI for linux/amd64 only (cgo + its OpenGL/X11 link means it can't cross-compile from one Linux runner the way api/tui do, and this repo has no registered macOS/Windows runner to build it on natively - documented in the workflow's comments), builds the web app, and creates a Gitea release with all of it attached via the Gitea API. Written to Gitea's documented GitHub-Actions-compatible syntax but not executable from here - there's no Gitea server in this environment to run it against, unlike everything else in this commit, which was. Verified: every Go package builds/vets/tests clean; sqlc diff and go mod tidy -diff are clean; the Angular app builds for production and its test suite passes via `npm test`/`npm run build` (the exact commands CI runs), including after a clean `npm ci` from the committed lockfile; the new CORS config was checked with real preflight and GET requests against a running server, not just read. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
356 lines
11 KiB
Go
356 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"completed/internal/db"
|
|
"completed/internal/model"
|
|
)
|
|
|
|
func RegisterRoutes(api huma.API, queries db.Querier) {
|
|
// Root/Hello Route
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "get-hello",
|
|
Tags: []string{"Misc"},
|
|
Method: http.MethodGet,
|
|
Path: "/hello",
|
|
Summary: "Say Hello",
|
|
Description: "Returns a friendly hello message.",
|
|
}, func(ctx context.Context, input *struct{}) (*struct {
|
|
Body struct {
|
|
Message string `json:"message"`
|
|
}
|
|
}, error) {
|
|
resp := &struct {
|
|
Body struct {
|
|
Message string `json:"message"`
|
|
}
|
|
}{}
|
|
resp.Body.Message = "Hello from Huma + Echo!"
|
|
return resp, nil
|
|
})
|
|
|
|
registerAuthRoutes(api, queries)
|
|
registerPlatformRoutes(api, queries)
|
|
registerGameRoutes(api, queries)
|
|
}
|
|
|
|
// --- Platform Routes ---
|
|
//
|
|
// Platforms are a shared list: every authenticated user reads the same
|
|
// rows, unlike games, which are private per user. None of the queries
|
|
// below filter by user_id, because platform has no such column. Reading
|
|
// the list only requires being logged in (addAuth); adding, renaming or
|
|
// deleting a platform requires being an admin (addAdminAuth), since that
|
|
// changes what everyone else sees.
|
|
|
|
func registerPlatformRoutes(api huma.API, queries db.Querier) {
|
|
listOp := huma.Operation{
|
|
OperationID: "list-platforms",
|
|
Tags: []string{"Platforms"},
|
|
Method: http.MethodGet,
|
|
Path: "/api/platforms",
|
|
Summary: "List Platforms",
|
|
}
|
|
addAuth(&listOp, api, queries)
|
|
huma.Register(api, listOp, func(ctx context.Context, input *struct{}) (*struct{ Body []model.Platform }, error) {
|
|
platforms, err := queries.ListPlatforms(ctx)
|
|
if err != nil {
|
|
return nil, dbError(err)
|
|
}
|
|
dtos := make([]model.Platform, len(platforms))
|
|
for i, p := range platforms {
|
|
dtos[i] = model.Platform{ID: p.ID.String(), Name: p.Name}
|
|
}
|
|
return &struct{ Body []model.Platform }{Body: dtos}, nil
|
|
})
|
|
|
|
getOp := huma.Operation{
|
|
OperationID: "get-platform",
|
|
Tags: []string{"Platforms"},
|
|
Method: http.MethodGet,
|
|
Path: "/api/platforms/{id}",
|
|
Summary: "Get Platform",
|
|
}
|
|
addAuth(&getOp, api, queries)
|
|
huma.Register(api, getOp, func(ctx context.Context, input *struct {
|
|
ID uuid.UUID `path:"id"`
|
|
}) (*struct{ Body model.Platform }, error) {
|
|
p, err := queries.GetPlatform(ctx, input.ID)
|
|
if err != nil {
|
|
return nil, dbError(err)
|
|
}
|
|
return &struct{ Body model.Platform }{Body: model.Platform{ID: p.ID.String(), Name: p.Name}}, nil
|
|
})
|
|
|
|
createOp := huma.Operation{
|
|
OperationID: "create-platform",
|
|
Tags: []string{"Platforms"},
|
|
Method: http.MethodPost,
|
|
Path: "/api/platforms",
|
|
Summary: "Create Platform",
|
|
}
|
|
addAdminAuth(&createOp, api, queries)
|
|
huma.Register(api, createOp, func(ctx context.Context, input *struct{ Body model.PlatformInput }) (*struct{ Body model.Platform }, error) {
|
|
platform, err := queries.CreatePlatform(ctx, input.Body.Name)
|
|
if err != nil {
|
|
return nil, dbError(err)
|
|
}
|
|
return &struct{ Body model.Platform }{Body: model.Platform{ID: platform.ID.String(), Name: platform.Name}}, nil
|
|
})
|
|
|
|
updateOp := huma.Operation{
|
|
OperationID: "update-platform",
|
|
Tags: []string{"Platforms"},
|
|
Method: http.MethodPut,
|
|
Path: "/api/platforms/{id}",
|
|
Summary: "Update Platform",
|
|
}
|
|
addAdminAuth(&updateOp, api, queries)
|
|
huma.Register(api, updateOp, func(ctx context.Context, input *struct {
|
|
ID uuid.UUID `path:"id"`
|
|
Body model.PlatformInput
|
|
}) (*struct{ Body model.Platform }, error) {
|
|
platform, err := queries.UpdatePlatform(ctx, db.UpdatePlatformParams{
|
|
ID: input.ID,
|
|
Name: input.Body.Name,
|
|
})
|
|
if err != nil {
|
|
return nil, dbError(err)
|
|
}
|
|
return &struct{ Body model.Platform }{Body: model.Platform{ID: platform.ID.String(), Name: platform.Name}}, nil
|
|
})
|
|
|
|
deleteOp := huma.Operation{
|
|
OperationID: "delete-platform",
|
|
Tags: []string{"Platforms"},
|
|
Method: http.MethodDelete,
|
|
Path: "/api/platforms/{id}",
|
|
Summary: "Delete Platform",
|
|
}
|
|
addAdminAuth(&deleteOp, api, queries)
|
|
huma.Register(api, deleteOp, func(ctx context.Context, input *struct {
|
|
ID uuid.UUID `path:"id"`
|
|
}) (*struct{}, error) {
|
|
// Checked separately so a 0-rows result from DeletePlatform below can
|
|
// only mean "blocked by games referencing it", not "doesn't exist" -
|
|
// soft delete means the FK alone can't tell the two apart (see
|
|
// DeletePlatform's comment in db/query.sql). Accepts a race where
|
|
// the platform is deleted between these two queries; harmless, since
|
|
// the second query would then just affect 0 rows and report 409
|
|
// instead of the 404 a plain retry would hit anyway.
|
|
if _, err := queries.GetPlatform(ctx, input.ID); err != nil {
|
|
return nil, dbError(err)
|
|
}
|
|
|
|
rows, err := queries.DeletePlatform(ctx, input.ID)
|
|
if err != nil {
|
|
return nil, dbError(err)
|
|
}
|
|
if rows == 0 {
|
|
return nil, huma.Error409Conflict("platform still has games; delete or reassign them first")
|
|
}
|
|
return nil, nil
|
|
})
|
|
}
|
|
|
|
// --- Game Routes ---
|
|
|
|
// formatDate and parseDate convert between the API's YYYY-MM-DD strings and
|
|
// pgtype.Date. CreateGame, GetGame, ListGames and UpdateGame each return a
|
|
// distinct sqlc row type with identical fields (id/name/platform_id/
|
|
// platform_name/score/release_date/finished), so the DTO conversion itself
|
|
// is written out at each call site rather than factored into one function.
|
|
func formatDate(d pgtype.Date) string {
|
|
if !d.Valid {
|
|
return ""
|
|
}
|
|
return d.Time.Format("2006-01-02")
|
|
}
|
|
|
|
// parseDate parses a YYYY-MM-DD string into a pgtype.Date. An empty string
|
|
// parses to an invalid/NULL date, which is valid for the optional
|
|
// "finished" field but rejected separately for the required release_date.
|
|
func parseDate(s string) (pgtype.Date, error) {
|
|
if s == "" {
|
|
return pgtype.Date{}, nil
|
|
}
|
|
t, err := time.Parse("2006-01-02", s)
|
|
if err != nil {
|
|
return pgtype.Date{}, err
|
|
}
|
|
return pgtype.Date{Time: t, Valid: true}, nil
|
|
}
|
|
|
|
func registerGameRoutes(api huma.API, queries db.Querier) {
|
|
listOp := huma.Operation{
|
|
OperationID: "list-games",
|
|
Tags: []string{"Games"},
|
|
Method: http.MethodGet,
|
|
Path: "/api/games",
|
|
Summary: "List Games",
|
|
}
|
|
addAuth(&listOp, api, queries)
|
|
huma.Register(api, listOp, func(ctx context.Context, input *struct{}) (*struct{ Body []model.Game }, error) {
|
|
games, err := queries.ListGames(ctx, userFromContext(ctx).ID)
|
|
if err != nil {
|
|
return nil, dbError(err)
|
|
}
|
|
dtos := make([]model.Game, len(games))
|
|
for i, g := range games {
|
|
dtos[i] = model.Game{
|
|
ID: g.ID.String(),
|
|
Name: g.Name,
|
|
PlatformID: g.PlatformID.String(),
|
|
PlatformName: g.PlatformName,
|
|
Score: g.Score,
|
|
ReleaseDate: formatDate(g.ReleaseDate),
|
|
Finished: formatDate(g.Finished),
|
|
}
|
|
}
|
|
return &struct{ Body []model.Game }{Body: dtos}, nil
|
|
})
|
|
|
|
getOp := huma.Operation{
|
|
OperationID: "get-game",
|
|
Tags: []string{"Games"},
|
|
Method: http.MethodGet,
|
|
Path: "/api/games/{id}",
|
|
Summary: "Get Game",
|
|
}
|
|
addAuth(&getOp, api, queries)
|
|
huma.Register(api, getOp, func(ctx context.Context, input *struct {
|
|
ID uuid.UUID `path:"id"`
|
|
}) (*struct{ Body model.Game }, error) {
|
|
g, err := queries.GetGame(ctx, db.GetGameParams{ID: input.ID, UserID: userFromContext(ctx).ID})
|
|
if err != nil {
|
|
return nil, dbError(err)
|
|
}
|
|
return &struct{ Body model.Game }{Body: model.Game{
|
|
ID: g.ID.String(),
|
|
Name: g.Name,
|
|
PlatformID: g.PlatformID.String(),
|
|
PlatformName: g.PlatformName,
|
|
Score: g.Score,
|
|
ReleaseDate: formatDate(g.ReleaseDate),
|
|
Finished: formatDate(g.Finished),
|
|
}}, nil
|
|
})
|
|
|
|
createOp := huma.Operation{
|
|
OperationID: "create-game",
|
|
Tags: []string{"Games"},
|
|
Method: http.MethodPost,
|
|
Path: "/api/games",
|
|
Summary: "Create Game",
|
|
}
|
|
addAuth(&createOp, api, queries)
|
|
huma.Register(api, createOp, func(ctx context.Context, input *struct{ Body model.GameInput }) (*struct{ Body model.Game }, error) {
|
|
platformID, err := uuid.Parse(input.Body.PlatformID)
|
|
if err != nil {
|
|
return nil, huma.Error400BadRequest("invalid platform_id")
|
|
}
|
|
ry, err := parseDate(input.Body.ReleaseDate)
|
|
if err != nil || !ry.Valid {
|
|
return nil, huma.Error400BadRequest("invalid or missing release_date (expected YYYY-MM-DD)")
|
|
}
|
|
fin, err := parseDate(input.Body.Finished)
|
|
if err != nil {
|
|
return nil, huma.Error400BadRequest("invalid finished date (expected YYYY-MM-DD)")
|
|
}
|
|
|
|
game, err := queries.CreateGame(ctx, db.CreateGameParams{
|
|
UserID: userFromContext(ctx).ID,
|
|
PlatformID: platformID,
|
|
Name: input.Body.Name,
|
|
Score: input.Body.Score,
|
|
ReleaseDate: ry,
|
|
Finished: fin,
|
|
})
|
|
if err != nil {
|
|
return nil, dbError(err)
|
|
}
|
|
return &struct{ Body model.Game }{Body: model.Game{
|
|
ID: game.ID.String(),
|
|
Name: game.Name,
|
|
PlatformID: game.PlatformID.String(),
|
|
PlatformName: game.PlatformName,
|
|
Score: game.Score,
|
|
ReleaseDate: formatDate(game.ReleaseDate),
|
|
Finished: formatDate(game.Finished),
|
|
}}, nil
|
|
})
|
|
|
|
updateOp := huma.Operation{
|
|
OperationID: "update-game",
|
|
Tags: []string{"Games"},
|
|
Method: http.MethodPut,
|
|
Path: "/api/games/{id}",
|
|
Summary: "Update Game",
|
|
}
|
|
addAuth(&updateOp, api, queries)
|
|
huma.Register(api, updateOp, func(ctx context.Context, input *struct {
|
|
ID uuid.UUID `path:"id"`
|
|
Body model.GameInput
|
|
}) (*struct{ Body model.Game }, error) {
|
|
platformID, err := uuid.Parse(input.Body.PlatformID)
|
|
if err != nil {
|
|
return nil, huma.Error400BadRequest("invalid platform_id")
|
|
}
|
|
ry, err := parseDate(input.Body.ReleaseDate)
|
|
if err != nil || !ry.Valid {
|
|
return nil, huma.Error400BadRequest("invalid or missing release_date (expected YYYY-MM-DD)")
|
|
}
|
|
fin, err := parseDate(input.Body.Finished)
|
|
if err != nil {
|
|
return nil, huma.Error400BadRequest("invalid finished date (expected YYYY-MM-DD)")
|
|
}
|
|
|
|
game, err := queries.UpdateGame(ctx, db.UpdateGameParams{
|
|
ID: input.ID,
|
|
UserID: userFromContext(ctx).ID,
|
|
PlatformID: platformID,
|
|
Name: input.Body.Name,
|
|
Score: input.Body.Score,
|
|
ReleaseDate: ry,
|
|
Finished: fin,
|
|
})
|
|
if err != nil {
|
|
return nil, dbError(err)
|
|
}
|
|
return &struct{ Body model.Game }{Body: model.Game{
|
|
ID: game.ID.String(),
|
|
Name: game.Name,
|
|
PlatformID: game.PlatformID.String(),
|
|
PlatformName: game.PlatformName,
|
|
Score: game.Score,
|
|
ReleaseDate: formatDate(game.ReleaseDate),
|
|
Finished: formatDate(game.Finished),
|
|
}}, nil
|
|
})
|
|
|
|
deleteOp := huma.Operation{
|
|
OperationID: "delete-game",
|
|
Tags: []string{"Games"},
|
|
Method: http.MethodDelete,
|
|
Path: "/api/games/{id}",
|
|
Summary: "Delete Game",
|
|
}
|
|
addAuth(&deleteOp, api, queries)
|
|
huma.Register(api, deleteOp, func(ctx context.Context, input *struct {
|
|
ID uuid.UUID `path:"id"`
|
|
}) (*struct{}, error) {
|
|
rows, err := queries.DeleteGame(ctx, db.DeleteGameParams{ID: input.ID, UserID: userFromContext(ctx).ID})
|
|
if err != nil {
|
|
return nil, dbError(err)
|
|
}
|
|
return nil, notFoundIfZero(rows)
|
|
})
|
|
}
|