From cb2d111ad735265189ee71b90928d134a1980f68 Mon Sep 17 00:00:00 2001 From: EonaCat Date: Mon, 22 Jun 2026 07:28:03 +0200 Subject: [PATCH] Added more security and metrics --- DoxaApi/ConsoleProxy.cs | 34 +- DoxaApi/ConsoleRequest.cs | 10 +- DoxaApi/ConsoleResponse.cs | 12 +- DoxaApi/EonaCat.DoxaApi.csproj | 6 +- DoxaApi/Exporter/OpenApiExporter.cs | 753 +++++++++-------- DoxaApi/Exporter/SwaggerExporter.cs | 775 +++++++++--------- DoxaApi/Generation/MetricsCollector.cs | 223 +++++ DoxaApi/Importer/ApiDocsImporter.cs | 8 +- .../Middleware/DoxaApiMiddlewareExtensions.cs | 51 +- DoxaApi/Models/AnalyticsDashboard.cs | 67 ++ DoxaApi/Models/ApiDocument.cs | 14 +- DoxaApi/Models/ApiEndpoint.cs | 24 +- DoxaApi/Models/ApiFeatureFlags.cs | 6 +- DoxaApi/Models/ApiGroup.cs | 8 +- DoxaApi/Models/ApiInfo.cs | 8 +- DoxaApi/Models/ApiMetrics.cs | 46 ++ DoxaApi/Models/ApiParameter.cs | 14 +- DoxaApi/Models/RequestBodyModel.cs | 10 +- DoxaApi/Models/RequestMetadata.cs | 46 ++ DoxaApi/Models/ResponseModel.cs | 8 +- DoxaApi/Models/SchemaModel.cs | 18 +- DoxaApi/Models/SecuritySchemeModel.cs | 34 +- DoxaApi/ServiceCollectionExtensions.cs | 1 + DoxaApi/UI/Assets/app.css | 178 ++++ DoxaApi/UI/Assets/app.js | 95 +++ DoxaApi/UI/Assets/index.html | 35 +- README.md | 38 +- 27 files changed, 1638 insertions(+), 884 deletions(-) create mode 100644 DoxaApi/Generation/MetricsCollector.cs create mode 100644 DoxaApi/Models/AnalyticsDashboard.cs create mode 100644 DoxaApi/Models/ApiMetrics.cs create mode 100644 DoxaApi/Models/RequestMetadata.cs diff --git a/DoxaApi/ConsoleProxy.cs b/DoxaApi/ConsoleProxy.cs index b966226..4445c53 100644 --- a/DoxaApi/ConsoleProxy.cs +++ b/DoxaApi/ConsoleProxy.cs @@ -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(context.Request.Body, ReadOptions); + using var reader = new StreamReader(context.Request.Body); + var json = await reader.ReadToEndAsync(); + req = JsonHelper.ToObject(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 - }; } } \ No newline at end of file diff --git a/DoxaApi/ConsoleRequest.cs b/DoxaApi/ConsoleRequest.cs index c7a977f..b83c12e 100644 --- a/DoxaApi/ConsoleRequest.cs +++ b/DoxaApi/ConsoleRequest.cs @@ -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? Headers { get; set; } - [JsonPropertyName("body")] + [JsonProperty("body")] public string? Body { get; set; } } } \ No newline at end of file diff --git a/DoxaApi/ConsoleResponse.cs b/DoxaApi/ConsoleResponse.cs index 5802bb4..5d82ade 100644 --- a/DoxaApi/ConsoleResponse.cs +++ b/DoxaApi/ConsoleResponse.cs @@ -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 Headers { get; set; } = new(); - [JsonPropertyName("body")] + [JsonProperty("body")] public string Body { get; set; } = ""; - [JsonPropertyName("networkError")] + [JsonProperty("networkError")] public bool NetworkError { get; set; } } } \ No newline at end of file diff --git a/DoxaApi/EonaCat.DoxaApi.csproj b/DoxaApi/EonaCat.DoxaApi.csproj index a1261e1..9440646 100644 --- a/DoxaApi/EonaCat.DoxaApi.csproj +++ b/DoxaApi/EonaCat.DoxaApi.csproj @@ -10,7 +10,7 @@ EonaCat.DoxaApi - 0.0.5 + 0.0.6 EonaCat (Jeroen Saey) 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. openapi;swagger;documentation;api;aspnetcore;doxa;docs;mock-server;oauth2;codegen;Jeroen;Saey;EonaCat;Scalar;Redoc;Postman;EchoAPI; @@ -58,4 +58,8 @@ \ + + + + \ No newline at end of file diff --git a/DoxaApi/Exporter/OpenApiExporter.cs b/DoxaApi/Exporter/OpenApiExporter.cs index c4d1c7a..31f935d 100644 --- a/DoxaApi/Exporter/OpenApiExporter.cs +++ b/DoxaApi/Exporter/OpenApiExporter.cs @@ -1,377 +1,376 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using EonaCat.DoxaApi.Models; - -namespace EonaCat.DoxaApi.Interop -{ - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - public static class OpenApiExporter - { - public static JsonObject Export(ApiDocument doc) - { - var root = new JsonObject - { - ["openapi"] = "3.0.3", - ["info"] = BuildInfo(doc.Info), - }; - - if (doc.Servers.Count > 0) - { - var servers = new JsonArray(); - foreach (var s in doc.Servers) - { - servers.Add(new JsonObject { ["url"] = s }); - } - - root["servers"] = servers; - } - - var paths = new JsonObject(); - foreach (var group in doc.Groups) - { - foreach (var endpoint in group.Endpoints) - { - var openApiPath = ToOpenApiPath(endpoint.Path); - if (!paths.ContainsKey(openApiPath)) - { - paths[openApiPath] = new JsonObject(); - } - - var pathItem = (JsonObject)paths[openApiPath]!; - pathItem[endpoint.Method.ToLowerInvariant()] = BuildOperation(endpoint, group.Name); - } - } - root["paths"] = paths; - - if (doc.Schemas.Count > 0) - { - var schemas = new JsonObject(); - foreach (var (name, schema) in doc.Schemas) - { - schemas[name] = SchemaToOpenApi(schema); - } - - root["components"] = new JsonObject { ["schemas"] = schemas }; - } - - if (doc.SecuritySchemes.Count > 0) - { - var schemes = new JsonObject(); - foreach (var (id, scheme) in doc.SecuritySchemes) - { - schemes[id] = SecuritySchemeToOpenApi(scheme); - } - - if (root["components"] is JsonObject componentsObj) - { - componentsObj["securitySchemes"] = schemes; - } - else - { - root["components"] = new JsonObject { ["securitySchemes"] = schemes }; - } - } - - return root; - } - - private static JsonObject BuildInfo(ApiInfo info) - { - var obj = new JsonObject - { - ["title"] = info.Title, - ["version"] = info.Version - }; - if (info.Description is not null) - { - obj["description"] = info.Description; - } - - return obj; - } - - private static JsonObject BuildOperation(ApiEndpoint endpoint, string groupName) - { - var op = new JsonObject(); - - var tags = new JsonArray(); - foreach (var t in (endpoint.Tags.Count > 0 ? endpoint.Tags : new List { groupName })) - { - tags.Add(t); - } - - op["tags"] = tags; - - op["operationId"] = endpoint.OperationId; - - if (endpoint.Summary is not null) - { - op["summary"] = endpoint.Summary; - } - - if (endpoint.Description is not null) - { - op["description"] = endpoint.Description; - } - - if (endpoint.Deprecated) - { - op["deprecated"] = true; - } - - if (endpoint.Parameters.Count > 0) - { - var parameters = new JsonArray(); - foreach (var p in endpoint.Parameters) - { - var param = new JsonObject - { - ["name"] = p.Name, - ["in"] = p.In, - ["required"] = p.Required, - ["schema"] = SchemaToOpenApi(p.Schema) - }; - if (p.Description is not null) - { - param["description"] = p.Description; - } - - parameters.Add(param); - } - op["parameters"] = parameters; - } - - if (endpoint.RequestBody is not null) - { - var rb = endpoint.RequestBody; - var content = new JsonObject - { - [rb.ContentType] = new JsonObject { ["schema"] = SchemaToOpenApi(rb.Schema) } - }; - if (rb.Example is not null) - { - ((JsonObject)content[rb.ContentType]!)["example"] = - JsonNode.Parse(rb.Example) ?? JsonValue.Create(rb.Example)!; - } - op["requestBody"] = new JsonObject - { - ["required"] = rb.Required, - ["content"] = content - }; - } - - var responses = new JsonObject(); - foreach (var r in endpoint.Responses) - { - var resp = new JsonObject(); - resp["description"] = r.Description ?? HttpStatusDescription(r.StatusCode); - - if (r.Schema is not null && r.Schema.Type != "void") - { - resp["content"] = new JsonObject - { - ["application/json"] = new JsonObject - { - ["schema"] = SchemaToOpenApi(r.Schema) - } - }; - } - responses[r.StatusCode] = resp; - } - op["responses"] = responses; - - if (endpoint.Security.Count > 0) - { - var security = new JsonArray(); - foreach (var schemeId in endpoint.Security) - { - security.Add(new JsonObject { [schemeId] = new JsonArray() }); - } - op["security"] = security; - } - - return op; - } - - private static JsonObject SecuritySchemeToOpenApi(SecuritySchemeModel scheme) - { - var obj = new JsonObject { ["type"] = scheme.Type }; - - if (scheme.Description is not null) - { - obj["description"] = scheme.Description; - } - - switch (scheme.Type) - { - case "apiKey": - obj["name"] = scheme.Name ?? "X-API-Key"; - obj["in"] = scheme.In ?? "header"; - break; - - case "http": - obj["scheme"] = scheme.Scheme ?? "bearer"; - if (scheme.BearerFormat is not null) - { - obj["bearerFormat"] = scheme.BearerFormat; - } - break; - - case "oauth2": - var flows = new JsonObject(); - if (scheme.Flows?.ClientCredentials is OAuthFlowModel cc) - { - flows["clientCredentials"] = OAuthFlowToOpenApi(cc); - } - if (scheme.Flows?.AuthorizationCode is OAuthFlowModel ac) - { - flows["authorizationCode"] = OAuthFlowToOpenApi(ac); - } - if (scheme.Flows?.Password is OAuthFlowModel pw) - { - flows["password"] = OAuthFlowToOpenApi(pw); - } - if (scheme.Flows?.Implicit is OAuthFlowModel im) - { - flows["implicit"] = OAuthFlowToOpenApi(im); - } - obj["flows"] = flows; - break; - } - - return obj; - } - - private static JsonObject OAuthFlowToOpenApi(OAuthFlowModel flow) - { - var obj = new JsonObject(); - if (flow.AuthorizationUrl is not null) - { - obj["authorizationUrl"] = flow.AuthorizationUrl; - } - if (flow.TokenUrl is not null) - { - obj["tokenUrl"] = flow.TokenUrl; - } - if (flow.RefreshUrl is not null) - { - obj["refreshUrl"] = flow.RefreshUrl; - } - - var scopes = new JsonObject(); - foreach (var (k, v) in flow.Scopes) - { - scopes[k] = v; - } - obj["scopes"] = scopes; - - return obj; - } - - private static JsonObject SchemaToOpenApi(SchemaModel schema) - { - - if (schema.RefName is not null) - { - return new JsonObject { ["$ref"] = $"#/components/schemas/{schema.RefName}" }; - } - - var obj = new JsonObject(); - - switch (schema.Type) - { - case "void": - return new JsonObject { ["type"] = "object" }; - - case "enum": - obj["type"] = "string"; - if (schema.EnumValues?.Count > 0) - { - var enums = new JsonArray(); - foreach (var v in schema.EnumValues) - { - enums.Add(v); - } - - obj["enum"] = enums; - } - break; - - case "array": - obj["type"] = "array"; - if (schema.Items is not null) - { - obj["items"] = SchemaToOpenApi(schema.Items); - } - - break; - - case "object": - obj["type"] = "object"; - if (schema.Properties?.Count > 0) - { - var props = new JsonObject(); - foreach (var (name, propSchema) in schema.Properties) - { - props[name] = SchemaToOpenApi(propSchema); - } - - obj["properties"] = props; - } - if (schema.Required?.Count > 0) - { - var req = new JsonArray(); - foreach (var r in schema.Required) - { - req.Add(r); - } - - obj["required"] = req; - } - - if (schema.Items is not null && schema.Properties is null) - { - obj["additionalProperties"] = SchemaToOpenApi(schema.Items); - } - - break; - - default: - obj["type"] = schema.Type; - if (schema.Format is not null) - { - obj["format"] = schema.Format; - } - - break; - } - - if (schema.Nullable) - { - obj["nullable"] = true; - } - - return obj; - } - - private static string ToOpenApiPath(string path) - - => path.StartsWith('/') ? path : "/" + path; - - private static string HttpStatusDescription(string code) => code switch - { - "200" => "OK", - "201" => "Created", - "204" => "No Content", - "400" => "Bad Request", - "401" => "Unauthorized", - "403" => "Forbidden", - "404" => "Not Found", - "409" => "Conflict", - "422" => "Unprocessable Entity", - "500" => "Internal Server Error", - _ => "Response" - }; - } -} \ No newline at end of file +using EonaCat.Json.Linq; +using EonaCat.DoxaApi.Models; + +namespace EonaCat.DoxaApi.Interop +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + public static class OpenApiExporter + { + public static JObject Export(ApiDocument doc) + { + var root = new JObject + { + ["openapi"] = "3.0.3", + ["info"] = BuildInfo(doc.Info), + }; + + if (doc.Servers.Count > 0) + { + var servers = new JArray(); + foreach (var s in doc.Servers) + { + servers.Add(new JObject { ["url"] = s }); + } + + root["servers"] = servers; + } + + var paths = new JObject(); + foreach (var group in doc.Groups) + { + foreach (var endpoint in group.Endpoints) + { + var openApiPath = ToOpenApiPath(endpoint.Path); + if (!paths.ContainsKey(openApiPath)) + { + paths[openApiPath] = new JObject(); + } + + var pathItem = (JObject)paths[openApiPath]!; + pathItem[endpoint.Method.ToLowerInvariant()] = BuildOperation(endpoint, group.Name); + } + } + root["paths"] = paths; + + if (doc.Schemas.Count > 0) + { + var schemas = new JObject(); + foreach (var (name, schema) in doc.Schemas) + { + schemas[name] = SchemaToOpenApi(schema); + } + + root["components"] = new JObject { ["schemas"] = schemas }; + } + + if (doc.SecuritySchemes.Count > 0) + { + var schemes = new JObject(); + foreach (var (id, scheme) in doc.SecuritySchemes) + { + schemes[id] = SecuritySchemeToOpenApi(scheme); + } + + if (root["components"] is JObject componentsObj) + { + componentsObj["securitySchemes"] = schemes; + } + else + { + root["components"] = new JObject { ["securitySchemes"] = schemes }; + } + } + + return root; + } + + private static JObject BuildInfo(ApiInfo info) + { + var obj = new JObject + { + ["title"] = info.Title, + ["version"] = info.Version + }; + if (info.Description is not null) + { + obj["description"] = info.Description; + } + + return obj; + } + + private static JObject BuildOperation(ApiEndpoint endpoint, string groupName) + { + var op = new JObject(); + + var tags = new JArray(); + foreach (var t in (endpoint.Tags.Count > 0 ? endpoint.Tags : new List { groupName })) + { + tags.Add(t); + } + + op["tags"] = tags; + + op["operationId"] = endpoint.OperationId; + + if (endpoint.Summary is not null) + { + op["summary"] = endpoint.Summary; + } + + if (endpoint.Description is not null) + { + op["description"] = endpoint.Description; + } + + if (endpoint.Deprecated) + { + op["deprecated"] = true; + } + + if (endpoint.Parameters.Count > 0) + { + var parameters = new JArray(); + foreach (var p in endpoint.Parameters) + { + var param = new JObject + { + ["name"] = p.Name, + ["in"] = p.In, + ["required"] = p.Required, + ["schema"] = SchemaToOpenApi(p.Schema) + }; + if (p.Description is not null) + { + param["description"] = p.Description; + } + + parameters.Add(param); + } + op["parameters"] = parameters; + } + + if (endpoint.RequestBody is not null) + { + var rb = endpoint.RequestBody; + var content = new JObject + { + [rb.ContentType] = new JObject { ["schema"] = SchemaToOpenApi(rb.Schema) } + }; + if (rb.Example is not null) + { + ((JObject)content[rb.ContentType]!)["example"] = + JToken.Parse(rb.Example); + } + op["requestBody"] = new JObject + { + ["required"] = rb.Required, + ["content"] = content + }; + } + + var responses = new JObject(); + foreach (var r in endpoint.Responses) + { + var resp = new JObject(); + resp["description"] = r.Description ?? HttpStatusDescription(r.StatusCode); + + if (r.Schema is not null && r.Schema.Type != "void") + { + resp["content"] = new JObject + { + ["application/json"] = new JObject + { + ["schema"] = SchemaToOpenApi(r.Schema) + } + }; + } + responses[r.StatusCode] = resp; + } + op["responses"] = responses; + + if (endpoint.Security.Count > 0) + { + var security = new JArray(); + foreach (var schemeId in endpoint.Security) + { + security.Add(new JObject { [schemeId] = new JArray() }); + } + op["security"] = security; + } + + return op; + } + + private static JObject SecuritySchemeToOpenApi(SecuritySchemeModel scheme) + { + var obj = new JObject { ["type"] = scheme.Type }; + + if (scheme.Description is not null) + { + obj["description"] = scheme.Description; + } + + switch (scheme.Type) + { + case "apiKey": + obj["name"] = scheme.Name ?? "X-API-Key"; + obj["in"] = scheme.In ?? "header"; + break; + + case "http": + obj["scheme"] = scheme.Scheme ?? "bearer"; + if (scheme.BearerFormat is not null) + { + obj["bearerFormat"] = scheme.BearerFormat; + } + break; + + case "oauth2": + var flows = new JObject(); + if (scheme.Flows?.ClientCredentials is OAuthFlowModel cc) + { + flows["clientCredentials"] = OAuthFlowToOpenApi(cc); + } + if (scheme.Flows?.AuthorizationCode is OAuthFlowModel ac) + { + flows["authorizationCode"] = OAuthFlowToOpenApi(ac); + } + if (scheme.Flows?.Password is OAuthFlowModel pw) + { + flows["password"] = OAuthFlowToOpenApi(pw); + } + if (scheme.Flows?.Implicit is OAuthFlowModel im) + { + flows["implicit"] = OAuthFlowToOpenApi(im); + } + obj["flows"] = flows; + break; + } + + return obj; + } + + private static JObject OAuthFlowToOpenApi(OAuthFlowModel flow) + { + var obj = new JObject(); + if (flow.AuthorizationUrl is not null) + { + obj["authorizationUrl"] = flow.AuthorizationUrl; + } + if (flow.TokenUrl is not null) + { + obj["tokenUrl"] = flow.TokenUrl; + } + if (flow.RefreshUrl is not null) + { + obj["refreshUrl"] = flow.RefreshUrl; + } + + var scopes = new JObject(); + foreach (var (k, v) in flow.Scopes) + { + scopes[k] = v; + } + obj["scopes"] = scopes; + + return obj; + } + + private static JObject SchemaToOpenApi(SchemaModel schema) + { + + if (schema.RefName is not null) + { + return new JObject { ["$ref"] = $"#/components/schemas/{schema.RefName}" }; + } + + var obj = new JObject(); + + switch (schema.Type) + { + case "void": + return new JObject { ["type"] = "object" }; + + case "enum": + obj["type"] = "string"; + if (schema.EnumValues?.Count > 0) + { + var enums = new JArray(); + foreach (var v in schema.EnumValues) + { + enums.Add(v); + } + + obj["enum"] = enums; + } + break; + + case "array": + obj["type"] = "array"; + if (schema.Items is not null) + { + obj["items"] = SchemaToOpenApi(schema.Items); + } + + break; + + case "object": + obj["type"] = "object"; + if (schema.Properties?.Count > 0) + { + var props = new JObject(); + foreach (var (name, propSchema) in schema.Properties) + { + props[name] = SchemaToOpenApi(propSchema); + } + + obj["properties"] = props; + } + if (schema.Required?.Count > 0) + { + var req = new JArray(); + foreach (var r in schema.Required) + { + req.Add(r); + } + + obj["required"] = req; + } + + if (schema.Items is not null && schema.Properties is null) + { + obj["additionalProperties"] = SchemaToOpenApi(schema.Items); + } + + break; + + default: + obj["type"] = schema.Type; + if (schema.Format is not null) + { + obj["format"] = schema.Format; + } + + break; + } + + if (schema.Nullable) + { + obj["nullable"] = true; + } + + return obj; + } + + private static string ToOpenApiPath(string path) + + => path.StartsWith('/') ? path : "/" + path; + + private static string HttpStatusDescription(string code) => code switch + { + "200" => "OK", + "201" => "Created", + "204" => "No Content", + "400" => "Bad Request", + "401" => "Unauthorized", + "403" => "Forbidden", + "404" => "Not Found", + "409" => "Conflict", + "422" => "Unprocessable Entity", + "500" => "Internal Server Error", + _ => "Response" + }; + } +} diff --git a/DoxaApi/Exporter/SwaggerExporter.cs b/DoxaApi/Exporter/SwaggerExporter.cs index 44b0083..e2e5dcd 100644 --- a/DoxaApi/Exporter/SwaggerExporter.cs +++ b/DoxaApi/Exporter/SwaggerExporter.cs @@ -1,387 +1,388 @@ -using System.Text.Json.Nodes; -using EonaCat.DoxaApi.Models; - -namespace EonaCat.DoxaApi.Interop -{ - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - - public static class SwaggerExporter - { - public static JsonObject Export(ApiDocument doc) - { - var root = new JsonObject - { - ["swagger"] = "2.0", - ["info"] = BuildInfo(doc.Info), - }; - - if (doc.Servers.Count > 0 && Uri.TryCreate(doc.Servers[0], UriKind.Absolute, out var uri)) - { - root["host"] = uri.Host + (uri.IsDefaultPort ? "" : $":{uri.Port}"); - root["basePath"] = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath; - var schemes = new JsonArray(); - schemes.Add(uri.Scheme); - root["schemes"] = schemes; - } - else - { - root["basePath"] = "/"; - } - - root["consumes"] = new JsonArray { "application/json" }; - root["produces"] = new JsonArray { "application/json" }; - - var paths = new JsonObject(); - foreach (var group in doc.Groups) - { - foreach (var endpoint in group.Endpoints) - { - var swaggerPath = ToSwaggerPath(endpoint.Path); - if (!paths.ContainsKey(swaggerPath)) - { - paths[swaggerPath] = new JsonObject(); - } - - var pathItem = (JsonObject)paths[swaggerPath]!; - pathItem[endpoint.Method.ToLowerInvariant()] = BuildOperation(endpoint, group.Name); - } - } - root["paths"] = paths; - - if (doc.Schemas.Count > 0) - { - var definitions = new JsonObject(); - foreach (var (name, schema) in doc.Schemas) - { - definitions[name] = SchemaToSwagger(schema); - } - - root["definitions"] = definitions; - } - - if (doc.SecuritySchemes.Count > 0) - { - var defs = new JsonObject(); - foreach (var (id, scheme) in doc.SecuritySchemes) - { - defs[id] = SecuritySchemeToSwagger(scheme); - } - - root["securityDefinitions"] = defs; - } - - return root; - } - - private static JsonObject BuildInfo(ApiInfo info) - { - var obj = new JsonObject - { - ["title"] = info.Title, - ["version"] = info.Version - }; - if (info.Description is not null) - { - obj["description"] = info.Description; - } - - return obj; - } - - private static JsonObject BuildOperation(ApiEndpoint endpoint, string groupName) - { - var op = new JsonObject(); - - var tags = new JsonArray(); - foreach (var t in (endpoint.Tags.Count > 0 ? endpoint.Tags : new List { groupName })) - { - tags.Add(t); - } - - op["tags"] = tags; - - op["operationId"] = endpoint.OperationId; - - if (endpoint.Summary is not null) - { - op["summary"] = endpoint.Summary; - } - - if (endpoint.Description is not null) - { - op["description"] = endpoint.Description; - } - - if (endpoint.Deprecated) - { - op["deprecated"] = true; - } - - var parameters = new JsonArray(); - - foreach (var p in endpoint.Parameters) - { - var param = new JsonObject - { - ["name"] = p.Name, - ["in"] = p.In, - ["required"] = p.Required, - }; - if (p.Description is not null) - { - param["description"] = p.Description; - } - - InlineSchemaIntoParam(param, p.Schema); - parameters.Add(param); - } - - if (endpoint.RequestBody is not null) - { - var rb = endpoint.RequestBody; - var bodyParam = new JsonObject - { - ["name"] = "body", - ["in"] = "body", - ["required"] = rb.Required, - ["schema"] = SchemaToSwagger(rb.Schema) - }; - parameters.Add(bodyParam); - } - - if (parameters.Count > 0) - { - op["parameters"] = parameters; - } - - var responses = new JsonObject(); - foreach (var r in endpoint.Responses) - { - var resp = new JsonObject - { - ["description"] = r.Description ?? HttpStatusDescription(r.StatusCode) - }; - if (r.Schema is not null && r.Schema.Type != "void") - { - resp["schema"] = SchemaToSwagger(r.Schema); - } - - responses[r.StatusCode] = resp; - } - op["responses"] = responses; - - if (endpoint.Security.Count > 0) - { - var security = new JsonArray(); - foreach (var schemeId in endpoint.Security) - { - security.Add(new JsonObject { [schemeId] = new JsonArray() }); - } - op["security"] = security; - } - - return op; - } - - private static void InlineSchemaIntoParam(JsonObject param, SchemaModel schema) - { - if (schema.RefName is not null) - { - - param["type"] = "string"; - return; - } - - switch (schema.Type) - { - case "array": - param["type"] = "array"; - if (schema.Items is not null) - { - var items = new JsonObject(); - InlineSchemaIntoParam(items, schema.Items); - param["items"] = items; - } - break; - - case "enum": - param["type"] = "string"; - if (schema.EnumValues?.Count > 0) - { - var enums = new JsonArray(); - foreach (var v in schema.EnumValues) - { - enums.Add(v); - } - - param["enum"] = enums; - } - break; - - default: - param["type"] = schema.Type == "void" ? "string" : schema.Type; - if (schema.Format is not null) - { - param["format"] = schema.Format; - } - - break; - } - } - - private static JsonObject SchemaToSwagger(SchemaModel schema) - { - if (schema.RefName is not null) - { - return new JsonObject { ["$ref"] = $"#/definitions/{schema.RefName}" }; - } - - var obj = new JsonObject(); - - switch (schema.Type) - { - case "void": - return new JsonObject { ["type"] = "object" }; - - case "enum": - obj["type"] = "string"; - if (schema.EnumValues?.Count > 0) - { - var enums = new JsonArray(); - foreach (var v in schema.EnumValues) - { - enums.Add(v); - } - - obj["enum"] = enums; - } - break; - - case "array": - obj["type"] = "array"; - if (schema.Items is not null) - { - obj["items"] = SchemaToSwagger(schema.Items); - } - - break; - - case "object": - obj["type"] = "object"; - if (schema.Properties?.Count > 0) - { - var props = new JsonObject(); - foreach (var (name, propSchema) in schema.Properties) - { - props[name] = SchemaToSwagger(propSchema); - } - - obj["properties"] = props; - } - if (schema.Required?.Count > 0) - { - var req = new JsonArray(); - foreach (var r in schema.Required) - { - req.Add(r); - } - - obj["required"] = req; - } - if (schema.Items is not null && schema.Properties is null) - { - obj["additionalProperties"] = SchemaToSwagger(schema.Items); - } - - break; - - default: - obj["type"] = schema.Type; - if (schema.Format is not null) - { - obj["format"] = schema.Format; - } - - break; - } - - return obj; - } - - private static JsonObject SecuritySchemeToSwagger(SecuritySchemeModel scheme) - { - var obj = new JsonObject(); - - if (scheme.Type == "apiKey") - { - obj["type"] = "apiKey"; - obj["name"] = scheme.Name ?? "X-API-Key"; - obj["in"] = scheme.In ?? "header"; - } - else if (scheme.Type == "http" && scheme.Scheme == "basic") - { - obj["type"] = "basic"; - } - else if (scheme.Type == "http" && scheme.Scheme == "bearer") - { - // Swagger 2.0 has no native bearer type; represent as an apiKey-style - // Authorization header for maximum tool compatibility. - obj["type"] = "apiKey"; - obj["name"] = "Authorization"; - obj["in"] = "header"; - } - else if (scheme.Type == "oauth2") - { - obj["type"] = "oauth2"; - var flow = scheme.Flows?.ClientCredentials; - if (flow is not null) - { - obj["flow"] = "application"; - obj["tokenUrl"] = flow.TokenUrl; - var scopes = new JsonObject(); - foreach (var (k, v) in flow.Scopes) - { - scopes[k] = v; - } - obj["scopes"] = scopes; - } - else if (scheme.Flows?.AuthorizationCode is OAuthFlowModel ac) - { - obj["flow"] = "accessCode"; - obj["authorizationUrl"] = ac.AuthorizationUrl; - obj["tokenUrl"] = ac.TokenUrl; - var scopes = new JsonObject(); - foreach (var (k, v) in ac.Scopes) - { - scopes[k] = v; - } - obj["scopes"] = scopes; - } - } - - if (scheme.Description is not null) - { - obj["description"] = scheme.Description; - } - - return obj; - } - - private static string ToSwaggerPath(string path) - => path.StartsWith('/') ? path : "/" + path; - - private static string HttpStatusDescription(string code) => code switch - { - "200" => "OK", - "201" => "Created", - "204" => "No Content", - "400" => "Bad Request", - "401" => "Unauthorized", - "403" => "Forbidden", - "404" => "Not Found", - "500" => "Internal Server Error", - _ => "Response" - }; - } -} \ No newline at end of file + +using EonaCat.Json.Linq; +using EonaCat.DoxaApi.Models; + +namespace EonaCat.DoxaApi.Interop +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + public static class SwaggerExporter + { + public static JObject Export(ApiDocument doc) + { + var root = new JObject + { + ["swagger"] = "2.0", + ["info"] = BuildInfo(doc.Info), + }; + + if (doc.Servers.Count > 0 && Uri.TryCreate(doc.Servers[0], UriKind.Absolute, out var uri)) + { + root["host"] = uri.Host + (uri.IsDefaultPort ? "" : $":{uri.Port}"); + root["basePath"] = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath; + var schemes = new JArray(); + schemes.Add(uri.Scheme); + root["schemes"] = schemes; + } + else + { + root["basePath"] = "/"; + } + + root["consumes"] = new JArray { "application/json" }; + root["produces"] = new JArray { "application/json" }; + + var paths = new JObject(); + foreach (var group in doc.Groups) + { + foreach (var endpoint in group.Endpoints) + { + var swaggerPath = ToSwaggerPath(endpoint.Path); + if (!paths.ContainsKey(swaggerPath)) + { + paths[swaggerPath] = new JObject(); + } + + var pathItem = (JObject)paths[swaggerPath]!; + pathItem[endpoint.Method.ToLowerInvariant()] = BuildOperation(endpoint, group.Name); + } + } + root["paths"] = paths; + + if (doc.Schemas.Count > 0) + { + var definitions = new JObject(); + foreach (var (name, schema) in doc.Schemas) + { + definitions[name] = SchemaToSwagger(schema); + } + + root["definitions"] = definitions; + } + + if (doc.SecuritySchemes.Count > 0) + { + var defs = new JObject(); + foreach (var (id, scheme) in doc.SecuritySchemes) + { + defs[id] = SecuritySchemeToSwagger(scheme); + } + + root["securityDefinitions"] = defs; + } + + return root; + } + + private static JObject BuildInfo(ApiInfo info) + { + var obj = new JObject + { + ["title"] = info.Title, + ["version"] = info.Version + }; + if (info.Description is not null) + { + obj["description"] = info.Description; + } + + return obj; + } + + private static JObject BuildOperation(ApiEndpoint endpoint, string groupName) + { + var op = new JObject(); + + var tags = new JArray(); + foreach (var t in (endpoint.Tags.Count > 0 ? endpoint.Tags : new List { groupName })) + { + tags.Add(t); + } + + op["tags"] = tags; + + op["operationId"] = endpoint.OperationId; + + if (endpoint.Summary is not null) + { + op["summary"] = endpoint.Summary; + } + + if (endpoint.Description is not null) + { + op["description"] = endpoint.Description; + } + + if (endpoint.Deprecated) + { + op["deprecated"] = true; + } + + var parameters = new JArray(); + + foreach (var p in endpoint.Parameters) + { + var param = new JObject + { + ["name"] = p.Name, + ["in"] = p.In, + ["required"] = p.Required, + }; + if (p.Description is not null) + { + param["description"] = p.Description; + } + + InlineSchemaIntoParam(param, p.Schema); + parameters.Add(param); + } + + if (endpoint.RequestBody is not null) + { + var rb = endpoint.RequestBody; + var bodyParam = new JObject + { + ["name"] = "body", + ["in"] = "body", + ["required"] = rb.Required, + ["schema"] = SchemaToSwagger(rb.Schema) + }; + parameters.Add(bodyParam); + } + + if (parameters.Count > 0) + { + op["parameters"] = parameters; + } + + var responses = new JObject(); + foreach (var r in endpoint.Responses) + { + var resp = new JObject + { + ["description"] = r.Description ?? HttpStatusDescription(r.StatusCode) + }; + if (r.Schema is not null && r.Schema.Type != "void") + { + resp["schema"] = SchemaToSwagger(r.Schema); + } + + responses[r.StatusCode] = resp; + } + op["responses"] = responses; + + if (endpoint.Security.Count > 0) + { + var security = new JArray(); + foreach (var schemeId in endpoint.Security) + { + security.Add(new JObject { [schemeId] = new JArray() }); + } + op["security"] = security; + } + + return op; + } + + private static void InlineSchemaIntoParam(JObject param, SchemaModel schema) + { + if (schema.RefName is not null) + { + + param["type"] = "string"; + return; + } + + switch (schema.Type) + { + case "array": + param["type"] = "array"; + if (schema.Items is not null) + { + var items = new JObject(); + InlineSchemaIntoParam(items, schema.Items); + param["items"] = items; + } + break; + + case "enum": + param["type"] = "string"; + if (schema.EnumValues?.Count > 0) + { + var enums = new JArray(); + foreach (var v in schema.EnumValues) + { + enums.Add(v); + } + + param["enum"] = enums; + } + break; + + default: + param["type"] = schema.Type == "void" ? "string" : schema.Type; + if (schema.Format is not null) + { + param["format"] = schema.Format; + } + + break; + } + } + + private static JObject SchemaToSwagger(SchemaModel schema) + { + if (schema.RefName is not null) + { + return new JObject { ["$ref"] = $"#/definitions/{schema.RefName}" }; + } + + var obj = new JObject(); + + switch (schema.Type) + { + case "void": + return new JObject { ["type"] = "object" }; + + case "enum": + obj["type"] = "string"; + if (schema.EnumValues?.Count > 0) + { + var enums = new JArray(); + foreach (var v in schema.EnumValues) + { + enums.Add(v); + } + + obj["enum"] = enums; + } + break; + + case "array": + obj["type"] = "array"; + if (schema.Items is not null) + { + obj["items"] = SchemaToSwagger(schema.Items); + } + + break; + + case "object": + obj["type"] = "object"; + if (schema.Properties?.Count > 0) + { + var props = new JObject(); + foreach (var (name, propSchema) in schema.Properties) + { + props[name] = SchemaToSwagger(propSchema); + } + + obj["properties"] = props; + } + if (schema.Required?.Count > 0) + { + var req = new JArray(); + foreach (var r in schema.Required) + { + req.Add(r); + } + + obj["required"] = req; + } + if (schema.Items is not null && schema.Properties is null) + { + obj["additionalProperties"] = SchemaToSwagger(schema.Items); + } + + break; + + default: + obj["type"] = schema.Type; + if (schema.Format is not null) + { + obj["format"] = schema.Format; + } + + break; + } + + return obj; + } + + private static JObject SecuritySchemeToSwagger(SecuritySchemeModel scheme) + { + var obj = new JObject(); + + if (scheme.Type == "apiKey") + { + obj["type"] = "apiKey"; + obj["name"] = scheme.Name ?? "X-API-Key"; + obj["in"] = scheme.In ?? "header"; + } + else if (scheme.Type == "http" && scheme.Scheme == "basic") + { + obj["type"] = "basic"; + } + else if (scheme.Type == "http" && scheme.Scheme == "bearer") + { + // Swagger 2.0 has no native bearer type; represent as an apiKey-style + // Authorization header for maximum tool compatibility. + obj["type"] = "apiKey"; + obj["name"] = "Authorization"; + obj["in"] = "header"; + } + else if (scheme.Type == "oauth2") + { + obj["type"] = "oauth2"; + var flow = scheme.Flows?.ClientCredentials; + if (flow is not null) + { + obj["flow"] = "application"; + obj["tokenUrl"] = flow.TokenUrl; + var scopes = new JObject(); + foreach (var (k, v) in flow.Scopes) + { + scopes[k] = v; + } + obj["scopes"] = scopes; + } + else if (scheme.Flows?.AuthorizationCode is OAuthFlowModel ac) + { + obj["flow"] = "accessCode"; + obj["authorizationUrl"] = ac.AuthorizationUrl; + obj["tokenUrl"] = ac.TokenUrl; + var scopes = new JObject(); + foreach (var (k, v) in ac.Scopes) + { + scopes[k] = v; + } + obj["scopes"] = scopes; + } + } + + if (scheme.Description is not null) + { + obj["description"] = scheme.Description; + } + + return obj; + } + + private static string ToSwaggerPath(string path) + => path.StartsWith('/') ? path : "/" + path; + + private static string HttpStatusDescription(string code) => code switch + { + "200" => "OK", + "201" => "Created", + "204" => "No Content", + "400" => "Bad Request", + "401" => "Unauthorized", + "403" => "Forbidden", + "404" => "Not Found", + "500" => "Internal Server Error", + _ => "Response" + }; + } +} diff --git a/DoxaApi/Generation/MetricsCollector.cs b/DoxaApi/Generation/MetricsCollector.cs new file mode 100644 index 0000000..f49d8b1 --- /dev/null +++ b/DoxaApi/Generation/MetricsCollector.cs @@ -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. + + /// + /// Collects and manages API metrics and analytics. + /// + public class MetricsCollector + { + private readonly Dictionary _metrics = new(); + private readonly List _requestHistory = new(); + private readonly object _lock = new(); + private readonly DateTime _startTime = DateTime.UtcNow; + + /// + /// Records a request to an endpoint. + /// + 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); + } + } + } + + /// + /// Records an error for an endpoint. + /// + 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); + } + } + } + + /// + /// Gets metrics for a specific endpoint. + /// + public ApiMetrics? GetMetrics(string method, string path) + { + lock (_lock) + { + var key = $"{method}:{path}"; + return _metrics.ContainsKey(key) ? _metrics[key] : null; + } + } + + /// + /// Gets all collected metrics. + /// + public IEnumerable GetAllMetrics() + { + lock (_lock) + { + return _metrics.Values.ToList(); + } + } + + /// + /// Gets the analytics dashboard data. + /// + 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 + }; + } + } + + /// + /// Clears all collected metrics and history. + /// + public void Clear() + { + lock (_lock) + { + _metrics.Clear(); + _requestHistory.Clear(); + } + } + + /// + /// Gets recent requests (last N). + /// + public IEnumerable GetRecentRequests(int count = 50) + { + lock (_lock) + { + return _requestHistory + .OrderByDescending(r => r.Timestamp) + .Take(count) + .ToList(); + } + } + } +} diff --git a/DoxaApi/Importer/ApiDocsImporter.cs b/DoxaApi/Importer/ApiDocsImporter.cs index 56c4c43..7f9ee1b 100644 --- a/DoxaApi/Importer/ApiDocsImporter.cs +++ b/DoxaApi/Importer/ApiDocsImporter.cs @@ -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(json) + return JsonHelper.ToObject(json) ?? throw new InvalidOperationException("Invalid DoxaApi spec."); } public static async Task ImportAsync(Stream stream) { - var doc = await JsonSerializer.DeserializeAsync(stream); + using var reader = new StreamReader(stream); + var json = await reader.ReadToEndAsync(); + var doc = JsonHelper.ToObject(json); return doc ?? throw new InvalidOperationException("Invalid DoxaApi spec."); } } diff --git a/DoxaApi/Middleware/DoxaApiMiddlewareExtensions.cs b/DoxaApi/Middleware/DoxaApiMiddlewareExtensions.cs index 2a78a03..4a9c8b5 100644 --- a/DoxaApi/Middleware/DoxaApiMiddlewareExtensions.cs +++ b/DoxaApi/Middleware/DoxaApiMiddlewareExtensions.cs @@ -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? configure = null) { var options = app.ApplicationServices.GetService() ?? new DoxaApiOptions(); + var metricsCollector = app.ApplicationServices.GetService(); 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; } diff --git a/DoxaApi/Models/AnalyticsDashboard.cs b/DoxaApi/Models/AnalyticsDashboard.cs new file mode 100644 index 0000000..9726c32 --- /dev/null +++ b/DoxaApi/Models/AnalyticsDashboard.cs @@ -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. + + /// + /// Analytics dashboard data containing metrics and insights. + /// + 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 TopEndpoints { get; set; } = new(); + + [JsonProperty("recentRequests")] + public List RecentRequests { get; set; } = new(); + + [JsonProperty("errorRate")] + public double ErrorRate { get; set; } + + [JsonProperty("generatedAt")] + public DateTime GeneratedAt { get; set; } = DateTime.UtcNow; + } + + /// + /// Summary of endpoint usage for dashboard display. + /// + 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; } + } +} diff --git a/DoxaApi/Models/ApiDocument.cs b/DoxaApi/Models/ApiDocument.cs index 32ffc0b..7cf9783 100644 --- a/DoxaApi/Models/ApiDocument.cs +++ b/DoxaApi/Models/ApiDocument.cs @@ -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 Servers { get; set; } = new(); - [JsonPropertyName("groups")] + [JsonProperty("groups")] public List Groups { get; set; } = new(); - [JsonPropertyName("schemas")] + [JsonProperty("schemas")] public Dictionary Schemas { get; set; } = new(); /// All security schemes available for this API (keyed by scheme id). - [JsonPropertyName("securitySchemes")] + [JsonProperty("securitySchemes")] public Dictionary SecuritySchemes { get; set; } = new(); /// Which optional DoxaApi server-side features are enabled, for UI feature detection. - [JsonPropertyName("features")] + [JsonProperty("features")] public ApiFeatureFlags Features { get; set; } = new(); } } \ No newline at end of file diff --git a/DoxaApi/Models/ApiEndpoint.cs b/DoxaApi/Models/ApiEndpoint.cs index 9144fab..197a103 100644 --- a/DoxaApi/Models/ApiEndpoint.cs +++ b/DoxaApi/Models/ApiEndpoint.cs @@ -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 Tags { get; set; } = new(); - [JsonPropertyName("parameters")] + [JsonProperty("parameters")] public List Parameters { get; set; } = new(); - [JsonPropertyName("requestBody")] + [JsonProperty("requestBody")] public RequestBodyModel? RequestBody { get; set; } - [JsonPropertyName("responses")] + [JsonProperty("responses")] public List Responses { get; set; } = new(); /// /// 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). /// - [JsonPropertyName("security")] + [JsonProperty("security")] public List Security { get; set; } = new(); } } \ No newline at end of file diff --git a/DoxaApi/Models/ApiFeatureFlags.cs b/DoxaApi/Models/ApiFeatureFlags.cs index f3a65da..0e84106 100644 --- a/DoxaApi/Models/ApiFeatureFlags.cs +++ b/DoxaApi/Models/ApiFeatureFlags.cs @@ -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; } } } \ No newline at end of file diff --git a/DoxaApi/Models/ApiGroup.cs b/DoxaApi/Models/ApiGroup.cs index 9b0c423..f36fe1b 100644 --- a/DoxaApi/Models/ApiGroup.cs +++ b/DoxaApi/Models/ApiGroup.cs @@ -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 Endpoints { get; set; } = new(); } } diff --git a/DoxaApi/Models/ApiInfo.cs b/DoxaApi/Models/ApiInfo.cs index 907416f..b33d012 100644 --- a/DoxaApi/Models/ApiInfo.cs +++ b/DoxaApi/Models/ApiInfo.cs @@ -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"; } } diff --git a/DoxaApi/Models/ApiMetrics.cs b/DoxaApi/Models/ApiMetrics.cs new file mode 100644 index 0000000..e7b7dce --- /dev/null +++ b/DoxaApi/Models/ApiMetrics.cs @@ -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. + + /// + /// Tracks metrics and statistics for API endpoints. + /// + 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 StatusCodes { get; set; } = new(); + + [JsonProperty("averageResponseSize")] + public long AverageResponseSize { get; set; } + } +} diff --git a/DoxaApi/Models/ApiParameter.cs b/DoxaApi/Models/ApiParameter.cs index 57c1b0b..7a5da41 100644 --- a/DoxaApi/Models/ApiParameter.cs +++ b/DoxaApi/Models/ApiParameter.cs @@ -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; } } } diff --git a/DoxaApi/Models/RequestBodyModel.cs b/DoxaApi/Models/RequestBodyModel.cs index 0fb6708..8d19278 100644 --- a/DoxaApi/Models/RequestBodyModel.cs +++ b/DoxaApi/Models/RequestBodyModel.cs @@ -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; } } } diff --git a/DoxaApi/Models/RequestMetadata.cs b/DoxaApi/Models/RequestMetadata.cs new file mode 100644 index 0000000..e5e260c --- /dev/null +++ b/DoxaApi/Models/RequestMetadata.cs @@ -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. + + /// + /// Metadata for tracking requests and responses. + /// + 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; } + } +} diff --git a/DoxaApi/Models/ResponseModel.cs b/DoxaApi/Models/ResponseModel.cs index e930ce0..e59c376 100644 --- a/DoxaApi/Models/ResponseModel.cs +++ b/DoxaApi/Models/ResponseModel.cs @@ -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; } } } diff --git a/DoxaApi/Models/SchemaModel.cs b/DoxaApi/Models/SchemaModel.cs index c2afe05..4608678 100644 --- a/DoxaApi/Models/SchemaModel.cs +++ b/DoxaApi/Models/SchemaModel.cs @@ -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? Properties { get; set; } - [JsonPropertyName("required")] + [JsonProperty("required")] public List? Required { get; set; } - [JsonPropertyName("enumValues")] + [JsonProperty("enumValues")] public List? EnumValues { get; set; } - [JsonPropertyName("nullable")] + [JsonProperty("nullable")] public bool Nullable { get; set; } } } diff --git a/DoxaApi/Models/SecuritySchemeModel.cs b/DoxaApi/Models/SecuritySchemeModel.cs index 053ecdf..3fa9532 100644 --- a/DoxaApi/Models/SecuritySchemeModel.cs +++ b/DoxaApi/Models/SecuritySchemeModel.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using EonaCat.Json; namespace EonaCat.DoxaApi.Models { @@ -13,64 +13,64 @@ namespace EonaCat.DoxaApi.Models /// public sealed class SecuritySchemeModel { - [JsonPropertyName("id")] + [JsonProperty("id")] public string Id { get; set; } = ""; /// apiKey | http | oauth2 | openIdConnect - [JsonPropertyName("type")] + [JsonProperty("type")] public string Type { get; set; } = "apiKey"; - [JsonPropertyName("description")] + [JsonProperty("description")] public string? Description { get; set; } /// For apiKey: header | query | cookie - [JsonPropertyName("in")] + [JsonProperty("in")] public string? In { get; set; } /// For apiKey: the header/query/cookie name. For http: ignored. - [JsonPropertyName("name")] + [JsonProperty("name")] public string? Name { get; set; } /// For http: bearer | basic | digest - [JsonPropertyName("scheme")] + [JsonProperty("scheme")] public string? Scheme { get; set; } /// For http bearer: an informational hint such as "JWT". - [JsonPropertyName("bearerFormat")] + [JsonProperty("bearerFormat")] public string? BearerFormat { get; set; } /// For oauth2: supported flows (clientCredentials, authorizationCode, password, implicit). - [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 Scopes { get; set; } = new(); } } \ No newline at end of file diff --git a/DoxaApi/ServiceCollectionExtensions.cs b/DoxaApi/ServiceCollectionExtensions.cs index 81ab2d1..a8a0fa7 100644 --- a/DoxaApi/ServiceCollectionExtensions.cs +++ b/DoxaApi/ServiceCollectionExtensions.cs @@ -13,6 +13,7 @@ namespace EonaCat.DoxaApi var options = new DoxaApiOptions(); configure?.Invoke(options); services.AddSingleton(options); + services.AddSingleton(); return services; } } diff --git a/DoxaApi/UI/Assets/app.css b/DoxaApi/UI/Assets/app.css index edd6a62..0d1d51e 100644 --- a/DoxaApi/UI/Assets/app.css +++ b/DoxaApi/UI/Assets/app.css @@ -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; + } +} diff --git a/DoxaApi/UI/Assets/app.js b/DoxaApi/UI/Assets/app.js index 7a3cafa..3f249d2 100644 --- a/DoxaApi/UI/Assets/app.js +++ b/DoxaApi/UI/Assets/app.js @@ -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 => ` +
+
+ ${ep.method} + ${ep.path} +
+
${ep.requestCount} req
+
+ `).join("") || "
No data yet
"; + + document.getElementById("topEndpointsChart").innerHTML = ` +
+

Top Endpoints

+ ${topEndpointsList} +
+ `; + + // Update recent requests + const recentRequestsList = (data.recentRequests || []).map(req => { + const statusClass = req.statusCode >= 400 ? "request-status-error" : "request-status-success"; + return ` +
+
+ ${req.method} + ${req.path} +
+
+ ${req.statusCode} + ${req.responseTimeMs}ms +
+
+ `; + }).join("") || "
No requests yet
"; + + document.getElementById("recentRequestsTable").innerHTML = ` +
+

Recent Requests

+ ${recentRequestsList} +
+ `; + } + init(); })(); \ No newline at end of file diff --git a/DoxaApi/UI/Assets/index.html b/DoxaApi/UI/Assets/index.html index 1676024..c3c2a08 100644 --- a/DoxaApi/UI/Assets/index.html +++ b/DoxaApi/UI/Assets/index.html @@ -33,7 +33,10 @@
- + @@ -71,6 +74,36 @@ + + +
diff --git a/README.md b/README.md index 2dc7e3b..f26842a 100644 --- a/README.md +++ b/README.md @@ -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(); +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: