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:
1 parent
384c4a7217
commit
9972537b63
97 files changed
+11810
-336
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user