Skip to content

Commit 34a468a

Browse files
committed
Obey annotations when flattening ParameterObject fields. Fixes springdoc#2817
When creating the flattened parameter definitions for an item annotated with @ParameterObject, the @Schema and @Property annotations on any fields prior to the final child-node of each branch of the parameter tree are not taken into consideration. This results in the field name in the code being used even where annotations may override that, prevents the fields being hidden where one of the parent fields is marked as hidden but the child field isn't hidden, and marks a field as mandatory even where the field would only be mandatory if the parent object had been declared through a sibling of the target field being set. To overcome this, whilst the flattened parameter map is being built, each field is now being inspected for a @parameter annotation or - where the @parameter annotation isn't found - a @Schema annotation. Where a custom name is included in either of those annotations then the overridden field name is used in the flattened definition. If either annotation declares the field as hidden then the field and all child fields are removed from the resulting definition, and a field is no longer set as mandatory unless the parent field was also declared as mandatory, or resolved as non-null and was set in an automatic state, or the child field is specifically set as required in the Schema or Parameter annotation.
1 parent a16c096 commit 34a468a

File tree

6 files changed

+800
-18
lines changed

6 files changed

+800
-18
lines changed

Diff for: springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/DelegatingMethodParameter.java

+19-4
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ public class DelegatingMethodParameter extends MethodParameter {
9393
*/
9494
private final Annotation[] methodAnnotations;
9595

96+
/**
97+
* The annotations to mask from the list of annotations on this method parameter.
98+
*/
99+
private final Annotation[] maskedAnnotations;
100+
96101
/**
97102
* The Is not required.
98103
*/
@@ -105,17 +110,19 @@ public class DelegatingMethodParameter extends MethodParameter {
105110
* @param parameterName the parameter name
106111
* @param additionalParameterAnnotations the additional parameter annotations
107112
* @param methodAnnotations the method annotations
113+
* @param maskedAnnotations any annotations that should not be included in the final list of annotations
108114
* @param isParameterObject the is parameter object
109115
* @param isNotRequired the is required
110116
*/
111-
DelegatingMethodParameter(MethodParameter delegate, String parameterName, Annotation[] additionalParameterAnnotations, Annotation[] methodAnnotations, boolean isParameterObject, boolean isNotRequired) {
117+
DelegatingMethodParameter(MethodParameter delegate, String parameterName, Annotation[] additionalParameterAnnotations, Annotation[] methodAnnotations, Annotation[] maskedAnnotations, boolean isParameterObject, boolean isNotRequired) {
112118
super(delegate);
113119
this.delegate = delegate;
114120
this.additionalParameterAnnotations = additionalParameterAnnotations;
115121
this.parameterName = parameterName;
116122
this.isParameterObject = isParameterObject;
117123
this.isNotRequired = isNotRequired;
118-
this.methodAnnotations =methodAnnotations;
124+
this.methodAnnotations = methodAnnotations;
125+
this.maskedAnnotations = maskedAnnotations;
119126
}
120127

121128
/**
@@ -146,7 +153,7 @@ public static MethodParameter[] customize(String[] pNames, MethodParameter[] par
146153
}
147154
else {
148155
String name = pNames != null ? pNames[i] : p.getParameterName();
149-
explodedParameters.add(new DelegatingMethodParameter(p, name, null, null, false, false));
156+
explodedParameters.add(new DelegatingMethodParameter(p, name, null, null, null, false, false));
150157
}
151158
}
152159
return explodedParameters.toArray(new MethodParameter[0]);
@@ -179,7 +186,15 @@ public static MethodParameter changeContainingClass(MethodParameter methodParame
179186
@NonNull
180187
public Annotation[] getParameterAnnotations() {
181188
Annotation[] methodAnnotations = ArrayUtils.addAll(delegate.getParameterAnnotations(), this.methodAnnotations);
182-
return ArrayUtils.addAll(methodAnnotations, additionalParameterAnnotations);
189+
methodAnnotations = ArrayUtils.addAll(methodAnnotations, additionalParameterAnnotations);
190+
if (maskedAnnotations == null) {
191+
return methodAnnotations;
192+
} else {
193+
List<Annotation> maskedAnnotationList = List.of(maskedAnnotations);
194+
return Arrays.stream(methodAnnotations)
195+
.filter(annotation -> !maskedAnnotationList.contains(annotation))
196+
.toArray(Annotation[]::new);
197+
}
183198
}
184199

185200
@Override

Diff for: springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/MethodParameterPojoExtractor.java

+105-12
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,13 @@
5050
import java.util.concurrent.atomic.AtomicInteger;
5151
import java.util.concurrent.atomic.AtomicLong;
5252
import java.util.function.Predicate;
53+
import java.util.stream.Collectors;
5354
import java.util.stream.Stream;
5455

5556
import io.swagger.v3.core.util.PrimitiveType;
5657
import io.swagger.v3.oas.annotations.Parameter;
58+
import io.swagger.v3.oas.annotations.media.Schema;
59+
import org.springdoc.core.service.AbstractRequestService;
5760

5861
import org.springframework.core.GenericTypeResolver;
5962
import org.springframework.core.MethodParameter;
@@ -63,7 +66,7 @@
6366
/**
6467
* The type Method parameter pojo extractor.
6568
*
66-
* @author bnasslahsen
69+
* @author bnasslahsen, michael.clarke
6770
*/
6871
public class MethodParameterPojoExtractor {
6972

@@ -113,20 +116,21 @@ private MethodParameterPojoExtractor() {
113116
* @return the stream
114117
*/
115118
static Stream<MethodParameter> extractFrom(Class<?> clazz) {
116-
return extractFrom(clazz, "");
119+
return extractFrom(clazz, "", true);
117120
}
118121

119122
/**
120123
* Extract from stream.
121124
*
122125
* @param clazz the clazz
123126
* @param fieldNamePrefix the field name prefix
127+
* @param parentRequired whether the field that hold the class currently being inspected was required or optional
124128
* @return the stream
125129
*/
126-
private static Stream<MethodParameter> extractFrom(Class<?> clazz, String fieldNamePrefix) {
130+
private static Stream<MethodParameter> extractFrom(Class<?> clazz, String fieldNamePrefix, boolean parentRequired) {
127131
return allFieldsOf(clazz).stream()
128132
.filter(field -> !field.getType().equals(clazz))
129-
.flatMap(f -> fromGetterOfField(clazz, f, fieldNamePrefix))
133+
.flatMap(f -> fromGetterOfField(clazz, f, fieldNamePrefix, parentRequired))
130134
.filter(Objects::nonNull);
131135
}
132136

@@ -136,20 +140,95 @@ private static Stream<MethodParameter> extractFrom(Class<?> clazz, String fieldN
136140
* @param paramClass the param class
137141
* @param field the field
138142
* @param fieldNamePrefix the field name prefix
143+
* @param parentRequired whether the field that holds the class currently being examined was required or optional
139144
* @return the stream
140145
*/
141-
private static Stream<MethodParameter> fromGetterOfField(Class<?> paramClass, Field field, String fieldNamePrefix) {
146+
private static Stream<MethodParameter> fromGetterOfField(Class<?> paramClass, Field field, String fieldNamePrefix, boolean parentRequired) {
142147
Class<?> type = extractType(paramClass, field);
143148

144149
if (Objects.isNull(type))
145150
return Stream.empty();
146151

147152
if (isSimpleType(type))
148-
return fromSimpleClass(paramClass, field, fieldNamePrefix);
153+
return fromSimpleClass(paramClass, field, fieldNamePrefix, parentRequired);
149154
else {
150-
String prefix = fieldNamePrefix + field.getName() + DOT;
151-
return extractFrom(type, prefix);
155+
Parameter parameter = field.getAnnotation(Parameter.class);
156+
Schema schema = field.getAnnotation(Schema.class);
157+
boolean visible = resolveVisible(parameter, schema);
158+
if (!visible) {
159+
return Stream.empty();
160+
}
161+
String prefix = fieldNamePrefix + resolveName(parameter, schema).orElse(field.getName()) + DOT;
162+
boolean notNullAnnotationsPresent = AbstractRequestService.hasNotNullAnnotation(Arrays.stream(field.getDeclaredAnnotations())
163+
.map(Annotation::annotationType)
164+
.map(Class::getSimpleName)
165+
.collect(Collectors.toSet()));
166+
return extractFrom(type, prefix, parentRequired && resolveRequired(schema, parameter, !notNullAnnotationsPresent));
167+
}
168+
}
169+
170+
private static Optional<String> resolveName(Parameter parameter, Schema schema) {
171+
if (parameter != null) {
172+
return resolveNameFromParameter(parameter);
173+
}
174+
if (schema != null) {
175+
return resolveNameFromSchema(schema);
176+
}
177+
return Optional.empty();
178+
}
179+
180+
private static Optional<String> resolveNameFromParameter(Parameter parameter) {
181+
if (parameter.name().isEmpty()) {
182+
return Optional.empty();
183+
}
184+
return Optional.of(parameter.name());
185+
}
186+
187+
private static Optional<String> resolveNameFromSchema(Schema schema) {
188+
if (schema.name().isEmpty()) {
189+
return Optional.empty();
190+
}
191+
return Optional.of(schema.name());
192+
}
193+
194+
private static boolean resolveVisible(Parameter parameter, Schema schema) {
195+
if (parameter != null) {
196+
return !parameter.hidden();
197+
}
198+
if (schema != null) {
199+
return !schema.hidden();
152200
}
201+
return true;
202+
}
203+
204+
private static boolean resolveRequired(Schema schema, Parameter parameter, boolean nullable) {
205+
if (parameter != null) {
206+
return resolveRequiredFromParameter(parameter, nullable);
207+
}
208+
if (schema != null) {
209+
return resolveRequiredFromSchema(schema, nullable);
210+
}
211+
return !nullable;
212+
}
213+
214+
private static boolean resolveRequiredFromParameter(Parameter parameter, boolean nullable) {
215+
if (parameter.required()) {
216+
return true;
217+
}
218+
return !nullable;
219+
}
220+
221+
private static boolean resolveRequiredFromSchema(Schema schema, boolean nullable) {
222+
if (schema.required()) {
223+
return true;
224+
}
225+
else if (schema.requiredMode() == Schema.RequiredMode.REQUIRED) {
226+
return true;
227+
}
228+
else if (schema.requiredMode() == Schema.RequiredMode.NOT_REQUIRED) {
229+
return false;
230+
}
231+
return !nullable;
153232
}
154233

155234
/**
@@ -181,18 +260,32 @@ private static Class<?> extractType(Class<?> paramClass, Field field) {
181260
* @param fieldNamePrefix the field name prefix
182261
* @return the stream
183262
*/
184-
private static Stream<MethodParameter> fromSimpleClass(Class<?> paramClass, Field field, String fieldNamePrefix) {
263+
private static Stream<MethodParameter> fromSimpleClass(Class<?> paramClass, Field field, String fieldNamePrefix, boolean isParentRequired) {
185264
Annotation[] fieldAnnotations = field.getDeclaredAnnotations();
186265
try {
187266
Parameter parameter = field.getAnnotation(Parameter.class);
188-
boolean isNotRequired = parameter == null || !parameter.required();
267+
Schema schema = field.getAnnotation(Schema.class);
268+
boolean visible = resolveVisible(parameter, schema);
269+
if (!visible) {
270+
return Stream.empty();
271+
}
272+
273+
boolean forcedRequired = resolveRequired(schema, parameter, true);
274+
275+
boolean isNotRequired = !((isParentRequired || forcedRequired) && resolveRequired(schema, parameter, !AbstractRequestService.hasNotNullAnnotation(Arrays.stream(fieldAnnotations)
276+
.map(Annotation::annotationType)
277+
.map(Class::getSimpleName)
278+
.collect(Collectors.toSet()))));
279+
Annotation[] notNullFieldAnnotations = Arrays.stream(fieldAnnotations)
280+
.filter(annotation -> AbstractRequestService.hasNotNullAnnotation(List.of(annotation.annotationType().getSimpleName())))
281+
.toArray(Annotation[]::new);
189282
if (paramClass.getSuperclass() != null && paramClass.isRecord()) {
190283
return Stream.of(paramClass.getRecordComponents())
191284
.filter(d -> d.getName().equals(field.getName()))
192285
.map(RecordComponent::getAccessor)
193286
.map(method -> new MethodParameter(method, -1))
194287
.map(methodParameter -> DelegatingMethodParameter.changeContainingClass(methodParameter, paramClass))
195-
.map(param -> new DelegatingMethodParameter(param, fieldNamePrefix + field.getName(), fieldAnnotations, param.getMethodAnnotations(), true, isNotRequired));
288+
.map(param -> new DelegatingMethodParameter(param, fieldNamePrefix + field.getName(), fieldAnnotations, param.getMethodAnnotations(), notNullFieldAnnotations, true, isNotRequired));
196289

197290
}
198291
else
@@ -202,7 +295,7 @@ private static Stream<MethodParameter> fromSimpleClass(Class<?> paramClass, Fiel
202295
.filter(Objects::nonNull)
203296
.map(method -> new MethodParameter(method, -1))
204297
.map(methodParameter -> DelegatingMethodParameter.changeContainingClass(methodParameter, paramClass))
205-
.map(param -> new DelegatingMethodParameter(param, fieldNamePrefix + field.getName(), fieldAnnotations, param.getMethodAnnotations(), true, isNotRequired));
298+
.map(param -> new DelegatingMethodParameter(param, fieldNamePrefix + field.getName(), fieldAnnotations, param.getMethodAnnotations(), notNullFieldAnnotations, true, isNotRequired));
206299
}
207300
catch (IntrospectionException e) {
208301
return Stream.of();

Diff for: springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java

+15-2
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,9 @@ public Parameter buildParam(ParameterInfo parameterInfo, Components components,
584584
if (parameter.getRequired() == null)
585585
parameter.setRequired(parameterInfo.isRequired());
586586

587+
if (Boolean.TRUE.equals(parameter.getRequired()) && parameterInfo.getMethodParameter() instanceof DelegatingMethodParameter delegatingMethodParameter && delegatingMethodParameter.isNotRequired())
588+
parameter.setRequired(false);
589+
587590
if (containsDeprecatedAnnotation(parameterInfo.getMethodParameter().getParameterAnnotations()))
588591
parameter.setDeprecated(true);
589592

@@ -616,7 +619,7 @@ public void applyBeanValidatorAnnotations(final Parameter parameter, final List<
616619
Map<String, Annotation> annos = new HashMap<>();
617620
if (annotations != null)
618621
annotations.forEach(annotation -> annos.put(annotation.annotationType().getSimpleName(), annotation));
619-
boolean annotationExists = Arrays.stream(ANNOTATIONS_FOR_REQUIRED).anyMatch(annos::containsKey);
622+
boolean annotationExists = hasNotNullAnnotation(annos.keySet());
620623
if (annotationExists)
621624
parameter.setRequired(true);
622625
Schema<?> schema = parameter.getSchema();
@@ -643,7 +646,7 @@ public void applyBeanValidatorAnnotations(final RequestBody requestBody, final L
643646
.filter(annotation -> io.swagger.v3.oas.annotations.parameters.RequestBody.class.equals(annotation.annotationType()))
644647
.anyMatch(annotation -> ((io.swagger.v3.oas.annotations.parameters.RequestBody) annotation).required());
645648
}
646-
boolean validationExists = Arrays.stream(ANNOTATIONS_FOR_REQUIRED).anyMatch(annos::containsKey);
649+
boolean validationExists = hasNotNullAnnotation(annos.keySet());
647650

648651
if (validationExists || (!isOptional && (springRequestBodyRequired || swaggerRequestBodyRequired)))
649652
requestBody.setRequired(true);
@@ -840,5 +843,15 @@ else if (requestBody.content().length > 0)
840843
}
841844
return false;
842845
}
846+
847+
/**
848+
* Check if the parameter has any of the annotations that make it non-optional
849+
*
850+
* @param annotationSimpleNames the annotation simple class named, e.g. NotNull
851+
* @return whether any of the known NotNull annotations are present
852+
*/
853+
public static boolean hasNotNullAnnotation(Collection<String> annotationSimpleNames) {
854+
return Arrays.stream(ANNOTATIONS_FOR_REQUIRED).anyMatch(annotationSimpleNames::contains);
855+
}
843856

844857
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * *
7+
* * * * * * Copyright 2019-2024 the original author or authors.
8+
* * * * * *
9+
* * * * * * Licensed under the Apache License, Version 2.0 (the "License");
10+
* * * * * * you may not use this file except in compliance with the License.
11+
* * * * * * You may obtain a copy of the License at
12+
* * * * * *
13+
* * * * * * https://www.apache.org/licenses/LICENSE-2.0
14+
* * * * * *
15+
* * * * * * Unless required by applicable law or agreed to in writing, software
16+
* * * * * * distributed under the License is distributed on an "AS IS" BASIS,
17+
* * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* * * * * * See the License for the specific language governing permissions and
19+
* * * * * * limitations under the License.
20+
* * * * *
21+
* * * *
22+
* * *
23+
* *
24+
*
25+
*/
26+
package test.org.springdoc.api.v30.app233;
27+
28+
import io.swagger.v3.oas.annotations.Parameter;
29+
import io.swagger.v3.oas.annotations.media.Schema;
30+
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
31+
import jakarta.validation.Valid;
32+
import jakarta.validation.constraints.NotNull;
33+
import org.springdoc.core.annotations.ParameterObject;
34+
35+
import org.springframework.web.bind.annotation.GetMapping;
36+
import org.springframework.web.bind.annotation.RestController;
37+
38+
@RestController
39+
public class ParameterController {
40+
41+
@GetMapping("/hidden-parent")
42+
public void nestedParameterObjectWithHiddenParentField(@ParameterObject ParameterObjectWithHiddenField parameters) {
43+
44+
}
45+
46+
public record ParameterObjectWithHiddenField(
47+
@Schema(hidden = true) NestedParameterObject schemaHiddenNestedParameterObject,
48+
@Parameter(hidden = true) NestedParameterObject parameterHiddenNestedParameterObject,
49+
NestedParameterObject visibleNestedParameterObject
50+
) {
51+
52+
}
53+
54+
public record NestedParameterObject(
55+
String parameterField) {
56+
}
57+
58+
@GetMapping("/renamed-parent")
59+
public void nestedParameterObjectWithRenamedParentField(@ParameterObject ParameterObjectWithRenamedField parameters) {
60+
61+
}
62+
63+
public record ParameterObjectWithRenamedField(
64+
@Schema(name = "schemaRenamed") NestedParameterObject schemaRenamedNestedParameterObject,
65+
@Parameter(name = "parameterRenamed") NestedParameterObject parameterRenamedNestedParameterObject,
66+
NestedParameterObject originalNameNestedParameterObject
67+
) {
68+
69+
}
70+
71+
@GetMapping("/optional-parent")
72+
public void nestedParameterObjectWithOptionalParentField(@Valid @ParameterObject MultiFieldParameterObject parameters) {
73+
74+
}
75+
76+
public record MultiFieldParameterObject(
77+
@Valid @Schema(requiredMode = RequiredMode.REQUIRED) @NotNull MultiFieldNestedParameterObject requiredNotNullParameterObject,
78+
@Valid @Schema(requiredMode = RequiredMode.REQUIRED) MultiFieldNestedParameterObject requiredNoValidationParameterObject,
79+
@Valid @Schema(requiredMode = RequiredMode.NOT_REQUIRED) @NotNull MultiFieldNestedParameterObject notRequiredNotNullParameterObject,
80+
@Valid @Schema(requiredMode = RequiredMode.NOT_REQUIRED) MultiFieldNestedParameterObject notRequiredNoValidationParameterObject,
81+
@Valid @NotNull MultiFieldNestedParameterObject noSchemaNotNullParameterObject,
82+
@Valid MultiFieldNestedParameterObject noSchemaNoValidationParameterObject) {
83+
84+
}
85+
86+
public record MultiFieldNestedParameterObject (
87+
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @NotNull String requiredNotNullField,
88+
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String requiredNoValidationField,
89+
@Schema(requiredMode = RequiredMode.NOT_REQUIRED) @NotNull String notRequiredNotNullField,
90+
@Schema(requiredMode = RequiredMode.NOT_REQUIRED) String notRequiredNoValidationField,
91+
@NotNull String noSchemaNotNullField,
92+
String noSchemaNoValidationField) {
93+
}
94+
95+
}

0 commit comments

Comments
 (0)