@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/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
+ "&": "&amp;",
829
+ "<": "&lt;",
830
+ ">": "&gt;",
831
+ "\"": "&quot;"
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 };