diff --git a/README.md b/README.md index 2b4cfcc5..2143d4ce 100644 --- a/README.md +++ b/README.md @@ -373,7 +373,7 @@ Appends `.strict()` to generated Zod object schemas. type: `boolean` default: `false` -Appends `.describe()` to generated Zod fields from GraphQL descriptions. +Appends `.describe()` to generated Zod fields and object schemas from GraphQL descriptions. ### `onlyEnums` diff --git a/src/schema_visitor.ts b/src/schema_visitor.ts index 669ce6bb..02d75152 100644 --- a/src/schema_visitor.ts +++ b/src/schema_visitor.ts @@ -67,7 +67,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 c8c7a6bf..32c762d4 100644 --- a/src/zod/index.ts +++ b/src/zod/index.ts @@ -30,6 +30,7 @@ import { schemaDepthVariable, unionLiterals, withDescription, + withTypeDescription, } from '../zod_shared.js'; import { buildZodOperationSchemas } from './operation.js'; @@ -87,9 +88,9 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { const name = visitor.convertName(node.name.value); this.importTypes.push(name); if (isOneOfInputObject(node)) - return this.buildOneOfInputFields(node.fields ?? [], visitor, name); + return this.buildOneOfInputFields(node.fields ?? [], visitor, name, node.description?.value); - return this.buildInputFields(node.fields ?? [], visitor, name); + return this.buildInputFields(node.fields ?? [], visitor, name, node.description?.value); }, }; } @@ -116,7 +117,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('const') .withName(`${name}Schema: z.ZodObject>`) - .withContent(buildObjectExpression(this.config, shape)) + .withContent(buildObjectExpression(this.config, shape, node.description?.value)) .string + appendArguments ); @@ -127,7 +128,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('function') .withName(`${name}Schema(${schemaDepthParameter(this.config)}): z.ZodObject>`) - .withBlock(buildObjectReturn(this.config, shape)) + .withBlock(buildObjectReturn(this.config, shape, node.description?.value)) .string + appendArguments ); } @@ -157,7 +158,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('const') .withName(`${name}Schema: z.ZodObject>`) - .withContent(buildObjectExpression(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n'))) + .withContent(buildObjectExpression(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n'), node.description?.value)) .string + appendArguments ); @@ -168,7 +169,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('function') .withName(`${name}Schema(${schemaDepthParameter(this.config)}): z.ZodObject>`) - .withBlock(buildObjectReturn(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n'))) + .withBlock(buildObjectReturn(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n'), node.description?.value)) .string + appendArguments ); } @@ -253,10 +254,11 @@ 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 objectSchema = buildObjectExpression(this.config, shape); + const objectSchema = buildObjectExpression(this.config, shape, description); switch (this.config.validationSchemaExportType) { case 'const': @@ -273,7 +275,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('function') .withName(`${name}Schema(): z.ZodObject>`) - .withBlock(buildObjectReturn(this.config, shape)) + .withBlock(buildObjectReturn(this.config, shape, description)) .string; } } @@ -282,6 +284,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { fields: readonly InputValueDefinitionNode[], visitor: Visitor, name: string, + description?: string, ) { const typeName = visitor.prefixTypeNamespace(name); const variants = fields.map((selectedField) => { @@ -296,7 +299,11 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { return buildObjectExpression(this.config, shape); }); - const schema = variants.length > 1 ? `z.union([\n${variants.map(variant => indent(variant, 2)).join(',\n')}\n])` : variants[0]; + const schema = withTypeDescription( + this.config, + description, + variants.length > 1 ? `z.union([\n${variants.map(variant => indent(variant, 2)).join(',\n')}\n])` : variants[0], + ); switch (this.config.validationSchemaExportType) { case 'const': diff --git a/src/zod_shared.ts b/src/zod_shared.ts index 3d06f7e3..c02fd0f2 100644 --- a/src/zod_shared.ts +++ b/src/zod_shared.ts @@ -91,18 +91,29 @@ export function isOneOfInputObject(node: InputObjectTypeDefinitionNode): boolean return node.directives?.some(directive => directive.name.value === 'oneOf') === true; } -export function buildObjectExpression(config: ValidationSchemaPluginConfig, shape: string | undefined): string { - return ['z.object({', shape, `})${strictObjectSuffix(config)}`].join('\n'); +export function buildObjectExpression(config: ValidationSchemaPluginConfig, shape: string | undefined, description?: string): string { + return ['z.object({', shape, `})${strictObjectSuffix(config)}${descriptionSuffix(config, description)}`].join('\n'); } -export function buildObjectReturn(config: ValidationSchemaPluginConfig, shape: string | undefined): string { - return [indent('return z.object({'), shape, indent(`})${strictObjectSuffix(config)}`)].join('\n'); +export function buildObjectReturn(config: ValidationSchemaPluginConfig, shape: string | undefined, description?: string): string { + return [indent('return z.object({'), shape, indent(`})${strictObjectSuffix(config)}${descriptionSuffix(config, description)}`)].join('\n'); } export function strictObjectSuffix(config: ValidationSchemaPluginConfig): string { return config.strictObjectSchemas === true ? '.strict()' : ''; } +export function descriptionSuffix(config: ValidationSchemaPluginConfig, description: string | undefined): string { + if (config.withDescriptions !== true || !description) + return ''; + + return `.describe(${JSON.stringify(description)})`; +} + +export function withTypeDescription(config: ValidationSchemaPluginConfig, description: string | undefined, gen: string): string { + return `${gen}${descriptionSuffix(config, description)}`; +} + export function zodOptionalType(config: ValidationSchemaPluginConfig): string { return config.nullishBehavior ?? config.zodOptionalType ?? 'nullish'; } diff --git a/src/zodv4/index.ts b/src/zodv4/index.ts index df99fb00..97ef5e89 100644 --- a/src/zodv4/index.ts +++ b/src/zodv4/index.ts @@ -32,6 +32,7 @@ import { schemaDepthVariable, unionLiterals, withDescription, + withTypeDescription, } from '../zod_shared.js'; export class ZodV4SchemaVisitor extends BaseSchemaVisitor { @@ -87,9 +88,9 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { const name = visitor.convertName(node.name.value); this.importTypes.push(name); if (isOneOfInputObject(node)) - return this.buildOneOfInputFields(node.fields ?? [], visitor, name); + return this.buildOneOfInputFields(node.fields ?? [], visitor, name, node.description?.value); - return this.buildInputFields(node.fields ?? [], visitor, name); + return this.buildInputFields(node.fields ?? [], visitor, name, node.description?.value); }, }; } @@ -116,7 +117,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { .export() .asKind('const') .withName(`${name}Schema: z.ZodObject>`) - .withContent(buildObjectExpression(this.config, shape)) + .withContent(buildObjectExpression(this.config, shape, node.description?.value)) .string + appendArguments ); @@ -127,7 +128,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { .export() .asKind('function') .withName(`${name}Schema(${schemaDepthParameter(this.config)}): z.ZodObject>`) - .withBlock(buildObjectReturn(this.config, shape)) + .withBlock(buildObjectReturn(this.config, shape, node.description?.value)) .string + appendArguments ); } @@ -157,7 +158,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { .export() .asKind('const') .withName(`${name}Schema: z.ZodObject>`) - .withContent(buildObjectExpression(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n'))) + .withContent(buildObjectExpression(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n'), node.description?.value)) .string + appendArguments ); @@ -168,7 +169,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { .export() .asKind('function') .withName(`${name}Schema(${schemaDepthParameter(this.config)}): z.ZodObject>`) - .withBlock(buildObjectReturn(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n'))) + .withBlock(buildObjectReturn(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n'), node.description?.value)) .string + appendArguments ); } @@ -254,10 +255,11 @@ 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 objectSchema = buildObjectExpression(this.config, shape); + const objectSchema = buildObjectExpression(this.config, shape, description); const schemaType = hasDefaultValue(fields) ? `z.ZodType<${typeName}>` : `z.ZodObject>`; switch (this.config.validationSchemaExportType) { @@ -275,7 +277,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { .export() .asKind('function') .withName(`${name}Schema(): ${schemaType}`) - .withBlock(buildObjectReturn(this.config, shape)) + .withBlock(buildObjectReturn(this.config, shape, description)) .string; } } @@ -284,6 +286,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { fields: readonly InputValueDefinitionNode[], visitor: Visitor, name: string, + description?: string, ) { const typeName = visitor.prefixTypeNamespace(name); const variants = fields.map((selectedField) => { @@ -298,7 +301,11 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { return buildObjectExpression(this.config, shape); }); - const schema = variants.length > 1 ? `z.union([\n${variants.map(variant => indent(variant, 2)).join(',\n')}\n])` : variants[0]; + const schema = withTypeDescription( + this.config, + description, + variants.length > 1 ? `z.union([\n${variants.map(variant => indent(variant, 2)).join(',\n')}\n])` : variants[0], + ); switch (this.config.validationSchemaExportType) { case 'const': diff --git a/tests/zod.spec.ts b/tests/zod.spec.ts index 413dd0a5..7e6c856d 100644 --- a/tests/zod.spec.ts +++ b/tests/zod.spec.ts @@ -2362,6 +2362,10 @@ describe('zod', () => { it('supports strict objects and GraphQL descriptions', async () => { const schema = buildSchema(/* GraphQL */ ` + """ + User "input" + with \\ slash + """ input UserInput { "Display name shown to users" name: String! @@ -2376,7 +2380,46 @@ describe('zod', () => { ); expect(result.content).toContain('name: z.string().describe("Display name shown to users")'); - expect(result.content).toContain('}).strict()'); + expect(result.content).toContain('}).strict().describe("User \\"input\\"\\nwith \\\\ slash")'); + }); + + it('adds type-level descriptions to const object schemas', async () => { + const schema = buildSchema(/* GraphQL */ ` + "Node contract" + interface Node { + id: ID! + } + + "User object" + type User implements Node { + id: ID! + name: String! + } + + "User input" + input UserInput { + name: String! + } + `); + + const result = await plugin( + schema, + [], + { + schema: 'zod', + validationSchemaExportType: 'const', + withDescriptions: true, + withObjectType: true, + }, + {}, + ); + + expect(result.content).toContain('export const NodeSchema: z.ZodObject>'); + expect(result.content).toContain('}).describe("Node contract")'); + expect(result.content).toContain('export const UserSchema: z.ZodObject>'); + expect(result.content).toContain('}).describe("User object")'); + expect(result.content).toContain('export const UserInputSchema: z.ZodObject>'); + expect(result.content).toContain('}).describe("User input")'); }); it('respects enumPrefix: false when typesPrefix is configured', async () => { diff --git a/tests/zodv4.spec.ts b/tests/zodv4.spec.ts index 6c5e1d25..c91834c6 100644 --- a/tests/zodv4.spec.ts +++ b/tests/zodv4.spec.ts @@ -2242,19 +2242,60 @@ describe('zodv4', () => { placeholder: String! } + "Exactly one event payload" input EventInput @oneOf { assignEvent: AssignEventInput placeholder: PlaceholderEventInput } `); - const result = await plugin(schema, [], { schema: 'zodv4' }, {}); + const result = await plugin(schema, [], { schema: 'zodv4', withDescriptions: true }, {}); expect(result.content).toContain('export function EventInputSchema(): z.ZodType'); expect(result.content).toContain('assignEvent: z.lazy(() => AssignEventInputSchema())'); expect(result.content).toContain('placeholder: z.never().optional()'); expect(result.content).toContain('placeholder: z.lazy(() => PlaceholderEventInputSchema())'); expect(result.content).toContain('assignEvent: z.never().optional()'); + expect(result.content).toContain(']).describe("Exactly one event payload")'); + }); + + it('adds type-level descriptions to const object schemas', async () => { + const schema = buildSchema(/* GraphQL */ ` + "Node contract" + interface Node { + id: ID! + } + + "User object" + type User implements Node { + id: ID! + name: String! + } + + "User input" + input UserInput { + name: String! + } + `); + + const result = await plugin( + schema, + [], + { + schema: 'zodv4', + validationSchemaExportType: 'const', + withDescriptions: true, + withObjectType: true, + }, + {}, + ); + + expect(result.content).toContain('export const NodeSchema: z.ZodObject>'); + expect(result.content).toContain('}).describe("Node contract")'); + expect(result.content).toContain('export const UserSchema: z.ZodObject>'); + expect(result.content).toContain('}).describe("User object")'); + expect(result.content).toContain('export const UserInputSchema: z.ZodObject>'); + expect(result.content).toContain('}).describe("User input")'); }); it('supports configurable nullable field behavior', async () => {