feat: add stella-callgraph-node for JavaScript/TypeScript call graph extraction
- Implemented a new tool `stella-callgraph-node` that extracts call graphs from JavaScript/TypeScript projects using Babel AST. - Added command-line interface with options for JSON output and help. - Included functionality to analyze project structure, detect functions, and build call graphs. - Created a package.json file for dependency management. feat: introduce stella-callgraph-python for Python call graph extraction - Developed `stella-callgraph-python` to extract call graphs from Python projects using AST analysis. - Implemented command-line interface with options for JSON output and verbose logging. - Added framework detection to identify popular web frameworks and their entry points. - Created an AST analyzer to traverse Python code and extract function definitions and calls. - Included requirements.txt for project dependencies. chore: add framework detection for Python projects - Implemented framework detection logic to identify frameworks like Flask, FastAPI, Django, and others based on project files and import patterns. - Enhanced the AST analyzer to recognize entry points based on decorators and function definitions.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
@@ -12,6 +13,7 @@ public sealed class GeneratedEndpointDiscoveryProvider : IEndpointDiscoveryProvi
|
||||
private readonly StellaMicroserviceOptions _options;
|
||||
private readonly ILogger<GeneratedEndpointDiscoveryProvider> _logger;
|
||||
private readonly ReflectionEndpointDiscoveryProvider _reflectionFallback;
|
||||
private readonly IServiceProviderIsService? _serviceProviderIsService;
|
||||
|
||||
private const string GeneratedProviderTypeName = "StellaOps.Microservice.Generated.GeneratedEndpointProvider";
|
||||
|
||||
@@ -20,11 +22,16 @@ public sealed class GeneratedEndpointDiscoveryProvider : IEndpointDiscoveryProvi
|
||||
/// </summary>
|
||||
public GeneratedEndpointDiscoveryProvider(
|
||||
StellaMicroserviceOptions options,
|
||||
ILogger<GeneratedEndpointDiscoveryProvider> logger)
|
||||
ILogger<GeneratedEndpointDiscoveryProvider> logger,
|
||||
IServiceProviderIsService? serviceProviderIsService = null)
|
||||
{
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
_reflectionFallback = new ReflectionEndpointDiscoveryProvider(options);
|
||||
_serviceProviderIsService = serviceProviderIsService;
|
||||
_reflectionFallback = new ReflectionEndpointDiscoveryProvider(
|
||||
options,
|
||||
assemblies: null,
|
||||
serviceProviderIsService: serviceProviderIsService);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -65,33 +72,38 @@ public sealed class GeneratedEndpointDiscoveryProvider : IEndpointDiscoveryProvi
|
||||
{
|
||||
try
|
||||
{
|
||||
// Look in the entry assembly first
|
||||
var entryAssembly = Assembly.GetEntryAssembly();
|
||||
var providerType = entryAssembly?.GetType(GeneratedProviderTypeName);
|
||||
|
||||
if (providerType != null)
|
||||
static Type? GetProviderType(Assembly? assembly)
|
||||
{
|
||||
return (IGeneratedEndpointProvider)Activator.CreateInstance(providerType)!;
|
||||
if (assembly is null) return null;
|
||||
var type = assembly.GetType(GeneratedProviderTypeName);
|
||||
if (type is null) return null;
|
||||
return typeof(IGeneratedEndpointProvider).IsAssignableFrom(type) ? type : null;
|
||||
}
|
||||
|
||||
// Also check the calling assembly
|
||||
var callingAssembly = Assembly.GetCallingAssembly();
|
||||
providerType = callingAssembly.GetType(GeneratedProviderTypeName);
|
||||
var providerTypes = new List<Type>();
|
||||
|
||||
if (providerType != null)
|
||||
// Look in the entry and calling assemblies first.
|
||||
var entryProviderType = GetProviderType(Assembly.GetEntryAssembly());
|
||||
if (entryProviderType is not null)
|
||||
{
|
||||
return (IGeneratedEndpointProvider)Activator.CreateInstance(providerType)!;
|
||||
providerTypes.Add(entryProviderType);
|
||||
}
|
||||
|
||||
// Check all loaded assemblies
|
||||
var callingProviderType = GetProviderType(Assembly.GetCallingAssembly());
|
||||
if (callingProviderType is not null)
|
||||
{
|
||||
providerTypes.Add(callingProviderType);
|
||||
}
|
||||
|
||||
// Check all loaded assemblies (integration tests may host multiple microservices in one process).
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
providerType = assembly.GetType(GeneratedProviderTypeName);
|
||||
if (providerType != null)
|
||||
var providerType = GetProviderType(assembly);
|
||||
if (providerType is not null)
|
||||
{
|
||||
return (IGeneratedEndpointProvider)Activator.CreateInstance(providerType)!;
|
||||
providerTypes.Add(providerType);
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -99,6 +111,78 @@ public sealed class GeneratedEndpointDiscoveryProvider : IEndpointDiscoveryProvi
|
||||
// Ignore assembly loading errors
|
||||
}
|
||||
}
|
||||
|
||||
providerTypes = providerTypes.Distinct().ToList();
|
||||
if (providerTypes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// If multiple providers exist (e.g., test process hosts several microservices), pick the provider that
|
||||
// best matches the currently-registered handler types in DI.
|
||||
if (_serviceProviderIsService is not null && providerTypes.Count > 1)
|
||||
{
|
||||
IGeneratedEndpointProvider? best = null;
|
||||
var bestScore = 0;
|
||||
|
||||
foreach (var providerType in providerTypes)
|
||||
{
|
||||
IGeneratedEndpointProvider? providerInstance;
|
||||
try
|
||||
{
|
||||
providerInstance =
|
||||
(IGeneratedEndpointProvider?)Activator.CreateInstance(providerType, nonPublic: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (providerInstance is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var score = 0;
|
||||
try
|
||||
{
|
||||
foreach (var handlerType in providerInstance.GetHandlerTypes())
|
||||
{
|
||||
if (_serviceProviderIsService.IsService(handlerType))
|
||||
{
|
||||
score++;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
score = 0;
|
||||
}
|
||||
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
best = providerInstance;
|
||||
}
|
||||
}
|
||||
|
||||
if (best is not null && bestScore > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Selected generated endpoint provider {ProviderType} (matched {MatchCount} handlers)",
|
||||
best.GetType().FullName,
|
||||
bestScore);
|
||||
return best;
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic fallback: choose the provider type with the lowest assembly name.
|
||||
var selectedProviderType = providerTypes
|
||||
.OrderBy(t => t.Assembly.GetName().Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(t => t.FullName, StringComparer.Ordinal)
|
||||
.First();
|
||||
|
||||
return (IGeneratedEndpointProvider)Activator.CreateInstance(selectedProviderType, nonPublic: true)!;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice;
|
||||
@@ -10,16 +11,21 @@ public sealed class ReflectionEndpointDiscoveryProvider : IEndpointDiscoveryProv
|
||||
{
|
||||
private readonly StellaMicroserviceOptions _options;
|
||||
private readonly IEnumerable<Assembly> _assemblies;
|
||||
private readonly IServiceProviderIsService? _serviceProviderIsService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ReflectionEndpointDiscoveryProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">The microservice options.</param>
|
||||
/// <param name="assemblies">The assemblies to scan for endpoints.</param>
|
||||
public ReflectionEndpointDiscoveryProvider(StellaMicroserviceOptions options, IEnumerable<Assembly>? assemblies = null)
|
||||
public ReflectionEndpointDiscoveryProvider(
|
||||
StellaMicroserviceOptions options,
|
||||
IEnumerable<Assembly>? assemblies = null,
|
||||
IServiceProviderIsService? serviceProviderIsService = null)
|
||||
{
|
||||
_options = options;
|
||||
_assemblies = assemblies ?? AppDomain.CurrentDomain.GetAssemblies();
|
||||
_serviceProviderIsService = serviceProviderIsService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -42,6 +48,11 @@ public sealed class ReflectionEndpointDiscoveryProvider : IEndpointDiscoveryProv
|
||||
$"Type {type.FullName} has [StellaEndpoint] but does not implement IStellaEndpoint.");
|
||||
}
|
||||
|
||||
if (_serviceProviderIsService is not null && !_serviceProviderIsService.IsService(type))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var claims = attribute.RequiredClaims
|
||||
.Select(c => new ClaimRequirement { Type = c })
|
||||
.ToList();
|
||||
@@ -54,7 +65,8 @@ public sealed class ReflectionEndpointDiscoveryProvider : IEndpointDiscoveryProv
|
||||
Path = attribute.Path,
|
||||
DefaultTimeout = TimeSpan.FromSeconds(attribute.TimeoutSeconds),
|
||||
SupportsStreaming = attribute.SupportsStreaming,
|
||||
RequiringClaims = claims
|
||||
RequiringClaims = claims,
|
||||
HandlerType = type
|
||||
};
|
||||
|
||||
endpoints.Add(descriptor);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
## Working Directory & Scope
|
||||
- Primary: `src/__Libraries/StellaOps.Router.Gateway`
|
||||
- Allowed tests: `src/__Libraries/__Tests/StellaOps.Router.Gateway.Tests`
|
||||
- Allowed tests: `tests/StellaOps.Router.Gateway.Tests`
|
||||
- Allowed shared dependencies (read/consume): `src/__Libraries/StellaOps.Router.Common`, `src/__Libraries/StellaOps.Router.Config`, `src/__Libraries/StellaOps.Router.Transport.*`
|
||||
- Cross-module edits require a note in the owning sprint’s **Execution Log** and **Decisions & Risks**.
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
## Testing Expectations
|
||||
- Add/modify unit tests for every behavior change.
|
||||
- Prefer unit tests for config parsing, route matching, and limiter logic; keep integration tests behind explicit opt-in when they require Docker/Valkey.
|
||||
- Default command: `dotnet test src/__Libraries/__Tests/StellaOps.Router.Gateway.Tests -c Release`.
|
||||
- Default command: `dotnet test tests/StellaOps.Router.Gateway.Tests/StellaOps.Router.Gateway.Tests.csproj -c Release`.
|
||||
|
||||
## Handoff Notes
|
||||
- Keep this file aligned with router architecture docs and sprint decisions; record updates in sprint **Execution Log**.
|
||||
|
||||
@@ -16,6 +16,10 @@ public static class ApplicationBuilderExtensions
|
||||
/// <returns>The application builder for chaining.</returns>
|
||||
public static IApplicationBuilder UseRouterGateway(this IApplicationBuilder app)
|
||||
{
|
||||
// Request logging and error handling wrap the full router pipeline.
|
||||
app.UseMiddleware<RequestLoggingMiddleware>();
|
||||
app.UseMiddleware<GlobalErrorHandlerMiddleware>();
|
||||
|
||||
// Enforce payload limits first
|
||||
app.UseMiddleware<PayloadLimitsMiddleware>();
|
||||
|
||||
@@ -60,6 +64,10 @@ public static class ApplicationBuilderExtensions
|
||||
/// <returns>The application builder for chaining.</returns>
|
||||
public static IApplicationBuilder UseRouterGatewayCore(this IApplicationBuilder app)
|
||||
{
|
||||
// Request logging and error handling wrap the full router pipeline.
|
||||
app.UseMiddleware<RequestLoggingMiddleware>();
|
||||
app.UseMiddleware<GlobalErrorHandlerMiddleware>();
|
||||
|
||||
// Resolve endpoints from routing state
|
||||
app.UseMiddleware<EndpointResolutionMiddleware>();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Middleware;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Authorization;
|
||||
|
||||
@@ -70,14 +71,19 @@ public sealed class AuthorizationMiddleware
|
||||
required.Type,
|
||||
required.Value ?? "(any)");
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Forbidden",
|
||||
message = "Authorization failed: missing required claim",
|
||||
requiredClaim = new { type = required.Type, value = required.Value }
|
||||
});
|
||||
await RouterErrorWriter.WriteAsync(
|
||||
context,
|
||||
statusCode: StatusCodes.Status403Forbidden,
|
||||
error: "Forbidden",
|
||||
message: "Authorization failed: missing required claim",
|
||||
service: endpoint.ServiceName,
|
||||
version: endpoint.Version,
|
||||
details: new Dictionary<string, object?>
|
||||
{
|
||||
["requiredClaimType"] = required.Type,
|
||||
["requiredClaimValue"] = required.Value
|
||||
},
|
||||
cancellationToken: context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,12 @@ public sealed class RouterNodeConfig
|
||||
/// <summary>
|
||||
/// Configuration section name for binding.
|
||||
/// </summary>
|
||||
public const string SectionName = "Router:Node";
|
||||
public const string SectionName = "GatewayNode";
|
||||
|
||||
/// <summary>
|
||||
/// Legacy configuration section name (deprecated).
|
||||
/// </summary>
|
||||
public const string LegacySectionName = "Router:Node";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the region where this gateway is deployed (e.g., "eu1").
|
||||
|
||||
@@ -25,9 +25,32 @@ public static class RouterServiceCollectionExtensions
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Bind configuration options
|
||||
services.Configure<RouterNodeConfig>(
|
||||
configuration.GetSection(RouterNodeConfig.SectionName));
|
||||
var nodeSection = configuration.GetSection(RouterNodeConfig.SectionName);
|
||||
if (!nodeSection.Exists())
|
||||
{
|
||||
var legacyNodeSection = configuration.GetSection(RouterNodeConfig.LegacySectionName);
|
||||
if (legacyNodeSection.Exists())
|
||||
{
|
||||
nodeSection = legacyNodeSection;
|
||||
}
|
||||
}
|
||||
|
||||
// Bind configuration options (fail-fast validation on startup).
|
||||
services.AddOptions<RouterNodeConfig>()
|
||||
.Bind(nodeSection)
|
||||
.ValidateDataAnnotations()
|
||||
.Validate(
|
||||
config => !string.IsNullOrWhiteSpace(config.Region),
|
||||
$"{RouterNodeConfig.SectionName} (or {RouterNodeConfig.LegacySectionName}):Region is required. Gateway cannot start without a region assignment.")
|
||||
.ValidateOnStart();
|
||||
services.PostConfigure<RouterNodeConfig>(config =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config.NodeId) && !string.IsNullOrWhiteSpace(config.Region))
|
||||
{
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
config.NodeId = $"gw-{config.Region}-{suffix}";
|
||||
}
|
||||
});
|
||||
services.Configure<RoutingOptions>(
|
||||
configuration.GetSection(RoutingOptions.SectionName));
|
||||
services.Configure<HealthOptions>(
|
||||
@@ -75,7 +98,21 @@ public static class RouterServiceCollectionExtensions
|
||||
Action<RoutingOptions>? configureRouting = null)
|
||||
{
|
||||
// Ensure default options are registered even if no configuration action provided
|
||||
services.AddOptions<RouterNodeConfig>();
|
||||
services.AddOptions<RouterNodeConfig>()
|
||||
.ValidateDataAnnotations()
|
||||
.Validate(
|
||||
config => !string.IsNullOrWhiteSpace(config.Region),
|
||||
$"{RouterNodeConfig.SectionName}:Region is required. Gateway cannot start without a region assignment.")
|
||||
.ValidateOnStart();
|
||||
services.PostConfigure<RouterNodeConfig>(config =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config.NodeId) && !string.IsNullOrWhiteSpace(config.Region))
|
||||
{
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
config.NodeId = $"gw-{config.Region}-{suffix}";
|
||||
}
|
||||
});
|
||||
|
||||
services.AddOptions<RoutingOptions>();
|
||||
services.AddOptions<HealthOptions>();
|
||||
services.AddOptions<PayloadLimits>();
|
||||
@@ -111,7 +148,20 @@ public static class RouterServiceCollectionExtensions
|
||||
public static IServiceCollection AddRouterGatewayCore(this IServiceCollection services)
|
||||
{
|
||||
// Register options with defaults
|
||||
services.AddOptions<RouterNodeConfig>();
|
||||
services.AddOptions<RouterNodeConfig>()
|
||||
.ValidateDataAnnotations()
|
||||
.Validate(
|
||||
config => !string.IsNullOrWhiteSpace(config.Region),
|
||||
$"{RouterNodeConfig.SectionName}:Region is required. Gateway cannot start without a region assignment.")
|
||||
.ValidateOnStart();
|
||||
services.PostConfigure<RouterNodeConfig>(config =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config.NodeId) && !string.IsNullOrWhiteSpace(config.Region))
|
||||
{
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
config.NodeId = $"gw-{config.Region}-{suffix}";
|
||||
}
|
||||
});
|
||||
services.AddOptions<RoutingOptions>();
|
||||
services.AddOptions<HealthOptions>();
|
||||
services.AddOptions<PayloadLimits>();
|
||||
|
||||
@@ -28,13 +28,11 @@ public sealed class EndpointResolutionMiddleware
|
||||
var endpoint = routingState.ResolveEndpoint(method, path);
|
||||
if (endpoint is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Endpoint not found",
|
||||
method,
|
||||
path
|
||||
});
|
||||
await RouterErrorWriter.WriteAsync(
|
||||
context,
|
||||
statusCode: StatusCodes.Status404NotFound,
|
||||
error: "Endpoint not found",
|
||||
cancellationToken: context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Catches unhandled exceptions and returns a structured JSON error response.
|
||||
/// </summary>
|
||||
public sealed class GlobalErrorHandlerMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<GlobalErrorHandlerMiddleware> _logger;
|
||||
private readonly IHostEnvironment _environment;
|
||||
|
||||
public GlobalErrorHandlerMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<GlobalErrorHandlerMiddleware> logger,
|
||||
IHostEnvironment environment)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
public async Task Invoke(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Request cancelled by client: {Method} {Path} TraceId={TraceId}",
|
||||
context.Request.Method,
|
||||
context.Request.Path,
|
||||
context.TraceIdentifier);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Unhandled gateway exception: {Method} {Path} TraceId={TraceId}",
|
||||
context.Request.Method,
|
||||
context.Request.Path,
|
||||
context.TraceIdentifier);
|
||||
|
||||
var message = _environment.IsDevelopment() ? ex.Message : null;
|
||||
|
||||
await RouterErrorWriter.WriteAsync(
|
||||
context,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
error: "Internal Server Error",
|
||||
message: message,
|
||||
cancellationToken: context.RequestAborted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,12 +44,16 @@ public sealed class PayloadLimitsMiddleware
|
||||
connectionId);
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Payload Too Large",
|
||||
maxBytes = _limits.MaxRequestBytesPerCall,
|
||||
contentLength = context.Request.ContentLength.Value
|
||||
});
|
||||
await RouterErrorWriter.WriteAsync(
|
||||
context,
|
||||
statusCode: StatusCodes.Status413PayloadTooLarge,
|
||||
error: "Payload Too Large",
|
||||
details: new Dictionary<string, object?>
|
||||
{
|
||||
["maxBytes"] = _limits.MaxRequestBytesPerCall,
|
||||
["contentLength"] = context.Request.ContentLength.Value
|
||||
},
|
||||
cancellationToken: context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,11 +71,12 @@ public sealed class PayloadLimitsMiddleware
|
||||
connectionId);
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Service Overloaded",
|
||||
message = "Too many concurrent requests"
|
||||
});
|
||||
await RouterErrorWriter.WriteAsync(
|
||||
context,
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
error: "Service Overloaded",
|
||||
message: "Too many concurrent requests",
|
||||
cancellationToken: context.RequestAborted);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -83,11 +88,12 @@ public sealed class PayloadLimitsMiddleware
|
||||
_limits.MaxRequestBytesPerConnection);
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Too Many Requests",
|
||||
message = "Per-connection limit exceeded"
|
||||
});
|
||||
await RouterErrorWriter.WriteAsync(
|
||||
context,
|
||||
statusCode: StatusCodes.Status429TooManyRequests,
|
||||
error: "Too Many Requests",
|
||||
message: "Per-connection limit exceeded",
|
||||
cancellationToken: context.RequestAborted);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -139,12 +145,16 @@ public sealed class PayloadLimitsMiddleware
|
||||
if (!context.Response.HasStarted)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Payload Too Large",
|
||||
maxBytes = _limits.MaxRequestBytesPerCall,
|
||||
bytesReceived = ex.BytesRead
|
||||
});
|
||||
await RouterErrorWriter.WriteAsync(
|
||||
context,
|
||||
statusCode: StatusCodes.Status413PayloadTooLarge,
|
||||
error: "Payload Too Large",
|
||||
details: new Dictionary<string, object?>
|
||||
{
|
||||
["maxBytes"] = _limits.MaxRequestBytesPerCall,
|
||||
["bytesReceived"] = ex.BytesRead
|
||||
},
|
||||
cancellationToken: context.RequestAborted);
|
||||
}
|
||||
|
||||
actualBytesRead = ex.BytesRead;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Logs basic request/response information for gateway requests.
|
||||
/// </summary>
|
||||
public sealed class RequestLoggingMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<RequestLoggingMiddleware> _logger;
|
||||
|
||||
public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Invoke(HttpContext context)
|
||||
{
|
||||
var start = Stopwatch.GetTimestamp();
|
||||
|
||||
await _next(context);
|
||||
|
||||
var elapsed = Stopwatch.GetElapsedTime(start);
|
||||
context.Items.TryGetValue(RouterHttpContextKeys.TargetMicroservice, out var targetServiceObj);
|
||||
|
||||
_logger.LogInformation(
|
||||
"HTTP {Method} {Path} -> {StatusCode} in {ElapsedMs}ms TraceId={TraceId} TargetService={TargetService}",
|
||||
context.Request.Method,
|
||||
context.Request.Path + context.Request.QueryString,
|
||||
context.Response.StatusCode,
|
||||
elapsed.TotalMilliseconds,
|
||||
context.TraceIdentifier,
|
||||
targetServiceObj as string ?? "(none)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace StellaOps.Router.Gateway.Middleware;
|
||||
|
||||
internal sealed record RouterErrorResponse
|
||||
{
|
||||
public required string Error { get; init; }
|
||||
public required int Status { get; init; }
|
||||
public required string TraceId { get; init; }
|
||||
public required string Method { get; init; }
|
||||
public required string Path { get; init; }
|
||||
|
||||
public string? Service { get; init; }
|
||||
public string? Version { get; init; }
|
||||
public string? Message { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, object?>? Details { get; init; }
|
||||
}
|
||||
|
||||
internal static class RouterErrorWriter
|
||||
{
|
||||
public static Task WriteAsync(
|
||||
HttpContext context,
|
||||
int statusCode,
|
||||
string error,
|
||||
string? message = null,
|
||||
string? service = null,
|
||||
string? version = null,
|
||||
IReadOnlyDictionary<string, object?>? details = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (context.Response.HasStarted)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = statusCode;
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
|
||||
var response = new RouterErrorResponse
|
||||
{
|
||||
Error = error,
|
||||
Status = statusCode,
|
||||
TraceId = context.TraceIdentifier,
|
||||
Method = context.Request.Method,
|
||||
Path = context.Request.Path + context.Request.QueryString,
|
||||
Message = message,
|
||||
Service = service,
|
||||
Version = version,
|
||||
Details = details
|
||||
};
|
||||
|
||||
return context.Response.WriteAsJsonAsync(response, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,11 @@ public sealed class RoutingDecisionMiddleware
|
||||
var endpoint = context.Items[RouterHttpContextKeys.EndpointDescriptor] as EndpointDescriptor;
|
||||
if (endpoint is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Endpoint descriptor missing" });
|
||||
await RouterErrorWriter.WriteAsync(
|
||||
context,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
error: "Endpoint descriptor missing",
|
||||
cancellationToken: context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,13 +69,13 @@ public sealed class RoutingDecisionMiddleware
|
||||
|
||||
if (decision is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "No instances available",
|
||||
service = endpoint.ServiceName,
|
||||
version = endpoint.Version
|
||||
});
|
||||
await RouterErrorWriter.WriteAsync(
|
||||
context,
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
error: "No instances available",
|
||||
service: endpoint.ServiceName,
|
||||
version: endpoint.Version,
|
||||
cancellationToken: context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,8 +70,11 @@ public sealed class TransportDispatchMiddleware
|
||||
var decision = context.Items[RouterHttpContextKeys.RoutingDecision] as RoutingDecision;
|
||||
if (decision is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Routing decision missing" });
|
||||
await RouterErrorWriter.WriteAsync(
|
||||
context,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
error: "Routing decision missing",
|
||||
cancellationToken: context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -196,12 +199,16 @@ public sealed class TransportDispatchMiddleware
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status504GatewayTimeout;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Upstream timeout",
|
||||
service = decision.Connection.Instance.ServiceName,
|
||||
timeout = decision.EffectiveTimeout.TotalSeconds
|
||||
});
|
||||
await RouterErrorWriter.WriteAsync(
|
||||
context,
|
||||
statusCode: StatusCodes.Status504GatewayTimeout,
|
||||
error: "Upstream timeout",
|
||||
service: decision.Connection.Instance.ServiceName,
|
||||
details: new Dictionary<string, object?>
|
||||
{
|
||||
["timeoutSeconds"] = decision.EffectiveTimeout.TotalSeconds
|
||||
},
|
||||
cancellationToken: context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -219,11 +226,12 @@ public sealed class TransportDispatchMiddleware
|
||||
decision.Connection.Instance.ServiceName);
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Upstream error",
|
||||
message = ex.Message
|
||||
});
|
||||
await RouterErrorWriter.WriteAsync(
|
||||
context,
|
||||
statusCode: StatusCodes.Status502BadGateway,
|
||||
error: "Upstream error",
|
||||
message: ex.Message,
|
||||
cancellationToken: context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -244,7 +252,11 @@ public sealed class TransportDispatchMiddleware
|
||||
requestId);
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Invalid upstream response" });
|
||||
await RouterErrorWriter.WriteAsync(
|
||||
context,
|
||||
statusCode: StatusCodes.Status502BadGateway,
|
||||
error: "Invalid upstream response",
|
||||
cancellationToken: context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -423,12 +435,16 @@ public sealed class TransportDispatchMiddleware
|
||||
if (!responseReceived)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status504GatewayTimeout;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Upstream streaming timeout",
|
||||
service = decision.Connection.Instance.ServiceName,
|
||||
timeout = decision.EffectiveTimeout.TotalSeconds
|
||||
});
|
||||
await RouterErrorWriter.WriteAsync(
|
||||
context,
|
||||
statusCode: StatusCodes.Status504GatewayTimeout,
|
||||
error: "Upstream streaming timeout",
|
||||
service: decision.Connection.Instance.ServiceName,
|
||||
details: new Dictionary<string, object?>
|
||||
{
|
||||
["timeoutSeconds"] = decision.EffectiveTimeout.TotalSeconds
|
||||
},
|
||||
cancellationToken: context.RequestAborted);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -446,11 +462,12 @@ public sealed class TransportDispatchMiddleware
|
||||
if (!responseReceived)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Upstream streaming error",
|
||||
message = ex.Message
|
||||
});
|
||||
await RouterErrorWriter.WriteAsync(
|
||||
context,
|
||||
statusCode: StatusCodes.Status502BadGateway,
|
||||
error: "Upstream streaming error",
|
||||
message: ex.Message,
|
||||
cancellationToken: context.RequestAborted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user