# 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; /// /// Marks a class as containing Stella endpoints. /// [AttributeUsage(AttributeTargets.Class)] public sealed class StellaEndpointAttribute : Attribute { public string? BasePath { get; set; } public string? Version { get; set; } public string[]? Tags { get; set; } } /// /// Marks a method as a Stella endpoint handler. /// [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; } } /// /// Specifies authentication requirements for an endpoint. /// [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; } } /// /// Specifies rate limiting for an endpoint. /// [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" } /// /// Specifies timeout for an endpoint. /// [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] public sealed class StellaTimeoutAttribute : Attribute { public int TimeoutMs { get; } public StellaTimeoutAttribute(int timeoutMs) { TimeoutMs = timeoutMs; } } /// /// Marks an endpoint as deprecated. /// [AttributeUsage(AttributeTargets.Method)] public sealed class StellaDeprecatedAttribute : Attribute { public string? Message { get; set; } public string? AlternativeEndpoint { get; set; } public string? SunsetDate { get; set; } } /// /// Convenience attributes for common HTTP methods. /// 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; /// /// Describes an endpoint for router registration. /// public sealed class EndpointDescriptor { /// HTTP method (GET, POST, etc.). public required string Method { get; init; } /// Path pattern (may include parameters like {id}). public required string Path { get; init; } /// Unique endpoint name. public string? Name { get; init; } /// Endpoint description for documentation. public string? Description { get; init; } /// API version. public string? Version { get; init; } /// Tags for grouping/filtering. public string[]? Tags { get; init; } /// Whether authentication is required. public bool RequiresAuth { get; init; } = true; /// Required claims for access. public string[]? RequiredClaims { get; init; } /// Authentication policy name. public string? AuthPolicy { get; init; } /// Rate limit configuration. public RateLimitDescriptor? RateLimit { get; init; } /// Request timeout in milliseconds. public int? TimeoutMs { get; init; } /// Deprecation information. public DeprecationDescriptor? Deprecation { get; init; } /// Custom metadata. public Dictionary? 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 { /// /// Discovers endpoints from configured assemblies. /// Task> 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 _logger; public ReflectionEndpointDiscovery( StellaMicroserviceOptions options, ILogger logger) { _config = options.Discovery; _logger = logger; } public Task> DiscoverAsync(CancellationToken cancellationToken) { var endpoints = new List(); var assemblies = GetAssembliesToScan(); foreach (var assembly in assemblies) { foreach (var type in assembly.GetExportedTypes()) { var classAttr = type.GetCustomAttribute(); if (classAttr == null) continue; var classAuth = type.GetCustomAttribute(); var classRateLimit = type.GetCustomAttribute(); var classTimeout = type.GetCustomAttribute(); foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance)) { var routeAttr = method.GetCustomAttribute(); 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>(endpoints); } private IEnumerable 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(); 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() ?? classAuth; var methodRateLimit = method.GetCustomAttribute() ?? classRateLimit; var methodTimeout = method.GetCustomAttribute() ?? classTimeout; var deprecatedAttr = method.GetCustomAttribute(); // 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 { /// /// Applies overrides to discovered endpoints. /// void ApplyOverrides(IList endpoints); } public sealed class YamlEndpointOverrideProvider : IEndpointOverrideProvider { private readonly EndpointDiscoveryConfig _config; private readonly ILogger _logger; private readonly Dictionary _overrides = new(); public YamlEndpointOverrideProvider( StellaMicroserviceOptions options, ILogger 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(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 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? MergeMetadata( Dictionary? original, Dictionary? over) { if (original == null && over == null) return null; var result = new Dictionary(original ?? new()); if (over != null) { foreach (var (key, value) in over) { result[key] = value; } } return result; } } internal class EndpointOverrideConfig { public Dictionary? 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? 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 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 _logger; private IReadOnlyList? _endpoints; private readonly Dictionary _endpointLookup = new(); public EndpointRegistry( IEndpointDiscovery discovery, IEndpointOverrideProvider? overrideProvider, ILogger logger) { _discovery = discovery; _overrideProvider = overrideProvider; _logger = logger; } public async Task 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()) { 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 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 or Task where T can be serialized if (!typeof(Task).IsAssignableFrom(returnType)) { throw new InvalidOperationException( $"Handler {method.Name} must return Task or Task"); } } } ``` --- ## 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.