Files
Flights/Flights/Services/RouteExplorerClient.swift
T
Trey T 4d026ef530 RouteExplorerClient: send browser-shaped headers (fix 403 on /api/token)
The route-explorer.com proxy gates /api/token and /api/flight-search by
Origin/Referer in production. Without those headers iOS got 403. Mirror
the existing United pattern (applyUnitedBrowserHeaders): set User-Agent,
Accept-Language, Referer, Origin on every request. Also log the token
response body on non-200 so future failures are diagnosable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:26:41 -05:00

228 lines
8.7 KiB
Swift

import Foundation
/// Talks to the public route-explorer.com API surface (StaffTraveler's web
/// proxy). No login required `/api/token` issues an anonymous HMAC token,
/// IP-rate-limited at 10/min, that we attach as `X-API-Token` to subsequent
/// `/api/flight-search` calls.
///
/// Endpoint allowlist exposed by the proxy: `/route`, `/route-batch`,
/// `/flight`, `/departures`, `/schedule`. We use `/route` (with `maxStops`)
/// for connection finding and `/departures` for the "where can I go" view.
actor RouteExplorerClient {
// MARK: - Errors
enum ClientError: Error, LocalizedError {
case tokenFetchFailed(status: Int)
case requestFailed(status: Int, body: String?)
case decodingFailed(underlying: Error)
var errorDescription: String? {
switch self {
case .tokenFetchFailed(let status):
return "Could not get an API token (HTTP \(status))."
case .requestFailed(let status, let body):
return "Request failed (HTTP \(status)). \(body ?? "")"
case .decodingFailed(let error):
return "Could not parse response: \(error.localizedDescription)"
}
}
}
// MARK: - Properties
private let session: URLSession
private let baseURL = URL(string: "https://route-explorer.com")!
private let dateFormatter: DateFormatter
/// Cached token. Tokens have a server-side TTL we don't know exactly;
/// we refresh defensively after 30 minutes or on first use.
private var cachedToken: (value: String, expiresAt: Date)?
// MARK: - Init
init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 20
config.requestCachePolicy = .reloadIgnoringLocalCacheData
session = URLSession(configuration: config)
let f = DateFormatter()
f.calendar = Calendar(identifier: .gregorian)
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "UTC")
f.dateFormat = "yyyy-MM-dd"
dateFormatter = f
}
// MARK: - Public search API
/// Find direct + multi-stop itineraries between two airports on one date.
func searchRoutes(
from origin: String,
to destination: String,
date: Date,
maxStops: Int = 1,
includeInterline: Bool = false,
sortBy: RouteSortOption = .departureTime,
limit: Int = 100
) async throws -> RouteSearchResult {
let dateStr = dateFormatter.string(from: date)
let payload: [String: Any] = [
"departureAirportIata": origin.uppercased(),
"arrivalAirportIata": destination.uppercased(),
"departureDates": [dateStr],
"maxStops": maxStops,
"sortBy": sortBy.rawValue,
"includeInterline": includeInterline,
"limit": limit,
"includeAppendix": true
]
return try await callFlightSearch(endpoint: "/route", json: payload)
}
/// All departures from an airport on a date. We filter by time window
/// client-side because the upstream endpoint doesn't accept one.
func searchDepartures(
from origin: String,
date: Date,
maxStops: Int = 0,
limit: Int = 200
) async throws -> RouteSearchResult {
let dateStr = dateFormatter.string(from: date)
let payload: [String: Any] = [
"departureAirportIata": origin.uppercased(),
"departureDates": [dateStr],
"maxStops": maxStops,
"limit": limit,
"includeAppendix": true
]
return try await callFlightSearch(endpoint: "/departures", json: payload)
}
// MARK: - Token
private func currentToken() async throws -> String {
if let cached = cachedToken, cached.expiresAt > Date() {
return cached.value
}
let url = baseURL.appendingPathComponent("api/token")
var req = URLRequest(url: url)
req.httpMethod = "GET"
Self.applyBrowserHeaders(to: &req)
req.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
if status != 200 {
let bodyStr = String(data: data, encoding: .utf8) ?? "<no body>"
print("[RouteExplorer] /api/token failed status=\(status) body=\(bodyStr.prefix(300))")
throw ClientError.tokenFetchFailed(status: status)
}
struct TokenResponse: Decodable { let token: String }
do {
let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
cachedToken = (decoded.token, Date().addingTimeInterval(30 * 60))
return decoded.token
} catch {
throw ClientError.decodingFailed(underlying: error)
}
}
/// Browser-shaped headers `/api/token` and `/api/flight-search` are
/// gated by Origin/Referer in production. Without these the server
/// returns 403 even though the endpoints are otherwise anonymous.
private static func applyBrowserHeaders(to request: inout URLRequest) {
request.setValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) "
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 "
+ "Mobile/15E148 Safari/604.1",
forHTTPHeaderField: "User-Agent"
)
request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
request.setValue("https://route-explorer.com/", forHTTPHeaderField: "Referer")
request.setValue("https://route-explorer.com", forHTTPHeaderField: "Origin")
}
// MARK: - Flight search proxy
private func callFlightSearch(
endpoint: String,
json: [String: Any]
) async throws -> RouteSearchResult {
let token = try await currentToken()
let url = baseURL.appendingPathComponent("api/flight-search")
let outerBody: [String: Any] = [
"endpoint": endpoint,
"body": ["json": json]
]
let bodyData = try JSONSerialization.data(withJSONObject: outerBody)
var req = URLRequest(url: url)
req.httpMethod = "POST"
Self.applyBrowserHeaders(to: &req)
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.setValue(token, forHTTPHeaderField: "X-API-Token")
req.httpBody = bodyData
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
// 401 / 403 likely means the token rotated. Drop cache and retry once.
if status == 401 || status == 403 {
cachedToken = nil
return try await retryAfterTokenRotation(endpoint: endpoint, json: json)
}
guard (200...299).contains(status) else {
let bodyStr = String(data: data, encoding: .utf8)
throw ClientError.requestFailed(status: status, body: bodyStr)
}
return try decode(data: data)
}
private func retryAfterTokenRotation(
endpoint: String,
json: [String: Any]
) async throws -> RouteSearchResult {
let token = try await currentToken()
let url = baseURL.appendingPathComponent("api/flight-search")
let outerBody: [String: Any] = [
"endpoint": endpoint,
"body": ["json": json]
]
let bodyData = try JSONSerialization.data(withJSONObject: outerBody)
var req = URLRequest(url: url)
req.httpMethod = "POST"
Self.applyBrowserHeaders(to: &req)
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue(token, forHTTPHeaderField: "X-API-Token")
req.httpBody = bodyData
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
guard (200...299).contains(status) else {
throw ClientError.requestFailed(status: status, body: String(data: data, encoding: .utf8))
}
return try decode(data: data)
}
private func decode(data: Data) throws -> RouteSearchResult {
do {
let response = try JSONDecoder.routeExplorer().decode(RouteExplorerResponse.self, from: data)
return RouteSearchResult(
connections: response.json.connections,
appendix: response.json.appendix
)
} catch {
throw ClientError.decodingFailed(underlying: error)
}
}
}