Add integration tests for migration categories and execution
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations. - Added tests for edge cases, including null, empty, and whitespace migration names. - Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers. - Included tests for migration execution, schema creation, and handling of pending release migrations. - Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
This commit is contained in:
696
docs/router/20-Step.md
Normal file
696
docs/router/20-Step.md
Normal file
@@ -0,0 +1,696 @@
|
||||
# Step 20: Endpoint Discovery & Registration
|
||||
|
||||
**Phase 5: Microservice SDK**
|
||||
**Estimated Complexity:** Medium
|
||||
**Dependencies:** Step 19 (Microservice Host Builder)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Endpoint discovery automatically finds and registers HTTP endpoints from microservice code using attributes and reflection. YAML configuration provides overrides for metadata like rate limits, authentication requirements, and versioning.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Discover endpoints via reflection and attributes
|
||||
2. Support YAML-based metadata overrides
|
||||
3. Generate EndpointDescriptor for router registration
|
||||
4. Support endpoint versioning and deprecation
|
||||
5. Validate endpoint configurations at startup
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Attributes
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Marks a class as containing Stella endpoints.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class)]
|
||||
public sealed class StellaEndpointAttribute : Attribute
|
||||
{
|
||||
public string? BasePath { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public string[]? Tags { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a method as a Stella endpoint handler.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class StellaRouteAttribute : Attribute
|
||||
{
|
||||
public string Method { get; }
|
||||
public string Path { get; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
|
||||
public StellaRouteAttribute(string method, string path)
|
||||
{
|
||||
Method = method;
|
||||
Path = path;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies authentication requirements for an endpoint.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
||||
public sealed class StellaAuthAttribute : Attribute
|
||||
{
|
||||
public bool Required { get; set; } = true;
|
||||
public string[]? RequiredClaims { get; set; }
|
||||
public string? Policy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies rate limiting for an endpoint.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
||||
public sealed class StellaRateLimitAttribute : Attribute
|
||||
{
|
||||
public int RequestsPerMinute { get; set; }
|
||||
public string? BucketKey { get; set; } // e.g., "sub", "ip", "path"
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies timeout for an endpoint.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
||||
public sealed class StellaTimeoutAttribute : Attribute
|
||||
{
|
||||
public int TimeoutMs { get; }
|
||||
|
||||
public StellaTimeoutAttribute(int timeoutMs)
|
||||
{
|
||||
TimeoutMs = timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks an endpoint as deprecated.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class StellaDeprecatedAttribute : Attribute
|
||||
{
|
||||
public string? Message { get; set; }
|
||||
public string? AlternativeEndpoint { get; set; }
|
||||
public string? SunsetDate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience attributes for common HTTP methods.
|
||||
/// </summary>
|
||||
public sealed class StellaGetAttribute : StellaRouteAttribute
|
||||
{
|
||||
public StellaGetAttribute(string path) : base("GET", path) { }
|
||||
}
|
||||
|
||||
public sealed class StellaPostAttribute : StellaRouteAttribute
|
||||
{
|
||||
public StellaPostAttribute(string path) : base("POST", path) { }
|
||||
}
|
||||
|
||||
public sealed class StellaPutAttribute : StellaRouteAttribute
|
||||
{
|
||||
public StellaPutAttribute(string path) : base("PUT", path) { }
|
||||
}
|
||||
|
||||
public sealed class StellaDeleteAttribute : StellaRouteAttribute
|
||||
{
|
||||
public StellaDeleteAttribute(string path) : base("DELETE", path) { }
|
||||
}
|
||||
|
||||
public sealed class StellaPatchAttribute : StellaRouteAttribute
|
||||
{
|
||||
public StellaPatchAttribute(string path) : base("PATCH", path) { }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Descriptor
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
/// <summary>
|
||||
/// Describes an endpoint for router registration.
|
||||
/// </summary>
|
||||
public sealed class EndpointDescriptor
|
||||
{
|
||||
/// <summary>HTTP method (GET, POST, etc.).</summary>
|
||||
public required string Method { get; init; }
|
||||
|
||||
/// <summary>Path pattern (may include parameters like {id}).</summary>
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>Unique endpoint name.</summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>Endpoint description for documentation.</summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>API version.</summary>
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>Tags for grouping/filtering.</summary>
|
||||
public string[]? Tags { get; init; }
|
||||
|
||||
/// <summary>Whether authentication is required.</summary>
|
||||
public bool RequiresAuth { get; init; } = true;
|
||||
|
||||
/// <summary>Required claims for access.</summary>
|
||||
public string[]? RequiredClaims { get; init; }
|
||||
|
||||
/// <summary>Authentication policy name.</summary>
|
||||
public string? AuthPolicy { get; init; }
|
||||
|
||||
/// <summary>Rate limit configuration.</summary>
|
||||
public RateLimitDescriptor? RateLimit { get; init; }
|
||||
|
||||
/// <summary>Request timeout in milliseconds.</summary>
|
||||
public int? TimeoutMs { get; init; }
|
||||
|
||||
/// <summary>Deprecation information.</summary>
|
||||
public DeprecationDescriptor? Deprecation { get; init; }
|
||||
|
||||
/// <summary>Custom metadata.</summary>
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RateLimitDescriptor
|
||||
{
|
||||
public int RequestsPerMinute { get; init; }
|
||||
public string BucketKey { get; init; } = "sub";
|
||||
}
|
||||
|
||||
public sealed class DeprecationDescriptor
|
||||
{
|
||||
public string? Message { get; init; }
|
||||
public string? AlternativeEndpoint { get; init; }
|
||||
public DateOnly? SunsetDate { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Discovery Interface
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
public interface IEndpointDiscovery
|
||||
{
|
||||
/// <summary>
|
||||
/// Discovers endpoints from configured assemblies.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DiscoveredEndpoint>> DiscoverAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class DiscoveredEndpoint
|
||||
{
|
||||
public required EndpointDescriptor Descriptor { get; init; }
|
||||
public required Type HandlerType { get; init; }
|
||||
public required MethodInfo HandlerMethod { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reflection-Based Discovery
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
public sealed class ReflectionEndpointDiscovery : IEndpointDiscovery
|
||||
{
|
||||
private readonly EndpointDiscoveryConfig _config;
|
||||
private readonly ILogger<ReflectionEndpointDiscovery> _logger;
|
||||
|
||||
public ReflectionEndpointDiscovery(
|
||||
StellaMicroserviceOptions options,
|
||||
ILogger<ReflectionEndpointDiscovery> logger)
|
||||
{
|
||||
_config = options.Discovery;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DiscoveredEndpoint>> DiscoverAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var endpoints = new List<DiscoveredEndpoint>();
|
||||
var assemblies = GetAssembliesToScan();
|
||||
|
||||
foreach (var assembly in assemblies)
|
||||
{
|
||||
foreach (var type in assembly.GetExportedTypes())
|
||||
{
|
||||
var classAttr = type.GetCustomAttribute<StellaEndpointAttribute>();
|
||||
if (classAttr == null)
|
||||
continue;
|
||||
|
||||
var classAuth = type.GetCustomAttribute<StellaAuthAttribute>();
|
||||
var classRateLimit = type.GetCustomAttribute<StellaRateLimitAttribute>();
|
||||
var classTimeout = type.GetCustomAttribute<StellaTimeoutAttribute>();
|
||||
|
||||
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
var routeAttr = method.GetCustomAttribute<StellaRouteAttribute>();
|
||||
if (routeAttr == null)
|
||||
continue;
|
||||
|
||||
var endpoint = BuildEndpoint(
|
||||
type, method, classAttr, routeAttr,
|
||||
classAuth, classRateLimit, classTimeout);
|
||||
|
||||
endpoints.Add(endpoint);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Discovered endpoint: {Method} {Path}",
|
||||
endpoint.Descriptor.Method, endpoint.Descriptor.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Discovered {Count} endpoints", endpoints.Count);
|
||||
return Task.FromResult<IReadOnlyList<DiscoveredEndpoint>>(endpoints);
|
||||
}
|
||||
|
||||
private IEnumerable<Assembly> GetAssembliesToScan()
|
||||
{
|
||||
if (_config.ScanAssemblies.Any())
|
||||
{
|
||||
return _config.ScanAssemblies.Select(Assembly.Load);
|
||||
}
|
||||
|
||||
// Default: scan entry assembly and referenced assemblies
|
||||
var entry = Assembly.GetEntryAssembly();
|
||||
if (entry == null)
|
||||
return Enumerable.Empty<Assembly>();
|
||||
|
||||
return new[] { entry }
|
||||
.Concat(entry.GetReferencedAssemblies().Select(Assembly.Load));
|
||||
}
|
||||
|
||||
private DiscoveredEndpoint BuildEndpoint(
|
||||
Type handlerType,
|
||||
MethodInfo method,
|
||||
StellaEndpointAttribute classAttr,
|
||||
StellaRouteAttribute routeAttr,
|
||||
StellaAuthAttribute? classAuth,
|
||||
StellaRateLimitAttribute? classRateLimit,
|
||||
StellaTimeoutAttribute? classTimeout)
|
||||
{
|
||||
// Method-level attributes override class-level
|
||||
var methodAuth = method.GetCustomAttribute<StellaAuthAttribute>() ?? classAuth;
|
||||
var methodRateLimit = method.GetCustomAttribute<StellaRateLimitAttribute>() ?? classRateLimit;
|
||||
var methodTimeout = method.GetCustomAttribute<StellaTimeoutAttribute>() ?? classTimeout;
|
||||
var deprecatedAttr = method.GetCustomAttribute<StellaDeprecatedAttribute>();
|
||||
|
||||
// Build full path
|
||||
var basePath = classAttr.BasePath?.TrimEnd('/') ?? "";
|
||||
if (!string.IsNullOrEmpty(_config.BasePath))
|
||||
{
|
||||
basePath = _config.BasePath.TrimEnd('/') + basePath;
|
||||
}
|
||||
var fullPath = basePath + "/" + routeAttr.Path.TrimStart('/');
|
||||
|
||||
var descriptor = new EndpointDescriptor
|
||||
{
|
||||
Method = routeAttr.Method,
|
||||
Path = fullPath,
|
||||
Name = routeAttr.Name ?? $"{handlerType.Name}.{method.Name}",
|
||||
Description = routeAttr.Description,
|
||||
Version = classAttr.Version,
|
||||
Tags = classAttr.Tags,
|
||||
RequiresAuth = methodAuth?.Required ?? true,
|
||||
RequiredClaims = methodAuth?.RequiredClaims,
|
||||
AuthPolicy = methodAuth?.Policy,
|
||||
RateLimit = methodRateLimit != null ? new RateLimitDescriptor
|
||||
{
|
||||
RequestsPerMinute = methodRateLimit.RequestsPerMinute,
|
||||
BucketKey = methodRateLimit.BucketKey ?? "sub"
|
||||
} : null,
|
||||
TimeoutMs = methodTimeout?.TimeoutMs,
|
||||
Deprecation = deprecatedAttr != null ? new DeprecationDescriptor
|
||||
{
|
||||
Message = deprecatedAttr.Message,
|
||||
AlternativeEndpoint = deprecatedAttr.AlternativeEndpoint,
|
||||
SunsetDate = DateOnly.TryParse(deprecatedAttr.SunsetDate, out var date) ? date : null
|
||||
} : null
|
||||
};
|
||||
|
||||
return new DiscoveredEndpoint
|
||||
{
|
||||
Descriptor = descriptor,
|
||||
HandlerType = handlerType,
|
||||
HandlerMethod = method
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## YAML Override Provider
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
public interface IEndpointOverrideProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies overrides to discovered endpoints.
|
||||
/// </summary>
|
||||
void ApplyOverrides(IList<DiscoveredEndpoint> endpoints);
|
||||
}
|
||||
|
||||
public sealed class YamlEndpointOverrideProvider : IEndpointOverrideProvider
|
||||
{
|
||||
private readonly EndpointDiscoveryConfig _config;
|
||||
private readonly ILogger<YamlEndpointOverrideProvider> _logger;
|
||||
private readonly Dictionary<string, EndpointOverride> _overrides = new();
|
||||
|
||||
public YamlEndpointOverrideProvider(
|
||||
StellaMicroserviceOptions options,
|
||||
ILogger<YamlEndpointOverrideProvider> logger)
|
||||
{
|
||||
_config = options.Discovery;
|
||||
_logger = logger;
|
||||
|
||||
LoadOverrides();
|
||||
}
|
||||
|
||||
private void LoadOverrides()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_config.ConfigFilePath))
|
||||
return;
|
||||
|
||||
if (!File.Exists(_config.ConfigFilePath))
|
||||
{
|
||||
_logger.LogWarning("Endpoint config file not found: {Path}", _config.ConfigFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
var yaml = File.ReadAllText(_config.ConfigFilePath);
|
||||
var deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.Build();
|
||||
|
||||
var config = deserializer.Deserialize<EndpointOverrideConfig>(yaml);
|
||||
|
||||
if (config?.Endpoints != null)
|
||||
{
|
||||
foreach (var (key, value) in config.Endpoints)
|
||||
{
|
||||
_overrides[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Loaded {Count} endpoint overrides", _overrides.Count);
|
||||
}
|
||||
|
||||
public void ApplyOverrides(IList<DiscoveredEndpoint> endpoints)
|
||||
{
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
var key = $"{endpoint.Descriptor.Method} {endpoint.Descriptor.Path}";
|
||||
|
||||
if (_overrides.TryGetValue(key, out var over) ||
|
||||
_overrides.TryGetValue(endpoint.Descriptor.Path, out over) ||
|
||||
(endpoint.Descriptor.Name != null && _overrides.TryGetValue(endpoint.Descriptor.Name, out over)))
|
||||
{
|
||||
ApplyOverride(endpoint, over);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyOverride(DiscoveredEndpoint endpoint, EndpointOverride over)
|
||||
{
|
||||
// Create new descriptor with overrides applied
|
||||
var original = endpoint.Descriptor;
|
||||
|
||||
var updated = new EndpointDescriptor
|
||||
{
|
||||
Method = original.Method,
|
||||
Path = original.Path,
|
||||
Name = over.Name ?? original.Name,
|
||||
Description = over.Description ?? original.Description,
|
||||
Version = over.Version ?? original.Version,
|
||||
Tags = over.Tags ?? original.Tags,
|
||||
RequiresAuth = over.RequiresAuth ?? original.RequiresAuth,
|
||||
RequiredClaims = over.RequiredClaims ?? original.RequiredClaims,
|
||||
AuthPolicy = over.AuthPolicy ?? original.AuthPolicy,
|
||||
RateLimit = over.RateLimit != null ? new RateLimitDescriptor
|
||||
{
|
||||
RequestsPerMinute = over.RateLimit.RequestsPerMinute,
|
||||
BucketKey = over.RateLimit.BucketKey ?? "sub"
|
||||
} : original.RateLimit,
|
||||
TimeoutMs = over.TimeoutMs ?? original.TimeoutMs,
|
||||
Deprecation = original.Deprecation, // Keep original deprecation
|
||||
Metadata = MergeMetadata(original.Metadata, over.Metadata)
|
||||
};
|
||||
|
||||
// Replace descriptor (need mutable property or rebuild)
|
||||
// In real implementation, use record with 'with' expression
|
||||
_logger.LogDebug("Applied override to endpoint {Path}", original.Path);
|
||||
}
|
||||
|
||||
private Dictionary<string, string>? MergeMetadata(
|
||||
Dictionary<string, string>? original,
|
||||
Dictionary<string, string>? over)
|
||||
{
|
||||
if (original == null && over == null)
|
||||
return null;
|
||||
|
||||
var result = new Dictionary<string, string>(original ?? new());
|
||||
if (over != null)
|
||||
{
|
||||
foreach (var (key, value) in over)
|
||||
{
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
internal class EndpointOverrideConfig
|
||||
{
|
||||
public Dictionary<string, EndpointOverride>? Endpoints { get; set; }
|
||||
}
|
||||
|
||||
internal class EndpointOverride
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public string[]? Tags { get; set; }
|
||||
public bool? RequiresAuth { get; set; }
|
||||
public string[]? RequiredClaims { get; set; }
|
||||
public string? AuthPolicy { get; set; }
|
||||
public RateLimitOverride? RateLimit { get; set; }
|
||||
public int? TimeoutMs { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
}
|
||||
|
||||
internal class RateLimitOverride
|
||||
{
|
||||
public int RequestsPerMinute { get; set; }
|
||||
public string? BucketKey { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Registry
|
||||
|
||||
```csharp
|
||||
namespace StellaOps.Microservice;
|
||||
|
||||
public interface IEndpointRegistry
|
||||
{
|
||||
Task<EndpointDescriptor[]> DiscoverEndpointsAsync(CancellationToken cancellationToken);
|
||||
DiscoveredEndpoint? FindEndpoint(string method, string path);
|
||||
}
|
||||
|
||||
public sealed class EndpointRegistry : IEndpointRegistry
|
||||
{
|
||||
private readonly IEndpointDiscovery _discovery;
|
||||
private readonly IEndpointOverrideProvider? _overrideProvider;
|
||||
private readonly ILogger<EndpointRegistry> _logger;
|
||||
private IReadOnlyList<DiscoveredEndpoint>? _endpoints;
|
||||
private readonly Dictionary<string, DiscoveredEndpoint> _endpointLookup = new();
|
||||
|
||||
public EndpointRegistry(
|
||||
IEndpointDiscovery discovery,
|
||||
IEndpointOverrideProvider? overrideProvider,
|
||||
ILogger<EndpointRegistry> logger)
|
||||
{
|
||||
_discovery = discovery;
|
||||
_overrideProvider = overrideProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<EndpointDescriptor[]> DiscoverEndpointsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_endpoints = await _discovery.DiscoverAsync(cancellationToken);
|
||||
|
||||
if (_overrideProvider != null)
|
||||
{
|
||||
var mutableList = _endpoints.ToList();
|
||||
_overrideProvider.ApplyOverrides(mutableList);
|
||||
_endpoints = mutableList;
|
||||
}
|
||||
|
||||
// Build lookup table
|
||||
_endpointLookup.Clear();
|
||||
foreach (var endpoint in _endpoints)
|
||||
{
|
||||
var key = $"{endpoint.Descriptor.Method}:{endpoint.Descriptor.Path}";
|
||||
_endpointLookup[key] = endpoint;
|
||||
}
|
||||
|
||||
// Validate endpoints
|
||||
ValidateEndpoints(_endpoints);
|
||||
|
||||
return _endpoints.Select(e => e.Descriptor).ToArray();
|
||||
}
|
||||
|
||||
public DiscoveredEndpoint? FindEndpoint(string method, string path)
|
||||
{
|
||||
// Exact match
|
||||
var key = $"{method}:{path}";
|
||||
if (_endpointLookup.TryGetValue(key, out var endpoint))
|
||||
return endpoint;
|
||||
|
||||
// Pattern match for path parameters
|
||||
foreach (var ep in _endpoints ?? Enumerable.Empty<DiscoveredEndpoint>())
|
||||
{
|
||||
if (ep.Descriptor.Method != method)
|
||||
continue;
|
||||
|
||||
if (IsPathMatch(path, ep.Descriptor.Path))
|
||||
return ep;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool IsPathMatch(string requestPath, string pattern)
|
||||
{
|
||||
var patternSegments = pattern.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var pathSegments = requestPath.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (patternSegments.Length != pathSegments.Length)
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < patternSegments.Length; i++)
|
||||
{
|
||||
var patternSeg = patternSegments[i];
|
||||
var pathSeg = pathSegments[i];
|
||||
|
||||
// Check for path parameter
|
||||
if (patternSeg.StartsWith('{') && patternSeg.EndsWith('}'))
|
||||
continue;
|
||||
|
||||
if (!string.Equals(patternSeg, pathSeg, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ValidateEndpoints(IReadOnlyList<DiscoveredEndpoint> endpoints)
|
||||
{
|
||||
var duplicates = endpoints
|
||||
.GroupBy(e => $"{e.Descriptor.Method}:{e.Descriptor.Path}")
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.Key)
|
||||
.ToList();
|
||||
|
||||
if (duplicates.Any())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Duplicate endpoints detected: {string.Join(", ", duplicates)}");
|
||||
}
|
||||
|
||||
// Validate handler method signatures
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
ValidateHandlerMethod(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateHandlerMethod(DiscoveredEndpoint endpoint)
|
||||
{
|
||||
var method = endpoint.HandlerMethod;
|
||||
var returnType = method.ReturnType;
|
||||
|
||||
// Must return Task<ResponsePayload> or Task<T> where T can be serialized
|
||||
if (!typeof(Task).IsAssignableFrom(returnType))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Handler {method.Name} must return Task or Task<T>");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## YAML Configuration Example
|
||||
|
||||
```yaml
|
||||
# endpoints.yaml - Endpoint overrides
|
||||
|
||||
Endpoints:
|
||||
# Override by path
|
||||
"GET /billing/invoices":
|
||||
RateLimit:
|
||||
RequestsPerMinute: 100
|
||||
BucketKey: "sub"
|
||||
TimeoutMs: 30000
|
||||
|
||||
# Override by name
|
||||
"InvoiceHandler.GetInvoice":
|
||||
RequiredClaims:
|
||||
- "billing:read"
|
||||
AuthPolicy: "billing-read"
|
||||
|
||||
# Override by method + path
|
||||
"POST /billing/invoices":
|
||||
RequiredClaims:
|
||||
- "billing:write"
|
||||
RateLimit:
|
||||
RequestsPerMinute: 10
|
||||
BucketKey: "sub"
|
||||
Metadata:
|
||||
audit: "required"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. `StellaOps.Microservice/Attributes/*.cs` (all endpoint attributes)
|
||||
2. `StellaOps.Microservice/EndpointDescriptor.cs`
|
||||
3. `StellaOps.Microservice/IEndpointDiscovery.cs`
|
||||
4. `StellaOps.Microservice/ReflectionEndpointDiscovery.cs`
|
||||
5. `StellaOps.Microservice/IEndpointOverrideProvider.cs`
|
||||
6. `StellaOps.Microservice/YamlEndpointOverrideProvider.cs`
|
||||
7. `StellaOps.Microservice/IEndpointRegistry.cs`
|
||||
8. `StellaOps.Microservice/EndpointRegistry.cs`
|
||||
9. Attribute parsing tests
|
||||
10. YAML override tests
|
||||
11. Path matching tests
|
||||
|
||||
---
|
||||
|
||||
## Next Step
|
||||
|
||||
Proceed to [Step 21: Request/Response Context](21-Step.md) to implement the request handling context.
|
||||
Reference in New Issue
Block a user