@croutonian/with-openapi 0.3.0 → 0.5.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,10 +149,10 @@ 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
 
132
- `route_not_found` says which of its two causes it was, because they are the
133
- same status and very different mistakes — a `basePath` nothing starts with
134
- turns every route into a 404, and blaming the document sends you looking for a
135
- path that is already in it:
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:
136
156
 
137
157
  ```
138
158
  no operation in the API description matches "/nope"
@@ -164,10 +184,10 @@ path to the property that failed.
164
184
 
165
185
  ### Descriptions
166
186
 
167
- `message` is the validator's, and says what is mechanically wrong.
168
- `description` is the **document's own prose** for whatever failed, and is
169
- usually the half a caller can act on. You wrote it once; there is no reason for
170
- 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.
171
191
 
172
192
  It is resolved from the most specific place that has it:
173
193
 
@@ -192,14 +212,14 @@ body Instance does not have required property "name".
192
212
  That third row is the one worth pointing at: `required` fails against the
193
213
  _object_, so the obvious implementation describes the object — "A person with
194
214
  access to the workspace" — which says nothing about what is missing. The
195
- property is named only inside the validator's message, so it is read from
196
- 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.
197
217
 
198
218
  A field with nothing written about it simply has no `description`. Set
199
- `validate: { describe: false }` to leave them all off — descriptions are
200
- written for a document's consumers, who are the same people reading these
201
- errors, but turn it off if yours carries notes you would rather not return in
202
- 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.
203
223
 
204
224
  To answer in your own error envelope:
205
225
 
@@ -261,8 +281,8 @@ reference: {
261
281
  }
262
282
  ```
263
283
 
264
- A path you give explicitly is taken **literally** — `basePath` is not applied
265
- to it, so an API under `/api/v1` can still put its docs at `/docs`:
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`:
266
286
 
267
287
  ```ts
268
288
  withOpenApi({ document, basePath: '/api/v1', reference: { path: '/docs' } })
