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

@@ -0,0 +1,263 @@
using System.Text;
using System.Text.Json;
using Xunit;
namespace StellaOps.Canonical.Json.Tests;
public class CanonJsonTests
{
[Fact]
public void Canonicalize_SameInput_ProducesSameHash()
{
var obj = new { foo = "bar", baz = 42, nested = new { x = 1, y = 2 } };
var bytes1 = CanonJson.Canonicalize(obj);
var bytes2 = CanonJson.Canonicalize(obj);
Assert.Equal(bytes1, bytes2);
Assert.Equal(CanonJson.Sha256Hex(bytes1), CanonJson.Sha256Hex(bytes2));
}
[Fact]
public void Canonicalize_SortsKeysAlphabetically()
{
var obj = new { z = 3, a = 1, m = 2 };
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
// Keys should be ordered: a, m, z
Assert.Matches(@"\{""a"":1,""m"":2,""z"":3\}", json);
}
[Fact]
public void Canonicalize_HandlesNestedObjects()
{
var obj = new { outer = new { z = 9, a = 1 }, inner = new { b = 2 } };
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
// Nested keys also sorted
Assert.Contains(@"""inner"":{""b"":2}", json);
Assert.Contains(@"""outer"":{""a"":1,""z"":9}", json);
}
[Fact]
public void Canonicalize_HandlesArrays()
{
var obj = new { items = new[] { 3, 1, 2 } };
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
// Array order preserved (not sorted)
Assert.Contains(@"""items"":[3,1,2]", json);
}
[Fact]
public void Canonicalize_HandlesNullValues()
{
var obj = new { name = "test", value = (string?)null };
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
Assert.Contains(@"""value"":null", json);
}
[Fact]
public void Canonicalize_HandlesBooleans()
{
var obj = new { enabled = true, disabled = false };
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
Assert.Contains(@"""disabled"":false", json);
Assert.Contains(@"""enabled"":true", json);
}
[Fact]
public void Canonicalize_HandlesDecimals()
{
var obj = new { value = 3.14159, integer = 42 };
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
Assert.Contains(@"""integer"":42", json);
Assert.Contains(@"""value"":3.14159", json);
}
[Fact]
public void Canonicalize_HandlesEmptyObject()
{
var obj = new { };
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
Assert.Equal("{}", json);
}
[Fact]
public void Canonicalize_HandlesEmptyArray()
{
var obj = new { items = Array.Empty<int>() };
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
Assert.Equal(@"{""items"":[]}", json);
}
[Fact]
public void Canonicalize_WithCustomOptions_UsesOptions()
{
var obj = new { MyProperty = "test" };
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj, options));
Assert.Contains(@"""my_property"":""test""", json);
}
[Fact]
public void Canonicalize_RawJsonBytes_SortsKeys()
{
var rawJson = Encoding.UTF8.GetBytes(@"{""z"":3,""a"":1}");
var canonical = CanonJson.CanonicalizeParsedJson(rawJson);
var json = Encoding.UTF8.GetString(canonical);
Assert.Equal(@"{""a"":1,""z"":3}", json);
}
[Fact]
public void Sha256Hex_ProducesLowercaseHex()
{
var bytes = Encoding.UTF8.GetBytes("test");
var hash = CanonJson.Sha256Hex(bytes);
Assert.Matches(@"^[0-9a-f]{64}$", hash);
}
[Fact]
public void Sha256Hex_ProducesConsistentHash()
{
var bytes = Encoding.UTF8.GetBytes("deterministic input");
var hash1 = CanonJson.Sha256Hex(bytes);
var hash2 = CanonJson.Sha256Hex(bytes);
Assert.Equal(hash1, hash2);
}
[Fact]
public void Sha256Prefixed_IncludesPrefix()
{
var bytes = Encoding.UTF8.GetBytes("test");
var hash = CanonJson.Sha256Prefixed(bytes);
Assert.StartsWith("sha256:", hash);
Assert.Equal(71, hash.Length); // "sha256:" (7) + 64 hex chars
}
[Fact]
public void Hash_CanonicalizesAndHashes()
{
var obj = new { z = 3, a = 1 };
var hash1 = CanonJson.Hash(obj);
var hash2 = CanonJson.Hash(obj);
Assert.Equal(hash1, hash2);
Assert.Matches(@"^[0-9a-f]{64}$", hash1);
}
[Fact]
public void HashPrefixed_CanonicalizesAndHashesWithPrefix()
{
var obj = new { name = "test" };
var hash = CanonJson.HashPrefixed(obj);
Assert.StartsWith("sha256:", hash);
}
[Fact]
public void DifferentObjects_ProduceDifferentHashes()
{
var obj1 = new { value = 1 };
var obj2 = new { value = 2 };
var hash1 = CanonJson.Hash(obj1);
var hash2 = CanonJson.Hash(obj2);
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void KeyOrderDoesNotAffectHash()
{
// These should produce the same hash because keys are sorted
var json1 = Encoding.UTF8.GetBytes(@"{""a"":1,""b"":2}");
var json2 = Encoding.UTF8.GetBytes(@"{""b"":2,""a"":1}");
var canonical1 = CanonJson.CanonicalizeParsedJson(json1);
var canonical2 = CanonJson.CanonicalizeParsedJson(json2);
Assert.Equal(
CanonJson.Sha256Hex(canonical1),
CanonJson.Sha256Hex(canonical2));
}
[Fact]
public void Canonicalize_DeeplyNestedStructure()
{
var obj = new
{
level1 = new
{
z = "last",
a = new
{
nested = new { b = 2, a = 1 }
}
}
};
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
// Verify deep nesting is sorted
Assert.Contains(@"""a"":{""nested"":{""a"":1,""b"":2}}", json);
}
[Fact]
public void Canonicalize_ArrayOfObjects_SortsObjectKeys()
{
// Use raw JSON to test mixed object shapes in array
var rawJson = Encoding.UTF8.GetBytes(@"{""items"":[{""z"":3,""a"":1},{""b"":2,""a"":1}]}");
var canonical = CanonJson.CanonicalizeParsedJson(rawJson);
var json = Encoding.UTF8.GetString(canonical);
// Objects in array have sorted keys
Assert.Contains(@"{""a"":1,""z"":3}", json);
Assert.Contains(@"{""a"":1,""b"":2}", json);
}
[Fact]
public void Canonicalize_UnicodeStrings()
{
var obj = new { greeting = "Привет мир", emoji = "🚀" };
var bytes = CanonJson.Canonicalize(obj);
// Verify deterministic hashing regardless of Unicode escaping
var hash1 = CanonJson.Sha256Hex(bytes);
var hash2 = CanonJson.Sha256Hex(CanonJson.Canonicalize(obj));
Assert.Equal(hash1, hash2);
// Unicode may be escaped in JSON output - this is valid canonical JSON
var json = Encoding.UTF8.GetString(bytes);
Assert.Contains("greeting", json);
Assert.Contains("emoji", json);
}
[Fact]
public void Canonicalize_SpecialCharactersInStrings()
{
var obj = new { path = "C:\\Users\\test", quote = "He said \"hello\"" };
var bytes = CanonJson.Canonicalize(obj);
// Should not throw and should produce consistent output
var hash1 = CanonJson.Sha256Hex(bytes);
var hash2 = CanonJson.Sha256Hex(CanonJson.Canonicalize(obj));
Assert.Equal(hash1, hash2);
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,151 @@
using System.Security.Cryptography;
using System.Text.Json;
namespace StellaOps.Canonical.Json;
/// <summary>
/// Canonical JSON serialization with deterministic hashing.
/// Produces bit-identical output across environments for proof replay.
/// </summary>
/// <remarks>
/// Key guarantees:
/// <list type="bullet">
/// <item>Object keys are sorted alphabetically (Ordinal comparison)</item>
/// <item>No whitespace or formatting variations</item>
/// <item>Consistent number formatting</item>
/// <item>UTF-8 encoding without BOM</item>
/// </list>
/// </remarks>
public static class CanonJson
{
/// <summary>
/// Canonicalizes an object to a deterministic byte array.
/// Object keys are recursively sorted using Ordinal comparison.
/// </summary>
/// <typeparam name="T">The type to serialize.</typeparam>
/// <param name="obj">The object to canonicalize.</param>
/// <returns>UTF-8 encoded canonical JSON bytes.</returns>
public static byte[] Canonicalize<T>(T obj)
{
var json = JsonSerializer.SerializeToUtf8Bytes(obj, new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
using var doc = JsonDocument.Parse(json);
using var ms = new MemoryStream();
using var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = false });
WriteElementSorted(doc.RootElement, writer);
writer.Flush();
return ms.ToArray();
}
/// <summary>
/// Canonicalizes an object using custom serializer options.
/// Object keys are recursively sorted using Ordinal comparison.
/// </summary>
/// <typeparam name="T">The type to serialize.</typeparam>
/// <param name="obj">The object to canonicalize.</param>
/// <param name="options">JSON serializer options to use for initial serialization.</param>
/// <returns>UTF-8 encoded canonical JSON bytes.</returns>
public static byte[] Canonicalize<T>(T obj, JsonSerializerOptions options)
{
var json = JsonSerializer.SerializeToUtf8Bytes(obj, options);
using var doc = JsonDocument.Parse(json);
using var ms = new MemoryStream();
using var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = false });
WriteElementSorted(doc.RootElement, writer);
writer.Flush();
return ms.ToArray();
}
/// <summary>
/// Canonicalizes raw JSON bytes by parsing and re-sorting keys.
/// Use this when you have existing JSON that needs to be canonicalized.
/// </summary>
/// <param name="jsonBytes">UTF-8 encoded JSON bytes.</param>
/// <returns>UTF-8 encoded canonical JSON bytes.</returns>
public static byte[] CanonicalizeParsedJson(ReadOnlySpan<byte> jsonBytes)
{
using var doc = JsonDocument.Parse(jsonBytes.ToArray());
using var ms = new MemoryStream();
using var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = false });
WriteElementSorted(doc.RootElement, writer);
writer.Flush();
return ms.ToArray();
}
private static void WriteElementSorted(JsonElement el, Utf8JsonWriter w)
{
switch (el.ValueKind)
{
case JsonValueKind.Object:
w.WriteStartObject();
foreach (var prop in el.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
{
w.WritePropertyName(prop.Name);
WriteElementSorted(prop.Value, w);
}
w.WriteEndObject();
break;
case JsonValueKind.Array:
w.WriteStartArray();
foreach (var item in el.EnumerateArray())
{
WriteElementSorted(item, w);
}
w.WriteEndArray();
break;
default:
el.WriteTo(w);
break;
}
}
/// <summary>
/// Computes SHA-256 hash of bytes, returns lowercase hex string.
/// </summary>
/// <param name="bytes">The bytes to hash.</param>
/// <returns>64-character lowercase hex string.</returns>
public static string Sha256Hex(ReadOnlySpan<byte> bytes)
=> Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
/// <summary>
/// Computes SHA-256 hash of bytes, returns prefixed hash string.
/// </summary>
/// <param name="bytes">The bytes to hash.</param>
/// <returns>Hash string with "sha256:" prefix.</returns>
public static string Sha256Prefixed(ReadOnlySpan<byte> bytes)
=> "sha256:" + Sha256Hex(bytes);
/// <summary>
/// Canonicalizes an object and computes its SHA-256 hash.
/// </summary>
/// <typeparam name="T">The type to serialize.</typeparam>
/// <param name="obj">The object to hash.</param>
/// <returns>64-character lowercase hex string.</returns>
public static string Hash<T>(T obj)
{
var canonical = Canonicalize(obj);
return Sha256Hex(canonical);
}
/// <summary>
/// Canonicalizes an object and computes its prefixed SHA-256 hash.
/// </summary>
/// <typeparam name="T">The type to serialize.</typeparam>
/// <param name="obj">The object to hash.</param>
/// <returns>Hash string with "sha256:" prefix.</returns>
public static string HashPrefixed<T>(T obj)
{
var canonical = Canonicalize(obj);
return Sha256Prefixed(canonical);
}
}

View File

@@ -0,0 +1,95 @@
# StellaOps.Canonical.Json
Canonical JSON serialization with deterministic hashing for StellaOps proofs.
## Overview
This library provides canonical JSON serialization that produces bit-identical output across different environments, enabling deterministic replay and cryptographic verification of score proofs.
## Key Features
- **Deterministic Output**: Object keys are recursively sorted using Ordinal comparison
- **No Whitespace**: Compact output with no formatting variations
- **Consistent Hashing**: SHA-256 hashes are always lowercase hex
- **Cross-Platform**: Same output across Windows, Linux, containers
## Usage
### Basic Canonicalization
```csharp
using StellaOps.Canonical.Json;
var obj = new { z = 3, a = 1, nested = new { b = 2, x = 1 } };
// Get canonical bytes
byte[] canonical = CanonJson.Canonicalize(obj);
// Result: {"a":1,"nested":{"b":2,"x":1},"z":3}
// Compute hash
string hash = CanonJson.Sha256Hex(canonical);
// Result: lowercase 64-char hex string
```
### One-Step Hash
```csharp
// Hash object directly
string hash = CanonJson.Hash(obj);
// With sha256: prefix
string prefixed = CanonJson.HashPrefixed(obj);
// Result: "sha256:a1b2c3..."
```
### Canonicalizing Existing JSON
```csharp
// Re-sort keys in existing JSON
byte[] rawJson = Encoding.UTF8.GetBytes(@"{""z"":1,""a"":2}");
byte[] canonical = CanonJson.CanonicalizeParsedJson(rawJson);
// Result: {"a":2,"z":1}
```
### Custom Serialization Options
```csharp
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
byte[] canonical = CanonJson.Canonicalize(obj, options);
```
## API Reference
| Method | Description |
|--------|-------------|
| `Canonicalize<T>(obj)` | Serialize and canonicalize an object |
| `Canonicalize<T>(obj, options)` | Serialize with custom options and canonicalize |
| `CanonicalizeParsedJson(bytes)` | Canonicalize existing JSON bytes |
| `Sha256Hex(bytes)` | Compute SHA-256, return lowercase hex |
| `Sha256Prefixed(bytes)` | Compute SHA-256 with "sha256:" prefix |
| `Hash<T>(obj)` | Canonicalize and hash in one step |
| `HashPrefixed<T>(obj)` | Canonicalize and hash with prefix |
## Guarantees
1. **Key Ordering**: Object keys are always sorted alphabetically (Ordinal)
2. **No Environment Dependencies**: No timestamps, random values, or environment variables
3. **UTF-8 Without BOM**: Output is always UTF-8 encoded without byte order mark
4. **Array Order Preserved**: Arrays maintain element order (only object keys are sorted)
## Use Cases
- **Scan Manifests**: Hash all inputs affecting scan results
- **DSSE Payloads**: Sign canonical JSON for attestations
- **Proof Replay**: Verify scores are deterministic
- **Content Addressing**: Store proofs by their hash
## Related Components
- `StellaOps.Scanner.Core.Models.ScanManifest` - Uses CanonJson for manifest hashing
- `StellaOps.Attestor` - Signs canonical JSON payloads
- `StellaOps.Evidence.Bundle` - Content-addressed proof storage

View File

@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<Description>Canonical JSON serialization with deterministic hashing for StellaOps proofs.</Description>
</PropertyGroup>
</Project>

View File

@@ -607,7 +607,7 @@ public sealed class StellaEndpointGenerator : IIncrementalGenerator
sb.AppendLine(" {");
sb.AppendLine(" using var sha256 = global::System.Security.Cryptography.SHA256.Create();");
sb.AppendLine(" var hash = sha256.ComputeHash(global::System.Text.Encoding.UTF8.GetBytes(content));");
sb.AppendLine(" return $\"\\\"{global::System.Convert.ToHexString(hash)[..16]}\\\"\";");
sb.AppendLine(" return $\"\\\"{(global::System.Convert.ToHexString(hash)[..16])}\\\"\";");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine("}");

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}";

View File

@@ -30,7 +30,7 @@ public sealed class PayloadLimitsMiddleware
/// </summary>
public async Task Invoke(HttpContext context, IPayloadTracker tracker)
{
var connectionId = context.Connection.Id;
var connectionId = context.Connection.Id ?? context.TraceIdentifier;
var contentLength = context.Request.ContentLength ?? 0;
// Early rejection for known oversized Content-Length (LIM-002, LIM-003)

View File

@@ -4,6 +4,7 @@ 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;
using static StellaOps.Router.Common.Models.CancelReasons;
@@ -18,6 +19,7 @@ public sealed class InMemoryTransportClient : ITransportClient, IMicroserviceTra
private readonly InMemoryConnectionRegistry _registry;
private readonly InMemoryTransportOptions _options;
private readonly ILogger<InMemoryTransportClient> _logger;
private readonly InMemoryTransportServer? _transportServer;
private readonly ConcurrentDictionary<string, TaskCompletionSource<Frame>> _pendingRequests = new();
private readonly ConcurrentDictionary<string, CancellationTokenSource> _inflightHandlers = new();
private readonly CancellationTokenSource _clientCts = new();
@@ -41,11 +43,18 @@ public sealed class InMemoryTransportClient : ITransportClient, IMicroserviceTra
public InMemoryTransportClient(
InMemoryConnectionRegistry registry,
IOptions<InMemoryTransportOptions> options,
ILogger<InMemoryTransportClient> logger)
ILogger<InMemoryTransportClient> logger,
InMemoryTransportServer? transportServer = null)
{
_registry = registry;
_options = options.Value;
_logger = logger;
_transportServer = transportServer;
if (_transportServer is not null)
{
_transportServer.OnResponseReceived += HandleResponseReceivedAsync;
}
}
/// <summary>
@@ -336,18 +345,15 @@ public sealed class InMemoryTransportClient : ITransportClient, IMicroserviceTra
{
ObjectDisposedException.ThrowIf(_disposed, this);
var channel = _registry.GetRequiredChannel(connection.ConnectionId);
var correlationId = requestHeader.CorrelationId ?? Guid.NewGuid().ToString("N");
// Send header frame
var headerFrame = requestHeader with
var request = FrameConverter.ToRequestFrame(requestHeader);
if (request is null)
{
Type = FrameType.Request,
CorrelationId = correlationId
};
await channel.ToMicroservice.Writer.WriteAsync(headerFrame, cancellationToken);
throw new InvalidOperationException("Invalid streaming request header frame.");
}
// Stream request body in chunks
// InMemory transport doesn't implement true per-chunk streaming yet.
// Buffer the request body, enforce limits, and dispatch as a normal request frame.
using var bufferedBody = new MemoryStream();
var buffer = ArrayPool<byte>.Shared.Rent(8192);
try
{
@@ -364,42 +370,47 @@ public sealed class InMemoryTransportClient : ITransportClient, IMicroserviceTra
$"Request body exceeds limit of {limits.MaxRequestBytesPerCall} bytes");
}
var dataFrame = new Frame
{
Type = FrameType.RequestStreamData,
CorrelationId = correlationId,
Payload = new ReadOnlyMemory<byte>(buffer, 0, bytesRead)
};
await channel.ToMicroservice.Writer.WriteAsync(dataFrame, cancellationToken);
if (_options.SimulatedLatency > TimeSpan.Zero)
{
await Task.Delay(_options.SimulatedLatency, cancellationToken);
}
await bufferedBody.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
}
// Signal end of request stream with empty data frame
var endFrame = new Frame
{
Type = FrameType.RequestStreamData,
CorrelationId = correlationId,
Payload = ReadOnlyMemory<byte>.Empty
};
await channel.ToMicroservice.Writer.WriteAsync(endFrame, cancellationToken);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
// Read streaming response
using var responseStream = new MemoryStream();
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingRequests[correlationId] = new TaskCompletionSource<Frame>();
var bufferedRequest = request with
{
SupportsStreaming = false,
Payload = bufferedBody.ToArray()
};
// TODO: Implement proper streaming response handling
// For now, we accumulate the response in memory
await readResponseBody(responseStream);
var bufferedFrame = FrameConverter.ToFrame(bufferedRequest);
// Preserve the transport correlation id used for request/response matching.
if (requestHeader.CorrelationId is not null)
{
bufferedFrame = bufferedFrame with { CorrelationId = requestHeader.CorrelationId };
}
var timeout = TimeSpan.FromSeconds(Math.Max(1, bufferedRequest.TimeoutSeconds));
var responseFrame = await SendRequestAsync(connection, bufferedFrame, timeout, cancellationToken);
var response = FrameConverter.ToResponseFrame(responseFrame)
?? throw new InvalidOperationException("Invalid response frame.");
using var responseBody = new MemoryStream(response.Payload.ToArray());
await readResponseBody(responseBody);
}
private Task HandleResponseReceivedAsync(ConnectionState connection, Frame frame)
{
if (frame.CorrelationId is not null &&
_pendingRequests.TryRemove(frame.CorrelationId, out var tcs))
{
tcs.TrySetResult(frame);
}
return Task.CompletedTask;
}
/// <summary>
@@ -478,6 +489,11 @@ public sealed class InMemoryTransportClient : ITransportClient, IMicroserviceTra
if (_disposed) return;
_disposed = true;
if (_transportServer is not null)
{
_transportServer.OnResponseReceived -= HandleResponseReceivedAsync;
}
// Cancel all inflight handlers
CancelAllInflight(Shutdown);

View File

@@ -18,6 +18,7 @@ public sealed class InMemoryTransportServer : ITransportServer, IDisposable
private readonly ILogger<InMemoryTransportServer> _logger;
private readonly ConcurrentDictionary<string, Task> _connectionTasks = new();
private readonly CancellationTokenSource _serverCts = new();
private Task? _acceptTask;
private bool _running;
private bool _disposed;
@@ -66,6 +67,7 @@ public sealed class InMemoryTransportServer : ITransportServer, IDisposable
}
_running = true;
_acceptTask = Task.Run(() => AcceptLoopAsync(_serverCts.Token), CancellationToken.None);
_logger.LogInformation("InMemory transport server started");
return Task.CompletedTask;
}
@@ -80,6 +82,18 @@ public sealed class InMemoryTransportServer : ITransportServer, IDisposable
await _serverCts.CancelAsync();
if (_acceptTask is not null)
{
try
{
await _acceptTask.WaitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
// Expected on shutdown.
}
}
// Wait for all connection tasks to complete
var tasks = _connectionTasks.Values.ToArray();
if (tasks.Length > 0)
@@ -98,8 +112,17 @@ public sealed class InMemoryTransportServer : ITransportServer, IDisposable
{
if (!_running) return;
if (!_connectionTasks.TryAdd(connectionId, Task.CompletedTask))
{
return; // Already listening.
}
var channel = _registry.GetChannel(connectionId);
if (channel is null) return;
if (channel is null)
{
_connectionTasks.TryRemove(connectionId, out _);
return;
}
var task = Task.Run(async () =>
{
@@ -128,6 +151,26 @@ public sealed class InMemoryTransportServer : ITransportServer, IDisposable
_connectionTasks[connectionId] = task;
}
private async Task AcceptLoopAsync(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
foreach (var connectionId in _registry.ConnectionIds)
{
StartListeningToConnection(connectionId);
}
await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken);
}
}
catch (OperationCanceledException)
{
// Expected on shutdown.
}
}
private async Task ProcessConnectionFramesAsync(InMemoryChannel channel, CancellationToken cancellationToken)
{
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
@@ -178,39 +221,47 @@ public sealed class InMemoryTransportServer : ITransportServer, IDisposable
private async Task ProcessHelloFrameAsync(InMemoryChannel channel, Frame frame, CancellationToken cancellationToken)
{
// In a real implementation, we'd deserialize the payload
// For now, the HelloPayload should be passed out-of-band via the channel
if (channel.Instance is null)
// In a real implementation, we'd deserialize the payload; for InMemory transport we use the channel state.
if (channel.State is null)
{
_logger.LogWarning("HELLO received but Instance not set for connection {ConnectionId}",
channel.ConnectionId);
return;
if (channel.Instance is null)
{
_logger.LogWarning("HELLO received but Instance not set for connection {ConnectionId}",
channel.ConnectionId);
return;
}
channel.State = new ConnectionState
{
ConnectionId = channel.ConnectionId,
Instance = channel.Instance,
Status = InstanceHealthStatus.Healthy,
LastHeartbeatUtc = DateTime.UtcNow,
TransportType = TransportType.InMemory
};
}
// Create ConnectionState
var state = new ConnectionState
{
ConnectionId = channel.ConnectionId,
Instance = channel.Instance,
Status = InstanceHealthStatus.Healthy,
LastHeartbeatUtc = DateTime.UtcNow,
TransportType = TransportType.InMemory
};
channel.State = state;
var state = channel.State;
_logger.LogInformation(
"HELLO received from {ServiceName}/{Version} instance {InstanceId}",
channel.Instance.ServiceName,
channel.Instance.Version,
channel.Instance.InstanceId);
state.Instance.ServiceName,
state.Instance.Version,
state.Instance.InstanceId);
// Fire event with dummy HelloPayload (real impl would deserialize from frame)
if (OnHelloReceived is not null)
{
var endpoints = state.Endpoints.Values
.OrderBy(e => e.Method, StringComparer.OrdinalIgnoreCase)
.ThenBy(e => e.Path, StringComparer.OrdinalIgnoreCase)
.ToList();
var payload = new HelloPayload
{
Instance = channel.Instance,
Endpoints = []
Instance = state.Instance,
Endpoints = endpoints,
Schemas = state.Schemas,
OpenApiInfo = state.OpenApiInfo
};
await OnHelloReceived(state, payload);
}

View File

@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Router.Testing.Fixtures;
using Testcontainers.RabbitMq;
using Xunit.Sdk;
namespace StellaOps.Router.Transport.RabbitMq.Tests.Fixtures;
@@ -82,15 +83,37 @@ public sealed class RabbitMqContainerFixture : RouterCollectionFixture, IAsyncDi
/// <inheritdoc />
public override async Task InitializeAsync()
{
_container = new RabbitMqBuilder()
.WithImage("rabbitmq:3.12-management")
.WithPortBinding(5672, true)
.WithPortBinding(15672, true)
.WithUsername("guest")
.WithPassword("guest")
.Build();
try
{
_container = new RabbitMqBuilder()
.WithImage("rabbitmq:3.12-management")
.WithPortBinding(5672, true)
.WithPortBinding(15672, true)
.WithUsername("guest")
.WithPassword("guest")
.Build();
await _container.StartAsync();
await _container.StartAsync();
}
catch (Exception ex)
{
try
{
if (_container is not null)
{
await _container.DisposeAsync();
}
}
catch
{
// Ignore cleanup failures during skip.
}
_container = null;
throw SkipException.ForSkip(
$"RabbitMQ integration tests require Docker/Testcontainers. Skipping because the container failed to start: {ex.Message}");
}
}
/// <inheritdoc />

View File

@@ -0,0 +1,19 @@
using System;
using Xunit;
namespace StellaOps.Router.Transport.RabbitMq.Tests.Fixtures;
[AttributeUsage(AttributeTargets.Method)]
public sealed class RabbitMqIntegrationFactAttribute : FactAttribute
{
public RabbitMqIntegrationFactAttribute()
{
var enabled = Environment.GetEnvironmentVariable("STELLAOPS_TEST_RABBITMQ");
if (!string.Equals(enabled, "1", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(enabled, "true", StringComparison.OrdinalIgnoreCase))
{
Skip = "RabbitMQ integration tests are opt-in. Set STELLAOPS_TEST_RABBITMQ=1 (requires Docker/Testcontainers).";
}
}
}

View File

@@ -60,7 +60,7 @@ public sealed class RabbitMqIntegrationTests : IAsyncLifetime
#region Connection Tests
[Fact]
[RabbitMqIntegrationFact]
public async Task ServerStartAsync_WithRealBroker_Succeeds()
{
// Arrange
@@ -74,7 +74,7 @@ public sealed class RabbitMqIntegrationTests : IAsyncLifetime
_server.ConnectionCount.Should().Be(0);
}
[Fact]
[RabbitMqIntegrationFact]
public async Task ServerStopAsync_AfterStart_Succeeds()
{
// Arrange
@@ -88,7 +88,7 @@ public sealed class RabbitMqIntegrationTests : IAsyncLifetime
await act.Should().NotThrowAsync();
}
[Fact]
[RabbitMqIntegrationFact]
public async Task ClientConnectAsync_WithRealBroker_Succeeds()
{
// Arrange
@@ -108,7 +108,7 @@ public sealed class RabbitMqIntegrationTests : IAsyncLifetime
await act.Should().NotThrowAsync();
}
[Fact]
[RabbitMqIntegrationFact]
public async Task ClientDisconnectAsync_AfterConnect_Succeeds()
{
// Arrange
@@ -133,7 +133,7 @@ public sealed class RabbitMqIntegrationTests : IAsyncLifetime
#region Hello Frame Tests
[Fact]
[RabbitMqIntegrationFact]
public async Task ClientConnectAsync_SendsHelloFrame_ServerReceives()
{
// Arrange
@@ -180,7 +180,7 @@ public sealed class RabbitMqIntegrationTests : IAsyncLifetime
#region Heartbeat Tests
[Fact]
[RabbitMqIntegrationFact]
public async Task ClientSendHeartbeatAsync_RealBroker_Succeeds()
{
// Arrange
@@ -210,7 +210,7 @@ public sealed class RabbitMqIntegrationTests : IAsyncLifetime
await act.Should().NotThrowAsync();
}
[Fact]
[RabbitMqIntegrationFact]
public async Task ServerReceivesHeartbeat_UpdatesLastHeartbeatUtc()
{
// Arrange
@@ -272,7 +272,7 @@ public sealed class RabbitMqIntegrationTests : IAsyncLifetime
#region Queue Declaration Tests
[Fact]
[RabbitMqIntegrationFact]
public async Task ServerStartAsync_CreatesExchangesAndQueues()
{
// Arrange
@@ -286,7 +286,7 @@ public sealed class RabbitMqIntegrationTests : IAsyncLifetime
// but the lack of exception indicates success
}
[Fact]
[RabbitMqIntegrationFact]
public async Task ClientConnectAsync_CreatesResponseQueue()
{
// Arrange
@@ -309,7 +309,7 @@ public sealed class RabbitMqIntegrationTests : IAsyncLifetime
#region Auto-Delete Queue Tests
[Fact]
[RabbitMqIntegrationFact]
public async Task AutoDeleteQueues_AreCleanedUpOnDisconnect()
{
// Arrange
@@ -343,7 +343,7 @@ public sealed class RabbitMqIntegrationTests : IAsyncLifetime
#region Prefetch Tests
[Fact]
[RabbitMqIntegrationFact]
public async Task PrefetchCount_IsAppliedOnConnect()
{
// Arrange
@@ -372,7 +372,7 @@ public sealed class RabbitMqIntegrationTests : IAsyncLifetime
#region Multiple Connections Tests
[Fact]
[RabbitMqIntegrationFact]
public async Task MultipleClients_CanConnectSimultaneously()
{
// Arrange

View File

@@ -112,10 +112,10 @@ public sealed class RabbitMqTransportClientTests
#region CancelAllInflight Tests
[Fact]
public void CancelAllInflight_WhenNoInflightRequests_DoesNotThrow()
public async Task CancelAllInflight_WhenNoInflightRequests_DoesNotThrow()
{
// Arrange
using var client = CreateClient();
await using var client = CreateClient();
// Act & Assert - should not throw
client.CancelAllInflight("TestReason");
@@ -373,12 +373,12 @@ public sealed class RabbitMqTransportClientConfigurationTests
// Arrange
var options = new RabbitMqTransportOptions
{
QueuePrefix = "myapp"
ExchangePrefix = "myapp"
};
// Assert
options.RequestExchange.Should().Be("myapp.request");
options.ResponseExchange.Should().Be("myapp.response");
options.RequestExchange.Should().Be("myapp.requests");
options.ResponseExchange.Should().Be("myapp.responses");
}
[Fact]
@@ -388,7 +388,7 @@ public sealed class RabbitMqTransportClientConfigurationTests
var options = new RabbitMqTransportOptions();
// Assert
options.RequestExchange.Should().Be("stellaops.request");
options.ResponseExchange.Should().Be("stellaops.response");
options.RequestExchange.Should().Be("stella.router.requests");
options.ResponseExchange.Should().Be("stella.router.responses");
}
}

View File

@@ -99,10 +99,10 @@ public sealed class RabbitMqTransportServerTests
#region Connection Management Tests
[Fact]
public void GetConnectionState_WithUnknownConnectionId_ReturnsNull()
public async Task GetConnectionState_WithUnknownConnectionId_ReturnsNull()
{
// Arrange
using var server = CreateServer();
await using var server = CreateServer();
// Act
var result = server.GetConnectionState("unknown-connection");
@@ -112,10 +112,10 @@ public sealed class RabbitMqTransportServerTests
}
[Fact]
public void GetConnections_WhenEmpty_ReturnsEmptyEnumerable()
public async Task GetConnections_WhenEmpty_ReturnsEmptyEnumerable()
{
// Arrange
using var server = CreateServer();
await using var server = CreateServer();
// Act
var result = server.GetConnections().ToList();
@@ -125,10 +125,10 @@ public sealed class RabbitMqTransportServerTests
}
[Fact]
public void ConnectionCount_WhenEmpty_ReturnsZero()
public async Task ConnectionCount_WhenEmpty_ReturnsZero()
{
// Arrange
using var server = CreateServer();
await using var server = CreateServer();
// Act
var result = server.ConnectionCount;
@@ -138,10 +138,10 @@ public sealed class RabbitMqTransportServerTests
}
[Fact]
public void RemoveConnection_WithUnknownConnectionId_DoesNotThrow()
public async Task RemoveConnection_WithUnknownConnectionId_DoesNotThrow()
{
// Arrange
using var server = CreateServer();
await using var server = CreateServer();
// Act
var act = () => server.RemoveConnection("unknown-connection");
@@ -155,10 +155,10 @@ public sealed class RabbitMqTransportServerTests
#region Event Handler Tests
[Fact]
public void OnConnection_CanBeRegistered()
public async Task OnConnection_CanBeRegistered()
{
// Arrange
using var server = CreateServer();
await using var server = CreateServer();
var connectionReceived = false;
// Act
@@ -172,10 +172,10 @@ public sealed class RabbitMqTransportServerTests
}
[Fact]
public void OnDisconnection_CanBeRegistered()
public async Task OnDisconnection_CanBeRegistered()
{
// Arrange
using var server = CreateServer();
await using var server = CreateServer();
var disconnectionReceived = false;
// Act
@@ -189,10 +189,10 @@ public sealed class RabbitMqTransportServerTests
}
[Fact]
public void OnFrame_CanBeRegistered()
public async Task OnFrame_CanBeRegistered()
{
// Arrange
using var server = CreateServer();
await using var server = CreateServer();
var frameReceived = false;
// Act
@@ -252,7 +252,7 @@ public sealed class RabbitMqTransportServerTests
public async Task SendFrameAsync_WithUnknownConnection_ThrowsInvalidOperationException()
{
// Arrange
using var server = CreateServer();
await using var server = CreateServer();
var frame = new Frame
{
@@ -277,7 +277,7 @@ public sealed class RabbitMqTransportServerTests
public async Task StopAsync_WhenNotStarted_DoesNotThrow()
{
// Arrange
using var server = CreateServer();
await using var server = CreateServer();
// Act
var act = async () => await server.StopAsync(CancellationToken.None);