Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/plugins/java/kotlin/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,20 @@ export interface KotlinResolversPluginRawConfig extends RawConfig {
* ```
*/
omitJvmStatic?: boolean;

/**
* @default false
* @description Enable Jakarta Validation annotations from GraphQL directives
*
* @exampleMarkdown
* ```yaml
* generates:
* src/main/kotlin/my-org/my-app/Types.kt:
* plugins:
* - kotlin
* config:
* validationAnnotations: true
* ```
*/
validationAnnotations?: boolean;
}
110 changes: 110 additions & 0 deletions packages/plugins/java/kotlin/src/directive-mapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* GraphQL validation directives to Jakarta Validation annotations mapping
*/

export interface DirectiveArgument {
name: string;
value: string;
}

export const VALIDATION_DIRECTIVES: Record<string, string> = {
'@notBlank': 'NotBlank',
'@size': 'Size',
'@email': 'Email',
'@pattern': 'Pattern',
'@positive': 'Positive',
'@future': 'Future',
'@past': 'Past',
'@min': 'Min',
'@max': 'Max',
'@notNull': 'NotNull',
'@null': 'Null',
'@assertTrue': 'AssertTrue',
'@assertFalse': 'AssertFalse',
'@negative': 'Negative',
'@negativeOrZero': 'NegativeOrZero',
'@positiveOrZero': 'PositiveOrZero',
'@decimalMin': 'DecimalMin',
'@decimalMax': 'DecimalMax',
'@digits': 'Digits'
};

/**
* Validation directive parameter mapping
*/
export const VALIDATION_PARAM_MAPPING: Record<string, Record<string, string>> = {
'@size': {
'min': 'min',
'max': 'max',
'message': 'message'
},
'@pattern': {
'regexp': 'regexp',
'message': 'message'
},
'@min': {
'value': 'value',
'message': 'message'
},
'@max': {
'value': 'value',
'message': 'message'
},
'@decimalMin': {
'value': 'value',
'message': 'message'
},
'@decimalMax': {
'value': 'value',
'message': 'message'
},
'@digits': {
'integer': 'integer',
'fraction': 'fraction',
'message': 'message'
}
};

/**
* Parse GraphQL directive arguments
*/
export function parseDirectiveArgs(
directiveName: string,
args: any[]
): DirectiveArgument[] {
const paramMapping = VALIDATION_PARAM_MAPPING[directiveName] || {};
return args.map(arg => {
const argName = arg.name.value;
const mappedArgName = paramMapping[argName] || argName;
const value = formatArgValue(arg.value);
return {
name: mappedArgName,
value: value
};
});
}

/**
* Format argument values
*/
function formatArgValue(value: any): string {
switch (value.kind) {
case 'StringValue':
// Escape backslashes in string values
return `"${value.value.replace(/\\/g, '\\')}"`;
case 'IntValue':
case 'FloatValue':
case 'BooleanValue':
return value.value;
default:
// Escape backslashes in string values for default case
return `"${value.value.replace(/\\/g, '\\')}"`;
}
}

/**
* Check if directive is a validation directive
*/
export function isValidationDirective(directiveName: string): boolean {
return VALIDATION_DIRECTIVES.hasOwnProperty(`@${directiveName}`);
}
16 changes: 15 additions & 1 deletion packages/plugins/java/kotlin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,21 @@ export const plugin: PluginFunction<KotlinResolversPluginRawConfig> = async (
const astNode = getCachedDocumentNodeFromSchema(schema);
const visitorResult = oldVisit(astNode, { leave: visitor as any });
const packageName = visitor.getPackageName();
const blockContent = visitorResult.definitions.filter(d => typeof d === 'string').join('\n\n');
let blockContent = visitorResult.definitions.filter(d => typeof d === 'string').join('\n\n');

// Add Jakarta Validation imports if validation annotations are enabled
if (config.validationAnnotations && blockContent.includes('@field:')) {
const packageRegex = /(package\s+[^\n]+)/;
const match = blockContent.match(packageRegex);
if (match) {
blockContent = blockContent.replace(
packageRegex,
`${match[1]}\n\nimport jakarta.validation.constraints.*`
);
} else {
blockContent = `import jakarta.validation.constraints.*\n\n${blockContent}`;
}
}

return [packageName, blockContent].join('\n');
};
161 changes: 159 additions & 2 deletions packages/plugins/java/kotlin/src/visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ import {
transformComment,
} from '@graphql-codegen/visitor-plugin-common';
import { KotlinResolversPluginRawConfig } from './config.js';
import {
VALIDATION_DIRECTIVES,
parseDirectiveArgs,
DirectiveArgument,
} from './directive-mapping.js';

export interface ValidationAnnotation {
name: string;
params?: DirectiveArgument[];
}

export const KOTLIN_SCALARS = {
ID: 'Any',
Expand All @@ -41,6 +51,7 @@ export interface KotlinResolverParsedConfig extends ParsedConfig {
enumValues: EnumValuesMap;
withTypes: boolean;
omitJvmStatic: boolean;
validationAnnotations?: boolean;
}

export interface FieldDefinitionReturnType {
Expand All @@ -64,6 +75,7 @@ export class KotlinResolversVisitor extends BaseVisitor<
package: rawConfig.package || defaultPackageName,
scalars: buildScalarsFromConfig(_schema, rawConfig, KOTLIN_SCALARS),
omitJvmStatic: rawConfig.omitJvmStatic || false,
validationAnnotations: rawConfig.validationAnnotations || false,
});
}

Expand Down Expand Up @@ -177,6 +189,123 @@ ${enumValues}
return result;
}

/**
* Extract validation annotations from field directives
*/
private extractValidationAnnotations(field: InputValueDefinitionNode): ValidationAnnotation[] {
if (!field.directives || field.directives.length === 0) {
return [];
}

const annotations: ValidationAnnotation[] = [];

for (const directive of field.directives) {
const directiveName = `@${directive.name.value}`;

// Check if it's a validation directive
if (VALIDATION_DIRECTIVES[directiveName]) {
const annotationName = VALIDATION_DIRECTIVES[directiveName];

// Parse directive arguments
let annotationParams: DirectiveArgument[] | undefined;
if (directive.arguments && directive.arguments.length > 0) {
annotationParams = parseDirectiveArgs(directiveName, Array.from(directive.arguments));
}

annotations.push({
name: annotationName,
params: annotationParams
});
}
}

return annotations;
}

/**
* Format validation annotations
*/
private formatValidationAnnotations(annotations: ValidationAnnotation[]): string[] {
// All validation annotations need @field: prefix because they are field annotations, not class annotations
const prefix = '@field:';
return annotations.map(annotation => {
const annotationString = annotation.params
? `${annotation.name}(${annotation.params.map(param => `${param.name} = ${param.value}`).join(', ')})`
: annotation.name;
return `${prefix}${annotationString}`;
});
}

/**
* Add validation annotations to field
*/
private addValidationAnnotations(
field: InputValueDefinitionNode,
_typeInfo: { nullable: boolean }
): string[] {
const annotations = this.extractValidationAnnotations(field);

if (annotations.length === 0) {
return [];
}

return this.formatValidationAnnotations(annotations);
}

/**
* Extract validation annotations from object type field directives
*/
private extractValidationAnnotationsForField(field: FieldDefinitionNode): ValidationAnnotation[] {
if (!field.directives || field.directives.length === 0) {
return [];
}

const annotations: ValidationAnnotation[] = [];

for (const directive of field.directives) {
const directiveName = `@${directive.name.value}`;

// Check if it's a validation directive
if (VALIDATION_DIRECTIVES[directiveName]) {
const annotationName = VALIDATION_DIRECTIVES[directiveName];

// Parse directive arguments
let annotationParams: DirectiveArgument[] | undefined;
if (directive.arguments && directive.arguments.length > 0) {
annotationParams = parseDirectiveArgs(directiveName, Array.from(directive.arguments));
}

annotations.push({
name: annotationName,
params: annotationParams
});
}
}

return annotations;
}

/**
* Add validation annotations to object type field constructor parameters
*/
private addValidationAnnotationsForField(
field: FieldDefinitionNode,
_typeInfo: { nullable: boolean }
): string[] {
const annotations = this.extractValidationAnnotationsForField(field);

if (annotations.length === 0) {
return [];
}

// For object type fields, format annotations without @field: prefix since they're constructor parameters
return annotations.map(annotation => {
return annotation.params
? `@${annotation.name}(${annotation.params.map(param => `${param.name} = ${param.value}`).join(', ')})`
: `@${annotation.name}`;
});
}

protected buildInputTransfomer(
name: string,
inputValueArray: ReadonlyArray<InputValueDefinitionNode>,
Expand All @@ -187,10 +316,24 @@ ${enumValues}
const initialValue = this.initialValue(typeToUse.typeName, arg.defaultValue);
const initial = initialValue ? ` = ${initialValue}` : typeToUse.nullable ? ' = null' : '';

return indent(
// Get validation annotations if enabled
const validationAnnotations = this.config.validationAnnotations ?
this.addValidationAnnotations(arg, typeToUse) : [];

// Build field declaration, including annotations
let fieldDeclaration = '';
if (validationAnnotations.length > 0) {
// Add validation annotations
fieldDeclaration += validationAnnotations.map(ann => indent(ann, 2)).join('\n') + '\n';
}

// Add field declaration
fieldDeclaration += indent(
`val ${arg.name.value}: ${typeToUse.typeName}${typeToUse.nullable ? '?' : ''}${initial}`,
2,
);

return fieldDeclaration;
})
.join(',\n');
let suppress = '';
Expand Down Expand Up @@ -252,10 +395,24 @@ ${ctorSet}
}
const typeToUse = this.resolveInputFieldType(arg.type);

return indent(
// Get validation annotations if enabled
const validationAnnotations = this.config.validationAnnotations ?
this.addValidationAnnotationsForField(arg, typeToUse) : [];

// Build field declaration, including annotations
let fieldDeclaration = '';
if (validationAnnotations.length > 0) {
// Add validation annotations
fieldDeclaration += validationAnnotations.map(ann => indent(ann, 2)).join('\n') + '\n';
}

// Add field declaration
fieldDeclaration += indent(
`val ${arg.name.value}: ${typeToUse.typeName}${typeToUse.nullable ? '?' : ''}`,
2,
);

return fieldDeclaration;
})
.join(',\n');

Expand Down
Loading
Loading