@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 +64 -36
- package/dist/errors.d.ts +3 -1
- package/dist/generate.d.ts +7 -1
- package/dist/generate.js +459 -34
- package/dist/index.js +8 -3
- package/dist/routes.js +2 -2
- package/dist/zod.d.ts +4 -0
- package/dist/zod.js +93 -0
- package/package.json +11 -11
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
|
|
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.
|
|
14
|
+
bun add zod@4.6.7
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Requirements:
|
|
18
18
|
|
|
19
|
-
- **Zod 4.6.
|
|
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.
|
|
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
|
|
82
|
+
bunx --bun @shipworthy/openapi-cli generate zod -i ./openapi.json -o ./src/generated/hotels
|
|
82
83
|
```
|
|
83
84
|
|
|
84
85
|
```text
|
|
85
|
-
openapi
|
|
86
|
-
file:///…/openapi.json →
|
|
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 [
|
|
107
|
+
openapi generate zod [-i <input>] -o <dir> [--camel-case] [--wire-types] [--check] [TypeScript flags]
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
|
|
|
110
|
+
| Flag | Type | Default | Description |
|
|
111
111
|
| ---------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
112
|
-
|
|
|
113
|
-
| `--output`, `-o` | string | required | Output
|
|
114
|
-
| `--
|
|
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. **
|
|
119
|
-
2. **Generates the Zod module.** It
|
|
120
|
-
3. **Writes the output.**
|
|
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.
|
|
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
|
|
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
|
|
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`
|
|
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.
|
|
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. `
|
|
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
|
|
325
|
-
- **`
|
|
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
|
-
- **
|
|
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
|
|
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.
|
|
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
|
-
-
|
|
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.
|
|
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 {};
|
package/dist/generate.d.ts
CHANGED
|
@@ -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
|
|
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 ??
|
|
89
|
-
const defs = indent(pipe(names, Array.map((name) => `${
|
|
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 =
|
|
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) ||
|
|
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) =>
|
|
301
|
-
const values = pipe(Array.fromIterable(definitions), Array.map(([name, schema]) => `${
|
|
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, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
343
|
-
const
|
|
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"][${
|
|
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 || !
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"files": [
|
|
5
5
|
"dist"
|
|
6
6
|
],
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@shipworthy/openapi-generator": "0.0
|
|
9
|
-
"@shipworthy/openapi-transformer": "0.0
|
|
10
|
-
"@shipworthy/openapi-typescript": "0.0
|
|
11
|
-
"effect": "
|
|
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.
|
|
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": "
|
|
36
|
+
"@effect/platform-node": "4.1.4",
|
|
37
37
|
"@shipworthy/typescript-config": "0.0.1",
|
|
38
|
-
"@types/node": "^
|
|
38
|
+
"@types/node": "^26.6.6",
|
|
39
39
|
"typescript": "^7.0.2",
|
|
40
|
-
"vitest": "5.0
|
|
41
|
-
"zod": "4.6.
|
|
40
|
+
"vitest": "5.1.0",
|
|
41
|
+
"zod": "4.6.7"
|
|
42
42
|
},
|
|
43
43
|
"engines": {
|
|
44
44
|
"node": ">=22.19.0"
|