Spring Rest Controller Return Specific Fields
Asked Answered
S

4

23

I've been going through my head the best way to design a JSON API using Spring MVC. As we all know IO is expensive, and thus I don't want to make the client make several API calls to get what they need. However at the same time I don't necessarily want to return the kitchen sink.

As an example I was working on a game API similar to IMDB but for video games instead.

If I returned everything connected to Game it would look something like this.

/api/game/1

{
    "id": 1,
    "title": "Call of Duty Advanced Warfare",
    "release_date": "2014-11-24",
    "publishers": [
        {
            "id": 1,
            "name": "Activision"
        }
    ],
    "developers": [
        {
            "id": 1,
            "name": "Sledge Hammer"
        }
    ],
    "platforms": [
        {
            "id": 1,
            "name": "Xbox One",
            "manufactorer": "Microsoft",
            "release_date": "2013-11-11"
        },
        {
            "id": 2,
            "name": "Playstation 4",
            "manufactorer": "Sony",
            "release_date": "2013-11-18"
        },
        {
            "id": 3,
            "name": "Xbox 360",
            "manufactorer": "Microsoft",
            "release_date": "2005-11-12"
        }
    ],
    "esrbRating": {
        "id": 1,
        "code": "T",
        "name": "Teen",
        "description": "Content is generally suitable for ages 13 and up. May contain violence, suggestive themes, crude humor, minimal blood, simulated gambling and/or infrequent use of strong language."
    },
    "reviews": [
        {
            "id": 1,
            "user_id": 111,
            "rating": 4.5,
            "description": "This game is awesome"
        }
    ]
}

However they may not need all this information, but then again they might. Making calls for everything seems like a bad idea from I/O and performance.

I thought about doing it by specifying include parameter in the requests.

Now for example if you did not specify any includes all you would get back is the following.

{
    "id": 1,
    "title": "Call of Duty Advanced Warfare",
    "release_date": "2014-11-24"
}

However it you want all the information your requests would look something like this.

/api/game/1?include=publishers,developers,platforms,reviews,esrbRating

This way the client has the ability to specify how much information they want. However I'm kind of at a loss the best way to implement this using Spring MVC.

I'm thinking the controller would look something like this.

public @ResponseBody Game getGame(@PathVariable("id") long id, 
    @RequestParam(value = "include", required = false) String include)) {

        // check which include params are present

        // then someone do the filtering?
}

I'm not sure how you would optionally serialize the Game object. Is this even possible. What is the best way to approach this in Spring MVC?

FYI, I am using Spring Boot which includes Jackson for serialization.

Scribner answered 31/5, 2015 at 14:49 Comment(5)
Seems like you do some premature optimization here. Is there really so much data in your entity that you need to filter it on the client's request? Based on what you've shown, you will overly complicate both client and server and break the RESTfullness of your service, while not saving much IO.Justificatory
In the case of my example, I agree this is definitely overkill. Lets just say for example sake though that returns the Game object would result in a huge JSON object, are you saying it would be better to do /game/1/reviews, instead of /game/1?include=reviews?Scribner
if the object is huge, then I would request the collections from separate subresourses, because the overhead of issuing several requests would be small anyway in relation to total volume of transferred data.Justificatory
#23101760Hutchens
Just curious, have you tried using @JsonFilter?Maintain
N
19

Instead of returning a Game object, you could serialize it as as a Map<String, Object>, where the map keys represent the attribute names. So you can add the values to your map based on the include parameter.

@ResponseBody
public Map<String, Object> getGame(@PathVariable("id") long id, String include) {

    Game game = service.loadGame(id);
    // check the `include` parameter and create a map containing only the required attributes
    Map<String, Object> gameMap = service.convertGameToMap(game, include);

    return gameMap;

}

As an example, if you have a Map<String, Object> like this:

gameMap.put("id", game.getId());
gameMap.put("title", game.getTitle());
gameMap.put("publishers", game.getPublishers());

It would be serialized like this:

{
  "id": 1,
  "title": "Call of Duty Advanced Warfare",
  "publishers": [
    {
        "id": 1,
        "name": "Activision"
    }
  ]
}
Nace answered 31/5, 2015 at 15:20 Comment(4)
Doing this with a custom service doesn't sound like a very good idea to me, is there any "Spring" way of doing this?Warlock
@Warlock you could also search how to serialise fields based on their name (using Jackson) or use something like GraphQL. Other than that, I'm not sure if there is a "spring" way to achieve the same result.Nace
How can we convert Game class to Map?Rig
Is it possible to provide an example or expand the answer with how to implement the 'service.convertGameToMap(game, include)' method? What is the best practice to include it, etc.Reduced
P
9

