# FuelBoard Relay API — keyless proxy for the official 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. # # This is the API-source relay (sibling of fuelboard-relay, which reads the # public CSV mirror). It talks to the OFFICIAL Fuel Finder API: # # FuelBoard app → GET http://:8789/api/v1/stations # relay → OAuth token (cached ~1h) → GET /api/v1/pfs (station info) # GET /api/v1/pfs/fuel-prices (prices) # relay joins on node_id, normalises to the SAME output shape as the CSV # relay, caches the snapshot (default 5 min) → returns JSON to the app # # The /api/v1/stations contract is IDENTICAL to fuelboard-relay, so the app, # widget and alerts work unchanged against either relay — switching sources is # just pointing the app at a different host:port. # # 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) # # Data shape notes (from developer.fuel-finder.service.gov.uk): # - Token: POST /api/v1/oauth/generate_access_token with client_id/client_secret # → { "data": { "access_token": ..., "expires_in": 3600 } } # - Stations: GET /api/v1/pfs → array, batched 500/page via ?batch-number=N # - Prices: GET /api/v1/pfs/fuel-prices → array, same batching # - Price values are DECIMAL STRINGS in pence ("0120.0000" = 120.0p); some # stations report pounds (< 2.0) — multiply by 100. # - lat/lng are STRINGS inside location: { latitude, longitude }. # - Fuel types: E10, E5, B7_STANDARD (diesel), B10, HVO, SDV, ... from contextlib import asynccontextmanager import os import time from typing import Optional import httpx from fastapi import FastAPI, HTTPException, Query # --------------------------------------------------------------------------- # 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", "") # Base host for the official Fuel Finder API (note: NOT api.fuelfinder... — # the real host is www.fuel-finder.service.gov.uk). API_BASE = os.environ.get( "FUEL_API_BASE", "https://www.fuel-finder.service.gov.uk", ) TOKEN_URL = os.environ.get("FUEL_API_TOKEN_URL", f"{API_BASE}/api/v1/oauth/generate_access_token") # Official Fuel Finder endpoints (batched, 500 records/page). PFS_URL = os.environ.get("FUEL_API_PFS_URL", f"{API_BASE}/api/v1/pfs") PRICES_URL = os.environ.get( "FUEL_API_PRICES_URL", f"{API_BASE}/api/v1/pfs/fuel-prices", ) PAGE_SIZE = int(os.environ.get("FUEL_API_PAGE_SIZE", "500")) 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 # Fuel Finder rate limit: 30 rpm per client, sequential only (429 on overlap). # uff.py's default of 4.0s between batch pages ≈ 15 rpm — stay inside. BATCH_SLEEP = float(os.environ.get("FUEL_API_BATCH_SLEEP", "4.0")) # Public full-UK mirror of the Fuel Finder dataset (GitHub Actions, hourly), # used as the fallback source when OAuth credentials are not configured so the # relay is testable end-to-end before GOV.UK One Login creds exist. # Licence: Open Government Licence v3.0. No auth required. CSV_URL = os.environ.get( "FUEL_CSV_URL", "https://raw.githubusercontent.com/matthewgall/fuelfinder-archive/main/data.csv", ) CSV_REFRESH_TTL = int(os.environ.get("FUEL_CSV_TTL", "3600")) # seconds (1h) # Source selection: "auto" (API if creds, CSV fallback), "csv", "api" SOURCE = os.environ.get("FUEL_SOURCE", "auto").lower() DEMO_MODE = not (CLIENT_ID and CLIENT_SECRET) and SOURCE != "csv" # --------------------------------------------------------------------------- # In-memory cache state # --------------------------------------------------------------------------- _state = { "stations": None, # normalised station list [{...}, ...] "stations_fetched_at": 0.0, "token": None, "token_expires_at": 0.0, "last_error": None, "sync_attempts": 0, "sync_failures": 0, "source_used": None, } # --------------------------------------------------------------------------- # Normalisation helpers (shared by CSV fallback and API path) # --------------------------------------------------------------------------- # Map official Fuel Finder fuel types → the app's grades (E5/E10/DIESEL). # The app tracks exactly these three; everything else (HVO, SDV, B10, ...) is # dropped so it can't poison the cheapest-price reference. FUEL_MAP = { "E5": "E5", "E10": "E10", "B7": "DIESEL", "B7_STANDARD": "DIESEL", "B7S": "DIESEL", "B7P": "DIESEL", "B7_PREMIUM": "DIESEL", "B10": "DIESEL", } def _parse_price(value) -> Optional[float]: """Parse a Fuel Finder price value into pence/litre, or None. The API sends DECIMAL STRINGS in pence ("0120.0000" = 120.0p). Some stations report pounds (values below 2.0) — detect and multiply by 100. Returns None for junk (non-numeric, or outside the sane 50–500p band so a pounds-denominated column can't poison the cheapest reference). """ if value is None: return None try: raw = float(value) except (TypeError, ValueError): return None if raw < 2.0: # Pounds-denominated ("1.299" → 129.9p). Guard against a near-zero # empty/placeholder value being inflated to something plausible. raw *= 100 if raw < 50 or raw > 500: return None return raw def _normalise_station(station_id, name, brand, address, postcode, lat, lng, prices, price_updated=None, is_motorway=False) -> Optional[dict]: """Build a station dict in the relay's canonical output shape. Returns None if the station has no tracked fuel prices (sells nothing the app monitors) or its location is unusable. """ if not prices: return None if lat is None or lng is None: return None return { "id": station_id, "name": name, "brand": brand or "", "address": address or "", "postcode": postcode or "", "lat": lat, "lng": lng, "prices": prices, # {"E5": 144.9, "E10": 137.9, "DIESEL": ...} "price_updated": price_updated, "is_motorway": bool(is_motorway), } def _normalise_api_payload(stations_info: list, fuel_prices: list) -> list: """Join the official API's two endpoints on node_id → canonical stations. stations_info: records from GET /api/v1/pfs (site details + location). fuel_prices: records from GET /api/v1/pfs/fuel-prices (prices array). """ info_by_id = {} for rec in stations_info: node_id = rec.get("node_id") if not node_id: continue loc = rec.get("location") or {} lat = _parse_lat_lng(loc.get("latitude")) lng = _parse_lat_lng(loc.get("longitude")) info_by_id[node_id] = { "name": rec.get("trading_name"), "brand": rec.get("brand_name"), "address": _join_address(loc), "postcode": loc.get("postcode"), "lat": lat, "lng": lng, "is_motorway": rec.get("is_motorway_service_station", False), } stations = [] for rec in fuel_prices: node_id = rec.get("node_id") info = info_by_id.get(node_id) if info is None: continue prices = {} latest_updated = None for entry in rec.get("fuel_prices") or []: fuel_type = (entry.get("fuel_type") or "").upper() grade = FUEL_MAP.get(fuel_type) if grade is None: continue price = _parse_price(entry.get("price")) if price is None: continue # Multiple diesel variants (B7_STANDARD, B7_PREMIUM, B10) map to # one DIESEL grade — take the MINIMUM so the cheapest-diesel # reference is never inflated by a premium price. if grade == "DIESEL": prices[grade] = min(prices.get(grade, float("inf")), price) else: prices[grade] = price updated = entry.get("price_last_updated") if updated and (latest_updated is None or updated > latest_updated): latest_updated = updated station = _normalise_station( station_id=node_id, name=info["name"], brand=info["brand"], address=info["address"], postcode=info["postcode"], lat=info["lat"], lng=info["lng"], prices=prices, price_updated=latest_updated, is_motorway=info["is_motorway"], ) if station is not None: stations.append(station) return stations def _parse_lat_lng(value) -> Optional[float]: """API sends lat/lng as STRINGS — parse to float, tolerate garbage.""" if value is None or value == "": return None try: return float(value) except (TypeError, ValueError): return None def _join_address(loc: dict) -> str: parts = [loc.get("address_line_1"), loc.get("address_line_2")] return " ".join(p for p in parts if p) or "" # --------------------------------------------------------------------------- # CSV fallback (public mirror — same parser as fuelboard-relay) # --------------------------------------------------------------------------- def _parse_csv_to_stations(text: str) -> list: """Parse the Fuel Finder archive CSV into a station list. Header uses dotted keys: forecourts.trading_name, forecourts.location.latitude, forecourts.fuel_price.E10, forecourts.price_change_effective_timestamp.E10, etc. """ import csv import io reader = csv.DictReader(io.StringIO(text)) stations = [] for row in reader: def num(key): try: val = row.get(key) return float(val) if val not in (None, "") else None except (TypeError, ValueError): return None lat = num("forecourts.location.latitude") lng = num("forecourts.location.longitude") prices = {} for grade, label in (("E5", "E5"), ("E10", "E10"), ("B7S", "DIESEL"), ("B7P", "DIESEL")): p = num(f"forecourts.fuel_price.{grade}") if p is not None and grade not in prices: prices[label] = p station = _normalise_station( station_id=row.get("forecourts.node_id"), name=row.get("forecourts.trading_name"), brand=row.get("forecourts.brand_name"), address=_join_address({ "address_line_1": row.get("forecourts.location.address_line_1"), "address_line_2": row.get("forecourts.location.address_line_2"), }), postcode=row.get("forecourts.location.postcode"), lat=lat, lng=lng, prices=prices, price_updated=row.get("forecourts.price_change_effective_timestamp.E10") or row.get("forecourts.price_change_effective_timestamp.E5"), is_motorway=row.get("forecourts.is_motorway_service_station") == "true", ) if station is not None: stations.append(station) return stations def _fetch_csv() -> list: """Download the hourly full-UK CSV mirror.""" with httpx.Client(timeout=120) as client: resp = client.get(CSV_URL) resp.raise_for_status() stations = _parse_csv_to_stations(resp.text) if not stations: raise RuntimeError("CSV parse returned zero stations") return stations # --------------------------------------------------------------------------- # Official API fetch (OAuth + batched paging + node_id join) # --------------------------------------------------------------------------- def _get_token(client: httpx.Client) -> str: """Return a valid bearer token, refreshing via client-credentials grant. The Fuel Finder token endpoint returns the token under "data": { "data": { "access_token": "...", "expires_in": 3600 }, ... } Tolerates a flat { "access_token": ... } shape too (older docs). """ if _state["token"] and time.time() < _state["token_expires_at"] - TOKEN_EXPIRY_BUFFER: return _state["token"] resp = client.post( TOKEN_URL, json={"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, ) resp.raise_for_status() payload = resp.json() data = payload.get("data") if isinstance(payload.get("data"), dict) else payload token = data.get("access_token") or payload.get("access_token") if not token: raise RuntimeError(f"Token response missing access_token: {list(payload.keys())}") expires_in = int(data.get("expires_in", payload.get("expires_in", 3600))) _state["token"] = token _state["token_expires_at"] = time.time() + expires_in return token def _fetch_all_pages(client: httpx.Client, url: str) -> list: """Fetch every page of a batched Fuel Finder endpoint (500/page). Fuel Finder is strictly sequential (30 rpm per client): a request sent before the previous one completes gets a 429. We therefore sleep BATCH_SLEEP between pages and never issue a second in-flight request. """ results = [] batch = 1 while True: resp = client.get( url, params={"batch-number": batch}, headers={"Authorization": f"Bearer {_get_token(client)}"}, ) resp.raise_for_status() page = resp.json() if not isinstance(page, list) or not page: break results.extend(page) if len(page) < PAGE_SIZE: break batch += 1 if batch > 200: # safety cap (~100k records) break time.sleep(BATCH_SLEEP) return results def _fetch_api() -> list: """Fetch the full dataset from the official API and normalise it. Two batched endpoints joined on node_id: 1. GET /api/v1/pfs → site details (trading_name, location, ...) 2. GET /api/v1/pfs/fuel-prices → per-station fuel_prices array """ with httpx.Client(timeout=60) as client: # Warm the token once so both loops reuse it (token endpoint is the # most rate-sensitive; docs say reuse until near expiry). _get_token(client) stations_info = _fetch_all_pages(client, PFS_URL) fuel_prices = _fetch_all_pages(client, PRICES_URL) stations = _normalise_api_payload(stations_info, fuel_prices) if not stations: raise RuntimeError("API fetch returned zero stations") return stations # --------------------------------------------------------------------------- # Cache refresh # --------------------------------------------------------------------------- import threading _refresh_lock = threading.Lock() def _refresh_cache() -> None: """Refresh the station cache (called synchronously or from a thread).""" with _refresh_lock: _refresh_cache_locked() def _refresh_cache_locked() -> None: """Refresh the station cache if stale (or on first run). Callers hold _refresh_lock. Full API sync takes ~1-2 min (17+ pages × 2 endpoints × BATCH_SLEEP), so this must NEVER run inline in a request path — see _ensure_fresh_cache(). """ _state["sync_attempts"] += 1 try: if SOURCE == "csv": _state["stations"] = _fetch_csv() _state["source_used"] = "csv" elif SOURCE == "api": _state["stations"] = _fetch_api() _state["source_used"] = "api" else: # auto: official API if creds present, otherwise public CSV if not DEMO_MODE: _state["stations"] = _fetch_api() _state["source_used"] = "api" else: _state["stations"] = _fetch_csv() _state["source_used"] = "csv" _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}" def _ensure_fresh_cache() -> None: """Non-blocking staleness check: serve stale data, refresh in background. A full API sync takes minutes (rate-limited batched paging); blocking a request on it would blow past the app/widget's 5-second timeout. We return whatever we have immediately and let the sync catch up on a daemon thread. """ if _state["stations"] is None: # First run: block (usually the lifespan warm-up, but guards a race # where a request beats it). CSV fallback is fast; API takes a while. _refresh_cache() return if time.time() - _state["stations_fetched_at"] > CACHE_TTL: threading.Thread(target=_refresh_cache, daemon=True).start() @asynccontextmanager async def lifespan(_: FastAPI): # Always try to warm the cache. In auto mode with no credentials this # fetches the public full-UK CSV; with credentials it uses the OAuth API. _refresh_cache() yield app = FastAPI(title="FuelBoard Relay API", 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 normalised station dict.""" prices = station.get("prices") or {} raw = prices.get(fuel.upper()) if isinstance(raw, (int, float)) and 50 <= raw <= 500: return float(raw) 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, "source": _state["source_used"], "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: Optional[float] = Query(None, description="Search radius in km (optional; omit for the full dataset)"), limit: int = Query(10000, ge=1, le=10000), ): if _state["stations"] is None: raise HTTPException(status_code=502, detail="No data cached yet and upstream fetch failed") # Refresh in the background if stale; never block a request on a # multi-minute rate-limited API sync. _ensure_fresh_cache() rows = [] for station in _stations_list(): s_lat = station.get("lat") s_lng = station.get("lng") # All known fuel grades for this station (E5/E10/DIESEL) so clients can # switch fuel tabs without another request. Every station is returned # regardless of the requested fuel — clients filter on-device. prices = {} for grade in ("E5", "E10", "DIESEL"): p = _fuel_price(station, grade) if p is not None: prices[grade] = p if not prices: continue # station sells nothing we track — skip item = { "id": station.get("id"), "name": station.get("name"), "brand": station.get("brand") or "", "address": station.get("address") or "", "postcode": station.get("postcode") or "", "lat": s_lat, "lng": s_lng, "price": prices.get(fuel.upper()), "prices": prices, "price_updated": station.get("price_updated"), } 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: # Optional server-side radius filter — used by the alert path for a # focused fresh fetch. The main app fetch omits radius and gets all. if radius 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.get("distance_km") if r.get("distance_km") is not None else float("inf")) else: rows.sort(key=lambda r: r["price"] if r["price"] is not None else float("inf")) return {"fuel": fuel, "count": len(rows[:limit]), "stations": rows[:limit]} if __name__ == "__main__": import uvicorn port = int(os.environ.get("FUEL_RELAY_PORT", "8789")) uvicorn.run(app, host="0.0.0.0", port=port)