Add unit tests for RabbitMq and Udp transport servers and clients
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented comprehensive unit tests for RabbitMqTransportServer, covering constructor, disposal, connection management, event handlers, and exception handling.
- Added configuration tests for RabbitMqTransportServer to validate SSL, durable queues, auto-recovery, and custom virtual host options.
- Created unit tests for UdpFrameProtocol, including frame parsing and serialization, header size validation, and round-trip data preservation.
- Developed tests for UdpTransportClient, focusing on connection handling, event subscriptions, and exception scenarios.
- Established tests for UdpTransportServer, ensuring proper start/stop behavior, connection state management, and event handling.
- Included tests for UdpTransportOptions to verify default values and modification capabilities.
- Enhanced service registration tests for Udp transport services in the dependency injection container.
This commit is contained in:
master
2025-12-05 19:01:12 +02:00
parent 53508ceccb
commit cc69d332e3
245 changed files with 22440 additions and 27719 deletions

View File

@@ -0,0 +1,216 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Microservice.Validation;
namespace StellaOps.Microservice.Endpoints;
/// <summary>
/// Response model for the schema index endpoint.
/// </summary>
public sealed record SchemaIndexResponse
{
/// <summary>
/// List of endpoints with schema information.
/// </summary>
[JsonPropertyName("endpoints")]
public required IReadOnlyList<EndpointSchemaInfo> Endpoints { get; init; }
}
/// <summary>
/// Information about an endpoint's schema availability.
/// </summary>
public sealed record EndpointSchemaInfo
{
/// <summary>
/// The HTTP method.
/// </summary>
[JsonPropertyName("method")]
public required string Method { get; init; }
/// <summary>
/// The endpoint path.
/// </summary>
[JsonPropertyName("path")]
public required string Path { get; init; }
/// <summary>
/// Whether a request schema is available.
/// </summary>
[JsonPropertyName("hasRequestSchema")]
public bool HasRequestSchema { get; init; }
/// <summary>
/// Whether a response schema is available.
/// </summary>
[JsonPropertyName("hasResponseSchema")]
public bool HasResponseSchema { get; init; }
/// <summary>
/// Link to fetch the request schema.
/// </summary>
[JsonPropertyName("requestSchemaUrl")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RequestSchemaUrl { get; init; }
/// <summary>
/// Link to fetch the response schema.
/// </summary>
[JsonPropertyName("responseSchemaUrl")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ResponseSchemaUrl { get; init; }
}
/// <summary>
/// Endpoint that returns an index of all available schemas.
/// </summary>
[StellaEndpoint("GET", "/.well-known/schemas")]
public sealed class SchemaIndexEndpoint : IStellaEndpoint<SchemaIndexResponse>
{
private readonly ISchemaRegistry _schemaRegistry;
/// <summary>
/// Initializes a new instance of the <see cref="SchemaIndexEndpoint"/> class.
/// </summary>
/// <param name="schemaRegistry">The schema registry.</param>
public SchemaIndexEndpoint(ISchemaRegistry schemaRegistry)
{
_schemaRegistry = schemaRegistry;
}
/// <inheritdoc />
public Task<SchemaIndexResponse> HandleAsync(CancellationToken cancellationToken)
{
var definitions = _schemaRegistry.GetAllSchemas();
var endpoints = definitions.Select(d => new EndpointSchemaInfo
{
Method = d.Method,
Path = d.Path,
HasRequestSchema = d.ValidateRequest && d.RequestSchemaJson is not null,
HasResponseSchema = d.ValidateResponse && d.ResponseSchemaJson is not null,
RequestSchemaUrl = d.ValidateRequest && d.RequestSchemaJson is not null
? $"/.well-known/schemas/{d.Method.ToLowerInvariant()}{d.Path}?direction=request"
: null,
ResponseSchemaUrl = d.ValidateResponse && d.ResponseSchemaJson is not null
? $"/.well-known/schemas/{d.Method.ToLowerInvariant()}{d.Path}?direction=response"
: null
}).ToList();
return Task.FromResult(new SchemaIndexResponse { Endpoints = endpoints });
}
}
/// <summary>
/// Endpoint that returns a specific schema as application/schema+json.
/// Supports ETag and If-None-Match for caching.
/// </summary>
[StellaEndpoint("GET", "/.well-known/schemas/{method}/{*path}")]
public sealed class SchemaDetailEndpoint : IRawStellaEndpoint
{
private readonly ISchemaRegistry _schemaRegistry;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
/// <summary>
/// Initializes a new instance of the <see cref="SchemaDetailEndpoint"/> class.
/// </summary>
/// <param name="schemaRegistry">The schema registry.</param>
public SchemaDetailEndpoint(ISchemaRegistry schemaRegistry)
{
_schemaRegistry = schemaRegistry;
}
/// <inheritdoc />
public Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
{
// Extract method and path from path parameters
if (!context.PathParameters.TryGetValue("method", out var method) ||
!context.PathParameters.TryGetValue("path", out var path))
{
return Task.FromResult(RawResponse.NotFound("Schema not found"));
}
// Normalize method to uppercase
method = method.ToUpperInvariant();
// Ensure path starts with /
if (!path.StartsWith('/'))
{
path = "/" + path;
}
// Get direction from query string (default to request)
var direction = SchemaDirection.Request;
if (context.Headers.TryGetValue("X-Schema-Direction", out var directionHeader) &&
directionHeader is not null)
{
if (directionHeader.Equals("response", StringComparison.OrdinalIgnoreCase))
{
direction = SchemaDirection.Response;
}
}
// Check for query parameter (parsed from path or context)
// For simplicity, check if path contains ?direction=response
if (path.Contains("?direction=response", StringComparison.OrdinalIgnoreCase))
{
direction = SchemaDirection.Response;
path = path.Split('?')[0];
}
else if (path.Contains("?direction=request", StringComparison.OrdinalIgnoreCase))
{
path = path.Split('?')[0];
}
// Check if schema exists
if (!_schemaRegistry.HasSchema(method, path, direction))
{
return Task.FromResult(RawResponse.NotFound($"No {direction.ToString().ToLowerInvariant()} schema found for {method} {path}"));
}
// Get ETag for conditional requests
var etag = _schemaRegistry.GetSchemaETag(method, path, direction);
// Check If-None-Match header
if (etag is not null && context.Headers.TryGetValue("If-None-Match", out var ifNoneMatch))
{
if (ifNoneMatch == etag)
{
var notModifiedHeaders = new HeaderCollection();
notModifiedHeaders.Set("ETag", etag);
notModifiedHeaders.Set("Cache-Control", "public, max-age=3600");
return Task.FromResult(new RawResponse
{
StatusCode = 304,
Headers = notModifiedHeaders,
Body = Stream.Null
});
}
}
// Get schema text
var schemaText = _schemaRegistry.GetSchemaText(method, path, direction);
if (schemaText is null)
{
return Task.FromResult(RawResponse.NotFound("Schema not found"));
}
// Build response
var headers = new HeaderCollection();
headers.Set("Content-Type", "application/schema+json; charset=utf-8");
headers.Set("Cache-Control", "public, max-age=3600");
if (etag is not null)
{
headers.Set("ETag", etag);
}
return Task.FromResult(new RawResponse
{
StatusCode = 200,
Headers = headers,
Body = new MemoryStream(Encoding.UTF8.GetBytes(schemaText))
});
}
}

View File

@@ -22,4 +22,10 @@ public interface IGeneratedEndpointProvider
/// Gets all handler types for endpoint discovery.
/// </summary>
IReadOnlyList<Type> GetHandlerTypes();
/// <summary>
/// Gets the schema definitions for OpenAPI and validation.
/// Keys are schema IDs, values are JSON Schema definitions.
/// </summary>
IReadOnlyDictionary<string, SchemaDefinition> GetSchemaDefinitions();
}

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Microservice.Validation;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Models;
@@ -15,6 +16,8 @@ public sealed class RequestDispatcher
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<RequestDispatcher> _logger;
private readonly JsonSerializerOptions _jsonOptions;
private readonly ISchemaRegistry? _schemaRegistry;
private readonly IRequestSchemaValidator? _schemaValidator;
/// <summary>
/// Initializes a new instance of the <see cref="RequestDispatcher"/> class.
@@ -22,16 +25,22 @@ public sealed class RequestDispatcher
/// <param name="registry">The endpoint registry.</param>
/// <param name="serviceProvider">The service provider for resolving handlers.</param>
/// <param name="logger">The logger.</param>
/// <param name="schemaRegistry">Optional schema registry for validation.</param>
/// <param name="schemaValidator">Optional schema validator.</param>
/// <param name="jsonOptions">Optional JSON serialization options.</param>
public RequestDispatcher(
IEndpointRegistry registry,
IServiceProvider serviceProvider,
ILogger<RequestDispatcher> logger,
ISchemaRegistry? schemaRegistry = null,
IRequestSchemaValidator? schemaValidator = null,
JsonSerializerOptions? jsonOptions = null)
{
_registry = registry;
_serviceProvider = serviceProvider;
_logger = logger;
_schemaRegistry = schemaRegistry;
_schemaValidator = schemaValidator;
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -180,6 +189,60 @@ public sealed class RequestDispatcher
{
try
{
// Schema validation (before deserialization)
if (_schemaRegistry is not null && _schemaValidator is not null &&
_schemaRegistry.HasSchema(context.Method, context.Path, SchemaDirection.Request))
{
if (context.Body == Stream.Null || context.Body.Length == 0)
{
return ValidationProblemDetails.Create(
context.Method,
context.Path,
SchemaDirection.Request,
[new SchemaValidationError("/", "#", "Request body is required", "required")],
context.CorrelationId
).ToRawResponse();
}
context.Body.Position = 0;
JsonDocument doc;
try
{
doc = await JsonDocument.ParseAsync(context.Body, cancellationToken: cancellationToken);
}
catch (JsonException ex)
{
return ValidationProblemDetails.Create(
context.Method,
context.Path,
SchemaDirection.Request,
[new SchemaValidationError("/", "#", $"Invalid JSON: {ex.Message}", "json")],
context.CorrelationId
).ToRawResponse();
}
var schema = _schemaRegistry.GetRequestSchema(context.Method, context.Path);
if (schema is not null && !_schemaValidator.TryValidate(doc, schema, out var errors))
{
_logger.LogInformation(
"Schema validation failed for {Method} {Path}: {ErrorCount} errors",
context.Method,
context.Path,
errors.Count);
return ValidationProblemDetails.Create(
context.Method,
context.Path,
SchemaDirection.Request,
errors,
context.CorrelationId
).ToRawResponse();
}
// Reset stream for deserialization
context.Body.Position = 0;
}
// Deserialize request
object? request;
if (context.Body == Stream.Null || context.Body.Length == 0)

View File

@@ -15,10 +15,13 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
private readonly StellaMicroserviceOptions _options;
private readonly IEndpointDiscoveryProvider _endpointDiscovery;
private readonly IMicroserviceTransport? _microserviceTransport;
private readonly IGeneratedEndpointProvider? _generatedProvider;
private readonly ILogger<RouterConnectionManager> _logger;
private readonly ConcurrentDictionary<string, ConnectionState> _connections = new();
private readonly CancellationTokenSource _cts = new();
private IReadOnlyList<EndpointDescriptor>? _endpoints;
private IReadOnlyDictionary<string, SchemaDefinition>? _schemas;
private ServiceOpenApiInfo? _openApiInfo;
private Task? _heartbeatTask;
private bool _disposed;
private volatile InstanceHealthStatus _currentStatus = InstanceHealthStatus.Healthy;
@@ -35,11 +38,13 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
IOptions<StellaMicroserviceOptions> options,
IEndpointDiscoveryProvider endpointDiscovery,
IMicroserviceTransport? microserviceTransport,
IGeneratedEndpointProvider? generatedProvider,
ILogger<RouterConnectionManager> logger)
{
_options = options.Value;
_endpointDiscovery = endpointDiscovery;
_microserviceTransport = microserviceTransport;
_generatedProvider = generatedProvider;
_logger = logger;
}
@@ -86,6 +91,19 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
_endpoints = _endpointDiscovery.DiscoverEndpoints();
_logger.LogInformation("Discovered {EndpointCount} endpoints", _endpoints.Count);
// Get schema definitions from generated provider
_schemas = _generatedProvider?.GetSchemaDefinitions()
?? new Dictionary<string, SchemaDefinition>();
_logger.LogInformation("Discovered {SchemaCount} schemas", _schemas.Count);
// Build OpenAPI info from options
_openApiInfo = new ServiceOpenApiInfo
{
Title = _options.ServiceName,
Description = _options.ServiceDescription,
Contact = _options.ContactInfo
};
// Connect to each router
foreach (var router in _options.Routers)
{
@@ -148,7 +166,9 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
Instance = instance,
Status = InstanceHealthStatus.Healthy,
LastHeartbeatUtc = DateTime.UtcNow,
TransportType = router.TransportType
TransportType = router.TransportType,
Schemas = _schemas ?? new Dictionary<string, SchemaDefinition>(),
OpenApiInfo = _openApiInfo
};
// Register endpoints

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using StellaOps.Microservice.Validation;
namespace StellaOps.Microservice;
@@ -50,6 +51,9 @@ public static class ServiceCollectionExtensions
// Register request dispatcher
services.TryAddSingleton<RequestDispatcher>();
// Register schema validation services
RegisterSchemaValidationServices(services);
// Register connection manager
services.TryAddSingleton<IRouterConnectionManager, RouterConnectionManager>();
@@ -102,6 +106,9 @@ public static class ServiceCollectionExtensions
// Register request dispatcher
services.TryAddSingleton<RequestDispatcher>();
// Register schema validation services
RegisterSchemaValidationServices(services);
// Register connection manager
services.TryAddSingleton<IRouterConnectionManager, RouterConnectionManager>();
@@ -123,4 +130,43 @@ public static class ServiceCollectionExtensions
services.AddScoped<THandler>();
return services;
}
/// <summary>
/// Registers a generated schema provider for schema validation.
/// </summary>
/// <typeparam name="TProvider">The generated schema provider type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddStellaSchemaProvider<TProvider>(this IServiceCollection services)
where TProvider : class, IGeneratedSchemaProvider
{
services.TryAddSingleton<IGeneratedSchemaProvider, TProvider>();
return services;
}
private static void RegisterSchemaValidationServices(IServiceCollection services)
{
// Try to find and register generated schema provider (if source gen was used)
// The generated provider will be named "GeneratedSchemaProvider" in the namespace
// "StellaOps.Microservice.Generated"
var generatedType = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a =>
{
try { return a.GetTypes(); }
catch { return []; }
})
.FirstOrDefault(t =>
t.Name == "GeneratedSchemaProvider" &&
typeof(IGeneratedSchemaProvider).IsAssignableFrom(t) &&
!t.IsAbstract);
if (generatedType is not null)
{
services.TryAddSingleton(typeof(IGeneratedSchemaProvider), generatedType);
}
// Register validator and registry
services.TryAddSingleton<IRequestSchemaValidator, RequestSchemaValidator>();
services.TryAddSingleton<ISchemaRegistry, SchemaRegistry>();
}
}

