Skip to content

Commit

Permalink
Better support for custom directives
Browse files Browse the repository at this point in the history
* Add support for directives without arguments like `@required()`
* Add support for custom directive mapping

Closes Code-Hex#422, Code-Hex#781
  • Loading branch information
rwysocki-equinix committed Aug 21, 2024
1 parent 3eb44f1 commit d0a3bb8
Show file tree
Hide file tree
Showing 9 changed files with 214 additions and 241 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ dist
package-lock.json
.DS_Store
tsconfig.tsbuildinfo
yarn-error.log
yarn-error.log
.idea
57 changes: 56 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ generates:
# directive:
# arg1: schemaApi
# arg2: ["schemaApi2", "Hello $1"]
# OR
# directive: schemaApi
#
# See more examples in `./tests/directive.spec.ts`
# https://github.com/Code-Hex/graphql-codegen-typescript-validation-schema/blob/main/tests/directive.spec.ts
Expand Down Expand Up @@ -290,6 +292,8 @@ generates:
# directive:
# arg1: schemaApi
# arg2: ["schemaApi2", "Hello $1"]
# OR
# directive: schemaApi
#
# See more examples in `./tests/directive.spec.ts`
# https://github.com/Code-Hex/graphql-codegen-typescript-validation-schema/blob/main/tests/directive.spec.ts
Expand Down Expand Up @@ -318,9 +322,60 @@ export function ExampleInputSchema(): z.ZodSchema<ExampleInput> {

Please see [example](https://github.com/Code-Hex/graphql-codegen-typescript-validation-schema/tree/main/example) directory.

#### Custom mapping functions

If you are using TS config you can define your own custom mapping for directives. The function will receive the arguments of the directive as an object and should return a string that will be appended to the schema.

```ts
const config: CodegenConfig = {
schema: 'http://localhost:4000/graphql',
documents: ['src/**/*.tsx'],
generates: {
plugins: ['typescript', 'typescript-validation-schema'],
config: {
schema: 'zod',
directives: {
between: (args) => `.refine(v => v >= ${args.min} && v <= ${args.max})`,
},
}
}
}
```

Additionally, you can define custom mapping functions for each argument, or even each argument value separately.

```ts
const config: CodegenConfig = {
schema: 'http://localhost:4000/graphql',
documents: ['src/**/*.tsx'],
generates: {
plugins: ['typescript', 'typescript-validation-schema'],
config: {
schema: 'zod',
directives: {
// @unique()
unique: () => `.refine(items => new Set(items).size === items.length)`,

// @array(unique: true)
array: {
unique: (value) => value ? `.refine(items => new Set(items).size === items.length)` : ``,
},

// @constraint(array: "UNIQUE")
constraint: {
array: {
UNIQUE: () => `.refine(items => new Set(items).size === items.length)`,
}
},
},
}
}
}
```

## Notes

Their is currently a compatibility issue with the client-preset. A workaround for this is to split the generation into two (one for client-preset and one for typescript-validation-schema).
There is currently a compatibility issue with the client-preset. A workaround for this is to split the generation into two (one for client-preset and one for typescript-validation-schema).

```yml
generates:
Expand Down
10 changes: 6 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ export type ValidationSchema = 'yup' | 'zod' | 'myzod' | 'valibot';
export type ValidationSchemaExportType = 'function' | 'const';

export interface DirectiveConfig {
[directive: string]: {
[argument: string]: string | string[] | DirectiveObjectArguments
}
[directive: string]: SingleDirectiveConfig | string | ((args: Record<string, any>) => string)
}

export interface SingleDirectiveConfig {
[argument: string]: string | string[] | DirectiveObjectArguments | ((argValue: any) => string)
}

export interface DirectiveObjectArguments {
[matched: string]: string | string[]
[matched: string]: string | string[] | (() => string)
}

interface ScalarSchemas {
Expand Down
154 changes: 53 additions & 101 deletions src/directive.ts
Original file line number Diff line number Diff line change
@@ -1,92 +1,9 @@
import type { ConstArgumentNode, ConstDirectiveNode, ConstValueNode } from 'graphql';
import { Kind, valueFromASTUntyped } from 'graphql';

import type { DirectiveConfig, DirectiveObjectArguments } from './config.js';
import type { DirectiveConfig, DirectiveObjectArguments, SingleDirectiveConfig } from './config.js';
import { isConvertableRegexp } from './regexp.js';

export interface FormattedDirectiveConfig {
[directive: string]: FormattedDirectiveArguments
}

export interface FormattedDirectiveArguments {
[argument: string]: string[] | FormattedDirectiveObjectArguments | undefined
}

export interface FormattedDirectiveObjectArguments {
[matched: string]: string[] | undefined
}

function isFormattedDirectiveObjectArguments(arg: FormattedDirectiveArguments[keyof FormattedDirectiveArguments]): arg is FormattedDirectiveObjectArguments {
return arg !== undefined && !Array.isArray(arg)
}

// ```yml
// directives:
// required:
// msg: required
// constraint:
// minLength: min
// format:
// uri: url
// email: email
// ```
//
// This function convterts to like below
// {
// 'required': {
// 'msg': ['required', '$1'],
// },
// 'constraint': {
// 'minLength': ['min', '$1'],
// 'format': {
// 'uri': ['url', '$2'],
// 'email': ['email', '$2'],
// }
// }
// }
export function formatDirectiveConfig(config: DirectiveConfig): FormattedDirectiveConfig {
return Object.fromEntries(
Object.entries(config).map(([directive, arg]) => {
const formatted = Object.fromEntries(
Object.entries(arg).map(([arg, val]) => {
if (Array.isArray(val))
return [arg, val];

if (typeof val === 'string')
return [arg, [val, '$1']];

return [arg, formatDirectiveObjectArguments(val)];
}),
);
return [directive, formatted];
}),
);
}

// ```yml
// format:
// # For example, `@constraint(format: "uri")`. this case $1 will be "uri".
// # Therefore the generator generates yup schema `.url()` followed by `uri: 'url'`
// # If $1 does not match anywhere, the generator will ignore.
// uri: url
// email: ["email", "$2"]
// ```
//
// This function convterts to like below
// {
// 'uri': ['url', '$2'],
// 'email': ['email'],
// }
export function formatDirectiveObjectArguments(args: DirectiveObjectArguments): FormattedDirectiveObjectArguments {
const formatted = Object.entries(args).map(([arg, val]) => {
if (Array.isArray(val))
return [arg, val];

return [arg, [val, '$2']];
});
return Object.fromEntries(formatted);
}

// This function generates `.required("message").min(100).email()`
//
// config
Expand All @@ -109,13 +26,19 @@ export function formatDirectiveObjectArguments(args: DirectiveObjectArguments):
// email: String! @required(msg: "message") @constraint(minLength: 100, format: "email")
// }
// ```
export function buildApi(config: FormattedDirectiveConfig, directives: ReadonlyArray<ConstDirectiveNode>): string {
export function buildApi(config: DirectiveConfig, directives: ReadonlyArray<ConstDirectiveNode>): string {
return directives
.filter(directive => config[directive.name.value] !== undefined)
.map((directive) => {
const directiveName = directive.name.value;
const argsConfig = config[directiveName];
return buildApiFromDirectiveArguments(argsConfig, directive.arguments ?? []);
const directiveConfig = config[directiveName];
if (typeof directiveConfig === 'string') {
return `.${directiveConfig}()`;
}
if (typeof directiveConfig === 'function') {
return directiveConfig(directiveArgs(directive));
}
return buildApiFromDirectiveArguments(directiveConfig, directive.arguments ?? []);
})
.join('')
}
Expand All @@ -142,20 +65,30 @@ export function buildApi(config: FormattedDirectiveConfig, directives: ReadonlyA
// ```
//
// FIXME: v.required() is not supported yet. v.required() is classified as `Methods` and must wrap the schema. ex) `v.required(v.object({...}))`
export function buildApiForValibot(config: FormattedDirectiveConfig, directives: ReadonlyArray<ConstDirectiveNode>): string[] {
export function buildApiForValibot(config: DirectiveConfig, directives: ReadonlyArray<ConstDirectiveNode>): string[] {
return directives
.filter(directive => config[directive.name.value] !== undefined)
.map((directive) => {
const directiveName = directive.name.value;
const argsConfig = config[directiveName];
const apis = _buildApiFromDirectiveArguments(argsConfig, directive.arguments ?? []);
const directiveConfig = config[directiveName];
if (typeof directiveConfig === 'string') {
return `.${directiveConfig}()`;
}
if (typeof directiveConfig === 'function') {
return directiveConfig(directiveArgs(directive));
}
const apis = _buildApiFromDirectiveArguments(directiveConfig, directive.arguments ?? []);
return apis.map(api => `v${api}`);
}).flat()
}

function buildApiSchema(validationSchema: string[] | undefined, argValue: ConstValueNode): string {
if (!validationSchema)
function buildApiSchema(validationSchema: string | string[] | undefined, argValue: ConstValueNode): string {
if (!validationSchema) {
return '';
}
if (!Array.isArray(validationSchema)) {
return `.${validationSchema}()`
}

const schemaApi = validationSchema[0];
const schemaApiArgs = validationSchema.slice(1).map((templateArg) => {
Expand All @@ -165,27 +98,39 @@ function buildApiSchema(validationSchema: string[] | undefined, argValue: ConstV
return `.${schemaApi}(${schemaApiArgs.join(', ')})`;
}

function buildApiFromDirectiveArguments(config: FormattedDirectiveArguments, args: ReadonlyArray<ConstArgumentNode>): string {
function buildApiFromDirectiveArguments(config: SingleDirectiveConfig, args: ReadonlyArray<ConstArgumentNode>): string {
return _buildApiFromDirectiveArguments(config, args).join('');
}

function _buildApiFromDirectiveArguments(config: FormattedDirectiveArguments, args: ReadonlyArray<ConstArgumentNode>): string[] {
function _buildApiFromDirectiveArguments(config: SingleDirectiveConfig, args: ReadonlyArray<ConstArgumentNode>): string[] {
return args
.map((arg) => {
const argName = arg.name.value;
const validationSchema = config[argName];
if (isFormattedDirectiveObjectArguments(validationSchema))
return buildApiFromDirectiveObjectArguments(validationSchema, arg.value);

return buildApiSchema(validationSchema, arg.value);
if (!validationSchema) {
return ''
}
if (typeof validationSchema === 'function') {
return validationSchema(valueFromASTUntyped(arg.value));
}
if (typeof validationSchema === 'string') {
return buildApiSchema([validationSchema, '$1'], arg.value);
}
if (Array.isArray(validationSchema)) {
return buildApiSchema(validationSchema, arg.value);
}
return buildApiFromDirectiveObjectArguments(validationSchema, arg.value);
})
}

function buildApiFromDirectiveObjectArguments(config: FormattedDirectiveObjectArguments, argValue: ConstValueNode): string {
if (argValue.kind !== Kind.STRING && argValue.kind !== Kind.ENUM)
function buildApiFromDirectiveObjectArguments(config: DirectiveObjectArguments, argValue: ConstValueNode): string {
if (argValue.kind !== Kind.STRING && argValue.kind !== Kind.ENUM) {
return '';

}
const validationSchema = config[argValue.value];
if (typeof validationSchema === 'function') {
return validationSchema();
}
return buildApiSchema(validationSchema, argValue);
}

Expand Down Expand Up @@ -240,6 +185,13 @@ function apiArgsFromConstValueNode(value: ConstValueNode): any[] {
return [val];
}

function directiveArgs(directive: ConstDirectiveNode): Record<string, any> {
if (!directive.arguments) {
return {}
}
return Object.fromEntries(directive.arguments.map(arg => [arg.name.value, valueFromASTUntyped(arg.value)]))
}

function tryEval(maybeValidJavaScript: string): any | undefined {
try {
// eslint-disable-next-line no-eval
Expand Down
5 changes: 2 additions & 3 deletions src/myzod/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {

import { resolveExternalModuleAndFn } from '@graphql-codegen/plugin-helpers';
import type { ValidationSchemaPluginConfig } from '../config.js';
import { buildApi, formatDirectiveConfig } from '../directive.js';
import { buildApi } from '../directive.js';
import { BaseSchemaVisitor } from '../schema_visitor.js';
import type { Visitor } from '../visitor.js';
import {
Expand Down Expand Up @@ -315,8 +315,7 @@ function generateFieldTypeMyZodSchema(config: ValidationSchemaPluginConfig, visi

function applyDirectives(config: ValidationSchemaPluginConfig, field: InputValueDefinitionNode | FieldDefinitionNode, gen: string): string {
if (config.directives && field.directives) {
const formatted = formatDirectiveConfig(config.directives);
return gen + buildApi(formatted, field.directives);
return gen + buildApi(config.directives, field.directives);
}
return gen;
}
Expand Down
7 changes: 3 additions & 4 deletions src/valibot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type {
import type { ValidationSchemaPluginConfig } from '../config.js';
import { BaseSchemaVisitor } from '../schema_visitor.js';
import type { Visitor } from '../visitor.js';
import { buildApiForValibot, formatDirectiveConfig } from '../directive.js';
import { buildApiForValibot } from '../directive.js';
import {
InterfaceTypeDefinitionBuilder,
ObjectTypeDefinitionBuilder,
Expand Down Expand Up @@ -222,7 +222,7 @@ function generateFieldTypeValibotSchema(config: ValidationSchemaPluginConfig, vi
const actions = actionsFromDirectives(config, field);

if (isNonNullType(parentType))
return pipeSchemaAndActions(gen, actions); ;
return pipeSchemaAndActions(gen, actions);

return `v.nullish(${pipeSchemaAndActions(gen, actions)})`;
}
Expand All @@ -232,8 +232,7 @@ function generateFieldTypeValibotSchema(config: ValidationSchemaPluginConfig, vi

function actionsFromDirectives(config: ValidationSchemaPluginConfig, field: InputValueDefinitionNode | FieldDefinitionNode): string[] {
if (config.directives && field.directives) {
const formatted = formatDirectiveConfig(config.directives);
return buildApiForValibot(formatted, field.directives);
return buildApiForValibot(config.directives, field.directives);
}

return [];
Expand Down
5 changes: 2 additions & 3 deletions src/yup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {

import { resolveExternalModuleAndFn } from '@graphql-codegen/plugin-helpers';
import type { ValidationSchemaPluginConfig } from '../config.js';
import { buildApi, formatDirectiveConfig } from '../directive.js';
import { buildApi } from '../directive.js';
import { BaseSchemaVisitor } from '../schema_visitor.js';
import type { Visitor } from '../visitor.js';
import {
Expand Down Expand Up @@ -311,8 +311,7 @@ function shapeFields(fields: readonly (FieldDefinitionNode | InputValueDefinitio
function generateFieldYupSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number): string {
let gen = generateFieldTypeYupSchema(config, visitor, field.type);
if (config.directives && field.directives) {
const formatted = formatDirectiveConfig(config.directives);
gen += buildApi(formatted, field.directives);
gen += buildApi(config.directives, field.directives);
}
return indent(`${field.name.value}: ${maybeLazy(field.type, gen)}`, indentCount);
}
Expand Down
Loading

0 comments on commit d0a3bb8

Please sign in to comment.