[API Proposal]: Expose HttpRouteFormatter and HttpRouteParser classes
Background and motivation
HTTP paths contain dynamic IDs that show up in logs, metrics, traces, cache keys and policy checks, yet those same IDs may be sensitive. Right now callers who want a clean, stable, privacy‑aware version of a request path either pull in the full ASP.NET Core routing stack or write their own fragile parsing and masking code. The existing internal HttpRouteParser and HttpRouteFormatter already solve this efficiently: the parser turns a route template into immutable segments (handling optional, default and catch‑all pieces); the formatter rebuilds the actual path into a canonical form, applying redaction rules using data classifications and a redactor provider.
Making them public lets any telemetry, logging or security component use the exact same canonicalization and redaction logic the library relies on, avoiding duplicate implementations and inconsistent results. It also gives external code structured access to parameter names, default values and omission state without reinventing offset and span calculations. Their sealed, focused design and internal caching are ready for direct consumption; exposing an interface instead would force everyone to copy the parsing details, while opening extensibility by inheritance would add surface area and risk. Public access simply turns a proven internal utility into a shared building block for safe, uniform route handling.
API Proposal
Introducing the options object addresses uncontrolled cache growth by letting you cap or disable template storage, prevents fragmented telemetry by standardizing configurable placeholder and normalization rules, reduces accidental data exposure by letting teams tighten or adjust the always‑unredacted parameter set, makes casing behavior explicit to avoid silent redaction mismatches, and creates an additive extension point so future changes (e.g. constraint handling or alternate placeholders) can be introduced without breaking core method signatures.
namespace Microsoft.Extensions.Http.Diagnostics;
/// <summary>
/// Represents either a literal or a parameter segment in a route template.
/// </summary>
public readonly struct Segment
{
public Segment(int start, int end, string content, bool isParam,
string paramName = "", string defaultValue = "", bool isCatchAll = false);
/// <summary>Start index in normalized template.</summary>
public int Start { get; }
/// <summary>End index (exclusive) in normalized template.</summary>
public int End { get; }
/// <summary>Literal text or raw parameter content (without braces).</summary>
public string Content { get; }
/// <summary>True if this segment is a parameter.</summary>
public bool IsParam { get; }
/// <summary>Parameter name (empty if not a parameter).</summary>
public string ParamName { get; }
/// <summary>Default value if provided; otherwise empty.</summary>
public string DefaultValue { get; }
/// <summary>True if parameter is catch-all (* or **).</summary>
public bool IsCatchAll { get; }
}
namespace Microsoft.Extensions.Http.Diagnostics;
/// <summary>
/// Immutable parsed representation of a route template.
/// </summary>
public readonly struct ParsedRouteSegments
{
/// <summary>Normalized template (leading slash trimmed if configured).</summary>
public string RouteTemplate { get; }
/// <summary>Ordered segments (literal + parameters) as a read-only list.</summary>
public IReadOnlyList<Segment> Segments { get; }
/// <summary>Total number of parameter segments (including optional and catch-all).</summary>
public int ParameterCount { get; }
}
namespace Microsoft.Extensions.Http.Diagnostics;
/// <summary>
/// Extracted parameter value plus redaction metadata.
/// </summary>
public readonly struct HttpRouteParameter
{
public HttpRouteParameter(string name, string value, bool isRedacted);
/// <summary>Parameter name.</summary>
public string Name { get; }
/// <summary>Original or redacted value (placeholder or transformed).</summary>
public string Value { get; }
/// <summary>True if redaction was applied.</summary>
public bool IsRedacted { get; }
}
namespace Microsoft.Extensions.Http.Diagnostics;
/// <summary>
/// Parses route templates and extracts (optionally redacted) parameter values from concrete paths.
/// </summary>
public abstract class HttpRouteParser
{
/// <summary>
/// Parses a route template into immutable segments (cached per options).
/// </summary>
public virtual ParsedRouteSegments ParseRoute(string httpRoute);
/// <summary>
/// Attempts extraction into caller-provided array; false if buffer too small.
/// </summary>
public virtual bool TryExtractParameters(
string httpPath,
in ParsedRouteSegments routeSegments,
HttpRouteParameterRedactionMode redactionMode,
IReadOnlyDictionary<string, DataClassification> parametersToRedact,
ref HttpRouteParameter[] httpRouteParameters);
}
namespace Microsoft.Extensions.Http.Diagnostics;
/// <summary>
/// Formats HTTP request paths using route templates with sensitive parameters optionally redacted.
/// </summary>
public abstract class HttpRouteFormatter
{
/// Formats the HTTP path using the route template with sensitive parameters redacted.
/// </summary>
/// <param name="httpRoute">HTTP request route template.</param>
/// <param name="httpPath">HTTP request's absolute path.</param>
/// <param name="redactionMode">Strategy to decide how parameters are redacted.</param>
/// <param name="parametersToRedact">Dictionary of parameters with their data classification that needs to be redacted.
public virtual string Format(
string httpRoute,
string httpPath,
HttpRouteParameterRedactionMode redactionMode,
IReadOnlyDictionary<string, DataClassification> parametersToRedact);
/// <summary>
/// Formats the HTTP path using the route template with sensitive parameters redacted.
/// </summary>
/// <param name="routeSegments">HTTP request's route segments.</param>
/// <param name="httpPath">HTTP request's absolute path.</param>
/// <param name="redactionMode">Strategy to decide how parameters are redacted.</param>
/// <param name="parametersToRedact">Dictionary of parameters with their data classification that needs to be redacted.</param>
/// <returns>Returns formatted path with sensitive parameter values redacted.</returns>
public virtual string Format(
in ParsedRouteSegments routeSegments,
string httpPath,
HttpRouteParameterRedactionMode redactionMode,
IReadOnlyDictionary<string, DataClassification> parametersToRedact);
}
namespace Microsoft.Extensions.Http.Diagnostics;
/// <summary>
/// Extensions for common telemetry utilities.
/// </summary>
public static class TelemetryCommonExtensions
{
public static IServiceCollection AddHttpRouteProcessor(this IServiceCollection services);
public static IServiceCollection AddHttpRouteParser(this IServiceCollection services);
public static IServiceCollection AddHttpRouteFormatter(this IServiceCollection services);
}
API Usage
Parse and reuse segments, extract parameters without allocation beyond initial array.
var redactorProvider = GetRedactorProvider(); // Your IRedactorProvider
var parser = new HttpRouteParser(redactorProvider);
var segments = parser.ParseRoute("/api/items/{id}/{region?}");
var paramBuffer = new HttpRouteParameter[segments.ParameterCount];
if (parser.TryExtractParameters(
httpPath: "/api/items/42",
routeSegments: segments,
redactionMode: HttpRouteParameterRedactionMode.Strict,
parametersToRedact: new Dictionary<string, DataClassification> { { "id", MyTaxonomy.PrivateId } },
ref paramBuffer))
{
// paramBuffer[0].Name == "id"
// paramBuffer[0].Value == redacted value (e.g. "Redacted:42")
// Optional 'region' omitted: paramBuffer[1].Value == "" (default) or provided default if template had one.
}
Reuse parsed segments across multiple requests for performance.
var customerRoute = parser.ParseRoute("/customers/{customerId}/profile");
HttpRouteParameter[] buffer = new HttpRouteParameter[customerRoute.ParameterCount];
void HandleRequest(string path)
{
parser.TryExtractParameters(
path,
customerRoute,
HttpRouteParameterRedactionMode.Loose,
new Dictionary<string, DataClassification> { { "customerId", MyTaxonomy.PrivateId } },
ref buffer);
// buffer[0] now holds current customerId (possibly redacted).
}
Catch-all parameter example
var segmentsCatchAll = parser.ParseRoute("/files/{*path}");
string formattedFiles = formatter.Format(
"/files/{*path}",
"/files/a/b/c/report.txt",
HttpRouteParameterRedactionMode.Loose,
new Dictionary<string, DataClassification> { { "path", MyTaxonomy.PrivateId } });
// Catch-all redacted in Loose (classification supplied).
Format a path directly from template (canonical + redaction).
var formatter = new HttpRouteFormatter(parser, redactorProvider);
string formattedStrict = formatter.Format(
httpRoute: "/api/items/{id}/{region?}",
httpPath: "/api/items/42/eu",
redactionMode: HttpRouteParameterRedactionMode.Strict,
parametersToRedact: new Dictionary<string, DataClassification> { { "id", MyTaxonomy.PrivateId } });
// Example result: "api/items/Redacted:42/eu"
string formattedLoose = formatter.Format(
httpRoute: "/api/items/{id}/{region?}",
httpPath: "/api/items/42/eu",
redactionMode: HttpRouteParameterRedactionMode.Loose,
parametersToRedact: new Dictionary<string, DataClassification> { { "id", MyTaxonomy.PrivateId } });
// Example result: "api/items/Redacted:42/eu"
Formatting when optional parameter omitted: trailing slash normalized.
string formattedOmitted = formatter.Format(
httpRoute: "/api/items/{id}/{region?}",
httpPath: "/api/items/42",
redactionMode: HttpRouteParameterRedactionMode.Loose,
parametersToRedact: new Dictionary<string, DataClassification> { { "id", MyTaxonomy.PrivateId } });
// "api/items/Redacted:42"
Format plus get parameter values for structured telemetry.
var redactorProvider = GetRedactorProvider();
var parser = new HttpRouteParser(redactorProvider);
var formatter = new HttpRouteFormatter(parser, redactorProvider);
var segments = parser.ParseRoute("/api/items/{id}/{region?}");
var buffer = new HttpRouteParameter[segments.ParameterCount];
bool ok = parser.TryExtractParameters(
httpPath: "/api/items/42/eu",
routeSegments: segments,
redactionMode: HttpRouteParameterRedactionMode.Strict,
parametersToRedact: new Dictionary<string, DataClassification>
{
["id"] = MyTaxonomy.PrivateId,
["region"] = DataClassification.None
},
ref buffer);
string path = formatter.Format(
routeSegments,
"/api/items/42/eu",
HttpRouteParameterRedactionMode.Strict,
new Dictionary<string, DataClassification>
{
["id"] = MyTaxonomy.PrivateId,
["region"] = DataClassification.None
});
// path == "api/items/Redacted:42/eu"
// buffer[0] Name="id", IsRedacted=true
// buffer[1] Name="region", IsRedacted=false
Metrics key (avoid leaking sensitive ids in Strict mode).
string metricKey = formatter.Format(
httpRoute: "/orders/{orderId}/lines/{lineId}",
httpPath: "/orders/123/lines/9",
redactionMode: HttpRouteParameterRedactionMode.Strict,
parametersToRedact: new Dictionary<string, DataClassification>());
// Both parameters redacted (no classifications + not well-known) -> "orders/[REDACTED]/lines/[REDACTED]" (placeholder depends on TelemetryConstants.Redacted).
// Build service collection
var services = new ServiceCollection();
// Prerequisite: register an IRedactorProvider (required by parser/formatter)
services.AddSingleton<IRedactorProvider, MyRedactorProvider>();
// Register both parser and formatter in one shot
services.AddHttpRouteProcessor();
services.AddHttpRouteParser();
services.AddHttpRouteFormatter();
// Build provider
var serviceProvider = services.BuildServiceProvider();
Alternative Designs
No response
Risks
- Redaction gaps: misuse of
KnownUnredactableParametersor mislabeling parameters asDataClassification.Nonein Strict mode can leak sensitive identifiers. - Cache correctness and memory: simplistic size-capped FIFO eviction may evict hot templates causing reparsing churn, or if misconfigured (very large or unbounded) can create memory pressure.