diff --git a/DoxaApi/Attributes/DoxaApiAuthAttribute.cs b/DoxaApi/Attributes/DoxaApiAuthAttribute.cs new file mode 100644 index 0000000..f8fd9e5 --- /dev/null +++ b/DoxaApi/Attributes/DoxaApiAuthAttribute.cs @@ -0,0 +1,27 @@ +namespace EonaCat.DoxaApi.Attributes +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + /// + /// Declares that an endpoint (or every endpoint in a controller) requires one of the + /// named security schemes registered via DoxaApiOptions.AddSecurityScheme(...). + /// Multiple attributes may be stacked; any one of the referenced schemes satisfies the + /// endpoint (logical OR), matching OpenAPI's security requirement semantics. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] + public sealed class DoxaApiAuthAttribute : Attribute + { + public string SchemeId { get; } + public DoxaApiAuthAttribute(string schemeId) => SchemeId = schemeId; + } + + /// + /// Marks an endpoint (or controller) as explicitly not requiring authentication, + /// overriding any document-wide default security scheme. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public sealed class DoxaApiAllowAnonymousAttribute : Attribute + { + } +} \ No newline at end of file diff --git a/DoxaApi/Attributes/DoxaApiDescriptionAttribute.cs b/DoxaApi/Attributes/DoxaApiDescriptionAttribute.cs index 36485bf..584976a 100644 --- a/DoxaApi/Attributes/DoxaApiDescriptionAttribute.cs +++ b/DoxaApi/Attributes/DoxaApiDescriptionAttribute.cs @@ -1,5 +1,8 @@ namespace EonaCat.DoxaApi.Attributes { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + [AttributeUsage(AttributeTargets.Method)] public sealed class DoxaApiDescriptionAttribute : Attribute { diff --git a/DoxaApi/Attributes/DoxaApiExampleAttribute.cs b/DoxaApi/Attributes/DoxaApiExampleAttribute.cs index 347bf82..1932037 100644 --- a/DoxaApi/Attributes/DoxaApiExampleAttribute.cs +++ b/DoxaApi/Attributes/DoxaApiExampleAttribute.cs @@ -1,5 +1,8 @@ namespace EonaCat.DoxaApi.Attributes { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + [AttributeUsage(AttributeTargets.Method)] public sealed class DoxaApiExampleAttribute : Attribute { diff --git a/DoxaApi/Attributes/DoxaApiGroupAttribute.cs b/DoxaApi/Attributes/DoxaApiGroupAttribute.cs index 73c9d66..3d4f25c 100644 --- a/DoxaApi/Attributes/DoxaApiGroupAttribute.cs +++ b/DoxaApi/Attributes/DoxaApiGroupAttribute.cs @@ -1,5 +1,8 @@ namespace EonaCat.DoxaApi.Attributes { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public sealed class DoxaApiGroupAttribute : Attribute { diff --git a/DoxaApi/Attributes/DoxaApiHiddenAttribute.cs b/DoxaApi/Attributes/DoxaApiHiddenAttribute.cs index 0ae45e7..421948d 100644 --- a/DoxaApi/Attributes/DoxaApiHiddenAttribute.cs +++ b/DoxaApi/Attributes/DoxaApiHiddenAttribute.cs @@ -1,5 +1,8 @@ namespace EonaCat.DoxaApi.Attributes { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public sealed class DoxaApiHiddenAttribute : Attribute { diff --git a/DoxaApi/Attributes/DoxaApiSummaryAttribute.cs b/DoxaApi/Attributes/DoxaApiSummaryAttribute.cs index ca8974d..c1e9fda 100644 --- a/DoxaApi/Attributes/DoxaApiSummaryAttribute.cs +++ b/DoxaApi/Attributes/DoxaApiSummaryAttribute.cs @@ -1,5 +1,8 @@ namespace EonaCat.DoxaApi.Attributes { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + [AttributeUsage(AttributeTargets.Method)] public sealed class DoxaApiSummaryAttribute : Attribute { diff --git a/DoxaApi/ConsoleProxy.cs b/DoxaApi/ConsoleProxy.cs new file mode 100644 index 0000000..b966226 --- /dev/null +++ b/DoxaApi/ConsoleProxy.cs @@ -0,0 +1,150 @@ +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http; + +namespace EonaCat.DoxaApi.Middleware +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + /// + /// Backs the DoxaApi "Console" feature: lets the UI send an arbitrary HTTP request + /// (method, absolute URL, headers, body) through the server rather than the browser. + /// This sidesteps CORS restrictions when documenting third-party or differently-hosted + /// APIs, and keeps credentials out of client-side network logs when desired. + /// + /// Only absolute http(s) URLs are allowed, and a small set of hop-by-hop / sensitive + /// headers are stripped from both the outgoing request and the proxied response to + /// avoid surprising behavior. + /// + internal static class ConsoleProxy + { + private static readonly HttpClient _client = new(new HttpClientHandler + { + AllowAutoRedirect = false + }) + { + Timeout = TimeSpan.FromSeconds(30) + }; + + private static readonly HashSet _blockedRequestHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "host", "content-length", "connection", "transfer-encoding" + }; + + public static async Task HandleAsync(HttpContext context, JsonSerializerOptions writeOptions) + { + ConsoleRequest? req; + try + { + req = await JsonSerializer.DeserializeAsync(context.Request.Body, ReadOptions); + } + catch (Exception ex) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync($"Invalid console request body: {ex.Message}"); + return; + } + + if (req is null || string.IsNullOrWhiteSpace(req.Url)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("A 'url' field is required."); + return; + } + + if (!Uri.TryCreate(req.Url, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Only absolute http:// or https:// URLs are supported."); + return; + } + + var method = string.IsNullOrWhiteSpace(req.Method) ? "GET" : req.Method.ToUpperInvariant(); + + using var requestMessage = new HttpRequestMessage(new HttpMethod(method), uri); + + if (req.Headers is not null) + { + foreach (var (key, value) in req.Headers) + { + if (_blockedRequestHeaders.Contains(key)) + { + continue; + } + + requestMessage.Headers.TryAddWithoutValidation(key, value); + } + } + + if (!string.IsNullOrEmpty(req.Body) && method is not ("GET" or "HEAD")) + { + var contentType = req.Headers?.FirstOrDefault(h => + string.Equals(h.Key, "content-type", StringComparison.OrdinalIgnoreCase)).Value + ?? "application/json"; + + requestMessage.Content = new StringContent(req.Body, Encoding.UTF8); + requestMessage.Content.Headers.Remove("Content-Type"); + requestMessage.Content.Headers.TryAddWithoutValidation("Content-Type", contentType); + } + + var sw = System.Diagnostics.Stopwatch.StartNew(); + try + { + using var responseMessage = await _client.SendAsync(requestMessage, context.RequestAborted); + sw.Stop(); + + var responseBody = await responseMessage.Content.ReadAsStringAsync(context.RequestAborted); + + var responseHeaders = new Dictionary(); + foreach (var h in responseMessage.Headers) + { + responseHeaders[h.Key] = string.Join(", ", h.Value); + } + foreach (var h in responseMessage.Content.Headers) + { + responseHeaders[h.Key] = string.Join(", ", h.Value); + } + + var result = new ConsoleResponse + { + StatusCode = (int)responseMessage.StatusCode, + ElapsedMs = sw.ElapsedMilliseconds, + Headers = responseHeaders, + Body = responseBody + }; + + context.Response.ContentType = "application/json; charset=utf-8"; + await JsonSerializer.SerializeAsync(context.Response.Body, result, writeOptions); + } + catch (TaskCanceledException) + { + context.Response.StatusCode = 504; + await JsonSerializer.SerializeAsync(context.Response.Body, new ConsoleResponse + { + StatusCode = 0, + ElapsedMs = sw.ElapsedMilliseconds, + NetworkError = true, + Body = "Request timed out after 30 seconds." + }, writeOptions); + } + catch (Exception ex) + { + context.Response.StatusCode = 502; + await JsonSerializer.SerializeAsync(context.Response.Body, new ConsoleResponse + { + StatusCode = 0, + ElapsedMs = sw.ElapsedMilliseconds, + NetworkError = true, + Body = ex.Message + }, writeOptions); + } + } + + private static readonly JsonSerializerOptions ReadOptions = new() + { + PropertyNameCaseInsensitive = true + }; + } +} \ No newline at end of file diff --git a/DoxaApi/ConsoleRequest.cs b/DoxaApi/ConsoleRequest.cs new file mode 100644 index 0000000..c7a977f --- /dev/null +++ b/DoxaApi/ConsoleRequest.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace EonaCat.DoxaApi.Middleware +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + internal sealed class ConsoleRequest + { + [JsonPropertyName("method")] + public string Method { get; set; } = "GET"; + + [JsonPropertyName("url")] + public string Url { get; set; } = ""; + + [JsonPropertyName("headers")] + public Dictionary? Headers { get; set; } + + [JsonPropertyName("body")] + public string? Body { get; set; } + } +} \ No newline at end of file diff --git a/DoxaApi/ConsoleResponse.cs b/DoxaApi/ConsoleResponse.cs new file mode 100644 index 0000000..5802bb4 --- /dev/null +++ b/DoxaApi/ConsoleResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace EonaCat.DoxaApi.Middleware +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + internal sealed class ConsoleResponse + { + [JsonPropertyName("statusCode")] + public int StatusCode { get; set; } + + [JsonPropertyName("elapsedMs")] + public long ElapsedMs { get; set; } + + [JsonPropertyName("headers")] + public Dictionary Headers { get; set; } = new(); + + [JsonPropertyName("body")] + public string Body { get; set; } = ""; + + [JsonPropertyName("networkError")] + public bool NetworkError { get; set; } + } +} \ No newline at end of file diff --git a/DoxaApi/EonaCat.DoxaApi.csproj b/DoxaApi/EonaCat.DoxaApi.csproj index c47eaed..e2afdbb 100644 --- a/DoxaApi/EonaCat.DoxaApi.csproj +++ b/DoxaApi/EonaCat.DoxaApi.csproj @@ -10,10 +10,10 @@ EonaCat.DoxaApi - 0.0.4 + 0.0.5 EonaCat (Jeroen Saey) - A modern, self-contained, dependency-free OpenAPI documentation UI for ASP.NET Core. - openapi;swagger;documentation;api;aspnetcore;doxa;api;docs;documentation;Jeroen;Saey;EonaCat;Scalar;Redoc;Postman;EchoAPI; + A modern, self-contained, dependency-free API documentation UI for ASP.NET Core with a built-in auth manager, mock server, server-side request console, multi-language code generation, and spec diffing. + openapi;swagger;documentation;api;aspnetcore;doxa;docs;mock-server;oauth2;codegen;Jeroen;Saey;EonaCat;Scalar;Redoc;Postman;EchoAPI; https://git.saey.me/EonaCat/EonaCat.DoxaApi README.md true @@ -27,7 +27,6 @@ EonaCat (Jeroen Saey) https://git.saey.me/EonaCat/EonaCat.DoxaApi icon.png - LICENSE @@ -52,7 +51,6 @@ True \ - - + \ No newline at end of file diff --git a/DoxaApi/Exporter/ApiDocsExporter.cs b/DoxaApi/Exporter/ApiDocsExporter.cs deleted file mode 100644 index a85256f..0000000 --- a/DoxaApi/Exporter/ApiDocsExporter.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace EonaCat.DoxaApi.Exporter -{ - public static class DoxaApiExporter { } -} diff --git a/DoxaApi/Exporter/DoxaApiExporter.cs b/DoxaApi/Exporter/DoxaApiExporter.cs new file mode 100644 index 0000000..1a59e12 --- /dev/null +++ b/DoxaApi/Exporter/DoxaApiExporter.cs @@ -0,0 +1,7 @@ +namespace EonaCat.DoxaApi.Exporter +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + public static class DoxaApiExporter { } +} diff --git a/DoxaApi/Exporter/OpenApiExporter.cs b/DoxaApi/Exporter/OpenApiExporter.cs index 845f036..c4d1c7a 100644 --- a/DoxaApi/Exporter/OpenApiExporter.cs +++ b/DoxaApi/Exporter/OpenApiExporter.cs @@ -4,6 +4,8 @@ using EonaCat.DoxaApi.Models; namespace EonaCat.DoxaApi.Interop { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. public static class OpenApiExporter { public static JsonObject Export(ApiDocument doc) @@ -53,6 +55,24 @@ namespace EonaCat.DoxaApi.Interop root["components"] = new JsonObject { ["schemas"] = schemas }; } + if (doc.SecuritySchemes.Count > 0) + { + var schemes = new JsonObject(); + foreach (var (id, scheme) in doc.SecuritySchemes) + { + schemes[id] = SecuritySchemeToOpenApi(scheme); + } + + if (root["components"] is JsonObject componentsObj) + { + componentsObj["securitySchemes"] = schemes; + } + else + { + root["components"] = new JsonObject { ["securitySchemes"] = schemes }; + } + } + return root; } @@ -161,9 +181,94 @@ namespace EonaCat.DoxaApi.Interop } op["responses"] = responses; + if (endpoint.Security.Count > 0) + { + var security = new JsonArray(); + foreach (var schemeId in endpoint.Security) + { + security.Add(new JsonObject { [schemeId] = new JsonArray() }); + } + op["security"] = security; + } + return op; } + private static JsonObject SecuritySchemeToOpenApi(SecuritySchemeModel scheme) + { + var obj = new JsonObject { ["type"] = scheme.Type }; + + if (scheme.Description is not null) + { + obj["description"] = scheme.Description; + } + + switch (scheme.Type) + { + case "apiKey": + obj["name"] = scheme.Name ?? "X-API-Key"; + obj["in"] = scheme.In ?? "header"; + break; + + case "http": + obj["scheme"] = scheme.Scheme ?? "bearer"; + if (scheme.BearerFormat is not null) + { + obj["bearerFormat"] = scheme.BearerFormat; + } + break; + + case "oauth2": + var flows = new JsonObject(); + if (scheme.Flows?.ClientCredentials is OAuthFlowModel cc) + { + flows["clientCredentials"] = OAuthFlowToOpenApi(cc); + } + if (scheme.Flows?.AuthorizationCode is OAuthFlowModel ac) + { + flows["authorizationCode"] = OAuthFlowToOpenApi(ac); + } + if (scheme.Flows?.Password is OAuthFlowModel pw) + { + flows["password"] = OAuthFlowToOpenApi(pw); + } + if (scheme.Flows?.Implicit is OAuthFlowModel im) + { + flows["implicit"] = OAuthFlowToOpenApi(im); + } + obj["flows"] = flows; + break; + } + + return obj; + } + + private static JsonObject OAuthFlowToOpenApi(OAuthFlowModel flow) + { + var obj = new JsonObject(); + if (flow.AuthorizationUrl is not null) + { + obj["authorizationUrl"] = flow.AuthorizationUrl; + } + if (flow.TokenUrl is not null) + { + obj["tokenUrl"] = flow.TokenUrl; + } + if (flow.RefreshUrl is not null) + { + obj["refreshUrl"] = flow.RefreshUrl; + } + + var scopes = new JsonObject(); + foreach (var (k, v) in flow.Scopes) + { + scopes[k] = v; + } + obj["scopes"] = scopes; + + return obj; + } + private static JsonObject SchemaToOpenApi(SchemaModel schema) { @@ -269,4 +374,4 @@ namespace EonaCat.DoxaApi.Interop _ => "Response" }; } -} +} \ No newline at end of file diff --git a/DoxaApi/Exporter/SwaggerExporter.cs b/DoxaApi/Exporter/SwaggerExporter.cs index 639d1d4..44b0083 100644 --- a/DoxaApi/Exporter/SwaggerExporter.cs +++ b/DoxaApi/Exporter/SwaggerExporter.cs @@ -3,6 +3,9 @@ using EonaCat.DoxaApi.Models; namespace EonaCat.DoxaApi.Interop { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + public static class SwaggerExporter { public static JsonObject Export(ApiDocument doc) @@ -57,6 +60,17 @@ namespace EonaCat.DoxaApi.Interop root["definitions"] = definitions; } + if (doc.SecuritySchemes.Count > 0) + { + var defs = new JsonObject(); + foreach (var (id, scheme) in doc.SecuritySchemes) + { + defs[id] = SecuritySchemeToSwagger(scheme); + } + + root["securityDefinitions"] = defs; + } + return root; } @@ -157,6 +171,16 @@ namespace EonaCat.DoxaApi.Interop } op["responses"] = responses; + if (endpoint.Security.Count > 0) + { + var security = new JsonArray(); + foreach (var schemeId in endpoint.Security) + { + security.Add(new JsonObject { [schemeId] = new JsonArray() }); + } + op["security"] = security; + } + return op; } @@ -285,6 +309,65 @@ namespace EonaCat.DoxaApi.Interop return obj; } + private static JsonObject SecuritySchemeToSwagger(SecuritySchemeModel scheme) + { + var obj = new JsonObject(); + + if (scheme.Type == "apiKey") + { + obj["type"] = "apiKey"; + obj["name"] = scheme.Name ?? "X-API-Key"; + obj["in"] = scheme.In ?? "header"; + } + else if (scheme.Type == "http" && scheme.Scheme == "basic") + { + obj["type"] = "basic"; + } + else if (scheme.Type == "http" && scheme.Scheme == "bearer") + { + // Swagger 2.0 has no native bearer type; represent as an apiKey-style + // Authorization header for maximum tool compatibility. + obj["type"] = "apiKey"; + obj["name"] = "Authorization"; + obj["in"] = "header"; + } + else if (scheme.Type == "oauth2") + { + obj["type"] = "oauth2"; + var flow = scheme.Flows?.ClientCredentials; + if (flow is not null) + { + obj["flow"] = "application"; + obj["tokenUrl"] = flow.TokenUrl; + var scopes = new JsonObject(); + foreach (var (k, v) in flow.Scopes) + { + scopes[k] = v; + } + obj["scopes"] = scopes; + } + else if (scheme.Flows?.AuthorizationCode is OAuthFlowModel ac) + { + obj["flow"] = "accessCode"; + obj["authorizationUrl"] = ac.AuthorizationUrl; + obj["tokenUrl"] = ac.TokenUrl; + var scopes = new JsonObject(); + foreach (var (k, v) in ac.Scopes) + { + scopes[k] = v; + } + obj["scopes"] = scopes; + } + } + + if (scheme.Description is not null) + { + obj["description"] = scheme.Description; + } + + return obj; + } + private static string ToSwaggerPath(string path) => path.StartsWith('/') ? path : "/" + path; @@ -301,4 +384,4 @@ namespace EonaCat.DoxaApi.Interop _ => "Response" }; } -} +} \ No newline at end of file diff --git a/DoxaApi/Generation/ApiDocumentGenerator.cs b/DoxaApi/Generation/ApiDocumentGenerator.cs index 6c823ad..610d85c 100644 --- a/DoxaApi/Generation/ApiDocumentGenerator.cs +++ b/DoxaApi/Generation/ApiDocumentGenerator.cs @@ -2,11 +2,15 @@ using System.Xml.Linq; using EonaCat.DoxaApi.Attributes; using EonaCat.DoxaApi.Models; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Controllers; namespace EonaCat.DoxaApi.Generation { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + public sealed class ApiDocumentGenerator { private readonly IReadOnlyList _actions; @@ -80,6 +84,12 @@ namespace EonaCat.DoxaApi.Generation } doc.Schemas = schemaRegistry; + doc.SecuritySchemes = _options.SecuritySchemes; + doc.Features = new ApiFeatureFlags + { + MockServer = _options.EnableMockServer, + Console = _options.EnableConsole + }; return doc; } @@ -112,6 +122,52 @@ namespace EonaCat.DoxaApi.Generation return name; } + /// + /// Determines which security schemes apply to an action, in priority order: + /// 1. Explicit [DoxaApiAllowAnonymous] on method or class -> no security. + /// 2. Explicit [DoxaApiAuth("schemeId")] on method (falls back to class) -> those schemes. + /// 3. Standard ASP.NET Core [Authorize] on method or class, combined with + /// DoxaApiOptions.DefaultSecurityScheme if one is configured. + /// 4. Otherwise: anonymous. + /// + private List ResolveSecurity(ControllerActionDescriptor cad) + { + var method = cad.MethodInfo; + var controller = cad.ControllerTypeInfo; + + bool anonymous = method.GetCustomAttribute() is not null + || controller.GetCustomAttribute() is not null + || method.GetCustomAttribute() is not null; + + if (anonymous) + { + return new List(); + } + + var methodSchemes = method.GetCustomAttributes().Select(a => a.SchemeId).ToList(); + if (methodSchemes.Count > 0) + { + return methodSchemes.Where(_options.SecuritySchemes.ContainsKey).ToList(); + } + + var classSchemes = controller.GetCustomAttributes().Select(a => a.SchemeId).ToList(); + if (classSchemes.Count > 0) + { + return classSchemes.Where(_options.SecuritySchemes.ContainsKey).ToList(); + } + + bool requiresAuth = method.GetCustomAttribute() is not null + || controller.GetCustomAttribute() is not null; + + if (requiresAuth && _options.DefaultSecurityScheme is not null + && _options.SecuritySchemes.ContainsKey(_options.DefaultSecurityScheme)) + { + return new List { _options.DefaultSecurityScheme }; + } + + return new List(); + } + private ApiEndpoint? BuildEndpoint(ControllerActionDescriptor cad, SchemaBuilder schemaBuilder) { var httpMethod = cad.ActionConstraints? @@ -136,7 +192,8 @@ namespace EonaCat.DoxaApi.Generation Summary = summaryAttr?.Summary ?? xmlDoc?.Summary ?? HumanizeName(cad.ActionName), Description = descAttr?.Description ?? xmlDoc?.Remarks, Deprecated = obsoleteAttr is not null, - Tags = new List { ResolveGroupName(cad) } + Tags = new List { ResolveGroupName(cad) }, + Security = ResolveSecurity(cad) }; foreach (var param in cad.Parameters) @@ -207,6 +264,12 @@ namespace EonaCat.DoxaApi.Generation endpoint.Responses.Add(new ResponseModel { StatusCode = "400", Description = "Invalid request" }); } + if (endpoint.Security.Count > 0) + { + endpoint.Responses.Add(new ResponseModel { StatusCode = "401", Description = "Unauthorized - missing or invalid credentials" }); + endpoint.Responses.Add(new ResponseModel { StatusCode = "403", Description = "Forbidden - insufficient permissions" }); + } + return endpoint; } @@ -275,4 +338,4 @@ namespace EonaCat.DoxaApi.Generation return reader; } } -} +} \ No newline at end of file diff --git a/DoxaApi/Generation/DoxaApiOptions.cs b/DoxaApi/Generation/DoxaApiOptions.cs index 35e87e4..2e64fa7 100644 --- a/DoxaApi/Generation/DoxaApiOptions.cs +++ b/DoxaApi/Generation/DoxaApiOptions.cs @@ -1,5 +1,10 @@ -namespace EonaCat.DoxaApi.Generation +using EonaCat.DoxaApi.Models; + +namespace EonaCat.DoxaApi.Generation { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + public sealed class DoxaApiOptions { public string Title { get; set; } = "DoxaApi Documentation"; @@ -9,5 +14,108 @@ public List Servers { get; set; } = new(); public string Theme { get; set; } = "auto"; public string AccentColor { get; set; } = "#6366f1"; + + /// + /// Security schemes available to this API, keyed by scheme id. Populate via + /// , , , + /// or , or add custom entries directly. + /// + public Dictionary SecuritySchemes { get; } = new(); + + /// + /// If set, endpoints without an explicit [DoxaApiAuth]/[Authorize]/[DoxaApiAllowAnonymous] + /// marker are assumed to require this scheme. Leave null to assume anonymous by default. + /// + public string? DefaultSecurityScheme { get; set; } + + /// + /// When true, exposes a mock-response endpoint (/{prefix}/mock/...) that returns + /// schema-shaped fake JSON for any documented operation without touching real + /// controller code - useful for frontend teams working ahead of a backend. + /// + public bool EnableMockServer { get; set; } = true; + + /// + /// When true, exposes a /{prefix}/console endpoint for running ad-hoc HTTP requests + /// (method/url/headers/body) against the API, independent of any documented operation. + /// + public bool EnableConsole { get; set; } = true; + + public DoxaApiOptions AddApiKeyAuth(string id, string headerOrQueryName, string @in = "header", string? description = null) + { + SecuritySchemes[id] = new SecuritySchemeModel + { + Id = id, + Type = "apiKey", + In = @in, + Name = headerOrQueryName, + Description = description + }; + return this; + } + + public DoxaApiOptions AddBearerAuth(string id = "bearer", string bearerFormat = "JWT", string? description = null) + { + SecuritySchemes[id] = new SecuritySchemeModel + { + Id = id, + Type = "http", + Scheme = "bearer", + BearerFormat = bearerFormat, + Description = description ?? "Bearer token authentication" + }; + return this; + } + + public DoxaApiOptions AddBasicAuth(string id = "basic", string? description = null) + { + SecuritySchemes[id] = new SecuritySchemeModel + { + Id = id, + Type = "http", + Scheme = "basic", + Description = description ?? "HTTP Basic authentication" + }; + return this; + } + + public DoxaApiOptions AddOAuth2ClientCredentials(string id, string tokenUrl, Dictionary? scopes = null, string? description = null) + { + SecuritySchemes[id] = new SecuritySchemeModel + { + Id = id, + Type = "oauth2", + Description = description ?? "OAuth 2.0 client credentials flow", + Flows = new OAuthFlowsModel + { + ClientCredentials = new OAuthFlowModel + { + TokenUrl = tokenUrl, + Scopes = scopes ?? new Dictionary() + } + } + }; + return this; + } + + public DoxaApiOptions AddOAuth2AuthorizationCode(string id, string authorizationUrl, string tokenUrl, Dictionary? scopes = null, string? description = null) + { + SecuritySchemes[id] = new SecuritySchemeModel + { + Id = id, + Type = "oauth2", + Description = description ?? "OAuth 2.0 authorization code flow", + Flows = new OAuthFlowsModel + { + AuthorizationCode = new OAuthFlowModel + { + AuthorizationUrl = authorizationUrl, + TokenUrl = tokenUrl, + Scopes = scopes ?? new Dictionary() + } + } + }; + return this; + } } -} +} \ No newline at end of file diff --git a/DoxaApi/Generation/MockResponseGenerator.cs b/DoxaApi/Generation/MockResponseGenerator.cs new file mode 100644 index 0000000..1fac1f2 --- /dev/null +++ b/DoxaApi/Generation/MockResponseGenerator.cs @@ -0,0 +1,88 @@ +using EonaCat.DoxaApi.Models; + +namespace EonaCat.DoxaApi.Generation +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + /// + /// Produces schema-shaped, deterministic-but-plausible fake JSON values for a given + /// . Used by the built-in mock server so that frontend teams + /// can develop against an API before its real implementation exists. + /// + public static class MockResponseGenerator + { + public static object? Generate(SchemaModel? schema, ApiDocument doc, int depth = 0, HashSet? seen = null) + { + seen ??= new HashSet(); + if (schema is null || depth > 8) + { + return null; + } + + if (schema.RefName is not null) + { + if (!seen.Add(schema.RefName)) + { + return new Dictionary(); + } + + if (doc.Schemas.TryGetValue(schema.RefName, out var resolved)) + { + return Generate(resolved, doc, depth + 1, seen); + } + + return new Dictionary(); + } + + switch (schema.Type) + { + case "string": + return MockString(schema.Format); + case "integer": + return RandomInt(depth); + case "number": + return Math.Round(RandomInt(depth) + 0.5, 2); + case "boolean": + return depth % 2 == 0; + case "enum": + return schema.EnumValues?.Count > 0 ? schema.EnumValues[0] : "value"; + case "array": + return new List + { + Generate(schema.Items, doc, depth + 1, seen), + Generate(schema.Items, doc, depth + 1, seen) + }; + case "object": + { + if (schema.Properties is null) + { + return new Dictionary(); + } + + var obj = new Dictionary(); + foreach (var (key, propSchema) in schema.Properties) + { + obj[key] = Generate(propSchema, doc, depth + 1, seen); + } + + return obj; + } + default: + return null; + } + } + + private static object MockString(string? format) => format switch + { + "date-time" => DateTime.UtcNow.ToString("O"), + "date" => DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"), + "time" => TimeOnly.FromDateTime(DateTime.UtcNow).ToString("HH:mm:ss"), + "uuid" => Guid.NewGuid().ToString(), + "uri" => "https://example.com/resource", + _ => "string" + }; + + private static int RandomInt(int depth) => 1 + depth; + } +} \ No newline at end of file diff --git a/DoxaApi/Generation/SchemaBuilder.cs b/DoxaApi/Generation/SchemaBuilder.cs index 2ddc500..2bff0b1 100644 --- a/DoxaApi/Generation/SchemaBuilder.cs +++ b/DoxaApi/Generation/SchemaBuilder.cs @@ -4,6 +4,8 @@ using EonaCat.DoxaApi.Models; namespace EonaCat.DoxaApi.Generation { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. public sealed class SchemaBuilder { private readonly Dictionary _registry; diff --git a/DoxaApi/Generation/XmlDocReader.cs b/DoxaApi/Generation/XmlDocReader.cs index a85ffb0..076afae 100644 --- a/DoxaApi/Generation/XmlDocReader.cs +++ b/DoxaApi/Generation/XmlDocReader.cs @@ -4,6 +4,9 @@ using System.Xml.Linq; namespace EonaCat.DoxaApi.Generation { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + internal sealed class MethodXmlDoc { public string? Summary { get; set; } diff --git a/DoxaApi/Importer/ApiDocsImporter.cs b/DoxaApi/Importer/ApiDocsImporter.cs index 0c0dece..56c4c43 100644 --- a/DoxaApi/Importer/ApiDocsImporter.cs +++ b/DoxaApi/Importer/ApiDocsImporter.cs @@ -3,6 +3,9 @@ using System.Text.Json; namespace EonaCat.DoxaApi.Interop { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + public static class DoxaApiImporter { public static ApiDocument Import(string json) diff --git a/DoxaApi/Importer/OpenApiImporter.cs b/DoxaApi/Importer/OpenApiImporter.cs index 2c4cb36..14a2c9e 100644 --- a/DoxaApi/Importer/OpenApiImporter.cs +++ b/DoxaApi/Importer/OpenApiImporter.cs @@ -4,9 +4,11 @@ using EonaCat.DoxaApi.Models; namespace EonaCat.DoxaApi.Interop { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + public static class OpenApiImporter { - public static ApiDocument Import(string json) { var node = JsonNode.Parse(json) ?? throw new ArgumentException("Input is not valid JSON."); @@ -77,6 +79,17 @@ namespace EonaCat.DoxaApi.Interop } } + if (root["components"]?["securitySchemes"] is JsonObject compSecuritySchemes) + { + foreach (var (id, schemeNode) in compSecuritySchemes) + { + if (schemeNode is JsonObject schemeObj) + { + doc.SecuritySchemes[id] = ParseSecurityScheme3(id, schemeObj); + } + } + } + var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); if (root["paths"] is JsonObject paths) @@ -130,19 +143,20 @@ namespace EonaCat.DoxaApi.Interop Method = method, Path = path, Deprecated = op["deprecated"]?.GetValue() ?? false, - Tags = ParseStringArray(op["tags"]) + Tags = ParseStringArray(op["tags"]), + Security = ParseSecurityArray(op["security"]) }; - if (op["parameters"] is JsonArray parameters) + if (op["parameters"] is JsonArray opParameters) { - foreach (var p in parameters) + foreach (var p in opParameters) { if (p is not JsonObject param) { continue; } - var schema = p["schema"] is JsonNode schemaNode + var schema = param["schema"] is JsonNode schemaNode ? ParseSchema3(schemaNode) : new SchemaModel { Type = "string" }; @@ -326,6 +340,17 @@ namespace EonaCat.DoxaApi.Interop } } + if (root["securityDefinitions"] is JsonObject secDefs) + { + foreach (var (id, defNode) in secDefs) + { + if (defNode is JsonObject defObj) + { + doc.SecuritySchemes[id] = ParseSecurityScheme2(id, defObj); + } + } + } + var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); if (root["paths"] is JsonObject paths) @@ -379,7 +404,8 @@ namespace EonaCat.DoxaApi.Interop Method = method, Path = path, Deprecated = op["deprecated"]?.GetValue() ?? false, - Tags = ParseStringArray(op["tags"]) + Tags = ParseStringArray(op["tags"]), + Security = ParseSecurityArray(op["security"]) }; if (op["parameters"] is JsonArray parameters) @@ -567,6 +593,144 @@ namespace EonaCat.DoxaApi.Interop return ("application/json", null); } + private static List ParseSecurityArray(JsonNode? node) + { + var list = new List(); + if (node is not JsonArray arr) + { + return list; + } + + foreach (var requirement in arr) + { + if (requirement is JsonObject reqObj) + { + foreach (var (schemeId, _) in reqObj) + { + list.Add(schemeId); + } + } + } + + return list; + } + + private static SecuritySchemeModel ParseSecurityScheme2(string id, JsonObject obj) + { + var type = obj["type"]?.GetValue() ?? "apiKey"; + var scheme = new SecuritySchemeModel + { + Id = id, + Description = obj["description"]?.GetValue() + }; + + if (type == "basic") + { + scheme.Type = "http"; + scheme.Scheme = "basic"; + } + else if (type == "apiKey") + { + scheme.Type = "apiKey"; + scheme.Name = obj["name"]?.GetValue(); + scheme.In = obj["in"]?.GetValue(); + } + else if (type == "oauth2") + { + scheme.Type = "oauth2"; + var flow = obj["flow"]?.GetValue(); + var flowModel = new OAuthFlowModel + { + AuthorizationUrl = obj["authorizationUrl"]?.GetValue(), + TokenUrl = obj["tokenUrl"]?.GetValue() + }; + if (obj["scopes"] is JsonObject scopesObj) + { + foreach (var (k, v) in scopesObj) + { + flowModel.Scopes[k] = v?.GetValue() ?? ""; + } + } + + scheme.Flows = flow switch + { + "application" => new OAuthFlowsModel { ClientCredentials = flowModel }, + "accessCode" => new OAuthFlowsModel { AuthorizationCode = flowModel }, + "password" => new OAuthFlowsModel { Password = flowModel }, + _ => new OAuthFlowsModel { Implicit = flowModel } + }; + } + else + { + scheme.Type = type; + } + + return scheme; + } + + private static SecuritySchemeModel ParseSecurityScheme3(string id, JsonObject obj) + { + var scheme = new SecuritySchemeModel + { + Id = id, + Type = obj["type"]?.GetValue() ?? "apiKey", + Description = obj["description"]?.GetValue() + }; + + switch (scheme.Type) + { + case "apiKey": + scheme.Name = obj["name"]?.GetValue(); + scheme.In = obj["in"]?.GetValue(); + break; + + case "http": + scheme.Scheme = obj["scheme"]?.GetValue(); + scheme.BearerFormat = obj["bearerFormat"]?.GetValue(); + break; + + case "oauth2": + if (obj["flows"] is JsonObject flowsObj) + { + scheme.Flows = new OAuthFlowsModel + { + ClientCredentials = ParseOAuthFlow3(flowsObj["clientCredentials"] as JsonObject), + AuthorizationCode = ParseOAuthFlow3(flowsObj["authorizationCode"] as JsonObject), + Password = ParseOAuthFlow3(flowsObj["password"] as JsonObject), + Implicit = ParseOAuthFlow3(flowsObj["implicit"] as JsonObject) + }; + } + break; + } + + return scheme; + } + + private static OAuthFlowModel? ParseOAuthFlow3(JsonObject? obj) + { + if (obj is null) + { + return null; + } + + var flow = new OAuthFlowModel + { + AuthorizationUrl = obj["authorizationUrl"]?.GetValue(), + TokenUrl = obj["tokenUrl"]?.GetValue(), + RefreshUrl = obj["refreshUrl"]?.GetValue() + }; + + if (obj["scopes"] is JsonObject scopesObj) + { + foreach (var (k, v) in scopesObj) + { + flow.Scopes[k] = v?.GetValue() ?? ""; + } + } + + return flow; + } + private static List ParseStringArray(JsonNode? node) { var list = new List(); @@ -600,4 +764,4 @@ namespace EonaCat.DoxaApi.Interop private static string SanitizePath(string path) => path.Trim('/').Replace('/', '_').Replace('{', '_').Replace('}', '_'); } -} +} \ No newline at end of file diff --git a/DoxaApi/Middleware/ApiDocsMiddleware.cs b/DoxaApi/Middleware/DoxaApiMiddlewareExtensions.cs similarity index 72% rename from DoxaApi/Middleware/ApiDocsMiddleware.cs rename to DoxaApi/Middleware/DoxaApiMiddlewareExtensions.cs index 2e46e11..2a78a03 100644 --- a/DoxaApi/Middleware/ApiDocsMiddleware.cs +++ b/DoxaApi/Middleware/DoxaApiMiddlewareExtensions.cs @@ -4,6 +4,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using EonaCat.DoxaApi.Generation; using EonaCat.DoxaApi.Interop; +using EonaCat.DoxaApi.Models; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; @@ -12,6 +13,9 @@ using Microsoft.Extensions.DependencyInjection; namespace EonaCat.DoxaApi.Middleware { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + public static class DoxaApiMiddlewareExtensions { private static readonly JsonSerializerOptions _writeOptions = new() @@ -100,6 +104,64 @@ namespace EonaCat.DoxaApi.Middleware }); }); + if (options.EnableMockServer) + { + app.Map(prefix + "/mock", mockApp => + { + mockApp.Run(async context => + { + var document = GenerateDocument(context, options); + var operationId = context.Request.Query["operationId"].FirstOrDefault(); + + ApiEndpoint? endpoint = null; + foreach (var group in document.Groups) + { + endpoint = group.Endpoints.FirstOrDefault(e => e.OperationId == operationId); + if (endpoint is not null) + { + break; + } + } + + if (endpoint is null) + { + context.Response.StatusCode = 404; + await context.Response.WriteAsync("Unknown operationId. Pass ?operationId= matching an entry in the spec."); + return; + } + + var successResponse = endpoint.Responses.FirstOrDefault(r => r.StatusCode.StartsWith('2')) + ?? endpoint.Responses.FirstOrDefault(); + + var mockValue = MockResponseGenerator.Generate(successResponse?.Schema, document); + + context.Response.StatusCode = int.TryParse(successResponse?.StatusCode, out var code) ? code : 200; + context.Response.ContentType = "application/json; charset=utf-8"; + context.Response.Headers["X-DoxaApi-Mock"] = "true"; + await JsonSerializer.SerializeAsync(context.Response.Body, mockValue, _writeOptions); + }); + }); + } + + if (options.EnableConsole) + { + app.Map(prefix + "/console", consoleApp => + { + consoleApp.Run(async context => + { + if (!HttpMethods.IsPost(context.Request.Method)) + { + context.Response.StatusCode = 405; + context.Response.Headers["Allow"] = "POST"; + await context.Response.WriteAsync("Method Not Allowed - use POST with a JSON ConsoleRequest body."); + return; + } + + await ConsoleProxy.HandleAsync(context, _writeOptions); + }); + }); + } + app.Map(prefix, uiApp => { uiApp.Run(async context => @@ -179,4 +241,4 @@ namespace EonaCat.DoxaApi.Middleware return (ms.ToArray(), contentType); } } -} +} \ No newline at end of file diff --git a/DoxaApi/Models/ApiDocument.cs b/DoxaApi/Models/ApiDocument.cs index d909d06..32ffc0b 100644 --- a/DoxaApi/Models/ApiDocument.cs +++ b/DoxaApi/Models/ApiDocument.cs @@ -2,6 +2,9 @@ namespace EonaCat.DoxaApi.Models { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + public sealed class ApiDocument { [JsonPropertyName("info")] @@ -15,5 +18,13 @@ namespace EonaCat.DoxaApi.Models [JsonPropertyName("schemas")] public Dictionary Schemas { get; set; } = new(); + + /// All security schemes available for this API (keyed by scheme id). + [JsonPropertyName("securitySchemes")] + public Dictionary SecuritySchemes { get; set; } = new(); + + /// Which optional DoxaApi server-side features are enabled, for UI feature detection. + [JsonPropertyName("features")] + public ApiFeatureFlags Features { get; set; } = new(); } -} +} \ No newline at end of file diff --git a/DoxaApi/Models/ApiEndpoint.cs b/DoxaApi/Models/ApiEndpoint.cs index 947124e..9144fab 100644 --- a/DoxaApi/Models/ApiEndpoint.cs +++ b/DoxaApi/Models/ApiEndpoint.cs @@ -2,6 +2,9 @@ namespace EonaCat.DoxaApi.Models { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + public sealed class ApiEndpoint { [JsonPropertyName("operationId")] @@ -33,5 +36,12 @@ namespace EonaCat.DoxaApi.Models [JsonPropertyName("responses")] public List Responses { get; set; } = new(); + + /// + /// Ids of security schemes that apply to this endpoint (referencing ApiDocument.SecuritySchemes). + /// Empty means the endpoint is unauthenticated (or uses the document-wide default, if any). + /// + [JsonPropertyName("security")] + public List Security { get; set; } = new(); } -} +} \ No newline at end of file diff --git a/DoxaApi/Models/ApiFeatureFlags.cs b/DoxaApi/Models/ApiFeatureFlags.cs new file mode 100644 index 0000000..f3a65da --- /dev/null +++ b/DoxaApi/Models/ApiFeatureFlags.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace EonaCat.DoxaApi.Models +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + public sealed class ApiFeatureFlags + { + [JsonPropertyName("mockServer")] + public bool MockServer { get; set; } + + [JsonPropertyName("console")] + public bool Console { get; set; } + } +} \ No newline at end of file diff --git a/DoxaApi/Models/ApiGroup.cs b/DoxaApi/Models/ApiGroup.cs index a2931f5..9b0c423 100644 --- a/DoxaApi/Models/ApiGroup.cs +++ b/DoxaApi/Models/ApiGroup.cs @@ -2,6 +2,9 @@ namespace EonaCat.DoxaApi.Models { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + public sealed class ApiGroup { [JsonPropertyName("name")] diff --git a/DoxaApi/Models/ApiInfo.cs b/DoxaApi/Models/ApiInfo.cs index 21a6452..907416f 100644 --- a/DoxaApi/Models/ApiInfo.cs +++ b/DoxaApi/Models/ApiInfo.cs @@ -2,6 +2,9 @@ namespace EonaCat.DoxaApi.Models { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + public sealed class ApiInfo { [JsonPropertyName("title")] diff --git a/DoxaApi/Models/ApiParameter.cs b/DoxaApi/Models/ApiParameter.cs index 992d1f5..57c1b0b 100644 --- a/DoxaApi/Models/ApiParameter.cs +++ b/DoxaApi/Models/ApiParameter.cs @@ -2,6 +2,9 @@ namespace EonaCat.DoxaApi.Models { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + public sealed class ApiParameter { [JsonPropertyName("name")] diff --git a/DoxaApi/Models/RequestBodyModel.cs b/DoxaApi/Models/RequestBodyModel.cs index fb8366b..0fb6708 100644 --- a/DoxaApi/Models/RequestBodyModel.cs +++ b/DoxaApi/Models/RequestBodyModel.cs @@ -2,6 +2,9 @@ namespace EonaCat.DoxaApi.Models { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + public sealed class RequestBodyModel { [JsonPropertyName("required")] diff --git a/DoxaApi/Models/ResponseModel.cs b/DoxaApi/Models/ResponseModel.cs index e2e0306..e930ce0 100644 --- a/DoxaApi/Models/ResponseModel.cs +++ b/DoxaApi/Models/ResponseModel.cs @@ -2,6 +2,9 @@ namespace EonaCat.DoxaApi.Models { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + public sealed class ResponseModel { [JsonPropertyName("statusCode")] diff --git a/DoxaApi/Models/SchemaModel.cs b/DoxaApi/Models/SchemaModel.cs index afc378d..c2afe05 100644 --- a/DoxaApi/Models/SchemaModel.cs +++ b/DoxaApi/Models/SchemaModel.cs @@ -2,6 +2,9 @@ namespace EonaCat.DoxaApi.Models { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + public sealed class SchemaModel { [JsonPropertyName("type")] diff --git a/DoxaApi/Models/SecuritySchemeModel.cs b/DoxaApi/Models/SecuritySchemeModel.cs new file mode 100644 index 0000000..053ecdf --- /dev/null +++ b/DoxaApi/Models/SecuritySchemeModel.cs @@ -0,0 +1,76 @@ +using System.Text.Json.Serialization; + +namespace EonaCat.DoxaApi.Models +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + /// + /// Describes how a single security scheme authenticates requests. + /// Mirrors the relevant parts of the OpenAPI "securityScheme" object so it can be + /// losslessly exported to OpenAPI/Swagger, while also carrying enough information + /// for the DoxaApi UI to render a live "Try it" authorization form. + /// + public sealed class SecuritySchemeModel + { + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// apiKey | http | oauth2 | openIdConnect + [JsonPropertyName("type")] + public string Type { get; set; } = "apiKey"; + + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// For apiKey: header | query | cookie + [JsonPropertyName("in")] + public string? In { get; set; } + + /// For apiKey: the header/query/cookie name. For http: ignored. + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// For http: bearer | basic | digest + [JsonPropertyName("scheme")] + public string? Scheme { get; set; } + + /// For http bearer: an informational hint such as "JWT". + [JsonPropertyName("bearerFormat")] + public string? BearerFormat { get; set; } + + /// For oauth2: supported flows (clientCredentials, authorizationCode, password, implicit). + [JsonPropertyName("flows")] + public OAuthFlowsModel? Flows { get; set; } + } + + public sealed class OAuthFlowsModel + { + [JsonPropertyName("clientCredentials")] + public OAuthFlowModel? ClientCredentials { get; set; } + + [JsonPropertyName("authorizationCode")] + public OAuthFlowModel? AuthorizationCode { get; set; } + + [JsonPropertyName("password")] + public OAuthFlowModel? Password { get; set; } + + [JsonPropertyName("implicit")] + public OAuthFlowModel? Implicit { get; set; } + } + + public sealed class OAuthFlowModel + { + [JsonPropertyName("authorizationUrl")] + public string? AuthorizationUrl { get; set; } + + [JsonPropertyName("tokenUrl")] + public string? TokenUrl { get; set; } + + [JsonPropertyName("refreshUrl")] + public string? RefreshUrl { get; set; } + + [JsonPropertyName("scopes")] + public Dictionary Scopes { get; set; } = new(); + } +} \ No newline at end of file diff --git a/DoxaApi/ServiceCollectionExtensions.cs b/DoxaApi/ServiceCollectionExtensions.cs index c027679..81ab2d1 100644 --- a/DoxaApi/ServiceCollectionExtensions.cs +++ b/DoxaApi/ServiceCollectionExtensions.cs @@ -3,9 +3,11 @@ using Microsoft.Extensions.DependencyInjection; namespace EonaCat.DoxaApi { + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + public static class DoxaApiServiceCollectionExtensions { - public static IServiceCollection AddDoxaApi(this IServiceCollection services, Action? configure = null) { var options = new DoxaApiOptions(); diff --git a/DoxaApi/UI/Assets/app.css b/DoxaApi/UI/Assets/app.css index 77dcc27..edd6a62 100644 --- a/DoxaApi/UI/Assets/app.css +++ b/DoxaApi/UI/Assets/app.css @@ -1312,6 +1312,21 @@ textarea.field-input { border-color: transparent; } +.icon-btn-badge { + position: relative; +} + + .icon-btn-badge .badge-dot { + position: absolute; + top: 5px; + right: 6px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--m-get); + border: 1.5px solid var(--bg-1); + } + /* curl snippet */ .curl-box { @@ -1441,33 +1456,443 @@ textarea.field-input { } -.api-switcher{ - display:flex; - align-items:center; - margin-right:12px; +.api-switcher { + display: flex; + align-items: center; + margin-right: 12px; } -#apiSelector{ - min-width:220px; - height:38px; - border-radius:10px; - padding:0 12px; - background:var(--panel,#1e1e1e); - color:var(--text,#fff); - border:1px solid var(--border,#3a3a3a); - font-weight:600; - cursor:pointer; +#apiSelector { + min-width: 220px; + height: 38px; + border-radius: 10px; + padding: 0 12px; + background: var(--panel,#1e1e1e); + color: var(--text,#fff); + border: 1px solid var(--border,#3a3a3a); + font-weight: 600; + cursor: pointer; } -#apiSelector:hover{ - filter:brightness(1.05); + #apiSelector:hover { + filter: brightness(1.05); + } + + #apiSelector:focus { + outline: none; + } + + +/* ---------------------------------------------------------------- */ +/* Advanced features: Auth, Code generation, History, Console, Mock */ +/* ---------------------------------------------------------------- */ + +.auth-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 11px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-2); + font-size: 11.5px; + color: var(--text-1); + margin-bottom: 14px; } -#apiSelector:focus{ - outline:none; + .auth-banner svg { + width: 14px; + height: 14px; + flex: 0 0 auto; + } + + .auth-banner.auth-set { + border-color: color-mix(in srgb, var(--m-get) 45%, var(--border)); + color: var(--text-0); + } + + .auth-banner.auth-set svg { + color: var(--m-get); + } + + .auth-banner.auth-missing svg { + color: var(--m-delete); + } + +.auth-banner-link { + margin-left: auto; + background: transparent; + border: 1px solid var(--border); + color: var(--text-0); + border-radius: 6px; + padding: 4px 9px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + font-family: var(--font-mono); + flex: 0 0 auto; } -.request-builder,.header-row,.query-row{display:flex;gap:8px;margin-bottom:8px} -.header-key,.query-key{flex:0 0 35%} -.header-value,.query-value,.request-url{flex:1} -.postman-section{margin-top:12px;padding-top:12px;border-top:1px solid var(--border)} + .auth-banner-link:hover { + border-color: var(--accent); + color: var(--accent); + } + +.auth-scheme-card { + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 12px; + margin-bottom: 12px; + background: var(--bg-2); +} + +.auth-scheme-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.auth-scheme-name { + font-weight: 700; + font-size: 12.5px; +} + +.auth-scheme-type { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--text-2); + background: var(--bg-3); + border: 1px solid var(--border); + padding: 1px 7px; + border-radius: 20px; +} + +.auth-scheme-status { + margin-left: auto; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-2); +} + + .auth-scheme-status.set { + background: var(--m-get); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--m-get) 25%, transparent); + } + +.auth-scheme-desc { + font-size: 11.5px; + color: var(--text-2); + margin: 0 0 10px; + line-height: 1.5; +} + +.auth-clear-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--text-2); + border-radius: 6px; + padding: 6px 10px; + font-size: 11px; + cursor: pointer; + font-family: var(--font-mono); + width: 100%; + margin-top: 4px; +} + + .auth-clear-btn:hover { + color: var(--m-delete); + border-color: var(--m-delete); + } + +.code-lang-tabs { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 12px; +} + +.code-lang-tab { + border: 1px solid var(--border); + background: var(--bg-2); + color: var(--text-1); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + padding: 5px 10px; + border-radius: 20px; + cursor: pointer; +} + + .code-lang-tab.active { + background: var(--accent); + border-color: var(--accent); + color: white; + } + +.history-empty { + padding: 40px 10px; + text-align: center; + color: var(--text-2); + font-size: 12px; +} + +.history-item { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 10px; + margin-bottom: 8px; + cursor: pointer; + transition: border-color .12s ease, background .12s ease; +} + + .history-item:hover { + border-color: var(--accent); + background: var(--bg-2); + } + +.history-item-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 3px; +} + +.history-item-time { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--text-2); + margin-left: auto; +} + +.history-item-path { + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--text-1); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mock-toggle-row { + display: flex; + align-items: center; + gap: 9px; + margin-bottom: 14px; + padding: 9px 11px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-2); +} + +.mock-toggle-label { + font-size: 11.5px; + color: var(--text-1); + flex: 1 1 auto; +} + +.switch { + position: relative; + width: 34px; + height: 19px; + flex: 0 0 auto; +} + + .switch input { + opacity: 0; + width: 0; + height: 0; + } + +.switch-slider { + position: absolute; + inset: 0; + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: 20px; + cursor: pointer; + transition: background .15s ease; +} + + .switch-slider::before { + content: ""; + position: absolute; + width: 13px; + height: 13px; + left: 2px; + top: 2px; + background: var(--text-2); + border-radius: 50%; + transition: transform .15s ease, background .15s ease; + } + +.switch input:checked + .switch-slider { + background: color-mix(in srgb, var(--accent) 30%, var(--bg-3)); + border-color: var(--accent); +} + + .switch input:checked + .switch-slider::before { + transform: translateX(15px); + background: var(--accent); + } + +.mock-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--m-patch); + background: color-mix(in srgb, var(--m-patch) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--m-patch) 35%, transparent); + padding: 2px 7px; + border-radius: 20px; +} + +.security-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 0 0; +} + +.security-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-1); + background: var(--bg-2); + border: 1px solid var(--border); + padding: 3px 9px; + border-radius: 20px; +} + + .security-badge svg { + width: 11px; + height: 11px; + color: var(--text-2); + } + +.console-trigger { + display: flex; + align-items: center; + gap: 7px; +} + + .console-trigger .request-url { + flex: 1 1 auto; + } + +.diff-summary { + display: flex; + flex-direction: column; + gap: 6px; + margin: 12px 0; +} + +.diff-row { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 12px; + padding: 8px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-2); +} + +.diff-tag { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + padding: 1px 7px; + border-radius: 20px; + flex: 0 0 auto; + text-transform: uppercase; +} + +.diff-tag-added { + color: var(--m-get); + background: color-mix(in srgb, var(--m-get) 16%, transparent); +} + +.diff-tag-removed { + color: var(--m-delete); + background: color-mix(in srgb, var(--m-delete) 16%, transparent); +} + +.diff-tag-changed { + color: var(--m-put); + background: color-mix(in srgb, var(--m-put) 16%, transparent); +} + +/* modal (used for auth config + console + diff) */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(5,6,10,0.55); + backdrop-filter: blur(2px); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.modal-box { + width: 100%; + max-width: 640px; + max-height: 86vh; + overflow-y: auto; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + padding: 22px; +} + + .modal-box.modal-wide { + max-width: 880px; + } + +.modal-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; +} + +.modal-title { + font-size: 15px; + font-weight: 700; +} + +.modal-close { + margin-left: auto; + background: transparent; + border: 1px solid var(--border); + color: var(--text-1); + width: 28px; + height: 28px; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + + .modal-close:hover { + border-color: var(--m-delete); + color: var(--m-delete); + } + + .modal-close svg { + width: 14px; + height: 14px; + } diff --git a/DoxaApi/UI/Assets/app.js b/DoxaApi/UI/Assets/app.js index 28c0c65..7a3cafa 100644 --- a/DoxaApi/UI/Assets/app.js +++ b/DoxaApi/UI/Assets/app.js @@ -8,8 +8,16 @@ let spec = null; let activeEndpoint = null; let activeTryTab = "body"; + let activeCodeLang = "curl"; + let mockMode = localStorage.getItem("DoxaApi.mockMode") === "1"; let collapsedGroups = new Set(JSON.parse(localStorage.getItem("DoxaApi.collapsed") || "[]")); + // schemeId -> { value } | { username, password } | { token } + let authCredentials = JSON.parse(localStorage.getItem("DoxaApi.auth") || "{}"); + + // Array of { id, method, path, status, elapsedMs, time, request, response } + let requestHistory = JSON.parse(localStorage.getItem("DoxaApi.history") || "[]"); + const el = { nav: document.getElementById("navContent"), detail: document.getElementById("detailContent"), @@ -19,13 +27,36 @@ brandTitle: document.getElementById("brandTitle"), brandVersion: document.getElementById("brandVersion"), apiSelector: document.getElementById("apiSelector"), + modalRoot: document.getElementById("modalRoot"), }; - function refreshApiSelector(){ - if(!el.apiSelector) return; - el.apiSelector.innerHTML = apis.map((a,i)=>``).join(''); - el.apiSelector.value=String(activeApiIndex); + function persistAuth() { + localStorage.setItem("DoxaApi.auth", JSON.stringify(authCredentials)); + updateAuthBadge(); + } + + function persistHistory() { + if (requestHistory.length > 30) requestHistory = requestHistory.slice(0, 30); + localStorage.setItem("DoxaApi.history", JSON.stringify(requestHistory)); + } + + function updateAuthBadge() { + const dot = document.getElementById("authBadgeDot"); + if (!dot) return; + const hasAny = Object.keys(authCredentials).some((k) => credentialIsSet(authCredentials[k])); + dot.style.display = hasAny ? "block" : "none"; + } + + function credentialIsSet(cred) { + if (!cred) return false; + return !!(cred.value || cred.token || (cred.username && cred.password)); + } + + function refreshApiSelector() { + if (!el.apiSelector) return; + el.apiSelector.innerHTML = apis.map((a, i) => ``).join(''); + el.apiSelector.value = String(activeApiIndex); } async function init() { @@ -38,8 +69,9 @@ const extend = apis.length > 0 && window.confirm("Extend the currently selected API?\n\nOK = Extend Current API\nCancel = Import As New API (default)"); if (extend) { - spec.groups = [...(spec.groups||[]), ...(importedSpec.groups||[])]; - spec.schemas = Object.assign(spec.schemas||{}, importedSpec.schemas||{}); + spec.groups = [...(spec.groups || []), ...(importedSpec.groups || [])]; + spec.schemas = Object.assign(spec.schemas || {}, importedSpec.schemas || {}); + spec.securitySchemes = Object.assign(spec.securitySchemes || {}, importedSpec.securitySchemes || {}); } else { apis.push(importedSpec); activeApiIndex = apis.length - 1; @@ -47,8 +79,8 @@ } refreshApiSelector(); - if(spec.info && spec.info.title) el.brandTitle.textContent = spec.info.title; - if(spec.info && spec.info.version) el.brandVersion.textContent = spec.info.version; + if (spec.info && spec.info.title) el.brandTitle.textContent = spec.info.title; + if (spec.info && spec.info.version) el.brandVersion.textContent = spec.info.version; } catch (err) { el.nav.innerHTML = ``; @@ -63,7 +95,8 @@ renderOverview(); renderTryEmpty(); bindGlobalEvents(); -} + updateAuthBadge(); + } function renderSkeleton() { el.nav.innerHTML = Array.from({ length: 6 }) @@ -79,6 +112,14 @@ return Object.keys(spec.schemas || {}).length; } + function mockServerAvailable() { + return !!(spec.features && spec.features.mockServer); + } + + function consoleAvailable() { + return !!(spec.features && spec.features.console); + } + // Nav rendering function renderNav(filterText) { const term = filterText.trim().toLowerCase(); @@ -184,8 +225,21 @@ html += `
${groups.length}
Groups
`; html += `
${totalEndpointCount()}
Endpoints
`; html += `
${totalSchemaCount()}
Schemas
`; + html += `
${Object.keys(spec.securitySchemes || {}).length}
Auth schemes
`; html += ``; + if (spec.securitySchemes && Object.keys(spec.securitySchemes).length > 0) { + html += `

