@paragraphcms/client 1.5.0 → 2.0.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/client.js CHANGED
@@ -1,8 +1,21 @@
1
1
  import { ParagraphApiError, ParagraphClientError, } from "./errors.js";
2
2
  import { RequestRateLimiter } from "./rate-limiter.js";
3
- const DEFAULT_BASE_URL = "https://api.paragraphcms.com/v1";
3
+ const API_BASE_URL = "https://api.paragraphcms.com/v1";
4
4
  const DEFAULT_REQUESTS_PER_SECOND = 5;
5
+ const DEFAULT_RATE_LIMIT_RETRIES = 2;
6
+ const DEFAULT_RATE_LIMIT_RETRY_DELAY_MS = 1000;
5
7
  const LOOKUP_PAGE_SIZE = 100;
8
+ const PRESERVED_TRANSFORM_KEYS = new Set([
9
+ "content",
10
+ "editorNode",
11
+ "fields",
12
+ ]);
13
+ const SPECIAL_API_KEY_MAP = {
14
+ labelIds: "label_id",
15
+ };
16
+ const SPECIAL_SDK_KEY_MAP = {
17
+ label_id: "labelIds",
18
+ };
6
19
  function resolveFetchImplementation(customFetch) {
7
20
  const fetchImpl = customFetch ?? globalThis.fetch;
8
21
  if (typeof fetchImpl !== "function") {
@@ -13,26 +26,59 @@ function resolveFetchImplementation(customFetch) {
13
26
  }
14
27
  return fetchImpl;
15
28
  }
16
- function normalizeBaseUrl(baseUrl) {
17
- const trimmed = baseUrl.trim().replace(/\/+$/, "");
18
- if (!trimmed) {
19
- throw new ParagraphClientError("`baseUrl` cannot be empty.");
29
+ function isPlainObject(value) {
30
+ if (typeof value !== "object" || value === null) {
31
+ return false;
20
32
  }
21
- const url = new URL(trimmed);
22
- const normalizedPath = url.pathname.replace(/\/+$/, "");
23
- if (normalizedPath === "" || normalizedPath === "/") {
24
- url.pathname = "/v1";
33
+ const prototype = Object.getPrototypeOf(value);
34
+ return prototype === Object.prototype || prototype === null;
35
+ }
36
+ function toCamelCaseKey(key) {
37
+ const mappedKey = SPECIAL_SDK_KEY_MAP[key];
38
+ if (mappedKey) {
39
+ return mappedKey;
25
40
  }
26
- else {
27
- url.pathname = normalizedPath;
41
+ return key.replace(/_([a-z])/g, (_match, letter) => letter.toUpperCase());
42
+ }
43
+ function toSnakeCaseKey(key) {
44
+ const mappedKey = SPECIAL_API_KEY_MAP[key];
45
+ if (mappedKey) {
46
+ return mappedKey;
28
47
  }
29
- return url.toString().replace(/\/+$/, "");
48
+ return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
30
49
  }
31
- function buildUrl(baseUrl, path, query) {
50
+ function transformKeysDeep(value, transformKey) {
51
+ if (Array.isArray(value)) {
52
+ return value.map((item) => transformKeysDeep(item, transformKey));
53
+ }
54
+ if (!isPlainObject(value)) {
55
+ return value;
56
+ }
57
+ const transformed = {};
58
+ for (const [key, nestedValue] of Object.entries(value)) {
59
+ const transformedKey = transformKey(key);
60
+ if (PRESERVED_TRANSFORM_KEYS.has(transformedKey)) {
61
+ transformed[transformedKey] = nestedValue;
62
+ continue;
63
+ }
64
+ transformed[transformedKey] = transformKeysDeep(nestedValue, transformKey);
65
+ }
66
+ return transformed;
67
+ }
68
+ function toApiPayload(value) {
69
+ return transformKeysDeep(value, toSnakeCaseKey);
70
+ }
71
+ function toSdkPayload(value) {
72
+ return transformKeysDeep(value, toCamelCaseKey);
73
+ }
74
+ function buildUrl(path, query) {
32
75
  const normalizedPath = path ? `/${path.replace(/^\/+/, "")}` : "";
33
- const url = new URL(`${baseUrl}${normalizedPath}`);
34
- if (query) {
35
- for (const [key, rawValue] of Object.entries(query)) {
76
+ const url = new URL(`${API_BASE_URL}${normalizedPath}`);
77
+ const apiQuery = query
78
+ ? toApiPayload(query)
79
+ : undefined;
80
+ if (apiQuery) {
81
+ for (const [key, rawValue] of Object.entries(apiQuery)) {
36
82
  if (rawValue === undefined || rawValue === null) {
37
83
  continue;
38
84
  }
@@ -123,9 +169,67 @@ function createRequestSignal(signal, timeoutMs) {
123
169
  didTimeout: () => timedOut,
124
170
  };
125
171
  }
172
+ function waitForDelay(delayMs, signal) {
173
+ if (delayMs <= 0) {
174
+ return Promise.resolve();
175
+ }
176
+ return new Promise((resolve, reject) => {
177
+ let timeoutId;
178
+ const cleanup = () => {
179
+ if (timeoutId !== undefined) {
180
+ clearTimeout(timeoutId);
181
+ }
182
+ if (signal) {
183
+ signal.removeEventListener("abort", onAbort);
184
+ }
185
+ };
186
+ const onAbort = () => {
187
+ cleanup();
188
+ reject(signal?.reason ??
189
+ new DOMException("The operation was aborted.", "AbortError"));
190
+ };
191
+ if (signal) {
192
+ if (signal.aborted) {
193
+ onAbort();
194
+ return;
195
+ }
196
+ signal.addEventListener("abort", onAbort, { once: true });
197
+ }
198
+ timeoutId = setTimeout(() => {
199
+ cleanup();
200
+ resolve();
201
+ }, delayMs);
202
+ });
203
+ }
126
204
  function isAbortError(error) {
127
205
  return (error instanceof DOMException && error.name === "AbortError");
128
206
  }
207
+ function resolveMaxRateLimitRetries(value, fallback = DEFAULT_RATE_LIMIT_RETRIES) {
208
+ const resolved = value ?? fallback;
209
+ if (!Number.isInteger(resolved) ||
210
+ resolved < 0) {
211
+ throw new ParagraphClientError("`maxRateLimitRetries` must be a non-negative integer.");
212
+ }
213
+ return resolved;
214
+ }
215
+ function parseRetryAfterMs(headerValue) {
216
+ if (!headerValue) {
217
+ return undefined;
218
+ }
219
+ const seconds = Number(headerValue);
220
+ if (Number.isFinite(seconds) && seconds >= 0) {
221
+ return Math.ceil(seconds * 1000);
222
+ }
223
+ const retryAt = Date.parse(headerValue);
224
+ if (Number.isNaN(retryAt)) {
225
+ return undefined;
226
+ }
227
+ return Math.max(0, retryAt - Date.now());
228
+ }
229
+ function resolveRateLimitRetryDelayMs(headers, retryCount) {
230
+ return (parseRetryAfterMs(headers.get("retry-after")) ??
231
+ DEFAULT_RATE_LIMIT_RETRY_DELAY_MS * 2 ** retryCount);
232
+ }
129
233
  function toBlobPart(file) {
130
234
  if (typeof File !== "undefined" && file instanceof File) {
131
235
  return {
@@ -155,11 +259,11 @@ function toBlobPart(file) {
155
259
  }
156
260
  function buildUploadFilePart(input) {
157
261
  const source = toBlobPart(input.file);
158
- const fileName = input.file_name ??
262
+ const fileName = input.fileName ??
159
263
  (typeof File !== "undefined" && input.file instanceof File
160
264
  ? input.file.name
161
265
  : "upload.bin");
162
- const contentType = input.content_type ??
266
+ const contentType = input.contentType ??
163
267
  ("type" in source.value &&
164
268
  typeof source.value.type === "string" &&
165
269
  source.value.type.length > 0
@@ -205,7 +309,7 @@ function createUploadFormData(input) {
205
309
  else {
206
310
  formData.append("file", filePart.value);
207
311
  }
208
- formData.append("page_id", input.page_id);
312
+ formData.append("page_id", input.pageId);
209
313
  if (input.alt !== undefined && input.alt !== null) {
210
314
  formData.append("alt", input.alt);
211
315
  }
@@ -213,10 +317,10 @@ function createUploadFormData(input) {
213
317
  }
214
318
  export class Client {
215
319
  apiKey;
216
- baseUrl;
217
320
  fetchImpl;
218
321
  defaultHeaders;
219
322
  timeoutMs;
323
+ maxRateLimitRetries;
220
324
  limiter;
221
325
  pages = {
222
326
  list: (query, options) => this.listPages(query, options),
@@ -251,6 +355,7 @@ export class Client {
251
355
  }),
252
356
  };
253
357
  page = {
358
+ get: (pageId, query, options) => this.pages.get(pageId, query, options),
254
359
  getBySlug: (slug, options) => this.pages.getBySlug(slug, options),
255
360
  };
256
361
  collections = {
@@ -426,14 +531,19 @@ export class Client {
426
531
  }),
427
532
  };
428
533
  constructor(options) {
429
- if (!options.apiKey.trim()) {
534
+ if ("baseUrl" in options ||
535
+ "apiUrl" in options) {
536
+ throw new ParagraphClientError("`baseUrl` and `apiUrl` are not supported. The client always uses the official Paragraph CMS API endpoint.");
537
+ }
538
+ if (typeof options.apiKey !== "string" ||
539
+ !options.apiKey.trim()) {
430
540
  throw new ParagraphClientError("`apiKey` is required.");
431
541
  }
432
542
  this.apiKey = options.apiKey.trim();
433
- this.baseUrl = normalizeBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
434
543
  this.fetchImpl = resolveFetchImplementation(options.fetch);
435
544
  this.defaultHeaders = new Headers(options.headers);
436
545
  this.timeoutMs = options.timeoutMs;
546
+ this.maxRateLimitRetries = resolveMaxRateLimitRetries(options.maxRateLimitRetries);
437
547
  this.limiter = new RequestRateLimiter(options.maxRequestsPerSecond ?? DEFAULT_REQUESTS_PER_SECOND);
438
548
  }
439
549
  getInfo(options) {
@@ -447,13 +557,13 @@ export class Client {
447
557
  });
448
558
  if (pageListQuery.limit !== undefined ||
449
559
  pageListQuery.page !== undefined ||
450
- !response.meta.has_next_page) {
560
+ !response.meta.hasNextPage) {
451
561
  return response;
452
562
  }
453
563
  const items = [...response.data];
454
564
  let nextPage = response.meta.page + 1;
455
565
  let lastMeta = response.meta;
456
- while (lastMeta.has_next_page) {
566
+ while (lastMeta.hasNextPage) {
457
567
  const nextResponse = await this.requestList("GET", "/pages", {
458
568
  query: {
459
569
  ...pageListQuery,
@@ -471,17 +581,17 @@ export class Client {
471
581
  meta: {
472
582
  page: 1,
473
583
  limit: items.length,
474
- total_items: items.length,
475
- total_pages: items.length > 0 ? 1 : 0,
476
- has_next_page: false,
477
- has_prev_page: false,
584
+ totalItems: items.length,
585
+ totalPages: items.length > 0 ? 1 : 0,
586
+ hasNextPage: false,
587
+ hasPrevPage: false,
478
588
  },
479
589
  };
480
590
  }
481
591
  createPageListQuery(query) {
482
592
  return {
483
593
  ...(query ?? {}),
484
- include_content: query?.include_content ?? false,
594
+ includeContent: query?.includeContent ?? false,
485
595
  };
486
596
  }
487
597
  requestData(method, path, config) {
@@ -514,7 +624,7 @@ export class Client {
514
624
  if (match) {
515
625
  return match;
516
626
  }
517
- if (!response.meta.has_next_page) {
627
+ if (!response.meta.hasNextPage) {
518
628
  break;
519
629
  }
520
630
  page += 1;
@@ -550,71 +660,85 @@ export class Client {
550
660
  code: config.code,
551
661
  message: config.message,
552
662
  details: config.details,
553
- request: createRequestDescriptor(method, buildUrl(this.baseUrl, path, config.query)),
663
+ request: createRequestDescriptor(method, buildUrl(path, config.query)),
554
664
  });
555
665
  }
556
666
  requestJson(method, path, config) {
557
- const url = buildUrl(this.baseUrl, path, config?.query);
667
+ const url = buildUrl(path, config?.query);
558
668
  const request = createRequestDescriptor(method, url);
559
- return this.limiter.schedule(async () => {
560
- const headers = new Headers(this.defaultHeaders);
561
- const timeoutMs = config?.options?.timeoutMs ?? this.timeoutMs;
562
- headers.set("accept", "application/json");
563
- if (!headers.has("x-api-key") &&
564
- !headers.has("authorization")) {
565
- headers.set("x-api-key", this.apiKey);
566
- }
567
- let body;
568
- if (config?.formData) {
569
- headers.delete("content-type");
570
- body = config.formData;
571
- }
572
- else if (config?.body !== undefined) {
573
- if (!headers.has("content-type")) {
574
- headers.set("content-type", "application/json");
575
- }
576
- body = JSON.stringify(config.body);
577
- }
578
- const optionHeaders = new Headers(config?.options?.headers);
579
- for (const [key, value] of optionHeaders.entries()) {
580
- headers.set(key, value);
581
- }
582
- if (config?.formData) {
583
- headers.delete("content-type");
669
+ const headers = new Headers(this.defaultHeaders);
670
+ const timeoutMs = config?.options?.timeoutMs ?? this.timeoutMs;
671
+ const maxRateLimitRetries = resolveMaxRateLimitRetries(config?.options?.maxRateLimitRetries, this.maxRateLimitRetries);
672
+ headers.set("accept", "application/json");
673
+ if (!headers.has("x-api-key") &&
674
+ !headers.has("authorization")) {
675
+ headers.set("x-api-key", this.apiKey);
676
+ }
677
+ let body;
678
+ if (config?.formData) {
679
+ headers.delete("content-type");
680
+ body = config.formData;
681
+ }
682
+ else if (config?.body !== undefined) {
683
+ if (!headers.has("content-type")) {
684
+ headers.set("content-type", "application/json");
584
685
  }
585
- const requestSignal = createRequestSignal(config?.options?.signal, timeoutMs);
686
+ body = JSON.stringify(toApiPayload(config.body));
687
+ }
688
+ const optionHeaders = new Headers(config?.options?.headers);
689
+ for (const [key, value] of optionHeaders.entries()) {
690
+ headers.set(key, value);
691
+ }
692
+ if (config?.formData) {
693
+ headers.delete("content-type");
694
+ }
695
+ const requestSignal = createRequestSignal(config?.options?.signal, timeoutMs);
696
+ return (async () => {
697
+ let retryCount = 0;
586
698
  try {
587
699
  const fetchImpl = this.fetchImpl;
588
- const response = await fetchImpl(url, {
589
- method,
590
- headers,
591
- body,
592
- signal: requestSignal.signal,
593
- });
594
- const payload = await parseResponse(response);
595
- if (!response.ok) {
596
- if (isApiErrorPayload(payload)) {
597
- throw new ParagraphApiError({
700
+ while (true) {
701
+ const response = await this.limiter.schedule(() => fetchImpl(url, {
702
+ method,
703
+ headers,
704
+ body,
705
+ signal: requestSignal.signal,
706
+ }));
707
+ const payload = await parseResponse(response);
708
+ if (response.ok) {
709
+ return toSdkPayload(payload);
710
+ }
711
+ const responseHeaders = new Headers(response.headers);
712
+ const apiError = isApiErrorPayload(payload)
713
+ ? new ParagraphApiError({
714
+ body: toSdkPayload(payload),
598
715
  status: response.status,
599
716
  code: payload.error.code,
600
717
  message: payload.error.message,
601
- details: payload.error.details,
602
- headers: new Headers(response.headers),
718
+ details: toSdkPayload(payload.error.details),
719
+ headers: responseHeaders,
720
+ request,
721
+ })
722
+ : new ParagraphApiError({
723
+ status: response.status,
724
+ code: response.status === 401
725
+ ? "unauthorized"
726
+ : "request_failed",
727
+ message: typeof payload === "string" && payload.length > 0
728
+ ? payload
729
+ : response.statusText || "Request failed.",
730
+ headers: responseHeaders,
603
731
  request,
604
- body: payload,
605
732
  });
733
+ if (response.status === 429 &&
734
+ retryCount < maxRateLimitRetries) {
735
+ const retryDelayMs = resolveRateLimitRetryDelayMs(responseHeaders, retryCount);
736
+ retryCount += 1;
737
+ await waitForDelay(retryDelayMs, requestSignal.signal);
738
+ continue;
606
739
  }
607
- throw new ParagraphApiError({
608
- status: response.status,
609
- code: response.status === 401 ? "unauthorized" : "request_failed",
610
- message: typeof payload === "string" && payload.length > 0
611
- ? payload
612
- : response.statusText || "Request failed.",
613
- headers: new Headers(response.headers),
614
- request,
615
- });
740
+ throw apiError;
616
741
  }
617
- return payload;
618
742
  }
619
743
  catch (error) {
620
744
  if (error instanceof ParagraphApiError ||
@@ -627,7 +751,8 @@ export class Client {
627
751
  cause: error,
628
752
  });
629
753
  }
630
- if (isAbortError(error)) {
754
+ if (isAbortError(error) ||
755
+ config?.options?.signal?.aborted) {
631
756
  throw error;
632
757
  }
633
758
  throw new ParagraphClientError("Request failed.", {
@@ -638,6 +763,6 @@ export class Client {
638
763
  finally {
639
764
  requestSignal.cleanup();
640
765
  }
641
- });
766
+ })();
642
767
  }
643
768
  }