Skip to main content

Sprinkle some HATEOAS on your Rails APIs

·21 mins

REST as a concept was introduced by Roy Fielding in his doctoral thesis, named Architectural Styles and the Design of Network-based Software Architectures. 16 years later, REST as an architecture is the most widely accepted way to design and build APIs. I am sure we have all heard about it, and most of us think we are building actual RESTful APIs. But, are we?

Let’s remind ourselves what REST is and then continue on something else that supplements REST, called HATEOAS.

So, what was REST again? #

I was thinking of a good way to explain this, but this gem by Ryan Tomako named How I Explained REST to My Wife is the best explanation of REST I have seen so far.

Overall, REST (an acronym for REpresentational State Transfer) is an architecture over which the Internet is built upon. The easiest way to explain REST is, in my opinion, the Richardson Maturity Model. Here, we will not go in much depth, but if you want to learn RMM in more depth, I recommend reading Richardson Maturity Model, steps toward the glory of REST written by Martin Fowler.

The RMM explains REST in four levels:

  1. Use a protocol to do remote procedure calls (RPC).
  2. Introduce Resources
  3. HTTP Verbs
  4. Hypermedia Controls

Let’s deconstruct all of these levels briefly.

Level 1 #

Level one basically states that we need to use a protocol (HTTP) to execute procedure calls on some remote location (a server). This also means that we will not use any of the mechanism that the protocol provides, but we will just use it as a tunnel to send the requests and responses.

Level 2 #

Level two is something very familiar to all of us, I am sure. Resources. Back in the day, when I was probably still in high school, RPC worked by communicating with a single endpoint on a server. This is called a “service endpoint”.

By implementing resources in an API, we provide multiple endpoints that will represent the resources. This means that instead of having one single multipurpose endpoint, we have an endpoint for every resource that the API exposes.

Level 3 #

With level 1 and level 2 the mechanisms of the protocol were skipped, because we think only about tunneling our calls instead of fully utilising the protocol. Level 3 takes this to the next level (pun intended) and states that any RESTful service needs to utilise HTTP verbs so clients can interact with resources.

If you are coming from Rails land, I am sure you already know this - there’s a big difference between GET, POST, PUT, PATCH and DELETE. Sure, you could handle these in various ways in your backend, but this is where Rails shines - it strongly encourages you to comply to REST.

If you do not obey Level 3 of the RMM, you could have a different endpoint for every action on the API side. For example, if you had a Pet resource, to create a new entry you could use a /pets/create or a /pets/update to update it. By utilising Level 3, when HTTP verbs come into play, you would use something that Rails makes super easy: GET /pets/:id to get a Pet, POST /pets to create a new Pet or PATCH /pets to update one.

Level 4 #

Now, while Rails does a great job to make you write RESTful APIs, Level 4 is something that falls onto you, the programmer, to implement. This is something that most of us don’t follow and we, as a community, still haven’t find the best way to accomplish.

Hypermedia Controls is REST’s “holy grail”, and the ultimate level of “RESTfulness”. It states that when an API is at level four, it’s clients can request resources in a specific data format and can navigate throughout the API by using Hypermedia Controls.

If you find this confusing, let’s take a step back and think about browsers and websites. When you use a website you usually know only the entry point, in the form of domainname.tld, like ieftimov.com or google.com. Then, by interacting the website via your browser you navigate throughout the different pages it has. Well, navigating is done by clicking on links, right? Imagine a website that instead of having links makes your remember every URL on the website and you have to type it in manually. Worst website ever!

Having this in mind, the idea of Hypermedia Controls means that a resource should provide links, so a client that consumes the resource can navigate through and interact with it without knowing any other endpoint except the entry point. Just like normal websites - the page provides you with the links so you can interact with it.

We will get into the format of the JSON responses that HATEOAS APIs return in a bit, but first we will look at another important mechanism of HATEAOS - content negotiation.

Content negotiation #

Content negotiation is a mechanism embedded into HTTP that allows web services to serve different versions (or formats) of documents. Or in REST terminology, resources. This is achieved by the Accept group of HTTP headers. For example, when requesting a web page the user agent (the browser) sends these (or similar) headers, depending on the browser:

Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch, br
Accept-Language: en-US,en;q=0.8

This tells the server that the user agent can accept the document in a text/html, compressed with one of the specified compression schemes, formatted in en-US (US English) language.

