dotnet / dotnet/extensions

[API Proposal]: Expose HttpRouteFormatter and HttpRouteParser classes

Open
#6,940 6 comments 1 reaction 1 assignee Claimed by @rainsxng View on GitHub
api-needs-work area-telemetry
Dominant language
C#
Stars
3.2k
Forks
894
Avg merge
1d 12h
Merged PRs (30d)
23

Description

### 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.

```csharp
namespace Microsoft.Extensions.Http.Diagnostics;

///
/// Represents either a literal or a parameter segment in a route template.
///
public readonly struct Segment
{
public Segment(int start, int end, string content, bool isParam,
string paramName = "", string defaultValue = "", bool isCatchAll = false);

/// Start index in normalized template.
public int Start { get; }

/// End index (exclusive) in normalized template.
public int End { get; }

/// Literal text or raw parameter content (without braces).
public string Content { get; }

/// True if this segment is a parameter.
public bool IsParam { get; }

/// Parameter name (empty if not a parameter).
public string ParamName { get; }

/// Default value if provided; otherwise empty.
public string DefaultValue { get; }

/// True if parameter is catch-all (* or **).
public bool IsCatchAll { get; }
}
```

```csharp
namespace Microsoft.Extensions.Http.Diagnostics;

///
/// Immutable parsed representation of a route template.
///
public readonly struct ParsedRouteSegments
{
/// Normalized template (leading slash trimmed if configured).
public string RouteTemplate { get; }

/// Ordered segments (literal + parameters) as a read-only list.
public IReadOnlyList Segments { get; }

/// Total number of parameter segments (including optional and catch-all).
public int ParameterCount { get; }
}
```

```csharp
namespace Microsoft.Extensions.Http.Diagnostics;

///
/// Extracted parameter value plus redaction metadata.
///
public readonly struct HttpRouteParameter
{
public HttpRouteParameter(string name, string value, bool isRedacted);

/// Parameter name.
public string Name { get; }

/// Original or redacted value (placeholder or transformed).
public string Value { get; }

/// True if redaction was applied.
public bool IsRedacted { get; }
}
```

```csharp
namespace Microsoft.Extensions.Http.Diagnostics;

///
/// Parses route templates and extracts (optionally redacted) parameter values from concrete paths.
///
public abstract class HttpRouteParser
{
///
/// Parses a route template into immutable segments (cached per options).
///
public virtual ParsedRouteSegments ParseRoute(string httpRoute);

///
/// Attempts extraction into caller-provided array; false if buffer too small.
///
public virtual bool TryExtractParameters(
string httpPath,
in ParsedRouteSegments routeSegments,
HttpRouteParameterRedactionMode redactionMode,
IReadOnlyDictionary parametersToRedact,
ref HttpRouteParameter[] httpRouteParameters);
}
```

```csharp
namespace Microsoft.Extensions.Http.Diagnostics;

///
/// Formats HTTP request paths using route templates with sensitive parameters optionally redacted.
///
public abstract class HttpRouteFormatter
{
/// Formats the HTTP path using the route template with sensitive parameters redacted.
///
/// HTTP request route template.
/// HTTP request's absolute path.
/// Strategy to decide how parameters are redacted.
/// Dictionary of parameters with their data classification that needs to be redacted.
public virtual string Format(
string httpRoute,
string httpPath,
HttpRouteParameterRedactionMode redactionMode,
IReadOnlyDictionary parametersToRedact);

///
/// Formats the HTTP path using the route template with sensitive parameters redacted.
///
/// HTTP request's route segments.
/// HTTP request's absolute path.
/// Strategy to decide how parameters are redacted.
/// Dictionary of parameters with their data classification that needs to be redacted.
/// Returns formatted path with sensitive parameter values redacted.
public virtual string Format(
in ParsedRouteSegments routeSegments,
string httpPath,
HttpRouteParameterRedactionMode redactionMode,
IReadOnlyDictionary parametersToRedact);
}
```

```csharp

namespace Microsoft.Extensions.Http.Diagnostics;

///
/// Extensions for common telemetry utilities.
///
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.
```csharp
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 { { "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.
```csharp
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 { { "customerId", MyTaxonomy.PrivateId } },
ref buffer);

// buffer[0] now holds current customerId (possibly redacted).
}
```
Catch-all parameter example
```csharp
var segmentsCatchAll = parser.ParseRoute("/files/{*path}");
string formattedFiles = formatter.Format(
"/files/{*path}",
"/files/a/b/c/report.txt",
HttpRouteParameterRedactionMode.Loose,
new Dictionary { { "path", MyTaxonomy.PrivateId } });
// Catch-all redacted in Loose (classification supplied).
```

Format a path directly from template (canonical + redaction).
```csharp
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 { { "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 { { "id", MyTaxonomy.PrivateId } });
// Example result: "api/items/Redacted:42/eu"
```

Formatting when optional parameter omitted: trailing slash normalized.

```csharp
string formattedOmitted = formatter.Format(
httpRoute: "/api/items/{id}/{region?}",
httpPath: "/api/items/42",
redactionMode: HttpRouteParameterRedactionMode.Loose,
parametersToRedact: new Dictionary { { "id", MyTaxonomy.PrivateId } });
// "api/items/Redacted:42"
```

Format plus get parameter values for structured telemetry.
```csharp
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
{
["id"] = MyTaxonomy.PrivateId,
["region"] = DataClassification.None
},
ref buffer);

string path = formatter.Format(
routeSegments,
"/api/items/42/eu",
HttpRouteParameterRedactionMode.Strict,
new Dictionary
{
["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).

```csharp
string metricKey = formatter.Format(
httpRoute: "/orders/{orderId}/lines/{lineId}",
httpPath: "/orders/123/lines/9",
redactionMode: HttpRouteParameterRedactionMode.Strict,
parametersToRedact: new Dictionary());
// Both parameters redacted (no classifications + not well-known) -> "orders/[REDACTED]/lines/[REDACTED]" (placeholder depends on TelemetryConstants.Redacted).
```

```csharp
// Build service collection
var services = new ServiceCollection();

// Prerequisite: register an IRedactorProvider (required by parser/formatter)
services.AddSingleton();

// 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
1. Redaction gaps: misuse of `KnownUnredactableParameters` or mislabeling parameters as `DataClassification.None` in Strict mode can leak sensitive identifiers.
2. 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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.