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>
This commit is contained in:
SansanandClaude Sonnet 5 committed 2026-10-03 12:16:59 +02:00
1 parent 384c4a7217
commit 9972537b63
97 files changed
+11810 -336

No files matched your search

+218
View File
@@ -0,0 +1,218 @@
// Package client is a thin, typed HTTP client for the Completed API. It's
// the shared base for the TUI and the Fyne GUI: both hold a *Client and
// call its methods instead of each hand-rolling net/http calls, so request
// building, auth, and error handling live in one tested place.
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"completed/internal/model"
)
// DefaultBaseURL is used when no other base URL is configured. It matches
// cmd/api's own default port.
const DefaultBaseURL = "http://localhost:8080"
// Client talks to the Completed API. The zero value is not usable; create
// one with New. A Client is safe for concurrent use once Token is no
// longer being written concurrently with reads - in practice, callers set
// it only right after Login/Register, before starting concurrent calls.
type Client struct {
BaseURL string
Token string
httpClient *http.Client
}
// New returns a Client pointed at baseURL. An empty baseURL is replaced
// with DefaultBaseURL.
func New(baseURL string) *Client {
if baseURL == "" {
baseURL = DefaultBaseURL
}
return &Client{
BaseURL: baseURL,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// APIError is returned for any non-2xx response. It carries the status
// code so callers can distinguish, say, a 401 (show "wrong password") from
// a 500 (show "something went wrong").
type APIError struct {
Status int
Title string
Detail string
}
func (e *APIError) Error() string {
if e.Detail != "" {
return e.Detail
}
if e.Title != "" {
return e.Title
}
return fmt.Sprintf("request failed with status %d", e.Status)
}
// huma's error body shape; see internal/api's ErrorModel (from the huma
// package) in api/openapi.json.
type errorBody struct {
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail"`
}
// do sends an HTTP request and decodes a JSON response into out (if out is
// non-nil). It attaches the bearer token, if set, and turns any non-2xx
// response into an *APIError.
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("encode request body: %w", err)
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, bodyReader)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode >= 300 {
var eb errorBody
_ = json.Unmarshal(data, &eb) // best-effort; fall back to status alone
return &APIError{Status: resp.StatusCode, Title: eb.Title, Detail: eb.Detail}
}
if out != nil && len(data) > 0 {
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("decode response: %w", err)
}
}
return nil
}
// --- Auth ---
func (c *Client) Register(ctx context.Context, email, password, name string) (model.AuthResponse, error) {
var resp model.AuthResponse
err := c.do(ctx, http.MethodPost, "/api/auth/register", model.RegisterInput{
Email: email, Password: password, Name: name,
}, &resp)
if err == nil {
c.Token = resp.Token
}
return resp, err
}
func (c *Client) Login(ctx context.Context, email, password string) (model.AuthResponse, error) {
var resp model.AuthResponse
err := c.do(ctx, http.MethodPost, "/api/auth/login", model.LoginInput{
Email: email, Password: password,
}, &resp)
if err == nil {
c.Token = resp.Token
}
return resp, err
}
func (c *Client) Logout(ctx context.Context) error {
err := c.do(ctx, http.MethodPost, "/api/auth/logout", nil, nil)
c.Token = ""
return err
}
func (c *Client) Me(ctx context.Context) (model.User, error) {
var u model.User
err := c.do(ctx, http.MethodGet, "/api/auth/me", nil, &u)
return u, err
}
// --- Platforms ---
func (c *Client) ListPlatforms(ctx context.Context) ([]model.Platform, error) {
var platforms []model.Platform
err := c.do(ctx, http.MethodGet, "/api/platforms", nil, &platforms)
return platforms, err
}
func (c *Client) GetPlatform(ctx context.Context, id string) (model.Platform, error) {
var p model.Platform
err := c.do(ctx, http.MethodGet, "/api/platforms/"+id, nil, &p)
return p, err
}
// CreatePlatform requires the current user to be an admin; see
// internal/api/middleware.go's addAdminAuth.
func (c *Client) CreatePlatform(ctx context.Context, name string) (model.Platform, error) {
var p model.Platform
err := c.do(ctx, http.MethodPost, "/api/platforms", model.PlatformInput{Name: name}, &p)
return p, err
}
func (c *Client) UpdatePlatform(ctx context.Context, id, name string) (model.Platform, error) {
var p model.Platform
err := c.do(ctx, http.MethodPut, "/api/platforms/"+id, model.PlatformInput{Name: name}, &p)
return p, err
}
func (c *Client) DeletePlatform(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/api/platforms/"+id, nil, nil)
}
// --- Games ---
func (c *Client) ListGames(ctx context.Context) ([]model.Game, error) {
var games []model.Game
err := c.do(ctx, http.MethodGet, "/api/games", nil, &games)
return games, err
}
func (c *Client) GetGame(ctx context.Context, id string) (model.Game, error) {
var g model.Game
err := c.do(ctx, http.MethodGet, "/api/games/"+id, nil, &g)
return g, err
}
func (c *Client) CreateGame(ctx context.Context, in model.GameInput) (model.Game, error) {
var g model.Game
err := c.do(ctx, http.MethodPost, "/api/games", in, &g)
return g, err
}
func (c *Client) UpdateGame(ctx context.Context, id string, in model.GameInput) (model.Game, error) {
var g model.Game
err := c.do(ctx, http.MethodPut, "/api/games/"+id, in, &g)
return g, err
}
func (c *Client) DeleteGame(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/api/games/"+id, nil, nil)
}