Skip to content

Commit 69e0439

Browse files
committed
added directive.ts
1 parent 078c82d commit 69e0439

File tree

2 files changed

+286
-2
lines changed

2 files changed

+286
-2
lines changed

src/config.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ import { TypeScriptPluginConfig } from "@graphql-codegen/typescript";
22

33
export type ValidationSchema = "yup";
44

5+
export interface DirectiveConfig {
6+
[directive: string]: {
7+
[argument: string]: string | string[] | DirectiveObjectArguments;
8+
};
9+
}
10+
11+
export interface DirectiveObjectArguments {
12+
[matched: string]: string | string[];
13+
}
14+
515
export interface ValidationSchemaPluginConfig extends TypeScriptPluginConfig {
616
/**
717
* @description specify generate schema
@@ -66,7 +76,48 @@ export interface ValidationSchemaPluginConfig extends TypeScriptPluginConfig {
6676
/**
6777
* @description this is for yup schema. use this when you specified `schema: yup`
6878
*/
69-
yup?: YupSchemaPluginConfig
79+
yup?: YupSchemaPluginConfig;
80+
/**
81+
* @description Generates yup schema as strict.
82+
* @exampleMarkdown
83+
* ```yml
84+
* generates:
85+
* path/to/file.ts:
86+
* plugins:
87+
* - graphql-codegen-validation-schema:
88+
* config:
89+
* schema: yup
90+
* directives:
91+
* required:
92+
* msg: required
93+
* # This is example using constraint directive.
94+
* # see: https://github.com/confuser/graphql-constraint-directive
95+
* constraint:
96+
* minLength: min # same as ['min', '$1']
97+
* maxLength: max
98+
* startsWith: ["matches", "^$1"]
99+
* endsWith: ["matches", "$1$"]
100+
* contains: ["matches", "$1"]
101+
* notContains: ["matches", "^((?!$1).)*$"]
102+
* pattern: matches
103+
* format:
104+
* # For example, `@constraint(format: "uri")`. this case $1 will be "uri".
105+
* # Therefore the generator generates yup schema `.url()` followed by `uri: 'url'`
106+
* # If $1 does not match anywhere, the generator will ignore.
107+
* uri: url
108+
* email: email
109+
* uuid: uuid
110+
* # yup does not have `ipv4` API. If you want to add this,
111+
* # you need to add the logic using `yup.addMethod`.
112+
* # see: https://github.com/jquense/yup#addmethodschematype-schema-name-string-method--schema-void
113+
* ipv4: ipv4
114+
* min: ["min", "$1 - 1"]
115+
* max: ["max", "$1 + 1"]
116+
* exclusiveMin: min
117+
* exclusiveMax: max
118+
* ```
119+
*/
120+
directives?: DirectiveConfig;
70121
}
71122

