# AGENTS.md — Horchposten Game analytics REST API backend written in Go with Echo v4 and SQLite. ## Build / Run / Test ### Build ```bash # Local (requires CGo + gcc for go-sqlite3) CGO_ENABLED=1 go build -o horchposten . # Static binary (for containers / scratch base) CGO_ENABLED=1 go build \ -ldflags='-s -w -extldflags "-static"' \ -tags 'netgo,osusergo' \ -trimpath \ -o horchposten . ``` ### Container build + deploy to local k3s ```bash ./build.sh ``` This runs `podman build`, exports the image, and imports it into k3s containerd. ### Run Required environment variables: | Variable | Required | Default | Description | |----------|----------|----------------|--------------------------| | API_KEY | yes | — | Auth key for all routes | | DB_PATH | no | `analytics.db` | SQLite database path | | ADDR | no | `:8080` | Listen address | ### Test ```bash # All tests go test ./... # Single package go test ./handlers/ # Single test function go test ./handlers/ -run TestSessionStart # Verbose go test -v ./... # With race detector (note: CGO_ENABLED=1 required) CGO_ENABLED=1 go test -race ./... ``` No tests exist yet. When adding tests, place `*_test.go` files next to the code they test (same package). Use the standard `testing` package. ### Lint No linter is configured. Use `go vet` at minimum: ```bash go vet ./... ``` ## Project Structure ``` main.go — entrypoint, routing, middleware wiring db/ db.go — database init, schema migrations handlers/ helpers.go — shared utilities (time, IP extraction, enums) middleware.go — API key auth middleware session_start.go — POST /api/analytics/session/start/ session_end.go — POST /api/analytics/session/:session_id/end/ leaderboard.go — GET /api/analytics/leaderboard/ player_stats.go — GET /api/analytics/player/:client_id/ models/ models.go — all structs (domain models + request/response DTOs) ``` One handler per file. Shared helpers live in `handlers/helpers.go`. ## Code Style ### Formatting Standard `gofmt`. Tabs for indentation. No custom formatter config. ### Imports Two groups separated by a blank line: 1. Standard library (alphabetical) 2. Third-party and internal packages together (alphabetical) ```go import ( "database/sql" "net/http" "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/tas/horchposten/models" ) ``` Internal packages (`github.com/tas/horchposten/...`) are NOT separated from third-party — they sort alphabetically together in the second group. ### Naming - **Files**: `snake_case.go` - **Structs**: `PascalCase` nouns — `Player`, `GameSession`, `DeviceInfo` - **Request/response types**: suffixed — `SessionStartRequest`, `PlayerStatsResponse`, `LeaderboardEntry` - **Functions**: `PascalCase` exported, `camelCase` unexported - **Variables**: short `camelCase` — `c` for echo.Context, `db` for database, `tx` for transaction, `req` for request body - **JSON tags**: `snake_case` — `client_id`, `total_sessions`, `device_pixel_ratio` - **Constants**: unexported `camelCase` — `timeFormat` ### Handler Pattern Handlers are **closure functions** that accept dependencies and return `echo.HandlerFunc`: ```go // SessionStart handles POST /api/analytics/session/start/ func SessionStart(db *sql.DB) echo.HandlerFunc { return func(c echo.Context) error { // ... } } ``` ### HTTP Responses - Success: `c.JSON(http.StatusOK, echo.Map{...})` or `c.JSON(http.StatusOK, typedStruct)` - Created: `http.StatusCreated` for POST that creates a resource - Errors: always `c.JSON(statusCode, echo.Map{"error": "message"})` - Use `echo.Map` for ad-hoc responses; use typed structs for complex payloads ### Request Handling - Bind JSON body with `c.Bind(&req)` - No struct validation tags — validate manually after binding - Path params via `c.Param("name")`, query params via `c.QueryParam("name")` - Validate UUIDs with `uuid.Parse()` - All route paths have **trailing slashes** ### Error Handling - **Handlers**: catch errors inline and return JSON error responses immediately. Never propagate handler errors up. Use generic messages for internal errors (`"database error"`, `"scan error"`). Use descriptive messages for validation errors (`"client_id is required"`, `"invalid end_reason"`). ```go if err != nil { return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) } ``` - **`sql.ErrNoRows`**: check with direct equality (`err == sql.ErrNoRows`), not `errors.Is` - **`db` package**: wrap errors with `fmt.Errorf("context: %w", err)` - **`main.go`**: use `log.Fatal` for startup-fatal conditions only ### Database - SQLite with WAL mode and foreign keys: `path + "?_journal_mode=WAL&_foreign_keys=on"` - Raw `*sql.DB` passed to handlers — no ORM, no repository layer - All queries use `?` placeholders — never interpolate user input - SQL keywords in UPPERCASE (`SELECT`, `FROM`, `WHERE`, etc.) - Multi-line SQL in backtick raw strings **Write operations** use transactions: ```go tx, err := db.Begin() if err != nil { return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) } defer tx.Rollback() // ... tx.Exec / tx.QueryRow ... if err := tx.Commit(); err != nil { return c.JSON(http.StatusInternalServerError, echo.Map{"error": "failed to commit transaction"}) } ``` **Read-only** handlers use `db.Query` / `db.QueryRow` directly. ### Types - Use `sql.NullInt32`, `sql.NullFloat64`, `sql.NullString` for nullable DB columns - Use pointer types (`*int`, `*float64`) for optional JSON fields in request/response structs - Initialize slices with `make([]Type, 0)` (not `nil`) to ensure JSON serializes as `[]` not `null` ### Comments - Exported functions/types: Go-doc comment starting with the symbol name - Handler doc comments include HTTP method and route path: `// Leaderboard handles GET /api/analytics/leaderboard/` - Inline section comments within functions: short, end with period - Separate model sections with `// --- Section Name ---`