Add Canonical JSON serialization library with tests and documentation

- Implemented CanonJson class for deterministic JSON serialization and hashing.
- Added unit tests for CanonJson functionality, covering various scenarios including key sorting, handling of nested objects, arrays, and special characters.
- Created project files for the Canonical JSON library and its tests, including necessary package references.
- Added README.md for library usage and API reference.
- Introduced RabbitMqIntegrationFactAttribute for conditional RabbitMQ integration tests.
This commit is contained in:
master
2025-12-19 15:35:00 +02:00
parent 43882078a4
commit 951a38d561
192 changed files with 27550 additions and 2611 deletions

View File

@@ -21,6 +21,12 @@ public sealed class RawRequestContext
public IReadOnlyDictionary<string, string> PathParameters { get; init; }
= new Dictionary<string, string>();
/// <summary>
/// Gets the query parameters extracted from the request path.
/// </summary>
public IReadOnlyDictionary<string, string> QueryParameters { get; init; }
= new Dictionary<string, string>();
/// <summary>
/// Gets the request headers.
/// </summary>

View File

@@ -1,3 +1,5 @@
using System.Globalization;
using System.Reflection;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -64,19 +66,21 @@ public sealed class RequestDispatcher
try
{
var (path, queryParameters) = SplitPathAndQuery(request.Path);
// Find matching endpoint
if (!_registry.TryMatch(request.Method, request.Path, out var match) || match is null)
if (!_registry.TryMatch(request.Method, path, out var match) || match is null)
{
_logger.LogWarning(
"No endpoint found for {Method} {Path}",
request.Method,
request.Path);
path);
return CreateErrorResponse(request.RequestId, 404, "Not Found");
}
// Create request context
var context = CreateRequestContext(request, match.PathParameters);
var context = CreateRequestContext(request, path, match.PathParameters, queryParameters, cancellationToken);
// Resolve and invoke handler within a scope
RawResponse response;
@@ -100,7 +104,12 @@ public sealed class RequestDispatcher
}
}
private RawRequestContext CreateRequestContext(RequestFrame request, IReadOnlyDictionary<string, string> pathParameters)
private static RawRequestContext CreateRequestContext(
RequestFrame request,
string path,
IReadOnlyDictionary<string, string> pathParameters,
IReadOnlyDictionary<string, string> queryParameters,
CancellationToken cancellationToken)
{
var headers = new HeaderCollection();
foreach (var (key, value) in request.Headers)
@@ -111,11 +120,12 @@ public sealed class RequestDispatcher
return new RawRequestContext
{
Method = request.Method,
Path = request.Path,
Path = path,
PathParameters = pathParameters,
QueryParameters = queryParameters,
Headers = headers,
Body = new MemoryStream(request.Payload.ToArray()),
CancellationToken = CancellationToken.None, // Will be overridden by caller
CancellationToken = cancellationToken,
CorrelationId = request.CorrelationId
};
}
@@ -243,21 +253,26 @@ public sealed class RequestDispatcher
context.Body.Position = 0;
}
// Deserialize request
// Deserialize request (or bind from query/path params when body is empty).
object? request;
if (context.Body == Stream.Null || context.Body.Length == 0)
{
request = null;
request = CreateRequestFromParameters(requestType, context);
}
else
{
context.Body.Position = 0;
request = await JsonSerializer.DeserializeAsync(context.Body, requestType, _jsonOptions, cancellationToken);
if (request is not null)
{
ApplyParametersToRequestObject(requestType, request, context);
}
}
if (request is null)
{
return RawResponse.BadRequest("Invalid request body");
return RawResponse.BadRequest("Invalid request");
}
// Get HandleAsync method
@@ -324,6 +339,200 @@ public sealed class RequestDispatcher
}
}
private static (string Path, IReadOnlyDictionary<string, string> QueryParameters) SplitPathAndQuery(string path)
{
if (string.IsNullOrEmpty(path))
{
return (path, new Dictionary<string, string>());
}
var idx = path.IndexOf('?', StringComparison.Ordinal);
if (idx < 0)
{
return (path, new Dictionary<string, string>());
}
var basePath = idx == 0 ? "/" : path[..idx];
var queryString = idx == path.Length - 1 ? string.Empty : path[(idx + 1)..];
return (basePath, ParseQueryString(queryString));
}
private static IReadOnlyDictionary<string, string> ParseQueryString(string queryString)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(queryString))
{
return result;
}
foreach (var pair in queryString.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var eq = pair.IndexOf('=', StringComparison.Ordinal);
var rawKey = eq < 0 ? pair : pair[..eq];
var rawValue = eq < 0 ? string.Empty : pair[(eq + 1)..];
var key = Uri.UnescapeDataString(rawKey.Replace('+', ' '));
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
var value = Uri.UnescapeDataString(rawValue.Replace('+', ' '));
result[key] = value;
}
return result;
}
private static object? CreateRequestFromParameters(Type requestType, RawRequestContext context)
{
object? request;
try
{
request = Activator.CreateInstance(requestType);
}
catch
{
return null;
}
if (request is null)
{
return null;
}
ApplyParametersToRequestObject(requestType, request, context);
return request;
}
private static void ApplyParametersToRequestObject(Type requestType, object request, RawRequestContext context)
{
var propertyMap = requestType
.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Where(p => p.SetMethod is not null && p.SetMethod.IsPublic)
.ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase);
ApplyDictionaryToRequestObject(propertyMap, request, context.QueryParameters);
ApplyDictionaryToRequestObject(propertyMap, request, context.PathParameters);
}
private static void ApplyDictionaryToRequestObject(
IReadOnlyDictionary<string, PropertyInfo> propertyMap,
object request,
IReadOnlyDictionary<string, string> parameters)
{
foreach (var (key, value) in parameters)
{
if (!propertyMap.TryGetValue(key, out var property))
{
continue;
}
if (!TryConvertString(value, property.PropertyType, out var converted))
{
continue;
}
property.SetValue(request, converted);
}
}
private static bool TryConvertString(string value, Type targetType, out object? converted)
{
var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType;
if (underlyingType == typeof(string))
{
converted = value;
return true;
}
if (string.IsNullOrEmpty(value))
{
if (Nullable.GetUnderlyingType(targetType) is not null)
{
converted = null;
return true;
}
converted = null;
return false;
}
if (underlyingType == typeof(int) &&
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i))
{
converted = i;
return true;
}
if (underlyingType == typeof(long) &&
long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l))
{
converted = l;
return true;
}
if (underlyingType == typeof(double) &&
double.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var d))
{
converted = d;
return true;
}
if (underlyingType == typeof(decimal) &&
decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var dec))
{
converted = dec;
return true;
}
if (underlyingType == typeof(bool))
{
if (bool.TryParse(value, out var b))
{
converted = b;
return true;
}
if (string.Equals(value, "1", StringComparison.Ordinal))
{
converted = true;
return true;
}
if (string.Equals(value, "0", StringComparison.Ordinal))
{
converted = false;
return true;
}
}
if (underlyingType == typeof(Guid) && Guid.TryParse(value, out var guid))
{
converted = guid;
return true;
}
if (underlyingType.IsEnum)
{
try
{
converted = Enum.Parse(underlyingType, value, ignoreCase: true);
return true;
}
catch
{
// Ignore parse failures.
}
}
converted = null;
return false;
}
private RawResponse SerializeResponse(object? response, Type responseType)
{
var json = JsonSerializer.SerializeToUtf8Bytes(response, responseType, _jsonOptions);

View File

@@ -1,8 +1,10 @@
using System.Collections.Concurrent;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice;
@@ -14,6 +16,7 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
{
private readonly StellaMicroserviceOptions _options;
private readonly IEndpointDiscoveryProvider _endpointDiscovery;
private readonly RequestDispatcher _requestDispatcher;
private readonly IMicroserviceTransport? _microserviceTransport;
private readonly IGeneratedEndpointProvider? _generatedProvider;
private readonly ILogger<RouterConnectionManager> _logger;
@@ -37,12 +40,14 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
public RouterConnectionManager(
IOptions<StellaMicroserviceOptions> options,
IEndpointDiscoveryProvider endpointDiscovery,
RequestDispatcher requestDispatcher,
IMicroserviceTransport? microserviceTransport,
IGeneratedEndpointProvider? generatedProvider,
ILogger<RouterConnectionManager> logger)
ILogger<RouterConnectionManager> logger,
IGeneratedEndpointProvider? generatedProvider = null)
{
_options = options.Value;
_endpointDiscovery = endpointDiscovery;
_requestDispatcher = requestDispatcher;
_microserviceTransport = microserviceTransport;
_generatedProvider = generatedProvider;
_logger = logger;
@@ -91,6 +96,12 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
_endpoints = _endpointDiscovery.DiscoverEndpoints();
_logger.LogInformation("Discovered {EndpointCount} endpoints", _endpoints.Count);
// Wire request handling before transport connect to avoid a race after HELLO.
if (_microserviceTransport is not null)
{
_microserviceTransport.OnRequestReceived += HandleRequestReceivedAsync;
}
// Get schema definitions from generated provider
_schemas = _generatedProvider?.GetSchemaDefinitions()
?? new Dictionary<string, SchemaDefinition>();
@@ -110,6 +121,24 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
await ConnectToRouterAsync(router, cancellationToken);
}
// Establish transport connection to the gateway (InMemory/TCP/RabbitMQ/etc).
if (_microserviceTransport is not null)
{
var instance = new InstanceDescriptor
{
InstanceId = _options.InstanceId,
ServiceName = _options.ServiceName,
Version = _options.Version,
Region = _options.Region
};
await _microserviceTransport.ConnectAsync(instance, _endpoints, cancellationToken);
}
else
{
_logger.LogWarning("No microservice transport configured; skipping transport connection.");
}
// Start heartbeat task
_heartbeatTask = Task.Run(() => HeartbeatLoopAsync(_cts.Token), CancellationToken.None);
}
@@ -121,6 +150,22 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
await _cts.CancelAsync();
if (_microserviceTransport is not null)
{
try
{
await _microserviceTransport.DisconnectAsync();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to disconnect transport");
}
finally
{
_microserviceTransport.OnRequestReceived -= HandleRequestReceivedAsync;
}
}
if (_heartbeatTask is not null)
{
try
@@ -136,6 +181,42 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
_connections.Clear();
}
private async Task<Frame> HandleRequestReceivedAsync(Frame frame, CancellationToken cancellationToken)
{
var request = FrameConverter.ToRequestFrame(frame);
if (request is null)
{
_logger.LogWarning(
"Received invalid request frame: type={FrameType}, correlationId={CorrelationId}",
frame.Type,
frame.CorrelationId ?? "(null)");
var error = new ResponseFrame
{
RequestId = frame.CorrelationId ?? Guid.NewGuid().ToString("N"),
StatusCode = 400,
Headers = new Dictionary<string, string>
{
["Content-Type"] = "text/plain; charset=utf-8"
},
Payload = Encoding.UTF8.GetBytes("Invalid request frame")
};
var errorFrame = FrameConverter.ToFrame(error);
return frame.CorrelationId is null
? errorFrame
: errorFrame with { CorrelationId = frame.CorrelationId };
}
var response = await _requestDispatcher.DispatchAsync(request, cancellationToken);
var responseFrame = FrameConverter.ToFrame(response);
// Ensure correlation ID matches the incoming request for transport-level matching.
return frame.CorrelationId is null
? responseFrame
: responseFrame with { CorrelationId = frame.CorrelationId };
}
private async Task ConnectToRouterAsync(RouterEndpointConfig router, CancellationToken cancellationToken)
{
var connectionId = $"{router.Host}:{router.Port}";