@financial-times/content-curation-client 5.1.0 → 5.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/dist/index.js CHANGED
@@ -1,3 +1,195 @@
1
+ // src/errors.ts
2
+ var ApiClientError = class extends Error {
3
+ /** The machine-readable error payload exposed by the client. */
4
+ payload;
5
+ /** The machine-readable error code. */
6
+ code;
7
+ /** Additional diagnostic detail captured by the client. */
8
+ details;
9
+ /** The API path associated with the failing request. */
10
+ path;
11
+ /** Optional timestamp supplied by the upstream service. */
12
+ timestamp;
13
+ /** Indicates whether the failure originated from transport, gateway, or service handling. */
14
+ origin;
15
+ /** The fully qualified request URL, when available. */
16
+ url;
17
+ /** The response content type, when a response was received. */
18
+ contentType;
19
+ /** A truncated copy of the raw response body, when captured. */
20
+ bodyText;
21
+ /** The upstream request identifier, when available. */
22
+ requestId;
23
+ /** The HTTP status code reported by the service or gateway. */
24
+ statusCode;
25
+ /** The underlying thrown error, if there was one. */
26
+ cause;
27
+ constructor(payload, statusCode, requestId, metadata = { origin: "service" }) {
28
+ super(payload.message);
29
+ this.name = new.target.name;
30
+ this.payload = payload;
31
+ this.statusCode = statusCode;
32
+ this.requestId = requestId;
33
+ this.code = payload.code;
34
+ this.details = payload.details;
35
+ this.path = payload.path;
36
+ this.timestamp = payload.timestamp;
37
+ this.origin = metadata.origin;
38
+ this.url = metadata.url;
39
+ this.contentType = metadata.contentType;
40
+ this.bodyText = metadata.bodyText;
41
+ this.cause = metadata.cause;
42
+ }
43
+ };
44
+ var ApiTransportError = class extends ApiClientError {
45
+ payload;
46
+ origin;
47
+ /** Whether the failure was caused by an abort, timeout, or generic network problem. */
48
+ transportKind;
49
+ constructor(payload, statusCode, requestId, metadata) {
50
+ super(payload, statusCode, requestId, {
51
+ ...metadata,
52
+ origin: "transport"
53
+ });
54
+ this.payload = payload;
55
+ this.origin = "transport";
56
+ this.transportKind = metadata.transportKind;
57
+ }
58
+ };
59
+ var ApiGatewayError = class extends ApiClientError {
60
+ payload;
61
+ origin;
62
+ constructor(payload, statusCode, requestId, metadata = {}) {
63
+ super(payload, statusCode, requestId, {
64
+ ...metadata,
65
+ origin: "gateway"
66
+ });
67
+ this.payload = payload;
68
+ this.origin = "gateway";
69
+ }
70
+ };
71
+ var ApiServiceError = class extends ApiClientError {
72
+ payload;
73
+ origin;
74
+ constructor(payload, statusCode, requestId, metadata = {}) {
75
+ super(payload, statusCode, requestId, {
76
+ ...metadata,
77
+ origin: "service"
78
+ });
79
+ this.payload = payload;
80
+ this.origin = "service";
81
+ }
82
+ };
83
+
84
+ // src/internal/error-factories.ts
85
+ function createTransportError(path, url, error) {
86
+ const transportKind = classifyTransportFailure(error);
87
+ const message = describeTransportFailure(transportKind);
88
+ const causeDetails = getErrorDiagnosticMessage(error);
89
+ const details = causeDetails ? `${message} Cause: ${causeDetails}` : message;
90
+ return new ApiTransportError(
91
+ {
92
+ code: "API_TRANSPORT_ERROR",
93
+ message,
94
+ details,
95
+ path
96
+ },
97
+ 0,
98
+ void 0,
99
+ {
100
+ transportKind,
101
+ url,
102
+ cause: error
103
+ }
104
+ );
105
+ }
106
+ function createGatewayError(path, url, statusCode, options) {
107
+ return new ApiGatewayError(
108
+ {
109
+ code: "API_GATEWAY_ERROR",
110
+ message: `The API gateway responded with status ${statusCode}.`,
111
+ details: options.details,
112
+ path
113
+ },
114
+ statusCode,
115
+ options.requestId,
116
+ {
117
+ url,
118
+ contentType: options.contentType,
119
+ bodyText: options.bodyText,
120
+ cause: options.cause
121
+ }
122
+ );
123
+ }
124
+ function createUnexpectedServiceResponseError(path, url, options) {
125
+ return new ApiServiceError(
126
+ {
127
+ code: "INTERNAL_SERVER_ERROR",
128
+ message: "Unexpected API response format",
129
+ details: options.details,
130
+ path
131
+ },
132
+ 500,
133
+ options.requestId,
134
+ {
135
+ url,
136
+ contentType: options.contentType,
137
+ bodyText: options.bodyText,
138
+ cause: options.cause
139
+ }
140
+ );
141
+ }
142
+ function classifyTransportFailure(error) {
143
+ const errorName = readStringProperty(error, "name");
144
+ if (errorName === "AbortError") {
145
+ return "abort";
146
+ }
147
+ if (errorName === "TimeoutError") {
148
+ return "timeout";
149
+ }
150
+ if (errorName === "FetchError" && readStringProperty(error, "type") === "request-timeout") {
151
+ return "timeout";
152
+ }
153
+ return "network";
154
+ }
155
+ function describeTransportFailure(transportKind) {
156
+ if (transportKind === "abort") {
157
+ return "The request was aborted before a response was received.";
158
+ }
159
+ if (transportKind === "timeout") {
160
+ return "The request timed out before a response was received.";
161
+ }
162
+ return "The request failed before a response was received.";
163
+ }
164
+ function getErrorDiagnosticMessage(error) {
165
+ const diagnosticParts = [
166
+ readCauseCode(error),
167
+ readStringProperty(error, "name"),
168
+ readStringProperty(error, "message")
169
+ ].filter(
170
+ (value, index, values) => Boolean(value) && values.indexOf(value) === index
171
+ );
172
+ return diagnosticParts.length > 0 ? diagnosticParts.join(": ") : void 0;
173
+ }
174
+ function readCauseCode(error) {
175
+ if (typeof error !== "object" || error === null) {
176
+ return void 0;
177
+ }
178
+ const directCode = readStringProperty(error, "code");
179
+ if (directCode) {
180
+ return directCode;
181
+ }
182
+ const cause = error.cause;
183
+ return readStringProperty(cause, "code");
184
+ }
185
+ function readStringProperty(error, property) {
186
+ if (typeof error !== "object" || error === null) {
187
+ return void 0;
188
+ }
189
+ const value = error[property];
190
+ return typeof value === "string" && value.length > 0 ? value : void 0;
191
+ }
192
+
1
193
  // src/schemas/response.ts
