37 KiB
Router · ASP.NET Endpoint Bridge
Status
- Version: 0.2 (revised)
- Last updated: 2025-12-24 (UTC)
- Implementation sprint:
SPRINT_8100_0011_0001
Problem Statement
StellaOps Router routes external HTTP requests (via Gateway) to internal microservices over binary transports (TCP/TLS/Messaging). Most StellaOps microservices are ASP.NET Core WebServices that register routes via standard ASP.NET endpoint registration (controllers or minimal APIs).
Current behavior: StellaOps.Microservice discovers and registers only Router-native endpoints implemented via [StellaEndpoint] handlers. If a service has only ASP.NET endpoints, it either:
- Registers zero Router endpoints (Gateway cannot route to it), or
- Duplicates routes as
[StellaEndpoint]handlers (route drift + duplicated security metadata).
Desired behavior: Treat ASP.NET endpoint registration as the single source of truth and automatically bridge it to Router endpoint registration.
Design Goals
| Goal | Description |
|---|---|
| Single source of truth | ASP.NET endpoint registration defines what the service exposes |
| Full ASP.NET fidelity | Authorization, filters, model binding, and all ASP.NET features work correctly |
| Determinism | Endpoint discovery produces stable ordering and normalized paths |
| Security alignment | Authorization metadata flows to Router RequiringClaims |
| Opt-in integration | Services explicitly enable the bridge via Program.cs |
| Offline-first | No runtime network dependency for discovery/dispatch |
Non-Goals (v0.1)
| Feature | Reason |
|---|---|
| SignalR/WebSocket support | Different protocol semantics |
| gRPC endpoint bridging | Different protocol |
| Streaming request bodies | Router SDK buffering limitation |
| Custom route constraints | Complexity; document as limitation |
| API versioning (header/query) | Complexity; use path-based versioning |
| Automatic schema generation | Depends on source generators; future enhancement |
Architecture Overview
┌─────────────────────────────────────────────────────────────────────┐
│ ASP.NET WebService │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Program.cs │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ builder.Services.AddStellaRouterBridge(options => { │ │ │
│ │ │ options.ServiceName = "scanner"; │ │ │
│ │ │ options.Version = "1.0.0"; │ │ │
│ │ │ }); │ │ │
│ │ │ app.UseStellaRouterBridge(); │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ StellaOps.Microservice.AspNetCore │ │
│ │ ┌─────────────────────┐ ┌─────────────────────────────────┐ │ │
│ │ │ Discovery Provider │ │ Request Dispatcher │ │ │
│ │ │ ─────────────────── │ │ ─────────────────────────────── │ │ │
│ │ │ • EndpointDataSource│ │ • RequestFrame → HttpContext │ │ │
│ │ │ • Metadata extraction│ │ • ASP.NET pipeline execution │ │ │
│ │ │ • Route normalization│ │ • ResponseFrame capture │ │ │
│ │ │ • Auth claim mapping │ │ • DI scope management │ │ │
│ │ └──────────┬──────────┘ └──────────────┬──────────────────┘ │ │
│ │ │ │ │ │
│ │ ▼ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ Router SDK (StellaOps.Microservice) │ │ │
│ │ │ • HELLO registration with discovered endpoints │ │ │
│ │ │ • Request routing to dispatcher │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ ASP.NET Endpoints (unchanged) │ │
│ │ • Minimal APIs: app.MapGet("/api/...", handler) │ │
│ │ • Controllers: [ApiController] with [HttpGet], etc. │ │
│ │ • Route groups: app.MapGroup("/api").MapScannerEndpoints() │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
│ Router Transport (TCP/TLS/Messaging)
▼
┌───────────────────────────────┐
│ StellaOps.Gateway.WebService │
│ • HELLO processing │
│ • Endpoint routing │
│ • OpenAPI aggregation │
└───────────────────────────────┘
Component Design
1. StellaRouterBridgeOptions
Configuration for the bridge, specified in Program.cs:
public sealed class StellaRouterBridgeOptions
{
// === Required: Service Identity ===
public required string ServiceName { get; set; }
public required string Version { get; set; }
public required string Region { get; set; }
public string? InstanceId { get; set; } // Auto-generated if null
// === Authorization Mapping ===
public AuthorizationMappingStrategy AuthorizationMapping { get; set; }
= AuthorizationMappingStrategy.Hybrid;
public MissingAuthorizationBehavior OnMissingAuthorization { get; set; }
= MissingAuthorizationBehavior.RequireExplicit;
// === YAML Overrides ===
public string? YamlConfigPath { get; set; }
// === Metadata Extraction ===
public bool ExtractSchemas { get; set; } = true;
public bool ExtractOpenApiMetadata { get; set; } = true;
// === Route Handling ===
public UnsupportedConstraintBehavior OnUnsupportedConstraint { get; set; }
= UnsupportedConstraintBehavior.WarnAndStrip;
public Func<RouteEndpoint, bool>? EndpointFilter { get; set; }
// === Defaults ===
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
}
public enum AuthorizationMappingStrategy
{
YamlOnly, // Only use YAML overrides
AspNetMetadataOnly, // Only use ASP.NET metadata
Hybrid // ASP.NET + YAML (YAML wins on conflict)
}
public enum MissingAuthorizationBehavior
{
RequireExplicit, // Fail if no auth metadata
AllowAuthenticated, // Allow with empty claims (authenticated required)
WarnAndAllow // Log warning, allow with empty claims
}
public enum UnsupportedConstraintBehavior
{
Fail, // Fail discovery on unsupported constraint
WarnAndStrip, // Log warning, strip constraint
SilentStrip // Strip without warning
}
2. AspNetEndpointDescriptor
Extended endpoint descriptor with full ASP.NET metadata:
public sealed record AspNetEndpointDescriptor
{
// === Core Identity ===
public required string ServiceName { get; init; }
public required string Version { get; init; }
public required string Method { get; init; }
public required string Path { get; init; }
public TimeSpan DefaultTimeout { get; init; } = TimeSpan.FromSeconds(30);
public bool SupportsStreaming { get; init; }
// === Authorization ===
public IReadOnlyList<ClaimRequirement> RequiringClaims { get; init; } = [];
public IReadOnlyList<string> AuthorizationPolicies { get; init; } = [];
public IReadOnlyList<string> Roles { get; init; } = [];
public bool AllowAnonymous { get; init; }
public AuthorizationSource AuthorizationSource { get; init; }
// === Parameters ===
public IReadOnlyList<ParameterDescriptor> Parameters { get; init; } = [];
// === Responses ===
public IReadOnlyList<ResponseDescriptor> Responses { get; init; } = [];
// === OpenAPI ===
public string? OperationId { get; init; }
public string? Summary { get; init; }
public string? Description { get; init; }
public IReadOnlyList<string> Tags { get; init; } = [];
// === Schema ===
public EndpointSchemaInfo? SchemaInfo { get; init; }
// === Internal ===
internal RouteEndpoint? OriginalEndpoint { get; init; }
internal string? OriginalRoutePattern { get; init; }
/// <summary>
/// Convert to standard EndpointDescriptor for HELLO payload.
/// </summary>
public EndpointDescriptor ToEndpointDescriptor() => new()
{
ServiceName = ServiceName,
Version = Version,
Method = Method,
Path = Path,
DefaultTimeout = DefaultTimeout,
SupportsStreaming = SupportsStreaming,
RequiringClaims = RequiringClaims,
SchemaInfo = SchemaInfo
};
}
public sealed record ParameterDescriptor
{
public required string Name { get; init; }
public required ParameterSource Source { get; init; }
public required Type Type { get; init; }
public bool IsRequired { get; init; } = true;
public object? DefaultValue { get; init; }
public string? Description { get; init; }
}
public enum ParameterSource { Route, Query, Header, Body, Services }
public sealed record ResponseDescriptor
{
public required int StatusCode { get; init; }
public Type? ResponseType { get; init; }
public string? Description { get; init; }
public string? ContentType { get; init; } = "application/json";
}
public enum AuthorizationSource { None, AspNetMetadata, YamlOverride, Hybrid }
3. IAspNetEndpointDiscoveryProvider
Discovery provider interface:
public interface IAspNetEndpointDiscoveryProvider : IEndpointDiscoveryProvider
{
/// <summary>
/// Discover ASP.NET endpoints with full metadata.
/// </summary>
IReadOnlyList<AspNetEndpointDescriptor> DiscoverAspNetEndpoints();
}
4. IAuthorizationClaimMapper
Authorization-to-claims mapping interface:
public interface IAuthorizationClaimMapper
{
/// <summary>
/// Map ASP.NET authorization metadata to Router claim requirements.
/// </summary>
Task<AuthorizationMappingResult> MapAsync(
RouteEndpoint endpoint,
CancellationToken cancellationToken = default);
}
public sealed record AuthorizationMappingResult
{
public IReadOnlyList<ClaimRequirement> Claims { get; init; } = [];
public IReadOnlyList<string> Policies { get; init; } = [];
public IReadOnlyList<string> Roles { get; init; } = [];
public bool AllowAnonymous { get; init; }
public AuthorizationSource Source { get; init; }
}
5. IAspNetRouterRequestDispatcher
Request dispatch interface:
public interface IAspNetRouterRequestDispatcher
{
/// <summary>
/// Dispatch a Router request frame through ASP.NET pipeline.
/// </summary>
Task<ResponseFrame> DispatchAsync(
RequestFrame request,
CancellationToken cancellationToken = default);
}
Endpoint Discovery Algorithm
Step 1: Enumerate Endpoints
var endpoints = endpointDataSource.Endpoints
.OfType<RouteEndpoint>()
.Where(e => e.Metadata.GetMetadata<HttpMethodMetadata>() is not null)
.Where(options.EndpointFilter ?? (_ => true));
Step 2: Extract Metadata per Endpoint
For each RouteEndpoint:
- HTTP Method: From
HttpMethodMetadata - Path: Normalize route pattern (see below)
- Authorization: From
IAuthorizeData,IAllowAnonymous - Parameters: From route pattern + parameter binding metadata
- Responses: From
IProducesResponseTypeMetadata - OpenAPI: From
IEndpointNameMetadata,IEndpointSummaryMetadata,ITagsMetadata
Step 3: Normalize Route Pattern
public static string NormalizeRoutePattern(RoutePattern pattern)
{
var raw = pattern.RawText ?? BuildFromSegments(pattern);
// 1. Ensure leading slash
if (!raw.StartsWith('/'))
raw = "/" + raw;
// 2. Strip constraints: {id:int} → {id}
raw = Regex.Replace(raw, @"\{(\*?)([A-Za-z0-9_]+)(:[^}]+)?\}", "{$2}");
// 3. Normalize catch-all: {**path} → {path}
raw = raw.Replace("**", "", StringComparison.Ordinal);
// 4. Remove trailing slash
raw = raw.TrimEnd('/');
// 5. Empty path becomes "/"
return string.IsNullOrEmpty(raw) ? "/" : raw;
}
Step 4: Deterministic Ordering
Sort endpoints for stable HELLO payloads:
var ordered = endpoints
.OrderBy(e => e.Path, StringComparer.OrdinalIgnoreCase)
.ThenBy(e => GetMethodOrder(e.Method))
.ThenBy(e => e.OriginalEndpoint?.DisplayName ?? "");
static int GetMethodOrder(string method) => method.ToUpperInvariant() switch
{
"GET" => 0,
"POST" => 1,
"PUT" => 2,
"PATCH" => 3,
"DELETE" => 4,
"OPTIONS" => 5,
"HEAD" => 6,
_ => 7
};
Authorization Mapping
Mapping Rules
| ASP.NET Metadata | Router Mapping |
|---|---|
[Authorize] (no args) |
Empty RequiringClaims (authenticated required) |
[Authorize(Policy = "X")] |
Resolve policy → claims via IAuthorizationPolicyProvider |
[Authorize(Roles = "A,B")] |
ClaimRequirement(ClaimTypes.Role, "A"), ClaimRequirement(ClaimTypes.Role, "B") |
[AllowAnonymous] |
AllowAnonymous = true, empty claims |
.RequireAuthorization("Policy") |
Same as [Authorize(Policy)] |
Policy Resolution
public async Task<IReadOnlyList<ClaimRequirement>> ResolvePolicyAsync(
string policyName,
IAuthorizationPolicyProvider policyProvider)
{
var policy = await policyProvider.GetPolicyAsync(policyName);
if (policy is null)
return [];
var claims = new List<ClaimRequirement>();
foreach (var requirement in policy.Requirements)
{
switch (requirement)
{
case ClaimsAuthorizationRequirement claimsReq:
foreach (var value in claimsReq.AllowedValues ?? [null])
{
claims.Add(new ClaimRequirement
{
Type = claimsReq.ClaimType,
Value = value
});
}
break;
case RolesAuthorizationRequirement rolesReq:
foreach (var role in rolesReq.AllowedRoles)
{
claims.Add(new ClaimRequirement
{
Type = ClaimTypes.Role,
Value = role
});
}
break;
// Other requirement types: log warning, continue
}
}
return claims;
}
YAML Override Merge
When AuthorizationMappingStrategy.Hybrid:
public IReadOnlyList<ClaimRequirement> MergeWithYaml(
IReadOnlyList<ClaimRequirement> aspNetClaims,
EndpointOverrideConfig? yamlOverride)
{
if (yamlOverride?.RequiringClaims is not { Count: > 0 } yamlClaims)
return aspNetClaims;
// YAML completely replaces ASP.NET claims when specified
return yamlClaims;
}
Request Dispatch
Dispatch Flow
RequestFrame (from Router)
│
▼
┌───────────────────────────────────────┐
│ 1. Create DI Scope │
│ var scope = CreateAsyncScope() │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 2. Build HttpContext │
│ • Method, Path, QueryString │
│ • Headers (including identity) │
│ • Body stream │
│ • RequestServices = scope.Provider │
│ • CancellationToken wiring │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 3. Match Endpoint │
│ Use ASP.NET EndpointSelector │
│ Preserves constraints/precedence │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 4. Populate Identity │
│ Map X-StellaOps-* headers to │
│ ClaimsPrincipal on HttpContext │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 5. Execute RequestDelegate │
│ Runs full ASP.NET pipeline: │
│ • Endpoint filters │
│ • Authorization filters │
│ • Model binding │
│ • Handler execution │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 6. Capture Response │
│ • Status code │
│ • Headers (filtered) │
│ • Body bytes (buffered) │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 7. Dispose Scope │
│ await scope.DisposeAsync() │
└───────────────────────────────────────┘
│
▼
ResponseFrame (to Router)
HttpContext Construction
public async Task<ResponseFrame> DispatchAsync(
RequestFrame request,
CancellationToken cancellationToken)
{
await using var scope = _serviceProvider.CreateAsyncScope();
var httpContext = new DefaultHttpContext
{
RequestServices = scope.ServiceProvider
};
// Link cancellation
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
httpContext.RequestAborted);
httpContext.RequestAborted = linkedCts.Token;
// Populate request
var httpRequest = httpContext.Request;
httpRequest.Method = request.Method;
(httpRequest.Path, httpRequest.QueryString) = ParsePathAndQuery(request.Path);
foreach (var (key, value) in request.Headers)
{
httpRequest.Headers[key] = value;
}
if (request.Body is { Length: > 0 })
{
httpRequest.Body = new MemoryStream(request.Body);
httpRequest.ContentLength = request.Body.Length;
}
// Set trace identifier
httpContext.TraceIdentifier = request.CorrelationId;
// Populate identity from headers
PopulateIdentity(httpContext, request.Headers);
// Match and execute endpoint
var endpoint = await MatchEndpointAsync(httpContext);
if (endpoint is null)
{
return CreateNotFoundResponse(request.CorrelationId);
}
httpContext.SetEndpoint(endpoint);
// Capture response
var responseBody = new MemoryStream();
httpContext.Response.Body = responseBody;
try
{
await endpoint.RequestDelegate!(httpContext);
}
catch (Exception ex)
{
return CreateErrorResponse(request.CorrelationId, ex);
}
// Build response frame
return new ResponseFrame
{
CorrelationId = request.CorrelationId,
StatusCode = httpContext.Response.StatusCode,
Headers = CaptureResponseHeaders(httpContext.Response),
Body = responseBody.ToArray()
};
}
Identity Population
Map Gateway-provided identity headers to ClaimsPrincipal:
private void PopulateIdentity(HttpContext httpContext, IReadOnlyDictionary<string, string> headers)
{
var claims = new List<Claim>();
if (headers.TryGetValue("X-StellaOps-Actor", out var actor) && !string.IsNullOrEmpty(actor))
{
claims.Add(new Claim(ClaimTypes.NameIdentifier, actor));
claims.Add(new Claim(StellaOpsClaimTypes.Subject, actor));
}
if (headers.TryGetValue("X-StellaOps-Tenant", out var tenant) && !string.IsNullOrEmpty(tenant))
{
claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenant));
}
if (headers.TryGetValue("X-StellaOps-Scopes", out var scopes) && !string.IsNullOrEmpty(scopes))
{
foreach (var scope in scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, scope));
}
}
if (claims.Count > 0)
{
var identity = new ClaimsIdentity(claims, "StellaRouter");
httpContext.User = new ClaimsPrincipal(identity);
}
}
Program.cs Integration
Service Registration
public static class StellaRouterBridgeExtensions
{
public static IServiceCollection AddStellaRouterBridge(
this IServiceCollection services,
Action<StellaRouterBridgeOptions> configure)
{
var options = new StellaRouterBridgeOptions
{
ServiceName = "",
Version = "",
Region = ""
};
configure(options);
ValidateOptions(options);
services.AddSingleton(options);
services.AddSingleton<IAuthorizationClaimMapper, DefaultAuthorizationClaimMapper>();
services.AddSingleton<IAspNetEndpointDiscoveryProvider, AspNetCoreEndpointDiscoveryProvider>();
services.AddSingleton<IAspNetRouterRequestDispatcher, AspNetRouterRequestDispatcher>();
// Register as IEndpointDiscoveryProvider for Router SDK integration
services.AddSingleton<IEndpointDiscoveryProvider>(sp =>
sp.GetRequiredService<IAspNetEndpointDiscoveryProvider>());
// Wire into Router SDK
services.AddStellaMicroservice(microserviceOptions =>
{
microserviceOptions.ServiceName = options.ServiceName;
microserviceOptions.Version = options.Version;
microserviceOptions.Region = options.Region;
microserviceOptions.InstanceId = options.InstanceId ?? Guid.NewGuid().ToString();
});
return services;
}
public static IApplicationBuilder UseStellaRouterBridge(this IApplicationBuilder app)
{
// Ensure EndpointDataSource is available (after UseRouting)
var endpointDataSource = app.ApplicationServices
.GetService<EndpointDataSource>()
?? throw new InvalidOperationException(
"UseStellaRouterBridge must be called after UseRouting()");
// Discovery happens on first Router HELLO
// Dispatch is handled by Router SDK
return app;
}
}
YAML Override Format
The existing microservice.yaml format is extended:
microservice:
serviceName: scanner
version: "1.0.0"
region: "${REGION:default}"
endpoints:
# Override by method + path
- method: POST
path: /api/reports
timeoutSeconds: 60
supportsStreaming: false
requiringClaims:
- type: "scanner.reports.read"
# Replaces any ASP.NET-derived claims for this endpoint
# Endpoint with no authorization (explicitly allow authenticated)
- method: GET
path: /api/health
requiringClaims: [] # Empty = authenticated only, no specific claims
# Override specific claim type mapping
- method: DELETE
path: /api/scans/{id}
requiringClaims:
- type: "role"
value: "scanner-admin"
- type: "scanner.scans.delete"
ASP.NET Feature Support Matrix
Fully Supported
| Feature | Discovery | Dispatch | Notes |
|---|---|---|---|
Minimal APIs (MapGet, etc.) |
✓ | ✓ | Primary use case |
Controllers ([ApiController]) |
✓ | ✓ | Full support |
Route groups (MapGroup) |
✓ | ✓ | Path composition |
[Authorize] attribute |
✓ | ✓ | Claims extraction |
[AllowAnonymous] |
✓ | ✓ | Explicit anonymous |
.RequireAuthorization() |
✓ | ✓ | Policy resolution |
[FromBody] binding |
✓ (type) | ✓ | JSON deserialization |
[FromRoute] binding |
✓ | ✓ | Path parameters |
[FromQuery] binding |
✓ | ✓ | Query parameters |
[FromHeader] binding |
✓ | ✓ | Header values |
[FromServices] injection |
N/A | ✓ | DI resolution |
.Produces<T>() |
✓ | N/A | Schema metadata |
.WithName() / .WithSummary() |
✓ | N/A | OpenAPI metadata |
.WithTags() |
✓ | N/A | Grouping |
| Endpoint filters | N/A | ✓ | Filter pipeline |
CancellationToken |
N/A | ✓ | From Router frame |
Route constraints ({id:int}) |
✓ (stripped) | ✓ | ASP.NET matcher |
Catch-all routes ({**path}) |
✓ | ✓ | Normalized |
Not Supported (v0.1)
| Feature | Reason | Workaround |
|---|---|---|
| SignalR hubs | Different protocol | Use native ASP.NET |
| gRPC services | Different protocol | Use native gRPC |
| Streaming request bodies | SDK limitation | Use IRawStellaEndpoint |
| Custom constraints | Complexity | Use standard constraints |
| API versioning (header/query) | Complexity | Path-based versioning |
IFormFile uploads |
Not buffered | Use raw endpoint |
Error Handling
Discovery Errors
| Condition | Behavior | Configuration |
|---|---|---|
| No authorization metadata | Fail discovery | OnMissingAuthorization = RequireExplicit |
| Unsupported constraint | Log warning, strip | OnUnsupportedConstraint = WarnAndStrip |
| Duplicate endpoints | Log warning, keep first | Always |
| Invalid route pattern | Skip endpoint, log error | Always |
Dispatch Errors
| Condition | Response |
|---|---|
| No matching endpoint | 404 Not Found |
| Authorization failure | 403 Forbidden |
| Model binding failure | 400 Bad Request |
| Handler exception | 500 Internal Server Error |
| Cancellation | No response (connection closed) |
Troubleshooting
Common Issues
1. Endpoints Not Discovered
Symptom: Gateway shows 0 endpoints for service, or specific endpoints missing.
Causes & Solutions:
| Cause | Solution |
|---|---|
UseStellaRouterBridge() called before MapControllers() |
Call UseStellaRouterBridge() after all endpoint registration |
Route filtered by EndpointFilter |
Check filter logic, ensure endpoint matches |
Route filtered by IncludePathPatterns |
Verify path pattern includes the endpoint |
Route filtered by ExcludePathPatterns |
Verify endpoint isn't accidentally excluded |
Missing [Authorize] with RequireExplicit |
Add authorization or change MissingAuthorizationBehavior |
Debug:
// Enable discovery logging
builder.Logging.AddFilter("StellaOps.Microservice.AspNetCore", LogLevel.Debug);
2. Authorization Claims Not Extracted
Symptom: Endpoints registered but RequiringClaims is empty when it shouldn't be.
Causes & Solutions:
| Cause | Solution |
|---|---|
[Authorize] without policy/roles |
Add explicit policy or roles |
| Policy not registered | Register policy with AddAuthorization() |
AuthorizationMappingStrategy.YamlOnly set |
Use Hybrid or AspNetMetadataOnly |
| Custom policy doesn't have claim requirements | Use claims-based policies |
Debug:
// Log authorization mapping
var mapper = app.Services.GetRequiredService<IAuthorizationClaimMapper>();
var result = await mapper.MapAsync(endpoint);
Console.WriteLine($"Claims: {string.Join(", ", result.Claims)}");
3. Request Dispatch Fails with 404
Symptom: Gateway routes request but microservice returns 404.
Causes & Solutions:
| Cause | Solution |
|---|---|
| Path parameters not matched | Verify parameter names in route pattern |
| Method mismatch | Verify HTTP method matches endpoint |
| Route constraint rejected value | Use standard constraints that bridge supports |
| Catch-all route not handled | Ensure {**path} is normalized correctly |
Debug:
# Check registered endpoints
curl http://localhost:5000/.well-known/stella-endpoints
4. Model Binding Errors
Symptom: Requests return 400 Bad Request with binding errors.
Causes & Solutions:
| Cause | Solution |
|---|---|
[FromBody] type mismatch |
Verify request body matches expected type |
| Required parameter missing | Include all [FromRoute]/[FromQuery] parameters |
[FromHeader] not populated |
Headers forwarded via X-StellaOps-Header-* |
| Complex type not deserialized | Verify JSON serialization settings |
Debug:
// Enable model binding logging
builder.Logging.AddFilter("Microsoft.AspNetCore.Mvc.ModelBinding", LogLevel.Debug);
5. Identity Not Populated
Symptom: User.Identity is null or claims missing in endpoint handler.
Causes & Solutions:
| Cause | Solution |
|---|---|
| Gateway not forwarding identity | Verify Gateway identity-header-policy configured |
Missing X-StellaOps-UserId header |
Gateway must send identity headers |
UseAuthentication() not called |
Call before UseStellaRouterBridge() |
| Custom claims not mapped | Use ClaimsPrincipalBuilder for custom claims |
Debug:
app.MapGet("/debug/identity", (HttpContext ctx) =>
new {
IsAuthenticated = ctx.User.Identity?.IsAuthenticated,
Name = ctx.User.Identity?.Name,
Claims = ctx.User.Claims.Select(c => new { c.Type, c.Value })
});
6. Performance Issues
Symptom: High latency or memory usage.
Causes & Solutions:
| Cause | Solution |
|---|---|
| HttpContext allocation per request | Pool contexts (internal implementation) |
| Large request bodies buffered | Use streaming endpoints for large payloads |
| Discovery runs too frequently | Discovery runs once at startup; cache is stable |
| Many endpoints slow startup | Discovery is O(n) but runs once |
Metrics to monitor:
stellaops_router_bridge_requests_totalstellaops_router_bridge_request_duration_secondsstellaops_router_bridge_dispatch_errors_total
7. YAML Override Not Applied
Symptom: Claims from YAML file not appearing in endpoint registration.
Causes & Solutions:
| Cause | Solution |
|---|---|
YamlConfigPath not set |
Set options.YamlConfigPath = "router.yaml" |
| File not found | Use absolute path or verify relative path |
| Path pattern doesn't match | YAML paths are case-insensitive, verify pattern |
AuthorizationMappingStrategy.AspNetMetadataOnly |
Use YamlOnly or Hybrid |
Example YAML:
# router.yaml
endpoints:
- path: "/api/admin/**"
requiringClaims:
- type: "Role"
value: "admin"
Diagnostic Endpoints
The bridge adds optional diagnostic endpoints (development only):
| Endpoint | Description |
|---|---|
/.well-known/stella-endpoints |
Lists all discovered endpoints |
/.well-known/stella-bridge-status |
Shows bridge configuration and health |
Enable in development:
builder.Services.AddStellaRouterBridge(options =>
{
options.EnableDiagnosticEndpoints = builder.Environment.IsDevelopment();
// ...
});
Logging Categories
Configure logging for troubleshooting:
{
"Logging": {
"LogLevel": {
"StellaOps.Microservice.AspNetCore.Discovery": "Debug",
"StellaOps.Microservice.AspNetCore.Authorization": "Debug",
"StellaOps.Microservice.AspNetCore.Dispatch": "Information"
}
}
}
Testing Strategy
Unit Tests
- Discovery determinism: Same endpoints → same descriptor order
- Route normalization: Constraints stripped, paths normalized
- Authorization mapping: Policies → claims correctly
- Metadata extraction: All ASP.NET metadata captured
Integration Tests
- Minimal API dispatch: Route parameters, query, body binding
- Controller dispatch: Attribute routing, model binding
- Authorization flow: Claims checked, 403 on failure
- Filter execution: Endpoint filters run correctly
- Error mapping: Exceptions → correct status codes
End-to-End Tests
- HELLO registration: Bridge endpoints appear in Gateway
- Gateway routing: HTTP request → Router → ASP.NET → response
- OpenAPI aggregation: Bridged endpoints in Gateway OpenAPI
Migration Guide
From HTTP-Only Service
// Before: HTTP only
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
await app.RunAsync();
// After: HTTP + Router bridge
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddStellaRouterBridge(options =>
{
options.ServiceName = "myservice";
options.Version = "1.0.0";
options.Region = "default";
});
builder.Services.AddMessagingTransportClient(); // Add transport
var app = builder.Build();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseStellaRouterBridge(); // Enable bridge
app.MapControllers();
await app.RunAsync();
From Dual Registration (HTTP + [StellaEndpoint])
- Remove
[StellaEndpoint]handler classes - Add
AddStellaRouterBridge()configuration - Add
UseStellaRouterBridge()middleware - Add/update
microservice.yamlfor claim overrides - Remove duplicate endpoint registrations
Related Documents
- Router architecture:
docs/modules/router/architecture.md - Migration guide:
docs/modules/router/migration-guide.md - Gateway identity policy:
docs/modules/gateway/identity-header-policy.md - Implementation sprint:
docs/implplan/SPRINT_8100_0011_0001_router_sdk_aspnet_bridge.md