@shipworthy/openapi-zod 0.0.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,18 +8,19 @@ Generate [Zod 4](https://zod.dev) validators from an OpenAPI 3.1 document. Every
8
8
  bun add -d @shipworthy/openapi-zod @shipworthy/openapi-cli
9
9
  ```
10
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:
11
+ The generator checks schemas with `zod`, and the generated file imports it. It is a required peer dependency of this package, so install this exact version in the package that owns the generated file:
12
12
 
13
13
  ```sh
14
- bun add zod@4.6.5
14
+ bun add zod@4.6.7
15
15
  ```
16
16
 
17
17
  Requirements:
18
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.
19
+ - **Zod 4.6.7.** The generated code calls `z.fromJSONSchema` and `z.compile`. `fromJSONSchema` is experimental in Zod, so the package pins `zod@4.6.7`. Use the same version in your app.
20
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
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.
22
+ - **Peer dependency.** `zod` `4.6.7`. npm 7+ installs peers automatically, but install it explicitly as shown above.
23
+ - **Workspace resolution.** The CLI looks for an installed Zod from the output directory upward. If none is installed there, it uses the generator's installation; Bun's global auto-install cache does not count as an installed package. A different installed version fails with the found version and resolved path before schema compilation. Install `zod@4.6.7` in the package that will import the generated file.
23
24
  - **Installed dependencies.** The package installs `effect`, `@shipworthy/openapi-generator`, `@shipworthy/openapi-transformer`, and `@shipworthy/openapi-typescript`.
24
25
 
25
26
  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).
@@ -78,13 +79,12 @@ Run the `openapi` commands below in a package script, or use `bunx --bun @shipwo
78
79
  Generate:
79
80
 
80
81
  ```sh
81
- bunx --bun @shipworthy/openapi-cli generate zod ./openapi.json -o ./src/generated/hotels/zod-schema.ts
82
+ bunx --bun @shipworthy/openapi-cli generate zod -i ./openapi.json -o ./src/generated/hotels
82
83
  ```
83
84
 
84
85
  ```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]
86
+ file:///…/openapi.json → ./src/generated/hotels/types.ts […]
87
+ file:///…/openapi.json → ./src/generated/hotels/zod-schema.ts […]
88
88
  ```
89
89
 
90
90
  Use:
@@ -101,23 +101,26 @@ result.success; // false: name has minLength 1
101
101
 
102
102
  ## CLI: `openapi generate zod`
103
103
 
104
- The command comes from `@shipworthy/openapi-cli`.
104
+ The command comes from `@shipworthy/openapi-cli`. The `zod` plugin writes `zod-schema.ts` and the `types.ts` it imports into the output directory:
105
105
 
106
106
  ```text
107
- openapi generate zod [flags] <input>
107
+ openapi generate zod [-i <input>] -o <dir> [--camel-case] [--wire-types] [--check] [TypeScript flags]
108
108
  ```
109
109
 
110
- | Argument / flag | Type | Default | Description |
110
+ | Flag | Type | Default | Description |
111
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. |
112
+ | `--input`, `-i` | string | stdin | 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 directory. Receives `zod-schema.ts` and `types.ts`. Required because the run writes two files. |
114
+ | `--camel-case` | boolean | `false` | Generate camelCase `types.ts` and camelCase validators. See [camelCase](#camelcase). |
115
+ | `--check` | boolean | `false` | Write nothing; exit 1 when `zod-schema.ts` or `types.ts` differs from the generated output or is missing. |
116
+
117
+ The TypeScript generator flags, such as `--no-array-length`, `--enum`, and `--root-types`, apply to `types.ts`. The `zod` plugin can be combined with other plugins in one run, for example `openapi generate zod tanstack-query -i openapi.json -o src/generated/hotels`, which writes one shared `types.ts`. See the [CLI reference](../openapi-cli/README.md#openapi-generate-plugins) for every flag, including `--wire-types`.
115
118
 
116
119
  What the command does:
117
120
 
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
+ 1. **Loads the input.** It loads, bundles, and validates the input once. Bundling resolves external `$ref`s and upgrades OpenAPI 3.0 documents to 3.1.
122
+ 2. **Generates `types.ts` and the Zod module.** It runs the TypeScript generator with the TypeScript flags and the `zod` generator. Both files are regenerated on every run.
123
+ 3. **Writes the output.** Only after both files were generated, it prepends a header to each and writes them, creating the output directory as needed. Existing files are overwritten.
121
124
 
122
125
  Generated file header:
123
126
 
@@ -130,13 +133,13 @@ Generated file header:
130
133
  */
131
134
  ```
132
135
 
133
- The stamp line is `<package>@<version>:<sha256 of the input file contents>`.
136
+ The stamp line is `<package>@<version>:<sha256 of the input file contents>`. With `--camel-case`, a `Field casing: camelCase` line follows the stamp, and the hash also covers the casing setting.
134
137
 
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:
138
+ On failure the command prints `ERROR` and the message, then exits with code 1. Neither `zod-schema.ts` nor `types.ts` is written. Example:
136
139
 
137
140
  ```text
138
141
  ERROR
139
- #/components/schemas/Guest/properties/name: Unsupported keyword discriminator
142
+ #/components/schemas/Guest/properties/name: Unsupported keyword if
140
143
  ```
141
144
 
142
145
  A typical `package.json` script:
@@ -144,7 +147,7 @@ A typical `package.json` script:
144
147
  ```json
145
148
  {
146
149
  "scripts": {
147
- "generate": "openapi generate zod openapi.json -o src/generated/hotels/zod-schema.ts"
150
+ "generate": "openapi generate zod -i openapi.json -o src/generated/hotels"
148
151
  }
149
152
  }
150
153
  ```
@@ -172,7 +175,12 @@ const zod: Generator<ZodSource, ZodGenerationError>;
172
175
  // { id: "zod", generate(model: ParsedService, options): Effect<ZodSource, ZodGenerationError> }
173
176
  ```
174
177
 
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"`).
178
+ The generator plugin. `Generator` is from `@shipworthy/openapi-generator`. `generate` accepts these options:
179
+
180
+ - `outputPath?: string`: resolve Zod from the generated file's location. Without it, Zod is resolved from the generator's installation.
181
+ - `camelCase?: boolean`: rename snake_case properties to camelCase. See [camelCase](#camelcase). Default `false`.
182
+
183
+ It then runs `operationRoutes(model)` followed by `zodSources(model, routes, options)`. Options of the wrong type fail with `ZodGenerationError` (`#: Invalid generator options`). Unknown option keys are ignored. The CLI loads this export by name (`exportName: "zod"`).
176
184
 
177
185
  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
186
 
@@ -188,6 +196,7 @@ const source = await Effect.runPromise(
188
196
  specifier: "@shipworthy/openapi-zod",
189
197
  exportName: "zod",
190
198
  source: pathToFileURL("./openapi.json"),
199
+ pluginOptions: { outputPath: "./src/generated/zod-schema.ts" },
191
200
  }).pipe(
192
201
  Effect.provideService(ModuleLoader, { load: (specifier) => import(specifier) }),
193
202
  Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)),
@@ -205,21 +214,32 @@ import { zod } from "@shipworthy/openapi-zod";
205
214
  import { Effect } from "effect";
206
215
 
207
216
  const model = Effect.runSync(decode(document));
208
- const source = Effect.runSync(zod.generate(model, {}));
217
+ const source = Effect.runSync(zod.generate(model, { outputPath: "./src/generated/zod-schema.ts" }));
209
218
  ```
210
219
 
211
220
  Write `source` next to a `types.ts` generated by `@shipworthy/openapi-typescript` from the same document.
212
221
 
213
- ### `zodSources(document, routes)`
222
+ ### `zodSources(document, routes, options?)`
214
223
 
215
224
  ```ts
216
225
  const zodSources: (
217
226
  document: ParsedService,
218
227
  routes: Record<string, OperationRoute>,
228
+ options?: { readonly outputPath?: string; readonly camelCase?: boolean },
219
229
  ) => Effect<ZodSource, ZodGenerationError>;
220
230
  ```
221
231
 
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.
232
+ 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`. Set `options.outputPath` to resolve Zod from the generated file's location; without it, generation uses the generator's own Zod. It fails with `ZodGenerationError` for anything listed under [Limitations](#limitations). Returns `""` when there are no component schemas and no JSON request bodies.
233
+
234
+ #### camelCase
235
+
236
+ Set `camelCase: true` when the validators sit next to a `types.ts` that `@shipworthy/openapi-typescript` generated with `camelCase: true`. The validators then accept and return the camelCase shapes those types declare. The CLI flag `--camel-case` sets this option and generates the matching camelCase `types.ts` in the same run.
237
+
238
+ - Renamed: lower snake_case names (`first_name`, `address_line_2`) in `properties` keys, `required` entries, `dependentRequired`, and the discriminator `propertyName`. This applies at every depth, in component schemas and request bodies alike. Other names, such as `already_Camel`, `_private`, or `UPPER_CASE`, keep their spelling.
239
+ - Kept: `enum` and `const` values, discriminator mapping values, `patternProperties` keys, and `$ref`s keep the API spelling. A `patternProperties` pattern therefore still matches the API's snake_case keys.
240
+ - Collisions: when two names at one object level become the same name, including names merged through `allOf`, `anyOf`, `oneOf`, or a `$ref`, generation fails before any output is produced. The message is `<path>: user_id and userId both become userId`, where `<path>` is the component pointer (`#/components/schemas/User`) or the operation (`POST /users`).
241
+
242
+ The validators check camelCase objects. Convert wire payloads to camelCase before you parse them with the same mapping the client uses. The generated module still imports `./types` and casts each export to `components["schemas"][Name]` or the operation's request body type.
223
243
 
224
244
  ### `operationRoutes(model)`
225
245
 
@@ -280,14 +300,18 @@ with the affected export name and validation error.
280
300
 
281
301
  ## Supported schemas
282
302
 
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`.
303
+ - **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.
304
+ - **Validation keywords:** `$ref`, `type`, `enum`, `const`, `properties`, `patternProperties`, `additionalProperties`, `propertyNames`, `required`, `minProperties`, `maxProperties`, `allOf`, `anyOf`, `oneOf`, `discriminator`, `items`, `prefixItems`, `contains`, `minContains`, `maxContains`, `minItems`, `maxItems`, `uniqueItems`, `minLength`, `maxLength`, `pattern`, `format`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`.
285
305
  - **Annotations, allowed and passed through:** `title`, `description`, `default` (stripped), `example`, `examples`, `deprecated`, `readOnly`, `writeOnly`, `$comment`, `externalDocs`, `xml`, and any `x-*` extension.
286
306
  - **Boolean schemas.** `true` and `false` are supported.
287
307
  - **Rewrites for Zod.** Some JSON Schema patterns are rewritten so Zod enforces them:
288
308
  - `dependentRequired` is rewritten into `allOf` branches. Example: `{ payment_source_id: ["hold_id"] }` requires `hold_id` only when `payment_source_id` is present.
289
309
  - A required-only `allOf` branch such as `{ required: ["name"] }` copies in the property schemas inherited from sibling branches, so the requirement is enforced.
290
310
  - `not: { anyOf: [{ required: ["k"] }, …] }`, when it stands alone in an object `allOf` branch, forbids those keys.
311
+ - A `discriminator` on `oneOf` or `anyOf` constrains each component `$ref` branch to its mapped property values. Multiple values can map to one branch. Without a mapping for a branch, its schema name is the implicit value unless that value explicitly maps to a different branch. A component reused by several unions with the same discriminator property accepts their combined mapped values. If the unions use different properties, the component alone remains general and each union branch enforces its own tag.
312
+ - Discriminated unions are closed: a value absent from the mapping and implicit branch names is rejected. Regenerate the SDK when the service adds a new value. For an invalid payload with a known value, the exported union validator reports the selected branch's issues directly, such as a missing `auth_url` at `path: ["auth_url"]`.
313
+ - Existing constraints on a discriminator property remain in effect. A mapping value that conflicts with them is rejected by the validator.
314
+ - When a component extends a parent with a `discriminator` through `allOf`, the child receives values mapped to it, or its schema name when no mapping entry exists. The parent remains a general validator.
291
315
  - **External refs.** External `$ref`s (local files or URLs) are bundled before conversion when you use the CLI or `generate()`.
292
316
 
293
317
  ## Limitations
@@ -300,7 +324,10 @@ Generation fails with `ZodGenerationError` when it finds any of the following:
300
324
  | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
301
325
  | Model `openapi` not `3.1.x` | `Zod generation requires OpenAPI 3.1` |
302
326
  | `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>` |
327
+ | Any keyword not listed above, e.g. `if`/`then`/`else`, `unevaluatedProperties` | `<path>: Unsupported keyword <key>` |
328
+ | A discriminated union branch without a component schema `$ref` | `<path>: Discriminator branch requires a component schema reference` |
329
+ | An implicit branch name claimed by an explicit mapping to another branch | `<path>: Discriminator branch has no unique value` |
330
+ | A mapped component that is neither an object nor an `allOf` schema | `<path>: Unsupported discriminator target` |
304
331
  | `$ref` not pointing to `#/components/schemas/…` | `<path>: Unsupported schema reference <ref>` |
305
332
  | Non-annotation keywords next to `$ref` | `<path>: Zod does not preserve <key> alongside $ref` |
306
333
  | Constraint keywords without `type` (except `enum`, `const`, `allOf`, `anyOf`, `oneOf`) | `<path>: Constraints without a type are not supported by Zod` |
@@ -311,6 +338,7 @@ Generation fails with `ZodGenerationError` when it finds any of the following:
311
338
  | Any operation without `operationId`; duplicate ids; ids `__proto__`/`constructor`/`prototype` | `Missing operationId` / `Duplicate operationId` / `Unsupported operationId` |
312
339
  | 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
340
  | Component name equal to some `<operationId>RequestBody` | `<name>: Duplicate schema export` |
341
+ | With `camelCase: true`, two property names that become the same camelCase name | `<path>: <a> and <b> both become <camelName>` |
314
342
  | Zod cannot convert the schema | `<ExportName>: <Zod error message>` |
315
343
 
316
344
  Not generated:
@@ -321,24 +349,24 @@ Not generated:
321
349
 
322
350
  ## Troubleshooting
323
351
 
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.
352
+ - **`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` next to `zod-schema.ts` on every run.
353
+ - **`Unsupported keyword discriminator`.** Upgrade from 0.0.1; discriminators on `oneOf`/`anyOf` and `allOf` inheritance are supported. A discriminated union branch must be a component schema `$ref`.
327
354
  - **`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`.
355
+ - **Wrong Zod version.** Install `zod@4.6.7` in the package that owns the generated file. The error reports the version and path found from the output location.
329
356
 
330
357
  ## Key facts (for AI agents)
331
358
 
332
359
  - 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.
360
+ - Run: `openapi generate zod -i <input> -o <dir> [--camel-case] [TypeScript flags]` (CLI from `@shipworthy/openapi-cli`, runs on Node.js `>=22.19.0` or Bun). It writes `<dir>/zod-schema.ts` and `<dir>/types.ts` on every run. Combine it with other plugins in one run, e.g. `openapi generate zod tanstack-query -i <input> -o <dir>`.
334
361
  - 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
362
  - Each export is `z.compile(z.fromJSONSchema(...)) as z.ZodType<T>`, where `T` comes from `./types`. Use `.parse` / `.safeParse`.
336
363
  - `default` values are stripped. Parsing never injects defaults.
337
- - Runtime dependency of generated code: `zod@4.6.5` (pinned).
364
+ - Runtime dependency of generated code: `zod@4.6.7` (pinned).
338
365
  - 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`.
366
+ - Library option `camelCase: true` (on `zod.generate` and `zodSources`) renames snake_case properties so validators match a camelCase `types.ts`. The CLI flag `--camel-case` sets it and generates the matching `types.ts`. Enum/const values and `patternProperties` keys keep API spelling. Name collisions fail generation.
367
+ - Generation is all-or-nothing. Unsupported keywords fail with a JSON-pointer message, and the CLI then writes neither `zod-schema.ts` nor `types.ts`.
340
368
  - 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`.
369
+ - The package ships compiled ESM JavaScript with `.d.ts` declarations (no CommonJS build). Peer dependency `zod@4.6.7`.
342
370
 
343
371
  ## License
344
372
 
package/dist/errors.d.ts CHANGED
@@ -2,7 +2,9 @@ import { Schema } from "effect";
2
2
  declare const ZodGenerationError_base: Schema.Class<ZodGenerationError, Schema.TaggedStruct<"ZodGenerationError", {
3
3
  readonly message: Schema.String;
4
4
  readonly cause: Schema.optionalKey<Schema.Defect>;
5
- }>, import("effect/Cause").YieldableError>;
5
+ }>, import("effect/Cause").YieldableError & {
6
+ readonly brand: unique symbol;
7
+ }>;
6
8
  export declare class ZodGenerationError extends ZodGenerationError_base {
7
9
  }
8
10
  export {};
@@ -18,5 +18,11 @@ export type ZodSource = string;
18
18
  *
19
19
  * `routes` selects which operations can produce a request-body validator.
20
20
  * `operationRoutes` builds that table from the document.
21
+ * Pass `outputPath` to validate with the Zod installation visible to the generated file.
22
+ * Pass `camelCase: true` to rename snake_case properties to camelCase, matching `types.ts`
23
+ * generated with `camelCase`. Two wire names that become the same name fail generation.
21
24
  */
22
- export declare const zodSources: (document: ParsedService, routes: Record<string, OperationRoute>) => Effect.Effect<string, ZodGenerationError, never>;
25
+ export declare const zodSources: (document: ParsedService, routes: Record<string, OperationRoute>, options?: {
26
+ readonly outputPath?: string;
27
+ readonly camelCase?: boolean;
28
+ } | undefined) => Effect.Effect<string, ZodGenerationError, never>;
package/dist/generate.js CHANGED
@@ -1,9 +1,9 @@
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";
1
+ import { camelCaseSchema, ParsedDiscriminator, ParsedSchema, propertyName, schemaPropertyCollision, } from "@shipworthy/openapi-transformer";
2
+ import { Array, Effect, Formatter, Option, Order, Predicate, Record, Result, Schema, String, Struct, pipe, } from "effect";
4
3
  import { ZodGenerationError } from "./errors.js";
5
4
  import { assertExportName } from "./names.js";
6
5
  import { resolveDocumentRef } from "./refs.js";
6
+ import { loadZod } from "./zod.js";
7
7
  const schemaMaps = ["properties", "patternProperties"];
8
8
  const compositions = ["allOf", "anyOf", "oneOf"];
9
9
  const schemaFields = ["items", "additionalProperties", "propertyNames", "contains"];
@@ -20,7 +20,7 @@ const annotations = new Set([
20
20
  "externalDocs",
21
21
  "xml",
22
22
  ]);
23
- const untypedKeywords = new Set(["enum", "const", "allOf", "anyOf", "oneOf"]);
23
+ const untypedKeywords = new Set(["enum", "const", "allOf", "anyOf", "oneOf", "discriminator"]);
24
24
  const keywords = new Set([
25
25
  "$ref",
26
26
  "type",
@@ -32,6 +32,7 @@ const keywords = new Set([
32
32
  "not",
33
33
  "required",
34
34
  "dependentRequired",
35
+ "discriminator",
35
36
  "minProperties",
36
37
  "maxProperties",
37
38
  "prefixItems",
@@ -50,8 +51,145 @@ const keywords = new Set([
50
51
  "exclusiveMaximum",
51
52
  "multipleOf",
52
53
  ]);
54
+ const discriminatorOf = Effect.fnUntraced(function* (value, path) {
55
+ if (value === undefined)
56
+ return undefined;
57
+ return yield* Schema.decodeUnknownEffect(ParsedDiscriminator)(value).pipe(Effect.mapError(() => new ZodGenerationError({ message: `${path}: Unsupported discriminator` })));
58
+ });
59
+ function schemaName(ref) {
60
+ const prefix = "#/components/schemas/";
61
+ if (!pipe(ref, String.startsWith(prefix)))
62
+ return undefined;
63
+ const token = pipe(ref, String.slice(prefix.length));
64
+ if (!token || pipe(token, String.includes("/")))
65
+ return undefined;
66
+ return pipe(token, String.replaceAll("~1", "/"), String.replaceAll("~0", "~"));
67
+ }
68
+ function componentRef(name) {
69
+ return `#/components/schemas/${pipe(name, String.replaceAll("~", "~0"), String.replaceAll("/", "~1"))}`;
70
+ }
71
+ function discriminatorValues(discriminator, ref) {
72
+ const name = schemaName(ref);
73
+ if (name === undefined)
74
+ return [];
75
+ const mapped = pipe(Record.toEntries(discriminator.mapping ?? {}), Array.filter(([, target]) => target === ref || target === name), Array.map(([value]) => value));
76
+ if (mapped.length > 0)
77
+ return mapped;
78
+ return Record.has(discriminator.mapping ?? {}, name) ? [] : [name];
79
+ }
80
+ const branchValues = Effect.fnUntraced(function* (discriminator, ref, path) {
81
+ const values = discriminatorValues(discriminator, ref);
82
+ if (values.length === 0)
83
+ return yield* new ZodGenerationError({
84
+ message: `${path}: Discriminator branch has no unique value`,
85
+ });
86
+ return values;
87
+ });
88
+ const discriminatorDiagnostics = Effect.fnUntraced(function* (schema, path) {
89
+ if (typeof schema === "boolean")
90
+ return;
91
+ const discriminator = yield* discriminatorOf(schema.discriminator, path);
92
+ if (!discriminator)
93
+ return;
94
+ const key = schema.oneOf !== undefined && schema.anyOf === undefined
95
+ ? "oneOf"
96
+ : schema.anyOf !== undefined && schema.oneOf === undefined
97
+ ? "anyOf"
98
+ : undefined;
99
+ if (!key)
100
+ return;
101
+ const parts = yield* schemaList(schema[key], path, key);
102
+ const branchIndices = yield* Effect.forEach(parts, Effect.fnUntraced(function* (part, index) {
103
+ const ref = Predicate.isObject(part) ? part.$ref : undefined;
104
+ const branchPath = `${path}/${key}/${index}`;
105
+ if (!Predicate.isString(ref) || schemaName(ref) === undefined)
106
+ return yield* new ZodGenerationError({
107
+ message: `${branchPath}: Discriminator branch requires a component schema reference`,
108
+ });
109
+ const values = yield* branchValues(discriminator, ref, branchPath);
110
+ return Array.map(values, (value) => [value, index]);
111
+ }));
112
+ return {
113
+ propertyName: discriminator.propertyName,
114
+ branchCount: parts.length,
115
+ branchIndices: Array.flatten(branchIndices),
116
+ };
117
+ });
118
+ const addDiscriminatorConstraint = Effect.fnUntraced(function* (constraints, ref, propertyName, values) {
119
+ yield* Effect.sync(() => {
120
+ const existing = constraints.get(ref);
121
+ if (existing === null)
122
+ return;
123
+ if (existing && existing.propertyName !== propertyName) {
124
+ constraints.set(ref, null);
125
+ return;
126
+ }
127
+ constraints.set(ref, {
128
+ propertyName,
129
+ values: existing ? Array.dedupe([...existing.values, ...values]) : values,
130
+ });
131
+ });
132
+ });
133
+ function discriminatorValue(values) {
134
+ return { type: "string", enum: [...values] };
135
+ }
136
+ function discriminatorConstraint(propertyName, values) {
137
+ return {
138
+ type: "object",
139
+ required: [propertyName],
140
+ properties: { [propertyName]: discriminatorValue(values) },
141
+ };
142
+ }
143
+ const discriminatorBranch = Effect.fnUntraced(function* (part, discriminator, path) {
144
+ const ref = Predicate.isObject(part) ? part.$ref : undefined;
145
+ if (!Predicate.isString(ref) || schemaName(ref) === undefined)
146
+ return yield* new ZodGenerationError({
147
+ message: `${path}: Discriminator branch requires a component schema reference`,
148
+ });
149
+ const values = yield* branchValues(discriminator, ref, path);
150
+ return {
151
+ allOf: [part, discriminatorConstraint(discriminator.propertyName, values)],
152
+ };
153
+ });
154
+ const constrainComponent = Effect.fnUntraced(function* (schema, constraint, path) {
155
+ if (!constraint)
156
+ return schema;
157
+ if (typeof schema === "boolean")
158
+ return yield* new ZodGenerationError({ message: `${path}: Unsupported discriminator target` });
159
+ if (schema.allOf) {
160
+ return {
161
+ ...schema,
162
+ allOf: [
163
+ ...(yield* schemaList(schema.allOf, path, "allOf")),
164
+ discriminatorConstraint(constraint.propertyName, constraint.values),
165
+ ],
166
+ };
167
+ }
168
+ if (schema.type !== "object")
169
+ return yield* new ZodGenerationError({ message: `${path}: Unsupported discriminator target` });
170
+ const properties = schema.properties
171
+ ? yield* schemaMap(schema.properties, path, "properties")
172
+ : {};
173
+ if (properties[constraint.propertyName] !== undefined) {
174
+ return {
175
+ ...schema,
176
+ allOf: [discriminatorConstraint(constraint.propertyName, constraint.values)],
177
+ };
178
+ }
179
+ const required = schema.required
180
+ ? yield* Schema.decodeUnknownEffect(Schema.Array(Schema.String))(schema.required).pipe(Effect.mapError(() => new ZodGenerationError({ message: `${path}: Unsupported required constraint` })))
181
+ : [];
182
+ return {
183
+ ...schema,
184
+ required: Array.dedupe([...required, constraint.propertyName]),
185
+ properties: {
186
+ ...properties,
187
+ [constraint.propertyName]: discriminatorValue(constraint.values),
188
+ },
189
+ };
190
+ });
53
191
  function isAnnotation(key) {
54
- return annotations.has(key) || key.startsWith("x-");
192
+ return annotations.has(key) || pipe(key, String.startsWith("x-"));
55
193
  }
56
194
  const encodeJson = Effect.fnUntraced(function* (value) {
57
195
  return yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(value).pipe(Effect.mapError((cause) => new ZodGenerationError({ message: cause.message, cause })));
@@ -65,6 +203,133 @@ const schemaMap = Effect.fnUntraced(function* (value, path, keyword) {
65
203
  const schemaList = Effect.fnUntraced(function* (value, path, keyword) {
66
204
  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
205
  });
206
+ const scanDiscriminatorSchema = Effect.fnUntraced(function* (schema, path, constraints, strict = true) {
207
+ if (typeof schema === "boolean")
208
+ return;
209
+ const discriminator = strict
210
+ ? yield* discriminatorOf(schema.discriminator, path)
211
+ : yield* discriminatorOf(schema.discriminator, path).pipe(Effect.orElseSucceed(() => undefined));
212
+ if (discriminator) {
213
+ yield* Effect.forEach(["oneOf", "anyOf"], Effect.fnUntraced(function* (key) {
214
+ if (!schema[key])
215
+ return;
216
+ const parts = strict
217
+ ? yield* schemaList(schema[key], path, key)
218
+ : yield* schemaList(schema[key], path, key).pipe(Effect.orElseSucceed(() => []));
219
+ yield* Effect.forEach(parts, Effect.fnUntraced(function* (part, index) {
220
+ const ref = Predicate.isObject(part) ? part.$ref : undefined;
221
+ const branchPath = `${path}/${key}/${index}`;
222
+ if (!Predicate.isString(ref) || schemaName(ref) === undefined) {
223
+ if (strict)
224
+ return yield* new ZodGenerationError({
225
+ message: `${branchPath}: Discriminator branch requires a component schema reference`,
226
+ });
227
+ return;
228
+ }
229
+ const values = strict
230
+ ? yield* branchValues(discriminator, ref, branchPath)
231
+ : discriminatorValues(discriminator, ref);
232
+ if (values.length === 0)
233
+ return;
234
+ yield* addDiscriminatorConstraint(constraints, ref, discriminator.propertyName, values);
235
+ }), { discard: true });
236
+ }), { discard: true });
237
+ if (schema.oneOf || schema.anyOf) {
238
+ yield* Effect.forEach(Record.values(discriminator.mapping ?? {}), Effect.fnUntraced(function* (target) {
239
+ const ref = pipe(target, String.startsWith("#")) ? target : componentRef(target);
240
+ if (schemaName(ref) === undefined)
241
+ return;
242
+ yield* addDiscriminatorConstraint(constraints, ref, discriminator.propertyName, discriminatorValues(discriminator, ref));
243
+ }), { discard: true });
244
+ }
245
+ }
246
+ yield* Effect.forEach(schemaMaps, Effect.fnUntraced(function* (key) {
247
+ if (!schema[key])
248
+ return;
249
+ const values = strict
250
+ ? yield* schemaMap(schema[key], path, key)
251
+ : yield* schemaMap(schema[key], path, key).pipe(Effect.orElseSucceed(() => ({})));
252
+ yield* Effect.forEach(Record.toEntries(values), ([name, part]) => scanDiscriminatorSchema(part, `${path}/${key}/${name}`, constraints, strict), { discard: true });
253
+ }), { discard: true });
254
+ yield* Effect.forEach([...compositions, "prefixItems"], Effect.fnUntraced(function* (key) {
255
+ if (!schema[key])
256
+ return;
257
+ const parts = strict
258
+ ? yield* schemaList(schema[key], path, key)
259
+ : yield* schemaList(schema[key], path, key).pipe(Effect.orElseSucceed(() => []));
260
+ yield* Effect.forEach(parts, (part, index) => scanDiscriminatorSchema(part, `${path}/${key}/${index}`, constraints, strict), { discard: true });
261
+ }), { discard: true });
262
+ yield* Effect.forEach([...schemaFields, "not"], Effect.fnUntraced(function* (key) {
263
+ if (schema[key] === undefined)
264
+ return;
265
+ if (Array.isArray(schema[key]))
266
+ return;
267
+ const part = strict
268
+ ? yield* asSchema(schema[key], `${path}/${key}`)
269
+ : yield* asSchema(schema[key], `${path}/${key}`).pipe(Effect.orElseSucceed(() => undefined));
270
+ if (part === undefined)
271
+ return;
272
+ yield* scanDiscriminatorSchema(part, `${path}/${key}`, constraints, strict);
273
+ }), { discard: true });
274
+ });
275
+ const scanContentSchemas = Effect.fnUntraced(function* (contents, path, constraints, strict = false) {
276
+ yield* Effect.forEach(contents, (content) => content.schema === undefined
277
+ ? Effect.void
278
+ : scanDiscriminatorSchema(content.schema, `${path}/${content.mediaType}`, constraints, strict), { discard: true });
279
+ });
280
+ const scanHeaderSchemas = Effect.fnUntraced(function* (header, path, constraints) {
281
+ if (header.schema !== undefined)
282
+ yield* scanDiscriminatorSchema(header.schema, `${path}/schema`, constraints, false);
283
+ yield* scanContentSchemas(header.contents, `${path}/content`, constraints);
284
+ });
285
+ const scanDiscriminatorConstraints = Effect.fnUntraced(function* (model) {
286
+ const constraints = new Map();
287
+ yield* Effect.forEach(Record.toEntries(model.components.schemas), ([name, schema]) => scanDiscriminatorSchema(schema, componentRef(name), constraints), { discard: true });
288
+ yield* Effect.forEach(model.operations, Effect.fnUntraced(function* (operation) {
289
+ const location = `${operation.method} ${operation.path}`;
290
+ yield* Effect.forEach([
291
+ ...operation.request.path,
292
+ ...operation.request.query,
293
+ ...operation.request.headers,
294
+ ...operation.request.cookie,
295
+ ], (parameter) => scanHeaderSchemas(parameter, `${location}/parameters/${parameter.name}`, constraints), { discard: true });
296
+ if (operation.request.body)
297
+ yield* scanContentSchemas(operation.request.body.contents, `${location}/requestBody`, constraints, true);
298
+ yield* Effect.forEach(operation.responses, Effect.fnUntraced(function* (response) {
299
+ const responsePath = `${location}/responses/${response.code}`;
300
+ yield* scanContentSchemas(response.contents, `${responsePath}/content`, constraints);
301
+ yield* Effect.forEach(response.headers, (header) => scanHeaderSchemas(header, `${responsePath}/headers/${header.name}`, constraints), { discard: true });
302
+ }), { discard: true });
303
+ }), { discard: true });
304
+ yield* Effect.forEach(Record.toEntries(model.components.requestBodies), ([name, body]) => scanContentSchemas(body.contents, `#/components/requestBodies/${name}`, constraints), { discard: true });
305
+ yield* Effect.forEach(Record.toEntries(model.components.responses), Effect.fnUntraced(function* ([name, response]) {
306
+ const responsePath = `#/components/responses/${name}`;
307
+ yield* scanContentSchemas(response.contents, `${responsePath}/content`, constraints);
308
+ yield* Effect.forEach(response.headers, (header) => scanHeaderSchemas(header, `${responsePath}/headers/${header.name}`, constraints), { discard: true });
309
+ }), { discard: true });
310
+ yield* Effect.forEach(Record.toEntries(model.components.parameters), ([name, parameter]) => scanHeaderSchemas(parameter, `#/components/parameters/${name}`, constraints), { discard: true });
311
+ yield* Effect.forEach(Record.toEntries(model.components.headers), ([name, header]) => scanHeaderSchemas(header, `#/components/headers/${name}`, constraints), { discard: true });
312
+ yield* Effect.forEach(Record.toEntries(model.components.schemas), Effect.fnUntraced(function* ([name, schema]) {
313
+ if (typeof schema === "boolean" || !schema.allOf)
314
+ return;
315
+ const path = componentRef(name);
316
+ const parts = yield* schemaList(schema.allOf, path, "allOf");
317
+ yield* Effect.forEach(parts, Effect.fnUntraced(function* (part) {
318
+ const ref = Predicate.isObject(part) ? part.$ref : undefined;
319
+ if (!Predicate.isString(ref) || schemaName(ref) === undefined)
320
+ return;
321
+ const parent = yield* resolveDocumentRef(model, ref).pipe(Effect.flatMap((value) => asSchema(value, ref)));
322
+ const discriminator = typeof parent === "boolean"
323
+ ? undefined
324
+ : yield* discriminatorOf(parent.discriminator, ref);
325
+ if (!discriminator)
326
+ return;
327
+ const values = yield* branchValues(discriminator, path, path);
328
+ yield* addDiscriminatorConstraint(constraints, path, discriminator.propertyName, values);
329
+ }), { discard: true });
330
+ }), { discard: true });
331
+ return constraints;
332
+ });
68
333
  function definitionSchema(schema) {
69
334
  if (schema === true)
70
335
  return {};
@@ -80,25 +345,25 @@ function isSingleRequiredKey(part) {
80
345
  Predicate.isString(part.required[0]));
81
346
  }
82
347
  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"));
348
+ const pad = pipe(" ", String.repeat(spaces));
349
+ return pipe(value, String.split("\n"), Array.map((line) => (line.length === 0 ? line : pad + line)), Array.join("\n"));
85
350
  }
86
351
  function schemaLiteral(schema, names, expression) {
87
352
  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);
353
+ return expression ?? Formatter.formatJson(schema, { space: 2 });
354
+ const defs = indent(pipe(names, Array.map((name) => `${Formatter.formatJson(name)}: definitions.${name}`), Array.join(",\n")), 4);
90
355
  if (expression)
91
356
  return `{\n ...${expression},\n $defs: {\n${defs}\n },\n}`;
92
- const json = JSON.stringify(schema, null, 2);
93
- if (!json.startsWith("{\n"))
357
+ const json = Formatter.formatJson(schema, { space: 2 });
358
+ if (!pipe(json, String.startsWith("{\n")))
94
359
  return `{\n $defs: {\n${defs}\n },\n}`;
95
- return `{\n${json.slice(2, -2)},\n $defs: {\n${defs}\n },\n}`;
360
+ return `{\n${pipe(json, String.slice(2, -2))},\n $defs: {\n${defs}\n },\n}`;
96
361
  }
97
362
  const definitionName = Effect.fnUntraced(function* (ref) {
98
363
  const marker = "#/$defs/";
99
- if (!ref.startsWith(marker))
364
+ if (!pipe(ref, String.startsWith(marker)))
100
365
  return yield* new ZodGenerationError({ message: `${ref}: Reference not found` });
101
- return ref.slice(marker.length).replaceAll("~1", "/").replaceAll("~0", "~");
366
+ return pipe(ref, String.slice(marker.length), String.replaceAll("~1", "/"), String.replaceAll("~0", "~"));
102
367
  });
103
368
  const objectProperties = Effect.fnUntraced(function* (document, schema, path, seen) {
104
369
  if (typeof schema === "boolean")
@@ -131,12 +396,23 @@ const normalize = Effect.fnUntraced(function* (document, schema, path, inherited
131
396
  });
132
397
  const result = { ...schema };
133
398
  delete result.default;
399
+ delete result.discriminator;
400
+ const discriminator = yield* discriminatorOf(schema.discriminator, path);
401
+ if (discriminator) {
402
+ yield* Effect.forEach(["oneOf", "anyOf"], Effect.fnUntraced(function* (key) {
403
+ if (!result[key])
404
+ return;
405
+ const parts = yield* schemaList(result[key], path, key);
406
+ result[key] = yield* Effect.forEach(parts, (part, index) => discriminatorBranch(part, discriminator, `${path}/${key}/${index}`));
407
+ }), { discard: true });
408
+ }
134
409
  const properties = {
135
410
  ...inherited,
136
411
  ...(yield* objectProperties(document, schema, path, new Set())),
137
412
  };
138
413
  if (schema.$ref) {
139
- if (!Predicate.isString(schema.$ref) || !schema.$ref.startsWith("#/components/schemas/")) {
414
+ if (!Predicate.isString(schema.$ref) ||
415
+ !pipe(schema.$ref, String.startsWith("#/components/schemas/"))) {
140
416
  const ref = Predicate.isString(schema.$ref) ? schema.$ref : yield* encodeJson(schema.$ref);
141
417
  return yield* new ZodGenerationError({
142
418
  message: `${path}: Unsupported schema reference ${ref}`,
@@ -148,7 +424,7 @@ const normalize = Effect.fnUntraced(function* (document, schema, path, inherited
148
424
  return yield* new ZodGenerationError({
149
425
  message: `${path}: Zod does not preserve ${sibling.value} alongside $ref`,
150
426
  });
151
- result.$ref = `#/$defs/${schema.$ref.slice("#/components/schemas/".length)}`;
427
+ result.$ref = `#/$defs/${pipe(schema.$ref, String.slice("#/components/schemas/".length))}`;
152
428
  return result;
153
429
  }
154
430
  // In an object intersection, not(anyOf(required(single key))) forbids those keys.
@@ -275,17 +551,50 @@ const visit = Effect.fnUntraced(function* (schema, needed, definitions) {
275
551
  }
276
552
  }), { discard: true });
277
553
  });
278
- const compileExport = Effect.fnUntraced(function* (name, root) {
554
+ const compileExport = Effect.fnUntraced(function* (z, name, root) {
279
555
  return yield* Effect.try({
280
556
  try: () => {
281
557
  z.compile(z.fromJSONSchema(root));
282
558
  },
283
559
  catch: (cause) => new ZodGenerationError({
284
- message: `${name}: ${cause instanceof Error ? cause.message : String(cause)}`,
560
+ message: `${name}: ${cause instanceof Error ? cause.message : globalThis.String(cause)}`,
285
561
  cause,
286
562
  }),
287
563
  });
288
564
  });
565
+ const discriminatorIssueHelper = `function __openapiDiscriminatorIssues(
566
+ schema: z.ZodType,
567
+ propertyName: string,
568
+ branchCount: number,
569
+ branchIndices: ReadonlyMap<string, number>,
570
+ ) {
571
+ return z.unknown().transform((input, context) => {
572
+ const parsed = schema.safeParse(input);
573
+ if (parsed.success) return parsed.data;
574
+
575
+ const tag: unknown =
576
+ typeof input === "object" && input !== null ? Reflect.get(input, propertyName) : undefined;
577
+ const index = typeof tag === "string" ? branchIndices.get(tag) : undefined;
578
+
579
+ for (const issue of parsed.error.issues) {
580
+ const selected =
581
+ index !== undefined &&
582
+ issue.code === "invalid_union" &&
583
+ issue.path.length === 0 &&
584
+ issue.errors.length === branchCount
585
+ ? issue.errors[index]
586
+ : undefined;
587
+
588
+ if (selected && selected.length > 0) {
589
+ for (const detail of selected) context.addIssue({ ...detail });
590
+ } else {
591
+ context.addIssue({ ...issue });
592
+ }
593
+ }
594
+
595
+ return z.NEVER;
596
+ });
597
+ }`;
289
598
  function renderSource(entries, definitions, exports) {
290
599
  if (exports.length === 0)
291
600
  return "";
@@ -297,21 +606,31 @@ function renderSource(entries, definitions, exports) {
297
606
  ], "\n"),
298
607
  ];
299
608
  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"));
609
+ const keys = pipe(Array.fromIterable(definitions.keys()), Array.map((name) => Formatter.formatJson(name)), Array.join(" | "));
610
+ const values = pipe(Array.fromIterable(definitions), Array.map(([name, schema]) => `${Formatter.formatJson(name)}: ${Formatter.formatJson(schema, { space: 2 })}`), Array.join(",\n"));
302
611
  blocks.push(`const definitions: Record<${keys}, z.core.JSONSchema.JSONSchema> = {\n${indent(values, 2)},\n};`);
303
612
  }
613
+ if (Array.some(entries, (entry) => entry.discriminatorDiagnostics !== undefined))
614
+ blocks.push(discriminatorIssueHelper);
304
615
  return `${Array.join([...blocks, ...exports], "\n\n")}\n`;
305
616
  }
306
- const source = Effect.fnUntraced(function* (entries, definitions) {
617
+ const source = Effect.fnUntraced(function* (entries, definitions, outputPath) {
307
618
  const seen = new Set();
619
+ const hasDiscriminatorDiagnostics = Array.some(entries, (entry) => entry.discriminatorDiagnostics !== undefined);
308
620
  yield* Effect.forEach(entries, Effect.fnUntraced(function* ({ name }) {
309
- yield* assertExportName(name, ["z", "definitions"]);
621
+ yield* assertExportName(name, [
622
+ "z",
623
+ "definitions",
624
+ ...(hasDiscriminatorDiagnostics ? ["__openapiDiscriminatorIssues"] : []),
625
+ ]);
310
626
  if (seen.has(name))
311
627
  return yield* new ZodGenerationError({ message: `${name}: Duplicate schema export` });
312
628
  seen.add(name);
313
629
  }), { discard: true });
314
- return yield* Effect.forEach(entries, Effect.fnUntraced(function* ({ name, schema, type, expression }) {
630
+ if (entries.length === 0)
631
+ return "";
632
+ const z = yield* loadZod(outputPath);
633
+ return yield* Effect.forEach(entries, Effect.fnUntraced(function* ({ name, schema, type, expression, discriminatorDiagnostics }) {
315
634
  const needed = new Set();
316
635
  yield* visit(schema, needed, definitions);
317
636
  const names = Array.sort(needed, Order.String);
@@ -326,30 +645,48 @@ const source = Effect.fnUntraced(function* (entries, definitions) {
326
645
  const root = typeof schema === "boolean"
327
646
  ? schema
328
647
  : { ...schema, ...(names.length > 0 ? { $defs: defs } : {}) };
329
- yield* compileExport(name, root);
648
+ yield* compileExport(z, name, root);
330
649
  const argument = schemaLiteral(schema, names, expression);
331
- return `export const ${name} = z.compile(z.fromJSONSchema(${argument})) as z.ZodType<${type}>;`;
650
+ const compiled = `z.compile(z.fromJSONSchema(${argument}))`;
651
+ let validator = compiled;
652
+ if (discriminatorDiagnostics) {
653
+ const { propertyName, branchIndices } = yield* Effect.all({
654
+ propertyName: encodeJson(discriminatorDiagnostics.propertyName),
655
+ branchIndices: encodeJson(discriminatorDiagnostics.branchIndices),
656
+ });
657
+ validator = `__openapiDiscriminatorIssues(${compiled}, ${propertyName}, ${discriminatorDiagnostics.branchCount}, new Map<string, number>(${branchIndices}))`;
658
+ }
659
+ return `export const ${name} = ${validator} as z.ZodType<${type}>;`;
332
660
  })).pipe(Effect.map((exports) => renderSource(entries, definitions, exports)));
333
661
  });
334
- const compile = Effect.fnUntraced(function* (model, routes) {
335
- if (!model.openapi.startsWith("3.1."))
662
+ const compile = Effect.fnUntraced(function* (model, routes, outputPath) {
663
+ if (!pipe(model.openapi, String.startsWith("3.1.")))
336
664
  return yield* new ZodGenerationError({ message: "Zod generation requires OpenAPI 3.1" });
337
665
  if (model.jsonSchemaDialect || Record.keys(model.$defs).length > 0) {
338
666
  return yield* new ZodGenerationError({
339
667
  message: "Zod generation does not support custom dialects or top-level $defs",
340
668
  });
341
669
  }
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]) => ({
670
+ const constraints = yield* scanDiscriminatorConstraints(model);
671
+ const prepared = yield* Effect.forEach(Record.toEntries(model.components.schemas), Effect.fnUntraced(function* ([name, schema]) {
672
+ const path = componentRef(name);
673
+ const constrained = yield* constrainComponent(schema, constraints.get(path), path);
674
+ const normalized = yield* normalize(model, constrained, path);
675
+ const diagnostics = yield* discriminatorDiagnostics(schema, path);
676
+ return [name, normalized, diagnostics];
677
+ }));
678
+ const definitions = new Map(Array.map(prepared, ([name, schema]) => [name, schema]));
679
+ const components = Array.map(prepared, ([name, schema, diagnostics]) => ({
344
680
  name,
345
681
  schema,
346
682
  types: "components",
347
- type: `components["schemas"][${JSON.stringify(name)}]`,
683
+ type: `components["schemas"][${Formatter.formatJson(name)}]`,
348
684
  expression: `definitions.${name}`,
685
+ discriminatorDiagnostics: diagnostics,
349
686
  }));
350
687
  const operations = yield* Effect.forEach(model.operations, Effect.fnUntraced(function* (operation) {
351
688
  const id = operation.operationId;
352
- if (!id || !Object.hasOwn(routes, id))
689
+ if (!id || !Record.has(routes, id))
353
690
  return;
354
691
  const location = `${operation.method} ${operation.path}`;
355
692
  const body = operation.request.body;
@@ -371,10 +708,92 @@ const compile = Effect.fnUntraced(function* (model, routes) {
371
708
  types: "operations",
372
709
  type: `NonNullable<operations[${idLiteral}]["requestBody"]>["content"]["application/json"]`,
373
710
  schema: yield* normalize(model, content.value.schema, location),
711
+ discriminatorDiagnostics: yield* discriminatorDiagnostics(content.value.schema, location),
374
712
  };
375
713
  })).pipe(Effect.map(Array.filter(Predicate.isNotUndefined)));
376
- return yield* source([...components, ...operations], definitions);
714
+ return yield* source([...components, ...operations], definitions, outputPath);
377
715
  });
716
+ /** The JSON request-body schema an operation exports as `<operationId>RequestBody`, if any. */
717
+ function exportedRequestBody(operation, routes) {
718
+ const id = operation.operationId;
719
+ if (!id || !Record.has(routes, id) || !operation.request.body)
720
+ return undefined;
721
+ const contents = operation.request.body.contents;
722
+ const content = contents.length === 1 ? contents[0] : undefined;
723
+ return content?.mediaType === "application/json" ? content.schema : undefined;
724
+ }
725
+ /** Fail when two wire names in one exported schema become the same camelCase name. */
726
+ const assertNoCamelCaseCollisions = Effect.fnUntraced(function* (model, routes) {
727
+ const resolve = (ref) => {
728
+ const name = schemaName(ref);
729
+ return name !== undefined && Record.has(model.components.schemas, name)
730
+ ? model.components.schemas[name]
731
+ : undefined;
732
+ };
733
+ const roots = [
734
+ ...Array.map(Record.toEntries(model.components.schemas), ([name, schema]) => [componentRef(name), schema]),
735
+ ...Array.filterMap(model.operations, (operation) => {
736
+ const schema = exportedRequestBody(operation, routes);
737
+ return schema === undefined
738
+ ? Result.failVoid
739
+ : Result.succeed([`${operation.method} ${operation.path}`, schema]);
740
+ }),
741
+ ];
742
+ yield* Effect.forEach(roots, Effect.fnUntraced(function* ([path, schema]) {
743
+ const collision = schemaPropertyCollision(schema, resolve);
744
+ if (!collision)
745
+ return;
746
+ const [a, b] = collision;
747
+ return yield* new ZodGenerationError({
748
+ message: `${path}: ${a} and ${b} both become ${propertyName(a, true)}`,
749
+ });
750
+ }), { discard: true });
751
+ });
752
+ const withCamelCaseSchema = {
753
+ schema: (schema) => schema === undefined ? undefined : camelCaseSchema(schema),
754
+ };
755
+ const withCamelCaseContents = {
756
+ contents: Array.map((content) => Struct.evolve(content, withCamelCaseSchema)),
757
+ };
758
+ const withCamelCaseBody = {
759
+ contents: Array.map((content) => Struct.evolve(content, withCamelCaseSchema)),
760
+ };
761
+ function camelCaseHeader(header) {
762
+ return Struct.evolve(header, { ...withCamelCaseSchema, ...withCamelCaseContents });
763
+ }
764
+ function camelCaseParameter(parameter) {
765
+ return Struct.evolve(parameter, { ...withCamelCaseSchema, ...withCamelCaseContents });
766
+ }
767
+ const withCamelCaseResponse = { ...withCamelCaseBody, headers: Array.map(camelCaseHeader) };
768
+ function camelCaseRequest(request) {
769
+ return Struct.evolve(request, {
770
+ path: Array.map(camelCaseParameter),
771
+ query: Array.map(camelCaseParameter),
772
+ headers: Array.map(camelCaseParameter),
773
+ cookie: Array.map(camelCaseParameter),
774
+ body: (body) => body === undefined ? undefined : Struct.evolve(body, withCamelCaseBody),
775
+ });
776
+ }
777
+ /**
778
+ * A copy of the model whose schemas use camelCase property names, so validators parse the
779
+ * shapes that camelCase `types.ts` declares. Every schema the generator reads is renamed,
780
+ * including the response and parameter schemas scanned for discriminator constraints.
781
+ */
782
+ function camelCaseModel(model) {
783
+ return Struct.evolve(model, {
784
+ operations: Array.map((operation) => Struct.evolve(operation, {
785
+ request: camelCaseRequest,
786
+ responses: Array.map((response) => Struct.evolve(response, withCamelCaseResponse)),
787
+ })),
788
+ components: (components) => Struct.evolve(components, {
789
+ schemas: Record.map(camelCaseSchema),
790
+ responses: Record.map((response) => Struct.evolve(response, withCamelCaseResponse)),
791
+ parameters: Record.map(camelCaseParameter),
792
+ headers: Record.map(camelCaseHeader),
793
+ requestBodies: Record.map((body) => Struct.evolve(body, withCamelCaseBody)),
794
+ }),
795
+ });
796
+ }
378
797
  /**
379
798
  * Turn an OpenAPI 3.1 document into Zod source.
380
799
  *
@@ -386,7 +805,13 @@ const compile = Effect.fnUntraced(function* (model, routes) {
386
805
  *
387
806
  * `routes` selects which operations can produce a request-body validator.
388
807
  * `operationRoutes` builds that table from the document.
808
+ * Pass `outputPath` to validate with the Zod installation visible to the generated file.
809
+ * Pass `camelCase: true` to rename snake_case properties to camelCase, matching `types.ts`
810
+ * generated with `camelCase`. Two wire names that become the same name fail generation.
389
811
  */
390
- export const zodSources = Effect.fn("zodSources")(function* (document, routes) {
391
- return yield* compile(document, routes);
812
+ export const zodSources = Effect.fn("zodSources")(function* (document, routes, options = {}) {
813
+ if (!options.camelCase)
814
+ return yield* compile(document, routes, options.outputPath);
815
+ yield* assertNoCamelCaseCollisions(document, routes);
816
+ return yield* compile(camelCaseModel(document), routes, options.outputPath);
392
817
  });
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
- import { Effect } from "effect";
1
+ import { Effect, Schema } from "effect";
2
+ import { ZodGenerationError } from "./errors.js";
2
3
  import { zodSources } from "./generate.js";
3
4
  import { operationRoutes } from "./routes.js";
4
5
  export { ZodGenerationError } from "./errors.js";
@@ -6,7 +7,11 @@ export { zodSources } from "./generate.js";
6
7
  export { operationRoutes } from "./routes.js";
7
8
  export const zod = {
8
9
  id: "zod",
9
- generate: Effect.fnUntraced(function* (model) {
10
- return yield* operationRoutes(model).pipe(Effect.flatMap((routes) => zodSources(model, routes)));
10
+ generate: Effect.fnUntraced(function* (model, options) {
11
+ const parsed = yield* Schema.decodeEffect(Schema.Struct({
12
+ outputPath: Schema.optionalKey(Schema.String),
13
+ camelCase: Schema.optionalKey(Schema.Boolean),
14
+ }))(options).pipe(Effect.mapError(() => new ZodGenerationError({ message: "#: Invalid generator options" })));
15
+ return yield* operationRoutes(model).pipe(Effect.flatMap((routes) => zodSources(model, routes, parsed)));
11
16
  }),
12
17
  };
package/dist/routes.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Effect } from "effect";
1
+ import { Effect, Record } from "effect";
2
2
  import { ZodGenerationError } from "./errors.js";
3
3
  const methods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
4
4
  const blockedOperationIds = new Set(["__proto__", "constructor", "prototype"]);
@@ -13,7 +13,7 @@ export const operationRoutes = Effect.fnUntraced(function* (model) {
13
13
  if (blockedOperationIds.has(id)) {
14
14
  return yield* new ZodGenerationError({ message: `Unsupported operationId: ${id}` });
15
15
  }
16
- if (Object.hasOwn(routes, id)) {
16
+ if (Record.has(routes, id)) {
17
17
  return yield* new ZodGenerationError({ message: `Duplicate operationId: ${id}` });
18
18
  }
19
19
  routes[id] = [operation.method, operation.path];
package/dist/zod.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { Effect } from "effect";
2
+ import { ZodGenerationError } from "./errors.ts";
3
+ /** Resolve the package visible to an ESM output file, then load its import entry. */
4
+ export declare const loadZod: (outputPath?: string | undefined) => Effect.Effect<typeof import("zod"), ZodGenerationError, never>;
package/dist/zod.js ADDED
@@ -0,0 +1,93 @@
1
+ import * as NodeModule from "node:module";
2
+ import { fileURLToPath, pathToFileURL } from "node:url";
3
+ import { Array, Effect, Option, Predicate, Schema, String, pipe } from "effect";
4
+ import { ZodGenerationError } from "./errors.js";
5
+ // The generated code targets the exact zod version in this package's peerDependencies.
6
+ const GeneratorPackageJson = Schema.Struct({
7
+ peerDependencies: Schema.Struct({ zod: Schema.String }),
8
+ });
9
+ const requiredZodVersion = Effect.try({
10
+ try: () => NodeModule.createRequire(import.meta.url)("../package.json"),
11
+ catch: (cause) => new ZodGenerationError({ message: "#: Cannot read the zod generator package.json", cause }),
12
+ }).pipe(Effect.flatMap(Schema.decodeUnknownEffect(GeneratorPackageJson)), Effect.mapError((cause) => new ZodGenerationError({ message: "#: Cannot read the required zod version", cause })), Effect.map((manifest) => manifest.peerDependencies.zod));
13
+ const PackageJson = Schema.Struct({
14
+ version: Schema.String,
15
+ exports: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
16
+ });
17
+ function isMissingPackage(cause) {
18
+ return (Predicate.isObject(cause) &&
19
+ (cause.code === "ERR_MODULE_NOT_FOUND" || cause.code === "MODULE_NOT_FOUND"));
20
+ }
21
+ const findZodPackage = Effect.fnUntraced(function* (from) {
22
+ // Only an installed package can be imported by the generated module. Bun's
23
+ // resolver may otherwise auto-install a version from its global cache.
24
+ const resolver = yield* Effect.try({
25
+ try: () => NodeModule.createRequire(from),
26
+ catch: (cause) => new ZodGenerationError({ message: `#: Cannot resolve zod from ${from}`, cause }),
27
+ });
28
+ const start = yield* Effect.try({
29
+ try: () => new URL(".", from),
30
+ catch: (cause) => new ZodGenerationError({ message: `#: Cannot resolve zod from ${from}`, cause }),
31
+ });
32
+ const candidates = Array.unfold(Option.some(start), (directory) => Option.map(directory, (url) => {
33
+ const parent = new URL("..", url);
34
+ return [
35
+ new URL("node_modules/zod/package.json", url),
36
+ parent.href === url.href ? Option.none() : Option.some(parent),
37
+ ];
38
+ }));
39
+ const found = yield* Effect.forEach(candidates, (candidate) => Effect.try({
40
+ try: () => resolver.resolve(fileURLToPath(candidate)),
41
+ catch: (cause) => new ZodGenerationError({ message: `#: Cannot resolve zod from ${from}`, cause }),
42
+ }).pipe(Effect.catchIf((error) => isMissingPackage(error.cause), () => Effect.void)));
43
+ return Option.getOrUndefined(Array.findFirst(found, Predicate.isString));
44
+ });
45
+ /** Resolve the package visible to an ESM output file, then load its import entry. */
46
+ export const loadZod = Effect.fnUntraced(function* (outputPath) {
47
+ const outputUrl = outputPath
48
+ ? yield* Effect.try({
49
+ try: () => pathToFileURL(outputPath).href,
50
+ catch: (cause) => new ZodGenerationError({ message: `#: Cannot resolve zod from ${outputPath}`, cause }),
51
+ })
52
+ : undefined;
53
+ const outputPackage = outputUrl ? yield* findZodPackage(outputUrl) : undefined;
54
+ const manifestPath = outputPackage ?? (yield* findZodPackage(import.meta.url));
55
+ if (!manifestPath)
56
+ return yield* new ZodGenerationError({
57
+ message: `#: Cannot resolve zod from ${outputPath ?? import.meta.url} or the generator`,
58
+ });
59
+ const rawManifest = yield* Effect.try({
60
+ try: () => NodeModule.createRequire(manifestPath)(manifestPath),
61
+ catch: (cause) => new ZodGenerationError({ message: `#: Cannot read zod package at ${manifestPath}`, cause }),
62
+ });
63
+ const manifest = yield* Schema.decodeUnknownEffect(PackageJson)(rawManifest).pipe(Effect.mapError((cause) => new ZodGenerationError({ message: `#: Cannot read zod version at ${manifestPath}`, cause })));
64
+ const requiredVersion = yield* requiredZodVersion;
65
+ if (manifest.version !== requiredVersion)
66
+ return yield* new ZodGenerationError({
67
+ message: `#: Found zod@${manifest.version} at ${manifestPath} (resolved from ${outputPackage ? outputPath : import.meta.url}); require zod@${requiredVersion}`,
68
+ });
69
+ const rootExport = manifest.exports?.["."];
70
+ const importEntry = Predicate.isObject(rootExport) ? rootExport.import : undefined;
71
+ if (!Predicate.isString(importEntry) || !pipe(importEntry, String.startsWith("./")))
72
+ return yield* new ZodGenerationError({
73
+ message: `#: Cannot resolve zod import entry at ${manifestPath}`,
74
+ });
75
+ const entry = yield* Effect.try({
76
+ try: () => fileURLToPath(new URL(importEntry, pathToFileURL(manifestPath))),
77
+ catch: (cause) => new ZodGenerationError({
78
+ message: `#: Cannot resolve zod import entry at ${manifestPath}`,
79
+ cause,
80
+ }),
81
+ });
82
+ const loaded = yield* Effect.try({
83
+ try: () => NodeModule.createRequire(manifestPath)(entry),
84
+ catch: (cause) => new ZodGenerationError({ message: `#: Cannot import zod at ${entry}`, cause }),
85
+ });
86
+ if (!Predicate.isObject(loaded) ||
87
+ !Predicate.isFunction(loaded.fromJSONSchema) ||
88
+ !Predicate.isFunction(loaded.compile))
89
+ return yield* new ZodGenerationError({
90
+ message: `#: zod@${manifest.version} at ${entry} does not expose fromJSONSchema and compile`,
91
+ });
92
+ return loaded;
93
+ });
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@shipworthy/openapi-zod",
3
- "version": "0.0.2",
3
+ "version": "1.0.0",
4
4
  "files": [
5
5
  "dist"
6
6
  ],
7
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"
8
+ "@shipworthy/openapi-generator": "1.0.0",
9
+ "@shipworthy/openapi-transformer": "1.0.0",
10
+ "@shipworthy/openapi-typescript": "1.0.0",
11
+ "effect": "4.1.4"
12
12
  },
13
13
  "peerDependencies": {
14
- "zod": "4.6.5"
14
+ "zod": "4.6.7"
15
15
  },
16
16
  "description": "Generate Zod validators from OpenAPI 3.1 schemas.",
17
17
  "license": "MIT",
@@ -29,16 +29,16 @@
29
29
  "./package.json": "./package.json"
30
30
  },
31
31
  "scripts": {
32
- "test": "bun test test/generate.test.ts test/cli.test.ts",
32
+ "test": "bun test --timeout 30000 test/generate.test.ts test/cli.test.ts",
33
33
  "build": "rm -rf dist && tsc -p tsconfig.build.json"
34
34
  },
35
35
  "devDependencies": {
36
- "@effect/platform-node": "^4.0.0",
36
+ "@effect/platform-node": "4.1.4",
37
37
  "@shipworthy/typescript-config": "0.0.1",
38
- "@types/node": "^24.10.3",
38
+ "@types/node": "^26.6.6",
39
39
  "typescript": "^7.0.2",
40
- "vitest": "5.0.3",
41
- "zod": "4.6.5"
40
+ "vitest": "5.1.0",
41
+ "zod": "4.6.7"
42
42
  },
43
43
  "engines": {
44
44
  "node": ">=22.19.0"