diff --git a/.changeset/eight-phones-applaud.md b/.changeset/eight-phones-applaud.md new file mode 100644 index 000000000..b8efaf7ec --- /dev/null +++ b/.changeset/eight-phones-applaud.md @@ -0,0 +1,5 @@ +--- +'@hey-api/openapi-ts': patch +--- + +fix: zod: generate patterns and improve plain schemas diff --git a/packages/openapi-ts/src/compiler/index.ts b/packages/openapi-ts/src/compiler/index.ts index 845514bcf..ad3e8a898 100644 --- a/packages/openapi-ts/src/compiler/index.ts +++ b/packages/openapi-ts/src/compiler/index.ts @@ -49,6 +49,7 @@ export const compiler = { propertyAccessExpression: types.createPropertyAccessExpression, propertyAccessExpressions: transform.createPropertyAccessExpressions, propertyAssignment: types.createPropertyAssignment, + regularExpressionLiteral: types.createRegularExpressionLiteral, returnFunctionCall: _return.createReturnFunctionCall, returnStatement: _return.createReturnStatement, returnVariable: _return.createReturnVariable, diff --git a/packages/openapi-ts/src/compiler/types.ts b/packages/openapi-ts/src/compiler/types.ts index e84c6c453..daee7d88f 100644 --- a/packages/openapi-ts/src/compiler/types.ts +++ b/packages/openapi-ts/src/compiler/types.ts @@ -894,3 +894,11 @@ export const createPropertyAssignment = ({ initializer: ts.Expression; name: string | ts.PropertyName; }) => ts.factory.createPropertyAssignment(name, initializer); + +export const createRegularExpressionLiteral = ({ + flags = [], + text, +}: { + flags?: ReadonlyArray<'g' | 'i' | 'm' | 's' | 'u' | 'y'>; + text: string; +}) => ts.factory.createRegularExpressionLiteral(`/${text}/${flags.join('')}`); diff --git a/packages/openapi-ts/src/ir/types.d.ts b/packages/openapi-ts/src/ir/types.d.ts index fae7fdcd2..fd9f04a02 100644 --- a/packages/openapi-ts/src/ir/types.d.ts +++ b/packages/openapi-ts/src/ir/types.d.ts @@ -133,6 +133,7 @@ interface IRSchemaObject | 'minimum' | 'minItems' | 'minLength' + | 'pattern' | 'required' | 'title' > { diff --git a/packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts b/packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts index 23b8b571c..f3f5be697 100644 --- a/packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts +++ b/packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts @@ -103,6 +103,10 @@ const parseSchemaMeta = ({ irSchema.minLength = schema.minLength; } + if (schema.pattern) { + irSchema.pattern = schema.pattern; + } + if (schema.readOnly) { irSchema.accessScope = 'read'; } else if (schema.writeOnly) { @@ -666,6 +670,12 @@ const parseNullableType = ({ schema, }); + if (typeIrSchema.default === null) { + // clear to avoid duplicate default inside the non-null schema. + // this would produce incorrect validator output + delete typeIrSchema.default; + } + const schemaItems: Array = [ parseOneType({ context, diff --git a/packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts b/packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts index 6549da4b9..dc5b2964e 100644 --- a/packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts +++ b/packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts @@ -133,6 +133,10 @@ const parseSchemaMeta = ({ irSchema.minLength = schema.minLength; } + if (schema.pattern) { + irSchema.pattern = schema.pattern; + } + if (schema.readOnly) { irSchema.accessScope = 'read'; } else if (schema.writeOnly) { @@ -772,6 +776,12 @@ const parseManyTypes = ({ schema, }); + if (schema.type.includes('null') && typeIrSchema.default === null) { + // clear to avoid duplicate default inside the non-null schema. + // this would produce incorrect validator output + delete typeIrSchema.default; + } + const schemaItems: Array = []; for (const type of schema.type) { diff --git a/packages/openapi-ts/src/plugins/zod/plugin.ts b/packages/openapi-ts/src/plugins/zod/plugin.ts index ebc9be1e7..b6586a56d 100644 --- a/packages/openapi-ts/src/plugins/zod/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/plugin.ts @@ -25,9 +25,13 @@ export const zodId = 'zod'; const defaultIdentifier = compiler.identifier({ text: 'default' }); const intersectionIdentifier = compiler.identifier({ text: 'intersection' }); const lazyIdentifier = compiler.identifier({ text: 'lazy' }); +const lengthIdentifier = compiler.identifier({ text: 'length' }); +const maxIdentifier = compiler.identifier({ text: 'max' }); const mergeIdentifier = compiler.identifier({ text: 'merge' }); +const minIdentifier = compiler.identifier({ text: 'min' }); const optionalIdentifier = compiler.identifier({ text: 'optional' }); const readonlyIdentifier = compiler.identifier({ text: 'readonly' }); +const regexIdentifier = compiler.identifier({ text: 'regex' }); const unionIdentifier = compiler.identifier({ text: 'union' }); const zIdentifier = compiler.identifier({ text: 'z' }); @@ -107,7 +111,7 @@ const arrayTypeToZodSchema = ({ arrayExpression = compiler.callExpression({ functionName: compiler.propertyAccessExpression({ expression: arrayExpression, - name: compiler.identifier({ text: 'length' }), + name: lengthIdentifier, }), parameters: [compiler.valueToExpression({ value: schema.minItems })], }); @@ -116,7 +120,7 @@ const arrayTypeToZodSchema = ({ arrayExpression = compiler.callExpression({ functionName: compiler.propertyAccessExpression({ expression: arrayExpression, - name: compiler.identifier({ text: 'min' }), + name: minIdentifier, }), parameters: [compiler.valueToExpression({ value: schema.minItems })], }); @@ -126,7 +130,7 @@ const arrayTypeToZodSchema = ({ arrayExpression = compiler.callExpression({ functionName: compiler.propertyAccessExpression({ expression: arrayExpression, - name: compiler.identifier({ text: 'max' }), + name: maxIdentifier, }), parameters: [compiler.valueToExpression({ value: schema.maxItems })], }); @@ -317,45 +321,13 @@ const objectTypeToZodSchema = ({ const property = schema.properties[name]!; const isRequired = required.includes(name); - let propertyExpression = schemaToZodSchema({ + const propertyExpression = schemaToZodSchema({ context, + optional: !isRequired, result, schema: property, }); - if (property.accessScope === 'read') { - propertyExpression = compiler.callExpression({ - functionName: compiler.propertyAccessExpression({ - expression: propertyExpression, - name: readonlyIdentifier, - }), - }); - } - - if (!isRequired) { - propertyExpression = compiler.callExpression({ - functionName: compiler.propertyAccessExpression({ - expression: propertyExpression, - name: optionalIdentifier, - }), - }); - } - - if (property.default !== undefined) { - const callParameter = compiler.valueToExpression({ - value: property.default, - }); - if (callParameter) { - propertyExpression = compiler.callExpression({ - functionName: compiler.propertyAccessExpression({ - expression: propertyExpression, - name: defaultIdentifier, - }), - parameters: [callParameter], - }); - } - } - digitsRegExp.lastIndex = 0; let propertyName = digitsRegExp.test(name) ? ts.factory.createNumericLiteral(name) @@ -493,7 +465,7 @@ const stringTypeToZodSchema = ({ stringExpression = compiler.callExpression({ functionName: compiler.propertyAccessExpression({ expression: stringExpression, - name: compiler.identifier({ text: 'length' }), + name: lengthIdentifier, }), parameters: [compiler.valueToExpression({ value: schema.minLength })], }); @@ -502,7 +474,7 @@ const stringTypeToZodSchema = ({ stringExpression = compiler.callExpression({ functionName: compiler.propertyAccessExpression({ expression: stringExpression, - name: compiler.identifier({ text: 'min' }), + name: minIdentifier, }), parameters: [compiler.valueToExpression({ value: schema.minLength })], }); @@ -512,13 +484,32 @@ const stringTypeToZodSchema = ({ stringExpression = compiler.callExpression({ functionName: compiler.propertyAccessExpression({ expression: stringExpression, - name: compiler.identifier({ text: 'max' }), + name: maxIdentifier, }), parameters: [compiler.valueToExpression({ value: schema.maxLength })], }); } } + if (schema.pattern) { + const text = schema.pattern + .replace(/\\/g, '\\\\') // backslashes + .replace(/\n/g, '\\n') // newlines + .replace(/\r/g, '\\r') // carriage returns + .replace(/\t/g, '\\t') // tabs + .replace(/\f/g, '\\f') // form feeds + .replace(/\v/g, '\\v') // vertical tabs + .replace(/'/g, "\\'") // single quotes + .replace(/"/g, '\\"'); // double quotes + stringExpression = compiler.callExpression({ + functionName: compiler.propertyAccessExpression({ + expression: stringExpression, + name: regexIdentifier, + }), + parameters: [compiler.regularExpressionLiteral({ text })], + }); + } + return stringExpression; }; @@ -680,6 +671,7 @@ const operationToZodSchema = ({ const schemaToZodSchema = ({ $ref, context, + optional, result, schema, }: { @@ -688,6 +680,12 @@ const schemaToZodSchema = ({ */ $ref?: string; context: IR.Context; + /** + * Accept `optional` to handle optional object properties. We can't handle + * this inside the object function because `.optional()` must come before + * `.default()` which is handled in this function. + */ + optional?: boolean; result: Result; schema: IR.SchemaObject; }): ts.Expression => { @@ -841,6 +839,41 @@ const schemaToZodSchema = ({ result.circularReferenceTracker.delete($ref); } + if (expression) { + if (schema.accessScope === 'read') { + expression = compiler.callExpression({ + functionName: compiler.propertyAccessExpression({ + expression, + name: readonlyIdentifier, + }), + }); + } + + if (optional) { + expression = compiler.callExpression({ + functionName: compiler.propertyAccessExpression({ + expression, + name: optionalIdentifier, + }), + }); + } + + if (schema.default !== undefined) { + const callParameter = compiler.valueToExpression({ + value: schema.default, + }); + if (callParameter) { + expression = compiler.callExpression({ + functionName: compiler.propertyAccessExpression({ + expression, + name: defaultIdentifier, + }), + parameters: [callParameter], + }); + } + } + } + // emit nodes only if $ref points to a reusable component if (identifier?.name) { const statement = compiler.constVariable({ diff --git a/packages/openapi-ts/test/3.0.x.test.ts b/packages/openapi-ts/test/3.0.x.test.ts index 96d65aa0b..f0b2397ec 100644 --- a/packages/openapi-ts/test/3.0.x.test.ts +++ b/packages/openapi-ts/test/3.0.x.test.ts @@ -479,6 +479,14 @@ describe(`OpenAPI ${VERSION}`, () => { }), description: 'gracefully handles invalid type', }, + { + config: createConfig({ + input: 'validators.json', + output: 'validators', + plugins: ['zod'], + }), + description: 'generates Zod schemas', + }, ]; it.each(scenarios)('$description', async ({ config }) => { diff --git a/packages/openapi-ts/test/3.1.x.test.ts b/packages/openapi-ts/test/3.1.x.test.ts index 6673d812c..840e6e7f3 100644 --- a/packages/openapi-ts/test/3.1.x.test.ts +++ b/packages/openapi-ts/test/3.1.x.test.ts @@ -470,14 +470,6 @@ describe(`OpenAPI ${VERSION}`, () => { description: 'does not set oneOf composition ref model properties as required', }, - { - config: createConfig({ - input: 'schema-recursive.json', - output: 'schema-recursive', - plugins: ['zod'], - }), - description: 'generates Zod schemas with from recursive schemas', - }, { config: createConfig({ input: 'security-api-key.json', @@ -548,6 +540,14 @@ describe(`OpenAPI ${VERSION}`, () => { }), description: 'gracefully handles invalid type', }, + { + config: createConfig({ + input: 'validators.json', + output: 'validators', + plugins: ['zod'], + }), + description: 'generates Zod schemas', + }, ]; it.each(scenarios)('$description', async ({ config }) => { diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/plugins/zod/default/zod.gen.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/plugins/zod/default/zod.gen.ts index 90a7326d2..a5a37e0e2 100644 --- a/packages/openapi-ts/test/__snapshots__/3.0.x/plugins/zod/default/zod.gen.ts +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/plugins/zod/default/zod.gen.ts @@ -35,7 +35,7 @@ export const zSimpleReference = z.object({ }); export const zSimpleStringWithPattern = z.union([ - z.string().max(64), + z.string().max(64).regex(/^[a-zA-Z0-9_]*$/), z.null() ]); @@ -67,7 +67,7 @@ export const zArrayWithNumbers = z.array(z.number()); export const zArrayWithBooleans = z.array(z.boolean()); -export const zArrayWithStrings = z.array(z.string()); +export const zArrayWithStrings = z.array(z.string()).default(['test']); export const zArrayWithReferences = z.array(z.object({ prop: z.string().optional() @@ -420,13 +420,13 @@ export const zModelWithNestedProperties = z.object({ second: z.union([ z.object({ third: z.union([ - z.string(), + z.string().readonly(), z.null() ]).readonly() - }), + }).readonly(), z.null() ]).readonly() - }), + }).readonly(), z.null() ]).readonly() }); @@ -458,15 +458,15 @@ export const zModelThatExtendsExtends = zModelWithString.merge(zModelThatExtends })); export const zModelWithPattern = z.object({ - key: z.string().max(64), + key: z.string().max(64).regex(/^[a-zA-Z0-9_]*$/), name: z.string().max(255), enabled: z.boolean().readonly().optional(), modified: z.string().datetime().readonly().optional(), - id: z.string().optional(), - text: z.string().optional(), - patternWithSingleQuotes: z.string().optional(), - patternWithNewline: z.string().optional(), - patternWithBacktick: z.string().optional() + id: z.string().regex(/^\\d{2}-\\d{3}-\\d{4}$/).optional(), + text: z.string().regex(/^\\w+$/).optional(), + patternWithSingleQuotes: z.string().regex(/^[a-zA-Z0-9\']*$/).optional(), + patternWithNewline: z.string().regex(/aaa\nbbb/).optional(), + patternWithBacktick: z.string().regex(/aaa`bbb/).optional() }); export const zFile = z.object({ @@ -535,7 +535,7 @@ export const zNullableObject = z.union([ foo: z.string().optional() }), z.null() -]); +]).default(null); export const zCharactersInDescription = z.string(); diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/validators/zod.gen.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/validators/zod.gen.ts new file mode 100644 index 000000000..7e9d3dc3c --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/validators/zod.gen.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zFoo: z.ZodTypeAny = z.union([ + z.object({ + foo: z.string().regex(/^\\d{3}-\\d{2}-\\d{4}$/).optional(), + bar: z.object({ + foo: z.lazy(() => { + return zFoo; + }).optional() + }).optional(), + baz: z.array(z.lazy(() => { + return zFoo; + })).optional(), + qux: z.number().optional().default(0) + }), + z.null() +]).default(null); + +export const zBar = z.object({ + foo: zFoo.optional() +}); + +export const zBaz = z.string().regex(/foo\nbar/).readonly().default('baz'); \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/plugins/zod/default/zod.gen.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/plugins/zod/default/zod.gen.ts index ac499cb56..3c35b83f3 100644 --- a/packages/openapi-ts/test/__snapshots__/3.1.x/plugins/zod/default/zod.gen.ts +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/plugins/zod/default/zod.gen.ts @@ -35,7 +35,7 @@ export const zSimpleReference = z.object({ }); export const zSimpleStringWithPattern = z.union([ - z.string().max(64), + z.string().max(64).regex(/^[a-zA-Z0-9_]*$/), z.null() ]); @@ -67,7 +67,7 @@ export const zArrayWithNumbers = z.array(z.number()); export const zArrayWithBooleans = z.array(z.boolean()); -export const zArrayWithStrings = z.array(z.string()); +export const zArrayWithStrings = z.array(z.string()).default(['test']); export const zArrayWithReferences = z.array(z.object({ prop: z.string().optional() @@ -415,13 +415,13 @@ export const zModelWithNestedProperties = z.object({ second: z.union([ z.object({ third: z.union([ - z.string(), + z.string().readonly(), z.null() ]).readonly() - }), + }).readonly(), z.null() ]).readonly() - }), + }).readonly(), z.null() ]).readonly() }); @@ -453,15 +453,15 @@ export const zModelThatExtendsExtends = zModelWithString.merge(zModelThatExtends })); export const zModelWithPattern = z.object({ - key: z.string().max(64), + key: z.string().max(64).regex(/^[a-zA-Z0-9_]*$/), name: z.string().max(255), enabled: z.boolean().readonly().optional(), modified: z.string().datetime().readonly().optional(), - id: z.string().optional(), - text: z.string().optional(), - patternWithSingleQuotes: z.string().optional(), - patternWithNewline: z.string().optional(), - patternWithBacktick: z.string().optional() + id: z.string().regex(/^\\d{2}-\\d{3}-\\d{4}$/).optional(), + text: z.string().regex(/^\\w+$/).optional(), + patternWithSingleQuotes: z.string().regex(/^[a-zA-Z0-9\']*$/).optional(), + patternWithNewline: z.string().regex(/aaa\nbbb/).optional(), + patternWithBacktick: z.string().regex(/aaa`bbb/).optional() }); export const zFile = z.object({ @@ -526,7 +526,7 @@ export const zNullableObject = z.union([ foo: z.string().optional() }), z.null() -]); +]).default(null); export const zCharactersInDescription = z.string(); diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/schema-recursive/zod.gen.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/schema-recursive/zod.gen.ts deleted file mode 100644 index b023898a8..000000000 --- a/packages/openapi-ts/test/__snapshots__/3.1.x/schema-recursive/zod.gen.ts +++ /dev/null @@ -1,19 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { z } from 'zod'; - -export const zFoo: z.ZodTypeAny = z.object({ - foo: z.string().optional(), - bar: z.object({ - foo: z.lazy(() => { - return zFoo; - }).optional() - }).optional(), - baz: z.array(z.lazy(() => { - return zFoo; - })).optional() -}); - -export const zBar = z.object({ - foo: zFoo.optional() -}); diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/validators/zod.gen.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/validators/zod.gen.ts new file mode 100644 index 000000000..7e9d3dc3c --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/validators/zod.gen.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zFoo: z.ZodTypeAny = z.union([ + z.object({ + foo: z.string().regex(/^\\d{3}-\\d{2}-\\d{4}$/).optional(), + bar: z.object({ + foo: z.lazy(() => { + return zFoo; + }).optional() + }).optional(), + baz: z.array(z.lazy(() => { + return zFoo; + })).optional(), + qux: z.number().optional().default(0) + }), + z.null() +]).default(null); + +export const zBar = z.object({ + foo: zFoo.optional() +}); + +export const zBaz = z.string().regex(/foo\nbar/).readonly().default('baz'); \ No newline at end of file diff --git a/packages/openapi-ts/test/sample.cjs b/packages/openapi-ts/test/sample.cjs index 4e415d4f5..6ab46eddd 100644 --- a/packages/openapi-ts/test/sample.cjs +++ b/packages/openapi-ts/test/sample.cjs @@ -14,7 +14,7 @@ const main = async () => { // exclude: '^#/components/schemas/ModelWithCircularReference$', // include: // '^(#/components/schemas/import|#/paths/api/v{api-version}/simple/options)$', - path: './test/spec/3.1.x/full.json', + path: './test/spec/3.0.x/validators.json', // path: './test/spec/v3-transforms.json', // path: 'https://mongodb-mms-prod-build-server.s3.amazonaws.com/openapi/2caffd88277a4e27c95dcefc7e3b6a63a3b03297-v2-2023-11-15.json', // path: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', diff --git a/packages/openapi-ts/test/spec/3.0.x/validators.json b/packages/openapi-ts/test/spec/3.0.x/validators.json new file mode 100644 index 000000000..7d75c2090 --- /dev/null +++ b/packages/openapi-ts/test/spec/3.0.x/validators.json @@ -0,0 +1,49 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "OpenAPI 3.0.4 validators example", + "version": "1" + }, + "components": { + "schemas": { + "Foo": { + "default": null, + "nullable": true, + "properties": { + "foo": { + "pattern": "^\\d{3}-\\d{2}-\\d{4}$", + "type": "string" + }, + "bar": { + "$ref": "#/components/schemas/Bar" + }, + "baz": { + "items": { + "$ref": "#/components/schemas/Foo" + }, + "type": "array" + }, + "qux": { + "default": 0, + "type": "number" + } + }, + "type": "object" + }, + "Bar": { + "properties": { + "foo": { + "$ref": "#/components/schemas/Foo" + } + }, + "type": "object" + }, + "Baz": { + "default": "baz", + "pattern": "foo\nbar", + "readOnly": true, + "type": "string" + } + } + } +} diff --git a/packages/openapi-ts/test/spec/3.1.x/schema-recursive.json b/packages/openapi-ts/test/spec/3.1.x/validators.json similarity index 60% rename from packages/openapi-ts/test/spec/3.1.x/schema-recursive.json rename to packages/openapi-ts/test/spec/3.1.x/validators.json index 7c3ab3f63..1ecffb505 100644 --- a/packages/openapi-ts/test/spec/3.1.x/schema-recursive.json +++ b/packages/openapi-ts/test/spec/3.1.x/validators.json @@ -1,15 +1,16 @@ { "openapi": "3.1.0", "info": { - "title": "OpenAPI 3.1.0 schema recursive example", + "title": "OpenAPI 3.1.0 validators example", "version": "1" }, "components": { "schemas": { "Foo": { - "type": "object", + "default": null, "properties": { "foo": { + "pattern": "^\\d{3}-\\d{2}-\\d{4}$", "type": "string" }, "bar": { @@ -20,8 +21,13 @@ "$ref": "#/components/schemas/Foo" }, "type": "array" + }, + "qux": { + "default": 0, + "type": "number" } - } + }, + "type": ["object", "null"] }, "Bar": { "properties": { @@ -30,6 +36,12 @@ } }, "type": "object" + }, + "Baz": { + "default": "baz", + "pattern": "foo\nbar", + "readOnly": true, + "type": "string" } } }