HATEOAS is a feature of the REST application architecture that allows you to navigate REST APIs just as easily as you can navigate websites. You can use HATEOAS to follow embedded URIs pointing to other resources to explore and interact with an API. This blog post explains HATEOAS in more detail and covers what we encountered while working with it.

What Is HATEOAS?

Hypermedia as the Engine of Application State (HATEOAS) is an often-overlooked part of the representational state transfer (REST) application architecture. Instead of needing extensive documentation of an API, a basic understanding of hypermedia is sufficient to interact with a service when using HATEOAS. Specifically, a REST client invokes an endpoint through an initial Uniform Resource Identifier (URI) as an entry point that returns a response containing references to additional resources. The client follows these references to discover and interact with the service. Typically, links (i.e. URIs) and media types make up the references.1 For the sake of brevity, we won’t dive deeper into the media types, as this blog post only focuses on JavaScript Object Notation (JSON) representations of resources.

A REST API features a client-server architecture that manages requests through the Hypertext Transfer Protocol (HTTP). The communication is stateless, but responses may be cached on the client. The API should offer a uniform interface, which means:

  • Requested resources are identifiable (e.g. through URIs) and separate from the internal representation.
  • Resources can be modified based on their returned state. No extra information should be necessary.
  • The response is self-descriptive so that the client can process it (e.g. with a media type).
  • Hypertext or hypermedia within the response describes the available actions to the client (HATEOAS).

Additionally, the API can use a layered system (security, load balancing, etc.) that’s invisible to the client. An optional requirement is code on demand, which allows the server to send executable code to the client to extend its functionality.2

Example

As an example, a JSON response for requesting information about a bank account via GET /accounts/1 from a REST API using HATEOAS may look as follows:

{
  "balance": {
    "amount": 100,
    "currency": "CHF"
  },
  "_links": {
    "self": {
      "uri": "/accounts/1"
    },
    "invoices": {
      "uri": "/accounts/1/invoices"
    },
    "deposit": {
      "uri": "/accounts/1/deposit"
    },
    "withdraw": {
      "uri": "/accounts/1/withdraw"
    },
    "close": {
      "uri": "/accounts/1/close"
    }
  }
}

The _links object contains the necessary information for retrieving additional resources related to the requested account. Like browsing a website, following the links would bring the client to the next resource. Therefore, the interaction with the service is driven by hypertext.

Constraints

Roy T. Fielding, who defined the REST architecture standard, elaborates in a blog post that for an HTTP-based interface to be considered a REST API, you must use HATEOAS:

A REST API should be entered with no prior knowledge beyond the initial URI (bookmark) and set of standardized media types that are appropriate for the intended audience … all application state transitions must be driven by client selection of server-provided choices that are present in the received representations or implied by the user’s manipulation of those representations … [Failure here implies that out-of-band information is driving interaction instead of hypertext.]

Many people in software development consider stateless APIs using HTTP and URIs to be REST APIs. However, few APIs would pass as a genuine REST API since HATEOAS has not been widely adopted. Many APIs still need so-called “out-of-band” information — one example of this is Stripe’s API. According to the Richardson Maturity Model, such APIs can be considered less mature REST APIs.

Instead of debating whether or not you can call your API a REST API, we want to use this blog post as an opportunity to elaborate on the HATEOAS principle and share how you can make use of it. Even if your API doesn’t fulfill the necessary requirements for a proper REST API, HATEOAS can still improve it.

Uses

At the time of writing this post, searching Stack Overflow for questions tagged with HATEOAS returned around 600 results. This is minuscule compared to the more than 84,000 questions with a REST tag — especially considering that HATEOAS is supposed to be a requirement for a REST API.

There are some well-known APIs that do implement HATEOAS. For example, PayPal’s API makes use of HATEOAS. The company includes an example of its HATEOAS link representation in its API documentation:

{
  "links": [{
    "href": "https://api-m.paypal.com/v1/payments/sale/36C38912MN9658832",
    "rel": "self",
    "method": "GET"
  }, {
    "href": "https://api-m.paypal.com/v1/payments/sale/36C38912MN9658832/refund",
    "rel": "refund",
    "method": "POST"
  }, {
    "href": "https://api-m.paypal.com/v1/payments/payment/PAY-5YK922393D847794YKER7MUI",
    "rel": "parent_payment",
    "method": "GET"
  }]
}

The links array is part of the response when requesting a resource. Each object in the array shows how additional resources are related to the current resource (rel) and its location (href), in addition to showing how to interact with its corresponding endpoint (method).

Another example is GitHub’s API. Take a look at the example response for GET /organizations in the company’s API documentation:

[
  {
    "login": "github",
    "id": 1,
    "node_id": "MDEyOk9yZ2FuaXphdGlvbjE=",
    "url": "https://api.github.com/orgs/github",
    "repos_url": "https://api.github.com/orgs/github/repos",
    "events_url": "https://api.github.com/orgs/github/events",
    "hooks_url": "https://api.github.com/orgs/github/hooks",
    "issues_url": "https://api.github.com/orgs/github/issues",
    "members_url": "https://api.github.com/orgs/github/members{/member}",
    "public_members_url": "https://api.github.com/orgs/github/public_members{/member}",
    "avatar_url": "https://github.com/images/error/octocat_happy.gif",
    "description": "A great organization"
  }
]

Fields ending in url contain a URI to retrieve additional resources. For example, repos_url points to the endpoint to get the repositories of the github organization. In this case, the URIs aren’t encapsulated in a links object, but they’re instead mixed with the object’s other fields.

As we’ll see later in this blog post, no universally accepted standard for representing links between resources exists. Most HATEOAS libraries tackle the representations in a slightly different way. The core principle remains the same, though: The response of a REST request returns additional information based on the current state of the application to indicate how resources are linked.

HATEOAS in Practice

Yapeal, a Swiss FinTech startup and partner of 3ap, offers a mobile banking experience and makes heavy use of HATEOAS in its application landscape. The company has created its own framework to implement HATEOAS using custom OpenAPI extensions; JSON Schema; and JSONata, a powerful query and transformation language for JSON data. Based on its success with HATEOAS, we want to share the advantages and disadvantages we’ve come across while working with HATEOAS at Yapeal.

Advantages

You can build user interfaces around the HATEOAS principle by interacting with the links in the resource. The UI doesn’t need to interpret the state of a resource to determine whether to display a component. A simple check if a HATEOAS link is present is sufficient, so that you don’t repeat business logic that’s already present in the REST service itself. This can speed up development and decouples the frontend from the backend.

Let’s examine this with a concrete example. We want to build a UI to manage a bank account and have access to the following endpoints:

  • GET /accounts/{id} allows you to retrieve the details of your bank account.
  • GET /accounts/{id}/invoices allows you to list the invoices of your bank account with count and offset parameters for pagination.
  • POST /accounts/{id}/deposit allows you to deposit money into your bank account.
  • POST /accounts/{id}/withdraw allows you to withdraw money from your account if the balance is greater than zero.
  • POST /accounts/{id}/close allows a bank employee to close an account.

A very basic Vue.js component without HATEOAS in mind could look as follows. It displays the bank account resource mentioned earlier:

<template>
  <div v-if="account">
    <h1>Account Details</h1>
    <p>Balance: {{ account.balance.amount }} {{ account.balance.currency }}</p>

    <div>
      <div>
        <label for="amount">Amount: </label>
        <input id="amount" type="number" v-model.number="amount" />
      </div>
      <div>
        <button @click="deposit()">Deposit</button>
        <button v-if="account.balance.amount > 0" @click="withdraw()">Withdraw</button>
        <button v-if="$store.state.auth.roles.includes('employee')" @click="close()">
          Close Account
        </button>
      </div>
    </div>

    <Invoices :invoiceUri="`${accountUri}/invoices`" />
  </div>
  <div v-else>
    Loading...
  </div>
</template>

<script>
import Invoices from './Invoices.vue';

export default {
  props: {
    accountUri: {
      type: String,
      required: true
    }
  },
  data() {
    return {
      account: undefined,
      amount: 0
    }
  },
  mounted: async function() {
    await this.getAccount();
  },
  methods: {
    getAccount: async function() {
      this.account = await this.callApi('GET', this.accountUri);
    },
    deposit: async function() {
      await this.callApi('POST', `${this.accountUri}/deposit`, { amount: this.amount });
    },
    withdraw: async function() {
      await this.callApi('POST', `${this.accountUri}/withdraw`, { amount: this.amount });
    },
    close: async function() {
      await this.callApi('POST', `${this.accountUri}/close`);
    }
    callApi: async function(method, uri, payload) {
      // ...
    }
  },
  components: {
    Invoices
  }
};
</script>

