RealmDigital.Hypermedia 0.0.4

There is a newer version of this package available.
See the version list below for details.
dotnet add package RealmDigital.Hypermedia --version 0.0.4
                    
NuGet\Install-Package RealmDigital.Hypermedia -Version 0.0.4
                    
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="RealmDigital.Hypermedia" Version="0.0.4" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="RealmDigital.Hypermedia" Version="0.0.4" />
                    
Directory.Packages.props
<PackageReference Include="RealmDigital.Hypermedia" />
                    
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 RealmDigital.Hypermedia --version 0.0.4
                    
#r "nuget: RealmDigital.Hypermedia, 0.0.4"
                    
#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 RealmDigital.Hypermedia@0.0.4
                    
#: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=RealmDigital.Hypermedia&version=0.0.4
                    
Install as a Cake Addin
#tool nuget:?package=RealmDigital.Hypermedia&version=0.0.4
                    
Install as a Cake Tool

Overview

RealmDigital.Hypermedia enriches API responses with actionable hypermedia links driven by server-side business rules. It provides:

  • A Link/Links model sent with each response
  • A DI-registered link building pipeline that inspects your response model (recursively) and builds links for each resource type
  • A rules engine that marks actions as allowed or disabled with reasons
  • Controller helpers to wrap responses consistently
  • Optional helpers for paged results
  • A TypeScript source generator to consume these links in the UI

Key concepts

  • Link/Links: a Link has href, method, optional title, allowed, and disabledReason. A Links is a string-keyed dictionary of Link objects.
  • Link builders: implementations of ILinkBuilder<T> (derive from AbstractLinkBuilder<T>) that construct links for a resource type T. All link building is async.
  • Business rules: IAction<TResource> declares an action. IBusinessRule<TResource> (via IActionRule<TResource, TAction>) evaluates whether an action is allowed; implement EvaluateAsync as the primary method.
  • Collection and nested resources: the collector walks your response model, building keys with path prefixes — including [id] for list items — so the UI can address the correct action.
  • Controller helpers: HypermediaApiController exposes OkWithLinks and CreatedWithLinks to send { data, links } consistently.
  • MediatR pipeline behaviour: HypermediaEnrichmentBehavior calculates links after your handler returns a successful ErrorOr<T>, placing the result into HttpContext.Items for the controller to read.

Install and reference

Add the RealmDigital.Hypermedia project or package to your solution and reference it from your API. The library targets net8.0, net9.0, and net10.0. It requires MediatR and ErrorOr.


Dependency injection setup

Register the core hypermedia services, then scan for your link builders and business rules. The IPipelineBehavior registration belongs in your AddMediatR block alongside other pipeline behaviours.

services.AddHttpContextAccessor();
services.AddScoped<IHypermediaLinkCollector, HypermediaLinkCollector>();
services.AddScoped<IBusinessRuleEngine, BusinessRuleEngine>();

services.AddLinkBuildersFromAssembly(typeof(SomeFeatureType).Assembly);
services.AddActionRulesFromAssembly(typeof(SomeFeatureType).Assembly);

services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(SomeFeatureType).Assembly);
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(HypermediaEnrichmentBehavior<,>));
    // ... other pipeline behaviours
});

Pass additional assemblies to AddLinkBuildersFromAssembly and AddActionRulesFromAssembly as needed.


Controller usage

Inherit from HypermediaApiController and return OkWithLinks or CreatedWithLinks after your MediatR handler returns a successful result.

[Route("/api/deceased")]
public class DeceasedController : HypermediaApiController
{
    private readonly IMediator _mediator;
    private readonly IMapper _mapper;

    public DeceasedController(IMediator mediator, IMapper mapper)
    {
        _mediator = mediator;
        _mapper = mapper;
    }

    [HttpGet("{deceasedId:guid}")]
    [ProducesResponseType(typeof(HypermediaResponse<CrudContracts.Deceased>), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult> GetDeceased(Guid deceasedId)
    {
        var result = await _mediator.Send(new GetDeceasedByIdQuery(deceasedId));
        return result.Match(x => OkWithLinks(_mapper.Map<CrudContracts.Deceased>(x)), Problem);
    }
}

What happens:

  1. The handler returns ErrorOr<T>.
  2. HypermediaEnrichmentBehavior runs, computes links for T (and nested resources), stores them in HttpContext.Items["_hypermedia_links"].
  3. OkWithLinks reads the stored links and returns HypermediaResponse<T>: { data, links }.

Create a link builder per resource type. Derive from AbstractLinkBuilder<T> and implement BuildLinksAsync.

Basic example:

public class DeceasedLinkBuilder : AbstractLinkBuilder<Deceased>
{
    public DeceasedLinkBuilder(LinkGenerator linkGenerator, IBusinessRuleEngine businessRuleEngine)
        : base(linkGenerator, businessRuleEngine) { }

    public override async Task<Links> BuildLinksAsync(Deceased deceased, HttpContext httpContext)
    {
        return new Links
        {
            ["self"] = new Link(
                LinkGenerator.GetUriByAction(
                    httpContext,
                    action: nameof(DeceasedController.GetDeceased),
                    controller: GetControllerName(typeof(DeceasedController)),
                    values: new { deceasedId = deceased.Id }
                )!
            )
        };
    }
}

Link with business-rule evaluation:

public class ClaimLinkBuilder : AbstractLinkBuilder<Claim>
{
    public ClaimLinkBuilder(LinkGenerator linkGenerator, IBusinessRuleEngine businessRuleEngine)
        : base(linkGenerator, businessRuleEngine) { }

    public override async Task<Links> BuildLinksAsync(Claim claim, HttpContext httpContext)
    {
        var links = new Links();

        var href = LinkGenerator.GetUriByAction(
            httpContext,
            action: nameof(ClaimController.AddBeneficiaryToClaim),
            controller: GetControllerName(typeof(ClaimController)),
            values: new { claimId = claim.Id }
        ) ?? throw new Exception("Invalid call to LinkGenerator.GetUriByAction — Uri is null");

        await AddActionLinkAsync<AddBeneficiaryAction>(
            links,
            href,
            method: "POST",
            resource: claim,
            httpContext: httpContext,
            title: "Add Beneficiary");

        return links;
    }
}

Notes:

  • AddActionLinkAsync<TAction> derives the dictionary key from ActionKeys.For<TAction>().
  • If an action has no registered rules it is allowed by default.
  • To add a non-action link (pure navigation), add entries directly: links["update"] = new Link(href, "POST", "Update");

Defining actions and rules

Define an action with IAction<TResource>. Optionally assign a key via attribute — otherwise the convention strips the Action suffix and converts to kebab-case.

[ActionKey("add-beneficiary")] // optional
public sealed class AddBeneficiaryAction : IAction<Claim> { }

Attach rules using IActionRule<TResource, TAction>, which extends IBusinessRule<TResource>. Implement EvaluateAsync as the primary method. For trivial rules with no async work, override the sync Evaluate instead — the default async wrapper will call it.

public class FuneralClaimBeneficiaryRule : IActionRule<Claim, AddBeneficiaryAction>
{
    public string RuleName => nameof(FuneralClaimBeneficiaryRule);

    public RuleResult Evaluate(Claim claim, HttpContext httpContext)
    {
        if (claim.BenefitType != BenefitType.Funeral)
            return RuleResult.Success();

        if (claim.Beneficiaries.Count >= 1)
            return RuleResult.Fail(
                reason: "Funeral claims are limited to a single beneficiary",
                code: "Claim.FuneralClaimSingleBeneficiary",
                severity: RuleSeverity.Conflict);

        return RuleResult.Success();
    }
}

For rules that need to call a database or external service:

public class SomeAsyncRule : IActionRule<Claim, SubmitClaimAction>
{
    private readonly IMyRepository _repo;

    public SomeAsyncRule(IMyRepository repo) { _repo = repo; }

    public string RuleName => nameof(SomeAsyncRule);

    public async Task<RuleResult> EvaluateAsync(Claim claim, HttpContext httpContext)
    {
        var isEligible = await _repo.CheckEligibilityAsync(claim.Id);
        return isEligible ? RuleResult.Success() : RuleResult.Fail("Claim is not eligible for submission");
    }
}

Rule severity maps to ErrorOr error types when you call EnsureActionAllowedAsync:

RuleSeverity ErrorOr error type
Validation Error.Validation
Conflict Error.Conflict
Forbidden Error.Forbidden
Precondition Error.Failure

Enforcing at command-handling time:

var result = await _businessRuleEngine.EnsureActionAllowedAsync<SubmitClaimAction, Claim>(claim);
if (result.IsError) return result.Errors;

The link collector automatically walks nested resources and collections. For each property whose type has a registered ILinkBuilder<T>, the collector builds prefixed keys like Claim.add-beneficiary or Beneficiaries[id].remove.

Pattern 1 — standalone child link builder

Use this when the rule only needs the child resource itself.

public class ClaimBeneficiaryLinkBuilder : AbstractLinkBuilder<ClaimBeneficiary>
{
    public override async Task<Links> BuildLinksAsync(ClaimBeneficiary beneficiary, HttpContext httpContext)
    {
        var links = new Links();
        var href = /* ... */;
        await AddActionLinkAsync<RemoveBeneficiaryAction>(links, href, "DELETE", beneficiary, httpContext);
        return links;
    }
}

Pattern 2 — parent builder with keyPrefix

Use this when the rule needs data from both the parent and child. This is typically required when your domain model only supports parent → child navigation (no back-references), so the child alone cannot carry the context the rule needs.

public class ClaimLinkBuilder : AbstractLinkBuilder<Claim>
{
    public override async Task<Links> BuildLinksAsync(Claim claim, HttpContext httpContext)
    {
        var links = new Links();

        foreach (var beneficiary in claim.Beneficiaries)
        {
            var href = /* ... */;
            await AddActionLinkAsync<DeleteClaimBeneficiaryAction, DeleteClaimBeneficiaryContext>(
                links,
                href,
                "POST",
                new DeleteClaimBeneficiaryContext(claim, beneficiary),
                httpContext,
                "Delete Beneficiary",
                keyPrefix: $"Beneficiaries[{beneficiary.BeneficiaryId}]");
        }

        return links;
    }
}

This emits keys like Beneficiaries[{id}].delete-claim-beneficiary directly from the parent builder, bypassing the collector for those child entries.


Paged results

Derive from AbstractPagedListLinkBuilder<T> to emit standard pagination links.

public class OrdersPagedLinkBuilder : AbstractPagedListLinkBuilder<OrderDto>
{
    public OrdersPagedLinkBuilder(LinkGenerator linkGenerator, IBusinessRuleEngine engine)
        : base(linkGenerator, engine) { }

    public override async Task<Links> BuildLinksAsync(PagedList<OrderDto> list, HttpContext httpContext)
    {
        var links = BuildPaginationLinks(list, httpContext);
        // add page-level actions here if needed
        return links;
    }
}

BuildPaginationLinks emits: self, first, last, and conditionally previous and next. It rewrites the request query string, changing only PageNumber.


The HypermediaLinkCollector walks the response object graph and merges link dictionaries with a path prefix:

  • Root-level links use their plain action keys: "add-beneficiary"
  • Nested object property adds a prefix: "claim.add-beneficiary"
  • Collections are indexed by item Id: "beneficiaries[<id>].remove"
  • Deep nesting composes: "claim.beneficiaries[<id>].something"

The collector reads the Id property to index collection items; if missing, it falls back to "unknown". Ensure your exposed models have Id where you want indexed paths.


TypeScript integration

Run the source generator from your dev tooling console app when you make API changes — alongside your NSwag pipeline run. It generates a hypermedia.ts file with action keys and helper functions.

TypescriptHypermediaActionsSourceGenerator.Generate(
    $"{outputPath}/hypermedia.ts",
    typeof(AddBeneficiaryAction).Assembly);

The generated file imports from ~/generated/api:

import type { HypermediaLink, HypermediaLinks } from '~/generated/api'

HypermediaLink and HypermediaLinks are generated by NSwag from the OpenAPI spec, using your controller's ProducesResponseType(typeof(HypermediaResponse<T>)) declarations. The type names follow NSwag's namespace-based naming convention.

For each resource type with registered actions, the generator emits:

export const ClaimActionKeys = {
  AddBeneficiary: 'add-beneficiary',
  SubmitClaim: 'submit-claim',
} as const;

export type ClaimAction = typeof ClaimActionKeys[keyof typeof ClaimActionKeys];
export const ClaimActions: ClaimAction[] = Object.values(ClaimActionKeys);

It also emits the ActionByResource map and these helper functions:

Function Purpose
root<R>() Target a single root-level resource
idx<R>(id) Target an item in a root-level collection: [id]
qualify(path, key) Concatenate a path prefix and an action key with .
linkFor(links, target, action) Resolve the raw HypermediaLink
can(links, target, action) Returns true if the action is allowed
reason(links, target, action) Returns the disabledReason string if present

Usage patterns

Root-level resource:

import { ClaimActionKeys, can, reason, root } from '~/generated/hypermedia';

const allowed = can(links, root<'Claim'>(), ClaimActionKeys.AddBeneficiary);
const why = reason(links, root<'Claim'>(), ClaimActionKeys.AddBeneficiary);

Item in a root-level collection:

const allowed = can(links, idx<'Claim'>(claim.id), ClaimActionKeys.AddBeneficiary);

Item in a nested collection (multi-segment path):

const claimPath = `Membership.Claims[${claim.id}]` as Path<'Claim'>;
const allowed = can(links, claimPath, ClaimActionKeys.AddBeneficiary);
const why = reason(links, claimPath, ClaimActionKeys.AddBeneficiary);

Wrapping in a typed helper (recommended for frequently used paths):

export function canStartClaimSubmission(
    claimId: string,
    links: HypermediaLinks,
    claimPath: Path<'Claim'> = `Membership.Claims[${claimId}]` as Path<'Claim'>,
): boolean {
    return can(links, claimPath, ClaimActionKeys.StartClaimSubmission);
}

Use idx(id) for root-collection paths. Use a template literal for multi-segment paths — the type cast to Path<R> keeps the TypeScript type system satisfied.


Putting it all together

  1. Define your response models with stable Id properties where you want indexed collection actions.
  2. Create IAction<TResource> types for each user-triggered behaviour; use [ActionKey("custom-key")] when the convention does not match.
  3. Implement IActionRule<TResource, TAction>. Override EvaluateAsync for rules that do async work; override Evaluate for trivial sync rules.
  4. Create AbstractLinkBuilder<TResource> classes. Call AddActionLinkAsync<TAction> for business-rule-gated actions, or add entries directly for static links. For child links where rules need parent context, use the keyPrefix overload instead of a standalone child builder.
  5. Register: AddLinkBuildersFromAssembly, AddActionRulesFromAssembly, IHypermediaLinkCollector, IBusinessRuleEngine, and HypermediaEnrichmentBehavior in your AddMediatR block.
  6. Return results via HypermediaApiController.OkWithLinks or CreatedWithLinks so { data, links } is emitted.
  7. Run the TypeScript source generator from your dev console app alongside NSwag whenever the API changes. Use can, reason, and the generated action keys in the UI to block or enable actions.

Troubleshooting

No links in response

  • Ensure your controller returns via OkWithLinks/CreatedWithLinks.
  • Ensure HypermediaEnrichmentBehavior is registered in the MediatR pipeline.
  • Ensure IHypermediaLinkCollector and link builders are registered.

An action is always allowed or disabled unexpectedly

  • Check that your IActionRule<TResource, TAction> is registered via AddActionRulesFromAssembly.
  • Confirm EvaluateAsync/Evaluate returns RuleResult.Fail when intended.
  • Verify you are calling AddActionLinkAsync<TAction> and not adding a bare new Link(...).

UI cannot find an action key

  • Confirm the final key in the server Links dictionary matches your ...ActionKeys constant in hypermedia.ts.
  • For collection items, ensure the item has an Id property and that you are computing the path correctly (idx(id) for root collections, template literal for nested paths).
  • If using keyPrefix in a parent builder, verify the prefix matches the path the UI is constructing.

Reference: important types and methods

Link and Links

  • new Link(href, method = "GET", title?, allowed = true, disabledReason?)

HypermediaApiController

  • OkWithLinks<T>(T data)
  • CreatedWithLinks<T>(string? uri, T data)

IHypermediaLinkCollector / HypermediaLinkCollector

  • Task<Links> CollectLinksAsync(object resource, HttpContext httpContext)

AbstractLinkBuilder<T>

  • Task<Links> BuildLinksAsync(T resource, HttpContext httpContext) — implement this
  • Task AddActionLinkAsync<TAction>(Links links, string href, string method, T resource, HttpContext httpContext, string? title = null, string? keyPrefix = null)
  • Task AddActionLinkAsync<TAction, TResource>(Links links, string href, string method, TResource resource, HttpContext httpContext, string? title = null, string? keyPrefix = null) — use when the rule needs a composite context object
  • Task<Link> CreateActionLinkAsync<TAction>(...) — creates a link without adding it to a dictionary
  • GetControllerName(Type controllerType)

AbstractPagedListLinkBuilder<T>

  • Links BuildPaginationLinks(PagedList<T> pagedList, HttpContext httpContext)

Rules engine

  • IAction<TResource> — marker interface for actions
  • IBusinessRule<TResource> — base rule interface; implement EvaluateAsync (primary) or Evaluate (sync convenience)
  • IActionRule<TResource, TAction> — extends IBusinessRule<TResource>; use this for your concrete rules
  • IBusinessRuleEngine.CanPerformActionAsync<TAction, TResource>(resource) — returns (bool CanPerform, string? Reason)
  • IBusinessRuleEngine.EnsureActionAllowedAsync<TAction, TResource>(resource) — returns ErrorOr<Success>
  • ActionKeys.For<TAction>() and [ActionKey("...")]

MediatR behaviour

  • HypermediaEnrichmentBehavior<TRequest, TResponse> — inspects ErrorOr<T> success values and stores links in HttpContext.Items["_hypermedia_links"]

TypeScript generator

  • TypescriptHypermediaActionsSourceGenerator.Generate(string outputPath, params Assembly[] assembliesToScan)
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.

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.0.5 45 7/3/2026
0.0.4 47 6/30/2026
0.0.3 114 5/26/2026
0.0.2 264 11/26/2025
0.0.1 226 11/24/2025