Skip to content

Commit b2e0c15

Browse files
committed
refactor(kotlin): improve validation annotation structure with typed interfaces
- Add DirectiveArgument interface for better type safety - Add ValidationAnnotation interface to represent annotation objects - Refactor parseDirectiveArgs to return structured objects instead of strings - Update extractValidationAnnotations methods to use typed interfaces - Improve formatValidationAnnotations to handle structured annotation data
1 parent 0ae1260 commit b2e0c15

5 files changed

Lines changed: 366 additions & 3 deletions

File tree

packages/plugins/java/kotlin/src/config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,20 @@ export interface KotlinResolversPluginRawConfig extends RawConfig {
7272
* ```
7373
*/
7474
omitJvmStatic?: boolean;
75+
76+
/**
77+
* @default false
78+
* @description Enable Jakarta Validation annotations from GraphQL directives
79+
*
80+
* @exampleMarkdown
81+
* ```yaml
82+
* generates:
83+
* src/main/kotlin/my-org/my-app/Types.kt:
84+
* plugins:
85+
* - kotlin
86+
* config:
87+
* validationAnnotations: true
88+
* ```
89+
*/
90+
validationAnnotations?: boolean;
7591
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* GraphQL validation directives to Jakarta Validation annotations mapping
3+
*/
4+
5+
export const VALIDATION_DIRECTIVES: Record<string, string> = {
6+
'@notBlank': 'NotBlank',
7+
'@size': 'Size',
8+
'@email': 'Email',
9+
'@pattern': 'Pattern',
10+
'@positive': 'Positive',
11+
'@future': 'Future',
12+
'@past': 'Past',
13+
'@min': 'Min',
14+
'@max': 'Max',
15+
'@notNull': 'NotNull',
16+
'@null': 'Null',
17+
'@assertTrue': 'AssertTrue',
18+
'@assertFalse': 'AssertFalse',
19+
'@negative': 'Negative',
20+
'@negativeOrZero': 'NegativeOrZero',
21+
'@positiveOrZero': 'PositiveOrZero',
22+
'@decimalMin': 'DecimalMin',
23+
'@decimalMax': 'DecimalMax',
24+
'@digits': 'Digits'
25+
};
26+
27+
/**
28+
* Validation directive parameter mapping
29+
*/
30+
export const VALIDATION_PARAM_MAPPING: Record<string, Record<string, string>> = {
31+
'@size': {
32+
'min': 'min',
33+
'max': 'max',
34+
'message': 'message'
35+
},
36+
'@pattern': {
37+
'regexp': 'regexp',
38+
'message': 'message'
39+
},
40+
'@min': {
41+
'value': 'value',
42+
'message': 'message'
43+
},
44+
'@max': {
45+
'value': 'value',
46+
'message': 'message'
47+
},
48+
'@decimalMin': {
49+
'value': 'value',
50+
'message': 'message'
51+
},
52+
'@decimalMax': {
53+
'value': 'value',
54+
'message': 'message'
55+
},
56+
'@digits': {
57+
'integer': 'integer',
58+
'fraction': 'fraction',
59+
'message': 'message'
60+
}
61+
};
62+
63+
/**
64+
* Parse GraphQL directive arguments
65+
*/
66+
export function parseDirectiveArgs(
67+
directiveName: string,
68+
args: any[]
69+
): string[] {
70+
const paramMapping = VALIDATION_PARAM_MAPPING[directiveName] || {};
71+
return args.map(arg => {
72+
const argName = arg.name.value;
73+
const mappedArgName = paramMapping[argName] || argName;
74+
const value = formatArgValue(arg.value);
75+
return `${mappedArgName} = ${value}`;
76+
});
77+
}
78+
79+
/**
80+
* Format argument values
81+
*/
82+
function formatArgValue(value: any): string {
83+
switch (value.kind) {
84+
case 'StringValue':
85+
// Escape backslashes in string values
86+
return `"${value.value.replace(/\\/g, '\\')}"`;
87+
case 'IntValue':
88+
case 'FloatValue':
89+
case 'BooleanValue':
90+
return value.value;
91+
default:
92+
// Escape backslashes in string values for default case
93+
return `"${value.value.replace(/\\/g, '\\')}"`;
94+
}
95+
}
96+
97+
/**
98+
* Check if directive is a validation directive
99+
*/
100+
export function isValidationDirective(directiveName: string): boolean {
101+
return VALIDATION_DIRECTIVES.hasOwnProperty(`@${directiveName}`);
102+
}

packages/plugins/java/kotlin/src/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,21 @@ export const plugin: PluginFunction<KotlinResolversPluginRawConfig> = async (
2222
const astNode = getCachedDocumentNodeFromSchema(schema);
2323
const visitorResult = oldVisit(astNode, { leave: visitor as any });
2424
const packageName = visitor.getPackageName();
25-
const blockContent = visitorResult.definitions.filter(d => typeof d === 'string').join('\n\n');
25+
let blockContent = visitorResult.definitions.filter(d => typeof d === 'string').join('\n\n');
26+
27+
// Add Jakarta Validation imports if validation annotations are enabled
28+
if (config.validationAnnotations && blockContent.includes('@field:')) {
29+
const packageRegex = /(package\s+[^\n]+)/;
30+
const match = blockContent.match(packageRegex);
31+
if (match) {
32+
blockContent = blockContent.replace(
33+
packageRegex,
34+
`${match[1]}\n\nimport jakarta.validation.constraints.*`
35+
);
36+
} else {
37+
blockContent = `import jakarta.validation.constraints.*\n\n${blockContent}`;
38+
}
39+
}
2640

2741
return [packageName, blockContent].join('\n');
2842
};

packages/plugins/java/kotlin/src/visitor.ts

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import {
2626
transformComment,
2727
} from '@graphql-codegen/visitor-plugin-common';
2828
import { KotlinResolversPluginRawConfig } from './config.js';
29+
import {
30+
VALIDATION_DIRECTIVES,
31+
parseDirectiveArgs,
32+
} from './directive-mapping.js';
2933

3034
export const KOTLIN_SCALARS = {
3135
ID: 'Any',
@@ -41,6 +45,7 @@ export interface KotlinResolverParsedConfig extends ParsedConfig {
4145
enumValues: EnumValuesMap;
4246
withTypes: boolean;
4347
omitJvmStatic: boolean;
48+
validationAnnotations?: boolean;
4449
}
4550

4651
export interface FieldDefinitionReturnType {
@@ -64,6 +69,7 @@ export class KotlinResolversVisitor extends BaseVisitor<
6469
package: rawConfig.package || defaultPackageName,
6570
scalars: buildScalarsFromConfig(_schema, rawConfig, KOTLIN_SCALARS),
6671
omitJvmStatic: rawConfig.omitJvmStatic || false,
72+
validationAnnotations: rawConfig.validationAnnotations || false,
6773
});
6874
}
6975

@@ -177,6 +183,110 @@ ${enumValues}
177183
return result;
178184
}
179185

186+
/**
187+
* Extract validation annotations from field directives
188+
*/
189+
private extractValidationAnnotations(field: InputValueDefinitionNode): string[] {
190+
if (!field.directives || field.directives.length === 0) {
191+
return [];
192+
}
193+
194+
const annotations: string[] = [];
195+
196+
for (const directive of field.directives) {
197+
const directiveName = `@${directive.name.value}`;
198+
199+
// Check if it's a validation directive
200+
if (VALIDATION_DIRECTIVES[directiveName]) {
201+
const annotationName = VALIDATION_DIRECTIVES[directiveName];
202+
203+
// Parse directive arguments
204+
let annotationParams = '';
205+
if (directive.arguments && directive.arguments.length > 0) {
206+
const params = parseDirectiveArgs(directiveName, Array.from(directive.arguments));
207+
annotationParams = `(${params.join(', ')})`;
208+
}
209+
210+
annotations.push(`${annotationName}${annotationParams}`);
211+
}
212+
}
213+
214+
return annotations;
215+
}
216+
217+
/**
218+
* Format validation annotations
219+
*/
220+
private formatValidationAnnotations(annotations: string[]): string[] {
221+
// All validation annotations need @field: prefix because they are field annotations, not class annotations
222+
const prefix = '@field:';
223+
return annotations.map(annotation => `${prefix}${annotation}`);
224+
}
225+
226+
/**
227+
* Add validation annotations to field
228+
*/
229+
private addValidationAnnotations(
230+
field: InputValueDefinitionNode,
231+
_typeInfo: { nullable: boolean }
232+
): string[] {
233+
const annotations = this.extractValidationAnnotations(field);
234+
235+
if (annotations.length === 0) {
236+
return [];
237+
}
238+
239+
return this.formatValidationAnnotations(annotations);
240+
}
241+
242+
/**
243+
* Extract validation annotations from object type field directives
244+
*/
245+
private extractValidationAnnotationsForField(field: FieldDefinitionNode): string[] {
246+
if (!field.directives || field.directives.length === 0) {
247+
return [];
248+
}
249+
250+
const annotations: string[] = [];
251+
252+
for (const directive of field.directives) {
253+
const directiveName = `@${directive.name.value}`;
254+
255+
// Check if it's a validation directive
256+
if (VALIDATION_DIRECTIVES[directiveName]) {
257+
const annotationName = VALIDATION_DIRECTIVES[directiveName];
258+
259+
// Parse directive arguments
260+
let annotationParams = '';
261+
if (directive.arguments && directive.arguments.length > 0) {
262+
const params = parseDirectiveArgs(directiveName, Array.from(directive.arguments));
263+
annotationParams = `(${params.join(', ')})`;
264+
}
265+
266+
annotations.push(`${annotationName}${annotationParams}`);
267+
}
268+
}
269+
270+
return annotations;
271+
}
272+
273+
/**
274+
* Add validation annotations to object type field constructor parameters
275+
*/
276+
private addValidationAnnotationsForField(
277+
field: FieldDefinitionNode,
278+
_typeInfo: { nullable: boolean }
279+
): string[] {
280+
const annotations = this.extractValidationAnnotationsForField(field);
281+
282+
if (annotations.length === 0) {
283+
return [];
284+
}
285+
286+
// For object type fields, annotations are added directly to constructor parameters
287+
return annotations;
288+
}
289+
180290
protected buildInputTransfomer(
181291
name: string,
182292
inputValueArray: ReadonlyArray<InputValueDefinitionNode>,
@@ -187,10 +297,24 @@ ${enumValues}
187297
const initialValue = this.initialValue(typeToUse.typeName, arg.defaultValue);
188298
const initial = initialValue ? ` = ${initialValue}` : typeToUse.nullable ? ' = null' : '';
189299

190-
return indent(
300+
// Get validation annotations if enabled
301+
const validationAnnotations = this.config.validationAnnotations ?
302+
this.addValidationAnnotations(arg, typeToUse) : [];
303+
304+
// Build field declaration, including annotations
305+
let fieldDeclaration = '';
306+
if (validationAnnotations.length > 0) {
307+
// Add validation annotations
308+
fieldDeclaration += validationAnnotations.map(ann => indent(ann, 2)).join('\n') + '\n';
309+
}
310+
311+
// Add field declaration
312+
fieldDeclaration += indent(
191313
`val ${arg.name.value}: ${typeToUse.typeName}${typeToUse.nullable ? '?' : ''}${initial}`,
192314
2,
193315
);
316+
317+
return fieldDeclaration;
194318
})
195319
.join(',\n');
196320
let suppress = '';
@@ -252,10 +376,24 @@ ${ctorSet}
252376
}
253377
const typeToUse = this.resolveInputFieldType(arg.type);
254378

255-
return indent(
379+
// Get validation annotations if enabled
380+
const validationAnnotations = this.config.validationAnnotations ?
381+
this.addValidationAnnotationsForField(arg, typeToUse) : [];
382+
383+
// Build field declaration, including annotations
384+
let fieldDeclaration = '';
385+
if (validationAnnotations.length > 0) {
386+
// Add validation annotations
387+
fieldDeclaration += validationAnnotations.map(ann => indent(ann, 2)).join('\n') + '\n';
388+
}
389+
390+
// Add field declaration
391+
fieldDeclaration += indent(
256392
`val ${arg.name.value}: ${typeToUse.typeName}${typeToUse.nullable ? '?' : ''}`,
257393
2,
258394
);
395+
396+
return fieldDeclaration;
259397
})
260398
.join(',\n');
261399

0 commit comments

Comments
 (0)