@jarenjs/contract 0.73.0 → 0.83.2
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/README.md +41 -0
- package/dist/types/app/binding.d.ts +10 -0
- package/dist/types/app/index.d.ts +1 -1
- package/dist/types/app/subscription.d.ts +14 -1
- package/dist/types/client/http.d.ts +1 -1
- package/dist/types/command.d.ts +27 -0
- package/dist/types/compile.d.ts +5 -0
- package/dist/types/errors.d.ts +4 -0
- package/dist/types/http/dispatch.d.ts +2 -2
- package/dist/types/http/wire.d.ts +6 -5
- package/dist/types/provider/compile.d.ts +73 -0
- package/dist/types/provider/execute.d.ts +104 -0
- package/dist/types/provider/index.d.ts +4 -0
- package/dist/types/provider/run.d.ts +33 -0
- package/docs/CONTRACT-FORMAT.md +107 -30
- package/docs/DURABLE.md +66 -0
- package/docs/PROVIDER-FORMAT.md +157 -0
- package/package.json +14 -6
- package/src/app/binding.js +23 -2
- package/src/app/index.js +1 -1
- package/src/app/subscription.js +32 -3
- package/src/client/http.js +10 -52
- package/src/command.js +70 -0
- package/src/compile.js +17 -12
- package/src/errors.js +4 -0
- package/src/http/dispatch.js +2 -2
- package/src/http/serve.js +1 -7
- package/src/http/wire.js +15 -10
- package/src/project/openapi.js +3 -1
- package/src/provider/compile.js +200 -0
- package/src/provider/execute.js +191 -0
- package/src/provider/index.js +5 -0
- package/src/provider/run.js +138 -0
package/src/client/http.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* Everything per operation is decided once at `open`.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import { backoffDelay as sharedBackoff, sleep as defaultSleep } from '@jarenjs/core/retry';
|
|
29
30
|
import { isJsonObject, setObjectMember } from '@jarenjs/core/object';
|
|
30
31
|
import { compileMessageCatalog } from '@jarenjs/core/message';
|
|
31
32
|
import { canonicalSha256 } from '@jarenjs/json/canonical';
|
|
@@ -199,9 +200,6 @@ const storageUpdates = new WeakMap();
|
|
|
199
200
|
/** The backoff ceiling of a retry, in ms. */
|
|
200
201
|
const BACKOFF_MAX = 8000;
|
|
201
202
|
|
|
202
|
-
/** The jitter added to a backoff, in ms (upper bound, exclusive). */
|
|
203
|
-
const BACKOFF_JITTER = 250;
|
|
204
|
-
|
|
205
203
|
/**
|
|
206
204
|
* @param {string} code
|
|
207
205
|
* @param {string} reason
|
|
@@ -211,42 +209,6 @@ function host(code, reason) {
|
|
|
211
209
|
return new ContractHostError(code, `openHttpClient: ${reason}`);
|
|
212
210
|
}
|
|
213
211
|
|
|
214
|
-
/**
|
|
215
|
-
* An `AbortError`-named error, the platform's when a signal carries one.
|
|
216
|
-
* @param {AbortSignal | null} signal
|
|
217
|
-
* @returns {unknown}
|
|
218
|
-
*/
|
|
219
|
-
function abortReason(signal) {
|
|
220
|
-
if (signal !== null && signal.reason !== undefined) return signal.reason;
|
|
221
|
-
const err = new Error('The operation was aborted.');
|
|
222
|
-
err.name = 'AbortError';
|
|
223
|
-
return err;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Abortable delay; rejects with the abort reason.
|
|
228
|
-
* @param {number} ms
|
|
229
|
-
* @param {AbortSignal} [signal]
|
|
230
|
-
* @returns {Promise<void>}
|
|
231
|
-
*/
|
|
232
|
-
function defaultSleep(ms, signal) {
|
|
233
|
-
return new Promise((resolve, reject) => {
|
|
234
|
-
if (signal !== undefined && signal.aborted) {
|
|
235
|
-
reject(abortReason(signal));
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
const onAbort = () => {
|
|
239
|
-
clearTimeout(timer);
|
|
240
|
-
reject(abortReason(signal ?? null));
|
|
241
|
-
};
|
|
242
|
-
const timer = setTimeout(() => {
|
|
243
|
-
if (signal !== undefined) signal.removeEventListener('abort', onAbort);
|
|
244
|
-
resolve();
|
|
245
|
-
}, ms);
|
|
246
|
-
if (signal !== undefined) signal.addEventListener('abort', onAbort, { once: true });
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
|
|
250
212
|
/**
|
|
251
213
|
* A rejection value's `name`, read guardedly; `null` when it has none.
|
|
252
214
|
* @param {unknown} err
|
|
@@ -289,7 +251,7 @@ function transportString(v) {
|
|
|
289
251
|
* @property {string} method
|
|
290
252
|
* @property {readonly import('../path.js').PathSegment[]} segments
|
|
291
253
|
* @property {readonly string[]} queryMembers
|
|
292
|
-
* @property {ReadonlySet<string>}
|
|
254
|
+
* @property {ReadonlySet<string>} queryJson
|
|
293
255
|
* @property {readonly string[]} headerMembers
|
|
294
256
|
* @property {readonly string[]} headerNames
|
|
295
257
|
* @property {readonly string[]} bodyMembers - body-located members when the body is their object
|
|
@@ -315,8 +277,6 @@ function prepare(op) {
|
|
|
315
277
|
const http = op.http;
|
|
316
278
|
/** @type {string[]} */
|
|
317
279
|
const queryMembers = [];
|
|
318
|
-
/** @type {Set<string>} */
|
|
319
|
-
const queryRepeated = new Set();
|
|
320
280
|
/** @type {string[]} */
|
|
321
281
|
const headerMembers = [];
|
|
322
282
|
/** @type {string[]} */
|
|
@@ -324,14 +284,12 @@ function prepare(op) {
|
|
|
324
284
|
/** @type {string[]} */
|
|
325
285
|
const bodyMembers = [];
|
|
326
286
|
const transport = op.input === null ? null : op.input.transport;
|
|
327
|
-
const repeated = new Set(transport === null ? [] : transport.members.repeated);
|
|
328
287
|
const members = Object.keys(http.in);
|
|
329
288
|
for (let i = 0; i < members.length; i++) {
|
|
330
289
|
const m = members[i];
|
|
331
290
|
const loc = http.in[m];
|
|
332
291
|
if (loc === 'query') {
|
|
333
292
|
queryMembers.push(m);
|
|
334
|
-
if (repeated.has(m)) queryRepeated.add(m);
|
|
335
293
|
}
|
|
336
294
|
else if (loc === 'header') {
|
|
337
295
|
headerMembers.push(m);
|
|
@@ -345,7 +303,7 @@ function prepare(op) {
|
|
|
345
303
|
method: http.method,
|
|
346
304
|
segments: http.template.segments,
|
|
347
305
|
queryMembers,
|
|
348
|
-
|
|
306
|
+
queryJson: new Set(transport === null ? [] : transport.queryJson),
|
|
349
307
|
headerMembers,
|
|
350
308
|
headerNames,
|
|
351
309
|
bodyMembers,
|
|
@@ -448,7 +406,8 @@ export function openHttpClient(contract, options = {}) {
|
|
|
448
406
|
* @returns {number}
|
|
449
407
|
*/
|
|
450
408
|
function backoffDelay(n) {
|
|
451
|
-
return
|
|
409
|
+
return sharedBackoff({ policy: 'contract-compat', baseMs: 1000, maxMs: BACKOFF_MAX,
|
|
410
|
+
random: () => hostFact('random', runtime.random) }, n + 1);
|
|
452
411
|
}
|
|
453
412
|
const now = options.now === undefined ? runtime.now : options.now;
|
|
454
413
|
/** @type {Catalog | null} */
|
|
@@ -535,13 +494,12 @@ export function openHttpClient(contract, options = {}) {
|
|
|
535
494
|
for (let i = 0; i < route.queryMembers.length; i++) {
|
|
536
495
|
const m = route.queryMembers[i];
|
|
537
496
|
const v = value[m];
|
|
538
|
-
if (
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
if (v[j] !== undefined && v[j] !== null) params.append(m, transportString(v[j]));
|
|
542
|
-
}
|
|
497
|
+
if (route.queryJson.has(m)) {
|
|
498
|
+
if (v !== undefined) params.append(m, JSON.stringify(v));
|
|
499
|
+
continue;
|
|
543
500
|
}
|
|
544
|
-
|
|
501
|
+
if (v === undefined || v === null) continue;
|
|
502
|
+
params.append(m, transportString(v));
|
|
545
503
|
}
|
|
546
504
|
const query = params.toString();
|
|
547
505
|
return query.length === 0 ? path : path + '?' + query;
|
package/src/command.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Opt-in business settlement shared by handlers on every invocation carrier. */
|
|
3
|
+
import { deepFreeze } from '@jarenjs/core/object';
|
|
4
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
5
|
+
import { ContractFailure, ContractHostError, ContractRuntimeError } from './errors.js';
|
|
6
|
+
import { runOperation, validateOperationInput } from './pipeline.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compile once around the existing neutral operation pipeline. Identity and
|
|
10
|
+
* current authorization are application policy, never inferred from a receipt.
|
|
11
|
+
* Handlers receive the repository's transaction as ctx.host; current-state reads
|
|
12
|
+
* belong to a separate read operation. Transport TTL policy remains independent.
|
|
13
|
+
* @param {import('./compile.js').CompiledOperation} operation
|
|
14
|
+
* @param {{ repository: { execute: Function }, identity: (input: any, context: any) => any,
|
|
15
|
+
* authorize: (input: any, context: any) => any, handler: (input: any, context: any) => any,
|
|
16
|
+
* commitFailures?: string[], references?: (result: any, context: any) => any[],
|
|
17
|
+
* refuse?: (reason: string, context: any) => any }} options
|
|
18
|
+
*/
|
|
19
|
+
export function createCommand(operation, options) {
|
|
20
|
+
if (!operation || operation.kind !== 'command' || operation.http.opaque || operation.policy.idempotency !== 'none' || typeof options?.repository?.execute !== 'function'
|
|
21
|
+
|| ['identity', 'authorize', 'handler'].some((name) => typeof options[name] !== 'function'))
|
|
22
|
+
throw new ContractHostError('JC1013', 'command settlement needs a JSON command, receipt repository, identity, authorization and handler');
|
|
23
|
+
const failures = new Set(options.commitFailures ?? []);
|
|
24
|
+
if ([...failures].some((code) => !Object.hasOwn(operation.errors, code))) throw new ContractHostError('JC1013', 'committed failure codes must be declared');
|
|
25
|
+
const route = { op: operation, handler: options.handler, raw: false, validateInput: operation.input?.validate ?? null,
|
|
26
|
+
validateOutput: operation.output.validate, details: operation.policy.errors.details, errors: operation.errors,
|
|
27
|
+
retryOn: new Set(operation.policy.retry?.on ?? []) };
|
|
28
|
+
const refused = (reason) => ({ state: 'refused', reason, historic: false });
|
|
29
|
+
/** @param {any} input @param {any} [context] */
|
|
30
|
+
async function execute(input, context = {}) {
|
|
31
|
+
// Freeze before asynchronous authority checks so callers cannot change the
|
|
32
|
+
// authorized request or the request whose hash the application computes.
|
|
33
|
+
const value = deepFreeze(JSON.parse(canonicalizeJson(input ?? null)));
|
|
34
|
+
if (await options.authorize(value, context) !== true) return refused('unauthorized');
|
|
35
|
+
if (validateOperationInput(route, value) !== null) throw new ContractRuntimeError('JC2110', 'command input failed validation', { msgid: 'contract/command-failed' });
|
|
36
|
+
const identity = options.identity(value, context);
|
|
37
|
+
if (identity?.op !== operation.id) throw new ContractHostError('JC1013', 'identity operation must match the compiled command');
|
|
38
|
+
try {
|
|
39
|
+
return await options.repository.execute(identity, async (tx) => {
|
|
40
|
+
if (context.signal?.aborted) throw refused('cancelled');
|
|
41
|
+
const hostContext = Object.freeze({ ...context, host: tx, fail: ContractFailure });
|
|
42
|
+
const result = await runOperation(route, value, hostContext);
|
|
43
|
+
if (result.kind === 'contract') throw new ContractRuntimeError('JC2110', 'command validation or handler failed', { msgid: 'contract/command-failed', cause: result.cause });
|
|
44
|
+
if (result.kind === 'failure' && !failures.has(result.code)) throw { state: 'uncommitted', historic: false, outcome: result };
|
|
45
|
+
if (context.signal?.aborted) throw refused('cancelled');
|
|
46
|
+
// Canonicalization rejects non-JSON values before any transaction commits.
|
|
47
|
+
const outcome = JSON.parse(canonicalizeJson(result.kind === 'failure' ? { ...result, details: result.details ?? null } : result));
|
|
48
|
+
return { outcome, references: options.references?.(outcome, hostContext) ?? [] };
|
|
49
|
+
}, context.lease === undefined ? {} : { lease: context.lease });
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error?.state === 'uncommitted' || error?.state === 'refused') return error;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
execute,
|
|
58
|
+
/** A deliberate carrier wrapper around the one settlement implementation.
|
|
59
|
+
* @param {any} input @param {any} context */
|
|
60
|
+
async handler(input, context) {
|
|
61
|
+
const settled = await execute(input, context);
|
|
62
|
+
if (settled.state === 'refused') {
|
|
63
|
+
if (options.refuse) return options.refuse(settled.reason, context);
|
|
64
|
+
throw new ContractRuntimeError('JC2110', 'command refused', { msgid: 'contract/command-failed' });
|
|
65
|
+
}
|
|
66
|
+
const outcome = settled.receipt?.outcome ?? settled.outcome;
|
|
67
|
+
return outcome.kind === 'failure' ? ContractFailure(outcome.code, outcome.params, outcome.details, { retryable: outcome.retryable }) : outcome.value;
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
package/src/compile.js
CHANGED
|
@@ -370,20 +370,18 @@ function checkRefs(node, docPath, scope, isRoot) {
|
|
|
370
370
|
*/
|
|
371
371
|
|
|
372
372
|
/**
|
|
373
|
-
* The transport half of an operation's input
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
* header member is a single line. Body members are never here.
|
|
381
|
-
* `schemas` holds each transport member's declared schema (what the
|
|
382
|
-
* normalizer was compiled over) and `required` the transport members the
|
|
383
|
-
* input schema requires — what a URL builder validates without the body.
|
|
373
|
+
* The transport half of an operation's input. Scalar path/query/header
|
|
374
|
+
* members use a compiled coercing normalizer; queryJson members carry
|
|
375
|
+
* one JSON value and bypass coercion. `repeated` marks array types (the
|
|
376
|
+
* header decoder collects their repeated lines or comma-separated list;
|
|
377
|
+
* JSON query encoding takes precedence for query members). Body members
|
|
378
|
+
* are never here. `schemas` and `required` describe every transport
|
|
379
|
+
* member for URL validation, including those excluded from normalization.
|
|
384
380
|
* @typedef {Object} InputTransport
|
|
385
381
|
* @property {(value: any) => any} normalize
|
|
386
382
|
* @property {{ path: readonly string[], query: readonly string[], header: readonly string[], repeated: readonly string[] }} members
|
|
383
|
+
* @property {readonly string[]} queryJson - query members with a declared
|
|
384
|
+
* object/array type, encoded as one JSON value (including nullable unions)
|
|
387
385
|
* @property {Readonly<Record<string, any>>} schemas
|
|
388
386
|
* @property {readonly string[]} required
|
|
389
387
|
*/
|
|
@@ -1074,6 +1072,7 @@ export function compileContract(doc, options = {}) {
|
|
|
1074
1072
|
const queryMembers = [];
|
|
1075
1073
|
const headerMembers = [];
|
|
1076
1074
|
const repeated = [];
|
|
1075
|
+
const queryJson = [];
|
|
1077
1076
|
/** @type {Record<string, any>} */
|
|
1078
1077
|
const pick = {};
|
|
1079
1078
|
for (let i = 0; i < p.members.length; i++) {
|
|
@@ -1089,18 +1088,24 @@ export function compileContract(doc, options = {}) {
|
|
|
1089
1088
|
const eff = effectiveSchema(schema, scope);
|
|
1090
1089
|
const type = isJsonObject(eff) ? eff.type : undefined;
|
|
1091
1090
|
if (type === 'array' || (Array.isArray(type) && type.includes('array'))) repeated.push(m);
|
|
1091
|
+
if (loc === 'query' && (type === 'object' || type === 'array'
|
|
1092
|
+
|| (Array.isArray(type) && (type.includes('object') || type.includes('array'))))) queryJson.push(m);
|
|
1092
1093
|
}
|
|
1093
1094
|
}
|
|
1094
1095
|
if (pathMembers.length + queryMembers.length + headerMembers.length > 0) {
|
|
1095
1096
|
// the sub-schema is rooted on the document itself, so every
|
|
1096
1097
|
// same-document `$ref` a member schema carries resolves exactly as
|
|
1097
1098
|
// it does for the validator
|
|
1098
|
-
|
|
1099
|
+
// JSON query values have the same typing discipline as a JSON
|
|
1100
|
+
// body: validate them verbatim, never coerce their descendants.
|
|
1101
|
+
const scalarPick = Object.fromEntries(Object.entries(pick).filter(([name]) => !queryJson.includes(name)));
|
|
1102
|
+
const sub = { ...src, type: 'object', properties: scalarPick };
|
|
1099
1103
|
const normalize = compileNormalizer(sub, { coerceTypes: true });
|
|
1100
1104
|
const declaredRequired = Array.isArray(p.inputEffective.required) ? p.inputEffective.required : [];
|
|
1101
1105
|
transport = {
|
|
1102
1106
|
normalize,
|
|
1103
1107
|
members: { path: pathMembers, query: queryMembers, header: headerMembers, repeated },
|
|
1108
|
+
queryJson,
|
|
1104
1109
|
schemas: pick,
|
|
1105
1110
|
required: declaredRequired.filter((/** @type {unknown} */ r) => typeof r === 'string' && Object.hasOwn(pick, r)),
|
|
1106
1111
|
};
|
package/src/errors.js
CHANGED
|
@@ -52,6 +52,7 @@ export const CONTRACT_CODES = Object.freeze({
|
|
|
52
52
|
JC0018: 'a subscribe operation declares a policy.task other than switch — a subscription slot is replaced, never queued',
|
|
53
53
|
JC0019: 'a subscribe operation is bound to a method other than GET — a stream is fetched, not sent',
|
|
54
54
|
JC0020: 'a subscribe operation declares a policy.idempotency other than none — a subscription registers, it does not commit',
|
|
55
|
+
JC0021: 'a provider protocol descriptor is malformed or names an undeclared transform capability',
|
|
55
56
|
// ——— projection compile (ContractCompileError, docPath into the contract document) ———
|
|
56
57
|
JC0060: 'the OpenAPI projection met a schema keyword it cannot map honestly: a boolean required (draft-04 style) or a same-document $ref that lands outside $defs (both dropped and reported under lenient), or a components member inside a schema',
|
|
57
58
|
JC0061: 'the public projection is not canonicalizable, so no revision exists — a string with an unpaired surrogate, say; docPath points at the offending value inside the projection',
|
|
@@ -67,6 +68,8 @@ export const CONTRACT_CODES = Object.freeze({
|
|
|
67
68
|
JC1009: 'encodeSseEvent (the stream wire): an event, id or data string the SSE frame cannot carry — a bare carriage return inside data, a line terminator inside event or id',
|
|
68
69
|
JC1010: 'client.subscribe was asked for an operation that is not a subscribe operation (invoke carries reads and commands; subscribe carries streams)',
|
|
69
70
|
JC1011: 'a ledger commit or fail named a ref that settles no started record: the key expired, was reclaimed under a newer generation, or was settled already — the settlement is refused; the binding reports it to onError and the response still goes out',
|
|
71
|
+
JC1012: 'a provider executor, descriptor host or run capability is malformed',
|
|
72
|
+
JC1013: 'a durable command settlement capability is malformed',
|
|
70
73
|
// ——— http request-time (ContractRuntimeError, mapped onto the wire) ———
|
|
71
74
|
JC2001: 'no operation matches the request method and path (404)',
|
|
72
75
|
JC2002: 'the path shape is served under other methods (405, Allow lists them)',
|
|
@@ -108,6 +111,7 @@ export const CONTRACT_CODES = Object.freeze({
|
|
|
108
111
|
JC2095: 'a requested resume was refused — informational, carried as resumed:false in the fresh snapshot\'s event data, never an outcome',
|
|
109
112
|
JC2096: 'the stream\'s bounded queue would overflow — the consumer reads slower than the source emits, or a replay page outran it (kind network, retryable; the stream ends with an error event carrying this code and the carrier tears the connection down)',
|
|
110
113
|
JC2097: 'the client\'s reconnect budget is exhausted: every attempt after a network loss failed the same way (kind network, not retryable; details carry the attempts made and the last network code, client-side)',
|
|
114
|
+
JC2110: 'a durable command was refused or failed validation',
|
|
111
115
|
});
|
|
112
116
|
|
|
113
117
|
/**
|
package/src/http/dispatch.js
CHANGED
|
@@ -133,7 +133,7 @@ import {
|
|
|
133
133
|
* @property {ReadonlySet<string>} nonBody - path/query/header member names, never taken from the body
|
|
134
134
|
* @property {readonly string[]} pathMembers
|
|
135
135
|
* @property {ReadonlySet<string>} queryMembers
|
|
136
|
-
* @property {ReadonlySet<string>}
|
|
136
|
+
* @property {ReadonlySet<string>} queryJson - JSON-encoded query members
|
|
137
137
|
* @property {readonly string[]} headerMembers - member names
|
|
138
138
|
* @property {readonly string[]} headerNames - the lowercase header of each
|
|
139
139
|
* @property {readonly boolean[]} headerArray - array-typed, per header member
|
|
@@ -597,7 +597,7 @@ function afterIdentity(server, request, route, trace, hit, isHead, method, path,
|
|
|
597
597
|
const m = route.pathMembers[i];
|
|
598
598
|
setObjectMember(input, m, params[m]);
|
|
599
599
|
}
|
|
600
|
-
if (!decodeQuery(query, route.queryMembers, route.
|
|
600
|
+
if (!decodeQuery(query, route.queryMembers, input, route.queryJson)) {
|
|
601
601
|
return refuse(server, 'JC2012', trace, {}, undefined, null, null);
|
|
602
602
|
}
|
|
603
603
|
/** @type {Record<string, string>} */
|
package/src/http/serve.js
CHANGED
|
@@ -158,8 +158,6 @@ function prepare(op, handler, tag) {
|
|
|
158
158
|
const pathMembers = [];
|
|
159
159
|
/** @type {Set<string>} */
|
|
160
160
|
const queryMembers = new Set();
|
|
161
|
-
/** @type {Set<string>} */
|
|
162
|
-
const repeated = new Set();
|
|
163
161
|
/** @type {string[]} */
|
|
164
162
|
const headerMembers = [];
|
|
165
163
|
/** @type {string[]} */
|
|
@@ -178,10 +176,6 @@ function prepare(op, handler, tag) {
|
|
|
178
176
|
headerNames.push(headerNameOf(m));
|
|
179
177
|
headerArray.push(transport.members.repeated.includes(m));
|
|
180
178
|
}
|
|
181
|
-
for (let i = 0; i < transport.members.repeated.length; i++) {
|
|
182
|
-
const m = transport.members.repeated[i];
|
|
183
|
-
if (queryMembers.has(m)) repeated.add(m);
|
|
184
|
-
}
|
|
185
179
|
}
|
|
186
180
|
const members = Object.keys(http.in);
|
|
187
181
|
for (let i = 0; i < members.length; i++) {
|
|
@@ -203,7 +197,7 @@ function prepare(op, handler, tag) {
|
|
|
203
197
|
nonBody,
|
|
204
198
|
pathMembers,
|
|
205
199
|
queryMembers,
|
|
206
|
-
|
|
200
|
+
queryJson: new Set(transport === null ? [] : transport.queryJson),
|
|
207
201
|
headerMembers,
|
|
208
202
|
headerNames,
|
|
209
203
|
headerArray,
|
package/src/http/wire.js
CHANGED
|
@@ -340,18 +340,19 @@ export function formatEntityTag(tag, strong) {
|
|
|
340
340
|
/**
|
|
341
341
|
* Decode a query string into the declared members of an input object:
|
|
342
342
|
* only declared names are set (an undeclared key is never merged, so no
|
|
343
|
-
* request can smuggle a member);
|
|
344
|
-
*
|
|
343
|
+
* request can smuggle a member); JSON members decode exactly one value,
|
|
344
|
+
* scalar members are last-wins; a `+` is a
|
|
345
345
|
* space and escapes decode as `application/x-www-form-urlencoded`
|
|
346
346
|
* (`URLSearchParams`). Returns `false` when the query is not decodable
|
|
347
|
-
* (
|
|
347
|
+
* (malformed percent-escape, UTF-8 or JSON, or a repeated JSON member)
|
|
348
|
+
* — the `JC2012` case.
|
|
348
349
|
* @param {string} query - the part after `?`, possibly empty
|
|
349
350
|
* @param {ReadonlySet<string>} declared - the query member names
|
|
350
|
-
* @param {ReadonlySet<string>} repeated - the array-typed ones
|
|
351
351
|
* @param {Record<string, unknown>} out - the input object under assembly
|
|
352
|
+
* @param {ReadonlySet<string>} json - schema-directed JSON members
|
|
352
353
|
* @returns {boolean} false when not decodable
|
|
353
354
|
*/
|
|
354
|
-
export function decodeQuery(query, declared,
|
|
355
|
+
export function decodeQuery(query, declared, out, json) {
|
|
355
356
|
if (query.length === 0) return true;
|
|
356
357
|
// URLSearchParams never throws: it keeps a malformed escape as its
|
|
357
358
|
// literal text and replaces invalid UTF-8; both are "not decodable"
|
|
@@ -368,12 +369,16 @@ export function decodeQuery(query, declared, repeated, out) {
|
|
|
368
369
|
const params = new URLSearchParams(query);
|
|
369
370
|
for (const [name, value] of params) {
|
|
370
371
|
if (!declared.has(name)) continue;
|
|
371
|
-
if (
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
372
|
+
if (json.has(name)) {
|
|
373
|
+
// One JSON value per declared structured member. Scalar strings
|
|
374
|
+
// are untouched; repeated JSON members are ambiguous and refused.
|
|
375
|
+
const values = params.getAll(name);
|
|
376
|
+
if (values.length !== 1) return false;
|
|
377
|
+
try { setObjectMember(out, name, JSON.parse(value)); }
|
|
378
|
+
catch { return false; }
|
|
379
|
+
continue;
|
|
375
380
|
}
|
|
376
|
-
|
|
381
|
+
setObjectMember(out, name, value);
|
|
377
382
|
}
|
|
378
383
|
return true;
|
|
379
384
|
}
|
package/src/project/openapi.js
CHANGED
|
@@ -324,7 +324,7 @@ function docLines(doc) {
|
|
|
324
324
|
function operationView(op, projected, ctx) {
|
|
325
325
|
const base = at('/operations', op.id);
|
|
326
326
|
const http = op.http;
|
|
327
|
-
/** @type {
|
|
327
|
+
/** @type {any[]} */
|
|
328
328
|
const parameters = [];
|
|
329
329
|
/** @type {Record<string, any>} */
|
|
330
330
|
const bodyMembers = {};
|
|
@@ -351,6 +351,8 @@ function operationView(op, projected, ctx) {
|
|
|
351
351
|
bodyCount++;
|
|
352
352
|
if (name === http.body) bodyMemberRequired = required;
|
|
353
353
|
}
|
|
354
|
+
else if (loc === 'query' && op.input?.transport?.queryJson.includes(name))
|
|
355
|
+
parameters.push({ name, in: loc, required, content: { 'application/json': { schema: mapped } } });
|
|
354
356
|
else parameters.push({ name, in: loc, required, schema: mapped });
|
|
355
357
|
}
|
|
356
358
|
if (op.policy.idempotency !== 'none') {
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Compile JSON protocol declarations to bounded pull-based page execution. */
|
|
3
|
+
import { deepFreeze, isJsonObject } from '@jarenjs/core/object';
|
|
4
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
5
|
+
import { compileJsonQuery } from '@jarenjs/json/query';
|
|
6
|
+
import { compileJsltStylesheet } from '@jarenjs/json/jslt';
|
|
7
|
+
import { ContractCompileError } from '../errors.js';
|
|
8
|
+
import { providerHostError } from './execute.js';
|
|
9
|
+
|
|
10
|
+
const clone = (value) => JSON.parse(canonicalizeJson(value));
|
|
11
|
+
const fail = (reason, path = '') => { throw new ContractCompileError('JC0021', reason, path); };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Compile selectors/transforms once using the existing Query/JSLT engines.
|
|
15
|
+
* Named callbacks receive cloned protocol data only, never the run host.
|
|
16
|
+
* These are trusted host functions, not a sandbox for arbitrary JavaScript.
|
|
17
|
+
* @param {any} document
|
|
18
|
+
* @param {{ callbacks?: Record<string, (value: any) => any>, compileSchema?: (schema: any) => (value: any) => boolean }} [options]
|
|
19
|
+
*/
|
|
20
|
+
export function compileProvider(document, options = {}) {
|
|
21
|
+
let doc;
|
|
22
|
+
try { doc = clone(document); }
|
|
23
|
+
catch { fail('provider descriptor must be JSON'); }
|
|
24
|
+
if (!isJsonObject(doc) || doc.$provider !== '0.1') fail('provider descriptor requires $provider:0.1');
|
|
25
|
+
const known = ['id', '$provider', 'apiVersion', 'endpoint', 'protocol', 'method', 'safety', 'headers', 'query', 'body', 'graphql', 'response', 'pagination', 'limits', 'capability', 'inputSchema'];
|
|
26
|
+
for (const key of Object.keys(doc)) if (!known.includes(key)) fail('unknown provider member', `/${key}`);
|
|
27
|
+
for (const name of ['id', 'apiVersion', 'endpoint']) if (typeof doc[name] !== 'string' || !doc[name]) fail(`${name} must be nonempty`, `/${name}`);
|
|
28
|
+
let endpoint;
|
|
29
|
+
try { endpoint = new URL(doc.endpoint); }
|
|
30
|
+
catch { fail('endpoint must be an absolute HTTP(S) URL', '/endpoint'); }
|
|
31
|
+
if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.hash)
|
|
32
|
+
fail('endpoint must be HTTP(S), without embedded credentials or a fragment', '/endpoint');
|
|
33
|
+
if (!['rest', 'graphql'].includes(doc.protocol) || !['safe-read', 'provider-idempotent', 'single-send'].includes(doc.safety))
|
|
34
|
+
fail('protocol and replay safety must be declared');
|
|
35
|
+
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].includes(doc.method)) fail('unsupported HTTP method', '/method');
|
|
36
|
+
if (doc.headers !== undefined && (!isJsonObject(doc.headers) || Object.entries(doc.headers).some(([name, value]) =>
|
|
37
|
+
typeof value !== 'string' || /authorization|cookie|token|secret|api[-_]key/i.test(name) || /[\r\n]/.test(name + value))))
|
|
38
|
+
fail('headers must be public static strings; authentication belongs to host transport', '/headers');
|
|
39
|
+
if (doc.capability !== undefined && !['json', 'upload', 'media', 'bulk', 'binary'].includes(doc.capability)) fail('unknown capability', '/capability');
|
|
40
|
+
const limits = doc.limits ?? {};
|
|
41
|
+
if (!isJsonObject(limits)) fail('limits must be an object', '/limits');
|
|
42
|
+
for (const [name, fallback] of Object.entries({ pages: 3, rows: 256, bytes: 262144 })) {
|
|
43
|
+
if (limits[name] === undefined) limits[name] = fallback;
|
|
44
|
+
if (!Number.isSafeInteger(limits[name]) || limits[name] < 1) fail('limits must be positive finite integers', `/limits/${name}`);
|
|
45
|
+
}
|
|
46
|
+
if (Object.keys(limits).some((name) => !['pages', 'rows', 'bytes'].includes(name))) fail('unknown limit', '/limits');
|
|
47
|
+
const pagination = doc.pagination ?? {};
|
|
48
|
+
if (!isJsonObject(pagination) || Object.keys(pagination).some((name) => !['cursorParam', 'cursorVariable', 'empty'].includes(name))
|
|
49
|
+
|| (pagination.empty !== undefined && !['complete', 'incomplete'].includes(pagination.empty))) fail('invalid pagination', '/pagination');
|
|
50
|
+
for (const name of ['cursorParam', 'cursorVariable']) if (pagination[name] !== undefined && (typeof pagination[name] !== 'string' || !pagination[name])) fail('cursor target must be nonempty', `/pagination/${name}`);
|
|
51
|
+
if (!isJsonObject(doc.response) || !Object.hasOwn(doc.response, 'rows') || !Object.hasOwn(doc.response, 'id')) fail('response needs rows and id selectors', '/response');
|
|
52
|
+
for (const key of Object.keys(doc.response)) if (!['rows', 'id', 'cursor', 'hasMore', 'errors', 'cost', 'version', 'transform', 'schema'].includes(key)) fail('unknown response member', `/response/${key}`);
|
|
53
|
+
const schema = (value, path) => {
|
|
54
|
+
if (value === undefined) return () => true;
|
|
55
|
+
if (typeof options.compileSchema !== 'function') fail('schema validation requires a declared compiler capability', path);
|
|
56
|
+
try {
|
|
57
|
+
const validate = options.compileSchema(value);
|
|
58
|
+
if (typeof validate !== 'function') fail('schema compiler must return a validator', path);
|
|
59
|
+
return validate;
|
|
60
|
+
}
|
|
61
|
+
catch { fail('schema could not be compiled', path); }
|
|
62
|
+
};
|
|
63
|
+
const validInput = schema(doc.inputSchema, '/inputSchema');
|
|
64
|
+
const validResponse = schema(doc.response.schema, '/response/schema');
|
|
65
|
+
|
|
66
|
+
const selector = (spec, path, fallback) => {
|
|
67
|
+
if (spec === undefined) return () => fallback;
|
|
68
|
+
try {
|
|
69
|
+
if (isJsonObject(spec) && Object.hasOwn(spec, 'callback')) {
|
|
70
|
+
if (Object.keys(spec).length !== 1 || typeof spec.callback !== 'string'
|
|
71
|
+
|| !Object.hasOwn(options.callbacks ?? {}, spec.callback) || typeof options.callbacks[spec.callback] !== 'function') fail('undeclared callback', path);
|
|
72
|
+
const callback = options.callbacks[spec.callback];
|
|
73
|
+
return (data) => clone(callback(clone(data)));
|
|
74
|
+
}
|
|
75
|
+
return compileJsonQuery(spec);
|
|
76
|
+
}
|
|
77
|
+
catch (error) { if (error instanceof ContractCompileError) throw error; fail('invalid selector', path); }
|
|
78
|
+
};
|
|
79
|
+
const rowsOf = selector(doc.response.rows, '/response/rows', []);
|
|
80
|
+
const idOf = selector(doc.response.id, '/response/id', null);
|
|
81
|
+
const cursorOf = selector(doc.response.cursor, '/response/cursor', null);
|
|
82
|
+
const moreOf = selector(doc.response.hasMore, '/response/hasMore', undefined);
|
|
83
|
+
const errorsOf = selector(doc.response.errors ?? (doc.protocol === 'graphql' ? '$.errors' : undefined), '/response/errors', null);
|
|
84
|
+
const costOf = selector(doc.response.cost, '/response/cost', null);
|
|
85
|
+
const versionOf = selector(doc.response.version, '/response/version', null);
|
|
86
|
+
const queryOf = selector(doc.query, '/query', {});
|
|
87
|
+
const bodyOf = selector(doc.body, '/body', undefined);
|
|
88
|
+
let transform = (row) => row;
|
|
89
|
+
if (doc.response.transform !== undefined) {
|
|
90
|
+
const spec = doc.response.transform;
|
|
91
|
+
if (spec?.kind === 'jslt') {
|
|
92
|
+
try { transform = compileJsltStylesheet(spec.expression); }
|
|
93
|
+
catch { fail('invalid JSLT transform', '/response/transform'); }
|
|
94
|
+
}
|
|
95
|
+
else if (spec?.kind === 'query') transform = selector(spec.expression, '/response/transform', null);
|
|
96
|
+
else if (spec?.callback) transform = selector(spec, '/response/transform', null);
|
|
97
|
+
else fail('transform must name query, jslt or a declared callback', '/response/transform');
|
|
98
|
+
}
|
|
99
|
+
let variablesOf;
|
|
100
|
+
if (doc.protocol === 'graphql') {
|
|
101
|
+
if (doc.method !== 'POST' || !isJsonObject(doc.graphql) || typeof doc.graphql.query !== 'string' || !doc.graphql.query.trim()
|
|
102
|
+
|| Object.keys(doc.graphql).some((name) => !['query', 'variables'].includes(name))) fail('GraphQL requires POST and a query with optional variables', '/graphql');
|
|
103
|
+
variablesOf = selector(doc.graphql.variables, '/graphql/variables', {});
|
|
104
|
+
}
|
|
105
|
+
else if (doc.graphql !== undefined) fail('graphql belongs to the GraphQL dialect', '/graphql');
|
|
106
|
+
if (['GET', 'HEAD'].includes(doc.method) && doc.body !== undefined) fail('GET/HEAD cannot carry a body', '/body');
|
|
107
|
+
deepFreeze(doc);
|
|
108
|
+
|
|
109
|
+
/** @param {any} input @param {any} context */
|
|
110
|
+
async function* pages(input, context) {
|
|
111
|
+
if (!context || typeof context.executor?.execute !== 'function') throw providerHostError('provider pages require an executor');
|
|
112
|
+
let pageCount = 0, rowCount = 0, byteCount = 0, attempts = 0;
|
|
113
|
+
let cursor = context.cursor ?? null;
|
|
114
|
+
const cursors = new Set(cursor === null ? [] : [canonicalizeJson(cursor)]);
|
|
115
|
+
const ids = new Set();
|
|
116
|
+
const end = (state, reason) => ({ state, reason, pages: pageCount, rows: rowCount, bytes: byteCount, attempts, cursor });
|
|
117
|
+
if (doc.capability && doc.capability !== 'json') { yield end('refused', 'unsupported-capability'); return; }
|
|
118
|
+
if (!validInput(input)) { yield end('refused', 'input-schema'); return; }
|
|
119
|
+
for (;;) {
|
|
120
|
+
if (context.signal?.aborted) { yield end('incomplete', 'cancelled'); return; }
|
|
121
|
+
if (pageCount >= limits.pages) { yield end('incomplete', 'page-limit'); return; }
|
|
122
|
+
const data = { input: clone(input), cursor, apiVersion: doc.apiVersion, partition: context.partition ?? null, sourceVersion: context.sourceVersion ?? null };
|
|
123
|
+
let request;
|
|
124
|
+
try {
|
|
125
|
+
const url = new URL(doc.endpoint);
|
|
126
|
+
const query = queryOf(data);
|
|
127
|
+
if (!isJsonObject(query)) throw new Error('query must return an object');
|
|
128
|
+
for (const [name, value] of Object.entries(query)) {
|
|
129
|
+
if (value !== null && !['string', 'number', 'boolean'].includes(typeof value)) throw new Error('query values must be scalar');
|
|
130
|
+
if (value !== null) url.searchParams.set(name, String(value));
|
|
131
|
+
}
|
|
132
|
+
if (cursor !== null && pagination.cursorParam) url.searchParams.set(pagination.cursorParam, String(cursor));
|
|
133
|
+
let body = bodyOf(data);
|
|
134
|
+
if (doc.protocol === 'graphql') {
|
|
135
|
+
const variables = clone(variablesOf(data));
|
|
136
|
+
if (!isJsonObject(variables)) throw new Error('variables must be an object');
|
|
137
|
+
if (pagination.cursorVariable) Object.defineProperty(variables, pagination.cursorVariable, { value: cursor, enumerable: true, configurable: true, writable: true });
|
|
138
|
+
body = { query: doc.graphql.query, variables };
|
|
139
|
+
}
|
|
140
|
+
request = { url: url.href, method: doc.method, headers: { ...doc.headers, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
141
|
+
...(body === undefined ? {} : { body: canonicalizeJson(body) }), safety: doc.safety,
|
|
142
|
+
account: context.account, idempotencyKey: context.idempotencyKey };
|
|
143
|
+
}
|
|
144
|
+
catch { yield end('refused', 'request-transform'); return; }
|
|
145
|
+
const response = await context.executor.execute(request, { ...context, maxBytes: limits.bytes - byteCount });
|
|
146
|
+
attempts += response.attempts;
|
|
147
|
+
byteCount += response.bytes;
|
|
148
|
+
if (response.state !== 'ok') { yield { ...end(response.state === 'refused' && response.reason !== 'byte-limit' ? 'refused' : 'incomplete', response.reason), response }; return; }
|
|
149
|
+
if (byteCount > limits.bytes) { yield end('incomplete', 'byte-limit'); return; }
|
|
150
|
+
let raw, rows, rowIds, next, hasMore, errors, cost, version;
|
|
151
|
+
try {
|
|
152
|
+
raw = JSON.parse(response.text);
|
|
153
|
+
if (!validResponse(raw)) throw new Error('response schema failed');
|
|
154
|
+
rows = rowsOf(raw);
|
|
155
|
+
if (!Array.isArray(rows)) throw new Error('rows must be an array');
|
|
156
|
+
rowIds = rows.map((row) => idOf(row));
|
|
157
|
+
if (rowIds.some((id) => (typeof id !== 'string' || !id) && (typeof id !== 'number' || !Number.isSafeInteger(id)))) throw new Error('ids must be nonempty strings or safe integers');
|
|
158
|
+
rows = rows.map((row) => clone(transform(clone(row))));
|
|
159
|
+
next = cursorOf(raw) ?? null;
|
|
160
|
+
if (next !== null && !['string', 'number'].includes(typeof next)) throw new Error('cursor must be a scalar');
|
|
161
|
+
hasMore = moreOf(raw);
|
|
162
|
+
if (hasMore !== undefined && typeof hasMore !== 'boolean') throw new Error('hasMore must be boolean');
|
|
163
|
+
hasMore ??= next !== null;
|
|
164
|
+
errors = errorsOf(raw) ?? null;
|
|
165
|
+
cost = costOf(raw) ?? null;
|
|
166
|
+
version = versionOf(raw) ?? null;
|
|
167
|
+
}
|
|
168
|
+
catch { yield { ...end('incomplete', 'response-transform'), text: response.text }; return; }
|
|
169
|
+
pageCount++;
|
|
170
|
+
rowCount += rows.length;
|
|
171
|
+
let reason = null;
|
|
172
|
+
if (errors !== null && (!Array.isArray(errors) || errors.length)) reason = 'partial-errors';
|
|
173
|
+
else if (rowCount > limits.rows) reason = 'row-limit';
|
|
174
|
+
else if (!rows.length && (hasMore || pagination.empty !== 'complete')) reason = 'empty-page';
|
|
175
|
+
else if (rowIds.some((id) => ids.has(canonicalizeJson(id))) || new Set(rowIds.map((id) => canonicalizeJson(id))).size !== rowIds.length) reason = 'no-progress';
|
|
176
|
+
else if (hasMore && (next === null || cursors.has(canonicalizeJson(next)))) reason = 'no-progress';
|
|
177
|
+
const complete = reason === null && !hasMore;
|
|
178
|
+
yield { state: 'page', raw, text: response.text, rows, ids: rowIds, cursor, continuation: hasMore ? next : null,
|
|
179
|
+
complete, errors, cost, version, reason, bytes: response.bytes, attempts: response.attempts };
|
|
180
|
+
if (reason !== null) { yield end('incomplete', reason); return; }
|
|
181
|
+
if (complete) { yield end('complete', 'complete'); return; }
|
|
182
|
+
for (const id of rowIds) ids.add(canonicalizeJson(id));
|
|
183
|
+
cursors.add(canonicalizeJson(next));
|
|
184
|
+
cursor = next;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return Object.freeze({
|
|
188
|
+
document: doc,
|
|
189
|
+
pages,
|
|
190
|
+
/** Collect a bounded pull; streaming consumers should iterate pages instead.
|
|
191
|
+
* @param {any} input @param {any} context @returns {Promise<any>} */
|
|
192
|
+
async pull(input, context) {
|
|
193
|
+
const observations = [];
|
|
194
|
+
for await (const page of pages(input, context)) {
|
|
195
|
+
if (page.state === 'page') observations.push(page);
|
|
196
|
+
else return { ...page, observations };
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|