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.
21 KiB
21 KiB
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
- Discover endpoints via reflection and attributes
- Support YAML-based metadata overrides
- Generate EndpointDescriptor for router registration
- Support endpoint versioning and deprecation
- Validate endpoint configurations at startup
Endpoint Attributes
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
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
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
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
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
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
# 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
StellaOps.Microservice/Attributes/*.cs(all endpoint attributes)StellaOps.Microservice/EndpointDescriptor.csStellaOps.Microservice/IEndpointDiscovery.csStellaOps.Microservice/ReflectionEndpointDiscovery.csStellaOps.Microservice/IEndpointOverrideProvider.csStellaOps.Microservice/YamlEndpointOverrideProvider.csStellaOps.Microservice/IEndpointRegistry.csStellaOps.Microservice/EndpointRegistry.cs- Attribute parsing tests
- YAML override tests
- Path matching tests
Next Step
Proceed to Step 21: Request/Response Context to implement the request handling context.