If we go back to APIs, Rails does a very good job in content negotiation. When we are building apps nowadays, we usually make sure our resources have a HTML, JSON and (maybe) an XML representation. I am sure all of us have seen this:

respond_to :html, :json, :xml

def index
  @pets = Pet.all
  respond_with(@pets)
end

Or maybe the older version of this code:

def index
  @pets = Pet.all
  respond_to do |format|
    format.html
    format.json { render :json => @pets }
    format.xml { render :xml => @pets }
  end
end

This tells our index action to allow three types of content format are requested, via content negotiation. HTML, JSON and XML are in fact Media (or MIME) types.

If you look at the example headers, you can notice the q parameter. This is called a Quality Value and it is what makes one content type more important than another. In other words, the q parameter adds a relative weight to the preference for that associated kind of content.

For example, let’s say we have the following Accept header:

Accept: application/json; q=0.5, application/xml; q=0.001

This tells the server that the client prefers JSON content type much more than XML. If the q value is not specified, then the value is defaulted to 1. You can read more about content negotiation in RFC 7231.

HATEOAS #

So what is HATEOAS? It is an acronym, just like REST, which translates to Hypermedia As The Engine Of Application State. Whoa, what a mouthful, right?

Like we mentioned, Level 4 of the RMM states that an API should provide hypermedia controls to navigate in and interact with. But, what does HATEOAS have to do with this? Well, think about this - REST means Representational State Transfer while HATEOAS means Hypermedia As The Engine Of Application State. If you look just as the names, it makes sense that HATEOAS is a mechanism of REST. It all revolves around application state and state transitions.

Too complex? Let’s see an example of a usual JSON representation of a resource, in this case, my user somewhere on a server:

curl http://awesomeapi.com/users/1
{
  "user": {
    "first_name": "Ilija",
    "last_name": "Eftimov",
    "age": 25
  }
}

Simple enough. Now, when we apply HATEOAS to our representations of resources, it should look something like this:

{
  "user": {
    "first_name": "Ilija",
    "last_name": "Eftimov",
    "age": 25,
    "links": [
      {
        "rel": "self",
        "href": "http://awesomeapi.com/users/1"
      },
      {
        "rel": "roles",
        "href": "http://awesomeapi.com/users/1/roles"
      }
    ]
  }
}

By providing the links we allow the clients to use hypermedia (in our case, JSON) as the engine (list of available actions) of our application state (changing the data on the API side). The idea here is that there should be only one entry point to an API, or to a resource, and that the representation of the resource should include all of the actions that can be executed on this resources.

This means that a consumer of the API can take use the self attribute of the User resource, and consider this the endpoint where it can execute actions on it. By knowing that the API is RESTful, the client knows how to update the resource:

curl -X "PUT" -d "{ 'age': 26 }" http://awesomeapi.com/users/1

Also, it knows how to delete the resource:

curl -X "DELETE" http://awesomeapi.com/users/1

Furthermore, if it wants to see all of the roles for this user, it can execute a GET request to the roles relation of the user:

curl http://awesomeapi.com/users/1/roles

As you can see, by putting HATEOAS in the mix, clients can interact with resources just by getting the URIs from the resources and by assuming that the API is level three RESTful (utilises HTTP verbs).

HATEOAS Serializers #

How can we make our Rails API implement Level 4 of RMM? Let’s see an example.

Our API is part of a blog CMS. It has an Author model, which has a one-to-many association with an Article model. This means that, one Author can have multiple Article.

The Author model:

# == Schema Information
#
# Table name: authors
#
#  id         :integer          not null, primary key
#  first_name :string
#  last_name  :string
#  created_at :datetime         not null
#  updated_at :datetime         not null
#

class Author < ApplicationRecord
  has_many :articles
end

And the Article model:

# == Schema Information
#
# Table name: articles
#
#  id         :integer          not null, primary key
#  author_id  :integer
#  title      :string
#  body       :text
#  created_at :datetime         not null
#  updated_at :datetime         not null
#

class Article < ApplicationRecord
  belongs_to :author
end

Our routes look like the following:

Rails.application.routes.draw do
  resources :authors do
    resources :articles
  end
end

Finally, we will have serializers for both, the Article and the Author model. For this example, we will use Active Model Serializers.

Author serializer #

