CSharpEssentials.AspNetCore
6.2.0
See the version list below for details.
dotnet add package CSharpEssentials.AspNetCore --version 6.2.0
NuGet\Install-Package CSharpEssentials.AspNetCore -Version 6.2.0
<PackageReference Include="CSharpEssentials.AspNetCore" Version="6.2.0" />
<PackageVersion Include="CSharpEssentials.AspNetCore" Version="6.2.0" />
<PackageReference Include="CSharpEssentials.AspNetCore" />
paket add CSharpEssentials.AspNetCore --version 6.2.0
#r "nuget: CSharpEssentials.AspNetCore, 6.2.0"
#:package CSharpEssentials.AspNetCore@6.2.0
#addin nuget:?package=CSharpEssentials.AspNetCore&version=6.2.0
#tool nuget:?package=CSharpEssentials.AspNetCore&version=6.2.0
CSharpEssentials.AspNetCore
Production-ready ASP.NET Core utilities: global exception handling, structured ProblemDetails, API versioning, enum conventions, and Result endpoint filters.
Features
- Enhanced Problem Details: One configurable ProblemDetails pipeline for Minimal API, MVC, the exception handler and status code pages. It has secure defaults and the extension points
IProblemDetailsEnricher,IErrorStatusCodeMapperandIExceptionProblemMapper. - Global Exception Handler: Maps exceptions to ProblemDetails. Unknown exceptions return 500, and the exception message goes to the log, not to the response.
- Result Endpoint Filter: Converts
Result<T>return values to200 OKor a ProblemDetails response. - Validation Endpoint Filter: Runs
CSharpEssentials.Validationvalidators for a handler argument and returns a 400 ProblemDetails response on failure. - Idempotency:
Idempotency-Keyhandling for opted-in endpoints. A retry with the same key and request gets the stored response instead of running the handler again. The store is pluggable (IIdempotencyStore); in-memory andIDistributedCachestores are included. - Enum Binding: Query and route values for
[StringEnum]enums accept the same spellings as JSON. Invalid values return 400. - Conditional Requests:
ETag/Last-Modifiedwith304 Not Modifiedon reads (WithConditionalGet(),[ConditionalGet]), andIf-Matchoptimistic concurrency with 412/428 on writes (WithIfMatch(),[IfMatch]). - API Versioning: Pre-configured URL segment and header-based versioning.
- OpenAPI: Not part of this package. Add
CSharpEssentials.AspNetCore.OpenApi(Microsoft.AspNetCore.OpenApi) orCSharpEssentials.AspNetCore.Swashbuckle(Swagger), one per host.
Installation
dotnet add package CSharpEssentials.AspNetCore
Depends on CSharpEssentials.Errors, CSharpEssentials.Json, CSharpEssentials.Results and CSharpEssentials.Validation (for the validation endpoint filter). None of them references this package, so there is no cycle.
Usage
Problem Details
builder.Services.AddEnhancedProblemDetails(o =>
{
o.TraceId = TraceIdFormat.W3CTraceId; // default
o.ErrorFields = ProblemErrorFields.Codes | ProblemErrorFields.ValidationErrors; // default
o.ExposeExceptionDetails = builder.Environment.IsDevelopment();
});
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
// Optional extension points
builder.Services.AddProblemDetailsEnricher<TenantEnricher>(); // add fields to every problem response
builder.Services.AddErrorStatusCodeMapper<AuthAwareStatusMapper>(); // Error -> status code / title
builder.Services.AddExceptionProblemMapper<PaymentExceptionMapper>(); // Exception -> problem
var app = builder.Build();
app.UseEnhancedProblemDetails(); // UseExceptionHandler + UseStatusCodePages
| Option | Default | Notes |
|---|---|---|
TraceId |
W3CTraceId |
TraceparentHeader or None |
IncludeRequestId / IncludeUser / IncludeSpanIds |
false |
|
Instance |
Path |
MethodAndPath or None |
ErrorFields |
Codes \| ValidationErrors |
Messages, AllErrors, All, None |
ValidationErrorsFormat |
List |
Dictionary groups descriptions by code |
TypeUriResolver |
ProblemTypeUris.Rfc9110 |
ProblemTypeUris.Rfc7231 or a custom function |
ExposeExceptionDetails |
false |
Enable only in Development |
o.UseLegacyDefaults() restores the 3.x output. See the migration notes in the repository README.
With no W3C Activity (tracing disabled), W3CTraceId falls back to HttpContext.TraceIdentifier. errors lists only ErrorType.Validation errors; every code is in errorCodes.
services.ConfigureInvalidModelStateResponse() writes the automatic 400 of [ApiController] (invalid model state) through the same pipeline, with the model state key as the error code. It can be called before or after AddControllers(). It has no effect together with ConfigureModelValidatorResponse(), which turns the automatic 400 off (SuppressModelStateInvalidFilter = true); use one or the other.
errors.ToProblemResult() (Minimal API) and errors.ToActionResult(HttpContext) (MVC) read these options when they execute, so both produce the same JSON.
API Versioning
builder.Services.AddAndConfigureApiVersioning();
var app = builder.Build();
var usersGroup = app.CreateVersionedGroup("users", version: 1);
usersGroup.MapGet("/{id}", (int id) => Results.Ok(new { Id = id }));
MapVersionedGroup(version) creates a group with only the v{version:apiVersion} prefix and the version set, so other route groups can be composed under it:
var v2 = app.MapVersionedGroup(2); // /v2, ApiExplorer group "v2"
v2.MapGet("/ping", () => "pong"); // GET /v2/ping
v2.MapAppsEndpoints(); // CSharpEssentials.Endpoints registry under /v2
OpenAPI Documents
AddSwagger, UseVersionableSwagger and the Swagger filters moved to CSharpEssentials.AspNetCore.Swashbuckle in 5.0 (same namespaces). For Microsoft.AspNetCore.OpenApi use CSharpEssentials.AspNetCore.OpenApi. A host references one of them, never both.
Result Endpoint Filter
builder.Services.AddScoped<IResultErrorMapper, DomainErrorMapper>(); // optional, any lifetime
app.MapGet("/users/{id}", (int id) => GetUser(id))
.AddEndpointFilter<ResultEndpointFilter>();
// Returns 200 with value on success; on failure the mapper's result (resolved per request),
// or a ProblemDetails response when no mapper is registered
Validation Endpoint Filter
builder.Services.AddValidator<CreateUserRequest, CreateUserRequestValidator>();
app.MapPost("/users", (CreateUserRequest request) => CreateUser(request))
.WithValidation<CreateUserRequest>();
- Validates each non-null argument of type
Twith every registeredIValidator<T>, in ascendingOrder. Errors are combined and returned withToProblemResult()(validation errors → 400); the handler does not run. - A
nullargument (optional body) is passed through. - No registered
IValidator<T>throwsInvalidOperationExceptionat request time, so unvalidated input never reaches the handler. A handler without aTparameter throws when the endpoint is built. ValidationEndpointFilter<T>can also be added directly withAddEndpointFilter<ValidationEndpointFilter<T>>(), for example on a route group.
Idempotency
builder.Services.AddIdempotency(o =>
{
o.RetentionPeriod = TimeSpan.FromHours(24); // default
o.RequireKey = true; // default false: requests without a key pass through
});
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseIdempotency(); // after auth, so the key is scoped to the user
app.MapPost("/orders", CreateOrder).WithIdempotency();
[HttpPost, Idempotent] // MVC
public IActionResult Create(CreateOrderRequest request) => ...;
How a request with an Idempotency-Key header is handled:
| Situation | Response |
|---|---|
| First request | The handler runs. A response that ShouldStore accepts (default: 2xx) is stored for RetentionPeriod |
| Same key, same request, completed | The stored status, body and allowlisted headers, plus Idempotency-Replayed: true. The handler does not run |
| Same key, same request, still running | 409 with Retry-After |
| Same key, different method, path, query or body | 422 |
| Missing key | Passes through (400 with RequireKey) |
Empty key, key longer than MaxKeyLength (255), several key headers |
400 |
Handler throws before the response started, or ShouldStore rejects the status |
The key is released, so the client can retry |
| Handler throws after the response started (for example the client went away mid-write) | The key stays in flight until InFlightTimeout, because the work is most likely done |
- Only endpoints with
IdempotentAttributemetadata (WithIdempotency(),[Idempotent], or on a route group) and methods inMethods(default POST and PATCH) participate. - Keys are scoped to the authenticated user (
NameIdentifier, thensub), so a user only ever receives responses stored under the same scope. Requests without a scope pass through; setKeyScope(for example to a tenant id, or the issuer plus the subject when several authentication schemes can produce the samesub) orAllowUnscopedKeys = trueto change that. Unscoped keys live in their own namespace and never match a scoped entry. - Only the headers in
ReplayedHeadersare replayed (Content-Type,Content-Language,Location,ETag,Cache-Control).Set-Cookieis never stored. - Put
UseResponseCompression()beforeUseIdempotency(): the stored body is uncompressed and compression runs again on replay.Content-EncodingandVaryare not replayed by default; add them only when the handler encodes the body itself. - A response body larger than
MaxResponseBodySize(1 MiB) is sent normally but not stored, and a warning is logged. - The request body is buffered (
EnableBuffering) to compute the fingerprint; bodies over 30 KB spill to a temporary file. InFlightTimeout(1 minute) frees the key of a request that never finished, for example after a crash. Keep it longer than your request timeout: a retry after it expires runs the handler again even if the first request is still running. Each reservation has a token, so the late request can no longer overwrite or release the retry's entry (a warning is logged), but its side effects have already happened.- A store failure after the handler ran is logged and does not change the response; the key then stays in flight until
InFlightTimeout.
Stores:
| Store | Register with | Notes |
|---|---|---|
InMemoryIdempotencyStore |
default, or o.UseInMemoryStore() |
Single instance only. Unbounded: every stored response stays in memory for RetentionPeriod, so use it for development or small workloads |
DistributedCacheIdempotencyStore |
o.UseDistributedCacheStore() plus an IDistributedCache |
Best effort: IDistributedCache has no atomic add, so two simultaneous first requests on different instances can both run |
Your own IIdempotencyStore |
o.UseStore<TStore>(lifetime) |
Use an atomic insert (for example INSERT ... ON CONFLICT DO NOTHING or Redis SET NX) for a strict guarantee, and make CompleteAsync/ReleaseAsync conditional on the reservation token (WHERE key = @key AND token = @token) |
Enum Conventions: JSON, Binding and Wire Format
builder.Services.AddEnumConventions(); // JSON options, binding rules, body errors, output filters
app.UseEnumBinding(); // after routing, UseAuthentication and UseAuthorization (otherwise a 400 hides the 401)
// [StringEnum] enum OrderStatus { Pending, PendingApproval }
// ?status=pending_approval, ?status=PENDING_APPROVAL, ?status=PendingApproval, ?status=1 all bind to OrderStatus.PendingApproval
app.MapGet("/orders", (OrderStatus? status) => Results.Ok(status));
AddEnumConventions(c => c with { ... }) takes the EnumConventions of CSharpEssentials.Enums (AcceptNumbers, CaseInsensitive, WriteAs, CanHandle, ...). Route, query, header and form values follow the same accept rules as a JSON body. This works for Minimal API (including [AsParameters]) and MVC ([FromQuery], [FromRoute], [FromHeader], [FromForm], and DTO properties, nested up to 8 levels: ?filter.inner.status=…). UseEnumBinding() throws at startup when AddEnumConventions() was not called.
- Nullable, array,
List<T>and[Flags]parameters are supported. Arrays accept repeated keys and comma-separated values (?s=a&s=b,?s=a,b); flags values are a comma-separated list. - These values return a 400 ProblemDetails response that lists the allowed values (
'99' is not a valid OrderStatus. Allowed values: pending, pending_approval.):- undefined numbers;
- unknown names (leading and trailing whitespace is trimmed first, so
?status=%20pendingbinds, while?status=%20bogus%20is still a 400; an empty or whitespace-only value bindsnullfor a nullable parameter and is a 400 for a non-nullable one); - commas on enums without
[Flags]; - empty values for non-nullable parameters.
- A missing required value keeps the framework's handling.
- The error code is the key (
status,filter.status,X-Status). Change the code or message once for binding and JSON bodies with.ConfigureErrors((error, key) => Error.Validation($"validation.{key}", error.Message)). - Enums without generated metadata (no
[StringEnum]) keep the framework's binding and output.AddEnumConventionsWithReflection(c => c with { CanHandle = ... })opts them in with reflection (not trimming or AOT safe). - A JSON body error of a Minimal API endpoint (with
RouteHandlerOptions.ThrowOnBadRequest) is mapped byGlobalExceptionHandlerto the same 400. MVC reports body errors through model state. - Empty values of nullable or collection parameters are dropped, so they bind as
nullor are skipped. - Matched values are rewritten to the C# member name before binding. Link generation that reuses the current route values (ambient values, such as
CreatedAtRoutewithout explicit values) therefore emits the member name (/items/HttpStatus). Pass the value explicitly to get the JSON spelling.
Legacy numeric output per group, controller or action, in both directions (WriteAs is the global default):
app.MapGroup("/api/v1").WithEnumWireFormat(EnumWireFormat.Number); // old clients: 1
app.MapGroup("/api/v2"); // "pending_approval"
app.MapGroup("/api/shared").WithEnumWireFormat("X-Enum-Format", // per request, adds Vary: X-Enum-Format
ctx => ctx.Request.Headers["X-Enum-Format"] == "string" ? EnumWireFormat.String : EnumWireFormat.Number);
[EnumWireFormat(EnumWireFormat.Number)] // MVC controller or action
public sealed class LegacyOrdersController : ControllerBase { ... }
Precedence: action attribute > controller attribute > endpoint or group (MapControllers().WithEnumWireFormat(...) for MVC) > WriteAs. The host's JsonOptions are never changed; the other format is written with a copy. Status codes and headers of the result (Created + Location, Results<Ok<T>, NotFound>) are kept. Reading is not affected.
AddEnumBinding/EnumBindingOptions are obsolete forwarders to AddEnumConventions (see the v4 to v5 migration guide).
Conditional requests
using Microsoft.Net.Http.Headers; // EntityTagHeaderValue; not System.Net.Http.Headers
builder.Services.AddConditionalRequests(); // MVC filters included; call once or many times
builder.Services.AddETagSource<Article, ArticleETagSource>(); // optional: ETag + Last-Modified for a type
// public sealed class Order : IVersioned { public uint Xmin { get; set; } public string Version => Xmin.ToString(); ... }
app.MapGet("/orders/{id}", async Task<Result<Order>> (Guid id, AppDbContext db) =>
await db.Orders.FindAsync(id) is { } order ? order : Error.NotFound("order.notFound", "Order not found"))
.AddEndpointFilter<ResultEndpointFilter>()
.WithConditionalGet(); // ETag "{Version}"; a failure is untouched
app.MapPut("/orders/{id}", async Task<Result> (Guid id, UpdateOrder body, HttpContext http, AppDbContext db) =>
{
Order? order = await db.Orders.FindAsync(id);
if (order is null)
return Error.NotFound("order.notFound", "Order not found");
Preconditions preconditions = http.GetPreconditions();
if (!preconditions.Matches(order)) // same ETag as the GET, also for hashed versions
return ConditionalRequestErrors.PreconditionFailed; // 412
if (preconditions.TryGetIfMatchVersion(out string? version))
db.Entry(order).Property(o => o.Xmin).OriginalValue = uint.Parse(version); // atomic check in SaveChanges
order.Apply(body);
await db.SaveChangesAsync();
return Result.Success();
}).AddEndpointFilter<ResultEndpointFilter>().WithIfMatch(required: true);
public sealed class ArticleETagSource : IETagSource<Article>
{
public ResourceValidators? GetValidators(Article article) =>
new(new EntityTagHeaderValue($"\"{article.Revision}\""), article.UpdatedAt); // long Revision
}
A byte[] row version must be encoded (Convert.ToBase64String(rowVersion)); interpolating it gives "System.Byte[]".
MVC: [ConditionalGet] on a [HttpGet, HttpHead] action or controller, [IfMatch(Required = true)] on a write action, or MapControllers().WithConditionalGet() / .WithIfMatch() for all actions (action > controller > endpoint).
| Request | Response |
|---|---|
GET/HEAD with a successful value (object, Result<T> success, Ok<T> or another 2xx IValueHttpResult) |
ETag and Last-Modified (second precision, never later than now) are set |
If-None-Match matches (weak comparison, * matches any) |
304 Not Modified without a body; headers already on the response (Cache-Control, Vary, Content-Location) are kept |
No If-None-Match, If-Modified-Since not older than Last-Modified |
304 (an unparsable date is ignored) |
Handler already set an ETag header |
Untouched (not overridden, no 304) |
Strings, ProblemDetails, null, failed results, non-2xx results, other methods |
Untouched (also a Result<string>) |
POST/PUT/PATCH/DELETE without If-Match, required: true |
428 Precondition Required |
Malformed If-Match (v1, *, "v1") |
400 |
Handler returns ConditionalRequestErrors.PreconditionFailed |
412 Precondition Failed (DefaultErrorStatusCodeMapper) |
If-Match is evaluated only for POST, PUT, PATCH and DELETE; safe methods get empty preconditions. If-Unmodified-Since is not supported. A header sent on several lines is combined. The 412 comes from DefaultErrorStatusCodeMapper: a registered IResultErrorMapper decides the ResultEndpointFilter response itself, and a custom IErrorStatusCodeMapper must map ConditionalRequestErrors.PreconditionFailedCode to 412.
Validators: a registered IETagSource<T> (exact runtime type, then base classes; interfaces are rejected; resolved per request so scoped dependencies work), otherwise IETagGenerator. The default generator turns IVersioned.Version into a strong ETag (versions with characters not allowed in an ETag are hashed to base64url SHA-256; an empty version gives none); versioned.ToETag() returns the same tag. With AddConditionalRequests(o => o.UseBodyHashFallback = true) other values get a weak ETag from the SHA-256 of their JSON. Only buffered values are hashed: IAsyncEnumerable<T>, Stream, PipeReader and IQueryable get no ETag, so they are not enumerated or run twice. Return materialized values (a list or array): a lazy IEnumerable<T> (yield, AsEnumerable().Select(...)) is enumerated once for the hash and again for the response. The hash uses the host JsonOptions (ConfigureHttpJsonOptions), also for MVC actions. It is off by default because it serializes every response twice. Last-Modified is capped at now, read from the TimeProvider in DI (the system clock when none is registered).
Preconditions (HttpContext.GetPreconditions()): HasIfMatch, IsWildcard, IfMatch, Matches(IVersioned?), Matches(ResourceValidators?), Matches(EntityTagHeaderValue?) (strong comparison, weak tags never match), and TryGetIfMatchVersion(out string version), the value of a single strong tag. That value equals IVersioned.Version only when the version is a valid entity tag (numbers such as uint xmin or a long revision are); hashed versions must be compared with Matches(IVersioned?). Pass it as the original concurrency token of the update (for example EF Core OriginalValue) for an atomic check. WithIfMatch<TBuilder, T>(loadCurrent) loads the current resource and returns 412 before the handler; that check is not atomic. A weak ETag (the body hash fallback) never matches there, so only If-Match: * passes. With UseIdempotency(), a retry with the same Idempotency-Key replays the stored response without evaluating If-Match again; the fingerprint covers method, path, query and body, not headers.
Structured Error Response
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "Email format is incorrect.",
"instance": "/users",
"traceId": "0af7651916cd43dd8448eb211c80319c",
"errorCodes": ["User.InvalidEmail"],
"errors": [
{ "code": "User.InvalidEmail", "description": "Email format is incorrect." }
]
}
| 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. net11.0 is compatible. |
-
net10.0
- Asp.Versioning.Abstractions (>= 8.1.0)
- Asp.Versioning.Http (>= 8.1.0)
- Asp.Versioning.Mvc (>= 8.1.0)
- Asp.Versioning.Mvc.ApiExplorer (>= 8.1.0)
- CSharpEssentials.Errors (>= 6.2.0)
- CSharpEssentials.Json (>= 6.2.0)
- CSharpEssentials.Results (>= 6.2.0)
- CSharpEssentials.Validation (>= 6.2.0)
-
net11.0
- Asp.Versioning.Abstractions (>= 8.1.0)
- Asp.Versioning.Http (>= 8.1.0)
- Asp.Versioning.Mvc (>= 8.1.0)
- Asp.Versioning.Mvc.ApiExplorer (>= 8.1.0)
- CSharpEssentials.Errors (>= 6.2.0)
- CSharpEssentials.Json (>= 6.2.0)
- CSharpEssentials.Results (>= 6.2.0)
- CSharpEssentials.Validation (>= 6.2.0)
-
net8.0
- Asp.Versioning.Abstractions (>= 8.1.0)
- Asp.Versioning.Http (>= 8.1.0)
- Asp.Versioning.Mvc (>= 8.1.0)
- Asp.Versioning.Mvc.ApiExplorer (>= 8.1.0)
- CSharpEssentials.Errors (>= 6.2.0)
- CSharpEssentials.Json (>= 6.2.0)
- CSharpEssentials.Results (>= 6.2.0)
- CSharpEssentials.Validation (>= 6.2.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.4)
- System.Text.Json (>= 9.0.4)
-
net9.0
- Asp.Versioning.Abstractions (>= 8.1.0)
- Asp.Versioning.Http (>= 8.1.0)
- Asp.Versioning.Mvc (>= 8.1.0)
- Asp.Versioning.Mvc.ApiExplorer (>= 8.1.0)
- CSharpEssentials.Errors (>= 6.2.0)
- CSharpEssentials.Json (>= 6.2.0)
- CSharpEssentials.Results (>= 6.2.0)
- CSharpEssentials.Validation (>= 6.2.0)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on CSharpEssentials.AspNetCore:
| Package | Downloads |
|---|---|
|
CSharpEssentials.AspNetCore.Swashbuckle
Swashbuckle (Swagger) integration for CSharpEssentials.AspNetCore: versioned Swagger setup, schema ids, security schemes and OpenAPI enum schemas that follow the CSharpEssentials enum conventions (wire names, numeric groups, flags and nullable properties). Use it instead of CSharpEssentials.AspNetCore.OpenApi, never together with it. |
|
|
CSharpEssentials.AspNetCore.OpenApi
Microsoft.AspNetCore.OpenApi integration for the CSharpEssentials enum conventions: enum schemas with wire names, numeric groups, flags, collections, defaults and nullable usages, for OpenAPI 3.0 and 3.1 documents. Use it instead of CSharpEssentials.AspNetCore.Swashbuckle, never together with it. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 6.5.0 | 0 | 10/9/2026 |
| 6.4.0 | 24 | 10/9/2026 |
| 6.3.0 | 35 | 10/8/2026 |
| 6.2.0 | 48 | 10/8/2026 |
| 6.1.0 | 43 | 10/7/2026 |
| 6.0.0 | 47 | 10/7/2026 |
| 5.2.1 | 47 | 10/7/2026 |
| 5.2.0 | 47 | 10/7/2026 |
| 5.1.0 | 43 | 10/7/2026 |
| 5.0.0 | 50 | 10/6/2026 |
| 4.1.0 | 46 | 10/6/2026 |
| 4.0.0 | 89 | 10/5/2026 |
| 3.2.3 | 161 | 5/31/2026 |
| 3.2.2 | 128 | 5/31/2026 |
| 3.2.1 | 119 | 5/31/2026 |
| 3.2.0 | 118 | 5/30/2026 |
| 3.1.0 | 124 | 5/27/2026 |
| 3.0.8 | 116 | 5/20/2026 |
| 3.0.7 | 120 | 5/20/2026 |
| 3.0.6 | 122 | 5/20/2026 |