Added more security and metrics

This commit is contained in:
2026-06-22 07:28:03 +02:00
committed by Jeroen Saey
parent 2a1f7b77ee
commit cb2d111ad7
27 changed files with 1638 additions and 884 deletions
+21 -13
View File
@@ -1,6 +1,6 @@
using System.Text; using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using EonaCat.Json;
namespace EonaCat.DoxaApi.Middleware namespace EonaCat.DoxaApi.Middleware
{ {
@@ -32,12 +32,20 @@ namespace EonaCat.DoxaApi.Middleware
"host", "content-length", "connection", "transfer-encoding" "host", "content-length", "connection", "transfer-encoding"
}; };
public static async Task HandleAsync(HttpContext context, JsonSerializerOptions writeOptions) private static readonly JsonSerializerSettings _settings = new()
{
NullValueHandling = NullValueHandling.Ignore,
Formatting = Formatting.Indented
};
public static async Task HandleAsync(HttpContext context, JsonSerializerSettings writeOptions)
{ {
ConsoleRequest? req; ConsoleRequest? req;
try try
{ {
req = await JsonSerializer.DeserializeAsync<ConsoleRequest>(context.Request.Body, ReadOptions); using var reader = new StreamReader(context.Request.Body);
var json = await reader.ReadToEndAsync();
req = JsonHelper.ToObject<ConsoleRequest>(json);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -116,35 +124,35 @@ namespace EonaCat.DoxaApi.Middleware
}; };
context.Response.ContentType = "application/json; charset=utf-8"; context.Response.ContentType = "application/json; charset=utf-8";
await JsonSerializer.SerializeAsync(context.Response.Body, result, writeOptions); var json = JsonHelper.ToJson(result, writeOptions);
await context.Response.WriteAsync(json, Encoding.UTF8);
} }
catch (TaskCanceledException) catch (TaskCanceledException)
{ {
context.Response.StatusCode = 504; context.Response.StatusCode = 504;
await JsonSerializer.SerializeAsync(context.Response.Body, new ConsoleResponse var errorResponse = new ConsoleResponse
{ {
StatusCode = 0, StatusCode = 0,
ElapsedMs = sw.ElapsedMilliseconds, ElapsedMs = sw.ElapsedMilliseconds,
NetworkError = true, NetworkError = true,
Body = "Request timed out after 30 seconds." Body = "Request timed out after 30 seconds."
}, writeOptions); };
var json = JsonHelper.ToJson(errorResponse, writeOptions);
await context.Response.WriteAsync(json, Encoding.UTF8);
} }
catch (Exception ex) catch (Exception ex)
{ {
context.Response.StatusCode = 502; context.Response.StatusCode = 502;
await JsonSerializer.SerializeAsync(context.Response.Body, new ConsoleResponse var errorResponse = new ConsoleResponse
{ {
StatusCode = 0, StatusCode = 0,
ElapsedMs = sw.ElapsedMilliseconds, ElapsedMs = sw.ElapsedMilliseconds,
NetworkError = true, NetworkError = true,
Body = ex.Message Body = ex.Message
}, writeOptions); };
var json = JsonHelper.ToJson(errorResponse, writeOptions);
await context.Response.WriteAsync(json, Encoding.UTF8);
} }
} }
private static readonly JsonSerializerOptions ReadOptions = new()
{
PropertyNameCaseInsensitive = true
};
} }
} }
+5 -5
View File
@@ -1,4 +1,4 @@
using System.Text.Json.Serialization; using EonaCat.Json;
namespace EonaCat.DoxaApi.Middleware namespace EonaCat.DoxaApi.Middleware
{ {
@@ -7,16 +7,16 @@ namespace EonaCat.DoxaApi.Middleware
internal sealed class ConsoleRequest internal sealed class ConsoleRequest
{ {
[JsonPropertyName("method")] [JsonProperty("method")]
public string Method { get; set; } = "GET"; public string Method { get; set; } = "GET";
[JsonPropertyName("url")] [JsonProperty("url")]
public string Url { get; set; } = ""; public string Url { get; set; } = "";
[JsonPropertyName("headers")] [JsonProperty("headers")]
public Dictionary<string, string>? Headers { get; set; } public Dictionary<string, string>? Headers { get; set; }
[JsonPropertyName("body")] [JsonProperty("body")]
public string? Body { get; set; } public string? Body { get; set; }
} }
} }
+6 -6
View File
@@ -1,4 +1,4 @@
using System.Text.Json.Serialization; using EonaCat.Json;
namespace EonaCat.DoxaApi.Middleware namespace EonaCat.DoxaApi.Middleware
{ {
@@ -7,19 +7,19 @@ namespace EonaCat.DoxaApi.Middleware
internal sealed class ConsoleResponse internal sealed class ConsoleResponse
{ {
[JsonPropertyName("statusCode")] [JsonProperty("statusCode")]
public int StatusCode { get; set; } public int StatusCode { get; set; }
[JsonPropertyName("elapsedMs")] [JsonProperty("elapsedMs")]
public long ElapsedMs { get; set; } public long ElapsedMs { get; set; }
[JsonPropertyName("headers")] [JsonProperty("headers")]
public Dictionary<string, string> Headers { get; set; } = new(); public Dictionary<string, string> Headers { get; set; } = new();
[JsonPropertyName("body")] [JsonProperty("body")]
public string Body { get; set; } = ""; public string Body { get; set; } = "";
[JsonPropertyName("networkError")] [JsonProperty("networkError")]
public bool NetworkError { get; set; } public bool NetworkError { get; set; }
} }
} }
+5 -1
View File
@@ -10,7 +10,7 @@
<!-- NuGet package metadata --> <!-- NuGet package metadata -->
<PackageId>EonaCat.DoxaApi</PackageId> <PackageId>EonaCat.DoxaApi</PackageId>
<Version>0.0.5</Version> <Version>0.0.6</Version>
<Authors>EonaCat (Jeroen Saey)</Authors> <Authors>EonaCat (Jeroen Saey)</Authors>
<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> <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> <PackageTags>openapi;swagger;documentation;api;aspnetcore;doxa;docs;mock-server;oauth2;codegen;Jeroen;Saey;EonaCat;Scalar;Redoc;Postman;EchoAPI;</PackageTags>
@@ -58,4 +58,8 @@
<PackagePath>\</PackagePath> <PackagePath>\</PackagePath>
</None> </None>
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="EonaCat.Json" Version="2.2.3" />
</ItemGroup>
</Project> </Project>
+376 -377
View File
@@ -1,377 +1,376 @@
using System.Text.Json; using EonaCat.Json.Linq;
using System.Text.Json.Nodes; using EonaCat.DoxaApi.Models;
using EonaCat.DoxaApi.Models;
namespace EonaCat.DoxaApi.Interop
namespace EonaCat.DoxaApi.Interop {
{ // This file is part of the EonaCat project(s) which is released under the Apache License.
// 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.
// See the LICENSE file or go to https://EonaCat.com/license for full license details. public static class OpenApiExporter
public static class OpenApiExporter {
{ public static JObject Export(ApiDocument doc)
public static JsonObject Export(ApiDocument doc) {
{ var root = new JObject
var root = new JsonObject {
{ ["openapi"] = "3.0.3",
["openapi"] = "3.0.3", ["info"] = BuildInfo(doc.Info),
["info"] = BuildInfo(doc.Info), };
};
if (doc.Servers.Count > 0)
if (doc.Servers.Count > 0) {
{ var servers = new JArray();
var servers = new JsonArray(); foreach (var s in doc.Servers)
foreach (var s in doc.Servers) {
{ servers.Add(new JObject { ["url"] = s });
servers.Add(new JsonObject { ["url"] = s }); }
}
root["servers"] = servers;
root["servers"] = servers; }
}
var paths = new JObject();
var paths = new JsonObject(); foreach (var group in doc.Groups)
foreach (var group in doc.Groups) {
{ foreach (var endpoint in group.Endpoints)
foreach (var endpoint in group.Endpoints) {
{ var openApiPath = ToOpenApiPath(endpoint.Path);
var openApiPath = ToOpenApiPath(endpoint.Path); if (!paths.ContainsKey(openApiPath))
if (!paths.ContainsKey(openApiPath)) {
{ paths[openApiPath] = new JObject();
paths[openApiPath] = new JsonObject(); }
}
var pathItem = (JObject)paths[openApiPath]!;
var pathItem = (JsonObject)paths[openApiPath]!; pathItem[endpoint.Method.ToLowerInvariant()] = BuildOperation(endpoint, group.Name);
pathItem[endpoint.Method.ToLowerInvariant()] = BuildOperation(endpoint, group.Name); }
} }
} root["paths"] = paths;
root["paths"] = paths;
if (doc.Schemas.Count > 0)
if (doc.Schemas.Count > 0) {
{ var schemas = new JObject();
var schemas = new JsonObject(); foreach (var (name, schema) in doc.Schemas)
foreach (var (name, schema) in doc.Schemas) {
{ schemas[name] = SchemaToOpenApi(schema);
schemas[name] = SchemaToOpenApi(schema); }
}
root["components"] = new JObject { ["schemas"] = schemas };
root["components"] = new JsonObject { ["schemas"] = schemas }; }
}
if (doc.SecuritySchemes.Count > 0)
if (doc.SecuritySchemes.Count > 0) {
{ var schemes = new JObject();
var schemes = new JsonObject(); foreach (var (id, scheme) in doc.SecuritySchemes)
foreach (var (id, scheme) in doc.SecuritySchemes) {
{ schemes[id] = SecuritySchemeToOpenApi(scheme);
schemes[id] = SecuritySchemeToOpenApi(scheme); }
}
if (root["components"] is JObject componentsObj)
if (root["components"] is JsonObject componentsObj) {
{ componentsObj["securitySchemes"] = schemes;
componentsObj["securitySchemes"] = schemes; }
} else
else {
{ root["components"] = new JObject { ["securitySchemes"] = schemes };
root["components"] = new JsonObject { ["securitySchemes"] = schemes }; }
} }
}
return root;
return root; }
}
private static JObject BuildInfo(ApiInfo info)
private static JsonObject BuildInfo(ApiInfo info) {
{ var obj = new JObject
var obj = new JsonObject {
{ ["title"] = info.Title,
["title"] = info.Title, ["version"] = info.Version
["version"] = info.Version };
}; if (info.Description is not null)
if (info.Description is not null) {
{ obj["description"] = info.Description;
obj["description"] = info.Description; }
}
return obj;
return obj; }
}
private static JObject BuildOperation(ApiEndpoint endpoint, string groupName)
private static JsonObject BuildOperation(ApiEndpoint endpoint, string groupName) {
{ var op = new JObject();
var op = new JsonObject();
var tags = new JArray();
var tags = new JsonArray(); foreach (var t in (endpoint.Tags.Count > 0 ? endpoint.Tags : new List<string> { groupName }))
foreach (var t in (endpoint.Tags.Count > 0 ? endpoint.Tags : new List<string> { groupName })) {
{ tags.Add(t);
tags.Add(t); }
}
op["tags"] = tags;
op["tags"] = tags;
op["operationId"] = endpoint.OperationId;
op["operationId"] = endpoint.OperationId;
if (endpoint.Summary is not null)
if (endpoint.Summary is not null) {
{ op["summary"] = endpoint.Summary;
op["summary"] = endpoint.Summary; }
}
if (endpoint.Description is not null)
if (endpoint.Description is not null) {
{ op["description"] = endpoint.Description;
op["description"] = endpoint.Description; }
}
if (endpoint.Deprecated)
if (endpoint.Deprecated) {
{ op["deprecated"] = true;
op["deprecated"] = true; }
}
if (endpoint.Parameters.Count > 0)
if (endpoint.Parameters.Count > 0) {
{ var parameters = new JArray();
var parameters = new JsonArray(); foreach (var p in endpoint.Parameters)
foreach (var p in endpoint.Parameters) {
{ var param = new JObject
var param = new JsonObject {
{ ["name"] = p.Name,
["name"] = p.Name, ["in"] = p.In,
["in"] = p.In, ["required"] = p.Required,
["required"] = p.Required, ["schema"] = SchemaToOpenApi(p.Schema)
["schema"] = SchemaToOpenApi(p.Schema) };
}; if (p.Description is not null)
if (p.Description is not null) {
{ param["description"] = p.Description;
param["description"] = p.Description; }
}
parameters.Add(param);
parameters.Add(param); }
} op["parameters"] = parameters;
op["parameters"] = parameters; }
}
if (endpoint.RequestBody is not null)
if (endpoint.RequestBody is not null) {
{ var rb = endpoint.RequestBody;
var rb = endpoint.RequestBody; var content = new JObject
var content = new JsonObject {
{ [rb.ContentType] = new JObject { ["schema"] = SchemaToOpenApi(rb.Schema) }
[rb.ContentType] = new JsonObject { ["schema"] = SchemaToOpenApi(rb.Schema) } };
}; if (rb.Example is not null)
if (rb.Example is not null) {
{ ((JObject)content[rb.ContentType]!)["example"] =
((JsonObject)content[rb.ContentType]!)["example"] = JToken.Parse(rb.Example);
JsonNode.Parse(rb.Example) ?? JsonValue.Create(rb.Example)!; }
} op["requestBody"] = new JObject
op["requestBody"] = new JsonObject {
{ ["required"] = rb.Required,
["required"] = rb.Required, ["content"] = content
["content"] = content };
}; }
}
var responses = new JObject();
var responses = new JsonObject(); foreach (var r in endpoint.Responses)
foreach (var r in endpoint.Responses) {
{ var resp = new JObject();
var resp = new JsonObject(); resp["description"] = r.Description ?? HttpStatusDescription(r.StatusCode);
resp["description"] = r.Description ?? HttpStatusDescription(r.StatusCode);
if (r.Schema is not null && r.Schema.Type != "void")
if (r.Schema is not null && r.Schema.Type != "void") {
{ resp["content"] = new JObject
resp["content"] = new JsonObject {
{ ["application/json"] = new JObject
["application/json"] = new JsonObject {
{ ["schema"] = SchemaToOpenApi(r.Schema)
["schema"] = SchemaToOpenApi(r.Schema) }
} };
}; }
} responses[r.StatusCode] = resp;
responses[r.StatusCode] = resp; }
} op["responses"] = responses;
op["responses"] = responses;
if (endpoint.Security.Count > 0)
if (endpoint.Security.Count > 0) {
{ var security = new JArray();
var security = new JsonArray(); foreach (var schemeId in endpoint.Security)
foreach (var schemeId in endpoint.Security) {
{ security.Add(new JObject { [schemeId] = new JArray() });
security.Add(new JsonObject { [schemeId] = new JsonArray() }); }
} op["security"] = security;
op["security"] = security; }
}
return op;
return op; }
}
private static JObject SecuritySchemeToOpenApi(SecuritySchemeModel scheme)
private static JsonObject SecuritySchemeToOpenApi(SecuritySchemeModel scheme) {
{ var obj = new JObject { ["type"] = scheme.Type };
var obj = new JsonObject { ["type"] = scheme.Type };
if (scheme.Description is not null)
if (scheme.Description is not null) {
{ obj["description"] = scheme.Description;
obj["description"] = scheme.Description; }
}
switch (scheme.Type)
switch (scheme.Type) {
{ case "apiKey":
case "apiKey": obj["name"] = scheme.Name ?? "X-API-Key";
obj["name"] = scheme.Name ?? "X-API-Key"; obj["in"] = scheme.In ?? "header";
obj["in"] = scheme.In ?? "header"; break;
break;
case "http":
case "http": obj["scheme"] = scheme.Scheme ?? "bearer";
obj["scheme"] = scheme.Scheme ?? "bearer"; if (scheme.BearerFormat is not null)
if (scheme.BearerFormat is not null) {
{ obj["bearerFormat"] = scheme.BearerFormat;
obj["bearerFormat"] = scheme.BearerFormat; }
} break;
break;
case "oauth2":
case "oauth2": var flows = new JObject();
var flows = new JsonObject(); if (scheme.Flows?.ClientCredentials is OAuthFlowModel cc)
if (scheme.Flows?.ClientCredentials is OAuthFlowModel cc) {
{ flows["clientCredentials"] = OAuthFlowToOpenApi(cc);
flows["clientCredentials"] = OAuthFlowToOpenApi(cc); }
} if (scheme.Flows?.AuthorizationCode is OAuthFlowModel ac)
if (scheme.Flows?.AuthorizationCode is OAuthFlowModel ac) {
{ flows["authorizationCode"] = OAuthFlowToOpenApi(ac);
flows["authorizationCode"] = OAuthFlowToOpenApi(ac); }
} if (scheme.Flows?.Password is OAuthFlowModel pw)
if (scheme.Flows?.Password is OAuthFlowModel pw) {
{ flows["password"] = OAuthFlowToOpenApi(pw);
flows["password"] = OAuthFlowToOpenApi(pw); }
} if (scheme.Flows?.Implicit is OAuthFlowModel im)
if (scheme.Flows?.Implicit is OAuthFlowModel im) {
{ flows["implicit"] = OAuthFlowToOpenApi(im);
flows["implicit"] = OAuthFlowToOpenApi(im); }
} obj["flows"] = flows;
obj["flows"] = flows; break;
break; }
}
return obj;
return obj; }
}
private static JObject OAuthFlowToOpenApi(OAuthFlowModel flow)
private static JsonObject OAuthFlowToOpenApi(OAuthFlowModel flow) {
{ var obj = new JObject();
var obj = new JsonObject(); if (flow.AuthorizationUrl is not null)
if (flow.AuthorizationUrl is not null) {
{ obj["authorizationUrl"] = flow.AuthorizationUrl;
obj["authorizationUrl"] = flow.AuthorizationUrl; }
} if (flow.TokenUrl is not null)
if (flow.TokenUrl is not null) {
{ obj["tokenUrl"] = flow.TokenUrl;
obj["tokenUrl"] = flow.TokenUrl; }
} if (flow.RefreshUrl is not null)
if (flow.RefreshUrl is not null) {
{ obj["refreshUrl"] = flow.RefreshUrl;
obj["refreshUrl"] = flow.RefreshUrl; }
}
var scopes = new JObject();
var scopes = new JsonObject(); foreach (var (k, v) in flow.Scopes)
foreach (var (k, v) in flow.Scopes) {
{ scopes[k] = v;
scopes[k] = v; }
} obj["scopes"] = scopes;
obj["scopes"] = scopes;
return obj;
return obj; }
}
private static JObject SchemaToOpenApi(SchemaModel schema)
private static JsonObject SchemaToOpenApi(SchemaModel schema) {
{
if (schema.RefName is not null)
if (schema.RefName is not null) {
{ return new JObject { ["$ref"] = $"#/components/schemas/{schema.RefName}" };
return new JsonObject { ["$ref"] = $"#/components/schemas/{schema.RefName}" }; }
}
var obj = new JObject();
var obj = new JsonObject();
switch (schema.Type)
switch (schema.Type) {
{ case "void":
case "void": return new JObject { ["type"] = "object" };
return new JsonObject { ["type"] = "object" };
case "enum":
case "enum": obj["type"] = "string";
obj["type"] = "string"; if (schema.EnumValues?.Count > 0)
if (schema.EnumValues?.Count > 0) {
{ var enums = new JArray();
var enums = new JsonArray(); foreach (var v in schema.EnumValues)
foreach (var v in schema.EnumValues) {
{ enums.Add(v);
enums.Add(v); }
}
obj["enum"] = enums;
obj["enum"] = enums; }
} break;
break;
case "array":
case "array": obj["type"] = "array";
obj["type"] = "array"; if (schema.Items is not null)
if (schema.Items is not null) {
{ obj["items"] = SchemaToOpenApi(schema.Items);
obj["items"] = SchemaToOpenApi(schema.Items); }
}
break;
break;
case "object":
case "object": obj["type"] = "object";
obj["type"] = "object"; if (schema.Properties?.Count > 0)
if (schema.Properties?.Count > 0) {
{ var props = new JObject();
var props = new JsonObject(); foreach (var (name, propSchema) in schema.Properties)
foreach (var (name, propSchema) in schema.Properties) {
{ props[name] = SchemaToOpenApi(propSchema);
props[name] = SchemaToOpenApi(propSchema); }
}
obj["properties"] = props;
obj["properties"] = props; }
} if (schema.Required?.Count > 0)
if (schema.Required?.Count > 0) {
{ var req = new JArray();
var req = new JsonArray(); foreach (var r in schema.Required)
foreach (var r in schema.Required) {
{ req.Add(r);
req.Add(r); }
}
obj["required"] = req;
obj["required"] = req; }
}
if (schema.Items is not null && schema.Properties is null)
if (schema.Items is not null && schema.Properties is null) {
{ obj["additionalProperties"] = SchemaToOpenApi(schema.Items);
obj["additionalProperties"] = SchemaToOpenApi(schema.Items); }
}
break;
break;
default:
default: obj["type"] = schema.Type;
obj["type"] = schema.Type; if (schema.Format is not null)
if (schema.Format is not null) {
{ obj["format"] = schema.Format;
obj["format"] = schema.Format; }
}
break;
break; }
}
if (schema.Nullable)
if (schema.Nullable) {
{ obj["nullable"] = true;
obj["nullable"] = true; }
}
return obj;
return obj; }
}
private static string ToOpenApiPath(string path)
private static string ToOpenApiPath(string path)
=> path.StartsWith('/') ? path : "/" + path;
=> path.StartsWith('/') ? path : "/" + path;
private static string HttpStatusDescription(string code) => code switch
private static string HttpStatusDescription(string code) => code switch {
{ "200" => "OK",
"200" => "OK", "201" => "Created",
"201" => "Created", "204" => "No Content",
"204" => "No Content", "400" => "Bad Request",
"400" => "Bad Request", "401" => "Unauthorized",
"401" => "Unauthorized", "403" => "Forbidden",
"403" => "Forbidden", "404" => "Not Found",
"404" => "Not Found", "409" => "Conflict",
"409" => "Conflict", "422" => "Unprocessable Entity",
"422" => "Unprocessable Entity", "500" => "Internal Server Error",
"500" => "Internal Server Error", _ => "Response"
_ => "Response" };
}; }
} }
}
+388 -387
View File
@@ -1,387 +1,388 @@
using System.Text.Json.Nodes;
using EonaCat.DoxaApi.Models; using EonaCat.Json.Linq;
using EonaCat.DoxaApi.Models;
namespace EonaCat.DoxaApi.Interop
{ namespace EonaCat.DoxaApi.Interop
// This file is part of the EonaCat project(s) which is released under the Apache License. {
// See the LICENSE file or go to https://EonaCat.com/license for full license details. // This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
public static class SwaggerExporter
{ public static class SwaggerExporter
public static JsonObject Export(ApiDocument doc) {
{ public static JObject Export(ApiDocument doc)
var root = new JsonObject {
{ var root = new JObject
["swagger"] = "2.0", {
["info"] = BuildInfo(doc.Info), ["swagger"] = "2.0",
}; ["info"] = BuildInfo(doc.Info),
};
if (doc.Servers.Count > 0 && Uri.TryCreate(doc.Servers[0], UriKind.Absolute, out var uri))
{ if (doc.Servers.Count > 0 && Uri.TryCreate(doc.Servers[0], UriKind.Absolute, out var uri))
root["host"] = uri.Host + (uri.IsDefaultPort ? "" : $":{uri.Port}"); {
root["basePath"] = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath; root["host"] = uri.Host + (uri.IsDefaultPort ? "" : $":{uri.Port}");
var schemes = new JsonArray(); root["basePath"] = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath;
schemes.Add(uri.Scheme); var schemes = new JArray();
root["schemes"] = schemes; schemes.Add(uri.Scheme);
} root["schemes"] = schemes;
else }
{ else
root["basePath"] = "/"; {
} root["basePath"] = "/";
}
root["consumes"] = new JsonArray { "application/json" };
root["produces"] = new JsonArray { "application/json" }; root["consumes"] = new JArray { "application/json" };
root["produces"] = new JArray { "application/json" };
var paths = new JsonObject();
foreach (var group in doc.Groups) var paths = new JObject();
{ foreach (var group in doc.Groups)
foreach (var endpoint in group.Endpoints) {
{ foreach (var endpoint in group.Endpoints)
var swaggerPath = ToSwaggerPath(endpoint.Path); {
if (!paths.ContainsKey(swaggerPath)) var swaggerPath = ToSwaggerPath(endpoint.Path);
{ if (!paths.ContainsKey(swaggerPath))
paths[swaggerPath] = new JsonObject(); {
} paths[swaggerPath] = new JObject();
}
var pathItem = (JsonObject)paths[swaggerPath]!;
pathItem[endpoint.Method.ToLowerInvariant()] = BuildOperation(endpoint, group.Name); var pathItem = (JObject)paths[swaggerPath]!;
} pathItem[endpoint.Method.ToLowerInvariant()] = BuildOperation(endpoint, group.Name);
} }
root["paths"] = paths; }
root["paths"] = paths;
if (doc.Schemas.Count > 0)
{ if (doc.Schemas.Count > 0)
var definitions = new JsonObject(); {
foreach (var (name, schema) in doc.Schemas) var definitions = new JObject();
{ foreach (var (name, schema) in doc.Schemas)
definitions[name] = SchemaToSwagger(schema); {
} definitions[name] = SchemaToSwagger(schema);
}
root["definitions"] = definitions;
} root["definitions"] = definitions;
}
if (doc.SecuritySchemes.Count > 0)
{ if (doc.SecuritySchemes.Count > 0)
var defs = new JsonObject(); {
foreach (var (id, scheme) in doc.SecuritySchemes) var defs = new JObject();
{ foreach (var (id, scheme) in doc.SecuritySchemes)
defs[id] = SecuritySchemeToSwagger(scheme); {
} defs[id] = SecuritySchemeToSwagger(scheme);
}
root["securityDefinitions"] = defs;
} root["securityDefinitions"] = defs;
}
return root;
} return root;
}
private static JsonObject BuildInfo(ApiInfo info)
{ private static JObject BuildInfo(ApiInfo info)
var obj = new JsonObject {
{ var obj = new JObject
["title"] = info.Title, {
["version"] = info.Version ["title"] = info.Title,
}; ["version"] = info.Version
if (info.Description is not null) };
{ if (info.Description is not null)
obj["description"] = info.Description; {
} obj["description"] = info.Description;
}
return obj;
} return obj;
}
private static JsonObject BuildOperation(ApiEndpoint endpoint, string groupName)
{ private static JObject BuildOperation(ApiEndpoint endpoint, string groupName)
var op = new JsonObject(); {
var op = new JObject();
var tags = new JsonArray();
foreach (var t in (endpoint.Tags.Count > 0 ? endpoint.Tags : new List<string> { groupName })) var tags = new JArray();
{ foreach (var t in (endpoint.Tags.Count > 0 ? endpoint.Tags : new List<string> { groupName }))
tags.Add(t); {
} tags.Add(t);
}
op["tags"] = tags;
op["tags"] = tags;
op["operationId"] = endpoint.OperationId;
op["operationId"] = endpoint.OperationId;
if (endpoint.Summary is not null)
{ if (endpoint.Summary is not null)
op["summary"] = endpoint.Summary; {
} op["summary"] = endpoint.Summary;
}
if (endpoint.Description is not null)
{ if (endpoint.Description is not null)
op["description"] = endpoint.Description; {
} op["description"] = endpoint.Description;
}
if (endpoint.Deprecated)
{ if (endpoint.Deprecated)
op["deprecated"] = true; {
} op["deprecated"] = true;
}
var parameters = new JsonArray();
var parameters = new JArray();
foreach (var p in endpoint.Parameters)
{ foreach (var p in endpoint.Parameters)
var param = new JsonObject {
{ var param = new JObject
["name"] = p.Name, {
["in"] = p.In, ["name"] = p.Name,
["required"] = p.Required, ["in"] = p.In,
}; ["required"] = p.Required,
if (p.Description is not null) };
{ if (p.Description is not null)
param["description"] = p.Description; {
} param["description"] = p.Description;
}
InlineSchemaIntoParam(param, p.Schema);
parameters.Add(param); InlineSchemaIntoParam(param, p.Schema);
} parameters.Add(param);
}
if (endpoint.RequestBody is not null)
{ if (endpoint.RequestBody is not null)
var rb = endpoint.RequestBody; {
var bodyParam = new JsonObject var rb = endpoint.RequestBody;
{ var bodyParam = new JObject
["name"] = "body", {
["in"] = "body", ["name"] = "body",
["required"] = rb.Required, ["in"] = "body",
["schema"] = SchemaToSwagger(rb.Schema) ["required"] = rb.Required,
}; ["schema"] = SchemaToSwagger(rb.Schema)
parameters.Add(bodyParam); };
} parameters.Add(bodyParam);
}
if (parameters.Count > 0)
{ if (parameters.Count > 0)
op["parameters"] = parameters; {
} op["parameters"] = parameters;
}
var responses = new JsonObject();
foreach (var r in endpoint.Responses) var responses = new JObject();
{ foreach (var r in endpoint.Responses)
var resp = new JsonObject {
{ var resp = new JObject
["description"] = r.Description ?? HttpStatusDescription(r.StatusCode) {
}; ["description"] = r.Description ?? HttpStatusDescription(r.StatusCode)
if (r.Schema is not null && r.Schema.Type != "void") };
{ if (r.Schema is not null && r.Schema.Type != "void")
resp["schema"] = SchemaToSwagger(r.Schema); {
} resp["schema"] = SchemaToSwagger(r.Schema);
}
responses[r.StatusCode] = resp;
} responses[r.StatusCode] = resp;
op["responses"] = responses; }
op["responses"] = responses;
if (endpoint.Security.Count > 0)
{ if (endpoint.Security.Count > 0)
var security = new JsonArray(); {
foreach (var schemeId in endpoint.Security) var security = new JArray();
{ foreach (var schemeId in endpoint.Security)
security.Add(new JsonObject { [schemeId] = new JsonArray() }); {
} security.Add(new JObject { [schemeId] = new JArray() });
op["security"] = security; }
} op["security"] = security;
}
return op;
} return op;
}
private static void InlineSchemaIntoParam(JsonObject param, SchemaModel schema)
{ private static void InlineSchemaIntoParam(JObject param, SchemaModel schema)
if (schema.RefName is not null) {
{ if (schema.RefName is not null)
{
param["type"] = "string";
return; param["type"] = "string";
} return;
}
switch (schema.Type)
{ switch (schema.Type)
case "array": {
param["type"] = "array"; case "array":
if (schema.Items is not null) param["type"] = "array";
{ if (schema.Items is not null)
var items = new JsonObject(); {
InlineSchemaIntoParam(items, schema.Items); var items = new JObject();
param["items"] = items; InlineSchemaIntoParam(items, schema.Items);
} param["items"] = items;
break; }
break;
case "enum":
param["type"] = "string"; case "enum":
if (schema.EnumValues?.Count > 0) param["type"] = "string";
{ if (schema.EnumValues?.Count > 0)
var enums = new JsonArray(); {
foreach (var v in schema.EnumValues) var enums = new JArray();
{ foreach (var v in schema.EnumValues)
enums.Add(v); {
} enums.Add(v);
}
param["enum"] = enums;
} param["enum"] = enums;
break; }
break;
default:
param["type"] = schema.Type == "void" ? "string" : schema.Type; default:
if (schema.Format is not null) param["type"] = schema.Type == "void" ? "string" : schema.Type;
{ if (schema.Format is not null)
param["format"] = schema.Format; {
} param["format"] = schema.Format;
}
break;
} break;
} }
}
private static JsonObject SchemaToSwagger(SchemaModel schema)
{ private static JObject SchemaToSwagger(SchemaModel schema)
if (schema.RefName is not null) {
{ if (schema.RefName is not null)
return new JsonObject { ["$ref"] = $"#/definitions/{schema.RefName}" }; {
} return new JObject { ["$ref"] = $"#/definitions/{schema.RefName}" };
}
var obj = new JsonObject();
var obj = new JObject();
switch (schema.Type)
{ switch (schema.Type)
case "void": {
return new JsonObject { ["type"] = "object" }; case "void":
return new JObject { ["type"] = "object" };
case "enum":
obj["type"] = "string"; case "enum":
if (schema.EnumValues?.Count > 0) obj["type"] = "string";
{ if (schema.EnumValues?.Count > 0)
var enums = new JsonArray(); {
foreach (var v in schema.EnumValues) var enums = new JArray();
{ foreach (var v in schema.EnumValues)
enums.Add(v); {
} enums.Add(v);
}
obj["enum"] = enums;
} obj["enum"] = enums;
break; }
break;
case "array":
obj["type"] = "array"; case "array":
if (schema.Items is not null) obj["type"] = "array";
{ if (schema.Items is not null)
obj["items"] = SchemaToSwagger(schema.Items); {
} obj["items"] = SchemaToSwagger(schema.Items);
}
break;
break;
case "object":
obj["type"] = "object"; case "object":
if (schema.Properties?.Count > 0) obj["type"] = "object";
{ if (schema.Properties?.Count > 0)
var props = new JsonObject(); {
foreach (var (name, propSchema) in schema.Properties) var props = new JObject();
{ foreach (var (name, propSchema) in schema.Properties)
props[name] = SchemaToSwagger(propSchema); {
} props[name] = SchemaToSwagger(propSchema);
}
obj["properties"] = props;
} obj["properties"] = props;
if (schema.Required?.Count > 0) }
{ if (schema.Required?.Count > 0)
var req = new JsonArray(); {
foreach (var r in schema.Required) var req = new JArray();
{ foreach (var r in schema.Required)
req.Add(r); {
} req.Add(r);
}
obj["required"] = req;
} obj["required"] = req;
if (schema.Items is not null && schema.Properties is null) }
{ if (schema.Items is not null && schema.Properties is null)
obj["additionalProperties"] = SchemaToSwagger(schema.Items); {
} obj["additionalProperties"] = SchemaToSwagger(schema.Items);
}
break;
break;
default:
obj["type"] = schema.Type; default:
if (schema.Format is not null) obj["type"] = schema.Type;
{ if (schema.Format is not null)
obj["format"] = schema.Format; {
} obj["format"] = schema.Format;
}
break;
} break;
}
return obj;
} return obj;
}
private static JsonObject SecuritySchemeToSwagger(SecuritySchemeModel scheme)
{ private static JObject SecuritySchemeToSwagger(SecuritySchemeModel scheme)
var obj = new JsonObject(); {
var obj = new JObject();
if (scheme.Type == "apiKey")
{ if (scheme.Type == "apiKey")
obj["type"] = "apiKey"; {
obj["name"] = scheme.Name ?? "X-API-Key"; obj["type"] = "apiKey";
obj["in"] = scheme.In ?? "header"; obj["name"] = scheme.Name ?? "X-API-Key";
} obj["in"] = scheme.In ?? "header";
else if (scheme.Type == "http" && scheme.Scheme == "basic") }
{ else if (scheme.Type == "http" && scheme.Scheme == "basic")
obj["type"] = "basic"; {
} obj["type"] = "basic";
else if (scheme.Type == "http" && scheme.Scheme == "bearer") }
{ 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. // Swagger 2.0 has no native bearer type; represent as an apiKey-style
obj["type"] = "apiKey"; // Authorization header for maximum tool compatibility.
obj["name"] = "Authorization"; obj["type"] = "apiKey";
obj["in"] = "header"; obj["name"] = "Authorization";
} obj["in"] = "header";
else if (scheme.Type == "oauth2") }
{ else if (scheme.Type == "oauth2")
obj["type"] = "oauth2"; {
var flow = scheme.Flows?.ClientCredentials; obj["type"] = "oauth2";
if (flow is not null) var flow = scheme.Flows?.ClientCredentials;
{ if (flow is not null)
obj["flow"] = "application"; {
obj["tokenUrl"] = flow.TokenUrl; obj["flow"] = "application";
var scopes = new JsonObject(); obj["tokenUrl"] = flow.TokenUrl;
foreach (var (k, v) in flow.Scopes) var scopes = new JObject();
{ foreach (var (k, v) in flow.Scopes)
scopes[k] = v; {
} scopes[k] = v;
obj["scopes"] = scopes; }
} obj["scopes"] = scopes;
else if (scheme.Flows?.AuthorizationCode is OAuthFlowModel ac) }
{ else if (scheme.Flows?.AuthorizationCode is OAuthFlowModel ac)
obj["flow"] = "accessCode"; {
obj["authorizationUrl"] = ac.AuthorizationUrl; obj["flow"] = "accessCode";
obj["tokenUrl"] = ac.TokenUrl; obj["authorizationUrl"] = ac.AuthorizationUrl;
var scopes = new JsonObject(); obj["tokenUrl"] = ac.TokenUrl;
foreach (var (k, v) in ac.Scopes) var scopes = new JObject();
{ foreach (var (k, v) in ac.Scopes)
scopes[k] = v; {
} scopes[k] = v;
obj["scopes"] = scopes; }
} obj["scopes"] = scopes;
} }
}
if (scheme.Description is not null)
{ if (scheme.Description is not null)
obj["description"] = scheme.Description; {
} obj["description"] = scheme.Description;
}
return obj;
} return obj;
}
private static string ToSwaggerPath(string path)
=> path.StartsWith('/') ? path : "/" + path; private static string ToSwaggerPath(string path)
=> path.StartsWith('/') ? path : "/" + path;
private static string HttpStatusDescription(string code) => code switch
{ private static string HttpStatusDescription(string code) => code switch
"200" => "OK", {
"201" => "Created", "200" => "OK",
"204" => "No Content", "201" => "Created",
"400" => "Bad Request", "204" => "No Content",
"401" => "Unauthorized", "400" => "Bad Request",
"403" => "Forbidden", "401" => "Unauthorized",
"404" => "Not Found", "403" => "Forbidden",
"500" => "Internal Server Error", "404" => "Not Found",
_ => "Response" "500" => "Internal Server Error",
}; _ => "Response"
} };
} }
}
+223
View File
@@ -0,0 +1,223 @@
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>
/// Collects and manages API metrics and analytics.
/// </summary>
public class MetricsCollector
{
private readonly Dictionary<string, ApiMetrics> _metrics = new();
private readonly List<RequestMetadata> _requestHistory = new();
private readonly object _lock = new();
private readonly DateTime _startTime = DateTime.UtcNow;
/// <summary>
/// Records a request to an endpoint.
/// </summary>
public void RecordRequest(string method, string path, int statusCode, long responseTimeMs, long responseSize, string? userAgent = null, string? ipAddress = null)
{
lock (_lock)
{
var key = $"{method}:{path}";
if (!_metrics.ContainsKey(key))
{
_metrics[key] = new ApiMetrics
{
EndpointId = key,
Method = method,
Path = path,
CreatedAt = DateTime.UtcNow
};
}
var metric = _metrics[key];
metric.TotalRequests++;
metric.LastRequestAt = DateTime.UtcNow;
if (statusCode >= 200 && statusCode < 300)
{
metric.SuccessfulRequests++;
}
else if (statusCode >= 400)
{
metric.FailedRequests++;
}
// Track status codes
if (!metric.StatusCodes.ContainsKey(statusCode))
{
metric.StatusCodes[statusCode] = 0;
}
metric.StatusCodes[statusCode]++;
// Calculate running average response time
metric.AverageResponseTimeMs = (metric.AverageResponseTimeMs * (metric.TotalRequests - 1) + responseTimeMs) / metric.TotalRequests;
// Calculate running average response size
metric.AverageResponseSize = (metric.AverageResponseSize * (metric.TotalRequests - 1) + responseSize) / metric.TotalRequests;
// Add to request history (keep last 1000)
_requestHistory.Add(new RequestMetadata
{
Method = method,
Path = path,
StatusCode = statusCode,
ResponseTimeMs = responseTimeMs,
ResponseSize = responseSize,
Timestamp = DateTime.UtcNow,
UserAgent = userAgent,
IpAddress = ipAddress
});
if (_requestHistory.Count > 1000)
{
_requestHistory.RemoveAt(0);
}
}
}
/// <summary>
/// Records an error for an endpoint.
/// </summary>
public void RecordError(string method, string path, string errorMessage, string? userAgent = null, string? ipAddress = null)
{
lock (_lock)
{
var key = $"{method}:{path}";
if (!_metrics.ContainsKey(key))
{
_metrics[key] = new ApiMetrics
{
EndpointId = key,
Method = method,
Path = path,
CreatedAt = DateTime.UtcNow
};
}
_metrics[key].FailedRequests++;
_requestHistory.Add(new RequestMetadata
{
Method = method,
Path = path,
StatusCode = 500,
ResponseTimeMs = 0,
Timestamp = DateTime.UtcNow,
UserAgent = userAgent,
IpAddress = ipAddress,
Error = errorMessage
});
if (_requestHistory.Count > 1000)
{
_requestHistory.RemoveAt(0);
}
}
}
/// <summary>
/// Gets metrics for a specific endpoint.
/// </summary>
public ApiMetrics? GetMetrics(string method, string path)
{
lock (_lock)
{
var key = $"{method}:{path}";
return _metrics.ContainsKey(key) ? _metrics[key] : null;
}
}
/// <summary>
/// Gets all collected metrics.
/// </summary>
public IEnumerable<ApiMetrics> GetAllMetrics()
{
lock (_lock)
{
return _metrics.Values.ToList();
}
}
/// <summary>
/// Gets the analytics dashboard data.
/// </summary>
public AnalyticsDashboard GetDashboard()
{
lock (_lock)
{
var allMetrics = _metrics.Values.ToList();
var totalRequests = allMetrics.Sum(m => m.TotalRequests);
var totalErrors = allMetrics.Sum(m => m.FailedRequests);
var topEndpoints = allMetrics
.OrderByDescending(m => m.TotalRequests)
.Take(10)
.Select(m => new EndpointUsageSummary
{
Method = m.Method,
Path = m.Path,
RequestCount = m.TotalRequests,
ErrorCount = m.FailedRequests,
AverageResponseTimeMs = m.AverageResponseTimeMs,
LastCalled = m.LastRequestAt
})
.ToList();
var recentRequests = _requestHistory
.OrderByDescending(r => r.Timestamp)
.Take(20)
.ToList();
var uptime = DateTime.UtcNow - _startTime;
var requestsPerSecond = totalRequests > 0 ? totalRequests / uptime.TotalSeconds : 0;
return new AnalyticsDashboard
{
TotalEndpoints = _metrics.Count,
TotalRequests = totalRequests,
TotalErrors = totalErrors,
AverageResponseTime = allMetrics.Any() ? allMetrics.Average(m => m.AverageResponseTimeMs) : 0,
PeakRequestsPerSecond = requestsPerSecond,
Uptime = uptime,
TopEndpoints = topEndpoints,
RecentRequests = recentRequests,
ErrorRate = totalRequests > 0 ? (double)totalErrors / totalRequests : 0,
GeneratedAt = DateTime.UtcNow
};
}
}
/// <summary>
/// Clears all collected metrics and history.
/// </summary>
public void Clear()
{
lock (_lock)
{
_metrics.Clear();
_requestHistory.Clear();
}
}
/// <summary>
/// Gets recent requests (last N).
/// </summary>
public IEnumerable<RequestMetadata> GetRecentRequests(int count = 50)
{
lock (_lock)
{
return _requestHistory
.OrderByDescending(r => r.Timestamp)
.Take(count)
.ToList();
}
}
}
}
+5 -3
View File
@@ -1,5 +1,5 @@
using EonaCat.DoxaApi.Models; using EonaCat.DoxaApi.Models;
using System.Text.Json; using EonaCat.Json;
namespace EonaCat.DoxaApi.Interop namespace EonaCat.DoxaApi.Interop
{ {
@@ -10,13 +10,15 @@ namespace EonaCat.DoxaApi.Interop
{ {
public static ApiDocument Import(string json) public static ApiDocument Import(string json)
{ {
return JsonSerializer.Deserialize<ApiDocument>(json) return JsonHelper.ToObject<ApiDocument>(json)
?? throw new InvalidOperationException("Invalid DoxaApi spec."); ?? throw new InvalidOperationException("Invalid DoxaApi spec.");
} }
public static async Task<ApiDocument> ImportAsync(Stream stream) public static async Task<ApiDocument> ImportAsync(Stream stream)
{ {
var doc = await JsonSerializer.DeserializeAsync<ApiDocument>(stream); using var reader = new StreamReader(stream);
var json = await reader.ReadToEndAsync();
var doc = JsonHelper.ToObject<ApiDocument>(json);
return doc ?? throw new InvalidOperationException("Invalid DoxaApi spec."); return doc ?? throw new InvalidOperationException("Invalid DoxaApi spec.");
} }
} }
@@ -1,7 +1,5 @@
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using EonaCat.DoxaApi.Generation; using EonaCat.DoxaApi.Generation;
using EonaCat.DoxaApi.Interop; using EonaCat.DoxaApi.Interop;
using EonaCat.DoxaApi.Models; using EonaCat.DoxaApi.Models;
@@ -10,6 +8,8 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using EonaCat.Json;
using EonaCat.Json.Linq;
namespace EonaCat.DoxaApi.Middleware namespace EonaCat.DoxaApi.Middleware
{ {
@@ -18,16 +18,17 @@ namespace EonaCat.DoxaApi.Middleware
public static class DoxaApiMiddlewareExtensions public static class DoxaApiMiddlewareExtensions
{ {
private static readonly JsonSerializerOptions _writeOptions = new() private static readonly JsonSerializerSettings _writeOptions = new()
{ {
WriteIndented = true, Formatting = Formatting.Indented,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull NullValueHandling = NullValueHandling.Ignore
}; };
public static IApplicationBuilder UseDoxaApi(this IApplicationBuilder app, Action<DoxaApiOptions>? configure = null) public static IApplicationBuilder UseDoxaApi(this IApplicationBuilder app, Action<DoxaApiOptions>? configure = null)
{ {
var options = app.ApplicationServices.GetService<DoxaApiOptions>() ?? new DoxaApiOptions(); var options = app.ApplicationServices.GetService<DoxaApiOptions>() ?? new DoxaApiOptions();
var metricsCollector = app.ApplicationServices.GetService<MetricsCollector>();
configure?.Invoke(options); configure?.Invoke(options);
var prefix = "/" + options.RoutePrefix.Trim('/'); var prefix = "/" + options.RoutePrefix.Trim('/');
@@ -38,7 +39,8 @@ namespace EonaCat.DoxaApi.Middleware
{ {
var document = GenerateDocument(context, options); var document = GenerateDocument(context, options);
context.Response.ContentType = "application/json; charset=utf-8"; context.Response.ContentType = "application/json; charset=utf-8";
await JsonSerializer.SerializeAsync(context.Response.Body, document, _writeOptions); var json = JsonHelper.ToJson(document, _writeOptions);
await context.Response.WriteAsync(json, Encoding.UTF8);
}); });
}); });
@@ -49,8 +51,8 @@ namespace EonaCat.DoxaApi.Middleware
var document = GenerateDocument(context, options); var document = GenerateDocument(context, options);
var openApi = OpenApiExporter.Export(document); var openApi = OpenApiExporter.Export(document);
context.Response.ContentType = "application/json; charset=utf-8"; context.Response.ContentType = "application/json; charset=utf-8";
await context.Response.WriteAsync( var json = JsonHelper.ToJson(openApi, _writeOptions);
openApi.ToJsonString(_writeOptions), Encoding.UTF8); await context.Response.WriteAsync(json, Encoding.UTF8);
}); });
}); });
@@ -61,8 +63,8 @@ namespace EonaCat.DoxaApi.Middleware
var document = GenerateDocument(context, options); var document = GenerateDocument(context, options);
var swagger = SwaggerExporter.Export(document); var swagger = SwaggerExporter.Export(document);
context.Response.ContentType = "application/json; charset=utf-8"; context.Response.ContentType = "application/json; charset=utf-8";
await context.Response.WriteAsync( var json = JsonHelper.ToJson(swagger, _writeOptions);
swagger.ToJsonString(_writeOptions), Encoding.UTF8); await context.Response.WriteAsync(json, Encoding.UTF8);
}); });
}); });
@@ -89,7 +91,8 @@ namespace EonaCat.DoxaApi.Middleware
{ {
var imported = await OpenApiImporter.ImportAsync(context.Request.Body); var imported = await OpenApiImporter.ImportAsync(context.Request.Body);
context.Response.ContentType = "application/json; charset=utf-8"; context.Response.ContentType = "application/json; charset=utf-8";
await JsonSerializer.SerializeAsync(context.Response.Body, imported, _writeOptions); var json = JsonHelper.ToJson(imported, _writeOptions);
await context.Response.WriteAsync(json, Encoding.UTF8);
} }
catch (NotSupportedException ex) catch (NotSupportedException ex)
{ {
@@ -138,7 +141,8 @@ namespace EonaCat.DoxaApi.Middleware
context.Response.StatusCode = int.TryParse(successResponse?.StatusCode, out var code) ? code : 200; context.Response.StatusCode = int.TryParse(successResponse?.StatusCode, out var code) ? code : 200;
context.Response.ContentType = "application/json; charset=utf-8"; context.Response.ContentType = "application/json; charset=utf-8";
context.Response.Headers["X-DoxaApi-Mock"] = "true"; context.Response.Headers["X-DoxaApi-Mock"] = "true";
await JsonSerializer.SerializeAsync(context.Response.Body, mockValue, _writeOptions); var json = JsonHelper.ToJson(mockValue, _writeOptions);
await context.Response.WriteAsync(json, Encoding.UTF8);
}); });
}); });
} }
@@ -199,6 +203,29 @@ namespace EonaCat.DoxaApi.Middleware
}); });
}); });
// Add analytics endpoint if metrics collector is registered
if (metricsCollector != null)
{
app.Map(prefix + "/analytics", analyticsApp =>
{
analyticsApp.Run(async context =>
{
if (!HttpMethods.IsGet(context.Request.Method))
{
context.Response.StatusCode = 405;
context.Response.Headers["Allow"] = "GET";
await context.Response.WriteAsync("Method Not Allowed - use GET");
return;
}
var dashboard = metricsCollector.GetDashboard();
context.Response.ContentType = "application/json; charset=utf-8";
var json = JsonHelper.ToJson(dashboard, _writeOptions);
await context.Response.WriteAsync(json, Encoding.UTF8);
});
});
}
return app; return app;
} }
+67
View File
@@ -0,0 +1,67 @@
using EonaCat.Json;
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>
/// Analytics dashboard data containing metrics and insights.
/// </summary>
public class AnalyticsDashboard
{
[JsonProperty("totalEndpoints")]
public int TotalEndpoints { get; set; }
[JsonProperty("totalRequests")]
public int TotalRequests { get; set; }
[JsonProperty("totalErrors")]
public int TotalErrors { get; set; }
[JsonProperty("averageResponseTime")]
public double AverageResponseTime { get; set; }
[JsonProperty("peakRequestsPerSecond")]
public double PeakRequestsPerSecond { get; set; }
[JsonProperty("uptime")]
public TimeSpan Uptime { get; set; }
[JsonProperty("topEndpoints")]
public List<EndpointUsageSummary> TopEndpoints { get; set; } = new();
[JsonProperty("recentRequests")]
public List<RequestMetadata> RecentRequests { get; set; } = new();
[JsonProperty("errorRate")]
public double ErrorRate { get; set; }
[JsonProperty("generatedAt")]
public DateTime GeneratedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Summary of endpoint usage for dashboard display.
/// </summary>
public class EndpointUsageSummary
{
[JsonProperty("method")]
public string Method { get; set; } = string.Empty;
[JsonProperty("path")]
public string Path { get; set; } = string.Empty;
[JsonProperty("requestCount")]
public int RequestCount { get; set; }
[JsonProperty("errorCount")]
public int ErrorCount { get; set; }
[JsonProperty("averageResponseTimeMs")]
public double AverageResponseTimeMs { get; set; }
[JsonProperty("lastCalled")]
public DateTime? LastCalled { get; set; }
}
}
+7 -7
View File
@@ -1,4 +1,4 @@
using System.Text.Json.Serialization; using EonaCat.Json;
namespace EonaCat.DoxaApi.Models namespace EonaCat.DoxaApi.Models
{ {
@@ -7,24 +7,24 @@ namespace EonaCat.DoxaApi.Models
public sealed class ApiDocument public sealed class ApiDocument
{ {
[JsonPropertyName("info")] [JsonProperty("info")]
public ApiInfo Info { get; set; } = new(); public ApiInfo Info { get; set; } = new();
[JsonPropertyName("servers")] [JsonProperty("servers")]
public List<string> Servers { get; set; } = new(); public List<string> Servers { get; set; } = new();
[JsonPropertyName("groups")] [JsonProperty("groups")]
public List<ApiGroup> Groups { get; set; } = new(); public List<ApiGroup> Groups { get; set; } = new();
[JsonPropertyName("schemas")] [JsonProperty("schemas")]
public Dictionary<string, SchemaModel> Schemas { get; set; } = new(); public Dictionary<string, SchemaModel> Schemas { get; set; } = new();
/// <summary>All security schemes available for this API (keyed by scheme id).</summary> /// <summary>All security schemes available for this API (keyed by scheme id).</summary>
[JsonPropertyName("securitySchemes")] [JsonProperty("securitySchemes")]
public Dictionary<string, SecuritySchemeModel> SecuritySchemes { get; set; } = new(); public Dictionary<string, SecuritySchemeModel> SecuritySchemes { get; set; } = new();
/// <summary>Which optional DoxaApi server-side features are enabled, for UI feature detection.</summary> /// <summary>Which optional DoxaApi server-side features are enabled, for UI feature detection.</summary>
[JsonPropertyName("features")] [JsonProperty("features")]
public ApiFeatureFlags Features { get; set; } = new(); public ApiFeatureFlags Features { get; set; } = new();
} }
} }
+12 -12
View File
@@ -1,4 +1,4 @@
using System.Text.Json.Serialization; using EonaCat.Json;
namespace EonaCat.DoxaApi.Models namespace EonaCat.DoxaApi.Models
{ {
@@ -7,41 +7,41 @@ namespace EonaCat.DoxaApi.Models
public sealed class ApiEndpoint public sealed class ApiEndpoint
{ {
[JsonPropertyName("operationId")] [JsonProperty("operationId")]
public string OperationId { get; set; } = ""; public string OperationId { get; set; } = "";
[JsonPropertyName("summary")] [JsonProperty("summary")]
public string? Summary { get; set; } public string? Summary { get; set; }
[JsonPropertyName("description")] [JsonProperty("description")]
public string? Description { get; set; } public string? Description { get; set; }
[JsonPropertyName("method")] [JsonProperty("method")]
public string Method { get; set; } = "GET"; public string Method { get; set; } = "GET";
[JsonPropertyName("path")] [JsonProperty("path")]
public string Path { get; set; } = "/"; public string Path { get; set; } = "/";
[JsonPropertyName("deprecated")] [JsonProperty("deprecated")]
public bool Deprecated { get; set; } public bool Deprecated { get; set; }
[JsonPropertyName("tags")] [JsonProperty("tags")]
public List<string> Tags { get; set; } = new(); public List<string> Tags { get; set; } = new();
[JsonPropertyName("parameters")] [JsonProperty("parameters")]
public List<ApiParameter> Parameters { get; set; } = new(); public List<ApiParameter> Parameters { get; set; } = new();
[JsonPropertyName("requestBody")] [JsonProperty("requestBody")]
public RequestBodyModel? RequestBody { get; set; } public RequestBodyModel? RequestBody { get; set; }
[JsonPropertyName("responses")] [JsonProperty("responses")]
public List<ResponseModel> Responses { get; set; } = new(); public List<ResponseModel> Responses { get; set; } = new();
/// <summary> /// <summary>
/// Ids of security schemes that apply to this endpoint (referencing ApiDocument.SecuritySchemes). /// 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). /// Empty means the endpoint is unauthenticated (or uses the document-wide default, if any).
/// </summary> /// </summary>
[JsonPropertyName("security")] [JsonProperty("security")]
public List<string> Security { get; set; } = new(); public List<string> Security { get; set; } = new();
} }
} }
+3 -3
View File
@@ -1,4 +1,4 @@
using System.Text.Json.Serialization; using EonaCat.Json;
namespace EonaCat.DoxaApi.Models namespace EonaCat.DoxaApi.Models
{ {
@@ -7,10 +7,10 @@ namespace EonaCat.DoxaApi.Models
public sealed class ApiFeatureFlags public sealed class ApiFeatureFlags
{ {
[JsonPropertyName("mockServer")] [JsonProperty("mockServer")]
public bool MockServer { get; set; } public bool MockServer { get; set; }
[JsonPropertyName("console")] [JsonProperty("console")]
public bool Console { get; set; } public bool Console { get; set; }
} }
} }
+4 -4
View File
@@ -1,4 +1,4 @@
using System.Text.Json.Serialization; using EonaCat.Json;
namespace EonaCat.DoxaApi.Models namespace EonaCat.DoxaApi.Models
{ {
@@ -7,13 +7,13 @@ namespace EonaCat.DoxaApi.Models
public sealed class ApiGroup public sealed class ApiGroup
{ {
[JsonPropertyName("name")] [JsonProperty("name")]
public string Name { get; set; } = ""; public string Name { get; set; } = "";
[JsonPropertyName("description")] [JsonProperty("description")]
public string? Description { get; set; } public string? Description { get; set; }
[JsonPropertyName("endpoints")] [JsonProperty("endpoints")]
public List<ApiEndpoint> Endpoints { get; set; } = new(); public List<ApiEndpoint> Endpoints { get; set; } = new();
} }
} }
+4 -4
View File
@@ -1,4 +1,4 @@
using System.Text.Json.Serialization; using EonaCat.Json;
namespace EonaCat.DoxaApi.Models namespace EonaCat.DoxaApi.Models
{ {
@@ -7,13 +7,13 @@ namespace EonaCat.DoxaApi.Models
public sealed class ApiInfo public sealed class ApiInfo
{ {
[JsonPropertyName("title")] [JsonProperty("title")]
public string Title { get; set; } = "API Documentation"; public string Title { get; set; } = "API Documentation";
[JsonPropertyName("description")] [JsonProperty("description")]
public string? Description { get; set; } public string? Description { get; set; }
[JsonPropertyName("version")] [JsonProperty("version")]
public string Version { get; set; } = "v1"; public string Version { get; set; } = "v1";
} }
} }
+46
View File
@@ -0,0 +1,46 @@
using EonaCat.Json;
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>
/// Tracks metrics and statistics for API endpoints.
/// </summary>
public class ApiMetrics
{
[JsonProperty("endpointId")]
public string EndpointId { get; set; } = string.Empty;
[JsonProperty("method")]
public string Method { get; set; } = string.Empty;
[JsonProperty("path")]
public string Path { get; set; } = string.Empty;
[JsonProperty("totalRequests")]
public int TotalRequests { get; set; }
[JsonProperty("successfulRequests")]
public int SuccessfulRequests { get; set; }
[JsonProperty("failedRequests")]
public int FailedRequests { get; set; }
[JsonProperty("averageResponseTimeMs")]
public double AverageResponseTimeMs { get; set; }
[JsonProperty("lastRequestAt")]
public DateTime? LastRequestAt { get; set; }
[JsonProperty("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[JsonProperty("statusCodes")]
public Dictionary<int, int> StatusCodes { get; set; } = new();
[JsonProperty("averageResponseSize")]
public long AverageResponseSize { get; set; }
}
}
+7 -7
View File
@@ -1,4 +1,4 @@
using System.Text.Json.Serialization; using EonaCat.Json;
namespace EonaCat.DoxaApi.Models namespace EonaCat.DoxaApi.Models
{ {
@@ -7,22 +7,22 @@ namespace EonaCat.DoxaApi.Models
public sealed class ApiParameter public sealed class ApiParameter
{ {
[JsonPropertyName("name")] [JsonProperty("name")]
public string Name { get; set; } = ""; public string Name { get; set; } = "";
[JsonPropertyName("in")] [JsonProperty("in")]
public string In { get; set; } = "query"; public string In { get; set; } = "query";
[JsonPropertyName("required")] [JsonProperty("required")]
public bool Required { get; set; } public bool Required { get; set; }
[JsonPropertyName("description")] [JsonProperty("description")]
public string? Description { get; set; } public string? Description { get; set; }
[JsonPropertyName("schema")] [JsonProperty("schema")]
public SchemaModel Schema { get; set; } = new(); public SchemaModel Schema { get; set; } = new();
[JsonPropertyName("default")] [JsonProperty("default")]
public object? Default { get; set; } public object? Default { get; set; }
} }
} }
+5 -5
View File
@@ -1,4 +1,4 @@
using System.Text.Json.Serialization; using EonaCat.Json;
namespace EonaCat.DoxaApi.Models namespace EonaCat.DoxaApi.Models
{ {
@@ -7,16 +7,16 @@ namespace EonaCat.DoxaApi.Models
public sealed class RequestBodyModel public sealed class RequestBodyModel
{ {
[JsonPropertyName("required")] [JsonProperty("required")]
public bool Required { get; set; } public bool Required { get; set; }
[JsonPropertyName("contentType")] [JsonProperty("contentType")]
public string ContentType { get; set; } = "application/json"; public string ContentType { get; set; } = "application/json";
[JsonPropertyName("schema")] [JsonProperty("schema")]
public SchemaModel Schema { get; set; } = new(); public SchemaModel Schema { get; set; } = new();
[JsonPropertyName("example")] [JsonProperty("example")]
public string? Example { get; set; } public string? Example { get; set; }
} }
} }
+46
View File
@@ -0,0 +1,46 @@
using EonaCat.Json;
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>
/// Metadata for tracking requests and responses.
/// </summary>
public class RequestMetadata
{
[JsonProperty("id")]
public string Id { get; set; } = Guid.NewGuid().ToString();
[JsonProperty("method")]
public string Method { get; set; } = string.Empty;
[JsonProperty("path")]
public string Path { get; set; } = string.Empty;
[JsonProperty("statusCode")]
public int StatusCode { get; set; }
[JsonProperty("responseTimeMs")]
public long ResponseTimeMs { get; set; }
[JsonProperty("requestSize")]
public long RequestSize { get; set; }
[JsonProperty("responseSize")]
public long ResponseSize { get; set; }
[JsonProperty("timestamp")]
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
[JsonProperty("userAgent", NullValueHandling = NullValueHandling.Ignore)]
public string? UserAgent { get; set; }
[JsonProperty("ipAddress", NullValueHandling = NullValueHandling.Ignore)]
public string? IpAddress { get; set; }
[JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)]
public string? Error { get; set; }
}
}
+4 -4
View File
@@ -1,4 +1,4 @@
using System.Text.Json.Serialization; using EonaCat.Json;
namespace EonaCat.DoxaApi.Models namespace EonaCat.DoxaApi.Models
{ {
@@ -7,13 +7,13 @@ namespace EonaCat.DoxaApi.Models
public sealed class ResponseModel public sealed class ResponseModel
{ {
[JsonPropertyName("statusCode")] [JsonProperty("statusCode")]
public string StatusCode { get; set; } = "200"; public string StatusCode { get; set; } = "200";
[JsonPropertyName("description")] [JsonProperty("description")]
public string? Description { get; set; } public string? Description { get; set; }
[JsonPropertyName("schema")] [JsonProperty("schema")]
public SchemaModel? Schema { get; set; } public SchemaModel? Schema { get; set; }
} }
} }
+9 -9
View File
@@ -1,4 +1,4 @@
using System.Text.Json.Serialization; using EonaCat.Json;
namespace EonaCat.DoxaApi.Models namespace EonaCat.DoxaApi.Models
{ {
@@ -7,28 +7,28 @@ namespace EonaCat.DoxaApi.Models
public sealed class SchemaModel public sealed class SchemaModel
{ {
[JsonPropertyName("type")] [JsonProperty("type")]
public string Type { get; set; } = "object"; public string Type { get; set; } = "object";
[JsonPropertyName("format")] [JsonProperty("format")]
public string? Format { get; set; } public string? Format { get; set; }
[JsonPropertyName("refName")] [JsonProperty("refName")]
public string? RefName { get; set; } public string? RefName { get; set; }
[JsonPropertyName("items")] [JsonProperty("items")]
public SchemaModel? Items { get; set; } public SchemaModel? Items { get; set; }
[JsonPropertyName("properties")] [JsonProperty("properties")]
public Dictionary<string, SchemaModel>? Properties { get; set; } public Dictionary<string, SchemaModel>? Properties { get; set; }
[JsonPropertyName("required")] [JsonProperty("required")]
public List<string>? Required { get; set; } public List<string>? Required { get; set; }
[JsonPropertyName("enumValues")] [JsonProperty("enumValues")]
public List<string>? EnumValues { get; set; } public List<string>? EnumValues { get; set; }
[JsonPropertyName("nullable")] [JsonProperty("nullable")]
public bool Nullable { get; set; } public bool Nullable { get; set; }
} }
} }
+17 -17
View File
@@ -1,4 +1,4 @@
using System.Text.Json.Serialization; using EonaCat.Json;
namespace EonaCat.DoxaApi.Models namespace EonaCat.DoxaApi.Models
{ {
@@ -13,64 +13,64 @@ namespace EonaCat.DoxaApi.Models
/// </summary> /// </summary>
public sealed class SecuritySchemeModel public sealed class SecuritySchemeModel
{ {
[JsonPropertyName("id")] [JsonProperty("id")]
public string Id { get; set; } = ""; public string Id { get; set; } = "";
/// <summary>apiKey | http | oauth2 | openIdConnect</summary> /// <summary>apiKey | http | oauth2 | openIdConnect</summary>
[JsonPropertyName("type")] [JsonProperty("type")]
public string Type { get; set; } = "apiKey"; public string Type { get; set; } = "apiKey";
[JsonPropertyName("description")] [JsonProperty("description")]
public string? Description { get; set; } public string? Description { get; set; }
/// <summary>For apiKey: header | query | cookie</summary> /// <summary>For apiKey: header | query | cookie</summary>
[JsonPropertyName("in")] [JsonProperty("in")]
public string? In { get; set; } public string? In { get; set; }
/// <summary>For apiKey: the header/query/cookie name. For http: ignored.</summary> /// <summary>For apiKey: the header/query/cookie name. For http: ignored.</summary>
[JsonPropertyName("name")] [JsonProperty("name")]
public string? Name { get; set; } public string? Name { get; set; }
/// <summary>For http: bearer | basic | digest</summary> /// <summary>For http: bearer | basic | digest</summary>
[JsonPropertyName("scheme")] [JsonProperty("scheme")]
public string? Scheme { get; set; } public string? Scheme { get; set; }
/// <summary>For http bearer: an informational hint such as "JWT".</summary> /// <summary>For http bearer: an informational hint such as "JWT".</summary>
[JsonPropertyName("bearerFormat")] [JsonProperty("bearerFormat")]
public string? BearerFormat { get; set; } public string? BearerFormat { get; set; }
/// <summary>For oauth2: supported flows (clientCredentials, authorizationCode, password, implicit).</summary> /// <summary>For oauth2: supported flows (clientCredentials, authorizationCode, password, implicit).</summary>
[JsonPropertyName("flows")] [JsonProperty("flows")]
public OAuthFlowsModel? Flows { get; set; } public OAuthFlowsModel? Flows { get; set; }
} }
public sealed class OAuthFlowsModel public sealed class OAuthFlowsModel
{ {
[JsonPropertyName("clientCredentials")] [JsonProperty("clientCredentials")]
public OAuthFlowModel? ClientCredentials { get; set; } public OAuthFlowModel? ClientCredentials { get; set; }
[JsonPropertyName("authorizationCode")] [JsonProperty("authorizationCode")]
public OAuthFlowModel? AuthorizationCode { get; set; } public OAuthFlowModel? AuthorizationCode { get; set; }
[JsonPropertyName("password")] [JsonProperty("password")]
public OAuthFlowModel? Password { get; set; } public OAuthFlowModel? Password { get; set; }
[JsonPropertyName("implicit")] [JsonProperty("implicit")]
public OAuthFlowModel? Implicit { get; set; } public OAuthFlowModel? Implicit { get; set; }
} }
public sealed class OAuthFlowModel public sealed class OAuthFlowModel
{ {
[JsonPropertyName("authorizationUrl")] [JsonProperty("authorizationUrl")]
public string? AuthorizationUrl { get; set; } public string? AuthorizationUrl { get; set; }
[JsonPropertyName("tokenUrl")] [JsonProperty("tokenUrl")]
public string? TokenUrl { get; set; } public string? TokenUrl { get; set; }
[JsonPropertyName("refreshUrl")] [JsonProperty("refreshUrl")]
public string? RefreshUrl { get; set; } public string? RefreshUrl { get; set; }
[JsonPropertyName("scopes")] [JsonProperty("scopes")]
public Dictionary<string, string> Scopes { get; set; } = new(); public Dictionary<string, string> Scopes { get; set; } = new();
} }
} }
+1
View File
@@ -13,6 +13,7 @@ namespace EonaCat.DoxaApi
var options = new DoxaApiOptions(); var options = new DoxaApiOptions();
configure?.Invoke(options); configure?.Invoke(options);
services.AddSingleton(options); services.AddSingleton(options);
services.AddSingleton<MetricsCollector>();
return services; return services;
} }
} }
+178
View File
@@ -1896,3 +1896,181 @@ textarea.field-input {
width: 14px; width: 14px;
height: 14px; height: 14px;
} }
/* Dashboard Styles */
.dashboard-panel {
position: absolute;
right: 0;
top: 58px;
bottom: 0;
width: 400px;
background: var(--bg-1);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 100;
box-shadow: var(--shadow);
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-raised);
}
.dashboard-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-0);
}
.close-btn {
background: transparent;
border: none;
font-size: 24px;
color: var(--text-1);
cursor: pointer;
padding: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: var(--text-0);
}
.dashboard-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.dashboard-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 16px;
text-align: center;
transition: all 0.2s ease;
}
.stat-card:hover {
border-color: var(--accent);
background: var(--bg-3);
}
.stat-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-2);
margin-bottom: 8px;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--accent);
font-family: var(--font-mono);
}
.stat-value.error-red {
color: var(--m-delete);
}
.metric-chart {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 16px;
margin-bottom: 16px;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-2);
font-size: 12px;
}
.recent-requests {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.request-item {
padding: 12px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
transition: background 0.2s ease;
}
.request-item:hover {
background: var(--bg-3);
}
.request-item:last-child {
border-bottom: none;
}
.request-method {
font-weight: 600;
font-family: var(--font-mono);
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
}
.request-status-success {
color: var(--m-get);
}
.request-status-error {
color: var(--m-delete);
}
.request-time {
color: var(--text-2);
font-family: var(--font-mono);
}
#app-shell.with-dashboard {
grid-template-columns: var(--nav-w) 1fr var(--try-w) 400px;
}
@media (max-width: 1400px) {
.dashboard-panel {
width: 300px;
}
#app-shell.with-dashboard {
grid-template-columns: var(--nav-w) 1fr 300px;
}
}
@media (max-width: 768px) {
.dashboard-panel {
width: 100%;
right: auto;
left: 0;
}
}
+95
View File
@@ -1386,6 +1386,9 @@
const exportDoxaApiBtn = document.getElementById("exportDoxaApiBtn"); const exportDoxaApiBtn = document.getElementById("exportDoxaApiBtn");
const exportOpenApiBtn = document.getElementById("exportOpenApiBtn"); const exportOpenApiBtn = document.getElementById("exportOpenApiBtn");
const exportSwaggerBtn = document.getElementById("exportSwaggerBtn"); const exportSwaggerBtn = document.getElementById("exportSwaggerBtn");
const dashboardBtn = document.getElementById("dashboardBtn");
const closeDashboard = document.getElementById("closeDashboard");
const dashboardPanel = document.getElementById("dashboardPanel");
importBtn?.addEventListener("click", () => importFile.click()); importBtn?.addEventListener("click", () => importFile.click());
@@ -1443,6 +1446,10 @@
exportOpenApiBtn?.addEventListener("click", () => download("openapi.json", "openapi.json")); exportOpenApiBtn?.addEventListener("click", () => download("openapi.json", "openapi.json"));
exportSwaggerBtn?.addEventListener("click", () => download("swagger.json", "swagger.json")); exportSwaggerBtn?.addEventListener("click", () => download("swagger.json", "swagger.json"));
// Dashboard functionality
dashboardBtn?.addEventListener("click", () => openDashboard());
closeDashboard?.addEventListener("click", () => closeDashboardPanel());
const authBtn = document.getElementById("authBtn"); const authBtn = document.getElementById("authBtn");
authBtn?.addEventListener("click", () => openAuthModal()); authBtn?.addEventListener("click", () => openAuthModal());
@@ -1578,5 +1585,93 @@
return escapeHtml(str); return escapeHtml(str);
} }
// Dashboard functions
async function openDashboard() {
const dashboardPanel = document.getElementById("dashboardPanel");
const dashboard = document.querySelector(".dashboard-panel");
if (!dashboardPanel) return;
dashboardPanel.style.display = "block";
try {
const res = await fetch("analytics");
if (!res.ok) throw new Error("Failed to load analytics");
const analyticsData = await res.json();
updateDashboard(analyticsData);
// Refresh every 5 seconds
if (window.dashboardInterval) clearInterval(window.dashboardInterval);
window.dashboardInterval = setInterval(async () => {
const res = await fetch("analytics");
if (res.ok) {
const data = await res.json();
updateDashboard(data);
}
}, 5000);
} catch (err) {
console.error("Dashboard load error:", err);
}
}
function closeDashboardPanel() {
const dashboardPanel = document.getElementById("dashboardPanel");
if (dashboardPanel) dashboardPanel.style.display = "none";
if (window.dashboardInterval) {
clearInterval(window.dashboardInterval);
window.dashboardInterval = null;
}
}
function updateDashboard(data) {
document.getElementById("totalEndpoints").textContent = data.totalEndpoints || "0";
document.getElementById("totalRequests").textContent = data.totalRequests || "0";
document.getElementById("totalErrors").textContent = data.totalErrors || "0";
document.getElementById("avgResponse").textContent = Math.round((data.averageResponseTime || 0)) + "ms";
// Update top endpoints
const topEndpointsList = (data.topEndpoints || []).map(ep => `
<div class="request-item">
<div>
<span class="request-method">${ep.method}</span>
<code style="font-size: 11px; color: var(--text-1);">${ep.path}</code>
</div>
<div class="request-time">${ep.requestCount} req</div>
</div>
`).join("") || "<div style='padding: 20px; color: var(--text-2); text-align: center;'>No data yet</div>";
document.getElementById("topEndpointsChart").innerHTML = `
<div style="width: 100%;">
<h3 style="margin: 0 0 12px 0; font-size: 13px; font-weight: 600;">Top Endpoints</h3>
${topEndpointsList}
</div>
`;
// Update recent requests
const recentRequestsList = (data.recentRequests || []).map(req => {
const statusClass = req.statusCode >= 400 ? "request-status-error" : "request-status-success";
return `
<div class="request-item">
<div>
<span class="request-method">${req.method}</span>
<code style="font-size: 11px; color: var(--text-1);">${req.path}</code>
</div>
<div>
<span class="request-time ${statusClass}">${req.statusCode}</span>
<span class="request-time" style="margin-left: 8px;">${req.responseTimeMs}ms</span>
</div>
</div>
`;
}).join("") || "<div style='padding: 20px; color: var(--text-2); text-align: center;'>No requests yet</div>";
document.getElementById("recentRequestsTable").innerHTML = `
<div style="padding: 16px; background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius-md);">
<h3 style="margin: 0 0 12px 0; font-size: 13px; font-weight: 600;">Recent Requests</h3>
${recentRequestsList}
</div>
`;
}
init(); init();
})(); })();
+34 -1
View File
@@ -33,7 +33,10 @@
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
<div class="action-group"> <div class="action-group">
<button id="authBtn" class="action-btn icon-btn-badge" title="Authorization"> <button id="dashboardBtn" class="action-btn" title="View Analytics Dashboard">
<svg viewBox="0 0 24 24" fill="none" width="15" height="15" style="vertical-align:-2px;margin-right:4px;"><rect x="3" y="3" width="7" height="7" stroke="currentColor" stroke-width="2"/><rect x="14" y="3" width="7" height="7" stroke="currentColor" stroke-width="2"/><rect x="3" y="14" width="7" height="7" stroke="currentColor" stroke-width="2"/><rect x="14" y="14" width="7" height="7" stroke="currentColor" stroke-width="2"/></svg>Dashboard
</button>
<button id="authBtn" class="action-btn" title="Manage authentication">
<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 <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> <span class="badge-dot" id="authBadgeDot" style="display:none;"></span>
</button> </button>
@@ -71,6 +74,36 @@
<aside class="try-pane" id="tryPane"> <aside class="try-pane" id="tryPane">
<div id="tryContent" class="try-content"></div> <div id="tryContent" class="try-content"></div>
</aside> </aside>
<!-- Dashboard Panel (initially hidden) -->
<div id="dashboardPanel" class="dashboard-panel" style="display: none;">
<div class="dashboard-header">
<h2>API Analytics</h2>
<button id="closeDashboard" class="close-btn" title="Close Dashboard">&times;</button>
</div>
<div class="dashboard-content">
<div class="dashboard-stats">
<div class="stat-card">
<div class="stat-label">Total Endpoints</div>
<div class="stat-value" id="totalEndpoints">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Requests</div>
<div class="stat-value" id="totalRequests">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Errors</div>
<div class="stat-value error-red" id="totalErrors">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Avg Response</div>
<div class="stat-value" id="avgResponse">0ms</div>
</div>
</div>
<div id="topEndpointsChart" class="metric-chart"></div>
<div id="recentRequestsTable" class="recent-requests"></div>
</div>
</div>
</div> </div>
</div> </div>
+31 -7
View File
@@ -1,8 +1,8 @@
# EonaCat.DoxaApi # EonaCat.DoxaApi
**A modern, self-contained, dependency-free API documentation UI for ASP.NET Core** **A modern, self-contained API documentation UI for ASP.NET Core with built-in analytics and monitoring**
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`. DoxaApi reflects over your controllers, generates an OpenAPI-compatible document, and serves a fast, single-page documentation UI.
![DoxaApi screenshot](images/image.png) ![DoxaApi screenshot](images/image.png)
@@ -41,10 +41,6 @@ app.Run();
Run the app and open `/doxa`. That's the whole setup. Run the app and open `/doxa`. That's the whole setup.
## Authoring your documentation
DoxaApi reads standard `///` XML doc comments and a handful of optional attributes: DoxaApi reads standard `///` XML doc comments and a handful of optional attributes:
```csharp ```csharp
@@ -104,7 +100,35 @@ Credentials entered in the UI are stored in the browser's `localStorage` (never
## Mock server ## Analytics Dashboard
The built-in **Analytics Dashboard** provides real-time insights into your API usage:
- **Endpoint Statistics** - View total requests, success rate, and average response times per endpoint
- **Request History** - Track recent requests with status codes and response times
- **Performance Metrics** - Monitor overall API health and uptime
- **Top Endpoints** - Identify your most-used endpoints at a glance
Click the **Dashboard** button in the top navigation to view live analytics. Metrics are automatically collected and refreshed every 5 seconds.
```csharp
// Access analytics programmatically
var metricsCollector = app.ApplicationServices.GetService<MetricsCollector>();
if (metricsCollector != null)
{
var dashboard = metricsCollector.GetDashboard();
var recentRequests = metricsCollector.GetRecentRequests(limit: 50);
}
```
Get the analytics JSON via:
```
GET /doxa/analytics
```
All API requests and responses are automatically serialized, ensuring consistent behavior across your entire API documentation.
## Authoring your documentation
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: 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: