Skip to content

Latest commit

 

History

History
 
 

spring-hateoas-and-spring-data-rest

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 

Spring HATEOAS - Combined with Spring Data REST

A common dilemna for people familiar with both Spring HATEAOS as well as Spring Data REST is deciding which to use. You can use both, in the same application. This section will show how.

This example will borrow the concept found in Oliver Drotbohm’s Spring RESTbucks example—​a coffee shop fulfilling orders.

Defining the problem

PROBLEM: You wish to implement the concept of orders. These orders have certain status codes which dictate what transitions the system can take, e.g. an order can’t be fulfilled until it’s paid for, and a fulfilled order can’t be cancelled.

SOLUTION: You must encode a set of OrderStatus codes, and enforce them using a custom Spring Web MVC controller. This controller should have routes that appear alongside the ones provided by Spring Data REST.

Getting off the ground

To build this example, you need:

  • Spring Data REST

  • Spring HATEAOS

  • Spring Data JPA

  • H2

Important
It’s especially easy if you use http://start.spring.io and pick Rest Repositories, Spring HATEOAS, Spring Data JPA, and H2 Database.

Assuming you have an empty project with these dependencies, you can get underway.

Before you can code a custom controller, you need the basics of an ordering system. And that starts with a domain object:

@Entity
@Table(name = "ORDERS") // (1)
class Order {

    @Id @GeneratedValue
    private Long id; // (2)

    private OrderStatus orderStatus; // (3)

    private String description; // (4)

    private Order() {
        this.id = null;
        this.orderStatus = OrderStatus.BEING_CREATED;
        this.description = "";
    }

    public Order(String description) {
        this();
        this.description = description;
    }

    ...
}
  1. In SQL, ORDER is a reserved word. Spring Data defaults to calling the table order which requires the override using JPA’s @Table annotation.

  2. The id field is your primary key set to automatic generation.

  3. Every Order has an OrderStatus, a value type that embodies the current state.

  4. description represents the rest of the POJOs data.

Also shown are two constructors: an private no-arg one to support JPA and one used by you to create new entries.

The next step in defining your domain is to define OrderStatus. Assuming we want a general flow of Create an orderPay for an orderFulfill an order, with the option to cancel only if you have not yet paid for it, this will do nicely:

public enum OrderStatus {

  BEING_CREATED, PAID_FOR, FULFILLED, CANCELLED;

  /**
   * Verify the transition between {@link OrderStatus} is valid.
   *
   * NOTE: This is where any/all rules for state transitions should be kept and enforced.
   */
  static boolean valid(OrderStatus currentStatus, OrderStatus newStatus) {

    if (currentStatus == BEING_CREATED) {
      return newStatus == PAID_FOR || newStatus == CANCELLED;
    } else if (currentStatus == PAID_FOR) {
      return newStatus == FULFILLED;
    } else if (currentStatus == FULFILLED) {
      return false;
    } else if (currentStatus == CANCELLED) {
      return false;
    } else {
      throw new RuntimeException("Unrecognized situation.");
    }
  }
}

At the top are the actual states. At the bottom is a static validation method. This is where the rules of state transitions are defined, and where the rest of the system should look to discern whether or not a transition is valid.

The last step to get off the ground is a Spring Data repository definition:

public interface OrderRepository extends CrudRepository<Order, Long> {

}

This repository extends Spring Data Commons' CrudRepository, filling in the domain and key types (Order and Long). For this example, there is no need for custom finder methods.

For good measure, why don’t you preload some data?

@Component
public class DatabaseLoader {

  @Bean
  CommandLineRunner init(OrderRepository repository) { // (1)

    return args -> { // (2)
      repository.save(new Order("grande mocha")); // (3)
      repository.save(new Order("venti hazelnut machiatto"));
    };
  }
}
  1. Returning a CommandLineRunner as a Spring bean will result in an object that Spring Boot invokes when the app finished starting up.

  2. Since CommandLineRunner is a Java 8 functional interface, you may simply return a lambda expression instead of creating an instance.

  3. Inside the lambda, you can leverage the injected OrderRepository to create a couple initial orders.

Since you’re building an API, why not serve it at the root path of /api? To do so, you need to create an application.yml:

Example 1. src/main/resources/application.yml
spring:
  data:
    rest:
      base-path: /api

It should be stated that right here, you can launch your application. Spring Boot will launch the web container, preload the data, and then bring Spring Data REST online. Spring Data REST with all of its prebuilt, hypermedia-powered routes, will respond to calls to create, replace, update and delete Order objects.

But Spring Data REST will know nothing of valid and invalid state transitions. It’s pre-built links will help you navigate from /api to the aggregate root for all orders, to individual entries, and back. But there will no concept of paying for, fulfilling, or cancelling orders. At least, not embedded in the hypermedia. The only hint end users may have are the payloads of the existing orders.

And that’s not effective.

No, it’s better to create some extra operations and then serve up their links when appropriate.

