diff --git a/README.md b/README.md index f07ab5f3..a9a19b69 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,47 @@ Uses the full path of the enum type as the default value instead of the stringif Related: https://the-guild.dev/graphql/codegen/docs/config-reference/naming-convention#namingconvention +### `withDescriptions` + +type: `boolean` default: `false` + +Generates `.describe()` calls on Zod schemas using GraphQL descriptions. When enabled, any GraphQL type or field that has a description comment will have `.describe('...')` appended to its generated Zod schema. + +Only applies when `schema` is set to `zod` or `zodv4`. + +```yml +generates: + path/to/graphql.ts: + plugins: + - typescript + - typescript-validation-schema + config: + schema: zod + withDescriptions: true +``` + +For the following GraphQL schema: + +```graphql +"""A user input""" +input UserInput { + """The user's name""" + name: String! + email: String +} +``` + +It generates: + +```ts +export function UserInputSchema(): z.ZodObject> { + return z.object({ + name: z.string().describe('The user\'s name'), + email: z.string().nullish() + }).describe('A user input') +} +``` + ### `directives` type: `DirectiveConfig` diff --git a/src/config.ts b/src/config.ts index f75ab40f..6f548993 100644 --- a/src/config.ts +++ b/src/config.ts @@ -305,6 +305,25 @@ export interface ValidationSchemaPluginConfig extends TypeScriptPluginConfig { * ``` */ namingConvention?: NamingConventionMap + /** + * @description Generates `.describe()` calls on Zod schemas using GraphQL descriptions. + * When enabled, any GraphQL type or field that has a description comment will have + * `.describe('...')` appended to its generated Zod schema. + * Only applies when schema is set to 'zod' or 'zodv4'. + * @default false + * + * @exampleMarkdown + * ```yml + * generates: + * path/to/file.ts: + * plugins: + * - graphql-codegen-validation-schema + * config: + * schema: zod + * withDescriptions: true + * ``` + */ + withDescriptions?: boolean /** * @description Generates validation schema with more API based on directive schema. * @exampleMarkdown diff --git a/src/graphql.ts b/src/graphql.ts index 08085b7b..31ea422e 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -219,3 +219,7 @@ export function escapeGraphQLCharacters(input: string): string { // eslint-disable-next-line regexp/no-escape-backspace return input.replace(/["\\/\f\n\r\t\b]/g, match => escapeMap[match]); } + +export function escapeForDescribe(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n'); +} diff --git a/src/schema_visitor.ts b/src/schema_visitor.ts index ca2daa66..f2f2c3c4 100644 --- a/src/schema_visitor.ts +++ b/src/schema_visitor.ts @@ -46,7 +46,8 @@ export abstract class BaseSchemaVisitor implements SchemaVisitor { protected abstract buildInputFields( fields: readonly (FieldDefinitionNode | InputValueDefinitionNode)[], visitor: Visitor, - name: string + name: string, + description?: string ): string; protected buildTypeDefinitionArguments( diff --git a/src/zod/index.ts b/src/zod/index.ts index 9161369b..2b2eedeb 100644 --- a/src/zod/index.ts +++ b/src/zod/index.ts @@ -23,6 +23,7 @@ import { import { buildApi, formatDirectiveConfig } from '../directive.js'; import { escapeGraphQLCharacters, + escapeForDescribe, InterfaceTypeDefinitionBuilder, isListType, isNamedType, @@ -79,7 +80,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { const visitor = this.createVisitor('input'); const name = visitor.convertName(node.name.value); this.importTypes.push(name); - return this.buildInputFields(node.fields ?? [], visitor, name); + return this.buildInputFields(node.fields ?? [], visitor, name, node.description?.value); }, }; } @@ -99,6 +100,10 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { // Building schema for fields. const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n'); + const maybeDescribe = this.config.withDescriptions && node.description?.value + ? `.describe('${escapeForDescribe(node.description.value)}')` + : ''; + switch (this.config.validationSchemaExportType) { case 'const': return ( @@ -106,7 +111,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('const') .withName(`${name}Schema: z.ZodObject>`) - .withContent([`z.object({`, shape, '})'].join('\n')) + .withContent([`z.object({`, shape, `})${maybeDescribe}`].join('\n')) .string + appendArguments ); @@ -117,7 +122,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('function') .withName(`${name}Schema(): z.ZodObject>`) - .withBlock([indent(`return z.object({`), shape, indent('})')].join('\n')) + .withBlock([indent(`return z.object({`), shape, indent(`})${maybeDescribe}`)].join('\n')) .string + appendArguments ); } @@ -140,6 +145,10 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { // Building schema for fields. const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n'); + const maybeDescribe = this.config.withDescriptions && node.description?.value + ? `.describe('${escapeForDescribe(node.description.value)}')` + : ''; + switch (this.config.validationSchemaExportType) { case 'const': return ( @@ -152,7 +161,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { `z.object({`, indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape, - '})', + `})${maybeDescribe}`, ].join('\n'), ) .string + appendArguments @@ -170,7 +179,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { indent(`return z.object({`), indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape, - indent('})'), + indent(`})${maybeDescribe}`), ].join('\n'), ) .string + appendArguments @@ -253,17 +262,22 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { fields: readonly (FieldDefinitionNode | InputValueDefinitionNode)[], visitor: Visitor, name: string, + description?: string, ) { const typeName = visitor.prefixTypeNamespace(name); const shape = fields.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n'); + const maybeDescribe = this.config.withDescriptions && description + ? `.describe('${escapeForDescribe(description)}')` + : ''; + switch (this.config.validationSchemaExportType) { case 'const': return new DeclarationBlock({}) .export() .asKind('const') .withName(`${name}Schema: z.ZodObject>`) - .withContent(['z.object({', shape, '})'].join('\n')) + .withContent([`z.object({`, shape, `})${maybeDescribe}`].join('\n')) .string; case 'function': @@ -272,7 +286,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('function') .withName(`${name}Schema(): z.ZodObject>`) - .withBlock([indent(`return z.object({`), shape, indent('})')].join('\n')) + .withBlock([indent(`return z.object({`), shape, indent(`})${maybeDescribe}`)].join('\n')) .string; } } @@ -280,7 +294,11 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { function generateFieldZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number): string { const gen = generateFieldTypeZodSchema(config, visitor, field, field.type); - return indent(`${field.name.value}: ${maybeLazy(visitor, field.type, gen)}`, indentCount); + let schema = maybeLazy(visitor, field.type, gen); + if (config.withDescriptions && field.description?.value) { + schema = `${schema}.describe('${escapeForDescribe(field.description.value)}')`; + } + return indent(`${field.name.value}: ${schema}`, indentCount); } function generateFieldTypeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, parentType?: TypeNode): string { diff --git a/src/zodv4/index.ts b/src/zodv4/index.ts index 38e1aabe..caac5f64 100644 --- a/src/zodv4/index.ts +++ b/src/zodv4/index.ts @@ -23,6 +23,7 @@ import { import { buildApi, formatDirectiveConfig } from '../directive.js'; import { escapeGraphQLCharacters, + escapeForDescribe, InterfaceTypeDefinitionBuilder, isListType, isNamedType, @@ -78,7 +79,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { const visitor = this.createVisitor('input'); const name = visitor.convertName(node.name.value); this.importTypes.push(name); - return this.buildInputFields(node.fields ?? [], visitor, name); + return this.buildInputFields(node.fields ?? [], visitor, name, node.description?.value); }, }; } @@ -98,6 +99,10 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { // Building schema for fields. const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n'); + const maybeDescribe = this.config.withDescriptions && node.description?.value + ? `.describe('${escapeForDescribe(node.description.value)}')` + : ''; + switch (this.config.validationSchemaExportType) { case 'const': return ( @@ -105,7 +110,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { .export() .asKind('const') .withName(`${name}Schema: z.ZodObject>`) - .withContent([`z.object({`, shape, '})'].join('\n')) + .withContent([`z.object({`, shape, `})${maybeDescribe}`].join('\n')) .string + appendArguments ); @@ -116,7 +121,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { .export() .asKind('function') .withName(`${name}Schema(): z.ZodObject>`) - .withBlock([indent(`return z.object({`), shape, indent('})')].join('\n')) + .withBlock([indent(`return z.object({`), shape, indent(`})${maybeDescribe}`)].join('\n')) .string + appendArguments ); } @@ -139,6 +144,10 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { // Building schema for fields. const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n'); + const maybeDescribe = this.config.withDescriptions && node.description?.value + ? `.describe('${escapeForDescribe(node.description.value)}')` + : ''; + switch (this.config.validationSchemaExportType) { case 'const': return ( @@ -151,7 +160,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { `z.object({`, indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape, - '})', + `})${maybeDescribe}`, ].join('\n'), ) .string + appendArguments @@ -169,7 +178,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { indent(`return z.object({`), indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape, - indent('})'), + indent(`})${maybeDescribe}`), ].join('\n'), ) .string + appendArguments @@ -249,17 +258,22 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { fields: readonly (FieldDefinitionNode | InputValueDefinitionNode)[], visitor: Visitor, name: string, + description?: string, ) { const typeName = visitor.prefixTypeNamespace(name); const shape = fields.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n'); + const maybeDescribe = this.config.withDescriptions && description + ? `.describe('${escapeForDescribe(description)}')` + : ''; + switch (this.config.validationSchemaExportType) { case 'const': return new DeclarationBlock({}) .export() .asKind('const') .withName(`${name}Schema: z.ZodObject>`) - .withContent(['z.object({', shape, '})'].join('\n')) + .withContent([`z.object({`, shape, `})${maybeDescribe}`].join('\n')) .string; case 'function': @@ -268,7 +282,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { .export() .asKind('function') .withName(`${name}Schema(): z.ZodObject>`) - .withBlock([indent(`return z.object({`), shape, indent('})')].join('\n')) + .withBlock([indent(`return z.object({`), shape, indent(`})${maybeDescribe}`)].join('\n')) .string; } } @@ -276,7 +290,11 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { function generateFieldZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number): string { const gen = generateFieldTypeZodSchema(config, visitor, field, field.type); - return indent(`${field.name.value}: ${maybeLazy(visitor, field.type, gen)}`, indentCount); + let schema = maybeLazy(visitor, field.type, gen); + if (config.withDescriptions && field.description?.value) { + schema = `${schema}.describe('${escapeForDescribe(field.description.value)}')`; + } + return indent(`${field.name.value}: ${schema}`, indentCount); } function generateFieldTypeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, parentType?: TypeNode): string { diff --git a/tests/zod.spec.ts b/tests/zod.spec.ts index e13d420c..09496d38 100644 --- a/tests/zod.spec.ts +++ b/tests/zod.spec.ts @@ -1861,4 +1861,112 @@ describe('zod', () => { " `); }); + + describe('withDescriptions', () => { + it('adds field-level describe from GraphQL descriptions', async () => { + const schema = buildSchema(/* GraphQL */ ` + input UserInput { + """The user's name""" + name: String! + """The user's email address""" + email: String + } + `); + const result = await plugin(schema, [], { schema: 'zod', withDescriptions: true }, {}); + expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` + " + export function UserInputSchema(): z.ZodObject> { + return z.object({ + name: z.string().describe('The user\\'s name'), + email: z.string().nullish().describe('The user\\'s email address') + }) + } + " + `); + }); + + it('adds type-level describe from GraphQL descriptions', async () => { + const schema = buildSchema(/* GraphQL */ ` + """An input for creating a user""" + input CreateUserInput { + name: String! + } + `); + const result = await plugin(schema, [], { schema: 'zod', withDescriptions: true }, {}); + expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` + " + export function CreateUserInputSchema(): z.ZodObject> { + return z.object({ + name: z.string() + }).describe('An input for creating a user') + } + " + `); + }); + + it('adds both type-level and field-level describe', async () => { + const schema = buildSchema(/* GraphQL */ ` + """A user input""" + input UserInput { + """The user's name""" + name: String! + } + `); + const result = await plugin(schema, [], { schema: 'zod', withDescriptions: true }, {}); + expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` + " + export function UserInputSchema(): z.ZodObject> { + return z.object({ + name: z.string().describe('The user\\'s name') + }).describe('A user input') + } + " + `); + }); + + it('does not add describe when withDescriptions is false', async () => { + const schema = buildSchema(/* GraphQL */ ` + """A user input""" + input UserInput { + """The user's name""" + name: String! + } + `); + const result = await plugin(schema, [], { schema: 'zod', withDescriptions: false }, {}); + expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` + " + export function UserInputSchema(): z.ZodObject> { + return z.object({ + name: z.string() + }) + } + " + `); + }); + + it('escapes special characters in descriptions', async () => { + const schema = buildSchema(/* GraphQL */ ` + input TestInput { + """Contains a backslash \\ and a newline""" + field: String! + } + `); + const result = await plugin(schema, [], { schema: 'zod', withDescriptions: true }, {}); + expect(result.content).toContain(`.describe(`); + }); + + it('adds describe to object types with withObjectType', async () => { + const schema = buildSchema(/* GraphQL */ ` + """A user type""" + type User { + """The user's name""" + name: String! + } + `); + const result = await plugin(schema, [], { schema: 'zod', withObjectType: true, withDescriptions: true }, {}); + const content = removedInitialEmitValue(result.content); + expect(content).toContain(`.describe('A user type')`); + expect(content).toContain(`.describe('The user\\'s name')`); + }); + }); }); diff --git a/tests/zodv4.spec.ts b/tests/zodv4.spec.ts index 9b784890..6e686c23 100644 --- a/tests/zodv4.spec.ts +++ b/tests/zodv4.spec.ts @@ -2227,4 +2227,101 @@ describe('zodv4', () => { " `) }); + + describe('withDescriptions', () => { + it('adds field-level describe from GraphQL descriptions', async () => { + const schema = buildSchema(/* GraphQL */ ` + input UserInput { + """The user's name""" + name: String! + """The user's email address""" + email: String + } + `); + const result = await plugin(schema, [], { schema: 'zodv4', withDescriptions: true }, {}); + expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` + " + export function UserInputSchema(): z.ZodObject> { + return z.object({ + name: z.string().describe('The user\\'s name'), + email: z.string().nullish().describe('The user\\'s email address') + }) + } + " + `); + }); + + it('adds type-level describe from GraphQL descriptions', async () => { + const schema = buildSchema(/* GraphQL */ ` + """An input for creating a user""" + input CreateUserInput { + name: String! + } + `); + const result = await plugin(schema, [], { schema: 'zodv4', withDescriptions: true }, {}); + expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` + " + export function CreateUserInputSchema(): z.ZodObject> { + return z.object({ + name: z.string() + }).describe('An input for creating a user') + } + " + `); + }); + + it('adds both type-level and field-level describe', async () => { + const schema = buildSchema(/* GraphQL */ ` + """A user input""" + input UserInput { + """The user's name""" + name: String! + } + `); + const result = await plugin(schema, [], { schema: 'zodv4', withDescriptions: true }, {}); + expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` + " + export function UserInputSchema(): z.ZodObject> { + return z.object({ + name: z.string().describe('The user\\'s name') + }).describe('A user input') + } + " + `); + }); + + it('does not add describe when withDescriptions is false', async () => { + const schema = buildSchema(/* GraphQL */ ` + """A user input""" + input UserInput { + """The user's name""" + name: String! + } + `); + const result = await plugin(schema, [], { schema: 'zodv4', withDescriptions: false }, {}); + expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` + " + export function UserInputSchema(): z.ZodObject> { + return z.object({ + name: z.string() + }) + } + " + `); + }); + + it('adds describe to object types with withObjectType', async () => { + const schema = buildSchema(/* GraphQL */ ` + """A user type""" + type User { + """The user's name""" + name: String! + } + `); + const result = await plugin(schema, [], { schema: 'zodv4', withObjectType: true, withDescriptions: true }, {}); + const content = removedInitialEmitValue(result.content); + expect(content).toContain(`.describe('A user type')`); + expect(content).toContain(`.describe('The user\\'s name')`); + }); + }); });