RealmDigital.Hypermedia 0.0.5

dotnet add package RealmDigital.Hypermedia --version 0.0.5
                    
NuGet\Install-Package RealmDigital.Hypermedia -Version 0.0.5
                    
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.5" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="RealmDigital.Hypermedia" Version="0.0.5" />
                    
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.5
                    
#r "nuget: RealmDigital.Hypermedia, 0.0.5"
                    
#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.5
                    
#: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.5
                    
Install as a Cake Addin
#tool nuget:?package=RealmDigital.Hypermedia&version=0.0.5
                    
Install as a Cake Tool

Overview

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

  • A Link/Links model and a Capabilities model sent with each response
  • A DI-registered enrichment pipeline that inspects your response model (recursively) and builds links and capabilities 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 links and capabilities 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.
  • Capabilities: a string-keyed dictionary of true values. Presence of a key means the capability is active; absence means it is not supported.
  • Hypermedia builders: implementations of IHypermediaBuilder<T> (derive from AbstractHypermediaBuilder<T>) that construct links and capabilities for a resource type T. All 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.
  • Capabilities: ICapability<TResource> declares a capability. Evaluation uses an inline async predicate passed to AddCapabilityAsync<TCapability> in the builder.
  • 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 or capability.
  • Controller helpers: HypermediaApiController exposes OkWithLinks and CreatedWithLinks to send { data, links, capabilities } consistently.
  • MediatR pipeline behaviour: HypermediaEnrichmentBehavior calculates links and capabilities 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 builders and business rules. The IPipelineBehavior registration belongs in your AddMediatR block alongside other pipeline behaviours.

services.AddHttpContextAccessor();
services.AddScoped<IHypermediaCollector, HypermediaCollector>();
services.AddScoped<IBusinessRuleEngine, BusinessRuleEngine>();

services.AddHypermediaBuildersFromAssembly(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 AddHypermediaBuildersFromAssembly 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 and capabilities for T (and nested resources), stores them in HttpContext.Items.
  3. OkWithLinks reads the stored enrichment and returns HypermediaResponse<T>: { data, links, capabilities }.

Create a builder per resource type. Derive from AbstractHypermediaBuilder<T> and implement BuildLinksAsync. The method receives a Links dictionary to populate in place.

Basic example:

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

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

Link with business-rule evaluation:

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

    protected override async Task BuildLinksAsync(Links links, Claim claim, HttpContext httpContext)
    {
        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");
    }
}

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");

Building capabilities for your resources

Declare each capability as a marker class implementing ICapability<TResource>. The key is derived by convention: strip the Capability suffix from the class name and convert PascalCase to kebab-case. Override [ActionKey("custom-key")] when the convention does not produce the right string.

public sealed class PartialSubmissionCapability : ICapability<Claim> { }

Override BuildCapabilitiesAsync in your builder to evaluate and register capabilities. The default implementation is a no-op, so builders with no capabilities require no changes.

public class ClaimLinkBuilder : AbstractHypermediaBuilder<Claim>
{
    private readonly IProductSettingsRepository _settings;

    public ClaimLinkBuilder(
        LinkGenerator linkGenerator,
        IBusinessRuleEngine businessRuleEngine,
        IProductSettingsRepository settings)
        : base(linkGenerator, businessRuleEngine)
    {
        _settings = settings;
    }

    protected override async Task BuildLinksAsync(Links links, Claim claim, HttpContext httpContext)
    {
        // ... link building as normal
    }

    protected override async Task BuildCapabilitiesAsync(Capabilities capabilities, Claim claim, HttpContext httpContext)
    {
        await AddCapabilityAsync<PartialSubmissionCapability>(
            capabilities,
            claim,
            async c => await _settings.IsPartialSubmissionEnabledAsync(c.ProductCode));
    }
}

Notes:

  • AddCapabilityAsync<TCapability> only writes an entry when the predicate returns true. Absence from the response unambiguously means the capability is not supported.
  • The capabilities field is always present in the JSON response as an empty object when no capabilities are active, so clients can access response.capabilities without null-checking.
  • Capability keys for nested resources are collected with path prefixes automatically by the collector, mirroring the existing link collection behaviour.

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 IHypermediaBuilder<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 : AbstractHypermediaBuilder<ClaimBeneficiary>
{
    protected override async Task BuildLinksAsync(Links links, ClaimBeneficiary beneficiary, HttpContext httpContext)
    {
        var href = /* ... */;
        await AddActionLinkAsync<RemoveBeneficiaryAction>(links, href, "DELETE", beneficiary, httpContext);
    }
}

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 : AbstractHypermediaBuilder<Claim>
{
    protected override async Task BuildLinksAsync(Links links, Claim claim, HttpContext httpContext)
    {
        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}]");
        }
    }
}

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) { }

    protected override Task BuildLinksAsync(Links links, PagedList<OrderDto> list, HttpContext httpContext)
    {
        foreach (var (key, link) in BuildPaginationLinks(list, httpContext))
            links[key] = link;
        // add page-level actions here if needed
        return Task.CompletedTask;
    }
}

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


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

  • Root-level keys use their plain names: "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, capability keys, and helper functions.

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

The generated file imports from ~/generated/api:

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

HypermediaLink, HypermediaLinks, and HypermediaCapabilities are generated by NSwag from the OpenAPI spec, using your controller's ProducesResponseType(typeof(HypermediaResponse<T>)) declarations.

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

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

export const ClaimCapabilityKeys = {
  PartialSubmission: 'partial-submission',
} as const;

export type ClaimCapability = typeof ClaimCapabilityKeys[keyof typeof ClaimCapabilityKeys];
export const ClaimCapabilities: ClaimCapability[] = Object.values(ClaimCapabilityKeys);

It also emits ActionByResource and CapabilityByResource maps 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 a 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
hasCapability(capabilities, target, capability) Returns true if the capability is present

Link 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);

Capability usage patterns

Root-level resource:

import { ClaimCapabilityKeys, hasCapability, root } from '~/generated/hypermedia';

const supportsPartialSubmission = hasCapability(capabilities, root<'Claim'>(), ClaimCapabilityKeys.PartialSubmission);

Item in a nested collection:

const claimPath = `Membership.Claims[${claim.id}]` as Path<'Claim'>;
const supportsPartialSubmission = hasCapability(capabilities, claimPath, ClaimCapabilityKeys.PartialSubmission);

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

export function claimSupportsPartialSubmission(
    claimId: string,
    capabilities: HypermediaCapabilities,
    claimPath: Path<'Claim'> = `Membership.Claims[${claimId}]` as Path<'Claim'>,
): boolean {
    return hasCapability(capabilities, claimPath, ClaimCapabilityKeys.PartialSubmission);
}

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 ICapability<TResource> types for resource-level behavioural flags; use [ActionKey("custom-key")] to override the derived key when needed.
  5. Create AbstractHypermediaBuilder<TResource> classes. Implement BuildLinksAsync and optionally override BuildCapabilitiesAsync. Call AddActionLinkAsync<TAction> for business-rule-gated links, AddCapabilityAsync<TCapability> for capability flags, or add link entries directly for static links.
  6. Register: AddHypermediaBuildersFromAssembly, AddActionRulesFromAssembly, IHypermediaCollector, IBusinessRuleEngine, and HypermediaEnrichmentBehavior in your AddMediatR block.
  7. Return results via HypermediaApiController.OkWithLinks or CreatedWithLinks so { data, links, capabilities } is emitted.
  8. Run the TypeScript source generator from your dev console app alongside NSwag whenever the API changes. Use can, reason, hasCapability, and the generated keys in the UI to block or enable actions and to branch on capabilities.

Troubleshooting

No links in response

  • Ensure your controller returns via OkWithLinks/CreatedWithLinks.
  • Ensure HypermediaEnrichmentBehavior is registered in the MediatR pipeline.
  • Ensure IHypermediaCollector and 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?)

Capabilities

  • Dictionary<string, object> — keys are capability identifiers; values are true. Presence = supported.

HypermediaApiController

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

IHypermediaCollector / HypermediaCollector

  • Task<HypermediaEnrichment> CollectAsync(object resource, HttpContext httpContext)

HypermediaEnrichment

  • record HypermediaEnrichment(Links Links, Capabilities Capabilities)

AbstractHypermediaBuilder<T>

  • protected abstract Task BuildLinksAsync(Links links, T resource, HttpContext httpContext) — implement this
  • protected virtual Task BuildCapabilitiesAsync(Capabilities capabilities, T resource, HttpContext httpContext) — override to add capabilities; defaults to no-op
  • 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
  • Task AddCapabilityAsync<TCapability>(Capabilities capabilities, T resource, Func<T, Task<bool>> predicate) — adds entry only when predicate returns true
  • GetControllerName(Type controllerType)

AbstractPagedListLinkBuilder<T>

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

Rules engine

  • IAction<TResource> — marker interface for actions
  • ICapability<TResource> — marker interface for capabilities
  • 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 CapabilityKeys.For<TCapability>() with [ActionKey("...")] for key override

MediatR behaviour

  • HypermediaEnrichmentBehavior<TRequest, TResponse> — inspects ErrorOr<T> success values and stores enrichment in HttpContext.Items

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