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:
263
src/__Libraries/StellaOps.Canonical.Json.Tests/CanonJsonTests.cs
Normal file
263
src/__Libraries/StellaOps.Canonical.Json.Tests/CanonJsonTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
151
src/__Libraries/StellaOps.Canonical.Json/CanonJson.cs
Normal file
151
src/__Libraries/StellaOps.Canonical.Json/CanonJson.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
95
src/__Libraries/StellaOps.Canonical.Json/README.md
Normal file
95
src/__Libraries/StellaOps.Canonical.Json/README.md
Normal 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
|
||||
@@ -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>
|
||||
@@ -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("}");
|
||||
|
||||
@@ -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}";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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).";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user