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>
This commit is contained in:
SansanandClaude Sonnet 5 committed 2026-10-03 11:24:11 +02:00
1 parent 47a9719eeb
commit 5072919b61
34 files changed
+1913 -512

No files matched your search

+160
View File
@@ -0,0 +1,160 @@
package api
import (
"context"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"completed/internal/auth"
"completed/internal/db"
"completed/internal/model"
)
type authOutput struct {
SetCookie string `header:"Set-Cookie"`
Body model.AuthResponse
}
// sessionCookie builds the Set-Cookie value for a successful login. Secure
// is left unset because the app is also meant to run over plain HTTP in
// local/docker-compose use; put a TLS-terminating proxy in front for
// anything reachable over the network and set Secure there instead.
func sessionCookie(token string) string {
c := http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: auth.SessionDuration,
}
return c.String()
}
func clearedSessionCookie() string {
c := http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
}
return c.String()
}
// newSession creates and stores a session for userID, returning the token
// to hand back to the client (only its hash is ever persisted).
func newSession(ctx context.Context, queries db.Querier, userID uuid.UUID) (string, error) {
token, tokenHash, err := auth.NewSessionToken()
if err != nil {
return "", err
}
err = queries.CreateSession(ctx, db.CreateSessionParams{
TokenHash: tokenHash,
UserID: userID,
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(auth.SessionDuration * time.Second), Valid: true},
})
return token, err
}
func registerAuthRoutes(api huma.API, queries db.Querier) {
huma.Register(api, huma.Operation{
OperationID: "register",
Method: http.MethodPost,
Path: "/api/auth/register",
Summary: "Register a new account",
}, func(ctx context.Context, input *struct{ Body model.RegisterInput }) (*authOutput, error) {
hash, err := auth.HashPassword(input.Body.Password)
if err != nil {
return nil, huma.Error500InternalServerError("could not process password")
}
u, err := queries.CreateUser(ctx, db.CreateUserParams{
Email: input.Body.Email,
PasswordHash: hash,
Name: input.Body.Name,
Bio: pgtype.Text{},
})
if err != nil {
return nil, dbError(err)
}
token, err := newSession(ctx, queries, u.ID)
if err != nil {
return nil, huma.Error500InternalServerError("could not create session")
}
return &authOutput{
SetCookie: sessionCookie(token),
Body: model.AuthResponse{
Token: token,
User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name},
},
}, nil
})
huma.Register(api, huma.Operation{
OperationID: "login",
Method: http.MethodPost,
Path: "/api/auth/login",
Summary: "Log in to an existing account",
}, func(ctx context.Context, input *struct{ Body model.LoginInput }) (*authOutput, error) {
u, err := queries.GetUserByEmail(ctx, input.Body.Email)
if err != nil {
// Same message whether the email is unknown or the password is
// wrong, so a client can't use this endpoint to enumerate
// registered emails.
return nil, huma.Error401Unauthorized("invalid email or password")
}
ok, err := auth.VerifyPassword(input.Body.Password, u.PasswordHash)
if err != nil || !ok {
return nil, huma.Error401Unauthorized("invalid email or password")
}
token, err := newSession(ctx, queries, u.ID)
if err != nil {
return nil, huma.Error500InternalServerError("could not create session")
}
return &authOutput{
SetCookie: sessionCookie(token),
Body: model.AuthResponse{
Token: token,
User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name},
},
}, nil
})
logoutOp := huma.Operation{
OperationID: "logout",
Method: http.MethodPost,
Path: "/api/auth/logout",
Summary: "Log out of the current session",
}
addAuth(&logoutOp, api, queries)
huma.Register(api, logoutOp, func(ctx context.Context, input *struct{}) (*struct {
SetCookie string `header:"Set-Cookie"`
}, error) {
_ = queries.DeleteSession(ctx, auth.HashSessionToken(tokenFromContext(ctx)))
return &struct {
SetCookie string `header:"Set-Cookie"`
}{SetCookie: clearedSessionCookie()}, nil
})
meOp := huma.Operation{
OperationID: "get-me",
Method: http.MethodGet,
Path: "/api/auth/me",
Summary: "Get the current user",
}
addAuth(&meOp, api, queries)
huma.Register(api, meOp, func(ctx context.Context, input *struct{}) (*struct{ Body model.User }, error) {
return &struct{ Body model.User }{Body: userFromContext(ctx).toModel()}, nil
})
}
+38
View File
@@ -0,0 +1,38 @@
package api
import (
"context"
"github.com/google/uuid"
"completed/internal/model"
)
// authedUser is what AuthMiddleware stores in the request context after
// validating a session token. Handlers read it with userFromContext.
type authedUser struct {
ID uuid.UUID
Email string
Name string
Bio string
}
type ctxKey string
const userCtxKey ctxKey = "completed.user"
func (u authedUser) toModel() model.User {
return model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, Bio: u.Bio}
}
// userFromContext returns the authenticated user set by AuthMiddleware. It
// panics if called from a route that isn't behind AuthMiddleware, since that
// is a programming error (a handler trusting a context value that was never
// guaranteed to be there), not a condition callers should need to check.
func userFromContext(ctx context.Context) authedUser {
u, ok := ctx.Value(userCtxKey).(authedUser)
if !ok {
panic("api: userFromContext called outside AuthMiddleware")
}
return u
}
+52
View File
@@ -0,0 +1,52 @@
package api
import (
"errors"
"log"
"github.com/danielgtaylor/huma/v2"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// Postgres error codes this package maps to specific HTTP statuses.
// See https://www.postgresql.org/docs/current/errcodes-appendix.html
const (
pgCodeUniqueViolation = "23505"
pgCodeForeignKeyViolation = "23503"
)
// dbError turns a database error into the huma error a handler should
// return, so Postgres's own error text never reaches a client and callers
// get the right HTTP status instead of a generic 500.
func dbError(err error) error {
if errors.Is(err, pgx.ErrNoRows) {
return huma.Error404NotFound("not found")
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case pgCodeUniqueViolation:
return huma.Error409Conflict("already exists")
case pgCodeForeignKeyViolation:
// A foreign key failure here is either a reference to a platform
// that doesn't exist (create/update) or a delete blocked by
// games that still reference this platform.
return huma.Error409Conflict("referenced by another record, or refers to one that doesn't exist")
}
}
log.Printf("unmapped database error: %v", err)
return huma.Error500InternalServerError("internal error")
}
// notFoundIfZero turns the "0 rows affected" result of a soft-delete
// UPDATE into a 404, since the row either never existed or was already
// deleted.
func notFoundIfZero(rows int64) error {
if rows == 0 {
return huma.Error404NotFound("not found")
}
return nil
}
+88
View File
@@ -0,0 +1,88 @@
package api
import (
"context"
"errors"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/jackc/pgx/v5"
"completed/internal/auth"
"completed/internal/db"
)
// sessionCookieName is also used by the auth routes when setting/clearing
// the cookie, so it lives here as the one shared constant.
const sessionCookieName = "session"
// bearerToken extracts the token from "Authorization: Bearer <token>", or
// the empty string if the header is missing or malformed.
func bearerToken(header string) string {
const prefix = "Bearer "
if !strings.HasPrefix(header, prefix) {
return ""
}
return strings.TrimPrefix(header, prefix)
}
// tokenFromRequest looks for a session token in the Authorization header
// first (used by the TUI and Fyne clients), falling back to the session
// cookie (used by the web client).
func tokenFromRequest(ctx huma.Context) string {
if t := bearerToken(ctx.Header("Authorization")); t != "" {
return t
}
if c, err := huma.ReadCookie(ctx, sessionCookieName); err == nil && c != nil {
return c.Value
}
return ""
}
// newAuthMiddleware returns Huma middleware that requires a valid session,
// set via AuthMiddleware on individual operations. On success it stores the
// authenticated user in the request context for userFromContext to read.
func newAuthMiddleware(api huma.API, queries db.Querier) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
token := tokenFromRequest(ctx)
if token == "" {
huma.WriteErr(api, ctx, 401, "authentication required")
return
}
row, err := queries.GetSessionAndUser(ctx.Context(), auth.HashSessionToken(token))
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
// Fail closed: an unreachable DB fails auth, not through.
huma.WriteErr(api, ctx, 401, "authentication required")
return
}
huma.WriteErr(api, ctx, 401, "session expired or invalid")
return
}
// Best-effort; a failure to record activity shouldn't fail the request.
_ = queries.TouchSession(ctx.Context(), auth.HashSessionToken(token))
user := authedUser{ID: row.ID, Email: row.Email, Name: row.Name, Bio: row.Bio.String}
newCtx := huma.WithValue(ctx, userCtxKey, user)
newCtx = huma.WithValue(newCtx, tokenCtxKey, token)
next(newCtx)
}
}
// addAuth attaches the auth middleware to op, so it requires a valid
// session. Call it on the *huma.Operation value before huma.Register.
func addAuth(op *huma.Operation, api huma.API, queries db.Querier) {
op.Middlewares = append(op.Middlewares, newAuthMiddleware(api, queries))
}
// tokenCtxKey holds the raw bearer/cookie token for the current request, so
// the logout handler can hash and delete the exact session that was used
// without re-parsing the request.
const tokenCtxKey ctxKey = "completed.token"
func tokenFromContext(ctx context.Context) string {
t, _ := ctx.Value(tokenCtxKey).(string)
return t
}
+58 -35
View File
@@ -3,61 +3,84 @@ package api
import (
"context"
"github.com/google/uuid"
"completed/internal/db"
)
// MockQuerier is a hand-written fake implementing db.Querier for handler
// tests. Each test sets only the *Func fields its handler path reaches;
// any other field left nil panics on call, which is deliberate: it makes an
// unexpectedly-reached query visible as a test failure instead of a nil
// pointer deep in sqlc-generated code.
type MockQuerier struct {
CreateGameFunc func(ctx context.Context, arg db.CreateGameParams) (db.Game, error)
CreatePlatformFunc func(ctx context.Context, name string) (db.Platform, error)
CreateUserFunc func(ctx context.Context, arg db.CreateUserParams) (db.User, error)
DeleteGameFunc func(ctx context.Context, id int64) error
DeletePlatformFunc func(ctx context.Context, id int64) error
DeleteUserFunc func(ctx context.Context, id int64) error
GetGameFunc func(ctx context.Context, id int64) (db.Game, error)
GetUserFunc func(ctx context.Context, id int64) (db.User, error)
ListGamesFunc func(ctx context.Context) ([]db.Game, error)
ListPlatformsFunc func(ctx context.Context) ([]db.Platform, error)
ListUsersFunc func(ctx context.Context) ([]db.User, error)
UpdateGameFunc func(ctx context.Context, arg db.UpdateGameParams) (db.Game, error)
UpdatePlatformFunc func(ctx context.Context, arg db.UpdatePlatformParams) (db.Platform, error)
CreateGameFunc func(ctx context.Context, arg db.CreateGameParams) (db.CreateGameRow, error)
CreatePlatformFunc func(ctx context.Context, arg db.CreatePlatformParams) (db.CreatePlatformRow, error)
CreateSessionFunc func(ctx context.Context, arg db.CreateSessionParams) error
CreateUserFunc func(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error)
DeleteGameFunc func(ctx context.Context, arg db.DeleteGameParams) (int64, error)
DeletePlatformFunc func(ctx context.Context, arg db.DeletePlatformParams) (int64, error)
DeleteSessionFunc func(ctx context.Context, tokenHash []byte) error
GetGameFunc func(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error)
GetPlatformFunc func(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error)
GetSessionAndUserFunc func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error)
GetUserByEmailFunc func(ctx context.Context, email string) (db.GetUserByEmailRow, error)
GetUserByIDFunc func(ctx context.Context, id uuid.UUID) (db.GetUserByIDRow, error)
ListGamesFunc func(ctx context.Context, userID uuid.UUID) ([]db.ListGamesRow, error)
ListPlatformsFunc func(ctx context.Context, userID uuid.UUID) ([]db.ListPlatformsRow, error)
TouchSessionFunc func(ctx context.Context, tokenHash []byte) error
UpdateGameFunc func(ctx context.Context, arg db.UpdateGameParams) (db.UpdateGameRow, error)
UpdatePlatformFunc func(ctx context.Context, arg db.UpdatePlatformParams) (db.UpdatePlatformRow, error)
}
func (m *MockQuerier) CreateGame(ctx context.Context, arg db.CreateGameParams) (db.Game, error) {
func (m *MockQuerier) CreateGame(ctx context.Context, arg db.CreateGameParams) (db.CreateGameRow, error) {
return m.CreateGameFunc(ctx, arg)
}
func (m *MockQuerier) CreatePlatform(ctx context.Context, name string) (db.Platform, error) {
return m.CreatePlatformFunc(ctx, name)
func (m *MockQuerier) CreatePlatform(ctx context.Context, arg db.CreatePlatformParams) (db.CreatePlatformRow, error) {
return m.CreatePlatformFunc(ctx, arg)
}
func (m *MockQuerier) CreateUser(ctx context.Context, arg db.CreateUserParams) (db.User, error) {
func (m *MockQuerier) CreateSession(ctx context.Context, arg db.CreateSessionParams) error {
return m.CreateSessionFunc(ctx, arg)
}
func (m *MockQuerier) CreateUser(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error) {
return m.CreateUserFunc(ctx, arg)
}
func (m *MockQuerier) DeleteGame(ctx context.Context, id int64) error {
return m.DeleteGameFunc(ctx, id)
func (m *MockQuerier) DeleteGame(ctx context.Context, arg db.DeleteGameParams) (int64, error) {
return m.DeleteGameFunc(ctx, arg)
}
func (m *MockQuerier) DeletePlatform(ctx context.Context, id int64) error {
return m.DeletePlatformFunc(ctx, id)
func (m *MockQuerier) DeletePlatform(ctx context.Context, arg db.DeletePlatformParams) (int64, error) {
return m.DeletePlatformFunc(ctx, arg)
}
func (m *MockQuerier) DeleteUser(ctx context.Context, id int64) error {
return m.DeleteUserFunc(ctx, id)
func (m *MockQuerier) DeleteSession(ctx context.Context, tokenHash []byte) error {
return m.DeleteSessionFunc(ctx, tokenHash)
}
func (m *MockQuerier) GetGame(ctx context.Context, id int64) (db.Game, error) {
return m.GetGameFunc(ctx, id)
func (m *MockQuerier) GetGame(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error) {
return m.GetGameFunc(ctx, arg)
}
func (m *MockQuerier) GetUser(ctx context.Context, id int64) (db.User, error) {
return m.GetUserFunc(ctx, id)
func (m *MockQuerier) GetPlatform(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) {
return m.GetPlatformFunc(ctx, arg)
}
func (m *MockQuerier) ListGames(ctx context.Context) ([]db.Game, error) {
return m.ListGamesFunc(ctx)
func (m *MockQuerier) GetSessionAndUser(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) {
return m.GetSessionAndUserFunc(ctx, tokenHash)
}
func (m *MockQuerier) ListPlatforms(ctx context.Context) ([]db.Platform, error) {
return m.ListPlatformsFunc(ctx)
func (m *MockQuerier) GetUserByEmail(ctx context.Context, email string) (db.GetUserByEmailRow, error) {
return m.GetUserByEmailFunc(ctx, email)
}
func (m *MockQuerier) ListUsers(ctx context.Context) ([]db.User, error) {
return m.ListUsersFunc(ctx)
func (m *MockQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (db.GetUserByIDRow, error) {
return m.GetUserByIDFunc(ctx, id)
}
func (m *MockQuerier) UpdateGame(ctx context.Context, arg db.UpdateGameParams) (db.Game, error) {
func (m *MockQuerier) ListGames(ctx context.Context, userID uuid.UUID) ([]db.ListGamesRow, error) {
return m.ListGamesFunc(ctx, userID)
}
func (m *MockQuerier) ListPlatforms(ctx context.Context, userID uuid.UUID) ([]db.ListPlatformsRow, error) {
return m.ListPlatformsFunc(ctx, userID)
}
func (m *MockQuerier) TouchSession(ctx context.Context, tokenHash []byte) error {
return m.TouchSessionFunc(ctx, tokenHash)
}
func (m *MockQuerier) UpdateGame(ctx context.Context, arg db.UpdateGameParams) (db.UpdateGameRow, error) {
return m.UpdateGameFunc(ctx, arg)
}
func (m *MockQuerier) UpdatePlatform(ctx context.Context, arg db.UpdatePlatformParams) (db.Platform, error) {
func (m *MockQuerier) UpdatePlatform(ctx context.Context, arg db.UpdatePlatformParams) (db.UpdatePlatformRow, error) {
return m.UpdatePlatformFunc(ctx, arg)
}
+202 -117
View File
@@ -6,9 +6,11 @@ import (
"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) {
@@ -33,83 +35,118 @@ func RegisterRoutes(api huma.API, queries db.Querier) {
return resp, nil
})
registerAuthRoutes(api, queries)
registerPlatformRoutes(api, queries)
registerGameRoutes(api, queries)
}
// --- Platform Routes ---
// PlatformDTO ensures consistent JSON output
type PlatformDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
type PlatformInput struct {
Name string `json:"name" doc:"Name of the platform"`
}
func registerPlatformRoutes(api huma.API, queries db.Querier) {
huma.Register(api, huma.Operation{
listOp := huma.Operation{
OperationID: "list-platforms",
Method: http.MethodGet,
Path: "/platforms",
Path: "/api/platforms",
Summary: "List Platforms",
}, func(ctx context.Context, input *struct{}) (*struct{ Body []PlatformDTO }, error) {
platforms, err := queries.ListPlatforms(ctx)
}
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, huma.Error500InternalServerError("Database error: " + err.Error())
return nil, dbError(err)
}
dtos := make([]PlatformDTO, len(platforms))
dtos := make([]model.Platform, len(platforms))
for i, p := range platforms {
dtos[i] = PlatformDTO{ID: p.ID, Name: p.Name}
dtos[i] = model.Platform{ID: p.ID.String(), Name: p.Name}
}
return &struct{ Body []PlatformDTO }{Body: dtos}, nil
return &struct{ Body []model.Platform }{Body: dtos}, nil
})
huma.Register(api, huma.Operation{
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: "/platforms",
Path: "/api/platforms",
Summary: "Create Platform",
}, func(ctx context.Context, input *struct{ Body PlatformInput }) (*struct{ Body PlatformDTO }, error) {
platform, err := queries.CreatePlatform(ctx, input.Body.Name)
if err != nil {
return nil, huma.Error500InternalServerError("Database error: " + err.Error())
}
return &struct{ Body PlatformDTO }{Body: PlatformDTO{ID: platform.ID, Name: platform.Name}}, nil
})
huma.Register(api, huma.Operation{
OperationID: "update-platform",
Method: http.MethodPut,
Path: "/platforms/{id}",
Summary: "Update Platform",
}, func(ctx context.Context, input *struct {
ID int64 `path:"id"`
Body PlatformInput
}) (*struct{ Body PlatformDTO }, error) {
platform, err := queries.UpdatePlatform(ctx, db.UpdatePlatformParams{
ID: input.ID,
Name: input.Body.Name,
}
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, huma.Error500InternalServerError("Database error: " + err.Error())
return nil, dbError(err)
}
return &struct{ Body PlatformDTO }{Body: PlatformDTO{ID: platform.ID, Name: platform.Name}}, nil
return &struct{ Body model.Platform }{Body: model.Platform{ID: platform.ID.String(), Name: platform.Name}}, nil
})
huma.Register(api, huma.Operation{
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: "/platforms/{id}",
Path: "/api/platforms/{id}",
Summary: "Delete Platform",
}, func(ctx context.Context, input *struct {
ID int64 `path:"id"`
}
addAuth(&deleteOp, api, queries)
huma.Register(api, deleteOp, func(ctx context.Context, input *struct {
ID uuid.UUID `path:"id"`
}) (*struct{}, error) {
err := queries.DeletePlatform(ctx, input.ID)
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, huma.Error500InternalServerError("Database error: " + err.Error())
return nil, dbError(err)
}
if rows == 0 {
return nil, huma.Error409Conflict("platform still has games; delete or reassign them first")
}
return nil, nil
})
@@ -117,40 +154,21 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
// --- Game Routes ---
// GameDTO for API communication (Clean JSON)
type GameDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
PlatformID int64 `json:"platform_id"`
Score int32 `json:"score"`
ReleaseYear string `json:"release_year" format:"date" doc:"YYYY-MM-DD"`
Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"`
}
type GameInput struct {
Name string `json:"name"`
PlatformID int64 `json:"platform_id"`
Score int32 `json:"score"`
ReleaseYear string `json:"release_year" format:"date" doc:"YYYY-MM-DD"`
Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"`
}
// Helper to convert DB Game to DTO
func toGameDTO(g db.Game) GameDTO {
dto := GameDTO{
ID: g.ID,
Name: g.Name,
PlatformID: g.PlatformID,
Score: g.Score,
ReleaseYear: g.ReleaseYear.Time.Format("2006-01-02"),
// 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 ""
}
if g.Finished.Valid {
dto.Finished = g.Finished.Time.Format("2006-01-02")
}
return dto
return d.Time.Format("2006-01-02")
}
// Helper to parse date string to pgtype.Date
// 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
@@ -163,95 +181,162 @@ func parseDate(s string) (pgtype.Date, error) {
}
func registerGameRoutes(api huma.API, queries db.Querier) {
huma.Register(api, huma.Operation{
listOp := huma.Operation{
OperationID: "list-games",
Method: http.MethodGet,
Path: "/games",
Path: "/api/games",
Summary: "List Games",
}, func(ctx context.Context, input *struct{}) (*struct{ Body []GameDTO }, error) {
games, err := queries.ListGames(ctx)
}
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, huma.Error500InternalServerError("Database error: " + err.Error())
return nil, dbError(err)
}
dtos := make([]GameDTO, len(games))
dtos := make([]model.Game, len(games))
for i, g := range games {
dtos[i] = toGameDTO(g)
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 []GameDTO }{Body: dtos}, nil
return &struct{ Body []model.Game }{Body: dtos}, nil
})
huma.Register(api, huma.Operation{
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: "/games",
Path: "/api/games",
Summary: "Create Game",
}, func(ctx context.Context, input *struct{ Body GameInput }) (*struct{ Body GameDTO }, error) {
ry, err := parseDate(input.Body.ReleaseYear)
}
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 release_year format (expected YYYY-MM-DD)")
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 format (expected YYYY-MM-DD)")
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,
PlatformID: input.Body.PlatformID,
Score: input.Body.Score,
ReleaseYear: ry,
ReleaseDate: ry,
Finished: fin,
})
if err != nil {
return nil, huma.Error500InternalServerError("Database error: " + err.Error())
return nil, dbError(err)
}
return &struct{ Body GameDTO }{Body: toGameDTO(game)}, nil
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
})
huma.Register(api, huma.Operation{
updateOp := huma.Operation{
OperationID: "update-game",
Method: http.MethodPut,
Path: "/games/{id}",
Path: "/api/games/{id}",
Summary: "Update Game",
}, func(ctx context.Context, input *struct {
ID int64 `path:"id"`
Body GameInput
}) (*struct{ Body GameDTO }, error) {
ry, err := parseDate(input.Body.ReleaseYear)
}
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 release_year format (expected YYYY-MM-DD)")
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 format (expected YYYY-MM-DD)")
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,
PlatformID: input.Body.PlatformID,
Score: input.Body.Score,
ReleaseYear: ry,
ReleaseDate: ry,
Finished: fin,
})
if err != nil {
return nil, huma.Error500InternalServerError("Database error: " + err.Error())
return nil, dbError(err)
}
return &struct{ Body GameDTO }{Body: toGameDTO(game)}, nil
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
})
huma.Register(api, huma.Operation{
deleteOp := huma.Operation{
OperationID: "delete-game",
Method: http.MethodDelete,
Path: "/games/{id}",
Path: "/api/games/{id}",
Summary: "Delete Game",
}, func(ctx context.Context, input *struct {
ID int64 `path:"id"`
}
addAuth(&deleteOp, api, queries)
huma.Register(api, deleteOp, func(ctx context.Context, input *struct {
ID uuid.UUID `path:"id"`
}) (*struct{}, error) {
err := queries.DeleteGame(ctx, input.ID)
rows, err := queries.DeleteGame(ctx, db.DeleteGameParams{ID: input.ID, UserID: userFromContext(ctx).ID})
if err != nil {
return nil, huma.Error500InternalServerError("Database error: " + err.Error())
return nil, dbError(err)
}
return nil, nil
return nil, notFoundIfZero(rows)
})
}
+194 -29
View File
@@ -9,58 +9,223 @@ import (
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"completed/internal/auth"
"completed/internal/db"
)
func TestListGames(t *testing.T) {
// 1. Setup Mock
mockDB := &MockQuerier{
ListGamesFunc: func(ctx context.Context) ([]db.Game, error) {
return []db.Game{
{ID: 1, Name: "Test Game", Score: 90},
}, nil
},
}
// 2. Setup API
// newTestAPI wires mockDB into a fresh Huma/Echo instance exactly as
// cmd/api/main.go does, so tests exercise routing and middleware, not just
// the handler bodies.
func newTestAPI(mockDB db.Querier) (*echo.Echo, huma.API) {
e := echo.New()
api := humaecho.New(e, huma.DefaultConfig("Test API", "1.0.0"))
RegisterRoutes(api, mockDB)
return e, api
}
// 3. Execute Request
req := httptest.NewRequest(http.MethodGet, "/games", nil)
// a fixed valid session, returned by GetSessionAndUserFunc so authed
// requests don't need a real database.
var testUserID = uuid.New()
func authedMock() *MockQuerier {
return &MockQuerier{
GetSessionAndUserFunc: func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) {
return db.GetSessionAndUserRow{ID: testUserID, Email: "a@example.com", Name: "Alice"}, nil
},
TouchSessionFunc: func(ctx context.Context, tokenHash []byte) error { return nil },
}
}
func authedRequest(method, path, body string) *http.Request {
var req *http.Request
if body == "" {
req = httptest.NewRequest(method, path, nil)
} else {
req = httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer any-token-the-mock-accepts")
return req
}
func TestListGamesRequiresAuth(t *testing.T) {
e, _ := newTestAPI(&MockQuerier{})
req := httptest.NewRequest(http.MethodGet, "/api/games", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
// 4. Assert
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestListGames(t *testing.T) {
mockDB := authedMock()
mockDB.ListGamesFunc = func(ctx context.Context, userID uuid.UUID) ([]db.ListGamesRow, error) {
assert.Equal(t, testUserID, userID, "handler must scope the query to the authenticated user")
return []db.ListGamesRow{
{ID: uuid.New(), Name: "Test Game", Score: 90, PlatformName: "PC"},
}, nil
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/games", ""))
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "Test Game")
assert.Contains(t, rec.Body.String(), `"platform_name":"PC"`)
}
func TestGetGameNotFoundForOtherUsersGame(t *testing.T) {
// A game that exists, but belongs to someone else: the query is scoped
// by user_id, so sqlc/postgres report it as not found, same as a
// genuinely missing id. The handler must turn that into 404, not 500.
mockDB := authedMock()
mockDB.GetGameFunc = func(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error) {
assert.Equal(t, testUserID, arg.UserID, "handler must scope the lookup to the authenticated user")
return db.GetGameRow{}, pgx.ErrNoRows
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/games/"+uuid.New().String(), ""))
assert.Equal(t, http.StatusNotFound, rec.Code)
}
func TestDeleteGameNotFoundWhenNoRowsAffected(t *testing.T) {
mockDB := authedMock()
mockDB.DeleteGameFunc = func(ctx context.Context, arg db.DeleteGameParams) (int64, error) {
return 0, nil
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/games/"+uuid.New().String(), ""))
assert.Equal(t, http.StatusNotFound, rec.Code)
}
func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) {
// Soft delete means the FK is never actually checked by Postgres (see
// db/query.sql's DeletePlatform comment), so the handler has to notice
// the 0-rows result itself and turn it into 409, not just pass through
// whatever error (if any) the query returns.
mockDB := authedMock()
mockDB.GetPlatformFunc = func(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) {
return db.GetPlatformRow{ID: arg.ID, Name: "PC"}, nil
}
mockDB.DeletePlatformFunc = func(ctx context.Context, arg db.DeletePlatformParams) (int64, error) {
return 0, nil
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/platforms/"+uuid.New().String(), ""))
assert.Equal(t, http.StatusConflict, rec.Code)
}
func TestDeletePlatformNotFound(t *testing.T) {
mockDB := authedMock()
mockDB.GetPlatformFunc = func(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) {
return db.GetPlatformRow{}, pgx.ErrNoRows
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/platforms/"+uuid.New().String(), ""))
assert.Equal(t, http.StatusNotFound, rec.Code)
}
func TestCreateGameRejectsScoreOutOfRange(t *testing.T) {
mockDB := authedMock()
// No CreateGameFunc set: validation must reject the request before the
// handler ever calls the database, or this test would panic.
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
body := `{"name":"Bad","platform_id":"` + uuid.New().String() + `","score":150,"release_date":"2020-01-01"}`
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/games", body))
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
}
func TestCreatePlatform(t *testing.T) {
// 1. Setup Mock
mockDB := authedMock()
mockDB.CreatePlatformFunc = func(ctx context.Context, arg db.CreatePlatformParams) (db.CreatePlatformRow, error) {
assert.Equal(t, testUserID, arg.UserID)
return db.CreatePlatformRow{ID: uuid.New(), Name: arg.Name}, nil
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/platforms", `{"name": "Sega Genesis"}`))
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "Sega Genesis")
}
func TestRegisterDuplicateEmailConflicts(t *testing.T) {
mockDB := &MockQuerier{
CreatePlatformFunc: func(ctx context.Context, name string) (db.Platform, error) {
return db.Platform{ID: 1, Name: name}, nil
CreateUserFunc: func(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error) {
return db.CreateUserRow{}, &pgconn.PgError{Code: pgCodeUniqueViolation}
},
}
// 2. Setup API
e := echo.New()
api := humaecho.New(e, huma.DefaultConfig("Test API", "1.0.0"))
RegisterRoutes(api, mockDB)
// 3. Execute Request
req := httptest.NewRequest(http.MethodPost, "/platforms", strings.NewReader(`{"name": "Sega Genesis"}`))
req.Header.Set("Content-Type", "application/json")
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
body := `{"email":"a@example.com","password":"password123","name":"Alice"}`
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/register", body))
// 4. Assert
assert.Equal(t, http.StatusConflict, rec.Code)
}
func TestLoginWrongPasswordUnauthorized(t *testing.T) {
hash, err := auth.HashPassword("correct-password")
require.NoError(t, err)
mockDB := &MockQuerier{
GetUserByEmailFunc: func(ctx context.Context, email string) (db.GetUserByEmailRow, error) {
return db.GetUserByEmailRow{ID: testUserID, Email: email, PasswordHash: hash}, nil
},
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
body := `{"email":"a@example.com","password":"wrong-password"}`
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/login", body))
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestLoginSuccessSetsSessionCookie(t *testing.T) {
hash, err := auth.HashPassword("correct-password")
require.NoError(t, err)
mockDB := &MockQuerier{
GetUserByEmailFunc: func(ctx context.Context, email string) (db.GetUserByEmailRow, error) {
return db.GetUserByEmailRow{ID: testUserID, Email: email, Name: "Alice", PasswordHash: hash}, nil
},
CreateSessionFunc: func(ctx context.Context, arg db.CreateSessionParams) error {
assert.Equal(t, testUserID, arg.UserID)
return nil
},
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
body := `{"email":"a@example.com","password":"correct-password"}`
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/login", body))
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "Sega Genesis")
assert.Contains(t, rec.Body.String(), `"id":1`)
assert.Contains(t, rec.Header().Get("Set-Cookie"), "session=")
}
+69
View File
@@ -0,0 +1,69 @@
// Package auth handles password hashing and session tokens.
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2id parameters, taken from the RFC 9106 "low memory" recommendation
// (2nd choice) as a reasonable default for a server handling interactive
// logins without dedicated hardware.
const (
argonTime = 2
argonMemory = 19 * 1024 // KiB
argonThreads = 1
argonKeyLen = 32
saltLen = 16
)
// HashPassword returns a self-describing hash string: algorithm, parameters,
// salt and key are all encoded together so the parameters can change later
// without invalidating hashes already stored.
func HashPassword(password string) (string, error) {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Key := base64.RawStdEncoding.EncodeToString(key)
return fmt.Sprintf("argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
argonMemory, argonTime, argonThreads, b64Salt, b64Key), nil
}
// VerifyPassword checks password against an encoded hash produced by
// HashPassword. It returns an error only on malformed input; a wrong
// password is reported via the bool return.
func VerifyPassword(password, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 5 || parts[0] != "argon2id" {
return false, errors.New("unrecognized hash format")
}
var memory, time uint32
var threads uint8
if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
return false, fmt.Errorf("parse hash params: %w", err)
}
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return false, fmt.Errorf("decode salt: %w", err)
}
wantKey, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, fmt.Errorf("decode key: %w", err)
}
gotKey := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(wantKey)))
return subtle.ConstantTimeCompare(gotKey, wantKey) == 1, nil
}
+51
View File
@@ -0,0 +1,51 @@
package auth
import "testing"
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
ok, err := VerifyPassword("correct horse battery staple", hash)
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if !ok {
t.Error("VerifyPassword returned false for the correct password")
}
ok, err = VerifyPassword("wrong password", hash)
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if ok {
t.Error("VerifyPassword returned true for an incorrect password")
}
}
func TestVerifyPasswordMalformedHash(t *testing.T) {
if _, err := VerifyPassword("anything", "not-a-valid-hash"); err == nil {
t.Error("expected an error for a malformed hash, got nil")
}
}
func TestNewSessionTokenIsUnpredictableAndHashVerifiable(t *testing.T) {
token1, hash1, err := NewSessionToken()
if err != nil {
t.Fatalf("NewSessionToken: %v", err)
}
token2, _, err := NewSessionToken()
if err != nil {
t.Fatalf("NewSessionToken: %v", err)
}
if token1 == token2 {
t.Error("two calls to NewSessionToken produced the same token")
}
gotHash := HashSessionToken(token1)
if string(gotHash) != string(hash1) {
t.Error("HashSessionToken(token) does not match the hash returned alongside it")
}
}
+37
View File
@@ -0,0 +1,37 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
)
// SessionDuration controls how long a login stays valid without activity.
// TouchSession bumps last_seen_at on each authenticated request, but
// expires_at (set at login) is what actually gates access.
const SessionDuration = 30 * 24 * 60 * 60 // 30 days, in seconds
// tokenLen is deliberately large: this is the only secret standing between
// an attacker and a full account take-over, so it gets more entropy than a
// password would need.
const tokenLen = 32
// NewSessionToken returns a random token to hand to the client, and the hash
// of it to store in the database. Only the hash is ever persisted, so a
// leaked database dump doesn't also leak usable session tokens.
func NewSessionToken() (token string, hash []byte, err error) {
raw := make([]byte, tokenLen)
if _, err := rand.Read(raw); err != nil {
return "", nil, fmt.Errorf("generate token: %w", err)
}
token = base64.RawURLEncoding.EncodeToString(raw)
return token, HashSessionToken(token), nil
}
// HashSessionToken hashes a token presented by a client so it can be looked
// up against the stored hash.
func HashSessionToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.31.1
package db
+30 -9
View File
@@ -1,29 +1,50 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.31.1
package db
import (
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type Game struct {
ID int64
ID uuid.UUID
UserID uuid.UUID
PlatformID uuid.UUID
Name string
PlatformID int64
Score int32
ReleaseYear pgtype.Date
ReleaseDate pgtype.Date
Finished pgtype.Date
UpdatedAt pgtype.Timestamptz
DeletedAt pgtype.Timestamptz
ChangeSeq int64
}
type Platform struct {
ID int64
Name string
ID uuid.UUID
UserID uuid.UUID
Name string
UpdatedAt pgtype.Timestamptz
DeletedAt pgtype.Timestamptz
ChangeSeq int64
}
type Session struct {
TokenHash []byte
UserID uuid.UUID
CreatedAt pgtype.Timestamptz
ExpiresAt pgtype.Timestamptz
LastSeenAt pgtype.Timestamptz
}
type User struct {
ID int64
Name string
Bio pgtype.Text
ID uuid.UUID
Email string
PasswordHash string
Name string
Bio pgtype.Text
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
+24 -14
View File
@@ -1,27 +1,37 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.31.1
package db
import (
"context"
"github.com/google/uuid"
)
type Querier interface {
CreateGame(ctx context.Context, arg CreateGameParams) (Game, error)
CreatePlatform(ctx context.Context, name string) (Platform, error)
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
DeleteGame(ctx context.Context, id int64) error
DeletePlatform(ctx context.Context, id int64) error
DeleteUser(ctx context.Context, id int64) error
GetGame(ctx context.Context, id int64) (Game, error)
GetUser(ctx context.Context, id int64) (User, error)
ListGames(ctx context.Context) ([]Game, error)
ListPlatforms(ctx context.Context) ([]Platform, error)
ListUsers(ctx context.Context) ([]User, error)
UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, error)
UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (Platform, error)
CreateGame(ctx context.Context, arg CreateGameParams) (CreateGameRow, error)
CreatePlatform(ctx context.Context, arg CreatePlatformParams) (CreatePlatformRow, error)
CreateSession(ctx context.Context, arg CreateSessionParams) error
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, error)
// Soft delete, so the FK on game.platform_id is never touched and never
// blocks this on its own; the NOT EXISTS guard is what stops a platform
// that still has active games from being deleted. Call GetPlatform first to
// tell "not found" apart from "blocked by games" when this affects 0 rows.
DeletePlatform(ctx context.Context, arg DeletePlatformParams) (int64, error)
DeleteSession(ctx context.Context, tokenHash []byte) error
GetGame(ctx context.Context, arg GetGameParams) (GetGameRow, error)
GetPlatform(ctx context.Context, arg GetPlatformParams) (GetPlatformRow, error)
GetSessionAndUser(ctx context.Context, tokenHash []byte) (GetSessionAndUserRow, error)
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error)
ListGames(ctx context.Context, userID uuid.UUID) ([]ListGamesRow, error)
ListPlatforms(ctx context.Context, userID uuid.UUID) ([]ListPlatformsRow, error)
TouchSession(ctx context.Context, tokenHash []byte) error
UpdateGame(ctx context.Context, arg UpdateGameParams) (UpdateGameRow, error)
UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (UpdatePlatformRow, error)
}
var _ Querier = (*Queries)(nil)
+353 -110
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.31.1
// source: query.sql
package db
@@ -8,41 +8,61 @@ package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createGame = `-- name: CreateGame :one
INSERT INTO game (
name, platform_id, score, release_year, finished
) VALUES (
$1, $2, $3, $4, $5
WITH inserted AS (
INSERT INTO game (
user_id, platform_id, name, score, release_date, finished
) VALUES (
$1, $2, $3, $4, $5, $6
)
RETURNING id, platform_id, name, score, release_date, finished
)
RETURNING id, name, platform_id, score, release_year, finished
SELECT inserted.id, inserted.name, inserted.platform_id, platform.name AS platform_name,
inserted.score, inserted.release_date, inserted.finished
FROM inserted
JOIN platform ON platform.id = inserted.platform_id
`
type CreateGameParams struct {
UserID uuid.UUID
PlatformID uuid.UUID
Name string
PlatformID int64
Score int32
ReleaseYear pgtype.Date
ReleaseDate pgtype.Date
Finished pgtype.Date
}
func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) {
type CreateGameRow struct {
ID uuid.UUID
Name string
PlatformID uuid.UUID
PlatformName string
Score int32
ReleaseDate pgtype.Date
Finished pgtype.Date
}
func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (CreateGameRow, error) {
row := q.db.QueryRow(ctx, createGame,
arg.Name,
arg.UserID,
arg.PlatformID,
arg.Name,
arg.Score,
arg.ReleaseYear,
arg.ReleaseDate,
arg.Finished,
)
var i Game
var i CreateGameRow
err := row.Scan(
&i.ID,
&i.Name,
&i.PlatformID,
&i.PlatformName,
&i.Score,
&i.ReleaseYear,
&i.ReleaseDate,
&i.Finished,
)
return i, err
@@ -50,122 +70,327 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e
const createPlatform = `-- name: CreatePlatform :one
INSERT INTO platform (
name
user_id, name
) VALUES (
$1
$1, $2
)
RETURNING id, name
`
func (q *Queries) CreatePlatform(ctx context.Context, name string) (Platform, error) {
row := q.db.QueryRow(ctx, createPlatform, name)
var i Platform
type CreatePlatformParams struct {
UserID uuid.UUID
Name string
}
type CreatePlatformRow struct {
ID uuid.UUID
Name string
}
func (q *Queries) CreatePlatform(ctx context.Context, arg CreatePlatformParams) (CreatePlatformRow, error) {
row := q.db.QueryRow(ctx, createPlatform, arg.UserID, arg.Name)
var i CreatePlatformRow
err := row.Scan(&i.ID, &i.Name)
return i, err
}
const createSession = `-- name: CreateSession :exec
INSERT INTO sessions (
token_hash, user_id, expires_at
) VALUES (
$1, $2, $3
)
`
type CreateSessionParams struct {
TokenHash []byte
UserID uuid.UUID
ExpiresAt pgtype.Timestamptz
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error {
_, err := q.db.Exec(ctx, createSession, arg.TokenHash, arg.UserID, arg.ExpiresAt)
return err
}
const createUser = `-- name: CreateUser :one
INSERT INTO users (
name, bio
email, password_hash, name, bio
) VALUES (
$1, $2
$1, $2, $3, $4
)
RETURNING id, name, bio
RETURNING id, email, name, bio, created_at
`
type CreateUserParams struct {
Name string
Bio pgtype.Text
Email string
PasswordHash string
Name string
Bio pgtype.Text
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRow(ctx, createUser, arg.Name, arg.Bio)
var i User
err := row.Scan(&i.ID, &i.Name, &i.Bio)
type CreateUserRow struct {
ID uuid.UUID
Email string
Name string
Bio pgtype.Text
CreatedAt pgtype.Timestamptz
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
row := q.db.QueryRow(ctx, createUser,
arg.Email,
arg.PasswordHash,
arg.Name,
arg.Bio,
)
var i CreateUserRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.Bio,
&i.CreatedAt,
)
return i, err
}
const deleteGame = `-- name: DeleteGame :exec
DELETE FROM game
WHERE id = $1
const deleteGame = `-- name: DeleteGame :execrows
UPDATE game
SET deleted_at = now()
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
`
func (q *Queries) DeleteGame(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, deleteGame, id)
return err
type DeleteGameParams struct {
ID uuid.UUID
UserID uuid.UUID
}
const deletePlatform = `-- name: DeletePlatform :exec
DELETE FROM platform
WHERE id = $1
`
func (q *Queries) DeletePlatform(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, deletePlatform, id)
return err
func (q *Queries) DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, error) {
result, err := q.db.Exec(ctx, deleteGame, arg.ID, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const deleteUser = `-- name: DeleteUser :exec
DELETE FROM users
WHERE id = $1
const deletePlatform = `-- name: DeletePlatform :execrows
UPDATE platform
SET deleted_at = now()
WHERE platform.id = $1 AND platform.user_id = $2 AND platform.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM game
WHERE game.platform_id = platform.id AND game.deleted_at IS NULL
)
`
func (q *Queries) DeleteUser(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, deleteUser, id)
type DeletePlatformParams struct {
ID uuid.UUID
UserID uuid.UUID
}
// Soft delete, so the FK on game.platform_id is never touched and never
// blocks this on its own; the NOT EXISTS guard is what stops a platform
// that still has active games from being deleted. Call GetPlatform first to
// tell "not found" apart from "blocked by games" when this affects 0 rows.
func (q *Queries) DeletePlatform(ctx context.Context, arg DeletePlatformParams) (int64, error) {
result, err := q.db.Exec(ctx, deletePlatform, arg.ID, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const deleteSession = `-- name: DeleteSession :exec
DELETE FROM sessions
WHERE token_hash = $1
`
func (q *Queries) DeleteSession(ctx context.Context, tokenHash []byte) error {
_, err := q.db.Exec(ctx, deleteSession, tokenHash)
return err
}
const getGame = `-- name: GetGame :one
SELECT id, name, platform_id, score, release_year, finished FROM game
WHERE id = $1 LIMIT 1
SELECT game.id, game.name, game.platform_id, platform.name AS platform_name,
game.score, game.release_date, game.finished
FROM game
JOIN platform ON platform.id = game.platform_id
WHERE game.id = $1 AND game.user_id = $2 AND game.deleted_at IS NULL
`
func (q *Queries) GetGame(ctx context.Context, id int64) (Game, error) {
row := q.db.QueryRow(ctx, getGame, id)
var i Game
type GetGameParams struct {
ID uuid.UUID
UserID uuid.UUID
}
type GetGameRow struct {
ID uuid.UUID
Name string
PlatformID uuid.UUID
PlatformName string
Score int32
ReleaseDate pgtype.Date
Finished pgtype.Date
}
func (q *Queries) GetGame(ctx context.Context, arg GetGameParams) (GetGameRow, error) {
row := q.db.QueryRow(ctx, getGame, arg.ID, arg.UserID)
var i GetGameRow
err := row.Scan(
&i.ID,
&i.Name,
&i.PlatformID,
&i.PlatformName,
&i.Score,
&i.ReleaseYear,
&i.ReleaseDate,
&i.Finished,
)
return i, err
}
const getUser = `-- name: GetUser :one
SELECT id, name, bio FROM users
WHERE id = $1 LIMIT 1
const getPlatform = `-- name: GetPlatform :one
SELECT id, name
FROM platform
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
`
func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) {
row := q.db.QueryRow(ctx, getUser, id)
var i User
err := row.Scan(&i.ID, &i.Name, &i.Bio)
type GetPlatformParams struct {
ID uuid.UUID
UserID uuid.UUID
}
type GetPlatformRow struct {
ID uuid.UUID
Name string
}
func (q *Queries) GetPlatform(ctx context.Context, arg GetPlatformParams) (GetPlatformRow, error) {
row := q.db.QueryRow(ctx, getPlatform, arg.ID, arg.UserID)
var i GetPlatformRow
err := row.Scan(&i.ID, &i.Name)
return i, err
}
const getSessionAndUser = `-- name: GetSessionAndUser :one
SELECT users.id, users.email, users.name, users.bio, sessions.expires_at
FROM sessions
JOIN users ON users.id = sessions.user_id
WHERE sessions.token_hash = $1 AND sessions.expires_at > now()
`
type GetSessionAndUserRow struct {
ID uuid.UUID
Email string
Name string
Bio pgtype.Text
ExpiresAt pgtype.Timestamptz
}
func (q *Queries) GetSessionAndUser(ctx context.Context, tokenHash []byte) (GetSessionAndUserRow, error) {
row := q.db.QueryRow(ctx, getSessionAndUser, tokenHash)
var i GetSessionAndUserRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.Bio,
&i.ExpiresAt,
)
return i, err
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, password_hash, name, bio, created_at
FROM users
WHERE email = $1
`
type GetUserByEmailRow struct {
ID uuid.UUID
Email string
PasswordHash string
Name string
Bio pgtype.Text
CreatedAt pgtype.Timestamptz
}
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) {
row := q.db.QueryRow(ctx, getUserByEmail, email)
var i GetUserByEmailRow
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.Name,
&i.Bio,
&i.CreatedAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, email, name, bio, created_at
FROM users
WHERE id = $1
`
type GetUserByIDRow struct {
ID uuid.UUID
Email string
Name string
Bio pgtype.Text
CreatedAt pgtype.Timestamptz
}
func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error) {
row := q.db.QueryRow(ctx, getUserByID, id)
var i GetUserByIDRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.Bio,
&i.CreatedAt,
)
return i, err
}
const listGames = `-- name: ListGames :many
SELECT id, name, platform_id, score, release_year, finished FROM game
ORDER BY name
SELECT game.id, game.name, game.platform_id, platform.name AS platform_name,
game.score, game.release_date, game.finished
FROM game
JOIN platform ON platform.id = game.platform_id
WHERE game.user_id = $1 AND game.deleted_at IS NULL
ORDER BY game.name
`
func (q *Queries) ListGames(ctx context.Context) ([]Game, error) {
rows, err := q.db.Query(ctx, listGames)
type ListGamesRow struct {
ID uuid.UUID
Name string
PlatformID uuid.UUID
PlatformName string
Score int32
ReleaseDate pgtype.Date
Finished pgtype.Date
}
func (q *Queries) ListGames(ctx context.Context, userID uuid.UUID) ([]ListGamesRow, error) {
rows, err := q.db.Query(ctx, listGames, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Game
var items []ListGamesRow
for rows.Next() {
var i Game
var i ListGamesRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.PlatformID,
&i.PlatformName,
&i.Score,
&i.ReleaseYear,
&i.ReleaseDate,
&i.Finished,
); err != nil {
return nil, err
@@ -179,19 +404,26 @@ func (q *Queries) ListGames(ctx context.Context) ([]Game, error) {
}
const listPlatforms = `-- name: ListPlatforms :many
SELECT id, name FROM platform
SELECT id, name
FROM platform
WHERE user_id = $1 AND deleted_at IS NULL
ORDER BY name
`
func (q *Queries) ListPlatforms(ctx context.Context) ([]Platform, error) {
rows, err := q.db.Query(ctx, listPlatforms)
type ListPlatformsRow struct {
ID uuid.UUID
Name string
}
func (q *Queries) ListPlatforms(ctx context.Context, userID uuid.UUID) ([]ListPlatformsRow, error) {
rows, err := q.db.Query(ctx, listPlatforms, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Platform
var items []ListPlatformsRow
for rows.Next() {
var i Platform
var i ListPlatformsRow
if err := rows.Scan(&i.ID, &i.Name); err != nil {
return nil, err
}
@@ -203,63 +435,68 @@ func (q *Queries) ListPlatforms(ctx context.Context) ([]Platform, error) {
return items, nil
}
const listUsers = `-- name: ListUsers :many
SELECT id, name, bio FROM users
ORDER BY name
const touchSession = `-- name: TouchSession :exec
UPDATE sessions
SET last_seen_at = now()
WHERE token_hash = $1
`
func (q *Queries) ListUsers(ctx context.Context) ([]User, error) {
rows, err := q.db.Query(ctx, listUsers)
if err != nil {
return nil, err
}
defer rows.Close()
var items []User
for rows.Next() {
var i User
if err := rows.Scan(&i.ID, &i.Name, &i.Bio); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
func (q *Queries) TouchSession(ctx context.Context, tokenHash []byte) error {
_, err := q.db.Exec(ctx, touchSession, tokenHash)
return err
}
const updateGame = `-- name: UpdateGame :one
UPDATE game
SET name = $2, platform_id = $3, score = $4, release_year = $5, finished = $6
WHERE id = $1
RETURNING id, name, platform_id, score, release_year, finished
WITH updated AS (
UPDATE game
SET platform_id = $3, name = $4, score = $5, release_date = $6, finished = $7
WHERE game.id = $1 AND game.user_id = $2 AND game.deleted_at IS NULL
RETURNING id, platform_id, name, score, release_date, finished
)
SELECT updated.id, updated.name, updated.platform_id, platform.name AS platform_name,
updated.score, updated.release_date, updated.finished
FROM updated
JOIN platform ON platform.id = updated.platform_id
`
type UpdateGameParams struct {
ID int64
ID uuid.UUID
UserID uuid.UUID
PlatformID uuid.UUID
Name string
PlatformID int64
Score int32
ReleaseYear pgtype.Date
ReleaseDate pgtype.Date
Finished pgtype.Date
}
func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, error) {
type UpdateGameRow struct {
ID uuid.UUID
Name string
PlatformID uuid.UUID
PlatformName string
Score int32
ReleaseDate pgtype.Date
Finished pgtype.Date
}
func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (UpdateGameRow, error) {
row := q.db.QueryRow(ctx, updateGame,
arg.ID,
arg.Name,
arg.UserID,
arg.PlatformID,
arg.Name,
arg.Score,
arg.ReleaseYear,
arg.ReleaseDate,
arg.Finished,
)
var i Game
var i UpdateGameRow
err := row.Scan(
&i.ID,
&i.Name,
&i.PlatformID,
&i.PlatformName,
&i.Score,
&i.ReleaseYear,
&i.ReleaseDate,
&i.Finished,
)
return i, err
@@ -267,19 +504,25 @@ func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, e
const updatePlatform = `-- name: UpdatePlatform :one
UPDATE platform
SET name = $2
WHERE id = $1
SET name = $3
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
RETURNING id, name
`
type UpdatePlatformParams struct {
ID int64
ID uuid.UUID
UserID uuid.UUID
Name string
}
type UpdatePlatformRow struct {
ID uuid.UUID
Name string
}
func (q *Queries) UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (Platform, error) {
row := q.db.QueryRow(ctx, updatePlatform, arg.ID, arg.Name)
var i Platform
func (q *Queries) UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (UpdatePlatformRow, error) {
row := q.db.QueryRow(ctx, updatePlatform, arg.ID, arg.UserID, arg.Name)
var i UpdatePlatformRow
err := row.Scan(&i.ID, &i.Name)
return i, err
}
+58
View File
@@ -0,0 +1,58 @@
// Package model holds the plain data types shared between the API and its
// clients. It has no dependency on Huma, Echo or the database, so clients
// can import it without pulling in the server.
package model
// User is the public view of an account; it never carries the password hash.
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Bio string `json:"bio,omitempty"`
}
type RegisterInput struct {
Email string `json:"email" format:"email"`
Password string `json:"password" minLength:"8"`
Name string `json:"name" minLength:"1"`
}
type LoginInput struct {
Email string `json:"email" format:"email"`
Password string `json:"password"`
}
// AuthResponse is returned by register and login. Token is also set as an
// httpOnly cookie, but is included in the body so non-browser clients (the
// TUI, the Fyne GUI) can store and send it themselves.
type AuthResponse struct {
Token string `json:"token"`
User User `json:"user"`
}
type Platform struct {
ID string `json:"id"`
Name string `json:"name"`
}
type PlatformInput struct {
Name string `json:"name" minLength:"1" doc:"Name of the platform"`
}
type Game struct {
ID string `json:"id"`
Name string `json:"name"`
PlatformID string `json:"platform_id"`
PlatformName string `json:"platform_name"`
Score int32 `json:"score"`
ReleaseDate string `json:"release_date" format:"date" doc:"YYYY-MM-DD"`
Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"`
}
type GameInput struct {
Name string `json:"name" minLength:"1"`
PlatformID string `json:"platform_id"`
Score int32 `json:"score" minimum:"0" maximum:"100"`
ReleaseDate string `json:"release_date" format:"date" doc:"YYYY-MM-DD"`
Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"`
}