

Also from Kynth Studios


Also from Kynth Studios


Also from Kynth Studios
1# API v2 Development Guidelines23## Essential Reference45**ALWAYS read `internal/api/v2/README.md` first** - it is the endpoint catalog:67- Complete list of all API endpoints, grouped by domain8- Per-route authentication requirements9- Request/response shapes and best practices1011## Architecture (post-split)1213`internal/api/v2` is a thin composition root (package `api`) plus one subpackage14per domain. The dependency direction is strictly acyclic:1516```17internal/api (parent server.go)18 | api.New, *api.Controller19 v20internal/api/v2 package api (the facade)21 Controller{ *apicore.Core; <domain>.Handler fields }22 New / NewWithOptions / InitializeAPI, ordered initRoutes()23 | | |24 | imports | imports | imports25 v v v26internal/api/v2/analytics internal/api/v2/weather ... internal/api/v2/<domain>27 | type Handler struct{ *apicore.Core }; New; RegisterRoutes28 +------------------+------------------+29 | imports | imports30 v v31 internal/api/v2/apicore internal/api/v2/dto32 (Core: shared state, (cross-domain request/33 helpers, middleware, response DTOs)34 SSE hub, broadcasters)35 |36 v37 leaf pkgs: conf, datastore, logger, securefs, observability, ...38```3940Rule, stated once: **domains depend on `apicore` and `dto`; the facade depends on41`apicore` and every domain; `apicore` depends on neither a domain nor the42facade.** There is no path back up, so no import cycle is possible. This is43enforced mechanically by `internal/api/v2/apicore/import_guard_test.go` (a44`go list -deps` check that fails if `apicore` or `apitest` ever imports a domain45or the facade); it runs inside the normal `go test ./...` unit-test jobs.4647### What lives where4849- **`apicore`** (`internal/api/v2/apicore`): the shared `Core` struct (deps,50 settings accessors, error/log helpers, telemetry reporting, `RequireDatastore`),51 the shared group middleware (`TunnelDetectionMiddleware`, `LoggingMiddleware`,52 `PrivateModeAuth`, the trusted-proxy IP extractor, `GetAuthMiddleware`), the SSE53 hub (`SSEManager`, `SSEClient`) and the broadcasters (`BroadcastDetection`,54 `BroadcastSoundLevel`, `BroadcastPending`). Everything a domain handler touches55 is **exported** on `Core` (cross-package field/method promotion does not bypass56 Go's export rules).57- **`<domain>`** (e.g. `internal/api/v2/weather`): `type Handler struct{ *apicore.Core }`,58 `New(core *apicore.Core) *Handler`, and `RegisterRoutes(g *echo.Group)`. The59 handler embeds `*apicore.Core` **by pointer** so the shared members promote onto60 it. Domain-only types and helpers stay unexported in the domain package.61- **`dto`** (`internal/api/v2/dto`): request/response structs shared by 2+ domains62 (e.g. `SourceInfo`, `WeatherInfo`, `RangeFilterSpecies`). Domain-only structs63 stay in their domain package.64- **`apitest`** (`internal/api/v2/apitest`): importable test scaffolding built65 around `*apicore.Core`. Domain tests build their own handler:66 `h := weather.New(apitest.NewCore(t))`. `apitest` imports only `apicore`/`dto`.67- **`api` (facade, directory root)**: `Controller` embeds `*apicore.Core` and holds68 one field per domain Handler. `New`/`NewWithOptions`/`InitializeAPI` build the69 single `Core`, construct each domain Handler around it, and `initRoutes` calls70 every `RegisterRoutes` in a deterministic ordered list. `settings.go` (and its71 large test suite) intentionally still lives here, as does the facade-owned72 name-map plumbing (`name_maps.go`) and the cross-domain `/system` wiring73 (`system_routes.go`).7475> **Embed `*apicore.Core` by pointer only, never by value.** `Core` holds76> `atomic.Pointer`, `sync.RWMutex` and `sync.WaitGroup` fields; copying it by value77> desyncs the atomics and trips `go vet` copylocks. A single `Core` is built once78> in `NewWithOptions` and shared by pointer to every handler and the facade.7980> **Broadcaster-payload rule.** Because the SSE broadcasters live in `apicore` and81> are called from domains, their parameter/return types must stay in leaf packages,82> `dto`, or `apicore` itself - **never a domain type** - or `apicore` would have to83> import a domain and close a cycle. Use a leaf type, `any` + serialize, or push the84> payload struct to `dto`.8586## Adding endpoints8788### Recipe A - new endpoint in an EXISTING domain (no facade change)89901. Add the handler method on that domain's `*Handler` in91 `internal/api/v2/<domain>/`, receiver named `c`:9293```go94 func (c *Handler) GetThing(ctx echo.Context) error {95 if c.DS == nil {96 return c.HandleError(ctx, nil, "datastore unavailable", http.StatusServiceUnavailable)97 }98 // ... use c.CurrentSettings(), c.HandleError(...), dto.Thing, etc.99 return ctx.JSON(http.StatusOK, resp)100 }101```1021032. Add the route line to that domain's `RegisterRoutes(g *echo.Group)`:104105```go106 g.GET("/things", c.GetThing) // public107 g.POST("/things", c.CreateThing, c.AuthMiddleware) // protected108```1091103. Update `README.md` with the new endpoint. No facade edit is needed.111112### Recipe B - new domain1131141. Create `internal/api/v2/<domain>/<domain>.go` with115 `type Handler struct{ *apicore.Core }`, `New(core *apicore.Core) *Handler`, and116 `RegisterRoutes(g *echo.Group)`.1172. In the facade (`api.go`): add one `*<domain>.Handler` field to `Controller`, one118 `c.<domain> = <domain>.New(c.Core)` line in `NewWithOptions`, and one ordered119 entry `{"<domain> routes", func() { c.<domain>.RegisterRoutes(c.Group) }}` in120 `initRoutes`. Registration is explicit (not `init()`-based) so order stays121 deterministic; preserve the existing ordering.1223. New domains are subpackages of `api/v2`, so the "all new endpoints stay under123 api/v2" rule holds.124125A single `RegisterRoutes` is the norm, but a larger domain may expose several named126registrars (e.g. `detections` has `RegisterSearchRoutes` + `RegisterDetectionRoutes`;127`analytics` has `RegisterAnalyticsRoutes` + `RegisterHeatmapRoutes` +128`RegisterInsightsRoutes` + `RegisterDatabaseOverviewRoutes`; `audio` and `system`129similarly). Each is wired as its own ordered `initRoutes` entry, which keeps the130original per-route registration order across the split.131132### Recipe C - new shared dependency or middleware133134- Shared dependency: add an exported field (+ functional option) on `apicore.Core`135 and thread it through `NewCore`; domains read it via promotion.136- Shared middleware: add it on `apicore` and apply it at the facade group level in137 `NewWithOptions` (preserving order), or as a shared per-route middleware138 constructor that domains call.139140## Authentication patterns141142- Public endpoints: no middleware.143- Protected endpoints/groups: apply the promoted `c.AuthMiddleware` field directly,144 e.g. `g.Group("/control", c.AuthMiddleware)` or145 `g.POST("/path", c.Handler, c.AuthMiddleware)`. (`c.GetAuthMiddleware()` returns146 the same value for callers that prefer an accessor.) The middleware is injected147 from the parent server via the `WithAuthMiddleware` functional option.148- Rate-limited streams: `middleware.RateLimiterWithConfig(config)`.149- PrivateMode: all endpoints are gated by `apicore.PrivateModeAuth` group150 middleware; the bootstrap/login/live-audio carve-outs are listed in the facade's151 `isPrivateModeExempt` allow-list (keyed on method + path, fail-closed).152153## Route namespace guide154155The API uses distinct namespaces. Adding endpoints to the wrong namespace causes156route collisions.157158| Namespace | Purpose | Registration | Example |159|---|---|---|---|160| `/audio/:id` | Detection audio clips by numeric note ID | `c.Echo.GET(...)` (media domain) | `ServeAudioByID` |161| `/system/audio/*` | Audio device/source management (protected) | `protectedGroup.Group("/audio")` (audio domain) | `GetAudioDevices`, `ListAudioSources` |162| `/streams/*` | Live streaming, SSE, source listing (public) | `g.GET("/streams/...")` (audio/sse domains) | `StreamAudioLevel`, `ListStreamSources` |163| `/media/*` | Static media files (images, spectrograms) | `g.GET("/media/...")` (media domain) | `ServeSpectrogram` |164165**WARNING:** `GET /api/v2/audio/:id` is registered directly on `c.Echo` (not the166`/api/v2` group) and catches ALL paths under `/api/v2/audio/*`. Any non-numeric167path like `/api/v2/audio/sources` returns 400. Never add new endpoints under168`/api/v2/audio/` unless they use a numeric `:id` parameter.169170**Public endpoints that expose source metadata** must anonymize display names for171unauthenticated clients (the audio domain does this with its local172`getAnonymizedSourceName`, matching `StreamAudioLevel`).173174## Critical rules175176- **Never duplicate existing endpoints** - check `README.md` first.177- **Always validate input** - prevent injection attacks; use SecureFS (`c.SFS`) for178 file operations and parameterized queries only.179- **Use structured logging** - `c.LogAPIRequest(ctx, level, msg, args...)` and the180 `c.LogInfoIfEnabled`/`LogWarnIfEnabled`/`LogErrorIfEnabled`/`LogDebugIfEnabled`181 family.182- **Follow the error format** - `return c.HandleError(ctx, err, "message", statusCode)`183 (or `c.HandleErrorWithKey(...)` for an i18n key). The `ErrorResponse` shape and184 correlation-id behavior live in `apicore`.185- **Hot-reload** - read settings per request via `c.CurrentSettings()` /186 `c.ControllerSettings()` (the atomic snapshot on `Core`); never branch on settings187 captured at startup.188- **Document in README.md** - update the endpoint table immediately.189190## Future api/v3191192A future `internal/api/v3` lives as a sibling facade with its own `Core` (do not193re-monolith). It can reuse these patterns but should not couple to `apicore` so the194two versions evolve independently.195196## CSRF Protection (legacy info)197198CSRF middleware validates tokens from the `X-CSRF-Token` header (primary) or the199`_csrf` form field (fallback); it is wired globally in the parent `server.go`. The200token is issued by the `/app/config` endpoint via `middleware.EnsureCSRFToken()`.201Public read-only endpoints skip CSRF validation.202
One repository carrying more than one format is the comparison this product exists for: does anyone actually write different content in each file, or is one a copy of the other?
| Repository | Format | Stack | Covers | Score | Changed |
|---|---|---|---|---|---|
| tphakala/birdnet-go.cursor/rules/database.mdc · 1.6k | Cursor rules | databasedo-not | 45/100 | today | |
| tphakala/birdnet-go.cursor/rules/frontend.mdc · 1.6k | Cursor rules | dependenciesuido-not | 61/100 | today | |
| tphakala/birdnet-go.cursor/rules/go.mdc · 1.6k | Cursor rules | buildteststylearch+5 | 69/100 | today | |
| tphakala/birdnet-go.cursor/rules/go_test.mdc · 1.6k | Cursor rules | setupteststyletesting-strategy+1 | 56/100 | today | |
| tphakala/birdnet-goAGENTS.md · 1.6k | AGENTS.md | teststylegitdo-not+1 | 78/100 | today | |
| tphakala/birdnet-goCLAUDE.md · 1.6k | CLAUDE.md | buildtestlint-formatstyle+8 | 100/100 | today | |
| tphakala/birdnet-gofrontend/CLAUDE.md · 1.6k | CLAUDE.md | setuptestlint-formatstyle+7 | 84/100 | today | |
| tphakala/birdnet-gofrontend/src/lib/desktop/components/CLAUDE.md · 1.6k | CLAUDE.md | teststylearchui | 70/100 | today | |
| tphakala/birdnet-gofrontend/src/lib/desktop/components/ui/CLAUDE.md · 1.6k | CLAUDE.md | styleuidocs | 54/100 | today | |
| tphakala/birdnet-gofrontend/src/lib/desktop/features/settings/CLAUDE.md · 1.6k | CLAUDE.md | buildstylearchtypes+2 | 66/100 | today | |
| tphakala/birdnet-gofrontend/static/messages/CLAUDE.md · 1.6k | CLAUDE.md | archuido-notagent-behaviour | 67/100 | today | |
| tphakala/birdnet-gofrontend/tools/CLAUDE.md · 1.6k | CLAUDE.md | no sections | 65/100 | today | |
| tphakala/birdnet-gointernal/CLAUDE.md · 1.6k | CLAUDE.md | buildteststylearch+5 | 88/100 | today | |
| tphakala/birdnet-gointernal/errors/CLAUDE.md · 1.6k | CLAUDE.md | styleuiperformancedo-not+1 | 61/100 | today |
Same format, overlapping stack, ranked by quality.
| Repository | Format | Stack | Covers | Score | Changed |
|---|---|---|---|---|---|
| dotCMS/corecore-web/CLAUDE.md · 949 | CLAUDE.md | teststylearchtesting-strategy+3 | 100/100 | 14 days ago | |
| tphakala/birdnet-goCLAUDE.md · 1.6k | CLAUDE.md | buildtestlint-formatstyle+8 | 100/100 | today | |
| tyrchen/geektime-bootcamp-aiw7/genslides/backend/CLAUDE.md · 230 | CLAUDE.md | testlint-formatstylearch+6 | 100/100 | 9 days ago | |
| nimbalyst/nimbalystpackages/android/CLAUDE.md · 1.5k | CLAUDE.md | setupbuildstylearch+2 | 100/100 | 14 days ago | |
| microsoft/playwrightCLAUDE.md · 95k | CLAUDE.md | buildtestlint-formatstyle+7 | 100/100 | 7 days ago | |
| Adit-Jain-srm/NightmareNetCLAUDE.md · 46 | CLAUDE.md | buildtestlint-formatstyle+6 | 100/100 | 14 days ago | |
| stacklok/toolhiveCLAUDE.md · 2.0k | CLAUDE.md | buildteststylearch+4 | 100/100 | 14 days ago | |
| bagisto/bagistoCLAUDE.md · 28k | CLAUDE.md | setupbuildteststyle+5 | 100/100 | 7 days ago |
A badge carrying the measured quality of the strongest agent config file in this repository, out of 100. It reads from this index every time somebody loads your page, so it changes when the measurement changes and there is nothing to keep up to date. Free, no account, and the value is not something you or we can set by hand.
[](https://rulestack.kynth.studio/configs/tphakala-birdnet-go-internal-api-v2-claude)Would rather not hotlink us? Every badge is also served in shields.io’s endpoint schema, so shields renders the image and your readers never talk to our domain:
Published by Toolproof, the masthead over this index and eight others. The method behind the number is at toolproof.kynth.studio/methodology, and the whole thing is readable as JSON with no key at /api.