@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/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
- #projectId;
26
- constructor(options) {
27
- const env = environment();
28
- // Empty environment variables count as unset.
29
- const apiKey = options.apiKey ?? (env.NOHEAD_API_KEY || undefined);
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 = withoutTrailingSlashes(options.baseUrl ?? (env.NOHEAD_API_URL || DEFAULT_BASE_URL));
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
- this.#projectId = Promise.resolve(options.projectId);
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.#projectId ??= this.request("me_get", {}).then((me) => {
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.#projectId = undefined;
65
+ this.#project.id = undefined;
53
66
  throw error;
54
67
  });
55
- return this.#projectId;
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
- /** `fetch` with this client's timeout and retries, for presigned URLs. */
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
- const headers = {
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
- ...this.#headers,
105
- };
136
+ });
137
+ setHeaders(headers, this.#headers);
138
+ setHeaders(headers, options.headers);
139
+ const own = {};
106
140
  if (input.body !== undefined)
107
- headers["Content-Type"] = "application/json";
141
+ own["Content-Type"] = "application/json";
108
142
  if (method !== "GET") {
109
- headers["Idempotency-Key"] = options.idempotencyKey ?? crypto.randomUUID();
143
+ own["Idempotency-Key"] = options.idempotencyKey || crypto.randomUUID();
110
144
  }
111
- if (options.ifMatch !== undefined) {
112
- const revision = typeof options.ifMatch === "object"
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
- headers["Nohead-Change-Note"] = encodeURIComponent(wellFormed(options.changeNote));
121
- Object.assign(headers, options.headers);
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 ? undefined : JSON.stringify(input.body),
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 = AbortSignal.timeout(timeout);
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
- const response = await this.#fetch(url, { ...init, signal });
164
- const body = readBody ? await parseBody(response) : undefined;
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 [, name] of template.matchAll(/\{(\w+)\}/g)) {
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.text();
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; a loop, as a regex backtracks on long runs of them. */
312
- function withoutTrailingSlashes(url) {
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
- return url.slice(0, end);
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
- /** The API answered with an error status. Subclasses follow the error `type`. */
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
- /** The API answered with an error status. Subclasses follow the error `type`. */
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
- const error = errorEnvelope(body);
24
- super(error?.message ?? `Request failed with status ${status}`);
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
- const seconds = Number(value);
135
- if (Number.isFinite(seconds))
136
- return Math.max(0, seconds);
137
- const date = Date.parse(value);
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
- // Only objects: currentRevision reads each detail's fields.
153
- details: Array.isArray(details)
154
- ? details.filter(isObject)
155
- : undefined,
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
+ ]);