Skip to content

Null key for a Map not allowed in JSON #3263

@XhstormR

Description

@XhstormR

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

  1. 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.

  2. Define a DTO:

public record MyDto(String name, int count) {}
  1. 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.

  1. Access the OpenAPI docs endpoint: GET /v3/api-docs

  2. 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

  • I have searched the existing issues and this is not a duplicate.
  • I have provided sufficient information for maintainers to reproduce the issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions