Added authentication
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,4 +0,0 @@
|
||||
namespace EonaCat.DoxaApi.Exporter
|
||||
{
|
||||
public static class DoxaApiExporter { }
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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('}', '_');
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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,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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
Reference in New Issue
Block a user