@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/LICENSE +21 -0
- package/README.md +39 -429
- package/dist/client.d.ts +2 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +208 -83
- package/dist/types.d.ts +96 -95
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
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
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
48
|
+
return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
30
49
|
}
|
|
31
|
-
function
|
|
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(`${
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
663
|
+
request: createRequestDescriptor(method, buildUrl(path, config.query)),
|
|
554
664
|
});
|
|
555
665
|
}
|
|
556
666
|
requestJson(method, path, config) {
|
|
557
|
-
const url = buildUrl(
|
|
667
|
+
const url = buildUrl(path, config?.query);
|
|
558
668
|
const request = createRequestDescriptor(method, url);
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
if (
|
|
597
|
-
|
|
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:
|
|
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
|
|
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
|
}
|