Initial version

This commit is contained in:
2026-06-20 10:24:36 +02:00
parent f85b83d90f
commit 7e1173bf2c
40 changed files with 5438 additions and 63 deletions
+6 -21
View File
@@ -2,7 +2,7 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
@@ -83,8 +83,6 @@ StyleCopReport.xml
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
@@ -209,6 +207,9 @@ PublishScripts/
*.nuget.props
*.nuget.targets
# Nuget personal access tokens and Credentials
nuget.config
# Microsoft Azure Build Output
csx/
*.build.csdef
@@ -297,17 +298,6 @@ node_modules/
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
@@ -364,9 +354,6 @@ ASALocalRun/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
@@ -398,6 +385,7 @@ FodyWeavers.xsd
*.msp
# JetBrains Rider
.idea/
*.sln.iml
# ---> VisualStudioCode
@@ -406,11 +394,8 @@ FodyWeavers.xsd
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
*.code-workspace
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
@@ -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;
}
}
+57
View File
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
namespace EonaCat.DoxaApi.Exporter
{
public static class DoxaApiExporter { }
}
+272
View File
@@ -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"
};
}
}
+304
View File
@@ -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"
};
}
}
+278
View File
@@ -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;
}
}
}
+13
View File
@@ -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";
}
}
+294
View File
@@ -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..];
}
}
}
+103
View File
@@ -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;
}
}
}
+20
View File
@@ -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.");
}
}
}
+603
View File
@@ -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('}', '_');
}
}
+182
View File
@@ -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);
}
}
}
+19
View File
@@ -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();
}
}
+37
View File
@@ -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();
}
}
+16
View File
@@ -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();
}
}
+16
View File
@@ -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";
}
}
+25
View File
@@ -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; }
}
}
+19
View File
@@ -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; }
}
}
+16
View File
@@ -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; }
}
}
+31
View File
@@ -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; }
}
}
+17
View File
@@ -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
+855
View File
@@ -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&lt;</span>${renderInlineType(schema.items, depth)}<span class="schema-punct">&gt;</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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[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: []
};
}
};
+71
View File
@@ -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>
+32
View File
@@ -0,0 +1,32 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.7.11903.348
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.DoxaApi", "DoxaApi\EonaCat.DoxaApi.csproj", "{11111111-1111-1111-1111-111111111111}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleApi", "sample\SampleApi\SampleApi.csproj", "{22222222-2222-2222-2222-222222222222}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3B167511-F7C3-4F80-B199-1E02ED686E11}"
ProjectSection(SolutionItems) = preProject
image.png = image.png
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{11111111-1111-1111-1111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{11111111-1111-1111-1111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU
{11111111-1111-1111-1111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU
{11111111-1111-1111-1111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU
{22222222-2222-2222-2222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{22222222-2222-2222-2222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU
{22222222-2222-2222-2222-222222222222}.Release|Any CPU.ActiveCfg = Release|Any CPU
{22222222-2222-2222-2222-222222222222}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
+172 -41
View File
@@ -1,73 +1,204 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
https://EonaCat.com/license/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
OF SOFTWARE BY EONACAT (JEROEN SAEY)
1. Definitions.
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 EonaCat
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+160 -1
View File
@@ -1,3 +1,162 @@
# EonaCat.DoxaApi
EonaCat.DoxaApi
A self-contained, API documentation UI for ASP.NET Core
EonaCat.DoxaApi scans your controllers with plain `System.Reflection` and ASP.NET Core's own `IActionDescriptorCollectionProvider`,
builds a small OpenAPI-like JSON document with `System.Text.Json`, and serves a UI
This can also be used in an offline environment!
![alt text](image.png)
## Install
```bash
dotnet add package EonaCat.DoxaApi
```
## Quick start
```csharp
using EonaCat.DoxaApi;
using EonaCat.DoxaApi.Middleware;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEonaCat.DoxaApi(options =>
{
options.Title = "My API";
options.Description = "Internal service API";
options.AccentColor = "#6366f1"; // any hex color
});
var app = builder.Build();
app.UseRouting();
app.UseEonaCat.DoxaApi(); // serves UI at /doxa and spec at /doxa/DoxaApi.json
app.MapControllers();
app.Run();
```
Run your app and open `https://localhost:xxxx/doxa`.
## Features
- **Route-table style navigation** - endpoints grouped by controller, each method color-coded (GET/POST/PUT/PATCH/DELETE), searchable with `/`.
- **Schema viewer** - nested request/response shapes rendered as readable, syntax-colored trees, with required fields marked.
- **Try it out** - a real three-pane layout: browse → inspect → call, with path/query/header inputs, an editable JSON body (pre-filled with a generated example), and a live response panel with status, timing, and syntax-highlighted JSON.
- **Light & dark themes**, persisted, with a system-preference default.
- **XML doc comment support** - `/// <summary>`, `<param>`, and `<returns>` are read directly from your project's generated `.xml` doc file (enable `<GenerateDocumentationFile>true</GenerateDocumentationFile>` in your csproj).
- **Attributes for fine control**: `[EonaCat.DoxaApiGroup]`, `[EonaCat.DoxaApiSummary]`, `[EonaCat.DoxaApiDescription]`, `[EonaCat.DoxaApiExample]`, `[EonaCat.DoxaApiHidden]`.
- **Zero external NuGet dependencies** - only references your app's own ASP.NET Core shared framework.
## Configuration reference
```csharp
builder.Services.AddEonaCat.DoxaApi(options =>
{
options.Title = "My API"; // shown in the top bar and browser tab
options.Description = "..."; // shown on the welcome screen
options.Version = "v1";
options.RoutePrefix = "doxa"; // UI served at /{RoutePrefix}
options.AccentColor = "#6366f1"; // primary button/accent color
options.Theme = "auto"; // "auto" | "light" | "dark"
});
```
## Attributes
```csharp
[EonaCat.DoxaApiGroup("Users")] // override the left-nav group name
public class UsersController : ControllerBase
{
/// <summary>Lists all users.</summary> // picked up automatically
[HttpGet]
public ActionResult<List<User>> GetUsers() => ...;
[HttpPost]
[EonaCat.DoxaApiExample("""{ "name": "Ada" }""")] // overrides the auto-generated example
public ActionResult<User> Create(CreateUserRequest request) => ...;
[HttpDelete("{id}")]
[EonaCat.DoxaApiHidden] // omit from docs entirely
public IActionResult Delete(Guid id) => ...;
}
```
## OpenAPI & Swagger
EonaCat.DoxaApi exposes dedicated endpoints for importing and exporting industry-standard spec formats alongside its own native JSON.
### Export
| URL | Format | Notes |
|-----|--------|-------|
| `/{RoutePrefix}/DoxaApi.json` | Native EonaCat.DoxaApi JSON | Always available; used by the built-in UI |
| `/{RoutePrefix}/openapi.json` | **OpenAPI 3.0.3** | Import into Postman, Insomnia, Stoplight, etc. |
| `/{RoutePrefix}/swagger.json` | **Swagger 2.0** | Import into older tools, AWS API Gateway, Azure APIM, etc. |
```bash
# Download the OpenAPI 3.0 spec
curl http://localhost:5000/doxa/openapi.json -o openapi.json
# Download the Swagger 2.0 spec
curl http://localhost:5000/doxa/swagger.json -o swagger.json
```
### Import
`POST /{RoutePrefix}/import`
Send any OpenAPI 3.x or Swagger 2.0 JSON document in the request body. The server detects the format automatically and returns the native EonaCat.DoxaApi document.
```bash
# Import a third-party OpenAPI spec and get the EonaCat.DoxaApi document back
curl -X POST http://localhost:5000/doxa/import \
-H "Content-Type: application/json" \
--data-binary @path/to/openapi.json
```
**Use cases:**
- Preview any public or third-party OpenAPI/Swagger spec in the EonaCat.DoxaApi UI
- Validate that your spec round-trips correctly through import → export
- Use the import endpoint as a conversion proxy (OpenAPI 3 ↔ Swagger 2)
### Programmatic use
The `OpenApiExporter`, `SwaggerExporter`, and `OpenApiImporter` classes in the
`EonaCat.DoxaApi.Interop` namespace are `public static` and can be used directly in your
own code:
```csharp
using EonaCat.DoxaApi.Interop;
using EonaCat.DoxaApi.Models;
// Export: ApiDocument → OpenAPI 3.0 JsonObject
ApiDocument doc = /* ... */;
System.Text.Json.Nodes.JsonObject openApi = OpenApiExporter.Export(doc);
string json = openApi.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
// Export: ApiDocument → Swagger 2.0 JsonObject
System.Text.Json.Nodes.JsonObject swagger = SwaggerExporter.Export(doc);
// Import: OpenAPI 3.x or Swagger 2.0 string → ApiDocument
ApiDocument imported = OpenApiImporter.Import(File.ReadAllText("openapi.json"));
// Import from a Stream
await using var stream = File.OpenRead("swagger.json");
ApiDocument imported2 = OpenApiImporter.Import(stream);
```
## Sample project
See `/sample/SampleApi` for a complete working example with two controllers (`Users`, `Orders`) demonstrating nested objects, enums, arrays, path/query parameters, and a deprecated endpoint.
```bash
cd sample/SampleApi
dotnet run
# open http://localhost:5000/doxa
```
## License
MIT
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

@@ -0,0 +1,54 @@
using EonaCat.DoxaApi.Attributes;
using Microsoft.AspNetCore.Mvc;
using SampleApi.Models;
namespace SampleApi.Controllers
{
[ApiController]
[Route("api/orders")]
[DoxaApiGroup("Orders")]
public class OrdersController : ControllerBase
{
private static readonly List<Order> _orders = new();
[HttpGet]
public ActionResult<List<Order>> GetOrders()
=> Ok(_orders.OrderByDescending(o => o.PlacedAt).ToList());
[HttpGet("{id}")]
public ActionResult<Order> GetById(Guid id)
{
var order = _orders.FirstOrDefault(o => o.Id == id);
return order is null ? NotFound() : Ok(order);
}
[HttpPost]
public ActionResult<Order> Create([FromBody] CreateOrderRequest request)
{
var order = new Order
{
Id = Guid.NewGuid(),
UserId = request.UserId,
Lines = request.Lines,
Total = request.Lines.Sum(l => l.UnitPrice * l.Quantity),
Status = OrderStatus.Pending,
PlacedAt = DateTime.UtcNow
};
_orders.Add(order);
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
}
[HttpPost("{id}/cancel")]
public ActionResult<Order> Cancel(Guid id)
{
var order = _orders.FirstOrDefault(o => o.Id == id);
if (order is null)
{
return NotFound();
}
order.Status = OrderStatus.Cancelled;
return Ok(order);
}
}
}
@@ -0,0 +1,102 @@
using EonaCat.DoxaApi.Attributes;
using Microsoft.AspNetCore.Mvc;
using SampleApi.Models;
namespace SampleApi.Controllers
{
[ApiController]
[Route("api/users")]
[DoxaApiGroup("Users")]
public class UsersController : ControllerBase
{
private static readonly List<User> _users = new()
{
new User { Id = Guid.NewGuid(), Name = "Ada Lovelace", Email = "ada@example.com", Role = UserRole.Admin, CreatedAt = DateTime.UtcNow },
new User { Id = Guid.NewGuid(), Name = "Alan Turing", Email = "alan@example.com", Role = UserRole.Member, CreatedAt = DateTime.UtcNow },
};
[HttpGet]
public ActionResult<List<User>> GetUsers([FromQuery] UserRole? role, [FromQuery] int page = 1)
{
var query = _users.AsEnumerable();
if (role is not null)
{
query = query.Where(u => u.Role == role);
}
return Ok(query.ToList());
}
[HttpGet("{id}")]
public ActionResult<User> GetById(Guid id)
{
var user = _users.FirstOrDefault(u => u.Id == id);
return user is null ? NotFound() : Ok(user);
}
[HttpPost]
[DoxaApiExample("""
{
"name": "Grace Hopper",
"email": "grace@example.com",
"role": "Member",
"address": { "street": "1 Compiler Way", "city": "Arlington", "postalCode": "22201", "country": "US" }
}
""")]
public ActionResult<User> Create([FromBody] CreateUserRequest request)
{
var user = new User
{
Id = Guid.NewGuid(),
Name = request.Name,
Email = request.Email,
Role = request.Role,
Address = request.Address,
CreatedAt = DateTime.UtcNow
};
_users.Add(user);
return CreatedAtAction(nameof(GetById), new { id = user.Id }, user);
}
[HttpPatch("{id}")]
public ActionResult<User> Update(Guid id, [FromBody] UpdateUserRequest request)
{
var user = _users.FirstOrDefault(u => u.Id == id);
if (user is null)
{
return NotFound();
}
if (request.Name is not null)
{
user.Name = request.Name;
}
if (request.Email is not null)
{
user.Email = request.Email;
}
if (request.Role is not null)
{
user.Role = request.Role.Value;
}
return Ok(user);
}
[HttpDelete("{id}")]
[Obsolete("Use POST /api/users/{id}/archive instead.")]
public IActionResult Delete(Guid id)
{
var user = _users.FirstOrDefault(u => u.Id == id);
if (user is null)
{
return NotFound();
}
_users.Remove(user);
return NoContent();
}
}
}
+86
View File
@@ -0,0 +1,86 @@
namespace SampleApi.Models
{
public enum UserRole
{
Admin,
Member,
Guest
}
public class User
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public UserRole Role { get; set; }
public DateTime CreatedAt { get; set; }
public Address? Address { get; set; }
public List<string> Tags { get; set; } = new();
}
public class Address
{
public string Street { get; set; } = "";
public string City { get; set; } = "";
public string PostalCode { get; set; } = "";
public string Country { get; set; } = "";
}
public class CreateUserRequest
{
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public UserRole Role { get; set; } = UserRole.Member;
public Address? Address { get; set; }
}
public class UpdateUserRequest
{
public string? Name { get; set; }
public string? Email { get; set; }
public UserRole? Role { get; set; }
}
public class Order
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public List<OrderLine> Lines { get; set; } = new();
public decimal Total { get; set; }
public OrderStatus Status { get; set; }
public DateTime PlacedAt { get; set; }
}
public class OrderLine
{
public string Sku { get; set; } = "";
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
public enum OrderStatus
{
Pending,
Paid,
Shipped,
Delivered,
Cancelled
}
public class CreateOrderRequest
{
public Guid UserId { get; set; }
public List<OrderLine> Lines { get; set; } = new();
}
}
+27
View File
@@ -0,0 +1,27 @@
using EonaCat.DoxaApi;
using EonaCat.DoxaApi.Middleware;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddDoxaApi(options =>
{
options.Title = "Sample API";
options.Description = "A demo service showing off the DoxaApi UI - users and orders.";
options.Version = "v1";
options.AccentColor = "#6366f1";
});
var app = builder.Build();
app.UseRouting();
app.UseDoxaApi(options =>
{
options.RoutePrefix = "doxa";
});
app.MapControllers();
app.MapGet("/", () => Results.Redirect("/doxa"));
app.Run();
@@ -0,0 +1,12 @@
{
"profiles": {
"SampleApi": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:53010;http://localhost:53011"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<!-- In your own app, replace this with a PackageReference to the published
DoxaApi NuGet package, e.g.:
<PackageReference Include="DoxaApi" Version="1.0.0" />
-->
<ProjectReference Include="..\..\DoxaApi\EonaCat.DoxaApi.csproj" />
</ItemGroup>
</Project>