72123
interface YupSchemaPluginConfig {
@@ -86,5 +137,5 @@ interface YupSchemaPluginConfig {
86137
* strict: true
87138
* ```
88139
*/
89-
strict?: boolean
140+
strict?: boolean;
90141
}

src/directive.ts

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import {
2+
ConstArgumentNode,
3+
ConstDirectiveNode,
4+
ConstValueNode,
5+
Kind,
6+
NameNode,
7+
valueFromASTUntyped,
8+
} from "graphql";
9+
import { DirectiveConfig, DirectiveObjectArguments } from "./config";
10+
11+
export interface FormattedDirectiveConfig {
12+
[directive: string]: FormattedDirectiveArguments;
13+
}
14+
15+
export interface FormattedDirectiveArguments {
16+
[argument: string]: string[] | FormattedDirectiveObjectArguments | undefined;
17+
}
18+
19+
export interface FormattedDirectiveObjectArguments {
20+
[matched: string]: string[] | undefined;
21+
}
22+
23+
const isFormattedDirectiveObjectArguments = (
24+
arg: FormattedDirectiveArguments[keyof FormattedDirectiveArguments]
25+
): arg is FormattedDirectiveObjectArguments => arg !== undefined && !Array.isArray(arg);
26+
27+
// ```yml
28+
// directives:
29+
// required:
30+
// msg: required
31+
// constraint:
32+
// minLength: min
33+
// format:
34+
// uri: url
35+
// email: email
36+
// ```
37+
//
38+
// This function convterts to like below
39+
// {
40+
// 'required': {
41+
// 'msg': ['required', '$1'],
42+
// },
43+
// 'constraint': {
44+
// 'minLength': ['min', '$1'],
45+
// 'format': {
46+
// 'uri': ['url', '$2'],
47+
// 'email': ['email', '$2'],
48+
// }
49+
// }
50+
// }
51+
export const formatDirectiveConfig = (
52+
config: DirectiveConfig
53+
): FormattedDirectiveConfig => {
54+
return Object.fromEntries(
55+
Object.entries(config).map(([directive, arg]) => {
56+
const formatted = Object.fromEntries(
57+
Object.entries(arg).map(([arg, val]) => {
58+
if (Array.isArray(val)) {
59+
return [arg, val];
60+
}
61+
if (typeof val === "string") {
62+
return [arg, [val, "$1"]];
63+
}
64+
return [arg, formatDirectiveObjectArguments(val)];
65+
})
66+
);
67+
return [directive, formatted];
68+
})
69+
);
70+
};
71+
72+
// ```yml
73+
// format:
74+
// # For example, `@constraint(format: "uri")`. this case $1 will be "uri".
75+
// # Therefore the generator generates yup schema `.url()` followed by `uri: 'url'`
76+
// # If $1 does not match anywhere, the generator will ignore.
77+
// uri: url
78+
// email: ["email", "$2"]
79+
// ```
80+
//
81+
// This function convterts to like below
82+
// {
83+
// 'uri': ['url', '$2'],
84+
// 'email': ['email'],
85+
// }
86+
export const formatDirectiveObjectArguments = (
87+
args: DirectiveObjectArguments
88+
): FormattedDirectiveObjectArguments => {
89+
const formatted = Object.entries(args).map(([arg, val]) => {
90+
if (Array.isArray(val)) {
91+
return [arg, val];
92+
}
93+
return [arg, [val, "$2"]];
94+
});
95+
return Object.fromEntries(formatted);
96+
};
97+
98+
// This function generates `.required("message").min(100).email()`
99+
//
100+
// config
101+
// {
102+
// 'required': {
103+
// 'msg': ['required', '$1'],
104+
// },
105+
// 'constraint': {
106+
// 'minLength': ['min', '$1'],
107+
// 'format': {
108+
// 'uri': ['url', '$2'],
109+
// 'email': ['email', '$2'],
110+
// }
111+
// }
112+
// }
113+
//
114+
// GraphQL schema
115+
// ```graphql
116+
// input ExampleInput {
117+
// email: String! @required(msg: "message") @constraint(minLength: 100, format: "email")
118+
// }
119+
// ```
120+
export const buildApi = (
121+
config: FormattedDirectiveConfig,
122+
directives: ReadonlyArray<ConstDirectiveNode>
123+
): string =>
124+
directives
125+
.map((directive) => {
126+
const directiveName = directive.name.value;
127+
const argsConfig = config[directiveName];
128+
return buildApiFromDirectiveArguments(
129+
argsConfig,
130+
directive.arguments ?? []
131+
);
132+
})
133+
.join("");
134+
135+
const buildApiSchema = (
136+
validationSchema: string[] | undefined,
137+
argValue: ConstValueNode
138+
): string => {
139+
if (!validationSchema) {
140+
return "";
141+
}
142+
const schemaApi = validationSchema[0];
143+
const schemaApiArgs = validationSchema.slice(1).map((templateArg) => {
144+
const gqlSchemaArgs = apiArgsFromConstValueNode(argValue);
145+
return applyArgToApiSchemaTemplate(templateArg, gqlSchemaArgs);
146+
});
147+
return `.${schemaApi}(${schemaApiArgs.join(", ")})`;
148+
};
149+
150+
const buildApiFromDirectiveArguments = (
151+
config: FormattedDirectiveArguments,
152+
args: ReadonlyArray<ConstArgumentNode>
153+
): string => {
154+
return args
155+
.map((arg) => {
156+
const argName = arg.name.value;
157+
const validationSchema = config[argName];
158+
if (isFormattedDirectiveObjectArguments(validationSchema)) {
159+
return buildApiFromDirectiveObjectArguments(
160+
validationSchema,
161+
arg.value
162+
);
163+
}
164+
return buildApiSchema(validationSchema, arg.value);
165+
})
166+
.join("");
167+
};
168+
169+
const buildApiFromDirectiveObjectArguments = (
170+
config: FormattedDirectiveObjectArguments,
171+
argValue: ConstValueNode
172+
): string => {
173+
if (argValue.kind !== Kind.STRING) {
174+
return "";
175+
}
176+
const validationSchema = config[argValue.value];
177+
return buildApiSchema(validationSchema, argValue);
178+
};
179+
180+
const applyArgToApiSchemaTemplate = (
181+
template: string,
182+
apiArgs: any[]
183+
): string => {
184+
const matches = template.matchAll(/[$](\d+)/g);
185+
for (const match of matches) {
186+
const placeholder = match[0]; // `$1`
187+
const idx = parseInt(match[1], 10) - 1; // start with `1 - 1`
188+
const apiArg = apiArgs[idx];
189+
if (!apiArg) {
190+
template = template.replace(placeholder, "");
191+
continue;
192+
}
193+
if (template === placeholder) {
194+
return stringify(apiArg);
195+
}
196+
template = template.replace(placeholder, apiArg);
197+
}
198+
if (template !== "") {
199+
return stringify(template, true);
200+
}
201+
return template;
202+
};
203+
204+
const stringify = (arg: any, quoteString?: boolean): string => {
205+
if (Array.isArray(arg)) {
206+
return arg.map((v) => stringify(v, true)).join(",");
207+
}
208+
if (quoteString && typeof arg === "string") {
209+
return JSON.stringify(arg);
210+
}
211+
if (
212+
typeof arg === "boolean" ||
213+
typeof arg === "number" ||
214+
typeof arg === "bigint"
215+
) {
216+
return `${arg}`;
217+
}
218+
return JSON.stringify(arg);
219+
};
220+
221+
const apiArgsFromConstValueNode = (value: ConstValueNode): any[] => {
222+
const val = valueFromASTUntyped(value);
223+
if (Array.isArray(val)) {
224+
return val;
225+
}
226+
return [val];
227+
};
228+
229+
export const exportedForTesting = {
230+
applyArgToApiSchemaTemplate,
231+
buildApiFromDirectiveObjectArguments,
232+
buildApiFromDirectiveArguments,
233+
};

0 commit comments

Comments
 (0)