# 07 — REST API Contract Status: **Accepted** · Version: `v1` · Last updated: Milestone 1 This document is the human-readable contract for the FablePool Kite Map API. The machine-readable, normative version lives at [`api/openapi.yaml`](../api/openapi.yaml). If the two ever disagree, the OpenAPI document wins and this file must be fixed. --- ## 1. Design principles 1. **Open by default.** All read endpoints (spots, weather, flyability) are public and require no API key. Write endpoints (reports, spot edits, favorites) require a user account. This mirrors the project charter: no paid-API lock-in, no registration wall in front of the map. 2. **Cache-friendly.** Weather and flyability responses are deterministic for a given `(location, model_run)` pair and carry strong `ETag` and `Cache-Control` headers so CDNs and the browser can do most of the work. 3. **Boring REST.** Plural nouns, standard verbs, JSON bodies, RFC 7807 errors, cursor pagination. No GraphQL, no RPC-over-POST. 4. **Versioned in the path.** `/v1/...`. Breaking changes require `/v2`; additive changes (new optional fields, new endpoints) do not bump the version. 5. **Units are explicit and metric.** Wind speeds are **m/s**, temperatures **°C**, precipitation **mm/h**, distances **m**, directions **degrees true (0–360, FROM which the wind blows)**. Clients convert for display (kts/mph/Beaufort). --- ## 2. Conventions ### 2.1 Base URL ``` https://api.kitemap.example/v1 ``` Self-hosters set their own host; clients must treat the base URL as configuration. ### 2.2 Authentication - Scheme: `Authorization: Bearer `. - Access tokens are short-lived (15 min); refresh tokens (30 days, rotated on use) are exchanged at `POST /auth/refresh`. - Anonymous requests are allowed wherever the endpoint table below says **public**. ### 2.3 Content types - Requests: `application/json` (UTF-8). - Success responses: `application/json`. - Error responses: `application/problem+json` (RFC 7807). - GeoJSON responses (`/spots.geojson`): `application/geo+json`. ### 2.4 Errors (RFC 7807) ```json { "type": "https://kitemap.example/errors/validation", "title": "Validation failed", "status": 422, "detail": "latitude must be between -90 and 90", "instance": "/v1/spots", "errors": [ { "field": "latitude", "message": "must be between -90 and 90" } ] } ``` Canonical `type` slugs: `validation`, `not-found`, `unauthorized`, `forbidden`, `conflict`, `rate-limited`, `upstream-weather-unavailable`, `internal`. ### 2.5 Pagination Cursor-based on all list endpoints: - Request: `?limit=50&cursor=eyJpZCI6...` - Response envelope: ```json { "data": [ ... ], "next_cursor": "eyJpZCI6MTIzfQ", "has_more": true } ``` `limit` default 50, max 200. Cursors are opaque base64 strings; clients must not parse them. ### 2.6 Rate limiting | Caller | Limit | |-------------------|--------------------------------| | Anonymous (by IP) | 60 req/min, 10 000 req/day | | Authenticated | 240 req/min, 50 000 req/day | | Write endpoints | 30 writes/hour per user | Headers on every response: `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` (seconds). On 429, also `Retry-After`. ### 2.7 Idempotency Mutating `POST` endpoints accept an optional `Idempotency-Key` header (UUID). Replays within 24 h return the original response with `Idempotency-Replayed: true`. --- ## 3. Endpoint summary | Method | Path | Auth | Purpose | |--------|---------------------------------------|-----------|---------| | GET | `/health` | public | Liveness/readiness probe | | POST | `/auth/register` | public | Create account | | POST | `/auth/login` | public | Exchange credentials for tokens | | POST | `/auth/refresh` | public | Rotate refresh token | | POST | `/auth/logout` | bearer | Revoke refresh token | | GET | `/users/me` | bearer | Current user profile | | PATCH | `/users/me` | bearer | Update profile / unit prefs | | GET | `/users/me/favorites` | bearer | List favorite spots | | PUT | `/users/me/favorites/{spotId}` | bearer | Add favorite (idempotent) | | DELETE | `/users/me/favorites/{spotId}` | bearer | Remove favorite | | GET | `/spots` | public | List/search spots (bbox, filters) | | GET | `/spots.geojson` | public | Same, as GeoJSON FeatureCollection | | POST | `/spots` | bearer | Propose a new spot | | GET | `/spots/{spotId}` | public | Spot detail | | PATCH | `/spots/{spotId}` | bearer | Edit spot (owner/moderator) | | DELETE | `/spots/{spotId}` | moderator | Soft-delete spot | | GET | `/spots/{spotId}/forecast` | public | Hourly forecast (up to 7 days) | | GET | `/spots/{spotId}/flyability` | public | Flyability scores per kite type | | GET | `/spots/{spotId}/reports` | public | Recent session/condition reports | | POST | `/spots/{spotId}/reports` | bearer | Submit a condition report | | DELETE | `/reports/{reportId}` | bearer | Delete own report (or moderator) | | GET | `/spots/{spotId}/hazards` | public | List hazards/obstacles | | POST | `/spots/{spotId}/hazards` | bearer | Report a hazard | | GET | `/weather/point` | public | Forecast for arbitrary lat/lon | | GET | `/flyability/point` | public | Flyability for arbitrary lat/lon | --- ## 4. Key endpoints in detail ### 4.1 `GET /spots` — search spots Query parameters: | Param | Type | Notes | |--------------|---------|-------| | `bbox` | string | `minLon,minLat,maxLon,maxLat`. Required unless `q` or `near` given. Max area 10°×10°. | | `near` | string | `lat,lon` — sorts by distance, implies 100 km radius. | | `q` | string | Free-text search on name/description. | | `spot_type` | enum[] | `beach`, `park`, `field`, `hill`, `water` (repeatable). | | `kite_type` | enum | Filter to spots suitable for `single_line`, `sport_stunt`, `power_foil`, `kitesurf`. | | `min_score` | int | 0–100; only spots whose **current** flyability ≥ value (joins cached scores). | | `verified` | bool | Only community-verified spots. | | `limit`, `cursor` | — | Pagination (§2.5). | Response item (abridged — full schema in OpenAPI): ```json { "id": "spt_01HXY3...", "name": "Ocean Beach North", "lat": 37.7694, "lon": -122.5107, "spot_type": "beach", "suitable_kite_types": ["sport_stunt", "power_foil", "kitesurf"], "verified": true, "hazard_count": 2, "current_flyability": { "as_of": "2025-06-01T14:00:00Z", "scores": { "single_line": 38, "sport_stunt": 82, "power_foil": 74, "kitesurf": 91 } }, "current_wind": { "speed_ms": 9.4, "gust_ms": 12.1, "direction_deg": 285 } } ``` `current_flyability` / `current_wind` may be `null` when the cache is cold; clients must render gracefully and fetch `/spots/{id}/flyability` on demand. ### 4.2 `GET /spots/{spotId}/forecast` | Param | Type | Default | Notes | |-----------|--------|---------|-------| | `hours` | int | 48 | 1–168. | | `start` | string | now | ISO 8601; truncated to the hour. | ```json { "spot_id": "spt_01HXY3...", "source": "open-meteo", "model": "best_match", "model_run": "2025-06-01T06:00:00Z", "elevation_m": 4.0, "hours": [ { "time": "2025-06-01T14:00:00Z", "wind_speed_ms": 9.4, "wind_gust_ms": 12.1, "wind_direction_deg": 285, "temperature_c": 16.2, "precipitation_mmh": 0.0, "precipitation_probability_pct": 5, "cloud_cover_pct": 40, "is_daylight": true } ] } ``` Caching: `Cache-Control: public, max-age=600, stale-while-revalidate=3600` plus `ETag` keyed on `(spot_id, model_run, hours, start)`. ### 4.3 `GET /spots/{spotId}/flyability` | Param | Type | Default | Notes | |-------------|-------|---------|-------| | `kite_type` | enum | all | One of the four kite types, or omit for all. | | `hours` | int | 48 | 1–168. | Response hour entry (rubric defined in `docs/05-flyability-scoring.md`): ```json { "time": "2025-06-01T14:00:00Z", "kite_type": "sport_stunt", "score": 82, "band": "good", "components": { "wind_speed": { "score": 90, "value_ms": 9.4 }, "gust_ratio": { "score": 78, "value": 1.29 }, "precipitation": { "score": 100, "value_mmh": 0.0 }, "direction": { "score": 70, "note": "onshore-cross, ok for this spot" }, "obstacles": { "penalty": -5, "hazard_ids": ["hzd_01..."] } }, "advisories": ["Gusty: gust factor 1.29", "Sunset 20:43 local"] } ``` `band` ∈ `no_fly` (0–19), `marginal` (20–44), `fair` (45–64), `good` (65–84), `excellent` (85–100). Bands are computed server-side so all clients agree. ### 4.4 `POST /spots/{spotId}/reports` Body: ```json { "observed_at": "2025-06-01T13:30:00Z", "kite_type": "kitesurf", "observed_wind_speed_ms": 10.0, "observed_gust_ms": 13.0, "observed_direction_deg": 290, "crowding": "moderate", "conditions_rating": 4, "comment": "Clean side-onshore, small chop, low tide gives big launch area." } ``` Rules: `observed_at` may not be in the future or older than 48 h; `conditions_rating` 1–5; `comment` ≤ 1000 chars; one report per user per spot per hour (409 on conflict). Returns `201` with the created report and `Location` header. ### 4.5 `GET /weather/point` and `GET /flyability/point` For map hover/click anywhere (not only saved spots): ``` GET /v1/flyability/point?lat=37.7694&lon=-122.5107&kite_type=sport_stunt&hours=24 ``` Coordinates are snapped to a 0.05° grid before hitting the upstream provider so the cache hit rate stays high; the response includes the snapped `grid_lat`/`grid_lon`. Obstacle/direction components are omitted (no spot metadata): the response sets `"components": { ..., "direction": null, "obstacles": null }` and the score is the weather-only score, flagged with `"partial": true`. --- ## 5. Auth flows ### Register `POST /auth/register` `{ "email", "password", "display_name" }` → `201 { "user": {...}, "access_token", "refresh_token", "expires_in": 900 }`. Password ≥ 10 chars; emails verified asynchronously (unverified users can read and favorite but not post spots/reports — enforced with `403 type=forbidden`, `detail="email not verified"`). ### Login / refresh / logout - `POST /auth/login` `{ "email", "password" }` → same token envelope. 401 on bad credentials (no user-enumeration: identical error for unknown email). - `POST /auth/refresh` `{ "refresh_token" }` → new pair; old refresh token is revoked (rotation). Reuse of a rotated token revokes the whole family (theft detection) and returns 401. - `POST /auth/logout` revokes the presented refresh token. Always `204`. ### Roles `user` (default), `moderator`, `admin` — stored in `users.role` (see `db/schema.sql`) and embedded in the JWT `role` claim. Moderators may edit/delete any spot, report, or hazard; admins may additionally change roles. --- ## 6. Versioning & deprecation policy - Additive changes (new fields, new endpoints, new enum values **on responses**) ship without notice; clients must ignore unknown fields. - New enum values on **request** parameters are also additive. - Removing/renaming fields, changing units, or changing auth requirements is breaking → new major version, with the old version maintained ≥ 6 months and a `Deprecation` + `Sunset` header on the old endpoints. --- ## 7. CORS & embedding `Access-Control-Allow-Origin: *` on all public GET endpoints (the project exists to be embedded). Credentialed endpoints echo the configured frontend origin(s) only.