In this article, we are going to talk about one of the most important concepts in building RESTful APIs – HATEOAS and learn how to implement HATEOAS in ASP.NET Core Web API. This article relies heavily on the concepts we’ve implemented so far in paging, filtering, searching, sorting articles, and especially data-shaping, and builds upon the foundations we’ve put down in these articles.

This article contains some advanced concepts of REST and Web API. If you find yourself lost or like you’re missing part of the whole picture, take a step back and take your time to go over these articles first. We’ll also link the relevant resources throughout the article if you want to explore some topics further.

You can find the source code on the GitHub repo. If you want to follow along with the article, you can use the start branch and if you want to get the final solution or if you get stuck, switch to the finished project.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

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.

Let’s dive in.

What is HATEOAS and Why Is It So Important?

HATEOAS (Hypermedia as the Engine of Application State) is a very important REST constraint. Without it, a REST API cannot be considered RESTful and many of the benefits we get by implementing a REST architecture are unavailable.

Hypermedia refers to any kind of content that contains links to media types such as documents, images, videos…

REST architecture allows us to generate hypermedia links in our responses dynamically and thus make navigation much easier. To put this into perspective, think about a website that uses hyperlinks to help you navigate to different parts of it. You can achieve the same effect with HATEOAS in your REST API.

Imagine a website that has a home page and you land on it, but there are no links anywhere. You need to scrape the website or find some other way to navigate it to get to the content you want. We’re not saying that the website is the same as a REST API, but you get the point.

The power of being able to explore an API on your own can be very useful.

Let’s see how that actually works.

Typical Response with HATEOAS Implemented

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. Well first we would go to the only thing we know how to request and that’s the root of the application:

https://localhost:50001/api

Our root endpoint should tell us more about our API, or rather where to start exploring it:

[
{
"href": "http://localhost:5001/api",
"rel": "self",
"method": "GET"
},
{
"href": "http://localhost:5001/api/owner",
"rel": "owner",
"method": "GET"
},
{
"href": "http://localhost:5000/api/owner",
"rel": "create_owner",
"method": "POST"
}
]
[ { "href": "http://localhost:5001/api", "rel": "self", "method": "GET" }, { "href": "http://localhost:5001/api/owner", "rel": "owner", "method": "GET" }, { "href": "http://localhost:5000/api/owner", "rel": "create_owner", "method": "POST" } ]
[
    {
        "href": "http://localhost:5001/api",
        "rel": "self",
        "method": "GET"
    },
    {
        "href": "http://localhost:5001/api/owner",
        "rel": "owner",
        "method": "GET"
    },
    {
        "href": "http://localhost:5000/api/owner",
        "rel": "create_owner",
        "method": "POST"
    }
]

Ok, now we know what is available to us immediately and we can proceed to get existing owners:

http://localhost:5001/api/owner

And indeed we get the owners:

[
{
"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/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
"rel": "self",
"method": "GET"
},
{
"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
"rel": "delete_owner",
"method": "DELETE"
},
{
"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
"rel": "update_owner",
"method": "PUT"
}
]
},
{
"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/owner/261e1685-cf26-494c-b17c-3546e65f5620",
"rel": "self",
"method": "GET"
},
{
"href": "https://localhost:5001/api/owner/261e1685-cf26-494c-b17c-3546e65f5620",
"rel": "delete_owner",
"method": "DELETE"
},
{
"href": "https://localhost:5001/api/owner/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/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906", "rel": "self", "method": "GET" }, { "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906", "rel": "delete_owner", "method": "DELETE" }, { "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906", "rel": "update_owner", "method": "PUT" } ] }, { "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/owner/261e1685-cf26-494c-b17c-3546e65f5620", "rel": "self", "method": "GET" }, { "href": "https://localhost:5001/api/owner/261e1685-cf26-494c-b17c-3546e65f5620", "rel": "delete_owner", "method": "DELETE" }, { "href": "https://localhost:5001/api/owner/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/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
				"rel": "self",
				"method": "GET"
			},
			{
				"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
				"rel": "delete_owner",
				"method": "DELETE"
			},
			{
				"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
				"rel": "update_owner",
				"method": "PUT"
			}
		]
	},
	{
		"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/owner/261e1685-cf26-494c-b17c-3546e65f5620",
				"rel": "self",
				"method": "GET"
			},
			{
				"href": "https://localhost:5001/api/owner/261e1685-cf26-494c-b17c-3546e65f5620",
				"rel": "delete_owner",
				"method": "DELETE"
			},
			{
				"href": "https://localhost:5001/api/owner/261e1685-cf26-494c-b17c-3546e65f5620",
				"rel": "update_owner",
				"method": "PUT"
			}
		]
	},
	"..."
]