Authentication

`; + html += `
`; + for (const [id, scheme] of Object.entries(spec.securitySchemes)) { + html += ``; + } + html += `
`; + } + html += `

Browse by group

`; for (const group of groups) { html += `
@@ -257,6 +311,17 @@ Deprecated - this endpoint may be removed in a future version
`; } + if (endpoint.security && endpoint.security.length > 0) { + html += `
`; + for (const schemeId of endpoint.security) { + const scheme = (spec.securitySchemes || {})[schemeId]; + html += ` + + ${escapeHtml(scheme ? authSchemeLabel(scheme) : schemeId)} + `; + } + html += `
`; + } html += ``; if (endpoint.parameters && endpoint.parameters.length > 0) { @@ -313,6 +378,15 @@ setTimeout(() => (btn.innerHTML = original), 1100); } + function authSchemeLabel(scheme) { + if (!scheme) return "Auth"; + if (scheme.type === "apiKey") return `API key (${scheme.name || "key"})`; + if (scheme.type === "http" && scheme.scheme === "bearer") return "Bearer token"; + if (scheme.type === "http" && scheme.scheme === "basic") return "Basic auth"; + if (scheme.type === "oauth2") return "OAuth 2.0"; + return scheme.id || "Auth"; + } + function schemaTypeLabel(schema) { if (!schema) return "any"; if (schema.refName) return schema.refName; @@ -385,11 +459,31 @@ html += `
- + +
`; html += `
`; + if (endpoint.security && endpoint.security.length > 0) { + const satisfied = endpoint.security.some((id) => credentialIsSet(authCredentials[id])); + html += `
+ + ${satisfied ? "Credentials will be sent with this request" : "This endpoint requires authorization"} + +
`; + } + + if (mockServerAvailable()) { + html += ``; + } + const pathParams = endpoint.parameters.filter((p) => p.in === "path"); const queryParams = endpoint.parameters.filter((p) => p.in === "query"); const headerParams = endpoint.parameters.filter((p) => p.in === "header"); @@ -443,16 +537,13 @@ html += `
`; html += `
`; // tryTabBody - html += `
-
- Shell snippet - -
-
${buildCurlSnippet(endpoint)}
-
`; + html += `
`; + html += renderCodeTab(endpoint); + html += `
`; + + html += `
`; + html += renderHistoryTab(endpoint); + html += `
`; html += ``; @@ -460,6 +551,19 @@ document.getElementById("sendBtn").addEventListener("click", () => sendTryRequest(endpoint)); + const mockToggle = document.getElementById("mockModeToggle"); + if (mockToggle) { + mockToggle.addEventListener("change", () => { + mockMode = mockToggle.checked; + localStorage.setItem("DoxaApi.mockMode", mockMode ? "1" : "0"); + }); + } + + const openAuthBtn = document.getElementById("openAuthFromTry"); + if (openAuthBtn) { + openAuthBtn.addEventListener("click", () => openAuthModal()); + } + el.try.querySelectorAll("[data-try-tab]").forEach((btn) => { btn.addEventListener("click", () => { activeTryTab = btn.dataset.tryTab; @@ -467,36 +571,265 @@ }); }); - const copyCurlBtn = document.getElementById("copyCurlBtn"); - if (copyCurlBtn) { - copyCurlBtn.addEventListener("click", () => { - const text = document.getElementById("curlSnippet").textContent; + bindCodeTabEvents(endpoint); + bindHistoryTabEvents(endpoint); + } + + function renderCodeTab(endpoint) { + const langs = [ + { id: "curl", label: "cURL" }, + { id: "js", label: "JavaScript" }, + { id: "python", label: "Python" }, + { id: "csharp", label: "C#" }, + { id: "go", label: "Go" }, + ]; + + let html = `
`; + for (const lang of langs) { + html += ``; + } + html += `
`; + + html += `
+ ${langs.find((l) => l.id === activeCodeLang).label} snippet + +
`; + html += `
${buildCodeSnippet(endpoint, activeCodeLang)}
`; + return html; + } + + function bindCodeTabEvents(endpoint) { + const root = document.getElementById("tryTabCode"); + if (!root) return; + + root.querySelectorAll("[data-code-lang]").forEach((btn) => { + btn.addEventListener("click", () => { + activeCodeLang = btn.dataset.codeLang; + document.getElementById("tryTabCode").innerHTML = renderCodeTab(endpoint); + bindCodeTabEvents(endpoint); + }); + }); + + const copyBtn = document.getElementById("copyCodeBtn"); + if (copyBtn) { + copyBtn.addEventListener("click", () => { + const text = document.getElementById("codeSnippet").textContent; navigator.clipboard.writeText(text).then(() => { - const original = copyCurlBtn.textContent; - copyCurlBtn.textContent = "Copied"; - setTimeout(() => (copyCurlBtn.innerHTML = `Copy`), 1100); + const original = copyBtn.innerHTML; + copyBtn.textContent = "Copied"; + setTimeout(() => (copyBtn.innerHTML = original), 1100); }); }); } } - function buildCurlSnippet(endpoint) { + function renderHistoryTab(endpoint) { + const items = requestHistory.filter((h) => h.operationId === endpoint.operationId); + if (items.length === 0) { + return `
No requests sent yet for this endpoint.
Responses you send will appear here.
`; + } + + let html = ""; + for (const item of items) { + const cls = item.status && item.status < 300 ? "status-2xx" : item.status && item.status < 500 ? "status-4xx" : "status-5xx"; + html += `
+
+ ${item.status || "ERR"} + ${escapeHtml(item.time)} +
+
${escapeHtml(item.method)} ${escapeHtml(item.url)}
+
`; + } + return html; + } + + function bindHistoryTabEvents(endpoint) { + const root = document.getElementById("tryTabHistory"); + if (!root) return; + root.querySelectorAll("[data-history-id]").forEach((node) => { + node.addEventListener("click", () => { + const item = requestHistory.find((h) => h.id === node.dataset.historyId); + if (!item) return; + activeTryTab = "body"; + renderTry({ name: "" }, endpoint); + showResponsePanelFromHistory(item); + }); + }); + } + + function showResponsePanelFromHistory(item) { + const panel = document.getElementById("responsePanel"); + if (!panel || !item.response) return; + renderResponsePanel(panel, item.response); + } + + // Builds a language-agnostic description of the request: url, headers, body. + // Shared by every code-snippet generator and by the auth-aware "Send" flow. + function buildRequestPlan(endpoint) { const base = (spec.servers && spec.servers[0]) || ""; - let path = endpoint.path; - // replace path params with placeholder tokens for readability - const lines = []; - lines.push(`curl -X ${endpoint.method} \\`); - lines.push(` "${escapeHtml(base)}${escapeHtml(path)}" \\`); - lines.push(` -H "Accept: application/json"`); + const path = endpoint.path; + const headers = { Accept: "application/json" }; + if (endpoint.requestBody) { - const example = endpoint.requestBody.example || generateExampleJson(endpoint.requestBody.schema, 0); - lines[lines.length - 1] += " \\"; - lines.push(` -H "Content-Type: ${escapeHtml(endpoint.requestBody.contentType || "application/json")}" \\`); - lines.push(` -d '${escapeHtml(example)}'`); + headers["Content-Type"] = endpoint.requestBody.contentType || "application/json"; + } + + applyAuthHeaders(endpoint, headers); + + const body = endpoint.requestBody + ? (endpoint.requestBody.example || generateExampleJson(endpoint.requestBody.schema, 0)) + : null; + + return { url: base + path, method: endpoint.method, headers, body }; + } + + // Mutates `headers` (and returns query/string additions where relevant) based on + // whichever security schemes apply to the endpoint and have stored credentials. + function applyAuthHeaders(endpoint, headers, queryParamsOut) { + if (!endpoint.security || endpoint.security.length === 0) return; + + for (const schemeId of endpoint.security) { + const scheme = (spec.securitySchemes || {})[schemeId]; + const cred = authCredentials[schemeId]; + if (!scheme || !credentialIsSet(cred)) continue; + + if (scheme.type === "apiKey") { + const headerName = scheme.name || "X-API-Key"; + if ((scheme.in || "header") === "header") { + headers[headerName] = cred.value; + } else if (scheme.in === "query" && queryParamsOut) { + queryParamsOut[headerName] = cred.value; + } + } else if (scheme.type === "http" && scheme.scheme === "bearer") { + headers["Authorization"] = `Bearer ${cred.token}`; + } else if (scheme.type === "http" && scheme.scheme === "basic") { + const encoded = typeof btoa === "function" ? btoa(`${cred.username}:${cred.password}`) : ""; + headers["Authorization"] = `Basic ${encoded}`; + } else if (scheme.type === "oauth2") { + headers["Authorization"] = `Bearer ${cred.token}`; + } + + // First matching satisfied scheme wins (schemes are an OR requirement). + break; + } + } + + function buildCodeSnippet(endpoint, lang) { + const plan = buildRequestPlan(endpoint); + switch (lang) { + case "js": return buildJsSnippet(plan); + case "python": return buildPythonSnippet(plan); + case "csharp": return buildCSharpSnippet(plan); + case "go": return buildGoSnippet(plan); + default: return buildCurlSnippet(plan); + } + } + + function buildCurlSnippet(plan) { + const lines = []; + lines.push(`curl -X ${plan.method} \\`); + lines.push(` "${escapeHtml(plan.url)}" \\`); + const headerEntries = Object.entries(plan.headers); + headerEntries.forEach(([k, v], i) => { + const isLast = i === headerEntries.length - 1 && !plan.body; + lines.push(` -H "${escapeHtml(k)}: ${escapeHtml(v)}"${isLast ? "" : " \\"}`); + }); + if (plan.body) { + lines.push(` -d '${escapeHtml(plan.body)}'`); } return lines.join("\n"); } + function buildJsSnippet(plan) { + const headersObj = JSON.stringify(plan.headers, null, 2).replace(/\n/g, "\n "); + const lines = [ + `const response = await fetch("${plan.url}", {`, + ` method: "${plan.method}",`, + ` headers: ${headersObj},`, + ]; + if (plan.body) { + lines.push(` body: ${JSON.stringify(plan.body)},`); + } + lines.push(`});`); + lines.push(`const data = await response.json();`); + lines.push(`console.log(data);`); + return escapeHtml(lines.join("\n")); + } + + function buildPythonSnippet(plan) { + const lines = [`import requests`, ``, `response = requests.request(`, ` "${plan.method}",`, ` "${plan.url}",`]; + const headerLines = Object.entries(plan.headers).map(([k, v]) => ` "${k}": "${v}",`); + lines.push(` headers={`); + lines.push(...headerLines); + lines.push(` },`); + if (plan.body) { + lines.push(` data="""${plan.body}""",`); + } + lines.push(`)`); + lines.push(``); + lines.push(`print(response.status_code, response.json())`); + return escapeHtml(lines.join("\n")); + } + + function buildCSharpSnippet(plan) { + const lines = [ + `using var client = new HttpClient();`, + `using var request = new HttpRequestMessage(HttpMethod.${capitalize(plan.method.toLowerCase())}, "${plan.url}");`, + ]; + for (const [k, v] of Object.entries(plan.headers)) { + if (k.toLowerCase() === "content-type") continue; + lines.push(`request.Headers.TryAddWithoutValidation("${k}", "${v}");`); + } + if (plan.body) { + const contentType = plan.headers["Content-Type"] || "application/json"; + lines.push(`request.Content = new StringContent(${JSON.stringify(plan.body)}, System.Text.Encoding.UTF8, "${contentType}");`); + } + lines.push(`using var response = await client.SendAsync(request);`); + lines.push(`var body = await response.Content.ReadAsStringAsync();`); + lines.push(`Console.WriteLine($"{(int)response.StatusCode}: {body}");`); + return escapeHtml(lines.join("\n")); + } + + function buildGoSnippet(plan) { + const lines = [ + `package main`, + ``, + `import (`, + `\t"fmt"`, + `\t"io"`, + `\t"net/http"`, + `\t"strings"`, + `)`, + ``, + `func main() {`, + ]; + if (plan.body) { + lines.push(`\tbody := strings.NewReader(\`${plan.body.replace(/`/g, "'")}\`)`); + lines.push(`\treq, _ := http.NewRequest("${plan.method}", "${plan.url}", body)`); + } else { + lines.push(`\treq, _ := http.NewRequest("${plan.method}", "${plan.url}", nil)`); + } + for (const [k, v] of Object.entries(plan.headers)) { + lines.push(`\treq.Header.Set("${k}", "${v}")`); + } + lines.push(`\tresp, err := http.DefaultClient.Do(req)`); + lines.push(`\tif err != nil {`); + lines.push(`\t\tpanic(err)`); + lines.push(`\t}`); + lines.push(`\tdefer resp.Body.Close()`); + lines.push(`\tdata, _ := io.ReadAll(resp.Body)`); + lines.push(`\tfmt.Println(resp.StatusCode, string(data))`); + lines.push(`}`); + return escapeHtml(lines.join("\n")); + } + + function capitalize(s) { + return s.charAt(0).toUpperCase() + s.slice(1); + } + function generateExampleJson(schema, depth) { const value = generateExampleValue(schema, depth, new Set()); return JSON.stringify(value, null, 2); @@ -553,7 +886,15 @@ path = path.replace(`{${name}}`, encodeURIComponent(input.value || "")); }); - const url = new URL(path, window.location.origin); + const isMock = mockMode && mockServerAvailable(); + const url = isMock + ? new URL("mock", window.location.href) + : new URL(path, window.location.origin); + + if (isMock) { + url.searchParams.set("operationId", endpoint.operationId); + } + document.querySelectorAll('[data-param-in="query"]').forEach((input) => { if (input.value) url.searchParams.set(input.dataset.paramName, input.value); }); @@ -570,18 +911,27 @@ body = bodyEl ? bodyEl.value : undefined; } + const authQueryParams = {}; + if (!isMock) { + applyAuthHeaders(endpoint, headers, authQueryParams); + for (const [k, v] of Object.entries(authQueryParams)) { + url.searchParams.set(k, v); + } + } + btn.disabled = true; label.textContent = "Sending…"; btn.querySelector("svg").style.display = "none"; btn.insertBefore(spinnerEl(), btn.firstChild); const startTime = performance.now(); + let resultForHistory = null; try { const res = await fetch(url.toString(), { - method: endpoint.method, - headers, - body: endpoint.method === "GET" || endpoint.method === "HEAD" ? undefined : body, + method: isMock ? "GET" : endpoint.method, + headers: isMock ? { Accept: "application/json" } : headers, + body: isMock || endpoint.method === "GET" || endpoint.method === "HEAD" ? undefined : body, }); const elapsedMs = Math.round(performance.now() - startTime); @@ -601,29 +951,43 @@ bodyText = await res.text(); } - renderResponsePanel(panel, { + resultForHistory = { status: res.status, ok: res.ok, elapsedMs, body: bodyText, isJson, - }); + isMock, + }; + renderResponsePanel(panel, resultForHistory); } catch (err) { const elapsedMs = Math.round(performance.now() - startTime); - renderResponsePanel(panel, { + resultForHistory = { status: null, ok: false, elapsedMs, body: String(err && err.message ? err.message : err), isJson: false, networkError: true, - }); + }; + renderResponsePanel(panel, resultForHistory); } finally { btn.disabled = false; label.textContent = "Send request"; const spinner = btn.querySelector(".spinner"); if (spinner) spinner.remove(); btn.querySelector("svg").style.display = ""; + + requestHistory.unshift({ + id: "h" + Date.now() + Math.random().toString(36).slice(2, 7), + operationId: endpoint.operationId, + method: isMock ? "GET (mock)" : endpoint.method, + url: url.toString(), + status: resultForHistory ? resultForHistory.status : null, + time: new Date().toLocaleTimeString(), + response: resultForHistory, + }); + persistHistory(); } } @@ -648,6 +1012,10 @@
${statusLabel} + ${result.isMock ? ` + + Mock + ` : ""} ${result.elapsedMs} ms @@ -688,9 +1056,331 @@ ); } + // --------------------------------------------------------------- + // Modal system - lightweight, dependency-free, used for Auth, + // Console, and Diff. Only one modal is shown at a time. + // --------------------------------------------------------------- + function closeModal() { + if (!el.modalRoot) return; + el.modalRoot.innerHTML = ""; + } + + function openModal(title, bodyHtml, { wide } = {}) { + if (!el.modalRoot) return null; + el.modalRoot.innerHTML = ` + `; + + const backdrop = document.getElementById("modalBackdrop"); + document.getElementById("modalCloseBtn").addEventListener("click", closeModal); + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) closeModal(); + }); + const escHandler = (e) => { + if (e.key === "Escape") { + closeModal(); + document.removeEventListener("keydown", escHandler); + } + }; + document.addEventListener("keydown", escHandler); + + return document.getElementById("modalBody"); + } + + // --------------------------------------------------------------- + // Authorization modal - one card per security scheme declared by + // the API, with scheme-appropriate inputs (API key / bearer token / + // basic credentials / OAuth2 client credentials). Values are kept + // in localStorage so they survive reloads, and are applied to every + // "Try it" request, cURL snippet, and generated code sample whose + // endpoint references that scheme. + // --------------------------------------------------------------- + function openAuthModal() { + const schemes = (spec && spec.securitySchemes) || {}; + const ids = Object.keys(schemes); + + let body; + if (ids.length === 0) { + body = `
This API doesn't declare any security schemes.
Configure them via DoxaApiOptions.AddBearerAuth() and similar helpers.
`; + } else { + body = ids.map((id) => authSchemeCardHtml(id, schemes[id])).join(""); + } + + const modalBody = openModal("Authorization", body, {}); + if (!modalBody || ids.length === 0) return; + + for (const id of ids) { + bindAuthSchemeCard(modalBody, id, schemes[id]); + } + } + + function authSchemeCardHtml(id, scheme) { + const cred = authCredentials[id] || {}; + const isSet = credentialIsSet(cred); + + let fieldsHtml = ""; + if (scheme.type === "apiKey") { + fieldsHtml = `
+
${escapeHtml(scheme.name || "API key")}${escapeHtml(scheme.in || "header")}
+ +
`; + } else if (scheme.type === "http" && scheme.scheme === "basic") { + fieldsHtml = `
+
Username
+ +
+
+
Password
+ +
`; + } else if (scheme.type === "oauth2") { + const flow = scheme.flows && (scheme.flows.clientCredentials || scheme.flows.authorizationCode); + fieldsHtml = `
+
Access tokenpaste a token, or fetch one separately
+ +
+ ${flow && flow.tokenUrl ? `
Token URL: ${escapeHtml(flow.tokenUrl)}
` : ""}`; + } else { + // http bearer (default) and any unrecognized http-style scheme + fieldsHtml = `
+
Bearer token${scheme.bearerFormat ? ` ${escapeHtml(scheme.bearerFormat)}` : ""}
+ +
`; + } + + return `
+
+ ${escapeHtml(authSchemeLabel(scheme))} + ${escapeHtml(scheme.type)} + +
+ ${scheme.description ? `

${escapeHtml(scheme.description)}

` : ""} + ${fieldsHtml} + +
`; + } + + function bindAuthSchemeCard(root, id, scheme) { + const card = root.querySelector(`[data-scheme-id="${cssEscape(id)}"]`); + if (!card) return; + + const persistFromInputs = () => { + const cred = {}; + card.querySelectorAll("[data-auth-field]").forEach((input) => { + cred[input.dataset.authField] = input.value; + }); + authCredentials[id] = cred; + persistAuth(); + + const statusDot = card.querySelector(".auth-scheme-status"); + if (statusDot) statusDot.classList.toggle("set", credentialIsSet(cred)); + }; + + card.querySelectorAll("[data-auth-field]").forEach((input) => { + input.addEventListener("input", persistFromInputs); + }); + + const clearBtn = card.querySelector("[data-auth-clear]"); + if (clearBtn) { + clearBtn.addEventListener("click", () => { + delete authCredentials[id]; + persistAuth(); + card.querySelectorAll("[data-auth-field]").forEach((input) => (input.value = "")); + const statusDot = card.querySelector(".auth-scheme-status"); + if (statusDot) statusDot.classList.remove("set"); + }); + } + } + + function cssEscape(s) { + return String(s).replace(/["\\]/g, "\\$&"); + } + + // --------------------------------------------------------------- + // Console - a free-form HTTP request runner that isn't tied to any + // documented operation. Requests are sent server-side via the + // /{prefix}/console endpoint, which avoids CORS restrictions when + // pointing at a different host than the one serving the docs. + // --------------------------------------------------------------- + function openConsoleModal() { + const base = (spec.servers && spec.servers[0]) || ""; + const body = ` +
+ + +
+
+
Headers (one per line, "Name: value")
+ +
+
+
Body
+ +
+ +
+ `; + + const modalBody = openModal("API Console", body, { wide: true }); + if (!modalBody) return; + + document.getElementById("consoleSendBtn").addEventListener("click", sendConsoleRequest); + } + + function parseHeaderLines(text) { + const headers = {}; + (text || "").split("\n").forEach((line) => { + const idx = line.indexOf(":"); + if (idx === -1) return; + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (key) headers[key] = value; + }); + return headers; + } + + async function sendConsoleRequest() { + const btn = document.getElementById("consoleSendBtn"); + const label = document.getElementById("consoleSendLabel"); + const panel = document.getElementById("consoleResponsePanel"); + + const method = document.getElementById("consoleMethod").value; + const url = document.getElementById("consoleUrl").value.trim(); + const headers = parseHeaderLines(document.getElementById("consoleHeaders").value); + const body = document.getElementById("consoleBody").value; + + if (!url) { + panel.innerHTML = `
Enter a URL to send a request.
`; + return; + } + + btn.disabled = true; + label.textContent = "Sending…"; + + const startTime = performance.now(); + try { + const res = await fetch("console", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ method, url, headers, body: body || undefined }), + }); + + const payload = await res.json(); + const elapsedMs = payload.elapsedMs || Math.round(performance.now() - startTime); + + let isJson = false; + let bodyText = payload.body || ""; + try { + JSON.parse(bodyText); + isJson = true; + } catch { + isJson = false; + } + + renderResponsePanel(panel, { + status: payload.statusCode, + ok: payload.statusCode >= 200 && payload.statusCode < 300, + elapsedMs, + body: isJson ? JSON.stringify(JSON.parse(bodyText), null, 2) : bodyText, + isJson, + networkError: !!payload.networkError, + }); + } catch (err) { + renderResponsePanel(panel, { + status: null, + ok: false, + elapsedMs: Math.round(performance.now() - startTime), + body: String(err && err.message ? err.message : err), + isJson: false, + networkError: true, + }); + } finally { + btn.disabled = false; + label.textContent = "Send request"; + } + } + + // --------------------------------------------------------------- + // Diff - compares the currently loaded spec against an uploaded + // OpenAPI / Swagger / DoxaApi JSON file and reports added, removed, + // and changed operations - useful for spotting breaking changes + // before shipping a new API version. + // --------------------------------------------------------------- + function computeSpecDiff(oldSpec, newSpec) { + const key = (ep) => `${ep.method} ${ep.path}`; + const oldOps = new Map(); + const newOps = new Map(); + + for (const g of oldSpec.groups || []) for (const e of g.endpoints) oldOps.set(key(e), e); + for (const g of newSpec.groups || []) for (const e of g.endpoints) newOps.set(key(e), e); + + const rows = []; + for (const [k, ep] of newOps) { + if (!oldOps.has(k)) rows.push({ tag: "added", key: k, summary: ep.summary }); + } + for (const [k, ep] of oldOps) { + if (!newOps.has(k)) rows.push({ tag: "removed", key: k, summary: ep.summary }); + } + for (const [k, oldEp] of oldOps) { + const newEp = newOps.get(k); + if (!newEp) continue; + + const changes = []; + if ((oldEp.requestBody == null) !== (newEp.requestBody == null)) changes.push("request body"); + if (JSON.stringify((oldEp.parameters || []).map((p) => p.name).sort()) !== + JSON.stringify((newEp.parameters || []).map((p) => p.name).sort())) changes.push("parameters"); + if (JSON.stringify((oldEp.security || []).slice().sort()) !== + JSON.stringify((newEp.security || []).slice().sort())) changes.push("security"); + if (!!oldEp.deprecated !== !!newEp.deprecated) changes.push("deprecation status"); + + if (changes.length > 0) { + rows.push({ tag: "changed", key: k, summary: changes.join(", ") }); + } + } + + return rows.sort((a, b) => a.key.localeCompare(b.key)); + } + + function openDiffModal(otherSpec) { + const rows = computeSpecDiff(spec, otherSpec); + const added = rows.filter((r) => r.tag === "added").length; + const removed = rows.filter((r) => r.tag === "removed").length; + const changed = rows.filter((r) => r.tag === "changed").length; + + let body = `
+
+${added} endpoints added
+
-${removed} endpoints removed
+
~${changed} endpoints changed
+
`; + + if (rows.length === 0) { + body += `
No differences found between the current API and the uploaded spec.
`; + } else { + body += rows.map((r) => `
+ ${r.tag} + ${escapeHtml(r.key)}${r.summary ? ` - ${escapeHtml(r.summary)}` : ""} +
`).join(""); + } + + openModal("Compare specs", body, { wide: true }); + } + // Global events - function bindGlobalEvents() - { + function bindGlobalEvents() { const importBtn = document.getElementById("importBtn"); const importFile = document.getElementById("importFile"); const exportDoxaApiBtn = document.getElementById("exportDoxaApiBtn"); @@ -720,8 +1410,9 @@ const extend = apis.length > 0 && window.confirm("Extend the currently selected API?\n\nOK = Extend Current API\nCancel = Import As New API (default)"); if (extend) { - spec.groups = [...(spec.groups||[]), ...(importedSpec.groups||[])]; - spec.schemas = Object.assign(spec.schemas||{}, importedSpec.schemas||{}); + spec.groups = [...(spec.groups || []), ...(importedSpec.groups || [])]; + spec.schemas = Object.assign(spec.schemas || {}, importedSpec.schemas || {}); + spec.securitySchemes = Object.assign(spec.securitySchemes || {}, importedSpec.securitySchemes || {}); } else { apis.push(importedSpec); activeApiIndex = apis.length - 1; @@ -729,8 +1420,8 @@ } refreshApiSelector(); - if(spec.info && spec.info.title) el.brandTitle.textContent = spec.info.title; - if(spec.info && spec.info.version) el.brandVersion.textContent = spec.info.version; + if (spec.info && spec.info.title) el.brandTitle.textContent = spec.info.title; + if (spec.info && spec.info.version) el.brandVersion.textContent = spec.info.version; activeEndpoint = null; @@ -752,6 +1443,47 @@ exportOpenApiBtn?.addEventListener("click", () => download("openapi.json", "openapi.json")); exportSwaggerBtn?.addEventListener("click", () => download("swagger.json", "swagger.json")); + const authBtn = document.getElementById("authBtn"); + authBtn?.addEventListener("click", () => openAuthModal()); + + const consoleBtn = document.getElementById("consoleBtn"); + if (consoleBtn) { + if (!consoleAvailable()) { + consoleBtn.style.display = "none"; + } else { + consoleBtn.addEventListener("click", () => openConsoleModal()); + } + } + + const diffFile = document.getElementById("diffFile"); + const compareBtn = document.getElementById("compareBtn"); + compareBtn?.addEventListener("click", () => diffFile?.click()); + + diffFile?.addEventListener("change", async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + const parsed = JSON.parse(text); + const normalized = parsed.groups + ? parsed + : await (async () => { + const res = await fetch("import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: text + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); + })(); + openDiffModal(normalized); + } catch (err) { + alert("Couldn't compare spec: " + (err && err.message ? err.message : err)); + } finally { + diffFile.value = ""; + } + }); + el.nav.addEventListener("click", (e) => { const overviewBtn = e.target.closest("[data-overview]"); if (overviewBtn) { @@ -787,6 +1519,12 @@ }); el.detail.addEventListener("click", (e) => { + const authBadge = e.target.closest("[data-overview-auth]"); + if (authBadge) { + openAuthModal(); + return; + } + const row = e.target.closest("[data-op]"); if (!row) return; const opId = row.dataset.op; @@ -841,15 +1579,4 @@ } init(); -})(); - -window.DoxaApiPostmanLike = { - methods:["GET","POST","PUT","PATCH","DELETE","HEAD","OPTIONS"], - buildRequest:function(){ - return { - method: document.getElementById("customMethod")?.value || "GET", - url: document.getElementById("customUrl")?.value || "", - headers: [] - }; - } -}; +})(); \ No newline at end of file diff --git a/DoxaApi/UI/Assets/index.html b/DoxaApi/UI/Assets/index.html index 7fd6146..1676024 100644 --- a/DoxaApi/UI/Assets/index.html +++ b/DoxaApi/UI/Assets/index.html @@ -33,13 +33,22 @@
+ + +
- + +
+
+ - + \ No newline at end of file diff --git a/README.md b/README.md index baf03fc..2dc7e3b 100644 --- a/README.md +++ b/README.md @@ -1,162 +1,154 @@ # EonaCat.DoxaApi -A self-contained, API documentation UI for ASP.NET Core +**A modern, self-contained, dependency-free API documentation UI for ASP.NET Core** -EonaCat.DoxaApi scans your controllers with plain `System.Reflection` and ASP.NET Core's own `IActionDescriptorCollectionProvider`, -builds a small OpenAPI-like JSON document with `System.Text.Json`, and serves a UI -This can also be used in an offline environment! +DoxaApi reflects over your controllers, generates an OpenAPI-compatible document, and serves a fast, single-page documentation UI with zero JavaScript dependencies. No npm build step, no CDN calls, no telemetry - one NuGet package, one line in `Program.cs`. -![Screenshot](DoxaApi/images/image.png) +![DoxaApi screenshot](images/image.png) -## Install +## Quick start ```bash dotnet add package EonaCat.DoxaApi ``` -## Quick start - ```csharp using EonaCat.DoxaApi; using EonaCat.DoxaApi.Middleware; var builder = WebApplication.CreateBuilder(args); + builder.Services.AddControllers(); builder.Services.AddDoxaApi(options => { - options.Title = "My API"; - options.Description = "Internal service API"; - options.AccentColor = "#6366f1"; // any hex color + options.Title = "Sample API"; + options.Description = "A demo service showing off the DoxaApi UI."; + options.Version = "v1"; + options.AccentColor = "#6366f1"; + + // Optional: declare how clients authenticate. + options.AddBearerAuth(description: "Sign in and paste your JWT here."); }); var app = builder.Build(); + app.UseRouting(); -app.UseDoxaApi(); // serves UI at /doxa and spec at /doxa/DoxaApi.json +app.UseDoxaApi(options => options.RoutePrefix = "doxa"); app.MapControllers(); app.Run(); ``` -Run your app and open `https://localhost:xxxx/doxa`. +Run the app and open `/doxa`. That's the whole setup. -## Features -- **Route-table style navigation** - endpoints grouped by controller, each method color-coded (GET/POST/PUT/PATCH/DELETE), searchable with `/`. -- **Schema viewer** - nested request/response shapes rendered as readable, syntax-colored trees, with required fields marked. -- **Try it out** - a real three-pane layout: browse → inspect → call, with path/query/header inputs, an editable JSON body (pre-filled with a generated example), and a live response panel with status, timing, and syntax-highlighted JSON. -- **Light & dark themes**, persisted, with a system-preference default. -- **XML doc comment support** - `/// `, ``, and `` are read directly from your project's generated `.xml` doc file (enable `true` in your csproj). -- **Attributes for fine control**: `[EonaCat.DoxaApiGroup]`, `[EonaCat.DoxaApiSummary]`, `[EonaCat.DoxaApiDescription]`, `[EonaCat.DoxaApiExample]`, `[EonaCat.DoxaApiHidden]`. -- **Zero external NuGet dependencies** - only references your app's own ASP.NET Core shared framework. + +## Authoring your documentation + +DoxaApi reads standard `///` XML doc comments and a handful of optional attributes: + +```csharp +[ApiController] +[Route("api/users")] +[DoxaApiGroup("Users")] +public class UsersController : ControllerBase +{ + /// Creates a new user. + /// The user to create. + /// The created user. + [HttpPost] + [DoxaApiExample("""{ "name": "Grace Hopper", "email": "grace@example.com" }""")] + public ActionResult Create([FromBody] CreateUserRequest request) { ... } + + [HttpDelete("{id}")] + [Obsolete("Use POST /api/users/{id}/archive instead.")] + public IActionResult Delete(Guid id) { ... } +} +``` + +| Attribute | Purpose | +||| +| `[DoxaApiGroup("Name")]` | Groups endpoints in the nav (class or method level) | +| `[DoxaApiSummary("...")]` | Overrides the auto-generated summary | +| `[DoxaApiDescription("...")]` | Adds a longer description | +| `[DoxaApiExample("""{ ... }""")]` | Supplies a realistic request body example | +| `[DoxaApiHidden]` | Excludes a controller/action from the docs entirely | +| `[DoxaApiAuth("schemeId")]` | Marks an endpoint as requiring a specific security scheme | +| `[DoxaApiAllowAnonymous]` | Marks an endpoint as public, overriding any default scheme | +| `[Obsolete("...")]` | Flags the endpoint as deprecated in the UI | + +Standard ASP.NET Core `[Authorize]` / `[AllowAnonymous]` attributes are also detected automatically. + + + +## Authentication + +Declare the security schemes your API actually uses, and DoxaApi takes care of the rest - detecting which endpoints need them, badging them in the docs, and prompting for credentials in the UI's **Authorize** dialog. + +```csharp +builder.Services.AddDoxaApi(options => +{ + options.AddBearerAuth(); // Authorization: Bearer + options.AddApiKeyAuth("apiKey", "X-API-Key", "header"); // custom header + options.AddBasicAuth(); // HTTP Basic + options.AddOAuth2ClientCredentials("oauth2", + tokenUrl: "https://auth.example.com/connect/token", + scopes: new() { ["api.read"] = "Read access" }); + + options.DefaultSecurityScheme = "bearer"; // applied to any [Authorize] endpoint + // without an explicit [DoxaApiAuth] +}); +``` + +Credentials entered in the UI are stored in the browser's `localStorage` (never sent anywhere except the documented API itself) and are automatically attached to every "Try it" request, cURL snippet, and generated code sample for endpoints that require them. + + + +## Mock server + +Every endpoint can return a schema-shaped fake response without touching your real controllers - flip the **"Send to mock server"** toggle in the Try-it panel, or call it directly: + +``` +GET /doxa/mock?operationId=Users_GetById +``` + +Useful for frontend teams who need to start building against an API before the backend is finished. Disable it with `options.EnableMockServer = false;` if you'd rather it not be exposed. + +## API console + +The **Console** (top bar) lets you fire an arbitrary HTTP request - any method, any absolute URL, custom headers and body - through the server rather than the browser. Because the request happens server-side, it isn't subject to the browser's CORS policy, which makes it useful for poking at a different host than the one serving the docs. Disable with `options.EnableConsole = false;`. + +## Code generation + +Every endpoint's Try-it panel includes a **Code** tab with ready-to-paste snippets in cURL, JavaScript (`fetch`), Python (`requests`), C# (`HttpClient`), and Go (`net/http`) - automatically including whatever auth headers you've configured. + +## Comparing spec versions + +Click **Compare** and upload a previous (or upcoming) OpenAPI/Swagger/DoxaApi JSON file to see exactly which endpoints were added, removed, or changed - including parameter, request-body, and security differences. Handy in code review for catching accidental breaking changes before they ship. + +## Import & export + +DoxaApi documents are available at: + +- `/doxa/doxaApi.json` - native format +- `/doxa/openapi.json` - OpenAPI 3.0.3 +- `/doxa/swagger.json` - Swagger 2.0 + +...and the **Import** button accepts any of the three formats back in, so you can merge in a hand-written spec, or load an API documented elsewhere. ## Configuration reference ```csharp builder.Services.AddDoxaApi(options => { - options.Title = "My API"; // shown in the top bar and browser tab - options.Description = "..."; // shown on the welcome screen + options.Title = "My API"; + options.Description = "..."; options.Version = "v1"; - options.RoutePrefix = "doxa"; // UI served at /{RoutePrefix} - options.AccentColor = "#6366f1"; // primary button/accent color - options.Theme = "auto"; // "auto" | "light" | "dark" + options.RoutePrefix = "doxa"; // served at /doxa + options.Servers.Add("https://api.example.com"); + options.Theme = "auto"; // "auto" | "dark" | "light" + options.AccentColor = "#6366f1"; + options.EnableMockServer = true; + options.EnableConsole = true; }); -``` - -## Attributes - -```csharp -[EonaCat.DoxaApiGroup("Users")] // override the left-nav group name -public class UsersController : ControllerBase -{ - /// Lists all users. // picked up automatically - [HttpGet] - public ActionResult> GetUsers() => ...; - - [HttpPost] - [EonaCat.DoxaApiExample("""{ "name": "Ada" }""")] // overrides the auto-generated example - public ActionResult Create(CreateUserRequest request) => ...; - - [HttpDelete("{id}")] - [EonaCat.DoxaApiHidden] // omit from docs entirely - public IActionResult Delete(Guid id) => ...; -} -``` - -## OpenAPI & Swagger - -EonaCat.DoxaApi exposes dedicated endpoints for importing and exporting industry-standard spec formats alongside its own native JSON. - -### Export - -| URL | Format | Notes | -|-----|--------|-------| -| `/{RoutePrefix}/DoxaApi.json` | Native EonaCat.DoxaApi JSON | Always available; used by the built-in UI | -| `/{RoutePrefix}/openapi.json` | **OpenAPI 3.0.3** | Import into Postman, Insomnia, Stoplight, etc. | -| `/{RoutePrefix}/swagger.json` | **Swagger 2.0** | Import into older tools, AWS API Gateway, Azure APIM, etc. | - -```bash -# Download the OpenAPI 3.0 spec -curl http://localhost:5000/doxa/openapi.json -o openapi.json - -# Download the Swagger 2.0 spec -curl http://localhost:5000/doxa/swagger.json -o swagger.json -``` - -### Import - -`POST /{RoutePrefix}/import` - -Send any OpenAPI 3.x or Swagger 2.0 JSON document in the request body. The server detects the format automatically and returns the native EonaCat.DoxaApi document. - -```bash -# Import a third-party OpenAPI spec and get the EonaCat.DoxaApi document back -curl -X POST http://localhost:5000/doxa/import \ - -H "Content-Type: application/json" \ - --data-binary @path/to/openapi.json -``` - -**Use cases:** -- Preview any public or third-party OpenAPI/Swagger spec in the EonaCat.DoxaApi UI -- Validate that your spec round-trips correctly through import → export -- Use the import endpoint as a conversion proxy (OpenAPI 3 ↔ Swagger 2) - -### Programmatic use - -The `OpenApiExporter`, `SwaggerExporter`, and `OpenApiImporter` classes in the -`EonaCat.DoxaApi.Interop` namespace are `public static` and can be used directly in your -own code: - -```csharp -using EonaCat.DoxaApi.Interop; -using EonaCat.DoxaApi.Models; - -// Export: ApiDocument → OpenAPI 3.0 JsonObject -ApiDocument doc = /* ... */; -System.Text.Json.Nodes.JsonObject openApi = OpenApiExporter.Export(doc); -string json = openApi.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); - -// Export: ApiDocument → Swagger 2.0 JsonObject -System.Text.Json.Nodes.JsonObject swagger = SwaggerExporter.Export(doc); - -// Import: OpenAPI 3.x or Swagger 2.0 string → ApiDocument -ApiDocument imported = OpenApiImporter.Import(File.ReadAllText("openapi.json")); - -// Import from a Stream -await using var stream = File.OpenRead("swagger.json"); -ApiDocument imported2 = OpenApiImporter.Import(stream); -``` - -## Sample project - -See `/sample/SampleApi` for a complete working example with two controllers (`Users`, `Orders`) demonstrating nested objects, enums, arrays, path/query parameters, and a deprecated endpoint. - -```bash -cd sample/SampleApi -dotnet run -# open http://localhost:5000/doxa -``` - -## License - -MIT +``` \ No newline at end of file diff --git a/sample/SampleApi/Controllers/OrdersController.cs b/sample/SampleApi/Controllers/OrdersController.cs index 61a8c46..f040b27 100644 --- a/sample/SampleApi/Controllers/OrdersController.cs +++ b/sample/SampleApi/Controllers/OrdersController.cs @@ -2,6 +2,9 @@ using Microsoft.AspNetCore.Mvc; using SampleApi.Models; +// This file is part of the EonaCat project(s) which is released under the Apache License. +// See the LICENSE file or go to https://EonaCat.com/license for full license details. + namespace SampleApi.Controllers { [ApiController] diff --git a/sample/SampleApi/Controllers/UsersController.cs b/sample/SampleApi/Controllers/UsersController.cs index dd9a61b..5dcfe10 100644 --- a/sample/SampleApi/Controllers/UsersController.cs +++ b/sample/SampleApi/Controllers/UsersController.cs @@ -2,6 +2,9 @@ using Microsoft.AspNetCore.Mvc; using SampleApi.Models; +// This file is part of the EonaCat project(s) which is released under the Apache License. +// See the LICENSE file or go to https://EonaCat.com/license for full license details. + namespace SampleApi.Controllers { [ApiController] @@ -35,6 +38,7 @@ namespace SampleApi.Controllers } [HttpPost] + [DoxaApiAuth("bearer")] [DoxaApiExample(""" { "name": "Grace Hopper", @@ -59,6 +63,7 @@ namespace SampleApi.Controllers } [HttpPatch("{id}")] + [DoxaApiAuth("bearer")] public ActionResult Update(Guid id, [FromBody] UpdateUserRequest request) { var user = _users.FirstOrDefault(u => u.Id == id); @@ -86,6 +91,7 @@ namespace SampleApi.Controllers } [HttpDelete("{id}")] + [DoxaApiAuth("apiKey")] [Obsolete("Use POST /api/users/{id}/archive instead.")] public IActionResult Delete(Guid id) { @@ -99,4 +105,4 @@ namespace SampleApi.Controllers return NoContent(); } } -} +} \ No newline at end of file diff --git a/sample/SampleApi/Models/Address.cs b/sample/SampleApi/Models/Address.cs new file mode 100644 index 0000000..fef9b9a --- /dev/null +++ b/sample/SampleApi/Models/Address.cs @@ -0,0 +1,13 @@ +namespace SampleApi.Models +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + public class Address + { + public string Street { get; set; } = ""; + public string City { get; set; } = ""; + public string PostalCode { get; set; } = ""; + public string Country { get; set; } = ""; + } +} diff --git a/sample/SampleApi/Models/CreateOrderRequest.cs b/sample/SampleApi/Models/CreateOrderRequest.cs new file mode 100644 index 0000000..ad12b6b --- /dev/null +++ b/sample/SampleApi/Models/CreateOrderRequest.cs @@ -0,0 +1,11 @@ +namespace SampleApi.Models +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + public class CreateOrderRequest + { + public Guid UserId { get; set; } + public List Lines { get; set; } = new(); + } +} diff --git a/sample/SampleApi/Models/CreateUserRequest.cs b/sample/SampleApi/Models/CreateUserRequest.cs new file mode 100644 index 0000000..51144f8 --- /dev/null +++ b/sample/SampleApi/Models/CreateUserRequest.cs @@ -0,0 +1,16 @@ +namespace SampleApi.Models +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + public class CreateUserRequest + { + public string Name { get; set; } = ""; + + public string Email { get; set; } = ""; + + public UserRole Role { get; set; } = UserRole.Member; + + public Address? Address { get; set; } + } +} diff --git a/sample/SampleApi/Models/Models.cs b/sample/SampleApi/Models/Models.cs deleted file mode 100644 index a033c62..0000000 --- a/sample/SampleApi/Models/Models.cs +++ /dev/null @@ -1,86 +0,0 @@ -namespace SampleApi.Models -{ - public enum UserRole - { - Admin, - Member, - Guest - } - - public class User - { - - public Guid Id { get; set; } - - public string Name { get; set; } = ""; - - public string Email { get; set; } = ""; - - public UserRole Role { get; set; } - - public DateTime CreatedAt { get; set; } - - public Address? Address { get; set; } - - public List Tags { get; set; } = new(); - } - - public class Address - { - public string Street { get; set; } = ""; - public string City { get; set; } = ""; - public string PostalCode { get; set; } = ""; - public string Country { get; set; } = ""; - } - - public class CreateUserRequest - { - - public string Name { get; set; } = ""; - - public string Email { get; set; } = ""; - - public UserRole Role { get; set; } = UserRole.Member; - - public Address? Address { get; set; } - } - - public class UpdateUserRequest - { - public string? Name { get; set; } - public string? Email { get; set; } - public UserRole? Role { get; set; } - } - - public class Order - { - public Guid Id { get; set; } - public Guid UserId { get; set; } - public List Lines { get; set; } = new(); - public decimal Total { get; set; } - public OrderStatus Status { get; set; } - public DateTime PlacedAt { get; set; } - } - - public class OrderLine - { - public string Sku { get; set; } = ""; - public int Quantity { get; set; } - public decimal UnitPrice { get; set; } - } - - public enum OrderStatus - { - Pending, - Paid, - Shipped, - Delivered, - Cancelled - } - - public class CreateOrderRequest - { - public Guid UserId { get; set; } - public List Lines { get; set; } = new(); - } -} diff --git a/sample/SampleApi/Models/Order.cs b/sample/SampleApi/Models/Order.cs new file mode 100644 index 0000000..7dfab1b --- /dev/null +++ b/sample/SampleApi/Models/Order.cs @@ -0,0 +1,15 @@ +namespace SampleApi.Models +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + public class Order + { + public Guid Id { get; set; } + public Guid UserId { get; set; } + public List Lines { get; set; } = new(); + public decimal Total { get; set; } + public OrderStatus Status { get; set; } + public DateTime PlacedAt { get; set; } + } +} diff --git a/sample/SampleApi/Models/OrderLine.cs b/sample/SampleApi/Models/OrderLine.cs new file mode 100644 index 0000000..2ae8bc2 --- /dev/null +++ b/sample/SampleApi/Models/OrderLine.cs @@ -0,0 +1,12 @@ +namespace SampleApi.Models +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + public class OrderLine + { + public string Sku { get; set; } = ""; + public int Quantity { get; set; } + public decimal UnitPrice { get; set; } + } +} diff --git a/sample/SampleApi/Models/OrderStatus.cs b/sample/SampleApi/Models/OrderStatus.cs new file mode 100644 index 0000000..a50f2ae --- /dev/null +++ b/sample/SampleApi/Models/OrderStatus.cs @@ -0,0 +1,14 @@ +namespace SampleApi.Models +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + public enum OrderStatus + { + Pending, + Paid, + Shipped, + Delivered, + Cancelled + } +} diff --git a/sample/SampleApi/Models/UpdateUserRequest.cs b/sample/SampleApi/Models/UpdateUserRequest.cs new file mode 100644 index 0000000..5481783 --- /dev/null +++ b/sample/SampleApi/Models/UpdateUserRequest.cs @@ -0,0 +1,12 @@ +namespace SampleApi.Models +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + public class UpdateUserRequest + { + public string? Name { get; set; } + public string? Email { get; set; } + public UserRole? Role { get; set; } + } +} diff --git a/sample/SampleApi/Models/User.cs b/sample/SampleApi/Models/User.cs new file mode 100644 index 0000000..10420b7 --- /dev/null +++ b/sample/SampleApi/Models/User.cs @@ -0,0 +1,22 @@ +namespace SampleApi.Models +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + public class User + { + public Guid Id { get; set; } + + public string Name { get; set; } = ""; + + public string Email { get; set; } = ""; + + public UserRole Role { get; set; } + + public DateTime CreatedAt { get; set; } + + public Address? Address { get; set; } + + public List Tags { get; set; } = new(); + } +} diff --git a/sample/SampleApi/Models/UserRole.cs b/sample/SampleApi/Models/UserRole.cs new file mode 100644 index 0000000..f20ddc7 --- /dev/null +++ b/sample/SampleApi/Models/UserRole.cs @@ -0,0 +1,12 @@ +namespace SampleApi.Models +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + public enum UserRole + { + Admin, + Member, + Guest + } +} diff --git a/sample/SampleApi/Program.cs b/sample/SampleApi/Program.cs index b81e314..417f99b 100644 --- a/sample/SampleApi/Program.cs +++ b/sample/SampleApi/Program.cs @@ -3,6 +3,9 @@ using EonaCat.DoxaApi.Middleware; var builder = WebApplication.CreateBuilder(args); +// This file is part of the EonaCat project(s) which is released under the Apache License. +// See the LICENSE file or go to https://EonaCat.com/license for full license details. + builder.Services.AddControllers(); builder.Services.AddDoxaApi(options => { @@ -10,6 +13,12 @@ builder.Services.AddDoxaApi(options => options.Description = "A demo service showing off the DoxaApi UI - users and orders."; options.Version = "v1"; options.AccentColor = "#6366f1"; + + // Declare how clients authenticate; DoxaApi auto-detects which endpoints + // require it via [Authorize] / [DoxaApiAuth] and renders an Authorize dialog. + options.AddBearerAuth(description: "Paste a JWT issued by /api/auth/login."); + options.AddApiKeyAuth("apiKey", "X-API-Key", description: "A static key for service-to-service calls."); + options.DefaultSecurityScheme = "bearer"; }); var app = builder.Build(); @@ -23,4 +32,4 @@ app.UseDoxaApi(options => app.MapControllers(); app.MapGet("/", () => Results.Redirect("/doxa")); -app.Run(); +app.Run(); \ No newline at end of file