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
{
// 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
{
@@ -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
{
@@ -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
{
@@ -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
{
@@ -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
{
+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; }
}
}
+4 -6
View File
@@ -10,10 +10,10 @@
<!-- NuGet package metadata -->
<PackageId>EonaCat.DoxaApi</PackageId>
<Version>0.0.4</Version>
<Version>0.0.5</Version>
<Authors>EonaCat (Jeroen Saey)</Authors>
<Description>A modern, self-contained, dependency-free OpenAPI documentation UI for ASP.NET Core.</Description>
<PackageTags>openapi;swagger;documentation;api;aspnetcore;doxa;api;docs;documentation;Jeroen;Saey;EonaCat;Scalar;Redoc;Postman;EchoAPI;</PackageTags>
<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;docs;mock-server;oauth2;codegen;Jeroen;Saey;EonaCat;Scalar;Redoc;Postman;EchoAPI;</PackageTags>
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.DoxaApi</RepositoryUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
@@ -27,7 +27,6 @@
<Copyright>EonaCat (Jeroen Saey)</Copyright>
<PackageProjectUrl>https://git.saey.me/EonaCat/EonaCat.DoxaApi</PackageProjectUrl>
<PackageIcon>icon.png</PackageIcon>
<AssemblyVersion></AssemblyVersion>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
</PropertyGroup>
@@ -52,7 +51,6 @@
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
<None Include="README.md" Pack="true" PackagePath="\" Condition="Exists('README.md')" />
<None Include="images\image.png" Pack="true" PackagePath="images\" />
</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 { }
}
+106 -1
View File
@@ -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"
};
}
}
}
+84 -1
View File
@@ -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"
};
}
}
}
+65 -2
View File
@@ -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<ActionDescriptor> _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;
}
/// <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)
{
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<string> { ResolveGroupName(cad) }
Tags = new List<string> { 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;
}
}
}
}
+110 -2
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 string Title { get; set; } = "DoxaApi Documentation";
@@ -9,5 +14,108 @@
public List<string> Servers { get; set; } = new();
public string Theme { get; set; } = "auto";
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
{
// 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<string, SchemaModel> _registry;
+3
View File
@@ -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; }
+3
View File
@@ -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)
+171 -7
View File
@@ -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<string, ApiGroup>(StringComparer.OrdinalIgnoreCase);
if (root["paths"] is JsonObject paths)
@@ -130,19 +143,20 @@ namespace EonaCat.DoxaApi.Interop
Method = method,
Path = path,
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)
{
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<string, ApiGroup>(StringComparer.OrdinalIgnoreCase);
if (root["paths"] is JsonObject paths)
@@ -379,7 +404,8 @@ namespace EonaCat.DoxaApi.Interop
Method = method,
Path = path,
Deprecated = op["deprecated"]?.GetValue<bool>() ?? 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<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)
{
var list = new List<string>();
@@ -600,4 +764,4 @@ namespace EonaCat.DoxaApi.Interop
private static string SanitizePath(string path)
=> path.Trim('/').Replace('/', '_').Replace('{', '_').Replace('}', '_');
}
}
}
@@ -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=<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 =>
{
uiApp.Run(async context =>
@@ -179,4 +241,4 @@ namespace EonaCat.DoxaApi.Middleware
return (ms.ToArray(), contentType);
}
}
}
}
+12 -1
View File
@@ -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<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();
}
}
}
+11 -1
View File
@@ -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<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
{
// 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")]
+3
View File
@@ -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")]
+3
View File
@@ -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")]
+3
View File
@@ -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")]
+3
View File
@@ -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")]
+3
View File
@@ -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")]
+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
{
// 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<DoxaApiOptions>? configure = null)
{
var options = new DoxaApiOptions();
+447 -22
View File
@@ -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;
}
+791 -64
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -33,13 +33,22 @@
</div>
<div class="topbar-actions">
<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="compareBtn" class="action-btn" title="Compare against another spec">⇄ Compare</button>
<button id="exportDoxaApiBtn" class="action-btn">DoxaApi</button>
<button id="exportOpenApiBtn" class="action-btn">OpenAPI</button>
<button id="exportSwaggerBtn" class="action-btn">Swagger</button>
</div>
<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">
<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>
@@ -65,7 +74,9 @@
</div>
</div>
<div id="modalRoot"></div>
<script>window.__DOXA_API_SPEC_URL__ = "__DOXA_API_SPEC_PATH__";</script>
<script src="app.js"></script>
</body>
</html>
</html>