Skip to content

Commit 4aeaa51

Browse files
authored
Honor PropertyNamingStrategy for get/is-prefixed property names (#5192)
* Apply PropertyNamingStrategy to get/is-prefixed property names The "avoid clobbering get/is names" hack in ModelResolver replaced the Jackson-resolved property name with the raw member name for any member whose name starts with a get/is prefix followed by a lower-case letter. When a custom PropertyNamingStrategy (e.g. SNAKE_CASE) was configured, this clobbered the translated name: record components such as issuanceDate were emitted as "issuanceDate" instead of "issuance_date", while sibling fields like familyName were correctly translated. Only apply the hack when no PropertyNamingStrategy is configured, so the strategy is honored uniformly. The original #415 (Scala is_persistent) and #2635 (JAXB-renamed is-prefixed fields) behavior is preserved, as neither configures a naming strategy. Reported in springdoc/springdoc-openapi#3293. Signed-off-by: seonwoo_jung <79202163+seonwooj0810@users.noreply.github.com> * fix: preserve Jackson-resolved property names when naming is supplied via class-level or mix-in @JsonNaming
1 parent 42ef86b commit 4aeaa51

2 files changed

Lines changed: 243 additions & 1 deletion

File tree

modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import com.fasterxml.jackson.databind.ObjectMapper;
2020
import com.fasterxml.jackson.databind.PropertyMetadata;
2121
import com.fasterxml.jackson.databind.SerializationFeature;
22+
import com.fasterxml.jackson.databind.annotation.JsonNaming;
2223
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
2324
import com.fasterxml.jackson.databind.introspect.Annotated;
2425
import com.fasterxml.jackson.databind.introspect.AnnotatedClass;
@@ -639,6 +640,7 @@ public Schema resolve(AnnotatedType annotatedType, ModelConverterContext context
639640
}
640641

641642
final XmlAccessorType xmlAccessorTypeAnnotation = beanDesc.getClassAnnotations().get(XmlAccessorType.class);
643+
final JsonNaming jsonNamingAnnotation = beanDesc.getClassAnnotations().get(JsonNaming.class);
642644

643645
// see if @JsonIgnoreProperties exist
644646
Set<String> propertiesToIgnore = resolveIgnoredProperties(beanDesc.getClassAnnotations(), annotatedType.getCtxAnnotations());
@@ -668,7 +670,16 @@ public Schema resolve(AnnotatedType annotatedType, ModelConverterContext context
668670

669671
// hack to avoid clobbering properties with get/is names
670672
// it's ugly but gets around https://github.com/swagger-api/swagger-core/issues/415
671-
if (propDef.getPrimaryMember() != null) {
673+
//
674+
// This restores the raw member name for members that merely start with a get/is prefix
675+
// (e.g. a Scala accessor "is_persistent", or a JAXB-renamed field, see #415 and #2635).
676+
// It must NOT run when a custom PropertyNamingStrategy (e.g. SNAKE_CASE) is configured:
677+
// in that case the strategy legitimately translates names such as "issuanceDate" ->
678+
// "issuance_date" and the raw member name must not clobber the translated one
679+
// (see springdoc/springdoc-openapi#3293).
680+
if (propDef.getPrimaryMember() != null
681+
&& _mapper.getSerializationConfig().getPropertyNamingStrategy() == null
682+
&& jsonNamingAnnotation == null) {
672683
final JsonProperty jsonPropertyAnn = propDef.getPrimaryMember().getAnnotation(JsonProperty.class);
673684
if (jsonPropertyAnn == null || !jsonPropertyAnn.value().equals(propName)) {
674685
if (member != null) {
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
package io.swagger.v3.java17.resolving;
2+
3+
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
4+
import com.fasterxml.jackson.databind.annotation.JsonNaming;
5+
import io.swagger.v3.core.converter.AnnotatedType;
6+
import io.swagger.v3.core.converter.ModelConverterContextImpl;
7+
import io.swagger.v3.core.jackson.ModelResolver;
8+
import io.swagger.v3.core.util.Json;
9+
import io.swagger.v3.oas.models.media.Schema;
10+
import org.testng.annotations.Test;
11+
12+
import javax.xml.bind.annotation.XmlElement;
13+
14+
import static org.testng.Assert.assertEquals;
15+
import static org.testng.Assert.assertNull;
16+
import static org.testng.Assert.assertTrue;
17+
18+
/**
19+
* Reproduces the property-naming inconsistency reported in
20+
* springdoc/springdoc-openapi#3293: when a {@link PropertyNamingStrategies} such as
21+
* {@code SNAKE_CASE} is configured, record components whose names start with the
22+
* {@code is}/{@code get} accessor prefixes followed by a lower-case letter (e.g.
23+
* {@code issuanceDate}) were not being translated, because the
24+
* "avoid clobbering get/is names" hack in {@code ModelResolver} replaced the
25+
* Jackson-resolved (translated) name with the raw component name.
26+
*/
27+
public class RecordPropertyNamingStrategyTest {
28+
29+
public record PidLookupResponse(
30+
String familyName,
31+
String expiryDate,
32+
String issuanceDate,
33+
String issuingCountry,
34+
String issuingAuthority,
35+
String getawayDate
36+
) {
37+
}
38+
39+
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
40+
public record JsonNamingPidLookupResponse(
41+
String familyName,
42+
String expiryDate,
43+
String issuanceDate,
44+
String issuingCountry,
45+
String issuingAuthority,
46+
String getawayDate
47+
) {
48+
}
49+
50+
public record MixInPidLookupResponse(
51+
String familyName,
52+
String expiryDate,
53+
String issuanceDate,
54+
String issuingCountry,
55+
String issuingAuthority,
56+
String getawayDate
57+
) {
58+
}
59+
60+
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
61+
public abstract static class SnakeCaseMixIn {
62+
}
63+
64+
public record BooleanStatusResponse(
65+
boolean isActive
66+
) {
67+
}
68+
69+
public static class LegacyIsPersistentResponse {
70+
71+
public boolean is_persistent() {
72+
return true;
73+
}
74+
75+
public String gettersAndHaters() {
76+
return null;
77+
}
78+
}
79+
80+
public static class XmlRenamedIsPrefixResponse {
81+
82+
@XmlElement(name = "beerDrinkXmlElement")
83+
private String isotonicDrinkOnlyXmlElement;
84+
85+
public String getIsotonicDrinkOnlyXmlElement() {
86+
return isotonicDrinkOnlyXmlElement;
87+
}
88+
}
89+
90+
public static class CustomPrefixStrategy extends PropertyNamingStrategies.NamingBase {
91+
92+
@Override
93+
public String translate(String propertyName) {
94+
return propertyName == null ? null : "custom_" + propertyName;
95+
}
96+
}
97+
98+
@Test
99+
public void testSnakeCaseNamingStrategyAppliedToRecordComponents() {
100+
ModelResolver modelResolver = new ModelResolver(
101+
Json.mapper().copy().setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE));
102+
ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);
103+
104+
Schema<?> schema = modelResolver.resolve(new AnnotatedType(PidLookupResponse.class), context, null);
105+
106+
assertTrue(schema.getProperties().containsKey("family_name"));
107+
assertTrue(schema.getProperties().containsKey("expiry_date"));
108+
assertTrue(schema.getProperties().containsKey("issuance_date"),
109+
"expected issuance_date but got " + schema.getProperties().keySet());
110+
assertTrue(schema.getProperties().containsKey("issuing_country"),
111+
"expected issuing_country but got " + schema.getProperties().keySet());
112+
assertTrue(schema.getProperties().containsKey("issuing_authority"),
113+
"expected issuing_authority but got " + schema.getProperties().keySet());
114+
assertTrue(schema.getProperties().containsKey("getaway_date"),
115+
"expected getaway_date but got " + schema.getProperties().keySet());
116+
assertEquals(schema.getProperties().size(), 6, "unexpected properties: " + schema.getProperties().keySet());
117+
}
118+
119+
@Test
120+
public void testClassLevelJsonNamingAppliedToRecordComponents() {
121+
ModelResolver modelResolver = new ModelResolver(Json.mapper().copy());
122+
ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);
123+
124+
Schema<?> schema = modelResolver.resolve(new AnnotatedType(JsonNamingPidLookupResponse.class), context, null);
125+
126+
assertNull(modelResolver.objectMapper()
127+
.getSerializationConfig()
128+
.getPropertyNamingStrategy());
129+
assertTrue(schema.getProperties().containsKey("family_name"));
130+
assertTrue(schema.getProperties().containsKey("expiry_date"));
131+
assertTrue(schema.getProperties().containsKey("issuance_date"),
132+
"expected issuance_date but got " + schema.getProperties().keySet());
133+
assertTrue(schema.getProperties().containsKey("issuing_country"),
134+
"expected issuing_country but got " + schema.getProperties().keySet());
135+
assertTrue(schema.getProperties().containsKey("issuing_authority"),
136+
"expected issuing_authority but got " + schema.getProperties().keySet());
137+
assertTrue(schema.getProperties().containsKey("getaway_date"),
138+
"expected getaway_date but got " + schema.getProperties().keySet());
139+
assertEquals(schema.getProperties().size(), 6, "unexpected properties: " + schema.getProperties().keySet());
140+
}
141+
142+
@Test
143+
public void testMixInJsonNamingAppliedToRecordComponents() {
144+
ModelResolver modelResolver = new ModelResolver(Json.mapper().copy()
145+
.addMixIn(MixInPidLookupResponse.class, SnakeCaseMixIn.class));
146+
ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);
147+
148+
Schema<?> schema = modelResolver.resolve(new AnnotatedType(MixInPidLookupResponse.class), context, null);
149+
150+
assertNull(modelResolver.objectMapper()
151+
.getSerializationConfig()
152+
.getPropertyNamingStrategy());
153+
assertTrue(schema.getProperties().containsKey("family_name"));
154+
assertTrue(schema.getProperties().containsKey("expiry_date"));
155+
assertTrue(schema.getProperties().containsKey("issuance_date"),
156+
"expected issuance_date but got " + schema.getProperties().keySet());
157+
assertTrue(schema.getProperties().containsKey("issuing_country"),
158+
"expected issuing_country but got " + schema.getProperties().keySet());
159+
assertTrue(schema.getProperties().containsKey("issuing_authority"),
160+
"expected issuing_authority but got " + schema.getProperties().keySet());
161+
assertTrue(schema.getProperties().containsKey("getaway_date"),
162+
"expected getaway_date but got " + schema.getProperties().keySet());
163+
assertEquals(schema.getProperties().size(), 6, "unexpected properties: " + schema.getProperties().keySet());
164+
}
165+
166+
@Test
167+
public void testBooleanIsRecordComponentWithSnakeCaseNamingStrategy() {
168+
ModelResolver modelResolver = new ModelResolver(
169+
Json.mapper().copy().setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE));
170+
ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);
171+
172+
Schema<?> schema = modelResolver.resolve(new AnnotatedType(BooleanStatusResponse.class), context, null);
173+
174+
assertTrue(schema.getProperties().containsKey("is_active"),
175+
"expected is_active but got " + schema.getProperties().keySet());
176+
assertEquals(schema.getProperties().size(), 1, "unexpected properties: " + schema.getProperties().keySet());
177+
}
178+
179+
@Test
180+
public void testLegacyIsPersistentNameWithNoNamingStrategy() {
181+
ModelResolver modelResolver = new ModelResolver(Json.mapper().copy());
182+
ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);
183+
184+
Schema<?> schema = modelResolver.resolve(new AnnotatedType(LegacyIsPersistentResponse.class), context, null);
185+
186+
assertNull(modelResolver.objectMapper()
187+
.getSerializationConfig()
188+
.getPropertyNamingStrategy());
189+
assertTrue(schema.getProperties().containsKey("is_persistent"),
190+
"expected is_persistent but got " + schema.getProperties().keySet());
191+
assertTrue(schema.getProperties().containsKey("gettersAndHaters"),
192+
"expected gettersAndHaters but got " + schema.getProperties().keySet());
193+
assertEquals(schema.getProperties().size(), 2, "unexpected properties: " + schema.getProperties().keySet());
194+
}
195+
196+
@Test
197+
public void testXmlRenamedIsPrefixFieldKeepsOriginalNameWithNoNamingStrategy() {
198+
ModelResolver modelResolver = new ModelResolver(Json.mapper().copy());
199+
ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);
200+
201+
Schema<?> schema = modelResolver.resolve(new AnnotatedType(XmlRenamedIsPrefixResponse.class), context, null);
202+
203+
assertNull(modelResolver.objectMapper()
204+
.getSerializationConfig()
205+
.getPropertyNamingStrategy());
206+
assertTrue(schema.getProperties().containsKey("isotonicDrinkOnlyXmlElement"),
207+
"expected isotonicDrinkOnlyXmlElement but got " + schema.getProperties().keySet());
208+
assertEquals(schema.getProperties().size(), 1, "unexpected properties: " + schema.getProperties().keySet());
209+
}
210+
211+
@Test
212+
public void testCustomNamingStrategyAppliedToGetAndIsPrefixedRecordComponents() {
213+
ModelResolver modelResolver = new ModelResolver(
214+
Json.mapper().copy().setPropertyNamingStrategy(new CustomPrefixStrategy()));
215+
ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);
216+
217+
Schema<?> schema = modelResolver.resolve(new AnnotatedType(PidLookupResponse.class), context, null);
218+
219+
assertTrue(schema.getProperties().containsKey("custom_familyName"));
220+
assertTrue(schema.getProperties().containsKey("custom_expiryDate"));
221+
assertTrue(schema.getProperties().containsKey("custom_issuanceDate"),
222+
"expected custom_issuanceDate but got " + schema.getProperties().keySet());
223+
assertTrue(schema.getProperties().containsKey("custom_issuingCountry"),
224+
"expected custom_issuingCountry but got " + schema.getProperties().keySet());
225+
assertTrue(schema.getProperties().containsKey("custom_issuingAuthority"),
226+
"expected custom_issuingAuthority but got " + schema.getProperties().keySet());
227+
assertTrue(schema.getProperties().containsKey("custom_getawayDate"),
228+
"expected custom_getawayDate but got " + schema.getProperties().keySet());
229+
assertEquals(schema.getProperties().size(), 6, "unexpected properties: " + schema.getProperties().keySet());
230+
}
231+
}

0 commit comments

Comments
 (0)