FuelBoard Relay API: official Fuel Finder API adapter (sibling of CSV relay)

- Talks to the OFFICIAL GOV.UK Fuel Finder API instead of the public CSV
  mirror: OAuth token (/api/v1/oauth/generate_access_token), batched
  paging (500/page via batch-number) over /api/v1/pfs and
  /api/v1/pfs/fuel-prices, joined on node_id.
- Price strings parsed to pence with pounds->pence correction (< 2.0);
  50-500p sane band keeps junk out of the cheapest reference.
- Fuel mapping: E5/E10 as-is; B7/B7_STANDARD/B7S/B7P/B10 -> DIESEL;
  HVO/SDV dropped (app tracks E5/E10/DIESEL only).
- lat/lng parsed from nested location strings.
- Output contract IDENTICAL to fuelboard-relay (/health + /api/v1/stations),
  so the app/widget/alerts work unchanged; switch = point app at :8789.
- CSV mirror kept as automatic fallback (FUEL_SOURCE=auto) so the full
  path is testable before GOV.UK One Login creds exist.
- 12 unit tests against documented API shapes (no network).
This commit is contained in:
FuelBoard Contributor
2026-08-12 15:34:57 +01:00
commit b315f4dc47
8 changed files with 817 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
# FuelBoard Relay API
Keyless proxy for the **official UK Fuel Finder API** (the OAuth-protected
GOV.UK service). Sibling of `fuelboard-relay` (which reads the public CSV
mirror): this one talks to the real API, joins the station-info and fuel-price
endpoints on `node_id`, and normalises to the **same output shape** so the
FuelBoard iOS app and widget work against either relay unchanged.
Holds the GOV.UK OAuth 2.0 client credentials server-side so the app never
embeds a secret (secrets inside an IPA are extractable by anyone).
## Architecture
```
FuelBoard app / widget
│ GET http://192.168.1.131:8789/api/v1/stations?fuel=e10&lat=..&lng=..
FuelBoard Relay API (this repo, runs on the Mac Mini)
│ POST /api/v1/oauth/generate_access_token (token cached ~1h)
│ GET /api/v1/pfs?batch-number=N (station info, 500/page)
│ GET /api/v1/pfs/fuel-prices?batch-number=N (prices, 500/page)
UK Fuel Finder API (official gov.uk open data)
```
- Token refreshed automatically before expiry (60s buffer); reused across the
two batched loops (docs: reuse tokens until near expiry).
- Both endpoints paginated 500 records/page via `batch-number` until a short
page is returned.
- Prices arrive as **decimal strings in pence** (`"0120.0000"` = 120.0p); some
stations report pounds (values < 2.0 are multiplied by 100). Out-of-band
values are dropped (50500p sane band) so a pounds-denominated column can't
poison the nationwide cheapest reference.
- Fuel types mapped: `E5`/`E10` → same, `B7`/`B7_STANDARD`/`B7S`/`B7P`/`B10`
`DIESEL`. Others (`HVO`, `SDV`, …) are ignored — the app tracks exactly
E5/E10/DIESEL.
- Station snapshot cached `FUEL_CACHE_TTL` (default 300s); stale cache is
served on upstream failure so the app never sees a hard outage.
- No credentials are exposed to clients — the app calls the relay keyless.
## Setup
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install pytest # only needed for the test suite
cp .env.example .env # then fill in FUEL_API_CLIENT_ID / FUEL_API_CLIENT_SECRET
./scripts/run.sh # serves on FUEL_RELAY_PORT (default 8789)
```
Without credentials, `FUEL_SOURCE=auto` falls back to the public hourly CSV
mirror (same parser as `fuelboard-relay`), so the full relay→app path is
testable end-to-end before GOV.UK One Login creds exist. `/health` reports the
active `source` (`csv` vs `api`).
## Endpoints
| Endpoint | Description |
|---|---|
| `GET /health` | Cache status, active source, last sync, failure counters |
| `GET /api/v1/stations` | All stations with prices (keyless) |
| `GET /api/v1/stations?fuel=e10&lat=53.72&lng=-1.85&radius=10&limit=10` | Filter by fuel, radius around a point, sorted nearest-first |
| `GET /api/v1/stations?fuel=diesel` (no lat/lng) | Sorted cheapest-first |
Fuel grades: `e10`, `e5`, `diesel`. Output contract identical to
`fuelboard-relay` — the app can switch relays by changing the base URL only.
## Getting credentials
1. Sign in at <https://developer.fuel-finder.service.gov.uk/get-started-ifr/onelogin> with GOV.UK One Login.
2. Register an application → get `client_id` + `client_secret`.
3. Put them in `.env` (never in git, never in the app).
## Fair use notes (from the Fuel Finder developer guideline)
- Refresh at least every 5 minutes — we do (cron-style refresh on request + TTL cache).
- Serve data unmodified — this relay only filters/sorts, never alters prices.
- Keep timestamps — `price_updated` is passed through untouched.
- Rate limits: ~100 rpm documented — batched loops issue ~2 requests per page
(info + prices), comfortably inside the limit on a 5-minute cadence.
## Tests
```bash
python -m pytest tests/ -q
```
Covers price-string parsing (pence, pounds, out-of-band), the node_id join,
fuel-type mapping, and CSV-fallback parsing — against the documented API
shapes, no network required.
## Switching the app
Point `Shared/FuelPriceProvider.swift`'s `RelayFuelProvider.baseURL` at
`http://192.168.1.131:8789` (API relay) instead of `:8788` (CSV relay). No
other app change needed — the station JSON is identical in shape.