Resrcify.SharedKernel.Web
4.0.0
See the version list below for details.
dotnet add package Resrcify.SharedKernel.Web --version 4.0.0
NuGet\Install-Package Resrcify.SharedKernel.Web -Version 4.0.0
<PackageReference Include="Resrcify.SharedKernel.Web" Version="4.0.0" />
<PackageVersion Include="Resrcify.SharedKernel.Web" Version="4.0.0" />
<PackageReference Include="Resrcify.SharedKernel.Web" />
paket add Resrcify.SharedKernel.Web --version 4.0.0
#r "nuget: Resrcify.SharedKernel.Web, 4.0.0"
#:package Resrcify.SharedKernel.Web@4.0.0
#addin nuget:?package=Resrcify.SharedKernel.Web&version=4.0.0
#tool nuget:?package=Resrcify.SharedKernel.Web&version=4.0.0
Resrcify.SharedKernel.Web
Resrcify.SharedKernel.Web provides helpers for converting Result/Result<T> outcomes into HTTP responses and standardized problem details.
Table of Contents
- Resrcify.SharedKernel.Web
- Table of Contents
- What you get
- Prerequisites
- Install
- Quick Start
- Usage guide
- Convert result to problem details
- Use Match in controllers
- Functional endpoint flow
- Endpoints for a mediator request
- Idempotency keys
- Read an HTTP response as a result
- Unhandled exceptions: the same problem details
- Health endpoints
- Calling another service: resilience keyed on ErrorType
- Accepting Resrcify.Identity's tokens
- Common issues
- Sample project
What you get
ApiControllerbase type inPrimitives/.- Result-to-HTTP conversion extensions in
Extensions/. MapGetRequest/MapPostRequest/MapPutRequest/MapPatchRequest/MapDeleteRequest: a Minimal-API endpoint that sends the mediator request its delegate returns and answers with its result, with its OpenAPI responses.IdempotencyHeaders: theIdempotency-Keyheader for the mediator's idempotent requests, and theIdempotency-Replayedmark the request endpoints put on a replayed answer.- Consistent mapping from
ErrorTypeto HTTP problem responses. AddResultProblemDetails(): unhandled exceptions answered with the same problem details as a failed result.MapHealthEndpoints():/health,/health/readyand/health/live, anonymous, with a JSON response writer.MapErrorQueueAdministration(): the message bus' error queue under/admin/error-queue(show, delete, move back).AddResultResilience(): retries, timeouts and a circuit breaker for anHttpClient, retrying what the result pattern calls transient.AddResrcifyJwtBearer(configuration): JWT bearer authentication against Resrcify.Identity.
Prerequisites
- .NET 10 SDK.
- ASP.NET Core dependencies in the consuming application.
Resrcify.SharedKernel.ResultsandResrcify.SharedKernel.Mediatorreferenced by the consuming application.
Install
Option A: Project reference
<ProjectReference Include="..\path\to\Resrcify.SharedKernel.Web.csproj" />
Option B: NuGet package
<PackageReference Include="Resrcify.SharedKernel.Web" Version="<latest>" />
CLI:
dotnet add package Resrcify.SharedKernel.Web
Quick Start
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http.Json;
services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
Usage guide
Convert result to problem details
using Resrcify.SharedKernel.Results.Primitives;
using Resrcify.SharedKernel.Web.Extensions;
Result result = await SomeOperationAsync();
if (result.IsFailure)
{
return result.ToProblemDetails();
}
return Results.Ok();
Use Match in controllers
using Resrcify.SharedKernel.Abstractions.Mediator;
using Resrcify.SharedKernel.Results.Primitives;
using Resrcify.SharedKernel.Web.Primitives;
[Route("api/company")]
public sealed class CompanyController(
ISender sender)
: ApiController(sender)
{
[HttpGet("{id:guid}")]
public async Task<IResult> GetById(
Guid id,
CancellationToken cancellationToken)
{
Result<CompanyDto> result = await Sender.Send(new GetCompanyByIdQuery(id), cancellationToken);
return result.Match(
onSuccess: Results.Ok,
onFailure: ToProblemDetails);
}
}
Functional endpoint flow
return await Result
.Create(new GetCompanyByIdQuery(id))
.Bind(request => Sender.Send(request, cancellationToken))
.Match(
onSuccess: Results.Ok,
onFailure: ToProblemDetails);
Endpoints for a mediator request
The flow above is the body of most endpoints. MapGetRequest, MapPostRequest, MapPutRequest, MapPatchRequest,
MapDeleteRequest and MapRequest(pattern, methods, ...) write it for you. The delegate is an ordinary Minimal-API
handler that returns the request: its parameters bind as usual (route values, query string, headers, body,
[AsParameters], services, HttpContext, a CancellationToken), and it may be async. The endpoint sends the request
through ISender and answers:
| The request is answered with | Success | Failure |
|---|---|---|
Result<T> (IQuery<T>, ICachingQuery<T>, ICommand<T>, ITransactionCommand<T>, …) |
200 with the value | problem details |
Result (ICommand, ITransactionCommand, …) |
204 | problem details |
Any request answered with a Result works, a type of your own implementing IRequest<Result> or
IRequest<Result<T>> too, and it goes through the mediator's whole pipeline (validation, caching, transactions). A
delegate returning anything else fails when the endpoint is mapped, not when it is called.
app.MapPostRequest(ApiEndpoints.Shards.AddShardMember,
(Guid shardId, long allyCode, AddShardMemberBody body) => new AddShardMemberCommand(shardId, allyCode, body.Emoji))
.WithName("Shards.AddShardMember")
.WithTags(ApiEndpoints.Shards.Tag)
.WithApiVersionSet(ApiVersioning.VersionSet)
.HasApiVersion(new ApiVersion(1, 0));
app.MapGetRequest(ApiEndpoints.Shards.GetShard, (Guid shardId) => new GetShardByIdQuery(shardId));
app.MapPostRequest(ApiEndpoints.Shards.Search, (SearchBody body) => new SearchShardsQuery(body.Name)); // a query by POST
app.MapGetRequest(ApiEndpoints.Shards.GetAll, () => new GetAllActiveShardsQuery());
Other answers. onSuccess replaces 200/204: a delegate taking the value (or nothing) and returning an IResult. A
TypedResults type describes itself for OpenAPI:
app.MapPostRequest(ApiEndpoints.Shards.Create, (CreateShardBody body) => new CreateShardCommand(body.Name),
onSuccess: (ShardDto shard) => TypedResults.Created($"/shards/{shard.Id}", shard)); // 201
app.MapPostRequest(ApiEndpoints.Jobs.Run, (string job) => new RunJobCommand(job),
onSuccess: () => TypedResults.Accepted("/jobs")); // 202
app.MapGetRequest(ApiEndpoints.Shards.GetShard, (Guid shardId) => new GetShardByIdQuery(shardId),
onSuccess: (GetShardByIdResponse response) => TypedResults.Ok(response.Shard)); // part of the result
onFailure replaces problem details for one endpoint; for the whole app, configure RequestEndpointOptions:
services.Configure<RequestEndpointOptions>(options => options.OnFailure = ...). Its ProblemStatusCodes (400, 404,
409 and 500) are the problems every endpoint declares for OpenAPI; add an endpoint's own with ProducesProblem.
The endpoints return the RouteHandlerBuilder, so naming, tags, authorization and the API version chain as usual, and
they work in route groups. A filter added to an endpoint runs inside it and may answer instead (a validation filter's
problem, say). The request itself is never described as a response.
Idempotency keys
Idempotency is the mediator's (IIdempotentRequest, see the Mediator package): it works however a request arrives.
Over HTTP, the endpoint passes the client's Idempotency-Key header to the request, and a repeat is answered with the
first result: the same 200/201/204, marked Idempotency-Replayed: true by the request endpoints.
public sealed record CreateShardCommand(string Name) : ICommand<ShardDto>, IIdempotentRequest
{
public string? IdempotencyKey { get; init; }
public string? IdempotencyScope { get; init; } // whose keys: the user's ID
}
app.MapPostRequest(ApiEndpoints.Shards.Create,
(CreateShardBody body, [FromHeader(Name = IdempotencyHeaders.Key)] string? key, ClaimsPrincipal user) =>
new CreateShardCommand(body.Name) { IdempotencyKey = key, IdempotencyScope = user.GetUserId().Value.ToString() },
onSuccess: (ShardDto shard) => TypedResults.Created($"/shards/{shard.Id}", shard));
A repeat while the first is still handled is answered 409, a key used for a different request 422, and a blank or too
long key 400. An endpoint of your own marks a replay with IdempotencyHeaders.MarkIfReplayed(httpContext, request)
after sending. A key the endpoint must have is a validation rule on the request (RuleFor(c => c.IdempotencyKey).NotEmpty()).
Read an HTTP response as a result
HttpResponseMessageExtensions.ToResultAsync reads a response from another service:
using var response = await httpClient.GetAsync($"players/{id}", cancellationToken);
Result<PlayerDto> player = await response.ToResultAsync<PlayerDto>(cancellationToken: cancellationToken);
- A success body is deserialized; a 204 or a
nullbody givesHttp.EmptyContent, a body that isn't JSON givesHttp.UnreadableContent. - A failure gives the errors in its problem details, or one
Http.<status>error of the status's type when the body isn't problem details (an HTML page from a proxy, an empty body).
The two directions live in two classes: HttpResultExtensions (ToProblemDetails, Match: a result to an HTTP
response) and HttpResponseMessageExtensions (ToResultAsync: an HTTP response to a result). Neither clashes with
Resrcify.SharedKernel.Results.Primitives.ResultExtensions, so endpoint files need no alias; a method group is
HttpResultExtensions.ToProblemDetails.
Unhandled exceptions: the same problem details
builder.Services.AddResultProblemDetails();
// ...
app.UseExceptionHandler();
An exception no handler caught is answered like a failed result (ToProblemDetails): a 500 problem details whose
errors hold one Failure error coded Unhandled (ResultExceptionHandler.ErrorCode), so a caller's
ToResultAsync reads it back as that error. Outside Development the message is only "An unexpected error occurred.";
in Development the error's message is the exception's type and message and the problem's detail the whole exception
(options.IncludeExceptionDetails decides either way). The exception is logged once, at Error (the exception
handler middleware logs nothing more for an exception a handler handled), following Results' LoggedExceptions
convention: when the mediator's behaviors logged it already on its way out, the handler logs a Debug line without the
stack instead, and answers the same 500. A request the client aborted (an
OperationCanceledException once RequestAborted fired) is a 499, logged at Debug, not as an error.
It replaces builder.Services.AddProblemDetails() (it registers it); keep app.UseExceptionHandler().
Health endpoints
builder.Services.AddHealthChecks()
.AddNpgSql(connectionString, name: "postgres", tags: [HealthTags.Ready])
.AddCheck<QuartzJobLivenessHealthCheck>("quartz-jobs", tags: [HealthTags.Live]);
// ...
app.MapHealthEndpoints();
| Path | Runs | |
|---|---|---|
/health |
every check | |
/health/ready |
the checks tagged HealthTags.Ready ("ready") |
the readiness probe |
/health/live |
the checks tagged HealthTags.Live ("live") |
the liveness probe: Healthy without any |
All three are anonymous and answer 200 when Healthy or Degraded, 503 when Unhealthy, with HealthResponseWriter's
JSON: status, duration, and per check (checks) its status, description, duration, data, error (the
exception's message) and tags. The paths can be changed (app.MapHealthEndpoints(paths => paths.LivePath = ...)),
and the returned builder adds conventions to all three. It needs no AspNetCore.HealthChecks.UI.Client.
Before (each service):
app.MapHealthChecks("health", new HealthCheckOptions { ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse }).AllowAnonymous();
app.MapHealthChecks("health/ready", new HealthCheckOptions { Predicate = check => check.Tags.Contains("ready"), ResponseWriter = ... }).AllowAnonymous();
app.MapHealthChecks("health/live", new HealthCheckOptions { Predicate = _ => false, ResponseWriter = ... }).AllowAnonymous();
After: app.MapHealthEndpoints();. A service whose liveness ran no check (Predicate = _ => false) gets the same
answer as long as no check is tagged live. The JSON's shape is this package's (checks, duration, error), not
HealthChecks.UI's (entries, totalDuration, exception): a probe reads only the status code.
The message bus' error queue
app.MapErrorQueueAdministration() // under /admin/error-queue
.RequireAuthorization(policy => policy.RequireRole("Admin"));
| Endpoint | |
|---|---|
GET /?take=100 |
the first messages, oldest first (1 to 1000), with why each failed, and how many there are |
DELETE /messages/{id} |
deletes a message: 204, or 404 |
DELETE /?type= |
deletes every message, or every one of the type: { "deleted": n } |
POST /messages/{id}/retry |
moves a message back to the queue it failed on: 204, 404, or 409 when it can't be moved |
POST /retry?type= |
moves every message, or every one of the type, back: { "retried": n } |
They answer through IErrorQueue (registered by the MessageBus package's AddMessageBus; see its README), and require
an authenticated caller; add the admin policy on the group returned. Messages hold event content: never open these to
everyone.
Calling another service: resilience keyed on ErrorType
services
.AddHttpClient<ISwgohApiClient, SwgohApiClient>(client => client.BaseAddress = new Uri(url))
.AddResultResilience(resilience => resilience.AlsoRetry.Add(StatusCodes.Status404NotFound));
Built on Microsoft.Extensions.Http.Resilience (Polly 8), which the service references itself. Outermost to innermost: the total timeout (30 s), the retries, the circuit breaker, the attempt timeout (10 s).
- What is retried (3 times by default,
MaxRetries; 1 s doubling to at most 30 s, with jitter): a response whose statusToResultAsyncreads as a transientErrorType(Failure,ExternalFailure,Timeout,RateLimit: a 5xx, a 429, and any other status it doesn't map, such as 408), a network error (HttpRequestException), an attempt that timed out. Not a 400, 401, 403, 404, 409 or 422: those are answers. Never the caller's cancellation. AlsoRetryadds statuses (e.g. 404 from an upstream that is still catching up);NeverRetryremoves them, and wins overAlsoRetry.Retry-After(a delay or a date) on a retried response is waited instead of the computed delay; one at least as long as what is left ofTotalTimeoutisn't waited for, and the caller gets that response (e.g. a 429) at once.- The circuit breaker opens when 10 % of at least 100 calls in 30 s fail transiently (
AlsoRetrystatuses don't count), for 5 s;CircuitBreaker = falseturns it off. - Time is the
TimeProviderin the container: every delay, timeout andRetry-Afterdate runs on aFakeTimeProviderin tests.
After the retries the caller gets the last response (ToResultAsync turns it into a failure); a timeout or an open
circuit throws TimeoutRejectedException / BrokenCircuitException, as with AddStandardResilienceHandler.
Before (Shard and Tournament):
.AddPolicyHandler(HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(msg => msg.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(6, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))));
After: .AddResultResilience(resilience => resilience.AlsoRetry.Add(404)), and drop Microsoft.Extensions.Http.Polly.
Six retries up to 64 s apart don't fit in the default 30 s total: raise TotalTimeout and MaxRetries to keep that
patience, or take the defaults.
Accepting Resrcify.Identity's tokens
{ "Jwt": { "Authority": "http://identity:22000", "Issuer": "Resrcify.Identity", "Audience": "Resrcify" } }
services.AddResrcifyJwtBearer(configuration);
services.AddAuthorizationBuilder(); // the service's own policies
The Jwt section (ResrcifyJwtOptions) is bound and validated at start-up (Authority an absolute http(s) URI,
Issuer and Audience set). JWT bearer becomes the default scheme; its signing keys come from the authority's
discovery document (JWKS, refreshed when Identity rotates its keys), and the issuer, audience, lifetime and signature
are checked. RequireHttpsMetadata is off unless set: the services reach Identity over plain HTTP in the cluster.
The optional Action<JwtBearerOptions> runs after, for events or claim mapping. The service references
Microsoft.AspNetCore.Authentication.JwtBearer itself. It replaces Sandbox's JwtOptions and ConfigureJwtOptions
(same section, same keys).
Common issues
- If response serialization differs between minimal APIs and controllers, ensure both
JsonOptionsand MVC JSON options are configured. - If status codes look wrong, verify error classification (
ErrorType) at the source.
Sample project
See samples/Resrcify.SharedKernel.WebApiExample for practical usage in controllers and endpoint flows.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | 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
- Resrcify.SharedKernel.Abstractions (>= 4.0.0)
- Resrcify.SharedKernel.Results (>= 4.0.0)
NuGet packages (3)
Showing the top 3 NuGet packages that depend on Resrcify.SharedKernel.Web:
| Package | Downloads |
|---|---|
|
Resrcify.DataProvider.Infrastructure
Package Description |
|
|
Resrcify.DataProvider.Presentation
Package Description |
|
|
Resrcify.SharedKernel.PerformanceTests
Package Description |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 4.5.0 | 0 | 10/18/2026 |
| 4.4.1 | 81 | 10/17/2026 |
| 4.4.0 | 73 | 10/16/2026 |
| 4.3.0 | 66 | 10/16/2026 |
| 4.2.0 | 92 | 10/14/2026 |
| 4.1.0 | 64 | 10/14/2026 |
| 4.0.0 | 180 | 10/13/2026 |
| 3.0.0 | 183 | 10/3/2026 |
| 2.2.0 | 155 | 9/13/2026 |
| 2.1.1 | 1,491 | 6/8/2026 |
| 2.1.0 | 133 | 5/24/2026 |
| 2.0.0 | 130 | 5/22/2026 |
| 1.16.3 | 257 | 5/7/2026 |
| 1.16.2 | 131 | 5/7/2026 |
| 1.16.1 | 125 | 5/7/2026 |
| 1.16.0 | 141 | 5/7/2026 |
| 1.15.0 | 530 | 4/18/2026 |
| 1.14.0 | 373 | 10/15/2025 |
| 1.13.0 | 530 | 6/27/2025 |
| 1.12.0 | 387 | 4/16/2025 |