From 1532763f9a77035d0d9b67c8363cbf193abce1b4 Mon Sep 17 00:00:00 2001
From: Mattias-Sehlstedt <60173714+Mattias-Sehlstedt@users.noreply.github.com>
Date: Thu, 8 Oct 2026 17:37:39 +0200
Subject: [PATCH] feat: add JavaNullablePropertyCustomizer to handle nullable
properties in OpenAPI schemas
---
.../configuration/SpringDocConfiguration.java | 16 +
.../JavaNullablePropertyCustomizer.java | 345 ++++++++++++++++++
.../org/springdoc/core/utils/Constants.java | 10 +
.../api/v30/app273/NullableController.java | 57 +++
.../api/v30/app273/SpringDocApp273Test.java | 54 +++
.../api/v30/app274/NullableController.java | 57 +++
.../api/v30/app274/SpringDocApp274Test.java | 36 ++
.../api/v31/app273/NullableController.java | 57 +++
.../api/v31/app273/SpringDocApp273Test.java | 54 +++
.../api/v31/app274/NullableController.java | 57 +++
.../api/v31/app274/SpringDocApp274Test.java | 36 ++
.../test/resources/results/3.0.1/app273.json | 141 +++++++
.../test/resources/results/3.0.1/app274.json | 131 +++++++
.../test/resources/results/3.1.0/app273.json | 146 ++++++++
.../test/resources/results/3.1.0/app274.json | 127 +++++++
15 files changed, 1324 insertions(+)
create mode 100644 springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/JavaNullablePropertyCustomizer.java
create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app273/NullableController.java
create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app273/SpringDocApp273Test.java
create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app274/NullableController.java
create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app274/SpringDocApp274Test.java
create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app273/NullableController.java
create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app273/SpringDocApp273Test.java
create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app274/NullableController.java
create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app274/SpringDocApp274Test.java
create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app273.json
create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app274.json
create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app273.json
create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app274.json
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:
+ *
+ *
OAS 3.0: Sets {@code nullable: true} on the property. For {@code $ref} properties,
+ * wraps in {@code allOf} since {@code $ref} and {@code nullable} are mutually exclusive.
+ *
OAS 3.1: Adds {@code "null"} to the {@code type} array. For {@code $ref} properties,
+ * wraps in {@code oneOf} with a {@code type: "null"} alternative.
+ *
+ *
+ * @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