Add unit tests for RabbitMq and Udp transport servers and clients
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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<CreateInvoiceRequest, CreateInvoiceResponse>
|
||||
///
|
||||
/// // With response validation
|
||||
/// [StellaEndpoint("GET", "/invoices/{id}")]
|
||||
/// [ValidateSchema(ValidateResponse = true)]
|
||||
/// public sealed class GetInvoiceEndpoint : IStellaEndpoint<GetInvoiceRequest, GetInvoiceResponse>
|
||||
///
|
||||
/// // With external schema file
|
||||
/// [StellaEndpoint("POST", "/orders")]
|
||||
/// [ValidateSchema(RequestSchemaResource = "Schemas.create-order.json")]
|
||||
/// public sealed class CreateOrderEndpoint : IStellaEndpoint<CreateOrderRequest, CreateOrderResponse>
|
||||
/// </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; }
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)";
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user