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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}";
|
||||
|
||||
Reference in New Issue
Block a user