@croutonian/with-openapi 0.2.0 → 0.4.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/README.md +225 -42
- package/dist/index.d.ts +335 -34
- package/dist/index.js +44 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
[](https://github.com/croutonian/with-openapi/actions/workflows/ci.yml)
|
|
7
7
|
[](./LICENSE)
|
|
8
8
|
|
|
9
|
-
OpenAPI middleware for
|
|
9
|
+
OpenAPI middleware for
|
|
10
|
+
[`@supabase/middleware`](https://github.com/supabase/middleware).
|
|
10
11
|
|
|
11
12
|
An OpenAPI document already says what your API accepts. This makes it say it at
|
|
12
13
|
runtime: every request is matched to an Operation Object, optionally refused if
|
|
@@ -24,7 +25,8 @@ export default {
|
|
|
24
25
|
[withOpenApi({ document, reference: true })],
|
|
25
26
|
async (_req, ctx) => {
|
|
26
27
|
if (!ctx.openapi.matched) return new Response(null, { status: 404 })
|
|
27
|
-
// Already validated
|
|
28
|
+
// Already validated and coerced, so this holds the number 10 — though
|
|
29
|
+
// its static type is `unknown`; see Parameters.
|
|
28
30
|
const { limit } = ctx.openapi.params.query
|
|
29
31
|
return Response.json({ operation: ctx.openapi.operationId, limit })
|
|
30
32
|
},
|
|
@@ -68,17 +70,35 @@ A discriminated union on `matched`:
|
|
|
68
70
|
```ts
|
|
69
71
|
if (ctx.openapi.matched) {
|
|
70
72
|
ctx.openapi.route // '/users/{id}' — the path template, not the pathname
|
|
71
|
-
ctx.openapi.method // 'get'
|
|
72
73
|
ctx.openapi.operation // the Operation Object, `$ref` already followed
|
|
73
74
|
ctx.openapi.operationId // 'getUser'
|
|
74
75
|
ctx.openapi.security // the operation's, falling back to the document's
|
|
75
76
|
ctx.openapi.params // { path, query, header, cookie }, deserialized + coerced
|
|
76
|
-
ctx.openapi.
|
|
77
|
-
ctx.openapi.mediaType // the `content` key that matched
|
|
78
|
-
ctx.openapi.validated // false when `validate: false`
|
|
77
|
+
ctx.openapi.mediaType // which `content` key matched
|
|
79
78
|
}
|
|
80
79
|
```
|
|
81
80
|
|
|
81
|
+
`params` is `unknown` here. Give the middleware a document whose type survived
|
|
82
|
+
and it narrows to that operation's own parameters — see
|
|
83
|
+
[Typed against your document](#typed-against-your-document).
|
|
84
|
+
|
|
85
|
+
Everything here is something only this middleware knows. What you already have,
|
|
86
|
+
it does not hand back: not the `document` you passed in, not the method
|
|
87
|
+
(`req.method`), not whether you configured `validate: false`, and not the parsed
|
|
88
|
+
body — `req.json()` is one call and reading it here does not consume it.
|
|
89
|
+
|
|
90
|
+
The parsed body is **not** here. Reading it in the middleware does not consume
|
|
91
|
+
it — the framework hands every layer a buffered request — so the handler calls
|
|
92
|
+
`req.json()` and gets the very bytes this middleware validated, without a second
|
|
93
|
+
copy on `ctx` to keep in step with it.
|
|
94
|
+
|
|
95
|
+
`mediaType` is contributed because it is the reverse case: it names which
|
|
96
|
+
`content` key matched, and so which schema ran, and repeating that takes
|
|
97
|
+
resolving a `$ref` on `requestBody` and reimplementing the exact / `type/*` /
|
|
98
|
+
`*/*` precedence. It is **not** the request's content type — a `*/*` range
|
|
99
|
+
matches anything — so a handler deciding how to parse should read the header,
|
|
100
|
+
which is what this middleware does too.
|
|
101
|
+
|
|
82
102
|
With the defaults, the handler only ever sees `matched: true` — anything else
|
|
83
103
|
was already answered with a `404` or a `405`. The narrowing matters once you set
|
|
84
104
|
`onUnknownRoute` or `onUnknownMethod` to `'pass'`, or pass a `skip`; then the
|
|
@@ -129,6 +149,16 @@ callback before that response is built:
|
|
|
129
149
|
| `unsupported_media_type` | 415 | The body's content type is not in the operation's `content`. |
|
|
130
150
|
| `validation_failed` | 400 | A parameter or body failed its schema. |
|
|
131
151
|
|
|
152
|
+
`route_not_found` says which of its two causes it was, because they are the same
|
|
153
|
+
status and very different mistakes — a `basePath` nothing starts with turns
|
|
154
|
+
every route into a 404, and blaming the document sends you looking for a path
|
|
155
|
+
that is already in it:
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
no operation in the API description matches "/nope"
|
|
159
|
+
the pathname "/users/me" is outside basePath "/api/v1"
|
|
160
|
+
```
|
|
161
|
+
|
|
132
162
|
The default body:
|
|
133
163
|
|
|
134
164
|
```json
|
|
@@ -154,10 +184,10 @@ path to the property that failed.
|
|
|
154
184
|
|
|
155
185
|
### Descriptions
|
|
156
186
|
|
|
157
|
-
`message` is the validator's, and says what is mechanically wrong.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
187
|
+
`message` is the validator's, and says what is mechanically wrong. `description`
|
|
188
|
+
is the **document's own prose** for whatever failed, and is usually the half a
|
|
189
|
+
caller can act on. You wrote it once; there is no reason for an error response
|
|
190
|
+
to throw it away.
|
|
161
191
|
|
|
162
192
|
It is resolved from the most specific place that has it:
|
|
163
193
|
|
|
@@ -182,14 +212,14 @@ body Instance does not have required property "name".
|
|
|
182
212
|
That third row is the one worth pointing at: `required` fails against the
|
|
183
213
|
_object_, so the obvious implementation describes the object — "A person with
|
|
184
214
|
access to the workspace" — which says nothing about what is missing. The
|
|
185
|
-
property is named only inside the validator's message, so it is read from
|
|
186
|
-
|
|
215
|
+
property is named only inside the validator's message, so it is read from there,
|
|
216
|
+
and falls back to the container's prose if that wording ever changes.
|
|
187
217
|
|
|
188
218
|
A field with nothing written about it simply has no `description`. Set
|
|
189
|
-
`validate: { describe: false }` to leave them all off — descriptions are
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
219
|
+
`validate: { describe: false }` to leave them all off — descriptions are written
|
|
220
|
+
for a document's consumers, who are the same people reading these errors, but
|
|
221
|
+
turn it off if yours carries notes you would rather not return in a response
|
|
222
|
+
body.
|
|
193
223
|
|
|
194
224
|
To answer in your own error envelope:
|
|
195
225
|
|
|
@@ -213,7 +243,16 @@ handy for customizing one kind and leaving the rest alone.
|
|
|
213
243
|
entry in the document:
|
|
214
244
|
|
|
215
245
|
- `GET /reference` — the HTML page
|
|
216
|
-
- `GET /
|
|
246
|
+
- `GET /openapi.json` — the document, for the page to load
|
|
247
|
+
|
|
248
|
+
Both defaults are derived from `basePath`, so under `basePath: '/api'` they are
|
|
249
|
+
`/api/reference` and `/api/openapi.json`. A reference outside the mount is
|
|
250
|
+
usually unreachable rather than merely unconventional: a host that routes only
|
|
251
|
+
`/api/*` to this handler can never produce a pathname of `/reference`.
|
|
252
|
+
|
|
253
|
+
The document path is derived from the mount, **not** from `path` — the document
|
|
254
|
+
is the artifact and the page is one view of it, so moving the page to `/docs`
|
|
255
|
+
leaves the document where it was.
|
|
217
256
|
|
|
218
257
|
```ts
|
|
219
258
|
withOpenApi({
|
|
@@ -238,14 +277,55 @@ entirely:
|
|
|
238
277
|
|
|
239
278
|
```ts
|
|
240
279
|
reference: {
|
|
241
|
-
html: ({ documentPath }) => myOwnPage(
|
|
280
|
+
html: ({ documentPath, documentUrl }) => myOwnPage(documentUrl)
|
|
242
281
|
}
|
|
243
282
|
```
|
|
244
283
|
|
|
245
|
-
|
|
284
|
+
A path you give explicitly is taken **literally** — `basePath` is not applied to
|
|
285
|
+
it, so an API under `/api/v1` can still put its docs at `/docs`:
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
withOpenApi({ document, basePath: '/api/v1', reference: { path: '/docs' } })
|
|
289
|
+
// -> /docs, not /api/v1/docs
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Only the default is derived from the mount.
|
|
293
|
+
|
|
294
|
+
### Behind a gateway that rewrites the path
|
|
295
|
+
|
|
296
|
+
`documentPath` is matched against the pathname this middleware is handed.
|
|
297
|
+
`documentUrl` is what the page tells the browser to fetch. They default to the
|
|
298
|
+
same string, which is right until something rewrites the path in front of you —
|
|
299
|
+
and then no single value works: one spelling never serves the JSON, the other
|
|
300
|
+
renders a page that loads and immediately reports that it could not load the
|
|
301
|
+
document.
|
|
302
|
+
|
|
303
|
+
Supabase Edge Functions is that case by default. The platform routes on
|
|
304
|
+
`/functions/v1/<fn>/...`, strips `/functions/v1`, and hands the worker
|
|
305
|
+
`/<fn>/...`:
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
withOpenApi({
|
|
309
|
+
document,
|
|
310
|
+
// What the worker sees — not the public URL, and not `servers[0].url`.
|
|
311
|
+
basePath: '/api',
|
|
312
|
+
reference: {
|
|
313
|
+
path: '/api/reference',
|
|
314
|
+
documentPath: '/api/openapi.json', // where this middleware serves it
|
|
315
|
+
documentUrl: '/functions/v1/api/openapi.json', // where a browser fetches it
|
|
316
|
+
},
|
|
317
|
+
})
|
|
318
|
+
```
|
|
246
319
|
|
|
247
320
|
## Parameters
|
|
248
321
|
|
|
322
|
+
Every parameter is typed `unknown`, whatever its schema says. The document is
|
|
323
|
+
data this middleware reads at runtime, and turning a schema into a TypeScript
|
|
324
|
+
type needs code generation, which is not something this package does. So the
|
|
325
|
+
_value_ is coerced and checked — `params.query.limit` holds the number `10` —
|
|
326
|
+
while the _type_ you hover is `unknown`, and narrowing it is yours. Anything
|
|
327
|
+
stronger would be a type that lies when a document changes without a rebuild.
|
|
328
|
+
|
|
249
329
|
`style` and `explode` are honored, so the document decides how a value is
|
|
250
330
|
spelled:
|
|
251
331
|
|
|
@@ -284,18 +364,121 @@ the request says it is:
|
|
|
284
364
|
| `multipart/form-data` | object, with parts left as `File` | no |
|
|
285
365
|
| anything else | not read at all | no |
|
|
286
366
|
|
|
287
|
-
Multipart parts are `File` objects, which no JSON Schema describes, so
|
|
288
|
-
is parsed
|
|
289
|
-
|
|
290
|
-
is
|
|
367
|
+
Multipart parts are `File` objects, which no JSON Schema describes, so a
|
|
368
|
+
multipart body is parsed — enough to reject a malformed one and to enforce
|
|
369
|
+
`required` — but never schema-checked. Binary media types are never buffered:
|
|
370
|
+
there is no shape to check, and reading a large upload to ignore it is pure
|
|
371
|
+
cost.
|
|
372
|
+
|
|
373
|
+
Two consequences of the body not being contributed, both worth knowing: reading
|
|
374
|
+
it here does not consume it, so `req.json()` in the handler returns the same
|
|
375
|
+
value that was validated; and the coercion in the urlencoded row is applied for
|
|
376
|
+
validation and then dropped, so a handler reading that body itself sees the
|
|
377
|
+
original text.
|
|
378
|
+
|
|
379
|
+
## Typed against your document
|
|
380
|
+
|
|
381
|
+
`ctx.openapi.params` is `unknown` by default, because the middleware reads
|
|
382
|
+
whatever document it is handed at runtime. Hand it a document whose type
|
|
383
|
+
survived, though, and it narrows to that document's own operations:
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
import { pipeline } from '@supabase/middleware'
|
|
387
|
+
import { withOpenApi, defineDocument } from '@croutonian/with-openapi'
|
|
388
|
+
|
|
389
|
+
const document = defineDocument({
|
|
390
|
+
openapi: '3.1.0',
|
|
391
|
+
info: { title: 'Acme', version: '1' },
|
|
392
|
+
paths: {
|
|
393
|
+
'/users/{id}': {
|
|
394
|
+
get: {
|
|
395
|
+
operationId: 'getUser',
|
|
396
|
+
parameters: [
|
|
397
|
+
{
|
|
398
|
+
name: 'id',
|
|
399
|
+
in: 'path',
|
|
400
|
+
required: true,
|
|
401
|
+
schema: { type: 'integer' },
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
responses: { '200': { description: 'ok' } },
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
'/users': {
|
|
408
|
+
get: {
|
|
409
|
+
operationId: 'listUsers',
|
|
410
|
+
parameters: [
|
|
411
|
+
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
|
|
412
|
+
],
|
|
413
|
+
responses: { '200': { description: 'ok' } },
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
export default {
|
|
420
|
+
fetch: pipeline([withOpenApi({ document })], async (_req, ctx) => {
|
|
421
|
+
if (!ctx.openapi.matched) return new Response(null, { status: 404 })
|
|
422
|
+
|
|
423
|
+
if (ctx.openapi.operationId === 'getUser') {
|
|
424
|
+
ctx.openapi.params.path.id // number
|
|
425
|
+
}
|
|
426
|
+
if (ctx.openapi.operationId === 'listUsers') {
|
|
427
|
+
ctx.openapi.params.query.limit // number | undefined
|
|
428
|
+
}
|
|
429
|
+
return Response.json({})
|
|
430
|
+
}),
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
`ctx.openapi` becomes one branch per declared operation, discriminated by
|
|
435
|
+
`operationId` (or `route`). Narrowing is all it takes — no cast, no route
|
|
436
|
+
argument, no runtime guard, and the compiler knows when you have handled every
|
|
437
|
+
operation.
|
|
438
|
+
|
|
439
|
+
### The document has to keep its type
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
withOpenApi({ document: { /* literal inline */ } }) // ✅
|
|
443
|
+
const document = defineDocument({ ... }) // ✅ in its own module
|
|
444
|
+
const document = { ... } satisfies OpenAPIObject // ⚠️ see below
|
|
445
|
+
const document: OpenAPIObject = { ... } // ❌ falls back to unknown
|
|
446
|
+
import document from './openapi.json' // ❌ fails to compile
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
`defineDocument` is an identity function whose only job is its `const` type
|
|
450
|
+
parameter. `satisfies` gets close — route names and schemas survive it — but it
|
|
451
|
+
still lets leaf values widen where the target type says `string`, so
|
|
452
|
+
`servers[0].url` comes back as `string` rather than the URL you wrote.
|
|
453
|
+
|
|
454
|
+
An annotation is the one that costs you silently: `paths` widens to an index
|
|
455
|
+
signature, and the contribution **falls back to the unspecialized shape**, so
|
|
456
|
+
everything keeps working and `params` stays `unknown`. That fallback is
|
|
457
|
+
deliberate — it is what makes this additive rather than breaking. A `.json`
|
|
458
|
+
import fails earlier and louder: its values widen too, so `in: string` no
|
|
459
|
+
longer narrows to a `ParameterLocation` and the document fails the constraint.
|
|
460
|
+
|
|
461
|
+
### What the projections cover
|
|
462
|
+
|
|
463
|
+
A deliberate subset of JSON Schema, not an implementation of it — the runtime
|
|
464
|
+
validator remains the authority. Covered: `$ref` into `#/components/schemas`
|
|
465
|
+
and `#/components/parameters`, `const`, `enum`, `string` / `integer` /
|
|
466
|
+
`number` / `boolean` / `null`, arrays via `items`, objects via `properties`
|
|
467
|
+
with `required` driving optionality, and Path Item parameters merged into every
|
|
468
|
+
operation beneath them. `$ref` chains are followed eight levels deep, so a
|
|
469
|
+
recursive schema terminates.
|
|
470
|
+
|
|
471
|
+
Not interpreted: `allOf`, `oneOf`, `anyOf`. Those resolve to `unknown`, which
|
|
472
|
+
is what the untyped path gives you anyway — guessing at them is how a type
|
|
473
|
+
starts disagreeing with the validator that actually runs.
|
|
291
474
|
|
|
292
|
-
|
|
293
|
-
|
|
475
|
+
`RoutesOf`, `MethodsOf`, `OperationIdsOf`, `OperationOf`, `ParamsFor` and
|
|
476
|
+
`FromSchema` are exported for reading the same document yourself.
|
|
294
477
|
|
|
295
478
|
## CORS
|
|
296
479
|
|
|
297
|
-
An OpenAPI document already knows most of a CORS policy. `cors` derives it,
|
|
298
|
-
|
|
480
|
+
An OpenAPI document already knows most of a CORS policy. `cors` derives it, per
|
|
481
|
+
route:
|
|
299
482
|
|
|
300
483
|
```ts
|
|
301
484
|
withOpenApi({
|
|
@@ -323,15 +506,15 @@ comes from.
|
|
|
323
506
|
Three things worth knowing:
|
|
324
507
|
|
|
325
508
|
- **`origin` is required and never derived.** A document says where an API
|
|
326
|
-
lives, not who may call it — `servers` is not an allowlist, and treating it
|
|
327
|
-
|
|
509
|
+
lives, not who may call it — `servers` is not an allowlist, and treating it as
|
|
510
|
+
one would be a security decision made from the wrong data. Same for
|
|
328
511
|
`credentials`.
|
|
329
512
|
- **Rejections are stamped too.** An unstamped `400` reaches a browser as an
|
|
330
513
|
opaque CORS error rather than the violations it is carrying.
|
|
331
514
|
- **The document is the source of truth for headers.** A request header the API
|
|
332
515
|
reads but the document does not declare will be refused by the browser. That
|
|
333
|
-
is usually the document being wrong; `allowedHeaders` is the escape hatch
|
|
334
|
-
|
|
516
|
+
is usually the document being wrong; `allowedHeaders` is the escape hatch when
|
|
517
|
+
it genuinely is not.
|
|
335
518
|
|
|
336
519
|
Preflights are answered after the route match but before the method lookup —
|
|
337
520
|
otherwise the `OPTIONS` no document declares an operation for would come back
|
|
@@ -368,11 +551,11 @@ Worth knowing before you wire this into something:
|
|
|
368
551
|
translating it. A 3.0 document falls back to draft 4, which gets
|
|
369
552
|
`exclusiveMinimum` and `required` right but does **not** translate `nullable`.
|
|
370
553
|
Convert to 3.1 for full fidelity.
|
|
371
|
-
- **Local `$ref`s only.** External and remote references are not fetched.
|
|
372
|
-
|
|
554
|
+
- **Local `$ref`s only.** External and remote references are not fetched. Bundle
|
|
555
|
+
the document first.
|
|
373
556
|
- **Requests only.** Responses are not validated. That is the framework's model,
|
|
374
|
-
not an omission: a middleware runs before the handler, and response shape
|
|
375
|
-
under the handler's ownership.
|
|
557
|
+
not an omission: a middleware runs before the handler, and response shape
|
|
558
|
+
stays under the handler's ownership.
|
|
376
559
|
- **`deepObject` is one level deep**, matching what the specification defines.
|
|
377
560
|
- **Trailing slashes are normalized**, so `/users` and `/users/` are one route.
|
|
378
561
|
- Indexing the document for validation stamps each node with its own absolute
|
|
@@ -391,8 +574,8 @@ Three, and each is load-bearing:
|
|
|
391
574
|
- **`@cfworker/json-schema`** — the validator. Zero dependencies, and it
|
|
392
575
|
_interprets_ schemas rather than compiling them to JavaScript, which is what
|
|
393
576
|
lets it run on Cloudflare Workers and anywhere else `new Function` is
|
|
394
|
-
unavailable. The document is walked once at construction and every subschema
|
|
395
|
-
it validated against that one index, so `$ref` — recursive ones included —
|
|
577
|
+
unavailable. The document is walked once at construction and every subschema
|
|
578
|
+
in it validated against that one index, so `$ref` — recursive ones included —
|
|
396
579
|
resolves without inlining anything.
|
|
397
580
|
- **`@supabase/middleware`** — the composition engine.
|
|
398
581
|
|
|
@@ -451,7 +634,7 @@ What it configures, and why each is needed:
|
|
|
451
634
|
| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
452
635
|
| A **GitHub App** with Contents and Pull requests write, installed on the repo, as `GH_APP_ID` + `GH_APP_PRIVATE_KEY` | release-please has to open a PR, and this org does not let GitHub Actions do that. An App is not GitHub Actions, so the policy does not cover it — and unlike `GITHUB_TOKEN`, its pushes trigger workflows, so the release PR gets CI. |
|
|
453
636
|
| The **pkg.pr.new App** installed on the repo | Branch previews. Without it the preview job warns and skips rather than failing. |
|
|
454
|
-
| An **npm trusted publisher** for `@croutonian/with-openapi
|
|
637
|
+
| An **npm trusted publisher** for `@croutonian/with-openapi`, with direct publish allowed | Publishing without a stored credential. Configurations created after 3 Sep 2026 default to staging only, and `release.yml` runs `npm publish` — stage-only would leave every release waiting in a staging area. |
|
|
455
638
|
| The **JSR package** linked to this repository | Same, on the JSR side. |
|
|
456
639
|
|
|
457
640
|
To do it by hand instead, the same steps are in the comments at the top of
|
|
@@ -465,10 +648,10 @@ resolves that by publishing from your machine — your npm login, your 2FA, no
|
|
|
465
648
|
token created and none stored. CI takes over from the next release.
|
|
466
649
|
|
|
467
650
|
`release.yml` also accepts an `NPM_TOKEN` secret, but it is not a general
|
|
468
|
-
answer: a token cannot answer a one-time password, and npm asks for one on
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
651
|
+
answer: a token cannot answer a one-time password, and npm asks for one on every
|
|
652
|
+
write unless the account's two-factor setting is _Authorization only_. Where 2FA
|
|
653
|
+
covers writes, a CI publish fails with `EOTP` and the first version has to come
|
|
654
|
+
from a human.
|
|
472
655
|
|
|
473
656
|
Between releases, every branch push and pull request publishes an installable
|
|
474
657
|
preview to [pkg.pr.new](https://pkg.pr.new):
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FetchHandler, Middleware } from "@supabase/middleware";
|
|
1
|
+
import { FetchHandler, Middleware, SingleKeyEntry } from "@supabase/middleware";
|
|
2
2
|
import { SchemaDraft } from "@cfworker/json-schema";
|
|
3
3
|
import { OpenAPIObject, OperationObject, ParameterLocation, ParameterStyle, ReferenceObject, SchemaObject, SecurityRequirementObject } from "openapi3-ts/oas31";
|
|
4
4
|
|
|
@@ -124,6 +124,8 @@ declare const SCALAR_CDN_URL = "https://cdn.jsdelivr.net/npm/@scalar/api-referen
|
|
|
124
124
|
interface ScalarHtmlInput {
|
|
125
125
|
/** Absolute path the document JSON is served from. */
|
|
126
126
|
readonly documentPath: string;
|
|
127
|
+
/** URL the page should fetch the document from. */
|
|
128
|
+
readonly documentUrl: string;
|
|
127
129
|
/** Page `<title>`. */
|
|
128
130
|
readonly title: string;
|
|
129
131
|
/** Script URL for Scalar's standalone build. */
|
|
@@ -137,15 +139,44 @@ interface ScalarReferenceOptions {
|
|
|
137
139
|
* Path the HTML page is served from. Matched exactly, and *before* the
|
|
138
140
|
* document's own routes, so it does not need to appear in the document.
|
|
139
141
|
*
|
|
140
|
-
*
|
|
142
|
+
* Given explicitly it is taken literally — `basePath` is **not** applied,
|
|
143
|
+
* so an API under `/api/v1` can still put its docs at `/docs`. The default
|
|
144
|
+
* is derived from `basePath` instead, because a reference sitting outside
|
|
145
|
+
* the mount is usually unreachable rather than merely unconventional: on a
|
|
146
|
+
* host that routes only `/api/*` to this handler, nothing can produce a
|
|
147
|
+
* pathname of `/reference`.
|
|
148
|
+
*
|
|
149
|
+
* @defaultValue `` `${basePath}/reference` ``, or `'/reference'` unmounted
|
|
141
150
|
*/
|
|
142
151
|
path?: string;
|
|
143
152
|
/**
|
|
144
|
-
* Path the document JSON is served from.
|
|
153
|
+
* Path the document JSON is served from. Matched against the pathname this
|
|
154
|
+
* middleware is handed.
|
|
155
|
+
*
|
|
156
|
+
* Derived from `basePath`, not from {@link path}: the document is the
|
|
157
|
+
* artifact and the page is one view of it, so moving the page does not move
|
|
158
|
+
* the document, and the conventional `/openapi.json` is where people —
|
|
159
|
+
* and tooling — look for it.
|
|
145
160
|
*
|
|
146
|
-
* @defaultValue `` `${
|
|
161
|
+
* @defaultValue `` `${basePath}/openapi.json` ``, or `'/openapi.json'`
|
|
147
162
|
*/
|
|
148
163
|
documentPath?: string;
|
|
164
|
+
/**
|
|
165
|
+
* URL the page tells the browser to fetch the document from.
|
|
166
|
+
*
|
|
167
|
+
* Defaults to {@link documentPath}, which is correct whenever the pathname
|
|
168
|
+
* this middleware is handed is the one a browser can reach. Behind a gateway
|
|
169
|
+
* that rewrites the path, it is not, and the two have to be set separately:
|
|
170
|
+
* on Supabase Edge Functions the platform routes on
|
|
171
|
+
* `/functions/v1/<fn>/...` and hands the worker `/<fn>/...`, so the document
|
|
172
|
+
* is *served* at `/api/openapi.json` and *fetched* from
|
|
173
|
+
* `/functions/v1/api/openapi.json`. No single value satisfies both -- set
|
|
174
|
+
* one and the JSON never serves, set the other and the page loads and then
|
|
175
|
+
* reports that it could not load the document.
|
|
176
|
+
*
|
|
177
|
+
* @defaultValue {@link documentPath}
|
|
178
|
+
*/
|
|
179
|
+
documentUrl?: string;
|
|
149
180
|
/** Page title. @defaultValue the document's `info.title`, or `'API Reference'` */
|
|
150
181
|
title?: string;
|
|
151
182
|
/** Script URL for Scalar's standalone build. @defaultValue {@link SCALAR_CDN_URL} */
|
|
@@ -153,7 +184,7 @@ interface ScalarReferenceOptions {
|
|
|
153
184
|
/**
|
|
154
185
|
* Extra options merged into the `Scalar.createApiReference` config — theme,
|
|
155
186
|
* `darkMode`, `proxyUrl`, and anything else Scalar accepts. `url` is set
|
|
156
|
-
* from {@link
|
|
187
|
+
* from {@link documentUrl} and can be overridden here.
|
|
157
188
|
*
|
|
158
189
|
* @see https://scalar.com/products/api-references/configuration
|
|
159
190
|
*/
|
|
@@ -167,7 +198,16 @@ interface ScalarReferenceOptions {
|
|
|
167
198
|
//#region src/types.d.ts
|
|
168
199
|
/** Where a parameter was declared. */
|
|
169
200
|
type ParameterIn = 'path' | 'query' | 'header' | 'cookie';
|
|
170
|
-
/**
|
|
201
|
+
/**
|
|
202
|
+
* Deserialized, coerced parameters, grouped by where they came from.
|
|
203
|
+
*
|
|
204
|
+
* `unknown` rather than the schema's type: the document is read at runtime, and
|
|
205
|
+
* lifting a schema into a TypeScript type needs code generation, which this
|
|
206
|
+
* package does not do. The value is coerced and checked; narrowing the type is
|
|
207
|
+
* the consumer's. Pinned by `N5` in `type-tests/negative.ts`, so widening this
|
|
208
|
+
* to `any` — which would compile and look safer while checking nothing — fails
|
|
209
|
+
* the build.
|
|
210
|
+
*/
|
|
171
211
|
interface OpenApiParams {
|
|
172
212
|
readonly path: Readonly<Record<string, unknown>>;
|
|
173
213
|
readonly query: Readonly<Record<string, unknown>>;
|
|
@@ -208,6 +248,13 @@ interface OpenApiRejection {
|
|
|
208
248
|
readonly method: string;
|
|
209
249
|
/** The request's pathname, before `basePath` is stripped. */
|
|
210
250
|
readonly pathname: string;
|
|
251
|
+
/**
|
|
252
|
+
* A more specific explanation than the kind's stock wording, when there is
|
|
253
|
+
* one to give. `route_not_found` uses it to say whether the pathname missed
|
|
254
|
+
* `basePath` or matched no template under it, which are the same status and
|
|
255
|
+
* very different mistakes.
|
|
256
|
+
*/
|
|
257
|
+
readonly message?: string;
|
|
211
258
|
/** Path template that matched, when one did. */
|
|
212
259
|
readonly route?: string;
|
|
213
260
|
/** Methods the route does declare. Set on `method_not_allowed`. */
|
|
@@ -229,7 +276,7 @@ interface OpenApiValidateOptions {
|
|
|
229
276
|
cookie?: boolean;
|
|
230
277
|
/**
|
|
231
278
|
* Check — and therefore read and parse — the request body. With this off,
|
|
232
|
-
*
|
|
279
|
+
* the body is never read here and reaches the handler unexamined.
|
|
233
280
|
*
|
|
234
281
|
* @defaultValue `true`
|
|
235
282
|
*/
|
|
@@ -316,7 +363,7 @@ interface WithOpenApiConfig {
|
|
|
316
363
|
/**
|
|
317
364
|
* Serve a Scalar API reference and the document JSON. `true` takes every
|
|
318
365
|
* default — the page at `/reference`, the document at
|
|
319
|
-
* `/
|
|
366
|
+
* `/openapi.json`.
|
|
320
367
|
*
|
|
321
368
|
* @defaultValue off
|
|
322
369
|
*/
|
|
@@ -354,54 +401,119 @@ interface WithOpenApiConfig {
|
|
|
354
401
|
*/
|
|
355
402
|
reject?: (rejection: OpenApiRejection, req: Request) => Response | undefined | Promise<Response | undefined>;
|
|
356
403
|
}
|
|
357
|
-
/**
|
|
404
|
+
/**
|
|
405
|
+
* `ctx.openapi` when an operation in the document describes the request.
|
|
406
|
+
*
|
|
407
|
+
* Every field here is something a consumer would otherwise have to derive from
|
|
408
|
+
* the document itself. What the caller already holds — the document, the
|
|
409
|
+
* method, the validate config, the request body — is deliberately absent.
|
|
410
|
+
*/
|
|
358
411
|
interface OpenApiMatched {
|
|
359
412
|
readonly matched: true;
|
|
360
|
-
/**
|
|
361
|
-
|
|
362
|
-
|
|
413
|
+
/**
|
|
414
|
+
* Path template that matched, e.g. `'/users/{id}'`.
|
|
415
|
+
*
|
|
416
|
+
* The label to group a request under. A pathname cannot be used for that —
|
|
417
|
+
* `/users/1` and `/users/2` are separate series — so this is what metrics,
|
|
418
|
+
* traces, rate-limit buckets and audit logs key on.
|
|
419
|
+
*/
|
|
363
420
|
readonly route: string;
|
|
364
|
-
/**
|
|
365
|
-
|
|
366
|
-
|
|
421
|
+
/**
|
|
422
|
+
* The Operation Object, with its own `$ref` (if any) already followed.
|
|
423
|
+
*
|
|
424
|
+
* How a consumer drives behaviour off the document: `x-` extensions
|
|
425
|
+
* (`operation['x-rate-limit']`, `x-required-scope`), `deprecated` to stamp a
|
|
426
|
+
* sunset header, `tags` to attribute a request to the team that owns it.
|
|
427
|
+
*
|
|
428
|
+
* Here rather than left to a `document.paths` lookup because a Path Item can
|
|
429
|
+
* itself be a `$ref`, so that lookup is not reliably an Operation Object.
|
|
430
|
+
*/
|
|
367
431
|
readonly operation: OperationObject;
|
|
432
|
+
/**
|
|
433
|
+
* A name for this operation that survives the path changing, which `route`
|
|
434
|
+
* does not — so it is the stable key for a permission check, a handler
|
|
435
|
+
* dispatch table, or a log field you intend to query next year.
|
|
436
|
+
*
|
|
437
|
+
* Read through from {@link operation}, so it adds no information; it is here
|
|
438
|
+
* because it is the field consumers reach for most.
|
|
439
|
+
*/
|
|
368
440
|
readonly operationId: string | undefined;
|
|
369
441
|
/**
|
|
370
442
|
* Security requirements in force — the operation's, falling back to the
|
|
371
|
-
* document's.
|
|
372
|
-
*
|
|
443
|
+
* document's. Never enforced here; contributed so a downstream auth layer
|
|
444
|
+
* can enforce it without a route table of its own, and tell a public
|
|
445
|
+
* operation (`[]`) from one that needs a credential.
|
|
446
|
+
*
|
|
447
|
+
* The fallback is the reason this is not left to the consumer: reading
|
|
448
|
+
* `operation.security` alone treats a document-secured operation as public,
|
|
449
|
+
* which fails open.
|
|
373
450
|
*/
|
|
374
451
|
readonly security: SecurityRequirementObject[] | undefined;
|
|
375
|
-
/**
|
|
452
|
+
/**
|
|
453
|
+
* Deserialized and (unless turned off) coerced parameters, keyed by
|
|
454
|
+
* location.
|
|
455
|
+
*
|
|
456
|
+
* The one that saves real work: `params.query.limit` holds a number, already
|
|
457
|
+
* checked against its `maximum`, with `style`/`explode` honoured, so
|
|
458
|
+
* `?ids=1,2,3` arrives as an array and `?filter[a]=b` as an object. Without
|
|
459
|
+
* it every handler re-reads `URLSearchParams` and re-coerces by hand.
|
|
460
|
+
*
|
|
461
|
+
* Values only — every entry is typed `unknown`. See {@link OpenApiParams}.
|
|
462
|
+
*/
|
|
376
463
|
readonly params: OpenApiParams;
|
|
377
464
|
/**
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
465
|
+
* Which `content` key matched, and so which Media Type Object's schema the
|
|
466
|
+
* body was checked against.
|
|
467
|
+
*
|
|
468
|
+
* Worth having when an operation declares more than one: it tells a handler
|
|
469
|
+
* which contract it is serving (`application/json` against
|
|
470
|
+
* `application/vnd.acme.v2+json`, say), and tells telemetry which declared
|
|
471
|
+
* variant clients actually send, which is what you need before deprecating
|
|
472
|
+
* one.
|
|
473
|
+
*
|
|
474
|
+
* Not a substitute for the request's own `Content-Type`: a catch-all range
|
|
475
|
+
* matches anything, so this says which *declaration* applied, not what was
|
|
476
|
+
* actually sent. A handler choosing how to parse wants the header — which is
|
|
477
|
+
* what this middleware reads too.
|
|
478
|
+
*
|
|
479
|
+
* Contributed because it is the one decision made here that a consumer
|
|
480
|
+
* cannot cheaply repeat: it takes resolving a `$ref` on `requestBody` and
|
|
481
|
+
* reimplementing the exact / type-wildcard / catch-all precedence, and a
|
|
482
|
+
* reimplementation that drifted would disagree about which schema ran. The
|
|
483
|
+
* parsed body is not contributed for the opposite reason — reading it here
|
|
484
|
+
* does not consume it, so `req.json()` in the handler is one call away and
|
|
485
|
+
* returns the same value this middleware validated.
|
|
381
486
|
*/
|
|
382
|
-
readonly body: unknown;
|
|
383
|
-
/** The `content` key that matched the request's content type. */
|
|
384
487
|
readonly mediaType: string | undefined;
|
|
385
|
-
/** `false` when `validate: false` — the request was matched, not checked. */
|
|
386
|
-
readonly validated: boolean;
|
|
387
488
|
}
|
|
388
|
-
/**
|
|
489
|
+
/**
|
|
490
|
+
* `ctx.openapi` when the document does not describe the request.
|
|
491
|
+
*
|
|
492
|
+
* Only reachable with `onUnknownRoute`/`onUnknownMethod` set to `'pass'`, or a
|
|
493
|
+
* `skip` — which is how you put this middleware in front of an existing API
|
|
494
|
+
* and watch what it *would* have refused before letting it refuse anything.
|
|
495
|
+
*/
|
|
389
496
|
interface OpenApiUnmatched {
|
|
390
497
|
readonly matched: false;
|
|
391
|
-
/**
|
|
498
|
+
/**
|
|
499
|
+
* Which kind of miss it was, so an observer can tell them apart: `no_route`
|
|
500
|
+
* is a path the document does not describe (an undocumented endpoint, or
|
|
501
|
+
* someone probing), `no_operation` is a path it does describe reached with a
|
|
502
|
+
* verb it does not, and `skipped` is the consumer's own `skip` firing. Three
|
|
503
|
+
* different follow-up actions, and nothing else records which happened.
|
|
504
|
+
*/
|
|
392
505
|
readonly reason: 'skipped' | 'no_route' | 'no_operation';
|
|
393
|
-
readonly document: OpenAPIObject;
|
|
394
506
|
/** Set when the path matched but the method was not declared under it. */
|
|
395
507
|
readonly route: string | undefined;
|
|
396
|
-
/** The request's method, as sent. */
|
|
397
|
-
readonly method: string;
|
|
398
508
|
readonly operation: undefined;
|
|
399
509
|
readonly operationId: undefined;
|
|
400
510
|
readonly security: undefined;
|
|
511
|
+
/**
|
|
512
|
+
* Always empty here. Kept uniform across both branches so the field every
|
|
513
|
+
* consumer reaches for does not need a `matched` guard.
|
|
514
|
+
*/
|
|
401
515
|
readonly params: OpenApiParams;
|
|
402
|
-
readonly body: undefined;
|
|
403
516
|
readonly mediaType: undefined;
|
|
404
|
-
readonly validated: false;
|
|
405
517
|
}
|
|
406
518
|
/**
|
|
407
519
|
* What lands at `ctx.openapi`.
|
|
@@ -413,7 +525,172 @@ interface OpenApiUnmatched {
|
|
|
413
525
|
*/
|
|
414
526
|
type OpenApiContribution = OpenApiMatched | OpenApiUnmatched;
|
|
415
527
|
//#endregion
|
|
528
|
+
//#region src/document-types.d.ts
|
|
529
|
+
/**
|
|
530
|
+
* Collapse an intersection of mapped types into one object type.
|
|
531
|
+
*
|
|
532
|
+
* Splitting `properties` by `required` produces `{ a: A } & { b?: B }`, which
|
|
533
|
+
* is assignable both ways but is not the *same* type — so an exact-equality
|
|
534
|
+
* test fails, and hovering it shows the intersection rather than the shape.
|
|
535
|
+
* Both matter here: the point of these projections is what the editor tells
|
|
536
|
+
* you.
|
|
537
|
+
*/
|
|
538
|
+
type Simplify<T> = { [K in keyof T]: T[K] } & {};
|
|
539
|
+
/** How deep `$ref` chains and nested schemas are followed before giving up. */
|
|
540
|
+
type MaxDepth = 8;
|
|
541
|
+
type Deeper<D extends unknown[]> = [...D, unknown];
|
|
542
|
+
type LocalRef<Document, Ref extends string, Bucket extends string> = Ref extends `#/components/${Bucket}/${infer Name}` ? Document extends {
|
|
543
|
+
components: { [K in Bucket]: Record<Name, infer Target> };
|
|
544
|
+
} ? Target : unknown : unknown;
|
|
545
|
+
/**
|
|
546
|
+
* A Schema Object as a TypeScript type.
|
|
547
|
+
*
|
|
548
|
+
* Covers `$ref` into `#/components/schemas`, `const`, `enum`, the primitive
|
|
549
|
+
* types, arrays, and objects with `required` driving optionality. Composition
|
|
550
|
+
* keywords (`allOf`, `oneOf`, `anyOf`) are not interpreted — they resolve to
|
|
551
|
+
* `unknown`, because guessing at them is how a type starts disagreeing with
|
|
552
|
+
* the validator.
|
|
553
|
+
*/
|
|
554
|
+
type FromSchema<Schema, Document, Depth extends unknown[] = []> = Depth['length'] extends MaxDepth ? unknown : Schema extends {
|
|
555
|
+
$ref: infer Ref extends string;
|
|
556
|
+
} ? FromSchema<LocalRef<Document, Ref, 'schemas'>, Document, Deeper<Depth>> : Schema extends {
|
|
557
|
+
const: infer Value;
|
|
558
|
+
} ? Value : Schema extends {
|
|
559
|
+
enum: ReadonlyArray<infer Member>;
|
|
560
|
+
} ? Member : Schema extends {
|
|
561
|
+
type: 'string';
|
|
562
|
+
} ? string : Schema extends {
|
|
563
|
+
type: 'integer' | 'number';
|
|
564
|
+
} ? number : Schema extends {
|
|
565
|
+
type: 'boolean';
|
|
566
|
+
} ? boolean : Schema extends {
|
|
567
|
+
type: 'null';
|
|
568
|
+
} ? null : Schema extends {
|
|
569
|
+
type: 'array';
|
|
570
|
+
} ? FromArraySchema<Schema, Document, Depth> : Schema extends {
|
|
571
|
+
type: 'object';
|
|
572
|
+
} ? FromObjectSchema<Schema, Document, Depth> : unknown;
|
|
573
|
+
type FromArraySchema<Schema, Document, Depth extends unknown[]> = Schema extends {
|
|
574
|
+
items: infer Items;
|
|
575
|
+
} ? Array<FromSchema<Items, Document, Deeper<Depth>>> : unknown[];
|
|
576
|
+
type FromObjectSchema<Schema, Document, Depth extends unknown[]> = Schema extends {
|
|
577
|
+
properties: infer Props;
|
|
578
|
+
} ? Schema extends {
|
|
579
|
+
required: ReadonlyArray<infer Required extends string>;
|
|
580
|
+
} ? Optionalize<Props, Required, Document, Depth> : Optionalize<Props, never, Document, Depth> : Record<string, unknown>;
|
|
581
|
+
/** Split `properties` by whether `required` names them. */
|
|
582
|
+
type Optionalize<Props, Required extends string, Document, Depth extends unknown[]> = Simplify<{ [K in keyof Props as K extends Required ? K : never]: FromSchema<Props[K], Document, Deeper<Depth>> } & { [K in keyof Props as K extends Required ? never : K]?: FromSchema<Props[K], Document, Deeper<Depth>> }>;
|
|
583
|
+
/** The path templates the document declares. */
|
|
584
|
+
type RoutesOf<Document> = Document extends {
|
|
585
|
+
paths: infer Paths;
|
|
586
|
+
} ? Extract<keyof Paths, string> : never;
|
|
587
|
+
type PathItemOf<Document, Route extends string> = Document extends {
|
|
588
|
+
paths: Record<Route, infer Item>;
|
|
589
|
+
} ? Item : never;
|
|
590
|
+
/** HTTP methods the document declares under `Route`. */
|
|
591
|
+
type MethodsOf<Document, Route extends string> = Extract<keyof PathItemOf<Document, Route>, 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch' | 'trace'>;
|
|
592
|
+
/** The Operation Object at `Route` + `Method`. */
|
|
593
|
+
type OperationOf<Document, Route extends string, Method extends string> = PathItemOf<Document, Route> extends Record<Method, infer Operation> ? Operation : never;
|
|
594
|
+
/** Every `operationId` the document declares, as a union. */
|
|
595
|
+
type OperationIdsOf<Document> = Document extends {
|
|
596
|
+
paths: infer Paths;
|
|
597
|
+
} ? { [R in keyof Paths]: { [M in keyof Paths[R]]: Paths[R][M] extends {
|
|
598
|
+
operationId: infer Id;
|
|
599
|
+
} ? Id : never }[keyof Paths[R]] }[keyof Paths] : never;
|
|
600
|
+
/**
|
|
601
|
+
* Parameter Objects in force for an operation: the Path Item's, then the
|
|
602
|
+
* operation's own, with `$ref`s into `#/components/parameters` followed.
|
|
603
|
+
*/
|
|
604
|
+
type ParametersOf<Document, Route extends string, Method extends string> = ResolveParameter<Document, PathItemOf<Document, Route> extends {
|
|
605
|
+
parameters: ReadonlyArray<infer Parameter>;
|
|
606
|
+
} ? Parameter : never> | ResolveParameter<Document, OperationOf<Document, Route, Method> extends {
|
|
607
|
+
parameters: ReadonlyArray<infer Parameter>;
|
|
608
|
+
} ? Parameter : never>;
|
|
609
|
+
type ResolveParameter<Document, Parameter> = Parameter extends {
|
|
610
|
+
$ref: infer Ref extends string;
|
|
611
|
+
} ? LocalRef<Document, Ref, 'parameters'> : Parameter;
|
|
612
|
+
type SchemaOfParameter<Document, Parameter> = Parameter extends {
|
|
613
|
+
schema: infer Schema;
|
|
614
|
+
} ? FromSchema<Schema, Document> : unknown;
|
|
615
|
+
/** Parameters declared `in: Location`, keyed by name, optional unless required. */
|
|
616
|
+
type ParametersIn<Document, Route extends string, Method extends string, Location extends string> = Extract<ParametersOf<Document, Route, Method>, {
|
|
617
|
+
name: string;
|
|
618
|
+
in: Location;
|
|
619
|
+
}> extends infer Declared ? Simplify<{ [P in Extract<Declared, {
|
|
620
|
+
name: string;
|
|
621
|
+
}> as P extends {
|
|
622
|
+
required: true;
|
|
623
|
+
} ? P['name'] : never]: SchemaOfParameter<Document, P> } & { [P in Extract<Declared, {
|
|
624
|
+
name: string;
|
|
625
|
+
}> as P extends {
|
|
626
|
+
required: true;
|
|
627
|
+
} ? never : P['name']]?: SchemaOfParameter<Document, P> }> : never;
|
|
628
|
+
/**
|
|
629
|
+
* `ctx.openapi.params` for one operation, with each value typed by its schema
|
|
630
|
+
* instead of `unknown`.
|
|
631
|
+
*/
|
|
632
|
+
type ParamsFor<Document, Route extends string, Method extends string> = {
|
|
633
|
+
readonly path: ParametersIn<Document, Route, Method, 'path'>;
|
|
634
|
+
readonly query: ParametersIn<Document, Route, Method, 'query'>;
|
|
635
|
+
readonly header: ParametersIn<Document, Route, Method, 'header'>;
|
|
636
|
+
readonly cookie: ParametersIn<Document, Route, Method, 'cookie'>;
|
|
637
|
+
};
|
|
638
|
+
/**
|
|
639
|
+
* The `matched` branch for one operation, with `route`, `operation` and
|
|
640
|
+
* `params` specialized to it.
|
|
641
|
+
*
|
|
642
|
+
* `security` and `mediaType` keep their general types: they are not what the
|
|
643
|
+
* narrowing is for, and pinning them would add conditional depth for no gain.
|
|
644
|
+
*/
|
|
645
|
+
type MatchedOperation<Document, Route extends string, Method extends string> = {
|
|
646
|
+
readonly matched: true;
|
|
647
|
+
readonly route: Route;
|
|
648
|
+
readonly operation: OperationOf<Document, Route, Method>;
|
|
649
|
+
readonly operationId: OperationOf<Document, Route, Method> extends {
|
|
650
|
+
operationId: infer Id;
|
|
651
|
+
} ? Id : undefined;
|
|
652
|
+
readonly security: OpenApiMatched['security'];
|
|
653
|
+
readonly params: ParamsFor<Document, Route, Method>;
|
|
654
|
+
readonly mediaType: OpenApiMatched['mediaType'];
|
|
655
|
+
};
|
|
656
|
+
/**
|
|
657
|
+
* Every operation the document declares, as a union — one branch each, so
|
|
658
|
+
* narrowing on `operationId` (or `route`) inside a handler reaches that
|
|
659
|
+
* operation's own parameter types with no cast.
|
|
660
|
+
*/
|
|
661
|
+
type MatchedFor<Document> = { [Route in RoutesOf<Document>]: { [Method in MethodsOf<Document, Route>]: MatchedOperation<Document, Route, Method> }[MethodsOf<Document, Route>] }[RoutesOf<Document>];
|
|
662
|
+
/**
|
|
663
|
+
* What lands at `ctx.openapi` for a given document.
|
|
664
|
+
*
|
|
665
|
+
* Falls back to the unspecialized {@link OpenApiContribution} when the
|
|
666
|
+
* document arrived without its literal type — annotated `: OpenAPIObject`,
|
|
667
|
+
* say. `RoutesOf` is `never` there, which would otherwise leave a union with
|
|
668
|
+
* no `matched: true` branch at all and break every existing consumer.
|
|
669
|
+
* Degrading to today's shape is what keeps this change additive.
|
|
670
|
+
*/
|
|
671
|
+
type ContributionFor<Document> = [RoutesOf<Document>] extends [never] ? OpenApiContribution : MatchedFor<Document> | OpenApiUnmatched;
|
|
672
|
+
//#endregion
|
|
416
673
|
//#region src/with-openapi.d.ts
|
|
674
|
+
/**
|
|
675
|
+
* The document-aware call signature, intersected *ahead* of the one
|
|
676
|
+
* `defineMiddleware` produces so it is tried first.
|
|
677
|
+
*
|
|
678
|
+
* Only the config-only form is specialized — the form that goes in a
|
|
679
|
+
* `pipeline` array, which is how this is mounted. `pipeline` then carries the
|
|
680
|
+
* document-specific contribution through to the handler on its own. The two
|
|
681
|
+
* handler-taking forms fall through to the general signatures below it, where
|
|
682
|
+
* `ctx.openapi` keeps its unspecialized shape.
|
|
683
|
+
*
|
|
684
|
+
* The runtime is untouched: this re-describes what `defineMiddleware` already
|
|
685
|
+
* returns. That makes the description an assertion we own — if `ParamsFor`
|
|
686
|
+
* ever disagrees with what the middleware actually deserializes, the types
|
|
687
|
+
* are what lie, and nothing here would catch it.
|
|
688
|
+
*/
|
|
689
|
+
interface TypedByDocument {
|
|
690
|
+
<const Document extends OpenAPIObject>(config: Omit<WithOpenApiConfig, 'document'> & {
|
|
691
|
+
document: Document;
|
|
692
|
+
}): SingleKeyEntry<'openapi', Record<never, never>, ContributionFor<Document>>;
|
|
693
|
+
}
|
|
417
694
|
/**
|
|
418
695
|
* Middleware that holds an API to its own description.
|
|
419
696
|
*
|
|
@@ -446,6 +723,30 @@ type OpenApiContribution = OpenApiMatched | OpenApiUnmatched;
|
|
|
446
723
|
*
|
|
447
724
|
* @category Middleware
|
|
448
725
|
*/
|
|
449
|
-
declare const withOpenApi: Middleware<'openapi', WithOpenApiConfig, Record<never, never>, OpenApiContribution>;
|
|
726
|
+
declare const withOpenApi: TypedByDocument & Middleware<'openapi', WithOpenApiConfig, Record<never, never>, OpenApiContribution>;
|
|
727
|
+
//#endregion
|
|
728
|
+
//#region src/define-document.d.ts
|
|
729
|
+
/**
|
|
730
|
+
* Check a document against `OpenAPIObject` without widening it.
|
|
731
|
+
*
|
|
732
|
+
* Returns its argument; the whole function is the type parameter.
|
|
733
|
+
*
|
|
734
|
+
* @example
|
|
735
|
+
* ```ts
|
|
736
|
+
* // document.ts
|
|
737
|
+
* export const document = defineDocument({
|
|
738
|
+
* openapi: '3.1.0',
|
|
739
|
+
* info: { title: 'Acme', version: '1' },
|
|
740
|
+
* servers: [{ url: '/api' }],
|
|
741
|
+
* paths: { ... },
|
|
742
|
+
* })
|
|
743
|
+
*
|
|
744
|
+
* // server.ts — `ctx.openapi` is typed against these operations
|
|
745
|
+
* pipeline([withOpenApi({ document })], handler)
|
|
746
|
+
*
|
|
747
|
+
* document.servers[0].url // '/api', not string
|
|
748
|
+
* ```
|
|
749
|
+
*/
|
|
750
|
+
declare function defineDocument<const Document extends OpenAPIObject>(document: Document): Document;
|
|
450
751
|
//#endregion
|
|
451
|
-
export { type CorsOrigin, type CorsPolicy, type FetchHandler, type HttpMethod, type OpenApiContribution, type OpenApiCorsOptions, type OpenApiMatched, type OpenApiParams, type OpenApiRejection, type OpenApiRejectionKind, type OpenApiUnmatched, type OpenApiValidateOptions, type OpenApiViolation, type ParameterIn, SCALAR_CDN_URL, type ScalarHtmlInput, type ScalarReferenceOptions, type SchemaDraft, type WithOpenApiConfig, withOpenApi };
|
|
752
|
+
export { type ContributionFor, type CorsOrigin, type CorsPolicy, type FetchHandler, type FromSchema, type HttpMethod, type MatchedFor, type MethodsOf, type OpenApiContribution, type OpenApiCorsOptions, type OpenApiMatched, type OpenApiParams, type OpenApiRejection, type OpenApiRejectionKind, type OpenApiUnmatched, type OpenApiValidateOptions, type OpenApiViolation, type OperationIdsOf, type OperationOf, type ParameterIn, type ParamsFor, type RoutesOf, SCALAR_CDN_URL, type ScalarHtmlInput, type ScalarReferenceOptions, type SchemaDraft, type WithOpenApiConfig, defineDocument, withOpenApi };
|
package/dist/index.js
CHANGED
|
@@ -868,18 +868,20 @@ function joinPath(base, child) {
|
|
|
868
868
|
return `${base.endsWith("/") ? base.slice(0, -1) : base}/${child}`;
|
|
869
869
|
}
|
|
870
870
|
/** Fill in the reference endpoint's defaults, once, at construction. */
|
|
871
|
-
function resolveReference(document, options) {
|
|
872
|
-
const path = options.path ?? "
|
|
871
|
+
function resolveReference(document, options, basePath) {
|
|
872
|
+
const path = options.path ?? joinPath(basePath ?? "", "reference");
|
|
873
873
|
if (!path.startsWith("/")) throw new Error(`withOpenApi: reference.path must start with "/", got ${JSON.stringify(path)}`);
|
|
874
|
-
const documentPath = options.documentPath ?? joinPath(
|
|
874
|
+
const documentPath = options.documentPath ?? joinPath(basePath ?? "", "openapi.json");
|
|
875
|
+
const documentUrl = options.documentUrl ?? documentPath;
|
|
875
876
|
const title = options.title ?? document.info?.title ?? "API Reference";
|
|
876
877
|
const cdnUrl = options.cdnUrl ?? SCALAR_CDN_URL;
|
|
877
878
|
const configuration = {
|
|
878
|
-
url:
|
|
879
|
+
url: documentUrl,
|
|
879
880
|
...options.configuration
|
|
880
881
|
};
|
|
881
882
|
const page = (options.html ?? renderScalarHtml)({
|
|
882
883
|
documentPath,
|
|
884
|
+
documentUrl,
|
|
883
885
|
title,
|
|
884
886
|
cdnUrl,
|
|
885
887
|
configuration
|
|
@@ -887,6 +889,7 @@ function resolveReference(document, options) {
|
|
|
887
889
|
return {
|
|
888
890
|
path,
|
|
889
891
|
documentPath,
|
|
892
|
+
documentUrl,
|
|
890
893
|
cacheControl: options.cacheControl ?? "no-cache",
|
|
891
894
|
render: () => page
|
|
892
895
|
};
|
|
@@ -1151,7 +1154,7 @@ function defaultRejectionResponse(rejection) {
|
|
|
1151
1154
|
if (rejection.allow !== void 0 && rejection.allow.length > 0) headers.set("allow", rejection.allow.join(", "));
|
|
1152
1155
|
return Response.json({
|
|
1153
1156
|
error: rejection.kind,
|
|
1154
|
-
message: REJECTION_MESSAGES[rejection.kind],
|
|
1157
|
+
message: rejection.message ?? REJECTION_MESSAGES[rejection.kind],
|
|
1155
1158
|
...rejection.accepts === void 0 ? {} : { accepts: rejection.accepts },
|
|
1156
1159
|
violations: rejection.violations
|
|
1157
1160
|
}, {
|
|
@@ -1159,20 +1162,16 @@ function defaultRejectionResponse(rejection) {
|
|
|
1159
1162
|
headers
|
|
1160
1163
|
});
|
|
1161
1164
|
}
|
|
1162
|
-
function unmatched(
|
|
1165
|
+
function unmatched(reason, route) {
|
|
1163
1166
|
return { openapi: {
|
|
1164
1167
|
matched: false,
|
|
1165
1168
|
reason,
|
|
1166
|
-
document,
|
|
1167
1169
|
route,
|
|
1168
|
-
method,
|
|
1169
1170
|
operation: void 0,
|
|
1170
1171
|
operationId: void 0,
|
|
1171
1172
|
security: void 0,
|
|
1172
1173
|
params: EMPTY_PARAMS,
|
|
1173
|
-
|
|
1174
|
-
mediaType: void 0,
|
|
1175
|
-
validated: false
|
|
1174
|
+
mediaType: void 0
|
|
1176
1175
|
} };
|
|
1177
1176
|
}
|
|
1178
1177
|
/**
|
|
@@ -1246,7 +1245,7 @@ const withOpenApi = defineMiddleware({
|
|
|
1246
1245
|
const onUnknownMethod = config.onUnknownMethod ?? "reject";
|
|
1247
1246
|
const resolve = (schema) => resolveSchema(document, schema);
|
|
1248
1247
|
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);
|
|
1248
|
+
const reference = config.reference === void 0 || config.reference === false ? void 0 : resolveReference(document, config.reference === true ? {} : config.reference, basePath);
|
|
1250
1249
|
const documentJson = reference === void 0 ? void 0 : JSON.stringify(document);
|
|
1251
1250
|
const cors = config.cors === void 0 ? void 0 : createCorsPolicy(document, routes, config.cors);
|
|
1252
1251
|
/**
|
|
@@ -1263,7 +1262,7 @@ const withOpenApi = defineMiddleware({
|
|
|
1263
1262
|
route: void 0
|
|
1264
1263
|
});
|
|
1265
1264
|
if (config.skip?.(req) === true) return {
|
|
1266
|
-
result: unmatched(
|
|
1265
|
+
result: unmatched("skipped", void 0),
|
|
1267
1266
|
route: void 0
|
|
1268
1267
|
};
|
|
1269
1268
|
const url = new URL(req.url);
|
|
@@ -1289,7 +1288,7 @@ const withOpenApi = defineMiddleware({
|
|
|
1289
1288
|
const match = pathname === void 0 ? void 0 : router.match(pathname);
|
|
1290
1289
|
if (match === void 0) {
|
|
1291
1290
|
if (onUnknownRoute === "pass") return {
|
|
1292
|
-
result: unmatched(
|
|
1291
|
+
result: unmatched("no_route", void 0),
|
|
1293
1292
|
route: void 0
|
|
1294
1293
|
};
|
|
1295
1294
|
return respond({
|
|
@@ -1297,6 +1296,7 @@ const withOpenApi = defineMiddleware({
|
|
|
1297
1296
|
status: 404,
|
|
1298
1297
|
method: req.method,
|
|
1299
1298
|
pathname: url.pathname,
|
|
1299
|
+
message: pathname === void 0 ? `the pathname ${JSON.stringify(url.pathname)} is outside basePath ${JSON.stringify(basePath)}` : `no operation in the API description matches ${JSON.stringify(pathname)}`,
|
|
1300
1300
|
violations: []
|
|
1301
1301
|
});
|
|
1302
1302
|
}
|
|
@@ -1307,7 +1307,7 @@ const withOpenApi = defineMiddleware({
|
|
|
1307
1307
|
const operation = isHttpMethod(method) ? match.route.operations.get(method) : void 0;
|
|
1308
1308
|
if (operation === void 0) {
|
|
1309
1309
|
if (onUnknownMethod === "pass") return {
|
|
1310
|
-
result: unmatched(
|
|
1310
|
+
result: unmatched("no_operation", match.route.template),
|
|
1311
1311
|
route: match.route
|
|
1312
1312
|
};
|
|
1313
1313
|
return respond({
|
|
@@ -1342,7 +1342,6 @@ const withOpenApi = defineMiddleware({
|
|
|
1342
1342
|
message: `query parameter "${name}" is not declared by this operation`
|
|
1343
1343
|
});
|
|
1344
1344
|
}
|
|
1345
|
-
let body;
|
|
1346
1345
|
let mediaType;
|
|
1347
1346
|
const requestBody = operation.requestBody;
|
|
1348
1347
|
if (requestBody !== void 0 && options?.body === true) {
|
|
@@ -1373,7 +1372,6 @@ const withOpenApi = defineMiddleware({
|
|
|
1373
1372
|
});
|
|
1374
1373
|
} else {
|
|
1375
1374
|
mediaType = read.content.mediaType;
|
|
1376
|
-
body = read.value;
|
|
1377
1375
|
if (read.validatable && read.content.schema !== void 0 && validateSchema) {
|
|
1378
1376
|
const schema = read.content.schema;
|
|
1379
1377
|
for (const unit of validateSchema(read.value, schema)) violations.push(toViolation("body", void 0, unit, options?.describe === true ? describeFailure(document, schema, unit) : void 0));
|
|
@@ -1391,16 +1389,12 @@ const withOpenApi = defineMiddleware({
|
|
|
1391
1389
|
return {
|
|
1392
1390
|
result: { openapi: {
|
|
1393
1391
|
matched: true,
|
|
1394
|
-
document,
|
|
1395
1392
|
route: operation.route,
|
|
1396
|
-
method: operation.method,
|
|
1397
1393
|
operation: operation.operation,
|
|
1398
1394
|
operationId: operation.operationId,
|
|
1399
1395
|
security: operation.security,
|
|
1400
1396
|
params,
|
|
1401
|
-
|
|
1402
|
-
mediaType,
|
|
1403
|
-
validated: options !== void 0
|
|
1397
|
+
mediaType
|
|
1404
1398
|
} },
|
|
1405
1399
|
route: match.route
|
|
1406
1400
|
};
|
|
@@ -1416,4 +1410,31 @@ const withOpenApi = defineMiddleware({
|
|
|
1416
1410
|
});
|
|
1417
1411
|
|
|
1418
1412
|
//#endregion
|
|
1419
|
-
|
|
1413
|
+
//#region src/define-document.ts
|
|
1414
|
+
/**
|
|
1415
|
+
* Check a document against `OpenAPIObject` without widening it.
|
|
1416
|
+
*
|
|
1417
|
+
* Returns its argument; the whole function is the type parameter.
|
|
1418
|
+
*
|
|
1419
|
+
* @example
|
|
1420
|
+
* ```ts
|
|
1421
|
+
* // document.ts
|
|
1422
|
+
* export const document = defineDocument({
|
|
1423
|
+
* openapi: '3.1.0',
|
|
1424
|
+
* info: { title: 'Acme', version: '1' },
|
|
1425
|
+
* servers: [{ url: '/api' }],
|
|
1426
|
+
* paths: { ... },
|
|
1427
|
+
* })
|
|
1428
|
+
*
|
|
1429
|
+
* // server.ts — `ctx.openapi` is typed against these operations
|
|
1430
|
+
* pipeline([withOpenApi({ document })], handler)
|
|
1431
|
+
*
|
|
1432
|
+
* document.servers[0].url // '/api', not string
|
|
1433
|
+
* ```
|
|
1434
|
+
*/
|
|
1435
|
+
function defineDocument(document) {
|
|
1436
|
+
return document;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
//#endregion
|
|
1440
|
+
export { SCALAR_CDN_URL, defineDocument, withOpenApi };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@croutonian/with-openapi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "OpenAPI middleware for @supabase/middleware. Matches each request against an OpenAPI 3.1 document, optionally rejects the ones it does not describe, contributes the matched operation and validated params to ctx, and optionally serves a Scalar API reference.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openapi",
|