RecordPropertyNamingStrategyTest.java

package io.swagger.v3.java17.resolving;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import io.swagger.v3.core.converter.AnnotatedType;
import io.swagger.v3.core.converter.ModelConverterContextImpl;
import io.swagger.v3.core.jackson.ModelResolver;
import io.swagger.v3.core.util.Json;
import io.swagger.v3.oas.models.media.Schema;
import org.testng.annotations.Test;

import javax.xml.bind.annotation.XmlElement;

import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertNull;
import static org.testng.Assert.assertTrue;

/**
 * Reproduces the property-naming inconsistency reported in
 * springdoc/springdoc-openapi#3293: when a {@link PropertyNamingStrategies} such as
 * {@code SNAKE_CASE} is configured, record components whose names start with the
 * {@code is}/{@code get} accessor prefixes followed by a lower-case letter (e.g.
 * {@code issuanceDate}) were not being translated, because the
 * "avoid clobbering get/is names" hack in {@code ModelResolver} replaced the
 * Jackson-resolved (translated) name with the raw component name.
 */
public class RecordPropertyNamingStrategyTest {

    public record PidLookupResponse(
            String familyName,
            String expiryDate,
            String issuanceDate,
            String issuingCountry,
            String issuingAuthority,
            String getawayDate
    ) {
    }

    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
    public record JsonNamingPidLookupResponse(
            String familyName,
            String expiryDate,
            String issuanceDate,
            String issuingCountry,
            String issuingAuthority,
            String getawayDate
    ) {
    }

    public record MixInPidLookupResponse(
            String familyName,
            String expiryDate,
            String issuanceDate,
            String issuingCountry,
            String issuingAuthority,
            String getawayDate
    ) {
    }

    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
    public abstract static class SnakeCaseMixIn {
    }

    public record BooleanStatusResponse(
            boolean isActive
    ) {
    }

    public static class LegacyIsPersistentResponse {

        public boolean is_persistent() {
            return true;
        }

        public String gettersAndHaters() {
            return null;
        }
    }

    public static class XmlRenamedIsPrefixResponse {

        @XmlElement(name = "beerDrinkXmlElement")
        private String isotonicDrinkOnlyXmlElement;

        public String getIsotonicDrinkOnlyXmlElement() {
            return isotonicDrinkOnlyXmlElement;
        }
    }

    public static class CustomPrefixStrategy extends PropertyNamingStrategies.NamingBase {

        @Override
        public String translate(String propertyName) {
            return propertyName == null ? null : "custom_" + propertyName;
        }
    }

