Today, we're embarking on a journey into HATEOAS, Hypermedia as the Engine of Application State. A RESTful API design principle that makes your APIs dynamic, discoverable, and future-proof.
I promise that if you follow along you'll understand this with clarity, enthusiasm, and, as a result, you'll get a practical mini-project using Minimal APIs in C# with .NET.
We'll build a Library API, incorporate HATEOAS links via an extension method, and address how to access IUrlHelper
in a Minimal API context.
Let's dive in!
What is HATEOAS? The Big Picture
HATEOAS is like giving your API a built-in GPS. Instead of clients hardcoding URLs, your API responses include data (e.g., a book's details) and hypermedia links (e.g., URLs to borrow or update the book).
Clients follow these links to navigate the API, just like clicking "Next Page" on a website.
For example, a book resource might return:
{
"id": 1,
"title": "The Great Gatsby",
"isAvailable": true,
"links": {
"self": { "href": "/api/books/1", "rel": "self", "method": "GET" },
"borrow": {
"href": "/api/books/1/borrow",
"rel": "borrow",
"method": "POST"
}
}
}
Why HATEOAS Matters
- Discoverability: Clients find new endpoints via links, reducing reliance on documentation.
- Flexibility: API changes (e.g., new URLs) don't break clients if links are updated.
- Loose Coupling: Clients depend on links, not fixed paths, making maintenance easier.
Common Misunderstandings
- "It's just adding URLs!" Not quite! HATEOAS guides state transitions (e.g., from "available" to "borrowed").
- "Too complex for small APIs." Even simple APIs benefit from future-proofing.
- "Clients ignore links." Well-designed links encourage automation and adoption.
HATEOAS in Action: The Concept
Imagine you're at a library:
- You request a book (GET
/api/books/1
). - The librarian hands you the book's details and a note: "Borrow it here" or "Check availability online" (hypermedia links).
- You don't need to know the library's internal system; you follow the note's instructions.
HATEOAS brings this intuitive navigation to APIs, making them self-documenting and adaptable.
Our Mini-Project: A Library API with Minimal APIs
We'll build a Library API using Minimal APIs in C#.
The API will manage books, and responses will include HATEOAS links generated via a Book.ToDto()
extension method.
We'll also tackle accessing IUrlHelper
from HttpContext
, a key challenge in Minimal APIs.
Step 1: Setting Up the Project
Let's create our Minimal API project.
-
Create a new web api project using your favorite IDE. Although it's a Library API, I'll call it
HateoasDemo
. -
Remove any pre-cooked code (if applies) like Weather endpoints, etc.
-
Add dependencies: We need to add the package
Microsoft.AspNetCore.Mvc.Core
to have access toIUrlHelperFactory
. So go ahead and install it using your IDE's NuGet Package Manager or your terminal.
Step 2: Modeling the Domain
Our API manages books. Let's define the Book
model.
Create Models/Book.cs
:
namespace HateoasDemo.Models;
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Author { get; set; } = string.Empty;
public bool IsAvailable { get; set; }
}
Note: We initialize strings to avoid null warnings. Links will be handled by an extension method, not embedded in Book
.
Step 3: Creating the HATEOAS Extension Method
We'll extend Book
with a ToDto
method that generates a response with HATEOAS links.
In Minimal APIs, we need IUrlHelper
from HttpContext
using IUrlHelperFactory
and IActionContextAccessor
, as there's no controller-provided Url
.
So, let's create Models/BookHateoasExtensions.cs
:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
namespace HateoasDemo.Models;
public record LinkDto(string Href, string Rel, string Method);
public static class BookHateoasExtensions
{
public static object ToDto(this Book book, HttpContext httpContext)
{
var urlHelperFactory = httpContext.RequestServices.GetRequiredService<IUrlHelperFactory>();
var actionContextAccessor = httpContext.RequestServices.GetRequiredService<IActionContextAccessor>();
var urlHelper = urlHelperFactory.GetUrlHelper(actionContextAccessor.ActionContext
?? new ActionContext(httpContext, new RouteData(), new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor()));
return new
{
book.Id,
book.Title,
book.Author,
book.IsAvailable,
Links = new Dictionary<string, LinkDto?>
{
["self"] = new LinkDto(
urlHelper.Link("GetBook", new { id = book.Id }) ?? string.Empty,
"self",
"GET"
),
["update"] = new LinkDto(
urlHelper.Link("UpdateBook", new { id = book.Id }) ?? string.Empty,
"update",
"PUT"
),
["delete"] = new LinkDto(
urlHelper.Link("DeleteBook", new { id = book.Id }) ?? string.Empty,
"delete",
"DELETE"
),
["borrow"] = book.IsAvailable
? new LinkDto(
urlHelper.Link("BorrowBook", new { id = book.Id }) ?? string.Empty,
"borrow",
"POST"
)
: null,
["return"] = book.IsAvailable
? null
: new LinkDto(
urlHelper.Link("ReturnBook", new { id = book.Id }) ?? string.Empty,
"return",
"POST"
)
}.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
};
}
}
Explanation
- LinkDto: A record for links, with
Href
(URL),Rel
(relationship), andMethod
(HTTP verb). - ToDto:
- Takes
HttpContext
to access DI services. - Uses
IUrlHelperFactory
andIActionContextAccessor
to createIUrlHelper
. - Returns an anonymous object with book properties and a
Links
dictionary. - Includes
self
,update
,delete
, and conditionalborrow
links (only ifIsAvailable
).
- Takes
- urlHelper.Link: Uses named routes (e.g., "GetBook") for URL generation, critical for Minimal APIs.
Gotcha: Forgetting to register IActionContextAccessor
or name routes causes null URLs. We'll address this in the next step.
Step 4: Building the Minimal API
We'll define endpoints using MapGet
, MapPost
, etc., and name them for urlHelper.Link
.
Let's update our Program.cs
:
using Microsoft.AspNetCore.Mvc.Infrastructure;
using HateoasDemo.Models;
var builder = WebApplication.CreateBuilder(args);
// Register services for IUrlHelper
builder.Services.AddSingleton<IActionContextAccessor, ActionContextAccessor>();
builder.Services.AddControllers(); // Needed for IUrlHelperFactory
var app = builder.Build();
app.UseHttpsRedirection();
// In-memory book store
var books = new List<Book>
{
new Book { Id = 1, Title = "The Great Gatsby", Author = "F. Scott Fitzgerald", IsAvailable = true },
new Book { Id = 2, Title = "1984", Author = "George Orwell", IsAvailable = false }
};
// Endpoints
app.MapGet("/api/books/{id}", (HttpContext context, int id) =>
{
var book = books.FirstOrDefault(b => b.Id == id);
return book != null ? Results.Ok(book.ToDto(context)) : Results.NotFound();
}).WithName("GetBook");
app.MapGet("/api/books", (HttpContext context) =>
{
var bookDtos = books.Select(b => b.ToDto(context)).ToList();
return Results.Ok(bookDtos);
}).WithName("GetBooks");
app.MapPost("/api/books/{id}/borrow", (HttpContext context, int id) =>
{
var book = books.FirstOrDefault(b => b.Id == id);
if (book == null)
{
return Results.NotFound();
}
if (!book.IsAvailable)
{
return Results.BadRequest("Book is not available.");
}
book.IsAvailable = false;
return Results.Ok(book.ToDto(context));
}).WithName("BorrowBook");
app.MapPut("/api/books/{id}", (HttpContext context, int id, Book book) =>
{
var bookToUpdate = books.FirstOrDefault(b => b.Id == id);
if (bookToUpdate == null)
{
return Results.NotFound();
}
bookToUpdate.Title = book.Title;
bookToUpdate.Author = book.Author;
bookToUpdate.IsAvailable = book.IsAvailable;
return Results.Ok(bookToUpdate.ToDto(context));
}).WithName("UpdateBook");
app.MapDelete("/api/books/{id}", (HttpContext context, int id) =>
{
var bookToDelete = books.FirstOrDefault(b => b.Id == id);
if (bookToDelete == null)
{
return Results.NotFound();
}
books.Remove(bookToDelete);
return Results.Ok();
}).WithName("DeleteBook");
app.MapPost("/api/books/{id}/return", (HttpContext context, int id) =>
{
var bookToReturn = books.FirstOrDefault(b => b.Id == id);
if (bookToReturn == null)
{
return Results.NotFound();
}
bookToReturn.IsAvailable = true;
return Results.Ok(bookToReturn.ToDto(context));
}).WithName("ReturnBook");
app.Run();
What's Happening?
- DI Setup:
IActionContextAccessor
enablesIUrlHelper
creation.AddControllers
providesIUrlHelperFactory
(even without controllers).
- Endpoints:
- GET
/api/books/{id}
: Fetches a book with HATEOAS links. - GET
/api/books
: Lists all books. - POST
/api/books/{id}/borrow
: Updates availability, showing dynamic links. - PUT
/api/books/{id}
: Updates the given book. - DELETE
/api/books/{id}
: Deletes the given book from memory. - POST
/api/books/{id}
: Updates availability (returns the book), showing dynamic links.
- GET
- Route Names:
.WithName()
assigns names (e.g., "GetBook") forurlHelper.Link
. - HttpContext: Passed to
ToDto
for link generation.
Pro Tip: Use .WithName()
consistently to ensure urlHelper.Link
generates correct URLs.
Step 5: Testing the API
Let's run and test our HATEOAS-enabled Minimal API.
-
Start the project
-
Test with Postman, curl, or a browser:
-
GET
https://localhost:5001/api/books/1
-
Expected response:
{ "id": 1, "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "isAvailable": true, "links": { "self": { "href": "/api/books/1", "rel": "self", "method": "GET" }, "update": { "href": "/api/books/1", "rel": "update", "method": "PUT" }, "delete": { "href": "/api/books/1", "rel": "delete", "method": "DELETE" }, "borrow": { "href": "/api/books/1/borrow", "rel": "borrow", "method": "POST" } } }
-
GET
https://localhost:5001/api/books/2
(unavailable book): Noborrow
link, butreturn
link. -
POST
https://localhost:5001/api/books/1/borrow
, then GET again:borrow
link disappears andreturn
link is displayed.
-
-
Verify Links:
- Check that
href
values match routes. - Ensure HTTP methods (
GET
,POST
, etc.) are correct.
- Check that
Gotcha: If links are null, verify .WithName()
matches urlHelper.Link
calls and services are registered.
To make tests easy, here is the HateoasDemo.http
file:
@HateoasDemo_HostAddress = http://localhost:5000
### Get all books
GET {{HateoasDemo_HostAddress}}/api/books
Accept: application/json
### Get a specific book by ID
GET {{HateoasDemo_HostAddress}}/api/books/1
Accept: application/json
### Borrow a book
POST {{HateoasDemo_HostAddress}}/api/books/1/borrow
Accept: application/json
### Return a borrowed book
POST {{HateoasDemo_HostAddress}}/api/books/1/return
Accept: application/json
### Update a book
PUT {{HateoasDemo_HostAddress}}/api/books/1
Content-Type: application/json
{
"id": 1,
"title": "The Great Gatsby - Updated",
"author": "F. Scott Fitzgerald",
"isAvailable": true
}
### Delete a book
DELETE {{HateoasDemo_HostAddress}}/api/books/1
Accept: application/json
Step 6: Best Practices and Pro Tips
- Lean and Mean: Minimal APIs are perfect for lightweight APIs. Keep logic simple and focused.
- Named Routes: Always use
.WithName()
for endpoints that generate links. - Service Layer: For complex APIs, move
ToDto
to aHateoasService
injected via DI. - Standardize Links: Adopt HAL (
links
) or JSON:API for consistency. Our approach is HAL-inspired.
Repo with the code
If you don't want to type all the code, I prepared this repo with the code in this mini-project.
The Final Mini-Project
You've built a HATEOAS-enabled Library API with:
- A
Book
model andToDto
extension method for dynamic links. - Minimal API endpoints in
Program.cs
for GET, POST, PUT and DELETE. IUrlHelper
access viaHttpContext
, usingIUrlHelperFactory
andIActionContextAccessor
.- State-driven links (e.g.,
borrow
only for available books,return
only for borrowed books). - A clean, extensible codebase.
Wrapping Up
HATEOAS transforms APIs into self-navigating systems, and Minimal APIs make implementation lightweight and fun.
By using Book.ToDto()
and accessing IUrlHelper
from HttpContext
, you've created a dynamic, discoverable Library API that guides clients with links.
This project showcases the power of C# for tailored, elegant, and modern API design.
I hope this guide was as thrilling to read as it was to write!
Run the project, tweak the endpoints, or add new features.
If you hit a snag, check route names, DI setup, or test your links.
Bookmark this, share it with peers 🔄, and keep exploring RESTful APIs.
Happy Coding! ⚡