Skip to content

Commit b706edf

Browse files
committed
Fixes #3263 - strip null-key entries from component schema properties before serialization
When swagger-core resolves a @JsonUnwrapped bean property (e.g. Spring HATEOAS EntityModel<T>), it uses JSON-based cloning internally. Schema.getName() is @JsonIgnore, so the name is lost during cloning. These null-named schemas are then inserted as null keys into the properties map, causing Jackson to throw JsonMappingException: Null key for a Map not allowed in JSON when serializing the OpenAPI document. Add SpringDocUtils.removeNullKeySchemas(OpenAPI) which recursively removes any null-key entry from every component schema properties map, and call it in AbstractOpenApiResource.getOpenApi() after schema resolution and before the user-facing customizers run. Five unit tests added in SpringDocUtilsTest covering top-level null keys, nested null keys, null OpenAPI, null components, and schemas with no properties.
1 parent da4cb67 commit b706edf

File tree

3 files changed

+173
-0
lines changed

3 files changed

+173
-0
lines changed

springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,8 @@ protected OpenAPI getOpenApi(String serverBaseUrl, Locale locale) {
407407
if (springDocConfigProperties.isRemoveBrokenReferenceDefinitions())
408408
this.removeBrokenReferenceDefinitions(openAPI);
409409

410+
SpringDocUtils.removeNullKeySchemas(openAPI);
411+
410412
// run the optional customizers
411413
List<Server> servers = openAPI.getServers();
412414
List<Server> serversCopy = cloneViaJson(servers, new TypeReference<List<Server>>() {}, springDocProviders.jsonMapper());

springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import com.fasterxml.jackson.databind.ObjectMapper;
3838
import io.swagger.v3.core.converter.AnnotatedType;
3939
import io.swagger.v3.core.util.PrimitiveType;
40+
import io.swagger.v3.oas.models.OpenAPI;
4041
import io.swagger.v3.oas.models.media.ComposedSchema;
4142
import io.swagger.v3.oas.models.media.Content;
4243
import io.swagger.v3.oas.models.media.Schema;
@@ -215,6 +216,41 @@ else if (types == null && "null".equals(addPropSchema.getType())) {
215216
}
216217
}
217218

