Add unit tests for RabbitMq and Udp transport servers and clients
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]}\"";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user