sabre-client 0.3.0-next.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,16 +12,15 @@ TypeScript SDK for the Sabre APIs.
12
12
 
13
13
  ## Status
14
14
 
15
- `0.3.0` is **not released yet**. It will be released once both Tier 1 and Tier 2 of the
16
- Air Booking flow are done. Until then, `npm install sabre-client` installs `0.2.4`.
17
-
18
- Prereleases of the rewrite are published under the `next` tag:
15
+ `0.3.0` is the first release of the rewrite. It covers Tier 1 and Tier 2 of the Air Booking
16
+ flow over REST, and Tier 3 (SOAP sessions and queues).
19
17
 
20
18
  ```sh
21
- npm install sabre-client@next
19
+ npm install sabre-client
22
20
  ```
23
21
 
24
- The API may still change before `0.3.0`.
22
+ The package is still `0.x`: a minor version may include breaking changes, and the
23
+ [CHANGELOG](./CHANGELOG.md) marks them.
25
24
 
26
25
  Tier 1 covers the core Air Booking flow over REST:
27
26
 
@@ -33,7 +32,22 @@ Tier 1 covers the core Air Booking flow over REST:
33
32
  | Booking | Create Booking (Booking Management API v1, ATPCO by `flightDetails`) | `sabre.booking.create` |
34
33
  | Booking management | Get Booking and Cancel Booking (Booking Management API v1) | `sabre.booking.get`, `sabre.booking.cancel` |
35
34
 
36
- Planned: Get Seats and Get Ancillaries (Tier 2), and a `SabreLegacy` client for SOAP services.
35
+ Tier 2 adds seat selection:
36
+
37
+ | Step | Service | Method |
38
+ | - | - | - |
39
+ | Seat map | Get Seats v3 (`byReservationPayload` and `byPnrLocator`) | `sabre.air.getSeats` |
40
+
41
+ Tier 3 adds the `SabreLegacy` SOAP client, with a stateful session and Queue Management (see
42
+ [SOAP sessions and queues](#soap-sessions-and-queues-sabrelegacy)):
43
+
44
+ | Step | Service | Method |
45
+ | - | - | - |
46
+ | Session | `SessionCreateRQ` 2.0.0 / 1.0.0, `OTA_PingRQ`, `SessionCloseRQ` | `legacy.withSession`, `legacy.openSession` |
47
+ | Queues | Get Queue Count, Access Queue, Place Queue Message | `session.queue.count`, `list`, `access`, `ignore`, `remove`, `place`, `exit`, `process` |
48
+ | Queue paragraph | — (pure function) | `parseQueueParagraph`, `resolveHostDate` |
49
+
50
+ Planned after `0.3.0`: Get Ancillaries (NDC only).
37
51
 
38
52
  ## Requirements
39
53
 
@@ -171,6 +185,244 @@ Every request accepts an `AbortSignal`. Shopping calls also accept a per-request
171
185
  await sabre.air.bargainFinderMax(search, { signal, retry: { maxRetries: 1 } });
172
186
  ```
173
187
 
188
+ ## Choosing a seat
189
+
190
+ `sabre.air.getSeats` returns the seat map with availability and prices. It is one function
191
+ for both Get Seats endpoints: without `pnrLocator` it goes to `byReservationPayload` (before
192
+ booking), with `pnrLocator` it goes to `byPnrLocator` (an existing PNR).
193
+
194
+ Before booking, build the request from the revalidated itinerary with
195
+ `buildSeatAvailabilityRequest`, pick a seat, and pass it to `buildBookingFlightDetails`:
196
+
197
+ ```ts
198
+ import { buildBookingFlightDetails, buildSeatAvailabilityRequest } from 'sabre-client';
199
+
200
+ // After step 2 of "Booking a flight": `confirmedGroup` and `confirmed`.
201
+ const seatMap = await sabre.air.getSeats(
202
+ buildSeatAvailabilityRequest({ group: confirmedGroup, itinerary: confirmed, currencyCode: 'USD' }),
203
+ );
204
+
205
+ // seatMaps[].segmentRef is S1, S2, … in itinerary order. `occupationStatusCode: 'F'` is a free
206
+ // seat; one with `offerItemRefIds` may have a price (offerItems[].priceDefinitionRef →
207
+ // priceDefinitions[]). Here: the first free seat of the first flight with no offer item.
208
+ const rows = seatMap.response.seatMaps
209
+ .filter((map) => map.segmentRef === 'S1')
210
+ .flatMap((map) => map.cabinCompartments ?? [])
211
+ .flatMap((cabin) => cabin.seatRows ?? []);
212
+ const seat = rows
213
+ .flatMap((row) => row.seats.map((s) => ({ number: `${row.row}${s.column}`, ...s })))
214
+ .find((s) => s.occupationStatusCode === 'F' && (s.offerItemRefIds ?? []).length === 0);
215
+
216
+ const booking = await sabre.booking.create({
217
+ flightDetails: buildBookingFlightDetails({
218
+ group: confirmedGroup,
219
+ itinerary: confirmed,
220
+ // Both indexes start at 1: first flight of the itinerary, first traveler.
221
+ seats: seat === undefined ? [] : [{ segmentIndex: 1, travelerIndex: 1, number: seat.number }],
222
+ seatAvailability: seatMap,
223
+ }),
224
+ travelers: [{ givenName: 'JANE', surname: 'DOE', passengerCode: 'ADT' }],
225
+ contactInfo: { emails: ['jane@example.com'], phones: ['5550000000'] },
226
+ });
227
+ ```
228
+
229
+ For a booking that already exists, ask by locator:
230
+
231
+ ```ts
232
+ const pnrSeats = await sabre.air.getSeats({ pnrLocator: booking.confirmationId! });
233
+ ```
234
+
235
+ Things to know:
236
+
237
+ - **Seats are booked by number, ATPCO only.** The seat goes to `flightDetails.flights[].seats[]`
238
+ (`BookSeat`). `seatOffers` and the Get Seats `offerId` are NDC only and are not supported.
239
+ `areaPreferences` and `changeOfGaugeSeats` are not supported by the helper either.
240
+ - **Prefer free seats.** It is not known what Sabre does with a chargeable seat requested by
241
+ number through ATPCO. If you pass `seatAvailability` and the chosen seat has a price,
242
+ `sabre.booking.create` logs a `sabre.booking.seat.priced` warning (segment, flight, seat,
243
+ amount and currency, no passenger data) and books anyway.
244
+ - **Local validation.** A seat number that does not match `^[0-9]+[A-Z]$`, a `segmentIndex` or
245
+ `travelerIndex` that does not exist, or a `pnrLocator` that is empty or mixed with payload
246
+ fields (`segments`, `passengers`, `returnSeatMapsOnlyForSegmentRefs`) throws a `TypeError`
247
+ before any request is sent.
248
+ - **Errors.** Get Seats has no published error catalog. `errors[]` without a seat map throws a
249
+ non-retryable `SabreBusinessError`; with a seat map, errors and `warnings[]` come back in
250
+ `seatMap.errors` and `seatMap.warnings`. Both endpoints are reads, so gateway `429`/`5xx`
251
+ are retried.
252
+ - **`byPnrLocator` only reads PNRs from the PCC you authenticate with.** Its request has no
253
+ `targetPcc`, unlike Create, Get and Cancel Booking. If the booking was created with
254
+ `targetPcc` in another PCC, Sabre answers `502` with `Viewership is restricted for the PNR`,
255
+ which surfaces as a `SabreGatewayError`.
256
+ - **What has been checked.** `byReservationPayload` was checked read-only in production, for
257
+ a nonstop and a connection. Booking with a seat was checked in sandbox: the seat reaches the
258
+ PNR and `sabre.booking.get` returns it in `flights[].seats[]`. `byPnrLocator` has not been
259
+ checked against Sabre yet, because the only sandbox bookings available live in another PCC.
260
+ Prices arrive as numbers even though the spec says strings, so the types accept both.
261
+
262
+ ## SOAP sessions and queues (`SabreLegacy`)
263
+
264
+ `SabreLegacy` is the client for Sabre's SOAP services. It takes the same `SabreConfig` as `Sabre`.
265
+ The session's user, PCC and domain come from `credentials.username`
266
+ (`V1:<user>:<PCC>:<domain>` or `<user>-<PCC>-<domain>`). The `SessionCreateRQ` version comes from
267
+ the credentials: 2.0.0 with `clientId` and `clientSecret`, 1.0.0 without them. Passing only one of
268
+ the two, or a `username` in neither format, throws a `TypeError` when the client is created.
269
+
270
+ ```ts
271
+ import { SabreLegacy } from 'sabre-client';
272
+
273
+ const legacy = new SabreLegacy({
274
+ environment: 'production',
275
+ authVersion: 'v2',
276
+ credentials: { username: 'V1:<user>:<PCC>:AA', password: process.env.SABRE_EPR_PASSWORD! },
277
+ });
278
+
279
+ const counts = await legacy.withSession((session) => session.queue.count());
280
+ ```
281
+
282
+ ### Sessions
283
+
284
+ - **`withSession(callback)` is the recommended form.** It opens a session, lends it to the
285
+ callback and always closes it, also when the callback throws. It returns what the callback
286
+ returns. If closing fails, it logs `sabre.session.closeFailed` and keeps the callback's result
287
+ or error.
288
+ - **`openSession(options?)`** is for controlling the session's lifetime yourself. Close it with
289
+ `try { … } finally { await session.close() }`. `close()` is idempotent. If the runtime has
290
+ `Symbol.asyncDispose`, `await using` works too, but the types do not declare it.
291
+ - **A session is exclusive.** Sabre does not allow concurrent calls on one session, so a second
292
+ call while another is in flight throws `TypeError: sabre-client: session busy` without calling
293
+ the network. Calls after `close()` throw `session closed`.
294
+ - **Inactivity.** Sabre documents a 15-minute inactivity timeout (in production, sessions have
295
+ lasted longer). `session.idleMs` is the
296
+ time since Sabre last answered, and `session.ping()` resets it. With
297
+ `openSession({ keepAlive: true })`, the SDK pings after 13 minutes of inactivity. It is off by
298
+ default, because a timer does not survive between serverless or edge invocations.
299
+ - **No pool, and no automatic reopening.** Each `withSession` opens and closes one session. After
300
+ a `SabreSessionError`, the session is unusable: later calls throw `session unusable` without
301
+ calling the network, and `close()` resolves without calling it. The SDK does not reopen the
302
+ session, because the queue cursor is already lost. Starting over is your decision.
303
+
304
+ ### Queues
305
+
306
+ The queue operations live in `session.queue`. `pcc` is optional in every call and defaults to the
307
+ session's PCC. A queue is a number from 0 to 511 or `{ name }` for alphabetic queues
308
+ (`QueueRef`). The queues returned by `count` can be passed straight to the other operations.
309
+
310
+ | Method | Sabre service | Returns |
311
+ | - | - | - |
312
+ | `count({ pcc? })` | `QueueCountLLSRQ` | `{ queues: [{ queue, count }], totals, countedAt, warnings }` |
313
+ | `list({ queue, pcc?, primaryPassenger? })` | `QueueAccessLLSRQ` (`List`) | `{ locators, entries, paragraph, warnings }`, without entering the queue |
314
+ | `access({ queue, pcc?, selection? })` | `QueueAccessLLSRQ` | `QueueStep` |
315
+ | `ignore()` | `QueueAccessLLSRQ` (`I`) | `QueueStep` |
316
+ | `remove({ from? })` | `QueueAccessLLSRQ` (`QR`) | `QueueStep` |
317
+ | `place({ to, locators? })` | `QueuePlaceLLSRQ` | `QueueStep` inside a queue, `{ placements, text, warnings }` outside |
318
+ | `exit()` | `QueueAccessLLSRQ` (`QXI`) | `void` |
319
+
320
+ ```ts
321
+ type QueueStep =
322
+ | { done: false; item: QueueItem; warnings: SabreWarning[] }
323
+ | { done: true; reason: 'end-of-display' | 'empty'; message: string; warnings: SabreWarning[] };
324
+ ```
325
+
326
+ - **The end of the queue is a result, not an error.** `END OF DISPLAY FOR REQUESTED DATA` gives
327
+ `{ done: true, reason: 'end-of-display' }`, and an empty queue (`QUEUE SELECTED WAS EMPTY`)
328
+ gives `reason: 'empty'`. In both cases the session is left outside the queue. Any other Sabre
329
+ error is thrown as a `SabreBusinessError`.
330
+ - **The operations that move forward return the next item**, so there is no extra call per PNR.
331
+ Inside a queue, `place` moves the current PNR to the target queues and returns the next one.
332
+ - **The SDK tracks whether the session is inside a queue** (`session.queue.state`: `'outside'`,
333
+ `'inside'` or `'unknown'`). `ignore`, `remove` and `exit` outside a queue throw a `TypeError`
334
+ without calling the network. So do `access` while inside a queue, and `place` with `locators`
335
+ inside a queue or in `'unknown'`. When an operation fails inside a queue with an error that is
336
+ not a session error, the state becomes `'unknown'`, because the SDK cannot tell whether Sabre
337
+ left the session inside. Call `exit()` to get out: in `'unknown'`, it tolerates
338
+ `CANNOT DO THIS IF OFF QUEUE`.
339
+ - **`place`**: inside a queue it places the current PNR. With `locators` (up to 6), it places
340
+ those PNRs from outside a queue. Without either, it places the PNR in the AAA.
341
+ - **`instructionCode` (the PIC) is required in every `place` target.** It must be 0–191 or 200–254.
342
+ Production rejects a `place` without it, although the design says it is optional.
343
+ - **`QueueItem`** has `locator`, the `placedAt` and `placedBy` of `Line`, `primaryPassenger` and
344
+ `group` when Sabre sends them, and `paragraph`: the PNR in host format, line by line and
345
+ untouched. `placedAt` is the text that arrived, with or without the year. `resolveHostDate`
346
+ turns it into a full date.
347
+ - `count` inside a queue does not count the PNR on screen.
348
+
349
+ ### Walking a queue with `process`
350
+
351
+ `process` enters the queue, calls your callback for each PNR, runs the action it returns and stops
352
+ at the end of the queue. It does nothing the primitives cannot do: it covers the common case and
353
+ makes sure the session leaves the queue.
354
+
355
+ ```ts
356
+ const result = await legacy.withSession((session) =>
357
+ session.queue.process({ queue: 12, limit: 200 }, (item) => {
358
+ if (isDone(item)) return { action: 'remove' };
359
+ if (needsReview(item)) return { action: 'place', to: [{ queue: 50, instructionCode: 11 }] };
360
+ return { action: 'ignore' };
361
+ }),
362
+ );
363
+ // result: { visited: number; ended: 'end-of-display' | 'empty' | 'limit' }
364
+ ```
365
+
366
+ - When it reaches `limit`, it leaves the queue with `QXI`. `limit` is optional and has no default
367
+ cap, but passing one is recommended.
368
+ - If the callback throws, `process` leaves the queue with `QXI` and rethrows. It does not turn the
369
+ error into `ignore`. To carry on, catch the error in the callback and return `ignore`.
370
+
371
+ ### Reading the paragraph
372
+
373
+ `parseQueueParagraph(item.paragraph, { referenceDate })` is a separate pure function that does not
374
+ run on its own. It returns `{ messages, passengers, segments, ticketing, receivedFrom, unparsed }`:
375
+ the queue notices (`011 SEE REMARKS` → `{ instructionCode: 11, text: 'SEE REMARKS' }`), names,
376
+ segments with their status, the ticketing field with an `issued` flag, and the received-from line.
377
+
378
+ - **It is best effort.** The host format has no contract. A line the parser does not recognize
379
+ goes to `unparsed`, and the parser does not throw.
380
+ - **`referenceDate` is required.** Host dates have no year (`06SEP`), so the year is resolved
381
+ against that date. Segments and ticketing take the nearest date within ±6 months, and the
382
+ received-from line goes backwards. Dates come out as ISO strings without a time zone.
383
+ - It does not read deadlines that airlines write as free text in remarks, SSR or OSI. Their format
384
+ belongs to each airline. The text is still in `paragraph`.
385
+
386
+ ### Testing your queue logic: `sabre-client/testing`
387
+
388
+ `sabre-client/testing` is a separate entry point with a fake of Sabre's sessions and queues. It is
389
+ not part of the main bundle. `createQueueFake` returns a `fetch` that you pass in `config.fetch`,
390
+ so your test goes through the SDK's real transport, session and parsing, without the network:
391
+
392
+ ```ts
393
+ import { SabreLegacy } from 'sabre-client';
394
+ import { createQueueFake } from 'sabre-client/testing';
395
+
396
+ const fake = createQueueFake({
397
+ queues: {
398
+ '12': [
399
+ [' 1.1DOE/JANE', ' 1 XX 123Y 15DEC 2 MEXCUN HK1', 'ABCD.ABCD*AAA 1200/01NOV26 AAAAAA H'],
400
+ { locator: 'BBBBBB', paragraph: [' 1.1ROE/JOHN'] },
401
+ ],
402
+ },
403
+ });
404
+ const legacy = new SabreLegacy({ ...config, fetch: fake.fetch });
405
+
406
+ await legacy.withSession((session) => session.queue.process({ queue: 12 }, () => ({ action: 'remove' })));
407
+
408
+ fake.locators(12); // []
409
+ fake.openSessions; // 0: the session was closed
410
+ ```
411
+
412
+ - Each PNR is a paragraph, with the locator taken from its signature line, or
413
+ `{ locator, paragraph?, placedAt?, placedBy?, primaryPassenger? }`. A paragraph without a
414
+ signature line gets a synthetic one. `pnrs` adds PNRs that are not in any queue, for
415
+ `place({ locators })`. The fake has no AAA: outside a queue, `place` needs `locators`.
416
+ - It answers like production: empty queues, the end of the queue, `place` moving the PNR, the
417
+ required PIC, and an expired session with `fake.expireSessions()`. `fake.calls` lists what the
418
+ SDK asked for.
419
+ - **Not simulated**: PCCs (the queues are the same for any `pcc`) and `selection`, which gets an
420
+ error instead of the whole queue.
421
+ - `queueFixtures` holds the anonymized responses recorded in production (`walk`, `emptyQueue`,
422
+ `place`, …), as `{ operation, status, body }`.
423
+
424
+ `examples/queue-review.mjs` is a full example, and its test runs it against the fake.
425
+
174
426
  ## Error handling
175
427
 
176
428
  Every error thrown by the SDK extends `SabreError`:
@@ -180,6 +432,7 @@ Every error thrown by the SDK extends `SabreError`:
180
432
  | `SabreGatewayError` | HTTP or gateway failure (`ERR.2SG.*`, `401`, `429`, `5xx`, OAuth errors) | `protocol`, `status`, `code` |
181
433
  | `SabreOrchestrationError` | Orchestrated service error inside a `200` (`SP.*`) | `code` |
182
434
  | `SabreBusinessError` | Error from a service's own catalog (e.g. `No Availability`, a booking that was not created) | `service`, `code`, `warnings`, `errors` |
435
+ | `SabreSessionError` | SOAP session failure in `SabreLegacy`, a subclass of `SabreGatewayError` | `kind`: `'limit'` (more than 50 sessions), `'invalid'`, `'expired'`, `'blocked'` |
183
436
  | `SabreWarning` | Non-fatal warning (e.g. `category: WARNING` in Create Booking) | `service`, `code` |
184
437
 
185
438
  All of them have `retryable`, which says whether the retry policy may try again.
@@ -207,10 +460,12 @@ try {
207
460
 
208
461
  ## Raw response
209
462
 
210
- Every REST operation (`air.bargainFinderMax`, `air.revalidateItinerary`, `booking.create`,
211
- `booking.get` and `booking.cancel`) accepts an `onRawResponse` callback in its options, next to
212
- `signal`. It receives Sabre's response as it arrived, for example to store it next to the typed
213
- result:
463
+ Every REST operation (`air.bargainFinderMax`, `air.revalidateItinerary`, `air.getSeats`,
464
+ `booking.create`, `booking.get` and `booking.cancel`), and every `SabreLegacy` operation, accepts
465
+ an `onRawResponse` callback in its
466
+ options, next to `signal`. It receives Sabre's response as it arrived, for example to store it
467
+ next to the typed result. For `air.getSeats`, `operation` names the endpoint that was called
468
+ (`air.getSeatsByPayload` or `air.getSeatsByPnrLocator`):
214
469
 
215
470
  ```ts
216
471
  import type { SabreRawResponse } from 'sabre-client';
@@ -230,6 +485,10 @@ const booking = await sabre.booking.get({ confirmationId }, { onRawResponse: (r)
230
485
  failing callback never makes `booking.create` lose its `confirmationId`. To persist
231
486
  asynchronously, keep the value and write it after the call returns.
232
487
 
488
+ In `SabreLegacy`, `protocol` is `'soap'`, `body` is the whole SOAP envelope, and `operation` is
489
+ `'openSession'`, `'close'`, `'ping'` or `'queue.<method>'`. The session token
490
+ (`BinarySecurityToken`) is replaced by `[REDACTED]` wherever it appears. Nothing else is changed.
491
+
233
492
  > [!WARNING]
234
493
  > `body` is the raw response and **contains passenger data**. The SDK never sends it to the
235
494
  > logger. Where you store it is up to you.
@@ -241,6 +500,12 @@ are read-only `POST`s). Create Booking is never retried. Retries use exponential
241
500
  full jitter and a 500 ms floor, on `429`, `500`, `503`, `504`, `SP.PROVIDER_ERROR` and the
242
501
  temporary errors in the Bargain Finder Max catalog.
243
502
 
503
+ In `SabreLegacy`, `count`, `list` and `ping` are reads and are retried on transient failures. The
504
+ operations that move the queue cursor (`access`, `ignore`, `remove`, `place`, `exit`) are retried
505
+ only on the `USG_*_BUSY` codes, which are taken to mean that Sabre did not process the request, and on
506
+ `PREVIOUS ENTRY ACTIVE` (3 × 1 s). They are never retried after a network failure, because the PNR
507
+ may already have moved: the queue state becomes `'unknown'`.
508
+
244
509
  ## Token reuse
245
510
 
246
511
  By default the token lives in memory, one per `Sabre` instance. To share it between
@@ -285,7 +550,9 @@ const sabre = new Sabre({
285
550
 
286
551
  The SDK logs structured events (`sabre.request.start`, `sabre.request.end`,
287
552
  `sabre.request.retry`, `sabre.request.rawResponseHookFailed`, `sabre.auth.token.refresh`,
288
- `sabre.auth.token.store`), never raw
553
+ `sabre.auth.token.store`, `sabre.booking.seat.priced`, `sabre.session.open`,
554
+ `sabre.session.close`, `sabre.session.closeFailed`, `sabre.session.keepAliveFailed`,
555
+ `sabre.queue.exitTolerated`, `sabre.queue.exitFailed`), never raw
289
556
  request or response bodies. Secrets, the `Authorization` header and passenger data are always
290
557
  redacted before they reach your logger.
291
558