@allisall/sdk 0.0.0-stage → 0.2.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 +202 -0
- package/NOTICE +2 -0
- package/README.md +227 -2
- package/dist/client.d.ts +121 -0
- package/dist/client.js +193 -0
- package/dist/decimal.d.ts +20 -0
- package/dist/decimal.js +23 -0
- package/dist/errors.d.ts +79 -0
- package/dist/errors.js +98 -0
- package/dist/generated/openapi.d.ts +7724 -0
- package/dist/generated/openapi.js +4 -0
- package/dist/generated/operations.d.ts +37 -0
- package/dist/generated/operations.js +976 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +5 -0
- package/dist/ingest.d.ts +28 -0
- package/dist/ingest.js +247 -0
- package/dist/money.d.ts +57 -0
- package/dist/money.js +284 -0
- package/dist/multipart.d.ts +27 -0
- package/dist/multipart.js +48 -0
- package/dist/options.d.ts +50 -0
- package/dist/options.js +73 -0
- package/dist/pagination.d.ts +55 -0
- package/dist/pagination.js +85 -0
- package/dist/request.d.ts +117 -0
- package/dist/request.js +363 -0
- package/dist/resources/agents.d.ts +51 -0
- package/dist/resources/agents.js +65 -0
- package/dist/resources/analytics.d.ts +42 -0
- package/dist/resources/analytics.js +68 -0
- package/dist/resources/api-keys.d.ts +54 -0
- package/dist/resources/api-keys.js +63 -0
- package/dist/resources/calculations.d.ts +78 -0
- package/dist/resources/calculations.js +111 -0
- package/dist/resources/clients.d.ts +65 -0
- package/dist/resources/clients.js +85 -0
- package/dist/resources/cost.d.ts +69 -0
- package/dist/resources/cost.js +89 -0
- package/dist/resources/dashboard.d.ts +24 -0
- package/dist/resources/dashboard.js +28 -0
- package/dist/resources/events.d.ts +47 -0
- package/dist/resources/events.js +69 -0
- package/dist/resources/health.d.ts +14 -0
- package/dist/resources/health.js +17 -0
- package/dist/resources/imports.d.ts +33 -0
- package/dist/resources/imports.js +37 -0
- package/dist/resources/outcome-types.d.ts +25 -0
- package/dist/resources/outcome-types.js +33 -0
- package/dist/resources/reports.d.ts +77 -0
- package/dist/resources/reports.js +103 -0
- package/dist/resources/value-models.d.ts +92 -0
- package/dist/resources/value-models.js +126 -0
- package/dist/retry.d.ts +18 -0
- package/dist/retry.js +105 -0
- package/dist/runtime.d.ts +19 -0
- package/dist/runtime.js +61 -0
- package/dist/share.d.ts +52 -0
- package/dist/share.js +60 -0
- package/dist/transport.d.ts +102 -0
- package/dist/transport.js +269 -0
- package/dist/types.d.ts +268 -0
- package/dist/types.js +89 -0
- package/dist/unicode.d.ts +2 -0
- package/dist/unicode.js +11 -0
- package/dist/uuid.d.ts +6 -0
- package/dist/uuid.js +20 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +40 -4
package/dist/retry.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The retry decision and the wait between attempts.
|
|
2
|
+
/** The longest `Retry-After` the SDK honours. A longer one ends the call. */
|
|
3
|
+
export const MAX_RETRY_AFTER_MS = 60_000;
|
|
4
|
+
const INITIAL_BACKOFF_MS = 500;
|
|
5
|
+
const MAX_BACKOFF_MS = 8_000;
|
|
6
|
+
/** The statuses worth repeating: a proxy's `408`, `429`, `500`, and a gateway's `502`, `503`, `504`. */
|
|
7
|
+
export function isRetryableStatus(status) {
|
|
8
|
+
return (status === 408 ||
|
|
9
|
+
status === 429 ||
|
|
10
|
+
status === 500 ||
|
|
11
|
+
status === 502 ||
|
|
12
|
+
status === 503 ||
|
|
13
|
+
status === 504);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The wait before retry `n` (1 for the first retry) when the response
|
|
17
|
+
* carried no usable `Retry-After`: `min(0.5 s × 2^(n−1), 8 s)`, multiplied
|
|
18
|
+
* by a random factor between 0.75 and 1.
|
|
19
|
+
*/
|
|
20
|
+
export function backoffMs(retry) {
|
|
21
|
+
const base = Math.min(INITIAL_BACKOFF_MS * 2 ** (retry - 1), MAX_BACKOFF_MS);
|
|
22
|
+
return base * (1 - Math.random() * 0.25);
|
|
23
|
+
}
|
|
24
|
+
const MONTHS = [
|
|
25
|
+
"Jan",
|
|
26
|
+
"Feb",
|
|
27
|
+
"Mar",
|
|
28
|
+
"Apr",
|
|
29
|
+
"May",
|
|
30
|
+
"Jun",
|
|
31
|
+
"Jul",
|
|
32
|
+
"Aug",
|
|
33
|
+
"Sep",
|
|
34
|
+
"Oct",
|
|
35
|
+
"Nov",
|
|
36
|
+
"Dec",
|
|
37
|
+
];
|
|
38
|
+
// RFC 9110 §5.6.7: IMF-fixdate, and the two obsolete forms a recipient must accept.
|
|
39
|
+
const IMF_FIXDATE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) ([A-Z][a-z]{2}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
|
|
40
|
+
const RFC850_DATE = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-([A-Z][a-z]{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
|
|
41
|
+
const ASCTIME_DATE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) ([A-Z][a-z]{2}) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/;
|
|
42
|
+
function utc(year, month, day, hour, minute, second) {
|
|
43
|
+
const m = MONTHS.indexOf(month);
|
|
44
|
+
if (m < 0)
|
|
45
|
+
return undefined;
|
|
46
|
+
const ms = Date.UTC(year, m, Number(day.trim()), Number(hour), Number(minute), Number(second));
|
|
47
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
48
|
+
}
|
|
49
|
+
function parseHttpDate(value, now) {
|
|
50
|
+
let match = IMF_FIXDATE.exec(value);
|
|
51
|
+
if (match !== null) {
|
|
52
|
+
const [, day = "", month = "", year = "", h = "", m = "", s = ""] = match;
|
|
53
|
+
return utc(Number(year), month, day, h, m, s);
|
|
54
|
+
}
|
|
55
|
+
match = RFC850_DATE.exec(value);
|
|
56
|
+
if (match !== null) {
|
|
57
|
+
const [, day = "", month = "", yy = "", h = "", m = "", s = ""] = match;
|
|
58
|
+
// RFC 9110 §5.6.7: a two-digit year more than 50 years in the future is in the past century.
|
|
59
|
+
const thisYear = new Date(now).getUTCFullYear();
|
|
60
|
+
let year = 2000 + Number(yy);
|
|
61
|
+
if (year > thisYear + 50)
|
|
62
|
+
year -= 100;
|
|
63
|
+
return utc(year, month, day, h, m, s);
|
|
64
|
+
}
|
|
65
|
+
match = ASCTIME_DATE.exec(value);
|
|
66
|
+
if (match !== null) {
|
|
67
|
+
const [, month = "", day = "", h = "", m = "", s = "", year = ""] = match;
|
|
68
|
+
return utc(Number(year), month, day, h, m, s);
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Parses a `Retry-After` value, delay-seconds or an HTTP-date (RFC 9110
|
|
74
|
+
* §10.2.3), into milliseconds from `now`. Returns `undefined` for a value
|
|
75
|
+
* that does not parse or names a time in the past.
|
|
76
|
+
*/
|
|
77
|
+
export function parseRetryAfter(value, now) {
|
|
78
|
+
if (value === null)
|
|
79
|
+
return undefined;
|
|
80
|
+
const text = value.trim();
|
|
81
|
+
if (/^\d+$/.test(text))
|
|
82
|
+
return Number(text) * 1000;
|
|
83
|
+
const at = parseHttpDate(text, now);
|
|
84
|
+
if (at === undefined || at < now)
|
|
85
|
+
return undefined;
|
|
86
|
+
return at - now;
|
|
87
|
+
}
|
|
88
|
+
/** Waits `ms` milliseconds, or rejects with the signal's reason when it aborts first. */
|
|
89
|
+
export function sleep(ms, signal) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
if (signal?.aborted) {
|
|
92
|
+
reject(signal.reason);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const onAbort = () => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
reject(signal?.reason);
|
|
98
|
+
};
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
signal?.removeEventListener("abort", onAbort);
|
|
101
|
+
resolve();
|
|
102
|
+
}, ms);
|
|
103
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Reads a global by name, so a runtime without it is never touched. */
|
|
2
|
+
export declare function globalValue(name: string): unknown;
|
|
3
|
+
/**
|
|
4
|
+
* Reports whether the SDK runs in a browser: a window, where `document`
|
|
5
|
+
* is defined, or a Web Worker, where `globalThis` is an instance of
|
|
6
|
+
* `WorkerGlobalScope`. Node, Deno, Bun and edge runtimes define neither.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isBrowser(): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Reads an environment variable from `globalThis.process.env`, where it
|
|
11
|
+
* exists. Returns `undefined` for a variable that is unset or empty.
|
|
12
|
+
*/
|
|
13
|
+
export declare function readEnv(name: string): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* The `User-Agent` to send: `allisall-ts/<version> node/<node version>`.
|
|
16
|
+
* A server runtime that reports no Node version, such as an edge runtime,
|
|
17
|
+
* sends `allisall-ts/<version>` alone. A browser sends none.
|
|
18
|
+
*/
|
|
19
|
+
export declare function userAgent(): string | undefined;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// What the SDK learns about the runtime it runs in: whether it is a
|
|
2
|
+
// browser, the environment, and the User-Agent. Globals are read through
|
|
3
|
+
// `globalThis` by name, so a runtime without them, such as a browser
|
|
4
|
+
// without `process`, is never touched.
|
|
5
|
+
import { VERSION } from "./version.js";
|
|
6
|
+
/** Reads a global by name, so a runtime without it is never touched. */
|
|
7
|
+
export function globalValue(name) {
|
|
8
|
+
const value = Reflect.get(globalThis, name);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
function isObject(value) {
|
|
12
|
+
return typeof value === "object" && value !== null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Reports whether the SDK runs in a browser: a window, where `document`
|
|
16
|
+
* is defined, or a Web Worker, where `globalThis` is an instance of
|
|
17
|
+
* `WorkerGlobalScope`. Node, Deno, Bun and edge runtimes define neither.
|
|
18
|
+
*/
|
|
19
|
+
export function isBrowser() {
|
|
20
|
+
if (globalValue("document") !== undefined)
|
|
21
|
+
return true;
|
|
22
|
+
const scope = globalValue("WorkerGlobalScope");
|
|
23
|
+
return typeof scope === "function" && globalThis instanceof scope;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Reads an environment variable from `globalThis.process.env`, where it
|
|
27
|
+
* exists. Returns `undefined` for a variable that is unset or empty.
|
|
28
|
+
*/
|
|
29
|
+
export function readEnv(name) {
|
|
30
|
+
const process = globalValue("process");
|
|
31
|
+
if (!isObject(process))
|
|
32
|
+
return undefined;
|
|
33
|
+
const env = Reflect.get(process, "env");
|
|
34
|
+
if (!isObject(env))
|
|
35
|
+
return undefined;
|
|
36
|
+
const value = Reflect.get(env, name);
|
|
37
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
38
|
+
}
|
|
39
|
+
function nodeVersion() {
|
|
40
|
+
const process = globalValue("process");
|
|
41
|
+
if (!isObject(process))
|
|
42
|
+
return undefined;
|
|
43
|
+
const versions = Reflect.get(process, "versions");
|
|
44
|
+
if (!isObject(versions))
|
|
45
|
+
return undefined;
|
|
46
|
+
const node = Reflect.get(versions, "node");
|
|
47
|
+
return typeof node === "string" && node !== "" ? node : undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The `User-Agent` to send: `allisall-ts/<version> node/<node version>`.
|
|
51
|
+
* A server runtime that reports no Node version, such as an edge runtime,
|
|
52
|
+
* sends `allisall-ts/<version>` alone. A browser sends none.
|
|
53
|
+
*/
|
|
54
|
+
export function userAgent() {
|
|
55
|
+
if (isBrowser())
|
|
56
|
+
return undefined;
|
|
57
|
+
const node = nodeVersion();
|
|
58
|
+
return node === undefined
|
|
59
|
+
? `allisall-ts/${VERSION}`
|
|
60
|
+
: `allisall-ts/${VERSION} node/${node}`;
|
|
61
|
+
}
|
package/dist/share.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type FetchLike } from "./transport.js";
|
|
2
|
+
import type { PublicReport } from "./types.js";
|
|
3
|
+
/** The options of `getSharedReport`. Every one is optional. */
|
|
4
|
+
export interface SharedReportOptions {
|
|
5
|
+
/**
|
|
6
|
+
* The API's base URL, absolute, `http` or `https`. Defaults to
|
|
7
|
+
* `AIA_BASE_URL` from the environment, then `https://api.allisai.ai`.
|
|
8
|
+
* In a browser it may also be a path on the page's own origin, starting
|
|
9
|
+
* with a single `/`.
|
|
10
|
+
*/
|
|
11
|
+
baseURL?: string | undefined;
|
|
12
|
+
/** The timeout of each attempt, in milliseconds. Defaults to 60,000. */
|
|
13
|
+
timeout?: number | undefined;
|
|
14
|
+
/** How many times a failure worth repeating is retried. Defaults to 2; 0 turns retries off. */
|
|
15
|
+
maxRetries?: number | undefined;
|
|
16
|
+
/** The `fetch` to send the request with. Defaults to the platform's own. */
|
|
17
|
+
fetch?: FetchLike | undefined;
|
|
18
|
+
/** Cancels the call, including any wait between retries. */
|
|
19
|
+
signal?: AbortSignal | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Extra headers to send. They may not name `Authorization`,
|
|
22
|
+
* `Content-Type`, `Idempotency-Key` or `X-Request-Id`, which the SDK sets.
|
|
23
|
+
*/
|
|
24
|
+
headers?: Readonly<Record<string, string>> | undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Reads a shared report with its share token, as the report's recipient
|
|
28
|
+
* sees it. Needs no API key and no session, and never reads `AIA_API_KEY`.
|
|
29
|
+
*
|
|
30
|
+
* A share token is a bearer capability: anyone holding it can read the
|
|
31
|
+
* report. Handle it like a secret, and keep it out of URLs and logs. The
|
|
32
|
+
* SDK sends it only in the `Authorization` header, as
|
|
33
|
+
* `Authorization: Report <token>`, and keeps it for this call alone.
|
|
34
|
+
*
|
|
35
|
+
* Every failure, a missing, unknown or revoked token included, is the same
|
|
36
|
+
* `404`, as an `ApiError`. Each read stamps the report's view times, and a
|
|
37
|
+
* retry after a lost response stamps them again.
|
|
38
|
+
*
|
|
39
|
+
* Operation `getSharedReport`.
|
|
40
|
+
*
|
|
41
|
+
* @param shareToken - the report's share token.
|
|
42
|
+
* @throws {AllisallError} when the share token is empty or holds a
|
|
43
|
+
* character other than printable ASCII, or an option is one a client would
|
|
44
|
+
* refuse, before anything is sent.
|
|
45
|
+
* @throws {TypeError} when `options.headers` is not a plain object of
|
|
46
|
+
* header names and string values, or names a header the SDK sets, before
|
|
47
|
+
* anything is sent.
|
|
48
|
+
* @throws {ApiError} for an error response, such as the `404` for a token
|
|
49
|
+
* that reads no report.
|
|
50
|
+
* @throws {ConnectionError} when no response arrived, after any retries.
|
|
51
|
+
*/
|
|
52
|
+
export declare function getSharedReport(shareToken: string, options?: SharedReportOptions): Promise<PublicReport>;
|
package/dist/share.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Reading a shared report with its share token. It is a function, not a
|
|
2
|
+
// client method: its caller, such as a server rendering the report's page,
|
|
3
|
+
// holds no API key and no session, which a client requires.
|
|
4
|
+
import { transportSettings } from "./client.js";
|
|
5
|
+
import { AllisallError } from "./errors.js";
|
|
6
|
+
import { request } from "./request.js";
|
|
7
|
+
import { isBrowser } from "./runtime.js";
|
|
8
|
+
import { isPrintableCredential, } from "./transport.js";
|
|
9
|
+
/** Trims a share token and checks it can travel in a header. */
|
|
10
|
+
function checkedShareToken(shareToken) {
|
|
11
|
+
const token = typeof shareToken === "string" ? shareToken.trim() : "";
|
|
12
|
+
if (token === "") {
|
|
13
|
+
throw new AllisallError("allisall: the share token is empty");
|
|
14
|
+
}
|
|
15
|
+
if (!isPrintableCredential(token)) {
|
|
16
|
+
throw new AllisallError("allisall: the share token holds a character other than printable ASCII, which cannot be sent");
|
|
17
|
+
}
|
|
18
|
+
return token;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Reads a shared report with its share token, as the report's recipient
|
|
22
|
+
* sees it. Needs no API key and no session, and never reads `AIA_API_KEY`.
|
|
23
|
+
*
|
|
24
|
+
* A share token is a bearer capability: anyone holding it can read the
|
|
25
|
+
* report. Handle it like a secret, and keep it out of URLs and logs. The
|
|
26
|
+
* SDK sends it only in the `Authorization` header, as
|
|
27
|
+
* `Authorization: Report <token>`, and keeps it for this call alone.
|
|
28
|
+
*
|
|
29
|
+
* Every failure, a missing, unknown or revoked token included, is the same
|
|
30
|
+
* `404`, as an `ApiError`. Each read stamps the report's view times, and a
|
|
31
|
+
* retry after a lost response stamps them again.
|
|
32
|
+
*
|
|
33
|
+
* Operation `getSharedReport`.
|
|
34
|
+
*
|
|
35
|
+
* @param shareToken - the report's share token.
|
|
36
|
+
* @throws {AllisallError} when the share token is empty or holds a
|
|
37
|
+
* character other than printable ASCII, or an option is one a client would
|
|
38
|
+
* refuse, before anything is sent.
|
|
39
|
+
* @throws {TypeError} when `options.headers` is not a plain object of
|
|
40
|
+
* header names and string values, or names a header the SDK sets, before
|
|
41
|
+
* anything is sent.
|
|
42
|
+
* @throws {ApiError} for an error response, such as the `404` for a token
|
|
43
|
+
* that reads no report.
|
|
44
|
+
* @throws {ConnectionError} when no response arrived, after any retries.
|
|
45
|
+
*/
|
|
46
|
+
export async function getSharedReport(shareToken, options = {}) {
|
|
47
|
+
const token = checkedShareToken(shareToken);
|
|
48
|
+
const config = {
|
|
49
|
+
...transportSettings(options, isBrowser()),
|
|
50
|
+
// The share token is the call's only credential, and it is not an API
|
|
51
|
+
// key, so no browser opt-in applies. No client sends default headers.
|
|
52
|
+
credential: undefined,
|
|
53
|
+
refuseApiKeys: false,
|
|
54
|
+
defaultHeaders: {},
|
|
55
|
+
};
|
|
56
|
+
return request(config, "getSharedReport", {
|
|
57
|
+
shareToken: token,
|
|
58
|
+
options: { signal: options.signal, headers: options.headers },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Problem } from "./types.js";
|
|
2
|
+
/** A `fetch`-compatible function. */
|
|
3
|
+
export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
|
|
4
|
+
/** A callback returning the bearer token to send, called before every attempt. */
|
|
5
|
+
export type TokenProvider = () => string | Promise<string>;
|
|
6
|
+
/** Where the bearer token comes from. */
|
|
7
|
+
export type Credential = {
|
|
8
|
+
readonly kind: "apiKey";
|
|
9
|
+
readonly apiKey: string;
|
|
10
|
+
} | {
|
|
11
|
+
readonly kind: "provider";
|
|
12
|
+
readonly provider: TokenProvider;
|
|
13
|
+
};
|
|
14
|
+
/** Everything an attempt needs, fixed when the client is constructed. */
|
|
15
|
+
export interface TransportConfig {
|
|
16
|
+
readonly baseURL: URL;
|
|
17
|
+
/**
|
|
18
|
+
* The client's credential. Absent for a shared report, whose call carries
|
|
19
|
+
* its own share token and sends no client credential.
|
|
20
|
+
*/
|
|
21
|
+
readonly credential: Credential | undefined;
|
|
22
|
+
readonly timeout: number;
|
|
23
|
+
readonly maxRetries: number;
|
|
24
|
+
readonly fetch: FetchLike | undefined;
|
|
25
|
+
/** Whether a token that is an API key is refused: a browser without the opt-in. */
|
|
26
|
+
readonly refuseApiKeys: boolean;
|
|
27
|
+
/** The `User-Agent` to send; `undefined` in a browser. */
|
|
28
|
+
readonly userAgent: string | undefined;
|
|
29
|
+
/** Headers sent on every request, already checked. */
|
|
30
|
+
readonly defaultHeaders: Readonly<Record<string, string>>;
|
|
31
|
+
}
|
|
32
|
+
/** The message of the error refusing an API key in a browser. */
|
|
33
|
+
export declare const BROWSER_KEY_MESSAGE: string;
|
|
34
|
+
/** Reports whether a bearer token is an API key, which starts `aia_`. */
|
|
35
|
+
export declare function isApiKey(token: string): boolean;
|
|
36
|
+
/** Joins the base URL and an operation's path with exactly one `/`. */
|
|
37
|
+
export declare function joinURL(base: URL, path: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* Reports whether every character of a credential, already trimmed, is
|
|
40
|
+
* printable ASCII (U+0021 to U+007E), as a bearer credential is. Anything
|
|
41
|
+
* else, a control character, an inner space or a non-ASCII character,
|
|
42
|
+
* cannot travel in the header. The check never quotes the credential, so
|
|
43
|
+
* no error built from it can leak the secret.
|
|
44
|
+
*/
|
|
45
|
+
export declare function isPrintableCredential(credential: string): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Reads an `application/problem+json` body as a problem document. Returns
|
|
48
|
+
* `undefined` for any other body, or one that is not a JSON object. A
|
|
49
|
+
* member of the wrong type is left out on its own, as is a malformed
|
|
50
|
+
* entry of `errors`; the rest of the problem is kept. Members the SDK does
|
|
51
|
+
* not know are ignored.
|
|
52
|
+
*/
|
|
53
|
+
export declare function readProblem(contentType: string | null, text: string): Problem | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* How a successful body is read. `undefined` means the body was not the
|
|
56
|
+
* expected shape, which is an `ApiError` carrying the status.
|
|
57
|
+
*/
|
|
58
|
+
export type ReadBody<T> = (text: string) => {
|
|
59
|
+
readonly value: T;
|
|
60
|
+
} | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* The `Authorization` a request sends: the client's credential as a bearer,
|
|
63
|
+
* none, or a share token under the `Report` scheme.
|
|
64
|
+
*/
|
|
65
|
+
export type Authorization = "bearer" | "none" | {
|
|
66
|
+
readonly report: string;
|
|
67
|
+
};
|
|
68
|
+
/** One request, fixed before the first attempt and sent unchanged on each. */
|
|
69
|
+
export interface Outgoing {
|
|
70
|
+
readonly method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
71
|
+
readonly url: string;
|
|
72
|
+
readonly authorization: Authorization;
|
|
73
|
+
/** The body: bytes, or a form whose boundary `fetch` writes. */
|
|
74
|
+
readonly body: Uint8Array | FormData | undefined;
|
|
75
|
+
/** The body's `Content-Type`; `undefined` for no body, or a form. */
|
|
76
|
+
readonly contentType: string | undefined;
|
|
77
|
+
/** The `Idempotency-Key` sent on every attempt, if any. */
|
|
78
|
+
readonly idempotencyKey: string | undefined;
|
|
79
|
+
}
|
|
80
|
+
/** What one call may do, from its operation and its options. */
|
|
81
|
+
export interface CallPolicy {
|
|
82
|
+
readonly timeout: number;
|
|
83
|
+
readonly maxRetries: number;
|
|
84
|
+
/** The call's headers, already checked. */
|
|
85
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
86
|
+
readonly signal: AbortSignal | undefined;
|
|
87
|
+
/** Whether a retry cannot apply the request twice, so it may be retried at all. */
|
|
88
|
+
readonly eligible: boolean;
|
|
89
|
+
/** Whether a `409` `idempotency_key_in_use` is retried: the request carries a reused key. */
|
|
90
|
+
readonly retryKeyInUse: boolean;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Sends a request, retrying a failure worth repeating when the request is
|
|
94
|
+
* eligible. The body is the same on every attempt. Each attempt reads a
|
|
95
|
+
* fresh token and sends a fresh `X-Request-Id`.
|
|
96
|
+
*/
|
|
97
|
+
export declare function send<T>(config: TransportConfig, out: Outgoing, read: ReadBody<T>, policy: CallPolicy): Promise<T>;
|
|
98
|
+
/**
|
|
99
|
+
* Posts a JSON body that a retry cannot apply twice, such as an ingest
|
|
100
|
+
* batch, retrying a failure worth repeating.
|
|
101
|
+
*/
|
|
102
|
+
export declare function post<T>(config: TransportConfig, path: string, body: Uint8Array, read: (body: unknown) => T | undefined, policy: Omit<CallPolicy, "eligible" | "retryKeyInUse">): Promise<T>;
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// One HTTP attempt (headers, timeout, reading the response) and the loop
|
|
2
|
+
// that repeats it under the retry rules in retry.ts, for any method and
|
|
3
|
+
// any body.
|
|
4
|
+
import { AllisallError, ApiError, ConnectionError } from "./errors.js";
|
|
5
|
+
import { mergeHeaders } from "./options.js";
|
|
6
|
+
import { backoffMs, isRetryableStatus, MAX_RETRY_AFTER_MS, parseRetryAfter, sleep, } from "./retry.js";
|
|
7
|
+
import { uuidv4 } from "./uuid.js";
|
|
8
|
+
/** The message of the error refusing an API key in a browser. */
|
|
9
|
+
export const BROWSER_KEY_MESSAGE = "allisall: refusing to use an API key in a browser, where it is readable by anyone who loads the page. " +
|
|
10
|
+
"Call the API from your server instead, or pass dangerouslyAllowBrowser: true if you accept that risk.";
|
|
11
|
+
/** Reports whether a bearer token is an API key, which starts `aia_`. */
|
|
12
|
+
export function isApiKey(token) {
|
|
13
|
+
return token.startsWith("aia_");
|
|
14
|
+
}
|
|
15
|
+
/** Joins the base URL and an operation's path with exactly one `/`. */
|
|
16
|
+
export function joinURL(base, path) {
|
|
17
|
+
const url = new URL(base.href);
|
|
18
|
+
url.pathname = url.pathname.replace(/\/+$/, "") + path;
|
|
19
|
+
return url.href;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Reports whether every character of a credential, already trimmed, is
|
|
23
|
+
* printable ASCII (U+0021 to U+007E), as a bearer credential is. Anything
|
|
24
|
+
* else, a control character, an inner space or a non-ASCII character,
|
|
25
|
+
* cannot travel in the header. The check never quotes the credential, so
|
|
26
|
+
* no error built from it can leak the secret.
|
|
27
|
+
*/
|
|
28
|
+
export function isPrintableCredential(credential) {
|
|
29
|
+
for (let i = 0; i < credential.length; i++) {
|
|
30
|
+
const c = credential.charCodeAt(i);
|
|
31
|
+
if (c < 0x21 || c > 0x7e)
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
async function bearerToken(config) {
|
|
37
|
+
const { credential } = config;
|
|
38
|
+
if (credential === undefined) {
|
|
39
|
+
throw new AllisallError("allisall: this call has no client credential");
|
|
40
|
+
}
|
|
41
|
+
if (credential.kind === "apiKey")
|
|
42
|
+
return credential.apiKey;
|
|
43
|
+
const raw = await credential.provider();
|
|
44
|
+
const token = typeof raw === "string" ? raw.trim() : raw;
|
|
45
|
+
if (token === "") {
|
|
46
|
+
throw new AllisallError("allisall: the token provider returned an empty token");
|
|
47
|
+
}
|
|
48
|
+
if (typeof token === "string" && !isPrintableCredential(token)) {
|
|
49
|
+
throw new AllisallError("allisall: the token provider returned a token holding a character other than printable ASCII, which cannot be sent");
|
|
50
|
+
}
|
|
51
|
+
if (config.refuseApiKeys && typeof token === "string" && isApiKey(token)) {
|
|
52
|
+
throw new AllisallError(BROWSER_KEY_MESSAGE);
|
|
53
|
+
}
|
|
54
|
+
return token;
|
|
55
|
+
}
|
|
56
|
+
function isObject(value) {
|
|
57
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
function parseJSON(text) {
|
|
60
|
+
try {
|
|
61
|
+
const value = JSON.parse(text);
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const OPTIONAL_LOCATORS = [
|
|
69
|
+
"pointer",
|
|
70
|
+
"parameter",
|
|
71
|
+
"header",
|
|
72
|
+
"part",
|
|
73
|
+
"column",
|
|
74
|
+
];
|
|
75
|
+
function readFieldError(value) {
|
|
76
|
+
if (!isObject(value))
|
|
77
|
+
return undefined;
|
|
78
|
+
const { code, detail } = value;
|
|
79
|
+
if (typeof code !== "string" || typeof detail !== "string")
|
|
80
|
+
return undefined;
|
|
81
|
+
const out = { code, detail };
|
|
82
|
+
for (const name of OPTIONAL_LOCATORS) {
|
|
83
|
+
const locator = value[name];
|
|
84
|
+
if (typeof locator === "string")
|
|
85
|
+
out[name] = locator;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
const PROBLEM_STRINGS = [
|
|
90
|
+
"type",
|
|
91
|
+
"title",
|
|
92
|
+
"detail",
|
|
93
|
+
"code",
|
|
94
|
+
"request_id",
|
|
95
|
+
];
|
|
96
|
+
/**
|
|
97
|
+
* Reads an `application/problem+json` body as a problem document. Returns
|
|
98
|
+
* `undefined` for any other body, or one that is not a JSON object. A
|
|
99
|
+
* member of the wrong type is left out on its own, as is a malformed
|
|
100
|
+
* entry of `errors`; the rest of the problem is kept. Members the SDK does
|
|
101
|
+
* not know are ignored.
|
|
102
|
+
*/
|
|
103
|
+
export function readProblem(contentType, text) {
|
|
104
|
+
const mediaType = contentType?.split(";")[0]?.trim().toLowerCase();
|
|
105
|
+
if (mediaType !== "application/problem+json")
|
|
106
|
+
return undefined;
|
|
107
|
+
const body = parseJSON(text);
|
|
108
|
+
if (!isObject(body))
|
|
109
|
+
return undefined;
|
|
110
|
+
const problem = {};
|
|
111
|
+
for (const name of PROBLEM_STRINGS) {
|
|
112
|
+
const value = body[name];
|
|
113
|
+
if (typeof value === "string")
|
|
114
|
+
problem[name] = value;
|
|
115
|
+
}
|
|
116
|
+
if (typeof body.status === "number")
|
|
117
|
+
problem.status = body.status;
|
|
118
|
+
if (Array.isArray(body.errors)) {
|
|
119
|
+
problem.errors = body.errors
|
|
120
|
+
.map(readFieldError)
|
|
121
|
+
.filter((e) => e !== undefined);
|
|
122
|
+
}
|
|
123
|
+
return problem;
|
|
124
|
+
}
|
|
125
|
+
function headerOrUndefined(headers, name) {
|
|
126
|
+
return headers.get(name) ?? undefined;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Sends one attempt. Resolves with the read `2xx` body; throws an
|
|
130
|
+
* `ApiError` for any other status or an unreadable `2xx` body, a
|
|
131
|
+
* `ConnectionError` when no response arrived, or the caller's abort reason.
|
|
132
|
+
*/
|
|
133
|
+
async function attempt(config, out, read, authorization, requestId, policy) {
|
|
134
|
+
const headers = {};
|
|
135
|
+
if (authorization !== undefined)
|
|
136
|
+
headers.Authorization = authorization;
|
|
137
|
+
if (out.contentType !== undefined)
|
|
138
|
+
headers["Content-Type"] = out.contentType;
|
|
139
|
+
headers["X-Request-Id"] = requestId;
|
|
140
|
+
if (config.userAgent !== undefined)
|
|
141
|
+
headers["User-Agent"] = config.userAgent;
|
|
142
|
+
if (out.idempotencyKey !== undefined) {
|
|
143
|
+
headers["Idempotency-Key"] = out.idempotencyKey;
|
|
144
|
+
}
|
|
145
|
+
mergeHeaders(headers, config.defaultHeaders);
|
|
146
|
+
mergeHeaders(headers, policy.headers);
|
|
147
|
+
const { signal } = policy;
|
|
148
|
+
const timeout = AbortSignal.timeout(policy.timeout);
|
|
149
|
+
const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
|
|
150
|
+
const doFetch = config.fetch ?? globalThis.fetch;
|
|
151
|
+
const init = {
|
|
152
|
+
method: out.method,
|
|
153
|
+
headers,
|
|
154
|
+
...(out.body === undefined ? {} : { body: out.body }),
|
|
155
|
+
signal: combined,
|
|
156
|
+
// A redirect is never followed, so Authorization never travels to
|
|
157
|
+
// another location.
|
|
158
|
+
redirect: "manual",
|
|
159
|
+
};
|
|
160
|
+
let response;
|
|
161
|
+
let text;
|
|
162
|
+
try {
|
|
163
|
+
response = await doFetch(out.url, init);
|
|
164
|
+
text = await response.text();
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
if (signal?.aborted)
|
|
168
|
+
throw signal.reason;
|
|
169
|
+
throw new ConnectionError(requestId, err);
|
|
170
|
+
}
|
|
171
|
+
const status = response.status;
|
|
172
|
+
const responseId = headerOrUndefined(response.headers, "X-Request-Id");
|
|
173
|
+
// A 3xx, or a browser's opaque redirect, whose status is 0.
|
|
174
|
+
if ((status >= 300 && status < 400) || response.type === "opaqueredirect") {
|
|
175
|
+
throw new ApiError({
|
|
176
|
+
status,
|
|
177
|
+
headers: response.headers,
|
|
178
|
+
requestId: responseId,
|
|
179
|
+
message: `allisall: the API answered ${status} with a redirect, which the SDK does not follow`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (status >= 200 && status < 300) {
|
|
183
|
+
const result = read(text);
|
|
184
|
+
if (result !== undefined)
|
|
185
|
+
return result.value;
|
|
186
|
+
throw new ApiError({
|
|
187
|
+
status,
|
|
188
|
+
headers: response.headers,
|
|
189
|
+
requestId: responseId,
|
|
190
|
+
message: `allisall: the API answered ${status} with a body the SDK could not read`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
throw new ApiError({
|
|
194
|
+
status,
|
|
195
|
+
problem: readProblem(response.headers.get("Content-Type"), text),
|
|
196
|
+
requestId: responseId,
|
|
197
|
+
retryAfter: parseRetryAfter(response.headers.get("Retry-After"), Date.now()),
|
|
198
|
+
headers: response.headers,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
function worthRepeating(err, policy) {
|
|
202
|
+
if (err instanceof ConnectionError)
|
|
203
|
+
return true;
|
|
204
|
+
if (isRetryableStatus(err.status))
|
|
205
|
+
return true;
|
|
206
|
+
// The first request with this key is still running; a later attempt can
|
|
207
|
+
// collect its outcome.
|
|
208
|
+
return (policy.retryKeyInUse &&
|
|
209
|
+
err.status === 409 &&
|
|
210
|
+
err.code === "idempotency_key_in_use");
|
|
211
|
+
}
|
|
212
|
+
/** The `Authorization` value of one attempt, reading a fresh bearer token. */
|
|
213
|
+
async function authorizationOf(config, out) {
|
|
214
|
+
const { authorization } = out;
|
|
215
|
+
if (authorization === "none")
|
|
216
|
+
return undefined;
|
|
217
|
+
if (authorization === "bearer")
|
|
218
|
+
return `Bearer ${await bearerToken(config)}`;
|
|
219
|
+
return `Report ${authorization.report}`;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Sends a request, retrying a failure worth repeating when the request is
|
|
223
|
+
* eligible. The body is the same on every attempt. Each attempt reads a
|
|
224
|
+
* fresh token and sends a fresh `X-Request-Id`.
|
|
225
|
+
*/
|
|
226
|
+
export async function send(config, out, read, policy) {
|
|
227
|
+
const maxRetries = policy.eligible ? policy.maxRetries : 0;
|
|
228
|
+
for (let retry = 0;; retry++) {
|
|
229
|
+
// An error from the token provider ends the call unwrapped and unretried.
|
|
230
|
+
const authorization = await authorizationOf(config, out);
|
|
231
|
+
try {
|
|
232
|
+
return await attempt(config, out, read, authorization, uuidv4(), policy);
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
if (!(err instanceof ApiError || err instanceof ConnectionError))
|
|
236
|
+
throw err;
|
|
237
|
+
if (!worthRepeating(err, policy) || retry >= maxRetries)
|
|
238
|
+
throw err;
|
|
239
|
+
let wait;
|
|
240
|
+
if (err instanceof ApiError && err.retryAfter !== undefined) {
|
|
241
|
+
if (err.retryAfter > MAX_RETRY_AFTER_MS)
|
|
242
|
+
throw err;
|
|
243
|
+
wait = err.retryAfter;
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
wait = backoffMs(retry + 1);
|
|
247
|
+
}
|
|
248
|
+
await sleep(wait, policy.signal);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Posts a JSON body that a retry cannot apply twice, such as an ingest
|
|
254
|
+
* batch, retrying a failure worth repeating.
|
|
255
|
+
*/
|
|
256
|
+
export function post(config, path, body, read, policy) {
|
|
257
|
+
return send(config, {
|
|
258
|
+
method: "POST",
|
|
259
|
+
url: joinURL(config.baseURL, path),
|
|
260
|
+
authorization: "bearer",
|
|
261
|
+
body,
|
|
262
|
+
contentType: "application/json",
|
|
263
|
+
idempotencyKey: undefined,
|
|
264
|
+
}, (text) => {
|
|
265
|
+
const parsed = parseJSON(text);
|
|
266
|
+
const value = parsed === undefined ? undefined : read(parsed);
|
|
267
|
+
return value === undefined ? undefined : { value };
|
|
268
|
+
}, { ...policy, eligible: true, retryKeyInUse: false });
|
|
269
|
+
}
|