diff --git a/Attributes/SimApiDocAttribute.cs b/Attributes/SimApiDocAttribute.cs index 12e3ec7..f96357e 100644 --- a/Attributes/SimApiDocAttribute.cs +++ b/Attributes/SimApiDocAttribute.cs @@ -1,39 +1,105 @@ -using System; +using System; using Swashbuckle.AspNetCore.Annotations; namespace SimApi.Attributes; /// -/// 快捷自定义接口文档类 +/// 快捷自定义接口文档类(所有参数均可省略, 支持命名参数)。 +/// 对应仓颉版 SimApiDoc: +/// tags → 接口标签(逗号分隔, 如 "认证,用户") +/// name → API 名称(映射到 Summary, 作为文档接口标题) +/// description → API 详细描述 +/// groupNames → 所属文档组(逗号分隔, 如 "api,admin"; "*" 表示所有文档; null/空 → 仅默认 "api" 文档) +/// ignore → true 时不出现在任何文档中(路由不受影响) +/// 写法示例: +/// [SimApiDoc("认证", "登录")] 位置参数(保持旧版兼容) +/// [SimApiDoc(tags: "认证", name: "登录", description: "...")] +/// [SimApiDoc(groupNames: "api,admin")] 仅指定文档组 +/// [SimApiDoc(GroupNames = "*", Ignore = false)] 属性命名参数 +/// [SimApiDoc] 全部默认(可标在类上, 仅用 Ignore/GroupNames 等) +/// 属性命名参数优先于构造命名参数。 /// [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] public class SimApiDocAttribute : SwaggerOperationAttribute { /// - /// 定义接口说明 + /// 出现在所有文档组中的通配符 /// - /// 接口分组列表 + public const string AllGroups = "*"; + + /// + /// 接口所属的文档组(逗号分隔, 如 "api,admin"); "*" 表示所有文档; null/空 表示未分组(仅默认 "api" 文档) + /// + public string? GroupNames { get; set; } + + /// + /// 为 true 时该接口不出现在任何文档中(不影响路由) + /// + public bool Ignore { get; set; } + + /// + /// 定义接口说明(全部可选) + /// + /// 接口标签, 逗号分隔, 如 "认证,用户" /// 接口名称 /// 接口描述 - public SimApiDocAttribute(string[] tags, string name, string? description = null) + /// 所属文档组, 逗号分隔; "*" 表示所有文档 + /// true 时不出现在任何文档 + public SimApiDocAttribute(string? tags = null, string? name = null, string? description = null, + string? groupNames = null, bool ignore = false) { - Tags = tags; - Summary = name; - if (description != null) - { - Description = description; - } - // Consumes = new[] {"application/json"}; - // Produces = new[] {"application/json"}; + Apply(tags, name, description, groupNames, ignore); } /// - /// 定义接口说明 + /// 定义接口说明(标签以数组传入) /// - /// 接口分组 + /// 接口标签列表 /// 接口名称 /// 接口描述 - public SimApiDocAttribute(string tag, string name, string? description = null) : this([tag], name, description) + /// 所属文档组, 逗号分隔; "*" 表示所有文档 + /// true 时不出现在任何文档 + public SimApiDocAttribute(string[] tags, string? name = null, string? description = null, + string? groupNames = null, bool ignore = false) { + Apply(tags, name, description, groupNames, ignore); } -} \ No newline at end of file + + private void Apply(string? tags, string? name, string? description, + string? groupNames, bool ignore) + { + if (!string.IsNullOrWhiteSpace(tags)) + { + Tags = tags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + ApplyCore(name, description, groupNames, ignore); + } + + private void Apply(string[] tags, string? name, string? description, + string? groupNames, bool ignore) + { + if (tags is { Length: > 0 }) + { + Tags = tags; + } + + ApplyCore(name, description, groupNames, ignore); + } + + private void ApplyCore(string? name, string? description, string? groupNames, bool ignore) + { + if (!string.IsNullOrEmpty(name)) + { + Summary = name; + } + + if (!string.IsNullOrEmpty(description)) + { + Description = description; + } + + GroupNames = groupNames; + Ignore = ignore; + } +} diff --git a/Configurations/SimApiDocOptions.cs b/Configurations/SimApiDocOptions.cs index 2271ad2..8003ce1 100644 --- a/Configurations/SimApiDocOptions.cs +++ b/Configurations/SimApiDocOptions.cs @@ -6,7 +6,7 @@ namespace SimApi.Configurations; /// /// 文档组配置 /// -public class SimApiDocGroupOption(string id, string name, string description = "") +public class SimApiDocGroupOption(string id, string name, string description = "", bool isDefault = false) { /// /// 文档标识 @@ -22,6 +22,11 @@ public class SimApiDocGroupOption(string id, string name, string description = " /// 文档描述 /// public string Description { get; set; } = description!; + + /// + /// 是否为默认文档组: 未标注分组的接口全部归入此文档组 + /// + public bool IsDefault { get; set; } = isDefault; } /// @@ -50,7 +55,7 @@ public class SimApiDocOptions /// public SimApiDocGroupOption[] ApiGroups { get; set; } = [ - new("api", "Api", "Api接口文档") + new("api", "Api", "Api接口文档", isDefault: true) ]; /// @@ -58,6 +63,12 @@ public class SimApiDocOptions /// public SimApiAuthOption ApiAuth { get; set; } = new(); + /// + /// 接口文档页面访问前缀, 默认 "docs"。 + /// 访问 /docs 即打开文档页面, JSON 地址为 /docs/{文档组Id}.json + /// + public string UrlPrefix { get; set; } = "docs"; + /// /// 文档页面标题 /// diff --git a/Controllers/SimApiAuthController.cs b/Controllers/SimApiAuthController.cs index bc8e481..135f126 100644 --- a/Controllers/SimApiAuthController.cs +++ b/Controllers/SimApiAuthController.cs @@ -11,7 +11,7 @@ public class SimApiAuthController(SimApiAuth auth) : SimApiBaseController /// 退出登陆 /// /// - [HttpPost, SimApiDoc("认证", "退出登陆")] + [HttpPost, SimApiDoc("认证", "退出登陆",groupNames: "*")] public void Logout() { if (Request.Headers.TryGetValue("Token", out var value)) diff --git a/Controllers/SimApiBuiltInRoutes.cs b/Controllers/SimApiBuiltInRoutes.cs new file mode 100644 index 0000000..d1d87a1 --- /dev/null +++ b/Controllers/SimApiBuiltInRoutes.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using SimApi.Configurations; + +namespace SimApi.Controllers; + +/// +/// SimApi 内置端点路由表中的一项。 +/// +public sealed record SimApiBuiltInRoute( + string RouteName, + string Controller, + string Action, + string[] HttpMethods, + string Path); + +/// +/// SimApi 内置端点路由表 —— 路径的唯一配置处。 +/// UseSimApi 用它注册约定路由(动态路由), +/// SimApiBuiltInRoutesDescriptionProvider 用它生成文档条目。 +/// 某项路径为 null / 空时该端点既不注册路由、也不出现在文档中。 +/// +public static class SimApiBuiltInRoutes +{ + public static IEnumerable Get(SimApiRouteOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (!string.IsNullOrEmpty(options.UserInfoRoute)) + { + yield return new SimApiBuiltInRoute( + "UserInfo", "SimApiCommon", "UserInfo", ["POST"], options.UserInfoRoute); + } + + if (!string.IsNullOrEmpty(options.LogoutRoute)) + { + yield return new SimApiBuiltInRoute( + "Logout", "SimApiAuth", "Logout", ["POST"], options.LogoutRoute); + } + + if (!string.IsNullOrEmpty(options.WebConfigRoute)) + { + // 控制器动作同时标注了 HttpGet/HttpPost + yield return new SimApiBuiltInRoute( + "WebConfig", "SimApiCommon", "WebConfig", ["GET", "POST"], options.WebConfigRoute); + } + } +} diff --git a/Controllers/SimApiBuiltInRoutesDescriptionProvider.cs b/Controllers/SimApiBuiltInRoutesDescriptionProvider.cs new file mode 100644 index 0000000..fb4ce97 --- /dev/null +++ b/Controllers/SimApiBuiltInRoutesDescriptionProvider.cs @@ -0,0 +1,117 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; +using SimApi.Configurations; + +namespace SimApi.Controllers; + +/// +/// 将 SimApiRouteOptions 动态配置的内置约定路由(UserInfo / Logout / WebConfig) +/// 翻译成 ApiDescription, 使它们能像属性路由接口一样出现在 Swagger 文档中: +/// 请求/响应 schema 由 Swashbuckle 自动分析, 方法上已有的 [SimApiDoc] 注解自动生效。 +/// 路径为 null / 空 的端点在此一并跳过(与路由注册保持一致)。 +/// +public class SimApiBuiltInRoutesDescriptionProvider( + IServiceProvider services, + SimApiOptions options) : IApiDescriptionProvider +{ + /// + /// 默认 provider(DefaultApiDescriptionProvider)的 Order 为 -1000, 位于其后执行。 + /// + public int Order => -900; + + public void OnProvidersExecuting(ApiDescriptionProviderContext context) + { + } + + public void OnProvidersExecuted(ApiDescriptionProviderContext context) + { + var modelMetadata = services.GetService(); + + foreach (var route in SimApiBuiltInRoutes.Get(options.SimApiRouteOptions)) + { + var action = context.Actions.OfType() + .FirstOrDefault(a => + string.Equals(a.ControllerName, route.Controller, StringComparison.Ordinal) && + string.Equals(a.ActionName, route.Action, StringComparison.Ordinal)); + if (action is null) + { + continue; + } + + // 若该动作已被属性路由 / 其他 provider 收录, 跳过避免重复 + if (context.Results.Any(d => ReferenceEquals(d.ActionDescriptor, action))) + { + continue; + } + + foreach (var httpMethod in route.HttpMethods) + { + context.Results.Add(CreateDescription(action, route, httpMethod, modelMetadata)); + } + } + } + + private static ApiDescription CreateDescription( + ControllerActionDescriptor action, + SimApiBuiltInRoute route, + string httpMethod, + IModelMetadataProvider? modelMetadata) + { + var apiDescription = new ApiDescription + { + ActionDescriptor = action, + HttpMethod = httpMethod, + RelativePath = route.Path.TrimStart('/') + }; + + // 输入: 按真实方法参数翻译(内置端点均为零参, 此处保证将来加参也可分析) + foreach (var parameter in action.Parameters) + { + var parameterDescription = new ApiParameterDescription + { + Name = parameter.Name, + Type = parameter.ParameterType, + ParameterDescriptor = parameter, + Source = parameter.BindingInfo?.BindingSource ?? BindingSource.Body, + IsRequired = true + }; + if (modelMetadata is not null) + { + parameterDescription.ModelMetadata = modelMetadata.GetMetadataForType(parameter.ParameterType); + } + + apiDescription.ParameterDescriptions.Add(parameterDescription); + } + + // 输出: 方法返回类型 => 200 + json + var returnType = Unwrap(action.MethodInfo.ReturnType); + if (returnType != typeof(void)) + { + var responseType = new ApiResponseType + { + StatusCode = 200, + Type = returnType, + IsDefaultResponse = true + }; + if (modelMetadata is not null) + { + responseType.ModelMetadata = modelMetadata.GetMetadataForType(returnType); + } + + responseType.ApiResponseFormats.Add(new ApiResponseFormat { MediaType = "application/json" }); + apiDescription.SupportedResponseTypes.Add(responseType); + } + + return apiDescription; + } + + private static Type Unwrap(Type type) => + type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Task<>) + ? type.GetGenericArguments()[0] + : type; +} diff --git a/Controllers/SimApiCommonController.cs b/Controllers/SimApiCommonController.cs index ffb6192..c1bbf0e 100644 --- a/Controllers/SimApiCommonController.cs +++ b/Controllers/SimApiCommonController.cs @@ -18,7 +18,7 @@ public class SimApiCommonController(SimApiOptions simApiOptions) : SimApiBaseCon /// 错误代码 /// [HttpGet("exception/{code:int}")] - [ApiExplorerSettings(IgnoreApi = true)] + [SimApiDoc(ignore: true)] public void ExceptionHandler(int code) { Error(code); @@ -29,6 +29,7 @@ public class SimApiCommonController(SimApiOptions simApiOptions) : SimApiBaseCon /// /// [HttpPost, HttpGet] + [HttpPost, SimApiDoc("公共", "获取自定义配置",groupNames: "*")] public Dictionary WebConfig() { var resp = simApiOptions.WebConfig!.ToDictionary(); @@ -49,6 +50,6 @@ public class SimApiCommonController(SimApiOptions simApiOptions) : SimApiBaseCon /// 获取已登录用户信息 /// /// - [HttpPost, SimApiAuth, SimApiDoc("认证", "获取已登录用户信息")] + [HttpPost, SimApiAuth, SimApiDoc("认证", "获取已登录用户信息",groupNames: "*")] public SimApiLoginItem UserInfo() => LoginInfo; } \ No newline at end of file diff --git a/Helpers/SimApiUtil.cs b/Helpers/SimApiUtil.cs index e5543a0..0186b39 100644 --- a/Helpers/SimApiUtil.cs +++ b/Helpers/SimApiUtil.cs @@ -28,7 +28,7 @@ public static class SimApiUtil { // ReferenceHandler = ReferenceHandler.Preserve, Encoder = JavaScriptEncoder.Create(UnicodeRanges.All), - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase // DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; diff --git a/Middlewares/SimApiRequestLogMiddleware.cs b/Middlewares/SimApiRequestLogMiddleware.cs index 4f114d6..f123080 100644 --- a/Middlewares/SimApiRequestLogMiddleware.cs +++ b/Middlewares/SimApiRequestLogMiddleware.cs @@ -60,18 +60,36 @@ public class SimApiRequestLogMiddleware( } else { - var reqLogs = SimApiUtil.FromJson>(requestBodyText); - foreach (var reqLog in reqLogs) + // GET/DELETE 等无 body 的请求, 或表单/文件上传等非 JSON body: + // 不进行长度裁剪, 原样记录, 避免空 JSON 解析抛异常 + Dictionary? reqLogs = null; + try { - if (reqLog.Value is not JsonElement { ValueKind: JsonValueKind.String } je) continue; - var str = je.GetString(); - if (str?.Length > options.RequestStringLogLength) - { - reqLogs[reqLog.Key] = str[..options.RequestStringLogLength] + $"...({str.Length})"; - } + reqLogs = SimApiUtil.FromJson>(requestBodyText); + } + catch (JsonException) + { + reqLogs = null; } - logMessage.AppendLine(SimApiUtil.Json(reqLogs)); + if (reqLogs == null) + { + logMessage.AppendLine(requestBodyText); + } + else + { + foreach (var reqLog in reqLogs) + { + if (reqLog.Value is not JsonElement { ValueKind: JsonValueKind.String } je) continue; + var str = je.GetString(); + if (str?.Length > options.RequestStringLogLength) + { + reqLogs[reqLog.Key] = str[..options.RequestStringLogLength] + $"...({str.Length})"; + } + } + + logMessage.AppendLine(SimApiUtil.Json(reqLogs)); + } } var originalBodyStream = context.Response.Body; diff --git a/SimApiExtensions.cs b/SimApiExtensions.cs index 96a4f07..ee0bd68 100644 --- a/SimApiExtensions.cs +++ b/SimApiExtensions.cs @@ -9,15 +9,18 @@ using Hangfire.Redis.StackExchange; using SimApi.Helpers; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.OpenApi; using SimApi.Middlewares; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using SimApi.Attributes; using SimApi.AuthSDK; using SimApi.Configurations; +using SimApi.Controllers; using SimApi.Interfaces; using SimApi.Logger; using SimApi.SwaggerFilters; @@ -229,15 +232,42 @@ public static class SimApiExtensions x.DocInclusionPredicate((docName, apiDesc) => { - // 获取接口标记的 GroupName(未标记则为 null) - var actionGroupName = apiDesc.ActionDescriptor.EndpointMetadata + var metadata = apiDesc.ActionDescriptor.EndpointMetadata; + + // 类/方法上的 [SimApiDoc] 标注统一控制文档归属 + var docAttrs = metadata.OfType().ToArray(); + + // Ignore: 不出现在任何文档(路由不受影响) + if (docAttrs.Any(a => a.Ignore)) + { + return false; + } + + // GroupNames: 逗号分隔的文档组列表; "*" 表示所有文档; + // 标注在类上则整类生效, 方法级标注可覆盖 + var groupNames = docAttrs + .Select(a => a.GroupNames) + .FirstOrDefault(g => !string.IsNullOrWhiteSpace(g)); + if (groupNames != null) + { + var groups = groupNames.Split(',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return groups.Contains(SimApiDocAttribute.AllGroups) || groups.Contains(docName); + } + + // 兼容: ApiExplorerSettings.GroupName 单组标注 + var actionGroupName = metadata .OfType() .FirstOrDefault()?.GroupName; - // 情况1:接口未标记任何 GroupName(actionGroupName 为 null) + // 未标注任何分组 → 放入配置为 IsDefault 的文档组; + // 未配置 IsDefault 时依次回退 id 为 "api" 的组 / 第一个组 if (actionGroupName == null) { - return docName == "api"; // 未分组接口只属于默认分组v1 + var defaultGroup = docOptions.ApiGroups.FirstOrDefault(g => g.IsDefault) + ?? docOptions.ApiGroups.FirstOrDefault(g => g.Id == "api") + ?? docOptions.ApiGroups.FirstOrDefault(); + return defaultGroup != null && docName == defaultGroup.Id; } return docName == actionGroupName; @@ -308,6 +338,13 @@ public static class SimApiExtensions }); } }); + + // 让 SimApiRouteOptions 配置的内置约定路由(UserInfo/Logout/WebConfig) + // 也能出现在文档中: 通过 IApiDescriptionProvider 手工产出 ApiDescription, + // 请求/响应 schema 由 Swashbuckle 自动分析, [SimApiDoc] 注解自动生效。 + builder.TryAddEnumerable( + ServiceDescriptor.Transient()); } // 使用Header转发,应对代理后获取真实ip @@ -316,7 +353,7 @@ public static class SimApiExtensions builder.Configure(fwOptions => { fwOptions.ForwardedHeaders = ForwardedHeaders.All; - fwOptions.KnownNetworks.Clear(); + fwOptions.KnownIPNetworks.Clear(); fwOptions.KnownProxies.Clear(); }); } @@ -462,36 +499,19 @@ public static class SimApiExtensions builder.UseMiddleware(); } - if (options.SimApiRouteOptions.UserInfoRoute != null) + // 注册内置Route(UserInfo/Logout/WebConfig)。 + // 路径统一由 SimApiBuiltInRoutes 路由表提供: 配置为 null/空 的端点不注册。 + foreach (var route in SimApiBuiltInRoutes.Get(options.SimApiRouteOptions)) { - logger.LogInformation("注册内置Route: UserInfo => {}", options.SimApiRouteOptions.UserInfoRoute); - builder.MapControllerRoute(name: "UserInfo", pattern: options.SimApiRouteOptions.UserInfoRoute, + logger.LogInformation("注册内置Route: {RouteName} => {RoutePath} [{HttpMethods}]", route.RouteName, + route.Path, string.Join(", ", route.HttpMethods)); + builder.MapControllerRoute( + name: route.RouteName, + pattern: route.Path, defaults: new { - controller = "SimApiCommon", - action = "UserInfo" - }); - } - - if (options.SimApiRouteOptions.LogoutRoute != null) - { - logger.LogInformation("注册内置Route: Logout => {}", options.SimApiRouteOptions.LogoutRoute); - builder.MapControllerRoute(name: "Logout", pattern: options.SimApiRouteOptions.LogoutRoute, - defaults: new - { - controller = "SimApiAuth", - action = "Logout" - }); - } - - if (options.SimApiRouteOptions.WebConfigRoute != null) - { - logger.LogInformation("注册内置Route: Logout => {}", options.SimApiRouteOptions.WebConfigRoute); - builder.MapControllerRoute(name: "WebConfig", pattern: options.SimApiRouteOptions.WebConfigRoute, - defaults: new - { - controller = "SimApiCommon", - action = "WebConfig" + controller = route.Controller, + action = route.Action }); } @@ -499,12 +519,14 @@ public static class SimApiExtensions { logger.LogInformation("开始配置SimApiDoc..."); var docOptions = options.SimApiDocOptions; - builder.UseSwagger(x => x.RouteTemplate = "/swagger/{documentName}.json") + builder.UseSwagger(x => x.RouteTemplate = $"/{docOptions.UrlPrefix}/{{documentName}}.json") .UseSwaggerUI(x => { + x.RoutePrefix = docOptions.UrlPrefix; x.DocumentTitle = docOptions.DocumentTitle; foreach (var group in docOptions.ApiGroups) { + // 相对端点: 页面位于 /{UrlPrefix}/, 自动解析为 /{UrlPrefix}/{group.Id}.json x.SwaggerEndpoint($"{group.Id}.json", name: group.Name); }