Files
Completed/internal/api/routes.go
T
SansanandClaude Sonnet 5 5072919b61 Rebuild the schema and API on a working, multi-user foundation
Steps 1-4 of the review plan (docs/plans not checked in; see conversation):

Step 1 - make it boot from a clean checkout:
- Replace the old schema.sql + single ALTER-TABLE migration (which never
  created the tables) with one init migration: users, sessions, platform,
  game, all with UUIDv7-ready uuid ids, soft deletes (deleted_at), and a
  shared change_seq sequence + trigger for a future sync feature.
- sqlc now reads the schema from db/migrations directly.
- Migrations are embedded in the binary (db/migrations/embed.go) instead of
  being copied around and found via path fallbacks.
- ensureDatabase/runMigrations failures are now fatal, and main pings the
  pool after connecting, instead of starting an API with no schema.
- Switched both the app and golang-migrate to pgx/v5 (dropped lib/pq).
- Fixed the Dockerfile's Go version (1.23 -> 1.25) and build target.
- Added a postgres healthcheck to docker-compose so the app waits for a
  ready database instead of racing it.
- go.mod tidied, bin/ added to .gitignore, Justfile's docker-compose calls
  switched to the v2 `docker compose` plugin.

Step 2 - auth and per-user data:
- internal/auth: argon2id password hashing, random session tokens stored
  only as a hash.
- POST /api/auth/{register,login,logout}, GET /api/auth/me. Sessions work
  via either an Authorization: Bearer header or an httpOnly cookie.
- Every platform/game query is scoped to the authenticated user.

Step 3 - API correctness:
- internal/model holds the DTOs, with no server-side dependencies, so
  clients can import it later without pulling in Huma/Echo.
- dbError() maps ErrNoRows -> 404, unique violation -> 409, FK violation ->
  409, instead of leaking Postgres error text with a 500.
- Soft-delete meant the FK on game.platform_id no longer blocks deleting a
  referenced platform on its own; added a NOT EXISTS guard plus an
  application-level check so that case now correctly returns 409 instead of
  silently succeeding (caught by testing the live server, not by a mock).
- Added GET /games/{id} and GET /platforms/{id}. All JSON routes moved
  under /api, freeing / for a future web client.
- ListGames/GetGame join platform_name in.
- cmd/openapi writes the generated spec to api/openapi.json.

Step 4 - tests and CI:
- Rewrote the mocks and handler tests for the new Querier shape; added
  coverage for 401 (no session), 404 (not found / another user's row),
  409 (duplicate email, platform referenced by games), 422 (score out of
  range), and that queries are actually scoped by the authenticated user's
  id, not just that the right status code comes back.
- .github/workflows/ci.yml: go vet, go test, go mod tidy -diff, sqlc diff.

Everything above was exercised against a real local Postgres (migrations
up/down, the trigger, constraints) and through the running server via curl
(register, login, scoping, every status-code case), not just unit tests.

Known regression, intentionally deferred to the next step: cmd/tui and
cmd/gui still call the old unauthenticated GET /games, which no longer
exists. They need internal/client and a login flow before they work again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 11:24:11 +02:00

343 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",
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 ---
func registerPlatformRoutes(api huma.API, queries db.Querier) {
listOp := huma.Operation{
OperationID: "list-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, userFromContext(ctx).ID)
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",
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, db.GetPlatformParams{ID: input.ID, UserID: userFromContext(ctx).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",
Method: http.MethodPost,
Path: "/api/platforms",
Summary: "Create Platform",
}
addAuth(&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, db.CreatePlatformParams{
UserID: userFromContext(ctx).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
})
updateOp := huma.Operation{
OperationID: "update-platform",
Method: http.MethodPut,
Path: "/api/platforms/{id}",
Summary: "Update Platform",
}
addAuth(&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,
UserID: userFromContext(ctx).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",
Method: http.MethodDelete,
Path: "/api/platforms/{id}",
Summary: "Delete Platform",
}
addAuth(&deleteOp, api, queries)
huma.Register(api, deleteOp, func(ctx context.Context, input *struct {
ID uuid.UUID `path:"id"`
}) (*struct{}, error) {
userID := userFromContext(ctx).ID
// 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, db.GetPlatformParams{ID: input.ID, UserID: userID}); err != nil {
return nil, dbError(err)
}
rows, err := queries.DeletePlatform(ctx, db.DeletePlatformParams{ID: input.ID, UserID: userID})
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",
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",
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",
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",
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",
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)
})
}