Creating custom operations

For starters, you can create a custom controller that is registered under the same /api like this:

@BasePathAwareController // (1)
public class CustomOrderController {

  private final OrderRepository repository;

  public CustomOrderController(OrderRepository repository) { // (2)
    this.repository = repository;
  }

  ...
}
  1. Spring Data REST’s @BasePathAwareController is used to denote a @RestController that only wishes to have the same base path (/api in this example).

  2. The controller receives a copy of the OrderRepository via constructor injection.

To add a method that supports making payments could look like this:

@PostMapping("/orders/{id}/pay") // (1)
ResponseEntity<?> pay(@PathVariable Long id) { // (2)

  Order order = this.repository.findById(id).orElseThrow(() -> new OrderNotFoundException(id)); // (3)

  if (valid(order.getOrderStatus(), OrderStatus.PAID_FOR)) { // (4)

    order.setOrderStatus(OrderStatus.PAID_FOR);
    return ResponseEntity.ok(repository.save(order)); // (5)
  }

  return ResponseEntity.badRequest()
      .body("Transitioning from " + order.getOrderStatus() + " to " + OrderStatus.PAID_FOR + " is not valid."); // (6)
}
  1. Invoking POST /orders/{id}/pay is a signal for end users to signal "I want to pay for this".

  2. Spring MVC decodes the {id} piece of the URI into an id argument.

  3. Use OrderRepository to retrieve the current Order or throw an exception.

  4. Check if the transition from the order’s current status to PAID_FOR is valid.

  5. If it is valid, update the order’s status and save it back to the database.

  6. If it is not valid, return an HTTP Bad Request status code with details about the requested transition in the response body.

Note
It’s important to note this only shows transitioning to a different state, i.e. OrderStatus. It doesn’t carry the concept of collecting payment and thus doesn’t denote currency.

I suggest reading that method a couple more times. If you grok it, then the following operations should make perfect sense:

@PostMapping("/orders/{id}/cancel")
ResponseEntity<?> cancel(@PathVariable Long id) {

  Order order = this.repository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));

  if (valid(order.getOrderStatus(), OrderStatus.CANCELLED)) {

    order.setOrderStatus(OrderStatus.CANCELLED);
    return ResponseEntity.ok(repository.save(order));
  }

  return ResponseEntity.badRequest()
      .body("Transitioning from " + order.getOrderStatus() + " to " + OrderStatus.CANCELLED + " is not valid.");
}

@PostMapping("/orders/{id}/fulfill")
ResponseEntity<?> fulfill(@PathVariable Long id) {

  Order order = this.repository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));

  if (valid(order.getOrderStatus(), OrderStatus.FULFILLED)) {

    order.setOrderStatus(OrderStatus.FULFILLED);
    return ResponseEntity.ok(repository.save(order));
  }

  return ResponseEntity.badRequest()
      .body("Transitioning from " + order.getOrderStatus() + " to " + OrderStatus.FULFILLED + " is not valid.");
}

This is how you code the transitions and ensuring they are only carried out if valid. But it doesn’t alter the hypermedia served by Spring Data REST. Hence, end users still don’t know about the extra operations nor if they are appropriate or not.

Altering what Spring Data REST is serving

That requires creating something that can alter the object before it gets serialized. Spring HATEOAS provides a RepresentationModelProcessor<T> as the means to define a post processor. In this case, you’d be interested in post processing EntityModel<Order> objects (instead of just Order objects).

Like this:

@Component
public class OrderProcessor implements RepresentationModelProcessor<EntityModel<Order>> { // (1)

  private final RepositoryRestConfiguration configuration;

  public OrderProcessor(RepositoryRestConfiguration configuration) { // (2)
    this.configuration = configuration;
  }

  ...
}
  1. Implementing RepresentationModelProcessor for EntityModel<Order> will gives us a handle on the hypermedia endowed object Spring Data REST assembles.

  2. We need a copy of Spring Data REST’s RepositoryRestConfiguration bean in order to know the base path. The way we use it will be shown below.

This interface only has one method to implement, T process(T model), where you can augment (or completely replace) the "thing" before it gets serialized.

Check it out:

@Override
public EntityModel<Order> process(EntityModel<Order> model) {

  CustomOrderController controller = methodOn(CustomOrderController.class); // (1)
  String basePath = configuration.getBasePath().toString(); // (2)

  // If PAID_FOR is valid, add a link to the `pay()` method
  if (valid(model.getContent().getOrderStatus(), OrderStatus.PAID_FOR)) {
    model.add(applyBasePath( //
        linkTo(controller.pay(model.getContent().getId())) //
            .withRel(IanaLinkRelations.PAYMENT), //
        basePath));
  }

  // If CANCELLED is valid, add a link to the `cancel()` method
  if (valid(model.getContent().getOrderStatus(), OrderStatus.CANCELLED)) {
    model.add(applyBasePath( //
        linkTo(controller.cancel(model.getContent().getId())) //
            .withRel(LinkRelation.of("cancel")), //
        basePath));
  }

  // If FULFILLED is valid, add a link to the `fulfill()` method
  if (valid(model.getContent().getOrderStatus(), OrderStatus.FULFILLED)) {
    model.add(applyBasePath( //
        linkTo(controller.fulfill(model.getContent().getId())) //
            .withRel(LinkRelation.of("fulfill")), //
        basePath));
  }

  return model;
}
  1. Get a hold of the CustomOrderController class with your custom pay, cancel, and fulfill methods.

  2. Look up the basePath Spring Data REST has been configured with.

