@minisylar/express-typed-router 1.8.2 → 1.9.1
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 +45 -2
- package/dist/schema-router.cjs +5 -5
- package/dist/schema-router.d.cts +36 -7
- package/dist/schema-router.d.mts +36 -7
- package/dist/schema-router.mjs +5 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -195,7 +195,7 @@ app.use(
|
|
|
195
195
|
|
|
196
196
|
- route paths, methods, and path parameters
|
|
197
197
|
- query and body schemas (from `querySchema` / `bodySchema`)
|
|
198
|
-
- response
|
|
198
|
+
- **response schemas** — inferred from real traffic (see [Response schemas](#response-schemas-from-live-traffic) below)
|
|
199
199
|
- tags and summaries — inferred from route paths, or set manually
|
|
200
200
|
|
|
201
201
|
**Custom route metadata:**
|
|
@@ -225,6 +225,49 @@ app.use("/docs", api.docs({ title: "My API", version: "1.0.0" }));
|
|
|
225
225
|
// Discovers all sub-routers and merges routes with correct prefixes
|
|
226
226
|
```
|
|
227
227
|
|
|
228
|
+
### Response schemas from live traffic
|
|
229
|
+
|
|
230
|
+
You don't have to declare what your routes return. The library **observes real responses** (`res.json` / `res.send`), **infers a JSON Schema** from them, and **merges across samples** — so it learns field types, which fields are nullable, and which are optional. This drives both the docs UI and `openapi-typescript` (real response types instead of `unknown`).
|
|
231
|
+
|
|
232
|
+
By default this runs in **redacted** mode: only the shape is kept, never the values — so no real user data is ever stored or shown.
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
// A few responses like { id: 1, email: "a@b.com", nickname: "Al" } and
|
|
236
|
+
// { id: 2, email: null } are observed and merged into:
|
|
237
|
+
{
|
|
238
|
+
type: "object",
|
|
239
|
+
properties: {
|
|
240
|
+
id: { type: "integer" },
|
|
241
|
+
email: { type: ["string", "null"] }, // nullable — seen as null sometimes
|
|
242
|
+
nickname: { type: "string" } // optional — missing in some responses
|
|
243
|
+
},
|
|
244
|
+
required: ["id", "email"] // nickname excluded
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Control it with `sampleResponses`:
|
|
249
|
+
|
|
250
|
+
| Value | Behavior |
|
|
251
|
+
|---|---|
|
|
252
|
+
| `true` _(default)_ | **Redacted** — infer schema only. Real values discarded at capture time. Safe to expose. |
|
|
253
|
+
| `"live"` | Infer schema **and** attach one real captured response as an example. ⚠️ Examples contain actual data — use only for trusted/internal docs. |
|
|
254
|
+
| `false` | Don't observe responses at all. |
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
// Safe default — schema only, no real data
|
|
258
|
+
app.use("/docs", api.docs({ title: "My API", version: "1.0.0" }));
|
|
259
|
+
|
|
260
|
+
// Show real example payloads (internal docs only)
|
|
261
|
+
app.use("/docs", api.docs({ title: "My API", version: "1.0.0", sampleResponses: "live" }));
|
|
262
|
+
|
|
263
|
+
// Disable entirely
|
|
264
|
+
app.use("/docs", api.docs({ title: "My API", version: "1.0.0", sampleResponses: false }));
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
> Exclude an individual sensitive route from docs with `hidden: true` in its route options — works in any mode.
|
|
268
|
+
|
|
269
|
+
> **Note:** observed schemas live in memory and fill in as traffic flows (they reset on server restart). The live `/docs/openapi.json` endpoint always reflects the latest — so to get inferred **response** types in your generated client, point `openapi-typescript` at the running URL after exercising your routes. The `specOutputPath` file is written once at startup (before traffic), so it carries request schemas only.
|
|
270
|
+
|
|
228
271
|
### Schema library support for docs
|
|
229
272
|
|
|
230
273
|
All validators work for **request validation**. For **OpenAPI schema generation** (showing field names and types in the spec), some libraries need an extra converter package installed in your project. This library auto-detects them at runtime — install the one you need and it just works, no config required.
|
|
@@ -240,7 +283,7 @@ All validators work for **request validation**. For **OpenAPI schema generation*
|
|
|
240
283
|
| Joi | ✅ | ⚠️ | not supported — no official JSON Schema converter |
|
|
241
284
|
| Decoders / ts.data.json / unhoax | ✅ | ⚠️ | not supported — no schema introspection |
|
|
242
285
|
|
|
243
|
-
> **⚠️ Partial docs** means routes still appear in the spec with paths, methods, and
|
|
286
|
+
> **⚠️ Partial docs** means routes still appear in the spec with paths, methods, and inferred response schemas — only the request body/query field shapes are missing.
|
|
244
287
|
|
|
245
288
|
---
|
|
246
289
|
|
package/dist/schema-router.cjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},s=(n,r,a)=>(a=n==null?{}:e(i(n)),o(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));let c=require("express");c=s(c,1);let l=require("@standard-schema/utils");function u(e,t){let n=e;if(n&&n[`~standard`]&&typeof n[`~standard`].validate==`function`){let e=n[`~standard`].validate(t);if(e instanceof Promise)throw TypeError(`Async schema validation is not supported by parseSchema`);if(e.issues)throw new l.SchemaError(e.issues);return e.value}throw TypeError(`Unsupported schema shape for parseSchema`)}function d(e,t){let n=e;if(n&&n[`~standard`]&&typeof n[`~standard`].validate==`function`)return n[`~standard`].validate(t);if(n&&typeof n.safeParse==`function`)return n.safeParse(t);if(n&&typeof n.parse==`function`)try{return{value:n.parse(t)}}catch(e){return{issues:[{message:e?.message??String(e)}]}}if(n&&typeof n.validate==`function`){let e=n.validate(t);return e&&e.then&&typeof e.then==`function`?e.then(e=>e.error?{issues:[{message:e.error.message}]}:e.issues?{issues:e.issues}:{value:e.value??e}):e&&e.error?{issues:[{message:e.error.message}]}:e&&e.issues?{issues:e.issues}:{value:e.value??e}}return{issues:[{message:`Unsupported schema shape`}]}}function f(e){return typeof e==`object`&&!!e&&`issues`in e&&Array.isArray(e.issues)}const p=Function(`m`,`return import(m)`);let m,h;async function g(e){try{m??=await p(`module`),h??=await p(`url`);let t=globalThis.process?.cwd?.()??``,n=m.createRequire(h.pathToFileURL(t+`/`).href).resolve(e);return p(h.pathToFileURL(n).href)}catch{return p(e)}}const _=new WeakMap;function v(e){return e.replace(/:([
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},s=(n,r,a)=>(a=n==null?{}:e(i(n)),o(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));let c=require("express");c=s(c,1);let l=require("@standard-schema/utils");function u(e,t){let n=e;if(n&&n[`~standard`]&&typeof n[`~standard`].validate==`function`){let e=n[`~standard`].validate(t);if(e instanceof Promise)throw TypeError(`Async schema validation is not supported by parseSchema`);if(e.issues)throw new l.SchemaError(e.issues);return e.value}throw TypeError(`Unsupported schema shape for parseSchema`)}function d(e,t){let n=e;if(n&&n[`~standard`]&&typeof n[`~standard`].validate==`function`)return n[`~standard`].validate(t);if(n&&typeof n.safeParse==`function`)return n.safeParse(t);if(n&&typeof n.parse==`function`)try{return{value:n.parse(t)}}catch(e){return{issues:[{message:e?.message??String(e)}]}}if(n&&typeof n.validate==`function`){let e=n.validate(t);return e&&e.then&&typeof e.then==`function`?e.then(e=>e.error?{issues:[{message:e.error.message}]}:e.issues?{issues:e.issues}:{value:e.value??e}):e&&e.error?{issues:[{message:e.error.message}]}:e&&e.issues?{issues:e.issues}:{value:e.value??e}}return{issues:[{message:`Unsupported schema shape`}]}}function f(e){return typeof e==`object`&&!!e&&`issues`in e&&Array.isArray(e.issues)}const p=Function(`m`,`return import(m)`);let m,h;async function g(e){try{m??=await p(`module`),h??=await p(`url`);let t=globalThis.process?.cwd?.()??``,n=m.createRequire(h.pathToFileURL(t+`/`).href).resolve(e);return p(h.pathToFileURL(n).href)}catch{return p(e)}}const _=new WeakMap;function v(e){return e.replace(/\{([^{}]*)\}/g,`$1`).replace(/:([A-Za-z0-9_]+)(?:\([^)]*\))?[?+*]?/g,`{$1}`).replace(/\/{2,}/g,`/`)}function y(e){return[...e.replace(/\{([^{}]*)\}/g,`$1`).matchAll(/:([A-Za-z0-9_]+)/g)].map(e=>e[1])}function b(e){return e.replace(/[{}]/g,``).split(`/`).filter(Boolean).find(e=>!e.startsWith(`:`)&&!e.startsWith(`*`))??`default`}function x(e,t){let n=t.replace(/[{}]/g,``).split(`/`).filter(e=>e&&!e.startsWith(`:`)&&!e.startsWith(`*`)),r=n[n.length-1]??`resource`;return`${{get:`Get`,post:`Create`,put:`Update`,patch:`Patch`,delete:`Delete`,head:`Head`,options:`Options`}[e]??e} ${r}`}async function S(e){let t=_.get(e);if(t)return t;let n=await C(e);return _.set(e,n),n}async function C(e){if(typeof e.toJsonSchema==`function`)try{return e.toJsonSchema()}catch{}let t=e[`~standard`]?.vendor;if(t===`zod`){try{let t=await g(`zod`);if(typeof t.toJSONSchema==`function`)return t.toJSONSchema(e)}catch{}try{let t=await g(`zod-to-json-schema`),n=t.zodToJsonSchema??t.default?.zodToJsonSchema;if(typeof n==`function`)return n(e)}catch{}}if(t===`valibot`)try{let t=await g(`@valibot/to-json-schema`),n=t.toJsonSchema??t.default?.toJsonSchema;if(typeof n==`function`)return n(e)}catch{}if(t===`effect`)try{let t=await g(`effect`),n=t.JSONSchema?.make??t.default?.JSONSchema?.make;if(typeof n==`function`)return n(e)}catch{}return{}}function w(e){if(e==null)return{type:`null`};if(Array.isArray(e)){if(e.length===0)return{type:`array`,items:{}};let t=w(e[0]);for(let n=1;n<e.length;n++)t=D(t,w(e[n]));return{type:`array`,items:t}}switch(typeof e){case`string`:return{type:`string`};case`boolean`:return{type:`boolean`};case`number`:return{type:Number.isInteger(e)?`integer`:`number`};case`object`:{let t={},n=[];for(let[r,i]of Object.entries(e))t[r]=w(i),n.push(r);let r={type:`object`,properties:t};return n.length&&(r.required=n),r}default:return{}}}function T(e){return!e||e.type===void 0?new Set:new Set(Array.isArray(e.type)?e.type:[e.type])}function E(e){e.has(`integer`)&&e.has(`number`)&&e.delete(`integer`);let t=[...e];if(t.length!==0)return t.length===1?t[0]:t}function D(e,t){if(!e||Object.keys(e).length===0)return t??{};if(!t||Object.keys(t).length===0)return e??{};let n=new Set([...T(e),...T(t)]),r={},i=E(n);if(i!==void 0&&(r.type=i),n.has(`object`)&&(e.properties||t.properties)){let n=e.properties??{},i=t.properties??{},a={};for(let e of new Set([...Object.keys(n),...Object.keys(i)]))a[e]=D(n[e],i[e]);r.properties=a;let o=e.required??[],s=t.required??[],c=o.filter(e=>s.includes(e));c.length&&(r.required=c)}if(n.has(`array`)){let n=D(e.items,t.items);n&&Object.keys(n).length&&(r.items=n)}return r}function O(e){return e.replace(/&/g,`&`).replace(/</g,`<`).replace(/>/g,`>`).replace(/"/g,`"`)}const k=`https://cdn.jsdelivr.net/npm/@scalar/api-reference`;function A(e,t,n){return`<!doctype html>
|
|
2
2
|
<html>
|
|
3
3
|
<head>
|
|
4
|
-
<title>${
|
|
4
|
+
<title>${O(e)}</title>
|
|
5
5
|
<meta charset="utf-8" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
7
|
</head>
|
|
8
8
|
<body>
|
|
9
|
-
<script id="api-reference" data-url="${
|
|
10
|
-
<script src="${
|
|
9
|
+
<script id="api-reference" data-url="${O(t)}"><\/script>
|
|
10
|
+
<script src="${O(n)}"><\/script>
|
|
11
11
|
</body>
|
|
12
|
-
</html>`}async function
|
|
12
|
+
</html>`}async function j(e,t){let n=Object.create(null);for(let t of e){if(t.method===`all`||t.hidden)continue;let e=v(t.path);n[e]||(n[e]={});let r=y(t.path).map(e=>({name:e,in:`path`,required:!0,schema:{type:`string`}}));if(t.querySchema){let e=await S(t.querySchema),n=e.properties??{},i=e.required??[];for(let[e,t]of Object.entries(n))r.push({name:e,in:`query`,required:i.includes(e),schema:t})}let i={summary:t.summary??x(t.method,t.path),tags:t.tags??[b(t.path)],parameters:r};t.description&&(i.description=t.description),t.deprecated&&(i.deprecated=!0),t.bodySchema&&(i.requestBody={required:!0,content:{"application/json":{schema:await S(t.bodySchema)}}});let a={};if(t.responseSamples.size>0)for(let[e,n]of t.responseSamples){let t={schema:n.schema};n.example!==void 0&&(t.example=n.example),a[String(e)]={description:e<400?`Success`:`Error`,content:{"application/json":t}}}else a[200]={description:`Success`};i.responses=a,n[e][t.method]=i}return{openapi:`3.1.0`,info:{title:t.title??`API`,version:t.version??`1.0.0`,...t.description?{description:t.description}:{}},...t.servers?{servers:t.servers}:{},paths:n}}const M=new WeakMap;var N=class e{router;routes=[];mountedRouters=[];sampleMode=`off`;scheduleSpecWrite;constructor(){this.router=c.default.Router(),M.set(this.router,this)}useMiddleware(e){return this.router.use(e),this}getRouter(){return this.router}use(t,...n){let r=typeof t==`string`,i=r?t:``,a=(r?n:[t,...n]).map(t=>{if(t instanceof e)return this.trackMounted(i,t),t.getRouter();let n=M.get(t);return n&&this.trackMounted(i,n),t});return r?this.router.use(t,...a):this.router.use(...a),this}mount(e,t){if(typeof e==`string`){let n=t;this.router.use(e,n.getRouter()),this.trackMounted(e,n)}else this.router.use(e.getRouter()),this.trackMounted(``,e);return this}getRouteMetadata(){let e=this.mountedRouters.flatMap(({prefix:e,router:t})=>t.getRouteMetadata().map(t=>({...t,path:e+t.path})));return[...this.routes,...e]}enableSampling(e=`redacted`,t,n=new Set){if(!n.has(this)){n.add(this),this.sampleMode=e,this.scheduleSpecWrite=t;for(let{router:r}of this.mountedRouters)r.enableSampling(e,t,n)}}trackMounted(e,t){this.mountedRouters.some(n=>n.router===t&&n.prefix===e)||(this.mountedRouters.push({prefix:e,router:t}),this.sampleMode!==`off`&&t.enableSampling(this.sampleMode,this.scheduleSpecWrite))}docs(e={}){let t=c.default.Router(),n;if(e.specOutputPath){let t=e.specOutputPath,r=async()=>{try{let n=await j(this.getRouteMetadata(),e);await(await p(`fs/promises`)).writeFile(t,JSON.stringify(n,null,2),`utf8`)}catch{}},i;n=()=>{i&&clearTimeout(i),i=setTimeout(r,300),i.unref?.()},setImmediate(r)}return e.sampleResponses!==!1&&this.enableSampling(e.sampleResponses===`live`?`live`:`redacted`,n),t.get(`/openapi.json`,async(t,n)=>{try{let t=await j(this.getRouteMetadata(),e);n.json(t)}catch(e){n.status(500).json({error:`Failed to generate spec`,details:String(e)})}}),t.get(`/`,(t,n)=>{let r=`${t.baseUrl}/openapi.json`,i=e.cdnUrl??k;n.setHeader(`Content-Type`,`text/html; charset=utf-8`),n.send(A(e.title??`API`,r,i))}),t}get(e,t,n){return this.registerRoute(`get`,e,t,n)}post(e,t,n){return this.registerRoute(`post`,e,t,n)}put(e,t,n){return this.registerRoute(`put`,e,t,n)}patch(e,t,n){return this.registerRoute(`patch`,e,t,n)}delete(e,t,n){return this.registerRoute(`delete`,e,t,n)}options(e,t,n){return this.registerRoute(`options`,e,t,n)}head(e,t,n){return this.registerRoute(`head`,e,t,n)}all(e,t,n){return this.registerRoute(`all`,e,t,n)}registerRoute(e,t,n,r){let i=[],a={method:e,path:t,responseSamples:new Map};if(this.routes.push(a),typeof n==`object`){let e=n;a.bodySchema=e.bodySchema,a.querySchema=e.querySchema,a.tags=e.tags,a.description=e.description,a.summary=e.summary,a.deprecated=e.deprecated,a.responseSchema=e.responseSchema,a.hidden=e.hidden,e.middleware&&i.push(...e.middleware),e.bodySchema&&i.push(this.createBodyValidationMiddleware(e.bodySchema)),e.querySchema&&i.push(this.createQueryValidationMiddleware(e.querySchema)),i.push(r)}else i.push(n);let o=0,s=(e,t)=>{let n=e.statusCode,r=w(t),i=a.responseSamples.get(n),o=i?D(i.schema,r):r,s=this.sampleMode===`live`?i?.example??t:void 0,c=!i||JSON.stringify(i.schema)!==JSON.stringify(o);a.responseSamples.set(n,{schema:o,example:s}),c&&this.scheduleSpecWrite?.()};return this.router[e](t,(e,t,n)=>{if(this.sampleMode===`off`||a.hidden||o>=50||a.responseSamples.size>=10){n();return}o++;let r=t.json;t.json=function(e){return s(t,e),r.call(this,e)};let i=t.send;t.send=function(e){return typeof e==`object`&&e&&!Buffer.isBuffer(e)&&s(t,e),i.call(this,e)},n()},...i),this}createBodyValidationMiddleware(e){return async(t,n,r)=>{try{let i=d(e,t.body),a=i&&typeof i.then==`function`?await i:i;if(a&&`issues`in a&&a.issues){n.status(400).json({error:`Validation failed`,details:a.errors||a.issues});return}t.body=a&&`value`in a?a.value:a,r()}catch(e){f(e)?n.status(400).json({error:`Validation failed`,details:e.errors||e.issues}):r(e)}}}createQueryValidationMiddleware(e){return async(t,n,r)=>{try{let i=d(e,t.query),a=i&&typeof i.then==`function`?await i:i;if(a&&`issues`in a&&a.issues){n.status(400).json({error:`Validation failed`,details:a.errors||a.issues});return}let o=a&&`value`in a?a.value:a;Object.defineProperty(t,"query",{value:o,writable:!1,enumerable:!0,configurable:!0}),r()}catch(e){f(e)?n.status(400).json({error:`Validation failed`,details:e.errors||e.issues}):r(e)}}}};function P(){return new N}function F(e){let t=new N;return e?.errorHandler&&t.getRouter().use(e.errorHandler),t}function I(...e){let t=new N;for(let n of e)t=t.useMiddleware(n);return t}function L(e,t={}){let n=(Array.isArray(e)?e:[e]).map(e=>`prefix`in e?e:{prefix:``,router:e});if(t.sampleResponses!==!1){let e=t.sampleResponses===`live`?`live`:`redacted`;for(let{router:t}of n)t.enableSampling(e)}let r=c.default.Router();return r.get(`/openapi.json`,async(e,r)=>{try{let e=await j(n.flatMap(({prefix:e,router:t})=>t.getRouteMetadata().map(t=>({...t,path:e+t.path}))),t);r.json(e)}catch(e){r.status(500).json({error:`Failed to generate spec`,details:String(e)})}}),r.get(`/`,(e,n)=>{let r=`${e.baseUrl}/openapi.json`,i=t.cdnUrl??k;n.setHeader(`Content-Type`,`text/html; charset=utf-8`),n.send(A(t.title??`API`,r,i))}),r}exports.TypedRouter=N,exports.createDocs=L,exports.createTypedRouter=P,exports.createTypedRouterWithConfig=F,exports.createTypedRouterWithMiddleware=I,exports.isSchemaError=f,exports.parseSchema=u,exports.safeParseSchema=d;
|
package/dist/schema-router.d.cts
CHANGED
|
@@ -148,6 +148,10 @@ interface DocsOptions {
|
|
|
148
148
|
* Enables `openapi-typescript --watch` in development — the tool watches
|
|
149
149
|
* the file and regenerates your client types automatically as routes change.
|
|
150
150
|
*
|
|
151
|
+
* The file is written once at startup, so it contains route **schemas only**
|
|
152
|
+
* — never captured response examples. This keeps real response data (which
|
|
153
|
+
* may include PII) out of any file you might commit or share.
|
|
154
|
+
*
|
|
151
155
|
* @example
|
|
152
156
|
* // docs options
|
|
153
157
|
* { specOutputPath: './openapi.json' }
|
|
@@ -156,6 +160,27 @@ interface DocsOptions {
|
|
|
156
160
|
* // npx openapi-typescript ./openapi.json -o ./src/client.d.ts --watch
|
|
157
161
|
*/
|
|
158
162
|
specOutputPath?: string;
|
|
163
|
+
/**
|
|
164
|
+
* Learn response shapes from live traffic and add them to the docs. The
|
|
165
|
+
* library observes real responses and **infers a JSON Schema** from them
|
|
166
|
+
* (field names, types, nullability, required vs optional) — so the docs and
|
|
167
|
+
* generated client types reflect what your API actually returns.
|
|
168
|
+
*
|
|
169
|
+
* Modes:
|
|
170
|
+
* - `true` (default) — **redacted**: infer the schema only. Real values are
|
|
171
|
+
* discarded at capture time, so no user data is ever stored or shown. Safe
|
|
172
|
+
* to expose.
|
|
173
|
+
* - `"live"` — infer the schema **and** attach a real captured response as an
|
|
174
|
+
* example. ⚠️ Examples contain actual data (emails, tokens, IDs). Only use
|
|
175
|
+
* for trusted/internal docs.
|
|
176
|
+
* - `false` — don't observe responses at all.
|
|
177
|
+
*
|
|
178
|
+
* Use the per-route `hidden: true` option to exclude individual sensitive
|
|
179
|
+
* routes regardless of mode.
|
|
180
|
+
*
|
|
181
|
+
* @default true
|
|
182
|
+
*/
|
|
183
|
+
sampleResponses?: boolean | "live";
|
|
159
184
|
}
|
|
160
185
|
interface RouteMetadata {
|
|
161
186
|
method: HttpMethod;
|
|
@@ -168,7 +193,10 @@ interface RouteMetadata {
|
|
|
168
193
|
deprecated?: boolean;
|
|
169
194
|
responseSchema?: AnyStandardSchema;
|
|
170
195
|
hidden?: boolean;
|
|
171
|
-
responseSamples: Map<number,
|
|
196
|
+
responseSamples: Map<number, {
|
|
197
|
+
schema: Record<string, any>;
|
|
198
|
+
example?: unknown;
|
|
199
|
+
}>;
|
|
172
200
|
}
|
|
173
201
|
/**
|
|
174
202
|
* Extra properties that middleware has added to the Express `req` object.
|
|
@@ -198,7 +226,8 @@ declare class TypedRouter<Req extends Record<string, any> = AdditionalReqProps,
|
|
|
198
226
|
private router;
|
|
199
227
|
private routes;
|
|
200
228
|
private mountedRouters;
|
|
201
|
-
private
|
|
229
|
+
private sampleMode;
|
|
230
|
+
private scheduleSpecWrite?;
|
|
202
231
|
constructor();
|
|
203
232
|
/**
|
|
204
233
|
* Add typed middleware that extends the request with additional properties
|
|
@@ -260,15 +289,15 @@ declare class TypedRouter<Req extends Record<string, any> = AdditionalReqProps,
|
|
|
260
289
|
*/
|
|
261
290
|
getRouteMetadata(): RouteMetadata[];
|
|
262
291
|
/**
|
|
263
|
-
* Turn on response
|
|
264
|
-
* it. Called by .docs() and createDocs() so
|
|
265
|
-
*
|
|
292
|
+
* Turn on response observation for this router and every router mounted under
|
|
293
|
+
* it. Called by .docs() and createDocs() so it happens only when docs are
|
|
294
|
+
* actually generated. The visited set guards against mount cycles.
|
|
266
295
|
* @internal Public only so createDocs() can reach it; not part of the API.
|
|
267
296
|
*/
|
|
268
|
-
enableSampling(visited?: Set<TypedRouter<any, any>>): void;
|
|
297
|
+
enableSampling(mode?: "redacted" | "live", writer?: () => void, visited?: Set<TypedRouter<any, any>>): void;
|
|
269
298
|
/**
|
|
270
299
|
* Record a sub-router for docs, de-duplicating identical (prefix, router)
|
|
271
|
-
* pairs and propagating
|
|
300
|
+
* pairs and propagating the sample mode if docs were already requested.
|
|
272
301
|
*/
|
|
273
302
|
private trackMounted;
|
|
274
303
|
/**
|
package/dist/schema-router.d.mts
CHANGED
|
@@ -148,6 +148,10 @@ interface DocsOptions {
|
|
|
148
148
|
* Enables `openapi-typescript --watch` in development — the tool watches
|
|
149
149
|
* the file and regenerates your client types automatically as routes change.
|
|
150
150
|
*
|
|
151
|
+
* The file is written once at startup, so it contains route **schemas only**
|
|
152
|
+
* — never captured response examples. This keeps real response data (which
|
|
153
|
+
* may include PII) out of any file you might commit or share.
|
|
154
|
+
*
|
|
151
155
|
* @example
|
|
152
156
|
* // docs options
|
|
153
157
|
* { specOutputPath: './openapi.json' }
|
|
@@ -156,6 +160,27 @@ interface DocsOptions {
|
|
|
156
160
|
* // npx openapi-typescript ./openapi.json -o ./src/client.d.ts --watch
|
|
157
161
|
*/
|
|
158
162
|
specOutputPath?: string;
|
|
163
|
+
/**
|
|
164
|
+
* Learn response shapes from live traffic and add them to the docs. The
|
|
165
|
+
* library observes real responses and **infers a JSON Schema** from them
|
|
166
|
+
* (field names, types, nullability, required vs optional) — so the docs and
|
|
167
|
+
* generated client types reflect what your API actually returns.
|
|
168
|
+
*
|
|
169
|
+
* Modes:
|
|
170
|
+
* - `true` (default) — **redacted**: infer the schema only. Real values are
|
|
171
|
+
* discarded at capture time, so no user data is ever stored or shown. Safe
|
|
172
|
+
* to expose.
|
|
173
|
+
* - `"live"` — infer the schema **and** attach a real captured response as an
|
|
174
|
+
* example. ⚠️ Examples contain actual data (emails, tokens, IDs). Only use
|
|
175
|
+
* for trusted/internal docs.
|
|
176
|
+
* - `false` — don't observe responses at all.
|
|
177
|
+
*
|
|
178
|
+
* Use the per-route `hidden: true` option to exclude individual sensitive
|
|
179
|
+
* routes regardless of mode.
|
|
180
|
+
*
|
|
181
|
+
* @default true
|
|
182
|
+
*/
|
|
183
|
+
sampleResponses?: boolean | "live";
|
|
159
184
|
}
|
|
160
185
|
interface RouteMetadata {
|
|
161
186
|
method: HttpMethod;
|
|
@@ -168,7 +193,10 @@ interface RouteMetadata {
|
|
|
168
193
|
deprecated?: boolean;
|
|
169
194
|
responseSchema?: AnyStandardSchema;
|
|
170
195
|
hidden?: boolean;
|
|
171
|
-
responseSamples: Map<number,
|
|
196
|
+
responseSamples: Map<number, {
|
|
197
|
+
schema: Record<string, any>;
|
|
198
|
+
example?: unknown;
|
|
199
|
+
}>;
|
|
172
200
|
}
|
|
173
201
|
/**
|
|
174
202
|
* Extra properties that middleware has added to the Express `req` object.
|
|
@@ -198,7 +226,8 @@ declare class TypedRouter<Req extends Record<string, any> = AdditionalReqProps,
|
|
|
198
226
|
private router;
|
|
199
227
|
private routes;
|
|
200
228
|
private mountedRouters;
|
|
201
|
-
private
|
|
229
|
+
private sampleMode;
|
|
230
|
+
private scheduleSpecWrite?;
|
|
202
231
|
constructor();
|
|
203
232
|
/**
|
|
204
233
|
* Add typed middleware that extends the request with additional properties
|
|
@@ -260,15 +289,15 @@ declare class TypedRouter<Req extends Record<string, any> = AdditionalReqProps,
|
|
|
260
289
|
*/
|
|
261
290
|
getRouteMetadata(): RouteMetadata[];
|
|
262
291
|
/**
|
|
263
|
-
* Turn on response
|
|
264
|
-
* it. Called by .docs() and createDocs() so
|
|
265
|
-
*
|
|
292
|
+
* Turn on response observation for this router and every router mounted under
|
|
293
|
+
* it. Called by .docs() and createDocs() so it happens only when docs are
|
|
294
|
+
* actually generated. The visited set guards against mount cycles.
|
|
266
295
|
* @internal Public only so createDocs() can reach it; not part of the API.
|
|
267
296
|
*/
|
|
268
|
-
enableSampling(visited?: Set<TypedRouter<any, any>>): void;
|
|
297
|
+
enableSampling(mode?: "redacted" | "live", writer?: () => void, visited?: Set<TypedRouter<any, any>>): void;
|
|
269
298
|
/**
|
|
270
299
|
* Record a sub-router for docs, de-duplicating identical (prefix, router)
|
|
271
|
-
* pairs and propagating
|
|
300
|
+
* pairs and propagating the sample mode if docs were already requested.
|
|
272
301
|
*/
|
|
273
302
|
private trackMounted;
|
|
274
303
|
/**
|
package/dist/schema-router.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import e from"express";import{SchemaError as t}from"@standard-schema/utils";function n(e,n){let r=e;if(r&&r[`~standard`]&&typeof r[`~standard`].validate==`function`){let e=r[`~standard`].validate(n);if(e instanceof Promise)throw TypeError(`Async schema validation is not supported by parseSchema`);if(e.issues)throw new t(e.issues);return e.value}throw TypeError(`Unsupported schema shape for parseSchema`)}function r(e,t){let n=e;if(n&&n[`~standard`]&&typeof n[`~standard`].validate==`function`)return n[`~standard`].validate(t);if(n&&typeof n.safeParse==`function`)return n.safeParse(t);if(n&&typeof n.parse==`function`)try{return{value:n.parse(t)}}catch(e){return{issues:[{message:e?.message??String(e)}]}}if(n&&typeof n.validate==`function`){let e=n.validate(t);return e&&e.then&&typeof e.then==`function`?e.then(e=>e.error?{issues:[{message:e.error.message}]}:e.issues?{issues:e.issues}:{value:e.value??e}):e&&e.error?{issues:[{message:e.error.message}]}:e&&e.issues?{issues:e.issues}:{value:e.value??e}}return{issues:[{message:`Unsupported schema shape`}]}}function i(e){return typeof e==`object`&&!!e&&`issues`in e&&Array.isArray(e.issues)}const a=Function(`m`,`return import(m)`);let o,s;async function c(e){try{o??=await a(`module`),s??=await a(`url`);let t=globalThis.process?.cwd?.()??``,n=o.createRequire(s.pathToFileURL(t+`/`).href).resolve(e);return a(s.pathToFileURL(n).href)}catch{return a(e)}}const l=new WeakMap;function u(e){return e.replace(/:([
|
|
1
|
+
import e from"express";import{SchemaError as t}from"@standard-schema/utils";function n(e,n){let r=e;if(r&&r[`~standard`]&&typeof r[`~standard`].validate==`function`){let e=r[`~standard`].validate(n);if(e instanceof Promise)throw TypeError(`Async schema validation is not supported by parseSchema`);if(e.issues)throw new t(e.issues);return e.value}throw TypeError(`Unsupported schema shape for parseSchema`)}function r(e,t){let n=e;if(n&&n[`~standard`]&&typeof n[`~standard`].validate==`function`)return n[`~standard`].validate(t);if(n&&typeof n.safeParse==`function`)return n.safeParse(t);if(n&&typeof n.parse==`function`)try{return{value:n.parse(t)}}catch(e){return{issues:[{message:e?.message??String(e)}]}}if(n&&typeof n.validate==`function`){let e=n.validate(t);return e&&e.then&&typeof e.then==`function`?e.then(e=>e.error?{issues:[{message:e.error.message}]}:e.issues?{issues:e.issues}:{value:e.value??e}):e&&e.error?{issues:[{message:e.error.message}]}:e&&e.issues?{issues:e.issues}:{value:e.value??e}}return{issues:[{message:`Unsupported schema shape`}]}}function i(e){return typeof e==`object`&&!!e&&`issues`in e&&Array.isArray(e.issues)}const a=Function(`m`,`return import(m)`);let o,s;async function c(e){try{o??=await a(`module`),s??=await a(`url`);let t=globalThis.process?.cwd?.()??``,n=o.createRequire(s.pathToFileURL(t+`/`).href).resolve(e);return a(s.pathToFileURL(n).href)}catch{return a(e)}}const l=new WeakMap;function u(e){return e.replace(/\{([^{}]*)\}/g,`$1`).replace(/:([A-Za-z0-9_]+)(?:\([^)]*\))?[?+*]?/g,`{$1}`).replace(/\/{2,}/g,`/`)}function d(e){return[...e.replace(/\{([^{}]*)\}/g,`$1`).matchAll(/:([A-Za-z0-9_]+)/g)].map(e=>e[1])}function f(e){return e.replace(/[{}]/g,``).split(`/`).filter(Boolean).find(e=>!e.startsWith(`:`)&&!e.startsWith(`*`))??`default`}function p(e,t){let n=t.replace(/[{}]/g,``).split(`/`).filter(e=>e&&!e.startsWith(`:`)&&!e.startsWith(`*`)),r=n[n.length-1]??`resource`;return`${{get:`Get`,post:`Create`,put:`Update`,patch:`Patch`,delete:`Delete`,head:`Head`,options:`Options`}[e]??e} ${r}`}async function m(e){let t=l.get(e);if(t)return t;let n=await h(e);return l.set(e,n),n}async function h(e){if(typeof e.toJsonSchema==`function`)try{return e.toJsonSchema()}catch{}let t=e[`~standard`]?.vendor;if(t===`zod`){try{let t=await c(`zod`);if(typeof t.toJSONSchema==`function`)return t.toJSONSchema(e)}catch{}try{let t=await c(`zod-to-json-schema`),n=t.zodToJsonSchema??t.default?.zodToJsonSchema;if(typeof n==`function`)return n(e)}catch{}}if(t===`valibot`)try{let t=await c(`@valibot/to-json-schema`),n=t.toJsonSchema??t.default?.toJsonSchema;if(typeof n==`function`)return n(e)}catch{}if(t===`effect`)try{let t=await c(`effect`),n=t.JSONSchema?.make??t.default?.JSONSchema?.make;if(typeof n==`function`)return n(e)}catch{}return{}}function g(e){if(e==null)return{type:`null`};if(Array.isArray(e)){if(e.length===0)return{type:`array`,items:{}};let t=g(e[0]);for(let n=1;n<e.length;n++)t=y(t,g(e[n]));return{type:`array`,items:t}}switch(typeof e){case`string`:return{type:`string`};case`boolean`:return{type:`boolean`};case`number`:return{type:Number.isInteger(e)?`integer`:`number`};case`object`:{let t={},n=[];for(let[r,i]of Object.entries(e))t[r]=g(i),n.push(r);let r={type:`object`,properties:t};return n.length&&(r.required=n),r}default:return{}}}function _(e){return!e||e.type===void 0?new Set:new Set(Array.isArray(e.type)?e.type:[e.type])}function v(e){e.has(`integer`)&&e.has(`number`)&&e.delete(`integer`);let t=[...e];if(t.length!==0)return t.length===1?t[0]:t}function y(e,t){if(!e||Object.keys(e).length===0)return t??{};if(!t||Object.keys(t).length===0)return e??{};let n=new Set([..._(e),..._(t)]),r={},i=v(n);if(i!==void 0&&(r.type=i),n.has(`object`)&&(e.properties||t.properties)){let n=e.properties??{},i=t.properties??{},a={};for(let e of new Set([...Object.keys(n),...Object.keys(i)]))a[e]=y(n[e],i[e]);r.properties=a;let o=e.required??[],s=t.required??[],c=o.filter(e=>s.includes(e));c.length&&(r.required=c)}if(n.has(`array`)){let n=y(e.items,t.items);n&&Object.keys(n).length&&(r.items=n)}return r}function b(e){return e.replace(/&/g,`&`).replace(/</g,`<`).replace(/>/g,`>`).replace(/"/g,`"`)}const x=`https://cdn.jsdelivr.net/npm/@scalar/api-reference`;function S(e,t,n){return`<!doctype html>
|
|
2
2
|
<html>
|
|
3
3
|
<head>
|
|
4
|
-
<title>${
|
|
4
|
+
<title>${b(e)}</title>
|
|
5
5
|
<meta charset="utf-8" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
7
|
</head>
|
|
8
8
|
<body>
|
|
9
|
-
<script id="api-reference" data-url="${
|
|
10
|
-
<script src="${
|
|
9
|
+
<script id="api-reference" data-url="${b(t)}"><\/script>
|
|
10
|
+
<script src="${b(n)}"><\/script>
|
|
11
11
|
</body>
|
|
12
|
-
</html>`}async function
|
|
12
|
+
</html>`}async function C(e,t){let n=Object.create(null);for(let t of e){if(t.method===`all`||t.hidden)continue;let e=u(t.path);n[e]||(n[e]={});let r=d(t.path).map(e=>({name:e,in:`path`,required:!0,schema:{type:`string`}}));if(t.querySchema){let e=await m(t.querySchema),n=e.properties??{},i=e.required??[];for(let[e,t]of Object.entries(n))r.push({name:e,in:`query`,required:i.includes(e),schema:t})}let i={summary:t.summary??p(t.method,t.path),tags:t.tags??[f(t.path)],parameters:r};t.description&&(i.description=t.description),t.deprecated&&(i.deprecated=!0),t.bodySchema&&(i.requestBody={required:!0,content:{"application/json":{schema:await m(t.bodySchema)}}});let a={};if(t.responseSamples.size>0)for(let[e,n]of t.responseSamples){let t={schema:n.schema};n.example!==void 0&&(t.example=n.example),a[String(e)]={description:e<400?`Success`:`Error`,content:{"application/json":t}}}else a[200]={description:`Success`};i.responses=a,n[e][t.method]=i}return{openapi:`3.1.0`,info:{title:t.title??`API`,version:t.version??`1.0.0`,...t.description?{description:t.description}:{}},...t.servers?{servers:t.servers}:{},paths:n}}const w=new WeakMap;var T=class t{router;routes=[];mountedRouters=[];sampleMode=`off`;scheduleSpecWrite;constructor(){this.router=e.Router(),w.set(this.router,this)}useMiddleware(e){return this.router.use(e),this}getRouter(){return this.router}use(e,...n){let r=typeof e==`string`,i=r?e:``,a=(r?n:[e,...n]).map(e=>{if(e instanceof t)return this.trackMounted(i,e),e.getRouter();let n=w.get(e);return n&&this.trackMounted(i,n),e});return r?this.router.use(e,...a):this.router.use(...a),this}mount(e,t){if(typeof e==`string`){let n=t;this.router.use(e,n.getRouter()),this.trackMounted(e,n)}else this.router.use(e.getRouter()),this.trackMounted(``,e);return this}getRouteMetadata(){let e=this.mountedRouters.flatMap(({prefix:e,router:t})=>t.getRouteMetadata().map(t=>({...t,path:e+t.path})));return[...this.routes,...e]}enableSampling(e=`redacted`,t,n=new Set){if(!n.has(this)){n.add(this),this.sampleMode=e,this.scheduleSpecWrite=t;for(let{router:r}of this.mountedRouters)r.enableSampling(e,t,n)}}trackMounted(e,t){this.mountedRouters.some(n=>n.router===t&&n.prefix===e)||(this.mountedRouters.push({prefix:e,router:t}),this.sampleMode!==`off`&&t.enableSampling(this.sampleMode,this.scheduleSpecWrite))}docs(t={}){let n=e.Router(),r;if(t.specOutputPath){let e=t.specOutputPath,n=async()=>{try{let n=await C(this.getRouteMetadata(),t);await(await a(`fs/promises`)).writeFile(e,JSON.stringify(n,null,2),`utf8`)}catch{}},i;r=()=>{i&&clearTimeout(i),i=setTimeout(n,300),i.unref?.()},setImmediate(n)}return t.sampleResponses!==!1&&this.enableSampling(t.sampleResponses===`live`?`live`:`redacted`,r),n.get(`/openapi.json`,async(e,n)=>{try{let e=await C(this.getRouteMetadata(),t);n.json(e)}catch(e){n.status(500).json({error:`Failed to generate spec`,details:String(e)})}}),n.get(`/`,(e,n)=>{let r=`${e.baseUrl}/openapi.json`,i=t.cdnUrl??x;n.setHeader(`Content-Type`,`text/html; charset=utf-8`),n.send(S(t.title??`API`,r,i))}),n}get(e,t,n){return this.registerRoute(`get`,e,t,n)}post(e,t,n){return this.registerRoute(`post`,e,t,n)}put(e,t,n){return this.registerRoute(`put`,e,t,n)}patch(e,t,n){return this.registerRoute(`patch`,e,t,n)}delete(e,t,n){return this.registerRoute(`delete`,e,t,n)}options(e,t,n){return this.registerRoute(`options`,e,t,n)}head(e,t,n){return this.registerRoute(`head`,e,t,n)}all(e,t,n){return this.registerRoute(`all`,e,t,n)}registerRoute(e,t,n,r){let i=[],a={method:e,path:t,responseSamples:new Map};if(this.routes.push(a),typeof n==`object`){let e=n;a.bodySchema=e.bodySchema,a.querySchema=e.querySchema,a.tags=e.tags,a.description=e.description,a.summary=e.summary,a.deprecated=e.deprecated,a.responseSchema=e.responseSchema,a.hidden=e.hidden,e.middleware&&i.push(...e.middleware),e.bodySchema&&i.push(this.createBodyValidationMiddleware(e.bodySchema)),e.querySchema&&i.push(this.createQueryValidationMiddleware(e.querySchema)),i.push(r)}else i.push(n);let o=0,s=(e,t)=>{let n=e.statusCode,r=g(t),i=a.responseSamples.get(n),o=i?y(i.schema,r):r,s=this.sampleMode===`live`?i?.example??t:void 0,c=!i||JSON.stringify(i.schema)!==JSON.stringify(o);a.responseSamples.set(n,{schema:o,example:s}),c&&this.scheduleSpecWrite?.()};return this.router[e](t,(e,t,n)=>{if(this.sampleMode===`off`||a.hidden||o>=50||a.responseSamples.size>=10){n();return}o++;let r=t.json;t.json=function(e){return s(t,e),r.call(this,e)};let i=t.send;t.send=function(e){return typeof e==`object`&&e&&!Buffer.isBuffer(e)&&s(t,e),i.call(this,e)},n()},...i),this}createBodyValidationMiddleware(e){return async(t,n,a)=>{try{let i=r(e,t.body),o=i&&typeof i.then==`function`?await i:i;if(o&&`issues`in o&&o.issues){n.status(400).json({error:`Validation failed`,details:o.errors||o.issues});return}t.body=o&&`value`in o?o.value:o,a()}catch(e){i(e)?n.status(400).json({error:`Validation failed`,details:e.errors||e.issues}):a(e)}}}createQueryValidationMiddleware(e){return async(t,n,a)=>{try{let i=r(e,t.query),o=i&&typeof i.then==`function`?await i:i;if(o&&`issues`in o&&o.issues){n.status(400).json({error:`Validation failed`,details:o.errors||o.issues});return}let s=o&&`value`in o?o.value:o;Object.defineProperty(t,"query",{value:s,writable:!1,enumerable:!0,configurable:!0}),a()}catch(e){i(e)?n.status(400).json({error:`Validation failed`,details:e.errors||e.issues}):a(e)}}}};function E(){return new T}function D(e){let t=new T;return e?.errorHandler&&t.getRouter().use(e.errorHandler),t}function O(...e){let t=new T;for(let n of e)t=t.useMiddleware(n);return t}function k(t,n={}){let r=(Array.isArray(t)?t:[t]).map(e=>`prefix`in e?e:{prefix:``,router:e});if(n.sampleResponses!==!1){let e=n.sampleResponses===`live`?`live`:`redacted`;for(let{router:t}of r)t.enableSampling(e)}let i=e.Router();return i.get(`/openapi.json`,async(e,t)=>{try{let e=await C(r.flatMap(({prefix:e,router:t})=>t.getRouteMetadata().map(t=>({...t,path:e+t.path}))),n);t.json(e)}catch(e){t.status(500).json({error:`Failed to generate spec`,details:String(e)})}}),i.get(`/`,(e,t)=>{let r=`${e.baseUrl}/openapi.json`,i=n.cdnUrl??x;t.setHeader(`Content-Type`,`text/html; charset=utf-8`),t.send(S(n.title??`API`,r,i))}),i}export{T as TypedRouter,k as createDocs,E as createTypedRouter,D as createTypedRouterWithConfig,O as createTypedRouterWithMiddleware,i as isSchemaError,n as parseSchema,r as safeParseSchema};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@minisylar/express-typed-router",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"description": "A strongly-typed Express router with Zod validation and automatic type inference for params, body, query, and middleware",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/schema-router.cjs",
|