RealmDigital.Hypermedia
0.0.4
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
<PackageReference Include="RealmDigital.Hypermedia" Version="0.0.4" />
<PackageVersion Include="RealmDigital.Hypermedia" Version="0.0.4" />
<PackageReference Include="RealmDigital.Hypermedia" />
paket add RealmDigital.Hypermedia --version 0.0.4
#r "nuget: RealmDigital.Hypermedia, 0.0.4"
#:package RealmDigital.Hypermedia@0.0.4
#addin nuget:?package=RealmDigital.Hypermedia&version=0.0.4
#tool nuget:?package=RealmDigital.Hypermedia&version=0.0.4
Overview
RealmDigital.Hypermedia 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 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: aLinkhashref,method, optionaltitle,allowed, anddisabledReason. ALinksis a string-keyed dictionary ofLinkobjects.- Link builders: implementations of
ILinkBuilder<T>(derive fromAbstractLinkBuilder<T>) that construct links for a resource typeT. All link building is async. - Business rules:
IAction<TResource>declares an action.IBusinessRule<TResource>(viaIActionRule<TResource, TAction>) evaluates whether an action is allowed; implementEvaluateAsyncas 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:
HypermediaApiControllerexposesOkWithLinksandCreatedWithLinksto send{ data, links }consistently. - MediatR pipeline behaviour:
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 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:
- The 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. 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 fromActionKeys.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;
Child resource links: two patterns
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.
Recursion and link key conventions
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
- Define your response models with stable
Idproperties where you want indexed collection actions. - Create
IAction<TResource>types for each user-triggered behaviour; use[ActionKey("custom-key")]when the convention does not match. - Implement
IActionRule<TResource, TAction>. OverrideEvaluateAsyncfor rules that do async work; overrideEvaluatefor trivial sync rules. - Create
AbstractLinkBuilder<TResource>classes. CallAddActionLinkAsync<TAction>for business-rule-gated actions, or add entries directly for static links. For child links where rules need parent context, use thekeyPrefixoverload instead of a standalone child builder. - Register:
AddLinkBuildersFromAssembly,AddActionRulesFromAssembly,IHypermediaLinkCollector,IBusinessRuleEngine, andHypermediaEnrichmentBehaviorin yourAddMediatRblock. - Return results via
HypermediaApiController.OkWithLinksorCreatedWithLinksso{ data, links }is emitted. - 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
HypermediaEnrichmentBehavioris registered in the MediatR pipeline. - Ensure
IHypermediaLinkCollectorand link builders are registered.
An action is always allowed or disabled unexpectedly
- Check that your
IActionRule<TResource, TAction>is registered viaAddActionRulesFromAssembly. - Confirm
EvaluateAsync/EvaluatereturnsRuleResult.Failwhen intended. - Verify you are calling
AddActionLinkAsync<TAction>and not adding a barenew Link(...).
UI cannot find an action key
- Confirm the final key in the server
Linksdictionary matches your...ActionKeysconstant inhypermedia.ts. - For collection items, ensure the item has an
Idproperty and that you are computing the path correctly (idx(id)for root collections, template literal for nested paths). - If using
keyPrefixin 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 thisTask 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 objectTask<Link> CreateActionLinkAsync<TAction>(...)— creates a link without adding it to a dictionaryGetControllerName(Type controllerType)
AbstractPagedListLinkBuilder<T>
Links BuildPaginationLinks(PagedList<T> pagedList, HttpContext httpContext)
Rules engine
IAction<TResource>— marker interface for actionsIBusinessRule<TResource>— base rule interface; implementEvaluateAsync(primary) orEvaluate(sync convenience)IActionRule<TResource, TAction>— extendsIBusinessRule<TResource>; use this for your concrete rulesIBusinessRuleEngine.CanPerformActionAsync<TAction, TResource>(resource)— returns(bool CanPerform, string? Reason)IBusinessRuleEngine.EnsureActionAllowedAsync<TAction, TResource>(resource)— returnsErrorOr<Success>ActionKeys.For<TAction>()and[ActionKey("...")]
MediatR behaviour
HypermediaEnrichmentBehavior<TRequest, TResponse>— inspectsErrorOr<T>success values and stores links inHttpContext.Items["_hypermedia_links"]
TypeScript generator
TypescriptHypermediaActionsSourceGenerator.Generate(string outputPath, params Assembly[] assembliesToScan)
| 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.4)
- RealmDigital.Common (>= 0.0.34)
-
net8.0
- JetBrains.Annotations (>= 2025.2.4)
- RealmDigital.Common (>= 0.0.34)
-
net9.0
- JetBrains.Annotations (>= 2025.2.4)
- 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.