Home > Back-end >  How can I force Spring to serialize my controller's @ResponseBody as the method's declared
How can I force Spring to serialize my controller's @ResponseBody as the method's declared

Time:12-07

I am attempting to use interfaces to define flexible response bodies from my Spring controllers.

What I expect: When I call an endpoint using Curl/Postman/etc, I should receive JSON objects that contain only the fields visible in the interface that the controller returns.

What I'm getting: When I call either endpoint, I receive JSON objects with every field defined in my entity.

Let's say my entity looks like this:

MyEntity.java

public class MyEntity implements ListEntityResponse, GetEntityResponse {
    int dbid;
    String name;
    String description;

    public int getDbid() { return dbid; }
    public String getName() { return name; }
    public String getDescription() { return description; }
}

Let's say MyEntity has many more fields that include complex data types that aren't suitable for serialization as part of a large list, or for certain other use cases. To solve this problem, I've created interfaces to limit which fields are visible in the response object. In this example, the first interface only defines two of the three getters, while the second interface defines all of them.

ListEntityResponse.java

public interface ListEntityResponse {
    int getDbid();
    String getName();
}

GetEntityResponse

public interface GetEntityResponse {
    int getDbid();
    String getName();
    String getDescription();
}

And finally, here are my controllers. The important part is that each defines its return type as one of the interfaces:

ListEntityController.java

    @GetMapping(path="/{name}")
    public @ResponseBody List<ListEntityResponse> getList() {
        return handler.getList(name);
    }

GetEntityController.java

    @GetMapping(path="/{name}")
    public @ResponseBody GetEntityResponse getByName(@PathVariable("name") String name) {
        return handler.getByName(name);
    }

To recap, if we assume that our handler returns MyEntity objects, then I want that object to be serialized by Spring as the interface defined in the controller's return type. E.G. each JSON object in the list returned by the ListEntityController should have only the dbid and name fields. Unfortunately, that's not happening, and the returned JSON objects have every field available despite being masked as interface objects.

I have attempted to add @JsonSerialize(as = ListEntityResponse.class) to my first interface, and a similar annotation to the second. This works only if the entity implements just one of those interfaces. Once the entity implements multiple interfaces each annotated with @JsonSerialize, Spring will serialize it as the first interface in the list regardless of the controller's return type.

How can I force Spring to always serialize its controller's responses as the controller function's return type?

Edit: I am trying to find a solution that does not require me to use @JsonIgnore or @JsonIgnoreProperties. Additionally, I am trying to find a solution that does not require me to add @JsonView to my entity classes. I am willing to use the @JsonView annotation in the interfaces but don't see an elegant way to do so.

CodePudding user response:

I'm sorry for leaving this as an answer, I've done this due to rep<50 (no comments). If you are not interested in Jackson json-annotations usage, you can start with Jackson ObjectMapper addition to your controller/logic layer. It can help you with entities wrapping. What's more, you can try to use custom wrapping logics and ResponseEntity<T> as a return value.


Easy level

If we go deeper, there is a way for custom serialization/deserialization with overriding ObjectMapper settings and creation.


Hard level

If dig to the end, we can use JRA (Java Reflection API) to create our own annotations and serialize entities as we want. I've never done this before, 'cause I had no case to use this. You can read about it here.


Hope my vision helps.

CodePudding user response:

How can I force Spring to always serialize its controller's responses as the controller function's return type?

Please note that I am not interested in using @JsonIgnore, @JsonIgnoreProperties, or @JsonView to provide the view masking that I require. They do not fit my use case.

One of the options would be to create a thin wrapper over MyEntity class which would be responsible for providing the required serialization-shape.

Every shape would be represented by it's own wrapper, implemented as a single-field class. To specify serialization-shape we can use as property of the @JsonSerialize annotation, by assigning the target interface as a value. And since we don't need the wrapper itself to reflected in the resulting JSON, we can make use of the @JsonUnwrapped annotation.

Here's a wrapper for GetEntityResponse shape:

@AllArgsConstructor
public class GetEntityResponseWrapper implements EntityWrapper {
    @JsonSerialize(as = GetEntityResponse.class)
    @JsonUnwrapped
    private MyEntity entity;
}

And that's a wrapper for ListEntityResponse shape:

@AllArgsConstructor
public class ListEntityResponseWrapper implements EntityWrapper {
    @JsonSerialize(as = ListEntityResponse.class)
    @JsonUnwrapped
    private MyEntity entity;
}

Basically, we have finished with serialization logic.

And you can use these lean classes in your controllers as is. But to make the solution more organized and easier to extend, I've introduced a level of abstraction. As you probably noticed both wrapper-classes are implementing EntityWrapper interface, its goal is to abstract away the concrete implementation representing shapes from the code in Controllers/Services.

public interface EntityWrapper {
    enum Type { LIST_ENTITY, GET_ENTITY } // each type represents a concrete implementation
    
    static EntityWrapper wrap(Type type, MyEntity entity) {
        return switch (type) {
            case LIST_ENTITY -> new ListEntityResponseWrapper(entity);
            case GET_ENTITY -> new GetEntityResponseWrapper(entity);
        };
    }
    
    static List<EntityWrapper> wrapAll(Type type, MyEntity... entities) {
        
        return Arrays.stream(entities)
            .map(entity -> wrap(type, entity))
            .toList();
    }
}

Methods EntityWrapper.wrap() and EntityWrapper.wrapAll() are uniform entry points. We can use an enum to represent the target type.

Note that EntityWrapper needs to be used in the return types in your Controller.

Here the two dummy end-points I've used for testing (I've removed the path-variables since they are not related to what I'm going to demonstrate):

@GetMapping("/a")
public List<EntityWrapper> getList() {
    // your logic here
    
    return EntityWrapper.wrapAll(
        EntityWrapper.Type.LIST_ENTITY,
        new MyEntity(1, "Alice", "A"),
        new MyEntity(2, "Bob", "B"),
        new MyEntity(3, "Carol", "C")
    );
}

@GetMapping("/b")
public EntityWrapper getByName() {
    // your logic here
    
    return EntityWrapper.wrap(
        EntityWrapper.Type.GET_ENTITY,
        new MyEntity(2, "Bob", "B")
    );
}

Response of the end-point "/a" (only two properties have been serialized):

[
    {
        "name": "Alice",
        "dbid": 1
    },
    {
        "name": "Bob",
        "dbid": 2
    },
    {
        "name": "Carol",
        "dbid": 3
    }
]

Response of the end-point "/b" (all three properties have been serialized):

{
    "name": "Bob",
    "description": "B",
    "dbid": 2
}
  • Related