Files
git.stella-ops.org/docs/router/20-Step.md
master 75f6942769
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
Add integration tests for migration categories and execution
- 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.
2025-12-04 19:10:54 +02:00

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

  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

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

  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 to implement the request handling context.