using System.Globalization; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Mvc; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; namespace StellaOps.Router.Gateway.OpenApi; /// /// Endpoints for serving OpenAPI documentation. /// public static class OpenApiEndpoints { private static readonly ISerializer YamlSerializer = new SerializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) .Build(); /// /// Maps OpenAPI endpoints to the application. /// public static IEndpointRouteBuilder MapRouterOpenApiEndpoints(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] IRouterOpenApiDocumentCache 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", CultureInfo.InvariantCulture) }; context.Response.Headers.CacheControl = "public, max-age=60"; return Results.Ok(discovery); } private static IResult GetOpenApiJson( [FromServices] IRouterOpenApiDocumentCache 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] IRouterOpenApiDocumentCache 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(), _ => null }; } }