Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
import org.springdoc.core.converters.PropertyCustomizingConverter;
import org.springdoc.core.converters.PropertyNamingStrategyConverter;
import org.springdoc.core.converters.ResponseSupportConverter;
import org.springdoc.core.converters.JsonNullableSupportConverter;
import org.springdoc.core.converters.SchemaPropertyDeprecatingConverter;
import org.springdoc.core.converters.WebFluxSupportConverter;
import org.springdoc.core.customizers.ActuatorOperationCustomizer;
Expand Down Expand Up @@ -268,6 +269,18 @@ ResponseSupportConverter responseSupportConverter(ObjectMapperProvider objectMap
return new ResponseSupportConverter(objectMapperProvider);
}

/**
* @param objectMapperProvider the OpenAPI object mapper provider
* @return the JsonNullable support converter
*/
@Bean
@ConditionalOnMissingBean
@ConditionalOnClass(name = "org.openapitools.jackson.nullable.JsonNullable")
@Lazy(false)
JsonNullableSupportConverter jsonNullableSupportConverter(ObjectMapperProvider objectMapperProvider) {
return new JsonNullableSupportConverter(objectMapperProvider);
}

/**
* Schema property deprecating converter schema property deprecating converter.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* 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.converters;

import java.lang.annotation.Annotation;
import java.lang.reflect.AnnotatedParameterizedType;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;

import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
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.core.converter.ModelConverters;
import io.swagger.v3.core.jackson.ModelResolver;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.media.ComposedSchema;
import io.swagger.v3.oas.models.media.Schema;
import org.springdoc.core.providers.ObjectMapperProvider;

import org.springdoc.core.utils.SchemaUtils;

/**
* Describes JsonNullable values without exposing their Java wrapper.
*
* @author dpkass
*/
public class JsonNullableSupportConverter implements ModelConverter {

private static final String JSON_NULLABLE = "org.openapitools.jackson.nullable.JsonNullable";

private final ObjectMapperProvider mapperProvider;

/**
* @param mapperProvider the OpenAPI object mapper provider
*/
public JsonNullableSupportConverter(ObjectMapperProvider mapperProvider) {
this.mapperProvider = mapperProvider;
}

@Override
public Schema resolve(AnnotatedType type, ModelConverterContext context, Iterator<ModelConverter> chain) {
JavaType javaType = mapperProvider.jsonMapper().constructType(type.getType());
if (javaType != null && JSON_NULLABLE.equals(javaType.getRawClass().getName()))
return resolveValue(javaType, type.getCtxAnnotations(), type, context);

Schema resolved = chain.hasNext() ? chain.next().resolve(type, context, chain) : null;
if (resolved == null || javaType == null)
return resolved;
Schema model = resolved;
if (model.get$ref() != null) {
if (!model.get$ref().startsWith(Components.COMPONENTS_SCHEMAS_REF))
return resolved;
model = context.getDefinedModels().get(model.get$ref().substring(Components.COMPONENTS_SCHEMAS_REF.length()));
}
if (model == null || model.getProperties() == null)
return resolved;

ObjectMapper mapper = ModelConverters.getInstance(mapperProvider.isOpenapi31()).getConverters().stream()
.filter(ModelResolver.class::isInstance).map(ModelResolver.class::cast)
.map(ModelResolver::objectMapper).findFirst().orElse(null);
if (mapper == null)
return resolved;
var bean = mapper.getSerializationConfig().introspect(javaType);
var schema = bean.getClassInfo().getAnnotation(io.swagger.v3.oas.annotations.media.Schema.class);
List<String> requiredProperties = schema == null ? List.of() : Arrays.asList(schema.requiredProperties());
for (BeanPropertyDefinition property : bean.findProperties()) {
if (!JSON_NULLABLE.equals(property.getPrimaryType().getRawClass().getName())
|| !model.getProperties().containsKey(property.getName()))
continue;
List<Annotation> annotations = typeArgumentAnnotations(property);
if (!annotations.isEmpty()) {
property.getPrimaryMember().annotations().forEach(annotations::add);
model.addProperty(property.getName(), resolveValue(property.getPrimaryType(),
annotations.toArray(Annotation[]::new), type, context));
}
// ModelResolver infers presence from validation constraints after resolving
// a property. JsonNullable instead permits omission unless explicitly required.
var propertySchema = property.getPrimaryMember().getAnnotation(io.swagger.v3.oas.annotations.media.Schema.class);
Boolean required = SchemaUtils.swaggerRequired(propertySchema, null);
boolean explicitlyRequired = required != null ? required : property.isRequired();
if (model.getRequired() != null && !explicitlyRequired && !requiredProperties.contains(property.getName()))
model.getRequired().remove(property.getName());
}
if (model.getRequired() != null && model.getRequired().isEmpty())
model.setRequired(null);
return resolved;
}

/**
* Resolve the value normally, then add null only when no non-null constraint applies.
* Conflicting annotations retain ModelResolver's behavior.
*/
private Schema resolveValue(JavaType wrapper, Annotation[] annotations, AnnotatedType original,
ModelConverterContext context) {
Schema value = context.resolve(new AnnotatedType(wrapper.containedTypeOrUnknown(0))
.ctxAnnotations(annotations).jsonViewAnnotation(original.getJsonViewAnnotation()).resolveAsRef(true));
if (value == null || (annotations != null && SchemaUtils.annotatedNotNull(Arrays.asList(annotations))))
return value;

if (value.get$ref() == null && value.getEnum() == null
&& value.getAllOf() == null && value.getAnyOf() == null && value.getOneOf() == null
&& (value.getType() != null || value.getTypes() != null)) {
// Keep nullability local rather than mutating a cached/shared schema.
Schema nullable = mapperProvider.jsonMapper().convertValue(value, Schema.class);
if (mapperProvider.isOpenapi31()) {
if (nullable.getTypes() == null && nullable.getType() != null)
nullable.addType(nullable.getType());
nullable.addType("null");
}
else
nullable.setNullable(true);
return nullable;
}
Schema nullValue = new Schema();
if (mapperProvider.isOpenapi31())
nullValue.addType("null");
else {
nullValue.setType("object");
nullValue.setNullable(true);
nullValue.setEnum(Collections.singletonList(null));
}
return new ComposedSchema().addAnyOfItem(value).addAnyOfItem(nullValue);
}

/**
* JavaType does not retain type-use annotations; retrieve them from the members.
*/
private List<Annotation> typeArgumentAnnotations(BeanPropertyDefinition property) {
List<Annotation> annotations = new ArrayList<>();
for (var member : Arrays.asList(property.getField(), property.getGetter(), property.getSetter())) {
if (member == null)
continue;
java.lang.reflect.AnnotatedType annotatedType = null;
if (member.getMember() instanceof Field field)
annotatedType = field.getAnnotatedType();
else if (member.getMember() instanceof Method method)
annotatedType = method.getParameterCount() == 0 ? method.getAnnotatedReturnType()
: method.getAnnotatedParameterTypes()[0];
if (annotatedType instanceof AnnotatedParameterizedType parameterized)
annotations.addAll(Arrays.asList(parameterized.getAnnotatedActualTypeArguments()[0].getAnnotations()));
}
return annotations;
}
}
6 changes: 6 additions & 0 deletions springdoc-openapi-starter-webmvc-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@
<optional>true</optional>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.openapitools</groupId>
<artifactId>jackson-databind-nullable</artifactId>
<version>0.2.8</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>javax.money</groupId>
<artifactId>money-api</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* 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.app270;

