diff --git a/README.md b/README.md index 17b96da8..431793e3 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,12 @@ type: `ScalarSchemas` Extends or overrides validation schema for the built-in scalars and custom GraphQL scalars. +### `withObjectType` + +type: `boolean` default: `false` + +Generates validation schema with GraphQL type objects. But excludes `Query`, `Mutation`, `Subscription` objects. + #### yup schema ```yml diff --git a/codegen.yml b/codegen.yml index 00f78617..f56c13f1 100644 --- a/codegen.yml +++ b/codegen.yml @@ -9,7 +9,7 @@ generates: - ./dist/main/index.js: schema: yup importFrom: ../types - useObjectTypes: true + withObjectType: true directives: required: msg: required @@ -43,7 +43,7 @@ generates: - ./dist/main/index.js: schema: zod importFrom: ../types - useObjectTypes: true + withObjectType: true directives: # Write directives like # @@ -64,7 +64,7 @@ generates: - ./dist/main/index.js: schema: myzod importFrom: ../types - useObjectTypes: true + withObjectType: true directives: constraint: minLength: min diff --git a/src/config.ts b/src/config.ts index bc5790d4..de6d0492 100644 --- a/src/config.ts +++ b/src/config.ts @@ -152,6 +152,25 @@ export interface ValidationSchemaPluginConfig extends TypeScriptPluginConfig { * ``` */ scalarSchemas?: ScalarSchemas; + /** + * @description Generates validation schema with GraphQL type objects. + * but excludes "Query", "Mutation", "Subscription" objects. + * + * @exampleMarkdown + * ```yml + * generates: + * path/to/types.ts: + * plugins: + * - typescript + * path/to/schemas.ts: + * plugins: + * - graphql-codegen-validation-schema + * config: + * schema: yup + * withObjectType: true + * ``` + */ + withObjectType?: boolean; /** * @description Generates validation schema with more API based on directive schema. * @exampleMarkdown @@ -193,22 +212,4 @@ export interface ValidationSchemaPluginConfig extends TypeScriptPluginConfig { * ``` */ directives?: DirectiveConfig; - /** - * @description Converts the regular graphql type into a zod validation function. - * - * @exampleMarkdown - * ```yml - * generates: - * path/to/types.ts: - * plugins: - * - typescript - * path/to/schemas.ts: - * plugins: - * - graphql-codegen-validation-schema - * config: - * schema: yup - * useObjectTypes: true - * ``` - */ - useObjectTypes?: boolean; } diff --git a/src/graphql.ts b/src/graphql.ts index 871d60af..535f2fb9 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -1,7 +1,22 @@ -import { ListTypeNode, NonNullTypeNode, NamedTypeNode, TypeNode } from 'graphql'; +import { ListTypeNode, NonNullTypeNode, NamedTypeNode, TypeNode, ObjectTypeDefinitionNode } from 'graphql'; export const isListType = (typ?: TypeNode): typ is ListTypeNode => typ?.kind === 'ListType'; export const isNonNullType = (typ?: TypeNode): typ is NonNullTypeNode => typ?.kind === 'NonNullType'; export const isNamedType = (typ?: TypeNode): typ is NamedTypeNode => typ?.kind === 'NamedType'; export const isInput = (kind: string) => kind.includes('Input'); + +type ObjectTypeDefinitionFn = (node: ObjectTypeDefinitionNode) => any; + +export const ObjectTypeDefinitionBuilder = ( + useObjectTypes: boolean | undefined, + callback: ObjectTypeDefinitionFn +): ObjectTypeDefinitionFn | undefined => { + if (!useObjectTypes) return undefined; + return node => { + if (/^Query|Mutation|Subscription$/.test(node.name.value)) { + return; + } + return callback(node); + }; +}; diff --git a/src/myzod/index.ts b/src/myzod/index.ts index 9730fcf0..fd975454 100644 --- a/src/myzod/index.ts +++ b/src/myzod/index.ts @@ -1,4 +1,4 @@ -import { isInput, isNonNullType, isListType, isNamedType } from './../graphql'; +import { isInput, isNonNullType, isListType, isNamedType, ObjectTypeDefinitionBuilder } from './../graphql'; import { ValidationSchemaPluginConfig } from '../config'; import { InputValueDefinitionNode, @@ -49,8 +49,7 @@ export const MyZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSche .withName(`${name}Schema(): myzod.Type<${name}>`) .withBlock([indent(`return myzod.object({`), shape, indent('})')].join('\n')).string; }, - ObjectTypeDefinition: (node: ObjectTypeDefinitionNode) => { - if (!config.useObjectTypes) return; + ObjectTypeDefinition: ObjectTypeDefinitionBuilder(config.withObjectType, (node: ObjectTypeDefinitionNode) => { const name = tsVisitor.convertName(node.name.value); importTypes.push(name); @@ -65,11 +64,12 @@ export const MyZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSche .withBlock( [ indent(`return myzod.object({`), - ` __typename: myzod.literal('${node.name.value}').optional(),\n${shape}`, + indent(`__typename: myzod.literal('${node.name.value}').optional(),`, 2), + shape, indent('})'), ].join('\n') ).string; - }, + }), EnumTypeDefinition: (node: EnumTypeDefinitionNode) => { const enumname = tsVisitor.convertName(node.name.value); importTypes.push(enumname); @@ -166,12 +166,17 @@ const generateNameNodeMyZodSchema = ( ): string => { const typ = schema.getType(node.value); - if (typ && typ.astNode?.kind === 'InputObjectTypeDefinition') { + if (typ?.astNode?.kind === 'InputObjectTypeDefinition') { + const enumName = tsVisitor.convertName(typ.astNode.name.value); + return `${enumName}Schema()`; + } + + if (typ?.astNode?.kind === 'ObjectTypeDefinition') { const enumName = tsVisitor.convertName(typ.astNode.name.value); return `${enumName}Schema()`; } - if (typ && typ.astNode?.kind === 'EnumTypeDefinition') { + if (typ?.astNode?.kind === 'EnumTypeDefinition') { const enumName = tsVisitor.convertName(typ.astNode.name.value); return `${enumName}Schema`; } diff --git a/src/yup/index.ts b/src/yup/index.ts index 08bd20d5..807ac82c 100644 --- a/src/yup/index.ts +++ b/src/yup/index.ts @@ -1,4 +1,4 @@ -import { isInput, isNonNullType, isListType, isNamedType } from './../graphql'; +import { isInput, isNonNullType, isListType, isNamedType, ObjectTypeDefinitionBuilder } from './../graphql'; import { ValidationSchemaPluginConfig } from '../config'; import { InputValueDefinitionNode, @@ -41,8 +41,7 @@ export const YupSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema .withName(`${name}Schema(): yup.SchemaOf<${name}>`) .withBlock([indent(`return yup.object({`), shape, indent('})')].join('\n')).string; }, - ObjectTypeDefinition: (node: ObjectTypeDefinitionNode) => { - if (!config.useObjectTypes) return; + ObjectTypeDefinition: ObjectTypeDefinitionBuilder(config.withObjectType, (node: ObjectTypeDefinitionNode) => { const name = tsVisitor.convertName(node.name.value); importTypes.push(name); @@ -55,11 +54,12 @@ export const YupSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema .withBlock( [ indent(`return yup.object({`), - ` __typename: yup.mixed().oneOf(['${node.name.value}', undefined]),\n${shape}`, + indent(`__typename: yup.mixed().oneOf(['${node.name.value}', undefined]),`, 2), + shape, indent('})'), ].join('\n') ).string; - }, + }), EnumTypeDefinition: (node: EnumTypeDefinitionNode) => { const enumname = tsVisitor.convertName(node.name.value); importTypes.push(enumname); @@ -146,7 +146,12 @@ const generateFieldTypeYupSchema = ( return maybeLazy(type.type, nonNullGen); } if (isNamedType(type)) { - return generateNameNodeYupSchema(config, tsVisitor, schema, type.name); + const gen = generateNameNodeYupSchema(config, tsVisitor, schema, type.name); + const typ = schema.getType(type.name.value); + if (typ?.astNode?.kind === 'ObjectTypeDefinition') { + return `${gen}.optional()`; + } + return gen; } console.warn('unhandled type:', type); return ''; @@ -160,12 +165,17 @@ const generateNameNodeYupSchema = ( ): string => { const typ = schema.getType(node.value); - if (typ && typ.astNode?.kind === 'InputObjectTypeDefinition') { + if (typ?.astNode?.kind === 'InputObjectTypeDefinition') { + const enumName = tsVisitor.convertName(typ.astNode.name.value); + return `${enumName}Schema()`; + } + + if (typ?.astNode?.kind === 'ObjectTypeDefinition') { const enumName = tsVisitor.convertName(typ.astNode.name.value); return `${enumName}Schema()`; } - if (typ && typ.astNode?.kind === 'EnumTypeDefinition') { + if (typ?.astNode?.kind === 'EnumTypeDefinition') { const enumName = tsVisitor.convertName(typ.astNode.name.value); return `${enumName}Schema`; } diff --git a/src/zod/index.ts b/src/zod/index.ts index cadcca91..093578fc 100644 --- a/src/zod/index.ts +++ b/src/zod/index.ts @@ -1,4 +1,4 @@ -import { isInput, isNonNullType, isListType, isNamedType } from './../graphql'; +import { isInput, isNonNullType, isListType, isNamedType, ObjectTypeDefinitionBuilder } from './../graphql'; import { ValidationSchemaPluginConfig } from '../config'; import { InputValueDefinitionNode, @@ -63,8 +63,7 @@ export const ZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema .withName(`${name}Schema(): z.ZodObject>`) .withBlock([indent(`return z.object({`), shape, indent('})')].join('\n')).string; }, - ObjectTypeDefinition: (node: ObjectTypeDefinitionNode) => { - if (!config.useObjectTypes) return; + ObjectTypeDefinition: ObjectTypeDefinitionBuilder(config.withObjectType, (node: ObjectTypeDefinitionNode) => { const name = tsVisitor.convertName(node.name.value); importTypes.push(name); @@ -77,11 +76,12 @@ export const ZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema .withBlock( [ indent(`return z.object({`), - ` __typename: z.literal('${node.name.value}').optional(),\n${shape}`, + indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), + shape, indent('})'), ].join('\n') ).string; - }, + }), EnumTypeDefinition: (node: EnumTypeDefinitionNode) => { const enumname = tsVisitor.convertName(node.name.value); importTypes.push(enumname); @@ -177,12 +177,17 @@ const generateNameNodeZodSchema = ( ): string => { const typ = schema.getType(node.value); - if (typ && typ.astNode?.kind === 'InputObjectTypeDefinition') { + if (typ?.astNode?.kind === 'InputObjectTypeDefinition') { + const enumName = tsVisitor.convertName(typ.astNode.name.value); + return `${enumName}Schema()`; + } + + if (typ?.astNode?.kind === 'ObjectTypeDefinition') { const enumName = tsVisitor.convertName(typ.astNode.name.value); return `${enumName}Schema()`; } - if (typ && typ.astNode?.kind === 'EnumTypeDefinition') { + if (typ?.astNode?.kind === 'EnumTypeDefinition') { const enumName = tsVisitor.convertName(typ.astNode.name.value); return `${enumName}Schema`; } @@ -210,6 +215,6 @@ const zod4Scalar = (config: ValidationSchemaPluginConfig, tsVisitor: TsVisitor, case 'boolean': return `z.boolean()`; } - console.warn('unhandled name:', scalarName); + console.warn('unhandled scalar name:', scalarName); return anySchema; }; diff --git a/tests/myzod.spec.ts b/tests/myzod.spec.ts index 7a1a5c4a..4ec29034 100644 --- a/tests/myzod.spec.ts +++ b/tests/myzod.spec.ts @@ -411,29 +411,14 @@ describe('myzod', () => { }); }); - describe('GraphQl Type Support', () => { - const schema = buildSchema(/* GraphQL */ ` - input ScalarsInput { - date: Date! - email: Email - } - scalar Date - scalar Email - input UserCreateInput { - name: String! - email: Email! - } - type User { - id: ID! - name: String - age: Int - email: Email - isMember: Boolean - createdAt: Date! - } - `); - - it('not generate if useObjectTypes false', async () => { + describe('with withObjectType', () => { + it('not generate if withObjectType false', async () => { + const schema = buildSchema(/* GraphQL */ ` + type User { + id: ID! + name: String + } + `); const result = await plugin( schema, [], @@ -445,13 +430,83 @@ describe('myzod', () => { expect(result.content).not.toContain('export function UserSchema(): myzod.Type {'); }); + it('generate object type contains object type', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Book { + author: Author + title: String + } + + type Author { + books: [Book] + name: String + } + `); + const result = await plugin( + schema, + [], + { + schema: 'myzod', + withObjectType: true, + }, + {} + ); + const wantContains = [ + 'export function AuthorSchema(): myzod.Type {', + "__typename: myzod.literal('Author').optional(),", + 'books: myzod.array(BookSchema().nullable()).optional().nullable(),', + 'name: myzod.string().optional().nullable()', + + 'export function BookSchema(): myzod.Type {', + "__typename: myzod.literal('Book').optional(),", + 'author: AuthorSchema().optional().nullable(),', + 'title: myzod.string().optional().nullable()', + ]; + for (const wantContain of wantContains) { + expect(result.content).toContain(wantContain); + } + + for (const wantNotContain of ['Query', 'Mutation', 'Subscription']) { + expect(result.content).not.toContain(wantNotContain); + } + }); + it('generate both input & type', async () => { + const schema = buildSchema(/* GraphQL */ ` + scalar Date + scalar Email + input UserCreateInput { + name: String! + date: Date! + email: Email! + } + type User { + id: ID! + name: String + age: Int + email: Email + isMember: Boolean + createdAt: Date! + } + + type Mutation { + _empty: String + } + + type Query { + _empty: String + } + + type Subscription { + _empty: String + } + `); const result = await plugin( schema, [], { schema: 'myzod', - useObjectTypes: true, + withObjectType: true, scalarSchemas: { Date: 'myzod.date()', Email: 'myzod.string().email()', @@ -460,14 +515,10 @@ describe('myzod', () => { {} ); const wantContains = [ - // ScalarsInput - 'export const definedNonNullAnySchema = myzod.object({});', - 'export function ScalarsInputSchema(): myzod.Type {', - 'date: myzod.date(),', - 'email: myzod.string().email().optional().nullable()', // User Create Input 'export function UserCreateInputSchema(): myzod.Type {', 'name: myzod.string(),', + 'date: myzod.date(),', 'email: myzod.string().email()', // User 'export function UserSchema(): myzod.Type {', @@ -482,6 +533,10 @@ describe('myzod', () => { for (const wantContain of wantContains) { expect(result.content).toContain(wantContain); } + + for (const wantNotContain of ['Query', 'Mutation', 'Subscription']) { + expect(result.content).not.toContain(wantNotContain); + } }); }); }); diff --git a/tests/yup.spec.ts b/tests/yup.spec.ts index 3b87bc06..975d40e8 100644 --- a/tests/yup.spec.ts +++ b/tests/yup.spec.ts @@ -313,29 +313,14 @@ describe('yup', () => { expect(result.prepend).toContain("import { SayI } from './types'"); expect(result.content).toContain('export function SayISchema(): yup.SchemaOf {'); }); - describe('GraphQl Type Support', () => { - const schema = buildSchema(/* GraphQL */ ` - input ScalarsInput { - date: Date! - email: Email - } - scalar Date - scalar Email - input UserCreateInput { - name: String! - email: Email! - } - type User { - id: ID! - name: String - age: Int - email: Email - isMember: Boolean - createdAt: Date! - } - `); - - it('not generate if useObjectTypes false', async () => { + describe('with withObjectType', () => { + it('not generate if withObjectType false', async () => { + const schema = buildSchema(/* GraphQL */ ` + type User { + id: ID! + name: String + } + `); const result = await plugin( schema, [], @@ -347,13 +332,94 @@ describe('yup', () => { expect(result.content).not.toContain('export function UserSchema(): yup.SchemaOf {'); }); - it('generate both input & type if useObjectTypes true', async () => { + it('generate object type contains object type', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Book { + author: Author + title: String + } + + type Book2 { + author: Author! + title: String! + } + + type Author { + books: [Book] + name: String + } + `); + const result = await plugin( + schema, + [], + { + schema: 'yup', + withObjectType: true, + }, + {} + ); + const wantContains = [ + 'export function AuthorSchema(): yup.SchemaOf {', + "__typename: yup.mixed().oneOf(['Author', undefined]),", + 'books: yup.array().of(BookSchema().optional()).optional(),', + 'name: yup.string()', + + 'export function BookSchema(): yup.SchemaOf {', + "__typename: yup.mixed().oneOf(['Book', undefined]),", + 'author: AuthorSchema().optional(),', + 'title: yup.string()', + + 'export function Book2Schema(): yup.SchemaOf {', + "__typename: yup.mixed().oneOf(['Book2', undefined]),", + 'author: AuthorSchema().optional().defined(),', + 'title: yup.string().defined()', + ]; + for (const wantContain of wantContains) { + expect(result.content).toContain(wantContain); + } + + for (const wantNotContain of ['Query', 'Mutation', 'Subscription']) { + expect(result.content).not.toContain(wantNotContain); + } + }); + + it('generate both input & type if withObjectType true', async () => { + const schema = buildSchema(/* GraphQL */ ` + scalar Date + scalar Email + input UserCreateInput { + name: String! + date: Date! + email: Email! + } + type User { + id: ID! + name: String + age: Int + email: Email + isMember: Boolean + createdAt: Date! + } + + type Mutation { + _empty: String + } + + type Query { + _empty: String + } + + type Subscription { + _empty: String + } + `); + const result = await plugin( schema, [], { schema: 'yup', - useObjectTypes: true, + withObjectType: true, scalarSchemas: { Date: 'yup.date()', Email: 'yup.string().email()', @@ -362,28 +428,28 @@ describe('yup', () => { {} ); const wantContains = [ - // ScalarsInput - 'export function ScalarsInputSchema(): yup.SchemaOf {', - 'return yup.object({', - 'date: yup.date().defined()', - 'email: yup.string().email()', // User Create Input 'export function UserCreateInputSchema(): yup.SchemaOf {', - 'name: yup.string().defined()', + 'name: yup.string().defined(),', + 'date: yup.date().defined(),', 'email: yup.string().email().defined()', // User 'export function UserSchema(): yup.SchemaOf {', - "__typename: yup.mixed().oneOf(['User', undefined])", - 'id: yup.string().defined()', - 'name: yup.string()', - 'age: yup.number()', - 'isMember: yup.boolean()', - 'email: yup.string().email()', + "__typename: yup.mixed().oneOf(['User', undefined]),", + 'id: yup.string().defined(),', + 'name: yup.string(),', + 'age: yup.number(),', + 'isMember: yup.boolean(),', + 'email: yup.string().email(),', 'createdAt: yup.date().defined()', ]; for (const wantContain of wantContains) { expect(result.content).toContain(wantContain); } + + for (const wantNotContain of ['Query', 'Mutation', 'Subscription']) { + expect(result.content).not.toContain(wantNotContain); + } }); }); }); diff --git a/tests/zod.spec.ts b/tests/zod.spec.ts index 86fd7bac..f9f3fe41 100644 --- a/tests/zod.spec.ts +++ b/tests/zod.spec.ts @@ -476,7 +476,7 @@ describe('zod', () => { } }); }); - describe('GraphQl Type Support', () => { + describe('with withObjectType', () => { const schema = buildSchema(/* GraphQL */ ` input ScalarsInput { date: Date! @@ -496,9 +496,27 @@ describe('zod', () => { isMember: Boolean createdAt: Date! } + + type Mutation { + _empty: String + } + + type Query { + _empty: String + } + + type Subscription { + _empty: String + } `); - it('not generate if useObjectTypes false', async () => { + it('not generate if withObjectType false', async () => { + const schema = buildSchema(/* GraphQL */ ` + type User { + id: ID! + name: String + } + `); const result = await plugin( schema, [], @@ -510,13 +528,83 @@ describe('zod', () => { expect(result.content).not.toContain('export function UserSchema(): z.ZodObject>'); }); + it('generate object type contains object type', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Book { + author: Author + title: String + } + + type Author { + books: [Book] + name: String + } + `); + const result = await plugin( + schema, + [], + { + schema: 'zod', + withObjectType: true, + }, + {} + ); + const wantContains = [ + 'export function AuthorSchema(): z.ZodObject> {', + "__typename: z.literal('Author').optional(),", + 'books: z.array(BookSchema().nullable()).nullish(),', + 'name: z.string().nullish()', + + 'export function BookSchema(): z.ZodObject> {', + "__typename: z.literal('Book').optional(),", + 'author: AuthorSchema().nullish(),', + 'title: z.string().nullish()', + ]; + for (const wantContain of wantContains) { + expect(result.content).toContain(wantContain); + } + + for (const wantNotContain of ['Query', 'Mutation', 'Subscription']) { + expect(result.content).not.toContain(wantNotContain); + } + }); + it('generate both input & type', async () => { + const schema = buildSchema(/* GraphQL */ ` + scalar Date + scalar Email + input UserCreateInput { + name: String! + date: Date! + email: Email! + } + type User { + id: ID! + name: String + age: Int + email: Email + isMember: Boolean + createdAt: Date! + } + + type Mutation { + _empty: String + } + + type Query { + _empty: String + } + + type Subscription { + _empty: String + } + `); const result = await plugin( schema, [], { schema: 'zod', - useObjectTypes: true, + withObjectType: true, scalarSchemas: { Date: 'z.date()', Email: 'z.string().email()', @@ -525,28 +613,28 @@ describe('zod', () => { {} ); const wantContains = [ - // ScalarsInput - 'export function ScalarsInputSchema(): z.ZodObject> {', - 'return z.object({', - 'date: z.date()', - 'email: z.string().email().nullish()', // User Create Input 'export function UserCreateInputSchema(): z.ZodObject> {', - 'name: z.string()', + 'name: z.string(),', + 'date: z.date(),', 'email: z.string().email()', // User 'export function UserSchema(): z.ZodObject> {', "__typename: z.literal('User').optional()", - 'id: z.string()', + 'id: z.string(),', 'name: z.string().nullish(),', - 'age: z.number().nullish()', - 'isMember: z.boolean().nullish()', - 'email: z.string().email().nullish()', + 'age: z.number().nullish(),', + 'isMember: z.boolean().nullish(),', + 'email: z.string().email().nullish(),', 'createdAt: z.date()', ]; for (const wantContain of wantContains) { expect(result.content).toContain(wantContain); } + + for (const wantNotContain of ['Query', 'Mutation', 'Subscription']) { + expect(result.content).not.toContain(wantNotContain); + } }); }); });