diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocConfiguration.java index 17c977e52..17426763f 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocConfiguration.java @@ -65,6 +65,7 @@ import org.springdoc.core.customizers.DelegatingMethodParameterCustomizer; import org.springdoc.core.customizers.GlobalOpenApiCustomizer; import org.springdoc.core.customizers.GlobalOperationCustomizer; +import org.springdoc.core.customizers.JavaNullablePropertyCustomizer; import org.springdoc.core.customizers.OpenApiBuilderCustomizer; import org.springdoc.core.customizers.OpenApiCustomizer; import org.springdoc.core.customizers.OperationCustomizer; @@ -138,6 +139,7 @@ import static org.springdoc.core.utils.Constants.SPRINGDOC_ENABLED; import static org.springdoc.core.utils.Constants.SPRINGDOC_ENABLE_EXTRA_SCHEMAS; import static org.springdoc.core.utils.Constants.SPRINGDOC_EXPLICIT_OBJECT_SCHEMA; +import static org.springdoc.core.utils.Constants.SPRINGDOC_JAVA_NULLABLE_PROPERTY_CUSTOMIZER_ENABLED; import static org.springdoc.core.utils.Constants.SPRINGDOC_POLYMORPHIC_CONVERTER_ENABLED; import static org.springdoc.core.utils.Constants.SPRINGDOC_SCHEMA_RESOLVE_PROPERTIES; import static org.springdoc.core.utils.Constants.SPRINGDOC_SHOW_ACTUATOR; @@ -307,6 +309,20 @@ PolymorphicModelConverter polymorphicModelConverter(ObjectMapperProvider objectM return new PolymorphicModelConverter(objectMapperProvider); } + /** + * Java nullable property customizer model converter. + * + * @param objectMapperProvider the object mapper provider + * @return the Java nullable property customizer model converter + */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = SPRINGDOC_JAVA_NULLABLE_PROPERTY_CUSTOMIZER_ENABLED, havingValue = "true") + @Lazy(false) + JavaNullablePropertyCustomizer javaNullablePropertyCustomizer(ObjectMapperProvider objectMapperProvider) { + return new JavaNullablePropertyCustomizer(objectMapperProvider); + } + /** * Open api builder open api builder. * diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/JavaNullablePropertyCustomizer.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/JavaNullablePropertyCustomizer.java new file mode 100644 index 000000000..3b13a64bc --- /dev/null +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/JavaNullablePropertyCustomizer.java @@ -0,0 +1,345 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 the original author or authors. + * * * * * * + * * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * * you may not use this file except in compliance with the License. + * * * * * * You may obtain a copy of the License at + * * * * * * + * * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * * + * * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ +package org.springdoc.core.customizers; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Executable; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.introspect.AnnotatedField; +import com.fasterxml.jackson.databind.introspect.AnnotatedMethod; +import com.fasterxml.jackson.databind.introspect.AnnotatedParameter; +import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; +import io.swagger.v3.core.converter.AnnotatedType; +import io.swagger.v3.core.converter.ModelConverter; +import io.swagger.v3.core.converter.ModelConverterContext; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.SpecVersion; +import io.swagger.v3.oas.models.media.Schema; +import org.springdoc.core.providers.ObjectMapperProvider; + +import static org.springdoc.core.utils.SchemaUtils.ANNOTATIONS_FOR_NULLABLE; + +/** + * Marks schema properties as nullable for Java class properties that are explicitly + * annotated with a {@code @Nullable} annotation (e.g. JSpecify, Spring, JSR-305), either on + * the field, its getter / record accessor, or its constructor parameter. Both declaration + * and type-use annotations are supported. + *

+ * Handles both OAS 3.0 and OAS 3.1 nullable semantics: + *

+ * + * @author Mattias-Sehlstedt + */ +public class JavaNullablePropertyCustomizer implements ModelConverter { + + /** + * The constant NULL_TYPE. + */ + private static final String NULL_TYPE = "null"; + + /** + * The Object mapper provider. + */ + private final ObjectMapperProvider objectMapperProvider; + + /** + * Instantiates a new Java nullable property customizer. + * + * @param objectMapperProvider the object mapper provider + */ + public JavaNullablePropertyCustomizer(ObjectMapperProvider objectMapperProvider) { + this.objectMapperProvider = objectMapperProvider; + } + + @Override + public Schema resolve(AnnotatedType type, ModelConverterContext context, Iterator chain) { + if (!chain.hasNext()) + return null; + Schema resolvedSchema = chain.next().resolve(type, context, chain); + + ObjectMapper mapper = objectMapperProvider.jsonMapper(); + JavaType javaType = mapper.constructType(type.getType()); + Class rawClass = javaType.getRawClass(); + if (rawClass == null || rawClass.isPrimitive() || rawClass.isArray() + || rawClass.getPackageName().startsWith("java.")) + return resolvedSchema; + + Schema targetSchema = resolveTargetSchema(resolvedSchema, javaType, context); + if (targetSchema == null || targetSchema.getProperties() == null) + return resolvedSchema; + + Set nullableProperties = findNullableProperties(mapper, javaType); + if (nullableProperties.isEmpty()) + return resolvedSchema; + + SpecVersion specVersion = targetSchema.getSpecVersion() != null ? targetSchema.getSpecVersion() : SpecVersion.V30; + Map properties = targetSchema.getProperties(); + Map> replacements = new LinkedHashMap<>(); + for (String propertyName : nullableProperties) { + Schema property = properties.get(propertyName); + if (property == null) + continue; + if (property.get$ref() != null) + replacements.put(propertyName, wrapRefNullable(property, specVersion)); + else + markNullable(property, specVersion); + } + replacements.forEach(properties::put); + + return resolvedSchema; + } + + /** + * Resolves the schema that actually carries the model's properties. + * When the resolved schema is a {@code $ref}, the target lives in the defined models. When it is + * a composed/polymorphic wrapper without direct properties, the named model is looked up by name. + * + * @param resolvedSchema the resolved schema + * @param javaType the java type + * @param context the context + * @return the target schema + */ + private Schema resolveTargetSchema(Schema resolvedSchema, JavaType javaType, ModelConverterContext context) { + Map definedModels = context.getDefinedModels(); + if (resolvedSchema != null && resolvedSchema.get$ref() != null) + return definedModels.get(resolvedSchema.get$ref().substring(Components.COMPONENTS_SCHEMAS_REF.length())); + if (resolvedSchema != null && resolvedSchema.getProperties() != null) + return resolvedSchema; + Schema schema = definedModels.get(javaType.getRawClass().getName()); + return schema != null ? schema : definedModels.get(javaType.getRawClass().getSimpleName()); + } + + /** + * Finds the serialized names of the properties explicitly annotated as nullable. + * + * @param mapper the mapper + * @param javaType the java type + * @return the set of nullable property names + */ + private Set findNullableProperties(ObjectMapper mapper, JavaType javaType) { + List definitions; + try { + BeanDescription beanDescription = mapper.getSerializationConfig().introspect(javaType); + definitions = beanDescription.findProperties(); + } + catch (Exception e) { + return Collections.emptySet(); + } + Set result = new LinkedHashSet<>(); + for (BeanPropertyDefinition definition : definitions) { + if (isNullable(definition)) + result.add(definition.getName()); + } + return result; + } + + /** + * Whether the given property is explicitly annotated as nullable. + * + * @param definition the property definition + * @return the boolean + */ + private boolean isNullable(BeanPropertyDefinition definition) { + AnnotatedField annotatedField = definition.getField(); + if (annotatedField != null) { + Field field = annotatedField.getAnnotated(); + if (field.getType().isPrimitive()) + return false; + if (hasNullableAnnotation(field) || hasNullableAnnotation(field.getAnnotatedType())) + return true; + } + AnnotatedMethod getter = definition.getGetter(); + if (getter != null) { + Method method = getter.getAnnotated(); + if (method.getReturnType().isPrimitive()) + return false; + if (hasNullableAnnotation(method) || hasNullableAnnotation(method.getAnnotatedReturnType())) + return true; + } + AnnotatedParameter ctorParameter = definition.getConstructorParameter(); + if (ctorParameter != null) { + Parameter parameter = toReflectParameter(ctorParameter); + return parameter != null && !parameter.getType().isPrimitive() + && (hasNullableAnnotation(parameter) || hasNullableAnnotation(parameter.getAnnotatedType())); + } + return false; + } + + /** + * Converts a Jackson annotated parameter to a reflection parameter. + * + * @param annotatedParameter the annotated parameter + * @return the parameter or {@code null} + */ + private Parameter toReflectParameter(AnnotatedParameter annotatedParameter) { + try { + AnnotatedElement owner = annotatedParameter.getOwner().getAnnotated(); + if (owner instanceof Executable executable) { + Parameter[] parameters = executable.getParameters(); + int index = annotatedParameter.getIndex(); + if (index >= 0 && index < parameters.length) + return parameters[index]; + } + } + catch (Exception ignored) { + // best-effort only + } + return null; + } + + /** + * Whether the element carries a {@code @Nullable} annotation. + * + * @param element the element + * @return the boolean + */ + private boolean hasNullableAnnotation(AnnotatedElement element) { + if (element == null) + return false; + for (Annotation annotation : element.getAnnotations()) { + if (ANNOTATIONS_FOR_NULLABLE.contains(annotation.annotationType().getSimpleName())) + return true; + } + return false; + } + + /** + * Marks a non-$ref property as nullable. + * - OAS 3.0: {@code nullable: true} + * - OAS 3.1: adds {@code "null"} to the {@code types} set, or, for {@code oneOf} schemas, + * appends a {@code { type: "null" }} alternative + *

+ * A schema without any type constraint already permits {@code null} and is left untouched. + * + * @param property the property + * @param specVersion the spec version + */ + private void markNullable(Schema property, SpecVersion specVersion) { + boolean hasOneOf = property.getOneOf() != null && !property.getOneOf().isEmpty(); + if (!hasOneOf && isAnySchema(property)) + return; + if (specVersion == SpecVersion.V31) { + if (hasOneOf) { + boolean hasNullBranch = property.getOneOf().stream().anyMatch(s -> NULL_TYPE.equals(s.getType()) + || (s.getTypes() != null && s.getTypes().contains(NULL_TYPE))); + if (!hasNullBranch) { + List oneOf = new java.util.ArrayList<>(property.getOneOf()); + Schema nullSchema = new Schema<>(); + nullSchema.addType(NULL_TYPE); + oneOf.add(nullSchema); + property.setOneOf(oneOf); + } + return; + } + Set currentTypes = new LinkedHashSet<>(); + if (property.getTypes() != null) + currentTypes.addAll(property.getTypes()); + else if (property.getType() != null) + currentTypes.add(property.getType()); + if (!currentTypes.contains(NULL_TYPE)) { + currentTypes.add(NULL_TYPE); + property.setTypes(currentTypes); + } + } + else { + property.setNullable(true); + } + } + + /** + * Returns true when the schema imposes no type constraint. + * + * @param property the property + * @return the boolean + */ + private boolean isAnySchema(Schema property) { + return property.get$ref() == null + && property.getType() == null + && (property.getTypes() == null || property.getTypes().isEmpty()); + } + + /** + * Wraps a $ref property in a nullable composite schema. Sibling metadata is copied over. + * - OAS 3.0: {@code { nullable: true, allOf: [{ $ref: "..." }] }} + * - OAS 3.1: {@code { oneOf: [{ $ref: "..." }, { type: "null" }] }} + * + * @param property the property + * @param specVersion the spec version + * @return the wrapper schema + */ + private Schema wrapRefNullable(Schema property, SpecVersion specVersion) { + Schema refSchema = new Schema<>(); + refSchema.set$ref(property.get$ref()); + Schema wrapper = new Schema<>(); + copySiblingMetadata(property, wrapper); + if (specVersion == SpecVersion.V31) { + Schema nullSchema = new Schema<>(); + nullSchema.addType(NULL_TYPE); + wrapper.setOneOf(List.of(refSchema, nullSchema)); + } + else { + wrapper.setNullable(true); + wrapper.setAllOf(List.of(refSchema)); + } + return wrapper; + } + + /** + * Copies the attributes swagger-core may set as siblings of a $ref property onto the wrapper. + * + * @param source the source + * @param target the target + */ + private void copySiblingMetadata(Schema source, Schema target) { + if (source.getDescription() != null) target.setDescription(source.getDescription()); + if (source.getTitle() != null) target.setTitle(source.getTitle()); + if (source.getDeprecated() != null) target.setDeprecated(source.getDeprecated()); + if (source.getExampleSetFlag()) target.setExample(source.getExample()); + if (source.getExternalDocs() != null) target.setExternalDocs(source.getExternalDocs()); + if (source.getReadOnly() != null) target.setReadOnly(source.getReadOnly()); + if (source.getWriteOnly() != null) target.setWriteOnly(source.getWriteOnly()); + if (source.getExtensions() != null) target.setExtensions(source.getExtensions()); + } +} \ No newline at end of file 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 0c9fa3561..7f43d778f 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 @@ -106,6 +106,11 @@ public final class Constants { */ public static final String SPRINGDOC_POLYMORPHIC_CONVERTER_ENABLED = "springdoc.model-converters.polymorphic-converter.enabled"; + /** + * The constant SPRINGDOC_JAVA_NULLABLE_PROPERTY_CUSTOMIZER_ENABLED. + */ + public static final String SPRINGDOC_JAVA_NULLABLE_PROPERTY_CUSTOMIZER_ENABLED = "springdoc.model-converters.java-nullable-property-customizer.enabled"; + /** * The constant SPRINGDOC_KOTLIN_NULLABLE_PROPERTY_CUSTOMIZER_ENABLED. */ @@ -442,6 +447,11 @@ public final class Constants { */ public static final String GLOBAL_OPEN_API_CUSTOMIZER = "globalOpenApiCustomizer"; + /** + * The constant JAVA_NULLABLE_PROPERTY_CUSTOMIZER. + */ + public static final String JAVA_NULLABLE_PROPERTY_CUSTOMIZER = "javaNullablePropertyCustomizer"; + /** * The constant SPRINGDOC_SORT_CONVERTER_ENABLED. */ diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app273/NullableController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app273/NullableController.java new file mode 100644 index 000000000..6abbdf1fc --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app273/NullableController.java @@ -0,0 +1,57 @@ +/* + * Copyright 2019-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v30.app273; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class NullableController { + + @GetMapping("/nullable") + public NullableFieldsResponse getNullableFields() { + return new NullableFieldsResponse("hello", null, null, null, null, null, null, null); + } + + public record NullableFieldsResponse( + String requiredField, + @Nullable String nullableString, + @Nullable Integer nullableInt, + @Nullable Object nullableAny, + @Schema(description = "The nullable nested object") @Nullable NestedObject nullableNested, + @Schema(description = "The nested object") NestedObject nested, + @Schema(description = "The nullable composed object") @Nullable ComposedObject nullableComposed, + @Schema(description = "The composed object") ComposedObject composed) { + } + + public record NestedObject(String name, @Nullable String description) { + } + + public sealed interface ComposedObject { + + record First(String first) implements ComposedObject { + } + + record Second(String second) implements ComposedObject { + } + + } +} + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app273/SpringDocApp273Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app273/SpringDocApp273Test.java new file mode 100644 index 000000000..abebfd126 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app273/SpringDocApp273Test.java @@ -0,0 +1,54 @@ +/* + * Copyright 2019-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v30.app273; + +import io.swagger.v3.core.converter.ModelConverters; +import org.junit.jupiter.api.AfterAll; +import org.springdoc.core.customizers.JavaNullablePropertyCustomizer; +import org.springdoc.core.utils.Constants; +import test.org.springdoc.api.v30.AbstractSpringDocV30Test; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.test.context.TestPropertySource; + +/** + * When the {@link org.springdoc.core.customizers.JavaNullablePropertyCustomizer} is enabled (opt-in), + * properties annotated with {@code @Nullable} are marked nullable. + * + * @author Mattias-Sehlstedt + */ +@TestPropertySource(properties = Constants.SPRINGDOC_JAVA_NULLABLE_PROPERTY_CUSTOMIZER_ENABLED + "=true") +public class SpringDocApp273Test extends AbstractSpringDocV30Test { + + /** + * The converter is registered in the JVM-wide {@link ModelConverters} singleton, + * so it must be removed to avoid leaking into other tests. + */ + @AfterAll + static void removeJavaNullablePropertyCustomizer() { + ModelConverters instance = ModelConverters.getInstance(false); + instance.getConverters().stream() + .filter(JavaNullablePropertyCustomizer.class::isInstance) + .toList() + .forEach(instance::removeConverter); + } + + @SpringBootApplication + static class SpringDocTestApp { + } +} + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app274/NullableController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app274/NullableController.java new file mode 100644 index 000000000..4524baa9c --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app274/NullableController.java @@ -0,0 +1,57 @@ +/* + * Copyright 2019-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v30.app274; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class NullableController { + + @GetMapping("/nullable") + public NullableFieldsResponse getNullableFields() { + return new NullableFieldsResponse("hello", null, null, null, null, null, null, null); + } + + public record NullableFieldsResponse( + String requiredField, + @Nullable String nullableString, + @Nullable Integer nullableInt, + @Nullable Object nullableAny, + @Schema(description = "The nullable nested object") @Nullable NestedObject nullableNested, + @Schema(description = "The nested object") NestedObject nested, + @Schema(description = "The nullable composed object") @Nullable ComposedObject nullableComposed, + @Schema(description = "The composed object") ComposedObject composed) { + } + + public record NestedObject(String name, @Nullable String description) { + } + + public sealed interface ComposedObject { + + record First(String first) implements ComposedObject { + } + + record Second(String second) implements ComposedObject { + } + + } +} + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app274/SpringDocApp274Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app274/SpringDocApp274Test.java new file mode 100644 index 000000000..50e3538a7 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app274/SpringDocApp274Test.java @@ -0,0 +1,36 @@ +/* + * Copyright 2019-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v30.app274; + +import org.springdoc.core.customizers.JavaNullablePropertyCustomizer; +import test.org.springdoc.api.v30.AbstractSpringDocV30Test; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * The {@link JavaNullablePropertyCustomizer} is opt-in, so by default {@code @Nullable} properties + * are not marked nullable. + * + * @author Mattias-Sehlstedt + */ +public class SpringDocApp274Test extends AbstractSpringDocV30Test { + + @SpringBootApplication + static class SpringDocTestApp { + } +} + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app273/NullableController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app273/NullableController.java new file mode 100644 index 000000000..eb770ab62 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app273/NullableController.java @@ -0,0 +1,57 @@ +/* + * Copyright 2019-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v31.app273; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class NullableController { + + @GetMapping("/nullable") + public NullableFieldsResponse getNullableFields() { + return new NullableFieldsResponse("hello", null, null, null, null, null, null, null); + } + + public record NullableFieldsResponse( + String requiredField, + @Nullable String nullableString, + @Nullable Integer nullableInt, + @Nullable Object nullableAny, + @Schema(description = "The nullable nested object") @Nullable NestedObject nullableNested, + @Schema(description = "The nested object") NestedObject nested, + @Schema(description = "The nullable composed object") @Nullable ComposedObject nullableComposed, + @Schema(description = "The composed object") ComposedObject composed) { + } + + public record NestedObject(String name, @Nullable String description) { + } + + public sealed interface ComposedObject { + + record First(String first) implements ComposedObject { + } + + record Second(String second) implements ComposedObject { + } + + } +} + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app273/SpringDocApp273Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app273/SpringDocApp273Test.java new file mode 100644 index 000000000..6edcc33e0 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app273/SpringDocApp273Test.java @@ -0,0 +1,54 @@ +/* + * Copyright 2019-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v31.app273; + +import io.swagger.v3.core.converter.ModelConverters; +import org.junit.jupiter.api.AfterAll; +import org.springdoc.core.customizers.JavaNullablePropertyCustomizer; +import org.springdoc.core.utils.Constants; +import test.org.springdoc.api.v31.AbstractSpringDocV31Test; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.test.context.TestPropertySource; + +/** + * When the {@link org.springdoc.core.customizers.JavaNullablePropertyCustomizer} is enabled (opt-in), + * properties annotated with {@code @Nullable} are marked nullable. + * + * @author Mattias-Sehlstedt + */ +@TestPropertySource(properties = Constants.SPRINGDOC_JAVA_NULLABLE_PROPERTY_CUSTOMIZER_ENABLED + "=true") +public class SpringDocApp273Test extends AbstractSpringDocV31Test { + + /** + * The converter is registered in the JVM-wide {@link ModelConverters} singleton, + * so it must be removed to avoid leaking into other tests. + */ + @AfterAll + static void removeJavaNullablePropertyCustomizer() { + ModelConverters instance = ModelConverters.getInstance(true); + instance.getConverters().stream() + .filter(JavaNullablePropertyCustomizer.class::isInstance) + .toList() + .forEach(instance::removeConverter); + } + + @SpringBootApplication + static class SpringDocTestApp { + } +} + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app274/NullableController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app274/NullableController.java new file mode 100644 index 000000000..26a97ce37 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app274/NullableController.java @@ -0,0 +1,57 @@ +/* + * Copyright 2019-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v31.app274; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class NullableController { + + @GetMapping("/nullable") + public NullableFieldsResponse getNullableFields() { + return new NullableFieldsResponse("hello", null, null, null, null, null, null, null); + } + + public record NullableFieldsResponse( + String requiredField, + @Nullable String nullableString, + @Nullable Integer nullableInt, + @Nullable Object nullableAny, + @Schema(description = "The nullable nested object") @Nullable NestedObject nullableNested, + @Schema(description = "The nested object") NestedObject nested, + @Schema(description = "The nullable composed object") @Nullable ComposedObject nullableComposed, + @Schema(description = "The composed object") ComposedObject composed) { + } + + public record NestedObject(String name, @Nullable String description) { + } + + public sealed interface ComposedObject { + + record First(String first) implements ComposedObject { + } + + record Second(String second) implements ComposedObject { + } + + } +} + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app274/SpringDocApp274Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app274/SpringDocApp274Test.java new file mode 100644 index 000000000..50104092e --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app274/SpringDocApp274Test.java @@ -0,0 +1,36 @@ +/* + * Copyright 2019-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v31.app274; + +import org.springdoc.core.customizers.JavaNullablePropertyCustomizer; +import test.org.springdoc.api.v31.AbstractSpringDocV31Test; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * The {@link JavaNullablePropertyCustomizer} is opt-in, so by default {@code @Nullable} properties + * are not marked nullable. + * + * @author Mattias-Sehlstedt + */ +public class SpringDocApp274Test extends AbstractSpringDocV31Test { + + @SpringBootApplication + static class SpringDocTestApp { + } +} + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app273.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app273.json new file mode 100644 index 000000000..ff12b4073 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app273.json @@ -0,0 +1,141 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/nullable": { + "get": { + "tags": [ + "nullable-controller" + ], + "operationId": "getNullableFields", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/NullableFieldsResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ComposedObject": { + "type": "object", + "description": "The composed object" + }, + "First": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/ComposedObject" + }, + { + "type": "object", + "properties": { + "first": { + "type": "string" + } + } + } + ] + }, + "NestedObject": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + } + }, + "description": "The nested object" + }, + "NullableFieldsResponse": { + "type": "object", + "properties": { + "requiredField": { + "type": "string" + }, + "nullableString": { + "type": "string", + "nullable": true + }, + "nullableInt": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "nullableAny": { + "type": "object", + "nullable": true + }, + "nullableNested": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/NestedObject" + } + ] + }, + "nested": { + "$ref": "#/components/schemas/NestedObject" + }, + "nullableComposed": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/First" + }, + { + "$ref": "#/components/schemas/Second" + } + ] + }, + "composed": { + "oneOf": [ + { + "$ref": "#/components/schemas/First" + }, + { + "$ref": "#/components/schemas/Second" + } + ] + } + } + }, + "Second": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/ComposedObject" + }, + { + "type": "object", + "properties": { + "second": { + "type": "string" + } + } + } + ] + } + } + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app274.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app274.json new file mode 100644 index 000000000..136c40453 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app274.json @@ -0,0 +1,131 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/nullable": { + "get": { + "tags": [ + "nullable-controller" + ], + "operationId": "getNullableFields", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/NullableFieldsResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ComposedObject": { + "type": "object", + "description": "The composed object" + }, + "First": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/ComposedObject" + }, + { + "type": "object", + "properties": { + "first": { + "type": "string" + } + } + } + ] + }, + "NestedObject": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "description": "The nested object" + }, + "NullableFieldsResponse": { + "type": "object", + "properties": { + "requiredField": { + "type": "string" + }, + "nullableString": { + "type": "string" + }, + "nullableInt": { + "type": "integer", + "format": "int32" + }, + "nullableAny": { + "type": "object" + }, + "nullableNested": { + "$ref": "#/components/schemas/NestedObject" + }, + "nested": { + "$ref": "#/components/schemas/NestedObject" + }, + "nullableComposed": { + "oneOf": [ + { + "$ref": "#/components/schemas/First" + }, + { + "$ref": "#/components/schemas/Second" + } + ] + }, + "composed": { + "oneOf": [ + { + "$ref": "#/components/schemas/First" + }, + { + "$ref": "#/components/schemas/Second" + } + ] + } + } + }, + "Second": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/ComposedObject" + }, + { + "type": "object", + "properties": { + "second": { + "type": "string" + } + } + } + ] + } + } + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app273.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app273.json new file mode 100644 index 000000000..81deefef5 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app273.json @@ -0,0 +1,146 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/nullable": { + "get": { + "tags": [ + "nullable-controller" + ], + "operationId": "getNullableFields", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/NullableFieldsResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ComposedObject": {}, + "First": { + "allOf": [ + { + "$ref": "#/components/schemas/ComposedObject" + }, + { + "type": "object", + "properties": { + "first": { + "type": "string" + } + } + } + ] + }, + "NestedObject": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + } + } + }, + "NullableFieldsResponse": { + "type": "object", + "properties": { + "requiredField": { + "type": "string" + }, + "nullableString": { + "type": [ + "string", + "null" + ] + }, + "nullableInt": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "nullableAny": {}, + "nullableNested": { + "description": "The nullable nested object", + "oneOf": [ + { + "$ref": "#/components/schemas/NestedObject" + }, + { + "type": "null" + } + ] + }, + "nested": { + "$ref": "#/components/schemas/NestedObject", + "description": "The nested object" + }, + "nullableComposed": { + "description": "The nullable composed object", + "oneOf": [ + { + "$ref": "#/components/schemas/First" + }, + { + "$ref": "#/components/schemas/Second" + }, + { + "type": "null" + } + ] + }, + "composed": { + "description": "The composed object", + "oneOf": [ + { + "$ref": "#/components/schemas/First" + }, + { + "$ref": "#/components/schemas/Second" + } + ] + } + } + }, + "Second": { + "allOf": [ + { + "$ref": "#/components/schemas/ComposedObject" + }, + { + "type": "object", + "properties": { + "second": { + "type": "string" + } + } + } + ] + } + } + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app274.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app274.json new file mode 100644 index 000000000..1053ee7ee --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app274.json @@ -0,0 +1,127 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/nullable": { + "get": { + "tags": [ + "nullable-controller" + ], + "operationId": "getNullableFields", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/NullableFieldsResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ComposedObject": {}, + "First": { + "allOf": [ + { + "$ref": "#/components/schemas/ComposedObject" + }, + { + "type": "object", + "properties": { + "first": { + "type": "string" + } + } + } + ] + }, + "NestedObject": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "NullableFieldsResponse": { + "type": "object", + "properties": { + "requiredField": { + "type": "string" + }, + "nullableString": { + "type": "string" + }, + "nullableInt": { + "type": "integer", + "format": "int32" + }, + "nullableAny": {}, + "nullableNested": { + "$ref": "#/components/schemas/NestedObject", + "description": "The nullable nested object" + }, + "nested": { + "$ref": "#/components/schemas/NestedObject", + "description": "The nested object" + }, + "nullableComposed": { + "description": "The nullable composed object", + "oneOf": [ + { + "$ref": "#/components/schemas/First" + }, + { + "$ref": "#/components/schemas/Second" + } + ] + }, + "composed": { + "description": "The composed object", + "oneOf": [ + { + "$ref": "#/components/schemas/First" + }, + { + "$ref": "#/components/schemas/Second" + } + ] + } + } + }, + "Second": { + "allOf": [ + { + "$ref": "#/components/schemas/ComposedObject" + }, + { + "type": "object", + "properties": { + "second": { + "type": "string" + } + } + } + ] + } + } + } +}