import java.util.List;
import java.util.Map;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.openapitools.jackson.nullable.JsonNullable;

import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

/**
* A merge-patch model with optional, nullable and explicitly required properties.
*
* @author dpkass
*/
@RestController
public class HelloController {

@PatchMapping("/example")
public Patch patch(@RequestBody Patch patch) {
return patch;
}

@PatchMapping("/bean")
public Bean patchBean(@RequestBody Bean bean) {
return bean;
}

public record Patch(
JsonNullable<String> nullable,
@NotNull JsonNullable<String> nonNull,
JsonNullable<@NotNull String> innerNonNull,
JsonNullable<@NotBlank @Size(max = 20) String> innerNonBlank,
@jakarta.annotation.Nullable JsonNullable<@NotNull String> outerNullable,
@NotNull JsonNullable<@org.jspecify.annotations.Nullable String> innerNullable,
@NonNull JsonNullable<String> nonNullAlias,
@NotBlank @Size(max = 20) JsonNullable<String> nonBlank,
@NotEmpty JsonNullable<List<String>> nonEmpty,
JsonNullable<List<String>> list,
JsonNullable<Map<String, String>> map,
JsonNullable<Child> child,
@NotNull JsonNullable<Child> nonNullChild,
@JsonProperty("renamed") @NotNull JsonNullable<String> original,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) JsonNullable<String> required,
@NotNull String ordinary) {}

public record Child(@NotNull String name) {}

@Retention(RetentionPolicy.RUNTIME)
public @interface NonNull {}

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public static class Bean {
public JsonNullable<@NotNull String> fieldValue;

private JsonNullable<String> setterValue;

private JsonNullable<String> getterValue;

public JsonNullable<String> getSetterValue() {
return setterValue;
}

@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
public void setSetterValue(JsonNullable<@NotNull String> value) {
setterValue = value;
}

public JsonNullable<@NotNull String> getGetterValue() {
return getterValue;
}

public void setGetterValue(JsonNullable<String> value) {
getterValue = value;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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.app270;

import test.org.springdoc.api.v30.AbstractSpringDocV30Test;

import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
* JsonNullable schema regression fixture.
*
* @author dpkass
*/
public class SpringDocApp270Test extends AbstractSpringDocV30Test {

@SpringBootApplication
static class SpringDocTestApp {}
}
Loading
Loading