Files
EonaCat.DoxaApi/DoxaApi/Generation/ApiDocumentGenerator.cs
T
2026-06-20 10:26:27 +02:00

279 lines
9.5 KiB
C#

using System.Reflection;
using System.Xml.Linq;
using EonaCat.DoxaApi.Attributes;
using EonaCat.DoxaApi.Models;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Controllers;
namespace EonaCat.DoxaApi.Generation
{
public sealed class ApiDocumentGenerator
{
private readonly IReadOnlyList<ActionDescriptor> _actions;
private readonly DoxaApiOptions _options;
private readonly Dictionary<string, XmlDocReader?> _xmlReadersByAssembly = new();
public ApiDocumentGenerator(IReadOnlyList<ActionDescriptor> actions, DoxaApiOptions options)
{
_actions = actions;
_options = options;
}
public ApiDocument Generate()
{
var doc = new ApiDocument
{
Info = new ApiInfo
{
Title = _options.Title,
Description = _options.Description,
Version = _options.Version
},
Servers = _options.Servers.ToList()
};
var schemaRegistry = new Dictionary<string, SchemaModel>();
var schemaBuilder = new SchemaBuilder(schemaRegistry);
var groups = new Dictionary<string, ApiGroup>(StringComparer.OrdinalIgnoreCase);
foreach (var action in _actions)
{
if (action is not ControllerActionDescriptor cad)
{
continue;
}
if (cad.MethodInfo.GetCustomAttribute<DoxaApiHiddenAttribute>() is not null)
{
continue;
}
if (cad.ControllerTypeInfo.GetCustomAttribute<DoxaApiHiddenAttribute>() is not null)
{
continue;
}
var groupName = ResolveGroupName(cad);
if (!groups.TryGetValue(groupName, out var group))
{
group = new ApiGroup { Name = groupName };
groups[groupName] = group;
}
var endpoint = BuildEndpoint(cad, schemaBuilder);
if (endpoint is not null)
{
group.Endpoints.Add(endpoint);
}
}
doc.Groups = groups.Values
.OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var group in doc.Groups)
{
group.Endpoints = group.Endpoints
.OrderBy(e => e.Path, StringComparer.OrdinalIgnoreCase)
.ThenBy(e => MethodSortOrder(e.Method))
.ToList();
}
doc.Schemas = schemaRegistry;
return doc;
}
private static int MethodSortOrder(string method) => method switch
{
"GET" => 0,
"POST" => 1,
"PUT" => 2,
"PATCH" => 3,
"DELETE" => 4,
_ => 5
};
private string ResolveGroupName(ControllerActionDescriptor cad)
{
var methodAttr = cad.MethodInfo.GetCustomAttribute<DoxaApiGroupAttribute>();
if (methodAttr is not null)
{
return methodAttr.Name;
}
var classAttr = cad.ControllerTypeInfo.GetCustomAttribute<DoxaApiGroupAttribute>();
if (classAttr is not null)
{
return classAttr.Name;
}
var name = cad.ControllerName;
return name;
}
private ApiEndpoint? BuildEndpoint(ControllerActionDescriptor cad, SchemaBuilder schemaBuilder)
{
var httpMethod = cad.ActionConstraints?
.OfType<Microsoft.AspNetCore.Mvc.ActionConstraints.HttpMethodActionConstraint>()
.FirstOrDefault()?.HttpMethods.FirstOrDefault() ?? "GET";
var path = "/" + (cad.AttributeRouteInfo?.Template?.TrimStart('/') ?? cad.ActionName);
var xmlReader = GetXmlReader(cad.MethodInfo.DeclaringType!.Assembly);
var xmlDoc = xmlReader?.GetMethodDoc(cad.MethodInfo);
var summaryAttr = cad.MethodInfo.GetCustomAttribute<DoxaApiSummaryAttribute>();
var descAttr = cad.MethodInfo.GetCustomAttribute<DoxaApiDescriptionAttribute>();
var exampleAttr = cad.MethodInfo.GetCustomAttribute<DoxaApiExampleAttribute>();
var obsoleteAttr = cad.MethodInfo.GetCustomAttribute<ObsoleteAttribute>();
var endpoint = new ApiEndpoint
{
OperationId = $"{cad.ControllerName}_{cad.ActionName}",
Method = httpMethod.ToUpperInvariant(),
Path = path,
Summary = summaryAttr?.Summary ?? xmlDoc?.Summary ?? HumanizeName(cad.ActionName),
Description = descAttr?.Description ?? xmlDoc?.Remarks,
Deprecated = obsoleteAttr is not null,
Tags = new List<string> { ResolveGroupName(cad) }
};
foreach (var param in cad.Parameters)
{
var bindingSource = param.BindingInfo?.BindingSource;
var inLocation = bindingSource?.Id switch
{
"Path" => "path",
"Query" => "query",
"Header" => "header",
"Body" => "body",
"Form" => "form",
_ => InferLocationFromPath(param.Name, path)
};
if (inLocation == "body")
{
endpoint.RequestBody = new RequestBodyModel
{
Required = true,
Schema = schemaBuilder.Build(param.ParameterType),
Example = exampleAttr?.Json
};
continue;
}
var paramDoc = xmlDoc?.Params.GetValueOrDefault(param.Name);
endpoint.Parameters.Add(new ApiParameter
{
Name = param.Name,
In = inLocation,
Required = inLocation == "path" || IsRequiredParam(param),
Description = paramDoc,
Schema = schemaBuilder.Build(param.ParameterType)
});
}
var returnType = cad.MethodInfo.ReturnType;
var responseSchema = schemaBuilder.Build(returnType);
var successCode = httpMethod.ToUpperInvariant() switch
{
"POST" => "201",
"DELETE" => "204",
_ => "200"
};
if (responseSchema.Type != "void")
{
endpoint.Responses.Add(new ResponseModel
{
StatusCode = successCode,
Description = xmlDoc?.Returns ?? "Success",
Schema = responseSchema
});
}
else
{
endpoint.Responses.Add(new ResponseModel
{
StatusCode = successCode,
Description = "Success"
});
}
if (endpoint.Parameters.Any(p => p.Required) || endpoint.RequestBody is not null)
{
endpoint.Responses.Add(new ResponseModel { StatusCode = "400", Description = "Invalid request" });
}
return endpoint;
}
private static bool IsRequiredParam(ParameterDescriptor param)
{
if (param is ControllerParameterDescriptor cpd)
{
var nullableUnderlying = Nullable.GetUnderlyingType(cpd.ParameterInfo.ParameterType);
bool hasDefault = cpd.ParameterInfo.HasDefaultValue;
bool isNullableType = nullableUnderlying is not null || !cpd.ParameterInfo.ParameterType.IsValueType;
return !hasDefault && !isNullableType;
}
return false;
}
private static string InferLocationFromPath(string paramName, string path)
{
return path.Contains("{" + paramName + "}", StringComparison.OrdinalIgnoreCase) ? "path" : "query";
}
private static string HumanizeName(string name)
{
var chars = new List<char>();
for (int i = 0; i < name.Length; i++)
{
if (i > 0 && char.IsUpper(name[i]) && !char.IsUpper(name[i - 1]))
{
chars.Add(' ');
}
chars.Add(name[i]);
}
return new string(chars.ToArray());
}
private XmlDocReader? GetXmlReader(Assembly assembly)
{
var key = assembly.GetName().Name ?? assembly.FullName ?? "unknown";
if (_xmlReadersByAssembly.TryGetValue(key, out var cached))
{
return cached;
}
var location = assembly.Location;
if (string.IsNullOrEmpty(location))
{
return null;
}
var xmlPath = Path.ChangeExtension(location, ".xml");
XmlDocReader? reader = null;
if (File.Exists(xmlPath))
{
try
{
reader = new XmlDocReader(XDocument.Load(xmlPath));
}
catch
{
reader = null;
}
}
_xmlReadersByAssembly[key] = reader;
return reader;
}
}
}