Files
fuelboard/Shared/FuelPriceProvider.swift
T

200 lines
8.9 KiB
Swift

// 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).
/// Throws on failure so callers can fall back to cached/sample data.
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType) 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()
}
// 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:8788")!
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType) 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)))
query.append(URLQueryItem(name: "radius", value: "50"))
}
query.append(URLQueryItem(name: "limit", value: "100"))
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 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",
brand: relay.brand ?? "",
address: relay.address ?? "",
postcode: relay.postcode ?? "",
lat: relay.lat ?? 0,
lng: relay.lng ?? 0,
prices: [fuel: relay.price],
priceUpdated: nil
)
}
}
}
private struct RelayResponse: Codable {
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 stations: [RelayStation]
}
// 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) 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) 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."
}
}
}