Resrcify.SharedKernel.Web 4.4.0

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

Resrcify.SharedKernel.Web

Resrcify.SharedKernel.Web provides helpers for converting Result/Result<T> outcomes into HTTP responses and standardized problem details.

Table of Contents

What you get

  • ApiController base type in Primitives/.
  • 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: the Idempotency-Key header for the mediator's idempotent requests, and the Idempotency-Replayed mark the request endpoints put on a replayed answer.
  • Consistent mapping from ErrorType to HTTP problem responses.
  • AddResultProblemDetails(): unhandled exceptions answered with the same problem details as a failed result, and a minimal-API parameter that fails to bind with a 400 naming it, in every environment.
  • MapHealthEndpoints(): /health, /health/ready and /health/live, anonymous, with a JSON response writer.
  • MapErrorQueueAdministration(): the message bus' error queue under /admin/error-queue (show, delete, move back).
  • MapOutboxAdministration<TContext>(): a DbContext's outbox under /admin/outbox (what waits and what gave up, try again, delete).
  • AddResultResilience(): retries, timeouts and a circuit breaker for an HttpClient, 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.Results and Resrcify.SharedKernel.Mediator referenced 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 null body gives Http.EmptyContent, a body that isn't JSON gives Http.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 (ErrorCodes.Request.Unhandled), 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 or IOException once RequestAborted fired) never reaches the handler: UseExceptionHandler() answers it with a 499 itself, logged at Debug, not as an error. A TransientFailureException (Results; itself or among the causes) holding errors is answered as the failed result it stands for, with problem details of its Errors (a 502 for an upstream's failure, a 504 for a timeout ...), logged at Warning, once.

A request ASP.NET Core couldn't bind (its BadHttpRequestException: a required query, route, header or body value left out, a value that doesn't parse, such as /shards/abc for a Guid id, a body that isn't valid JSON) isn't a fault of the service: it is answered with the exception's status (400, or 413, 415 ...) and problem details whose errors hold one Validation error coded with the parameter's name (ErrorCodes.Request.Binding, Request, when the exception names none), logged at Debug:

{ "status": 400, "title": "Bad Request", "errors": [ { "code": "page",
  "message": "The required parameter \"page\" was not provided from query string." } ] }

The message names the parameter only, never its type or the value sent; in Development (IncludeExceptionDetails) the problem's detail holds ASP.NET Core's own message. Minimal APIs throw the exception only when RouteHandlerOptions.ThrowOnBadRequest is on, which ASP.NET Core does in Development only (elsewhere an empty 400), so AddResultProblemDetails() turns it on in every environment. To keep ASP.NET Core's default, set options.ProblemDetailsForBindingFailures = false (a BadHttpRequestException that reaches the handler is still answered as above). A service's own handler for it (Oracle's BadRequestExceptionHandler) and its ThrowOnBadRequest = true can go.

It replaces builder.Services.AddProblemDetails() (it registers it); keep app.UseExceptionHandler().

Health endpoints

builder.Services.AddHealthChecks()
    .AddPostgres<ShardDbContext>()          // UnitOfWork.Postgres: ready by default
    .AddMessageBus()                        // MessageBus: ready by default
    .AddOutbox<ShardDbContext>()            // UnitOfWork: ready by default (Degraded on a backlog)
    .AddQuartzJobs()                        // UnitOfWork: live by default
    .AddCheck<UpstreamHealthCheck>("upstream", tags: [HealthTags.Ready]);
// ...
app.MapHealthEndpoints();

HealthTags (Ready, Live) lives in Resrcify.SharedKernel.Abstractions (Resrcify.SharedKernel.Abstractions.Health), so every package's checks share it: given no tags, SharedKernel's checks take HealthTags.Ready (AddPostgres, AddMessageBus, AddOutbox) or HealthTags.Live (AddQuartzJobs); pass tags: [] for none.

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.

The outbox

So an operator never needs hand-written SQL to see what is stuck in a DbContext's outbox or to try it again:

app.MapOutboxAdministration<AppDbContext>()             // under /admin/outbox
    .RequireAuthorization(policy => policy.RequireRole("Admin"));
Endpoint
GET / messages waiting, retrying and given up, in all and per event type (with its lane)
GET /given-up?type=&take= the messages that gave up, most recent first (50 by default, 1 to 500)
GET /messages/{id} a message with its content, where it stands and its last error; 404
POST /messages/{id}/retry tries a message that gave up again: 204, or 404 when it didn't give up
POST /given-up/retry?type= tries every message that gave up, or every one of the type, again: { "retried": n }
DELETE /messages/{id} deletes a message that gave up, for good: 204, or 404 when it didn't give up
DELETE /given-up?type= deletes every message that gave up, or every one of the type: { "deleted": n }

They answer through IOutboxAdministration<AppDbContext> (Abstractions), which the UnitOfWork package registers with the outbox (AddOutboxProcessing, AddProcessOutboxMessagesJob, AddOutboxLanes): this package doesn't reference UnitOfWork. A retried message starts from its first try, keeps its last error until the next one, and the outbox is woken so it is handled at once; a deleted one's event is never handled. Only messages that gave up are tried again or deleted. The endpoints are named Outbox.<context>.<operation> (Outbox.AppDbContext.Summary, ...), require an authenticated caller (add the admin policy on the group returned), and 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 comes with the package. Outermost to innermost: the answer for a timeout or an open circuit, the total timeout (30 s), the retries, the circuit breaker, the attempt timeout (10 s).

  • What is retried (Retries, a RetrySchedule: 4 attempts by default, the first included, 1 s doubling to at most 30 s, with jitter; MaxAttempts = 1 turns retrying off): a response whose status ToResultAsync reads as a transient ErrorType (Failure, ExternalFailure, Timeout, RateLimit: a 5xx, a 408 or a 429), 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.

  • AlsoRetry adds statuses (e.g. 404 from an upstream that is still catching up); NeverRetry removes them, and wins over AlsoRetry.

  • RetryOnly replaces the rule with a list: only those statuses are retried (network errors and timed-out attempts still are), any other is returned at once and doesn't count against the circuit. It can't be combined with AlsoRetry or NeverRetry and holds 400–599 only: the host doesn't start otherwise (see Options, validated when the host starts). ResultResilienceOptions.SafeToRetryStatuses (408, 429, 503) is the list for a service that retries its own failures, so that its other 5xx are final:

    // Was: foreach (var status in Enumerable.Range(500, 100).Where(s => s != 503)) resilience.NeverRetry.Add(status);
    client.AddResultResilience(resilience => resilience.RetryOnly = ResultResilienceOptions.SafeToRetryStatuses);
    // More: resilience.RetryOnly = new HashSet<int>(ResultResilienceOptions.SafeToRetryStatuses) { 404 };
    
  • 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 of TotalTimeout isn'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 (AlsoRetry statuses don't count), for 5 s; CircuitBreaker = false turns it off.

  • Time is the TimeProvider in the container: every delay, timeout and Retry-After date runs on a FakeTimeProvider in tests.

  • HttpClient's own timeout (HttpClient.Timeout, 100 s by default) never ends a call before the pipeline does: when it is shorter than TotalTimeout plus ResultResilienceHttpClientBuilderExtensions.HttpClientTimeoutMargin (5 s), it is raised to that, after every other configuration of the client (a ConfigureHttpClient before or after AddResultResilience alike). A longer one the service set, or Timeout.InfiniteTimeSpan, is kept. So a hung call ends as ResultResilienceErrors.Timeout, never as HttpClient's TaskCanceledException, whatever TotalTimeout is: a service no longer keeps its timeouts "inside HttpClient's 100 s", nor sets HttpClient.Timeout for them.

Options, validated when the host starts

The options are the client's named ResultResilienceOptions (named after the client), so a service configures them in code, from a configuration section, or both, and never reads IConfiguration itself:

// appsettings.json: "SwgohApiService": { "Resilience": { "AttemptTimeout": "00:01:00", "TotalTimeout": "00:01:35",
//   "CircuitBreaker": false, "Retries": { "MaxAttempts": 4, "FirstDelay": "00:00:01", "MaxDelay": "00:00:04", "Jitter": true } } }
services.AddHttpClient<ISwgohApiClient, SwgohApiClient>()
    .AddResultResilience("SwgohApiService:Resilience", resilience =>
        resilience.RetryOnly = ResultResilienceOptions.SafeToRetryStatuses);

The configure action runs first (the service's own defaults, in code), then the section is bound (BindConfiguration) when the options are first read: a value the section holds wins (a test's shorter timeouts), one it leaves out keeps the code's or the default, Retries' values one by one too (Retries:FirstDelay alone keeps the attempts). Either way the options are validated when the host starts (ValidateOnStart): a host whose options are unusable doesn't start, with an OptionsValidationException naming the client and the problem: an AttemptTimeout longer than TotalTimeout (or one that isn't positive), a negative circuit-breaker duration, a failure ratio outside 0–1, no Retries, or a RetryOnly combined with AlsoRetry / NeverRetry or holding a status outside 400–599. A container used without a host gets that exception when it first builds the pipeline.

Before (Sentinel's SwgohApiResilienceOptions): a class of its own with the same properties and production's values, a Validate method keeping TotalTimeout under HttpClient's 100 s, AddOptions<...>().BindConfiguration(...).ValidateOnStart(), and configuration.GetSection(...).Get<...>() to copy each value into AddResultResilience. After: production's values in the configure action, the tests' overrides under the same section (Retries:FirstDelay / Retries:MaxDelay for RetryDelay / MaxRetryDelay), and the one call:

client.AddResultResilience("SwgohApiService:Resilience", resilience =>
{
    resilience.Retries = new RetrySchedule(4, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(4), Jitter: true);
    resilience.AttemptTimeout = TimeSpan.FromSeconds(60);
    resilience.TotalTimeout = TimeSpan.FromSeconds(95);
    resilience.CircuitBreaker = false;
    resilience.RetryOnly = ResultResilienceOptions.SafeToRetryStatuses;
});

After the retries the caller gets the last response (ToResultAsync turns it into a failure). A timeout (the last attempt's, or the whole call's), an open circuit or a service that couldn't be reached after the retries is answered in the service's place, so ToResultAsync reads it as a failure like any other, with no try/catch:

Gave up on Response ToResultAsync's error
a timeout (TimeoutRejectedException) 504, problem details ResultResilienceErrors.Timeout (Http.ResilienceTimeout, Timeout)
an open circuit (BrokenCircuitException) 502, problem details ResultResilienceErrors.CircuitOpen (Http.CircuitOpen, ExternalFailure)
no answer (HttpRequestException without a status: refused, reset, a name that doesn't resolve) 502, problem details ResultResilienceErrors.Unreachable(authority, cause) (Http.Unreachable, ExternalFailure), e.g. The service at swgohapi:8080 could not be reached: Connection refused (swgohapi:8080)

The made-up response carries the header Resrcify-Resilience: timeout / circuit-open / unreachable (a response from the service called never does) and the request; EnsureSuccessStatusCode throws HttpRequestException for it, as for a real 504. The caller's own cancellation still throws OperationCanceledException. ThrowTimeoutsAndOpenCircuits = true throws TimeoutRejectedException / BrokenCircuitException / HttpRequestException instead, as AddStandardResilienceHandler (and 4.0.0) does. A failure while HttpClient reads the body of an answer it got (a connection reset halfway through a buffered response) still throws an HttpRequestException: it happens after the pipeline returned the response, outside it.

AddResultResilience also adds the Web package's ITransientFailureRule (Abstractions): Polly's rejections (ExecutionRejectedException: a timeout, a broken or isolated circuit, the rate limiter), when they are thrown, are transient to the outbox, the mediator's logging and the message bus. The other packages never name Polly's types.

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 Retries.MaxAttempts 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. Microsoft.AspNetCore.Authentication.JwtBearer comes with the package. It replaces Sandbox's JwtOptions and ConfigureJwtOptions (same section, same keys).

Common issues

  • If response serialization differs between minimal APIs and controllers, ensure both JsonOptions and 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.4.0 0 10/16/2026
4.3.0 27 10/16/2026
4.2.0 89 10/14/2026
4.1.0 60 10/14/2026
4.0.0 178 10/13/2026
3.0.0 178 10/3/2026
2.2.0 155 9/13/2026
2.1.1 1,490 6/8/2026
2.1.0 133 5/24/2026
2.0.0 130 5/22/2026
1.16.3 255 5/7/2026
1.16.2 131 5/7/2026
1.16.1 125 5/7/2026
1.16.0 140 5/7/2026
1.15.0 530 4/18/2026
1.14.0 371 10/15/2025
1.13.0 530 6/27/2025
1.12.0 387 4/16/2025
1.11.0 273 4/15/2025
1.10.8 324 4/11/2025
Loading failed