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..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 @@ -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. + * @author dyun */ @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 + */ + @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..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 @@ -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); } } @@ -617,19 +614,30 @@ 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 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 +660,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 +693,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 +720,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 +799,13 @@ 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)} + * @param annotationSimpleNames the annotation simple names + * @return boolean */ + @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..4dccb8c4f --- /dev/null +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java @@ -0,0 +1,227 @@ +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 { + + /** + * 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); + } + } + } + +} 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." } } } 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/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..e4710c5c3 --- /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.v30.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..147928b44 --- /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.v30.app15 + +import org.springframework.boot.autoconfigure.SpringBootApplication +import test.org.springdoc.api.v30.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..6519e1c6b --- /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.v30.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..ad74ff133 --- /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.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.v30.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..3af377965 --- /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.v30.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..7316e31e5 --- /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.v30.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.v30.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/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.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..e2035d0a9 --- /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.0.1", + "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": { + "required": [ + "field10true", + "field11true", + "fieldN0true" + ], + "type": "object", + "properties": { + "field11true": { + "type": "string" + }, + "field10true": { + "type": "string" + }, + "field01false": { + "type": "string" + }, + "field00false": { + "type": "string" + }, + "fieldN1false": { + "type": "string" + }, + "fieldN0true": { + "type": "string" + } + } + }, + "DemoDto": { + "required": [ + "id" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + } + } + } + } + } +} 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..b37922a5e --- /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.0.1", + "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": { + "required": [ + "id" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + } + } + } + } + } +} 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..b545c6704 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app17.json @@ -0,0 +1,111 @@ +{ + "openapi": "3.0.1", + "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": { + "maxItems": 21, + "minItems": 11, + "type": "array", + "items": { + "maxLength": 41, + "minLength": 31, + "type": "string", + "description": "expect: list[minItems = 11, maxItems = 21] expect: string[minLength = 31, maxLength = 41]" + } + } + }, + { + "name": "barList", + "in": "query", + "required": true, + "schema": { + "maxItems": 22, + "minItems": 12, + "type": "array", + "items": { + "maxLength": 42, + "minLength": 32, + "type": "string", + "description": "expect: list[minItems = 12, maxItems = 22] expect: string[minLength = 32, maxLength = 42]" + } + } + }, + { + "name": "validOrder1", + "in": "query", + "description": "expect: minimum = 2", + "required": true, + "schema": { + "minimum": 2, + "exclusiveMinimum": false, + "type": "integer", + "description": "expect: minimum = 2", + "format": "int64" + } + }, + { + "name": "validOrder2", + "in": "query", + "description": "expect: minimum = 1", + "required": true, + "schema": { + "minimum": 1.0, + "exclusiveMinimum": false, + "type": "integer", + "description": "expect: minimum = 1", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DemoDto" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "DemoDto": { + "required": [ + "id" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + } + } + } + } + } +} 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" + ] + } + } + } +}