diff --git a/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java b/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java index f25f99a654..e24c358b91 100644 --- a/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java +++ b/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java @@ -95,7 +95,6 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -1461,7 +1460,18 @@ protected boolean ignore(final Annotated member, final XmlAccessorType xmlAccess private void handleUnwrapped(List props, Schema innerModel, String prefix, String suffix, List requiredProps) { if (StringUtils.isBlank(suffix) && StringUtils.isBlank(prefix)) { if (innerModel.getProperties() != null) { - props.addAll(innerModel.getProperties().values()); + // Schema.getName() is @JsonIgnore, so any prior JSON-based clone of innerModel + // (e.g. AnnotationsUtils.clone) leaves nested property schemas with a null name + // while the properties-map key still carries the correct name. Restore from the + // map key so the eventual `modelProps.put(prop.getName(), prop)` does not insert + // a null key (see swagger-api/swagger-core#5126). + for (Map.Entry entry : ((Map) innerModel.getProperties()).entrySet()) { + Schema prop = entry.getValue(); + if (prop.getName() == null) { + prop.setName(entry.getKey()); + } + props.add(prop); + } if (innerModel.getRequired() != null) { requiredProps.addAll(innerModel.getRequired()); } @@ -1475,10 +1485,14 @@ private void handleUnwrapped(List props, Schema innerModel, String prefi suffix = ""; } if (innerModel.getProperties() != null) { - for (Schema prop : (Collection) innerModel.getProperties().values()) { + for (Map.Entry entry : ((Map) innerModel.getProperties()).entrySet()) { + Schema prop = entry.getValue(); try { Schema clonedProp = Json.mapper().readValue(Json.pretty(prop), Schema.class); - clonedProp.setName(prefix + prop.getName() + suffix); + // Fall back to the map key when the prop's transient name has been lost + // by a prior clone (Schema.getName() is @JsonIgnore). + String baseName = prop.getName() != null ? prop.getName() : entry.getKey(); + clonedProp.setName(prefix + baseName + suffix); props.add(clonedProp); } catch (IOException e) { LOGGER.error("Exception cloning property", e); diff --git a/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/Ticket5126Test.java b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/Ticket5126Test.java new file mode 100644 index 0000000000..f9eca6c955 --- /dev/null +++ b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/Ticket5126Test.java @@ -0,0 +1,95 @@ +package io.swagger.v3.core.resolving; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.core.jackson.ModelResolver; +import io.swagger.v3.oas.models.media.Schema; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; + +/** + * Regression test for #5126. + * + *

When an inner model's nested property schemas have a {@code null} name — which happens + * naturally after {@code AnnotationsUtils.clone} round-trips the schema through JSON, because + * {@code Schema.getName()} is {@code @JsonIgnore} — {@link ModelResolver#handleUnwrapped} would + * forward those schemas into the outer model's properties list as-is. The subsequent + * {@code modelProps.put(prop.getName(), prop)} then inserts a {@code null} key into the outer's + * properties map, which later causes Jackson to fail serializing the OpenAPI document with + * {@code JsonMappingException: Null key for a Map not allowed in JSON}. + * + *

This test invokes {@code handleUnwrapped} directly with a hand-crafted inner model whose + * property schemas have a {@code null} name (simulating the post-clone state) and verifies that + * the resulting property names come from the inner properties-map keys, not from the transient + * {@code Schema.name} field. + */ +public class Ticket5126Test extends SwaggerTestBase { + + private ModelResolver modelResolver; + private Method handleUnwrapped; + + @BeforeMethod + public void setup() throws Exception { + modelResolver = new ModelResolver(new ObjectMapper()); + handleUnwrapped = ModelResolver.class.getDeclaredMethod( + "handleUnwrapped", List.class, Schema.class, String.class, String.class, List.class); + handleUnwrapped.setAccessible(true); + } + + @Test + public void noPrefixOrSuffix_restoresNameFromInnerPropertiesMapKey() throws Exception { + Schema nameSchema = new Schema<>().type("string"); // name field unset + Schema countSchema = new Schema<>().type("integer"); // name field unset + Schema inner = new Schema<>(); + Map innerProps = new LinkedHashMap<>(); + innerProps.put("name", nameSchema); + innerProps.put("count", countSchema); + inner.setProperties(innerProps); + + List props = new ArrayList<>(); + List requiredProps = new ArrayList<>(); + handleUnwrapped.invoke(modelResolver, props, inner, "", "", requiredProps); + + assertEquals(props.size(), 2); + LinkedHashSet names = new LinkedHashSet<>(); + for (Schema p : props) { + assertNotNull(p.getName(), + "Each unwrapped property must carry a non-null name to avoid null keys in the outer's properties map"); + names.add(p.getName()); + } + assertEquals(names, new LinkedHashSet<>(java.util.Arrays.asList("name", "count"))); + } + + @Test + public void withPrefixAndSuffix_combinesMapKeyWhenInnerNameIsNull() throws Exception { + Schema nameSchema = new Schema<>().type("string"); + Schema countSchema = new Schema<>().type("integer"); + Schema inner = new Schema<>(); + Map innerProps = new LinkedHashMap<>(); + innerProps.put("name", nameSchema); + innerProps.put("count", countSchema); + inner.setProperties(innerProps); + + List props = new ArrayList<>(); + List requiredProps = new ArrayList<>(); + handleUnwrapped.invoke(modelResolver, props, inner, "p_", "_s", requiredProps); + + assertEquals(props.size(), 2); + LinkedHashSet names = new LinkedHashSet<>(); + for (Schema p : props) { + assertNotNull(p.getName(), + "Prefixed/suffixed names must be derived from the inner properties-map key when the original Schema.name has been lost"); + names.add(p.getName()); + } + assertEquals(names, new LinkedHashSet<>(java.util.Arrays.asList("p_name_s", "p_count_s"))); + } +}