@jarenjs/contract 0.43.1
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 +508 -0
- package/dist/types/adapters/fetch.d.ts +27 -0
- package/dist/types/adapters/node.d.ts +47 -0
- package/dist/types/app/binding.d.ts +122 -0
- package/dist/types/app/effect.d.ts +77 -0
- package/dist/types/app/index.d.ts +31 -0
- package/dist/types/app/subscription.d.ts +82 -0
- package/dist/types/bundle.d.ts +43 -0
- package/dist/types/cli.d.ts +15 -0
- package/dist/types/client/http.d.ts +242 -0
- package/dist/types/client/outcome.d.ts +289 -0
- package/dist/types/compat.d.ts +36 -0
- package/dist/types/compile.d.ts +196 -0
- package/dist/types/describe.d.ts +115 -0
- package/dist/types/diff.d.ts +91 -0
- package/dist/types/errors.d.ts +205 -0
- package/dist/types/http/dispatch.d.ts +148 -0
- package/dist/types/http/serve.d.ts +154 -0
- package/dist/types/http/wire.d.ts +334 -0
- package/dist/types/index.d.ts +39 -0
- package/dist/types/ledger.d.ts +207 -0
- package/dist/types/local/index.d.ts +127 -0
- package/dist/types/messages.d.ts +63 -0
- package/dist/types/path.d.ts +119 -0
- package/dist/types/pipeline.d.ts +157 -0
- package/dist/types/port/client.d.ts +142 -0
- package/dist/types/port/frame.d.ts +195 -0
- package/dist/types/port/serve.d.ts +102 -0
- package/dist/types/project/index.d.ts +34 -0
- package/dist/types/project/markdown.d.ts +28 -0
- package/dist/types/project/openapi.d.ts +102 -0
- package/dist/types/project/tools.d.ts +57 -0
- package/dist/types/project/typescript.d.ts +59 -0
- package/dist/types/public.d.ts +73 -0
- package/dist/types/revision.d.ts +36 -0
- package/dist/types/stream/client.d.ts +104 -0
- package/dist/types/stream/server.d.ts +106 -0
- package/dist/types/stream/sse.d.ts +62 -0
- package/docs/APP-INTEGRATION.md +301 -0
- package/docs/CONTRACT-FORMAT.md +1923 -0
- package/package.json +110 -0
- package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
- package/schemas/jaren-contract-port.schema.json +241 -0
- package/schemas/jaren-contract.draft-07.schema.json +287 -0
- package/schemas/jaren-contract.schema.json +287 -0
- package/src/adapters/fetch.js +109 -0
- package/src/adapters/node.js +238 -0
- package/src/app/binding.js +426 -0
- package/src/app/effect.js +190 -0
- package/src/app/index.js +26 -0
- package/src/app/subscription.js +130 -0
- package/src/bundle.js +168 -0
- package/src/cli.js +264 -0
- package/src/client/http.js +1150 -0
- package/src/client/outcome.js +364 -0
- package/src/compat.js +62 -0
- package/src/compile.js +1162 -0
- package/src/describe.js +109 -0
- package/src/diff.js +610 -0
- package/src/errors.js +236 -0
- package/src/http/dispatch.js +1054 -0
- package/src/http/serve.js +301 -0
- package/src/http/wire.js +469 -0
- package/src/index.js +33 -0
- package/src/ledger.js +225 -0
- package/src/local/index.js +363 -0
- package/src/messages.js +68 -0
- package/src/path.js +471 -0
- package/src/pipeline.js +241 -0
- package/src/port/client.js +518 -0
- package/src/port/frame.js +196 -0
- package/src/port/serve.js +442 -0
- package/src/project/index.js +29 -0
- package/src/project/markdown.js +244 -0
- package/src/project/openapi.js +564 -0
- package/src/project/openapi.jslt.json +149 -0
- package/src/project/tools.js +139 -0
- package/src/project/typescript.js +152 -0
- package/src/project/typescript.jtlt.json +72 -0
- package/src/public.js +206 -0
- package/src/revision.js +90 -0
- package/src/stream/client.js +212 -0
- package/src/stream/server.js +306 -0
- package/src/stream/sse.js +67 -0
package/src/compile.js
ADDED
|
@@ -0,0 +1,1162 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `compileContract`: a `$contract` document (docs/CONTRACT-FORMAT.md
|
|
4
|
+
* §2) becomes a frozen `Contract` — per-operation validators, transport
|
|
5
|
+
* normalizers, the materialized HTTP binding with every default resolved,
|
|
6
|
+
* and one path matcher over the operation table.
|
|
7
|
+
*
|
|
8
|
+
* Two stages, like every compiler in the suite: everything is decided
|
|
9
|
+
* here, once; nothing that runs per request allocates or re-reads the
|
|
10
|
+
* document. The document is TRUSTED input but is still read totally: it
|
|
11
|
+
* is first snapshotted through guarded access into plain JSON (a
|
|
12
|
+
* throwing accessor, a non-JSON member or a cycle is `JC0001` at the
|
|
13
|
+
* member), then validated rule by rule against a CLOSED vocabulary
|
|
14
|
+
* (`JC0013` — a silently ignored `policy` member is a behavior bug), and
|
|
15
|
+
* only then compiled. Every refusal is a `ContractCompileError` with the
|
|
16
|
+
* `docPath` of the member at fault.
|
|
17
|
+
*
|
|
18
|
+
* `$ref` resolution rides the validator: the document is registered under
|
|
19
|
+
* a synthetic id and each operation schema is compiled as a `$ref` into
|
|
20
|
+
* it, so `#/$defs/Product` means the contract's own `$defs` and an
|
|
21
|
+
* absolute `$id` means one of `options.schemas`. Unresolved is `JC0007`
|
|
22
|
+
* at compile — never at request time.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { isJsonObject, setObjectMember, deepFreeze } from '@jarenjs/core/object';
|
|
26
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
27
|
+
import {
|
|
28
|
+
compileNormalizer, collectSameDocumentAnchors, resolveSameDocumentRef,
|
|
29
|
+
} from '@jarenjs/validate/normalize';
|
|
30
|
+
import { encodeJSONPointerSegment, parseJSONPointer } from '@jarenjs/json/pointer';
|
|
31
|
+
|
|
32
|
+
import { ContractCompileError } from './errors.js';
|
|
33
|
+
import { parsePathTemplate, pathShape, compileRoutes } from './path.js';
|
|
34
|
+
import { describeContract } from './describe.js';
|
|
35
|
+
import { contractRevision } from './revision.js';
|
|
36
|
+
|
|
37
|
+
//#region vocabulary
|
|
38
|
+
|
|
39
|
+
const CONTRACT_VERSION = '0.1';
|
|
40
|
+
|
|
41
|
+
const ROOT_MEMBERS = new Set(['$contract', 'id', 'version', 'compat', '$defs', 'operations']);
|
|
42
|
+
const OP_MEMBERS = new Set(['kind', 'input', 'output', 'errors', 'policy', 'http', 'doc']);
|
|
43
|
+
const POLICY_MEMBERS = new Set(['task', 'idempotency', 'revision', 'cache', 'limits', 'errors', 'retry', 'stream', 'audience']);
|
|
44
|
+
const LIMITS_MEMBERS = new Set(['maxBodyBytes']);
|
|
45
|
+
const STREAM_MEMBERS = new Set(['resume', 'heartbeatMs', 'maxPatchBytes']);
|
|
46
|
+
const POLICY_ERRORS_MEMBERS = new Set(['details']);
|
|
47
|
+
const RETRY_MEMBERS = new Set(['max', 'on']);
|
|
48
|
+
const HTTP_MEMBERS = new Set(['method', 'path', 'in', 'body', 'status', 'media']);
|
|
49
|
+
const ERROR_DECL_MEMBERS = new Set(['status', 'schema']);
|
|
50
|
+
|
|
51
|
+
const KINDS = Object.freeze(['read', 'command', 'subscribe']);
|
|
52
|
+
const TASKS = Object.freeze(['switch', 'exhaust', 'concat', 'parallel']);
|
|
53
|
+
const IDEMPOTENCY = Object.freeze(['none', 'optional', 'required']);
|
|
54
|
+
const CACHE = Object.freeze(['none', 'revision']);
|
|
55
|
+
const RESUME = Object.freeze(['snapshot', 'replay']);
|
|
56
|
+
const DETAILS = Object.freeze(['none', 'paths', 'full']);
|
|
57
|
+
const AUDIENCES = Object.freeze(['public', 'server']);
|
|
58
|
+
const METHODS = Object.freeze(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);
|
|
59
|
+
const LOCATIONS = Object.freeze(['path', 'query', 'header', 'body']);
|
|
60
|
+
|
|
61
|
+
const DEFAULT_MAX_BODY_BYTES = 1048576;
|
|
62
|
+
const DEFAULT_MEDIA = 'application/json';
|
|
63
|
+
const STREAM_MEDIA = 'text/event-stream';
|
|
64
|
+
const DEFAULT_STATUS = 200;
|
|
65
|
+
const DEFAULT_ERROR_STATUS = 400;
|
|
66
|
+
const DEFAULT_HEARTBEAT_MS = 15000;
|
|
67
|
+
|
|
68
|
+
/** The contract `id`: an identifier that may carry hyphens. */
|
|
69
|
+
const CONTRACT_ID = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
70
|
+
/** An operation id: dotted lowercase words. */
|
|
71
|
+
const OP_ID = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$/;
|
|
72
|
+
/** A declared error code: a lowercase hyphenated word. */
|
|
73
|
+
const ERROR_CODE = /^[a-z][a-z0-9-]*$/;
|
|
74
|
+
/** A media type: `type/subtype` with optional parameters. */
|
|
75
|
+
const MEDIA_TYPE = /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+(?:\s*;.*)?$/;
|
|
76
|
+
|
|
77
|
+
/** JSON-value keywords whose content is data, not schema — not walked for `$ref`. */
|
|
78
|
+
const DATA_KEYWORDS = new Set(['const', 'enum', 'default', 'examples']);
|
|
79
|
+
|
|
80
|
+
/** One synthetic `$id` per compile, so a shared validator never sees two documents under one name. */
|
|
81
|
+
let compileSequence = 0;
|
|
82
|
+
|
|
83
|
+
//#endregion
|
|
84
|
+
|
|
85
|
+
//#region helpers
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} code
|
|
89
|
+
* @param {string} reason
|
|
90
|
+
* @param {string} docPath
|
|
91
|
+
* @param {Error} [cause]
|
|
92
|
+
* @returns {ContractCompileError}
|
|
93
|
+
*/
|
|
94
|
+
function refuse(code, reason, docPath, cause) {
|
|
95
|
+
return new ContractCompileError(code, reason, docPath, cause);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {unknown} v
|
|
100
|
+
* @returns {Error | undefined}
|
|
101
|
+
*/
|
|
102
|
+
function asCause(v) {
|
|
103
|
+
return v instanceof Error ? v : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Append one reference token to a JSON Pointer.
|
|
108
|
+
* @param {string} base
|
|
109
|
+
* @param {string | number} key
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
function at(base, key) {
|
|
113
|
+
return `${base}/${encodeJSONPointerSegment(key)}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A URI fragment addressing `pointer` inside the registered document.
|
|
118
|
+
* Reference tokens are RFC 6901-escaped and then percent-encoded, which
|
|
119
|
+
* the validator decodes; `~` is unreserved so `~0`/`~1` survive.
|
|
120
|
+
* @param {(string | number)[]} tokens
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
function fragment(tokens) {
|
|
124
|
+
let out = '#';
|
|
125
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
126
|
+
out += '/' + encodeURIComponent(encodeJSONPointerSegment(tokens[i]));
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* True for a JSON Schema value: an object or a boolean.
|
|
133
|
+
* @param {unknown} v
|
|
134
|
+
* @returns {boolean}
|
|
135
|
+
*/
|
|
136
|
+
function isSchema(v) {
|
|
137
|
+
return typeof v === 'boolean' || isJsonObject(v);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Whether a media type carries JSON: `application/json` or any `+json`
|
|
142
|
+
* structured syntax suffix; parameters are ignored.
|
|
143
|
+
* @param {string} media
|
|
144
|
+
* @returns {boolean}
|
|
145
|
+
*/
|
|
146
|
+
function isJsonMedia(media) {
|
|
147
|
+
const semi = media.indexOf(';');
|
|
148
|
+
const bare = (semi === -1 ? media : media.slice(0, semi)).trim().toLowerCase();
|
|
149
|
+
return bare === 'application/json' || bare.endsWith('+json');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Freeze a compiled structure at every level, functions included, so a
|
|
154
|
+
* host cannot reshape what a binding will read per request.
|
|
155
|
+
* @template T
|
|
156
|
+
* @param {T} value
|
|
157
|
+
* @returns {T}
|
|
158
|
+
*/
|
|
159
|
+
function freezeAll(value) {
|
|
160
|
+
if (typeof value === 'function') return Object.freeze(value);
|
|
161
|
+
if (typeof value !== 'object' || value === null) return value;
|
|
162
|
+
if (Object.isFrozen(value)) return value;
|
|
163
|
+
const keys = Object.keys(value);
|
|
164
|
+
for (let i = 0; i < keys.length; i++) freezeAll(/** @type {any} */ (value)[keys[i]]);
|
|
165
|
+
return Object.freeze(value);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Snapshot a document member into plain JSON through guarded access.
|
|
170
|
+
* TOTAL: an accessor that throws, a member that is not a JSON value
|
|
171
|
+
* (function, symbol, bigint, non-finite number, class instance), or a
|
|
172
|
+
* cycle is `JC0001` at the member's `docPath`. `undefined` members are
|
|
173
|
+
* absent, as JSON would have them.
|
|
174
|
+
* @param {unknown} value
|
|
175
|
+
* @param {string} docPath
|
|
176
|
+
* @param {Set<object>} path - containers on the current descent
|
|
177
|
+
* @returns {any}
|
|
178
|
+
*/
|
|
179
|
+
function snapshot(value, docPath, path) {
|
|
180
|
+
switch (typeof value) {
|
|
181
|
+
case 'string':
|
|
182
|
+
case 'boolean':
|
|
183
|
+
return value;
|
|
184
|
+
case 'number':
|
|
185
|
+
if (!Number.isFinite(value)) throw refuse('JC0001', 'a non-finite number is not a JSON value', docPath);
|
|
186
|
+
return value;
|
|
187
|
+
case 'object':
|
|
188
|
+
break;
|
|
189
|
+
default:
|
|
190
|
+
throw refuse('JC0001', `a ${typeof value} is not a JSON value`, docPath);
|
|
191
|
+
}
|
|
192
|
+
if (value === null) return null;
|
|
193
|
+
const obj = /** @type {any} */ (value);
|
|
194
|
+
let isArray;
|
|
195
|
+
let proto;
|
|
196
|
+
let keys;
|
|
197
|
+
try {
|
|
198
|
+
isArray = Array.isArray(obj);
|
|
199
|
+
proto = isArray ? null : Object.getPrototypeOf(obj);
|
|
200
|
+
keys = isArray ? null : Object.keys(obj);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
throw refuse('JC0001', 'the member threw when read', docPath, asCause(err));
|
|
204
|
+
}
|
|
205
|
+
if (!isArray && proto !== Object.prototype && proto !== null) {
|
|
206
|
+
throw refuse('JC0001', 'a class instance is not a JSON value (expected a plain object)', docPath);
|
|
207
|
+
}
|
|
208
|
+
if (path.has(obj)) throw refuse('JC0001', 'the document is cyclic', docPath);
|
|
209
|
+
path.add(obj);
|
|
210
|
+
let out;
|
|
211
|
+
if (isArray) {
|
|
212
|
+
let length;
|
|
213
|
+
try {
|
|
214
|
+
length = obj.length;
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
throw refuse('JC0001', 'the member threw when read', docPath, asCause(err));
|
|
218
|
+
}
|
|
219
|
+
out = new Array(length);
|
|
220
|
+
for (let i = 0; i < length; i++) {
|
|
221
|
+
let item;
|
|
222
|
+
try {
|
|
223
|
+
item = obj[i];
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
throw refuse('JC0001', 'the member threw when read', at(docPath, i), asCause(err));
|
|
227
|
+
}
|
|
228
|
+
out[i] = item === undefined ? null : snapshot(item, at(docPath, i), path);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
out = {};
|
|
233
|
+
const names = /** @type {string[]} */ (keys);
|
|
234
|
+
for (let i = 0; i < names.length; i++) {
|
|
235
|
+
const key = names[i];
|
|
236
|
+
let member;
|
|
237
|
+
try {
|
|
238
|
+
member = obj[key];
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
throw refuse('JC0001', 'the member threw when read', at(docPath, key), asCause(err));
|
|
242
|
+
}
|
|
243
|
+
if (member === undefined) continue;
|
|
244
|
+
setObjectMember(out, key, snapshot(member, at(docPath, key), path));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
path.delete(obj);
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
//#endregion
|
|
252
|
+
|
|
253
|
+
//#region references
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The reference resolver of one compile: same-document refs through the
|
|
257
|
+
* document's anchors, absolute refs through the validator's registry.
|
|
258
|
+
* @typedef {Object} RefScope
|
|
259
|
+
* @property {any} src - the snapshotted document
|
|
260
|
+
* @property {Map<string, object>} anchors
|
|
261
|
+
* @property {JarenValidator<any>} validator
|
|
262
|
+
*/
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A URI fragment as a same-document reference: percent-decoded when it
|
|
266
|
+
* decodes, kept verbatim otherwise (the resolver then fails to find it).
|
|
267
|
+
* @param {string} frag
|
|
268
|
+
* @returns {string}
|
|
269
|
+
*/
|
|
270
|
+
function decodeFragment(frag) {
|
|
271
|
+
try {
|
|
272
|
+
return decodeURIComponent(frag);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return frag;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Follow a `$ref` chain to the schema it names, or `undefined` when a
|
|
281
|
+
* link does not resolve. Bounded so a reference cycle terminates.
|
|
282
|
+
* @param {any} node
|
|
283
|
+
* @param {RefScope} scope
|
|
284
|
+
* @returns {any}
|
|
285
|
+
*/
|
|
286
|
+
function effectiveSchema(node, scope) {
|
|
287
|
+
let cur = node;
|
|
288
|
+
for (let hops = 0; hops < 32 && isJsonObject(cur) && typeof cur.$ref === 'string'; hops++) {
|
|
289
|
+
const ref = cur.$ref;
|
|
290
|
+
let target;
|
|
291
|
+
if (ref.startsWith('#')) {
|
|
292
|
+
target = resolveSameDocumentRef(ref, scope.src, scope.anchors);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
const hash = ref.indexOf('#');
|
|
296
|
+
const base = hash === -1 ? ref : ref.slice(0, hash);
|
|
297
|
+
const frag = hash === -1 ? '#' : ref.slice(hash);
|
|
298
|
+
const external = scope.validator.getSchema(base);
|
|
299
|
+
target = external === null || external === undefined
|
|
300
|
+
? undefined
|
|
301
|
+
: resolveSameDocumentRef(decodeFragment(frag), external, collectSameDocumentAnchors(external));
|
|
302
|
+
}
|
|
303
|
+
if (target === undefined) return undefined;
|
|
304
|
+
cur = target;
|
|
305
|
+
}
|
|
306
|
+
return cur;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Walk a schema subtree and refuse (`JC0007`) the first `$ref` that
|
|
311
|
+
* resolves neither inside the document nor to a registered `$id`, at
|
|
312
|
+
* the `$ref` member's own `docPath`. A subtree declaring its own `$id`
|
|
313
|
+
* is an embedded resource with another base and is left to the
|
|
314
|
+
* validator's whole-document probe.
|
|
315
|
+
* @param {any} node
|
|
316
|
+
* @param {string} docPath
|
|
317
|
+
* @param {RefScope} scope
|
|
318
|
+
* @param {boolean} isRoot
|
|
319
|
+
*/
|
|
320
|
+
function checkRefs(node, docPath, scope, isRoot) {
|
|
321
|
+
if (Array.isArray(node)) {
|
|
322
|
+
for (let i = 0; i < node.length; i++) checkRefs(node[i], at(docPath, i), scope, false);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (!isJsonObject(node)) return;
|
|
326
|
+
if (!isRoot && typeof node.$id === 'string') return;
|
|
327
|
+
if (typeof node.$ref === 'string') {
|
|
328
|
+
const ref = node.$ref;
|
|
329
|
+
if (ref.startsWith('#')) {
|
|
330
|
+
if (resolveSameDocumentRef(ref, scope.src, scope.anchors) === undefined) {
|
|
331
|
+
throw refuse('JC0007', `the $ref '${ref}' does not resolve inside the document`, at(docPath, '$ref'));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
const hash = ref.indexOf('#');
|
|
336
|
+
const base = hash === -1 ? ref : ref.slice(0, hash);
|
|
337
|
+
const external = scope.validator.getSchema(base);
|
|
338
|
+
if (external === null || external === undefined) {
|
|
339
|
+
throw refuse('JC0007',
|
|
340
|
+
`the $ref '${ref}' names no registered schema (register it through options.schemas)`,
|
|
341
|
+
at(docPath, '$ref'));
|
|
342
|
+
}
|
|
343
|
+
// the validator resolves a pointer fragment into a registered schema
|
|
344
|
+
// lazily (a missing pointer surfaces at validation time), so the
|
|
345
|
+
// fragment is checked here — unresolved must be a compile refusal
|
|
346
|
+
if (hash !== -1 && resolveSameDocumentRef(decodeFragment(ref.slice(hash)), external, collectSameDocumentAnchors(external)) === undefined) {
|
|
347
|
+
throw refuse('JC0007',
|
|
348
|
+
`the $ref '${ref}' names a registered schema but its fragment does not resolve inside it`,
|
|
349
|
+
at(docPath, '$ref'));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const keys = Object.keys(node);
|
|
354
|
+
for (let i = 0; i < keys.length; i++) {
|
|
355
|
+
const key = keys[i];
|
|
356
|
+
if (DATA_KEYWORDS.has(key)) continue;
|
|
357
|
+
checkRefs(node[key], at(docPath, key), scope, false);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
//#endregion
|
|
362
|
+
|
|
363
|
+
//#region the compiled shapes
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* A compiled validator: whatever the injected validator's `compile`
|
|
367
|
+
* returns — `{ valid, errors }` under the default collect-errors
|
|
368
|
+
* validator, a boolean under a boolean one.
|
|
369
|
+
* @typedef {(value: unknown) => any} CompiledValidate
|
|
370
|
+
*/
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The transport half of an operation's input: the members that travel
|
|
374
|
+
* as strings (path, query, header) and the normalizer that decodes them
|
|
375
|
+
* with `coerceTypes` scoped to exactly those members. `repeated` lists
|
|
376
|
+
* the query and header members whose effective schema type is `array` —
|
|
377
|
+
* a decoder collects repeats of those into an array (a repeated query
|
|
378
|
+
* key; a repeated header line or a comma-separated header list) before
|
|
379
|
+
* normalizing; every other query member is last-wins and every other
|
|
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.
|
|
384
|
+
* @typedef {Object} InputTransport
|
|
385
|
+
* @property {(value: any) => any} normalize
|
|
386
|
+
* @property {{ path: readonly string[], query: readonly string[], header: readonly string[], repeated: readonly string[] }} members
|
|
387
|
+
* @property {Readonly<Record<string, any>>} schemas
|
|
388
|
+
* @property {readonly string[]} required
|
|
389
|
+
*/
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* @typedef {Object} CompiledInput
|
|
393
|
+
* @property {any} schema - the declared input schema (frozen source)
|
|
394
|
+
* @property {any} effective - the object schema `schema` resolves to: itself,
|
|
395
|
+
* or the end of its `$ref` chain (inside the document or a registered
|
|
396
|
+
* schema) — whose `properties` are the operation's members; frozen
|
|
397
|
+
* @property {CompiledValidate} validate
|
|
398
|
+
* @property {InputTransport | null} transport - `null` when no member travels as a string
|
|
399
|
+
*/
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* @typedef {Object} CompiledOutput
|
|
403
|
+
* @property {any} schema
|
|
404
|
+
* @property {CompiledValidate} validate
|
|
405
|
+
*/
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* @typedef {Object} CompiledErrorDecl
|
|
409
|
+
* @property {number} status
|
|
410
|
+
* @property {any} schema - `null` when undeclared
|
|
411
|
+
* @property {CompiledValidate | null} validate
|
|
412
|
+
*/
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The resolved policy, every default materialized.
|
|
416
|
+
* @typedef {Object} CompiledPolicy
|
|
417
|
+
* @property {'switch' | 'exhaust' | 'concat' | 'parallel'} task
|
|
418
|
+
* @property {'none' | 'optional' | 'required'} idempotency
|
|
419
|
+
* @property {string | null} revision - `input:<json-pointer>` or null
|
|
420
|
+
* @property {'none' | 'revision'} cache
|
|
421
|
+
* @property {{ maxBodyBytes: number }} limits
|
|
422
|
+
* @property {{ details: 'none' | 'paths' | 'full' }} errors
|
|
423
|
+
* @property {{ max: number, on: readonly string[] } | null} retry
|
|
424
|
+
* @property {{ resume: 'snapshot' | 'replay', heartbeatMs: number, maxPatchBytes: number | null } | null} stream
|
|
425
|
+
* - the stream policy of a subscribe operation, defaults materialized; `null` on every other kind
|
|
426
|
+
* @property {'public' | 'server'} audience - who may see the operation: `server`
|
|
427
|
+
* keeps it out of the public projection and every projection built on it
|
|
428
|
+
*/
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The materialized HTTP binding. `template` is the parsed canonical
|
|
432
|
+
* template (segments in order — the input of a URL builder); `in` maps
|
|
433
|
+
* every declared input member to its location; `body` names the member
|
|
434
|
+
* whose value IS the request body, or `null` when the body is the object
|
|
435
|
+
* of body-located members; `opaque` is true for non-JSON `media`.
|
|
436
|
+
* @typedef {Object} CompiledHttp
|
|
437
|
+
* @property {string} method
|
|
438
|
+
* @property {string} path
|
|
439
|
+
* @property {import('./path.js').ParsedPathTemplate} template
|
|
440
|
+
* @property {readonly string[]} variables
|
|
441
|
+
* @property {Readonly<Record<string, 'path' | 'query' | 'header' | 'body'>>} in
|
|
442
|
+
* @property {string | null} body
|
|
443
|
+
* @property {number} status
|
|
444
|
+
* @property {string} media
|
|
445
|
+
* @property {boolean} opaque
|
|
446
|
+
*/
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* @typedef {Object} CompiledOperation
|
|
450
|
+
* @property {string} id
|
|
451
|
+
* @property {'read' | 'command' | 'subscribe'} kind
|
|
452
|
+
* @property {string | null} doc
|
|
453
|
+
* @property {CompiledInput | null} input
|
|
454
|
+
* @property {CompiledOutput} output
|
|
455
|
+
* @property {Readonly<Record<string, CompiledErrorDecl>>} errors
|
|
456
|
+
* @property {CompiledPolicy} policy
|
|
457
|
+
* @property {CompiledHttp} http
|
|
458
|
+
*/
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* @typedef {Object} Contract
|
|
462
|
+
* @property {any} doc - the source document, deep-frozen
|
|
463
|
+
* @property {string | null} id
|
|
464
|
+
* @property {string | null} version
|
|
465
|
+
* @property {readonly string[]} compat
|
|
466
|
+
* @property {Readonly<Record<string, CompiledOperation>>} operations
|
|
467
|
+
* @property {readonly string[]} ids - operation ids in document order
|
|
468
|
+
* @property {(method: string, path: string) => { op: CompiledOperation, params: Readonly<Record<string, string>> } | null} match
|
|
469
|
+
* @property {(path: string) => string[]} allowed - the methods under which
|
|
470
|
+
* this path shape reaches an operation, sorted (`[]` for none) — what a
|
|
471
|
+
* 405 answers in `Allow`; the path only, query split off, like `match`
|
|
472
|
+
* @property {() => any} describe - a pure-JSON summary (docs/CONTRACT-FORMAT.md §3)
|
|
473
|
+
* @property {() => Promise<string>} revision - the SHA-256 (lowercase hex)
|
|
474
|
+
* over the RFC 8785 canonical bytes of the public projection, memoized —
|
|
475
|
+
* computed at most once per compiled contract (docs/CONTRACT-FORMAT.md §14)
|
|
476
|
+
* @property {any} $defs - frozen view of the document's `$defs` (`{}` when absent)
|
|
477
|
+
*/
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* @typedef {Object} CompileContractOptions
|
|
481
|
+
* @property {JarenValidator<any>} [validator] - the validator every schema
|
|
482
|
+
* compiles through; default `new JarenValidator({ collectErrors: true, skipErrors: false })`
|
|
483
|
+
* @property {Record<string, any>[]} [schemas] - schemas registered by `$id` before compile, so
|
|
484
|
+
* an absolute `$ref` resolves
|
|
485
|
+
*/
|
|
486
|
+
|
|
487
|
+
//#endregion
|
|
488
|
+
|
|
489
|
+
//#region per-member validation
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* @param {any} src
|
|
493
|
+
* @param {RefScope} scope
|
|
494
|
+
*/
|
|
495
|
+
function checkRoot(src, scope) {
|
|
496
|
+
const keys = Object.keys(src);
|
|
497
|
+
for (let i = 0; i < keys.length; i++) {
|
|
498
|
+
if (!ROOT_MEMBERS.has(keys[i])) {
|
|
499
|
+
throw refuse('JC0013',
|
|
500
|
+
`unknown document member '${keys[i]}' — the root vocabulary is closed ($contract, id, version, compat, $defs, operations)`,
|
|
501
|
+
at('', keys[i]));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (src.$contract !== CONTRACT_VERSION) {
|
|
505
|
+
throw refuse('JC0001',
|
|
506
|
+
src.$contract === undefined
|
|
507
|
+
? 'the document does not declare "$contract": "0.1"'
|
|
508
|
+
: `unknown contract format version ${JSON.stringify(src.$contract)} (this compiler speaks '0.1')`,
|
|
509
|
+
'/$contract');
|
|
510
|
+
}
|
|
511
|
+
if (src.id !== undefined && (typeof src.id !== 'string' || !CONTRACT_ID.test(src.id))) {
|
|
512
|
+
throw refuse('JC0015', 'id must be an identifier string ([A-Za-z_][A-Za-z0-9_-]*)', '/id');
|
|
513
|
+
}
|
|
514
|
+
if (src.version !== undefined && (typeof src.version !== 'string' || src.version === '')) {
|
|
515
|
+
throw refuse('JC0015', 'version must be a non-empty string', '/version');
|
|
516
|
+
}
|
|
517
|
+
if (src.compat !== undefined) {
|
|
518
|
+
if (!Array.isArray(src.compat)) throw refuse('JC0015', 'compat must be an array of version strings', '/compat');
|
|
519
|
+
for (let i = 0; i < src.compat.length; i++) {
|
|
520
|
+
if (typeof src.compat[i] !== 'string' || src.compat[i] === '') {
|
|
521
|
+
throw refuse('JC0015', 'compat entries must be non-empty version strings', at('/compat', i));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (src.$defs !== undefined) {
|
|
526
|
+
if (!isJsonObject(src.$defs)) throw refuse('JC0001', '$defs must be an object of named schemas', '/$defs');
|
|
527
|
+
const names = Object.keys(src.$defs);
|
|
528
|
+
for (let i = 0; i < names.length; i++) {
|
|
529
|
+
if (!isSchema(src.$defs[names[i]])) {
|
|
530
|
+
throw refuse('JC0001', `$defs entry '${names[i]}' is not a schema (an object or a boolean)`, at('/$defs', names[i]));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
checkRefs(src.$defs, '/$defs', scope, false);
|
|
534
|
+
}
|
|
535
|
+
if (!isJsonObject(src.operations) || Object.keys(src.operations).length === 0) {
|
|
536
|
+
throw refuse('JC0002', 'operations must be an object with at least one operation', '/operations');
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Validate `errors` and return the resolved declarations (statuses
|
|
542
|
+
* defaulted, schemas checked); validators are compiled later.
|
|
543
|
+
* @param {any} errors
|
|
544
|
+
* @param {string} base - `/operations/<id>/errors`
|
|
545
|
+
* @param {RefScope} scope
|
|
546
|
+
* @returns {{ code: string, status: number, schema: any }[]}
|
|
547
|
+
*/
|
|
548
|
+
function checkErrors(errors, base, scope) {
|
|
549
|
+
if (errors === undefined) return [];
|
|
550
|
+
if (!isJsonObject(errors)) throw refuse('JC0011', 'errors must be an object of code → { status?, schema? }', base);
|
|
551
|
+
const out = [];
|
|
552
|
+
const codes = Object.keys(errors);
|
|
553
|
+
for (let i = 0; i < codes.length; i++) {
|
|
554
|
+
const code = codes[i];
|
|
555
|
+
const path = at(base, code);
|
|
556
|
+
if (!ERROR_CODE.test(code)) {
|
|
557
|
+
throw refuse('JC0011', `error code '${code}' must match ^[a-z][a-z0-9-]*$`, path);
|
|
558
|
+
}
|
|
559
|
+
const decl = errors[code];
|
|
560
|
+
if (!isJsonObject(decl)) throw refuse('JC0011', `error '${code}' must be an object { status?, schema? }`, path);
|
|
561
|
+
const members = Object.keys(decl);
|
|
562
|
+
for (let j = 0; j < members.length; j++) {
|
|
563
|
+
if (!ERROR_DECL_MEMBERS.has(members[j])) {
|
|
564
|
+
throw refuse('JC0013', `unknown error member '${members[j]}' — an error declaration is { status?, schema? }`, at(path, members[j]));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
let status = DEFAULT_ERROR_STATUS;
|
|
568
|
+
if (decl.status !== undefined) {
|
|
569
|
+
if (!Number.isInteger(decl.status) || decl.status < 100 || decl.status > 599) {
|
|
570
|
+
throw refuse('JC0011', `error '${code}' status must be an integer in 100–599`, at(path, 'status'));
|
|
571
|
+
}
|
|
572
|
+
status = decl.status;
|
|
573
|
+
}
|
|
574
|
+
let schema = null;
|
|
575
|
+
if (decl.schema !== undefined) {
|
|
576
|
+
if (!isSchema(decl.schema)) throw refuse('JC0011', `error '${code}' schema must be a schema (an object or a boolean)`, at(path, 'schema'));
|
|
577
|
+
checkRefs(decl.schema, at(path, 'schema'), scope, false);
|
|
578
|
+
schema = decl.schema;
|
|
579
|
+
}
|
|
580
|
+
out.push({ code, status, schema });
|
|
581
|
+
}
|
|
582
|
+
return out;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Validate `policy` and return it with every default materialized.
|
|
587
|
+
* @param {any} policy
|
|
588
|
+
* @param {'read' | 'command' | 'subscribe'} kind
|
|
589
|
+
* @param {readonly string[] | null} inputMembers - the input's declared property names, `null` when the operation declares no input
|
|
590
|
+
* @param {string} base - `/operations/<id>/policy`
|
|
591
|
+
* @returns {CompiledPolicy}
|
|
592
|
+
*/
|
|
593
|
+
function checkPolicy(policy, kind, inputMembers, base) {
|
|
594
|
+
const p = policy === undefined ? {} : policy;
|
|
595
|
+
if (!isJsonObject(p)) throw refuse('JC0014', 'policy must be an object', base);
|
|
596
|
+
const members = Object.keys(p);
|
|
597
|
+
for (let i = 0; i < members.length; i++) {
|
|
598
|
+
if (!POLICY_MEMBERS.has(members[i])) {
|
|
599
|
+
throw refuse('JC0013',
|
|
600
|
+
`unknown policy member '${members[i]}' — the policy vocabulary is closed (task, idempotency, revision, cache, limits, errors, retry, stream, audience)`,
|
|
601
|
+
at(base, members[i]));
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const task = p.task === undefined ? (kind === 'command' ? 'exhaust' : 'switch') : p.task;
|
|
605
|
+
if (!TASKS.includes(task)) {
|
|
606
|
+
throw refuse('JC0014', `policy.task must be one of switch, exhaust, concat, parallel`, at(base, 'task'));
|
|
607
|
+
}
|
|
608
|
+
if (kind === 'subscribe' && task !== 'switch') {
|
|
609
|
+
throw refuse('JC0018',
|
|
610
|
+
"a subscribe operation's policy.task must be switch (or absent) — a subscription slot is replaced, never queued",
|
|
611
|
+
at(base, 'task'));
|
|
612
|
+
}
|
|
613
|
+
const idempotency = p.idempotency === undefined ? 'none' : p.idempotency;
|
|
614
|
+
if (!IDEMPOTENCY.includes(idempotency)) {
|
|
615
|
+
throw refuse('JC0014', 'policy.idempotency must be one of none, optional, required', at(base, 'idempotency'));
|
|
616
|
+
}
|
|
617
|
+
if (kind === 'read' && idempotency !== 'none') {
|
|
618
|
+
throw refuse('JC0014', 'a read operation is idempotent by nature; policy.idempotency must be none (or absent)', at(base, 'idempotency'));
|
|
619
|
+
}
|
|
620
|
+
if (kind === 'subscribe' && idempotency !== 'none') {
|
|
621
|
+
throw refuse('JC0020',
|
|
622
|
+
"a subscribe operation's policy.idempotency must be none (or absent) — a subscription registers, it does not commit",
|
|
623
|
+
at(base, 'idempotency'));
|
|
624
|
+
}
|
|
625
|
+
let revision = null;
|
|
626
|
+
if (p.revision !== undefined) {
|
|
627
|
+
if (typeof p.revision !== 'string' || !p.revision.startsWith('input:')) {
|
|
628
|
+
throw refuse('JC0014', 'policy.revision must be a string "input:<json-pointer>"', at(base, 'revision'));
|
|
629
|
+
}
|
|
630
|
+
let tokens;
|
|
631
|
+
try {
|
|
632
|
+
tokens = parseJSONPointer(p.revision.slice('input:'.length));
|
|
633
|
+
}
|
|
634
|
+
catch (err) {
|
|
635
|
+
throw refuse('JC0014', 'policy.revision must carry a valid RFC 6901 pointer after "input:"', at(base, 'revision'), asCause(err));
|
|
636
|
+
}
|
|
637
|
+
// the pointer must address a declared input member: the first
|
|
638
|
+
// reference token names one of input.properties (deeper tokens are
|
|
639
|
+
// not checked — a member's schema may be a $ref or open)
|
|
640
|
+
if (inputMembers === null) {
|
|
641
|
+
throw refuse('JC0014', 'policy.revision names an input member but the operation has no input', at(base, 'revision'));
|
|
642
|
+
}
|
|
643
|
+
if (tokens.length > 0 && !inputMembers.includes(String(tokens[0]))) {
|
|
644
|
+
throw refuse('JC0014', `policy.revision points at '/${String(tokens[0])}' but input declares no member '${String(tokens[0])}'`, at(base, 'revision'));
|
|
645
|
+
}
|
|
646
|
+
revision = p.revision;
|
|
647
|
+
}
|
|
648
|
+
const cache = p.cache === undefined ? 'none' : p.cache;
|
|
649
|
+
if (!CACHE.includes(cache)) {
|
|
650
|
+
throw refuse('JC0014', 'policy.cache must be one of none, revision', at(base, 'cache'));
|
|
651
|
+
}
|
|
652
|
+
let maxBodyBytes = DEFAULT_MAX_BODY_BYTES;
|
|
653
|
+
if (p.limits !== undefined) {
|
|
654
|
+
if (!isJsonObject(p.limits)) throw refuse('JC0014', 'policy.limits must be an object', at(base, 'limits'));
|
|
655
|
+
const lm = Object.keys(p.limits);
|
|
656
|
+
for (let i = 0; i < lm.length; i++) {
|
|
657
|
+
if (!LIMITS_MEMBERS.has(lm[i])) {
|
|
658
|
+
throw refuse('JC0013', `unknown limits member '${lm[i]}' — limits is { maxBodyBytes? }`, at(at(base, 'limits'), lm[i]));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
if (p.limits.maxBodyBytes !== undefined) {
|
|
662
|
+
if (!Number.isInteger(p.limits.maxBodyBytes) || p.limits.maxBodyBytes <= 0) {
|
|
663
|
+
throw refuse('JC0014', 'policy.limits.maxBodyBytes must be a positive integer', at(at(base, 'limits'), 'maxBodyBytes'));
|
|
664
|
+
}
|
|
665
|
+
maxBodyBytes = p.limits.maxBodyBytes;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
/** @type {'none' | 'paths' | 'full'} */
|
|
669
|
+
let details = 'paths';
|
|
670
|
+
if (p.errors !== undefined) {
|
|
671
|
+
if (!isJsonObject(p.errors)) throw refuse('JC0014', 'policy.errors must be an object', at(base, 'errors'));
|
|
672
|
+
const em = Object.keys(p.errors);
|
|
673
|
+
for (let i = 0; i < em.length; i++) {
|
|
674
|
+
if (!POLICY_ERRORS_MEMBERS.has(em[i])) {
|
|
675
|
+
throw refuse('JC0013', `unknown policy.errors member '${em[i]}' — policy.errors is { details? }`, at(at(base, 'errors'), em[i]));
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (p.errors.details !== undefined) {
|
|
679
|
+
if (!DETAILS.includes(p.errors.details)) {
|
|
680
|
+
throw refuse('JC0014', 'policy.errors.details must be one of none, paths, full', at(at(base, 'errors'), 'details'));
|
|
681
|
+
}
|
|
682
|
+
details = p.errors.details;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
let retry = null;
|
|
686
|
+
if (p.retry !== undefined) {
|
|
687
|
+
const rp = at(base, 'retry');
|
|
688
|
+
if (!isJsonObject(p.retry)) throw refuse('JC0014', 'policy.retry must be an object { max, on }', rp);
|
|
689
|
+
const rm = Object.keys(p.retry);
|
|
690
|
+
for (let i = 0; i < rm.length; i++) {
|
|
691
|
+
if (!RETRY_MEMBERS.has(rm[i])) {
|
|
692
|
+
throw refuse('JC0013', `unknown retry member '${rm[i]}' — retry is { max, on }`, at(rp, rm[i]));
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (!Number.isInteger(p.retry.max) || p.retry.max < 0) {
|
|
696
|
+
throw refuse('JC0014', 'policy.retry.max must be an integer ≥ 0', at(rp, 'max'));
|
|
697
|
+
}
|
|
698
|
+
if (!Array.isArray(p.retry.on)) throw refuse('JC0014', 'policy.retry.on must be an array of error codes', at(rp, 'on'));
|
|
699
|
+
for (let i = 0; i < p.retry.on.length; i++) {
|
|
700
|
+
if (typeof p.retry.on[i] !== 'string' || p.retry.on[i] === '') {
|
|
701
|
+
throw refuse('JC0014', 'policy.retry.on entries must be error code strings', at(at(rp, 'on'), i));
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
// a command may only be retried under a key the server can deduplicate
|
|
705
|
+
// on: a retried command without one runs twice
|
|
706
|
+
if (kind === 'command' && idempotency !== 'required') {
|
|
707
|
+
throw refuse('JC0014',
|
|
708
|
+
"policy.retry on a command requires policy.idempotency 'required' — a retried command without an idempotency key runs twice",
|
|
709
|
+
rp);
|
|
710
|
+
}
|
|
711
|
+
retry = { max: p.retry.max, on: p.retry.on.slice() };
|
|
712
|
+
}
|
|
713
|
+
/** @type {CompiledPolicy['stream']} */
|
|
714
|
+
let stream = null;
|
|
715
|
+
if (p.stream !== undefined && kind !== 'subscribe') {
|
|
716
|
+
throw refuse('JC0014', 'policy.stream applies to subscribe operations only', at(base, 'stream'));
|
|
717
|
+
}
|
|
718
|
+
if (kind === 'subscribe') {
|
|
719
|
+
const sp = at(base, 'stream');
|
|
720
|
+
const s = p.stream === undefined ? {} : p.stream;
|
|
721
|
+
if (!isJsonObject(s)) throw refuse('JC0014', 'policy.stream must be an object { resume?, heartbeatMs?, maxPatchBytes? }', sp);
|
|
722
|
+
const sm = Object.keys(s);
|
|
723
|
+
for (let i = 0; i < sm.length; i++) {
|
|
724
|
+
if (!STREAM_MEMBERS.has(sm[i])) {
|
|
725
|
+
throw refuse('JC0013', `unknown stream member '${sm[i]}' — policy.stream is { resume?, heartbeatMs?, maxPatchBytes? }`, at(sp, sm[i]));
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const resume = s.resume === undefined ? 'snapshot' : s.resume;
|
|
729
|
+
if (!RESUME.includes(resume)) {
|
|
730
|
+
throw refuse('JC0014', 'policy.stream.resume must be one of snapshot, replay', at(sp, 'resume'));
|
|
731
|
+
}
|
|
732
|
+
let heartbeatMs = DEFAULT_HEARTBEAT_MS;
|
|
733
|
+
if (s.heartbeatMs !== undefined) {
|
|
734
|
+
if (!Number.isInteger(s.heartbeatMs) || s.heartbeatMs < 1000) {
|
|
735
|
+
throw refuse('JC0014', 'policy.stream.heartbeatMs must be an integer ≥ 1000', at(sp, 'heartbeatMs'));
|
|
736
|
+
}
|
|
737
|
+
heartbeatMs = s.heartbeatMs;
|
|
738
|
+
}
|
|
739
|
+
let maxPatchBytes = null;
|
|
740
|
+
if (s.maxPatchBytes !== undefined) {
|
|
741
|
+
if (!Number.isInteger(s.maxPatchBytes) || s.maxPatchBytes <= 0) {
|
|
742
|
+
throw refuse('JC0014', 'policy.stream.maxPatchBytes must be a positive integer', at(sp, 'maxPatchBytes'));
|
|
743
|
+
}
|
|
744
|
+
maxPatchBytes = s.maxPatchBytes;
|
|
745
|
+
}
|
|
746
|
+
stream = { resume, heartbeatMs, maxPatchBytes };
|
|
747
|
+
}
|
|
748
|
+
const audience = p.audience === undefined ? 'public' : p.audience;
|
|
749
|
+
if (!AUDIENCES.includes(audience)) {
|
|
750
|
+
throw refuse('JC0014', 'policy.audience must be one of public, server', at(base, 'audience'));
|
|
751
|
+
}
|
|
752
|
+
return { task, idempotency, revision, cache, limits: { maxBodyBytes }, errors: { details }, retry, stream, audience };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Validate `http` and materialize the binding: the canonical
|
|
757
|
+
* `POST /<op-id>` with every member in the body when absent, otherwise
|
|
758
|
+
* the declared binding with template canonicalized, locations defaulted
|
|
759
|
+
* (path variables → `path`; `read` → `query`; `command` → `body`) and
|
|
760
|
+
* every cross-rule (`JC0009`, `JC0016`, `JC0017`) applied. Returns the
|
|
761
|
+
* compiled http and the location table by member.
|
|
762
|
+
* @param {any} http
|
|
763
|
+
* @param {string} id
|
|
764
|
+
* @param {'read' | 'command' | 'subscribe'} kind
|
|
765
|
+
* @param {readonly string[]} members - the input's declared property names
|
|
766
|
+
* @param {string} base - `/operations/<id>/http`
|
|
767
|
+
* @returns {CompiledHttp}
|
|
768
|
+
*/
|
|
769
|
+
function checkHttp(http, id, kind, members, base) {
|
|
770
|
+
/** @type {Record<string, 'path' | 'query' | 'header' | 'body'>} */
|
|
771
|
+
const locations = {};
|
|
772
|
+
if (http === undefined) {
|
|
773
|
+
const template = parsePathTemplate(`/${id}`);
|
|
774
|
+
if (kind === 'subscribe') {
|
|
775
|
+
// the canonical subscribe binding: GET /<op-id>, every member in
|
|
776
|
+
// the query (input travels as for a read), the stream media
|
|
777
|
+
for (let i = 0; i < members.length; i++) setObjectMember(locations, members[i], 'query');
|
|
778
|
+
return {
|
|
779
|
+
method: 'GET', path: template.path, template, variables: template.variables,
|
|
780
|
+
in: locations, body: null, status: DEFAULT_STATUS, media: STREAM_MEDIA, opaque: false,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
for (let i = 0; i < members.length; i++) setObjectMember(locations, members[i], 'body');
|
|
784
|
+
return {
|
|
785
|
+
method: 'POST', path: template.path, template, variables: template.variables,
|
|
786
|
+
in: locations, body: null, status: DEFAULT_STATUS, media: DEFAULT_MEDIA, opaque: false,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
if (!isJsonObject(http)) throw refuse('JC0012', 'http must be an object { method, path, in?, body?, status?, media? }', base);
|
|
790
|
+
const hm = Object.keys(http);
|
|
791
|
+
for (let i = 0; i < hm.length; i++) {
|
|
792
|
+
if (!HTTP_MEMBERS.has(hm[i])) {
|
|
793
|
+
throw refuse('JC0013',
|
|
794
|
+
`unknown http member '${hm[i]}' — the http vocabulary is closed (method, path, in, body, status, media)`,
|
|
795
|
+
at(base, hm[i]));
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (typeof http.method !== 'string' || !METHODS.includes(http.method)) {
|
|
799
|
+
throw refuse('JC0012',
|
|
800
|
+
`http.method must be an uppercase token of ${METHODS.join(', ')}` + (typeof http.method === 'string' && METHODS.includes(http.method.toUpperCase()) ? ` (write '${http.method.toUpperCase()}')` : ''),
|
|
801
|
+
at(base, 'method'));
|
|
802
|
+
}
|
|
803
|
+
const method = http.method;
|
|
804
|
+
if (kind === 'subscribe' && method !== 'GET') {
|
|
805
|
+
throw refuse('JC0019',
|
|
806
|
+
`a subscribe operation must be bound to GET (a stream is fetched, not sent), got ${method}`,
|
|
807
|
+
at(base, 'method'));
|
|
808
|
+
}
|
|
809
|
+
let status = DEFAULT_STATUS;
|
|
810
|
+
if (http.status !== undefined) {
|
|
811
|
+
if (!Number.isInteger(http.status) || http.status < 200 || http.status > 299) {
|
|
812
|
+
throw refuse('JC0012', 'http.status must be an integer in 200–299', at(base, 'status'));
|
|
813
|
+
}
|
|
814
|
+
status = http.status;
|
|
815
|
+
}
|
|
816
|
+
let media = kind === 'subscribe' ? STREAM_MEDIA : DEFAULT_MEDIA;
|
|
817
|
+
if (http.media !== undefined) {
|
|
818
|
+
if (typeof http.media !== 'string' || !MEDIA_TYPE.test(http.media)) {
|
|
819
|
+
throw refuse('JC0012', 'http.media must be a media type string (type/subtype)', at(base, 'media'));
|
|
820
|
+
}
|
|
821
|
+
if (kind === 'subscribe' && http.media !== STREAM_MEDIA) {
|
|
822
|
+
// forced, never silently overridden: a declared conflicting media
|
|
823
|
+
// would be a behavior the binding cannot honor
|
|
824
|
+
throw refuse('JC0012', `a subscribe operation's http.media is ${STREAM_MEDIA} (leave it absent — the binding forces it)`, at(base, 'media'));
|
|
825
|
+
}
|
|
826
|
+
media = http.media;
|
|
827
|
+
}
|
|
828
|
+
if (http.path === undefined) throw refuse('JC0008', 'http.path is required when http is declared', at(base, 'path'));
|
|
829
|
+
let template;
|
|
830
|
+
try {
|
|
831
|
+
template = parsePathTemplate(http.path);
|
|
832
|
+
}
|
|
833
|
+
catch (err) {
|
|
834
|
+
throw refuse('JC0008', `http.path is not a valid template: ${asCause(err)?.message ?? 'malformed'}`, at(base, 'path'), asCause(err));
|
|
835
|
+
}
|
|
836
|
+
const variables = template.variables;
|
|
837
|
+
for (let i = 0; i < variables.length; i++) {
|
|
838
|
+
if (!members.includes(variables[i])) {
|
|
839
|
+
throw refuse('JC0009', `path variable '${variables[i]}' is not a member of input.properties`, at(base, 'path'));
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
let bodyMember = null;
|
|
843
|
+
if (http.body !== undefined) {
|
|
844
|
+
if (typeof http.body !== 'string' || !members.includes(http.body)) {
|
|
845
|
+
throw refuse('JC0009', 'http.body must name a member of input.properties whose value is the request body', at(base, 'body'));
|
|
846
|
+
}
|
|
847
|
+
if (variables.includes(http.body)) {
|
|
848
|
+
throw refuse('JC0009', `http.body '${http.body}' is a path variable and cannot also be the body`, at(base, 'body'));
|
|
849
|
+
}
|
|
850
|
+
bodyMember = http.body;
|
|
851
|
+
}
|
|
852
|
+
const inMap = http.in === undefined ? {} : http.in;
|
|
853
|
+
if (!isJsonObject(inMap)) throw refuse('JC0009', 'http.in must be an object of member → path | query | header | body', at(base, 'in'));
|
|
854
|
+
const inKeys = Object.keys(inMap);
|
|
855
|
+
for (let i = 0; i < inKeys.length; i++) {
|
|
856
|
+
const m = inKeys[i];
|
|
857
|
+
const loc = inMap[m];
|
|
858
|
+
const path = at(at(base, 'in'), m);
|
|
859
|
+
if (!members.includes(m)) throw refuse('JC0009', `http.in names '${m}', which is not a member of input.properties`, path);
|
|
860
|
+
if (typeof loc !== 'string' || !LOCATIONS.includes(loc)) {
|
|
861
|
+
throw refuse('JC0009', `http.in.${m} must be one of path, query, header, body`, path);
|
|
862
|
+
}
|
|
863
|
+
if (variables.includes(m) && loc !== 'path') {
|
|
864
|
+
throw refuse('JC0009', `'${m}' is a path variable and cannot travel as ${loc}`, path);
|
|
865
|
+
}
|
|
866
|
+
if (!variables.includes(m) && loc === 'path') {
|
|
867
|
+
throw refuse('JC0009', `'${m}' is mapped to path but the template declares no {${m}}`, path);
|
|
868
|
+
}
|
|
869
|
+
if (bodyMember !== null && m === bodyMember && loc !== 'body') {
|
|
870
|
+
throw refuse('JC0009', `'${m}' is the http.body member and cannot travel as ${loc}`, path);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
const defaultLocation = kind === 'command' ? 'body' : 'query';
|
|
874
|
+
for (let i = 0; i < members.length; i++) {
|
|
875
|
+
const m = members[i];
|
|
876
|
+
/** @type {'path' | 'query' | 'header' | 'body'} */
|
|
877
|
+
let loc;
|
|
878
|
+
if (variables.includes(m)) loc = 'path';
|
|
879
|
+
else if (m === bodyMember) loc = 'body';
|
|
880
|
+
else if (inMap[m] !== undefined) loc = inMap[m];
|
|
881
|
+
else loc = defaultLocation;
|
|
882
|
+
if (bodyMember !== null && m !== bodyMember && loc === 'body') {
|
|
883
|
+
throw refuse('JC0009',
|
|
884
|
+
`'${m}' would travel in the body, but http.body names '${bodyMember}' as the whole body — map '${m}' to query or header`,
|
|
885
|
+
inMap[m] !== undefined ? at(at(base, 'in'), m) : at(base, 'body'));
|
|
886
|
+
}
|
|
887
|
+
setObjectMember(locations, m, loc);
|
|
888
|
+
}
|
|
889
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
890
|
+
for (let i = 0; i < members.length; i++) {
|
|
891
|
+
const m = members[i];
|
|
892
|
+
if (locations[m] === 'body') {
|
|
893
|
+
throw refuse('JC0016',
|
|
894
|
+
`a ${kind} operation bound to ${method} cannot carry '${m}' in the body (a GET body)`,
|
|
895
|
+
m === bodyMember ? at(base, 'body') : (inMap[m] !== undefined ? at(at(base, 'in'), m) : at(base, 'method')));
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
// a subscribe operation's media is the stream envelope, not an opaque
|
|
900
|
+
// body: its events are JSON the contract decodes and validates
|
|
901
|
+
const opaque = kind !== 'subscribe' && !isJsonMedia(media);
|
|
902
|
+
if (opaque) {
|
|
903
|
+
// an opaque body is bytes the contract never decodes (§4.5): a
|
|
904
|
+
// body-located member could never be validated, so the transport
|
|
905
|
+
// members of an opaque operation are ALWAYS its whole input
|
|
906
|
+
for (let i = 0; i < members.length; i++) {
|
|
907
|
+
const m = members[i];
|
|
908
|
+
if (locations[m] === 'body') {
|
|
909
|
+
const declaredBy = m === bodyMember ? 'http.body' : (inMap[m] !== undefined ? `http.in.${m}` : `the default location of a ${kind} member`);
|
|
910
|
+
throw refuse('JC0017',
|
|
911
|
+
`an opaque operation (media ${media}) cannot carry '${m}' in the body (placed there by ${declaredBy}) — its body is bytes the contract never decodes; map '${m}' to query or header, or make the operation JSON`,
|
|
912
|
+
m === bodyMember ? at(base, 'body') : (inMap[m] !== undefined ? at(at(base, 'in'), m) : at(base, 'media')));
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return {
|
|
917
|
+
method, path: template.path, template, variables, in: locations, body: bodyMember,
|
|
918
|
+
status, media, opaque,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
//#endregion
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Compile a `$contract` document (docs/CONTRACT-FORMAT.md) into a frozen
|
|
926
|
+
* `Contract`: every operation's `input`/`output`/error validators, its
|
|
927
|
+
* transport normalizer, its resolved policy and materialized HTTP
|
|
928
|
+
* binding, and one `match(method, path)` over the whole table.
|
|
929
|
+
* Synchronous; total for a hostile document; no I/O.
|
|
930
|
+
*
|
|
931
|
+
* @param {unknown} doc - the contract document
|
|
932
|
+
* @param {CompileContractOptions} [options]
|
|
933
|
+
* @returns {Contract}
|
|
934
|
+
* @throws {ContractCompileError} when the document violates the format (`JC0001–JC0017`)
|
|
935
|
+
* @example
|
|
936
|
+
* const contract = compileContract({
|
|
937
|
+
* $contract: '0.1',
|
|
938
|
+
* operations: {
|
|
939
|
+
* 'catalog.load': { kind: 'read', output: true, http: { method: 'GET', path: '/api/catalog' } },
|
|
940
|
+
* },
|
|
941
|
+
* });
|
|
942
|
+
* contract.match('GET', '/api/catalog').op.id; // 'catalog.load'
|
|
943
|
+
*/
|
|
944
|
+
export function compileContract(doc, options = {}) {
|
|
945
|
+
const validator = options.validator ?? new JarenValidator({ collectErrors: true, skipErrors: false });
|
|
946
|
+
if (options.schemas !== undefined) {
|
|
947
|
+
if (!Array.isArray(options.schemas)) throw new TypeError('compileContract: options.schemas must be an array of schemas with $id');
|
|
948
|
+
for (let i = 0; i < options.schemas.length; i++) {
|
|
949
|
+
const s = options.schemas[i];
|
|
950
|
+
if (!isJsonObject(s) || typeof s.$id !== 'string') {
|
|
951
|
+
throw new TypeError(`compileContract: options.schemas[${i}] must be a schema object with a string $id`);
|
|
952
|
+
}
|
|
953
|
+
validator.addSchema(s);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (!isJsonObject(doc)) throw refuse('JC0001', 'the contract document must be an object', '');
|
|
958
|
+
const src = deepFreeze(snapshot(doc, '', new Set()));
|
|
959
|
+
/** @type {RefScope} */
|
|
960
|
+
const scope = { src, anchors: collectSameDocumentAnchors(src), validator };
|
|
961
|
+
|
|
962
|
+
checkRoot(src, scope);
|
|
963
|
+
|
|
964
|
+
// ——— per-operation structure ———
|
|
965
|
+
/** @type {Set<string>} method + shape */
|
|
966
|
+
const shapes = new Set();
|
|
967
|
+
/** @type {string[]} */
|
|
968
|
+
const ids = [];
|
|
969
|
+
/** @type {{ id: string, kind: 'read' | 'command' | 'subscribe', doc: string | null, input: any, inputEffective: any, members: string[], output: any, errors: { code: string, status: number, schema: any }[], policy: CompiledPolicy, http: CompiledHttp }[]} */
|
|
970
|
+
const parsed = [];
|
|
971
|
+
const opIds = Object.keys(src.operations);
|
|
972
|
+
for (let n = 0; n < opIds.length; n++) {
|
|
973
|
+
const id = opIds[n];
|
|
974
|
+
const base = at('/operations', id);
|
|
975
|
+
if (!OP_ID.test(id)) {
|
|
976
|
+
throw refuse('JC0003', `operation id '${id}' must match ^[a-z][a-z0-9]*(\\.[a-z][a-z0-9]*)*$`, base);
|
|
977
|
+
}
|
|
978
|
+
const op = src.operations[id];
|
|
979
|
+
if (!isJsonObject(op)) throw refuse('JC0002', `operation '${id}' must be an object`, base);
|
|
980
|
+
const om = Object.keys(op);
|
|
981
|
+
for (let i = 0; i < om.length; i++) {
|
|
982
|
+
if (!OP_MEMBERS.has(om[i])) {
|
|
983
|
+
throw refuse('JC0013',
|
|
984
|
+
`unknown operation member '${om[i]}' — the operation vocabulary is closed (kind, input, output, errors, policy, http, doc)`,
|
|
985
|
+
at(base, om[i]));
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
if (!KINDS.includes(op.kind)) {
|
|
989
|
+
throw refuse('JC0004', "kind must be 'read', 'command' or 'subscribe'", at(base, 'kind'));
|
|
990
|
+
}
|
|
991
|
+
/** @type {'read' | 'command' | 'subscribe'} */
|
|
992
|
+
const kind = op.kind;
|
|
993
|
+
if (op.doc !== undefined && typeof op.doc !== 'string') {
|
|
994
|
+
throw refuse('JC0015', 'doc must be a string', at(base, 'doc'));
|
|
995
|
+
}
|
|
996
|
+
if (op.output === undefined) throw refuse('JC0006', 'output is required (a schema; `true` accepts anything)', at(base, 'output'));
|
|
997
|
+
if (!isSchema(op.output)) throw refuse('JC0006', 'output must be a schema (an object or a boolean)', at(base, 'output'));
|
|
998
|
+
checkRefs(op.output, at(base, 'output'), scope, false);
|
|
999
|
+
|
|
1000
|
+
let inputEffective = null;
|
|
1001
|
+
/** @type {string[]} */
|
|
1002
|
+
let members = [];
|
|
1003
|
+
let declaredMembers = null;
|
|
1004
|
+
if (op.input !== undefined) {
|
|
1005
|
+
if (!isJsonObject(op.input)) throw refuse('JC0005', 'input must be an object schema (or a $ref to one)', at(base, 'input'));
|
|
1006
|
+
checkRefs(op.input, at(base, 'input'), scope, false);
|
|
1007
|
+
inputEffective = effectiveSchema(op.input, scope);
|
|
1008
|
+
if (!isJsonObject(inputEffective) || inputEffective.type !== 'object') {
|
|
1009
|
+
throw refuse('JC0005', 'input must be a schema whose effective type is object (declare "type": "object")', at(base, 'input'));
|
|
1010
|
+
}
|
|
1011
|
+
members = isJsonObject(inputEffective.properties) ? Object.keys(inputEffective.properties) : [];
|
|
1012
|
+
declaredMembers = members;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const errors = checkErrors(op.errors, at(base, 'errors'), scope);
|
|
1016
|
+
const policy = checkPolicy(op.policy, kind, declaredMembers, at(base, 'policy'));
|
|
1017
|
+
const http = checkHttp(op.http, id, kind, members, at(base, 'http'));
|
|
1018
|
+
|
|
1019
|
+
const shape = `${http.method} ${pathShape(http.template)}`;
|
|
1020
|
+
if (shapes.has(shape)) {
|
|
1021
|
+
throw refuse('JC0010', `operation '${id}' shares the route shape ${shape} with an earlier operation`,
|
|
1022
|
+
op.http === undefined ? base : at(at(base, 'http'), 'path'));
|
|
1023
|
+
}
|
|
1024
|
+
shapes.add(shape);
|
|
1025
|
+
|
|
1026
|
+
ids.push(id);
|
|
1027
|
+
parsed.push({
|
|
1028
|
+
id, kind, doc: op.doc === undefined ? null : op.doc,
|
|
1029
|
+
input: op.input === undefined ? null : op.input, inputEffective, members,
|
|
1030
|
+
output: op.output, errors, policy, http,
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// ——— references: the validator's whole-document probe ———
|
|
1035
|
+
const synthetic = `urn:jaren:contract:${++compileSequence}`;
|
|
1036
|
+
validator.addSchema({ ...src, $id: synthetic });
|
|
1037
|
+
try {
|
|
1038
|
+
validator.compile({ $ref: `${synthetic}#` });
|
|
1039
|
+
}
|
|
1040
|
+
catch (err) {
|
|
1041
|
+
throw refuse('JC0007', `a $ref in the document does not resolve: ${asCause(err)?.message ?? 'unresolved'}`, '', asCause(err));
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* @param {(string | number)[]} tokens
|
|
1046
|
+
* @param {string} docPath
|
|
1047
|
+
* @returns {CompiledValidate}
|
|
1048
|
+
*/
|
|
1049
|
+
function compileAt(tokens, docPath) {
|
|
1050
|
+
try {
|
|
1051
|
+
return validator.compile({ $ref: synthetic + fragment(tokens) });
|
|
1052
|
+
}
|
|
1053
|
+
catch (err) {
|
|
1054
|
+
throw refuse('JC0007', `the schema failed to compile: ${asCause(err)?.message ?? 'unresolved'}`, docPath, asCause(err));
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// ——— compilation ———
|
|
1059
|
+
/** @type {Record<string, CompiledOperation>} */
|
|
1060
|
+
const operations = {};
|
|
1061
|
+
/** @type {{ method: string, path: string, key: string }[]} */
|
|
1062
|
+
const routes = [];
|
|
1063
|
+
for (let n = 0; n < parsed.length; n++) {
|
|
1064
|
+
const p = parsed[n];
|
|
1065
|
+
const base = at('/operations', p.id);
|
|
1066
|
+
|
|
1067
|
+
/** @type {CompiledInput | null} */
|
|
1068
|
+
let input = null;
|
|
1069
|
+
if (p.input !== null) {
|
|
1070
|
+
const validate = compileAt(['operations', p.id, 'input'], at(base, 'input'));
|
|
1071
|
+
/** @type {InputTransport | null} */
|
|
1072
|
+
let transport = null;
|
|
1073
|
+
const pathMembers = [];
|
|
1074
|
+
const queryMembers = [];
|
|
1075
|
+
const headerMembers = [];
|
|
1076
|
+
const repeated = [];
|
|
1077
|
+
/** @type {Record<string, any>} */
|
|
1078
|
+
const pick = {};
|
|
1079
|
+
for (let i = 0; i < p.members.length; i++) {
|
|
1080
|
+
const m = p.members[i];
|
|
1081
|
+
const loc = p.http.in[m];
|
|
1082
|
+
if (loc === 'body') continue;
|
|
1083
|
+
const schema = p.inputEffective.properties[m];
|
|
1084
|
+
setObjectMember(pick, m, schema);
|
|
1085
|
+
if (loc === 'path') pathMembers.push(m);
|
|
1086
|
+
else {
|
|
1087
|
+
if (loc === 'query') queryMembers.push(m);
|
|
1088
|
+
else headerMembers.push(m);
|
|
1089
|
+
const eff = effectiveSchema(schema, scope);
|
|
1090
|
+
const type = isJsonObject(eff) ? eff.type : undefined;
|
|
1091
|
+
if (type === 'array' || (Array.isArray(type) && type.includes('array'))) repeated.push(m);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
if (pathMembers.length + queryMembers.length + headerMembers.length > 0) {
|
|
1095
|
+
// the sub-schema is rooted on the document itself, so every
|
|
1096
|
+
// same-document `$ref` a member schema carries resolves exactly as
|
|
1097
|
+
// it does for the validator
|
|
1098
|
+
const sub = { ...src, type: 'object', properties: pick };
|
|
1099
|
+
const normalize = compileNormalizer(sub, { coerceTypes: true });
|
|
1100
|
+
const declaredRequired = Array.isArray(p.inputEffective.required) ? p.inputEffective.required : [];
|
|
1101
|
+
transport = {
|
|
1102
|
+
normalize,
|
|
1103
|
+
members: { path: pathMembers, query: queryMembers, header: headerMembers, repeated },
|
|
1104
|
+
schemas: pick,
|
|
1105
|
+
required: declaredRequired.filter((/** @type {unknown} */ r) => typeof r === 'string' && Object.hasOwn(pick, r)),
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
input = { schema: p.input, effective: p.inputEffective, validate, transport };
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** @type {CompiledOutput} */
|
|
1112
|
+
const output = { schema: p.output, validate: compileAt(['operations', p.id, 'output'], at(base, 'output')) };
|
|
1113
|
+
|
|
1114
|
+
/** @type {Record<string, CompiledErrorDecl>} */
|
|
1115
|
+
const errors = {};
|
|
1116
|
+
for (let i = 0; i < p.errors.length; i++) {
|
|
1117
|
+
const e = p.errors[i];
|
|
1118
|
+
setObjectMember(errors, e.code, {
|
|
1119
|
+
status: e.status,
|
|
1120
|
+
schema: e.schema,
|
|
1121
|
+
validate: e.schema === null
|
|
1122
|
+
? null
|
|
1123
|
+
: compileAt(['operations', p.id, 'errors', e.code, 'schema'], at(at(at(base, 'errors'), e.code), 'schema')),
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/** @type {CompiledOperation} */
|
|
1128
|
+
const compiled = {
|
|
1129
|
+
id: p.id, kind: p.kind, doc: p.doc,
|
|
1130
|
+
input, output, errors, policy: p.policy, http: p.http,
|
|
1131
|
+
};
|
|
1132
|
+
setObjectMember(operations, p.id, freezeAll(compiled));
|
|
1133
|
+
routes.push({ method: p.http.method, path: p.http.path, key: p.id });
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const router = compileRoutes(routes);
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* @param {string} method
|
|
1140
|
+
* @param {string} path
|
|
1141
|
+
*/
|
|
1142
|
+
function match(method, path) {
|
|
1143
|
+
const found = router.match(method, path);
|
|
1144
|
+
return found === null ? null : { op: operations[found.key], params: found.params };
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/** @type {Contract} */
|
|
1148
|
+
const contract = {
|
|
1149
|
+
doc: src,
|
|
1150
|
+
id: src.id === undefined ? null : src.id,
|
|
1151
|
+
version: src.version === undefined ? null : src.version,
|
|
1152
|
+
compat: Object.freeze(src.compat === undefined ? [] : src.compat.slice()),
|
|
1153
|
+
operations: Object.freeze(operations),
|
|
1154
|
+
ids: Object.freeze(ids),
|
|
1155
|
+
match,
|
|
1156
|
+
allowed: router.allowed,
|
|
1157
|
+
describe: () => describeContract(contract),
|
|
1158
|
+
revision: () => contractRevision(contract),
|
|
1159
|
+
$defs: src.$defs === undefined ? Object.freeze({}) : src.$defs,
|
|
1160
|
+
};
|
|
1161
|
+
return freezeAll(contract);
|
|
1162
|
+
}
|