From 53b9b4c03bc867695912252ee6859efa53ac39ec Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 20:36:28 +0100 Subject: [PATCH 01/19] docs: design spec for migrating remaining APIs to Huma Fort scan (draft-badged) + pokemon search/id + tier 3 reads + tier 4 operational endpoints. Pointer-based response structs everywhere (pokemon template), reusing existing builders and the PR #368 Huma infrastructure. Stacked on feat/huma-pokemon-v2-v3-scan. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-30-huma-remaining-apis-design.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-30-huma-remaining-apis-design.md diff --git a/docs/superpowers/specs/2026-05-30-huma-remaining-apis-design.md b/docs/superpowers/specs/2026-05-30-huma-remaining-apis-design.md new file mode 100644 index 00000000..5c8c4ede --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-huma-remaining-apis-design.md @@ -0,0 +1,159 @@ +# Design: Migrate the remaining API endpoints to Huma + +**Date:** 2026-05-30 +**Status:** Approved (design), pending implementation plan +**Author:** James Berry (with Claude) +**Branch:** `feat/huma-remaining-apis` (stacked on `feat/huma-pokemon-v2-v3-scan` / PR #368) + +## Goal + +Migrate (almost) all remaining `/api` endpoints from the hand-rolled gin handlers +to Huma operations, so the whole API is documented in OpenAPI and browsable at +`/docs`. This builds directly on the pokemon v2/v3 migration (PR #368), reusing its +infrastructure (`setupHumaAPI`, `newHumaConfig`, the `golbatSecret` scheme, the +goccy serializer, `ApiLatLon`, the per-field required/optional pattern, the body +logger). + +Because this depends on PR #368's infrastructure, this branch is **stacked** on +`feat/huma-pokemon-v2-v3-scan`. It should land after #368 merges (rebasing onto +`main`), or as a stacked PR targeting that branch. + +## Decisions (resolved during brainstorming) + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Representation | **Pointer-based response structs everywhere** (`*T`, no omitempty, `doc:` tags). No `RegisterTypeAlias`, no `null.X` in the API layer. | A pointer *is* the external contract (maps directly to "nullable"), decoupled from the internal `guregu`/DB type — the most honest external-schema representation. Pokemon already does this, so it is the template; uniform style across all endpoints. | +| Bounding box | **Reuse `ApiLatLon`** for every scan's min/max. | Fort scan embeds the same internal `geo.Location` (capitalized fields) bug pokemon had; `ApiLatLon` accepts lat/lon (+ latitude/longitude alias) and documents lat/lon. | +| Builders | **Reuse and modify the existing builders**; do not write new ones. | `buildGymResult`/`BuildGymResult`, `buildPokestopResult`/`BuildPokestopResult`, `BuildStationResult` already convert entities → `Api*Result`. The change is field types `null.X → *T` + `.Ptr()` in the builder. | +| Draft marking | **Fort scan endpoints only**, via Stoplight `x-badges` + a description note. | Fort scan has no real public consumers yet; the badge signals "subject to change" without the wrong semantics of OpenAPI `deprecated`. | +| Scope / PR | **One branch, all ~18 endpoints**, built as many small commits. | The work is mechanical and follows a proven template; keeping it one PR is fine given the existing builders. | +| v1 pokemon scan | **Leave on gin (deprecated).** | Its `[]int8`-as-min/max input documents terribly; migrating it is a deprecation decision, out of scope. | + +## Architecture + +### Per-endpoint pattern (uniform) + +For each migrated endpoint: + +1. Define Huma input/output wrapper structs in `routes_huma.go` (package `main`): + `type xxxInput struct { Body decoder.RequestType }` (or path/query params via + Huma field tags for GET-by-id endpoints), `type xxxOutput struct { Body ResponseType }`. +2. `huma.Register(api, huma.Operation{...}, handler)` where the handler is a thin + wrapper calling the existing `decoder` logic function. +3. Operation metadata: `OperationID`, `Summary`, `Description`, `Tags`, `Security: + {golbatSecret}`, and `DefaultStatus` matching the current gin status code. +4. Remove the corresponding `apiGroup` gin route and the now-dead gin handler in + `routes.go` (keeping any shared logic the handler delegated to). + +GET-by-id endpoints use Huma path params, e.g. +`type getGymInput struct { GymId string `path:"gym_id"` }`. + +### Response struct conversion (the bulk of the work) + +Every `Api*Result` response struct still using `guregu/null` is converted to +pointers, following `ApiPokemonResult`: +- `null.Int → *int64`, `null.Float → *float64`, `null.String → *string`, + `null.Bool → *bool`. No `omitempty` (preserve `null` on the wire). Add `doc:` + tags to every field. Keep field order and json tags identical (wire-compat). +- Update the struct's existing builder(s) to assign via `.Ptr()` instead of + copying the `null.X` value. Plain (non-null) fields are unchanged. + +Structs in scope (initial list — the plan will confirm the full set by grep): +`ApiGymResult`, `ApiPokestopResult`, `ApiStationResult`, the gym/station **query** +result structs, the tappable result struct, and any other `Api*Result` on `null.X`. +The scan **envelope** structs (`ApiGymScanResult` etc.) already hold `[]*Api*Result` +and need only `doc:` tags. + +Wire compatibility holds because `null.X` and the corresponding pointer marshal +identically (value or `null`), and Huma does not validate responses. + +### Draft marking helper + +A small helper in `routes_huma.go`: + +```go +func draftBadge(op *huma.Operation) { + op.Description = "**Draft — subject to change.**\n\n" + op.Description + op.Extensions = map[string]any{ + "x-badges": []map[string]any{{"name": "Draft", "color": "orange"}}, + } +} +``` + +Applied to the four fort scan operations only. (`x-badges` is rendered by Stoplight +Elements, which Huma serves at `/docs`.) + +### Bounding box + +`ApiFortScan.Min/Max` change from `geo.Location` to `ApiLatLon` (with the +`GetMin()/GetMax()` accessors returning `.Location()`), and the gRPC/other builders +that construct `ApiFortScan` switch to `ApiLatLon{Lat,Lon}` — mirroring the pokemon +change. The scan endpoints continue to 503 when `config.Config.FortInMemory` is off. + +## Endpoint inventory + +**Fort scan (draft):** +- `POST /api/gym/scan` → `GymScan`/`GymScanEndpoint` +- `POST /api/pokestop/scan` → `PokestopScan`/`PokestopScanEndpoint` +- `POST /api/station/scan` → `StationScan`/`StationScanEndpoint` +- `POST /api/fort/scan` → `FortScan`/`FortCombinedScanEndpoint` + +**Pokemon (finish the family; v1 stays on gin):** +- `POST /api/pokemon/search` → `PokemonSearch`/`SearchPokemon` +- `GET /api/pokemon/id/:pokemon_id` → `PokemonOne`/`GetOnePokemon` + +**Tier 3 (reads/queries):** +- `POST /api/gym/query` → `GetGyms` +- `POST /api/station/query` → `GetStations` +- `POST /api/gym/search` → `SearchGyms` +- `GET /api/gym/id/:gym_id` → `GetGym` +- `GET /api/pokestop/id/:fort_id` → `GetPokestop` +- `GET /api/tappable/id/:tappable_id` → `GetTappable` +- `POST /api/pokestop-positions` → `GetPokestopPositions` + +**Tier 4 (operational/admin):** +- `GET /api/devices/all` → `GetDevices` +- `GET /api/fort-tracker/cell/:cell_id` → `GetFortTrackerCell` +- `GET /api/fort-tracker/forts/:fort_id` → `GetFortTrackerFort` +- `POST /api/quest-status` → `GetQuestStatus` +- `POST /api/clear-quests` → `ClearQuests` +- `GET|POST /api/reload-geojson` → `ReloadGeojson` +- `GET|POST /api/skip-preserve-pokemon` → `SkipPreservePokemon` + +**Stay on gin (out of scope):** `POST /raw`, `GET /health`, `GET /version`, +`POST /api/pokemon/scan` (v1, deprecated), and `GET /api/pokemon/available` (not in +the requested scope — leave on gin for now). + +## Error handling + +- Request validation: Huma validates bodies against the schema and returns 422 + before the handler (same as pokemon). Per-field required/optional tags applied to + request structs as appropriate (the plan will specify per endpoint; default to the + pokemon convention — bounding box required, filters/limit optional). +- `fort_in_memory` gating: fort scan handlers return 503 via `huma.Error503...` + when the feature is disabled, preserving current behavior. +- Reused `additionalProperties: false` strictness; coordinate objects stay lenient + via `ApiLatLon` (only the coordinate accepts extra keys). +- Endpoints that currently return non-2xx (e.g. search with empty input) keep their + status via mapped Huma errors. + +## Testing + +- **Golden snapshots** for each converted result struct (`ApiGymResult`, + `ApiPokestopResult`, `ApiStationResult`, query results, tappable), asserting the + exact JSON of the builder for a representative entity — guarding wire-compat after + the `null.X → *T` flip (mirrors `TestBuildApiPokemonResult_GoldenSnapshot`). +- **OpenAPI discoverability** assertions: each new operation, its tags, and the key + schemas appear in the generated spec. +- **Draft-badge assertion**: the four fort scan operations carry the `x-badges` + Draft badge and the others do not. +- **e2e (humatest)** for a representative endpoint per group: auth (401/200), + status, and response envelope shape (no DB needed; empty caches return empty). +- Full suite + `go build -tags go_json ./...` + `go vet` green at each step. + +## Out of scope / future + +- Migrating v1 pokemon scan (deprecated) and the ingest `/raw` path. +- Per-field semantic constraints on numeric ranges (e.g. IV 0–15) — a shared range + type can't express per-field domains; separate future change. +- Splitting draft vs stable into separate PRs (we chose one PR). From 8756f83a0f573c8d1a7bf10e778eb15c89be4c98 Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 20:50:18 +0100 Subject: [PATCH 02/19] docs: implementation plan for migrating remaining APIs to Huma 13 tasks in 3 phases: shared infra (fort range unification, ApiLatLon, draft-badge + geofence helpers) -> fort scan (draft) -> pokemon search/id + tier 3 -> tier 4. Pointer-based result structs with golden snapshots; geofence endpoints via RawBody + NormaliseFenceFromBytes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-05-30-huma-remaining-apis.md | 427 ++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-30-huma-remaining-apis.md diff --git a/docs/superpowers/plans/2026-05-30-huma-remaining-apis.md b/docs/superpowers/plans/2026-05-30-huma-remaining-apis.md new file mode 100644 index 00000000..8fb713ff --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-huma-remaining-apis.md @@ -0,0 +1,427 @@ +# Migrate Remaining APIs to Huma — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate the remaining `/api` endpoints (fort scan, pokemon search/by-id, tier-3 reads, tier-4 operational) from gin handlers to documented Huma operations, with pointer-based response structs and a "Draft" badge on the fort scan endpoints. + +**Architecture:** Reuse PR #368's Huma infrastructure (`setupHumaAPI`, `newHumaConfig`, `golbatSecret` scheme, goccy serializer, `ApiLatLon`, the body logger). Each endpoint becomes a `huma.Register` call in `routes_huma.go` wrapping the existing `decoder` logic; its gin route + handler are removed. Response structs using `guregu/null` are converted to pointers (`*T`) following `ApiPokemonResult`, updating their existing builders to `.Ptr()`. Done on branch `feat/huma-remaining-apis` (stacked on `feat/huma-pokemon-v2-v3-scan`). + +**Tech Stack:** Go 1.26, gin, Huma v2 (+ humagin), goccy/go-json, guregu/null/v6, Stoplight Elements (docs UI). + +**Reference spec:** `docs/superpowers/specs/2026-05-30-huma-remaining-apis-design.md` +**Reference pattern (read first):** `routes_huma.go`, `huma_api.go`, `decoder/api_latlon.go`, `decoder/api_pokemon_response.go` (the pokemon v2/v3 migration — every task follows these patterns). + +**Conventions used throughout:** +- Operation security: `Security: []map[string][]string{{securitySchemeName: {}}}`. +- Preserve each endpoint's current HTTP status via `DefaultStatus`. +- Errors: return `huma.Error404NotFound(...)`, `huma.Error503ServiceUnavailable(...)`, `huma.Error400BadRequest(...)` as appropriate (Huma renders them as RFC7807 problem JSON). +- Null→pointer conversion rule: `null.Int→*int64`, `null.Float→*float64`, `null.String→*string`, `null.Bool→*bool`, **no `omitempty`** (preserve `null`), keep json tags + field order, add `doc:` tags. Update the builder to assign `field.Ptr()`. +- After each struct conversion, a golden-snapshot test pins the JSON (mirrors `decoder/api_pokemon_response_test.go:TestBuildApiPokemonResult_GoldenSnapshot`). + +--- + +## PHASE 0 — Shared infrastructure + +### Task 1: Unify the fort DNF range types + +**Files:** Modify `decoder/api_fort.go` (types `ApiFortDnfMinMax8`/`ApiFortDnfMinMax16`, the `isFortDnfMatch` comparisons, and any `convertToFortMinMax8/16` helpers/grpc builders). + +Mirrors the pokemon `ApiPokemonDnfMinMax` unification (commit `1a1845c`). The fort filter has `ApiFortDnfMinMax8` (int8) and `ApiFortDnfMinMax16` (int16) — collapse to one `ApiFortDnfMinMax` (int16) so the schema exposes one range type. + +- [ ] **Step 1: Grep the usages** + +Run: `grep -rn "ApiFortDnfMinMax8\|ApiFortDnfMinMax16\|ApiFortDnfMinMax\b" decoder/` +Record every field using the int8 variant (`PowerUpLevel`, `AvailableSlots`) and the int16 variant (`QuestRewardAmount`, `ContestTotalEntries`), plus the comparison sites in `isFortDnfMatch` (search `decoder/api_fort.go` for where these filter fields are compared against the `FortLookup` fields). + +- [ ] **Step 2: Replace the two types with one** + +In `decoder/api_fort.go`, delete `ApiFortDnfMinMax8` and `ApiFortDnfMinMax16`, add: +```go +// ApiFortDnfMinMax is an inclusive integer range used by the fort filter clauses +// (int16 internally — wide enough for all fort range fields). +type ApiFortDnfMinMax struct { + Min int16 `json:"min" doc:"Minimum value (inclusive)."` + Max int16 `json:"max" doc:"Maximum value (inclusive)."` +} +``` +Change all `*ApiFortDnfMinMax8` and `*ApiFortDnfMinMax16` fields in `ApiFortDnfFilter` to `*ApiFortDnfMinMax`. + +- [ ] **Step 3: Fix the comparison sites** + +In `isFortDnfMatch`, wherever an int8 `FortLookup` field is compared to a now-int16 filter bound, cast the lookup field up: `int16(lookup.PowerUpLevel) < filter.PowerUpLevel.Min`. (The int16 fields need no cast.) Read the function and apply to each affected comparison. Collapse any `convertToFortMinMax8`/`convertToFortMinMax16` helpers to one `convertToFortMinMax` and update call sites. + +- [ ] **Step 4: Build + vet** + +Run: `go build -tags go_json ./... && go vet ./...` +Expected: clean. `grep -rn "ApiFortDnfMinMax8\|ApiFortDnfMinMax16" decoder/` → no matches. + +- [ ] **Step 5: Commit** + +```bash +git add decoder/api_fort.go +git commit -m "refactor: unify fort DNF range types (hide int8/int16 from schema)" +``` + +### Task 2: `ApiFortScan` bounding box → `ApiLatLon` + +**Files:** Modify `decoder/api_fort.go` (`ApiFortScan` struct + the `internalGetForts`/`internalGetFortsCombined` reads of `.Min`/`.Max`). + +`ApiFortScan.Min/Max` are `geo.Location` (capitalized JSON fields — same bug pokemon had). Switch to `ApiLatLon`. + +- [ ] **Step 1: Change the struct + add accessors** + +In `decoder/api_fort.go`: +```go +type ApiFortScan struct { + Min ApiLatLon `json:"min" doc:"SW (minimum lat/lon) corner of the bounding box."` + Max ApiLatLon `json:"max" doc:"NE (maximum lat/lon) corner of the bounding box."` + Limit int `json:"limit" required:"false" doc:"Max results to return; 0 uses the server default."` + DnfFilters []ApiFortDnfFilter `json:"filters" required:"false" doc:"OR'd filter clauses; a fort matches if it satisfies any one clause."` +} +``` + +- [ ] **Step 2: Fix the `.Min`/`.Max` reads** + +`internalGetForts` (and `internalGetFortsCombined`) read `retrieveParameters.Min` / `.Max` as `geo.Location` (around api_fort.go:220-221, 240, 427). Replace those reads with `retrieveParameters.Min.Location()` / `.Max.Location()` so they still get a `geo.Location`. + +- [ ] **Step 3: Build + vet** + +Run: `go build -tags go_json ./... && go vet ./...` → clean. + +- [ ] **Step 4: Commit** + +```bash +git add decoder/api_fort.go +git commit -m "fix: fort scan bounding box takes ApiLatLon (lat/lon), not geo.Location" +``` + +### Task 3: Draft-badge helper + geofence bytes parser + +**Files:** Create `routes_huma_draft.go` (package `main`); modify `geo/` (add a bytes-based fence parser); Test: `routes_huma_draft_test.go`. + +- [ ] **Step 1: Write the draft-badge test** + +`routes_huma_draft_test.go`: +```go +package main + +import ( + "strings" + "testing" + + "github.com/danielgtaylor/huma/v2" +) + +func TestDraftBadge(t *testing.T) { + op := &huma.Operation{Description: "Does a thing."} + draftBadge(op) + if op.Extensions["x-badges"] == nil { + t.Errorf("expected x-badges to be set") + } + if !strings.HasPrefix(op.Description, "**Draft") { + t.Errorf("expected description to start with the Draft note, got %q", op.Description) + } +} +``` + +- [ ] **Step 2: Run it (fails to compile)** + +Run: `go test . -run TestDraftBadge` → FAIL `undefined: draftBadge`. + +- [ ] **Step 3: Implement the helper** + +`routes_huma_draft.go`: +```go +package main + +import "github.com/danielgtaylor/huma/v2" + +// draftBadge marks an operation as a draft API: a "Draft" badge in the Stoplight +// docs (via the x-badges extension) and a note prepended to the description. Used +// for endpoints with no stable public consumers yet. +func draftBadge(op *huma.Operation) { + op.Description = "**Draft — subject to change.**\n\n" + op.Description + if op.Extensions == nil { + op.Extensions = map[string]any{} + } + op.Extensions["x-badges"] = []map[string]any{{"name": "Draft", "color": "orange"}} +} +``` + +- [ ] **Step 4: Add a bytes-based fence parser** + +Read `geo/` for `NormaliseFenceRequest(c *gin.Context)`. It reads the request body and parses a GeoJSON feature. Refactor so the body-parsing logic is available without gin: +- Add `func NormaliseFenceFromBytes(body []byte) (*geojson.Feature, error)` containing the parse logic. +- Rewrite `NormaliseFenceRequest` to read `c`'s body bytes and delegate to `NormaliseFenceFromBytes` (so existing gin callers are unchanged). +Show the exact refactor by reading the current `NormaliseFenceRequest` body first; keep its existing behavior identical. + +- [ ] **Step 5: Run tests + build** + +Run: `go test . -run TestDraftBadge -v && go build -tags go_json ./...` → PASS + clean. + +- [ ] **Step 6: Commit** + +```bash +git add routes_huma_draft.go routes_huma_draft_test.go geo/ +git commit -m "feat: add draft-badge helper and bytes-based geofence parser" +``` + +--- + +## PHASE 1 — Fort scan endpoints + +### Task 4: Convert `ApiGymResult` to pointers + +**Files:** Modify `decoder/api_gym.go` (struct `ApiGymResult` + `buildGymResult`); Test: `decoder/api_gym_test.go`. + +- [ ] **Step 1: Convert the struct** + +Apply the null→pointer rule to every `null.X` field of `ApiGymResult` (api_gym.go:15-57). `null.String→*string`, `null.Int→*int64`. Keep `Id string`, `Lat/Lon float64`, `Updated int64`, `Deleted bool`, `FirstSeenTimestamp int64` as-is. Add a `doc:` tag to every field. Example (first fields; apply the same to all): +```go +type ApiGymResult struct { + Id string `json:"id" doc:"Gym fort ID"` + Lat float64 `json:"lat" doc:"Latitude"` + Lon float64 `json:"lon" doc:"Longitude"` + Name *string `json:"name" doc:"Gym name"` + Url *string `json:"url" doc:"Image URL"` + LastModifiedTimestamp *int64 `json:"last_modified_timestamp" doc:"Last modified unix timestamp"` + // ... convert every remaining null.String -> *string, null.Int -> *int64, add doc tags ... +} +``` + +- [ ] **Step 2: Update the builder** + +In `buildGymResult`, change each converted field assignment from `gym.Field` to `gym.Field.Ptr()`. Plain fields (`Id`, `Lat`, `Lon`, `Updated`, `Deleted`, `FirstSeenTimestamp`) stay as direct assignments. (`BuildGymResult` just calls `buildGymResult`, no change.) + +- [ ] **Step 3: Write the golden snapshot test** + +`decoder/api_gym_test.go` — construct a `*Gym` with a representative mix of set/unset fields, marshal `buildGymResult(g)`, assert the exact JSON string. Generate the expected string by running the test once with a placeholder and copying the actual output (like `TestBuildApiPokemonResult_GoldenSnapshot`). Assert unset null fields serialize as `null`. + +- [ ] **Step 4: Run + build** + +Run: `go test ./decoder/ -run TestBuildGymResult -v && go build -tags go_json ./...` → PASS + clean. + +- [ ] **Step 5: Commit** + +```bash +git add decoder/api_gym.go decoder/api_gym_test.go +git commit -m "refactor: ApiGymResult pointer-based + doc tags (Huma-documentable)" +``` + +### Task 5: Convert `ApiPokestopResult` to pointers + +**Files:** Modify `decoder/api_pokestop.go` (`ApiPokestopResult` + `buildPokestopResult`); Test: `decoder/api_pokestop_test.go`. + +Identical pattern to Task 4. Convert every `null.String→*string`, `null.Int→*int64`, `null.Bool→*bool` field in `ApiPokestopResult` (api_pokestop.go:5-49); keep `Id`, `Lat`, `Lon`, `Updated`, `Deleted`, `LureId int16`, `FirstSeenTimestamp int16`. Add `doc:` tags. Update `buildPokestopResult` to `.Ptr()` the converted fields. Add a golden-snapshot test `TestBuildPokestopResult`. Run `go test ./decoder/ -run TestBuildPokestopResult -v && go build -tags go_json ./...` → PASS. Commit: +```bash +git commit -m "refactor: ApiPokestopResult pointer-based + doc tags" +``` + +### Task 6: Convert `ApiStationResult` to pointers + +**Files:** Modify `decoder/api_station.go` (`ApiStationResult` + `BuildStationResult`); Test: `decoder/api_station_test.go`. + +Same pattern. Convert every `null.Int→*int64`, `null.String→*string` field in `ApiStationResult` (api_station.go:5-28); keep `Id`, `Lat`, `Lon`, `Name string`, `StartTime`, `EndTime`, `IsBattleAvailable`, `Updated`. Add `doc:` tags. Update `BuildStationResult` to `.Ptr()`. Add golden-snapshot `TestBuildStationResult`. Run + commit: +```bash +git commit -m "refactor: ApiStationResult pointer-based + doc tags" +``` + +### Task 7: Document the fort filter + envelopes + +**Files:** Modify `decoder/api_fort.go` (`ApiFortDnfFilter`, `ApiDnfId`, the scan-result envelopes). + +- [ ] **Step 1: Add doc tags + required/optional** + +Add a `doc:` tag to every field of `ApiFortDnfFilter` (explain raid/quest/incident/contest/battle filters), `ApiDnfId` (`pokemon_id`, `form`), and the envelope structs `ApiGymScanResult`/`ApiPokestopScanResult`/`ApiStationScanResult`/`ApiFortCombinedScanResult`. Mark all `ApiFortDnfFilter` fields `required:"false"` (every filter attribute is optional). For `ApiDnfId`, mark `form` `required:"false"` and leave `pokemon_id` required (mirrors pokemon `ApiPokemonDnfId`). + +- [ ] **Step 2: Build + vet** + +Run: `go build -tags go_json ./... && go vet ./...` → clean. + +- [ ] **Step 3: Commit** + +```bash +git add decoder/api_fort.go +git commit -m "docs: doc tags + required/optional on fort scan request/result types" +``` + +### Task 8: Register the 4 fort scan Huma operations + +**Files:** Modify `routes_huma.go` (add input/output types + registrations), `main.go` (remove the 4 gin routes), `routes.go` (remove `GymScan`/`PokestopScan`/`StationScan`/`FortScan` handlers); Test: `huma_routes_test.go` (e2e + draft-badge assertions). + +- [ ] **Step 1: Add the operations** + +In `routes_huma.go`, for each of gym/pokestop/station/fort scan, add input/output types and a `huma.Register` (the `dbDetails` package global is available in `main`). Pattern (gym shown; repeat for the other three with their types/paths/endpoint funcs): +```go +type gymScanInput struct{ Body decoder.ApiFortScan } +type gymScanOutput struct{ Body decoder.ApiGymScanResult } + +func registerFortScanRoutes(api huma.API) { + gymOp := huma.Operation{ + OperationID: "scan-gyms", Method: http.MethodPost, Path: "/api/gym/scan", + Summary: "Search gyms in a bounding box (DNF filters)", + Description: "Returns gyms within [min,max] matching any DNF filter clause.", + Tags: []string{"Fort"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusOK, + } + draftBadge(&gymOp) + huma.Register(api, gymOp, func(ctx context.Context, in *gymScanInput) (*gymScanOutput, error) { + if !config.Config.FortInMemory { + return nil, huma.Error503ServiceUnavailable("fort_in_memory not enabled") + } + return &gymScanOutput{Body: *decoder.GymScanEndpoint(in.Body, dbDetails)}, nil + }) + // ... repeat: scan-pokestops (/api/pokestop/scan, PokestopScanEndpoint, ApiPokestopScanResult), + // scan-stations (/api/station/scan, StationScanEndpoint, ApiStationScanResult), + // scan-forts (/api/fort/scan, FortCombinedScanEndpoint, ApiFortCombinedScanResult) + // each with draftBadge(&op) and the FortInMemory 503 guard. +} +``` +Call `registerFortScanRoutes(humaAPI)` from where `registerHumaRoutes` is invoked (in `main.go`, alongside the existing `registerHumaRoutes(humaAPI)` — or call it inside `registerHumaRoutes`). + +- [ ] **Step 2: Remove the gin routes + handlers** + +In `main.go` delete the four `apiGroup.POST(".../scan", ...)` fort lines. In `routes.go` delete the `GymScan`, `PokestopScan`, `StationScan`, `FortScan` functions. Fix any now-unused imports. + +- [ ] **Step 3: Write e2e + draft-badge tests** + +In `huma_routes_test.go` add: (a) an e2e test posting an empty-filter `ApiFortScan` (with `config.Config.FortInMemory=true`, `ApiSecret=""`) to `/api/gym/scan` asserting 200 and a `{gyms,examined,skipped,total}` envelope; a 401 without the secret when `ApiSecret` set; and a 503 when `FortInMemory=false`. (b) An OpenAPI assertion that the four fort ops carry the `x-badges` Draft badge (marshal `api.OpenAPI()`, find the operations, assert the extension) and that `scan-pokemon-v2` does NOT. + +- [ ] **Step 4: Run + build + vet** + +Run: `go test ./... && go build -tags go_json ./... && go vet ./...` → all green. + +- [ ] **Step 5: Commit** + +```bash +git add routes_huma.go main.go routes.go huma_routes_test.go +git commit -m "feat: serve fort scan via Huma (draft), retire gin handlers" +``` + +--- + +## PHASE 2 — Pokemon search/by-id + Tier 3 reads + +### Task 9: Pokemon search + by-id + +**Files:** Modify `decoder/api_pokemon.go` (`ApiPokemonSearch` Min/Max/Center → `ApiLatLon` + doc tags), `routes_huma.go` (register), `main.go`/`routes.go` (remove gin route + `PokemonSearch`/`PokemonOne`). + +- [ ] **Step 1: Convert ApiPokemonSearch coordinates** + +In `decoder/api_pokemon.go`, change `ApiPokemonSearch.Min/Max/Center` from `geo.Location` to `ApiLatLon`, add doc tags + `required:"false"` on `center`/`limit`/`searchIds` (min/max required). Update `SearchPokemon` internals that read `.Min`/`.Max`/`.Center` to use `.Location()`. + +- [ ] **Step 2: Register the operations** + +In `routes_huma.go`: +```go +type pokemonSearchInput struct{ Body decoder.ApiPokemonSearch } +type pokemonSearchOutput struct{ Body []decoder.ApiPokemonResult } +// POST /api/pokemon/search, OperationID "search-pokemon", Tags ["Pokemon"], +// DefaultStatus 202, security golbatSecret. +// handler: res, err := decoder.SearchPokemon(in.Body); on err return huma.Error400BadRequest(...); +// build []ApiPokemonResult (deref the []*ApiPokemonResult into values) and return. + +type pokemonByIdInput struct{ PokemonId uint64 `path:"pokemon_id" doc:"Encounter ID"` } +type pokemonByIdOutput struct{ Body decoder.ApiPokemonResult } +// GET /api/pokemon/id/{pokemon_id}, OperationID "get-pokemon", DefaultStatus 202. +// handler: res := decoder.GetOnePokemon(in.PokemonId); if res == nil return nil, huma.Error404NotFound("not found"); +// return &pokemonByIdOutput{Body: *res}, nil +``` +Note: Huma path params use `{name}` not `:name`. `SearchPokemon` returns `[]*ApiPokemonResult`; deref into `[]ApiPokemonResult` for the body (or change the output to `[]*decoder.ApiPokemonResult` — pick one, keep consistent). + +- [ ] **Step 3: Remove gin routes/handlers** + +Delete `apiGroup.POST("/pokemon/search", ...)` and `apiGroup.GET("/pokemon/id/:pokemon_id", ...)` from `main.go`; delete `PokemonSearch` and `PokemonOne` from `routes.go`. + +- [ ] **Step 4: Test + build + commit** + +e2e: search with empty searchIds (expect the current 400 behavior preserved) and a by-id 404. Run `go test ./... && go build -tags go_json ./...`. Commit: +```bash +git commit -m "feat: serve pokemon search + by-id via Huma" +``` + +### Task 10: Convert `ApiTappableResult` to pointers + +**Files:** Modify `decoder/api_tappable.go` (`ApiTappableResult` + `BuildTappableResult`); Test: `decoder/api_tappable_test.go`. + +Same null→pointer pattern as Task 4 (fields: `FortId→*string`; `SpawnId`/`Encounter`/`ItemId`/`Count`/`ExpireTimestamp→*int64`; keep `Id uint64`, `Lat`, `Lon`, `Type string`, `ExpireTimestampVerified bool`, `Updated int64`). Add doc tags, update `BuildTappableResult` to `.Ptr()`, add golden-snapshot `TestBuildTappableResult`. Commit: +```bash +git commit -m "refactor: ApiTappableResult pointer-based + doc tags" +``` + +### Task 11: Register Tier-3 read endpoints + +**Files:** Modify `routes_huma.go`, `decoder/api_gym.go` (doc tags on `ApiGymSearch`/`ApiGymSearchFilter`), `main.go`/`routes.go` (remove gin routes/handlers `GetGyms`, `GetStations`, `SearchGyms`, `GetGym`, `GetPokestop`, `GetTappable`, `GetPokestopPositions`). + +Register each (reuse the existing decoder functions; the converted result structs document automatically): + +- [ ] **`gym/query`** (`GetGyms`): input `struct{ Body struct{ IDs []string `json:"ids" doc:"Fort IDs to fetch (max 500)"` } }` — **standardize on `{"ids":[...]}` only** (drop the bare-array form). Output `struct{ Body []decoder.ApiGymResult }`, status 200. Handler replicates the existing dedup + 500-cap + 5s-timeout loop calling `decoder.GetGymRecordReadOnly` + `decoder.BuildGymResult`; over-500 → `huma.Error400BadRequest`. +- [ ] **`station/query`** (`GetStations`): same shape, `[]decoder.ApiStationResult`, `GetStationRecordReadOnly` + `BuildStationResult`. +- [ ] **`gym/search`** (`SearchGyms`): input `struct{ Body decoder.ApiGymSearch }` (add doc tags to `ApiGymSearch`/`ApiGymSearchFilter`/`LocationDistance`), output `[]decoder.ApiGymResult`, status 200. Handler replicates the existing filter validation + `decoder.SearchGymsAPI` + result fetch; timeout → `huma.Error504GatewayTimeout`, bad filters → `huma.Error400BadRequest`. +- [ ] **`gym/id/{gym_id}`** (`GetGym`): input `struct{ GymId string `path:"gym_id"` }`, output `decoder.ApiGymResult`, status 202. Handler: `GetGymRecordReadOnly`; nil → 404. +- [ ] **`pokestop/id/{fort_id}`** (`GetPokestop`): input `struct{ FortId string `path:"fort_id"` }`, output `decoder.ApiPokestopResult`, status 202. Handler: `PeekPokestopRecord`; nil → 404. +- [ ] **`tappable/id/{tappable_id}`** (`GetTappable`): input `struct{ TappableId uint64 `path:"tappable_id"` }`, output `decoder.ApiTappableResult`, status 202. Handler: `PeekTappableRecord`; nil → 404. +- [ ] **`pokestop-positions`** (`GetPokestopPositions`): geofence endpoint — input `struct{ RawBody []byte }`, output `struct{ Body []db.QuestLocation }`, status 202. Handler: `fence, err := geo.NormaliseFenceFromBytes(in.RawBody)`; on err `huma.Error400BadRequest`; `decoder.GetPokestopPositions(dbDetails, fence)`. + +Remove each corresponding gin route from `main.go` and handler from `routes.go`. Add e2e tests for a representative few (a by-id 404; a `gym/query` with `{"ids":[]}` → empty array 200). Run `go test ./... && go build -tags go_json ./...`. Commit: +```bash +git commit -m "feat: serve tier-3 read endpoints via Huma" +``` + +--- + +## PHASE 3 — Tier 4 operational + +### Task 12: Geofence write/status endpoints + +**Files:** Modify `routes_huma.go`, `main.go`/`routes.go` (remove `GetQuestStatus`, `ClearQuests`). + +- [ ] **`quest-status`** (`GetQuestStatus`): input `struct{ RawBody []byte }`, output `struct{ Body db.QuestStatus }`, status 200. Handler: `fence, err := geo.NormaliseFenceFromBytes(in.RawBody)`; err → 400; `decoder.GetQuestStatusWithGeofence(dbDetails, fence)`. +- [ ] **`clear-quests`** (`ClearQuests`): input `struct{ RawBody []byte }`, output `struct{ Body StatusResponse }` (move/duplicate the `StatusResponse{Status string}` type into a shared spot accessible to `routes_huma.go`), status 202. Handler: parse fence (err → 400), 10s context, `decoder.ClearQuestsWithinGeofence(ctx, dbDetails, fence)`, return `{Status:"ok"}`. (Performs DB deletes — keep behavior identical.) + +Remove the two gin routes/handlers. Test + build. Commit: +```bash +git commit -m "feat: serve quest-status + clear-quests via Huma (geofence body)" +``` + +### Task 13: Remaining operational endpoints + +**Files:** Modify `routes_huma.go`, `main.go`/`routes.go` (remove `GetDevices`, `GetFortTrackerCell`, `GetFortTrackerFort`, `ReloadGeojson`, `SkipPreservePokemon`). + +- [ ] **`devices/all`** (`GetDevices`): GET, output `struct{ Body struct{ Devices map[string]ApiDeviceLocation `json:"devices"` } }`, status 200. Handler returns `GetAllDevices()` wrapped. +- [ ] **`fort-tracker/cell/{cell_id}`** (`GetFortTrackerCell`): input `struct{ CellId uint64 `path:"cell_id"` }`, output `decoder.CellFortInfo`, status 200. Handler: tracker nil → 503; `GetCellInfo`; nil → 404. +- [ ] **`fort-tracker/forts/{fort_id}`** (`GetFortTrackerFort`): input `struct{ FortId string `path:"fort_id"` }`, output `decoder.FortTrackerInfo`, status 200. Handler: tracker nil → 503; `GetFortInfo`; nil → 404. +- [ ] **`reload-geojson`** (`ReloadGeojson`): register BOTH GET and POST `/api/reload-geojson` (two `huma.Register` calls, distinct OperationIDs `reload-geojson-get`/`-post`), no input, output `struct{ Body StatusResponse }`, status 202. Handler calls `decoder.ReloadGeofenceAndClearStats()`. +- [ ] **`skip-preserve-pokemon`** (`SkipPreservePokemon`): register GET + POST, no input, output `struct{ Body struct{ Status string `json:"status"`; Message string `json:"message"` } }`, status 200. Handler calls `decoder.SetSkipPreservePokemon(true)`. + +Remove the gin routes/handlers. Test + build. Commit: +```bash +git commit -m "feat: serve tier-4 operational endpoints via Huma" +``` + +--- + +## Final task: cleanup + verification + +- [ ] **Step 1: Confirm only intended routes remain on gin** + +Run: `grep -nE "apiGroup\.(GET|POST)" main.go` +Expected remaining: `/api/health`, `/api/pokemon/scan` (v1), `/api/pokemon/available`, `/api/reload-geojson` only if not migrated. Everything else should be gone. Confirm `r.POST("/raw")`, `/health`, `/version` remain on root. + +- [ ] **Step 2: Full green + manual docs check** + +Run: `go build -tags go_json ./... && go vet ./... && go test ./...` → all green. Boot against the live DB, open `/docs`, confirm the new operations appear grouped by tag (Pokemon/Fort/etc.), the fort scan ops show the orange **Draft** badge, and the converted result schemas show typed nullable fields. + +- [ ] **Step 3: Record results in the spec** + +Append a short "Results" section to `docs/superpowers/specs/2026-05-30-huma-remaining-apis-design.md` (endpoints migrated, anything left on gin, draft badge confirmed). Commit. + +--- + +## Self-review notes + +- **Spec coverage:** Task 1–3 = shared infra (fort range unification, ApiLatLon, draft+geofence helpers); Tasks 4–8 = fort scan (draft); Tasks 9–11 = pokemon search/id + tier 3; Tasks 12–13 = tier 4. All spec endpoints covered; v1 pokemon scan + `/raw` + `/health` + `/version` + `/pokemon/available` intentionally left on gin. +- **Wire-compat traps:** null→pointer (no omitempty → still `null`); golden snapshots per converted struct; status codes preserved per endpoint (fort scans 200, by-id 202, etc.); `gym/query`/`station/query` standardized to `{"ids":[...]}` (documented behavior change). +- **Decisions baked in:** geofence endpoints use `RawBody` + `geo.NormaliseFenceFromBytes`; fort range types unified to `ApiFortDnfMinMax` (int16, cast lookups); draft badge on fort scan only. +- **Reuse:** every registration follows the pokemon `routes_huma.go` pattern; `ApiLatLon`, `securitySchemeName`, goccy config, the body logger all reused from PR #368. From 35c64b5acc11333f5a175fbe187b11ed4180c57b Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 21:12:50 +0100 Subject: [PATCH 03/19] refactor: unify fort DNF range types (hide int8/int16 from schema) Co-Authored-By: Claude Opus 4.8 (1M context) --- decoder/api_fort.go | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/decoder/api_fort.go b/decoder/api_fort.go index 95fb2db3..3038248e 100644 --- a/decoder/api_fort.go +++ b/decoder/api_fort.go @@ -20,11 +20,11 @@ type ApiFortScan struct { } type ApiFortDnfFilter struct { - PowerUpLevel *ApiFortDnfMinMax8 `json:"power_up_level"` - IsArScanEligible *bool `json:"is_ar_scan_eligible"` + PowerUpLevel *ApiFortDnfMinMax `json:"power_up_level"` + IsArScanEligible *bool `json:"is_ar_scan_eligible"` // Gym - AvailableSlots *ApiFortDnfMinMax8 `json:"available_slots"` + AvailableSlots *ApiFortDnfMinMax `json:"available_slots"` TeamId []int8 `json:"team_id"` RaidLevel []int8 `json:"raid_level"` RaidPokemon []ApiDnfId `json:"raid_pokemon_id"` @@ -32,7 +32,7 @@ type ApiFortDnfFilter struct { // Pokestop - unified quest (matches AR or no-AR) LureId []int16 `json:"lure_id"` QuestRewardType []int16 `json:"quest_reward_type"` - QuestRewardAmount *ApiFortDnfMinMax16 `json:"quest_reward_amount"` + QuestRewardAmount *ApiFortDnfMinMax `json:"quest_reward_amount"` QuestRewardItemId []int16 `json:"quest_reward_item_id"` QuestRewardPokemon []ApiDnfId `json:"quest_reward_pokemon"` @@ -45,7 +45,7 @@ type ApiFortDnfFilter struct { // Pokestop - contest ContestPokemon []ApiDnfId `json:"contest_pokemon"` ContestPokemonType []int8 `json:"contest_pokemon_type"` - ContestTotalEntries *ApiFortDnfMinMax16 `json:"contest_total_entries"` + ContestTotalEntries *ApiFortDnfMinMax `json:"contest_total_entries"` // Station BattleLevel []int8 `json:"battle_level"` @@ -57,14 +57,11 @@ type ApiDnfId struct { Form *int16 `json:"form"` } -type ApiFortDnfMinMax8 struct { - Min int8 `json:"min"` - Max int8 `json:"max"` -} - -type ApiFortDnfMinMax16 struct { - Min int16 `json:"min"` - Max int16 `json:"max"` +// ApiFortDnfMinMax is an inclusive integer range used by the fort filter clauses +// (int16 internally — wide enough for all fort range fields). +type ApiFortDnfMinMax struct { + Min int16 `json:"min" doc:"Minimum value (inclusive)."` + Max int16 `json:"max" doc:"Maximum value (inclusive)."` } type ApiGymScanResult struct { @@ -112,7 +109,7 @@ func isFortDnfMatch(fortType FortType, fortLookup *FortLookup, filter *ApiFortDn if fortType != 0 && fortType != fortLookup.FortType { return false } - if filter.PowerUpLevel != nil && (fortLookup.PowerUpLevel < filter.PowerUpLevel.Min || fortLookup.PowerUpLevel > filter.PowerUpLevel.Max) { + if filter.PowerUpLevel != nil && (int16(fortLookup.PowerUpLevel) < filter.PowerUpLevel.Min || int16(fortLookup.PowerUpLevel) > filter.PowerUpLevel.Max) { return false } if filter.IsArScanEligible != nil && !fortLookup.IsArScanEligible { @@ -121,7 +118,7 @@ func isFortDnfMatch(fortType FortType, fortLookup *FortLookup, filter *ApiFortDn switch fortLookup.FortType { case GYM: - if filter.AvailableSlots != nil && (fortLookup.AvailableSlots < filter.AvailableSlots.Min || fortLookup.AvailableSlots > filter.AvailableSlots.Max) { + if filter.AvailableSlots != nil && (int16(fortLookup.AvailableSlots) < filter.AvailableSlots.Min || int16(fortLookup.AvailableSlots) > filter.AvailableSlots.Max) { return false } if filter.TeamId != nil && !slices.Contains(filter.TeamId, fortLookup.TeamId) { From 70bf6cbcec0e2e96430f23db1ed61c80a857b614 Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 21:16:12 +0100 Subject: [PATCH 04/19] fix: fort scan bounding box takes ApiLatLon (lat/lon), not geo.Location Co-Authored-By: Claude Opus 4.8 (1M context) --- decoder/api_fort.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/decoder/api_fort.go b/decoder/api_fort.go index 3038248e..85f3eee0 100644 --- a/decoder/api_fort.go +++ b/decoder/api_fort.go @@ -9,14 +9,13 @@ import ( "golbat/config" "golbat/db" - "golbat/geo" ) type ApiFortScan struct { - Min geo.Location `json:"min"` - Max geo.Location `json:"max"` - Limit int `json:"limit"` - DnfFilters []ApiFortDnfFilter `json:"filters"` + Min ApiLatLon `json:"min" doc:"SW (minimum lat/lon) corner of the bounding box."` + Max ApiLatLon `json:"max" doc:"NE (maximum lat/lon) corner of the bounding box."` + Limit int `json:"limit" required:"false" doc:"Max results to return; 0 uses the server default."` + DnfFilters []ApiFortDnfFilter `json:"filters" required:"false" doc:"OR'd filter clauses; a fort matches if it satisfies any one clause."` } type ApiFortDnfFilter struct { @@ -214,8 +213,8 @@ func isFortDnfMatch(fortType FortType, fortLookup *FortLookup, filter *ApiFortDn func internalGetForts(fortType FortType, retrieveParameters ApiFortScan) ([]string, int, int, int) { start := time.Now() - minLocation := retrieveParameters.Min - maxLocation := retrieveParameters.Max + minLocation := retrieveParameters.Min.Location() + maxLocation := retrieveParameters.Max.Location() maxForts := config.Config.Tuning.MaxPokemonResults if retrieveParameters.Limit > 0 && retrieveParameters.Limit < maxForts { @@ -404,8 +403,8 @@ func FortCombinedScanEndpoint(retrieveParameters ApiFortScan, dbDetails db.DbDet func internalGetFortsCombined(retrieveParameters ApiFortScan) (gymKeys, pokestopKeys, stationKeys []string, examined, skipped, total int) { start := time.Now() - minLocation := retrieveParameters.Min - maxLocation := retrieveParameters.Max + minLocation := retrieveParameters.Min.Location() + maxLocation := retrieveParameters.Max.Location() maxForts := config.Config.Tuning.MaxPokemonResults if retrieveParameters.Limit > 0 && retrieveParameters.Limit < maxForts { From 9f221d350dec0bb7c100613b178afc03f5bfb0d3 Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 21:19:29 +0100 Subject: [PATCH 05/19] feat: add draft-badge helper and bytes-based geofence parser Co-Authored-By: Claude Opus 4.8 (1M context) --- geo/geofence.go | 19 ++++++++++++++++--- routes_huma_draft.go | 14 ++++++++++++++ routes_huma_draft_test.go | 19 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 routes_huma_draft.go create mode 100644 routes_huma_draft_test.go diff --git a/geo/geofence.go b/geo/geofence.go index d8a05139..ee063e70 100644 --- a/geo/geofence.go +++ b/geo/geofence.go @@ -299,22 +299,35 @@ func NormaliseFenceRequest(c *gin.Context) (*geojson.Feature, error) { return nil, err } + return normaliseFenceFromBytes(bodyBytes, c.Request.Method+" "+c.FullPath()) +} + +// NormaliseFenceFromBytes parses a request body (geometry, feature, or Golbat +// fence JSON) into a geojson.Feature. This is the body-parsing path shared by +// the gin handler NormaliseFenceRequest and the Huma POST endpoints. +func NormaliseFenceFromBytes(body []byte) (*geojson.Feature, error) { + return normaliseFenceFromBytes(body, "fence request") +} + +// normaliseFenceFromBytes contains the actual parse logic. logContext is used +// only for debug logging. +func normaliseFenceFromBytes(bodyBytes []byte, logContext string) (*geojson.Feature, error) { geometry, err := geojson.UnmarshalGeometry(bodyBytes) if err == nil { - log.Debugf("%s %s - received a geometry", c.Request.Method, c.FullPath()) + log.Debugf("%s - received a geometry", logContext) return geojson.NewFeature(geometry.Geometry()), nil } feature, err := geojson.UnmarshalFeature(bodyBytes) if err == nil { - log.Debugf("%s %s - received a feature", c.Request.Method, c.FullPath()) + log.Debugf("%s - received a feature", logContext) return feature, nil } var golbatFance *GeofenceApi err = json.Unmarshal(bodyBytes, &golbatFance) if err == nil { - log.Debugf("%s %s - received a fence", c.Request.Method, c.FullPath()) + log.Debugf("%s - received a fence", logContext) return golbatFance.toGeofence().toFeature(), err } diff --git a/routes_huma_draft.go b/routes_huma_draft.go new file mode 100644 index 00000000..9504a058 --- /dev/null +++ b/routes_huma_draft.go @@ -0,0 +1,14 @@ +package main + +import "github.com/danielgtaylor/huma/v2" + +// draftBadge marks an operation as a draft API: a "Draft" badge in the Stoplight +// docs (via the x-badges extension) and a note prepended to the description. Used +// for endpoints with no stable public consumers yet. +func draftBadge(op *huma.Operation) { + op.Description = "**Draft — subject to change.**\n\n" + op.Description + if op.Extensions == nil { + op.Extensions = map[string]any{} + } + op.Extensions["x-badges"] = []map[string]any{{"name": "Draft", "color": "orange"}} +} diff --git a/routes_huma_draft_test.go b/routes_huma_draft_test.go new file mode 100644 index 00000000..d4e68584 --- /dev/null +++ b/routes_huma_draft_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "strings" + "testing" + + "github.com/danielgtaylor/huma/v2" +) + +func TestDraftBadge(t *testing.T) { + op := &huma.Operation{Description: "Does a thing."} + draftBadge(op) + if op.Extensions["x-badges"] == nil { + t.Errorf("expected x-badges to be set") + } + if !strings.HasPrefix(op.Description, "**Draft") { + t.Errorf("expected description to start with the Draft note, got %q", op.Description) + } +} From bc363ac49713656d27aa4636deef5a87aa3811b6 Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 21:23:26 +0100 Subject: [PATCH 06/19] refactor: ApiGymResult pointer-based + doc tags (Huma-documentable) --- decoder/api_gym.go | 157 ++++++++++++++++++++-------------------- decoder/api_gym_test.go | 75 +++++++++++++++++++ 2 files changed, 154 insertions(+), 78 deletions(-) create mode 100644 decoder/api_gym_test.go diff --git a/decoder/api_gym.go b/decoder/api_gym.go index 8003cde3..93981d73 100644 --- a/decoder/api_gym.go +++ b/decoder/api_gym.go @@ -8,52 +8,53 @@ import ( "golbat/db" "golbat/geo" - - "github.com/guregu/null/v6" ) +// ApiGymResult is the API representation of a gym. Nullable database columns are +// represented as pointers (nil => JSON null) without omitempty so every key is +// always present. type ApiGymResult struct { - Id string `json:"id"` - Lat float64 `json:"lat"` - Lon float64 `json:"lon"` - Name null.String `json:"name"` - Url null.String `json:"url"` - LastModifiedTimestamp null.Int `json:"last_modified_timestamp"` - RaidEndTimestamp null.Int `json:"raid_end_timestamp"` - RaidSpawnTimestamp null.Int `json:"raid_spawn_timestamp"` - RaidBattleTimestamp null.Int `json:"raid_battle_timestamp"` - Updated int64 `json:"updated"` - RaidPokemonId null.Int `json:"raid_pokemon_id"` - GuardingPokemonId null.Int `json:"guarding_pokemon_id"` - GuardingPokemonDisplay null.String `json:"guarding_pokemon_display"` - AvailableSlots null.Int `json:"available_slots"` - TeamId null.Int `json:"team_id"` - RaidLevel null.Int `json:"raid_level"` - Enabled null.Int `json:"enabled"` - ExRaidEligible null.Int `json:"ex_raid_eligible"` - InBattle null.Int `json:"in_battle"` - RaidPokemonMove1 null.Int `json:"raid_pokemon_move_1"` - RaidPokemonMove2 null.Int `json:"raid_pokemon_move_2"` - RaidPokemonForm null.Int `json:"raid_pokemon_form"` - RaidPokemonAlignment null.Int `json:"raid_pokemon_alignment"` - RaidPokemonCp null.Int `json:"raid_pokemon_cp"` - RaidIsExclusive null.Int `json:"raid_is_exclusive"` - CellId null.Int `json:"cell_id"` - Deleted bool `json:"deleted"` - TotalCp null.Int `json:"total_cp"` - FirstSeenTimestamp int64 `json:"first_seen_timestamp"` - RaidPokemonGender null.Int `json:"raid_pokemon_gender"` - SponsorId null.Int `json:"sponsor_id"` - PartnerId null.String `json:"partner_id"` - RaidPokemonCostume null.Int `json:"raid_pokemon_costume"` - RaidPokemonEvolution null.Int `json:"raid_pokemon_evolution"` - ArScanEligible null.Int `json:"ar_scan_eligible"` - PowerUpLevel null.Int `json:"power_up_level"` - PowerUpPoints null.Int `json:"power_up_points"` - PowerUpEndTimestamp null.Int `json:"power_up_end_timestamp"` - Description null.String `json:"description"` - Defenders null.String `json:"defenders"` - Rsvps null.String `json:"rsvps"` + Id string `json:"id" doc:"Fort ID of the gym"` + Lat float64 `json:"lat" doc:"Latitude of the gym"` + Lon float64 `json:"lon" doc:"Longitude of the gym"` + Name *string `json:"name" doc:"Name of the gym"` + Url *string `json:"url" doc:"Image URL of the gym"` + LastModifiedTimestamp *int64 `json:"last_modified_timestamp" doc:"Unix timestamp when the gym was last modified in-game"` + RaidEndTimestamp *int64 `json:"raid_end_timestamp" doc:"Unix timestamp when the current raid ends"` + RaidSpawnTimestamp *int64 `json:"raid_spawn_timestamp" doc:"Unix timestamp when the current raid egg spawned"` + RaidBattleTimestamp *int64 `json:"raid_battle_timestamp" doc:"Unix timestamp when the current raid battle begins"` + Updated int64 `json:"updated" doc:"Unix timestamp when the record was last updated"` + RaidPokemonId *int64 `json:"raid_pokemon_id" doc:"Pokedex ID of the raid boss"` + GuardingPokemonId *int64 `json:"guarding_pokemon_id" doc:"Pokedex ID of the pokemon guarding the gym"` + GuardingPokemonDisplay *string `json:"guarding_pokemon_display" doc:"Display details of the guarding pokemon"` + AvailableSlots *int64 `json:"available_slots" doc:"Number of open defender slots"` + TeamId *int64 `json:"team_id" doc:"ID of the team controlling the gym"` + RaidLevel *int64 `json:"raid_level" doc:"Level/tier of the current raid"` + Enabled *int64 `json:"enabled" doc:"Whether the gym is enabled"` + ExRaidEligible *int64 `json:"ex_raid_eligible" doc:"Whether the gym is eligible for EX raids"` + InBattle *int64 `json:"in_battle" doc:"Whether the gym is currently in battle"` + RaidPokemonMove1 *int64 `json:"raid_pokemon_move_1" doc:"Fast move ID of the raid boss"` + RaidPokemonMove2 *int64 `json:"raid_pokemon_move_2" doc:"Charge move ID of the raid boss"` + RaidPokemonForm *int64 `json:"raid_pokemon_form" doc:"Form ID of the raid boss"` + RaidPokemonAlignment *int64 `json:"raid_pokemon_alignment" doc:"Alignment of the raid boss"` + RaidPokemonCp *int64 `json:"raid_pokemon_cp" doc:"Combat power of the raid boss"` + RaidIsExclusive *int64 `json:"raid_is_exclusive" doc:"Whether the current raid is exclusive (EX)"` + CellId *int64 `json:"cell_id" doc:"S2 cell ID the gym belongs to"` + Deleted bool `json:"deleted" doc:"Whether the gym has been deleted"` + TotalCp *int64 `json:"total_cp" doc:"Total combat power of the gym defenders"` + FirstSeenTimestamp int64 `json:"first_seen_timestamp" doc:"Unix timestamp when the gym was first seen"` + RaidPokemonGender *int64 `json:"raid_pokemon_gender" doc:"Gender of the raid boss"` + SponsorId *int64 `json:"sponsor_id" doc:"Sponsor ID of the gym, if sponsored"` + PartnerId *string `json:"partner_id" doc:"Partner ID of the gym, if partnered"` + RaidPokemonCostume *int64 `json:"raid_pokemon_costume" doc:"Costume ID of the raid boss"` + RaidPokemonEvolution *int64 `json:"raid_pokemon_evolution" doc:"Evolution ID of the raid boss (e.g. mega)"` + ArScanEligible *int64 `json:"ar_scan_eligible" doc:"Whether the gym is eligible for AR scanning"` + PowerUpLevel *int64 `json:"power_up_level" doc:"Power-up level of the gym"` + PowerUpPoints *int64 `json:"power_up_points" doc:"Power-up points accumulated for the gym"` + PowerUpEndTimestamp *int64 `json:"power_up_end_timestamp" doc:"Unix timestamp when the power-up ends"` + Description *string `json:"description" doc:"Description of the gym"` + Defenders *string `json:"defenders" doc:"Serialized defender pokemon data"` + Rsvps *string `json:"rsvps" doc:"Serialized raid RSVP data"` } func buildGymResult(gym *Gym) ApiGymResult { @@ -61,44 +62,44 @@ func buildGymResult(gym *Gym) ApiGymResult { Id: gym.Id, Lat: gym.Lat, Lon: gym.Lon, - Name: gym.Name, - Url: gym.Url, - LastModifiedTimestamp: gym.LastModifiedTimestamp, - RaidEndTimestamp: gym.RaidEndTimestamp, - RaidSpawnTimestamp: gym.RaidSpawnTimestamp, - RaidBattleTimestamp: gym.RaidBattleTimestamp, + Name: gym.Name.Ptr(), + Url: gym.Url.Ptr(), + LastModifiedTimestamp: gym.LastModifiedTimestamp.Ptr(), + RaidEndTimestamp: gym.RaidEndTimestamp.Ptr(), + RaidSpawnTimestamp: gym.RaidSpawnTimestamp.Ptr(), + RaidBattleTimestamp: gym.RaidBattleTimestamp.Ptr(), Updated: gym.Updated, - RaidPokemonId: gym.RaidPokemonId, - GuardingPokemonId: gym.GuardingPokemonId, - GuardingPokemonDisplay: gym.GuardingPokemonDisplay, - AvailableSlots: gym.AvailableSlots, - TeamId: gym.TeamId, - RaidLevel: gym.RaidLevel, - Enabled: gym.Enabled, - ExRaidEligible: gym.ExRaidEligible, - InBattle: gym.InBattle, - RaidPokemonMove1: gym.RaidPokemonMove1, - RaidPokemonMove2: gym.RaidPokemonMove2, - RaidPokemonForm: gym.RaidPokemonForm, - RaidPokemonAlignment: gym.RaidPokemonAlignment, - RaidPokemonCp: gym.RaidPokemonCp, - RaidIsExclusive: gym.RaidIsExclusive, - CellId: gym.CellId, + RaidPokemonId: gym.RaidPokemonId.Ptr(), + GuardingPokemonId: gym.GuardingPokemonId.Ptr(), + GuardingPokemonDisplay: gym.GuardingPokemonDisplay.Ptr(), + AvailableSlots: gym.AvailableSlots.Ptr(), + TeamId: gym.TeamId.Ptr(), + RaidLevel: gym.RaidLevel.Ptr(), + Enabled: gym.Enabled.Ptr(), + ExRaidEligible: gym.ExRaidEligible.Ptr(), + InBattle: gym.InBattle.Ptr(), + RaidPokemonMove1: gym.RaidPokemonMove1.Ptr(), + RaidPokemonMove2: gym.RaidPokemonMove2.Ptr(), + RaidPokemonForm: gym.RaidPokemonForm.Ptr(), + RaidPokemonAlignment: gym.RaidPokemonAlignment.Ptr(), + RaidPokemonCp: gym.RaidPokemonCp.Ptr(), + RaidIsExclusive: gym.RaidIsExclusive.Ptr(), + CellId: gym.CellId.Ptr(), Deleted: gym.Deleted, - TotalCp: gym.TotalCp, + TotalCp: gym.TotalCp.Ptr(), FirstSeenTimestamp: gym.FirstSeenTimestamp, - RaidPokemonGender: gym.RaidPokemonGender, - SponsorId: gym.SponsorId, - PartnerId: gym.PartnerId, - RaidPokemonCostume: gym.RaidPokemonCostume, - RaidPokemonEvolution: gym.RaidPokemonEvolution, - ArScanEligible: gym.ArScanEligible, - PowerUpLevel: gym.PowerUpLevel, - PowerUpPoints: gym.PowerUpPoints, - PowerUpEndTimestamp: gym.PowerUpEndTimestamp, - Description: gym.Description, - Defenders: gym.Defenders, - Rsvps: gym.Rsvps, + RaidPokemonGender: gym.RaidPokemonGender.Ptr(), + SponsorId: gym.SponsorId.Ptr(), + PartnerId: gym.PartnerId.Ptr(), + RaidPokemonCostume: gym.RaidPokemonCostume.Ptr(), + RaidPokemonEvolution: gym.RaidPokemonEvolution.Ptr(), + ArScanEligible: gym.ArScanEligible.Ptr(), + PowerUpLevel: gym.PowerUpLevel.Ptr(), + PowerUpPoints: gym.PowerUpPoints.Ptr(), + PowerUpEndTimestamp: gym.PowerUpEndTimestamp.Ptr(), + Description: gym.Description.Ptr(), + Defenders: gym.Defenders.Ptr(), + Rsvps: gym.Rsvps.Ptr(), } } diff --git a/decoder/api_gym_test.go b/decoder/api_gym_test.go new file mode 100644 index 00000000..9768c1c4 --- /dev/null +++ b/decoder/api_gym_test.go @@ -0,0 +1,75 @@ +package decoder + +import ( + "encoding/json" + "testing" + + "github.com/guregu/null/v6" +) + +// goldenSnapshotGym is a representative gym with a mix of set and unset (null) +// fields across every nullable column, used to pin the exact wire format. +func goldenSnapshotGym() *Gym { + return &Gym{ + GymData: GymData{ + Id: "gym-abc", + Lat: 12.3456, + Lon: -65.4321, + Name: null.StringFrom("Test Gym"), + Url: null.StringFrom("https://example.com/gym.png"), + LastModifiedTimestamp: null.IntFrom(1699990000), + RaidEndTimestamp: null.IntFrom(1700003600), + // RaidSpawnTimestamp intentionally left null + RaidBattleTimestamp: null.IntFrom(1700000000), + Updated: 1699999999, + RaidPokemonId: null.IntFrom(150), + GuardingPokemonId: null.IntFrom(143), + // GuardingPokemonDisplay intentionally left null + AvailableSlots: null.IntFrom(3), + TeamId: null.IntFrom(2), + RaidLevel: null.IntFrom(5), + Enabled: null.IntFrom(1), + ExRaidEligible: null.IntFrom(0), + InBattle: null.IntFrom(0), + RaidPokemonMove1: null.IntFrom(216), + RaidPokemonMove2: null.IntFrom(94), + RaidPokemonForm: null.IntFrom(0), + RaidPokemonAlignment: null.IntFrom(0), + RaidPokemonCp: null.IntFrom(3500), + RaidIsExclusive: null.IntFrom(0), + CellId: null.IntFrom(1234567890123), + Deleted: false, + TotalCp: null.IntFrom(12000), + FirstSeenTimestamp: 1699990000, + RaidPokemonGender: null.IntFrom(1), + // SponsorId intentionally left null + PartnerId: null.StringFrom("partner-1"), + RaidPokemonCostume: null.IntFrom(0), + RaidPokemonEvolution: null.IntFrom(0), + ArScanEligible: null.IntFrom(1), + PowerUpLevel: null.IntFrom(2), + PowerUpPoints: null.IntFrom(50), + // PowerUpEndTimestamp intentionally left null + Description: null.StringFrom("A test gym"), + // Defenders intentionally left null + Rsvps: null.StringFrom("[]"), + }, + } +} + +// TestBuildGymResult_GoldenSnapshot pins the exact JSON wire format of an +// ApiGymResult. Any accidental change to a json tag, field type, pointer/null +// handling, or field order will fail this test. Unset nullable fields serialize +// as null (pointers are nil, no omitempty). +func TestBuildGymResult_GoldenSnapshot(t *testing.T) { + got, err := json.Marshal(buildGymResult(goldenSnapshotGym())) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + const want = `{"id":"gym-abc","lat":12.3456,"lon":-65.4321,"name":"Test Gym","url":"https://example.com/gym.png","last_modified_timestamp":1699990000,"raid_end_timestamp":1700003600,"raid_spawn_timestamp":null,"raid_battle_timestamp":1700000000,"updated":1699999999,"raid_pokemon_id":150,"guarding_pokemon_id":143,"guarding_pokemon_display":null,"available_slots":3,"team_id":2,"raid_level":5,"enabled":1,"ex_raid_eligible":0,"in_battle":0,"raid_pokemon_move_1":216,"raid_pokemon_move_2":94,"raid_pokemon_form":0,"raid_pokemon_alignment":0,"raid_pokemon_cp":3500,"raid_is_exclusive":0,"cell_id":1234567890123,"deleted":false,"total_cp":12000,"first_seen_timestamp":1699990000,"raid_pokemon_gender":1,"sponsor_id":null,"partner_id":"partner-1","raid_pokemon_costume":0,"raid_pokemon_evolution":0,"ar_scan_eligible":1,"power_up_level":2,"power_up_points":50,"power_up_end_timestamp":null,"description":"A test gym","defenders":null,"rsvps":"[]"}` + + if string(got) != want { + t.Errorf("wire format changed.\n got: %s\nwant: %s", got, want) + } +} From aaf16ed1ae40fdc0c9d4da341d82c97ee621e0ee Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 21:28:18 +0100 Subject: [PATCH 07/19] refactor: ApiPokestopResult pointer-based + doc tags --- decoder/api_pokestop.go | 163 ++++++++++++++++++----------------- decoder/api_pokestop_test.go | 79 +++++++++++++++++ 2 files changed, 161 insertions(+), 81 deletions(-) create mode 100644 decoder/api_pokestop_test.go diff --git a/decoder/api_pokestop.go b/decoder/api_pokestop.go index 618c81d4..636c4164 100644 --- a/decoder/api_pokestop.go +++ b/decoder/api_pokestop.go @@ -1,51 +1,52 @@ package decoder -import "github.com/guregu/null/v6" - +// ApiPokestopResult is the API representation of a pokestop. Nullable database +// columns are represented as pointers (nil => JSON null) without omitempty so +// every key is always present. type ApiPokestopResult struct { - Id string `json:"id"` - Lat float64 `json:"lat"` - Lon float64 `json:"lon"` - Name null.String `json:"name"` - Url null.String `json:"url"` - LureExpireTimestamp null.Int `json:"lure_expire_timestamp"` - LastModifiedTimestamp null.Int `json:"last_modified_timestamp"` - Updated int64 `json:"updated"` - Enabled null.Bool `json:"enabled"` - QuestType null.Int `json:"quest_type"` - QuestTimestamp null.Int `json:"quest_timestamp"` - QuestTarget null.Int `json:"quest_target"` - QuestConditions null.String `json:"quest_conditions"` - QuestRewards null.String `json:"quest_rewards"` - QuestTemplate null.String `json:"quest_template"` - QuestTitle null.String `json:"quest_title"` - QuestExpiry null.Int `json:"quest_expiry"` - CellId null.Int `json:"cell_id"` - Deleted bool `json:"deleted"` - LureId int16 `json:"lure_id"` - FirstSeenTimestamp int16 `json:"first_seen_timestamp"` - SponsorId null.Int `json:"sponsor_id"` - PartnerId null.String `json:"partner_id"` - ArScanEligible null.Int `json:"ar_scan_eligible"` - PowerUpLevel null.Int `json:"power_up_level"` - PowerUpPoints null.Int `json:"power_up_points"` - PowerUpEndTimestamp null.Int `json:"power_up_end_timestamp"` - AlternativeQuestType null.Int `json:"alternative_quest_type"` - AlternativeQuestTimestamp null.Int `json:"alternative_quest_timestamp"` - AlternativeQuestTarget null.Int `json:"alternative_quest_target"` - AlternativeQuestConditions null.String `json:"alternative_quest_conditions"` - AlternativeQuestRewards null.String `json:"alternative_quest_rewards"` - AlternativeQuestTemplate null.String `json:"alternative_quest_template"` - AlternativeQuestTitle null.String `json:"alternative_quest_title"` - AlternativeQuestExpiry null.Int `json:"alternative_quest_expiry"` - Description null.String `json:"description"` - ShowcaseFocus null.String `json:"showcase_focus"` - ShowcasePokemon null.Int `json:"showcase_pokemon_id"` - ShowcasePokemonForm null.Int `json:"showcase_pokemon_form_id"` - ShowcasePokemonType null.Int `json:"showcase_pokemon_type_id"` - ShowcaseRankingStandard null.Int `json:"showcase_ranking_standard"` - ShowcaseExpiry null.Int `json:"showcase_expiry"` - ShowcaseRankings null.String `json:"showcase_rankings"` + Id string `json:"id" doc:"Fort ID of the pokestop"` + Lat float64 `json:"lat" doc:"Latitude of the pokestop"` + Lon float64 `json:"lon" doc:"Longitude of the pokestop"` + Name *string `json:"name" doc:"Name of the pokestop"` + Url *string `json:"url" doc:"Image URL of the pokestop"` + LureExpireTimestamp *int64 `json:"lure_expire_timestamp" doc:"Unix timestamp when the current lure expires"` + LastModifiedTimestamp *int64 `json:"last_modified_timestamp" doc:"Unix timestamp when the pokestop was last modified in-game"` + Updated int64 `json:"updated" doc:"Unix timestamp when the record was last updated"` + Enabled *bool `json:"enabled" doc:"Whether the pokestop is enabled"` + QuestType *int64 `json:"quest_type" doc:"Type of the AR quest"` + QuestTimestamp *int64 `json:"quest_timestamp" doc:"Unix timestamp when the AR quest was set"` + QuestTarget *int64 `json:"quest_target" doc:"Target count for the AR quest"` + QuestConditions *string `json:"quest_conditions" doc:"Serialized conditions of the AR quest"` + QuestRewards *string `json:"quest_rewards" doc:"Serialized rewards of the AR quest"` + QuestTemplate *string `json:"quest_template" doc:"Template ID of the AR quest"` + QuestTitle *string `json:"quest_title" doc:"Title of the AR quest"` + QuestExpiry *int64 `json:"quest_expiry" doc:"Unix timestamp when the AR quest expires"` + CellId *int64 `json:"cell_id" doc:"S2 cell ID the pokestop belongs to"` + Deleted bool `json:"deleted" doc:"Whether the pokestop has been deleted"` + LureId int16 `json:"lure_id" doc:"ID of the current lure module"` + FirstSeenTimestamp int16 `json:"first_seen_timestamp" doc:"Unix timestamp when the pokestop was first seen"` + SponsorId *int64 `json:"sponsor_id" doc:"Sponsor ID of the pokestop, if sponsored"` + PartnerId *string `json:"partner_id" doc:"Partner ID of the pokestop, if partnered"` + ArScanEligible *int64 `json:"ar_scan_eligible" doc:"Whether the pokestop is eligible for AR scanning"` + PowerUpLevel *int64 `json:"power_up_level" doc:"Power-up level of the pokestop"` + PowerUpPoints *int64 `json:"power_up_points" doc:"Power-up points accumulated for the pokestop"` + PowerUpEndTimestamp *int64 `json:"power_up_end_timestamp" doc:"Unix timestamp when the power-up ends"` + AlternativeQuestType *int64 `json:"alternative_quest_type" doc:"Type of the non-AR quest"` + AlternativeQuestTimestamp *int64 `json:"alternative_quest_timestamp" doc:"Unix timestamp when the non-AR quest was set"` + AlternativeQuestTarget *int64 `json:"alternative_quest_target" doc:"Target count for the non-AR quest"` + AlternativeQuestConditions *string `json:"alternative_quest_conditions" doc:"Serialized conditions of the non-AR quest"` + AlternativeQuestRewards *string `json:"alternative_quest_rewards" doc:"Serialized rewards of the non-AR quest"` + AlternativeQuestTemplate *string `json:"alternative_quest_template" doc:"Template ID of the non-AR quest"` + AlternativeQuestTitle *string `json:"alternative_quest_title" doc:"Title of the non-AR quest"` + AlternativeQuestExpiry *int64 `json:"alternative_quest_expiry" doc:"Unix timestamp when the non-AR quest expires"` + Description *string `json:"description" doc:"Description of the pokestop"` + ShowcaseFocus *string `json:"showcase_focus" doc:"Focus type of the showcase contest"` + ShowcasePokemon *int64 `json:"showcase_pokemon_id" doc:"Pokedex ID of the showcase contest pokemon"` + ShowcasePokemonForm *int64 `json:"showcase_pokemon_form_id" doc:"Form ID of the showcase contest pokemon"` + ShowcasePokemonType *int64 `json:"showcase_pokemon_type_id" doc:"Type ID of the showcase contest pokemon"` + ShowcaseRankingStandard *int64 `json:"showcase_ranking_standard" doc:"Ranking standard of the showcase contest"` + ShowcaseExpiry *int64 `json:"showcase_expiry" doc:"Unix timestamp when the showcase contest expires"` + ShowcaseRankings *string `json:"showcase_rankings" doc:"Serialized showcase contest rankings"` } func buildPokestopResult(stop *Pokestop) ApiPokestopResult { @@ -53,46 +54,46 @@ func buildPokestopResult(stop *Pokestop) ApiPokestopResult { Id: stop.Id, Lat: stop.Lat, Lon: stop.Lon, - Name: stop.Name, - Url: stop.Url, - LureExpireTimestamp: stop.LureExpireTimestamp, - LastModifiedTimestamp: stop.LastModifiedTimestamp, + Name: stop.Name.Ptr(), + Url: stop.Url.Ptr(), + LureExpireTimestamp: stop.LureExpireTimestamp.Ptr(), + LastModifiedTimestamp: stop.LastModifiedTimestamp.Ptr(), Updated: stop.Updated, - Enabled: stop.Enabled, - QuestType: stop.QuestType, - QuestTimestamp: stop.QuestTimestamp, - QuestTarget: stop.QuestTarget, - QuestConditions: stop.QuestConditions, - QuestRewards: stop.QuestRewards, - QuestTemplate: stop.QuestTemplate, - QuestTitle: stop.QuestTitle, - QuestExpiry: stop.QuestExpiry, - CellId: stop.CellId, + Enabled: stop.Enabled.Ptr(), + QuestType: stop.QuestType.Ptr(), + QuestTimestamp: stop.QuestTimestamp.Ptr(), + QuestTarget: stop.QuestTarget.Ptr(), + QuestConditions: stop.QuestConditions.Ptr(), + QuestRewards: stop.QuestRewards.Ptr(), + QuestTemplate: stop.QuestTemplate.Ptr(), + QuestTitle: stop.QuestTitle.Ptr(), + QuestExpiry: stop.QuestExpiry.Ptr(), + CellId: stop.CellId.Ptr(), Deleted: stop.Deleted, LureId: stop.LureId, FirstSeenTimestamp: stop.FirstSeenTimestamp, - SponsorId: stop.SponsorId, - PartnerId: stop.PartnerId, - ArScanEligible: stop.ArScanEligible, - PowerUpLevel: stop.PowerUpLevel, - PowerUpPoints: stop.PowerUpPoints, - PowerUpEndTimestamp: stop.PowerUpEndTimestamp, - AlternativeQuestType: stop.AlternativeQuestType, - AlternativeQuestTimestamp: stop.AlternativeQuestTimestamp, - AlternativeQuestTarget: stop.AlternativeQuestTarget, - AlternativeQuestConditions: stop.AlternativeQuestConditions, - AlternativeQuestRewards: stop.AlternativeQuestRewards, - AlternativeQuestTemplate: stop.AlternativeQuestTemplate, - AlternativeQuestTitle: stop.AlternativeQuestTitle, - AlternativeQuestExpiry: stop.AlternativeQuestExpiry, - Description: stop.Description, - ShowcaseFocus: stop.ShowcaseFocus, - ShowcasePokemon: stop.ShowcasePokemon, - ShowcasePokemonForm: stop.ShowcasePokemonForm, - ShowcasePokemonType: stop.ShowcasePokemonType, - ShowcaseRankingStandard: stop.ShowcaseRankingStandard, - ShowcaseExpiry: stop.ShowcaseExpiry, - ShowcaseRankings: stop.ShowcaseRankings, + SponsorId: stop.SponsorId.Ptr(), + PartnerId: stop.PartnerId.Ptr(), + ArScanEligible: stop.ArScanEligible.Ptr(), + PowerUpLevel: stop.PowerUpLevel.Ptr(), + PowerUpPoints: stop.PowerUpPoints.Ptr(), + PowerUpEndTimestamp: stop.PowerUpEndTimestamp.Ptr(), + AlternativeQuestType: stop.AlternativeQuestType.Ptr(), + AlternativeQuestTimestamp: stop.AlternativeQuestTimestamp.Ptr(), + AlternativeQuestTarget: stop.AlternativeQuestTarget.Ptr(), + AlternativeQuestConditions: stop.AlternativeQuestConditions.Ptr(), + AlternativeQuestRewards: stop.AlternativeQuestRewards.Ptr(), + AlternativeQuestTemplate: stop.AlternativeQuestTemplate.Ptr(), + AlternativeQuestTitle: stop.AlternativeQuestTitle.Ptr(), + AlternativeQuestExpiry: stop.AlternativeQuestExpiry.Ptr(), + Description: stop.Description.Ptr(), + ShowcaseFocus: stop.ShowcaseFocus.Ptr(), + ShowcasePokemon: stop.ShowcasePokemon.Ptr(), + ShowcasePokemonForm: stop.ShowcasePokemonForm.Ptr(), + ShowcasePokemonType: stop.ShowcasePokemonType.Ptr(), + ShowcaseRankingStandard: stop.ShowcaseRankingStandard.Ptr(), + ShowcaseExpiry: stop.ShowcaseExpiry.Ptr(), + ShowcaseRankings: stop.ShowcaseRankings.Ptr(), } } diff --git a/decoder/api_pokestop_test.go b/decoder/api_pokestop_test.go new file mode 100644 index 00000000..84b479fc --- /dev/null +++ b/decoder/api_pokestop_test.go @@ -0,0 +1,79 @@ +package decoder + +import ( + "encoding/json" + "testing" + + "github.com/guregu/null/v6" +) + +// goldenSnapshotPokestop is a representative pokestop with a mix of set and +// unset (null) fields across every nullable column, used to pin the exact wire +// format. +func goldenSnapshotPokestop() *Pokestop { + return &Pokestop{ + PokestopData: PokestopData{ + Id: "stop-abc", + Lat: 12.3456, + Lon: -65.4321, + Name: null.StringFrom("Test Pokestop"), + Url: null.StringFrom("https://example.com/stop.png"), + // LureExpireTimestamp intentionally left null + LastModifiedTimestamp: null.IntFrom(1699990000), + Updated: 1699999999, + Enabled: null.BoolFrom(true), + QuestType: null.IntFrom(7), + QuestTimestamp: null.IntFrom(1699991000), + QuestTarget: null.IntFrom(3), + QuestConditions: null.StringFrom("[]"), + QuestRewards: null.StringFrom("[{\"type\":1}]"), + QuestTemplate: null.StringFrom("challenge_template"), + // QuestTitle intentionally left null + QuestExpiry: null.IntFrom(1700003600), + CellId: null.IntFrom(1234567890123), + Deleted: false, + LureId: 501, + // FirstSeenTimestamp is int16, plain field + FirstSeenTimestamp: 0, + // SponsorId intentionally left null + PartnerId: null.StringFrom("partner-1"), + ArScanEligible: null.IntFrom(1), + PowerUpLevel: null.IntFrom(2), + PowerUpPoints: null.IntFrom(50), + PowerUpEndTimestamp: null.IntFrom(1700007200), + AlternativeQuestType: null.IntFrom(7), + AlternativeQuestTimestamp: null.IntFrom(1699992000), + AlternativeQuestTarget: null.IntFrom(5), + // AlternativeQuestConditions intentionally left null + AlternativeQuestRewards: null.StringFrom("[{\"type\":2}]"), + AlternativeQuestTemplate: null.StringFrom("alt_template"), + AlternativeQuestTitle: null.StringFrom("Alt Quest"), + AlternativeQuestExpiry: null.IntFrom(1700003601), + Description: null.StringFrom("A test pokestop"), + // ShowcaseFocus intentionally left null + ShowcasePokemon: null.IntFrom(150), + ShowcasePokemonForm: null.IntFrom(0), + ShowcasePokemonType: null.IntFrom(1), + ShowcaseRankingStandard: null.IntFrom(0), + // ShowcaseExpiry intentionally left null + ShowcaseRankings: null.StringFrom("[]"), + }, + } +} + +// TestBuildPokestopResult_GoldenSnapshot pins the exact JSON wire format of an +// ApiPokestopResult. Any accidental change to a json tag, field type, +// pointer/null handling, or field order will fail this test. Unset nullable +// fields serialize as null (pointers are nil, no omitempty). +func TestBuildPokestopResult_GoldenSnapshot(t *testing.T) { + got, err := json.Marshal(buildPokestopResult(goldenSnapshotPokestop())) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + const want = `{"id":"stop-abc","lat":12.3456,"lon":-65.4321,"name":"Test Pokestop","url":"https://example.com/stop.png","lure_expire_timestamp":null,"last_modified_timestamp":1699990000,"updated":1699999999,"enabled":true,"quest_type":7,"quest_timestamp":1699991000,"quest_target":3,"quest_conditions":"[]","quest_rewards":"[{\"type\":1}]","quest_template":"challenge_template","quest_title":null,"quest_expiry":1700003600,"cell_id":1234567890123,"deleted":false,"lure_id":501,"first_seen_timestamp":0,"sponsor_id":null,"partner_id":"partner-1","ar_scan_eligible":1,"power_up_level":2,"power_up_points":50,"power_up_end_timestamp":1700007200,"alternative_quest_type":7,"alternative_quest_timestamp":1699992000,"alternative_quest_target":5,"alternative_quest_conditions":null,"alternative_quest_rewards":"[{\"type\":2}]","alternative_quest_template":"alt_template","alternative_quest_title":"Alt Quest","alternative_quest_expiry":1700003601,"description":"A test pokestop","showcase_focus":null,"showcase_pokemon_id":150,"showcase_pokemon_form_id":0,"showcase_pokemon_type_id":1,"showcase_ranking_standard":0,"showcase_expiry":null,"showcase_rankings":"[]"}` + + if string(got) != want { + t.Errorf("wire format changed.\n got: %s\nwant: %s", got, want) + } +} From 595c6f36a2ccf5c3c506182b891b3517c972acdf Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 21:31:33 +0100 Subject: [PATCH 08/19] refactor: ApiStationResult pointer-based + doc tags --- decoder/api_station.go | 77 +++++++++++++++++++------------------ decoder/api_station_test.go | 57 +++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 38 deletions(-) create mode 100644 decoder/api_station_test.go diff --git a/decoder/api_station.go b/decoder/api_station.go index 88d9c1ee..dc1eab3b 100644 --- a/decoder/api_station.go +++ b/decoder/api_station.go @@ -1,30 +1,31 @@ package decoder -import "github.com/guregu/null/v6" - +// ApiStationResult is the API representation of a station. Nullable database +// columns are represented as pointers (nil => JSON null) without omitempty so +// every key is always present. type ApiStationResult struct { - Id string `json:"id"` - Lat float64 `json:"lat"` - Lon float64 `json:"lon"` - Name string `json:"name"` - StartTime int64 `json:"start_time"` - EndTime int64 `json:"end_time"` - IsBattleAvailable bool `json:"is_battle_available"` - Updated int64 `json:"updated"` - BattleLevel null.Int `json:"battle_level"` - BattleStart null.Int `json:"battle_start"` - BattleEnd null.Int `json:"battle_end"` - BattlePokemonId null.Int `json:"battle_pokemon_id"` - BattlePokemonForm null.Int `json:"battle_pokemon_form"` - BattlePokemonCostume null.Int `json:"battle_pokemon_costume"` - BattlePokemonGender null.Int `json:"battle_pokemon_gender"` - BattlePokemonAlignment null.Int `json:"battle_pokemon_alignment"` - BattlePokemonBreadMode null.Int `json:"battle_pokemon_bread_mode"` - BattlePokemonMove1 null.Int `json:"battle_pokemon_move_1"` - BattlePokemonMove2 null.Int `json:"battle_pokemon_move_2"` - TotalStationedPokemon null.Int `json:"total_stationed_pokemon"` - TotalStationedGmax null.Int `json:"total_stationed_gmax"` - StationedPokemon null.String `json:"stationed_pokemon"` + Id string `json:"id" doc:"Station ID"` + Lat float64 `json:"lat" doc:"Latitude of the station"` + Lon float64 `json:"lon" doc:"Longitude of the station"` + Name string `json:"name" doc:"Name of the station"` + StartTime int64 `json:"start_time" doc:"Unix timestamp when the station becomes active"` + EndTime int64 `json:"end_time" doc:"Unix timestamp when the station expires"` + IsBattleAvailable bool `json:"is_battle_available" doc:"Whether a battle is currently available at the station"` + Updated int64 `json:"updated" doc:"Unix timestamp when the record was last updated"` + BattleLevel *int64 `json:"battle_level" doc:"Level of the current battle"` + BattleStart *int64 `json:"battle_start" doc:"Unix timestamp when the current battle starts"` + BattleEnd *int64 `json:"battle_end" doc:"Unix timestamp when the current battle ends"` + BattlePokemonId *int64 `json:"battle_pokemon_id" doc:"Pokedex ID of the battle pokemon"` + BattlePokemonForm *int64 `json:"battle_pokemon_form" doc:"Form ID of the battle pokemon"` + BattlePokemonCostume *int64 `json:"battle_pokemon_costume" doc:"Costume ID of the battle pokemon"` + BattlePokemonGender *int64 `json:"battle_pokemon_gender" doc:"Gender of the battle pokemon"` + BattlePokemonAlignment *int64 `json:"battle_pokemon_alignment" doc:"Alignment of the battle pokemon"` + BattlePokemonBreadMode *int64 `json:"battle_pokemon_bread_mode" doc:"Bread mode of the battle pokemon"` + BattlePokemonMove1 *int64 `json:"battle_pokemon_move_1" doc:"First move ID of the battle pokemon"` + BattlePokemonMove2 *int64 `json:"battle_pokemon_move_2" doc:"Second move ID of the battle pokemon"` + TotalStationedPokemon *int64 `json:"total_stationed_pokemon" doc:"Total number of pokemon stationed"` + TotalStationedGmax *int64 `json:"total_stationed_gmax" doc:"Total number of Gigantamax pokemon stationed"` + StationedPokemon *string `json:"stationed_pokemon" doc:"Serialized list of stationed pokemon"` } func BuildStationResult(station *Station) ApiStationResult { @@ -37,19 +38,19 @@ func BuildStationResult(station *Station) ApiStationResult { EndTime: station.EndTime, IsBattleAvailable: station.IsBattleAvailable, Updated: station.Updated, - BattleLevel: station.BattleLevel, - BattleStart: station.BattleStart, - BattleEnd: station.BattleEnd, - BattlePokemonId: station.BattlePokemonId, - BattlePokemonForm: station.BattlePokemonForm, - BattlePokemonCostume: station.BattlePokemonCostume, - BattlePokemonGender: station.BattlePokemonGender, - BattlePokemonAlignment: station.BattlePokemonAlignment, - BattlePokemonBreadMode: station.BattlePokemonBreadMode, - BattlePokemonMove1: station.BattlePokemonMove1, - BattlePokemonMove2: station.BattlePokemonMove2, - TotalStationedPokemon: station.TotalStationedPokemon, - TotalStationedGmax: station.TotalStationedGmax, - StationedPokemon: station.StationedPokemon, + BattleLevel: station.BattleLevel.Ptr(), + BattleStart: station.BattleStart.Ptr(), + BattleEnd: station.BattleEnd.Ptr(), + BattlePokemonId: station.BattlePokemonId.Ptr(), + BattlePokemonForm: station.BattlePokemonForm.Ptr(), + BattlePokemonCostume: station.BattlePokemonCostume.Ptr(), + BattlePokemonGender: station.BattlePokemonGender.Ptr(), + BattlePokemonAlignment: station.BattlePokemonAlignment.Ptr(), + BattlePokemonBreadMode: station.BattlePokemonBreadMode.Ptr(), + BattlePokemonMove1: station.BattlePokemonMove1.Ptr(), + BattlePokemonMove2: station.BattlePokemonMove2.Ptr(), + TotalStationedPokemon: station.TotalStationedPokemon.Ptr(), + TotalStationedGmax: station.TotalStationedGmax.Ptr(), + StationedPokemon: station.StationedPokemon.Ptr(), } } diff --git a/decoder/api_station_test.go b/decoder/api_station_test.go new file mode 100644 index 00000000..c692d2c9 --- /dev/null +++ b/decoder/api_station_test.go @@ -0,0 +1,57 @@ +package decoder + +import ( + "encoding/json" + "testing" + + "github.com/guregu/null/v6" +) + +// goldenSnapshotStation is a representative station with a mix of set and +// unset (null) fields across every nullable column, used to pin the exact wire +// format. +func goldenSnapshotStation() *Station { + return &Station{ + StationData: StationData{ + Id: "station-abc", + Lat: 45.6789, + Lon: -120.9876, + Name: "Test Station", + StartTime: 1699990000, + EndTime: 1700003600, + IsBattleAvailable: true, + Updated: 1699999999, + BattleLevel: null.IntFrom(5), + // BattleStart intentionally left null + BattleEnd: null.IntFrom(1700001000), + BattlePokemonId: null.IntFrom(150), + BattlePokemonForm: null.IntFrom(0), + BattlePokemonCostume: null.IntFrom(1), + // BattlePokemonGender intentionally left null + BattlePokemonAlignment: null.IntFrom(2), + BattlePokemonBreadMode: null.IntFrom(0), + BattlePokemonMove1: null.IntFrom(101), + BattlePokemonMove2: null.IntFrom(202), + TotalStationedPokemon: null.IntFrom(6), + // TotalStationedGmax intentionally left null + StationedPokemon: null.StringFrom("[{\"pokemon_id\":150}]"), + }, + } +} + +// TestBuildStationResult_GoldenSnapshot pins the exact JSON wire format of an +// ApiStationResult. Any accidental change to a json tag, field type, +// pointer/null handling, or field order will fail this test. Unset nullable +// fields serialize as null (pointers are nil, no omitempty). +func TestBuildStationResult_GoldenSnapshot(t *testing.T) { + got, err := json.Marshal(BuildStationResult(goldenSnapshotStation())) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + const want = `{"id":"station-abc","lat":45.6789,"lon":-120.9876,"name":"Test Station","start_time":1699990000,"end_time":1700003600,"is_battle_available":true,"updated":1699999999,"battle_level":5,"battle_start":null,"battle_end":1700001000,"battle_pokemon_id":150,"battle_pokemon_form":0,"battle_pokemon_costume":1,"battle_pokemon_gender":null,"battle_pokemon_alignment":2,"battle_pokemon_bread_mode":0,"battle_pokemon_move_1":101,"battle_pokemon_move_2":202,"total_stationed_pokemon":6,"total_stationed_gmax":null,"stationed_pokemon":"[{\"pokemon_id\":150}]"}` + + if string(got) != want { + t.Errorf("wire format changed.\n got: %s\nwant: %s", got, want) + } +} From 9e0d0e198b3b1fd7eb4ab23adccecfe3984c483c Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 21:34:21 +0100 Subject: [PATCH 09/19] docs: doc tags + required/optional on fort scan request/result types Co-Authored-By: Claude Opus 4.8 (1M context) --- decoder/api_fort.go | 80 ++++++++++++++++++++++----------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/decoder/api_fort.go b/decoder/api_fort.go index 85f3eee0..726c47cf 100644 --- a/decoder/api_fort.go +++ b/decoder/api_fort.go @@ -19,41 +19,41 @@ type ApiFortScan struct { } type ApiFortDnfFilter struct { - PowerUpLevel *ApiFortDnfMinMax `json:"power_up_level"` - IsArScanEligible *bool `json:"is_ar_scan_eligible"` + PowerUpLevel *ApiFortDnfMinMax `json:"power_up_level" required:"false" doc:"Inclusive power-up level range; null means no power-up level constraint."` + IsArScanEligible *bool `json:"is_ar_scan_eligible" required:"false" doc:"When true, only match forts that are AR scan eligible; null means no AR eligibility constraint."` // Gym - AvailableSlots *ApiFortDnfMinMax `json:"available_slots"` - TeamId []int8 `json:"team_id"` - RaidLevel []int8 `json:"raid_level"` - RaidPokemon []ApiDnfId `json:"raid_pokemon_id"` + AvailableSlots *ApiFortDnfMinMax `json:"available_slots" required:"false" doc:"Gym only: inclusive range of open defender slots; null means no slot constraint."` + TeamId []int8 `json:"team_id" required:"false" doc:"Gym only: allowed controlling team ids; empty means no team constraint."` + RaidLevel []int8 `json:"raid_level" required:"false" doc:"Gym only: allowed active raid levels; empty means no raid level constraint. Only matches gyms with an active raid."` + RaidPokemon []ApiDnfId `json:"raid_pokemon_id" required:"false" doc:"Gym only: allowed active raid boss pokemon/form pairs; empty means no raid pokemon constraint. Only matches gyms with an active raid."` // Pokestop - unified quest (matches AR or no-AR) - LureId []int16 `json:"lure_id"` - QuestRewardType []int16 `json:"quest_reward_type"` - QuestRewardAmount *ApiFortDnfMinMax `json:"quest_reward_amount"` - QuestRewardItemId []int16 `json:"quest_reward_item_id"` - QuestRewardPokemon []ApiDnfId `json:"quest_reward_pokemon"` + LureId []int16 `json:"lure_id" required:"false" doc:"Pokestop only: allowed active lure module ids; empty means no lure constraint."` + QuestRewardType []int16 `json:"quest_reward_type" required:"false" doc:"Pokestop only: allowed quest reward types; matched against either the AR or no-AR quest. Empty means no reward type constraint."` + QuestRewardAmount *ApiFortDnfMinMax `json:"quest_reward_amount" required:"false" doc:"Pokestop only: inclusive quest reward amount range; matched against either the AR or no-AR quest. Null means no reward amount constraint."` + QuestRewardItemId []int16 `json:"quest_reward_item_id" required:"false" doc:"Pokestop only: allowed quest reward item ids; matched against either the AR or no-AR quest. Empty means no reward item constraint."` + QuestRewardPokemon []ApiDnfId `json:"quest_reward_pokemon" required:"false" doc:"Pokestop only: allowed quest reward pokemon/form pairs; matched against either the AR or no-AR quest. Empty means no reward pokemon constraint."` // Pokestop - incident - IncidentDisplayType []int8 `json:"incident_display_type"` - IncidentStyle []int8 `json:"incident_style"` - IncidentCharacter []int16 `json:"incident_character"` - IncidentPokemon []ApiDnfId `json:"incident_pokemon"` + IncidentDisplayType []int8 `json:"incident_display_type" required:"false" doc:"Pokestop only: allowed incident display types; empty means no incident display type constraint."` + IncidentStyle []int8 `json:"incident_style" required:"false" doc:"Pokestop only: allowed incident styles; empty means no incident style constraint."` + IncidentCharacter []int16 `json:"incident_character" required:"false" doc:"Pokestop only: allowed incident character ids; empty means no incident character constraint."` + IncidentPokemon []ApiDnfId `json:"incident_pokemon" required:"false" doc:"Pokestop only: allowed incident pokemon/form pairs; empty means no incident pokemon constraint."` // Pokestop - contest - ContestPokemon []ApiDnfId `json:"contest_pokemon"` - ContestPokemonType []int8 `json:"contest_pokemon_type"` - ContestTotalEntries *ApiFortDnfMinMax `json:"contest_total_entries"` + ContestPokemon []ApiDnfId `json:"contest_pokemon" required:"false" doc:"Pokestop only: allowed contest focus pokemon/form pairs; empty means no contest pokemon constraint."` + ContestPokemonType []int8 `json:"contest_pokemon_type" required:"false" doc:"Pokestop only: allowed contest pokemon types; empty means no contest type constraint."` + ContestTotalEntries *ApiFortDnfMinMax `json:"contest_total_entries" required:"false" doc:"Pokestop only: inclusive range for the contest's total number of entries; null means no contest entries constraint."` // Station - BattleLevel []int8 `json:"battle_level"` - BattlePokemon []ApiDnfId `json:"battle_pokemon"` + BattleLevel []int8 `json:"battle_level" required:"false" doc:"Station only: allowed active max battle levels; empty means no battle level constraint. Only matches stations with an active battle."` + BattlePokemon []ApiDnfId `json:"battle_pokemon" required:"false" doc:"Station only: allowed active max battle pokemon/form pairs; empty means no battle pokemon constraint. Only matches stations with an active battle."` } type ApiDnfId struct { - Pokemon int16 `json:"pokemon_id"` - Form *int16 `json:"form"` + Pokemon int16 `json:"pokemon_id" doc:"Pokedex id to match. Required within an entry — a form without an id can never match."` + Form *int16 `json:"form" required:"false" doc:"Form id to match; null matches any form of the given id."` } // ApiFortDnfMinMax is an inclusive integer range used by the fort filter clauses @@ -64,33 +64,33 @@ type ApiFortDnfMinMax struct { } type ApiGymScanResult struct { - Gyms []*ApiGymResult `json:"gyms"` - Examined int `json:"examined"` - Skipped int `json:"skipped"` - Total int `json:"total"` + Gyms []*ApiGymResult `json:"gyms" doc:"Matching gyms within the bounding box."` + Examined int `json:"examined" doc:"Number of forts examined during the spatial scan."` + Skipped int `json:"skipped" doc:"Number of forts skipped because they were not found in the lookup cache."` + Total int `json:"total" doc:"Total number of forts in the spatial index at scan time."` } type ApiPokestopScanResult struct { - Pokestops []*ApiPokestopResult `json:"pokestops"` - Examined int `json:"examined"` - Skipped int `json:"skipped"` - Total int `json:"total"` + Pokestops []*ApiPokestopResult `json:"pokestops" doc:"Matching pokestops within the bounding box."` + Examined int `json:"examined" doc:"Number of forts examined during the spatial scan."` + Skipped int `json:"skipped" doc:"Number of forts skipped because they were not found in the lookup cache."` + Total int `json:"total" doc:"Total number of forts in the spatial index at scan time."` } type ApiStationScanResult struct { - Stations []*ApiStationResult `json:"stations"` - Examined int `json:"examined"` - Skipped int `json:"skipped"` - Total int `json:"total"` + Stations []*ApiStationResult `json:"stations" doc:"Matching stations within the bounding box."` + Examined int `json:"examined" doc:"Number of forts examined during the spatial scan."` + Skipped int `json:"skipped" doc:"Number of forts skipped because they were not found in the lookup cache."` + Total int `json:"total" doc:"Total number of forts in the spatial index at scan time."` } type ApiFortCombinedScanResult struct { - Gyms []*ApiGymResult `json:"gyms"` - Pokestops []*ApiPokestopResult `json:"pokestops"` - Stations []*ApiStationResult `json:"stations"` - Examined int `json:"examined"` - Skipped int `json:"skipped"` - Total int `json:"total"` + Gyms []*ApiGymResult `json:"gyms" doc:"Matching gyms within the bounding box."` + Pokestops []*ApiPokestopResult `json:"pokestops" doc:"Matching pokestops within the bounding box."` + Stations []*ApiStationResult `json:"stations" doc:"Matching stations within the bounding box."` + Examined int `json:"examined" doc:"Number of forts examined during the spatial scan."` + Skipped int `json:"skipped" doc:"Number of forts skipped because they were not found in the lookup cache."` + Total int `json:"total" doc:"Total number of forts in the spatial index at scan time."` } // matchDnfIdPair checks if any ApiDnfId in the filter matches the given pokemon/form pair From c51e3a664942769ea4021e937650f6cb263e618d Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 21:37:40 +0100 Subject: [PATCH 10/19] feat: serve fort scan via Huma (draft), retire gin handlers Co-Authored-By: Claude Opus 4.8 (1M context) --- huma_routes_test.go | 104 ++++++++++++++++++++++++++++++++++++++++++++ main.go | 5 +-- routes.go | 72 ------------------------------ routes_huma.go | 89 +++++++++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 76 deletions(-) diff --git a/huma_routes_test.go b/huma_routes_test.go index 8e21685d..05d5e281 100644 --- a/huma_routes_test.go +++ b/huma_routes_test.go @@ -7,7 +7,9 @@ import ( "golbat/config" + "github.com/danielgtaylor/huma/v2/adapters/humagin" "github.com/danielgtaylor/huma/v2/humatest" + "github.com/gin-gonic/gin" gojson "github.com/goccy/go-json" ) @@ -96,3 +98,105 @@ func TestHumaScanAcceptsLatLonSpellings(t *testing.T) { }) } } + +// fortScanBody is an empty-filter fort scan request that matches against the +// empty in-memory rtree (no DB), yielding zero results. +const fortScanBody = `{"min":{"lat":0,"lon":0},"max":{"lat":1,"lon":1},"filters":[]}` + +// TestFortScanEndpoints exercises the HTTP pipeline for the migrated fort scan +// endpoints: success (200 with the expected envelope), the FortInMemory 503 +// guard, and the auth requirement (401). No database is required. +func TestFortScanEndpoints(t *testing.T) { + prevSecret := config.Config.ApiSecret + prevInMem := config.Config.FortInMemory + defer func() { + config.Config.ApiSecret = prevSecret + config.Config.FortInMemory = prevInMem + }() + + config.Config.ApiSecret = "" + config.Config.FortInMemory = true + + _, api := humatest.New(t, newHumaConfig("test")) + api.UseMiddleware(golbatSecretMiddleware(api)) + registerHumaRoutes(api) + registerFortScanRoutes(api) + + t.Run("gym scan returns 200 envelope", func(t *testing.T) { + resp := api.Post("/api/gym/scan", strings.NewReader(fortScanBody)) + if resp.Code != http.StatusOK { + t.Fatalf("got %d, want 200; body=%s", resp.Code, resp.Body.String()) + } + var m map[string]any + if err := gojson.Unmarshal(resp.Body.Bytes(), &m); err != nil { + t.Fatalf("body is not a JSON object: %v; body=%s", err, resp.Body.String()) + } + for _, key := range []string{"gyms", "examined", "skipped", "total"} { + if _, ok := m[key]; !ok { + t.Errorf("body missing key %q: %s", key, resp.Body.String()) + } + } + }) + + t.Run("503 when fort_in_memory disabled", func(t *testing.T) { + config.Config.FortInMemory = false + defer func() { config.Config.FortInMemory = true }() + resp := api.Post("/api/gym/scan", strings.NewReader(fortScanBody)) + if resp.Code != http.StatusServiceUnavailable { + t.Errorf("got %d, want 503; body=%s", resp.Code, resp.Body.String()) + } + }) + + t.Run("401 without secret when auth configured", func(t *testing.T) { + config.Config.ApiSecret = "secret" + defer func() { config.Config.ApiSecret = "" }() + resp := api.Post("/api/gym/scan", strings.NewReader(fortScanBody)) + if resp.Code != http.StatusUnauthorized { + t.Errorf("got %d, want 401; body=%s", resp.Code, resp.Body.String()) + } + }) +} + +// TestFortScanDraftBadge asserts the four fort scan operations carry the +// x-badges extension (draft marker) in the OpenAPI spec, while an existing +// pokemon operation does not. +func TestFortScanDraftBadge(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + api := humagin.New(r, newHumaConfig("test")) + registerHumaRoutes(api) + registerFortScanRoutes(api) + + raw, err := gojson.Marshal(api.OpenAPI()) + if err != nil { + t.Fatalf("marshal openapi: %v", err) + } + + var doc struct { + Paths map[string]map[string]struct { + Badges any `json:"x-badges"` + } `json:"paths"` + } + if err := gojson.Unmarshal(raw, &doc); err != nil { + t.Fatalf("unmarshal openapi: %v", err) + } + + for _, path := range []string{"/api/gym/scan", "/api/pokestop/scan", "/api/station/scan", "/api/fort/scan"} { + op, ok := doc.Paths[path]["post"] + if !ok { + t.Errorf("path %q has no post operation", path) + continue + } + if op.Badges == nil { + t.Errorf("path %q post is missing x-badges (draft marker)", path) + } + } + + pokemonOp, ok := doc.Paths["/api/pokemon/v2/scan"]["post"] + if !ok { + t.Fatalf("pokemon v2 scan op not found") + } + if pokemonOp.Badges != nil { + t.Errorf("pokemon v2 scan must NOT carry x-badges, got %v", pokemonOp.Badges) + } +} diff --git a/main.go b/main.go index 550d04a6..493dbbce 100644 --- a/main.go +++ b/main.go @@ -338,11 +338,7 @@ func main() { apiGroup.GET("/gym/id/:gym_id", GetGym) apiGroup.POST("/gym/query", GetGyms) apiGroup.POST("/gym/search", SearchGyms) - apiGroup.POST("/gym/scan", GymScan) - apiGroup.POST("/pokestop/scan", PokestopScan) apiGroup.POST("/station/query", GetStations) - apiGroup.POST("/station/scan", StationScan) - apiGroup.POST("/fort/scan", FortScan) apiGroup.POST("/reload-geojson", ReloadGeojson) apiGroup.GET("/reload-geojson", ReloadGeojson) @@ -395,6 +391,7 @@ func main() { humaAPI := setupHumaAPI(r) registerHumaRoutes(humaAPI) + registerFortScanRoutes(humaAPI) srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Port), diff --git a/routes.go b/routes.go index 03d22500..5eb60990 100644 --- a/routes.go +++ b/routes.go @@ -785,78 +785,6 @@ func SearchGyms(c *gin.Context) { c.JSON(http.StatusOK, out) } -// POST /api/gym/scan -// In-memory fort scan with DNF filters (requires fort_in_memory=true) -func GymScan(c *gin.Context) { - if !config.Config.FortInMemory { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "fort_in_memory not enabled"}) - return - } - - var params decoder.ApiFortScan - if err := c.ShouldBindJSON(¶ms); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) - return - } - - result := decoder.GymScanEndpoint(params, dbDetails) - c.JSON(http.StatusOK, result) -} - -// POST /api/pokestop/scan -// In-memory fort scan with DNF filters (requires fort_in_memory=true) -func PokestopScan(c *gin.Context) { - if !config.Config.FortInMemory { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "fort_in_memory not enabled"}) - return - } - - var params decoder.ApiFortScan - if err := c.ShouldBindJSON(¶ms); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) - return - } - - result := decoder.PokestopScanEndpoint(params, dbDetails) - c.JSON(http.StatusOK, result) -} - -// POST /api/fort/scan -// Combined in-memory fort scan returning gyms, pokestops, and stations in a single rtree traversal -func FortScan(c *gin.Context) { - if !config.Config.FortInMemory { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "fort_in_memory not enabled"}) - return - } - - var params decoder.ApiFortScan - if err := c.ShouldBindJSON(¶ms); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) - return - } - - result := decoder.FortCombinedScanEndpoint(params, dbDetails) - c.JSON(http.StatusOK, result) -} - -// POST /api/station/scan -// In-memory fort scan with DNF filters (requires fort_in_memory=true) -func StationScan(c *gin.Context) { - if !config.Config.FortInMemory { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "fort_in_memory not enabled"}) - return - } - - var params decoder.ApiFortScan - if err := c.ShouldBindJSON(¶ms); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) - return - } - - result := decoder.StationScanEndpoint(params, dbDetails) - c.JSON(http.StatusOK, result) -} - func GetTappable(c *gin.Context) { id := c.Param("tappable_id") tappableId, err := strconv.ParseUint(id, 10, 64) diff --git a/routes_huma.go b/routes_huma.go index dc4a4c33..61fceeb0 100644 --- a/routes_huma.go +++ b/routes_huma.go @@ -4,6 +4,7 @@ import ( "context" "net/http" + "golbat/config" "golbat/decoder" "github.com/danielgtaylor/huma/v2" @@ -53,3 +54,91 @@ func registerHumaRoutes(api huma.API) { return &pokemonV3ScanOutput{Body: *decoder.GetPokemonInArea3Clean(in.Body)}, nil }) } + +type gymScanInput struct{ Body decoder.ApiFortScan } +type gymScanOutput struct{ Body decoder.ApiGymScanResult } + +type pokestopScanInput struct{ Body decoder.ApiFortScan } +type pokestopScanOutput struct{ Body decoder.ApiPokestopScanResult } + +type stationScanInput struct{ Body decoder.ApiFortScan } +type stationScanOutput struct{ Body decoder.ApiStationScanResult } + +type fortScanInput struct{ Body decoder.ApiFortScan } +type fortScanOutput struct{ Body decoder.ApiFortCombinedScanResult } + +// registerFortScanRoutes registers the four in-memory fort scan operations. +// These are gated by config.Config.FortInMemory and return 503 when disabled. +func registerFortScanRoutes(api huma.API) { + gymOp := huma.Operation{ + OperationID: "scan-gyms", + Method: http.MethodPost, + Path: "/api/gym/scan", + Summary: "Search gyms in a bounding box (DNF filters)", + Description: "Returns gyms within [min,max] matching any DNF filter clause.", + Tags: []string{"Fort"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusOK, + } + draftBadge(&gymOp) + huma.Register(api, gymOp, func(ctx context.Context, in *gymScanInput) (*gymScanOutput, error) { + if !config.Config.FortInMemory { + return nil, huma.Error503ServiceUnavailable("fort_in_memory not enabled") + } + return &gymScanOutput{Body: *decoder.GymScanEndpoint(in.Body, dbDetails)}, nil + }) + + pokestopOp := huma.Operation{ + OperationID: "scan-pokestops", + Method: http.MethodPost, + Path: "/api/pokestop/scan", + Summary: "Search pokestops in a bounding box (DNF filters)", + Description: "Returns pokestops within [min,max] matching any DNF filter clause.", + Tags: []string{"Fort"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusOK, + } + draftBadge(&pokestopOp) + huma.Register(api, pokestopOp, func(ctx context.Context, in *pokestopScanInput) (*pokestopScanOutput, error) { + if !config.Config.FortInMemory { + return nil, huma.Error503ServiceUnavailable("fort_in_memory not enabled") + } + return &pokestopScanOutput{Body: *decoder.PokestopScanEndpoint(in.Body, dbDetails)}, nil + }) + + stationOp := huma.Operation{ + OperationID: "scan-stations", + Method: http.MethodPost, + Path: "/api/station/scan", + Summary: "Search stations in a bounding box (DNF filters)", + Description: "Returns stations within [min,max] matching any DNF filter clause.", + Tags: []string{"Fort"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusOK, + } + draftBadge(&stationOp) + huma.Register(api, stationOp, func(ctx context.Context, in *stationScanInput) (*stationScanOutput, error) { + if !config.Config.FortInMemory { + return nil, huma.Error503ServiceUnavailable("fort_in_memory not enabled") + } + return &stationScanOutput{Body: *decoder.StationScanEndpoint(in.Body, dbDetails)}, nil + }) + + fortOp := huma.Operation{ + OperationID: "scan-forts", + Method: http.MethodPost, + Path: "/api/fort/scan", + Summary: "Search all fort types in a bounding box (DNF filters)", + Description: "Returns gyms, pokestops, and stations within [min,max] matching any DNF filter clause, in a single rtree traversal.", + Tags: []string{"Fort"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusOK, + } + draftBadge(&fortOp) + huma.Register(api, fortOp, func(ctx context.Context, in *fortScanInput) (*fortScanOutput, error) { + if !config.Config.FortInMemory { + return nil, huma.Error503ServiceUnavailable("fort_in_memory not enabled") + } + return &fortScanOutput{Body: *decoder.FortCombinedScanEndpoint(in.Body, dbDetails)}, nil + }) +} From 20db9893ee391484612fa1177daf932722b8c1cf Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 21:42:35 +0100 Subject: [PATCH 11/19] feat: serve pokemon search + by-id via Huma Co-Authored-By: Claude Opus 4.8 (1M context) --- decoder/api_pokemon.go | 16 +++++++-------- huma_routes_test.go | 39 ++++++++++++++++++++++++++++++++++++ main.go | 3 +-- routes.go | 34 ------------------------------- routes_huma.go | 45 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 44 deletions(-) diff --git a/decoder/api_pokemon.go b/decoder/api_pokemon.go index 2ff49ff7..8c85adae 100644 --- a/decoder/api_pokemon.go +++ b/decoder/api_pokemon.go @@ -39,11 +39,11 @@ func GetAvailablePokemon() []*ApiPokemonAvailableResult { // Pokemon search type ApiPokemonSearch struct { - Min geo.Location `json:"min"` - Max geo.Location `json:"max"` - Center geo.Location `json:"center"` - Limit int `json:"limit"` - SearchIds []int16 `json:"searchIds"` + Min ApiLatLon `json:"min" doc:"Lower-left (minimum lat/lon) corner of the bounding box to search."` + Max ApiLatLon `json:"max" doc:"Upper-right (maximum lat/lon) corner of the bounding box to search."` + Center ApiLatLon `json:"center" required:"false" doc:"Center point used to order results by distance. Defaults to the zero coordinate."` + Limit int `json:"limit" required:"false" doc:"Maximum number of results to return. 0 means use the configured maximum."` + SearchIds []int16 `json:"searchIds" required:"false" doc:"Pokemon ids to match. A pokemon is returned only if its id is in this list."` } func calculateHypotenuse(a, b float64) float64 { @@ -76,7 +76,7 @@ func SearchPokemon(request ApiPokemonSearch) ([]*ApiPokemonResult, error) { if request.SearchIds == nil { return nil, fmt.Errorf("SearchPokemon - no search ids provided") } - if haversine(request.Min, request.Max) > config.Config.Tuning.MaxPokemonDistance { + if haversine(request.Min.Location(), request.Max.Location()) > config.Config.Tuning.MaxPokemonDistance { return nil, fmt.Errorf("SearchPokemon - the distance between max and min points is greater than the configurable max distance") } @@ -90,13 +90,13 @@ func SearchPokemon(request ApiPokemonSearch) ([]*ApiPokemonResult, error) { } pokemonSkipped := 0 pokemonScanned := 0 - maxDistance := calculateHypotenuse(request.Max.Longitude-request.Min.Longitude, request.Max.Latitude-request.Min.Latitude) / 2 + maxDistance := calculateHypotenuse(request.Max.Lon-request.Min.Lon, request.Max.Lat-request.Min.Lat) / 2 if maxDistance == 0 { maxDistance = 10 } pokemonTree2.Nearby( - rtree.BoxDist[float64, uint64]([2]float64{request.Center.Longitude, request.Center.Latitude}, [2]float64{request.Center.Longitude, request.Center.Latitude}, nil), + rtree.BoxDist[float64, uint64]([2]float64{request.Center.Lon, request.Center.Lat}, [2]float64{request.Center.Lon, request.Center.Lat}, nil), func(min, max [2]float64, pokemonId uint64, dist float64) bool { pokemonLookupItem, inCache := pokemonLookupCache.Load(pokemonId) if !inCache { diff --git a/huma_routes_test.go b/huma_routes_test.go index 05d5e281..f5b497ff 100644 --- a/huma_routes_test.go +++ b/huma_routes_test.go @@ -99,6 +99,45 @@ func TestHumaScanAcceptsLatLonSpellings(t *testing.T) { } } +// TestPokemonReadEndpoints exercises the migrated pokemon search and by-id read +// endpoints over the HTTP pipeline without a database: get-pokemon for an absent +// id is 404, and search-pokemon returns a 202 bare JSON array (empty against the +// empty in-memory rtree). +func TestPokemonReadEndpoints(t *testing.T) { + prev := config.Config.ApiSecret + prevDist := config.Config.Tuning.MaxPokemonDistance + config.Config.ApiSecret = "" + // The 1x1 degree bounding box spans ~157km; allow it through the distance guard. + config.Config.Tuning.MaxPokemonDistance = 100000 + defer func() { + config.Config.ApiSecret = prev + config.Config.Tuning.MaxPokemonDistance = prevDist + }() + + _, api := humatest.New(t, newHumaConfig("test")) + api.UseMiddleware(golbatSecretMiddleware(api)) + registerPokemonReadRoutes(api) + + t.Run("get-pokemon for unknown id is 404", func(t *testing.T) { + resp := api.Get("/api/pokemon/id/123456789") + if resp.Code != http.StatusNotFound { + t.Errorf("got %d, want 404; body=%s", resp.Code, resp.Body.String()) + } + }) + + t.Run("search-pokemon returns 202 bare array", func(t *testing.T) { + body := `{"min":{"lat":0,"lon":0},"max":{"lat":1,"lon":1},"searchIds":[25]}` + resp := api.Post("/api/pokemon/search", strings.NewReader(body)) + if resp.Code != http.StatusAccepted { + t.Fatalf("got %d, want 202; body=%s", resp.Code, resp.Body.String()) + } + var arr []any + if err := gojson.Unmarshal(resp.Body.Bytes(), &arr); err != nil { + t.Fatalf("body is not a JSON array: %v; body=%s", err, resp.Body.String()) + } + }) +} + // fortScanBody is an empty-filter fort scan request that matches against the // empty in-memory rtree (no DB), yielding zero results. const fortScanBody = `{"min":{"lat":0,"lon":0},"max":{"lat":1,"lon":1},"filters":[]}` diff --git a/main.go b/main.go index 493dbbce..4247eaf1 100644 --- a/main.go +++ b/main.go @@ -342,10 +342,8 @@ func main() { apiGroup.POST("/reload-geojson", ReloadGeojson) apiGroup.GET("/reload-geojson", ReloadGeojson) - apiGroup.GET("/pokemon/id/:pokemon_id", PokemonOne) apiGroup.GET("/pokemon/available", PokemonAvailable) apiGroup.POST("/pokemon/scan", PokemonScan) - apiGroup.POST("/pokemon/search", PokemonSearch) apiGroup.GET("/tappable/id/:tappable_id", GetTappable) @@ -392,6 +390,7 @@ func main() { humaAPI := setupHumaAPI(r) registerHumaRoutes(humaAPI) registerFortScanRoutes(humaAPI) + registerPokemonReadRoutes(humaAPI) srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Port), diff --git a/routes.go b/routes.go index 5eb60990..b677d5d3 100644 --- a/routes.go +++ b/routes.go @@ -376,45 +376,11 @@ func PokemonScan(c *gin.Context) { c.JSON(http.StatusAccepted, res) } -func PokemonOne(c *gin.Context) { - pokemonId, err := strconv.ParseUint(c.Param("pokemon_id"), 10, 64) - if err != nil { - log.Warnf("GET /api/pokemon/:pokemon_id/ Error during get pokemon %v", err) - c.Status(http.StatusInternalServerError) - return - } - res := decoder.GetOnePokemon(uint64(pokemonId)) - - if res != nil { - c.JSON(http.StatusAccepted, res) - } else { - c.Status(http.StatusNotFound) - } -} - func PokemonAvailable(c *gin.Context) { res := decoder.GetAvailablePokemon() c.JSON(http.StatusAccepted, res) } -func PokemonSearch(c *gin.Context) { - var requestBody decoder.ApiPokemonSearch - - if err := c.BindJSON(&requestBody); err != nil { - log.Warnf("POST /api/search/ Error during post search %v", err) - c.Status(http.StatusInternalServerError) - return - } - - res, err := decoder.SearchPokemon(requestBody) - if err != nil { - log.Warnf("POST /api/search/ Error during post search %v", err) - c.Status(http.StatusBadRequest) - return - } - c.JSON(http.StatusAccepted, res) -} - func GetQuestStatus(c *gin.Context) { fence, err := geo.NormaliseFenceRequest(c) diff --git a/routes_huma.go b/routes_huma.go index 61fceeb0..cc6f91d7 100644 --- a/routes_huma.go +++ b/routes_huma.go @@ -55,6 +55,51 @@ func registerHumaRoutes(api huma.API) { }) } +type pokemonSearchInput struct{ Body decoder.ApiPokemonSearch } +type pokemonSearchOutput struct{ Body []*decoder.ApiPokemonResult } + +type pokemonByIdInput struct { + PokemonId uint64 `path:"pokemon_id" doc:"Encounter ID of the pokemon"` +} +type pokemonByIdOutput struct{ Body decoder.ApiPokemonResult } + +// registerPokemonReadRoutes registers the pokemon search and by-id read operations. +func registerPokemonReadRoutes(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "search-pokemon", + Method: http.MethodPost, + Path: "/api/pokemon/search", + Summary: "Search pokemon by id within a bounding box", + Description: "Returns pokemon within [min,max] whose id is in searchIds, ordered by distance from center. Returns a bare array.", + Tags: []string{"Pokemon"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusAccepted, + }, func(ctx context.Context, in *pokemonSearchInput) (*pokemonSearchOutput, error) { + res, err := decoder.SearchPokemon(in.Body) + if err != nil { + return nil, huma.Error400BadRequest(err.Error()) + } + return &pokemonSearchOutput{Body: res}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "get-pokemon", + Method: http.MethodGet, + Path: "/api/pokemon/id/{pokemon_id}", + Summary: "Get a single pokemon by encounter id", + Description: "Returns the pokemon with the given encounter id, or 404 if not present in the cache.", + Tags: []string{"Pokemon"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusAccepted, + }, func(ctx context.Context, in *pokemonByIdInput) (*pokemonByIdOutput, error) { + res := decoder.GetOnePokemon(in.PokemonId) + if res == nil { + return nil, huma.Error404NotFound("pokemon not found") + } + return &pokemonByIdOutput{Body: *res}, nil + }) +} + type gymScanInput struct{ Body decoder.ApiFortScan } type gymScanOutput struct{ Body decoder.ApiGymScanResult } From 821031310022c2cc0758f2a94180fb9790e9b5cb Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 21:46:29 +0100 Subject: [PATCH 12/19] refactor: ApiTappableResult pointer-based + doc tags --- decoder/api_tappable.go | 41 ++++++++++++++++--------------- decoder/api_tappable_test.go | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 20 deletions(-) create mode 100644 decoder/api_tappable_test.go diff --git a/decoder/api_tappable.go b/decoder/api_tappable.go index 96874b65..d71383ba 100644 --- a/decoder/api_tappable.go +++ b/decoder/api_tappable.go @@ -1,20 +1,21 @@ package decoder -import "github.com/guregu/null/v6" - +// ApiTappableResult is the API representation of a tappable. Nullable database +// columns are represented as pointers (nil => JSON null) without omitempty so +// every key is always present. type ApiTappableResult struct { - Id uint64 `json:"id"` - Lat float64 `json:"lat"` - Lon float64 `json:"lon"` - FortId null.String `json:"fort_id"` - SpawnId null.Int `json:"spawn_id"` - Type string `json:"type"` - Encounter null.Int `json:"pokemon_id"` - ItemId null.Int `json:"item_id"` - Count null.Int `json:"count"` - ExpireTimestamp null.Int `json:"expire_timestamp"` - ExpireTimestampVerified bool `json:"expire_timestamp_verified"` - Updated int64 `json:"updated"` + Id uint64 `json:"id" doc:"Tappable encounter ID"` + Lat float64 `json:"lat" doc:"Latitude of the tappable"` + Lon float64 `json:"lon" doc:"Longitude of the tappable"` + FortId *string `json:"fort_id" doc:"ID of the fort the tappable belongs to"` + SpawnId *int64 `json:"spawn_id" doc:"ID of the spawnpoint the tappable belongs to"` + Type string `json:"type" doc:"Type of the tappable"` + Encounter *int64 `json:"pokemon_id" doc:"Pokedex ID of the encountered pokemon"` + ItemId *int64 `json:"item_id" doc:"ID of the item reward"` + Count *int64 `json:"count" doc:"Count of the item reward"` + ExpireTimestamp *int64 `json:"expire_timestamp" doc:"Unix timestamp when the tappable expires"` + ExpireTimestampVerified bool `json:"expire_timestamp_verified" doc:"Whether the expire timestamp is verified"` + Updated int64 `json:"updated" doc:"Unix timestamp when the record was last updated"` } func buildTappableResult(tappable *Tappable) ApiTappableResult { @@ -22,13 +23,13 @@ func buildTappableResult(tappable *Tappable) ApiTappableResult { Id: tappable.Id, Lat: tappable.Lat, Lon: tappable.Lon, - FortId: tappable.FortId, - SpawnId: tappable.SpawnId, + FortId: tappable.FortId.Ptr(), + SpawnId: tappable.SpawnId.Ptr(), Type: tappable.Type, - Encounter: tappable.Encounter, - ItemId: tappable.ItemId, - Count: tappable.Count, - ExpireTimestamp: tappable.ExpireTimestamp, + Encounter: tappable.Encounter.Ptr(), + ItemId: tappable.ItemId.Ptr(), + Count: tappable.Count.Ptr(), + ExpireTimestamp: tappable.ExpireTimestamp.Ptr(), ExpireTimestampVerified: tappable.ExpireTimestampVerified, Updated: tappable.Updated, } diff --git a/decoder/api_tappable_test.go b/decoder/api_tappable_test.go new file mode 100644 index 00000000..601f4f58 --- /dev/null +++ b/decoder/api_tappable_test.go @@ -0,0 +1,47 @@ +package decoder + +import ( + "encoding/json" + "testing" + + "github.com/guregu/null/v6" +) + +// goldenSnapshotTappable is a representative tappable with a mix of set and +// unset (null) fields across the nullable columns, used to pin the exact wire +// format. +func goldenSnapshotTappable() *Tappable { + return &Tappable{ + TappableData: TappableData{ + Id: 123456789, + Lat: 45.6789, + Lon: -120.9876, + FortId: null.StringFrom("fort-abc"), + // SpawnId intentionally left null + Type: "item", + Encounter: null.IntFrom(150), + ItemId: null.IntFrom(1), + // Count intentionally left null + ExpireTimestamp: null.IntFrom(1700001000), + ExpireTimestampVerified: true, + Updated: 1699999999, + }, + } +} + +// TestBuildTappableResult_GoldenSnapshot pins the exact JSON wire format of an +// ApiTappableResult. Any accidental change to a json tag, field type, +// pointer/null handling, or field order will fail this test. Unset nullable +// fields serialize as null (pointers are nil, no omitempty). +func TestBuildTappableResult_GoldenSnapshot(t *testing.T) { + got, err := json.Marshal(BuildTappableResult(goldenSnapshotTappable())) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + const want = `{"id":123456789,"lat":45.6789,"lon":-120.9876,"fort_id":"fort-abc","spawn_id":null,"type":"item","pokemon_id":150,"item_id":1,"count":null,"expire_timestamp":1700001000,"expire_timestamp_verified":true,"updated":1699999999}` + + if string(got) != want { + t.Errorf("wire format changed.\n got: %s\nwant: %s", got, want) + } +} From 475301473cdbea49ca90dfdb5209d7d71fe77af6 Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 21:52:20 +0100 Subject: [PATCH 13/19] feat: serve tier-3 read endpoints via Huma Co-Authored-By: Claude Opus 4.8 (1M context) --- decoder/api_gym.go | 102 ++++++------ huma_routes_test.go | 91 +++++++++++ main.go | 9 +- routes.go | 377 -------------------------------------------- routes_huma.go | 345 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 487 insertions(+), 437 deletions(-) diff --git a/decoder/api_gym.go b/decoder/api_gym.go index 93981d73..01322c24 100644 --- a/decoder/api_gym.go +++ b/decoder/api_gym.go @@ -14,47 +14,47 @@ import ( // represented as pointers (nil => JSON null) without omitempty so every key is // always present. type ApiGymResult struct { - Id string `json:"id" doc:"Fort ID of the gym"` - Lat float64 `json:"lat" doc:"Latitude of the gym"` - Lon float64 `json:"lon" doc:"Longitude of the gym"` - Name *string `json:"name" doc:"Name of the gym"` - Url *string `json:"url" doc:"Image URL of the gym"` - LastModifiedTimestamp *int64 `json:"last_modified_timestamp" doc:"Unix timestamp when the gym was last modified in-game"` - RaidEndTimestamp *int64 `json:"raid_end_timestamp" doc:"Unix timestamp when the current raid ends"` - RaidSpawnTimestamp *int64 `json:"raid_spawn_timestamp" doc:"Unix timestamp when the current raid egg spawned"` - RaidBattleTimestamp *int64 `json:"raid_battle_timestamp" doc:"Unix timestamp when the current raid battle begins"` - Updated int64 `json:"updated" doc:"Unix timestamp when the record was last updated"` - RaidPokemonId *int64 `json:"raid_pokemon_id" doc:"Pokedex ID of the raid boss"` - GuardingPokemonId *int64 `json:"guarding_pokemon_id" doc:"Pokedex ID of the pokemon guarding the gym"` - GuardingPokemonDisplay *string `json:"guarding_pokemon_display" doc:"Display details of the guarding pokemon"` - AvailableSlots *int64 `json:"available_slots" doc:"Number of open defender slots"` - TeamId *int64 `json:"team_id" doc:"ID of the team controlling the gym"` - RaidLevel *int64 `json:"raid_level" doc:"Level/tier of the current raid"` - Enabled *int64 `json:"enabled" doc:"Whether the gym is enabled"` - ExRaidEligible *int64 `json:"ex_raid_eligible" doc:"Whether the gym is eligible for EX raids"` - InBattle *int64 `json:"in_battle" doc:"Whether the gym is currently in battle"` - RaidPokemonMove1 *int64 `json:"raid_pokemon_move_1" doc:"Fast move ID of the raid boss"` - RaidPokemonMove2 *int64 `json:"raid_pokemon_move_2" doc:"Charge move ID of the raid boss"` - RaidPokemonForm *int64 `json:"raid_pokemon_form" doc:"Form ID of the raid boss"` - RaidPokemonAlignment *int64 `json:"raid_pokemon_alignment" doc:"Alignment of the raid boss"` - RaidPokemonCp *int64 `json:"raid_pokemon_cp" doc:"Combat power of the raid boss"` - RaidIsExclusive *int64 `json:"raid_is_exclusive" doc:"Whether the current raid is exclusive (EX)"` - CellId *int64 `json:"cell_id" doc:"S2 cell ID the gym belongs to"` - Deleted bool `json:"deleted" doc:"Whether the gym has been deleted"` - TotalCp *int64 `json:"total_cp" doc:"Total combat power of the gym defenders"` - FirstSeenTimestamp int64 `json:"first_seen_timestamp" doc:"Unix timestamp when the gym was first seen"` - RaidPokemonGender *int64 `json:"raid_pokemon_gender" doc:"Gender of the raid boss"` - SponsorId *int64 `json:"sponsor_id" doc:"Sponsor ID of the gym, if sponsored"` - PartnerId *string `json:"partner_id" doc:"Partner ID of the gym, if partnered"` - RaidPokemonCostume *int64 `json:"raid_pokemon_costume" doc:"Costume ID of the raid boss"` - RaidPokemonEvolution *int64 `json:"raid_pokemon_evolution" doc:"Evolution ID of the raid boss (e.g. mega)"` - ArScanEligible *int64 `json:"ar_scan_eligible" doc:"Whether the gym is eligible for AR scanning"` - PowerUpLevel *int64 `json:"power_up_level" doc:"Power-up level of the gym"` - PowerUpPoints *int64 `json:"power_up_points" doc:"Power-up points accumulated for the gym"` - PowerUpEndTimestamp *int64 `json:"power_up_end_timestamp" doc:"Unix timestamp when the power-up ends"` - Description *string `json:"description" doc:"Description of the gym"` - Defenders *string `json:"defenders" doc:"Serialized defender pokemon data"` - Rsvps *string `json:"rsvps" doc:"Serialized raid RSVP data"` + Id string `json:"id" doc:"Fort ID of the gym"` + Lat float64 `json:"lat" doc:"Latitude of the gym"` + Lon float64 `json:"lon" doc:"Longitude of the gym"` + Name *string `json:"name" doc:"Name of the gym"` + Url *string `json:"url" doc:"Image URL of the gym"` + LastModifiedTimestamp *int64 `json:"last_modified_timestamp" doc:"Unix timestamp when the gym was last modified in-game"` + RaidEndTimestamp *int64 `json:"raid_end_timestamp" doc:"Unix timestamp when the current raid ends"` + RaidSpawnTimestamp *int64 `json:"raid_spawn_timestamp" doc:"Unix timestamp when the current raid egg spawned"` + RaidBattleTimestamp *int64 `json:"raid_battle_timestamp" doc:"Unix timestamp when the current raid battle begins"` + Updated int64 `json:"updated" doc:"Unix timestamp when the record was last updated"` + RaidPokemonId *int64 `json:"raid_pokemon_id" doc:"Pokedex ID of the raid boss"` + GuardingPokemonId *int64 `json:"guarding_pokemon_id" doc:"Pokedex ID of the pokemon guarding the gym"` + GuardingPokemonDisplay *string `json:"guarding_pokemon_display" doc:"Display details of the guarding pokemon"` + AvailableSlots *int64 `json:"available_slots" doc:"Number of open defender slots"` + TeamId *int64 `json:"team_id" doc:"ID of the team controlling the gym"` + RaidLevel *int64 `json:"raid_level" doc:"Level/tier of the current raid"` + Enabled *int64 `json:"enabled" doc:"Whether the gym is enabled"` + ExRaidEligible *int64 `json:"ex_raid_eligible" doc:"Whether the gym is eligible for EX raids"` + InBattle *int64 `json:"in_battle" doc:"Whether the gym is currently in battle"` + RaidPokemonMove1 *int64 `json:"raid_pokemon_move_1" doc:"Fast move ID of the raid boss"` + RaidPokemonMove2 *int64 `json:"raid_pokemon_move_2" doc:"Charge move ID of the raid boss"` + RaidPokemonForm *int64 `json:"raid_pokemon_form" doc:"Form ID of the raid boss"` + RaidPokemonAlignment *int64 `json:"raid_pokemon_alignment" doc:"Alignment of the raid boss"` + RaidPokemonCp *int64 `json:"raid_pokemon_cp" doc:"Combat power of the raid boss"` + RaidIsExclusive *int64 `json:"raid_is_exclusive" doc:"Whether the current raid is exclusive (EX)"` + CellId *int64 `json:"cell_id" doc:"S2 cell ID the gym belongs to"` + Deleted bool `json:"deleted" doc:"Whether the gym has been deleted"` + TotalCp *int64 `json:"total_cp" doc:"Total combat power of the gym defenders"` + FirstSeenTimestamp int64 `json:"first_seen_timestamp" doc:"Unix timestamp when the gym was first seen"` + RaidPokemonGender *int64 `json:"raid_pokemon_gender" doc:"Gender of the raid boss"` + SponsorId *int64 `json:"sponsor_id" doc:"Sponsor ID of the gym, if sponsored"` + PartnerId *string `json:"partner_id" doc:"Partner ID of the gym, if partnered"` + RaidPokemonCostume *int64 `json:"raid_pokemon_costume" doc:"Costume ID of the raid boss"` + RaidPokemonEvolution *int64 `json:"raid_pokemon_evolution" doc:"Evolution ID of the raid boss (e.g. mega)"` + ArScanEligible *int64 `json:"ar_scan_eligible" doc:"Whether the gym is eligible for AR scanning"` + PowerUpLevel *int64 `json:"power_up_level" doc:"Power-up level of the gym"` + PowerUpPoints *int64 `json:"power_up_points" doc:"Power-up points accumulated for the gym"` + PowerUpEndTimestamp *int64 `json:"power_up_end_timestamp" doc:"Unix timestamp when the power-up ends"` + Description *string `json:"description" doc:"Description of the gym"` + Defenders *string `json:"defenders" doc:"Serialized defender pokemon data"` + Rsvps *string `json:"rsvps" doc:"Serialized raid RSVP data"` } func buildGymResult(gym *Gym) ApiGymResult { @@ -108,23 +108,23 @@ func BuildGymResult(gym *Gym) ApiGymResult { } type ApiGymSearch struct { - Limit int `json:"limit"` - Filters []ApiGymSearchFilter `json:"filters"` + Limit int `json:"limit" doc:"Maximum number of gyms to return (default 500, max 10000)"` + Filters []ApiGymSearchFilter `json:"filters" doc:"Filter clauses; conditions within a clause are AND'd"` } type LocationDistance struct { Location struct { - Latitude float64 `json:"lat"` - Longitude float64 `json:"lon"` - } `json:"location"` - Distance float64 `json:"distance"` + Latitude float64 `json:"lat" doc:"Latitude of the search center"` + Longitude float64 `json:"lon" doc:"Longitude of the search center"` + } `json:"location" doc:"Center point of the radius search"` + Distance float64 `json:"distance" doc:"Search radius in meters (max 500000)"` } type ApiGymSearchFilter struct { - Name *string `json:"name"` - Description *string `json:"description"` - LocationDistance *LocationDistance `json:"location_distance"` - Bbox *geo.Bbox `json:"bbox"` + Name *string `json:"name" doc:"Optional gym name substring to match"` + Description *string `json:"description" doc:"Optional gym description substring to match"` + LocationDistance *LocationDistance `json:"location_distance" doc:"Optional geographic radius search"` + Bbox *geo.Bbox `json:"bbox" doc:"Optional bounding box search"` } // SearchGymsAPI searches for gyms using the new API structure with AND filters diff --git a/huma_routes_test.go b/huma_routes_test.go index f5b497ff..495ea441 100644 --- a/huma_routes_test.go +++ b/huma_routes_test.go @@ -2,6 +2,7 @@ package main import ( "net/http" + "strconv" "strings" "testing" @@ -138,6 +139,96 @@ func TestPokemonReadEndpoints(t *testing.T) { }) } +// TestTier3ReadEndpoints exercises the migrated tier-3 read endpoints over the +// HTTP pipeline without a database: gym/query with an empty ids list returns a +// 200 empty array, and an oversized ids list returns 413. (gym/id 404 needs a +// DB fallback so it is covered by the registration smoke test instead.) +func TestTier3ReadEndpoints(t *testing.T) { + prev := config.Config.ApiSecret + config.Config.ApiSecret = "" + defer func() { config.Config.ApiSecret = prev }() + + _, api := humatest.New(t, newHumaConfig("test")) + api.UseMiddleware(golbatSecretMiddleware(api)) + registerTier3Routes(api) + + t.Run("gym/query with empty ids returns 200 empty array", func(t *testing.T) { + resp := api.Post("/api/gym/query", strings.NewReader(`{"ids":[]}`)) + if resp.Code != http.StatusOK { + t.Fatalf("got %d, want 200; body=%s", resp.Code, resp.Body.String()) + } + body := strings.TrimSpace(resp.Body.String()) + if body != "[]" { + t.Errorf("body = %q, want \"[]\"", body) + } + }) + + t.Run("pokestop/id for unknown id is 404", func(t *testing.T) { + // PeekPokestopRecord is cache-only (no DB fallback), so a missing id is + // a clean 404 with no database. + resp := api.Get("/api/pokestop/id/does-not-exist") + if resp.Code != http.StatusNotFound { + t.Errorf("got %d, want 404; body=%s", resp.Code, resp.Body.String()) + } + }) + + t.Run("tappable/id for unknown id is 404", func(t *testing.T) { + // PeekTappableRecord is cache-only, so a missing id is a clean 404. + resp := api.Get("/api/tappable/id/123456789") + if resp.Code != http.StatusNotFound { + t.Errorf("got %d, want 404; body=%s", resp.Code, resp.Body.String()) + } + }) + + t.Run("gym/query rejecting >500 ids returns 413", func(t *testing.T) { + ids := make([]string, 0, 501) + for i := 0; i < 501; i++ { + ids = append(ids, "id"+strconv.Itoa(i)) + } + raw, _ := gojson.Marshal(map[string][]string{"ids": ids}) + resp := api.Post("/api/gym/query", strings.NewReader(string(raw))) + if resp.Code != http.StatusRequestEntityTooLarge { + t.Errorf("got %d, want 413; body=%s", resp.Code, resp.Body.String()) + } + }) +} + +// TestTier3RoutesRegisterInSpec asserts all seven tier-3 operations appear in +// the OpenAPI spec at their expected method+path (registration smoke test for +// the endpoints that need a DB and so are not exercised end-to-end here). +func TestTier3RoutesRegisterInSpec(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + api := humagin.New(r, newHumaConfig("test")) + registerTier3Routes(api) + + raw, err := gojson.Marshal(api.OpenAPI()) + if err != nil { + t.Fatalf("marshal openapi: %v", err) + } + var doc struct { + Paths map[string]map[string]any `json:"paths"` + } + if err := gojson.Unmarshal(raw, &doc); err != nil { + t.Fatalf("unmarshal openapi: %v", err) + } + + want := []struct{ method, path string }{ + {"post", "/api/gym/query"}, + {"post", "/api/station/query"}, + {"post", "/api/gym/search"}, + {"get", "/api/gym/id/{gym_id}"}, + {"get", "/api/pokestop/id/{fort_id}"}, + {"get", "/api/tappable/id/{tappable_id}"}, + {"post", "/api/pokestop-positions"}, + } + for _, w := range want { + if _, ok := doc.Paths[w.path][w.method]; !ok { + t.Errorf("missing %s %s in OpenAPI spec", w.method, w.path) + } + } +} + // fortScanBody is an empty-filter fort scan request that matches against the // empty in-memory rtree (no DB), yielding zero results. const fortScanBody = `{"min":{"lat":0,"lon":0},"max":{"lat":1,"lon":1},"filters":[]}` diff --git a/main.go b/main.go index 4247eaf1..7a47fb5a 100644 --- a/main.go +++ b/main.go @@ -333,20 +333,12 @@ func main() { apiGroup.GET("/health", GetHealth) apiGroup.POST("/clear-quests", ClearQuests) apiGroup.POST("/quest-status", GetQuestStatus) - apiGroup.POST("/pokestop-positions", GetPokestopPositions) - apiGroup.GET("/pokestop/id/:fort_id", GetPokestop) - apiGroup.GET("/gym/id/:gym_id", GetGym) - apiGroup.POST("/gym/query", GetGyms) - apiGroup.POST("/gym/search", SearchGyms) - apiGroup.POST("/station/query", GetStations) apiGroup.POST("/reload-geojson", ReloadGeojson) apiGroup.GET("/reload-geojson", ReloadGeojson) apiGroup.GET("/pokemon/available", PokemonAvailable) apiGroup.POST("/pokemon/scan", PokemonScan) - apiGroup.GET("/tappable/id/:tappable_id", GetTappable) - apiGroup.GET("/devices/all", GetDevices) apiGroup.GET("/fort-tracker/cell/:cell_id", GetFortTrackerCell) apiGroup.GET("/fort-tracker/forts/:fort_id", GetFortTrackerFort) @@ -391,6 +383,7 @@ func main() { registerHumaRoutes(humaAPI) registerFortScanRoutes(humaAPI) registerPokemonReadRoutes(humaAPI) + registerTier3Routes(humaAPI) srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Port), diff --git a/routes.go b/routes.go index b677d5d3..c07e2804 100644 --- a/routes.go +++ b/routes.go @@ -4,7 +4,6 @@ import ( "context" b64 "encoding/base64" "encoding/json" - "errors" "io" "net/http" "strconv" @@ -400,382 +399,6 @@ func GetHealth(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } -func GetPokestopPositions(c *gin.Context) { - fence, err := geo.NormaliseFenceRequest(c) - if err != nil { - log.Warnf("POST /api/pokestop-positions/ Error during post area %v %v", err, fence) - c.Status(http.StatusInternalServerError) - return - } - - response, err := decoder.GetPokestopPositions(dbDetails, fence) - if err != nil { - log.Warnf("POST /api/pokestop-positions/ Error during post retrieve %v", err) - c.Status(http.StatusInternalServerError) - return - } - - c.JSON(http.StatusAccepted, response) -} - -func GetPokestop(c *gin.Context) { - fortId := c.Param("fort_id") - - //ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - pokestop, unlock, err := decoder.PeekPokestopRecord(fortId, "API.GetPokestop") - if unlock != nil { - defer unlock() - } - //cancel() - if err != nil { - log.Warnf("GET /api/pokestop/id/:fort_id/ Error during post retrieve %v", err) - c.Status(http.StatusInternalServerError) - return - } - - if pokestop == nil { - c.Status(http.StatusNotFound) - return - } - result := decoder.BuildPokestopResult(pokestop) - c.JSON(http.StatusAccepted, result) -} - -func GetGym(c *gin.Context) { - gymId := c.Param("gym_id") - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - gym, unlock, err := decoder.GetGymRecordReadOnly(ctx, dbDetails, gymId, "API.GetGym") - if unlock != nil { - defer unlock() - } - cancel() - if err != nil { - log.Warnf("GET /api/gym/id/:gym_id/ Error during post retrieve %v", err) - c.Status(http.StatusInternalServerError) - return - } - - if gym == nil { - c.Status(http.StatusNotFound) - return - } - result := decoder.BuildGymResult(gym) - c.JSON(http.StatusAccepted, result) -} - -// POST /api/gym/query -// -// { "ids": ["gymid1", "gymid2", ...] } -func GetGyms(c *gin.Context) { - type idsPayload struct { - IDs []string `json:"ids"` - } - - var payload idsPayload - if err := c.ShouldBindJSON(&payload); err != nil { - var arr []string - if err2 := c.ShouldBindJSON(&arr); err2 != nil { - log.Warnf("invalid JSON: %v / %v", err, err2) - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body; expected {\"ids\":[...] }"}) - return - } - payload.IDs = arr - } - - seen := make(map[string]struct{}, len(payload.IDs)) - ids := make([]string, 0, len(payload.IDs)) - for _, id := range payload.IDs { - if id == "" { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - ids = append(ids, id) - } - - const maxIDs = 500 - if len(ids) > maxIDs { - c.JSON(http.StatusRequestEntityTooLarge, gin.H{ - "error": "too many ids", - "max_supported": maxIDs, - }) - return - } - - if len(ids) == 0 { - c.JSON(http.StatusOK, []decoder.ApiGymResult{}) - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - out := make([]decoder.ApiGymResult, 0, len(ids)) - for _, id := range ids { - g, unlock, err := decoder.GetGymRecordReadOnly(ctx, dbDetails, id, "API.GetGyms") - if err != nil { - if unlock != nil { - unlock() - } - log.Warnf("error retrieving gym %s: %v", id, err) - c.Status(http.StatusInternalServerError) - return - } - if g != nil { - out = append(out, decoder.BuildGymResult(g)) - } - if unlock != nil { - unlock() - } - if ctx.Err() != nil { - c.Status(http.StatusInternalServerError) - return - } - } - - c.JSON(http.StatusOK, out) -} - -// POST /api/station/query -// -// { "ids": ["stationid1", "stationid2", ...] } -func GetStations(c *gin.Context) { - type idsPayload struct { - IDs []string `json:"ids"` - } - - var payload idsPayload - if err := c.ShouldBindJSON(&payload); err != nil { - var arr []string - if err2 := c.ShouldBindJSON(&arr); err2 != nil { - log.Warnf("invalid JSON: %v / %v", err, err2) - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body; expected {\"ids\":[...] }"}) - return - } - payload.IDs = arr - } - - seen := make(map[string]struct{}, len(payload.IDs)) - ids := make([]string, 0, len(payload.IDs)) - for _, id := range payload.IDs { - if id == "" { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - ids = append(ids, id) - } - - const maxIDs = 500 - if len(ids) > maxIDs { - c.JSON(http.StatusRequestEntityTooLarge, gin.H{ - "error": "too many ids", - "max_supported": maxIDs, - }) - return - } - - if len(ids) == 0 { - c.JSON(http.StatusOK, []decoder.ApiStationResult{}) - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - out := make([]decoder.ApiStationResult, 0, len(ids)) - for _, id := range ids { - s, unlock, err := decoder.GetStationRecordReadOnly(ctx, dbDetails, id, "API.GetStations") - if err != nil { - if unlock != nil { - unlock() - } - log.Warnf("error retrieving station %s: %v", id, err) - c.Status(http.StatusInternalServerError) - return - } - if s != nil { - out = append(out, decoder.BuildStationResult(s)) - } - if unlock != nil { - unlock() - } - if ctx.Err() != nil { - c.Status(http.StatusInternalServerError) - return - } - } - - c.JSON(http.StatusOK, out) -} - -// POST /api/gym/search -// Multiple filter combinations with AND logic -// -// { -// "filters": [ -// { -// "name": "central park", // optional: gym name search -// "description": "playground", // optional: gym description search -// "location_distance": { // optional: geographic radius search -// "location": {"lat": 40.7829, "lon": -73.9654}, -// "distance": 500 // meters, max 500_000 -// }, -// "bbox": { // optional: bounding box search -// "min_lon": -74.0, "min_lat": 40.7, -// "max_lon": -73.9, "max_lat": 40.8 -// } -// } -// ], -// "limit": 100 // optional, default 500, max 10000 -// } -func SearchGyms(c *gin.Context) { - type payload struct { - Filters []decoder.ApiGymSearchFilter `json:"filters"` - Limit *int `json:"limit"` - } - - var p payload - if err := c.ShouldBindJSON(&p); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) - return - } - - // Validate request - if len(p.Filters) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "filters array is required"}) - return - } - - var search decoder.ApiGymSearch - search.Filters = p.Filters - - // Validate filters - for _, filter := range search.Filters { - if filter.LocationDistance != nil { - locDist := *filter.LocationDistance - if locDist.Distance <= 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "distance must be > 0"}) - return - } - if locDist.Distance > 500_000 { - locDist.Distance = 500_000 - filter.LocationDistance = &locDist - } - lat, lon := locDist.Location.Latitude, locDist.Location.Longitude - if lat < -90 || lat > 90 || lon < -180 || lon > 180 { - c.JSON(http.StatusBadRequest, gin.H{"error": "lat must be [-90,90], lon must be [-180,180]"}) - return - } - } - if filter.Bbox != nil { - bbox := *filter.Bbox - if bbox.MinLat < -90 || bbox.MinLat > 90 || bbox.MaxLat < -90 || bbox.MaxLat > 90 || - bbox.MinLon < -180 || bbox.MinLon > 180 || bbox.MaxLon < -180 || bbox.MaxLon > 180 { - c.JSON(http.StatusBadRequest, gin.H{"error": "bbox coordinates out of range: lat must be [-90,90], lon must be [-180,180]"}) - return - } - if bbox.MinLat > bbox.MaxLat { - c.JSON(http.StatusBadRequest, gin.H{"error": "bbox invalid: minLat must be <= maxLat"}) - return - } - if bbox.MinLon > bbox.MaxLon { - c.JSON(http.StatusBadRequest, gin.H{"error": "bbox invalid: minLon must be <= maxLon"}) - return - } - } - } - - // Set limit - search.Limit = 500 - if p.Limit != nil && *p.Limit > 0 { - search.Limit = *p.Limit - } - if search.Limit > 10000 { - search.Limit = 10000 - } - - // Execute search - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - ids, err := decoder.SearchGymsAPI(ctx, dbDetails, search) - - if err != nil { - if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) { - log.Warnf("timed out: %v", err) - c.Status(http.StatusGatewayTimeout) - return - } - log.Warnf("error: %v", err) - c.Status(http.StatusInternalServerError) - return - } - - out := make([]decoder.ApiGymResult, 0, len(ids)) - for _, id := range ids { - if id == "" { - continue - } - g, unlock, err := decoder.GetGymRecordReadOnly(ctx, dbDetails, id, "API.GetFortTracker") - if err != nil { - if unlock != nil { - unlock() - } - if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) { - log.Warnf("timed out while fetching %s: %v", id, err) - c.Status(http.StatusGatewayTimeout) - return - } - log.Warnf("error retrieving gym %s: %v", id, err) - c.Status(http.StatusInternalServerError) - return - } - if g != nil { - out = append(out, decoder.BuildGymResult(g)) - } - if unlock != nil { - unlock() - } - if ctx.Err() != nil { - c.Status(http.StatusInternalServerError) - return - } - } - - c.JSON(http.StatusOK, out) -} - -func GetTappable(c *gin.Context) { - id := c.Param("tappable_id") - tappableId, err := strconv.ParseUint(id, 10, 64) - if err != nil { - log.Warnf("GET /api/tappable/id/:tappable_id/ Non valid param: %v", err) - c.Status(http.StatusBadRequest) - return - } - tappable, unlock, err := decoder.PeekTappableRecord(tappableId, "API.GetTappable") - if unlock != nil { - defer unlock() - } - if err != nil { - log.Warnf("GET /api/tappable/id/:tappable_id/ Error during post retrieve %v", err) - c.Status(http.StatusInternalServerError) - return - } - if tappable == nil { - c.Status(http.StatusNotFound) - return - } - result := decoder.BuildTappableResult(tappable) - c.JSON(http.StatusAccepted, result) -} - func GetDevices(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"devices": GetAllDevices()}) } diff --git a/routes_huma.go b/routes_huma.go index cc6f91d7..5314c9d8 100644 --- a/routes_huma.go +++ b/routes_huma.go @@ -2,10 +2,14 @@ package main import ( "context" + "errors" "net/http" + "time" "golbat/config" + db2 "golbat/db" "golbat/decoder" + "golbat/geo" "github.com/danielgtaylor/huma/v2" ) @@ -110,7 +114,9 @@ type stationScanInput struct{ Body decoder.ApiFortScan } type stationScanOutput struct{ Body decoder.ApiStationScanResult } type fortScanInput struct{ Body decoder.ApiFortScan } -type fortScanOutput struct{ Body decoder.ApiFortCombinedScanResult } +type fortScanOutput struct { + Body decoder.ApiFortCombinedScanResult +} // registerFortScanRoutes registers the four in-memory fort scan operations. // These are gated by config.Config.FortInMemory and return 503 when disabled. @@ -187,3 +193,340 @@ func registerFortScanRoutes(api huma.API) { return &fortScanOutput{Body: *decoder.FortCombinedScanEndpoint(in.Body, dbDetails)}, nil }) } + +// maxQueryIDs caps the number of ids accepted by the by-id batch query endpoints. +const maxQueryIDs = 500 + +// dedupeIDs drops empty and duplicate ids while preserving order. +func dedupeIDs(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, id := range in { + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +type idsQueryInput struct { + Body struct { + IDs []string `json:"ids" doc:"Fort IDs to fetch (max 500)"` + } +} + +type gymQueryOutput struct{ Body []decoder.ApiGymResult } +type stationQueryOutput struct{ Body []decoder.ApiStationResult } + +type gymSearchInput struct{ Body decoder.ApiGymSearch } +type gymSearchOutput struct{ Body []decoder.ApiGymResult } + +type gymByIdInput struct { + GymId string `path:"gym_id" doc:"Fort ID of the gym"` +} +type gymByIdOutput struct{ Body decoder.ApiGymResult } + +type pokestopByIdInput struct { + FortId string `path:"fort_id" doc:"Fort ID of the pokestop"` +} +type pokestopByIdOutput struct{ Body decoder.ApiPokestopResult } + +type tappableByIdInput struct { + TappableId uint64 `path:"tappable_id" doc:"Encounter ID of the tappable"` +} +type tappableByIdOutput struct{ Body decoder.ApiTappableResult } + +type pokestopPositionsInput struct { + RawBody []byte +} +type pokestopPositionsOutput struct{ Body []db2.QuestLocation } + +// registerTier3Routes registers the tier-3 read endpoints (by-id reads, batch +// id queries, gym search, and pokestop positions) on the given API. +func registerTier3Routes(api huma.API) { + // POST /api/gym/query + huma.Register(api, huma.Operation{ + OperationID: "query-gyms", + Method: http.MethodPost, + Path: "/api/gym/query", + Summary: "Fetch gyms by id", + Description: "Returns the gyms with the given ids (max 500, deduplicated). Unknown ids are omitted.", + Tags: []string{"Fort"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusOK, + }, func(ctx context.Context, in *idsQueryInput) (*gymQueryOutput, error) { + ids := dedupeIDs(in.Body.IDs) + if len(ids) > maxQueryIDs { + return nil, huma.Error413RequestEntityTooLarge("too many ids") + } + if len(ids) == 0 { + return &gymQueryOutput{Body: []decoder.ApiGymResult{}}, nil + } + + tctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + out := make([]decoder.ApiGymResult, 0, len(ids)) + for _, id := range ids { + g, unlock, err := decoder.GetGymRecordReadOnly(tctx, dbDetails, id, "API.GetGyms") + if err != nil { + if unlock != nil { + unlock() + } + return nil, huma.Error500InternalServerError("error retrieving gym") + } + if g != nil { + out = append(out, decoder.BuildGymResult(g)) + } + if unlock != nil { + unlock() + } + if tctx.Err() != nil { + return nil, huma.Error500InternalServerError("timed out") + } + } + return &gymQueryOutput{Body: out}, nil + }) + + // POST /api/station/query + huma.Register(api, huma.Operation{ + OperationID: "query-stations", + Method: http.MethodPost, + Path: "/api/station/query", + Summary: "Fetch stations by id", + Description: "Returns the stations with the given ids (max 500, deduplicated). Unknown ids are omitted.", + Tags: []string{"Fort"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusOK, + }, func(ctx context.Context, in *idsQueryInput) (*stationQueryOutput, error) { + ids := dedupeIDs(in.Body.IDs) + if len(ids) > maxQueryIDs { + return nil, huma.Error413RequestEntityTooLarge("too many ids") + } + if len(ids) == 0 { + return &stationQueryOutput{Body: []decoder.ApiStationResult{}}, nil + } + + tctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + out := make([]decoder.ApiStationResult, 0, len(ids)) + for _, id := range ids { + s, unlock, err := decoder.GetStationRecordReadOnly(tctx, dbDetails, id, "API.GetStations") + if err != nil { + if unlock != nil { + unlock() + } + return nil, huma.Error500InternalServerError("error retrieving station") + } + if s != nil { + out = append(out, decoder.BuildStationResult(s)) + } + if unlock != nil { + unlock() + } + if tctx.Err() != nil { + return nil, huma.Error500InternalServerError("timed out") + } + } + return &stationQueryOutput{Body: out}, nil + }) + + // POST /api/gym/search + huma.Register(api, huma.Operation{ + OperationID: "search-gyms", + Method: http.MethodPost, + Path: "/api/gym/search", + Summary: "Search gyms by name, description, or location", + Description: "Returns gyms matching the AND'd filter conditions, up to limit (default 500, max 10000).", + Tags: []string{"Fort"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusOK, + }, func(ctx context.Context, in *gymSearchInput) (*gymSearchOutput, error) { + search := in.Body + + if len(search.Filters) == 0 { + return nil, huma.Error400BadRequest("filters array is required") + } + + // Validate filters (and clamp distance like the legacy handler). + for i := range search.Filters { + filter := &search.Filters[i] + if filter.LocationDistance != nil { + locDist := *filter.LocationDistance + if locDist.Distance <= 0 { + return nil, huma.Error400BadRequest("distance must be > 0") + } + if locDist.Distance > 500_000 { + locDist.Distance = 500_000 + filter.LocationDistance = &locDist + } + lat, lon := locDist.Location.Latitude, locDist.Location.Longitude + if lat < -90 || lat > 90 || lon < -180 || lon > 180 { + return nil, huma.Error400BadRequest("lat must be [-90,90], lon must be [-180,180]") + } + } + if filter.Bbox != nil { + bbox := *filter.Bbox + if bbox.MinLat < -90 || bbox.MinLat > 90 || bbox.MaxLat < -90 || bbox.MaxLat > 90 || + bbox.MinLon < -180 || bbox.MinLon > 180 || bbox.MaxLon < -180 || bbox.MaxLon > 180 { + return nil, huma.Error400BadRequest("bbox coordinates out of range: lat must be [-90,90], lon must be [-180,180]") + } + if bbox.MinLat > bbox.MaxLat { + return nil, huma.Error400BadRequest("bbox invalid: minLat must be <= maxLat") + } + if bbox.MinLon > bbox.MaxLon { + return nil, huma.Error400BadRequest("bbox invalid: minLon must be <= maxLon") + } + } + } + + // Limit defaulting: default 500, cap 10000. The legacy handler used a + // *int (nil/<=0 => default); here a missing/zero/negative limit defaults. + if search.Limit <= 0 { + search.Limit = 500 + } + if search.Limit > 10000 { + search.Limit = 10000 + } + + tctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + ids, err := decoder.SearchGymsAPI(tctx, dbDetails, search) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(tctx.Err(), context.DeadlineExceeded) { + return nil, huma.Error504GatewayTimeout("timed out") + } + return nil, huma.Error500InternalServerError("search failed") + } + + out := make([]decoder.ApiGymResult, 0, len(ids)) + for _, id := range ids { + if id == "" { + continue + } + g, unlock, err := decoder.GetGymRecordReadOnly(tctx, dbDetails, id, "API.GetFortTracker") + if err != nil { + if unlock != nil { + unlock() + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(tctx.Err(), context.DeadlineExceeded) { + return nil, huma.Error504GatewayTimeout("timed out") + } + return nil, huma.Error500InternalServerError("error retrieving gym") + } + if g != nil { + out = append(out, decoder.BuildGymResult(g)) + } + if unlock != nil { + unlock() + } + if tctx.Err() != nil { + return nil, huma.Error500InternalServerError("timed out") + } + } + return &gymSearchOutput{Body: out}, nil + }) + + // GET /api/gym/id/{gym_id} + huma.Register(api, huma.Operation{ + OperationID: "get-gym", + Method: http.MethodGet, + Path: "/api/gym/id/{gym_id}", + Summary: "Get a single gym by id", + Description: "Returns the gym with the given fort id, or 404 if not present.", + Tags: []string{"Fort"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusAccepted, + }, func(ctx context.Context, in *gymByIdInput) (*gymByIdOutput, error) { + tctx, cancel := context.WithTimeout(ctx, 5*time.Second) + gym, unlock, err := decoder.GetGymRecordReadOnly(tctx, dbDetails, in.GymId, "API.GetGym") + if unlock != nil { + defer unlock() + } + cancel() + if err != nil { + return nil, huma.Error500InternalServerError("error retrieving gym") + } + if gym == nil { + return nil, huma.Error404NotFound("gym not found") + } + return &gymByIdOutput{Body: decoder.BuildGymResult(gym)}, nil + }) + + // GET /api/pokestop/id/{fort_id} + huma.Register(api, huma.Operation{ + OperationID: "get-pokestop", + Method: http.MethodGet, + Path: "/api/pokestop/id/{fort_id}", + Summary: "Get a single pokestop by id", + Description: "Returns the pokestop with the given fort id, or 404 if not present in the cache.", + Tags: []string{"Fort"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusAccepted, + }, func(ctx context.Context, in *pokestopByIdInput) (*pokestopByIdOutput, error) { + pokestop, unlock, err := decoder.PeekPokestopRecord(in.FortId, "API.GetPokestop") + if unlock != nil { + defer unlock() + } + if err != nil { + return nil, huma.Error500InternalServerError("error retrieving pokestop") + } + if pokestop == nil { + return nil, huma.Error404NotFound("pokestop not found") + } + return &pokestopByIdOutput{Body: decoder.BuildPokestopResult(pokestop)}, nil + }) + + // GET /api/tappable/id/{tappable_id} + huma.Register(api, huma.Operation{ + OperationID: "get-tappable", + Method: http.MethodGet, + Path: "/api/tappable/id/{tappable_id}", + Summary: "Get a single tappable by encounter id", + Description: "Returns the tappable with the given encounter id, or 404 if not present in the cache.", + Tags: []string{"Tappable"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusAccepted, + }, func(ctx context.Context, in *tappableByIdInput) (*tappableByIdOutput, error) { + tappable, unlock, err := decoder.PeekTappableRecord(in.TappableId, "API.GetTappable") + if unlock != nil { + defer unlock() + } + if err != nil { + return nil, huma.Error500InternalServerError("error retrieving tappable") + } + if tappable == nil { + return nil, huma.Error404NotFound("tappable not found") + } + return &tappableByIdOutput{Body: decoder.BuildTappableResult(tappable)}, nil + }) + + // POST /api/pokestop-positions + huma.Register(api, huma.Operation{ + OperationID: "get-pokestop-positions", + Method: http.MethodPost, + Path: "/api/pokestop-positions", + Summary: "List pokestop positions within a geofence", + Description: "Returns the positions of pokestops within the supplied geofence (geometry, feature, or Golbat fence).", + Tags: []string{"Fort"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusAccepted, + }, func(ctx context.Context, in *pokestopPositionsInput) (*pokestopPositionsOutput, error) { + fence, err := geo.NormaliseFenceFromBytes(in.RawBody) + if err != nil { + return nil, huma.Error400BadRequest(err.Error()) + } + response, err := decoder.GetPokestopPositions(dbDetails, fence) + if err != nil { + return nil, huma.Error500InternalServerError("error retrieving pokestop positions") + } + return &pokestopPositionsOutput{Body: response}, nil + }) +} From 2ca1000b00c37b5b2443cd1b99f7502f1924b68a Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 21:57:32 +0100 Subject: [PATCH 14/19] feat: serve quest-status + clear-quests via Huma (geofence body) Co-Authored-By: Claude Opus 4.8 (1M context) --- huma_routes_test.go | 49 ++++++++++++++++++++++++++++++++++++ main.go | 3 +-- routes.go | 35 -------------------------- routes_huma.go | 61 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 37 deletions(-) diff --git a/huma_routes_test.go b/huma_routes_test.go index 495ea441..ca9eb618 100644 --- a/huma_routes_test.go +++ b/huma_routes_test.go @@ -330,3 +330,52 @@ func TestFortScanDraftBadge(t *testing.T) { t.Errorf("pokemon v2 scan must NOT carry x-badges, got %v", pokemonOp.Badges) } } + +// TestTier4RoutesRegisterInSpec asserts the two geofence-body quest operations +// register in the OpenAPI spec at their expected method+path. These hit the DB +// so they are not exercised end-to-end here. +func TestTier4RoutesRegisterInSpec(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + api := humagin.New(r, newHumaConfig("test")) + registerTier4Routes(api) + + raw, err := gojson.Marshal(api.OpenAPI()) + if err != nil { + t.Fatalf("marshal openapi: %v", err) + } + var doc struct { + Paths map[string]map[string]any `json:"paths"` + } + if err := gojson.Unmarshal(raw, &doc); err != nil { + t.Fatalf("unmarshal openapi: %v", err) + } + + want := []struct{ method, path string }{ + {"post", "/api/quest-status"}, + {"post", "/api/clear-quests"}, + } + for _, w := range want { + if _, ok := doc.Paths[w.path][w.method]; !ok { + t.Errorf("missing %s %s in OpenAPI spec", w.method, w.path) + } + } +} + +// TestQuestStatusMalformedFenceIs400 verifies that quest-status rejects a +// malformed geofence body with 400 before reaching the database: +// NormaliseFenceFromBytes fails to parse garbage bytes, so no DB call occurs. +func TestQuestStatusMalformedFenceIs400(t *testing.T) { + prev := config.Config.ApiSecret + config.Config.ApiSecret = "" + defer func() { config.Config.ApiSecret = prev }() + + _, api := humatest.New(t, newHumaConfig("test")) + api.UseMiddleware(golbatSecretMiddleware(api)) + registerTier4Routes(api) + + resp := api.Post("/api/quest-status", strings.NewReader(`{"bad":`)) + if resp.Code != http.StatusBadRequest { + t.Errorf("got %d, want 400; body=%s", resp.Code, resp.Body.String()) + } +} diff --git a/main.go b/main.go index 7a47fb5a..5b5f9b79 100644 --- a/main.go +++ b/main.go @@ -331,8 +331,6 @@ func main() { apiGroup := r.Group("/api", AuthRequired()) apiGroup.GET("/health", GetHealth) - apiGroup.POST("/clear-quests", ClearQuests) - apiGroup.POST("/quest-status", GetQuestStatus) apiGroup.POST("/reload-geojson", ReloadGeojson) apiGroup.GET("/reload-geojson", ReloadGeojson) @@ -384,6 +382,7 @@ func main() { registerFortScanRoutes(humaAPI) registerPokemonReadRoutes(humaAPI) registerTier3Routes(humaAPI) + registerTier4Routes(humaAPI) srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Port), diff --git a/routes.go b/routes.go index c07e2804..ce87d908 100644 --- a/routes.go +++ b/routes.go @@ -14,7 +14,6 @@ import ( "golbat/config" "golbat/decoder" - "golbat/geo" "golbat/pogo" ) @@ -332,26 +331,6 @@ func AuthRequired() gin.HandlerFunc { } } -func ClearQuests(c *gin.Context) { - fence, err := geo.NormaliseFenceRequest(c) - - if err != nil { - log.Warnf("POST /api/clear-quests/ Error during post area %v", err) - c.Status(http.StatusInternalServerError) - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - log.Debugf("Clear quests %+v", fence) - startTime := time.Now() - decoder.ClearQuestsWithinGeofence(ctx, dbDetails, fence) - log.Infof("Clear quest took %s", time.Since(startTime)) - - c.JSON(http.StatusAccepted, StatusResponse{Status: "ok"}) -} - func ReloadGeojson(c *gin.Context) { decoder.ReloadGeofenceAndClearStats() @@ -380,20 +359,6 @@ func PokemonAvailable(c *gin.Context) { c.JSON(http.StatusAccepted, res) } -func GetQuestStatus(c *gin.Context) { - fence, err := geo.NormaliseFenceRequest(c) - - if err != nil { - log.Warnf("POST /api/quest-status/ Error during post area %v", err) - c.Status(http.StatusInternalServerError) - return - } - - questStatus := decoder.GetQuestStatusWithGeofence(dbDetails, fence) - - c.JSON(http.StatusOK, &questStatus) -} - // GetHealth provides unrestricted health status for monitoring tools func GetHealth(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) diff --git a/routes_huma.go b/routes_huma.go index 5314c9d8..7afb3b36 100644 --- a/routes_huma.go +++ b/routes_huma.go @@ -12,6 +12,7 @@ import ( "golbat/geo" "github.com/danielgtaylor/huma/v2" + log "github.com/sirupsen/logrus" ) type pokemonV2ScanInput struct { @@ -530,3 +531,63 @@ func registerTier3Routes(api huma.API) { return &pokestopPositionsOutput{Body: response}, nil }) } + +type questStatusInput struct { + RawBody []byte +} +type questStatusOutput struct{ Body db2.QuestStatus } + +type clearQuestsInput struct { + RawBody []byte +} +type clearQuestsOutput struct{ Body StatusResponse } + +// registerTier4Routes registers the geofence-body quest endpoints (quest-status +// and clear-quests) on the given API. +func registerTier4Routes(api huma.API) { + // POST /api/quest-status + huma.Register(api, huma.Operation{ + OperationID: "get-quest-status", + Method: http.MethodPost, + Path: "/api/quest-status", + Summary: "Quest status within a geofence", + Description: "Returns quest completion status for pokestops within the supplied geofence (geometry, feature, or Golbat fence).", + Tags: []string{"Quest"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusOK, + }, func(ctx context.Context, in *questStatusInput) (*questStatusOutput, error) { + fence, err := geo.NormaliseFenceFromBytes(in.RawBody) + if err != nil { + return nil, huma.Error400BadRequest(err.Error()) + } + status := decoder.GetQuestStatusWithGeofence(dbDetails, fence) + return &questStatusOutput{Body: status}, nil + }) + + // POST /api/clear-quests + huma.Register(api, huma.Operation{ + OperationID: "clear-quests", + Method: http.MethodPost, + Path: "/api/clear-quests", + Summary: "Clear quests within a geofence", + Description: "Deletes quests for pokestops within the supplied geofence (geometry, feature, or Golbat fence).", + Tags: []string{"Quest"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusAccepted, + }, func(ctx context.Context, in *clearQuestsInput) (*clearQuestsOutput, error) { + fence, err := geo.NormaliseFenceFromBytes(in.RawBody) + if err != nil { + return nil, huma.Error400BadRequest(err.Error()) + } + + tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + log.Debugf("Clear quests %+v", fence) + startTime := time.Now() + decoder.ClearQuestsWithinGeofence(tctx, dbDetails, fence) + log.Infof("Clear quest took %s", time.Since(startTime)) + + return &clearQuestsOutput{Body: StatusResponse{Status: "ok"}}, nil + }) +} From b597f1d736d18d15cc8df82c98db6e5a43493c98 Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 22:02:29 +0100 Subject: [PATCH 15/19] feat: serve tier-4 operational endpoints via Huma Co-Authored-By: Claude Opus 4.8 (1M context) --- huma_routes_test.go | 71 ++++++++++++++++++++++ main.go | 8 --- routes.go | 58 ------------------ routes_huma.go | 141 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 66 deletions(-) diff --git a/huma_routes_test.go b/huma_routes_test.go index ca9eb618..014da5c3 100644 --- a/huma_routes_test.go +++ b/huma_routes_test.go @@ -379,3 +379,74 @@ func TestQuestStatusMalformedFenceIs400(t *testing.T) { t.Errorf("got %d, want 400; body=%s", resp.Code, resp.Body.String()) } } + +// TestTier4OperationalEndpoints exercises the migrated tier-4 operational +// endpoints over the HTTP pipeline without a database: devices/all, reload +// -geojson, skip-preserve-pokemon, and the nil-FortTracker 503 guard. +func TestTier4OperationalEndpoints(t *testing.T) { + prev := config.Config.ApiSecret + config.Config.ApiSecret = "" + defer func() { config.Config.ApiSecret = prev }() + + // GetAllDevices() reads from the global device cache; initialise it (no DB + // required) so devices/all does not nil-deref. + if config.Config.Cleanup.DeviceHours == 0 { + config.Config.Cleanup.DeviceHours = 1 + } + InitDeviceCache() + + _, api := humatest.New(t, newHumaConfig("test")) + api.UseMiddleware(golbatSecretMiddleware(api)) + registerTier4Routes(api) + + t.Run("devices/all returns 200 with devices object", func(t *testing.T) { + resp := api.Get("/api/devices/all") + if resp.Code != http.StatusOK { + t.Fatalf("got %d, want 200; body=%s", resp.Code, resp.Body.String()) + } + var m map[string]any + if err := gojson.Unmarshal(resp.Body.Bytes(), &m); err != nil { + t.Fatalf("body is not a JSON object: %v; body=%s", err, resp.Body.String()) + } + if _, ok := m["devices"]; !ok { + t.Errorf("body missing key %q: %s", "devices", resp.Body.String()) + } + }) + + t.Run("reload-geojson POST returns 202 status ok", func(t *testing.T) { + resp := api.Post("/api/reload-geojson", strings.NewReader("")) + if resp.Code != http.StatusAccepted { + t.Fatalf("got %d, want 202; body=%s", resp.Code, resp.Body.String()) + } + var m map[string]any + if err := gojson.Unmarshal(resp.Body.Bytes(), &m); err != nil { + t.Fatalf("body is not a JSON object: %v; body=%s", err, resp.Body.String()) + } + if m["status"] != "ok" { + t.Errorf("status = %v, want \"ok\"; body=%s", m["status"], resp.Body.String()) + } + }) + + t.Run("skip-preserve-pokemon GET returns 200", func(t *testing.T) { + resp := api.Get("/api/skip-preserve-pokemon") + if resp.Code != http.StatusOK { + t.Fatalf("got %d, want 200; body=%s", resp.Code, resp.Body.String()) + } + var m map[string]any + if err := gojson.Unmarshal(resp.Body.Bytes(), &m); err != nil { + t.Fatalf("body is not a JSON object: %v; body=%s", err, resp.Body.String()) + } + if m["status"] != "ok" { + t.Errorf("status = %v, want \"ok\"; body=%s", m["status"], resp.Body.String()) + } + }) + + t.Run("fort-tracker/cell with nil tracker returns 503", func(t *testing.T) { + // GetFortTracker() is nil in this DB-free test (no Preload), so the + // handler short-circuits to 503 before any cell lookup. + resp := api.Get("/api/fort-tracker/cell/1234567890") + if resp.Code != http.StatusServiceUnavailable { + t.Errorf("got %d, want 503; body=%s", resp.Code, resp.Body.String()) + } + }) +} diff --git a/main.go b/main.go index 5b5f9b79..d193dff4 100644 --- a/main.go +++ b/main.go @@ -331,18 +331,10 @@ func main() { apiGroup := r.Group("/api", AuthRequired()) apiGroup.GET("/health", GetHealth) - apiGroup.POST("/reload-geojson", ReloadGeojson) - apiGroup.GET("/reload-geojson", ReloadGeojson) apiGroup.GET("/pokemon/available", PokemonAvailable) apiGroup.POST("/pokemon/scan", PokemonScan) - apiGroup.GET("/devices/all", GetDevices) - apiGroup.GET("/fort-tracker/cell/:cell_id", GetFortTrackerCell) - apiGroup.GET("/fort-tracker/forts/:fort_id", GetFortTrackerFort) - apiGroup.GET("/skip-preserve-pokemon", SkipPreservePokemon) - apiGroup.POST("/skip-preserve-pokemon", SkipPreservePokemon) - debugGroup := r.Group("/debug") if cfg.Tuning.ProfileRoutes { diff --git a/routes.go b/routes.go index ce87d908..21612702 100644 --- a/routes.go +++ b/routes.go @@ -6,7 +6,6 @@ import ( "encoding/json" "io" "net/http" - "strconv" "time" "github.com/gin-gonic/gin" @@ -331,12 +330,6 @@ func AuthRequired() gin.HandlerFunc { } } -func ReloadGeojson(c *gin.Context) { - decoder.ReloadGeofenceAndClearStats() - - c.JSON(http.StatusAccepted, StatusResponse{Status: "ok"}) -} - func PokemonScan(c *gin.Context) { var requestBody decoder.ApiPokemonScan @@ -364,54 +357,3 @@ func GetHealth(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } -func GetDevices(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"devices": GetAllDevices()}) -} - -func GetFortTrackerCell(c *gin.Context) { - cellIdStr := c.Param("cell_id") - cellId, err := strconv.ParseUint(cellIdStr, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid cell ID"}) - return - } - - fortTracker := decoder.GetFortTracker() - if fortTracker == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "FortTracker not initialized"}) - return - } - - cellInfo := fortTracker.GetCellInfo(cellId) - if cellInfo == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Cell not found"}) - return - } - - c.JSON(http.StatusOK, cellInfo) -} - -func GetFortTrackerFort(c *gin.Context) { - fortId := c.Param("fort_id") - - fortTracker := decoder.GetFortTracker() - if fortTracker == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "FortTracker not initialized"}) - return - } - - fortInfo := fortTracker.GetFortInfo(fortId) - if fortInfo == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Fort not found"}) - return - } - - c.JSON(http.StatusOK, fortInfo) -} - -// SkipPreservePokemon sets a flag to prevent pokemon preservation on shutdown -func SkipPreservePokemon(c *gin.Context) { - decoder.SetSkipPreservePokemon(true) - log.Info("Skip preserve pokemon flag set - pokemon will not be preserved on shutdown") - c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Pokemon preservation will be skipped on shutdown"}) -} diff --git a/routes_huma.go b/routes_huma.go index 7afb3b36..b52a22b4 100644 --- a/routes_huma.go +++ b/routes_huma.go @@ -590,4 +590,145 @@ func registerTier4Routes(api huma.API) { return &clearQuestsOutput{Body: StatusResponse{Status: "ok"}}, nil }) + + // GET /api/devices/all + huma.Register(api, huma.Operation{ + OperationID: "get-devices", + Method: http.MethodGet, + Path: "/api/devices/all", + Summary: "List all known devices", + Description: "Returns the last-known location for every device that has submitted data.", + Tags: []string{"Devices"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusOK, + }, func(ctx context.Context, in *struct{}) (*devicesOutput, error) { + out := &devicesOutput{} + out.Body.Devices = GetAllDevices() + return out, nil + }) + + // GET /api/fort-tracker/cell/{cell_id} + huma.Register(api, huma.Operation{ + OperationID: "get-fort-tracker-cell", + Method: http.MethodGet, + Path: "/api/fort-tracker/cell/{cell_id}", + Summary: "Forts within an S2 cell", + Description: "Returns the pokestops and gyms the fort tracker has seen within the given S2 cell.", + Tags: []string{"FortTracker"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusOK, + }, func(ctx context.Context, in *fortTrackerCellInput) (*fortTrackerCellOutput, error) { + ft := decoder.GetFortTracker() + if ft == nil { + return nil, huma.Error503ServiceUnavailable("FortTracker not initialized") + } + info := ft.GetCellInfo(in.CellId) + if info == nil { + return nil, huma.Error404NotFound("Cell not found") + } + return &fortTrackerCellOutput{Body: *info}, nil + }) + + // GET /api/fort-tracker/forts/{fort_id} + huma.Register(api, huma.Operation{ + OperationID: "get-fort-tracker-fort", + Method: http.MethodGet, + Path: "/api/fort-tracker/forts/{fort_id}", + Summary: "Fort tracker info for a fort", + Description: "Returns the S2 cell and last-seen timestamp the fort tracker holds for the given fort id.", + Tags: []string{"FortTracker"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusOK, + }, func(ctx context.Context, in *fortTrackerFortInput) (*fortTrackerFortOutput, error) { + ft := decoder.GetFortTracker() + if ft == nil { + return nil, huma.Error503ServiceUnavailable("FortTracker not initialized") + } + info := ft.GetFortInfo(in.FortId) + if info == nil { + return nil, huma.Error404NotFound("Fort not found") + } + return &fortTrackerFortOutput{Body: *info}, nil + }) + + // GET+POST /api/reload-geojson + reloadGeojsonHandler := func(ctx context.Context, in *struct{}) (*reloadGeojsonOutput, error) { + decoder.ReloadGeofenceAndClearStats() + return &reloadGeojsonOutput{Body: StatusResponse{Status: "ok"}}, nil + } + huma.Register(api, huma.Operation{ + OperationID: "reload-geojson-get", + Method: http.MethodGet, + Path: "/api/reload-geojson", + Summary: "Reload geofences and clear stats", + Description: "Reloads geofences from the configured source and clears area statistics.", + Tags: []string{"Admin"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusAccepted, + }, reloadGeojsonHandler) + huma.Register(api, huma.Operation{ + OperationID: "reload-geojson-post", + Method: http.MethodPost, + Path: "/api/reload-geojson", + Summary: "Reload geofences and clear stats", + Description: "Reloads geofences from the configured source and clears area statistics.", + Tags: []string{"Admin"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusAccepted, + }, reloadGeojsonHandler) + + // GET+POST /api/skip-preserve-pokemon + skipPreserveHandler := func(ctx context.Context, in *struct{}) (*skipPreservePokemonOutput, error) { + decoder.SetSkipPreservePokemon(true) + log.Info("Skip preserve pokemon flag set - pokemon will not be preserved on shutdown") + out := &skipPreservePokemonOutput{} + out.Body.Status = "ok" + out.Body.Message = "Pokemon preservation will be skipped on shutdown" + return out, nil + } + huma.Register(api, huma.Operation{ + OperationID: "skip-preserve-pokemon-get", + Method: http.MethodGet, + Path: "/api/skip-preserve-pokemon", + Summary: "Skip pokemon preservation on shutdown", + Description: "Sets a flag so pokemon are not preserved to the database on shutdown.", + Tags: []string{"Admin"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusOK, + }, skipPreserveHandler) + huma.Register(api, huma.Operation{ + OperationID: "skip-preserve-pokemon-post", + Method: http.MethodPost, + Path: "/api/skip-preserve-pokemon", + Summary: "Skip pokemon preservation on shutdown", + Description: "Sets a flag so pokemon are not preserved to the database on shutdown.", + Tags: []string{"Admin"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusOK, + }, skipPreserveHandler) +} + +type devicesOutput struct { + Body struct { + Devices map[string]ApiDeviceLocation `json:"devices"` + } +} + +type fortTrackerCellInput struct { + CellId uint64 `path:"cell_id" doc:"S2 cell id"` +} +type fortTrackerCellOutput struct{ Body decoder.CellFortInfo } + +type fortTrackerFortInput struct { + FortId string `path:"fort_id" doc:"Fort id"` +} +type fortTrackerFortOutput struct{ Body decoder.FortTrackerInfo } + +type reloadGeojsonOutput struct{ Body StatusResponse } + +type skipPreservePokemonOutput struct { + Body struct { + Status string `json:"status"` + Message string `json:"message"` + } } From d61e03799868d7614b5c7adcd58b865bead8b1bc Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 22:05:23 +0100 Subject: [PATCH 16/19] docs: record results of remaining-APIs Huma migration All planned endpoints (fort scan draft-badged, pokemon search/id, tier 3, tier 4) now served by Huma; v1 scan + /raw + health/version stay on gin. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-30-huma-remaining-apis-design.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/superpowers/specs/2026-05-30-huma-remaining-apis-design.md b/docs/superpowers/specs/2026-05-30-huma-remaining-apis-design.md index 5c8c4ede..aa8af6a7 100644 --- a/docs/superpowers/specs/2026-05-30-huma-remaining-apis-design.md +++ b/docs/superpowers/specs/2026-05-30-huma-remaining-apis-design.md @@ -157,3 +157,38 @@ the requested scope — leave on gin for now). - Per-field semantic constraints on numeric ranges (e.g. IV 0–15) — a shared range type can't express per-field domains; separate future change. - Splitting draft vs stable into separate PRs (we chose one PR). + +## Results (2026-05-30) + +Implemented on `feat/huma-remaining-apis` in 13 tasks (each spec- and +quality-reviewed). All planned endpoints are now served by Huma: + +- **Fort scan (Draft-badged):** `/api/gym/scan`, `/api/pokestop/scan`, + `/api/station/scan`, `/api/fort/scan` — with the Stoplight `x-badges` Draft badge, + `fort_in_memory` 503 gating, and the int8/int16 fort range types unified. +- **Pokemon:** `/api/pokemon/search`, `/api/pokemon/id/{pokemon_id}`. +- **Tier 3:** `/api/gym/query`, `/api/station/query`, `/api/gym/search`, + `/api/gym/id/{gym_id}`, `/api/pokestop/id/{fort_id}`, + `/api/tappable/id/{tappable_id}`, `/api/pokestop-positions`. +- **Tier 4:** `/api/quest-status`, `/api/clear-quests`, `/api/devices/all`, + `/api/fort-tracker/cell/{cell_id}`, `/api/fort-tracker/forts/{fort_id}`, + `/api/reload-geojson` (GET+POST), `/api/skip-preserve-pokemon` (GET+POST). + +**Response structs converted to pointers** (with golden-snapshot tests pinning the +wire format): `ApiGymResult`, `ApiPokestopResult`, `ApiStationResult`, +`ApiTappableResult`. `ApiFortScan` and `ApiPokemonSearch` now use `ApiLatLon`. + +**Still on gin (intentional):** `POST /raw`, `GET /health`, `GET /version`, +`POST /api/pokemon/scan` (v1, deprecated), `GET /api/pokemon/available`. + +**Decisions realized:** geofence endpoints take a `RawBody` GeoJSON body via +`geo.NormaliseFenceFromBytes`; `gym/query`/`station/query` standardized to +`{"ids":[...]}`. Known transport changes vs the gin handlers (consistent across the +migration): validation errors are now `422` + `application/problem+json` instead of +ad-hoc `400`/`500` `{"error":...}`; a malformed geofence on `pokestop-positions` +returns `400` (was `500`). `clear-quests` DB-delete behavior verified identical. + +**Remaining manual step:** boot against a populated MariaDB and open `/docs` to +confirm the operations group by tag and the fort scan ops show the Draft badge +(the implementation environment has no live DB; all HTTP-contract behavior is +covered by `humatest` e2e + golden-snapshot tests). From 861de0bed636e3bbb4c6d1684cd3217e0273b30c Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 22:18:16 +0100 Subject: [PATCH 17/19] fix: correct stale lock-instrumentation caller label in search-gyms Co-Authored-By: Claude Opus 4.8 (1M context) --- routes_huma.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes_huma.go b/routes_huma.go index b52a22b4..5410ad7e 100644 --- a/routes_huma.go +++ b/routes_huma.go @@ -412,7 +412,7 @@ func registerTier3Routes(api huma.API) { if id == "" { continue } - g, unlock, err := decoder.GetGymRecordReadOnly(tctx, dbDetails, id, "API.GetFortTracker") + g, unlock, err := decoder.GetGymRecordReadOnly(tctx, dbDetails, id, "API.SearchGyms") if err != nil { if unlock != nil { unlock() From dc487e635dd8c7c4ada028ef5edd2ee0f8c0643d Mon Sep 17 00:00:00 2001 From: James Berry Date: Sat, 30 May 2026 22:43:24 +0100 Subject: [PATCH 18/19] feat: serve GET /api/pokemon/available via Huma MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register available-pokemon (GET, 202) and retire the gin handler. The result struct (ApiPokemonAvailableResult) is plain ints, so no pointer conversion needed — just doc tags. Co-Authored-By: Claude Opus 4.8 (1M context) --- decoder/api_pokemon.go | 6 +++--- huma_routes_test.go | 7 +++++++ main.go | 1 - routes.go | 4 ---- routes_huma.go | 17 +++++++++++++++++ 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/decoder/api_pokemon.go b/decoder/api_pokemon.go index 8c85adae..7c70a1bf 100644 --- a/decoder/api_pokemon.go +++ b/decoder/api_pokemon.go @@ -16,9 +16,9 @@ import ( const earthRadiusKm = 6371 type ApiPokemonAvailableResult struct { - PokemonId int16 `json:"id"` - Form int16 `json:"form"` - Count int `json:"count"` + PokemonId int16 `json:"id" doc:"Pokedex id"` + Form int16 `json:"form" doc:"Form id"` + Count int `json:"count" doc:"Number currently in the cache"` } func GetAvailablePokemon() []*ApiPokemonAvailableResult { diff --git a/huma_routes_test.go b/huma_routes_test.go index 014da5c3..5afec386 100644 --- a/huma_routes_test.go +++ b/huma_routes_test.go @@ -137,6 +137,13 @@ func TestPokemonReadEndpoints(t *testing.T) { t.Fatalf("body is not a JSON array: %v; body=%s", err, resp.Body.String()) } }) + + t.Run("available-pokemon returns 202", func(t *testing.T) { + resp := api.Get("/api/pokemon/available") + if resp.Code != http.StatusAccepted { + t.Fatalf("got %d, want 202; body=%s", resp.Code, resp.Body.String()) + } + }) } // TestTier3ReadEndpoints exercises the migrated tier-3 read endpoints over the diff --git a/main.go b/main.go index d193dff4..3b02e40e 100644 --- a/main.go +++ b/main.go @@ -332,7 +332,6 @@ func main() { apiGroup := r.Group("/api", AuthRequired()) apiGroup.GET("/health", GetHealth) - apiGroup.GET("/pokemon/available", PokemonAvailable) apiGroup.POST("/pokemon/scan", PokemonScan) debugGroup := r.Group("/debug") diff --git a/routes.go b/routes.go index 21612702..90674ed1 100644 --- a/routes.go +++ b/routes.go @@ -347,10 +347,6 @@ func PokemonScan(c *gin.Context) { c.JSON(http.StatusAccepted, res) } -func PokemonAvailable(c *gin.Context) { - res := decoder.GetAvailablePokemon() - c.JSON(http.StatusAccepted, res) -} // GetHealth provides unrestricted health status for monitoring tools func GetHealth(c *gin.Context) { diff --git a/routes_huma.go b/routes_huma.go index 5410ad7e..3612b49e 100644 --- a/routes_huma.go +++ b/routes_huma.go @@ -103,6 +103,23 @@ func registerPokemonReadRoutes(api huma.API) { } return &pokemonByIdOutput{Body: *res}, nil }) + + huma.Register(api, huma.Operation{ + OperationID: "available-pokemon", + Method: http.MethodGet, + Path: "/api/pokemon/available", + Summary: "List currently available pokemon", + Description: "Returns the distinct pokemon id/form combinations currently in the cache with their counts.", + Tags: []string{"Pokemon"}, + Security: []map[string][]string{{securitySchemeName: {}}}, + DefaultStatus: http.StatusAccepted, + }, func(ctx context.Context, _ *struct{}) (*pokemonAvailableOutput, error) { + return &pokemonAvailableOutput{Body: decoder.GetAvailablePokemon()}, nil + }) +} + +type pokemonAvailableOutput struct { + Body []*decoder.ApiPokemonAvailableResult } type gymScanInput struct{ Body decoder.ApiFortScan } From 5120eebbf1b65616a54e36c1c28172fbc8704fa4 Mon Sep 17 00:00:00 2001 From: James Berry Date: Sun, 31 May 2026 11:50:06 +0100 Subject: [PATCH 19/19] refactor: geofence endpoints take json.RawMessage, documented as JSON The quest-status, clear-quests, and pokestop-positions bodies are JSON (GeoJSON geometry/feature or a Golbat fence), but Huma's RawBody []byte documented them as application/octet-stream (a binary string). Switch to Body json.RawMessage so the OpenAPI shows application/json. json.RawMessage is assignable to []byte, so NormaliseFenceFromBytes is called unchanged; malformed JSON still returns 400 (Huma body-parse error). Co-Authored-By: Claude Opus 4.8 (1M context) --- routes_huma.go | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/routes_huma.go b/routes_huma.go index 3612b49e..23c9ff6f 100644 --- a/routes_huma.go +++ b/routes_huma.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "net/http" "time" @@ -260,7 +261,9 @@ type tappableByIdInput struct { type tappableByIdOutput struct{ Body decoder.ApiTappableResult } type pokestopPositionsInput struct { - RawBody []byte + // Body is the geofence: a GeoJSON geometry, a GeoJSON feature, or a Golbat + // fence object. Captured as raw JSON and parsed by NormaliseFenceFromBytes. + Body json.RawMessage } type pokestopPositionsOutput struct{ Body []db2.QuestLocation } @@ -537,7 +540,7 @@ func registerTier3Routes(api huma.API) { Security: []map[string][]string{{securitySchemeName: {}}}, DefaultStatus: http.StatusAccepted, }, func(ctx context.Context, in *pokestopPositionsInput) (*pokestopPositionsOutput, error) { - fence, err := geo.NormaliseFenceFromBytes(in.RawBody) + fence, err := geo.NormaliseFenceFromBytes(in.Body) if err != nil { return nil, huma.Error400BadRequest(err.Error()) } @@ -550,12 +553,16 @@ func registerTier3Routes(api huma.API) { } type questStatusInput struct { - RawBody []byte + // Body is the geofence: a GeoJSON geometry, a GeoJSON feature, or a Golbat + // fence object. Captured as raw JSON and parsed by NormaliseFenceFromBytes. + Body json.RawMessage } type questStatusOutput struct{ Body db2.QuestStatus } type clearQuestsInput struct { - RawBody []byte + // Body is the geofence: a GeoJSON geometry, a GeoJSON feature, or a Golbat + // fence object. Captured as raw JSON and parsed by NormaliseFenceFromBytes. + Body json.RawMessage } type clearQuestsOutput struct{ Body StatusResponse } @@ -573,7 +580,7 @@ func registerTier4Routes(api huma.API) { Security: []map[string][]string{{securitySchemeName: {}}}, DefaultStatus: http.StatusOK, }, func(ctx context.Context, in *questStatusInput) (*questStatusOutput, error) { - fence, err := geo.NormaliseFenceFromBytes(in.RawBody) + fence, err := geo.NormaliseFenceFromBytes(in.Body) if err != nil { return nil, huma.Error400BadRequest(err.Error()) } @@ -592,7 +599,7 @@ func registerTier4Routes(api huma.API) { Security: []map[string][]string{{securitySchemeName: {}}}, DefaultStatus: http.StatusAccepted, }, func(ctx context.Context, in *clearQuestsInput) (*clearQuestsOutput, error) { - fence, err := geo.NormaliseFenceFromBytes(in.RawBody) + fence, err := geo.NormaliseFenceFromBytes(in.Body) if err != nil { return nil, huma.Error400BadRequest(err.Error()) }