Files
Flights/Flights/Services/RouteExplorerClient.swift
T
Trey T d122c95342 RouteExplorer: real Safari UA + surface real upstream status
Two follow-ups to the WebView-routing commit after user reported
"Could not get any token HTTP -1":

1. WKWebView's default UA is "Mozilla/5.0 (iPhone; ...) Mobile/15E148"
   — missing the "Version/17.5 Safari/604.1" suffix real Safari sends.
   Cloudflare and other bot filters use that suffix to ID true Safari.
   Now we explicitly set a complete Safari UA on the WebView before
   navigating to route-explorer.

2. WebViewFetcher returns its errors as "HTTP <code>: <body>" strings;
   we were always throwing tokenFetchFailed(status: -1) regardless.
   New extractStatus helper parses the real upstream HTTP code out
   of the error string so the thrown error reflects what the server
   actually said — "HTTP 403" instead of "HTTP -1" makes it
   diagnosable from the device.

If the deployed app still 403s after this, the issue is more than UA
(probably Cloudflare clearance cookie needed via interactive challenge)
and we'd have to consider ASWebAuthenticationSession or fall back to
a paid schedule API.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 13:32:05 -05:00

325 lines
13 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 = .departureEarliest,
limit: Int = 100
) async throws -> RouteSearchResult {
let dateStr = dateFormatter.string(from: date)
// All sorts apply client-side. Upstream is told to use
// `departure_time` so the result order is stable; RoutePlannerView
// reorders after the fetch returns.
let serverSort = sortBy.apiValue ?? "departure_time"
let payload: [String: Any] = [
"departureAirportIata": origin.uppercased(),
"arrivalAirportIata": destination.uppercased(),
"departureDates": [dateStr],
"maxStops": maxStops,
"sortBy": serverSort,
"includeInterline": includeInterline,
"limit": limit,
"includeAppendix": true
]
return try await callFlightSearch(endpoint: "/route", json: payload)
}
/// Schedule lookup for a specific flight number across a date range.
/// Powers the live-flight detail sheet given an ICAO callsign like
/// `AAL1234` we resolve it to `(carrier: "AA", flightNumber: 1234)` and
/// pull the operating record, which carries real departure + arrival
/// airports and times.
///
/// Returns `nil` if the carrier/flight isn't in route-explorer's
/// schedule feed (typical for regional codeshares, charter ops, and
/// carriers the upstream platform doesn't index).
func searchSchedule(
carrierCode: String,
flightNumber: Int,
startDate: Date,
endDate: Date? = nil
) async -> [RouteFlight] {
let startStr = dateFormatter.string(from: startDate)
let endStr = dateFormatter.string(from: endDate ?? startDate)
let payload: [String: Any] = [
"carrierCode": carrierCode.uppercased(),
"flightNumber": flightNumber,
"startDate": startStr,
"endDate": endStr,
"limit": 20,
"includeAppendix": true
]
do {
let token = try await currentToken()
let body = try JSONSerialization.data(withJSONObject: [
"endpoint": "/schedule",
"body": ["json": payload]
])
let respStr = try await fetchViaWebView(
method: "POST",
apiPath: "/api/flight-search",
extraHeaders: ["X-API-Token": token],
requestBody: body
)
guard let data = respStr.data(using: .utf8) else { return [] }
let decoded = try JSONDecoder.routeExplorer().decode(
RouteExplorerScheduleResponse.self, from: data
)
return decoded.json.flights
} catch {
return []
}
}
/// 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
}
// route-explorer's edge now rejects URLSession-shaped requests
// (returns 403 "clearance"). A WKWebView running inside the
// route-explorer.com origin passes the gate, presumably because
// the TLS fingerprint + same-origin cookies match what their
// bot rules expect. We route both /api/token and
// /api/flight-search through that path.
let bodyStr = try await fetchViaWebView(
method: "GET",
apiPath: "/api/token",
extraHeaders: [:],
requestBody: nil
)
struct TokenResponse: Decodable { let token: String }
guard let data = bodyStr.data(using: .utf8) else {
throw ClientError.tokenFetchFailed(status: -1)
}
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)
}
}
/// Real iPhone Safari UA WKWebView's default ("Mobile/15E148"
/// only) is missing the `Version/x.x Safari/604.1` suffix that
/// Cloudflare uses to identify true Safari. Setting this on the
/// WebView gets us past the simplest UA-based filters.
private static let safariUA: String =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) " +
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 " +
"Mobile/15E148 Safari/604.1"
/// Runs an XHR from inside a WKWebView that's been navigated to
/// `https://route-explorer.com/`. The page context provides the
/// real Safari TLS fingerprint and any first-party cookies the
/// edge expects. Returns the response body as a string, or
/// throws with the real upstream status code on failure.
private func fetchViaWebView(
method: String,
apiPath: String,
extraHeaders: [String: String],
requestBody: Data?
) async throws -> String {
let fetcher = await WebViewFetcher()
var headers: [String: String] = [
"Accept": "application/json",
"Content-Type": "application/json"
]
for (k, v) in extraHeaders { headers[k] = v }
// For POST, body is interpolated verbatim into a JS literal.
// The body we send is already a JSON-encoded byte string, so
// wrapping in JSON.stringify(...) re-emits the same string.
var bodyJS: String?
if let requestBody {
let raw = String(data: requestBody, encoding: .utf8) ?? "null"
bodyJS = "JSON.stringify(\(raw))"
}
let result = await fetcher.fetch(
navigateTo: "https://route-explorer.com/",
fetchURL: "https://route-explorer.com\(apiPath)",
method: method,
headers: headers,
body: bodyJS,
userAgent: Self.safariUA,
includeCredentials: true
)
if let err = result.error {
// WebViewFetcher returns errors in the form "HTTP <code>: <body>"
// or a free-form description. Extract the code if we can so
// the thrown error carries the real upstream status.
let upstreamStatus = Self.extractStatus(from: err) ?? -1
print("[RouteExplorer] WebView \(method) \(apiPath) failed: \(err)")
throw ClientError.tokenFetchFailed(status: upstreamStatus)
}
guard let data = result.data else {
throw ClientError.tokenFetchFailed(status: -1)
}
return data
}
/// Pull the integer HTTP status code from WebViewFetcher's
/// "HTTP <code>: ..." formatted error string. Returns nil for
/// anything we can't parse.
private static func extractStatus(from err: String) -> Int? {
guard let range = err.range(of: #"HTTP (\d+)"#, options: .regularExpression),
let codeRange = err[range].range(of: #"\d+"#, options: .regularExpression)
else { return nil }
return Int(err[range][codeRange])
}
/// 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 outerBody: [String: Any] = [
"endpoint": endpoint,
"body": ["json": json]
]
let bodyData = try JSONSerialization.data(withJSONObject: outerBody)
do {
let respStr = try await fetchViaWebView(
method: "POST",
apiPath: "/api/flight-search",
extraHeaders: ["X-API-Token": token],
requestBody: bodyData
)
guard let data = respStr.data(using: .utf8) else {
throw ClientError.requestFailed(status: -1, body: nil)
}
return try decode(data: data)
} catch let err as ClientError {
// Token may have rotated server-side. Drop cache and retry once.
if case .tokenFetchFailed = err {
cachedToken = nil
let token2 = try await currentToken()
let respStr = try await fetchViaWebView(
method: "POST",
apiPath: "/api/flight-search",
extraHeaders: ["X-API-Token": token2],
requestBody: bodyData
)
guard let data = respStr.data(using: .utf8) else {
throw ClientError.requestFailed(status: -1, body: nil)
}
return try decode(data: data)
}
throw err
}
}
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)
}
}
}