    @Test
    public void testSnakeCaseNamingStrategyAppliedToRecordComponents() {
        ModelResolver modelResolver = new ModelResolver(
                Json.mapper().copy().setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE));
        ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);

        Schema<?> schema = modelResolver.resolve(new AnnotatedType(PidLookupResponse.class), context, null);

        assertTrue(schema.getProperties().containsKey("family_name"));
        assertTrue(schema.getProperties().containsKey("expiry_date"));
        assertTrue(schema.getProperties().containsKey("issuance_date"),
                "expected issuance_date but got " + schema.getProperties().keySet());
        assertTrue(schema.getProperties().containsKey("issuing_country"),
                "expected issuing_country but got " + schema.getProperties().keySet());
        assertTrue(schema.getProperties().containsKey("issuing_authority"),
                "expected issuing_authority but got " + schema.getProperties().keySet());
        assertTrue(schema.getProperties().containsKey("getaway_date"),
                "expected getaway_date but got " + schema.getProperties().keySet());
        assertEquals(schema.getProperties().size(), 6, "unexpected properties: " + schema.getProperties().keySet());
    }

    @Test
    public void testClassLevelJsonNamingAppliedToRecordComponents() {
        ModelResolver modelResolver = new ModelResolver(Json.mapper().copy());
        ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);

        Schema<?> schema = modelResolver.resolve(new AnnotatedType(JsonNamingPidLookupResponse.class), context, null);

        assertNull(modelResolver.objectMapper()
                        .getSerializationConfig()
                        .getPropertyNamingStrategy());
        assertTrue(schema.getProperties().containsKey("family_name"));
        assertTrue(schema.getProperties().containsKey("expiry_date"));
        assertTrue(schema.getProperties().containsKey("issuance_date"),
                "expected issuance_date but got " + schema.getProperties().keySet());
        assertTrue(schema.getProperties().containsKey("issuing_country"),
                "expected issuing_country but got " + schema.getProperties().keySet());
        assertTrue(schema.getProperties().containsKey("issuing_authority"),
                "expected issuing_authority but got " + schema.getProperties().keySet());
        assertTrue(schema.getProperties().containsKey("getaway_date"),
                "expected getaway_date but got " + schema.getProperties().keySet());
        assertEquals(schema.getProperties().size(), 6, "unexpected properties: " + schema.getProperties().keySet());
    }

    @Test
    public void testMixInJsonNamingAppliedToRecordComponents() {
        ModelResolver modelResolver = new ModelResolver(Json.mapper().copy()
                .addMixIn(MixInPidLookupResponse.class, SnakeCaseMixIn.class));
        ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);

        Schema<?> schema = modelResolver.resolve(new AnnotatedType(MixInPidLookupResponse.class), context, null);

        assertNull(modelResolver.objectMapper()
                .getSerializationConfig()
                .getPropertyNamingStrategy());
        assertTrue(schema.getProperties().containsKey("family_name"));
        assertTrue(schema.getProperties().containsKey("expiry_date"));
        assertTrue(schema.getProperties().containsKey("issuance_date"),
                "expected issuance_date but got " + schema.getProperties().keySet());
        assertTrue(schema.getProperties().containsKey("issuing_country"),
                "expected issuing_country but got " + schema.getProperties().keySet());
        assertTrue(schema.getProperties().containsKey("issuing_authority"),
                "expected issuing_authority but got " + schema.getProperties().keySet());
        assertTrue(schema.getProperties().containsKey("getaway_date"),
                "expected getaway_date but got " + schema.getProperties().keySet());
        assertEquals(schema.getProperties().size(), 6, "unexpected properties: " + schema.getProperties().keySet());
    }

    @Test
    public void testBooleanIsRecordComponentWithSnakeCaseNamingStrategy() {
        ModelResolver modelResolver = new ModelResolver(
                Json.mapper().copy().setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE));
        ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);

        Schema<?> schema = modelResolver.resolve(new AnnotatedType(BooleanStatusResponse.class), context, null);

        assertTrue(schema.getProperties().containsKey("is_active"),
                "expected is_active but got " + schema.getProperties().keySet());
        assertEquals(schema.getProperties().size(), 1, "unexpected properties: " + schema.getProperties().keySet());
    }

    @Test
    public void testLegacyIsPersistentNameWithNoNamingStrategy() {
        ModelResolver modelResolver = new ModelResolver(Json.mapper().copy());
        ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);

        Schema<?> schema = modelResolver.resolve(new AnnotatedType(LegacyIsPersistentResponse.class), context, null);

        assertNull(modelResolver.objectMapper()
                .getSerializationConfig()
                .getPropertyNamingStrategy());
        assertTrue(schema.getProperties().containsKey("is_persistent"),
                "expected is_persistent but got " + schema.getProperties().keySet());
        assertTrue(schema.getProperties().containsKey("gettersAndHaters"),
                "expected gettersAndHaters but got " + schema.getProperties().keySet());
        assertEquals(schema.getProperties().size(), 2, "unexpected properties: " + schema.getProperties().keySet());
    }

    @Test
    public void testXmlRenamedIsPrefixFieldKeepsOriginalNameWithNoNamingStrategy() {
        ModelResolver modelResolver = new ModelResolver(Json.mapper().copy());
        ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);

        Schema<?> schema = modelResolver.resolve(new AnnotatedType(XmlRenamedIsPrefixResponse.class), context, null);

        assertNull(modelResolver.objectMapper()
                .getSerializationConfig()
                .getPropertyNamingStrategy());
        assertTrue(schema.getProperties().containsKey("isotonicDrinkOnlyXmlElement"),
                "expected isotonicDrinkOnlyXmlElement but got " + schema.getProperties().keySet());
        assertEquals(schema.getProperties().size(), 1, "unexpected properties: " + schema.getProperties().keySet());
    }

    @Test
    public void testCustomNamingStrategyAppliedToGetAndIsPrefixedRecordComponents() {
        ModelResolver modelResolver = new ModelResolver(
                Json.mapper().copy().setPropertyNamingStrategy(new CustomPrefixStrategy()));
        ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);

        Schema<?> schema = modelResolver.resolve(new AnnotatedType(PidLookupResponse.class), context, null);

        assertTrue(schema.getProperties().containsKey("custom_familyName"));
        assertTrue(schema.getProperties().containsKey("custom_expiryDate"));
        assertTrue(schema.getProperties().containsKey("custom_issuanceDate"),
                "expected custom_issuanceDate but got " + schema.getProperties().keySet());
        assertTrue(schema.getProperties().containsKey("custom_issuingCountry"),
                "expected custom_issuingCountry but got " + schema.getProperties().keySet());
        assertTrue(schema.getProperties().containsKey("custom_issuingAuthority"),
                "expected custom_issuingAuthority but got " + schema.getProperties().keySet());
        assertTrue(schema.getProperties().containsKey("custom_getawayDate"),
                "expected custom_getawayDate but got " + schema.getProperties().keySet());
        assertEquals(schema.getProperties().size(), 6, "unexpected properties: " + schema.getProperties().keySet());
    }
}