All of the Author objects will be serialized by the AuthorSerializer. Let’s add the required attributes first to the serializer, and we can talk about extension afterwards.

The AuthorSerializer class will look like this:

# == Schema Information
#
# Table name: authors
#
#  id         :integer          not null, primary key
#  first_name :string
#  last_name  :string
#  created_at :datetime         not null
#  updated_at :datetime         not null
#

class AuthorSerializer < ActiveModel::Serializer
  attributes :id, :first_name, :last_name, :created_at, :updated_at
  has_many :articles
  type :author
end

Super simple! I will leave the annotations in, to keep the context of the data visible to us throughout this short example. In my everyday work, I usually consult with the schema file if I forget the shape of the data instead of adding annotations.

Let’s try sending a GET /authors request and see what the data looks like. For reference, this is the implementation of the index action in the AuthorsController:

class AuthorsController < ApplicationController
  def index
    render json: Author.all
  end
end
curl http://localhost:3000/authors

This request will return the following JSON:

[
  {
    "id": 1,
    "first_name": "ilija",
    "last_name": "eftimov",
    "created_at": "2016-06-25T20:52:12.804Z",
    "updated_at": "2016-06-25T20:52:12.804Z",
    "articles": [
      {
        "id": 1,
        "title": "Lorem ipsum",
        "body": "Lorem ipsum dolor sit amet",
        "created_at": "2016-06-25T22:25:51.874Z",
        "updated_at": "2016-06-25T22:25:51.874Z"
      },
      {
        "id": 2,
        "title": "A princess of Mars",
        "body": "His reference to the great games of which I had heard so much while among the Tharks convinced me that I had but jumped from purgatory into gehenna. After a few more words with the female, during which she assured him that I was now fully fit to travel, the jed ordered that we mount and ride after the main column. I was strapped securely to as wild and unmanageable a thoat as I had",
        "created_at": "2016-06-26T00:20:09.388Z",
        "updated_at": "2016-06-26T00:20:09.388Z"
      }
    ]
  }
]

Since my database has only one Author instance, it returns only that one. Also, the Author has two associated Article instances, so the serializer includes them in the response.

Now we have our serializer working. The next step is to extend it with links. This is quite trivial to do using Active Model Serializers - we need to add a new links attribute to the serializer, and provide the implementation of the attribute (which will be a method in fact) in the class:

# == Schema Information
#
# Table name: authors
#
#  id         :integer          not null, primary key
#  first_name :string
#  last_name  :string
#  created_at :datetime         not null
#  updated_at :datetime         not null
#

class AuthorSerializer < BaseSerializer
  attributes :id, :first_name, :last_name, :created_at, :updated_at, :links
  has_many :articles
  type :author

  def links
    [
      {
        rel: :self,
        href: author_path(object)
      }
    ]
  end
end

As you can see, the implementation is quite simple. We add a new attribute called links, and the method just returns an array of hashes. For starters, we will have only one - the self link which will point to the resource whose representation we are seeing.

But, if you execute the curl request again, you will gen an error, something like:

#<NoMethodError: undefined method `author_path' for #<AuthorSerializer:0x007fe251b4c450>>

This occurs because Rails’ route helpers are not included in the scope of the serializers. This is easily fixable by including them to the serializer:

class AuthorSerializer < ActiveModel::Serializer
  include Rails.application.routes.url_helpers

  # *snip*
end

If we execute the curl request again now, we will see the links in the JSON:

[
  {
    "id": 1,
    "first_name": "ilija",
    "last_name": "eftimov",
    "created_at": "2016-06-25T20:52:12.804Z",
    "updated_at": "2016-06-25T20:52:12.804Z",
    "links": [
      {
        "rel": "self",
        "href": "/authors/1"
      }
    ],
    "articles": [
      {
        "id": 1,
        "title": "Lorem ipsum",
        "body": "Lorem ipsum dolor sit amet",
        "created_at": "2016-06-25T22:25:51.874Z",
        "updated_at": "2016-06-25T22:25:51.874Z"
      },
      {
        "id": 2,
        "title": "A princess of Mars",
        "body": "His reference to the great games of which I had heard so much while among the Tharks convinced me that I had but jumped from purgatory into gehenna. After a few more words with the female, during which she assured him that I was now fully fit to travel, the jed ordered that we mount and ride after the main column. I was strapped securely to as wild and unmanageable a thoat as I had",
        "created_at": "2016-06-26T00:20:09.388Z",
        "updated_at": "2016-06-26T00:20:09.388Z"
      }
    ]
  }
]

