RealmDigital.Hypermedia 0.0.2

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

Overview

RealmDigital.Hypermedia is a lightweight library that 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/disabled with reasons
  • Controller helpers to wrap responses consistently
  • Optional helpers for paged results
  • Generated TypeScript helpers to consume these links in the UI and block/enable actions

This document shows how to integrate it into your project, with concrete examples mirroring the attached Sanlam DeathClaims implementation. No UI patterns are introduced—examples mirror the included summary.tsx, select.tsx, and hypermedia.ts usage.


Key Concepts

  • Link/Links: A Link has href, method, optional title, allowed, and disabledReason. A Links is a string-indexed dictionary of Link objects.
  • Link builders: Implementations of ILinkBuilder<T> (typically derive from AbstractLinkBuilder<T>) that construct links for a resource type T.
  • Business rules: IAction<TResource> declares an action. IActionRule<TResource, TAction> enforces whether an action is allowed; the engine returns allowed/disabledReason.
  • Collection and nested resources: The collector walks your response model, building keys with path prefixes (including [id] for list items) so the UI can target the correct action.
  • Controller helpers: HypermediaApiController exposes OkWithLinks/CreatedWithLinks to send { data, links } consistently.
  • MediatR pipeline behavior: 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/package to your solution and reference it from your API. Ensure you also reference MediatR and ErrorOr (as used in the examples).


Dependency Injection Setup

Register the core hypermedia services, then scan for your link builders and business rules. Typical setup:

// using MediatR;
// using RealmDigital.Hypermedia;
// using RealmDigital.Hypermedia.LinkBuilder;
// using RealmDigital.Hypermedia.Rules;
// using System.Reflection;

public static class ServiceCollectionExtensions_Host
{
    public static IServiceCollection AddHypermedia(this IServiceCollection services, Assembly featureAssembly)
    {
        services.AddHttpContextAccessor();

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

        // Register link builders and rules from your assemblies
        services.AddLinkBuildersFromAssembly(featureAssembly);
        services.AddActionRulesFromAssembly(featureAssembly);

        // MediatR pipeline: enrich successful ErrorOr<T> results with links
        services.AddTransient(typeof(IPipelineBehavior<,>), typeof(HypermediaEnrichmentBehavior<,>));

        return services;
    }
}

In your API startup (e.g., Program/WebApplicationBuilderExtensions), call services.AddHypermedia(typeof(SomeFeatureType).Assembly); and add any additional assemblies that contain link builders or rules.

Notes:

  • The provided RealmDigital.Hypermedia.ServiceCollectionExtensions exposes AddLinkBuildersFromAssembly and AddActionRulesFromAssembly—use these. The snippet above shows how projects typically wire the remaining pieces around them.

Controller Usage

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

Example (mirrors DeceasedController):

[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:

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

Create a link builder per resource type you return to the client. Derive from AbstractLinkBuilder<T> and implement BuildLinks.

Basic example (mirrors DeceasedLinkBuilder):

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

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

Link with business-rule evaluation (mirrors ClaimLinkBuilder):

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

    public override Links BuildLinks(Claim claim, HttpContext httpContext)
    {
        var links = new Links();

        var addBeneficiaryHref = 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");

        // Creates link key from action key, sets allowed/disabledReason via rules engine
        AddActionLink<AddBeneficiaryAction>(
            links,
            addBeneficiaryHref,
            method: "POST",
            resource: claim,
            httpContext: httpContext,
            title: "Add Beneficiary");

        return links;
    }
}

Notes:

  • AddActionLink<TAction> uses ActionKeys.For<TAction>() to derive the dictionary key.
  • If an action has no rules, it's allowed by default.
  • To add non-action links (pure navigation), add entries directly: links["update"] = new Link(href, "POST", "Update");

Defining Actions and Rules

Define an action with IAction<TResource> and optionally assign a key via attribute (otherwise, it uses the class name convention: strip Action, convert to kebab-case).

// using RealmDigital.Hypermedia.Rules;

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

Attach rules using IActionRule<TResource, TAction>. Rules evaluate in the current HttpContext and return RuleResult.

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

How it flows:

  • Link builder calls AddActionLink<AddBeneficiaryAction>(...)
  • BusinessRuleEngine.CanPerformAction discovers registered IActionRule<Claim, AddBeneficiaryAction> implementations
  • If any rule fails, the link is returned with allowed: false and disabledReason populated

Enforcing at command-handling time:

  • For user-triggered commands, call IBusinessRuleEngine.EnsureActionAllowed<TResource, TAction>(resource) in your handler to return a consistent ErrorOr when forbidden, matching your RuleSeverity mapping.

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

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

The collector determines the Id by reading a property named Id on each item; if missing, "unknown" is used. Ensure your exposed models have Id where you want indexed paths.


Paged Results

If a handler returns a paged list, derive from AbstractPagedListLinkBuilder<T> to emit standard pagination links:

  • self, first, previous (when applicable), next (when applicable), last
  • It rewrites the request query string preserving parameters, changing only PageNumber (from IPagedQuery<>.PageNumber)

Example:

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

    public override Links BuildLinks(PagedList<OrderDto> list, HttpContext httpContext)
    {
        var links = BuildPaginationLinks(list, httpContext);
        // Add more page-level actions if needed
        return links;
    }
}

Register the builder type via AddLinkBuildersFromAssembly and the collector will automatically use it when the response is a PagedList<T>.


TypeScript Integration (Consume in UI)

There is a source code generator that you can use to generate TypeScript types to use in your front-end

TypescriptHypermediaActionsSourceGenerator.Generate(
            "../../../../../src/Appication.Ui/app/generated/hypermedia.ts",
            typeof(AddBeneficiaryAction).Assembly);

The provided code generator creates app/generated/hypermedia.ts containing:

  • ...ActionKeys objects per resource (e.g., ClaimActionKeys) mapping to kebab-case keys
  • can(links, target, action), linkFor(...), and reason(...) helpers
  • Target helpers: root<Resource>() and idx(id) to build the correct path prefix for nested/collection cases

Generated example (excerpt from attachments):

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

export function idx<R extends keyof ActionByResource>(id: string): Path<R> {
  return (`[${id}]`) as Path<R>;
}

export function qualify(path: string, actionKey: string): string {
  return path.length > 0 ? `${path}.${actionKey}` : actionKey;
}

export function can<R extends keyof ActionByResource>(
  links: HypermediaLinks,
  target: Path<R> | RootOf<R>,
  action: ActionByResource[R]
): boolean {
  const link = linkFor(links, target, action);
  if (link == null) return false;
  return link.allowed;
}

Usage patterns (mirror summary.tsx/select.tsx):

  • Root-level resource action (e.g., a claim form-wide action):
import { ClaimActionKeys, can, root } from '~/generated/hypermedia';

const allowed = can(links, root<'Claim'>(), ClaimActionKeys.AddBeneficiary);
// Disable/enable a button based on `allowed`. Show `reason(links, root<'Claim'>(), ClaimActionKeys.AddBeneficiary)` if disabled.

// OR for a nested links



  • Action on an item in a collection (e.g., remove beneficiary):
import { ClaimActionKeys, can, reason, idx, qualify } from '~/generated/hypermedia';

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

Notes:

  • The server will generate keys like "beneficiaries[123].remove". Use idx(id) and qualify(...) if you build a string path yourself.
  • The examples in the attached code only define add-beneficiary on Claim and remove on ClaimBeneficiary; match the exact action keys you generate server-side.

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 behavior; optionally use [ActionKey("custom-key")] when convention does not match.
  3. Implement IActionRule<TResource, TAction> to enforce when an action is allowed. Return RuleResult.Fail("reason", code?, severity?) to disable.
  4. Create AbstractLinkBuilder<TResource> classes and call AddActionLink<TAction>(...) for actions, or add static links directly.
  5. Register: AddLinkBuildersFromAssembly, AddActionRulesFromAssembly, IHypermediaLinkCollector, IBusinessRuleEngine, MediatR HypermediaEnrichmentBehavior.
  6. Return results via HypermediaApiController.OkWithLinks(...)/CreatedWithLinks(...) so { data, links } are emitted.
  7. In the UI, use the generated hypermedia.ts to call can(...)/reason(...) and block/enable buttons. For nested collections, use idx(id) and qualify(...) to address the right link key.

