Skip to content

Commit

Permalink
fix: zod: generate patterns and improve plain schemas
Browse files Browse the repository at this point in the history
  • Loading branch information
mrlubos committed Dec 19, 2024
1 parent ac442f0 commit a7608c2
Show file tree
Hide file tree
Showing 17 changed files with 263 additions and 95 deletions.
5 changes: 5 additions & 0 deletions .changeset/eight-phones-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hey-api/openapi-ts': patch
---

fix: zod: generate patterns and improve plain schemas
1 change: 1 addition & 0 deletions packages/openapi-ts/src/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/openapi-ts/src/compiler/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('')}`);

Check warning on line 904 in packages/openapi-ts/src/compiler/types.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/compiler/types.ts#L899-L904

Added lines #L899 - L904 were not covered by tests
1 change: 1 addition & 0 deletions packages/openapi-ts/src/ir/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ interface IRSchemaObject
| 'minimum'
| 'minItems'
| 'minLength'
| 'pattern'
| 'required'
| 'title'
> {
Expand Down
10 changes: 10 additions & 0 deletions packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ const parseSchemaMeta = ({
irSchema.minLength = schema.minLength;
}

if (schema.pattern) {
irSchema.pattern = schema.pattern;
}

Check warning on line 109 in packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts#L106-L109

Added lines #L106 - L109 were not covered by tests
if (schema.readOnly) {
irSchema.accessScope = 'read';
} else if (schema.writeOnly) {
Expand Down Expand Up @@ -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;
}

Check warning on line 678 in packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts#L673-L678

Added lines #L673 - L678 were not covered by tests
const schemaItems: Array<IR.SchemaObject> = [
parseOneType({
context,
Expand Down
10 changes: 10 additions & 0 deletions packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<IR.SchemaObject> = [];

for (const type of schema.type) {
Expand Down
113 changes: 73 additions & 40 deletions packages/openapi-ts/src/plugins/zod/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand Down Expand Up @@ -107,7 +111,7 @@ const arrayTypeToZodSchema = ({
arrayExpression = compiler.callExpression({
functionName: compiler.propertyAccessExpression({
expression: arrayExpression,
name: compiler.identifier({ text: 'length' }),
name: lengthIdentifier,

Check warning on line 114 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L114

Added line #L114 was not covered by tests
}),
parameters: [compiler.valueToExpression({ value: schema.minItems })],
});
Expand All @@ -116,7 +120,7 @@ const arrayTypeToZodSchema = ({
arrayExpression = compiler.callExpression({
functionName: compiler.propertyAccessExpression({
expression: arrayExpression,
name: compiler.identifier({ text: 'min' }),
name: minIdentifier,

Check warning on line 123 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L123

Added line #L123 was not covered by tests
}),
parameters: [compiler.valueToExpression({ value: schema.minItems })],
});
Expand All @@ -126,7 +130,7 @@ const arrayTypeToZodSchema = ({
arrayExpression = compiler.callExpression({
functionName: compiler.propertyAccessExpression({
expression: arrayExpression,
name: compiler.identifier({ text: 'max' }),
name: maxIdentifier,

Check warning on line 133 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L133

Added line #L133 was not covered by tests
}),
parameters: [compiler.valueToExpression({ value: schema.maxItems })],
});
Expand Down Expand Up @@ -317,45 +321,13 @@ const objectTypeToZodSchema = ({
const property = schema.properties[name]!;
const isRequired = required.includes(name);

let propertyExpression = schemaToZodSchema({
const propertyExpression = schemaToZodSchema({

Check warning on line 324 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L324

Added line #L324 was not covered by tests
context,
optional: !isRequired,

Check warning on line 326 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L326

Added line #L326 was not covered by tests
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)
Expand Down Expand Up @@ -493,7 +465,7 @@ const stringTypeToZodSchema = ({
stringExpression = compiler.callExpression({
functionName: compiler.propertyAccessExpression({
expression: stringExpression,
name: compiler.identifier({ text: 'length' }),
name: lengthIdentifier,

Check warning on line 468 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L468

Added line #L468 was not covered by tests
}),
parameters: [compiler.valueToExpression({ value: schema.minLength })],
});
Expand All @@ -502,7 +474,7 @@ const stringTypeToZodSchema = ({
stringExpression = compiler.callExpression({
functionName: compiler.propertyAccessExpression({
expression: stringExpression,
name: compiler.identifier({ text: 'min' }),
name: minIdentifier,

Check warning on line 477 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L477

Added line #L477 was not covered by tests
}),
parameters: [compiler.valueToExpression({ value: schema.minLength })],
});
Expand All @@ -512,13 +484,32 @@ const stringTypeToZodSchema = ({
stringExpression = compiler.callExpression({
functionName: compiler.propertyAccessExpression({
expression: stringExpression,
name: compiler.identifier({ text: 'max' }),
name: maxIdentifier,

Check warning on line 487 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L487

Added line #L487 was not covered by tests
}),
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 })],
});
}

Check warning on line 512 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L494-L512

Added lines #L494 - L512 were not covered by tests
return stringExpression;
};

Expand Down Expand Up @@ -680,6 +671,7 @@ const operationToZodSchema = ({
const schemaToZodSchema = ({
$ref,
context,
optional,

Check warning on line 674 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L674

Added line #L674 was not covered by tests
result,
schema,
}: {
Expand All @@ -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;

Check warning on line 688 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L683-L688

Added lines #L683 - L688 were not covered by tests
result: Result;
schema: IR.SchemaObject;
}): ts.Expression => {
Expand Down Expand Up @@ -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],
});
}
}
}

Check warning on line 876 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L842-L876

Added lines #L842 - L876 were not covered by tests
// emit nodes only if $ref points to a reusable component
if (identifier?.name) {
const statement = compiler.constVariable({
Expand Down
8 changes: 8 additions & 0 deletions packages/openapi-ts/test/3.0.x.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
16 changes: 8 additions & 8 deletions packages/openapi-ts/test/3.1.x.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
]);

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
});
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -535,7 +535,7 @@ export const zNullableObject = z.union([
foo: z.string().optional()
}),
z.null()
]);
]).default(null);

export const zCharactersInDescription = z.string();

Expand Down
Loading

0 comments on commit a7608c2

Please sign in to comment.