Great stuff! We added some HATEOAS to our resources.

Specifications #

Before we continue to add any more hypermedia to our resources, we need to put some thought into this. As you can see, we went on to implement the links attribute to the serializer without any thought. Sure, you might say, I am the creator of the API and I will structure the responses of the API as I please.

True, you are the author of the API, but that statement is false. Imagine if all of us thought of a way to implement hypermedia in a different way? Every single API would be different in it’s hypermedia controls and it would be a hell to find your way around.

Well, the sad part is that we still don’t know how to do HATEOAS. Or better said, we still don’t know the best way to do HATEOAS. That is why, people have tried to create specifications, which have been somewhat adopted.

HAL #

From HAL’s documentation:

HAL is a simple format that gives a consistent and easy way to hyperlink between resources in your API.

As you can see, the motivation behind HAL is to create an easy and consistent way to add hypermedia controls. Just like any other specification, HAL provides a set of conventions for expressing hyperlinks in either JSON or XML.

This is an example of a JSON that applies the HAL specification:

{
    "_links": {
        "self": { "href": "/orders" },
        "curies": [{ "name": "ea", "href": "http://example.com/docs/rels/{rel}", "templated": true }],
        "next": { "href": "/orders?page=2" },
        "ea:find": {
            "href": "/orders{?id}",
            "templated": true
        },
        "ea:admin": [{
            "href": "/admins/2",
            "title": "Fred"
        }, {
            "href": "/admins/5",
            "title": "Kate"
        }]
    },
    "currentlyProcessing": 14,
    "shippedToday": 20,
    "_embedded": {
        "ea:order": [{
            "_links": {
                "self": { "href": "/orders/123" },
                "ea:basket": { "href": "/baskets/98712" },
                "ea:customer": { "href": "/customers/7809" }
            },
            "total": 30.00,
            "currency": "USD",
            "status": "shipped"
        }, {
            "_links": {
                "self": { "href": "/orders/124" },
                "ea:basket": { "href": "/baskets/97213" },
                "ea:customer": { "href": "/customers/12369" }
            },
            "total": 20.00,
            "currency": "USD",
            "status": "processing"
        }]
    }
}

JSON API #

JSON API, just like HAL is:

a specification for how a client should request that resources be fetched or modified, and how a server should respond to those requests.

It was was originally drafted by Yehuda Katz in 2013. This first draft was extracted from the JSON transport implicitly defined by Ember Data’s REST adapter. From there on, it picked up some popularity, but I cannot say with certainty that it’s the de facto API specification.

This is an example of a JSON that applies the JSON API specification:

{
  "data": [{
    "type": "articles",
    "id": "1",
    "attributes": {
      "title": "JSON API paints my bikeshed!",
      "body": "The shortest article. Ever.",
      "created": "2015-05-22T14:56:29.000Z",
      "updated": "2015-05-22T14:56:28.000Z"
    },
    "relationships": {
      "author": {
        "data": {"id": "42", "type": "people"}
      }
    }
  }],
  "included": [
    {
      "type": "people",
      "id": "42",
      "attributes": {
        "name": "John",
        "age": 80,
        "gender": "male"
      }
    }
  ]
}

Other standards #

As you can imagine, people have tried to create a good JSON standard for quite some time. Some of the more popular specs are JSON for Linking Data, also known as JSON-LD; Collection+JSON and SIREN.

JSON API and Active Model Serializers #

Since JSON API is picking up momentum and Active Model Serializers has a very good integration of it, we will stick to it and carry on with the implementation. Active Model Serializers on a high-level works through two parts: serializers and adapters. From the documentation:

Serializers describe which attributes and relationships should be serialized. Adapters describe how attributes and relationships should be serialized.

To get our JSON serialized in the JSON API format, we need to use a different adapter called, believe it or not, :json_api. Personally, I prefer to do this type of configuration in an initializer file:

# config/initializers/active_model_serializers.rb

ActiveModelSerializers.config.adapter = :json_api

By setting the adapter to :json_api, our API will produce and consume JSON formatted in the JSON API spec. If we execute the same API call that we did earlier, we will get a newly formatted JSON:

