RealmDigital.Hypermedia
0.0.2
dotnet add package RealmDigital.Hypermedia --version 0.0.2
NuGet\Install-Package RealmDigital.Hypermedia -Version 0.0.2
<PackageReference Include="RealmDigital.Hypermedia" Version="0.0.2" />
<PackageVersion Include="RealmDigital.Hypermedia" Version="0.0.2" />
<PackageReference Include="RealmDigital.Hypermedia" />
paket add RealmDigital.Hypermedia --version 0.0.2
#r "nuget: RealmDigital.Hypermedia, 0.0.2"
#:package RealmDigital.Hypermedia@0.0.2
#addin nuget:?package=RealmDigital.Hypermedia&version=0.0.2
#tool nuget:?package=RealmDigital.Hypermedia&version=0.0.2
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/Linksmodel 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: ALinkhashref,method, optionaltitle,allowed, anddisabledReason. ALinksis a string-indexed dictionary ofLinkobjects.- Link builders: Implementations of
ILinkBuilder<T>(typically derive fromAbstractLinkBuilder<T>) that construct links for a resource typeT. - Business rules:
IAction<TResource>declares an action.IActionRule<TResource, TAction>enforces whether an action is allowed; the engine returnsallowed/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:
HypermediaApiControllerexposesOkWithLinks/CreatedWithLinksto send{ data, links }consistently. - MediatR pipeline behavior:
HypermediaEnrichmentBehaviorcalculates links after your handler returns a successfulErrorOr<T>, placing the result intoHttpContext.Itemsfor 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.ServiceCollectionExtensionsexposesAddLinkBuildersFromAssemblyandAddActionRulesFromAssembly—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> HypermediaEnrichmentBehaviorruns, computes links forT(and nested resources), stores them inHttpContext.Items["_hypermedia_links"]OkWithLinksreads the stored links and returnsHypermediaResponse<T>:{ data, links }
Building Links for Your Resources
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>usesActionKeys.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.CanPerformActiondiscovers registeredIActionRule<Claim, AddBeneficiaryAction>implementations- If any rule fails, the link is returned with
allowed: falseanddisabledReasonpopulated
Enforcing at command-handling time:
- For user-triggered commands, call
IBusinessRuleEngine.EnsureActionAllowed<TResource, TAction>(resource)in your handler to return a consistentErrorOrwhen forbidden, matching yourRuleSeveritymapping.
Recursion and Link Key Conventions (Nested and Collections)
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
Idproperty 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(fromIPagedQuery<>.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:
...ActionKeysobjects per resource (e.g.,ClaimActionKeys) mapping to kebab-case keyscan(links, target, action),linkFor(...), andreason(...)helpers- Target helpers:
root<Resource>()andidx(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". Useidx(id)andqualify(...)if you build a string path yourself. - The examples in the attached code only define
add-beneficiaryonClaimandremoveonClaimBeneficiary; match the exact action keys you generate server-side.
Putting It All Together
- Define your response models with stable
Idproperties where you want indexed collection actions. - Create
IAction<TResource>types for each user-triggered behavior; optionally use[ActionKey("custom-key")]when convention does not match. - Implement
IActionRule<TResource, TAction>to enforce when an action is allowed. ReturnRuleResult.Fail("reason", code?, severity?)to disable. - Create
AbstractLinkBuilder<TResource>classes and callAddActionLink<TAction>(...)for actions, or add static links directly. - Register:
AddLinkBuildersFromAssembly,AddActionRulesFromAssembly,IHypermediaLinkCollector,IBusinessRuleEngine, MediatRHypermediaEnrichmentBehavior. - Return results via
HypermediaApiController.OkWithLinks(...)/CreatedWithLinks(...)so{ data, links }are emitted. - In the UI, use the generated
hypermedia.tsto callcan(...)/reason(...)and block/enable buttons. For nested collections, useidx(id)andqualify(...)to address the right link key.
Reference: Important Types and Methods
LinkandLinks(server payload):new Link(href, method = "GET", title?, allowed = true, disabledReason?)
HypermediaApiControllerOkWithLinks<T>(T data)CreatedWithLinks<T>(string? uri, T data)
IHypermediaLinkCollector/HypermediaLinkCollector- Recursively builds link keys like
"prop[Id].action"
- Recursively builds link keys like
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>implementsEvaluate(T resource, HttpContext ctx) => RuleResultIBusinessRuleEngine.CanPerformAction<TResource,TAction>(...)IBusinessRuleEngine.EnsureActionAllowed<TResource,TAction>(...)ActionKeys.For<TAction>()and[ActionKey("...")]
- MediatR behavior
HypermediaEnrichmentBehavior<TRequest,TResponse>inspectsErrorOr<T>success values and stores links inHttpContext.Items["_hypermedia_links"]
Troubleshooting
- No links in response
- Ensure your controller returns via
OkWithLinks/CreatedWithLinks - Ensure
HypermediaEnrichmentBehavioris registered in the MediatR pipeline - Ensure
IHypermediaLinkCollectorand link builders are registered (viaAddLinkBuildersFromAssembly)
- Ensure your controller returns via
- An action is always allowed/disabled unexpectedly
- Check that your
IActionRule<TResource,TAction>is registered (AddActionRulesFromAssembly) - Confirm the rules’
Evaluatelogic returnsRuleResult.Failwhen intended - Verify you’re calling
AddActionLink<TAction>for that action (and not a barenew Link(...))
- Check that your
- UI cannot find an action key
- Confirm the final key (server
Linksdictionary) matches your...ActionKeysinhypermedia.ts - For collection items, ensure the item has an
Idproperty and you compute the path usingidx(id)andqualify(...)
- Confirm the final key (server
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 | Versions 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. |
-
net10.0
- JetBrains.Annotations (>= 2025.2.2)
- RealmDigital.Common (>= 0.0.34)
-
net8.0
- JetBrains.Annotations (>= 2025.2.2)
- RealmDigital.Common (>= 0.0.34)
-
net9.0
- JetBrains.Annotations (>= 2025.2.2)
- RealmDigital.Common (>= 0.0.34)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.