增加了api文档

This commit is contained in:
2026-09-01 18:17:37 +08:00
parent e0e57a7eb4
commit 73e770db80
25 changed files with 913 additions and 140 deletions
+2 -4
View File
@@ -28,12 +28,10 @@ public class SimApiStringIdOnlyRequest {
* @param T 数据类型。
*/
public class SimApiOneFieldRequest<T> {
public var data: ?T = None
public init() {}
public var data: T
public init(data: T) {
this.data = Some(data)
this.data = data
}
}
+39 -3
View File
@@ -15,12 +15,20 @@ public class SimApiDocGroup {
public var name: String = ""
public var description: String = ""
/**
* 是否为默认文档组。
* 未标注 @SimApiDoc[groupName] 的接口仅进入默认组文档;
* 若所有组均未标记,则第一个组视为默认组。
*/
public var isDefault: Bool = false
public init() {}
public init(id: String, name: String, description!: String = "") {
public init(id: String, name!: String = "", description!: String = "", isDefault!: Bool = false) {
this.id = id
this.name = name
this.name = if (name.isEmpty()) { id } else { name }
this.description = description
this.isDefault = isDefault
}
}
@@ -75,12 +83,40 @@ public class SimApiDocOptions {
*/
public var documentTitle: String = "API接口文档"
/**
* 文档路由前缀(默认 "docs")。
* Swagger UI 静态资源挂载在 /{urlPrefix}/ 下,文档 JSON 挂载在 /{urlPrefix}/{id}.json。
*/
public var urlPrefix: String = "docs"
/**
* 接口支持的调用方式(默认仅 POST)。
*/
public var supportedMethods: Array<String> = ["POST"]
public init() {
apiGroups.add(SimApiDocGroup("api", "Api", description: "Api接口文档"))
apiGroups.add(SimApiDocGroup("api"))
}
/**
* 返回按 id 去重后的文档组(保留最后出现的组,用户配置覆盖默认值)。
* 按首次出现的顺序排列,但每个 id 取最后出现的定义。
*/
public func distinctGroups(): ArrayList<SimApiDocGroup> {
let order = ArrayList<String>()
let latest = HashMap<String, SimApiDocGroup>()
for (g in apiGroups) {
if (!latest.contains(g.id)) {
order.add(g.id)
}
latest[g.id] = g
}
let result = ArrayList<SimApiDocGroup>()
for (id in order) {
if (let Some(g) <- latest.get(id)) {
result.add(g)
}
}
return result
}
}
-21
View File
@@ -48,27 +48,6 @@ public open class SimApiBaseController <: Controller & BindRequestContext {
}
}
/**
* 当前登录信息(可选)。
*/
protected func getLogin(): ?SimApiLoginItem {
if (let Some(item) <- context.items.get("LoginInfo")) {
if (let login: SimApiLoginItem <- item) {
return Some(login)
}
}
None
}
/**
* 检查登录状态,未登录抛 401。
*/
protected func requireLogin(): Unit {
match (getLogin()) {
case None => SimApiError.error(code: 401, message: "需要登录")
case _ => ()
}
}
/**
* 绑定当前请求上下文(供 SimApiRequestDelegateFactory 调用;
+15 -22
View File
@@ -11,6 +11,7 @@ import soulsoft_web_mvc.annotations.*
import simcu::simapi.communications.*
import simcu::simapi.configurations.*
import simcu::simapi.helpers.*
import simcu::simapi.annotations.{SimApiAuth as SimApiAuthAttribute}
import simcu::simapi.openapi.annotations.*
/**
@@ -29,8 +30,8 @@ public class SimApiCommonController <: SimApiBaseController {
* 抛 SimApiException,由异常中间件统一输出。
*/
@HttpGet["exception/{code}"]
@SimApiDoc[tags:"公共",summary:"异常报错"]
public func exceptionHandler(@FromRoute[] code: Int64): Unit { // cjlint-ignore !G.FUN.02 注解绑定参数误报
@SimApiDoc[ignore: true]
public func exceptionHandler(@FromRoute[] code: Int64) { // cjlint-ignore !G.FUN.02 注解绑定参数误报
SimApiError.error(code: code)
}
@@ -39,7 +40,17 @@ public class SimApiCommonController <: SimApiBaseController {
* 动态注册:路由路径由 SimApiRouteOptions.webConfigRoute 决定(见 simapi_extensions.cj)。
*/
public func webConfig(): HashMap<String, Any> {
webConfigMap()
var versionMap = HashMap<String, Any>()
versionMap["SimApi"] = SimApiUtil.simApiVersion
versionMap["App"] = SimApiUtil.appVersion
var map = HashMap<String, Any>()
for ((key, value) in _options.webConfig) {
map[key] = value
}
if (_options.webConfigIncludeVersion) {
map["Versions"] = versionMap
}
map
}
@@ -47,26 +58,8 @@ public class SimApiCommonController <: SimApiBaseController {
* POST /user/info:获取已登录用户信息(需登录)。
* 动态注册:路由路径由 SimApiRouteOptions.userInfoRoute 决定(见 simapi_extensions.cj)。
*/
@SimApiAuthAttribute
public func userInfo(): SimApiLoginItem {
requireLogin()
loginInfo
}
private func versionsMap(): HashMap<String, Any> {
var map = HashMap<String, Any>()
map["SimApi"] = SimApiUtil.simApiVersion
map["App"] = SimApiUtil.appVersion
map
}
private func webConfigMap(): HashMap<String, Any> {
var map = HashMap<String, Any>()
for ((key, value) in _options.webConfig) {
map[key] = value
}
if (_options.webConfigIncludeVersion) {
map["Versions"] = versionsMap()
}
map
}
}
+163 -30
View File
@@ -11,22 +11,45 @@ package simcu::simapi.openapi
import std.io.*
import std.fs.*
import std.unicode.*
import stdx.encoding.base64.*
import soulsoft_web_http.*
import soulsoft_web_hosting.*
import simcu::simapi.configurations.*
/**
* @brief 提供 OpenAPI UI 静态资源中间件。
*
* 资源加载顺序:优先读取应用 wwwroot/{prefix} 目录(允许使用方覆盖),
* 缺失时回退到 simapi-cj 包内置资源(Base64 内联)。
*
* 路由约定:
* /{prefix} → 重定向到 /{prefix}/all.html
* /{prefix}/all.html → 多文档切换页(含顶部栏下拉)
* /{prefix}/{id}.html → 单文档页(无顶部栏,JS 从 URL 提取 id 加载 {id}.json
* /{prefix}/urls → 文档列表 JSON(由 simapi_extensions 注册)
* /{prefix}/{id}.json → OpenAPI 文档 JSON(由 mapOpenApi 注册)
*/
public class OpenApiUIMiddleware <: IMiddleware {
private static let _openApiRootPath = PathString("/openapi")
private static var _allHtmlCache: ?Array<Byte> = None
private static var _singleHtmlCache: ?Array<Byte> = None
private static var _bundleJsCache: ?Array<Byte> = None
private static var _presetJsCache: ?Array<Byte> = None
private static var _cssCache: ?Array<Byte> = None
private let _env: IWebHostEnvironment
private let _rootPath: PathString
private let _wwwRootSubDir: String
/**
* @brief 创建 OpenAPI UI 中间件实例。
* @param evn 当前 Web 主机环境。
* @param options SimApi 全局配置,用于读取文档路由前缀。
*/
public init(evn: IWebHostEnvironment) {
public init(evn: IWebHostEnvironment, options: SimApiOptions) {
_env = evn
let prefix = options.simApiDocOptions.urlPrefix.trim().trimStart('/').trimEnd('/')
_rootPath = PathString(if (prefix.isEmpty()) { "/docs" } else { "/${prefix}" })
_wwwRootSubDir = if (prefix.isEmpty()) { "docs" } else { prefix }
}
/**
@@ -36,7 +59,7 @@ public class OpenApiUIMiddleware <: IMiddleware {
*/
public func invoke(context: HttpContext, next: RequestDelegate): Unit {
let remainingPath = if (let Some(path) <- context.request.path.startsWithSegments(
OpenApiUIMiddleware._openApiRootPath
_rootPath
)) {
path
} else {
@@ -44,41 +67,151 @@ public class OpenApiUIMiddleware <: IMiddleware {
return
}
if (context.request.path == "/openapi") {
context.response.redirect("/openapi/index.html")
return
}
let path = if (let Some(path) <- resolveAssetPath(remainingPath)) {
path
} else {
next(context)
return
}
if (exists(path) && isPathWithinRoot(path)) {
try (fs = File(path, OpenMode.Read)) {
let data = readToEnd(fs)
context.response.write(data)
if (let Some(relativePath) <- resolveRelativePath(remainingPath)) {
if (serveFromWebRoot(context, relativePath) || serveFromEmbedded(context, relativePath)) {
return
}
} else {
next(context)
}
next(context)
}
private func resolveRelativePath(remainingPath: PathString): ?String {
if (remainingPath == "/" || !remainingPath.hasValue) {
return None
}
let candidate = remainingPath.value.trimStart('/')
if (!isSafeRelativePath(candidate)) {
return None
}
return Some(candidate)
}
private func serveFromWebRoot(context: HttpContext, relativePath: String): Bool {
let openApiRoot = Path(_env.webRootPath).join(_wwwRootSubDir)
let path = openApiRoot.join(relativePath).normalize()
if (!exists(path) || !isPathWithinRoot(path)) {
return false
}
var served = false
try (fs = File(path, OpenMode.Read)) {
let data = readToEnd(fs)
context.response.contentType = contentTypeFor(relativePath)
context.response.write(data)
served = true
} catch (_: Exception) {
served = false
}
return served
}
private func serveFromEmbedded(context: HttpContext, relativePath: String): Bool {
let data = embeddedResource(relativePath)
if (let Some(bytes) <- data) {
context.response.contentType = contentTypeFor(relativePath)
context.response.write(bytes)
return true
}
return false
}
private func embeddedResource(relativePath: String): ?Array<Byte> {
if (relativePath == "all.html") {
return cachedResource(SwaggerUIResources.allHtml, "allHtml")
}
// index.html 不支持(已废弃,使用 all.html 或 {id}.html
if (relativePath == "index.html") {
return None
}
// 任意 {id}.html(非 all.html)→ 返回 single.html 模板(JS 从 URL 提取 id
if (relativePath.endsWith(".html")) {
return cachedResource(SwaggerUIResources.singleHtml, "singleHtml")
}
if (relativePath == "swagger-ui-bundle.js") {
return cachedResource(SwaggerUIResources.swaggerUiBundleJs, "bundleJs")
}
if (relativePath == "swagger-ui-standalone-preset.js") {
return cachedResource(SwaggerUIResources.swaggerUiStandalonePresetJs, "presetJs")
}
if (relativePath == "swagger-ui.css") {
return cachedResource(SwaggerUIResources.swaggerUiCss, "css")
}
return None
}
private func cachedResource(b64: String, key: String): ?Array<Byte> {
if (let Some(cached) <- OpenApiUIMiddleware.embeddedCache(key)) {
return Some(cached)
}
let decoded = OpenApiUIMiddleware.decodeBase64(b64)
if (let Some(bytes) <- decoded) {
OpenApiUIMiddleware.setEmbeddedCache(key, bytes)
}
return decoded
}
private static func embeddedCache(key: String): ?Array<Byte> {
if (key == "allHtml") {
return OpenApiUIMiddleware._allHtmlCache
}
if (key == "singleHtml") {
return OpenApiUIMiddleware._singleHtmlCache
}
if (key == "bundleJs") {
return OpenApiUIMiddleware._bundleJsCache
}
if (key == "presetJs") {
return OpenApiUIMiddleware._presetJsCache
}
if (key == "css") {
return OpenApiUIMiddleware._cssCache
}
return None
}
private static func setEmbeddedCache(key: String, bytes: Array<Byte>): Unit {
if (key == "allHtml") {
OpenApiUIMiddleware._allHtmlCache = Some(bytes)
} else if (key == "singleHtml") {
OpenApiUIMiddleware._singleHtmlCache = Some(bytes)
} else if (key == "bundleJs") {
OpenApiUIMiddleware._bundleJsCache = Some(bytes)
} else if (key == "presetJs") {
OpenApiUIMiddleware._presetJsCache = Some(bytes)
} else if (key == "css") {
OpenApiUIMiddleware._cssCache = Some(bytes)
}
}
private func resolveAssetPath(remainingPath: PathString): ?Path {
let relativePath = if (remainingPath == "/" || !remainingPath.hasValue) {
"index.html"
} else {
let candidate = remainingPath.value.trimStart('/')
if (!isSafeRelativePath(candidate)) {
private static func decodeBase64(b64: String): ?Array<Byte> {
if (b64.isEmpty()) {
return None
}
try {
let bytes = fromBase64String(b64).getOrThrow { Exception("内置资源 Base64 解码失败") }
if (bytes.size == 0) {
return None
}
candidate
return Some(bytes)
} catch (_: Exception) {
return None
}
}
let openApiRoot = Path(_env.webRootPath).join("openapi")
return openApiRoot.join(relativePath).normalize()
private func contentTypeFor(relativePath: String): String {
if (relativePath.endsWith(".html")) {
return "text/html; charset=utf-8"
}
if (relativePath.endsWith(".css")) {
return "text/css; charset=utf-8"
}
if (relativePath.endsWith(".js")) {
return "application/javascript; charset=utf-8"
}
return "application/octet-stream"
}
private func isSafeRelativePath(relativePath: String): Bool {
@@ -97,7 +230,7 @@ public class OpenApiUIMiddleware <: IMiddleware {
private func isPathWithinRoot(path: Path): Bool {
try {
let rootPath = canonicalize(Path(_env.webRootPath).join("openapi"))
let rootPath = canonicalize(Path(_env.webRootPath).join(_wwwRootSubDir))
let targetPath = canonicalize(path)
let rootPathString = rootPath.toString()
let targetPathString = targetPath.toString()
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 SimCuTeam. All rights reserved.
* 遵循 MIT 许可证。
*/
package simcu::simapi.openapi
import std.reflect.*
import soulsoft_web_http.*
import simcu::simapi.annotations.*
import simcu::simapi.openapi.metadata.*
/**
* @brief 响应类型元数据实现。
*/
public class ApiResponseTypeMetadata <: IApiResponseTypeMetadata {
private let _responseType: ?TypeInfo
public init(responseType: ?TypeInfo) {
_responseType = responseType
}
public prop responseType: ?TypeInfo {
get() {
_responseType
}
}
}
/**
* @brief 为动态注册的路由提供认证和响应类型元数据扩展。
*/
public interface SimApiEndpointMetadataExtensions {
func withSimApiAuth(auth: SimApiAuth): EndpointConventionBuilder
func withResponseType(typeInfo: TypeInfo): EndpointConventionBuilder
}
extend EndpointConventionBuilder <: SimApiEndpointMetadataExtensions {
/**
* @brief 为端点附加 SimApiAuth 认证元数据(使 OpenAPI 文档显示锁图标)。
*/
public func withSimApiAuth(auth: SimApiAuth): EndpointConventionBuilder {
this.add {
builder => builder.metadata.add(auth)
}
return this
}
/**
* @brief 为端点附加响应类型元数据(使动态路由也能生成 response schema)。
*/
public func withResponseType(typeInfo: TypeInfo): EndpointConventionBuilder {
this.add {
builder => builder.metadata.add(ApiResponseTypeMetadata(Some(typeInfo)))
}
return this
}
}
File diff suppressed because one or more lines are too long
+14 -11
View File
@@ -3,7 +3,7 @@
* This source file is licensed under the MIT License found in the
* LICENSE file in the root directory of this source tree.
*/
package simcu::simapi.openapi.annotations
import simcu::simapi.openapi.metadata.*
@@ -13,14 +13,17 @@ import simcu::simapi.openapi.metadata.*
*
* 用法:
* @SimApiDoc[tags: "登录", summary: "用户登录相关接口"]
* @SimApiDoc[tags: "认证", summary: "后台登录", groupNames: "admin"]
* @SimApiDoc[tags: "公共", groupNames: "api,admin"] // 同时出现在 api 和 admin 文档
* @SimApiDoc[tags: "公共", groupNames: "*"] // 出现在所有文档
*/
@Annotation[target: [MemberFunction, Type, MemberProperty, MemberVariable, Parameter]]
public class SimApiDoc <: IApiTagsMetadata & IApiNameMetadata & IApiGroupNameProvider & IApiDescriptionMetadata & IApiSummaryMetadata & IApiVisibilityProvider {
public class SimApiDoc <: IApiTagsMetadata & IApiNameMetadata & IApiGroupNamesProvider & IApiDescriptionMetadata & IApiSummaryMetadata & IApiVisibilityProvider {
private let _ignore: Bool
private let _name: ?String
private let _tags: ?String
private let _summary: ?String
private let _groupName: ?String
private let _groupNames: String
private let _description: ?String
/**
@@ -28,17 +31,17 @@ public class SimApiDoc <: IApiTagsMetadata & IApiNameMetadata & IApiGroupNamePro
* @param name API 名称。
* @param ignore 是否忽略当前 API。
* @param summary API 摘要。
* @param groupName API 分组名称。
* @param groupNames API 分组名称(逗号分隔,如 "api,admin");"*" 表示出现在所有文档;空串表示未分组(仅进默认文档)
* @param description API 描述。
* @param tags API 标签字符串。
*/
public const init(name!: ?String = None, ignore!: Bool = false, summary!: ?String = None,
groupName!: ?String = None, description!: ?String = None, tags!: ?String = None) {
groupNames!: String = "", description!: ?String = None, tags!: ?String = None) {
_tags = tags
_name = name
_ignore = ignore
_summary = summary
_groupName = groupName
_groupNames = groupNames
_description = description
}
@@ -58,7 +61,7 @@ public class SimApiDoc <: IApiTagsMetadata & IApiNameMetadata & IApiGroupNamePro
*/
public prop name: ?String {
get() {
_name
_name
}
}
@@ -83,12 +86,12 @@ public class SimApiDoc <: IApiTagsMetadata & IApiNameMetadata & IApiGroupNamePro
}
/**
* @brief 返回 API 分组名称。
* @return 当前 API 的分组名称
* @brief 返回 API 分组名称(逗号分隔,"*" 表示所有文档)
* @return 逗号分隔的分组名称字符串,空串表示未分组
*/
public prop groupName: ?String {
public prop groupNames: String {
get() {
_groupName
_groupNames
}
}
@@ -6,16 +6,16 @@
* This source file is licensed under the MIT License found in the
* LICENSE file in the root directory of this source tree.
*/
package simcu::simapi.openapi.metadata
/**
* @brief 提供 API 分组名称元数据。
* @brief 提供 API 分组名称元数据(逗号分隔,如 "api,admin"
*/
public interface IApiGroupNameProvider {
public interface IApiGroupNamesProvider {
/**
* @brief 返回 API 分组名称
* @return 当前 API 的分组名称
* @brief 返回 API 所属的多个分组名称(逗号分隔)
* @return 逗号分隔的分组名称字符串,空串表示未指定
*/
prop groupName: ?String
}
prop groupNames: String
}
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 SimCuTeam. All rights reserved.
* 遵循 MIT 许可证。
*/
package simcu::simapi.openapi.metadata
import std.reflect.*
/**
* @brief 提供 API 响应类型元数据(供动态注册的路由指定返回类型)。
*/
public interface IApiResponseTypeMetadata {
/**
* @brief 返回 API 响应的类型信息。
*/
prop responseType: ?TypeInfo
}
+14
View File
@@ -20,6 +20,11 @@ public class OpenApiComponents <: IOpenApiSerializable {
*/
public var schemas = HashMap<String, OpenApiSchema>()
/**
* @brief 表示组件中的安全方案集合。
*/
public var securitySchemes = HashMap<String, OpenApiSecurityScheme>()
/**
* @brief 创建 OpenAPI 组件实例。
*/
@@ -41,6 +46,15 @@ public class OpenApiComponents <: IOpenApiSerializable {
}
writer.endObject()
}
if (!securitySchemes.isEmpty()) {
writer.writeName("securitySchemes")
writer.startObject()
for ((key, value) in securitySchemes) {
writer.writeName(key)
value.serializeAsV3(writer)
}
writer.endObject()
}
writer.endObject()
}
}
@@ -6,22 +6,56 @@
* This source file is licensed under the MIT License found in the
* LICENSE file in the root directory of this source tree.
*/
package simcu::simapi.openapi.models
import std.collection.*
/**
* @brief 表示 OpenAPI 安全需求对象。
*
* 每个实例对应一个安全需求条目,例如 {"Token": []}。
*/
public class OpenApiSecurityRequirement <: IOpenApiSerializable {
private let _requirements = ArrayList<(String, ArrayList<String>)>()
/**
* @brief 创建 OpenAPI 安全需求对象。
*/
public init() {
}
/**
* @brief 添加一个安全方案需求。
* @param schemeName 安全方案名称。
*/
public func addScheme(schemeName: String): Unit {
_requirements.add((schemeName, ArrayList<String>()))
}
/**
* @brief 添加一个带授权范围的安全方案需求。
* @param schemeName 安全方案名称。
* @param scopes 该方案要求的授权范围集合(OAuth2 场景)。
*/
public func addScheme(schemeName: String, scopes: ArrayList<String>): Unit {
_requirements.add((schemeName, scopes))
}
/**
* @brief 按 OpenAPI V3 格式写出当前安全需求对象。
* @param writer OpenAPI 写入器。
*/
public func serializeAsV3(writer: IOpenApiWriter): Unit {}
public func serializeAsV3(writer: IOpenApiWriter): Unit {
writer.startObject()
for ((schemeName, scopes) in _requirements) {
writer.writeName(schemeName)
writer.startArray()
for (scope in scopes) {
writer.writeValue(scope)
}
writer.endArray()
}
writer.endObject()
}
}
@@ -0,0 +1,77 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
/*
* Copyright (c) 杭州颉创科技有限公司 2025. All rights reserved.
* This source file is licensed under the MIT License found in the
* LICENSE file in the root directory of this source tree.
*/
package simcu::simapi.openapi.models
/**
* @brief 表示 OpenAPI 安全方案对象。
*/
public class OpenApiSecurityScheme <: IOpenApiSerializable {
/**
* @brief 表示安全方案类型(apiKey/http/oauth2/openIdConnect)。
*/
public var schemeType: String = "apiKey"
/**
* @brief 表示用于安全方案的参数名称(apiKey 时有效)。
*/
public var name: ?String = None
/**
* @brief 表示 apiKey 参数所在位置(header/query/cookie)。
*/
public var location: ?String = None
/**
* @brief 表示 HTTP 认证方案名称(http 时有效,如 bearer)。
*/
public var scheme: ?String = None
/**
* @brief 表示安全方案的描述信息。
*/
public var description: ?String = None
/**
* @brief 表示 bearer 令牌的格式提示(http+bearer 时有效)。
*/
public var bearerFormat: ?String = None
/**
* @brief 创建 OpenAPI 安全方案对象。
*/
public init() {
}
/**
* @brief 按 OpenAPI V3 格式写出当前安全方案对象。
* @param writer OpenAPI 写入器。
*/
public func serializeAsV3(writer: IOpenApiWriter): Unit {
writer.startObject()
writer.writeName("type")
writer.writeValue(schemeType)
if (let Some(value) <- description) {
writer.writeName("description")
writer.writeValue(value)
}
if (let Some(value) <- name) {
writer.writeName("name")
writer.writeValue(value)
}
if (let Some(value) <- location) {
writer.writeName("in")
writer.writeValue(value)
}
if (let Some(value) <- scheme) {
writer.writeName("scheme")
writer.writeValue(value)
}
if (let Some(value) <- bearerFormat) {
writer.writeName("bearerFormat")
writer.writeValue(value)
}
writer.endObject()
}
}
+113 -12
View File
@@ -11,6 +11,7 @@ package simcu::simapi.openapi.services
import std.reflect.*
import std.collection.*
import std.unicode.*
import soulsoft_web_mvc.*
import soulsoft_web_http.*
import soulsoft_web_routing.http.*
@@ -23,6 +24,8 @@ import simcu::simapi.openapi.metadata.*
import soulsoft_web_mvc.abstractions.*
import simcu::simapi.openapi.transformers.*
import simcu::simapi.openapi.infrastructure.*
import simcu::simapi.configurations.*
import simcu::simapi.annotations.*
/**
* @brief 提供 OpenAPI 文档生成功能。
@@ -59,18 +62,52 @@ protected class OpenApiDocumentService {
*/
public func getOpenApiDocument(services: IServiceProvider): OpenApiDocument {
let document = OpenApiDocument()
document.info = OpenApiInfo(title: "OpenApi | ${_documentName}", version: "1.0.0")
// 从 SimApiDocOptions 中查找当前文档组的标题和描述
var docTitle = "OpenApi | ${_documentName}"
var docDescription: ?String = None
if (let Some(simOptions) <- services.get<SimApiOptions>()) {
docTitle = simOptions.simApiDocOptions.documentTitle
for (g in simOptions.simApiDocOptions.distinctGroups()) {
if (g.id == _documentName) {
docTitle = g.name
if (!g.description.isEmpty()) {
docDescription = g.description
}
break
}
}
}
let info = OpenApiInfo(title: docTitle, version: "1.0.0")
info.description = docDescription
document.info = info
document.paths = createOpenApiPaths(services)
document.components = createOpenApiComponents()
applyAuthSecurity(document, services)
return document
}
/// 当启用 SimApiAuth 时,为文档注册 Token 认证方案(apiKey Header)。
/// 仅注册 scheme,不添加全局 security;由 createOpenApiOperation 按接口注解逐个添加。
private func applyAuthSecurity(document: OpenApiDocument, services: IServiceProvider): Unit {
let enableAuth = services.get<SimApiOptions>().flatMap {f => Some(f.enableSimApiAuth)} ?? false
if (!enableAuth) {
return
}
if (let Some(components) <- document.components) {
let scheme = OpenApiSecurityScheme()
scheme.name = Some("Token")
scheme.location = Some("header")
scheme.description = Some("登录后返回的 TokenHeader: Token")
components.securitySchemes.add("Token", scheme)
}
}
private func createOpenApiPaths(services: IServiceProvider) {
let paths = OpenApiPaths()
let openApiOptions = services.getOrThrow<IOptionsMonitor<OpenApiOptions>>().get(_documentName)
let operationTransformers = openApiOptions.operationTransformers
for (endpoint in _endpointSource.endpoints |> filterMap {f => f as RouteEndpoint} where !isIgnore(endpoint)) {
if (!isShouldInclude(endpoint)) {
if (!isShouldInclude(endpoint, openApiOptions.includeUnGrouped)) {
continue
}
@@ -106,13 +143,35 @@ protected class OpenApiDocumentService {
return paths
}
private func isShouldInclude(endpoint: RouteEndpoint) {
let groupNames = endpoint.metadata.getOrderedMetadata<IApiGroupNameProvider>() |> filterMap {f => f.groupName} |>
collectArray
if (groupNames.isEmpty() || groupNames.contains(_documentName)) {
private func isShouldInclude(endpoint: RouteEndpoint, includeUnGrouped: Bool) {
let rawGroupNames = endpoint.metadata.getOrderedMetadata<IApiGroupNamesProvider>() |>
filterMap { f => f.groupNames } |> collectArray
if (rawGroupNames.isEmpty()) {
// 未标注 groupNames 的接口:仅进入默认文档(includeUnGrouped=true 的文档)
return includeUnGrouped
}
// 合并所有注解的 groupNames,按逗号拆分并去空白
let names = ArrayList<String>()
for (raw in rawGroupNames) {
for (part in raw.split(",")) {
let trimmed = part.trim()
if (trimmed.isEmpty()) {
continue
}
if (!names.contains(trimmed)) {
names.add(trimmed)
}
}
}
if (names.isEmpty()) {
// 仅标注了空串:视为未分组
return includeUnGrouped
}
// "*" 表示出现在所有文档
if (names.contains("*")) {
return true
}
return false
return names.contains(_documentName)
}
private func createOpenApiComponents() {
@@ -142,7 +201,7 @@ protected class OpenApiDocumentService {
}
// response
operation.response = createOpenApiOperationResponses(actionDescriptor)
operation.response = createOpenApiOperationResponses(endpoint, actionDescriptor)
// summary
if (let Some(metadata) <- endpoint.metadata.getLastMetadata<IApiSummaryMetadata> {f => f.summary.isSome()}) {
@@ -165,6 +224,13 @@ protected class OpenApiDocumentService {
operation.operationId = metadata.name
}
// security:仅标注了 @SimApiAuth 的接口才显示锁图标
if (let Some(_) <- endpoint.metadata.getMetadata<SimApiAuth>()) {
let requirement = OpenApiSecurityRequirement()
requirement.addScheme("Token")
operation.security.add(requirement)
}
return operation
}
@@ -230,30 +296,65 @@ protected class OpenApiDocumentService {
/*
生成响应描述
*/
private func createOpenApiOperationResponses(actionDescriptor: ?ControllerActionDescriptor) {
private func createOpenApiOperationResponses(endpoint: RouteEndpoint, actionDescriptor: ?ControllerActionDescriptor) {
let responses = OpenApiResponses()
// 获取action的返回类型
// 获取action的返回类型:优先从 actionDescriptor,其次从 IApiResponseTypeMetadata(动态路由)
let returnType: ?TypeInfo = if (let Some(actionDescriptor) <- actionDescriptor) {
Nullable.getUnderlyingType(actionDescriptor.actionFunction.returnType) ?? actionDescriptor
.actionFunction
.returnType
} else if (let Some(meta) <- endpoint.metadata.getMetadata<IApiResponseTypeMetadata>()) {
meta.responseType
} else {
None
}
// 判断是否跳过封装:@OriginResponse 或返回类型已是 SimApiBaseResponse/SimApiResponse/SimApiDataResponse
var skipWrap = false
if (let Some(_) <- endpoint.metadata.getMetadata<OriginResponse>()) {
skipWrap = true
}
if (let Some(rt) <- returnType) {
let rtName = rt.name
if (rtName == "SimApiBaseResponse" || rtName == "SimApiDataResponse" || rtName.startsWith("SimApiResponse")) {
skipWrap = true
}
}
if (let Some(returnType) <- returnType && returnType != TypeInfo.of<Unit>()) {
let response = OpenApiResponse("OK")
let schema = _openApiSchemaService.createSchema(returnType)
let dataSchema = _openApiSchemaService.createSchema(returnType)
let schema = if (skipWrap) { dataSchema } else { wrapResponseSchema(dataSchema) }
response.content.add("text/plain", OpenApiMediaType(schema))
response.content.add("application/json", OpenApiMediaType(schema))
response.content.add("text/json", OpenApiMediaType(schema))
responses.add("200", response)
} else {
responses.add("200", OpenApiResponse("OK"))
// Unitvoid)→ SimApiBaseResponse {code, message}
let response = OpenApiResponse("OK")
let schema = if (skipWrap) { OpenApiSchema() } else { wrapResponseSchema(None) }
response.content.add("text/plain", OpenApiMediaType(schema))
response.content.add("application/json", OpenApiMediaType(schema))
response.content.add("text/json", OpenApiMediaType(schema))
responses.add("200", response)
}
return responses
}
/// 将 schema 包装为统一响应结构 {code, message, data?}。
private func wrapResponseSchema(dataSchema: ?OpenApiSchema): OpenApiSchema {
let wrapped = OpenApiSchema(`type`: OpenApiSchemaTypes.OBJECT)
let codeSchema = OpenApiSchema(`type`: "integer", format: "int64")
let msgSchema = OpenApiSchema(`type`: OpenApiSchemaTypes.STRING)
wrapped.properties.add("code", codeSchema)
wrapped.properties.add("message", msgSchema)
if (let Some(ds) <- dataSchema) {
wrapped.properties.add("data", ds)
}
return wrapped
}
// api ignore
private func isIgnore(endpoint: RouteEndpoint) {
if (let Some(metadata) <- endpoint.metadata.getLastMetadata<IApiVisibilityProvider> {f => f.ignore}) {
+6
View File
@@ -22,6 +22,12 @@ public class OpenApiOptions {
private let _operationTransformer = ArrayList<IOpenApiOperationTransformer>()
private let _schemaTransformers = ArrayList<IOpenApiSchemaTransformer>()
/**
* 未标注 groupName 的接口是否进入本文档。
* 多文档分组时,仅默认组(第一个 apiGroup 或 isDefault 的组)应设为 true。
*/
public var includeUnGrouped: Bool = false
/**
* @brief 确定指定类型在 components/schemas 中使用的引用 ID。
* 返回 None 时该 Schema 始终内联,不生成 $ref。
+60 -7
View File
@@ -25,6 +25,7 @@ import std.collection.*
import std.convert.*
import std.reflect.*
import std.time.*
import std.unicode.*
import soulsoft_web_http.*
import soulsoft_web_hosting.*
import soulsoft_web_mvc.*
@@ -44,6 +45,7 @@ import simcu::simapi.logger.*
import simcu::simapi.middlewares.*
import simcu::simapi.openapi.*
import simcu::simapi.openapi.annotations.*
import simcu::simapi.annotations.{SimApiAuth as SimApiAuthAnnotation}
/**
* SimApi 扩展入口。
@@ -174,7 +176,7 @@ public class SimApiExtensions {
if (let c: SimApiCommonController <- controller) {
SimApiResultWriter.write(context, c.userInfo())
}
}).withOpenApi(SimApiDoc(tags: "认证", summary: "获取登录用户信息"))
}).withOpenApi(SimApiDoc(tags: "认证", summary: "获取登录用户信息",groupNames:"*")).withSimApiAuth(SimApiAuthAnnotation()).withResponseType(TypeInfo.of<SimApiLoginItem>())
logger.info("注册内置Route: UserInfo => ${route}")
}
if (let Some(route) <- routeOptions.logoutRoute) {
@@ -187,7 +189,7 @@ public class SimApiExtensions {
if (let c: SimApiAuthController <- controller) {
SimApiResultWriter.write(context, c.logout())
}
}).withOpenApi(SimApiDoc(tags: "认证", summary: "退出登录"))
}).withOpenApi(SimApiDoc(tags: "认证", summary: "退出登录",groupNames:"*"))
logger.info("注册内置Route: Logout => ${route}")
}
if (let Some(route) <- routeOptions.webConfigRoute) {
@@ -200,7 +202,7 @@ public class SimApiExtensions {
if (let c: SimApiCommonController <- controller) {
SimApiResultWriter.write(context, c.webConfig())
}
}).withOpenApi(SimApiDoc(tags: "公共", summary: "获取公共系统配置"))
}).withOpenApi(SimApiDoc(tags: "公共", summary: "获取公共系统配置",groupNames:"*")).withResponseType(TypeInfo.of<HashMap<String, Any>>())
host.mapPost(route, { context =>
let controller = ActivatorUtilities.createInstance(context.services,
TypeInfo.of<SimApiCommonController>())
@@ -210,7 +212,7 @@ public class SimApiExtensions {
if (let c: SimApiCommonController <- controller) {
SimApiResultWriter.write(context, c.webConfig())
}
}).withOpenApi(SimApiDoc(tags: "公共", summary: "获取公共系统配置"))
}).withOpenApi(SimApiDoc(tags: "公共", summary: "获取公共系统配置",groupNames:"*")).withResponseType(TypeInfo.of<HashMap<String, Any>>())
logger.info("注册内置Route: WebConfig => ${route}")
}
@@ -252,7 +254,29 @@ public class SimApiExtensions {
// SimApiDocOpenAPI 文档 JSON 路由 + Swagger UI 静态资源,最内层挂载)
if (options.enableSimApiDoc) {
logger.info("开始配置 SimApiDoc...")
host.mapOpenApi()
// 归一化路由前缀:去掉首尾斜杠,保证形如 "docs"
let rawPrefix = options.simApiDocOptions.urlPrefix.trim().trimStart('/').trimEnd('/')
let prefix = if (rawPrefix.isEmpty()) { "docs" } else { rawPrefix }
let routeBase = "/${prefix}"
// 文档列表端点:供 Swagger UI 下拉切换多文档([{name, url}]
host.mapGet("${routeBase}/urls", { context =>
let simOptions = context.services.getOrThrow<SimApiOptions>()
let groups = simOptions.simApiDocOptions.distinctGroups()
let sb = StringBuilder()
sb.append("[")
for (i in 0..groups.size) {
if (i > 0) {
sb.append(",")
}
let g = groups[i]
let name = g.name.replace("\"", "\\\"")
sb.append("{\"name\":\"${name}\",\"url\":\"${routeBase}/${g.id}.json\"}")
}
sb.append("]")
context.response.contentType = "application/json; charset=utf-8"
context.response.write(sb.toString())
}).withOpenApi(SimApiDoc(ignore: true))
host.mapOpenApi("${routeBase}/{documentName}.json")
host.useOpenApiUI()
}
}
@@ -277,9 +301,38 @@ public class SimApiExtensions {
// 中间件无需注册:挂载时由 ActivatorUtilities 从 DI 解析构造参数创建
// API 文档(OpenAPI
// API 文档(OpenAPI:按 SimApiDocOptions.apiGroups 注册多个文档
// (如 api/admin 各生成 /{urlPrefix}/{id}.json);未标注 groupNames 的接口仅进入默认组文档
if (options.enableSimApiDoc) {
builder.services.addOpenApi()
let docOptions = options.simApiDocOptions
let groups = docOptions.distinctGroups()
if (groups.isEmpty()) {
builder.services.addOpenApi() {opt =>
opt.includeUnGrouped = true
}
} else {
var defaultId: ?String = None
for (group in groups) {
if (group.isDefault) {
defaultId = Some(group.id)
break
}
}
if (let None <- defaultId) {
defaultId = Some(groups[0].id)
}
let defId = match (defaultId) {
case Some(id) => id
case None => ""
}
for (group in groups) {
let gid = group.id
let include = (gid == defId)
builder.services.addOpenApi(gid) {opt =>
opt.includeUnGrouped = include
}
}
}
}
// 认证(DI 自动注入 SimApiOptions