@@ -299,6 +319,13 @@ withOpenApi({
299
319
 
300
320
  ## Parameters
301
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
+
302
329
  `style` and `explode` are honored, so the document decides how a value is
303
330
  spelled:
304
331
 
@@ -337,18 +364,121 @@ the request says it is:
337
364
  | `multipart/form-data` | object, with parts left as `File` | no |
338
365
  | anything else | not read at all | no |
339
366
 
340
- Multipart parts are `File` objects, which no JSON Schema describes, so the body
341
- is parsed onto `ctx` but not schema-checked. Binary media types are never
342
- bufferedthere is no shape to check, and reading a large upload to ignore it
343
- 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.
344
474
 
345
- Reading the body here does not consume it. The framework hands every layer a
346
- 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.
347
477
 
348
478
  ## CORS
349
479
 
350
- An OpenAPI document already knows most of a CORS policy. `cors` derives it,
351
- per route:
480
+ An OpenAPI document already knows most of a CORS policy. `cors` derives it, per
481
+ route:
352
482
 
353
483
  ```ts
354
484
  withOpenApi({
@@ -376,15 +506,15 @@ comes from.
376
506
  Three things worth knowing:
377
507
 
378
508
  - **`origin` is required and never derived.** A document says where an API
379
- lives, not who may call it — `servers` is not an allowlist, and treating it
380
- 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
381
511
  `credentials`.
382
512
  - **Rejections are stamped too.** An unstamped `400` reaches a browser as an
383
513
  opaque CORS error rather than the violations it is carrying.
384
514
  - **The document is the source of truth for headers.** A request header the API
385
515
  reads but the document does not declare will be refused by the browser. That
386
- is usually the document being wrong; `allowedHeaders` is the escape hatch
387
- when it genuinely is not.
516
+ is usually the document being wrong; `allowedHeaders` is the escape hatch when
517
+ it genuinely is not.
388
518
 
389
519
  Preflights are answered after the route match but before the method lookup —
390
520
  otherwise the `OPTIONS` no document declares an operation for would come back
@@ -421,11 +551,11 @@ Worth knowing before you wire this into something:
421
551
  translating it. A 3.0 document falls back to draft 4, which gets
422
552
  `exclusiveMinimum` and `required` right but does **not** translate `nullable`.
423
553
  Convert to 3.1 for full fidelity.
424
- - **Local `$ref`s only.** External and remote references are not fetched.
425
- Bundle the document first.
554
+ - **Local `$ref`s only.** External and remote references are not fetched. Bundle
555
+ the document first.
426
556
  - **Requests only.** Responses are not validated. That is the framework's model,
427
- not an omission: a middleware runs before the handler, and response shape stays
428
- 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.
429
559
  - **`deepObject` is one level deep**, matching what the specification defines.
430
560
  - **Trailing slashes are normalized**, so `/users` and `/users/` are one route.
431
561
  - Indexing the document for validation stamps each node with its own absolute
@@ -444,8 +574,8 @@ Three, and each is load-bearing:
444
574
  - **`@cfworker/json-schema`** — the validator. Zero dependencies, and it
445
575
  _interprets_ schemas rather than compiling them to JavaScript, which is what
446
576
  lets it run on Cloudflare Workers and anywhere else `new Function` is
447
- unavailable. The document is walked once at construction and every subschema in
448
- 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 —
449
579
  resolves without inlining anything.
450
580
  - **`@supabase/middleware`** — the composition engine.
451
581
 
@@ -518,10 +648,10 @@ resolves that by publishing from your machine — your npm login, your 2FA, no
518
648
  token created and none stored. CI takes over from the next release.
519
649
 
520
650
  `release.yml` also accepts an `NPM_TOKEN` secret, but it is not a general
521
- answer: a token cannot answer a one-time password, and npm asks for one on
522
- every write unless the account's two-factor setting is _Authorization only_.
523
- Where 2FA covers writes, a CI publish fails with `EOTP` and the first version
524
- 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.
525
655
 
526
656
  Between releases, every branch push and pull request publishes an installable
527
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
 
@@ -198,7 +198,16 @@ interface ScalarReferenceOptions {
198
198
  //#region src/types.d.ts
199
199
  /** Where a parameter was declared. */
200
200
  type ParameterIn = 'path' | 'query' | 'header' | 'cookie';
201
- /** 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
+ */
202
211
  interface OpenApiParams {
203
212
  readonly path: Readonly<Record<string, unknown>>;
204
213
  readonly query: Readonly<Record<string, unknown>>;
@@ -267,7 +276,7 @@ interface OpenApiValidateOptions {
267
276
  cookie?: boolean;
268
277
  /**
269
278
  * Check — and therefore read and parse — the request body. With this off,
270
- * `ctx.openapi.body` is `undefined` and the handler reads the body itself.
279
+ * the body is never read here and reaches the handler unexamined.
271
280
  *
272
281
  * @defaultValue `true`
273
282
  */
@@ -392,54 +401,119 @@ interface WithOpenApiConfig {
392
401
  */
393
402
  reject?: (rejection: OpenApiRejection, req: Request) => Response | undefined | Promise<Response | undefined>;
394
403
  }
395
- /** `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
+ */
396
411
  interface OpenApiMatched {
397
412
  readonly matched: true;
398
- /** The document, as passed to `withOpenApi`. */
399
- readonly document: OpenAPIObject;
400
- /** 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
+ */
401
420
  readonly route: string;
402
- /** Lowercase method the operation was declared under. */
403
- readonly method: HttpMethod;
404
- /** 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
+ */
405
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
+ */
406
440
  readonly operationId: string | undefined;
407
441
  /**
408
442
  * Security requirements in force — the operation's, falling back to the
409
- * document's. Contributed for a downstream auth middleware to act on; this
410
- * 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.
411
450
  */
412
451
  readonly security: SecurityRequirementObject[] | undefined;
413
- /** 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
+ */
414
463
  readonly params: OpenApiParams;
415
464
  /**
416
- * The parsed request body. `undefined` when the operation declares none,
417
- * when none was sent, when body validation is off, or when the media type
418
- * 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.
419
486
  */
420
- readonly body: unknown;
421
- /** The `content` key that matched the request's content type. */
422
487
  readonly mediaType: string | undefined;
423
- /** `false` when `validate: false` — the request was matched, not checked. */
424
- readonly validated: boolean;
425
488
  }
426
- /** `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
+ */
427
496
  interface OpenApiUnmatched {
428
497
  readonly matched: false;
429
- /** 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
+ */
430
505
  readonly reason: 'skipped' | 'no_route' | 'no_operation';
431
- readonly document: OpenAPIObject;
432
506
  /** Set when the path matched but the method was not declared under it. */
433
507
  readonly route: string | undefined;
434
- /** The request's method, as sent. */
435
- readonly method: string;
436
508
  readonly operation: undefined;
437
509
  readonly operationId: undefined;
438
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
+ */
439
515
  readonly params: OpenApiParams;
440
- readonly body: undefined;
441
516
  readonly mediaType: undefined;
442
- readonly validated: false;
443
517
  }
444
518
  /**
445
519
  * What lands at `ctx.openapi`.
@@ -451,7 +525,208 @@ interface OpenApiUnmatched {
451
525
  */
452
526
  type OpenApiContribution = OpenApiMatched | OpenApiUnmatched;
453
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
+ * Whether `Key` is set to `'pass'` — or might be.
664
+ *
665
+ * Everything here fails *safe*: the unmatched branch is only dropped when the
666
+ * config provably cannot produce one. A widened config satisfies
667
+ * `'pass' extends string`, so it keeps the branch rather than promising a
668
+ * `matched: true` the runtime may not deliver.
669
+ */
670
+ type MayPass<Config, Key extends string> = Key extends keyof Config ? 'pass' extends Config[Key] ? true : false : false;
671
+ /**
672
+ * Whether a `skip` predicate might fire.
673
+ *
674
+ * Asks whether `skip` could *possibly* be a function, not whether it
675
+ * definitely is. `skip: condition ? fn : undefined` has type
676
+ * `Fn | undefined`, and a check of the second kind reads that as "no skip"
677
+ * and drops the branch — while at runtime the predicate fires whenever the
678
+ * condition holds, handing a handler an unmatched contribution the compiler
679
+ * said could not exist.
680
+ */
681
+ type HasSkip<Config> = 'skip' extends keyof Config ? [Config['skip']] extends [undefined] ? false : true : false;
682
+ /**
683
+ * Whether this config can produce an unmatched contribution at all.
684
+ *
685
+ * Mirrors the three runtime branches that call `unmatched()`: a `skip` that
686
+ * fired, `onUnknownRoute: 'pass'` with no route match, `onUnknownMethod:
687
+ * 'pass'` with no operation. On the defaults none of them can, because both
688
+ * options default to `'reject'` and answer the request themselves.
689
+ */
690
+ type CanBeUnmatched<Config> = MayPass<Config, 'onUnknownRoute'> extends true ? true : MayPass<Config, 'onUnknownMethod'> extends true ? true : HasSkip<Config> extends true ? true : false;
691
+ /**
692
+ * What lands at `ctx.openapi` for a given document and config.
693
+ *
694
+ * Two reductions, in order. Handed a document that arrived without its
695
+ * literal type — annotated `: OpenAPIObject`, say — `RoutesOf` is `never`,
696
+ * and this degrades to the unspecialized {@link OpenApiContribution}; without
697
+ * that, the union would have no `matched: true` branch at all and every
698
+ * existing consumer would break. Otherwise the unmatched branch is dropped
699
+ * unless the config can actually produce one, so a downstream middleware or
700
+ * handler can read `route`, `operationId` and `params` without a guard for a
701
+ * case that cannot happen.
702
+ */
703
+ type ContributionFor<Document, Config = WithOpenApiConfig> = [RoutesOf<Document>] extends [never] ? OpenApiContribution : CanBeUnmatched<Config> extends true ? MatchedFor<Document> | OpenApiUnmatched : MatchedFor<Document>;
704
+ //#endregion
454
705
  //#region src/with-openapi.d.ts
706
+ /**
707
+ * The document-aware call signature, intersected *ahead* of the one
708
+ * `defineMiddleware` produces so it is tried first.
709
+ *
710
+ * Only the config-only form is specialized — the form that goes in a
711
+ * `pipeline` array, which is how this is mounted. `pipeline` then carries the
712
+ * document-specific contribution through to the handler on its own. The two
713
+ * handler-taking forms fall through to the general signatures below it, where
714
+ * `ctx.openapi` keeps its unspecialized shape.
715
+ *
716
+ * The whole config is captured, not just the document: `onUnknownRoute`,
717
+ * `onUnknownMethod` and `skip` are what decide whether an unmatched
718
+ * contribution is reachable, and on the defaults it is not — so a downstream
719
+ * middleware or handler gets `matched: true` already narrowed, with no guard
720
+ * for a case the config rules out.
721
+ *
722
+ * The runtime is untouched: this re-describes what `defineMiddleware` already
723
+ * returns. That makes the description an assertion we own — if `ParamsFor`
724
+ * ever disagrees with what the middleware actually deserializes, the types
725
+ * are what lie, and nothing here would catch it.
726
+ */
727
+ interface TypedByDocument {
728
+ <const Config extends WithOpenApiConfig>(config: Config): SingleKeyEntry<'openapi', Record<never, never>, ContributionFor<Config['document'], Config>>;
729
+ }
455
730
  /**
456
731
  * Middleware that holds an API to its own description.
457
732
  *
@@ -484,6 +759,30 @@ type OpenApiContribution = OpenApiMatched | OpenApiUnmatched;
484
759
  *
485
760
  * @category Middleware
486
761
  */
487
- declare const withOpenApi: Middleware<'openapi', WithOpenApiConfig, Record<never, never>, OpenApiContribution>;
762
+ declare const withOpenApi: TypedByDocument & Middleware<'openapi', WithOpenApiConfig, Record<never, never>, OpenApiContribution>;
763
+ //#endregion
764
+ //#region src/define-document.d.ts
765
+ /**
766
+ * Check a document against `OpenAPIObject` without widening it.
767
+ *
768
+ * Returns its argument; the whole function is the type parameter.
769
+ *
770
+ * @example
771
+ * ```ts
772
+ * // document.ts
773
+ * export const document = defineDocument({
774
+ * openapi: '3.1.0',
775
+ * info: { title: 'Acme', version: '1' },
776
+ * servers: [{ url: '/api' }],
777
+ * paths: { ... },
778
+ * })
779
+ *
780
+ * // server.ts — `ctx.openapi` is typed against these operations
781
+ * pipeline([withOpenApi({ document })], handler)
782
+ *
783
+ * document.servers[0].url // '/api', not string
784
+ * ```
785
+ */
786
+ declare function defineDocument<const Document extends OpenAPIObject>(document: Document): Document;
488
787
  //#endregion
489
- 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 };
788
+ 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
@@ -1162,20 +1162,16 @@ function defaultRejectionResponse(rejection) {
1162
1162
  headers
1163
1163
  });
