diff --git a/.gitignore b/.gitignore index 7e2e97c..1998960 100644 --- a/.gitignore +++ b/.gitignore @@ -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 - diff --git a/DoxaApi/Attributes/DoxaApiDescriptionAttribute.cs b/DoxaApi/Attributes/DoxaApiDescriptionAttribute.cs new file mode 100644 index 0000000..36485bf --- /dev/null +++ b/DoxaApi/Attributes/DoxaApiDescriptionAttribute.cs @@ -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; + } +} diff --git a/DoxaApi/Attributes/DoxaApiExampleAttribute.cs b/DoxaApi/Attributes/DoxaApiExampleAttribute.cs new file mode 100644 index 0000000..347bf82 --- /dev/null +++ b/DoxaApi/Attributes/DoxaApiExampleAttribute.cs @@ -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; + } +} diff --git a/DoxaApi/Attributes/DoxaApiGroupAttribute.cs b/DoxaApi/Attributes/DoxaApiGroupAttribute.cs new file mode 100644 index 0000000..73c9d66 --- /dev/null +++ b/DoxaApi/Attributes/DoxaApiGroupAttribute.cs @@ -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; + } +} diff --git a/DoxaApi/Attributes/DoxaApiHiddenAttribute.cs b/DoxaApi/Attributes/DoxaApiHiddenAttribute.cs new file mode 100644 index 0000000..0ae45e7 --- /dev/null +++ b/DoxaApi/Attributes/DoxaApiHiddenAttribute.cs @@ -0,0 +1,7 @@ +namespace EonaCat.DoxaApi.Attributes +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public sealed class DoxaApiHiddenAttribute : Attribute + { + } +} diff --git a/DoxaApi/Attributes/DoxaApiSummaryAttribute.cs b/DoxaApi/Attributes/DoxaApiSummaryAttribute.cs new file mode 100644 index 0000000..ca8974d --- /dev/null +++ b/DoxaApi/Attributes/DoxaApiSummaryAttribute.cs @@ -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; + } +} diff --git a/DoxaApi/EonaCat.DoxaApi.csproj b/DoxaApi/EonaCat.DoxaApi.csproj new file mode 100644 index 0000000..dd58ad6 --- /dev/null +++ b/DoxaApi/EonaCat.DoxaApi.csproj @@ -0,0 +1,57 @@ + + + + net8.0 + enable + enable + latest + EonaCat.DoxaApi + EonaCat.DoxaApi + + + EonaCat.DoxaApi + 0.0.2 + EonaCat (Jeroen Saey) + A modern, self-contained, dependency-free OpenAPI documentation UI for ASP.NET Core. + openapi;swagger;documentation;api;aspnetcore;doxa;api;docs;documentation;Jeroen;Saey;EonaCat;Scalar;Redoc;Postman;EchoAPI; + https://git.saey.me/EonaCat/EonaCat.DoxaApi + README.md + true + + true + false + True + EonaCat.DoxaApi + EonaCat + EonaCat.DoxaApi + EonaCat (Jeroen Saey) + https://git.saey.me/EonaCat/EonaCat.DoxaApi + icon.png + + LICENSE + + + + + + + + + + + + + True + \ + + + True + \ + + + True + \ + + + + diff --git a/DoxaApi/Exporter/ApiDocsExporter.cs b/DoxaApi/Exporter/ApiDocsExporter.cs new file mode 100644 index 0000000..a85256f --- /dev/null +++ b/DoxaApi/Exporter/ApiDocsExporter.cs @@ -0,0 +1,4 @@ +namespace EonaCat.DoxaApi.Exporter +{ + public static class DoxaApiExporter { } +} diff --git a/DoxaApi/Exporter/OpenApiExporter.cs b/DoxaApi/Exporter/OpenApiExporter.cs new file mode 100644 index 0000000..845f036 --- /dev/null +++ b/DoxaApi/Exporter/OpenApiExporter.cs @@ -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 { 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" + }; + } +} diff --git a/DoxaApi/Exporter/SwaggerExporter.cs b/DoxaApi/Exporter/SwaggerExporter.cs new file mode 100644 index 0000000..639d1d4 --- /dev/null +++ b/DoxaApi/Exporter/SwaggerExporter.cs @@ -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 { 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" + }; + } +} diff --git a/DoxaApi/Generation/ApiDocumentGenerator.cs b/DoxaApi/Generation/ApiDocumentGenerator.cs new file mode 100644 index 0000000..6c823ad --- /dev/null +++ b/DoxaApi/Generation/ApiDocumentGenerator.cs @@ -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 _actions; + private readonly DoxaApiOptions _options; + private readonly Dictionary _xmlReadersByAssembly = new(); + + public ApiDocumentGenerator(IReadOnlyList 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(); + var schemaBuilder = new SchemaBuilder(schemaRegistry); + var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var action in _actions) + { + if (action is not ControllerActionDescriptor cad) + { + continue; + } + + if (cad.MethodInfo.GetCustomAttribute() is not null) + { + continue; + } + + if (cad.ControllerTypeInfo.GetCustomAttribute() 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(); + if (methodAttr is not null) + { + return methodAttr.Name; + } + + var classAttr = cad.ControllerTypeInfo.GetCustomAttribute(); + 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() + .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(); + var descAttr = cad.MethodInfo.GetCustomAttribute(); + var exampleAttr = cad.MethodInfo.GetCustomAttribute(); + var obsoleteAttr = cad.MethodInfo.GetCustomAttribute(); + + 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 { 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(); + 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; + } + } +} diff --git a/DoxaApi/Generation/DoxaApiOptions.cs b/DoxaApi/Generation/DoxaApiOptions.cs new file mode 100644 index 0000000..35e87e4 --- /dev/null +++ b/DoxaApi/Generation/DoxaApiOptions.cs @@ -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 Servers { get; set; } = new(); + public string Theme { get; set; } = "auto"; + public string AccentColor { get; set; } = "#6366f1"; + } +} diff --git a/DoxaApi/Generation/SchemaBuilder.cs b/DoxaApi/Generation/SchemaBuilder.cs new file mode 100644 index 0000000..2ddc500 --- /dev/null +++ b/DoxaApi/Generation/SchemaBuilder.cs @@ -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 _registry; + private readonly HashSet _inProgress = new(); + + public SchemaBuilder(Dictionary 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(); + var required = new List(); + + 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..]; + } + } +} diff --git a/DoxaApi/Generation/XmlDocReader.cs b/DoxaApi/Generation/XmlDocReader.cs new file mode 100644 index 0000000..a85ffb0 --- /dev/null +++ b/DoxaApi/Generation/XmlDocReader.cs @@ -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 Params { get; } = new(); + } + + internal sealed class XmlDocReader + { + private readonly Dictionary _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; + } + } +} diff --git a/DoxaApi/Importer/ApiDocsImporter.cs b/DoxaApi/Importer/ApiDocsImporter.cs new file mode 100644 index 0000000..0c0dece --- /dev/null +++ b/DoxaApi/Importer/ApiDocsImporter.cs @@ -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(json) + ?? throw new InvalidOperationException("Invalid DoxaApi spec."); + } + + public static async Task ImportAsync(Stream stream) + { + var doc = await JsonSerializer.DeserializeAsync(stream); + return doc ?? throw new InvalidOperationException("Invalid DoxaApi spec."); + } + } +} diff --git a/DoxaApi/Importer/OpenApiImporter.cs b/DoxaApi/Importer/OpenApiImporter.cs new file mode 100644 index 0000000..2c4cb36 --- /dev/null +++ b/DoxaApi/Importer/OpenApiImporter.cs @@ -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 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(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() ?? "API"; + doc.Info.Version = info["version"]?.GetValue() ?? "v1"; + doc.Info.Description = info["description"]?.GetValue(); + } + + if (root["servers"] is JsonArray servers) + { + foreach (var s in servers) + { + if (s?["url"]?.GetValue() 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(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() ?? $"{method}_{SanitizePath(path)}", + Summary = op["summary"]?.GetValue(), + Description = op["description"]?.GetValue(), + Method = method, + Path = path, + Deprecated = op["deprecated"]?.GetValue() ?? 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() ?? "", + In = param["in"]?.GetValue() ?? "query", + Required = param["required"]?.GetValue() ?? false, + Description = param["description"]?.GetValue(), + Schema = schema + }); + } + } + + if (op["requestBody"] is JsonObject rb) + { + var required = rb["required"]?.GetValue() ?? 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(), + 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() is string refVal) + { + var refName = refVal.Split('/').Last(); + return new SchemaModel { Type = "object", RefName = refName }; + } + + bool nullable = obj["nullable"]?.GetValue() ?? false; + + if (obj["enum"] is JsonArray enumArray) + { + return new SchemaModel + { + Type = "enum", + EnumValues = enumArray.Select(e => e?.GetValue() ?? "").ToList(), + Nullable = nullable + }; + } + + var type = obj["type"]?.GetValue() ?? "object"; + + if (type == "array") + { + return new SchemaModel + { + Type = "array", + Items = obj["items"] is JsonNode items ? ParseSchema3(items) : null, + Nullable = nullable + }; + } + + if (type == "object") + { + Dictionary? props = null; + if (obj["properties"] is JsonObject propsNode) + { + props = new Dictionary(); + 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(), + Nullable = nullable + }; + } + + private static ApiDocument ImportSwagger2(JsonNode root) + { + var doc = new ApiDocument(); + + if (root["info"] is JsonObject info) + { + doc.Info.Title = info["title"]?.GetValue() ?? "API"; + doc.Info.Version = info["version"]?.GetValue() ?? "v1"; + doc.Info.Description = info["description"]?.GetValue(); + } + + var host = root["host"]?.GetValue(); + var basePath = root["basePath"]?.GetValue() ?? "/"; + var scheme = root["schemes"] is JsonArray schemes && schemes.Count > 0 + ? schemes[0]?.GetValue() ?? "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(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() ?? $"{method}_{SanitizePath(path)}", + Summary = op["summary"]?.GetValue(), + Description = op["description"]?.GetValue(), + Method = method, + Path = path, + Deprecated = op["deprecated"]?.GetValue() ?? 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() ?? "query"; + + if (inLoc == "body") + { + endpoint.RequestBody = new RequestBodyModel + { + Required = param["required"]?.GetValue() ?? 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() ?? "", + In = inLoc, + Required = param["required"]?.GetValue() ?? false, + Description = param["description"]?.GetValue(), + 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(), + Schema = schema + }); + } + } + + return endpoint; + } + + private static SchemaModel ParseInlineSchema2(JsonObject param) + { + if (param["$ref"]?.GetValue() 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() ?? "").ToList() + }; + } + + var type = param["type"]?.GetValue() ?? "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() + }; + } + + private static SchemaModel ParseSchema2(JsonNode node) + { + if (node is not JsonObject obj) + { + return new SchemaModel { Type = "object" }; + } + + if (obj["$ref"]?.GetValue() 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() ?? "").ToList() + }; + } + + var type = obj["type"]?.GetValue() ?? "object"; + + if (type == "array") + { + return new SchemaModel + { + Type = "array", + Items = obj["items"] is JsonNode items ? ParseSchema2(items) : null + }; + } + + if (type == "object") + { + Dictionary? props = null; + if (obj["properties"] is JsonObject propsNode) + { + props = new Dictionary(); + 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() + }; + } + + 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 ParseStringArray(JsonNode? node) + { + var list = new List(); + if (node is JsonArray arr) + { + foreach (var item in arr) + { + if (item?.GetValue() is string s) + { + list.Add(s); + } + } + } + + return list; + } + + private static List? ParseStringList(JsonNode? node) + { + if (node is not JsonArray arr || arr.Count == 0) + { + return null; + } + + return arr.Select(e => e?.GetValue() ?? "").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('}', '_'); + } +} diff --git a/DoxaApi/Middleware/ApiDocsMiddleware.cs b/DoxaApi/Middleware/ApiDocsMiddleware.cs new file mode 100644 index 0000000..3b46954 --- /dev/null +++ b/DoxaApi/Middleware/ApiDocsMiddleware.cs @@ -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? configure = null) + { + + var options = app.ApplicationServices.GetService() ?? 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(); + 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); + } + } +} diff --git a/DoxaApi/Models/ApiDocument.cs b/DoxaApi/Models/ApiDocument.cs new file mode 100644 index 0000000..d909d06 --- /dev/null +++ b/DoxaApi/Models/ApiDocument.cs @@ -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 Servers { get; set; } = new(); + + [JsonPropertyName("groups")] + public List Groups { get; set; } = new(); + + [JsonPropertyName("schemas")] + public Dictionary Schemas { get; set; } = new(); + } +} diff --git a/DoxaApi/Models/ApiEndpoint.cs b/DoxaApi/Models/ApiEndpoint.cs new file mode 100644 index 0000000..947124e --- /dev/null +++ b/DoxaApi/Models/ApiEndpoint.cs @@ -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 Tags { get; set; } = new(); + + [JsonPropertyName("parameters")] + public List Parameters { get; set; } = new(); + + [JsonPropertyName("requestBody")] + public RequestBodyModel? RequestBody { get; set; } + + [JsonPropertyName("responses")] + public List Responses { get; set; } = new(); + } +} diff --git a/DoxaApi/Models/ApiGroup.cs b/DoxaApi/Models/ApiGroup.cs new file mode 100644 index 0000000..a2931f5 --- /dev/null +++ b/DoxaApi/Models/ApiGroup.cs @@ -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 Endpoints { get; set; } = new(); + } +} diff --git a/DoxaApi/Models/ApiInfo.cs b/DoxaApi/Models/ApiInfo.cs new file mode 100644 index 0000000..21a6452 --- /dev/null +++ b/DoxaApi/Models/ApiInfo.cs @@ -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"; + } +} diff --git a/DoxaApi/Models/ApiParameter.cs b/DoxaApi/Models/ApiParameter.cs new file mode 100644 index 0000000..992d1f5 --- /dev/null +++ b/DoxaApi/Models/ApiParameter.cs @@ -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; } + } +} diff --git a/DoxaApi/Models/RequestBodyModel.cs b/DoxaApi/Models/RequestBodyModel.cs new file mode 100644 index 0000000..fb8366b --- /dev/null +++ b/DoxaApi/Models/RequestBodyModel.cs @@ -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; } + } +} diff --git a/DoxaApi/Models/ResponseModel.cs b/DoxaApi/Models/ResponseModel.cs new file mode 100644 index 0000000..e2e0306 --- /dev/null +++ b/DoxaApi/Models/ResponseModel.cs @@ -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; } + } +} diff --git a/DoxaApi/Models/SchemaModel.cs b/DoxaApi/Models/SchemaModel.cs new file mode 100644 index 0000000..afc378d --- /dev/null +++ b/DoxaApi/Models/SchemaModel.cs @@ -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? Properties { get; set; } + + [JsonPropertyName("required")] + public List? Required { get; set; } + + [JsonPropertyName("enumValues")] + public List? EnumValues { get; set; } + + [JsonPropertyName("nullable")] + public bool Nullable { get; set; } + } +} diff --git a/DoxaApi/ServiceCollectionExtensions.cs b/DoxaApi/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..c027679 --- /dev/null +++ b/DoxaApi/ServiceCollectionExtensions.cs @@ -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? configure = null) + { + var options = new DoxaApiOptions(); + configure?.Invoke(options); + services.AddSingleton(options); + return services; + } + } +} diff --git a/DoxaApi/UI/Assets/app.css b/DoxaApi/UI/Assets/app.css new file mode 100644 index 0000000..77dcc27 --- /dev/null +++ b/DoxaApi/UI/Assets/app.css @@ -0,0 +1,1473 @@ +:root { + --accent: #6366f1; /* overridden inline per-instance */ + --accent-ink: #ffffff; + --m-get: #34D399; + --m-post: #5B9CFF; + --m-put: #F5B947; + --m-patch: #C792EA; + --m-delete: #FB7185; + --m-default: #8A93A6; + --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Consolas, Menlo, monospace; + --radius-sm: 5px; + --radius-md: 8px; + --radius-lg: 14px; + --nav-w: 288px; + --try-w: 432px; + --topbar-h: 58px; + --ease: cubic-bezier(0.16, 1, 0.3, 1); +} + +[data-theme="dark"], [data-theme="auto"] { + --bg-0: #08090D; + --bg-1: #0E1015; + --bg-2: #15171E; + --bg-3: #1C1F28; + --bg-raised: #181B23; + --border: #232733; + --border-soft: #1A1D26; + --text-0: #EEF0F4; + --text-1: #9CA3B5; + --text-2: #696F80; + --code-bg: #0B0C10; + --shadow: 0 16px 48px rgba(0,0,0,0.55); + --glow-alpha: 0.16; + --grid-line: rgba(255,255,255,0.025); +} + +[data-theme="light"] { + --bg-0: #FAFAFA; + --bg-1: #FFFFFF; + --bg-2: #F5F5F7; + --bg-3: #ECEDF1; + --bg-raised: #FFFFFF; + --border: #E5E6EB; + --border-soft: #EEEFF2; + --text-0: #14151A; + --text-1: #5B5F6B; + --text-2: #92959E; + --code-bg: #F5F5F7; + --shadow: 0 16px 48px rgba(20,21,26,0.10); + --glow-alpha: 0.08; + --grid-line: rgba(0,0,0,0.025); +} + +@media (prefers-color-scheme: light) { + [data-theme="auto"] { + --bg-0: #FAFAFA; + --bg-1: #FFFFFF; + --bg-2: #F5F5F7; + --bg-3: #ECEDF1; + --bg-raised: #FFFFFF; + --border: #E5E6EB; + --border-soft: #EEEFF2; + --text-0: #14151A; + --text-1: #5B5F6B; + --text-2: #92959E; + --code-bg: #F5F5F7; + --shadow: 0 16px 48px rgba(20,21,26,0.10); + --glow-alpha: 0.08; + --grid-line: rgba(0,0,0,0.025); + } +} + +* { + box-sizing: border-box; +} + +html, body { + height: 100%; +} + +body { + margin: 0; + background: var(--bg-0); + color: var(--text-0); + font-family: var(--font-ui); + font-size: 14px; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +button { + font-family: inherit; +} + +a { + color: inherit; +} + +::selection { + background: color-mix(in srgb, var(--accent) 35%, transparent); +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +/* scrollbars */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + + *::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + *::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 8px; + } + + *::-webkit-scrollbar-track { + background: transparent; + } + +.app-shell { + height: 100vh; + display: flex; + flex-direction: column; + background-image: linear-gradient(var(--grid-line) 1px, transparent 1px), linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); + background-size: 28px 28px; +} + +.topbar { + height: var(--topbar-h); + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 22px; + padding: 0 20px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--bg-1) 92%, transparent); + backdrop-filter: blur(10px); + position: relative; + z-index: 30; +} + +.topbar-brand { + display: flex; + align-items: center; + gap: 10px; + min-width: 160px; +} + +.brand-mark { + width: 26px; + height: 26px; + flex: 0 0 auto; + border-radius: 7px; + background: linear-gradient(155deg, var(--accent), color-mix(in srgb, var(--accent) 55%, #000 15%)); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent), 0 4px 14px color-mix(in srgb, var(--accent) var(--glow-alpha), transparent); +} + + .brand-mark svg { + width: 15px; + height: 15px; + color: var(--accent-ink); + } + +.brand-title { + font-weight: 650; + font-size: 15px; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.brand-version { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--text-2); + background: var(--bg-3); + border: 1px solid var(--border); + padding: 1px 6px; + border-radius: 20px; + margin-left: 2px; +} + +.topbar-search { + flex: 1 1 auto; + max-width: 480px; + display: flex; + align-items: center; + gap: 9px; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0 11px; + height: 36px; + color: var(--text-2); + transition: border-color .15s var(--ease), box-shadow .15s var(--ease); +} + + .topbar-search:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent); + } + +.search-icon { + width: 15px; + height: 15px; + flex: 0 0 auto; +} + +.topbar-search input { + flex: 1 1 auto; + background: transparent; + border: none; + outline: none; + color: var(--text-0); + font-size: 13.5px; + min-width: 0; +} + + .topbar-search input::placeholder { + color: var(--text-2); + } + +.topbar-search kbd { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-2); + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 5px; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto; +} + +.icon-btn { + width: 34px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-md); + color: var(--text-1); + cursor: pointer; + transition: background .12s ease, border-color .12s ease, color .12s ease; +} + + .icon-btn:hover { + background: var(--bg-2); + border-color: var(--border); + color: var(--text-0); + } + + .icon-btn svg { + width: 17px; + height: 17px; + } + +.icon-moon { + display: none; +} + +[data-theme="light"] .icon-sun { + display: none; +} + +[data-theme="light"] .icon-moon { + display: block; +} + +.text-link { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-2); + text-decoration: none; + border: 1px solid var(--border); + padding: 6px 10px; + border-radius: var(--radius-md); + transition: color .12s ease, border-color .12s ease; + display: inline-flex; + align-items: center; + gap: 6px; +} + + .text-link:hover { + color: var(--text-0); + border-color: var(--text-2); + } + +.body-grid { + flex: 1 1 auto; + display: grid; + grid-template-columns: var(--nav-w) 1fr var(--try-w); + min-height: 0; +} + +/* Nav pane (left) */ +.nav-pane { + border-right: 1px solid var(--border); + background: color-mix(in srgb, var(--bg-1) 96%, transparent); + overflow-y: auto; + min-width: 0; + position: relative; +} + +.nav-content { + padding: 16px 10px 40px; +} + +.nav-overview-link { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + background: transparent; + border: 1px solid transparent; + padding: 8px 10px; + margin-bottom: 14px; + color: var(--text-1); + font-weight: 600; + font-size: 12.5px; + cursor: pointer; + border-radius: var(--radius-sm); + text-align: left; +} + + .nav-overview-link svg { + width: 14px; + height: 14px; + flex: 0 0 auto; + color: var(--text-2); + } + + .nav-overview-link:hover { + background: var(--bg-2); + color: var(--text-0); + } + + .nav-overview-link.active { + background: var(--bg-3); + color: var(--text-0); + border-color: var(--border); + } + +.nav-group { + margin-bottom: 4px; +} + +.nav-group-header { + display: flex; + align-items: center; + gap: 7px; + width: 100%; + background: transparent; + border: none; + padding: 8px 8px; + color: var(--text-1); + font-weight: 650; + font-size: 11.5px; + letter-spacing: 0.05em; + text-transform: uppercase; + cursor: pointer; + border-radius: var(--radius-sm); +} + + .nav-group-header:hover { + color: var(--text-0); + background: var(--bg-2); + } + + .nav-group-header .chev { + width: 11px; + height: 11px; + transition: transform .18s var(--ease); + flex: 0 0 auto; + color: var(--text-2); + } + +.nav-group.collapsed .chev { + transform: rotate(-90deg); +} + +.nav-group.collapsed .nav-endpoints { + display: none; +} + +.nav-group-count { + margin-left: auto; + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--text-2); + background: var(--bg-3); + border-radius: 20px; + padding: 1px 6px; +} + +.nav-endpoints { + display: flex; + flex-direction: column; + gap: 1px; + padding: 2px 0 10px; + position: relative; +} + +.nav-endpoint { + display: flex; + align-items: center; + gap: 9px; + padding: 7px 8px 7px 10px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + cursor: pointer; + text-align: left; + width: 100%; + position: relative; + transition: background .1s ease; +} + + .nav-endpoint::before { + content: ""; + position: absolute; + left: 0; + top: 4px; + bottom: 4px; + width: 3px; + border-radius: 3px; + background: var(--method-color, var(--text-2)); + opacity: 0; + transition: opacity .12s ease; + } + + .nav-endpoint:hover { + background: var(--bg-2); + } + + .nav-endpoint.active { + background: var(--bg-3); + } + + .nav-endpoint.active::before { + opacity: 1; + } + +.method-tag { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--method-color, var(--text-2)); + width: 36px; + flex: 0 0 auto; +} + +.nav-endpoint-path { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nav-endpoint.active .nav-endpoint-path { + color: var(--text-0); +} + +.nav-endpoint.deprecated .nav-endpoint-path { + text-decoration: line-through; + color: var(--text-2); +} + +.nav-empty { + padding: 30px 14px; + color: var(--text-2); + font-size: 12.5px; + text-align: center; + line-height: 1.6; +} + +/* Detail pane (middle) */ +.detail-pane { + overflow-y: auto; + min-width: 0; + background: var(--bg-0); +} + +.detail-content { + max-width: 800px; + padding: 0 36px 90px; +} + +/* Welcome / overview screen */ +.overview-hero { + padding: 56px 0 36px; + border-bottom: 1px solid var(--border-soft); + margin-bottom: 32px; +} + +.overview-eyebrow { + display: inline-flex; + align-items: center; + gap: 7px; + font-family: var(--font-mono); + font-size: 11.5px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); + padding: 4px 10px; + border-radius: 20px; + margin-bottom: 18px; +} + + .overview-eyebrow svg { + width: 12px; + height: 12px; + } + +.overview-title { + font-size: 34px; + font-weight: 700; + letter-spacing: -0.02em; + margin: 0 0 12px; + line-height: 1.15; +} + +.overview-desc { + font-size: 15px; + color: var(--text-1); + line-height: 1.65; + max-width: 560px; + margin: 0 0 26px; +} + +.overview-meta { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.overview-pill { + display: inline-flex; + align-items: center; + gap: 7px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-1); + background: var(--bg-2); + border: 1px solid var(--border); + padding: 6px 11px; + border-radius: var(--radius-md); +} + + .overview-pill svg { + width: 13px; + height: 13px; + color: var(--text-2); + } + +.overview-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + margin-bottom: 36px; +} + +.overview-stat { + background: var(--bg-1); + padding: 18px 20px; +} + +.overview-stat-value { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.02em; + font-family: var(--font-mono); +} + +.overview-stat-label { + font-size: 11.5px; + color: var(--text-2); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-top: 3px; +} + +.overview-section-title { + font-size: 11.5px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-2); + margin: 0 0 14px; +} + +.overview-group-card { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + margin-bottom: 14px; + overflow: hidden; + background: var(--bg-1); +} + +.overview-group-card-header { + padding: 14px 18px; + border-bottom: 1px solid var(--border-soft); + font-weight: 650; + font-size: 14px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.overview-group-routes { + display: flex; + flex-direction: column; +} + +.overview-route-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 18px; + border-bottom: 1px solid var(--border-soft); + cursor: pointer; + transition: background .1s ease; +} + + .overview-route-row:last-child { + border-bottom: none; + } + + .overview-route-row:hover { + background: var(--bg-2); + } + + .overview-route-row .method-badge { + width: 58px; + text-align: center; + flex: 0 0 auto; + } + + .overview-route-row .route-path { + font-family: var(--font-mono); + font-size: 12.5px; + color: var(--text-0); + flex: 0 0 auto; + } + + .overview-route-row .route-summary { + color: var(--text-2); + font-size: 12.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .overview-route-row .route-arrow { + margin-left: auto; + width: 14px; + height: 14px; + color: var(--text-2); + flex: 0 0 auto; + opacity: 0; + transform: translateX(-3px); + transition: opacity .12s ease, transform .12s ease; + } + + .overview-route-row:hover .route-arrow { + opacity: 1; + transform: none; + } + +/* Endpoint detail */ +.endpoint-header { + padding: 30px 0 0; + margin-bottom: 6px; +} + +.breadcrumb { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-2); + margin-bottom: 16px; +} + + .breadcrumb svg { + width: 11px; + height: 11px; + } + + .breadcrumb .current { + color: var(--text-1); + } + +.request-line { + display: flex; + align-items: stretch; + font-family: var(--font-mono); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--code-bg); + overflow: hidden; + margin-bottom: 18px; +} + +.method-badge { + font-size: 11.5px; + font-weight: 700; + letter-spacing: 0.03em; + color: var(--bg-0); + background: var(--method-color, var(--text-2)); + padding: 9px 14px; + flex: 0 0 auto; + display: flex; + align-items: center; +} + +.request-line .endpoint-path { + flex: 1 1 auto; + font-size: 14px; + color: var(--text-0); + word-break: break-all; + padding: 9px 14px; + display: flex; + align-items: center; +} + +.request-line .copy-route-btn { + flex: 0 0 auto; + border: none; + border-left: 1px solid var(--border); + background: transparent; + color: var(--text-2); + cursor: pointer; + padding: 0 13px; + display: flex; + align-items: center; + transition: color .12s ease, background .12s ease; +} + + .request-line .copy-route-btn:hover { + color: var(--text-0); + background: var(--bg-2); + } + + .request-line .copy-route-btn svg { + width: 14px; + height: 14px; + } + +.endpoint-summary { + font-size: 23px; + font-weight: 650; + letter-spacing: -0.015em; + margin: 0 0 8px; + line-height: 1.3; +} + +.endpoint-description { + color: var(--text-1); + font-size: 14px; + line-height: 1.65; + margin: 0; + max-width: 640px; +} + +.deprecated-banner { + display: flex; + align-items: center; + gap: 8px; + margin-top: 14px; + font-size: 12.5px; + color: var(--m-delete); + background: color-mix(in srgb, var(--m-delete) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--m-delete) 28%, transparent); + padding: 9px 12px; + border-radius: var(--radius-md); +} + + .deprecated-banner svg { + width: 15px; + height: 15px; + flex: 0 0 auto; + } + +.section { + margin-top: 34px; +} + +.section-title { + font-size: 11.5px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--text-2); + margin: 0 0 14px; + display: flex; + align-items: center; + gap: 8px; +} + + .section-title .count { + font-family: var(--font-mono); + font-weight: 600; + color: var(--text-2); + background: var(--bg-2); + border-radius: 20px; + padding: 0px 7px; + font-size: 10.5px; + } + +.param-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; +} + + .param-table th { + text-align: left; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-2); + font-weight: 650; + padding: 9px 12px; + background: var(--bg-2); + border-bottom: 1px solid var(--border); + } + + .param-table td { + padding: 11px 12px; + border-bottom: 1px solid var(--border-soft); + vertical-align: top; + } + + .param-table tr:last-child td { + border-bottom: none; + } + +.param-name { + font-family: var(--font-mono); + font-size: 12.5px; + color: var(--text-0); + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; +} + +.param-required { + color: var(--m-delete); + font-size: 11px; +} + +.param-loc { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--text-2); + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 5px; +} + +.param-type { + font-family: var(--font-mono); + font-size: 12px; + color: var(--accent); +} + +.param-desc { + color: var(--text-1); + font-size: 12.5px; + line-height: 1.55; +} + +.schema-box { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 16px 18px; + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.75; + overflow-x: auto; + position: relative; +} + +.schema-line { + white-space: pre; +} + +.schema-key { + color: var(--text-0); +} + +.schema-punct { + color: var(--text-2); +} + +.schema-type { + color: var(--accent); +} + +.schema-comment { + color: var(--text-2); + font-style: italic; +} + +.schema-required-mark { + color: var(--m-delete); +} + +.schema-nullable-mark { + color: var(--text-2); + font-style: italic; +} + +.response-block { + margin-bottom: 14px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; +} + +.response-block-header { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: var(--bg-2); + border-bottom: 1px solid var(--border); +} + +.status-pill { + font-family: var(--font-mono); + font-size: 11.5px; + font-weight: 700; + padding: 2px 8px; + border-radius: 4px; +} + +.status-2xx { + color: var(--m-get); + background: color-mix(in srgb, var(--m-get) 15%, transparent); +} + +.status-4xx { + color: var(--m-put); + background: color-mix(in srgb, var(--m-put) 15%, transparent); +} + +.status-5xx { + color: var(--m-delete); + background: color-mix(in srgb, var(--m-delete) 15%, transparent); +} + +.response-desc { + font-size: 12.5px; + color: var(--text-1); +} + +.response-block-body { + padding: 13px 15px; +} + +/* Try pane (right) */ +.try-pane { + border-left: 1px solid var(--border); + background: color-mix(in srgb, var(--bg-1) 96%, transparent); + overflow-y: auto; + min-width: 0; +} + +.try-content { + padding: 24px 22px 60px; +} + +.try-empty { + padding: 70px 18px; + text-align: center; + color: var(--text-2); + font-size: 12.5px; + line-height: 1.6; +} + + .try-empty svg { + width: 30px; + height: 30px; + color: var(--border); + margin-bottom: 12px; + } + +.try-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 18px; + padding-bottom: 14px; + border-bottom: 1px solid var(--border-soft); +} + +.try-title { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-2); + display: flex; + align-items: center; + gap: 7px; +} + + .try-title .live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--method-color, var(--m-get)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--method-color, var(--m-get)) 25%, transparent); + } + +.try-tabs { + display: flex; + gap: 2px; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 2px; + margin-bottom: 18px; +} + +.try-tab { + flex: 1 1 auto; + text-align: center; + border: none; + background: transparent; + color: var(--text-2); + font-size: 12px; + font-weight: 600; + padding: 7px 8px; + border-radius: 6px; + cursor: pointer; + font-family: var(--font-mono); +} + + .try-tab.active { + background: var(--bg-raised); + color: var(--text-0); + box-shadow: 0 1px 2px rgba(0,0,0,0.08); + } + +.field-group { + margin-bottom: 16px; +} + +.field-label { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-1); + margin-bottom: 6px; +} + +.field-sublabel { + font-family: var(--font-mono); + margin-bottom: 4px; + color: var(--text-2); + font-size: 11px; + display: flex; + align-items: center; + gap: 5px; +} + + .field-sublabel .req-star { + color: var(--m-delete); + } + + .field-sublabel .type-hint { + color: var(--text-2); + font-weight: 400; + margin-left: auto; + opacity: 0.8; + } + +.field-input { + width: 100%; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 9px 11px; + color: var(--text-0); + font-family: var(--font-mono); + font-size: 12.5px; + outline: none; + transition: border-color .12s ease, box-shadow .12s ease; +} + + .field-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); + } + +textarea.field-input { + resize: vertical; + min-height: 140px; + line-height: 1.65; +} + +.send-btn { + width: 100%; + background: var(--method-color, var(--accent)); + color: #08090D; + border: none; + border-radius: var(--radius-md); + padding: 11px; + font-weight: 700; + font-size: 13px; + cursor: pointer; + margin-top: 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: filter .12s ease, transform .04s ease; + font-family: var(--font-mono); +} + + .send-btn:hover { + filter: brightness(1.08); + } + + .send-btn:active { + transform: scale(0.99); + } + + .send-btn:disabled { + opacity: 0.6; + cursor: default; + } + + .send-btn svg { + width: 14px; + height: 14px; + } + +.spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(0,0,0,0.25); + border-top-color: #08090D; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.response-panel { + margin-top: 24px; + border-top: 1px solid var(--border); + padding-top: 20px; +} + +.response-meta { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.response-time { + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--text-2); + display: flex; + align-items: center; + gap: 4px; +} + + .response-time svg { + width: 11px; + height: 11px; + } + +.response-body { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 13px 15px; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.65; + max-height: 420px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.response-error { + color: var(--m-delete); +} + +.copy-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--text-2); + border-radius: 5px; + padding: 4px 9px; + font-size: 11px; + cursor: pointer; + font-family: var(--font-mono); + display: inline-flex; + align-items: center; + gap: 5px; + transition: color .12s ease, border-color .12s ease; +} + + .copy-btn svg { + width: 11px; + height: 11px; + } + + .copy-btn:hover { + color: var(--text-0); + border-color: var(--text-1); + } + +.json-key { + color: #7FB3FF; +} + +.json-string { + color: #5FE3A8; +} + +.json-number { + color: #F5B947; +} + +.json-boolean { + color: #D5A6F5; +} + +.json-null { + color: var(--text-2); +} + +.action-group { + display: flex; + gap: 8px; +} + +.action-btn { + height: 34px; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-2); + color: var(--text-0); + cursor: pointer; + font-weight: 600; + transition: all .18s var(--ease); +} + + .action-btn:hover { + transform: translateY(-1px); + border-color: color-mix(in srgb,var(--accent) 40%,var(--border)); + box-shadow: 0 8px 24px color-mix(in srgb,var(--accent) 15%,transparent); + } + +.action-btn-primary { + background: linear-gradient( 135deg, var(--accent), color-mix(in srgb,var(--accent) 70%,#ffffff 10%) ); + color: white; + border-color: transparent; +} + + +/* curl snippet */ +.curl-box { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 13px 15px; + font-family: var(--font-mono); + font-size: 11.5px; + line-height: 1.7; + overflow-x: auto; + white-space: pre; + position: relative; + color: var(--text-1); +} + + .curl-box .curl-flag { + color: var(--accent); + } + + .curl-box .curl-string { + color: #5FE3A8; + } + +.curl-header-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +/* responsive: collapse try pane on narrow screens behind a toggle */ +@media (max-width: 1180px) { + :root { + --try-w: 380px; + } +} + +@media (max-width: 980px) { + .body-grid { + grid-template-columns: 240px 1fr; + } + + .try-pane { + display: none; + } + + .try-pane.open { + display: block; + position: fixed; + right: 0; + top: var(--topbar-h); + bottom: 0; + width: 380px; + box-shadow: var(--shadow); + z-index: 20; + } +} + +@media (max-width: 680px) { + .body-grid { + grid-template-columns: 1fr; + } + + .nav-pane { + display: none; + position: fixed; + left: 0; + top: var(--topbar-h); + bottom: 0; + width: 270px; + z-index: 20; + box-shadow: var(--shadow); + } + + .nav-pane.open { + display: block; + } + + .topbar-search { + max-width: none; + } + + .overview-stats { + grid-template-columns: 1fr; + } +} + +.skeleton { + background: linear-gradient(90deg, var(--bg-2) 25%, var(--bg-3) 37%, var(--bg-2) 63%); + background-size: 400% 100%; + animation: shimmer 1.4s ease infinite; + border-radius: var(--radius-sm); +} + +@keyframes shimmer { + 0% { + background-position: 100% 50%; + } + + 100% { + background-position: 0 50%; + } +} + +.fade-in { + animation: fadeIn .22s var(--ease); +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(3px); + } + + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; + } +} + + +.api-switcher{ + display:flex; + align-items:center; + margin-right:12px; +} + +#apiSelector{ + min-width:220px; + height:38px; + border-radius:10px; + padding:0 12px; + background:var(--panel,#1e1e1e); + color:var(--text,#fff); + border:1px solid var(--border,#3a3a3a); + font-weight:600; + cursor:pointer; +} + +#apiSelector:hover{ + filter:brightness(1.05); +} + +#apiSelector:focus{ + outline:none; +} + +.request-builder,.header-row,.query-row{display:flex;gap:8px;margin-bottom:8px} +.header-key,.query-key{flex:0 0 35%} +.header-value,.query-value,.request-url{flex:1} +.postman-section{margin-top:12px;padding-top:12px;border-top:1px solid var(--border)} diff --git a/DoxaApi/UI/Assets/app.js b/DoxaApi/UI/Assets/app.js new file mode 100644 index 0000000..28c0c65 --- /dev/null +++ b/DoxaApi/UI/Assets/app.js @@ -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)=>``).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 = ``; + 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(() => `
`) + .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 = ``; + + if (groups.length === 0) { + html += ``; + 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 += ` + `; + } + + if (term && totalMatches === 0) { + html += ``; + } else { + html += groupHtml; + } + + el.nav.innerHTML = html; + } + + function navEndpointHtml(group, endpoint) { + const isActive = + activeEndpoint && + activeEndpoint.endpoint.operationId === endpoint.operationId; + return ` + `; + } + + 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 = `
`; + html += `
`; + html += `
+ + API reference +
`; + html += `

${escapeHtml(info.title || "API Documentation")}

`; + if (info.description) { + html += `

${escapeHtml(info.description)}

`; + } + html += `
`; + if (info.version) { + html += ` + + ${escapeHtml(info.version)} + `; + } + if (spec.servers && spec.servers.length) { + html += ` + + ${escapeHtml(spec.servers[0])} + `; + } + html += `
`; + html += `
`; // hero + + html += `
`; + html += `
${groups.length}
Groups
`; + html += `
${totalEndpointCount()}
Endpoints
`; + html += `
${totalSchemaCount()}
Schemas
`; + html += `
`; + + html += `

Browse by group

`; + for (const group of groups) { + html += `
+
+ ${escapeHtml(group.name)} + ${group.endpoints.length} +
+
`; + for (const ep of group.endpoints) { + html += `
+ ${ep.method} + ${escapeHtml(ep.path)} + ${escapeHtml(ep.summary || "")} + +
`; + } + html += `
`; + } + + html += `
`; + el.detail.innerHTML = html; + el.detail.scrollTop = 0; + } + + function renderTryEmpty() { + el.try.innerHTML = `
+ + Pick an endpoint to send a live request. +
`; + } + + 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 = `
`; + html += `
`; + html += ``; + html += `
+ ${endpoint.method} + ${escapeHtml(endpoint.path)} + +
`; + html += `

