From 265a1f45d8ef0872bb64eb4d5b7b90efce8e514c Mon Sep 17 00:00:00 2001 From: m2 Date: Wed, 26 Mar 2025 19:32:16 +0800 Subject: [PATCH 1/6] :zap: support(kotlin&customize&validation&generic) SchemaUtils --- .../SpringDocKotlinConfiguration.kt | 13 +- .../DelegatingMethodParameterCustomizer.java | 15 ++ .../core/data/DataRestRequestService.java | 2 +- .../extractor/DelegatingMethodParameter.java | 30 ++- .../MethodParameterPojoExtractor.java | 78 +------ .../core/service/AbstractRequestService.java | 125 +++------- .../org/springdoc/core/utils/Constants.java | 7 +- .../org/springdoc/core/utils/SchemaUtils.java | 213 ++++++++++++++++++ .../pom.xml | 4 + .../springdoc/api/v31/app15/DemoController.kt | 50 ++++ .../api/v31/app15/SpringDocApp15Test.kt | 9 + .../springdoc/api/v31/app16/DemoController.kt | 50 ++++ .../api/v31/app16/SpringDocApp16Test.kt | 12 + .../springdoc/api/v31/app17/DemoController.kt | 58 +++++ .../api/v31/app17/SpringDocApp17Test.kt | 51 +++++ .../test/resources/results/3.1.0/app15.json | 89 ++++++++ .../test/resources/results/3.1.0/app16.json | 101 +++++++++ .../test/resources/results/3.1.0/app17.json | 109 +++++++++ 18 files changed, 843 insertions(+), 173 deletions(-) create mode 100644 springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app15/DemoController.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app15/SpringDocApp15Test.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app16/DemoController.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app16/SpringDocApp16Test.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app17/DemoController.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app17/SpringDocApp17Test.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app15.json create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app16.json create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app17.json diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinConfiguration.kt b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinConfiguration.kt index d11d0cb91..e0cb33dde 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinConfiguration.kt +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinConfiguration.kt @@ -31,6 +31,7 @@ import org.springdoc.core.customizers.KotlinDeprecatedPropertyCustomizer import org.springdoc.core.customizers.ParameterCustomizer import org.springdoc.core.providers.ObjectMapperProvider import org.springdoc.core.utils.Constants +import org.springdoc.core.utils.SchemaUtils import org.springdoc.core.utils.SpringDocUtils import org.springframework.boot.autoconfigure.condition.ConditionalOnBean import org.springframework.boot.autoconfigure.condition.ConditionalOnClass @@ -75,17 +76,13 @@ class SpringDocKotlinConfiguration() { } /** - * Kotlin springdoc-openapi ParameterCustomizer + * Kotlin springdoc-openapi ParameterCustomizer. + * deprecated as not anymore required, use [SchemaUtils.fieldNullable] * * @return the nullable Kotlin Request Parameter Customizer + * @see SchemaUtils.fieldNullable() */ - @Bean - @Lazy(false) - @ConditionalOnProperty( - name = [Constants.SPRINGDOC_NULLABLE_REQUEST_PARAMETER_ENABLED], - matchIfMissing = true - ) - @ConditionalOnMissingBean + @Deprecated("Deprecated since 2.8.7", level = DeprecationLevel.ERROR) fun nullableKotlinRequestParameterCustomizer(): ParameterCustomizer { return ParameterCustomizer { parameterModel, methodParameter -> if (parameterModel == null) return@ParameterCustomizer null diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/DelegatingMethodParameterCustomizer.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/DelegatingMethodParameterCustomizer.java index d6475c4c4..c6b9d3242 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/DelegatingMethodParameterCustomizer.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/DelegatingMethodParameterCustomizer.java @@ -27,12 +27,27 @@ package org.springdoc.core.customizers; import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; + +import java.util.List; /** * The interface Delegating method parameter customizer. */ @FunctionalInterface public interface DelegatingMethodParameterCustomizer { + /** + * Customize. + * tip: parameters include the parent fields, you can choose how to deal with the methodParameters + * + * @param originalParameter the original parameter + * @param methodParameters the exploded parameters + * @return the list + */ + @Nullable + default void customizeList(MethodParameter originalParameter, List methodParameters) { + methodParameters.forEach(parameter -> customize(originalParameter, parameter)); + } /** * Customize. diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/data/DataRestRequestService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/data/DataRestRequestService.java index 6fad33712..6bd93ed4c 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/data/DataRestRequestService.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/data/DataRestRequestService.java @@ -227,7 +227,7 @@ private void addParameters(OpenAPI openAPI, RequestMethod requestMethod, MethodA MethodParameter methodParameter, ParameterInfo parameterInfo, Parameter parameter) { List parameterAnnotations = Arrays.asList(getParameterAnnotations(methodParameter)); if (requestBuilder.isValidParameter(parameter,methodAttributes)) { - requestBuilder.applyBeanValidatorAnnotations(parameter, parameterAnnotations, parameterInfo.isParameterObject()); + requestBuilder.applyBeanValidatorAnnotations(methodParameter, parameter, parameterAnnotations, parameterInfo.isParameterObject()); operation.addParametersItem(parameter); } else if (!RequestMethod.GET.equals(requestMethod)) { diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/DelegatingMethodParameter.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/DelegatingMethodParameter.java index 11b39e482..69d2604c1 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/DelegatingMethodParameter.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/DelegatingMethodParameter.java @@ -38,6 +38,7 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.reflect.FieldUtils; @@ -88,6 +89,11 @@ public class DelegatingMethodParameter extends MethodParameter { */ private final boolean isParameterObject; + /** + * If Is parameter object. then The Field should be not null + */ + private final Field field; + /** * The Method annotations. */ @@ -108,9 +114,10 @@ public class DelegatingMethodParameter extends MethodParameter { * @param isParameterObject the is parameter object * @param isNotRequired the is required */ - DelegatingMethodParameter(MethodParameter delegate, String parameterName, Annotation[] additionalParameterAnnotations, Annotation[] methodAnnotations, boolean isParameterObject, boolean isNotRequired) { + DelegatingMethodParameter(MethodParameter delegate, String parameterName, Annotation[] additionalParameterAnnotations, Annotation[] methodAnnotations, boolean isParameterObject, Field field, boolean isNotRequired) { super(delegate); this.delegate = delegate; + this.field = field; this.additionalParameterAnnotations = additionalParameterAnnotations; this.parameterName = parameterName; this.isParameterObject = isParameterObject; @@ -139,14 +146,14 @@ public static MethodParameter[] customize(String[] pNames, MethodParameter[] par .anyMatch(annotation -> Arrays.asList(RequestBody.class, RequestPart.class).contains(annotation.annotationType())); if (!MethodParameterPojoExtractor.isSimpleType(paramClass) && (hasFlatAnnotation || (defaultFlatParamObject && !hasNotFlatAnnotation && !AbstractRequestService.isRequestTypeToIgnore(paramClass)))) { - MethodParameterPojoExtractor.extractFrom(paramClass).forEach(methodParameter -> { - optionalDelegatingMethodParameterCustomizers.ifPresent(delegatingMethodParameterCustomizers -> delegatingMethodParameterCustomizers.forEach(customizer -> customizer.customize(p, methodParameter))); - explodedParameters.add(methodParameter); - }); + List flatParams = new CopyOnWriteArrayList<>(); + MethodParameterPojoExtractor.extractFrom(paramClass).forEach(flatParams::add); + optionalDelegatingMethodParameterCustomizers.orElseGet(ArrayList::new).forEach(cz -> cz.customizeList(p, flatParams)); + explodedParameters.addAll(flatParams); } else { String name = pNames != null ? pNames[i] : p.getParameterName(); - explodedParameters.add(new DelegatingMethodParameter(p, name, null, null, false, false)); + explodedParameters.add(new DelegatingMethodParameter(p, name, null, null, false, null, false)); } } return explodedParameters.toArray(new MethodParameter[0]); @@ -297,4 +304,13 @@ public boolean isParameterObject() { return isParameterObject; } -} \ No newline at end of file + /** + * Gets field. If Is parameter object. then The Field should be not null + * @return the field + * @see #isParameterObject + */ + @Nullable + public Field getField() { + return field; + } +} diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/MethodParameterPojoExtractor.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/MethodParameterPojoExtractor.java index 715ef1b59..5daabb436 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/MethodParameterPojoExtractor.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/MethodParameterPojoExtractor.java @@ -46,9 +46,7 @@ import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.HashSet; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -61,17 +59,16 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Predicate; -import java.util.stream.Collectors; import java.util.stream.Stream; import io.swagger.v3.core.util.PrimitiveType; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; +import org.springdoc.core.utils.SchemaUtils; import org.springframework.core.GenericTypeResolver; import org.springframework.core.MethodParameter; -import static org.springdoc.core.service.AbstractRequestService.hasNotNullAnnotation; import static org.springdoc.core.utils.Constants.DOT; /** @@ -174,13 +171,13 @@ private static Stream fromGetterOfField(Class paramClass, Fi else { Parameter parameter = field.getAnnotation(Parameter.class); Schema schema = field.getAnnotation(Schema.class); - boolean visible = resolveVisible(parameter, schema); + boolean visible = SchemaUtils.swaggerVisible(schema, parameter); if (!visible) { return Stream.empty(); } String prefix = fieldNamePrefix + resolveName(parameter, schema).orElse(field.getName()) + DOT; - boolean isNullable = isNullable(field.getDeclaredAnnotations()); - return extractFrom(type, prefix, parentRequired && resolveRequired(schema, parameter, isNullable)); + boolean fieldRequired = SchemaUtils.fieldRequired(field, schema, parameter); + return extractFrom(type, prefix, parentRequired && fieldRequired); } } @@ -208,46 +205,6 @@ private static Optional resolveNameFromSchema(Schema schema) { return Optional.of(schema.name()); } - private static boolean resolveVisible(Parameter parameter, Schema schema) { - if (parameter != null) { - return !parameter.hidden(); - } - if (schema != null) { - return !schema.hidden(); - } - return true; - } - - private static boolean resolveRequired(Schema schema, Parameter parameter, boolean nullable) { - if (parameter != null) { - return resolveRequiredFromParameter(parameter, nullable); - } - if (schema != null) { - return resolveRequiredFromSchema(schema, nullable); - } - return !nullable; - } - - private static boolean resolveRequiredFromParameter(Parameter parameter, boolean nullable) { - if (parameter.required()) { - return true; - } - return !nullable; - } - - private static boolean resolveRequiredFromSchema(Schema schema, boolean nullable) { - if (schema.required()) { - return true; - } - else if (schema.requiredMode() == Schema.RequiredMode.REQUIRED) { - return true; - } - else if (schema.requiredMode() == Schema.RequiredMode.NOT_REQUIRED) { - return false; - } - return !nullable; - } - /** * Extract the type * @@ -277,20 +234,21 @@ private static Class extractType(Class paramClass, Field field) { * @param fieldNamePrefix the field name prefix * @return the stream */ - private static Stream fromSimpleClass(Class paramClass, Field field, String fieldNamePrefix, boolean isParentRequired) { + private static Stream fromSimpleClass(Class paramClass, Field field, String fieldNamePrefix, boolean parentRequired) { Annotation[] fieldAnnotations = field.getDeclaredAnnotations(); try { Parameter parameter = field.getAnnotation(Parameter.class); Schema schema = field.getAnnotation(Schema.class); - boolean isNullable = isNullable(fieldAnnotations); - boolean isNotRequired = !(isParentRequired && resolveRequired(schema, parameter, isNullable)); + boolean fieldRequired = SchemaUtils.fieldRequired(field, schema, parameter); + + boolean paramRequired = parentRequired && fieldRequired; if (paramClass.getSuperclass() != null && paramClass.isRecord()) { return Stream.of(paramClass.getRecordComponents()) .filter(d -> d.getName().equals(field.getName())) .map(RecordComponent::getAccessor) .map(method -> new MethodParameter(method, -1)) .map(methodParameter -> DelegatingMethodParameter.changeContainingClass(methodParameter, paramClass)) - .map(param -> new DelegatingMethodParameter(param, fieldNamePrefix + field.getName(), fieldAnnotations, param.getMethodAnnotations(), true, isNotRequired)); + .map(param -> new DelegatingMethodParameter(param, fieldNamePrefix + field.getName(), fieldAnnotations, param.getMethodAnnotations(), true, field, !paramRequired)); } else return Stream.of(Introspector.getBeanInfo(paramClass).getPropertyDescriptors()) @@ -299,7 +257,7 @@ private static Stream fromSimpleClass(Class paramClass, Fiel .filter(Objects::nonNull) .map(method -> new MethodParameter(method, -1)) .map(methodParameter -> DelegatingMethodParameter.changeContainingClass(methodParameter, paramClass)) - .map(param -> new DelegatingMethodParameter(param, fieldNamePrefix + field.getName(), fieldAnnotations, param.getMethodAnnotations(), true, isNotRequired)); + .map(param -> new DelegatingMethodParameter(param, fieldNamePrefix + field.getName(), fieldAnnotations, param.getMethodAnnotations(), true, field, !paramRequired)); } catch (IntrospectionException e) { return Stream.of(); @@ -307,7 +265,7 @@ private static Stream fromSimpleClass(Class paramClass, Fiel } /** - * All fields of list. + * All fields of list. include parent fields * * @param clazz the clazz * @return the list @@ -370,17 +328,5 @@ public static void removeSimpleTypes(Class... classes) { SIMPLE_TYPES.removeAll(Arrays.asList(classes)); } - /** - * Is nullable boolean. - * - * @param fieldAnnotations the field annotations - * @return the boolean - */ - private static boolean isNullable(Annotation[] fieldAnnotations) { - Collection annotationSimpleNames = Arrays.stream(fieldAnnotations) - .map(Annotation::annotationType) - .map(Class::getSimpleName) - .collect(Collectors.toCollection(LinkedHashSet::new)); - return !hasNotNullAnnotation(annotationSimpleNames); - } + } diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java index c232d7e79..4a8f95cba 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java @@ -31,8 +31,9 @@ import java.io.Reader; import java.io.Writer; import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedParameterizedType; +import java.lang.reflect.Field; import java.lang.reflect.Method; -import java.math.BigDecimal; import java.security.Principal; import java.time.ZoneId; import java.util.ArrayList; @@ -54,22 +55,18 @@ import java.util.stream.Stream; import com.fasterxml.jackson.annotation.JsonView; +import io.swagger.v3.core.converter.AnnotatedType; import io.swagger.v3.core.util.PrimitiveType; import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.media.ArraySchema; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.media.StringSchema; import io.swagger.v3.oas.models.parameters.Parameter; import io.swagger.v3.oas.models.parameters.RequestBody; -import jakarta.validation.constraints.DecimalMax; -import jakarta.validation.constraints.DecimalMin; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; import org.apache.commons.lang3.StringUtils; import org.springdoc.core.customizers.ParameterCustomizer; import org.springdoc.core.discoverer.SpringDocParameterNameDiscoverer; @@ -82,6 +79,7 @@ import org.springdoc.core.providers.JavadocProvider; import org.springdoc.core.utils.SpringDocAnnotationsUtils; +import org.springdoc.core.utils.SchemaUtils; import org.springframework.core.MethodParameter; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.http.HttpMethod; @@ -104,8 +102,6 @@ import static org.springdoc.core.converters.SchemaPropertyDeprecatingConverter.containsDeprecatedAnnotation; import static org.springdoc.core.service.GenericParameterService.isFile; -import static org.springdoc.core.utils.Constants.OPENAPI_ARRAY_TYPE; -import static org.springdoc.core.utils.Constants.OPENAPI_STRING_TYPE; import static org.springdoc.core.utils.SpringDocUtils.getParameterAnnotations; import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE; import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; @@ -122,12 +118,6 @@ public abstract class AbstractRequestService { */ private static final List> PARAM_TYPES_TO_IGNORE = Collections.synchronizedList(new ArrayList<>()); - /** - * The constant ANNOTATIONS_FOR_REQUIRED. - */ -// using string litterals to support both validation-api v1 and v2 - private static final String[] ANNOTATIONS_FOR_REQUIRED = { "NotNull", "NonNull", "NotBlank", "NotEmpty" }; - /** * The constant POSITIVE_OR_ZERO. */ @@ -280,6 +270,9 @@ public static Collection getHeaders(MethodAttributes methodAttributes * @param methodAttributes the method attributes * @param openAPI the open api * @return the operation + * @see org.springdoc.core.customizers.DelegatingMethodParameterCustomizer#customizeList(MethodParameter, List) + * @see ParameterCustomizer#customize(Parameter, MethodParameter) + * @see org.springdoc.core.customizers.PropertyCustomizer#customize(Schema, AnnotatedType) */ public Operation build(HandlerMethod handlerMethod, RequestMethod requestMethod, Operation operation, MethodAttributes methodAttributes, OpenAPI openAPI) { @@ -292,6 +285,7 @@ public Operation build(HandlerMethod handlerMethod, RequestMethod requestMethod, String[] reflectionParametersNames = Arrays.stream(handlerMethod.getMethod().getParameters()).map(java.lang.reflect.Parameter::getName).toArray(String[]::new); if (pNames == null || Arrays.stream(pNames).anyMatch(Objects::isNull)) pNames = reflectionParametersNames; + // Process: DelegatingMethodParameterCustomizer parameters = DelegatingMethodParameter.customize(pNames, parameters, parameterBuilder.getOptionalDelegatingMethodParameterCustomizers(), this.defaultFlatParamObject); RequestBodyInfo requestBodyInfo = new RequestBodyInfo(); List operationParameters = (operation.getParameters() != null) ? operation.getParameters() : new ArrayList<>(); @@ -343,15 +337,18 @@ public Operation build(HandlerMethod handlerMethod, RequestMethod requestMethod, parameter.setDescription(paramJavadocDescription); } } - applyBeanValidatorAnnotations(parameter, parameterAnnotations, parameterInfo.isParameterObject()); + // Process: applyValidationsToSchema + applyBeanValidatorAnnotations(methodParameter, parameter, parameterAnnotations, parameterInfo.isParameterObject()); } else if (!RequestMethod.GET.equals(requestMethod) || OpenApiVersion.OPENAPI_3_1.getVersion().equals(openAPI.getOpenapi())) { if (operation.getRequestBody() != null) requestBodyInfo.setRequestBody(operation.getRequestBody()); + // Process: PropertyCustomizer requestBodyService.calculateRequestBodyInfo(components, methodAttributes, parameterInfo, requestBodyInfo); applyBeanValidatorAnnotations(requestBodyInfo.getRequestBody(), parameterAnnotations, methodParameter.isOptional()); } + // Process: ParameterCustomizer customiseParameter(parameter, parameterInfo, operationParameters); } } @@ -621,15 +618,25 @@ public Parameter buildParam(ParameterInfo parameterInfo, Components components, * @param annotations the annotations * @param isParameterObject the is parameter object */ - public void applyBeanValidatorAnnotations(final Parameter parameter, final List annotations, final boolean isParameterObject) { - Map annos = new HashMap<>(); - if (annotations != null) - annotations.forEach(annotation -> annos.put(annotation.annotationType().getSimpleName(), annotation)); - boolean annotationExists = hasNotNullAnnotation(annos.keySet()); - if (annotationExists && !isParameterObject) + public void applyBeanValidatorAnnotations(final MethodParameter methodParameter, final Parameter parameter, final List annotations, final boolean isParameterObject) { + boolean annotatedNotNull = annotations != null && SchemaUtils.annotatedNotNull(annotations); + if (annotatedNotNull && !isParameterObject) { parameter.setRequired(true); - Schema schema = parameter.getSchema(); - applyValidationsToSchema(annos, schema); + } + if (annotations != null) { + Schema schema = parameter.getSchema(); + SchemaUtils.applyValidationsToSchema(schema, annotations); + if (schema instanceof ArraySchema && isParameterObject && methodParameter instanceof DelegatingMethodParameter mp) { + Field field = mp.getField(); + if (field != null && field.getAnnotatedType() instanceof AnnotatedParameterizedType paramType) { + java.lang.reflect.AnnotatedType[] typeArgs = paramType.getAnnotatedActualTypeArguments(); + for (java.lang.reflect.AnnotatedType typeArg : typeArgs) { + List genericAnnotations = Arrays.stream(typeArg.getAnnotations()).toList(); + SchemaUtils.applyValidationsToSchema(schema.getItems(), genericAnnotations); + } + } + } + } } /** @@ -652,7 +659,7 @@ public void applyBeanValidatorAnnotations(final RequestBody requestBody, final L .filter(annotation -> io.swagger.v3.oas.annotations.parameters.RequestBody.class.equals(annotation.annotationType())) .anyMatch(annotation -> ((io.swagger.v3.oas.annotations.parameters.RequestBody) annotation).required()); } - boolean validationExists = hasNotNullAnnotation(annos.keySet()); + boolean validationExists = SchemaUtils.annotatedNotNull(annos.values().stream().toList()); if (validationExists || (!isOptional && (springRequestBodyRequired || swaggerRequestBodyRequired))) requestBody.setRequired(true); @@ -685,26 +692,6 @@ public GenericParameterService getParameterBuilder() { return parameterBuilder; } - /** - * Calculate size. - * - * @param annos the annos - * @param schema the schema - */ - private void calculateSize(Map annos, Schema schema) { - if (annos.containsKey(Size.class.getSimpleName())) { - Size size = (Size) annos.get(Size.class.getSimpleName()); - if (OPENAPI_ARRAY_TYPE.equals(schema.getType())) { - schema.setMinItems(size.min()); - schema.setMaxItems(size.max()); - } - else if (OPENAPI_STRING_TYPE.equals(schema.getType())) { - schema.setMinLength(size.min()); - schema.setMaxLength(size.max()); - } - } - } - /** * Gets api parameters. * @@ -732,46 +719,6 @@ private Map getApiParamete return apiParametersMap; } - /** - * Apply validations to schema. - * - * @param annos the annos - * @param schema the schema - */ - private void applyValidationsToSchema(Map annos, Schema schema) { - if (annos.containsKey(Min.class.getSimpleName())) { - Min min = (Min) annos.get(Min.class.getSimpleName()); - schema.setMinimum(BigDecimal.valueOf(min.value())); - } - if (annos.containsKey(Max.class.getSimpleName())) { - Max max = (Max) annos.get(Max.class.getSimpleName()); - schema.setMaximum(BigDecimal.valueOf(max.value())); - } - calculateSize(annos, schema); - if (annos.containsKey(DecimalMin.class.getSimpleName())) { - DecimalMin min = (DecimalMin) annos.get(DecimalMin.class.getSimpleName()); - if (min.inclusive()) - schema.setMinimum(BigDecimal.valueOf(Double.parseDouble(min.value()))); - else - schema.setExclusiveMinimum(true); - } - if (annos.containsKey(DecimalMax.class.getSimpleName())) { - DecimalMax max = (DecimalMax) annos.get(DecimalMax.class.getSimpleName()); - if (max.inclusive()) - schema.setMaximum(BigDecimal.valueOf(Double.parseDouble(max.value()))); - else - schema.setExclusiveMaximum(true); - } - if (annos.containsKey(POSITIVE_OR_ZERO)) - schema.setMinimum(BigDecimal.ZERO); - if (annos.containsKey(NEGATIVE_OR_ZERO)) - schema.setMaximum(BigDecimal.ZERO); - if (annos.containsKey(Pattern.class.getSimpleName())) { - Pattern pattern = (Pattern) annos.get(Pattern.class.getSimpleName()); - schema.setPattern(pattern.regexp()); - } - } - /** * Is RequestBody param boolean. * @@ -851,13 +798,11 @@ else if (requestBody.content().length > 0) } /** - * Check if the parameter has any of the annotations that make it non-optional - * - * @param annotationSimpleNames the annotation simple class named, e.g. NotNull - * @return whether any of the known NotNull annotations are present + * @deprecated use {@link SchemaUtils#hasNotNullAnnotation(Collection)} */ + @Deprecated(forRemoval = true) public static boolean hasNotNullAnnotation(Collection annotationSimpleNames) { - return Arrays.stream(ANNOTATIONS_FOR_REQUIRED).anyMatch(annotationSimpleNames::contains); + return SchemaUtils.hasNotNullAnnotation(annotationSimpleNames); } } diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/Constants.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/Constants.java index 9ab905af6..8c504af8e 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/Constants.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/Constants.java @@ -46,7 +46,7 @@ public final class Constants { * The constant SPRINGDOC_SWAGGER_PREFIX. */ public static final String SPRINGDOC_SWAGGER_PREFIX =SPRINGDOC_PREFIX+".swagger-ui"; - + /** * The constant DEFAULT_API_DOCS_URL. */ @@ -422,8 +422,13 @@ public final class Constants { /** * The constant SPRINGDOC_NULLABLE_REQUEST_PARAMETER_ENABLED. */ + @Deprecated(since = "2.8.7") public static final String SPRINGDOC_NULLABLE_REQUEST_PARAMETER_ENABLED = "springdoc.nullable-request-parameter-enabled"; + /** + * The constant SPRINGDOC_DEFAULT_FLAT_PARAM_OBJECT. + */ + public static final String SPRINGDOC_DEFAULT_FLAT_PARAM_OBJECT = "springdoc.default-flat-param-object"; /** * The constant SPRINGDOC_ENABLE_ADDITIONAL_SCHEMAS_RESOLUTION. */ diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java new file mode 100644 index 000000000..6d5f2d56f --- /dev/null +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java @@ -0,0 +1,213 @@ +package org.springdoc.core.utils; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.models.media.Schema; +import jakarta.validation.constraints.*; +import kotlin.reflect.KProperty; +import kotlin.reflect.jvm.ReflectJvmMapping; +import org.springframework.core.KotlinDetector; +import org.springframework.lang.Nullable; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +import static org.springdoc.core.utils.Constants.OPENAPI_ARRAY_TYPE; +import static org.springdoc.core.utils.Constants.OPENAPI_STRING_TYPE; + +/** + * The type Validation utils. + * + * @author dyun + */ +public class SchemaUtils { + + public static Boolean JAVA_FIELD_NULLABLE_DEFAULT = true; + + private static final Set> OPTIONAL_TYPES = new HashSet<>(); + + static { + OPTIONAL_TYPES.add(Optional.class); + OPTIONAL_TYPES.add(OptionalInt.class); + OPTIONAL_TYPES.add(OptionalLong.class); + OPTIONAL_TYPES.add(OptionalDouble.class); + } + + /** + * The constant ANNOTATIONS_FOR_REQUIRED. + */ + // using string litterals to support both validation-api v1 and v2 + public static final List ANNOTATIONS_FOR_REQUIRED = Arrays.asList("NotNull", "NonNull", "NotBlank", "NotEmpty"); + + /** + * Is swagger visible. + * + * @param schema the schema + * @param parameter the parameter + * @return the boolean + */ + public static boolean swaggerVisible(@Nullable io.swagger.v3.oas.annotations.media.Schema schema, @Nullable Parameter parameter) { + if (parameter != null) { + return !parameter.hidden(); + } + if (schema != null) { + return !schema.hidden(); + } + return true; + } + + /** + * Is swagger required. it may return {@code null} if not specified require value or mode + * + * @param schema the schema + * @param parameter the parameter + * @return the boolean or {@code null} + * @see io.swagger.v3.oas.annotations.Parameter#required() + * @see io.swagger.v3.oas.annotations.media.Schema#required() + * @see io.swagger.v3.oas.annotations.media.Schema#requiredMode() + */ + @Nullable + public static Boolean swaggerRequired(@Nullable io.swagger.v3.oas.annotations.media.Schema schema, @Nullable Parameter parameter) { + if (parameter != null && parameter.required()) { + return true; + } + if (schema != null) { + if (schema.required() || schema.requiredMode() == io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED) { + return true; + } + if (schema.requiredMode() == io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED) { + return false; + } + } + return null; + } + + /** + * Check if the parameter has any of the annotations that make it non-optional + * + * @param annotationSimpleNames the annotation simple class named, e.g. NotNull + * @return whether any of the known NotNull annotations are present + */ + public static boolean hasNotNullAnnotation(Collection annotationSimpleNames) { + return ANNOTATIONS_FOR_REQUIRED.stream().anyMatch(annotationSimpleNames::contains); + } + + /** + * Is annotated notnull. + * + * @param annotations the field annotations + * @return the boolean + */ + public static boolean annotatedNotNull(List annotations) { + Collection annotationSimpleNames = annotations.stream() + .map(annotation -> annotation.annotationType().getSimpleName()) + .collect(Collectors.toSet()); + return ANNOTATIONS_FOR_REQUIRED.stream().anyMatch(annotationSimpleNames::contains); + } + + /** + * Is field nullable. + * java default is nullable {@link SchemaUtils#JAVA_FIELD_NULLABLE_DEFAULT} + * + * @param field the field + * @return the boolean + */ + public static boolean fieldNullable(Field field) { + if (OPTIONAL_TYPES.stream().anyMatch(c -> c.isAssignableFrom(field.getType()))) { + return true; + } + if (KotlinDetector.isKotlinPresent() && KotlinDetector.isKotlinReflectPresent() + && KotlinDetector.isKotlinType(field.getDeclaringClass())) { + KProperty kotlinProperty = ReflectJvmMapping.getKotlinProperty(field); + if (kotlinProperty != null) { + return kotlinProperty.getReturnType().isMarkedNullable(); + } + } + return JAVA_FIELD_NULLABLE_DEFAULT; + } + + /** + * Is field required. + * + * @param field the field + * @param schema the schema + * @param parameter the parameter + * @return the boolean + * @see io.swagger.v3.oas.annotations.Parameter#required() + * @see io.swagger.v3.oas.annotations.media.Schema#required() + * @see io.swagger.v3.oas.annotations.media.Schema#requiredMode() + */ + public static boolean fieldRequired(Field field, @Nullable io.swagger.v3.oas.annotations.media.Schema schema, @Nullable Parameter parameter) { + Boolean swaggerRequired = swaggerRequired(schema, parameter); + if (swaggerRequired != null) { + return swaggerRequired; + } + boolean annotatedNotNull = annotatedNotNull(Arrays.asList(field.getDeclaredAnnotations())); + if (annotatedNotNull) { + return true; + } + return !fieldNullable(field); + } + + /** + * Apply validations to schema. + * the annotation order effects the result of the validation. + * + * @param schema the schema + * @param annotations the annotations + */ + public static void applyValidationsToSchema(Schema schema, List annotations) { + annotations.forEach(anno -> { + String annotationName = anno.annotationType().getSimpleName(); + if (annotationName.equals(PositiveOrZero.class.getSimpleName())) { + schema.setMinimum(BigDecimal.ZERO); + } + if (annotationName.equals(NegativeOrZero.class.getSimpleName())) { + schema.setMaximum(BigDecimal.ZERO); + } + if (annotationName.equals(Min.class.getSimpleName())) { + schema.setMinimum(BigDecimal.valueOf(((Min) anno).value())); + } + if (annotationName.equals(Max.class.getSimpleName())) { + schema.setMaximum(BigDecimal.valueOf(((Max) anno).value())); + } + if (annotationName.equals(DecimalMin.class.getSimpleName())) { + DecimalMin min = (DecimalMin) anno; + if (min.inclusive()) { + schema.setMinimum(BigDecimal.valueOf(Double.parseDouble(min.value()))); + } else { + schema.setExclusiveMinimum(true); + } + } + if (annotationName.equals(DecimalMax.class.getSimpleName())) { + DecimalMax max = (DecimalMax) anno; + if (max.inclusive()) { + schema.setMaximum(BigDecimal.valueOf(Double.parseDouble(max.value()))); + } else { + schema.setExclusiveMaximum(true); + } + } + if (annotationName.equals(Size.class.getSimpleName())) { + if (OPENAPI_ARRAY_TYPE.equals(schema.getType())) { + schema.setMinItems(((Size) anno).min()); + schema.setMaxItems(((Size) anno).max()); + } else if (OPENAPI_STRING_TYPE.equals(schema.getType())) { + schema.setMinLength(((Size) anno).min()); + schema.setMaxLength(((Size) anno).max()); + } + } + if (annotationName.equals(Pattern.class.getSimpleName())) { + schema.setPattern(((Pattern) anno).regexp()); + } + }); + if (annotatedNotNull(annotations)) { + String specVersion = schema.getSpecVersion().name(); + if (!"V30".equals(specVersion)) { + schema.setNullable(false); + } + } + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/pom.xml index 7c16b5020..73c9858e4 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/pom.xml @@ -69,6 +69,10 @@ spring + + -java-parameters + -Xemit-jvm-type-annotations + diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app15/DemoController.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app15/DemoController.kt new file mode 100644 index 000000000..1d8c6be57 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app15/DemoController.kt @@ -0,0 +1,50 @@ +package test.org.springdoc.api.v31.app15 + +import io.swagger.v3.oas.annotations.media.Schema +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/demo") +class DocumentsApiController { + + @GetMapping + suspend fun getDocuments( + request: DemoRequest + ): DemoDto = DemoDto(42) +} + +data class DemoDto( + var id: Long, +) + +/** +field following test cases: +``` +| case required | 11true | 10true | 01false | 00false | N1false | N0true | +| :-------------------- | :----- | :----- | :------ | :------ | :----- | :------ | +| schema required value | true | true | false | false | none | none | +| field nullable | true | false | true | false | true | false | +``` + */ +class DemoRequest( + + @field:Schema(required = true) + val field11true: String?, + + @field:Schema(required = true) + val field10true: String, + + @field:Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED) + val field01false: String?, + + @field:Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED) + val field00false: String, + + @field:Schema + val fieldN1false: String?, + + @field:Schema + val fieldN0true: String, +) diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app15/SpringDocApp15Test.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app15/SpringDocApp15Test.kt new file mode 100644 index 000000000..ae6b98f4b --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app15/SpringDocApp15Test.kt @@ -0,0 +1,9 @@ +package test.org.springdoc.api.v31.app15 + +import org.springframework.boot.autoconfigure.SpringBootApplication +import test.org.springdoc.api.v31.AbstractKotlinSpringDocMVCTest + +class SpringDocApp15Test : AbstractKotlinSpringDocMVCTest() { + @SpringBootApplication + class DemoApplication +} diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app16/DemoController.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app16/DemoController.kt new file mode 100644 index 000000000..95ad31811 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app16/DemoController.kt @@ -0,0 +1,50 @@ +package test.org.springdoc.api.v31.app16 + +import io.swagger.v3.oas.annotations.media.Schema +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/demo") +class DocumentsApiController { + + @GetMapping + suspend fun getDocuments( + request: DemoRequest + ): DemoDto = DemoDto(42) +} + +data class DemoDto( + var id: Long, +) + +/** +field following test cases: +``` +| case required | 11true | 10true | 01false | 00false | N1false | N0true | +| :-------------------- | :----- | :----- | :------ | :------ | :----- | :------ | +| schema required value | true | true | false | false | none | none | +| field nullable | true | false | true | false | true | false | +``` + */ +class DemoRequest( + + @field:Schema(required = true) + val field11true: String?, + + @field:Schema(required = true) + val field10true: String, + + @field:Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED) + val field01false: String?, + + @field:Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED) + val field00false: String, + + @field:Schema + val fieldN1false: String?, + + @field:Schema + val fieldN0true: String, +) diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app16/SpringDocApp16Test.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app16/SpringDocApp16Test.kt new file mode 100644 index 000000000..1215fe572 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app16/SpringDocApp16Test.kt @@ -0,0 +1,12 @@ +package test.org.springdoc.api.v31.app16 + +import org.springdoc.core.utils.Constants.SPRINGDOC_DEFAULT_FLAT_PARAM_OBJECT +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.test.context.TestPropertySource +import test.org.springdoc.api.v31.AbstractKotlinSpringDocMVCTest + +@TestPropertySource(properties = ["$SPRINGDOC_DEFAULT_FLAT_PARAM_OBJECT=true"]) +class SpringDocApp16Test : AbstractKotlinSpringDocMVCTest() { + @SpringBootApplication + class DemoApplication +} diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app17/DemoController.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app17/DemoController.kt new file mode 100644 index 000000000..ce8b21574 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app17/DemoController.kt @@ -0,0 +1,58 @@ +package test.org.springdoc.api.v31.app17 + +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.Size +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Validated +@RestController +@RequestMapping("/api/demo") +class DocumentsApiController { + + @Valid + @GetMapping + suspend fun validOverrideAndOrder( + @Valid request: ChildBar + ): DemoDto = DemoDto(42) +} + +data class DemoDto( + var id: Long, +) + +@Schema(description = "ParentFoo Request") +open class ParentFoo( + @field:NotEmpty // use flat-param-object. to remove or merge annotations by use DelegatingMethodParameterCustomizer + @field:Size(min = 10, max = 20) + @field:ArraySchema(schema = Schema(description = "wrong: list[minItems = 10, maxItems = 20] wrong: string[minLength = 30, maxLength = 40]")) + open var fooList: List<@Size(min = 30, max = 40) String> = listOf() +) + +@Schema(description = "ChildBar Request") +data class ChildBar( + @field:Size(min = 11, max = 21) // should override parent + @field:ArraySchema(schema = Schema(description = "expect: list[minItems = 11, maxItems = 21] expect: string[minLength = 31, maxLength = 41]")) + override var fooList: List<@Size(min = 31, max = 41) String>, + + @field:Size(min = 12, max = 22) // genericType should work + @field:ArraySchema(schema = Schema(description = "expect: list[minItems = 12, maxItems = 22] expect: string[minLength = 32, maxLength = 42]")) + var barList: List<@Size(min = 32, max = 42) String> = listOf(), + + @field:DecimalMin("1") // will be override by min + @field:Min(2) + @field:Schema(description = "expect: minimum = 2") + var validOrder1: Long, + + @field:Min(2) // will be override by DecimalMin + @field:DecimalMin("1") + @field:Schema(description = "expect: minimum = 1") + var validOrder2: Long, +) : ParentFoo() diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app17/SpringDocApp17Test.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app17/SpringDocApp17Test.kt new file mode 100644 index 000000000..b2a22101b --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app17/SpringDocApp17Test.kt @@ -0,0 +1,51 @@ +package test.org.springdoc.api.v31.app17 + +import jakarta.validation.constraints.NotEmpty +import org.junit.jupiter.api.Assertions +import org.springdoc.core.customizers.DelegatingMethodParameterCustomizer +import org.springdoc.core.extractor.DelegatingMethodParameter +import org.springdoc.core.utils.Constants.SPRINGDOC_DEFAULT_FLAT_PARAM_OBJECT +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.context.annotation.Bean +import org.springframework.core.MethodParameter +import org.springframework.test.context.TestPropertySource +import test.org.springdoc.api.v31.AbstractKotlinSpringDocMVCTest + +@TestPropertySource(properties = ["$SPRINGDOC_DEFAULT_FLAT_PARAM_OBJECT=true"]) +class SpringDocApp17Test : AbstractKotlinSpringDocMVCTest() { + @SpringBootApplication + class DemoApplication { + + @Bean + fun optionalKotlinDelegatingMethodParameterCustomizer(): DelegatingMethodParameterCustomizer { + return object : DelegatingMethodParameterCustomizer { + override fun customizeList( + originalParameter: MethodParameter, + methodParameters: MutableList + ) { + val fieldNameSet = mutableSetOf() + methodParameters.forEachIndexed { index, it -> + if (it is DelegatingMethodParameter && it.isParameterObject && it.field != null) { + val field = it.field!! + if (fieldNameSet.contains(field.name)) { + val fieldAnnotations = field.annotations + Assertions.assertTrue(fieldAnnotations.any { it is NotEmpty }) + // remove parent field + methodParameters.removeAt(index) + } else fieldNameSet.add(field.name) + } + } + if (methodParameters.size > 1) { + methodParameters.sortBy { it.parameterIndex } + } + } + + override fun customize( + originalParameter: MethodParameter, + methodParameter: MethodParameter + ) { + } + } + } + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app15.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app15.json new file mode 100644 index 000000000..84fea23c2 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app15.json @@ -0,0 +1,89 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/api/demo": { + "get": { + "tags": [ + "documents-api-controller" + ], + "operationId": "getDocuments", + "parameters": [ + { + "name": "request", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/DemoRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DemoDto" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "DemoRequest": { + "type": "object", + "properties": { + "field11true": { + "type": "string" + }, + "field10true": { + "type": "string" + }, + "field01false": { + "type": "string" + }, + "field00false": { + "type": "string" + }, + "fieldN1false": { + "type": "string" + }, + "fieldN0true": { + "type": "string" + } + }, + "required": [ + "field10true", + "field11true", + "fieldN0true" + ] + }, + "DemoDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "id" + ] + } + } + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app16.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app16.json new file mode 100644 index 000000000..e8bfb168d --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app16.json @@ -0,0 +1,101 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/api/demo": { + "get": { + "tags": [ + "documents-api-controller" + ], + "operationId": "getDocuments", + "parameters": [ + { + "name": "field11true", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "field10true", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "field01false", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "field00false", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "fieldN1false", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "fieldN0true", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DemoDto" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "DemoDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "id" + ] + } + } + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app17.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app17.json new file mode 100644 index 000000000..d7316b16a --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app17.json @@ -0,0 +1,109 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/api/demo": { + "get": { + "tags": [ + "documents-api-controller" + ], + "operationId": "validOverrideAndOrder", + "parameters": [ + { + "name": "fooList", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "description": "expect: list[minItems = 11, maxItems = 21] expect: string[minLength = 31, maxLength = 41]", + "maxLength": 41, + "minLength": 31 + }, + "maxItems": 21, + "minItems": 11 + } + }, + { + "name": "barList", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "description": "expect: list[minItems = 12, maxItems = 22] expect: string[minLength = 32, maxLength = 42]", + "maxLength": 42, + "minLength": 32 + }, + "maxItems": 22, + "minItems": 12 + } + }, + { + "name": "validOrder1", + "in": "query", + "description": "expect: minimum = 2", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "description": "expect: minimum = 2", + "minimum": 2 + } + }, + { + "name": "validOrder2", + "in": "query", + "description": "expect: minimum = 1", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "description": "expect: minimum = 1", + "minimum": 1.0 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DemoDto" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "DemoDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "id" + ] + } + } + } +} From f7629c199b2f71fb361695f11430071961471b13 Mon Sep 17 00:00:00 2001 From: m2 Date: Wed, 26 Mar 2025 23:05:45 +0800 Subject: [PATCH 2/6] fix test --- .../src/test/resources/results/3.1.0/app193.json | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app193.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app193.json index a4d76d380..221943c56 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app193.json +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app193.json @@ -70,8 +70,6 @@ "components": { "schemas": { "Books": { - "type": "array", - "description": "Represents a list of Books.", "allOf": [ { "$ref": "#/components/schemas/Knowledge" @@ -84,7 +82,8 @@ } } } - ] + ], + "description": "Represents a list of Books." }, "Knowledge": { "type": "object", @@ -95,8 +94,6 @@ "description": "Represents an Animal class." }, "Cat": { - "type": "object", - "description": "Represents a Cat class.", "allOf": [ { "$ref": "#/components/schemas/Animal" @@ -110,11 +107,10 @@ } } } - ] + ], + "description": "Represents a Cat class." }, "Dog": { - "type": "object", - "description": "Represents a Dog class.", "allOf": [ { "$ref": "#/components/schemas/Animal" @@ -131,7 +127,8 @@ } } } - ] + ], + "description": "Represents a Dog class." } } } From 93783501d5c3a62adcd68145c4f9217e0a6a0341 Mon Sep 17 00:00:00 2001 From: m2 Date: Thu, 27 Mar 2025 15:28:21 +0800 Subject: [PATCH 3/6] format doc --- .../DelegatingMethodParameterCustomizer.java | 4 +- .../core/service/AbstractRequestService.java | 9 +- .../org/springdoc/core/utils/SchemaUtils.java | 384 +++++++++--------- 3 files changed, 207 insertions(+), 190 deletions(-) diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/DelegatingMethodParameterCustomizer.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/DelegatingMethodParameterCustomizer.java index c6b9d3242..3faae7340 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/DelegatingMethodParameterCustomizer.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/DelegatingMethodParameterCustomizer.java @@ -33,6 +33,7 @@ /** * The interface Delegating method parameter customizer. + * @author dyun */ @FunctionalInterface public interface DelegatingMethodParameterCustomizer { @@ -42,8 +43,7 @@ public interface DelegatingMethodParameterCustomizer { * * @param originalParameter the original parameter * @param methodParameters the exploded parameters - * @return the list - */ + */ @Nullable default void customizeList(MethodParameter originalParameter, List methodParameters) { methodParameters.forEach(parameter -> customize(originalParameter, parameter)); diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java index 4a8f95cba..194562dca 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java @@ -614,8 +614,9 @@ public Parameter buildParam(ParameterInfo parameterInfo, Components components, /** * Apply bean validator annotations. * - * @param parameter the parameter - * @param annotations the annotations + * @param methodParameter the method parameter + * @param parameter the parameter + * @param annotations the annotations * @param isParameterObject the is parameter object */ public void applyBeanValidatorAnnotations(final MethodParameter methodParameter, final Parameter parameter, final List annotations, final boolean isParameterObject) { @@ -798,7 +799,9 @@ else if (requestBody.content().length > 0) } /** - * @deprecated use {@link SchemaUtils#hasNotNullAnnotation(Collection)} + * deprecated use {@link SchemaUtils#hasNotNullAnnotation(Collection)} + * @param annotationSimpleNames the annotation simple names + * @return boolean */ @Deprecated(forRemoval = true) public static boolean hasNotNullAnnotation(Collection annotationSimpleNames) { diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java index 6d5f2d56f..4dccb8c4f 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java @@ -24,190 +24,204 @@ */ public class SchemaUtils { - public static Boolean JAVA_FIELD_NULLABLE_DEFAULT = true; - - private static final Set> OPTIONAL_TYPES = new HashSet<>(); - - static { - OPTIONAL_TYPES.add(Optional.class); - OPTIONAL_TYPES.add(OptionalInt.class); - OPTIONAL_TYPES.add(OptionalLong.class); - OPTIONAL_TYPES.add(OptionalDouble.class); - } - - /** - * The constant ANNOTATIONS_FOR_REQUIRED. - */ - // using string litterals to support both validation-api v1 and v2 - public static final List ANNOTATIONS_FOR_REQUIRED = Arrays.asList("NotNull", "NonNull", "NotBlank", "NotEmpty"); - - /** - * Is swagger visible. - * - * @param schema the schema - * @param parameter the parameter - * @return the boolean - */ - public static boolean swaggerVisible(@Nullable io.swagger.v3.oas.annotations.media.Schema schema, @Nullable Parameter parameter) { - if (parameter != null) { - return !parameter.hidden(); - } - if (schema != null) { - return !schema.hidden(); - } - return true; - } - - /** - * Is swagger required. it may return {@code null} if not specified require value or mode - * - * @param schema the schema - * @param parameter the parameter - * @return the boolean or {@code null} - * @see io.swagger.v3.oas.annotations.Parameter#required() - * @see io.swagger.v3.oas.annotations.media.Schema#required() - * @see io.swagger.v3.oas.annotations.media.Schema#requiredMode() - */ - @Nullable - public static Boolean swaggerRequired(@Nullable io.swagger.v3.oas.annotations.media.Schema schema, @Nullable Parameter parameter) { - if (parameter != null && parameter.required()) { - return true; - } - if (schema != null) { - if (schema.required() || schema.requiredMode() == io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED) { - return true; - } - if (schema.requiredMode() == io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED) { - return false; - } - } - return null; - } - - /** - * Check if the parameter has any of the annotations that make it non-optional - * - * @param annotationSimpleNames the annotation simple class named, e.g. NotNull - * @return whether any of the known NotNull annotations are present - */ - public static boolean hasNotNullAnnotation(Collection annotationSimpleNames) { - return ANNOTATIONS_FOR_REQUIRED.stream().anyMatch(annotationSimpleNames::contains); - } - - /** - * Is annotated notnull. - * - * @param annotations the field annotations - * @return the boolean - */ - public static boolean annotatedNotNull(List annotations) { - Collection annotationSimpleNames = annotations.stream() - .map(annotation -> annotation.annotationType().getSimpleName()) - .collect(Collectors.toSet()); - return ANNOTATIONS_FOR_REQUIRED.stream().anyMatch(annotationSimpleNames::contains); - } - - /** - * Is field nullable. - * java default is nullable {@link SchemaUtils#JAVA_FIELD_NULLABLE_DEFAULT} - * - * @param field the field - * @return the boolean - */ - public static boolean fieldNullable(Field field) { - if (OPTIONAL_TYPES.stream().anyMatch(c -> c.isAssignableFrom(field.getType()))) { - return true; - } - if (KotlinDetector.isKotlinPresent() && KotlinDetector.isKotlinReflectPresent() - && KotlinDetector.isKotlinType(field.getDeclaringClass())) { - KProperty kotlinProperty = ReflectJvmMapping.getKotlinProperty(field); - if (kotlinProperty != null) { - return kotlinProperty.getReturnType().isMarkedNullable(); - } - } - return JAVA_FIELD_NULLABLE_DEFAULT; - } - - /** - * Is field required. - * - * @param field the field - * @param schema the schema - * @param parameter the parameter - * @return the boolean - * @see io.swagger.v3.oas.annotations.Parameter#required() - * @see io.swagger.v3.oas.annotations.media.Schema#required() - * @see io.swagger.v3.oas.annotations.media.Schema#requiredMode() - */ - public static boolean fieldRequired(Field field, @Nullable io.swagger.v3.oas.annotations.media.Schema schema, @Nullable Parameter parameter) { - Boolean swaggerRequired = swaggerRequired(schema, parameter); - if (swaggerRequired != null) { - return swaggerRequired; - } - boolean annotatedNotNull = annotatedNotNull(Arrays.asList(field.getDeclaredAnnotations())); - if (annotatedNotNull) { - return true; - } - return !fieldNullable(field); - } - - /** - * Apply validations to schema. - * the annotation order effects the result of the validation. - * - * @param schema the schema - * @param annotations the annotations - */ - public static void applyValidationsToSchema(Schema schema, List annotations) { - annotations.forEach(anno -> { - String annotationName = anno.annotationType().getSimpleName(); - if (annotationName.equals(PositiveOrZero.class.getSimpleName())) { - schema.setMinimum(BigDecimal.ZERO); - } - if (annotationName.equals(NegativeOrZero.class.getSimpleName())) { - schema.setMaximum(BigDecimal.ZERO); - } - if (annotationName.equals(Min.class.getSimpleName())) { - schema.setMinimum(BigDecimal.valueOf(((Min) anno).value())); - } - if (annotationName.equals(Max.class.getSimpleName())) { - schema.setMaximum(BigDecimal.valueOf(((Max) anno).value())); - } - if (annotationName.equals(DecimalMin.class.getSimpleName())) { - DecimalMin min = (DecimalMin) anno; - if (min.inclusive()) { - schema.setMinimum(BigDecimal.valueOf(Double.parseDouble(min.value()))); - } else { - schema.setExclusiveMinimum(true); - } - } - if (annotationName.equals(DecimalMax.class.getSimpleName())) { - DecimalMax max = (DecimalMax) anno; - if (max.inclusive()) { - schema.setMaximum(BigDecimal.valueOf(Double.parseDouble(max.value()))); - } else { - schema.setExclusiveMaximum(true); - } - } - if (annotationName.equals(Size.class.getSimpleName())) { - if (OPENAPI_ARRAY_TYPE.equals(schema.getType())) { - schema.setMinItems(((Size) anno).min()); - schema.setMaxItems(((Size) anno).max()); - } else if (OPENAPI_STRING_TYPE.equals(schema.getType())) { - schema.setMinLength(((Size) anno).min()); - schema.setMaxLength(((Size) anno).max()); - } - } - if (annotationName.equals(Pattern.class.getSimpleName())) { - schema.setPattern(((Pattern) anno).regexp()); - } - }); - if (annotatedNotNull(annotations)) { - String specVersion = schema.getSpecVersion().name(); - if (!"V30".equals(specVersion)) { - schema.setNullable(false); - } - } - } + /** + * The constructor. + */ + private SchemaUtils() { + } + + /** + * The constant JAVA_FIELD_NULLABLE_DEFAULT. + */ + public static Boolean JAVA_FIELD_NULLABLE_DEFAULT = true; + + /** + * The constant OPTIONAL_TYPES. + */ + private static final Set> OPTIONAL_TYPES = new HashSet<>(); + + static { + OPTIONAL_TYPES.add(Optional.class); + OPTIONAL_TYPES.add(OptionalInt.class); + OPTIONAL_TYPES.add(OptionalLong.class); + OPTIONAL_TYPES.add(OptionalDouble.class); + } + + /** + * The constant ANNOTATIONS_FOR_REQUIRED. + */ + // using string litterals to support both validation-api v1 and v2 + public static final List ANNOTATIONS_FOR_REQUIRED = Arrays.asList("NotNull", "NonNull", "NotBlank", + "NotEmpty"); + + /** + * Is swagger visible. + * @param schema the schema + * @param parameter the parameter + * @return the boolean + */ + public static boolean swaggerVisible(@Nullable io.swagger.v3.oas.annotations.media.Schema schema, + @Nullable Parameter parameter) { + if (parameter != null) { + return !parameter.hidden(); + } + if (schema != null) { + return !schema.hidden(); + } + return true; + } + + /** + * Is swagger required. it may return {@code null} if not specified require value or + * mode + * @param schema the schema + * @param parameter the parameter + * @return the boolean or {@code null} + * @see io.swagger.v3.oas.annotations.Parameter#required() + * @see io.swagger.v3.oas.annotations.media.Schema#required() + * @see io.swagger.v3.oas.annotations.media.Schema#requiredMode() + */ + @Nullable + public static Boolean swaggerRequired(@Nullable io.swagger.v3.oas.annotations.media.Schema schema, + @Nullable Parameter parameter) { + if (parameter != null && parameter.required()) { + return true; + } + if (schema != null) { + if (schema.required() + || schema.requiredMode() == io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED) { + return true; + } + if (schema.requiredMode() == io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED) { + return false; + } + } + return null; + } + + /** + * Check if the parameter has any of the annotations that make it non-optional + * @param annotationSimpleNames the annotation simple class named, e.g. NotNull + * @return whether any of the known NotNull annotations are present + */ + public static boolean hasNotNullAnnotation(Collection annotationSimpleNames) { + return ANNOTATIONS_FOR_REQUIRED.stream().anyMatch(annotationSimpleNames::contains); + } + + /** + * Is annotated notnull. + * @param annotations the field annotations + * @return the boolean + */ + public static boolean annotatedNotNull(List annotations) { + Collection annotationSimpleNames = annotations.stream() + .map(annotation -> annotation.annotationType().getSimpleName()) + .collect(Collectors.toSet()); + return ANNOTATIONS_FOR_REQUIRED.stream().anyMatch(annotationSimpleNames::contains); + } + + /** + * Is field nullable. java default is nullable + * {@link SchemaUtils#JAVA_FIELD_NULLABLE_DEFAULT} + * @param field the field + * @return the boolean + */ + public static boolean fieldNullable(Field field) { + if (OPTIONAL_TYPES.stream().anyMatch(c -> c.isAssignableFrom(field.getType()))) { + return true; + } + if (KotlinDetector.isKotlinPresent() && KotlinDetector.isKotlinReflectPresent() + && KotlinDetector.isKotlinType(field.getDeclaringClass())) { + KProperty kotlinProperty = ReflectJvmMapping.getKotlinProperty(field); + if (kotlinProperty != null) { + return kotlinProperty.getReturnType().isMarkedNullable(); + } + } + return JAVA_FIELD_NULLABLE_DEFAULT; + } + + /** + * Is field required. + * @param field the field + * @param schema the schema + * @param parameter the parameter + * @return the boolean + * @see io.swagger.v3.oas.annotations.Parameter#required() + * @see io.swagger.v3.oas.annotations.media.Schema#required() + * @see io.swagger.v3.oas.annotations.media.Schema#requiredMode() + */ + public static boolean fieldRequired(Field field, @Nullable io.swagger.v3.oas.annotations.media.Schema schema, + @Nullable Parameter parameter) { + Boolean swaggerRequired = swaggerRequired(schema, parameter); + if (swaggerRequired != null) { + return swaggerRequired; + } + boolean annotatedNotNull = annotatedNotNull(Arrays.asList(field.getDeclaredAnnotations())); + if (annotatedNotNull) { + return true; + } + return !fieldNullable(field); + } + + /** + * Apply validations to schema. the annotation order effects the result of the + * validation. + * @param schema the schema + * @param annotations the annotations + */ + public static void applyValidationsToSchema(Schema schema, List annotations) { + annotations.forEach(anno -> { + String annotationName = anno.annotationType().getSimpleName(); + if (annotationName.equals(PositiveOrZero.class.getSimpleName())) { + schema.setMinimum(BigDecimal.ZERO); + } + if (annotationName.equals(NegativeOrZero.class.getSimpleName())) { + schema.setMaximum(BigDecimal.ZERO); + } + if (annotationName.equals(Min.class.getSimpleName())) { + schema.setMinimum(BigDecimal.valueOf(((Min) anno).value())); + } + if (annotationName.equals(Max.class.getSimpleName())) { + schema.setMaximum(BigDecimal.valueOf(((Max) anno).value())); + } + if (annotationName.equals(DecimalMin.class.getSimpleName())) { + DecimalMin min = (DecimalMin) anno; + if (min.inclusive()) { + schema.setMinimum(BigDecimal.valueOf(Double.parseDouble(min.value()))); + } + else { + schema.setExclusiveMinimum(true); + } + } + if (annotationName.equals(DecimalMax.class.getSimpleName())) { + DecimalMax max = (DecimalMax) anno; + if (max.inclusive()) { + schema.setMaximum(BigDecimal.valueOf(Double.parseDouble(max.value()))); + } + else { + schema.setExclusiveMaximum(true); + } + } + if (annotationName.equals(Size.class.getSimpleName())) { + if (OPENAPI_ARRAY_TYPE.equals(schema.getType())) { + schema.setMinItems(((Size) anno).min()); + schema.setMaxItems(((Size) anno).max()); + } + else if (OPENAPI_STRING_TYPE.equals(schema.getType())) { + schema.setMinLength(((Size) anno).min()); + schema.setMaxLength(((Size) anno).max()); + } + } + if (annotationName.equals(Pattern.class.getSimpleName())) { + schema.setPattern(((Pattern) anno).regexp()); + } + }); + if (annotatedNotNull(annotations)) { + String specVersion = schema.getSpecVersion().name(); + if (!"V30".equals(specVersion)) { + schema.setNullable(false); + } + } + } } From f57de3672c24f08cd77263ed5d2a7897daf052bc Mon Sep 17 00:00:00 2001 From: m2 Date: Thu, 27 Mar 2025 16:13:32 +0800 Subject: [PATCH 4/6] add v30 test case --- .../springdoc/api/v30/app15/DemoController.kt | 50 ++++++++ .../api/v30/app15/SpringDocApp15Test.kt | 9 ++ .../springdoc/api/v30/app16/DemoController.kt | 50 ++++++++ .../api/v30/app16/SpringDocApp16Test.kt | 12 ++ .../springdoc/api/v30/app17/DemoController.kt | 58 ++++++++++ .../api/v30/app17/SpringDocApp17Test.kt | 51 ++++++++ .../test/resources/results/3.0.1/app15.json | 89 ++++++++++++++ .../test/resources/results/3.0.1/app16.json | 101 ++++++++++++++++ .../test/resources/results/3.0.1/app17.json | 109 ++++++++++++++++++ 9 files changed, 529 insertions(+) create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app15/DemoController.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app15/SpringDocApp15Test.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app16/DemoController.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app16/SpringDocApp16Test.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app17/DemoController.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app17/SpringDocApp17Test.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app15.json create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app16.json create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app17.json diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app15/DemoController.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app15/DemoController.kt new file mode 100644 index 000000000..1d8c6be57 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app15/DemoController.kt @@ -0,0 +1,50 @@ +package test.org.springdoc.api.v31.app15 + +import io.swagger.v3.oas.annotations.media.Schema +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/demo") +class DocumentsApiController { + + @GetMapping + suspend fun getDocuments( + request: DemoRequest + ): DemoDto = DemoDto(42) +} + +data class DemoDto( + var id: Long, +) + +/** +field following test cases: +``` +| case required | 11true | 10true | 01false | 00false | N1false | N0true | +| :-------------------- | :----- | :----- | :------ | :------ | :----- | :------ | +| schema required value | true | true | false | false | none | none | +| field nullable | true | false | true | false | true | false | +``` + */ +class DemoRequest( + + @field:Schema(required = true) + val field11true: String?, + + @field:Schema(required = true) + val field10true: String, + + @field:Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED) + val field01false: String?, + + @field:Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED) + val field00false: String, + + @field:Schema + val fieldN1false: String?, + + @field:Schema + val fieldN0true: String, +) diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app15/SpringDocApp15Test.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app15/SpringDocApp15Test.kt new file mode 100644 index 000000000..ae6b98f4b --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app15/SpringDocApp15Test.kt @@ -0,0 +1,9 @@ +package test.org.springdoc.api.v31.app15 + +import org.springframework.boot.autoconfigure.SpringBootApplication +import test.org.springdoc.api.v31.AbstractKotlinSpringDocMVCTest + +class SpringDocApp15Test : AbstractKotlinSpringDocMVCTest() { + @SpringBootApplication + class DemoApplication +} diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app16/DemoController.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app16/DemoController.kt new file mode 100644 index 000000000..95ad31811 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app16/DemoController.kt @@ -0,0 +1,50 @@ +package test.org.springdoc.api.v31.app16 + +import io.swagger.v3.oas.annotations.media.Schema +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/demo") +class DocumentsApiController { + + @GetMapping + suspend fun getDocuments( + request: DemoRequest + ): DemoDto = DemoDto(42) +} + +data class DemoDto( + var id: Long, +) + +/** +field following test cases: +``` +| case required | 11true | 10true | 01false | 00false | N1false | N0true | +| :-------------------- | :----- | :----- | :------ | :------ | :----- | :------ | +| schema required value | true | true | false | false | none | none | +| field nullable | true | false | true | false | true | false | +``` + */ +class DemoRequest( + + @field:Schema(required = true) + val field11true: String?, + + @field:Schema(required = true) + val field10true: String, + + @field:Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED) + val field01false: String?, + + @field:Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED) + val field00false: String, + + @field:Schema + val fieldN1false: String?, + + @field:Schema + val fieldN0true: String, +) diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app16/SpringDocApp16Test.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app16/SpringDocApp16Test.kt new file mode 100644 index 000000000..1215fe572 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app16/SpringDocApp16Test.kt @@ -0,0 +1,12 @@ +package test.org.springdoc.api.v31.app16 + +import org.springdoc.core.utils.Constants.SPRINGDOC_DEFAULT_FLAT_PARAM_OBJECT +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.test.context.TestPropertySource +import test.org.springdoc.api.v31.AbstractKotlinSpringDocMVCTest + +@TestPropertySource(properties = ["$SPRINGDOC_DEFAULT_FLAT_PARAM_OBJECT=true"]) +class SpringDocApp16Test : AbstractKotlinSpringDocMVCTest() { + @SpringBootApplication + class DemoApplication +} diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app17/DemoController.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app17/DemoController.kt new file mode 100644 index 000000000..ce8b21574 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app17/DemoController.kt @@ -0,0 +1,58 @@ +package test.org.springdoc.api.v31.app17 + +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.Size +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Validated +@RestController +@RequestMapping("/api/demo") +class DocumentsApiController { + + @Valid + @GetMapping + suspend fun validOverrideAndOrder( + @Valid request: ChildBar + ): DemoDto = DemoDto(42) +} + +data class DemoDto( + var id: Long, +) + +@Schema(description = "ParentFoo Request") +open class ParentFoo( + @field:NotEmpty // use flat-param-object. to remove or merge annotations by use DelegatingMethodParameterCustomizer + @field:Size(min = 10, max = 20) + @field:ArraySchema(schema = Schema(description = "wrong: list[minItems = 10, maxItems = 20] wrong: string[minLength = 30, maxLength = 40]")) + open var fooList: List<@Size(min = 30, max = 40) String> = listOf() +) + +@Schema(description = "ChildBar Request") +data class ChildBar( + @field:Size(min = 11, max = 21) // should override parent + @field:ArraySchema(schema = Schema(description = "expect: list[minItems = 11, maxItems = 21] expect: string[minLength = 31, maxLength = 41]")) + override var fooList: List<@Size(min = 31, max = 41) String>, + + @field:Size(min = 12, max = 22) // genericType should work + @field:ArraySchema(schema = Schema(description = "expect: list[minItems = 12, maxItems = 22] expect: string[minLength = 32, maxLength = 42]")) + var barList: List<@Size(min = 32, max = 42) String> = listOf(), + + @field:DecimalMin("1") // will be override by min + @field:Min(2) + @field:Schema(description = "expect: minimum = 2") + var validOrder1: Long, + + @field:Min(2) // will be override by DecimalMin + @field:DecimalMin("1") + @field:Schema(description = "expect: minimum = 1") + var validOrder2: Long, +) : ParentFoo() diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app17/SpringDocApp17Test.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app17/SpringDocApp17Test.kt new file mode 100644 index 000000000..b2a22101b --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app17/SpringDocApp17Test.kt @@ -0,0 +1,51 @@ +package test.org.springdoc.api.v31.app17 + +import jakarta.validation.constraints.NotEmpty +import org.junit.jupiter.api.Assertions +import org.springdoc.core.customizers.DelegatingMethodParameterCustomizer +import org.springdoc.core.extractor.DelegatingMethodParameter +import org.springdoc.core.utils.Constants.SPRINGDOC_DEFAULT_FLAT_PARAM_OBJECT +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.context.annotation.Bean +import org.springframework.core.MethodParameter +import org.springframework.test.context.TestPropertySource +import test.org.springdoc.api.v31.AbstractKotlinSpringDocMVCTest + +@TestPropertySource(properties = ["$SPRINGDOC_DEFAULT_FLAT_PARAM_OBJECT=true"]) +class SpringDocApp17Test : AbstractKotlinSpringDocMVCTest() { + @SpringBootApplication + class DemoApplication { + + @Bean + fun optionalKotlinDelegatingMethodParameterCustomizer(): DelegatingMethodParameterCustomizer { + return object : DelegatingMethodParameterCustomizer { + override fun customizeList( + originalParameter: MethodParameter, + methodParameters: MutableList + ) { + val fieldNameSet = mutableSetOf() + methodParameters.forEachIndexed { index, it -> + if (it is DelegatingMethodParameter && it.isParameterObject && it.field != null) { + val field = it.field!! + if (fieldNameSet.contains(field.name)) { + val fieldAnnotations = field.annotations + Assertions.assertTrue(fieldAnnotations.any { it is NotEmpty }) + // remove parent field + methodParameters.removeAt(index) + } else fieldNameSet.add(field.name) + } + } + if (methodParameters.size > 1) { + methodParameters.sortBy { it.parameterIndex } + } + } + + override fun customize( + originalParameter: MethodParameter, + methodParameter: MethodParameter + ) { + } + } + } + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app15.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app15.json new file mode 100644 index 000000000..84fea23c2 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app15.json @@ -0,0 +1,89 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/api/demo": { + "get": { + "tags": [ + "documents-api-controller" + ], + "operationId": "getDocuments", + "parameters": [ + { + "name": "request", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/DemoRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DemoDto" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "DemoRequest": { + "type": "object", + "properties": { + "field11true": { + "type": "string" + }, + "field10true": { + "type": "string" + }, + "field01false": { + "type": "string" + }, + "field00false": { + "type": "string" + }, + "fieldN1false": { + "type": "string" + }, + "fieldN0true": { + "type": "string" + } + }, + "required": [ + "field10true", + "field11true", + "fieldN0true" + ] + }, + "DemoDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "id" + ] + } + } + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app16.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app16.json new file mode 100644 index 000000000..e8bfb168d --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app16.json @@ -0,0 +1,101 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/api/demo": { + "get": { + "tags": [ + "documents-api-controller" + ], + "operationId": "getDocuments", + "parameters": [ + { + "name": "field11true", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "field10true", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "field01false", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "field00false", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "fieldN1false", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "fieldN0true", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DemoDto" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "DemoDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "id" + ] + } + } + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app17.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app17.json new file mode 100644 index 000000000..d7316b16a --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app17.json @@ -0,0 +1,109 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/api/demo": { + "get": { + "tags": [ + "documents-api-controller" + ], + "operationId": "validOverrideAndOrder", + "parameters": [ + { + "name": "fooList", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "description": "expect: list[minItems = 11, maxItems = 21] expect: string[minLength = 31, maxLength = 41]", + "maxLength": 41, + "minLength": 31 + }, + "maxItems": 21, + "minItems": 11 + } + }, + { + "name": "barList", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "description": "expect: list[minItems = 12, maxItems = 22] expect: string[minLength = 32, maxLength = 42]", + "maxLength": 42, + "minLength": 32 + }, + "maxItems": 22, + "minItems": 12 + } + }, + { + "name": "validOrder1", + "in": "query", + "description": "expect: minimum = 2", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "description": "expect: minimum = 2", + "minimum": 2 + } + }, + { + "name": "validOrder2", + "in": "query", + "description": "expect: minimum = 1", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "description": "expect: minimum = 1", + "minimum": 1.0 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DemoDto" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "DemoDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "id" + ] + } + } + } +} From 8f6b04968f378c14ec5c8c9a1ccb80ff2bb74763 Mon Sep 17 00:00:00 2001 From: m2 Date: Thu, 27 Mar 2025 16:39:12 +0800 Subject: [PATCH 5/6] add v30 test case --- .../springdoc/api/v30/app15/DemoController.kt | 2 +- .../api/v30/app15/SpringDocApp15Test.kt | 4 +- .../springdoc/api/v30/app16/DemoController.kt | 2 +- .../api/v30/app16/SpringDocApp16Test.kt | 4 +- .../springdoc/api/v30/app17/DemoController.kt | 2 +- .../api/v30/app17/SpringDocApp17Test.kt | 4 +- .../api/v31/app17/SpringDocApp17Test.kt | 2 +- .../test/resources/results/3.0.1/app15.json | 22 +++++----- .../test/resources/results/3.0.1/app16.json | 10 ++--- .../test/resources/results/3.0.1/app17.json | 44 ++++++++++--------- 10 files changed, 49 insertions(+), 47 deletions(-) diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app15/DemoController.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app15/DemoController.kt index 1d8c6be57..e4710c5c3 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app15/DemoController.kt +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app15/DemoController.kt @@ -1,4 +1,4 @@ -package test.org.springdoc.api.v31.app15 +package test.org.springdoc.api.v30.app15 import io.swagger.v3.oas.annotations.media.Schema import org.springframework.web.bind.annotation.GetMapping diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app15/SpringDocApp15Test.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app15/SpringDocApp15Test.kt index ae6b98f4b..147928b44 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app15/SpringDocApp15Test.kt +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app15/SpringDocApp15Test.kt @@ -1,7 +1,7 @@ -package test.org.springdoc.api.v31.app15 +package test.org.springdoc.api.v30.app15 import org.springframework.boot.autoconfigure.SpringBootApplication -import test.org.springdoc.api.v31.AbstractKotlinSpringDocMVCTest +import test.org.springdoc.api.v30.AbstractKotlinSpringDocMVCTest class SpringDocApp15Test : AbstractKotlinSpringDocMVCTest() { @SpringBootApplication diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app16/DemoController.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app16/DemoController.kt index 95ad31811..6519e1c6b 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app16/DemoController.kt +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app16/DemoController.kt @@ -1,4 +1,4 @@ -package test.org.springdoc.api.v31.app16 +package test.org.springdoc.api.v30.app16 import io.swagger.v3.oas.annotations.media.Schema import org.springframework.web.bind.annotation.GetMapping diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app16/SpringDocApp16Test.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app16/SpringDocApp16Test.kt index 1215fe572..ad74ff133 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app16/SpringDocApp16Test.kt +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app16/SpringDocApp16Test.kt @@ -1,9 +1,9 @@ -package test.org.springdoc.api.v31.app16 +package test.org.springdoc.api.v30.app16 import org.springdoc.core.utils.Constants.SPRINGDOC_DEFAULT_FLAT_PARAM_OBJECT import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.test.context.TestPropertySource -import test.org.springdoc.api.v31.AbstractKotlinSpringDocMVCTest +import test.org.springdoc.api.v30.AbstractKotlinSpringDocMVCTest @TestPropertySource(properties = ["$SPRINGDOC_DEFAULT_FLAT_PARAM_OBJECT=true"]) class SpringDocApp16Test : AbstractKotlinSpringDocMVCTest() { diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app17/DemoController.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app17/DemoController.kt index ce8b21574..3af377965 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app17/DemoController.kt +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app17/DemoController.kt @@ -1,4 +1,4 @@ -package test.org.springdoc.api.v31.app17 +package test.org.springdoc.api.v30.app17 import io.swagger.v3.oas.annotations.media.ArraySchema import io.swagger.v3.oas.annotations.media.Schema diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app17/SpringDocApp17Test.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app17/SpringDocApp17Test.kt index b2a22101b..7316e31e5 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app17/SpringDocApp17Test.kt +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app17/SpringDocApp17Test.kt @@ -1,4 +1,4 @@ -package test.org.springdoc.api.v31.app17 +package test.org.springdoc.api.v30.app17 import jakarta.validation.constraints.NotEmpty import org.junit.jupiter.api.Assertions @@ -9,7 +9,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.context.annotation.Bean import org.springframework.core.MethodParameter import org.springframework.test.context.TestPropertySource -import test.org.springdoc.api.v31.AbstractKotlinSpringDocMVCTest +import test.org.springdoc.api.v30.AbstractKotlinSpringDocMVCTest @TestPropertySource(properties = ["$SPRINGDOC_DEFAULT_FLAT_PARAM_OBJECT=true"]) class SpringDocApp17Test : AbstractKotlinSpringDocMVCTest() { diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app17/SpringDocApp17Test.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app17/SpringDocApp17Test.kt index b2a22101b..c0aef0f5a 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app17/SpringDocApp17Test.kt +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app17/SpringDocApp17Test.kt @@ -31,7 +31,7 @@ class SpringDocApp17Test : AbstractKotlinSpringDocMVCTest() { val fieldAnnotations = field.annotations Assertions.assertTrue(fieldAnnotations.any { it is NotEmpty }) // remove parent field - methodParameters.removeAt(index) + // methodParameters.removeAt(index) } else fieldNameSet.add(field.name) } } diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app15.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app15.json index 84fea23c2..e2035d0a9 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app15.json +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app15.json @@ -1,5 +1,5 @@ { - "openapi": "3.1.0", + "openapi": "3.0.1", "info": { "title": "OpenAPI definition", "version": "v0" @@ -45,6 +45,11 @@ "components": { "schemas": { "DemoRequest": { + "required": [ + "field10true", + "field11true", + "fieldN0true" + ], "type": "object", "properties": { "field11true": { @@ -65,24 +70,19 @@ "fieldN0true": { "type": "string" } - }, - "required": [ - "field10true", - "field11true", - "fieldN0true" - ] + } }, "DemoDto": { + "required": [ + "id" + ], "type": "object", "properties": { "id": { "type": "integer", "format": "int64" } - }, - "required": [ - "id" - ] + } } } } diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app16.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app16.json index e8bfb168d..b37922a5e 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app16.json +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app16.json @@ -1,5 +1,5 @@ { - "openapi": "3.1.0", + "openapi": "3.0.1", "info": { "title": "OpenAPI definition", "version": "v0" @@ -85,16 +85,16 @@ "components": { "schemas": { "DemoDto": { + "required": [ + "id" + ], "type": "object", "properties": { "id": { "type": "integer", "format": "int64" } - }, - "required": [ - "id" - ] + } } } } diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app17.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app17.json index d7316b16a..b545c6704 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app17.json +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app17.json @@ -1,5 +1,5 @@ { - "openapi": "3.1.0", + "openapi": "3.0.1", "info": { "title": "OpenAPI definition", "version": "v0" @@ -23,15 +23,15 @@ "in": "query", "required": true, "schema": { + "maxItems": 21, + "minItems": 11, "type": "array", "items": { - "type": "string", - "description": "expect: list[minItems = 11, maxItems = 21] expect: string[minLength = 31, maxLength = 41]", "maxLength": 41, - "minLength": 31 - }, - "maxItems": 21, - "minItems": 11 + "minLength": 31, + "type": "string", + "description": "expect: list[minItems = 11, maxItems = 21] expect: string[minLength = 31, maxLength = 41]" + } } }, { @@ -39,15 +39,15 @@ "in": "query", "required": true, "schema": { + "maxItems": 22, + "minItems": 12, "type": "array", "items": { - "type": "string", - "description": "expect: list[minItems = 12, maxItems = 22] expect: string[minLength = 32, maxLength = 42]", "maxLength": 42, - "minLength": 32 - }, - "maxItems": 22, - "minItems": 12 + "minLength": 32, + "type": "string", + "description": "expect: list[minItems = 12, maxItems = 22] expect: string[minLength = 32, maxLength = 42]" + } } }, { @@ -56,10 +56,11 @@ "description": "expect: minimum = 2", "required": true, "schema": { + "minimum": 2, + "exclusiveMinimum": false, "type": "integer", - "format": "int64", "description": "expect: minimum = 2", - "minimum": 2 + "format": "int64" } }, { @@ -68,10 +69,11 @@ "description": "expect: minimum = 1", "required": true, "schema": { + "minimum": 1.0, + "exclusiveMinimum": false, "type": "integer", - "format": "int64", "description": "expect: minimum = 1", - "minimum": 1.0 + "format": "int64" } } ], @@ -93,16 +95,16 @@ "components": { "schemas": { "DemoDto": { + "required": [ + "id" + ], "type": "object", "properties": { "id": { "type": "integer", "format": "int64" } - }, - "required": [ - "id" - ] + } } } } From 12dbff1133b1dc13830d243e5eb90e5fd962ab80 Mon Sep 17 00:00:00 2001 From: m2 Date: Thu, 27 Mar 2025 16:51:29 +0800 Subject: [PATCH 6/6] add v30 test case --- .../test/org/springdoc/api/v31/app17/SpringDocApp17Test.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app17/SpringDocApp17Test.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app17/SpringDocApp17Test.kt index c0aef0f5a..b2a22101b 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app17/SpringDocApp17Test.kt +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app17/SpringDocApp17Test.kt @@ -31,7 +31,7 @@ class SpringDocApp17Test : AbstractKotlinSpringDocMVCTest() { val fieldAnnotations = field.annotations Assertions.assertTrue(fieldAnnotations.any { it is NotEmpty }) // remove parent field - // methodParameters.removeAt(index) + methodParameters.removeAt(index) } else fieldNameSet.add(field.name) } }