Map to snake case while using a builder

Viewed 1828

I am receiving json data from a rest call. The keys are all in camel case.

I am able to obtain this data fine from the rest call. But I wish to convert all these keys to snake case cos that's
the version I am sending back to the client that needs my response.

In my configuration, I have the following to map snake case.

@Bean
public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() {
    MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
    converter.setObjectMapper(new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
        .setPropertyNamingStrategy(PropertyNamingStrategy.CAMEL_CASE_TO_LOWER_CASE_WITH_UNDERSCORES));
    return converter;
}

This works if I do not explicitly use @JsonProperty and also stick with getters and setters instead of a builder.
Example, this would work and give me snake case if my beans are declared in following format.

@Getter
@Setter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Layout {
    private final String myBanner;
}

It will not work (will not capture data from rest call) if I use a builder but not use @JsonProperty as follows.

@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonDeserialize(builder = Layout.LayoutBuilder.class)
public class Layout {
    private final String myBanner;
}

This is what I have now which works but is in camel case. I want snake case.

@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonDeserialize(builder = Layout.LayoutBuilder.class)
public class Layout {

    @JsonProperty("myBanner")
    private final String myBanner;
}

I want to stick to using a builder. Thus question is, is there a way around this to use a builder and still get the values in snake case for my response.

Or alternative, a way to recursively loop over all fields in an object including nested objects and switch them all out to be snake cased?

Json data from rest call

{
    "mainData": {
        "groupData": "",
        "benefits": {
            "summary": {
                "title": "",
                "shortCopy": ""
            }
        },
        "simpleLayout": {
            "myBanner": "summary",
            "titles": [
                [
                    "",
                    ""
                ]
            ]
        },
        "maxLayout": {
            "myBanner": "summary",
            "titles": [
                [
                    ""
                ]
            ]
        }
    }
}

Screenshots on not able to get translate method. enter image description here

enter image description here

2 Answers

You can use @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class) annotation.

Work with Project Lombok as well.

For example,

@Getter
@Builder
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class)
public class YourDto {
    private final String myBanner;

    public YourDto(@JsonProperty("myBanner")
                   @JsonAlias("my_banner") String myBanner) {
        this.myBanner = myBanner;
    }
}

Above class can de-serialize following JSON,

{   
    "myBanner": "My own banner.."
}

And serialize to following JSON

{
    "my_banner": "My own banner.."
}

TL;DR: Extend Jackson's PropertyNamingStrategy to translate in both directions differently.

This approach keeps your target-classes (e.g. Layout) untouched (no constructor added, no fields annotated). See "Example" to copy from.

Issues

You are mixing a lot of configuration options here:

  • PropertyNamingStrategy.CAMEL_CASE_TO_LOWER_CASE_WITH_UNDERSCORES
  • @JsonProperty
  • @JsonDeserialize(builder = Layout.LayoutBuilder.class)

1. PropertyNamingStrategy works bidirectional

The PropertyNamingStrategy.CAMEL_CASE_TO_LOWER_CASE_WITH_UNDERSCORES is deprecated since version 2.7 and was replaced by SNAKE_CASE.

⚠️ It might also be misconceived as a bidirectional translation from JSON input fields in "camelCase" to JSON output fields in "snake_case".

But it actually applies this property-name translation from and to SNAKE_CASE for both - deserialization (JSON input) and serialization (JSON output).

So in your first attempt (with builder but without @JsonProperty) it does not read the "camelCase" input because expecting "snake_case", as previously configured on objectMapper

2. JsonProperty overrules PropertyNamingStrategy

In your second attempt (with @JsonProperty("camelCase")) it does read the "camelCase" but also writes "camelCase", because the field-annotation overrides the SNAKE_CASE naming-strategy previously configured on objectMapper.

See similar issue: Lombok with Jackson Deserializer from camelCase

3. Immutable requires a builder to deserialize

Immutable means the fields are final and can't work with setter-deserialization. Only a constructor will work. Thus you decided for Lomboks @Builder together with Jackson's @Deserialize(builder = Layout.LayoutBuilder.class).

Which keeps the class definition simple and consisting only of a field list.

Since the builder code generated by Lombok uses original field-names for build-methods and constructor-parameters, any fields annotated using @JsonProperty and different names would not be considered with those different names for deserialization.

To define a separate constructor for deserialization (with @JsonProperty annotated parameters) is cumbersome and means extra effort for maintenance. That's my concern with rai's answer.

Solving the Property-Naming issue

So you have to get the best of both worlds.

You could extend class PropertyNamingStrategy to have different naming-strategies for deserialization and serialization of a single class.

In a simple case override these methods:

Whereas both mutable and immutable classes use getters for serialization, the setter deserialization only applies to mutable classes.

For immutable classes and constructor-based deserialization (e.g. using the builder-pattern like defined with Lombok's @Builder) you can override the method:

Then annotate the target-class with @JsonNaming

Example

For example when your extended naming-strategy is:

public class CamelToSnakeStrategy extends PropertyNamingStrategy {

    @Override
    public String nameForField(MapperConfig<?> config, AnnotatedField field, String defaultName) {
        return defaultName;  // no translation
    }

    @Override
    public String nameForGetterMethod(MapperConfig<?> config, AnnotatedMethod method, String defaultName) {
        return PropertyNamingStrategy.SNAKE_CASE.translate(defaultName);
    }

    @Override
    public String nameForSetterMethod(MapperConfig<?> config, AnnotatedMethod method, String defaultName) {
        return PropertyNamingStrategy.LOWER_CAMEL_CASE.translate(defaultName);
    }

    @Override
    public String nameForConstructorParameter(MapperConfig<?> config, AnnotatedParameter ctorParam,
            String defaultName) {
        return PropertyNamingStrategy.LOWER_CAMEL_CASE.translate(defaultName);
    }
}

Then annotate your target bean with this @JsonNaming strategy:

@JsonNaming(CamelToSnakeStrategy.class) // use different cased naming: camel for in / snake for out
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonDeserialize(builder = Layout.LayoutBuilder.class)
@Builder
@Getter
public class Layout {
   
    // property default-name is translated 
    private final String myBanner;
}

You could also configure this naming-strategy in a module or at your (global) ObjectMapper:

objectMapper.setPropertyNamingStrategy(CamelToSnakeStrategy.class);

Solving the Builder-Deserialization issue

When deserializing immutable objects, this means fields are final to deny state-modification. Hence setters are no valid operation and only constructors (or builder) can be used to create instances.

See Deserialize Immutable Objects with Jackson.

This means for the naming-strategies:

  • constructors used for deserialization; apply naming-strategy to constructor-parameters
  • getters used for serialization; apply specific naming-strategy to getters

Lombok and Jackson

See related:


See also related questions:

Related