Now let’s dive into the details of the advantages of HATEOAS and see how we can apply them to our specific example.

No Hardcoded URIs

Without HATEOAS, the client needs to know the URIs beforehand to call the correct endpoints. While they can be injected through configuration, often they’re hardcoded, as they are in our example. Instead of hardcoding the URIs in our component, we can refer to the links contained within our account resource:

    <Invoices :invoiceUri="account._links.invoices.uri" />
    deposit: async function() {
      await this.callApi('POST', this.account._links.deposit.uri, { amount: this.amount });
    },
    withdraw: async function() {
      await this.callApi('POST', this.account._links.withdraw.uri, { amount: this.amount });
    },
    close: async function() {
      await this.callApi('POST', this.account._links.close.uri);
    }

No Business Logic in UI

We can omit implementing the business logic that checks whether users can withdraw money with their current balance. The resource already represents the current state and if withdrawals are possible. Checking for the existence of a link is enough for our component to display the button to withdraw money correctly:

        <button v-if="account._links.withdraw" @click="withdraw()">Withdraw</button>

Entitlements

Additionally, determining whether a user is entitled to a certain action can be done solely in the backend. Depending on the API, a user is already authenticated by a token needed to interact with the available endpoints. Therefore, we don’t need to repeat this check in the frontend. It just needs to verify if a link is present:

        <button v-if="account._links.close" @click="close()">Close Account</button>

Paging

We can retrieve a list of invoices from GET /accounts/1/invoices, which features basic pagination:

{
  "invoices": [
    {
      "ref": "someReferenceNumber",
      "amount": 100,
      "currency": "CHF",
      "dueDate": "2021-04-01",
      "_links": {
        "self": {
          "uri": "/invoices/12345"
        }
      }
    },
    ...
  ],
  "totalCount": 25,
  "_links": {
    "self": {
      "uri": "/accounts/1/invoices?count=5&offset=5"
    },
    "next": {
      "uri": "/accounts/1/invoices?count=5&offset=10"
    },
    "previous": {
      "uri": "/accounts/1/invoices?count=5&offset=0"
    },
    "first": {
      "uri": "/accounts/1/invoices?count=5&offset=0"
    },
    "last": {
      "uri": "/accounts/1/invoices?count=5&offset=20"
    }
  }
}

To display the invoices, the example account component uses an Invoices component. Like its parent component, it doesn’t yet react to HATEOAS links:

<template>
  <div>
    <h2>Invoices</h2>
    <ul v-if="invoiceList">
      <li v-for="invoice in invoiceList.invoices" :key="invoice.ref">
        {{invoice.dueDate}}: {{invoice.amount}} {{invoice.currency}}
      </li>
    </ul>
    <div>
      <button v-if="hasPrevious()" @click="first()">Show First</button>
      <button v-if="hasPrevious()" @click="previous()">Show Previous</button>
      <button v-if="hasNext()" @click="next()">Show Next</button>
      <button v-if="hasNext()" @click="last()">Show Last</button>
    </div>
  </div>
</template>

<script>

export default {
  props: {
    invoiceUri: {
      type: String,
      required: true
    }
  },
  data() {
    return {
      invoiceList: undefined,
      count: 5,
      offset: 0
    }
  },
  computed: {
    hasNext: function() {
      return (this.offset + this.count) < this.invoiceList.totalCount;
    },
    hasPrevious: function() {
      return this.offset > 0;
    }
  },
  mounted: async function() {
    await this.getInvoices();
  },
  methods: {
    getInvoices: async function() {
      this.invoiceList = 
        await this.callApi('GET',
          `${this.invoiceUri}?count=${this.count}&offset=${this.offset}`);
    },
    previous: async function() {
      this.offset = Math.max(0, this.offset - this.count);
      await this.getInvoices();
    },
    next: async function() {
      this.offset = Math.min(this.totalCount - this.count, this.offset + this.count);
      await this.getInvoices();
    },
    first: async function() {
      this.offset = 0;
      await this.getInvoices();
    },
    last: async function() {
      this.offset = Math.max(0, this.invoiceList.totalCount - this.count);
      await this.getInvoices();
    },
    callApi: async function(method, uri, payload) {
      // ...
    }
  }
};
</script>

In this example, we have to calculate the proper offsets ourselves to move to the next page. If the API doesn’t return the total count of invoices, we can’t calculate the correct offset to move to the last page.

With HATEOAS, the UI doesn’t need to calculate whether or not to show certain navigational buttons. Instead, we can react to the links again:

    <div>
      <button 
        v-if="invoiceList._links.first"
        @click="changePage(invoiceList._links.first.uri)">Show First</button>
      <button
        v-if="invoiceList._links.previous"
        @click="changePage(invoiceList._links.previous.uri)">Show Previous</button>
      <button
        v-if="invoiceList._links.next"
        @click="changePage(invoiceList._links.next.uri)">Show Next</button>
      <button 
        v-if="invoiceList._links.last"
        @click="changePage(invoiceList._links.last.uri)">Show Last</button>
    </div>
      changePage: async function(uri) {
        this.invoiceList = await this.callApi('GET', uri);
      }

Since pagination usually follows the same pattern, you can abstract it into its own component that reacts to the available HATEOAS links.

Feature Flags

HATEOAS can also be used to hide or disable components in a UI. Instead of redeploying your frontend, the API service no longer returns certain links, and the components reacting to the links will no longer show the disabled feature.

Imagine that the bank offering the API used in this example runs out of money and wants to disable withdrawals. Since the button to withdraw money reacts to a HATEOAS link, the bank can adjust its API service to no longer send the withdrawal link. The button would then not be displayed. The frontend needs no additional changes:

        <button v-if="account._links.withdraw" @click="withdraw()">Withdraw</button>

Disadvantages

Using HATEOAS also comes with its disadvantages. Most of them are due to the low adoption of HATEOAS.

No Established Standard

There are many implementations of HATEOAS with different representations of links, which makes it harder to grasp and use HATEOAS. While there were attempts to standardize HATEOAS links (e.g. RFC 8288 and JSON Hypermedia API Language), HATEOAS libraries still ended up with different representations of links. The output may be semantically equivalent, but the syntax is different. Without an established standard, consuming APIs that implement HATEOAS becomes more difficult because your code might need to handle different link representations.

For example, Java Spring applications might use the Spring HATEOAS library. It defines links as follows:

{
  ...
  "_links":{
    "self":{
      "href":"http://localhost:8080/greeting?name=World"
    }
  }
}

Node Express developers could use the express-hateoas-links library. This library chooses a different representation for links:

{
  ...
  "links":[
    {
      "rel": "self",
      "method": "GET",
      "href": "http://127.0.0.1"
    }
  ]
}

For .NET developers, a library called RiskFirst.Hateoas features a slightly different link structure:

{
  ...
  "_links" : {
    "self": {
      "rel": "MyController\\GetModelRoute",
      "href": "https://api.example.com/my/1",
      "method": "GET"
    }
  }
}

While these libraries might support changing the output structure of the HATEOAS links, differing default configurations show that there isn’t a common consensus among the developer community on how to implement HATEOAS.

As we saw earlier, GitHub doesn’t feature a links or _links object at all in its responses. Instead, the company chose to include the URIs in the fields of its resource.

Meanwhile, Yapeal decided to create its own framework with link representations, e.g.:

{
  ...
  "_links": {
    "self": {
      "uri": "/examples/1"
    },
    "owner": {
      "uri": "/users/42",
      "embedded": {
        "name": "Sam Sepiol"
      }
    }
  },
  "_actions": {
    "approve": {
      "uri": "/examples/1/approve"
    },
    "reject": {
      "uri": "/examples/1/reject"
    }
  }
}

The _links object contains references to related resources, whereas the _actions object features actions to perform on the current resource. Additionally, a link might include extra data. In this example, we already know the name of the owner, so we can show this prefetched information to users without performing additional API calls.

Lack of Library Support

Even though REST is well-established in the world of software development, you might not find a suitable HATEOAS library for your programming language or framework of choice. Especially now that other approaches, like GraphQL, are gaining more popularity3, it’s reasonable to think that the need for HATEOAS libraries is declining, considering it never really gained much popularity.

