A comprehensive .NET/C# implementation of the HAL (Hypertext Application Language) specification for building and consuming RESTful APIs
- Fluent Builder API: Intuitive, chainable methods for constructing HAL resources
- Multiple Deserialization Options: Support for strongly-typed objects, Resource objects, and source-generated types
- Embedded Resources: Easily work with nested HAL resources
- Link Management: Comprehensive link handling with curies, templates, and metadata
- Source Generators: Code generation support via
Chatter.Rest.Hal.CodeGeneratorspackage - Flexible Data Access: Extension methods for querying links and embedded resources
- HAL Specification Compliant: Full compliance with the official HAL specification
- URI Template Expansion: Built-in RFC 6570 Levels 1-3 URI template parsing and expansion via the
Chatter.Rest.UriTemplatespackage - Stable Link Array Representation: Opt-in control over single-object vs. array serialization per relation or globally, resolving a common HAL API consistency issue
- Chatter.Rest.Hal
Install the NuGet package:
dotnet add package Chatter.Rest.HalMinimal, copy-paste example (build, serialize):
using System;
using System.Text.Json;
using Chatter.Rest.Hal;
// Build a simple resource with state and a self link
var resource = ResourceBuilder
.WithState(new { message = "Hello, HAL!" })
.AddSelf().AddLinkObject("/api/greeting")
.Build();
// Serialize with System.Text.Json (pretty-print for readability)
var json = JsonSerializer.Serialize(resource, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
/* Output:
{
"message": "Hello, HAL!",
"_links": {
"self": {
"href": "/api/greeting"
}
}
}
*/If you want compile-time helpers generated for your response types, also install the code generators package:
dotnet add package Chatter.Rest.Hal.CodeGeneratorsThe core library provides builders and runtime support for HAL resources:
dotnet add package Chatter.Rest.HalNuGet Link: Chatter.Rest.Hal
For compile-time code generation support, add the code generators package:
dotnet add package Chatter.Rest.Hal.CodeGeneratorsNuGet Link: Chatter.Rest.Hal.CodeGenerators
HAL stands for Hypertext Application Language, a simple, standardized format for designing REST APIs that are easy to explore, understand, and consume. It enables self-documenting APIs where hypermedia controls and resource relationships are discoverable through the API itself.
According to Mike Kelly, the HAL specification creator:
"HAL is a simple format that gives a consistent and easy way to hyperlink between resources in your API. Adopting HAL will make your API explorable, and its documentation easily discoverable from within the API itself. In short, it will make your API easier to work with and therefore more attractive to client developers."
A typical HAL response includes _links for navigation and _embedded for related resources. Here's a comprehensive example:
{
"_links": {
"self": { "href": "/orders" },
"curies": [{ "name": "ea", "href": "http://example.com/docs/rels/{rel}", "templated": true }],
"next": { "href": "/orders?page=2" },
"ea:find": {
"href": "/orders{?id}",
"templated": true
},
"ea:admin": [{
"href": "/admins/2",
"title": "Fred"
}, {
"href": "/admins/5",
"title": "Kate"
}]
},
"currentlyProcessing": 14,
"shippedToday": 20,
"_embedded": {
"ea:order": [{
"_links": {
"self": { "href": "/orders/123" },
"ea:basket": { "href": "/baskets/98712" },
"ea:customer": { "href": "/customers/7809" }
},
"total": 30.00,
"currency": "USD",
"status": "shipped"
}, {
"_links": {
"self": { "href": "/orders/124" },
"ea:basket": { "href": "/baskets/97213" },
"ea:customer": { "href": "/customers/12369" }
},
"total": 20.00,
"currency": "USD",
"status": "processing"
}]
}
}The Fluent Builder API makes it easy to construct complex, HAL-compliant resources. It ensures your resource structure matches the HAL specification while maintaining readable, chainable code.
Example: Building the resource structure shown in the JSON above:
var resource = ResourceBuilder.WithState(new { currentlyProcessing = 14, shippedToday = 20 })
.AddSelf().AddLinkObject("/orders")
.AddCuries().AddLinkObject("http://example.com/docs/rels/{rel}", "ea")
.AddLink("next").AddLinkObject("/orders?page=2")
.AddLink("ea:find").AddLinkObject("/orders{?id}").Templated()
.AddLink("ea:admin").AddLinkObject("/admins/2").WithTitle("Fred")
.AddLinkObject("/admins/5").WithTitle("Kate")
.AddEmbedded("ea:order")
.AddResource(new { total = 30.00F, currency = "USD", status = "shipped" })
.AddSelf().AddLinkObject("/orders/123")
.AddLink("ea:basket").AddLinkObject("/baskets/98712")
.AddLink("ea:customer").AddLinkObject("/customers/7809")
.AddResource(new { total = 20.00F, currency = "USD", status = "processing" })
.AddSelf().AddLinkObject("/orders/124")
.AddLink("ea:basket").AddLinkObject("/baskets/97213")
.AddLink("ea:customer").AddLinkObject("/customers/12369")
.Build();In real-world scenarios, you typically construct resources dynamically from database objects. This example demonstrates building a HAL resource from a collection of Order objects:
Define your model:
public class Order
{
public string Id { get; set; }
public float Total { get; set; }
public string Currency { get; set; }
public string Status { get; set; }
}Build the resource dynamically:
var orders = new List<Order>()
{
new Order() { Id = Guid.NewGuid().ToString(), Currency = "USD", Total = 10, Status = "shipped" },
new Order() { Id = Guid.NewGuid().ToString(), Currency = "CAD", Total = 20, Status = "processing" },
new Order() { Id = Guid.NewGuid().ToString(), Currency = "EUR", Total = 30, Status = "customs" },
new Order() { Id = Guid.NewGuid().ToString(), Currency = "USD", Total = 40, Status = "shipped" },
new Order() { Id = Guid.NewGuid().ToString(), Currency = "USD", Total = 50, Status = "complete" },
new Order() { Id = Guid.NewGuid().ToString(), Currency = "CAD", Total = 69, Status = "nice" }
};
var resource = ResourceBuilder.WithState(new { currentlyProcessing = 14, shippedToday = 20 })
.AddSelf().AddLinkObject("/orders")
.AddCuries().AddLinkObject("http://example.com/docs/rels/{rel}", "ea")
.AddLink("next").AddLinkObject("/orders?page=2")
.AddLink("ea:find").AddLinkObject("/orders{?id}").Templated()
.AddLink("ea:admin").AddLinkObject("/admins/2").WithTitle("Fred")
.AddLinkObject("/admins/5").WithTitle("Kate")
.AddEmbedded("ea:order")
.AddResources(orders, (o, builder) =>
{
builder.AddSelf().AddLinkObject($"/orders/{o.Id}")
.AddLink("ea:basket").AddLinkObject("/baskets/{basketId}").Templated()
.AddLink("ea:customer").AddLinkObject("/customers/{custId}").Templated();
})
.Build();Resulting JSON output:
{
"currentlyProcessing": 14,
"shippedToday": 20,
"_links": {
"self": {
"href": "/orders"
},
"curies": {
"href": "http://example.com/docs/rels/{rel}",
"templated": true,
"name": "ea"
},
"next": {
"href": "/orders?page=2"
},
"ea:find": {
"href": "/orders{?id}",
"templated": true
},
"ea:admin": {
"href": "/admins/2",
"title": "Fred"
}
},
"_embedded": {
"ea:order": [
{
"total": 10,
"currency": "USD",
"status": "shipped",
"id": "6d5edc98-8b81-435f-ad7a-a66a60d91bd2",
"_links": {
"self": {
"href": "/orders/6d5edc98-8b81-435f-ad7a-a66a60d91bd2"
},
"ea:basket": {
"href": "/baskets/{basketId}",
"templated": true
},
"ea:customer": {
"href": "/customers/{custId}",
"templated": true
}
}
},
{
"total": 20,
"currency": "CAD",
"status": "processing",
"id": "418845a7-ec41-4288-83eb-22a8bb22e472",
"_links": {
"self": {
"href": "/orders/418845a7-ec41-4288-83eb-22a8bb22e472"
},
"ea:basket": {
"href": "/baskets/{basketId}",
"templated": true
},
"ea:customer": {
"href": "/customers/{custId}",
"templated": true
}
}
}
]
}
}The output above shows two of the six orders. Remaining orders follow the same structure.
Deserialize HAL+JSON responses into strongly typed .NET objects for type safety and IntelliSense support:
public class OrderCollection
{
[JsonPropertyName("currentlyProcessing")]
public int CurrentlyProcessing { get; set; }
[JsonPropertyName("shippedToday")]
public int ShippedToday { get; set; }
[JsonPropertyName("_links")]
public LinkCollection? Links { get; set; }
[JsonPropertyName("_embedded")]
public EmbeddedResourceCollection? Embedded { get; set; }
}
// Deserialize from HAL+JSON
var stronglyTypedOrder = JsonSerializer.Deserialize<OrderCollection>(halJson);For more flexible deserialization when you don't have a strongly-typed target class, use the Resource object:
public class OrderState
{
[JsonPropertyName("currentlyProcessing")]
public int CurrentlyProcessing { get; set; }
[JsonPropertyName("shippedToday")]
public int ShippedToday { get; set; }
}
// Deserialize to a Resource object
var resource = JsonSerializer.Deserialize<Resource>(halJson);
// Access the state as a strongly-typed object
var orderState = resource.State<OrderState>();Convert a Resource object to a strongly-typed object:
var resource = ResourceBuilder.WithState(new { currentlyProcessing = 14, shippedToday = 20 })
.AddSelf().AddLinkObject("/orders")
.AddCuries().AddLinkObject("http://example.com/docs/rels/{rel}", "ea")
.AddLink("next").AddLinkObject("/orders?page=2")
.AddLink("ea:find").AddLinkObject("/orders{?id}").Templated()
.AddLink("ea:admin").AddLinkObject("/admins/2").WithTitle("Fred")
.AddLinkObject("/admins/5").WithTitle("Kate")
.AddEmbedded("ea:order")
.AddResources(orders, (o, builder) =>
{
builder.AddSelf().AddLinkObject($"/orders/{o.Id}")
.AddLink("ea:basket").AddLinkObject("/baskets/{basketId}").Templated()
.AddLink("ea:customer").AddLinkObject("/customers/{custId}").Templated();
})
.Build();
// Cast to strongly-typed object using the .As<T>() method
var orderCollection = resource!.As<OrderCollection>();For more details, see the Resource class documentation.
For automatic property generation, use the Chatter.Rest.Hal.CodeGenerators package to generate HAL-specific properties at compile time.
Step 1: Install the code generators package:
dotnet add package Chatter.Rest.Hal.CodeGeneratorsStep 2: Decorate your class with the [HalResponse] attribute:
[HalResponse]
public partial class OrderCollection
{
[JsonPropertyName("currentlyProcessing")]
public int CurrentlyProcessing { get; set; }
[JsonPropertyName("shippedToday")]
public int ShippedToday { get; set; }
}The [HalResponse] attribute automatically generates a partial class that adds the required HAL properties:
[JsonPropertyName("_links")]
public LinkCollection? Links { get; set; }
[JsonPropertyName("_embedded")]
public EmbeddedResourceCollection? Embedded { get; set; }See the HalResponseAttribute documentation for more information.
Once you've received an application/hal+json response from your API, the library provides convenient extension methods to navigate links and access embedded resources. The following examples show how to use the Resource object extension methods.
Extract embedded resources as strongly-typed objects:
var embeddedOrders = resource!.GetEmbeddedResources<Order>("ea:order");Retrieve a collection of Resource objects by relation name:
var resources = resource!.GetResourceCollection("ea:order");Retrieve a single link by its relation. Returns null if not found, or throws an exception if multiple links with the same relation exist:
var link = resource!.GetLinkOrDefault("self");Retrieve all link objects associated with a specific relation:
var linkObjCol = resource!.GetLinkObjects("self");When using custom namespaces (curies), retrieve a link object by both relation and name. Returns null if not found, or throws an exception if multiple matches exist:
var linkObj = resource!.GetLinkObjectOrDefault("curies", "ea");For relations with a single link object, retrieve it directly by relation:
var linkObj = resource!.GetLinkObjectOrDefault("self");If used on the resource from the example JSON above, this would return a Link Object with { "href": "/orders" } since "self" is the only link object for that relation.
The HAL specification states that servers SHOULD NOT change a relation between a single Link Object and an array across responses. However, by default, this library auto-selects the representation based on count:
- 1 link object →
"self": { "href": "/orders" }(single object) - 2+ link objects →
"self": [{ "href": "..." }, ...](array)
This means a relation that currently returns one link could silently change its JSON shape as soon as a second link is added, breaking clients that hard-coded the single-object form.
To force all link relations to serialize as JSON arrays regardless of count, use HalJsonOptions with AddHalConverters():
using Chatter.Rest.Hal;
using Chatter.Rest.Hal.Extensions;
// ASP.NET Core
services.AddControllers().AddJsonOptions(o =>
o.JsonSerializerOptions.AddHalConverters(
new HalJsonOptions { AlwaysUseArrayForLinks = true }));
// Standalone (process-global startup mutation)
HalJsonOptions.Default.AlwaysUseArrayForLinks = true;With this configuration, a single link object now serializes as:
{
"_links": {
"self": [{ "href": "/orders" }]
}
}Note:
AddHalConverters()registers converters that override the library's default[JsonConverter]-attribute-wired converters when the suppliedJsonSerializerOptionsare passed toJsonSerializer. Consumers that never callAddHalConverters()are unaffected. Calling it twice on the same instance is safe.
To force array representation for a specific relation only, use AsArray() in the builder chain or set Link.IsArray directly:
Via builder:
var resource = ResourceBuilder.WithState(new { total = 5 })
.AddLinks()
.AddLink("orders").AsArray() // always emit as array
.AddLinkObject("/orders/1")
.AddSelf() // count-based (default behavior)
.AddLinkObject("/api/orders")
.Build();Via domain object:
var link = new Link("orders") { IsArray = true };
link.LinkObjects.Add(new LinkObject("/orders/1"));Round-trip fidelity: When deserializing a HAL response where a relation was expressed as a JSON array, the library automatically sets IsArray = true on the deserialized Link. Re-serializing that resource preserves the array form.
AlwaysUseArrayForLinks (global) |
Link.IsArray (per-relation) |
Result |
|---|---|---|
false (default) |
false (default) |
Count-based (existing behavior) |
false |
true |
Array |
true |
false |
Array |
true |
true |
Array |
- Source Code: GitHub Repository
- Core Library: Resource.cs
- Code Generators: Source Generator Project
- HAL Specification: Official Spec
This project is licensed under the MIT License. See the LICENSE file for details.
Contributions are welcome. To get started, open an issue to discuss the change you have in mind, then fork the repository and submit a pull request against main. Please include tests for any new behavior.
For questions, bug reports, or feature requests, visit the GitHub Repository.