341 lines
12 KiB
C#
341 lines
12 KiB
C#
using System.Reflection;
|
|
using System.Xml.Linq;
|
|
using EonaCat.DoxaApi.Attributes;
|
|
using EonaCat.DoxaApi.Models;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc.Abstractions;
|
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
|
|
|
namespace EonaCat.DoxaApi.Generation
|
|
{
|
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
|
|
|
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;
|
|
doc.SecuritySchemes = _options.SecuritySchemes;
|
|
doc.Features = new ApiFeatureFlags
|
|
{
|
|
MockServer = _options.EnableMockServer,
|
|
Console = _options.EnableConsole
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines which security schemes apply to an action, in priority order:
|
|
/// 1. Explicit [DoxaApiAllowAnonymous] on method or class -> no security.
|
|
/// 2. Explicit [DoxaApiAuth("schemeId")] on method (falls back to class) -> those schemes.
|
|
/// 3. Standard ASP.NET Core [Authorize] on method or class, combined with
|
|
/// DoxaApiOptions.DefaultSecurityScheme if one is configured.
|
|
/// 4. Otherwise: anonymous.
|
|
/// </summary>
|
|
private List<string> ResolveSecurity(ControllerActionDescriptor cad)
|
|
{
|
|
var method = cad.MethodInfo;
|
|
var controller = cad.ControllerTypeInfo;
|
|
|
|
bool anonymous = method.GetCustomAttribute<DoxaApiAllowAnonymousAttribute>() is not null
|
|
|| controller.GetCustomAttribute<DoxaApiAllowAnonymousAttribute>() is not null
|
|
|| method.GetCustomAttribute<AllowAnonymousAttribute>() is not null;
|
|
|
|
if (anonymous)
|
|
{
|
|
return new List<string>();
|
|
}
|
|
|
|
var methodSchemes = method.GetCustomAttributes<DoxaApiAuthAttribute>().Select(a => a.SchemeId).ToList();
|
|
if (methodSchemes.Count > 0)
|
|
{
|
|
return methodSchemes.Where(_options.SecuritySchemes.ContainsKey).ToList();
|
|
}
|
|
|
|
var classSchemes = controller.GetCustomAttributes<DoxaApiAuthAttribute>().Select(a => a.SchemeId).ToList();
|
|
if (classSchemes.Count > 0)
|
|
{
|
|
return classSchemes.Where(_options.SecuritySchemes.ContainsKey).ToList();
|
|
}
|
|
|
|
bool requiresAuth = method.GetCustomAttribute<AuthorizeAttribute>() is not null
|
|
|| controller.GetCustomAttribute<AuthorizeAttribute>() is not null;
|
|
|
|
if (requiresAuth && _options.DefaultSecurityScheme is not null
|
|
&& _options.SecuritySchemes.ContainsKey(_options.DefaultSecurityScheme))
|
|
{
|
|
return new List<string> { _options.DefaultSecurityScheme };
|
|
}
|
|
|
|
return new List<string>();
|
|
}
|
|
|
|
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) },
|
|
Security = ResolveSecurity(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" });
|
|
}
|
|
|
|
if (endpoint.Security.Count > 0)
|
|
{
|
|
endpoint.Responses.Add(new ResponseModel { StatusCode = "401", Description = "Unauthorized - missing or invalid credentials" });
|
|
endpoint.Responses.Add(new ResponseModel { StatusCode = "403", Description = "Forbidden - insufficient permissions" });
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
} |