Map deep and complex JSONs to POJOs using configurable annotations.
Need to change some annotation, but you don't have time to create a release and deploy it? Don't worry, just update the properties and you're good to go!
Work in progress! This is just a proof of concept where the two most basic annotations (
@MapFrom
and@MapEachFrom
) are already working, but there are more annotations that have to be implemented.Besides that, the code should be improved. I created this in a couple of hours and my main goal was to get something working, so there are things that could definitely be done better.
Also, check the
TODO
in theJsonMapping
class, where I talk about another implementation that could allow for a simpler code.
These are the most basic annotations and the ones you will use the most:
@MapFrom
: maps a field from one or multiple possible field paths (the first that works)@MapEachFrom
: maps multiple objects from an array
Assuming you have DTOs like this:
@Data
public class HackerNewsPosts {
@MapEachFrom("/hits")
private List<HackerNewsPost> posts;
}
@Data
public class HackerNewsPost {
@MapFrom("/story_id")
private String id;
@MapFrom("/title")
private String title;
@MapFrom("/points")
private Integer points;
@MapFrom("/num_comments")
private Integer numberOfComments;
@MapFrom("/url")
private String url;
}
And a JSON like this:
{
"hits": [
{
"num_comments": 403,
"points": 610,
"story_id": 25300396,
"title": "Self-host your fonts for better performance",
"url": "https://wicki.io/posts/2020-11-goodbye-google-fonts/"
},
{
"num_comments": 335,
"points": 538,
"story_id": 21235957,
"title": "Ask HN: What do you self-host?"
}
]
}
The resulting mapping will be this:
[
{
"id": "25300396",
"title": "Self-host your fonts for better performance",
"points": 610,
"numberOfComments": 403,
"url": "https://wicki.io/posts/2020-11-goodbye-google-fonts/"
},
{
"id": "21235957",
"title": "Ask HN: What do you self-host?",
"points": 538,
"numberOfComments": 335,
"url": null
}
]
TODO
Maps the text value of multiple fields after joining them with a delimiter.
Assuming you have a DTO like this:
@Data
public class Person {
@ConcatMapFrom(paths = {"/name", "/lastName"}, delimiter = " ")
private String fullName;
}
And a JSON like this:
{
"name": "Jack",
"lastName": "Cooper"
}
The resulting mapping will be this:
{
"fullName": "Jack Cooper"
}
If a field is missing or its value is null
or a blank text, it will be ignored in the final concatenation.
For example, a Person
was mapped from the following JSON:
{
"name": "Jack",
"lastName": null
}
The resulting mapping will be this:
{
"fullName": "Jack"
}
TODO
TODO
This app supports "configuring" annotations from:
- Class fields
- Class methods
- Parameters of class methods
- Class themselves (not used for now, but it may be useful in the future)
To do that, you have to define an array of "replacements" in your properties with a JSON syntax.
Required fields:
targetClass
: full "path" of the class containing the annotation- Example:
dev.matiaspg.annotationsmapping.dto.HackerNewsPost
- Example:
targetField
: name of the field containing the annotationannotationClass
: the full "path" of the annotation class whose values you want to replace- Example:
dev.matiaspg.annotationsmapping.annotations.MapFrom
- Example:
replacements
: key-value map with the name of the annotation method you want to replace, and the new value
Required fields:
targetClass
: full "path" of the class containing the annotation- Example:
dev.matiaspg.annotationsmapping.dto.HackerNewsPost
- Example:
targetMethod
: name of the method containing the annotationannotationClass
: the full "path" of the annotation class whose values you want to replace- Example:
dev.matiaspg.annotationsmapping.annotations.MapFrom
- Example:
replacements
: key-value map with the name of the annotation method you want to replace, and the new value
targetClass
: full "path" of the class containing the annotation- Example:
dev.matiaspg.annotationsmapping.dto.HackerNewsPost
- Example:
targetMethod
: name of the methodtargetMethodParam
: name of the parameter containing the annotationannotationClass
: the full "path" of the annotation class whose values you want to replace- Example:
dev.matiaspg.annotationsmapping.annotations.MapFrom
- Example:
replacements
: key-value map with the name of the annotation method you want to replace, and the new value
There is no mapping annotation that you can apply on classes as of now, so this is useless for now. Still, once a use case is found, it will be there.
targetClass
: full "path" of the class containing the annotation- Example:
dev.matiaspg.annotationsmapping.dto.HackerNewsPost
- Example:
annotationClass
: the full "path" of the annotation class whose values you want to replace- Example:
dev.matiaspg.annotationsmapping.annotations.MapFrom
- Example:
replacements
: key-value map with the name of the annotation method you want to replace, and the new value
Assuming you have the HackerNewsPost
(notice singular) DTO from above and this updated HackerNewsPosts
DTO:
@Data
public class HackerNewsPosts {
private List<HackerNewsPost> posts;
private String query;
private Boolean nsfw;
@MapEachFrom("/hits")
private void setPosts(List<HackerNewsPost> posts) {
this.posts = posts;
}
private void setMultipleValues(
@MapFrom("/meta/query") String query,
@MapFrom(value = "/meta/nsfw", defaultValue = "true") Boolean nsfw
) {
this.query = query;
this.nsfw = nsfw;
}
}
You can replace annotation values by putting the following in your properties:
dev.matiaspg.mapping.annotationReplacements=[\
{\
"targetClass": "dev.matiaspg.annotationsmapping.dto.HackerNewsPost",\
"targetField": "id",\
"annotationClass": "dev.matiaspg.annotationsmapping.annotations.MapFrom",\
"replacements": { "value": ["/v2/story_id"] }\
},\
{\
"targetClass": "dev.matiaspg.annotationsmapping.dto.HackerNewsPosts",\
"targetMethod": "setPosts",\
"annotationClass": "dev.matiaspg.annotationsmapping.annotations.MapEachFrom",\
"replacements": { "value": "/v2/hits" }\
},\
{\
"targetClass": "dev.matiaspg.annotationsmapping.dto.HackerNewsPosts",\
"targetMethod": "setMultipleValues",\
"targetMethodParam": "nsfw",\
"annotationClass": "dev.matiaspg.annotationsmapping.annotations.MapFrom",\
"replacements": { "value": ["/v2/meta/nsfw", "/v3/meta/nsfw"] }\
}\
]
I know it looks kind of ugly, but it's because
properties
files require you to escape new lines. In YAML it would probably look much better.
With those replacements, the following things changed:
HackerNewsPost#id
now works as if it was annotated with@MapFrom("/v2/story_id")
HackerNewsPosts#setPosts()
now works as if it was annotated with@MapEachFrom("/v2/hits")
HackerNewsPosts#setMultipleValues()
now works as if thensfw
parameter was annotated with@MapFrom(value = {"/v2/meta/nsfw", "/v3/meta/nsfw"}, defaultValue = "true")
(notice how thedefaultValue
is kept the same)
The performance of mapping with annotations is, surprisingly, VERY good.
Take a look at the following screenshot for example:
In the screenshot you see the time it took to map 11447 items using annotations vs the "classic" way.
The "classic" way of mapping is by using mapping functions, like here.
As you can see, the first mapping took almost the same time in both approaches, which is surprising to me because I thought it would take much longer due to Reflection.
But what surprises me the most is how much faster it's after the first mapping. I knew it would be fast because a lot of stuff is cached after the first mapping, but I didn't expect it to be up to 4 times faster than the classic way.
The difference is in single digit milliseconds but still, I didn't think it would be faster than the classic way even with the optimizations. Very happy with the results.
Disclaimer: the testing was done in an AMD Ryzen 5950X CPU, which has 16 cores and 32 threads, so the results may be different in other devices/environments (e.g. the cloud).