@croutonian/with-openapi 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +499 -0
- package/dist/index.d.ts +451 -0
- package/dist/index.js +1419 -0
- package/package.json +77 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1419 @@
|
|
|
1
|
+
import { defineMiddleware } from "@supabase/middleware";
|
|
2
|
+
import { dereference, validate } from "@cfworker/json-schema";
|
|
3
|
+
|
|
4
|
+
//#region src/params.ts
|
|
5
|
+
const ABSENT = {
|
|
6
|
+
present: false,
|
|
7
|
+
value: void 0
|
|
8
|
+
};
|
|
9
|
+
function typesOf(schema) {
|
|
10
|
+
const type = schema?.type;
|
|
11
|
+
if (type === void 0) return [];
|
|
12
|
+
return Array.isArray(type) ? type : [type];
|
|
13
|
+
}
|
|
14
|
+
/** Classify a schema, falling back to its keywords when `type` is absent. */
|
|
15
|
+
function schemaKind(schema) {
|
|
16
|
+
const types = typesOf(schema);
|
|
17
|
+
if (types.includes("array")) return "array";
|
|
18
|
+
if (types.includes("object")) return "object";
|
|
19
|
+
if (types.length > 0) return "primitive";
|
|
20
|
+
if (schema?.items !== void 0 || schema?.prefixItems !== void 0) return "array";
|
|
21
|
+
if (schema?.properties !== void 0) return "object";
|
|
22
|
+
return "primitive";
|
|
23
|
+
}
|
|
24
|
+
/** `['a','1','b','2']` — the un-exploded object encoding. */
|
|
25
|
+
function pairsToObject(tokens) {
|
|
26
|
+
const out = {};
|
|
27
|
+
for (let i = 0; i + 1 < tokens.length; i += 2) {
|
|
28
|
+
const key = tokens[i];
|
|
29
|
+
const value = tokens[i + 1];
|
|
30
|
+
if (key !== void 0 && value !== void 0) out[key] = value;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/** `['a=1','b=2']` — the exploded object encoding. */
|
|
35
|
+
function assignmentsToObject(tokens) {
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const token of tokens) {
|
|
38
|
+
const at = token.indexOf("=");
|
|
39
|
+
if (at > 0) out[token.slice(0, at)] = token.slice(at + 1);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** Split that treats the empty string as the empty list, not `['']`. */
|
|
44
|
+
function splitList(raw, delimiter) {
|
|
45
|
+
return raw === "" ? [] : raw.split(delimiter);
|
|
46
|
+
}
|
|
47
|
+
/** Parse a `Cookie` header into a name/value record. */
|
|
48
|
+
function parseCookies(header) {
|
|
49
|
+
const out = {};
|
|
50
|
+
if (!header) return out;
|
|
51
|
+
for (const part of header.split(";")) {
|
|
52
|
+
const at = part.indexOf("=");
|
|
53
|
+
if (at < 1) continue;
|
|
54
|
+
const name = part.slice(0, at).trim();
|
|
55
|
+
const raw = part.slice(at + 1).trim();
|
|
56
|
+
try {
|
|
57
|
+
out[name] = decodeURIComponent(raw);
|
|
58
|
+
} catch {
|
|
59
|
+
out[name] = raw;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
function readPath(param, raw) {
|
|
65
|
+
const kind = schemaKind(param.resolved);
|
|
66
|
+
if (param.style === "matrix") {
|
|
67
|
+
const body = raw.startsWith(";") ? raw.slice(1) : raw;
|
|
68
|
+
if (kind === "object") return param.explode ? {
|
|
69
|
+
present: true,
|
|
70
|
+
value: assignmentsToObject(splitList(body, ";"))
|
|
71
|
+
} : {
|
|
72
|
+
present: true,
|
|
73
|
+
value: pairsToObject(splitList(afterName(body, param.name), ","))
|
|
74
|
+
};
|
|
75
|
+
if (kind === "array") {
|
|
76
|
+
if (param.explode) return {
|
|
77
|
+
present: true,
|
|
78
|
+
value: splitList(body, ";").flatMap((token) => {
|
|
79
|
+
const at = token.indexOf("=");
|
|
80
|
+
return at > 0 ? [token.slice(at + 1)] : [];
|
|
81
|
+
})
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
present: true,
|
|
85
|
+
value: splitList(afterName(body, param.name), ",")
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
present: true,
|
|
90
|
+
value: afterName(body, param.name)
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (param.style === "label") {
|
|
94
|
+
const body = raw.startsWith(".") ? raw.slice(1) : raw;
|
|
95
|
+
const delimiter = param.explode ? "." : ",";
|
|
96
|
+
if (kind === "object") return {
|
|
97
|
+
present: true,
|
|
98
|
+
value: param.explode ? assignmentsToObject(splitList(body, ".")) : pairsToObject(splitList(body, ","))
|
|
99
|
+
};
|
|
100
|
+
if (kind === "array") return {
|
|
101
|
+
present: true,
|
|
102
|
+
value: splitList(body, delimiter)
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
present: true,
|
|
106
|
+
value: body
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (kind === "object") return {
|
|
110
|
+
present: true,
|
|
111
|
+
value: param.explode ? assignmentsToObject(splitList(raw, ",")) : pairsToObject(splitList(raw, ","))
|
|
112
|
+
};
|
|
113
|
+
if (kind === "array") return {
|
|
114
|
+
present: true,
|
|
115
|
+
value: splitList(raw, ",")
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
present: true,
|
|
119
|
+
value: raw
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function afterName(body, name) {
|
|
123
|
+
const prefix = `${name}=`;
|
|
124
|
+
return body.startsWith(prefix) ? body.slice(prefix.length) : body;
|
|
125
|
+
}
|
|
126
|
+
function readDeepObject(param, search) {
|
|
127
|
+
const prefix = `${param.name}[`;
|
|
128
|
+
const out = {};
|
|
129
|
+
let present = false;
|
|
130
|
+
for (const [key, value] of search) {
|
|
131
|
+
if (!key.startsWith(prefix) || !key.endsWith("]")) continue;
|
|
132
|
+
out[key.slice(prefix.length, -1)] = value;
|
|
133
|
+
present = true;
|
|
134
|
+
}
|
|
135
|
+
return present ? {
|
|
136
|
+
present,
|
|
137
|
+
value: out
|
|
138
|
+
} : ABSENT;
|
|
139
|
+
}
|
|
140
|
+
function readExplodedObject(param, lookup) {
|
|
141
|
+
const properties = param.resolved?.properties;
|
|
142
|
+
if (properties === void 0) return ABSENT;
|
|
143
|
+
const out = {};
|
|
144
|
+
let present = false;
|
|
145
|
+
for (const key of Object.keys(properties)) {
|
|
146
|
+
const value = lookup(key);
|
|
147
|
+
if (value === void 0) continue;
|
|
148
|
+
out[key] = value;
|
|
149
|
+
present = true;
|
|
150
|
+
}
|
|
151
|
+
return present ? {
|
|
152
|
+
present,
|
|
153
|
+
value: out
|
|
154
|
+
} : ABSENT;
|
|
155
|
+
}
|
|
156
|
+
function readQuery(param, search) {
|
|
157
|
+
const kind = schemaKind(param.resolved);
|
|
158
|
+
if (param.style === "deepObject") return kind === "object" ? readDeepObject(param, search) : ABSENT;
|
|
159
|
+
const delimiter = param.style === "spaceDelimited" ? " " : param.style === "pipeDelimited" ? "|" : ",";
|
|
160
|
+
if (kind === "object" && param.explode && param.style === "form") return readExplodedObject(param, (key) => search.get(key) ?? void 0);
|
|
161
|
+
if (kind === "array" && param.explode) return search.has(param.name) ? {
|
|
162
|
+
present: true,
|
|
163
|
+
value: search.getAll(param.name)
|
|
164
|
+
} : ABSENT;
|
|
165
|
+
const raw = search.get(param.name);
|
|
166
|
+
if (raw === null) return ABSENT;
|
|
167
|
+
if (kind === "array") return {
|
|
168
|
+
present: true,
|
|
169
|
+
value: splitList(raw, delimiter)
|
|
170
|
+
};
|
|
171
|
+
if (kind === "object") return {
|
|
172
|
+
present: true,
|
|
173
|
+
value: pairsToObject(splitList(raw, delimiter))
|
|
174
|
+
};
|
|
175
|
+
return {
|
|
176
|
+
present: true,
|
|
177
|
+
value: raw
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function readSimpleString(param, raw) {
|
|
181
|
+
const kind = schemaKind(param.resolved);
|
|
182
|
+
if (kind === "array") return {
|
|
183
|
+
present: true,
|
|
184
|
+
value: splitList(raw, ",").map((v) => v.trim())
|
|
185
|
+
};
|
|
186
|
+
if (kind === "object") {
|
|
187
|
+
const tokens = splitList(raw, ",").map((v) => v.trim());
|
|
188
|
+
return {
|
|
189
|
+
present: true,
|
|
190
|
+
value: param.explode ? assignmentsToObject(tokens) : pairsToObject(tokens)
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
present: true,
|
|
195
|
+
value: raw
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function readCookie(param, cookies) {
|
|
199
|
+
const kind = schemaKind(param.resolved);
|
|
200
|
+
if (kind === "object" && param.explode) return readExplodedObject(param, (key) => cookies[key]);
|
|
201
|
+
const raw = cookies[param.name];
|
|
202
|
+
if (raw === void 0) return ABSENT;
|
|
203
|
+
if (kind === "array") return {
|
|
204
|
+
present: true,
|
|
205
|
+
value: splitList(raw, ",")
|
|
206
|
+
};
|
|
207
|
+
if (kind === "object") return {
|
|
208
|
+
present: true,
|
|
209
|
+
value: pairsToObject(splitList(raw, ","))
|
|
210
|
+
};
|
|
211
|
+
return {
|
|
212
|
+
present: true,
|
|
213
|
+
value: raw
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/** Read one parameter out of the request, undoing its `style`/`explode`. */
|
|
217
|
+
function readParameter(param, sources) {
|
|
218
|
+
switch (param.in) {
|
|
219
|
+
case "path": {
|
|
220
|
+
const raw = sources.pathValues[param.name];
|
|
221
|
+
return raw === void 0 ? ABSENT : readPath(param, raw);
|
|
222
|
+
}
|
|
223
|
+
case "query": return readQuery(param, sources.search);
|
|
224
|
+
case "header": {
|
|
225
|
+
const raw = sources.headers.get(param.name);
|
|
226
|
+
return raw === null ? ABSENT : readSimpleString(param, raw);
|
|
227
|
+
}
|
|
228
|
+
case "cookie": return readCookie(param, sources.cookies);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function isPlainObject(value) {
|
|
232
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
233
|
+
}
|
|
234
|
+
function coerceScalar(value, schema) {
|
|
235
|
+
const types = typesOf(schema);
|
|
236
|
+
if (types.length === 0) {
|
|
237
|
+
const values = schema.enum;
|
|
238
|
+
if (Array.isArray(values) && values.length > 0 && values.every((v) => typeof v === "number")) {
|
|
239
|
+
const parsed = toNumber(value);
|
|
240
|
+
return parsed === void 0 ? value : parsed;
|
|
241
|
+
}
|
|
242
|
+
return value;
|
|
243
|
+
}
|
|
244
|
+
if (types.includes("string")) return value;
|
|
245
|
+
if (types.includes("boolean")) {
|
|
246
|
+
if (value === "true") return true;
|
|
247
|
+
if (value === "false") return false;
|
|
248
|
+
}
|
|
249
|
+
if (types.includes("integer")) {
|
|
250
|
+
const parsed = toNumber(value);
|
|
251
|
+
if (parsed !== void 0 && Number.isInteger(parsed)) return parsed;
|
|
252
|
+
}
|
|
253
|
+
if (types.includes("number")) {
|
|
254
|
+
const parsed = toNumber(value);
|
|
255
|
+
if (parsed !== void 0) return parsed;
|
|
256
|
+
}
|
|
257
|
+
if (types.includes("null") && value === "null") return null;
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
function toNumber(value) {
|
|
261
|
+
if (value.trim() === "") return void 0;
|
|
262
|
+
const parsed = Number(value);
|
|
263
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Recursively convert a deserialized value into the JSON types its schema
|
|
267
|
+
* describes. Non-string leaves and unknown properties are passed through
|
|
268
|
+
* untouched.
|
|
269
|
+
*/
|
|
270
|
+
function coerceToSchema(value, schema, resolve) {
|
|
271
|
+
if (schema === void 0) return value;
|
|
272
|
+
if (Array.isArray(value)) {
|
|
273
|
+
const prefixItems = schema.prefixItems;
|
|
274
|
+
const items = resolve(schema.items);
|
|
275
|
+
return value.map((entry, index) => {
|
|
276
|
+
const prefix = prefixItems?.[index];
|
|
277
|
+
return coerceToSchema(entry, prefix !== void 0 ? resolve(prefix) : items, resolve);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
if (isPlainObject(value)) {
|
|
281
|
+
const properties = schema.properties;
|
|
282
|
+
const additional = typeof schema.additionalProperties === "object" ? resolve(schema.additionalProperties) : void 0;
|
|
283
|
+
const out = {};
|
|
284
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
285
|
+
const declared = properties?.[key];
|
|
286
|
+
out[key] = coerceToSchema(entry, declared !== void 0 ? resolve(declared) : additional, resolve);
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
return typeof value === "string" ? coerceScalar(value, schema) : value;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
//#endregion
|
|
294
|
+
//#region src/body.ts
|
|
295
|
+
/** Lowercase `type/subtype` of a content-type header, parameters dropped. */
|
|
296
|
+
function essenceOf(contentType) {
|
|
297
|
+
if (contentType === null) return void 0;
|
|
298
|
+
const essence = contentType.split(";")[0]?.trim().toLowerCase();
|
|
299
|
+
return essence === void 0 || essence === "" ? void 0 : essence;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Find the Media Type Object covering a request's content type.
|
|
303
|
+
*
|
|
304
|
+
* Exact match first, then a `type` wildcard range, then the
|
|
305
|
+
* catch-all range — the precedence the spec gives for `content` map keys.
|
|
306
|
+
*/
|
|
307
|
+
function matchMediaType(contents, essence) {
|
|
308
|
+
const type = essence.split("/")[0] ?? "";
|
|
309
|
+
return contents.find((entry) => entry.mediaType === essence) ?? contents.find((entry) => entry.mediaType === `${type}/*`) ?? contents.find((entry) => entry.mediaType === "*/*");
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Decide how to read the body from what the *request* says it is, not from
|
|
313
|
+
* what the document declared — a catch-all range matches anything, and the
|
|
314
|
+
* request is the only thing that knows what was actually sent.
|
|
315
|
+
*/
|
|
316
|
+
function bodyFormFor(essence) {
|
|
317
|
+
if (essence === "application/json" || essence.endsWith("+json")) return "json";
|
|
318
|
+
if (essence.endsWith("/json")) return "json";
|
|
319
|
+
if (essence === "application/x-www-form-urlencoded") return "urlencoded";
|
|
320
|
+
if (essence === "multipart/form-data") return "multipart";
|
|
321
|
+
if (essence.startsWith("text/") || essence.endsWith("+xml")) return "text";
|
|
322
|
+
return "opaque";
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Flatten form entries into an object, consulting the schema to decide which
|
|
326
|
+
* keys are arrays. `?tag=a&tag=b` is `['a','b']` only where the schema says so;
|
|
327
|
+
* everywhere else a repeated key keeps its last value, as a form post would.
|
|
328
|
+
*/
|
|
329
|
+
function formEntriesToObject(entries, schema, resolve) {
|
|
330
|
+
const collected = /* @__PURE__ */ new Map();
|
|
331
|
+
for (const [key, value] of entries) {
|
|
332
|
+
const existing = collected.get(key);
|
|
333
|
+
if (existing) existing.push(value);
|
|
334
|
+
else collected.set(key, [value]);
|
|
335
|
+
}
|
|
336
|
+
const properties = schema?.properties;
|
|
337
|
+
const out = {};
|
|
338
|
+
for (const [key, values] of collected) {
|
|
339
|
+
const declared = properties?.[key];
|
|
340
|
+
out[key] = schemaKind(declared === void 0 ? void 0 : resolve(declared)) === "array" ? values : values[values.length - 1];
|
|
341
|
+
}
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Read and parse the request body against an operation's Request Body Object.
|
|
346
|
+
*
|
|
347
|
+
* `absent` means no body was sent — whether that is an error is the caller's
|
|
348
|
+
* call, since it depends on the Request Body Object's `required`.
|
|
349
|
+
*/
|
|
350
|
+
async function readBody(req, contents, resolve) {
|
|
351
|
+
if (req.body === null) return { outcome: "absent" };
|
|
352
|
+
const essence = essenceOf(req.headers.get("content-type"));
|
|
353
|
+
if (essence === void 0) return await req.text() === "" ? { outcome: "absent" } : {
|
|
354
|
+
outcome: "unsupported",
|
|
355
|
+
essence
|
|
356
|
+
};
|
|
357
|
+
const content = matchMediaType(contents, essence);
|
|
358
|
+
if (content === void 0) return {
|
|
359
|
+
outcome: "unsupported",
|
|
360
|
+
essence
|
|
361
|
+
};
|
|
362
|
+
const form = bodyFormFor(essence);
|
|
363
|
+
const schema = content.resolved;
|
|
364
|
+
if (form === "opaque") return {
|
|
365
|
+
outcome: "read",
|
|
366
|
+
content,
|
|
367
|
+
value: void 0,
|
|
368
|
+
validatable: false
|
|
369
|
+
};
|
|
370
|
+
if (form === "multipart") {
|
|
371
|
+
let parsed;
|
|
372
|
+
try {
|
|
373
|
+
parsed = await req.formData();
|
|
374
|
+
} catch {
|
|
375
|
+
return {
|
|
376
|
+
outcome: "malformed",
|
|
377
|
+
message: "body is not valid multipart/form-data",
|
|
378
|
+
content
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
outcome: "read",
|
|
383
|
+
content,
|
|
384
|
+
value: formEntriesToObject(parsed, schema, resolve),
|
|
385
|
+
validatable: false
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const raw = await req.text();
|
|
389
|
+
if (raw === "") return { outcome: "absent" };
|
|
390
|
+
if (form === "json") try {
|
|
391
|
+
return {
|
|
392
|
+
outcome: "read",
|
|
393
|
+
content,
|
|
394
|
+
value: JSON.parse(raw),
|
|
395
|
+
validatable: true
|
|
396
|
+
};
|
|
397
|
+
} catch {
|
|
398
|
+
return {
|
|
399
|
+
outcome: "malformed",
|
|
400
|
+
message: "body is not valid JSON",
|
|
401
|
+
content
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
if (form === "urlencoded") return {
|
|
405
|
+
outcome: "read",
|
|
406
|
+
content,
|
|
407
|
+
value: coerceToSchema(formEntriesToObject(new URLSearchParams(raw), schema, resolve), schema, resolve),
|
|
408
|
+
validatable: true
|
|
409
|
+
};
|
|
410
|
+
return {
|
|
411
|
+
outcome: "read",
|
|
412
|
+
content,
|
|
413
|
+
value: raw,
|
|
414
|
+
validatable: true
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
//#endregion
|
|
419
|
+
//#region src/document.ts
|
|
420
|
+
/** The HTTP methods an OpenAPI Path Item Object may declare an operation for. */
|
|
421
|
+
const HTTP_METHODS = [
|
|
422
|
+
"get",
|
|
423
|
+
"put",
|
|
424
|
+
"post",
|
|
425
|
+
"delete",
|
|
426
|
+
"options",
|
|
427
|
+
"head",
|
|
428
|
+
"patch",
|
|
429
|
+
"trace"
|
|
430
|
+
];
|
|
431
|
+
const METHOD_SET = new Set(HTTP_METHODS);
|
|
432
|
+
/** Narrow an arbitrary lowercase method string to an {@link HttpMethod}. */
|
|
433
|
+
function isHttpMethod(value) {
|
|
434
|
+
return METHOD_SET.has(value);
|
|
435
|
+
}
|
|
436
|
+
/** True when a node is a Reference Object rather than the thing it points at. */
|
|
437
|
+
function isRef(node) {
|
|
438
|
+
return typeof node === "object" && node !== null && typeof node.$ref === "string";
|
|
439
|
+
}
|
|
440
|
+
function unescapePointerToken(token) {
|
|
441
|
+
return token.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
442
|
+
}
|
|
443
|
+
function pointerGet(document, ref) {
|
|
444
|
+
if (!ref.startsWith("#/") && ref !== "#") throw new Error(`withOpenApi: only local $refs are supported, got ${JSON.stringify(ref)}. Bundle external references into the document before passing it in.`);
|
|
445
|
+
if (ref === "#") return document;
|
|
446
|
+
let node = document;
|
|
447
|
+
for (const raw of ref.slice(2).split("/")) {
|
|
448
|
+
const token = unescapePointerToken(decodeURIComponent(raw));
|
|
449
|
+
if (typeof node !== "object" || node === null) return void 0;
|
|
450
|
+
node = node[token];
|
|
451
|
+
}
|
|
452
|
+
return node;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Follow a `$ref` chain to the node it names.
|
|
456
|
+
*
|
|
457
|
+
* Structural nodes only — Path Items, Operations, Parameters, Request Bodies.
|
|
458
|
+
* Schemas are deliberately **not** resolved before validation: the validator
|
|
459
|
+
* resolves `$ref` itself against the whole document, which is what lets a
|
|
460
|
+
* recursive schema work. {@link resolveSchema} is the exception, and exists
|
|
461
|
+
* only so coercion can see a schema's `type`.
|
|
462
|
+
*/
|
|
463
|
+
function resolveRef(document, node, seen = /* @__PURE__ */ new Set()) {
|
|
464
|
+
let current = node;
|
|
465
|
+
while (isRef(current)) {
|
|
466
|
+
const ref = current.$ref;
|
|
467
|
+
if (seen.has(ref)) throw new Error(`withOpenApi: circular $ref chain at ${ref}`);
|
|
468
|
+
seen.add(ref);
|
|
469
|
+
const target = pointerGet(document, ref);
|
|
470
|
+
if (target === void 0) throw new Error(`withOpenApi: $ref ${JSON.stringify(ref)} does not resolve`);
|
|
471
|
+
current = target;
|
|
472
|
+
}
|
|
473
|
+
return current;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Resolve a schema's `$ref` chain far enough to read its `type`, `items` and
|
|
477
|
+
* `properties`. Used only to decide how to deserialize and coerce a parameter;
|
|
478
|
+
* the *unresolved* schema is what gets handed to the validator.
|
|
479
|
+
*
|
|
480
|
+
* Returns `undefined` rather than throwing on a dangling ref, because a schema
|
|
481
|
+
* the validator can still report on is more useful than a crash.
|
|
482
|
+
*/
|
|
483
|
+
function resolveSchema(document, schema) {
|
|
484
|
+
if (schema === void 0) return void 0;
|
|
485
|
+
try {
|
|
486
|
+
return resolveRef(document, schema);
|
|
487
|
+
} catch {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const PARAM_PATTERN = /^\{(.+)\}$/;
|
|
492
|
+
function parseTemplate(template) {
|
|
493
|
+
if (!template.startsWith("/")) throw new Error(`withOpenApi: path template ${JSON.stringify(template)} must start with "/"`);
|
|
494
|
+
return normalizePathname(template).slice(1).split("/").map((raw) => {
|
|
495
|
+
const match = PARAM_PATTERN.exec(raw);
|
|
496
|
+
return match?.[1] ? {
|
|
497
|
+
kind: "param",
|
|
498
|
+
name: match[1]
|
|
499
|
+
} : {
|
|
500
|
+
kind: "static",
|
|
501
|
+
value: raw
|
|
502
|
+
};
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Drop a single trailing slash so `/users` and `/users/` are the same route.
|
|
507
|
+
* Applied to both templates and request pathnames, so the two always agree.
|
|
508
|
+
*/
|
|
509
|
+
function normalizePathname(pathname) {
|
|
510
|
+
return pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
|
511
|
+
}
|
|
512
|
+
/** The shape a template reduces to when parameter *names* are ignored. */
|
|
513
|
+
function templateShape(segments) {
|
|
514
|
+
return segments.map((s) => s.kind === "static" ? s.value : "{}").join("/");
|
|
515
|
+
}
|
|
516
|
+
function defaultStyleFor(location) {
|
|
517
|
+
switch (location) {
|
|
518
|
+
case "path":
|
|
519
|
+
case "header": return "simple";
|
|
520
|
+
default: return "form";
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function indexParameter(document, param) {
|
|
524
|
+
const style = param.style ?? defaultStyleFor(param.in);
|
|
525
|
+
return {
|
|
526
|
+
name: param.name,
|
|
527
|
+
in: param.in,
|
|
528
|
+
description: param.description,
|
|
529
|
+
required: param.in === "path" ? true : param.required ?? false,
|
|
530
|
+
style,
|
|
531
|
+
explode: param.explode ?? (style === "form" || style === "deepObject"),
|
|
532
|
+
allowEmptyValue: param.allowEmptyValue ?? false,
|
|
533
|
+
schema: param.schema,
|
|
534
|
+
resolved: resolveSchema(document, param.schema)
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
function mergeParameters(document, pathLevel, operationLevel) {
|
|
538
|
+
const byIdentity = /* @__PURE__ */ new Map();
|
|
539
|
+
for (const list of [pathLevel ?? [], operationLevel ?? []]) for (const entry of list) {
|
|
540
|
+
const param = resolveRef(document, entry);
|
|
541
|
+
byIdentity.set(`${param.in}:${param.name}`, indexParameter(document, param));
|
|
542
|
+
}
|
|
543
|
+
const grouped = {
|
|
544
|
+
path: [],
|
|
545
|
+
query: [],
|
|
546
|
+
header: [],
|
|
547
|
+
cookie: []
|
|
548
|
+
};
|
|
549
|
+
for (const param of byIdentity.values()) grouped[param.in].push(param);
|
|
550
|
+
return grouped;
|
|
551
|
+
}
|
|
552
|
+
function indexRequestBody(document, node) {
|
|
553
|
+
if (node === void 0) return void 0;
|
|
554
|
+
const body = resolveRef(document, node);
|
|
555
|
+
const contents = Object.entries(body.content ?? {}).map(([mediaType, media]) => ({
|
|
556
|
+
mediaType: mediaType.toLowerCase(),
|
|
557
|
+
schema: media?.schema,
|
|
558
|
+
resolved: resolveSchema(document, media?.schema)
|
|
559
|
+
}));
|
|
560
|
+
return {
|
|
561
|
+
required: body.required ?? false,
|
|
562
|
+
description: body.description,
|
|
563
|
+
contents
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
function queryConsumption(params) {
|
|
567
|
+
const names = /* @__PURE__ */ new Set();
|
|
568
|
+
const prefixes = [];
|
|
569
|
+
for (const param of params) {
|
|
570
|
+
if (param.style === "deepObject") {
|
|
571
|
+
prefixes.push(`${param.name}[`);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
const properties = param.resolved?.properties;
|
|
575
|
+
if (param.style === "form" && param.explode && properties) {
|
|
576
|
+
for (const key of Object.keys(properties)) names.add(key);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
names.add(param.name);
|
|
580
|
+
}
|
|
581
|
+
return {
|
|
582
|
+
names,
|
|
583
|
+
prefixes
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Read a document into a flat list of routes, outermost work done once.
|
|
588
|
+
*
|
|
589
|
+
* Throws on anything that cannot be made sense of — a dangling `$ref`, a path
|
|
590
|
+
* template that does not start with `/`, or two templates that differ only in
|
|
591
|
+
* the *names* of their parameters (`/a/{id}` and `/a/{key}` are one path, and
|
|
592
|
+
* the spec forbids declaring both).
|
|
593
|
+
*/
|
|
594
|
+
function indexDocument(document) {
|
|
595
|
+
const routes = [];
|
|
596
|
+
const shapes = /* @__PURE__ */ new Map();
|
|
597
|
+
for (const [template, node] of Object.entries(document.paths ?? {})) {
|
|
598
|
+
if (node === void 0 || node === null) continue;
|
|
599
|
+
const pathItem = resolveRef(document, node);
|
|
600
|
+
const segments = parseTemplate(template);
|
|
601
|
+
const shape = templateShape(segments);
|
|
602
|
+
const clash = shapes.get(shape);
|
|
603
|
+
if (clash !== void 0) throw new Error(`withOpenApi: path templates ${JSON.stringify(clash)} and ${JSON.stringify(template)} are the same path — they differ only in parameter names.`);
|
|
604
|
+
shapes.set(shape, template);
|
|
605
|
+
const declared = new Set(segments.flatMap((s) => s.kind === "param" ? [s.name] : []));
|
|
606
|
+
const operations = /* @__PURE__ */ new Map();
|
|
607
|
+
for (const method of HTTP_METHODS) {
|
|
608
|
+
const operation = pathItem[method];
|
|
609
|
+
if (operation === void 0) continue;
|
|
610
|
+
const parameters = mergeParameters(document, pathItem.parameters, operation.parameters);
|
|
611
|
+
for (const param of parameters.path) if (!declared.has(param.name)) throw new Error(`withOpenApi: ${method.toUpperCase()} ${template} declares a path parameter "${param.name}" that the template does not contain.`);
|
|
612
|
+
const { names, prefixes } = queryConsumption(parameters.query);
|
|
613
|
+
operations.set(method, {
|
|
614
|
+
method,
|
|
615
|
+
route: template,
|
|
616
|
+
operation,
|
|
617
|
+
operationId: operation.operationId,
|
|
618
|
+
security: operation.security ?? document.security,
|
|
619
|
+
parameters,
|
|
620
|
+
requestBody: indexRequestBody(document, operation.requestBody),
|
|
621
|
+
knownQueryNames: names,
|
|
622
|
+
knownQueryPrefixes: prefixes
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
routes.push({
|
|
626
|
+
template,
|
|
627
|
+
segments,
|
|
628
|
+
operations,
|
|
629
|
+
allow: [...operations.keys()].map((m) => m.toUpperCase())
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
return routes;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
//#endregion
|
|
636
|
+
//#region src/cors.ts
|
|
637
|
+
/**
|
|
638
|
+
* Response headers a browser exposes without being asked, so naming them in
|
|
639
|
+
* `Access-Control-Expose-Headers` is noise.
|
|
640
|
+
*
|
|
641
|
+
* @see https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name
|
|
642
|
+
*/
|
|
643
|
+
const SAFELISTED_RESPONSE_HEADERS = new Set([
|
|
644
|
+
"cache-control",
|
|
645
|
+
"content-language",
|
|
646
|
+
"content-length",
|
|
647
|
+
"content-type",
|
|
648
|
+
"expires",
|
|
649
|
+
"last-modified",
|
|
650
|
+
"pragma"
|
|
651
|
+
]);
|
|
652
|
+
/** Header names are case-insensitive; keep the first spelling seen. */
|
|
653
|
+
var HeaderNames = class {
|
|
654
|
+
#byLower = /* @__PURE__ */ new Map();
|
|
655
|
+
add(name) {
|
|
656
|
+
const lower = name.toLowerCase();
|
|
657
|
+
if (!this.#byLower.has(lower)) this.#byLower.set(lower, name);
|
|
658
|
+
}
|
|
659
|
+
has(name) {
|
|
660
|
+
return this.#byLower.has(name.toLowerCase());
|
|
661
|
+
}
|
|
662
|
+
join() {
|
|
663
|
+
return [...this.#byLower.values()].join(", ");
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
/**
|
|
667
|
+
* The request header a security scheme travels in, or `undefined` when it
|
|
668
|
+
* travels somewhere CORS does not care about (a cookie, a query parameter).
|
|
669
|
+
*/
|
|
670
|
+
function securityHeaderName(scheme) {
|
|
671
|
+
switch (scheme.type) {
|
|
672
|
+
case "apiKey": return scheme.in === "header" ? scheme.name : void 0;
|
|
673
|
+
case "http":
|
|
674
|
+
case "oauth2":
|
|
675
|
+
case "openIdConnect": return "Authorization";
|
|
676
|
+
default: return;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
function deriveRouteCors(document, route, schemeHeaders, options) {
|
|
680
|
+
const allowed = new HeaderNames();
|
|
681
|
+
const exposed = new HeaderNames();
|
|
682
|
+
for (const operation of route.operations.values()) {
|
|
683
|
+
for (const param of operation.parameters.header) allowed.add(param.name);
|
|
684
|
+
if (operation.requestBody !== void 0) allowed.add("Content-Type");
|
|
685
|
+
for (const requirement of operation.security ?? []) for (const schemeName of Object.keys(requirement)) {
|
|
686
|
+
const header = schemeHeaders.get(schemeName);
|
|
687
|
+
if (header !== void 0) allowed.add(header);
|
|
688
|
+
}
|
|
689
|
+
for (const response of Object.values(operation.operation.responses ?? {})) {
|
|
690
|
+
const resolved = resolveRef(document, response);
|
|
691
|
+
for (const name of Object.keys(resolved.headers ?? {})) if (!SAFELISTED_RESPONSE_HEADERS.has(name.toLowerCase())) exposed.add(name);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
for (const name of options.allowedHeaders ?? []) allowed.add(name);
|
|
695
|
+
for (const name of options.exposedHeaders ?? []) exposed.add(name);
|
|
696
|
+
return {
|
|
697
|
+
methods: route.allow.join(", "),
|
|
698
|
+
allowedHeaders: allowed.join(),
|
|
699
|
+
exposedHeaders: exposed.join()
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
function resolveAllowOrigin(options, requestOrigin) {
|
|
703
|
+
const { origin } = options;
|
|
704
|
+
if (typeof origin === "function") return origin(requestOrigin) ? requestOrigin ?? "*" : null;
|
|
705
|
+
if (origin === "*") return options.credentials === true ? requestOrigin : "*";
|
|
706
|
+
return requestOrigin !== null && (typeof origin === "string" ? [origin] : origin).includes(requestOrigin) ? requestOrigin : null;
|
|
707
|
+
}
|
|
708
|
+
function applyCommonHeaders(headers, options, allowOrigin) {
|
|
709
|
+
if (allowOrigin === null) return;
|
|
710
|
+
headers.set("Access-Control-Allow-Origin", allowOrigin);
|
|
711
|
+
if (options.credentials === true) headers.set("Access-Control-Allow-Credentials", "true");
|
|
712
|
+
if (allowOrigin !== "*" && !varyIncludesOrigin(headers)) headers.append("Vary", "Origin");
|
|
713
|
+
}
|
|
714
|
+
function varyIncludesOrigin(headers) {
|
|
715
|
+
const vary = headers.get("Vary");
|
|
716
|
+
return vary !== null && vary.split(",").some((token) => token.trim().toLowerCase() === "origin");
|
|
717
|
+
}
|
|
718
|
+
/** Derive a CORS policy from the document, once. */
|
|
719
|
+
function createCorsPolicy(document, routes, options) {
|
|
720
|
+
const schemeHeaders = /* @__PURE__ */ new Map();
|
|
721
|
+
for (const [name, node] of Object.entries(document.components?.securitySchemes ?? {})) {
|
|
722
|
+
const header = securityHeaderName(resolveRef(document, node));
|
|
723
|
+
if (header !== void 0) schemeHeaders.set(name, header);
|
|
724
|
+
}
|
|
725
|
+
const byRoute = /* @__PURE__ */ new Map();
|
|
726
|
+
for (const route of routes) byRoute.set(route, deriveRouteCors(document, route, schemeHeaders, options));
|
|
727
|
+
const successStatus = options.optionsSuccessStatus ?? 204;
|
|
728
|
+
return {
|
|
729
|
+
isPreflight: (req) => req.method === "OPTIONS" && req.headers.has("Access-Control-Request-Method"),
|
|
730
|
+
preflight(req, route) {
|
|
731
|
+
const derived = byRoute.get(route);
|
|
732
|
+
const headers = new Headers();
|
|
733
|
+
applyCommonHeaders(headers, options, resolveAllowOrigin(options, req.headers.get("Origin")));
|
|
734
|
+
if (derived !== void 0) {
|
|
735
|
+
if (derived.methods !== "") headers.set("Access-Control-Allow-Methods", derived.methods);
|
|
736
|
+
if (derived.allowedHeaders !== "") headers.set("Access-Control-Allow-Headers", derived.allowedHeaders);
|
|
737
|
+
}
|
|
738
|
+
if (options.maxAge !== void 0) headers.set("Access-Control-Max-Age", String(options.maxAge));
|
|
739
|
+
headers.append("Vary", "Access-Control-Request-Method");
|
|
740
|
+
return new Response(null, {
|
|
741
|
+
status: successStatus,
|
|
742
|
+
headers
|
|
743
|
+
});
|
|
744
|
+
},
|
|
745
|
+
stamp(response, req, route) {
|
|
746
|
+
const headers = new Headers(response.headers);
|
|
747
|
+
applyCommonHeaders(headers, options, resolveAllowOrigin(options, req.headers.get("Origin")));
|
|
748
|
+
const exposed = route === void 0 ? void 0 : byRoute.get(route)?.exposedHeaders;
|
|
749
|
+
if (exposed !== void 0 && exposed !== "") headers.set("Access-Control-Expose-Headers", exposed);
|
|
750
|
+
return new Response(response.body, {
|
|
751
|
+
status: response.status,
|
|
752
|
+
statusText: response.statusText,
|
|
753
|
+
headers
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
//#endregion
|
|
760
|
+
//#region src/router.ts
|
|
761
|
+
/**
|
|
762
|
+
* Matching a request pathname against the document's path templates.
|
|
763
|
+
*
|
|
764
|
+
* Routes are bucketed by segment count and, within a bucket, ordered by
|
|
765
|
+
* specificity: reading left to right, a static segment beats a templated one.
|
|
766
|
+
* That is the ordering OpenAPI asks for — `/pets/mine` wins over `/pets/{id}`
|
|
767
|
+
* — and doing it once at construction keeps matching to a linear scan of a
|
|
768
|
+
* bucket that is usually one or two entries deep.
|
|
769
|
+
*/
|
|
770
|
+
function moreSpecific(a, b) {
|
|
771
|
+
const length = Math.min(a.segments.length, b.segments.length);
|
|
772
|
+
for (let i = 0; i < length; i++) {
|
|
773
|
+
const left = a.segments[i]?.kind === "static";
|
|
774
|
+
if (left !== (b.segments[i]?.kind === "static")) return left ? -1 : 1;
|
|
775
|
+
}
|
|
776
|
+
return a.template < b.template ? -1 : a.template > b.template ? 1 : 0;
|
|
777
|
+
}
|
|
778
|
+
function decodeSegment(raw) {
|
|
779
|
+
try {
|
|
780
|
+
return decodeURIComponent(raw);
|
|
781
|
+
} catch {
|
|
782
|
+
return raw;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
function matchSegments(segments, actual) {
|
|
786
|
+
const pathValues = {};
|
|
787
|
+
for (let i = 0; i < segments.length; i++) {
|
|
788
|
+
const segment = segments[i];
|
|
789
|
+
const value = actual[i];
|
|
790
|
+
if (segment === void 0 || value === void 0) return void 0;
|
|
791
|
+
if (segment.kind === "static") {
|
|
792
|
+
if (segment.value !== value) return void 0;
|
|
793
|
+
} else {
|
|
794
|
+
if (value === "") return void 0;
|
|
795
|
+
pathValues[segment.name] = decodeSegment(value);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return pathValues;
|
|
799
|
+
}
|
|
800
|
+
/** Build a matcher over an indexed document. */
|
|
801
|
+
function createRouter(routes) {
|
|
802
|
+
const byLength = /* @__PURE__ */ new Map();
|
|
803
|
+
for (const route of routes) {
|
|
804
|
+
const bucket = byLength.get(route.segments.length);
|
|
805
|
+
if (bucket) bucket.push(route);
|
|
806
|
+
else byLength.set(route.segments.length, [route]);
|
|
807
|
+
}
|
|
808
|
+
for (const bucket of byLength.values()) bucket.sort(moreSpecific);
|
|
809
|
+
return { match(pathname) {
|
|
810
|
+
const actual = normalizePathname(pathname).slice(1).split("/");
|
|
811
|
+
const bucket = byLength.get(actual.length);
|
|
812
|
+
if (bucket === void 0) return void 0;
|
|
813
|
+
for (const route of bucket) {
|
|
814
|
+
const pathValues = matchSegments(route.segments, actual);
|
|
815
|
+
if (pathValues !== void 0) return {
|
|
816
|
+
route,
|
|
817
|
+
pathValues
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
} };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
//#endregion
|
|
824
|
+
//#region src/reference.ts
|
|
825
|
+
/** Default CDN for Scalar's standalone browser build. */
|
|
826
|
+
const SCALAR_CDN_URL = "https://cdn.jsdelivr.net/npm/@scalar/api-reference";
|
|
827
|
+
const HTML_ESCAPES = {
|
|
828
|
+
"&": "&",
|
|
829
|
+
"<": "<",
|
|
830
|
+
">": ">",
|
|
831
|
+
"\"": """
|
|
832
|
+
};
|
|
833
|
+
function escapeHtml(value) {
|
|
834
|
+
return value.replace(/[&<>"]/g, (char) => HTML_ESCAPES[char] ?? char);
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Serialize for embedding inside a `<script>` body. Escaping `<` is what stops
|
|
838
|
+
* a `<\/script>` sequence anywhere in the config from ending the element early —
|
|
839
|
+
* the standard XSS hole in inline JSON.
|
|
840
|
+
*/
|
|
841
|
+
function jsonForScript(value) {
|
|
842
|
+
return JSON.stringify(value).replace(/</g, "\\u003c");
|
|
843
|
+
}
|
|
844
|
+
function renderScalarHtml(input) {
|
|
845
|
+
return `<!doctype html>
|
|
846
|
+
<html>
|
|
847
|
+
<head>
|
|
848
|
+
<title>${escapeHtml(input.title)}</title>
|
|
849
|
+
<meta charset="utf-8" />
|
|
850
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
851
|
+
<style>
|
|
852
|
+
body {
|
|
853
|
+
margin: 0;
|
|
854
|
+
}
|
|
855
|
+
</style>
|
|
856
|
+
</head>
|
|
857
|
+
<body>
|
|
858
|
+
<div id="app"></div>
|
|
859
|
+
<script src="${escapeHtml(input.cdnUrl)}"><\/script>
|
|
860
|
+
<script>
|
|
861
|
+
Scalar.createApiReference('#app', ${jsonForScript(input.configuration)})
|
|
862
|
+
<\/script>
|
|
863
|
+
</body>
|
|
864
|
+
</html>
|
|
865
|
+
`;
|
|
866
|
+
}
|
|
867
|
+
function joinPath(base, child) {
|
|
868
|
+
return `${base.endsWith("/") ? base.slice(0, -1) : base}/${child}`;
|
|
869
|
+
}
|
|
870
|
+
/** Fill in the reference endpoint's defaults, once, at construction. */
|
|
871
|
+
function resolveReference(document, options) {
|
|
872
|
+
const path = options.path ?? "/reference";
|
|
873
|
+
if (!path.startsWith("/")) throw new Error(`withOpenApi: reference.path must start with "/", got ${JSON.stringify(path)}`);
|
|
874
|
+
const documentPath = options.documentPath ?? joinPath(path, "openapi.json");
|
|
875
|
+
const title = options.title ?? document.info?.title ?? "API Reference";
|
|
876
|
+
const cdnUrl = options.cdnUrl ?? SCALAR_CDN_URL;
|
|
877
|
+
const configuration = {
|
|
878
|
+
url: documentPath,
|
|
879
|
+
...options.configuration
|
|
880
|
+
};
|
|
881
|
+
const page = (options.html ?? renderScalarHtml)({
|
|
882
|
+
documentPath,
|
|
883
|
+
title,
|
|
884
|
+
cdnUrl,
|
|
885
|
+
configuration
|
|
886
|
+
});
|
|
887
|
+
return {
|
|
888
|
+
path,
|
|
889
|
+
documentPath,
|
|
890
|
+
cacheControl: options.cacheControl ?? "no-cache",
|
|
891
|
+
render: () => page
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
//#endregion
|
|
896
|
+
//#region src/schema.ts
|
|
897
|
+
/**
|
|
898
|
+
* JSON Schema validation against the document, on every runtime this package
|
|
899
|
+
* targets.
|
|
900
|
+
*
|
|
901
|
+
* OpenAPI 3.1 schemas *are* JSON Schema 2020-12, so no translation layer is
|
|
902
|
+
* needed — but the validator has to be one that works where `eval` and `new
|
|
903
|
+
* Function` do not, which rules out the code-generating validators. `@cfworker
|
|
904
|
+
* /json-schema` interprets rather than compiles, has no dependencies, and is
|
|
905
|
+
* built for exactly this constraint.
|
|
906
|
+
*
|
|
907
|
+
* The one thing worth understanding here is the `lookup`. `dereference` walks
|
|
908
|
+
* a document once and returns a map from absolute URI to node; `validate`
|
|
909
|
+
* takes that map and resolves `$ref` through it. Because the map is a plain
|
|
910
|
+
* argument, the whole document can be walked **once** at construction and every
|
|
911
|
+
* subschema in it validated against the shared map afterwards. That is what
|
|
912
|
+
* makes `#/components/schemas/…` resolve from a parameter schema buried in a
|
|
913
|
+
* path item, recursive schemas included, without inlining anything.
|
|
914
|
+
*/
|
|
915
|
+
/**
|
|
916
|
+
* Keywords whose only job is to say that something nested failed. The specific
|
|
917
|
+
* errors always follow them, so repeating "a subschema had errors" above every
|
|
918
|
+
* one of them just pads the response.
|
|
919
|
+
*/
|
|
920
|
+
const CONTAINER_KEYWORDS = new Set([
|
|
921
|
+
"$ref",
|
|
922
|
+
"$recursiveRef",
|
|
923
|
+
"properties",
|
|
924
|
+
"patternProperties",
|
|
925
|
+
"additionalProperties",
|
|
926
|
+
"unevaluatedProperties",
|
|
927
|
+
"items",
|
|
928
|
+
"prefixItems",
|
|
929
|
+
"additionalItems",
|
|
930
|
+
"unevaluatedItems",
|
|
931
|
+
"allOf",
|
|
932
|
+
"then",
|
|
933
|
+
"else",
|
|
934
|
+
"dependentSchemas",
|
|
935
|
+
"contains"
|
|
936
|
+
]);
|
|
937
|
+
/**
|
|
938
|
+
* Reduce the validator's output to the errors worth showing a caller.
|
|
939
|
+
*
|
|
940
|
+
* Drops the container keywords above, then de-duplicates. The last step is a
|
|
941
|
+
* workaround: with `shortCircuit: false`, `additionalProperties` is re-applied
|
|
942
|
+
* to any property `properties` already *rejected* — the implementation only
|
|
943
|
+
* marks a property evaluated when it passes — so an `additionalProperties:
|
|
944
|
+
* false` schema reports a second, misleading "not allowed here" against a
|
|
945
|
+
* property it does in fact declare. It never changes `valid`, only the error
|
|
946
|
+
* list.
|
|
947
|
+
*
|
|
948
|
+
* Undoing it: drop a boolean-`false` error at a path that some *other* error
|
|
949
|
+
* already accounts for, at that path or below it. A `false` schema never
|
|
950
|
+
* recurses, so it can never be the reason for a deeper error — if there is
|
|
951
|
+
* one, the `false` is the re-check and not the real complaint.
|
|
952
|
+
*/
|
|
953
|
+
function normalizeErrors(units) {
|
|
954
|
+
const kept = [];
|
|
955
|
+
const seen = /* @__PURE__ */ new Set();
|
|
956
|
+
const explained = [];
|
|
957
|
+
for (const unit of units) {
|
|
958
|
+
if (CONTAINER_KEYWORDS.has(unit.keyword)) continue;
|
|
959
|
+
const id = `${unit.instanceLocation}\u0000${unit.keyword}`;
|
|
960
|
+
if (seen.has(id)) continue;
|
|
961
|
+
seen.add(id);
|
|
962
|
+
kept.push(unit);
|
|
963
|
+
if (unit.keyword !== "false") explained.push(unit.instanceLocation);
|
|
964
|
+
}
|
|
965
|
+
return kept.filter((unit) => unit.keyword !== "false" || !explained.some((at) => at === unit.instanceLocation || at.startsWith(`${unit.instanceLocation}/`)));
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Walk the document once and return a validator over it.
|
|
969
|
+
*
|
|
970
|
+
* `dereference` records each node's absolute URI on the node itself, as
|
|
971
|
+
* non-enumerable properties (`__absolute_uri__`, `__absolute_ref__`). That
|
|
972
|
+
* mutates the document object the consumer passed in, but invisibly:
|
|
973
|
+
* non-enumerable properties do not appear in `Object.keys`, spreads, or
|
|
974
|
+
* `JSON.stringify`, so the document served at the reference endpoint is
|
|
975
|
+
* byte-for-byte the one that came in.
|
|
976
|
+
*/
|
|
977
|
+
function createSchemaValidator(document, draft) {
|
|
978
|
+
let lookup;
|
|
979
|
+
try {
|
|
980
|
+
lookup = dereference(document);
|
|
981
|
+
} catch (cause) {
|
|
982
|
+
throw new Error(`withOpenApi: could not index the document for validation — ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
983
|
+
}
|
|
984
|
+
addPointerAliases(lookup, document);
|
|
985
|
+
return (value, schema) => {
|
|
986
|
+
const result = validate(value, schema, draft, lookup, false);
|
|
987
|
+
return result.valid ? [] : normalizeErrors(result.errors);
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Register every node under its bare JSON pointer as well as its absolute URI.
|
|
992
|
+
*
|
|
993
|
+
* `dereference` stamps each node it walks with the absolute URI its `$ref`
|
|
994
|
+
* resolves to, and `validate` prefers that stamp. But the walk only descends
|
|
995
|
+
* through *JSON Schema* keywords, and an OpenAPI `parameters` list is a plain
|
|
996
|
+
* array under a key it does not recognise — so a parameter whose schema is
|
|
997
|
+
* `{ $ref: '#/components/schemas/Foo' }` is never stamped, and resolving it
|
|
998
|
+
* falls back to looking up the raw `'#/components/schemas/Foo'`, which is not
|
|
999
|
+
* a key the lookup has.
|
|
1000
|
+
*
|
|
1001
|
+
* Aliasing each `<root>#<pointer>` key to its bare `<pointer>` closes that,
|
|
1002
|
+
* for parameters and for anything else the walk does not reach.
|
|
1003
|
+
*/
|
|
1004
|
+
function addPointerAliases(lookup, document) {
|
|
1005
|
+
const root = document.__absolute_uri__;
|
|
1006
|
+
if (root === void 0) return;
|
|
1007
|
+
for (const [uri, schema] of Object.entries(lookup)) {
|
|
1008
|
+
if (uri === root) {
|
|
1009
|
+
lookup["#"] ??= schema;
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
if (!uri.startsWith(`${root}#`)) continue;
|
|
1013
|
+
const pointer = uri.slice(root.length);
|
|
1014
|
+
lookup[pointer] ??= schema;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* The `description` of the schema the failing keyword belongs to.
|
|
1019
|
+
*
|
|
1020
|
+
* A validator says what is mechanically wrong — `"-3 is less than 0."` — while
|
|
1021
|
+
* the document says what the field is *for*. The second is the half a caller
|
|
1022
|
+
* can usually act on, and it is already written; this goes and gets it.
|
|
1023
|
+
*
|
|
1024
|
+
* `keywordLocation` is a JSON pointer into the schema in which `$ref` appears
|
|
1025
|
+
* as a literal segment, meaning "the validator followed the reference here",
|
|
1026
|
+
* so walking it means resolving those hops as they come. The final segment is
|
|
1027
|
+
* the keyword itself, so the node before it is the schema that failed.
|
|
1028
|
+
*
|
|
1029
|
+
* Never throws. A description is a nicety, and a walk that does not land is
|
|
1030
|
+
* simply one violation without one.
|
|
1031
|
+
*/
|
|
1032
|
+
function describeFailure(document, rootSchema, unit) {
|
|
1033
|
+
const node = schemaAt(document, rootSchema, unit.keywordLocation);
|
|
1034
|
+
if (node === void 0) return void 0;
|
|
1035
|
+
if (unit.keyword === "required") {
|
|
1036
|
+
const missing = /required property "([^"]+)"/.exec(unit.error)?.[1];
|
|
1037
|
+
const property = missing === void 0 ? void 0 : node.properties?.[missing];
|
|
1038
|
+
if (property !== void 0 && typeof property !== "boolean") {
|
|
1039
|
+
const described = descriptionOf(follow(document, property));
|
|
1040
|
+
if (described !== void 0) return described;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return descriptionOf(node);
|
|
1044
|
+
}
|
|
1045
|
+
/** Walk a schema-side JSON pointer to the schema holding the failing keyword. */
|
|
1046
|
+
function schemaAt(document, rootSchema, keywordLocation) {
|
|
1047
|
+
if (!keywordLocation.startsWith("#")) return void 0;
|
|
1048
|
+
const segments = keywordLocation.slice(1).split("/").filter((part) => part !== "");
|
|
1049
|
+
segments.pop();
|
|
1050
|
+
let node = rootSchema;
|
|
1051
|
+
for (const raw of segments) {
|
|
1052
|
+
if (typeof node !== "object" || node === null) return void 0;
|
|
1053
|
+
if (raw === "$ref") {
|
|
1054
|
+
node = follow(document, node);
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
const key = decodeURIComponent(raw).replace(/~1/g, "/").replace(/~0/g, "~");
|
|
1058
|
+
node = node[key];
|
|
1059
|
+
}
|
|
1060
|
+
return follow(document, node);
|
|
1061
|
+
}
|
|
1062
|
+
function descriptionOf(schema) {
|
|
1063
|
+
const description = schema?.description;
|
|
1064
|
+
return typeof description === "string" && description !== "" ? description : void 0;
|
|
1065
|
+
}
|
|
1066
|
+
function follow(document, node) {
|
|
1067
|
+
if (typeof node !== "object" || node === null) return void 0;
|
|
1068
|
+
try {
|
|
1069
|
+
return resolveRef(document, node);
|
|
1070
|
+
} catch {
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Pick the schema draft matching the document's `openapi` version.
|
|
1076
|
+
*
|
|
1077
|
+
* 3.1 aligned with JSON Schema 2020-12; 3.0 used a bespoke subset closer to
|
|
1078
|
+
* draft 4. Neither the `nullable` keyword nor 3.0's other divergences are
|
|
1079
|
+
* translated, so a 3.0 document is best converted to 3.1 before it gets here.
|
|
1080
|
+
*/
|
|
1081
|
+
function draftFor(document) {
|
|
1082
|
+
return document.openapi?.startsWith("3.0") ? "4" : "2020-12";
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
//#endregion
|
|
1086
|
+
//#region src/with-openapi.ts
|
|
1087
|
+
/**
|
|
1088
|
+
* `withOpenApi` — match every request against an OpenAPI document, optionally
|
|
1089
|
+
* refuse the ones it does not describe, and optionally serve a Scalar
|
|
1090
|
+
* reference for it.
|
|
1091
|
+
*
|
|
1092
|
+
* The whole document is read at construction. A request then costs a segment
|
|
1093
|
+
* walk, a map lookup, and whatever validation was asked for.
|
|
1094
|
+
*/
|
|
1095
|
+
const PARAMETER_LOCATIONS = [
|
|
1096
|
+
"path",
|
|
1097
|
+
"query",
|
|
1098
|
+
"header",
|
|
1099
|
+
"cookie"
|
|
1100
|
+
];
|
|
1101
|
+
const EMPTY_PARAMS = {
|
|
1102
|
+
path: {},
|
|
1103
|
+
query: {},
|
|
1104
|
+
header: {},
|
|
1105
|
+
cookie: {}
|
|
1106
|
+
};
|
|
1107
|
+
const REJECTION_MESSAGES = {
|
|
1108
|
+
route_not_found: "no operation in the API description matches this path",
|
|
1109
|
+
method_not_allowed: "this path does not accept this method",
|
|
1110
|
+
unsupported_media_type: "this operation does not accept this content type",
|
|
1111
|
+
validation_failed: "the request does not match the API description"
|
|
1112
|
+
};
|
|
1113
|
+
function resolveValidateOptions(validate) {
|
|
1114
|
+
if (validate === false) return void 0;
|
|
1115
|
+
const given = validate === void 0 || validate === true ? {} : validate;
|
|
1116
|
+
return {
|
|
1117
|
+
path: given.path ?? true,
|
|
1118
|
+
query: given.query ?? true,
|
|
1119
|
+
header: given.header ?? true,
|
|
1120
|
+
cookie: given.cookie ?? true,
|
|
1121
|
+
body: given.body ?? true,
|
|
1122
|
+
additionalQuery: given.additionalQuery ?? "allow",
|
|
1123
|
+
status: given.status ?? 400,
|
|
1124
|
+
describe: given.describe ?? true,
|
|
1125
|
+
maxViolations: given.maxViolations ?? 20
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
function normalizeBasePath(basePath) {
|
|
1129
|
+
if (basePath === void 0 || basePath === "" || basePath === "/") return void 0;
|
|
1130
|
+
if (!basePath.startsWith("/")) throw new Error(`withOpenApi: basePath must start with "/", got ${JSON.stringify(basePath)}`);
|
|
1131
|
+
return basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
|
|
1132
|
+
}
|
|
1133
|
+
/** `undefined` when the pathname is outside the mount point entirely. */
|
|
1134
|
+
function stripBasePath(pathname, basePath) {
|
|
1135
|
+
if (basePath === void 0) return pathname;
|
|
1136
|
+
if (pathname === basePath) return "/";
|
|
1137
|
+
return pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : void 0;
|
|
1138
|
+
}
|
|
1139
|
+
function toViolation(location, name, unit, description) {
|
|
1140
|
+
return {
|
|
1141
|
+
in: location,
|
|
1142
|
+
...name === void 0 ? {} : { name },
|
|
1143
|
+
location: unit.instanceLocation,
|
|
1144
|
+
keyword: unit.keyword,
|
|
1145
|
+
...description === void 0 ? {} : { description },
|
|
1146
|
+
message: unit.keyword === "false" ? "this value is not allowed here" : unit.error
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
function defaultRejectionResponse(rejection) {
|
|
1150
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
1151
|
+
if (rejection.allow !== void 0 && rejection.allow.length > 0) headers.set("allow", rejection.allow.join(", "));
|
|
1152
|
+
return Response.json({
|
|
1153
|
+
error: rejection.kind,
|
|
1154
|
+
message: REJECTION_MESSAGES[rejection.kind],
|
|
1155
|
+
...rejection.accepts === void 0 ? {} : { accepts: rejection.accepts },
|
|
1156
|
+
violations: rejection.violations
|
|
1157
|
+
}, {
|
|
1158
|
+
status: rejection.status,
|
|
1159
|
+
headers
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
function unmatched(document, reason, method, route) {
|
|
1163
|
+
return { openapi: {
|
|
1164
|
+
matched: false,
|
|
1165
|
+
reason,
|
|
1166
|
+
document,
|
|
1167
|
+
route,
|
|
1168
|
+
method,
|
|
1169
|
+
operation: void 0,
|
|
1170
|
+
operationId: void 0,
|
|
1171
|
+
security: void 0,
|
|
1172
|
+
params: EMPTY_PARAMS,
|
|
1173
|
+
body: void 0,
|
|
1174
|
+
mediaType: void 0,
|
|
1175
|
+
validated: false
|
|
1176
|
+
} };
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Read every declared parameter off the request, coercing and checking as
|
|
1180
|
+
* configured. Mutates `params` and `violations` rather than allocating four
|
|
1181
|
+
* intermediate records per request.
|
|
1182
|
+
*/
|
|
1183
|
+
function collectParameters(document, operation, sources, options, coerce, resolve, validateSchema, params, violations) {
|
|
1184
|
+
const describe = options?.describe === true;
|
|
1185
|
+
for (const location of PARAMETER_LOCATIONS) {
|
|
1186
|
+
const checking = options !== void 0 && options[location];
|
|
1187
|
+
for (const param of operation.parameters[location]) {
|
|
1188
|
+
const read = readParameter(param, sources);
|
|
1189
|
+
if (!read.present) {
|
|
1190
|
+
if (checking && param.required) violations.push({
|
|
1191
|
+
in: location,
|
|
1192
|
+
name: param.name,
|
|
1193
|
+
message: `required ${location} parameter "${param.name}" is missing`,
|
|
1194
|
+
...describe && param.description !== void 0 ? { description: param.description } : {}
|
|
1195
|
+
});
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
const value = coerce ? coerceToSchema(read.value, param.resolved, resolve) : read.value;
|
|
1199
|
+
params[location][param.name] = value;
|
|
1200
|
+
if (checking && param.schema !== void 0 && validateSchema !== void 0) for (const unit of validateSchema(value, param.schema)) violations.push(toViolation(location, param.name, unit, describe ? param.description ?? describeFailure(document, param.schema, unit) : void 0));
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Middleware that holds an API to its own description.
|
|
1206
|
+
*
|
|
1207
|
+
* @example Reject anything the document does not describe, and serve the docs.
|
|
1208
|
+
* ```ts
|
|
1209
|
+
* import { pipeline } from '@supabase/middleware'
|
|
1210
|
+
* import { withOpenApi } from '@croutonian/with-openapi'
|
|
1211
|
+
* import document from './openapi.json' with { type: 'json' }
|
|
1212
|
+
*
|
|
1213
|
+
* export default {
|
|
1214
|
+
* fetch: pipeline(
|
|
1215
|
+
* [withOpenApi({ document, reference: true })],
|
|
1216
|
+
* async (_req, ctx) => {
|
|
1217
|
+
* if (!ctx.openapi.matched) return new Response(null, { status: 404 })
|
|
1218
|
+
* return Response.json({ op: ctx.openapi.operationId })
|
|
1219
|
+
* },
|
|
1220
|
+
* ),
|
|
1221
|
+
* }
|
|
1222
|
+
* ```
|
|
1223
|
+
*
|
|
1224
|
+
* @example Describe and document, but do not enforce.
|
|
1225
|
+
* ```ts
|
|
1226
|
+
* withOpenApi({
|
|
1227
|
+
* document,
|
|
1228
|
+
* validate: false,
|
|
1229
|
+
* onUnknownRoute: 'pass',
|
|
1230
|
+
* reference: { path: '/docs' },
|
|
1231
|
+
* })
|
|
1232
|
+
* ```
|
|
1233
|
+
*
|
|
1234
|
+
* @category Middleware
|
|
1235
|
+
*/
|
|
1236
|
+
const withOpenApi = defineMiddleware({
|
|
1237
|
+
key: "openapi",
|
|
1238
|
+
run: (config) => {
|
|
1239
|
+
const { document } = config;
|
|
1240
|
+
const routes = indexDocument(document);
|
|
1241
|
+
const router = createRouter(routes);
|
|
1242
|
+
const options = resolveValidateOptions(config.validate);
|
|
1243
|
+
const coerce = config.coerce ?? true;
|
|
1244
|
+
const basePath = normalizeBasePath(config.basePath);
|
|
1245
|
+
const onUnknownRoute = config.onUnknownRoute ?? "reject";
|
|
1246
|
+
const onUnknownMethod = config.onUnknownMethod ?? "reject";
|
|
1247
|
+
const resolve = (schema) => resolveSchema(document, schema);
|
|
1248
|
+
const validateSchema = options === void 0 ? void 0 : createSchemaValidator(document, config.schemaDraft ?? draftFor(document));
|
|
1249
|
+
const reference = config.reference === void 0 || config.reference === false ? void 0 : resolveReference(document, config.reference === true ? {} : config.reference);
|
|
1250
|
+
const documentJson = reference === void 0 ? void 0 : JSON.stringify(document);
|
|
1251
|
+
const cors = config.cors === void 0 ? void 0 : createCorsPolicy(document, routes, config.cors);
|
|
1252
|
+
/**
|
|
1253
|
+
* One request, taken to the point where it either has an answer of its own
|
|
1254
|
+
* or a contribution to hand downstream.
|
|
1255
|
+
*
|
|
1256
|
+
* The matched route is reported alongside, because the CORS response phase
|
|
1257
|
+
* derives `Access-Control-Expose-Headers` from it and threading it out
|
|
1258
|
+
* here beats reaching for a side channel keyed on the request.
|
|
1259
|
+
*/
|
|
1260
|
+
const handle = async (req) => {
|
|
1261
|
+
const respond = async (rejection) => ({
|
|
1262
|
+
result: await config.reject?.(rejection, req) ?? defaultRejectionResponse(rejection),
|
|
1263
|
+
route: void 0
|
|
1264
|
+
});
|
|
1265
|
+
if (config.skip?.(req) === true) return {
|
|
1266
|
+
result: unmatched(document, "skipped", req.method, void 0),
|
|
1267
|
+
route: void 0
|
|
1268
|
+
};
|
|
1269
|
+
const url = new URL(req.url);
|
|
1270
|
+
const method = req.method.toLowerCase();
|
|
1271
|
+
if (reference !== void 0 && (method === "get" || method === "head")) {
|
|
1272
|
+
const head = method === "head";
|
|
1273
|
+
if (url.pathname === reference.path) return {
|
|
1274
|
+
result: new Response(head ? null : reference.render(), { headers: {
|
|
1275
|
+
"content-type": "text/html; charset=utf-8",
|
|
1276
|
+
"cache-control": reference.cacheControl
|
|
1277
|
+
} }),
|
|
1278
|
+
route: void 0
|
|
1279
|
+
};
|
|
1280
|
+
if (url.pathname === reference.documentPath) return {
|
|
1281
|
+
result: new Response(head ? null : documentJson, { headers: {
|
|
1282
|
+
"content-type": "application/json; charset=utf-8",
|
|
1283
|
+
"cache-control": reference.cacheControl
|
|
1284
|
+
} }),
|
|
1285
|
+
route: void 0
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
const pathname = stripBasePath(url.pathname, basePath);
|
|
1289
|
+
const match = pathname === void 0 ? void 0 : router.match(pathname);
|
|
1290
|
+
if (match === void 0) {
|
|
1291
|
+
if (onUnknownRoute === "pass") return {
|
|
1292
|
+
result: unmatched(document, "no_route", req.method, void 0),
|
|
1293
|
+
route: void 0
|
|
1294
|
+
};
|
|
1295
|
+
return respond({
|
|
1296
|
+
kind: "route_not_found",
|
|
1297
|
+
status: 404,
|
|
1298
|
+
method: req.method,
|
|
1299
|
+
pathname: url.pathname,
|
|
1300
|
+
violations: []
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
if (cors !== void 0 && cors.isPreflight(req)) return {
|
|
1304
|
+
result: cors.preflight(req, match.route),
|
|
1305
|
+
route: match.route
|
|
1306
|
+
};
|
|
1307
|
+
const operation = isHttpMethod(method) ? match.route.operations.get(method) : void 0;
|
|
1308
|
+
if (operation === void 0) {
|
|
1309
|
+
if (onUnknownMethod === "pass") return {
|
|
1310
|
+
result: unmatched(document, "no_operation", req.method, match.route.template),
|
|
1311
|
+
route: match.route
|
|
1312
|
+
};
|
|
1313
|
+
return respond({
|
|
1314
|
+
kind: "method_not_allowed",
|
|
1315
|
+
status: 405,
|
|
1316
|
+
method: req.method,
|
|
1317
|
+
pathname: url.pathname,
|
|
1318
|
+
route: match.route.template,
|
|
1319
|
+
allow: match.route.allow,
|
|
1320
|
+
violations: []
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
const violations = [];
|
|
1324
|
+
const params = {
|
|
1325
|
+
path: {},
|
|
1326
|
+
query: {},
|
|
1327
|
+
header: {},
|
|
1328
|
+
cookie: {}
|
|
1329
|
+
};
|
|
1330
|
+
collectParameters(document, operation, {
|
|
1331
|
+
pathValues: match.pathValues,
|
|
1332
|
+
search: url.searchParams,
|
|
1333
|
+
headers: req.headers,
|
|
1334
|
+
cookies: operation.parameters.cookie.length > 0 ? parseCookies(req.headers.get("cookie")) : {}
|
|
1335
|
+
}, options, coerce, resolve, validateSchema, params, violations);
|
|
1336
|
+
if (options?.additionalQuery === "reject") for (const name of new Set(url.searchParams.keys())) {
|
|
1337
|
+
if (operation.knownQueryNames.has(name)) continue;
|
|
1338
|
+
if (operation.knownQueryPrefixes.some((prefix) => name.startsWith(prefix))) continue;
|
|
1339
|
+
violations.push({
|
|
1340
|
+
in: "query",
|
|
1341
|
+
name,
|
|
1342
|
+
message: `query parameter "${name}" is not declared by this operation`
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
let body;
|
|
1346
|
+
let mediaType;
|
|
1347
|
+
const requestBody = operation.requestBody;
|
|
1348
|
+
if (requestBody !== void 0 && options?.body === true) {
|
|
1349
|
+
const read = await readBody(req, requestBody.contents, resolve);
|
|
1350
|
+
if (read.outcome === "unsupported") return respond({
|
|
1351
|
+
kind: "unsupported_media_type",
|
|
1352
|
+
status: 415,
|
|
1353
|
+
method: req.method,
|
|
1354
|
+
pathname: url.pathname,
|
|
1355
|
+
route: operation.route,
|
|
1356
|
+
accepts: requestBody.contents.map((entry) => entry.mediaType),
|
|
1357
|
+
violations: [{
|
|
1358
|
+
in: "body",
|
|
1359
|
+
message: read.essence === void 0 ? "a content-type header is required for a request with a body" : `content type "${read.essence}" is not accepted by this operation`
|
|
1360
|
+
}]
|
|
1361
|
+
});
|
|
1362
|
+
if (read.outcome === "absent") {
|
|
1363
|
+
if (requestBody.required) violations.push({
|
|
1364
|
+
in: "body",
|
|
1365
|
+
message: "a request body is required",
|
|
1366
|
+
...options?.describe === true && requestBody.description !== void 0 ? { description: requestBody.description } : {}
|
|
1367
|
+
});
|
|
1368
|
+
} else if (read.outcome === "malformed") {
|
|
1369
|
+
mediaType = read.content.mediaType;
|
|
1370
|
+
violations.push({
|
|
1371
|
+
in: "body",
|
|
1372
|
+
message: read.message
|
|
1373
|
+
});
|
|
1374
|
+
} else {
|
|
1375
|
+
mediaType = read.content.mediaType;
|
|
1376
|
+
body = read.value;
|
|
1377
|
+
if (read.validatable && read.content.schema !== void 0 && validateSchema) {
|
|
1378
|
+
const schema = read.content.schema;
|
|
1379
|
+
for (const unit of validateSchema(read.value, schema)) violations.push(toViolation("body", void 0, unit, options?.describe === true ? describeFailure(document, schema, unit) : void 0));
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
if (violations.length > 0) return respond({
|
|
1384
|
+
kind: "validation_failed",
|
|
1385
|
+
status: options?.status ?? 400,
|
|
1386
|
+
method: req.method,
|
|
1387
|
+
pathname: url.pathname,
|
|
1388
|
+
route: operation.route,
|
|
1389
|
+
violations: violations.slice(0, options?.maxViolations ?? violations.length)
|
|
1390
|
+
});
|
|
1391
|
+
return {
|
|
1392
|
+
result: { openapi: {
|
|
1393
|
+
matched: true,
|
|
1394
|
+
document,
|
|
1395
|
+
route: operation.route,
|
|
1396
|
+
method: operation.method,
|
|
1397
|
+
operation: operation.operation,
|
|
1398
|
+
operationId: operation.operationId,
|
|
1399
|
+
security: operation.security,
|
|
1400
|
+
params,
|
|
1401
|
+
body,
|
|
1402
|
+
mediaType,
|
|
1403
|
+
validated: options !== void 0
|
|
1404
|
+
} },
|
|
1405
|
+
route: match.route
|
|
1406
|
+
};
|
|
1407
|
+
};
|
|
1408
|
+
if (cors === void 0) return async (req) => (await handle(req)).result;
|
|
1409
|
+
return async function* (req) {
|
|
1410
|
+
const { result, route } = await handle(req);
|
|
1411
|
+
if (result instanceof Response) return cors.stamp(result, req, route);
|
|
1412
|
+
const response = yield result;
|
|
1413
|
+
return cors.stamp(response, req, route);
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
//#endregion
|
|
1419
|
+
export { SCALAR_CDN_URL, withOpenApi };
|