5

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

Fllappy
  • 371
  • 3
  • 11

2 Answers2

4

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.."
}
ray
  • 1,512
  • 4
  • 11
  • Thanks. I've tried this. Believe it won't work without @setter. I can't have setter cos it makes the class mutable. – Fllappy Aug 23 '21 at 18:30
  • I have updated the example.. Check and let me know – ray Aug 23 '21 at 18:48
  • Thanks is an option. But feel not very feasible as can see the json block above has a few more fields and in real the block of json is actually a lot larger. Would be quite code heavy to be writing this way for all the nested beans. – Fllappy Aug 23 '21 at 18:58
  • I get your point. Actually if your DTO class have more that one attributes you can skip the implementing constructor. If DTO has only one attribute constructor is mandatory like the example. (Even though constructor is must, you can skip @JsonAlias("my_banner") part ) – ray Aug 23 '21 at 19:09
1

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:

hc_dev
  • 8,389
  • 1
  • 26
  • 38
  • @Fllappy No, `Layout` is your _target-bean_ which is to be annotated with `@JsonNaming`. The naming-strategy is a base class of Jackson. This needs to be customized = extended. – hc_dev Aug 23 '21 at 20:25
  • Thanks. This is what I was looking for. But .translate() is actually not a valid method for me. Using jackson-databind-2.12.4 . Added screenshots above on error. – Fllappy Aug 24 '21 at 08:58
  • Believe is cos translate is a non static method within a static class. Bit puzzled how you are able to call translate above unless there is some differences in versions. – Fllappy Aug 24 '21 at 09:52
  • Adding CamelToSnakeStrategy as JsonNaming for Layout class actually has no effect. Tried returning empty String for all over rides expecting that to at least break the response. Instead worked fine returning response in camel case. – Fllappy Aug 24 '21 at 10:14