${escapeHtml(endpoint.summary || endpoint.operationId)}

`; + if (endpoint.description) { + html += `

${escapeHtml(endpoint.description)}

`; + } + if (endpoint.deprecated) { + html += `
+ + Deprecated - this endpoint may be removed in a future version +
`; + } + html += `
`; + + if (endpoint.parameters && endpoint.parameters.length > 0) { + html += `

Parameters ${endpoint.parameters.length}

`; + html += ``; + for (const p of endpoint.parameters) { + html += ` + + + + + `; + } + html += `
NameLocated inTypeDescription
${escapeHtml(p.name)}${p.required ? '*' : ""}${p.in}${schemaTypeLabel(p.schema)}${escapeHtml(p.description || "-")}
`; + } + + if (endpoint.requestBody) { + html += `

Request body

`; + html += `
${renderSchemaTree(endpoint.requestBody.schema, 0)}
`; + } + + if (endpoint.responses && endpoint.responses.length > 0) { + html += `

Responses ${endpoint.responses.length}

`; + for (const r of endpoint.responses) { + const cls = r.statusCode[0] === "2" ? "status-2xx" : r.statusCode[0] === "4" ? "status-4xx" : "status-5xx"; + html += `
+
+ ${r.statusCode} + ${escapeHtml(r.description || "")} +
`; + if (r.schema) { + html += `
${renderSchemaTree(r.schema, 0)}
`; + } + html += `
`; + } + html += `
`; + } + + html += `
`; + 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 = ``; + 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 `unknown`; + 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 = [`{`]; + 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 ? '?' : ""; + lines.push( + `${indent} ${escapeHtml(key)}${isReq ? '*' : ""}${nullableMark}: ${renderInlineType(propSchema, depth + 1)}${comma}` + ); + }); + lines.push(`${indent}}`); + return lines.join("\n"); + } + + if (schema.type === "array") { + return `[\n${indent} ${renderInlineType(schema.items, depth + 1)}\n${indent}]`; + } + + if (schema.type === "enum") { + return `enum (${(schema.enumValues || []).join(" | ")})`; + } + + return `${schema.type}${schema.format ? " (" + schema.format + ")" : ""}`; + } + + function renderInlineType(schema, depth) { + if (!schema) return `any`; + if (schema.refName) { + if (spec.schemas && spec.schemas[schema.refName] && depth < 6) { + return renderSchemaTree({ ...spec.schemas[schema.refName] }, depth); + } + return `${escapeHtml(schema.refName)}`; + } + if (schema.type === "object" && schema.properties) return renderSchemaTree(schema, depth); + if (schema.type === "array") { + return `Array<${renderInlineType(schema.items, depth)}>`; + } + if (schema.type === "enum") { + return `(${(schema.enumValues || []).join(" | ")})`; + } + return `${schema.type}${schema.format ? " (" + schema.format + ")" : ""}`; + } + + // Try-it-out pane + function renderTry(group, endpoint) { + const methodColorVar = `var(--m-${endpoint.method.toLowerCase()}, var(--m-default))`; + let html = `
`; + html += `
+ Try it +
`; + + html += `
+ + +
`; + + html += `
`; + + 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 += `
Path parameters
`; + for (const p of pathParams) { + html += `
+
${escapeHtml(p.name)}${p.required ? '*' : ""}${schemaTypeLabel(p.schema)}
+ +
`; + } + html += `
`; + } + + if (queryParams.length) { + html += `
Query parameters
`; + for (const p of queryParams) { + html += `
+
${escapeHtml(p.name)}${p.required ? '*' : ""}${schemaTypeLabel(p.schema)}
+ +
`; + } + html += `
`; + } + + if (headerParams.length) { + html += `
Headers
`; + for (const p of headerParams) { + html += `
+
${escapeHtml(p.name)}${p.required ? '*' : ""}${schemaTypeLabel(p.schema)}
+ +
`; + } + html += `
`; + } + + if (endpoint.requestBody) { + const example = endpoint.requestBody.example || generateExampleJson(endpoint.requestBody.schema, 0); + html += `
+
Request body (JSON)
+ +
`; + } + + html += ``; + + html += `
`; + html += `
`; // tryTabBody + + html += `
+
+ Shell snippet + +
+
${buildCurlSnippet(endpoint)}
+
`; + + html += `
`; + + 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 = `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(`curl -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 '${escapeHtml(example)}'`); + } + 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 = ` +
+
+ ${statusLabel} + + + ${result.elapsedMs} ms + + +
+
${result.isJson ? syntaxHighlightJson(result.body) : escapeHtml(result.body) + }
+
`; + + 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 `${match}`; + } + ); + } + + // Global events + function bindGlobalEvents() + { + const importBtn = document.getElementById("importBtn"); + const importFile = document.getElementById("importFile"); + const exportDoxaApiBtn = document.getElementById("exportDoxaApiBtn"); + const exportOpenApiBtn = document.getElementById("exportOpenApiBtn"); + const exportSwaggerBtn = document.getElementById("exportSwaggerBtn"); + + importBtn?.addEventListener("click", () => importFile.click()); + + importFile?.addEventListener("change", async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + + const text = await file.text(); + + const res = await fetch("import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: text + }); + + if (!res.ok) { + alert(await res.text()); + return; + } + + const importedSpec = await res.json(); + const extend = apis.length > 0 && window.confirm("Extend the currently selected API?\n\nOK = Extend Current API\nCancel = Import As New API (default)"); + + if (extend) { + spec.groups = [...(spec.groups||[]), ...(importedSpec.groups||[])]; + spec.schemas = Object.assign(spec.schemas||{}, importedSpec.schemas||{}); + } else { + apis.push(importedSpec); + activeApiIndex = apis.length - 1; + spec = importedSpec; + } + + refreshApiSelector(); + if(spec.info && spec.info.title) el.brandTitle.textContent = spec.info.title; + if(spec.info && spec.info.version) el.brandVersion.textContent = spec.info.version; + + activeEndpoint = null; + + renderNav(""); + renderOverview(); + renderTryEmpty(); + }); + + function download(url, filename) { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + } + + exportDoxaApiBtn?.addEventListener("click", () => download("doxaApi.json", "DoxaApi.json")); + exportOpenApiBtn?.addEventListener("click", () => download("openapi.json", "openapi.json")); + exportSwaggerBtn?.addEventListener("click", () => download("swagger.json", "swagger.json")); + + el.nav.addEventListener("click", (e) => { + const overviewBtn = e.target.closest("[data-overview]"); + if (overviewBtn) { + activeEndpoint = null; + document.querySelectorAll(".nav-endpoint").forEach((b) => b.classList.remove("active")); + overviewBtn.classList.add("active"); + renderOverview(); + renderTryEmpty(); + return; + } + + const groupToggle = e.target.closest("[data-toggle-group]"); + if (groupToggle) { + const name = groupToggle.dataset.toggleGroup; + if (collapsedGroups.has(name)) collapsedGroups.delete(name); + else collapsedGroups.add(name); + localStorage.setItem("DoxaApi.collapsed", JSON.stringify([...collapsedGroups])); + renderNav(el.search.value); + return; + } + + const endpointBtn = e.target.closest("[data-op]"); + if (endpointBtn) { + const opId = endpointBtn.dataset.op; + for (const group of spec.groups) { + const endpoint = group.endpoints.find((ep) => ep.operationId === opId); + if (endpoint) { + selectEndpoint(group, endpoint); + break; + } + } + } + }); + + el.detail.addEventListener("click", (e) => { + const row = e.target.closest("[data-op]"); + if (!row) return; + const opId = row.dataset.op; + for (const group of spec.groups) { + const endpoint = group.endpoints.find((ep) => ep.operationId === opId); + if (endpoint) { + selectEndpoint(group, endpoint); + break; + } + } + }); + + el.search.addEventListener("input", () => renderNav(el.search.value)); + + document.addEventListener("keydown", (e) => { + if (e.key === "/" && document.activeElement !== el.search) { + e.preventDefault(); + el.search.focus(); + } + if (e.key === "Escape" && document.activeElement === el.search) { + el.search.blur(); + } + }); + + el.themeToggle.addEventListener("click", () => { + const root = document.documentElement; + const current = root.getAttribute("data-theme"); + const isDark = + current === "dark" || (current === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches); + const next = isDark ? "light" : "dark"; + root.setAttribute("data-theme", next); + localStorage.setItem("DoxaApi.theme", next); + }); + + const savedTheme = localStorage.getItem("DoxaApi.theme"); + if (savedTheme) document.documentElement.setAttribute("data-theme", savedTheme); + } + + // Helpers + function escapeHtml(str) { + return String(str ?? "").replace(/[&<>"']/g, (c) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }[c])); + } + + function escapeAttr(str) { + return escapeHtml(str); + } + + init(); +})(); + +window.DoxaApiPostmanLike = { + methods:["GET","POST","PUT","PATCH","DELETE","HEAD","OPTIONS"], + buildRequest:function(){ + return { + method: document.getElementById("customMethod")?.value || "GET", + url: document.getElementById("customUrl")?.value || "", + headers: [] + }; + } +}; diff --git a/DoxaApi/UI/Assets/index.html b/DoxaApi/UI/Assets/index.html new file mode 100644 index 0000000..7fd6146 --- /dev/null +++ b/DoxaApi/UI/Assets/index.html @@ -0,0 +1,71 @@ + + + + + + SampleApi + + + + + +
+ +
+
+ + + + + + + SampleApi + +
+
+
+
+ + + + +
+ + + +
+
+ +
+ + + + +
+
+
+ + + +
+
+ + + + + diff --git a/EonaCat.DoxaApi.sln b/EonaCat.DoxaApi.sln new file mode 100644 index 0000000..f3829f3 --- /dev/null +++ b/EonaCat.DoxaApi.sln @@ -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 diff --git a/LICENSE b/LICENSE index 494f695..ab37379 100644 --- a/LICENSE +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index c982bde..62fc359 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,162 @@ # EonaCat.DoxaApi -EonaCat.DoxaApi \ No newline at end of file +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** - `/// `, ``, and `` are read directly from your project's generated `.xml` doc file (enable `true` 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 +{ + /// Lists all users. // picked up automatically + [HttpGet] + public ActionResult> GetUsers() => ...; + + [HttpPost] + [EonaCat.DoxaApiExample("""{ "name": "Ada" }""")] // overrides the auto-generated example + public ActionResult 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 diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..0595b89 Binary files /dev/null and b/icon.png differ diff --git a/image.png b/image.png new file mode 100644 index 0000000..5ec406a Binary files /dev/null and b/image.png differ diff --git a/sample/SampleApi/Controllers/OrdersController.cs b/sample/SampleApi/Controllers/OrdersController.cs new file mode 100644 index 0000000..61a8c46 --- /dev/null +++ b/sample/SampleApi/Controllers/OrdersController.cs @@ -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 _orders = new(); + + [HttpGet] + public ActionResult> GetOrders() + => Ok(_orders.OrderByDescending(o => o.PlacedAt).ToList()); + + [HttpGet("{id}")] + public ActionResult GetById(Guid id) + { + var order = _orders.FirstOrDefault(o => o.Id == id); + return order is null ? NotFound() : Ok(order); + } + + [HttpPost] + public ActionResult 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 Cancel(Guid id) + { + var order = _orders.FirstOrDefault(o => o.Id == id); + if (order is null) + { + return NotFound(); + } + + order.Status = OrderStatus.Cancelled; + return Ok(order); + } + } +} diff --git a/sample/SampleApi/Controllers/UsersController.cs b/sample/SampleApi/Controllers/UsersController.cs new file mode 100644 index 0000000..dd9a61b --- /dev/null +++ b/sample/SampleApi/Controllers/UsersController.cs @@ -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 _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> 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 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 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 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(); + } + } +} diff --git a/sample/SampleApi/Models/Models.cs b/sample/SampleApi/Models/Models.cs new file mode 100644 index 0000000..a033c62 --- /dev/null +++ b/sample/SampleApi/Models/Models.cs @@ -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 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 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 Lines { get; set; } = new(); + } +} diff --git a/sample/SampleApi/Program.cs b/sample/SampleApi/Program.cs new file mode 100644 index 0000000..eece97d --- /dev/null +++ b/sample/SampleApi/Program.cs @@ -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(); diff --git a/sample/SampleApi/Properties/launchSettings.json b/sample/SampleApi/Properties/launchSettings.json new file mode 100644 index 0000000..38c14d7 --- /dev/null +++ b/sample/SampleApi/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "SampleApi": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:53010;http://localhost:53011" + } + } +} \ No newline at end of file diff --git a/sample/SampleApi/SampleApi.csproj b/sample/SampleApi/SampleApi.csproj new file mode 100644 index 0000000..98d4fca --- /dev/null +++ b/sample/SampleApi/SampleApi.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + true + $(NoWarn);CS1591 + + + + + + + +