@croutonian/with-openapi 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,451 @@
1
+ import { FetchHandler, Middleware } from "@supabase/middleware";
2
+ import { SchemaDraft } from "@cfworker/json-schema";
3
+ import { OpenAPIObject, OperationObject, ParameterLocation, ParameterStyle, ReferenceObject, SchemaObject, SecurityRequirementObject } from "openapi3-ts/oas31";
4
+
5
+ //#region src/document.d.ts
6
+ /** The HTTP methods an OpenAPI Path Item Object may declare an operation for. */
7
+ declare const HTTP_METHODS: readonly ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
8
+ /** One of the eight methods {@link HTTP_METHODS} lists. */
9
+ type HttpMethod = (typeof HTTP_METHODS)[number];
10
+ /** One segment of a path template: a literal, or a `{name}` placeholder. */
11
+ type Segment = {
12
+ readonly kind: 'static';
13
+ readonly value: string;
14
+ } | {
15
+ readonly kind: 'param';
16
+ readonly name: string;
17
+ };
18
+ /** A parameter, with its defaults filled in and its schema pre-resolved. */
19
+ interface IndexedParameter {
20
+ readonly name: string;
21
+ readonly in: ParameterLocation;
22
+ /** The Parameter Object's own prose, surfaced on any violation about it. */
23
+ readonly description: string | undefined;
24
+ readonly required: boolean;
25
+ readonly style: ParameterStyle;
26
+ readonly explode: boolean;
27
+ readonly allowEmptyValue: boolean;
28
+ /** As written in the document — handed to the validator unresolved. */
29
+ readonly schema: SchemaObject | ReferenceObject | undefined;
30
+ /** `$ref` chain followed, so deserialization can read `type`. */
31
+ readonly resolved: SchemaObject | undefined;
32
+ }
33
+ /** One entry of a Request Body Object's `content` map. */
34
+ interface IndexedContent {
35
+ readonly mediaType: string;
36
+ readonly schema: SchemaObject | ReferenceObject | undefined;
37
+ readonly resolved: SchemaObject | undefined;
38
+ }
39
+ /** A Request Body Object, flattened. */
40
+ interface IndexedRequestBody {
41
+ readonly required: boolean;
42
+ /** The Request Body Object's own prose, surfaced on body-level violations. */
43
+ readonly description: string | undefined;
44
+ readonly contents: readonly IndexedContent[];
45
+ }
46
+ /** Everything the middleware needs about one operation, resolved up front. */
47
+ interface IndexedOperation {
48
+ readonly method: HttpMethod;
49
+ readonly route: string;
50
+ readonly operation: OperationObject;
51
+ readonly operationId: string | undefined;
52
+ readonly security: SecurityRequirementObject[] | undefined;
53
+ readonly parameters: Readonly<Record<ParameterLocation, readonly IndexedParameter[]>>;
54
+ readonly requestBody: IndexedRequestBody | undefined;
55
+ /**
56
+ * Query string names the declared parameters may legitimately consume —
57
+ * a `form`/`explode` object contributes its property names, not its own.
58
+ * Only read when `additionalQuery: 'reject'`.
59
+ */
60
+ readonly knownQueryNames: ReadonlySet<string>;
61
+ /** `deepObject` parameters consume anything starting with `name[`. */
62
+ readonly knownQueryPrefixes: readonly string[];
63
+ }
64
+ /** A path template, its segments, and the operations declared under it. */
65
+ interface IndexedRoute {
66
+ readonly template: string;
67
+ readonly segments: readonly Segment[];
68
+ readonly operations: ReadonlyMap<HttpMethod, IndexedOperation>;
69
+ /** Declared methods, upper-cased — the `Allow` header on a 405. */
70
+ readonly allow: readonly string[];
71
+ }
72
+ //#endregion
73
+ //#region src/cors.d.ts
74
+ /**
75
+ * Which origins may call the API.
76
+ *
77
+ * A literal `'*'` with `credentials` is forbidden by the Fetch standard, so in
78
+ * that combination the request's `Origin` is reflected instead.
79
+ */
80
+ type CorsOrigin = '*' | string | readonly string[] | ((origin: string | null) => boolean);
81
+ /** CORS configuration. Everything not listed here comes from the document. */
82
+ interface OpenApiCorsOptions {
83
+ /**
84
+ * Allowed origins. **Required, and never derived** — a document describes
85
+ * where an API lives, not who may call it.
86
+ */
87
+ origin: CorsOrigin;
88
+ /** Send `Access-Control-Allow-Credentials: true`. @defaultValue `false` */
89
+ credentials?: boolean;
90
+ /** `Access-Control-Max-Age` in seconds, for preflight caching. */
91
+ maxAge?: number;
92
+ /**
93
+ * Request headers to allow **on top of** the ones derived from the document.
94
+ * Reach for this when a header is genuinely not describable in OpenAPI; a
95
+ * header the API actually reads belongs in the document instead.
96
+ */
97
+ allowedHeaders?: readonly string[];
98
+ /** Response headers to expose on top of the ones derived from the document. */
99
+ exposedHeaders?: readonly string[];
100
+ /** Status for a successful preflight. @defaultValue `204` */
101
+ optionsSuccessStatus?: number;
102
+ }
103
+ /** Built once at construction; answers preflights and stamps responses. */
104
+ interface CorsPolicy {
105
+ /** A preflight is `OPTIONS` carrying `Access-Control-Request-Method`. */
106
+ isPreflight(req: Request): boolean;
107
+ /** The preflight response for a matched route. */
108
+ preflight(req: Request, route: IndexedRoute): Response;
109
+ /**
110
+ * Copy a response, adding the response-side CORS headers.
111
+ *
112
+ * The matched route is passed in rather than looked up, because it is what
113
+ * `Access-Control-Expose-Headers` is derived from and the caller already
114
+ * holds it. A side channel keyed on the request would be the alternative,
115
+ * and the authoring guide's rule 3 exists to prevent exactly that.
116
+ */
117
+ stamp(response: Response, req: Request, route: IndexedRoute | undefined): Response;
118
+ }
119
+ //#endregion
120
+ //#region src/reference.d.ts
121
+ /** Default CDN for Scalar's standalone browser build. */
122
+ declare const SCALAR_CDN_URL = "https://cdn.jsdelivr.net/npm/@scalar/api-reference";
123
+ /** Everything {@link ScalarReferenceOptions.html} is given to render a page. */
124
+ interface ScalarHtmlInput {
125
+ /** Absolute path the document JSON is served from. */
126
+ readonly documentPath: string;
127
+ /** Page `<title>`. */
128
+ readonly title: string;
129
+ /** Script URL for Scalar's standalone build. */
130
+ readonly cdnUrl: string;
131
+ /** Config object passed to `Scalar.createApiReference`, `url` included. */
132
+ readonly configuration: Record<string, unknown>;
133
+ }
134
+ /** Configuration for the reference endpoint. */
135
+ interface ScalarReferenceOptions {
136
+ /**
137
+ * Path the HTML page is served from. Matched exactly, and *before* the
138
+ * document's own routes, so it does not need to appear in the document.
139
+ *
140
+ * @defaultValue `'/reference'`
141
+ */
142
+ path?: string;
143
+ /**
144
+ * Path the document JSON is served from.
145
+ *
146
+ * @defaultValue `` `${path}/openapi.json` ``
147
+ */
148
+ documentPath?: string;
149
+ /** Page title. @defaultValue the document's `info.title`, or `'API Reference'` */
150
+ title?: string;
151
+ /** Script URL for Scalar's standalone build. @defaultValue {@link SCALAR_CDN_URL} */
152
+ cdnUrl?: string;
153
+ /**
154
+ * Extra options merged into the `Scalar.createApiReference` config — theme,
155
+ * `darkMode`, `proxyUrl`, and anything else Scalar accepts. `url` is set
156
+ * from {@link documentPath} and can be overridden here.
157
+ *
158
+ * @see https://scalar.com/products/api-references/configuration
159
+ */
160
+ configuration?: Record<string, unknown>;
161
+ /** `Cache-Control` on both responses. @defaultValue `'no-cache'` */
162
+ cacheControl?: string;
163
+ /** Render the page yourself, ignoring every option above except the paths. */
164
+ html?: (input: ScalarHtmlInput) => string;
165
+ }
166
+ //#endregion
167
+ //#region src/types.d.ts
168
+ /** Where a parameter was declared. */
169
+ type ParameterIn = 'path' | 'query' | 'header' | 'cookie';
170
+ /** Deserialized, coerced parameters, grouped by where they came from. */
171
+ interface OpenApiParams {
172
+ readonly path: Readonly<Record<string, unknown>>;
173
+ readonly query: Readonly<Record<string, unknown>>;
174
+ readonly header: Readonly<Record<string, unknown>>;
175
+ readonly cookie: Readonly<Record<string, unknown>>;
176
+ }
177
+ /** One thing wrong with a request. */
178
+ interface OpenApiViolation {
179
+ /** Which half of the request it is about. */
180
+ readonly in: ParameterIn | 'body';
181
+ /** Parameter name. Absent for a violation of the body as a whole. */
182
+ readonly name?: string;
183
+ /** JSON pointer into the offending value, from the schema validator. */
184
+ readonly location?: string;
185
+ /** The JSON Schema keyword that failed, when one did. */
186
+ readonly keyword?: string;
187
+ /** What the validator objected to, mechanically. */
188
+ readonly message: string;
189
+ /**
190
+ * The document's own prose for whatever failed — the Parameter Object's
191
+ * `description`, or the `description` on the schema the failing keyword
192
+ * belongs to.
193
+ *
194
+ * {@link message} says what is wrong; this says what the thing is *for*, and
195
+ * it is the half a caller can usually act on. Absent when the document does
196
+ * not describe that field, or when `validate.describe` is off.
197
+ */
198
+ readonly description?: string;
199
+ }
200
+ /** Why the middleware is refusing a request. */
201
+ type OpenApiRejectionKind = 'route_not_found' | 'method_not_allowed' | 'unsupported_media_type' | 'validation_failed';
202
+ /** Everything known about a refusal, handed to {@link WithOpenApiConfig.reject}. */
203
+ interface OpenApiRejection {
204
+ readonly kind: OpenApiRejectionKind;
205
+ /** Status the default response would use. */
206
+ readonly status: number;
207
+ /** The request's method, as sent. */
208
+ readonly method: string;
209
+ /** The request's pathname, before `basePath` is stripped. */
210
+ readonly pathname: string;
211
+ /** Path template that matched, when one did. */
212
+ readonly route?: string;
213
+ /** Methods the route does declare. Set on `method_not_allowed`. */
214
+ readonly allow?: readonly string[];
215
+ /** Media types the operation accepts. Set on `unsupported_media_type`. */
216
+ readonly accepts?: readonly string[];
217
+ /** Empty for the routing kinds, which have nothing per-field to report. */
218
+ readonly violations: readonly OpenApiViolation[];
219
+ }
220
+ /** Which halves of a request to check, and how strictly. */
221
+ interface OpenApiValidateOptions {
222
+ /** Check path parameters. @defaultValue `true` */
223
+ path?: boolean;
224
+ /** Check query parameters. @defaultValue `true` */
225
+ query?: boolean;
226
+ /** Check header parameters. @defaultValue `true` */
227
+ header?: boolean;
228
+ /** Check cookie parameters. @defaultValue `true` */
229
+ cookie?: boolean;
230
+ /**
231
+ * 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.
233
+ *
234
+ * @defaultValue `true`
235
+ */
236
+ body?: boolean;
237
+ /**
238
+ * What to do with query parameters the operation does not declare.
239
+ * `'allow'` ignores them; `'reject'` treats each as a violation.
240
+ *
241
+ * @defaultValue `'allow'`
242
+ */
243
+ additionalQuery?: 'allow' | 'reject';
244
+ /** Status for a validation failure. @defaultValue `400` */
245
+ status?: number;
246
+ /**
247
+ * Include the document's `description` prose on each violation, so a caller
248
+ * is told what a field is for and not only that it is wrong.
249
+ *
250
+ * A document's descriptions are written for its consumers, which is who
251
+ * reads these errors — but turn this off if yours carries notes you would
252
+ * rather not return in a response body.
253
+ *
254
+ * @defaultValue `true`
255
+ */
256
+ describe?: boolean;
257
+ /**
258
+ * Most violations to report in one rejection. One bad array in a large body
259
+ * can fail thousands of times, and nobody reads past the first few.
260
+ *
261
+ * @defaultValue `20`
262
+ */
263
+ maxViolations?: number;
264
+ }
265
+ /** Per-instance configuration for `withOpenApi`. */
266
+ interface WithOpenApiConfig {
267
+ /**
268
+ * The OpenAPI 3.1 document. Read once, at construction: `$ref`s are
269
+ * followed, parameters merged, and path templates compiled, so a request
270
+ * costs a segment walk rather than a document traversal.
271
+ *
272
+ * External `$ref`s are not fetched — bundle the document first.
273
+ */
274
+ document: OpenAPIObject;
275
+ /**
276
+ * Prefix stripped from the pathname before matching, for an API mounted
277
+ * under a sub-path. The reference endpoint's paths are **not** relative to
278
+ * it; they are matched against the full pathname.
279
+ */
280
+ basePath?: string;
281
+ /**
282
+ * Check requests against the document, and reject the ones that do not fit.
283
+ *
284
+ * `false` turns rejection off entirely: routes are still matched and
285
+ * parameters still deserialized onto `ctx.openapi.params`, but nothing is
286
+ * refused for being invalid. Pass an object to check some halves of the
287
+ * request and not others.
288
+ *
289
+ * @defaultValue `true`
290
+ */
291
+ validate?: boolean | OpenApiValidateOptions;
292
+ /**
293
+ * Convert parameter text into the JSON types the schemas describe, so
294
+ * `?limit=10` satisfies `type: integer`. Off, every non-string parameter in
295
+ * every document fails its own type check.
296
+ *
297
+ * @defaultValue `true`
298
+ */
299
+ coerce?: boolean;
300
+ /**
301
+ * What to do with a pathname no path template matches. `'reject'` answers
302
+ * `404`; `'pass'` falls through to the handler with
303
+ * `ctx.openapi.matched === false`.
304
+ *
305
+ * @defaultValue `'reject'`
306
+ */
307
+ onUnknownRoute?: 'reject' | 'pass';
308
+ /**
309
+ * What to do when the path matches but the operation is not declared for
310
+ * the request's method. `'reject'` answers `405` with an `Allow` header;
311
+ * `'pass'` falls through with `ctx.openapi.matched === false`.
312
+ *
313
+ * @defaultValue `'reject'`
314
+ */
315
+ onUnknownMethod?: 'reject' | 'pass';
316
+ /**
317
+ * Serve a Scalar API reference and the document JSON. `true` takes every
318
+ * default — the page at `/reference`, the document at
319
+ * `/reference/openapi.json`.
320
+ *
321
+ * @defaultValue off
322
+ */
323
+ reference?: boolean | ScalarReferenceOptions;
324
+ /**
325
+ * Answer CORS preflights and stamp `Access-Control-*` headers, with the
326
+ * policy derived from the document: a path's declared operations are its
327
+ * allowed methods, its `in: header` parameters and security schemes are its
328
+ * allowed request headers, its Response Objects' `headers` are what it
329
+ * exposes.
330
+ *
331
+ * `origin` is the one field that is never derived and so is required — a
332
+ * document says where an API lives, not who may call it.
333
+ *
334
+ * Rejections are stamped too. An unstamped `400` reaches a browser as an
335
+ * opaque CORS error rather than the violations it is carrying.
336
+ *
337
+ * @defaultValue off
338
+ */
339
+ cors?: OpenApiCorsOptions;
340
+ /**
341
+ * JSON Schema draft the document's schemas are written against. Inferred
342
+ * from the document's `openapi` version, which is almost always right.
343
+ */
344
+ schemaDraft?: SchemaDraft;
345
+ /**
346
+ * Leave a request alone entirely — no matching, no reference endpoint, no
347
+ * rejection. The handler still sees `ctx.openapi`, with
348
+ * `matched === false`.
349
+ */
350
+ skip?: (req: Request) => boolean;
351
+ /**
352
+ * Answer a refusal yourself. Return `undefined` to fall back to the default
353
+ * response for that {@link OpenApiRejection}.
354
+ */
355
+ reject?: (rejection: OpenApiRejection, req: Request) => Response | undefined | Promise<Response | undefined>;
356
+ }
357
+ /** `ctx.openapi` when an operation in the document describes the request. */
358
+ interface OpenApiMatched {
359
+ readonly matched: true;
360
+ /** The document, as passed to `withOpenApi`. */
361
+ readonly document: OpenAPIObject;
362
+ /** Path template that matched, e.g. `'/users/{id}'`. */
363
+ 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. */
367
+ readonly operation: OperationObject;
368
+ readonly operationId: string | undefined;
369
+ /**
370
+ * 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.
373
+ */
374
+ readonly security: SecurityRequirementObject[] | undefined;
375
+ /** Deserialized and (unless turned off) coerced parameters. */
376
+ readonly params: OpenApiParams;
377
+ /**
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.
381
+ */
382
+ readonly body: unknown;
383
+ /** The `content` key that matched the request's content type. */
384
+ readonly mediaType: string | undefined;
385
+ /** `false` when `validate: false` — the request was matched, not checked. */
386
+ readonly validated: boolean;
387
+ }
388
+ /** `ctx.openapi` when the document does not describe the request. */
389
+ interface OpenApiUnmatched {
390
+ readonly matched: false;
391
+ /** Why nothing matched. */
392
+ readonly reason: 'skipped' | 'no_route' | 'no_operation';
393
+ readonly document: OpenAPIObject;
394
+ /** Set when the path matched but the method was not declared under it. */
395
+ readonly route: string | undefined;
396
+ /** The request's method, as sent. */
397
+ readonly method: string;
398
+ readonly operation: undefined;
399
+ readonly operationId: undefined;
400
+ readonly security: undefined;
401
+ readonly params: OpenApiParams;
402
+ readonly body: undefined;
403
+ readonly mediaType: undefined;
404
+ readonly validated: false;
405
+ }
406
+ /**
407
+ * What lands at `ctx.openapi`.
408
+ *
409
+ * With the default `onUnknownRoute`/`onUnknownMethod` of `'reject'` and no
410
+ * `skip`, the handler only ever sees {@link OpenApiMatched} — anything else was
411
+ * already answered with a `404` or a `405`. Narrow with
412
+ * `if (!ctx.openapi.matched)` where those are set to `'pass'`.
413
+ */
414
+ type OpenApiContribution = OpenApiMatched | OpenApiUnmatched;
415
+ //#endregion
416
+ //#region src/with-openapi.d.ts
417
+ /**
418
+ * Middleware that holds an API to its own description.
419
+ *
420
+ * @example Reject anything the document does not describe, and serve the docs.
421
+ * ```ts
422
+ * import { pipeline } from '@supabase/middleware'
423
+ * import { withOpenApi } from '@croutonian/with-openapi'
424
+ * import document from './openapi.json' with { type: 'json' }
425
+ *
426
+ * export default {
427
+ * fetch: pipeline(
428
+ * [withOpenApi({ document, reference: true })],
429
+ * async (_req, ctx) => {
430
+ * if (!ctx.openapi.matched) return new Response(null, { status: 404 })
431
+ * return Response.json({ op: ctx.openapi.operationId })
432
+ * },
433
+ * ),
434
+ * }
435
+ * ```
436
+ *
437
+ * @example Describe and document, but do not enforce.
438
+ * ```ts
439
+ * withOpenApi({
440
+ * document,
441
+ * validate: false,
442
+ * onUnknownRoute: 'pass',
443
+ * reference: { path: '/docs' },
444
+ * })
445
+ * ```
446
+ *
447
+ * @category Middleware
448
+ */
449
+ declare const withOpenApi: Middleware<'openapi', WithOpenApiConfig, Record<never, never>, OpenApiContribution>;
450
+ //#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 };