Skip to content

Commit 71ebeb2

Browse files
committed
feat(spring): add x-inner-validation vendor extension for collection items
Introduces a new field-level OpenAPI vendor extension `x-inner-validation` that places a custom annotation on the type argument of Java collections (`List`, `Set`) generated by the Spring server generator, enabling per-element bean validation constraints such as `@NotNull`. When set on the items schema, the generated POJO field, getter, setter and fluent builder all use the annotated parameterized type, e.g.: private List<@jakarta.validation.constraints.NotNull Stubb> sample; The implementation precomputes the full datatype string in `SpringCodegen#postProcessModelProperty` and exposes it via `x-datatype-with-inner-annotation` on the property; a small mustache partial (`dataTypeWithInnerAnnotation`) then renders that string or falls back to `{{datatypeWithEnum}}`, so existing models without the extension produce byte-identical output. The wrap is also propagated through `JsonNullable<...>` for nullable containers. Map / `additionalProperties` is intentionally not covered by this extension. Scoped to the Spring generator on purpose: the issue author's reference attempt was on `JavaSpring/pojo.mustache` and Spring has no library overrides for that template, so coverage is complete here. Other Java and JaxRS generators have library-specific pojo overrides that would each need to be wired up separately; that can be added in a follow-up if requested. Fixes #23705
1 parent ba67ed7 commit 71ebeb2

10 files changed

Lines changed: 95 additions & 4 deletions

File tree

docs/generators/java-camel.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
151151
|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
152152
|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
153153
|x-spring-api-version|Value for 'version' attribute in @RequestMapping (for Spring 7 and above).|OPERATION|null
154+
|x-inner-validation|Custom annotation to be placed on the type argument of a collection (List, Set), enabling per-element bean validation constraints (e.g. `@NotNull`)|FIELD|null
154155

155156

156157
## IMPORT MAPPING

docs/generators/spring.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
144144
|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
145145
|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
146146
|x-spring-api-version|Value for 'version' attribute in @RequestMapping (for Spring 7 and above).|OPERATION|null
147+
|x-inner-validation|Custom annotation to be placed on the type argument of a collection (List, Set), enabling per-element bean validation constraints (e.g. `@NotNull`)|FIELD|null
147148

148149

149150
## IMPORT MAPPING

modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public enum VendorExtension {
2424
X_CONTENT_TYPE("x-content-type", ExtensionLevel.OPERATION, "Specify custom value for 'Content-Type' header for operation", null),
2525
X_CLASS_EXTRA_ANNOTATION("x-class-extra-annotation", ExtensionLevel.MODEL, "List of custom annotations to be added to model", null),
2626
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),
27+
X_INNER_VALIDATION("x-inner-validation", ExtensionLevel.FIELD, "Custom annotation to be placed on the type argument of a collection (List, Set), enabling per-element bean validation constraints (e.g. `@NotNull`)", null),
2728
X_OPERATION_EXTRA_ANNOTATION("x-operation-extra-annotation", ExtensionLevel.OPERATION, "List of custom annotations to be added to operation", null),
2829
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),
2930
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),

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,6 +1139,21 @@ public void setParameterExampleValue(CodegenParameter p) {
11391139
public void postProcessModelProperty(CodegenModel model, CodegenProperty property) {
11401140
super.postProcessModelProperty(model, property);
11411141

1142+
// x-inner-validation: when set on the items of an array/set, expose a precomputed
1143+
// datatype string that places the annotation as a JSR-308 type-use annotation on
1144+
// the element type, e.g. List<@NotNull Stubb> or Set<@NotNull Stubb>.
1145+
// Restricted to isArray (List/Set) — Maps are intentionally not supported.
1146+
if (property.isArray && property.items != null
1147+
&& property.items.vendorExtensions != null
1148+
&& property.items.vendorExtensions.get("x-inner-validation") instanceof String) {
1149+
String innerAnnotation = ((String) property.items.vendorExtensions.get("x-inner-validation")).trim();
1150+
if (!innerAnnotation.isEmpty()) {
1151+
String containerType = property.getUniqueItems() ? "Set" : "List";
1152+
String datatype = containerType + "<" + innerAnnotation + " " + property.items.datatypeWithEnum + ">";
1153+
property.vendorExtensions.put("x-datatype-with-inner-annotation", datatype);
1154+
}
1155+
}
1156+
11421157
// add org.springframework.format.annotation.DateTimeFormat when needed
11431158
if (property.isDate || property.isDateTime) {
11441159
model.imports.add("DateTimeFormat");
@@ -1543,6 +1558,7 @@ public List<VendorExtension> getSupportedVendorExtensions() {
15431558
extensions.add(VendorExtension.X_MINIMUM_MESSAGE);
15441559
extensions.add(VendorExtension.X_MAXIMUM_MESSAGE);
15451560
extensions.add(VendorExtension.X_SPRING_API_VERSION);
1561+
extensions.add(VendorExtension.X_INNER_VALIDATION);
15461562
return extensions;
15471563
}
15481564

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{{#vendorExtensions.x-datatype-with-inner-annotation}}{{{.}}}{{/vendorExtensions.x-datatype-with-inner-annotation}}{{^vendorExtensions.x-datatype-with-inner-annotation}}{{{datatypeWithEnum}}}{{/vendorExtensions.x-datatype-with-inner-annotation}}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +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}}
1+
{{#lambda.trim}}{{#openApiNullable}}{{#isNullable}}JsonNullable<{{/isNullable}}{{#useOptional}}{{^required}}{{^isNullable}}{{^isContainer}}Optional<{{/isContainer}}{{/isNullable}}{{/required}}{{/useOptional}}{{/openApiNullable}}{{#vendorExtensions.x-datatype-with-inner-annotation}}{{{.}}}{{/vendorExtensions.x-datatype-with-inner-annotation}}{{^vendorExtensions.x-datatype-with-inner-annotation}}{{#lambda.jSpecifyDatatype}}{{{datatypeWithEnum}}}{{/lambda.jSpecifyDatatype}}{{/vendorExtensions.x-datatype-with-inner-annotation}}{{#openApiNullable}}{{#isNullable}}>{{/isNullable}}{{#useOptional}}{{^required}}{{^isNullable}}{{^isContainer}}>{{/isContainer}}{{/isNullable}}{{/required}}{{/useOptional}}{{/openApiNullable}}{{/lambda.trim}}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +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}}
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}}{{#vendorExtensions.x-datatype-with-inner-annotation}}{{{.}}}{{/vendorExtensions.x-datatype-with-inner-annotation}}{{^vendorExtensions.x-datatype-with-inner-annotation}}{{#lambda.jSpecifyDatatype}}{{{datatypeWithEnum}}}{{/lambda.jSpecifyDatatype}}{{/vendorExtensions.x-datatype-with-inner-annotation}}{{#openApiNullable}}{{#isNullable}}>{{/isNullable}}{{#useOptional}}{{^required}}{{^isNullable}}{{^isContainer}}>{{/isContainer}}{{/isNullable}}{{/required}}{{/useOptional}}{{/openApiNullable}}{{/lambda.trim}}

modules/openapi-generator/src/main/resources/JavaSpring/pojo.mustache

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}
6767
{{#isContainer}}
6868
{{#useBeanValidation}}@Valid{{/useBeanValidation}}
6969
{{#openApiNullable}}
70-
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}}
70+
private {{>nullableAnnotation}}{{#isNullable}}{{>nullableDataTypeBeanValidation}} {{name}} = JsonNullable.<{{>dataTypeWithInnerAnnotation}}>undefined();{{/isNullable}}{{^required}}{{^isNullable}}{{>nullableDataTypeBeanValidation}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};{{/isNullable}}{{/required}}{{#required}}{{^isNullable}}{{>nullableDataTypeBeanValidation}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};{{/isNullable}}{{/required}}
7171
{{/openApiNullable}}
7272
{{^openApiNullable}}
7373
private {{>nullableAnnotation}}{{>nullableDataType}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};
@@ -144,7 +144,7 @@ public {{>sealed}}class {{classname}}{{#parent}} extends {{{parent}}}{{/parent}}
144144
{{^lombok.Data}}
145145

146146
{{! begin feature: fluent setter methods }}
147-
public {{classname}} {{name}}({{>nullableAnnotation}}{{{datatypeWithEnum}}} {{name}}) {
147+
public {{classname}} {{name}}({{>nullableAnnotation}}{{>dataTypeWithInnerAnnotation}} {{name}}) {
148148
{{#openApiNullable}}
149149
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}};
150150
{{/openApiNullable}}

modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7725,4 +7725,31 @@ void disableDiscriminatorJsonIgnorePropertiesIsTrueThenJsonIgnorePropertiesShoul
77257725
JavaFileAssert.assertThat(files.get("BaseConfiguration.java"))
77267726
.assertTypeAnnotations().containsWithName("JsonIgnoreProperties");
77277727
}
7728+
7729+
@Test
7730+
void innerValidationAnnotationOnCollectionItems_issue23705() throws IOException {
7731+
Map<String, File> files = generateFromContract("src/test/resources/3_0/issue_23705.yaml", SPRING_BOOT,
7732+
Map.of("useBeanValidation", "true", "useSpringBoot3", "true"));
7733+
7734+
JavaFileAssert.assertThat(files.get("SampleModel.java"))
7735+
// field declarations
7736+
.fileContains("private List<@jakarta.validation.constraints.NotNull Stubb> listSample")
7737+
.fileContains("private Set<@jakarta.validation.constraints.NotNull Stubb> setSample")
7738+
// getter return types
7739+
.fileContains("public List<@jakarta.validation.constraints.NotNull Stubb> getListSample()")
7740+
.fileContains("public Set<@jakarta.validation.constraints.NotNull Stubb> getSetSample()")
7741+
// setter parameter types
7742+
.fileContains("public void setListSample(List<@jakarta.validation.constraints.NotNull Stubb> listSample)")
7743+
.fileContains("public void setSetSample(Set<@jakarta.validation.constraints.NotNull Stubb> setSample)")
7744+
// fluent builder parameter types — the Set builder must use Set, not List
7745+
.fileContains("public SampleModel listSample(List<@jakarta.validation.constraints.NotNull Stubb> listSample)")
7746+
.fileContains("public SampleModel setSample(Set<@jakarta.validation.constraints.NotNull Stubb> setSample)")
7747+
// negative checks: the un-annotated container types should not appear for these fields
7748+
.fileDoesNotContain("List<Stubb> listSample")
7749+
.fileDoesNotContain("Set<Stubb> setSample")
7750+
// nullable container with inner annotation: JsonNullable wrap must keep the annotation
7751+
.fileContains("JsonNullable<List<@jakarta.validation.constraints.NotNull Stubb>>")
7752+
// Map (additionalProperties) must NOT receive the inner annotation — only List/Set are supported
7753+
.fileDoesNotContain("Map<String, @jakarta.validation.constraints.NotNull Stubb>");
7754+
}
77287755
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
openapi: 3.0.3
2+
info:
3+
title: issue 23705 x-inner-validation
4+
version: 1.0.0
5+
components:
6+
schemas:
7+
Stubb:
8+
type: object
9+
properties:
10+
name:
11+
type: string
12+
SampleModel:
13+
type: object
14+
properties:
15+
listSample:
16+
type: array
17+
items:
18+
x-inner-validation: '@jakarta.validation.constraints.NotNull'
19+
allOf:
20+
- $ref: '#/components/schemas/Stubb'
21+
setSample:
22+
type: array
23+
uniqueItems: true
24+
items:
25+
x-inner-validation: '@jakarta.validation.constraints.NotNull'
26+
allOf:
27+
- $ref: '#/components/schemas/Stubb'
28+
regularList:
29+
type: array
30+
items:
31+
$ref: '#/components/schemas/Stubb'
32+
nullableListSample:
33+
type: array
34+
nullable: true
35+
items:
36+
x-inner-validation: '@jakarta.validation.constraints.NotNull'
37+
allOf:
38+
- $ref: '#/components/schemas/Stubb'
39+
mapSample:
40+
type: object
41+
additionalProperties:
42+
x-inner-validation: '@jakarta.validation.constraints.NotNull'
43+
allOf:
44+
- $ref: '#/components/schemas/Stubb'

0 commit comments

Comments
 (0)