Updated on

HATEOAS, Hypermedia as the Engine of Application State, is the REST constraint that says a response carries the links describing what the client can do next. Instead of building URLs from a documented template, the client reads them out of the payload it already has.

An owner comes back with its fields and a Links array beside them: self to fetch it again, delete_owner with the DELETE method, update_owner with PUT. Nothing about those URLs is compiled into the client.

We build that on the API from the rest of this series, paging, filtering, searching, sorting and especially data shaping, which is where the interesting problem comes from.

To download the source code for the starting project, you can visit our GitHub repository. The source code for this article is here.

NOTE: Some degree of previous knowledge is needed to follow this article. It relies heavily on the ASP.NET Core Web API series on Code Maze, so if you are not sure how to set up the database or how the underlying architecture works, we strongly suggest you go through the series.

What Is HATEOAS?

HATEOAS stands for Hypermedia as the Engine of Application State. It is the REST constraint that says a response carries the links describing what the client can do next, so the client follows links rather than building URLs from a template it was given out of band.

A response looks like the resource with a Links array beside it. Each link has an href, a rel naming the relationship, and the HTTP method to use. A client that reads rel: "delete_owner" learns both the URL and the verb without either being compiled into it.

Leonard Richardson’s maturity model puts this at Level 3, above resources at Level 1 and HTTP verbs at Level 2. Roy Fielding, who defined REST, was blunt about it in 2008: “if the engine of application state (and hence the API) is not being driven by hypertext, then it cannot be RESTful and cannot be a REST API. Period.” Most APIs stop at Level 2, and they work.

That sentence comes from Fielding’s post REST APIs must be hypertext-driven, and he adds the mechanism in the same paragraph: every application state transition has to be driven by the client picking one of the choices the server put in the representation it just sent.

Let’s see how that actually works.

What Does a HATEOAS Response Look Like?

Let’s say we want to get some owners from our API.

But how do we do that?

We don’t even know how to get to the owners endpoint. So first we would request the only thing we do know, the root of the application:

https://localhost:5001/api

A root endpoint like that tells us more about our API, or rather where to start exploring it:

[
    {
        "href": "https://localhost:5001/api",
        "rel": "self",
        "method": "GET"
    },
    {
        "href": "https://localhost:5001/api/owners",
        "rel": "owners",
        "method": "GET"
    },
    {
        "href": "https://localhost:5001/api/owners",
        "rel": "create_owner",
        "method": "POST"
    }
]

Now we know what is available to us and we can proceed to get the existing owners:

https://localhost:5001/api/owners

And indeed we get the owners, each one carrying the actions we can perform on it:

{
    "value": [
        {
            "Id": "261e1685-cf26-494c-b17c-3546e65f5620",
            "Name": "Anna Bosh",
            "DateOfBirth": "1974-11-14T00:00:00",
            "Address": "27 Colored Row",
            "Links": [
                {
                    "href": "https://localhost:5001/api/owners/261e1685-cf26-494c-b17c-3546e65f5620",
                    "rel": "self",
                    "method": "GET"
                },
                {
                    "href": "https://localhost:5001/api/owners/261e1685-cf26-494c-b17c-3546e65f5620",
                    "rel": "delete_owner",
                    "method": "DELETE"
                },
                {
                    "href": "https://localhost:5001/api/owners/261e1685-cf26-494c-b17c-3546e65f5620",
                    "rel": "update_owner",
                    "method": "PUT"
                }
            ]
        },
        "..."
    ],
    "links": [
        {
            "href": "https://localhost:5001/api/owners",
            "rel": "self",
            "method": "GET"
        }
    ]
}

Each owner carries its own links, and the collection carries a link describing itself. That is a nice way to make an API self-discoverable and evolvable.

What Is a Link?

According to RFC 8288, “a link is a typed connection between two resources”, and the specification lists its parts as a link context, a link relation type, a link target and, optionally, target attributes. Simply put, we use links to traverse the resources on the internet.

