@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
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `toOpenApi`: the public projection of a compiled contract as a
|
|
4
|
+
* valid, deterministic OpenAPI 3.1 document (docs/CONTRACT-FORMAT.md
|
|
5
|
+
* §12.2), in two stages that each own one concern:
|
|
6
|
+
*
|
|
7
|
+
* 1. **The keyword policy** (this file, JavaScript) walks every schema
|
|
8
|
+
* the projection carries and maps it into the OpenAPI 3.1 dialect —
|
|
9
|
+
* `#/$defs/X` → `#/components/schemas/X`, `nullable: true` → `null`
|
|
10
|
+
* in `type` — or refuses what it cannot map honestly (`JC0060`: a
|
|
11
|
+
* boolean `required`, a `components` member inside a schema, a
|
|
12
|
+
* same-document `$ref` that lands outside `$defs`) or drops and
|
|
13
|
+
* REPORTS what is Jaren-side (`$query`, `$data`, `errorMessage`,
|
|
14
|
+
* `x-form` and other `x-*`) in the `dropped` list with the `docPath`
|
|
15
|
+
* of the keyword in the contract document. Nothing unsupported passes
|
|
16
|
+
* through silently. It also derives, per operation, the facts a
|
|
17
|
+
* document cannot carry for a `$ref` input — the member table with
|
|
18
|
+
* each member's location, requiredness and effective schema — from
|
|
19
|
+
* the compiled operation.
|
|
20
|
+
* 2. **The stylesheet** (`openapi.jslt.json`, a JSLT document compiled
|
|
21
|
+
* once at module scope) shapes the document: `paths` grouped by
|
|
22
|
+
* canonical path and sorted by path then method, the operation
|
|
23
|
+
* object in a fixed member order, parameters, the request body, the
|
|
24
|
+
* success response, the declared errors grouped by status into
|
|
25
|
+
* wire-error schemas (`code` enum-pinned, `details` the declared
|
|
26
|
+
* schema), the binding's own statuses as shared
|
|
27
|
+
* `components.responses`, `components.schemas` from `$defs`, and the
|
|
28
|
+
* `x-jaren-policy` extension.
|
|
29
|
+
*
|
|
30
|
+
* Rendered twice, the document is byte-identical; a test validates it
|
|
31
|
+
* against the vendored OpenAPI 3.1 meta-schema with `JarenValidator`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { isJsonObject, setObjectMember } from '@jarenjs/core/object';
|
|
35
|
+
import { compileJsltStylesheet } from '@jarenjs/json/jslt';
|
|
36
|
+
import { encodeJSONPointerSegment } from '@jarenjs/json/pointer';
|
|
37
|
+
import { createTypeTestCompiler } from '@jarenjs/validate/query';
|
|
38
|
+
|
|
39
|
+
import { ContractCompileError, ContractHostError, CONTRACT_CODES } from '../errors.js';
|
|
40
|
+
import { HTTP_ERRORS } from '../http/wire.js';
|
|
41
|
+
import { publicProjection, retainedOperations } from '../public.js';
|
|
42
|
+
import OPENAPI_STYLESHEET from './openapi.jslt.json' with { type: 'json' };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {import('../compile.js').Contract} Contract
|
|
46
|
+
* @typedef {import('../compile.js').CompiledOperation} CompiledOperation
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One keyword the projection dropped, with where it was.
|
|
51
|
+
* @typedef {Object} DroppedKeyword
|
|
52
|
+
* @property {string} docPath - JSON Pointer of the keyword in the contract document
|
|
53
|
+
* @property {string} keyword
|
|
54
|
+
* @property {string} reason
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {Object} OpenApiOptions
|
|
59
|
+
* @property {{ title?: string, version?: string, [k: string]: unknown }} [info] -
|
|
60
|
+
* the `info` object; `title` defaults to the contract id, `version` to
|
|
61
|
+
* the contract version
|
|
62
|
+
* @property {{ url: string, description?: string }[]} [servers] - the `servers` array, verbatim
|
|
63
|
+
* @property {boolean} [lenient=false] - drop and report a boolean `required`
|
|
64
|
+
* and an unmappable same-document `$ref` instead of refusing (`JC0060`)
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {Object} OpenApiResult
|
|
69
|
+
* @property {Record<string, unknown>} document - the OpenAPI 3.1 document
|
|
70
|
+
* @property {DroppedKeyword[]} dropped - every keyword the keyword policy removed
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/** The stylesheet, compiled once. */
|
|
74
|
+
const render = compileJsltStylesheet(OPENAPI_STYLESHEET, { compileTypeTest: createTypeTestCompiler() });
|
|
75
|
+
|
|
76
|
+
/** Jaren-side keywords the OpenAPI dialect has no reading for — dropped and reported. */
|
|
77
|
+
const JAREN_KEYWORDS = new Set(['$query', '$data', 'errorMessage']);
|
|
78
|
+
/** Keywords whose value is one schema. */
|
|
79
|
+
const SCHEMA_KEYWORDS = new Set([
|
|
80
|
+
'items', 'additionalItems', 'additionalProperties', 'contains', 'not', 'if', 'then', 'else',
|
|
81
|
+
'propertyNames', 'unevaluatedItems', 'unevaluatedProperties', 'contentSchema',
|
|
82
|
+
]);
|
|
83
|
+
/** Keywords whose value is an array of schemas. */
|
|
84
|
+
const SCHEMA_ARRAY_KEYWORDS = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']);
|
|
85
|
+
/** Keywords whose value is a map of schemas (the keys are names, never keywords). */
|
|
86
|
+
const SCHEMA_MAP_KEYWORDS = new Set(['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas']);
|
|
87
|
+
/** Keywords whose value is data, copied verbatim. */
|
|
88
|
+
const DATA_KEYWORDS = new Set(['const', 'enum', 'default', 'examples', 'example']);
|
|
89
|
+
|
|
90
|
+
const COMPONENT_REF = '#/components/schemas/';
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The binding's own responses, shared by every operation they apply to
|
|
94
|
+
* (`components.responses`): name → status, the taxonomy codes the binding
|
|
95
|
+
* answers with that status, and when the response applies. Built from
|
|
96
|
+
* `HTTP_ERRORS`, the single source of the statuses.
|
|
97
|
+
* @type {readonly { name: string, status: number, codes: readonly string[], when: 'always' | 'body' | 'idempotent' }[]}
|
|
98
|
+
*/
|
|
99
|
+
const SHARED_RESPONSES = Object.freeze([
|
|
100
|
+
{ name: 'BadRequest', status: 400, codes: codesWithStatus(400), when: 'always' },
|
|
101
|
+
{ name: 'NotFound', status: 404, codes: codesWithStatus(404), when: 'always' },
|
|
102
|
+
{ name: 'IdempotencyConflict', status: 409, codes: codesWithStatus(409), when: 'idempotent' },
|
|
103
|
+
{ name: 'PayloadTooLarge', status: 413, codes: codesWithStatus(413), when: 'always' },
|
|
104
|
+
{ name: 'UnsupportedMediaType', status: 415, codes: codesWithStatus(415), when: 'body' },
|
|
105
|
+
{ name: 'InternalError', status: 500, codes: codesWithStatus(500), when: 'always' },
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The taxonomy codes answered with a status, in code order.
|
|
110
|
+
* @param {number} status
|
|
111
|
+
* @returns {string[]}
|
|
112
|
+
*/
|
|
113
|
+
function codesWithStatus(status) {
|
|
114
|
+
const out = [];
|
|
115
|
+
const codes = Object.keys(HTTP_ERRORS);
|
|
116
|
+
for (let i = 0; i < codes.length; i++) {
|
|
117
|
+
if (HTTP_ERRORS[/** @type {keyof typeof HTTP_ERRORS} */ (codes[i])].status === status) out.push(codes[i]);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {string} base
|
|
124
|
+
* @param {string | number} key
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
function at(base, key) {
|
|
128
|
+
return `${base}/${encodeJSONPointerSegment(key)}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
//#region the keyword policy
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @typedef {Object} MapContext
|
|
135
|
+
* @property {boolean} lenient
|
|
136
|
+
* @property {DroppedKeyword[]} dropped
|
|
137
|
+
* @property {Set<string>} defs - the `$defs` names the projection carries
|
|
138
|
+
* @property {Map<object, any>} mapped - source schema node → its mapped
|
|
139
|
+
* copy; every node is mapped once (the projection's roots cover every
|
|
140
|
+
* member), so a member's mapped schema is a lookup, and a keyword is
|
|
141
|
+
* reported once
|
|
142
|
+
*/
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {MapContext} ctx
|
|
146
|
+
* @param {string} docPath
|
|
147
|
+
* @param {string} keyword
|
|
148
|
+
* @param {string} reason
|
|
149
|
+
*/
|
|
150
|
+
function drop(ctx, docPath, keyword, reason) {
|
|
151
|
+
ctx.dropped.push({ docPath: at(docPath, keyword), keyword, reason });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Refuse a keyword (`JC0060`), or under `lenient` drop and report it.
|
|
156
|
+
* @param {MapContext} ctx
|
|
157
|
+
* @param {string} docPath
|
|
158
|
+
* @param {string} keyword
|
|
159
|
+
* @param {string} reason
|
|
160
|
+
* @param {boolean} lenientDrops - whether `lenient` may drop it (a `components` member never)
|
|
161
|
+
*/
|
|
162
|
+
function refuseOrDrop(ctx, docPath, keyword, reason, lenientDrops) {
|
|
163
|
+
if (ctx.lenient && lenientDrops) {
|
|
164
|
+
drop(ctx, docPath, keyword, reason);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
throw new ContractCompileError('JC0060', `the OpenAPI projection cannot carry '${keyword}' here: ${reason}`
|
|
168
|
+
+ (lenientDrops ? ' (toOpenApi({ lenient: true }) drops it and reports it in `dropped`)' : ''), at(docPath, keyword));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Map a same-document reference to its place in the OpenAPI document.
|
|
173
|
+
* `#/$defs/X…` becomes `#/components/schemas/X…`; anything else inside the
|
|
174
|
+
* document (`#`, an anchor, a pointer into an operation) has no place
|
|
175
|
+
* there — refused, or dropped under `lenient`. An absolute reference is
|
|
176
|
+
* kept as written. Returns `undefined` for a dropped reference.
|
|
177
|
+
* @param {string} ref
|
|
178
|
+
* @param {string} docPath - of the schema node
|
|
179
|
+
* @param {MapContext} ctx
|
|
180
|
+
* @returns {string | undefined}
|
|
181
|
+
*/
|
|
182
|
+
function mapRef(ref, docPath, ctx) {
|
|
183
|
+
if (!ref.startsWith('#')) return ref;
|
|
184
|
+
if (ref.startsWith('#/$defs/')) {
|
|
185
|
+
const rest = ref.slice('#/$defs/'.length);
|
|
186
|
+
const slash = rest.indexOf('/');
|
|
187
|
+
const name = decodeURIComponent(slash === -1 ? rest : rest.slice(0, slash)).replaceAll('~1', '/').replaceAll('~0', '~');
|
|
188
|
+
if (ctx.defs.has(name)) return COMPONENT_REF + rest;
|
|
189
|
+
}
|
|
190
|
+
refuseOrDrop(ctx, docPath, '$ref',
|
|
191
|
+
`the same-document reference '${ref}' lands outside the projection's $defs and would not resolve in the OpenAPI document — keep shared schemas in $defs`,
|
|
192
|
+
true);
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Add `'null'` to a `type` for `nullable: true`; `undefined` when `type`
|
|
198
|
+
* is absent (then `nullable` says nothing the dialect can carry).
|
|
199
|
+
* @param {unknown} type
|
|
200
|
+
* @returns {unknown}
|
|
201
|
+
*/
|
|
202
|
+
function nullableType(type) {
|
|
203
|
+
if (typeof type === 'string') return type === 'null' ? type : [type, 'null'];
|
|
204
|
+
if (Array.isArray(type)) return type.includes('null') ? type : [...type, 'null'];
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Map one schema (object, boolean, or whatever the document carries) into
|
|
210
|
+
* the OpenAPI 3.1 dialect. Fresh objects throughout; data keywords are
|
|
211
|
+
* shared as they are.
|
|
212
|
+
* @param {any} node
|
|
213
|
+
* @param {string} docPath
|
|
214
|
+
* @param {MapContext} ctx
|
|
215
|
+
* @returns {any}
|
|
216
|
+
*/
|
|
217
|
+
function mapSchema(node, docPath, ctx) {
|
|
218
|
+
if (!isJsonObject(node)) return node;
|
|
219
|
+
const seen = ctx.mapped.get(node);
|
|
220
|
+
if (seen !== undefined) return seen;
|
|
221
|
+
/** @type {Record<string, any>} */
|
|
222
|
+
const out = {};
|
|
223
|
+
ctx.mapped.set(node, out);
|
|
224
|
+
const keys = Object.keys(node);
|
|
225
|
+
for (let i = 0; i < keys.length; i++) {
|
|
226
|
+
const key = keys[i];
|
|
227
|
+
const value = node[key];
|
|
228
|
+
if (key === '$ref' && typeof value === 'string') {
|
|
229
|
+
const mapped = mapRef(value, docPath, ctx);
|
|
230
|
+
if (mapped !== undefined) out.$ref = mapped;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (key === 'nullable') {
|
|
234
|
+
if (value === true) {
|
|
235
|
+
const type = nullableType(node.type);
|
|
236
|
+
if (type === undefined) drop(ctx, docPath, key, 'nullable: true without a type — OpenAPI 3.1 carries nullability in type, and there is none to widen');
|
|
237
|
+
else out.type = type;
|
|
238
|
+
}
|
|
239
|
+
else drop(ctx, docPath, key, 'asserts nothing in OpenAPI 3.1 (only nullable: true maps, to "null" in type)');
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (key === 'type' && node.nullable === true && nullableType(value) !== undefined) {
|
|
243
|
+
out.type = nullableType(value);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (key === 'required' && typeof value === 'boolean') {
|
|
247
|
+
refuseOrDrop(ctx, docPath, key, 'a boolean required (draft-04 style) has no meaning in OpenAPI 3.1 — declare the member in the parent\'s required array', true);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (key === 'components') {
|
|
251
|
+
refuseOrDrop(ctx, docPath, key, 'a components member inside a schema is an OpenAPI document member, not a schema keyword', false);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (JAREN_KEYWORDS.has(key) || key.startsWith('x-')) {
|
|
255
|
+
drop(ctx, docPath, key, 'a Jaren-side keyword the OpenAPI dialect has no reading for');
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (DATA_KEYWORDS.has(key)) {
|
|
259
|
+
out[key] = value;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (SCHEMA_KEYWORDS.has(key)) {
|
|
263
|
+
out[key] = mapSchema(value, at(docPath, key), ctx);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (SCHEMA_ARRAY_KEYWORDS.has(key) && Array.isArray(value)) {
|
|
267
|
+
const arr = new Array(value.length);
|
|
268
|
+
for (let j = 0; j < value.length; j++) arr[j] = mapSchema(value[j], at(at(docPath, key), j), ctx);
|
|
269
|
+
out[key] = arr;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (SCHEMA_MAP_KEYWORDS.has(key) && isJsonObject(value)) {
|
|
273
|
+
/** @type {Record<string, any>} */
|
|
274
|
+
const map = {};
|
|
275
|
+
const names = Object.keys(value);
|
|
276
|
+
for (let j = 0; j < names.length; j++) {
|
|
277
|
+
setObjectMember(map, names[j], mapSchema(value[names[j]], at(at(docPath, key), names[j]), ctx));
|
|
278
|
+
}
|
|
279
|
+
out[key] = map;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (key === 'dependencies' && isJsonObject(value)) {
|
|
283
|
+
/** @type {Record<string, any>} */
|
|
284
|
+
const map = {};
|
|
285
|
+
const names = Object.keys(value);
|
|
286
|
+
for (let j = 0; j < names.length; j++) {
|
|
287
|
+
const dep = value[names[j]];
|
|
288
|
+
setObjectMember(map, names[j], Array.isArray(dep) ? dep : mapSchema(dep, at(at(docPath, key), names[j]), ctx));
|
|
289
|
+
}
|
|
290
|
+
out[key] = map;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
out[key] = value;
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
//#endregion
|
|
299
|
+
|
|
300
|
+
//#region the operation view
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The first line of a doc string, and whether there is more.
|
|
304
|
+
* @param {string} doc
|
|
305
|
+
* @returns {{ summary: string, long: boolean }}
|
|
306
|
+
*/
|
|
307
|
+
function docLines(doc) {
|
|
308
|
+
const nl = doc.indexOf('\n');
|
|
309
|
+
if (nl === -1) return { summary: doc.trim(), long: false };
|
|
310
|
+
return { summary: doc.slice(0, nl).trim(), long: doc.slice(nl + 1).trim().length > 0 };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The stylesheet's view of one operation: the projection's own members
|
|
315
|
+
* plus what only the compiled operation knows — the member table (name,
|
|
316
|
+
* location, requiredness, effective schema), the body, the success
|
|
317
|
+
* response and the declared errors with string statuses, and the shared
|
|
318
|
+
* binding responses that apply.
|
|
319
|
+
* @param {CompiledOperation} op
|
|
320
|
+
* @param {Record<string, any>} projected - the operation's public projection, schemas mapped
|
|
321
|
+
* @param {MapContext} ctx
|
|
322
|
+
* @returns {Record<string, unknown>}
|
|
323
|
+
*/
|
|
324
|
+
function operationView(op, projected, ctx) {
|
|
325
|
+
const base = at('/operations', op.id);
|
|
326
|
+
const http = op.http;
|
|
327
|
+
/** @type {{ name: string, in: string, required: boolean, schema: any }[]} */
|
|
328
|
+
const parameters = [];
|
|
329
|
+
/** @type {Record<string, any>} */
|
|
330
|
+
const bodyMembers = {};
|
|
331
|
+
/** @type {string[]} */
|
|
332
|
+
const bodyRequired = [];
|
|
333
|
+
let bodyCount = 0;
|
|
334
|
+
let bodyMemberRequired = false;
|
|
335
|
+
const eff = effectiveInput(op);
|
|
336
|
+
const declaredRequired = eff !== null && Array.isArray(eff.required) ? /** @type {unknown[]} */ (eff.required) : [];
|
|
337
|
+
const members = Object.keys(http.in);
|
|
338
|
+
for (let i = 0; i < members.length; i++) {
|
|
339
|
+
const name = members[i];
|
|
340
|
+
const loc = http.in[name];
|
|
341
|
+
// the member's schema as the projection mapped it: the effective
|
|
342
|
+
// input's property (the projection's `input` may be a $ref — the
|
|
343
|
+
// compiled operation resolved it), already walked as part of the
|
|
344
|
+
// input or of the $defs entry it lives in; a member of a registered
|
|
345
|
+
// external schema is mapped here, at the input's docPath
|
|
346
|
+
const mapped = mappedMember(effectiveMemberSchema(op, name), at(base, 'input'), ctx);
|
|
347
|
+
const required = loc === 'path' || declaredRequired.includes(name);
|
|
348
|
+
if (loc === 'body') {
|
|
349
|
+
setObjectMember(bodyMembers, name, mapped);
|
|
350
|
+
if (required) bodyRequired.push(name);
|
|
351
|
+
bodyCount++;
|
|
352
|
+
if (name === http.body) bodyMemberRequired = required;
|
|
353
|
+
}
|
|
354
|
+
else parameters.push({ name, in: loc, required, schema: mapped });
|
|
355
|
+
}
|
|
356
|
+
if (op.policy.idempotency !== 'none') {
|
|
357
|
+
parameters.push({
|
|
358
|
+
name: 'Idempotency-Key', in: 'header', required: op.policy.idempotency === 'required',
|
|
359
|
+
schema: { type: 'string', minLength: 1 },
|
|
360
|
+
description: 'The caller-generated idempotency key the server deduplicates this command on (policy.idempotency '
|
|
361
|
+
+ op.policy.idempotency + ').',
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
/** @type {Record<string, unknown> | undefined} */
|
|
365
|
+
let body;
|
|
366
|
+
if (http.body !== null) {
|
|
367
|
+
body = { media: http.media, required: bodyMemberRequired, schema: bodyMembers[http.body] };
|
|
368
|
+
}
|
|
369
|
+
else if (bodyCount > 0) {
|
|
370
|
+
const whole = bodyCount === members.length;
|
|
371
|
+
/** @type {Record<string, unknown>} */
|
|
372
|
+
let schema;
|
|
373
|
+
if (whole) schema = projected.input;
|
|
374
|
+
else {
|
|
375
|
+
schema = { type: 'object', properties: bodyMembers };
|
|
376
|
+
if (bodyRequired.length > 0) schema.required = bodyRequired;
|
|
377
|
+
if (eff !== null && eff.additionalProperties !== undefined) {
|
|
378
|
+
schema.additionalProperties = mappedMember(eff.additionalProperties, at(base, 'input'), ctx);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
body = { media: http.media, required: bodyRequired.length > 0, schema };
|
|
382
|
+
}
|
|
383
|
+
/** @type {Record<string, unknown>} */
|
|
384
|
+
const success = { status: String(http.status), media: http.media };
|
|
385
|
+
if (http.status !== 204 && http.method !== 'HEAD') {
|
|
386
|
+
success.schema = http.opaque ? { type: 'string', format: 'binary' } : projected.output;
|
|
387
|
+
}
|
|
388
|
+
/** @type {Record<string, unknown>[]} */
|
|
389
|
+
const errors = [];
|
|
390
|
+
const codes = Object.keys(op.errors);
|
|
391
|
+
for (let i = 0; i < codes.length; i++) {
|
|
392
|
+
const decl = op.errors[codes[i]];
|
|
393
|
+
/** @type {Record<string, unknown>} */
|
|
394
|
+
const e = { code: codes[i], status: String(decl.status), declared: true };
|
|
395
|
+
if (decl.schema !== null) e.schema = projected.errors[codes[i]].schema;
|
|
396
|
+
errors.push(e);
|
|
397
|
+
}
|
|
398
|
+
for (let i = 0; i < SHARED_RESPONSES.length; i++) {
|
|
399
|
+
const shared = SHARED_RESPONSES[i];
|
|
400
|
+
if (shared.when === 'body' && body === undefined) continue;
|
|
401
|
+
if (shared.when === 'idempotent' && op.policy.idempotency === 'none') continue;
|
|
402
|
+
for (let j = 0; j < shared.codes.length; j++) {
|
|
403
|
+
errors.push({ code: shared.codes[j], status: String(shared.status), declared: false, shared: shared.name });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/** @type {Record<string, unknown>} */
|
|
407
|
+
const view = { id: op.id, tag: op.id.split('.')[0], method: http.method.toLowerCase(), path: http.path };
|
|
408
|
+
if (op.doc !== null) {
|
|
409
|
+
const lines = docLines(op.doc);
|
|
410
|
+
if (lines.summary !== '') view.summary = lines.summary;
|
|
411
|
+
if (lines.long) view.description = op.doc;
|
|
412
|
+
}
|
|
413
|
+
view.parameters = parameters;
|
|
414
|
+
if (body !== undefined) view.body = body;
|
|
415
|
+
view.success = success;
|
|
416
|
+
view.errors = errors;
|
|
417
|
+
view.policy = policyExtension(op);
|
|
418
|
+
return view;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The effective input schema of an operation — the declared `input` with
|
|
423
|
+
* its `$ref` chain followed — or `null` for an input-less operation.
|
|
424
|
+
* @param {CompiledOperation} op
|
|
425
|
+
* @returns {any}
|
|
426
|
+
*/
|
|
427
|
+
function effectiveInput(op) {
|
|
428
|
+
return op.input === null ? null : op.input.effective;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The mapped copy of a schema node the projection has walked (a member of
|
|
433
|
+
* an input, or of the `$defs` entry a `$ref` input resolves to); a node
|
|
434
|
+
* it has not — a member of a registered external schema — is mapped now,
|
|
435
|
+
* reported at `docPath`.
|
|
436
|
+
* @param {any} schema
|
|
437
|
+
* @param {string} docPath
|
|
438
|
+
* @param {MapContext} ctx
|
|
439
|
+
* @returns {any}
|
|
440
|
+
*/
|
|
441
|
+
function mappedMember(schema, docPath, ctx) {
|
|
442
|
+
if (!isJsonObject(schema)) return schema;
|
|
443
|
+
const seen = ctx.mapped.get(schema);
|
|
444
|
+
return seen !== undefined ? seen : mapSchema(schema, docPath, ctx);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The declared schema of a body-located member, read from the effective
|
|
449
|
+
* input (the projection's `input` may be a `$ref`; the compiled operation
|
|
450
|
+
* resolved it).
|
|
451
|
+
* @param {CompiledOperation} op
|
|
452
|
+
* @param {string} name
|
|
453
|
+
* @returns {any}
|
|
454
|
+
*/
|
|
455
|
+
function effectiveMemberSchema(op, name) {
|
|
456
|
+
const eff = effectiveInput(op);
|
|
457
|
+
return eff !== null && isJsonObject(eff.properties) ? eff.properties[name] : undefined;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* The `x-jaren-policy` extension: the client-facing policy verbatim.
|
|
462
|
+
* @param {CompiledOperation} op
|
|
463
|
+
* @returns {Record<string, unknown>}
|
|
464
|
+
*/
|
|
465
|
+
function policyExtension(op) {
|
|
466
|
+
const p = op.policy;
|
|
467
|
+
/** @type {Record<string, unknown>} */
|
|
468
|
+
const out = { task: p.task, idempotency: p.idempotency, cache: p.cache };
|
|
469
|
+
if (p.revision !== null) out.revision = p.revision;
|
|
470
|
+
if (p.retry !== null) out.retry = { max: p.retry.max, on: p.retry.on.slice() };
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
//#endregion
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Project a compiled contract to an OpenAPI 3.1 document.
|
|
478
|
+
* @param {Contract} contract
|
|
479
|
+
* @param {OpenApiOptions} [options]
|
|
480
|
+
* @returns {OpenApiResult}
|
|
481
|
+
* @throws {ContractHostError} `JC1008` — not a compiled contract, or a malformed option
|
|
482
|
+
* @throws {ContractCompileError} `JC0060` — a schema keyword the projection refuses (see `lenient`)
|
|
483
|
+
* @example
|
|
484
|
+
* const { document, dropped } = toOpenApi(contract, { info: { title: 'Shop', version: '5' } });
|
|
485
|
+
* document.paths['/api/catalog'].get.operationId; // 'catalog.load'
|
|
486
|
+
*/
|
|
487
|
+
export function toOpenApi(contract, options = {}) {
|
|
488
|
+
if (!isJsonObject(options)) throw new ContractHostError('JC1008', 'toOpenApi: options must be an object');
|
|
489
|
+
const ops = retainedOperations(contract, undefined, 'toOpenApi');
|
|
490
|
+
const lenient = options.lenient === undefined ? false : options.lenient;
|
|
491
|
+
if (typeof lenient !== 'boolean') throw new ContractHostError('JC1008', 'toOpenApi: options.lenient must be a boolean');
|
|
492
|
+
if (options.info !== undefined && !isJsonObject(options.info)) {
|
|
493
|
+
throw new ContractHostError('JC1008', 'toOpenApi: options.info must be an object { title, version, ... }');
|
|
494
|
+
}
|
|
495
|
+
if (options.servers !== undefined) {
|
|
496
|
+
if (!Array.isArray(options.servers)) throw new ContractHostError('JC1008', 'toOpenApi: options.servers must be an array of { url, description? }');
|
|
497
|
+
for (let i = 0; i < options.servers.length; i++) {
|
|
498
|
+
const s = options.servers[i];
|
|
499
|
+
if (!isJsonObject(s) || typeof s.url !== 'string') {
|
|
500
|
+
throw new ContractHostError('JC1008', `toOpenApi: options.servers[${i}] must be { url: string, description? }`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/** @type {Record<string, unknown>} */
|
|
505
|
+
const info = { ...options.info };
|
|
506
|
+
if (typeof info.title !== 'string') info.title = contract.id === null ? 'jaren-contract' : contract.id;
|
|
507
|
+
if (typeof info.version !== 'string') info.version = contract.version === null ? '0' : contract.version;
|
|
508
|
+
|
|
509
|
+
const pub = /** @type {any} */ (publicProjection(contract));
|
|
510
|
+
/** @type {MapContext} */
|
|
511
|
+
const ctx = { lenient, dropped: [], defs: new Set(Object.keys(pub.$defs ?? {})), mapped: new Map() };
|
|
512
|
+
|
|
513
|
+
/** @type {Record<string, any>} */
|
|
514
|
+
const schemas = {};
|
|
515
|
+
const defNames = Object.keys(pub.$defs ?? {});
|
|
516
|
+
for (let i = 0; i < defNames.length; i++) {
|
|
517
|
+
setObjectMember(schemas, defNames[i], mapSchema(pub.$defs[defNames[i]], at('/$defs', defNames[i]), ctx));
|
|
518
|
+
}
|
|
519
|
+
/** @type {Record<string, unknown>[]} */
|
|
520
|
+
const operations = [];
|
|
521
|
+
/** @type {Set<string>} */
|
|
522
|
+
const sharedUsed = new Set();
|
|
523
|
+
for (let i = 0; i < ops.length; i++) {
|
|
524
|
+
const op = ops[i];
|
|
525
|
+
const base = at('/operations', op.id);
|
|
526
|
+
const src = pub.operations[op.id];
|
|
527
|
+
// walked in document order — $defs first, then per operation input,
|
|
528
|
+
// output, errors — so `dropped` reads like the document
|
|
529
|
+
/** @type {Record<string, any>} */
|
|
530
|
+
const projected = { input: undefined, output: undefined, errors: {} };
|
|
531
|
+
if (src.input !== undefined) projected.input = mapSchema(src.input, at(base, 'input'), ctx);
|
|
532
|
+
projected.output = mapSchema(src.output, at(base, 'output'), ctx);
|
|
533
|
+
const codes = Object.keys(src.errors ?? {});
|
|
534
|
+
for (let j = 0; j < codes.length; j++) {
|
|
535
|
+
const decl = src.errors[codes[j]];
|
|
536
|
+
setObjectMember(projected.errors, codes[j], {
|
|
537
|
+
schema: decl.schema === undefined ? undefined : mapSchema(decl.schema, at(at(at(base, 'errors'), codes[j]), 'schema'), ctx),
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
const view = operationView(op, projected, ctx);
|
|
541
|
+
for (const e of /** @type {any[]} */ (view.errors)) if (e.shared !== undefined) sharedUsed.add(e.shared);
|
|
542
|
+
operations.push(view);
|
|
543
|
+
}
|
|
544
|
+
/** @type {Record<string, unknown>} */
|
|
545
|
+
const shared = {};
|
|
546
|
+
for (let i = 0; i < SHARED_RESPONSES.length; i++) {
|
|
547
|
+
const s = SHARED_RESPONSES[i];
|
|
548
|
+
if (!sharedUsed.has(s.name)) continue;
|
|
549
|
+
shared[s.name] = {
|
|
550
|
+
status: String(s.status),
|
|
551
|
+
codes: s.codes.slice(),
|
|
552
|
+
description: `Answered by the binding, not the handler — ${s.codes.map((c) => `${c}: ${CONTRACT_CODES[/** @type {keyof typeof CONTRACT_CODES} */ (c)]}`).join('; ')}.`,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
const input = {
|
|
556
|
+
info,
|
|
557
|
+
servers: options.servers === undefined ? [] : options.servers,
|
|
558
|
+
schemas,
|
|
559
|
+
operations,
|
|
560
|
+
shared,
|
|
561
|
+
};
|
|
562
|
+
const document = /** @type {Record<string, unknown>} */ (render(input));
|
|
563
|
+
return { document, dropped: ctx.dropped };
|
|
564
|
+
}
|