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>
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
+62
@@ -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 =>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+790
-63
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,6 +74,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modalRoot"></div>
|
||||
|
||||
<script>window.__DOXA_API_SPEC_URL__ = "__DOXA_API_SPEC_PATH__";</script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,162 +1,154 @@
|
||||
# EonaCat.DoxaApi
|
||||
|
||||
A self-contained, API documentation UI for ASP.NET Core
|
||||
**A modern, self-contained, dependency-free API documentation UI for ASP.NET Core**
|
||||
|
||||
EonaCat.DoxaApi scans your controllers with plain `System.Reflection` and ASP.NET Core's own `IActionDescriptorCollectionProvider`,
|
||||
builds a small OpenAPI-like JSON document with `System.Text.Json`, and serves a UI
|
||||
This can also be used in an offline environment!
|
||||
DoxaApi reflects over your controllers, generates an OpenAPI-compatible document, and serves a fast, single-page documentation UI with zero JavaScript dependencies. No npm build step, no CDN calls, no telemetry - one NuGet package, one line in `Program.cs`.
|
||||
|
||||

|
||||

|
||||
|
||||
## Install
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
dotnet add package EonaCat.DoxaApi
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```csharp
|
||||
using EonaCat.DoxaApi;
|
||||
using EonaCat.DoxaApi.Middleware;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddDoxaApi(options =>
|
||||
{
|
||||
options.Title = "My API";
|
||||
options.Description = "Internal service API";
|
||||
options.AccentColor = "#6366f1"; // any hex color
|
||||
options.Title = "Sample API";
|
||||
options.Description = "A demo service showing off the DoxaApi UI.";
|
||||
options.Version = "v1";
|
||||
options.AccentColor = "#6366f1";
|
||||
|
||||
// Optional: declare how clients authenticate.
|
||||
options.AddBearerAuth(description: "Sign in and paste your JWT here.");
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseRouting();
|
||||
app.UseDoxaApi(); // serves UI at /doxa and spec at /doxa/DoxaApi.json
|
||||
app.UseDoxaApi(options => options.RoutePrefix = "doxa");
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
Run your app and open `https://localhost:xxxx/doxa`.
|
||||
Run the app and open `/doxa`. That's the whole setup.
|
||||
|
||||
## Features
|
||||
|
||||
- **Route-table style navigation** - endpoints grouped by controller, each method color-coded (GET/POST/PUT/PATCH/DELETE), searchable with `/`.
|
||||
- **Schema viewer** - nested request/response shapes rendered as readable, syntax-colored trees, with required fields marked.
|
||||
- **Try it out** - a real three-pane layout: browse → inspect → call, with path/query/header inputs, an editable JSON body (pre-filled with a generated example), and a live response panel with status, timing, and syntax-highlighted JSON.
|
||||
- **Light & dark themes**, persisted, with a system-preference default.
|
||||
- **XML doc comment support** - `/// <summary>`, `<param>`, and `<returns>` are read directly from your project's generated `.xml` doc file (enable `<GenerateDocumentationFile>true</GenerateDocumentationFile>` in your csproj).
|
||||
- **Attributes for fine control**: `[EonaCat.DoxaApiGroup]`, `[EonaCat.DoxaApiSummary]`, `[EonaCat.DoxaApiDescription]`, `[EonaCat.DoxaApiExample]`, `[EonaCat.DoxaApiHidden]`.
|
||||
- **Zero external NuGet dependencies** - only references your app's own ASP.NET Core shared framework.
|
||||
|
||||
## Authoring your documentation
|
||||
|
||||
DoxaApi reads standard `///` XML doc comments and a handful of optional attributes:
|
||||
|
||||
```csharp
|
||||
[ApiController]
|
||||
[Route("api/users")]
|
||||
[DoxaApiGroup("Users")]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
/// <summary>Creates a new user.</summary>
|
||||
/// <param name="request">The user to create.</param>
|
||||
/// <returns>The created user.</returns>
|
||||
[HttpPost]
|
||||
[DoxaApiExample("""{ "name": "Grace Hopper", "email": "grace@example.com" }""")]
|
||||
public ActionResult<User> Create([FromBody] CreateUserRequest request) { ... }
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[Obsolete("Use POST /api/users/{id}/archive instead.")]
|
||||
public IActionResult Delete(Guid id) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
| Attribute | Purpose |
|
||||
|||
|
||||
| `[DoxaApiGroup("Name")]` | Groups endpoints in the nav (class or method level) |
|
||||
| `[DoxaApiSummary("...")]` | Overrides the auto-generated summary |
|
||||
| `[DoxaApiDescription("...")]` | Adds a longer description |
|
||||
| `[DoxaApiExample("""{ ... }""")]` | Supplies a realistic request body example |
|
||||
| `[DoxaApiHidden]` | Excludes a controller/action from the docs entirely |
|
||||
| `[DoxaApiAuth("schemeId")]` | Marks an endpoint as requiring a specific security scheme |
|
||||
| `[DoxaApiAllowAnonymous]` | Marks an endpoint as public, overriding any default scheme |
|
||||
| `[Obsolete("...")]` | Flags the endpoint as deprecated in the UI |
|
||||
|
||||
Standard ASP.NET Core `[Authorize]` / `[AllowAnonymous]` attributes are also detected automatically.
|
||||
|
||||
|
||||
|
||||
## Authentication
|
||||
|
||||
Declare the security schemes your API actually uses, and DoxaApi takes care of the rest - detecting which endpoints need them, badging them in the docs, and prompting for credentials in the UI's **Authorize** dialog.
|
||||
|
||||
```csharp
|
||||
builder.Services.AddDoxaApi(options =>
|
||||
{
|
||||
options.AddBearerAuth(); // Authorization: Bearer <token>
|
||||
options.AddApiKeyAuth("apiKey", "X-API-Key", "header"); // custom header
|
||||
options.AddBasicAuth(); // HTTP Basic
|
||||
options.AddOAuth2ClientCredentials("oauth2",
|
||||
tokenUrl: "https://auth.example.com/connect/token",
|
||||
scopes: new() { ["api.read"] = "Read access" });
|
||||
|
||||
options.DefaultSecurityScheme = "bearer"; // applied to any [Authorize] endpoint
|
||||
// without an explicit [DoxaApiAuth]
|
||||
});
|
||||
```
|
||||
|
||||
Credentials entered in the UI are stored in the browser's `localStorage` (never sent anywhere except the documented API itself) and are automatically attached to every "Try it" request, cURL snippet, and generated code sample for endpoints that require them.
|
||||
|
||||
|
||||
|
||||
## Mock server
|
||||
|
||||
Every endpoint can return a schema-shaped fake response without touching your real controllers - flip the **"Send to mock server"** toggle in the Try-it panel, or call it directly:
|
||||
|
||||
```
|
||||
GET /doxa/mock?operationId=Users_GetById
|
||||
```
|
||||
|
||||
Useful for frontend teams who need to start building against an API before the backend is finished. Disable it with `options.EnableMockServer = false;` if you'd rather it not be exposed.
|
||||
|
||||
## API console
|
||||
|
||||
The **Console** (top bar) lets you fire an arbitrary HTTP request - any method, any absolute URL, custom headers and body - through the server rather than the browser. Because the request happens server-side, it isn't subject to the browser's CORS policy, which makes it useful for poking at a different host than the one serving the docs. Disable with `options.EnableConsole = false;`.
|
||||
|
||||
## Code generation
|
||||
|
||||
Every endpoint's Try-it panel includes a **Code** tab with ready-to-paste snippets in cURL, JavaScript (`fetch`), Python (`requests`), C# (`HttpClient`), and Go (`net/http`) - automatically including whatever auth headers you've configured.
|
||||
|
||||
## Comparing spec versions
|
||||
|
||||
Click **Compare** and upload a previous (or upcoming) OpenAPI/Swagger/DoxaApi JSON file to see exactly which endpoints were added, removed, or changed - including parameter, request-body, and security differences. Handy in code review for catching accidental breaking changes before they ship.
|
||||
|
||||
## Import & export
|
||||
|
||||
DoxaApi documents are available at:
|
||||
|
||||
- `/doxa/doxaApi.json` - native format
|
||||
- `/doxa/openapi.json` - OpenAPI 3.0.3
|
||||
- `/doxa/swagger.json` - Swagger 2.0
|
||||
|
||||
...and the **Import** button accepts any of the three formats back in, so you can merge in a hand-written spec, or load an API documented elsewhere.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
```csharp
|
||||
builder.Services.AddDoxaApi(options =>
|
||||
{
|
||||
options.Title = "My API"; // shown in the top bar and browser tab
|
||||
options.Description = "..."; // shown on the welcome screen
|
||||
options.Title = "My API";
|
||||
options.Description = "...";
|
||||
options.Version = "v1";
|
||||
options.RoutePrefix = "doxa"; // UI served at /{RoutePrefix}
|
||||
options.AccentColor = "#6366f1"; // primary button/accent color
|
||||
options.Theme = "auto"; // "auto" | "light" | "dark"
|
||||
options.RoutePrefix = "doxa"; // served at /doxa
|
||||
options.Servers.Add("https://api.example.com");
|
||||
options.Theme = "auto"; // "auto" | "dark" | "light"
|
||||
options.AccentColor = "#6366f1";
|
||||
options.EnableMockServer = true;
|
||||
options.EnableConsole = true;
|
||||
});
|
||||
```
|
||||
|
||||
## Attributes
|
||||
|
||||
```csharp
|
||||
[EonaCat.DoxaApiGroup("Users")] // override the left-nav group name
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
/// <summary>Lists all users.</summary> // picked up automatically
|
||||
[HttpGet]
|
||||
public ActionResult<List<User>> GetUsers() => ...;
|
||||
|
||||
[HttpPost]
|
||||
[EonaCat.DoxaApiExample("""{ "name": "Ada" }""")] // overrides the auto-generated example
|
||||
public ActionResult<User> Create(CreateUserRequest request) => ...;
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[EonaCat.DoxaApiHidden] // omit from docs entirely
|
||||
public IActionResult Delete(Guid id) => ...;
|
||||
}
|
||||
```
|
||||
|
||||
## OpenAPI & Swagger
|
||||
|
||||
EonaCat.DoxaApi exposes dedicated endpoints for importing and exporting industry-standard spec formats alongside its own native JSON.
|
||||
|
||||
### Export
|
||||
|
||||
| URL | Format | Notes |
|
||||
|-----|--------|-------|
|
||||
| `/{RoutePrefix}/DoxaApi.json` | Native EonaCat.DoxaApi JSON | Always available; used by the built-in UI |
|
||||
| `/{RoutePrefix}/openapi.json` | **OpenAPI 3.0.3** | Import into Postman, Insomnia, Stoplight, etc. |
|
||||
| `/{RoutePrefix}/swagger.json` | **Swagger 2.0** | Import into older tools, AWS API Gateway, Azure APIM, etc. |
|
||||
|
||||
```bash
|
||||
# Download the OpenAPI 3.0 spec
|
||||
curl http://localhost:5000/doxa/openapi.json -o openapi.json
|
||||
|
||||
# Download the Swagger 2.0 spec
|
||||
curl http://localhost:5000/doxa/swagger.json -o swagger.json
|
||||
```
|
||||
|
||||
### Import
|
||||
|
||||
`POST /{RoutePrefix}/import`
|
||||
|
||||
Send any OpenAPI 3.x or Swagger 2.0 JSON document in the request body. The server detects the format automatically and returns the native EonaCat.DoxaApi document.
|
||||
|
||||
```bash
|
||||
# Import a third-party OpenAPI spec and get the EonaCat.DoxaApi document back
|
||||
curl -X POST http://localhost:5000/doxa/import \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @path/to/openapi.json
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
- Preview any public or third-party OpenAPI/Swagger spec in the EonaCat.DoxaApi UI
|
||||
- Validate that your spec round-trips correctly through import → export
|
||||
- Use the import endpoint as a conversion proxy (OpenAPI 3 ↔ Swagger 2)
|
||||
|
||||
### Programmatic use
|
||||
|
||||
The `OpenApiExporter`, `SwaggerExporter`, and `OpenApiImporter` classes in the
|
||||
`EonaCat.DoxaApi.Interop` namespace are `public static` and can be used directly in your
|
||||
own code:
|
||||
|
||||
```csharp
|
||||
using EonaCat.DoxaApi.Interop;
|
||||
using EonaCat.DoxaApi.Models;
|
||||
|
||||
// Export: ApiDocument → OpenAPI 3.0 JsonObject
|
||||
ApiDocument doc = /* ... */;
|
||||
System.Text.Json.Nodes.JsonObject openApi = OpenApiExporter.Export(doc);
|
||||
string json = openApi.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
// Export: ApiDocument → Swagger 2.0 JsonObject
|
||||
System.Text.Json.Nodes.JsonObject swagger = SwaggerExporter.Export(doc);
|
||||
|
||||
// Import: OpenAPI 3.x or Swagger 2.0 string → ApiDocument
|
||||
ApiDocument imported = OpenApiImporter.Import(File.ReadAllText("openapi.json"));
|
||||
|
||||
// Import from a Stream
|
||||
await using var stream = File.OpenRead("swagger.json");
|
||||
ApiDocument imported2 = OpenApiImporter.Import(stream);
|
||||
```
|
||||
|
||||
## Sample project
|
||||
|
||||
See `/sample/SampleApi` for a complete working example with two controllers (`Users`, `Orders`) demonstrating nested objects, enums, arrays, path/query parameters, and a deprecated endpoint.
|
||||
|
||||
```bash
|
||||
cd sample/SampleApi
|
||||
dotnet run
|
||||
# open http://localhost:5000/doxa
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SampleApi.Models;
|
||||
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||
|
||||
namespace SampleApi.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SampleApi.Models;
|
||||
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||
|
||||
namespace SampleApi.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
@@ -35,6 +38,7 @@ namespace SampleApi.Controllers
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[DoxaApiAuth("bearer")]
|
||||
[DoxaApiExample("""
|
||||
{
|
||||
"name": "Grace Hopper",
|
||||
@@ -59,6 +63,7 @@ namespace SampleApi.Controllers
|
||||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
[DoxaApiAuth("bearer")]
|
||||
public ActionResult<User> Update(Guid id, [FromBody] UpdateUserRequest request)
|
||||
{
|
||||
var user = _users.FirstOrDefault(u => u.Id == id);
|
||||
@@ -86,6 +91,7 @@ namespace SampleApi.Controllers
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[DoxaApiAuth("apiKey")]
|
||||
[Obsolete("Use POST /api/users/{id}/archive instead.")]
|
||||
public IActionResult Delete(Guid id)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace SampleApi.Models
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||
|
||||
public class Address
|
||||
{
|
||||
public string Street { get; set; } = "";
|
||||
public string City { get; set; } = "";
|
||||
public string PostalCode { get; set; } = "";
|
||||
public string Country { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace SampleApi.Models
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||
|
||||
public class CreateOrderRequest
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public List<OrderLine> Lines { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace SampleApi.Models
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||
|
||||
public class CreateUserRequest
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
public UserRole Role { get; set; } = UserRole.Member;
|
||||
|
||||
public Address? Address { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
namespace SampleApi.Models
|
||||
{
|
||||
public enum UserRole
|
||||
{
|
||||
Admin,
|
||||
Member,
|
||||
Guest
|
||||
}
|
||||
|
||||
public class User
|
||||
{
|
||||
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
public UserRole Role { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public Address? Address { get; set; }
|
||||
|
||||
public List<string> Tags { get; set; } = new();
|
||||
}
|
||||
|
||||
public class Address
|
||||
{
|
||||
public string Street { get; set; } = "";
|
||||
public string City { get; set; } = "";
|
||||
public string PostalCode { get; set; } = "";
|
||||
public string Country { get; set; } = "";
|
||||
}
|
||||
|
||||
public class CreateUserRequest
|
||||
{
|
||||
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
public UserRole Role { get; set; } = UserRole.Member;
|
||||
|
||||
public Address? Address { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateUserRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public UserRole? Role { get; set; }
|
||||
}
|
||||
|
||||
public class Order
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public List<OrderLine> Lines { get; set; } = new();
|
||||
public decimal Total { get; set; }
|
||||
public OrderStatus Status { get; set; }
|
||||
public DateTime PlacedAt { get; set; }
|
||||
}
|
||||
|
||||
public class OrderLine
|
||||
{
|
||||
public string Sku { get; set; } = "";
|
||||
public int Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
}
|
||||
|
||||
public enum OrderStatus
|
||||
{
|
||||
Pending,
|
||||
Paid,
|
||||
Shipped,
|
||||
Delivered,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
public class CreateOrderRequest
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public List<OrderLine> Lines { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace SampleApi.Models
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||
|
||||
public class Order
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public List<OrderLine> Lines { get; set; } = new();
|
||||
public decimal Total { get; set; }
|
||||
public OrderStatus Status { get; set; }
|
||||
public DateTime PlacedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace SampleApi.Models
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||
|
||||
public class OrderLine
|
||||
{
|
||||
public string Sku { get; set; } = "";
|
||||
public int Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace SampleApi.Models
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||
|
||||
public enum OrderStatus
|
||||
{
|
||||
Pending,
|
||||
Paid,
|
||||
Shipped,
|
||||
Delivered,
|
||||
Cancelled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace SampleApi.Models
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||
|
||||
public class UpdateUserRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public UserRole? Role { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace SampleApi.Models
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||
|
||||
public class User
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
public UserRole Role { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public Address? Address { get; set; }
|
||||
|
||||
public List<string> Tags { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace SampleApi.Models
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||
|
||||
public enum UserRole
|
||||
{
|
||||
Admin,
|
||||
Member,
|
||||
Guest
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ using EonaCat.DoxaApi.Middleware;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddDoxaApi(options =>
|
||||
{
|
||||
@@ -10,6 +13,12 @@ builder.Services.AddDoxaApi(options =>
|
||||
options.Description = "A demo service showing off the DoxaApi UI - users and orders.";
|
||||
options.Version = "v1";
|
||||
options.AccentColor = "#6366f1";
|
||||
|
||||
// Declare how clients authenticate; DoxaApi auto-detects which endpoints
|
||||
// require it via [Authorize] / [DoxaApiAuth] and renders an Authorize dialog.
|
||||
options.AddBearerAuth(description: "Paste a JWT issued by /api/auth/login.");
|
||||
options.AddApiKeyAuth("apiKey", "X-API-Key", description: "A static key for service-to-service calls.");
|
||||
options.DefaultSecurityScheme = "bearer";
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
Reference in New Issue
Block a user