@cobre-npm/library-response-catalog-node 0.6.0 → 0.7.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 +101 -60
- package/dist/config/catalog-http-adapters.d.ts +22 -0
- package/dist/config/catalog-http-adapters.d.ts.map +1 -0
- package/dist/config/create-catalog-client.d.ts +26 -0
- package/dist/config/create-catalog-client.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +988 -13
- package/dist/index.js.map +1 -1
- package/package.json +10 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,981 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
var axios = require('axios');
|
|
4
|
+
var api = require('@opentelemetry/api');
|
|
5
|
+
var libraryNodejsTelemetry = require('@cobre-npm/library-nodejs-telemetry');
|
|
6
|
+
var cockatiel = require('cockatiel');
|
|
7
|
+
var http = require('http');
|
|
8
|
+
var https = require('https');
|
|
9
|
+
var fs = require('fs');
|
|
10
|
+
require('path');
|
|
11
|
+
|
|
12
|
+
const NO_AUTH_HEADERS_SOURCE = {
|
|
13
|
+
async headers() {
|
|
14
|
+
return {};
|
|
15
|
+
},
|
|
16
|
+
confirm(_httpStatusCode) {
|
|
17
|
+
// no-op
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
const noAuthHeadersSource = () => NO_AUTH_HEADERS_SOURCE;
|
|
21
|
+
|
|
22
|
+
const CONNECTION_MODES = {
|
|
23
|
+
INTERNAL_GATEWAY: 'INTERNAL_GATEWAY',
|
|
24
|
+
CLUSTER_DNS: 'CLUSTER_DNS',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const CREDENTIAL_ERROR_CODES$1 = new Set([401, 403, 491, 493]);
|
|
28
|
+
/**
|
|
29
|
+
* Auth source for INTERNAL_GATEWAY. Caches headers from library-nodejs-common and
|
|
30
|
+
* invalidates the cache when `confirm` reports a credential rejection so the next
|
|
31
|
+
* retry (after confirm) fetches fresh credentials — same semantic as Java's
|
|
32
|
+
* APIGWKeysUseCase.confirmStatusCode.
|
|
33
|
+
*/
|
|
34
|
+
class InternalGatewayAuthHeadersSource {
|
|
35
|
+
options;
|
|
36
|
+
cachedHeaders = null;
|
|
37
|
+
inFlight = null;
|
|
38
|
+
invalidateOnNextHeaders = false;
|
|
39
|
+
constructor(options) {
|
|
40
|
+
this.options = options;
|
|
41
|
+
}
|
|
42
|
+
async headers() {
|
|
43
|
+
if (this.invalidateOnNextHeaders) {
|
|
44
|
+
this.cachedHeaders = null;
|
|
45
|
+
this.invalidateOnNextHeaders = false;
|
|
46
|
+
}
|
|
47
|
+
if (this.cachedHeaders !== null) {
|
|
48
|
+
return this.cachedHeaders;
|
|
49
|
+
}
|
|
50
|
+
// Concurrent cold calls share one fetch: the retry issues headers() per
|
|
51
|
+
// attempt, and a consumer resolving several errors at once would otherwise
|
|
52
|
+
// hit Secrets Manager once per caller.
|
|
53
|
+
this.inFlight ??= this.fetchAndCache();
|
|
54
|
+
return this.inFlight;
|
|
55
|
+
}
|
|
56
|
+
async fetchAndCache() {
|
|
57
|
+
try {
|
|
58
|
+
const headers = await this.options.fetchHeaders({
|
|
59
|
+
secretAdapterRegion: this.options.secretAdapterRegion,
|
|
60
|
+
apigwSecretName: this.options.secretName,
|
|
61
|
+
authManagerBaseURL: this.options.authManagerBaseURL,
|
|
62
|
+
});
|
|
63
|
+
// Forwarded as-is, like Java's adapter does with getKeysForAPICalls. An
|
|
64
|
+
// allowlist here silently dropped anything the credential source added,
|
|
65
|
+
// and a casing change (X-APIGW-AUTH vs x-apigw-auth) would have sent the
|
|
66
|
+
// request with no credentials at all rather than failing loudly.
|
|
67
|
+
this.cachedHeaders = { ...headers };
|
|
68
|
+
return this.cachedHeaders;
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
this.inFlight = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
confirm(httpStatusCode) {
|
|
75
|
+
if (CREDENTIAL_ERROR_CODES$1.has(httpStatusCode)) {
|
|
76
|
+
this.invalidateOnNextHeaders = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Outbound port: resolve a supplier error against the response catalog.
|
|
83
|
+
* Consumers depend on this abstract class, never on a concrete adapter — the
|
|
84
|
+
* HTTP implementation ships in `createCatalogClient` / `createCatalogClientSync`.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```typescript
|
|
88
|
+
* class FakeCatalogPort extends CatalogPort {
|
|
89
|
+
* async fetchResponse(context: SupplierErrorContext): Promise<Response> {
|
|
90
|
+
* return {
|
|
91
|
+
* supplier: null,
|
|
92
|
+
* internal: null,
|
|
93
|
+
* api: null,
|
|
94
|
+
* resolvedLocale: 'en-US',
|
|
95
|
+
* resolvedMessage: 'stubbed for a test',
|
|
96
|
+
* fallbackApplied: false,
|
|
97
|
+
* }
|
|
98
|
+
* }
|
|
99
|
+
* }
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
class CatalogPort {
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const HTTP_SERVER_ERROR_THRESHOLD = 500;
|
|
106
|
+
const CREDENTIAL_ERROR_CODES = new Set([401, 403, 491, 493]);
|
|
107
|
+
/**
|
|
108
|
+
* Raised when a {@link CatalogPort} implementation fails to resolve a
|
|
109
|
+
* supplier error, whether due to an HTTP error response from the catalog or
|
|
110
|
+
* a network-level failure (timeout, DNS, connection reset).
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* ```typescript
|
|
114
|
+
* try {
|
|
115
|
+
* await catalogPort.fetchResponse(context)
|
|
116
|
+
* } catch (error) {
|
|
117
|
+
* if (error instanceof CatalogClientError && error.isTransient()) {
|
|
118
|
+
* // safe to retry or fall back locally
|
|
119
|
+
* }
|
|
120
|
+
* }
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
class CatalogClientError extends Error {
|
|
124
|
+
/**
|
|
125
|
+
* HTTP status code returned by the catalog, or `null` when the failure
|
|
126
|
+
* was a network-level error (no HTTP response was received).
|
|
127
|
+
*/
|
|
128
|
+
httpStatusCode;
|
|
129
|
+
/**
|
|
130
|
+
* @param message - Human-readable description of the failure
|
|
131
|
+
* @param httpStatusCodeOrCause - HTTP status code from the catalog response,
|
|
132
|
+
* or the underlying `Error` when the failure happened before a response
|
|
133
|
+
* was received (network failure). Omit for failures with neither.
|
|
134
|
+
*/
|
|
135
|
+
constructor(message, httpStatusCodeOrCause) {
|
|
136
|
+
if (httpStatusCodeOrCause instanceof Error) {
|
|
137
|
+
super(message, { cause: httpStatusCodeOrCause });
|
|
138
|
+
this.httpStatusCode = null;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
super(message);
|
|
142
|
+
this.httpStatusCode = httpStatusCodeOrCause ?? null;
|
|
143
|
+
}
|
|
144
|
+
this.name = 'CatalogClientError';
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Whether this failure is likely temporary and safe to retry: a network
|
|
148
|
+
* failure (`httpStatusCode` is `null`) or a `5xx` response from the
|
|
149
|
+
* catalog.
|
|
150
|
+
*/
|
|
151
|
+
isTransient() {
|
|
152
|
+
return this.httpStatusCode === null || this.httpStatusCode >= HTTP_SERVER_ERROR_THRESHOLD;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Whether this failure was caused by invalid or expired credentials:
|
|
156
|
+
* `401`, `403`, or the catalog's custom credential-error codes `491`/`493`.
|
|
157
|
+
*/
|
|
158
|
+
isCredentialError() {
|
|
159
|
+
return this.httpStatusCode !== null && CREDENTIAL_ERROR_CODES.has(this.httpStatusCode);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Blank-string predicates shared by the domain and the adapters. A value counts
|
|
165
|
+
* as text only when it is present and holds at least one non-whitespace
|
|
166
|
+
* character, so `''`, `' '`, `null` and `undefined` are all treated alike.
|
|
167
|
+
*/
|
|
168
|
+
/**
|
|
169
|
+
* Narrows a possibly-absent string to one that holds non-whitespace text.
|
|
170
|
+
*
|
|
171
|
+
* @param value - The value to check
|
|
172
|
+
* @returns `true` when the value is a string with non-whitespace content
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```typescript
|
|
176
|
+
* hasText(' es-CO ') // true
|
|
177
|
+
* hasText(' ') // false
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
const hasText = (value) => value !== null && value !== undefined && value.trim().length > 0;
|
|
181
|
+
/**
|
|
182
|
+
* Returns the trimmed value, or throws when it holds no text.
|
|
183
|
+
*
|
|
184
|
+
* @param value - The value to validate
|
|
185
|
+
* @param fieldName - Name used in the error message
|
|
186
|
+
* @returns The value with surrounding whitespace removed
|
|
187
|
+
* @throws Error when the value holds no non-whitespace character
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```typescript
|
|
191
|
+
* requireText(' #UNKNOWN# ', 'token') // '#UNKNOWN#'
|
|
192
|
+
* requireText(' ', 'token') // throws: token must not be blank
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
const requireText = (value, fieldName) => {
|
|
196
|
+
if (!hasText(value)) {
|
|
197
|
+
throw new Error(`${fieldName} must not be blank`);
|
|
198
|
+
}
|
|
199
|
+
return value.trim();
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const CATALOG_UNAVAILABLE_REASONS = {
|
|
203
|
+
NETWORK: 'network',
|
|
204
|
+
SERVER_ERROR: 'server_error',
|
|
205
|
+
CIRCUIT_OPEN: 'circuit_open',
|
|
206
|
+
INVALID_RESPONSE: 'invalid_response',
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
class CatalogUnavailableError extends CatalogClientError {
|
|
210
|
+
reason;
|
|
211
|
+
constructor(message, reason, httpStatusCodeOrCause) {
|
|
212
|
+
super(message, httpStatusCodeOrCause);
|
|
213
|
+
this.reason = reason;
|
|
214
|
+
this.name = 'CatalogUnavailableError';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Builds the degraded {@link Response} returned when the catalog is
|
|
220
|
+
* unavailable. `internal` and `api` are null because there is no catalog entry
|
|
221
|
+
* to populate them — see `docs/fallback.md`.
|
|
222
|
+
*
|
|
223
|
+
* @param context - The supplier error being resolved
|
|
224
|
+
* @param fallback - The locally resolved token
|
|
225
|
+
* @returns A response carrying the token as `resolvedMessage`
|
|
226
|
+
*/
|
|
227
|
+
const createFallbackResponse = (context, fallback) => ({
|
|
228
|
+
supplier: {
|
|
229
|
+
supplierId: context.supplier,
|
|
230
|
+
domain: context.domain,
|
|
231
|
+
supplierResponseStatusCode: context.code,
|
|
232
|
+
},
|
|
233
|
+
internal: null,
|
|
234
|
+
api: null,
|
|
235
|
+
resolvedLocale: fallback.resolvedLocale,
|
|
236
|
+
resolvedMessage: fallback.token,
|
|
237
|
+
fallbackApplied: true,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Walks an error and its `cause` chain looking for the first value the
|
|
242
|
+
* predicate accepts. Resilience policies wrap the original failure, so the
|
|
243
|
+
* catalog exception is usually nested rather than thrown directly.
|
|
244
|
+
*
|
|
245
|
+
* @param error - The error to inspect, along with its causes
|
|
246
|
+
* @param predicate - Type guard identifying the error being looked for
|
|
247
|
+
* @returns The first matching error, or `undefined` when none matches
|
|
248
|
+
*
|
|
249
|
+
* @example
|
|
250
|
+
* ```typescript
|
|
251
|
+
* const catalogError = findErrorInChain(
|
|
252
|
+
* wrapped,
|
|
253
|
+
* (candidate): candidate is CatalogClientError => candidate instanceof CatalogClientError,
|
|
254
|
+
* )
|
|
255
|
+
* ```
|
|
256
|
+
*/
|
|
257
|
+
const findErrorInChain = (error, predicate) => {
|
|
258
|
+
let current = error;
|
|
259
|
+
// Guards against a cause cycle, which would otherwise loop forever.
|
|
260
|
+
const seen = new Set();
|
|
261
|
+
while (current !== undefined && current !== null && !seen.has(current)) {
|
|
262
|
+
seen.add(current);
|
|
263
|
+
if (predicate(current)) {
|
|
264
|
+
return current;
|
|
265
|
+
}
|
|
266
|
+
if (current instanceof Error && current.cause !== undefined && current.cause !== current) {
|
|
267
|
+
current = current.cause;
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return undefined;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
277
|
+
const DEFAULT_CIRCUIT_BREAKER_WAIT_DURATION_MS = 30_000;
|
|
278
|
+
/** Failure-rate threshold (0–1). Matches Resilience4j's default 50%. */
|
|
279
|
+
const CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD = 0.5;
|
|
280
|
+
const RETRY_INITIAL_INTERVAL_MS = 100;
|
|
281
|
+
const RETRY_BACKOFF_MULTIPLIER = 2;
|
|
282
|
+
const SLIDING_WINDOW_SIZE = 20;
|
|
283
|
+
const MINIMUM_NUMBER_OF_CALLS = 10;
|
|
284
|
+
const findCatalogError = (error) => findErrorInChain(error, (candidate) => candidate instanceof CatalogClientError);
|
|
285
|
+
const isRetryable = (error) => {
|
|
286
|
+
const catalogError = findCatalogError(error);
|
|
287
|
+
if (catalogError === undefined) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
// Java retries on isTransient() || isCredentialError(). CatalogUnavailableError
|
|
291
|
+
// is added explicitly because an unusable 2xx body carries that status, so it
|
|
292
|
+
// is not transient by status alone, yet Java retries it too.
|
|
293
|
+
return catalogError instanceof CatalogUnavailableError
|
|
294
|
+
|| catalogError.isTransient()
|
|
295
|
+
|| catalogError.isCredentialError();
|
|
296
|
+
};
|
|
297
|
+
/**
|
|
298
|
+
* A business response — a supplier code the catalog does not have, a rejected
|
|
299
|
+
* request — says nothing about the service's health, so the breaker ignores it
|
|
300
|
+
* rather than counting it either way.
|
|
301
|
+
*/
|
|
302
|
+
const isBusinessResponse = (error) => {
|
|
303
|
+
const catalogError = findCatalogError(error);
|
|
304
|
+
if (catalogError === undefined || catalogError instanceof CatalogUnavailableError) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
return !catalogError.isTransient();
|
|
308
|
+
};
|
|
309
|
+
const createResiliencePolicies = ({ maxAttempts = DEFAULT_MAX_ATTEMPTS, circuitBreakerOpenDurationMs = DEFAULT_CIRCUIT_BREAKER_WAIT_DURATION_MS, } = {}) => {
|
|
310
|
+
// Known divergence from Java: Resilience4j permits 3 trial calls in the
|
|
311
|
+
// half-open state, cockatiel permits exactly one, so recovery is decided on a
|
|
312
|
+
// single probe. Not configurable in cockatiel.
|
|
313
|
+
const breaker = cockatiel.circuitBreaker(cockatiel.handleWhen((error) => !isBusinessResponse(error)), {
|
|
314
|
+
halfOpenAfter: circuitBreakerOpenDurationMs,
|
|
315
|
+
breaker: new cockatiel.CountBreaker({
|
|
316
|
+
threshold: CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD,
|
|
317
|
+
size: SLIDING_WINDOW_SIZE,
|
|
318
|
+
minimumNumberOfCalls: MINIMUM_NUMBER_OF_CALLS,
|
|
319
|
+
}),
|
|
320
|
+
});
|
|
321
|
+
// cockatiel counts retries; Resilience4j counts total attempts including the
|
|
322
|
+
// first. Convert so maxAttempts means the same to consumers of this library
|
|
323
|
+
// as it does in library-response-catalog-java.
|
|
324
|
+
const maxRetries = Math.max(maxAttempts - 1, 0);
|
|
325
|
+
const retryPolicy = cockatiel.retry(cockatiel.handleWhen(isRetryable), {
|
|
326
|
+
maxAttempts: maxRetries,
|
|
327
|
+
backoff: new cockatiel.ExponentialBackoff({
|
|
328
|
+
initialDelay: RETRY_INITIAL_INTERVAL_MS,
|
|
329
|
+
exponent: RETRY_BACKOFF_MULTIPLIER,
|
|
330
|
+
maxDelay: RETRY_INITIAL_INTERVAL_MS * RETRY_BACKOFF_MULTIPLIER ** maxRetries,
|
|
331
|
+
}),
|
|
332
|
+
});
|
|
333
|
+
return { policy: cockatiel.wrap(retryPolicy, breaker) };
|
|
334
|
+
};
|
|
335
|
+
const isBrokenCircuitError = (error) => error instanceof cockatiel.BrokenCircuitError;
|
|
336
|
+
|
|
337
|
+
const RESOLVE_PATH = '/v1/resolve';
|
|
338
|
+
const RELATIVE_RESOLVE_PATH = RESOLVE_PATH.slice(1);
|
|
339
|
+
const SPAN_NAME = 'adapter.response_catalog.fetch_response';
|
|
340
|
+
const TRACER_NAME = '@cobre-npm/library-response-catalog-node';
|
|
341
|
+
const METER_NAME$1 = '@cobre-npm/library-response-catalog-node';
|
|
342
|
+
const HTTP_CLIENT_DURATION_METRIC = 'http.client.request.duration';
|
|
343
|
+
class CatalogHttpAdapter extends CatalogPort {
|
|
344
|
+
axiosClient;
|
|
345
|
+
baseUrl;
|
|
346
|
+
authHeadersSource;
|
|
347
|
+
fallbackTokenResolver;
|
|
348
|
+
fallbackActivationRecorder;
|
|
349
|
+
resilience;
|
|
350
|
+
httpClientDuration = api.metrics
|
|
351
|
+
.getMeter(METER_NAME$1)
|
|
352
|
+
.createHistogram(HTTP_CLIENT_DURATION_METRIC, {
|
|
353
|
+
description: 'Duration of outbound Response Catalog HTTP calls',
|
|
354
|
+
unit: 'ms',
|
|
355
|
+
});
|
|
356
|
+
constructor(dependencies) {
|
|
357
|
+
super();
|
|
358
|
+
this.axiosClient = dependencies.axiosClient;
|
|
359
|
+
this.baseUrl = dependencies.baseUrl;
|
|
360
|
+
this.authHeadersSource = dependencies.authHeadersSource;
|
|
361
|
+
this.fallbackTokenResolver = dependencies.fallbackTokenResolver;
|
|
362
|
+
this.fallbackActivationRecorder = dependencies.fallbackActivationRecorder;
|
|
363
|
+
this.resilience = dependencies.resilience ?? createResiliencePolicies();
|
|
364
|
+
}
|
|
365
|
+
async fetchResponse(context) {
|
|
366
|
+
const tracer = api.trace.getTracer(TRACER_NAME);
|
|
367
|
+
return tracer.startActiveSpan(SPAN_NAME, { kind: api.SpanKind.CLIENT }, async (span) => {
|
|
368
|
+
try {
|
|
369
|
+
return await this.resilience.policy.execute(async () => this.callCatalog(context));
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
const unavailable = findErrorInChain(error, (candidate) => candidate instanceof CatalogUnavailableError);
|
|
373
|
+
if (unavailable !== undefined) {
|
|
374
|
+
return this.activateFallback(context, unavailable.reason);
|
|
375
|
+
}
|
|
376
|
+
if (isBrokenCircuitError(error)) {
|
|
377
|
+
libraryNodejsTelemetry.log.error({
|
|
378
|
+
message: 'Catalog service circuit breaker is open',
|
|
379
|
+
content: error,
|
|
380
|
+
attrs: this.contextAttributes(context),
|
|
381
|
+
});
|
|
382
|
+
return this.activateFallback(context, CATALOG_UNAVAILABLE_REASONS.CIRCUIT_OPEN);
|
|
383
|
+
}
|
|
384
|
+
const clientError = findErrorInChain(error, (candidate) => candidate instanceof CatalogClientError
|
|
385
|
+
&& !(candidate instanceof CatalogUnavailableError));
|
|
386
|
+
if (clientError !== undefined) {
|
|
387
|
+
throw clientError;
|
|
388
|
+
}
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
finally {
|
|
392
|
+
span.end();
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
async callCatalog(context) {
|
|
397
|
+
const startedAt = process.hrtime.bigint();
|
|
398
|
+
let statusCode;
|
|
399
|
+
try {
|
|
400
|
+
const url = this.buildUrl(context);
|
|
401
|
+
const headers = await this.authHeadersSource.headers();
|
|
402
|
+
const response = await this.axiosClient.get(url, { headers });
|
|
403
|
+
statusCode = response.status;
|
|
404
|
+
this.recordHttpDuration(startedAt, response.status);
|
|
405
|
+
// mapResponseError owns the confirm for error statuses, so the
|
|
406
|
+
// success path is the only other place that reports an outcome. Calling
|
|
407
|
+
// it here as well would double-fire on every 4xx/5xx, because
|
|
408
|
+
// validateStatus lets error responses through instead of throwing.
|
|
409
|
+
if (response.status >= 400) {
|
|
410
|
+
throw this.mapResponseError(context, response.status);
|
|
411
|
+
}
|
|
412
|
+
this.authHeadersSource.confirm(response.status);
|
|
413
|
+
return this.requireBody({
|
|
414
|
+
context,
|
|
415
|
+
response: response.data,
|
|
416
|
+
statusCode: response.status,
|
|
417
|
+
startedAt,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
// validateStatus accepts every status, so axios rejects only when no
|
|
422
|
+
// response arrived at all: a network failure, a timeout, or a body it
|
|
423
|
+
// could not read. Anything carrying a status took the branch above.
|
|
424
|
+
if (statusCode === undefined) {
|
|
425
|
+
this.recordHttpDuration(startedAt, 0);
|
|
426
|
+
}
|
|
427
|
+
// Covers CatalogUnavailableError too, which extends it.
|
|
428
|
+
if (error instanceof CatalogClientError) {
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
if (axios.isAxiosError(error)) {
|
|
432
|
+
throw this.mapNetworkError(context, error);
|
|
433
|
+
}
|
|
434
|
+
throw this.mapUnexpectedError(context, error);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
recordHttpDuration(startedAt, statusCode) {
|
|
438
|
+
const durationMillis = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
|
439
|
+
this.httpClientDuration.record(durationMillis, {
|
|
440
|
+
'url.template': RESOLVE_PATH,
|
|
441
|
+
'http.response.status_code': statusCode,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
requireBody({ context, response, statusCode, startedAt }) {
|
|
445
|
+
if (!this.isUsable(response)) {
|
|
446
|
+
const cause = new Error('response body is empty or missing required fields');
|
|
447
|
+
libraryNodejsTelemetry.log.error({
|
|
448
|
+
message: 'Catalog service returned an unusable response body',
|
|
449
|
+
content: cause,
|
|
450
|
+
attrs: this.contextAttributes(context),
|
|
451
|
+
});
|
|
452
|
+
throw new CatalogUnavailableError(`Catalog service returned an unusable response body for supplier ${context.supplier}`, CATALOG_UNAVAILABLE_REASONS.INVALID_RESPONSE, statusCode);
|
|
453
|
+
}
|
|
454
|
+
this.logSuccess(context, startedAt);
|
|
455
|
+
return response;
|
|
456
|
+
}
|
|
457
|
+
isUsable(response) {
|
|
458
|
+
return response != null
|
|
459
|
+
&& response.supplier != null
|
|
460
|
+
&& response.internal != null
|
|
461
|
+
&& response.api != null
|
|
462
|
+
&& hasText(response.resolvedLocale)
|
|
463
|
+
&& hasText(response.resolvedMessage);
|
|
464
|
+
}
|
|
465
|
+
buildUrl(context) {
|
|
466
|
+
// RESOLVE_PATH is joined as a relative reference so a baseUrl carrying a
|
|
467
|
+
// path prefix (the usual shape behind the internal gateway) keeps it. An
|
|
468
|
+
// absolute '/v1/resolve' would replace the prefix instead of extending it.
|
|
469
|
+
const base = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`;
|
|
470
|
+
const url = new URL(RELATIVE_RESOLVE_PATH, base);
|
|
471
|
+
url.searchParams.set('supplier', context.supplier);
|
|
472
|
+
url.searchParams.set('domain', context.domain);
|
|
473
|
+
url.searchParams.set('code', context.code);
|
|
474
|
+
if (context.locale !== null) {
|
|
475
|
+
url.searchParams.set('locale', context.locale);
|
|
476
|
+
}
|
|
477
|
+
// URLSearchParams serializes a space as '+', which is only equivalent to
|
|
478
|
+
// %20 for servers that apply form decoding. Java percent-encodes it, so
|
|
479
|
+
// match that and keep the query unambiguous.
|
|
480
|
+
url.search = url.searchParams.toString().replace(/\+/g, '%20');
|
|
481
|
+
return url.toString();
|
|
482
|
+
}
|
|
483
|
+
mapResponseError(context, statusCode) {
|
|
484
|
+
this.authHeadersSource.confirm(statusCode);
|
|
485
|
+
const exception = this.catalogErrorForStatus(context, statusCode);
|
|
486
|
+
const attrs = {
|
|
487
|
+
...this.contextAttributes(context),
|
|
488
|
+
'response.status_code': String(statusCode),
|
|
489
|
+
};
|
|
490
|
+
if (exception.isTransient()) {
|
|
491
|
+
libraryNodejsTelemetry.log.error({
|
|
492
|
+
message: 'Catalog service returned an error response',
|
|
493
|
+
attrs,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
else if (exception.isCredentialError()) {
|
|
497
|
+
libraryNodejsTelemetry.log.error({
|
|
498
|
+
message: 'Catalog service rejected the credentials',
|
|
499
|
+
attrs,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
libraryNodejsTelemetry.log.info({
|
|
504
|
+
message: 'Catalog service rejected the request as a client error',
|
|
505
|
+
attrs,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return exception;
|
|
509
|
+
}
|
|
510
|
+
catalogErrorForStatus(context, statusCode) {
|
|
511
|
+
const message = `Catalog service returned status ${statusCode} for supplier ${context.supplier}`;
|
|
512
|
+
if (statusCode >= 500) {
|
|
513
|
+
return new CatalogUnavailableError(message, CATALOG_UNAVAILABLE_REASONS.SERVER_ERROR, statusCode);
|
|
514
|
+
}
|
|
515
|
+
return new CatalogClientError(message, statusCode);
|
|
516
|
+
}
|
|
517
|
+
mapNetworkError(context, error) {
|
|
518
|
+
libraryNodejsTelemetry.log.error({
|
|
519
|
+
message: 'Catalog service could not be reached',
|
|
520
|
+
content: error,
|
|
521
|
+
attrs: this.contextAttributes(context),
|
|
522
|
+
});
|
|
523
|
+
return new CatalogUnavailableError(`Catalog service could not be reached for supplier ${context.supplier}`, CATALOG_UNAVAILABLE_REASONS.NETWORK, error);
|
|
524
|
+
}
|
|
525
|
+
mapUnexpectedError(context, error) {
|
|
526
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
527
|
+
libraryNodejsTelemetry.log.error({
|
|
528
|
+
message: 'Unexpected error calling the catalog service',
|
|
529
|
+
content: cause,
|
|
530
|
+
attrs: this.contextAttributes(context),
|
|
531
|
+
});
|
|
532
|
+
return new CatalogClientError(`Unexpected error calling the catalog service for supplier ${context.supplier}`, cause);
|
|
533
|
+
}
|
|
534
|
+
activateFallback(context, reason) {
|
|
535
|
+
const fallback = this.fallbackTokenResolver.resolve(context);
|
|
536
|
+
this.fallbackActivationRecorder.record(context, fallback, reason);
|
|
537
|
+
return createFallbackResponse(context, fallback);
|
|
538
|
+
}
|
|
539
|
+
logSuccess(context, startedAt) {
|
|
540
|
+
const durationMillis = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
|
541
|
+
libraryNodejsTelemetry.log.info({
|
|
542
|
+
message: 'Resolved response catalog entry',
|
|
543
|
+
attrs: {
|
|
544
|
+
...this.contextAttributes(context),
|
|
545
|
+
'result.duration_ms': String(Math.round(durationMillis)),
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
contextAttributes(context) {
|
|
550
|
+
const attrs = {
|
|
551
|
+
'request.supplier': context.supplier,
|
|
552
|
+
'request.domain': context.domain,
|
|
553
|
+
'request.code': context.code,
|
|
554
|
+
};
|
|
555
|
+
if (context.locale !== null) {
|
|
556
|
+
attrs['request.locale'] = context.locale;
|
|
557
|
+
}
|
|
558
|
+
return attrs;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 2_000;
|
|
563
|
+
const DEFAULT_READ_TIMEOUT_MS = 5_000;
|
|
564
|
+
const createAxiosClient = ({ connectTimeoutMs, readTimeoutMs }) => axios.create({
|
|
565
|
+
timeout: readTimeoutMs,
|
|
566
|
+
validateStatus: () => true,
|
|
567
|
+
transitional: {
|
|
568
|
+
silentJSONParsing: false,
|
|
569
|
+
},
|
|
570
|
+
httpAgent: new http.Agent({ keepAlive: true, timeout: connectTimeoutMs }),
|
|
571
|
+
httpsAgent: new https.Agent({ keepAlive: true, timeout: connectTimeoutMs }),
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
const FALLBACK_MATCHES = {
|
|
575
|
+
EXACT: 'exact',
|
|
576
|
+
DEFAULT: 'default',
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const toFallbackKey = (code, locale) => `${code}::${locale}`;
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Canonicalizes a locale tag, accepting both underscore and hyphen separators.
|
|
583
|
+
*
|
|
584
|
+
* @param locale - Locale tag such as `es_CO` or `es-co`
|
|
585
|
+
* @returns The canonical BCP 47 tag (`es-CO`)
|
|
586
|
+
* @throws Error when the tag is not a structurally valid locale
|
|
587
|
+
*
|
|
588
|
+
* @example
|
|
589
|
+
* ```typescript
|
|
590
|
+
* normalizeLocale('es_CO') // 'es-CO'
|
|
591
|
+
* ```
|
|
592
|
+
*/
|
|
593
|
+
const normalizeLocale = (locale) => {
|
|
594
|
+
const languageTag = locale.replace(/_/g, '-');
|
|
595
|
+
try {
|
|
596
|
+
// getCanonicalLocales returns exactly one tag for a single input and throws
|
|
597
|
+
// RangeError when the tag is not structurally valid, so joining the result
|
|
598
|
+
// yields the canonical form without an unreachable index fallback.
|
|
599
|
+
return Intl.getCanonicalLocales(languageTag).join('');
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
throw new Error(`Invalid locale: ${locale}`);
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* The locale the lookup runs under: the canonicalized context locale, or the
|
|
608
|
+
* map's default when it is absent or not a structurally valid tag.
|
|
609
|
+
*/
|
|
610
|
+
const effectiveLocale = (tokenMap, locale) => {
|
|
611
|
+
if (!hasText(locale)) {
|
|
612
|
+
return tokenMap.defaultLocale;
|
|
613
|
+
}
|
|
614
|
+
try {
|
|
615
|
+
return normalizeLocale(locale);
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
return tokenMap.defaultLocale;
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
/**
|
|
622
|
+
* Binds a validated token map to a resolver. The map is loaded once when the
|
|
623
|
+
* client is created, so resolution is a pure in-memory lookup.
|
|
624
|
+
*
|
|
625
|
+
* @param tokenMap - The validated map to look up in
|
|
626
|
+
* @returns A resolver over that map
|
|
627
|
+
*/
|
|
628
|
+
const createFallbackTokenResolver = (tokenMap) => ({
|
|
629
|
+
resolve: (context) => {
|
|
630
|
+
const locale = effectiveLocale(tokenMap, context.locale);
|
|
631
|
+
const token = tokenMap.tokens.get(toFallbackKey(context.code, locale));
|
|
632
|
+
return token === undefined
|
|
633
|
+
? { token: tokenMap.defaultToken, resolvedLocale: locale, match: FALLBACK_MATCHES.DEFAULT }
|
|
634
|
+
: { token, resolvedLocale: locale, match: FALLBACK_MATCHES.EXACT };
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
var defaultLocale = "en-US";
|
|
639
|
+
var defaultToken = "#UNKNOWN_ERROR#";
|
|
640
|
+
var entries = [
|
|
641
|
+
];
|
|
642
|
+
var bundledFallbackTokens = {
|
|
643
|
+
defaultLocale: defaultLocale,
|
|
644
|
+
defaultToken: defaultToken,
|
|
645
|
+
entries: entries
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const BUNDLE_PREFIX = 'bundle:';
|
|
649
|
+
const FILE_PREFIX = 'file:';
|
|
650
|
+
const TOKEN_PATTERN = /^#[A-Z0-9_]+#$/;
|
|
651
|
+
const BUNDLED_RESOURCE_NAME = 'response-catalog-fallbacks.json';
|
|
652
|
+
const FILE_PROPERTIES = ['defaultLocale', 'defaultToken', 'entries'];
|
|
653
|
+
const ENTRY_PROPERTIES = ['code', 'locale', 'token'];
|
|
654
|
+
const DEFAULT_FALLBACK_TOKENS_LOCATION = `${BUNDLE_PREFIX}${BUNDLED_RESOURCE_NAME}`;
|
|
655
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
656
|
+
/**
|
|
657
|
+
* Rejects properties the schema does not define, so a typo in a consumer's
|
|
658
|
+
* token file fails loudly instead of being silently ignored.
|
|
659
|
+
*/
|
|
660
|
+
const requireKnownProperties = (record, allowed, subject) => {
|
|
661
|
+
const unexpected = Object.keys(record).find((key) => !allowed.includes(key));
|
|
662
|
+
if (unexpected !== undefined) {
|
|
663
|
+
throw new Error(`${subject} contains unknown property '${unexpected}'`);
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
const requireString = (value, fieldName) => {
|
|
667
|
+
if (value !== null && value !== undefined && typeof value !== 'string') {
|
|
668
|
+
throw new Error(`${fieldName} must be a string, got: ${typeof value}`);
|
|
669
|
+
}
|
|
670
|
+
return requireText(value, fieldName);
|
|
671
|
+
};
|
|
672
|
+
const requireToken = (value, fieldName) => {
|
|
673
|
+
const token = requireString(value, fieldName);
|
|
674
|
+
if (!TOKEN_PATTERN.test(token)) {
|
|
675
|
+
throw new Error(`${fieldName} must match ${TOKEN_PATTERN.source}, got: ${token}`);
|
|
676
|
+
}
|
|
677
|
+
return token;
|
|
678
|
+
};
|
|
679
|
+
const requireValidLocale = (value, fieldName) => normalizeLocale(requireString(value, fieldName));
|
|
680
|
+
const requireSupportedLocation = (location) => {
|
|
681
|
+
const value = requireText(location, 'fallbackTokensLocation');
|
|
682
|
+
if (!value.startsWith(BUNDLE_PREFIX) && !value.startsWith(FILE_PREFIX)) {
|
|
683
|
+
throw new Error(`fallbackTokensLocation must use ${BUNDLE_PREFIX} or ${FILE_PREFIX}, got: ${location}`);
|
|
684
|
+
}
|
|
685
|
+
return value;
|
|
686
|
+
};
|
|
687
|
+
/** Reads the raw document. Its shape is unverified until {@link toMap} runs. */
|
|
688
|
+
const readFallbackFile = (location) => {
|
|
689
|
+
const supportedLocation = requireSupportedLocation(location);
|
|
690
|
+
if (supportedLocation.startsWith(BUNDLE_PREFIX)) {
|
|
691
|
+
const resourceName = supportedLocation.slice(BUNDLE_PREFIX.length);
|
|
692
|
+
if (resourceName !== BUNDLED_RESOURCE_NAME) {
|
|
693
|
+
throw new Error(`Only '${DEFAULT_FALLBACK_TOKENS_LOCATION}' is bundled with this package; `
|
|
694
|
+
+ `use '${FILE_PREFIX}/absolute/path.json' for custom files. Got: ${supportedLocation}`);
|
|
695
|
+
}
|
|
696
|
+
return bundledFallbackTokens;
|
|
697
|
+
}
|
|
698
|
+
// Only file: can reach this point: requireSupportedLocation rejects anything
|
|
699
|
+
// else and the bundle: case returned above.
|
|
700
|
+
const filePath = supportedLocation.slice(FILE_PREFIX.length);
|
|
701
|
+
if (!fs.existsSync(filePath)) {
|
|
702
|
+
throw new Error(`Fallback tokens resource not found: ${supportedLocation}`);
|
|
703
|
+
}
|
|
704
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
705
|
+
};
|
|
706
|
+
const toEntryTokens = (entries) => {
|
|
707
|
+
if (!Array.isArray(entries)) {
|
|
708
|
+
throw new Error('Fallback tokens entries must be an array');
|
|
709
|
+
}
|
|
710
|
+
const tokens = new Map();
|
|
711
|
+
for (const entry of entries) {
|
|
712
|
+
if (entry === null || entry === undefined) {
|
|
713
|
+
throw new Error('Fallback tokens entries must not contain null');
|
|
714
|
+
}
|
|
715
|
+
if (!isRecord(entry)) {
|
|
716
|
+
throw new Error('Fallback tokens entries must contain JSON objects');
|
|
717
|
+
}
|
|
718
|
+
requireKnownProperties(entry, ENTRY_PROPERTIES, 'Fallback token entry');
|
|
719
|
+
const code = requireString(entry.code, 'code');
|
|
720
|
+
const locale = requireValidLocale(entry.locale, 'locale');
|
|
721
|
+
const key = toFallbackKey(code, locale);
|
|
722
|
+
if (tokens.has(key)) {
|
|
723
|
+
throw new Error(`Duplicate fallback token entry for code '${code}' and locale '${locale}'`);
|
|
724
|
+
}
|
|
725
|
+
tokens.set(key, requireToken(entry.token, 'token'));
|
|
726
|
+
}
|
|
727
|
+
return tokens;
|
|
728
|
+
};
|
|
729
|
+
/** Validates the unverified document and narrows it to a {@link FallbackTokenMap}. */
|
|
730
|
+
const toMap = (file) => {
|
|
731
|
+
if (file === null || file === undefined) {
|
|
732
|
+
throw new Error('Fallback tokens file must not be empty');
|
|
733
|
+
}
|
|
734
|
+
if (!isRecord(file)) {
|
|
735
|
+
throw new Error(`Fallback tokens file must be a JSON object, got: ${typeof file}`);
|
|
736
|
+
}
|
|
737
|
+
requireKnownProperties(file, FILE_PROPERTIES, 'Fallback tokens file');
|
|
738
|
+
return {
|
|
739
|
+
defaultLocale: requireValidLocale(file.defaultLocale, 'defaultLocale'),
|
|
740
|
+
defaultToken: requireToken(file.defaultToken, 'defaultToken'),
|
|
741
|
+
tokens: toEntryTokens(file.entries ?? []),
|
|
742
|
+
};
|
|
743
|
+
};
|
|
744
|
+
const loadFallbackTokenMap = (location) => {
|
|
745
|
+
try {
|
|
746
|
+
return toMap(readFallbackFile(location));
|
|
747
|
+
}
|
|
748
|
+
catch (error) {
|
|
749
|
+
if (error instanceof SyntaxError) {
|
|
750
|
+
throw new Error(`Fallback tokens file is not valid JSON: ${location}`, { cause: error });
|
|
751
|
+
}
|
|
752
|
+
throw error;
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
const METRIC_NAME = 'response_catalog.fallback.activations';
|
|
757
|
+
const METRIC_UNIT = '{activation}';
|
|
758
|
+
const LOG_MESSAGE = 'Response catalog local fallback activated';
|
|
759
|
+
const METER_NAME = '@cobre-npm/library-response-catalog-node';
|
|
760
|
+
const activationAttributes = (context, fallback, reason) => {
|
|
761
|
+
const attrs = {
|
|
762
|
+
'request.supplier': context.supplier,
|
|
763
|
+
'request.domain': context.domain,
|
|
764
|
+
'request.code': context.code,
|
|
765
|
+
'fallback.resolved_locale': fallback.resolvedLocale,
|
|
766
|
+
'fallback.token': fallback.token,
|
|
767
|
+
'fallback.match': fallback.match,
|
|
768
|
+
'failure.reason': reason,
|
|
769
|
+
};
|
|
770
|
+
if (context.locale !== null) {
|
|
771
|
+
attrs['request.locale'] = context.locale;
|
|
772
|
+
}
|
|
773
|
+
return attrs;
|
|
774
|
+
};
|
|
775
|
+
/**
|
|
776
|
+
* Records fallback activations as an OpenTelemetry counter plus a warning log.
|
|
777
|
+
* The counter is created once, when the recorder is built, and closed over.
|
|
778
|
+
*
|
|
779
|
+
* @returns A recorder wired to the process meter provider
|
|
780
|
+
*/
|
|
781
|
+
const createTelemetryFallbackActivationRecorder = () => {
|
|
782
|
+
const counter = api.metrics.getMeter(METER_NAME).createCounter(METRIC_NAME, {
|
|
783
|
+
description: 'Local fallback activations after response catalog technical unavailability',
|
|
784
|
+
unit: METRIC_UNIT,
|
|
785
|
+
});
|
|
786
|
+
return {
|
|
787
|
+
record: (context, fallback, reason) => {
|
|
788
|
+
libraryNodejsTelemetry.log.warning({
|
|
789
|
+
message: LOG_MESSAGE,
|
|
790
|
+
attrs: activationAttributes(context, fallback, reason),
|
|
791
|
+
});
|
|
792
|
+
counter.add(1, {
|
|
793
|
+
'failure.reason': reason,
|
|
794
|
+
'fallback.match': fallback.match,
|
|
795
|
+
});
|
|
796
|
+
},
|
|
797
|
+
};
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Rejects a `baseUrl` that is not an absolute HTTP(S) URL, so a typo or a
|
|
802
|
+
* missing scheme fails when the client is created rather than surfacing later
|
|
803
|
+
* disguised as a network failure.
|
|
804
|
+
*
|
|
805
|
+
* Stricter than the Java library, which accepts any absolute URI: only `http:`
|
|
806
|
+
* and `https:` can reach the catalog over this adapter.
|
|
807
|
+
*
|
|
808
|
+
* @param baseUrl - The configured base URL
|
|
809
|
+
* @throws Error when the value is unparseable or uses another scheme
|
|
810
|
+
*/
|
|
811
|
+
const requireAbsoluteBaseUrl = (baseUrl) => {
|
|
812
|
+
let uri;
|
|
813
|
+
try {
|
|
814
|
+
uri = new URL(baseUrl);
|
|
815
|
+
}
|
|
816
|
+
catch (error) {
|
|
817
|
+
throw new Error(`baseUrl is not a valid URI: ${baseUrl}`, { cause: error });
|
|
818
|
+
}
|
|
819
|
+
if (uri.protocol !== 'http:' && uri.protocol !== 'https:') {
|
|
820
|
+
throw new Error('baseUrl must be an absolute URI with a scheme '
|
|
821
|
+
+ `(e.g. http://... or https://...), got: ${baseUrl}`);
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
const buildCatalogClientSettings = (settings) => ({
|
|
826
|
+
baseUrl: settings.baseUrl,
|
|
827
|
+
connectTimeoutMs: settings.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
|
828
|
+
readTimeoutMs: settings.readTimeoutMs ?? DEFAULT_READ_TIMEOUT_MS,
|
|
829
|
+
maxAttempts: settings.maxAttempts && settings.maxAttempts > 0
|
|
830
|
+
? settings.maxAttempts
|
|
831
|
+
: DEFAULT_MAX_ATTEMPTS,
|
|
832
|
+
fallbackTokensLocation: settings.fallbackTokensLocation?.trim()
|
|
833
|
+
? settings.fallbackTokensLocation
|
|
834
|
+
: DEFAULT_FALLBACK_TOKENS_LOCATION,
|
|
835
|
+
});
|
|
836
|
+
const createCatalogHttpAdapter = (settings, authHeadersSource) => {
|
|
837
|
+
const resolvedSettings = buildCatalogClientSettings(settings);
|
|
838
|
+
requireAbsoluteBaseUrl(resolvedSettings.baseUrl);
|
|
839
|
+
let tokenMap;
|
|
840
|
+
try {
|
|
841
|
+
tokenMap = loadFallbackTokenMap(resolvedSettings.fallbackTokensLocation);
|
|
842
|
+
}
|
|
843
|
+
catch (error) {
|
|
844
|
+
throw new Error(`Failed to load response catalog fallback tokens from ${resolvedSettings.fallbackTokensLocation}`, { cause: error });
|
|
845
|
+
}
|
|
846
|
+
return new CatalogHttpAdapter({
|
|
847
|
+
axiosClient: createAxiosClient({
|
|
848
|
+
connectTimeoutMs: resolvedSettings.connectTimeoutMs,
|
|
849
|
+
readTimeoutMs: resolvedSettings.readTimeoutMs,
|
|
850
|
+
}),
|
|
851
|
+
baseUrl: resolvedSettings.baseUrl,
|
|
852
|
+
authHeadersSource,
|
|
853
|
+
fallbackTokenResolver: createFallbackTokenResolver(tokenMap),
|
|
854
|
+
fallbackActivationRecorder: createTelemetryFallbackActivationRecorder(),
|
|
855
|
+
resilience: createResiliencePolicies({ maxAttempts: resolvedSettings.maxAttempts }),
|
|
856
|
+
});
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
const requireSecretName = (options) => {
|
|
860
|
+
if (!hasText(options.secretName)) {
|
|
861
|
+
throw new Error('secretName must be configured when mode is INTERNAL_GATEWAY');
|
|
862
|
+
}
|
|
863
|
+
return options.secretName.trim();
|
|
864
|
+
};
|
|
865
|
+
const resolveAuthHeadersSource = async (options) => {
|
|
866
|
+
if (options.authHeadersSource !== undefined) {
|
|
867
|
+
return options.authHeadersSource;
|
|
868
|
+
}
|
|
869
|
+
const mode = options.mode ?? CONNECTION_MODES.CLUSTER_DNS;
|
|
870
|
+
if (mode === CONNECTION_MODES.CLUSTER_DNS) {
|
|
871
|
+
return noAuthHeadersSource();
|
|
872
|
+
}
|
|
873
|
+
return createInternalGatewayAuthHeadersSource({
|
|
874
|
+
secretName: requireSecretName(options),
|
|
875
|
+
authManagerDomainURL: options.authManagerDomainURL,
|
|
876
|
+
secretAdapterRegion: options.secretAdapterRegion,
|
|
877
|
+
});
|
|
878
|
+
};
|
|
879
|
+
const createInternalGatewayAuthHeadersSource = async (options) => {
|
|
880
|
+
let getInternalApiHeaders;
|
|
881
|
+
try {
|
|
882
|
+
// Deep import on purpose: library-nodejs-common@3.x declares `main: index.js`,
|
|
883
|
+
// a file it does not ship, so requiring the package root fails. dist/index.js
|
|
884
|
+
// is the real bundle and the only entry that resolves at runtime. Its types
|
|
885
|
+
// come from src/types/optional-peers.d.ts, so no assertion is needed here.
|
|
886
|
+
({ getInternalApiHeaders } = await import('@cobre-npm/library-nodejs-common/dist/index.js'));
|
|
887
|
+
}
|
|
888
|
+
catch (error) {
|
|
889
|
+
throw new Error('mode is INTERNAL_GATEWAY but @cobre-npm/library-nodejs-common could not be loaded; '
|
|
890
|
+
+ 'install it to use this mode. See the cause for the underlying resolution error.', { cause: error });
|
|
891
|
+
}
|
|
892
|
+
const secretAdapterRegion = options.secretAdapterRegion
|
|
893
|
+
?? process.env.AWS_REGION
|
|
894
|
+
?? 'us-east-1';
|
|
895
|
+
if (!hasText(options.authManagerDomainURL)) {
|
|
896
|
+
throw new Error('authManagerDomainURL must be configured when mode is INTERNAL_GATEWAY');
|
|
897
|
+
}
|
|
898
|
+
return new InternalGatewayAuthHeadersSource({
|
|
899
|
+
secretName: options.secretName,
|
|
900
|
+
authManagerBaseURL: options.authManagerDomainURL.trim(),
|
|
901
|
+
secretAdapterRegion,
|
|
902
|
+
fetchHeaders: getInternalApiHeaders,
|
|
903
|
+
});
|
|
904
|
+
};
|
|
905
|
+
const validateOptions = (options) => {
|
|
906
|
+
if (!hasText(options.baseUrl)) {
|
|
907
|
+
throw new Error('baseUrl must be configured');
|
|
908
|
+
}
|
|
909
|
+
try {
|
|
910
|
+
requireAbsoluteBaseUrl(options.baseUrl);
|
|
911
|
+
}
|
|
912
|
+
catch (error) {
|
|
913
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
914
|
+
throw new Error(`baseUrl is malformed: ${reason}`, { cause: error });
|
|
915
|
+
}
|
|
916
|
+
const mode = options.mode ?? CONNECTION_MODES.CLUSTER_DNS;
|
|
917
|
+
if (mode === CONNECTION_MODES.INTERNAL_GATEWAY && !options.authHeadersSource) {
|
|
918
|
+
requireSecretName(options);
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
/**
|
|
922
|
+
* Preferred entry point. Resolves INTERNAL_GATEWAY credentials via
|
|
923
|
+
* `@cobre-npm/library-nodejs-common` when needed.
|
|
924
|
+
*/
|
|
925
|
+
const createCatalogClient = async (options) => {
|
|
926
|
+
validateOptions(options);
|
|
927
|
+
const authHeadersSource = await resolveAuthHeadersSource(options);
|
|
928
|
+
return createCatalogHttpAdapter({
|
|
929
|
+
baseUrl: options.baseUrl,
|
|
930
|
+
connectTimeoutMs: options.connectTimeoutMs,
|
|
931
|
+
readTimeoutMs: options.readTimeoutMs,
|
|
932
|
+
maxAttempts: options.maxAttempts,
|
|
933
|
+
fallbackTokensLocation: options.fallbackTokensLocation,
|
|
934
|
+
}, authHeadersSource);
|
|
935
|
+
};
|
|
936
|
+
/**
|
|
937
|
+
* Sync factory for CLUSTER_DNS or when you already have an `authHeadersSource`.
|
|
938
|
+
* Cannot auto-resolve INTERNAL_GATEWAY credentials — use `createCatalogClient` instead.
|
|
939
|
+
*/
|
|
940
|
+
const createCatalogClientSync = (options) => {
|
|
941
|
+
validateOptions(options);
|
|
942
|
+
const mode = options.mode ?? CONNECTION_MODES.CLUSTER_DNS;
|
|
943
|
+
if (mode === CONNECTION_MODES.INTERNAL_GATEWAY && !options.authHeadersSource) {
|
|
944
|
+
throw new Error('createCatalogClientSync cannot resolve INTERNAL_GATEWAY auth; pass authHeadersSource or use createCatalogClient');
|
|
945
|
+
}
|
|
946
|
+
const authHeadersSource = options.authHeadersSource ?? noAuthHeadersSource();
|
|
947
|
+
return createCatalogHttpAdapter({
|
|
948
|
+
baseUrl: options.baseUrl,
|
|
949
|
+
connectTimeoutMs: options.connectTimeoutMs,
|
|
950
|
+
readTimeoutMs: options.readTimeoutMs,
|
|
951
|
+
maxAttempts: options.maxAttempts,
|
|
952
|
+
fallbackTokensLocation: options.fallbackTokensLocation,
|
|
953
|
+
}, authHeadersSource);
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Validates and builds a {@link SupplierErrorContext} from raw input.
|
|
958
|
+
*
|
|
959
|
+
* @param input - Supplier, domain, error code, and optional locale
|
|
960
|
+
* @returns A `SupplierErrorContext` with `locale` normalized to `null` when omitted
|
|
961
|
+
* @throws {Error} When `supplier`, `domain`, or `code` is `null`, `undefined`, or blank
|
|
962
|
+
* @example
|
|
963
|
+
* ```typescript
|
|
964
|
+
* const context = buildSupplierErrorContext({
|
|
965
|
+
* supplier: 'nequi',
|
|
966
|
+
* domain: 'wallets',
|
|
967
|
+
* code: '58',
|
|
968
|
+
* locale: 'es-CO',
|
|
969
|
+
* })
|
|
970
|
+
* ```
|
|
971
|
+
*/
|
|
972
|
+
const buildSupplierErrorContext = (input) => ({
|
|
973
|
+
supplier: requireText(input.supplier, 'supplier'),
|
|
974
|
+
domain: requireText(input.domain, 'domain'),
|
|
975
|
+
code: requireText(input.code, 'code'),
|
|
976
|
+
locale: hasText(input.locale) ? input.locale.trim() : null,
|
|
977
|
+
});
|
|
978
|
+
|
|
3
979
|
/**
|
|
4
980
|
* @module @cobre-npm/library-response-catalog-node
|
|
5
981
|
*
|
|
@@ -8,8 +984,6 @@
|
|
|
8
984
|
* or `createCatalogClientSync`, then call `fetchResponse` with a
|
|
9
985
|
* `SupplierErrorContext` from `buildSupplierErrorContext`.
|
|
10
986
|
*/
|
|
11
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.noAuthHeadersSource = exports.CONNECTION_MODES = exports.CatalogClientError = exports.buildSupplierErrorContext = exports.CatalogPort = exports.LIBRARY_VERSION = void 0;
|
|
13
987
|
/**
|
|
14
988
|
* Published package version. Matches `package.json`.
|
|
15
989
|
*
|
|
@@ -21,16 +995,17 @@ exports.noAuthHeadersSource = exports.CONNECTION_MODES = exports.CatalogClientEr
|
|
|
21
995
|
* ```
|
|
22
996
|
*
|
|
23
997
|
* @see {@link https://github.com/Cobre-Colombia/library-response-catalog-node/blob/trunk/CHANGELOG.md | CHANGELOG}
|
|
998
|
+
* @see {@link https://github.com/Cobre-Colombia/library-response-catalog-node/blob/trunk/docs/releases/01-scaffold.md | Scaffold notes}
|
|
24
999
|
*/
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
1000
|
+
const LIBRARY_VERSION = '0.3.0';
|
|
1001
|
+
|
|
1002
|
+
exports.CONNECTION_MODES = CONNECTION_MODES;
|
|
1003
|
+
exports.CatalogClientError = CatalogClientError;
|
|
1004
|
+
exports.CatalogPort = CatalogPort;
|
|
1005
|
+
exports.DEFAULT_FALLBACK_TOKENS_LOCATION = DEFAULT_FALLBACK_TOKENS_LOCATION;
|
|
1006
|
+
exports.LIBRARY_VERSION = LIBRARY_VERSION;
|
|
1007
|
+
exports.buildSupplierErrorContext = buildSupplierErrorContext;
|
|
1008
|
+
exports.createCatalogClient = createCatalogClient;
|
|
1009
|
+
exports.createCatalogClientSync = createCatalogClientSync;
|
|
1010
|
+
exports.noAuthHeadersSource = noAuthHeadersSource;
|
|
36
1011
|
//# sourceMappingURL=index.js.map
|