Our responses contain an array of links, and two of the three properties on each one come straight out of that model:

  • href – the link target, a URI
  • rel – the link relation type, which describes how the current context is related to the target resource
  • method – the HTTP method, which is our addition rather than the RFC’s. We need it to distinguish two links that share the same target URI

RFC 8288’s model is context, relation type, target and optional target attributes, and it deliberately leaves attribute names to the serialization. So method is a design choice we are free to make, and a good one here, but it is our convention and not something a generic client will recognize.

What Do We Gain From HATEOAS?

So what are the benefits we can expect when implementing HATEOAS?

HATEOAS is not trivial to implement. Some of the things we get in return:

  • API becomes self-discoverable and explorable
  • A client can use the links to implement its logic, it becomes much easier, and any changes that happen in the API structure are directly reflected onto the client
  • The server drives the application state and URL structure and not vice versa
  • The link relations can be used to point to developer documentation
  • Versioning through hyperlinks becomes easier
  • Reduced invalid state transaction calls
  • API is evolvable without breaking all the clients

The costs get a section of their own near the end of the article, once we have seen what implementing this actually takes.

That is more than enough theory for now. Let’s get to work and see what the concrete implementation of HATEOAS looks like.

Extending the Model for HATEOAS Links

Let’s begin with the concept we know so far, and that’s the link. First, we create the Link class in the Models folder of the Entities project:

public class Link
{
    public string Href { get; set; } = string.Empty;
    public string Rel { get; set; } = string.Empty;
    public string Method { get; set; } = string.Empty;

    public Link()
    {
    }

    public Link(string href, string rel, string method)
    {
        Href = href;
        Rel = rel;
        Method = method;
    }
}

Note that we have an empty constructor too. We need that for XML serialization purposes, so keep it that way: DataContractSerializer requires a parameterless constructor for a plain class carrying no data-contract attributes. That is also why Link cannot be shortened into a positional record, which would throw at serialization time.

Next, we need a class that holds all of our links, LinkResourceBase:

public class LinkResourceBase
{
    public List<Link> Links { get; set; } = [];
}

And since a collection response needs links of its own, not just one set per item, we need a wrapper:

public class LinkCollectionWrapper<T> : LinkResourceBase
{
    public List<T> Value { get; set; } = [];

    public LinkCollectionWrapper()
    {
    }

    public LinkCollectionWrapper(List<T> value)
    {
        Value = value;
    }
}

The wrapper is what lets a collection response carry a self link describing the endpoint itself, beside the array of owners that each carry their own links. We will see both shapes in the responses further down.

One more model, and it exists because of data shaping. Link generation needs the resource id, and data shaping is allowed to remove it: a request for ?fields=name returns owners with no id at all, and there is nothing left to build a URL from. So the shaper returns a wrapper that keeps the id outside the shaped payload:

public class ShapedEntity
{
    public ShapedEntity()
    {
        Entity = new Entity();
    }

    public Guid Id { get; set; }
    public Entity Entity { get; set; }
}

The shaper fills it in by reading the Id property separately from the required-properties loop, so the id survives whatever the client asked for:

private static ShapedEntity FetchDataForEntity(T entity, IEnumerable<PropertyInfo> requiredProperties)
{
    var shapedObject = new ShapedEntity();

    foreach (var property in requiredProperties)
        shapedObject.Entity.TryAdd(property.Name, property.GetValue(entity)!);

    var idProperty = typeof(T).GetProperty("Id");
    shapedObject.Id = (Guid)idProperty!.GetValue(entity)!;

    return shapedObject;
}

Everywhere the shaper used to hand back an Entity, it now hands back a ShapedEntity: IDataShaper, DataShaper, IOwnerRepository, OwnerRepository, IAccountRepository and AccountRepository.

Since our responses contain links now, we also need to extend the XML serialization rules so that an XML response returns properly formatted links. Without this we would get something like <Links>System.Collections.Generic.List`1[Entities.Models.Link]</Links>. So we extend the WriteXmlElement method to support links:

public void WriteXml(XmlWriter writer)
{
    foreach (var key in _expando.Keys)
        WriteXmlElement(key, _expando[key], writer);
}

private void WriteXmlElement(string key, object value, XmlWriter writer)
{
    writer.WriteStartElement(key);

    if (value is List<Link> links)
    {
        foreach (var link in links)
        {
            writer.WriteStartElement(nameof(Link));
            WriteXmlElement(nameof(link.Href), link.Href, writer);
            WriteXmlElement(nameof(link.Method), link.Method, writer);
            WriteXmlElement(nameof(link.Rel), link.Rel, writer);
            writer.WriteEndElement();
        }
    }
    else
    {
        writer.WriteString(value.ToString());
    }

    writer.WriteEndElement();
}

As we did in the data shaping article, we won’t go into too much detail here since it is out of the scope of the article, but the logic isn’t complicated: we check whether the value is a List<Link>, and if it is, we iterate through the links and call the method recursively for each of the properties, href, method, and rel.

That’s all we need. We have a solid foundation to implement HATEOAS in our controllers.

How Do We Generate Links in a Controller?

LinkGenerator is the service that turns an action name and its route values into a URL. Inject it into the controller and call GetUriByAction, passing HttpContext, the name of the target action, and the values its route needs.

Every generated link needs the resource’s id, and data shaping is allowed to remove it. A request for ?fields=name returns owners with no id at all, and there is nothing left to build a URL from.

ShapedEntity solves that by keeping the id outside the shaped payload. The shaper reads the Id property separately, before it filters anything, so the id is always available to the link builder and never forced into a response the client did not ask for.

The collection case needs one more piece. Individual resources carry their own links, and LinkCollectionWrapper adds the links that describe the collection itself.

Let’s head to our OwnerController and implement all of that.

First, we extend the controller with the LinkGenerator class, which will help us build the links we want:

private readonly ILoggerManager _logger;
private readonly IRepositoryWrapper _repository;
private readonly LinkGenerator _linkGenerator;

public OwnerController(ILoggerManager logger,
    IRepositoryWrapper repository,
    LinkGenerator linkGenerator)
{
    _logger = logger;
    _repository = repository;
    _linkGenerator = linkGenerator;
}

Next, we extend the GetOwners action to add the relevant links to the returned owners:

[HttpGet]
public async Task<IActionResult> GetOwners([FromQuery] OwnerParameters ownerParameters)
{
    if (!ownerParameters.ValidYearRange)
    {
        return Problem(
            detail: "maxYearOfBirth must be greater than or equal to minYearOfBirth.",
            statusCode: StatusCodes.Status400BadRequest);
    }

    var owners = await _repository.Owner.GetOwners(ownerParameters);

    var metadata = new
    {
        owners.TotalCount,
        owners.PageSize,
        owners.CurrentPage,
        owners.TotalPages,
        owners.HasNext,
        owners.HasPrevious
    };

    Response.Headers["X-Pagination"] = JsonSerializer.Serialize(metadata);

    var shapedOwners = owners.Select(o => o.Entity).ToList();

    for (var index = 0; index < owners.Count; index++)
        shapedOwners[index].Add("Links", CreateLinksForOwner(owners[index].Id, ownerParameters.Fields));

    var ownersWrapper = new LinkCollectionWrapper<Entity>(shapedOwners);

    return Ok(CreateLinksForOwners(ownersWrapper));
}

We walk the page of owners and add the relevant links to each one. After that, we wrap the collection and create the links that matter for the collection as a whole.

The six values in the X-Pagination header come from the paged query rather than from the shaped list. The repository pages the IQueryable first and shapes the page it gets back, so the counts describe what the database returned.

Now we need the implementation for the CreateLinksForOwner and CreateLinksForOwners methods:

private List<Link> CreateLinksForOwner(Guid id, string? fields = "")
{
    var links = new List<Link>
    {
        new(_linkGenerator.GetUriByAction(HttpContext, nameof(GetOwnerById), values: new { id, fields })!,
            "self",
            "GET"),

        new(_linkGenerator.GetUriByAction(HttpContext, nameof(DeleteOwner), values: new { id })!,
            "delete_owner",
            "DELETE"),

        new(_linkGenerator.GetUriByAction(HttpContext, nameof(UpdateOwner), values: new { id })!,
            "update_owner",
            "PUT")
    };

    return links;
}

private LinkCollectionWrapper<Entity> CreateLinksForOwners(LinkCollectionWrapper<Entity> ownersWrapper)
{
    ownersWrapper.Links.Add(new Link(
        _linkGenerator.GetUriByAction(HttpContext, nameof(GetOwners), values: null)!,
        "self",
        "GET"));

    return ownersWrapper;
}

There are a few things to note here.

We take the fields into consideration while creating the links, since we might be using them in our requests. We create the links with LinkGenerator‘s GetUriByAction method, which accepts HttpContext, the name of the action, and the route values needed to make the URL valid. For the owner links we pass the owner id and the fields; the collection link needs no route values at all, so it passes null.

We do something similar in our GetOwnerById action:

[HttpGet("{id}", Name = "OwnerById")]
public IActionResult GetOwnerById(Guid id, [FromQuery] string? fields)
{
    var owner = _repository.Owner.GetOwnerById(id, fields);

    if (owner.Id == Guid.Empty)
    {
        _logger.LogError($"Owner with id: {id}, hasn't been found in db.");
        return NotFound();
    }

    owner.Entity.Add("Links", CreateLinksForOwner(owner.Id, fields));

    return Ok(owner.Entity);
}

The logic is easier to implement for a single owner since we don’t need to wrap it up.

We are going to do the same for the AccountController, with one difference. Its endpoints are nested under the owner:

/api/owners/{ownerId}/accounts
/api/owners/{ownerId}/accounts/{accountId}

We need to take that into consideration while creating our HATEOAS links:

private List<Link> CreateLinksForAccount(Guid ownerId, Guid id, string? fields = "")
{
    var links = new List<Link>
    {
        new(_linkGenerator.GetUriByAction(HttpContext, nameof(GetAccountForOwner), values: new { ownerId, id, fields })!,
            "self",
            "GET")
    };

    return links;
}

private LinkCollectionWrapper<Entity> CreateLinksForAccounts(LinkCollectionWrapper<Entity> accountsWrapper)
{
    accountsWrapper.Links.Add(new Link(
        _linkGenerator.GetUriByAction(HttpContext, nameof(GetAccountsForOwner), values: null)!,
        "self",
        "GET"));

    return accountsWrapper;
}

Our AccountController has no create, update or delete actions, so it is simpler, but as we can see, in addition to the account id we need to provide the owner id too in order to generate a valid link. We take fields into consideration here as well.

So let’s test our implementation and see how it works.

Testing Our Solution

Let’s begin with a simple query to our owners endpoint:
GET https://localhost:5001/api/owners

This returns the list of owners with the links attached:

{
    "value": [
        {
            "Id": "261e1685-cf26-494c-b17c-3546e65f5620",
            "Name": "Anna Bosh",
            "DateOfBirth": "1974-11-14T00:00:00",
            "Address": "27 Colored Row",
            "Links": [
                {
                    "href": "https://localhost:5001/api/owners/261e1685-cf26-494c-b17c-3546e65f5620",
                    "rel": "self",
                    "method": "GET"
                },
                {
                    "href": "https://localhost:5001/api/owners/261e1685-cf26-494c-b17c-3546e65f5620",
                    "rel": "delete_owner",
                    "method": "DELETE"
                },
                {
                    "href": "https://localhost:5001/api/owners/261e1685-cf26-494c-b17c-3546e65f5620",
                    "rel": "update_owner",
                    "method": "PUT"
                }
            ]
        },
        {
            "Id": "24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
            "Name": "John Keen",
            "DateOfBirth": "1980-12-05T00:00:00",
            "Address": "61 Wellfield Road",
            "Links": [
                {
                    "href": "https://localhost:5001/api/owners/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "self",
                    "method": "GET"
                },
                {
                    "href": "https://localhost:5001/api/owners/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "delete_owner",
                    "method": "DELETE"
                },
                {
                    "href": "https://localhost:5001/api/owners/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "update_owner",
                    "method": "PUT"
                }
            ]
        },
        "..."
    ],
    "links": [
        {
            "href": "https://localhost:5001/api/owners",
            "rel": "self",
            "method": "GET"
        }
    ]
}

There are all the links we defined for an owner. The whole collection sits under value, and beside it is a links array describing the collection itself, in this case the self link back to the endpoint. We can extend that anytime with other links that matter for the controller, and it is exactly what the wrapper was for.

Now let’s test a single owner:
GET https://localhost:5001/api/owners/24fd81f8-d58a-4bcc-9f35-dc6cd5641906

This results in:

{
    "Id": "24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
    "Name": "John Keen",
    "DateOfBirth": "1980-12-05T00:00:00",
    "Address": "61 Wellfield Road",
    "Links": [
        {
            "href": "https://localhost:5001/api/owners/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
            "rel": "self",
            "method": "GET"
        },
        {
            "href": "https://localhost:5001/api/owners/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
            "rel": "delete_owner",
            "method": "DELETE"
        },
        {
            "href": "https://localhost:5001/api/owners/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
            "rel": "update_owner",
            "method": "PUT"
        }
    ]
}

Just one owner, no wrappers whatsoever.

Now, let’s get the accounts for that owner:
GET https://localhost:5001/api/owners/24fd81f8-d58a-4bcc-9f35-dc6cd5641906/accounts

We get the list of that owner’s accounts and the links to them:

{
    "value": [
        {
            "Id": "371b93f2-f8c5-4a32-894a-fc672741aa5b",
            "DateCreated": "1999-05-04T00:00:00",
            "AccountType": "Domestic",
            "OwnerId": "24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
            "Links": [
                {
                    "href": "https://localhost:5001/api/owners/24fd81f8-d58a-4bcc-9f35-dc6cd5641906/accounts/371b93f2-f8c5-4a32-894a-fc672741aa5b",
                    "rel": "self",
                    "method": "GET"
                }
            ]
        },
        "..."
    ],
    "links": [
        {
            "href": "https://localhost:5001/api/owners/24fd81f8-d58a-4bcc-9f35-dc6cd5641906/accounts",
            "rel": "self",
            "method": "GET"
        }
    ]
}

And finally, a single account:
GET https://localhost:5001/api/owners/24fd81f8-d58a-4bcc-9f35-dc6cd5641906/accounts/371b93f2-f8c5-4a32-894a-fc672741aa5b

The result is:

{
    "Id": "371b93f2-f8c5-4a32-894a-fc672741aa5b",
    "DateCreated": "1999-05-04T00:00:00",
    "AccountType": "Domestic",
    "OwnerId": "24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
    "Links": [
        {
            "href": "https://localhost:5001/api/owners/24fd81f8-d58a-4bcc-9f35-dc6cd5641906/accounts/371b93f2-f8c5-4a32-894a-fc672741aa5b",
            "rel": "self",
            "method": "GET"
        }
    ]
}

One link, describing how to get to that account. Note the two different ids in it: the owner id in the first segment and the account id in the second, which is what the nested route needs to resolve.

The last thing to test is the case data shaping makes interesting, selecting the fields we want:
GET https://localhost:5001/api/owners?fields=name

And we get:

{
    "value": [
        {
            "Name": "Anna Bosh",
            "Links": [
                {
                    "href": "https://localhost:5001/api/owners/261e1685-cf26-494c-b17c-3546e65f5620?fields=name",
                    "rel": "self",
                    "method": "GET"
                },
                {
                    "href": "https://localhost:5001/api/owners/261e1685-cf26-494c-b17c-3546e65f5620",
                    "rel": "delete_owner",
                    "method": "DELETE"
                },
                {
                    "href": "https://localhost:5001/api/owners/261e1685-cf26-494c-b17c-3546e65f5620",
                    "rel": "update_owner",
                    "method": "PUT"
                }
            ]
        },
        "..."
    ],
    "links": [
        {
            "href": "https://localhost:5001/api/owners",
            "rel": "self",
            "method": "GET"
        }
    ]
}

The owners come back with nothing but their names, and the links are still there. The id never reached the response body, but ShapedEntity kept it available to the link builder, which is the whole reason the shaper returns it separately.

Which Hypermedia Format Should We Use?

Three published formats cover most hypermedia APIs, and they differ mainly in how much they insist on. HAL adds a _links object and stops there. JSON:API specifies the entire document, down to relationships and error objects. Siren adds actions, so a response can describe not just where to go but what to send.

Choose a published format when the clients are not ours. A consumer can then reach for an existing library instead of reading our documentation, and the meaning of the link array does not shift when we refactor a controller.

Choose a vendor media type when the API and its clients ship together. The shape is then a private contract between two things released at the same time, which is what the rest of this article builds.

The choice is about who reads the response, not about which format is best.

FormatMedia typeWhere the links liveWhat it specifiesUse when
HALapplication/hal+json_links, an object keyed by link relation typeLinks and embedded resources, and nothing elseYou want the smallest addition to a JSON body that clients already recognise
JSON:APIapplication/vnd.api+jsonlinks on the document and on each resourceThe whole document: resources, relationships, includes, errors, and sparse fieldsetsClients are outside your control and you want one specification to settle every question
Sirenapplication/vnd.siren+jsonlinks, plus actions carrying the fields each action expectsLinks, actions and their input fields, sub-entitiesClients render forms or controls from the response
A vendor media typeapplication/vnd.<vendor>.<name>+jsonWherever we put them: a Links array, in this articleNothing. The shape is ours and only our clients know itThe API and its clients ship together, as in this series

None of the three went through a standards body. HAL’s specification is draft-kelly-json-hal, an Internet-Draft that expired with no formal standing in the IETF process, and all three formats sit in IANA’s vendor tree as application/vnd.hal+json, application/vnd.api+json and application/vnd.siren+json (IETF Datatracker and the IANA media types registry, both read 2026-09-13).

The Ultimate ASP.NET Core Web API course builds this on a service-layer architecture and carries the link generation through versioning and content negotiation, which is where the format choice starts to bite.

How Do We Add a Custom Media Type?

Returning HATEOAS links is a nice addition to our API, but the responses get lengthy, and not every client wants them. We want to give an API user the ability to choose.

The answer is a custom media type, which is content negotiation with a media type of our own. If the client asks for it, the response includes the links; if it doesn’t, the response is plain and simple, the way we used to return it.

A custom media type looks something like this: application/vnd.codemaze.hateoas+json. Compare it to the plain JSON media type we use by default, application/json.

So let’s break down its parts:

  • vnd – vendor prefix, it’s always there
  • codemaze – vendor identifier, we’ve chosen codemaze, because why not
  • hateoas – media type name
  • json – suffix, we can use it to describe whether we want a JSON or an XML response, for example

Now let’s implement that in our application.

Registering Custom Media Types

First, we want to register our new custom media types with the output formatters. Otherwise, we’ll just get 406 Not Acceptable.

Let’s add a new extension method to our ServiceExtensions class:

public static void AddCustomMediaTypes(this IServiceCollection services)
{
    services.Configure<MvcOptions>(config =>
    {
        var systemTextJsonOutputFormatter = config.OutputFormatters
            .OfType<SystemTextJsonOutputFormatter>().FirstOrDefault();

        systemTextJsonOutputFormatter?.SupportedMediaTypes
            .Add("application/vnd.codemaze.hateoas+json");

        var xmlOutputFormatter = config.OutputFormatters
            .OfType<XmlDataContractSerializerOutputFormatter>().FirstOrDefault();

        xmlOutputFormatter?.SupportedMediaTypes
            .Add("application/vnd.codemaze.hateoas+xml");
    });
}

We register application/vnd.codemaze.hateoas+json on the SystemTextJsonOutputFormatter and application/vnd.codemaze.hateoas+xml on the XmlDataContractSerializerOutputFormatter. That is what stops a request carrying either Accept value from getting a 406.

If our clients are not ours to change, application/hal+json is the conventional choice, and this same mechanism registers it identically.

Then we call the extension from Program.cs, right after AddControllers:

builder.Services.AddControllers(config =>
{
    config.RespectBrowserAcceptHeader = true;
    config.ReturnHttpNotAcceptable = true;
}).AddXmlDataContractSerializerFormatters();

builder.Services.AddCustomMediaTypes();

That takes care of the custom media types registration.

Implementing a Media Type Validation Filter

Now that we have custom media types, we want the Accept header to be present in our requests so we can detect when the user asked for the HATEOAS-enriched response.

To do that, we implement an action filter that validates the header and the media type:

public class ValidateMediaTypeAttribute : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context)
    {
        if (context.HttpContext.Request.Headers.Accept.Count == 0)
        {
            context.Result = new BadRequestObjectResult("Accept header is missing.");
            return;
        }

        var mediaType = context.HttpContext.Request.Headers.Accept.FirstOrDefault();

        if (!MediaTypeHeaderValue.TryParse(mediaType, out var outMediaType))
        {
            context.Result = new BadRequestObjectResult(
                "Media type not present. Please add Accept header with the required media type.");
            return;
        }

        context.HttpContext.Items["AcceptHeaderMediaType"] = outMediaType;
    }

    public void OnActionExecuted(ActionExecutedContext context)
    {
    }
}

We check for the Accept header first, and return BadRequest if it is missing. If it is there, we parse the media type, and if it doesn’t parse, we return BadRequest again. Once past both checks, we hand the parsed media type to the HttpContext so the action can read it.

One detail worth knowing before the compiler tells us: the MediaTypeHeaderValue here is Microsoft.Net.Http.Headers.MediaTypeHeaderValue. There is a second type with the same name in System.Net.Http.Headers, and only the first one has the SubTypeWithoutSuffix property we are about to use.

Don’t forget to register the filter in the IoC container:

services.AddScoped<ValidateMediaTypeAttribute>();

Now we decorate our GetOwners and GetOwnerById actions with [ServiceFilter(typeof(ValidateMediaTypeAttribute))] so the validation runs, and extend them a bit:

[HttpGet]
[ServiceFilter(typeof(ValidateMediaTypeAttribute))]
public async Task<IActionResult> GetOwners([FromQuery] OwnerParameters ownerParameters)
{
    //implementation

    var shapedOwners = owners.Select(o => o.Entity).ToList();

    var mediaType = (MediaTypeHeaderValue)HttpContext.Items["AcceptHeaderMediaType"]!;

    if (!mediaType.SubTypeWithoutSuffix.EndsWith("hateoas", StringComparison.InvariantCultureIgnoreCase))
        return Ok(shapedOwners);

    for (var index = 0; index < owners.Count; index++)
        shapedOwners[index].Add("Links", CreateLinksForOwner(owners[index].Id, ownerParameters.Fields));

    var ownersWrapper = new LinkCollectionWrapper<Entity>(shapedOwners);

    return Ok(CreateLinksForOwners(ownersWrapper));
}

We read the media type the filter parsed and cast it to MediaTypeHeaderValue. Using SubTypeWithoutSuffix.EndsWith we check whether HATEOAS was requested, and if it wasn’t, we return the shaped owners immediately. If it was, we add the links and return them as before.

Same story with the GetOwnerById action:

[HttpGet("{id}", Name = "OwnerById")]
[ServiceFilter(typeof(ValidateMediaTypeAttribute))]
public IActionResult GetOwnerById(Guid id, [FromQuery] string? fields)
{
    var owner = _repository.Owner.GetOwnerById(id, fields);

    if (owner.Id == Guid.Empty)
    {
        _logger.LogError($"Owner with id: {id}, hasn't been found in db.");
        return NotFound();
    }

    var mediaType = (MediaTypeHeaderValue)HttpContext.Items["AcceptHeaderMediaType"]!;

    if (!mediaType.SubTypeWithoutSuffix.EndsWith("hateoas", StringComparison.InvariantCultureIgnoreCase))
    {
        _logger.LogInfo($"Returned shaped owner with id: {id}");
        return Ok(owner.Entity);
    }

    owner.Entity.Add("Links", CreateLinksForOwner(owner.Id, fields));

    return Ok(owner.Entity);
}

To test this out, let’s request the owners with the Accept header set to application/json:
GET https://localhost:5001/api/owners

We get the plain response:

[
    {
        "Id": "261e1685-cf26-494c-b17c-3546e65f5620",
        "Name": "Anna Bosh",
        "DateOfBirth": "1974-11-14T00:00:00",
        "Address": "27 Colored Row"
    },
    {
        "Id": "24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
        "Name": "John Keen",
        "DateOfBirth": "1980-12-05T00:00:00",
        "Address": "61 Wellfield Road"
    },
    "..."
]

And now, when we change the Accept header to application/vnd.codemaze.hateoas+json, we get the wrapped response with the links:

{
    "value": [
        {
            "Id": "261e1685-cf26-494c-b17c-3546e65f5620",
            "Name": "Anna Bosh",
            "DateOfBirth": "1974-11-14T00:00:00",
            "Address": "27 Colored Row",
            "Links": [
                {
                    "href": "https://localhost:5001/api/owners/261e1685-cf26-494c-b17c-3546e65f5620",
                    "rel": "self",
                    "method": "GET"
                },
                {
                    "href": "https://localhost:5001/api/owners/261e1685-cf26-494c-b17c-3546e65f5620",
                    "rel": "delete_owner",
                    "method": "DELETE"
                },
                {
                    "href": "https://localhost:5001/api/owners/261e1685-cf26-494c-b17c-3546e65f5620",
                    "rel": "update_owner",
                    "method": "PUT"
                }
            ]
        },
        "..."
    ],
    "links": [
        {
            "href": "https://localhost:5001/api/owners",
            "rel": "self",
            "method": "GET"
        }
    ]
}

An Accept value we never registered, application/foo for instance, still returns 406 Not Acceptable, which is what ReturnHttpNotAcceptable is for. Now we have the means to choose between the JSON, XML, and HATEOAS responses as we please.

Should We Implement HATEOAS?

HATEOAS pays when clients are numerous, external, and long-lived. Those are the clients that break when a URL changes, and links are what stop them breaking.

It rarely pays inside a system we control at both ends. A single-page application built against our own API already knows every route, and generating links it will never read costs a link generator call per resource, per request.

The honest reason most APIs skip it is that almost no client follows the links. Consumers read the documentation, hard-code the URLs, and ignore the Links array, which turns hypermedia into payload weight and nothing else.

So implement it when a real client will navigate by link, and know which client that is before starting. Otherwise ship well-documented resources and spend the effort on versioning, which solves the same problem for the same clients at a fraction of the cost.

As with everything else, the context is everything, so keep it simple. If the clients are ours and they hard-code the URLs anyway, a clear versioning strategy buys more than a links array does, and our REST API best practices article covers the rest of what a well-documented API owes its consumers.

Conclusion

HATEOAS is one of the most useful and one of the most complicated REST concepts to implement. How far we take it is a decision about the clients, not about the API, and it is worth making that decision deliberately.

So what have we learned this time:

  • What HATEOAS is, and where it sits in the Richardson maturity model
  • How to generate links in an ASP.NET Core Web API project with LinkGenerator
  • How ShapedEntity keeps the id available to the link builder when data shaping removes it
  • How to support XML serialization of dynamic objects with HATEOAS links
  • What custom media types are, how the published formats compare, and how to make HATEOAS optional in our responses
  • When implementing HATEOAS is worth the effort, and when it is not

If you want to compare your solution with ours, the finished project lives in the Hateoas folder of our GitHub repository.

This is the last part of the advanced REST concepts series. Everything it builds on, and everything that comes before it, is in the ASP.NET Core Web API series.

Tested with .NET 10.0.10 and EF Core 10.0.11.