diff --git a/package.json b/package.json index 7e98e646..0d8d88f4 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@typescript-eslint/parser": "^5.10.0", "eslint": "^8.7.0", "jest": "^27.4.7", + "myzod": "^1.8.7", "npm-run-all": "^4.1.5", "prettier": "2.5.1", "ts-jest": "^27.1.3", diff --git a/src/config.ts b/src/config.ts index 8f894bbb..fd6503ed 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import { TypeScriptPluginConfig } from '@graphql-codegen/typescript'; -export type ValidationSchema = 'yup' | 'zod'; +export type ValidationSchema = 'yup' | 'zod' | 'myzod'; export interface DirectiveConfig { [directive: string]: { diff --git a/src/index.ts b/src/index.ts index 291c10bd..5a3c8e1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { ZodSchemaVisitor } from './zod/index'; +import { MyZodSchemaVisitor } from './myzod/index'; import { transformSchemaAST } from '@graphql-codegen/schema-ast'; import { YupSchemaVisitor } from './yup/index'; import { ValidationSchemaPluginConfig } from './config'; @@ -30,6 +31,8 @@ export const plugin: PluginFunction { if (config?.schema === 'zod') { return ZodSchemaVisitor(schema, config); + } else if (config?.schema === 'myzod') { + return MyZodSchemaVisitor(schema, config); } return YupSchemaVisitor(schema, config); }; diff --git a/src/myzod/index.ts b/src/myzod/index.ts new file mode 100644 index 00000000..3612128e --- /dev/null +++ b/src/myzod/index.ts @@ -0,0 +1,205 @@ +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 myzod from 'myzod'`; +const anySchema = `definedNonNullAnySchema`; + +export const MyZodSchemaVisitor = (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' + + [ + /* + * MyZod allows you to create typed objects with `myzod.Type` + * See https://www.npmjs.com/package/myzod#lazy + new DeclarationBlock({}) + .asKind('type') + .withName('Properties') + .withContent(['Required<{', ' [K in keyof T]: z.ZodType;', '}>'].join('\n')).string, + */ + /* + * MyZod allows empty object hence no need for these hacks + * See https://www.npmjs.com/package/myzod#object + // 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}`) + .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 => generateInputObjectFieldMyZodSchema(config, tsVisitor, schema, field, 2)) + .join(',\n'); + + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: myzod.Type<${name}>`) //TODO: Test this + .withBlock([indent(`myzod.object({`), shape, indent('})')].join('\n')).string; + }, + EnumTypeDefinition: (node: EnumTypeDefinitionNode) => { + const enumname = tsVisitor.convertName(node.name.value); + importTypes.push(enumname); + // z.enum are basically myzod.literals + if (config.enumsAsTypes) { + return new DeclarationBlock({}) + .export() + .asKind('type') + .withName(`${enumname}Schema`) + .withContent(`myzod.literals(${node.values?.map(enumOption => `'${enumOption.name.value}'`).join(', ')})`) + .string; + } + + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${enumname}Schema`) + .withContent(`myzod.enum(${enumname})`).string; + }, + }; +}; + +const generateInputObjectFieldMyZodSchema = ( + config: ValidationSchemaPluginConfig, + tsVisitor: TsVisitor, + schema: GraphQLSchema, + field: InputValueDefinitionNode, + indentCount: number +): string => { + const gen = generateInputObjectFieldTypeMyZodSchema(config, tsVisitor, schema, field, field.type); + return indent(`${field.name.value}: ${maybeLazy(field.type, gen)}`, indentCount); +}; + +const generateInputObjectFieldTypeMyZodSchema = ( + config: ValidationSchemaPluginConfig, + tsVisitor: TsVisitor, + schema: GraphQLSchema, + field: InputValueDefinitionNode, + type: TypeNode, + parentType?: TypeNode +): string => { + if (isListType(type)) { + const gen = generateInputObjectFieldTypeMyZodSchema(config, tsVisitor, schema, field, type.type, type); + if (!isNonNullType(parentType)) { + const arrayGen = `myzod.array(${maybeLazy(type.type, gen)})`; + const maybeLazyGen = applyDirectives(config, field, arrayGen); + return `${maybeLazyGen}.optional().nullable()`; + } + return `myzod.array(${maybeLazy(type.type, gen)})`; + } + if (isNonNullType(type)) { + const gen = generateInputObjectFieldTypeMyZodSchema(config, tsVisitor, schema, field, type.type, type); + return maybeLazy(type.type, gen); + } + if (isNamedType(type)) { + const gen = generateNameNodeMyZodSchema(config, tsVisitor, schema, type.name); + if (isListType(parentType)) { + return `${gen}.nullable()`; + } + const appliedDirectivesGen = applyDirectives(config, field, gen); + if (isNonNullType(parentType)) { + if (config.notAllowEmptyString === true) { + const tsType = tsVisitor.scalars[type.name.value]; + if (tsType === 'string') return `${gen}.min(1)`; + } + return appliedDirectivesGen; + } + if (isListType(parentType)) { + return `${appliedDirectivesGen}.nullable()`; + } + return `${appliedDirectivesGen}.optional().nullable()`; + } + console.warn('unhandled type:', type); + return ''; +}; + +const applyDirectives = ( + config: ValidationSchemaPluginConfig, + field: InputValueDefinitionNode, + gen: string +): string => { + if (config.directives && field.directives) { + const formatted = formatDirectiveConfig(config.directives); + return gen + buildApi(formatted, field.directives); + } + return gen; +}; + +const generateNameNodeMyZodSchema = ( + config: ValidationSchemaPluginConfig, + 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(config, tsVisitor, node.value); +}; + +const maybeLazy = (type: TypeNode, schema: string): string => { + if (isNamedType(type) && isInput(type.name.value)) { + return `myzod.lazy(() => ${schema})`; + } + return schema; +}; + +const zod4Scalar = (config: ValidationSchemaPluginConfig, tsVisitor: TsVisitor, scalarName: string): string => { + if (config.scalarSchemas?.[scalarName]) { + return config.scalarSchemas[scalarName]; + } + const tsType = tsVisitor.scalars[scalarName]; + switch (tsType) { + case 'string': + return `myzod.string()`; + case 'number': + return `myzod.number()`; + case 'boolean': + return `myzod.boolean()`; + } + console.warn('unhandled name:', scalarName); + return anySchema; +}; diff --git a/src/zod/index.ts b/src/zod/index.ts index 21b90747..e9e7ee68 100644 --- a/src/zod/index.ts +++ b/src/zod/index.ts @@ -54,7 +54,7 @@ export const ZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema importTypes.push(name); const shape = node.fields - ?.map(field => generateInputObjectFieldYupSchema(config, tsVisitor, schema, field, 2)) + ?.map(field => generateInputObjectFieldZodSchema(config, tsVisitor, schema, field, 2)) .join(',\n'); return new DeclarationBlock({}) @@ -84,7 +84,7 @@ export const ZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema }; }; -const generateInputObjectFieldYupSchema = ( +const generateInputObjectFieldZodSchema = ( config: ValidationSchemaPluginConfig, tsVisitor: TsVisitor, schema: GraphQLSchema, diff --git a/tests/myzod.spec.ts b/tests/myzod.spec.ts new file mode 100644 index 00000000..b9af0d9f --- /dev/null +++ b/tests/myzod.spec.ts @@ -0,0 +1,375 @@ +import { buildSchema } from 'graphql'; +import { plugin } from '../src/index'; + +describe('myzod', () => { + test.each([ + [ + 'non-null and defined', + /* GraphQL */ ` + input PrimitiveInput { + a: ID! + b: String! + c: Boolean! + d: Int! + e: Float! + } + `, + [ + 'export const PrimitiveInputSchema: myzod.Type', + 'a: myzod.string()', + 'b: myzod.string()', + 'c: myzod.boolean()', + 'd: myzod.number()', + 'e: myzod.number()', + ], + ], + [ + 'nullish', + /* GraphQL */ ` + input PrimitiveInput { + a: ID + b: String + c: Boolean + d: Int + e: Float + z: String! # no defined check + } + `, + [ + 'export const PrimitiveInputSchema: myzod.Type', + // alphabet order + 'a: myzod.string().optional().nullable(),', + 'b: myzod.string().optional().nullable(),', + 'c: myzod.boolean().optional().nullable(),', + 'd: myzod.number().optional().nullable(),', + 'e: myzod.number().optional().nullable(),', + ], + ], + [ + 'array', + /* GraphQL */ ` + input ArrayInput { + a: [String] + b: [String!] + c: [String!]! + d: [[String]] + e: [[String]!] + f: [[String]!]! + } + `, + [ + 'export const ArrayInputSchema: myzod.Type', + 'a: myzod.array(myzod.string().nullable()).optional().nullable(),', + 'b: myzod.array(myzod.string()).optional().nullable(),', + 'c: myzod.array(myzod.string()),', + 'd: myzod.array(myzod.array(myzod.string().nullable()).optional().nullable()).optional().nullable(),', + 'e: myzod.array(myzod.array(myzod.string().nullable())).optional().nullable(),', + 'f: myzod.array(myzod.array(myzod.string().nullable()))', + ], + ], + [ + 'ref input object', + /* GraphQL */ ` + input AInput { + b: BInput! + } + input BInput { + c: CInput! + } + input CInput { + a: AInput! + } + `, + [ + 'export const AInputSchema: myzod.Type', + 'b: myzod.lazy(() => BInputSchema())', + 'export const BInputSchema: myzod.Type', + 'c: myzod.lazy(() => CInputSchema())', + 'export const CInputSchema: myzod.Type', + 'a: myzod.lazy(() => AInputSchema())', + ], + ], + [ + 'nested input object', + /* GraphQL */ ` + input NestedInput { + child: NestedInput + childrens: [NestedInput] + } + `, + [ + 'export const NestedInputSchema: myzod.Type', + 'child: myzod.lazy(() => NestedInputSchema().optional().nullable()),', + 'childrens: myzod.array(myzod.lazy(() => NestedInputSchema().nullable())).optional().nullable()', + ], + ], + [ + 'enum', + /* GraphQL */ ` + enum PageType { + PUBLIC + BASIC_AUTH + } + input PageInput { + pageType: PageType! + } + `, + [ + 'export const PageTypeSchema = myzod.enum(PageType)', + 'export const PageInputSchema: myzod.Type', + 'pageType: PageTypeSchema', + ], + ], + [ + 'camelcase', + /* GraphQL */ ` + input HTTPInput { + method: HTTPMethod + url: URL! + } + + enum HTTPMethod { + GET + POST + } + + scalar URL # unknown scalar, should be any (definedNonNullAnySchema) + `, + [ + 'export const HttpInputSchema: myzod.Type', + 'export const HttpMethodSchema = myzod.enum(HttpMethod)', + 'method: HttpMethodSchema', + 'url: definedNonNullAnySchema', + ], + ], + ])('%s', async (_, textSchema, wantContains) => { + const schema = buildSchema(textSchema); + const result = await plugin(schema, [], { schema: 'myzod' }, {}); + expect(result.prepend).toContain("import myzod from 'myzod'"); + + 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: 'myzod', + scalars: { + Text: 'string', + Count: 'number', + }, + }, + {} + ); + expect(result.content).toContain('phrase: myzod.string()'); + expect(result.content).toContain('times: myzod.number()'); + }); + + it('with importFrom', async () => { + const schema = buildSchema(/* GraphQL */ ` + input Say { + phrase: String! + } + `); + const result = await plugin( + schema, + [], + { + schema: 'myzod', + importFrom: './types', + }, + {} + ); + expect(result.prepend).toContain("import { Say } from './types'"); + expect(result.content).toContain('phrase: myzod.string()'); + }); + + it('with enumsAsTypes', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum PageType { + PUBLIC + BASIC_AUTH + } + `); + const result = await plugin( + schema, + [], + { + schema: 'myzod', + enumsAsTypes: true, + }, + {} + ); + expect(result.content).toContain("export type PageTypeSchema = myzod.literals('PUBLIC', 'BASIC_AUTH')"); + }); + + it('with notAllowEmptyString', async () => { + const schema = buildSchema(/* GraphQL */ ` + input PrimitiveInput { + a: ID! + b: String! + c: Boolean! + d: Int! + e: Float! + } + `); + const result = await plugin( + schema, + [], + { + schema: 'myzod', + notAllowEmptyString: true, + }, + {} + ); + const wantContains = [ + 'export const PrimitiveInputSchema: myzod.Type', + 'a: myzod.string().min(1),', + 'b: myzod.string().min(1),', + 'c: myzod.boolean(),', + 'd: myzod.number(),', + 'e: myzod.number()', + ]; + for (const wantContain of wantContains) { + expect(result.content).toContain(wantContain); + } + }); + + it('with scalarSchemas', async () => { + const schema = buildSchema(/* GraphQL */ ` + input ScalarsInput { + date: Date! + email: Email + str: String! + } + scalar Date + scalar Email + `); + const result = await plugin( + schema, + [], + { + schema: 'myzod', + scalarSchemas: { + Date: 'myzod.date()', + Email: 'myzod.string()', // generate the basic type. User can later extend it using `withPredicate(fn: (val: string) => boolean), errMsg?: string }` + }, + }, + {} + ); + const wantContains = [ + 'export const ScalarsInputSchema: myzod.Type', + 'date: myzod.date(),', + 'email: myzod.string()', // TODO: Test implementation + 'str: myzod.string()', + ]; + for (const wantContain of wantContains) { + expect(result.content).toContain(wantContain); + } + }); + describe('issues #19', () => { + it('string field', async () => { + const schema = buildSchema(/* GraphQL */ ` + input UserCreateInput { + profile: String @constraint(minLength: 1, maxLength: 5000) + } + + directive @constraint(minLength: Int!, maxLength: Int!) on INPUT_FIELD_DEFINITION + `); + const result = await plugin( + schema, + [], + { + schema: 'myzod', + directives: { + constraint: { + minLength: ['min', '$1', 'Please input more than $1'], + maxLength: ['max', '$1', 'Please input less than $1'], + }, + }, + }, + {} + ); + const wantContains = [ + 'export const UserCreateInputSchema: myzod.Type', + 'profile: myzod.string().min(1, "Please input more than 1").max(5000, "Please input less than 5000").optional().nullable()', + ]; + for (const wantContain of wantContains) { + expect(result.content).toContain(wantContain); + } + }); + it('not null field', async () => { + const schema = buildSchema(/* GraphQL */ ` + input UserCreateInput { + profile: String! @constraint(minLength: 1, maxLength: 5000) + } + + directive @constraint(minLength: Int!, maxLength: Int!) on INPUT_FIELD_DEFINITION + `); + const result = await plugin( + schema, + [], + { + schema: 'myzod', + directives: { + constraint: { + minLength: ['min', '$1', 'Please input more than $1'], + maxLength: ['max', '$1', 'Please input less than $1'], + }, + }, + }, + {} + ); + const wantContains = [ + 'export const UserCreateInputSchema: myzod.Type', + 'profile: myzod.string().min(1, "Please input more than 1").max(5000, "Please input less than 5000")', + ]; + for (const wantContain of wantContains) { + expect(result.content).toContain(wantContain); + } + }); + it('list field', async () => { + const schema = buildSchema(/* GraphQL */ ` + input UserCreateInput { + profile: [String] @constraint(minLength: 1, maxLength: 5000) + } + + directive @constraint(minLength: Int!, maxLength: Int!) on INPUT_FIELD_DEFINITION + `); + const result = await plugin( + schema, + [], + { + schema: 'myzod', + directives: { + constraint: { + minLength: ['min', '$1', 'Please input more than $1'], + maxLength: ['max', '$1', 'Please input less than $1'], + }, + }, + }, + {} + ); + const wantContains = [ + 'export const UserCreateInputSchema: myzod.Type', + 'profile: myzod.array(myzod.string().nullable()).min(1, "Please input more than 1").max(5000, "Please input less than 5000").optional().nullable()', + ]; + for (const wantContain of wantContains) { + expect(result.content).toContain(wantContain); + } + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index d658fd2f..8e1383ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4321,6 +4321,11 @@ mute-stream@0.0.8: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== +myzod@^1.8.7: + version "1.8.7" + resolved "https://registry.yarnpkg.com/myzod/-/myzod-1.8.7.tgz#f7e1d5379b517de039729735a8578a550c562292" + integrity sha512-H/Nmst+ZIGQppKVeOq6ufieRnnK0u+UfDLgCrG1Rtn6W/GzMoz6Ur9/iLBwB0N8s6ZE/4hhbRTUt3Pg7nH4X2Q== + nanoclone@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4"