Files
Horchposten/AGENTS.md
2026-03-08 14:44:50 +00:00

6.1 KiB

AGENTS.md — Horchposten

Game analytics REST API backend written in Go with Echo v4 and SQLite.

Build / Run / Test

Build

# 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

./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

# 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:

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)
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 camelCasec for echo.Context, db for database, tx for transaction, req for request body
  • JSON tags: snake_caseclient_id, total_sessions, device_pixel_ratio
  • Constants: unexported camelCasetimeFormat

Handler Pattern

Handlers are closure functions that accept dependencies and return echo.HandlerFunc:

// 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").
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:

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 ---