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; } } }