Server-Driven API Workflows with Quarkus and HAL
Reduce client-side workflow drift and integration bugs by letting the API advertise valid transitions based on resource state.
A shipment with status CREATED looks fine in JSON. It still leaves the client guessing.
Can it pay now? Can it cancel? Is ship valid yet, or does that come back as 409 Conflict? In a lot of APIs, that logic ends up in client code, buried in if statements and stale enums. The server owns the workflow, but the client still tries to reconstruct it from one status field.
HAL helps here, but not as a religion. Not every REST response needs a _links block. The value shows up when the server can tell the client what is valid next. In Quarkus, the REST guide already gives us the parts we need: @RestLink, @InjectRestLinks, and programmatic wrappers like HalEntityWrapper. The extension page for quarkus-hal still marks it as experimental. Fine for a focused walkthrough, and also a useful reminder not to treat it like invisible infrastructure.
We build a small shipment workflow API where:
plain JSON still works
Accept: application/hal+jsonreturns discoverable linksthe links change with shipment state
invalid transitions still fail on the server
tests prove the link contract instead of only the status code
What we build
We build Shipment Next Actions, a tiny Quarkus service with:
POST /shipmentsto create a shipmentGET /shipmentsto list shipmentsGET /shipments/{id}to fetch one shipment as JSON or HALPUT /shipments/{id}/payPUT /shipments/{id}/packPUT /shipments/{id}/shipPUT /shipments/{id}/deliverPUT /shipments/{id}/cancel
The workflow is intentionally small:
CREATEDcanpayorcancelPAIDcanpackorcancelPACKEDcanshipSHIPPEDcandeliverDELIVEREDandCANCELLEDare terminal
The logistics domain is fake on purpose. What matters is that the client stops guessing which button to enable.
What you need
JDK 21
curloptional:
jqfor readable JSONabout ☕️☕️
Project setup
Create the app with Quarkus REST, HAL, and REST links in one shot or start from my Github repsoitory.
quarkus create app dev.themainthread:shipment-next-actions \
--extension='rest-jackson,hal,rest-links' \
--java=21 \
--no-code
cd shipment-next-actionsUnder src/main/java, create these package directories:
dev/themainthread/shipments/apidev/themainthread/shipments/modeldev/themainthread/shipments/service
Under src/test/java, create:
dev/themainthread/shipments
quarkus-rest-jackson gives us JSON serialization. quarkus-hal adds the HAL renderers and wrappers described in the Quarkus REST guide. quarkus-rest-links adds the link injection support behind @RestLink and @InjectRestLinks.
Start with plain JSON
I want a plain baseline before we add _links. If we start with HAL immediately, it tends to only looks like an add on. But I want to show the difference in thinking.
Domain model
Create ShipmentStatus.java:
package dev.themainthread.shipments.model;
public enum ShipmentStatus {
CREATED,
PAID,
PACKED,
SHIPPED,
DELIVERED,
CANCELLED
}Create Shipment.java:
package dev.themainthread.shipments.model;
public record Shipment(
long id,
String trackingNumber,
String recipient,
String destinationCity,
ShipmentStatus status) {
public Shipment withStatus(ShipmentStatus newStatus) {
return new Shipment(id, trackingNumber, recipient, destinationCity, newStatus);
}
}Create CreateShipmentRequest.java:
package dev.themainthread.shipments.api;
public record CreateShipmentRequest(
String trackingNumber,
String recipient,
String destinationCity) {
}The model is boring. Like in many of my examples. id is numeric so the Quarkus REST link machinery can resolve instance links without extra hints. trackingNumber is still there because real users remember that value, not database IDs.
In-memory store
Create ShipmentStore.java:
package dev.themainthread.shipments.service;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import dev.themainthread.shipments.api.CreateShipmentRequest;
import dev.themainthread.shipments.model.Shipment;
import dev.themainthread.shipments.model.ShipmentStatus;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.NotFoundException;
@ApplicationScoped
public class ShipmentStore {
private final Map<Long, Shipment> shipments = new ConcurrentHashMap<>();
private final AtomicLong ids = new AtomicLong();
public Shipment create(CreateShipmentRequest request) {
long id = ids.incrementAndGet();
Shipment shipment = new Shipment(
id,
request.trackingNumber(),
request.recipient(),
request.destinationCity(),
ShipmentStatus.CREATED);
shipments.put(id, shipment);
return shipment;
}
public List<Shipment> list() {
return shipments.values().stream()
.sorted(Comparator.comparingLong(Shipment::id))
.toList();
}
public Shipment get(long id) {
Shipment shipment = shipments.get(id);
if (shipment == null) {
throw new NotFoundException("Shipment " + id + " was not found");
}
return shipment;
}
public Shipment update(Shipment shipment) {
shipments.put(shipment.id(), shipment);
return shipment;
}
public void clear() {
shipments.clear();
ids.set(0);
}
}Yes, this is only memory. I do not want JPA mapping, transactions, and optimistic locking sneaking into an article about hypermedia and server-owned workflow. I have some more tutorials about JPA if you want to look for them.
First resource
Create this initial ShipmentResource.java:
package dev.themainthread.shipments;
import java.net.URI;
import java.util.List;
import dev.themainthread.shipments.api.CreateShipmentRequest;
import dev.themainthread.shipments.model.Shipment;
import dev.themainthread.shipments.service.ShipmentStore;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import org.jboss.resteasy.reactive.common.util.RestMediaType;
@Path("/shipments")
@Produces(MediaType.APPLICATION_JSON)
public class ShipmentResource {
private final ShipmentStore store;
public ShipmentResource(ShipmentStore store) {
this.store = store;
}
@POST
@Consumes(MediaType.APPLICATION_JSON)
public Response create(CreateShipmentRequest request, @Context UriInfo uriInfo) {
Shipment created = store.create(request);
URI location = uriInfo.getAbsolutePathBuilder()
.path(Long.toString(created.id()))
.build();
return Response.created(location)
.entity(created)
.build();
}
@GET
public List<Shipment> list() {
return store.list();
}
@GET
@Path("/{id}")
public Shipment get(@PathParam("id") long id) {
return store.get(id);
}
}Start dev mode:
./mvnw quarkus:devCreate one shipment:
curl -s -X POST http://localhost:8080/shipments \
-H 'Content-Type: application/json' \
-d '{
"trackingNumber":"TMT-1001",
"recipient":"Ada Lovelace",
"destinationCity":"Berlin"
}' | jqYou should get something like:
{
"id": 1,
"trackingNumber": "TMT-1001",
"recipient": "Ada Lovelace",
"destinationCity": "Berlin",
"status": "CREATED"
}Now list shipments:
curl -s http://localhost:8080/shipments | jqSo far this is plain JSON. That is the point. The server tells us the current state, but it does not tell us what is valid next.
Put the workflow on the server
The status field is only the snapshot. The transition rules are the contract.
At this point, pause for one prediction: if a shipment is CREATED, which actions should the client expose? If your answer is “that depends on client code,” that is the problem we are fixing.
Transition rules
Create TransitionNotAllowedException.java:
package dev.themainthread.shipments.service;
import dev.themainthread.shipments.model.Shipment;
public class TransitionNotAllowedException extends RuntimeException {
public TransitionNotAllowedException(Shipment shipment, String action) {
super("Shipment %d in status %s cannot %s"
.formatted(shipment.id(), shipment.status(), action));
}
}Create ShipmentWorkflow.java:
package dev.themainthread.shipments.service;
import java.util.List;
import dev.themainthread.shipments.model.Shipment;
import dev.themainthread.shipments.model.ShipmentStatus;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ShipmentWorkflow {
public Shipment pay(Shipment shipment) {
requireStatus(shipment, ShipmentStatus.CREATED, "pay");
return shipment.withStatus(ShipmentStatus.PAID);
}
public Shipment pack(Shipment shipment) {
requireStatus(shipment, ShipmentStatus.PAID, "pack");
return shipment.withStatus(ShipmentStatus.PACKED);
}
public Shipment ship(Shipment shipment) {
requireStatus(shipment, ShipmentStatus.PACKED, "ship");
return shipment.withStatus(ShipmentStatus.SHIPPED);
}
public Shipment deliver(Shipment shipment) {
requireStatus(shipment, ShipmentStatus.SHIPPED, "deliver");
return shipment.withStatus(ShipmentStatus.DELIVERED);
}
public Shipment cancel(Shipment shipment) {
if (shipment.status() != ShipmentStatus.CREATED && shipment.status() != ShipmentStatus.PAID) {
throw new TransitionNotAllowedException(shipment, "cancel");
}
return shipment.withStatus(ShipmentStatus.CANCELLED);
}
public List<String> allowedActions(Shipment shipment) {
return switch (shipment.status()) {
case CREATED -> List.of("pay", "cancel");
case PAID -> List.of("pack", "cancel");
case PACKED -> List.of("ship");
case SHIPPED -> List.of("deliver");
case DELIVERED, CANCELLED -> List.of();
};
}
private void requireStatus(Shipment shipment, ShipmentStatus expected, String action) {
if (shipment.status() != expected) {
throw new TransitionNotAllowedException(shipment, action);
}
}
}allowedActions is really domain logic. HAL is only one way to expose it.
Make bad transitions visible
Create ErrorResponse.java:
package dev.themainthread.shipments.api;
public record ErrorResponse(String error, String message) {
}Create TransitionNotAllowedExceptionMapper.java:
package dev.themainthread.shipments.api;
import dev.themainthread.shipments.service.TransitionNotAllowedException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
@Provider
public class TransitionNotAllowedExceptionMapper implements ExceptionMapper<TransitionNotAllowedException> {
@Override
public Response toResponse(TransitionNotAllowedException exception) {
return Response.status(Status.CONFLICT)
.type(MediaType.APPLICATION_JSON)
.entity(new ErrorResponse("transition_not_allowed", exception.getMessage()))
.build();
}
}Nothing fancy. Discoverable links are nice. A 409 when the client still does the wrong thing matters more.
Replace the resource with action endpoints
Replace ShipmentResource.java with this version:
package dev.themainthread.shipments;
import java.net.URI;
import java.util.List;
import dev.themainthread.shipments.api.CreateShipmentRequest;
import dev.themainthread.shipments.model.Shipment;
import dev.themainthread.shipments.service.ShipmentStore;
import dev.themainthread.shipments.service.ShipmentWorkflow;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
@Path("/shipments")
@Produces(MediaType.APPLICATION_JSON)
public class ShipmentResource {
private final ShipmentStore store;
private final ShipmentWorkflow workflow;
public ShipmentResource(ShipmentStore store, ShipmentWorkflow workflow) {
this.store = store;
this.workflow = workflow;
}
@POST
@Consumes(MediaType.APPLICATION_JSON)
public Response create(CreateShipmentRequest request, @Context UriInfo uriInfo) {
Shipment created = store.create(request);
URI location = uriInfo.getAbsolutePathBuilder()
.path(Long.toString(created.id()))
.build();
return Response.created(location)
.entity(created)
.build();
}
@GET
public List<Shipment> list() {
return store.list();
}
@GET
@Path("/{id}")
public Shipment get(@PathParam("id") long id) {
return store.get(id);
}
@PUT
@Path("/{id}/pay")
public Shipment pay(@PathParam("id") long id) {
return store.update(workflow.pay(store.get(id)));
}
@PUT
@Path("/{id}/pack")
public Shipment pack(@PathParam("id") long id) {
return store.update(workflow.pack(store.get(id)));
}
@PUT
@Path("/{id}/ship")
public Shipment ship(@PathParam("id") long id) {
return store.update(workflow.ship(store.get(id)));
}
@PUT
@Path("/{id}/deliver")
public Shipment deliver(@PathParam("id") long id) {
return store.update(workflow.deliver(store.get(id)));
}
@PUT
@Path("/{id}/cancel")
public Shipment cancel(@PathParam("id") long id) {
return store.update(workflow.cancel(store.get(id)));
}
}Try the happy path:
curl -s -X PUT http://localhost:8080/shipments/1/pay | jq
curl -s -X PUT http://localhost:8080/shipments/1/pack | jq
curl -s -X PUT http://localhost:8080/shipments/1/ship | jqNow try an invalid transition:
curl -s -X PUT http://localhost:8080/shipments/1/cancel | jqYou should get a 409 body like:
{
"error": "transition_not_allowed",
"message": "Shipment 1 in status SHIPPED cannot cancel"
}The server owns the workflow now. The client still has to know which endpoint to call next.
Add HAL without turning the API inside out
Here is the important separation:
@RestLinkand@InjectRestLinkshandle stable navigation likeselfandlistHalEntityWrapperhandles the links that depend on business state
If we fake the second part with static annotations alone, every shipment advertises pay, pack, ship, deliver, and cancel all the time. The code looks clean, but the API is lying about what the client can actually do.
Build one small HAL helper
Create ShipmentHalFactory.java:
package dev.themainthread.shipments.service;
import dev.themainthread.shipments.model.Shipment;
import io.quarkus.hal.HalEntityWrapper;
import io.quarkus.hal.HalService;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.core.Link;
@ApplicationScoped
public class ShipmentHalFactory {
private final HalService halService;
private final ShipmentWorkflow workflow;
public ShipmentHalFactory(HalService halService, ShipmentWorkflow workflow) {
this.halService = halService;
this.workflow = workflow;
}
public HalEntityWrapper<Shipment> wrap(Shipment shipment) {
HalEntityWrapper<Shipment> wrapper = halService.toHalWrapper(shipment);
for (String action : workflow.allowedActions(shipment)) {
wrapper.addLinks(Link.fromPath("/shipments/%d/%s".formatted(shipment.id(), action))
.rel(action)
.build());
}
return wrapper;
}
}That helper does the one HAL-specific job that matters in this tutorial: translate valid workflow actions into discoverable links.
Replace the GET methods
Now replace ShipmentResource.java one more time:
package dev.themainthread.shipments;
import java.net.URI;
import java.util.List;
import dev.themainthread.shipments.api.CreateShipmentRequest;
import dev.themainthread.shipments.model.Shipment;
import dev.themainthread.shipments.service.ShipmentHalFactory;
import dev.themainthread.shipments.service.ShipmentStore;
import dev.themainthread.shipments.service.ShipmentWorkflow;
import io.quarkus.hal.HalEntityWrapper;
import io.quarkus.resteasy.reactive.links.InjectRestLinks;
import io.quarkus.resteasy.reactive.links.RestLink;
import io.quarkus.resteasy.reactive.links.RestLinkType;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
@Path("/shipments")
@Produces(MediaType.APPLICATION_JSON)
public class ShipmentResource {
private final ShipmentStore store;
private final ShipmentWorkflow workflow;
private final ShipmentHalFactory halFactory;
public ShipmentResource(
ShipmentStore store,
ShipmentWorkflow workflow,
ShipmentHalFactory halFactory) {
this.store = store;
this.workflow = workflow;
this.halFactory = halFactory;
}
@POST
@Consumes(MediaType.APPLICATION_JSON)
public Response create(CreateShipmentRequest request, @Context UriInfo uriInfo) {
Shipment created = store.create(request);
URI location = uriInfo.getAbsolutePathBuilder()
.path(Long.toString(created.id()))
.build();
return Response.created(location)
.entity(created)
.build();
}
@GET
@Produces({ MediaType.APPLICATION_JSON, RestMediaType.APPLICATION_HAL_JSON })
@RestLink(rel = "list")
@InjectRestLinks
public List<Shipment> list() {
return store.list();
}
@GET
@Path("/{id}")
@Produces({ MediaType.APPLICATION_JSON, RestMediaType.APPLICATION_HAL_JSON })
@RestLink(rel = "self")
@InjectRestLinks(RestLinkType.INSTANCE)
public HalEntityWrapper<Shipment> get(@PathParam("id") long id) {
return halFactory.wrap(store.get(id));
}
@PUT
@Path("/{id}/pay")
public Shipment pay(@PathParam("id") long id) {
return store.update(workflow.pay(store.get(id)));
}
@PUT
@Path("/{id}/pack")
public Shipment pack(@PathParam("id") long id) {
return store.update(workflow.pack(store.get(id)));
}
@PUT
@Path("/{id}/ship")
public Shipment ship(@PathParam("id") long id) {
return store.update(workflow.ship(store.get(id)));
}
@PUT
@Path("/{id}/deliver")
public Shipment deliver(@PathParam("id") long id) {
return store.update(workflow.deliver(store.get(id)));
}
@PUT
@Path("/{id}/cancel")
public Shipment cancel(@PathParam("id") long id) {
return store.update(workflow.cancel(store.get(id)));
}
}Before you run this, make one quick prediction: after a shipment moves from CREATED to PAID, which links should disappear and which should appear?
If pay still shows up after the transition, the API is lying.
Verify the HAL response
Fetch a shipment with HAL:
curl -s -H 'Accept: application/hal+json' \
http://localhost:8080/shipments/1 | jqFor a SHIPPED shipment, you should see something like:
{
"id": 1,
"trackingNumber": "TMT-1001",
"recipient": "Ada Lovelace",
"destinationCity": "Berlin",
"status": "SHIPPED",
"_links": {
"self": {
"href": "http://localhost:8080/shipments/1"
},
"list": {
"href": "http://localhost:8080/shipments"
},
"deliver": {
"href": "http://localhost:8080/shipments/1/deliver"
}
}
}If you create a fresh shipment and fetch it immediately, the interesting links should be pay and cancel, not pack, ship, or deliver.
The collection view also changes shape under HAL:
curl -s -H 'Accept: application/hal+json' \
http://localhost:8080/shipments | jqQuarkus renders the list under _embedded.items.
Make the client follow links instead of guessing
Now the client asks for a shipment, reads _links.pay.href, and follows it.
Create a second shipment:
curl -s -X POST http://localhost:8080/shipments \
-H 'Content-Type: application/json' \
-d '{
"trackingNumber":"TMT-1002",
"recipient":"Grace Hopper",
"destinationCity":"Paris"
}' | jqExtract the pay link and follow it:
PAY_URL=$(curl -s -H 'Accept: application/hal+json' \
http://localhost:8080/shipments/2 | jq -r '._links.pay.href')
curl -s -X PUT "$PAY_URL" | jqFetch the shipment again:
curl -s -H 'Accept: application/hal+json' \
http://localhost:8080/shipments/2 | jq '._links'Now pay should be gone and pack should exist.
This is the mental model I want you to keep:
the resource state tells you where the shipment is
the HAL links tell you what the server is willing to accept next
Test the contract
The easy mistake with HAL: we check one curl response, see _links in the output, and move on.
The real important parts are:
default JSON still works
the HAL link set matches the workflow state
invalid transitions return
409
Create ShipmentResourceTest.java:
package dev.themainthread.shipments;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.endsWith;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.nullValue;
import dev.themainthread.shipments.service.ShipmentStore;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@QuarkusTest
public class ShipmentResourceTest {
@Inject
ShipmentStore store;
@BeforeEach
void resetStore() {
store.clear();
}
@Test
void shouldReturnPlainJsonByDefault() {
given()
.contentType("application/json")
.body("""
{
"trackingNumber":"TMT-2001",
"recipient":"Linus Torvalds",
"destinationCity":"Helsinki"
}
""")
.when()
.post("/shipments")
.then()
.statusCode(201)
.body("status", is("CREATED"));
given()
.when()
.get("/shipments")
.then()
.statusCode(200)
.body("$", hasSize(1))
.body("[0].trackingNumber", is("TMT-2001"))
.body("[0].status", is("CREATED"));
}
@Test
void shouldAdvertiseOnlyValidNextActionsInHal() {
long id = given()
.contentType("application/json")
.body("""
{
"trackingNumber":"TMT-2002",
"recipient":"Barbara Liskov",
"destinationCity":"Boston"
}
""")
.when()
.post("/shipments")
.then()
.statusCode(201)
.extract()
.path("id");
given()
.accept("application/hal+json")
.when()
.get("/shipments/{id}", id)
.then()
.statusCode(200)
.body("_links.self.href", endsWith("/shipments/" + id))
.body("_links.list.href", endsWith("/shipments"))
.body("_links.pay.href", endsWith("/shipments/" + id + "/pay"))
.body("_links.cancel.href", endsWith("/shipments/" + id + "/cancel"))
.body("_links.pack", nullValue());
given()
.when()
.put("/shipments/{id}/pay", id)
.then()
.statusCode(200)
.body("status", is("PAID"));
given()
.accept("application/hal+json")
.when()
.get("/shipments/{id}", id)
.then()
.statusCode(200)
.body("_links.pack.href", endsWith("/shipments/" + id + "/pack"))
.body("_links.cancel.href", endsWith("/shipments/" + id + "/cancel"))
.body("_links.pay", nullValue())
.body("_links.ship", nullValue());
}
@Test
void shouldRejectInvalidTransitions() {
long id = given()
.contentType("application/json")
.body("""
{
"trackingNumber":"TMT-2003",
"recipient":"Margaret Hamilton",
"destinationCity":"Cambridge"
}
""")
.when()
.post("/shipments")
.then()
.statusCode(201)
.extract()
.path("id");
given()
.when()
.put("/shipments/{id}/ship", id)
.then()
.statusCode(409)
.body("error", is("transition_not_allowed"))
.body("message", is("Shipment %d in status CREATED cannot ship".formatted(id)));
}
}Run the tests:
./mvnw testThe second test probably is the real one. It proves the link contract changed when the workflow changed. That is worth more here than another round of “endpoint returns 200.”
What HAL Helps With
HAL helps with discoverability. It does not remove the need for:
authorization checks on the action endpoints
server-side workflow enforcement
stable domain rules
useful error responses when a client still tries something invalid
In other words, a pay link does not mean the caller is allowed to pay. It means the resource is in a state where pay makes sense at the workflow level.
Two more practical limits:
quarkus-halis still marked experimental on the extension page, so keep an eye on version notes before hardening this into a long-lived external contractclients still need to understand the semantics of your
relnames;_links.shipis better than guessing URLs, but it is not self-writing business documentation
Small transfer exercise
If you want one small variation after the base path works, add a return-to-sender action for DELIVERED shipments.
Change only:
ShipmentWorkflow.allowedActionsone new
PUT /shipments/{id}/return-to-senderendpointone new transition method
one test that proves the link appears only after
DELIVERED
That small edit is enough to tell you if the model stuck with you. Happy playing around with this tutorial.
And feel free to share this tutorial to your network!
Closing
Lots of frameworks can emit _links. What really makes a difference is small and specific: Quarkus lets you combine two layers cleanly:
static navigation that the framework can infer
state-aware actions that your application has to tell the truth about
That is the difference between “hypermedia support” and “an API that actually helps clients make the next correct move.”