{
  "data": [
    {
      "id": "1",
      "type": "author",
      "attributes": {
        "first-name": "ilija",
        "last-name": "eftimov",
        "created-at": "2016-06-25T20:52:12.804Z",
        "updated-at": "2016-06-25T20:52:12.804Z"
      },
      "relationships": {
        "articles": {
          "data": [
            {
              "id": "1",
              "type": "article"
            },
            {
              "id": "2",
              "type": "article"
            }
          ]
        }
      },
      "links": {
        "self": {
          "href": "/authors/1"
        }
      }
    }
  ]
}

As you can notice, the JSON that we get back has a completely different structure compared to what we have before. The reason is that the JSON API adapter that we use now formats the JSON in the JSON API Spec format.

Another cool thing about the JSON API adapter is that it allows us to specify links very explicitly, with a very nice DSL:

class AuthorSerializer < ActiveModel::Serializer
  attributes :id, :first_name, :last_name, :created_at, :updated_at
  has_many :articles
  type :author

  link :self do
    href author_path(object)
  end
end

Also, we do not have to specify the links as an attribute and we can remove the include line that mixed-in Rails’ url helpers in our serializer. If we call the /authors endpoint with a GET request again, we will see an extended JSON, that again complies with the JSON API Spec:

{
  "data": [
    {
      "id": "1",
      "type": "author",
      "attributes": {
        "first-name": "ilija",
        "last-name": "eftimov",
        "created-at": "2016-06-25T20:52:12.804Z",
        "updated-at": "2016-06-25T20:52:12.804Z"
      },
      "relationships": {
        "articles": {
          "data": [
            {
              "id": "1",
              "type": "article"
            },
            {
              "id": "2",
              "type": "article"
            }
          ]
        }
      },
      "links": {
        "self": {
          "href": "/authors/1"
        }
      }
    }
  ]
}

Including additional resources #

Since AMS is an awesome gem, it allows you to include additional resources by including them in the controllers:

class AuthorsController < ApplicationController
  def index
    render json: Author.all, include: 'articles'
  end
end

By including the Article association of on the Author model, we are able to extend the JSON result with the associated articles for each of the users:

{
  "data": [
    {
      "id": "1",
      "type": "author",
      "attributes": {
        "first-name": "ilija",
        "last-name": "eftimov",
        "created-at": "2016-06-25T20:52:12.804Z",
        "updated-at": "2016-06-25T20:52:12.804Z"
      },
      "relationships": {
        "articles": {
          "data": [
            {
              "id": "1",
              "type": "article"
            },
            {
              "id": "2",
              "type": "article"
            }
          ]
        }
      },
      "links": {
        "self": {
          "href": "/authors/1"
        }
      }
    }
  ],
  "included": [
    {
      "id": "1",
      "type": "article",
      "attributes": {
        "title": "Lorem ipsum",
        "body": "Lorem ipsum dolor sit amet",
        "created-at": "2016-06-25T22:25:51.874Z",
        "updated-at": "2016-06-25T22:25:51.874Z"
      },
      "relationships": {
        "author": {
          "data": {
            "id": "1",
            "type": "author"
          }
        }
      },
      "links": {
        "self": {
          "href": "/authors/1/articles/1"
        }
      }
    },
    {
      "id": "2",
      "type": "article",
      "attributes": {
        "title": "A princess of Mars",
        "body": "His reference to the great games of which I had heard so much while among the Tharks convinced me that I had but jumped from purgatory into gehenna. After a few more words with the female, during which she assured him that I was now fully fit to travel, the jed ordered that we mount and ride after the main column. I was strapped securely to as wild and unmanageable a thoat as I had",
        "created-at": "2016-06-26T00:20:09.388Z",
        "updated-at": "2016-06-26T00:20:09.388Z"
      },
      "relationships": {
        "author": {
          "data": {
            "id": "1",
            "type": "author"
          }
        }
      },
      "links": {
        "self": {
          "href": "/authors/1/articles/2"
        }
      }
    }
  ]
}

Although this is quite nice, beware that this introduces N+1 queries for every resource that we fetch. That’s why, usually index actions return only the requested resources, while show actions tend to include some additional resources:

class AuthorsController < ApplicationController
  def index
    render json: Author.all
  end

  def show
    author = Author.find(params[:id])
    render json: author, include: :articles
  end
end

This will add all of the articles found for the author that we requested:

{
  "data": {
    "id": "1",
    "type": "author",
    "attributes": {
      "first-name": "ilija",
      "last-name": "eftimov",
      "created-at": "2016-06-25T20:52:12.804Z",
      "updated-at": "2016-06-25T20:52:12.804Z"
    },
    "relationships": {
      "articles": {
        "data": [
          {
            "id": "1",
            "type": "article"
          },
          {
            "id": "2",
            "type": "article"
          }
        ]
      }
    },
    "links": {
      "self": {
        "href": "/authors/1"
      }
    }
  },
  "included": [
    {
      "id": "1",
      "type": "article",
      "attributes": {
        "title": "Lorem ipsum",
        "body": "Lorem ipsum dolor sit amet",
        "created-at": "2016-06-25T22:25:51.874Z",
        "updated-at": "2016-06-25T22:25:51.874Z"
      },
      "relationships": {
        "author": {
          "data": {
            "id": "1",
            "type": "author"
          }
        }
      },
      "links": {
        "self": {
          "href": "/authors/1/articles/1"
        }
      }
    },
    {
      "id": "2",
      "type": "article",
      "attributes": {
        "title": "A princess of Mars",
        "body": "His reference to the great games of which I had heard so much while among the Tharks convinced me that I had but jumped from purgatory into gehenna. After a few more words with the female, during which she assured him that I was now fully fit to travel, the jed ordered that we mount and ride after the main column. I was strapped securely to as wild and unmanageable a thoat as I had",
        "created-at": "2016-06-26T00:20:09.388Z",
        "updated-at": "2016-06-26T00:20:09.388Z"
      },
      "relationships": {
        "author": {
          "data": {
            "id": "1",
            "type": "author"
          }
        }
      },
      "links": {
        "self": {
          "href": "/authors/1/articles/2"
        }
      }
    }
  ]
}

As you can notice, because we supplied the has_many association in the serializer, AMS knows to utilise the correct serializer for each of the included resources. This adds the same structure to the included resources and the requested resource, which makes the result consistent and parsing this JSON much easier.

Now, to actually see how easy it is to navigate through resources, let’s implement the ArticlesController#show action as well:

class ArticlesController < ApplicationController

  def show
    article = Article.find_by(author_id: params[:author_id], id: params[:id])
    render json: article
  end
end

Super simple - we find the Article by the author_id and the id params and render it as a JSON. If we call GET /authors/1/articles/2, we will get the JSON API representation of the Article object:

{
  "data": {
    "id": "2",
    "type": "article",
    "attributes": {
      "title": "A princess of Mars",
      "body": "His reference to the great games of which I had heard so much while among the Tharks convinced me that I had but jumped from purgatory into gehenna. After a few more words with the female, during which she assured him that I was now fully fit to travel, the jed ordered that we mount and ride after the main column. I was strapped securely to as wild and unmanageable a thoat as I had",
      "created-at": "2016-06-26T00:20:09.388Z",
      "updated-at": "2016-06-26T00:20:09.388Z"
    },
    "relationships": {
      "author": {
        "data": {
          "id": "1",
          "type": "author"
        }
      }
    },
    "links": {
      "self": {
        "href": "/authors/1/articles/2"
      }
    }
  }
}

If you use Postman, which I really enjoy using, or any other API browser to test the endpoints you could see how easy it is to navigate the resources:

Just link in the web browser, you know the entry point localhost:3000/authors. From there you can click on the links property of the Author and get the single Author resource:

By seeing the resource with all of it’s included resources, then you can click on any of the links on the included Article resources, and see their attributes in JSON format:

This is what essentially HATEOAS means - navigating and interacting with resources via the endpoints that are provided in the JSON representation of the resources.

Outro #

As you can see, to understand and learn what Hypermedia As The Engine Of Application State is, there is quite a bit of context that we need to get in our heads. Although all of this sometimes can appear as quite complex, Rails makes implementing HATEOAS APIs quite easy, as you could see in the examples. Sure, there is more to hypermedia controls, but these examples should be more than enough to get your feet wet.

Also, the HATEOAS concept seems quite complex on the surface, but when you deconstruct it it’s quite simple, because we have already seen the same behaviour in web browsers. The issue of why it seems quite complex is because we haven’t even thought about normal web sites like RESTful APIs, and vice-versa.

If you want to see the actual implementation of the endpoints that we worked with in this article, you can check this repo on Github.

Additional reading #