@cube-nl/dmp-sdk 0.0.0-stage → 1.0.0-rc.4

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/auth.js ADDED
@@ -0,0 +1,321 @@
1
+ import { DmpError } from './errors.js';
2
+ /**
3
+ * A fixed token: a personal token (`$user->createToken(...)`) or an API-client token. Pass a
4
+ * function to read it fresh on every call. Never hardcode one in code that reaches a browser bundle.
5
+ */
6
+ export function bearer(token) {
7
+ return {
8
+ async getAccessToken() {
9
+ const value = typeof token === 'function' ? await token() : token;
10
+ return value === undefined || value === '' ? null : value;
11
+ },
12
+ };
13
+ }
14
+ const PENDING_KEY = 'cube-dmp-sdk.pending-login';
15
+ /**
16
+ * OAuth 2.1 authorization code with PKCE (S256) against cube/oauth-server.
17
+ *
18
+ * The access token lives in memory unless you pass a `tokenStore`. What survives the redirect is
19
+ * the verifier and the state, in sessionStorage: useless without the code, and removed as soon as
20
+ * the callback is handled.
21
+ */
22
+ export function createOAuth(options) {
23
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
24
+ const tokenStore = options.tokenStore ?? memoryTokenStore();
25
+ const pendingStore = options.pendingStore ?? defaultPendingStore();
26
+ const leeway = (options.refreshLeewaySeconds ?? 30) * 1000;
27
+ const scope = typeof options.scopes === 'string' ? options.scopes : options.scopes.join(' ');
28
+ const overrides = options.metadata ?? {};
29
+ let metadata = isCompleteMetadata(overrides)
30
+ ? Promise.resolve({ ...overrides, issuer: overrides.issuer ?? options.issuer })
31
+ : null;
32
+ let refreshing = null;
33
+ const discover = () => {
34
+ metadata ??= discoverMetadata(options.issuer, doFetch).then((discovered) => ({ ...discovered, ...overrides }), (error) => {
35
+ metadata = null;
36
+ throw error;
37
+ });
38
+ return metadata;
39
+ };
40
+ // RFC 6749 section 5.1: a server may leave `scope` out when it granted exactly what was asked.
41
+ const tokenRequest = async (form, grantedIfOmitted) => {
42
+ const { token_endpoint: endpoint } = await discover();
43
+ const body = new URLSearchParams(form);
44
+ if (options.clientSecret !== undefined) {
45
+ body.set('client_secret', options.clientSecret);
46
+ }
47
+ // Form-encoded, not JSON: that is what the token endpoint reads.
48
+ const response = await doFetch(endpoint, {
49
+ method: 'POST',
50
+ headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
51
+ body: body.toString(),
52
+ });
53
+ const json = await readJson(response);
54
+ if (!response.ok || json === null || typeof json !== 'object' || typeof json.access_token !== 'string') {
55
+ throw new DmpError(response.status, response.headers, json);
56
+ }
57
+ const tokens = {
58
+ accessToken: json.access_token,
59
+ refreshToken: typeof json.refresh_token === 'string' ? json.refresh_token : null,
60
+ expiresAt: typeof json.expires_in === 'number' ? Date.now() + json.expires_in * 1000 : null,
61
+ scope: typeof json.scope === 'string' ? json.scope : grantedIfOmitted,
62
+ };
63
+ await tokenStore.set(tokens);
64
+ return tokens;
65
+ };
66
+ const refresh = () => {
67
+ refreshing ??= (async () => {
68
+ const current = await tokenStore.get();
69
+ if (current?.refreshToken == null) {
70
+ throw new TypeError('No refresh token: sign in again.');
71
+ }
72
+ try {
73
+ return await tokenRequest({
74
+ grant_type: 'refresh_token',
75
+ refresh_token: current.refreshToken,
76
+ client_id: options.clientId,
77
+ }, current.scope);
78
+ }
79
+ catch (error) {
80
+ // A refused refresh token is spent or revoked: holding on to it only repeats the refusal.
81
+ if (error instanceof DmpError && error.status >= 400 && error.status < 500) {
82
+ await tokenStore.set(null);
83
+ }
84
+ throw error;
85
+ }
86
+ })().finally(() => {
87
+ refreshing = null;
88
+ });
89
+ return refreshing;
90
+ };
91
+ const authorizationUrl = async (urlOptions = {}) => {
92
+ const server = await discover();
93
+ if (!(server.code_challenge_methods_supported ?? []).includes('S256')) {
94
+ throw new TypeError('The authorization server does not offer PKCE S256; this SDK will not sign in without it.');
95
+ }
96
+ const verifier = randomString(64);
97
+ const state = urlOptions.state ?? randomString(32);
98
+ const challenge = base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)));
99
+ const pending = { verifier, state, redirectUri: options.redirectUri, clientId: options.clientId };
100
+ pendingStore.setItem(PENDING_KEY, JSON.stringify(pending));
101
+ const url = new URL(server.authorization_endpoint);
102
+ url.search = new URLSearchParams({
103
+ ...urlOptions.extraParams,
104
+ response_type: 'code',
105
+ client_id: options.clientId,
106
+ redirect_uri: options.redirectUri,
107
+ scope,
108
+ state,
109
+ code_challenge: challenge,
110
+ code_challenge_method: 'S256',
111
+ }).toString();
112
+ return url.toString();
113
+ };
114
+ return {
115
+ discover,
116
+ authorizationUrl,
117
+ async signIn(signInOptions = {}) {
118
+ const location = browserLocation();
119
+ if (location === null) {
120
+ throw new TypeError('signIn() needs a browser; use authorizationUrl() and redirect yourself.');
121
+ }
122
+ location.assign(await authorizationUrl(signInOptions));
123
+ },
124
+ async handleCallback(url) {
125
+ const current = url ?? browserLocation()?.href;
126
+ if (current === undefined) {
127
+ throw new TypeError('handleCallback() needs the callback URL outside a browser.');
128
+ }
129
+ const parsed = new URL(current);
130
+ const params = new URLSearchParams(parsed.search);
131
+ if (!isCallbackUrl(parsed, options.redirectUri) || (!params.has('code') && !params.has('error'))) {
132
+ return null;
133
+ }
134
+ if (url === undefined) {
135
+ // The code must not stay in the address bar, the history or a bookmark.
136
+ for (const key of ['code', 'state', 'error', 'error_description', 'iss']) {
137
+ parsed.searchParams.delete(key);
138
+ }
139
+ globalThis.history?.replaceState(null, '', parsed.pathname + parsed.search + parsed.hash);
140
+ }
141
+ const pending = parsePending(pendingStore.getItem(PENDING_KEY));
142
+ // Checked before an error is surfaced and before the verifier is spent: a forged callback link
143
+ // must neither pose as the server's answer nor cancel a sign-in that is still underway.
144
+ if (pending === null || params.get('state') !== pending.state) {
145
+ throw new TypeError('The state does not match: this callback does not belong to a sign-in started here.');
146
+ }
147
+ pendingStore.removeItem(PENDING_KEY);
148
+ if (params.has('error')) {
149
+ throw new DmpError(400, new Headers(), {
150
+ error: params.get('error') ?? 'access_denied',
151
+ error_description: params.get('error_description') ?? undefined,
152
+ });
153
+ }
154
+ return tokenRequest({
155
+ grant_type: 'authorization_code',
156
+ code: params.get('code') ?? '',
157
+ redirect_uri: pending.redirectUri,
158
+ client_id: pending.clientId,
159
+ code_verifier: pending.verifier,
160
+ }, scope);
161
+ },
162
+ refresh,
163
+ async getTokens() {
164
+ return tokenStore.get();
165
+ },
166
+ async setTokens(tokens) {
167
+ await tokenStore.set(tokens);
168
+ },
169
+ async signOut() {
170
+ await tokenStore.set(null);
171
+ pendingStore.removeItem(PENDING_KEY);
172
+ },
173
+ async getAccessToken() {
174
+ const tokens = await tokenStore.get();
175
+ if (tokens === null) {
176
+ return null;
177
+ }
178
+ const expired = tokens.expiresAt !== null && tokens.expiresAt - leeway <= Date.now();
179
+ if (expired && tokens.refreshToken !== null) {
180
+ try {
181
+ return (await refresh()).accessToken;
182
+ }
183
+ catch {
184
+ // Send what we have: the 401 that follows is a clearer answer than a refresh error here.
185
+ return (await tokenStore.get())?.accessToken ?? null;
186
+ }
187
+ }
188
+ return tokens.accessToken;
189
+ },
190
+ async onUnauthorized() {
191
+ const tokens = await tokenStore.get();
192
+ if (tokens?.refreshToken == null) {
193
+ return false;
194
+ }
195
+ try {
196
+ await refresh();
197
+ return true;
198
+ }
199
+ catch {
200
+ return false;
201
+ }
202
+ },
203
+ };
204
+ }
205
+ function isCompleteMetadata(value) {
206
+ return typeof value.authorization_endpoint === 'string' && typeof value.token_endpoint === 'string';
207
+ }
208
+ /**
209
+ * RFC 7591 dynamic client registration, when the DMP turned it on (`registration_endpoint` is then
210
+ * in discovery). Returns the registration response, with `client_id`.
211
+ */
212
+ export async function registerClient(server, request, doFetch = globalThis.fetch.bind(globalThis)) {
213
+ if (server.registration_endpoint === undefined) {
214
+ throw new TypeError('This DMP does not let apps register themselves (no registration_endpoint in discovery).');
215
+ }
216
+ const { clientName, redirectUris, ...extra } = request;
217
+ const response = await doFetch(server.registration_endpoint, {
218
+ method: 'POST',
219
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
220
+ body: JSON.stringify({ ...extra, client_name: clientName, redirect_uris: redirectUris }),
221
+ });
222
+ const json = await readJson(response);
223
+ if (!response.ok || json === null || typeof json !== 'object' || typeof json.client_id !== 'string') {
224
+ throw new DmpError(response.status, response.headers, json);
225
+ }
226
+ return json;
227
+ }
228
+ export async function discoverMetadata(issuer, doFetch = globalThis.fetch.bind(globalThis)) {
229
+ const url = `${issuer.replace(/\/+$/, '')}/.well-known/oauth-authorization-server`;
230
+ const response = await doFetch(url, { headers: { Accept: 'application/json' } });
231
+ const json = await readJson(response);
232
+ if (!response.ok || json === null || typeof json !== 'object') {
233
+ throw new DmpError(response.status, response.headers, json);
234
+ }
235
+ return json;
236
+ }
237
+ export function memoryTokenStore(initial = null) {
238
+ let tokens = initial;
239
+ return {
240
+ get: () => tokens,
241
+ set: (value) => {
242
+ tokens = value;
243
+ },
244
+ };
245
+ }
246
+ export function memoryKeyValueStore() {
247
+ const items = new Map();
248
+ return {
249
+ getItem: (key) => items.get(key) ?? null,
250
+ setItem: (key, value) => {
251
+ items.set(key, value);
252
+ },
253
+ removeItem: (key) => {
254
+ items.delete(key);
255
+ },
256
+ };
257
+ }
258
+ function defaultPendingStore() {
259
+ try {
260
+ const storage = globalThis.sessionStorage;
261
+ if (storage !== undefined) {
262
+ return storage;
263
+ }
264
+ }
265
+ catch {
266
+ // Blocked storage (a sandboxed iframe, strict privacy settings) throws on access.
267
+ }
268
+ return memoryKeyValueStore();
269
+ }
270
+ function browserLocation() {
271
+ const location = globalThis.location;
272
+ return location !== undefined && typeof location.assign === 'function' ? location : null;
273
+ }
274
+ /**
275
+ * Only the registered redirect URI is a callback; other pages may carry a `code` or `error` of their own. A trailing
276
+ * slash does not count: a static host answers `/callback` with a redirect to `/callback/` when it serves the page from
277
+ * `callback/index.html`.
278
+ */
279
+ function isCallbackUrl(url, redirectUri) {
280
+ const expected = new URL(redirectUri);
281
+ const path = (pathname) => pathname.replace(/\/+$/, '') || '/';
282
+ return url.origin === expected.origin && path(url.pathname) === path(expected.pathname);
283
+ }
284
+ function parsePending(raw) {
285
+ if (raw === null) {
286
+ return null;
287
+ }
288
+ try {
289
+ const value = JSON.parse(raw);
290
+ return typeof value.verifier === 'string' && typeof value.state === 'string'
291
+ && typeof value.redirectUri === 'string' && typeof value.clientId === 'string'
292
+ ? value
293
+ : null;
294
+ }
295
+ catch {
296
+ return null;
297
+ }
298
+ }
299
+ async function readJson(response) {
300
+ const text = await response.text();
301
+ if (text === '') {
302
+ return null;
303
+ }
304
+ try {
305
+ return JSON.parse(text);
306
+ }
307
+ catch {
308
+ return text;
309
+ }
310
+ }
311
+ function randomString(bytes) {
312
+ return base64url(crypto.getRandomValues(new Uint8Array(bytes)));
313
+ }
314
+ export function base64url(buffer) {
315
+ const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
316
+ let binary = '';
317
+ for (const byte of bytes) {
318
+ binary += String.fromCharCode(byte);
319
+ }
320
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
321
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import { CubeApp } from './app.js';
2
+ // The entry of dist/cube-sdk.js: one classic script, no imports, `window.CubeApp` for a page without a build step.
3
+ globalThis.CubeApp = CubeApp;
@@ -0,0 +1,102 @@
1
+ import { type AuthStrategy, type TokenSource } from './auth.js';
2
+ import type { AggregateQuery, Capabilities, DmpRecord, DmpResponse, GetQuery, ListQuery, ListResponse, OperationResponse } from './types.js';
3
+ export interface ClientOptions {
4
+ /**
5
+ * The API's base URL, prefix included: `https://dmp.example.nl/apps/api/v1` — what the panel's
6
+ * API → Connect screen and `dmp.instructions.de_api.basis_url` show. A relative one
7
+ * (`/apps/api/v1`) works in a browser behind a dev-server proxy.
8
+ */
9
+ baseUrl: string;
10
+ /** A token, a function returning one, `bearer(...)`, or an OAuth session from `createOAuth()`. */
11
+ auth?: TokenSource | AuthStrategy;
12
+ fetch?: typeof fetch;
13
+ /** Sent on every request. For example `X-Dmp-Act-As` from a server-side edge function — never a browser. */
14
+ headers?: Record<string, string>;
15
+ /**
16
+ * The fetch `cache` mode. Default `'no-cache'` in a browser (revalidate with the ETag rather than
17
+ * trust a cached list after a write), unset in Node.
18
+ */
19
+ cache?: RequestCache;
20
+ /** Every completed call, for a log or a devtools panel. Never receives the token. */
21
+ onResponse?: (entry: {
22
+ method: string;
23
+ url: string;
24
+ status: number;
25
+ ms: number;
26
+ }) => void;
27
+ }
28
+ export interface CreateOptions {
29
+ /**
30
+ * Send the same key again to get the first attempt's 201 replayed instead of a second record.
31
+ * Use a fresh one per logical create (`newIdempotencyKey()`), and the same body on a retry.
32
+ */
33
+ idempotencyKey?: string;
34
+ /** `fields[{resource}]` for the 201 body. Part of the idempotency fingerprint: repeat it on a retry. */
35
+ fields?: readonly string[];
36
+ }
37
+ export interface WriteOptions {
38
+ /** A `recordVersion` from an earlier response (never the ETag). A stale one answers 409 `conflict`. */
39
+ ifMatch?: string;
40
+ fields?: readonly string[];
41
+ }
42
+ export interface OperationOptions {
43
+ /** Required by the API for an operation `_capabilities` marks `idempotent: false`. */
44
+ idempotencyKey?: string;
45
+ /** Record operations only. */
46
+ ifMatch?: string;
47
+ fields?: readonly string[];
48
+ }
49
+ export interface RequestOptions {
50
+ query?: string;
51
+ body?: unknown;
52
+ headers?: Record<string, string>;
53
+ signal?: AbortSignal;
54
+ }
55
+ export interface ResourceClient<T extends DmpRecord = DmpRecord> {
56
+ readonly name: string;
57
+ /** `GET /{resource}`. Always paginated; `meta.page` says where you are. */
58
+ list(query?: ListQuery, init?: {
59
+ signal?: AbortSignal;
60
+ }): Promise<ListResponse<T>>;
61
+ /** Walks every page with the same query, one request per page. Mind the rate limit. */
62
+ pages(query?: ListQuery, init?: {
63
+ signal?: AbortSignal;
64
+ }): AsyncGenerator<ListResponse<T>, void, void>;
65
+ /** Every record of every page. */
66
+ all(query?: ListQuery, init?: {
67
+ signal?: AbortSignal;
68
+ }): AsyncGenerator<T, void, void>;
69
+ /** `GET /{resource}/{id}`. Accepts only fields, include and withCount. Carries `recordVersion`. */
70
+ get(id: string | number, query?: GetQuery, init?: {
71
+ signal?: AbortSignal;
72
+ }): Promise<DmpResponse<T>>;
73
+ /** `GET /{resource}/_aggregate`. `data` rows are keyed by exactly the `fn`/`group` strings you sent. */
74
+ aggregate<R extends DmpRecord = DmpRecord>(query: AggregateQuery, init?: {
75
+ signal?: AbortSignal;
76
+ }): Promise<DmpResponse<R[]>>;
77
+ /** `POST /{resource}`: 201 with the record, `location` and `recordVersion`; `replayed` on a replay. */
78
+ create(body: Partial<T>, options?: CreateOptions): Promise<DmpResponse<T>>;
79
+ /** `PATCH /{resource}/{id}`: partial, absent fields stay. */
80
+ update(id: string | number, body: Partial<T>, options?: WriteOptions): Promise<DmpResponse<T>>;
81
+ /** `DELETE /{resource}/{id}`: 204. Off on the server unless `allow_delete` is on. */
82
+ delete(id: string | number, options?: {
83
+ ifMatch?: string;
84
+ }): Promise<DmpResponse<null>>;
85
+ /** `POST /{resource}/{id}/{operation}`. A 202 means queued (`accepted`), a 204 done with no body. */
86
+ operation<R = T>(id: string | number, name: string, input?: Record<string, unknown>, options?: OperationOptions): Promise<OperationResponse<R>>;
87
+ /** `POST /{resource}/_operations/{operation}`. */
88
+ collectionOperation<R = T>(name: string, input?: Record<string, unknown>, options?: Omit<OperationOptions, 'ifMatch'>): Promise<OperationResponse<R>>;
89
+ }
90
+ export interface DmpClient {
91
+ /** `GET /_capabilities`: call it first. What this token and this user may reach, nothing more. */
92
+ capabilities(init?: {
93
+ signal?: AbortSignal;
94
+ }): Promise<Capabilities>;
95
+ resources<T extends DmpRecord = DmpRecord>(name: string): ResourceClient<T>;
96
+ /** The escape hatch: any path under the base URL, with the same auth, errors and envelope. */
97
+ request<T = unknown>(method: string, path: string, options?: RequestOptions): Promise<DmpResponse<T>>;
98
+ }
99
+ /** One client, resources as a property (OP-05): `createClient(...).resources('projects').list()`. */
100
+ export declare function createClient(options: ClientOptions): DmpClient;
101
+ /** A fresh key for one logical POST. Reuse it only to retry that same request with that same body. */
102
+ export declare function newIdempotencyKey(): string;
package/dist/client.js ADDED
@@ -0,0 +1,220 @@
1
+ import { bearer } from './auth.js';
2
+ import { DmpError } from './errors.js';
3
+ import { buildQuery } from './query.js';
4
+ const RESOURCE_NAME = /^[a-z][a-z0-9_]*$/;
5
+ /** One client, resources as a property (OP-05): `createClient(...).resources('projects').list()`. */
6
+ export function createClient(options) {
7
+ const baseUrl = options.baseUrl.replace(/\/+$/, '');
8
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
9
+ const auth = options.auth === undefined
10
+ ? null
11
+ : typeof options.auth === 'string' || typeof options.auth === 'function'
12
+ ? bearer(options.auth)
13
+ : options.auth;
14
+ const cache = options.cache ?? (typeof document !== 'undefined' ? 'no-cache' : undefined);
15
+ const request = async (method, path, { query, body, headers = {}, signal } = {}) => {
16
+ const url = `${baseUrl}${path.startsWith('/') ? path : `/${path}`}${query ? `?${query}` : ''}`;
17
+ const send = async () => {
18
+ const token = auth === null ? null : await auth.getAccessToken();
19
+ const init = {
20
+ method,
21
+ headers: {
22
+ Accept: 'application/json',
23
+ ...options.headers,
24
+ ...(token !== null ? { Authorization: `Bearer ${token}` } : {}),
25
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
26
+ ...headers,
27
+ },
28
+ };
29
+ if (body !== undefined) {
30
+ init.body = JSON.stringify(body);
31
+ }
32
+ if (cache !== undefined) {
33
+ init.cache = cache;
34
+ }
35
+ if (signal !== undefined) {
36
+ init.signal = signal;
37
+ }
38
+ const started = Date.now();
39
+ const response = await doFetch(url, init);
40
+ options.onResponse?.({ method, url, status: response.status, ms: Date.now() - started });
41
+ return response;
42
+ };
43
+ let response = await send();
44
+ // One retry, and only when the strategy says it has a new token: a second 401 is the answer.
45
+ if (response.status === 401 && auth?.onUnauthorized !== undefined && (await auth.onUnauthorized())) {
46
+ await response.body?.cancel().catch(() => undefined);
47
+ response = await send();
48
+ }
49
+ const parsed = await readBody(response);
50
+ if (!response.ok) {
51
+ throw new DmpError(response.status, response.headers, parsed);
52
+ }
53
+ const envelope = isEnvelope(parsed) ? parsed : null;
54
+ return {
55
+ status: response.status,
56
+ data: (envelope !== null ? envelope.data : parsed),
57
+ meta: envelope?.meta ?? null,
58
+ headers: response.headers,
59
+ etag: response.headers.get('ETag'),
60
+ recordVersion: response.headers.get('X-Dmp-Record-Version'),
61
+ location: response.headers.get('Location'),
62
+ replayed: response.headers.get('Idempotency-Replayed') === 'true',
63
+ rateLimit: rateLimit(response.headers),
64
+ };
65
+ };
66
+ const resources = (name) => {
67
+ if (!RESOURCE_NAME.test(name)) {
68
+ throw new TypeError(`'${name}' is not a resource name: cube/api names match [a-z][a-z0-9_]*.`);
69
+ }
70
+ const collection = `/${name}`;
71
+ const record = (id) => {
72
+ const key = String(id);
73
+ if (key === '') {
74
+ throw new TypeError(`${name}: an empty id would address the collection, not a record.`);
75
+ }
76
+ if (isDotSegment(key)) {
77
+ // The URL parser resolves '.' and '..' as path steps, so the request would leave this record.
78
+ throw new TypeError(`${name}: '${key}' is not an id: it would address another path, not a record.`);
79
+ }
80
+ return `${collection}/${encodeURIComponent(key)}`;
81
+ };
82
+ const fieldsQuery = (fields) => fields === undefined ? '' : buildQuery(name, { fields });
83
+ const list = async (query = {}, init = {}) => (await request('GET', collection, withSignal({ query: buildQuery(name, query) }, init)));
84
+ const pages = async function* (query = {}, init = {}) {
85
+ let number = query.page?.number ?? 1;
86
+ while (true) {
87
+ const page = await list({ ...query, page: { ...query.page, number } }, init);
88
+ yield page;
89
+ const meta = page.meta.page;
90
+ if (meta === undefined || meta === null || meta.number >= meta.pages || page.data.length === 0) {
91
+ return;
92
+ }
93
+ number = meta.number + 1;
94
+ }
95
+ };
96
+ const runOperation = async (path, input, opts) => {
97
+ const response = await request('POST', path, {
98
+ query: fieldsQuery(opts.fields),
99
+ body: input ?? {},
100
+ headers: writeHeaders(opts),
101
+ });
102
+ return { ...response, data: response.status === 204 ? null : response.data, accepted: response.status === 202 };
103
+ };
104
+ return {
105
+ name,
106
+ list,
107
+ pages,
108
+ async *all(query = {}, init = {}) {
109
+ for await (const page of pages(query, init)) {
110
+ yield* page.data;
111
+ }
112
+ },
113
+ get: (id, query = {}, init = {}) => request('GET', record(id), withSignal({ query: buildQuery(name, query) }, init)),
114
+ aggregate: (query, init = {}) => request('GET', `${collection}/_aggregate`, withSignal({ query: buildQuery(name, query) }, init)),
115
+ create: (body, opts = {}) => request('POST', collection, {
116
+ query: fieldsQuery(opts.fields),
117
+ body,
118
+ headers: writeHeaders(opts),
119
+ }),
120
+ update: (id, body, opts = {}) => request('PATCH', record(id), {
121
+ query: fieldsQuery(opts.fields),
122
+ body,
123
+ headers: writeHeaders(opts),
124
+ }),
125
+ delete: async (id, opts = {}) => {
126
+ const response = await request('DELETE', record(id), { headers: writeHeaders(opts) });
127
+ return { ...response, data: null };
128
+ },
129
+ operation: (id, operation, input, opts = {}) => runOperation(`${record(id)}/${encodeURIComponent(operationName(operation))}`, input, opts),
130
+ collectionOperation: (operation, input, opts = {}) => runOperation(`${collection}/_operations/${encodeURIComponent(operationName(operation))}`, input, opts),
131
+ };
132
+ };
133
+ return {
134
+ async capabilities(init = {}) {
135
+ const capabilities = (await request('GET', '/_capabilities', withSignal({}, init))).data;
136
+ return { ...capabilities, resources: capabilities.resources.map(normalizeResource) };
137
+ },
138
+ resources,
139
+ request,
140
+ };
141
+ }
142
+ /**
143
+ * PHP writes an empty map as `[]`: a resource without aggregatable fields or relations arrives with
144
+ * `aggregatable: []` and `relations: []`, and an operation without input fields with `input: []`.
145
+ * The types promise an object, so give one.
146
+ */
147
+ function normalizeResource(resource) {
148
+ return {
149
+ ...resource,
150
+ operations: (resource.operations ?? []).map((operation) => ({
151
+ ...operation,
152
+ input: emptyListAsMap(operation.input),
153
+ })),
154
+ aggregatable: emptyListAsMap(resource.aggregatable),
155
+ relations: emptyListAsMap(resource.relations),
156
+ };
157
+ }
158
+ function emptyListAsMap(value) {
159
+ return Array.isArray(value) || value === undefined || value === null ? {} : value;
160
+ }
161
+ /** A fresh key for one logical POST. Reuse it only to retry that same request with that same body. */
162
+ export function newIdempotencyKey() {
163
+ return crypto.randomUUID();
164
+ }
165
+ function operationName(name) {
166
+ if (name === '' || name.startsWith('_')) {
167
+ throw new TypeError(`'${name}' is not an operation name: the '_' namespace is reserved by the API.`);
168
+ }
169
+ if (isDotSegment(name)) {
170
+ throw new TypeError(`'${name}' is not an operation name: it would address another path.`);
171
+ }
172
+ return name;
173
+ }
174
+ /** encodeURIComponent keeps '.' as is, and '%2E' is decoded again by the URL parser, so both forms must go. */
175
+ function isDotSegment(segment) {
176
+ return segment === '.' || segment === '..';
177
+ }
178
+ function writeHeaders(options) {
179
+ const headers = {};
180
+ if (options.idempotencyKey !== undefined) {
181
+ headers['Idempotency-Key'] = options.idempotencyKey;
182
+ }
183
+ if (options.ifMatch !== undefined) {
184
+ if (options.ifMatch.startsWith('W/')) {
185
+ // The API refuses a weak tag with 409 anyway; saying why here saves a round trip.
186
+ throw new TypeError('ifMatch takes recordVersion (X-Dmp-Record-Version), not the weak ETag.');
187
+ }
188
+ headers['If-Match'] = options.ifMatch;
189
+ }
190
+ return headers;
191
+ }
192
+ function withSignal(options, init) {
193
+ return init.signal === undefined ? options : { ...options, signal: init.signal };
194
+ }
195
+ async function readBody(response) {
196
+ const text = await response.text();
197
+ if (text === '') {
198
+ return null;
199
+ }
200
+ try {
201
+ return JSON.parse(text);
202
+ }
203
+ catch {
204
+ return text;
205
+ }
206
+ }
207
+ function isEnvelope(value) {
208
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && 'data' in value;
209
+ }
210
+ function rateLimit(headers) {
211
+ const number = (name) => {
212
+ const value = headers.get(name);
213
+ return value === null || value === '' || !Number.isFinite(Number(value)) ? null : Number(value);
214
+ };
215
+ return {
216
+ limit: number('X-RateLimit-Limit'),
217
+ remaining: number('X-RateLimit-Remaining'),
218
+ reset: number('X-RateLimit-Reset'),
219
+ };
220
+ }