diff --git a/packages/plugins/java/kotlin/src/config.ts b/packages/plugins/java/kotlin/src/config.ts index 5f325ef966..f85a854244 100644 --- a/packages/plugins/java/kotlin/src/config.ts +++ b/packages/plugins/java/kotlin/src/config.ts @@ -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; } diff --git a/packages/plugins/java/kotlin/src/directive-mapping.ts b/packages/plugins/java/kotlin/src/directive-mapping.ts new file mode 100644 index 0000000000..ee501e5e2c --- /dev/null +++ b/packages/plugins/java/kotlin/src/directive-mapping.ts @@ -0,0 +1,110 @@ +/** + * GraphQL validation directives to Jakarta Validation annotations mapping + */ + +export interface DirectiveArgument { + name: string; + value: string; +} + +export const VALIDATION_DIRECTIVES: Record = { + '@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> = { + '@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}`); +} \ No newline at end of file diff --git a/packages/plugins/java/kotlin/src/index.ts b/packages/plugins/java/kotlin/src/index.ts index e9fd100afd..449a086963 100644 --- a/packages/plugins/java/kotlin/src/index.ts +++ b/packages/plugins/java/kotlin/src/index.ts @@ -22,7 +22,21 @@ export const plugin: PluginFunction = 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'); }; diff --git a/packages/plugins/java/kotlin/src/visitor.ts b/packages/plugins/java/kotlin/src/visitor.ts index 70c37f72a2..05eebd90c1 100644 --- a/packages/plugins/java/kotlin/src/visitor.ts +++ b/packages/plugins/java/kotlin/src/visitor.ts @@ -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', @@ -41,6 +51,7 @@ export interface KotlinResolverParsedConfig extends ParsedConfig { enumValues: EnumValuesMap; withTypes: boolean; omitJvmStatic: boolean; + validationAnnotations?: boolean; } export interface FieldDefinitionReturnType { @@ -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, }); } @@ -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, @@ -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 = ''; @@ -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'); diff --git a/packages/plugins/java/kotlin/tests/kotlin.spec.ts b/packages/plugins/java/kotlin/tests/kotlin.spec.ts index f0aff662e0..03884c0400 100644 --- a/packages/plugins/java/kotlin/tests/kotlin.spec.ts +++ b/packages/plugins/java/kotlin/tests/kotlin.spec.ts @@ -358,4 +358,97 @@ describe('Kotlin', () => { )`); }); }); + + describe('Jakarta Validation', () => { + // language=GraphQL + const validationSchema = buildSchema(` + directive @notBlank on INPUT_FIELD_DEFINITION | FIELD_DEFINITION + directive @size(min: Int, max: Int) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION + directive @email on INPUT_FIELD_DEFINITION | FIELD_DEFINITION + directive @min(value: Int!) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION + directive @max(value: Int!) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION + directive @pattern(regexp: String!) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION + + input UserInput { + username: String! @notBlank @size(min: 3, max: 20) + email: String! @email + age: Int @min(value: 18) + phone: String @pattern(regexp: "^[0-9]{10}$") + } + + type User { + id: ID! + username: String! @notBlank @size(min: 3, max: 20) + email: String! @email + age: Int @min(value: 18) + phone: String @pattern(regexp: "^[0-9]{10}$") + } + + type Query { + user(id: ID!): User + } + `); + + it('should generate validation annotations for input types when validationAnnotations is enabled', async () => { + const result = await plugin( + validationSchema, + [], + { + validationAnnotations: true, + package: 'com.example' + }, + { + outputFile: OUTPUT_FILE + } + ); + + expect(result).toContain('import jakarta.validation.constraints.*'); + expect(result).toContain('@field:NotBlank'); + expect(result).toContain('@field:Size(min = 3, max = 20)'); + expect(result).toContain('@field:Email'); + expect(result).toContain('@field:Min(value = 18)'); + expect(result).toContain('@field:Pattern(regexp = "^[0-9]{10}$")'); + }); + + it('should not generate validation annotations when validationAnnotations is disabled', async () => { + const result = await plugin( + validationSchema, + [], + { + validationAnnotations: false, + package: 'com.example' + }, + { + outputFile: OUTPUT_FILE + } + ); + + expect(result).not.toContain('import jakarta.validation.constraints.*'); + expect(result).not.toContain('@field:NotBlank'); + expect(result).not.toContain('@field:Size'); + expect(result).not.toContain('@field:Email'); + }); + + it('should generate validation annotations for object types when withTypes is true', async () => { + const result = await plugin( + validationSchema, + [], + { + validationAnnotations: true, + withTypes: true, + package: 'com.example' + }, + { + outputFile: OUTPUT_FILE + } + ); + + expect(result).toContain('import jakarta.validation.constraints.*'); + expect(result).toContain('@field:NotBlank'); + expect(result).toContain('@field:Size(min = 3, max = 20)'); + expect(result).toContain('@field:Email'); + expect(result).toContain('@field:Min(value = 18)'); + expect(result).toContain('@field:Pattern(regexp = "^[0-9]{10}$")'); + }); + }); });