Reference: Important Types and Methods

  • Link and Links (server payload):
    • new Link(href, method = "GET", title?, allowed = true, disabledReason?)
  • HypermediaApiController
    • OkWithLinks<T>(T data)
    • CreatedWithLinks<T>(string? uri, T data)
  • IHypermediaLinkCollector/HypermediaLinkCollector
    • Recursively builds link keys like "prop[Id].action"
  • AbstractLinkBuilder<T>
    • BuildLinks(T resource, HttpContext httpContext)
    • AddActionLink<TAction>(Links links, string href, string method, T resource, HttpContext httpContext, string? title = null)
    • CreateActionLink<TAction>(...)
    • GetControllerName(Type controllerType)
  • AbstractPagedListLinkBuilder<T>
    • BuildPaginationLinks(PagedList<T> pagedList, HttpContext httpContext)
  • Rules engine
    • IAction<TResource>
    • IActionRule<TResource,TAction> implements Evaluate(T resource, HttpContext ctx) => RuleResult
    • IBusinessRuleEngine.CanPerformAction<TResource,TAction>(...)
    • IBusinessRuleEngine.EnsureActionAllowed<TResource,TAction>(...)
    • ActionKeys.For<TAction>() and [ActionKey("...")]
  • MediatR behavior
    • HypermediaEnrichmentBehavior<TRequest,TResponse> inspects ErrorOr<T> success values and stores links in HttpContext.Items["_hypermedia_links"]

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 (via AddLinkBuildersFromAssembly)
  • An action is always allowed/disabled unexpectedly
    • Check that your IActionRule<TResource,TAction> is registered (AddActionRulesFromAssembly)
    • Confirm the rules’ Evaluate logic returns RuleResult.Fail when intended
    • Verify you’re calling AddActionLink<TAction> for that action (and not a bare new Link(...))
  • UI cannot find an action key
    • Confirm the final key (server Links dictionary) matches your ...ActionKeys in hypermedia.ts
    • For collection items, ensure the item has an Id property and you compute the path using idx(id) and qualify(...)

Minimal End-to-End Example

Server:

// Action
public sealed class AddBeneficiaryAction : IAction<Claim> { }

// Rule
public class FuneralClaimBeneficiaryRule : IActionRule<Claim, AddBeneficiaryAction>
{
    public string RuleName => nameof(FuneralClaimBeneficiaryRule);
    public RuleResult Evaluate(Claim claim, HttpContext ctx)
        => claim.BenefitType == BenefitType.Funeral && claim.Beneficiaries.Count >= 1
            ? RuleResult.Fail("Funeral claims are limited to a single beneficiary", severity: RuleSeverity.Conflict)
            : RuleResult.Success();
}

// Link builder
public class ClaimLinkBuilder : AbstractLinkBuilder<Claim>
{
    public ClaimLinkBuilder(LinkGenerator lg, IBusinessRuleEngine bre) : base(lg, bre) { }
    public override Links BuildLinks(Claim claim, HttpContext http)
    {
        var links = new Links();
        var href = LinkGenerator.GetUriByAction(http, nameof(ClaimController.AddBeneficiaryToClaim), GetControllerName(typeof(ClaimController)), new { claimId = claim.Id })!;
        AddActionLink<AddBeneficiaryAction>(links, href, "POST", claim, http, "Add Beneficiary");
        return links;
    }
}

// Startup
services.AddHypermedia(typeof(ClaimLinkBuilder).Assembly);

Client:

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

const claimPath = `Membership.Claims[${claim.id}]` as Path<'Claim'>
const canAddBeneficiary = can(links, claimPath, ClaimActionKeys.AddBeneficiary)
const disabledReason = reason(links, claimPath, ClaimActionKeys.AddBeneficiary);
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.2 233 11/26/2025
0.0.1 205 11/24/2025