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:
- Standard library (alphabetical)
- 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:
PascalCasenouns —Player,GameSession,DeviceInfo - Request/response types: suffixed —
SessionStartRequest,PlayerStatsResponse,LeaderboardEntry - Functions:
PascalCaseexported,camelCaseunexported - Variables: short
camelCase—cfor echo.Context,dbfor database,txfor transaction,reqfor 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:
// 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{...})orc.JSON(http.StatusOK, typedStruct) - Created:
http.StatusCreatedfor POST that creates a resource - Errors: always
c.JSON(statusCode, echo.Map{"error": "message"}) - Use
echo.Mapfor 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 viac.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), noterrors.Isdbpackage: wrap errors withfmt.Errorf("context: %w", err)main.go: uselog.Fatalfor startup-fatal conditions only
Database
- SQLite with WAL mode and foreign keys:
path + "?_journal_mode=WAL&_foreign_keys=on" - Raw
*sql.DBpassed 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.NullStringfor nullable DB columns - Use pointer types (
*int,*float64) for optional JSON fields in request/response structs - Initialize slices with
make([]Type, 0)(notnil) to ensure JSON serializes as[]notnull
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 ---