├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .vscode ├── extensions.json └── settings.json ├── LICENSE.md ├── README.md ├── babel.config.json ├── jest.config.js ├── openapi.original.json ├── openapi.transformed.json ├── openapi.transformed.yml ├── openapitools.json ├── package-lock.json ├── package.json ├── src ├── FastifyZod.ts ├── JsonSchema.ts ├── Models.ts ├── Path.ts ├── SpecTransformer.ts ├── __tests__ │ ├── FastifyZod.test.ts │ ├── SpecTransformer.test.ts │ ├── buildJsonSchemas.test.ts │ ├── generate-spec.fixtures.ts │ ├── issues.test.ts │ ├── lisa.openapi.original.fixtures.json │ ├── lisa.openapi.transformed.fixtures.json │ ├── lisa.test.ts │ ├── models.fixtures.ts │ ├── openapi-client.test.ts │ ├── server.fixtures.ts │ ├── server.legacy.fixtures.ts │ └── server.legacy.test.ts ├── index.ts └── util.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: `@typescript-eslint/parser`, 4 | plugins: [ 5 | `@typescript-eslint/eslint-plugin`, 6 | `eslint-plugin-import`, 7 | `eslint-plugin-prettier`, 8 | ], 9 | extends: [ 10 | `plugin:@typescript-eslint/recommended`, 11 | `prettier`, 12 | `plugin:prettier/recommended`, 13 | `plugin:import/errors`, 14 | `plugin:import/warnings`, 15 | `plugin:import/typescript`, 16 | ], 17 | settings: { 18 | "import/parsers": { 19 | "@typescript-eslint/parser": [`.ts`, `.d.ts`], 20 | }, 21 | }, 22 | parserOptions: { 23 | ecmaVersion: 2018, 24 | sourceType: `module`, 25 | }, 26 | rules: { 27 | "prettier/prettier": [1, { trailingComma: `all`, endOfLine: `auto` }], 28 | "object-shorthand": [1, `always`], 29 | quotes: [1, `backtick`], 30 | "@typescript-eslint/no-unused-vars": [1, { argsIgnorePattern: `^_` }], 31 | "@typescript-eslint/naming-convention": [ 32 | `error`, 33 | { 34 | selector: `variableLike`, 35 | format: [`strictCamelCase`, `UPPER_CASE`, `PascalCase`, `snake_case`], 36 | leadingUnderscore: `allow`, 37 | }, 38 | ], 39 | "@typescript-eslint/explicit-function-return-type": [ 40 | 1, 41 | { 42 | allowExpressions: true, 43 | allowTypedFunctionExpressions: true, 44 | }, 45 | ], 46 | "import/order": [ 47 | 1, 48 | { 49 | groups: [ 50 | `builtin`, 51 | `external`, 52 | `internal`, 53 | `parent`, 54 | `sibling`, 55 | `index`, 56 | ], 57 | "newlines-between": `always`, 58 | }, 59 | ], 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | test-openapi-client 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test-openapi-client 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | // List of extensions which should be recommended for users of this workspace. 5 | "recommendations": [ 6 | "dbaeumer.vscode-eslint", 7 | "VisualStudioExptTeam.vscodeintellicode" 8 | ], 9 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 10 | "unwantedRecommendations": [] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.fontLigatures": true, 3 | "editor.suggestSelection": "recentlyUsed", 4 | "editor.tabSize": 2, 5 | "emmet.showAbbreviationSuggestions": false, 6 | "emmet.showExpandedAbbreviation": "never", 7 | "emmet.excludeLanguages": [ 8 | "typescript", 9 | "javascript", 10 | ], 11 | "eslint.packageManager": "npm", 12 | "eslint.workingDirectories": [{ "mode": "auto" }], 13 | "eslint.lintTask.enable": true, 14 | "files.autoSave": "off", 15 | "javascript.updateImportsOnFileMove.enabled": "always", 16 | "[javascript]": { 17 | "editor.formatOnSave": false 18 | }, 19 | "[javascriptreact]": { 20 | "editor.formatOnSave": false 21 | }, 22 | "[typescript]": { 23 | "editor.formatOnSave": false 24 | }, 25 | "[typescriptreact]": { 26 | "editor.formatOnSave": false 27 | }, 28 | "typescript.preferences.importModuleSpecifier": "relative", 29 | "typescript.suggest.paths": true, 30 | "yaml.format.enable": true, 31 | "yaml.validate": false, 32 | "editor.codeActionsOnSave": { 33 | "source.fixAll.eslint": true 34 | }, 35 | "editor.suggest.showSnippets": false, 36 | "editor.suggest.snippetsPreventQuickSuggestions": false, 37 | "editor.snippetSuggestions": "none", 38 | "editor.formatOnSave": true, 39 | "typescript.updateImportsOnFileMove.enabled": "always", 40 | "javascript.referencesCodeLens.enabled": true, 41 | "javascript.referencesCodeLens.showOnAllFunctions": true, 42 | "typescript.implementationsCodeLens.enabled": true, 43 | "typescript.referencesCodeLens.showOnAllFunctions": true, 44 | "typescript.referencesCodeLens.enabled": true, 45 | "references.preferredLocation": "view", 46 | "editor.gotoLocation.multipleTypeDefinitions": "goto" 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Elie Rotenberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastify-zod 2 | 3 | ## Why? 4 | 5 | `fastify` is awesome and arguably the best Node http server around. 6 | 7 | `zod` is awesome and arguably the best TypeScript modeling / validation library around. 8 | 9 | Unfortunately, `fastify` and `zod` don't work together very well. [`fastify` suggests using `@sinclair/typebox`](https://www.fastify.io/docs/latest/TypeScript/#typebox), which is nice but is nowhere close to `zod`. This library allows you to use `zod` as your primary source of truth for models with nice integration with `fastify`, `fastify-swagger` and OpenAPI `typescript-fetch` generator. 10 | 11 | ## Features 12 | 13 | - Define your models using `zod` in a single place, without redundancy / conflicting sources of truth 14 | - Use your models in business logic code and get out of the box type-safety in `fastify` 15 | - First-class support for `fastify-swagger` and `openapitools-generator/typescript-fetch` 16 | - Referential transparency, including for `enum` 17 | - Deduplication of structurally equivalent models 18 | - Internal generated JSON Schemas available for reuse 19 | 20 | ## Setup 21 | 22 | - Install `fastify-zod` 23 | 24 | ``` 25 | npm i fastify-zod 26 | ``` 27 | 28 | - Define your models using `zod` 29 | 30 | ```ts 31 | const TodoItemId = z.object({ 32 | id: z.string().uuid(), 33 | }); 34 | 35 | enum TodoStateEnum { 36 | Todo = `todo`, 37 | InProgress = `in progress`, 38 | Done = `done`, 39 | } 40 | 41 | const TodoState = z.nativeEnum(TodoStateEnum); 42 | 43 | const TodoItem = TodoItemId.extend({ 44 | label: z.string(), 45 | dueDate: z.date().optional(), 46 | state: TodoState, 47 | }); 48 | 49 | const TodoItems = z.object({ 50 | todoItems: z.array(TodoItem), 51 | }); 52 | 53 | const TodoItemsGroupedByStatus = z.object({ 54 | todo: z.array(TodoItem), 55 | inProgress: z.array(TodoItem), 56 | done: z.array(TodoItem), 57 | }); 58 | 59 | const models = { 60 | TodoItemId, 61 | TodoItem, 62 | TodoItems, 63 | TodoItemsGroupedByStatus, 64 | }; 65 | ``` 66 | 67 | - Register `fastify` types 68 | 69 | ```ts 70 | import type { FastifyZod } from "fastify-zod"; 71 | 72 | // Global augmentation, as suggested by 73 | // https://www.fastify.io/docs/latest/Reference/TypeScript/#creating-a-typescript-fastify-plugin 74 | declare module "fastify" { 75 | interface FastifyInstance { 76 | readonly zod: FastifyZod; 77 | } 78 | } 79 | 80 | // Local augmentation 81 | // See below for register() 82 | const f = await register(fastify(), { jsonSchemas }); 83 | ``` 84 | 85 | - Register `fastify-zod` with optional config for `fastify-swagger` 86 | 87 | ```ts 88 | import { buildJsonSchemas, register } from "fastify-zod"; 89 | 90 | const f = fastify(); 91 | 92 | await register(f, { 93 | jsonSchemas: buildJsonSchemas(models), 94 | swaggerOptions: { 95 | // See https://github.com/fastify/fastify-swagger 96 | }, 97 | swaggerUiOptions: { 98 | // See https://github.com/fastify/fastify-swagger-ui 99 | }, 100 | transformSpec: {}, // optional, see below 101 | }); 102 | ``` 103 | 104 | - Define fastify routes using simplified syntax and get automatic type inference 105 | 106 | ```ts 107 | f.zod.post( 108 | `/item`, 109 | { 110 | operationId: `postTodoItem`, 111 | body: `TodoItem`, 112 | reply: `TodoItems`, 113 | }, 114 | async ({ body: nextItem }) => { 115 | /* body is correctly inferred as TodoItem */ 116 | if (state.todoItems.some((prevItem) => prevItem.id === nextItem.id)) { 117 | throw new BadRequest(`item already exists`); 118 | } 119 | state.todoItems = [...state.todoItems, nextItem]; 120 | /* reply is typechecked against TodoItems */ 121 | return state; 122 | } 123 | ); 124 | ``` 125 | 126 | - Generate transformed spec with first-class support for downstream `openapitools-generator` 127 | 128 | ```ts 129 | const transformedSpecJson = await f 130 | .inject({ 131 | method: `get`, 132 | url: `/documentation_transformed/json`, 133 | }) 134 | .then((res) => res.body); 135 | 136 | await writeFile( 137 | join(__dirname, `..`, `..`, `openapi.transformed.json`), 138 | transformedSpecJson, 139 | { encoding: `utf-8` } 140 | ); 141 | ``` 142 | 143 | - Generate OpenAPI Client with `openapitools-generator` 144 | 145 | `openapi-generator-cli generate` 146 | 147 | - For multiple response types / status codes, use `response` instead of `reply`: 148 | 149 | ```ts 150 | f.zod.get( 151 | `/item/:id`, 152 | { 153 | operationId: `getTodoItem`, 154 | params: `TodoItemId`, 155 | response: { 156 | 200: `TodoItem`, 157 | 404: `TodoItemNotFoundError`, 158 | }, 159 | }, 160 | async ({ params: { id } }, reply) => { 161 | const item = state.todoItems.find((item) => item.id === id); 162 | if (item) { 163 | return item; 164 | } 165 | reply.code(404); 166 | return { 167 | id, 168 | message: `item not found`, 169 | }; 170 | } 171 | ); 172 | ``` 173 | 174 | - For custom error messages, you must enable error messages when building the schemas, as well as [configuring fastify to handle them](https://www.fastify.io/docs/latest/Reference/Validation-and-Serialization/#schemaerrorformatter): 175 | 176 | ```ts 177 | // Define custom messages 178 | const TodoItemId = z.object({ 179 | id: z.string().uuid("this is not a valid id!"), 180 | }); 181 | 182 | // Then configure fastify 183 | const f = fastify({ 184 | ajv: { 185 | customOptions: { 186 | allErrors: true, 187 | }, 188 | }, 189 | plugins: [require("ajv-errors")], 190 | }); 191 | 192 | await register(f, { 193 | jsonSchemas: buildJsonSchemas(models, { errorMessages: true }), 194 | }); 195 | ``` 196 | 197 | ## API 198 | 199 | ### `buildJsonSchemas(models: Models, options: BuildJsonSchemasOptions = {}): BuildJonSchemaResult` 200 | 201 | Build JSON Schemas and `$ref` function from Zod models. 202 | 203 | The result can be used either with `register` (recommended, see [example in tests](./src/__tests__/server.fixtures.ts)) or directly with `fastify.addSchema` using the `$ref` function (legacy, see [example in tests](./src/__tests__/server.legacy.fixtures.ts)). 204 | 205 | #### `Models` 206 | 207 | Record mapping model keys to Zod types. Keys will be used to reference models in routes definitions. 208 | 209 | Example: 210 | 211 | ```ts 212 | const TodoItem = z.object({ 213 | /* ... */ 214 | }); 215 | const TodoList = z.object({ 216 | todoItems: z.array(TodoItem), 217 | }); 218 | 219 | const models = { 220 | TodoItem, 221 | TodoList, 222 | }; 223 | ``` 224 | 225 | #### `BuildJsonSchemasOptions = {}` 226 | 227 | ##### `BuildJsonSchemasOptions.$id: string = "Schemas"`: `$id` of the generated schema (defaults to "Schemas") 228 | 229 | ##### `BuildJsonSchemasOptions.target: `jsonSchema7`|`openApi3` = "jsonSchema7"`: _jsonSchema7_ (default) or _openApi3_ 230 | 231 | Generates either `jsonSchema7` or `openApi3` schema. See [`zod-to-json-schema`](https://github.com/StefanTerdell/zod-to-json-schema#options-object). 232 | 233 | #### `BuildJsonSchemasResult = { schemas: JsonSchema[], $ref: $ref }` 234 | 235 | The result of `buildJsonSchemas` has 2 components: an array of schemas that can be added directly to fastify using `fastify.addSchema`, and a `$ref` function that returns a `{ $ref: string }` object that can be used directly. 236 | 237 | If you simply pass the result to `register`, you won't have to care about this however. 238 | 239 | ```ts 240 | const { schemas, $ref } = buildJsonSchemas(models, { $id: "MySchema" }); 241 | 242 | for (const schema of schemas) { 243 | fastify.addSchema(schema); 244 | } 245 | 246 | equals($ref("TodoItem"), { 247 | $ref: "MySchema#/properties/TodoItem", 248 | }); 249 | ``` 250 | 251 | ### `buildJsonSchema($id: string, Type: ZodType)` (_deprecated_) 252 | 253 | Shorthand to `buildJsonSchema({ [$id]: Type }).schemas[0]`. 254 | 255 | ### `register(f: FastifyInstance, { jsonSchemas, swaggerOptions?: = {} }: RegisterOptions` 256 | 257 | Add schemas to `fastify` and decorate instance with `zod` property to add strongly-typed routes (see `fastify.zod` below). 258 | 259 | ### `RegisterOptions` 260 | 261 | #### `RegisterOptions.jsonSchema` 262 | 263 | The result of `buildJsonSchemas(models)` (see above). 264 | 265 | ##### `RegisterOptions.swaggerOptions = FastifyDynamicSwaggerOptions & { transformSpec: TransformSpecOptions }` 266 | 267 | If present, this options will automatically register `fastify-swagger` in addition to `fastify.zod`. 268 | 269 | Any options will be passed directly to `fastify-swagger` so you may refer to [their documentation](https://github.com/fastify/fastify-swagger). 270 | 271 | In addition to `fastify-swagger` options, you can pass an additional property, `transformSpec`, to expose a transformed version of the original spec (see below). 272 | 273 | ```ts 274 | await register(f, { 275 | jsonSchemas: buildJsonSchemas(models), 276 | swaggerOptions: { 277 | swagger: { 278 | info: { 279 | title: `Fastify Zod Test Server`, 280 | description: `Test Server for Fastify Zod`, 281 | version: `0.0.0`, 282 | }, 283 | }, 284 | }, 285 | swaggerUiOptions: { 286 | routePrefix: `/swagger`, 287 | staticCSP: true, 288 | }, 289 | transformSpec: { 290 | /* see below */ 291 | }, 292 | }); 293 | ``` 294 | 295 | ##### `TransformSpecOptions = { cache: boolean = false, routePrefix?: string, options?: TransformOptions }` 296 | 297 | If this property is present on the `swaggerOptions`, then in addition to routes added to `fastify` by `fastify-swagger`, a transformed version of the spec is also exposed. The transformed version is semantically equivalent but benefits from several improvements, notably first-class support for `openapitools-generator-cli` (see below). 298 | 299 | `cache` caches the transformed spec. As `SpecTransformer` can be computationally expensive, this may be useful if used in production. Defaults to `false`. 300 | 301 | `routePrefix` is the route used to expose the transformed spec, similar to the `routePrefix` option of `fastify-swagger`. Defaults to `${swaggerOptions.routePrefix}_transformed`. Since `swaggerOptions.routePrefix` defaults to `/documentation`, then the default if no `routePrefix` is provided in either options is `/documentation_transformed`. 302 | The exposed routes are `/${routePrefix}/json` and `/${routePrefix}/yaml` for JSON and YAML respectively versions of the transformed spec. 303 | 304 | `options` are options passed to `SpecTransformer.transform` (see below). By default all transforms are applied. 305 | 306 | ## `fastify.zod.(delete|get|head|options|patch|post|put)(url: string, config: RouteConfig, handler)` 307 | 308 | Add route with strong typing. 309 | 310 | Example: 311 | 312 | ```ts 313 | f.zod.put( 314 | "/:id", 315 | { 316 | operationId: "putTodoItem", 317 | params: "TodoItemId", // this is a key of "models" object above 318 | body: "TodoItem", 319 | reply: { 320 | description: "The updated todo item", 321 | key: "TodoItem", 322 | }, 323 | }, 324 | async ({ params: { id }, body: item }) => { 325 | /* ... */ 326 | } 327 | ); 328 | ``` 329 | 330 | ### withRefResolver: (options: FastifyDynamicSwaggerOptions) => FastifyDynamicSwaggerOptions 331 | 332 | Wraps `fastify-swagger` options providing a sensible default [`refResolver` function](https://github.com/fastify/fastify-swagger#managing-your-refs) compatible with using the `$ref` function returned by buildJsonSchemas`. 333 | 334 | `register` automatically uses this under the hood so this is only required if you are using the result of `buildJsonSchemas` directly without using `register`. 335 | 336 | ### SpecTransformer(spec: ApiSpec) 337 | 338 | `SpecTransformer` takes an API spec (typically the output of `/openapi/json` when using `fastify-swagger`) and applies various transforms. This class is used under the hood by `register` when `swaggerOptions.transformSpec` is set so you probably don't need to use it directly. 339 | 340 | The transforms should typically be semantically transparent (no semantic difference) but applies some spec-level optimization and most importantly works around the many quirks of the `typescript-fetch` generator of `openapitools-generator-cli`. 341 | 342 | `SpecTransformer` is a stateful object that mutates itself internally, but the original spec object is not modified. 343 | 344 | Available transforms: 345 | 346 | - `rewriteSchemasAbsoluteRefs` transform 347 | 348 | Transforms `$ref`s relative to a schema to refs relative to the global spec. 349 | 350 | Example input: 351 | 352 | ```json 353 | { 354 | "components": { 355 | "schemas": { 356 | "Schema": { 357 | "type": "object", 358 | "properties": { 359 | "Item": { 360 | /* ... */ 361 | }, 362 | "Items": { 363 | "type": "array", 364 | "items": { 365 | // "#" refers to "Schema" scope 366 | "$ref": "#/properties/Item" 367 | } 368 | } 369 | } 370 | } 371 | } 372 | } 373 | } 374 | ``` 375 | 376 | Output: 377 | 378 | ```json 379 | { 380 | "components": { 381 | "schemas": { 382 | "Schema": { 383 | "type": "object", 384 | "properties": { 385 | "Item": { 386 | /* ... */ 387 | }, 388 | "Items": { 389 | "type": "array", 390 | "items": { 391 | // "#" refers to global scope 392 | "$ref": "#/components/schemas/Schema/properties/Item" 393 | } 394 | } 395 | } 396 | } 397 | } 398 | } 399 | } 400 | ``` 401 | 402 | - `extractSchemasProperties` transform 403 | 404 | Extract `properties` of schemas into new schemas and rewrite all `$ref`s to point to the new schema. 405 | 406 | Example input: 407 | 408 | ```json 409 | { 410 | "components": { 411 | "schemas": { 412 | "Schema": { 413 | "type": "object", 414 | "properties": { 415 | "Item": { 416 | /* ... */ 417 | }, 418 | "Items": { 419 | "type": "array", 420 | "items": { 421 | "$ref": "#/components/schemas/Schema/properties/Item" 422 | } 423 | } 424 | } 425 | } 426 | } 427 | } 428 | } 429 | ``` 430 | 431 | Output: 432 | 433 | ```json 434 | { 435 | "components": { 436 | "schemas": { 437 | "Schema": { 438 | "type": "object", 439 | "properties": { 440 | "Item": { 441 | "$ref": "#/components/schemas/Schema_TodoItem" 442 | }, 443 | "Items": { 444 | "$ref": "#/components/schemas/Schema_TodoItems" 445 | } 446 | } 447 | }, 448 | "Schema_TodoItem": { 449 | /* ... */ 450 | }, 451 | "Schema_TodoItems": { 452 | "type": "array", 453 | "items": { 454 | "$ref": "#/components/schemas/Schema_TodoItem" 455 | } 456 | } 457 | } 458 | } 459 | } 460 | ``` 461 | 462 | - `mergeRefs` transform 463 | 464 | Finds deeply nested structures equivalent to existing schemas and replace them with `$ref`s to this schema. In practice this means deduplication and more importantly, referential equivalence in addition to structrural equivalence. This is especially useful for `enum`s since in TypeScript to equivalent enums are not assignable to each other. 465 | 466 | Example input: 467 | 468 | ```json 469 | { 470 | "components": { 471 | "schemas": { 472 | "TodoItemState": { 473 | "type": "string", 474 | "enum": ["todo", "in progress", "done"] 475 | }, 476 | "TodoItem": { 477 | "type": "object", 478 | "properties": { 479 | "state": { 480 | "type": "string", 481 | "enum": ["todo", "in progress", "done"] 482 | } 483 | } 484 | } 485 | } 486 | } 487 | } 488 | { 489 | "mergeRefs": [{ 490 | "$ref": "TodoItemState#" 491 | }] 492 | } 493 | ``` 494 | 495 | Output: 496 | 497 | ```json 498 | { 499 | "components": { 500 | "schemas": { 501 | "TodoItemState": { 502 | "type": "string", 503 | "enum": ["todo", "in progress", "done"] 504 | }, 505 | "TodoItem": { 506 | "type": "object", 507 | "properties": { 508 | "state": { 509 | "$ref": "#/components/schemas/TodoItemState" 510 | } 511 | } 512 | } 513 | } 514 | } 515 | } 516 | ``` 517 | 518 | In the typical case, you will not create each ref explicitly, but rather use the `$ref` function provided by `buildJsonSchemas`: 519 | 520 | ```ts 521 | { 522 | mergeRefs: [$ref("TodoItemState")]; 523 | } 524 | ``` 525 | 526 | - `deleteUnusedSchemas` transform 527 | 528 | Delete all schemas that are not referenced anywhere, including in `paths`. This is useful to remove leftovers of the previous transforms. 529 | 530 | Example input: 531 | 532 | ```json 533 | { 534 | "components": { 535 | "schemas": { 536 | // Schema_TodoItem has been extracted, 537 | // there are no references to this anymore 538 | "Schema": { 539 | "type": "object", 540 | "properties": { 541 | "TodoItem": { 542 | "$ref": "#/components/schemas/Schema_TodoItem" 543 | } 544 | } 545 | }, 546 | "Schema_TodoItem": { 547 | /* ... */ 548 | } 549 | } 550 | }, 551 | "paths": { 552 | "/item": { 553 | "get": { 554 | "responses": { 555 | "200": { 556 | "content": { 557 | "application/json": { 558 | "schema": { 559 | // This used to be #/components/Schema/properties/TodoItem 560 | // but has been transformed by extractSchemasProperties 561 | "$ref": "#/components/schemas/Schema_TodoItem" 562 | } 563 | } 564 | } 565 | } 566 | } 567 | } 568 | } 569 | } 570 | } 571 | ``` 572 | 573 | Output: 574 | 575 | ```json 576 | { 577 | "components": { 578 | "schemas": { 579 | // "Schema" has been deleted 580 | "Schema_TodoItem": { 581 | /* ... */ 582 | } 583 | } 584 | }, 585 | "paths": { 586 | /* ... */ 587 | } 588 | } 589 | ``` 590 | 591 | - `schemaKeys` option 592 | 593 | This option controls the behavior of newly created schemas (e.g. during `extractSchemasProperties` transform). 594 | 595 | Available configurations: 596 | 597 | - `schemaKeys.removeInitialSchemasPrefix`: remove `schemaKey` prefix of initial schemas to create less verbose schema names, e.g. `TodoState` instead of `MySchema_TodoState` 598 | 599 | - `schemaKeys.changeCase`: change case of generated schema keys. Defaults to `preserve`. In this case, original schema key and property key prefixes are preserved, and segments are underscore-separated. 600 | 601 | In case of schema key conflict, an error will be thrown during `transform`. 602 | 603 | #### SpecTransformer#transform(options: TransformOptions) 604 | 605 | Applies the given transforms. 606 | 607 | Default options: 608 | 609 | ```ts 610 | { 611 | rewriteAbsoluteRefs?: boolean = true, 612 | extractSchemasProperties?: boolean = true, 613 | mergeRefs?: { $ref: string }[] = [], 614 | deleteUnusedSchemas?: boolean = true, 615 | schemaKeys?: { 616 | removeInitialSchemasPrefix: boolean = false, 617 | changeCase: "preserve" | "camelCase" | "PascalCase" | "snake_case" | "param-case" = "preserve" 618 | } = {} 619 | } 620 | ``` 621 | 622 | All transforms default to `true` except `mergeRefs` that you must explicitly configure. 623 | 624 | #### SpecTransformer#getSpec(): Spec 625 | 626 | Return the current state of the spec. This is typically called after `transform` to use the transformed spec. 627 | 628 | ## Usage with `openapitools` 629 | 630 | Together with `fastify-swagger`, and `SpecTransformer` this library supports downstream client code generation using `openapitools-generator-cli`. 631 | 632 | Recommended use is with `register` and `fastify.inject`. 633 | 634 | For this you need to first generate the spec file, then run `openapitools-generator`: 635 | 636 | ```ts 637 | const jsonSchemas = buildJsonSchemas(models); 638 | 639 | await register(f, { 640 | jsonSchemas, 641 | swaggerOptions: { 642 | openapi: { 643 | /* ... */ 644 | }, 645 | exposeRoute: true, 646 | transformSpec: { 647 | routePrefix: "/openapi_transformed", 648 | options: { 649 | mergeRefs: [$ref("TodoItemState")], 650 | }, 651 | }, 652 | }, 653 | }); 654 | 655 | const spec = await f 656 | .inject({ 657 | method: "get", 658 | url: "/openapi_transformed/json", 659 | }) 660 | .then((spec) => spec.json()); 661 | 662 | writeFileSync("openapi-spec.json", JSON.stringify(spec), { encoding: "utf-8" }); 663 | ``` 664 | 665 | `openapi-generator-cli generate` 666 | 667 | We recommend running this as part as the build step of your app, see [package.json](./package.json). 668 | 669 | ## Caveats 670 | 671 | Unfortunately and despite best efforts by `SpecTransformer`, the OpenAPI generator has many quirks and limited support for some features. Complex nested arrays are sometimes not validated / parsed correctly, discriminated unions have limited support, etc. 672 | 673 | ## License 674 | 675 | MIT License Copyright (c) Elie Rotenberg 676 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ], 11 | "@babel/typescript" 12 | ], 13 | "sourceMaps": true 14 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: `node`, 3 | testMatch: [`**/*.test.js`], 4 | maxConcurrency: 30, 5 | testTimeout: 240000, 6 | }; 7 | -------------------------------------------------------------------------------- /openapi.original.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.3", 3 | "info": { 4 | "title": "Fastify Zod Test Server", 5 | "description": "Test Server for Fastify Zod", 6 | "version": "0.0.0" 7 | }, 8 | "components": { 9 | "schemas": { 10 | "Schema": { 11 | "type": "object", 12 | "properties": { 13 | "TodoState": { 14 | "type": "string", 15 | "enum": [ 16 | "todo", 17 | "in progress", 18 | "done" 19 | ] 20 | }, 21 | "TodoItemId": { 22 | "type": "object", 23 | "properties": { 24 | "id": { 25 | "type": "string", 26 | "format": "uuid" 27 | } 28 | }, 29 | "required": [ 30 | "id" 31 | ], 32 | "additionalProperties": false 33 | }, 34 | "TodoItem": { 35 | "type": "object", 36 | "properties": { 37 | "id": { 38 | "$ref": "#/components/schemas/Schema/properties/TodoItemId/properties/id" 39 | }, 40 | "label": { 41 | "type": "string" 42 | }, 43 | "dueDateMs": { 44 | "type": "integer", 45 | "minimum": 0 46 | }, 47 | "state": { 48 | "$ref": "#/components/schemas/Schema/properties/TodoState" 49 | } 50 | }, 51 | "required": [ 52 | "id", 53 | "label", 54 | "state" 55 | ], 56 | "additionalProperties": false 57 | }, 58 | "TodoItemNotFoundError": { 59 | "type": "object", 60 | "properties": { 61 | "id": { 62 | "$ref": "#/components/schemas/Schema/properties/TodoItemId/properties/id" 63 | }, 64 | "message": { 65 | "type": "string", 66 | "enum": [ 67 | "item not found" 68 | ] 69 | } 70 | }, 71 | "required": [ 72 | "id", 73 | "message" 74 | ], 75 | "additionalProperties": false 76 | }, 77 | "TodoItems": { 78 | "type": "object", 79 | "properties": { 80 | "todoItems": { 81 | "type": "array", 82 | "items": { 83 | "$ref": "#/components/schemas/Schema/properties/TodoItem" 84 | } 85 | } 86 | }, 87 | "required": [ 88 | "todoItems" 89 | ], 90 | "additionalProperties": false 91 | }, 92 | "TodoItemsGroupedByStatus": { 93 | "type": "object", 94 | "properties": { 95 | "todo": { 96 | "type": "array", 97 | "items": { 98 | "$ref": "#/components/schemas/Schema/properties/TodoItem" 99 | } 100 | }, 101 | "inProgress": { 102 | "type": "array", 103 | "items": { 104 | "$ref": "#/components/schemas/Schema/properties/TodoItem" 105 | } 106 | }, 107 | "done": { 108 | "type": "array", 109 | "items": { 110 | "$ref": "#/components/schemas/Schema/properties/TodoItem" 111 | } 112 | } 113 | }, 114 | "required": [ 115 | "todo", 116 | "inProgress", 117 | "done" 118 | ], 119 | "additionalProperties": false 120 | }, 121 | "FortyTwo": { 122 | "type": "number", 123 | "enum": [ 124 | 42 125 | ] 126 | } 127 | }, 128 | "required": [ 129 | "TodoState", 130 | "TodoItemId", 131 | "TodoItem", 132 | "TodoItemNotFoundError", 133 | "TodoItems", 134 | "TodoItemsGroupedByStatus", 135 | "FortyTwo" 136 | ], 137 | "additionalProperties": false 138 | } 139 | } 140 | }, 141 | "paths": { 142 | "/documentation_transformed/json": { 143 | "get": { 144 | "responses": { 145 | "200": { 146 | "description": "Default Response" 147 | } 148 | } 149 | } 150 | }, 151 | "/documentation_transformed/yaml": { 152 | "get": { 153 | "responses": { 154 | "200": { 155 | "description": "Default Response" 156 | } 157 | } 158 | } 159 | }, 160 | "/item": { 161 | "get": { 162 | "operationId": "getTodoItems", 163 | "responses": { 164 | "200": { 165 | "description": "The list of Todo Items", 166 | "content": { 167 | "application/json": { 168 | "schema": { 169 | "$ref": "#/components/schemas/Schema/properties/TodoItems", 170 | "description": "The list of Todo Items" 171 | } 172 | } 173 | } 174 | } 175 | } 176 | }, 177 | "post": { 178 | "operationId": "postTodoItem", 179 | "requestBody": { 180 | "content": { 181 | "application/json": { 182 | "schema": { 183 | "$ref": "#/components/schemas/Schema/properties/TodoItem" 184 | } 185 | } 186 | } 187 | }, 188 | "responses": { 189 | "200": { 190 | "description": "Default Response", 191 | "content": { 192 | "application/json": { 193 | "schema": { 194 | "$ref": "#/components/schemas/Schema/properties/TodoItems" 195 | } 196 | } 197 | } 198 | } 199 | } 200 | } 201 | }, 202 | "/item/grouped-by-status": { 203 | "get": { 204 | "operationId": "getTodoItemsGroupedByStatus", 205 | "responses": { 206 | "200": { 207 | "description": "Default Response", 208 | "content": { 209 | "application/json": { 210 | "schema": { 211 | "$ref": "#/components/schemas/Schema/properties/TodoItemsGroupedByStatus" 212 | } 213 | } 214 | } 215 | } 216 | } 217 | } 218 | }, 219 | "/item/{id}": { 220 | "get": { 221 | "operationId": "getTodoItem", 222 | "parameters": [ 223 | { 224 | "schema": { 225 | "type": "string", 226 | "format": "uuid" 227 | }, 228 | "in": "path", 229 | "name": "id", 230 | "required": true 231 | } 232 | ], 233 | "responses": { 234 | "200": { 235 | "description": "Default Response", 236 | "content": { 237 | "application/json": { 238 | "schema": { 239 | "$ref": "#/components/schemas/Schema/properties/TodoItem" 240 | } 241 | } 242 | } 243 | }, 244 | "404": { 245 | "description": "Default Response", 246 | "content": { 247 | "application/json": { 248 | "schema": { 249 | "$ref": "#/components/schemas/Schema/properties/TodoItemNotFoundError" 250 | } 251 | } 252 | } 253 | } 254 | } 255 | }, 256 | "put": { 257 | "operationId": "putTodoItem", 258 | "requestBody": { 259 | "content": { 260 | "application/json": { 261 | "schema": { 262 | "$ref": "#/components/schemas/Schema/properties/TodoItem" 263 | } 264 | } 265 | } 266 | }, 267 | "parameters": [ 268 | { 269 | "schema": { 270 | "type": "string", 271 | "format": "uuid" 272 | }, 273 | "in": "path", 274 | "name": "id", 275 | "required": true 276 | } 277 | ], 278 | "responses": { 279 | "200": { 280 | "description": "Default Response", 281 | "content": { 282 | "application/json": { 283 | "schema": { 284 | "$ref": "#/components/schemas/Schema/properties/TodoItem" 285 | } 286 | } 287 | } 288 | } 289 | } 290 | } 291 | }, 292 | "/42": { 293 | "get": { 294 | "operationId": "getFortyTwo", 295 | "responses": { 296 | "200": { 297 | "description": "Default Response", 298 | "content": { 299 | "application/json": { 300 | "schema": { 301 | "$ref": "#/components/schemas/Schema/properties/FortyTwo" 302 | } 303 | } 304 | } 305 | } 306 | } 307 | } 308 | } 309 | } 310 | } -------------------------------------------------------------------------------- /openapi.transformed.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.3", 3 | "info": { 4 | "title": "Fastify Zod Test Server", 5 | "description": "Test Server for Fastify Zod", 6 | "version": "0.0.0" 7 | }, 8 | "components": { 9 | "schemas": { 10 | "Schema_TodoState": { 11 | "type": "string", 12 | "enum": [ 13 | "todo", 14 | "in progress", 15 | "done" 16 | ] 17 | }, 18 | "Schema_TodoItem": { 19 | "type": "object", 20 | "properties": { 21 | "id": { 22 | "$ref": "#/components/schemas/Schema_TodoItemId_id" 23 | }, 24 | "label": { 25 | "$ref": "#/components/schemas/Schema_TodoItem_label" 26 | }, 27 | "dueDateMs": { 28 | "$ref": "#/components/schemas/Schema_TodoItem_dueDateMs" 29 | }, 30 | "state": { 31 | "$ref": "#/components/schemas/Schema_TodoState" 32 | } 33 | }, 34 | "required": [ 35 | "id", 36 | "label", 37 | "state" 38 | ], 39 | "additionalProperties": false 40 | }, 41 | "Schema_TodoItemNotFoundError": { 42 | "type": "object", 43 | "properties": { 44 | "id": { 45 | "$ref": "#/components/schemas/Schema_TodoItemId_id" 46 | }, 47 | "message": { 48 | "$ref": "#/components/schemas/Schema_TodoItemNotFoundError_message" 49 | } 50 | }, 51 | "required": [ 52 | "id", 53 | "message" 54 | ], 55 | "additionalProperties": false 56 | }, 57 | "Schema_TodoItems": { 58 | "type": "object", 59 | "properties": { 60 | "todoItems": { 61 | "$ref": "#/components/schemas/Schema_TodoItems_todoItems" 62 | } 63 | }, 64 | "required": [ 65 | "todoItems" 66 | ], 67 | "additionalProperties": false 68 | }, 69 | "Schema_TodoItemsGroupedByStatus": { 70 | "type": "object", 71 | "properties": { 72 | "todo": { 73 | "$ref": "#/components/schemas/Schema_TodoItemsGroupedByStatus_todo" 74 | }, 75 | "inProgress": { 76 | "$ref": "#/components/schemas/Schema_TodoItemsGroupedByStatus_inProgress" 77 | }, 78 | "done": { 79 | "$ref": "#/components/schemas/Schema_TodoItemsGroupedByStatus_done" 80 | } 81 | }, 82 | "required": [ 83 | "todo", 84 | "inProgress", 85 | "done" 86 | ], 87 | "additionalProperties": false 88 | }, 89 | "Schema_FortyTwo": { 90 | "type": "number", 91 | "enum": [ 92 | 42 93 | ] 94 | }, 95 | "Schema_TodoItemId_id": { 96 | "type": "string", 97 | "format": "uuid" 98 | }, 99 | "Schema_TodoItem_label": { 100 | "type": "string" 101 | }, 102 | "Schema_TodoItem_dueDateMs": { 103 | "type": "integer", 104 | "minimum": 0 105 | }, 106 | "Schema_TodoItemNotFoundError_message": { 107 | "type": "string", 108 | "enum": [ 109 | "item not found" 110 | ] 111 | }, 112 | "Schema_TodoItems_todoItems": { 113 | "type": "array", 114 | "items": { 115 | "$ref": "#/components/schemas/Schema_TodoItem" 116 | } 117 | }, 118 | "Schema_TodoItemsGroupedByStatus_todo": { 119 | "type": "array", 120 | "items": { 121 | "$ref": "#/components/schemas/Schema_TodoItem" 122 | } 123 | }, 124 | "Schema_TodoItemsGroupedByStatus_inProgress": { 125 | "type": "array", 126 | "items": { 127 | "$ref": "#/components/schemas/Schema_TodoItem" 128 | } 129 | }, 130 | "Schema_TodoItemsGroupedByStatus_done": { 131 | "type": "array", 132 | "items": { 133 | "$ref": "#/components/schemas/Schema_TodoItem" 134 | } 135 | } 136 | } 137 | }, 138 | "paths": { 139 | "/documentation_transformed/json": { 140 | "get": { 141 | "responses": { 142 | "200": { 143 | "description": "Default Response" 144 | } 145 | } 146 | } 147 | }, 148 | "/documentation_transformed/yaml": { 149 | "get": { 150 | "responses": { 151 | "200": { 152 | "description": "Default Response" 153 | } 154 | } 155 | } 156 | }, 157 | "/item": { 158 | "get": { 159 | "operationId": "getTodoItems", 160 | "responses": { 161 | "200": { 162 | "description": "The list of Todo Items", 163 | "content": { 164 | "application/json": { 165 | "schema": { 166 | "$ref": "#/components/schemas/Schema_TodoItems", 167 | "description": "The list of Todo Items" 168 | } 169 | } 170 | } 171 | } 172 | } 173 | }, 174 | "post": { 175 | "operationId": "postTodoItem", 176 | "requestBody": { 177 | "content": { 178 | "application/json": { 179 | "schema": { 180 | "$ref": "#/components/schemas/Schema_TodoItem" 181 | } 182 | } 183 | } 184 | }, 185 | "responses": { 186 | "200": { 187 | "description": "Default Response", 188 | "content": { 189 | "application/json": { 190 | "schema": { 191 | "$ref": "#/components/schemas/Schema_TodoItems" 192 | } 193 | } 194 | } 195 | } 196 | } 197 | } 198 | }, 199 | "/item/grouped-by-status": { 200 | "get": { 201 | "operationId": "getTodoItemsGroupedByStatus", 202 | "responses": { 203 | "200": { 204 | "description": "Default Response", 205 | "content": { 206 | "application/json": { 207 | "schema": { 208 | "$ref": "#/components/schemas/Schema_TodoItemsGroupedByStatus" 209 | } 210 | } 211 | } 212 | } 213 | } 214 | } 215 | }, 216 | "/item/{id}": { 217 | "get": { 218 | "operationId": "getTodoItem", 219 | "parameters": [ 220 | { 221 | "schema": { 222 | "type": "string", 223 | "format": "uuid" 224 | }, 225 | "in": "path", 226 | "name": "id", 227 | "required": true 228 | } 229 | ], 230 | "responses": { 231 | "200": { 232 | "description": "Default Response", 233 | "content": { 234 | "application/json": { 235 | "schema": { 236 | "$ref": "#/components/schemas/Schema_TodoItem" 237 | } 238 | } 239 | } 240 | }, 241 | "404": { 242 | "description": "Default Response", 243 | "content": { 244 | "application/json": { 245 | "schema": { 246 | "$ref": "#/components/schemas/Schema_TodoItemNotFoundError" 247 | } 248 | } 249 | } 250 | } 251 | } 252 | }, 253 | "put": { 254 | "operationId": "putTodoItem", 255 | "requestBody": { 256 | "content": { 257 | "application/json": { 258 | "schema": { 259 | "$ref": "#/components/schemas/Schema_TodoItem" 260 | } 261 | } 262 | } 263 | }, 264 | "parameters": [ 265 | { 266 | "schema": { 267 | "type": "string", 268 | "format": "uuid" 269 | }, 270 | "in": "path", 271 | "name": "id", 272 | "required": true 273 | } 274 | ], 275 | "responses": { 276 | "200": { 277 | "description": "Default Response", 278 | "content": { 279 | "application/json": { 280 | "schema": { 281 | "$ref": "#/components/schemas/Schema_TodoItem" 282 | } 283 | } 284 | } 285 | } 286 | } 287 | } 288 | }, 289 | "/42": { 290 | "get": { 291 | "operationId": "getFortyTwo", 292 | "responses": { 293 | "200": { 294 | "description": "Default Response", 295 | "content": { 296 | "application/json": { 297 | "schema": { 298 | "$ref": "#/components/schemas/Schema_FortyTwo" 299 | } 300 | } 301 | } 302 | } 303 | } 304 | } 305 | } 306 | } 307 | } -------------------------------------------------------------------------------- /openapi.transformed.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: Fastify Zod Test Server 4 | description: Test Server for Fastify Zod 5 | version: 0.0.0 6 | components: 7 | schemas: 8 | Schema_TodoState: 9 | type: string 10 | enum: 11 | - todo 12 | - in progress 13 | - done 14 | Schema_TodoItem: 15 | type: object 16 | properties: 17 | id: 18 | $ref: '#/components/schemas/Schema_TodoItemId_id' 19 | label: 20 | $ref: '#/components/schemas/Schema_TodoItem_label' 21 | dueDateMs: 22 | $ref: '#/components/schemas/Schema_TodoItem_dueDateMs' 23 | state: 24 | $ref: '#/components/schemas/Schema_TodoState' 25 | required: 26 | - id 27 | - label 28 | - state 29 | additionalProperties: false 30 | Schema_TodoItemNotFoundError: 31 | type: object 32 | properties: 33 | id: 34 | $ref: '#/components/schemas/Schema_TodoItemId_id' 35 | message: 36 | $ref: '#/components/schemas/Schema_TodoItemNotFoundError_message' 37 | required: 38 | - id 39 | - message 40 | additionalProperties: false 41 | Schema_TodoItems: 42 | type: object 43 | properties: 44 | todoItems: 45 | $ref: '#/components/schemas/Schema_TodoItems_todoItems' 46 | required: 47 | - todoItems 48 | additionalProperties: false 49 | Schema_TodoItemsGroupedByStatus: 50 | type: object 51 | properties: 52 | todo: 53 | $ref: '#/components/schemas/Schema_TodoItemsGroupedByStatus_todo' 54 | inProgress: 55 | $ref: '#/components/schemas/Schema_TodoItemsGroupedByStatus_inProgress' 56 | done: 57 | $ref: '#/components/schemas/Schema_TodoItemsGroupedByStatus_done' 58 | required: 59 | - todo 60 | - inProgress 61 | - done 62 | additionalProperties: false 63 | Schema_FortyTwo: 64 | type: number 65 | enum: 66 | - 42 67 | Schema_TodoItemId_id: 68 | type: string 69 | format: uuid 70 | Schema_TodoItem_label: 71 | type: string 72 | Schema_TodoItem_dueDateMs: 73 | type: integer 74 | minimum: 0 75 | Schema_TodoItemNotFoundError_message: 76 | type: string 77 | enum: 78 | - item not found 79 | Schema_TodoItems_todoItems: 80 | type: array 81 | items: 82 | $ref: '#/components/schemas/Schema_TodoItem' 83 | Schema_TodoItemsGroupedByStatus_todo: 84 | type: array 85 | items: 86 | $ref: '#/components/schemas/Schema_TodoItem' 87 | Schema_TodoItemsGroupedByStatus_inProgress: 88 | type: array 89 | items: 90 | $ref: '#/components/schemas/Schema_TodoItem' 91 | Schema_TodoItemsGroupedByStatus_done: 92 | type: array 93 | items: 94 | $ref: '#/components/schemas/Schema_TodoItem' 95 | paths: 96 | /documentation_transformed/json: 97 | get: 98 | responses: 99 | '200': 100 | description: Default Response 101 | /documentation_transformed/yaml: 102 | get: 103 | responses: 104 | '200': 105 | description: Default Response 106 | /item: 107 | get: 108 | operationId: getTodoItems 109 | responses: 110 | '200': 111 | description: The list of Todo Items 112 | content: 113 | application/json: 114 | schema: 115 | $ref: '#/components/schemas/Schema_TodoItems' 116 | description: The list of Todo Items 117 | post: 118 | operationId: postTodoItem 119 | requestBody: 120 | content: 121 | application/json: 122 | schema: 123 | $ref: '#/components/schemas/Schema_TodoItem' 124 | responses: 125 | '200': 126 | description: Default Response 127 | content: 128 | application/json: 129 | schema: 130 | $ref: '#/components/schemas/Schema_TodoItems' 131 | /item/grouped-by-status: 132 | get: 133 | operationId: getTodoItemsGroupedByStatus 134 | responses: 135 | '200': 136 | description: Default Response 137 | content: 138 | application/json: 139 | schema: 140 | $ref: '#/components/schemas/Schema_TodoItemsGroupedByStatus' 141 | /item/{id}: 142 | get: 143 | operationId: getTodoItem 144 | parameters: 145 | - schema: 146 | type: string 147 | format: uuid 148 | in: path 149 | name: id 150 | required: true 151 | responses: 152 | '200': 153 | description: Default Response 154 | content: 155 | application/json: 156 | schema: 157 | $ref: '#/components/schemas/Schema_TodoItem' 158 | '404': 159 | description: Default Response 160 | content: 161 | application/json: 162 | schema: 163 | $ref: '#/components/schemas/Schema_TodoItemNotFoundError' 164 | put: 165 | operationId: putTodoItem 166 | requestBody: 167 | content: 168 | application/json: 169 | schema: 170 | $ref: '#/components/schemas/Schema_TodoItem' 171 | parameters: 172 | - schema: 173 | type: string 174 | format: uuid 175 | in: path 176 | name: id 177 | required: true 178 | responses: 179 | '200': 180 | description: Default Response 181 | content: 182 | application/json: 183 | schema: 184 | $ref: '#/components/schemas/Schema_TodoItem' 185 | /42: 186 | get: 187 | operationId: getFortyTwo 188 | responses: 189 | '200': 190 | description: Default Response 191 | content: 192 | application/json: 193 | schema: 194 | $ref: '#/components/schemas/Schema_FortyTwo' 195 | -------------------------------------------------------------------------------- /openapitools.json: -------------------------------------------------------------------------------- 1 | { 2 | "spaces": 2, 3 | "generator-cli": { 4 | "version": "5.1.0", 5 | "generators": { 6 | "v1": { 7 | "generatorName": "typescript-fetch", 8 | "glob": "openapi.transformed.json", 9 | "output": "./test-openapi-client", 10 | "additionalProperties": { 11 | "supportsES6": true, 12 | "modelPropertyNaming": "original", 13 | "paramNaming": "original", 14 | "npmName": "fastify-zod-test-openapi-client", 15 | "typescriptThreePlus": true, 16 | "generateAliasAsModel": true 17 | } 18 | } 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-zod", 3 | "version": "1.4.0", 4 | "description": "Zod integration with Fastify", 5 | "main": "build/index.js", 6 | "scripts": { 7 | "check:types": "tsc -p . --noEmit", 8 | "check:lint": "eslint src", 9 | "check": "npm run check:types && npm run check:lint", 10 | "clean": "rm -rf build", 11 | "build:types": "tsc -p . --emitDeclarationOnly", 12 | "build:babel": "babel src --out-dir build --extensions '.ts' --source-maps", 13 | "build:openapi-spec": "node build/__tests__/generate-spec.fixtures.js", 14 | "build:openapi-client": "rm -rf test-openapi-client && openapi-generator-cli generate && cd test-openapi-client && npm i", 15 | "build": "npm run clean && npm run build:babel && npm run build:openapi-spec && npm run build:openapi-client && npm run build:types", 16 | "test": "jest" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/elierotenberg/fastify-zod.git" 21 | }, 22 | "keywords": [ 23 | "zod", 24 | "fastify", 25 | "openapi" 26 | ], 27 | "author": "Elie Rotenberg ", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/elierotenberg/fastify-zod/issues" 31 | }, 32 | "homepage": "https://github.com/elierotenberg/fastify-zod#readme", 33 | "devDependencies": { 34 | "@babel/cli": "^7.22.10", 35 | "@babel/core": "^7.22.10", 36 | "@babel/preset-env": "^7.22.10", 37 | "@babel/preset-typescript": "^7.22.5", 38 | "@openapitools/openapi-generator-cli": "^2.7.0", 39 | "@types/http-errors": "^2.0.1", 40 | "@types/jest": "^29.5.3", 41 | "@types/node": "^20.5.0", 42 | "@typescript-eslint/eslint-plugin": "^6.4.0", 43 | "@typescript-eslint/parser": "^6.4.0", 44 | "ajv-errors": "^3.0.0", 45 | "eslint": "^8.47.0", 46 | "eslint-config-prettier": "^9.0.0", 47 | "eslint-plugin-import": "^2.28.0", 48 | "eslint-plugin-prettier": "^5.0.0", 49 | "fastify": "^4.21.0", 50 | "fastify-zod-test-openapi-client": "file:test-openapi-client", 51 | "http-errors": "^2.0.0", 52 | "jest": "^29.6.2", 53 | "node-fetch": "^3.3.2", 54 | "pino-pretty": "^10.2.0", 55 | "prettier": "^3.0.2", 56 | "typed-jest-expect": "^1.0.1", 57 | "typescript": "^5.1.6" 58 | }, 59 | "peerDependencies": { 60 | "fastify": "^4.15.0" 61 | }, 62 | "dependencies": { 63 | "@fastify/swagger": "^8.9.0", 64 | "@fastify/swagger-ui": "^1.9.3", 65 | "@types/js-yaml": "^4.0.5", 66 | "change-case": "^4.1.2", 67 | "fast-deep-equal": "^3.1.3", 68 | "js-yaml": "^4.1.0", 69 | "tslib": "^2.6.1", 70 | "zod": "^3.22.1", 71 | "zod-to-json-schema": "^3.21.4" 72 | } 73 | } -------------------------------------------------------------------------------- /src/FastifyZod.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage, ServerResponse } from "http"; 2 | 3 | import { 4 | FastifyInstance, 5 | FastifyRequest, 6 | FastifySchema, 7 | HTTPMethods as FastifyHTTPMethods, 8 | RouteHandlerMethod, 9 | FastifyReply, 10 | RawServerBase, 11 | } from "fastify"; 12 | import fastifySwagger, { FastifyDynamicSwaggerOptions } from "@fastify/swagger"; 13 | import fastifySwaggerUi, { FastifySwaggerUiOptions } from "@fastify/swagger-ui"; 14 | import * as yaml from "js-yaml"; 15 | 16 | import { SpecTransformer, TransformOptions } from "./SpecTransformer"; 17 | import { $Ref, BuildJsonSchemasResult } from "./JsonSchema"; 18 | import { 19 | Models as M_, 20 | SchemaKey, 21 | SchemaKeyOrDescription, 22 | SchemaTypeOption, 23 | } from "./Models"; 24 | 25 | export type RegisterOptions = { 26 | readonly jsonSchemas: BuildJsonSchemasResult; 27 | readonly transformSpec?: { 28 | readonly cache?: boolean; 29 | readonly routePrefix?: string; 30 | readonly options?: TransformOptions; 31 | }; 32 | readonly swaggerOptions?: FastifyDynamicSwaggerOptions; 33 | readonly swaggerUiOptions?: false | FastifySwaggerUiOptions; 34 | }; 35 | 36 | type V_ = Lowercase & keyof FastifyInstance; 37 | 38 | type P_ = void | SchemaKey; 39 | type B_ = void | SchemaKey; 40 | type Q_ = void | SchemaKey; 41 | type R_ = void | SchemaKey; 42 | type Rx_ = void | Record>; 43 | 44 | type FatifyZodRouteGenericInterface< 45 | M extends M_, 46 | P extends P_, 47 | B extends B_, 48 | Q extends Q_, 49 | R extends R_, 50 | Rx extends Rx_, 51 | > = { 52 | Params: SchemaTypeOption; 53 | Body: SchemaTypeOption; 54 | Reply: SchemaTypeOption< 55 | M, 56 | R | (Rx extends Record ? Rx[number] : never) 57 | >; 58 | Querystring: SchemaTypeOption; 59 | }; 60 | 61 | type RouteHandlerParams< 62 | M extends M_, 63 | P extends P_, 64 | B extends B_, 65 | Q extends Q_, 66 | R extends R_, 67 | Rx extends Rx_, 68 | > = FastifyRequest>; 69 | 70 | type RouteHandler< 71 | M extends M_, 72 | P extends P_, 73 | B extends B_, 74 | Q extends Q_, 75 | R extends R_, 76 | Rx extends Rx_, 77 | > = ( 78 | params: RouteHandlerParams, 79 | reply: FastifyReply< 80 | RawServerBase, 81 | IncomingMessage, 82 | ServerResponse, 83 | FatifyZodRouteGenericInterface 84 | >, 85 | ) => Promise< 86 | SchemaTypeOption< 87 | M, 88 | R | (Rx extends Record ? Rx[number] : never) 89 | > 90 | >; 91 | 92 | type RouteConfig< 93 | M extends M_, 94 | V extends V_, 95 | P extends P_, 96 | B extends B_, 97 | Q extends Q_, 98 | R extends R_, 99 | Rx extends Rx_, 100 | > = { 101 | readonly url: string; 102 | readonly method: V; 103 | readonly operationId: string; 104 | readonly description?: string; 105 | readonly params?: 106 | | Exclude 107 | | { 108 | readonly description: string; 109 | readonly key: Exclude; 110 | }; 111 | readonly body?: 112 | | Exclude 113 | | { 114 | readonly description: string; 115 | readonly key: Exclude; 116 | }; 117 | readonly querystring?: 118 | | Exclude 119 | | { 120 | readonly description: string; 121 | readonly key: Exclude; 122 | }; 123 | readonly reply?: 124 | | Exclude 125 | | { 126 | readonly description: string; 127 | readonly key: Exclude; 128 | }; 129 | readonly response?: Rx extends Record 130 | ? { 131 | readonly [Code in keyof Rx]: 132 | | Exclude 133 | | { 134 | readonly description: string; 135 | readonly key: Exclude; 136 | }; 137 | } 138 | : void; 139 | readonly handler: RouteHandler; 140 | } & FastifySchema; 141 | 142 | export type FastifyZod = { 143 | readonly [Method in V_]: < 144 | P extends P_, 145 | B extends B_, 146 | Q extends Q_, 147 | R extends R_, 148 | Rx extends Rx_, 149 | >( 150 | url: string, 151 | config: Omit< 152 | RouteConfig, 153 | `url` | `method` | `schema` | `handler` 154 | >, 155 | handler: RouteHandler, 156 | ) => void; 157 | }; 158 | 159 | export type FastifyZodInstance = FastifyInstance & { 160 | readonly zod: FastifyZod; 161 | }; 162 | 163 | export const withRefResolver = ( 164 | options: FastifyDynamicSwaggerOptions, 165 | ): FastifyDynamicSwaggerOptions => ({ 166 | ...options, 167 | refResolver: { 168 | ...options.refResolver, 169 | clone: true, 170 | buildLocalReference: (json, _baseUri, _fragment, i) => 171 | typeof json.$id === `string` ? json.$id : `def-${i}`, 172 | }, 173 | }); 174 | 175 | export const register = async ( 176 | f: FastifyInstance, 177 | { 178 | jsonSchemas: { schemas, $ref }, 179 | swaggerOptions, 180 | swaggerUiOptions, 181 | transformSpec, 182 | }: RegisterOptions, 183 | ): Promise> => { 184 | for (const schema of schemas) { 185 | f.addSchema(schema); 186 | } 187 | await f.register(fastifySwagger, withRefResolver(swaggerOptions ?? {})); 188 | if (swaggerUiOptions !== false) { 189 | await f.register(fastifySwaggerUi, swaggerUiOptions ?? {}); 190 | 191 | if (transformSpec) { 192 | const originalRoutePrefix = 193 | swaggerUiOptions?.routePrefix ?? `/documentation`; 194 | const transformedRoutePrefix = 195 | transformSpec.routePrefix ?? `${originalRoutePrefix}_transformed`; 196 | 197 | const fetchTransformedSpec = async (): Promise => { 198 | const originalSpec = await f 199 | .inject({ 200 | method: `get`, 201 | url: `${swaggerUiOptions?.routePrefix ?? `documentation`}/json`, 202 | }) 203 | .then((res) => res.json()); 204 | const t = new SpecTransformer(originalSpec); 205 | return t.transform(transformSpec.options); 206 | }; 207 | 208 | let cachedTransformedSpec: null | Promise = null; 209 | const getTransformedSpec = async (): Promise => { 210 | if (!transformSpec.cache) { 211 | return await fetchTransformedSpec(); 212 | } 213 | if (!cachedTransformedSpec) { 214 | cachedTransformedSpec = fetchTransformedSpec(); 215 | } 216 | return await cachedTransformedSpec; 217 | }; 218 | 219 | let cachedTransformedSpecJson: null | string = null; 220 | const getTransformedSpecJson = async (): Promise => { 221 | const transformedSpec = await getTransformedSpec(); 222 | if (!transformSpec.cache) { 223 | return JSON.stringify(transformedSpec, null, 2); 224 | } 225 | if (!cachedTransformedSpecJson) { 226 | cachedTransformedSpecJson = JSON.stringify(transformedSpec, null, 2); 227 | } 228 | return cachedTransformedSpecJson; 229 | }; 230 | 231 | let cachedTransformedSpecYaml: null | string = null; 232 | const getTransformedSpecYaml = async (): Promise => { 233 | const transformedSpec = await getTransformedSpec(); 234 | if (!transformSpec.cache) { 235 | return yaml.dump(transformedSpec); 236 | } 237 | if (!cachedTransformedSpecYaml) { 238 | cachedTransformedSpecYaml = yaml.dump(transformedSpec); 239 | } 240 | return cachedTransformedSpecYaml; 241 | }; 242 | 243 | f.get(`${transformedRoutePrefix}/json`, async (_request, reply) => { 244 | reply.type(`application/json`); 245 | return await getTransformedSpecJson(); 246 | }); 247 | 248 | f.get(`${transformedRoutePrefix}/yaml`, async (_request, reply) => { 249 | reply.type(`text/x-yaml`); 250 | return await getTransformedSpecYaml(); 251 | }); 252 | } 253 | } 254 | 255 | const addRoute = < 256 | V extends V_, 257 | P extends P_, 258 | B extends B_, 259 | Q extends Q_, 260 | R extends R_, 261 | Rx extends Rx_, 262 | >({ 263 | method, 264 | url, 265 | operationId, 266 | params, 267 | body, 268 | reply, 269 | response, 270 | querystring, 271 | handler, 272 | ...fastifySchema 273 | }: RouteConfig): void => { 274 | const customSchema: FastifySchema = {}; 275 | if (operationId) { 276 | customSchema.operationId = operationId; 277 | } 278 | if (params) { 279 | customSchema.params = $ref(params as SchemaKeyOrDescription); 280 | } 281 | if (body) { 282 | customSchema.body = $ref(body as SchemaKeyOrDescription); 283 | } 284 | if (querystring) { 285 | customSchema.querystring = $ref(querystring as SchemaKeyOrDescription); 286 | } 287 | if (reply || response) { 288 | const customSchemaResponse: Record>> = {}; 289 | if (reply) { 290 | customSchemaResponse[200] = $ref(reply as SchemaKeyOrDescription); 291 | } 292 | if (response) { 293 | for (const code of Object.keys(response)) { 294 | customSchemaResponse[parseInt(code)] = $ref( 295 | response[parseInt(code)] as SchemaKeyOrDescription, 296 | ); 297 | } 298 | } 299 | customSchema.response = customSchemaResponse; 300 | } 301 | 302 | f[method]<{ 303 | Params: SchemaTypeOption; 304 | Body: SchemaTypeOption; 305 | Querystring: SchemaTypeOption; 306 | Reply: SchemaTypeOption< 307 | M, 308 | R | (Rx extends Record ? Rx[number] : never) 309 | >; 310 | }>( 311 | url, 312 | { 313 | schema: { 314 | ...customSchema, 315 | ...fastifySchema, 316 | }, 317 | }, 318 | handler as RouteHandlerMethod, 319 | ); 320 | }; 321 | 322 | const createAddRoute = 323 | (method: Method): FastifyZod[Method] => 324 | (url, config, handler) => 325 | addRoute({ url, handler, method, ...config }); 326 | 327 | const pluginInstance: FastifyZod = { 328 | delete: createAddRoute(`delete`), 329 | get: createAddRoute(`get`), 330 | head: createAddRoute(`head`), 331 | options: createAddRoute(`options`), 332 | patch: createAddRoute(`patch`), 333 | post: createAddRoute(`post`), 334 | put: createAddRoute(`put`), 335 | }; 336 | 337 | f.decorate(`zod`, pluginInstance); 338 | 339 | return f as FastifyZodInstance; 340 | }; 341 | -------------------------------------------------------------------------------- /src/JsonSchema.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodType } from "zod"; 2 | import zodToJsonSchema from "zod-to-json-schema"; 3 | 4 | import { Models, SchemaKeyOrDescription } from "./Models"; 5 | export type BuildJsonSchemasOptions = { 6 | readonly $id?: string; 7 | readonly target?: `jsonSchema7` | `openApi3`; 8 | readonly errorMessages?: boolean; 9 | }; 10 | 11 | export type $Ref = (key: SchemaKeyOrDescription) => { 12 | readonly $ref: string; 13 | readonly description?: string; 14 | }; 15 | 16 | export type JsonSchema = { 17 | readonly $id: string; 18 | }; 19 | 20 | export type BuildJsonSchemasResult = { 21 | readonly schemas: JsonSchema[]; 22 | readonly $ref: $Ref; 23 | }; 24 | 25 | /** 26 | * @deprecated 27 | */ 28 | export const buildJsonSchema = ( 29 | Type: ZodType, 30 | schemaKey: string, 31 | ): JsonSchema => 32 | buildJsonSchemas({ [schemaKey]: Type }, { $id: schemaKey }).schemas[0]; 33 | 34 | export const buildJsonSchemas = ( 35 | models: M, 36 | opts: BuildJsonSchemasOptions = {}, 37 | ): BuildJsonSchemasResult => { 38 | const zodSchema = z.object(models); 39 | 40 | const $id = opts.$id ?? `Schema`; 41 | 42 | const zodJsonSchema = zodToJsonSchema(zodSchema, { 43 | target: opts.target, 44 | basePath: [`${$id}#`], 45 | errorMessages: opts.errorMessages, 46 | }); 47 | 48 | const jsonSchema: JsonSchema = { 49 | $id, 50 | ...zodJsonSchema, 51 | }; 52 | 53 | const $ref: $Ref = (key) => { 54 | const $ref = `${$id}#/properties/${ 55 | typeof key === `string` ? key : key.key 56 | }`; 57 | return typeof key === `string` 58 | ? { 59 | $ref, 60 | } 61 | : { 62 | $ref, 63 | description: key.description, 64 | }; 65 | }; 66 | 67 | return { 68 | schemas: [jsonSchema], 69 | $ref, 70 | }; 71 | }; 72 | -------------------------------------------------------------------------------- /src/Models.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodType } from "zod"; 2 | 3 | export type Models = { 4 | readonly [K in Key]: ZodType; 5 | }; 6 | 7 | export type SchemaKey = M extends Models 8 | ? Key & string 9 | : never; 10 | 11 | export type SchemaKeyOrDescription = 12 | | SchemaKey 13 | | { 14 | readonly description: string; 15 | readonly key: SchemaKey; 16 | }; 17 | 18 | type SchemaType> = z.infer; 19 | 20 | export type SchemaTypeOption< 21 | M extends Models, 22 | Key extends void | SchemaKey, 23 | > = Key extends SchemaKey ? SchemaType : void; 24 | -------------------------------------------------------------------------------- /src/Path.ts: -------------------------------------------------------------------------------- 1 | import { isRecord } from "./util"; 2 | 3 | export const stringifyPath = (path: string[]): string => path.join(`/`); 4 | 5 | export const equalPath = (a: string[], b: string[]): boolean => { 6 | if (a.length !== b.length) { 7 | return false; 8 | } 9 | for (let k = 0; k < a.length; k++) { 10 | if (a[k] !== b[k]) { 11 | return false; 12 | } 13 | } 14 | return true; 15 | }; 16 | 17 | const getParentPath = (path: T[]): T[] => { 18 | if (path.length === 0) { 19 | throw new Error(`path='${stringifyPath(path)}' has no parent`); 20 | } 21 | return path.slice(0, -1); 22 | }; 23 | 24 | export const getChildPath = ( 25 | path: T[], 26 | ...childPath: T[] 27 | ): T[] => [...path, ...childPath]; 28 | 29 | const getPathLastSegment = (path: T[]): T => { 30 | if (path.length === 0) { 31 | throw new Error(`path='${stringifyPath}' has no parent`); 32 | } 33 | return path[path.length - 1]; 34 | }; 35 | 36 | const getChildSafe = ( 37 | current: unknown, 38 | segment: string, 39 | ): [childFound: boolean, value: unknown] => { 40 | if (Array.isArray(current)) { 41 | const index = parseInt(segment); 42 | if (!Number.isInteger(index)) { 43 | throw new Error( 44 | `current is an array but segment='${segment}' is not an integer`, 45 | ); 46 | } 47 | if (index < 0) { 48 | throw new Error(`current is an array but index='${index}' is negative`); 49 | } 50 | return [typeof current[index] !== undefined, current[index]]; 51 | } 52 | if (isRecord(current)) { 53 | return [Object.keys(current).includes(segment), current[segment]]; 54 | } 55 | return [false, undefined]; 56 | }; 57 | 58 | export const getAtPathSafe = ( 59 | obj: unknown, 60 | path: string[], 61 | ): [valueFound: boolean, value: unknown] => { 62 | let current = obj; 63 | 64 | for (let k = 0; k < path.length; k++) { 65 | const [childFound, child] = getChildSafe(current, path[k]); 66 | if (!childFound) { 67 | if (k !== path.length - 1) { 68 | throw new Error( 69 | `parent(path='${stringifyPath(path)}') has no child at segment='${ 70 | path[k] 71 | }'`, 72 | ); 73 | } 74 | return [false, undefined]; 75 | } 76 | current = child; 77 | } 78 | return [true, current]; 79 | }; 80 | 81 | export const getAtPath = (obj: unknown, path: string[]): unknown => { 82 | const [found, value] = getAtPathSafe(obj, path); 83 | if (!found) { 84 | throw new Error(`value(path='${stringifyPath(path)}') not found`); 85 | } 86 | return value; 87 | }; 88 | 89 | export const setAtPath = ( 90 | obj: unknown, 91 | path: string[], 92 | value: unknown, 93 | ): void => { 94 | const [parentFound, parent] = getAtPathSafe(obj, getParentPath(path)); 95 | if (!parentFound) { 96 | throw new Error(`parent(path='${stringifyPath(path)}') not found`); 97 | } 98 | const key = getPathLastSegment(path); 99 | 100 | if (Array.isArray(parent)) { 101 | const index = parseInt(key); 102 | if (!Number.isInteger(index)) { 103 | throw new Error( 104 | `key(path='${stringifyPath(path)}', key='${key}') is not an integer`, 105 | ); 106 | } 107 | if (index < 0) { 108 | throw new Error( 109 | `index(path='${stringifyPath(path)}', index='${index}') is negative`, 110 | ); 111 | } 112 | if (index > parent.length) { 113 | throw new Error( 114 | `index(path='${stringifyPath( 115 | path, 116 | )}', index='${index}') is out of bounds`, 117 | ); 118 | } 119 | if (index === parent.length) { 120 | parent.push(value); 121 | } else { 122 | parent[index] = value; 123 | } 124 | } else if (isRecord(parent)) { 125 | parent[key] = value; 126 | } else { 127 | throw new Error(`parent(path='${path}') is not an Array or a Record`); 128 | } 129 | }; 130 | 131 | export const deleteAtPath = (obj: unknown, path: string[]): void => { 132 | const [parentFound, parent] = getAtPathSafe(obj, getParentPath(path)); 133 | if (!parentFound) { 134 | throw new Error(`parent(path='${stringifyPath(path)}') not found`); 135 | } 136 | const key = getPathLastSegment(path); 137 | 138 | if (Array.isArray(parent)) { 139 | const index = parseInt(key); 140 | if (!Number.isInteger(index)) { 141 | throw new Error( 142 | `key(path='${stringifyPath(path)}', key='${key}') is not an integer`, 143 | ); 144 | } 145 | if (index !== parent.length) { 146 | throw new Error( 147 | `index(path='${stringifyPath( 148 | path, 149 | )}', index='${index}') is invalid: only last item of parent array can be deleted`, 150 | ); 151 | } 152 | parent.pop(); 153 | } else if (isRecord(parent)) { 154 | if (!Object.keys(parent).includes(key)) { 155 | throw new Error( 156 | `key(path='${stringifyPath(path)}', key='${key}') not found in parent`, 157 | ); 158 | } 159 | delete parent[key]; 160 | } else { 161 | throw new Error( 162 | `parent(path='${stringifyPath(path)}') is not an Array or a Record`, 163 | ); 164 | } 165 | }; 166 | 167 | export const matchPathPrefix = ( 168 | prefixPath: readonly string[], 169 | path: string[], 170 | ): boolean => { 171 | if (path.length < prefixPath.length) { 172 | return false; 173 | } 174 | for (let k = 0; k < prefixPath.length; k++) { 175 | if (path[k] !== prefixPath[k]) { 176 | return false; 177 | } 178 | } 179 | return true; 180 | }; 181 | 182 | export const replacePathPrefix = ( 183 | prevPrefixPath: T[], 184 | nextPrefixPath: T[], 185 | path: T[], 186 | ): T[] => { 187 | if (!matchPathPrefix(prevPrefixPath, path)) { 188 | throw new Error( 189 | `path='${stringifyPath( 190 | path, 191 | )}' doesn't match prevPrefixPath='${stringifyPath(prevPrefixPath)}'`, 192 | ); 193 | } 194 | return [...nextPrefixPath, ...path.slice(prevPrefixPath.length)]; 195 | }; 196 | -------------------------------------------------------------------------------- /src/SpecTransformer.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from "util"; 2 | 3 | import { camelCase, pascalCase, snakeCase, paramCase } from "change-case"; 4 | import deepEqual from "fast-deep-equal"; 5 | 6 | import { 7 | deleteAtPath, 8 | equalPath, 9 | getAtPath, 10 | getAtPathSafe, 11 | getChildPath, 12 | matchPathPrefix, 13 | replacePathPrefix, 14 | setAtPath, 15 | } from "./Path"; 16 | import { findFirstDeep, isRecord, visitDeep } from "./util"; 17 | 18 | type Ref = { 19 | $ref: string; 20 | }; 21 | 22 | const isRef = (ref: unknown): ref is Ref => { 23 | if (!isRecord(ref)) { 24 | return false; 25 | } 26 | return typeof ref.$ref === `string`; 27 | }; 28 | 29 | type RefPath = { 30 | readonly basePath: string; 31 | readonly path: string[]; 32 | }; 33 | 34 | const toRefPath = ($ref: string): RefPath => { 35 | const indexOfPath = $ref.indexOf(`#`); 36 | return { 37 | basePath: $ref.slice(0, indexOfPath), 38 | path: $ref 39 | .slice(indexOfPath + 1) 40 | .split(`/`) 41 | .slice(1), 42 | }; 43 | }; 44 | 45 | const toRef = (refPath: RefPath): Ref => ({ 46 | $ref: [`${refPath.basePath}#`, ...refPath.path].join(`/`), 47 | }); 48 | 49 | type SpecSchema = { 50 | readonly properties?: Record; 51 | readonly additionalProperties?: boolean | Record; 52 | readonly patternProperties?: Record; 53 | readonly items?: Record; 54 | }; 55 | 56 | const isSpecSchema = (input: unknown): input is SpecSchema => { 57 | if (!isRecord(input)) { 58 | return false; 59 | } 60 | const { properties, additionalProperties, patternProperties, items } = input; 61 | if (typeof properties !== `undefined` && !isRecord(properties)) { 62 | return false; 63 | } 64 | 65 | if ( 66 | typeof additionalProperties !== `undefined` && 67 | typeof additionalProperties !== `boolean` && 68 | !isRecord(additionalProperties) 69 | ) { 70 | return false; 71 | } 72 | 73 | if ( 74 | typeof patternProperties !== `undefined` && 75 | typeof patternProperties !== `boolean` && 76 | !isRecord(patternProperties) 77 | ) { 78 | return false; 79 | } 80 | 81 | if (typeof items !== `undefined` && !isRecord(items)) { 82 | return false; 83 | } 84 | 85 | return true; 86 | }; 87 | 88 | type SpecSchemas = Record; 89 | 90 | const isSpecSchemas = (input: unknown): input is SpecSchemas => { 91 | if (!isRecord(input)) { 92 | return false; 93 | } 94 | for (const value of Object.values(input)) { 95 | if (!isSpecSchema(value)) { 96 | return false; 97 | } 98 | } 99 | return true; 100 | }; 101 | 102 | type OpenApiSpec = { 103 | readonly components: { 104 | readonly schemas: SpecSchemas; 105 | }; 106 | readonly paths?: Record; 107 | }; 108 | 109 | const isOpenApiSpec = (input: unknown): input is OpenApiSpec => { 110 | if (!isRecord(input)) { 111 | return false; 112 | } 113 | const { components, paths } = input; 114 | if (!isRecord(components)) { 115 | return false; 116 | } 117 | const { schemas } = components; 118 | if (!isSpecSchemas(schemas)) { 119 | return false; 120 | } 121 | if (typeof paths !== `undefined` && !isRecord(paths)) { 122 | return false; 123 | } 124 | return true; 125 | }; 126 | 127 | type SwaggerSpec = { 128 | readonly definitions: SpecSchemas; 129 | readonly paths?: Record; 130 | }; 131 | 132 | const isSwaggerSpec = (input: unknown): input is SwaggerSpec => { 133 | if (!isRecord(input)) { 134 | return false; 135 | } 136 | const { definitions, paths } = input; 137 | if (!isSpecSchemas(definitions)) { 138 | return false; 139 | } 140 | if (typeof paths !== `undefined` && !isRecord(paths)) { 141 | return false; 142 | } 143 | return true; 144 | }; 145 | 146 | export type Spec = OpenApiSpec | SwaggerSpec; 147 | 148 | const isSpec = (input: unknown): input is Spec => 149 | isOpenApiSpec(input) || isSwaggerSpec(input); 150 | 151 | const deepClone = (spec: Spec): Spec => JSON.parse(JSON.stringify(spec)); 152 | 153 | type ExtractSchemaPropertiesKey = 154 | | `properties` 155 | | `additionalProperties` 156 | | `patternProperties` 157 | | `items`; 158 | 159 | const defaultExtractSchemaPropertiesKey: ExtractSchemaPropertiesKey[] = [ 160 | `properties`, 161 | `additionalProperties`, 162 | `patternProperties`, 163 | `items`, 164 | ]; 165 | 166 | type Timings = { 167 | readonly begin: number; 168 | readonly end: number; 169 | readonly delta: number; 170 | }; 171 | 172 | const withTimings = (fn: () => T): [result: T, timings: Timings] => { 173 | const begin = performance.now(); 174 | const result = fn(); 175 | const end = performance.now(); 176 | 177 | return [ 178 | result, 179 | { 180 | begin, 181 | end, 182 | delta: end - begin, 183 | }, 184 | ]; 185 | }; 186 | 187 | type TransformWithTimingsResult = { 188 | readonly spec: Spec; 189 | readonly timings: { 190 | readonly rewriteSchemasAbsoluteRefs: Timings; 191 | readonly extractSchemasProperties: Timings; 192 | readonly mergeRefs: Timings; 193 | readonly deleteUnusedSchemas: Timings; 194 | readonly total: Timings; 195 | }; 196 | }; 197 | 198 | type SchemaKeysOptions = { 199 | readonly removeInitialSchemasPrefix?: boolean; 200 | readonly changeCase?: 201 | | `preserve` 202 | | `camelCase` 203 | | `PascalCase` 204 | | `snake_case` 205 | | `param-case`; 206 | }; 207 | 208 | export type TransformOptions = { 209 | readonly rewriteSchemasAbsoluteRefs?: boolean; 210 | readonly extractSchemasProperties?: boolean | ExtractSchemaPropertiesKey[]; 211 | readonly mergeRefs?: Ref[]; 212 | readonly deleteUnusedSchemas?: boolean; 213 | readonly schemaKeys?: SchemaKeysOptions; 214 | }; 215 | 216 | export class SpecTransformer { 217 | private readonly DEBUG: boolean; 218 | private readonly spec: Spec; 219 | private readonly initialSchemaKeys: string[]; 220 | 221 | private readonly schemasPath: string[]; 222 | 223 | public constructor(spec: unknown, DEBUG = false) { 224 | if (!isSpec(spec)) { 225 | throw new Error(`spec is not an OpenApiSpec or a SwaggerSpec`); 226 | } 227 | this.spec = deepClone(spec); 228 | this.schemasPath = isOpenApiSpec(spec) 229 | ? [`components`, `schemas`] 230 | : [`definitions`]; 231 | this.initialSchemaKeys = this.getSchemaKeys(); 232 | this.DEBUG = DEBUG; 233 | } 234 | 235 | private readonly _DEBUG = (...args: unknown[]): void => { 236 | if (this.DEBUG) { 237 | console.debug(...args); 238 | } 239 | }; 240 | 241 | private readonly throw = (error: unknown): never => { 242 | if (this.DEBUG) { 243 | console.debug(inspect(this.spec, { depth: null })); 244 | console.error(error); 245 | } 246 | throw error; 247 | }; 248 | 249 | private readonly getSchemaPath = (schemaKey: string): string[] => 250 | getChildPath(getChildPath(this.schemasPath, schemaKey)); 251 | 252 | private readonly getSchema = (schemaKey: string): SpecSchema => { 253 | const schema = this.getAtPath(this.getSchemaPath(schemaKey)); 254 | if (!isSpecSchema(schema)) { 255 | return this.throw(new Error(`schema is not a SpecSchema`)); 256 | } 257 | return schema; 258 | }; 259 | 260 | private readonly getSchemaKeys = (): string[] => { 261 | const schemas = this.getAtPath(this.schemasPath); 262 | if (!isRecord(schemas)) { 263 | return this.throw(new Error(`schemas is not a Record`)); 264 | } 265 | return Object.keys(schemas); 266 | }; 267 | 268 | private readonly createSchemaKey = ( 269 | parentSchemaKey: string, 270 | path: string[], 271 | schemaKeysOptions?: SchemaKeysOptions, 272 | ): string => { 273 | const parts: string[] = []; 274 | if ( 275 | !schemaKeysOptions?.removeInitialSchemasPrefix || 276 | !this.initialSchemaKeys.includes(parentSchemaKey) 277 | ) { 278 | parts.push(parentSchemaKey); 279 | } 280 | parts.push(...path); 281 | const baseName = parts.join(`_`); 282 | const schemaKey = 283 | schemaKeysOptions?.changeCase === `PascalCase` 284 | ? pascalCase(baseName) 285 | : schemaKeysOptions?.changeCase === `camelCase` 286 | ? camelCase(baseName) 287 | : schemaKeysOptions?.changeCase === `param-case` 288 | ? paramCase(baseName) 289 | : schemaKeysOptions?.changeCase === `snake_case` 290 | ? snakeCase(baseName) 291 | : baseName; 292 | if (this.getSchemaKeys().includes(schemaKey)) { 293 | throw new Error(`schemaKey(schemaKey='${schemaKey}') already exists`); 294 | } 295 | return schemaKey; 296 | }; 297 | 298 | private readonly getAtPath = (path: string[]): unknown => 299 | getAtPath(this.spec, path); 300 | 301 | private readonly getAtPathSafe = ( 302 | path: string[], 303 | ): [valueFound: boolean, value: unknown] => getAtPathSafe(this.spec, path); 304 | 305 | private readonly resolveRef = ( 306 | $ref: string, 307 | ): [value: unknown, $ref: string] => { 308 | const refPath = toRefPath($ref); 309 | if (refPath.basePath.length > 0) { 310 | const nextRef = toRef({ 311 | basePath: ``, 312 | path: getChildPath( 313 | this.getSchemaPath(refPath.basePath), 314 | ...refPath.path, 315 | ), 316 | }); 317 | return this.resolveRef(nextRef.$ref); 318 | } 319 | const value = this.getAtPath(refPath.path); 320 | if (isRef(value)) { 321 | return this.resolveRef(value.$ref); 322 | } 323 | return [value, $ref]; 324 | }; 325 | 326 | private readonly setAtPath = (path: string[], value: unknown): void => { 327 | this._DEBUG(`setAtPath`, { path, value }); 328 | return setAtPath(this.spec, path, value); 329 | }; 330 | 331 | private readonly deleteAtPath = (path: string[]): void => { 332 | this._DEBUG(`deleteAtPath`, { path }); 333 | return deleteAtPath(this.spec, path); 334 | }; 335 | 336 | private readonly findFirstDeep = ( 337 | predicate: (value: unknown, path: string[]) => boolean, 338 | ): [found: boolean, value: unknown, path: string[]] => 339 | findFirstDeep(this.spec, predicate); 340 | 341 | private readonly hasProperRef = (schemaKey: string): boolean => { 342 | const schemaPath = this.getSchemaPath(schemaKey); 343 | const [hasProperRef] = this.findFirstDeep((value, path) => { 344 | if (equalPath(path, schemaPath)) { 345 | return false; 346 | } 347 | if (isRef(value)) { 348 | const refPath = toRefPath(value.$ref); 349 | return ( 350 | refPath.basePath.length === 0 && 351 | matchPathPrefix(schemaPath, refPath.path) 352 | ); 353 | } 354 | return false; 355 | }); 356 | return hasProperRef; 357 | }; 358 | 359 | private readonly deleteUnusedSchemas = (): void => { 360 | this._DEBUG(`deleteUnusedSchemas`); 361 | let dirty = true; 362 | while (dirty) { 363 | dirty = false; 364 | for (const schemaKey of this.getSchemaKeys()) { 365 | if (!this.hasProperRef(schemaKey)) { 366 | this.deleteAtPath(this.getSchemaPath(schemaKey)); 367 | dirty = true; 368 | break; 369 | } 370 | } 371 | } 372 | }; 373 | 374 | private readonly rewriteSchemaAbsoluteRefs = (schemaKey: string): boolean => { 375 | const schema = this.getSchema(schemaKey); 376 | const schemaPath = this.getSchemaPath(schemaKey); 377 | let dirty = false; 378 | visitDeep(schema, (value) => { 379 | if (isRef(value)) { 380 | const refPath = toRefPath(value.$ref); 381 | if ( 382 | refPath.basePath.length === 0 && 383 | !matchPathPrefix(schemaPath, refPath.path) 384 | ) { 385 | const nextPath = getChildPath(schemaPath, ...refPath.path); 386 | value.$ref = toRef({ basePath: ``, path: nextPath }).$ref; 387 | dirty = true; 388 | } 389 | } 390 | }); 391 | return dirty; 392 | }; 393 | 394 | private readonly rewriteSchemasAbsoluteRefs = (): void => { 395 | this._DEBUG(`rewriteSchemasAbsoluteRefs`); 396 | let dirty = true; 397 | while (dirty) { 398 | dirty = false; 399 | for (const schemaKey of this.getSchemaKeys()) { 400 | if (this.rewriteSchemaAbsoluteRefs(schemaKey)) { 401 | dirty = true; 402 | break; 403 | } 404 | } 405 | } 406 | }; 407 | 408 | private readonly extractSchemaPathAsSchema = ( 409 | prevSchemaKey: string, 410 | prevRelativePath: string[], 411 | nextSchemaKey: string, 412 | ): boolean => { 413 | const prevPath = getChildPath( 414 | this.getSchemaPath(prevSchemaKey), 415 | ...prevRelativePath, 416 | ); 417 | 418 | const prevValue = this.getAtPath(prevPath); 419 | const nextPath = this.getSchemaPath(nextSchemaKey); 420 | 421 | const [hasValueAtNextPath, valueAtNextPath] = this.getAtPathSafe(nextPath); 422 | 423 | if (hasValueAtNextPath) { 424 | if (!deepEqual(prevValue, valueAtNextPath)) { 425 | this.throw( 426 | new Error( 427 | `schema(schemaKey='${nextSchemaKey}') already exists with a different value`, 428 | ), 429 | ); 430 | } 431 | } else { 432 | this.setAtPath(nextPath, prevValue); 433 | } 434 | 435 | this.setAtPath(prevPath, toRef({ basePath: ``, path: nextPath })); 436 | 437 | let dirty = false; 438 | visitDeep(this.spec, (value) => { 439 | if (isRef(value)) { 440 | const refPath = toRefPath(value.$ref); 441 | if ( 442 | refPath.basePath.length === 0 && 443 | matchPathPrefix(prevPath, refPath.path) 444 | ) { 445 | value.$ref = toRef({ 446 | basePath: ``, 447 | path: replacePathPrefix(prevPath, nextPath, refPath.path), 448 | }).$ref; 449 | dirty = true; 450 | } 451 | } 452 | }); 453 | return dirty; 454 | }; 455 | 456 | private readonly extractSchemaPropertiesAtKey = ( 457 | parentSchemaKey: string, 458 | propertiesKey: ExtractSchemaPropertiesKey, 459 | schemaKeysOptions?: SchemaKeysOptions, 460 | ): boolean => { 461 | let globalDirty = false; 462 | let dirty = true; 463 | while (dirty) { 464 | dirty = false; 465 | if ( 466 | propertiesKey === `properties` || 467 | propertiesKey === `additionalProperties` || 468 | propertiesKey === `patternProperties` 469 | ) { 470 | const properties = this.getSchema(parentSchemaKey)[propertiesKey]; 471 | if (isRecord(properties)) { 472 | for (const k of Object.keys(properties)) { 473 | const property = properties[k]; 474 | if (!isRef(property)) { 475 | const nextSchemaKey = this.createSchemaKey( 476 | parentSchemaKey, 477 | [`${k}`], 478 | schemaKeysOptions, 479 | ); 480 | this.extractSchemaPathAsSchema( 481 | parentSchemaKey, 482 | [propertiesKey, k], 483 | nextSchemaKey, 484 | ); 485 | dirty = true; 486 | break; 487 | } 488 | } 489 | } 490 | } 491 | if (propertiesKey === `items`) { 492 | const properties = this.getSchema(parentSchemaKey)[propertiesKey]; 493 | if (isRecord(properties) && !isRef(properties)) { 494 | const nextSchemaKey = this.createSchemaKey( 495 | parentSchemaKey, 496 | [`item`], 497 | schemaKeysOptions, 498 | ); 499 | this.extractSchemaPathAsSchema( 500 | parentSchemaKey, 501 | [`items`], 502 | nextSchemaKey, 503 | ); 504 | dirty = true; 505 | } 506 | } 507 | if (dirty) { 508 | globalDirty = true; 509 | } 510 | } 511 | return globalDirty; 512 | }; 513 | 514 | private readonly extractSchemaProperties = ( 515 | schemaKey: string, 516 | propertiesKeys: ExtractSchemaPropertiesKey[], 517 | schemaKeysOptions?: SchemaKeysOptions, 518 | ): boolean => { 519 | let globalDirty = false; 520 | let dirty = true; 521 | while (dirty) { 522 | dirty = false; 523 | for (const propertiesKey of propertiesKeys) { 524 | if ( 525 | this.extractSchemaPropertiesAtKey( 526 | schemaKey, 527 | propertiesKey, 528 | schemaKeysOptions, 529 | ) 530 | ) { 531 | dirty = true; 532 | } 533 | } 534 | if (dirty) { 535 | globalDirty = true; 536 | } 537 | } 538 | for (const propertiesKey of propertiesKeys) { 539 | if ( 540 | this.extractSchemaPropertiesAtKey( 541 | schemaKey, 542 | propertiesKey, 543 | schemaKeysOptions, 544 | ) 545 | ) { 546 | this.extractSchemaProperties( 547 | schemaKey, 548 | propertiesKeys, 549 | schemaKeysOptions, 550 | ); 551 | return true; 552 | } 553 | } 554 | return globalDirty; 555 | }; 556 | 557 | private readonly extractSchemasProperties = ( 558 | propertiesKeys: ExtractSchemaPropertiesKey[], 559 | schemaKeysOptions?: SchemaKeysOptions, 560 | ): void => { 561 | this._DEBUG(`extractSchemasProperties`); 562 | let dirty = true; 563 | while (dirty) { 564 | dirty = false; 565 | for (const schemaKey of this.getSchemaKeys()) { 566 | if ( 567 | this.extractSchemaProperties( 568 | schemaKey, 569 | propertiesKeys, 570 | schemaKeysOptions, 571 | ) 572 | ) { 573 | dirty = true; 574 | break; 575 | } 576 | } 577 | } 578 | }; 579 | 580 | private readonly mergeRef = (prev$ref: string): boolean => { 581 | const [schema, next$ref] = this.resolveRef(prev$ref); 582 | let globalDirty = false; 583 | let dirty = true; 584 | while (dirty) { 585 | dirty = false; 586 | visitDeep(this.spec, (value, path) => { 587 | if (value !== schema && deepEqual(value, schema)) { 588 | this.setAtPath(path, { 589 | $ref: next$ref, 590 | }); 591 | dirty = true; 592 | } 593 | }); 594 | if (dirty) { 595 | globalDirty = true; 596 | } 597 | } 598 | return globalDirty; 599 | }; 600 | 601 | private readonly mergeRefs = ($refs: string[]): void => { 602 | let dirty = true; 603 | while (dirty) { 604 | dirty = false; 605 | for (const $ref of $refs) { 606 | if (this.mergeRef($ref)) { 607 | dirty = true; 608 | break; 609 | } 610 | } 611 | } 612 | }; 613 | 614 | public readonly transformWithTimings = ( 615 | opts: TransformOptions = {}, 616 | ): TransformWithTimingsResult => { 617 | const [ 618 | { 619 | rewriteSchemasAbsoluteRefs, 620 | extractSchemasProperties, 621 | mergeRefs, 622 | deleteUnusedSchemas, 623 | }, 624 | total, 625 | ] = withTimings(() => { 626 | const [, rewriteSchemasAbsoluteRefs] = withTimings(() => { 627 | if (opts.rewriteSchemasAbsoluteRefs === false) { 628 | return; 629 | } 630 | this.rewriteSchemasAbsoluteRefs(); 631 | }); 632 | 633 | const [, mergeRefs] = withTimings(() => { 634 | if (opts.mergeRefs) { 635 | this.mergeRefs(opts.mergeRefs.map((ref) => ref.$ref)); 636 | } 637 | }); 638 | 639 | this._DEBUG({ mergeRefs }); 640 | 641 | this._DEBUG({ rewriteSchemasAbsoluteRefs }); 642 | 643 | const [, extractSchemasProperties] = withTimings(() => { 644 | if (opts.extractSchemasProperties === false) { 645 | return; 646 | } 647 | const extractSchemasPropertiesKeys = !Array.isArray( 648 | opts.extractSchemasProperties, 649 | ) 650 | ? defaultExtractSchemaPropertiesKey 651 | : opts.extractSchemasProperties; 652 | this.extractSchemasProperties( 653 | extractSchemasPropertiesKeys, 654 | opts.schemaKeys, 655 | ); 656 | }); 657 | 658 | this._DEBUG({ extractSchemasProperties }); 659 | 660 | const [, deleteUnusedSchemas] = withTimings(() => { 661 | if (opts.deleteUnusedSchemas === false) { 662 | return; 663 | } 664 | this.deleteUnusedSchemas(); 665 | }); 666 | 667 | this._DEBUG({ deleteUnusedSchemas }); 668 | 669 | return { 670 | rewriteSchemasAbsoluteRefs, 671 | extractSchemasProperties, 672 | mergeRefs, 673 | deleteUnusedSchemas, 674 | }; 675 | }); 676 | 677 | this._DEBUG({ total }); 678 | 679 | return { 680 | spec: this.spec, 681 | timings: { 682 | rewriteSchemasAbsoluteRefs, 683 | extractSchemasProperties, 684 | mergeRefs, 685 | deleteUnusedSchemas, 686 | total, 687 | }, 688 | }; 689 | }; 690 | 691 | public readonly transform = (opts: TransformOptions = {}): Spec => 692 | this.transformWithTimings(opts).spec; 693 | } 694 | -------------------------------------------------------------------------------- /src/__tests__/FastifyZod.test.ts: -------------------------------------------------------------------------------- 1 | import { buildJsonSchemas } from "../JsonSchema"; 2 | 3 | import { models } from "./models.fixtures"; 4 | import { createTestServer } from "./server.fixtures"; 5 | 6 | test(`FastifyZod`, async () => { 7 | const jsonSchemas = buildJsonSchemas(models, { errorMessages: true }); 8 | const f = await createTestServer( 9 | { 10 | ajv: { 11 | customOptions: { 12 | allErrors: true, 13 | }, 14 | plugins: [require(`ajv-errors`)], 15 | }, 16 | }, 17 | { 18 | jsonSchemas, 19 | transformSpec: { 20 | options: { 21 | mergeRefs: [jsonSchemas.$ref(`TodoState`)], 22 | }, 23 | }, 24 | }, 25 | ); 26 | 27 | await expect( 28 | f 29 | .inject({ 30 | method: `get`, 31 | url: `/item`, 32 | }) 33 | .then((res) => res.json()), 34 | ).resolves.toEqual({ todoItems: [] }); 35 | 36 | await expect( 37 | f 38 | .inject({ 39 | method: `post`, 40 | url: `/item`, 41 | payload: {}, 42 | }) 43 | .then((res) => res.json()), 44 | ).resolves.toEqual({ 45 | code: `FST_ERR_VALIDATION`, 46 | error: `Bad Request`, 47 | message: `body must have required property 'id', body must have required property 'label', body must have required property 'state'`, 48 | statusCode: 400, 49 | }); 50 | 51 | await expect( 52 | f 53 | .inject({ 54 | method: `post`, 55 | url: `/item`, 56 | payload: { 57 | id: 1337, 58 | label: `todo`, 59 | state: `todo`, 60 | }, 61 | }) 62 | .then((res) => res.json()), 63 | ).resolves.toEqual({ 64 | code: `FST_ERR_VALIDATION`, 65 | error: `Bad Request`, 66 | message: `body/id invalid todo item id`, 67 | statusCode: 400, 68 | }); 69 | 70 | await expect( 71 | f 72 | .inject({ 73 | method: `get`, 74 | url: `/item/e7f7082a-4f16-430d-8c3b-db6b8d4d3e73`, 75 | }) 76 | .then(async (response) => ({ 77 | statusCode: response.statusCode, 78 | body: await response.json(), 79 | })), 80 | ).resolves.toEqual({ 81 | statusCode: 404, 82 | body: { 83 | id: `e7f7082a-4f16-430d-8c3b-db6b8d4d3e73`, 84 | message: `item not found`, 85 | }, 86 | }); 87 | 88 | await expect( 89 | f 90 | .inject({ 91 | method: `post`, 92 | url: `/item`, 93 | payload: { 94 | id: `e7f7082a-4f16-430d-8c3b-db6b8d4d3e73`, 95 | label: `todo`, 96 | state: `todo`, 97 | dueDateMs: new Date(1337).getTime(), 98 | }, 99 | }) 100 | .then((res) => res.json()), 101 | ).resolves.toEqual({ 102 | todoItems: [ 103 | { 104 | id: `e7f7082a-4f16-430d-8c3b-db6b8d4d3e73`, 105 | label: `todo`, 106 | state: `todo`, 107 | dueDateMs: new Date(1337).getTime(), 108 | }, 109 | ], 110 | }); 111 | 112 | await expect( 113 | f 114 | .inject({ 115 | method: `get`, 116 | url: `/item/e7f7082a-4f16-430d-8c3b-db6b8d4d3e73`, 117 | }) 118 | .then(async (response) => ({ 119 | statusCode: response.statusCode, 120 | body: await response.json(), 121 | })), 122 | ).resolves.toEqual({ 123 | statusCode: 200, 124 | body: { 125 | id: `e7f7082a-4f16-430d-8c3b-db6b8d4d3e73`, 126 | label: `todo`, 127 | state: `todo`, 128 | dueDateMs: new Date(1337).getTime(), 129 | }, 130 | }); 131 | 132 | await expect( 133 | f 134 | .inject({ 135 | method: `put`, 136 | url: `/item/1337`, 137 | }) 138 | .then((res) => res.json()), 139 | ).resolves.toEqual({ 140 | code: `FST_ERR_VALIDATION`, 141 | error: `Bad Request`, 142 | message: `params/id invalid todo item id`, 143 | statusCode: 400, 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /src/__tests__/SpecTransformer.test.ts: -------------------------------------------------------------------------------- 1 | import { SpecTransformer } from "../SpecTransformer"; 2 | 3 | describe(`SpecTransformer`, () => { 4 | test(`transform`, () => { 5 | const originalSpec = { 6 | openapi: `3.0.3`, 7 | info: { 8 | title: `Fastify Zod Test Server`, 9 | description: `Test Server for Fastify Zod`, 10 | version: `0.0.0`, 11 | }, 12 | components: { 13 | schemas: { 14 | Schema: { 15 | type: `object`, 16 | properties: { 17 | TodoState: { 18 | type: `string`, 19 | enum: [`todo`, `in progress`, `done`], 20 | }, 21 | TodoItemId: { 22 | type: `object`, 23 | properties: { 24 | id: { 25 | type: `string`, 26 | format: `uuid`, 27 | }, 28 | }, 29 | required: [`id`], 30 | additionalProperties: false, 31 | }, 32 | TodoItem: { 33 | type: `object`, 34 | properties: { 35 | id: { 36 | type: `string`, 37 | format: `uuid`, 38 | }, 39 | label: { 40 | type: `string`, 41 | }, 42 | dueDateMs: { 43 | type: `integer`, 44 | minimum: 0, 45 | }, 46 | state: { 47 | type: `string`, 48 | enum: [`todo`, `in progress`, `done`], 49 | }, 50 | }, 51 | required: [`id`, `label`, `state`], 52 | additionalProperties: false, 53 | }, 54 | TodoItems: { 55 | type: `object`, 56 | properties: { 57 | todoItems: { 58 | type: `array`, 59 | items: { 60 | $ref: `#/properties/TodoItem`, 61 | }, 62 | }, 63 | }, 64 | required: [`todoItems`], 65 | additionalProperties: false, 66 | }, 67 | TodoItemsGroupedByStatus: { 68 | type: `object`, 69 | properties: { 70 | todo: { 71 | type: `array`, 72 | items: { 73 | $ref: `#/properties/TodoItem`, 74 | }, 75 | }, 76 | inProgress: { 77 | type: `array`, 78 | items: { 79 | $ref: `#/properties/TodoItem`, 80 | }, 81 | }, 82 | done: { 83 | type: `array`, 84 | items: { 85 | $ref: `#/properties/TodoItem`, 86 | }, 87 | }, 88 | }, 89 | required: [`todo`, `inProgress`, `done`], 90 | additionalProperties: false, 91 | }, 92 | FortyTwo: { 93 | type: `number`, 94 | enum: [42], 95 | }, 96 | }, 97 | required: [ 98 | `TodoState`, 99 | `TodoItemId`, 100 | `TodoItem`, 101 | `TodoItems`, 102 | `TodoItemsGroupedByStatus`, 103 | `FortyTwo`, 104 | ], 105 | additionalProperties: false, 106 | }, 107 | }, 108 | }, 109 | paths: { 110 | "/documentation_transformed/json": { 111 | get: { 112 | responses: { 113 | "200": { 114 | description: `Default Response`, 115 | }, 116 | }, 117 | }, 118 | }, 119 | "/documentation_transformed/yaml": { 120 | get: { 121 | responses: { 122 | "200": { 123 | description: `Default Response`, 124 | }, 125 | }, 126 | }, 127 | }, 128 | "/item": { 129 | get: { 130 | operationId: `getTodoItems`, 131 | responses: { 132 | "200": { 133 | description: `The list of Todo Items`, 134 | content: { 135 | "application/json": { 136 | schema: { 137 | $ref: `#/components/schemas/Schema/properties/TodoItems`, 138 | description: `The list of Todo Items`, 139 | }, 140 | }, 141 | }, 142 | }, 143 | }, 144 | }, 145 | post: { 146 | operationId: `postTodoItem`, 147 | requestBody: { 148 | content: { 149 | "application/json": { 150 | schema: { 151 | $ref: `#/components/schemas/Schema/properties/TodoItem`, 152 | }, 153 | }, 154 | }, 155 | }, 156 | responses: { 157 | "200": { 158 | description: `Default Response`, 159 | content: { 160 | "application/json": { 161 | schema: { 162 | $ref: `#/components/schemas/Schema/properties/TodoItems`, 163 | }, 164 | }, 165 | }, 166 | }, 167 | }, 168 | }, 169 | }, 170 | "/item/grouped-by-status": { 171 | get: { 172 | operationId: `getTodoItemsGroupedByStatus`, 173 | responses: { 174 | "200": { 175 | description: `Default Response`, 176 | content: { 177 | "application/json": { 178 | schema: { 179 | $ref: `#/components/schemas/Schema/properties/TodoItemsGroupedByStatus`, 180 | }, 181 | }, 182 | }, 183 | }, 184 | }, 185 | }, 186 | }, 187 | "/item/{id}": { 188 | put: { 189 | operationId: `putTodoItem`, 190 | requestBody: { 191 | content: { 192 | "application/json": { 193 | schema: { 194 | $ref: `#/components/schemas/Schema/properties/TodoItem`, 195 | }, 196 | }, 197 | }, 198 | }, 199 | parameters: [ 200 | { 201 | in: `path`, 202 | name: `id`, 203 | required: true, 204 | schema: { 205 | type: `string`, 206 | format: `uuid`, 207 | }, 208 | }, 209 | ], 210 | responses: { 211 | "200": { 212 | description: `Default Response`, 213 | content: { 214 | "application/json": { 215 | schema: { 216 | $ref: `#/components/schemas/Schema/properties/TodoItem`, 217 | }, 218 | }, 219 | }, 220 | }, 221 | }, 222 | }, 223 | }, 224 | "/42": { 225 | get: { 226 | operationId: `getFortyTwo`, 227 | responses: { 228 | "200": { 229 | description: `Default Response`, 230 | content: { 231 | "application/json": { 232 | schema: { 233 | $ref: `#/components/schemas/Schema/properties/FortyTwo`, 234 | }, 235 | }, 236 | }, 237 | }, 238 | }, 239 | }, 240 | }, 241 | }, 242 | }; 243 | 244 | const t = new SpecTransformer(originalSpec); 245 | 246 | const transformedSpec = t.transform({ 247 | mergeRefs: [{ $ref: `Schema#/properties/TodoState` }], 248 | }); 249 | 250 | expect(transformedSpec).toEqual({ 251 | openapi: `3.0.3`, 252 | info: { 253 | title: `Fastify Zod Test Server`, 254 | description: `Test Server for Fastify Zod`, 255 | version: `0.0.0`, 256 | }, 257 | components: { 258 | schemas: { 259 | Schema_TodoState: { 260 | type: `string`, 261 | enum: [`todo`, `in progress`, `done`], 262 | }, 263 | Schema_TodoItem: { 264 | type: `object`, 265 | properties: { 266 | id: { 267 | $ref: `#/components/schemas/Schema_TodoItem_id`, 268 | }, 269 | label: { 270 | $ref: `#/components/schemas/Schema_TodoItem_label`, 271 | }, 272 | dueDateMs: { 273 | $ref: `#/components/schemas/Schema_TodoItem_dueDateMs`, 274 | }, 275 | state: { 276 | $ref: `#/components/schemas/Schema_TodoState`, 277 | }, 278 | }, 279 | required: [`id`, `label`, `state`], 280 | additionalProperties: false, 281 | }, 282 | Schema_TodoItems: { 283 | type: `object`, 284 | properties: { 285 | todoItems: { 286 | $ref: `#/components/schemas/Schema_TodoItems_todoItems`, 287 | }, 288 | }, 289 | required: [`todoItems`], 290 | additionalProperties: false, 291 | }, 292 | Schema_TodoItemsGroupedByStatus: { 293 | type: `object`, 294 | properties: { 295 | todo: { 296 | $ref: `#/components/schemas/Schema_TodoItemsGroupedByStatus_todo`, 297 | }, 298 | inProgress: { 299 | $ref: `#/components/schemas/Schema_TodoItemsGroupedByStatus_inProgress`, 300 | }, 301 | done: { 302 | $ref: `#/components/schemas/Schema_TodoItemsGroupedByStatus_done`, 303 | }, 304 | }, 305 | required: [`todo`, `inProgress`, `done`], 306 | additionalProperties: false, 307 | }, 308 | Schema_FortyTwo: { 309 | type: `number`, 310 | enum: [42], 311 | }, 312 | Schema_TodoItem_id: { 313 | type: `string`, 314 | format: `uuid`, 315 | }, 316 | Schema_TodoItem_label: { 317 | type: `string`, 318 | }, 319 | Schema_TodoItem_dueDateMs: { 320 | type: `integer`, 321 | minimum: 0, 322 | }, 323 | Schema_TodoItems_todoItems: { 324 | type: `array`, 325 | items: { 326 | $ref: `#/components/schemas/Schema_TodoItem`, 327 | }, 328 | }, 329 | Schema_TodoItemsGroupedByStatus_todo: { 330 | type: `array`, 331 | items: { 332 | $ref: `#/components/schemas/Schema_TodoItem`, 333 | }, 334 | }, 335 | Schema_TodoItemsGroupedByStatus_inProgress: { 336 | type: `array`, 337 | items: { 338 | $ref: `#/components/schemas/Schema_TodoItem`, 339 | }, 340 | }, 341 | Schema_TodoItemsGroupedByStatus_done: { 342 | type: `array`, 343 | items: { 344 | $ref: `#/components/schemas/Schema_TodoItem`, 345 | }, 346 | }, 347 | }, 348 | }, 349 | paths: { 350 | "/documentation_transformed/json": { 351 | get: { 352 | responses: { 353 | "200": { 354 | description: `Default Response`, 355 | }, 356 | }, 357 | }, 358 | }, 359 | "/documentation_transformed/yaml": { 360 | get: { 361 | responses: { 362 | "200": { 363 | description: `Default Response`, 364 | }, 365 | }, 366 | }, 367 | }, 368 | "/item": { 369 | get: { 370 | operationId: `getTodoItems`, 371 | responses: { 372 | "200": { 373 | description: `The list of Todo Items`, 374 | content: { 375 | "application/json": { 376 | schema: { 377 | $ref: `#/components/schemas/Schema_TodoItems`, 378 | description: `The list of Todo Items`, 379 | }, 380 | }, 381 | }, 382 | }, 383 | }, 384 | }, 385 | post: { 386 | operationId: `postTodoItem`, 387 | requestBody: { 388 | content: { 389 | "application/json": { 390 | schema: { 391 | $ref: `#/components/schemas/Schema_TodoItem`, 392 | }, 393 | }, 394 | }, 395 | }, 396 | responses: { 397 | "200": { 398 | description: `Default Response`, 399 | content: { 400 | "application/json": { 401 | schema: { 402 | $ref: `#/components/schemas/Schema_TodoItems`, 403 | }, 404 | }, 405 | }, 406 | }, 407 | }, 408 | }, 409 | }, 410 | "/item/grouped-by-status": { 411 | get: { 412 | operationId: `getTodoItemsGroupedByStatus`, 413 | responses: { 414 | "200": { 415 | description: `Default Response`, 416 | content: { 417 | "application/json": { 418 | schema: { 419 | $ref: `#/components/schemas/Schema_TodoItemsGroupedByStatus`, 420 | }, 421 | }, 422 | }, 423 | }, 424 | }, 425 | }, 426 | }, 427 | "/item/{id}": { 428 | put: { 429 | operationId: `putTodoItem`, 430 | requestBody: { 431 | content: { 432 | "application/json": { 433 | schema: { 434 | $ref: `#/components/schemas/Schema_TodoItem`, 435 | }, 436 | }, 437 | }, 438 | }, 439 | parameters: [ 440 | { 441 | in: `path`, 442 | name: `id`, 443 | required: true, 444 | schema: { 445 | type: `string`, 446 | format: `uuid`, 447 | }, 448 | }, 449 | ], 450 | responses: { 451 | "200": { 452 | description: `Default Response`, 453 | content: { 454 | "application/json": { 455 | schema: { 456 | $ref: `#/components/schemas/Schema_TodoItem`, 457 | }, 458 | }, 459 | }, 460 | }, 461 | }, 462 | }, 463 | }, 464 | "/42": { 465 | get: { 466 | operationId: `getFortyTwo`, 467 | responses: { 468 | "200": { 469 | description: `Default Response`, 470 | content: { 471 | "application/json": { 472 | schema: { 473 | $ref: `#/components/schemas/Schema_FortyTwo`, 474 | }, 475 | }, 476 | }, 477 | }, 478 | }, 479 | }, 480 | }, 481 | }, 482 | }); 483 | }); 484 | }); 485 | -------------------------------------------------------------------------------- /src/__tests__/buildJsonSchemas.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { buildJsonSchemas } from ".."; 4 | type Helpers = { 5 | $schema: Record; 6 | constOrEnum: (value: unknown) => Record; 7 | stringEnum: (values: unknown[]) => Record; 8 | target?: `jsonSchema7` | `openApi3`; 9 | }; 10 | 11 | export const helpers = ( 12 | target: `jsonSchema7` | `openApi3` | undefined, 13 | ): Helpers => { 14 | if (target === `openApi3`) { 15 | return { 16 | $schema: {}, 17 | constOrEnum: (value) => ({ 18 | enum: [value], 19 | }), 20 | stringEnum: (values) => ({ 21 | anyOf: values.map((value) => ({ 22 | type: `string`, 23 | enum: [value], 24 | })), 25 | }), 26 | target, 27 | }; 28 | } 29 | if (target === `jsonSchema7`) { 30 | return { 31 | $schema: { $schema: `http://json-schema.org/draft-07/schema#` }, 32 | constOrEnum: (value) => ({ const: value }), 33 | stringEnum: (values) => ({ 34 | type: `string`, 35 | enum: values, 36 | }), 37 | target, 38 | }; 39 | } 40 | return { 41 | $schema: {}, 42 | constOrEnum: (value) => ({ const: value }), 43 | stringEnum: (values) => ({ 44 | type: `string`, 45 | enum: values, 46 | }), 47 | }; 48 | }; 49 | 50 | describe(`buildJsonSchemas`, () => { 51 | for (const target of [`jsonSchema7`, `openApi3`, undefined] as const) { 52 | const { $schema, constOrEnum, stringEnum } = helpers(target); 53 | describe(`target: ${target ?? `none`}`, () => { 54 | test(`primitives`, () => { 55 | const models = { 56 | ZString: z.string(), 57 | ZStringMin: z.string().min(42), 58 | ZDate: z.date(), 59 | ZLiteral: z.literal(42), 60 | ZUuid: z.string().uuid(), 61 | }; 62 | 63 | const { schemas, $ref } = buildJsonSchemas(models, { 64 | target, 65 | }); 66 | 67 | expect($ref(`ZString`)).toEqual({ $ref: `Schema#/properties/ZString` }); 68 | 69 | expect($ref(`ZStringMin`)).toEqual({ 70 | $ref: `Schema#/properties/ZStringMin`, 71 | }); 72 | 73 | expect($ref(`ZDate`)).toEqual({ $ref: `Schema#/properties/ZDate` }); 74 | 75 | expect($ref(`ZLiteral`)).toEqual({ 76 | $ref: `Schema#/properties/ZLiteral`, 77 | }); 78 | 79 | expect($ref(`ZUuid`)).toEqual({ $ref: `Schema#/properties/ZUuid` }); 80 | 81 | expect(schemas).toEqual([ 82 | { 83 | $id: `Schema`, 84 | ...$schema, 85 | type: `object`, 86 | properties: { 87 | ZString: { 88 | type: `string`, 89 | }, 90 | ZStringMin: { 91 | type: `string`, 92 | minLength: 42, 93 | }, 94 | ZDate: { 95 | type: `string`, 96 | format: `date-time`, 97 | }, 98 | ZLiteral: { 99 | type: `number`, 100 | ...constOrEnum(42), 101 | }, 102 | ZUuid: { 103 | type: `string`, 104 | format: `uuid`, 105 | }, 106 | }, 107 | required: [`ZString`, `ZStringMin`, `ZDate`, `ZLiteral`, `ZUuid`], 108 | additionalProperties: false, 109 | }, 110 | ]); 111 | }); 112 | 113 | test(`enums`, () => { 114 | enum NativeEnum { 115 | One = `one`, 116 | Two = `two`, 117 | Three = `three`, 118 | } 119 | 120 | const schema = { 121 | ZEnum: z.enum([`one`, `two`, `three`]), 122 | ZNativeEnum: z.nativeEnum(NativeEnum), 123 | }; 124 | 125 | const { schemas, $ref } = buildJsonSchemas(schema, { 126 | target, 127 | }); 128 | 129 | expect($ref(`ZEnum`)).toEqual({ $ref: `Schema#/properties/ZEnum` }); 130 | 131 | expect($ref(`ZNativeEnum`)).toEqual({ 132 | $ref: `Schema#/properties/ZNativeEnum`, 133 | }); 134 | 135 | expect(schemas).toEqual([ 136 | { 137 | $id: `Schema`, 138 | ...$schema, 139 | type: `object`, 140 | properties: { 141 | ZEnum: { 142 | type: `string`, 143 | enum: [`one`, `two`, `three`], 144 | }, 145 | ZNativeEnum: { 146 | type: `string`, 147 | enum: [`one`, `two`, `three`], 148 | }, 149 | }, 150 | required: [`ZEnum`, `ZNativeEnum`], 151 | additionalProperties: false, 152 | }, 153 | ]); 154 | }); 155 | 156 | test(`objects`, () => { 157 | const models = { 158 | ZObject: z.object({ 159 | name: z.string(), 160 | age: z.number(), 161 | uuid: z.string().uuid().optional(), 162 | }), 163 | ZObjectPartial: z 164 | .object({ 165 | name: z.string(), 166 | age: z.number(), 167 | uuid: z.string().uuid().optional(), 168 | }) 169 | .partial(), 170 | }; 171 | 172 | const { schemas, $ref } = buildJsonSchemas(models, { target }); 173 | 174 | expect($ref(`ZObject`)).toEqual({ $ref: `Schema#/properties/ZObject` }); 175 | 176 | expect($ref(`ZObjectPartial`)).toEqual({ 177 | $ref: `Schema#/properties/ZObjectPartial`, 178 | }); 179 | 180 | expect(schemas).toEqual([ 181 | { 182 | $id: `Schema`, 183 | ...$schema, 184 | type: `object`, 185 | properties: { 186 | ZObject: { 187 | type: `object`, 188 | properties: { 189 | name: { 190 | type: `string`, 191 | }, 192 | age: { 193 | type: `number`, 194 | }, 195 | uuid: { 196 | type: `string`, 197 | format: `uuid`, 198 | }, 199 | }, 200 | required: [`name`, `age`], 201 | additionalProperties: false, 202 | }, 203 | ZObjectPartial: { 204 | type: `object`, 205 | properties: { 206 | name: { 207 | type: `string`, 208 | }, 209 | age: { 210 | type: `number`, 211 | }, 212 | uuid: { 213 | type: `string`, 214 | format: `uuid`, 215 | }, 216 | }, 217 | additionalProperties: false, 218 | }, 219 | }, 220 | required: [`ZObject`, `ZObjectPartial`], 221 | additionalProperties: false, 222 | }, 223 | ]); 224 | }); 225 | 226 | test(`arrays`, () => { 227 | const models = { 228 | ZArray: z.array(z.string()), 229 | ZArrayMinMax: z.array(z.string()).min(5).max(12), 230 | }; 231 | 232 | const { schemas, $ref } = buildJsonSchemas(models, { target }); 233 | 234 | expect($ref(`ZArray`)).toEqual({ $ref: `Schema#/properties/ZArray` }); 235 | 236 | expect($ref(`ZArrayMinMax`)).toEqual({ 237 | $ref: `Schema#/properties/ZArrayMinMax`, 238 | }); 239 | 240 | expect(schemas).toEqual([ 241 | { 242 | $id: `Schema`, 243 | ...$schema, 244 | type: `object`, 245 | properties: { 246 | ZArray: { 247 | type: `array`, 248 | items: { 249 | type: `string`, 250 | }, 251 | }, 252 | ZArrayMinMax: { 253 | type: `array`, 254 | items: { 255 | type: `string`, 256 | }, 257 | minItems: 5, 258 | maxItems: 12, 259 | }, 260 | }, 261 | required: [`ZArray`, `ZArrayMinMax`], 262 | additionalProperties: false, 263 | }, 264 | ]); 265 | }); 266 | 267 | test(`tuples`, () => { 268 | const models = { 269 | ZTuple: z.tuple([z.string(), z.number(), z.literal(42)]), 270 | }; 271 | 272 | const { schemas, $ref } = buildJsonSchemas(models, { target }); 273 | 274 | expect($ref(`ZTuple`)).toEqual({ $ref: `Schema#/properties/ZTuple` }); 275 | 276 | expect(schemas).toEqual([ 277 | { 278 | $id: `Schema`, 279 | ...$schema, 280 | type: `object`, 281 | properties: { 282 | ZTuple: { 283 | type: `array`, 284 | minItems: 3, 285 | maxItems: 3, 286 | items: [ 287 | { 288 | type: `string`, 289 | }, 290 | { 291 | type: `number`, 292 | }, 293 | { 294 | type: `number`, 295 | ...constOrEnum(42), 296 | }, 297 | ], 298 | }, 299 | }, 300 | required: [`ZTuple`], 301 | additionalProperties: false, 302 | }, 303 | ]); 304 | }); 305 | 306 | test(`unions`, () => { 307 | const models = { 308 | ZUnion: z.union([z.string(), z.number(), z.literal(42)]), 309 | }; 310 | 311 | const { schemas, $ref } = buildJsonSchemas(models, { target }); 312 | 313 | expect($ref(`ZUnion`)).toEqual({ $ref: `Schema#/properties/ZUnion` }); 314 | 315 | expect(schemas).toEqual([ 316 | { 317 | $id: `Schema`, 318 | ...$schema, 319 | type: `object`, 320 | properties: { 321 | ZUnion: { 322 | anyOf: [ 323 | { 324 | type: `string`, 325 | }, 326 | { 327 | type: `number`, 328 | }, 329 | { 330 | type: `number`, 331 | ...constOrEnum(42), 332 | }, 333 | ], 334 | }, 335 | }, 336 | required: [`ZUnion`], 337 | additionalProperties: false, 338 | }, 339 | ]); 340 | }); 341 | 342 | test(`records`, () => { 343 | const models = { 344 | ZRecord: z.record(z.number()), 345 | }; 346 | 347 | const { schemas, $ref } = buildJsonSchemas(models, { target }); 348 | 349 | expect($ref(`ZRecord`)).toEqual({ $ref: `Schema#/properties/ZRecord` }); 350 | 351 | expect(schemas).toEqual([ 352 | { 353 | $id: `Schema`, 354 | ...$schema, 355 | type: `object`, 356 | properties: { 357 | ZRecord: { 358 | type: `object`, 359 | additionalProperties: { type: `number` }, 360 | }, 361 | }, 362 | required: [`ZRecord`], 363 | additionalProperties: false, 364 | }, 365 | ]); 366 | }); 367 | 368 | test(`intersections`, () => { 369 | const models = { 370 | ZIntersection: z.intersection(z.number().min(2), z.number().max(12)), 371 | }; 372 | 373 | const { schemas, $ref } = buildJsonSchemas(models, { target }); 374 | 375 | expect($ref(`ZIntersection`)).toEqual({ 376 | $ref: `Schema#/properties/ZIntersection`, 377 | }); 378 | 379 | expect(schemas).toEqual([ 380 | { 381 | $id: `Schema`, 382 | ...$schema, 383 | type: `object`, 384 | properties: { 385 | ZIntersection: { 386 | allOf: [ 387 | { type: `number`, minimum: 2 }, 388 | { type: `number`, maximum: 12 }, 389 | ], 390 | }, 391 | }, 392 | required: [`ZIntersection`], 393 | additionalProperties: false, 394 | }, 395 | ]); 396 | }); 397 | 398 | test(`composite`, () => { 399 | const TodoItem = z.object({ 400 | itemId: z.number(), 401 | label: z.string(), 402 | state: z.union([ 403 | z.literal(`todo`), 404 | z.literal(`in progress`), 405 | z.literal(`done`), 406 | ]), 407 | dueDate: z.string().optional(), 408 | }); 409 | 410 | const TodoList = z.array(TodoItem); 411 | 412 | const models = { 413 | TodoList, 414 | }; 415 | 416 | const { schemas, $ref } = buildJsonSchemas(models, { target }); 417 | 418 | expect($ref(`TodoList`)).toEqual({ 419 | $ref: `Schema#/properties/TodoList`, 420 | }); 421 | 422 | expect(schemas).toEqual([ 423 | { 424 | $id: `Schema`, 425 | ...$schema, 426 | type: `object`, 427 | properties: { 428 | TodoList: { 429 | type: `array`, 430 | items: { 431 | type: `object`, 432 | properties: { 433 | itemId: { type: `number` }, 434 | label: { type: `string` }, 435 | state: stringEnum([`todo`, `in progress`, `done`]), 436 | dueDate: { type: `string` }, 437 | }, 438 | required: [`itemId`, `label`, `state`], 439 | additionalProperties: false, 440 | }, 441 | }, 442 | }, 443 | required: [`TodoList`], 444 | additionalProperties: false, 445 | }, 446 | ]); 447 | }); 448 | 449 | test(`references`, () => { 450 | const TodoItemState = z.enum([`todo`, `in progress`, `done`]); 451 | 452 | const TodoItem = z.object({ 453 | id: z.number(), 454 | label: z.string(), 455 | state: TodoItemState, 456 | }); 457 | 458 | const TodoList = z.array(TodoItem); 459 | 460 | const schema = { 461 | TodoItemState, 462 | TodoItem, 463 | TodoList, 464 | }; 465 | 466 | const { schemas } = buildJsonSchemas(schema, { target }); 467 | 468 | expect(schemas).toEqual([ 469 | { 470 | $id: `Schema`, 471 | ...$schema, 472 | type: `object`, 473 | properties: { 474 | TodoItemState: { 475 | type: `string`, 476 | enum: [`todo`, `in progress`, `done`], 477 | }, 478 | TodoItem: { 479 | type: `object`, 480 | properties: { 481 | id: { type: `number` }, 482 | label: { type: `string` }, 483 | state: { $ref: `Schema#/properties/TodoItemState` }, 484 | }, 485 | required: [`id`, `label`, `state`], 486 | additionalProperties: false, 487 | }, 488 | TodoList: { 489 | type: `array`, 490 | items: { $ref: `Schema#/properties/TodoItem` }, 491 | }, 492 | }, 493 | required: [`TodoItemState`, `TodoItem`, `TodoList`], 494 | additionalProperties: false, 495 | }, 496 | ]); 497 | }); 498 | }); 499 | } 500 | }); 501 | -------------------------------------------------------------------------------- /src/__tests__/generate-spec.fixtures.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from "fs/promises"; 2 | import { join } from "path"; 3 | 4 | import { buildJsonSchemas } from ".."; 5 | 6 | import { models } from "./models.fixtures"; 7 | import { 8 | createTestServer, 9 | openApiOptions, 10 | swaggerUiOptions, 11 | } from "./server.fixtures"; 12 | 13 | const main = async (): Promise => { 14 | const f = await createTestServer( 15 | {}, 16 | { 17 | jsonSchemas: buildJsonSchemas(models, {}), 18 | transformSpec: { options: {} }, 19 | swaggerOptions: { 20 | ...openApiOptions, 21 | }, 22 | swaggerUiOptions, 23 | }, 24 | ); 25 | 26 | const originalSpec = await f 27 | .inject({ 28 | method: `get`, 29 | url: `/documentation/json`, 30 | }) 31 | .then((res) => res.json()); 32 | 33 | await writeFile( 34 | join(__dirname, `..`, `..`, `openapi.original.json`), 35 | JSON.stringify(originalSpec, null, 2), 36 | { encoding: `utf-8` }, 37 | ); 38 | 39 | const transformedSpecJson = await f 40 | .inject({ 41 | method: `get`, 42 | url: `/documentation_transformed/json`, 43 | }) 44 | .then((res) => res.body); 45 | 46 | await writeFile( 47 | join(__dirname, `..`, `..`, `openapi.transformed.json`), 48 | transformedSpecJson, 49 | { encoding: `utf-8` }, 50 | ); 51 | 52 | const transformedSpecYaml = await f 53 | .inject({ 54 | method: `get`, 55 | url: `/documentation_transformed/yaml`, 56 | }) 57 | .then((res) => res.body); 58 | 59 | await writeFile( 60 | join(__dirname, `..`, `..`, `openapi.transformed.yml`), 61 | transformedSpecYaml, 62 | { encoding: `utf-8` }, 63 | ); 64 | }; 65 | 66 | main().catch((error) => { 67 | console.error(error); 68 | process.exit(1); 69 | }); 70 | -------------------------------------------------------------------------------- /src/__tests__/issues.test.ts: -------------------------------------------------------------------------------- 1 | import fastify from "fastify"; 2 | import { z } from "zod"; 3 | 4 | import { buildJsonSchemas, register } from ".."; 5 | 6 | test(`fix #8`, () => { 7 | const productInput = { 8 | title: z.string(), 9 | price: z.number(), 10 | content: z.string().optional(), 11 | }; 12 | 13 | const productGenerated = { 14 | id: z.number(), 15 | createdAt: z.string(), 16 | updatedAt: z.string(), 17 | }; 18 | 19 | const createProductSchema = z.object({ 20 | ...productInput, 21 | }); 22 | 23 | const productResponseSchema = z.object({ 24 | ...productInput, 25 | ...productGenerated, 26 | }); 27 | 28 | const productsResponseSchema = z.array(productResponseSchema); 29 | 30 | buildJsonSchemas({ 31 | createProductSchema, 32 | productResponseSchema, 33 | productsResponseSchema, 34 | }); 35 | 36 | const userCoreSchema = { 37 | email: z 38 | .string({ 39 | required_error: `Email is required`, 40 | invalid_type_error: `Email must be a string`, 41 | }) 42 | .email(), 43 | name: z.string(), 44 | }; 45 | 46 | const createUserSchema = z.object({ 47 | ...userCoreSchema, 48 | password: z.string({ 49 | required_error: `Password is required`, 50 | invalid_type_error: `Password must be a string`, 51 | }), 52 | }); 53 | 54 | const createUserResponseSchema = z.object({ 55 | ...userCoreSchema, 56 | id: z.number(), 57 | }); 58 | 59 | const loginSchema = z.object({ 60 | email: z 61 | .string({ 62 | required_error: `Email is required`, 63 | invalid_type_error: `Email must be a string`, 64 | }) 65 | .email(), 66 | password: z.string(), 67 | }); 68 | 69 | const loginResponseSchema = z.object({ 70 | accessToken: z.string(), 71 | }); 72 | 73 | buildJsonSchemas({ 74 | createUserSchema, 75 | createUserResponseSchema, 76 | loginSchema, 77 | loginResponseSchema, 78 | }); 79 | }); 80 | 81 | test(`fix #14, #17`, async () => { 82 | const Name = z.object({ 83 | kind: z.literal(`name`), 84 | name: z.string(), 85 | lastName: z.string(), 86 | }); 87 | 88 | const Address = z.object({ 89 | kind: z.literal(`address`), 90 | street: z.string(), 91 | postcode: z.string(), 92 | }); 93 | 94 | const UserDetails = z.union([Name, Address]); 95 | 96 | const Unknown = z.unknown(); 97 | 98 | const jsonSchemas = buildJsonSchemas({ UserDetails, Unknown }, {}); 99 | 100 | const f = await register(fastify(), { 101 | jsonSchemas, 102 | }); 103 | 104 | f.zod.get( 105 | `/`, 106 | { 107 | operationId: `getUserDetails`, 108 | querystring: `UserDetails`, 109 | reply: `UserDetails`, 110 | }, 111 | async ({ query }) => query, 112 | ); 113 | 114 | const name = await f 115 | .inject({ method: `get`, url: `/`, query: { kind: `name` } }) 116 | .then((res) => res.json()); 117 | 118 | expect(name).toEqual({ 119 | code: `FST_ERR_VALIDATION`, 120 | error: `Bad Request`, 121 | message: `querystring must have required property 'name', querystring must have required property 'street', querystring must match a schema in anyOf`, 122 | statusCode: 400, 123 | }); 124 | 125 | const address = await f 126 | .inject({ method: `get`, url: `/`, query: { kind: `address` } }) 127 | .then((res) => res.json()); 128 | 129 | expect(address).toEqual({ 130 | code: `FST_ERR_VALIDATION`, 131 | error: `Bad Request`, 132 | message: `querystring must have required property 'name', querystring must have required property 'street', querystring must match a schema in anyOf`, 133 | statusCode: 400, 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/__tests__/lisa.test.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from "fs/promises"; 2 | import { join } from "path"; 3 | 4 | import { SpecTransformer } from "../SpecTransformer"; 5 | 6 | test(`lisa`, async () => { 7 | const originalSpec = JSON.parse( 8 | await readFile( 9 | join( 10 | __dirname, 11 | `..`, 12 | `..`, 13 | `src`, 14 | `__tests__`, 15 | `lisa.openapi.original.fixtures.json`, 16 | ), 17 | { 18 | encoding: `utf-8`, 19 | }, 20 | ), 21 | ); 22 | const t = new SpecTransformer(originalSpec); 23 | 24 | const $ref = (key: string) => 25 | ({ 26 | $ref: `T#/properties/${key}`, 27 | } as const); 28 | 29 | const result = t.transform({ 30 | mergeRefs: [ 31 | $ref(`JsonRecord`), 32 | $ref(`Uuid`), 33 | $ref(`Email`), 34 | $ref(`UserEmail`), 35 | $ref(`GroupRole`), 36 | $ref(`ParticipantRole`), 37 | $ref(`ParticipantId`), 38 | $ref(`ParticipantGroupId`), 39 | $ref(`UserParticipantRelationship`), 40 | ], 41 | schemaKeys: { 42 | removeInitialSchemasPrefix: true, 43 | changeCase: `PascalCase`, 44 | }, 45 | }); 46 | 47 | await writeFile( 48 | join( 49 | __dirname, 50 | `..`, 51 | `..`, 52 | `src`, 53 | `__tests__`, 54 | `lisa.openapi.transformed.fixtures.json`, 55 | ), 56 | JSON.stringify(result, null, 2), 57 | { encoding: `utf-8` }, 58 | ); 59 | 60 | const transformedSpec = JSON.parse( 61 | await readFile( 62 | join( 63 | __dirname, 64 | `..`, 65 | `..`, 66 | `src`, 67 | `__tests__`, 68 | `lisa.openapi.transformed.fixtures.json`, 69 | ), 70 | { encoding: `utf-8` }, 71 | ), 72 | ); 73 | 74 | expect(result).toEqual(transformedSpec); 75 | }); 76 | -------------------------------------------------------------------------------- /src/__tests__/models.fixtures.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const TodoItemId = z.object({ 4 | id: z.string().uuid(`invalid todo item id`), 5 | }); 6 | export type TodoItemId = z.infer; 7 | 8 | enum TodoStateEnum { 9 | Todo = `todo`, 10 | InProgress = `in progress`, 11 | Done = `done`, 12 | } 13 | 14 | const TodoState = z.nativeEnum(TodoStateEnum); 15 | 16 | const TodoItem = TodoItemId.extend({ 17 | label: z.string(), 18 | dueDateMs: z.number().int().nonnegative().optional(), 19 | state: TodoState, 20 | }); 21 | 22 | export type TodoItem = z.infer; 23 | 24 | const TodoItemNotFoundError = TodoItemId.extend({ 25 | message: z.literal(`item not found`), 26 | }); 27 | 28 | const TodoItems = z.object({ 29 | todoItems: z.array(TodoItem), 30 | }); 31 | export type TodoItems = z.infer; 32 | 33 | const TodoItemsGroupedByStatus = z.object({ 34 | todo: z.array(TodoItem), 35 | inProgress: z.array(TodoItem), 36 | done: z.array(TodoItem), 37 | }); 38 | 39 | export type TodoItemsGroupedByStatus = z.infer; 40 | 41 | const FortyTwo = z.literal(42); 42 | export type FortyTwo = z.infer; 43 | 44 | export const models = { 45 | TodoState, 46 | TodoItemId, 47 | TodoItem, 48 | TodoItemNotFoundError, 49 | TodoItems, 50 | TodoItemsGroupedByStatus, 51 | FortyTwo, 52 | }; 53 | -------------------------------------------------------------------------------- /src/__tests__/openapi-client.test.ts: -------------------------------------------------------------------------------- 1 | import { tExpect } from "typed-jest-expect"; 2 | 3 | import { 4 | Configuration, 5 | DefaultApi, 6 | SchemaTodoState, 7 | } from "../../test-openapi-client"; 8 | import { buildJsonSchemas } from ".."; 9 | 10 | import { models } from "./models.fixtures"; 11 | import { 12 | createTestServer, 13 | openApiOptions, 14 | swaggerUiOptions, 15 | } from "./server.fixtures"; 16 | 17 | test(`openapi-client`, async () => { 18 | const f = await createTestServer( 19 | {}, 20 | { 21 | jsonSchemas: buildJsonSchemas(models, {}), 22 | transformSpec: {}, 23 | swaggerOptions: { 24 | ...openApiOptions, 25 | }, 26 | swaggerUiOptions, 27 | }, 28 | ); 29 | 30 | const basePath = await f.listen({ port: 0 }); 31 | 32 | try { 33 | const client = new DefaultApi( 34 | new Configuration({ basePath, fetchApi: global.fetch }), 35 | ); 36 | 37 | await tExpect(client.getTodoItems()).resolves.toEqual({ todoItems: [] }); 38 | 39 | await tExpect( 40 | client.postTodoItem({ 41 | schemaTodoItem: { 42 | id: `e7f7082a-4f16-430d-8c3b-db6b8d4d3e73`, 43 | label: `todo`, 44 | state: SchemaTodoState.Todo, 45 | dueDateMs: new Date(1337).getTime(), 46 | }, 47 | }), 48 | ).resolves.toEqual({ 49 | todoItems: [ 50 | { 51 | id: `e7f7082a-4f16-430d-8c3b-db6b8d4d3e73`, 52 | label: `todo`, 53 | state: SchemaTodoState.Todo, 54 | dueDateMs: new Date(1337).getTime(), 55 | }, 56 | ], 57 | }); 58 | } finally { 59 | await f.close(); 60 | } 61 | }); 62 | -------------------------------------------------------------------------------- /src/__tests__/server.fixtures.ts: -------------------------------------------------------------------------------- 1 | import fastify, { FastifyInstance, FastifyServerOptions } from "fastify"; 2 | import { BadRequest, NotFound } from "http-errors"; 3 | 4 | import { register } from ".."; 5 | import { RegisterOptions } from "../FastifyZod"; 6 | 7 | import { models, TodoItems } from "./models.fixtures"; 8 | 9 | export const swaggerOptions: RegisterOptions[`swaggerOptions`] = 10 | { 11 | swagger: { 12 | info: { 13 | title: `Fastify Zod Test Server`, 14 | description: `Test Server for Fastify Zod`, 15 | version: `0.0.0`, 16 | }, 17 | }, 18 | }; 19 | 20 | export const openApiOptions: RegisterOptions[`swaggerOptions`] = 21 | { 22 | openapi: { 23 | info: { 24 | title: `Fastify Zod Test Server`, 25 | description: `Test Server for Fastify Zod`, 26 | version: `0.0.0`, 27 | }, 28 | }, 29 | }; 30 | 31 | export const swaggerUiOptions: RegisterOptions< 32 | typeof models 33 | >[`swaggerUiOptions`] = { 34 | staticCSP: true, 35 | }; 36 | 37 | export const createTestServer = async ( 38 | fastifyOptions: FastifyServerOptions, 39 | registerOptions: RegisterOptions, 40 | ): Promise => { 41 | const f = await register(fastify(fastifyOptions), registerOptions); 42 | 43 | const state: TodoItems = { 44 | todoItems: [], 45 | }; 46 | 47 | f.zod.get( 48 | `/item`, 49 | { 50 | operationId: `getTodoItems`, 51 | reply: { 52 | description: `The list of Todo Items`, 53 | key: `TodoItems`, 54 | }, 55 | }, 56 | async () => state, 57 | ); 58 | 59 | f.zod.get( 60 | `/item/grouped-by-status`, 61 | { 62 | operationId: `getTodoItemsGroupedByStatus`, 63 | reply: `TodoItemsGroupedByStatus`, 64 | }, 65 | async () => ({ 66 | done: state.todoItems.filter((item) => item.state === `done`), 67 | inProgress: state.todoItems.filter( 68 | (item) => item.state === `in progress`, 69 | ), 70 | todo: state.todoItems.filter((item) => item.state === `todo`), 71 | }), 72 | ); 73 | 74 | f.zod.post( 75 | `/item`, 76 | { 77 | operationId: `postTodoItem`, 78 | body: `TodoItem`, 79 | reply: `TodoItems`, 80 | }, 81 | async ({ body: nextItem }) => { 82 | if (state.todoItems.some((prevItem) => prevItem.id === nextItem.id)) { 83 | throw new BadRequest(`item already exists`); 84 | } 85 | state.todoItems = [...state.todoItems, nextItem]; 86 | return state; 87 | }, 88 | ); 89 | 90 | f.zod.get( 91 | `/item/:id`, 92 | { 93 | operationId: `getTodoItem`, 94 | params: `TodoItemId`, 95 | response: { 96 | 200: `TodoItem`, 97 | 404: `TodoItemNotFoundError`, 98 | }, 99 | }, 100 | async ({ params: { id } }, reply) => { 101 | const item = state.todoItems.find((item) => item.id === id); 102 | if (item) { 103 | return item; 104 | } 105 | reply.code(404); 106 | return { 107 | id, 108 | message: `item not found`, 109 | }; 110 | }, 111 | ); 112 | 113 | f.zod.put( 114 | `/item/:id`, 115 | { 116 | operationId: `putTodoItem`, 117 | body: `TodoItem`, 118 | params: `TodoItemId`, 119 | reply: `TodoItem`, 120 | }, 121 | async ({ params: { id }, body: nextItem }) => { 122 | if (!state.todoItems.some((prevItem) => prevItem.id === id)) { 123 | throw new NotFound(`no such item`); 124 | } 125 | state.todoItems = state.todoItems.map((prevItem) => 126 | prevItem.id === id ? nextItem : prevItem, 127 | ); 128 | return nextItem; 129 | }, 130 | ); 131 | 132 | f.zod.get( 133 | `/42`, 134 | { operationId: `getFortyTwo`, reply: `FortyTwo` }, 135 | async () => 42, 136 | ); 137 | 138 | return f; 139 | }; 140 | -------------------------------------------------------------------------------- /src/__tests__/server.legacy.fixtures.ts: -------------------------------------------------------------------------------- 1 | import fastify, { FastifyInstance, FastifyServerOptions } from "fastify"; 2 | import fastifySwagger, { FastifyDynamicSwaggerOptions } from "@fastify/swagger"; 3 | import { NotFound, BadRequest } from "http-errors"; 4 | 5 | import { buildJsonSchemas, withRefResolver } from ".."; 6 | import { BuildJsonSchemasOptions } from "../JsonSchema"; 7 | 8 | import { 9 | models, 10 | TodoItem, 11 | TodoItemId, 12 | TodoItems, 13 | TodoItemsGroupedByStatus, 14 | } from "./models.fixtures"; 15 | 16 | export const createLegacyTestServer = ( 17 | fastifyOptions: FastifyServerOptions, 18 | buildJsonSchemasOptions: BuildJsonSchemasOptions, 19 | swaggerOptions: FastifyDynamicSwaggerOptions, 20 | ): FastifyInstance => { 21 | const f = fastify(fastifyOptions); 22 | 23 | f.register(fastifySwagger, withRefResolver(swaggerOptions)); 24 | 25 | const { $ref, schemas } = buildJsonSchemas(models, buildJsonSchemasOptions); 26 | 27 | for (const schema of schemas) { 28 | f.addSchema(schema); 29 | } 30 | 31 | const state: TodoItems = { 32 | todoItems: [], 33 | }; 34 | 35 | f.get<{ 36 | Reply: TodoItems; 37 | }>( 38 | `/item`, 39 | { 40 | schema: { 41 | operationId: `getTodoItems`, 42 | response: { 43 | 200: $ref(`TodoItems`), 44 | }, 45 | }, 46 | }, 47 | async () => state, 48 | ); 49 | 50 | f.get<{ 51 | Reply: TodoItemsGroupedByStatus; 52 | }>( 53 | `/item/grouped-by-status`, 54 | { 55 | schema: { 56 | operationId: `getTodoItemsGroupedByStatus`, 57 | response: { 58 | 200: $ref(`TodoItemsGroupedByStatus`), 59 | }, 60 | }, 61 | }, 62 | async () => ({ 63 | done: state.todoItems.filter((item) => item.state === `done`), 64 | inProgress: state.todoItems.filter( 65 | (item) => item.state === `in progress`, 66 | ), 67 | todo: state.todoItems.filter((item) => item.state === `todo`), 68 | }), 69 | ); 70 | 71 | f.post<{ 72 | Body: TodoItem; 73 | Reply: TodoItems; 74 | }>( 75 | `/item`, 76 | { 77 | schema: { 78 | operationId: `postTodoItem`, 79 | body: $ref(`TodoItem`), 80 | response: { 81 | 200: $ref(`TodoItems`), 82 | }, 83 | }, 84 | }, 85 | async ({ body: nextItem }) => { 86 | if (state.todoItems.some((prevItem) => prevItem.id === nextItem.id)) { 87 | throw new BadRequest(`item already exists`); 88 | } 89 | state.todoItems = [...state.todoItems, nextItem]; 90 | return state; 91 | }, 92 | ); 93 | 94 | f.put<{ 95 | Body: TodoItem; 96 | Params: TodoItemId; 97 | Reply: TodoItem; 98 | }>( 99 | `/item/:id`, 100 | { 101 | schema: { 102 | operationId: `putTodoItem`, 103 | body: $ref(`TodoItem`), 104 | params: $ref(`TodoItemId`), 105 | response: { 106 | 200: $ref(`TodoItem`), 107 | }, 108 | }, 109 | }, 110 | async ({ params: { id }, body: nextItem }) => { 111 | if (!state.todoItems.some((prevItem) => prevItem.id === id)) { 112 | throw new NotFound(`no such item`); 113 | } 114 | state.todoItems = state.todoItems.map((prevItem) => 115 | prevItem.id === id ? nextItem : prevItem, 116 | ); 117 | return nextItem; 118 | }, 119 | ); 120 | 121 | return f; 122 | }; 123 | -------------------------------------------------------------------------------- /src/__tests__/server.legacy.test.ts: -------------------------------------------------------------------------------- 1 | import { createLegacyTestServer } from "./server.legacy.fixtures"; 2 | 3 | test(`server.legacy`, async () => { 4 | const f = createLegacyTestServer({}, {}, {}); 5 | 6 | await expect( 7 | f 8 | .inject({ 9 | method: `get`, 10 | url: `/item`, 11 | }) 12 | .then((res) => res.json()), 13 | ).resolves.toEqual({ todoItems: [] }); 14 | 15 | await expect( 16 | f 17 | .inject({ 18 | method: `post`, 19 | url: `/item`, 20 | payload: {}, 21 | }) 22 | .then((res) => res.json()), 23 | ).resolves.toEqual({ 24 | code: `FST_ERR_VALIDATION`, 25 | error: `Bad Request`, 26 | message: `body must have required property 'id'`, 27 | statusCode: 400, 28 | }); 29 | 30 | await expect( 31 | f 32 | .inject({ 33 | method: `post`, 34 | url: `/item`, 35 | payload: { 36 | id: 1337, 37 | label: `test item`, 38 | state: `todo`, 39 | }, 40 | }) 41 | .then((res) => res.json()), 42 | ).resolves.toEqual({ 43 | code: `FST_ERR_VALIDATION`, 44 | error: `Bad Request`, 45 | message: `body/id must match format "uuid"`, 46 | statusCode: 400, 47 | }); 48 | 49 | await expect( 50 | f 51 | .inject({ 52 | method: `post`, 53 | url: `/item`, 54 | payload: { 55 | id: `e7f7082a-4f16-430d-8c3b-db6b8d4d3e73`, 56 | label: `todo`, 57 | state: `todo`, 58 | dueDateMs: new Date(1337).getTime(), 59 | }, 60 | }) 61 | .then((res) => res.json()), 62 | ).resolves.toEqual({ 63 | todoItems: [ 64 | { 65 | id: `e7f7082a-4f16-430d-8c3b-db6b8d4d3e73`, 66 | label: `todo`, 67 | state: `todo`, 68 | dueDateMs: new Date(1337).getTime(), 69 | }, 70 | ], 71 | }); 72 | 73 | await expect( 74 | f 75 | .inject({ 76 | method: `put`, 77 | url: `/item/1337`, 78 | }) 79 | .then((res) => res.json()), 80 | ).resolves.toEqual({ 81 | code: `FST_ERR_VALIDATION`, 82 | error: `Bad Request`, 83 | message: `params/id must match format "uuid"`, 84 | statusCode: 400, 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { buildJsonSchemas, buildJsonSchema } from "./JsonSchema"; 2 | export type { 3 | BuildJsonSchemasOptions, 4 | BuildJsonSchemasResult, 5 | JsonSchema, 6 | } from "./JsonSchema"; 7 | export { register, withRefResolver } from "./FastifyZod"; 8 | export type { FastifyZod, RegisterOptions } from "./FastifyZod"; 9 | export { SpecTransformer } from "./SpecTransformer"; 10 | export type { TransformOptions } from "./SpecTransformer"; 11 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { ZodSchema, ZodType, z } from "zod"; 2 | 3 | export const isZod = 4 | (Type: ZodType) => 5 | (input: unknown): input is T => { 6 | try { 7 | Type.parse(input); 8 | return true; 9 | } catch { 10 | return false; 11 | } 12 | }; 13 | export type ZodShape = { 14 | // Require all the keys from T 15 | [key in keyof T]-?: undefined extends T[key] 16 | ? // When optional, require the type to be optional in zod 17 | z.ZodOptionalType> 18 | : z.ZodType; 19 | }; 20 | 21 | export const ZodShape = (shape: ZodShape): ZodSchema => 22 | z.object(shape).strict() as unknown as ZodSchema; 23 | 24 | export const isRecord = (input: unknown): input is Record => 25 | !Array.isArray(input) && typeof input === `object` && input !== null; 26 | 27 | export const mapDeep = ( 28 | current: unknown, 29 | replace: (value: unknown) => void, 30 | ): unknown => { 31 | if (Array.isArray(current)) { 32 | for (let k = 0; k < current.length; k++) { 33 | current[k] = replace(mapDeep(current[k], replace)); 34 | } 35 | } else if (isRecord(current)) { 36 | for (const k of Object.keys(current)) { 37 | current[k] = replace(mapDeep(current[k], replace)); 38 | } 39 | } 40 | return current; 41 | }; 42 | 43 | export const findFirstDeep = ( 44 | current: unknown, 45 | predicate: (value: unknown, path: string[]) => boolean, 46 | path: string[] = [], 47 | ): [found: boolean, value: unknown, path: string[]] => { 48 | if (predicate(current, path)) { 49 | return [true, current, path]; 50 | } 51 | if (Array.isArray(current)) { 52 | for (let k = 0; k < current.length; k++) { 53 | path.push(`${k}`); 54 | const [found, value] = findFirstDeep(current[k], predicate, path); 55 | if (found) { 56 | return [found, value, path]; 57 | } 58 | path.pop(); 59 | } 60 | return [false, null, []]; 61 | } 62 | if (isRecord(current)) { 63 | for (const k of Object.keys(current)) { 64 | path.push(k); 65 | const [found, value] = findFirstDeep(current[k], predicate, path); 66 | if (found) { 67 | return [found, value, path]; 68 | } 69 | path.pop(); 70 | } 71 | return [false, null, []]; 72 | } 73 | return [false, null, []]; 74 | }; 75 | 76 | export const visitDeep = ( 77 | current: unknown, 78 | visit: (value: unknown, path: string[]) => void, 79 | path: string[] = [], 80 | ): void => { 81 | visit(current, path); 82 | if (Array.isArray(current)) { 83 | for (let k = 0; k < current.length; k++) { 84 | path.push(`${k}`); 85 | visitDeep(current[k], visit, path); 86 | path.pop(); 87 | } 88 | return; 89 | } 90 | if (isRecord(current)) { 91 | for (const k of Object.keys(current)) { 92 | path.push(k); 93 | visitDeep(current[k], visit, path); 94 | path.pop(); 95 | } 96 | return; 97 | } 98 | return; 99 | }; 100 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es2019" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 5 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | "lib": [ 7 | "es2019", 8 | "ESNext", 9 | "DOM", // for Fetch API 10 | ] /* Specify library files to be included in the compilation. */, 11 | // "allowJs": true, /* Allow javascript files to be compiled. */ 12 | // "checkJs": true /* Report errors in .js files. */, 13 | "jsx": "preserve" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 14 | "declaration": true /* Generates corresponding '.d.ts' file. */, 15 | // "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, 16 | "sourceMap": true /* Generates corresponding '.map' file. */, 17 | // "outFile": "./", /* Concatenate and emit output to single file. */ 18 | "outDir": "build" /* Redirect output structure to the directory. */, 19 | // "rootDir": "src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 20 | "rootDirs": [ 21 | "src" 22 | ], 23 | // "composite": true /* Enable project compilation */, 24 | // "incremental": true /* Enable incremental compilation */, 25 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify file to store incremental compilation information */ 26 | // "removeComments": true, /* Do not emit comments to output. */ 27 | // "noEmit": true, /* Do not emit outputs. */ 28 | "importHelpers": true /* Import emit helpers from 'tslib'. */, 29 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 30 | "isolatedModules": true /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */, 31 | /* Strict Type-Checking Options */ 32 | "strict": true /* Enable all strict type-checking options. */, 33 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 34 | // "strictNullChecks": true, /* Enable strict null checks. */ 35 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 36 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 37 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 38 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 39 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 40 | /* Additional Checks */ 41 | "noUnusedLocals": true /* Report errors on unused locals. */, 42 | "noUnusedParameters": true /* Report errors on unused parameters. */, 43 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 44 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 45 | "useUnknownInCatchVariables": false, 46 | /* Module Resolution Options */ 47 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 48 | "baseUrl": "src" /* Base directory to resolve non-absolute module names. */, 49 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 50 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 51 | // "typeRoots": [], /* List of folders to include type definitions from. */ 52 | // "types": [], /* Type declaration files to be included in compilation. */ 53 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */, 54 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 55 | "noErrorTruncation": true, 56 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | /* Experimental Options */ 63 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 64 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */ 65 | }, 66 | "include": [ 67 | "src/**/*.ts", 68 | "src/**/*.d.ts" 69 | ], 70 | "exclude": [ 71 | "node_modules", 72 | "build" 73 | ], 74 | "compileOnSave": true 75 | } --------------------------------------------------------------------------------