diff --git a/README.md b/README.md index 2ced1593..307a6c82 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [GraphQL code generator](https://github.com/dotansimha/graphql-code-generator) plugin to generate form validation schema from your GraphQL schema. - [x] support [yup](https://github.com/jquense/yup) -- [ ] support [zod](https://github.com/colinhacks/zod) +- [x] support [zod](https://github.com/colinhacks/zod) ## Quick Start @@ -26,7 +26,7 @@ generates: # see: https://www.graphql-code-generator.com/plugins/typescript strictScalars: true # You can also write the config for this plugin together - schema: yup + schema: yup # or zod ``` You can check [example directory](https://github.com/Code-Hex/graphql-codegen-typescript-validation-schema/tree/main/example) if you want to see more complex config example or how is generated some files. @@ -39,6 +39,8 @@ type: `ValidationSchema` default: `'yup'` Specify generete validation schema you want. +You can specify `yup` or `zod`. + ```yml generates: path/to/graphql.ts: @@ -87,6 +89,15 @@ type: `DirectiveConfig` Generates validation schema with more API based on directive schema. For example, yaml config and GraphQL schema is here. +```graphql +input ExampleInput { + email: String! @required(msg: "Hello, World!") @constraint(minLength: 50, format: "email") + message: String! @constraint(startsWith: "Hello") +} +``` + +#### yup + ```yml generates: path/to/graphql.ts: @@ -114,13 +125,6 @@ generates: email: email ``` -```graphql -input ExampleInput { - email: String! @required(msg: "Hello, World!") @constraint(minLength: 50, format: "email") - message: String! @constraint(startsWith: "Hello") -} -``` - Then generates yup validation schema like below. ```ts @@ -131,3 +135,42 @@ export function ExampleInputSchema(): yup.SchemaOf { }) } ``` + +#### zod + + +```yml +generates: + path/to/graphql.ts: + plugins: + - typescript + - graphql-codegen-validation-schema + config: + schema: zod + directives: + # Write directives like + # + # directive: + # arg1: schemaApi + # arg2: ["schemaApi2", "Hello $1"] + # + # See more examples in `./tests/directive.spec.ts` + # https://github.com/Code-Hex/graphql-codegen-typescript-validation-schema/blob/main/tests/directive.spec.ts + constraint: + minLength: min + # Replace $1 with specified `startsWith` argument value of the constraint directive + startsWith: ["regex", "/^$1/", "message"] + format: + email: email +``` + +Then generates yup validation schema like below. + +```ts +export function ExampleInputSchema(): z.ZodSchema { + return z.object({ + email: z.string().min(50).email(), + message: z.string().regex(/^Hello/, "message") + }) +} +``` \ No newline at end of file diff --git a/codegen.yml b/codegen.yml index 1c24c845..6ccd8a11 100644 --- a/codegen.yml +++ b/codegen.yml @@ -37,3 +37,23 @@ generates: max: ['max', '$1 + 1'] exclusiveMin: min exclusiveMax: max + example/zod/schemas.ts: + plugins: + - ./dist/main/index.js: + schema: zod + importFrom: ../types + directives: + # Write directives like + # + # directive: + # arg1: schemaApi + # arg2: ["schemaApi2", "Hello $1"] + # + # See more examples in `./tests/directive.spec.ts` + # https://github.com/Code-Hex/graphql-codegen-typescript-validation-schema/blob/main/tests/directive.spec.ts + constraint: + minLength: min + # Replace $1 with specified `startsWith` argument value of the constraint directive + startsWith: ['regex', '/^$1/', 'message'] + format: + email: email diff --git a/example/zod/schemas.ts b/example/zod/schemas.ts new file mode 100644 index 00000000..842c45e8 --- /dev/null +++ b/example/zod/schemas.ts @@ -0,0 +1,83 @@ +import { z } from 'zod' +import { AttributeInput, ButtonComponentType, ComponentInput, DropDownComponentInput, EventArgumentInput, EventInput, EventOptionType, HttpInput, HttpMethod, LayoutInput, PageInput, PageType } from '../types' + +type definedNonNullAny = {}; + +export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + +export const definedNonNullAnySchema: z.ZodSchema = z.any().refine((v) => isDefinedNonNullAny(v)); + +export function AttributeInputSchema(): z.ZodSchema { + return z.object({ + key: z.string().nullish(), + val: z.string().nullish() + }) +} + +export const ButtonComponentTypeSchema = z.nativeEnum(ButtonComponentType); + +export function ComponentInputSchema(): z.ZodSchema { + return z.object({ + child: z.lazy(() => ComponentInputSchema().nullish()), + childrens: z.array(z.lazy(() => ComponentInputSchema().nullable())).nullish(), + event: z.lazy(() => EventInputSchema().nullish()), + name: z.string(), + type: ButtonComponentTypeSchema + }) +} + +export function DropDownComponentInputSchema(): z.ZodSchema { + return z.object({ + dropdownComponent: z.lazy(() => ComponentInputSchema().nullish()), + getEvent: z.lazy(() => EventInputSchema()) + }) +} + +export function EventArgumentInputSchema(): z.ZodSchema { + return z.object({ + name: z.string().min(5), + value: z.string().regex(/^foo/, "message") + }) +} + +export function EventInputSchema(): z.ZodSchema { + return z.object({ + arguments: z.array(z.lazy(() => EventArgumentInputSchema())), + options: z.array(EventOptionTypeSchema).nullish() + }) +} + +export const EventOptionTypeSchema = z.nativeEnum(EventOptionType); + +export function HttpInputSchema(): z.ZodSchema { + return z.object({ + method: HttpMethodSchema.nullish(), + url: definedNonNullAnySchema + }) +} + +export const HttpMethodSchema = z.nativeEnum(HttpMethod); + +export function LayoutInputSchema(): z.ZodSchema { + return z.object({ + dropdown: z.lazy(() => DropDownComponentInputSchema().nullish()) + }) +} + +export function PageInputSchema(): z.ZodSchema { + return z.object({ + attributes: z.array(z.lazy(() => AttributeInputSchema())).nullish(), + date: definedNonNullAnySchema.nullish(), + height: z.number(), + id: z.string(), + layout: z.lazy(() => LayoutInputSchema()), + pageType: PageTypeSchema, + postIDs: z.array(z.string()).nullish(), + show: z.boolean(), + tags: z.array(z.string().nullable()).nullish(), + title: z.string(), + width: z.number() + }) +} + +export const PageTypeSchema = z.nativeEnum(PageType); diff --git a/package.json b/package.json index 977145bd..871346b4 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,8 @@ "prettier": "2.5.1", "ts-jest": "^27.1.3", "typescript": "^4.5.4", - "yup": "^0.32.11" + "yup": "^0.32.11", + "zod": "^3.11.6" }, "dependencies": { "@graphql-codegen/plugin-helpers": "^2.3.2", diff --git a/src/config.ts b/src/config.ts index 0125018d..40507132 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import { TypeScriptPluginConfig } from '@graphql-codegen/typescript'; -export type ValidationSchema = 'yup'; +export type ValidationSchema = 'yup' | 'zod'; export interface DirectiveConfig { [directive: string]: { diff --git a/src/directive.ts b/src/directive.ts index de72be2b..59caa56e 100644 --- a/src/directive.ts +++ b/src/directive.ts @@ -109,6 +109,7 @@ export const formatDirectiveObjectArguments = (args: DirectiveObjectArguments): // ``` export const buildApi = (config: FormattedDirectiveConfig, directives: ReadonlyArray): string => directives + .filter(directive => config[directive.name.value] !== undefined) .map(directive => { const directiveName = directive.name.value; const argsConfig = config[directiveName]; diff --git a/src/index.ts b/src/index.ts index b865b318..291c10bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { ZodSchemaVisitor } from './zod/index'; import { transformSchemaAST } from '@graphql-codegen/schema-ast'; import { YupSchemaVisitor } from './yup/index'; import { ValidationSchemaPluginConfig } from './config'; @@ -10,7 +11,7 @@ export const plugin: PluginFunction { const { schema: _schema, ast } = transformSchemaAST(schema, config); - const { buildImports, ...visitor } = YupSchemaVisitor(_schema, config); + const { buildImports, initialEmit, ...visitor } = schemaVisitor(_schema, config); const result = oldVisit(ast, { leave: visitor, @@ -22,6 +23,13 @@ export const plugin: PluginFunction { + if (config?.schema === 'zod') { + return ZodSchemaVisitor(schema, config); + } + return YupSchemaVisitor(schema, config); +}; diff --git a/src/yup/index.ts b/src/yup/index.ts index 5f275187..b6647863 100644 --- a/src/yup/index.ts +++ b/src/yup/index.ts @@ -26,6 +26,7 @@ export const YupSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema } return [importYup]; }, + initialEmit: (): string => '', InputObjectTypeDefinition: (node: InputObjectTypeDefinitionNode) => { const name = tsVisitor.convertName(node.name.value); importTypes.push(name); diff --git a/src/zod/index.ts b/src/zod/index.ts new file mode 100644 index 00000000..04328bcb --- /dev/null +++ b/src/zod/index.ts @@ -0,0 +1,165 @@ +import { isInput, isNonNullType, isListType, isNamedType } from './../graphql'; +import { ValidationSchemaPluginConfig } from '../config'; +import { + InputValueDefinitionNode, + NameNode, + TypeNode, + GraphQLSchema, + InputObjectTypeDefinitionNode, + EnumTypeDefinitionNode, +} from 'graphql'; +import { DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; +import { TsVisitor } from '@graphql-codegen/typescript'; +import { buildApi, formatDirectiveConfig } from '../directive'; + +const importZod = `import { z } from 'zod'`; +const anySchema = `definedNonNullAnySchema`; + +export const ZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchemaPluginConfig) => { + const tsVisitor = new TsVisitor(schema, config); + + const importTypes: string[] = []; + + return { + buildImports: (): string[] => { + if (config.importFrom && importTypes.length > 0) { + return [importZod, `import { ${importTypes.join(', ')} } from '${config.importFrom}'`]; + } + return [importZod]; + }, + initialEmit: (): string => + '\n' + + [ + // Unfortunately, zod doesn’t provide non-null defined any schema. + // This is a temporary hack until it is fixed. + // see: https://github.com/colinhacks/zod/issues/884 + new DeclarationBlock({}).asKind('type').withName('definedNonNullAny').withContent('{}').string, + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`isDefinedNonNullAny`) + .withContent(`(v: any): v is definedNonNullAny => v !== undefined && v !== null`).string, + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${anySchema}: z.ZodSchema`) + .withContent(`z.any().refine((v) => isDefinedNonNullAny(v))`).string, + ].join('\n'), + InputObjectTypeDefinition: (node: InputObjectTypeDefinitionNode) => { + const name = tsVisitor.convertName(node.name.value); + importTypes.push(name); + + const shape = node.fields + ?.map(field => generateInputObjectFieldYupSchema(config, tsVisitor, schema, field, 2)) + .join(',\n'); + + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): z.ZodSchema<${name}>`) + .withBlock([indent(`return z.object({`), shape, indent('})')].join('\n')).string; + }, + EnumTypeDefinition: (node: EnumTypeDefinitionNode) => { + const enumname = tsVisitor.convertName(node.name.value); + importTypes.push(enumname); + + if (config.enumsAsTypes) { + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${enumname}Schema`) + .withContent(`z.enum([${node.values?.map(enumOption => `'${enumOption.name.value}'`).join(', ')}])`).string; + } + + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${enumname}Schema`) + .withContent(`z.nativeEnum(${enumname})`).string; + }, + }; +}; + +const generateInputObjectFieldYupSchema = ( + config: ValidationSchemaPluginConfig, + tsVisitor: TsVisitor, + schema: GraphQLSchema, + field: InputValueDefinitionNode, + indentCount: number +): string => { + let gen = generateInputObjectFieldTypeZodSchema(config, tsVisitor, schema, field.type); + if (config.directives && field.directives) { + const formatted = formatDirectiveConfig(config.directives); + gen += buildApi(formatted, field.directives); + } + return indent(`${field.name.value}: ${maybeLazy(field.type, gen)}`, indentCount); +}; + +const generateInputObjectFieldTypeZodSchema = ( + config: ValidationSchemaPluginConfig, + tsVisitor: TsVisitor, + schema: GraphQLSchema, + type: TypeNode, + parentType?: TypeNode +): string => { + if (isListType(type)) { + const gen = generateInputObjectFieldTypeZodSchema(config, tsVisitor, schema, type.type, type); + if (!isNonNullType(parentType)) { + return `z.array(${maybeLazy(type.type, gen)}).nullish()`; + } + return `z.array(${maybeLazy(type.type, gen)})`; + } + if (isNonNullType(type)) { + const gen = generateInputObjectFieldTypeZodSchema(config, tsVisitor, schema, type.type, type); + return maybeLazy(type.type, gen); + } + if (isNamedType(type)) { + const gen = generateNameNodeZodSchema(tsVisitor, schema, type.name); + if (isNonNullType(parentType)) { + return gen; + } + if (isListType(parentType)) { + return `${gen}.nullable()`; + } + return `${gen}.nullish()`; + } + console.warn('unhandled type:', type); + return ''; +}; + +const generateNameNodeZodSchema = (tsVisitor: TsVisitor, schema: GraphQLSchema, node: NameNode): string => { + const typ = schema.getType(node.value); + + if (typ && typ.astNode?.kind === 'InputObjectTypeDefinition') { + const enumName = tsVisitor.convertName(typ.astNode.name.value); + return `${enumName}Schema()`; + } + + if (typ && typ.astNode?.kind === 'EnumTypeDefinition') { + const enumName = tsVisitor.convertName(typ.astNode.name.value); + return `${enumName}Schema`; + } + + return zod4Scalar(tsVisitor, node.value); +}; + +const maybeLazy = (type: TypeNode, schema: string): string => { + if (isNamedType(type) && isInput(type.name.value)) { + return `z.lazy(() => ${schema})`; + } + return schema; +}; + +const zod4Scalar = (tsVisitor: TsVisitor, scalarName: string): string => { + const tsType = tsVisitor.scalars[scalarName]; + switch (tsType) { + case 'string': + return `z.string()`; + case 'number': + return `z.number()`; + case 'boolean': + return `z.boolean()`; + } + console.warn('unhandled name:', scalarName); + return anySchema; +}; diff --git a/tests/zod.spec.ts b/tests/zod.spec.ts new file mode 100644 index 00000000..642a8492 --- /dev/null +++ b/tests/zod.spec.ts @@ -0,0 +1,218 @@ +import { buildSchema } from 'graphql'; +import { plugin } from '../src/index'; + +describe('zod', () => { + test.each([ + [ + 'non-null and defined', + /* GraphQL */ ` + input PrimitiveInput { + a: ID! + b: String! + c: Boolean! + d: Int! + e: Float! + } + `, + [ + 'export function PrimitiveInputSchema(): z.ZodSchema', + 'a: z.string()', + 'b: z.string()', + 'c: z.boolean()', + 'd: z.number()', + 'e: z.number()', + ], + ], + [ + 'nullish', + /* GraphQL */ ` + input PrimitiveInput { + a: ID + b: String + c: Boolean + d: Int + e: Float + z: String! # no defined check + } + `, + [ + 'export function PrimitiveInputSchema(): z.ZodSchema', + // alphabet order + 'a: z.string().nullish(),', + 'b: z.string().nullish(),', + 'c: z.boolean().nullish(),', + 'd: z.number().nullish(),', + 'e: z.number().nullish(),', + ], + ], + [ + 'array', + /* GraphQL */ ` + input ArrayInput { + a: [String] + b: [String!] + c: [String!]! + d: [[String]] + e: [[String]!] + f: [[String]!]! + } + `, + [ + 'export function ArrayInputSchema(): z.ZodSchema', + 'a: z.array(z.string().nullable()).nullish(),', + 'b: z.array(z.string()).nullish(),', + 'c: z.array(z.string()),', + 'd: z.array(z.array(z.string().nullable()).nullish()).nullish(),', + 'e: z.array(z.array(z.string().nullable())).nullish(),', + 'f: z.array(z.array(z.string().nullable()))', + ], + ], + [ + 'ref input object', + /* GraphQL */ ` + input AInput { + b: BInput! + } + input BInput { + c: CInput! + } + input CInput { + a: AInput! + } + `, + [ + 'export function AInputSchema(): z.ZodSchema', + 'b: z.lazy(() => BInputSchema())', + 'export function BInputSchema(): z.ZodSchema', + 'c: z.lazy(() => CInputSchema())', + 'export function CInputSchema(): z.ZodSchema', + 'a: z.lazy(() => AInputSchema())', + ], + ], + [ + 'nested input object', + /* GraphQL */ ` + input NestedInput { + child: NestedInput + childrens: [NestedInput] + } + `, + [ + 'export function NestedInputSchema(): z.ZodSchema', + 'child: z.lazy(() => NestedInputSchema().nullish()),', + 'childrens: z.array(z.lazy(() => NestedInputSchema().nullable())).nullish()', + ], + ], + [ + 'enum', + /* GraphQL */ ` + enum PageType { + PUBLIC + BASIC_AUTH + } + input PageInput { + pageType: PageType! + } + `, + [ + 'export const PageTypeSchema = z.nativeEnum(PageType)', + 'export function PageInputSchema(): z.ZodSchema', + 'pageType: PageTypeSchema', + ], + ], + [ + 'camelcase', + /* GraphQL */ ` + input HTTPInput { + method: HTTPMethod + url: URL! + } + + enum HTTPMethod { + GET + POST + } + + scalar URL # unknown scalar, should be any (definedNonNullAnySchema) + `, + [ + 'export function HttpInputSchema(): z.ZodSchema', + 'export const HttpMethodSchema = z.nativeEnum(HttpMethod)', + 'method: HttpMethodSchema', + 'url: definedNonNullAnySchema', + ], + ], + ])('%s', async (_, textSchema, wantContains) => { + const schema = buildSchema(textSchema); + const result = await plugin(schema, [], { schema: 'zod' }, {}); + expect(result.prepend).toContain("import { z } from 'zod'"); + + for (const wantContain of wantContains) { + expect(result.content).toContain(wantContain); + } + }); + + it('with scalars', async () => { + const schema = buildSchema(/* GraphQL */ ` + input Say { + phrase: Text! + times: Count! + } + + scalar Count + scalar Text + `); + const result = await plugin( + schema, + [], + { + schema: 'zod', + scalars: { + Text: 'string', + Count: 'number', + }, + }, + {} + ); + expect(result.content).toContain('phrase: z.string()'); + expect(result.content).toContain('times: z.number()'); + }); + + it('with importFrom', async () => { + const schema = buildSchema(/* GraphQL */ ` + input Say { + phrase: String! + } + `); + const result = await plugin( + schema, + [], + { + schema: 'zod', + importFrom: './types', + }, + {} + ); + expect(result.prepend).toContain("import { Say } from './types'"); + expect(result.content).toContain('phrase: z.string()'); + }); + + it('with enumsAsTypes', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum PageType { + PUBLIC + BASIC_AUTH + } + `); + const result = await plugin( + schema, + [], + { + schema: 'zod', + enumsAsTypes: true, + }, + {} + ); + expect(result.content).toContain("export const PageTypeSchema = z.enum(['PUBLIC', 'BASIC_AUTH'])"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index dd41427a..85db1042 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,16 +4,15 @@ "incremental": true, "declaration": true, "noEmit": false, - "rootDir": "src", "outDir": "dist", "baseUrl": ".", "paths": {} }, "include": [ - "src" + "src", + "example" ], "exclude": [ - "./example", - "./dist" + "dist" ] } \ No newline at end of file diff --git a/tsconfig.main.json b/tsconfig.main.json index 54a340ab..c33d0b44 100644 --- a/tsconfig.main.json +++ b/tsconfig.main.json @@ -1,9 +1,13 @@ { "extends": "./tsconfig", "compilerOptions": { + "rootDir": "src", "outDir": "dist/main", "types": [ "node" ] - } + }, + "exclude": [ + "example" + ] } \ No newline at end of file diff --git a/tsconfig.module.json b/tsconfig.module.json index 1c9cedf4..5fad3c03 100644 --- a/tsconfig.module.json +++ b/tsconfig.module.json @@ -6,15 +6,14 @@ "declaration": false, "moduleResolution": "node", "resolveJsonModule": true, + "rootDir": "src", "outDir": "dist/module", "types": [ "node" ] }, - "include": [ - "src/**/*.ts" - ], "exclude": [ - "node_modules/**" + "node_modules/**", + "example" ] } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 03a0d6d7..d658fd2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5975,3 +5975,8 @@ yup@^0.32.11: nanoclone "^0.2.1" property-expr "^2.0.4" toposort "^2.0.2" + +zod@^3.11.6: + version "3.11.6" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.11.6.tgz#e43a5e0c213ae2e02aefe7cb2b1a6fa3d7f1f483" + integrity sha512-daZ80A81I3/9lIydI44motWe6n59kRBfNzTuS2bfzVh1nAXi667TOTWWtatxyG+fwgNUiagSj/CWZwRRbevJIg==