diff --git a/docs/generators/java-camel.md b/docs/generators/java-camel.md index 42c864eed0f8..18ad84cb5a6f 100644 --- a/docs/generators/java-camel.md +++ b/docs/generators/java-camel.md @@ -151,6 +151,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |x-minimum-message|Add this property whenever you need to customize the invalidation error message for the minimum value of a variable|FIELD, OPERATION_PARAMETER|null |x-maximum-message|Add this property whenever you need to customize the invalidation error message for the maximum value of a variable|FIELD, OPERATION_PARAMETER|null |x-spring-api-version|Value for 'version' attribute in @RequestMapping (for Spring 7 and above).|OPERATION|null +|x-item-validation|Custom annotation to be placed on the type argument of a collection (List, Set), set on the array property itself, enabling per-element bean validation constraints (e.g. `@NotNull`)|FIELD|null ## IMPORT MAPPING diff --git a/docs/generators/spring.md b/docs/generators/spring.md index e1a4d7771e61..e8b5484a3cc6 100644 --- a/docs/generators/spring.md +++ b/docs/generators/spring.md @@ -144,6 +144,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |x-minimum-message|Add this property whenever you need to customize the invalidation error message for the minimum value of a variable|FIELD, OPERATION_PARAMETER|null |x-maximum-message|Add this property whenever you need to customize the invalidation error message for the maximum value of a variable|FIELD, OPERATION_PARAMETER|null |x-spring-api-version|Value for 'version' attribute in @RequestMapping (for Spring 7 and above).|OPERATION|null +|x-item-validation|Custom annotation to be placed on the type argument of a collection (List, Set), set on the array property itself, enabling per-element bean validation constraints (e.g. `@NotNull`)|FIELD|null ## IMPORT MAPPING diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java index bce0e2a691f4..4f6801ebda50 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java @@ -24,6 +24,7 @@ public enum VendorExtension { X_CONTENT_TYPE("x-content-type", ExtensionLevel.OPERATION, "Specify custom value for 'Content-Type' header for operation", null), X_CLASS_EXTRA_ANNOTATION("x-class-extra-annotation", ExtensionLevel.MODEL, "List of custom annotations to be added to model", null), X_FIELD_EXTRA_ANNOTATION("x-field-extra-annotation", Arrays.asList(ExtensionLevel.FIELD, ExtensionLevel.OPERATION_PARAMETER), "List of custom annotations to be added to property", null), + X_ITEM_VALIDATION("x-item-validation", ExtensionLevel.FIELD, "Custom annotation to be placed on the type argument of a collection (List, Set), set on the array property itself, enabling per-element bean validation constraints (e.g. `@NotNull`)", null), X_OPERATION_EXTRA_ANNOTATION("x-operation-extra-annotation", ExtensionLevel.OPERATION, "List of custom annotations to be added to operation", null), X_VERSION_PARAM("x-version-param", ExtensionLevel.OPERATION_PARAMETER, "Marker property that tells that this parameter would be used for endpoint versioning. Applicable for headers & query params. true/false", null), X_PATTERN_MESSAGE("x-pattern-message", Arrays.asList(ExtensionLevel.FIELD, ExtensionLevel.OPERATION_PARAMETER), "Add this property whenever you need to customize the invalidation error message for the regex pattern of a variable", null), diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java index 9329e8a1b5c5..5e23961fc910 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java @@ -1139,6 +1139,20 @@ public void setParameterExampleValue(CodegenParameter p) { public void postProcessModelProperty(CodegenModel model, CodegenProperty property) { super.postProcessModelProperty(model, property); + // x-item-validation: when set on an array/set property, expose a precomputed + // datatype string that places the annotation as a JSR-308 type-use annotation on + // the element type, e.g. List<@NotNull Stubb> or Set<@NotNull Stubb>. + // Restricted to isArray (List/Set) — Maps are intentionally not supported. + if (property.isArray && property.vendorExtensions != null + && property.vendorExtensions.get("x-item-validation") instanceof String) { + String itemAnnotation = ((String) property.vendorExtensions.get("x-item-validation")).trim(); + if (!itemAnnotation.isEmpty() && property.items != null) { + String containerType = property.getUniqueItems() ? "Set" : "List"; + String datatype = containerType + "<" + itemAnnotation + " " + property.items.datatypeWithEnum + ">"; + property.vendorExtensions.put("x-datatype-with-item-annotation", datatype); + } + } + // add org.springframework.format.annotation.DateTimeFormat when needed if (property.isDate || property.isDateTime) { model.imports.add("DateTimeFormat"); @@ -1543,6 +1557,7 @@ public List getSupportedVendorExtensions() { extensions.add(VendorExtension.X_MINIMUM_MESSAGE); extensions.add(VendorExtension.X_MAXIMUM_MESSAGE); extensions.add(VendorExtension.X_SPRING_API_VERSION); + extensions.add(VendorExtension.X_ITEM_VALIDATION); return extensions; } diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/dataTypeWithItemAnnotation.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/dataTypeWithItemAnnotation.mustache new file mode 100644 index 000000000000..adaa6ee4f4d4 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/JavaSpring/dataTypeWithItemAnnotation.mustache @@ -0,0 +1 @@ +{{#vendorExtensions.x-datatype-with-item-annotation}}{{{.}}}{{/vendorExtensions.x-datatype-with-item-annotation}}{{^vendorExtensions.x-datatype-with-item-annotation}}{{{datatypeWithEnum}}}{{/vendorExtensions.x-datatype-with-item-annotation}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/nullableDataType.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/nullableDataType.mustache index cc1995172615..1391243bb833 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/nullableDataType.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/nullableDataType.mustache @@ -1 +1 @@ -{{#lambda.trim}}{{#openApiNullable}}{{#isNullable}}JsonNullable<{{/isNullable}}{{#useOptional}}{{^required}}{{^isNullable}}{{^isContainer}}Optional<{{/isContainer}}{{/isNullable}}{{/required}}{{/useOptional}}{{/openApiNullable}}{{#lambda.jSpecifyDatatype}}{{{datatypeWithEnum}}}{{/lambda.jSpecifyDatatype}}{{#openApiNullable}}{{#isNullable}}>{{/isNullable}}{{#useOptional}}{{^required}}{{^isNullable}}{{^isContainer}}>{{/isContainer}}{{/isNullable}}{{/required}}{{/useOptional}}{{/openApiNullable}}{{/lambda.trim}} \ No newline at end of file +{{#lambda.trim}}{{#openApiNullable}}{{#isNullable}}JsonNullable<{{/isNullable}}{{#useOptional}}{{^required}}{{^isNullable}}{{^isContainer}}Optional<{{/isContainer}}{{/isNullable}}{{/required}}{{/useOptional}}{{/openApiNullable}}{{#vendorExtensions.x-datatype-with-item-annotation}}{{{.}}}{{/vendorExtensions.x-datatype-with-item-annotation}}{{^vendorExtensions.x-datatype-with-item-annotation}}{{#lambda.jSpecifyDatatype}}{{{datatypeWithEnum}}}{{/lambda.jSpecifyDatatype}}{{/vendorExtensions.x-datatype-with-item-annotation}}{{#openApiNullable}}{{#isNullable}}>{{/isNullable}}{{#useOptional}}{{^required}}{{^isNullable}}{{^isContainer}}>{{/isContainer}}{{/isNullable}}{{/required}}{{/useOptional}}{{/openApiNullable}}{{/lambda.trim}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/nullableDataTypeBeanValidation.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/nullableDataTypeBeanValidation.mustache index 808d1b6312af..e8a0135237dc 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/nullableDataTypeBeanValidation.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/nullableDataTypeBeanValidation.mustache @@ -1 +1 @@ -{{#lambda.trim}}{{#openApiNullable}}{{#isNullable}}{{^isContainer}}JsonNullable<{{#useBeanValidation}}{{>beanValidationCore}}{{/useBeanValidation}}{{/isContainer}}{{#isContainer}}JsonNullable<{{/isContainer}}{{/isNullable}}{{#useOptional}}{{^required}}{{^isNullable}}{{^isContainer}}Optional<{{#useBeanValidation}}{{>beanValidationCore}}{{/useBeanValidation}}{{/isContainer}}{{/isNullable}}{{/required}}{{/useOptional}}{{/openApiNullable}}{{#lambda.jSpecifyDatatype}}{{{datatypeWithEnum}}}{{/lambda.jSpecifyDatatype}}{{#openApiNullable}}{{#isNullable}}>{{/isNullable}}{{#useOptional}}{{^required}}{{^isNullable}}{{^isContainer}}>{{/isContainer}}{{/isNullable}}{{/required}}{{/useOptional}}{{/openApiNullable}}{{/lambda.trim}} \ No newline at end of file +{{#lambda.trim}}{{#openApiNullable}}{{#isNullable}}{{^isContainer}}JsonNullable<{{#useBeanValidation}}{{>beanValidationCore}}{{/useBeanValidation}}{{/isContainer}}{{#isContainer}}JsonNullable<{{/isContainer}}{{/isNullable}}{{#useOptional}}{{^required}}{{^isNullable}}{{^isContainer}}Optional<{{#useBeanValidation}}{{>beanValidationCore}}{{/useBeanValidation}}{{/isContainer}}{{/isNullable}}{{/required}}{{/useOptional}}{{/openApiNullable}}{{#vendorExtensions.x-datatype-with-item-annotation}}{{{.}}}{{/vendorExtensions.x-datatype-with-item-annotation}}{{^vendorExtensions.x-datatype-with-item-annotation}}{{#lambda.jSpecifyDatatype}}{{{datatypeWithEnum}}}{{/lambda.jSpecifyDatatype}}{{/vendorExtensions.x-datatype-with-item-annotation}}{{#openApiNullable}}{{#isNullable}}>{{/isNullable}}{{#useOptional}}{{^required}}{{^isNullable}}{{^isContainer}}>{{/isContainer}}{{/isNullable}}{{/required}}{{/useOptional}}{{/openApiNullable}}{{/lambda.trim}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache index f76cf70a7b59..ece4ee0500e9 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache @@ -67,7 +67,7 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}} {{#isContainer}} {{#useBeanValidation}}@Valid{{/useBeanValidation}} {{#openApiNullable}} - private {{>nullableAnnotation}}{{#isNullable}}{{>nullableDataTypeBeanValidation}} {{name}} = JsonNullable.<{{{datatypeWithEnum}}}>undefined();{{/isNullable}}{{^required}}{{^isNullable}}{{>nullableDataTypeBeanValidation}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};{{/isNullable}}{{/required}}{{#required}}{{^isNullable}}{{>nullableDataTypeBeanValidation}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};{{/isNullable}}{{/required}} + private {{>nullableAnnotation}}{{#isNullable}}{{>nullableDataTypeBeanValidation}} {{name}} = JsonNullable.<{{>dataTypeWithItemAnnotation}}>undefined();{{/isNullable}}{{^required}}{{^isNullable}}{{>nullableDataTypeBeanValidation}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};{{/isNullable}}{{/required}}{{#required}}{{^isNullable}}{{>nullableDataTypeBeanValidation}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};{{/isNullable}}{{/required}} {{/openApiNullable}} {{^openApiNullable}} private {{>nullableAnnotation}}{{>nullableDataType}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}; @@ -144,7 +144,7 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}} {{^lombok.Data}} {{! begin feature: fluent setter methods }} - public {{classname}} {{name}}({{>nullableAnnotation}}{{{datatypeWithEnum}}} {{name}}) { + public {{classname}} {{name}}({{>nullableAnnotation}}{{>dataTypeWithItemAnnotation}} {{name}}) { {{#openApiNullable}} this.{{name}} = {{#isNullable}}JsonNullable.of({{/isNullable}}{{#useOptional}}{{^required}}{{^isNullable}}{{^isContainer}}Optional.of{{#optionalAcceptNullable}}Nullable{{/optionalAcceptNullable}}({{/isContainer}}{{/isNullable}}{{/required}}{{/useOptional}}{{name}}{{#isNullable}}){{/isNullable}}{{#useOptional}}{{^required}}{{^isNullable}}{{^isContainer}}){{/isContainer}}{{/isNullable}}{{/required}}{{/useOptional}}; {{/openApiNullable}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index da1e4aefface..4ce5c8d3df4d 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java @@ -7725,4 +7725,31 @@ void disableDiscriminatorJsonIgnorePropertiesIsTrueThenJsonIgnorePropertiesShoul JavaFileAssert.assertThat(files.get("BaseConfiguration.java")) .assertTypeAnnotations().containsWithName("JsonIgnoreProperties"); } + + @Test + void itemValidationAnnotationOnCollectionItems_issue23705() throws IOException { + Map files = generateFromContract("src/test/resources/3_0/issue_23705.yaml", SPRING_BOOT, + Map.of("useBeanValidation", "true", "useSpringBoot3", "true")); + + JavaFileAssert.assertThat(files.get("SampleModel.java")) + // field declarations + .fileContains("private List<@jakarta.validation.constraints.NotNull Stubb> listSample") + .fileContains("private Set<@jakarta.validation.constraints.NotNull Stubb> setSample") + // getter return types + .fileContains("public List<@jakarta.validation.constraints.NotNull Stubb> getListSample()") + .fileContains("public Set<@jakarta.validation.constraints.NotNull Stubb> getSetSample()") + // setter parameter types + .fileContains("public void setListSample(List<@jakarta.validation.constraints.NotNull Stubb> listSample)") + .fileContains("public void setSetSample(Set<@jakarta.validation.constraints.NotNull Stubb> setSample)") + // fluent builder parameter types — the Set builder must use Set, not List + .fileContains("public SampleModel listSample(List<@jakarta.validation.constraints.NotNull Stubb> listSample)") + .fileContains("public SampleModel setSample(Set<@jakarta.validation.constraints.NotNull Stubb> setSample)") + // negative checks: the un-annotated container types should not appear for these fields + .fileDoesNotContain("List listSample") + .fileDoesNotContain("Set setSample") + // nullable container with item annotation: JsonNullable wrap must keep the annotation + .fileContains("JsonNullable>") + // Map (additionalProperties) must NOT receive the item annotation — only List/Set are supported + .fileDoesNotContain("Map"); + } } diff --git a/modules/openapi-generator/src/test/resources/3_0/issue_23705.yaml b/modules/openapi-generator/src/test/resources/3_0/issue_23705.yaml new file mode 100644 index 000000000000..6390e999d3ab --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue_23705.yaml @@ -0,0 +1,40 @@ +openapi: 3.0.3 +info: + title: issue 23705 x-item-validation + version: 1.0.0 +components: + schemas: + Stubb: + type: object + properties: + name: + type: string + SampleModel: + type: object + properties: + listSample: + type: array + x-item-validation: '@jakarta.validation.constraints.NotNull' + items: + $ref: '#/components/schemas/Stubb' + setSample: + type: array + uniqueItems: true + x-item-validation: '@jakarta.validation.constraints.NotNull' + items: + $ref: '#/components/schemas/Stubb' + regularList: + type: array + items: + $ref: '#/components/schemas/Stubb' + nullableListSample: + type: array + nullable: true + x-item-validation: '@jakarta.validation.constraints.NotNull' + items: + $ref: '#/components/schemas/Stubb' + mapSample: + type: object + x-item-validation: '@jakarta.validation.constraints.NotNull' + additionalProperties: + $ref: '#/components/schemas/Stubb'