UNPKG

graphql-codegen-typescript-validation-schema

Version:

GraphQL Code Generator plugin to generate form validation schema from your GraphQL schema

247 lines (246 loc) 8.33 kB
import { Kind, valueFromASTUntyped } from 'graphql'; import { isConvertableRegexp } from './regexp.js'; const DIRECTIVE_SCHEMA_KEY = '__directive'; function isFormattedDirectiveObjectArguments(arg) { 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) { return Object.fromEntries(Object.entries(config).map(([directive, arg]) => { if (Array.isArray(arg)) return [directive, { [DIRECTIVE_SCHEMA_KEY]: arg }]; if (typeof arg !== 'object' || arg === null || typeof arg === 'function') return [directive, { [DIRECTIVE_SCHEMA_KEY]: [arg] }]; const formatted = Object.fromEntries(Object.entries(arg).map(([arg, val]) => { if (Array.isArray(val)) return [arg, val]; if (typeof val !== 'object' || val === null || typeof val === 'function') 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) { 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 // { // 'required': { // 'msg': ['required', '$1'], // }, // 'constraint': { // 'minLength': ['min', '$1'], // 'format': { // 'uri': ['url', '$2'], // 'email': ['email', '$2'], // } // } // } // // GraphQL schema // ```graphql // input ExampleInput { // email: String! @required(msg: "message") @constraint(minLength: 100, format: "email") // } // ``` export function buildApi(config, directives) { 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 ?? []); }) .join(''); } // This function generates `[v.minLength(100), v.email()]` // NOTE: valibot's API is not a method chain, so it is prepared separately from buildApi. // // config // { // 'constraint': { // 'minLength': ['minLength', '$1'], // 'format': { // 'uri': ['url', '$2'], // 'email': ['email', '$2'], // } // } // } // // GraphQL schema // ```graphql // input ExampleInput { // email: String! @required(msg: "message") @constraint(minLength: 100, format: "email") // } // ``` // // 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, directives) { 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 ?? []); return apis.map(api => `v${api}`); }) .flat(); } function buildApiSchema(validationSchema, argValue) { if (!validationSchema) return ''; const schemaApi = validationSchema[0]; if (typeof schemaApi !== 'string') return ''; const schemaApiArgs = validationSchema.slice(1).map((templateArg) => { const gqlSchemaArgs = argValue ? apiArgsFromConstValueNode(argValue) : []; return applyArgToApiSchemaTemplate(templateArg, gqlSchemaArgs); }); return `.${schemaApi}(${schemaApiArgs.join(', ')})`; } function buildApiFromDirectiveArguments(config, args) { return _buildApiFromDirectiveArguments(config, args).join(''); } function _buildApiFromDirectiveArguments(config, args) { if (args.length === 0) { const validationSchema = config[DIRECTIVE_SCHEMA_KEY]; return [isFormattedDirectiveObjectArguments(validationSchema) ? '' : buildApiSchema(validationSchema)]; } 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); }); } function buildApiFromDirectiveObjectArguments(config, argValue) { if (argValue.kind !== Kind.STRING && argValue.kind !== Kind.ENUM) return ''; const validationSchema = config[argValue.value]; return buildApiSchema(validationSchema, argValue); } function applyArgToApiSchemaTemplate(template, apiArgs) { if (typeof template !== 'string') return stringify(applyArgsToTemplateValue(template, apiArgs)); const matches = template.matchAll(/\$(\d+)/g); for (const match of matches) { const placeholder = match[0]; // `$1` const idx = Number.parseInt(match[1], 10) - 1; // start with `1 - 1` const apiArg = apiArgs[idx]; if (apiArg === undefined) { template = template.replace(placeholder, ''); continue; } if (template === placeholder) return stringify(apiArg); template = template.replace(placeholder, apiArg); } if (template !== '') return stringify(template, true); return template; } function stringify(arg, quoteString) { if (Array.isArray(arg)) return arg.map(v => stringify(v, true)).join(','); if (typeof arg === 'function') return arg.toString(); if (typeof arg === 'string') { if (isConvertableRegexp(arg)) return arg; const v = tryEval(arg); if (v !== undefined) arg = v; if (quoteString) return JSON.stringify(arg); } if (typeof arg === 'boolean' || typeof arg === 'number' || typeof arg === 'bigint' || arg === 'undefined' || arg === null) return `${arg}`; return JSON.stringify(arg); } function applyArgsToTemplateValue(template, apiArgs) { if (typeof template === 'string') { if (template === '') return template; let value = template; for (const match of template.matchAll(/\$(\d+)/g)) { const placeholder = match[0]; const idx = Number.parseInt(match[1], 10) - 1; const apiArg = apiArgs[idx]; value = value.replace(placeholder, apiArg === undefined ? '' : apiArg); } return value; } if (Array.isArray(template)) return template.map(item => applyArgsToTemplateValue(item, apiArgs)); if (template && typeof template === 'object') { return Object.fromEntries(Object.entries(template).map(([key, value]) => [key, applyArgsToTemplateValue(value, apiArgs)])); } return template; } function apiArgsFromConstValueNode(value) { const val = valueFromASTUntyped(value); if (Array.isArray(val)) return val; return [val]; } function tryEval(maybeValidJavaScript) { try { // eslint-disable-next-line no-eval return eval(maybeValidJavaScript); } catch { return undefined; } } export const exportedForTesting = { applyArgToApiSchemaTemplate, buildApiFromDirectiveObjectArguments, buildApiFromDirectiveArguments, };