增加了api文档

This commit is contained in:
xrain committed 2026-09-01 18:17:37 +08:00
1 parent e0e57a7eb4
commit 73e770db80
25 files changed
+913 -140

No files matched your search

+105 -10
View File
@@ -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`。
---
+10 -10
View File
@@ -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"}
+1 -1
View File
@@ -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 = ""
+58
View File
@@ -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>
+40
View File
@@ -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>
+2
View File
@@ -0,0 +1,2 @@
/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */
!function webpackUniversalModuleDefinition(s,o){"object"==typeof exports&&"object"==typeof module?module.exports=o():"function"==typeof define&&define.amd?define([],o):"object"==typeof exports?exports.SwaggerUIBundle=o():s.SwaggerUIBundle=o()}(this,(()=>(()=>{var s={67526(s,o){"use strict";o.byteLength=function byteLength(s){var o=getLens(s),i=o[0],a=o[1];return 3*(i+a)/4-a},o.toByteArray=function toByteArray(s){var o,i,_=getLens(s),w=_[0],x=_[1],C=new u(function _byteLength(s,o,i){return 3*(o+i)/4-i}(0,w,x)),j=0,L=x>0?w-4:w;for(i=0;i<L;i+=4)o=a[s.charCodeAt(i)]<<18|a[s.charCodeAt(i+1)]<<12|a[s.charCodeAt(i+2)]<<6|a[s.charCodeAt(i+3)],C[j++]=o>>16&255,C[j++]=o>>8&255,C[j++]=255&o;2===x&&(o=a[s.charCodeAt(i)]<<2|a[s.charCodeAt(i+1)]>>4,C[j++]=255&o);1===x&&(o=a[s.charCodeAt(i)]<<10|a[s.charCodeAt(i+1)]<<4|a[s.charCodeAt(i+2)]>>2,C[j++]=o>>8&255,C[j++]=255&o);return C},o.fromByteArray=function fromByteArray(s){for(var o,a=s.length,u=a%3,_=[],w=16383,x=0,C=a-u;x<C;x+=w)_.push(encodeChunk(s,x,x+w>C?C:x+w));1===u?(o=s[a-1],_.push(i[o>>2]+i[o<<4&63]+"==")):2===u&&(o=(s[a-2]<<8)+s[a-1],_.push(i[o>>10]+i[o>>4&63]+i[o<<2&63]+"="));return _.join("")};for(var i=[],a=[],u="undefined"!=typeof Uint8Array?Uint8Array:Array,_="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",w=0;w<64;++w)i[w]=_[w],a[_.charCodeAt(w)]=w;function getLens(s){var o=s.length;if(o%4>0)throw new Error("Invalid string. Length must be a multiple of 4");var i=s.indexOf("=");return-1===i&&(i=o),[i,i===o?0:4-i%4]}function encodeChunk(s,o,a){for(var u,_,w=[],x=o;x<a;x+=3)u=(s[x]<<16&16711680)+(s[x+1]<<8&65280)+(255&s[x+2]),w.push(i[(_=u)>>18&63]+i[_>>12&63]+i[_>>6&63]+i[63&_]);return w.join("")}a["-".charCodeAt(0)]=62,a["_".charCodeAt(0)]=63},48287(s,o,i){"use strict";const a=i(67526),u=i(251),_="function"==typeof Symbol&&"function"==typeof Symbol.for?Symbol.for("nodejs.util.inspect.custom"):null;o.Buffer=Buffer,o.SlowBuffer=function SlowBuffer(s){+s!=s&&(s=0);return Buffer.alloc(+s)},o.INSPECT_MAX_BYTES=50;const w=2147483647;function createBuffer(s){if(s>w)throw new RangeError('The value "'+s+'" is invalid for option "size"');const o=new Uint8Array(s);return Object.setPrototypeOf(o,Buffer.prototype),o}function Buffer(s,o,i){if("number"==typeof s){if("string"==typeof o)throw new TypeError('The "string" argument must be of type string. Received type number');return allocUnsafe(s)}return from(s,o,i)}function from(s,o,i){if("string"==typeof s)return function fromString(s,o){"string"==typeof o&&""!==o||(o="utf8");if(!Buffer.isEncoding(o))throw new TypeError("Unknown encoding: "+o);const i=0|byteLength(s,o);let a=createBuffer(i);const u=a.write(s,o);u!==i&&(a=a.slice(0,u));return a}(s,o);if(ArrayBuffer.isView(s))return function fromArrayView(s){if(isInstance(s,Uint8Array)){const o=new Uint8Array(s);return fromArrayBuffer(o.buffer,o.byteOffset,o.byteLength)}return fromArrayLike(s)}(s);if(null==s)throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof s);if(isInstance(s,ArrayBuffer)||s&&isInstance(s.buffer,ArrayBuffer))return fromArrayBuffer(s,o,i);if("undefined"!=typeof SharedArrayBuffer&&(isInstance(s,SharedArrayBuffer)||s&&isInstance(s.buffer,SharedArrayBuffer)))return fromArrayBuffer(s,o,i);if("number"==typeof s)throw new TypeError('The "value" argument must not be of type number. Received type number');const a=s.valueOf&&s.valueOf();if(null!=a&&a!==s)return Buffer.from(a,o,i);const u=function fromObject(s){if(Buffer.isBuffer(s)){const o=0|checked(s.length),i=createBuffer(o);return 0===i.length||s.copy(i,0,0,o),i}if(void 0!==s.length)return"number"!=typeof s.length||numberIsNaN(s.length)?createBuffer(0):fromArrayLike(s);if("Buffer"===s.type&&Array.isArray(s.data))return fromArrayLike(s.data)}(s);if(u)return u;if("undefined"!=typeof Symbol&&null!=Symbol.toPrimitive&&"function"==typeof s[Symbol.toPrimitive])return Buffer.from(s[Symbol.toPrimitive]("string"),o,i);throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof s)}function assertSize(s){if("number"!=typeof s)throw new TypeError('"size" argument must be of type number');if(s<0)throw new RangeError('The value "'+s+'" is invalid for option "size"')}function allocUnsafe(s){return assertSize(s),createBuffer(s<0?0:0|checked(s))}function fromArrayLike(s){const o=s.length<0?0:0|checked(s.length),i=createBuffer(o);for(let a=0;a<o;a+=1)i[a]=255&s[a];return i}function fromArrayBuffer(s,o,i){if(o<0||s.byteLength<o)throw new RangeError('"offset" is outside of buffer bounds');if(s.byteLength<o+(i||0))throw new RangeError('"length" is outside of buffer bounds');let a;return a=void 0===o&&void 0===i?new Uint8Array(s):void 0===i?new Uint8Array(s,o):new Uint8Array(s,o,i),Object.setPrototypeOf(a,Buffer.prototype),a}function checked(s){if(s>=w)throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+w.toString(16)+" Line truncated
@@ -0,0 +1,2 @@
/*! For license information please see swagger-ui-standalone-preset.js.LICENSE.txt */
!function webpackUniversalModuleDefinition(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SwaggerUIStandalonePreset=e():t.SwaggerUIStandalonePreset=e()}(this,(()=>(()=>{var t={7526(t,e){"use strict";e.byteLength=function byteLength(t){var e=getLens(t),r=e[0],n=e[1];return 3*(r+n)/4-n},e.toByteArray=function toByteArray(t){var e,r,i=getLens(t),a=i[0],s=i[1],u=new o(function _byteLength(t,e,r){return 3*(e+r)/4-r}(0,a,s)),c=0,l=s>0?a-4:a;for(r=0;r<l;r+=4)e=n[t.charCodeAt(r)]<<18|n[t.charCodeAt(r+1)]<<12|n[t.charCodeAt(r+2)]<<6|n[t.charCodeAt(r+3)],u[c++]=e>>16&255,u[c++]=e>>8&255,u[c++]=255&e;2===s&&(e=n[t.charCodeAt(r)]<<2|n[t.charCodeAt(r+1)]>>4,u[c++]=255&e);1===s&&(e=n[t.charCodeAt(r)]<<10|n[t.charCodeAt(r+1)]<<4|n[t.charCodeAt(r+2)]>>2,u[c++]=e>>8&255,u[c++]=255&e);return u},e.fromByteArray=function fromByteArray(t){for(var e,n=t.length,o=n%3,i=[],a=16383,s=0,u=n-o;s<u;s+=a)i.push(encodeChunk(t,s,s+a>u?u:s+a));1===o?(e=t[n-1],i.push(r[e>>2]+r[e<<4&63]+"==")):2===o&&(e=(t[n-2]<<8)+t[n-1],i.push(r[e>>10]+r[e>>4&63]+r[e<<2&63]+"="));return i.join("")};for(var r=[],n=[],o="undefined"!=typeof Uint8Array?Uint8Array:Array,i="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",a=0;a<64;++a)r[a]=i[a],n[i.charCodeAt(a)]=a;function getLens(t){var e=t.length;if(e%4>0)throw new Error("Invalid string. Length must be a multiple of 4");var r=t.indexOf("=");return-1===r&&(r=e),[r,r===e?0:4-r%4]}function encodeChunk(t,e,n){for(var o,i,a=[],s=e;s<n;s+=3)o=(t[s]<<16&16711680)+(t[s+1]<<8&65280)+(255&t[s+2]),a.push(r[(i=o)>>18&63]+r[i>>12&63]+r[i>>6&63]+r[63&i]);return a.join("")}n["-".charCodeAt(0)]=62,n["_".charCodeAt(0)]=63},8287(t,e,r){"use strict";const n=r(7526),o=r(251),i="function"==typeof Symbol&&"function"==typeof Symbol.for?Symbol.for("nodejs.util.inspect.custom"):null;e.Buffer=Buffer,e.SlowBuffer=function SlowBuffer(t){+t!=t&&(t=0);return Buffer.alloc(+t)},e.INSPECT_MAX_BYTES=50;const a=2147483647;function createBuffer(t){if(t>a)throw new RangeError('The value "'+t+'" is invalid for option "size"');const e=new Uint8Array(t);return Object.setPrototypeOf(e,Buffer.prototype),e}function Buffer(t,e,r){if("number"==typeof t){if("string"==typeof e)throw new TypeError('The "string" argument must be of type string. Received type number');return allocUnsafe(t)}return from(t,e,r)}function from(t,e,r){if("string"==typeof t)return function fromString(t,e){"string"==typeof e&&""!==e||(e="utf8");if(!Buffer.isEncoding(e))throw new TypeError("Unknown encoding: "+e);const r=0|byteLength(t,e);let n=createBuffer(r);const o=n.write(t,e);o!==r&&(n=n.slice(0,o));return n}(t,e);if(ArrayBuffer.isView(t))return function fromArrayView(t){if(isInstance(t,Uint8Array)){const e=new Uint8Array(t);return fromArrayBuffer(e.buffer,e.byteOffset,e.byteLength)}return fromArrayLike(t)}(t);if(null==t)throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof t);if(isInstance(t,ArrayBuffer)||t&&isInstance(t.buffer,ArrayBuffer))return fromArrayBuffer(t,e,r);if("undefined"!=typeof SharedArrayBuffer&&(isInstance(t,SharedArrayBuffer)||t&&isInstance(t.buffer,SharedArrayBuffer)))return fromArrayBuffer(t,e,r);if("number"==typeof t)throw new TypeError('The "value" argument must not be of type number. Received type number');const n=t.valueOf&&t.valueOf();if(null!=n&&n!==t)return Buffer.from(n,e,r);const o=function fromObject(t){if(Buffer.isBuffer(t)){const e=0|checked(t.length),r=createBuffer(e);return 0===r.length||t.copy(r,0,0,e),r}if(void 0!==t.length)return"number"!=typeof t.length||numberIsNaN(t.length)?createBuffer(0):fromArrayLike(t);if("Buffer"===t.type&&Array.isArray(t.data))return fromArrayLike(t.data)}(t);if(o)return o;if("undefined"!=typeof Symbol&&null!=Symbol.toPrimitive&&"function"==typeof t[Symbol.toPrimitive])return Buffer.from(t[Symbol.toPrimitive]("string"),e,r);throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof t)}function assertSize(t){if("number"!=typeof t)throw new TypeError('"size" argument must be of type number');if(t<0)throw new RangeError('The value "'+t+'" is invalid for option "size"')}function allocUnsafe(t){return assertSize(t),createBuffer(t<0?0:0|checked(t))}function fromArrayLike(t){const e=t.length<0?0:0|checked(t.length),r=createBuffer(e);for(let n=0;n<e;n+=1)r[n]=255&t[n];return r}function fromArrayBuffer(t,e,r){if(e<0||t.byteLength<e)throw new RangeError('"offset" is outside of buffer bounds');if(t.byteLength<e+(r||0))throw new RangeError('"length" is outside of buffer bounds');let n;return n=void 0===e&&void 0===r?new Uint8Array(t):void 0===r?new Uint8Array(t,e):new Uint8Array(t,e,r),Object.setPrototypeOf(n,Buffer.prototype),n}function checked(t){if(t>=a)throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+Line truncated
+3
View File
@@ -0,0 +1,3 @@
.swagger-ui{color:#3b4151;container-name:swagger-ui;container-type:inline-size;/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */font-family:sans-serif}.swagger-ui html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}.swagger-ui body{margin:0}.swagger-ui article,.swagger-ui aside,.swagger-ui footer,.swagger-ui header,.swagger-ui nav,.swagger-ui section{display:block}.swagger-ui h1{font-size:2em;margin:.67em 0}.swagger-ui figcaption,.swagger-ui figure,.swagger-ui main{display:block}.swagger-ui figure{margin:1em 40px}.swagger-ui hr{box-sizing:content-box;height:0;overflow:visible}.swagger-ui pre{font-family:monospace,monospace;font-size:1em}.swagger-ui a{background-color:transparent;-webkit-text-decoration-skip:objects}.swagger-ui abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.swagger-ui b,.swagger-ui strong{font-weight:inherit;font-weight:bolder}.swagger-ui code,.swagger-ui kbd,.swagger-ui samp{font-family:monospace,monospace;font-size:1em}.swagger-ui dfn{font-style:italic}.swagger-ui mark{background-color:#ff0;color:#000}.swagger-ui small{font-size:80%}.swagger-ui sub,.swagger-ui sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}.swagger-ui sub{bottom:-.25em}.swagger-ui sup{top:-.5em}.swagger-ui audio,.swagger-ui video{display:inline-block}.swagger-ui audio:not([controls]){display:none;height:0}.swagger-ui img{border-style:none}.swagger-ui svg:not(:root){overflow:hidden}.swagger-ui button,.swagger-ui input,.swagger-ui optgroup,.swagger-ui select,.swagger-ui textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}.swagger-ui button,.swagger-ui input{overflow:visible}.swagger-ui button,.swagger-ui select{text-transform:none}.swagger-ui [type=reset],.swagger-ui [type=submit],.swagger-ui button,.swagger-ui html [type=button]{-webkit-appearance:button}.swagger-ui [type=button]::-moz-focus-inner,.swagger-ui [type=reset]::-moz-focus-inner,.swagger-ui [type=submit]::-moz-focus-inner,.swagger-ui button::-moz-focus-inner{border-style:none;padding:0}.swagger-ui [type=button]:-moz-focusring,.swagger-ui [type=reset]:-moz-focusring,.swagger-ui [type=submit]:-moz-focusring,.swagger-ui button:-moz-focusring{outline:1px dotted ButtonText}.swagger-ui fieldset{padding:.35em .75em .625em}.swagger-ui legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}.swagger-ui progress{display:inline-block;vertical-align:baseline}.swagger-ui textarea{overflow:auto}.swagger-ui [type=checkbox],.swagger-ui [type=radio]{box-sizing:border-box;padding:0}.swagger-ui [type=number]::-webkit-inner-spin-button,.swagger-ui [type=number]::-webkit-outer-spin-button{height:auto}.swagger-ui [type=search]{-webkit-appearance:textfield;outline-offset:-2px}.swagger-ui [type=search]::-webkit-search-cancel-button,.swagger-ui [type=search]::-webkit-search-decoration{-webkit-appearance:none}.swagger-ui ::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}.swagger-ui details,.swagger-ui menu{display:block}.swagger-ui summary{display:list-item}.swagger-ui canvas{display:inline-block}.swagger-ui [hidden],.swagger-ui template{display:none}.swagger-ui .debug *{outline:1px solid gold}.swagger-ui .debug-white *{outline:1px solid #fff}.swagger-ui .debug-black *{outline:1px solid #000}.swagger-ui .debug-grid{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTRDOTY4N0U2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTRDOTY4N0Q2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3NjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3NzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsBS+GMAAAAjSURBVHjaYvz//z8DLsD4gcGXiYEAGBIKGBne//fFpwAgwAB98AaF2pjlUQAAAABJRU5ErkJggg==) repeat 0 0}.swagger-ui .debug-grid-16{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpLine truncated
/*# sourceMappingURL=swagger-ui.css.map*/
+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
}
}
+24
View File
@@ -0,0 +1,24 @@
/*
* 自动生成文件,请勿手动编辑。
* 由 tools/gen-swagger-ui-resources.ps1 从 resources/openapi/ 生成。
* Swagger UI 静态资源以 Base64 内联,运行时由 OpenApiUIMiddleware 解码输出。
*/
package simcu::simapi.openapi
/**
* Swagger UI 内置静态资源(Base64 编码)。
*/
public class SwaggerUIResources {
public static let allHtml: String = "PCFET0NUWVBFIGh0bWw+DQo8aHRtbCBsYW5nPSJ6aC1DTiI+DQoNCjxoZWFkPg0KICAgIDxtZXRhIGNoYXJzZXQ9IlVURi04Ij4NCiAgICA8dGl0bGU+QVBJIOaWh+aho++8iOWFqOmDqO+8iTwvdGl0bGU+DQogICAgPGxpbmsgcmVsPSJzdHlsZXNoZWV0IiBocmVmPSJzd2FnZ2VyLXVpLmNzcyI+DQogICAgPHN0eWxlPg0KICAgICAgICBodG1sIHsNCiAgICAgICAgICAgIGJveC1zaXppbmc6IGJvcmRlci1ib3g7DQogICAgICAgICAgICBvdmVyZmxvdzogLW1vei1zY3JvbGxiYXJzLXZlcnRpY2FsOw0KICAgICAgICAgICAgb3ZlcmZsb3cteTogc2Nyb2xsOw0KICAgICAgICB9DQoNCiAgICAgICAgYm9keSB7DQogICAgICAgICAgICBtYXJnaW46IDA7DQogICAgICAgICAgICBiYWNrZ3JvdW5kOiAjZmFmYWZhOw0KICAgICAgICB9DQogICAgPC9zdHlsZT4NCjwvaGVhZD4NCg0KPGJvZHk+DQogICAgPGRpdiBpZD0ic3dhZ2dlci11aSI+PC9kaXY+DQogICAgPHNjcmlwdCBzcmM9InN3YWdnZXItdWktYnVuZGxlLmpzIj48L3NjcmlwdD4NCiAgICA8c2NyaXB0IHNyYz0ic3dhZ2dlci11aS1zdGFuZGFsb25lLXByZXNldC5qcyI+PC9zY3JpcHQ+DQogICAgPHNjcmlwdD4NCiAgICAgICAgd2luZG93Lm9ubG9hZCA9IGFzeW5jIGZ1bmN0aW9uICgpIHsNCiAgICAgICAgICAgIGxldCB1cmxzID0gW107DQogICAgICAgICAgICB0cnkgew0KICAgICAgICAgICAgICAgIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKCd1cmxzJyk7DQogICAgICAgICAgICAgICAgaWYgKHJlcy5vaykgew0KICAgICAgICAgICAgICAgICAgICBjb25zdCBsaXN0ID0gYXdhaXQgcmVzLmpzb24oKTsNCiAgICAgICAgICAgICAgICAgICAgaWYgKEFycmF5LmlzQXJyYXkobGlzdCkgJiYgbGlzdC5sZW5ndGggPiAwKSB7DQogICAgICAgICAgICAgICAgICAgICAgICB1cmxzID0gbGlzdDsNCiAgICAgICAgICAgICAgICAgICAgfQ0KICAgICAgICAgICAgICAgIH0NCiAgICAgICAgICAgIH0gY2F0Y2ggKGUpIHsgfQ0KICAgICAgICAgICAgaWYgKHVybHMubGVuZ3RoID09PSAwKSB7DQogICAgICAgICAgICAgICAgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3N3YWdnZXItdWknKS5pbm5lckhUTUwgPQ0KICAgICAgICAgICAgICAgICAgICAnPGRpdiBzdHlsZT0icGFkZGluZzoyMHB4O2NvbG9yOiM2NjY7Ij7ml6Dms5XliqDovb3mlofmoaPliJfooajvvIzor7fnoa7orqTmnI3liqHmraPluLjov5DooYzjgII8L2Rpdj4nOw0KICAgICAgICAgICAgICAgIHJldHVybjsNCiAgICAgICAgICAgIH0NCiAgICAgICAgICAgIHdpbmRvdy51aSA9IFN3YWdnZXJVSUJ1bmRsZSh7DQogICAgICAgICAgICAgICAgdXJsczogdXJscywNCiAgICAgICAgICAgICAgICAidXJscy5wcmltYXJ5TmFtZSI6IHVybHNbMF0ubmFtZSwNCiAgICAgICAgICAgICAgICBkb21faWQ6ICIjc3dhZ2dlci11aSIsDQogICAgICAgICAgICAgICAgZGVlcExpbmtpbmc6IHRydWUsDQogICAgICAgICAgICAgICAgcHJlc2V0czogWw0KICAgICAgICAgICAgICAgICAgICBTd2FnZ2VyVUlCdW5kbGUucHJlc2V0cy5hcGlzLA0KICAgICAgICAgICAgICAgICAgICBTd2FnZ2VyVUlTdGFuZGFsb25lUHJlc2V0DQogICAgICAgICAgICAgICAgXSwNCiAgICAgICAgICAgICAgICBsYXlvdXQ6ICJTdGFuZGFsb25lTGF5b3V0Ig0KICAgICAgICAgICAgfSk7DQogICAgICAgIH07DQogICAgPC9zY3JpcHQ+DQo8L2JvZHk+DQoNCjwvaHRtbD4="
public static let singleHtml: String = "PCFET0NUWVBFIGh0bWw+DQo8aHRtbCBsYW5nPSJ6aC1DTiI+DQoNCjxoZWFkPg0KICAgIDxtZXRhIGNoYXJzZXQ9IlVURi04Ij4NCiAgICA8dGl0bGU+QVBJIOaWh+ahozwvdGl0bGU+DQogICAgPGxpbmsgcmVsPSJzdHlsZXNoZWV0IiBocmVmPSJzd2FnZ2VyLXVpLmNzcyI+DQogICAgPHN0eWxlPg0KICAgICAgICBodG1sIHsNCiAgICAgICAgICAgIGJveC1zaXppbmc6IGJvcmRlci1ib3g7DQogICAgICAgICAgICBvdmVyZmxvdzogLW1vei1zY3JvbGxiYXJzLXZlcnRpY2FsOw0KICAgICAgICAgICAgb3ZlcmZsb3cteTogc2Nyb2xsOw0KICAgICAgICB9DQoNCiAgICAgICAgYm9keSB7DQogICAgICAgICAgICBtYXJnaW46IDA7DQogICAgICAgICAgICBiYWNrZ3JvdW5kOiAjZmFmYWZhOw0KICAgICAgICB9DQogICAgPC9zdHlsZT4NCjwvaGVhZD4NCg0KPGJvZHk+DQogICAgPGRpdiBpZD0ic3dhZ2dlci11aSI+PC9kaXY+DQogICAgPHNjcmlwdCBzcmM9InN3YWdnZXItdWktYnVuZGxlLmpzIj48L3NjcmlwdD4NCiAgICA8c2NyaXB0Pg0KICAgICAgICB3aW5kb3cub25sb2FkID0gZnVuY3Rpb24gKCkgew0KICAgICAgICAgICAgdmFyIHBhdGggPSB3aW5kb3cubG9jYXRpb24ucGF0aG5hbWU7DQogICAgICAgICAgICB2YXIgbWF0Y2ggPSBwYXRoLm1hdGNoKC9cLyhbXlwvXSspXC5odG1sJC8pOw0KICAgICAgICAgICAgdmFyIGlkID0gbWF0Y2ggPyBtYXRjaFsxXSA6ICdhcGknOw0KICAgICAgICAgICAgU3dhZ2dlclVJQnVuZGxlKHsNCiAgICAgICAgICAgICAgICB1cmw6IGlkICsgJy5qc29uJywNCiAgICAgICAgICAgICAgICBkb21faWQ6ICcjc3dhZ2dlci11aScsDQogICAgICAgICAgICAgICAgZGVlcExpbmtpbmc6IHRydWUsDQogICAgICAgICAgICAgICAgcHJlc2V0czogW1N3YWdnZXJVSUJ1bmRsZS5wcmVzZXRzLmFwaXNdDQogICAgICAgICAgICB9KTsNCiAgICAgICAgfTsNCiAgICA8L3NjcmlwdD4NCjwvYm9keT4NCg0KPC9odG1sPg=="
public static let swaggerUiBundleJs: String = "LyohIEZvciBsaWNlbnNlIGluZm9ybWF0aW9uIHBsZWFzZSBzZWUgc3dhZ2dlci11aS1idW5kbGUuanMuTElDRU5TRS50eHQgKi8KIWZ1bmN0aW9uIHdlYnBhY2tVbml2ZXJzYWxNb2R1bGVEZWZpbml0aW9uKHMsbyl7Im9iamVjdCI9PXR5cGVvZiBleHBvcnRzJiYib2JqZWN0Ij09dHlwZW9mIG1vZHVsZT9tb2R1bGUuZXhwb3J0cz1vKCk6ImZ1bmN0aW9uIj09dHlwZW9mIGRlZmluZSYmZGVmaW5lLmFtZD9kZWZpbmUoW10sbyk6Im9iamVjdCI9PXR5cGVvZiBleHBvcnRzP2V4cG9ydHMuU3dhZ2dlclVJQnVuZGxlPW8oKTpzLlN3YWdnZXJVSUJ1bmRsZT1vKCl9KHRoaXMsKCgpPT4oKCk9Pnt2YXIgcz17Njc1MjYocyxvKXsidXNlIHN0cmljdCI7by5ieXRlTGVuZ3RoPWZ1bmN0aW9uIGJ5dGVMZW5ndGgocyl7dmFyIG89Z2V0TGVucyhzKSxpPW9bMF0sYT1vWzFdO3JldHVybiAzKihpK2EpLzQtYX0sby50b0J5dGVBcnJheT1mdW5jdGlvbiB0b0J5dGVBcnJheShzKXt2YXIgbyxpLF89Z2V0TGVucyhzKSx3PV9bMF0seD1fWzFdLEM9bmV3IHUoZnVuY3Rpb24gX2J5dGVMZW5ndGgocyxvLGkpe3JldHVybiAzKihvK2kpLzQtaX0oMCx3LHgpKSxqPTAsTD14PjA/dy00Onc7Zm9yKGk9MDtpPEw7aSs9NClvPWFbcy5jaGFyQ29kZUF0KGkpXTw8MTh8YVtzLmNoYXJDb2RlQXQoaSsxKV08PDEyfGFbcy5jaGFyQ29kZUF0KGkrMildPDw2fGFbcy5jaGFyQ29kZUF0KGkrMyldLENbaisrXT1vPj4xNiYyNTUsQ1tqKytdPW8+PjgmMjU1LENbaisrXT0yNTUmbzsyPT09eCYmKG89YVtzLmNoYXJDb2RlQXQoaSldPDwyfGFbcy5jaGFyQ29kZUF0KGkrMSldPj40LENbaisrXT0yNTUmbyk7MT09PXgmJihvPWFbcy5jaGFyQ29kZUF0KGkpXTw8MTB8YVtzLmNoYXJDb2RlQXQoaSsxKV08PDR8YVtzLmNoYXJDb2RlQXQoaSsyKV0+PjIsQ1tqKytdPW8+PjgmMjU1LENbaisrXT0yNTUmbyk7cmV0dXJuIEN9LG8uZnJvbUJ5dGVBcnJheT1mdW5jdGlvbiBmcm9tQnl0ZUFycmF5KHMpe2Zvcih2YXIgbyxhPXMubGVuZ3RoLHU9YSUzLF89W10sdz0xNjM4Myx4PTAsQz1hLXU7eDxDO3grPXcpXy5wdXNoKGVuY29kZUNodW5rKHMseCx4K3c+Qz9DOngrdykpOzE9PT11PyhvPXNbYS0xXSxfLnB1c2goaVtvPj4yXStpW288PDQmNjNdKyI9PSIpKToyPT09dSYmKG89KHNbYS0yXTw8OCkrc1thLTFdLF8ucHVzaChpW28+PjEwXStpW28+PjQmNjNdK2lbbzw8MiY2M10rIj0iKSk7cmV0dXJuIF8uam9pbigiIil9O2Zvcih2YXIgaT1bXSxhPVtdLHU9InVuZGVmaW5lZCIhPXR5cGVvZiBVaW50OEFycmF5P1VpbnQ4QXJyYXk6QXJyYXksXz0iQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLyIsdz0wO3c8NjQ7Kyt3KWlbd109X1t3XSxhW18uY2hhckNvZGVBdCh3KV09dztmdW5jdGlvbiBnZXRMZW5zKHMpe3ZhciBvPXMubGVuZ3RoO2lmKG8lND4wKXRocm93IG5ldyBFcnJvcigiSW52YWxpZCBzdHJpbmcuIExlbmd0aCBtdXN0IGJlIGEgbXVsdGlwbGUgb2YgNCIpO3ZhciBpPXMuaW5kZXhPZigiPSIpO3JldHVybi0xPT09aSYmKGk9byksW2ksaT09PW8/MDo0LWklNF19ZnVuY3Rpb24gZW5jb2RlQ2h1bmsocyxvLGEpe2Zvcih2YXIgdSxfLHc9W10seD1vO3g8YTt4Kz0zKXU9KHNbeF08PDE2JjE2NzExNjgwKSsoc1t4KzFdPDw4JjY1MjgwKSsoMjU1JnNbeCsyXSksdy5wdXNoKGlbKF89dSk+PjE4JjYzXStpW18+PjEyJjYzXStpW18+PjYmNjNdK2lbNjMmX10pO3JldHVybiB3LmpvaW4oIiIpfWFbIi0iLmNoYXJDb2RlQXQoMCldPTYyLGFbIl8iLmNoYXJDb2RlQXQoMCldPTYzfSw0ODI4NyhzLG8saSl7InVzZSBzdHJpY3QiO2NvbnN0IGE9aSg2NzUyNiksdT1pKDI1MSksXz0iZnVuY3Rpb24iPT10eXBlb2YgU3ltYm9sJiYiZnVuY3Rpb24iPT10eXBlb2YgU3ltYm9sLmZvcj9TeW1ib2wuZm9yKCJub2RlanMudXRpbC5pbnNwZWN0LmN1c3RvbSIpOm51bGw7by5CdWZmZXI9QnVmZmVyLG8uU2xvd0J1ZmZlcj1mdW5jdGlvbiBTbG93QnVmZmVyKHMpeytzIT1zJiYocz0wKTtyZXR1cm4gQnVmZmVyLmFsbG9jKCtzKX0sby5JTlNQRUNUX01BWF9CWVRFUz01MDtjb25zdCB3PTIxNDc0ODM2NDc7ZnVuY3Rpb24gY3JlYXRlQnVmZmVyKHMpe2lmKHM+dyl0aHJvdyBuZXcgUmFuZ2VFcnJvcignVGhlIHZhbHVlICInK3MrJyIgaXMgaW52YWxpZCBmb3Igb3B0aW9uICJzaXplIicpO2NvbnN0IG89bmV3IFVpbnQ4QXJyYXkocyk7cmV0dXJuIE9iamVjdC5zZXRQcm90b3R5cGVPZihvLEJ1ZmZlci5wcm90b3R5cGUpLG99ZnVuY3Rpb24gQnVmZmVyKHMsbyxpKXtpZigibnVtYmVyIj09dHlwZW9mIHMpe2lmKCJzdHJpbmciPT10eXBlb2Ygbyl0aHJvdyBuZXcgVHlwZUVycm9yKCdUaGUgInN0cmluZyIgYXJndW1lbnQgbXVzdCBiZSBvZiB0eXBlIHN0cmluZy4gUmVjZWl2ZWQgdHlwZSBudW1iZXInKTtyZXR1cm4gYWxsb2NVbnNhZmUocyl9cmV0dXJuIGZyb20ocyxvLGkpfWZ1bmN0aW9uIGZyb20ocyxvLGkpe2lmKCJzdHJpbmciPT10eXBlb2YgcylyZXR1cm4gZnVuY3Rpb24gZnJvbVN0cmluZyhzLG8peyJzdHJpbmciPT10eXBlb2YgbyYmIiIhPT1vfHwobz0idXRmOCIpO2lmKCFCdWZmZXIuaXNFbmNvZGluZyhvKSl0aHJvdyBuZXcgVHlwZUVycm9yKCJVbmtub3duIGVuY29kaW5nOiAiK28pO2NvbnN0IGk9MHxieXRlTGVuZ3RoKHMsbyk7bGV0IGE9Y3JlYXRlQnVmZmVyKGkpO2NvbnN0IHU9YS53cml0ZShzLG8pO3UhPT1pJiYoYT1hLnNsaWNlKDAsdSkpO3JldHVybiBhfShzLG8pO2lmKEFycmF5QnVmZmVyLmlzVmlldyhzKSlyZXR1cm4gZnVuY3Rpb24gZnJvbUFycmF5VmlldyhzKXtpZihpc0luc3RhbmNlKHMsVWludDhBcnJheSkpe2NvbnN0IG89bmV3IFVpbnQ4QXJyYXkocyk7cmV0dXJuIGZyb21BcnJheUJ1ZmZlcihvLmJ1ZmZlcixvLmJ5dGVPZmZzZXQsby5ieXRlTGVuZ3RoKX1yZXR1cm4gZnJvbUFycmF5TGlrZShzKX0ocyk7aWYobnVsbD09cyl0aHJvdyBuZXcgVHlwZUVycm9yKCJUaGUgZmlyc3QgYXJndW1lbnQgbXVzdCBiZSBvbmUgb2YgdHlwZSBzdHJpbmcsIEJ1ZmZlciwgQXJyYXlCdWZmZXIsIEFycmF5LCBvciBBcnJheS1saWtlIE9iamVjdC4gUmVjZWl2ZWQgdHlwZSAiK3R5cGVvZiBzKTtpZihpc0luc3RhbmNlKHMsQXJyYXlCdWZmZXIpfHxzJiZpc0luc3RhbmNlKHMuYnVmZmVyLEFycmF5QnVmZmVyKSlyZXR1cm4gZnJvbUFycmF5QnVmZmVyKHMsbyxpKTtpZigidW5kZWZpbmVkIiE9dHlwZW9mIFNoYXJlZEFycmF5QnVmZmVyJiYoaXNJbnN0YW5jZShzLFNoYXJlZEFycmF5QnVmZmVyKXx8cyYmaXNJbnN0YW5jZShzLmJ1ZmZlcixTaGFyZWRBcnJheUJ1ZmZlcikpKXJldHVybiBmcm9tQXJyYXlCdWZmZXIocyxvLGkpO2lmKCJudW1iZXIiPT10eXBlb2Ygcyl0aHJvdyBuZXcgVHlwZUVycm9yKCdUaGUgInZhbHVlIiBhcmd1bWVudCBtdXN0IG5vdCBiZSBvZiB0eXBlIG51bWJlci4gUmVjZWl2ZWQgdHlwZSBudW1iZXInKTtjb25zdCBhPXMudmFsdWVPZiYmcy52YWx1ZU9mKCk7aWYobnVsbCE9YSYmYSE9PXMpcmV0dXJuIEJ1ZmZlci5mcm9tKGEsbyxpKTtjb25zdCB1PWZ1bmN0aW9uIGZyb21PYmplY3Qocyl7aWYoQnVmZmVyLmlzQnVmZmVyKHMpKXtjb25zdCBvPTB8Y2hlY2tlZChzLmxlbmd0aCksaT1jcmVhdGVCdWZmZXIobyk7cmV0dXJuIDA9PT1pLmxlbmd0aHx8cy5jb3B5KGksMCwwLG8pLGl9aWYodm9pLine truncated
public static let swaggerUiStandalonePresetJs: String = "LyohIEZvciBsaWNlbnNlIGluZm9ybWF0aW9uIHBsZWFzZSBzZWUgc3dhZ2dlci11aS1zdGFuZGFsb25lLXByZXNldC5qcy5MSUNFTlNFLnR4dCAqLwohZnVuY3Rpb24gd2VicGFja1VuaXZlcnNhbE1vZHVsZURlZmluaXRpb24odCxlKXsib2JqZWN0Ij09dHlwZW9mIGV4cG9ydHMmJiJvYmplY3QiPT10eXBlb2YgbW9kdWxlP21vZHVsZS5leHBvcnRzPWUoKToiZnVuY3Rpb24iPT10eXBlb2YgZGVmaW5lJiZkZWZpbmUuYW1kP2RlZmluZShbXSxlKToib2JqZWN0Ij09dHlwZW9mIGV4cG9ydHM/ZXhwb3J0cy5Td2FnZ2VyVUlTdGFuZGFsb25lUHJlc2V0PWUoKTp0LlN3YWdnZXJVSVN0YW5kYWxvbmVQcmVzZXQ9ZSgpfSh0aGlzLCgoKT0+KCgpPT57dmFyIHQ9ezc1MjYodCxlKXsidXNlIHN0cmljdCI7ZS5ieXRlTGVuZ3RoPWZ1bmN0aW9uIGJ5dGVMZW5ndGgodCl7dmFyIGU9Z2V0TGVucyh0KSxyPWVbMF0sbj1lWzFdO3JldHVybiAzKihyK24pLzQtbn0sZS50b0J5dGVBcnJheT1mdW5jdGlvbiB0b0J5dGVBcnJheSh0KXt2YXIgZSxyLGk9Z2V0TGVucyh0KSxhPWlbMF0scz1pWzFdLHU9bmV3IG8oZnVuY3Rpb24gX2J5dGVMZW5ndGgodCxlLHIpe3JldHVybiAzKihlK3IpLzQtcn0oMCxhLHMpKSxjPTAsbD1zPjA/YS00OmE7Zm9yKHI9MDtyPGw7cis9NCllPW5bdC5jaGFyQ29kZUF0KHIpXTw8MTh8blt0LmNoYXJDb2RlQXQocisxKV08PDEyfG5bdC5jaGFyQ29kZUF0KHIrMildPDw2fG5bdC5jaGFyQ29kZUF0KHIrMyldLHVbYysrXT1lPj4xNiYyNTUsdVtjKytdPWU+PjgmMjU1LHVbYysrXT0yNTUmZTsyPT09cyYmKGU9blt0LmNoYXJDb2RlQXQocildPDwyfG5bdC5jaGFyQ29kZUF0KHIrMSldPj40LHVbYysrXT0yNTUmZSk7MT09PXMmJihlPW5bdC5jaGFyQ29kZUF0KHIpXTw8MTB8blt0LmNoYXJDb2RlQXQocisxKV08PDR8blt0LmNoYXJDb2RlQXQocisyKV0+PjIsdVtjKytdPWU+PjgmMjU1LHVbYysrXT0yNTUmZSk7cmV0dXJuIHV9LGUuZnJvbUJ5dGVBcnJheT1mdW5jdGlvbiBmcm9tQnl0ZUFycmF5KHQpe2Zvcih2YXIgZSxuPXQubGVuZ3RoLG89biUzLGk9W10sYT0xNjM4MyxzPTAsdT1uLW87czx1O3MrPWEpaS5wdXNoKGVuY29kZUNodW5rKHQscyxzK2E+dT91OnMrYSkpOzE9PT1vPyhlPXRbbi0xXSxpLnB1c2gocltlPj4yXStyW2U8PDQmNjNdKyI9PSIpKToyPT09byYmKGU9KHRbbi0yXTw8OCkrdFtuLTFdLGkucHVzaChyW2U+PjEwXStyW2U+PjQmNjNdK3JbZTw8MiY2M10rIj0iKSk7cmV0dXJuIGkuam9pbigiIil9O2Zvcih2YXIgcj1bXSxuPVtdLG89InVuZGVmaW5lZCIhPXR5cGVvZiBVaW50OEFycmF5P1VpbnQ4QXJyYXk6QXJyYXksaT0iQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLyIsYT0wO2E8NjQ7KythKXJbYV09aVthXSxuW2kuY2hhckNvZGVBdChhKV09YTtmdW5jdGlvbiBnZXRMZW5zKHQpe3ZhciBlPXQubGVuZ3RoO2lmKGUlND4wKXRocm93IG5ldyBFcnJvcigiSW52YWxpZCBzdHJpbmcuIExlbmd0aCBtdXN0IGJlIGEgbXVsdGlwbGUgb2YgNCIpO3ZhciByPXQuaW5kZXhPZigiPSIpO3JldHVybi0xPT09ciYmKHI9ZSksW3Iscj09PWU/MDo0LXIlNF19ZnVuY3Rpb24gZW5jb2RlQ2h1bmsodCxlLG4pe2Zvcih2YXIgbyxpLGE9W10scz1lO3M8bjtzKz0zKW89KHRbc108PDE2JjE2NzExNjgwKSsodFtzKzFdPDw4JjY1MjgwKSsoMjU1JnRbcysyXSksYS5wdXNoKHJbKGk9byk+PjE4JjYzXStyW2k+PjEyJjYzXStyW2k+PjYmNjNdK3JbNjMmaV0pO3JldHVybiBhLmpvaW4oIiIpfW5bIi0iLmNoYXJDb2RlQXQoMCldPTYyLG5bIl8iLmNoYXJDb2RlQXQoMCldPTYzfSw4Mjg3KHQsZSxyKXsidXNlIHN0cmljdCI7Y29uc3Qgbj1yKDc1MjYpLG89cigyNTEpLGk9ImZ1bmN0aW9uIj09dHlwZW9mIFN5bWJvbCYmImZ1bmN0aW9uIj09dHlwZW9mIFN5bWJvbC5mb3I/U3ltYm9sLmZvcigibm9kZWpzLnV0aWwuaW5zcGVjdC5jdXN0b20iKTpudWxsO2UuQnVmZmVyPUJ1ZmZlcixlLlNsb3dCdWZmZXI9ZnVuY3Rpb24gU2xvd0J1ZmZlcih0KXsrdCE9dCYmKHQ9MCk7cmV0dXJuIEJ1ZmZlci5hbGxvYygrdCl9LGUuSU5TUEVDVF9NQVhfQllURVM9NTA7Y29uc3QgYT0yMTQ3NDgzNjQ3O2Z1bmN0aW9uIGNyZWF0ZUJ1ZmZlcih0KXtpZih0PmEpdGhyb3cgbmV3IFJhbmdlRXJyb3IoJ1RoZSB2YWx1ZSAiJyt0KyciIGlzIGludmFsaWQgZm9yIG9wdGlvbiAic2l6ZSInKTtjb25zdCBlPW5ldyBVaW50OEFycmF5KHQpO3JldHVybiBPYmplY3Quc2V0UHJvdG90eXBlT2YoZSxCdWZmZXIucHJvdG90eXBlKSxlfWZ1bmN0aW9uIEJ1ZmZlcih0LGUscil7aWYoIm51bWJlciI9PXR5cGVvZiB0KXtpZigic3RyaW5nIj09dHlwZW9mIGUpdGhyb3cgbmV3IFR5cGVFcnJvcignVGhlICJzdHJpbmciIGFyZ3VtZW50IG11c3QgYmUgb2YgdHlwZSBzdHJpbmcuIFJlY2VpdmVkIHR5cGUgbnVtYmVyJyk7cmV0dXJuIGFsbG9jVW5zYWZlKHQpfXJldHVybiBmcm9tKHQsZSxyKX1mdW5jdGlvbiBmcm9tKHQsZSxyKXtpZigic3RyaW5nIj09dHlwZW9mIHQpcmV0dXJuIGZ1bmN0aW9uIGZyb21TdHJpbmcodCxlKXsic3RyaW5nIj09dHlwZW9mIGUmJiIiIT09ZXx8KGU9InV0ZjgiKTtpZighQnVmZmVyLmlzRW5jb2RpbmcoZSkpdGhyb3cgbmV3IFR5cGVFcnJvcigiVW5rbm93biBlbmNvZGluZzogIitlKTtjb25zdCByPTB8Ynl0ZUxlbmd0aCh0LGUpO2xldCBuPWNyZWF0ZUJ1ZmZlcihyKTtjb25zdCBvPW4ud3JpdGUodCxlKTtvIT09ciYmKG49bi5zbGljZSgwLG8pKTtyZXR1cm4gbn0odCxlKTtpZihBcnJheUJ1ZmZlci5pc1ZpZXcodCkpcmV0dXJuIGZ1bmN0aW9uIGZyb21BcnJheVZpZXcodCl7aWYoaXNJbnN0YW5jZSh0LFVpbnQ4QXJyYXkpKXtjb25zdCBlPW5ldyBVaW50OEFycmF5KHQpO3JldHVybiBmcm9tQXJyYXlCdWZmZXIoZS5idWZmZXIsZS5ieXRlT2Zmc2V0LGUuYnl0ZUxlbmd0aCl9cmV0dXJuIGZyb21BcnJheUxpa2UodCl9KHQpO2lmKG51bGw9PXQpdGhyb3cgbmV3IFR5cGVFcnJvcigiVGhlIGZpcnN0IGFyZ3VtZW50IG11c3QgYmUgb25lIG9mIHR5cGUgc3RyaW5nLCBCdWZmZXIsIEFycmF5QnVmZmVyLCBBcnJheSwgb3IgQXJyYXktbGlrZSBPYmplY3QuIFJlY2VpdmVkIHR5cGUgIit0eXBlb2YgdCk7aWYoaXNJbnN0YW5jZSh0LEFycmF5QnVmZmVyKXx8dCYmaXNJbnN0YW5jZSh0LmJ1ZmZlcixBcnJheUJ1ZmZlcikpcmV0dXJuIGZyb21BcnJheUJ1ZmZlcih0LGUscik7aWYoInVuZGVmaW5lZCIhPXR5cGVvZiBTaGFyZWRBcnJheUJ1ZmZlciYmKGlzSW5zdGFuY2UodCxTaGFyZWRBcnJheUJ1ZmZlcil8fHQmJmlzSW5zdGFuY2UodC5idWZmZXIsU2hhcmVkQXJyYXlCdWZmZXIpKSlyZXR1cm4gZnJvbUFycmF5QnVmZmVyKHQsZSxyKTtpZigibnVtYmVyIj09dHlwZW9mIHQpdGhyb3cgbmV3IFR5cGVFcnJvcignVGhlICJ2YWx1ZSIgYXJndW1lbnQgbXVzdCBub3QgYmUgb2YgdHlwZSBudW1iZXIuIFJlY2VpdmVkIHR5cGUgbnVtYmVyJyk7Y29uc3Qgbj10LnZhbHVlT2YmJnQudmFsdWVPZigpO2lmKG51bGwhPW4mJm4hPT10KXJldHVybiBCdWZmZXIuZnJvbShuLGUscik7Y29uc3Qgbz1mdW5jdGlvbiBmcm9tT2JqZWN0KHQpe2lmKEJ1ZmZlci5pc0J1ZmZlcih0KSl7Y29uc3QgZT0wfGNoZWNrZWQodC5sZW5ndGgpLHI9Y3JlYXRlQnVmZmVyKGUpO3JldHVybiAwPTLine truncated
public static let swaggerUiCss: String = "LnN3YWdnZXItdWl7Y29sb3I6IzNiNDE1MTtjb250YWluZXItbmFtZTpzd2FnZ2VyLXVpO2NvbnRhaW5lci10eXBlOmlubGluZS1zaXplOy8qISBub3JtYWxpemUuY3NzIHY3LjAuMCB8IE1JVCBMaWNlbnNlIHwgZ2l0aHViLmNvbS9uZWNvbGFzL25vcm1hbGl6ZS5jc3MgKi9mb250LWZhbWlseTpzYW5zLXNlcmlmfS5zd2FnZ2VyLXVpIGh0bWx7bGluZS1oZWlnaHQ6MS4xNTstbXMtdGV4dC1zaXplLWFkanVzdDoxMDAlOy13ZWJraXQtdGV4dC1zaXplLWFkanVzdDoxMDAlfS5zd2FnZ2VyLXVpIGJvZHl7bWFyZ2luOjB9LnN3YWdnZXItdWkgYXJ0aWNsZSwuc3dhZ2dlci11aSBhc2lkZSwuc3dhZ2dlci11aSBmb290ZXIsLnN3YWdnZXItdWkgaGVhZGVyLC5zd2FnZ2VyLXVpIG5hdiwuc3dhZ2dlci11aSBzZWN0aW9ue2Rpc3BsYXk6YmxvY2t9LnN3YWdnZXItdWkgaDF7Zm9udC1zaXplOjJlbTttYXJnaW46LjY3ZW0gMH0uc3dhZ2dlci11aSBmaWdjYXB0aW9uLC5zd2FnZ2VyLXVpIGZpZ3VyZSwuc3dhZ2dlci11aSBtYWlue2Rpc3BsYXk6YmxvY2t9LnN3YWdnZXItdWkgZmlndXJle21hcmdpbjoxZW0gNDBweH0uc3dhZ2dlci11aSBocntib3gtc2l6aW5nOmNvbnRlbnQtYm94O2hlaWdodDowO292ZXJmbG93OnZpc2libGV9LnN3YWdnZXItdWkgcHJle2ZvbnQtZmFtaWx5Om1vbm9zcGFjZSxtb25vc3BhY2U7Zm9udC1zaXplOjFlbX0uc3dhZ2dlci11aSBhe2JhY2tncm91bmQtY29sb3I6dHJhbnNwYXJlbnQ7LXdlYmtpdC10ZXh0LWRlY29yYXRpb24tc2tpcDpvYmplY3RzfS5zd2FnZ2VyLXVpIGFiYnJbdGl0bGVde2JvcmRlci1ib3R0b206bm9uZTt0ZXh0LWRlY29yYXRpb246dW5kZXJsaW5lOy13ZWJraXQtdGV4dC1kZWNvcmF0aW9uOnVuZGVybGluZSBkb3R0ZWQ7dGV4dC1kZWNvcmF0aW9uOnVuZGVybGluZSBkb3R0ZWR9LnN3YWdnZXItdWkgYiwuc3dhZ2dlci11aSBzdHJvbmd7Zm9udC13ZWlnaHQ6aW5oZXJpdDtmb250LXdlaWdodDpib2xkZXJ9LnN3YWdnZXItdWkgY29kZSwuc3dhZ2dlci11aSBrYmQsLnN3YWdnZXItdWkgc2FtcHtmb250LWZhbWlseTptb25vc3BhY2UsbW9ub3NwYWNlO2ZvbnQtc2l6ZToxZW19LnN3YWdnZXItdWkgZGZue2ZvbnQtc3R5bGU6aXRhbGljfS5zd2FnZ2VyLXVpIG1hcmt7YmFja2dyb3VuZC1jb2xvcjojZmYwO2NvbG9yOiMwMDB9LnN3YWdnZXItdWkgc21hbGx7Zm9udC1zaXplOjgwJX0uc3dhZ2dlci11aSBzdWIsLnN3YWdnZXItdWkgc3Vwe2ZvbnQtc2l6ZTo3NSU7bGluZS1oZWlnaHQ6MDtwb3NpdGlvbjpyZWxhdGl2ZTt2ZXJ0aWNhbC1hbGlnbjpiYXNlbGluZX0uc3dhZ2dlci11aSBzdWJ7Ym90dG9tOi0uMjVlbX0uc3dhZ2dlci11aSBzdXB7dG9wOi0uNWVtfS5zd2FnZ2VyLXVpIGF1ZGlvLC5zd2FnZ2VyLXVpIHZpZGVve2Rpc3BsYXk6aW5saW5lLWJsb2NrfS5zd2FnZ2VyLXVpIGF1ZGlvOm5vdChbY29udHJvbHNdKXtkaXNwbGF5Om5vbmU7aGVpZ2h0OjB9LnN3YWdnZXItdWkgaW1ne2JvcmRlci1zdHlsZTpub25lfS5zd2FnZ2VyLXVpIHN2Zzpub3QoOnJvb3Qpe292ZXJmbG93OmhpZGRlbn0uc3dhZ2dlci11aSBidXR0b24sLnN3YWdnZXItdWkgaW5wdXQsLnN3YWdnZXItdWkgb3B0Z3JvdXAsLnN3YWdnZXItdWkgc2VsZWN0LC5zd2FnZ2VyLXVpIHRleHRhcmVhe2ZvbnQtZmFtaWx5OnNhbnMtc2VyaWY7Zm9udC1zaXplOjEwMCU7bGluZS1oZWlnaHQ6MS4xNTttYXJnaW46MH0uc3dhZ2dlci11aSBidXR0b24sLnN3YWdnZXItdWkgaW5wdXR7b3ZlcmZsb3c6dmlzaWJsZX0uc3dhZ2dlci11aSBidXR0b24sLnN3YWdnZXItdWkgc2VsZWN0e3RleHQtdHJhbnNmb3JtOm5vbmV9LnN3YWdnZXItdWkgW3R5cGU9cmVzZXRdLC5zd2FnZ2VyLXVpIFt0eXBlPXN1Ym1pdF0sLnN3YWdnZXItdWkgYnV0dG9uLC5zd2FnZ2VyLXVpIGh0bWwgW3R5cGU9YnV0dG9uXXstd2Via2l0LWFwcGVhcmFuY2U6YnV0dG9ufS5zd2FnZ2VyLXVpIFt0eXBlPWJ1dHRvbl06Oi1tb3otZm9jdXMtaW5uZXIsLnN3YWdnZXItdWkgW3R5cGU9cmVzZXRdOjotbW96LWZvY3VzLWlubmVyLC5zd2FnZ2VyLXVpIFt0eXBlPXN1Ym1pdF06Oi1tb3otZm9jdXMtaW5uZXIsLnN3YWdnZXItdWkgYnV0dG9uOjotbW96LWZvY3VzLWlubmVye2JvcmRlci1zdHlsZTpub25lO3BhZGRpbmc6MH0uc3dhZ2dlci11aSBbdHlwZT1idXR0b25dOi1tb3otZm9jdXNyaW5nLC5zd2FnZ2VyLXVpIFt0eXBlPXJlc2V0XTotbW96LWZvY3VzcmluZywuc3dhZ2dlci11aSBbdHlwZT1zdWJtaXRdOi1tb3otZm9jdXNyaW5nLC5zd2FnZ2VyLXVpIGJ1dHRvbjotbW96LWZvY3VzcmluZ3tvdXRsaW5lOjFweCBkb3R0ZWQgQnV0dG9uVGV4dH0uc3dhZ2dlci11aSBmaWVsZHNldHtwYWRkaW5nOi4zNWVtIC43NWVtIC42MjVlbX0uc3dhZ2dlci11aSBsZWdlbmR7Ym94LXNpemluZzpib3JkZXItYm94O2NvbG9yOmluaGVyaXQ7ZGlzcGxheTp0YWJsZTttYXgtd2lkdGg6MTAwJTtwYWRkaW5nOjA7d2hpdGUtc3BhY2U6bm9ybWFsfS5zd2FnZ2VyLXVpIHByb2dyZXNze2Rpc3BsYXk6aW5saW5lLWJsb2NrO3ZlcnRpY2FsLWFsaWduOmJhc2VsaW5lfS5zd2FnZ2VyLXVpIHRleHRhcmVhe292ZXJmbG93OmF1dG99LnN3YWdnZXItdWkgW3R5cGU9Y2hlY2tib3hdLC5zd2FnZ2VyLXVpIFt0eXBlPXJhZGlvXXtib3gtc2l6aW5nOmJvcmRlci1ib3g7cGFkZGluZzowfS5zd2FnZ2VyLXVpIFt0eXBlPW51bWJlcl06Oi13ZWJraXQtaW5uZXItc3Bpbi1idXR0b24sLnN3YWdnZXItdWkgW3R5cGU9bnVtYmVyXTo6LXdlYmtpdC1vdXRlci1zcGluLWJ1dHRvbntoZWlnaHQ6YXV0b30uc3dhZ2dlci11aSBbdHlwZT1zZWFyY2hdey13ZWJraXQtYXBwZWFyYW5jZTp0ZXh0ZmllbGQ7b3V0bGluZS1vZmZzZXQ6LTJweH0uc3dhZ2dlci11aSBbdHlwZT1zZWFyY2hdOjotd2Via2l0LXNlYXJjaC1jYW5jZWwtYnV0dG9uLC5zd2FnZ2VyLXVpIFt0eXBlPXNlYXJjaF06Oi13ZWJraXQtc2VhcmNoLWRlY29yYXRpb257LXdlYmtpdC1hcHBlYXJhbmNlOm5vbmV9LnN3YWdnZXItdWkgOjotd2Via2l0LWZpbGUtdXBsb2FkLWJ1dHRvbnstd2Via2l0LWFwcGVhcmFuY2U6YnV0dG9uO2ZvbnQ6aW5oZXJpdH0uc3dhZ2dlci11aSBkZXRhaWxzLC5zd2FnZ2VyLXVpIG1lbnV7ZGlzcGxheTpibG9ja30uc3dhZ2dlci11aSBzdW1tYXJ5e2Rpc3BsYXk6bGlzdC1pdGVtfS5zd2FnZ2VyLXVpIGNhbnZhc3tkaXNwbGF5OmlubGluZS1ibG9ja30uc3dhZ2dlci11aSBbaGlkZGVuXSwuc3dhZ2dlci11aSB0ZW1wbGF0ZXtkaXNwbGF5Om5vbmV9LnN3YWdnZXItdWkgLmRlYnVnICp7b3V0bGluZToxcHggc29saWQgZ29sZH0uc3dhZ2dlci11aSAuZGVidWctd2hpdGUgKntvdXRsaW5lOjFweCBzb2xpZCAjZmZmfS5zd2FnZ2VyLXVpIC5kZWJ1Zy1ibGFjayAqe291dGxpbmU6MXB4IHNvbGlkICMwMDB9LnN3YWdnZXItdWkgLmRlYnVnLWdyaWR7YmFja2dyb3VuZDp0cmFuc3BhcmVudCB1cmwoZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFBZ0FBQUFJQ0FZQUFBREVENzZMQUFBQUdYUkZXSFJUYjJaMGQyRnlaUUJCWkc5aVpTQkpiV0ZuWlZKbFlXUjVjY2xsUEFBQUF5aHBWRmgwV0UxTU9tTnZiUzVoWkc5aVpTNTRiWEFBQUFBQUFEdy9lSEJoWTJ0bGRDQmlaV2RwYmowaTc3dS9JaUJwWkQwaVZ6Vk5NRTF3UTJWb2FVaDZjbVZUZWs1VVkzcHJZemxrSWo4K0lEeDRPbmh0Y0cxbGRHRWdlRzFzYLine truncated
private init() {}
}
+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("登录后返回的 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}) {
+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 {
// 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)
+48
View File
@@ -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 字节)"