View File

@@ -40,6 +40,16 @@ public sealed partial class StellaMicroserviceOptions
/// </summary>
public string? ConfigFilePath { get; set; }
/// <summary>
/// Gets or sets the service description for OpenAPI documentation.
/// </summary>
public string? ServiceDescription { get; set; }
/// <summary>
/// Gets or sets the contact information for OpenAPI documentation.
/// </summary>
public string? ContactInfo { get; set; }
/// <summary>
/// Gets or sets the heartbeat interval.
/// Default: 10 seconds.

View File

@@ -12,6 +12,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="YamlDotNet" Version="13.7.1" />
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />

View File

@@ -0,0 +1,87 @@
namespace StellaOps.Microservice;
/// <summary>
/// Enables JSON Schema validation for this endpoint.
/// Schemas are generated from TRequest/TResponse types at compile time.
/// </summary>
/// <remarks>
/// <para>
/// When applied to an endpoint class that implements <see cref="IStellaEndpoint{TRequest, TResponse}"/>,
/// the source generator will generate a JSON Schema from the request type and validate all incoming
/// requests against it. Invalid requests receive a 422 Unprocessable Entity response with detailed errors.
/// </para>
/// <para>
/// Response validation is opt-in via <see cref="ValidateResponse"/>. This is useful for catching
/// bugs in handlers but adds overhead.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// // Basic usage - validates request only
/// [StellaEndpoint("POST", "/invoices")]
/// [ValidateSchema]
/// public sealed class CreateInvoiceEndpoint : IStellaEndpoint&lt;CreateInvoiceRequest, CreateInvoiceResponse&gt;
///
/// // With response validation
/// [StellaEndpoint("GET", "/invoices/{id}")]
/// [ValidateSchema(ValidateResponse = true)]
/// public sealed class GetInvoiceEndpoint : IStellaEndpoint&lt;GetInvoiceRequest, GetInvoiceResponse&gt;
///
/// // With external schema file
/// [StellaEndpoint("POST", "/orders")]
/// [ValidateSchema(RequestSchemaResource = "Schemas.create-order.json")]
/// public sealed class CreateOrderEndpoint : IStellaEndpoint&lt;CreateOrderRequest, CreateOrderResponse&gt;
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class ValidateSchemaAttribute : Attribute
{
/// <summary>
/// Gets or sets whether to validate request bodies.
/// Default is true.
/// </summary>
public bool ValidateRequest { get; set; } = true;
/// <summary>
/// Gets or sets whether to validate response bodies.
/// Default is false. Enable for debugging or strict contract enforcement.
/// </summary>
public bool ValidateResponse { get; set; } = false;
/// <summary>
/// Gets or sets the embedded resource path to an external request schema file.
/// If null, the schema is auto-generated from the TRequest type.
/// </summary>
/// <example>"Schemas.create-order.json"</example>
public string? RequestSchemaResource { get; set; }
/// <summary>
/// Gets or sets the embedded resource path to an external response schema file.
/// If null, the schema is auto-generated from the TResponse type.
/// </summary>
public string? ResponseSchemaResource { get; set; }
/// <summary>
/// Gets or sets the OpenAPI operation summary.
/// A brief description of what the endpoint does.
/// </summary>
public string? Summary { get; set; }
/// <summary>
/// Gets or sets the OpenAPI operation description.
/// A longer description of the endpoint's behavior.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the OpenAPI tags for this endpoint.
/// Tags are used to group endpoints in documentation.
/// </summary>
public string[]? Tags { get; set; }
/// <summary>
/// Gets or sets whether this endpoint is deprecated.
/// Deprecated endpoints are marked as such in OpenAPI documentation.
/// </summary>
public bool Deprecated { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Microservice.Validation;
/// <summary>
/// Defines the schema information for an endpoint.
/// Generated at compile time by the source generator.
/// </summary>
/// <param name="Method">The HTTP method (GET, POST, etc.).</param>
/// <param name="Path">The endpoint path template.</param>
/// <param name="RequestSchemaJson">The JSON Schema for the request body, or null if not validated.</param>
/// <param name="ResponseSchemaJson">The JSON Schema for the response body, or null if not validated.</param>
/// <param name="ValidateRequest">Whether request validation is enabled.</param>
/// <param name="ValidateResponse">Whether response validation is enabled.</param>
public sealed record EndpointSchemaDefinition(
string Method,
string Path,
string? RequestSchemaJson,
string? ResponseSchemaJson,
bool ValidateRequest,
bool ValidateResponse);

View File

@@ -0,0 +1,14 @@
namespace StellaOps.Microservice.Validation;
/// <summary>
/// Interface implemented by the source-generated schema provider.
/// Provides access to schemas generated at compile time.
/// </summary>
public interface IGeneratedSchemaProvider
{
/// <summary>
/// Gets all endpoint schema definitions generated at compile time.
/// </summary>
/// <returns>A list of all endpoint schema definitions.</returns>
IReadOnlyList<EndpointSchemaDefinition> GetSchemaDefinitions();
}

View File

@@ -0,0 +1,22 @@
using System.Text.Json;
using Json.Schema;
namespace StellaOps.Microservice.Validation;
/// <summary>
/// Validates JSON documents against JSON schemas.
/// </summary>
public interface IRequestSchemaValidator
{
/// <summary>
/// Validates a JSON document against a schema.
/// </summary>
/// <param name="document">The JSON document to validate.</param>
/// <param name="schema">The JSON schema to validate against.</param>
/// <param name="errors">When validation fails, contains the list of errors.</param>
/// <returns>True if the document is valid, false otherwise.</returns>
bool TryValidate(
JsonDocument document,
JsonSchema schema,
out IReadOnlyList<SchemaValidationError> errors);
}

View File

@@ -0,0 +1,59 @@
using Json.Schema;
namespace StellaOps.Microservice.Validation;
/// <summary>
/// Registry for JSON schemas associated with endpoints.
/// Provides compiled schemas for validation and raw text for documentation.
/// </summary>
public interface ISchemaRegistry
{
/// <summary>
/// Gets the compiled JSON schema for an endpoint's request.
/// </summary>
/// <param name="method">The HTTP method.</param>
/// <param name="path">The endpoint path template.</param>
/// <returns>The compiled schema, or null if no schema is registered.</returns>
JsonSchema? GetRequestSchema(string method, string path);
/// <summary>
/// Gets the compiled JSON schema for an endpoint's response.
/// </summary>
/// <param name="method">The HTTP method.</param>
/// <param name="path">The endpoint path template.</param>
/// <returns>The compiled schema, or null if no schema is registered.</returns>
JsonSchema? GetResponseSchema(string method, string path);
/// <summary>
/// Gets the raw schema text for documentation/publication.
/// </summary>
/// <param name="method">The HTTP method.</param>
/// <param name="path">The endpoint path template.</param>
/// <param name="direction">Whether to get request or response schema.</param>
/// <returns>The raw JSON schema text, or null if no schema is registered.</returns>
string? GetSchemaText(string method, string path, SchemaDirection direction);
/// <summary>
/// Gets the ETag for the schema (for HTTP caching).
/// </summary>
/// <param name="method">The HTTP method.</param>
/// <param name="path">The endpoint path template.</param>
/// <param name="direction">Whether to get request or response schema.</param>
/// <returns>The ETag value, or null if no schema is registered.</returns>
string? GetSchemaETag(string method, string path, SchemaDirection direction);
/// <summary>
/// Checks if an endpoint has a schema registered.
/// </summary>
/// <param name="method">The HTTP method.</param>
/// <param name="path">The endpoint path template.</param>
/// <param name="direction">Whether to check request or response schema.</param>
/// <returns>True if a schema is registered.</returns>
bool HasSchema(string method, string path, SchemaDirection direction);
/// <summary>
/// Gets all registered schema definitions.
/// </summary>
/// <returns>All endpoint schema definitions.</returns>
IReadOnlyList<EndpointSchemaDefinition> GetAllSchemas();
}

View File

@@ -0,0 +1,102 @@
using System.Text.Json;
using Json.Schema;
using Microsoft.Extensions.Logging;
namespace StellaOps.Microservice.Validation;
/// <summary>
/// Validates JSON documents against JSON schemas using JsonSchema.Net.
/// Follows the same pattern as Concelier's JsonSchemaValidator.
/// </summary>
public sealed class RequestSchemaValidator : IRequestSchemaValidator
{
private readonly ILogger<RequestSchemaValidator> _logger;
private const int MaxLoggedErrors = 5;
/// <summary>
/// Creates a new request schema validator.
/// </summary>
public RequestSchemaValidator(ILogger<RequestSchemaValidator> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public bool TryValidate(
JsonDocument document,
JsonSchema schema,
out IReadOnlyList<SchemaValidationError> errors)
{
ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(schema);
var result = schema.Evaluate(document.RootElement, new EvaluationOptions
{
OutputFormat = OutputFormat.List,
RequireFormatValidation = true
});
if (result.IsValid)
{
errors = [];
return true;
}
errors = CollectErrors(result);
if (errors.Count == 0)
{
_logger.LogWarning("Schema validation failed with unknown errors");
errors = [new SchemaValidationError("#", "#", "Unknown validation error", "unknown")];
return false;
}
foreach (var violation in errors.Take(MaxLoggedErrors))
{
_logger.LogDebug(
"Schema violation at {InstanceLocation} (keyword: {Keyword}): {Message}",
string.IsNullOrEmpty(violation.InstanceLocation) ? "#" : violation.InstanceLocation,
violation.Keyword,
violation.Message);
}
if (errors.Count > MaxLoggedErrors)
{
_logger.LogDebug("{Count} additional schema violations suppressed", errors.Count - MaxLoggedErrors);
}
return false;
}
private static IReadOnlyList<SchemaValidationError> CollectErrors(EvaluationResults result)
{
var errors = new List<SchemaValidationError>();
Aggregate(result, errors);
return errors;
}
private static void Aggregate(EvaluationResults node, List<SchemaValidationError> errors)
{
if (node.Errors is { Count: > 0 })
{
foreach (var kvp in node.Errors)
{
errors.Add(new SchemaValidationError(
node.InstanceLocation?.ToString() ?? string.Empty,
node.SchemaLocation?.ToString() ?? string.Empty,
kvp.Value,
kvp.Key));
}
}
if (node.Details is null)
{
return;
}
foreach (var child in node.Details)
{
Aggregate(child, errors);
}
}
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Microservice.Validation;
/// <summary>
/// Specifies the direction of schema validation.
/// </summary>
public enum SchemaDirection
{
/// <summary>
/// Validates incoming request bodies.
/// </summary>
Request,
/// <summary>
/// Validates outgoing response bodies.
/// </summary>
Response
}

View File

@@ -0,0 +1,155 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using Json.Schema;
using Microsoft.Extensions.Logging;
namespace StellaOps.Microservice.Validation;
/// <summary>
/// Registry for JSON schemas with caching and ETag support.
/// Follows the RiskProfileSchemaProvider pattern.
/// </summary>
public sealed class SchemaRegistry : ISchemaRegistry
{
private readonly ILogger<SchemaRegistry> _logger;
private readonly IReadOnlyList<EndpointSchemaDefinition> _definitions;
private readonly ConcurrentDictionary<(string Method, string Path, SchemaDirection Direction), SchemaEntry> _cache = new();
/// <summary>
/// Creates a new schema registry.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="generatedProvider">The source-generated schema provider, if available.</param>
public SchemaRegistry(
ILogger<SchemaRegistry> logger,
IGeneratedSchemaProvider? generatedProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_definitions = generatedProvider?.GetSchemaDefinitions() ?? [];
if (_definitions.Count > 0)
{
_logger.LogInformation("Schema registry initialized with {Count} endpoint schemas", _definitions.Count);
}
}
/// <inheritdoc />
public JsonSchema? GetRequestSchema(string method, string path)
{
return GetEntry(method, path, SchemaDirection.Request)?.CompiledSchema;
}
/// <inheritdoc />
public JsonSchema? GetResponseSchema(string method, string path)
{
return GetEntry(method, path, SchemaDirection.Response)?.CompiledSchema;
}
/// <inheritdoc />
public string? GetSchemaText(string method, string path, SchemaDirection direction)
{
return GetEntry(method, path, direction)?.SchemaText;
}
/// <inheritdoc />
public string? GetSchemaETag(string method, string path, SchemaDirection direction)
{
return GetEntry(method, path, direction)?.ETag;
}
/// <inheritdoc />
public bool HasSchema(string method, string path, SchemaDirection direction)
{
var def = FindDefinition(method, path);
if (def is null)
{
return false;
}
return direction == SchemaDirection.Request
? def.ValidateRequest && def.RequestSchemaJson is not null
: def.ValidateResponse && def.ResponseSchemaJson is not null;
}
/// <inheritdoc />
public IReadOnlyList<EndpointSchemaDefinition> GetAllSchemas()
{
return _definitions;
}
private SchemaEntry? GetEntry(string method, string path, SchemaDirection direction)
{
var key = (method.ToUpperInvariant(), path, direction);
return _cache.GetOrAdd(key, k =>
{
var def = FindDefinition(k.Method, k.Path);
if (def is null)
{
return null!;
}
var schemaJson = k.Direction == SchemaDirection.Request
? def.RequestSchemaJson
: def.ResponseSchemaJson;
if (schemaJson is null)
{
return null!;
}
var shouldValidate = k.Direction == SchemaDirection.Request
? def.ValidateRequest
: def.ValidateResponse;
if (!shouldValidate)
{
return null!;
}
try
{
var compiled = JsonSchema.FromText(schemaJson);
var etag = ComputeETag(schemaJson);
_logger.LogDebug(
"Compiled {Direction} schema for {Method} {Path}",
k.Direction,
k.Method,
k.Path);
return new SchemaEntry(compiled, schemaJson, etag);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to compile {Direction} schema for {Method} {Path}",
k.Direction,
k.Method,
k.Path);
return null!;
}
});
}
private EndpointSchemaDefinition? FindDefinition(string method, string path)
{
var normalizedMethod = method.ToUpperInvariant();
return _definitions.FirstOrDefault(d =>
d.Method.Equals(normalizedMethod, StringComparison.OrdinalIgnoreCase) &&
d.Path.Equals(path, StringComparison.Ordinal));
}
private static string ComputeETag(string schemaText)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(schemaText));
return $"\"{Convert.ToHexStringLower(hash)[..16]}\"";
}
private sealed record SchemaEntry(
JsonSchema CompiledSchema,
string SchemaText,
string ETag);
}

View File

@@ -0,0 +1,14 @@
namespace StellaOps.Microservice.Validation;
/// <summary>
/// Represents a single schema validation error.
/// </summary>
/// <param name="InstanceLocation">JSON pointer to the invalid value (e.g., "/amount").</param>
/// <param name="SchemaLocation">JSON pointer to the schema constraint (e.g., "#/properties/amount/minimum").</param>
/// <param name="Message">Human-readable error message.</param>
/// <param name="Keyword">The JSON Schema keyword that failed (e.g., "required", "minimum", "type").</param>
public sealed record SchemaValidationError(
string InstanceLocation,
string SchemaLocation,
string Message,
string Keyword);

View File

@@ -0,0 +1,53 @@
namespace StellaOps.Microservice.Validation;
/// <summary>
/// Exception thrown when request or response body fails schema validation.
/// </summary>
public sealed class SchemaValidationException : Exception
{
/// <summary>
/// Gets the endpoint path where validation failed.
/// </summary>
public string EndpointPath { get; }
/// <summary>
/// Gets the HTTP method of the endpoint.
/// </summary>
public string EndpointMethod { get; }
/// <summary>
/// Gets whether this was request or response validation.
/// </summary>
public SchemaDirection Direction { get; }
/// <summary>
/// Gets the list of validation errors.
/// </summary>
public IReadOnlyList<SchemaValidationError> Errors { get; }
/// <summary>
/// Creates a new schema validation exception.
/// </summary>
public SchemaValidationException(
string endpointMethod,
string endpointPath,
SchemaDirection direction,
IReadOnlyList<SchemaValidationError> errors)
: base(BuildMessage(endpointMethod, endpointPath, direction, errors))
{
EndpointMethod = endpointMethod;
EndpointPath = endpointPath;
Direction = direction;
Errors = errors;
}
private static string BuildMessage(
string method,
string path,
SchemaDirection direction,
IReadOnlyList<SchemaValidationError> errors)
{
var directionText = direction == SchemaDirection.Request ? "request" : "response";
return $"Schema validation failed for {directionText} of {method} {path}: {errors.Count} error(s)";
}
}

View File

@@ -0,0 +1,101 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Microservice.Validation;
/// <summary>
/// RFC 7807 Problem Details for schema validation failures.
/// Returns HTTP 422 Unprocessable Entity.
/// </summary>
public sealed class ValidationProblemDetails
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// A URI reference identifying the problem type.
/// </summary>
[JsonPropertyName("type")]
public string Type { get; init; } = "https://stellaops.io/errors/schema-validation";
/// <summary>
/// A short, human-readable summary of the problem.
/// </summary>
[JsonPropertyName("title")]
public string Title { get; init; } = "Schema Validation Failed";
/// <summary>
/// The HTTP status code (422).
/// </summary>
[JsonPropertyName("status")]
public int Status { get; init; } = 422;
/// <summary>
/// A human-readable explanation specific to this occurrence.
/// </summary>
[JsonPropertyName("detail")]
public string? Detail { get; init; }
/// <summary>
/// A URI reference identifying the specific occurrence (the endpoint path).
/// </summary>
[JsonPropertyName("instance")]
public string? Instance { get; init; }
/// <summary>
/// The trace/correlation ID for distributed tracing.
/// </summary>
[JsonPropertyName("traceId")]
public string? TraceId { get; init; }
/// <summary>
/// The list of validation errors.
/// </summary>
[JsonPropertyName("errors")]
public IReadOnlyList<SchemaValidationError> Errors { get; init; } = [];
/// <summary>
/// Creates a ValidationProblemDetails for schema validation failures.
/// </summary>
/// <param name="method">The HTTP method.</param>
/// <param name="path">The endpoint path.</param>
/// <param name="direction">Request or response validation.</param>
/// <param name="errors">The validation errors.</param>
/// <param name="correlationId">Optional correlation ID.</param>
public static ValidationProblemDetails Create(
string method,
string path,
SchemaDirection direction,
IReadOnlyList<SchemaValidationError> errors,
string? correlationId = null)
{
var directionText = direction == SchemaDirection.Request ? "request" : "response";
return new ValidationProblemDetails
{
Detail = $"{char.ToUpperInvariant(directionText[0])}{directionText[1..]} body failed schema validation for {method} {path}",
Instance = path,
TraceId = correlationId,
Errors = errors
};
}
/// <summary>
/// Converts this problem details to a RawResponse.
/// </summary>
public RawResponse ToRawResponse()
{
var json = JsonSerializer.SerializeToUtf8Bytes(this, SerializerOptions);
var headers = new HeaderCollection();
headers.Set("Content-Type", "application/problem+json; charset=utf-8");
return new RawResponse
{
StatusCode = Status,
Headers = headers,
Body = new MemoryStream(json)
};
}
}