219+
/**
220+
* Remove null-key entries from schema properties maps across all component schemas.
221+
* This is a defensive fix for a swagger-core issue where {@code @JsonUnwrapped}
222+
* bean properties (e.g. on Spring HATEOAS {@code EntityModel}) can produce schemas
223+
* whose name is {@code null} — because {@link io.swagger.v3.oas.models.media.Schema#getName()}
224+
* is annotated with {@code @JsonIgnore} and is lost during internal JSON round-trip clones
225+
* inside {@code ModelResolver}. Those null-named schemas are then inserted as null keys
226+
* into the properties map, causing Jackson to fail with:
227+
* <pre>JsonMappingException: Null key for a Map not allowed in JSON</pre>
228+
*
229+
* @param openAPI the OpenAPI document to sanitize; no-op if {@code null}
230+
*/
231+
public static void removeNullKeySchemas(OpenAPI openAPI) {
232+
if (openAPI == null || openAPI.getComponents() == null
233+
|| openAPI.getComponents().getSchemas() == null) {
234+
return;
235+
}
236+
openAPI.getComponents().getSchemas().values()
237+
.forEach(schema -> removeNullKeyFromSchemaProperties((Schema<?>) schema));
238+
}
239+
240+
/**
241+
* Recursively remove null-key entries from the given schema's properties map.
242+
*
243+
* @param schema the schema to sanitize
244+
*/
245+
private static void removeNullKeyFromSchemaProperties(Schema<?> schema) {
246+
if (schema == null || schema.getProperties() == null) {
247+
return;
248+
}
249+
schema.getProperties().entrySet().removeIf(entry -> entry.getKey() == null);
250+
schema.getProperties().values()
251+
.forEach(prop -> removeNullKeyFromSchemaProperties((Schema<?>) prop));
252+
}
253+
218254
/**
219255
* Handle schema types.
220256
*
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * * Copyright 2019-2026 the original author or authors.
7+
* * * * *
8+
* * * * * Licensed under the Apache License, Version 2.0 (the "License");
9+
* * * * * you may not use this file except in compliance with the License.
10+
* * * * * You may obtain a copy of the License at
11+
* * * * *
12+
* * * * * https://www.apache.org/licenses/LICENSE-2.0
13+
* * * * *
14+
* * * * * Unless required by applicable law or agreed to in writing, software
15+
* * * * * distributed under the License is distributed on an "AS IS" BASIS,
16+
* * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
* * * * * See the License for the specific language governing permissions and
18+
* * * * * limitations under the License.
19+
* * * *
20+
* * *
21+
* *
22+
*
23+
*/
24+
25+
package org.springdoc.core.utils;
26+
27+
import java.util.LinkedHashMap;
28+
import java.util.Map;
29+
30+
import io.swagger.v3.oas.models.Components;
31+
import io.swagger.v3.oas.models.OpenAPI;
32+
import io.swagger.v3.oas.models.media.Schema;
33+
import io.swagger.v3.oas.models.media.StringSchema;
34+
import org.junit.jupiter.api.Test;
35+
36+
import static org.assertj.core.api.Assertions.assertThat;
37+
38+
/**
39+
* Unit tests for {@link SpringDocUtils}.
40+
*/
41+
class SpringDocUtilsTest {
42+
43+
/**
44+
* Verify that {@link SpringDocUtils#removeNullKeySchemas(OpenAPI)} strips null-key
45+
* entries from the top-level properties map of every component schema.
46+
*
47+
* <p>This is a regression guard for GitHub issue #3263: when swagger-core resolves
48+
* a {@code @JsonUnwrapped} property (e.g. from Spring HATEOAS {@code EntityModel}),
49+
* it can produce a schema whose {@code name} is {@code null} and then insert that
50+
* schema under a null key in the parent's properties map. Jackson subsequently
51+
* fails with {@code JsonMappingException: Null key for a Map not allowed in JSON}.
52+
*/
53+
@Test
54+
void removeNullKeySchemas_removesNullKeyFromTopLevelSchemaProperties() {
55+
// Arrange – build a component schema whose properties map has a null key entry
56+
Schema<Object> parentSchema = new Schema<>();
57+
Map<String, Schema> props = new LinkedHashMap<>();
58+
props.put("name", new StringSchema());
59+
props.put(null, new StringSchema()); // null key – the problematic entry
60+
props.put("count", new StringSchema());
61+
parentSchema.setProperties(props);
62+
63+
OpenAPI openAPI = new OpenAPI()
64+
.components(new Components().addSchemas("MyDto", parentSchema));
65+
66+
// Act
67+
SpringDocUtils.removeNullKeySchemas(openAPI);
68+
69+
// Assert
70+
Map<String, Schema> result = openAPI.getComponents().getSchemas().get("MyDto").getProperties();
71+
assertThat(result).containsOnlyKeys("name", "count");
72+
assertThat(result).doesNotContainKey(null);
73+
}
74+
75+
/**
76+
* Verify that null-key cleanup recurses into nested schema properties.
77+
*/
78+
@Test
79+
void removeNullKeySchemas_removesNullKeyFromNestedSchemaProperties() {
80+
// Arrange
81+
Schema<Object> nestedSchema = new Schema<>();
82+
Map<String, Schema> nestedProps = new LinkedHashMap<>();
83+
nestedProps.put("field", new StringSchema());
84+
nestedProps.put(null, new StringSchema()); // null key in nested schema
85+
nestedSchema.setProperties(nestedProps);
86+
87+
Schema<Object> parentSchema = new Schema<>();
88+
Map<String, Schema> parentProps = new LinkedHashMap<>();
89+
parentProps.put("nested", nestedSchema);
90+
parentSchema.setProperties(parentProps);
91+
92+
OpenAPI openAPI = new OpenAPI()
93+
.components(new Components().addSchemas("Parent", parentSchema));
94+
95+
// Act
96+
SpringDocUtils.removeNullKeySchemas(openAPI);
97+
98+
// Assert – nested null key is also gone
99+
Schema<?> parentResult = openAPI.getComponents().getSchemas().get("Parent");
100+
Schema<?> nestedResult = (Schema<?>) parentResult.getProperties().get("nested");
101+
assertThat(nestedResult.getProperties()).containsOnlyKeys("field");
102+
assertThat(nestedResult.getProperties()).doesNotContainKey(null);
103+
}
104+
105+
/**
106+
* Verify that a {@code null} OpenAPI argument is handled gracefully (no NPE).
107+
*/
108+
@Test
109+
void removeNullKeySchemas_toleratesNullOpenApi() {
110+
SpringDocUtils.removeNullKeySchemas(null);
111+
// no exception expected
112+
}
113+
114+
/**
115+
* Verify that an OpenAPI with no components is handled gracefully.
116+
*/
117+
@Test
118+
void removeNullKeySchemas_toleratesNullComponents() {
119+
SpringDocUtils.removeNullKeySchemas(new OpenAPI());
120+
// no exception expected
121+
}
122+
123+
/**
124+
* Verify that schemas with no properties are handled gracefully.
125+
*/
126+
@Test
127+
void removeNullKeySchemas_toleratesSchemaWithNoProperties() {
128+
Schema<Object> emptySchema = new Schema<>();
129+
OpenAPI openAPI = new OpenAPI()
130+
.components(new Components().addSchemas("Empty", emptySchema));
131+
SpringDocUtils.removeNullKeySchemas(openAPI);
132+
// no exception expected, schema unchanged
133+
assertThat(openAPI.getComponents().getSchemas().get("Empty").getProperties()).isNull();
134+
}
135+
}

0 commit comments

Comments
 (0)