From 6a8f3db2d4e8358bd21977eccffd53ab5c3c3b65 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Tue, 11 Aug 2026 14:28:24 +0100 Subject: [PATCH] FuelBoard Relay: keyless OAuth proxy for UK Fuel Finder API (FastAPI, token + snapshot cache, demo mode) --- .env.example | 7 ++ .gitignore | 5 + README.md | 71 ++++++++++++++ app/main.py | 237 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + scripts/run.sh | 15 +++ 6 files changed, 338 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/main.py create mode 100644 requirements.txt create mode 100755 scripts/run.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e4a97e4 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Copy to .env and fill in — NEVER commit .env +FUEL_API_CLIENT_ID= +FUEL_API_CLIENT_SECRET= +FUEL_API_TOKEN_URL=https://api.fuelfinder.service.gov.uk/v1/token +FUEL_API_PRICES_URL=https://api.fuelfinder.service.gov.uk/v1/prices +FUEL_CACHE_TTL=300 +FUEL_RELAY_PORT=8788 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf73a13 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +__pycache__/ +*.pyc +.venv/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..3976b4e --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# FuelBoard Relay + +Keyless proxy for the **UK Fuel Finder API**. Holds the GOV.UK OAuth 2.0 +client credentials server-side so the FuelBoard iOS app and widget never embed +a secret (secrets inside an IPA are extractable by anyone). + +## Architecture + +``` +FuelBoard app / widget + │ GET http://192.168.1.131:8788/api/v1/stations?fuel=e10&lat=..&lng=.. + ▼ + FuelBoard Relay (this repo, runs on the Mac Mini) + │ OAuth 2.0 client_credentials grant (token cached ~1h) + │ GET https://api.fuelfinder.service.gov.uk/v1/prices (snapshot, cached 5 min) + ▼ + UK Fuel Finder API (official gov.uk open data) +``` + +- Token refreshed automatically before expiry (60s buffer). +- 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 +cp .env.example .env # then fill in FUEL_API_CLIENT_ID / FUEL_API_CLIENT_SECRET +./scripts/run.sh +``` + +Without credentials the relay runs in **demo mode**: `/health` reports +`demo_mode: true` and `/api/v1/stations` returns a clean 503 with a hint — +useful for testing the app→relay path before credentials exist. + +## Endpoints + +| Endpoint | Description | +|---|---| +| `GET /health` | Cache status, 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`. + +## Getting credentials + +1. Sign in at 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. + +## Integration point (FuelBoard app) + +`Shared/FuelPriceProvider.swift` has `FuelFinderProvider` stubbed with a +`TODO`. When the relay is live, point it at: + +``` +http://192.168.1.131:8788/api/v1/stations?fuel=&lat=..&lng=.. +``` + +and map `stations[].price` / `stations[].distance_km` into `FuelStation`. diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..63185ff --- /dev/null +++ b/app/main.py @@ -0,0 +1,237 @@ +# FuelBoard Relay — keyless proxy for the UK Fuel Finder API +# +# Holds the GOV.UK Fuel Finder OAuth 2.0 client credentials server-side so the +# iOS app / widget never embeds a secret. The app calls this relay with no +# auth and gets stations + prices as simple JSON. +# +# Flow: +# FuelBoard app → GET http://:8788/api/v1/stations +# relay → OAuth token (cached ~1h) → GET https://api.fuelfinder.service.gov.uk/v1/prices +# relay caches the snapshot (default 5 min) → returns JSON to the app +# +# Endpoints: +# GET /health — cache status + last sync timestamp +# GET /api/v1/stations — full station list with current prices (keyless) +# GET /api/v1/stations?fuel=e10&lat=53.72&lng=-1.85&radius=10 +# — optional server-side filter/sort (cheapest first) + +from contextlib import asynccontextmanager +import os +import time +from typing import Optional + +import httpx +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import JSONResponse + +# --------------------------------------------------------------------------- +# Config (all via environment; never hardcode credentials) +# --------------------------------------------------------------------------- + +CLIENT_ID = os.environ.get("FUEL_API_CLIENT_ID", "") +CLIENT_SECRET = os.environ.get("FUEL_API_CLIENT_SECRET", "") +TOKEN_URL = os.environ.get("FUEL_API_TOKEN_URL", "https://api.fuelfinder.service.gov.uk/v1/token") +PRICES_URL = os.environ.get("FUEL_API_PRICES_URL", "https://api.fuelfinder.service.gov.uk/v1/prices") +CACHE_TTL = int(os.environ.get("FUEL_CACHE_TTL", "300")) # seconds (5 min) +TOKEN_EXPIRY_BUFFER = int(os.environ.get("FUEL_TOKEN_BUFFER", "60")) # seconds + +DEMO_MODE = not (CLIENT_ID and CLIENT_SECRET) + +# --------------------------------------------------------------------------- +# In-memory cache state +# --------------------------------------------------------------------------- + +_state = { + "stations": None, # parsed JSON payload (list or {"stations": [...]}) + "stations_fetched_at": 0.0, + "token": None, + "token_expires_at": 0.0, + "last_error": None, + "sync_attempts": 0, + "sync_failures": 0, +} + + +def _get_token(client: httpx.Client) -> str: + """Return a valid bearer token, refreshing via client-credentials grant.""" + if _state["token"] and time.time() < _state["token_expires_at"] - TOKEN_EXPIRY_BUFFER: + return _state["token"] + + resp = client.post( + TOKEN_URL, + data={"grant_type": "client_credentials"}, + auth=(CLIENT_ID, CLIENT_SECRET), + ) + resp.raise_for_status() + payload = resp.json() + token = payload["access_token"] + # Prefer expires_in (seconds); default to 1h if absent. + expires_in = int(payload.get("expires_in", 3600)) + _state["token"] = token + _state["token_expires_at"] = time.time() + expires_in + return token + + +def _fetch_prices() -> dict: + """Fetch the full prices snapshot, returning the JSON payload.""" + with httpx.Client(timeout=30) as client: + token = _get_token(client) + resp = client.get( + PRICES_URL, + headers={"Authorization": f"Bearer {token}"}, + ) + resp.raise_for_status() + return resp.json() + + +def _refresh_cache() -> None: + """Refresh the station cache if stale (or on first run).""" + _state["sync_attempts"] += 1 + try: + if DEMO_MODE: + raise RuntimeError("Fuel Finder credentials not configured (FUEL_API_CLIENT_ID/SECRET)") + data = _fetch_prices() + _state["stations"] = data + _state["stations_fetched_at"] = time.time() + _state["last_error"] = None + except Exception as exc: # keep serving stale cache on failure + _state["sync_failures"] += 1 + _state["last_error"] = f"{type(exc).__name__}: {exc}" + + +@asynccontextmanager +async def lifespan(_: FastAPI): + if not DEMO_MODE: + _refresh_cache() + yield + + +app = FastAPI(title="FuelBoard Relay", version="1.0.0", lifespan=lifespan) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _stations_list() -> list: + if _state["stations"] is None: + return [] + data = _state["stations"] + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("stations", "prices", "data"): + if isinstance(data.get(key), list): + return data[key] + return [] + + +def _distance_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float: + import math + r = 6371.0 + dlat = math.radians(lat2 - lat1) + dlng = math.radians(lng2 - lng1) + a = math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlng / 2) ** 2 + return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + +def _fuel_price(station: dict, fuel: str) -> Optional[float]: + """Extract a price for the fuel grade from a station dict. + + The Fuel Finder payload nests prices under keys like 'prices' -> 'E10'. + Tolerates both flat and nested shapes. + """ + prices = station.get("prices") or station.get("price") or {} + if isinstance(prices, dict): + for key in (fuel.upper(), fuel.upper() + "_PRICE", "price_" + fuel.upper()): + if key in prices: + val = prices[key] + if isinstance(val, (int, float)): + return float(val) + # flat fallback + for key in (fuel.upper(), "PRICE_" + fuel.upper()): + if key in station: + val = station[key] + if isinstance(val, (int, float)): + return float(val) + return None + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +@app.get("/health") +def health(): + return { + "status": "ok" if not DEMO_MODE and _state["stations"] is not None else "degraded", + "demo_mode": DEMO_MODE, + "stations_cached": _state["stations"] is not None, + "stations_count": len(_stations_list()), + "last_sync": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(_state["stations_fetched_at"])) if _state["stations_fetched_at"] else None, + "sync_attempts": _state["sync_attempts"], + "sync_failures": _state["sync_failures"], + "last_error": _state["last_error"], + } + + +@app.get("/api/v1/stations") +def stations( + fuel: str = Query("e10", description="Fuel grade: e10, e5, diesel"), + lat: Optional[float] = Query(None, description="User latitude"), + lng: Optional[float] = Query(None, description="User longitude"), + radius: float = Query(20.0, description="Search radius in km"), + limit: int = Query(10, ge=1, le=100), +): + if DEMO_MODE: + return JSONResponse( + status_code=503, + content={ + "error": "Fuel Finder credentials not configured on the relay", + "hint": "Set FUEL_API_CLIENT_ID and FUEL_API_CLIENT_SECRET (see README)", + }, + ) + + # Trigger a refresh if stale; keep serving stale cache if it fails. + if time.time() - _state["stations_fetched_at"] > CACHE_TTL: + _refresh_cache() + + if _state["stations"] is None: + raise HTTPException(status_code=502, detail="No data cached yet and upstream fetch failed") + + rows = [] + for station in _stations_list(): + price = _fuel_price(station, fuel) + if price is None: + continue + s_lat = station.get("latitude", station.get("lat")) + s_lng = station.get("longitude", station.get("lng")) + item = { + "id": station.get("id") or station.get("station_id"), + "name": station.get("name") or station.get("station_name"), + "brand": station.get("brand") or station.get("operator") or "", + "address": station.get("address") or "", + "postcode": station.get("postcode") or "", + "lat": s_lat, + "lng": s_lng, + "price": price, + "price_updated": station.get("price_updated") or station.get("updated_at"), + } + if lat is not None and lng is not None and isinstance(s_lat, (int, float)) and isinstance(s_lng, (int, float)): + item["distance_km"] = round(_distance_km(lat, lng, s_lat, s_lng), 2) + rows.append(item) + + if lat is not None and lng is not None: + rows = [r for r in rows if r.get("distance_km") is not None and r["distance_km"] <= radius] + rows.sort(key=lambda r: r["distance_km"]) + else: + rows.sort(key=lambda r: r["price"]) + + return {"fuel": fuel, "count": len(rows[:limit]), "stations": rows[:limit]} + + +if __name__ == "__main__": + import uvicorn + + port = int(os.environ.get("FUEL_RELAY_PORT", "8788")) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..00001d8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115 +uvicorn>=0.30 +httpx>=0.27 diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..70da60a --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# FuelBoard Relay launcher +set -euo pipefail +cd "$(dirname "$0")/.." + +if [ ! -f .env ]; then + echo "⚠ No .env found — running in DEMO mode (no credentials)." +fi + +set -a +[ -f .env ] && source .env +set +a + +PORT="${FUEL_RELAY_PORT:-8788}" +exec python3 -m uvicorn app.main:app --host 0.0.0.0 --port "$PORT"