diff --git a/.gitignore b/.gitignore index 7a2da6f5..143f25a1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ dist package-lock.json .DS_Store tsconfig.tsbuildinfo -yarn-error.log \ No newline at end of file +yarn-error.log +.idea \ No newline at end of file diff --git a/README.md b/README.md index 94f52e6e..84d70cd4 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -318,9 +322,60 @@ export function ExampleInputSchema(): z.ZodSchema { 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: diff --git a/src/config.ts b/src/config.ts index 4e26153b..60b66eec 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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) +} + +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 { diff --git a/src/directive.ts b/src/directive.ts index d7807149..44ff644b 100644 --- a/src/directive.ts +++ b/src/directive.ts @@ -1,92 +1,9 @@ import type { ConstArgumentNode, ConstDirectiveNode, ConstValueNode } from 'graphql'; -import type { DirectiveConfig, DirectiveObjectArguments } from './config.js'; +import type { DirectiveConfig, DirectiveObjectArguments, SingleDirectiveConfig } from './config.js'; import { Kind, valueFromASTUntyped } from 'graphql'; 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 @@ -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): string { +export function buildApi(config: DirectiveConfig, directives: ReadonlyArray): 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('') } @@ -142,21 +65,31 @@ 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): string[] { +export function buildApiForValibot(config: DirectiveConfig, directives: ReadonlyArray): 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) => { @@ -166,27 +99,39 @@ function buildApiSchema(validationSchema: string[] | undefined, argValue: ConstV return `.${schemaApi}(${schemaApiArgs.join(', ')})`; } -function buildApiFromDirectiveArguments(config: FormattedDirectiveArguments, args: ReadonlyArray): string { +function buildApiFromDirectiveArguments(config: SingleDirectiveConfig, args: ReadonlyArray): string { return _buildApiFromDirectiveArguments(config, args).join(''); } -function _buildApiFromDirectiveArguments(config: FormattedDirectiveArguments, args: ReadonlyArray): string[] { +function _buildApiFromDirectiveArguments(config: SingleDirectiveConfig, args: ReadonlyArray): 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); } @@ -241,6 +186,13 @@ function apiArgsFromConstValueNode(value: ConstValueNode): any[] { return [val]; } +function directiveArgs(directive: ConstDirectiveNode): Record { + 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 diff --git a/src/myzod/index.ts b/src/myzod/index.ts index 8804a153..83515983 100644 --- a/src/myzod/index.ts +++ b/src/myzod/index.ts @@ -18,7 +18,7 @@ import { convertNameParts, DeclarationBlock, indent } from '@graphql-codegen/vis import { Kind, } from 'graphql'; -import { buildApi, formatDirectiveConfig } from '../directive.js'; +import { buildApi } from '../directive.js'; import { escapeGraphQLCharacters, InterfaceTypeDefinitionBuilder, @@ -320,8 +320,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; } diff --git a/src/valibot/index.ts b/src/valibot/index.ts index 71c4d09b..66b3cfa5 100644 --- a/src/valibot/index.ts +++ b/src/valibot/index.ts @@ -14,7 +14,7 @@ import type { ValidationSchemaPluginConfig } from '../config.js'; import type { Visitor } from '../visitor.js'; import { DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; -import { buildApiForValibot, formatDirectiveConfig } from '../directive.js'; +import { buildApiForValibot } from '../directive.js'; import { InterfaceTypeDefinitionBuilder, isInput, @@ -225,7 +225,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)})`; } @@ -235,8 +235,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 []; diff --git a/src/yup/index.ts b/src/yup/index.ts index b1805d8a..51edb03e 100644 --- a/src/yup/index.ts +++ b/src/yup/index.ts @@ -18,7 +18,7 @@ import { convertNameParts, DeclarationBlock, indent } from '@graphql-codegen/vis import { Kind, } from 'graphql'; -import { buildApi, formatDirectiveConfig } from '../directive.js'; +import { buildApi } from '../directive.js'; import { escapeGraphQLCharacters, InterfaceTypeDefinitionBuilder, @@ -316,8 +316,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); } diff --git a/src/zod/index.ts b/src/zod/index.ts index b1164fb0..752bd026 100644 --- a/src/zod/index.ts +++ b/src/zod/index.ts @@ -18,7 +18,7 @@ import { convertNameParts, DeclarationBlock, indent } from '@graphql-codegen/vis import { Kind, } from 'graphql'; -import { buildApi, formatDirectiveConfig } from '../directive.js'; +import { buildApi } from '../directive.js'; import { escapeGraphQLCharacters, InterfaceTypeDefinitionBuilder, @@ -336,8 +336,7 @@ function generateFieldTypeZodSchema(config: ValidationSchemaPluginConfig, visito 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; } diff --git a/tests/directive.spec.ts b/tests/directive.spec.ts index 1fa8036a..661b2099 100644 --- a/tests/directive.spec.ts +++ b/tests/directive.spec.ts @@ -1,18 +1,16 @@ -import type { ConstArgumentNode, ConstDirectiveNode, ConstValueNode, NameNode } from 'graphql'; -import type { DirectiveConfig, DirectiveObjectArguments } from '../src/config'; - import type { - FormattedDirectiveArguments, - FormattedDirectiveConfig, - FormattedDirectiveObjectArguments, -} from '../src/directive'; + ConstArgumentNode, + ConstDirectiveNode, + ConstValueNode, + NameNode, +} from 'graphql'; + +import type { DirectiveConfig, DirectiveObjectArguments, SingleDirectiveConfig } from '../src/config'; import { Kind, parseConstValue } from 'graphql'; import { buildApi, buildApiForValibot, exportedForTesting, - formatDirectiveConfig, - formatDirectiveObjectArguments, } from '../src/directive'; const { applyArgToApiSchemaTemplate, buildApiFromDirectiveObjectArguments, buildApiFromDirectiveArguments } @@ -44,112 +42,6 @@ function buildConstDirectiveNodes(name: string, args: Record): C } describe('format directive config', () => { - describe('formatDirectiveObjectArguments', () => { - const cases: { - name: string - arg: DirectiveObjectArguments - want: FormattedDirectiveObjectArguments - }[] = [ - { - name: 'normal', - arg: { - uri: 'url', - email: 'email', - }, - want: { - uri: ['url', '$2'], - email: ['email', '$2'], - }, - }, - { - name: 'contains array', - arg: { - startWith: ['matches', '/^$2/'], - email: 'email', - }, - want: { - startWith: ['matches', '/^$2/'], - email: ['email', '$2'], - }, - }, - ]; - for (const tc of cases) { - it(tc.name, () => { - const got = formatDirectiveObjectArguments(tc.arg); - expect(got).toStrictEqual(tc.want); - }); - } - }); - - describe('formatDirectiveConfig', () => { - const cases: { - name: string - arg: DirectiveConfig - want: FormattedDirectiveConfig - }[] = [ - { - name: 'normal', - arg: { - required: { - msg: 'required', - }, - constraint: { - minLength: 'min', - format: { - uri: 'url', - email: 'email', - }, - }, - }, - want: { - required: { - msg: ['required', '$1'], - }, - constraint: { - minLength: ['min', '$1'], - format: { - uri: ['url', '$2'], - email: ['email', '$2'], - }, - }, - }, - }, - { - name: 'complex', - arg: { - required: { - msg: 'required', - }, - constraint: { - startWith: ['matches', '/^$1/g'], - format: { - uri: ['url', '$2'], - email: 'email', - }, - }, - }, - want: { - required: { - msg: ['required', '$1'], - }, - constraint: { - startWith: ['matches', '/^$1/g'], - format: { - uri: ['url', '$2'], - email: ['email', '$2'], - }, - }, - }, - }, - ]; - for (const tc of cases) { - it(tc.name, () => { - const got = formatDirectiveConfig(tc.arg); - expect(got).toStrictEqual(tc.want); - }); - } - }); - describe('applyArgToApiSchemaTemplate', () => { const cases: { name: string @@ -301,11 +193,21 @@ describe('format directive config', () => { const cases: { name: string args: { - config: FormattedDirectiveObjectArguments + config: DirectiveObjectArguments argValue: ConstValueNode } want: string }[] = [ + { + name: 'simple', + args: { + config: { + uri: 'url', + }, + argValue: parseConstValue(`"uri"`), + }, + want: `.url()`, + }, { name: 'contains in config', args: { @@ -336,6 +238,16 @@ describe('format directive config', () => { }, want: ``, }, + { + name: 'function', + args: { + config: { + UNIQUE: () => `.refine((items) => new Set(items).size === items.length])`, + }, + argValue: parseConstValue(`UNIQUE`), + }, + want: `.refine((items) => new Set(items).size === items.length])`, + }, ]; for (const tc of cases) { it(tc.name, () => { @@ -350,11 +262,23 @@ describe('format directive config', () => { const cases: { name: string args: { - config: FormattedDirectiveArguments + config: SingleDirectiveConfig args: ReadonlyArray } want: string }[] = [ + { + name: 'simple', + args: { + config: { + msg: 'required', + }, + args: buildConstArgumentNodes({ + msg: `"hello"`, + }), + }, + want: `.required("hello")`, + }, { name: 'string', args: { @@ -528,6 +452,18 @@ describe('format directive config', () => { }, want: `.required("message")`, }, + { + name: 'function', + args: { + config: { + max: value => `.refine(items => items.length <= ${value})`, + }, + args: buildConstArgumentNodes({ + max: `10`, + }), + }, + want: `.refine(items => items.length <= 10)`, + }, ]; for (const tc of cases) { it(tc.name, () => { @@ -542,7 +478,7 @@ describe('format directive config', () => { const cases: { name: string args: { - config: FormattedDirectiveConfig + config: DirectiveConfig args: ReadonlyArray } want: string @@ -552,7 +488,7 @@ describe('format directive config', () => { args: { config: { required: { - msg: ['required', '$1'], + msg: 'required', }, constraint: { minLength: ['min', '$1'], @@ -576,6 +512,19 @@ describe('format directive config', () => { }, want: `.required("message").min(100).email()`, }, + { + name: 'simple', + args: { + config: { + required: 'required', + }, + args: [ + // @required() + buildConstDirectiveNodes('required', {}), + ], + }, + want: `.required()`, + }, { name: 'enum', args: { @@ -595,6 +544,24 @@ describe('format directive config', () => { }, want: `.uri()`, }, + { + name: 'function', + args: { + config: { + between: (args) => { + return `.refine(arr => arr.length > ${args.min} && arr.length < ${args.max})`; + }, + }, + args: [ + // @between(min: 1, max: 99) + buildConstDirectiveNodes('between', { + min: `1`, + max: `99`, + }), + ], + }, + want: `.refine(arr => arr.length > 1 && arr.length < 99)`, + }, ]; for (const tc of cases) { it(tc.name, () => { @@ -609,7 +576,7 @@ describe('format directive config', () => { const cases: { name: string args: { - config: FormattedDirectiveConfig + config: DirectiveConfig args: ReadonlyArray } want: string[] @@ -622,7 +589,7 @@ describe('format directive config', () => { minLength: ['minLength', '$1'], format: { uri: ['url'], - email: ['email'], + email: 'email', }, }, },