Skip to content

Commit 72447da

Browse files
committed
Added article about json filter in Spring Boot
1 parent e2e5852 commit 72447da

File tree

5 files changed

+283
-6
lines changed

5 files changed

+283
-6
lines changed

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,4 +254,4 @@ DEPENDENCIES
254254
jekyll-redirect-from
255255

256256
BUNDLED WITH
257-
1.17.2
257+
2.2.19
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
---
2+
layout: post
3+
title: "Filtering JSON Response with SpringBoot and Jackson"
4+
share-img: /img/content/springboot-json-filter-share.jpg
5+
image: /img/content/springboot-json-filter-share.jpg
6+
bigimg: /img/content/springboot-json-filter-title.jpg
7+
permalink: /filtering-json-springboot
8+
gh-repo: eiselems/springboot-field-filtering
9+
gh-badge: [star, fork, follow]
10+
tags: [spring-boot]
11+
excerpt: "There are many APIs where you can specify the parts of the response you are really interested in. Let's have a look at how we could build such a filter with SpringBoot"
12+
---
13+
14+
You might be ending here by a google search. I hope you find what you were coming for.
15+
This article is about how to set up a SpringBoot Application to filter parts of the JSON response on the server-side.
16+
17+
This article is inspired by a requirement I had at work.
18+
The approach has some downsides but it does exactly what it should, it allows a consumer to specify in which top-level elements he is interested in.
19+
20+
## What to expect?
21+
22+
We will implement a simple GET Operation that returns a DTO (Data Transfer Object, also POJO).
23+
By adding a `fields` query parameter our consumers will be able to filter the top-level elements on the server-side.
24+
The API will only return the fields the consumer asked for.
25+
26+
```bash
27+
GET http://localhost:8080/users/99?fields=firstName,lastName
28+
Accept: application/json
29+
30+
will give you:
31+
{
32+
"firstName": "John",
33+
"lastName": "Doe"
34+
}
35+
36+
instead of:
37+
{
38+
"firstName": "John",
39+
"lastName": "Doe",
40+
"birthday": "1990-12-31",
41+
"profession": "Programmer",
42+
"createdAt": "2021-07-06T01:37:50.32079+02:00"
43+
}
44+
```
45+
46+
## Let's have a quick look at the application
47+
48+
Our application is a simple SpringBoot Application created with https://start.spring.io. Flavor for the tutorial will be Maven/Kotlin.
49+
50+
What I really like about the usage of Kotlin for tutorials is that Kotlin allows to have almost all of the implementation within a few files. Let's have a look:
51+
52+
```java
53+
UserService.kt
54+
55+
56+
@Service
57+
class UserService {
58+
59+
fun getUser(id: String): User {
60+
// in reality this comes from a DB
61+
return User(
62+
id = id,
63+
firstName = "John",
64+
lastName = "Doe",
65+
birthday = LocalDate.of(1990, Month.DECEMBER, 31),
66+
profession = "Programmer",
67+
createdAt = ZonedDateTime.now()
68+
)
69+
}
70+
}
71+
72+
data class User(
73+
val id: String,
74+
val firstName: String,
75+
val lastName: String,
76+
val birthday: LocalDate,
77+
val profession: String,
78+
val createdAt: ZonedDateTime
79+
)
80+
```
81+
82+
```java
83+
UserController.kt
84+
85+
@RestController
86+
class UserController(
87+
val userService: UserService
88+
) {
89+
@GetMapping("/users/{id}")
90+
fun getUser(@PathVariable("id") id: String) = UserDto.fromUser(userService.getUser(id))
91+
}
92+
93+
data class UserDto(
94+
val firstName: String,
95+
val lastName: String,
96+
val birthday: LocalDate,
97+
val profession: String,
98+
val createdAt: ZonedDateTime
99+
) {
100+
companion object {
101+
fun fromUser(user: User) = UserDto(
102+
firstName = user.firstName,
103+
lastName = user.lastName,
104+
birthday = user.birthday,
105+
profession = user.profession,
106+
createdAt = user.createdAt
107+
)
108+
}
109+
}
110+
```
111+
112+
I also threw a `Configuration` class for our `ObjectMapper` into the mix since that never hurts.
113+
This is doing more than needed for this tutorial, but I wanted to include some kind of sane configuration that you could also use for deserializing content. Most input here is for adding only non-null fields and to properly format the `createdAt` date of the user.
114+
115+
```java
116+
@Configuration
117+
class ObjectMapperConfiguration {
118+
119+
@Primary
120+
@Bean
121+
fun configure() =
122+
ObjectMapper().apply {
123+
setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
124+
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
125+
configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, true)
126+
configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE, true)
127+
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
128+
129+
val javaTimeModule = JavaTimeModule().apply {
130+
this.addDeserializer(
131+
LocalDateTime::class.java,
132+
LocalDateTimeDeserializer(DateTimeFormatter.ISO_DATE_TIME)
133+
)
134+
}
135+
136+
registerModules(javaTimeModule, KotlinModule())
137+
}
138+
}
139+
```
140+
141+
That is all for now. As you can see we have a typical three-tier architecture (Controller/Service/Repository). The repository is omitted since adding a database has no benefit for this tutorial.
142+
143+
The `/users/{id}` endpoint will return a `UserDto` that gets created based on the model the Controller receives from the Service. Hopefully, things are looking familiar.
144+
145+
We can quickly start the service and query
146+
```
147+
GET http://localhost:8080/users/99
148+
Accept: application/json
149+
```
150+
which will return information about a User consisting of a lot of fields.
151+
152+
## Starting with what you came for
153+
154+
For our example, the Service Class will stay exactly the way it is. We will modify our Controller to only return the fields a consumer of our API provided.
155+
156+
### Add the query parameter
157+
158+
That is quite easy, isn't it? Just add:
159+
160+
```
161+
@RequestParam(required = false, value = "fields") fields: String?
162+
```
163+
164+
on top of our existing `GetMapping`. This adds an optional `field` query parameter that can get added at the end of the URL. I decided to make it optional since this won't affect the behavior of our API for existing API consumers - always something you should keep in mind.
165+
166+
Sadly the code will get a bit more verbose now, but this is the price we have to pay.
167+
168+
### Bring the pre-requisites into place
169+
We will be using Jackson's `JsonFilter` functionality that allows us to remove parts while serializing our DTO.
170+
171+
#### Let's add the annotation on top of our `UserDto`-class:
172+
173+
```java
174+
const val FIELDS_FILTER = "FIELDS_FILTER"
175+
176+
@JsonFilter(FIELDS_FILTER)
177+
data class UserDto(
178+
...
179+
```
180+
181+
Having the name of the filter in a constant is in general a good idea. It prevents errors by mistyping in other places.
182+
183+
#### Use MappingJacksonValue to filter our response
184+
185+
`MappingJacksonValue` is a wrapper for DTOs that allows many operations on top of them. One of them is adding filters.
186+
187+
We will add a `filterOutAllExcept`-filter in case the `fields`-parameter we added earlier is populated.
188+
There we will pass the fields we received in our call as a `Set`. This needs to get wrapped with a `SimpleFilterProvider` for which we need to add our filter. Have a look below, i think the code might be easier to read than the text here.
189+
190+
```java
191+
@GetMapping("/users/{id}")
192+
fun getUser(
193+
@PathVariable("id") id: String,
194+
@RequestParam(required = false, value = "fields") fields: String?
195+
): MappingJacksonValue {
196+
197+
val fromUser = UserDto.fromUser(userService.getUser(id))
198+
199+
val mappingJacksonValue = MappingJacksonValue(fromUser)
200+
201+
if (!fields.isNullOrEmpty()) {
202+
mappingJacksonValue.filters =
203+
SimpleFilterProvider().addFilter(
204+
FIELDS_FILTER,
205+
SimpleBeanPropertyFilter.filterOutAllExcept(fields.split(",").toSet())
206+
)
207+
}
208+
return mappingJacksonValue
209+
}
210+
```
211+
212+
The only thing that is not perfect: Once your DTO has a `JsonFilter` annotation, you are required to apply a filter in all cases. This means for us that even if we don't get a filter, we still need to apply SOME filter to our response. The implementation above is dealing with the requirement to only return the fields given in the `fields` parameter.
213+
214+
### Let's take it for a test run
215+
216+
Start our application and tinker around with the request.
217+
Let's take the request from before and add a fields parameter:
218+
219+
```java
220+
GET http://localhost:8080/users/99?fields=firstName,lastName
221+
Accept: application/json
222+
```
223+
224+
will exactly return:
225+
226+
```
227+
{
228+
"firstName": "John",
229+
"lastName": "Doe"
230+
}
231+
```
232+
233+
Awesome. Let's remove the fields parameter again from our request and see what happens.
234+
What happens when we invoke our application?
235+
236+
We receive an HTTP 500 with the following exception:
237+
238+
```
239+
InvalidDefinitionException: Cannot resolve PropertyFilter with id 'FIELDS_FILTER'; no FilterProvider configured
240+
```
241+
242+
This is what I was talking about earlier, that for all cases there needs to be a filter.
243+
Before you start to modify your controller - I got another solution.
244+
245+
I added the following block of code to the `ObjectMapperConfiguration`:
246+
247+
```
248+
// this filter allows to have a JSON Filter Annotation on top of a DTO and be able to serialize it without extra handling
249+
setFilterProvider(SimpleFilterProvider().apply {
250+
this.defaultFilter = SimpleBeanPropertyFilter.serializeAll()
251+
})
252+
```
253+
254+
This applies a `serializeAll` filter as default filter for all requests that had no filter applied.
255+
In effect: Every time we have our MappingJacksonValue not getting a filter applied, it will just return all fields.
256+
257+
Awesome!
258+
259+
## What we achieved
260+
261+
We added a `filter` parameter to our API that allows consumers to filter the response in advance.
262+
This has benefits for the size of the payload. Our requirement was data protection, our consumers should be able to prevent receiving data they don't need. Especially user data is really sensitive!
263+
264+
## Limitations / Known issues
265+
266+
In my opinion, this approach has two small issues:
267+
1. The filtering is only applied on the Controller level - your service still has to work in order to fetch all the required information. For this minimal example, it is not an issue. It could be one if the DTO in question is rather large and a bit more expensive to calculate.
268+
2. Filtering only works for top-level fields. That was not an issue for our use case but it could be for you. If you need more advanced field filters for nested objects. There are a few projects on Github just giving you that, or maybe you could have a look at `GraphQL`.
269+
270+
## Closing
271+
272+
I hope you liked the article and could learn something from it. I was quite impressed how neat the implementation turned out. Things can be really awesome if they just work.
273+
274+
You can leave a comment here or reach out on Twitter.<br><a href="https://twitter.com/intent/tweet?screen_name=eiselems&ref_src=twsrc%5Etfw" class="twitter-mention-button" data-size="large" data-text="Filtering JSON Responses with SpringBoot: #programmerfriend" data-related="eiselems" data-show-count="false">Tweet to @eiselems</a><script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
275+
276+
All the source code can be found within the [Github Repository](https://github.com/eiselems/springboot-field-filtering).

docker-compose.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
version: "3"
2-
1+
version: '3'
32
services:
43
jekyll:
5-
image: jekyll/jekyll
4+
image: starefossen/github-pages
5+
environment:
6+
- "JEKYLL_GITHUB_TOKEN:${JEKYLL_GITHUB_TOKEN}"
67
ports:
78
- "4000:4000"
8-
command: jekyll serve --incremental --watch --host "0.0.0.0" --config _config.yml
99
volumes:
10-
- .:/srv/jekyll
10+
- ./:/usr/src/app
11+
tty: true
226 KB
Loading
348 KB
Loading

0 commit comments

Comments
 (0)