Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
3 changes: 2 additions & 1 deletion src/schema_visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
25 changes: 16 additions & 9 deletions src/zod/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
schemaDepthVariable,
unionLiterals,
withDescription,
withTypeDescription,
} from '../zod_shared.js';
import { buildZodOperationSchemas } from './operation.js';

Expand Down Expand Up @@ -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);
},
};
}
Expand All @@ -116,7 +117,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
.export()
.asKind('const')
.withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
.withContent(buildObjectExpression(this.config, shape))
.withContent(buildObjectExpression(this.config, shape, node.description?.value))
.string + appendArguments
);

Expand All @@ -127,7 +128,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
.export()
.asKind('function')
.withName(`${name}Schema(${schemaDepthParameter(this.config)}): z.ZodObject<Properties<${typeName}>>`)
.withBlock(buildObjectReturn(this.config, shape))
.withBlock(buildObjectReturn(this.config, shape, node.description?.value))
.string + appendArguments
);
}
Expand Down Expand Up @@ -157,7 +158,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
.export()
.asKind('const')
.withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
.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
);

Expand All @@ -168,7 +169,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
.export()
.asKind('function')
.withName(`${name}Schema(${schemaDepthParameter(this.config)}): z.ZodObject<Properties<${typeName}>>`)
.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
);
}
Expand Down Expand Up @@ -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':
Expand All @@ -273,7 +275,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
.export()
.asKind('function')
.withName(`${name}Schema(): z.ZodObject<Properties<${typeName}>>`)
.withBlock(buildObjectReturn(this.config, shape))
.withBlock(buildObjectReturn(this.config, shape, description))
.string;
}
}
Expand All @@ -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) => {
Expand All @@ -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':
Expand Down
19 changes: 15 additions & 4 deletions src/zod_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Expand Down
25 changes: 16 additions & 9 deletions src/zodv4/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
schemaDepthVariable,
unionLiterals,
withDescription,
withTypeDescription,
} from '../zod_shared.js';

export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
Expand Down Expand Up @@ -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);
},
};
}
Expand All @@ -116,7 +117,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
.export()
.asKind('const')
.withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
.withContent(buildObjectExpression(this.config, shape))
.withContent(buildObjectExpression(this.config, shape, node.description?.value))
.string + appendArguments
);

Expand All @@ -127,7 +128,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
.export()
.asKind('function')
.withName(`${name}Schema(${schemaDepthParameter(this.config)}): z.ZodObject<Properties<${typeName}>>`)
.withBlock(buildObjectReturn(this.config, shape))
.withBlock(buildObjectReturn(this.config, shape, node.description?.value))
.string + appendArguments
);
}
Expand Down Expand Up @@ -157,7 +158,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
.export()
.asKind('const')
.withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
.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
);

Expand All @@ -168,7 +169,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
.export()
.asKind('function')
.withName(`${name}Schema(${schemaDepthParameter(this.config)}): z.ZodObject<Properties<${typeName}>>`)
.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
);
}
Expand Down Expand Up @@ -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<Properties<${typeName}>>`;

switch (this.config.validationSchemaExportType) {
Expand All @@ -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;
}
}
Expand All @@ -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) => {
Expand All @@ -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':
Expand Down
45 changes: 44 additions & 1 deletion tests/zod.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand All @@ -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<Properties<Node>>');
expect(result.content).toContain('}).describe("Node contract")');
expect(result.content).toContain('export const UserSchema: z.ZodObject<Properties<User>>');
expect(result.content).toContain('}).describe("User object")');
expect(result.content).toContain('export const UserInputSchema: z.ZodObject<Properties<UserInput>>');
expect(result.content).toContain('}).describe("User input")');
});

it('respects enumPrefix: false when typesPrefix is configured', async () => {
Expand Down
43 changes: 42 additions & 1 deletion tests/zodv4.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EventInput>');
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<Properties<Node>>');
expect(result.content).toContain('}).describe("Node contract")');
expect(result.content).toContain('export const UserSchema: z.ZodObject<Properties<User>>');
expect(result.content).toContain('}).describe("User object")');
expect(result.content).toContain('export const UserInputSchema: z.ZodObject<Properties<UserInput>>');
expect(result.content).toContain('}).describe("User input")');
});

it('supports configurable nullable field behavior', async () => {
Expand Down
Loading