1164
1164
  }
1165
- function unmatched(document, reason, method, route) {
1165
+ function unmatched(reason, route) {
1166
1166
  return { openapi: {
1167
1167
  matched: false,
1168
1168
  reason,
1169
- document,
1170
1169
  route,
1171
- method,
1172
1170
  operation: void 0,
1173
1171
  operationId: void 0,
1174
1172
  security: void 0,
1175
1173
  params: EMPTY_PARAMS,
1176
- body: void 0,
1177
- mediaType: void 0,
1178
- validated: false
1174
+ mediaType: void 0
1179
1175
  } };
1180
1176
  }
1181
1177
  /**
@@ -1266,7 +1262,7 @@ const withOpenApi = defineMiddleware({
1266
1262
  route: void 0
1267
1263
  });
1268
1264
  if (config.skip?.(req) === true) return {
1269
- result: unmatched(document, "skipped", req.method, void 0),
1265
+ result: unmatched("skipped", void 0),
1270
1266
  route: void 0
1271
1267
  };
1272
1268
  const url = new URL(req.url);
@@ -1292,7 +1288,7 @@ const withOpenApi = defineMiddleware({
1292
1288
  const match = pathname === void 0 ? void 0 : router.match(pathname);
1293
1289
  if (match === void 0) {
1294
1290
  if (onUnknownRoute === "pass") return {
1295
- result: unmatched(document, "no_route", req.method, void 0),
1291
+ result: unmatched("no_route", void 0),
1296
1292
  route: void 0
1297
1293
  };
1298
1294
  return respond({
@@ -1311,7 +1307,7 @@ const withOpenApi = defineMiddleware({
1311
1307
  const operation = isHttpMethod(method) ? match.route.operations.get(method) : void 0;
1312
1308
  if (operation === void 0) {
1313
1309
  if (onUnknownMethod === "pass") return {
1314
- result: unmatched(document, "no_operation", req.method, match.route.template),
1310
+ result: unmatched("no_operation", match.route.template),
1315
1311
  route: match.route
1316
1312
  };
1317
1313
  return respond({
@@ -1346,7 +1342,6 @@ const withOpenApi = defineMiddleware({
1346
1342
  message: `query parameter "${name}" is not declared by this operation`
1347
1343
  });
1348
1344
  }
1349
- let body;
1350
1345
  let mediaType;
1351
1346
  const requestBody = operation.requestBody;
1352
1347
  if (requestBody !== void 0 && options?.body === true) {
@@ -1377,7 +1372,6 @@ const withOpenApi = defineMiddleware({
1377
1372
  });
1378
1373
  } else {
1379
1374
  mediaType = read.content.mediaType;
1380
- body = read.value;
1381
1375
  if (read.validatable && read.content.schema !== void 0 && validateSchema) {
1382
1376
  const schema = read.content.schema;
1383
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));
@@ -1395,16 +1389,12 @@ const withOpenApi = defineMiddleware({
1395
1389
  return {
1396
1390
  result: { openapi: {
1397
1391
  matched: true,
1398
- document,
1399
1392
  route: operation.route,
1400
- method: operation.method,
1401
1393
  operation: operation.operation,
1402
1394
  operationId: operation.operationId,
1403
1395
  security: operation.security,
1404
1396
  params,
1405
- body,
1406
- mediaType,
1407
- validated: options !== void 0
1397
+ mediaType
1408
1398
  } },
1409
1399
  route: match.route
1410
1400
  };
@@ -1420,4 +1410,31 @@ const withOpenApi = defineMiddleware({
1420
1410
  });
1421
1411
 
1422
1412
  //#endregion
1423
- 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.3.0",
3
+ "version": "0.5.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",