Added authentication

This commit is contained in:
2026-06-21 20:10:02 +02:00
parent efe63211ed
commit a50be1ec0b
50 changed files with 2596 additions and 328 deletions
@@ -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.
/// <summary>
/// Declares that an endpoint (or every endpoint in a controller) requires one of the
/// named security schemes registered via <c>DoxaApiOptions.AddSecurityScheme(...)</c>.
/// Multiple attributes may be stacked; any one of the referenced schemes satisfies the
/// endpoint (logical OR), matching OpenAPI's security requirement semantics.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public sealed class DoxaApiAuthAttribute : Attribute
{
public string SchemeId { get; }
public DoxaApiAuthAttribute(string schemeId) => SchemeId = schemeId;
}
/// <summary>
/// Marks an endpoint (or controller) as explicitly not requiring authentication,
/// overriding any document-wide default security scheme.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class DoxaApiAllowAnonymousAttribute : Attribute
{
}
}
@@ -1,5 +1,8 @@
namespace EonaCat.DoxaApi.Attributes 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)] [AttributeUsage(AttributeTargets.Method)]
public sealed class DoxaApiDescriptionAttribute : Attribute public sealed class DoxaApiDescriptionAttribute : Attribute
{ {
@@ -1,5 +1,8 @@
namespace EonaCat.DoxaApi.Attributes 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)] [AttributeUsage(AttributeTargets.Method)]
public sealed class DoxaApiExampleAttribute : Attribute public sealed class DoxaApiExampleAttribute : Attribute
{ {
@@ -1,5 +1,8 @@
namespace EonaCat.DoxaApi.Attributes 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)] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class DoxaApiGroupAttribute : Attribute public sealed class DoxaApiGroupAttribute : Attribute
{ {
@@ -1,5 +1,8 @@
namespace EonaCat.DoxaApi.Attributes 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)] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class DoxaApiHiddenAttribute : Attribute public sealed class DoxaApiHiddenAttribute : Attribute
{ {
@@ -1,5 +1,8 @@
namespace EonaCat.DoxaApi.Attributes 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)] [AttributeUsage(AttributeTargets.Method)]
public sealed class DoxaApiSummaryAttribute : Attribute public sealed class DoxaApiSummaryAttribute : Attribute
{ {
+150
View File
@@ -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.
/// <summary>
/// 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.
/// </summary>
internal static class ConsoleProxy
{
private static readonly HttpClient _client = new(new HttpClientHandler
{
AllowAutoRedirect = false
})
{
Timeout = TimeSpan.FromSeconds(30)
};
private static readonly HashSet<string> _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<ConsoleRequest>(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<string, string>();
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
};
}
}
+22
View File
@@ -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<string, string>? Headers { get; set; }
[JsonPropertyName("body")]
public string? Body { get; set; }
}
}
+25
View File
@@ -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<string, string> Headers { get; set; } = new();
[JsonPropertyName("body")]
public string Body { get; set; } = "";
[JsonPropertyName("networkError")]
public bool NetworkError { get; set; }
}
}
+3 -5
View File
@@ -10,10 +10,10 @@
<!-- NuGet package metadata --> <!-- NuGet package metadata -->
<PackageId>EonaCat.DoxaApi</PackageId> <PackageId>EonaCat.DoxaApi</PackageId>
<Version>0.0.4</Version> <Version>0.0.5</Version>
<Authors>EonaCat (Jeroen Saey)</Authors> <Authors>EonaCat (Jeroen Saey)</Authors>
<Description>A modern, self-contained, dependency-free OpenAPI documentation UI for ASP.NET Core.</Description> <Description>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.</Description>
<PackageTags>openapi;swagger;documentation;api;aspnetcore;doxa;api;docs;documentation;Jeroen;Saey;EonaCat;Scalar;Redoc;Postman;EchoAPI;</PackageTags> <PackageTags>openapi;swagger;documentation;api;aspnetcore;doxa;docs;mock-server;oauth2;codegen;Jeroen;Saey;EonaCat;Scalar;Redoc;Postman;EchoAPI;</PackageTags>
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.DoxaApi</RepositoryUrl> <RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.DoxaApi</RepositoryUrl>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
@@ -27,7 +27,6 @@
<Copyright>EonaCat (Jeroen Saey)</Copyright> <Copyright>EonaCat (Jeroen Saey)</Copyright>
<PackageProjectUrl>https://git.saey.me/EonaCat/EonaCat.DoxaApi</PackageProjectUrl> <PackageProjectUrl>https://git.saey.me/EonaCat/EonaCat.DoxaApi</PackageProjectUrl>
<PackageIcon>icon.png</PackageIcon> <PackageIcon>icon.png</PackageIcon>
<AssemblyVersion></AssemblyVersion>
<PackageLicenseFile>LICENSE</PackageLicenseFile> <PackageLicenseFile>LICENSE</PackageLicenseFile>
</PropertyGroup> </PropertyGroup>
@@ -52,7 +51,6 @@
<Pack>True</Pack> <Pack>True</Pack>
<PackagePath>\</PackagePath> <PackagePath>\</PackagePath>
</None> </None>
<None Include="README.md" Pack="true" PackagePath="\" Condition="Exists('README.md')" />
<None Include="images\image.png" Pack="true" PackagePath="images\" /> <None Include="images\image.png" Pack="true" PackagePath="images\" />
</ItemGroup> </ItemGroup>
</Project> </Project>
-4
View File
@@ -1,4 +0,0 @@
namespace EonaCat.DoxaApi.Exporter
{
public static class DoxaApiExporter { }
}
+7
View File
@@ -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 { }
}
+105
View File
@@ -4,6 +4,8 @@ using EonaCat.DoxaApi.Models;
namespace EonaCat.DoxaApi.Interop 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 class OpenApiExporter
{ {
public static JsonObject Export(ApiDocument doc) public static JsonObject Export(ApiDocument doc)
@@ -53,6 +55,24 @@ namespace EonaCat.DoxaApi.Interop
root["components"] = new JsonObject { ["schemas"] = schemas }; 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; return root;
} }
@@ -161,9 +181,94 @@ namespace EonaCat.DoxaApi.Interop
} }
op["responses"] = responses; 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; 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) private static JsonObject SchemaToOpenApi(SchemaModel schema)
{ {
+83
View File
@@ -3,6 +3,9 @@ using EonaCat.DoxaApi.Models;
namespace EonaCat.DoxaApi.Interop 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 class SwaggerExporter
{ {
public static JsonObject Export(ApiDocument doc) public static JsonObject Export(ApiDocument doc)
@@ -57,6 +60,17 @@ namespace EonaCat.DoxaApi.Interop
root["definitions"] = definitions; 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; return root;
} }
@@ -157,6 +171,16 @@ namespace EonaCat.DoxaApi.Interop
} }
op["responses"] = responses; 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; return op;
} }
@@ -285,6 +309,65 @@ namespace EonaCat.DoxaApi.Interop
return obj; 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) private static string ToSwaggerPath(string path)
=> path.StartsWith('/') ? path : "/" + path; => path.StartsWith('/') ? path : "/" + path;
+64 -1
View File
@@ -2,11 +2,15 @@
using System.Xml.Linq; using System.Xml.Linq;
using EonaCat.DoxaApi.Attributes; using EonaCat.DoxaApi.Attributes;
using EonaCat.DoxaApi.Models; using EonaCat.DoxaApi.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Controllers;
namespace EonaCat.DoxaApi.Generation 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 public sealed class ApiDocumentGenerator
{ {
private readonly IReadOnlyList<ActionDescriptor> _actions; private readonly IReadOnlyList<ActionDescriptor> _actions;
@@ -80,6 +84,12 @@ namespace EonaCat.DoxaApi.Generation
} }
doc.Schemas = schemaRegistry; doc.Schemas = schemaRegistry;
doc.SecuritySchemes = _options.SecuritySchemes;
doc.Features = new ApiFeatureFlags
{
MockServer = _options.EnableMockServer,
Console = _options.EnableConsole
};
return doc; return doc;
} }
@@ -112,6 +122,52 @@ namespace EonaCat.DoxaApi.Generation
return name; return name;
} }
/// <summary>
/// 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.
/// </summary>
private List<string> ResolveSecurity(ControllerActionDescriptor cad)
{
var method = cad.MethodInfo;
var controller = cad.ControllerTypeInfo;
bool anonymous = method.GetCustomAttribute<DoxaApiAllowAnonymousAttribute>() is not null
|| controller.GetCustomAttribute<DoxaApiAllowAnonymousAttribute>() is not null
|| method.GetCustomAttribute<AllowAnonymousAttribute>() is not null;
if (anonymous)
{
return new List<string>();
}
var methodSchemes = method.GetCustomAttributes<DoxaApiAuthAttribute>().Select(a => a.SchemeId).ToList();
if (methodSchemes.Count > 0)
{
return methodSchemes.Where(_options.SecuritySchemes.ContainsKey).ToList();
}
var classSchemes = controller.GetCustomAttributes<DoxaApiAuthAttribute>().Select(a => a.SchemeId).ToList();
if (classSchemes.Count > 0)
{
return classSchemes.Where(_options.SecuritySchemes.ContainsKey).ToList();
}
bool requiresAuth = method.GetCustomAttribute<AuthorizeAttribute>() is not null
|| controller.GetCustomAttribute<AuthorizeAttribute>() is not null;
if (requiresAuth && _options.DefaultSecurityScheme is not null
&& _options.SecuritySchemes.ContainsKey(_options.DefaultSecurityScheme))
{
return new List<string> { _options.DefaultSecurityScheme };
}
return new List<string>();
}
private ApiEndpoint? BuildEndpoint(ControllerActionDescriptor cad, SchemaBuilder schemaBuilder) private ApiEndpoint? BuildEndpoint(ControllerActionDescriptor cad, SchemaBuilder schemaBuilder)
{ {
var httpMethod = cad.ActionConstraints? var httpMethod = cad.ActionConstraints?
@@ -136,7 +192,8 @@ namespace EonaCat.DoxaApi.Generation
Summary = summaryAttr?.Summary ?? xmlDoc?.Summary ?? HumanizeName(cad.ActionName), Summary = summaryAttr?.Summary ?? xmlDoc?.Summary ?? HumanizeName(cad.ActionName),
Description = descAttr?.Description ?? xmlDoc?.Remarks, Description = descAttr?.Description ?? xmlDoc?.Remarks,
Deprecated = obsoleteAttr is not null, Deprecated = obsoleteAttr is not null,
Tags = new List<string> { ResolveGroupName(cad) } Tags = new List<string> { ResolveGroupName(cad) },
Security = ResolveSecurity(cad)
}; };
foreach (var param in cad.Parameters) foreach (var param in cad.Parameters)
@@ -207,6 +264,12 @@ namespace EonaCat.DoxaApi.Generation
endpoint.Responses.Add(new ResponseModel { StatusCode = "400", Description = "Invalid request" }); 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; return endpoint;
} }
+109 -1
View File
@@ -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 sealed class DoxaApiOptions
{ {
public string Title { get; set; } = "DoxaApi Documentation"; public string Title { get; set; } = "DoxaApi Documentation";
@@ -9,5 +14,108 @@
public List<string> Servers { get; set; } = new(); public List<string> Servers { get; set; } = new();
public string Theme { get; set; } = "auto"; public string Theme { get; set; } = "auto";
public string AccentColor { get; set; } = "#6366f1"; public string AccentColor { get; set; } = "#6366f1";
/// <summary>
/// Security schemes available to this API, keyed by scheme id. Populate via
/// <see cref="AddApiKeyAuth"/>, <see cref="AddBearerAuth"/>, <see cref="AddBasicAuth"/>,
/// or <see cref="AddOAuth2ClientCredentials"/>, or add custom entries directly.
/// </summary>
public Dictionary<string, SecuritySchemeModel> SecuritySchemes { get; } = new();
/// <summary>
/// If set, endpoints without an explicit [DoxaApiAuth]/[Authorize]/[DoxaApiAllowAnonymous]
/// marker are assumed to require this scheme. Leave null to assume anonymous by default.
/// </summary>
public string? DefaultSecurityScheme { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool EnableMockServer { get; set; } = true;
/// <summary>
/// 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.
/// </summary>
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<string, string>? 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<string, string>()
}
}
};
return this;
}
public DoxaApiOptions AddOAuth2AuthorizationCode(string id, string authorizationUrl, string tokenUrl, Dictionary<string, string>? 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<string, string>()
}
}
};
return this;
}
} }
} }
@@ -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.
/// <summary>
/// Produces schema-shaped, deterministic-but-plausible fake JSON values for a given
/// <see cref="SchemaModel"/>. Used by the built-in mock server so that frontend teams
/// can develop against an API before its real implementation exists.
/// </summary>
public static class MockResponseGenerator
{
public static object? Generate(SchemaModel? schema, ApiDocument doc, int depth = 0, HashSet<string>? seen = null)
{
seen ??= new HashSet<string>();
if (schema is null || depth > 8)
{
return null;
}
if (schema.RefName is not null)
{
if (!seen.Add(schema.RefName))
{
return new Dictionary<string, object?>();
}
if (doc.Schemas.TryGetValue(schema.RefName, out var resolved))
{
return Generate(resolved, doc, depth + 1, seen);
}
return new Dictionary<string, object?>();
}
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<object?>
{
Generate(schema.Items, doc, depth + 1, seen),
Generate(schema.Items, doc, depth + 1, seen)
};
case "object":
{
if (schema.Properties is null)
{
return new Dictionary<string, object?>();
}
var obj = new Dictionary<string, object?>();
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;
}
}
+2
View File
@@ -4,6 +4,8 @@ using EonaCat.DoxaApi.Models;
namespace EonaCat.DoxaApi.Generation 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 public sealed class SchemaBuilder
{ {
private readonly Dictionary<string, SchemaModel> _registry; private readonly Dictionary<string, SchemaModel> _registry;
+3
View File
@@ -4,6 +4,9 @@ using System.Xml.Linq;
namespace EonaCat.DoxaApi.Generation 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 internal sealed class MethodXmlDoc
{ {
public string? Summary { get; set; } public string? Summary { get; set; }
+3
View File
@@ -3,6 +3,9 @@ using System.Text.Json;
namespace EonaCat.DoxaApi.Interop 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 class DoxaApiImporter
{ {
public static ApiDocument Import(string json) public static ApiDocument Import(string json)
+170 -6
View File
@@ -4,9 +4,11 @@ using EonaCat.DoxaApi.Models;
namespace EonaCat.DoxaApi.Interop 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 class OpenApiImporter
{ {
public static ApiDocument Import(string json) public static ApiDocument Import(string json)
{ {
var node = JsonNode.Parse(json) ?? throw new ArgumentException("Input is not valid 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<string, ApiGroup>(StringComparer.OrdinalIgnoreCase); var groups = new Dictionary<string, ApiGroup>(StringComparer.OrdinalIgnoreCase);
if (root["paths"] is JsonObject paths) if (root["paths"] is JsonObject paths)
@@ -130,19 +143,20 @@ namespace EonaCat.DoxaApi.Interop
Method = method, Method = method,
Path = path, Path = path,
Deprecated = op["deprecated"]?.GetValue<bool>() ?? false, Deprecated = op["deprecated"]?.GetValue<bool>() ?? 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) if (p is not JsonObject param)
{ {
continue; continue;
} }
var schema = p["schema"] is JsonNode schemaNode var schema = param["schema"] is JsonNode schemaNode
? ParseSchema3(schemaNode) ? ParseSchema3(schemaNode)
: new SchemaModel { Type = "string" }; : 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<string, ApiGroup>(StringComparer.OrdinalIgnoreCase); var groups = new Dictionary<string, ApiGroup>(StringComparer.OrdinalIgnoreCase);
if (root["paths"] is JsonObject paths) if (root["paths"] is JsonObject paths)
@@ -379,7 +404,8 @@ namespace EonaCat.DoxaApi.Interop
Method = method, Method = method,
Path = path, Path = path,
Deprecated = op["deprecated"]?.GetValue<bool>() ?? false, Deprecated = op["deprecated"]?.GetValue<bool>() ?? false,
Tags = ParseStringArray(op["tags"]) Tags = ParseStringArray(op["tags"]),
Security = ParseSecurityArray(op["security"])
}; };
if (op["parameters"] is JsonArray parameters) if (op["parameters"] is JsonArray parameters)
@@ -567,6 +593,144 @@ namespace EonaCat.DoxaApi.Interop
return ("application/json", null); return ("application/json", null);
} }
private static List<string> ParseSecurityArray(JsonNode? node)
{
var list = new List<string>();
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<string>() ?? "apiKey";
var scheme = new SecuritySchemeModel
{
Id = id,
Description = obj["description"]?.GetValue<string>()
};
if (type == "basic")
{
scheme.Type = "http";
scheme.Scheme = "basic";
}
else if (type == "apiKey")
{
scheme.Type = "apiKey";
scheme.Name = obj["name"]?.GetValue<string>();
scheme.In = obj["in"]?.GetValue<string>();
}
else if (type == "oauth2")
{
scheme.Type = "oauth2";
var flow = obj["flow"]?.GetValue<string>();
var flowModel = new OAuthFlowModel
{
AuthorizationUrl = obj["authorizationUrl"]?.GetValue<string>(),
TokenUrl = obj["tokenUrl"]?.GetValue<string>()
};
if (obj["scopes"] is JsonObject scopesObj)
{
foreach (var (k, v) in scopesObj)
{
flowModel.Scopes[k] = v?.GetValue<string>() ?? "";
}
}
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<string>() ?? "apiKey",
Description = obj["description"]?.GetValue<string>()
};
switch (scheme.Type)
{
case "apiKey":
scheme.Name = obj["name"]?.GetValue<string>();
scheme.In = obj["in"]?.GetValue<string>();
break;
case "http":
scheme.Scheme = obj["scheme"]?.GetValue<string>();
scheme.BearerFormat = obj["bearerFormat"]?.GetValue<string>();
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<string>(),
TokenUrl = obj["tokenUrl"]?.GetValue<string>(),
RefreshUrl = obj["refreshUrl"]?.GetValue<string>()
};
if (obj["scopes"] is JsonObject scopesObj)
{
foreach (var (k, v) in scopesObj)
{
flow.Scopes[k] = v?.GetValue<string>() ?? "";
}
}
return flow;
}
private static List<string> ParseStringArray(JsonNode? node) private static List<string> ParseStringArray(JsonNode? node)
{ {
var list = new List<string>(); var list = new List<string>();
@@ -4,6 +4,7 @@ using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using EonaCat.DoxaApi.Generation; using EonaCat.DoxaApi.Generation;
using EonaCat.DoxaApi.Interop; using EonaCat.DoxaApi.Interop;
using EonaCat.DoxaApi.Models;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Abstractions;
@@ -12,6 +13,9 @@ using Microsoft.Extensions.DependencyInjection;
namespace EonaCat.DoxaApi.Middleware 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 public static class DoxaApiMiddlewareExtensions
{ {
private static readonly JsonSerializerOptions _writeOptions = new() 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=<id> 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 => app.Map(prefix, uiApp =>
{ {
uiApp.Run(async context => uiApp.Run(async context =>
+11
View File
@@ -2,6 +2,9 @@
namespace EonaCat.DoxaApi.Models 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 public sealed class ApiDocument
{ {
[JsonPropertyName("info")] [JsonPropertyName("info")]
@@ -15,5 +18,13 @@ namespace EonaCat.DoxaApi.Models
[JsonPropertyName("schemas")] [JsonPropertyName("schemas")]
public Dictionary<string, SchemaModel> Schemas { get; set; } = new(); public Dictionary<string, SchemaModel> Schemas { get; set; } = new();
/// <summary>All security schemes available for this API (keyed by scheme id).</summary>
[JsonPropertyName("securitySchemes")]
public Dictionary<string, SecuritySchemeModel> SecuritySchemes { get; set; } = new();
/// <summary>Which optional DoxaApi server-side features are enabled, for UI feature detection.</summary>
[JsonPropertyName("features")]
public ApiFeatureFlags Features { get; set; } = new();
} }
} }
+10
View File
@@ -2,6 +2,9 @@
namespace EonaCat.DoxaApi.Models 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 public sealed class ApiEndpoint
{ {
[JsonPropertyName("operationId")] [JsonPropertyName("operationId")]
@@ -33,5 +36,12 @@ namespace EonaCat.DoxaApi.Models
[JsonPropertyName("responses")] [JsonPropertyName("responses")]
public List<ResponseModel> Responses { get; set; } = new(); public List<ResponseModel> Responses { get; set; } = new();
/// <summary>
/// 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).
/// </summary>
[JsonPropertyName("security")]
public List<string> Security { get; set; } = new();
} }
} }
+16
View File
@@ -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; }
}
}
+3
View File
@@ -2,6 +2,9 @@
namespace EonaCat.DoxaApi.Models 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 public sealed class ApiGroup
{ {
[JsonPropertyName("name")] [JsonPropertyName("name")]
+3
View File
@@ -2,6 +2,9 @@
namespace EonaCat.DoxaApi.Models 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 public sealed class ApiInfo
{ {
[JsonPropertyName("title")] [JsonPropertyName("title")]
+3
View File
@@ -2,6 +2,9 @@
namespace EonaCat.DoxaApi.Models 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 public sealed class ApiParameter
{ {
[JsonPropertyName("name")] [JsonPropertyName("name")]
+3
View File
@@ -2,6 +2,9 @@
namespace EonaCat.DoxaApi.Models 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 public sealed class RequestBodyModel
{ {
[JsonPropertyName("required")] [JsonPropertyName("required")]
+3
View File
@@ -2,6 +2,9 @@
namespace EonaCat.DoxaApi.Models 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 public sealed class ResponseModel
{ {
[JsonPropertyName("statusCode")] [JsonPropertyName("statusCode")]
+3
View File
@@ -2,6 +2,9 @@
namespace EonaCat.DoxaApi.Models 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 public sealed class SchemaModel
{ {
[JsonPropertyName("type")] [JsonPropertyName("type")]
+76
View File
@@ -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.
/// <summary>
/// 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.
/// </summary>
public sealed class SecuritySchemeModel
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
/// <summary>apiKey | http | oauth2 | openIdConnect</summary>
[JsonPropertyName("type")]
public string Type { get; set; } = "apiKey";
[JsonPropertyName("description")]
public string? Description { get; set; }
/// <summary>For apiKey: header | query | cookie</summary>
[JsonPropertyName("in")]
public string? In { get; set; }
/// <summary>For apiKey: the header/query/cookie name. For http: ignored.</summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>For http: bearer | basic | digest</summary>
[JsonPropertyName("scheme")]
public string? Scheme { get; set; }
/// <summary>For http bearer: an informational hint such as "JWT".</summary>
[JsonPropertyName("bearerFormat")]
public string? BearerFormat { get; set; }
/// <summary>For oauth2: supported flows (clientCredentials, authorizationCode, password, implicit).</summary>
[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<string, string> Scopes { get; set; } = new();
}
}
+3 -1
View File
@@ -3,9 +3,11 @@ using Microsoft.Extensions.DependencyInjection;
namespace EonaCat.DoxaApi 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 class DoxaApiServiceCollectionExtensions
{ {
public static IServiceCollection AddDoxaApi(this IServiceCollection services, Action<DoxaApiOptions>? configure = null) public static IServiceCollection AddDoxaApi(this IServiceCollection services, Action<DoxaApiOptions>? configure = null)
{ {
var options = new DoxaApiOptions(); var options = new DoxaApiOptions();
+429 -4
View File
@@ -1312,6 +1312,21 @@ textarea.field-input {
border-color: transparent; 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 snippet */
.curl-box { .curl-box {
@@ -1467,7 +1482,417 @@ textarea.field-input {
outline: none; outline: none;
} }
.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} /* Advanced features: Auth, Code generation, History, Console, Mock */
.postman-section{margin-top:12px;padding-top:12px;border-top:1px solid var(--border)} /* ---------------------------------------------------------------- */
.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;
}
.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;
}
.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;
}
+777 -50
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -33,13 +33,22 @@
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
<div class="action-group"> <div class="action-group">
<button id="authBtn" class="action-btn icon-btn-badge" title="Authorization">
<svg viewBox="0 0 24 24" fill="none" width="15" height="15" style="vertical-align:-2px;margin-right:4px;"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="2" /><path d="M8 11V7a4 4 0 018 0v4" stroke="currentColor" stroke-width="2" stroke-linecap="round" /></svg>Auth
<span class="badge-dot" id="authBadgeDot" style="display:none;"></span>
</button>
<button id="consoleBtn" class="action-btn" title="Open API console">
<svg viewBox="0 0 24 24" fill="none" width="15" height="15" style="vertical-align:-2px;margin-right:4px;"><path d="M4 5h16v14H4z" stroke="currentColor" stroke-width="2" /><path d="M7 9l3 3-3 3M13 15h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>Console
</button>
<button id="importBtn" class="action-btn action-btn-primary">⬆ Import</button> <button id="importBtn" class="action-btn action-btn-primary">⬆ Import</button>
<button id="compareBtn" class="action-btn" title="Compare against another spec">⇄ Compare</button>
<button id="exportDoxaApiBtn" class="action-btn">DoxaApi</button> <button id="exportDoxaApiBtn" class="action-btn">DoxaApi</button>
<button id="exportOpenApiBtn" class="action-btn">OpenAPI</button> <button id="exportOpenApiBtn" class="action-btn">OpenAPI</button>
<button id="exportSwaggerBtn" class="action-btn">Swagger</button> <button id="exportSwaggerBtn" class="action-btn">Swagger</button>
</div> </div>
<input id="importFile" type="file" accept=".json" hidden /> <input id="importFile" type="file" accept=".json" hidden />
<input id="diffFile" type="file" accept=".json" hidden />
<button id="themeToggle" class="icon-btn" title="Toggle theme" aria-label="Toggle theme"> <button id="themeToggle" class="icon-btn" title="Toggle theme" aria-label="Toggle theme">
<svg class="icon-sun" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2" /><path d="M12 2V4M12 20V22M4 12H2M22 12H20M19.07 4.93L17.66 6.34M6.34 17.66L4.93 19.07M19.07 19.07L17.66 17.66M6.34 6.34L4.93 4.93" stroke="currentColor" stroke-width="2" stroke-linecap="round" /></svg> <svg class="icon-sun" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2" /><path d="M12 2V4M12 20V22M4 12H2M22 12H20M19.07 4.93L17.66 6.34M6.34 17.66L4.93 19.07M19.07 19.07L17.66 17.66M6.34 6.34L4.93 4.93" stroke="currentColor" stroke-width="2" stroke-linecap="round" /></svg>
<svg class="icon-moon" viewBox="0 0 24 24" fill="none"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" /></svg> <svg class="icon-moon" viewBox="0 0 24 24" fill="none"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" /></svg>
@@ -65,6 +74,8 @@
</div> </div>
</div> </div>
<div id="modalRoot"></div>
<script>window.__DOXA_API_SPEC_URL__ = "__DOXA_API_SPEC_PATH__";</script> <script>window.__DOXA_API_SPEC_URL__ = "__DOXA_API_SPEC_PATH__";</script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
+116 -124
View File
@@ -1,162 +1,154 @@
# EonaCat.DoxaApi # 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`, 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`.
builds a small OpenAPI-like JSON document with `System.Text.Json`, and serves a UI
This can also be used in an offline environment!
![Screenshot](DoxaApi/images/image.png) ![DoxaApi screenshot](images/image.png)
## Install ## Quick start
```bash ```bash
dotnet add package EonaCat.DoxaApi dotnet add package EonaCat.DoxaApi
``` ```
## Quick start
```csharp ```csharp
using EonaCat.DoxaApi; using EonaCat.DoxaApi;
using EonaCat.DoxaApi.Middleware; using EonaCat.DoxaApi.Middleware;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddDoxaApi(options => builder.Services.AddDoxaApi(options =>
{ {
options.Title = "My API"; options.Title = "Sample API";
options.Description = "Internal service API"; options.Description = "A demo service showing off the DoxaApi UI.";
options.AccentColor = "#6366f1"; // any hex color 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(); var app = builder.Build();
app.UseRouting(); app.UseRouting();
app.UseDoxaApi(); // serves UI at /doxa and spec at /doxa/DoxaApi.json app.UseDoxaApi(options => options.RoutePrefix = "doxa");
app.MapControllers(); app.MapControllers();
app.Run(); 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. ## Authoring your documentation
- **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. DoxaApi reads standard `///` XML doc comments and a handful of optional attributes:
- **XML doc comment support** - `/// <summary>`, `<param>`, and `<returns>` are read directly from your project's generated `.xml` doc file (enable `<GenerateDocumentationFile>true</GenerateDocumentationFile>` in your csproj).
- **Attributes for fine control**: `[EonaCat.DoxaApiGroup]`, `[EonaCat.DoxaApiSummary]`, `[EonaCat.DoxaApiDescription]`, `[EonaCat.DoxaApiExample]`, `[EonaCat.DoxaApiHidden]`. ```csharp
- **Zero external NuGet dependencies** - only references your app's own ASP.NET Core shared framework. [ApiController]
[Route("api/users")]
[DoxaApiGroup("Users")]
public class UsersController : ControllerBase
{
/// <summary>Creates a new user.</summary>
/// <param name="request">The user to create.</param>
/// <returns>The created user.</returns>
[HttpPost]
[DoxaApiExample("""{ "name": "Grace Hopper", "email": "grace@example.com" }""")]
public ActionResult<User> 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 <token>
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 ## Configuration reference
```csharp ```csharp
builder.Services.AddDoxaApi(options => builder.Services.AddDoxaApi(options =>
{ {
options.Title = "My API"; // shown in the top bar and browser tab options.Title = "My API";
options.Description = "..."; // shown on the welcome screen options.Description = "...";
options.Version = "v1"; options.Version = "v1";
options.RoutePrefix = "doxa"; // UI served at /{RoutePrefix} options.RoutePrefix = "doxa"; // served at /doxa
options.AccentColor = "#6366f1"; // primary button/accent color options.Servers.Add("https://api.example.com");
options.Theme = "auto"; // "auto" | "light" | "dark" 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
{
/// <summary>Lists all users.</summary> // picked up automatically
[HttpGet]
public ActionResult<List<User>> GetUsers() => ...;
[HttpPost]
[EonaCat.DoxaApiExample("""{ "name": "Ada" }""")] // overrides the auto-generated example
public ActionResult<User> 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
@@ -2,6 +2,9 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SampleApi.Models; 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 namespace SampleApi.Controllers
{ {
[ApiController] [ApiController]
@@ -2,6 +2,9 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SampleApi.Models; 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 namespace SampleApi.Controllers
{ {
[ApiController] [ApiController]
@@ -35,6 +38,7 @@ namespace SampleApi.Controllers
} }
[HttpPost] [HttpPost]
[DoxaApiAuth("bearer")]
[DoxaApiExample(""" [DoxaApiExample("""
{ {
"name": "Grace Hopper", "name": "Grace Hopper",
@@ -59,6 +63,7 @@ namespace SampleApi.Controllers
} }
[HttpPatch("{id}")] [HttpPatch("{id}")]
[DoxaApiAuth("bearer")]
public ActionResult<User> Update(Guid id, [FromBody] UpdateUserRequest request) public ActionResult<User> Update(Guid id, [FromBody] UpdateUserRequest request)
{ {
var user = _users.FirstOrDefault(u => u.Id == id); var user = _users.FirstOrDefault(u => u.Id == id);
@@ -86,6 +91,7 @@ namespace SampleApi.Controllers
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[DoxaApiAuth("apiKey")]
[Obsolete("Use POST /api/users/{id}/archive instead.")] [Obsolete("Use POST /api/users/{id}/archive instead.")]
public IActionResult Delete(Guid id) public IActionResult Delete(Guid id)
{ {
+13
View File
@@ -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; } = "";
}
}
@@ -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<OrderLine> Lines { get; set; } = new();
}
}
@@ -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; }
}
}
-86
View File
@@ -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<string> 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<OrderLine> 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<OrderLine> Lines { get; set; } = new();
}
}
+15
View File
@@ -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<OrderLine> Lines { get; set; } = new();
public decimal Total { get; set; }
public OrderStatus Status { get; set; }
public DateTime PlacedAt { get; set; }
}
}
+12
View File
@@ -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; }
}
}
+14
View File
@@ -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
}
}
@@ -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; }
}
}
+22
View File
@@ -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<string> Tags { get; set; } = new();
}
}
+12
View File
@@ -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
}
}
+9
View File
@@ -3,6 +3,9 @@ using EonaCat.DoxaApi.Middleware;
var builder = WebApplication.CreateBuilder(args); 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.AddControllers();
builder.Services.AddDoxaApi(options => 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.Description = "A demo service showing off the DoxaApi UI - users and orders.";
options.Version = "v1"; options.Version = "v1";
options.AccentColor = "#6366f1"; 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(); var app = builder.Build();