Mapping JPA entities to external REST resources in spring-data-rest
Quickstart
Clone the example project from github and run the following commands:
./gradlew bootRun
curl http://localhost:8080/users
curl http://localhost:8080/profiles
Great. What does it do?
This example Spring Boot project, featuring spring-data-rest, demonstrates how you can link a field within an entity (e.g. a value in a table column) to a REST resource, as opposed to another table.
This linking is achieved via the following:
- Annotations within an @Entity class that indicate the intent of linking to a REST resource
- A custom ResourceProcessor that is used to add links and resolve external resources within the HATEOAS (Hypermedia as the Engine of Application State) output generated by spring-data-rest
Breaking it down
First, some simple data is loaded on startup via a BootstrapData class. This is done via simple spring-data JPA repositories. I’ve highlighted the interesting bits. Note: I’ve been using Groovy (mostly within the context of Grails) almost exclusively for the past 5 years. For that reason, all of this code is in Groovy. It wouldn’t take much effort to rewrite it in Java though.
void loadData() {
User user = new User(
id: 1L,
username: "opi1",
<strong>userProfile</strong>: "5"
)
userRepository.save(user)
Profile profile = new Profile(
id: Long.parseLong(<strong>user.userProfile</strong>),
firstName: "Hammer",
lastName: "Proper",
<strong>zipCode</strong>: "55413"
)
profileRepository.save(profile)
}
You can see from this setup code that there is a link between User and Profile. Even though these two domain classes exist within the same app, pretend that they live in different apps and in different databases.
Why is zipCode highlighted? That doesn’t look like it’s linked to anything. We’ll explore that when we look at the classes for these two entities.
The @Entity classes: straight up JPA with a little extra flavor
Let’s start with the User class. Once again, the important pieces are highlighted.
@Entity
<strong>@EntityWithRestResource</strong>
class User {
@Id
Long id
String username
<strong>@RestResourceMapper(context = RestResourceContext.PROFILE, path="/#id")</strong>
String userProfile
}
First up is the @EntityWithRestResource annotation. This is simply used as a way to identify this class as needing some additional treatment by the ResourceProcessor, which will be mentioned many times in this article. We don’t want to waste any processing time on regular entity classes that don’t have any mappings to REST resources.
Now for the crux of the topic, the @RestResourceMapper. This annotation indicates the intent that a particular field within an entity is linked to a REST resource. This linking is meant to be read only. In this case, userProfile is linked to some REST resource, which ultimately is a Profile instance in another app. If you wanted to modify that Profile instance, you would do so in the app where it lives. The good news is that the HATEOAS output will tell you exactly where to go if you want to do that. The ResourceProcessor uses the attribute values of the annotation to construct links and/or resolve REST resources.
Let’s look at the @RestResourceMapper annotation in more detail.
@Target([ElementType.FIELD])
@Retention(RetentionPolicy.RUNTIME)
public @interface RestResourceMapper {
boolean external() default false
RestResourceContext context() default RestResourceContext.LOCAL
String path() default ""
String[] params() default []
String apiKey() default ""
String resolveToProperty() default ""
}
How are these annotation attributes used?
- external indicates to the ResourceProcessor whether or not it should build a HATEOAS link that shares the same host and port as the other internal links that it exposes. If external is set to true, the link is just constructed as is. This only affects the HATEOAS “_links” output. It has no bearing on how the resource is fetched
- context is basically the root URL of the API, for example: “https://www.example.com/rest/v1”. You can see that the default is RestResourceContext.LOCAL, which is just “http://localhost:8080”
- path is the path to the endpoint, e.g. “/people”, “/places”
- params is a String array that contains any URL parameters, and they must be in “{{param}}={{value}}” format, e.g. [“namespace=basketball”, “startDate=2016-01-01”]
- apiKey is used to specify an API key, if required by the API that you’re using
- resolveToProperty is used to indicate which field the resource should be resolved to. This field should be marked as @Transient and it is required if you actually want to resolve the REST resource and embed it in the output
Let’s take another look at how the @RestResourceMapper annotation is being used by the User class.
@RestResourceMapper(context = RestResourceContext.PROFILE, path="/#id")
String userProfile
Only two attributes are specified, the context and path. The ResourceProcessor will use these attribute values, along with the value in the userProfile String to construct a link. We can see what that link looks like by running the following command.
curl <strong>http://localhost:8080/users</strong>
This yields the following output:
{
"_embedded" : {
"users" : [ {
"username" : "opi1",
"userProfile" : "5",
"_links" : {
"self" : {
"href" : "http://localhost:8080/users/1"
},
"user" : {
"href" : "http://localhost:8080/users/1"
},
"userProfile" : {
"href" : "<strong>http://localhost:8080/profiles/5</strong>"
}
}
} ]
},
"_links" : {
"self" : {
"href" : "http://localhost:8080/users"
},
"profile" : {
"href" : "http://localhost:8080/profile/users"
}
},
"page" : {
"size" : 20,
"totalElements" : 1,
"totalPages" : 1,
"number" : 0
}
}
As you can see, the link http://localhost:8080/profiles/5 was constructed by the ResourceProcessor and inserted into the _links portion of the HATEOAS output. So if we break it down, we can see the following:
- context = RestResourceContext.PROFILE
- The value of RestResourceContext.PROFILE is “http://localhost:8080/profiles”. Again, pretend that the Profile is living in a different app
- path=”/#id”
- Here, the endpoint is simply the root that’s already in the context. This is probably unlikely in the real world, but you never know
- #id – what the heck is that? Well, it’s simply used for substitution. When the ResourceProcessor constructs the link, it will substitute the value of the field for #id, which is the value of userProfile in this case, which is “5”
Before we examine the Profile for this User, let’s look at the Profile class first.
@Entity
@EntityWithRestResource
class Profile {
@Id
Long id
String firstName
String lastName
@RestResourceMapper(
external = true,
context = RestResourceContext.LOCATOR,
path = "/geocode/json",
params = ["key=#apiKey", "components=postal_code:#id"],
apiKey = RestResourceApiKey.GOOGLE,
resolveToProperty = "location"
)
String zipCode
@Transient
Object location
}
Now we can see all of the @RestResourceMapper attributes in action. In this case, the ResourceProcessor will do the following:
- Construct the link for the REST resource, which is simply the concatenation of the context, path, and params. In this case it’s “https://maps.googleapis.com/maps/api” + “/geocode/json” + “?key=#apiKey&components=postal_code:#id”
- Because external is true, no manipulation is done to the link before being added to the _links section of the HATEOAS output. However, in this example, no link will be added, as the resource is going to be fully resolved within the output instead
- In the params attribute, we see another keyword here, #apiKey. The value in the apiKey attribute will be substituted for #apiKey when the link is built
- The value of zipCode is substituted for #id in the params attribute. In this case, the value is “55413”
- The REST resource found at the constructed URL will be fully resolved within the transient location field
Let’s take a brief look at how it does this. Note: For clarification, the ResourceProcessor is a Spring HATEOAS interface for processing @Entity types. In this case, we have a generic ResourceProcessor that will be triggered for processing any entity.
if (resource.content.class.isAnnotationPresent(EntityWithRestResource.class)) {
Map links = [:]
// process any fields that have the RestResourceMapper annotation
resource.content.class.declaredFields.each { Field field ->
RestResourceMapper restResourceMapper = field.getAnnotation(RestResourceMapper.class)
if (restResourceMapper) {
String resourceId = resource.content."${field.name}"
if (resourceId) {
// construct a REST endpoint URL from the annotation properties and resource id
final String resourceURL = restResourceMapperService.getResourceURL(restResourceMapper, resourceId)
// for eager fetching, fetch the resource and embed its contents within the designated property
// no links are added
if (restResourceMapper.resolveToProperty()) {
String resolvedResource = restResourceMapperService.getResolvedResource(resourceURL)
resource.content."${restResourceMapper.resolveToProperty()}" =
ResourceParsingUtil.deserializeJSON(resolvedResource)
} else {
// for external links, we simply want to put the constructed URL into the JSON output
// for internal links, we want to ensure that the URL conforms to HATEOAS for the given resource
links.put(field.name, restResourceMapper.external() ?
resourceURL : restResourceMapperService.getHATEOASURLForResource(resourceURL, resource.content.class))
}
}
}
}
//...
}
You can see that there’s some Groovy goodness being used to get and set attributes on the entity class with a little more ease. Hopefully you can get the gist of it from this code snippet.
Now, knowing all of that, let’s see the content of the Profile for this User:
curl <strong>http://localhost:8080/profiles/5</strong>
{
"firstName" : "Hammer",
"lastName" : "Proper",
"zipCode" : "55413",
"location" : {
"error_message" : "The provided API key is invalid.",
"results" : [ ],
"status" : "REQUEST_DENIED"
},
"_links" : {
"self" : {
"href" : "http://localhost:8080/profiles/5"
},
"profile" : {
"href" : "http://localhost:8080/profiles/5"
}
}
}
Notice that an error message is encapsulated within the location field. If we had a valid API key from Google, the output would look like this:
{
"firstName" : "Hammer",
"lastName" : "Proper",
"zipCode" : "55413",
"location" : {
"results" : [ {
"address_components" : [ {
"long_name" : "55413",
"short_name" : "55413",
"types" : [ "postal_code" ]
}, {
"long_name" : "Minneapolis",
"short_name" : "Minneapolis",
"types" : [ "locality", "political" ]
}, {
"long_name" : "Minnesota",
"short_name" : "MN",
"types" : [ "administrative_area_level_1", "political" ]
}, {
"long_name" : "United States",
"short_name" : "US",
"types" : [ "country", "political" ]
} ],
"formatted_address" : "Minneapolis, MN 55413, USA",
"geometry" : {
"bounds" : {
"northeast" : {
"lat" : 45.01548100000001,
"lng" : -93.2061531
},
"southwest" : {
"lat" : 44.9868921,
"lng" : -93.27577
}
},
"location" : {
"lat" : 44.9956414,
"lng" : -93.258095
},
"location_type" : "APPROXIMATE",
"viewport" : {
"northeast" : {
"lat" : 45.01548100000001,
"lng" : -93.2061531
},
"southwest" : {
"lat" : 44.9868921,
"lng" : -93.27577
}
}
},
"place_id" : "ChIJ3T8206Mts1IRp8YOpjWHU9k",
"types" : [ "postal_code" ]
} ],
"status" : "OK"
},
"_links" : {
"self" : {
"href" : "http://localhost:8080/profiles/5"
},
"profile" : {
"href" : "http://localhost:8080/profiles/5"
}
}
}
And there you have it, links to REST resources within entity mappings. Remember, this is only meant to provide read-only functionality. If you’re interested in doing writes, feel free to fork the project and take a crack at it. That sort of design is questionable though, and the ResourceProcessor is not going to help you at all in that case. Speaking of the ResourceProcessor, it resides as a Bean within a configuration class, RestResourceMapperConfig, so take a look at that if you want to see where the “magic” happens.
That’s all for now. Happy coding!
Nice job! I currently need to find a way to do a findBy with a remote entity. Like in you example it would be “findByUser(User user)” in the UserProfileRepository. You don’t happen to have a solution for this, too?