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.
697 lines
21 KiB
Markdown
697 lines
21 KiB
Markdown
# 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.
|