fork of hey-api/openapi-ts because I need some additional things
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #1469 from hey-api/fix/zod-schema-pattern

fix: zod: generate patterns and improve plain schemas

authored by

Lubos and committed by
GitHub
680e55fe ac442f05

+263 -95
+5
.changeset/eight-phones-applaud.md
··· 1 + --- 2 + '@hey-api/openapi-ts': patch 3 + --- 4 + 5 + fix: zod: generate patterns and improve plain schemas
+1
packages/openapi-ts/src/compiler/index.ts
··· 49 49 propertyAccessExpression: types.createPropertyAccessExpression, 50 50 propertyAccessExpressions: transform.createPropertyAccessExpressions, 51 51 propertyAssignment: types.createPropertyAssignment, 52 + regularExpressionLiteral: types.createRegularExpressionLiteral, 52 53 returnFunctionCall: _return.createReturnFunctionCall, 53 54 returnStatement: _return.createReturnStatement, 54 55 returnVariable: _return.createReturnVariable,
+8
packages/openapi-ts/src/compiler/types.ts
··· 894 894 initializer: ts.Expression; 895 895 name: string | ts.PropertyName; 896 896 }) => ts.factory.createPropertyAssignment(name, initializer); 897 + 898 + export const createRegularExpressionLiteral = ({ 899 + flags = [], 900 + text, 901 + }: { 902 + flags?: ReadonlyArray<'g' | 'i' | 'm' | 's' | 'u' | 'y'>; 903 + text: string; 904 + }) => ts.factory.createRegularExpressionLiteral(`/${text}/${flags.join('')}`);
+1
packages/openapi-ts/src/ir/types.d.ts
··· 133 133 | 'minimum' 134 134 | 'minItems' 135 135 | 'minLength' 136 + | 'pattern' 136 137 | 'required' 137 138 | 'title' 138 139 > {
+10
packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts
··· 103 103 irSchema.minLength = schema.minLength; 104 104 } 105 105 106 + if (schema.pattern) { 107 + irSchema.pattern = schema.pattern; 108 + } 109 + 106 110 if (schema.readOnly) { 107 111 irSchema.accessScope = 'read'; 108 112 } else if (schema.writeOnly) { ··· 665 669 irSchema: typeIrSchema, 666 670 schema, 667 671 }); 672 + 673 + if (typeIrSchema.default === null) { 674 + // clear to avoid duplicate default inside the non-null schema. 675 + // this would produce incorrect validator output 676 + delete typeIrSchema.default; 677 + } 668 678 669 679 const schemaItems: Array<IR.SchemaObject> = [ 670 680 parseOneType({
+10
packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts
··· 133 133 irSchema.minLength = schema.minLength; 134 134 } 135 135 136 + if (schema.pattern) { 137 + irSchema.pattern = schema.pattern; 138 + } 139 + 136 140 if (schema.readOnly) { 137 141 irSchema.accessScope = 'read'; 138 142 } else if (schema.writeOnly) { ··· 771 775 irSchema: typeIrSchema, 772 776 schema, 773 777 }); 778 + 779 + if (schema.type.includes('null') && typeIrSchema.default === null) { 780 + // clear to avoid duplicate default inside the non-null schema. 781 + // this would produce incorrect validator output 782 + delete typeIrSchema.default; 783 + } 774 784 775 785 const schemaItems: Array<IR.SchemaObject> = []; 776 786
+73 -40
packages/openapi-ts/src/plugins/zod/plugin.ts
··· 25 25 const defaultIdentifier = compiler.identifier({ text: 'default' }); 26 26 const intersectionIdentifier = compiler.identifier({ text: 'intersection' }); 27 27 const lazyIdentifier = compiler.identifier({ text: 'lazy' }); 28 + const lengthIdentifier = compiler.identifier({ text: 'length' }); 29 + const maxIdentifier = compiler.identifier({ text: 'max' }); 28 30 const mergeIdentifier = compiler.identifier({ text: 'merge' }); 31 + const minIdentifier = compiler.identifier({ text: 'min' }); 29 32 const optionalIdentifier = compiler.identifier({ text: 'optional' }); 30 33 const readonlyIdentifier = compiler.identifier({ text: 'readonly' }); 34 + const regexIdentifier = compiler.identifier({ text: 'regex' }); 31 35 const unionIdentifier = compiler.identifier({ text: 'union' }); 32 36 const zIdentifier = compiler.identifier({ text: 'z' }); 33 37 ··· 107 111 arrayExpression = compiler.callExpression({ 108 112 functionName: compiler.propertyAccessExpression({ 109 113 expression: arrayExpression, 110 - name: compiler.identifier({ text: 'length' }), 114 + name: lengthIdentifier, 111 115 }), 112 116 parameters: [compiler.valueToExpression({ value: schema.minItems })], 113 117 }); ··· 116 120 arrayExpression = compiler.callExpression({ 117 121 functionName: compiler.propertyAccessExpression({ 118 122 expression: arrayExpression, 119 - name: compiler.identifier({ text: 'min' }), 123 + name: minIdentifier, 120 124 }), 121 125 parameters: [compiler.valueToExpression({ value: schema.minItems })], 122 126 }); ··· 126 130 arrayExpression = compiler.callExpression({ 127 131 functionName: compiler.propertyAccessExpression({ 128 132 expression: arrayExpression, 129 - name: compiler.identifier({ text: 'max' }), 133 + name: maxIdentifier, 130 134 }), 131 135 parameters: [compiler.valueToExpression({ value: schema.maxItems })], 132 136 }); ··· 317 321 const property = schema.properties[name]!; 318 322 const isRequired = required.includes(name); 319 323 320 - let propertyExpression = schemaToZodSchema({ 324 + const propertyExpression = schemaToZodSchema({ 321 325 context, 326 + optional: !isRequired, 322 327 result, 323 328 schema: property, 324 329 }); 325 330 326 - if (property.accessScope === 'read') { 327 - propertyExpression = compiler.callExpression({ 328 - functionName: compiler.propertyAccessExpression({ 329 - expression: propertyExpression, 330 - name: readonlyIdentifier, 331 - }), 332 - }); 333 - } 334 - 335 - if (!isRequired) { 336 - propertyExpression = compiler.callExpression({ 337 - functionName: compiler.propertyAccessExpression({ 338 - expression: propertyExpression, 339 - name: optionalIdentifier, 340 - }), 341 - }); 342 - } 343 - 344 - if (property.default !== undefined) { 345 - const callParameter = compiler.valueToExpression({ 346 - value: property.default, 347 - }); 348 - if (callParameter) { 349 - propertyExpression = compiler.callExpression({ 350 - functionName: compiler.propertyAccessExpression({ 351 - expression: propertyExpression, 352 - name: defaultIdentifier, 353 - }), 354 - parameters: [callParameter], 355 - }); 356 - } 357 - } 358 - 359 331 digitsRegExp.lastIndex = 0; 360 332 let propertyName = digitsRegExp.test(name) 361 333 ? ts.factory.createNumericLiteral(name) ··· 493 465 stringExpression = compiler.callExpression({ 494 466 functionName: compiler.propertyAccessExpression({ 495 467 expression: stringExpression, 496 - name: compiler.identifier({ text: 'length' }), 468 + name: lengthIdentifier, 497 469 }), 498 470 parameters: [compiler.valueToExpression({ value: schema.minLength })], 499 471 }); ··· 502 474 stringExpression = compiler.callExpression({ 503 475 functionName: compiler.propertyAccessExpression({ 504 476 expression: stringExpression, 505 - name: compiler.identifier({ text: 'min' }), 477 + name: minIdentifier, 506 478 }), 507 479 parameters: [compiler.valueToExpression({ value: schema.minLength })], 508 480 }); ··· 512 484 stringExpression = compiler.callExpression({ 513 485 functionName: compiler.propertyAccessExpression({ 514 486 expression: stringExpression, 515 - name: compiler.identifier({ text: 'max' }), 487 + name: maxIdentifier, 516 488 }), 517 489 parameters: [compiler.valueToExpression({ value: schema.maxLength })], 518 490 }); 519 491 } 520 492 } 521 493 494 + if (schema.pattern) { 495 + const text = schema.pattern 496 + .replace(/\\/g, '\\\\') // backslashes 497 + .replace(/\n/g, '\\n') // newlines 498 + .replace(/\r/g, '\\r') // carriage returns 499 + .replace(/\t/g, '\\t') // tabs 500 + .replace(/\f/g, '\\f') // form feeds 501 + .replace(/\v/g, '\\v') // vertical tabs 502 + .replace(/'/g, "\\'") // single quotes 503 + .replace(/"/g, '\\"'); // double quotes 504 + stringExpression = compiler.callExpression({ 505 + functionName: compiler.propertyAccessExpression({ 506 + expression: stringExpression, 507 + name: regexIdentifier, 508 + }), 509 + parameters: [compiler.regularExpressionLiteral({ text })], 510 + }); 511 + } 512 + 522 513 return stringExpression; 523 514 }; 524 515 ··· 680 671 const schemaToZodSchema = ({ 681 672 $ref, 682 673 context, 674 + optional, 683 675 result, 684 676 schema, 685 677 }: { ··· 688 680 */ 689 681 $ref?: string; 690 682 context: IR.Context; 683 + /** 684 + * Accept `optional` to handle optional object properties. We can't handle 685 + * this inside the object function because `.optional()` must come before 686 + * `.default()` which is handled in this function. 687 + */ 688 + optional?: boolean; 691 689 result: Result; 692 690 schema: IR.SchemaObject; 693 691 }): ts.Expression => { ··· 839 837 840 838 if ($ref) { 841 839 result.circularReferenceTracker.delete($ref); 840 + } 841 + 842 + if (expression) { 843 + if (schema.accessScope === 'read') { 844 + expression = compiler.callExpression({ 845 + functionName: compiler.propertyAccessExpression({ 846 + expression, 847 + name: readonlyIdentifier, 848 + }), 849 + }); 850 + } 851 + 852 + if (optional) { 853 + expression = compiler.callExpression({ 854 + functionName: compiler.propertyAccessExpression({ 855 + expression, 856 + name: optionalIdentifier, 857 + }), 858 + }); 859 + } 860 + 861 + if (schema.default !== undefined) { 862 + const callParameter = compiler.valueToExpression({ 863 + value: schema.default, 864 + }); 865 + if (callParameter) { 866 + expression = compiler.callExpression({ 867 + functionName: compiler.propertyAccessExpression({ 868 + expression, 869 + name: defaultIdentifier, 870 + }), 871 + parameters: [callParameter], 872 + }); 873 + } 874 + } 842 875 } 843 876 844 877 // emit nodes only if $ref points to a reusable component
+8
packages/openapi-ts/test/3.0.x.test.ts
··· 479 479 }), 480 480 description: 'gracefully handles invalid type', 481 481 }, 482 + { 483 + config: createConfig({ 484 + input: 'validators.json', 485 + output: 'validators', 486 + plugins: ['zod'], 487 + }), 488 + description: 'generates Zod schemas', 489 + }, 482 490 ]; 483 491 484 492 it.each(scenarios)('$description', async ({ config }) => {
+8 -8
packages/openapi-ts/test/3.1.x.test.ts
··· 472 472 }, 473 473 { 474 474 config: createConfig({ 475 - input: 'schema-recursive.json', 476 - output: 'schema-recursive', 477 - plugins: ['zod'], 478 - }), 479 - description: 'generates Zod schemas with from recursive schemas', 480 - }, 481 - { 482 - config: createConfig({ 483 475 input: 'security-api-key.json', 484 476 output: 'security-api-key', 485 477 plugins: [ ··· 547 539 output: 'type-invalid', 548 540 }), 549 541 description: 'gracefully handles invalid type', 542 + }, 543 + { 544 + config: createConfig({ 545 + input: 'validators.json', 546 + output: 'validators', 547 + plugins: ['zod'], 548 + }), 549 + description: 'generates Zod schemas', 550 550 }, 551 551 ]; 552 552
+12 -12
packages/openapi-ts/test/__snapshots__/3.0.x/plugins/zod/default/zod.gen.ts
··· 35 35 }); 36 36 37 37 export const zSimpleStringWithPattern = z.union([ 38 - z.string().max(64), 38 + z.string().max(64).regex(/^[a-zA-Z0-9_]*$/), 39 39 z.null() 40 40 ]); 41 41 ··· 67 67 68 68 export const zArrayWithBooleans = z.array(z.boolean()); 69 69 70 - export const zArrayWithStrings = z.array(z.string()); 70 + export const zArrayWithStrings = z.array(z.string()).default(['test']); 71 71 72 72 export const zArrayWithReferences = z.array(z.object({ 73 73 prop: z.string().optional() ··· 420 420 second: z.union([ 421 421 z.object({ 422 422 third: z.union([ 423 - z.string(), 423 + z.string().readonly(), 424 424 z.null() 425 425 ]).readonly() 426 - }), 426 + }).readonly(), 427 427 z.null() 428 428 ]).readonly() 429 - }), 429 + }).readonly(), 430 430 z.null() 431 431 ]).readonly() 432 432 }); ··· 458 458 })); 459 459 460 460 export const zModelWithPattern = z.object({ 461 - key: z.string().max(64), 461 + key: z.string().max(64).regex(/^[a-zA-Z0-9_]*$/), 462 462 name: z.string().max(255), 463 463 enabled: z.boolean().readonly().optional(), 464 464 modified: z.string().datetime().readonly().optional(), 465 - id: z.string().optional(), 466 - text: z.string().optional(), 467 - patternWithSingleQuotes: z.string().optional(), 468 - patternWithNewline: z.string().optional(), 469 - patternWithBacktick: z.string().optional() 465 + id: z.string().regex(/^\\d{2}-\\d{3}-\\d{4}$/).optional(), 466 + text: z.string().regex(/^\\w+$/).optional(), 467 + patternWithSingleQuotes: z.string().regex(/^[a-zA-Z0-9\']*$/).optional(), 468 + patternWithNewline: z.string().regex(/aaa\nbbb/).optional(), 469 + patternWithBacktick: z.string().regex(/aaa`bbb/).optional() 470 470 }); 471 471 472 472 export const zFile = z.object({ ··· 535 535 foo: z.string().optional() 536 536 }), 537 537 z.null() 538 - ]); 538 + ]).default(null); 539 539 540 540 export const zCharactersInDescription = z.string(); 541 541
+25
packages/openapi-ts/test/__snapshots__/3.0.x/validators/zod.gen.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + import { z } from 'zod'; 4 + 5 + export const zFoo: z.ZodTypeAny = z.union([ 6 + z.object({ 7 + foo: z.string().regex(/^\\d{3}-\\d{2}-\\d{4}$/).optional(), 8 + bar: z.object({ 9 + foo: z.lazy(() => { 10 + return zFoo; 11 + }).optional() 12 + }).optional(), 13 + baz: z.array(z.lazy(() => { 14 + return zFoo; 15 + })).optional(), 16 + qux: z.number().optional().default(0) 17 + }), 18 + z.null() 19 + ]).default(null); 20 + 21 + export const zBar = z.object({ 22 + foo: zFoo.optional() 23 + }); 24 + 25 + export const zBaz = z.string().regex(/foo\nbar/).readonly().default('baz');
+12 -12
packages/openapi-ts/test/__snapshots__/3.1.x/plugins/zod/default/zod.gen.ts
··· 35 35 }); 36 36 37 37 export const zSimpleStringWithPattern = z.union([ 38 - z.string().max(64), 38 + z.string().max(64).regex(/^[a-zA-Z0-9_]*$/), 39 39 z.null() 40 40 ]); 41 41 ··· 67 67 68 68 export const zArrayWithBooleans = z.array(z.boolean()); 69 69 70 - export const zArrayWithStrings = z.array(z.string()); 70 + export const zArrayWithStrings = z.array(z.string()).default(['test']); 71 71 72 72 export const zArrayWithReferences = z.array(z.object({ 73 73 prop: z.string().optional() ··· 415 415 second: z.union([ 416 416 z.object({ 417 417 third: z.union([ 418 - z.string(), 418 + z.string().readonly(), 419 419 z.null() 420 420 ]).readonly() 421 - }), 421 + }).readonly(), 422 422 z.null() 423 423 ]).readonly() 424 - }), 424 + }).readonly(), 425 425 z.null() 426 426 ]).readonly() 427 427 }); ··· 453 453 })); 454 454 455 455 export const zModelWithPattern = z.object({ 456 - key: z.string().max(64), 456 + key: z.string().max(64).regex(/^[a-zA-Z0-9_]*$/), 457 457 name: z.string().max(255), 458 458 enabled: z.boolean().readonly().optional(), 459 459 modified: z.string().datetime().readonly().optional(), 460 - id: z.string().optional(), 461 - text: z.string().optional(), 462 - patternWithSingleQuotes: z.string().optional(), 463 - patternWithNewline: z.string().optional(), 464 - patternWithBacktick: z.string().optional() 460 + id: z.string().regex(/^\\d{2}-\\d{3}-\\d{4}$/).optional(), 461 + text: z.string().regex(/^\\w+$/).optional(), 462 + patternWithSingleQuotes: z.string().regex(/^[a-zA-Z0-9\']*$/).optional(), 463 + patternWithNewline: z.string().regex(/aaa\nbbb/).optional(), 464 + patternWithBacktick: z.string().regex(/aaa`bbb/).optional() 465 465 }); 466 466 467 467 export const zFile = z.object({ ··· 526 526 foo: z.string().optional() 527 527 }), 528 528 z.null() 529 - ]); 529 + ]).default(null); 530 530 531 531 export const zCharactersInDescription = z.string(); 532 532
-19
packages/openapi-ts/test/__snapshots__/3.1.x/schema-recursive/zod.gen.ts
··· 1 - // This file is auto-generated by @hey-api/openapi-ts 2 - 3 - import { z } from 'zod'; 4 - 5 - export const zFoo: z.ZodTypeAny = z.object({ 6 - foo: z.string().optional(), 7 - bar: z.object({ 8 - foo: z.lazy(() => { 9 - return zFoo; 10 - }).optional() 11 - }).optional(), 12 - baz: z.array(z.lazy(() => { 13 - return zFoo; 14 - })).optional() 15 - }); 16 - 17 - export const zBar = z.object({ 18 - foo: zFoo.optional() 19 - });
+25
packages/openapi-ts/test/__snapshots__/3.1.x/validators/zod.gen.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + import { z } from 'zod'; 4 + 5 + export const zFoo: z.ZodTypeAny = z.union([ 6 + z.object({ 7 + foo: z.string().regex(/^\\d{3}-\\d{2}-\\d{4}$/).optional(), 8 + bar: z.object({ 9 + foo: z.lazy(() => { 10 + return zFoo; 11 + }).optional() 12 + }).optional(), 13 + baz: z.array(z.lazy(() => { 14 + return zFoo; 15 + })).optional(), 16 + qux: z.number().optional().default(0) 17 + }), 18 + z.null() 19 + ]).default(null); 20 + 21 + export const zBar = z.object({ 22 + foo: zFoo.optional() 23 + }); 24 + 25 + export const zBaz = z.string().regex(/foo\nbar/).readonly().default('baz');
+1 -1
packages/openapi-ts/test/sample.cjs
··· 14 14 // exclude: '^#/components/schemas/ModelWithCircularReference$', 15 15 // include: 16 16 // '^(#/components/schemas/import|#/paths/api/v{api-version}/simple/options)$', 17 - path: './test/spec/3.1.x/full.json', 17 + path: './test/spec/3.0.x/validators.json', 18 18 // path: './test/spec/v3-transforms.json', 19 19 // path: 'https://mongodb-mms-prod-build-server.s3.amazonaws.com/openapi/2caffd88277a4e27c95dcefc7e3b6a63a3b03297-v2-2023-11-15.json', 20 20 // path: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml',
+49
packages/openapi-ts/test/spec/3.0.x/validators.json
··· 1 + { 2 + "openapi": "3.0.4", 3 + "info": { 4 + "title": "OpenAPI 3.0.4 validators example", 5 + "version": "1" 6 + }, 7 + "components": { 8 + "schemas": { 9 + "Foo": { 10 + "default": null, 11 + "nullable": true, 12 + "properties": { 13 + "foo": { 14 + "pattern": "^\\d{3}-\\d{2}-\\d{4}$", 15 + "type": "string" 16 + }, 17 + "bar": { 18 + "$ref": "#/components/schemas/Bar" 19 + }, 20 + "baz": { 21 + "items": { 22 + "$ref": "#/components/schemas/Foo" 23 + }, 24 + "type": "array" 25 + }, 26 + "qux": { 27 + "default": 0, 28 + "type": "number" 29 + } 30 + }, 31 + "type": "object" 32 + }, 33 + "Bar": { 34 + "properties": { 35 + "foo": { 36 + "$ref": "#/components/schemas/Foo" 37 + } 38 + }, 39 + "type": "object" 40 + }, 41 + "Baz": { 42 + "default": "baz", 43 + "pattern": "foo\nbar", 44 + "readOnly": true, 45 + "type": "string" 46 + } 47 + } 48 + } 49 + }
+15 -3
packages/openapi-ts/test/spec/3.1.x/schema-recursive.json packages/openapi-ts/test/spec/3.1.x/validators.json
··· 1 1 { 2 2 "openapi": "3.1.0", 3 3 "info": { 4 - "title": "OpenAPI 3.1.0 schema recursive example", 4 + "title": "OpenAPI 3.1.0 validators example", 5 5 "version": "1" 6 6 }, 7 7 "components": { 8 8 "schemas": { 9 9 "Foo": { 10 - "type": "object", 10 + "default": null, 11 11 "properties": { 12 12 "foo": { 13 + "pattern": "^\\d{3}-\\d{2}-\\d{4}$", 13 14 "type": "string" 14 15 }, 15 16 "bar": { ··· 20 21 "$ref": "#/components/schemas/Foo" 21 22 }, 22 23 "type": "array" 24 + }, 25 + "qux": { 26 + "default": 0, 27 + "type": "number" 23 28 } 24 - } 29 + }, 30 + "type": ["object", "null"] 25 31 }, 26 32 "Bar": { 27 33 "properties": { ··· 30 36 } 31 37 }, 32 38 "type": "object" 39 + }, 40 + "Baz": { 41 + "default": "baz", 42 + "pattern": "foo\nbar", 43 + "readOnly": true, 44 + "type": "string" 33 45 } 34 46 } 35 47 }