Added more security and metrics
This commit is contained in:
+21
-13
@@ -1,6 +1,6 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using EonaCat.Json;
|
||||
|
||||
namespace EonaCat.DoxaApi.Middleware
|
||||
{
|
||||
@@ -32,12 +32,20 @@ namespace EonaCat.DoxaApi.Middleware
|
||||
"host", "content-length", "connection", "transfer-encoding"
|
||||
};
|
||||
|
||||
public static async Task HandleAsync(HttpContext context, JsonSerializerOptions writeOptions)
|
||||
private static readonly JsonSerializerSettings _settings = new()
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
Formatting = Formatting.Indented
|
||||
};
|
||||
|
||||
public static async Task HandleAsync(HttpContext context, JsonSerializerSettings writeOptions)
|
||||
{
|
||||
ConsoleRequest? req;
|
||||
try
|
||||
{
|
||||
req = await JsonSerializer.DeserializeAsync<ConsoleRequest>(context.Request.Body, ReadOptions);
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var json = await reader.ReadToEndAsync();
|
||||
req = JsonHelper.ToObject<ConsoleRequest>(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -116,35 +124,35 @@ namespace EonaCat.DoxaApi.Middleware
|
||||
};
|
||||
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, result, writeOptions);
|
||||
var json = JsonHelper.ToJson(result, writeOptions);
|
||||
await context.Response.WriteAsync(json, Encoding.UTF8);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
context.Response.StatusCode = 504;
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, new ConsoleResponse
|
||||
var errorResponse = new ConsoleResponse
|
||||
{
|
||||
StatusCode = 0,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
NetworkError = true,
|
||||
Body = "Request timed out after 30 seconds."
|
||||
}, writeOptions);
|
||||
};
|
||||
var json = JsonHelper.ToJson(errorResponse, writeOptions);
|
||||
await context.Response.WriteAsync(json, Encoding.UTF8);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Response.StatusCode = 502;
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, new ConsoleResponse
|
||||
var errorResponse = new ConsoleResponse
|
||||
{
|
||||
StatusCode = 0,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
NetworkError = true,
|
||||
Body = ex.Message
|
||||
}, writeOptions);
|
||||
};
|
||||
var json = JsonHelper.ToJson(errorResponse, writeOptions);
|
||||
await context.Response.WriteAsync(json, Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions ReadOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using EonaCat.Json;
|
||||
|
||||
namespace EonaCat.DoxaApi.Middleware
|
||||
{
|
||||
@@ -7,16 +7,16 @@ namespace EonaCat.DoxaApi.Middleware
|
||||
|
||||
internal sealed class ConsoleRequest
|
||||
{
|
||||
[JsonPropertyName("method")]
|
||||
[JsonProperty("method")]
|
||||
public string Method { get; set; } = "GET";
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
[JsonProperty("url")]
|
||||
public string Url { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("headers")]
|
||||
[JsonProperty("headers")]
|
||||
public Dictionary<string, string>? Headers { get; set; }
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
[JsonProperty("body")]
|
||||
public string? Body { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using EonaCat.Json;
|
||||
|
||||
namespace EonaCat.DoxaApi.Middleware
|
||||
{
|
||||
@@ -7,19 +7,19 @@ namespace EonaCat.DoxaApi.Middleware
|
||||
|
||||
internal sealed class ConsoleResponse
|
||||
{
|
||||
[JsonPropertyName("statusCode")]
|
||||
[JsonProperty("statusCode")]
|
||||
public int StatusCode { get; set; }
|
||||
|
||||
[JsonPropertyName("elapsedMs")]
|
||||
[JsonProperty("elapsedMs")]
|
||||
public long ElapsedMs { get; set; }
|
||||
|
||||
[JsonPropertyName("headers")]
|
||||
[JsonProperty("headers")]
|
||||
public Dictionary<string, string> Headers { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
[JsonProperty("body")]
|
||||
public string Body { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("networkError")]
|
||||
[JsonProperty("networkError")]
|
||||
public bool NetworkError { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<!-- NuGet package metadata -->
|
||||
<PackageId>EonaCat.DoxaApi</PackageId>
|
||||
<Version>0.0.5</Version>
|
||||
<Version>0.0.6</Version>
|
||||
<Authors>EonaCat (Jeroen Saey)</Authors>
|
||||
<Description>A modern, self-contained, dependency-free API documentation UI for ASP.NET Core with a built-in auth manager, mock server, server-side request console, multi-language code generation, and spec diffing.</Description>
|
||||
<PackageTags>openapi;swagger;documentation;api;aspnetcore;doxa;docs;mock-server;oauth2;codegen;Jeroen;Saey;EonaCat;Scalar;Redoc;Postman;EchoAPI;</PackageTags>
|
||||
@@ -58,4 +58,8 @@
|
||||
<PackagePath>\</PackagePath>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EonaCat.Json" Version="2.2.3" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+376
-377
@@ -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<string> { 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
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<string> { 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+388
-387
@@ -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<string> { 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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<string> { 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using EonaCat.DoxaApi.Models;
|
||||
using System.Text.Json;
|
||||
using EonaCat.Json;
|
||||
|
||||
namespace EonaCat.DoxaApi.Interop
|
||||
{
|
||||
@@ -10,13 +10,15 @@ namespace EonaCat.DoxaApi.Interop
|
||||
{
|
||||
public static ApiDocument Import(string json)
|
||||
{
|
||||
return JsonSerializer.Deserialize<ApiDocument>(json)
|
||||
return JsonHelper.ToObject<ApiDocument>(json)
|
||||
?? throw new InvalidOperationException("Invalid DoxaApi spec.");
|
||||
}
|
||||
|
||||
public static async Task<ApiDocument> ImportAsync(Stream stream)
|
||||
{
|
||||
var doc = await JsonSerializer.DeserializeAsync<ApiDocument>(stream);
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = await reader.ReadToEndAsync();
|
||||
var doc = JsonHelper.ToObject<ApiDocument>(json);
|
||||
return doc ?? throw new InvalidOperationException("Invalid DoxaApi spec.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using EonaCat.DoxaApi.Generation;
|
||||
using EonaCat.DoxaApi.Interop;
|
||||
using EonaCat.DoxaApi.Models;
|
||||
@@ -10,6 +8,8 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using EonaCat.Json;
|
||||
using EonaCat.Json.Linq;
|
||||
|
||||
namespace EonaCat.DoxaApi.Middleware
|
||||
{
|
||||
@@ -18,16 +18,17 @@ namespace EonaCat.DoxaApi.Middleware
|
||||
|
||||
public static class DoxaApiMiddlewareExtensions
|
||||
{
|
||||
private static readonly JsonSerializerOptions _writeOptions = new()
|
||||
private static readonly JsonSerializerSettings _writeOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
Formatting = Formatting.Indented,
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
};
|
||||
|
||||
public static IApplicationBuilder UseDoxaApi(this IApplicationBuilder app, Action<DoxaApiOptions>? configure = null)
|
||||
{
|
||||
|
||||
var options = app.ApplicationServices.GetService<DoxaApiOptions>() ?? new DoxaApiOptions();
|
||||
var metricsCollector = app.ApplicationServices.GetService<MetricsCollector>();
|
||||
configure?.Invoke(options);
|
||||
|
||||
var prefix = "/" + options.RoutePrefix.Trim('/');
|
||||
@@ -38,7 +39,8 @@ namespace EonaCat.DoxaApi.Middleware
|
||||
{
|
||||
var document = GenerateDocument(context, options);
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, document, _writeOptions);
|
||||
var json = JsonHelper.ToJson(document, _writeOptions);
|
||||
await context.Response.WriteAsync(json, Encoding.UTF8);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,8 +51,8 @@ namespace EonaCat.DoxaApi.Middleware
|
||||
var document = GenerateDocument(context, options);
|
||||
var openApi = OpenApiExporter.Export(document);
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
await context.Response.WriteAsync(
|
||||
openApi.ToJsonString(_writeOptions), Encoding.UTF8);
|
||||
var json = JsonHelper.ToJson(openApi, _writeOptions);
|
||||
await context.Response.WriteAsync(json, Encoding.UTF8);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,8 +63,8 @@ namespace EonaCat.DoxaApi.Middleware
|
||||
var document = GenerateDocument(context, options);
|
||||
var swagger = SwaggerExporter.Export(document);
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
await context.Response.WriteAsync(
|
||||
swagger.ToJsonString(_writeOptions), Encoding.UTF8);
|
||||
var json = JsonHelper.ToJson(swagger, _writeOptions);
|
||||
await context.Response.WriteAsync(json, Encoding.UTF8);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +91,8 @@ namespace EonaCat.DoxaApi.Middleware
|
||||
{
|
||||
var imported = await OpenApiImporter.ImportAsync(context.Request.Body);
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, imported, _writeOptions);
|
||||
var json = JsonHelper.ToJson(imported, _writeOptions);
|
||||
await context.Response.WriteAsync(json, Encoding.UTF8);
|
||||
}
|
||||
catch (NotSupportedException ex)
|
||||
{
|
||||
@@ -138,7 +141,8 @@ namespace EonaCat.DoxaApi.Middleware
|
||||
context.Response.StatusCode = int.TryParse(successResponse?.StatusCode, out var code) ? code : 200;
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
context.Response.Headers["X-DoxaApi-Mock"] = "true";
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, mockValue, _writeOptions);
|
||||
var json = JsonHelper.ToJson(mockValue, _writeOptions);
|
||||
await context.Response.WriteAsync(json, Encoding.UTF8);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -199,6 +203,29 @@ namespace EonaCat.DoxaApi.Middleware
|
||||
});
|
||||
});
|
||||
|
||||
// Add analytics endpoint if metrics collector is registered
|
||||
if (metricsCollector != null)
|
||||
{
|
||||
app.Map(prefix + "/analytics", analyticsApp =>
|
||||
{
|
||||
analyticsApp.Run(async context =>
|
||||
{
|
||||
if (!HttpMethods.IsGet(context.Request.Method))
|
||||
{
|
||||
context.Response.StatusCode = 405;
|
||||
context.Response.Headers["Allow"] = "GET";
|
||||
await context.Response.WriteAsync("Method Not Allowed - use GET");
|
||||
return;
|
||||
}
|
||||
|
||||
var dashboard = metricsCollector.GetDashboard();
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
var json = JsonHelper.ToJson(dashboard, _writeOptions);
|
||||
await context.Response.WriteAsync(json, Encoding.UTF8);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using EonaCat.Json;
|
||||
|
||||
namespace EonaCat.DoxaApi.Models
|
||||
{
|
||||
@@ -7,24 +7,24 @@ namespace EonaCat.DoxaApi.Models
|
||||
|
||||
public sealed class ApiDocument
|
||||
{
|
||||
[JsonPropertyName("info")]
|
||||
[JsonProperty("info")]
|
||||
public ApiInfo Info { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("servers")]
|
||||
[JsonProperty("servers")]
|
||||
public List<string> Servers { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("groups")]
|
||||
[JsonProperty("groups")]
|
||||
public List<ApiGroup> Groups { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("schemas")]
|
||||
[JsonProperty("schemas")]
|
||||
public Dictionary<string, SchemaModel> Schemas { get; set; } = new();
|
||||
|
||||
/// <summary>All security schemes available for this API (keyed by scheme id).</summary>
|
||||
[JsonPropertyName("securitySchemes")]
|
||||
[JsonProperty("securitySchemes")]
|
||||
public Dictionary<string, SecuritySchemeModel> SecuritySchemes { get; set; } = new();
|
||||
|
||||
/// <summary>Which optional DoxaApi server-side features are enabled, for UI feature detection.</summary>
|
||||
[JsonPropertyName("features")]
|
||||
[JsonProperty("features")]
|
||||
public ApiFeatureFlags Features { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using EonaCat.Json;
|
||||
|
||||
namespace EonaCat.DoxaApi.Models
|
||||
{
|
||||
@@ -7,41 +7,41 @@ namespace EonaCat.DoxaApi.Models
|
||||
|
||||
public sealed class ApiEndpoint
|
||||
{
|
||||
[JsonPropertyName("operationId")]
|
||||
[JsonProperty("operationId")]
|
||||
public string OperationId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
[JsonProperty("summary")]
|
||||
public string? Summary { get; set; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
[JsonProperty("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonPropertyName("method")]
|
||||
[JsonProperty("method")]
|
||||
public string Method { get; set; } = "GET";
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
[JsonProperty("path")]
|
||||
public string Path { get; set; } = "/";
|
||||
|
||||
[JsonPropertyName("deprecated")]
|
||||
[JsonProperty("deprecated")]
|
||||
public bool Deprecated { get; set; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
[JsonProperty("tags")]
|
||||
public List<string> Tags { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("parameters")]
|
||||
[JsonProperty("parameters")]
|
||||
public List<ApiParameter> Parameters { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("requestBody")]
|
||||
[JsonProperty("requestBody")]
|
||||
public RequestBodyModel? RequestBody { get; set; }
|
||||
|
||||
[JsonPropertyName("responses")]
|
||||
[JsonProperty("responses")]
|
||||
public List<ResponseModel> Responses { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Ids of security schemes that apply to this endpoint (referencing ApiDocument.SecuritySchemes).
|
||||
/// Empty means the endpoint is unauthenticated (or uses the document-wide default, if any).
|
||||
/// </summary>
|
||||
[JsonPropertyName("security")]
|
||||
[JsonProperty("security")]
|
||||
public List<string> Security { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using EonaCat.Json;
|
||||
|
||||
namespace EonaCat.DoxaApi.Models
|
||||
{
|
||||
@@ -7,13 +7,13 @@ namespace EonaCat.DoxaApi.Models
|
||||
|
||||
public sealed class ApiGroup
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
[JsonProperty("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonPropertyName("endpoints")]
|
||||
[JsonProperty("endpoints")]
|
||||
public List<ApiEndpoint> Endpoints { get; set; } = new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using EonaCat.Json;
|
||||
|
||||
namespace EonaCat.DoxaApi.Models
|
||||
{
|
||||
@@ -7,28 +7,28 @@ namespace EonaCat.DoxaApi.Models
|
||||
|
||||
public sealed class SchemaModel
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
[JsonProperty("type")]
|
||||
public string Type { get; set; } = "object";
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
[JsonProperty("format")]
|
||||
public string? Format { get; set; }
|
||||
|
||||
[JsonPropertyName("refName")]
|
||||
[JsonProperty("refName")]
|
||||
public string? RefName { get; set; }
|
||||
|
||||
[JsonPropertyName("items")]
|
||||
[JsonProperty("items")]
|
||||
public SchemaModel? Items { get; set; }
|
||||
|
||||
[JsonPropertyName("properties")]
|
||||
[JsonProperty("properties")]
|
||||
public Dictionary<string, SchemaModel>? Properties { get; set; }
|
||||
|
||||
[JsonPropertyName("required")]
|
||||
[JsonProperty("required")]
|
||||
public List<string>? Required { get; set; }
|
||||
|
||||
[JsonPropertyName("enumValues")]
|
||||
[JsonProperty("enumValues")]
|
||||
public List<string>? EnumValues { get; set; }
|
||||
|
||||
[JsonPropertyName("nullable")]
|
||||
[JsonProperty("nullable")]
|
||||
public bool Nullable { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using EonaCat.Json;
|
||||
|
||||
namespace EonaCat.DoxaApi.Models
|
||||
{
|
||||
@@ -13,64 +13,64 @@ namespace EonaCat.DoxaApi.Models
|
||||
/// </summary>
|
||||
public sealed class SecuritySchemeModel
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
[JsonProperty("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
/// <summary>apiKey | http | oauth2 | openIdConnect</summary>
|
||||
[JsonPropertyName("type")]
|
||||
[JsonProperty("type")]
|
||||
public string Type { get; set; } = "apiKey";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
[JsonProperty("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>For apiKey: header | query | cookie</summary>
|
||||
[JsonPropertyName("in")]
|
||||
[JsonProperty("in")]
|
||||
public string? In { get; set; }
|
||||
|
||||
/// <summary>For apiKey: the header/query/cookie name. For http: ignored.</summary>
|
||||
[JsonPropertyName("name")]
|
||||
[JsonProperty("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>For http: bearer | basic | digest</summary>
|
||||
[JsonPropertyName("scheme")]
|
||||
[JsonProperty("scheme")]
|
||||
public string? Scheme { get; set; }
|
||||
|
||||
/// <summary>For http bearer: an informational hint such as "JWT".</summary>
|
||||
[JsonPropertyName("bearerFormat")]
|
||||
[JsonProperty("bearerFormat")]
|
||||
public string? BearerFormat { get; set; }
|
||||
|
||||
/// <summary>For oauth2: supported flows (clientCredentials, authorizationCode, password, implicit).</summary>
|
||||
[JsonPropertyName("flows")]
|
||||
[JsonProperty("flows")]
|
||||
public OAuthFlowsModel? Flows { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OAuthFlowsModel
|
||||
{
|
||||
[JsonPropertyName("clientCredentials")]
|
||||
[JsonProperty("clientCredentials")]
|
||||
public OAuthFlowModel? ClientCredentials { get; set; }
|
||||
|
||||
[JsonPropertyName("authorizationCode")]
|
||||
[JsonProperty("authorizationCode")]
|
||||
public OAuthFlowModel? AuthorizationCode { get; set; }
|
||||
|
||||
[JsonPropertyName("password")]
|
||||
[JsonProperty("password")]
|
||||
public OAuthFlowModel? Password { get; set; }
|
||||
|
||||
[JsonPropertyName("implicit")]
|
||||
[JsonProperty("implicit")]
|
||||
public OAuthFlowModel? Implicit { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OAuthFlowModel
|
||||
{
|
||||
[JsonPropertyName("authorizationUrl")]
|
||||
[JsonProperty("authorizationUrl")]
|
||||
public string? AuthorizationUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("tokenUrl")]
|
||||
[JsonProperty("tokenUrl")]
|
||||
public string? TokenUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("refreshUrl")]
|
||||
[JsonProperty("refreshUrl")]
|
||||
public string? RefreshUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("scopes")]
|
||||
[JsonProperty("scopes")]
|
||||
public Dictionary<string, string> Scopes { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ namespace EonaCat.DoxaApi
|
||||
var options = new DoxaApiOptions();
|
||||
configure?.Invoke(options);
|
||||
services.AddSingleton(options);
|
||||
services.AddSingleton<MetricsCollector>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1386,6 +1386,9 @@
|
||||
const exportDoxaApiBtn = document.getElementById("exportDoxaApiBtn");
|
||||
const exportOpenApiBtn = document.getElementById("exportOpenApiBtn");
|
||||
const exportSwaggerBtn = document.getElementById("exportSwaggerBtn");
|
||||
const dashboardBtn = document.getElementById("dashboardBtn");
|
||||
const closeDashboard = document.getElementById("closeDashboard");
|
||||
const dashboardPanel = document.getElementById("dashboardPanel");
|
||||
|
||||
importBtn?.addEventListener("click", () => importFile.click());
|
||||
|
||||
@@ -1443,6 +1446,10 @@
|
||||
exportOpenApiBtn?.addEventListener("click", () => download("openapi.json", "openapi.json"));
|
||||
exportSwaggerBtn?.addEventListener("click", () => download("swagger.json", "swagger.json"));
|
||||
|
||||
// Dashboard functionality
|
||||
dashboardBtn?.addEventListener("click", () => openDashboard());
|
||||
closeDashboard?.addEventListener("click", () => closeDashboardPanel());
|
||||
|
||||
const authBtn = document.getElementById("authBtn");
|
||||
authBtn?.addEventListener("click", () => openAuthModal());
|
||||
|
||||
@@ -1578,5 +1585,93 @@
|
||||
return escapeHtml(str);
|
||||
}
|
||||
|
||||
// Dashboard functions
|
||||
async function openDashboard() {
|
||||
const dashboardPanel = document.getElementById("dashboardPanel");
|
||||
const dashboard = document.querySelector(".dashboard-panel");
|
||||
|
||||
if (!dashboardPanel) return;
|
||||
dashboardPanel.style.display = "block";
|
||||
|
||||
try {
|
||||
const res = await fetch("analytics");
|
||||
if (!res.ok) throw new Error("Failed to load analytics");
|
||||
|
||||
const analyticsData = await res.json();
|
||||
updateDashboard(analyticsData);
|
||||
|
||||
// Refresh every 5 seconds
|
||||
if (window.dashboardInterval) clearInterval(window.dashboardInterval);
|
||||
window.dashboardInterval = setInterval(async () => {
|
||||
const res = await fetch("analytics");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
updateDashboard(data);
|
||||
}
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
console.error("Dashboard load error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function closeDashboardPanel() {
|
||||
const dashboardPanel = document.getElementById("dashboardPanel");
|
||||
if (dashboardPanel) dashboardPanel.style.display = "none";
|
||||
|
||||
if (window.dashboardInterval) {
|
||||
clearInterval(window.dashboardInterval);
|
||||
window.dashboardInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateDashboard(data) {
|
||||
document.getElementById("totalEndpoints").textContent = data.totalEndpoints || "0";
|
||||
document.getElementById("totalRequests").textContent = data.totalRequests || "0";
|
||||
document.getElementById("totalErrors").textContent = data.totalErrors || "0";
|
||||
document.getElementById("avgResponse").textContent = Math.round((data.averageResponseTime || 0)) + "ms";
|
||||
|
||||
// Update top endpoints
|
||||
const topEndpointsList = (data.topEndpoints || []).map(ep => `
|
||||
<div class="request-item">
|
||||
<div>
|
||||
<span class="request-method">${ep.method}</span>
|
||||
<code style="font-size: 11px; color: var(--text-1);">${ep.path}</code>
|
||||
</div>
|
||||
<div class="request-time">${ep.requestCount} req</div>
|
||||
</div>
|
||||
`).join("") || "<div style='padding: 20px; color: var(--text-2); text-align: center;'>No data yet</div>";
|
||||
|
||||
document.getElementById("topEndpointsChart").innerHTML = `
|
||||
<div style="width: 100%;">
|
||||
<h3 style="margin: 0 0 12px 0; font-size: 13px; font-weight: 600;">Top Endpoints</h3>
|
||||
${topEndpointsList}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Update recent requests
|
||||
const recentRequestsList = (data.recentRequests || []).map(req => {
|
||||
const statusClass = req.statusCode >= 400 ? "request-status-error" : "request-status-success";
|
||||
return `
|
||||
<div class="request-item">
|
||||
<div>
|
||||
<span class="request-method">${req.method}</span>
|
||||
<code style="font-size: 11px; color: var(--text-1);">${req.path}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span class="request-time ${statusClass}">${req.statusCode}</span>
|
||||
<span class="request-time" style="margin-left: 8px;">${req.responseTimeMs}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("") || "<div style='padding: 20px; color: var(--text-2); text-align: center;'>No requests yet</div>";
|
||||
|
||||
document.getElementById("recentRequestsTable").innerHTML = `
|
||||
<div style="padding: 16px; background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius-md);">
|
||||
<h3 style="margin: 0 0 12px 0; font-size: 13px; font-weight: 600;">Recent Requests</h3>
|
||||
${recentRequestsList}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
@@ -33,7 +33,10 @@
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<div class="action-group">
|
||||
<button id="authBtn" class="action-btn icon-btn-badge" title="Authorization">
|
||||
<button id="dashboardBtn" class="action-btn" title="View Analytics Dashboard">
|
||||
<svg viewBox="0 0 24 24" fill="none" width="15" height="15" style="vertical-align:-2px;margin-right:4px;"><rect x="3" y="3" width="7" height="7" stroke="currentColor" stroke-width="2"/><rect x="14" y="3" width="7" height="7" stroke="currentColor" stroke-width="2"/><rect x="3" y="14" width="7" height="7" stroke="currentColor" stroke-width="2"/><rect x="14" y="14" width="7" height="7" stroke="currentColor" stroke-width="2"/></svg>Dashboard
|
||||
</button>
|
||||
<button id="authBtn" class="action-btn" title="Manage authentication">
|
||||
<svg viewBox="0 0 24 24" fill="none" width="15" height="15" style="vertical-align:-2px;margin-right:4px;"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="2" /><path d="M8 11V7a4 4 0 018 0v4" stroke="currentColor" stroke-width="2" stroke-linecap="round" /></svg>Auth
|
||||
<span class="badge-dot" id="authBadgeDot" style="display:none;"></span>
|
||||
</button>
|
||||
@@ -71,6 +74,36 @@
|
||||
<aside class="try-pane" id="tryPane">
|
||||
<div id="tryContent" class="try-content"></div>
|
||||
</aside>
|
||||
|
||||
<!-- Dashboard Panel (initially hidden) -->
|
||||
<div id="dashboardPanel" class="dashboard-panel" style="display: none;">
|
||||
<div class="dashboard-header">
|
||||
<h2>API Analytics</h2>
|
||||
<button id="closeDashboard" class="close-btn" title="Close Dashboard">×</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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user