Add unit tests for RabbitMq and Udp transport servers and clients
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented comprehensive unit tests for RabbitMqTransportServer, covering constructor, disposal, connection management, event handlers, and exception handling.
- Added configuration tests for RabbitMqTransportServer to validate SSL, durable queues, auto-recovery, and custom virtual host options.
- Created unit tests for UdpFrameProtocol, including frame parsing and serialization, header size validation, and round-trip data preservation.
- Developed tests for UdpTransportClient, focusing on connection handling, event subscriptions, and exception scenarios.
- Established tests for UdpTransportServer, ensuring proper start/stop behavior, connection state management, and event handling.
- Included tests for UdpTransportOptions to verify default values and modification capabilities.
- Enhanced service registration tests for Udp transport services in the dependency injection container.
This commit is contained in:
master
2025-12-05 19:01:12 +02:00
parent 53508ceccb
commit cc69d332e3
245 changed files with 22440 additions and 27719 deletions

View File

@@ -1,4 +1,5 @@
using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Gateway.WebService.OpenApi;
namespace StellaOps.Gateway.WebService;
@@ -25,4 +26,15 @@ public static class ApplicationBuilderExtensions
return app;
}
/// <summary>
/// Maps OpenAPI endpoints to the application.
/// Should be called before UseGatewayRouter so OpenAPI requests are handled first.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <returns>The endpoint route builder for chaining.</returns>
public static IEndpointRouteBuilder MapGatewayOpenApi(this IEndpointRouteBuilder endpoints)
{
return endpoints.MapGatewayOpenApiEndpoints();
}
}

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using StellaOps.Gateway.WebService.OpenApi;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
@@ -14,17 +15,20 @@ internal sealed class ConnectionManager : IHostedService
private readonly InMemoryTransportServer _transportServer;
private readonly InMemoryConnectionRegistry _connectionRegistry;
private readonly IGlobalRoutingState _routingState;
private readonly IGatewayOpenApiDocumentCache? _openApiCache;
private readonly ILogger<ConnectionManager> _logger;
public ConnectionManager(
InMemoryTransportServer transportServer,
InMemoryConnectionRegistry connectionRegistry,
IGlobalRoutingState routingState,
ILogger<ConnectionManager> logger)
ILogger<ConnectionManager> logger,
IGatewayOpenApiDocumentCache? openApiCache = null)
{
_transportServer = transportServer;
_connectionRegistry = connectionRegistry;
_routingState = routingState;
_openApiCache = openApiCache;
_logger = logger;
}
@@ -55,11 +59,12 @@ internal sealed class ConnectionManager : IHostedService
private Task HandleHelloReceivedAsync(ConnectionState connectionState, HelloPayload payload)
{
_logger.LogInformation(
"Connection registered: {ConnectionId} from {ServiceName}/{Version} with {EndpointCount} endpoints",
"Connection registered: {ConnectionId} from {ServiceName}/{Version} with {EndpointCount} endpoints, {SchemaCount} schemas",
connectionState.ConnectionId,
connectionState.Instance.ServiceName,
connectionState.Instance.Version,
connectionState.Endpoints.Count);
connectionState.Endpoints.Count,
connectionState.Schemas.Count);
// Add the connection to the routing state
_routingState.AddConnection(connectionState);
@@ -67,6 +72,9 @@ internal sealed class ConnectionManager : IHostedService
// Start listening to this connection for frames
_transportServer.StartListeningToConnection(connectionState.ConnectionId);
// Invalidate OpenAPI cache when connections change
_openApiCache?.Invalidate();
return Task.CompletedTask;
}
@@ -94,6 +102,9 @@ internal sealed class ConnectionManager : IHostedService
// Remove from routing state
_routingState.RemoveConnection(connectionId);
// Invalidate OpenAPI cache when connections change
_openApiCache?.Invalidate();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,106 @@
using System.Text.Json.Nodes;
using StellaOps.Router.Common.Models;
namespace StellaOps.Gateway.WebService.OpenApi;
/// <summary>
/// Maps claim requirements to OpenAPI security schemes.
/// </summary>
internal static class ClaimSecurityMapper
{
/// <summary>
/// Generates security schemes from claim requirements.
/// </summary>
/// <param name="endpoints">All endpoint descriptors.</param>
/// <param name="tokenUrl">The OAuth2 token URL.</param>
/// <returns>Security schemes JSON object.</returns>
public static JsonObject GenerateSecuritySchemes(
IEnumerable<EndpointDescriptor> endpoints,
string tokenUrl)
{
var schemes = new JsonObject();
// Always add BearerAuth scheme
schemes["BearerAuth"] = new JsonObject
{
["type"] = "http",
["scheme"] = "bearer",
["bearerFormat"] = "JWT",
["description"] = "JWT Bearer token authentication"
};
// Collect all unique scopes from claims
var scopes = new Dictionary<string, string>();
foreach (var endpoint in endpoints)
{
foreach (var claim in endpoint.RequiringClaims)
{
var scope = claim.Type;
if (!scopes.ContainsKey(scope))
{
scopes[scope] = $"Access scope: {scope}";
}
}
}
// Add OAuth2 scheme if there are any scopes
if (scopes.Count > 0)
{
var scopesObject = new JsonObject();
foreach (var (scope, description) in scopes)
{
scopesObject[scope] = description;
}
schemes["OAuth2"] = new JsonObject
{
["type"] = "oauth2",
["flows"] = new JsonObject
{
["clientCredentials"] = new JsonObject
{
["tokenUrl"] = tokenUrl,
["scopes"] = scopesObject
}
}
};
}
return schemes;
}
/// <summary>
/// Generates security requirement for an endpoint.
/// </summary>
/// <param name="endpoint">The endpoint descriptor.</param>
/// <returns>Security requirement JSON array.</returns>
public static JsonArray GenerateSecurityRequirement(EndpointDescriptor endpoint)
{
var requirements = new JsonArray();
if (endpoint.RequiringClaims.Count == 0)
{
return requirements;
}
var requirement = new JsonObject();
// Always require BearerAuth
requirement["BearerAuth"] = new JsonArray();
// Add OAuth2 scopes
var scopes = new JsonArray();
foreach (var claim in endpoint.RequiringClaims)
{
scopes.Add(claim.Type);
}
if (scopes.Count > 0)
{
requirement["OAuth2"] = scopes;
}
requirements.Add(requirement);
return requirements;
}
}

View File

@@ -0,0 +1,69 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
namespace StellaOps.Gateway.WebService.OpenApi;
/// <summary>
/// Caches the generated OpenAPI document with TTL-based expiration.
/// </summary>
internal sealed class GatewayOpenApiDocumentCache : IGatewayOpenApiDocumentCache
{
private readonly IOpenApiDocumentGenerator _generator;
private readonly OpenApiAggregationOptions _options;
private readonly object _lock = new();
private string? _cachedDocument;
private string? _cachedETag;
private DateTime _generatedAt;
private bool _invalidated = true;
public GatewayOpenApiDocumentCache(
IOpenApiDocumentGenerator generator,
IOptions<OpenApiAggregationOptions> options)
{
_generator = generator;
_options = options.Value;
}
/// <inheritdoc />
public (string DocumentJson, string ETag, DateTime GeneratedAt) GetDocument()
{
lock (_lock)
{
var now = DateTime.UtcNow;
var ttl = TimeSpan.FromSeconds(_options.CacheTtlSeconds);
// Check if we need to regenerate
if (_invalidated || _cachedDocument is null || now - _generatedAt > ttl)
{
Regenerate();
}
return (_cachedDocument!, _cachedETag!, _generatedAt);
}
}
/// <inheritdoc />
public void Invalidate()
{
lock (_lock)
{
_invalidated = true;
}
}
private void Regenerate()
{
_cachedDocument = _generator.GenerateDocument();
_cachedETag = ComputeETag(_cachedDocument);
_generatedAt = DateTime.UtcNow;
_invalidated = false;
}
private static string ComputeETag(string content)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"\"{Convert.ToHexString(hash)[..16]}\"";
}
}

View File

@@ -0,0 +1,18 @@
namespace StellaOps.Gateway.WebService.OpenApi;
/// <summary>
/// Caches the generated OpenAPI document with TTL-based expiration.
/// </summary>
public interface IGatewayOpenApiDocumentCache
{
/// <summary>
/// Gets the cached document or regenerates if expired.
/// </summary>
/// <returns>A tuple containing the document JSON, ETag, and generation timestamp.</returns>
(string DocumentJson, string ETag, DateTime GeneratedAt) GetDocument();
/// <summary>
/// Invalidates the cache, forcing regeneration on next access.
/// </summary>
void Invalidate();
}

View File

@@ -0,0 +1,13 @@
namespace StellaOps.Gateway.WebService.OpenApi;
/// <summary>
/// Generates OpenAPI documents from aggregated microservice schemas.
/// </summary>
public interface IOpenApiDocumentGenerator
{
/// <summary>
/// Generates the OpenAPI 3.1.0 document as JSON.
/// </summary>
/// <returns>The OpenAPI document as a JSON string.</returns>
string GenerateDocument();
}

View File

@@ -0,0 +1,62 @@
namespace StellaOps.Gateway.WebService.OpenApi;
/// <summary>
/// Configuration options for OpenAPI document aggregation.
/// </summary>
public sealed class OpenApiAggregationOptions
{
/// <summary>
/// The configuration section name.
/// </summary>
public const string SectionName = "OpenApi";
/// <summary>
/// Gets or sets the API title.
/// </summary>
public string Title { get; set; } = "StellaOps Gateway API";
/// <summary>
/// Gets or sets the API description.
/// </summary>
public string Description { get; set; } = "Unified API aggregating all connected microservices.";
/// <summary>
/// Gets or sets the API version.
/// </summary>
public string Version { get; set; } = "1.0.0";
/// <summary>
/// Gets or sets the server URL.
/// </summary>
public string ServerUrl { get; set; } = "/";
/// <summary>
/// Gets or sets the cache TTL in seconds.
/// </summary>
public int CacheTtlSeconds { get; set; } = 60;
/// <summary>
/// Gets or sets whether OpenAPI aggregation is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Gets or sets the license name.
/// </summary>
public string LicenseName { get; set; } = "AGPL-3.0-or-later";
/// <summary>
/// Gets or sets the contact name.
/// </summary>
public string? ContactName { get; set; }
/// <summary>
/// Gets or sets the contact email.
/// </summary>
public string? ContactEmail { get; set; }
/// <summary>
/// Gets or sets the OAuth2 token URL for security schemes.
/// </summary>
public string TokenUrl { get; set; } = "/auth/token";
}

View File

@@ -0,0 +1,285 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Models;
namespace StellaOps.Gateway.WebService.OpenApi;
/// <summary>
/// Generates OpenAPI 3.1.0 documents from aggregated microservice schemas.
/// </summary>
internal sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
{
private readonly IGlobalRoutingState _routingState;
private readonly OpenApiAggregationOptions _options;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
public OpenApiDocumentGenerator(
IGlobalRoutingState routingState,
IOptions<OpenApiAggregationOptions> options)
{
_routingState = routingState;
_options = options.Value;
}
/// <inheritdoc />
public string GenerateDocument()
{
var connections = _routingState.GetAllConnections();
var doc = new JsonObject
{
["openapi"] = "3.1.0",
["info"] = GenerateInfo(),
["servers"] = GenerateServers(),
["paths"] = GeneratePaths(connections),
["components"] = GenerateComponents(connections),
["tags"] = GenerateTags(connections)
};
return doc.ToJsonString(JsonOptions);
}
private JsonObject GenerateInfo()
{
var info = new JsonObject
{
["title"] = _options.Title,
["version"] = _options.Version,
["description"] = _options.Description,
["license"] = new JsonObject
{
["name"] = _options.LicenseName
}
};
if (_options.ContactName is not null || _options.ContactEmail is not null)
{
var contact = new JsonObject();
if (_options.ContactName is not null)
contact["name"] = _options.ContactName;
if (_options.ContactEmail is not null)
contact["email"] = _options.ContactEmail;
info["contact"] = contact;
}
return info;
}
private JsonArray GenerateServers()
{
return new JsonArray
{
new JsonObject
{
["url"] = _options.ServerUrl
}
};
}
private JsonObject GeneratePaths(IReadOnlyList<ConnectionState> connections)
{
var paths = new JsonObject();
// Group endpoints by path
var pathGroups = new Dictionary<string, List<(ConnectionState Conn, EndpointDescriptor Endpoint)>>();
foreach (var conn in connections)
{
foreach (var endpoint in conn.Endpoints.Values)
{
if (!pathGroups.TryGetValue(endpoint.Path, out var list))
{
list = [];
pathGroups[endpoint.Path] = list;
}
list.Add((conn, endpoint));
}
}
// Generate path items
foreach (var (path, endpoints) in pathGroups.OrderBy(p => p.Key))
{
var pathItem = new JsonObject();
foreach (var (conn, endpoint) in endpoints)
{
var operation = GenerateOperation(conn, endpoint);
var method = endpoint.Method.ToLowerInvariant();
pathItem[method] = operation;
}
paths[path] = pathItem;
}
return paths;
}
private JsonObject GenerateOperation(ConnectionState conn, EndpointDescriptor endpoint)
{
var operation = new JsonObject
{
["operationId"] = $"{conn.Instance.ServiceName}_{endpoint.Path.Replace("/", "_").Trim('_')}_{endpoint.Method}",
["tags"] = new JsonArray { conn.Instance.ServiceName }
};
// Add documentation from SchemaInfo
if (endpoint.SchemaInfo is not null)
{
if (endpoint.SchemaInfo.Summary is not null)
operation["summary"] = endpoint.SchemaInfo.Summary;
if (endpoint.SchemaInfo.Description is not null)
operation["description"] = endpoint.SchemaInfo.Description;
if (endpoint.SchemaInfo.Deprecated)
operation["deprecated"] = true;
// Override tags if specified
if (endpoint.SchemaInfo.Tags.Count > 0)
{
var tags = new JsonArray();
foreach (var tag in endpoint.SchemaInfo.Tags)
{
tags.Add(tag);
}
operation["tags"] = tags;
}
}
// Add security requirements
var security = ClaimSecurityMapper.GenerateSecurityRequirement(endpoint);
if (security.Count > 0)
{
operation["security"] = security;
}
// Add request body if schema exists
if (endpoint.SchemaInfo?.RequestSchemaId is not null)
{
var schemaRef = $"#/components/schemas/{conn.Instance.ServiceName}_{endpoint.SchemaInfo.RequestSchemaId}";
operation["requestBody"] = new JsonObject
{
["required"] = true,
["content"] = new JsonObject
{
["application/json"] = new JsonObject
{
["schema"] = new JsonObject
{
["$ref"] = schemaRef
}
}
}
};
}
// Add responses
var responses = new JsonObject();
// Success response
var successResponse = new JsonObject
{
["description"] = "Success"
};
if (endpoint.SchemaInfo?.ResponseSchemaId is not null)
{
var schemaRef = $"#/components/schemas/{conn.Instance.ServiceName}_{endpoint.SchemaInfo.ResponseSchemaId}";
successResponse["content"] = new JsonObject
{
["application/json"] = new JsonObject
{
["schema"] = new JsonObject
{
["$ref"] = schemaRef
}
}
};
}
responses["200"] = successResponse;
// Error responses
responses["400"] = new JsonObject { ["description"] = "Bad Request" };
responses["401"] = new JsonObject { ["description"] = "Unauthorized" };
responses["404"] = new JsonObject { ["description"] = "Not Found" };
responses["422"] = new JsonObject { ["description"] = "Validation Error" };
responses["500"] = new JsonObject { ["description"] = "Internal Server Error" };
operation["responses"] = responses;
return operation;
}
private JsonObject GenerateComponents(IReadOnlyList<ConnectionState> connections)
{
var components = new JsonObject();
// Generate schemas with service prefix
var schemas = new JsonObject();
foreach (var conn in connections)
{
foreach (var (schemaId, schemaDef) in conn.Schemas)
{
var prefixedId = $"{conn.Instance.ServiceName}_{schemaId}";
try
{
var schemaNode = JsonNode.Parse(schemaDef.SchemaJson);
if (schemaNode is not null)
{
schemas[prefixedId] = schemaNode;
}
}
catch (JsonException)
{
// Skip invalid schemas
}
}
}
if (schemas.Count > 0)
{
components["schemas"] = schemas;
}
// Generate security schemes
var allEndpoints = connections.SelectMany(c => c.Endpoints.Values);
var securitySchemes = ClaimSecurityMapper.GenerateSecuritySchemes(allEndpoints, _options.TokenUrl);
if (securitySchemes.Count > 0)
{
components["securitySchemes"] = securitySchemes;
}
return components;
}
private JsonArray GenerateTags(IReadOnlyList<ConnectionState> connections)
{
var tags = new JsonArray();
var seen = new HashSet<string>();
foreach (var conn in connections)
{
var serviceName = conn.Instance.ServiceName;
if (seen.Add(serviceName))
{
var tag = new JsonObject
{
["name"] = serviceName,
["description"] = $"{serviceName} microservice (v{conn.Instance.Version})"
};
if (conn.OpenApiInfo?.Description is not null)
{
tag["description"] = conn.OpenApiInfo.Description;
}
tags.Add(tag);
}
}
return tags;
}
}

View File

@@ -0,0 +1,124 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Gateway.WebService.OpenApi;
/// <summary>
/// Endpoints for serving OpenAPI documentation.
/// </summary>
public static class OpenApiEndpoints
{
private static readonly ISerializer YamlSerializer = new SerializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
/// <summary>
/// Maps OpenAPI endpoints to the application.
/// </summary>
public static IEndpointRouteBuilder MapGatewayOpenApiEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/.well-known/openapi", GetOpenApiDiscovery)
.ExcludeFromDescription();
endpoints.MapGet("/openapi.json", GetOpenApiJson)
.ExcludeFromDescription();
endpoints.MapGet("/openapi.yaml", GetOpenApiYaml)
.ExcludeFromDescription();
return endpoints;
}
private static IResult GetOpenApiDiscovery(
[FromServices] IGatewayOpenApiDocumentCache cache,
HttpContext context)
{
var (_, etag, generatedAt) = cache.GetDocument();
var discovery = new
{
openapi_json = "/openapi.json",
openapi_yaml = "/openapi.yaml",
etag,
generated_at = generatedAt.ToString("O")
};
context.Response.Headers.CacheControl = "public, max-age=60";
return Results.Ok(discovery);
}
private static IResult GetOpenApiJson(
[FromServices] IGatewayOpenApiDocumentCache cache,
HttpContext context)
{
var (documentJson, etag, _) = cache.GetDocument();
// Check If-None-Match header
if (context.Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch))
{
if (ifNoneMatch == etag)
{
context.Response.Headers.ETag = etag;
context.Response.Headers.CacheControl = "public, max-age=60";
return Results.StatusCode(304);
}
}
context.Response.Headers.ETag = etag;
context.Response.Headers.CacheControl = "public, max-age=60";
return Results.Content(documentJson, "application/json; charset=utf-8");
}
private static IResult GetOpenApiYaml(
[FromServices] IGatewayOpenApiDocumentCache cache,
HttpContext context)
{
var (documentJson, etag, _) = cache.GetDocument();
// Check If-None-Match header
if (context.Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch))
{
if (ifNoneMatch == etag)
{
context.Response.Headers.ETag = etag;
context.Response.Headers.CacheControl = "public, max-age=60";
return Results.StatusCode(304);
}
}
// Convert JSON to YAML
var jsonNode = JsonNode.Parse(documentJson);
var yamlContent = ConvertToYaml(jsonNode);
context.Response.Headers.ETag = etag;
context.Response.Headers.CacheControl = "public, max-age=60";
return Results.Content(yamlContent, "application/yaml; charset=utf-8");
}
private static string ConvertToYaml(JsonNode? node)
{
if (node is null)
return string.Empty;
var obj = ConvertJsonNodeToObject(node);
return YamlSerializer.Serialize(obj);
}
private static object? ConvertJsonNodeToObject(JsonNode? node)
{
return node switch
{
null => null,
JsonObject obj => obj.ToDictionary(
kvp => kvp.Key,
kvp => ConvertJsonNodeToObject(kvp.Value)),
JsonArray arr => arr.Select(ConvertJsonNodeToObject).ToList(),
JsonValue val => val.GetValue<object>(),
_ => null
};
}
}

View File

@@ -1,3 +1,4 @@
using StellaOps.Gateway.WebService.OpenApi;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Transport.InMemory;
@@ -41,6 +42,12 @@ public static class ServiceCollectionExtensions
// Register health monitor as hosted service
services.AddHostedService<HealthMonitorService>();
// Register OpenAPI aggregation services
services.Configure<OpenApiAggregationOptions>(
configuration.GetSection(OpenApiAggregationOptions.SectionName));
services.AddSingleton<IOpenApiDocumentGenerator, OpenApiDocumentGenerator>();
services.AddSingleton<IGatewayOpenApiDocumentCache, GatewayOpenApiDocumentCache>();
return services;
}

View File

@@ -6,6 +6,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="YamlDotNet" Version="16.2.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Config\StellaOps.Router.Config.csproj" />