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.Json;
using Microsoft.AspNetCore.Http;
using EonaCat.Json;
namespace EonaCat.DoxaApi.Middleware
{
@@ -32,12 +32,20 @@ namespace EonaCat.DoxaApi.Middleware
"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;
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)
{
@@ -116,35 +124,35 @@ namespace EonaCat.DoxaApi.Middleware
};
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)
{
context.Response.StatusCode = 504;
await JsonSerializer.SerializeAsync(context.Response.Body, new ConsoleResponse
var errorResponse = new ConsoleResponse
{
StatusCode = 0,
ElapsedMs = sw.ElapsedMilliseconds,
NetworkError = true,
Body = "Request timed out after 30 seconds."
}, writeOptions);
};
var json = JsonHelper.ToJson(errorResponse, writeOptions);
await context.Response.WriteAsync(json, Encoding.UTF8);
}
catch (Exception ex)
{
context.Response.StatusCode = 502;
await JsonSerializer.SerializeAsync(context.Response.Body, new ConsoleResponse
var errorResponse = new ConsoleResponse
{
StatusCode = 0,
ElapsedMs = sw.ElapsedMilliseconds,
NetworkError = true,
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
{
@@ -7,16 +7,16 @@ namespace EonaCat.DoxaApi.Middleware
internal sealed class ConsoleRequest
{
[JsonPropertyName("method")]
[JsonProperty("method")]
public string Method { get; set; } = "GET";
[JsonPropertyName("url")]
[JsonProperty("url")]
public string Url { get; set; } = "";
[JsonPropertyName("headers")]
[JsonProperty("headers")]
public Dictionary<string, string>? Headers { get; set; }
[JsonPropertyName("body")]
[JsonProperty("body")]
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
{
@@ -7,19 +7,19 @@ namespace EonaCat.DoxaApi.Middleware
internal sealed class ConsoleResponse
{
[JsonPropertyName("statusCode")]
[JsonProperty("statusCode")]
public int StatusCode { get; set; }
[JsonPropertyName("elapsedMs")]
[JsonProperty("elapsedMs")]
public long ElapsedMs { get; set; }
[JsonPropertyName("headers")]
[JsonProperty("headers")]
public Dictionary<string, string> Headers { get; set; } = new();
[JsonPropertyName("body")]
[JsonProperty("body")]
public string Body { get; set; } = "";
[JsonPropertyName("networkError")]
[JsonProperty("networkError")]
public bool NetworkError { get; set; }
}
}
+5 -1
View File
@@ -10,7 +10,7 @@
<!-- NuGet package metadata -->
<PackageId>EonaCat.DoxaApi</PackageId>
<Version>0.0.5</Version>
<Version>0.0.6</Version>
<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>
<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>
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="EonaCat.Json" Version="2.2.3" />
</ItemGroup>
</Project>
+44 -45
View File
@@ -1,5 +1,4 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using EonaCat.Json.Linq;
using EonaCat.DoxaApi.Models;
namespace EonaCat.DoxaApi.Interop
@@ -8,9 +7,9 @@ namespace EonaCat.DoxaApi.Interop
// 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)
public static JObject Export(ApiDocument doc)
{
var root = new JsonObject
var root = new JObject
{
["openapi"] = "3.0.3",
["info"] = BuildInfo(doc.Info),
@@ -18,16 +17,16 @@ namespace EonaCat.DoxaApi.Interop
if (doc.Servers.Count > 0)
{
var servers = new JsonArray();
var servers = new JArray();
foreach (var s in doc.Servers)
{
servers.Add(new JsonObject { ["url"] = s });
servers.Add(new JObject { ["url"] = s });
}
root["servers"] = servers;
}
var paths = new JsonObject();
var paths = new JObject();
foreach (var group in doc.Groups)
{
foreach (var endpoint in group.Endpoints)
@@ -35,10 +34,10 @@ namespace EonaCat.DoxaApi.Interop
var openApiPath = ToOpenApiPath(endpoint.Path);
if (!paths.ContainsKey(openApiPath))
{
paths[openApiPath] = new JsonObject();
paths[openApiPath] = new JObject();
}
var pathItem = (JsonObject)paths[openApiPath]!;
var pathItem = (JObject)paths[openApiPath]!;
pathItem[endpoint.Method.ToLowerInvariant()] = BuildOperation(endpoint, group.Name);
}
}
@@ -46,39 +45,39 @@ namespace EonaCat.DoxaApi.Interop
if (doc.Schemas.Count > 0)
{
var schemas = new JsonObject();
var schemas = new JObject();
foreach (var (name, schema) in doc.Schemas)
{
schemas[name] = SchemaToOpenApi(schema);
}
root["components"] = new JsonObject { ["schemas"] = schemas };
root["components"] = new JObject { ["schemas"] = schemas };
}
if (doc.SecuritySchemes.Count > 0)
{
var schemes = new JsonObject();
var schemes = new JObject();
foreach (var (id, scheme) in doc.SecuritySchemes)
{
schemes[id] = SecuritySchemeToOpenApi(scheme);
}
if (root["components"] is JsonObject componentsObj)
if (root["components"] is JObject componentsObj)
{
componentsObj["securitySchemes"] = schemes;
}
else
{
root["components"] = new JsonObject { ["securitySchemes"] = schemes };
root["components"] = new JObject { ["securitySchemes"] = schemes };
}
}
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
@@ -91,11 +90,11 @@ namespace EonaCat.DoxaApi.Interop
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();
var tags = new JArray();
foreach (var t in (endpoint.Tags.Count > 0 ? endpoint.Tags : new List<string> { groupName }))
{
tags.Add(t);
@@ -122,10 +121,10 @@ namespace EonaCat.DoxaApi.Interop
if (endpoint.Parameters.Count > 0)
{
var parameters = new JsonArray();
var parameters = new JArray();
foreach (var p in endpoint.Parameters)
{
var param = new JsonObject
var param = new JObject
{
["name"] = p.Name,
["in"] = p.In,
@@ -145,33 +144,33 @@ namespace EonaCat.DoxaApi.Interop
if (endpoint.RequestBody is not null)
{
var rb = endpoint.RequestBody;
var content = new JsonObject
var content = new JObject
{
[rb.ContentType] = new JsonObject { ["schema"] = SchemaToOpenApi(rb.Schema) }
[rb.ContentType] = new JObject { ["schema"] = SchemaToOpenApi(rb.Schema) }
};
if (rb.Example is not null)
{
((JsonObject)content[rb.ContentType]!)["example"] =
JsonNode.Parse(rb.Example) ?? JsonValue.Create(rb.Example)!;
((JObject)content[rb.ContentType]!)["example"] =
JToken.Parse(rb.Example);
}
op["requestBody"] = new JsonObject
op["requestBody"] = new JObject
{
["required"] = rb.Required,
["content"] = content
};
}
var responses = new JsonObject();
var responses = new JObject();
foreach (var r in endpoint.Responses)
{
var resp = new JsonObject();
var resp = new JObject();
resp["description"] = r.Description ?? HttpStatusDescription(r.StatusCode);
if (r.Schema is not null && r.Schema.Type != "void")
{
resp["content"] = new JsonObject
resp["content"] = new JObject
{
["application/json"] = new JsonObject
["application/json"] = new JObject
{
["schema"] = SchemaToOpenApi(r.Schema)
}
@@ -183,10 +182,10 @@ namespace EonaCat.DoxaApi.Interop
if (endpoint.Security.Count > 0)
{
var security = new JsonArray();
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;
}
@@ -194,9 +193,9 @@ namespace EonaCat.DoxaApi.Interop
return op;
}
private static JsonObject SecuritySchemeToOpenApi(SecuritySchemeModel scheme)
private static JObject SecuritySchemeToOpenApi(SecuritySchemeModel scheme)
{
var obj = new JsonObject { ["type"] = scheme.Type };
var obj = new JObject { ["type"] = scheme.Type };
if (scheme.Description is not null)
{
@@ -219,7 +218,7 @@ namespace EonaCat.DoxaApi.Interop
break;
case "oauth2":
var flows = new JsonObject();
var flows = new JObject();
if (scheme.Flows?.ClientCredentials is OAuthFlowModel cc)
{
flows["clientCredentials"] = OAuthFlowToOpenApi(cc);
@@ -243,9 +242,9 @@ namespace EonaCat.DoxaApi.Interop
return obj;
}
private static JsonObject OAuthFlowToOpenApi(OAuthFlowModel flow)
private static JObject OAuthFlowToOpenApi(OAuthFlowModel flow)
{
var obj = new JsonObject();
var obj = new JObject();
if (flow.AuthorizationUrl is not null)
{
obj["authorizationUrl"] = flow.AuthorizationUrl;
@@ -259,7 +258,7 @@ namespace EonaCat.DoxaApi.Interop
obj["refreshUrl"] = flow.RefreshUrl;
}
var scopes = new JsonObject();
var scopes = new JObject();
foreach (var (k, v) in flow.Scopes)
{
scopes[k] = v;
@@ -269,26 +268,26 @@ namespace EonaCat.DoxaApi.Interop
return obj;
}
private static JsonObject SchemaToOpenApi(SchemaModel schema)
private static JObject SchemaToOpenApi(SchemaModel schema)
{
if (schema.RefName is not null)
{
return new JsonObject { ["$ref"] = $"#/components/schemas/{schema.RefName}" };
return new JObject { ["$ref"] = $"#/components/schemas/{schema.RefName}" };
}
var obj = new JsonObject();
var obj = new JObject();
switch (schema.Type)
{
case "void":
return new JsonObject { ["type"] = "object" };
return new JObject { ["type"] = "object" };
case "enum":
obj["type"] = "string";
if (schema.EnumValues?.Count > 0)
{
var enums = new JsonArray();
var enums = new JArray();
foreach (var v in schema.EnumValues)
{
enums.Add(v);
@@ -311,7 +310,7 @@ namespace EonaCat.DoxaApi.Interop
obj["type"] = "object";
if (schema.Properties?.Count > 0)
{
var props = new JsonObject();
var props = new JObject();
foreach (var (name, propSchema) in schema.Properties)
{
props[name] = SchemaToOpenApi(propSchema);
@@ -321,7 +320,7 @@ namespace EonaCat.DoxaApi.Interop
}
if (schema.Required?.Count > 0)
{
var req = new JsonArray();
var req = new JArray();
foreach (var r in schema.Required)
{
req.Add(r);
+38 -37
View File
@@ -1,4 +1,5 @@
using System.Text.Json.Nodes;
using EonaCat.Json.Linq;
using EonaCat.DoxaApi.Models;
namespace EonaCat.DoxaApi.Interop
@@ -8,9 +9,9 @@ namespace EonaCat.DoxaApi.Interop
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),
@@ -20,7 +21,7 @@ namespace EonaCat.DoxaApi.Interop
{
root["host"] = uri.Host + (uri.IsDefaultPort ? "" : $":{uri.Port}");
root["basePath"] = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath;
var schemes = new JsonArray();
var schemes = new JArray();
schemes.Add(uri.Scheme);
root["schemes"] = schemes;
}
@@ -29,10 +30,10 @@ namespace EonaCat.DoxaApi.Interop
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();
var paths = new JObject();
foreach (var group in doc.Groups)
{
foreach (var endpoint in group.Endpoints)
@@ -40,10 +41,10 @@ namespace EonaCat.DoxaApi.Interop
var swaggerPath = ToSwaggerPath(endpoint.Path);
if (!paths.ContainsKey(swaggerPath))
{
paths[swaggerPath] = new JsonObject();
paths[swaggerPath] = new JObject();
}
var pathItem = (JsonObject)paths[swaggerPath]!;
var pathItem = (JObject)paths[swaggerPath]!;
pathItem[endpoint.Method.ToLowerInvariant()] = BuildOperation(endpoint, group.Name);
}
}
@@ -51,7 +52,7 @@ namespace EonaCat.DoxaApi.Interop
if (doc.Schemas.Count > 0)
{
var definitions = new JsonObject();
var definitions = new JObject();
foreach (var (name, schema) in doc.Schemas)
{
definitions[name] = SchemaToSwagger(schema);
@@ -62,7 +63,7 @@ namespace EonaCat.DoxaApi.Interop
if (doc.SecuritySchemes.Count > 0)
{
var defs = new JsonObject();
var defs = new JObject();
foreach (var (id, scheme) in doc.SecuritySchemes)
{
defs[id] = SecuritySchemeToSwagger(scheme);
@@ -74,9 +75,9 @@ namespace EonaCat.DoxaApi.Interop
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
@@ -89,11 +90,11 @@ namespace EonaCat.DoxaApi.Interop
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();
var tags = new JArray();
foreach (var t in (endpoint.Tags.Count > 0 ? endpoint.Tags : new List<string> { groupName }))
{
tags.Add(t);
@@ -118,11 +119,11 @@ namespace EonaCat.DoxaApi.Interop
op["deprecated"] = true;
}
var parameters = new JsonArray();
var parameters = new JArray();
foreach (var p in endpoint.Parameters)
{
var param = new JsonObject
var param = new JObject
{
["name"] = p.Name,
["in"] = p.In,
@@ -140,7 +141,7 @@ namespace EonaCat.DoxaApi.Interop
if (endpoint.RequestBody is not null)
{
var rb = endpoint.RequestBody;
var bodyParam = new JsonObject
var bodyParam = new JObject
{
["name"] = "body",
["in"] = "body",
@@ -155,10 +156,10 @@ namespace EonaCat.DoxaApi.Interop
op["parameters"] = parameters;
}
var responses = new JsonObject();
var responses = new JObject();
foreach (var r in endpoint.Responses)
{
var resp = new JsonObject
var resp = new JObject
{
["description"] = r.Description ?? HttpStatusDescription(r.StatusCode)
};
@@ -173,10 +174,10 @@ namespace EonaCat.DoxaApi.Interop
if (endpoint.Security.Count > 0)
{
var security = new JsonArray();
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;
}
@@ -184,7 +185,7 @@ namespace EonaCat.DoxaApi.Interop
return op;
}
private static void InlineSchemaIntoParam(JsonObject param, SchemaModel schema)
private static void InlineSchemaIntoParam(JObject param, SchemaModel schema)
{
if (schema.RefName is not null)
{
@@ -199,7 +200,7 @@ namespace EonaCat.DoxaApi.Interop
param["type"] = "array";
if (schema.Items is not null)
{
var items = new JsonObject();
var items = new JObject();
InlineSchemaIntoParam(items, schema.Items);
param["items"] = items;
}
@@ -209,7 +210,7 @@ namespace EonaCat.DoxaApi.Interop
param["type"] = "string";
if (schema.EnumValues?.Count > 0)
{
var enums = new JsonArray();
var enums = new JArray();
foreach (var v in schema.EnumValues)
{
enums.Add(v);
@@ -230,25 +231,25 @@ namespace EonaCat.DoxaApi.Interop
}
}
private static JsonObject SchemaToSwagger(SchemaModel schema)
private static JObject SchemaToSwagger(SchemaModel schema)
{
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)
{
case "void":
return new JsonObject { ["type"] = "object" };
return new JObject { ["type"] = "object" };
case "enum":
obj["type"] = "string";
if (schema.EnumValues?.Count > 0)
{
var enums = new JsonArray();
var enums = new JArray();
foreach (var v in schema.EnumValues)
{
enums.Add(v);
@@ -271,7 +272,7 @@ namespace EonaCat.DoxaApi.Interop
obj["type"] = "object";
if (schema.Properties?.Count > 0)
{
var props = new JsonObject();
var props = new JObject();
foreach (var (name, propSchema) in schema.Properties)
{
props[name] = SchemaToSwagger(propSchema);
@@ -281,7 +282,7 @@ namespace EonaCat.DoxaApi.Interop
}
if (schema.Required?.Count > 0)
{
var req = new JsonArray();
var req = new JArray();
foreach (var r in schema.Required)
{
req.Add(r);
@@ -309,9 +310,9 @@ namespace EonaCat.DoxaApi.Interop
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")
{
@@ -339,7 +340,7 @@ namespace EonaCat.DoxaApi.Interop
{
obj["flow"] = "application";
obj["tokenUrl"] = flow.TokenUrl;
var scopes = new JsonObject();
var scopes = new JObject();
foreach (var (k, v) in flow.Scopes)
{
scopes[k] = v;
@@ -351,7 +352,7 @@ namespace EonaCat.DoxaApi.Interop
obj["flow"] = "accessCode";
obj["authorizationUrl"] = ac.AuthorizationUrl;
obj["tokenUrl"] = ac.TokenUrl;
var scopes = new JsonObject();
var scopes = new JObject();
foreach (var (k, v) in ac.Scopes)
{
scopes[k] = v;
+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 System.Text.Json;
using EonaCat.Json;
namespace EonaCat.DoxaApi.Interop
{
@@ -10,13 +10,15 @@ namespace EonaCat.DoxaApi.Interop
{
public static ApiDocument Import(string json)
{
return JsonSerializer.Deserialize<ApiDocument>(json)
return JsonHelper.ToObject<ApiDocument>(json)
?? throw new InvalidOperationException("Invalid DoxaApi spec.");
}
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.");
}
}
@@ -1,7 +1,5 @@
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using EonaCat.DoxaApi.Generation;
using EonaCat.DoxaApi.Interop;
using EonaCat.DoxaApi.Models;
@@ -10,6 +8,8 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using EonaCat.Json;
using EonaCat.Json.Linq;
namespace EonaCat.DoxaApi.Middleware
{
@@ -18,16 +18,17 @@ namespace EonaCat.DoxaApi.Middleware
public static class DoxaApiMiddlewareExtensions
{
private static readonly JsonSerializerOptions _writeOptions = new()
private static readonly JsonSerializerSettings _writeOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
Formatting = Formatting.Indented,
NullValueHandling = NullValueHandling.Ignore
};
public static IApplicationBuilder UseDoxaApi(this IApplicationBuilder app, Action<DoxaApiOptions>? configure = null)
{
var options = app.ApplicationServices.GetService<DoxaApiOptions>() ?? new DoxaApiOptions();
var metricsCollector = app.ApplicationServices.GetService<MetricsCollector>();
configure?.Invoke(options);
var prefix = "/" + options.RoutePrefix.Trim('/');
@@ -38,7 +39,8 @@ namespace EonaCat.DoxaApi.Middleware
{
var document = GenerateDocument(context, options);
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 openApi = OpenApiExporter.Export(document);
context.Response.ContentType = "application/json; charset=utf-8";
await context.Response.WriteAsync(
openApi.ToJsonString(_writeOptions), Encoding.UTF8);
var json = JsonHelper.ToJson(openApi, _writeOptions);
await context.Response.WriteAsync(json, Encoding.UTF8);
});
});
@@ -61,8 +63,8 @@ namespace EonaCat.DoxaApi.Middleware
var document = GenerateDocument(context, options);
var swagger = SwaggerExporter.Export(document);
context.Response.ContentType = "application/json; charset=utf-8";
await context.Response.WriteAsync(
swagger.ToJsonString(_writeOptions), Encoding.UTF8);
var json = JsonHelper.ToJson(swagger, _writeOptions);
await context.Response.WriteAsync(json, Encoding.UTF8);
});
});
@@ -89,7 +91,8 @@ namespace EonaCat.DoxaApi.Middleware
{
var imported = await OpenApiImporter.ImportAsync(context.Request.Body);
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)
{
@@ -138,7 +141,8 @@ namespace EonaCat.DoxaApi.Middleware
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);
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;
}
+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
{
@@ -7,24 +7,24 @@ namespace EonaCat.DoxaApi.Models
public sealed class ApiDocument
{
[JsonPropertyName("info")]
[JsonProperty("info")]
public ApiInfo Info { get; set; } = new();
[JsonPropertyName("servers")]
[JsonProperty("servers")]
public List<string> Servers { get; set; } = new();
[JsonPropertyName("groups")]
[JsonProperty("groups")]
public List<ApiGroup> Groups { get; set; } = new();
[JsonPropertyName("schemas")]
[JsonProperty("schemas")]
public Dictionary<string, SchemaModel> Schemas { get; set; } = new();
/// <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();
/// <summary>Which optional DoxaApi server-side features are enabled, for UI feature detection.</summary>
[JsonPropertyName("features")]
[JsonProperty("features")]
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
{
@@ -7,41 +7,41 @@ namespace EonaCat.DoxaApi.Models
public sealed class ApiEndpoint
{
[JsonPropertyName("operationId")]
[JsonProperty("operationId")]
public string OperationId { get; set; } = "";
[JsonPropertyName("summary")]
[JsonProperty("summary")]
public string? Summary { get; set; }
[JsonPropertyName("description")]
[JsonProperty("description")]
public string? Description { get; set; }
[JsonPropertyName("method")]
[JsonProperty("method")]
public string Method { get; set; } = "GET";
[JsonPropertyName("path")]
[JsonProperty("path")]
public string Path { get; set; } = "/";
[JsonPropertyName("deprecated")]
[JsonProperty("deprecated")]
public bool Deprecated { get; set; }
[JsonPropertyName("tags")]
[JsonProperty("tags")]
public List<string> Tags { get; set; } = new();
[JsonPropertyName("parameters")]
[JsonProperty("parameters")]
public List<ApiParameter> Parameters { get; set; } = new();
[JsonPropertyName("requestBody")]
[JsonProperty("requestBody")]
public RequestBodyModel? RequestBody { get; set; }
[JsonPropertyName("responses")]
[JsonProperty("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")]
[JsonProperty("security")]
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
{
@@ -7,10 +7,10 @@ namespace EonaCat.DoxaApi.Models
public sealed class ApiFeatureFlags
{
[JsonPropertyName("mockServer")]
[JsonProperty("mockServer")]
public bool MockServer { get; set; }
[JsonPropertyName("console")]
[JsonProperty("console")]
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
{
@@ -7,13 +7,13 @@ namespace EonaCat.DoxaApi.Models
public sealed class ApiGroup
{
[JsonPropertyName("name")]
[JsonProperty("name")]
public string Name { get; set; } = "";
[JsonPropertyName("description")]
[JsonProperty("description")]
public string? Description { get; set; }
[JsonPropertyName("endpoints")]
[JsonProperty("endpoints")]
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
{
@@ -7,13 +7,13 @@ namespace EonaCat.DoxaApi.Models
public sealed class ApiInfo
{
[JsonPropertyName("title")]
[JsonProperty("title")]
public string Title { get; set; } = "API Documentation";
[JsonPropertyName("description")]
[JsonProperty("description")]
public string? Description { get; set; }
[JsonPropertyName("version")]
[JsonProperty("version")]
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
{
@@ -7,22 +7,22 @@ namespace EonaCat.DoxaApi.Models
public sealed class ApiParameter
{
[JsonPropertyName("name")]
[JsonProperty("name")]
public string Name { get; set; } = "";
[JsonPropertyName("in")]
[JsonProperty("in")]
public string In { get; set; } = "query";
[JsonPropertyName("required")]
[JsonProperty("required")]
public bool Required { get; set; }
[JsonPropertyName("description")]
[JsonProperty("description")]
public string? Description { get; set; }
[JsonPropertyName("schema")]
[JsonProperty("schema")]
public SchemaModel Schema { get; set; } = new();
[JsonPropertyName("default")]
[JsonProperty("default")]
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
{
@@ -7,16 +7,16 @@ namespace EonaCat.DoxaApi.Models
public sealed class RequestBodyModel
{
[JsonPropertyName("required")]
[JsonProperty("required")]
public bool Required { get; set; }
[JsonPropertyName("contentType")]
[JsonProperty("contentType")]
public string ContentType { get; set; } = "application/json";
[JsonPropertyName("schema")]
[JsonProperty("schema")]
public SchemaModel Schema { get; set; } = new();
[JsonPropertyName("example")]
[JsonProperty("example")]
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
{
@@ -7,13 +7,13 @@ namespace EonaCat.DoxaApi.Models
public sealed class ResponseModel
{
[JsonPropertyName("statusCode")]
[JsonProperty("statusCode")]
public string StatusCode { get; set; } = "200";
[JsonPropertyName("description")]
[JsonProperty("description")]
public string? Description { get; set; }
[JsonPropertyName("schema")]
[JsonProperty("schema")]
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
{
@@ -7,28 +7,28 @@ namespace EonaCat.DoxaApi.Models
public sealed class SchemaModel
{
[JsonPropertyName("type")]
[JsonProperty("type")]
public string Type { get; set; } = "object";
[JsonPropertyName("format")]
[JsonProperty("format")]
public string? Format { get; set; }
[JsonPropertyName("refName")]
[JsonProperty("refName")]
public string? RefName { get; set; }
[JsonPropertyName("items")]
[JsonProperty("items")]
public SchemaModel? Items { get; set; }
[JsonPropertyName("properties")]
[JsonProperty("properties")]
public Dictionary<string, SchemaModel>? Properties { get; set; }
[JsonPropertyName("required")]
[JsonProperty("required")]
public List<string>? Required { get; set; }
[JsonPropertyName("enumValues")]
[JsonProperty("enumValues")]
public List<string>? EnumValues { get; set; }
[JsonPropertyName("nullable")]
[JsonProperty("nullable")]
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
{
@@ -13,64 +13,64 @@ namespace EonaCat.DoxaApi.Models
/// </summary>
public sealed class SecuritySchemeModel
{
[JsonPropertyName("id")]
[JsonProperty("id")]
public string Id { get; set; } = "";
/// <summary>apiKey | http | oauth2 | openIdConnect</summary>
[JsonPropertyName("type")]
[JsonProperty("type")]
public string Type { get; set; } = "apiKey";
[JsonPropertyName("description")]
[JsonProperty("description")]
public string? Description { get; set; }
/// <summary>For apiKey: header | query | cookie</summary>
[JsonPropertyName("in")]
[JsonProperty("in")]
public string? In { get; set; }
/// <summary>For apiKey: the header/query/cookie name. For http: ignored.</summary>
[JsonPropertyName("name")]
[JsonProperty("name")]
public string? Name { get; set; }
/// <summary>For http: bearer | basic | digest</summary>
[JsonPropertyName("scheme")]
[JsonProperty("scheme")]
public string? Scheme { get; set; }
/// <summary>For http bearer: an informational hint such as "JWT".</summary>
[JsonPropertyName("bearerFormat")]
[JsonProperty("bearerFormat")]
public string? BearerFormat { get; set; }
/// <summary>For oauth2: supported flows (clientCredentials, authorizationCode, password, implicit).</summary>
[JsonPropertyName("flows")]
[JsonProperty("flows")]
public OAuthFlowsModel? Flows { get; set; }
}
public sealed class OAuthFlowsModel
{
[JsonPropertyName("clientCredentials")]
[JsonProperty("clientCredentials")]
public OAuthFlowModel? ClientCredentials { get; set; }
[JsonPropertyName("authorizationCode")]
[JsonProperty("authorizationCode")]
public OAuthFlowModel? AuthorizationCode { get; set; }
[JsonPropertyName("password")]
[JsonProperty("password")]
public OAuthFlowModel? Password { get; set; }
[JsonPropertyName("implicit")]
[JsonProperty("implicit")]
public OAuthFlowModel? Implicit { get; set; }
}
public sealed class OAuthFlowModel
{
[JsonPropertyName("authorizationUrl")]
[JsonProperty("authorizationUrl")]
public string? AuthorizationUrl { get; set; }
[JsonPropertyName("tokenUrl")]
[JsonProperty("tokenUrl")]
public string? TokenUrl { get; set; }
[JsonPropertyName("refreshUrl")]
[JsonProperty("refreshUrl")]
public string? RefreshUrl { get; set; }
[JsonPropertyName("scopes")]
[JsonProperty("scopes")]
public Dictionary<string, string> Scopes { get; set; } = new();
}
}
+1
View File
@@ -13,6 +13,7 @@ namespace EonaCat.DoxaApi
var options = new DoxaApiOptions();
configure?.Invoke(options);
services.AddSingleton(options);
services.AddSingleton<MetricsCollector>();
return services;
}
}
+178
View File
@@ -1896,3 +1896,181 @@ textarea.field-input {
width: 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 exportOpenApiBtn = document.getElementById("exportOpenApiBtn");
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());
@@ -1443,6 +1446,10 @@
exportOpenApiBtn?.addEventListener("click", () => download("openapi.json", "openapi.json"));
exportSwaggerBtn?.addEventListener("click", () => download("swagger.json", "swagger.json"));
// Dashboard functionality
dashboardBtn?.addEventListener("click", () => openDashboard());
closeDashboard?.addEventListener("click", () => closeDashboardPanel());
const authBtn = document.getElementById("authBtn");
authBtn?.addEventListener("click", () => openAuthModal());
@@ -1578,5 +1585,93 @@
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();
})();
+34 -1
View File
@@ -33,7 +33,10 @@
</div>
<div class="topbar-actions">
<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
<span class="badge-dot" id="authBadgeDot" style="display:none;"></span>
</button>
@@ -71,6 +74,36 @@
<aside class="try-pane" id="tryPane">
<div id="tryContent" class="try-content"></div>
</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>
+31 -7
View File
@@ -1,8 +1,8 @@
# 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)
@@ -41,10 +41,6 @@ app.Run();
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:
```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: