SyntaxCircus.AspNetCore.Common 0.1.8

There is a newer version of this package available.
See the version list below for details.
dotnet add package SyntaxCircus.AspNetCore.Common --version 0.1.8
                    
NuGet\Install-Package SyntaxCircus.AspNetCore.Common -Version 0.1.8
                    
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="SyntaxCircus.AspNetCore.Common" Version="0.1.8" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SyntaxCircus.AspNetCore.Common" Version="0.1.8" />
                    
Directory.Packages.props
<PackageReference Include="SyntaxCircus.AspNetCore.Common" />
                    
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 SyntaxCircus.AspNetCore.Common --version 0.1.8
                    
#r "nuget: SyntaxCircus.AspNetCore.Common, 0.1.8"
                    
#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 SyntaxCircus.AspNetCore.Common@0.1.8
                    
#: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=SyntaxCircus.AspNetCore.Common&version=0.1.8
                    
Install as a Cake Addin
#tool nuget:?package=SyntaxCircus.AspNetCore.Common&version=0.1.8
                    
Install as a Cake Tool

SyntaxCircus.AspNetCore.Common

Build NuGet License: MIT

The small pieces of ASP.NET Core host boilerplate that show up in nearly every project, in one place: 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.

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.01.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).

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 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.
  • net10.0

    • No dependencies.

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.

Version Downloads Last Updated
0.1.12 90 9/7/2026
0.1.11 188 8/29/2026
0.1.10 105 8/28/2026
0.1.9 623 8/21/2026
0.1.8 110 8/21/2026
0.1.7 140 8/19/2026
0.1.6 115 8/18/2026
0.1.5 118 8/18/2026
0.1.4 112 8/18/2026
0.1.3 112 8/17/2026
0.1.2 119 8/17/2026
0.1.1 103 8/16/2026
0.1.0 108 8/16/2026