Initial version
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
namespace EonaCat.DoxaApi.Attributes
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class DoxaApiDescriptionAttribute : Attribute
|
||||
{
|
||||
public string Description { get; }
|
||||
public DoxaApiDescriptionAttribute(string description) => Description = description;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace EonaCat.DoxaApi.Attributes
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class DoxaApiExampleAttribute : Attribute
|
||||
{
|
||||
public string Json { get; }
|
||||
public DoxaApiExampleAttribute(string json) => Json = json;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace EonaCat.DoxaApi.Attributes
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public sealed class DoxaApiGroupAttribute : Attribute
|
||||
{
|
||||
public string Name { get; }
|
||||
public DoxaApiGroupAttribute(string name) => Name = name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace EonaCat.DoxaApi.Attributes
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public sealed class DoxaApiHiddenAttribute : Attribute
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace EonaCat.DoxaApi.Attributes
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class DoxaApiSummaryAttribute : Attribute
|
||||
{
|
||||
public string Summary { get; }
|
||||
public DoxaApiSummaryAttribute(string summary) => Summary = summary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyName>EonaCat.DoxaApi</AssemblyName>
|
||||
<RootNamespace>EonaCat.DoxaApi</RootNamespace>
|
||||
|
||||
<!-- NuGet package metadata -->
|
||||
<PackageId>EonaCat.DoxaApi</PackageId>
|
||||
<Version>0.0.2</Version>
|
||||
<Authors>EonaCat (Jeroen Saey)</Authors>
|
||||
<Description>A modern, self-contained, dependency-free OpenAPI documentation UI for ASP.NET Core.</Description>
|
||||
<PackageTags>openapi;swagger;documentation;api;aspnetcore;doxa;api;docs;documentation;Jeroen;Saey;EonaCat;Scalar;Redoc;Postman;EchoAPI;</PackageTags>
|
||||
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.DoxaApi</RepositoryUrl>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
|
||||
<IsPackable>true</IsPackable>
|
||||
<EnableDefaultContentItems>false</EnableDefaultContentItems>
|
||||
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
|
||||
<Title>EonaCat.DoxaApi</Title>
|
||||
<Company>EonaCat</Company>
|
||||
<Product>EonaCat.DoxaApi</Product>
|
||||
<Copyright>EonaCat (Jeroen Saey)</Copyright>
|
||||
<PackageProjectUrl>https://git.saey.me/EonaCat/EonaCat.DoxaApi</PackageProjectUrl>
|
||||
<PackageIcon>icon.png</PackageIcon>
|
||||
<AssemblyVersion></AssemblyVersion>
|
||||
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="UI\Assets\**\*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\icon.png">
|
||||
<Pack>True</Pack>
|
||||
<PackagePath>\</PackagePath>
|
||||
</None>
|
||||
<None Include="..\LICENSE">
|
||||
<Pack>True</Pack>
|
||||
<PackagePath>\</PackagePath>
|
||||
</None>
|
||||
<None Include="..\README.md">
|
||||
<Pack>True</Pack>
|
||||
<PackagePath>\</PackagePath>
|
||||
</None>
|
||||
<None Include="README.md" Pack="true" PackagePath="\" Condition="Exists('README.md')" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace EonaCat.DoxaApi.Exporter
|
||||
{
|
||||
public static class DoxaApiExporter { }
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using EonaCat.DoxaApi.Models;
|
||||
|
||||
namespace EonaCat.DoxaApi.Interop
|
||||
{
|
||||
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 };
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return op;
|
||||
}
|
||||
|
||||
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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using EonaCat.DoxaApi.Models;
|
||||
|
||||
namespace EonaCat.DoxaApi.Interop
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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 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,278 @@
|
||||
using System.Reflection;
|
||||
using System.Xml.Linq;
|
||||
using EonaCat.DoxaApi.Attributes;
|
||||
using EonaCat.DoxaApi.Models;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
|
||||
namespace EonaCat.DoxaApi.Generation
|
||||
{
|
||||
public sealed class ApiDocumentGenerator
|
||||
{
|
||||
private readonly IReadOnlyList<ActionDescriptor> _actions;
|
||||
private readonly DoxaApiOptions _options;
|
||||
private readonly Dictionary<string, XmlDocReader?> _xmlReadersByAssembly = new();
|
||||
|
||||
public ApiDocumentGenerator(IReadOnlyList<ActionDescriptor> actions, DoxaApiOptions options)
|
||||
{
|
||||
_actions = actions;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public ApiDocument Generate()
|
||||
{
|
||||
var doc = new ApiDocument
|
||||
{
|
||||
Info = new ApiInfo
|
||||
{
|
||||
Title = _options.Title,
|
||||
Description = _options.Description,
|
||||
Version = _options.Version
|
||||
},
|
||||
Servers = _options.Servers.ToList()
|
||||
};
|
||||
|
||||
var schemaRegistry = new Dictionary<string, SchemaModel>();
|
||||
var schemaBuilder = new SchemaBuilder(schemaRegistry);
|
||||
var groups = new Dictionary<string, ApiGroup>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var action in _actions)
|
||||
{
|
||||
if (action is not ControllerActionDescriptor cad)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cad.MethodInfo.GetCustomAttribute<DoxaApiHiddenAttribute>() is not null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cad.ControllerTypeInfo.GetCustomAttribute<DoxaApiHiddenAttribute>() is not null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var groupName = ResolveGroupName(cad);
|
||||
if (!groups.TryGetValue(groupName, out var group))
|
||||
{
|
||||
group = new ApiGroup { Name = groupName };
|
||||
groups[groupName] = group;
|
||||
}
|
||||
|
||||
var endpoint = BuildEndpoint(cad, schemaBuilder);
|
||||
if (endpoint is not null)
|
||||
{
|
||||
group.Endpoints.Add(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
doc.Groups = groups.Values
|
||||
.OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
foreach (var group in doc.Groups)
|
||||
{
|
||||
group.Endpoints = group.Endpoints
|
||||
.OrderBy(e => e.Path, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(e => MethodSortOrder(e.Method))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
doc.Schemas = schemaRegistry;
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static int MethodSortOrder(string method) => method switch
|
||||
{
|
||||
"GET" => 0,
|
||||
"POST" => 1,
|
||||
"PUT" => 2,
|
||||
"PATCH" => 3,
|
||||
"DELETE" => 4,
|
||||
_ => 5
|
||||
};
|
||||
|
||||
private string ResolveGroupName(ControllerActionDescriptor cad)
|
||||
{
|
||||
var methodAttr = cad.MethodInfo.GetCustomAttribute<DoxaApiGroupAttribute>();
|
||||
if (methodAttr is not null)
|
||||
{
|
||||
return methodAttr.Name;
|
||||
}
|
||||
|
||||
var classAttr = cad.ControllerTypeInfo.GetCustomAttribute<DoxaApiGroupAttribute>();
|
||||
if (classAttr is not null)
|
||||
{
|
||||
return classAttr.Name;
|
||||
}
|
||||
|
||||
var name = cad.ControllerName;
|
||||
return name;
|
||||
}
|
||||
|
||||
private ApiEndpoint? BuildEndpoint(ControllerActionDescriptor cad, SchemaBuilder schemaBuilder)
|
||||
{
|
||||
var httpMethod = cad.ActionConstraints?
|
||||
.OfType<Microsoft.AspNetCore.Mvc.ActionConstraints.HttpMethodActionConstraint>()
|
||||
.FirstOrDefault()?.HttpMethods.FirstOrDefault() ?? "GET";
|
||||
|
||||
var path = "/" + (cad.AttributeRouteInfo?.Template?.TrimStart('/') ?? cad.ActionName);
|
||||
|
||||
var xmlReader = GetXmlReader(cad.MethodInfo.DeclaringType!.Assembly);
|
||||
var xmlDoc = xmlReader?.GetMethodDoc(cad.MethodInfo);
|
||||
|
||||
var summaryAttr = cad.MethodInfo.GetCustomAttribute<DoxaApiSummaryAttribute>();
|
||||
var descAttr = cad.MethodInfo.GetCustomAttribute<DoxaApiDescriptionAttribute>();
|
||||
var exampleAttr = cad.MethodInfo.GetCustomAttribute<DoxaApiExampleAttribute>();
|
||||
var obsoleteAttr = cad.MethodInfo.GetCustomAttribute<ObsoleteAttribute>();
|
||||
|
||||
var endpoint = new ApiEndpoint
|
||||
{
|
||||
OperationId = $"{cad.ControllerName}_{cad.ActionName}",
|
||||
Method = httpMethod.ToUpperInvariant(),
|
||||
Path = path,
|
||||
Summary = summaryAttr?.Summary ?? xmlDoc?.Summary ?? HumanizeName(cad.ActionName),
|
||||
Description = descAttr?.Description ?? xmlDoc?.Remarks,
|
||||
Deprecated = obsoleteAttr is not null,
|
||||
Tags = new List<string> { ResolveGroupName(cad) }
|
||||
};
|
||||
|
||||
foreach (var param in cad.Parameters)
|
||||
{
|
||||
var bindingSource = param.BindingInfo?.BindingSource;
|
||||
var inLocation = bindingSource?.Id switch
|
||||
{
|
||||
"Path" => "path",
|
||||
"Query" => "query",
|
||||
"Header" => "header",
|
||||
"Body" => "body",
|
||||
"Form" => "form",
|
||||
_ => InferLocationFromPath(param.Name, path)
|
||||
};
|
||||
|
||||
if (inLocation == "body")
|
||||
{
|
||||
endpoint.RequestBody = new RequestBodyModel
|
||||
{
|
||||
Required = true,
|
||||
Schema = schemaBuilder.Build(param.ParameterType),
|
||||
Example = exampleAttr?.Json
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
var paramDoc = xmlDoc?.Params.GetValueOrDefault(param.Name);
|
||||
|
||||
endpoint.Parameters.Add(new ApiParameter
|
||||
{
|
||||
Name = param.Name,
|
||||
In = inLocation,
|
||||
Required = inLocation == "path" || IsRequiredParam(param),
|
||||
Description = paramDoc,
|
||||
Schema = schemaBuilder.Build(param.ParameterType)
|
||||
});
|
||||
}
|
||||
|
||||
var returnType = cad.MethodInfo.ReturnType;
|
||||
var responseSchema = schemaBuilder.Build(returnType);
|
||||
var successCode = httpMethod.ToUpperInvariant() switch
|
||||
{
|
||||
"POST" => "201",
|
||||
"DELETE" => "204",
|
||||
_ => "200"
|
||||
};
|
||||
|
||||
if (responseSchema.Type != "void")
|
||||
{
|
||||
endpoint.Responses.Add(new ResponseModel
|
||||
{
|
||||
StatusCode = successCode,
|
||||
Description = xmlDoc?.Returns ?? "Success",
|
||||
Schema = responseSchema
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
endpoint.Responses.Add(new ResponseModel
|
||||
{
|
||||
StatusCode = successCode,
|
||||
Description = "Success"
|
||||
});
|
||||
}
|
||||
|
||||
if (endpoint.Parameters.Any(p => p.Required) || endpoint.RequestBody is not null)
|
||||
{
|
||||
endpoint.Responses.Add(new ResponseModel { StatusCode = "400", Description = "Invalid request" });
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
private static bool IsRequiredParam(ParameterDescriptor param)
|
||||
{
|
||||
if (param is ControllerParameterDescriptor cpd)
|
||||
{
|
||||
var nullableUnderlying = Nullable.GetUnderlyingType(cpd.ParameterInfo.ParameterType);
|
||||
bool hasDefault = cpd.ParameterInfo.HasDefaultValue;
|
||||
bool isNullableType = nullableUnderlying is not null || !cpd.ParameterInfo.ParameterType.IsValueType;
|
||||
return !hasDefault && !isNullableType;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string InferLocationFromPath(string paramName, string path)
|
||||
{
|
||||
return path.Contains("{" + paramName + "}", StringComparison.OrdinalIgnoreCase) ? "path" : "query";
|
||||
}
|
||||
|
||||
private static string HumanizeName(string name)
|
||||
{
|
||||
|
||||
var chars = new List<char>();
|
||||
for (int i = 0; i < name.Length; i++)
|
||||
{
|
||||
if (i > 0 && char.IsUpper(name[i]) && !char.IsUpper(name[i - 1]))
|
||||
{
|
||||
chars.Add(' ');
|
||||
}
|
||||
|
||||
chars.Add(name[i]);
|
||||
}
|
||||
return new string(chars.ToArray());
|
||||
}
|
||||
|
||||
private XmlDocReader? GetXmlReader(Assembly assembly)
|
||||
{
|
||||
var key = assembly.GetName().Name ?? assembly.FullName ?? "unknown";
|
||||
if (_xmlReadersByAssembly.TryGetValue(key, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var location = assembly.Location;
|
||||
if (string.IsNullOrEmpty(location))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var xmlPath = Path.ChangeExtension(location, ".xml");
|
||||
XmlDocReader? reader = null;
|
||||
if (File.Exists(xmlPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
reader = new XmlDocReader(XDocument.Load(xmlPath));
|
||||
}
|
||||
catch
|
||||
{
|
||||
reader = null;
|
||||
}
|
||||
}
|
||||
|
||||
_xmlReadersByAssembly[key] = reader;
|
||||
return reader;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace EonaCat.DoxaApi.Generation
|
||||
{
|
||||
public sealed class DoxaApiOptions
|
||||
{
|
||||
public string Title { get; set; } = "DoxaApi Documentation";
|
||||
public string? Description { get; set; }
|
||||
public string Version { get; set; } = "v1";
|
||||
public string RoutePrefix { get; set; } = "doxa";
|
||||
public List<string> Servers { get; set; } = new();
|
||||
public string Theme { get; set; } = "auto";
|
||||
public string AccentColor { get; set; } = "#6366f1";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using EonaCat.DoxaApi.Models;
|
||||
|
||||
namespace EonaCat.DoxaApi.Generation
|
||||
{
|
||||
public sealed class SchemaBuilder
|
||||
{
|
||||
private readonly Dictionary<string, SchemaModel> _registry;
|
||||
private readonly HashSet<Type> _inProgress = new();
|
||||
|
||||
public SchemaBuilder(Dictionary<string, SchemaModel> registry)
|
||||
{
|
||||
_registry = registry;
|
||||
}
|
||||
|
||||
public SchemaModel Build(Type type)
|
||||
{
|
||||
|
||||
type = UnwrapAsyncAndActionResult(type);
|
||||
|
||||
var underlying = Nullable.GetUnderlyingType(type);
|
||||
bool nullable = underlying is not null;
|
||||
if (underlying is not null)
|
||||
{
|
||||
type = underlying;
|
||||
}
|
||||
|
||||
var schema = BuildCore(type);
|
||||
schema.Nullable = nullable || schema.Nullable;
|
||||
return schema;
|
||||
}
|
||||
|
||||
private static Type UnwrapAsyncAndActionResult(Type type)
|
||||
{
|
||||
if (type == typeof(void))
|
||||
{
|
||||
return type;
|
||||
}
|
||||
|
||||
if (type.IsGenericType)
|
||||
{
|
||||
var def = type.GetGenericTypeDefinition();
|
||||
if (def == typeof(Task<>) || def.Name.StartsWith("ValueTask`"))
|
||||
{
|
||||
return UnwrapAsyncAndActionResult(type.GetGenericArguments()[0]);
|
||||
}
|
||||
|
||||
if (def.Name is "ActionResult`1")
|
||||
{
|
||||
return UnwrapAsyncAndActionResult(type.GetGenericArguments()[0]);
|
||||
}
|
||||
}
|
||||
|
||||
if (type == typeof(Task))
|
||||
{
|
||||
return typeof(void);
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
private SchemaModel BuildCore(Type type)
|
||||
{
|
||||
if (type == typeof(void))
|
||||
{
|
||||
return new SchemaModel { Type = "void" };
|
||||
}
|
||||
|
||||
if (type == typeof(string) || type == typeof(char))
|
||||
{
|
||||
return new SchemaModel { Type = "string" };
|
||||
}
|
||||
|
||||
if (type == typeof(bool))
|
||||
{
|
||||
return new SchemaModel { Type = "boolean" };
|
||||
}
|
||||
|
||||
if (type == typeof(byte) || type == typeof(sbyte) || type == typeof(short) ||
|
||||
type == typeof(ushort) || type == typeof(int) || type == typeof(uint) ||
|
||||
type == typeof(long) || type == typeof(ulong))
|
||||
{
|
||||
return new SchemaModel { Type = "integer", Format = type.Name.ToLowerInvariant() };
|
||||
}
|
||||
|
||||
if (type == typeof(float) || type == typeof(double) || type == typeof(decimal))
|
||||
{
|
||||
return new SchemaModel { Type = "number", Format = type.Name.ToLowerInvariant() };
|
||||
}
|
||||
|
||||
if (type == typeof(DateTime) || type == typeof(DateTimeOffset))
|
||||
{
|
||||
return new SchemaModel { Type = "string", Format = "date-time" };
|
||||
}
|
||||
|
||||
if (type == typeof(DateOnly))
|
||||
{
|
||||
return new SchemaModel { Type = "string", Format = "date" };
|
||||
}
|
||||
|
||||
if (type == typeof(TimeOnly) || type == typeof(TimeSpan))
|
||||
{
|
||||
return new SchemaModel { Type = "string", Format = "time" };
|
||||
}
|
||||
|
||||
if (type == typeof(Guid))
|
||||
{
|
||||
return new SchemaModel { Type = "string", Format = "uuid" };
|
||||
}
|
||||
|
||||
if (type == typeof(Uri))
|
||||
{
|
||||
return new SchemaModel { Type = "string", Format = "uri" };
|
||||
}
|
||||
|
||||
if (type == typeof(object))
|
||||
{
|
||||
return new SchemaModel { Type = "object" };
|
||||
}
|
||||
|
||||
if (type.IsEnum)
|
||||
{
|
||||
return new SchemaModel
|
||||
{
|
||||
Type = "enum",
|
||||
EnumValues = Enum.GetNames(type).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
if (IsDictionary(type, out var valueType))
|
||||
{
|
||||
return new SchemaModel
|
||||
{
|
||||
Type = "object",
|
||||
Items = Build(valueType!)
|
||||
};
|
||||
}
|
||||
|
||||
var elementType = GetEnumerableElementType(type);
|
||||
if (elementType is not null)
|
||||
{
|
||||
return new SchemaModel
|
||||
{
|
||||
Type = "array",
|
||||
Items = Build(elementType)
|
||||
};
|
||||
}
|
||||
|
||||
var refName = FriendlyTypeName(type);
|
||||
|
||||
if (_registry.ContainsKey(refName))
|
||||
{
|
||||
return new SchemaModel { Type = "object", RefName = refName };
|
||||
}
|
||||
|
||||
if (_inProgress.Contains(type))
|
||||
{
|
||||
return new SchemaModel { Type = "object", RefName = refName };
|
||||
}
|
||||
|
||||
_inProgress.Add(type);
|
||||
|
||||
var props = new Dictionary<string, SchemaModel>();
|
||||
var required = new List<string>();
|
||||
|
||||
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (prop.GetIndexParameters().Length > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!prop.CanRead)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var propSchema = Build(prop.PropertyType);
|
||||
props[ToCamelCase(prop.Name)] = propSchema;
|
||||
|
||||
if (!propSchema.Nullable && Nullable.GetUnderlyingType(prop.PropertyType) is null
|
||||
&& (prop.PropertyType.IsValueType || IsNonNullableReferenceProperty(prop)))
|
||||
{
|
||||
required.Add(ToCamelCase(prop.Name));
|
||||
}
|
||||
}
|
||||
|
||||
_registry[refName] = new SchemaModel
|
||||
{
|
||||
Type = "object",
|
||||
Properties = props,
|
||||
Required = required.Count > 0 ? required : null
|
||||
};
|
||||
|
||||
_inProgress.Remove(type);
|
||||
|
||||
return new SchemaModel { Type = "object", RefName = refName };
|
||||
}
|
||||
|
||||
private static bool IsNonNullableReferenceProperty(PropertyInfo prop)
|
||||
{
|
||||
|
||||
try
|
||||
{
|
||||
var ctx = new NullabilityInfoContext();
|
||||
var info = ctx.Create(prop);
|
||||
return info.ReadState == NullabilityState.NotNull;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Type? GetEnumerableElementType(Type type)
|
||||
{
|
||||
if (type == typeof(string))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type.IsArray)
|
||||
{
|
||||
return type.GetElementType();
|
||||
}
|
||||
|
||||
if (type.IsGenericType)
|
||||
{
|
||||
var def = type.GetGenericTypeDefinition();
|
||||
if (def == typeof(List<>) || def == typeof(IList<>) || def == typeof(ICollection<>) ||
|
||||
def == typeof(IEnumerable<>) || def == typeof(IReadOnlyList<>) || def == typeof(IReadOnlyCollection<>) ||
|
||||
def == typeof(HashSet<>) || def == typeof(Queue<>) || def == typeof(Stack<>))
|
||||
{
|
||||
return type.GetGenericArguments()[0];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var iface in type.GetInterfaces())
|
||||
{
|
||||
if (iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IEnumerable<>))
|
||||
{
|
||||
return iface.GetGenericArguments()[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string))
|
||||
{
|
||||
return typeof(object);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsDictionary(Type type, out Type? valueType)
|
||||
{
|
||||
valueType = null;
|
||||
if (!type.IsGenericType)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var def = type.GetGenericTypeDefinition();
|
||||
if (def == typeof(Dictionary<,>) || def == typeof(IDictionary<,>) || def == typeof(IReadOnlyDictionary<,>))
|
||||
{
|
||||
valueType = type.GetGenericArguments()[1];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string FriendlyTypeName(Type type)
|
||||
{
|
||||
if (!type.IsGenericType)
|
||||
{
|
||||
return type.Name;
|
||||
}
|
||||
|
||||
var name = type.Name[..type.Name.IndexOf('`')];
|
||||
var args = string.Join(",", type.GetGenericArguments().Select(FriendlyTypeName));
|
||||
return $"{name}<{args}>";
|
||||
}
|
||||
|
||||
private static string ToCamelCase(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name) || char.IsLower(name[0]))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
return char.ToLowerInvariant(name[0]) + name[1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace EonaCat.DoxaApi.Generation
|
||||
{
|
||||
internal sealed class MethodXmlDoc
|
||||
{
|
||||
public string? Summary { get; set; }
|
||||
public string? Remarks { get; set; }
|
||||
public string? Returns { get; set; }
|
||||
public Dictionary<string, string> Params { get; } = new();
|
||||
}
|
||||
|
||||
internal sealed class XmlDocReader
|
||||
{
|
||||
private readonly Dictionary<string, MethodXmlDoc> _members = new();
|
||||
|
||||
public XmlDocReader(XDocument document)
|
||||
{
|
||||
var members = document.Root?.Element("members")?.Elements("member");
|
||||
if (members is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var member in members)
|
||||
{
|
||||
var name = member.Attribute("name")?.Value;
|
||||
if (name is null || !name.StartsWith("M:"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var doc = new MethodXmlDoc
|
||||
{
|
||||
Summary = CleanText(member.Element("summary")?.Value),
|
||||
Remarks = CleanText(member.Element("remarks")?.Value),
|
||||
Returns = CleanText(member.Element("returns")?.Value)
|
||||
};
|
||||
|
||||
foreach (var paramEl in member.Elements("param"))
|
||||
{
|
||||
var pName = paramEl.Attribute("name")?.Value;
|
||||
if (pName is not null)
|
||||
{
|
||||
doc.Params[pName] = CleanText(paramEl.Value) ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
_members[name] = doc;
|
||||
}
|
||||
}
|
||||
|
||||
public MethodXmlDoc? GetMethodDoc(MethodInfo method)
|
||||
{
|
||||
var key = BuildMemberKey(method);
|
||||
return _members.GetValueOrDefault(key);
|
||||
}
|
||||
|
||||
private static string? CleanText(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var lines = raw.Split('\n').Select(l => l.Trim());
|
||||
return string.Join(" ", lines).Trim();
|
||||
}
|
||||
|
||||
private static string BuildMemberKey(MethodInfo method)
|
||||
{
|
||||
var sb = new StringBuilder("M:");
|
||||
sb.Append(method.DeclaringType!.FullName!.Replace('+', '.'));
|
||||
sb.Append('.');
|
||||
sb.Append(method.Name);
|
||||
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length > 0)
|
||||
{
|
||||
sb.Append('(');
|
||||
sb.Append(string.Join(",", parameters.Select(p => XmlTypeName(p.ParameterType))));
|
||||
sb.Append(')');
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string XmlTypeName(Type type)
|
||||
{
|
||||
if (type.IsGenericType)
|
||||
{
|
||||
var def = type.GetGenericTypeDefinition();
|
||||
var name = def.FullName![..def.FullName!.IndexOf('`')];
|
||||
var args = string.Join(",", type.GetGenericArguments().Select(XmlTypeName));
|
||||
return $"{name}{{{args}}}";
|
||||
}
|
||||
|
||||
return type.FullName?.Replace('+', '.') ?? type.Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using EonaCat.DoxaApi.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace EonaCat.DoxaApi.Interop
|
||||
{
|
||||
public static class DoxaApiImporter
|
||||
{
|
||||
public static ApiDocument Import(string json)
|
||||
{
|
||||
return JsonSerializer.Deserialize<ApiDocument>(json)
|
||||
?? throw new InvalidOperationException("Invalid DoxaApi spec.");
|
||||
}
|
||||
|
||||
public static async Task<ApiDocument> ImportAsync(Stream stream)
|
||||
{
|
||||
var doc = await JsonSerializer.DeserializeAsync<ApiDocument>(stream);
|
||||
return doc ?? throw new InvalidOperationException("Invalid DoxaApi spec.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using EonaCat.DoxaApi.Models;
|
||||
|
||||
namespace EonaCat.DoxaApi.Interop
|
||||
{
|
||||
public static class OpenApiImporter
|
||||
{
|
||||
|
||||
public static ApiDocument Import(string json)
|
||||
{
|
||||
var node = JsonNode.Parse(json) ?? throw new ArgumentException("Input is not valid JSON.");
|
||||
return Import(node);
|
||||
}
|
||||
|
||||
public static async Task<ApiDocument> ImportAsync(Stream stream)
|
||||
{
|
||||
var node = await JsonNode.ParseAsync(stream)
|
||||
?? throw new ArgumentException("Stream is not valid JSON.");
|
||||
|
||||
return Import(node);
|
||||
}
|
||||
|
||||
public static ApiDocument Import(JsonNode root)
|
||||
{
|
||||
if (root["openapi"] is not null)
|
||||
{
|
||||
return ImportOpenApi3(root);
|
||||
}
|
||||
|
||||
if (root["swagger"] is not null)
|
||||
{
|
||||
return ImportSwagger2(root);
|
||||
}
|
||||
|
||||
if (root["groups"] is not null &&
|
||||
root["info"] is not null)
|
||||
{
|
||||
return JsonSerializer.Deserialize<ApiDocument>(root)!
|
||||
?? throw new InvalidOperationException();
|
||||
}
|
||||
|
||||
throw new NotSupportedException(
|
||||
"Supported formats: DoxaApi, OpenAPI 3.x, Swagger 2.0");
|
||||
}
|
||||
|
||||
private static ApiDocument ImportOpenApi3(JsonNode root)
|
||||
{
|
||||
var doc = new ApiDocument();
|
||||
|
||||
if (root["info"] is JsonObject info)
|
||||
{
|
||||
doc.Info.Title = info["title"]?.GetValue<string>() ?? "API";
|
||||
doc.Info.Version = info["version"]?.GetValue<string>() ?? "v1";
|
||||
doc.Info.Description = info["description"]?.GetValue<string>();
|
||||
}
|
||||
|
||||
if (root["servers"] is JsonArray servers)
|
||||
{
|
||||
foreach (var s in servers)
|
||||
{
|
||||
if (s?["url"]?.GetValue<string>() is string url)
|
||||
{
|
||||
doc.Servers.Add(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (root["components"]?["schemas"] is JsonObject compSchemas)
|
||||
{
|
||||
foreach (var (name, schemaNode) in compSchemas)
|
||||
{
|
||||
if (schemaNode is not null)
|
||||
{
|
||||
doc.Schemas[name] = ParseSchema3(schemaNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var groups = new Dictionary<string, ApiGroup>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (root["paths"] is JsonObject paths)
|
||||
{
|
||||
foreach (var (rawPath, pathNode) in paths)
|
||||
{
|
||||
if (pathNode is not JsonObject pathItem)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var (methodStr, opNode) in pathItem)
|
||||
{
|
||||
if (opNode is not JsonObject op)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsHttpMethod(methodStr))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var endpoint = ParseOperation3(op, rawPath, methodStr.ToUpperInvariant());
|
||||
var groupName = endpoint.Tags.FirstOrDefault() ?? "Default";
|
||||
|
||||
if (!groups.TryGetValue(groupName, out var group))
|
||||
{
|
||||
group = new ApiGroup { Name = groupName };
|
||||
groups[groupName] = group;
|
||||
}
|
||||
group.Endpoints.Add(endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doc.Groups = groups.Values
|
||||
.OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static ApiEndpoint ParseOperation3(JsonObject op, string path, string method)
|
||||
{
|
||||
var endpoint = new ApiEndpoint
|
||||
{
|
||||
OperationId = op["operationId"]?.GetValue<string>() ?? $"{method}_{SanitizePath(path)}",
|
||||
Summary = op["summary"]?.GetValue<string>(),
|
||||
Description = op["description"]?.GetValue<string>(),
|
||||
Method = method,
|
||||
Path = path,
|
||||
Deprecated = op["deprecated"]?.GetValue<bool>() ?? false,
|
||||
Tags = ParseStringArray(op["tags"])
|
||||
};
|
||||
|
||||
if (op["parameters"] is JsonArray parameters)
|
||||
{
|
||||
foreach (var p in parameters)
|
||||
{
|
||||
if (p is not JsonObject param)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var schema = p["schema"] is JsonNode schemaNode
|
||||
? ParseSchema3(schemaNode)
|
||||
: new SchemaModel { Type = "string" };
|
||||
|
||||
endpoint.Parameters.Add(new ApiParameter
|
||||
{
|
||||
Name = param["name"]?.GetValue<string>() ?? "",
|
||||
In = param["in"]?.GetValue<string>() ?? "query",
|
||||
Required = param["required"]?.GetValue<bool>() ?? false,
|
||||
Description = param["description"]?.GetValue<string>(),
|
||||
Schema = schema
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (op["requestBody"] is JsonObject rb)
|
||||
{
|
||||
var required = rb["required"]?.GetValue<bool>() ?? false;
|
||||
var contentNode = rb["content"] as JsonObject;
|
||||
|
||||
var (contentType, mediaObj) = PickMediaType(contentNode);
|
||||
|
||||
SchemaModel schema;
|
||||
string? example = null;
|
||||
|
||||
if (mediaObj is JsonObject mo)
|
||||
{
|
||||
schema = mo["schema"] is JsonNode s ? ParseSchema3(s) : new SchemaModel { Type = "object" };
|
||||
example = mo["example"]?.ToJsonString();
|
||||
}
|
||||
else
|
||||
{
|
||||
schema = new SchemaModel { Type = "object" };
|
||||
}
|
||||
|
||||
endpoint.RequestBody = new RequestBodyModel
|
||||
{
|
||||
Required = required,
|
||||
ContentType = contentType,
|
||||
Schema = schema,
|
||||
Example = example
|
||||
};
|
||||
}
|
||||
|
||||
if (op["responses"] is JsonObject responses)
|
||||
{
|
||||
foreach (var (statusCode, respNode) in responses)
|
||||
{
|
||||
if (respNode is not JsonObject resp)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
SchemaModel? schema = null;
|
||||
if (resp["content"] is JsonObject content)
|
||||
{
|
||||
var (_, mediaObj) = PickMediaType(content);
|
||||
if (mediaObj?["schema"] is JsonNode s)
|
||||
{
|
||||
schema = ParseSchema3(s);
|
||||
}
|
||||
}
|
||||
|
||||
endpoint.Responses.Add(new ResponseModel
|
||||
{
|
||||
StatusCode = statusCode,
|
||||
Description = resp["description"]?.GetValue<string>(),
|
||||
Schema = schema
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
private static SchemaModel ParseSchema3(JsonNode node)
|
||||
{
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
return new SchemaModel { Type = "object" };
|
||||
}
|
||||
|
||||
if (obj["$ref"]?.GetValue<string>() is string refVal)
|
||||
{
|
||||
var refName = refVal.Split('/').Last();
|
||||
return new SchemaModel { Type = "object", RefName = refName };
|
||||
}
|
||||
|
||||
bool nullable = obj["nullable"]?.GetValue<bool>() ?? false;
|
||||
|
||||
if (obj["enum"] is JsonArray enumArray)
|
||||
{
|
||||
return new SchemaModel
|
||||
{
|
||||
Type = "enum",
|
||||
EnumValues = enumArray.Select(e => e?.GetValue<string>() ?? "").ToList(),
|
||||
Nullable = nullable
|
||||
};
|
||||
}
|
||||
|
||||
var type = obj["type"]?.GetValue<string>() ?? "object";
|
||||
|
||||
if (type == "array")
|
||||
{
|
||||
return new SchemaModel
|
||||
{
|
||||
Type = "array",
|
||||
Items = obj["items"] is JsonNode items ? ParseSchema3(items) : null,
|
||||
Nullable = nullable
|
||||
};
|
||||
}
|
||||
|
||||
if (type == "object")
|
||||
{
|
||||
Dictionary<string, SchemaModel>? props = null;
|
||||
if (obj["properties"] is JsonObject propsNode)
|
||||
{
|
||||
props = new Dictionary<string, SchemaModel>();
|
||||
foreach (var (name, propNode) in propsNode)
|
||||
{
|
||||
if (propNode is not null)
|
||||
{
|
||||
props[name] = ParseSchema3(propNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SchemaModel? dictItems = null;
|
||||
if (obj["additionalProperties"] is JsonNode addProps)
|
||||
{
|
||||
dictItems = ParseSchema3(addProps);
|
||||
}
|
||||
|
||||
return new SchemaModel
|
||||
{
|
||||
Type = "object",
|
||||
Properties = props,
|
||||
Required = ParseStringList(obj["required"]),
|
||||
Items = dictItems,
|
||||
Nullable = nullable
|
||||
};
|
||||
}
|
||||
|
||||
return new SchemaModel
|
||||
{
|
||||
Type = type,
|
||||
Format = obj["format"]?.GetValue<string>(),
|
||||
Nullable = nullable
|
||||
};
|
||||
}
|
||||
|
||||
private static ApiDocument ImportSwagger2(JsonNode root)
|
||||
{
|
||||
var doc = new ApiDocument();
|
||||
|
||||
if (root["info"] is JsonObject info)
|
||||
{
|
||||
doc.Info.Title = info["title"]?.GetValue<string>() ?? "API";
|
||||
doc.Info.Version = info["version"]?.GetValue<string>() ?? "v1";
|
||||
doc.Info.Description = info["description"]?.GetValue<string>();
|
||||
}
|
||||
|
||||
var host = root["host"]?.GetValue<string>();
|
||||
var basePath = root["basePath"]?.GetValue<string>() ?? "/";
|
||||
var scheme = root["schemes"] is JsonArray schemes && schemes.Count > 0
|
||||
? schemes[0]?.GetValue<string>() ?? "https"
|
||||
: "https";
|
||||
|
||||
if (host is not null)
|
||||
{
|
||||
doc.Servers.Add($"{scheme}://{host}{basePath.TrimEnd('/')}");
|
||||
}
|
||||
|
||||
if (root["definitions"] is JsonObject defs)
|
||||
{
|
||||
foreach (var (name, defNode) in defs)
|
||||
{
|
||||
if (defNode is not null)
|
||||
{
|
||||
doc.Schemas[name] = ParseSchema2(defNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var groups = new Dictionary<string, ApiGroup>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (root["paths"] is JsonObject paths)
|
||||
{
|
||||
foreach (var (rawPath, pathNode) in paths)
|
||||
{
|
||||
if (pathNode is not JsonObject pathItem)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var (methodStr, opNode) in pathItem)
|
||||
{
|
||||
if (opNode is not JsonObject op)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsHttpMethod(methodStr))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var endpoint = ParseOperation2(op, rawPath, methodStr.ToUpperInvariant());
|
||||
var groupName = endpoint.Tags.FirstOrDefault() ?? "Default";
|
||||
|
||||
if (!groups.TryGetValue(groupName, out var group))
|
||||
{
|
||||
group = new ApiGroup { Name = groupName };
|
||||
groups[groupName] = group;
|
||||
}
|
||||
group.Endpoints.Add(endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doc.Groups = groups.Values
|
||||
.OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static ApiEndpoint ParseOperation2(JsonObject op, string path, string method)
|
||||
{
|
||||
var endpoint = new ApiEndpoint
|
||||
{
|
||||
OperationId = op["operationId"]?.GetValue<string>() ?? $"{method}_{SanitizePath(path)}",
|
||||
Summary = op["summary"]?.GetValue<string>(),
|
||||
Description = op["description"]?.GetValue<string>(),
|
||||
Method = method,
|
||||
Path = path,
|
||||
Deprecated = op["deprecated"]?.GetValue<bool>() ?? false,
|
||||
Tags = ParseStringArray(op["tags"])
|
||||
};
|
||||
|
||||
if (op["parameters"] is JsonArray parameters)
|
||||
{
|
||||
foreach (var p in parameters)
|
||||
{
|
||||
if (p is not JsonObject param)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var inLoc = param["in"]?.GetValue<string>() ?? "query";
|
||||
|
||||
if (inLoc == "body")
|
||||
{
|
||||
endpoint.RequestBody = new RequestBodyModel
|
||||
{
|
||||
Required = param["required"]?.GetValue<bool>() ?? false,
|
||||
ContentType = "application/json",
|
||||
Schema = param["schema"] is JsonNode s ? ParseSchema2(s) : new SchemaModel { Type = "object" }
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
endpoint.Parameters.Add(new ApiParameter
|
||||
{
|
||||
Name = param["name"]?.GetValue<string>() ?? "",
|
||||
In = inLoc,
|
||||
Required = param["required"]?.GetValue<bool>() ?? false,
|
||||
Description = param["description"]?.GetValue<string>(),
|
||||
Schema = ParseInlineSchema2(param)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (op["responses"] is JsonObject responses)
|
||||
{
|
||||
foreach (var (statusCode, respNode) in responses)
|
||||
{
|
||||
if (respNode is not JsonObject resp)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
SchemaModel? schema = null;
|
||||
if (resp["schema"] is JsonNode s)
|
||||
{
|
||||
schema = ParseSchema2(s);
|
||||
}
|
||||
|
||||
endpoint.Responses.Add(new ResponseModel
|
||||
{
|
||||
StatusCode = statusCode,
|
||||
Description = resp["description"]?.GetValue<string>(),
|
||||
Schema = schema
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
private static SchemaModel ParseInlineSchema2(JsonObject param)
|
||||
{
|
||||
if (param["$ref"]?.GetValue<string>() is string refVal)
|
||||
{
|
||||
return new SchemaModel { Type = "object", RefName = refVal.Split('/').Last() };
|
||||
}
|
||||
|
||||
if (param["enum"] is JsonArray enumArray)
|
||||
{
|
||||
return new SchemaModel
|
||||
{
|
||||
Type = "enum",
|
||||
EnumValues = enumArray.Select(e => e?.GetValue<string>() ?? "").ToList()
|
||||
};
|
||||
}
|
||||
|
||||
var type = param["type"]?.GetValue<string>() ?? "string";
|
||||
if (type == "array")
|
||||
{
|
||||
return new SchemaModel
|
||||
{
|
||||
Type = "array",
|
||||
Items = param["items"] is JsonNode items ? ParseInlineSchema2((JsonObject)items) : null
|
||||
};
|
||||
}
|
||||
|
||||
return new SchemaModel
|
||||
{
|
||||
Type = type,
|
||||
Format = param["format"]?.GetValue<string>()
|
||||
};
|
||||
}
|
||||
|
||||
private static SchemaModel ParseSchema2(JsonNode node)
|
||||
{
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
return new SchemaModel { Type = "object" };
|
||||
}
|
||||
|
||||
if (obj["$ref"]?.GetValue<string>() is string refVal)
|
||||
{
|
||||
return new SchemaModel { Type = "object", RefName = refVal.Split('/').Last() };
|
||||
}
|
||||
|
||||
if (obj["enum"] is JsonArray enumArray)
|
||||
{
|
||||
return new SchemaModel
|
||||
{
|
||||
Type = "enum",
|
||||
EnumValues = enumArray.Select(e => e?.GetValue<string>() ?? "").ToList()
|
||||
};
|
||||
}
|
||||
|
||||
var type = obj["type"]?.GetValue<string>() ?? "object";
|
||||
|
||||
if (type == "array")
|
||||
{
|
||||
return new SchemaModel
|
||||
{
|
||||
Type = "array",
|
||||
Items = obj["items"] is JsonNode items ? ParseSchema2(items) : null
|
||||
};
|
||||
}
|
||||
|
||||
if (type == "object")
|
||||
{
|
||||
Dictionary<string, SchemaModel>? props = null;
|
||||
if (obj["properties"] is JsonObject propsNode)
|
||||
{
|
||||
props = new Dictionary<string, SchemaModel>();
|
||||
foreach (var (name, propNode) in propsNode)
|
||||
{
|
||||
if (propNode is not null)
|
||||
{
|
||||
props[name] = ParseSchema2(propNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SchemaModel? dictItems = null;
|
||||
if (obj["additionalProperties"] is JsonNode addProps)
|
||||
{
|
||||
dictItems = ParseSchema2(addProps);
|
||||
}
|
||||
|
||||
return new SchemaModel
|
||||
{
|
||||
Type = "object",
|
||||
Properties = props,
|
||||
Required = ParseStringList(obj["required"]),
|
||||
Items = dictItems
|
||||
};
|
||||
}
|
||||
|
||||
return new SchemaModel
|
||||
{
|
||||
Type = type,
|
||||
Format = obj["format"]?.GetValue<string>()
|
||||
};
|
||||
}
|
||||
|
||||
private static (string contentType, JsonObject? mediaObj) PickMediaType(JsonObject? content)
|
||||
{
|
||||
if (content is null)
|
||||
{
|
||||
return ("application/json", null);
|
||||
}
|
||||
|
||||
if (content["application/json"] is JsonObject json)
|
||||
{
|
||||
return ("application/json", json);
|
||||
}
|
||||
|
||||
foreach (var (ct, node) in content)
|
||||
{
|
||||
if (node is JsonObject obj)
|
||||
{
|
||||
return (ct, obj);
|
||||
}
|
||||
}
|
||||
|
||||
return ("application/json", null);
|
||||
}
|
||||
|
||||
private static List<string> ParseStringArray(JsonNode? node)
|
||||
{
|
||||
var list = new List<string>();
|
||||
if (node is JsonArray arr)
|
||||
{
|
||||
foreach (var item in arr)
|
||||
{
|
||||
if (item?.GetValue<string>() is string s)
|
||||
{
|
||||
list.Add(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static List<string>? ParseStringList(JsonNode? node)
|
||||
{
|
||||
if (node is not JsonArray arr || arr.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return arr.Select(e => e?.GetValue<string>() ?? "").ToList();
|
||||
}
|
||||
|
||||
private static bool IsHttpMethod(string s) =>
|
||||
s is "get" or "post" or "put" or "patch" or "delete" or "head" or "options" or "trace";
|
||||
|
||||
private static string SanitizePath(string path)
|
||||
=> path.Trim('/').Replace('/', '_').Replace('{', '_').Replace('}', '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using EonaCat.DoxaApi.Generation;
|
||||
using EonaCat.DoxaApi.Interop;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace EonaCat.DoxaApi.Middleware
|
||||
{
|
||||
public static class DoxaApiMiddlewareExtensions
|
||||
{
|
||||
private static readonly JsonSerializerOptions _writeOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public static IApplicationBuilder UseDoxaApi(this IApplicationBuilder app, Action<DoxaApiOptions>? configure = null)
|
||||
{
|
||||
|
||||
var options = app.ApplicationServices.GetService<DoxaApiOptions>() ?? new DoxaApiOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
var prefix = "/" + options.RoutePrefix.Trim('/');
|
||||
|
||||
app.Map(prefix + "/doxaApi.json", specApp =>
|
||||
{
|
||||
specApp.Run(async context =>
|
||||
{
|
||||
var document = GenerateDocument(context, options);
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, document, _writeOptions);
|
||||
});
|
||||
});
|
||||
|
||||
app.Map(prefix + "/openapi.json", oaApp =>
|
||||
{
|
||||
oaApp.Run(async context =>
|
||||
{
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
app.Map(prefix + "/swagger.json", swApp =>
|
||||
{
|
||||
swApp.Run(async context =>
|
||||
{
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
app.Map(prefix + "/import", importApp =>
|
||||
{
|
||||
importApp.Run(async context =>
|
||||
{
|
||||
if (!HttpMethods.IsPost(context.Request.Method))
|
||||
{
|
||||
context.Response.StatusCode = 405;
|
||||
context.Response.Headers["Allow"] = "POST";
|
||||
await context.Response.WriteAsync("Method Not Allowed - use POST with a JSON body.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.Request.ContentLength == 0 || context.Request.Body is null)
|
||||
{
|
||||
context.Response.StatusCode = 400;
|
||||
await context.Response.WriteAsync("Request body is empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var imported = await OpenApiImporter.ImportAsync(context.Request.Body);
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, imported, _writeOptions);
|
||||
}
|
||||
catch (NotSupportedException ex)
|
||||
{
|
||||
context.Response.StatusCode = 400;
|
||||
await context.Response.WriteAsync($"Unsupported spec format: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Response.StatusCode = 400;
|
||||
await context.Response.WriteAsync($"Failed to parse spec: {ex.Message}");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.Map(prefix, uiApp =>
|
||||
{
|
||||
uiApp.Run(async context =>
|
||||
{
|
||||
var requestPath = context.Request.Path.Value ?? "";
|
||||
var assetName = requestPath.Trim('/');
|
||||
if (string.IsNullOrEmpty(assetName) || assetName == options.RoutePrefix.Trim('/'))
|
||||
{
|
||||
assetName = "index.html";
|
||||
}
|
||||
|
||||
var asset = EmbeddedAssetLoader.Load(assetName);
|
||||
if (asset is null)
|
||||
{
|
||||
context.Response.StatusCode = 404;
|
||||
await context.Response.WriteAsync("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.ContentType = asset.Value.ContentType;
|
||||
|
||||
if (assetName == "index.html")
|
||||
{
|
||||
var html = Encoding.UTF8.GetString(asset.Value.Bytes);
|
||||
html = html.Replace("__DOXA_API_TITLE__", options.Title)
|
||||
.Replace("__DOXA_API_ACCENT__", options.AccentColor)
|
||||
.Replace("__DOXA_API_THEME__", options.Theme)
|
||||
.Replace("__DOXA_API_BASE__", prefix + "/")
|
||||
.Replace("__DOXA_API_SPEC_PATH__", prefix + "/doxaApi.json");
|
||||
await context.Response.WriteAsync(html, Encoding.UTF8);
|
||||
return;
|
||||
}
|
||||
|
||||
await context.Response.Body.WriteAsync(asset.Value.Bytes);
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static Models.ApiDocument GenerateDocument(HttpContext context, DoxaApiOptions options)
|
||||
{
|
||||
var provider = context.RequestServices.GetRequiredService<IActionDescriptorCollectionProvider>();
|
||||
var actions = provider.ActionDescriptors.Items;
|
||||
return new ApiDocumentGenerator(actions, options).Generate();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class EmbeddedAssetLoader
|
||||
{
|
||||
private static readonly Assembly _assembly = typeof(EmbeddedAssetLoader).Assembly;
|
||||
private static readonly string _prefix = "DoxaApi.UI.Assets.";
|
||||
|
||||
public static (byte[] Bytes, string ContentType)? Load(string assetName)
|
||||
{
|
||||
var resourceName = _prefix + assetName.Replace('/', '.');
|
||||
using var stream = _assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
stream.CopyTo(ms);
|
||||
|
||||
var contentType = Path.GetExtension(assetName) switch
|
||||
{
|
||||
".html" => "text/html; charset=utf-8",
|
||||
".css" => "text/css; charset=utf-8",
|
||||
".js" => "application/javascript; charset=utf-8",
|
||||
".svg" => "image/svg+xml",
|
||||
".png" => "image/png",
|
||||
".ico" => "image/x-icon",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
|
||||
return (ms.ToArray(), contentType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace EonaCat.DoxaApi.Models
|
||||
{
|
||||
public sealed class ApiDocument
|
||||
{
|
||||
[JsonPropertyName("info")]
|
||||
public ApiInfo Info { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("servers")]
|
||||
public List<string> Servers { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("groups")]
|
||||
public List<ApiGroup> Groups { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("schemas")]
|
||||
public Dictionary<string, SchemaModel> Schemas { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace EonaCat.DoxaApi.Models
|
||||
{
|
||||
public sealed class ApiEndpoint
|
||||
{
|
||||
[JsonPropertyName("operationId")]
|
||||
public string OperationId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public string? Summary { get; set; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonPropertyName("method")]
|
||||
public string Method { get; set; } = "GET";
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; set; } = "/";
|
||||
|
||||
[JsonPropertyName("deprecated")]
|
||||
public bool Deprecated { get; set; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public List<string> Tags { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("parameters")]
|
||||
public List<ApiParameter> Parameters { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("requestBody")]
|
||||
public RequestBodyModel? RequestBody { get; set; }
|
||||
|
||||
[JsonPropertyName("responses")]
|
||||
public List<ResponseModel> Responses { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace EonaCat.DoxaApi.Models
|
||||
{
|
||||
public sealed class ApiGroup
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonPropertyName("endpoints")]
|
||||
public List<ApiEndpoint> Endpoints { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace EonaCat.DoxaApi.Models
|
||||
{
|
||||
public sealed class ApiInfo
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = "API Documentation";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; set; } = "v1";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace EonaCat.DoxaApi.Models
|
||||
{
|
||||
public sealed class ApiParameter
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("in")]
|
||||
public string In { get; set; } = "query";
|
||||
|
||||
[JsonPropertyName("required")]
|
||||
public bool Required { get; set; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonPropertyName("schema")]
|
||||
public SchemaModel Schema { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("default")]
|
||||
public object? Default { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace EonaCat.DoxaApi.Models
|
||||
{
|
||||
public sealed class RequestBodyModel
|
||||
{
|
||||
[JsonPropertyName("required")]
|
||||
public bool Required { get; set; }
|
||||
|
||||
[JsonPropertyName("contentType")]
|
||||
public string ContentType { get; set; } = "application/json";
|
||||
|
||||
[JsonPropertyName("schema")]
|
||||
public SchemaModel Schema { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("example")]
|
||||
public string? Example { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace EonaCat.DoxaApi.Models
|
||||
{
|
||||
public sealed class ResponseModel
|
||||
{
|
||||
[JsonPropertyName("statusCode")]
|
||||
public string StatusCode { get; set; } = "200";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonPropertyName("schema")]
|
||||
public SchemaModel? Schema { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace EonaCat.DoxaApi.Models
|
||||
{
|
||||
public sealed class SchemaModel
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = "object";
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; set; }
|
||||
|
||||
[JsonPropertyName("refName")]
|
||||
public string? RefName { get; set; }
|
||||
|
||||
[JsonPropertyName("items")]
|
||||
public SchemaModel? Items { get; set; }
|
||||
|
||||
[JsonPropertyName("properties")]
|
||||
public Dictionary<string, SchemaModel>? Properties { get; set; }
|
||||
|
||||
[JsonPropertyName("required")]
|
||||
public List<string>? Required { get; set; }
|
||||
|
||||
[JsonPropertyName("enumValues")]
|
||||
public List<string>? EnumValues { get; set; }
|
||||
|
||||
[JsonPropertyName("nullable")]
|
||||
public bool Nullable { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using EonaCat.DoxaApi.Generation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace EonaCat.DoxaApi
|
||||
{
|
||||
public static class DoxaApiServiceCollectionExtensions
|
||||
{
|
||||
|
||||
public static IServiceCollection AddDoxaApi(this IServiceCollection services, Action<DoxaApiOptions>? configure = null)
|
||||
{
|
||||
var options = new DoxaApiOptions();
|
||||
configure?.Invoke(options);
|
||||
services.AddSingleton(options);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,855 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const SPEC_URL = window.__DOXA_API_SPEC_URL__ || "doxaApi.json";
|
||||
|
||||
let apis = [];
|
||||
let activeApiIndex = 0;
|
||||
let spec = null;
|
||||
let activeEndpoint = null;
|
||||
let activeTryTab = "body";
|
||||
let collapsedGroups = new Set(JSON.parse(localStorage.getItem("DoxaApi.collapsed") || "[]"));
|
||||
|
||||
const el = {
|
||||
nav: document.getElementById("navContent"),
|
||||
detail: document.getElementById("detailContent"),
|
||||
try: document.getElementById("tryContent"),
|
||||
search: document.getElementById("searchInput"),
|
||||
themeToggle: document.getElementById("themeToggle"),
|
||||
brandTitle: document.getElementById("brandTitle"),
|
||||
brandVersion: document.getElementById("brandVersion"),
|
||||
apiSelector: document.getElementById("apiSelector"),
|
||||
|
||||
};
|
||||
|
||||
function refreshApiSelector(){
|
||||
if(!el.apiSelector) return;
|
||||
el.apiSelector.innerHTML = apis.map((a,i)=>`<option value="${i}">${(a.info&&a.info.title)||('API '+(i+1))}</option>`).join('');
|
||||
el.apiSelector.value=String(activeApiIndex);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
renderSkeleton();
|
||||
try {
|
||||
const res = await fetch(SPEC_URL);
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
|
||||
const importedSpec = await res.json();
|
||||
const extend = apis.length > 0 && window.confirm("Extend the currently selected API?\n\nOK = Extend Current API\nCancel = Import As New API (default)");
|
||||
|
||||
if (extend) {
|
||||
spec.groups = [...(spec.groups||[]), ...(importedSpec.groups||[])];
|
||||
spec.schemas = Object.assign(spec.schemas||{}, importedSpec.schemas||{});
|
||||
} else {
|
||||
apis.push(importedSpec);
|
||||
activeApiIndex = apis.length - 1;
|
||||
spec = importedSpec;
|
||||
}
|
||||
|
||||
refreshApiSelector();
|
||||
if(spec.info && spec.info.title) el.brandTitle.textContent = spec.info.title;
|
||||
if(spec.info && spec.info.version) el.brandVersion.textContent = spec.info.version;
|
||||
|
||||
} catch (err) {
|
||||
el.nav.innerHTML = `<div class="nav-empty">Couldn't load doxaApi.json<br/><span style="color:var(--text-2)">${escapeHtml(String(err))}</span></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (spec.info && spec.info.title) el.brandTitle.textContent = spec.info.title;
|
||||
if (spec.info && spec.info.version) el.brandVersion.textContent = spec.info.version;
|
||||
document.title = (spec.info && spec.info.title) || "API Documentation";
|
||||
|
||||
renderNav("");
|
||||
renderOverview();
|
||||
renderTryEmpty();
|
||||
bindGlobalEvents();
|
||||
}
|
||||
|
||||
function renderSkeleton() {
|
||||
el.nav.innerHTML = Array.from({ length: 6 })
|
||||
.map(() => `<div class="skeleton" style="height:14px;margin:10px 12px;border-radius:4px;"></div>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function totalEndpointCount() {
|
||||
return (spec.groups || []).reduce((n, g) => n + g.endpoints.length, 0);
|
||||
}
|
||||
|
||||
function totalSchemaCount() {
|
||||
return Object.keys(spec.schemas || {}).length;
|
||||
}
|
||||
|
||||
// Nav rendering
|
||||
function renderNav(filterText) {
|
||||
const term = filterText.trim().toLowerCase();
|
||||
const groups = spec.groups || [];
|
||||
|
||||
let html = `<button class="nav-overview-link ${!activeEndpoint ? "active" : ""}" data-overview="1">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M4 5H20M4 12H20M4 19H12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<span>Overview</span>
|
||||
</button>`;
|
||||
|
||||
if (groups.length === 0) {
|
||||
html += `<div class="nav-empty">No endpoints found.</div>`;
|
||||
el.nav.innerHTML = html;
|
||||
return;
|
||||
}
|
||||
|
||||
let totalMatches = 0;
|
||||
let groupHtml = "";
|
||||
|
||||
for (const group of groups) {
|
||||
const endpoints = group.endpoints.filter((e) => matchesFilter(e, term));
|
||||
if (term && endpoints.length === 0) continue;
|
||||
totalMatches += endpoints.length;
|
||||
|
||||
const isCollapsed = collapsedGroups.has(group.name) && !term;
|
||||
|
||||
groupHtml += `
|
||||
<div class="nav-group ${isCollapsed ? "collapsed" : ""}" data-group="${escapeAttr(group.name)}">
|
||||
<button class="nav-group-header" data-toggle-group="${escapeAttr(group.name)}">
|
||||
<svg class="chev" viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span>${escapeHtml(group.name)}</span>
|
||||
<span class="nav-group-count">${group.endpoints.length}</span>
|
||||
</button>
|
||||
<div class="nav-endpoints">
|
||||
${endpoints.map((e) => navEndpointHtml(group, e)).join("")}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (term && totalMatches === 0) {
|
||||
html += `<div class="nav-empty">No endpoints match "${escapeHtml(filterText)}"</div>`;
|
||||
} else {
|
||||
html += groupHtml;
|
||||
}
|
||||
|
||||
el.nav.innerHTML = html;
|
||||
}
|
||||
|
||||
function navEndpointHtml(group, endpoint) {
|
||||
const isActive =
|
||||
activeEndpoint &&
|
||||
activeEndpoint.endpoint.operationId === endpoint.operationId;
|
||||
return `
|
||||
<button class="nav-endpoint ${isActive ? "active" : ""} ${endpoint.deprecated ? "deprecated" : ""}"
|
||||
style="--method-color: var(--m-${endpoint.method.toLowerCase()}, var(--m-default))"
|
||||
data-op="${escapeAttr(endpoint.operationId)}">
|
||||
<span class="method-tag">${endpoint.method}</span>
|
||||
<span class="nav-endpoint-path">${escapeHtml(endpoint.path)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function matchesFilter(endpoint, term) {
|
||||
if (!term) return true;
|
||||
return (
|
||||
endpoint.path.toLowerCase().includes(term) ||
|
||||
(endpoint.summary || "").toLowerCase().includes(term) ||
|
||||
endpoint.method.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
// Overview / welcome screen
|
||||
function renderOverview() {
|
||||
const info = spec.info || {};
|
||||
const groups = spec.groups || [];
|
||||
|
||||
let html = `<div class="fade-in">`;
|
||||
html += `<div class="overview-hero">`;
|
||||
html += `<div class="overview-eyebrow">
|
||||
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/><path d="M12 7V12L15 15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
API reference
|
||||
</div>`;
|
||||
html += `<h1 class="overview-title">${escapeHtml(info.title || "API Documentation")}</h1>`;
|
||||
if (info.description) {
|
||||
html += `<p class="overview-desc">${escapeHtml(info.description)}</p>`;
|
||||
}
|
||||
html += `<div class="overview-meta">`;
|
||||
if (info.version) {
|
||||
html += `<span class="overview-pill">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M3 12L12 3L21 12M5 10V20H19V10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
${escapeHtml(info.version)}
|
||||
</span>`;
|
||||
}
|
||||
if (spec.servers && spec.servers.length) {
|
||||
html += `<span class="overview-pill">
|
||||
<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" stroke-width="2"/><path d="M7 9H17M7 13H13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
${escapeHtml(spec.servers[0])}
|
||||
</span>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
html += `</div>`; // hero
|
||||
|
||||
html += `<div class="overview-stats">`;
|
||||
html += `<div class="overview-stat"><div class="overview-stat-value">${groups.length}</div><div class="overview-stat-label">Groups</div></div>`;
|
||||
html += `<div class="overview-stat"><div class="overview-stat-value">${totalEndpointCount()}</div><div class="overview-stat-label">Endpoints</div></div>`;
|
||||
html += `<div class="overview-stat"><div class="overview-stat-value">${totalSchemaCount()}</div><div class="overview-stat-label">Schemas</div></div>`;
|
||||
html += `</div>`;
|
||||
|
||||
html += `<h3 class="overview-section-title">Browse by group</h3>`;
|
||||
for (const group of groups) {
|
||||
html += `<div class="overview-group-card">
|
||||
<div class="overview-group-card-header">
|
||||
<span>${escapeHtml(group.name)}</span>
|
||||
<span class="nav-group-count">${group.endpoints.length}</span>
|
||||
</div>
|
||||
<div class="overview-group-routes">`;
|
||||
for (const ep of group.endpoints) {
|
||||
html += `<div class="overview-route-row" data-op="${escapeAttr(ep.operationId)}"
|
||||
style="--method-color: var(--m-${ep.method.toLowerCase()}, var(--m-default))">
|
||||
<span class="method-badge">${ep.method}</span>
|
||||
<span class="route-path">${escapeHtml(ep.path)}</span>
|
||||
<span class="route-summary">${escapeHtml(ep.summary || "")}</span>
|
||||
<svg class="route-arrow" viewBox="0 0 24 24" fill="none"><path d="M5 12H19M19 12L13 6M19 12L13 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
el.detail.innerHTML = html;
|
||||
el.detail.scrollTop = 0;
|
||||
}
|
||||
|
||||
function renderTryEmpty() {
|
||||
el.try.innerHTML = `<div class="try-empty">
|
||||
<svg viewBox="0 0 24 24" fill="none" style="margin-left:auto;margin-right:auto;display:block;"><path d="M5 12H19M19 12L13 6M19 12L13 18" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Pick an endpoint to send a live request.
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function selectEndpoint(group, endpoint) {
|
||||
activeEndpoint = { group, endpoint };
|
||||
activeTryTab = "body";
|
||||
document.querySelectorAll(".nav-endpoint").forEach((b) => {
|
||||
b.classList.toggle("active", b.dataset.op === endpoint.operationId);
|
||||
});
|
||||
const overviewLink = document.querySelector(".nav-overview-link");
|
||||
if (overviewLink) overviewLink.classList.remove("active");
|
||||
renderDetail(group, endpoint);
|
||||
renderTry(group, endpoint);
|
||||
}
|
||||
|
||||
function renderDetail(group, endpoint) {
|
||||
const methodColorVar = `var(--m-${endpoint.method.toLowerCase()}, var(--m-default))`;
|
||||
|
||||
let html = `<div class="fade-in">`;
|
||||
html += `<div class="endpoint-header">`;
|
||||
html += `<div class="breadcrumb">
|
||||
<span>${escapeHtml(group.name)}</span>
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M9 6L15 12L9 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span class="current">${escapeHtml(endpoint.summary || endpoint.operationId)}</span>
|
||||
</div>`;
|
||||
html += `<div class="request-line">
|
||||
<span class="method-badge" style="--method-color:${methodColorVar}">${endpoint.method}</span>
|
||||
<span class="endpoint-path">${escapeHtml(endpoint.path)}</span>
|
||||
<button class="copy-route-btn" id="copyRouteBtn" title="Copy path" aria-label="Copy path">
|
||||
<svg viewBox="0 0 24 24" fill="none"><rect x="8" y="8" width="12" height="12" rx="2" stroke="currentColor" stroke-width="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" stroke="currentColor" stroke-width="2"/></svg>
|
||||
</button>
|
||||
</div>`;
|
||||
html += `<h1 class="endpoint-summary">${escapeHtml(endpoint.summary || endpoint.operationId)}</h1>`;
|
||||
if (endpoint.description) {
|
||||
html += `<p class="endpoint-description">${escapeHtml(endpoint.description)}</p>`;
|
||||
}
|
||||
if (endpoint.deprecated) {
|
||||
html += `<div class="deprecated-banner">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M12 9V13M12 17H12.01M10.29 3.86L1.82 18A2 2 0 003.54 21H20.46A2 2 0 0022.18 18L13.71 3.86A2 2 0 0010.29 3.86Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Deprecated - this endpoint may be removed in a future version
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
|
||||
if (endpoint.parameters && endpoint.parameters.length > 0) {
|
||||
html += `<div class="section"><h3 class="section-title">Parameters <span class="count">${endpoint.parameters.length}</span></h3>`;
|
||||
html += `<table class="param-table"><thead><tr><th>Name</th><th>Located in</th><th>Type</th><th>Description</th></tr></thead><tbody>`;
|
||||
for (const p of endpoint.parameters) {
|
||||
html += `<tr>
|
||||
<td><span class="param-name">${escapeHtml(p.name)}${p.required ? '<span class="param-required">*</span>' : ""}</span></td>
|
||||
<td><span class="param-loc">${p.in}</span></td>
|
||||
<td><span class="param-type">${schemaTypeLabel(p.schema)}</span></td>
|
||||
<td><span class="param-desc">${escapeHtml(p.description || "-")}</span></td>
|
||||
</tr>`;
|
||||
}
|
||||
html += `</tbody></table></div>`;
|
||||
}
|
||||
|
||||
if (endpoint.requestBody) {
|
||||
html += `<div class="section"><h3 class="section-title">Request body</h3>`;
|
||||
html += `<div class="schema-box">${renderSchemaTree(endpoint.requestBody.schema, 0)}</div></div>`;
|
||||
}
|
||||
|
||||
if (endpoint.responses && endpoint.responses.length > 0) {
|
||||
html += `<div class="section"><h3 class="section-title">Responses <span class="count">${endpoint.responses.length}</span></h3>`;
|
||||
for (const r of endpoint.responses) {
|
||||
const cls = r.statusCode[0] === "2" ? "status-2xx" : r.statusCode[0] === "4" ? "status-4xx" : "status-5xx";
|
||||
html += `<div class="response-block">
|
||||
<div class="response-block-header">
|
||||
<span class="status-pill ${cls}">${r.statusCode}</span>
|
||||
<span class="response-desc">${escapeHtml(r.description || "")}</span>
|
||||
</div>`;
|
||||
if (r.schema) {
|
||||
html += `<div class="response-block-body"><div class="schema-box">${renderSchemaTree(r.schema, 0)}</div></div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
el.detail.innerHTML = html;
|
||||
el.detail.scrollTop = 0;
|
||||
|
||||
const copyBtn = document.getElementById("copyRouteBtn");
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(endpoint.path).then(() => flashIcon(copyBtn));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function flashIcon(btn) {
|
||||
const original = btn.innerHTML;
|
||||
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none"><path d="M5 13L9 17L19 7" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||||
setTimeout(() => (btn.innerHTML = original), 1100);
|
||||
}
|
||||
|
||||
function schemaTypeLabel(schema) {
|
||||
if (!schema) return "any";
|
||||
if (schema.refName) return schema.refName;
|
||||
if (schema.type === "array") return schemaTypeLabel(schema.items) + "[]";
|
||||
if (schema.type === "enum") return "enum";
|
||||
return schema.format ? `${schema.type} (${schema.format})` : schema.type;
|
||||
}
|
||||
|
||||
function renderSchemaTree(schema, depth) {
|
||||
if (!schema) return `<span class="schema-comment">unknown</span>`;
|
||||
const indent = " ".repeat(depth);
|
||||
|
||||
if (schema.refName && spec.schemas && spec.schemas[schema.refName] && depth < 6) {
|
||||
const resolved = spec.schemas[schema.refName];
|
||||
return renderSchemaTree({ ...resolved, refName: undefined }, depth);
|
||||
}
|
||||
|
||||
if (schema.type === "object" && schema.properties) {
|
||||
const required = new Set(schema.required || []);
|
||||
let lines = [`<span class="schema-punct">{</span>`];
|
||||
const entries = Object.entries(schema.properties);
|
||||
entries.forEach(([key, propSchema], i) => {
|
||||
const isReq = required.has(key);
|
||||
const comma = i < entries.length - 1 ? "," : "";
|
||||
const nullableMark = propSchema && propSchema.nullable ? '<span class="schema-nullable-mark">?</span>' : "";
|
||||
lines.push(
|
||||
`${indent} <span class="schema-key">${escapeHtml(key)}</span>${isReq ? '<span class="schema-required-mark">*</span>' : ""}${nullableMark}<span class="schema-punct">:</span> ${renderInlineType(propSchema, depth + 1)}<span class="schema-punct">${comma}</span>`
|
||||
);
|
||||
});
|
||||
lines.push(`${indent}<span class="schema-punct">}</span>`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
return `<span class="schema-punct">[</span>\n${indent} ${renderInlineType(schema.items, depth + 1)}\n${indent}<span class="schema-punct">]</span>`;
|
||||
}
|
||||
|
||||
if (schema.type === "enum") {
|
||||
return `<span class="schema-type">enum</span> <span class="schema-comment">(${(schema.enumValues || []).join(" | ")})</span>`;
|
||||
}
|
||||
|
||||
return `<span class="schema-type">${schema.type}${schema.format ? " (" + schema.format + ")" : ""}</span>`;
|
||||
}
|
||||
|
||||
function renderInlineType(schema, depth) {
|
||||
if (!schema) return `<span class="schema-comment">any</span>`;
|
||||
if (schema.refName) {
|
||||
if (spec.schemas && spec.schemas[schema.refName] && depth < 6) {
|
||||
return renderSchemaTree({ ...spec.schemas[schema.refName] }, depth);
|
||||
}
|
||||
return `<span class="schema-type">${escapeHtml(schema.refName)}</span>`;
|
||||
}
|
||||
if (schema.type === "object" && schema.properties) return renderSchemaTree(schema, depth);
|
||||
if (schema.type === "array") {
|
||||
return `<span class="schema-punct">Array<</span>${renderInlineType(schema.items, depth)}<span class="schema-punct">></span>`;
|
||||
}
|
||||
if (schema.type === "enum") {
|
||||
return `<span class="schema-comment">(${(schema.enumValues || []).join(" | ")})</span>`;
|
||||
}
|
||||
return `<span class="schema-type">${schema.type}${schema.format ? " (" + schema.format + ")" : ""}</span>`;
|
||||
}
|
||||
|
||||
// Try-it-out pane
|
||||
function renderTry(group, endpoint) {
|
||||
const methodColorVar = `var(--m-${endpoint.method.toLowerCase()}, var(--m-default))`;
|
||||
let html = `<div class="fade-in" style="--method-color:${methodColorVar}">`;
|
||||
html += `<div class="try-header">
|
||||
<span class="try-title"><span class="live-dot"></span>Try it</span>
|
||||
</div>`;
|
||||
|
||||
html += `<div class="try-tabs">
|
||||
<button class="try-tab ${activeTryTab === "body" ? "active" : ""}" data-try-tab="body">Request</button>
|
||||
<button class="try-tab ${activeTryTab === "curl" ? "active" : ""}" data-try-tab="curl">cURL</button>
|
||||
</div>`;
|
||||
|
||||
html += `<div id="tryTabBody" style="${activeTryTab === "body" ? "" : "display:none;"}">`;
|
||||
|
||||
const pathParams = endpoint.parameters.filter((p) => p.in === "path");
|
||||
const queryParams = endpoint.parameters.filter((p) => p.in === "query");
|
||||
const headerParams = endpoint.parameters.filter((p) => p.in === "header");
|
||||
|
||||
if (pathParams.length) {
|
||||
html += `<div class="field-group"><div class="field-label">Path parameters</div>`;
|
||||
for (const p of pathParams) {
|
||||
html += `<div style="margin-bottom:8px;">
|
||||
<div class="field-sublabel">${escapeHtml(p.name)}${p.required ? '<span class="req-star">*</span>' : ""}<span class="type-hint">${schemaTypeLabel(p.schema)}</span></div>
|
||||
<input class="field-input" data-param-in="path" data-param-name="${escapeAttr(p.name)}" placeholder="${schemaTypeLabel(p.schema)}" />
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
if (queryParams.length) {
|
||||
html += `<div class="field-group"><div class="field-label">Query parameters</div>`;
|
||||
for (const p of queryParams) {
|
||||
html += `<div style="margin-bottom:8px;">
|
||||
<div class="field-sublabel">${escapeHtml(p.name)}${p.required ? '<span class="req-star">*</span>' : ""}<span class="type-hint">${schemaTypeLabel(p.schema)}</span></div>
|
||||
<input class="field-input" data-param-in="query" data-param-name="${escapeAttr(p.name)}" placeholder="${schemaTypeLabel(p.schema)}" />
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
if (headerParams.length) {
|
||||
html += `<div class="field-group"><div class="field-label">Headers</div>`;
|
||||
for (const p of headerParams) {
|
||||
html += `<div style="margin-bottom:8px;">
|
||||
<div class="field-sublabel">${escapeHtml(p.name)}${p.required ? '<span class="req-star">*</span>' : ""}<span class="type-hint">${schemaTypeLabel(p.schema)}</span></div>
|
||||
<input class="field-input" data-param-in="header" data-param-name="${escapeAttr(p.name)}" placeholder="${schemaTypeLabel(p.schema)}" />
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
if (endpoint.requestBody) {
|
||||
const example = endpoint.requestBody.example || generateExampleJson(endpoint.requestBody.schema, 0);
|
||||
html += `<div class="field-group">
|
||||
<div class="field-label">Request body <span style="color:var(--text-2);font-weight:400;">(JSON)</span></div>
|
||||
<textarea class="field-input" id="tryBody" spellcheck="false">${escapeHtml(example)}</textarea>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `<button class="send-btn" id="sendBtn" style="--method-color:${methodColorVar}">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M5 12H19M19 12L13 6M19 12L13 18" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span id="sendBtnLabel">Send request</span>
|
||||
</button>`;
|
||||
|
||||
html += `<div id="responsePanel"></div>`;
|
||||
html += `</div>`; // tryTabBody
|
||||
|
||||
html += `<div id="tryTabCurl" style="${activeTryTab === "curl" ? "" : "display:none;"}">
|
||||
<div class="curl-header-row">
|
||||
<span class="field-label" style="margin-bottom:0;">Shell snippet</span>
|
||||
<button class="copy-btn" id="copyCurlBtn">
|
||||
<svg viewBox="0 0 24 24" fill="none"><rect x="8" y="8" width="12" height="12" rx="2" stroke="currentColor" stroke-width="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" stroke="currentColor" stroke-width="2"/></svg>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="curl-box" id="curlSnippet">${buildCurlSnippet(endpoint)}</div>
|
||||
</div>`;
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
el.try.innerHTML = html;
|
||||
|
||||
document.getElementById("sendBtn").addEventListener("click", () => sendTryRequest(endpoint));
|
||||
|
||||
el.try.querySelectorAll("[data-try-tab]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
activeTryTab = btn.dataset.tryTab;
|
||||
renderTry(group, endpoint);
|
||||
});
|
||||
});
|
||||
|
||||
const copyCurlBtn = document.getElementById("copyCurlBtn");
|
||||
if (copyCurlBtn) {
|
||||
copyCurlBtn.addEventListener("click", () => {
|
||||
const text = document.getElementById("curlSnippet").textContent;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const original = copyCurlBtn.textContent;
|
||||
copyCurlBtn.textContent = "Copied";
|
||||
setTimeout(() => (copyCurlBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none"><rect x="8" y="8" width="12" height="12" rx="2" stroke="currentColor" stroke-width="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" stroke="currentColor" stroke-width="2"/></svg>Copy`), 1100);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildCurlSnippet(endpoint) {
|
||||
const base = (spec.servers && spec.servers[0]) || "";
|
||||
let path = endpoint.path;
|
||||
// replace path params with placeholder tokens for readability
|
||||
const lines = [];
|
||||
lines.push(`<span class="curl-flag">curl</span> -X ${endpoint.method} \\`);
|
||||
lines.push(` "${escapeHtml(base)}${escapeHtml(path)}" \\`);
|
||||
lines.push(` -H "Accept: application/json"`);
|
||||
if (endpoint.requestBody) {
|
||||
const example = endpoint.requestBody.example || generateExampleJson(endpoint.requestBody.schema, 0);
|
||||
lines[lines.length - 1] += " \\";
|
||||
lines.push(` -H "Content-Type: ${escapeHtml(endpoint.requestBody.contentType || "application/json")}" \\`);
|
||||
lines.push(` -d <span class="curl-string">'${escapeHtml(example)}'</span>`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function generateExampleJson(schema, depth) {
|
||||
const value = generateExampleValue(schema, depth, new Set());
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function generateExampleValue(schema, depth, seen) {
|
||||
if (!schema || depth > 6) return null;
|
||||
|
||||
if (schema.refName) {
|
||||
if (seen.has(schema.refName)) return {};
|
||||
const resolved = spec.schemas && spec.schemas[schema.refName];
|
||||
if (!resolved) return {};
|
||||
const nextSeen = new Set(seen);
|
||||
nextSeen.add(schema.refName);
|
||||
return generateExampleValue(resolved, depth + 1, nextSeen);
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case "string":
|
||||
if (schema.format === "date-time") return new Date().toISOString();
|
||||
if (schema.format === "uuid") return "00000000-0000-0000-0000-000000000000";
|
||||
return "string";
|
||||
case "integer":
|
||||
return 0;
|
||||
case "number":
|
||||
return 0;
|
||||
case "boolean":
|
||||
return true;
|
||||
case "enum":
|
||||
return (schema.enumValues && schema.enumValues[0]) || "string";
|
||||
case "array":
|
||||
return [generateExampleValue(schema.items, depth + 1, seen)];
|
||||
case "object": {
|
||||
if (!schema.properties) return {};
|
||||
const obj = {};
|
||||
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
||||
obj[key] = generateExampleValue(propSchema, depth + 1, seen);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTryRequest(endpoint) {
|
||||
const btn = document.getElementById("sendBtn");
|
||||
const label = document.getElementById("sendBtnLabel");
|
||||
const panel = document.getElementById("responsePanel");
|
||||
|
||||
let path = endpoint.path;
|
||||
document.querySelectorAll('[data-param-in="path"]').forEach((input) => {
|
||||
const name = input.dataset.paramName;
|
||||
path = path.replace(`{${name}}`, encodeURIComponent(input.value || ""));
|
||||
});
|
||||
|
||||
const url = new URL(path, window.location.origin);
|
||||
document.querySelectorAll('[data-param-in="query"]').forEach((input) => {
|
||||
if (input.value) url.searchParams.set(input.dataset.paramName, input.value);
|
||||
});
|
||||
|
||||
const headers = { Accept: "application/json" };
|
||||
document.querySelectorAll('[data-param-in="header"]').forEach((input) => {
|
||||
if (input.value) headers[input.dataset.paramName] = input.value;
|
||||
});
|
||||
|
||||
let body = undefined;
|
||||
if (endpoint.requestBody) {
|
||||
headers["Content-Type"] = endpoint.requestBody.contentType || "application/json";
|
||||
const bodyEl = document.getElementById("tryBody");
|
||||
body = bodyEl ? bodyEl.value : undefined;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
label.textContent = "Sending…";
|
||||
btn.querySelector("svg").style.display = "none";
|
||||
btn.insertBefore(spinnerEl(), btn.firstChild);
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(url.toString(), {
|
||||
method: endpoint.method,
|
||||
headers,
|
||||
body: endpoint.method === "GET" || endpoint.method === "HEAD" ? undefined : body,
|
||||
});
|
||||
|
||||
const elapsedMs = Math.round(performance.now() - startTime);
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
let bodyText;
|
||||
let isJson = false;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
const json = await res.json();
|
||||
bodyText = JSON.stringify(json, null, 2);
|
||||
isJson = true;
|
||||
} catch {
|
||||
bodyText = await res.text();
|
||||
}
|
||||
} else {
|
||||
bodyText = await res.text();
|
||||
}
|
||||
|
||||
renderResponsePanel(panel, {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
elapsedMs,
|
||||
body: bodyText,
|
||||
isJson,
|
||||
});
|
||||
} catch (err) {
|
||||
const elapsedMs = Math.round(performance.now() - startTime);
|
||||
renderResponsePanel(panel, {
|
||||
status: null,
|
||||
ok: false,
|
||||
elapsedMs,
|
||||
body: String(err && err.message ? err.message : err),
|
||||
isJson: false,
|
||||
networkError: true,
|
||||
});
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
label.textContent = "Send request";
|
||||
const spinner = btn.querySelector(".spinner");
|
||||
if (spinner) spinner.remove();
|
||||
btn.querySelector("svg").style.display = "";
|
||||
}
|
||||
}
|
||||
|
||||
function spinnerEl() {
|
||||
const s = document.createElement("span");
|
||||
s.className = "spinner";
|
||||
return s;
|
||||
}
|
||||
|
||||
function renderResponsePanel(panel, result) {
|
||||
const statusClass = result.networkError
|
||||
? "status-5xx"
|
||||
: result.status < 300
|
||||
? "status-2xx"
|
||||
: result.status < 500
|
||||
? "status-4xx"
|
||||
: "status-5xx";
|
||||
|
||||
const statusLabel = result.networkError ? "Network error" : result.status;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="response-panel fade-in">
|
||||
<div class="response-meta">
|
||||
<span class="status-pill ${statusClass}">${statusLabel}</span>
|
||||
<span class="response-time">
|
||||
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/><path d="M12 7V12L15 15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
${result.elapsedMs} ms
|
||||
</span>
|
||||
<button class="copy-btn" id="copyResponseBtn" style="margin-left:auto;">
|
||||
<svg viewBox="0 0 24 24" fill="none"><rect x="8" y="8" width="12" height="12" rx="2" stroke="currentColor" stroke-width="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" stroke="currentColor" stroke-width="2"/></svg>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="response-body ${result.networkError ? "response-error" : ""}" id="responseBody">${result.isJson ? syntaxHighlightJson(result.body) : escapeHtml(result.body)
|
||||
}</div>
|
||||
</div>`;
|
||||
|
||||
document.getElementById("copyResponseBtn").addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(result.body).then(() => {
|
||||
const btn = document.getElementById("copyResponseBtn");
|
||||
btn.lastChild.textContent = "Copied";
|
||||
setTimeout(() => (btn.lastChild.textContent = "Copy"), 1200);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function syntaxHighlightJson(json) {
|
||||
const escaped = escapeHtml(json);
|
||||
return escaped.replace(
|
||||
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false)\b|\bnull\b|-?\d+(\.\d+)?([eE][+-]?\d+)?)/g,
|
||||
(match) => {
|
||||
let cls = "json-number";
|
||||
if (/^"/.test(match)) {
|
||||
cls = /:$/.test(match) ? "json-key" : "json-string";
|
||||
} else if (/true|false/.test(match)) {
|
||||
cls = "json-boolean";
|
||||
} else if (/null/.test(match)) {
|
||||
cls = "json-null";
|
||||
}
|
||||
return `<span class="${cls}">${match}</span>`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Global events
|
||||
function bindGlobalEvents()
|
||||
{
|
||||
const importBtn = document.getElementById("importBtn");
|
||||
const importFile = document.getElementById("importFile");
|
||||
const exportDoxaApiBtn = document.getElementById("exportDoxaApiBtn");
|
||||
const exportOpenApiBtn = document.getElementById("exportOpenApiBtn");
|
||||
const exportSwaggerBtn = document.getElementById("exportSwaggerBtn");
|
||||
|
||||
importBtn?.addEventListener("click", () => importFile.click());
|
||||
|
||||
importFile?.addEventListener("change", async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const text = await file.text();
|
||||
|
||||
const res = await fetch("import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: text
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
alert(await res.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const importedSpec = await res.json();
|
||||
const extend = apis.length > 0 && window.confirm("Extend the currently selected API?\n\nOK = Extend Current API\nCancel = Import As New API (default)");
|
||||
|
||||
if (extend) {
|
||||
spec.groups = [...(spec.groups||[]), ...(importedSpec.groups||[])];
|
||||
spec.schemas = Object.assign(spec.schemas||{}, importedSpec.schemas||{});
|
||||
} else {
|
||||
apis.push(importedSpec);
|
||||
activeApiIndex = apis.length - 1;
|
||||
spec = importedSpec;
|
||||
}
|
||||
|
||||
refreshApiSelector();
|
||||
if(spec.info && spec.info.title) el.brandTitle.textContent = spec.info.title;
|
||||
if(spec.info && spec.info.version) el.brandVersion.textContent = spec.info.version;
|
||||
|
||||
activeEndpoint = null;
|
||||
|
||||
renderNav("");
|
||||
renderOverview();
|
||||
renderTryEmpty();
|
||||
});
|
||||
|
||||
function download(url, filename) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
exportDoxaApiBtn?.addEventListener("click", () => download("doxaApi.json", "DoxaApi.json"));
|
||||
exportOpenApiBtn?.addEventListener("click", () => download("openapi.json", "openapi.json"));
|
||||
exportSwaggerBtn?.addEventListener("click", () => download("swagger.json", "swagger.json"));
|
||||
|
||||
el.nav.addEventListener("click", (e) => {
|
||||
const overviewBtn = e.target.closest("[data-overview]");
|
||||
if (overviewBtn) {
|
||||
activeEndpoint = null;
|
||||
document.querySelectorAll(".nav-endpoint").forEach((b) => b.classList.remove("active"));
|
||||
overviewBtn.classList.add("active");
|
||||
renderOverview();
|
||||
renderTryEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
const groupToggle = e.target.closest("[data-toggle-group]");
|
||||
if (groupToggle) {
|
||||
const name = groupToggle.dataset.toggleGroup;
|
||||
if (collapsedGroups.has(name)) collapsedGroups.delete(name);
|
||||
else collapsedGroups.add(name);
|
||||
localStorage.setItem("DoxaApi.collapsed", JSON.stringify([...collapsedGroups]));
|
||||
renderNav(el.search.value);
|
||||
return;
|
||||
}
|
||||
|
||||
const endpointBtn = e.target.closest("[data-op]");
|
||||
if (endpointBtn) {
|
||||
const opId = endpointBtn.dataset.op;
|
||||
for (const group of spec.groups) {
|
||||
const endpoint = group.endpoints.find((ep) => ep.operationId === opId);
|
||||
if (endpoint) {
|
||||
selectEndpoint(group, endpoint);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
el.detail.addEventListener("click", (e) => {
|
||||
const row = e.target.closest("[data-op]");
|
||||
if (!row) return;
|
||||
const opId = row.dataset.op;
|
||||
for (const group of spec.groups) {
|
||||
const endpoint = group.endpoints.find((ep) => ep.operationId === opId);
|
||||
if (endpoint) {
|
||||
selectEndpoint(group, endpoint);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
el.search.addEventListener("input", () => renderNav(el.search.value));
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "/" && document.activeElement !== el.search) {
|
||||
e.preventDefault();
|
||||
el.search.focus();
|
||||
}
|
||||
if (e.key === "Escape" && document.activeElement === el.search) {
|
||||
el.search.blur();
|
||||
}
|
||||
});
|
||||
|
||||
el.themeToggle.addEventListener("click", () => {
|
||||
const root = document.documentElement;
|
||||
const current = root.getAttribute("data-theme");
|
||||
const isDark =
|
||||
current === "dark" || (current === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const next = isDark ? "light" : "dark";
|
||||
root.setAttribute("data-theme", next);
|
||||
localStorage.setItem("DoxaApi.theme", next);
|
||||
});
|
||||
|
||||
const savedTheme = localStorage.getItem("DoxaApi.theme");
|
||||
if (savedTheme) document.documentElement.setAttribute("data-theme", savedTheme);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function escapeHtml(str) {
|
||||
return String(str ?? "").replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return escapeHtml(str);
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
|
||||
window.DoxaApiPostmanLike = {
|
||||
methods:["GET","POST","PUT","PATCH","DELETE","HEAD","OPTIONS"],
|
||||
buildRequest:function(){
|
||||
return {
|
||||
method: document.getElementById("customMethod")?.value || "GET",
|
||||
url: document.getElementById("customUrl")?.value || "",
|
||||
headers: []
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SampleApi</title>
|
||||
<base href="__DOXA_API_BASE__" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<style>
|
||||
:root {
|
||||
--accent: #6366f1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="app-shell">
|
||||
<!-- Top bar -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-brand">
|
||||
<span class="brand-mark">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 6L10 12L4 18" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M13 18H20" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="brand-title" id="brandTitle">SampleApi</span>
|
||||
<span class="brand-version" id="brandVersion"></span>
|
||||
</div>
|
||||
<div class="api-switcher"><select id="apiSelector"><option>Default API</option></select></div><div class="topbar-search">
|
||||
<svg viewBox="0 0 24 24" fill="none" class="search-icon"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" /><path d="M21 21L16.65 16.65" stroke="currentColor" stroke-width="2" stroke-linecap="round" /></svg>
|
||||
<input id="searchInput" type="text" placeholder="Search endpoints (press /)" autocomplete="off" />
|
||||
<kbd>/</kbd>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<div class="action-group">
|
||||
<button id="importBtn" class="action-btn action-btn-primary">⬆ Import</button>
|
||||
<button id="exportDoxaApiBtn" class="action-btn">DoxaApi</button>
|
||||
<button id="exportOpenApiBtn" class="action-btn">OpenAPI</button>
|
||||
<button id="exportSwaggerBtn" class="action-btn">Swagger</button>
|
||||
</div>
|
||||
|
||||
<input id="importFile" type="file" accept=".json" hidden />
|
||||
<button id="themeToggle" class="icon-btn" title="Toggle theme" aria-label="Toggle theme">
|
||||
<svg class="icon-sun" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2" /><path d="M12 2V4M12 20V22M4 12H2M22 12H20M19.07 4.93L17.66 6.34M6.34 17.66L4.93 19.07M19.07 19.07L17.66 17.66M6.34 6.34L4.93 4.93" stroke="currentColor" stroke-width="2" stroke-linecap="round" /></svg>
|
||||
<svg class="icon-moon" viewBox="0 0 24 24" fill="none"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="body-grid">
|
||||
<!-- Left nav: groups + endpoint list -->
|
||||
<nav class="nav-pane" id="navPane">
|
||||
<div id="navContent" class="nav-content"></div>
|
||||
</nav>
|
||||
|
||||
<!-- Middle: endpoint detail -->
|
||||
<main class="detail-pane" id="detailPane">
|
||||
<div id="detailContent" class="detail-content"></div>
|
||||
</main>
|
||||
|
||||
<!-- Right: try it out / response -->
|
||||
<aside class="try-pane" id="tryPane">
|
||||
<div id="tryContent" class="try-content"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>window.__DOXA_API_SPEC_URL__ = "__DOXA_API_SPEC_PATH__";</script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user