SyntaxCircus.AspNetCore.Common
0.1.11
See the version list below for details.
dotnet add package SyntaxCircus.AspNetCore.Common --version 0.1.11
NuGet\Install-Package SyntaxCircus.AspNetCore.Common -Version 0.1.11
<PackageReference Include="SyntaxCircus.AspNetCore.Common" Version="0.1.11" />
<PackageVersion Include="SyntaxCircus.AspNetCore.Common" Version="0.1.11" />
<PackageReference Include="SyntaxCircus.AspNetCore.Common" />
paket add SyntaxCircus.AspNetCore.Common --version 0.1.11
#r "nuget: SyntaxCircus.AspNetCore.Common, 0.1.11"
#:package SyntaxCircus.AspNetCore.Common@0.1.11
#addin nuget:?package=SyntaxCircus.AspNetCore.Common&version=0.1.11
#tool nuget:?package=SyntaxCircus.AspNetCore.Common&version=0.1.11
SyntaxCircus.AspNetCore.Common
The small pieces of ASP.NET Core host boilerplate that show up in nearly every project, in one place: result-to-ProblemDetails mapping, correlation-ID middleware, security headers, search-indexing opt-out headers with robots.txt/sitemap.xml endpoint helpers, canonical-host redirects, a composable exception-handler/HSTS bootstrap, RFC 7807 ProblemDetails exception handling, trusted-proxy validation, standard health check endpoints, fixed-window/token-bucket rate-limiting policy helpers, and (via the optional SyntaxCircus.AspNetCore.Common.MassTransit package) correlation-ID propagation across a MassTransit bus.
No support guaranteed. Published as-is and maintained on a best-effort basis. Issues and PRs are welcome, but there's no SLA — fork it or vendor what you need if that's not enough.
Blazor Web App static assets
For a .NET 10 Blazor Web App, map static assets before the component fallback and select the render mode at the host:
app.MapRazorComponentsWithStaticAssets<App>()
.AddInteractiveServerRenderMode();
The host page must reference the boot script through the static-asset collection so publishing can fingerprint it:
<script src="@Assets["_framework/blazor.web.js"]"></script>
Keep deployment verification in the application: make the Docker publish stage assert that wwwroot/_framework/blazor.web.js exists, and integration-test that the host page emits a static-asset boot-script URL. Smoke-test /_framework/blazor.web.js against the deployed host. If the direct Kestrel request is 404, fix the application or image before investigating reverse-proxy routing.
Correlation ID
builder.Services.AddCorrelationId(); // optionally: options => options.HeaderName = "X-My-Correlation-Id"
var app = builder.Build();
app.UseCorrelationId();
Reads (or generates) a correlation ID per request, echoes it on the response header, tags the current Activity, and pushes it — with the current trace/span ID — into the logger scope for the rest of the request, so downstream log lines carry it automatically. CorrelationContextAccessor gives ambient (AsyncLocal) access to it outside the middleware pipeline:
| Member | Purpose |
|---|---|
CurrentCorrelationId |
The current request's correlation ID (get/set), or null outside a request. |
CurrentTraceId |
The current System.Diagnostics.Activity's trace ID, or null if there is none. |
ResolveCorrelationId(fallback = null) |
CurrentCorrelationId, else fallback, else CurrentTraceId, else a new GUID — in that order. Useful for background work (queue consumers, hosted services) that wants a correlation ID even outside an HTTP request. |
Security headers
builder.Services.AddSecurityHeaders(builder.Configuration); // binds the "SecurityHeaders" section
var app = builder.Build();
app.UseSecurityHeaders();
Sets Referrer-Policy, X-Frame-Options, X-Content-Type-Options, Permissions-Policy, Content-Security-Policy, and Strict-Transport-Security from SecurityHeadersOptions, with sensible defaults you can override per-key in configuration. X-Robots-Tag is also supported (RobotsTag) but omitted entirely unless set.
Some routes need different values than the rest of the app — e.g. a public, unauthenticated page that must never leak a referrer or get indexed. Configure PathOverrides for that instead of writing a second middleware:
{
"SecurityHeaders": {
"PathOverrides": [
{ "PathPrefix": "/public-profile", "ReferrerPolicy": "no-referrer", "RobotsTag": "noindex, nofollow" }
]
}
}
The first entry whose PathPrefix matches the request path (via PathString.StartsWithSegments) wins; everything else keeps the top-level defaults.
Search indexing
builder.Services.AddSearchIndexing(builder.Configuration); // binds the "SearchIndexing" section
var app = builder.Build();
app.UseSearchIndexingHeaders(); // reads BlockPageMetadata per request
Useful for a staging/preview deployment, or a pre-launch site, that should never show up in
search results: when SearchIndexingOptions.BlockPageMetadata is true, every response gets
X-Robots-Tag: noindex,nofollow — unless a response already has an X-Robots-Tag header, in
which case this middleware leaves it alone rather than overwriting it. Toggle the whole thing
with one config value instead of hand-rolling middleware per environment:
{ "SearchIndexing": { "BlockPageMetadata": true } }
If BlockPageMetadata needs to depend on something other than a static flag — e.g. only on a
specific host, or only outside Production — use the predicate overload instead of binding
options at all:
app.UseSearchIndexingHeaders(ctx => ctx.Request.Host.Host == "staging.example.com");
The parameterless overload never sets X-Robots-Tag on /robots.txt or /sitemap.xml — those
two paths should never carry a noindex signal themselves — even when BlockPageMetadata is
true. Add more paths to that skip list with ExcludedPaths (purely additive; the built-in two
are always skipped regardless of what's configured here):
{ "SearchIndexing": { "BlockPageMetadata": true, "ExcludedPaths": ["/health"] } }
SearchIndexingOptions.BlockRobotsAndSitemap powers the search discovery
endpoints below — it isn't read anywhere else.
The literal value written is SearchIndexingOptions.RobotsDirective, which defaults to
NoIndexDirective ("noindex,nofollow") but is a plain settable string — set it to "noindex"
alone, or add directives like "noindex,nofollow,noarchive", without needing the predicate
overload:
{ "SearchIndexing": { "BlockPageMetadata": true, "RobotsDirective": "noindex" } }
RobotsDirective only affects the parameterless overload — UseSearchIndexingHeaders(shouldApply)
always writes the fixed NoIndexDirective value, regardless of what's configured.
Interaction with security headers: if you also use security headers and
set SecurityHeadersOptions.RobotsTag (or a matching PathOverride) for the same path, that
value always wins over UseSearchIndexingHeaders's noindex,nofollow, regardless of which
Use... call comes first in the pipeline — UseSecurityHeaders writes X-Robots-Tag
unconditionally, while UseSearchIndexingHeaders only writes it when nothing else has already
set it. Don't configure both for the same route expecting the search-indexing value to take
precedence; use SecurityHeadersOptions.RobotsTag/PathOverrides there instead.
Search discovery endpoints (robots.txt / sitemap.xml)
app.MapRobotsTxt(_ => "User-agent: *\nAllow: /\nSitemap: https://example.com/sitemap.xml");
app.MapSitemap(_ =>
[
new SitemapEntry("https://example.com/", LastModified: DateOnly.FromDateTime(DateTime.UtcNow)),
new SitemapEntry("https://example.com/pricing"),
]);
Both read SearchIndexingOptions.BlockRobotsAndSitemap per request (no AddSearchIndexing call
required — IOptions<T> resolves to defaults even unconfigured) and short-circuit your supplied
content when it's true: MapRobotsTxt returns a disallow-all body
(SearchIndexingOptions.DisallowAllRobotsTxt) instead of calling contentFactory, and
MapSitemap returns 404 instead of calling entriesFactory — a single flag takes a
staging/preview deployment fully out of both files without touching the app-specific content
itself. MapSitemap XML-escapes URLs properly (via System.Xml.Linq), and both endpoints are
mapped .AllowAnonymous() so a global auth policy elsewhere in the app doesn't block crawlers.
Routes default to /robots.txt//sitemap.xml but take an optional second parameter to use
something else.
Both also accept an async factory — useful when the content comes from a database (e.g. a sitemap built from published blog posts):
app.MapSitemap(async ctx =>
{
var posts = await ctx.RequestServices.GetRequiredService<IBlogRepository>().GetPublishedAsync();
return posts.Select(p => new SitemapEntry($"https://example.com/blog/{p.Slug}", p.PublishedOn)).ToList();
});
SitemapEntry also carries the sitemap protocol's other two optional fields —
ChangeFrequency (a SitemapChangeFrequency enum: Always/Hourly/Daily/Weekly/Monthly/
Yearly/Never) and Priority (a double, conventionally 0.0–1.0) — both omitted from the
XML when left null.
Since robots.txt/sitemap.xml rarely change per-request, both methods take an optional
cacheDuration to set Cache-Control: public, max-age=... (omitted entirely when not passed, and
never applied to MapSitemap's blocked-state 404 so a CDN doesn't cache a stale not-found across
a BlockRobotsAndSitemap flip):
app.MapRobotsTxt(_ => "User-agent: *\nAllow: /", cacheDuration: TimeSpan.FromHours(1));
Canonical host redirect
builder.Services.AddCanonicalHostRedirect(builder.Configuration); // binds the "CanonicalHost" section
var app = builder.Build();
app.UseCanonicalHostRedirect(); // call early — before routing/auth — so legacy hosts short-circuit cheaply
301-redirects a configured set of legacy/apex hostnames to one canonical host, preserving the request's scheme, path, and query string:
{ "CanonicalHost": { "CanonicalHost": "www.example.com", "LegacyHosts": ["example.com", "old.example.com"] } }
A no-op until CanonicalHost is set (null by default) — safe to register unconditionally.
Redirects only host-consolidation by default, not scheme; pair with UseStandardExceptionHandling's
UseHsts() (or your reverse proxy) if you also need to force HTTPS on requests that are already on
the canonical host.
For a legacy host on http, that means two redirects back-to-back (this middleware, then
UseHsts/UseHttpsRedirection) unless you opt into ForceHttps, which upgrades the scheme in the
same redirect:
{ "CanonicalHost": { "CanonicalHost": "www.example.com", "LegacyHosts": ["example.com"], "ForceHttps": true } }
The redirect is a permanent 301 by default; set Permanent: false for a 302 during a staged
rollout — reversible while you verify the redirect behaves correctly, then flip back to permanent
once confirmed:
{ "CanonicalHost": { "CanonicalHost": "www.example.com", "LegacyHosts": ["example.com"], "Permanent": false } }
Exception handling / HSTS bootstrap
var app = builder.Build();
app.UseStandardExceptionHandling(); // "/error", HSTS — skipped entirely in Development
Bundles UseExceptionHandler(errorPath) + UseHsts(), both skipped in Development, with an optional status-code re-execute page (useStatusCodePages: true). It's a plain extension method, not something wired in automatically — a pure API host behind a reverse proxy that already terminates TLS and handles error pages doesn't need to call it.
ProblemDetails exception handling
builder.Services.AddProblemDetailsExceptionHandling(options =>
{
options.BaseTypeUri = "https://errors.example.com";
options.ExceptionMapper = ex => ex switch
{
NotFoundException => new ProblemMapping(StatusCodes.Status404NotFound, "not-found"),
_ => new ProblemMapping(StatusCodes.Status500InternalServerError, "internal-error"),
};
});
var app = builder.Build();
app.UseProblemDetailsExceptionHandling();
Catches unhandled exceptions and writes an RFC 7807 ProblemDetails response, with Type built from BaseTypeUri + your error code. Which exception types mean what status/code is deliberately a delegate you supply — that mapping is product-specific and the package doesn't try to guess it for you. A reasonable default mapper is provided if you don't set one, and it never puts a raw ex.Message into the response body: every case — including the unmapped/500 fallback — gets an explicit, generic Detail string. This matters because ex.Message on an exception nobody anticipated (a database error, a file path, connection details) can carry internals that shouldn't reach an API client.
The same rule applies to custom mappers: if your ExceptionMapper leaves a ProblemMapping's Detail unset (null), the middleware leaves Detail null in the response too — it does not silently substitute ex.Message. If you want the old fall-back-to-ex.Message behavior for cases your mapper doesn't set Detail for, opt in explicitly:
builder.Services.AddProblemDetailsExceptionHandling(options =>
{
options.IncludeExceptionMessageInDetail = true; // restores ex.Message fallback when a mapping's Detail is null
});
IncludeExceptionMessageInDetail defaults to false. Only set it to true if you've verified your exception messages are safe to expose to API clients (e.g. gated to non-production environments).
Result ProblemDetails mapping
SyntaxCircus.Common keeps application-layer results transport-neutral. Register this package's HTTP adapter once, then let each controller choose its own success response explicitly:
builder.Services.AddResultProblemDetails(options =>
{
options.BaseTypeUri = "https://errors.example.com";
});
[HttpPost]
public async Task<IActionResult> Create(
CreateWidgetRequest request,
CancellationToken cancellationToken)
{
var result = await requestHandler.HandleAsync(request, cancellationToken);
return result.ToActionResult(
this,
widget => CreatedAtAction(nameof(Get), new { id = widget.Id }, widget));
}
The adapter maps validation, unauthenticated, forbidden, not-found, conflict, and general failures to 400, 401, 403, 404, 409, and 500 by default. Override StatusCodeMapper when a host needs different transport semantics. ProblemDetails.Type is built from BaseTypeUri and the stable error code; Detail comes from the explicitly client-safe result message; and Instance is the request path.
Validation failures become ValidationProblemDetails. Messages are grouped by ResultError.Target (request-level errors use the empty key), while stable codes are grouped the same way in the errorCodes extension. Controllers still own success semantics such as Ok, CreatedAtAction, Accepted, or NoContent.
ApiResult and passthrough status codes
For the rare case where a failure needs to carry an exact upstream status code (a 429/502/503 from a third-party API) rather than one of the six kinds above, use ApiResult/ApiResult<T> (from SyntaxCircus.Common) instead of Result/Result<T>:
return apiResult.ToActionResult(this, widget => Ok(widget));
The passthrough survives even when a handler interface declares Task<Result<T>> but returns an ApiResult<T> internally: the base Result/Result<T> overloads of ToActionResult also check the runtime type, so result.ToActionResult(...) still detects the ApiResult<T> instance and renders its exact status code, regardless of whether the compile-time type says Result<T> or ApiResult<T>.
Trusted-proxy validation
builder.Services.AddTrustedProxyForwardedHeaders(builder.Configuration); // binds the "TrustedProxy" section
var app = builder.Build();
app.UseForwardedHeaders();
Wires ForwardedHeadersOptions (X-Forwarded-For/-Proto/-Host) from TrustedProxies/TrustedNetworks, and automatically fails fast at startup if you're running behind a reverse proxy without telling ASP.NET Core which upstream hosts to actually trust — with neither configured, forwarded headers would otherwise be trusted from anyone. This validation runs on its own (via a registered IStartupFilter); you don't need to call anything else for it to take effect.
If you want to trigger the same check outside the normal startup path (e.g. in a test), ValidateTrustedProxyConfiguration is available directly:
builder.Environment.ValidateTrustedProxyConfiguration(trustedProxyOptions); // throws outside Development if misconfigured
Health checks
var app = builder.Build();
app.MapStandardHealthChecks(); // /health/live (no checks run) and /health/ready (checks tagged "ready")
Maps standard liveness/readiness endpoints rendered via HealthCheckResponseWriter (status, total duration, and per-check name/status/duration/description as JSON). Register your own IHealthCheck implementations with AddHealthChecks().AddCheck<T>(tags: ["ready"]) as usual — this just standardizes the endpoints and response shape.
Pass metadataFactory to include extra data (e.g. an app version) under a metadata key — invoked per-request, omitted entirely when not supplied:
app.MapStandardHealthChecks(metadataFactory: _ => new Dictionary<string, object?> { ["version"] = appVersion });
Rate limiting
builder.Services.AddRateLimiter(options =>
{
options.AddPerIpFixedWindow("public", permitLimit: 60, window: TimeSpan.FromMinutes(1));
options.AddPerSubjectFixedWindow("authenticated", permitLimit: 600, window: TimeSpan.FromMinutes(1));
options.UseProblemDetailsRejection();
});
var app = builder.Build();
app.UseRateLimiter();
API surface
| Method | Purpose |
|---|---|
AddPerIpFixedWindow(policyName, permitLimit, window) |
Fixed-window policy partitioned by remote IP ("unknown" fallback). |
AddPerIpFixedWindow(policyName, permitLimit, window, configure) |
Same as above, with per-policy FixedWindowRateLimiterOptions overrides. |
AddPerSubjectFixedWindow(policyName, permitLimit, window) |
Fixed-window policy partitioned by authenticated subject (sub, fallback NameIdentifier, then remote IP). |
AddPerSubjectFixedWindow(policyName, permitLimit, window, configure) |
Same as above, with per-policy FixedWindowRateLimiterOptions overrides. |
AddPartitionedFixedWindow(policyName, partitionKeySelector, permitLimit, window, configure = null) |
Additive advanced API for custom partition key composition (route, claim combinations, tenant headers, etc.). |
UseProblemDetailsRejection(errorCode = "rate-limited") |
Writes ProblemDetails 429 responses and includes Retry-After when available. |
Advanced partition-key composition (additive, non-breaking)
builder.Services.AddRateLimiter(options =>
{
options.AddPartitionedFixedWindow(
"tenant-route",
ctx => $"{ctx.User.FindFirst("tenant_id")?.Value ?? "anon"}:{ctx.Request.Path}",
permitLimit: 120,
window: TimeSpan.FromMinutes(1),
configure: fixedWindow =>
{
fixedWindow.QueueLimit = 5;
fixedWindow.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
options.UseProblemDetailsRejection();
});
This keeps the existing convenience helpers intact while adding custom partition selection and optional per-policy overrides when app-level policy composition needs to be richer.
Token-bucket (additive, non-breaking)
Fixed-window resets its whole quota at each window boundary — good for a hard burst ceiling,
but it lets a client burn its entire limit in the first instant of a new window. Token-bucket
instead replenishes continuously (tokensPerPeriod tokens added every replenishmentPeriod),
which suits a steady-state throttle better:
builder.Services.AddRateLimiter(options =>
{
options.AddPartitionedTokenBucket(
"steady-state",
ctx => ctx.Connection.RemoteIpAddress?.ToString(),
tokenLimit: 10,
tokensPerPeriod: 10,
replenishmentPeriod: TimeSpan.FromSeconds(1));
options.UseProblemDetailsRejection();
});
| Method | Purpose |
|---|---|
AddPartitionedTokenBucket(policyName, partitionKeySelector, tokenLimit, tokensPerPeriod, replenishmentPeriod, configure = null) |
Token-bucket policy partitioned by a custom key selector, with optional per-policy TokenBucketRateLimiterOptions overrides. |
Chained multi-tier global limiter (additive, non-breaking)
For defense-in-depth rate limiting — e.g. requiring a request to pass both a per-actor
and a per-IP quota, so a leaked token can't bypass IP-level throttling and vice versa —
compose independently-partitioned tiers into RateLimiterOptions.GlobalLimiter:
builder.Services.AddRateLimiter(options =>
{
options.UseChainedGlobalLimiter(
RateLimiterOptionsExtensions.CreateFixedWindowTier(
ctx => ctx.User.FindFirst("sub")?.Value,
permitLimit: 600,
window: TimeSpan.FromMinutes(1),
isExempt: ctx => ctx.Request.Path.StartsWithSegments("/health")),
RateLimiterOptionsExtensions.CreateFixedWindowTier(
ctx => ctx.Connection.RemoteIpAddress?.ToString(),
permitLimit: 60,
window: TimeSpan.FromMinutes(1),
isExempt: ctx => ctx.Request.Path.StartsWithSegments("/health")));
options.UseProblemDetailsRejection();
});
A request must pass every tier's limiter to proceed; isExempt bypasses an individual
tier (e.g. for health-check paths) without disabling the others.
Tiers aren't limited to one kind of limiter — chaining a fixed-window burst ceiling with a token-bucket steady-state throttle is a common defense-in-depth shape for APIs that need both "no more than 60 requests in any one minute" and "no more than ~10 requests per second, sustained":
builder.Services.AddRateLimiter(options =>
{
options.UseChainedGlobalLimiter(
RateLimiterOptionsExtensions.CreateFixedWindowTier(
ctx => ctx.User.FindFirst("sub")?.Value,
permitLimit: 60,
window: TimeSpan.FromMinutes(1)),
RateLimiterOptionsExtensions.CreateTokenBucketTier(
ctx => ctx.User.FindFirst("sub")?.Value,
tokenLimit: 10,
tokensPerPeriod: 10,
replenishmentPeriod: TimeSpan.FromSeconds(1)));
options.UseProblemDetailsRejection();
});
This is a drop-in replacement for hand-rolling the equivalent with raw BCL types
(RateLimiter.CreateChained(new FixedWindowRateLimiter(...), new TokenBucketRateLimiter(...)))
— same semantics, without constructing the limiter options by hand at every call site.
| Method | Purpose |
|---|---|
CreateFixedWindowTier(partitionKeySelector, permitLimit, window, isExempt = null, configure = null) |
Builds one fixed-window PartitionedRateLimiter<HttpContext> tier for chaining. |
CreateTokenBucketTier(partitionKeySelector, tokenLimit, tokensPerPeriod, replenishmentPeriod, isExempt = null, configure = null) |
Builds one token-bucket PartitionedRateLimiter<HttpContext> tier for chaining. |
UseChainedGlobalLimiter(params tiers) |
Sets GlobalLimiter to the chained combination of the given tiers. |
MassTransit correlation propagation
Optional companion package — install SyntaxCircus.AspNetCore.Common.MassTransit separately:
services.AddMassTransit(x =>
{
x.UseCorrelationIdPropagation(); // registers filters + consume pipeline
x.UsingRabbitMq((ctx, cfg) =>
{
cfg.UseCorrelationIdPropagation(ctx); // wires publish + send pipeline
cfg.ConfigureEndpoints(ctx);
});
});
Both calls are required — they wire different halves of the pipeline. Together, they carry the
ambient correlation ID (the same one CorrelationContextAccessor/CorrelationIdMiddleware
manage for HTTP requests) across message-bus boundaries: UseCorrelationIdPropagation(ctx)
stamps it onto the configured header (CorrelationIdOptions.HeaderName) on publish/send, and
UseCorrelationIdPropagation() reads it back off inbound messages on consume, sets
CorrelationContextAccessor.CurrentCorrelationId for the duration of the consume, and pushes
CorrelationId/TraceId/SpanId into the logger scope — the same enrichment shape as the HTTP
middleware, so a request that triggers a published message keeps one correlation ID through both.
| Method | Purpose |
|---|---|
UseCorrelationIdPropagation(this IBusRegistrationConfigurator) |
Registers the filter types and wires the consume pipeline for all auto-registered receive endpoints. Call inside AddMassTransit(x => ...). |
UseCorrelationIdPropagation(this IBusFactoryConfigurator, IRegistrationContext) |
Wires the publish and send filters. Call inside the transport configurator lambda (e.g. UsingRabbitMq((ctx, cfg) => ...)). |
Contributing
Issues and pull requests are welcome:
- Keep changes focused, with a clear description of the behavior change.
- Match the existing code style (see
.editorconfig). - Call out any breaking changes to the public API in your PR description.
License
MIT — see LICENSE.txt.
| 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
- SyntaxCircus.Common (= 0.1.3)
NuGet packages (3)
Showing the top 3 NuGet packages that depend on SyntaxCircus.AspNetCore.Common:
| Package | Downloads |
|---|---|
|
SyntaxCircus.AspNetCore.Common.MassTransit
Optional companion to SyntaxCircus.AspNetCore.Common: MassTransit consume/publish/send filters that propagate the configured correlation ID across message-bus boundaries, keeping log enrichment consistent with the HTTP middleware. |
|
|
SyntaxCircus.Observability
Opt-in OpenTelemetry, Serilog OTLP, and Sentry-compatible error-reporting bootstrap for .NET server hosts. |
|
|
SyntaxCircus.Blazor.Seo
SEO building blocks for Blazor Server marketing sites: a SeoHead component, typed Schema.org JSON-LD records, a canonical URL builder, and thin sitemap.xml/robots.txt wrappers over SyntaxCircus.AspNetCore.Common. |
GitHub repositories
This package is not used by any popular GitHub repositories.