As you can see we got the list of our owners, and for each owner all the actions we can perform on them. And so on…

So, it’s a nice way to make an API self-discoverable and evolvable.

What is a Link?

According to RFC5988, a link is “a typed connection between two resources that are identified by Internationalised Resource Identifiers (IRIs)“. Simply put we use links to traverse the internet or rather the resources on the internet.

Our responses contain an array of Links, which consist of a few properties according to the RFC:

  • href – represents a target URI
  • rel – represents a link relation type, which means it describes how the current context is related to the target resource
  • method – we need an HTTP method to know how to distinguish the same target URIs

Pros/Cons of Implementing HATEOAS

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

HATEOAS is not trivial to implement, but the rewards we reap are worth it. Some of the things we can expect to get when we implement HATEOAS:

  • 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

We can do so much with HATEOAS. But since it’s not easy to implement all these features, we should keep in mind the scope of our API and if we actually need all this. There is a great difference between a high-volume public API and some internal API that is needed to communicate between parts of the same system.

As with everything else, the context is everything. Keep it simple.

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 in Preparation For HATEOAS Implementation

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

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

	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’ll need that for XML serialization purposes, so keep it that way.

Next on, we need to create a class that will contain all of our links – LinkResourceBase:

public class LinkResourceBase
{
public LinkResourceBase()
{
}
public List<Link> Links { get; set; } = new List<Link>();
}
public class LinkResourceBase { public LinkResourceBase() { } public List<Link> Links { get; set; } = new List<Link>(); }
public class LinkResourceBase
{
	public LinkResourceBase()
	{

	}

	public List<Link> Links { get; set; } = new List<Link>();
}

And finally, since our response needs to describe the root of the controller, we need a wrapper for our Links:

public class LinkCollectionWrapper<T> : LinkResourceBase
{
public List<T> Value { get; set; } = new List<T>();
public LinkCollectionWrapper()
{
}
public LinkCollectionWrapper(List<T> value)
{
Value = value;
}
}
public class LinkCollectionWrapper<T> : LinkResourceBase { public List<T> Value { get; set; } = new List<T>(); public LinkCollectionWrapper() { } public LinkCollectionWrapper(List<T> value) { Value = value; } }
public class LinkCollectionWrapper<T> : LinkResourceBase
{
	public List<T> Value { get; set; } = new List<T>();

	public LinkCollectionWrapper()
	{

	}

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

This class might not make too much sense right now, but stay with us and it will become clear later down the road. For now, let’s just assume we wrapped our links in another class for response representation purposes.

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

public void WriteXml(XmlWriter writer)
{
foreach (var key in expando.Keys)
{
var value = expando[key];
WriteLinksToXml(key, value, writer);
}
}
private void WriteLinksToXml(string key, object value, XmlWriter writer)
{
writer.WriteStartElement(key);
if (value.GetType() == typeof(List<Link>))
{
foreach (var val in value as List<Link>)
{
writer.WriteStartElement(nameof(Link));
WriteLinksToXml(nameof(val.Href), val.Href, writer);
WriteLinksToXml(nameof(val.Method), val.Method, writer);
WriteLinksToXml(nameof(val.Rel), val.Rel, writer);
writer.WriteEndElement();
}
}
else
{
writer.WriteString(value.ToString());
}
writer.WriteEndElement();
}
public void WriteXml(XmlWriter writer) { foreach (var key in expando.Keys) { var value = expando[key]; WriteLinksToXml(key, value, writer); } } private void WriteLinksToXml(string key, object value, XmlWriter writer) { writer.WriteStartElement(key); if (value.GetType() == typeof(List<Link>)) { foreach (var val in value as List<Link>) { writer.WriteStartElement(nameof(Link)); WriteLinksToXml(nameof(val.Href), val.Href, writer); WriteLinksToXml(nameof(val.Method), val.Method, writer); WriteLinksToXml(nameof(val.Rel), val.Rel, writer); writer.WriteEndElement(); } } else { writer.WriteString(value.ToString()); } writer.WriteEndElement(); }
public void WriteXml(XmlWriter writer)
{
	foreach (var key in expando.Keys)
	{
		var value = expando[key];
		WriteLinksToXml(key, value, writer);
	}
}

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

	if (value.GetType() == typeof(List<Link>))
	{
		foreach (var val in value as List<Link>)
		{
			writer.WriteStartElement(nameof(Link));
			WriteLinksToXml(nameof(val.Href), val.Href, writer);
			WriteLinksToXml(nameof(val.Method), val.Method, writer);
			WriteLinksToXml(nameof(val.Rel), val.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 too complicated either. In short, we check if the type is List<Link>, and if it is, we iterate through all the links and call the method recursively for each of the properties: href, method, and rel.

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

Implementing HATEOAS in ASP.NET Core Web API

Now, let’s head towards our OwnerController and actually implement HATEOAS.

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

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

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

Next on, we need to extend our GetOwners method to add the relevant links to the returned owners:

[HttpGet]
public IActionResult GetOwners([FromQuery] OwnerParameters ownerParameters)
{
if (!ownerParameters.ValidYearRange)
{
return BadRequest("Max year of birth cannot be less than min year of birth");
}
var owners = _repository.Owner.GetOwners(ownerParameters);
var metadata = new
{
owners.TotalCount,
owners.PageSize,
owners.CurrentPage,
owners.TotalPages,
owners.HasNext,
owners.HasPrevious
};
Response.Headers.Add("X-Pagination", JsonConvert.SerializeObject(metadata));
_logger.LogInfo($"Returned {owners.TotalCount} owners from database.");
for (var index = 0; index < owners.Count(); index++)
{
var ownerLinks = CreateLinksForOwner(owners[index].Id, ownerParameters.Fields);
owners[index].Add("Links", ownerLinks);
}
var ownersWrapper = new LinkCollectionWrapper<Entity>(owners);
return Ok(CreateLinksForOwners(ownersWrapper));
}
[HttpGet] public IActionResult GetOwners([FromQuery] OwnerParameters ownerParameters) { if (!ownerParameters.ValidYearRange) { return BadRequest("Max year of birth cannot be less than min year of birth"); } var owners = _repository.Owner.GetOwners(ownerParameters); var metadata = new { owners.TotalCount, owners.PageSize, owners.CurrentPage, owners.TotalPages, owners.HasNext, owners.HasPrevious }; Response.Headers.Add("X-Pagination", JsonConvert.SerializeObject(metadata)); _logger.LogInfo($"Returned {owners.TotalCount} owners from database."); for (var index = 0; index < owners.Count(); index++) { var ownerLinks = CreateLinksForOwner(owners[index].Id, ownerParameters.Fields); owners[index].Add("Links", ownerLinks); } var ownersWrapper = new LinkCollectionWrapper<Entity>(owners); return Ok(CreateLinksForOwners(ownersWrapper)); }
[HttpGet]
public IActionResult GetOwners([FromQuery] OwnerParameters ownerParameters)
{
	if (!ownerParameters.ValidYearRange)
	{
		return BadRequest("Max year of birth cannot be less than min year of birth");
	}

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

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

	Response.Headers.Add("X-Pagination", JsonConvert.SerializeObject(metadata));

	_logger.LogInfo($"Returned {owners.TotalCount} owners from database.");

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

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

	return Ok(CreateLinksForOwners(ownersWrapper));
}

We iterate through all the owners we’ve returned and added the relevant links to them. After that, we wrap the owners collection and create links that are important for the entire collection.

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

private IEnumerable<Link> CreateLinksForOwner(Guid id, string fields = "")
{
var links = new List<Link>
{
new Link(_linkGenerator.GetUriByAction(HttpContext, nameof(GetOwnerById), values: new { id, fields }),
"self",
"GET"),
new Link(_linkGenerator.GetUriByAction(HttpContext, nameof(DeleteOwner), values: new { id }),
"delete_owner",
"DELETE"),
new Link(_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: new { }),
"self",
"GET"));
return ownersWrapper;
}
private IEnumerable<Link> CreateLinksForOwner(Guid id, string fields = "") { var links = new List<Link> { new Link(_linkGenerator.GetUriByAction(HttpContext, nameof(GetOwnerById), values: new { id, fields }), "self", "GET"), new Link(_linkGenerator.GetUriByAction(HttpContext, nameof(DeleteOwner), values: new { id }), "delete_owner", "DELETE"), new Link(_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: new { }), "self", "GET")); return ownersWrapper; }
private IEnumerable<Link> CreateLinksForOwner(Guid id, string fields = "")
{
	var links = new List<Link>
	{
		new Link(_linkGenerator.GetUriByAction(HttpContext, nameof(GetOwnerById), values: new { id, fields }),
		"self",
		"GET"),

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

		new Link(_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: new { }),
			"self",
			"GET"));

	return ownersWrapper;
}

There are a few things to note here.

We need to take the fields into consideration while creating the links since we might be using them in our requests. We are creating the links by using the LinkGenerator‘s GetUriByAction method which accepts HttpContext, the name of the action, and the values that need to be used to make the URL valid. In the case of the OwnersController, we send the owner id and fields.

The response that we expect is exactly the one we’ve used as an example.

We are doing something similar to our GetOwnerById method:

[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.Add("Links", CreateLinksForOwner(owner.Id, fields));
return Ok(owner);
}
[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.Add("Links", CreateLinksForOwner(owner.Id, fields)); return Ok(owner); }
[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.Add("Links", CreateLinksForOwner(owner.Id, fields));

	return Ok(owner);
}

The logic is much 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 but with a slight difference. As you know the endpoint to the AccountController is a bit different:

/api/owner/{ownerId}/account
/api/owner/{ownerId}/account/{accountId}

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

private List<Link> CreateLinksForAccount(Guid ownerId, Guid id, string fields = "")
{
var links = new List<Link>
{
new Link(_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: new { }),
"self",
"GET"));
return accountsWrapper;
}
private List<Link> CreateLinksForAccount(Guid ownerId, Guid id, string fields = "") { var links = new List<Link> { new Link(_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: new { }), "self", "GET")); return accountsWrapper; }
private List<Link> CreateLinksForAccount(Guid ownerId, Guid id, string fields = "")
{
	var links = new List<Link>
	{
		new Link(_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: new { }),
			"self",
			"GET"));

	return accountsWrapper;
}

Our AccountController implementation doesn’t contain Create, Update or Delete methods so it’s much simpler, but, as you can see, in addition to the account id, we need to provide the owner id too, in order to properly generate links. We need to take fields into consideration in this case too.

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

Testing Our Solution

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

This should return the list of owners with the links attached:

{
"value": [
{
"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/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
"rel": "self",
"method": "GET"
},
{
"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
"rel": "delete_owner",
"method": "DELETE"
},
{
"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
"rel": "update_owner",
"method": "PUT"
}
]
},
...
...
...
"links": [
{
"href": "https://localhost:5001/api/owner",
"rel": "self",
"method": "GET"
}
]
}
{ "value": [ { "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/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906", "rel": "self", "method": "GET" }, { "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906", "rel": "delete_owner", "method": "DELETE" }, { "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906", "rel": "update_owner", "method": "PUT" } ] }, ... ... ... "links": [ { "href": "https://localhost:5001/api/owner", "rel": "self", "method": "GET" } ] }
{
    "value": [
        {
            "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/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "self",
                    "method": "GET"
                },
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "delete_owner",
                    "method": "DELETE"
                },
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "update_owner",
                    "method": "PUT"
                }
            ]
        },
		...
		...
		...
    "links": [
        {
            "href": "https://localhost:5001/api/owner",
            "rel": "self",
            "method": "GET"
        }
    ]
}

As you can see, there are all the links we’ve defined for our owner. The whole collection of owners is wrapped in the “value” entity because right at the end we define a link that describes “self” or rather how to get to the controller. We can extend it anytime we want with other relevant links that are important to the controller.

That was the whole point of the wrapper we implemented.

Now let’s proceed by testing the single owner:
GET https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906

This should result 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/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
"rel": "self",
"method": "GET"
},
{
"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
"rel": "delete_owner",
"method": "DELETE"
},
{
"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
"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/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906", "rel": "self", "method": "GET" }, { "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906", "rel": "delete_owner", "method": "DELETE" }, { "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906", "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/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
            "rel": "self",
            "method": "GET"
        },
        {
            "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
            "rel": "delete_owner",
            "method": "DELETE"
        },
        {
            "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
            "rel": "update_owner",
            "method": "PUT"
        }
    ]
}

Just one owner, no wrappers whatsoever.

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

We get the list of that owner’s accounts and 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/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906/account/371b93f2-f8c5-4a32-894a-fc672741aa5b",
"rel": "self",
"method": "GET"
}
]
}
...
],
"links": [
{
"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906/account",
"rel": "self",
"method": "GET"
}
]
}
{ "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/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906/account/371b93f2-f8c5-4a32-894a-fc672741aa5b", "rel": "self", "method": "GET" } ] } ... ], "links": [ { "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906/account", "rel": "self", "method": "GET" } ] }
{
    "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/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906/account/371b93f2-f8c5-4a32-894a-fc672741aa5b",
                    "rel": "self",
                    "method": "GET"
                }
            ]
        }
		...
    ],
    "links": [
        {
            "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906/account",
            "rel": "self",
            "method": "GET"
        }
    ]
}

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

The result should be pretty obvious at this point:

{
"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/owner/371b93f2-f8c5-4a32-894a-fc672741aa5b/account/371b93f2-f8c5-4a32-894a-fc672741aa5b",
"rel": "self",
"method": "GET"
}
]
}
{ "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/owner/371b93f2-f8c5-4a32-894a-fc672741aa5b/account/371b93f2-f8c5-4a32-894a-fc672741aa5b", "rel": "self", "method": "GET" } ] }
{
    "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/owner/371b93f2-f8c5-4a32-894a-fc672741aa5b/account/371b93f2-f8c5-4a32-894a-fc672741aa5b",
            "rel": "self",
            "method": "GET"
        }
    ]
}

One link, describing how to get to that account. As you can see, there are two ids in that link, so we’ve done a great job in our implementation.

Now, the only thing remaining to test is how it works when we select the fields we want:
GET https://localhost:5001/api/owner?fields=name

And much to our surprise, this doesn’t work.

Why is that?

Improving the Solution

Well, as you can see the HATEOAS implementation relies on having the ids available to construct the links for the response. Data shaping, on the other hand, enables us to return only the fields we want. And in our test scenario, we wanted just the names of the owners. That means we haven’t returned the ids of those owners and we cannot construct the target URIs now.

To solve this problem we want to make sure that id is always returned to the controller.

First, we’ll implement a wrapper named ShapedEntity which besides the entity contains the Id property:

public class ShapedEntity
{
public ShapedEntity()
{
Entity = new Entity();
}
public Guid Id { get; set; }
public Entity Entity { get; set; }
}
public class ShapedEntity { public ShapedEntity() { Entity = new Entity(); } public Guid Id { get; set; } public Entity Entity { get; set; } }
public class ShapedEntity
{
	public ShapedEntity()
	{
		Entity = new Entity();
	}

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

Next on, we are going to replace all usages of the Entity class in the entire project with the ShapedEntity. The classes in which you need to replace every usage of the Entity class: AccountRepository, IAccountRepository, OwnerRepository, IOwnerRepository, DataShaper, and IDataShaper.

In addition to that, we need to extend the FetchDataForEntity method in the DataShaper class to get the id separately:

private ShapedEntity FetchDataForEntity(T entity, IEnumerable<PropertyInfo> requiredProperties)
{
var shapedObject = new ShapedEntity();
foreach (var property in requiredProperties)
{
var objectPropertyValue = property.GetValue(entity);
shapedObject.Entity.TryAdd(property.Name, objectPropertyValue);
}
var objectProperty = entity.GetType().GetProperty("Id");
shapedObject.Id = (Guid)objectProperty.GetValue(entity);
return shapedObject;
}
private ShapedEntity FetchDataForEntity(T entity, IEnumerable<PropertyInfo> requiredProperties) { var shapedObject = new ShapedEntity(); foreach (var property in requiredProperties) { var objectPropertyValue = property.GetValue(entity); shapedObject.Entity.TryAdd(property.Name, objectPropertyValue); } var objectProperty = entity.GetType().GetProperty("Id"); shapedObject.Id = (Guid)objectProperty.GetValue(entity); return shapedObject; }
private ShapedEntity FetchDataForEntity(T entity, IEnumerable<PropertyInfo> requiredProperties)
{
	var shapedObject = new ShapedEntity();

	foreach (var property in requiredProperties)
	{
		var objectPropertyValue = property.GetValue(entity);
		shapedObject.Entity.TryAdd(property.Name, objectPropertyValue);
	}

	var objectProperty = entity.GetType().GetProperty("Id");
	shapedObject.Id = (Guid)objectProperty.GetValue(entity);

	return shapedObject;
}

Now we have all we need to create the links no matter what properties we get using data shaping.

All that is left is to tweak our controller actions a bit:

[HttpGet]
public IActionResult GetOwners([FromQuery] OwnerParameters ownerParameters)
{
...//implementation
var shapedOwners = owners.Select(o => o.Entity).ToList();
for (var index = 0; index < owners.Count(); index++)
{
var ownerLinks = CreateLinksForOwner(owners[index].Id, ownerParameters.Fields);
shapedOwners[index].Add("Links", ownerLinks);
}
var ownersWrapper = new LinkCollectionWrapper<Entity>(shapedOwners);
return Ok(CreateLinksForOwners(ownersWrapper));
}
[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);
}
[HttpGet] public IActionResult GetOwners([FromQuery] OwnerParameters ownerParameters) { ...//implementation var shapedOwners = owners.Select(o => o.Entity).ToList(); for (var index = 0; index < owners.Count(); index++) { var ownerLinks = CreateLinksForOwner(owners[index].Id, ownerParameters.Fields); shapedOwners[index].Add("Links", ownerLinks); } var ownersWrapper = new LinkCollectionWrapper<Entity>(shapedOwners); return Ok(CreateLinksForOwners(ownersWrapper)); } [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); }
[HttpGet]
public IActionResult GetOwners([FromQuery] OwnerParameters ownerParameters)
{
	...//implementation

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

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

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

	return Ok(CreateLinksForOwners(ownersWrapper));
}

[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 AccountController class gets the same treatment.

Now let’s try the same query again:
GET https://localhost:5001/api/owner?fields=name

And we get:

{
"value": [
{
"Name": "John Keen",
"Links": [
{
"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906?fields=name",
"rel": "self",
"method": "GET"
},
{
"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
"rel": "delete_owner",
"method": "DELETE"
},
{
"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
"rel": "update_owner",
"method": "PUT"
}
]
},
...
],
"links": [
{
"href": "https://localhost:5001/api/owner",
"rel": "self",
"method": "GET"
}
]
}
{ "value": [ { "Name": "John Keen", "Links": [ { "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906?fields=name", "rel": "self", "method": "GET" }, { "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906", "rel": "delete_owner", "method": "DELETE" }, { "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906", "rel": "update_owner", "method": "PUT" } ] }, ... ], "links": [ { "href": "https://localhost:5001/api/owner", "rel": "self", "method": "GET" } ] }
{
    "value": [
        {
            "Name": "John Keen",
            "Links": [
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906?fields=name",
                    "rel": "self",
                    "method": "GET"
                },
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "delete_owner",
                    "method": "DELETE"
                },
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "update_owner",
                    "method": "PUT"
                }
            ]
        },
		...
    ],
    "links": [
        {
            "href": "https://localhost:5001/api/owner",
            "rel": "self",
            "method": "GET"
        }
    ]
}

Great! That’s exactly what we wanted.

Introducing Custom Media Types

Now, returning HATEOAS links is a nice addition to our API. But as you can see the responses can be a bit lengthy and we purposely shortened a few of them to make the article more readable.

We want to give an API user the ability to do that, too.

And how can we do that?

The answer is by utilizing the power of custom media types.

We are going to create our own media types that we use when we want to return a response that includes HATEOAS links. If we don’t use it, the response won’t include any links and will be plain and simple like we’ve used to return it.

Before we start, let’s see how we can create a custom media type. A custom media type should look something like this: application/vnd.codemaze.hateoas+json. To compare it to the typical json media type which we use by default: application/json.

So let’s break down the different parts of a custom media type:

  • 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 if we want json or 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 in the middleware. Otherwise, we’ll just get 406 Not Acceptable.

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

public static void AddCustomMediaTypes(this IServiceCollection services)
{
services.Configure<MvcOptions>(config =>
{
var newtonsoftJsonOutputFormatter = config.OutputFormatters
.OfType<NewtonsoftJsonOutputFormatter>()?.FirstOrDefault();
if (newtonsoftJsonOutputFormatter != null)
{
newtonsoftJsonOutputFormatter.SupportedMediaTypes.Add("application/vnd.codemaze.hateoas+json");
}
var xmlOutputFormatter = config.OutputFormatters
.OfType<XmlDataContractSerializerOutputFormatter>()?.FirstOrDefault();
if (xmlOutputFormatter != null)
{
xmlOutputFormatter.SupportedMediaTypes.Add("application/vnd.codemaze.hateoas+xml");
}
});
}
public static void AddCustomMediaTypes(this IServiceCollection services) { services.Configure<MvcOptions>(config => { var newtonsoftJsonOutputFormatter = config.OutputFormatters .OfType<NewtonsoftJsonOutputFormatter>()?.FirstOrDefault(); if (newtonsoftJsonOutputFormatter != null) { newtonsoftJsonOutputFormatter.SupportedMediaTypes.Add("application/vnd.codemaze.hateoas+json"); } var xmlOutputFormatter = config.OutputFormatters .OfType<XmlDataContractSerializerOutputFormatter>()?.FirstOrDefault(); if (xmlOutputFormatter != null) { xmlOutputFormatter.SupportedMediaTypes.Add("application/vnd.codemaze.hateoas+xml"); } }); }
public static void AddCustomMediaTypes(this IServiceCollection services)
{
	services.Configure<MvcOptions>(config =>
	{
		var newtonsoftJsonOutputFormatter = config.OutputFormatters
				.OfType<NewtonsoftJsonOutputFormatter>()?.FirstOrDefault();

		if (newtonsoftJsonOutputFormatter != null)
		{
			newtonsoftJsonOutputFormatter.SupportedMediaTypes.Add("application/vnd.codemaze.hateoas+json");
		}

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

		if (xmlOutputFormatter != null)
		{
			xmlOutputFormatter.SupportedMediaTypes.Add("application/vnd.codemaze.hateoas+xml");
		}
	});
}

We are registering two new custom media types: application/vnd.codemaze.hateoas+json for our newtonSoftJsonSerializerOutputFormatter and application/vnd.codemaze.hateoas+xml for our xmlDataContractSerializerOutputFormatter. This will make sure we don’t get a 406 Not Acceptable response.

Add that to our Startup.cs in the ConfigureServices method, just after the AddControllers method:

services.AddControllers(config =>
{
config.RespectBrowserAcceptHeader = true;
config.ReturnHttpNotAcceptable = true;
}).AddXmlDataContractSerializerFormatters()
.AddNewtonsoftJson();
services.AddCustomMediaTypes();
services.AddControllers(config => { config.RespectBrowserAcceptHeader = true; config.ReturnHttpNotAcceptable = true; }).AddXmlDataContractSerializerFormatters() .AddNewtonsoftJson(); services.AddCustomMediaTypes();
services.AddControllers(config =>
{
	config.RespectBrowserAcceptHeader = true;
	config.ReturnHttpNotAcceptable = true;
}).AddXmlDataContractSerializerFormatters()
.AddNewtonsoftJson();

services.AddCustomMediaTypes();

That takes care of the custom media types registration.

Implementing a Media Type Validation Filter

Now, since we’ve implemented custom media types, we want our Accept header to be present in our requests so we can detect when the user requested the HATEOAS enriched response.

To do that, we’ll implement an ActionFilter which will validate our Accept header and media types:

public class ValidateMediaTypeAttribute : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
var acceptHeaderPresent = context.HttpContext.Request.Headers.ContainsKey("Accept");
if (!acceptHeaderPresent)
{
context.Result = new BadRequestObjectResult($"Accept header is missing.");
return;
}
var mediaType = context.HttpContext.Request.Headers["Accept"].FirstOrDefault();
if (!MediaTypeHeaderValue.TryParse(mediaType, out MediaTypeHeaderValue outMediaType))
{
context.Result = new BadRequestObjectResult($"Media type not present. Please add Accept header with the required media type.");
return;
}
context.HttpContext.Items.Add("AcceptHeaderMediaType", outMediaType);
}
public void OnActionExecuted(ActionExecutedContext context)
{
}
}
public class ValidateMediaTypeAttribute : IActionFilter { public void OnActionExecuting(ActionExecutingContext context) { var acceptHeaderPresent = context.HttpContext.Request.Headers.ContainsKey("Accept"); if (!acceptHeaderPresent) { context.Result = new BadRequestObjectResult($"Accept header is missing."); return; } var mediaType = context.HttpContext.Request.Headers["Accept"].FirstOrDefault(); if (!MediaTypeHeaderValue.TryParse(mediaType, out MediaTypeHeaderValue outMediaType)) { context.Result = new BadRequestObjectResult($"Media type not present. Please add Accept header with the required media type."); return; } context.HttpContext.Items.Add("AcceptHeaderMediaType", outMediaType); } public void OnActionExecuted(ActionExecutedContext context) { } }
public class ValidateMediaTypeAttribute : IActionFilter
{
	public void OnActionExecuting(ActionExecutingContext context)
	{
		var acceptHeaderPresent = context.HttpContext.Request.Headers.ContainsKey("Accept");

		if (!acceptHeaderPresent)
		{
			context.Result = new BadRequestObjectResult($"Accept header is missing.");
			return;
		}

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

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

		context.HttpContext.Items.Add("AcceptHeaderMediaType", outMediaType);
	}

	public void OnActionExecuted(ActionExecutedContext context)
	{

	}
}

We check for the existence of the Accept header first. If it’s not present we return BadRequest. If it is, we parse the media type, and if there is no valid media type present, we return BadRequest.

Once we’ve passed the validation checks, we pass the parsed media type to the HttpContext of the controller.

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

services.AddScoped<ValidateMediaTypeAttribute>();
services.AddScoped<ValidateMediaTypeAttribute>();
services.AddScoped<ValidateMediaTypeAttribute>();

Now we need to decorate our GetOwners and GetOwnerById actions with [ServiceFilter(typeof(ValidateMediaTypeAttribute))] for these validations to take place.

As for the logic itself, we need to extend these actions a bit:

[HttpGet]
[ServiceFilter(typeof(ValidateMediaTypeAttribute))]
public 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++)
{
var ownerLinks = CreateLinksForOwner(owners[index].Id, ownerParameters.Fields);
shapedOwners[index].Add("Links", ownerLinks);
}
var ownersWrapper = new LinkCollectionWrapper<Entity>(shapedOwners);
return Ok(CreateLinksForOwners(ownersWrapper));
}
[HttpGet] [ServiceFilter(typeof(ValidateMediaTypeAttribute))] public 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++) { var ownerLinks = CreateLinksForOwner(owners[index].Id, ownerParameters.Fields); shapedOwners[index].Add("Links", ownerLinks); } var ownersWrapper = new LinkCollectionWrapper<Entity>(shapedOwners); return Ok(CreateLinksForOwners(ownersWrapper)); }
[HttpGet]
[ServiceFilter(typeof(ValidateMediaTypeAttribute))]
public 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++)
	{
		var ownerLinks = CreateLinksForOwner(owners[index].Id, ownerParameters.Fields);
		shapedOwners[index].Add("Links", ownerLinks);
	}

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

	return Ok(CreateLinksForOwners(ownersWrapper));
}

We read the media type we’ve parsed in the ValidateMediaTypeAttribute ActionFilter and cast it to MediaTypeHeaderValue type. Using the SubTypeWithoutSuffix.EndsWith of the MediaTypeHeaderValue class we check if HATEOAS is requested, and if it’s not we return owners immediately. If it is, we add the links and return them as we did before this implementation.

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);
}
[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); }
[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);
}

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

We should get:

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

And now, when we change the Accept header to application/vnd.codemaze.hateoas+json and we should get:

{
"value": [
{
"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/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
"rel": "self",
"method": "GET"
},
{
"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
"rel": "delete_owner",
"method": "DELETE"
},
{
"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
"rel": "update_owner",
"method": "PUT"
}
]
},
...
],
"links": [
{
"href": "https://localhost:5001/api/owner",
"rel": "self",
"method": "GET"
}
]
}
{ "value": [ { "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/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906", "rel": "self", "method": "GET" }, { "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906", "rel": "delete_owner", "method": "DELETE" }, { "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906", "rel": "update_owner", "method": "PUT" } ] }, ... ], "links": [ { "href": "https://localhost:5001/api/owner", "rel": "self", "method": "GET" } ] }
{
    "value": [
        {
            "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/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "self",
                    "method": "GET"
                },
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "delete_owner",
                    "method": "DELETE"
                },
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "update_owner",
                    "method": "PUT"
                }
            ]
        },
        ...
    ],
    "links": [
        {
            "href": "https://localhost:5001/api/owner",
            "rel": "self",
            "method": "GET"
        }
    ]
}

Fantastic. Now we have the means to choose between the json, XML, and HATEOAS responses as we please.

Let’s summarize.

Conclusion

HATEOAS is indeed one of the most useful but at the same one of the most complicated REST concepts to implement. How you implement HATEOAS in your API and how much you invest in polishing it is up to you. HATEOAS is easily “the one” thing that separates an excellent API from just good or bad ones.

So what have we learned this time:

  • What HATEOAS is and how important it is in the RESTful world
  • How to implement HATEOAS in ASP.NET Core WebAPI project
  • Improved our solution to work nicely with data shaping
  • How to support XML serialization of dynamic objects with HATEOAS links
  • What custom media types are and how to utilize them to make HATEOAS optional in our responses

This article turned out to be pretty lengthy, but we hope you learned something new, and once again if you found it hard to follow, check out our finished project, and compare and contrast it to your own solution.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!