增加了api文档
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
> 仓颉版 SimApi:ASP.NET Core 风格 API 基础框架,移植自 [SimApi](https://github.com/SimcuTeam/simapi-net)。
|
||||
|
||||
提供**统一响应格式、异常拦截、Token 认证、缓存、工具集、HTTP 客户端、S3 存储、声明式注解**等 API 基础能力。
|
||||
提供**统一响应格式、异常拦截、Token 认证、缓存、工具集、HTTP 客户端、S3 存储、声明式注解、OpenAPI 文档**等 API 基础能力。
|
||||
|
||||
---
|
||||
|
||||
@@ -118,7 +118,13 @@ simapi-cj/
|
||||
│ ├── logger/ # SimApiLogger, SimApiLoggerProvider(彩色日志)
|
||||
│ ├── macros/ # ReadTomlVersion(编译期读版本号)、EnumString(枚举字符串双向转换)
|
||||
│ ├── middlewares/ # SimApiExceptionMiddleware, SimApiAuthMiddleware, SimApiRequestLogMiddleware
|
||||
│ └── models/ # SimApiBaseModel(实体基类)
|
||||
│ ├── models/ # SimApiBaseModel(实体基类)
|
||||
│ └── openapi/ # OpenAPI 文档生成 + Swagger UI 内置资源
|
||||
│ ├── annotations/ # @SimApiDoc(文档元数据注解)
|
||||
│ ├── metadata/ # IApiGroupNamesProvider, IApiResponseTypeMetadata 等元数据接口
|
||||
│ ├── models/ # OpenApiDocument, OpenApiSchema, OpenApiInfo 等 OpenAPI 模型
|
||||
│ ├── services/ # OpenApiDocumentService(文档生成), OpenApiSchemaService, OpenApiOptions
|
||||
│ └── infrastructure/ # OpenApiConstants
|
||||
```
|
||||
|
||||
---
|
||||
@@ -380,7 +386,7 @@ SimApiExtensions.addSimApi(builder) { options =>
|
||||
| ----------------- | -------- | ---------------------------- | -------------------------- |
|
||||
| `/user/info` | POST | `enableSimApiAuth` | 需登录,返回 LoginInfo |
|
||||
| `/auth/logout` | POST | `enableSimApiAuth` | 退出登录 |
|
||||
| `/exception/{code}` | GET | 始终 | 错误反馈(抛 SimApiException) |
|
||||
| `/exception/{code}` | GET | 始终 | 错误反馈(抛 SimApiException,不出现在文档中) |
|
||||
|
||||
路由路径可自定义(`configureSimApiRoute`):
|
||||
|
||||
@@ -394,7 +400,7 @@ options.configureSimApiRoute { route =>
|
||||
|
||||
### 7. 认证后处理 Hook — SimApiAuthChecker
|
||||
|
||||
实现后每次认证成功都会调用(配合 `@SimApiAuth` 注解或手动 `requireLogin`):
|
||||
实现后每次认证成功都会调用(配合 `@SimApiAuth` 注解):
|
||||
|
||||
```cangjie
|
||||
import simcu::simapi.interfaces.*
|
||||
@@ -440,7 +446,89 @@ iam.checkPermission(profileId, "app:create") // 无权限抛 403
|
||||
|
||||
---
|
||||
|
||||
## 宏(编译期)
|
||||
## OpenAPI 文档 — enableSimApiDoc
|
||||
|
||||
`enableSimApiDoc = true` 时自动生成 OpenAPI 3.0 JSON 文档并内置 Swagger UI 静态资源(无需外部文件,打包后不失效)。
|
||||
|
||||
### 文档分组
|
||||
|
||||
支持多个文档组,未标注 `@SimApiDoc` 的接口默认进入默认组文档:
|
||||
|
||||
```cangjie
|
||||
SimApiExtensions.addSimApi(builder) { options =>
|
||||
options.enableSimApiDoc = true
|
||||
options.configureSimApiDoc { doc =>
|
||||
doc.apiGroups.add(SimApiDocGroup("api", name: "App接口", description: "对接App相关接口"))
|
||||
doc.apiGroups.add(SimApiDocGroup("admin", name: "后台管理接口", description: "后台管理接口"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `SimApiDocGroup(id, name!, description!, isDefault!)`:`name` 默认取 `id` 值
|
||||
- `distinctGroups()`:按 id 去重,**用户配置覆盖默认值**(保留最后出现)
|
||||
- 若所有组均未标记 `isDefault`,第一个组视为默认组
|
||||
|
||||
### @SimApiDoc 注解
|
||||
|
||||
```cangjie
|
||||
import simcu::simapi.openapi.annotations.*
|
||||
|
||||
@SimApiDoc[tags: "登录", summary: "用户登录"]
|
||||
@SimApiDoc[tags: "认证", summary: "后台登录", groupNames: "admin"]
|
||||
@SimApiDoc[tags: "公共", groupNames: "api,admin"] // 同时出现在 api 和 admin 文档
|
||||
@SimApiDoc[tags: "公共", groupNames: "*"] // 出现在所有文档
|
||||
@SimApiDoc[ignore: true] // 不出现在文档中
|
||||
```
|
||||
|
||||
- `groupNames`:逗号分隔多个组名,`*` 表示所有文档,空串表示未分组(仅进默认文档)
|
||||
- `ignore: true`:从文档中隐藏
|
||||
|
||||
### 路由前缀
|
||||
|
||||
```cangjie
|
||||
options.configureSimApiDoc { doc =>
|
||||
doc.urlPrefix = "docs" // 默认值,可自定义
|
||||
}
|
||||
```
|
||||
|
||||
| 路由 | 说明 |
|
||||
|------|------|
|
||||
| `/{prefix}/all.html` | 多文档切换页(顶部栏下拉选择所有文档) |
|
||||
| `/{prefix}/{id}.html` | 单文档页(无顶部栏,自动加载 `{id}.json`) |
|
||||
| `/{prefix}/urls` | 文档列表 JSON(供 Swagger UI 下拉) |
|
||||
| `/{prefix}/{id}.json` | OpenAPI 文档 JSON |
|
||||
|
||||
> Swagger UI 静态资源(CSS/JS/HTML)以 Base64 内联编译,运行时由 `OpenApiUIMiddleware` 解码输出。资源更新后运行 `pwsh tools/gen-swagger-ui-resources.ps1` 重新生成。
|
||||
|
||||
### 响应封装与文档
|
||||
|
||||
接口返回值自动封装为统一响应格式,**文档中 response schema 也体现封装**:
|
||||
|
||||
| 返回类型 | `@OriginResponse` | 文档 response schema |
|
||||
|----------|-------------------|---------------------|
|
||||
| `SimApiBaseResponse`/`SimApiResponse<T>`/`SimApiDataResponse` | - | 原样不封装 |
|
||||
| 任意类型 | ✓ | 原样不封装 |
|
||||
| `Unit` (void) | ✗ | `{code: int64, message: string}` |
|
||||
| `String` | ✗ | `{code, message, data: {type: string}}` |
|
||||
| DTO | ✗ | `{code, message, data: {$ref: DTO}}` |
|
||||
|
||||
### 认证锁图标
|
||||
|
||||
仅标注了 `@SimApiAuth` 的接口在文档中显示锁图标(operation 级 `security`),未标注的接口不显示。
|
||||
|
||||
动态注册的路由(lambda)需用 `withSimApiAuth` 扩展方法手动添加认证元数据:
|
||||
|
||||
```cangjie
|
||||
host.mapPost(route, { context => ... })
|
||||
.withOpenApi(SimApiDoc(tags: "认证", summary: "获取用户信息"))
|
||||
.withSimApiAuth(SimApiAuth())
|
||||
.withResponseType(TypeInfo.of<SimApiLoginItem>())
|
||||
```
|
||||
|
||||
- `withSimApiAuth`:添加 `@SimApiAuth` 元数据(显示锁图标)
|
||||
- `withResponseType`:添加响应类型元数据(动态路由无 `ControllerActionDescriptor`,需手动指定返回类型才能生成 response schema)
|
||||
|
||||
---
|
||||
|
||||
### EnumString — 枚举字符串双向转换
|
||||
|
||||
@@ -499,6 +587,7 @@ SimApiExtensions.addSimApi(builder) { options =>
|
||||
options.enableSimApiResponseFilter = true // 响应统一封装
|
||||
options.enableSimApiHttpClient = false // HTTP 客户端
|
||||
options.enableSimApiAuthGate = false // 认证中心 SDK + 网关中间件
|
||||
options.enableSimApiDoc = false // OpenAPI 文档 + Swagger UI
|
||||
options.enableRequestLog = false // 请求日志中间件
|
||||
options.enableCors = true // 全量 CORS
|
||||
options.enableLogger = true // 控制台日志
|
||||
@@ -509,6 +598,11 @@ SimApiExtensions.addSimApi(builder) { options =>
|
||||
route.logoutRoute = Some("/auth/logout")
|
||||
route.webConfigRoute = Some("/config")
|
||||
}
|
||||
options.configureSimApiDoc { doc =>
|
||||
doc.urlPrefix = "docs" // 文档路由前缀(默认 "docs")
|
||||
doc.apiGroups.add(SimApiDocGroup("api", name: "App接口", description: "App接口文档"))
|
||||
doc.apiGroups.add(SimApiDocGroup("admin", name: "后台管理", description: "后台管理接口"))
|
||||
}
|
||||
options.configureSimApiRequestLog { opt =>
|
||||
opt.showFullResponse = true
|
||||
opt.showFullHeader = false
|
||||
@@ -535,14 +629,14 @@ simapi 提供 Spire MVC 控制器(继承 `SimApiBaseController`),`addSimAp
|
||||
|--------|------|------|
|
||||
| `SimApiCommonController` | `/exception/{code}`、`/config`、`/user/info` | 通用内置路由 |
|
||||
| `SimApiAuthController` | `/auth/logout` | 退出登录 |
|
||||
| `SimApiBaseController` | — | 基类:`loginInfo` / `loginToken` / `requireLogin()` / `getLogin()` |
|
||||
| `SimApiBaseController` | — | 基类:`loginInfo` / `loginToken`(访问时自动校验登录,未登录抛 401) |
|
||||
|
||||
```cangjie
|
||||
import simcu::simapi.controllers.*
|
||||
import simcu::simapi.annotations.{SimApiAuth}
|
||||
|
||||
// 控制器写法:继承 SimApiBaseController,注解路由 + DI 注入
|
||||
@SimApiAuth // 类级鉴权(可选,替代 requireLogin)
|
||||
@SimApiAuth // 类级鉴权
|
||||
public class MyController <: SimApiBaseController {
|
||||
private let _auth: SimApiAuth
|
||||
public init(auth: SimApiAuth) { this._auth = auth }
|
||||
@@ -560,15 +654,14 @@ public class MyController <: SimApiBaseController {
|
||||
|
||||
## 未实现模块(选项占位)
|
||||
|
||||
以下原包功能因仓颉生态暂无对应库(Hangfire/MQTT/Swashbuckle),**选项保留但未实现**:
|
||||
以下原包功能因仓颉生态暂无对应库(Hangfire/MQTT),**选项保留但未实现**:
|
||||
|
||||
| 选项 | 原功能 | 状态 |
|
||||
|------|--------|------|
|
||||
| `enableSimApiDoc` | Swagger 文档(可换 soulsoft_web_openapi) | ❌ 未实现 |
|
||||
| `enableSynapse` | MQTT 通信 | ❌ 未实现 |
|
||||
| `enableJob` | Hangfire 任务调度 | ❌ 未实现 |
|
||||
|
||||
> ✅ 已实现(曾为占位):`enableSimApiStorage`(S3/MinIO,自实现 AWS SigV4)、`enableSimApiAuthGate`(AuthSDK 认证中心)、`SimApiAesUtil`(纯仓颉 AES-256-CBC)、`SimApiAuthChecker`、`@SimApiSign` / `@AesBody` 声明式注解、内置路由自定义路径。
|
||||
> ✅ 已实现(曾为占位):`enableSimApiDoc`(OpenAPI 文档 + Swagger UI)、`enableSimApiStorage`(S3/MinIO,自实现 AWS SigV4)、`enableSimApiAuthGate`(AuthSDK 认证中心)、`SimApiAesUtil`(纯仓颉 AES-256-CBC)、`SimApiAuthChecker`、`@SimApiSign` / `@AesBody` 声明式注解、内置路由自定义路径。
|
||||
|
||||
---
|
||||
|
||||
@@ -583,8 +676,10 @@ public class MyController <: SimApiBaseController {
|
||||
| `simcu::serialization`(path 依赖) | JSON 序列化(simapi 自研,反射免标注) |
|
||||
| `redis`(pkg.cangjie-lang.cn) | Redis 客户端(认证/缓存 Redis 模式) |
|
||||
| `stdx`(CANGJIE_STDX_PATH) | 标准扩展库(md5/sha1/base64/http/tls) |
|
||||
| `soulsoft_web_mvc` | MVC 框架(控制器路由、模型绑定) |
|
||||
|
||||
> 构建前需设置 `CANGJIE_STDX_PATH` 指向本地 stdx 的 `static/stdx` 目录。
|
||||
> OpenAPI Swagger UI 静态资源内置(Base64 内联),更新资源后运行 `pwsh tools/gen-swagger-ui-resources.ps1`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
version = 0
|
||||
|
||||
[requires]
|
||||
soulsoft_web_http = {version = "1.0.20260528"}
|
||||
soulsoft_extensions_hosting = {version = "1.0.20260528"}
|
||||
soulsoft_extensions_logging_configuration = {version = "1.0.20260528"}
|
||||
soulsoft_extensions_options_configuration = {version = "1.0.20260528"}
|
||||
soulsoft_web_hosting = {version = "1.0.20260528"}
|
||||
soulsoft_extensions_logging_console = {version = "1.0.20260528"}
|
||||
redis = {version = "1.0.20260627"}
|
||||
soulsoft_extensions_logging_configuration = {version = "1.0.20260528"}
|
||||
soulsoft_identity_claims = {version = "1.0.20260528"}
|
||||
soulsoft_web_http = {version = "1.0.20260528"}
|
||||
"simcu::serialization" = {version = "1.2.1"}
|
||||
soulsoft_extensions_configuration = {version = "1.0.20260528"}
|
||||
soulsoft_web_routing = {version = "1.0.20260528"}
|
||||
soulsoft_web_mvc = {version = "1.0.20260528"}
|
||||
soulsoft_extensions_injection = {version = "1.0.20260528"}
|
||||
soulsoft_extensions_options = {version = "1.0.20260528"}
|
||||
soulsoft_web_cors = {version = "1.0.20260528"}
|
||||
soulsoft_extensions_injection = {version = "1.0.20260528"}
|
||||
soulsoft_web_mvc = {version = "1.0.20260528"}
|
||||
soulsoft_serialization = {version = "1.0.20260528"}
|
||||
redis = {version = "1.0.20260627"}
|
||||
soulsoft_identity_claims = {version = "1.0.20260528"}
|
||||
soulsoft_extensions_options = {version = "1.0.20260528"}
|
||||
soulsoft_extensions_logging = {version = "1.0.20260528"}
|
||||
"simcu::serialization" = {version = "1.2.1"}
|
||||
soulsoft_extensions_logging_console = {version = "1.0.20260528"}
|
||||
soulsoft_web_hosting = {version = "1.0.20260528"}
|
||||
|
||||
@@ -3,7 +3,7 @@ cjc-version = "1.1.3"
|
||||
name = "simapi"
|
||||
organization = "simcu"
|
||||
description = "SimApi 仓颉版:ASP.NET Core 风格 API 基础框架(统一响应/异常拦截/Token认证/缓存/工具集/HTTP客户端)"
|
||||
version = "1.0.3"
|
||||
version = "1.1.0"
|
||||
target-dir = ""
|
||||
output-type = "static"
|
||||
override-compile-option = ""
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>API 文档(全部)</title>
|
||||
<link rel="stylesheet" href="swagger-ui.css">
|
||||
<style>
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="swagger-ui-bundle.js"></script>
|
||||
<script src="swagger-ui-standalone-preset.js"></script>
|
||||
<script>
|
||||
window.onload = async function () {
|
||||
let urls = [];
|
||||
try {
|
||||
const res = await fetch('urls');
|
||||
if (res.ok) {
|
||||
const list = await res.json();
|
||||
if (Array.isArray(list) && list.length > 0) {
|
||||
urls = list;
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
if (urls.length === 0) {
|
||||
document.getElementById('swagger-ui').innerHTML =
|
||||
'<div style="padding:20px;color:#666;">无法加载文档列表,请确认服务正常运行。</div>';
|
||||
return;
|
||||
}
|
||||
window.ui = SwaggerUIBundle({
|
||||
urls: urls,
|
||||
"urls.primaryName": urls[0].name,
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>API 文档</title>
|
||||
<link rel="stylesheet" href="swagger-ui.css">
|
||||
<style>
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
var path = window.location.pathname;
|
||||
var match = path.match(/\/([^\/]+)\.html$/);
|
||||
var id = match ? match[1] : 'api';
|
||||
SwaggerUIBundle({
|
||||
url: id + '.json',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [SwaggerUIBundle.presets.apis]
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 调用;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
if (let Some(relativePath) <- resolveRelativePath(remainingPath)) {
|
||||
if (serveFromWebRoot(context, relativePath) || serveFromEmbedded(context, relativePath)) {
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
next(context)
|
||||
}
|
||||
|
||||
private func resolveRelativePath(remainingPath: PathString): ?String {
|
||||
if (remainingPath == "/" || !remainingPath.hasValue) {
|
||||
return None
|
||||
}
|
||||
|
||||
private func resolveAssetPath(remainingPath: PathString): ?Path {
|
||||
let relativePath = if (remainingPath == "/" || !remainingPath.hasValue) {
|
||||
"index.html"
|
||||
} else {
|
||||
let candidate = remainingPath.value.trimStart('/')
|
||||
if (!isSafeRelativePath(candidate)) {
|
||||
return None
|
||||
}
|
||||
candidate
|
||||
return Some(candidate)
|
||||
}
|
||||
|
||||
let openApiRoot = Path(_env.webRootPath).join("openapi")
|
||||
return openApiRoot.join(relativePath).normalize()
|
||||
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 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
|
||||
}
|
||||
return Some(bytes)
|
||||
} catch (_: Exception) {
|
||||
return None
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -10,12 +10,12 @@
|
||||
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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,19 +9,53 @@
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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("登录后返回的 Token(Header: 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"))
|
||||
// Unit(void)→ 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}) {
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 {
|
||||
// SimApiDoc(OpenAPI 文档 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)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 生成 SwaggerUIResources.cj:将 resources/openapi/ 下静态资源以 Base64 内联为 CJ 源常量。
|
||||
# 用法:pwsh tools/gen-swagger-ui-resources.ps1
|
||||
# 资源更新后重新运行此脚本即可。
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$inDir = Join-Path $root "resources\openapi"
|
||||
$outFile = Join-Path $root "src\openapi\SwaggerUIResources.cj"
|
||||
|
||||
$files = @(
|
||||
@{ Name = "allHtml"; File = "all.html" },
|
||||
@{ Name = "singleHtml"; File = "single.html" },
|
||||
@{ Name = "swaggerUiBundleJs"; File = "swagger-ui-bundle.js" },
|
||||
@{ Name = "swaggerUiStandalonePresetJs"; File = "swagger-ui-standalone-preset.js" },
|
||||
@{ Name = "swaggerUiCss"; File = "swagger-ui.css" }
|
||||
)
|
||||
|
||||
$sb = [System.Text.StringBuilder]::new()
|
||||
[void]$sb.AppendLine("/*")
|
||||
[void]$sb.AppendLine(" * 自动生成文件,请勿手动编辑。")
|
||||
[void]$sb.AppendLine(" * 由 tools/gen-swagger-ui-resources.ps1 从 resources/openapi/ 生成。")
|
||||
[void]$sb.AppendLine(" * Swagger UI 静态资源以 Base64 内联,运行时由 OpenApiUIMiddleware 解码输出。")
|
||||
[void]$sb.AppendLine(" */")
|
||||
[void]$sb.AppendLine("")
|
||||
[void]$sb.AppendLine("package simcu::simapi.openapi")
|
||||
[void]$sb.AppendLine("")
|
||||
[void]$sb.AppendLine("/**")
|
||||
[void]$sb.AppendLine(" * Swagger UI 内置静态资源(Base64 编码)。")
|
||||
[void]$sb.AppendLine(" */")
|
||||
[void]$sb.AppendLine("public class SwaggerUIResources {")
|
||||
|
||||
foreach ($f in $files) {
|
||||
$path = Join-Path $inDir $f.File
|
||||
if (-not (Test-Path $path)) {
|
||||
throw "资源文件不存在: $path"
|
||||
}
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$b64 = [System.Convert]::ToBase64String($bytes)
|
||||
[void]$sb.AppendLine(" public static let $($f.Name): String = `"$b64`"")
|
||||
[void]$sb.AppendLine("")
|
||||
}
|
||||
|
||||
[void]$sb.AppendLine(" private init() {}")
|
||||
[void]$sb.AppendLine("}")
|
||||
|
||||
[System.IO.File]::WriteAllText($outFile, $sb.ToString(), [System.Text.UTF8Encoding]::new($false))
|
||||
$size = (Get-Item $outFile).Length
|
||||
Write-Host "已生成 $outFile ($size 字节)"
|
||||
Reference in New Issue
Block a user