More Bandwidth and Higher Latency

Nowadays, many APIs are consumed by mobile devices that suffer more from low bandwidth and high latency than traditional desktop computers. To circumvent this, APIs for mobile devices should use smaller payloads.

With HATEOAS, payloads can grow substantially due to the links provided in a response. Depending on the requested resource’s complexity, lots of different links may be returned, some of which might not be required by the mobile client.

Let’s take another look at PayPal’s API. The following is an example response for POST /v2/checkout/orders to create a new order:

{
  "id": "5O190127TN364715T",
  "status": "CREATED",
  "links": [
    {
      "href": "https://api-m.paypal.com/v2/checkout/orders/5O190127TN364715T",
      "rel": "self",
      "method": "GET"
    },
    {
      "href": "https://www.paypal.com/checkoutnow?token=5O190127TN364715T",
      "rel": "approve",
      "method": "GET"
    },
    {
      "href": "https://api-m.paypal.com/v2/checkout/orders/5O190127TN364715T",
      "rel": "update",
      "method": "PATCH"
    },
    {
      "href": "https://api-m.paypal.com/v2/checkout/orders/5O190127TN364715T/capture",
      "rel": "capture",
      "method": "POST"
    }
  ]
}

This response is 475 bytes when minified, with the links object making up 429 bytes, or approximately 90 percent of the entire payload.

While HATEOAS links don’t always have to take up such a considerable percentage of a payload, it can still be an undesirable side effect of using HATEOAS.

Extra Work Needed

Let’s examine the sample Java code from Baeldung’s Spring HATEOAS tutorial. It returns a list of customers, along with links to the customers and their orders:

@GetMapping(produces = { "application/hal+json" })
public CollectionModel<Customer> getAllCustomers() {
    List<Customer> allCustomers = customerService.allCustomers();

    for (Customer customer : allCustomers) {
        String customerId = customer.getCustomerId();
        Link selfLink = linkTo(CustomerController.class).slash(customerId).withSelfRel();
        customer.add(selfLink);
        if (orderService.getAllOrdersForCustomer(customerId).size() > 0) {
            Link ordersLink = linkTo(methodOn(CustomerController.class)
              .getOrdersForCustomer(customerId)).withRel("allOrders");
            customer.add(ordersLink);
        }
    }

    Link link = linkTo(CustomerController.class).withSelfRel();
    CollectionModel<Customer> result = CollectionModel.of(allCustomers, link);
    return result;
}

Instead of just returning a list of all customers, the programmer needs to know how the resource relates to other accessible resources and add the corresponding links to the response. This can be done by creating Link instances based on the current state of the resource. In this example, the link to fetch orders is only appended if the customer has placed orders before.

Depending on the project, adding HATEOAS links in hindsight can be complex, and justifying the cost isn’t always possible. This complexity can be kept in check by following an API-first approach: Before you start implementing, define your API contract with its request and response structures so that the HATEOAS links within the responses are known to the developer beforehand. While classical API-first tools like Swagger and OpenAPI don’t explicitly support HATEOAS, you’re free to define your response structures as you wish. This includes defining HATEOAS links.

Conclusion

HATEOAS is a powerful concept of the REST application architecture that enables consumers of an API to easily navigate between endpoints to retrieve data without requiring deep knowledge of the API. It comes with clear benefits to users of REST APIs:

  • Informing about related resources and possible actions
  • Decoupling the frontend from the backend

The disadvantages of HATEOAS stem mainly from the poor adoption and bigger payloads. There’s no accepted standard, and few resources on HATEOAS exist. As with every additional concept in your codebase, extra work is required.

Yapeal decided to put in the extra work to implement HATEOAS, and we’ve seen the advantages in action while working with the company. Building UIs dynamically with JSON schemas and using HATEOAS to link different resources allows Yapeal to create new features quickly without sacrificing usability or security. The company’s API-first design process is essential to its success, and HATEOAS plays a significant role in it.

If you’re creating a new API, consider giving HATEOAS a try. The decoupling of the frontend from the backend alone is worth the effort.


  1. HATEOAS on Wikipedia ↩︎

  2. What is a REST API ↩︎

  3. Google Trends for HATEOAS and GraphQL ↩︎