Files
Completed/cmd/api/main.go
T
SansanandClaude Sonnet 5 9972537b63 Add a small, tested base for all three clients, and Gitea CI/release workflows
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>
2026-10-03 12:16:59 +02:00

154 lines
4.5 KiB
Go

package main
import (
"context"
"database/sql"
"fmt"
"net/url"
"os"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"completed/db/migrations"
"completed/internal/api"
"completed/internal/db"
)
// ensureDatabase creates the target database if it doesn't exist yet, by
// connecting to the server's default "postgres" database first. This is a
// convenience for local/compose use; a real deployment would provision the
// database up front and this would be a no-op every time.
func ensureDatabase(dbURL string) error {
u, err := url.Parse(dbURL)
if err != nil {
return fmt.Errorf("parse DATABASE_URL: %w", err)
}
dbName := strings.TrimPrefix(u.Path, "/")
if dbName == "" {
return fmt.Errorf("database name is empty in URL: %s", dbURL)
}
u.Path = "/postgres"
adminDB, err := sql.Open("pgx", u.String())
if err != nil {
return fmt.Errorf("open admin connection: %w", err)
}
defer adminDB.Close()
var exists bool
err = adminDB.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", dbName).Scan(&exists)
if err != nil {
return fmt.Errorf("check database existence: %w", err)
}
if exists {
return nil
}
fmt.Printf("Database %q does not exist. Creating...\n", dbName)
// Table/database identifiers can't be parameterized; dbName comes from
// our own DATABASE_URL, not untrusted request input.
if _, err := adminDB.Exec(fmt.Sprintf(`CREATE DATABASE "%s"`, dbName)); err != nil {
return fmt.Errorf("create database %q: %w", dbName, err)
}
fmt.Printf("Database %q created.\n", dbName)
return nil
}
// runMigrations applies pending migrations, embedded into the binary via
// db/migrations/embed.go. A failure here is fatal: the alternative is an API
// that starts and answers requests against a schema that was never created.
func runMigrations(dbURL string) error {
source, err := iofs.New(migrations.FS, ".")
if err != nil {
return fmt.Errorf("load embedded migrations: %w", err)
}
// golang-migrate's pgx/v5 driver is registered under the "pgx5" scheme;
// swap it in regardless of whether DATABASE_URL says postgres:// or
// postgresql://.
u, err := url.Parse(dbURL)
if err != nil {
return fmt.Errorf("parse DATABASE_URL: %w", err)
}
u.Scheme = "pgx5"
m, err := migrate.NewWithSourceInstance("iofs", source, u.String())
if err != nil {
return fmt.Errorf("init migrator: %w", err)
}
defer m.Close()
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("apply migrations: %w", err)
}
fmt.Println("Migrations applied.")
return nil
}
func main() {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
// Matches docker-compose.yaml's db service, so `just run-api`
// against `just docker-up`'s database works without extra setup.
dbURL = "postgres://postgres:password@localhost:5432/gamedb?sslmode=disable"
}
if err := ensureDatabase(dbURL); err != nil {
fmt.Fprintf(os.Stderr, "Failed to ensure database exists: %v\n", err)
os.Exit(1)
}
if err := runMigrations(dbURL); err != nil {
fmt.Fprintf(os.Stderr, "Failed to run migrations: %v\n", err)
os.Exit(1)
}
pool, err := pgxpool.New(context.Background(), dbURL)
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to create connection pool: %v\n", err)
os.Exit(1)
}
defer pool.Close()
if err := pool.Ping(context.Background()); err != nil {
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
os.Exit(1)
}
queries := db.New(pool)
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// The web client (e.g. the Angular dev server on localhost:4200) calls
// this API from a different origin. It authenticates with a bearer
// token, not cookies, so this doesn't need AllowCredentials - which
// means a wildcard origin is safe here. Tighten AllowOrigins to the
// real deployed origin(s) if this ever serves more than local dev.
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowHeaders: []string{echo.HeaderContentType, echo.HeaderAuthorization},
}))
humaApi := humaecho.New(e, huma.DefaultConfig("Completed API", "1.0.0"))
api.RegisterRoutes(humaApi, queries)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
e.Logger.Fatal(e.Start(":" + port))
}