4d026ef530
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>
228 lines
8.7 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|