diff --git a/README.adoc b/README.adoc index ef9a2da..27fcde3 100644 --- a/README.adoc +++ b/README.adoc @@ -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. \ No newline at end of file +* 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 \ No newline at end of file diff --git a/affordances/README.adoc b/affordances/README.adoc new file mode 100644 index 0000000..8b06223 --- /dev/null +++ b/affordances/README.adoc @@ -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 { + + @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 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`. + +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 { +} +---- + +To round things out, you need to make some updates to the `Employee` domain object: + +[source,java] +---- +@Data +@Entity +@NoArgsConstructor +class Employee implements Identifiable { + + @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 { + + 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>> 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> 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>> 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 { + + List 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 { + + ... + + /** + * Retain default links provided by {@link SimpleIdentifiableResourceAssembler}, but add extra ones to each {@link Manager}. + * + * @param resource + */ + @Override + protected void addLinks(Resource 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>> 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> 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 { + + /** + * Define links to add to every individual {@link Resource}. + * + * @param resource + */ + @Override + protected void addLinks(Resource 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> 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`. + So instead, you want to fall back to `SimpleResourceAssembler`, in which NO links are defined out of the box. +* Because there are no defined routes, you are in full control. +** `addLinks(Resource resource)` defines links for single items +** `addLinks(Resources> 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> resources)` gives you access to a single item's `Resource` 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 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 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> findOne(@PathVariable Long id) { + + Resource managerResource = controller.findOne(id).getBody(); + Resource 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` 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>` 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` 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. diff --git a/affordances/pom.xml b/affordances/pom.xml new file mode 100644 index 0000000..9e4eca8 --- /dev/null +++ b/affordances/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + spring-hateoas-examples-affordances + Spring HATEOAS - Examples - Affordances + jar + + + org.springframework.hateoas.examples + spring-hateoas-examples + 1.0.0.BUILD-SNAPSHOT + + + + + org.springframework.hateoas.examples + commons + 1.0.0.BUILD-SNAPSHOT + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/affordances/src/main/java/org/springframework/hateoas/examples/DatabaseLoader.java b/affordances/src/main/java/org/springframework/hateoas/examples/DatabaseLoader.java new file mode 100644 index 0000000..9583481 --- /dev/null +++ b/affordances/src/main/java/org/springframework/hateoas/examples/DatabaseLoader.java @@ -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")); + }; + } + +} diff --git a/affordances/src/main/java/org/springframework/hateoas/examples/Employee.java b/affordances/src/main/java/org/springframework/hateoas/examples/Employee.java new file mode 100644 index 0000000..259c13b --- /dev/null +++ b/affordances/src/main/java/org/springframework/hateoas/examples/Employee.java @@ -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 { + + @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 getId() { + return Optional.ofNullable(this.id); + } +} diff --git a/affordances/src/main/java/org/springframework/hateoas/examples/EmployeeController.java b/affordances/src/main/java/org/springframework/hateoas/examples/EmployeeController.java new file mode 100644 index 0000000..24abd88 --- /dev/null +++ b/affordances/src/main/java/org/springframework/hateoas/examples/EmployeeController.java @@ -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>> findAll() { + return ResponseEntity.ok( + assembler.toResources(repository.findAll())); + } + + @GetMapping("/employees/{id}") + ResponseEntity> 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)); + } +} diff --git a/affordances/src/main/java/org/springframework/hateoas/examples/EmployeeRepository.java b/affordances/src/main/java/org/springframework/hateoas/examples/EmployeeRepository.java new file mode 100644 index 0000000..5d0d119 --- /dev/null +++ b/affordances/src/main/java/org/springframework/hateoas/examples/EmployeeRepository.java @@ -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 { + +} diff --git a/affordances/src/main/java/org/springframework/hateoas/examples/EmployeeResourceAssembler.java b/affordances/src/main/java/org/springframework/hateoas/examples/EmployeeResourceAssembler.java new file mode 100644 index 0000000..f1917f0 --- /dev/null +++ b/affordances/src/main/java/org/springframework/hateoas/examples/EmployeeResourceAssembler.java @@ -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 { + + /** + * 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 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> resources) { + resources.add(getCollectionLinkBuilder().withSelfRel() + .andAffordance(afford(methodOn(EmployeeController.class).newEmployee(null)))); + } +} diff --git a/affordances/src/main/java/org/springframework/hateoas/examples/HypermediaConfiguration.java b/affordances/src/main/java/org/springframework/hateoas/examples/HypermediaConfiguration.java new file mode 100644 index 0000000..02c6fb7 --- /dev/null +++ b/affordances/src/main/java/org/springframework/hateoas/examples/HypermediaConfiguration.java @@ -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; + } + } +} diff --git a/affordances/src/main/java/org/springframework/hateoas/examples/SpringHateoasAffordancesApplication.java b/affordances/src/main/java/org/springframework/hateoas/examples/SpringHateoasAffordancesApplication.java new file mode 100644 index 0000000..5d4d55c --- /dev/null +++ b/affordances/src/main/java/org/springframework/hateoas/examples/SpringHateoasAffordancesApplication.java @@ -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); + } +} diff --git a/affordances/src/test/java/org/springframework/hateoas/examples/EmployeeControllerTests.java b/affordances/src/test/java/org/springframework/hateoas/examples/EmployeeControllerTests.java new file mode 100644 index 0000000..d0be990 --- /dev/null +++ b/affordances/src/test/java/org/springframework/hateoas/examples/EmployeeControllerTests.java @@ -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"))); + } +} \ No newline at end of file diff --git a/commons/src/main/java/org/springframework/hateoas/SimpleIdentifiableResourceAssembler.java b/commons/src/main/java/org/springframework/hateoas/SimpleIdentifiableResourceAssembler.java index 696f3fa..88d650e 100644 --- a/commons/src/main/java/org/springframework/hateoas/SimpleIdentifiableResourceAssembler.java +++ b/commons/src/main/java/org/springframework/hateoas/SimpleIdentifiableResourceAssembler.java @@ -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> 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> exte private String getPrefix() { return getBasePath().isEmpty() ? "" : getBasePath() + "/"; } - - public String getBasePath() { - return this.basePath; - } - - public void setBasePath(String basePath) { - this.basePath = basePath; - } - - } diff --git a/pom.xml b/pom.xml index 9603040..79b6192 100644 --- a/pom.xml +++ b/pom.xml @@ -39,7 +39,7 @@ org.springframework.boot spring-boot-starter-parent - 2.0.0.M6 + 2.0.0.M7 @@ -48,6 +48,7 @@ basics api-evolution hypermedia + affordances