refactor: 重构待办事项模块结构与命名
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
using System.Reflection;
|
||||
using Hua.Todo.Application.Interfaces;
|
||||
using Hua.Todo.Application.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Microsoft.OpenApi;
|
||||
|
||||
namespace Hua.Todo.Application.DynamicApi.Swagger;
|
||||
|
||||
/// <summary>
|
||||
/// 为 Dynamic API(<c>/api/{service}/...</c>)补齐 OpenAPI 文档的过滤器。
|
||||
/// 说明:Dynamic API 通过中间件反射分发,不会被 ASP.NET Core 的 ApiExplorer 自动发现;
|
||||
/// 因此需要在 Swagger 生成阶段手动把这些端点补充到 OpenAPI Paths 中。
|
||||
/// </summary>
|
||||
public sealed class DynamicApiSwaggerDocumentFilter : IDocumentFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// 将 Dynamic API 端点追加到 <paramref name="swaggerDoc"/>。
|
||||
/// </summary>
|
||||
/// <param name="swaggerDoc">待输出的 OpenAPI 文档。</param>
|
||||
/// <param name="context">Swagger 生成上下文(用于 Schema 生成与引用管理)。</param>
|
||||
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
|
||||
{
|
||||
var dynamicApiAssembly = typeof(IDynamicApiService).Assembly;
|
||||
var serviceInterfaces = dynamicApiAssembly
|
||||
.GetTypes()
|
||||
.Where(t => t.IsInterface && typeof(IDynamicApiService).IsAssignableFrom(t))
|
||||
.Where(IsRemoteServiceEnabled)
|
||||
.ToList();
|
||||
|
||||
foreach (var serviceInterface in serviceInterfaces)
|
||||
{
|
||||
var serviceSegment = GetServiceRouteSegment(serviceInterface);
|
||||
if (string.IsNullOrWhiteSpace(serviceSegment))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddServiceOperations(swaggerDoc, context, serviceInterface, serviceSegment);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddServiceOperations(
|
||||
OpenApiDocument swaggerDoc,
|
||||
DocumentFilterContext context,
|
||||
Type serviceInterface,
|
||||
string serviceSegment)
|
||||
{
|
||||
var methods = serviceInterface
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(IsRemoteServiceEnabled)
|
||||
.ToList();
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
var httpMethod = GetHttpMethod(method);
|
||||
if (string.IsNullOrWhiteSpace(httpMethod))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryGetRouteSuffix(serviceInterface, method, out var routeSuffix))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fullPath = BuildFullPath(serviceSegment, routeSuffix);
|
||||
if (!swaggerDoc.Paths.TryGetValue(fullPath, out var pathItem))
|
||||
{
|
||||
pathItem = new OpenApiPathItem();
|
||||
swaggerDoc.Paths[fullPath] = pathItem;
|
||||
}
|
||||
|
||||
var operation = BuildOperation(context, serviceSegment, method, httpMethod, routeSuffix);
|
||||
TrySetOperation(pathItem, httpMethod, operation);
|
||||
}
|
||||
}
|
||||
|
||||
private static OpenApiOperation BuildOperation(
|
||||
DocumentFilterContext context,
|
||||
string serviceSegment,
|
||||
MethodInfo method,
|
||||
string httpMethod,
|
||||
string routeSuffix)
|
||||
{
|
||||
var operation = new OpenApiOperation
|
||||
{
|
||||
OperationId = $"DynamicApi_{serviceSegment}_{httpMethod}_{method.Name}",
|
||||
Summary = $"{serviceSegment} - {method.Name}",
|
||||
Tags = new HashSet<OpenApiTagReference> { new OpenApiTagReference(serviceSegment, null, serviceSegment) },
|
||||
Responses = BuildResponses(context, method)
|
||||
};
|
||||
|
||||
AddParametersAndBody(context, operation, method, routeSuffix, httpMethod);
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
private static OpenApiResponses BuildResponses(DocumentFilterContext context, MethodInfo method)
|
||||
{
|
||||
var returnDataType = UnwrapReturnDataType(method.ReturnType) ?? typeof(object);
|
||||
var responseType = typeof(ApiResponse<>).MakeGenericType(returnDataType);
|
||||
var responseSchema = context.SchemaGenerator.GenerateSchema(responseType, context.SchemaRepository);
|
||||
|
||||
return new OpenApiResponses
|
||||
{
|
||||
["200"] = new OpenApiResponse
|
||||
{
|
||||
Description = "OK",
|
||||
Content = new Dictionary<string, OpenApiMediaType>
|
||||
{
|
||||
["application/json"] = new OpenApiMediaType { Schema = responseSchema }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddParametersAndBody(
|
||||
DocumentFilterContext context,
|
||||
OpenApiOperation operation,
|
||||
MethodInfo method,
|
||||
string routeSuffix,
|
||||
string httpMethod)
|
||||
{
|
||||
var routeParams = ExtractRouteParameters(routeSuffix);
|
||||
if (routeParams.Count > 0)
|
||||
{
|
||||
operation.Parameters ??= new List<IOpenApiParameter>();
|
||||
|
||||
foreach (var routeParamName in routeParams)
|
||||
{
|
||||
var paramInfo = method.GetParameters()
|
||||
.FirstOrDefault(p => string.Equals(p.Name, routeParamName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var paramType = paramInfo?.ParameterType ?? typeof(string);
|
||||
var schema = context.SchemaGenerator.GenerateSchema(paramType, context.SchemaRepository);
|
||||
|
||||
operation.Parameters.Add(new OpenApiParameter
|
||||
{
|
||||
Name = routeParamName,
|
||||
In = ParameterLocation.Path,
|
||||
Required = true,
|
||||
Schema = schema
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var requestBodyParameter = GetRequestBodyParameter(method, httpMethod);
|
||||
if (requestBodyParameter == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bodySchema = context.SchemaGenerator.GenerateSchema(requestBodyParameter.ParameterType, context.SchemaRepository);
|
||||
operation.RequestBody = new OpenApiRequestBody
|
||||
{
|
||||
Required = true,
|
||||
Content = new Dictionary<string, OpenApiMediaType>
|
||||
{
|
||||
["application/json"] = new OpenApiMediaType { Schema = bodySchema }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ParameterInfo? GetRequestBodyParameter(MethodInfo method, string httpMethod)
|
||||
{
|
||||
if (string.Equals(httpMethod, "GET", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(httpMethod, "DELETE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parameters = method.GetParameters();
|
||||
if (parameters.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bodyParam = parameters.FirstOrDefault(p => p.GetCustomAttribute<FromBodyAttribute>() != null);
|
||||
if (bodyParam != null)
|
||||
{
|
||||
return bodyParam;
|
||||
}
|
||||
|
||||
return parameters.FirstOrDefault(p => !IsSimpleType(p.ParameterType));
|
||||
}
|
||||
|
||||
private static Type? UnwrapReturnDataType(Type returnType)
|
||||
{
|
||||
if (returnType == typeof(void))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (returnType == typeof(Task))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>))
|
||||
{
|
||||
return returnType.GetGenericArguments()[0];
|
||||
}
|
||||
|
||||
return returnType;
|
||||
}
|
||||
|
||||
private static string BuildFullPath(string serviceSegment, string routeSuffix)
|
||||
{
|
||||
var normalizedSuffix = string.IsNullOrWhiteSpace(routeSuffix)
|
||||
? string.Empty
|
||||
: routeSuffix.StartsWith("/", StringComparison.Ordinal) ? routeSuffix : $"/{routeSuffix}";
|
||||
|
||||
return $"/api/{serviceSegment}{normalizedSuffix}";
|
||||
}
|
||||
|
||||
private static List<string> ExtractRouteParameters(string routeSuffix)
|
||||
{
|
||||
var list = new List<string>();
|
||||
var suffix = routeSuffix ?? string.Empty;
|
||||
|
||||
var startIndex = 0;
|
||||
while (startIndex < suffix.Length)
|
||||
{
|
||||
var open = suffix.IndexOf('{', startIndex);
|
||||
if (open < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var close = suffix.IndexOf('}', open + 1);
|
||||
if (close < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var name = suffix.Substring(open + 1, close - open - 1).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
list.Add(name);
|
||||
}
|
||||
|
||||
startIndex = close + 1;
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static bool TryGetRouteSuffix(Type serviceInterface, MethodInfo method, out string routeSuffix)
|
||||
{
|
||||
routeSuffix = string.Empty;
|
||||
|
||||
var explicitRoute = method.GetCustomAttribute<HttpGetAttribute>()?.Route
|
||||
?? method.GetCustomAttribute<HttpPostAttribute>()?.Route
|
||||
?? method.GetCustomAttribute<HttpPutAttribute>()?.Route
|
||||
?? method.GetCustomAttribute<HttpDeleteAttribute>()?.Route
|
||||
?? method.GetCustomAttribute<HttpPatchAttribute>()?.Route;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(explicitRoute))
|
||||
{
|
||||
routeSuffix = explicitRoute.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(serviceInterface.Name, "ITaskService", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TryGetTaskServiceRouteSuffix(method, out routeSuffix);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetTaskServiceRouteSuffix(MethodInfo method, out string routeSuffix)
|
||||
{
|
||||
routeSuffix = string.Empty;
|
||||
|
||||
return method.Name switch
|
||||
{
|
||||
"GetAllTasksAsync" => Return(string.Empty, out routeSuffix),
|
||||
"GetTaskByIdAsync" => Return("/{id}", out routeSuffix),
|
||||
"GetActiveTasksAsync" => Return("/active", out routeSuffix),
|
||||
"GetCompletedTasksAsync" => Return("/completed", out routeSuffix),
|
||||
"CreateTaskAsync" => Return(string.Empty, out routeSuffix),
|
||||
"UpdateTaskAsync" => Return(string.Empty, out routeSuffix),
|
||||
"ToggleCompleteAsync" => Return("/{id}/toggle", out routeSuffix),
|
||||
"DeleteTaskAsync" => Return("/{id}", out routeSuffix),
|
||||
"GetSubTasksAsync" => Return("/{parentTaskId}/subtasks", out routeSuffix),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool Return(string value, out string routeSuffix)
|
||||
{
|
||||
routeSuffix = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void TrySetOperation(IOpenApiPathItem pathItem, string httpMethod, OpenApiOperation operation)
|
||||
{
|
||||
var operationsObject = pathItem.GetType().GetProperty("Operations")?.GetValue(pathItem);
|
||||
if (operationsObject == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (operationsObject is System.Collections.IDictionary dict)
|
||||
{
|
||||
var dictType = operationsObject.GetType();
|
||||
var keyType = dictType.IsGenericType ? dictType.GetGenericArguments()[0] : typeof(object);
|
||||
dict[CreateOperationKey(keyType, httpMethod)] = operation;
|
||||
return;
|
||||
}
|
||||
|
||||
var indexer = operationsObject.GetType().GetProperty("Item");
|
||||
var keyTypeFromIndexer = indexer?.GetIndexParameters().FirstOrDefault()?.ParameterType ?? typeof(object);
|
||||
indexer?.SetValue(operationsObject, operation, new[] { CreateOperationKey(keyTypeFromIndexer, httpMethod) });
|
||||
}
|
||||
|
||||
private static object CreateOperationKey(Type keyType, string httpMethod)
|
||||
{
|
||||
if (keyType == typeof(string))
|
||||
{
|
||||
return httpMethod.ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (keyType.IsEnum)
|
||||
{
|
||||
var name = httpMethod.ToUpperInvariant() switch
|
||||
{
|
||||
"GET" => "Get",
|
||||
"POST" => "Post",
|
||||
"PUT" => "Put",
|
||||
"DELETE" => "Delete",
|
||||
"PATCH" => "Patch",
|
||||
_ => httpMethod
|
||||
};
|
||||
|
||||
return Enum.Parse(keyType, name, ignoreCase: true);
|
||||
}
|
||||
|
||||
return httpMethod;
|
||||
}
|
||||
|
||||
private static bool IsRemoteServiceEnabled(MemberInfo memberInfo)
|
||||
{
|
||||
var attribute = memberInfo.GetCustomAttribute<RemoteServiceAttribute>();
|
||||
return attribute == null || attribute.IsEnabled;
|
||||
}
|
||||
|
||||
private static string GetServiceRouteSegment(Type serviceInterface)
|
||||
{
|
||||
var name = serviceInterface.Name;
|
||||
if (name.EndsWith("Service", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name = name.Substring(0, name.Length - "Service".Length);
|
||||
}
|
||||
|
||||
if (name.StartsWith("I", StringComparison.Ordinal) &&
|
||||
name.Length > 1 &&
|
||||
char.IsUpper(name[1]))
|
||||
{
|
||||
name = name.Substring(1);
|
||||
}
|
||||
|
||||
if (name.EndsWith("App", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name = name.Substring(0, name.Length - "App".Length);
|
||||
}
|
||||
|
||||
return name.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string GetHttpMethod(MethodInfo method)
|
||||
{
|
||||
if (method.GetCustomAttribute<HttpGetAttribute>() != null) return "GET";
|
||||
if (method.GetCustomAttribute<HttpPostAttribute>() != null) return "POST";
|
||||
if (method.GetCustomAttribute<HttpPutAttribute>() != null) return "PUT";
|
||||
if (method.GetCustomAttribute<HttpDeleteAttribute>() != null) return "DELETE";
|
||||
if (method.GetCustomAttribute<HttpPatchAttribute>() != null) return "PATCH";
|
||||
|
||||
if (method.Name.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase)) return "PATCH";
|
||||
if (method.Name.StartsWith("Get", StringComparison.OrdinalIgnoreCase)) return "GET";
|
||||
if (method.Name.StartsWith("Put", StringComparison.OrdinalIgnoreCase) ||
|
||||
method.Name.StartsWith("Update", StringComparison.OrdinalIgnoreCase)) return "PUT";
|
||||
if (method.Name.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) ||
|
||||
method.Name.StartsWith("Remove", StringComparison.OrdinalIgnoreCase)) return "DELETE";
|
||||
if (method.Name.StartsWith("Patch", StringComparison.OrdinalIgnoreCase)) return "PATCH";
|
||||
|
||||
return "POST";
|
||||
}
|
||||
|
||||
private static bool IsSimpleType(Type type)
|
||||
{
|
||||
return type.IsPrimitive ||
|
||||
type == typeof(string) ||
|
||||
type == typeof(decimal) ||
|
||||
type == typeof(DateTime) ||
|
||||
type == typeof(Guid) ||
|
||||
Nullable.GetUnderlyingType(type) != null;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
|
||||
|
||||
Reference in New Issue
Block a user