Cairn.Testing 0.11.0

dotnet add package Cairn.Testing --version 0.11.0
                    
NuGet\Install-Package Cairn.Testing -Version 0.11.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Cairn.Testing" Version="0.11.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Cairn.Testing" Version="0.11.0" />
                    
Directory.Packages.props
<PackageReference Include="Cairn.Testing" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Cairn.Testing --version 0.11.0
                    
#r "nuget: Cairn.Testing, 0.11.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Cairn.Testing@0.11.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Cairn.Testing&version=0.11.0
                    
Install as a Cake Addin
#tool nuget:?package=Cairn.Testing&version=0.11.0
                    
Install as a Cake Tool

<div align="center">

Cairn

Opt-in HATEOAS for ASP.NET Core — hypermedia links and actions, added only where they help.

NuGet Coverage CI OpenSSF Scorecard License: MIT .NET

Documentation · Getting started · Sample API · Releases

</div>


Cairn adds hypermedia to ASP.NET Core APIs without touching your models. DTOs stay plain record types, link rules live in a separate LinkConfig<T>, and endpoints opt in one at a time — everything you don't opt in serializes exactly as before, so Cairn is safe to introduce incrementally into an existing API.

What is HATEOAS?

Hypermedia As The Engine of Application State is the idea that an API response should tell the client where it can go and what it can do next, instead of leaving the client to hardcode URLs and business rules.

A typical API returns bare data:

{ "id": 42, "status": "Pending" }

To act on this, the client has to already know things that aren't in the response: how to build the order's URL, that orders can only be cancelled while pending, and whether this user is allowed to cancel. That knowledge gets duplicated into every client — and silently breaks when the server changes.

A hypermedia response carries the knowledge with the data:

{
  "id": 42,
  "status": "Pending",
  "_links": {
    "self": { "href": "https://api.example.com/orders/42" }
  },
  "_actions": {
    "cancel": { "href": "https://api.example.com/orders/42/cancel", "method": "POST" }
  }
}

Reading it top to bottom:

  • _links answers "where can I go from here?" Each key is a relationself is the canonical URL of this resource; a collection page adds relations like next and prev.
  • _actions answers "what can I do right now?" Each entry is an affordance: a state transition with a target URL and HTTP method.
  • cancel is present because it's currently valid — the order is Pending, and the caller passed the authorization policy that guards cancellation.

Fetch the same order after it ships, and the response changes:

{
  "id": 42,
  "status": "Shipped",
  "_links": {
    "self": { "href": "https://api.example.com/orders/42" }
  }
}

The cancel action is gone. That is the "engine of application state" part: the server — the only party that actually knows the rules — tells the client what is possible, and the client's job reduces to "render a Cancel button if _actions.cancel exists." No duplicated state machine, no duplicated permission checks, no hardcoded URLs.

New to the concept? The docs have a longer, gentler introduction: What is HATEOAS?

Why Cairn?

Most hypermedia libraries want to own your whole API: base classes on your DTOs, wrapper types on your responses, a global formatter over every endpoint. Cairn deliberately does the opposite.

  • Clean DTOs. Links are declared outside the model and injected at serialization time through a System.Text.Json contract modifier — no base class, no marker interface, no attributes on your types.
  • Opt-in per endpoint. .WithLinks() on a minimal-API endpoint or [CairnLinks] on a controller action. Everything else is byte-for-byte unchanged.
  • Affordances that authorize. An action can be advertised only when the resource is in the right state and the caller satisfies an ASP.NET Core authorization policy — the same policy that guards the endpoint itself.
  • One config, three formats. Declare links once; serve Cairn's flat default shape, HAL, or HAL-FORMS via Accept-header negotiation, or plug in your own format.
  • Tooling that keeps it honest. Roslyn analyzers catch broken route names and unconfigured types at compile time, a source generator gives you a typed Routes.* catalog instead of magic strings, and dedicated packages cover OpenAPI/Swagger docs, a browsable HAL explorer, test assertions, and a typed client.

Quick start

Starting a new API? Scaffold one already wired for hypermedia:

dotnet new install Cairn.Templates
dotnet new cairn-api -o Orders.Api

That gives you the wiring below already in place — see Project template. To add Cairn to an existing API, install the ASP.NET Core package:

dotnet add package Cairn.AspNetCore

1. Your DTO stays a plain record — Cairn never modifies it:

public enum OrderStatus { Pending, Shipped, Cancelled }

public record OrderDto(int Id, OrderStatus Status);

2. Declare the hypermedia in a LinkConfig<T>, separate from the model:

public sealed class OrderLinks : LinkConfig<OrderDto>
{
    public override void Configure(ILinkBuilder<OrderDto> builder)
    {
        builder.Self(order => LinkTarget.Route("GetOrderById", new { id = order.Id }));

        builder.Affordance("cancel", order => LinkTarget.Route("CancelOrder", new { id = order.Id }))
            .Post()
            .When(order => order.Status == OrderStatus.Pending);
    }
}

3. Register Cairn and opt the endpoint in. LinkTarget.Route resolves against endpoint names, so name the routes with .WithName(...):

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCairn(options => options.AddLinks(new OrderLinks()));

var app = builder.Build();

app.MapGet("/orders/{id:int}", (int id) => TypedResults.Ok(new OrderDto(id, OrderStatus.Pending)))
   .WithName("GetOrderById")
   .WithLinks();                       // ← this endpoint's responses now carry hypermedia

app.MapPost("/orders/{id:int}/cancel", (int id) => TypedResults.NoContent())
   .WithName("CancelOrder");

app.Run();

4. Call it. GET /orders/42 now returns the DTO with its links and — because the order is pending — the cancel action:

{
  "id": 42,
  "status": "Pending",
  "_links": {
    "self": { "href": "https://localhost:7043/orders/42" }
  },
  "_actions": {
    "cancel": { "href": "https://localhost:7043/orders/42/cancel", "method": "POST" }
  }
}

Controllers work the same way with the same LinkConfig<T> — opt an action (or the whole controller) in with [CairnLinks]:

[ApiController]
[Route("orders")]
public class OrdersController(IOrderRepo repo) : ControllerBase
{
    [HttpGet("{id:int}", Name = "GetOrderById")]
    [CairnLinks]
    public OrderDto Get(int id) => repo.Get(id);
}

The step-by-step walkthrough — including what to do when links don't appear — is in Getting started.

A tour of the toolbox

Actions gated by state and permissions

When(...) gates an affordance on resource state; RequireAuthorization(...) gates it on the caller — evaluated against real ASP.NET Core policies, memoized per request. The response advertises exactly the actions this caller can take on this resource, right now:

builder.Affordance("cancel", o => LinkTarget.Route("CancelOrder", new { id = o.Id }))
    .Post()
    .When(o => o.Status == OrderStatus.Pending)
    .RequireAuthorization("CanCancelOrders");   // same policy that guards the endpoint

Need a per-item decision — "may this caller cancel this order?" — rather than a caller-wide one? The resource-based overload RequireAuthorization("CancelOrder", o => o) hands the resource to your ASP.NET Core policy handlers as context.Resource. Conditions can also be service-aware and async when the decision needs more than the DTO — see Link configurations.

One declaration, three wire formats

The same LinkConfig<T> serves three built-in shapes, selected by the request's Accept header (or forced per endpoint):

Accept Format Affordances emitted as
application/json Default (flat) _actions
application/hal+json HAL — (HAL has no actions)
application/prs.hal-forms+json HAL-FORMS _templates

HAL-FORMS templates go further than a URL and a method: Accepts<TInput>() derives full form field descriptions (types, required flags, ranges, enum options) from your input type's data annotations, so a client can render the form without knowing the type. Custom formats such as Siren plug in through IHypermediaFormatter and participate in the same negotiation. See Wire formats and Affordances & HAL-FORMS.

Opt-in cuts a second way, too: set DefaultFormat = HypermediaFormat.None and hypermedia becomes opt-in by the client. A plain application/json request then returns the bare resource, and links appear only when the caller's Accept header asks for a hypermedia media type — so callers that just want data aren't paying for links they'll ignore.

Return a PagedResource<T> (offset) or CursorPage<T> (keyset) and the envelope gets self/first/prev/next/last links derived from the request URL — while each item on the page still gets its own links:

"_links": {
  "self":  { "href": "https://api.example.com/orders?page=2" },
  "prev":  { "href": "https://api.example.com/orders?page=1" },
  "next":  { "href": "https://api.example.com/orders?page=3" }
}

The request side is covered too: declare a PageRequest (or CursorRequest) handler parameter and the paging query parameters bind under the same names the links swap — with a configurable default and cap, and the bound values flowing back into the envelope via paging.ToResource(items, total). Existing envelope types can join in without being changed, via AddPaging<T>. See Pagination.

Cairn.Client consumes what the server emits — follow relations, check for affordances, and invoke them, without building a URL anywhere:

var order = (await client.GetAsync<Order>("/orders/42")).Resource!;

if (order.HasAffordance("cancel"))
{
    await order.InvokeAsync("cancel");
}

// Traverson-style multi-hop: follow a chain of relations in one call.
var item = await client.TraverseAsync<OrderItem>("/", "orders", "next", "item");

See The typed client.

Test assertions for your hypermedia contract

Cairn.Testing parses a response and asserts on links, actions, and forms — framework-agnostic, with URL patterns that survive the random port of an in-memory test server:

var hypermedia = await client.GetHypermediaAsync("/orders/42");

hypermedia.Should()
    .HaveSelfLink()
    .And.HaveAffordance("cancel").WithMethod(HttpMethod.Post)
    .And.NotHaveAffordance("delete");

HypermediaSnapshot renders stable, snapshot-friendly JSON for approval-style tests. See Testing.

Compile-time route safety

Link targets reference routes by name, and magic strings rot. Cairn.AspNetCore bundles Roslyn analyzers and a source generator — nothing extra to install — that close the loop:

  • CAIRN001 flags a LinkTarget.Route("name") that no endpoint declares (with a code fix).
  • CAIRN002 flags a .WithLinks() endpoint returning a type that has no LinkConfig<T> — the classic silent no-op.
  • The source generator builds a typed Routes.* catalog from your named endpoints, so configs can say Routes.GetOrderById(order.Id) and get compile errors instead of broken links.

See Route safety.

Browse your API

Cairn.AspNetCore.Explorer serves a HAL Explorer — an in-browser console that navigates your live API the way a hypermedia client does: follow _links, drill into _embedded, and run HAL-FORMS actions as real forms.

app.UseCairnExplorer();   // browse at /explorer — Development only by default

The UI is a single embedded HTML document (no CDN, no build step) and fetches on the same origin with the caller's credentials, so it shows exactly the links and actions the current user is authorized to see. See Browsable API explorer.

Also in the box

  • Embedded resources — HAL _embedded, link arrays, and CURIEs.
  • API versioning — composes with Asp.Versioning; URL-segment versions flow into links automatically.
  • Link URL policy — absolute URLs by default, with PublicBaseUri pinning (or a per-request ResolvePublicBaseUri for multi-tenant hosts) and path-relative mode for proxied deployments.
  • Conditional requestsWithETag(...), precondition evaluation (304/412/428), an OPTIONS handler, and deprecation headers.
  • Error responses — problem details (RFC 9457) that carry links and actions.
  • OpenAPI & Swagger — hypermedia properties documented in your OpenAPI schema.
  • Diagnostics & observability — an ActivitySource, a Meter, and one-time warnings for every silent-failure mode.

When is hypermedia worth it?

Cairn is opt-in because hypermedia isn't free and isn't always the right call. It pays off when:

  • Resources are state machines. Orders, approvals, subscriptions, tickets — anything where "what you can do" depends on "what state it's in". The server owns the transition rules once, instead of every client reimplementing them.
  • UIs are permission-aware. Rendering buttons from _actions means the frontend never re-implements authorization logic — and never shows an action the API would reject.
  • Clients navigate rather than construct. Pagination, search results, and workflow chains where following next beats string-building URLs.
  • URLs need freedom to change. Clients that follow links survive route restructuring; clients that build URLs from templates don't.

If your API is internal, its clients are generated from an OpenAPI spec, and its resources have no interesting state — plain JSON is fine, and Cairn will happily stay out of those endpoints. Opt in the ones where it helps.

One structural caveat: streaming responses (IAsyncEnumerable<T>) don't get links. Cairn computes hypermedia before serialization and an async stream can't be enumerated twice, so streamed items serialize without _links (a one-time warning is logged). Materialize first (e.g. ToListAsync()) — or leave streaming endpoints out of Cairn.

Packages

Package Purpose Frameworks
Cairn.Core Transport-agnostic hypermedia model (links, relations, affordances). No ASP.NET dependency. net8.0 · net9.0 · net10.0
Cairn.AspNetCore ASP.NET Core integration: minimal APIs (.WithLinks()) and MVC ([CairnLinks]), formats, pagination. Bundles the analyzers, code fixes, and Routes.* source generator. net8.0 · net9.0 · net10.0
Cairn.Client Typed client for consuming hypermedia APIs. net8.0 · net9.0 · net10.0
Cairn.Testing Assertions and snapshots for links and affordances. net8.0 · net9.0 · net10.0
Cairn.Swashbuckle Hypermedia in Swashbuckle Swagger documents. net8.0 · net9.0 · net10.0
Cairn.OpenApi Hypermedia in Microsoft.AspNetCore.OpenApi documents. net10.0
Cairn.AspNetCore.Explorer A browsable HAL Explorer UI (UseCairnExplorer()), served from the app. Development-only by default. net8.0 · net9.0 · net10.0
Cairn.Mcp Exposes state/auth-gated affordances as Model Context Protocol tools for AI agents. net8.0 · net9.0 · net10.0
Cairn.Templates dotnet new cairn-api — scaffolds a minimal API wired for hypermedia. Installed with dotnet new install. scaffolds net8.0 · net9.0 · net10.0

Cairn.OpenApi builds on the schema-transformer pipeline that only exists in this shape on .NET 10; on .NET 8/9, use Cairn.Swashbuckle instead. Details in Packages.

Performance

Hypermedia is computed per response, so the overhead is measurable — and measured. The benchmarks serve the same page (50 items as a representative size, 1,000 as a stress case) end to end five ways — no links, links hand-rolled in the handler, WithLinks with route-based and with explicit-URI configs, and WithLinks over unconfigured items — plus single-resource and serializer-only suites. The benchmark README explains how to read the results (marginal per-item cost, not ratios against a near-empty baseline) and which numbers are deliberate non-goals:

dotnet run -c Release --project benchmarks/Cairn.Benchmarks

Building from source

dotnet build Cairn.slnx
dotnet test Cairn.slnx

Building requires the .NET 10 SDK; running the full multi-targeted test suite additionally needs the .NET 8 and .NET 9 runtimes. The shipped packages run on .NET 8 (LTS) and later. A complete runnable example lives in samples/Cairn.Sample.Api.

Why "Cairn"?

A cairn is a small stack of stones a traveler leaves along a trail so the next traveler can find the way — placed only where the path is unclear, and followed by choice. That's the design philosophy: hypermedia added deliberately, where it guides, never imposed everywhere.

License

MIT © Jonah Largen

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.
  • net8.0

    • No dependencies.
  • net9.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.11.0 137 7/14/2026
0.10.0 111 7/5/2026
0.9.0 116 7/5/2026
0.8.0 99 7/4/2026
0.7.0 112 7/4/2026
0.6.16 105 7/4/2026
0.6.15 115 7/4/2026
0.6.14 104 7/4/2026
0.6.13 113 7/4/2026
0.6.12 109 7/4/2026
0.6.11 99 7/4/2026
0.6.10 118 7/3/2026
0.6.9 126 7/3/2026
0.6.8 116 7/3/2026
0.6.7 101 7/3/2026
0.6.6 675 7/3/2026
0.6.5 98 7/3/2026
0.6.4 116 7/3/2026
0.6.3 112 7/3/2026
0.6.2 104 7/3/2026
Loading failed