@shipworthy/openapi-zod 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +344 -2
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +6 -0
- package/dist/generate.d.ts +22 -0
- package/dist/generate.js +392 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +12 -0
- package/dist/names.d.ts +4 -0
- package/dist/names.js +64 -0
- package/dist/refs.d.ts +5 -0
- package/dist/refs.js +14 -0
- package/dist/routes.d.ts +9 -0
- package/dist/routes.js +22 -0
- package/package.json +49 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shipworthy AI
|
|
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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,345 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @shipworthy/openapi-zod
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Generate [Zod 4](https://zod.dev) validators from an OpenAPI 3.1 document. Every schema in `components.schemas` becomes a named export. Every operation with a JSON request body gets a `<operationId>RequestBody` export. Each validator is typed against the TypeScript types that `@shipworthy/openapi-typescript` generates into a sibling `types.ts`, so `.parse()` returns the same types the rest of your client uses. Use this package when you want runtime validation of request payloads or component shapes with Zod. It is a generator plugin. Most users run it through the `openapi` CLI (`@shipworthy/openapi-cli`), which also writes the `types.ts` it depends on. You can also call it from code on a model produced by `@shipworthy/openapi-transformer`. If you don't want to depend on Zod, the sibling `@shipworthy/openapi-standard-schema` generates Standard Schema validators instead.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add -d @shipworthy/openapi-zod @shipworthy/openapi-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The generator and generated file both import `zod`. It is a required peer dependency of this package, so install this exact version as your app's runtime dependency:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
bun add zod@4.6.5
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requirements:
|
|
18
|
+
|
|
19
|
+
- **Zod 4.6.5.** The generated code calls `z.fromJSONSchema` and `z.compile`. `fromJSONSchema` is experimental in Zod, so the package pins `zod@4.6.5`. Use the same version in your app.
|
|
20
|
+
- **Node.js `>=22.19.0` or Bun.** The package ships compiled JavaScript and `.d.ts` declarations in `dist/`, so no TypeScript loader or bundler is needed. The `openapi` CLI runs on Node.js or Bun (`bunx --bun @shipworthy/openapi-cli ...`).
|
|
21
|
+
- **ESM only.** The package sets `"type": "module"` and has a single `.` export. There is no CommonJS build.
|
|
22
|
+
- **Peer dependency.** `zod` `4.6.5`, shared by the generator and your app. npm 7+ installs peers automatically, but install it explicitly as shown above.
|
|
23
|
+
- **Installed dependencies.** The package installs `effect`, `@shipworthy/openapi-generator`, `@shipworthy/openapi-transformer`, and `@shipworthy/openapi-typescript`.
|
|
24
|
+
|
|
25
|
+
Install `@shipworthy/openapi-cli` only if you use the CLI. For programmatic examples, also install the packages you import directly (`@shipworthy/openapi-generator`, `@shipworthy/openapi-transformer`, and `@effect/platform-node` as needed).
|
|
26
|
+
|
|
27
|
+
`@shipworthy/openapi-cli` lists this package as an optional peer dependency. The CLI loads it by name with `import("@shipworthy/openapi-zod")`, so install both packages in the same project.
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
Run the `openapi` commands below in a package script, or use `bunx --bun @shipworthy/openapi-cli` in place of `openapi`.
|
|
32
|
+
|
|
33
|
+
`openapi.json`:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"openapi": "3.1.0",
|
|
38
|
+
"info": { "title": "Hotels", "version": "1" },
|
|
39
|
+
"paths": {
|
|
40
|
+
"/bookings": {
|
|
41
|
+
"post": {
|
|
42
|
+
"operationId": "createBooking",
|
|
43
|
+
"requestBody": {
|
|
44
|
+
"required": true,
|
|
45
|
+
"content": {
|
|
46
|
+
"application/json": {
|
|
47
|
+
"schema": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"required": ["guest"],
|
|
50
|
+
"properties": {
|
|
51
|
+
"guest": { "$ref": "#/components/schemas/Guest" },
|
|
52
|
+
"note": { "type": "string" }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"responses": { "201": { "description": "Created" } }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"components": {
|
|
63
|
+
"schemas": {
|
|
64
|
+
"Guest": {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"required": ["name"],
|
|
67
|
+
"properties": {
|
|
68
|
+
"name": { "type": "string", "minLength": 1 },
|
|
69
|
+
"email": { "type": "string", "format": "email" },
|
|
70
|
+
"rooms": { "type": "integer", "minimum": 1, "default": 1 }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Generate:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
bunx --bun @shipworthy/openapi-cli generate zod ./openapi.json -o ./src/generated/hotels/zod-schema.ts
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
openapi generate typescript 0.0.1
|
|
86
|
+
file:///…/openapi.json → /…/src/generated/hotels/types.ts [49ms]
|
|
87
|
+
file:///…/openapi.json → /…/src/generated/hotels/zod-schema.ts [71ms]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Use:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { Guest, createBookingRequestBody } from "./generated/hotels/zod-schema";
|
|
94
|
+
|
|
95
|
+
Guest.parse({ name: "Ada", rooms: 2 }); // { name: "Ada", rooms: 2 }
|
|
96
|
+
Guest.parse({ name: "Ada" }); // { name: "Ada" } (defaults are not filled in)
|
|
97
|
+
|
|
98
|
+
const result = createBookingRequestBody.safeParse({ guest: { name: "" } });
|
|
99
|
+
result.success; // false: name has minLength 1
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## CLI: `openapi generate zod`
|
|
103
|
+
|
|
104
|
+
The command comes from `@shipworthy/openapi-cli`.
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
openapi generate zod [flags] <input>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
| Argument / flag | Type | Default | Description |
|
|
111
|
+
| ---------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
112
|
+
| `<input>` | string | required | OpenAPI schema file path or URL (`http:`, `https:`, `file:`). JSON or YAML. Globs (`*`) are rejected with `Globbing is unsupported.` |
|
|
113
|
+
| `--output`, `-o` | string | required | Output file for the Zod module. `types.ts` is written to the same directory. |
|
|
114
|
+
| `--force` | boolean | `false` | Regenerate `types.ts` even when its stamp matches. |
|
|
115
|
+
|
|
116
|
+
What the command does:
|
|
117
|
+
|
|
118
|
+
1. **Ensures `types.ts`.** It looks for `types.ts` next to the output file. If that file is missing, or its header stamp is not `@shipworthy/openapi-typescript@<version>:<sha256 of the input>`, it runs the TypeScript generator with default flags and writes `types.ts`. With `--force` it always rewrites `types.ts`.
|
|
119
|
+
2. **Generates the Zod module.** It loads, bundles, and validates the input. Bundling resolves external `$ref`s and upgrades OpenAPI 3.0 documents to 3.1. It then runs the `zod` generator.
|
|
120
|
+
3. **Writes the output.** It prepends a header and creates parent directories as needed. An existing output file is overwritten.
|
|
121
|
+
|
|
122
|
+
Generated file header:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
/**
|
|
126
|
+
* This file was auto-generated by @shipworthy/openapi-zod.
|
|
127
|
+
* Do not make direct changes to the file.
|
|
128
|
+
*
|
|
129
|
+
* @shipworthy/openapi-zod@0.0.1:67e0f42fc82c37b843728d1871e180fadef6dc99804131c0c1c7534fccd8885d
|
|
130
|
+
*/
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The stamp line is `<package>@<version>:<sha256 of the input file contents>`.
|
|
134
|
+
|
|
135
|
+
On failure the command prints `ERROR` and the message, then exits with code 1. The Zod file is not written. `types.ts` is generated first, so it can already exist when the Zod step fails. Example:
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
ERROR
|
|
139
|
+
#/components/schemas/Guest/properties/name: Unsupported keyword discriminator
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
A typical `package.json` script:
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"scripts": {
|
|
147
|
+
"generate": "openapi generate zod openapi.json -o src/generated/hotels/zod-schema.ts"
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Programmatic API
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
import {
|
|
156
|
+
zod,
|
|
157
|
+
zodSources,
|
|
158
|
+
operationRoutes,
|
|
159
|
+
ZodGenerationError,
|
|
160
|
+
type ZodSource,
|
|
161
|
+
type OperationRoute,
|
|
162
|
+
type HttpMethod,
|
|
163
|
+
} from "@shipworthy/openapi-zod";
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
All functions return [Effect](https://effect.website) values (Effect 4). `ParsedService` comes from `@shipworthy/openapi-transformer`.
|
|
167
|
+
|
|
168
|
+
### `zod`
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
const zod: Generator<ZodSource, ZodGenerationError>;
|
|
172
|
+
// { id: "zod", generate(model: ParsedService, options): Effect<ZodSource, ZodGenerationError> }
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The generator plugin. `Generator` is from `@shipworthy/openapi-generator`. `generate` ignores `options` and runs `operationRoutes(model)` followed by `zodSources(model, routes)`. The CLI loads this export by name (`exportName: "zod"`).
|
|
176
|
+
|
|
177
|
+
The usual way to run it from code is `generate` from `@shipworthy/openapi-generator`. That function reads the source, bundles external refs, upgrades 3.0 to 3.1, and decodes the model:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import { NodeServices } from "@effect/platform-node";
|
|
181
|
+
import { generate, ModuleLoader } from "@shipworthy/openapi-generator";
|
|
182
|
+
import { Effect, Layer } from "effect";
|
|
183
|
+
import { FetchHttpClient } from "effect/http";
|
|
184
|
+
import { pathToFileURL } from "node:url";
|
|
185
|
+
|
|
186
|
+
const source = await Effect.runPromise(
|
|
187
|
+
generate({
|
|
188
|
+
specifier: "@shipworthy/openapi-zod",
|
|
189
|
+
exportName: "zod",
|
|
190
|
+
source: pathToFileURL("./openapi.json"),
|
|
191
|
+
}).pipe(
|
|
192
|
+
Effect.provideService(ModuleLoader, { load: (specifier) => import(specifier) }),
|
|
193
|
+
Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)),
|
|
194
|
+
),
|
|
195
|
+
); // string: Zod module source, without the CLI header
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The `ModuleLoader` override resolves the plugin from your script. On Bun, use `BunServices` from `@effect/platform-bun` in place of `NodeServices`.
|
|
199
|
+
|
|
200
|
+
You can also call the plugin directly on a decoded model. Here `decode` does not bundle or upgrade, so the input must already be a self-contained OpenAPI 3.1 object:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import { decode } from "@shipworthy/openapi-transformer";
|
|
204
|
+
import { zod } from "@shipworthy/openapi-zod";
|
|
205
|
+
import { Effect } from "effect";
|
|
206
|
+
|
|
207
|
+
const model = Effect.runSync(decode(document));
|
|
208
|
+
const source = Effect.runSync(zod.generate(model, {}));
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Write `source` next to a `types.ts` generated by `@shipworthy/openapi-typescript` from the same document.
|
|
212
|
+
|
|
213
|
+
### `zodSources(document, routes)`
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
const zodSources: (
|
|
217
|
+
document: ParsedService,
|
|
218
|
+
routes: Record<string, OperationRoute>,
|
|
219
|
+
) => Effect<ZodSource, ZodGenerationError>;
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Turns a parsed OpenAPI 3.1 model into Zod module source. `routes` selects which operations can produce a `<operationId>RequestBody` validator. An operation whose `operationId` is not a key of `routes` is skipped. Build the table with `operationRoutes`. It fails with `ZodGenerationError` for anything listed under [Limitations](#limitations). Returns `""` when there are no component schemas and no JSON request bodies.
|
|
223
|
+
|
|
224
|
+
### `operationRoutes(model)`
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
const operationRoutes: (
|
|
228
|
+
model: ParsedService,
|
|
229
|
+
) => Effect<Record<string, OperationRoute>, ZodGenerationError>;
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Maps `operationId → [METHOD, path]` in document order. The returned object has a `null` prototype. Example: `{ createBooking: ["POST", "/bookings"] }`. It fails with:
|
|
233
|
+
|
|
234
|
+
- `<METHOD> <path>: Missing operationId` if any operation lacks an id. This applies to every operation, including those without a request body.
|
|
235
|
+
- `Duplicate operationId: <id>`
|
|
236
|
+
- `Unsupported operationId: <id>` for `__proto__`, `constructor`, or `prototype`.
|
|
237
|
+
|
|
238
|
+
### Types
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
type ZodSource = string; // the generated module source
|
|
242
|
+
type HttpMethod = "GET" | "PUT" | "POST" | "DELETE" | "OPTIONS" | "HEAD" | "PATCH" | "TRACE";
|
|
243
|
+
type OperationRoute = readonly [HttpMethod, string]; // [method, path]
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### `ZodGenerationError`
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
class ZodGenerationError extends Schema.TaggedError<ZodGenerationError>()("ZodGenerationError", {
|
|
250
|
+
message: Schema.String,
|
|
251
|
+
cause: Schema.optionalKey(Schema.Defect()),
|
|
252
|
+
}) {}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The only error the package produces. `_tag` is `"ZodGenerationError"`. Messages start with a location: a JSON pointer such as `#/components/schemas/Guest/properties/name`, an operation such as `POST /items`, or an export name. When run through `generate()` or the CLI, it reaches you wrapped in `TransformError` from `@shipworthy/openapi-generator`, with the same message.
|
|
256
|
+
|
|
257
|
+
## Generated output
|
|
258
|
+
|
|
259
|
+
The module exports one Zod validator per component schema and one validator per operation with
|
|
260
|
+
a JSON request body. Component validators use the component name. Request validators use
|
|
261
|
+
`<operationId>RequestBody`. Operations without JSON request bodies produce no request validator.
|
|
262
|
+
|
|
263
|
+
Each validator is typed against the adjacent `types.ts` generated by
|
|
264
|
+
`@shipworthy/openapi-typescript`. For example, a `Guest` component and a `createBooking` JSON
|
|
265
|
+
request body expose these declarations:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
import type * as z from "zod";
|
|
269
|
+
import type { components, operations } from "./types";
|
|
270
|
+
|
|
271
|
+
export declare const Guest: z.ZodType<components["schemas"]["Guest"]>;
|
|
272
|
+
export declare const createBookingRequestBody: z.ZodType<
|
|
273
|
+
NonNullable<operations["createBooking"]["requestBody"]>["content"]["application/json"]
|
|
274
|
+
>;
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Use `.parse` or `.safeParse` to validate application input. Keep the generated module next to
|
|
278
|
+
`types.ts`. Parsing does not fill in defaults. Unsupported conversions fail during generation
|
|
279
|
+
with the affected export name and validation error.
|
|
280
|
+
|
|
281
|
+
## Supported schemas
|
|
282
|
+
|
|
283
|
+
- **OpenAPI version.** The generator requires `openapi: 3.1.x` on the model. Through the CLI or `generate()`, 3.0 documents are upgraded to 3.1 first. The 3.0 upgrade does not remove keywords such as `discriminator`, and those still fail (see Limitations).
|
|
284
|
+
- **Validation keywords:** `$ref`, `type`, `enum`, `const`, `properties`, `patternProperties`, `additionalProperties`, `propertyNames`, `required`, `minProperties`, `maxProperties`, `allOf`, `anyOf`, `oneOf`, `items`, `prefixItems`, `contains`, `minContains`, `maxContains`, `minItems`, `maxItems`, `uniqueItems`, `minLength`, `maxLength`, `pattern`, `format`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`.
|
|
285
|
+
- **Annotations, allowed and passed through:** `title`, `description`, `default` (stripped), `example`, `examples`, `deprecated`, `readOnly`, `writeOnly`, `$comment`, `externalDocs`, `xml`, and any `x-*` extension.
|
|
286
|
+
- **Boolean schemas.** `true` and `false` are supported.
|
|
287
|
+
- **Rewrites for Zod.** Some JSON Schema patterns are rewritten so Zod enforces them:
|
|
288
|
+
- `dependentRequired` is rewritten into `allOf` branches. Example: `{ payment_source_id: ["hold_id"] }` requires `hold_id` only when `payment_source_id` is present.
|
|
289
|
+
- A required-only `allOf` branch such as `{ required: ["name"] }` copies in the property schemas inherited from sibling branches, so the requirement is enforced.
|
|
290
|
+
- `not: { anyOf: [{ required: ["k"] }, …] }`, when it stands alone in an object `allOf` branch, forbids those keys.
|
|
291
|
+
- **External refs.** External `$ref`s (local files or URLs) are bundled before conversion when you use the CLI or `generate()`.
|
|
292
|
+
|
|
293
|
+
## Limitations
|
|
294
|
+
|
|
295
|
+
Generated files use extensionless `./types` imports and expect TypeScript `moduleResolution: "bundler"`. Direct `NodeNext` compilation of those generated files is not currently supported. This does not affect the package's compiled exports.
|
|
296
|
+
|
|
297
|
+
Generation fails with `ZodGenerationError` when it finds any of the following:
|
|
298
|
+
|
|
299
|
+
| Condition | Message (abridged) |
|
|
300
|
+
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
|
301
|
+
| Model `openapi` not `3.1.x` | `Zod generation requires OpenAPI 3.1` |
|
|
302
|
+
| `jsonSchemaDialect` set, or top-level `$defs` | `Zod generation does not support custom dialects or top-level $defs` |
|
|
303
|
+
| Any keyword not listed above, e.g. `discriminator`, `if`/`then`/`else`, `unevaluatedProperties` | `<path>: Unsupported keyword <key>` |
|
|
304
|
+
| `$ref` not pointing to `#/components/schemas/…` | `<path>: Unsupported schema reference <ref>` |
|
|
305
|
+
| Non-annotation keywords next to `$ref` | `<path>: Zod does not preserve <key> alongside $ref` |
|
|
306
|
+
| Constraint keywords without `type` (except `enum`, `const`, `allOf`, `anyOf`, `oneOf`) | `<path>: Constraints without a type are not supported by Zod` |
|
|
307
|
+
| Array-form `items` (tuples) | `<path>: Tuple items require prefixItems` |
|
|
308
|
+
| Any other `not` form | `<path>: Unsupported not constraint` |
|
|
309
|
+
| Request body with more than one media type, or no `application/json` | `<METHOD> <path>: Request bodies require a single application/json media type` |
|
|
310
|
+
| JSON request body without a schema | `<METHOD> <path>: Request body has no schema` |
|
|
311
|
+
| Any operation without `operationId`; duplicate ids; ids `__proto__`/`constructor`/`prototype` | `Missing operationId` / `Duplicate operationId` / `Unsupported operationId` |
|
|
312
|
+
| Component name that is not a valid JS identifier, is a reserved word, or is `z` / `definitions` | `<name>: Cannot emit this name as a schema export` |
|
|
313
|
+
| Component name equal to some `<operationId>RequestBody` | `<name>: Duplicate schema export` |
|
|
314
|
+
| Zod cannot convert the schema | `<ExportName>: <Zod error message>` |
|
|
315
|
+
|
|
316
|
+
Not generated:
|
|
317
|
+
|
|
318
|
+
- Validators for responses, parameters, headers, or non-JSON bodies.
|
|
319
|
+
- Any wiring into a fetch client. The generated validators are not called automatically, so call them where a payload should be checked.
|
|
320
|
+
- Rules that exist only in `description` text. Those need their own checks.
|
|
321
|
+
|
|
322
|
+
## Troubleshooting
|
|
323
|
+
|
|
324
|
+
- **`Cannot find module './types'` or type errors in the generated file.** The Zod module imports `./types`. Generate it with the CLI, which writes `types.ts`, or run `openapi generate typescript` into the same directory.
|
|
325
|
+
- **`types.ts` looks stale or was generated with other flags.** The CLI keeps an existing `types.ts` whose stamp matches the input. Pass `--force` to rewrite it with default flags.
|
|
326
|
+
- **`Unsupported keyword discriminator`.** Remove `discriminator` from the schema, or express the union with `oneOf`/`anyOf` and `const` only. Zod generation does not accept the keyword.
|
|
327
|
+
- **`Missing operationId`.** Every operation needs an `operationId`, even operations with no request body.
|
|
328
|
+
- **Runtime errors from `z.fromJSONSchema`/`z.compile`.** Check that your app resolves `zod@4.6.5`.
|
|
329
|
+
|
|
330
|
+
## Key facts (for AI agents)
|
|
331
|
+
|
|
332
|
+
- Purpose: OpenAPI 3.1 document → one TypeScript module of Zod 4 validators. 3.0 input is upgraded only when run through the CLI or `generate()`.
|
|
333
|
+
- Run: `openapi generate zod <input> -o <dir>/<file>.ts [--force]` (CLI from `@shipworthy/openapi-cli`, runs on Node.js `>=22.19.0` or Bun). It also writes `<dir>/types.ts` when that file is missing or stale.
|
|
334
|
+
- Exports from generated file: `<ComponentName>` for each `components.schemas` entry, and `<operationId>RequestBody` for each operation with a single `application/json` request body. Nothing for responses or parameters.
|
|
335
|
+
- Each export is `z.compile(z.fromJSONSchema(...)) as z.ZodType<T>`, where `T` comes from `./types`. Use `.parse` / `.safeParse`.
|
|
336
|
+
- `default` values are stripped. Parsing never injects defaults.
|
|
337
|
+
- Runtime dependency of generated code: `zod@4.6.5` (pinned).
|
|
338
|
+
- Package exports: `zod` (generator plugin), `zodSources`, `operationRoutes`, `ZodGenerationError`, and the types `ZodSource`, `OperationRoute`, `HttpMethod`. All functions return Effect 4 values.
|
|
339
|
+
- Generation is all-or-nothing for the Zod file. Unsupported keywords fail with a JSON-pointer message, but the CLI may already have written `types.ts`.
|
|
340
|
+
- Every operation needs a unique `operationId`. Component names must be valid JS identifiers.
|
|
341
|
+
- The package ships compiled ESM JavaScript with `.d.ts` declarations (no CommonJS build). Peer dependency `zod@4.6.5`.
|
|
342
|
+
|
|
343
|
+
## License
|
|
344
|
+
|
|
345
|
+
MIT. See [LICENSE](./LICENSE).
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
declare const ZodGenerationError_base: Schema.Class<ZodGenerationError, Schema.TaggedStruct<"ZodGenerationError", {
|
|
3
|
+
readonly message: Schema.String;
|
|
4
|
+
readonly cause: Schema.optionalKey<Schema.Defect>;
|
|
5
|
+
}>, import("effect/Cause").YieldableError>;
|
|
6
|
+
export declare class ZodGenerationError extends ZodGenerationError_base {
|
|
7
|
+
}
|
|
8
|
+
export {};
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ParsedService } from "@shipworthy/openapi-transformer";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { ZodGenerationError } from "./errors.ts";
|
|
4
|
+
import type { OperationRoute } from "./routes.ts";
|
|
5
|
+
/**
|
|
6
|
+
* One module: compiled Zod schemas for each component schema and each JSON
|
|
7
|
+
* request body (`<operationId>RequestBody`), plus the shared JSON Schema they use.
|
|
8
|
+
*/
|
|
9
|
+
export type ZodSource = string;
|
|
10
|
+
/**
|
|
11
|
+
* Turn an OpenAPI 3.1 document into Zod source.
|
|
12
|
+
*
|
|
13
|
+
* Component schemas become named exports. JSON request bodies become
|
|
14
|
+
* `<operationId>RequestBody` exports. Shared schemas stay as JSON Schema in a
|
|
15
|
+
* module-local `definitions` object, and each validator includes only the
|
|
16
|
+
* `$defs` it references.
|
|
17
|
+
* Defaults are removed so parsing does not fill them in.
|
|
18
|
+
*
|
|
19
|
+
* `routes` selects which operations can produce a request-body validator.
|
|
20
|
+
* `operationRoutes` builds that table from the document.
|
|
21
|
+
*/
|
|
22
|
+
export declare const zodSources: (document: ParsedService, routes: Record<string, OperationRoute>) => Effect.Effect<string, ZodGenerationError, never>;
|
package/dist/generate.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { ParsedSchema } from "@shipworthy/openapi-transformer";
|
|
2
|
+
import { Array, Effect, Option, Order, Predicate, Record, Schema, pipe } from "effect";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import { ZodGenerationError } from "./errors.js";
|
|
5
|
+
import { assertExportName } from "./names.js";
|
|
6
|
+
import { resolveDocumentRef } from "./refs.js";
|
|
7
|
+
const schemaMaps = ["properties", "patternProperties"];
|
|
8
|
+
const compositions = ["allOf", "anyOf", "oneOf"];
|
|
9
|
+
const schemaFields = ["items", "additionalProperties", "propertyNames", "contains"];
|
|
10
|
+
const annotations = new Set([
|
|
11
|
+
"title",
|
|
12
|
+
"description",
|
|
13
|
+
"default",
|
|
14
|
+
"example",
|
|
15
|
+
"examples",
|
|
16
|
+
"deprecated",
|
|
17
|
+
"readOnly",
|
|
18
|
+
"writeOnly",
|
|
19
|
+
"$comment",
|
|
20
|
+
"externalDocs",
|
|
21
|
+
"xml",
|
|
22
|
+
]);
|
|
23
|
+
const untypedKeywords = new Set(["enum", "const", "allOf", "anyOf", "oneOf"]);
|
|
24
|
+
const keywords = new Set([
|
|
25
|
+
"$ref",
|
|
26
|
+
"type",
|
|
27
|
+
"enum",
|
|
28
|
+
"const",
|
|
29
|
+
...schemaMaps,
|
|
30
|
+
...compositions,
|
|
31
|
+
...schemaFields,
|
|
32
|
+
"not",
|
|
33
|
+
"required",
|
|
34
|
+
"dependentRequired",
|
|
35
|
+
"minProperties",
|
|
36
|
+
"maxProperties",
|
|
37
|
+
"prefixItems",
|
|
38
|
+
"minItems",
|
|
39
|
+
"maxItems",
|
|
40
|
+
"uniqueItems",
|
|
41
|
+
"minContains",
|
|
42
|
+
"maxContains",
|
|
43
|
+
"minLength",
|
|
44
|
+
"maxLength",
|
|
45
|
+
"pattern",
|
|
46
|
+
"format",
|
|
47
|
+
"minimum",
|
|
48
|
+
"maximum",
|
|
49
|
+
"exclusiveMinimum",
|
|
50
|
+
"exclusiveMaximum",
|
|
51
|
+
"multipleOf",
|
|
52
|
+
]);
|
|
53
|
+
function isAnnotation(key) {
|
|
54
|
+
return annotations.has(key) || key.startsWith("x-");
|
|
55
|
+
}
|
|
56
|
+
const encodeJson = Effect.fnUntraced(function* (value) {
|
|
57
|
+
return yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(value).pipe(Effect.mapError((cause) => new ZodGenerationError({ message: cause.message, cause })));
|
|
58
|
+
});
|
|
59
|
+
const asSchema = Effect.fnUntraced(function* (value, path) {
|
|
60
|
+
return yield* Schema.decodeUnknownEffect(ParsedSchema)(value).pipe(Effect.mapError(() => new ZodGenerationError({ message: `${path}: Expected a JSON schema` })));
|
|
61
|
+
});
|
|
62
|
+
const schemaMap = Effect.fnUntraced(function* (value, path, keyword) {
|
|
63
|
+
return yield* Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown))(value).pipe(Effect.mapError(() => new ZodGenerationError({ message: `${path}: Unsupported ${keyword}` })), Effect.flatMap((values) => Effect.forEach(Record.toEntries(values), ([name, part]) => asSchema(part, `${path}/${keyword}/${name}`).pipe(Effect.map((schema) => [name, schema])))), Effect.map(Record.fromEntries));
|
|
64
|
+
});
|
|
65
|
+
const schemaList = Effect.fnUntraced(function* (value, path, keyword) {
|
|
66
|
+
return yield* Schema.decodeUnknownEffect(Schema.Array(Schema.Unknown))(value).pipe(Effect.mapError(() => new ZodGenerationError({ message: `${path}: Unsupported ${keyword}` })), Effect.flatMap((values) => Effect.forEach(values, (part, index) => asSchema(part, `${path}/${keyword}/${index}`))));
|
|
67
|
+
});
|
|
68
|
+
function definitionSchema(schema) {
|
|
69
|
+
if (schema === true)
|
|
70
|
+
return {};
|
|
71
|
+
if (schema === false)
|
|
72
|
+
return { not: {} };
|
|
73
|
+
return schema;
|
|
74
|
+
}
|
|
75
|
+
function isSingleRequiredKey(part) {
|
|
76
|
+
return (Predicate.isObject(part) &&
|
|
77
|
+
Record.keys(part).length === 1 &&
|
|
78
|
+
Array.isArray(part.required) &&
|
|
79
|
+
part.required.length === 1 &&
|
|
80
|
+
Predicate.isString(part.required[0]));
|
|
81
|
+
}
|
|
82
|
+
function indent(value, spaces) {
|
|
83
|
+
const pad = " ".repeat(spaces);
|
|
84
|
+
return pipe(value.split("\n"), Array.map((line) => (line.length === 0 ? line : pad + line)), Array.join("\n"));
|
|
85
|
+
}
|
|
86
|
+
function schemaLiteral(schema, names, expression) {
|
|
87
|
+
if (names.length === 0)
|
|
88
|
+
return expression ?? JSON.stringify(schema, null, 2);
|
|
89
|
+
const defs = indent(pipe(names, Array.map((name) => `${JSON.stringify(name)}: definitions.${name}`), Array.join(",\n")), 4);
|
|
90
|
+
if (expression)
|
|
91
|
+
return `{\n ...${expression},\n $defs: {\n${defs}\n },\n}`;
|
|
92
|
+
const json = JSON.stringify(schema, null, 2);
|
|
93
|
+
if (!json.startsWith("{\n"))
|
|
94
|
+
return `{\n $defs: {\n${defs}\n },\n}`;
|
|
95
|
+
return `{\n${json.slice(2, -2)},\n $defs: {\n${defs}\n },\n}`;
|
|
96
|
+
}
|
|
97
|
+
const definitionName = Effect.fnUntraced(function* (ref) {
|
|
98
|
+
const marker = "#/$defs/";
|
|
99
|
+
if (!ref.startsWith(marker))
|
|
100
|
+
return yield* new ZodGenerationError({ message: `${ref}: Reference not found` });
|
|
101
|
+
return ref.slice(marker.length).replaceAll("~1", "/").replaceAll("~0", "~");
|
|
102
|
+
});
|
|
103
|
+
const objectProperties = Effect.fnUntraced(function* (document, schema, path, seen) {
|
|
104
|
+
if (typeof schema === "boolean")
|
|
105
|
+
return {};
|
|
106
|
+
if (Predicate.isString(schema.$ref) && schema.$ref) {
|
|
107
|
+
const ref = schema.$ref;
|
|
108
|
+
if (seen.has(ref))
|
|
109
|
+
return {};
|
|
110
|
+
const resolved = yield* resolveDocumentRef(document, ref).pipe(Effect.flatMap((value) => asSchema(value, ref)));
|
|
111
|
+
return yield* objectProperties(document, resolved, path, new Set([...seen, schema.$ref]));
|
|
112
|
+
}
|
|
113
|
+
const parts = yield* schemaList(schema.allOf ?? [], path, "allOf");
|
|
114
|
+
const inherited = yield* Effect.forEach(parts, (part, index) => objectProperties(document, part, `${path}/allOf/${index}`, seen));
|
|
115
|
+
const properties = schema.properties
|
|
116
|
+
? yield* schemaMap(schema.properties, path, "properties")
|
|
117
|
+
: {};
|
|
118
|
+
return {
|
|
119
|
+
...Array.reduce(inherited, {}, (merged, part) => ({ ...merged, ...part })),
|
|
120
|
+
...properties,
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
const normalize = Effect.fnUntraced(function* (document, schema, path, inherited = {}) {
|
|
124
|
+
// `true` and `false` are the same as `{}` and `{ not: {} }` once Zod converts them.
|
|
125
|
+
if (typeof schema === "boolean")
|
|
126
|
+
return definitionSchema(schema);
|
|
127
|
+
const unsupported = Array.findFirst(Record.keys(schema), (key) => !keywords.has(key) && !isAnnotation(key));
|
|
128
|
+
if (Option.isSome(unsupported))
|
|
129
|
+
return yield* new ZodGenerationError({
|
|
130
|
+
message: `${path}: Unsupported keyword ${unsupported.value}`,
|
|
131
|
+
});
|
|
132
|
+
const result = { ...schema };
|
|
133
|
+
delete result.default;
|
|
134
|
+
const properties = {
|
|
135
|
+
...inherited,
|
|
136
|
+
...(yield* objectProperties(document, schema, path, new Set())),
|
|
137
|
+
};
|
|
138
|
+
if (schema.$ref) {
|
|
139
|
+
if (!Predicate.isString(schema.$ref) || !schema.$ref.startsWith("#/components/schemas/")) {
|
|
140
|
+
const ref = Predicate.isString(schema.$ref) ? schema.$ref : yield* encodeJson(schema.$ref);
|
|
141
|
+
return yield* new ZodGenerationError({
|
|
142
|
+
message: `${path}: Unsupported schema reference ${ref}`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
yield* resolveDocumentRef(document, schema.$ref);
|
|
146
|
+
const sibling = Array.findFirst(Record.keys(schema), (key) => key !== "$ref" && !isAnnotation(key));
|
|
147
|
+
if (Option.isSome(sibling))
|
|
148
|
+
return yield* new ZodGenerationError({
|
|
149
|
+
message: `${path}: Zod does not preserve ${sibling.value} alongside $ref`,
|
|
150
|
+
});
|
|
151
|
+
result.$ref = `#/$defs/${schema.$ref.slice("#/components/schemas/".length)}`;
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
// In an object intersection, not(anyOf(required(single key))) forbids those keys.
|
|
155
|
+
// Zod only accepts `not: {}` (never), so the forbidden keys become `false` schemas.
|
|
156
|
+
if (schema.not !== undefined) {
|
|
157
|
+
const notSchema = schema.not;
|
|
158
|
+
const exclusions = Predicate.isObject(notSchema) ? notSchema.anyOf : undefined;
|
|
159
|
+
const onlyNot = Array.every(Record.keys(schema), (key) => key === "not" || isAnnotation(key));
|
|
160
|
+
if (!Array.isArray(exclusions) ||
|
|
161
|
+
!Predicate.isObject(notSchema) ||
|
|
162
|
+
Record.keys(notSchema).length !== 1 ||
|
|
163
|
+
Record.keys(properties).length === 0 ||
|
|
164
|
+
!onlyNot) {
|
|
165
|
+
return yield* new ZodGenerationError({ message: `${path}: Unsupported not constraint` });
|
|
166
|
+
}
|
|
167
|
+
const forbidden = yield* Effect.forEach(exclusions, Effect.fnUntraced(function* (part) {
|
|
168
|
+
const key = yield* asSchema(part, path);
|
|
169
|
+
if (!isSingleRequiredKey(key) || !key.required[0])
|
|
170
|
+
return yield* new ZodGenerationError({ message: `${path}: Unsupported not constraint` });
|
|
171
|
+
return [key.required[0], false];
|
|
172
|
+
})).pipe(Effect.map(Record.fromEntries));
|
|
173
|
+
delete result.not;
|
|
174
|
+
result.type = "object";
|
|
175
|
+
result.properties = forbidden;
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
// Zod rejects dependentRequired. Each entry becomes an allOf branch that either
|
|
179
|
+
// forbids the trigger key or requires it with its dependencies.
|
|
180
|
+
if (schema.dependentRequired !== undefined) {
|
|
181
|
+
if (!Predicate.isObject(schema.dependentRequired)) {
|
|
182
|
+
return yield* new ZodGenerationError({ message: `${path}: Unsupported dependentRequired` });
|
|
183
|
+
}
|
|
184
|
+
const branches = yield* Effect.forEach(Record.toEntries(schema.dependentRequired), ([name, value]) => Schema.decodeUnknownEffect(Schema.Array(Schema.String))(value).pipe(Effect.mapError(() => new ZodGenerationError({ message: `${path}: Unsupported dependentRequired` })), Effect.map((dependencies) => ({
|
|
185
|
+
anyOf: [
|
|
186
|
+
{ not: { anyOf: [{ required: [name] }] } },
|
|
187
|
+
{ required: [name, ...dependencies] },
|
|
188
|
+
],
|
|
189
|
+
}))));
|
|
190
|
+
delete result.dependentRequired;
|
|
191
|
+
result.allOf = [...(yield* schemaList(result.allOf ?? [], path, "allOf")), ...branches];
|
|
192
|
+
}
|
|
193
|
+
// Zod ignores required-only allOf branches. Copy the property schemas they inherit.
|
|
194
|
+
if (schema.required && !schema.type) {
|
|
195
|
+
const required = yield* Schema.decodeUnknownEffect(Schema.Array(Schema.String))(schema.required).pipe(Effect.mapError(() => new ZodGenerationError({ message: `${path}: Unsupported required constraint` })));
|
|
196
|
+
result.type = "object";
|
|
197
|
+
const props = yield* Effect.forEach(required, Effect.fnUntraced(function* (name) {
|
|
198
|
+
const property = properties[name];
|
|
199
|
+
if (property === undefined)
|
|
200
|
+
return yield* new ZodGenerationError({
|
|
201
|
+
message: `${path}: Cannot resolve required property ${name}`,
|
|
202
|
+
});
|
|
203
|
+
return [name, property];
|
|
204
|
+
})).pipe(Effect.map(Record.fromEntries));
|
|
205
|
+
result.properties = {
|
|
206
|
+
...(schema.properties ? yield* schemaMap(schema.properties, path, "properties") : {}),
|
|
207
|
+
...props,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (!result.type &&
|
|
211
|
+
Array.some(Record.keys(schema), (key) => keywords.has(key) && !untypedKeywords.has(key))) {
|
|
212
|
+
return yield* new ZodGenerationError({
|
|
213
|
+
message: `${path}: Constraints without a type are not supported by Zod`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
yield* Effect.forEach(schemaMaps, Effect.fnUntraced(function* (key) {
|
|
217
|
+
const values = result[key];
|
|
218
|
+
if (!values)
|
|
219
|
+
return;
|
|
220
|
+
const schemas = yield* schemaMap(values, path, key);
|
|
221
|
+
result[key] = yield* Effect.forEach(Record.toEntries(schemas), ([name, value]) => normalize(document, value, `${path}/${key}/${name}`).pipe(Effect.map((schema) => [name, schema]))).pipe(Effect.map(Record.fromEntries));
|
|
222
|
+
}), { discard: true });
|
|
223
|
+
yield* Effect.forEach(compositions, Effect.fnUntraced(function* (key) {
|
|
224
|
+
const values = result[key];
|
|
225
|
+
if (!values)
|
|
226
|
+
return;
|
|
227
|
+
const schemas = yield* schemaList(values, path, key);
|
|
228
|
+
result[key] = yield* Effect.forEach(schemas, (part, index) => normalize(document, part, `${path}/${key}/${index}`, properties));
|
|
229
|
+
}), { discard: true });
|
|
230
|
+
if (result.prefixItems) {
|
|
231
|
+
const schemas = yield* schemaList(result.prefixItems, path, "prefixItems");
|
|
232
|
+
result.prefixItems = yield* Effect.forEach(schemas, (part, index) => normalize(document, part, `${path}/prefixItems/${index}`));
|
|
233
|
+
}
|
|
234
|
+
yield* Effect.forEach(schemaFields, Effect.fnUntraced(function* (key) {
|
|
235
|
+
const value = result[key];
|
|
236
|
+
if (value === undefined)
|
|
237
|
+
return;
|
|
238
|
+
if (Array.isArray(value))
|
|
239
|
+
return yield* new ZodGenerationError({
|
|
240
|
+
message: `${path}: Tuple items require prefixItems`,
|
|
241
|
+
});
|
|
242
|
+
result[key] = yield* asSchema(value, `${path}/${key}`).pipe(Effect.flatMap((schema) => normalize(document, schema, `${path}/${key}`)));
|
|
243
|
+
}), { discard: true });
|
|
244
|
+
return result;
|
|
245
|
+
});
|
|
246
|
+
const visit = Effect.fnUntraced(function* (schema, needed, definitions) {
|
|
247
|
+
if (typeof schema === "boolean")
|
|
248
|
+
return;
|
|
249
|
+
if (Predicate.isString(schema.$ref) && schema.$ref) {
|
|
250
|
+
const name = yield* definitionName(schema.$ref);
|
|
251
|
+
if (!needed.has(name)) {
|
|
252
|
+
const definition = definitions.get(name);
|
|
253
|
+
if (definition === undefined)
|
|
254
|
+
return yield* new ZodGenerationError({ message: `${schema.$ref}: Reference not found` });
|
|
255
|
+
needed.add(name);
|
|
256
|
+
yield* visit(definition, needed, definitions);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
yield* Effect.forEach(schemaMaps, Effect.fnUntraced(function* (key) {
|
|
260
|
+
const values = schema[key];
|
|
261
|
+
if (!Predicate.isObject(values))
|
|
262
|
+
return;
|
|
263
|
+
yield* Effect.forEach(Record.values(values), (part) => asSchema(part, "schema").pipe(Effect.flatMap((child) => visit(child, needed, definitions))), { discard: true });
|
|
264
|
+
}), { discard: true });
|
|
265
|
+
yield* Effect.forEach([...compositions, "prefixItems"], Effect.fnUntraced(function* (key) {
|
|
266
|
+
const values = schema[key];
|
|
267
|
+
if (!Array.isArray(values))
|
|
268
|
+
return;
|
|
269
|
+
yield* Effect.forEach(values, (part) => asSchema(part, "schema").pipe(Effect.flatMap((child) => visit(child, needed, definitions))), { discard: true });
|
|
270
|
+
}), { discard: true });
|
|
271
|
+
yield* Effect.forEach(schemaFields, Effect.fnUntraced(function* (key) {
|
|
272
|
+
const value = schema[key];
|
|
273
|
+
if (value !== undefined && !Array.isArray(value)) {
|
|
274
|
+
yield* asSchema(value, "schema").pipe(Effect.flatMap((child) => visit(child, needed, definitions)));
|
|
275
|
+
}
|
|
276
|
+
}), { discard: true });
|
|
277
|
+
});
|
|
278
|
+
const compileExport = Effect.fnUntraced(function* (name, root) {
|
|
279
|
+
return yield* Effect.try({
|
|
280
|
+
try: () => {
|
|
281
|
+
z.compile(z.fromJSONSchema(root));
|
|
282
|
+
},
|
|
283
|
+
catch: (cause) => new ZodGenerationError({
|
|
284
|
+
message: `${name}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
285
|
+
cause,
|
|
286
|
+
}),
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
function renderSource(entries, definitions, exports) {
|
|
290
|
+
if (exports.length === 0)
|
|
291
|
+
return "";
|
|
292
|
+
const types = pipe(entries, Array.map((entry) => entry.types), Array.dedupe, Array.sort(Order.String));
|
|
293
|
+
const blocks = [
|
|
294
|
+
Array.join([
|
|
295
|
+
`import * as z from "zod";`,
|
|
296
|
+
...(types.length > 0 ? [`import type { ${Array.join(types, ", ")} } from "./types";`] : []),
|
|
297
|
+
], "\n"),
|
|
298
|
+
];
|
|
299
|
+
if (definitions.size > 0) {
|
|
300
|
+
const keys = pipe(Array.fromIterable(definitions.keys()), Array.map((name) => JSON.stringify(name)), Array.join(" | "));
|
|
301
|
+
const values = pipe(Array.fromIterable(definitions), Array.map(([name, schema]) => `${JSON.stringify(name)}: ${JSON.stringify(schema, null, 2)}`), Array.join(",\n"));
|
|
302
|
+
blocks.push(`const definitions: Record<${keys}, z.core.JSONSchema.JSONSchema> = {\n${indent(values, 2)},\n};`);
|
|
303
|
+
}
|
|
304
|
+
return `${Array.join([...blocks, ...exports], "\n\n")}\n`;
|
|
305
|
+
}
|
|
306
|
+
const source = Effect.fnUntraced(function* (entries, definitions) {
|
|
307
|
+
const seen = new Set();
|
|
308
|
+
yield* Effect.forEach(entries, Effect.fnUntraced(function* ({ name }) {
|
|
309
|
+
yield* assertExportName(name, ["z", "definitions"]);
|
|
310
|
+
if (seen.has(name))
|
|
311
|
+
return yield* new ZodGenerationError({ message: `${name}: Duplicate schema export` });
|
|
312
|
+
seen.add(name);
|
|
313
|
+
}), { discard: true });
|
|
314
|
+
return yield* Effect.forEach(entries, Effect.fnUntraced(function* ({ name, schema, type, expression }) {
|
|
315
|
+
const needed = new Set();
|
|
316
|
+
yield* visit(schema, needed, definitions);
|
|
317
|
+
const names = Array.sort(needed, Order.String);
|
|
318
|
+
const defs = yield* Effect.forEach(names, Effect.fnUntraced(function* (definition) {
|
|
319
|
+
const value = definitions.get(definition);
|
|
320
|
+
if (value === undefined)
|
|
321
|
+
return yield* new ZodGenerationError({
|
|
322
|
+
message: `#/$defs/${definition}: Reference not found`,
|
|
323
|
+
});
|
|
324
|
+
return [definition, value];
|
|
325
|
+
})).pipe(Effect.map(Record.fromEntries));
|
|
326
|
+
const root = typeof schema === "boolean"
|
|
327
|
+
? schema
|
|
328
|
+
: { ...schema, ...(names.length > 0 ? { $defs: defs } : {}) };
|
|
329
|
+
yield* compileExport(name, root);
|
|
330
|
+
const argument = schemaLiteral(schema, names, expression);
|
|
331
|
+
return `export const ${name} = z.compile(z.fromJSONSchema(${argument})) as z.ZodType<${type}>;`;
|
|
332
|
+
})).pipe(Effect.map((exports) => renderSource(entries, definitions, exports)));
|
|
333
|
+
});
|
|
334
|
+
const compile = Effect.fnUntraced(function* (model, routes) {
|
|
335
|
+
if (!model.openapi.startsWith("3.1."))
|
|
336
|
+
return yield* new ZodGenerationError({ message: "Zod generation requires OpenAPI 3.1" });
|
|
337
|
+
if (model.jsonSchemaDialect || Record.keys(model.$defs).length > 0) {
|
|
338
|
+
return yield* new ZodGenerationError({
|
|
339
|
+
message: "Zod generation does not support custom dialects or top-level $defs",
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
const definitions = yield* Effect.forEach(Record.toEntries(model.components.schemas), ([name, schema]) => normalize(model, schema, `#/components/schemas/${name}`).pipe(Effect.map((normalized) => [name, normalized]))).pipe(Effect.map((entries) => new Map(entries)));
|
|
343
|
+
const components = Array.map(Array.fromIterable(definitions), ([name, schema]) => ({
|
|
344
|
+
name,
|
|
345
|
+
schema,
|
|
346
|
+
types: "components",
|
|
347
|
+
type: `components["schemas"][${JSON.stringify(name)}]`,
|
|
348
|
+
expression: `definitions.${name}`,
|
|
349
|
+
}));
|
|
350
|
+
const operations = yield* Effect.forEach(model.operations, Effect.fnUntraced(function* (operation) {
|
|
351
|
+
const id = operation.operationId;
|
|
352
|
+
if (!id || !Object.hasOwn(routes, id))
|
|
353
|
+
return;
|
|
354
|
+
const location = `${operation.method} ${operation.path}`;
|
|
355
|
+
const body = operation.request.body;
|
|
356
|
+
if (!body)
|
|
357
|
+
return;
|
|
358
|
+
const content = Array.findFirst(body.contents, (entry) => entry.mediaType === "application/json");
|
|
359
|
+
if (body.contents.length !== 1 || Option.isNone(content)) {
|
|
360
|
+
return yield* new ZodGenerationError({
|
|
361
|
+
message: `${location}: Request bodies require a single application/json media type`,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
if (content.value.schema === undefined)
|
|
365
|
+
return yield* new ZodGenerationError({
|
|
366
|
+
message: `${location}: Request body has no schema`,
|
|
367
|
+
});
|
|
368
|
+
const idLiteral = yield* encodeJson(id);
|
|
369
|
+
return {
|
|
370
|
+
name: `${id}RequestBody`,
|
|
371
|
+
types: "operations",
|
|
372
|
+
type: `NonNullable<operations[${idLiteral}]["requestBody"]>["content"]["application/json"]`,
|
|
373
|
+
schema: yield* normalize(model, content.value.schema, location),
|
|
374
|
+
};
|
|
375
|
+
})).pipe(Effect.map(Array.filter(Predicate.isNotUndefined)));
|
|
376
|
+
return yield* source([...components, ...operations], definitions);
|
|
377
|
+
});
|
|
378
|
+
/**
|
|
379
|
+
* Turn an OpenAPI 3.1 document into Zod source.
|
|
380
|
+
*
|
|
381
|
+
* Component schemas become named exports. JSON request bodies become
|
|
382
|
+
* `<operationId>RequestBody` exports. Shared schemas stay as JSON Schema in a
|
|
383
|
+
* module-local `definitions` object, and each validator includes only the
|
|
384
|
+
* `$defs` it references.
|
|
385
|
+
* Defaults are removed so parsing does not fill them in.
|
|
386
|
+
*
|
|
387
|
+
* `routes` selects which operations can produce a request-body validator.
|
|
388
|
+
* `operationRoutes` builds that table from the document.
|
|
389
|
+
*/
|
|
390
|
+
export const zodSources = Effect.fn("zodSources")(function* (document, routes) {
|
|
391
|
+
return yield* compile(document, routes);
|
|
392
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Generator } from "@shipworthy/openapi-generator";
|
|
2
|
+
import { ZodGenerationError } from "./errors.ts";
|
|
3
|
+
import { type ZodSource } from "./generate.ts";
|
|
4
|
+
export { ZodGenerationError } from "./errors.ts";
|
|
5
|
+
export { zodSources, type ZodSource } from "./generate.ts";
|
|
6
|
+
export { operationRoutes, type HttpMethod, type OperationRoute } from "./routes.ts";
|
|
7
|
+
export declare const zod: Generator<ZodSource, ZodGenerationError>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { zodSources } from "./generate.js";
|
|
3
|
+
import { operationRoutes } from "./routes.js";
|
|
4
|
+
export { ZodGenerationError } from "./errors.js";
|
|
5
|
+
export { zodSources } from "./generate.js";
|
|
6
|
+
export { operationRoutes } from "./routes.js";
|
|
7
|
+
export const zod = {
|
|
8
|
+
id: "zod",
|
|
9
|
+
generate: Effect.fnUntraced(function* (model) {
|
|
10
|
+
return yield* operationRoutes(model).pipe(Effect.flatMap((routes) => zodSources(model, routes)));
|
|
11
|
+
}),
|
|
12
|
+
};
|
package/dist/names.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { ZodGenerationError } from "./errors.ts";
|
|
3
|
+
/** Reject names that cannot be emitted as `export const` bindings. */
|
|
4
|
+
export declare const assertExportName: (name: string, blocked: readonly string[]) => Effect.Effect<void, ZodGenerationError, never>;
|
package/dist/names.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { ZodGenerationError } from "./errors.js";
|
|
3
|
+
const reservedWords = new Set([
|
|
4
|
+
"await",
|
|
5
|
+
"break",
|
|
6
|
+
"case",
|
|
7
|
+
"catch",
|
|
8
|
+
"class",
|
|
9
|
+
"const",
|
|
10
|
+
"continue",
|
|
11
|
+
"debugger",
|
|
12
|
+
"default",
|
|
13
|
+
"delete",
|
|
14
|
+
"do",
|
|
15
|
+
"else",
|
|
16
|
+
"enum",
|
|
17
|
+
"export",
|
|
18
|
+
"extends",
|
|
19
|
+
"false",
|
|
20
|
+
"finally",
|
|
21
|
+
"for",
|
|
22
|
+
"function",
|
|
23
|
+
"if",
|
|
24
|
+
"import",
|
|
25
|
+
"in",
|
|
26
|
+
"instanceof",
|
|
27
|
+
"interface",
|
|
28
|
+
"implements",
|
|
29
|
+
"let",
|
|
30
|
+
"new",
|
|
31
|
+
"null",
|
|
32
|
+
"package",
|
|
33
|
+
"private",
|
|
34
|
+
"protected",
|
|
35
|
+
"public",
|
|
36
|
+
"return",
|
|
37
|
+
"static",
|
|
38
|
+
"super",
|
|
39
|
+
"switch",
|
|
40
|
+
"this",
|
|
41
|
+
"throw",
|
|
42
|
+
"true",
|
|
43
|
+
"try",
|
|
44
|
+
"typeof",
|
|
45
|
+
"var",
|
|
46
|
+
"void",
|
|
47
|
+
"while",
|
|
48
|
+
"with",
|
|
49
|
+
"yield",
|
|
50
|
+
]);
|
|
51
|
+
const identifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
52
|
+
/** Reject names that cannot be emitted as `export const` bindings. */
|
|
53
|
+
export const assertExportName = Effect.fnUntraced(function* (name, blocked) {
|
|
54
|
+
if (!identifier.test(name) ||
|
|
55
|
+
reservedWords.has(name) ||
|
|
56
|
+
blocked.includes(name) ||
|
|
57
|
+
name === "__proto__" ||
|
|
58
|
+
name === "constructor" ||
|
|
59
|
+
name === "prototype") {
|
|
60
|
+
return yield* new ZodGenerationError({
|
|
61
|
+
message: `${name}: Cannot emit this name as a schema export`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
});
|
package/dist/refs.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ParsedService } from "@shipworthy/openapi-transformer";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { ZodGenerationError } from "./errors.ts";
|
|
4
|
+
/** Follow an internal `$ref` on a parsed document. Boolean `false` is a real schema. */
|
|
5
|
+
export declare const resolveDocumentRef: (model: ParsedService, ref: string) => Effect.Effect<unknown, ZodGenerationError, never>;
|
package/dist/refs.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { resolveRef } from "@shipworthy/openapi-typescript";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { ZodGenerationError } from "./errors.js";
|
|
4
|
+
/** Follow an internal `$ref` on a parsed document. Boolean `false` is a real schema. */
|
|
5
|
+
export const resolveDocumentRef = Effect.fnUntraced(function* (model, ref) {
|
|
6
|
+
const resolved = yield* Effect.try({
|
|
7
|
+
try: () => resolveRef(model, ref, { silent: true }),
|
|
8
|
+
catch: (cause) => new ZodGenerationError({ message: `${ref}: Unresolved reference`, cause }),
|
|
9
|
+
});
|
|
10
|
+
if (resolved === undefined) {
|
|
11
|
+
return yield* new ZodGenerationError({ message: `${ref}: Unresolved reference` });
|
|
12
|
+
}
|
|
13
|
+
return resolved;
|
|
14
|
+
});
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ParsedService } from "@shipworthy/openapi-transformer";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { ZodGenerationError } from "./errors.ts";
|
|
4
|
+
declare const methods: readonly ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
5
|
+
export type HttpMethod = Uppercase<(typeof methods)[number]>;
|
|
6
|
+
export type OperationRoute = readonly [HttpMethod, string];
|
|
7
|
+
/** operationId → [METHOD, path], in document order. Every operation needs an id. */
|
|
8
|
+
export declare const operationRoutes: (model: ParsedService) => Effect.Effect<Record<string, OperationRoute>, ZodGenerationError, never>;
|
|
9
|
+
export {};
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { ZodGenerationError } from "./errors.js";
|
|
3
|
+
const methods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
4
|
+
const blockedOperationIds = new Set(["__proto__", "constructor", "prototype"]);
|
|
5
|
+
/** operationId → [METHOD, path], in document order. Every operation needs an id. */
|
|
6
|
+
export const operationRoutes = Effect.fnUntraced(function* (model) {
|
|
7
|
+
const routes = Object.create(null);
|
|
8
|
+
return yield* Effect.reduce(model.operations, () => routes, Effect.fnUntraced(function* (routes, operation) {
|
|
9
|
+
const label = `${operation.method} ${operation.path}`;
|
|
10
|
+
const id = operation.operationId;
|
|
11
|
+
if (!id)
|
|
12
|
+
return yield* new ZodGenerationError({ message: `${label}: Missing operationId` });
|
|
13
|
+
if (blockedOperationIds.has(id)) {
|
|
14
|
+
return yield* new ZodGenerationError({ message: `Unsupported operationId: ${id}` });
|
|
15
|
+
}
|
|
16
|
+
if (Object.hasOwn(routes, id)) {
|
|
17
|
+
return yield* new ZodGenerationError({ message: `Duplicate operationId: ${id}` });
|
|
18
|
+
}
|
|
19
|
+
routes[id] = [operation.method, operation.path];
|
|
20
|
+
return routes;
|
|
21
|
+
}));
|
|
22
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipworthy/openapi-zod",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"files": [
|
|
5
|
+
"dist"
|
|
6
|
+
],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@shipworthy/openapi-generator": "0.0.2",
|
|
9
|
+
"@shipworthy/openapi-transformer": "0.0.2",
|
|
10
|
+
"@shipworthy/openapi-typescript": "0.0.2",
|
|
11
|
+
"effect": "^4.0.0"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"zod": "4.6.5"
|
|
15
|
+
},
|
|
16
|
+
"description": "Generate Zod validators from OpenAPI 3.1 schemas.",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/ShipWorthyAI/openapi.git",
|
|
21
|
+
"directory": "packages/openapi-zod"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "bun test test/generate.test.ts test/cli.test.ts",
|
|
33
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@effect/platform-node": "^4.0.0",
|
|
37
|
+
"@shipworthy/typescript-config": "0.0.1",
|
|
38
|
+
"@types/node": "^24.10.3",
|
|
39
|
+
"typescript": "^7.0.2",
|
|
40
|
+
"vitest": "5.0.3",
|
|
41
|
+
"zod": "4.6.5"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22.19.0"
|
|
45
|
+
},
|
|
46
|
+
"main": "./dist/index.js",
|
|
47
|
+
"types": "./dist/index.d.ts",
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
}
|
|
51
|
+
}
|