// 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) }