Skip to content
Closed
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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Properties<UserInput>> {
return z.object({
name: z.string().describe('The user\'s name'),
email: z.string().nullish()
}).describe('A user input')
}
```

### `directives`

type: `DirectiveConfig`
Expand Down
19 changes: 19 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
3 changes: 2 additions & 1 deletion src/schema_visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
34 changes: 26 additions & 8 deletions src/zod/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { buildApi, formatDirectiveConfig } from '../directive.js';
import {
escapeGraphQLCharacters,
escapeForDescribe,
InterfaceTypeDefinitionBuilder,
isListType,
isNamedType,
Expand Down Expand Up @@ -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);
},
};
}
Expand All @@ -99,14 +100,18 @@ 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 (
new DeclarationBlock({})
.export()
.asKind('const')
.withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
.withContent([`z.object({`, shape, '})'].join('\n'))
.withContent([`z.object({`, shape, `})${maybeDescribe}`].join('\n'))
.string + appendArguments
);

Expand All @@ -117,7 +122,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
.export()
.asKind('function')
.withName(`${name}Schema(): z.ZodObject<Properties<${typeName}>>`)
.withBlock([indent(`return z.object({`), shape, indent('})')].join('\n'))
.withBlock([indent(`return z.object({`), shape, indent(`})${maybeDescribe}`)].join('\n'))
.string + appendArguments
);
}
Expand All @@ -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 (
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Properties<${typeName}>>`)
.withContent(['z.object({', shape, '})'].join('\n'))
.withContent([`z.object({`, shape, `})${maybeDescribe}`].join('\n'))
.string;

case 'function':
Expand All @@ -272,15 +286,19 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
.export()
.asKind('function')
.withName(`${name}Schema(): z.ZodObject<Properties<${typeName}>>`)
.withBlock([indent(`return z.object({`), shape, indent('})')].join('\n'))
.withBlock([indent(`return z.object({`), shape, indent(`})${maybeDescribe}`)].join('\n'))
.string;
}
}
}

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 {
Expand Down
34 changes: 26 additions & 8 deletions src/zodv4/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { buildApi, formatDirectiveConfig } from '../directive.js';
import {
escapeGraphQLCharacters,
escapeForDescribe,
InterfaceTypeDefinitionBuilder,
isListType,
isNamedType,
Expand Down Expand Up @@ -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);
},
};
}
Expand All @@ -98,14 +99,18 @@ 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 (
new DeclarationBlock({})
.export()
.asKind('const')
.withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
.withContent([`z.object({`, shape, '})'].join('\n'))
.withContent([`z.object({`, shape, `})${maybeDescribe}`].join('\n'))
.string + appendArguments
);

Expand All @@ -116,7 +121,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
.export()
.asKind('function')
.withName(`${name}Schema(): z.ZodObject<Properties<${typeName}>>`)
.withBlock([indent(`return z.object({`), shape, indent('})')].join('\n'))
.withBlock([indent(`return z.object({`), shape, indent(`})${maybeDescribe}`)].join('\n'))
.string + appendArguments
);
}
Expand All @@ -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 (
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Properties<${typeName}>>`)
.withContent(['z.object({', shape, '})'].join('\n'))
.withContent([`z.object({`, shape, `})${maybeDescribe}`].join('\n'))
.string;

case 'function':
Expand All @@ -268,15 +282,19 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
.export()
.asKind('function')
.withName(`${name}Schema(): z.ZodObject<Properties<${typeName}>>`)
.withBlock([indent(`return z.object({`), shape, indent('})')].join('\n'))
.withBlock([indent(`return z.object({`), shape, indent(`})${maybeDescribe}`)].join('\n'))
.string;
}
}
}

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 {
Expand Down
Loading