Files
fuelboard/Shared/FuelPriceProvider.swift
T
FuelBoard Contributor fccd0c07ef Settings About: data source section (connection, station count, GOV.UK update time)
- Relay envelope now carries source (api|csv), stations_count, and
  data_updated (freshest price_last_updated the GOV.UK server reports,
  independent of relay sync time).
- App decodes the new envelope metadata (RelayMeta), persists it after
  each full fetch, and shows it in Settings → About → Data source:
  Connection (API/CSV), Stations count, Data updated (local-formatted).
- Older relays without the fields decode cleanly (nil meta → em-dash).
- 2 new tests for meta decode + absent-meta tolerance; 37/37 pass.
2026-08-12 16:23:41 +01:00

283 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// FuelPriceProvider.swift — data-source seam for FuelBoard.
//
// The official UK Fuel Finder API (api.fuelfinder.service.gov.uk) is the live
// source for station-level prices, but it requires GOV.UK One Login + OAuth 2.0
// client credentials. For the scaffold we ship SampleFuelProvider (realistic
// stations around a location, works offline), plus the FuelFinderProvider
// skeleton with the exact integration steps documented inline so wiring the
// live API later is a drop-in: change `activeProvider` to `.fuelFinder`.
import Foundation
// MARK: - Provider protocol
protocol FuelPriceProviding {
/// Fetch stations with prices. `location` may be nil (sort by price only).
/// `radiusKM` nil = full dataset (device filters); set = focused server-side
/// radius (alert path). Throws on failure so callers can fall back.
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation]
}
enum FuelPriceProvider {
/// Default: the keyless relay (full-UK Fuel Finder data). Falls back to
/// the England-wide sample set when the relay is unreachable.
static let active: FuelPriceProviding = RelayFuelProvider()
/// Decodes a relay payload into stations, applying the defensive price
/// band. Internal so the unit-test target can exercise the guard.
static func decodeStations(from data: Data) throws -> [FuelStation] {
let payload = try JSONDecoder().decode(RelayResponse.self, from: data)
return payload.stations.map { relay in
FuelStation(
id: relay.id ?? relay.name ?? UUID().uuidString,
name: (relay.name ?? "Unknown").sanitizedStationTitle,
brand: relay.brand ?? "",
address: relay.address ?? "",
postcode: relay.postcode ?? "",
lat: relay.lat ?? 0,
lng: relay.lng ?? 0,
prices: relay.allPrices,
priceUpdated: nil
)
}
}
/// Decodes the relay envelope metadata (source, dataset update time) — the
/// About section shows these so the user can see which data source is
/// live and how fresh the GOV.UK data itself is. The fields are additive
/// on the relay, so older relays simply return nil values.
static func decodeRelayMeta(from data: Data) -> RelayMeta? {
guard let payload = try? JSONDecoder().decode(RelayResponse.self, from: data) else {
return nil
}
return RelayMeta(
source: payload.source,
stationCount: payload.stationsCount,
dataUpdated: payload.dataUpdated
)
}
}
// MARK: - Relay provider (default)
/// Talks to the FuelBoard Relay (keyless proxy). The relay serves the FULL-UK
/// Fuel Finder dataset — every station in England, Wales, Scotland, NI — and
/// filters/sorts server-side. No credentials in the app.
struct RelayFuelProvider: FuelPriceProviding {
var baseURL = URL(string: "http://192.168.1.131:8789")!
/// Envelope metadata from the most recent successful relay fetch —
/// consumed by Settings → About (source api/csv, station count, GOV.UK
/// dataset update time). Written by fetchStations, read by the view.
static var latestMeta: RelayMeta?
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
var components = URLComponents(url: baseURL.appendingPathComponent("api/v1/stations"), resolvingAgainstBaseURL: false)!
var query: [URLQueryItem] = [URLQueryItem(name: "fuel", value: fuel.rawValue)]
if let lat, let lng {
query.append(URLQueryItem(name: "lat", value: String(lat)))
query.append(URLQueryItem(name: "lng", value: String(lng)))
}
if let radiusKM {
// Focused fetch (alert path) — small response, server-side radius.
query.append(URLQueryItem(name: "radius", value: String(radiusKM)))
query.append(URLQueryItem(name: "limit", value: "500"))
} else {
// Full-UK dump: NO radius — the device filters locally
// (5/10/15-mile radius, fuel, sort). 10,000 covers the dataset.
query.append(URLQueryItem(name: "limit", value: "10000"))
}
components.queryItems = query
let (data, response) = try await URLSession.shared.data(from: components.url!)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw FuelProviderError.relayUnavailable
}
let stations = try FuelPriceProvider.decodeStations(from: data)
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
return stations
}
}
private struct RelayResponse: Codable {
/// Envelope metadata surfaced in Settings → About so the user can see
/// which relay source is live and how fresh the GOV.UK data is.
let source: String?
let stationsCount: Int?
let dataUpdated: String?
enum CodingKeys: String, CodingKey {
case source, stations
case stationsCount = "stations_count"
case dataUpdated = "data_updated"
}
struct RelayStation: Codable {
let id: String?
let name: String?
let brand: String?
let address: String?
let postcode: String?
let lat: Double?
let lng: Double?
let price: Double?
let prices: [String: Double]?
/// Map relay grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the
/// station sells — one fetch populates every fuel tab. Defensive guard:
/// any grade outside the 50–500 pence/litre band is dropped, so a relay
/// regression (e.g. the band being removed server-side) can't re-poison
/// the nationwide cheapest reference with 1.3p / 1589p garbage.
var allPrices: [FuelType: Double] {
var result: [FuelType: Double] = [:]
if let prices {
for (grade, value) in prices {
guard (50...500).contains(value) else { continue }
switch grade.uppercased() {
case "E10": result[.e10] = value
case "E5": result[.e5] = value
case "DIESEL", "B7", "B7S", "B7P", "B10": result[.diesel] = value
default: break
}
}
}
// Backwards-compat: relay versions without `prices` still send `price`.
if result.isEmpty, let price, (50...500).contains(price) {
result[.e10] = price
}
return result
}
}
let stations: [RelayStation]
}
/// Envelope metadata from the relay response — which data source is live
/// (api/csv) and the freshest price-update timestamp the GOV.UK server
/// itself reports for the dataset. Displayed in Settings → About.
struct RelayMeta: Codable, Equatable {
let source: String?
let stationCount: Int?
let dataUpdated: String?
}
// MARK: - Sample provider (offline fallback only)
/// Representative stations spread across ENGLAND (one cluster per region) so
/// the fallback works anywhere in the country — it is NOT tied to one town.
/// Real England-wide data comes from the relay (full-UK Fuel Finder CSV/API).
/// Prices in pence/litre.
struct SampleFuelProvider: FuelPriceProviding {
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
try await Task.sleep(nanoseconds: 300_000_000) // simulate fetch
return Self.sampleStations
}
static let sampleStations: [FuelStation] = [
// Yorkshire & the Humber (Halifax area)
FuelStation(id: "y1", name: "Morrisons Halifax", brand: "Morrisons",
address: "Haugh Shaw Road", postcode: "HX1 3TU",
lat: 53.7265, lng: -1.8580,
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9],
priceUpdated: nil),
FuelStation(id: "y2", name: "Asda Leeds Crown Point", brand: "Asda",
address: "Crown Point Road", postcode: "LS10 1ET",
lat: 53.7800, lng: -1.5300,
prices: [.e10: 136.9, .e5: 143.9, .diesel: 143.9],
priceUpdated: nil),
FuelStation(id: "y3", name: "Tesco Express York", brand: "Tesco",
address: "Fulford Road", postcode: "YO10 4AB",
lat: 53.9500, lng: -1.0800,
prices: [.e10: 138.9, .e5: 146.9, .diesel: 145.9],
priceUpdated: nil),
// North West
FuelStation(id: "nw1", name: "Sainsbury's Manchester", brand: "Sainsbury's",
address: "Regent Road", postcode: "M5 3QH",
lat: 53.4700, lng: -2.2800,
prices: [.e10: 139.9, .e5: 147.9, .diesel: 146.9],
priceUpdated: nil),
FuelStation(id: "nw2", name: "Shell Liverpool", brand: "Shell",
address: "Rice Lane", postcode: "L9 1AD",
lat: 53.4400, lng: -2.9600,
prices: [.e10: 142.9, .e5: 149.9, .diesel: 148.9],
priceUpdated: nil),
// Midlands
FuelStation(id: "m1", name: "Morrisons Birmingham", brand: "Morrisons",
address: "Hagley Road", postcode: "B16 8NA",
lat: 52.4700, lng: -1.9500,
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9],
priceUpdated: nil),
FuelStation(id: "m2", name: "Tesco Express Nottingham", brand: "Tesco",
address: "Mansfield Road", postcode: "NG5 2DP",
lat: 52.9900, lng: -1.1500,
prices: [.e10: 138.9, .e5: 146.9, .diesel: 145.9],
priceUpdated: nil),
// South East / London
FuelStation(id: "se1", name: "Asda Wembley", brand: "Asda",
address: "Great Central Way", postcode: "HA9 0DB",
lat: 51.5500, lng: -0.2800,
prices: [.e10: 136.9, .e5: 143.9, .diesel: 143.9],
priceUpdated: nil),
FuelStation(id: "se2", name: "BP Brighton", brand: "BP",
address: "Lewes Road", postcode: "BN2 3QB",
lat: 50.8300, lng: -0.1100,
prices: [.e10: 140.9, .diesel: 147.9],
priceUpdated: nil),
// South West
FuelStation(id: "sw1", name: "Esso Bristol", brand: "Esso",
address: "Wells Road", postcode: "BS4 2PP",
lat: 51.4400, lng: -2.5700,
prices: [.e10: 141.9, .e5: 148.9, .diesel: 147.9],
priceUpdated: nil),
// North East
FuelStation(id: "ne1", name: "Gulf Newcastle", brand: "Gulf",
address: "Scotswood Road", postcode: "NE4 7AA",
lat: 54.9700, lng: -1.6400,
prices: [.e10: 143.9, .e5: 151.9, .diesel: 149.9],
priceUpdated: nil),
]
}
// MARK: - Fuel Finder API provider (integration point)
/// Live provider skeleton for the official GOV.UK Fuel Finder API.
///
/// To activate:
/// 1. Sign in at developer.fuel-finder.service.gov.uk with GOV.UK One Login,
/// register an app, and generate client credentials (client_id + secret).
/// 2. Implement token fetch: POST to the token endpoint with OAuth 2.0
/// client_credentials grant → access_token (expires in 1h).
/// 3. GET prices with `Authorization: Bearer <token>` and decode the
/// stations JSON into [FuelStation].
///
/// Endpoints (per gov.uk docs):
/// base: https://api.fuelfinder.service.gov.uk
/// auth: base + /v1/token
/// prices: base + /v1/prices (full snapshot)
/// price: base + /v1/prices/GB-12345 (single station)
///
/// Then switch `FuelPriceProvider.active` to `FuelFinderProvider(...)`.
struct FuelFinderProvider: FuelPriceProviding {
let baseURL = URL(string: "https://api.fuelfinder.service.gov.uk")!
let clientID: String
let clientSecret: String
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
// TODO: OAuth token → GET /v1/prices → map to FuelStation.
// The live API requires authentication; see notes above.
throw FuelProviderError.notImplemented
}
}
enum FuelProviderError: LocalizedError {
case notImplemented
case relayUnavailable
var errorDescription: String? {
switch self {
case .notImplemented:
return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials."
case .relayUnavailable:
return "FuelBoard Relay unreachable — showing cached/sample data."
}
}
}