2
194
  import { z } from "zod";
3
195
  var ApiErrorCode = z.enum([
@@ -33,24 +225,242 @@ function ApiResponseSchema(dataSchema) {
33
225
  return z.discriminatedUnion("status", [successSchema, errorSchema]);
34
226
  }
35
227
 
36
- // src/base.ts
37
- var ApiClientError = class extends Error {
38
- constructor(payload, statusCode, requestId) {
39
- super(payload.message);
40
- this.payload = payload;
41
- this.statusCode = statusCode;
42
- this.requestId = requestId;
43
- this.name = "ApiError";
44
- this.code = payload.code;
45
- this.details = payload.details;
46
- this.path = payload.path;
47
- this.timestamp = payload.timestamp;
228
+ // src/internal/response-handling.ts
229
+ var GATEWAY_STATUS_CODES = /* @__PURE__ */ new Set([499, 502, 503]);
230
+ var MAX_DIAGNOSTIC_BODY_LENGTH = 2e3;
231
+ function isGatewayStatusCode(statusCode) {
232
+ return GATEWAY_STATUS_CODES.has(statusCode);
233
+ }
234
+ function getResponseContentType(response) {
235
+ return response.headers?.get("content-type") ?? void 0;
236
+ }
237
+ function getResponseRequestId(response) {
238
+ return response.headers?.get("x-request-id") ?? response.headers?.get("request-id") ?? void 0;
239
+ }
240
+ async function readResponseBody(response) {
241
+ const contentType = getResponseContentType(response);
242
+ let rawBody = "";
243
+ try {
244
+ rawBody = await response.text();
245
+ } catch (error) {
246
+ await discardResponseBody(response);
247
+ throw error;
48
248
  }
49
- code;
50
- details;
51
- path;
52
- timestamp;
53
- };
249
+ const bodyText = truncateBody(rawBody);
250
+ if (!isJsonContentType(contentType) || rawBody.length === 0) {
251
+ return {
252
+ contentType,
253
+ bodyText
254
+ };
255
+ }
256
+ try {
257
+ return {
258
+ contentType,
259
+ bodyText,
260
+ json: JSON.parse(rawBody)
261
+ };
262
+ } catch (error) {
263
+ return {
264
+ contentType,
265
+ bodyText,
266
+ jsonParseError: toError(error)
267
+ };
268
+ }
269
+ }
270
+ function parseApiResponse(responseDataSchema, responseBody) {
271
+ if (responseBody.json === void 0) {
272
+ return null;
273
+ }
274
+ const parsedResponse = ApiResponseSchema(responseDataSchema).safeParse(responseBody.json);
275
+ return parsedResponse.success ? parsedResponse.data : null;
276
+ }
277
+ function parseApiErrorResponse(responseBody) {
278
+ if (responseBody.json === void 0) {
279
+ return null;
280
+ }
281
+ const parsedError = ApiErrorResponseSchema.safeParse(responseBody.json);
282
+ return parsedError.success ? parsedError.data : null;
283
+ }
284
+ function describeUnexpectedSuccessResponse(responseBody) {
285
+ if (responseBody.jsonParseError) {
286
+ return buildDiagnosticDetails(
287
+ "The service reported a JSON response but the body could not be parsed as JSON.",
288
+ responseBody
289
+ );
290
+ }
291
+ if (!responseBody.contentType || !isJsonContentType(responseBody.contentType)) {
292
+ return buildDiagnosticDetails(
293
+ "The service returned a success status without a JSON response body.",
294
+ responseBody
295
+ );
296
+ }
297
+ return buildDiagnosticDetails(
298
+ "The service returned JSON, but it did not match the expected Content Curation API envelope.",
299
+ responseBody
300
+ );
301
+ }
302
+ function describeGatewayResponse(statusCode, responseBody) {
303
+ if (responseBody.jsonParseError) {
304
+ return buildDiagnosticDetails(
305
+ `The non-success response with status ${statusCode} declared a JSON content type, but its body could not be parsed as JSON.`,
306
+ responseBody
307
+ );
308
+ }
309
+ if (!responseBody.contentType || !isJsonContentType(responseBody.contentType)) {
310
+ return buildDiagnosticDetails(
311
+ `The non-success response with status ${statusCode} was not a valid service JSON error envelope.`,
312
+ responseBody
313
+ );
314
+ }
315
+ return buildDiagnosticDetails(
316
+ `The non-success response with status ${statusCode} returned JSON, but it did not match the service error envelope.`,
317
+ responseBody
318
+ );
319
+ }
320
+ async function discardResponseBody(response) {
321
+ const body = response.body;
322
+ if (!body) {
323
+ return;
324
+ }
325
+ if (typeof body.cancel === "function") {
326
+ try {
327
+ await body.cancel();
328
+ return;
329
+ } catch {
330
+ }
331
+ }
332
+ if (typeof body.resume === "function" && typeof body.on === "function") {
333
+ await new Promise((resolve) => {
334
+ const stream = body;
335
+ const finish = () => resolve();
336
+ stream.on("end", finish);
337
+ stream.on("error", finish);
338
+ stream.resume();
339
+ });
340
+ }
341
+ }
342
+ function buildDiagnosticDetails(message, responseBody) {
343
+ const detailParts = [message];
344
+ if (responseBody.contentType) {
345
+ detailParts.push(`Content-Type: ${responseBody.contentType}.`);
346
+ }
347
+ if (responseBody.jsonParseError) {
348
+ detailParts.push(`JSON parse error: ${responseBody.jsonParseError.message}.`);
349
+ }
350
+ if (responseBody.bodyText) {
351
+ detailParts.push("The raw response body is available in bodyText.");
352
+ }
353
+ return detailParts.join(" ");
354
+ }
355
+ function isJsonContentType(contentType) {
356
+ if (!contentType) {
357
+ return false;
358
+ }
359
+ const mimeType = contentType.split(";")[0]?.trim().toLowerCase();
360
+ if (!mimeType) {
361
+ return false;
362
+ }
363
+ return mimeType === "application/json" || mimeType.endsWith("+json");
364
+ }
365
+ function truncateBody(bodyText) {
366
+ if (bodyText.length === 0) {
367
+ return void 0;
368
+ }
369
+ if (bodyText.length <= MAX_DIAGNOSTIC_BODY_LENGTH) {
370
+ return bodyText;
371
+ }
372
+ return `${bodyText.slice(0, MAX_DIAGNOSTIC_BODY_LENGTH - 3)}...`;
373
+ }
374
+ function toError(error) {
375
+ if (error instanceof Error) {
376
+ return error;
377
+ }
378
+ const message = typeof error === "string" && error.length > 0 ? error : "Unknown error";
379
+ return new Error(message);
380
+ }
381
+
382
+ // src/internal/request-lifecycle.ts
383
+ function parseRequestBody(requestSchema, body) {
384
+ if (!requestSchema || body === void 0) {
385
+ return body;
386
+ }
387
+ return requestSchema.parse(body);
388
+ }
389
+ async function readApiResponseBody(path, url, response) {
390
+ try {
391
+ return await readResponseBody(response);
392
+ } catch (error) {
393
+ if (response.ok) {
394
+ throw createUnexpectedServiceResponseError(path, url, {
395
+ contentType: getResponseContentType(response),
396
+ cause: error,
397
+ details: "Failed to read the response body before the API response could be validated.",
398
+ requestId: getResponseRequestId(response)
399
+ });
400
+ }
401
+ throw createGatewayError(path, url, response.status, {
402
+ contentType: getResponseContentType(response),
403
+ cause: error,
404
+ details: "Failed to read the non-success response body, so the response could not be validated as a service error.",
405
+ requestId: getResponseRequestId(response)
406
+ });
407
+ }
408
+ }
409
+ function parseSuccessfulResponse(path, url, response, responseBody, responseDataSchema) {
410
+ const parsedResponse = parseApiResponse(responseDataSchema, responseBody);
411
+ if (!parsedResponse) {
412
+ throw createUnexpectedServiceResponseError(path, url, {
413
+ contentType: responseBody.contentType,
414
+ bodyText: responseBody.bodyText,
415
+ details: describeUnexpectedSuccessResponse(responseBody),
416
+ requestId: getResponseRequestId(response)
417
+ });
418
+ }
419
+ if ("error" in parsedResponse) {
420
+ throw new ApiServiceError(
421
+ parsedResponse.error,
422
+ parsedResponse.statusCode,
423
+ parsedResponse.requestId,
424
+ {
425
+ url,
426
+ contentType: responseBody.contentType,
427
+ bodyText: responseBody.bodyText
428
+ }
429
+ );
430
+ }
431
+ return parsedResponse.data;
432
+ }
433
+ function createResponseError(path, url, response, responseBody) {
434
+ if (isGatewayStatusCode(response.status)) {
435
+ return createGatewayError(path, url, response.status, {
436
+ contentType: responseBody.contentType,
437
+ bodyText: responseBody.bodyText,
438
+ details: describeGatewayResponse(response.status, responseBody),
439
+ requestId: getResponseRequestId(response)
440
+ });
441
+ }
442
+ const parsedServiceError = parseApiErrorResponse(responseBody);
443
+ if (parsedServiceError) {
444
+ return new ApiServiceError(
445
+ parsedServiceError.error,
446
+ parsedServiceError.statusCode,
447
+ parsedServiceError.requestId,
448
+ {
449
+ url,
450
+ contentType: responseBody.contentType,
451
+ bodyText: responseBody.bodyText
452
+ }
453
+ );
454
+ }
455
+ return createGatewayError(path, url, response.status, {
456
+ contentType: responseBody.contentType,
457
+ bodyText: responseBody.bodyText,
458
+ details: describeGatewayResponse(response.status, responseBody),
459
+ requestId: getResponseRequestId(response)
460
+ });
461
+ }
462
+
463
+ // src/base.ts
54
464
  var BaseApiClient = class {
55
465
  config;
56
466
  constructor(config) {
@@ -58,55 +468,60 @@ var BaseApiClient = class {
58
468
  ...config,
59
469
  fetch: config.fetch || fetch
60
470
  };
61
- this.config.baseUrl = this.config.baseUrl.replace(/\/+$/, "");
471
+ this.config.baseUrl = trimTrailingSlashes(this.config.baseUrl);
62
472
  }
63
473
  /**
64
- * Generic HTTP request to a JSON API
474
+ * Performs a request against the JSON API, validates the response envelope,
475
+ * and returns the typed `data` payload on success.
65
476
  */
66
477
  async request(method, path, opts) {
67
- let bodyPayload = opts.body;
68
- if (opts.requestSchema && opts.body !== void 0) {
69
- bodyPayload = opts.requestSchema.parse(opts.body);
70
- }
71
- const url = new URL(this.config.baseUrl + path);
72
- const response = await this.config.fetch(url.toString(), {
73
- method,
74
- headers: {
75
- "Content-Type": "application/json",
76
- ...this.config.apiKey ? { "x-api-key": this.config.apiKey } : { Authorization: `Bearer ${this.config.apiToken}` }
77
- },
78
- body: bodyPayload ? JSON.stringify(bodyPayload) : void 0
79
- });
80
- const json = await response.json();
81
- const parsedJson = ApiResponseSchema(opts.responseDataSchema).parse(json);
82
- if ("error" in parsedJson) {
83
- throw new ApiClientError(parsedJson.error, parsedJson.statusCode, parsedJson.requestId);
84
- } else if (!("data" in parsedJson)) {
85
- throw new ApiClientError(
86
- {
87
- code: "INTERNAL_SERVER_ERROR",
88
- message: "Unexpected API response format",
89
- details: 'Response did not contain expected "data" field'
478
+ const bodyPayload = parseRequestBody(opts.requestSchema, opts.body);
479
+ const urlString = new URL(this.config.baseUrl + path).toString();
480
+ let response;
481
+ try {
482
+ response = await this.config.fetch(urlString, {
483
+ method,
484
+ headers: {
485
+ "Content-Type": "application/json",
486
+ ...this.config.apiKey ? { "x-api-key": this.config.apiKey } : { Authorization: `Bearer ${this.config.apiToken}` }
90
487
  },
91
- 500,
92
- parsedJson.requestId
488
+ body: bodyPayload === void 0 ? void 0 : JSON.stringify(bodyPayload)
489
+ });
490
+ } catch (error) {
491
+ throw createTransportError(path, urlString, error);
492
+ }
493
+ const responseBody = await readApiResponseBody(path, urlString, response);
494
+ if (response.ok) {
495
+ return parseSuccessfulResponse(
496
+ path,
497
+ urlString,
498
+ response,
499
+ responseBody,
500
+ opts.responseDataSchema
93
501
  );
94
502
  }
95
- return parsedJson.data;
503
+ throw createResponseError(path, urlString, response, responseBody);
96
504
  }
97
505
  /**
98
- * GET convenience method inferring response type from schema
506
+ * GET convenience method inferring response type from schema.
99
507
  */
100
508
  get(path, responseDataSchema) {
101
509
  return this.request("GET", path, { responseDataSchema });
102
510
  }
103
511
  /**
104
- * PUT convenience method inferring both request and response types
512
+ * PUT convenience method inferring both request and response types.
105
513
  */
106
514
  put(path, body, requestSchema, responseDataSchema) {
107
515
  return this.request("PUT", path, { body, requestSchema, responseDataSchema });
108
516
  }
109
517
  };
518
+ function trimTrailingSlashes(value) {
519
+ let end = value.length;
520
+ while (end > 0 && value.charCodeAt(end - 1) === 47) {
521
+ end -= 1;
522
+ }
523
+ return end === value.length ? value : value.slice(0, end);
524
+ }
110
525
 
111
526
  // src/schemas/ftpink/page/index.ts
112
527
  import { z as z3 } from "zod";
@@ -249,7 +664,7 @@ var BasePagePropertiesSchema = z3.object({
249
664
  title: z3.string(),
250
665
  pageId: z3.string().uuid(),
251
666
  publicationId: z3.string(),
252
- conceptId: z3.string().optional(),
667
+ conceptId: z3.string().uuid().optional(),
253
668
  metaDescription: z3.string().optional(),
254
669
  pageTheme: z3.string().optional(),
255
670
  sponsorText: z3.string().optional(),
@@ -417,7 +832,7 @@ var PageClient = class extends BaseApiClient {
417
832
  try {
418
833
  return await this.get(`/v1/page/${pageId}/draft`, PageDraftSchema);
419
834
  } catch (error) {
420
- if (error instanceof ApiClientError && error.code === "NOT_FOUND") {
835
+ if (error instanceof ApiServiceError && error.code === "NOT_FOUND") {
421
836
  return null;
422
837
  }
423
838
  throw error;
@@ -455,8 +870,11 @@ export {
455
870
  ApiErrorCode,
456
871
  ApiErrorPayloadSchema,
457
872
  ApiErrorResponseSchema,
873
+ ApiGatewayError,
458
874
  ApiResponseSchema,
875
+ ApiServiceError,
459
876
  ApiSuccessResponseSchema,
877
+ ApiTransportError,
460
878
  DraftContributorInputSchema,
461
879
  DraftContributorSchema,
462
880
  DraftSchema,