FuelBoard Relay: keyless OAuth proxy for UK Fuel Finder API (FastAPI, token + snapshot cache, demo mode)
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.DS_Store
|
||||
@@ -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 <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.
|
||||
|
||||
## 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=<e10|e5|diesel>&lat=..&lng=..
|
||||
```
|
||||
|
||||
and map `stations[].price` / `stations[].distance_km` into `FuelStation`.
|
||||
+237
@@ -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://<relay>: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)
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.115
|
||||
uvicorn>=0.30
|
||||
httpx>=0.27
|
||||
Executable
+15
@@ -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"
|
||||
Reference in New Issue
Block a user