@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 CHANGED
@@ -6,7 +6,8 @@
6
6
  [![CI](https://github.com/croutonian/with-openapi/actions/workflows/ci.yml/badge.svg)](https://github.com/croutonian/with-openapi/actions/workflows/ci.yml)
7
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
8
8
 
9
- OpenAPI middleware for [`@supabase/middleware`](https://github.com/supabase/middleware).
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, already coerced: `limit` is a number.
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.body // the parsed request body
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
- `description` is the **document's own prose** for whatever failed, and is
159
- usually the half a caller can act on. You wrote it once; there is no reason for
160
- an error response to throw it away.
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
- there, and falls back to the container's prose if that wording ever changes.
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
- written for a document's consumers, who are the same people reading these
191
- errors, but turn it off if yours carries notes you would rather not return in
192
- a response body.
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 /reference/openapi.json` — the document, for the page to load
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(documentPath)
280
+ html: ({ documentPath, documentUrl }) => myOwnPage(documentUrl)
242
281
  }
243
282
  ```
244
283
 
245
- The reference paths are absolutethey are **not** relative to `basePath`.
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 the body
288
- is parsed onto `ctx` but not schema-checked. Binary media types are never
289
- bufferedthere is no shape to check, and reading a large upload to ignore it
290
- is pure cost. `required` is enforced for both.
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
- Reading the body here does not consume it. The framework hands every layer a
293
- buffered request, so the handler can still call `req.json()`.
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
- per route:
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
- as one would be a security decision made from the wrong data. Same for
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
- when it genuinely is not.
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
- Bundle the document first.
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 stays
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 in
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` | Publishing without a stored credential. |
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
- every write unless the account's two-factor setting is _Authorization only_.
470
- Where 2FA covers writes, a CI publish fails with `EOTP` and the first version
471
- has to come from a human.
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
- * @defaultValue `'/reference'`
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 `` `${path}/openapi.json` ``
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 documentPath} and can be overridden here.
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
- /** Deserialized, coerced parameters, grouped by where they came from. */
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
- * `ctx.openapi.body` is `undefined` and the handler reads the body itself.
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
- * `/reference/openapi.json`.
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
- /** `ctx.openapi` when an operation in the document describes the request. */
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
- /** The document, as passed to `withOpenApi`. */
361
- readonly document: OpenAPIObject;
362
- /** Path template that matched, e.g. `'/users/{id}'`. */
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
- /** Lowercase method the operation was declared under. */
365
- readonly method: HttpMethod;
366
- /** The Operation Object, with its own `$ref` (if any) already followed. */
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. Contributed for a downstream auth middleware to act on; this
372
- * middleware never enforces them.
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
- /** Deserialized and (unless turned off) coerced parameters. */
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
- * The parsed request body. `undefined` when the operation declares none,
379
- * when none was sent, when body validation is off, or when the media type
380
- * is binary and was deliberately left unread.
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
- /** `ctx.openapi` when the document does not describe the request. */
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
- /** Why nothing matched. */
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 ?? "/reference";
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(path, "openapi.json");
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: documentPath,
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(document, reason, method, route) {
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
- body: void 0,
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(document, "skipped", req.method, void 0),
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(document, "no_route", req.method, void 0),
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(document, "no_operation", req.method, match.route.template),
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
- body,
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
- export { SCALAR_CDN_URL, withOpenApi };
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.2.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",