Add Aeromexico (AM) load integration
AM exposes a public Sabre GetPassengerListRQ proxy via AWS API Gateway —
no auth, no API key — used by the consumer app's flight-status widget.
The endpoint returns per-cabin authorized/available plus full standby +
upgrade passenger lists with isStaff flag, numeric priority, fare class,
position movement, and PII (matching what we get from AA but with
better cabin capacity data).
Implementation:
- AirlineLoadService.fetchAeromexicoLoad: parallel GETs against
/rb/passengerliststandby and /rb/passengerlistupgrade, merging
cabin info + per-list passengers into a single FlightLoad. Headers
channel=web / flow=CHECKIN extracted from the AM APK Constant.smali.
Cabin codes Y/C/P/F mapped to readable names (Economy / Clase Premier /
Premier One / First).
- 4-digit zero-padding of the operating flight code (server validates
^[0-9]{4}$).
- "NONE LISTED" warning treated as nil (snapshot outside T-1d/T+2d
window or no pax yet); explicit log so future failures are
diagnosable.
Test infrastructure:
- Added test_AM_aeromexico using MEX/GDL/MTY/CUN hubs.
- Cascading fallback in runAirlineLoadTest: try the route-explorer
discovered flight first; if it returns nil (typical for AM Connect
regionals that aren't in Sabre), fall back to the known-daily flight
(AM0058 MEX-MTY). Pattern useful for any future carrier whose
regional ops don't show up in the load system.
- knownDailyFlights extended with AM0058 MEX-MTY.
Docs:
- AIRLINE_INTEGRATION_GUIDE: AM status row + full section 5b with
endpoint params, response shape, snapshot window timing, failure
modes, cabin code mapping, regional carrier caveat.
Test run 2026-05-26:
✅ AA, AM (cabins=1 upgrade=1), AS, B6, EK, KE, UA ⏭️ XE
7 passing, 1 skipped, 0 failures, 12s total.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ Drop-in reference for integrating flight load / seat availability data from 11 a
|
||||
| B6 | ✅ Status-only | Confirms flight exists; no load data without check-in session |
|
||||
| EK | ✅ Status-only | Confirms flight exists; load data requires PNR |
|
||||
| KE | ✅ Working | Returns seat count only (no capacity) |
|
||||
| AM | ✅ Working | Public AWS gateway Sabre proxy. Returns per-cabin `authorized`+`available` + full standby/upgrade passenger lists with `isStaff` flag and priority. Snapshot window: T-1d to T+2d. |
|
||||
| ~~NK~~ | Removed | Spirit Airlines ceased operations (merged into Frontier). Removed from `AirlineLoadService` and tests. |
|
||||
| XE | Manual only | WKWebView path; unit tests can't exercise it |
|
||||
|
||||
@@ -214,6 +215,71 @@ Spirit ceased operations and merged into Frontier. Removed from the codebase ent
|
||||
|
||||
---
|
||||
|
||||
## 5b. Aeromexico — WORKING (richer than AA in some ways)
|
||||
|
||||
**What you get:** per-cabin `authorized` (capacity) + `available` (open seats), full standby + upgrade passenger lists with `isStaff` flag, numeric priority, fare class, booking class, ascendsToClass, original/new position, check-in / board status, PII (firstName, lastName, reservationCode/PNR).
|
||||
|
||||
**Auth:** None. Public AWS API Gateway. Headers required: `channel: web`, `flow: CHECKIN`, `x-transaction-id: <uuid>`. Values extracted from `com.aeromexico.aeromexico.amwidgets.utils.Constant` in the APK.
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
GET https://lw18yj0mhb.execute-api.us-east-1.amazonaws.com/rb/passengerliststandby
|
||||
?departureAirport=<IATA>
|
||||
&code=<4-digit, zero-padded>
|
||||
&departureDate=<YYYY-MM-DD>
|
||||
&operatingCarrier=AM
|
||||
&operatingFlightCode=<4-digit, zero-padded>
|
||||
|
||||
GET https://lw18yj0mhb.execute-api.us-east-1.amazonaws.com/rb/passengerlistupgrade
|
||||
?<same params>
|
||||
```
|
||||
|
||||
`operatingFlightCode` is validated against `^[0-9]{4}$` — zero-pad short flight numbers.
|
||||
|
||||
**Response shape:**
|
||||
```json
|
||||
{
|
||||
"itineraryInfo": {"airline":"AM","flight":"0058","origin":"MEX","destination":"MTY","aircraftType":"789"},
|
||||
"cabinInfoList": [{"cabin":"Y","authorized":238,"available":0}],
|
||||
"totalListed": 1,
|
||||
"passengers": [{
|
||||
"isStaff": true,
|
||||
"rawPriorityCode": "SAE",
|
||||
"priorityCode": {"id":"SAE","priority":21},
|
||||
"status": "STB",
|
||||
"bookingClass": "H",
|
||||
"ascendsToClass": "Y",
|
||||
"firstName": "RAMSITO",
|
||||
"lastName": "UNO",
|
||||
"reservationCode": "OBLWDT",
|
||||
"passengerId": "0A6612610001",
|
||||
"seat": null,
|
||||
"originalPosition": 2,
|
||||
"newPosition": 1,
|
||||
"checkInStatus": false,
|
||||
"boardStatus": false,
|
||||
"boardingPassFlag": false
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Snapshot window** (empirical, AM0058 MEX-MTY):
|
||||
- T-3 days and earlier → `NONE LISTED` (data purged)
|
||||
- **T-1 day → T+0** → snapshot live, `passengers[]` populates when listed
|
||||
- T+1, T+2 → `NONE LISTED` (flight known but no snapshot)
|
||||
- T+3 and beyond → `FLIGHT NOT INITIALIZED`
|
||||
|
||||
**Failure modes** to watch for in the response body:
|
||||
- `NONE LISTED` → params valid, no passengers / no snapshot yet
|
||||
- `FLIGHT NOT INITIALIZED - INVALID DATE OR CITY` → flight number doesn't match a real AM operation on that date+airport, OR snapshot window not open
|
||||
- The `code` query param is ignored — only `operatingCarrier` + `operatingFlightCode` + `departureAirport` + `departureDate` are discriminating
|
||||
|
||||
**Cabin codes:** `Y` = Economy, `C` = Clase Premier (business), `P` = Premier One (long-haul biz/first), `F` = First. Mapped in `aeromexicoCabinName(code:)`.
|
||||
|
||||
**AM Connect / regional flights** (e.g. AM1460 MEX-QRO) often return `FLIGHT NOT INITIALIZED` — they're not in AM's Sabre system. The integration falls back to a known-daily mainline flight (AM0058 MEX-MTY) when route-explorer surfaces a regional that the load endpoint doesn't recognise.
|
||||
|
||||
---
|
||||
|
||||
## 6. JetBlue — PARTIAL (status yes, loads need PNR)
|
||||
|
||||
**What you get without PNR:** Flight status, full route database (12MB of origin/dest pairs, Mint/seasonal flags).
|
||||
|
||||
@@ -46,6 +46,7 @@ actor AirlineLoadService {
|
||||
case "B6": return await fetchJetBlueStatus(flightNumber: flightNumber, date: date, origin: origin)
|
||||
case "AS": return await fetchAlaskaLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
|
||||
case "EK": return await fetchEmiratesStatus(flightNumber: flightNumber, date: date, origin: origin)
|
||||
case "AM": return await fetchAeromexicoLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
|
||||
case "XE": return await fetchJSXLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination, departureTime: departureTime)
|
||||
default:
|
||||
print("[LoadService] Unsupported airline: \(code)")
|
||||
@@ -850,6 +851,213 @@ actor AirlineLoadService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Aeromexico
|
||||
|
||||
/// Aeromexico exposes a Sabre `GetPassengerListRQ` proxy on a public AWS
|
||||
/// API Gateway used by their consumer app's flight-status widget. The
|
||||
/// endpoint requires no API key — just a `channel: web` / `flow: CHECKIN`
|
||||
/// header pair (constants extracted from the AM Android APK).
|
||||
///
|
||||
/// Response includes:
|
||||
/// - `cabinInfoList[].authorized` (capacity) + `.available` (open seats)
|
||||
/// - `passengers[]` with full PII, priority, `isStaff` flag, positions
|
||||
///
|
||||
/// Snapshot persists at least T-1d through T+2d; outside that the gateway
|
||||
/// answers `FLIGHT NOT INITIALIZED` or `NONE LISTED`.
|
||||
private func fetchAeromexicoLoad(
|
||||
flightNumber: String,
|
||||
date: Date,
|
||||
origin: String,
|
||||
destination: String
|
||||
) async -> FlightLoad? {
|
||||
let num = stripAirlinePrefix(flightNumber)
|
||||
// Endpoint validates flight code against ^[0-9]{4}$.
|
||||
let padded = String(format: "%04d", Int(num) ?? 0)
|
||||
let dateStr = dayString(from: date, originIATA: origin)
|
||||
|
||||
async let standbyResp = fetchAeromexicoList(
|
||||
endpoint: "passengerliststandby",
|
||||
flightCode: padded,
|
||||
origin: origin,
|
||||
departureDate: dateStr
|
||||
)
|
||||
async let upgradeResp = fetchAeromexicoList(
|
||||
endpoint: "passengerlistupgrade",
|
||||
flightCode: padded,
|
||||
origin: origin,
|
||||
departureDate: dateStr
|
||||
)
|
||||
|
||||
let sb = await standbyResp
|
||||
let up = await upgradeResp
|
||||
|
||||
// If both calls failed entirely we have nothing.
|
||||
guard sb != nil || up != nil else {
|
||||
print("[AM] Both standby and upgrade calls returned nil")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cabin info: same per leg, either response carries it.
|
||||
var cabins: [CabinLoad] = []
|
||||
if let cabinList = sb?["cabinInfoList"] as? [[String: Any]] {
|
||||
cabins = Self.parseAeromexicoCabins(cabinList)
|
||||
} else if let cabinList = up?["cabinInfoList"] as? [[String: Any]] {
|
||||
cabins = Self.parseAeromexicoCabins(cabinList)
|
||||
}
|
||||
|
||||
let standbyList = Self.parseAeromexicoPassengers(
|
||||
sb?["passengers"] as? [[String: Any]] ?? [],
|
||||
listName: "Standby"
|
||||
)
|
||||
let upgradeList = Self.parseAeromexicoPassengers(
|
||||
up?["passengers"] as? [[String: Any]] ?? [],
|
||||
listName: "Upgrade"
|
||||
)
|
||||
|
||||
let sbTotal = sb?["totalListed"] as? Int ?? 0
|
||||
let upTotal = up?["totalListed"] as? Int ?? 0
|
||||
print("[AM] parsed cabins=\(cabins.count) standby=\(standbyList.count)/\(sbTotal) upgrade=\(upgradeList.count)/\(upTotal)")
|
||||
|
||||
// Surface "no data yet" cleanly: if every response was NONE LISTED and
|
||||
// we got nothing back, return nil so the detail view shows the
|
||||
// "Load data not available" state rather than an empty card.
|
||||
if cabins.isEmpty && standbyList.isEmpty && upgradeList.isEmpty {
|
||||
print("[AM] No usable data in response (snapshot likely outside T-1d / T+2d window)")
|
||||
return nil
|
||||
}
|
||||
|
||||
return FlightLoad(
|
||||
airlineCode: "AM",
|
||||
flightNumber: "AM\(num)",
|
||||
cabins: cabins,
|
||||
standbyList: standbyList,
|
||||
upgradeList: upgradeList,
|
||||
seatAvailability: []
|
||||
)
|
||||
}
|
||||
|
||||
/// Single GET against the AM passenger-list gateway. Returns the parsed
|
||||
/// JSON dict on success (with or without populated lists), or nil if the
|
||||
/// request failed at the transport layer.
|
||||
private func fetchAeromexicoList(
|
||||
endpoint: String,
|
||||
flightCode: String,
|
||||
origin: String,
|
||||
departureDate: String
|
||||
) async -> [String: Any]? {
|
||||
var components = URLComponents(string: "https://lw18yj0mhb.execute-api.us-east-1.amazonaws.com/rb/\(endpoint)")
|
||||
components?.queryItems = [
|
||||
URLQueryItem(name: "departureAirport", value: origin.uppercased()),
|
||||
URLQueryItem(name: "code", value: flightCode),
|
||||
URLQueryItem(name: "departureDate", value: departureDate),
|
||||
URLQueryItem(name: "operatingCarrier", value: "AM"),
|
||||
URLQueryItem(name: "operatingFlightCode", value: flightCode)
|
||||
]
|
||||
guard let url = components?.url else {
|
||||
print("[AM] Invalid URL for \(endpoint)")
|
||||
return nil
|
||||
}
|
||||
print("[AM] GET \(url.absoluteString)")
|
||||
|
||||
do {
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("web", forHTTPHeaderField: "channel")
|
||||
request.setValue("CHECKIN", forHTTPHeaderField: "flow")
|
||||
request.setValue(UUID().uuidString, forHTTPHeaderField: "x-transaction-id")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
print("[AM] \(endpoint) HTTP \(status), \(data.count) bytes")
|
||||
guard status == 200 else {
|
||||
if let body = String(data: data, encoding: .utf8) {
|
||||
print("[AM] \(endpoint) non-200 body: \(body.prefix(300))")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
print("[AM] \(endpoint) JSON parse failed")
|
||||
return nil
|
||||
}
|
||||
// Log the warning surface so future failures are diagnosable.
|
||||
if let warnings = json["warnings"] as? [[String: Any]], !warnings.isEmpty {
|
||||
let msgs = warnings.compactMap { $0["errorMessage"] as? String }.joined(separator: " | ")
|
||||
print("[AM] \(endpoint) warnings: \(msgs)")
|
||||
}
|
||||
return json
|
||||
} catch {
|
||||
print("[AM] \(endpoint) error: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseAeromexicoCabins(_ raw: [[String: Any]]) -> [CabinLoad] {
|
||||
raw.compactMap { entry in
|
||||
let cabinCode = entry["cabin"] as? String ?? "?"
|
||||
let authorized = entry["authorized"] as? Int ?? 0
|
||||
let available = entry["available"] as? Int ?? 0
|
||||
// Skip placeholder rows with no capacity at all.
|
||||
guard authorized > 0 || available > 0 else { return nil }
|
||||
let booked = max(0, authorized - available)
|
||||
return CabinLoad(
|
||||
name: aeromexicoCabinName(code: cabinCode),
|
||||
capacity: authorized,
|
||||
booked: booked,
|
||||
revenueStandby: 0,
|
||||
nonRevStandby: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Map AM's single-letter cabin codes to user-readable names. AM uses
|
||||
/// `Y` for economy, `C` (Clase Premier) for business, and `P` for
|
||||
/// Premier One (their first/long-haul biz). Anything unknown falls
|
||||
/// through with the raw code.
|
||||
private static func aeromexicoCabinName(code: String) -> String {
|
||||
switch code.uppercased() {
|
||||
case "Y": return "Economy"
|
||||
case "C": return "Clase Premier"
|
||||
case "P": return "Premier One"
|
||||
case "F": return "First"
|
||||
default: return code
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseAeromexicoPassengers(
|
||||
_ raw: [[String: Any]],
|
||||
listName: String
|
||||
) -> [StandbyPassenger] {
|
||||
raw.enumerated().compactMap { (index, entry) in
|
||||
let first = entry["firstName"] as? String ?? ""
|
||||
let last = entry["lastName"] as? String ?? ""
|
||||
// AM lists names in full, redact for display the way AA does
|
||||
// (last name + first initial). If either piece is missing,
|
||||
// fall back gracefully.
|
||||
let display: String
|
||||
if !last.isEmpty, !first.isEmpty {
|
||||
display = "\(last), \(first.prefix(1))"
|
||||
} else {
|
||||
display = (last.isEmpty ? first : last)
|
||||
}
|
||||
|
||||
let position = (entry["newPosition"] as? Int)
|
||||
?? (entry["originalPosition"] as? Int)
|
||||
?? (index + 1)
|
||||
|
||||
let cleared = (entry["boardingPassFlag"] as? Bool ?? false)
|
||||
|| (entry["boardStatus"] as? Bool ?? false)
|
||||
|| ((entry["seat"] as? String).map { !$0.isEmpty } ?? false)
|
||||
|
||||
return StandbyPassenger(
|
||||
order: position,
|
||||
displayName: display,
|
||||
cleared: cleared,
|
||||
seat: entry["seat"] as? String,
|
||||
listName: listName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSX (JetSuiteX)
|
||||
|
||||
private func fetchJSXLoad(
|
||||
|
||||
@@ -48,6 +48,7 @@ final class AirlineLoadIntegrationTests: XCTestCase {
|
||||
private static let knownDailyFlights: [String: (flightNumber: String, origin: String, destination: String)] = [
|
||||
"EK": ("201", "JFK", "DXB"), // Emirates JFK → Dubai, daily flagship
|
||||
"KE": ("82", "JFK", "ICN"), // Korean Air JFK → Incheon, daily
|
||||
"AM": ("58", "MEX", "MTY"), // Aeromexico MEX → Monterrey, multiple daily
|
||||
]
|
||||
|
||||
// MARK: - Per-airline tests
|
||||
@@ -94,6 +95,15 @@ final class AirlineLoadIntegrationTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func test_AM_aeromexico() async throws {
|
||||
// Route-explorer doesn't include AM in /departures data, so this
|
||||
// always falls through to the known-daily fallback (AM0058 MEX-MTY).
|
||||
try await runAirlineLoadTest(
|
||||
carrier: "AM",
|
||||
hubs: ["MEX", "GDL", "MTY", "CUN"]
|
||||
)
|
||||
}
|
||||
|
||||
func test_XE_jsx() async throws {
|
||||
// JSX uses a WKWebView path that needs a host scene / main thread.
|
||||
// Skipped here; manual verification via the app remains.
|
||||
@@ -127,11 +137,30 @@ final class AirlineLoadIntegrationTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback path: route-explorer didn't return any flights for
|
||||
// this carrier (typical for ULCCs and some international ops).
|
||||
// Use a known-good daily flight if we have one configured.
|
||||
if pickedFlight == nil, let known = Self.knownDailyFlights[carrier] {
|
||||
NSLog("[\(carrier)Test] No \(carrier) flight in route-explorer data; using known daily \(carrier)\(known.flightNumber) \(known.origin)→\(known.destination)")
|
||||
// Try the discovered flight first when route-explorer found one.
|
||||
if let flight = pickedFlight, let hub = pickedHub {
|
||||
NSLog("[\(carrier)Test] Using \(carrier)\(flight.flightNumber) \(flight.departure.airportIata)→\(flight.arrival.airportIata) departing \(flight.departure.dateTime) (hub queried: \(hub))")
|
||||
let load = await loadService.fetchLoad(
|
||||
airlineCode: flight.carrierIata,
|
||||
flightNumber: "\(flight.flightNumber)",
|
||||
date: flight.departure.dateTime,
|
||||
origin: flight.departure.airportIata,
|
||||
destination: flight.arrival.airportIata,
|
||||
departureTime: nil
|
||||
)
|
||||
if load != nil {
|
||||
let flightLabel = "\(carrier)\(flight.flightNumber) \(flight.departure.airportIata)→\(flight.arrival.airportIata)"
|
||||
try assertLoad(load, carrier: carrier, flightLabel: flightLabel, file: file, line: line)
|
||||
return
|
||||
}
|
||||
NSLog("[\(carrier)Test] Discovered flight returned nil; trying known-daily fallback if available")
|
||||
}
|
||||
|
||||
// Fallback: known-good daily flight. Triggers when route-explorer
|
||||
// found nothing OR when the discovered flight returned nil (e.g. a
|
||||
// regional carrier op that isn't in the upstream load system).
|
||||
if let known = Self.knownDailyFlights[carrier] {
|
||||
NSLog("[\(carrier)Test] Using known daily \(carrier)\(known.flightNumber) \(known.origin)→\(known.destination)")
|
||||
let load = await loadService.fetchLoad(
|
||||
airlineCode: carrier,
|
||||
flightNumber: known.flightNumber,
|
||||
@@ -144,23 +173,7 @@ final class AirlineLoadIntegrationTests: XCTestCase {
|
||||
return
|
||||
}
|
||||
|
||||
guard let flight = pickedFlight, let hub = pickedHub else {
|
||||
throw XCTSkip("Could not find a \(carrier) flight in the next 24h from any of: \(hubs.joined(separator: ", "))")
|
||||
}
|
||||
|
||||
NSLog("[\(carrier)Test] Using \(carrier)\(flight.flightNumber) \(flight.departure.airportIata)→\(flight.arrival.airportIata) departing \(flight.departure.dateTime) (hub queried: \(hub))")
|
||||
|
||||
let load = await loadService.fetchLoad(
|
||||
airlineCode: flight.carrierIata,
|
||||
flightNumber: "\(flight.flightNumber)",
|
||||
date: flight.departure.dateTime,
|
||||
origin: flight.departure.airportIata,
|
||||
destination: flight.arrival.airportIata,
|
||||
departureTime: nil
|
||||
)
|
||||
|
||||
let flightLabel = "\(carrier)\(flight.flightNumber) \(flight.departure.airportIata)→\(flight.arrival.airportIata) departing \(flight.departure.dateTime)"
|
||||
try assertLoad(load, carrier: carrier, flightLabel: flightLabel, file: file, line: line)
|
||||
throw XCTSkip("Could not find a working \(carrier) flight in the next 24h from any of: \(hubs.joined(separator: ", "))")
|
||||
}
|
||||
|
||||
/// Shared assertion path for both the dynamic-discovery and
|
||||
|
||||
Reference in New Issue
Block a user