sabre-client 0.2.4 → 0.3.0-next.1

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Carlos Ivan Soto Perez
3
+ Copyright (c) 2026 Carlos Ivan Soto Perez
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.es.md ADDED
@@ -0,0 +1,294 @@
1
+ # sabre-client
2
+
3
+ SDK de TypeScript para las APIs de Sabre.
4
+
5
+ [Read in English](./README.md)
6
+
7
+ > **`0.3.0` es una reescritura completa, no una actualización incremental de `0.2.4`.**
8
+ > Tiene otra arquitectura: un cliente REST `Sabre` con un `AuthManager` que gestiona el token,
9
+ > una jerarquía de errores tipada, un logger inyectable con redacción obligatoria y solo Web
10
+ > Standard APIs, para correr en cualquier runtime de servidor. El código escrito para `0.2.4`
11
+ > no funciona sin cambios. Las versiones anteriores siguen publicadas tal como están.
12
+
13
+ ## Estado
14
+
15
+ `0.3.0` **todavía no está publicada**. Se publica cuando estén terminados el Tier 1 y el
16
+ Tier 2 del flujo de Air Booking. Mientras tanto, `npm install sabre-client` instala `0.2.4`.
17
+
18
+ Las prereleases de la reescritura se publican con el tag `next`:
19
+
20
+ ```sh
21
+ npm install sabre-client@next
22
+ ```
23
+
24
+ La API todavía puede cambiar antes de `0.3.0`.
25
+
26
+ El Tier 1 cubre el flujo básico de Air Booking por REST:
27
+
28
+ | Paso | Servicio | Método |
29
+ | - | - | - |
30
+ | Autenticación | OAuth Token Create v3 / v2 | automática |
31
+ | Búsqueda | Bargain Finder Max v5 | `sabre.air.bargainFinderMax` |
32
+ | Revalidación | Revalidate Itinerary v5 | `sabre.air.revalidateItinerary` |
33
+ | Reserva | Create Booking (Booking Management API v1, ATPCO por `flightDetails`) | `sabre.booking.create` |
34
+ | Gestión de la reserva | Get Booking y Cancel Booking (Booking Management API v1) | `sabre.booking.get`, `sabre.booking.cancel` |
35
+
36
+ Planeado: Get Seats y Get Ancillaries (Tier 2), y un cliente `SabreLegacy` para los servicios SOAP.
37
+
38
+ ## Requisitos
39
+
40
+ - Node.js `>=22.12`, o un runtime edge de servidor con `fetch` y `crypto.subtle`
41
+ (Cloudflare Workers, Vercel Edge Functions, Deno Deploy). El paquete usa solo Web Standard
42
+ APIs.
43
+ - Solo ESM.
44
+ - **Nunca lo corras en un navegador.** Tus credenciales de Sabre quedarían expuestas a
45
+ cualquiera con las devtools.
46
+
47
+ ## Configuración
48
+
49
+ El SDK nunca lee variables de entorno. La configuración se pasa explícitamente:
50
+
51
+ ```ts
52
+ import { Sabre } from 'sabre-client';
53
+
54
+ const sabre = new Sabre({
55
+ environment: 'sandbox', // 'sandbox' (CERT) o 'production'
56
+ credentials: {
57
+ clientId: process.env.SABRE_CLIENT_ID!,
58
+ clientSecret: process.env.SABRE_CLIENT_SECRET!,
59
+ username: process.env.SABRE_EPR_USERNAME!, // usuario EPR, p. ej. `<user>-<PCC>-AA`
60
+ password: process.env.SABRE_EPR_PASSWORD!,
61
+ },
62
+ });
63
+ ```
64
+
65
+ | Opción | Default | Descripción |
66
+ | - | - | - |
67
+ | `environment` | requerido | `'sandbox'` → `api.cert.platform.sabre.com`, `'production'` → `api.platform.sabre.com` |
68
+ | `authVersion` | `'v3'` | Versión de OAuth Token Create. `'v3'` necesita `clientId`, `clientSecret`, `username` y `password`; `'v2'` solo el `username` EPR (`V1:<user>:<PCC>:AA`) y el `password` |
69
+ | `credentials` | requerido | Ver arriba |
70
+ | `logger` | ninguno | Cualquier objeto con `debug`, `info`, `warn` y `error` (ver [Logging](#logging)) |
71
+ | `retry` | `{ maxRetries: 3, baseDelayMs: 500 }` | Política de reintentos del cliente; cada request puede pisarla |
72
+ | `fetch` | `fetch` global | `fetch` propio (proxies, tests, otros runtimes) |
73
+ | `tokenStore` | solo en memoria | Comparte el token entre procesos (ver [Reutilizar el token](#reutilizar-el-token)) |
74
+
75
+ ### Autenticación
76
+
77
+ Nunca pides el token tú. El cliente lo obtiene en la primera llamada, lo cachea, lo renueva
78
+ alrededor de una hora antes de que expire y comparte una sola petición en vuelo entre llamadas
79
+ concurrentes. Si Sabre rechaza un token con `401`, el cliente obtiene uno nuevo y reintenta esa
80
+ request una vez.
81
+
82
+ ## Reservar un vuelo
83
+
84
+ El flujo completo del Tier 1: buscar, revalidar el itinerario elegido y reservarlo.
85
+
86
+ ```ts
87
+ import { Sabre, buildRevalidateItineraryRequest, buildBookingFlightDetails } from 'sabre-client';
88
+
89
+ const sabre = new Sabre({ environment: 'sandbox', credentials });
90
+
91
+ // 1. Buscar con Bargain Finder Max. El request es el body que documenta Sabre.
92
+ const search = {
93
+ OTA_AirLowFareSearchRQ: {
94
+ Version: '5',
95
+ POS: { Source: [{ PseudoCityCode: 'XXXX', RequestorID: { Type: '1', ID: '1', CompanyName: { Code: 'TN' } } }] },
96
+ OriginDestinationInformation: [
97
+ { DepartureDateTime: '2026-12-01T10:00:00', OriginLocation: { LocationCode: 'DFW' }, DestinationLocation: { LocationCode: 'LAX' } },
98
+ ],
99
+ TravelerInfoSummary: { AirTravelerAvail: [{ PassengerTypeQuantity: [{ Code: 'ADT', Quantity: 1 }] }] },
100
+ TPA_Extensions: { IntelliSellTransaction: { RequestType: { Name: '50ITINS' } } },
101
+ },
102
+ };
103
+ const found = await sabre.air.bargainFinderMax(search);
104
+
105
+ // Las respuestas llegan con las referencias ya resueltas: cada itinerario trae sus tramos,
106
+ // horarios y tarifas en línea, sin tener que buscar por `ref`.
107
+ const group = found.itineraryGroups[0]!;
108
+ const itinerary = group.itineraries[0]!;
109
+
110
+ // 2. Revalidar el itinerario elegido (disponibilidad y precio, sin retener inventario).
111
+ const revalidated = await sabre.air.revalidateItinerary(
112
+ buildRevalidateItineraryRequest({ search, group, itinerary }),
113
+ );
114
+ const confirmedGroup = revalidated.itineraryGroups[0]!;
115
+ const confirmed = confirmedGroup.itineraries[0]!;
116
+
117
+ // 3. Reservarlo por el camino ATPCO (`flightDetails`).
118
+ const booking = await sabre.booking.create({
119
+ flightDetails: buildBookingFlightDetails({ group: confirmedGroup, itinerary: confirmed }),
120
+ travelers: [{ givenName: 'JANE', surname: 'DOE', passengerCode: 'ADT' }],
121
+ contactInfo: { emails: ['jane@example.com'], phones: ['5550000000'] },
122
+ });
123
+
124
+ console.log(booking.confirmationId); // localizador del PNR
125
+ ```
126
+
127
+ `buildBookingFlightDetails` convierte el itinerario revalidado en el bloque `flightDetails` de
128
+ Create Booking. Sabre no documenta ese mapeo y todavía no se comprobó con una reserva real en
129
+ sandbox, así que revisa el request antes de depender de él.
130
+
131
+ `sabre.booking.create` **nunca se reintenta automáticamente**, porque no es idempotente. Si
132
+ Sabre crea la reserva con avisos o errores no fatales, la llamada no lanza: revisa
133
+ `booking.warnings` y `booking.errors`.
134
+
135
+ `sabre.booking.get` lee una reserva por `confirmationId`. A diferencia de Create Booking, la
136
+ respuesta trae los campos de la reserva en el primer nivel (`bookingId`, `flights`,
137
+ `travelers`, …), tal como la define Sabre. Usa `returnOnly` para pedir solo las secciones que
138
+ necesitas; además, la llamada es más rápida:
139
+
140
+ ```ts
141
+ const pnr = await sabre.booking.get({
142
+ confirmationId: booking.confirmationId,
143
+ returnOnly: ['FLIGHTS', 'TRAVELERS'],
144
+ });
145
+ ```
146
+
147
+ Solo lanza `SabreBusinessError` si no hay reserva (por ejemplo, `BOOKING_NOT_FOUND`). Si la
148
+ reserva existe, los problemas parciales vuelven en `pnr.warnings` y `pnr.errors`; una reserva
149
+ sin emitir suele traer el aviso `RESOURCE_UNAVAILABLE` porque todavía no tiene fare rules. Es
150
+ una lectura, así que los fallos transitorios se reintentan.
151
+
152
+ `sabre.booking.cancel` cancela la reserva entera (`cancelAll: true`) o productos concretos por
153
+ el `itemId` que devuelve `get`:
154
+
155
+ ```ts
156
+ await sabre.booking.cancel({ confirmationId: booking.confirmationId, cancelAll: true });
157
+ await sabre.booking.cancel({ confirmationId: pnr.bookingId, flights: [{ itemId: '2' }] });
158
+ ```
159
+
160
+ Como `create`, **nunca se reintenta automáticamente**. Las combinaciones que Sabre siempre
161
+ rechaza (`cancelAll` junto con listas, ni `cancelAll` ni listas, `offerItemId` junto con
162
+ `flightTicketOperation`) lanzan un `TypeError` antes de enviar nada. Si Sabre responde
163
+ `NO_ITEMS_CANCELLED`, la llamada lanza `SabreBusinessError` aunque Sabre lo mande como aviso,
164
+ porque no se canceló nada; los errores que explican el motivo están en `error.errors`. El aviso
165
+ `END_TRANSACTION_PROBLEM` significa que la confirmación sigue pendiente: verifica el resultado
166
+ con `sabre.booking.get`.
167
+
168
+ Toda request acepta un `AbortSignal`. Las búsquedas aceptan además una política de reintentos
169
+ propia:
170
+
171
+ ```ts
172
+ await sabre.air.bargainFinderMax(search, { signal, retry: { maxRetries: 1 } });
173
+ ```
174
+
175
+ ## Manejo de errores
176
+
177
+ Todos los errores que lanza el SDK extienden `SabreError`:
178
+
179
+ | Clase | Cuándo | Campos extra |
180
+ | - | - | - |
181
+ | `SabreGatewayError` | Falla HTTP o del gateway (`ERR.2SG.*`, `401`, `429`, `5xx`, errores de OAuth) | `protocol`, `status`, `code` |
182
+ | `SabreOrchestrationError` | Error de un servicio orquestado dentro de un `200` (`SP.*`) | `code` |
183
+ | `SabreBusinessError` | Error del catálogo propio de un servicio (p. ej. `No Availability`, una reserva que no se creó) | `service`, `code`, `warnings`, `errors` |
184
+ | `SabreWarning` | Aviso no fatal (p. ej. `category: WARNING` en Create Booking) | `service`, `code` |
185
+
186
+ Todos tienen `retryable`, que indica si la política de reintentos puede volver a intentar.
187
+
188
+ ```ts
189
+ import { SabreBusinessError, SabreGatewayError } from 'sabre-client';
190
+
191
+ try {
192
+ await sabre.booking.create(request);
193
+ } catch (error) {
194
+ if (error instanceof SabreBusinessError) {
195
+ console.error(error.service, error.code, error.message);
196
+ } else if (error instanceof SabreGatewayError) {
197
+ console.error(error.status, error.code);
198
+ }
199
+ throw error;
200
+ }
201
+ ```
202
+
203
+ > [!WARNING]
204
+ > `SabreError.raw` guarda el fragmento original de la respuesta, sin tocar, para debugging.
205
+ > **Puede contener credenciales o datos de pasajeros.** No es enumerable, así que no aparece en
206
+ > `JSON.stringify` ni en un spread, pero sigue accesible como `error.raw`. Nunca lo mandes a un
207
+ > logger, a un tracker de errores ni a un servicio de monitoreo sin sanitizarlo antes.
208
+
209
+ ## Respuesta cruda
210
+
211
+ Toda operación REST (`air.bargainFinderMax`, `air.revalidateItinerary`, `booking.create`,
212
+ `booking.get` y `booking.cancel`) acepta un callback `onRawResponse` en sus opciones, junto a
213
+ `signal`. Recibe la respuesta de Sabre tal cual llegó, por ejemplo para guardarla junto al
214
+ resultado tipado:
215
+
216
+ ```ts
217
+ import type { SabreRawResponse } from 'sabre-client';
218
+
219
+ let raw: SabreRawResponse | undefined;
220
+ const booking = await sabre.booking.get({ confirmationId }, { onRawResponse: (r) => (raw = r) });
221
+ // raw: { operation: 'booking.get', protocol: 'rest', status, headers, body, attempts }
222
+ ```
223
+
224
+ - Se llama **una vez**, con el último intento HTTP, antes de que la operación devuelva o lance.
225
+ También se llama cuando la operación lanza `SabreBusinessError` (un `200` con `errors[]`) o
226
+ `SabreGatewayError` (un no `2xx`). Si hubo reintentos, `attempts` cuenta todos los intentos HTTP.
227
+ - No se llama si ningún intento tuvo respuesta HTTP (error de red, abort), ni con las llamadas de
228
+ token. Tras un `401`, recibe la respuesta del reintento con el token nuevo.
229
+ - Es síncrono y su retorno se ignora. Si lanza, el SDK lo atrapa, registra
230
+ `sabre.request.rawResponseHookFailed` sin el body y la operación sigue: un callback que falla
231
+ nunca hace que `booking.create` pierda su `confirmationId`. Para guardar de forma asíncrona,
232
+ conserva el valor y escríbelo cuando la llamada vuelva.
233
+
234
+ > [!WARNING]
235
+ > `body` es la respuesta cruda y **trae datos de pasajeros**. El SDK nunca lo manda al logger.
236
+ > Dónde lo guardas es tu responsabilidad.
237
+
238
+ ## Reintentos
239
+
240
+ Solo se reintentan las operaciones idempotentes: Bargain Finder Max y Revalidate Itinerary
241
+ (ambas son `POST` de solo lectura). Create Booking nunca se reintenta. Los reintentos usan
242
+ backoff exponencial con full jitter y un piso de 500 ms, ante `429`, `500`, `503`, `504`,
243
+ `SP.PROVIDER_ERROR` y los errores temporales del catálogo de Bargain Finder Max.
244
+
245
+ ## Reutilizar el token
246
+
247
+ Por defecto el token vive en memoria, uno por instancia de `Sabre`. Para compartirlo entre
248
+ procesos (scripts, funciones serverless), pasa un `tokenStore`. El SDK no trae ninguna
249
+ implementación:
250
+
251
+ ```ts
252
+ import type { SabreTokenStore } from 'sabre-client';
253
+
254
+ const tokenStore: SabreTokenStore = {
255
+ async get(key) {
256
+ return JSON.parse((await kv.get(key)) ?? 'null') ?? undefined;
257
+ },
258
+ async set(key, token) {
259
+ await kv.put(key, JSON.stringify(token));
260
+ },
261
+ };
262
+ ```
263
+
264
+ `key` identifica el entorno, la versión de OAuth y un hash de la identidad de la credencial,
265
+ así que un mismo store sirve a varios clientes. No contiene secretos, pero el token guardado sí
266
+ lo es: protegerlo te toca a ti. Si `get` o `set` lanzan, el SDK sigue con su caché en memoria.
267
+
268
+ ## Logging
269
+
270
+ No hay logging salvo que pases un `logger`. Es compatible con `console` y `winston`; con `pino`,
271
+ invierte los argumentos:
272
+
273
+ ```ts
274
+ const sabre = new Sabre({
275
+ environment: 'sandbox',
276
+ credentials,
277
+ logger: {
278
+ debug: (m, c) => pino.debug(c, m),
279
+ info: (m, c) => pino.info(c, m),
280
+ warn: (m, c) => pino.warn(c, m),
281
+ error: (m, c) => pino.error(c, m),
282
+ },
283
+ });
284
+ ```
285
+
286
+ El SDK registra eventos estructurados (`sabre.request.start`, `sabre.request.end`,
287
+ `sabre.request.retry`, `sabre.request.rawResponseHookFailed`, `sabre.auth.token.refresh`,
288
+ `sabre.auth.token.store`) y nunca los bodies
289
+ crudos de request o response. Los secretos, el header `Authorization` y los datos de pasajeros
290
+ se redactan siempre antes de llegar a tu logger.
291
+
292
+ ## Changelog
293
+
294
+ Ver [CHANGELOG.md](./CHANGELOG.md).