# 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.