Springdoc 3.0.3 downgrade to 3.0.2 works well.
swagger-core-null-key-reproducer.zip
Description of the problem/issue
When swagger-core resolves a bean property annotated with @JsonUnwrapped, the generated OpenAPI schema's properties map may contain a null key entry, which causes Jackson serialization to fail with JsonMappingException: Null key for a Map not allowed in JSON.
This is triggered by Spring HATEOAS EntityModel<T>, where getContent() is annotated with @JsonUnwrapped. When swagger-core resolves EntityModel<SomeDto>, it unwraps the DTO's properties into the parent schema. However, the unwrapped property Schema objects may have name == null due to Schema.getName() being @JsonIgnore and being lost during JSON-based clone operations within ModelResolver. These null-named schemas are then inserted into the properties map via modelProps.put(prop.getName(), prop), producing a null key.
Root Cause Analysis
The bug is in ModelResolver.java and involves three interacting mechanisms:
1. handleUnwrapped() (line ~1461) extracts Schema values from the inner model's properties map:
private void handleUnwrapped(List<Schema> props, Schema innerModel,
String prefix, String suffix, List<String> requiredProps) {
if (StringUtils.isBlank(suffix) && StringUtils.isBlank(prefix)) {
if (innerModel.getProperties() != null) {
props.addAll(innerModel.getProperties().values()); // Schema values added directly
// ...
}
}
// ...
}
2. Schema.getName() is @JsonIgnore, lost during any JSON round-trip clone:
// io.swagger.v3.oas.models.media.Schema
@JsonIgnore
public String getName() {
return this.name;
}
The AnnotationsUtils.clone() method only restores the name for the top-level schema, not for nested property schemas:
public static Schema clone(Schema schema, boolean openapi31) {
String cloneName = schema.getName();
schema = Json.mapper().readValue(Json.pretty(schema), Schema.class); // name lost for nested schemas
schema.setName(cloneName); // only top-level name is restored
return schema;
}
3. Null key insertion (line ~940):
for (Schema prop : props) {
modelProps.put(prop.getName(), prop); // null key if prop.getName() is null
}
When handleUnwrapped retrieves property schemas from a model that has been through a clone/re-resolution cycle (e.g., $ref dereferencing at line ~817, resolveSubtypes, or context.defineModel + later getDefinedModels retrieval), the nested property Schema objects have name == null. These are added to the props list by handleUnwrapped, and then modelProps.put(prop.getName(), prop) inserts a null key into the LinkedHashMap.
Suggested Fix
In handleUnwrapped(), when no prefix/suffix is specified, preserve the property name from the inner model's properties map key instead of relying on Schema.getName():
private void handleUnwrapped(List<Schema> props, Schema innerModel,
String prefix, String suffix, List<String> requiredProps) {
if (StringUtils.isBlank(suffix) && StringUtils.isBlank(prefix)) {
if (innerModel.getProperties() != null) {
for (Map.Entry<String, Schema> entry : innerModel.getProperties().entrySet()) {
Schema prop = entry.getValue();
if (prop.getName() == null) {
prop.setName(entry.getKey()); // restore name from map key
}
props.add(prop);
}
if (innerModel.getRequired() != null) {
requiredProps.addAll(innerModel.getRequired());
}
}
}
// ...
}
Alternatively, the null key insertion point at line ~940 could be guarded:
for (Schema prop : props) {
if (prop.getName() != null) {
modelProps.put(prop.getName(), prop);
}
}
Affected Version
- swagger-core-jakarta: 2.2.47
- swagger-models-jakarta: 2.2.47
Earliest version the bug appears in (if known): likely any version that includes the handleUnwrapped method using props.addAll(innerModel.getProperties().values()) combined with @JsonIgnore on Schema.getName().
Steps to Reproduce
-
Add swagger-core-jakarta 2.2.47 (e.g., via springdoc-openapi-starter-webmvc-ui 3.0.3) to a Spring Boot project that also uses Spring HATEOAS.
-
Define a DTO:
public record MyDto(String name, int count) {}
- Define a REST endpoint returning
EntityModel<MyDto>:
@GetMapping("/items")
public PagedModel<EntityModel<MyDto>> getItems(PagedResourcesAssembler<MyDto> assembler, Pageable pageable) {
return assembler.toModel(repository.findAll(pageable));
}
Spring HATEOAS EntityModel.getContent() is annotated with @JsonUnwrapped, which triggers the unwrapped schema resolution path in ModelResolver.
-
Access the OpenAPI docs endpoint: GET /v3/api-docs
-
The request fails with a JsonMappingException because the generated schema for EntityModelMyDto has a null key in its properties map.
Expected Behavior
The OpenAPI schema for EntityModelMyDto should contain properly keyed properties (e.g., name, count, _links) with no null entries in the properties map.
Actual Behavior
The properties map of the EntityModelMyDto schema contains a null key, causing Jackson to fail during serialization of the OpenAPI spec:
JsonMappingException: Null key for a Map not allowed in JSON (use a converting NullKeySerializer?)
(through reference chain:
io.swagger.v3.oas.models.OpenAPI["components"]
->io.swagger.v3.oas.models.Components["schemas"]
->java.util.LinkedHashMap["EntityModelMyDto"]
->io.swagger.v3.oas.models.media.JsonSchema["properties"]
->java.util.LinkedHashMap["null"])
Logs / Stack Traces
com.fasterxml.jackson.databind.JsonMappingException: Null key for a Map not allowed in JSON (use a converting NullKeySerializer?) (through reference chain: io.swagger.v3.oas.models.OpenAPI["components"]->io.swagger.v3.oas.models.Components["schemas"]->java.util.LinkedHashMap["EntityModelContractResponse"]->io.swagger.v3.oas.models.media.JsonSchema["properties"]->java.util.LinkedHashMap["null"])
at com.fasterxml.jackson.databind.JsonMappingException.from(JsonMappingException.java:282)
at com.fasterxml.jackson.databind.SerializerProvider.mappingException(SerializerProvider.java:1417)
at com.fasterxml.jackson.databind.SerializerProvider.reportMappingProblem(SerializerProvider.java:1315)
at com.fasterxml.jackson.databind.ser.impl.FailingSerializer.serialize(FailingSerializer.java:31)
at com.fasterxml.jackson.databind.ser.std.MapSerializer.serializeOptionalFields(MapSerializer.java:867)
at com.fasterxml.jackson.databind.ser.std.MapSerializer.serializeWithoutTypeInfo(MapSerializer.java:759)
at com.fasterxml.jackson.databind.ser.std.MapSerializer.serialize(MapSerializer.java:719)
at com.fasterxml.jackson.databind.ser.std.MapSerializer.serialize(MapSerializer.java:34)
at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732)
at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:760)
at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:183)
at io.swagger.v3.core.jackson.Schema31Serializer.serialize(Schema31Serializer.java:51)
at io.swagger.v3.core.jackson.Schema31Serializer.serialize(Schema31Serializer.java:12)
at com.fasterxml.jackson.databind.ser.std.MapSerializer.serializeOptionalFields(MapSerializer.java:868)
at com.fasterxml.jackson.databind.ser.std.MapSerializer.serializeWithoutTypeInfo(MapSerializer.java:759)
at com.fasterxml.jackson.databind.ser.std.MapSerializer.serialize(MapSerializer.java:719)
at com.fasterxml.jackson.databind.ser.std.MapSerializer.serialize(MapSerializer.java:34)
Additional Context
Environment
- Spring Boot 4.0.5
- Spring HATEOAS 3.0.3
- springdoc-openapi-starter-webmvc-ui 3.0.3
- swagger-core-jakarta 2.2.47
- Kotlin 2.3.20
Workaround
Register a springdoc OpenApiCustomizer bean to strip null keys from all schema properties maps after generation:
@Bean
public OpenApiCustomizer nullKeyCleanupCustomizer() {
return openApi -> {
if (openApi.getComponents() != null && openApi.getComponents().getSchemas() != null) {
openApi.getComponents().getSchemas().values().forEach(this::removeNullKeys);
}
};
}
private void removeNullKeys(Schema<?> schema) {
if (schema.getProperties() != null) {
schema.getProperties().entrySet().removeIf(e -> e.getKey() == null);
schema.getProperties().values().forEach(this::removeNullKeys);
}
}
Key code references in swagger-core 2.2.47
| File |
Line(s) |
Description |
ModelResolver.java |
~796-812 |
jsonUnwrappedHandler lambda registration |
ModelResolver.java |
~940-941 |
modelProps.put(prop.getName(), prop) — null key insertion point |
ModelResolver.java |
~1461-1489 |
handleUnwrapped() — extracts property schemas relying on Schema.getName() |
Schema.java |
getName() |
@JsonIgnore annotation causes name loss during JSON round-trip clone |
AnnotationsUtils.java |
clone() |
Only restores top-level schema name, not nested property names |
Checklist
Springdoc 3.0.3 downgrade to 3.0.2 works well.
swagger-core-null-key-reproducer.zip
Description of the problem/issue
When swagger-core resolves a bean property annotated with
@JsonUnwrapped, the generated OpenAPI schema'spropertiesmap may contain anullkey entry, which causes Jackson serialization to fail withJsonMappingException: Null key for a Map not allowed in JSON.This is triggered by Spring HATEOAS
EntityModel<T>, wheregetContent()is annotated with@JsonUnwrapped. When swagger-core resolvesEntityModel<SomeDto>, it unwraps the DTO's properties into the parent schema. However, the unwrapped propertySchemaobjects may havename == nulldue toSchema.getName()being@JsonIgnoreand being lost during JSON-based clone operations withinModelResolver. These null-named schemas are then inserted into the properties map viamodelProps.put(prop.getName(), prop), producing anullkey.Root Cause Analysis
The bug is in
ModelResolver.javaand involves three interacting mechanisms:1.
handleUnwrapped()(line ~1461) extracts Schema values from the inner model's properties map:2.
Schema.getName()is@JsonIgnore, lost during any JSON round-trip clone:The
AnnotationsUtils.clone()method only restores the name for the top-level schema, not for nested property schemas:3. Null key insertion (line ~940):
When
handleUnwrappedretrieves property schemas from a model that has been through a clone/re-resolution cycle (e.g.,$refdereferencing at line ~817,resolveSubtypes, orcontext.defineModel+ latergetDefinedModelsretrieval), the nested propertySchemaobjects havename == null. These are added to thepropslist byhandleUnwrapped, and thenmodelProps.put(prop.getName(), prop)inserts anullkey into theLinkedHashMap.Suggested Fix
In
handleUnwrapped(), when no prefix/suffix is specified, preserve the property name from the inner model's properties map key instead of relying onSchema.getName():Alternatively, the null key insertion point at line ~940 could be guarded:
Affected Version
Earliest version the bug appears in (if known): likely any version that includes the
handleUnwrappedmethod usingprops.addAll(innerModel.getProperties().values())combined with@JsonIgnoreonSchema.getName().Steps to Reproduce
Add swagger-core-jakarta 2.2.47 (e.g., via springdoc-openapi-starter-webmvc-ui 3.0.3) to a Spring Boot project that also uses Spring HATEOAS.
Define a DTO:
EntityModel<MyDto>:Spring HATEOAS
EntityModel.getContent()is annotated with@JsonUnwrapped, which triggers the unwrapped schema resolution path inModelResolver.Access the OpenAPI docs endpoint:
GET /v3/api-docsThe request fails with a
JsonMappingExceptionbecause the generated schema forEntityModelMyDtohas anullkey in itspropertiesmap.Expected Behavior
The OpenAPI schema for
EntityModelMyDtoshould contain properly keyed properties (e.g.,name,count,_links) with nonullentries in the properties map.Actual Behavior
The
propertiesmap of theEntityModelMyDtoschema contains anullkey, causing Jackson to fail during serialization of the OpenAPI spec:Logs / Stack Traces
Additional Context
Environment
Workaround
Register a springdoc
OpenApiCustomizerbean to strip null keys from all schema properties maps after generation:Key code references in swagger-core 2.2.47
ModelResolver.javajsonUnwrappedHandlerlambda registrationModelResolver.javamodelProps.put(prop.getName(), prop)— null key insertion pointModelResolver.javahandleUnwrapped()— extracts property schemas relying onSchema.getName()Schema.javagetName()@JsonIgnoreannotation causes name loss during JSON round-trip cloneAnnotationsUtils.javaclone()Checklist