@nohead/sdk 0.8.0 → 0.10.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/CHANGELOG.md +91 -0
- package/README.md +72 -24
- package/dist/client.d.ts +10 -2
- package/dist/client.js +14 -2
- package/dist/core.d.ts +48 -9
- package/dist/core.js +167 -46
- package/dist/errors.d.ts +13 -1
- package/dist/errors.js +74 -16
- package/dist/generated/operations.gen.d.ts +2 -0
- package/dist/generated/operations.gen.js +24 -0
- package/dist/generated/types.gen.d.ts +172 -37
- package/dist/images.js +8 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -0
- package/dist/resources/assets.d.ts +5 -1
- package/dist/resources/assets.js +23 -14
- package/dist/resources/drafts.d.ts +9 -9
- package/dist/resources/records.d.ts +14 -14
- package/dist/types.d.ts +7 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/webhooks.d.ts +2 -1
- package/dist/webhooks.js +6 -5
- package/package.json +2 -1
package/dist/core.js
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
import { APIPromise, Page, PagePromise, } from "./api-promise.js";
|
|
2
|
-
import { AbortError, ConnectionError, NoheadError, TimeoutError, apiError, retryAfterSeconds, } from "./errors.js";
|
|
3
|
-
import { OPERATIONS, } from "./generated/operations.gen.js";
|
|
2
|
+
import { AbortError, ConnectionError, NoheadError, TimeoutError, apiError, isObject, retryAfterSeconds, } from "./errors.js";
|
|
3
|
+
import { OPERATIONS, RICH_TEXT_OPERATIONS, } from "./generated/operations.gen.js";
|
|
4
4
|
import { VERSION } from "./version.js";
|
|
5
5
|
export const DEFAULT_BASE_URL = "https://api.nohead.io";
|
|
6
|
-
/**
|
|
7
|
-
* The string with lone surrogates (half an emoji, from cutting a string)
|
|
8
|
-
* replaced by U+FFFD, as `String.prototype.toWellFormed` does, so
|
|
9
|
-
* `encodeURIComponent` can encode it.
|
|
10
|
-
*/
|
|
11
|
-
const wellFormed = (text) => text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD");
|
|
12
6
|
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
|
|
13
7
|
const MAX_RETRY_AFTER_SECONDS = 60;
|
|
8
|
+
// Once per process, not per client: an app that makes a client per request
|
|
9
|
+
// (as on Workers) would otherwise warn on every one.
|
|
14
10
|
const warnedDeprecations = new Set();
|
|
15
11
|
let warnedUsage = false;
|
|
16
12
|
export class Core {
|
|
@@ -22,37 +18,54 @@ export class Core {
|
|
|
22
18
|
#timeout;
|
|
23
19
|
#warnings;
|
|
24
20
|
#onRetry;
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
#richText;
|
|
22
|
+
// The project lookup, shared with the client's copies (`withOptions`).
|
|
23
|
+
#project;
|
|
24
|
+
constructor(options, project = {}) {
|
|
25
|
+
// Empty options and environment variables count as unset.
|
|
26
|
+
const apiKey = options.apiKey || environment().NOHEAD_API_KEY;
|
|
30
27
|
if (!apiKey) {
|
|
31
28
|
throw new NoheadError("Missing API key: pass `apiKey` or set NOHEAD_API_KEY");
|
|
32
29
|
}
|
|
33
30
|
this.#apiKey = apiKey;
|
|
34
|
-
this.#baseUrl =
|
|
31
|
+
this.#baseUrl = baseUrlOf(options.baseUrl || DEFAULT_BASE_URL);
|
|
35
32
|
this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
36
33
|
this.#headers = options.headers ?? {};
|
|
37
34
|
this.#maxRetries = options.maxRetries ?? 2;
|
|
38
35
|
this.#timeout = options.timeout ?? 60_000;
|
|
39
36
|
this.#warnings = options.warnings ?? true;
|
|
40
37
|
this.#onRetry = options.onRetry;
|
|
38
|
+
this.#richText = options.richText;
|
|
39
|
+
this.#project = project;
|
|
41
40
|
if (options.projectId)
|
|
42
|
-
|
|
41
|
+
project.id ??= Promise.resolve(options.projectId);
|
|
42
|
+
}
|
|
43
|
+
/** A copy with other settings, sharing the project lookup. */
|
|
44
|
+
withOptions(options) {
|
|
45
|
+
return new Core({
|
|
46
|
+
apiKey: this.#apiKey,
|
|
47
|
+
baseUrl: this.#baseUrl,
|
|
48
|
+
fetch: this.#fetch,
|
|
49
|
+
headers: { ...this.#headers, ...options.headers },
|
|
50
|
+
maxRetries: options.maxRetries ?? this.#maxRetries,
|
|
51
|
+
timeout: options.timeout ?? this.#timeout,
|
|
52
|
+
warnings: this.#warnings,
|
|
53
|
+
onRetry: options.onRetry ?? this.#onRetry,
|
|
54
|
+
richText: options.richText || this.#richText,
|
|
55
|
+
}, this.#project);
|
|
43
56
|
}
|
|
44
57
|
/** The API key's project, from `GET /v1/me` the first time. */
|
|
45
58
|
projectId() {
|
|
46
|
-
this.#
|
|
59
|
+
this.#project.id ??= this.request("me_get", {}).then((me) => {
|
|
47
60
|
if (!me.api_key) {
|
|
48
61
|
throw new NoheadError("The credentials are not a project API key");
|
|
49
62
|
}
|
|
50
63
|
return me.api_key.project_id;
|
|
51
64
|
}, (error) => {
|
|
52
|
-
this.#
|
|
65
|
+
this.#project.id = undefined;
|
|
53
66
|
throw error;
|
|
54
67
|
});
|
|
55
|
-
return this.#
|
|
68
|
+
return this.#project.id;
|
|
56
69
|
}
|
|
57
70
|
request(operation, input = {}, options = {}) {
|
|
58
71
|
return new APIPromise(this.#send(operation, input, options));
|
|
@@ -63,21 +76,32 @@ export class Core {
|
|
|
63
76
|
const query = { ...input.query, ...(cursor ? { cursor } : {}) };
|
|
64
77
|
const result = await this.#send(operation, { ...input, query }, options);
|
|
65
78
|
const list = result.data;
|
|
79
|
+
// A proxy's page, say, rather than the API's list.
|
|
80
|
+
if (!isObject(list) ||
|
|
81
|
+
!Array.isArray(list.data) ||
|
|
82
|
+
!isObject(list.meta)) {
|
|
83
|
+
throw new NoheadError(`The API's response could not be read (HTTP ${result.response.status})`);
|
|
84
|
+
}
|
|
66
85
|
const page = new Page(list.data, list.meta, (next) => fetchPage(next).then((r) => r.data));
|
|
67
86
|
return { ...result, data: page };
|
|
68
87
|
};
|
|
69
88
|
const first = input.query?.cursor;
|
|
70
89
|
return new PagePromise(fetchPage(first));
|
|
71
90
|
}
|
|
72
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* `fetch` with this client's retries, for an upload's presigned URL. fetch
|
|
93
|
+
* shows no upload progress, so each attempt may take as long as sending
|
|
94
|
+
* `byteSize` at 64 KiB/s, if that is longer than the timeout.
|
|
95
|
+
*/
|
|
73
96
|
async fetchWithRetries(url, init, options) {
|
|
74
97
|
const maxRetries = options.retry
|
|
75
98
|
? (options.maxRetries ?? this.#maxRetries)
|
|
76
99
|
: 0;
|
|
100
|
+
const timeout = Math.max(options.timeout ?? this.#timeout, (options.byteSize / 65_536) * 1000);
|
|
77
101
|
for (let attempt = 0;; attempt++) {
|
|
78
102
|
let failed;
|
|
79
103
|
try {
|
|
80
|
-
const { response } = await this.#attempt(url, init, options, false);
|
|
104
|
+
const { response } = await this.#attempt(url, init, { ...options, timeout }, false);
|
|
81
105
|
if (!RETRYABLE_STATUSES.has(response.status) || attempt >= maxRetries) {
|
|
82
106
|
return response;
|
|
83
107
|
}
|
|
@@ -96,33 +120,44 @@ export class Core {
|
|
|
96
120
|
const { method, path } = OPERATIONS[operation];
|
|
97
121
|
const url = new URL(this.#baseUrl + (await this.#path(path, input.path)));
|
|
98
122
|
appendQuery(url.searchParams, input.query);
|
|
99
|
-
|
|
123
|
+
// The call's, else the client's, and only where the operation takes it.
|
|
124
|
+
const richText = options.richText || this.#richText;
|
|
125
|
+
if (richText && RICH_TEXT_OPERATIONS.has(operation)) {
|
|
126
|
+
url.searchParams.set("rich_text", richText);
|
|
127
|
+
}
|
|
128
|
+
// Each name once, whatever its case: the defaults, then the client's
|
|
129
|
+
// headers, the call's, and those of the request options, which win.
|
|
130
|
+
const headers = {};
|
|
131
|
+
setHeaders(headers, {
|
|
100
132
|
Accept: "application/json",
|
|
101
133
|
Authorization: `Bearer ${this.#apiKey}`,
|
|
102
134
|
"Nohead-Client": `sdk-typescript/${VERSION}`,
|
|
103
135
|
...USER_AGENT,
|
|
104
|
-
|
|
105
|
-
|
|
136
|
+
});
|
|
137
|
+
setHeaders(headers, this.#headers);
|
|
138
|
+
setHeaders(headers, options.headers);
|
|
139
|
+
const own = {};
|
|
106
140
|
if (input.body !== undefined)
|
|
107
|
-
|
|
141
|
+
own["Content-Type"] = "application/json";
|
|
108
142
|
if (method !== "GET") {
|
|
109
|
-
|
|
143
|
+
own["Idempotency-Key"] = options.idempotencyKey || crypto.randomUUID();
|
|
110
144
|
}
|
|
111
|
-
if (options.ifMatch !== undefined) {
|
|
112
|
-
|
|
113
|
-
? options.ifMatch.revision
|
|
114
|
-
: options.ifMatch;
|
|
115
|
-
headers["If-Match"] = `"${revision}"`;
|
|
145
|
+
if (options.ifMatch !== undefined && options.ifMatch !== null) {
|
|
146
|
+
own["If-Match"] = `"${revisionOf(options.ifMatch)}"`;
|
|
116
147
|
}
|
|
117
148
|
// Percent-encoded UTF-8: header values are bytes, and fetch refuses
|
|
118
|
-
// anything past Latin-1 (the API decodes it).
|
|
149
|
+
// anything past Latin-1 (the API decodes it). Lone surrogates (half an
|
|
150
|
+
// emoji, from cutting a string) become U+FFFD, so encodeURIComponent
|
|
151
|
+
// can encode them.
|
|
119
152
|
if (options.changeNote)
|
|
120
|
-
|
|
121
|
-
|
|
153
|
+
own["Nohead-Change-Note"] = encodeURIComponent(options.changeNote.toWellFormed());
|
|
154
|
+
setHeaders(headers, own);
|
|
122
155
|
const init = {
|
|
123
156
|
method,
|
|
124
157
|
headers,
|
|
125
|
-
body: input.body === undefined
|
|
158
|
+
body: input.body === undefined
|
|
159
|
+
? undefined
|
|
160
|
+
: JSON.stringify(input.body, finiteNumbers),
|
|
126
161
|
};
|
|
127
162
|
const maxRetries = options.maxRetries ?? this.#maxRetries;
|
|
128
163
|
for (let attempt = 0;; attempt++) {
|
|
@@ -154,14 +189,32 @@ export class Core {
|
|
|
154
189
|
return sleep(delay, signal);
|
|
155
190
|
}
|
|
156
191
|
async #attempt(url, init, options, readBody) {
|
|
192
|
+
// The timeout is the longest wait for progress, so a response that
|
|
193
|
+
// keeps arriving never times out: the timer starts again when the
|
|
194
|
+
// response comes, and with each chunk of its body.
|
|
157
195
|
const timeout = options.timeout ?? this.#timeout;
|
|
158
|
-
const timer =
|
|
196
|
+
const timer = new AbortController();
|
|
197
|
+
let timeoutId;
|
|
198
|
+
const progress = () => {
|
|
199
|
+
clearTimeout(timeoutId);
|
|
200
|
+
timeoutId = setTimeout(() => timer.abort(), timeout);
|
|
201
|
+
};
|
|
159
202
|
const signal = options.signal
|
|
160
|
-
? AbortSignal.any([options.signal, timer])
|
|
161
|
-
: timer;
|
|
203
|
+
? AbortSignal.any([options.signal, timer.signal])
|
|
204
|
+
: timer.signal;
|
|
162
205
|
try {
|
|
163
|
-
|
|
164
|
-
|
|
206
|
+
progress();
|
|
207
|
+
// Redirects aren't followed: one would send the API key to another
|
|
208
|
+
// host, or a write as a GET.
|
|
209
|
+
const response = await this.#fetch(url, {
|
|
210
|
+
...init,
|
|
211
|
+
redirect: "manual",
|
|
212
|
+
signal,
|
|
213
|
+
});
|
|
214
|
+
progress();
|
|
215
|
+
const body = readBody
|
|
216
|
+
? await parseBody(response, signal, progress)
|
|
217
|
+
: undefined;
|
|
165
218
|
return { response, body };
|
|
166
219
|
}
|
|
167
220
|
catch (error) {
|
|
@@ -170,11 +223,14 @@ export class Core {
|
|
|
170
223
|
cause: options.signal.reason,
|
|
171
224
|
});
|
|
172
225
|
}
|
|
173
|
-
if (timer.aborted) {
|
|
226
|
+
if (timer.signal.aborted) {
|
|
174
227
|
throw new TimeoutError(`The request timed out after ${timeout} ms`);
|
|
175
228
|
}
|
|
176
229
|
throw new ConnectionError(`Could not reach ${new URL(url).host}: ${error.message}`, { cause: error });
|
|
177
230
|
}
|
|
231
|
+
finally {
|
|
232
|
+
clearTimeout(timeoutId);
|
|
233
|
+
}
|
|
178
234
|
}
|
|
179
235
|
async #path(template, values = {}) {
|
|
180
236
|
let path = template;
|
|
@@ -183,7 +239,7 @@ export class Core {
|
|
|
183
239
|
for (const name of Object.keys(values))
|
|
184
240
|
if (!names.includes(name))
|
|
185
241
|
throw new TypeError(`${name} is not a path parameter of ${template}`);
|
|
186
|
-
for (const
|
|
242
|
+
for (const name of names) {
|
|
187
243
|
let value = values[name];
|
|
188
244
|
if (value === undefined && name === "project_id") {
|
|
189
245
|
value = await this.projectId();
|
|
@@ -191,6 +247,10 @@ export class Core {
|
|
|
191
247
|
if (value === undefined || value === "") {
|
|
192
248
|
throw new NoheadError(`Missing ${name}`);
|
|
193
249
|
}
|
|
250
|
+
// Encoding leaves these as they are, and URLs resolve them.
|
|
251
|
+
if (value === "." || value === "..") {
|
|
252
|
+
throw new NoheadError(`Invalid ${name}: "${value}"`);
|
|
253
|
+
}
|
|
194
254
|
path = path.replace(`{${name}}`, encodeURIComponent(String(value)));
|
|
195
255
|
}
|
|
196
256
|
return path;
|
|
@@ -249,8 +309,8 @@ function sleep(seconds, signal) {
|
|
|
249
309
|
signal?.addEventListener("abort", aborted, { once: true });
|
|
250
310
|
});
|
|
251
311
|
}
|
|
252
|
-
async function parseBody(response) {
|
|
253
|
-
const text = await response
|
|
312
|
+
async function parseBody(response, signal, progress) {
|
|
313
|
+
const text = await readText(response, signal, progress);
|
|
254
314
|
if (!text)
|
|
255
315
|
return undefined;
|
|
256
316
|
if (!response.headers.get("content-type")?.includes("json"))
|
|
@@ -262,6 +322,31 @@ async function parseBody(response) {
|
|
|
262
322
|
return text;
|
|
263
323
|
}
|
|
264
324
|
}
|
|
325
|
+
/** The body's text, calling `progress` for each chunk that arrives. */
|
|
326
|
+
async function readText(response, signal, progress) {
|
|
327
|
+
if (!response.body)
|
|
328
|
+
return "";
|
|
329
|
+
const reader = response.body.getReader();
|
|
330
|
+
// A fetch ends the body when the signal aborts, but not every fetch is
|
|
331
|
+
// the platform's.
|
|
332
|
+
const cancel = () => void reader.cancel().catch(() => undefined);
|
|
333
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
334
|
+
const decoder = new TextDecoder();
|
|
335
|
+
let text = "";
|
|
336
|
+
try {
|
|
337
|
+
for (;;) {
|
|
338
|
+
const { done, value } = await reader.read();
|
|
339
|
+
signal.throwIfAborted();
|
|
340
|
+
if (done)
|
|
341
|
+
return text + decoder.decode();
|
|
342
|
+
progress();
|
|
343
|
+
text += decoder.decode(value, { stream: true });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
signal.removeEventListener("abort", cancel);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
265
350
|
/**
|
|
266
351
|
* Query parameters as the API reads them: objects become `key[sub]=…`
|
|
267
352
|
* (`filter[price][lt]=50`), arrays are comma-separated (`expand=author,tags`)
|
|
@@ -308,12 +393,48 @@ function runtimeVersion() {
|
|
|
308
393
|
return `node/${g.process.versions.node}`;
|
|
309
394
|
return undefined;
|
|
310
395
|
}
|
|
311
|
-
/** `url` without trailing slashes
|
|
312
|
-
function
|
|
396
|
+
/** `url` without trailing slashes, if it is an http or https URL. */
|
|
397
|
+
function baseUrlOf(url) {
|
|
398
|
+
// A loop, as a regex backtracks on long runs of slashes.
|
|
313
399
|
let end = url.length;
|
|
314
400
|
while (end > 0 && url[end - 1] === "/")
|
|
315
401
|
end--;
|
|
316
|
-
|
|
402
|
+
const base = url.slice(0, end);
|
|
403
|
+
let protocol;
|
|
404
|
+
try {
|
|
405
|
+
protocol = new URL(base).protocol;
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
// Not a URL at all.
|
|
409
|
+
}
|
|
410
|
+
if (protocol !== "http:" && protocol !== "https:") {
|
|
411
|
+
throw new NoheadError("baseUrl must be an http or https URL");
|
|
412
|
+
}
|
|
413
|
+
return base;
|
|
414
|
+
}
|
|
415
|
+
/** Sets each header, replacing one of the same name in any case. */
|
|
416
|
+
function setHeaders(headers, values = {}) {
|
|
417
|
+
for (const [name, value] of Object.entries(values)) {
|
|
418
|
+
for (const key of Object.keys(headers))
|
|
419
|
+
if (key.toLowerCase() === name.toLowerCase())
|
|
420
|
+
delete headers[key];
|
|
421
|
+
headers[name] = value;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/** `ifMatch`'s revision: a number, or a record's or a draft's. */
|
|
425
|
+
function revisionOf(ifMatch) {
|
|
426
|
+
const revision = isObject(ifMatch) ? ifMatch.revision : ifMatch;
|
|
427
|
+
if (!Number.isInteger(revision)) {
|
|
428
|
+
throw new TypeError("ifMatch takes a revision number, or a record, draft or object with a revision");
|
|
429
|
+
}
|
|
430
|
+
return revision;
|
|
431
|
+
}
|
|
432
|
+
// JSON has no NaN or Infinity, which JSON.stringify would send as null.
|
|
433
|
+
function finiteNumbers(key, value) {
|
|
434
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
435
|
+
throw new TypeError(`JSON can't carry ${value}${key ? ` (in ${key})` : ""}`);
|
|
436
|
+
}
|
|
437
|
+
return value;
|
|
317
438
|
}
|
|
318
439
|
function environment() {
|
|
319
440
|
const process = globalThis.process;
|
package/dist/errors.d.ts
CHANGED
|
@@ -3,7 +3,11 @@ import type { ErrorDetail, ErrorType } from "./generated/types.gen.ts";
|
|
|
3
3
|
export declare class NoheadError extends Error {
|
|
4
4
|
name: string;
|
|
5
5
|
}
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* The API answered with an error status. Subclasses follow the error `type`.
|
|
8
|
+
* Its `message` names the status, the type and the request's ID along with
|
|
9
|
+
* the API's message: `422 validation_error: Title is required (request req_…)`.
|
|
10
|
+
*/
|
|
7
11
|
export declare class APIError extends NoheadError {
|
|
8
12
|
name: string;
|
|
9
13
|
/** The HTTP status. */
|
|
@@ -13,6 +17,8 @@ export declare class APIError extends NoheadError {
|
|
|
13
17
|
* none. A type newer than this SDK is kept as its string.
|
|
14
18
|
*/
|
|
15
19
|
readonly type: ErrorType | (string & {}) | undefined;
|
|
20
|
+
/** The API's own message, without the status, type and ID `message` adds. */
|
|
21
|
+
readonly apiMessage: string;
|
|
16
22
|
/** The request's ID (`req_…`), for support requests and logs. */
|
|
17
23
|
readonly requestId: string | undefined;
|
|
18
24
|
/** Field-level details, e.g. `[{ field: "title", code: "required", … }]`. */
|
|
@@ -85,4 +91,10 @@ export declare class WebhookVerificationError extends NoheadError {
|
|
|
85
91
|
}
|
|
86
92
|
/** The error for a response, by its `type`, or by status when it has none. */
|
|
87
93
|
export declare function apiError(status: number, body: unknown, headers: Headers): APIError;
|
|
94
|
+
/**
|
|
95
|
+
* `Retry-After` in seconds: a number of seconds (`120`, `1.5`), or an HTTP
|
|
96
|
+
* date. Anything else (`-1`, `1e3`, `0x10`, an ISO 8601 time) is undefined,
|
|
97
|
+
* as the Python and Ruby SDKs read it.
|
|
98
|
+
*/
|
|
88
99
|
export declare function retryAfterSeconds(headers: Headers): number | undefined;
|
|
100
|
+
export declare const isObject: (value: unknown) => value is Record<string, unknown>;
|
package/dist/errors.js
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
export class NoheadError extends Error {
|
|
3
3
|
name = "NoheadError";
|
|
4
4
|
}
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* The API answered with an error status. Subclasses follow the error `type`.
|
|
7
|
+
* Its `message` names the status, the type and the request's ID along with
|
|
8
|
+
* the API's message: `422 validation_error: Title is required (request req_…)`.
|
|
9
|
+
*/
|
|
6
10
|
export class APIError extends NoheadError {
|
|
7
11
|
name = "APIError";
|
|
8
12
|
/** The HTTP status. */
|
|
@@ -12,6 +16,8 @@ export class APIError extends NoheadError {
|
|
|
12
16
|
* none. A type newer than this SDK is kept as its string.
|
|
13
17
|
*/
|
|
14
18
|
type;
|
|
19
|
+
/** The API's own message, without the status, type and ID `message` adds. */
|
|
20
|
+
apiMessage;
|
|
15
21
|
/** The request's ID (`req_…`), for support requests and logs. */
|
|
16
22
|
requestId;
|
|
17
23
|
/** Field-level details, e.g. `[{ field: "title", code: "required", … }]`. */
|
|
@@ -20,12 +26,17 @@ export class APIError extends NoheadError {
|
|
|
20
26
|
/** The parsed response body, or its text when it was not JSON. */
|
|
21
27
|
body;
|
|
22
28
|
constructor(status, body, headers) {
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
// Below 400 (and not 2xx) is a redirect, which the SDK doesn't follow:
|
|
30
|
+
// `0` is a browser's, which shows nothing of it.
|
|
31
|
+
const error = status < 400 ? redirect(headers) : errorEnvelope(body);
|
|
32
|
+
const requestId = error?.request_id ?? headers.get("x-request-id") ?? undefined;
|
|
33
|
+
const apiMessage = error?.message || `Request failed with status ${status}`;
|
|
34
|
+
super(`${status} ${error?.type || "error"}: ${apiMessage}` +
|
|
35
|
+
(requestId ? ` (request ${requestId})` : ""));
|
|
25
36
|
this.status = status;
|
|
37
|
+
this.apiMessage = apiMessage;
|
|
26
38
|
this.type = error?.type;
|
|
27
|
-
this.requestId =
|
|
28
|
-
error?.request_id ?? headers.get("x-request-id") ?? undefined;
|
|
39
|
+
this.requestId = requestId;
|
|
29
40
|
this.details = error?.details ?? [];
|
|
30
41
|
this.headers = headers;
|
|
31
42
|
this.body = body;
|
|
@@ -118,6 +129,8 @@ const CLASSES = new Map(Object.entries({
|
|
|
118
129
|
}));
|
|
119
130
|
/** The error for a response, by its `type`, or by status when it has none. */
|
|
120
131
|
export function apiError(status, body, headers) {
|
|
132
|
+
if (status < 400)
|
|
133
|
+
return new APIError(status, body, headers);
|
|
121
134
|
const type = errorEnvelope(body)?.type;
|
|
122
135
|
const Class = (type && CLASSES.get(type)) ||
|
|
123
136
|
(status === 503
|
|
@@ -127,18 +140,49 @@ export function apiError(status, body, headers) {
|
|
|
127
140
|
: APIError);
|
|
128
141
|
return new Class(status, body, headers);
|
|
129
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* `Retry-After` in seconds: a number of seconds (`120`, `1.5`), or an HTTP
|
|
145
|
+
* date. Anything else (`-1`, `1e3`, `0x10`, an ISO 8601 time) is undefined,
|
|
146
|
+
* as the Python and Ruby SDKs read it.
|
|
147
|
+
*/
|
|
130
148
|
export function retryAfterSeconds(headers) {
|
|
131
|
-
const value = headers.get("retry-after");
|
|
149
|
+
const value = headers.get("retry-after")?.trim();
|
|
132
150
|
if (!value)
|
|
133
151
|
return undefined;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return Number.isNaN(date)
|
|
152
|
+
if (/^\d+(\.\d+)?$/.test(value))
|
|
153
|
+
return Number(value);
|
|
154
|
+
const date = httpDate(value);
|
|
155
|
+
return date === undefined
|
|
139
156
|
? undefined
|
|
140
157
|
: Math.max(0, (date - Date.now()) / 1000);
|
|
141
158
|
}
|
|
159
|
+
// The three forms of an HTTP date (RFC 9110), all in GMT. Not Date.parse,
|
|
160
|
+
// which takes more than these, and asctime's in the local time zone.
|
|
161
|
+
const HTTP_DATES = [
|
|
162
|
+
// IMF-fixdate: Sun, 06 Nov 1994 08:49:37 GMT
|
|
163
|
+
/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (?<day>\d{2}) (?<month>\w{3}) (?<year>\d{4}) (?<time>\d{2}:\d{2}:\d{2}) GMT$/,
|
|
164
|
+
// RFC 850: Sunday, 06-Nov-94 08:49:37 GMT
|
|
165
|
+
/^(?:Mon|Tues|Wednes|Thurs|Fri|Satur|Sun)day, (?<day>\d{2})-(?<month>\w{3})-(?<year>\d{2}) (?<time>\d{2}:\d{2}:\d{2}) GMT$/,
|
|
166
|
+
// asctime: Sun Nov 6 08:49:37 1994
|
|
167
|
+
/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?<month>\w{3}) (?<day>[ \d]\d) (?<time>\d{2}:\d{2}:\d{2}) (?<year>\d{4})$/,
|
|
168
|
+
];
|
|
169
|
+
const MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" ");
|
|
170
|
+
/** An HTTP date in milliseconds, or undefined if `value` isn't one. */
|
|
171
|
+
function httpDate(value) {
|
|
172
|
+
const date = HTTP_DATES.map((pattern) => pattern.exec(value)?.groups).find(Boolean);
|
|
173
|
+
const month = MONTHS.indexOf(date?.month ?? "");
|
|
174
|
+
if (!date || month < 0)
|
|
175
|
+
return undefined;
|
|
176
|
+
let year = Number(date.year);
|
|
177
|
+
// RFC 850's two digits: the latest such year not over 50 years ahead.
|
|
178
|
+
if (date.year.length === 2) {
|
|
179
|
+
year += 2000;
|
|
180
|
+
if (year > new Date().getUTCFullYear() + 50)
|
|
181
|
+
year -= 100;
|
|
182
|
+
}
|
|
183
|
+
const [hours, minutes, seconds] = date.time.split(":").map(Number);
|
|
184
|
+
return Date.UTC(year, month, Number(date.day), hours, minutes, seconds);
|
|
185
|
+
}
|
|
142
186
|
// The error envelope's fields that have the right type: a proxy or gateway
|
|
143
187
|
// in front of the API may answer with any JSON.
|
|
144
188
|
function errorEnvelope(body) {
|
|
@@ -149,10 +193,24 @@ function errorEnvelope(body) {
|
|
|
149
193
|
type: typeof type === "string" ? type : undefined,
|
|
150
194
|
message: typeof message === "string" ? message : undefined,
|
|
151
195
|
request_id: typeof request_id === "string" ? request_id : undefined,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
196
|
+
details: Array.isArray(details) ? details.map(errorDetail) : undefined,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// A detail that isn't an object is kept as its text, so currentRevision can
|
|
200
|
+
// read every detail's fields.
|
|
201
|
+
function errorDetail(detail) {
|
|
202
|
+
if (isObject(detail) && !Array.isArray(detail))
|
|
203
|
+
return detail;
|
|
204
|
+
const message = typeof detail === "string" ? detail : JSON.stringify(detail);
|
|
205
|
+
return { code: "unknown", message: String(message) };
|
|
206
|
+
}
|
|
207
|
+
// A redirect's error: following one would send the API key elsewhere, or a
|
|
208
|
+
// write as a GET.
|
|
209
|
+
function redirect(headers) {
|
|
210
|
+
const location = headers.get("location");
|
|
211
|
+
return {
|
|
212
|
+
type: "redirect",
|
|
213
|
+
message: `The API answered with a redirect${location ? ` to ${location}` : ""}; check the base URL`,
|
|
156
214
|
};
|
|
157
215
|
}
|
|
158
|
-
const isObject = (value) => typeof value === "object" && value !== null;
|
|
216
|
+
export const isObject = (value) => typeof value === "object" && value !== null;
|
|
@@ -326,6 +326,8 @@ export declare const OPERATIONS: {
|
|
|
326
326
|
};
|
|
327
327
|
};
|
|
328
328
|
export type OperationId = keyof typeof OPERATIONS;
|
|
329
|
+
/** The operations that take `rich_text`: those returning records or drafts. */
|
|
330
|
+
export declare const RICH_TEXT_OPERATIONS: ReadonlySet<OperationId>;
|
|
329
331
|
export interface Operations {
|
|
330
332
|
assets_complete: {
|
|
331
333
|
data: T.AssetsCompleteData;
|
|
@@ -81,3 +81,27 @@ export const OPERATIONS = {
|
|
|
81
81
|
webhooks_test: { method: "POST", path: "/v1/webhooks/{webhook_id}/test" },
|
|
82
82
|
webhooks_update: { method: "PATCH", path: "/v1/webhooks/{webhook_id}" },
|
|
83
83
|
};
|
|
84
|
+
/** The operations that take `rich_text`: those returning records or drafts. */
|
|
85
|
+
export const RICH_TEXT_OPERATIONS = new Set([
|
|
86
|
+
"collections_search",
|
|
87
|
+
"drafts_create",
|
|
88
|
+
"drafts_discard",
|
|
89
|
+
"drafts_get",
|
|
90
|
+
"drafts_list",
|
|
91
|
+
"drafts_merge",
|
|
92
|
+
"drafts_schedule",
|
|
93
|
+
"drafts_unschedule",
|
|
94
|
+
"drafts_update",
|
|
95
|
+
"projects_search",
|
|
96
|
+
"record_drafts_list",
|
|
97
|
+
"record_revisions_revert",
|
|
98
|
+
"records_bulk",
|
|
99
|
+
"records_create",
|
|
100
|
+
"records_delete",
|
|
101
|
+
"records_get",
|
|
102
|
+
"records_list",
|
|
103
|
+
"records_publish",
|
|
104
|
+
"records_restore",
|
|
105
|
+
"records_unpublish",
|
|
106
|
+
"records_update",
|
|
107
|
+
]);
|