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>
+44 -45
View File
@@ -1,5 +1,4 @@
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
@@ -8,9 +7,9 @@ namespace EonaCat.DoxaApi.Interop
// 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 JsonObject Export(ApiDocument doc) public static JObject Export(ApiDocument doc)
{ {
var root = new JsonObject var root = new JObject
{ {
["openapi"] = "3.0.3", ["openapi"] = "3.0.3",
["info"] = BuildInfo(doc.Info), ["info"] = BuildInfo(doc.Info),
@@ -18,16 +17,16 @@ namespace EonaCat.DoxaApi.Interop
if (doc.Servers.Count > 0) if (doc.Servers.Count > 0)
{ {
var servers = new JsonArray(); var servers = new JArray();
foreach (var s in doc.Servers) foreach (var s in doc.Servers)
{ {
servers.Add(new JsonObject { ["url"] = s }); servers.Add(new JObject { ["url"] = s });
} }
root["servers"] = servers; root["servers"] = servers;
} }
var paths = new JsonObject(); var paths = new JObject();
foreach (var group in doc.Groups) foreach (var group in doc.Groups)
{ {
foreach (var endpoint in group.Endpoints) foreach (var endpoint in group.Endpoints)
@@ -35,10 +34,10 @@ namespace EonaCat.DoxaApi.Interop
var openApiPath = ToOpenApiPath(endpoint.Path); var openApiPath = ToOpenApiPath(endpoint.Path);
if (!paths.ContainsKey(openApiPath)) 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); pathItem[endpoint.Method.ToLowerInvariant()] = BuildOperation(endpoint, group.Name);
} }
} }
@@ -46,39 +45,39 @@ namespace EonaCat.DoxaApi.Interop
if (doc.Schemas.Count > 0) if (doc.Schemas.Count > 0)
{ {
var schemas = new JsonObject(); var schemas = new JObject();
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 JsonObject { ["schemas"] = schemas }; root["components"] = new JObject { ["schemas"] = schemas };
} }
if (doc.SecuritySchemes.Count > 0) if (doc.SecuritySchemes.Count > 0)
{ {
var schemes = new JsonObject(); var schemes = new JObject();
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 JsonObject componentsObj) if (root["components"] is JObject componentsObj)
{ {
componentsObj["securitySchemes"] = schemes; componentsObj["securitySchemes"] = schemes;
} }
else else
{ {
root["components"] = new JsonObject { ["securitySchemes"] = schemes }; root["components"] = new JObject { ["securitySchemes"] = schemes };
} }
} }
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, ["title"] = info.Title,
["version"] = info.Version ["version"] = info.Version
@@ -91,11 +90,11 @@ namespace EonaCat.DoxaApi.Interop
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(); var tags = new JArray();
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);
@@ -122,10 +121,10 @@ namespace EonaCat.DoxaApi.Interop
if (endpoint.Parameters.Count > 0) if (endpoint.Parameters.Count > 0)
{ {
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, ["name"] = p.Name,
["in"] = p.In, ["in"] = p.In,
@@ -145,33 +144,33 @@ namespace EonaCat.DoxaApi.Interop
if (endpoint.RequestBody is not null) if (endpoint.RequestBody is not null)
{ {
var rb = endpoint.RequestBody; 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) if (rb.Example is not null)
{ {
((JsonObject)content[rb.ContentType]!)["example"] = ((JObject)content[rb.ContentType]!)["example"] =
JsonNode.Parse(rb.Example) ?? JsonValue.Create(rb.Example)!; JToken.Parse(rb.Example);
} }
op["requestBody"] = new JsonObject op["requestBody"] = new JObject
{ {
["required"] = rb.Required, ["required"] = rb.Required,
["content"] = content ["content"] = content
}; };
} }
var responses = new JsonObject(); var responses = new JObject();
foreach (var r in endpoint.Responses) foreach (var r in endpoint.Responses)
{ {
var resp = new JsonObject(); var resp = new JObject();
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 JsonObject resp["content"] = new JObject
{ {
["application/json"] = new JsonObject ["application/json"] = new JObject
{ {
["schema"] = SchemaToOpenApi(r.Schema) ["schema"] = SchemaToOpenApi(r.Schema)
} }
@@ -183,10 +182,10 @@ namespace EonaCat.DoxaApi.Interop
if (endpoint.Security.Count > 0) if (endpoint.Security.Count > 0)
{ {
var security = new JsonArray(); var security = new JArray();
foreach (var schemeId in endpoint.Security) 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;
} }
@@ -194,9 +193,9 @@ namespace EonaCat.DoxaApi.Interop
return op; 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) if (scheme.Description is not null)
{ {
@@ -219,7 +218,7 @@ namespace EonaCat.DoxaApi.Interop
break; break;
case "oauth2": case "oauth2":
var flows = new JsonObject(); var flows = new JObject();
if (scheme.Flows?.ClientCredentials is OAuthFlowModel cc) if (scheme.Flows?.ClientCredentials is OAuthFlowModel cc)
{ {
flows["clientCredentials"] = OAuthFlowToOpenApi(cc); flows["clientCredentials"] = OAuthFlowToOpenApi(cc);
@@ -243,9 +242,9 @@ namespace EonaCat.DoxaApi.Interop
return obj; 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) if (flow.AuthorizationUrl is not null)
{ {
obj["authorizationUrl"] = flow.AuthorizationUrl; obj["authorizationUrl"] = flow.AuthorizationUrl;
@@ -259,7 +258,7 @@ namespace EonaCat.DoxaApi.Interop
obj["refreshUrl"] = flow.RefreshUrl; obj["refreshUrl"] = flow.RefreshUrl;
} }
var scopes = new JsonObject(); var scopes = new JObject();
foreach (var (k, v) in flow.Scopes) foreach (var (k, v) in flow.Scopes)
{ {
scopes[k] = v; scopes[k] = v;
@@ -269,26 +268,26 @@ namespace EonaCat.DoxaApi.Interop
return obj; return obj;
} }
private static JsonObject SchemaToOpenApi(SchemaModel schema) private static JObject SchemaToOpenApi(SchemaModel schema)
{ {
if (schema.RefName is not null) 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) switch (schema.Type)
{ {
case "void": case "void":
return new JsonObject { ["type"] = "object" }; return new JObject { ["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 JsonArray(); var enums = new JArray();
foreach (var v in schema.EnumValues) foreach (var v in schema.EnumValues)
{ {
enums.Add(v); enums.Add(v);
@@ -311,7 +310,7 @@ namespace EonaCat.DoxaApi.Interop
obj["type"] = "object"; obj["type"] = "object";
if (schema.Properties?.Count > 0) if (schema.Properties?.Count > 0)
{ {
var props = new JsonObject(); var props = new JObject();
foreach (var (name, propSchema) in schema.Properties) foreach (var (name, propSchema) in schema.Properties)
{ {
props[name] = SchemaToOpenApi(propSchema); props[name] = SchemaToOpenApi(propSchema);
@@ -321,7 +320,7 @@ namespace EonaCat.DoxaApi.Interop
} }
if (schema.Required?.Count > 0) if (schema.Required?.Count > 0)
{ {
var req = new JsonArray(); var req = new JArray();
foreach (var r in schema.Required) foreach (var r in schema.Required)
{ {
req.Add(r); req.Add(r);
+38 -37
View File
@@ -1,4 +1,5 @@
using System.Text.Json.Nodes;
using EonaCat.Json.Linq;
using EonaCat.DoxaApi.Models; using EonaCat.DoxaApi.Models;
namespace EonaCat.DoxaApi.Interop namespace EonaCat.DoxaApi.Interop
@@ -8,9 +9,9 @@ namespace EonaCat.DoxaApi.Interop
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", ["swagger"] = "2.0",
["info"] = BuildInfo(doc.Info), ["info"] = BuildInfo(doc.Info),
@@ -20,7 +21,7 @@ namespace EonaCat.DoxaApi.Interop
{ {
root["host"] = uri.Host + (uri.IsDefaultPort ? "" : $":{uri.Port}"); root["host"] = uri.Host + (uri.IsDefaultPort ? "" : $":{uri.Port}");
root["basePath"] = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath; root["basePath"] = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath;
var schemes = new JsonArray(); var schemes = new JArray();
schemes.Add(uri.Scheme); schemes.Add(uri.Scheme);
root["schemes"] = schemes; root["schemes"] = schemes;
} }
@@ -29,10 +30,10 @@ namespace EonaCat.DoxaApi.Interop
root["basePath"] = "/"; root["basePath"] = "/";
} }
root["consumes"] = new JsonArray { "application/json" }; root["consumes"] = new JArray { "application/json" };
root["produces"] = new JsonArray { "application/json" }; root["produces"] = new JArray { "application/json" };
var paths = new JsonObject(); var paths = new JObject();
foreach (var group in doc.Groups) foreach (var group in doc.Groups)
{ {
foreach (var endpoint in group.Endpoints) foreach (var endpoint in group.Endpoints)
@@ -40,10 +41,10 @@ namespace EonaCat.DoxaApi.Interop
var swaggerPath = ToSwaggerPath(endpoint.Path); var swaggerPath = ToSwaggerPath(endpoint.Path);
if (!paths.ContainsKey(swaggerPath)) 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); pathItem[endpoint.Method.ToLowerInvariant()] = BuildOperation(endpoint, group.Name);
} }
} }
@@ -51,7 +52,7 @@ namespace EonaCat.DoxaApi.Interop
if (doc.Schemas.Count > 0) if (doc.Schemas.Count > 0)
{ {
var definitions = new JsonObject(); var definitions = new JObject();
foreach (var (name, schema) in doc.Schemas) foreach (var (name, schema) in doc.Schemas)
{ {
definitions[name] = SchemaToSwagger(schema); definitions[name] = SchemaToSwagger(schema);
@@ -62,7 +63,7 @@ namespace EonaCat.DoxaApi.Interop
if (doc.SecuritySchemes.Count > 0) if (doc.SecuritySchemes.Count > 0)
{ {
var defs = new JsonObject(); var defs = new JObject();
foreach (var (id, scheme) in doc.SecuritySchemes) foreach (var (id, scheme) in doc.SecuritySchemes)
{ {
defs[id] = SecuritySchemeToSwagger(scheme); defs[id] = SecuritySchemeToSwagger(scheme);
@@ -74,9 +75,9 @@ namespace EonaCat.DoxaApi.Interop
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, ["title"] = info.Title,
["version"] = info.Version ["version"] = info.Version
@@ -89,11 +90,11 @@ namespace EonaCat.DoxaApi.Interop
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(); var tags = new JArray();
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);
@@ -118,11 +119,11 @@ namespace EonaCat.DoxaApi.Interop
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, ["name"] = p.Name,
["in"] = p.In, ["in"] = p.In,
@@ -140,7 +141,7 @@ namespace EonaCat.DoxaApi.Interop
if (endpoint.RequestBody is not null) if (endpoint.RequestBody is not null)
{ {
var rb = endpoint.RequestBody; var rb = endpoint.RequestBody;
var bodyParam = new JsonObject var bodyParam = new JObject
{ {
["name"] = "body", ["name"] = "body",
["in"] = "body", ["in"] = "body",
@@ -155,10 +156,10 @@ namespace EonaCat.DoxaApi.Interop
op["parameters"] = parameters; op["parameters"] = parameters;
} }
var responses = new JsonObject(); var responses = new JObject();
foreach (var r in endpoint.Responses) 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)
}; };
@@ -173,10 +174,10 @@ namespace EonaCat.DoxaApi.Interop
if (endpoint.Security.Count > 0) if (endpoint.Security.Count > 0)
{ {
var security = new JsonArray(); var security = new JArray();
foreach (var schemeId in endpoint.Security) 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;
} }
@@ -184,7 +185,7 @@ namespace EonaCat.DoxaApi.Interop
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)
{ {
@@ -199,7 +200,7 @@ namespace EonaCat.DoxaApi.Interop
param["type"] = "array"; param["type"] = "array";
if (schema.Items is not null) if (schema.Items is not null)
{ {
var items = new JsonObject(); var items = new JObject();
InlineSchemaIntoParam(items, schema.Items); InlineSchemaIntoParam(items, schema.Items);
param["items"] = items; param["items"] = items;
} }
@@ -209,7 +210,7 @@ namespace EonaCat.DoxaApi.Interop
param["type"] = "string"; param["type"] = "string";
if (schema.EnumValues?.Count > 0) if (schema.EnumValues?.Count > 0)
{ {
var enums = new JsonArray(); var enums = new JArray();
foreach (var v in schema.EnumValues) foreach (var v in schema.EnumValues)
{ {
enums.Add(v); 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) 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": case "void":
return new JsonObject { ["type"] = "object" }; return new JObject { ["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 JsonArray(); var enums = new JArray();
foreach (var v in schema.EnumValues) foreach (var v in schema.EnumValues)
{ {
enums.Add(v); enums.Add(v);
@@ -271,7 +272,7 @@ namespace EonaCat.DoxaApi.Interop
obj["type"] = "object"; obj["type"] = "object";
if (schema.Properties?.Count > 0) if (schema.Properties?.Count > 0)
{ {
var props = new JsonObject(); var props = new JObject();
foreach (var (name, propSchema) in schema.Properties) foreach (var (name, propSchema) in schema.Properties)
{ {
props[name] = SchemaToSwagger(propSchema); props[name] = SchemaToSwagger(propSchema);
@@ -281,7 +282,7 @@ namespace EonaCat.DoxaApi.Interop
} }
if (schema.Required?.Count > 0) if (schema.Required?.Count > 0)
{ {
var req = new JsonArray(); var req = new JArray();
foreach (var r in schema.Required) foreach (var r in schema.Required)
{ {
req.Add(r); req.Add(r);
@@ -309,9 +310,9 @@ namespace EonaCat.DoxaApi.Interop
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")
{ {
@@ -339,7 +340,7 @@ namespace EonaCat.DoxaApi.Interop
{ {
obj["flow"] = "application"; obj["flow"] = "application";
obj["tokenUrl"] = flow.TokenUrl; obj["tokenUrl"] = flow.TokenUrl;
var scopes = new JsonObject(); var scopes = new JObject();
foreach (var (k, v) in flow.Scopes) foreach (var (k, v) in flow.Scopes)
{ {
scopes[k] = v; scopes[k] = v;
@@ -351,7 +352,7 @@ namespace EonaCat.DoxaApi.Interop
obj["flow"] = "accessCode"; obj["flow"] = "accessCode";
obj["authorizationUrl"] = ac.AuthorizationUrl; obj["authorizationUrl"] = ac.AuthorizationUrl;
obj["tokenUrl"] = ac.TokenUrl; obj["tokenUrl"] = ac.TokenUrl;
var scopes = new JsonObject(); var scopes = new JObject();
foreach (var (k, v) in ac.Scopes) foreach (var (k, v) in ac.Scopes)
{ {
scopes[k] = v; 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 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: