@croutonian/with-openapi 0.2.0 → 0.3.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
@@ -129,6 +129,16 @@ callback before that response is built:
129
129
  | `unsupported_media_type` | 415 | The body's content type is not in the operation's `content`. |
130
130
  | `validation_failed` | 400 | A parameter or body failed its schema. |
131
131
 
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:
136
+
137
+ ```
138
+ no operation in the API description matches "/nope"
139
+ the pathname "/users/me" is outside basePath "/api/v1"
140
+ ```
141
+
132
142
  The default body:
133
143
 
134
144
  ```json
@@ -213,7 +223,16 @@ handy for customizing one kind and leaving the rest alone.
213
223
  entry in the document:
214
224
 
215
225
  - `GET /reference` — the HTML page
216
- - `GET /reference/openapi.json` — the document, for the page to load
226
+ - `GET /openapi.json` — the document, for the page to load
227
+
228
+ Both defaults are derived from `basePath`, so under `basePath: '/api'` they are
229
+ `/api/reference` and `/api/openapi.json`. A reference outside the mount is
230
+ usually unreachable rather than merely unconventional: a host that routes only
231
+ `/api/*` to this handler can never produce a pathname of `/reference`.
232
+
233
+ The document path is derived from the mount, **not** from `path` — the document
234
+ is the artifact and the page is one view of it, so moving the page to `/docs`
235
+ leaves the document where it was.
217
236
 
218
237
  ```ts
219
238
  withOpenApi({
@@ -238,11 +257,45 @@ entirely:
238
257
 
239
258
  ```ts
240
259
  reference: {
241
- html: ({ documentPath }) => myOwnPage(documentPath)
260
+ html: ({ documentPath, documentUrl }) => myOwnPage(documentUrl)
242
261
  }
243
262
  ```
244
263
 
245
- The reference paths are absolute they are **not** relative to `basePath`.
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`:
266
+
267
+ ```ts
268
+ withOpenApi({ document, basePath: '/api/v1', reference: { path: '/docs' } })
269
+ // -> /docs, not /api/v1/docs
270
+ ```
271
+
272
+ Only the default is derived from the mount.
273
+
274
+ ### Behind a gateway that rewrites the path
275
+
276
+ `documentPath` is matched against the pathname this middleware is handed.
277
+ `documentUrl` is what the page tells the browser to fetch. They default to the
278
+ same string, which is right until something rewrites the path in front of you —
279
+ and then no single value works: one spelling never serves the JSON, the other
280
+ renders a page that loads and immediately reports that it could not load the
281
+ document.
282
+
283
+ Supabase Edge Functions is that case by default. The platform routes on
284
+ `/functions/v1/<fn>/...`, strips `/functions/v1`, and hands the worker
285
+ `/<fn>/...`:
286
+
287
+ ```ts
288
+ withOpenApi({
289
+ document,
290
+ // What the worker sees — not the public URL, and not `servers[0].url`.
291
+ basePath: '/api',
292
+ reference: {
293
+ path: '/api/reference',
294
+ documentPath: '/api/openapi.json', // where this middleware serves it
295
+ documentUrl: '/functions/v1/api/openapi.json', // where a browser fetches it
296
+ },
297
+ })
298
+ ```
246
299
 
247
300
  ## Parameters
248
301
 
@@ -451,7 +504,7 @@ What it configures, and why each is needed:
451
504
  | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
452
505
  | 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
506
  | 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. |
507
+ | 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
508
  | The **JSR package** linked to this repository | Same, on the JSR side. |
456
509
 
457
510
  To do it by hand instead, the same steps are in the comments at the top of
package/dist/index.d.ts CHANGED
@@ -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
  */
@@ -208,6 +239,13 @@ interface OpenApiRejection {
208
239
  readonly method: string;
209
240
  /** The request's pathname, before `basePath` is stripped. */
210
241
  readonly pathname: string;
242
+ /**
243
+ * A more specific explanation than the kind's stock wording, when there is
244
+ * one to give. `route_not_found` uses it to say whether the pathname missed
245
+ * `basePath` or matched no template under it, which are the same status and
246
+ * very different mistakes.
247
+ */
248
+ readonly message?: string;
211
249
  /** Path template that matched, when one did. */
212
250
  readonly route?: string;
213
251
  /** Methods the route does declare. Set on `method_not_allowed`. */
@@ -316,7 +354,7 @@ interface WithOpenApiConfig {
316
354
  /**
317
355
  * Serve a Scalar API reference and the document JSON. `true` takes every
318
356
  * default — the page at `/reference`, the document at
319
- * `/reference/openapi.json`.
357
+ * `/openapi.json`.
320
358
  *
321
359
  * @defaultValue off
322
360
  */
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
  }, {
@@ -1246,7 +1249,7 @@ const withOpenApi = defineMiddleware({
1246
1249
  const onUnknownMethod = config.onUnknownMethod ?? "reject";
1247
1250
  const resolve = (schema) => resolveSchema(document, schema);
1248
1251
  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);
1252
+ const reference = config.reference === void 0 || config.reference === false ? void 0 : resolveReference(document, config.reference === true ? {} : config.reference, basePath);
1250
1253
  const documentJson = reference === void 0 ? void 0 : JSON.stringify(document);
1251
1254
  const cors = config.cors === void 0 ? void 0 : createCorsPolicy(document, routes, config.cors);
1252
1255
  /**
@@ -1297,6 +1300,7 @@ const withOpenApi = defineMiddleware({
1297
1300
  status: 404,
1298
1301
  method: req.method,
1299
1302
  pathname: url.pathname,
1303
+ 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
1304
  violations: []
1301
1305
  });
1302
1306
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@croutonian/with-openapi",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",