Add example of Affordances API
This commit is contained in:
@@ -13,4 +13,5 @@ We have separate folders for each of these:
|
||||
|
||||
* link:basics[Basics] - Poke and prod at a hypermedia-powered service from inside the code as well as externally using standard tools
|
||||
* link:api-evolution[API Evolution] - Upgrade an existing REST resource
|
||||
* link:hypermedia[Hypermedia] - Create hypermedia-driven REST resources, linking them together, and supporting older links.
|
||||
* link:hypermedia[Hypermedia] - Create hypermedia-driven REST resources, linking them together, and supporting older links.
|
||||
* link:affordances[Affordances] - Create richer hypermedia controls using more complex hypermedia formats
|
||||
564
affordances/README.adoc
Normal file
564
affordances/README.adoc
Normal file
@@ -0,0 +1,564 @@
|
||||
= Spring HATEOAS - Hypermedia Example
|
||||
|
||||
This guide shows a more detailed foray into linking resources with hypermedia. It includes automated links, custom ones,
|
||||
and retaining legacy links to support older clients.
|
||||
|
||||
Before proceeding, have you read these yet?
|
||||
|
||||
. link:../basics[Spring HATEOAS - Basic Example]
|
||||
. link:../api-evolution[Spring HATEOAS - API Evolution Example]
|
||||
|
||||
You may wish to read them first before reading this one.
|
||||
|
||||
NOTE: This example uses https://projectlombok.org[Project Lombok] to reduce writing Java code.
|
||||
|
||||
== Defining Your Domain
|
||||
|
||||
This example takes off where Basics and API Evolution end: an employee payroll system. Only this time, you'll introduce
|
||||
a new domain: *managers*.
|
||||
|
||||
You'll explore how to create create REST representations for a manager and tie it into employees.
|
||||
|
||||
For starters, here is the basic definition:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Data
|
||||
@Entity
|
||||
@NoArgsConstructor
|
||||
class Manager implements Identifiable<Long> {
|
||||
|
||||
@Id @GeneratedValue
|
||||
private Long id;
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* To break the recursive, bi-directional interface, don't serialize {@literal employees}.
|
||||
*/
|
||||
@JsonIgnore
|
||||
@OneToMany(mappedBy = "manager")
|
||||
private List<Employee> employees = new ArrayList<>();
|
||||
|
||||
Manager(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
This is very similar to `Employee`:
|
||||
|
||||
* Uses the same `@Data` Lombok annotation to reduce boilerplate in defining a mutable value object.
|
||||
* They are stored in a JPA data store using `@Entity`, `@Id`, and `@GeneratedValue`.
|
||||
* Has a `@NoArgsConstructor` to support Jackson's serializers.
|
||||
|
||||
But it contains a new aspect: a 1-to-many relationship with `Employee` in the form a `List<Employee>`.
|
||||
|
||||
This domain object initializes the field with an empty list to avoid NPEs. The JPA `@OneToMany` annotation indicates
|
||||
that the relationship between `Manager` and `Employee` is stored in the database tables in the `Employee` entity's
|
||||
*manager* property, i.e. the manager's primary key will be stored as a foreign key in the *EMPLOYEE* table.
|
||||
|
||||
WARNING: Bi-directional relationships can be modeled in JPA, but you must carefully handle this. Jackson tends to
|
||||
navigate as far as possible when serializing, so you have to tell it to stop with the `@JsonIgnore` directive. Otherwise,
|
||||
it will generate a stack overflow exception when hopping Manager -> Employee -> Manager -> etc.
|
||||
|
||||
A handy constructor is also added to support link:src/main/java/org/springframework/hateoas/examples/DatabaseLoader.java[loading the database]
|
||||
with sample data.
|
||||
|
||||
A corresponding Spring Data JPA repository is defined:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
interface ManagerRepository extends CrudRepository<Manager, Long> {
|
||||
}
|
||||
----
|
||||
|
||||
To round things out, you need to make some updates to the `Employee` domain object:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Data
|
||||
@Entity
|
||||
@NoArgsConstructor
|
||||
class Employee implements Identifiable<Long> {
|
||||
|
||||
@Id @GeneratedValue
|
||||
private Long id;
|
||||
private String name;
|
||||
private String role;
|
||||
|
||||
/**
|
||||
* To break the recursive, bi-directional relationship, don't serialize {@literal manager}.
|
||||
*/
|
||||
@JsonIgnore
|
||||
@OneToOne
|
||||
private Manager manager;
|
||||
|
||||
Employee(String name, String role, Manager manager) {
|
||||
|
||||
this.name = name;
|
||||
this.role = role;
|
||||
this.manager = manager;
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
This is very similar to what you saw in *Basics*, except that now there is a 1-to-1 JPA relationship in the *manager* field.
|
||||
|
||||
The constructor call has also been updated. Finally, the same stack overflow is blocked from this end by also putting a `@JsonIgnore`
|
||||
Jackson annotation on the *manager* field.
|
||||
|
||||
With these changes in place, you can now define a `ResourceAssembler` for the `Manager`:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Component
|
||||
class ManagerResourceAssembler extends SimpleIdentifiableResourceAssembler<Manager> {
|
||||
|
||||
ManagerResourceAssembler() {
|
||||
super(ManagerController.class);
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
If you follow the same paradigm of extending Spring HATEOAS's `SimpleIdentifiableResourceAssembler` and applying the `Manager` type,
|
||||
you can easily inherit links for */managers* and */managers/{id}*
|
||||
|
||||
Before we go any further, we need to define those links!
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@RestController
|
||||
class ManagerController {
|
||||
|
||||
private final ManagerRepository repository;
|
||||
private final ManagerResourceAssembler assembler;
|
||||
|
||||
ManagerController(ManagerRepository repository, ManagerResourceAssembler assembler) {
|
||||
|
||||
this.repository = repository;
|
||||
this.assembler = assembler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up all managers, and transform them into a REST collection resource using
|
||||
* {@link ManagerResourceAssembler#toResources(Iterable)}. Then return them through
|
||||
* Spring Web's {@link ResponseEntity} fluent API.
|
||||
*
|
||||
* NOTE: cURL will fetch things as HAL JSON directly, but browsers issue a different
|
||||
* default accept header, which allows XML to get requested first, so "produces"
|
||||
* forces it to HAL JSON for all clients.
|
||||
*/
|
||||
@GetMapping(value = "/managers", produces = MediaTypes.HAL_JSON_VALUE)
|
||||
ResponseEntity<Resources<Resource<Manager>>> findAll() {
|
||||
return ResponseEntity.ok(
|
||||
assembler.toResources(repository.findAll()));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a single {@link Manager} and transform it into a REST resource using
|
||||
* {@link ManagerResourceAssembler#toResource(Object)}. Then return it through
|
||||
* Spring Web's {@link ResponseEntity} fluent API.
|
||||
*
|
||||
* See {@link #findAll()} to explain {@link GetMapping}'s "produces" argument.
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
@GetMapping(value = "/managers/{id}", produces = MediaTypes.HAL_JSON_VALUE)
|
||||
ResponseEntity<Resource<Manager>> findOne(@PathVariable long id) {
|
||||
return ResponseEntity.ok(
|
||||
assembler.toResource(repository.findOne(id)));
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
This controller should look familar, since it's almost identical to `EmployeeController` as seen in link:../api-evolution[API Evolution].
|
||||
You have simply swapped */employees* with */managers* and plugged in `ManagerRepository` and `ManagerResourceAssembler`.
|
||||
|
||||
IMPORTANT: It's not a requirement to use a `ResourceAssembler`. But having one place to define all links for a given domain object
|
||||
ensures a consistent representation.
|
||||
|
||||
With the basic routes defined, you could say we have an operational REST service. But it's not fleshed out very well. To truly
|
||||
power up the hypermedia and serve clients, you need to add links _between_ the relevant domain types.
|
||||
|
||||
NOTE: Up until this point, we've been using the term "domain types" or "domain objects". This is lingo found in Domain Driven Design.
|
||||
What you are building are *REST resources* and how the various mediatypes they are represented in. The paradigm of REST is
|
||||
to construct resources that contain both data for the client to consume as well as controls to navigate to related data.
|
||||
|
||||
The first link to navigate from a `Manager` resource to its related `Employee` resources would be a */managers/{id}/employees*
|
||||
route. Since a controller that yields employee objects would be found in the `EmployeeController`, we need to make the following alterations:
|
||||
|
||||
.EmployeeController
|
||||
[source,java]
|
||||
----
|
||||
@RestController
|
||||
class EmployeeController {
|
||||
|
||||
...
|
||||
|
||||
/**
|
||||
* Find an {@link Employee}'s {@link Manager} based upon employee id. Turn it into a context-based link.
|
||||
*
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@GetMapping(value = "/managers/{id}/employees", produces = MediaTypes.HAL_JSON_VALUE)
|
||||
public ResponseEntity<Resources<Resource<Employee>>> findEmployees(@PathVariable long id) {
|
||||
return ResponseEntity.ok(
|
||||
assembler.toResources(repository.findByManagerId(id)));
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
We've added another route, but how are we getting the data? Oh yeah, we need to add another finder!
|
||||
|
||||
[source,java]
|
||||
----
|
||||
interface EmployeeRepository extends CrudRepository<Employee, Long> {
|
||||
|
||||
List<Employee> findByManagerId(Long id);
|
||||
|
||||
}
|
||||
----
|
||||
|
||||
With Spring Data, we can define a new finder _just by writing it's method signature!_ This custom finder will navigate by property
|
||||
and find a list of employees pointed at the chosen manager id.
|
||||
|
||||
NOTE: Navigation by property is analogous to writing `select EMPLOYEE.* from EMPLOYEE join MANAGER on MANAGER.PK = EMPLOYEE.FK where MANAGER.PK == :id`.
|
||||
It makes it super simple to navigate over JPA relationships and find what we need.
|
||||
|
||||
This newly minted route needs to be added to every `Manager` representation we render. To do that, we need to make an alteration
|
||||
to `ManagerResourceAssembler`:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Component
|
||||
class ManagerResourceAssembler extends SimpleIdentifiableResourceAssembler<Manager> {
|
||||
|
||||
...
|
||||
|
||||
/**
|
||||
* Retain default links provided by {@link SimpleIdentifiableResourceAssembler}, but add extra ones to each {@link Manager}.
|
||||
*
|
||||
* @param resource
|
||||
*/
|
||||
@Override
|
||||
protected void addLinks(Resource<Manager> resource) {
|
||||
/**
|
||||
* Retain default links.
|
||||
*/
|
||||
super.addLinks(resource);
|
||||
|
||||
// Add custom link to find all managed employees
|
||||
resource.add(linkTo(methodOn(EmployeeController.class).findEmployees(resource.getContent().getId())).withRel("employees"));
|
||||
}
|
||||
|
||||
...
|
||||
}
|
||||
|
||||
----
|
||||
|
||||
`SimpleIdentifiableResourceAssembler` has methods to alter a resource representation for single items or collections. It has pre-baked
|
||||
renderings to create a self link to a single item as well as a link back to the collection. In this code, you are extending that
|
||||
method and invoking `super.addLinks()` in order to include those links. Then you add the link to the manager's employees you just created.
|
||||
|
||||
IMPORTANT: You can either _add_ to the links defined by `SimpleIdentifiableResourceAssembler` as shown, or you can totally replace them by _not_
|
||||
invoking `super.addLinks()`. Your choice.
|
||||
|
||||
There is a corresponding combination of a route/repository finder/assembler to allow an employee to find his or her manager. It's left as an exericise
|
||||
for you to discover it in `ManagerController`, `ManagerRepository`, and `EmployeeResourceAssembler`.
|
||||
|
||||
== Augmenting Representations
|
||||
|
||||
Some critics of REST will point to certain toolkits or coded solutions and argue that "hopping" can be inefficient. A common example is
|
||||
a relational set of tables that through 3NF (3rd Normal Form) split up data between a parent/child relationship. In essence, part of the data
|
||||
is in the parent table, part in the child table. The parent table's data is shown along with a link to navigate to the child table's data.
|
||||
|
||||
This is a false comparison, because REST wholely supports merging data if it makes sense. In DDD, such items are referred to as *aggregates*.
|
||||
Nothing about a REST resource is confined by the rules of 3NF, written forty years ago. That can simply be shortfall of certain
|
||||
toolkits (but not Spring HATEOAS!)
|
||||
|
||||
What if you wanted a detailed `Employee` representation that included the `Manager` details? No problem! Just model it.
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Value
|
||||
@JsonPropertyOrder({"id", "name", "role", "manager"})
|
||||
public class EmployeeWithManager {
|
||||
|
||||
@JsonIgnore
|
||||
private final Employee employee;
|
||||
|
||||
public Long getId() {
|
||||
return this.employee.getId();
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return this.employee.getName();
|
||||
}
|
||||
|
||||
public String getRole() {
|
||||
return this.employee.getRole();
|
||||
}
|
||||
|
||||
public String getManager() {
|
||||
return this.employee.getManager().getName();
|
||||
}
|
||||
|
||||
}
|
||||
----
|
||||
|
||||
This _immutable_ value object (thanks to Lombok's `@Value` annotation) is initialized with an `Employee` object. It defines
|
||||
how it gets rendered through various getter methods. It also subtly does _not_ render the `Employee` object itself.
|
||||
|
||||
IMPORTANT: `Employee` and `Manager` both have a *name* field. With combined representations, there has to be agreement on how these
|
||||
two fields will appear. In this case, `Employee.name` is kept and `Manager.name` is turned into *manager*.
|
||||
|
||||
To support this, we can write the corresponding route in `EmployeeController`:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@GetMapping(value = "/employees/detailed", produces = MediaTypes.HAL_JSON_VALUE)
|
||||
public ResponseEntity<Resources<Resource<EmployeeWithManager>>> findAllDetailedEmployees() {
|
||||
|
||||
return ResponseEntity.ok(
|
||||
employeeWithManagerResourceAssembler.toResources(
|
||||
StreamSupport.stream(repository.findAll().spliterator(), false)
|
||||
.map(EmployeeWithManager::new)
|
||||
.collect(Collectors.toList())));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/employees/{id}/detailed", produces = MediaTypes.HAL_JSON_VALUE)
|
||||
public ResponseEntity<Resource<EmployeeWithManager>> findDetailedEmployee(@PathVariable Long id) {
|
||||
|
||||
Employee employee = repository.findOne(id);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
employeeWithManagerResourceAssembler.toResource(
|
||||
new EmployeeWithManager(employee)));
|
||||
}
|
||||
----
|
||||
|
||||
This shows both a collection of "detailed" employees as well as a single one. The collection fetches all employees, uses a Java 8
|
||||
stream to convert each `Employee` into an `EmployeeWithManager`, and wraps it into a Spring HATEOAS `Resources` collection.
|
||||
|
||||
The single employee version does the corresponding transformation against a single `Employee`.
|
||||
|
||||
To support building REST resources, you also need a `ResourceAssembler` for `EmployeeWithManager`. This should appear very
|
||||
familiar by now:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@Component
|
||||
class EmployeeWithManagerResourceAssembler extends SimpleResourceAssembler<EmployeeWithManager> {
|
||||
|
||||
/**
|
||||
* Define links to add to every individual {@link Resource}.
|
||||
*
|
||||
* @param resource
|
||||
*/
|
||||
@Override
|
||||
protected void addLinks(Resource<EmployeeWithManager> resource) {
|
||||
|
||||
resource.add(linkTo(methodOn(EmployeeController.class).findDetailedEmployee(resource.getContent().getId())).withSelfRel());
|
||||
resource.add(linkTo(methodOn(EmployeeController.class).findOne(resource.getContent().getId())).withRel("summary"));
|
||||
resource.add(linkTo(methodOn(EmployeeController.class).findAllDetailedEmployees()).withRel("detailedEmployees"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Define links to add to the {@link Resources} collection.
|
||||
*
|
||||
* @param resources
|
||||
*/
|
||||
@Override
|
||||
protected void addLinks(Resources<Resource<EmployeeWithManager>> resources) {
|
||||
|
||||
resources.add(linkTo(methodOn(EmployeeController.class).findAllDetailedEmployees()).withSelfRel());
|
||||
resources.add(linkTo(methodOn(EmployeeController.class).findAll()).withRel("employees"));
|
||||
resources.add(linkTo(methodOn(ManagerController.class).findAll()).withRel("managers"));
|
||||
resources.add(linkTo(methodOn(RootController.class).root()).withRel("root"));
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
This has a handful of differences from the `ResourceAssembler` objects you've built up to this point:
|
||||
|
||||
* Since the routes are different than traditional */employees* and */employees/{id}*, it makes no sense to use `SimpleIdentifiableResourceAssembler<T>`.
|
||||
So instead, you want to fall back to `SimpleResourceAssembler<EmployeeWithManager>`, in which NO links are defined out of the box.
|
||||
* Because there are no defined routes, you are in full control.
|
||||
** `addLinks(Resource<EmployeeWithManager> resource)` defines links for single items
|
||||
** `addLinks(Resources<Resource<EmployeeWithManager>> resources)` defines links for collections
|
||||
|
||||
In this case, single `EmployeeWithManager` items include a self link to itself, a hop to it's parallel record that only has `Employee` info known as *summary*,
|
||||
and a link to the detailed collection. To avoid semantic confusion, this is called *detailedEmployees* given *employees* is the common reference to
|
||||
a collection of summary `Employee` records.
|
||||
|
||||
It also makes sense to add links from the other existing REST resources to this detailed `EmployeeWithManager`.
|
||||
|
||||
WARNING: Even though `addLinks(Resources<Resource<EmployeeWithManager>> resources)` gives you access to a single item's `Resource<T>` object,
|
||||
it is recommended to NOT manipulate individual item links this way. Instead, use the other method.
|
||||
|
||||
Is this the _only_ way to display a detailed record? Not at all. Spring MVC supports request parameters, so it's not that difficult
|
||||
to code something like this:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@GetMapping(value = "/employees/{id}", produces = MediaTypes.HAL_JSON_VALUE)
|
||||
public ResponseEntity<?> findOne(@PathVariable long id,
|
||||
@RequestParam(value = "detailed", required = false,
|
||||
defaultValue = false) boolean detailed) {
|
||||
|
||||
if (detailed) {
|
||||
Employee employee = repository.findOne(id);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
employeeWithManagerResourceAssembler.toResource(
|
||||
new EmployeeWithManager(employee)));
|
||||
} else {
|
||||
return ResponseEntity.ok(
|
||||
assembler.toResource(repository.findOne(id)));
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
This type of solution allows serving two different representations from the same URI based on an optional `?detailed=true`
|
||||
parameter.
|
||||
|
||||
There are tradeoffs either way, but this option lends itself to supporting existing routes that you may already have.
|
||||
|
||||
To find the other places where detailed `EmployeeWithManager` links have been added, inspect all the `ResourceAssembler` objects
|
||||
in the example's code base.
|
||||
|
||||
== Don't Forget the Root URI
|
||||
|
||||
In order to "start at the top" and hop, you must include a `RootController`:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
@RestController
|
||||
class RootController {
|
||||
|
||||
@GetMapping("/")
|
||||
ResponseEntity<ResourceSupport> root() {
|
||||
|
||||
ResourceSupport resourceSupport = new ResourceSupport();
|
||||
|
||||
resourceSupport.add(linkTo(methodOn(RootController.class).root()).withSelfRel());
|
||||
resourceSupport.add(linkTo(methodOn(EmployeeController.class).findAll()).withRel("employees"));
|
||||
resourceSupport.add(linkTo(methodOn(EmployeeController.class).findAllDetailedEmployees()).withRel("detailedEmployees"));
|
||||
resourceSupport.add(linkTo(methodOn(ManagerController.class).findAll()).withRel("managers"));
|
||||
|
||||
return ResponseEntity.ok(resourceSupport);
|
||||
}
|
||||
|
||||
}
|
||||
----
|
||||
|
||||
Because there is no data at the top, just links, returning back a `ResourceSupport` is perfect. This allows defining all the top links.
|
||||
|
||||
And it's easy to go into the various `ResourceAssemblers` and add a link back to the top as needed. It's up to you to see which
|
||||
bits of hypermedia serve such a link.
|
||||
|
||||
== Legacy Routes
|
||||
|
||||
What if you started with one set of routes and migrated things to another set? This is the type of scenario that drives people screaming
|
||||
to version their APIs.
|
||||
|
||||
Instead of shouting "don't version APIs" from the rooftops, and appealing to the authority of Roy Fielding, it's better to see
|
||||
how it's not that hard to support both old and new routes.
|
||||
|
||||
For this example, assume that before the `Manager` entity and it's `ManagerController` existed, there was a `Supervisor` with a
|
||||
matching `SupervisorController`. It had similar data but fewer links. A bit more RPC-like. If the original `Supervisor` entity
|
||||
was gone, we can add a DTO to represent the old format based on `Manager` like this:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
/**
|
||||
* Legacy representation. Contains older format of data. Fewer links because hypermedia at the time was an after
|
||||
* thought.
|
||||
*
|
||||
* @author Greg Turnquist
|
||||
*/
|
||||
@Value
|
||||
@JsonPropertyOrder({"id", "name", "employees"})
|
||||
class Supervisor {
|
||||
|
||||
@JsonIgnore
|
||||
private final Manager manager;
|
||||
|
||||
public Long getId() {
|
||||
return this.manager.getId();
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return this.manager.getName();
|
||||
}
|
||||
|
||||
public List<String> getEmployees() {
|
||||
return manager.getEmployees().stream()
|
||||
.map(employee -> employee.getName() + "::" + employee.getRole())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
This representation assumes old record had:
|
||||
|
||||
* Supervisor's *id*, *name* and a somewhat sloppy display of employee's name and role.
|
||||
* It's powered by the new `Manager` object, so no need to store multiple copies of data.
|
||||
* The `Manager` itself is not rendered thanks to the `@JsonIgnore` annotation.
|
||||
|
||||
To honor the old route (*/supervisors/{id}*), create a new controller:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
/**
|
||||
* Represent an older controller that has since been replaced with {@link ManagerController}.
|
||||
* This controller is used to provide legacy routes, i.e. backwards compatibility.
|
||||
*
|
||||
* @author Greg Turnquist
|
||||
*/
|
||||
@RestController
|
||||
public class SupervisorController {
|
||||
|
||||
private final ManagerController controller;
|
||||
|
||||
public SupervisorController(ManagerController controller) {
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
@GetMapping(value = "/supervisors/{id}", produces = MediaTypes.HAL_JSON_VALUE)
|
||||
public ResponseEntity<Resource<Supervisor>> findOne(@PathVariable Long id) {
|
||||
|
||||
Resource<Manager> managerResource = controller.findOne(id).getBody();
|
||||
Resource<Supervisor> supervisorResource = new Resource<>(
|
||||
new Supervisor(managerResource.getContent()),
|
||||
managerResource.getLinks());
|
||||
|
||||
return ResponseEntity.ok(supervisorResource);
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
In this example, the assumption is that there was a route for individual supervisors, but not a link for a collection.
|
||||
This controller has that route, and serves up a `Resource<Supervisor>` record. But instead of fetching the data directly,
|
||||
it leverages the `ManagerController`.
|
||||
|
||||
Is that a good idea or a bad idea?
|
||||
|
||||
Again, there are tradeoffs. This example is meant to illustrate other options. In this case, leveraging `ManagerController`
|
||||
allows all links to be generated courtesy of the `ManagerResourceAssembler`. When a `ResponseEntity<Resource<Manager>>` object
|
||||
is returned by the controller, its wrapped REST resource is extracted by Spring MVC's `getBody()` method.
|
||||
|
||||
A new `Supervisor` REST resource is constructed by injecting the `Manager` into a `Supervisor` DTO. The provided links are
|
||||
then copied into that `Resource<Supervisor>` object.
|
||||
|
||||
Hence, this controller will respond to calls for */supervisors/{id}*, but provide links onto the new system should the client
|
||||
want to gracefully start migrating.
|
||||
|
||||
IMPORTANT: This example also assumes the clients can handle new links as long as the legacy ones are also there. For
|
||||
a different scenario, that assumption can be adjusted.
|
||||
|
||||
With this amount of linking between related objects and DTOs, it's easy to see how Spring HATEOAS can be used to model
|
||||
a link-driven API. And with the flexible nature of REST, more links can be added in the future along with additional representations.
|
||||
As long as the existing links are maintained, clients can have a much easier path of migration.
|
||||
34
affordances/pom.xml
Normal file
34
affordances/pom.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>spring-hateoas-examples-affordances</artifactId>
|
||||
<name>Spring HATEOAS - Examples - Affordances</name>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.hateoas.examples</groupId>
|
||||
<artifactId>spring-hateoas-examples</artifactId>
|
||||
<version>1.0.0.BUILD-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.hateoas.examples</groupId>
|
||||
<artifactId>commons</artifactId>
|
||||
<version>1.0.0.BUILD-SNAPSHOT</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2017 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.hateoas.examples;
|
||||
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Pre-load some data using a Spring Boot {@link CommandLineRunner}.
|
||||
*
|
||||
* @author Greg Turnquist
|
||||
*/
|
||||
@Component
|
||||
class DatabaseLoader {
|
||||
|
||||
/**
|
||||
* Use Spring to inject a {@link EmployeeRepository} that can then load data. Since this will run
|
||||
* only after the app is operational, the database will be up.
|
||||
*
|
||||
* @param repository
|
||||
*/
|
||||
@Bean
|
||||
CommandLineRunner init(EmployeeRepository repository) {
|
||||
return args -> {
|
||||
repository.save(new Employee("Frodo", "Baggins", "ring bearer"));
|
||||
repository.save(new Employee("Bilbo", "Baggins", "burglar"));
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2017 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.hateoas.examples;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.Id;
|
||||
|
||||
import org.springframework.hateoas.Identifiable;
|
||||
|
||||
/**
|
||||
* Domain object representing a company employee. Project Lombok keeps actual code at a minimum.
|
||||
*
|
||||
* {@code @Data} - Generates getters, setters, toString, hash, and equals functions
|
||||
* {@code @Entity} - JPA annotation to flag this class for DB persistence
|
||||
* {@code @NoArgsConstructor} - Create a constructor with no args to support JPA
|
||||
* {@code @AllArgsConstructor} - Create a constructor with all args to support testing
|
||||
*
|
||||
* {@code @JsonIgnoreProperties(ignoreUnknow=true)}
|
||||
* When converting JSON to Java, ignore any unrecognized attributes. This is critical for REST because it
|
||||
* encourages adding new fields in later versions that won't break. It also allows things like _links to be
|
||||
* ignore as well, meaning HAL documents can be fetched and later posted to the server without adjustment.
|
||||
*
|
||||
*
|
||||
* @author Greg Turnquist
|
||||
*/
|
||||
@Data
|
||||
@Entity
|
||||
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@AllArgsConstructor
|
||||
class Employee implements Identifiable<Long> {
|
||||
|
||||
@Id @GeneratedValue
|
||||
private Long id;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String role;
|
||||
|
||||
/**
|
||||
* Useful constructor when id is not yet known.
|
||||
*/
|
||||
Employee(String firstName, String lastName, String role) {
|
||||
|
||||
this.firstName = firstName;
|
||||
this.lastName = lastName;
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public Optional<Long> getId() {
|
||||
return Optional.ofNullable(this.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2017 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.hateoas.examples;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.hateoas.Resource;
|
||||
import org.springframework.hateoas.ResourceSupport;
|
||||
import org.springframework.hateoas.Resources;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* @author Greg Turnquist
|
||||
*/
|
||||
@RestController
|
||||
class EmployeeController {
|
||||
|
||||
private final EmployeeRepository repository;
|
||||
private final EmployeeResourceAssembler assembler;
|
||||
|
||||
EmployeeController(EmployeeRepository repository, EmployeeResourceAssembler assembler) {
|
||||
|
||||
this.repository = repository;
|
||||
this.assembler = assembler;
|
||||
}
|
||||
|
||||
@GetMapping("/employees")
|
||||
ResponseEntity<Resources<Resource<Employee>>> findAll() {
|
||||
return ResponseEntity.ok(
|
||||
assembler.toResources(repository.findAll()));
|
||||
}
|
||||
|
||||
@GetMapping("/employees/{id}")
|
||||
ResponseEntity<Resource<Employee>> findOne(@PathVariable long id) {
|
||||
|
||||
return repository.findById(id)
|
||||
.map(assembler::toResource)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping("/employees")
|
||||
ResponseEntity<?> newEmployee(@RequestBody Employee employee) {
|
||||
|
||||
return repository.save(employee).getId()
|
||||
.map(this::findOne)
|
||||
.map(HttpEntity::getBody)
|
||||
.flatMap(ResourceSupport::getId)
|
||||
.map(Link::getHref)
|
||||
.map(href -> {
|
||||
try {
|
||||
return new URI(href);
|
||||
} catch (URISyntaxException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
})
|
||||
.map(uri -> ResponseEntity.noContent().location(uri).build())
|
||||
.orElse(ResponseEntity.badRequest().body("Unable to create " + employee));
|
||||
}
|
||||
|
||||
@PutMapping("/employees/{id}")
|
||||
ResponseEntity<?> updateEmployee(@RequestBody Employee employee, @PathVariable long id) {
|
||||
|
||||
Employee employeeToUpdate = employee;
|
||||
employeeToUpdate.setId(id);
|
||||
|
||||
return repository.save(employeeToUpdate).getId()
|
||||
.map(this::findOne)
|
||||
.map(HttpEntity::getBody)
|
||||
.flatMap(ResourceSupport::getId)
|
||||
.map(Link::getHref)
|
||||
.map(href -> {
|
||||
try {
|
||||
return new URI(href);
|
||||
} catch (URISyntaxException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
})
|
||||
.map(uri -> ResponseEntity.noContent().location(uri).build())
|
||||
.orElse(ResponseEntity.badRequest().body("Unable to update " + employeeToUpdate));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2017 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.hateoas.examples;
|
||||
|
||||
import org.springframework.data.repository.CrudRepository;
|
||||
|
||||
/**
|
||||
* @author Greg Turnquist
|
||||
*/
|
||||
interface EmployeeRepository extends CrudRepository<Employee, Long> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2017 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.hateoas.examples;
|
||||
|
||||
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*;
|
||||
|
||||
import org.springframework.hateoas.Resource;
|
||||
import org.springframework.hateoas.Resources;
|
||||
import org.springframework.hateoas.SimpleIdentifiableResourceAssembler;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* @author Greg Turnquist
|
||||
*/
|
||||
@Component
|
||||
class EmployeeResourceAssembler extends SimpleIdentifiableResourceAssembler<Employee> {
|
||||
|
||||
/**
|
||||
* Link the {@link Employee} domain type to the {@link EmployeeController} using this
|
||||
* {@link SimpleIdentifiableResourceAssembler} in order to generate both {@link org.springframework.hateoas.Resource}
|
||||
* and {@link org.springframework.hateoas.Resources}.
|
||||
*/
|
||||
EmployeeResourceAssembler() {
|
||||
super(EmployeeController.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Define links to add to every {@link Resource}.
|
||||
*
|
||||
* @param resource
|
||||
*/
|
||||
@Override
|
||||
protected void addLinks(Resource<Employee> resource) {
|
||||
|
||||
resource.getContent().getId()
|
||||
.ifPresent(id -> {
|
||||
resource.add(getCollectionLinkBuilder().slash(resource.getContent()).withSelfRel()
|
||||
.andAffordance(afford(methodOn(EmployeeController.class).updateEmployee(null, id))));
|
||||
});
|
||||
|
||||
resource.add(getCollectionLinkBuilder().withRel(this.getRelProvider().getCollectionResourceRelFor(this.getResourceType())));
|
||||
}
|
||||
|
||||
/**
|
||||
* Define links to add to {@link Resources} collection.
|
||||
*
|
||||
* @param resources
|
||||
*/
|
||||
@Override
|
||||
protected void addLinks(Resources<Resource<Employee>> resources) {
|
||||
resources.add(getCollectionLinkBuilder().withSelfRel()
|
||||
.andAffordance(afford(methodOn(EmployeeController.class).newEmployee(null))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2017 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.hateoas.examples;
|
||||
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.BeanFactory;
|
||||
import org.springframework.beans.factory.BeanFactoryAware;
|
||||
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
|
||||
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.hateoas.config.EnableHypermediaSupport;
|
||||
import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType;
|
||||
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Right now, only one hypermedia type can be registered at a time. An extras will break Spring Boot's
|
||||
* autoconfiguration options. For this example, we are using {@literal HAL_FORMS}.
|
||||
*
|
||||
* As a side effect, of using {@link EnableHypermediaSupport}, we must configure post processing the related
|
||||
* {@link ObjectMapper} directly.
|
||||
*
|
||||
* @author Greg Turnquist
|
||||
*/
|
||||
@Configuration
|
||||
@EnableHypermediaSupport(type = HypermediaType.HAL_FORMS)
|
||||
public class HypermediaConfiguration {
|
||||
|
||||
@Bean
|
||||
public static HalObjectMapperConfigurer halObjectMapperConfigurer() {
|
||||
return new HalObjectMapperConfigurer();
|
||||
}
|
||||
|
||||
private static class HalObjectMapperConfigurer
|
||||
implements BeanPostProcessor, BeanFactoryAware {
|
||||
|
||||
private BeanFactory beanFactory;
|
||||
|
||||
/**
|
||||
* Assume any {@link ObjectMapper} starts with {@literal _hal} and ends with {@literal Mapper}.
|
||||
*/
|
||||
@Override
|
||||
public Object postProcessBeforeInitialization(Object bean, String beanName)
|
||||
throws BeansException {
|
||||
if (bean instanceof ObjectMapper && beanName.startsWith("_hal") && beanName.endsWith("Mapper")) {
|
||||
postProcessHalObjectMapper((ObjectMapper) bean);
|
||||
}
|
||||
return bean;
|
||||
}
|
||||
|
||||
private void postProcessHalObjectMapper(ObjectMapper objectMapper) {
|
||||
try {
|
||||
Jackson2ObjectMapperBuilder builder = this.beanFactory.getBean(Jackson2ObjectMapperBuilder.class);
|
||||
builder.configure(objectMapper);
|
||||
} catch (NoSuchBeanDefinitionException ex) {
|
||||
// No Jackson configuration required
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object postProcessAfterInitialization(Object bean, String beanName)
|
||||
throws BeansException {
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
|
||||
this.beanFactory = beanFactory;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2017 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.hateoas.examples;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* @author Greg Turnquist
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class SpringHateoasAffordancesApplication {
|
||||
|
||||
public static void main(String... args) {
|
||||
SpringApplication.run(SpringHateoasAffordancesApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright 2017 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.hateoas.examples;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.BDDMockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.hateoas.MediaTypes;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.test.context.junit4.SpringRunner;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
/**
|
||||
* @author Greg Turnquist
|
||||
*/
|
||||
@RunWith(SpringRunner.class)
|
||||
@WebMvcTest(EmployeeController.class)
|
||||
@Import({EmployeeResourceAssembler.class, HypermediaConfiguration.class})
|
||||
public class EmployeeControllerTests {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mvc;
|
||||
|
||||
@MockBean
|
||||
private EmployeeRepository repository;
|
||||
|
||||
@Test
|
||||
public void getAllShouldFetchAHalFormsEmbeddedDocument() throws Exception {
|
||||
|
||||
given(repository.findAll()).willReturn(
|
||||
Arrays.asList(
|
||||
new Employee(1L, "Frodo", "Baggins", "ring bearer"),
|
||||
new Employee(2L, "Bilbo", "Baggins", "burglar")));
|
||||
|
||||
mvc.perform(get("/employees").accept(MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andDo(print())
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_FORMS_JSON_VALUE + ";charset=UTF-8"))
|
||||
|
||||
.andExpect(jsonPath("$._embedded.employees[0].id", is(1)))
|
||||
.andExpect(jsonPath("$._embedded.employees[0].firstName", is("Frodo")))
|
||||
.andExpect(jsonPath("$._embedded.employees[0].lastName", is("Baggins")))
|
||||
.andExpect(jsonPath("$._embedded.employees[0].role", is("ring bearer")))
|
||||
.andExpect(jsonPath("$._embedded.employees[0]._templates.default.method", is("put")))
|
||||
.andExpect(jsonPath("$._embedded.employees[0]._templates.default.properties[0].name", is("firstName")))
|
||||
.andExpect(jsonPath("$._embedded.employees[0]._templates.default.properties[0].required", is(true)))
|
||||
.andExpect(jsonPath("$._embedded.employees[0]._templates.default.properties[1].name", is("id")))
|
||||
.andExpect(jsonPath("$._embedded.employees[0]._templates.default.properties[1].required", is(true)))
|
||||
.andExpect(jsonPath("$._embedded.employees[0]._templates.default.properties[2].name", is("lastName")))
|
||||
.andExpect(jsonPath("$._embedded.employees[0]._templates.default.properties[2].required", is(true)))
|
||||
.andExpect(jsonPath("$._embedded.employees[0]._templates.default.properties[3].name", is("role")))
|
||||
.andExpect(jsonPath("$._embedded.employees[0]._templates.default.properties[3].required", is(true)))
|
||||
.andExpect(jsonPath("$._embedded.employees[0]._links.self.href", is("http://localhost/employees/1")))
|
||||
.andExpect(jsonPath("$._embedded.employees[0]._links.employees.href", is("http://localhost/employees")))
|
||||
|
||||
.andExpect(jsonPath("$._embedded.employees[1].id", is(2)))
|
||||
.andExpect(jsonPath("$._embedded.employees[1].firstName", is("Bilbo")))
|
||||
.andExpect(jsonPath("$._embedded.employees[1].lastName", is("Baggins")))
|
||||
.andExpect(jsonPath("$._embedded.employees[1].role", is("burglar")))
|
||||
.andExpect(jsonPath("$._embedded.employees[1]._templates.default.method", is("put")))
|
||||
.andExpect(jsonPath("$._embedded.employees[1]._templates.default.properties[0].name", is("firstName")))
|
||||
.andExpect(jsonPath("$._embedded.employees[1]._templates.default.properties[0].required", is(true)))
|
||||
.andExpect(jsonPath("$._embedded.employees[1]._templates.default.properties[1].name", is("id")))
|
||||
.andExpect(jsonPath("$._embedded.employees[1]._templates.default.properties[1].required", is(true)))
|
||||
.andExpect(jsonPath("$._embedded.employees[1]._templates.default.properties[2].name", is("lastName")))
|
||||
.andExpect(jsonPath("$._embedded.employees[1]._templates.default.properties[2].required", is(true)))
|
||||
.andExpect(jsonPath("$._embedded.employees[1]._templates.default.properties[3].name", is("role")))
|
||||
.andExpect(jsonPath("$._embedded.employees[1]._templates.default.properties[3].required", is(true)))
|
||||
.andExpect(jsonPath("$._embedded.employees[1]._links.self.href", is("http://localhost/employees/2")))
|
||||
.andExpect(jsonPath("$._embedded.employees[1]._links.employees.href", is("http://localhost/employees")))
|
||||
|
||||
.andExpect(jsonPath("$._templates.default.method", is("post")))
|
||||
.andExpect(jsonPath("$._templates.default.properties[0].name", is("firstName")))
|
||||
.andExpect(jsonPath("$._templates.default.properties[0].required", is(true)))
|
||||
.andExpect(jsonPath("$._templates.default.properties[1].name", is("id")))
|
||||
.andExpect(jsonPath("$._templates.default.properties[1].required", is(true)))
|
||||
.andExpect(jsonPath("$._templates.default.properties[2].name", is("lastName")))
|
||||
.andExpect(jsonPath("$._templates.default.properties[2].required", is(true)))
|
||||
.andExpect(jsonPath("$._templates.default.properties[3].name", is("role")))
|
||||
.andExpect(jsonPath("$._templates.default.properties[3].required", is(true)))
|
||||
|
||||
.andExpect(jsonPath("$._links.self.href", is("http://localhost/employees")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getOneShouldFetchASingleHalFormsDocument() throws Exception {
|
||||
|
||||
given(repository.findById(any())).willReturn(
|
||||
Optional.of(new Employee(1L, "Frodo", "Baggins", "ring bearer")));
|
||||
|
||||
mvc.perform(get("/employees/1").accept(MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andDo(print())
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_FORMS_JSON_VALUE + ";charset=UTF-8"))
|
||||
|
||||
.andExpect(jsonPath("$.id", is(1)))
|
||||
.andExpect(jsonPath("$.firstName", is("Frodo")))
|
||||
.andExpect(jsonPath("$.lastName", is("Baggins")))
|
||||
.andExpect(jsonPath("$.role", is("ring bearer")))
|
||||
|
||||
.andExpect(jsonPath("$._templates.default.method", is("put")))
|
||||
.andExpect(jsonPath("$._templates.default.properties[0].name", is("firstName")))
|
||||
.andExpect(jsonPath("$._templates.default.properties[0].required", is(true)))
|
||||
.andExpect(jsonPath("$._templates.default.properties[1].name", is("id")))
|
||||
.andExpect(jsonPath("$._templates.default.properties[1].required", is(true)))
|
||||
.andExpect(jsonPath("$._templates.default.properties[2].name", is("lastName")))
|
||||
.andExpect(jsonPath("$._templates.default.properties[2].required", is(true)))
|
||||
.andExpect(jsonPath("$._templates.default.properties[3].name", is("role")))
|
||||
.andExpect(jsonPath("$._templates.default.properties[3].required", is(true)))
|
||||
|
||||
.andExpect(jsonPath("$._links.self.href", is("http://localhost/employees/1")))
|
||||
.andExpect(jsonPath("$._links.employees.href", is("http://localhost/employees")));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ package org.springframework.hateoas;
|
||||
|
||||
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import org.springframework.core.GenericTypeResolver;
|
||||
import org.springframework.hateoas.core.EvoInflectorRelProvider;
|
||||
import org.springframework.hateoas.mvc.ControllerLinkBuilder;
|
||||
@@ -34,17 +37,17 @@ public class SimpleIdentifiableResourceAssembler<T extends Identifiable<?>> exte
|
||||
/**
|
||||
* A {@link RelProvider} to look up names of links as options for resource paths.
|
||||
*/
|
||||
private final RelProvider relProvider;
|
||||
@Getter private final RelProvider relProvider;
|
||||
|
||||
/**
|
||||
* A {@link Class} depicting the {@link Identifiable}'s type.
|
||||
*/
|
||||
private final Class<?> resourceType;
|
||||
@Getter private final Class<?> resourceType;
|
||||
|
||||
/**
|
||||
* Default base path as empty.
|
||||
*/
|
||||
private String basePath = "";
|
||||
@Getter @Setter private String basePath = "";
|
||||
|
||||
/**
|
||||
* Default a assembler based on Spring MVC controller, resource type, and {@link RelProvider}. With this combination
|
||||
@@ -124,14 +127,4 @@ public class SimpleIdentifiableResourceAssembler<T extends Identifiable<?>> exte
|
||||
private String getPrefix() {
|
||||
return getBasePath().isEmpty() ? "" : getBasePath() + "/";
|
||||
}
|
||||
|
||||
public String getBasePath() {
|
||||
return this.basePath;
|
||||
}
|
||||
|
||||
public void setBasePath(String basePath) {
|
||||
this.basePath = basePath;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
3
pom.xml
3
pom.xml
@@ -39,7 +39,7 @@
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>2.0.0.M6</version>
|
||||
<version>2.0.0.M7</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
<module>basics</module>
|
||||
<module>api-evolution</module>
|
||||
<module>hypermedia</module>
|
||||
<module>affordances</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
|
||||
Reference in New Issue
Block a user