Being aware that my answer comes quite late: I'd recommend to look at Projections.

What you're asking for is what projections are about.

Since you're asking about Spring I'd give this one a try: https://docs.spring.io/spring-data/rest/docs/current/reference/html/#projections-excerpts

A very dynamic way for providing different projections on demand is offered by GraphQL. I just came across a very helpful article about how to use GraphQL with SpringBoot: https://www.graphql-java.com/tutorials/getting-started-with-spring-boot/

Proteus answered 14/10, 2018 at 21:21 Comment(2)
I don't think this answers OP's question. First, you'd need to use Sprint Data Rest (which has not been specified by OP) meaning exposing Spring @Repository directly. Also, how do you handle which projection to return dynamically ? AFAIK, Projection is just the way that Spring Data Rest provides to handle DTO/client representation but does not handle the dynamic part of resource expansionChinaware
@Blockost: Projection is the technical term for the mapping of a set into a subset. In the first instance this term is not coupled to the Spring framework at all. Since the OP explicitly mentioned the Spring context I also added a link to the Spring implementation of projections. Not more and not less.Proteus
B
1

Looks there is always quite a lot of manual work. If you use some persistence abstraction you can have less work compared to plain SpringJDBC (JdbcTemplate). Also depends if your model is aligned with database column names. There are nice series about Query Languages e.g. QueryDSL: https://www.baeldung.com/rest-search-language-spring-jpa-criteria.

Using SpringRest & QueryDSL you can end up with something like this:

Rest controller:

//...
import com.querydsl.core.types.Path;
import com.querydsl.core.types.dsl.BooleanExpression;
//...
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
//...

@ApiOperation("Returns list of all users")
@GetMapping(value = "/users", produces = {MediaType.APPLICATION_JSON_VALUE})
@ResponseStatus(HttpStatus.OK)
public Page<UsersRest> getUsers(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "10") int size,
        @RequestParam(defaultValue = "userId,desc") String[] sort,
        @RequestParam(required = false) Optional<String> search,
        @RequestParam(required = false) Optional<String> fields) {

    Sort sorting = parser.parseSortingParameters(sort);
    PageRequest pageable = PageRequest.of(page, size, sorting);
    // search
    BooleanExpression searchPredicate = parser.parseSearchParameter(search);
    // requested columns
    Path[] columns = parser.parseFieldsParameter(fields);

    Page<User> userPage = userService.getAllUsers(pageable, searchPredicate, columns);

    return new PageImpl<>(userPage, userPage.getPageable(), userPage.getTotalElements());
}

Repository class:

//...
import com.querydsl.core.QueryResults;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.Path;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.sql.Configuration;
import com.querydsl.sql.SQLQuery;
import com.querydsl.sql.SQLQueryFactory;
import com.querydsl.sql.spring.SpringConnectionProvider;
import com.querydsl.sql.spring.SpringExceptionTranslator;
//...
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
//...

@Transactional(readOnly = true)
public Page<User> findAll(Pageable pageable, BooleanExpression searchPredicate, Path[] columns) {
    final var userTable = new QUser("USER");

    // Alternatively (if column names are aligned with field names - so manual mapping is not needed) can be used
    // Expressions.path constructor to dynamically create path:
    // http://www.querydsl.com/static/querydsl/latest/reference/html/ch03.html
    OrderSpecifier<?>[] order = convertToDslOrder(pageable.getSort());

    SQLQuery<Tuple> sql = queryFactory
            .select(columns)
            .from(userTable)
            .where(searchPredicate)
            .orderBy(order);

    sql.offset(pageable.getPageNumber());
    sql.limit(pageable.getPageSize());

    QueryResults<Tuple> queryResults = sql.fetchResults();

    final long totalCount = queryResults.getTotal();
    List<Tuple> results = queryResults.getResults();
    List<User> users = userRowMapper(userTable, results);

    return new PageImpl<>(users, pageable, totalCount);
}
Bedridden answered 10/6, 2022 at 6:19 Comment(0)
J
0

Solution 1: Add @JsonIgnore to the variable you dont want to include in API response (in the model)

@JsonIgnore
    private Set<Student> students;

Solution 2: Remove the getters for the variables you don't want included.

If you need them else where, use different format for the getters so spring doesn't know about it.

Jermaine answered 28/8, 2021 at 8:25 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.