Check the model object’s payload (an Order object) for the current OrderStatus. Check if PAID_FOR is valid. If so, add a link to that method. Repeat for the other two state transitions.

If you’ll notice, there is another function, applyBasePath, used for each of these links. A gap between Spring HATEOAS and Spring Data REST is that Spring HATEAOS knows nothing about Spring Data REST’s basePath setting. Hence, when you build a link to CustomerOrderController, it won’t know about @BasePathAwareController. So you have to put in yourself (for now).

Important
It’s to implement this. Otherwise, end users will only see links to /orders/{id}/pay, but the controller will expect /api/orders/{id}/pay
/**
 * Adjust the {@link Link} such that it starts at {@literal basePath}.
 *
 * @param link - link presumably supplied via Spring HATEOAS
 * @param basePath - base path provided by Spring Data REST
 * @return new {@link Link} with these two values melded together
 */
private static Link applyBasePath(Link link, String basePath) {

  URI uri = link.toUri();

  URI newUri = null;
  try {
    newUri = new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), //
        uri.getPort(), basePath + uri.getPath(), uri.getQuery(), uri.getFragment());
  } catch (URISyntaxException e) {
    e.printStackTrace();
  }

  return new Link(newUri.toString(), link.getRel());
}

This functional essentially extracts the URI of the incoming link, inserts the basePath at the front of it’s path, and then fashions a new Spring HATEAOS `link.

Interacting with your API

With this, you can easily run things, and see your conditional links appear:

$ curl localhost:8080/api/orders/1
{
  "orderStatus" : "BEING_CREATED",
  "description" : "grande mocha",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/api/orders/1"
    },
    "order" : {
      "href" : "http://localhost:8080/api/orders/1"
    },
    "payment" : {
      "href" : "http://localhost:8080/api/orders/1/pay"
    },
    "cancel" : {
      "href" : "http://localhost:8080/api/orders/1/cancel"
    }
  }
}

Apply the payment link:

$ curl -X POST localhost:8080/api/orders/1/pay
{
  "id" : 1,
  "orderStatus" : "PAID_FOR",
  "description" : "grande mocha"
}

$ curl localhost:8080/api/orders/1
{
  "orderStatus" : "PAID_FOR",
  "description" : "grande mocha",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/api/orders/1"
    },
    "order" : {
      "href" : "http://localhost:8080/api/orders/1"
    },
    "fulfill" : {
      "href" : "http://localhost:8080/api/orders/1/fulfill"
    }
  }
}

The pay and cancel links have disappeared, replaced with a fulfill link. Fulfill the order and see the final state:

$ curl -X POST localhost:8080/api/orders/1/fulfill
{
  "id" : 1,
  "orderStatus" : "FULFILLED",
  "description" : "grande mocha"
}

$ curl localhost:8080/api/orders/1
{
  "orderStatus" : "FULFILLED",
  "description" : "grande mocha",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/api/orders/1"
    },
    "order" : {
      "href" : "http://localhost:8080/api/orders/1"
    }
  }
}

The first drink order has been fulfilled. If you cancel the second order, you can see what it’s links tell you:

$ curl localhost:8080/api/orders/2
{
  "orderStatus" : "BEING_CREATED",
  "description" : "venti hazelnut machiatto",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/api/orders/2"
    },
    "order" : {
      "href" : "http://localhost:8080/api/orders/2"
    },
    "payment" : {
      "href" : "http://localhost:8080/api/orders/2/pay"
    },
    "cancel" : {
      "href" : "http://localhost:8080/api/orders/2/cancel"
    }
  }
}

$ curl -X POST localhost:8080/api/orders/2/cancel
{
  "id" : 2,
  "orderStatus" : "CANCELLED",
  "description" : "venti hazelnut machiatto"
}

$ curl localhost:8080/api/orders/2
{
  "orderStatus" : "CANCELLED",
  "description" : "venti hazelnut machiatto",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/api/orders/2"
    },
    "order" : {
      "href" : "http://localhost:8080/api/orders/2"
    }
  }
}

It has reached a different end state, and the links guided you the whole way.

Conclusion

This is what it takes to create custom, conditional, hypermedia-based routes, and tied them into the unconditional ones provided by Spring Data REST. Seamlessly. By letting Spring Data REST do the heavy lifting, you are freed up to work on such business-oriented logic when building a resilient API.