Compare commits
12 Commits
v0.9.0-pre
...
v0.13.0-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| f0991d3ff2 | |||
| 8a348951eb | |||
| 158b8002b3 | |||
| eb69260586 | |||
| a2a07a39ea | |||
| bca2ef92cc | |||
| 6e3f025aad | |||
| 2041e7f661 | |||
| ebfd279f9a | |||
| bb2caefbb4 | |||
| 20e84e5ff8 | |||
| 6200864a64 |
94
API.md
94
API.md
@@ -1,5 +1,7 @@
|
||||
# API 契约与项目生成
|
||||
|
||||
当前生成目录和启动扩展采用新布局,见 [项目维护指南](PROJECT.md)。`@app(defaultResponse: Name)` 在入口选择 App 默认响应。`@body(mode: raw|multipart)` 和 `@produces` 支持自动 Handler,手写 Handler 仅为显式选择的扩展。
|
||||
|
||||
首次接入请先看 [README](README.md):包含固定远程版本安装、可运行项目、完整 `.api` 示例和已有服务接入步骤。本页用于查阅语法与生成规则;下方带 `--framework-dir` 的命令用于框架源码开发。
|
||||
|
||||
本阶段目标:从 `.api` 定义生成可运行的 Go 项目;定义、绑定、校验与 OpenAPI 保持一致;重复生成不覆盖业务实现;业务代码继续独立于网络层。实现入口是 `cmd/go-omni/apigen`,运行时契约编译器是 `contract`。
|
||||
@@ -15,9 +17,9 @@
|
||||
| 熟悉的命令别名 | `api go -api user.api -dir .` |
|
||||
| 校验契约 | `api validate --api user.api` |
|
||||
| CI 检查生成产物是否过期 | `api generate --api user.api --dir . --check` |
|
||||
| 业务逻辑目录 | `internal/service/<group>`,沿用 Omni 的 Service 分层 |
|
||||
| 依赖装配 | `internal/servicecontext` |
|
||||
| 接口文档 | 自动生成 `docs/openapi.json`,同时提供 `/openapi.json` |
|
||||
| 业务逻辑目录 | `internal/logic/<group>`,使用标准 context.Context |
|
||||
| 依赖装配 | `internal/svc` |
|
||||
| 接口文档 | 自动生成 `docs/openapi.json`,HTTP 端点由 `server.endpoints.openapi` 配置 |
|
||||
|
||||
这是明确限定的 go-zero 风格方言,**不是完整 goctl 兼容实现**。不支持的声明和校验标签会在生成前报错。
|
||||
|
||||
@@ -31,10 +33,10 @@ go run ./cmd/go-omni api new \
|
||||
|
||||
cd ../omniDemo
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
go run .
|
||||
```
|
||||
|
||||
访问 `http://127.0.0.1:8080/api/greet/Alice` 可得到带 `data.message` 的统一响应。`api new` 的问候示例包含可运行逻辑;自定义契约新生成的 Service 会明确返回 501,等待业务实现。
|
||||
访问 `http://127.0.0.1:8080/api/greet/Alice` 可得到带 `data.message` 的统一响应。`api new` 的问候示例包含可运行逻辑;自定义契约新生成的 Logic 会明确返回 501,等待业务实现。
|
||||
|
||||
用现成的多分组定义生成项目,在 go-omni 仓库执行:
|
||||
|
||||
@@ -57,11 +59,11 @@ go-omni new --api user.api --module example.com/userService \
|
||||
- `--storage none|local|s3|r2|oss`:默认 none,生成统一 Files / Uploads / Downloads / UploadLinks 依赖;本地签名对象端点为 `/storage/object`。
|
||||
- `--log-output stdout|stderr|file` 与 `--log-format json|text`:配置和无配置文件启动使用一致默认值。
|
||||
|
||||
初次选项记录在 `.goOmni/apiManifest.json`,以后直接 `api generate` 会继承。已有项目通过参数切换选项会报错,因为初始化代码属于业务方;可在新目录生成对照后手动迁移。`--force` 不允许覆盖 API 项目。旧式 CRUD 脚手架仍保留。
|
||||
初次选项记录在 `.goOmni/apiManifest.json`,以后直接 `api generate` 会继承。已有项目通过参数切换选项会报错,因为初始化代码属于业务方;可在新目录生成对照后手动迁移。`--force` 不允许覆盖 API 项目。new 与 api new 统一生成契约项目,不再使用旧式 CRUD 目录。
|
||||
|
||||
MySQL 密钥只从 `GO_OMNI_DB_DSN` 获取,非秘密池配置位于 YAML,环境变量优先。存储使用原有 `GO_OMNI_STORAGE_*`;API 项目不会额外生成公开的签名发放接口,鉴权和对象权限由业务实现。初始化失败会清理已打开资源,退出清理可重复调用。
|
||||
|
||||
HTTP 请求类型不能推导数据库表结构;迁移和 Repository 由业务自己维护。完整案例见 [用户管理示例](examples/userService/README.md),覆盖真实 MySQL、事务审计、鉴权、文件导出和日志。
|
||||
HTTP 请求类型不能推导数据库表结构;迁移和 Repository 由业务自己维护。当前目录的完整示例见 [managedService](examples/managedService/README.md)。MySQL、事务审计和鉴权实现可参考 [旧目录用户管理示例](examples/userService/README.md),接入时按 [项目指南](PROJECT.md) 装配到 config/svc/logic。
|
||||
|
||||
## 接口定义
|
||||
|
||||
@@ -111,6 +113,44 @@ service user-api {
|
||||
|
||||
## 业务错误:声明、代码和文档共用一份定义
|
||||
|
||||
**错误目录也可以只在 Go 中维护。** 从 `v0.11.1-preview` 起,生成路由没有声明错误列表时,继承 `omni.WithErrors(catalog)` 注册的 App 目录;`.api` 继续定义接口和返回格式,无需重复登记错误。全局中间件提前返回同样适用。显式引用 `.api` 错误时才使用路由独立目录,规则见 [目录继承](errors/README.md)。CLI 不执行运行时 Go 配置,因此静态文档不会自动枚举只在 Go 中定义的具体错误。
|
||||
|
||||
从 `v0.10.0-preview` 起,推荐用 `errorCatalog` 同时定义逻辑标识、对外业务码、提示和 HTTP 状态:
|
||||
|
||||
```text
|
||||
errorCatalog CommonErrors (
|
||||
defaultStatus: 200
|
||||
errors: {
|
||||
"UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400002, "message": "用户名格式不正确"},
|
||||
"PasswordInvalid": {"key": "PASSWORD_INVALID", "code": 400002, "message": "密码格式不正确"},
|
||||
"Forbidden": {"key": "FORBIDDEN", "status": 403, "code": 403001, "message": "没有权限"}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
对象键是生成函数名;`key` 是唯一逻辑标识,允许项目自己的命名;`code` 是对外业务码,可以重复。数字码需搭配 `codeType: int32` 或 `int64` 的公共响应;字符串码搭配 `string`。目录不规定返回体字段。
|
||||
|
||||
省略 `defaultStatus` 时为 400;设为 200 时,只作用于目录里未单独写 `status` 的条目。生成路由自动绑定它实际声明的错误目录,在 App 中间件之前生效。业务只调用 `apierrors.UsernameInvalid()`;无需再维护 `codes` / `messages`,也无需修改 main。手写路由可复用生成的 `apierrors.Catalog()`。
|
||||
|
||||
同一 HTTP 200 下,OpenAPI 用 `anyOf` 保留成功 DTO 和错误体,日志与指标仍识别业务失败。完整语法、路由作用域与手写项目配置见 [统一错误目录](errors/README.md),可运行契约见 [errors.api](examples/contracts/errors.api)。
|
||||
|
||||
从 `v0.11.0-preview` 起,目录条目支持 `params` 有序列表与 `{name}` 提示模板:
|
||||
|
||||
```json
|
||||
"TextTooLong": {
|
||||
"key": "TEXT_TOO_LONG", "code": 400002,
|
||||
"message": "{label}不能超过{max}个字符",
|
||||
"params": [
|
||||
{"name": "label", "type": "string", "example": "企业名称"},
|
||||
{"name": "max", "type": "int", "example": 100}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
生成 `apierrors.TextTooLong(label string, max int)`,返回时 Message 已填好;固定提示保持无参数函数。声明顺序决定参数顺序。模板校验、运行时缺参/类型错误、转义及非 HTTP 使用规则见 [提示模板参数](errors/README.md)。OpenAPI 的错误目录保留参数定义,响应示例使用具体文案。
|
||||
|
||||
原有声明继续支持:
|
||||
|
||||
```go
|
||||
error (
|
||||
Unauthenticated 401 "UNAUTHENTICATED" "authentication required"
|
||||
@@ -121,10 +161,10 @@ error (
|
||||
```
|
||||
|
||||
也可写单条 `error EmailTaken 409 "EMAIL_TAKEN" "email already in use"`,或把错误目录放进相对导入的 `errors.api`。
|
||||
名称用 UpperCamelCase,HTTP 状态范围 400–599,错误码用大写字母/数字/下划线且全局唯一,message 是可公开返回的稳定提示。
|
||||
名称用 UpperCamelCase,HTTP 状态允许 200–599 中可携带响应体的最终状态(排除 204/205/304),旧声明的逻辑错误码用大写字母/数字/下划线且全局唯一,message 是可公开返回的稳定提示。`Catalog` 为生成函数保留名称。
|
||||
|
||||
`@server (errors: Unauthenticated)` 声明组级错误;路由 `@errors UserNotFound, EmailTaken` 追加到组级列表并去重。
|
||||
`@errors none` 清空继承的业务错误。未声明、重复声明或生成符号冲突会报告源码位置。
|
||||
`@errors none` 清空 `.api` 分组继承的错误列表,随后按未设置路由目录处理,继承 App 错误目录。未声明、重复声明或生成符号冲突会报告源码位置。
|
||||
|
||||
生成 `internal/apierrors/errorsGen.go`:
|
||||
|
||||
@@ -138,7 +178,7 @@ return nil, apierrors.EmailTaken()
|
||||
|
||||
OpenAPI 对每个状态按选定的响应模板生成结构和命名 examples,同一 409 下多个错误不会相互覆盖;平铺模式的纯业务状态还列出 code enum。
|
||||
默认框架错误状态与自动添加的鉴权 401 保留开放的 code 字段,容纳校验器/中间件实际错误。
|
||||
`x-omni-errors` 提供错误目录与路由关联。文档声明不拦截、改写或穷举运行时的所有错误。
|
||||
`x-omni-errors` 提供错误目录与路由关联。目录解析已定义的错误,不自动推断数据库或其他未声明错误的业务含义,也不穷举所有运行时错误。
|
||||
|
||||
## 鉴权:声明与路由注册一起变化
|
||||
|
||||
@@ -200,27 +240,29 @@ service user-api {
|
||||
```sh
|
||||
go-omni api validate # 发现 manifest 中的入口并校验
|
||||
go-omni api inspect # 方法、路径、Handler、鉴权与业务错误总览
|
||||
go-omni api generate --dry-run # 预览新增/更新/移除,完全不写文件
|
||||
go-omni api generate --dry-run # 预览文件变化、契约变更和 Logic 提示,完全不写文件
|
||||
go-omni api generate # 实际生成,保留手写业务
|
||||
go-omni api generate --check # 只读检查,可用于 CI
|
||||
go-omni api generate --check # 只读检查产物漂移及明确的 Logic 签名错误,可用于 CI
|
||||
go-omni api export > openapi.json # 独立导出纯 JSON,无需启动服务
|
||||
go test ./...
|
||||
```
|
||||
|
||||
`--dry-run` 和 `--check` 不能组合。新项目预览也不创建目录;预览不是审批或锁定,实际生成会重新检查文件状态。
|
||||
契约变化会定位到相关 Logic 文件,涵盖嵌套 DTO、原始 body 和流式返回。生成后签名不一致的问题仍可由 `--check` 检出;项目类型别名/build tags 等不确定情况提示 REVIEW,最终用 `go test ./...` 验证。详见 [契约变更提示](PROJECT.md#契约变更提示)。
|
||||
在项目外可显式传 `--api`;生成时传 `--dir` 可定位其他项目。不会猜测多个 .api 中哪个是入口。
|
||||
路由总览的 `(undeclared)` 仅表示没有声明 auth;旧式中间件仍可能做认证。
|
||||
外发文档可用 export;项目内受生成器管理的 `docs/openapi.json` 请通过 generate 更新,以同步文件哈希。
|
||||
|
||||
## 迁移扩展(v0.9.0-preview)
|
||||
## 迁移扩展
|
||||
|
||||
- 动态 JSON 字段 `any` / `interface{}`,包含切片和 map;外围契约仍严格。
|
||||
- `scalar Name (...)` 显式引用项目类型,声明 JSON 输入/输出类型及文本绑定;支持已有 JSON/Text 转换方法。
|
||||
- `@body (mode: json/raw/multipart; maxBytes: ...)`:按路由限制请求体。
|
||||
- `@binding custom` 生成保留的绑定 hook;`@handlerMode manual` 生成保留的整个 Handler,raw/multipart 使用这个入口。
|
||||
- manual 可用 `@produces "text/plain"` 等声明无 DTO 成功输出。默认状态 200,初始手写 stub 返回 501。
|
||||
- `@binding custom` 生成保留的绑定 hook;`@handlerMode manual` 生成保留的整个 Handler,仅用于自定义协议。raw/multipart 默认生成自动 Handler,将受限流交给 Logic。
|
||||
- `@produces "text/plain"` 等声明无 DTO 成功输出;自动模式的 Logic 返回 `*httpx.Stream`,Handler 负责流式发送和关闭。默认状态 200。manual 模式保留项目自己的输出实现。
|
||||
- 自动 multipart Handler 检查未读部分和结束边界;损坏/截断默认返回 400,超限为 413。公共状态、码和文案仍可用项目错误目录配置,见 [流式接入与错误处理](PROJECT.md#自动上传回调与下载)。
|
||||
|
||||
完整语法、边界和示例见 [迁移扩展指南](MIGRATION.md)。新增生成代码要求 CLI 与框架依赖一起升级到 `v0.9.0-preview`;不兼容全部 goctl 语法。
|
||||
迁移扩展起始于 v0.9.0,原有手写扩展说明见 [迁移扩展指南](MIGRATION.md)。当前根目录入口和自动流式 Handler 要求 CLI 与框架依赖一起使用 v0.13.0-preview;版本说明和新目录维护规则见 [项目指南](PROJECT.md)。不兼容全部 goctl 语法。
|
||||
|
||||
## 参数语义
|
||||
|
||||
@@ -236,17 +278,17 @@ JSON 必须是一个 object,Content-Type 为 application/json。默认最多 1
|
||||
|
||||
```text
|
||||
api/ # 开发者维护的契约及导入
|
||||
cmd/server/main.go # 启动、日志、优雅退出
|
||||
main.go # 启动、日志、优雅退出
|
||||
etc/config.yaml
|
||||
internal/settings/
|
||||
internal/servicecontext/ # 依赖初始化和释放
|
||||
internal/config/
|
||||
internal/svc/ # 依赖初始化和释放
|
||||
internal/types/typesGen.go
|
||||
internal/apierrors/errorsGen.go # 声明错误后生成
|
||||
internal/handler/routesGen.go
|
||||
internal/handler/openapiGen.go
|
||||
internal/handler/<group>/handlersGen.go
|
||||
internal/service/<group>/service.go
|
||||
internal/service/<group>/<operation>.go
|
||||
internal/logic/<group>/logic.go
|
||||
internal/logic/<group>/<operation>.go
|
||||
internal/repository/<group>/repository.go
|
||||
internal/middleware/registryGen.go
|
||||
internal/middleware/<name>.go
|
||||
@@ -254,7 +296,7 @@ docs/openapi.json
|
||||
.goOmni/apiManifest.json
|
||||
```
|
||||
|
||||
Service 方法接收标准 `context.Context` 与生成的请求 DTO;Handler 调用 Service 并完成响应包装。网络适配器仍经 Omni/httpx 接入。Repository 是业务所有的扩展位置,不自动生成虚构 CRUD 查询。
|
||||
Logic 方法接收标准 `context.Context` 与生成的请求 DTO;Handler 调用 Logic 并完成响应包装。网络适配器仍经 Omni/httpx 接入。Repository 是业务所有的扩展位置,不自动生成虚构 CRUD 查询。
|
||||
|
||||
命名中间件按声明顺序组合,第一个在最外层。新 hook 初始返回 501,必须实现后才放行;registry 自动维护,hook 文件只创建一次。缺失 hook 在注册任何路由前报错。文档通过 `x-omni-middleware` 标出命名 hook,不把名称误当成已实现的认证协议。
|
||||
|
||||
@@ -269,7 +311,7 @@ go-omni api generate --api api/user.api --dir . --check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
`Gen.go` 和 OpenAPI 由生成器管理,manifest 保存其 SHA-256。手动修改后更新会报冲突。Service、Repository、main、配置、依赖装配和中间件实现按字节保留;只补缺失文件。删除接口/分组时只移除旧的生成文件,保留业务代码;已删除类型仍被旧业务引用时,需要明确迁移该业务文件,编译器会提示。
|
||||
`main.go`、`Gen.go` 和 OpenAPI 由生成器管理,manifest 保存其 SHA-256。手动修改后更新会报冲突。Logic、Repository、配置、依赖装配和中间件实现按字节保留;只补缺失文件。删除接口/分组时只移除旧的生成文件,保留业务代码;已删除类型仍被旧业务引用时,需要明确迁移该业务文件,编译器会提示。
|
||||
|
||||
`--check` 只读,检查产物是否过期,以及所需骨架是否缺失。契约复制到项目后,请编辑项目内的 API;外部定义与已存在副本不一致时会拒绝覆盖。一个项目由一个 API 入口管理,可以通过导入拆分文件。
|
||||
|
||||
@@ -281,7 +323,9 @@ go test ./...
|
||||
|
||||
```sh
|
||||
go test -race -p=2 ./contract ./openapi ./cmd/go-omni/...
|
||||
python3 scripts/verifyAPI.py
|
||||
python3 scripts/verifyManagedProject.py
|
||||
```
|
||||
|
||||
Go 测试覆盖契约拒绝规则、多源绑定、零值/缺失、默认/范围/嵌套集合、文档约束、幂等更新、只读检查、业务保留、删除路由、路径边界及生成项目编译。Python 验收会在临时目录生成项目,分别启动 nethttp/native,验证真实 HTTP 请求、中间件、日志 requestId 和优雅退出;结束后清理自己创建的进程和临时文件。结果记录在 `performance/apiGenerationLocal.json`。
|
||||
Go 测试覆盖契约拒绝规则、多源绑定、零值/缺失、默认/范围/嵌套集合、文档约束、幂等更新、只读检查、业务保留、删除路由、路径边界及生成项目编译。Python 验收会在临时目录生成项目,分别启动 nethttp/native,验证真实 HTTP 请求、中间件、日志 requestId 和优雅退出;结束后清理自己创建的进程和临时文件。结果记录在 `performance/managedProjectLocal.json`;完整记录见 [第一版验收](performance/managedProjectAcceptance.md)。
|
||||
|
||||
内置健康与 OpenAPI 路由可通过配置禁用或修改路径,`.api` 不再固定保留 `/health/live`、`/health/ready`、`/openapi.json`。启用的内置路径与业务 GET 路由重叠时,生成的 NewApp 在监听前返回配置错误;禁用后可以由 `.api` 自己维护这些路径。见 [内置接口配置](PROJECT.md#配置内置接口)。
|
||||
|
||||
54
CHANGELOG.md
54
CHANGELOG.md
@@ -1,5 +1,59 @@
|
||||
# Changelog
|
||||
|
||||
## v0.13.0-preview(2026-09-24)
|
||||
|
||||
- 新增通用 lifecycle.Controller,svc.RequestShutdown 支持依赖主动通知正常停止或致命故障,通知不阻塞,首个故障原因保留;生成入口排空 HTTP 后关闭依赖与日志,故障退出码为 1。
|
||||
- App.RunContext、ServeContext、RunWith 保留自定义 context 取消原因;正常信号取消仍正常返回。
|
||||
- 健康检查默认使用 App 响应模板和错误目录,支持 raw JSON;HTTP 状态保持 200/503,避免业务 HTTP 200 策略掩盖未就绪。内部探针原因仅供日志,失败状态继续记录到日志/指标。
|
||||
- 生成项目通过 server.endpoints 配置 liveness/readiness/openapi 的开关与路径;全局中间件继续生效,OpenAPI 保持原始规范。禁用端点不保留路径,启用路径与业务 GET/参数/通配路由冲突时在监听前报错。
|
||||
- 生成器最低框架版本更新到 v0.13.0-preview;manifest v2 和业务文件保持。v0.12 项目需要显式升级其 svc 扩展,main/Handler 由重新生成更新。
|
||||
- 增加故障退出、排空/清理顺序、初始化期间通知、子进程退出码、健康格式、损坏编码器、端点禁用/改名/冲突的回归验收。
|
||||
|
||||
## v0.12.0-preview(2026-09-24)
|
||||
|
||||
- 生成项目统一采用根目录 main.go 和 config/svc/logic/handler;new 与 api new 共用受管理生成器,manifest v2 不兼容旧目录。
|
||||
- main 和 HTTP 装配持续生成;svc 提供 AppOptions、GlobalMiddleware、OnClose 和幂等 Close,初始化失败回收资源,正常退出先排空 HTTP 再关闭项目依赖及日志。
|
||||
- 入口 @app(defaultResponse: Name) 自动接入 App 默认响应;生成路由、全局中间件及 404/405 共用策略,Go 错误目录保持单处注册。
|
||||
- raw/multipart 输入和 @produces 流式输出支持自动 Handler,Logic 接收受限请求流/返回可关闭内容流,保留显式手写扩展。
|
||||
- 新增真实生成项目生命周期、流式输入输出、文件保留和入口漂移检查,提供可运行 managedService 示例与 PROJECT.md。
|
||||
- multipart 流式读取保留解析错误,检查未消费部分与最终边界;损坏请求默认 400,超限保持 413,错误目录可定制公开响应。配置校验与实际 App 响应编码器/错误目录装配使用同一规则,冲突在构造前报错。
|
||||
- manifest 保存契约快照;生成/预览/检查提供接口类型、嵌套 DTO、scalar、body/流式模式变更提示,定位受影响的 Logic。轻量源码检查识别签名、语法和重复方法问题;--check 在重新生成后仍拒绝明确的业务签名错误,保留所有手写文件,类型别名/build tags 等不确定情况提示人工复查。
|
||||
|
||||
|
||||
## v0.11.1-preview
|
||||
|
||||
- 修复生成路由用空错误目录遮蔽 App 目录的问题;空 WithErrors 完全透传,避免 Context 与 Encoder 二次解析时丢失状态、业务码和中文提示。
|
||||
- 未声明错误列表的路由继承 App,支持错误只在 Go 中维护;显式路由目录保持独立,响应格式、RawJSON、body limit 和全局中间件提前返回保持正确策略。
|
||||
- 兼容已有 v0.11.0 生成代码;CLI 不再为空列表绑定目录。补充纯 Go 错误目录的实际生成消费者与回归验收。
|
||||
|
||||
## v0.11.0-preview
|
||||
|
||||
- 错误目录增加有序 params 和命名提示模板,生成带类型的错误函数;固定提示保持兼容,业务不再自行拼接文案。
|
||||
- 新增 WithParams、参数校验与共用解析器;复制请求参数、保留错误链,重复解析和缓冲链路保留填充结果。非法调用转系统兜底,访问日志提供不含参数值的诊断。
|
||||
- OpenAPI 错误目录同步参数类型/示例,响应示例由运行时解析器生成;JSON/YAML 配置同样支持模板。
|
||||
- 本地/共享幂等重放保存已解析公开错误快照,不保存原始参数或 cause;Redis Lua 重写记录不损失 int64 业务码精度。
|
||||
- 模板编译拒绝未声明/未使用参数、非法类型及示例;模板错误不能再维护同标识的旧 messages 映射。
|
||||
- CLI 新生成代码最低要求 v0.11.0-preview;已生成的固定错误函数和 v0.10.0 调用继续支持。
|
||||
|
||||
## v0.10.0-preview
|
||||
|
||||
- 新增 App/路由独立错误目录:`omni.WithErrors`、`errors.Catalog`、`ByCode` 与不可变 Registry,业务只选逻辑标识;对外码和提示由项目配置。
|
||||
- 默认 HTTP 状态与逐项覆盖均可配置,包括 HTTP 200 的业务失败;错误模板、RawJSON、日志、业务失败指标、链路、标准中间件桥接和幂等重放保留失败语义。
|
||||
- `.api errorCatalog` 同源生成 Catalog、独立错误函数、路由目录和 OpenAPI;允许重复对外码、不同路由不同码类型、公共契约导入。旧 error 声明也允许 HTTP 200。
|
||||
- 同一 200 的成功/错误响应使用 anyOf 保留两种 schema,错误示例保持各自提示;自定义 Encoder 和直接输出继续支持。
|
||||
- 目录校验类型、范围、状态和冲突;JSON 配置拒绝重复键,整数保留 int64 精度;未知标识使用系统兜底,默认公开响应不暴露 cause。
|
||||
- 新生成代码最低要求 v0.10.0-preview;原有构造函数、Codes/Messages 与旧生成代码继续可用。Catalog 为生成错误包保留函数名。
|
||||
|
||||
|
||||
## v0.9.1-preview
|
||||
|
||||
- 新增标准 `database.OpenConnector` 和独立 `PoolConfig`;与 DSN 入口共享池参数、限时启动 Ping、失败清理、Health、事务,不注册全局驱动配置。
|
||||
- 新增 `omni.WithHTTPConfig` / `Config.HTTP`:读头、读请求、写响应、空闲超时和头大小,贯通代码、JSON/YAML、环境变量、Run/Serve 与 NewServer;nethttp 支持 Go-only ErrorLog。
|
||||
- 保持已有默认行为;负值和实验 http2 不支持的超时项在监听前报错;记录各网络层的具体语义。
|
||||
- 增加真实 MySQL TLS/驱动日志隔离、连接池归属/取消/事务、nethttp/native 慢连接与写背压验收,以及无本地 replace 的远程消费者脚本。
|
||||
- 兼容 v0.9.0-preview 生成代码;不修改业务项目的数据库策略或生成代码最低版本。
|
||||
|
||||
|
||||
## v0.9.0-preview
|
||||
|
||||
- 标准 HTTP Handler/Middleware 桥接:跟踪 Raw 状态与提交,保留取消、流式能力和中间件跨请求状态;已提交响应不追加 JSON。
|
||||
|
||||
234
PROJECT.md
Normal file
234
PROJECT.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# 生成项目与文件维护边界
|
||||
|
||||
v0.13.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||
|
||||
## 创建和日常开发
|
||||
|
||||
```sh
|
||||
# 在存放业务项目的目录执行,目标目录必须为空。
|
||||
go-omni new --module example.com/hello ./hello
|
||||
cd hello
|
||||
go mod tidy
|
||||
go run .
|
||||
go build .
|
||||
```
|
||||
|
||||
在项目目录使用同版 CLI:编辑 `api/service.api`,运行 `go-omni api generate`,填写 `internal/logic` 的业务方法。增加接口、分组或具名中间件会自动更新接入代码,无需修改入口。`--dry-run` 预览;`--check` 只读检查,包括 main、HTTP 接入和 OpenAPI。API 文件通过相对 import 拆分模块。
|
||||
|
||||
## 契约变更提示
|
||||
|
||||
生成器在 manifest 中保存上次成功生成的接口快照,比较请求/返回类型、嵌套 DTO 字段与标签、自定义 scalar、路径、body 模式/限额和流式输出类型。只提示受影响的接口,并标出业务文件;删除或移动接口会提醒整理保留的旧实现。注释、文件排版、Logic 参数名与导入别名不会被当成接口变化。
|
||||
|
||||
例如把 `GreetReq` 改名为 `HelloReq`,旧业务方法尚未修改时:
|
||||
|
||||
```text
|
||||
ERROR [logicSignature] internal/logic/greet/greet.go:12 (GET /api/greet/:name): ...
|
||||
before: Greet(context.Context, *types.GreetReq) (*types.GreetResp, error)
|
||||
after: Greet(context.Context, *types.HelloReq) (*types.GreetResp, error)
|
||||
```
|
||||
|
||||
| 命令 | 变更提示与检查行为 |
|
||||
| --- | --- |
|
||||
| `api generate --dry-run` | 显示计划、契约变化和业务签名问题,不写文件;诊断本身不让预览失败 |
|
||||
| `api generate` | 更新生成文件和契约快照,保留业务代码,并显示需要处理的诊断;生成成功不代表业务已通过编译 |
|
||||
| `api generate --check` | 只读检查;生成产物过期或发现明确的 Logic 签名/语法/重复方法错误时返回非零退出码 |
|
||||
| `api validate` | 只验证 `.api`,不检查项目 Go 实现 |
|
||||
|
||||
`REVIEW` 提醒复查业务语义;`ERROR` 表示已发现源代码问题。生成后,旧签名仍会在后续 `--check` 中报错,直到你改好;DTO 字段变化的 REVIEW 与上次生成快照比较,生成后不持续要求确认,也不会替你修改业务。业务方法移到同组其他 Go 文件时仍能定位。
|
||||
|
||||
这是轻量源码分析,不运行项目 init、不执行 Go 命令、不下载依赖。项目自定义类型别名、嵌入方法或 build tags 等无法可靠判断时,会给 REVIEW 并交给 Go 编译器验证;不把这些情况强行判为签名错误。它不检查方法体是否引用已删除字段,也不证明业务逻辑正确。CI 中应同时执行 `go-omni api generate --check` 和 `go test ./...`,按项目实际构建参数测试。当前版本首次接手缺少快照的 manifest v2 时,会明确提示建立基线,不猜测旧契约;Logic 签名检查仍会执行。
|
||||
|
||||
## 哪些文件由用户维护
|
||||
|
||||
| 文件 | 首次生成后由项目维护 |
|
||||
| --- | --- |
|
||||
| `api/*.api` | 路由、DTO、具名中间件、响应模板、请求/响应模式 |
|
||||
| `etc/config.yaml` | 运行参数;密钥通过环境变量提供 |
|
||||
| `internal/config/config.go` | 自定义配置字段、加载、默认值与校验 |
|
||||
| `internal/svc/serviceContext.go` | 依赖字段和初始化,持有加载后的 Config |
|
||||
| `internal/svc/options.go` | AppOptions、GlobalMiddleware;注册 Go 错误目录和全局扩展 |
|
||||
| `internal/svc/close.go` | 资源关闭;OnClose 为自定义资源提供标准清理入口 |
|
||||
| `internal/svc/storage.go` | 选用存储时生成,之后保留 |
|
||||
| `internal/logic/<group>/*.go` | 业务方法,使用标准 context.Context |
|
||||
| `internal/repository/<group>/*.go` | 数据访问,按需实现 |
|
||||
| `internal/middleware/<name>.go` | 项目鉴权/权限等具名中间件实现 |
|
||||
| 项目 Go 错误包 | 自行维护 Catalog 和常量,可跨服务共用 |
|
||||
| 自定义标量文件、`*Bind.go`、`*Manual.go` | 只有显式选择相应扩展时才生成/使用 |
|
||||
| 项目测试、迁移、部署脚本、go.mod/go.sum | 项目所有,生成器不重置其内容 |
|
||||
|
||||
以上骨架只补缺失文件,既有内容按字节保留。删除已废弃接口后,对应业务文件也不会被自动删除;由开发者整理编译器指出的失效类型或签名。
|
||||
|
||||
生成器持续管理:`main.go`、`internal/handler/*Gen.go`、分组的 `handlersGen.go`、`internal/types/*Gen.go`、中间件 `registryGen.go`、`docs/openapi.json`、`.goOmni/apiManifest.json`。如果在 `.api` 里选择声明错误,还会管理 `internal/apierrors/errorsGen.go`。文件标注 DO NOT EDIT;手改会被拒绝覆盖,需先移走自定义内容。
|
||||
|
||||
Logic 实例在注册时创建并由请求并发复用;请求数据用方法参数和局部变量传递,不存入共享 Logic/svc 字段。
|
||||
|
||||
普通 Handler 不需要维护。目录内可以同时有生成文件和单独的手写扩展文件,所有权按文件区分。没有额外 server 包或第二层 service。
|
||||
|
||||
## 依赖与启动扩展
|
||||
|
||||
`svc.New(ctx, logger, cfg)` 初始化依赖,可返回错误;Logger 由入口创建并借用。新增客户端时只需要扩展配置和 svc:
|
||||
|
||||
```go
|
||||
client, err := newClient(cfg.Client)
|
||||
if err != nil { return nil, err }
|
||||
c.Client = client
|
||||
c.OnClose(client.Close) // 创建成功后立即登记,后续失败也能释放
|
||||
```
|
||||
|
||||
`New` 的失败清理骨架已生成。OnClose 按登记的相反顺序执行;Close 幂等。项目不要重复关闭同一资源,借用的 Logger 也不应由 svc 关闭。构造器在返回错误前若创建了尚未交付的资源,该构造器自己负责释放。
|
||||
|
||||
错误注册写在 `internal/svc/options.go`,错误表可以来自任意项目共用包:
|
||||
|
||||
```go
|
||||
func (c *ServiceContext) AppOptions() []omni.Option {
|
||||
return []omni.Option{omni.WithErrors(apierrors.Catalog())}
|
||||
}
|
||||
```
|
||||
|
||||
业务调用 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`,可接 `WithParams` / `WithCause`。无需在 `.api` 重复错误目录。未声明独立错误目录的路由继承 App 目录;独立目录不与 App 合并。静态 OpenAPI 不执行 Go,不能自动枚举仅存在于 Go 中的错误目录。
|
||||
|
||||
`GlobalMiddleware()` 提供全局中间件;`Middlewares` 映射提供 `.api` 声明的具名中间件。全部显式调用,不依赖项目 init 注册,也不要求 svc 导入 handler。框架请求链按原有次序运行。
|
||||
|
||||
生成入口执行:配置加载/校验 → 日志 → svc.New → 生成的 handler.NewApp(应用 AppOptions、响应策略、校验最终 HTTP 配置、全局中间件、全部路由)→ App.RunContext → 依赖关闭 → 日志关闭。初始化失败、接入配置失败和监听失败也清理资源。
|
||||
|
||||
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。
|
||||
|
||||
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,在 OnClose 中取消。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
|
||||
|
||||
## 依赖主动申请退出
|
||||
|
||||
从 v0.13.0-preview 起,生成的 svc 提供 `RequestShutdown(err)`。数据库客户端、节点租约、后台任务等发现不可恢复故障时,由项目决定是否退出:
|
||||
|
||||
```go
|
||||
// 在 svc 的依赖监控回调中调用;err 是实际故障原因。
|
||||
c.RequestShutdown(err)
|
||||
```
|
||||
|
||||
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
|
||||
|
||||
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。业务应在 Close/OnClose 中停止并等待自己启动的监控任务,再结束资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
|
||||
|
||||
`Lifecycle *lifecycle.Controller` 是 svc 持有的通用控制器,初始化时创建,生成入口使用它的 Context。该 Context 表示服务正在停止,不代表数据库已经关闭。不能把它当作在途请求的 Context,也不要在它取消时立刻关闭请求仍依赖的资源。发号器失去租约后拒绝发号等业务安全规则仍由项目实现。
|
||||
|
||||
## 配置内置接口
|
||||
|
||||
生成项目在 `etc/config.yaml` 明确列出以下默认配置;可以独立关闭、修改路径,或让健康接口返回原始 JSON:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
endpoints:
|
||||
liveness:
|
||||
enabled: true
|
||||
path: /health/live
|
||||
responseMode: app
|
||||
readiness:
|
||||
enabled: true
|
||||
path: /health/ready
|
||||
responseMode: app
|
||||
openapi:
|
||||
enabled: true
|
||||
path: /openapi.json
|
||||
```
|
||||
|
||||
`responseMode: app` 使用项目 App 的响应模板/自定义 Encoder。健康成功使用项目成功码和成功提示;未就绪的 `NOT_READY` 可在项目 Go 错误目录配置业务码和公开提示,无需在 `.api` 再声明。
|
||||
|
||||
健康探针的 HTTP 状态固定遵循探针语义:存活/就绪为 200,未就绪为 503;项目错误目录或自定义编码器不能把未就绪改为 HTTP 200。响应模板 `${status}` 同样使用探针状态。失败继续进入业务失败日志/指标,探针底层原因保留在错误链供日志使用,不直接返回给调用方。
|
||||
|
||||
`responseMode: raw` 返回 `{"status":"ready"}` 等健康数据,有依赖时包含检查名称、结果与耗时;不包装公共体。存活检查不执行依赖探针;服务开始退出后,就绪检查返回 503,存活仍可返回 200,直到监听关闭。OpenAPI 始终输出规范文档本身,不使用业务响应包装;关闭 HTTP 文档端点不影响生成 `docs/openapi.json`。
|
||||
|
||||
环境变量覆盖配置文件,例如 `GO_OMNI_ENDPOINTS_READINESS_PATH=/readyz`、`GO_OMNI_ENDPOINTS_READINESS_RESPONSE_MODE=raw`、`GO_OMNI_ENDPOINTS_OPENAPI_ENABLED=false`;liveness/readiness/openapi 都支持对应的 ENABLED 和 PATH。Config/AppOptions 的最终有效配置决定是否注册。
|
||||
|
||||
关闭或改名后,旧路径不再由框架保留,项目可以在 `.api` 自己声明。启用的内置 GET 路由与业务路由相同或被业务参数/通配路径覆盖时,在创建 App、开始监听之前报错;不会覆盖业务路由。内置接口仍经过全局中间件;项目在鉴权中间件中明确决定这些路径的访问规则,框架不自动绕过鉴权。
|
||||
|
||||
## 从 v0.12 生成项目升级
|
||||
|
||||
manifest v2 和现有目录保留。升级同版 CLI 与框架依赖后执行 `api generate`,生成器自动更新 main 和 Handler;`svc`、配置与业务文件仍按字节保留,所以需要在项目拥有的 svc 文件补入以下扩展:
|
||||
|
||||
1. `serviceContext.go` 导入 `git.yuntuo.hk/k2safe/go-omni/lifecycle`,给 ServiceContext 增加 `Lifecycle *lifecycle.Controller`;创建 c 时用 `lifecycle.New(ctx)` 初始化,并在后续依赖初始化前设置 `ctx = c.Lifecycle.Context()`。
|
||||
2. 增加 `func (c *ServiceContext) RequestShutdown(err error) { c.Lifecycle.RequestShutdown(err) }`。
|
||||
3. 构造失败的 defer 在 `c.Close()` 后保留 `c.Lifecycle.Err()`;Close 的 `closeOnce.Do` 开头调用 `c.Lifecycle.RequestShutdown(nil)`,重复关闭仍由已有 once 保护。
|
||||
4. 配置默认值若来自 `config.Default()`,自动具有默认端点配置;如需禁用或改路径,在 YAML 或项目 Config 中显式设置。手动拼 Config 的项目需要自行填写要启用的端点。
|
||||
|
||||
精确实现可对照 [示例 svc](examples/managedService/internal/svc/serviceContext.go) 和 [关闭逻辑](examples/managedService/internal/svc/close.go),也可在临时目录用新版 CLI 生成参考。不要覆盖原有数据库/客户端初始化;不需要手改 main、Handler 或引入另一套启动器。最后执行 `api generate --check`、`go test ./...`、`go build .`,并验证故障退出。
|
||||
|
||||
## App 默认响应格式
|
||||
|
||||
在 **入口** `.api` 声明一次:
|
||||
|
||||
```text
|
||||
@app (
|
||||
defaultResponse: CommonResponse
|
||||
)
|
||||
response CommonResponse (
|
||||
codeType: int32
|
||||
successCode: 0
|
||||
errorCode: 9999
|
||||
messageField: msg
|
||||
dataMode: always
|
||||
)
|
||||
```
|
||||
|
||||
优先级:路由 `@response` → 分组 `@server(response: ...)` → App 默认。模板可以定义在 import 文件里,`@app` 只能在入口声明。重复声明、引用不存在的模板会报错;不会按文件顺序猜默认值。未声明 `@app` 时使用框架现有默认格式。
|
||||
|
||||
生成的 App 普通路由、404/405、未单独指定格式的生成路由共用默认模板。已匹配路由的中间件提前报错也使用路由策略。`@response raw` 的成功 JSON 不包装,错误沿用有效模板。
|
||||
|
||||
显式 `.api` 默认响应优先于 Config/AppOptions 中的响应配置,保持接口文档和实际响应一致。其他 AppOptions 继续生效;要改变格式就修改 `.api`。项目无需访问 handler 私有变量。
|
||||
|
||||
## 自动上传、回调与下载
|
||||
|
||||
以下声明生成完整 Handler,用户填写 Logic:
|
||||
|
||||
```text
|
||||
@handler upload
|
||||
@body (mode: multipart; maxBytes: 33554432)
|
||||
post /upload returns (UploadResp)
|
||||
|
||||
@handler callback
|
||||
@body (mode: raw; maxBytes: 65536)
|
||||
@produces "text/plain"
|
||||
post /callback
|
||||
|
||||
@handler download
|
||||
@produces "application/octet-stream"
|
||||
get /download (DownloadReq)
|
||||
```
|
||||
|
||||
- raw/multipart 的 Logic 增加 `body *httpx.Body` 参数。`body.Raw` 是受路由上限约束的原始字节流;`body.Header` 是请求头副本。
|
||||
- multipart 用 `body.Parts.NextPart()` 顺序读取,关闭每个 Part;不调用 ParseMultipartForm,不产生上传临时文件,也不把整个文件放入内存。每个字段/文件的业务要求由 Logic 校验。请求 DTO 可定义 path/form/header;`form` 仍是 URL 查询参数,不能用 json DTO 重复读取上传体。
|
||||
- Parts 是 `*httpx.MultipartReader`,返回的 `*httpx.MultipartPart` 提供 Read、Close、Header、FormName、FileName;NextRawPart 可读取未做 quoted-printable 解码的部分。NextPart 自动关闭上一个未读完的 Part;Close 会报告读取错误。不要混用 Parts 与 Raw 读取同一个 multipart 请求。
|
||||
- multipart 缺少结束边界、头部/内容损坏默认返回 `400 / INVALID_MULTIPART`;超过路由限额仍为 413,媒体类型/缺少 boundary 为 415。框架只提供通用标识和默认提示,项目可以在 Go 错误目录中配置自己的状态、业务码和文案。内部解析原因保留在错误链,不进入公开响应;Logic 自己返回的数据库等错误不会被统一改成 400。
|
||||
- raw 数据不经过 JSON 解码。签名算法、密钥及回执内容由业务提供;同一个流只消费一次。若中间件先读取验签,项目必须显式使用受限缓冲/重放,生成器不会自动缓存大文件。
|
||||
- `@produces` 使 Logic 返回 `(*httpx.Stream, error)`,其中 Content 是 `io.ReadCloser`,可选 Name 生成附件下载头。ContentType 可携带 charset,但必须与契约媒体类型匹配。Handler 负责关闭内容流,无公共体包装;写出后发生错误不会追加 JSON。
|
||||
- Handler 在成功响应前检查剩余请求流,包含未读完的 multipart 部分、最终边界和请求体上限;已经遇到的 multipart 读取错误不会因业务忽略它而变成成功。读取失败和 413 不能撤回 Logic 已产生的外部副作用;重要业务应先调用 `body.Complete()` 确认完整读取/验证,再提交事务或发布文件。Complete 可重复调用。
|
||||
- 自动 Handler 采用先完成输入、再发送输出的顺序;不要把正在读取的 body.Raw 直接当作 Stream.Content 返回。需要同时读写请求/响应的协议时使用手写扩展。
|
||||
- 大文件的耗时仍受 HTTP 读写超时约束。按接口需要配置,框架不自动无限延长。
|
||||
|
||||
默认请求体上限为 1 MiB,按路由设置 maxBytes,不能通过统一放大全局限制替代。`@produces` 仅声明媒体类型,不保证业务返回的内容符合该媒体的语义。Range、SSE 刷新、特殊协议等需要时仍可显式选 `@handlerMode manual`;它是可选扩展,不是普通上传下载的前置条件。
|
||||
|
||||
## 旧结构处理
|
||||
|
||||
新生成器使用 manifest v2,不兼容原 settings/servicecontext/service 与 cmd/server 布局。已有旧目录会报错,不能通过覆盖 main 自动迁移。试验代码需保留时,在新目录生成,移动 `.api`、Logic、配置和 svc 扩展并调整导入;独立的 `cmd/migrate` 等工具由项目维护。不会静默覆盖手写 main 或删除无关命令。
|
||||
|
||||
`go-omni new` 与 `api new` 统一使用此结构。受管理项目通过 `.api` 增加模块,旧的 `add` 手动模块装配方式不适用于该结构。直接引用 go-omni 框架包的项目不受这些目录约定约束。
|
||||
|
||||
## 已有服务接入步骤
|
||||
|
||||
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.13.0-preview:
|
||||
|
||||
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
||||
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
||||
3. 把配置放入 `internal/config` 和 `etc/config.yaml`;数据库、自定义 Connector、Redis、ID Client 等依赖放入 `internal/svc`,初始化成功后立即 `OnClose`。现有事务和迁移工具继续由项目维护;数据库扩展见 [database](database/README.md)。
|
||||
4. 业务错误目录只在项目 Go 包维护,通过 `svc.AppOptions()` 返回 `omni.WithErrors(apierrors.Catalog())` 注册一次。需要继承 App 目录的路由不要重复声明独立错误目录。项目鉴权放入中间件,业务实现迁入 Logic/Repository;无需修改 main 或生成的 Handler。
|
||||
5. 上传、原始请求体验签和下载优先使用自动 Handler;只有特殊协议才选择手写扩展。保留实际验签算法、流式处理、大小限制和存储权限,按 [迁移扩展](MIGRATION.md) 处理旧 Handler 与自定义标量。
|
||||
6. 每个服务运行下面的检查,再用实际配置验证依赖连通、启动失败清理和正常退出。业务侧补充原有接口兼容、鉴权、事务回滚与实际上传下载回归;框架测试不能代替这些业务验收。
|
||||
|
||||
```sh
|
||||
go mod tidy
|
||||
go-omni api validate
|
||||
go-omni api generate --check
|
||||
go test ./...
|
||||
go build .
|
||||
go run .
|
||||
```
|
||||
|
||||
后续增加接口修改 `.api` 并重新生成;增加依赖修改配置和 svc。将契约、生成文件、manifest 与业务代码一起提交。完整流程不需要项目另外维护启动器,也不需要读取 handler 的私有变量。
|
||||
155
README.md
155
README.md
@@ -1,8 +1,10 @@
|
||||
# GoOmni
|
||||
|
||||
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Service 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
||||
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
||||
|
||||
**从这份 README 开始即可接入。** 当前版本为 **`v0.9.0-preview`**,Go **1.22+**;CLI 和项目依赖使用同一固定版本。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
|
||||
**从这份 README 开始接入。** 当前版本 **`v0.13.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||
|
||||
Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
|
||||
|
||||
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。
|
||||
|
||||
@@ -22,27 +24,26 @@ GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义
|
||||
|
||||
module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可用,并具有仓库读取权限;使用自己的 Git 凭据管理器,不把账号或 Token 写进 module 地址。
|
||||
|
||||
以下命令在同一个终端执行。已有 `GOPRIVATE` 时,把 `git.yuntuo.hk` 追加到原来的逗号分隔列表。
|
||||
安装固定版本的 CLI,后面的命令继续使用同一终端。私有 module 加入 GOPRIVATE,保留已有配置:
|
||||
|
||||
```sh
|
||||
export GOPRIVATE=git.yuntuo.hk
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.9.0-preview
|
||||
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.13.0-preview
|
||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||
go-omni --help
|
||||
```
|
||||
|
||||
如果配置了自定义 `GOBIN`,将那个目录加入 PATH。只引用框架包、不生成项目时可以跳过 CLI 安装,直接使用下文的 `go get`。
|
||||
如果设置过 GOBIN,将该目录加入 PATH。远程 CLI 默认引用自身版本的框架,生成的 go.mod 不需要本地 replace。开发者与 CI 使用同版 CLI;框架源码开发方式见 [CLI 文档](cmd/go-omni/README.md#版本与迁移)。
|
||||
|
||||
## 新项目:生成并运行
|
||||
|
||||
在准备存放业务项目的目录执行,`./hello` 必须是新目录或空目录;把 `example.com/hello` 换成自己的 Go module 名称。
|
||||
|
||||
```sh
|
||||
go-omni api new --framework-version v0.9.0-preview \
|
||||
--module example.com/hello ./hello
|
||||
go-omni api new --module example.com/hello ./hello
|
||||
cd hello
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
go run .
|
||||
```
|
||||
|
||||
另开终端验证:
|
||||
@@ -59,30 +60,30 @@ curl -sS http://127.0.0.1:8080/openapi.json
|
||||
{"code":"OK","message":"success","data":{"message":"Hello Alice"},"requestId":"本次请求编号"}
|
||||
```
|
||||
|
||||
按 Ctrl+C 停止服务。项目依赖已固定为远程版本,不需要克隆框架源码或添加本地 `replace`。
|
||||
按 Ctrl+C 停止服务。进入项目目录即可 `go build .`;该示例不需要数据库或其他外部服务。
|
||||
|
||||
### 生成的项目放什么
|
||||
|
||||
```text
|
||||
hello/
|
||||
├── api/service.api # 维护接口定义,也可 import 其他 .api
|
||||
├── cmd/server/main.go # 启动、路由注册、优雅退出
|
||||
├── main.go # 生成器维护:启动、路由注册、优雅退出
|
||||
├── etc/config.yaml # 项目配置
|
||||
├── internal/
|
||||
│ ├── settings/ # 配置读取
|
||||
│ ├── servicecontext/ # 数据库、日志、外部客户端等依赖
|
||||
│ ├── config/ # 配置读取
|
||||
│ ├── svc/ # 数据库、日志、外部客户端等依赖
|
||||
│ ├── types/typesGen.go # 生成的请求与业务返回类型
|
||||
│ ├── handler/ # 生成的绑定、响应策略、路由及文档
|
||||
│ ├── service/greet/ # 在这里实现业务逻辑
|
||||
│ ├── logic/greet/ # 在这里实现业务逻辑
|
||||
│ ├── repository/greet/ # 在这里实现数据访问
|
||||
│ └── middleware/ # 声明鉴权等中间件后生成对应 hook
|
||||
├── docs/openapi.json # 生成的 OpenAPI 3.0.3
|
||||
└── .goOmni/apiManifest.json # 生成文件所有权与校验信息,一起提交 Git
|
||||
```
|
||||
|
||||
修改 `api/service.api` 后重新生成,业务写在 `internal/service`,数据访问写在 `internal/repository`,依赖在 `internal/servicecontext` 装配。声明业务错误后还会生成 `internal/apierrors/errorsGen.go`。
|
||||
修改 `api/service.api` 后重新生成,业务写在 `internal/logic`,数据访问写在 `internal/repository`,依赖在 `internal/svc` 装配。声明业务错误后还会生成 `internal/apierrors/errorsGen.go`。
|
||||
|
||||
`api new` 的问候接口可以直接运行;自定义接口新生成的 Service 和鉴权 hook 返回 **501**,需要实现后才可用。生成器不会根据 HTTP 字段猜数据库表或业务规则。
|
||||
`api new` 的问候接口可以直接运行;自定义接口新生成的 Logic 和鉴权 hook 返回 **501**,需要实现后才可用。生成器不会根据 HTTP 字段猜数据库表或业务规则。
|
||||
|
||||
### 从自己的接口定义生成
|
||||
|
||||
@@ -91,6 +92,8 @@ hello/
|
||||
```text
|
||||
syntax = "v1"
|
||||
|
||||
@app (defaultResponse: CommonResponse)
|
||||
|
||||
response CommonResponse (
|
||||
body: {"code":"${code}", "msg":"${message}", "data":"${data}"}
|
||||
codeType: int32
|
||||
@@ -126,13 +129,13 @@ service catalog-api {
|
||||
|
||||
```sh
|
||||
go-omni api validate --api catalog.api
|
||||
go-omni new --api catalog.api --framework-version v0.9.0-preview \
|
||||
go-omni new --api catalog.api \
|
||||
--module example.com/catalog ./catalog
|
||||
cd catalog
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
将生成的 `internal/service/item/getItem.go` 替换为下面的演示实现。实际业务中换成 Repository 查询,Service 返回业务数据或错误,不再手工包装公共体:
|
||||
将生成的 `internal/logic/item/getItem.go` 替换为下面的演示实现。实际业务中换成 Repository 查询,Logic 返回业务数据或错误,不再手工包装公共体:
|
||||
|
||||
```go
|
||||
package item
|
||||
@@ -144,7 +147,7 @@ import (
|
||||
"example.com/catalog/internal/types"
|
||||
)
|
||||
|
||||
func (s *Service) GetItem(ctx context.Context, req *types.GetItemReq) (*types.ItemResp, error) {
|
||||
func (s *Logic) GetItem(ctx context.Context, req *types.GetItemReq) (*types.ItemResp, error) {
|
||||
if req.ItemID != "1001" {
|
||||
return nil, apierrors.ItemNotFound()
|
||||
}
|
||||
@@ -156,7 +159,7 @@ func (s *Service) GetItem(ctx context.Context, req *types.GetItemReq) (*types.It
|
||||
|
||||
```sh
|
||||
# catalog 项目目录中启动;先停掉前面的 hello 服务,释放 8080 端口。
|
||||
go run ./cmd/server
|
||||
go run .
|
||||
# 另一个终端:
|
||||
curl -i http://127.0.0.1:8080/api/v1/items/1001
|
||||
curl -i http://127.0.0.1:8080/api/v1/items/missing
|
||||
@@ -167,24 +170,24 @@ curl -i http://127.0.0.1:8080/api/v1/items/missing
|
||||
需要 MySQL、文件存储或文件日志时,**首次生成**可以使用:
|
||||
|
||||
```sh
|
||||
go-omni new --api catalog.api --framework-version v0.9.0-preview \
|
||||
go-omni new --api catalog.api \
|
||||
--module example.com/catalogFull --database mysql --storage local \
|
||||
--log-output file --log-format json ./catalogFull
|
||||
```
|
||||
|
||||
该命令在 `catalog.api` 所在目录执行,目标仍需是新目录。数据库迁移、表结构和 Repository 由项目维护,API 模板不自动建表;MySQL DSN 通过 `GO_OMNI_DB_DSN` 提供。存储还可选 `s3`、`r2`、`oss`,按生成项目的配置说明提供桶、端点和凭据。完整装配例子见 [userService](examples/userService/README.md)。
|
||||
该命令在 `catalog.api` 所在目录执行,目标仍需是新目录。数据库迁移、表结构和 Repository 由项目维护,API 模板不自动建表;MySQL DSN 通过 `GO_OMNI_DB_DSN` 提供。存储还可选 `s3`、`r2`、`oss`,按生成项目的配置说明提供桶、端点和凭据。完整生成项目示例见 [managedService](examples/managedService/README.md)。
|
||||
|
||||
不采用 `.api` 时,也可以用 `go-omni new --framework-version v0.9.0-preview --module example.com/billing ./billing` 创建手写接口的 CRUD 示例项目;两种生成方式选择一种即可。
|
||||
`go-omni new` 与 `go-omni api new` 统一生成契约项目。需要手写项目时,可直接引用框架包;生成器不再提供另一套 CRUD 启动目录。
|
||||
|
||||
## 已有项目:按模块接入
|
||||
|
||||
在业务项目的 **go.mod 所在目录**执行:
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.9.0-preview
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.13.0-preview
|
||||
```
|
||||
|
||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。
|
||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
||||
|
||||
下面是可独立运行的 `main.go` 示例;迁入现有项目时,把路由调用接到自己的业务服务即可。
|
||||
|
||||
@@ -307,11 +310,79 @@ auth UserAuth (
|
||||
|
||||
生成器会创建 `internal/middleware/auth.go`,由项目实现凭据校验和身份注入。声明会生成路由绑定与 OpenAPI 的认证描述,**不会自动实现登录或授权规则**;未实现的 hook 返回 501。手写路由可使用 `auth.RequireAuth(authenticator)` 或自己的中间件。JWT、Session 和 Policy 按需选择。
|
||||
|
||||
业务错误在 `.api` 中用 `error` 声明,通过 `@errors` 关联路由后,生成可调用的错误函数和 OpenAPI 中对应的 401、403、409 等响应。手写路由可以直接返回 `omni.NotFound`、`omni.Forbidden` 等错误。已有项目在自己的适配层转换原错误,内部原因和公开提示分开处理。
|
||||
业务错误可以集中定义,业务只选择发生了哪个错误。**HTTP 状态由项目配置,业务失败可以返回 HTTP 200**;框架不根据 HTTP 状态单独判断成功与否。
|
||||
|
||||
错误目录支持两种维护方式,按项目选择一种:**Go 中定义并用 `omni.WithErrors` 注册一次**,或者在公共 `.api` 中声明并生成调用函数。前者不要求 `.api` 重复声明目录或逐路由列出错误;生成路由默认继承 App 目录。
|
||||
|
||||
采用 `.api` 管理时,其他契约可通过 `import` 引用:
|
||||
|
||||
```text
|
||||
errorCatalog CommonErrors (
|
||||
defaultStatus: 200
|
||||
errors: {
|
||||
"UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400002, "message": "用户名格式不正确"},
|
||||
"PasswordInvalid": {"key": "PASSWORD_INVALID", "code": 400002, "message": "密码格式不正确"},
|
||||
"PermissionDenied": {"key": "PERMISSION_DENIED", "status": 403, "code": 403001, "message": "没有权限"}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
`UsernameInvalid` 是生成函数名;`key` 是唯一逻辑标识;`code` 是项目对外的字符串或整数业务码,可以重复。条目没有填写 `status` 时使用目录的 `defaultStatus`,未配置目录默认值时为 400。这个默认值只作用于该目录的条目,不会把所有框架异常都改成 200。
|
||||
|
||||
在已有公共响应中保留 `codeType: int32` / `int64` / `string`、成功码、未定义错误的兜底码和返回体模板;不再重复填写这几条错误的 `codes` / `messages`。通过 `@server (errors: UsernameInvalid, PasswordInvalid)` 或路由 `@errors UsernameInvalid` 引用它们。
|
||||
|
||||
生成后业务调用:
|
||||
|
||||
```go
|
||||
return TokenResponse{}, apierrors.UsernameInvalid()
|
||||
// 需要保留内部原因时:
|
||||
return TokenResponse{}, apierrors.UsernameInvalid().WithCause(err)
|
||||
```
|
||||
|
||||
生成器同时提供 `internal/apierrors.Catalog()`、错误函数、路由错误目录及 OpenAPI。目录会在 App 中间件执行前绑定到生成路由;无需手动改已经生成的 main。HTTP 200 的错误使用失败响应模板;OpenAPI 用 `anyOf` 同时保留成功 DTO 和错误体,多条错误共用数字码时仍保留各自示例。完整契约见 [errors.api](examples/contracts/errors.api)。
|
||||
|
||||
手写项目可在启动时加载自己的目录,保留原来的响应配置:
|
||||
|
||||
```go
|
||||
// omnierrors 是 git.yuntuo.hk/k2safe/go-omni/errors。
|
||||
catalog := omnierrors.Catalog{
|
||||
DefaultStatus: 200,
|
||||
Definitions: map[string]omnierrors.Definition{
|
||||
"USERNAME_INVALID": {Code: 400002, Message: "用户名格式不正确"},
|
||||
"PASSWORD_INVALID": {Code: 400002, Message: "密码格式不正确"},
|
||||
},
|
||||
}
|
||||
app := omni.New(
|
||||
omni.WithErrors(catalog),
|
||||
omni.WithResponse(response.Config{
|
||||
CodeType: "int32", SuccessCode: 0, ErrorCode: 9999,
|
||||
MessageField: "msg", DataMode: "always",
|
||||
}),
|
||||
)
|
||||
// Service 只返回标准 error,不依赖 App 或 HTTP Context:
|
||||
// return omnierrors.ByCode("USERNAME_INVALID").WithCause(err)
|
||||
```
|
||||
|
||||
目录也可放入 `config.Config.Errors`,由 JSON/YAML 加载;生成项目的路径为 `server.errors`,生成路由只有显式引用错误定义时才使用 `.api` 独立目录,未声明则继承 App。不同 App 独立持有配置,单路由也可通过 `response.WithErrors` 绑定专用目录与自定义 Encoder。直接输出文本、XML、下载或已提交的响应继续保留原协议。
|
||||
|
||||
访问日志保留实际 HTTP 状态,并增加失败标记、逻辑错误标识和目录业务码;指标、链路及框架标准中间件/幂等重放也保留失败语义。未知标识使用系统错误兜底,内部 cause 不写入默认公开响应。旧 `error (...)`、`New` / `BadRequest` / `Codes` / `Messages` 继续支持;新目录与旧映射冲突会报错,避免静默覆盖。
|
||||
|
||||
动态提示也可以集中配置,在目录中声明 `message: "{label}不能超过{max}个字符"` 和有序 `params`(label/string、max/int),生成带类型的调用:
|
||||
|
||||
```go
|
||||
return apierrors.TextTooLong("企业名称", 100)
|
||||
// 最终提示:企业名称不能超过100个字符
|
||||
```
|
||||
|
||||
运行时支持 `omnierrors.ByCode("TEXT_TOO_LONG").WithParams(...)`,与生成函数共用解析器。固定错误继续无参数调用;缺参或类型错误使用系统兜底。模板、参数类型及具体响应示例同步写入 OpenAPI。完整示例见 [errorParams.api](examples/contracts/errorParams.api)。
|
||||
|
||||
更详细的配置、作用域和升级步骤见 [统一错误目录](errors/README.md)。
|
||||
|
||||
### 数据库、存储及其他模块
|
||||
|
||||
依赖在启动入口或项目自己的 `internal/servicecontext` 中创建,通过构造函数传给 Service/Repository,避免每个请求都新建连接池。只注册自己拥有的资源,例如 `app.OnShutdown(db.Close)`;关闭按注册的逆序执行。
|
||||
生成项目在 `internal/svc` 中创建依赖,提供给 Logic/Repository,并用 `c.OnClose(db.Close)` 登记自有资源。生成的 main 在 HTTP 排空后关闭 svc,再关闭日志。直接使用框架的手写项目则可以用 `app.OnShutdown(db.Close)`;不要对同一资源重复登记。
|
||||
|
||||
已有项目使用 `driver.Connector` 时,直接 `database.OpenConnector(ctx, connector, database.DefaultPoolConfig())`。MySQL 等驱动的独立 TLS、Logger、时区与拨号参数由项目构造 Connector;框架负责连接池、启动 Ping、健康检查及事务。无需转成 DSN,也无需注册全局 TLS/Logger。具体示例和资源归属见 [Connector 接入](database/README.md#connector-接入v091-preview)。
|
||||
|
||||
| 能力 | 包与接入说明 |
|
||||
| --- | --- |
|
||||
@@ -332,6 +403,9 @@ API 项目的数据库迁移需要单独维护并显式执行;启动不会自
|
||||
| `GO_OMNI_HTTP_ADDR` | 监听地址;生成项目默认 `127.0.0.1:8080` |
|
||||
| `GO_OMNI_TRANSPORT` | `nethttp`(默认)、`native`、`http2` |
|
||||
| `GO_OMNI_TLS_CERT_FILE` / `GO_OMNI_TLS_KEY_FILE` | 成对设置 TLS 证书和私钥路径 |
|
||||
| `GO_OMNI_HTTP_READ_HEADER_TIMEOUT` / `GO_OMNI_HTTP_READ_TIMEOUT` | 请求头 / 请求读取超时,如 `5s` / `30s` |
|
||||
| `GO_OMNI_HTTP_WRITE_TIMEOUT` / `GO_OMNI_HTTP_IDLE_TIMEOUT` | 响应写入 / Keep-Alive 空闲超时 |
|
||||
| `GO_OMNI_HTTP_MAX_HEADER_BYTES` | 请求头大小限制,字节数 |
|
||||
| `GO_OMNI_SHUTDOWN_TIMEOUT` | 优雅退出等待时间,默认 `10s` |
|
||||
| `GO_OMNI_ENABLE_LOGGER` / `GO_OMNI_LOG_LEVEL` | 访问日志开关、日志级别 |
|
||||
| `GO_OMNI_TRUSTED_PROXIES` | 可解析转发地址头的可信代理 IP/CIDR,逗号分隔 |
|
||||
@@ -340,13 +414,29 @@ API 项目的数据库迁移需要单独维护并显式执行;启动不会自
|
||||
例如在生成项目中切换 native:
|
||||
|
||||
```sh
|
||||
GO_OMNI_TRANSPORT=native go run ./cmd/server
|
||||
GO_OMNI_TRANSPORT=native go run .
|
||||
```
|
||||
|
||||
手写入口可用 `omni.WithTransport(omni.TransportNative)`;TLS 配置为 `omni.WithTLS(certFile, keyFile)`。native 提供 HTTP/1.1、Keep-Alive、TLS、超时、连接/请求并发上限和对象复用;HTTP/2 是单独的实验适配器,必须配置 TLS。默认 nethttp 的 TLS 支持 HTTP/1.1 与 HTTP/2。
|
||||
|
||||
读写超时也可以在生成项目的 `etc/config.yaml` 中声明(放在 `server.http` 下):
|
||||
|
||||
```yaml
|
||||
server:
|
||||
http:
|
||||
readHeaderTimeout: 5s
|
||||
readTimeout: 30s
|
||||
writeTimeout: 30s
|
||||
idleTimeout: 60s
|
||||
maxHeaderBytes: 1048576
|
||||
```
|
||||
|
||||
代码配置使用 `omni.WithHTTPConfig(omni.HTTPConfig{ReadTimeout: 30*time.Second, WriteTimeout: 30*time.Second})`。`Run`、`RunContext`、`ServeContext` 会应用它;`NewServer` 会配置返回的标准 `http.Server`,包含可选的 Go 字段 `ErrorLog`。**正值覆盖、零值保留所选网络层的原有默认行为、负值报错**;不配置时升级行为不变。超时含义与支持范围见 [HTTP 配置](transport/README.md#http-配置v091-preview),不要把网络 I/O 超时当成业务执行时限。
|
||||
|
||||
自研网络层通过 `RunWith(ctx, runner)` 接入监听与连接生命周期,通过 `NewWithRouter(router)` 替换路由;业务 Handler/Service 不需要导入具体适配器。细节与验收契约见 [网络适配层](transport/README.md)。
|
||||
|
||||
生成项目的依赖可调用 `svcCtx.RequestShutdown(err)` 主动申请退出,框架排空请求、清理依赖后保留故障原因并以非零状态退出。健康检查与 OpenAPI 的开关、路径通过 `server.endpoints` 配置,健康响应默认沿用项目公共体;未就绪保持 HTTP 503。完整配置和 v0.12 项目的升级步骤见 [项目指南](PROJECT.md#依赖主动申请退出)。
|
||||
|
||||
`Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。服务先停止接收新请求,等待活跃请求完成,再清理资源;超时会取消请求并关闭连接,业务代码仍需响应 context 取消。
|
||||
|
||||
## 日常开发与升级
|
||||
@@ -362,9 +452,9 @@ go-omni api generate --check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Service、Repository、中间件实现、main、配置和依赖装配属于项目,重复生成保留手写内容;契约改变后仍需自行调整业务签名并通过编译。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 可运行 `generate --check` 检查是否同步。
|
||||
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
|
||||
|
||||
升级时先安装目标版本的 CLI,再在业务 module 中 `go get` **同一版本**;API 项目随后执行上述生成与测试步骤,手写项目只需依赖升级及业务回归。`v0.9.0-preview` 的变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||
CLI 与业务 module 固定使用 `v0.13.0-preview`。新布局不兼容旧 manifest;旧试验项目需在新目录生成并搬入业务文件,不能直接覆盖手写入口。新布局内部升级再执行上述生成与测试步骤,手写框架项目只需依赖升级及业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||
|
||||
## 按需深入
|
||||
|
||||
@@ -372,8 +462,9 @@ go test ./...
|
||||
| --- | --- |
|
||||
| `.api` 类型、参数绑定、错误、鉴权和生成规则 | [API 开发指南](API.md) |
|
||||
| 公共响应体、提示优先级、独立回调协议 | [响应参考](RESPONSE.md) |
|
||||
| CLI 完整参数、本地框架开发和模块脚手架 | [CLI](cmd/go-omni/README.md) |
|
||||
| 完整 API 业务项目 | [userService](examples/userService/README.md) |
|
||||
| CLI 完整参数、本地框架开发与迁移 | [CLI](cmd/go-omni/README.md) |
|
||||
| 当前生成项目:Go 错误目录、上传、回调和下载 | [managedService](examples/managedService/README.md) |
|
||||
| 数据库/事务业务参考(旧目录示例) | [userService](examples/userService/README.md) |
|
||||
| 手写接口分层示例 | [reference](examples/reference/README.md) |
|
||||
| 框架成熟度、历史变更 | [路线图](ROADMAP.md)、[更新记录](CHANGELOG.md) |
|
||||
| 网络性能、测试条件与适用范围 | [性能说明](PERFORMANCE.md)、[HTTP/2 验收](transport/native/HTTP2.md) |
|
||||
|
||||
132
RELEASE.md
132
RELEASE.md
@@ -1,43 +1,123 @@
|
||||
# v0.9.0-preview
|
||||
# v0.13.0-preview
|
||||
|
||||
本版补齐已有 HTTP 项目的迁移接入能力:标准 Handler/Middleware 桥接、动态 JSON、路由级请求体限额与手写绑定、自定义标量转换。业务的响应格式、身份、资金事务和数据库实现仍由项目决定。
|
||||
本版补齐项目依赖主动退出与内置接口配置。ID 租约、数据库或后台任务等故障由项目判断,框架通过统一生命周期完成退出;不内置业务规则。
|
||||
|
||||
## 安装与升级
|
||||
## 使用与升级
|
||||
|
||||
```sh
|
||||
export GOPRIVATE=git.yuntuo.hk
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.9.0-preview
|
||||
# 在业务 module 中:
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.9.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.13.0-preview
|
||||
# 在业务 module 中执行
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.13.0-preview
|
||||
go-omni api generate
|
||||
```
|
||||
|
||||
将私有域名追加到已有 GOPRIVATE;凭据通过本机 Git 凭据管理器配置。CLI 与项目框架依赖一起升级。进入已生成的项目,再运行 `go-omni api generate --dry-run`、`go-omni api generate`、`go test ./...`。
|
||||
新项目自动获得全部扩展。v0.12 的 config/svc 属于项目文件,重新生成会保留它们;需按 [升级步骤](PROJECT.md#从-v012-生成项目升级) 给 svc 补入 Lifecycle、RequestShutdown 与清理衔接,main 和 Handler 由生成器更新。旧 v0.11 目录仍按原迁移规则在新目录生成。
|
||||
|
||||
## 本次变化
|
||||
## 行为
|
||||
|
||||
- `httpx.WrapHandler`、`WrapMiddleware`、`WrapFuncMiddleware` 将现有标准代码接入 App 中间件链。Raw 写入会记录真实状态和提交;已经输出的响应不再追加错误 JSON。下载/流式输出不整体缓存,保留底层支持的 HTTP 可选能力和取消信号。
|
||||
- `.api` 的 `any` / `interface{}` 支持动态 JSON。数字保留为 json.Number,外围 DTO 严格校验不变。
|
||||
- `@body` 按路由声明限额;内置 App 在全局中间件读取前限制 body。`@binding custom`、`@handlerMode manual` 生成保留的项目实现文件;上传、原文验签、下载可自行处理。`@produces` 描述手写成功输出的媒体类型。
|
||||
- `scalar` 显式引用项目 Go 类型,调用标准 JSON/Text 方法;输入和输出各自生成 OpenAPI。普通基础类型不会自动放宽。
|
||||
- `svc.RequestShutdown(err)` 非阻塞通知:停止接收请求、排空、关闭依赖和日志。首个非空故障保留,故障退出码为 1;正常停止在没有其他故障时退出码为 0。初始化期间通知、并发通知和排空期间报告故障均可处理。
|
||||
- App 生命周期入口保留异常 context 取消原因;符合 context.Canceled 的信号取消保持正常停止。生成入口还读取 Controller.Err,保留显式依赖故障,即使该故障包装了 context.Canceled。
|
||||
- `/health/ready` 修复绕过响应配置的问题,默认与 liveness 一起使用项目 App 的响应格式。就绪成功使用项目成功码与提示;未就绪使用 `NOT_READY` 的项目错误定义,同时固定 HTTP 503。raw 模式只返回健康数据;内部探针原因仅供日志。
|
||||
- `server.endpoints` 控制三个内置 GET 接口的开关和路径,健康端点另有 responseMode。支持环境变量覆盖。OpenAPI 保持原始规范,不包业务公共体;关闭 HTTP 端点不影响生成文档。
|
||||
- `.api` 不再固定保留三个默认路径;启用的内置路径与业务 GET/参数/通配路由重叠时,启动前返回明确错误并清理已创建的依赖。关闭或改名后允许项目接管旧路径。全局中间件继续执行。
|
||||
|
||||
详见 [迁移扩展指南](MIGRATION.md)、[完整契约](examples/contracts/compatibility.api)、[运行示例](examples/compatibility/main.go) 和 [历史更新](CHANGELOG.md)。
|
||||
## 验收
|
||||
|
||||
## 兼容与边界
|
||||
|
||||
原有 `.api`、公共响应配置和普通 Handler 可以继续使用。新生成代码最低运行时版本为本版。`Raw()` 现在返回跟踪包装器;直接断言某个具体 writer 类型的代码需改用标准接口/ResponseController。
|
||||
|
||||
标准中间件须保留 incoming request context 并同步调用 next;第三方框架私有的参数读取器仍由项目适配。手写 Handler/绑定 hook 初始返回 501;实现和文档一致性由项目负责,未实现不能上线。切换 manual 回自动模式时须显式迁移并移除对应手写文件,生成器不会删除它。
|
||||
|
||||
新增能力没有引入 Gin 或 go-zero 依赖。不是完整 goctl 语法兼容层,不代表任何业务项目已完成迁移。HTTP/2 仍为实验功能,本轮没有重新做 Linux 长测、代理验收或云存储账号实网验收。
|
||||
|
||||
## 验证入口
|
||||
源码测试和实际生成项目验证记录见 [生命周期与端点验收](performance/lifecycleEndpointsAcceptance.md)。复验命令:
|
||||
|
||||
```sh
|
||||
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
python3 scripts/verifyCompatibilityRelease.py --version v0.9.0-preview \
|
||||
--report performance/compatibilityRemote.json
|
||||
python3 scripts/verifyManagedProject.py --report performance/lifecycleEndpointsLocal.json
|
||||
python3 scripts/verifyManagedRelease.py --version v0.13.0-preview --report performance/lifecycleEndpointsRemote.json
|
||||
```
|
||||
|
||||
测试覆盖旧 Handler 404 与访问日志、提交后错误/panic、标准中间件状态/取消/提前写响应、nethttp/native 实际流式下载与请求体超限、multipart 临时文件清理、自定义标量精度/越界/nullable/集合,以及真实生成项目的验签与重生成保护。远程脚本从全新 module cache 安装 CLI,并检查生成项目的固定版本、校验和以及没有本地 replace。
|
||||
远程验收在发布后执行,报告单独提交,不改写发布标签。此版仍为 preview;具体业务的租约失效保护、鉴权与事务回归由接入项目验证。资源清理顺序不代表能强行结束忽略 context 取消的业务 goroutine;项目 Close 必须停止并等待自己启动的后台任务。
|
||||
|
||||
---
|
||||
|
||||
# v0.12.0-preview(历史版本)
|
||||
|
||||
本版用于固定版本开展业务接入。生成器统一维护服务根目录 main.go、路由、HTTP Handler、DTO 和 OpenAPI,业务方维护 `.api`、config、svc、logic、repository 与项目中间件。目录不再生成 cmd/server、settings、servicecontext 或 service。
|
||||
|
||||
## 安装与接入
|
||||
|
||||
仓库权限及 PATH 配置见 [README](README.md#准备环境与安装)。CLI 与框架使用同一版本:
|
||||
|
||||
```sh
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.12.0-preview
|
||||
go-omni new --module example.com/hello ./hello
|
||||
cd hello
|
||||
go mod tidy
|
||||
go run .
|
||||
```
|
||||
|
||||
已存在的手写项目可直接 `go get git.yuntuo.hk/k2safe/go-omni@v0.12.0-preview`。采用生成目录的旧项目在新目录生成后迁入业务代码,详见 [接入步骤](PROJECT.md#已有服务接入步骤) 和 [文件维护边界](PROJECT.md#哪些文件由用户维护)。新 manifest v2 不兼容旧 manifest;不会静默覆盖手写 main,也不会删除独立 cmd/migrate 等工具。
|
||||
|
||||
## 本版行为
|
||||
|
||||
- 根目录支持 go run / go build;入口负责配置、日志、依赖、HTTP 启动与信号退出。svc 的 AppOptions、GlobalMiddleware 和 OnClose 是显式扩展入口;初始化/监听失败会释放资源,正常退出先排空请求再关闭依赖和日志。
|
||||
- `.api` 入口通过 `@app(defaultResponse: Name)` 选择默认响应,覆盖普通路由、404/405 与相应中间件错误;多模板不会按声明顺序猜默认值。字段、状态、业务码和文案由项目配置。
|
||||
- 保持 v0.11.1 的错误目录继承:Go 中注册一次,无独立错误目录的生成路由继承 App。带参数提示、内部错误链、raw 响应和独立目录保持各自行为。
|
||||
- raw/multipart 上传及 `@produces` 下载可直接编写 Logic,由生成 Handler 完成受限读取、响应和流关闭。损坏 multipart 默认 400,超限 413;项目可以定制公开错误。
|
||||
- 契约变更提示受影响的 Logic、DTO 字段和请求模式;明确的签名/语法/重复方法错误使 `generate --check` 失败。重新生成保留业务文件,main 和其他生成文件的漂移也会被检查。
|
||||
|
||||
## 验收与范围
|
||||
|
||||
发布前全量 race 测试、go vet、go build 和本地实际生成项目进程验收通过。发布后的空缓存远程验收也已通过,记录见 [生成项目验收](performance/managedProjectAcceptance.md) 和 [远程产物报告](performance/managedProjectRemote.json)。报告作为单独提交保存,v0.12.0-preview 标签保持在发布代码提交 `158b8002b355f09237cd12f49d9b66ee78d493f3`,没有改写。复验命令:
|
||||
|
||||
```sh
|
||||
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
python3 scripts/verifyManagedProject.py
|
||||
python3 scripts/verifyManagedRelease.py --version v0.12.0-preview
|
||||
```
|
||||
|
||||
远程验收使用空 module 缓存安装已发布 CLI,生成的业务项目仅依赖同版远程 module,无本地 replace;检查根目录运行/构建、重新生成、错误继承/模板参数、上传下载、nethttp/native 的真实请求与退出信号,以及初始化失败/监听失败/请求排空后的清理。
|
||||
|
||||
本版不代表具体业务项目已完成迁移验收。HTTP/2 仍为实验状态;真实数据库、云账号和业务服务器不属于本轮验收范围。轻量 Logic 检查不能代替 Go 编译器或业务测试。静态 OpenAPI 不执行 Go 代码,因此不会自动列举仅在 Go 中维护的错误目录。请求流校验不能回滚业务提前执行的副作用,关键写入应放在 `body.Complete()` 成功之后。
|
||||
|
||||
---
|
||||
|
||||
# v0.11.1-preview(历史版本)
|
||||
|
||||
修复生成路由使用空错误目录遮蔽 `omni.WithErrors(catalog)` 的问题。错误现在可以只在 Go 中注册一次,`.api` 无需重复定义错误目录或逐条列出错误。
|
||||
|
||||
## 行为
|
||||
|
||||
- 未声明路由错误列表:继承 App 目录,包括带参数提示及项目自定义 INTERNAL_ERROR 兜底。
|
||||
- 显式引用路由错误:继续使用路由独立目录,不与 App 合并。
|
||||
- `@errors none` 清空 `.api` 分组错误列表,随后继承 App。
|
||||
- 错误继承不覆盖 `.api` 的返回格式、RawJSON、手写绑定或请求体限额;全局中间件提前返回、标准桥接与克隆保持一致。
|
||||
- 空 `response.WithErrors` / `MustWithErrors` 返回原 Encoder,既不覆盖 Context 的目录,也不在编码阶段按空目录重新解析错误。
|
||||
|
||||
## 升级
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.11.1-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.1-preview
|
||||
go-omni api generate --dry-run
|
||||
go-omni api generate
|
||||
go test ./...
|
||||
```
|
||||
|
||||
运行时修复也适用于旧 v0.11.0 生成代码,因此升级运行时后即可解除空目录遮蔽。同步升级 CLI 重新生成时不再创建空目录包装;已生成业务文件继续受保护。代码生成最低运行时仍为 v0.11.0-preview。
|
||||
|
||||
接入见 [README](README.md)、[错误目录继承](errors/README.md) 和 [只在 Go 注册错误的契约示例](examples/contracts/errorInheritance.api)。静态 OpenAPI 不执行 Go 配置,保留契约响应结构,不自动列举运行时目录内容。
|
||||
|
||||
## 验收
|
||||
|
||||
复现用例在修复前:普通路由通过,生成/RawJSON 路由和提前返回中间件共 12 项失败,均变成 500/英文系统提示。修复后同一批用例通过。
|
||||
|
||||
```sh
|
||||
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
python3 scripts/verifyErrorInheritanceRelease.py --version v0.11.1-preview
|
||||
python3 scripts/verifyErrorParamsRelease.py --version v0.11.1-preview --report performance/errorInheritanceParamsRemote.json
|
||||
python3 scripts/verifyCompatibilityRelease.py --version v0.11.1-preview --report performance/errorInheritanceCompatibilityRemote.json
|
||||
```
|
||||
|
||||
结果见 [目录继承验收](performance/errorInheritanceAcceptance.md)。本轮未修改 eTax/ePay 业务代码,没有使用业务服务器。
|
||||
|
||||
@@ -138,3 +138,12 @@ go run ./cmd/go-omni api generate --api examples/contracts/responses.api \
|
||||
```
|
||||
|
||||
示例业务方法起初为 501,完成 Service 后就会套用声明的模板。参考 [完整 .api](examples/contracts/responses.api)。验收测试会在临时目录生成真实项目,写入业务方法,执行已生成路由,检查数字错误码、原始 JSON、鉴权错误、路由覆盖与重复生成保护。
|
||||
|
||||
|
||||
## 错误目录与 HTTP 200 失败(v0.10.0-preview)
|
||||
|
||||
错误含义由项目的 `errors.Catalog` 或 `.api errorCatalog` 集中定义,返回字段/结构仍由本页的 response 模板控制。目录提供独立的逻辑标识、对外字符串/整数码、消息、HTTP 状态,使用 `WithErrors` 或生成路由绑定。详见 [统一错误目录](errors/README.md)。
|
||||
|
||||
`response.Result.IsError()` 综合明确失败标记、Err 和 HTTP 状态判断;HTTP 200 的业务错误不会使用成功码/成功提示,仍可选择 `errorBody`。新目录的公开码与提示优先于通用兜底;与同标识 Codes/Messages 冲突会拒绝,不会静默覆盖。没有目录的旧映射保持原有行为。
|
||||
|
||||
自定义 Encoder 可读取 `Code`(逻辑标识)、`BusinessCode`(目录对外码)、Message 与 Err,并返回项目要求的最终状态和媒体类型。不要仅通过 Status 判断成功;内部原因默认只供诊断,不应由 Encoder 直接序列化 Err。RawJSON 的成功输出保持原样,失败继续交给所选错误编码器。
|
||||
|
||||
32
app/app.go
32
app/app.go
@@ -14,6 +14,7 @@ import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/middleware"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
||||
@@ -67,6 +68,7 @@ func configure(options ...config.Option) config.Config {
|
||||
|
||||
func newApp(router httpx.Router, settings config.Config) *App {
|
||||
settings.Response = settings.Response.Clone()
|
||||
settings.Errors = settings.Errors.Clone()
|
||||
encoder, responseErr := response.New(settings.Response)
|
||||
if responseErr != nil {
|
||||
panic(responseErr)
|
||||
@@ -77,6 +79,10 @@ func newApp(router httpx.Router, settings config.Config) *App {
|
||||
}
|
||||
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
||||
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
|
||||
responseEncoder, responseErr = response.WithErrors(settings.Errors, responseEncoder)
|
||||
if responseErr != nil {
|
||||
panic(responseErr)
|
||||
}
|
||||
resolver, err := httpx.NewClientIPResolver(settings.TrustedProxies...)
|
||||
if err != nil {
|
||||
panic("go-omni: " + err.Error())
|
||||
@@ -114,6 +120,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
func (a *App) Config() config.Config {
|
||||
settings := a.config
|
||||
settings.Response = settings.Response.Clone()
|
||||
settings.Errors = settings.Errors.Clone()
|
||||
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
||||
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
|
||||
return settings
|
||||
@@ -156,20 +163,29 @@ func (a *App) Run(address ...string) error {
|
||||
return a.RunContext(ctx, address...)
|
||||
}
|
||||
|
||||
// NewServer creates a plain standard HTTP server around the application.
|
||||
// NewServer creates a plain standard HTTP server with the configured HTTP options.
|
||||
// Invalid HTTP options panic; RunContext/ServeContext return validation errors.
|
||||
// Use RunContext or ServeContext to apply the configured transport and TLS.
|
||||
func (a *App) NewServer(address string) *http.Server {
|
||||
if err := a.config.HTTP.Validate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if address == "" {
|
||||
address = a.config.Address
|
||||
if address == "" {
|
||||
address = ":8080"
|
||||
}
|
||||
}
|
||||
return &http.Server{Addr: address, Handler: a.router}
|
||||
cfg := a.config.HTTP
|
||||
return &http.Server{Addr: address, Handler: a.router,
|
||||
ReadHeaderTimeout: cfg.ReadHeaderTimeout, ReadTimeout: cfg.ReadTimeout,
|
||||
WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout,
|
||||
MaxHeaderBytes: cfg.MaxHeaderBytes, ErrorLog: cfg.ErrorLog}
|
||||
}
|
||||
|
||||
// RunContext serves until the context is canceled, then performs a graceful shutdown.
|
||||
func (a *App) RunContext(ctx context.Context, address ...string) error {
|
||||
func (a *App) RunContext(ctx context.Context, address ...string) (runErr error) {
|
||||
defer func() { runErr = shutdownResult(ctx, runErr) }()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
@@ -191,7 +207,8 @@ func (a *App) RunContext(ctx context.Context, address ...string) error {
|
||||
// RunWith starts a custom server runner while keeping App-owned cleanup in the
|
||||
// same lifecycle. The runner receives the transport-neutral App handler and
|
||||
// owns transport/TLS settings; built-in transport selection is bypassed.
|
||||
func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) error {
|
||||
func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) (runErr error) {
|
||||
defer func() { runErr = shutdownResult(ctx, runErr) }()
|
||||
if runner == nil {
|
||||
return errors.Join(errors.New("go-omni: nil runner"), a.Close())
|
||||
}
|
||||
@@ -204,6 +221,13 @@ func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) err
|
||||
return errors.Join(runner.Run(ctx, a, a.listenAddress(address...)), a.Close())
|
||||
}
|
||||
|
||||
func shutdownResult(ctx context.Context, err error) error {
|
||||
if cause := lifecycle.Cause(ctx); cause != nil && !errors.Is(err, cause) {
|
||||
return errors.Join(err, cause)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) listenAddress(address ...string) string {
|
||||
listenOn := a.config.Address
|
||||
if listenOn == "" {
|
||||
|
||||
178
app/errorCatalog_test.go
Normal file
178
app/errorCatalog_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/metrics"
|
||||
"git.yuntuo.hk/k2safe/go-omni/middleware"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
func TestAppErrorCatalogHTTP200LogsAndReplay(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
logCfg := logx.Default()
|
||||
logCfg.Writer = &logs
|
||||
l, err := logx.New(logCfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logx.SetDefault(l)
|
||||
defer logx.SetDefault(nil)
|
||||
defer l.Close()
|
||||
cat := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"INVALID": {Code: 400002, Message: "项目提示"}, "DENIED": {Status: 403, Code: 9, Message: "no"}}}
|
||||
policy := response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"}
|
||||
app := omni.New(omni.WithErrors(cat), omni.WithResponse(policy), omni.WithLogger(true))
|
||||
defer app.Close()
|
||||
meter := metrics.New()
|
||||
app.Use(meter.Middleware())
|
||||
cat.Definitions["INVALID"] = apierrors.Definition{Status: 500, Code: 1, Message: "mutated"}
|
||||
app.Config().Errors.Definitions["INVALID"] = apierrors.Definition{Status: 500, Code: 2, Message: "mutated"}
|
||||
app.GET("/error", func(c *omni.Context) error {
|
||||
return fmt.Errorf("wrapped: %w", apierrors.ByCode("INVALID").WithCause(errors.New("private")))
|
||||
})
|
||||
app.GET("/direct", func(c *omni.Context) error { c.WriteError(apierrors.ByCode("INVALID")); return nil })
|
||||
app.GET("/bridge", func(c *omni.Context) error { return apierrors.ByCode("INVALID") }, httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) })
|
||||
}))
|
||||
app.GET("/missing", func(c *omni.Context) error { return apierrors.ByCode("MISSING") })
|
||||
app.GET("/denied", func(c *omni.Context) error { return apierrors.ByCode("DENIED") })
|
||||
app.GET("/committed", func(c *omni.Context) error {
|
||||
if err := c.Text(201, "done"); err != nil {
|
||||
return err
|
||||
}
|
||||
return apierrors.ByCode("INVALID")
|
||||
})
|
||||
app.HandleWithResponse("GET", "/callback", response.EncoderFunc(func(r response.Result) (response.Payload, error) {
|
||||
if !r.IsError() || r.BusinessCode != int64(400002) {
|
||||
t.Fatal(r)
|
||||
}
|
||||
return response.Text(202, "ACK"), nil
|
||||
}), func(c *omni.Context) error { return apierrors.ByCode("INVALID") })
|
||||
idem, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Scope: func(*httpx.Context) (string, error) { return "fixture", nil }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := 0
|
||||
app.POST("/idempotent", func(c *omni.Context) error { calls++; return apierrors.ByCode("INVALID") }, idem.Middleware())
|
||||
shared, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Store: &catalogStore{records: map[string]middleware.IdempotencyRecord{}}, Scope: func(*httpx.Context) (string, error) { return "fixture", nil }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.POST("/shared", func(c *omni.Context) error { calls++; return apierrors.ByCode("INVALID") }, shared.Middleware())
|
||||
|
||||
for _, path := range []string{"/error", "/direct", "/bridge", "/idempotent", "/idempotent", "/shared", "/shared"} {
|
||||
method := "GET"
|
||||
if path == "/idempotent" || path == "/shared" {
|
||||
method = "POST"
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(method, path, nil)
|
||||
r.Header.Set("Idempotency-Key", "same")
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `"code":400002`) || !strings.Contains(w.Body.String(), "项目提示") || strings.Contains(w.Body.String(), "private") {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if stats := meter.Snapshot(); stats.Errors != 7 || stats.StatusCounts["200"] != 7 {
|
||||
t.Fatal("lost business failure metrics", stats)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatal("replay executed business twice")
|
||||
}
|
||||
for _, test := range []struct {
|
||||
path string
|
||||
status int
|
||||
body string
|
||||
}{{"/denied", 403, `"code":9`}, {"/missing", 500, `"code":9999`}, {"/committed", 201, "done"}, {"/callback", 202, "ACK"}} {
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("GET", test.path, nil))
|
||||
if w.Code != test.status || !strings.Contains(w.Body.String(), test.body) {
|
||||
t.Fatal(test.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") {
|
||||
var entry map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry["msg"] != "http.request" {
|
||||
continue
|
||||
}
|
||||
if entry["failed"] != true {
|
||||
t.Fatal("lost failure:", entry)
|
||||
}
|
||||
if entry["path"] == "/callback" {
|
||||
if entry["status"] != float64(202) {
|
||||
t.Fatal(entry)
|
||||
}
|
||||
} else if entry["path"] == "/error" || entry["path"] == "/direct" || entry["path"] == "/bridge" || entry["path"] == "/idempotent" || entry["path"] == "/shared" {
|
||||
if entry["status"] != float64(200) || entry["businessCode"] != float64(400002) || entry["errorKey"] != "INVALID" {
|
||||
t.Fatal(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A second application may interpret the same logical identifier differently.
|
||||
app2 := omni.New(omni.WithErrors(apierrors.Catalog{Definitions: map[string]apierrors.Definition{"INVALID": {Status: 409, Code: "conflict", Message: "other"}}}))
|
||||
defer app2.Close()
|
||||
app2.GET("/", func(*omni.Context) error { return apierrors.ByCode("INVALID") })
|
||||
w := httptest.NewRecorder()
|
||||
app2.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
||||
if w.Code != 409 || !strings.Contains(w.Body.String(), "other") {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A serialized store exercises the shared middleware contract, including replay
|
||||
// metadata. No external service or project database is used by this fixture.
|
||||
type catalogStore struct {
|
||||
mu sync.Mutex
|
||||
records map[string]middleware.IdempotencyRecord
|
||||
}
|
||||
|
||||
func (s *catalogStore) Claim(_ context.Context, key, fp, owner string) (middleware.IdempotencyRecord, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if r, ok := s.records[key]; ok {
|
||||
return r, false, nil
|
||||
}
|
||||
r := middleware.IdempotencyRecord{Fingerprint: fp, Owner: owner, State: "running"}
|
||||
s.records[key] = r
|
||||
return r, true, nil
|
||||
}
|
||||
func (s *catalogStore) Complete(_ context.Context, key, owner string, response *middleware.StoredResponse, _ time.Duration) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
r := s.records[key]
|
||||
if r.Owner != owner {
|
||||
return errors.New("wrong owner")
|
||||
}
|
||||
r.State = "failed"
|
||||
if response != nil {
|
||||
r.State = "completed"
|
||||
encoded, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var copy middleware.StoredResponse
|
||||
if err := json.Unmarshal(encoded, ©); err != nil {
|
||||
return err
|
||||
}
|
||||
r.Response = ©
|
||||
}
|
||||
s.records[key] = r
|
||||
return nil
|
||||
}
|
||||
126
app/errorInheritance_test.go
Normal file
126
app/errorInheritance_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
func TestRouteInheritsAppErrorCatalog(t *testing.T) {
|
||||
catalog := apierrors.Catalog{DefaultStatus: 400, Definitions: map[string]apierrors.Definition{
|
||||
"USERNAME_INVALID": {Code: 400002, Message: "用户名格式不正确"},
|
||||
"TEXT_TOO_LONG": {Status: 200, Code: 400003, Message: "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int"}}},
|
||||
"INTERNAL_ERROR": {Status: 500, Code: 9999, Message: "系统错误"},
|
||||
}}
|
||||
format := response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"}
|
||||
app := omni.New(omni.WithErrors(catalog), omni.WithResponse(format))
|
||||
defer app.Close()
|
||||
getError := func(c *httpx.Context) error {
|
||||
key := c.Header("X-Error")
|
||||
if key == "TEXT_TOO_LONG" {
|
||||
return apierrors.ByCode(key).WithParams(map[string]any{"label": "企业名称", "max": 100})
|
||||
}
|
||||
return apierrors.ByCode(key)
|
||||
}
|
||||
app.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
if c.Header("X-Early") == "true" {
|
||||
return getError(c)
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
bridge := httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) })
|
||||
})
|
||||
app.GET("/plain", getError)
|
||||
// This is exactly the wrapper emitted by v0.11.0, not just the new generator.
|
||||
legacy := response.MustWithErrors(apierrors.Catalog{}, response.MustNew(format))
|
||||
app.HandleWithResponse("GET", "/generated", legacy, getError, bridge)
|
||||
app.HandleWithResponse("GET", "/raw", response.RawJSON(legacy), getError, bridge)
|
||||
for _, path := range []string{"/plain", "/generated", "/raw"} {
|
||||
for _, early := range []string{"false", "true"} {
|
||||
for _, test := range []struct {
|
||||
key string
|
||||
status, code int
|
||||
message string
|
||||
}{
|
||||
{"USERNAME_INVALID", 400, 400002, "用户名格式不正确"},
|
||||
{"TEXT_TOO_LONG", 200, 400003, "企业名称不能超过100个字符"},
|
||||
{"MISSING", 500, 9999, "系统错误"},
|
||||
} {
|
||||
t.Run(path+"/"+early+"/"+test.key, func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
r.Header.Set("X-Early", early)
|
||||
r.Header.Set("X-Error", test.key)
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &value); err != nil {
|
||||
t.Fatal(err, w.Body.String())
|
||||
}
|
||||
if w.Code != test.status || value["code"] != float64(test.code) || value["msg"] != test.message {
|
||||
t.Fatal(w.Code, value)
|
||||
}
|
||||
if data, ok := value["data"]; !ok || data != nil {
|
||||
t.Fatal("data:null lost", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitRouteErrorCatalogRemainsIndependent(t *testing.T) {
|
||||
format := response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"}
|
||||
global := apierrors.Catalog{DefaultStatus: 400, Definitions: map[string]apierrors.Definition{
|
||||
"INVALID": {Code: 400002, Message: "全局提示"}, "APP_ONLY": {Code: 400003, Message: "全局独有"},
|
||||
"INTERNAL_ERROR": {Status: 500, Code: 9999, Message: "全局系统错误"},
|
||||
}}
|
||||
local := apierrors.Catalog{DefaultStatus: 409, Definitions: map[string]apierrors.Definition{
|
||||
"INVALID": {Code: 409001, Message: "路由提示"}, "INTERNAL_ERROR": {Status: 500, Code: 9998, Message: "路由系统错误"},
|
||||
}}
|
||||
app := omni.New(omni.WithErrors(global), omni.WithResponse(format))
|
||||
defer app.Close()
|
||||
getError := func(c *httpx.Context) error { return apierrors.ByCode(c.Header("X-Error")) }
|
||||
app.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
if c.Header("X-Early") == "true" {
|
||||
return getError(c)
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
routeFormat := format
|
||||
routeFormat.MessageField = "detail"
|
||||
localEncoder := response.MustWithErrors(local, response.MustNew(routeFormat))
|
||||
app.HandleWithResponse("GET", "/", response.RawJSON(localEncoder), getError)
|
||||
for _, early := range []string{"false", "true"} {
|
||||
for _, test := range []struct {
|
||||
key string
|
||||
status, code int
|
||||
message string
|
||||
}{
|
||||
{"INVALID", 409, 409001, "路由提示"}, {"APP_ONLY", 500, 9998, "路由系统错误"},
|
||||
} {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("X-Early", early)
|
||||
r.Header.Set("X-Error", test.key)
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != test.status || value["code"] != float64(test.code) || value["detail"] != test.message || value["msg"] != nil {
|
||||
t.Fatal(w.Code, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
186
app/errorParams_test.go
Normal file
186
app/errorParams_test.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/metrics"
|
||||
"git.yuntuo.hk/k2safe/go-omni/middleware"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
func TestTemplateErrorsAcrossPoliciesAndReplay(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
lc := logx.Default()
|
||||
lc.Writer = &logs
|
||||
logger, err := logx.New(lc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logx.SetDefault(logger)
|
||||
defer logx.SetDefault(nil)
|
||||
defer logger.Close()
|
||||
cat := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"TOO_LONG": {
|
||||
Code: 400002, Message: "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int"}},
|
||||
}}}
|
||||
registry := apierrors.MustRegistry(cat)
|
||||
encoder := response.MustWithErrors(cat, response.MustNew(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"}))
|
||||
app := omni.New(omni.WithLogger(true))
|
||||
defer app.Close()
|
||||
meter := metrics.New()
|
||||
app.Use(meter.Middleware())
|
||||
// The clone uses the route policy even though the App has a different default.
|
||||
app.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
err := next(c)
|
||||
if f := c.Failure(); f != nil && strings.Contains(f.Message, "{label}") {
|
||||
t.Error("unrendered failure", f)
|
||||
}
|
||||
return err
|
||||
}
|
||||
})
|
||||
bridge := httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) })
|
||||
})
|
||||
calls := 0
|
||||
handler := func(c *httpx.Context) error {
|
||||
calls++
|
||||
values := map[string]any{"label": "企业名称", "max": 100}
|
||||
if c.Header("X-Label") == "remark" {
|
||||
values = map[string]any{"label": "备注", "max": 500}
|
||||
}
|
||||
switch c.Header("X-Failure") {
|
||||
case "missing":
|
||||
delete(values, "max")
|
||||
case "type":
|
||||
values["max"] = "private-value"
|
||||
}
|
||||
e := apierrors.ByCode("TOO_LONG").WithParams(values).WithCause(errors.New("private cause"))
|
||||
if c.Header("X-Resolved") == "true" {
|
||||
return registry.Resolve(e)
|
||||
}
|
||||
return e
|
||||
}
|
||||
app.HandleWithResponse("POST", "/direct", encoder, handler, bridge)
|
||||
for _, shared := range []bool{false, true} {
|
||||
cfg := middleware.IdempotencyConfig{Scope: func(*httpx.Context) (string, error) { return "fixture", nil }}
|
||||
path := "/local"
|
||||
if shared {
|
||||
path = "/shared"
|
||||
cfg.Store = &catalogStore{records: map[string]middleware.IdempotencyRecord{}}
|
||||
}
|
||||
idem, err := middleware.NewIdempotency(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.HandleWithResponse("POST", path, encoder, handler, idem.Middleware(), bridge)
|
||||
}
|
||||
check := func(path, label, key, kind string, status int, want string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", path, nil)
|
||||
r.Header.Set("Idempotency-Key", key)
|
||||
r.Header.Set("X-Label", label)
|
||||
r.Header.Set("X-Failure", kind)
|
||||
r.Header.Set("X-Resolved", "true")
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Code != status || !strings.Contains(w.Body.String(), want) || strings.Contains(w.Body.String(), "private") || strings.Contains(w.Body.String(), "{label}") {
|
||||
t.Fatal(path, w.Code, w.Body.String())
|
||||
}
|
||||
if status == 200 && (!strings.Contains(w.Body.String(), `"data":null`) || !strings.Contains(w.Body.String(), `"code":400002`)) {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
return w
|
||||
}
|
||||
for _, path := range []string{"/direct", "/local", "/shared"} {
|
||||
for _, label := range []string{"company", "remark"} {
|
||||
want := "企业名称不能超过100个字符"
|
||||
if label == "remark" {
|
||||
want = "备注不能超过500个字符"
|
||||
}
|
||||
first := check(path, label, label, "", 200, want)
|
||||
if path != "/direct" {
|
||||
before := calls
|
||||
second := check(path, label, label, "", 200, want)
|
||||
if calls != before || first.Body.String() != second.Body.String() || second.Header().Get("Idempotency-Replayed") != "true" {
|
||||
t.Fatal("bad replay")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
check("/direct", "company", "", "missing", 500, "internal server error")
|
||||
check("/direct", "company", "", "type", 500, "internal server error")
|
||||
stats := meter.Snapshot()
|
||||
if stats.Errors != 12 || stats.StatusCounts["200"] != 10 || stats.StatusCounts["500"] != 2 {
|
||||
t.Fatal(stats)
|
||||
}
|
||||
seen, diagnostics := 0, 0
|
||||
for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") {
|
||||
var entry map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry["msg"] != "http.request" {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
if entry["failed"] != true {
|
||||
t.Fatal(entry)
|
||||
}
|
||||
if entry["status"] == float64(200) && (entry["errorKey"] != "TOO_LONG" || entry["businessCode"] != float64(400002)) {
|
||||
t.Fatal(entry)
|
||||
}
|
||||
if entry["errorParameter"] != nil {
|
||||
diagnostics++
|
||||
if strings.Contains(entry["errorParameter"].(string), "private-value") {
|
||||
t.Fatal(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
if seen != 12 || diagnostics != 2 {
|
||||
t.Fatal(seen, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedFailureReplaySurvivesCatalogChanges(t *testing.T) {
|
||||
store := &catalogStore{records: map[string]middleware.IdempotencyRecord{}}
|
||||
var observed []*apierrors.APIError
|
||||
makeApp := func(message string) *omni.App {
|
||||
cat := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"T": {Code: "code", Message: message, Params: []apierrors.Parameter{{Name: "label", Type: "string"}}}}}
|
||||
app := omni.New(omni.WithErrors(cat))
|
||||
app.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error { err := next(c); observed = append(observed, c.Failure()); return err }
|
||||
})
|
||||
idem, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Store: store, Scope: func(*httpx.Context) (string, error) { return "fixture", nil }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.POST("/", func(c *httpx.Context) error {
|
||||
return apierrors.ByCode("T").WithParams(map[string]any{"label": "企业"})
|
||||
}, idem.Middleware())
|
||||
return app
|
||||
}
|
||||
for _, message := range []string{"{label}原文案", "{label}新文案"} {
|
||||
app := makeApp(message)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/", nil)
|
||||
r.Header.Set("Idempotency-Key", "same")
|
||||
app.ServeHTTP(w, r)
|
||||
app.Close()
|
||||
if !strings.Contains(w.Body.String(), "企业原文案") {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
}
|
||||
if len(observed) != 2 || observed[0].Message != "企业原文案" || observed[1].Message != "企业原文案" || observed[1].Code != "T" {
|
||||
t.Fatal(observed)
|
||||
}
|
||||
}
|
||||
119
app/fatalShutdown_test.go
Normal file
119
app/fatalShutdown_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
)
|
||||
|
||||
func TestFatalShutdownDrainsAndPreservesCause(t *testing.T) {
|
||||
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
app := New(config.WithTransport(transport), config.WithShutdownTimeout(2*time.Second))
|
||||
control := lifecycle.New(context.Background())
|
||||
defer control.RequestShutdown(nil)
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
defer unblock()
|
||||
var closed atomic.Int32
|
||||
closeErr := errors.New("cleanup diagnostic")
|
||||
app.OnShutdown(func() error { closed.Add(1); return closeErr })
|
||||
app.GET("/work", func(c *httpx.Context) error {
|
||||
close(entered)
|
||||
<-release
|
||||
if closed.Load() != 0 {
|
||||
return errors.New("closed during request")
|
||||
}
|
||||
return c.OK("done")
|
||||
})
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- app.ServeContext(control.Context(), listener) }()
|
||||
requestDone := make(chan error, 1)
|
||||
client := http.Client{Timeout: 3 * time.Second}
|
||||
go func() {
|
||||
res, err := client.Get("http://" + listener.Addr().String() + "/work")
|
||||
if err == nil {
|
||||
_, err = io.Copy(io.Discard, res.Body)
|
||||
_ = res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
err = errors.New("request failed")
|
||||
}
|
||||
}
|
||||
requestDone <- err
|
||||
}()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("request never started")
|
||||
}
|
||||
fatal := errors.New("lease lost")
|
||||
control.RequestShutdown(fatal)
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatal("did not drain", err)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
if closed.Load() != 0 {
|
||||
t.Fatal("closed dependency before drain")
|
||||
}
|
||||
unblock()
|
||||
select {
|
||||
case err := <-requestDone:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("request stuck")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, fatal) || !errors.Is(err, closeErr) {
|
||||
t.Fatal("lost cause", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("shutdown stuck")
|
||||
}
|
||||
_ = app.Close()
|
||||
if closed.Load() != 1 {
|
||||
t.Fatal("cleanup repeated")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanceledStartupPreservesFault(t *testing.T) {
|
||||
fatal := errors.New("dependency failed during startup")
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
cancel(fatal)
|
||||
app := New()
|
||||
if err := app.RunContext(ctx); !errors.Is(err, fatal) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = New().ServeContext(ctx, listener); !errors.Is(err, fatal) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normal, stop := context.WithCancel(context.Background())
|
||||
stop()
|
||||
if err = New().RunContext(normal); err != nil {
|
||||
t.Fatal("normal stop failed", err)
|
||||
}
|
||||
}
|
||||
199
app/httpConfig_test.go
Normal file
199
app/httpConfig_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
)
|
||||
|
||||
func TestHTTPConfiguredStandardServer(t *testing.T) {
|
||||
cfg := omni.HTTPConfig{ReadHeaderTimeout: time.Second, ReadTimeout: 2 * time.Second, WriteTimeout: 3 * time.Second, IdleTimeout: 4 * time.Second, MaxHeaderBytes: 8192, ErrorLog: log.New(io.Discard, "", 0)}
|
||||
a := omni.New(omni.WithHTTPConfig(cfg))
|
||||
s := a.NewServer(":9000")
|
||||
if s.ReadHeaderTimeout != cfg.ReadHeaderTimeout || s.ReadTimeout != cfg.ReadTimeout || s.WriteTimeout != cfg.WriteTimeout || s.IdleTimeout != cfg.IdleTimeout || s.MaxHeaderBytes != cfg.MaxHeaderBytes || s.ErrorLog != cfg.ErrorLog || s.Addr != ":9000" {
|
||||
t.Fatalf("server options: %+v", s)
|
||||
}
|
||||
s = omni.New().NewServer("")
|
||||
if s.ReadHeaderTimeout != 0 || s.ReadTimeout != 0 || s.WriteTimeout != 0 || s.IdleTimeout != 0 || s.MaxHeaderBytes != 0 || s.ErrorLog != nil {
|
||||
t.Fatal("default behavior changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPInvalidStartupReleasesResources(t *testing.T) {
|
||||
a := omni.New(omni.WithHTTPConfig(omni.HTTPConfig{ReadTimeout: -time.Second}))
|
||||
called := 0
|
||||
cleanupErr := errors.New("cleanup")
|
||||
a.OnShutdown(func() error { called++; return cleanupErr })
|
||||
err := a.RunContext(context.Background(), "bad address")
|
||||
if err == nil || !strings.Contains(err.Error(), "http.readTimeout") || !errors.Is(err, cleanupErr) || called != 1 {
|
||||
t.Fatalf("validation/cleanup: %v, %d", err, called)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("NewServer accepted invalid config")
|
||||
}
|
||||
}()
|
||||
a.NewServer("")
|
||||
}
|
||||
|
||||
type httpConfigListener struct{ net.Listener }
|
||||
|
||||
func (l httpConfigListener) Accept() (net.Conn, error) {
|
||||
c, err := l.Listener.Accept()
|
||||
if err == nil {
|
||||
if tcp, ok := c.(*net.TCPConn); ok {
|
||||
_ = tcp.SetWriteBuffer(32 << 10)
|
||||
}
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
func httpConfigConnection(t *testing.T, transport string, cfg omni.HTTPConfig, handler omni.Handler) net.Conn {
|
||||
t.Helper()
|
||||
a := omni.New(omni.WithTransport(transport), omni.WithHTTPConfig(cfg), omni.WithShutdownTimeout(time.Second))
|
||||
a.GET("/", handler)
|
||||
a.POST("/", handler)
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.ServeContext(ctx, httpConfigListener{l}) }()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Errorf("server shutdown: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Error("server did not stop")
|
||||
}
|
||||
})
|
||||
c, err := net.DialTimeout("tcp", l.Addr().String(), time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { c.Close() })
|
||||
_ = c.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
return c
|
||||
}
|
||||
|
||||
func TestHTTPConfiguredNetworkLimits(t *testing.T) {
|
||||
for _, transport := range []string{omni.TransportNetHTTP, omni.TransportNative} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
empty := func(c *omni.Context) error { w := c.Raw(); w.WriteHeader(204); return nil }
|
||||
t.Run("slowHeaders", func(t *testing.T) {
|
||||
c := httpConfigConnection(t, transport, omni.HTTPConfig{ReadHeaderTimeout: 60 * time.Millisecond}, empty)
|
||||
if _, err := io.WriteString(c, "GET / HTTP/1.1\r\nHost: localhost\r\nX-Incomplete:"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := io.ReadAll(c)
|
||||
if e, ok := err.(net.Error); ok && e.Timeout() {
|
||||
t.Fatal("server read-header timeout not applied")
|
||||
}
|
||||
})
|
||||
t.Run("slowBody", func(t *testing.T) {
|
||||
var accepted atomic.Bool
|
||||
c := httpConfigConnection(t, transport, omni.HTTPConfig{ReadTimeout: 60 * time.Millisecond}, func(c *omni.Context) error {
|
||||
_, err := io.ReadAll(c.Request().Body)
|
||||
accepted.Store(err == nil)
|
||||
return nil
|
||||
})
|
||||
if _, err := io.WriteString(c, "POST / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: 10\r\n\r\nx"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := io.ReadAll(c)
|
||||
if e, ok := err.(net.Error); ok && e.Timeout() {
|
||||
t.Fatal("read timeout not applied")
|
||||
}
|
||||
if accepted.Load() {
|
||||
t.Fatal("incomplete body accepted")
|
||||
}
|
||||
})
|
||||
t.Run("idle", func(t *testing.T) {
|
||||
c := httpConfigConnection(t, transport, omni.HTTPConfig{IdleTimeout: 60 * time.Millisecond}, empty)
|
||||
if _, err := io.WriteString(c, "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := http.ReadResponse(bufio.NewReader(c), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Body.Close()
|
||||
if r.StatusCode != 204 {
|
||||
t.Fatal(r.StatusCode)
|
||||
}
|
||||
_, err = c.Read(make([]byte, 1))
|
||||
if e, ok := err.(net.Error); ok && e.Timeout() {
|
||||
t.Fatal("idle timeout not applied")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("idle connection not closed")
|
||||
}
|
||||
})
|
||||
t.Run("writeBackpressure", func(t *testing.T) {
|
||||
done := make(chan error, 1)
|
||||
c := httpConfigConnection(t, transport, omni.HTTPConfig{WriteTimeout: 60 * time.Millisecond}, func(c *omni.Context) error {
|
||||
w := c.Raw()
|
||||
w.WriteHeader(200)
|
||||
if err := http.NewResponseController(w).Flush(); err != nil {
|
||||
done <- err
|
||||
return nil
|
||||
}
|
||||
_, err := io.Copy(w, io.LimitReader(repeatingHTTPReader{}, 64<<20))
|
||||
done <- err
|
||||
return nil
|
||||
})
|
||||
if tcp, ok := c.(*net.TCPConn); ok {
|
||||
_ = tcp.SetReadBuffer(1024)
|
||||
}
|
||||
if _, err := io.WriteString(c, "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("write unexpectedly completed")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("write timeout not applied")
|
||||
}
|
||||
})
|
||||
t.Run("headerLimit", func(t *testing.T) {
|
||||
c := httpConfigConnection(t, transport, omni.HTTPConfig{MaxHeaderBytes: 1024}, empty)
|
||||
if _, err := io.WriteString(c, "GET / HTTP/1.1\r\nHost: localhost\r\nX-Large: "+strings.Repeat("a", 8192)+"\r\n\r\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := http.ReadResponse(bufio.NewReader(c), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Body.Close()
|
||||
if r.StatusCode != 431 {
|
||||
t.Fatalf("oversized header status: %d", r.StatusCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type repeatingHTTPReader struct{}
|
||||
|
||||
func (repeatingHTTPReader) Read(b []byte) (int, error) {
|
||||
for i := range b {
|
||||
b[i] = 'x'
|
||||
}
|
||||
return len(b), nil
|
||||
}
|
||||
@@ -30,6 +30,9 @@ func (a *App) adaptResponse(encoder response.Encoder, handler httpx.Handler, mid
|
||||
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), a.clientIPResolver))
|
||||
}
|
||||
requestContext := httpx.NewContext(request, writer, httpx.RouteParams(request.Context()))
|
||||
if provider, ok := a.responseEncoder.(response.ErrorRegistryProvider); ok {
|
||||
requestContext.SetErrorRegistry(provider.ErrorRegistry())
|
||||
}
|
||||
requestContext.SetResponseEncoder(encoder)
|
||||
defer func() { httpx.CleanupMultipart(requestContext.Request()) }()
|
||||
if err := wrapped(requestContext); err != nil {
|
||||
|
||||
@@ -17,7 +17,8 @@ import (
|
||||
// ServeContext serves a caller-created listener using the configured transport
|
||||
// and TLS. App takes ownership of the listener, including on startup failure,
|
||||
// and runs resource cleanup after the server has stopped.
|
||||
func (a *App) ServeContext(ctx context.Context, listener net.Listener) error {
|
||||
func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr error) {
|
||||
defer func() { runErr = shutdownResult(ctx, runErr) }()
|
||||
if listener == nil {
|
||||
return errors.Join(errors.New("go-omni: nil listener"), a.Close())
|
||||
}
|
||||
@@ -57,6 +58,22 @@ func (a *App) prepareTransport() (func(context.Context, net.Listener) error, err
|
||||
case config.TransportNative:
|
||||
server := native.New(a)
|
||||
server.ShutdownTimeout = shutdownTimeout
|
||||
cfg := a.config.HTTP
|
||||
if cfg.ReadHeaderTimeout > 0 {
|
||||
server.ReadHeaderTimeout = cfg.ReadHeaderTimeout
|
||||
}
|
||||
if cfg.ReadTimeout > 0 {
|
||||
server.ReadTimeout = cfg.ReadTimeout
|
||||
}
|
||||
if cfg.WriteTimeout > 0 {
|
||||
server.WriteTimeout = cfg.WriteTimeout
|
||||
}
|
||||
if cfg.IdleTimeout > 0 {
|
||||
server.IdleTimeout = cfg.IdleTimeout
|
||||
}
|
||||
if cfg.MaxHeaderBytes > 0 {
|
||||
server.MaxHeaderBytes = cfg.MaxHeaderBytes
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
return func(ctx context.Context, listener net.Listener) error {
|
||||
return server.ServeTLS(ctx, listener, tlsConfig)
|
||||
@@ -66,6 +83,12 @@ func (a *App) prepareTransport() (func(context.Context, net.Listener) error, err
|
||||
case config.TransportHTTP2:
|
||||
server := h2transport.New(a)
|
||||
server.ShutdownTimeout = shutdownTimeout
|
||||
if a.config.HTTP.IdleTimeout > 0 {
|
||||
server.IdleTimeout = a.config.HTTP.IdleTimeout
|
||||
}
|
||||
if a.config.HTTP.MaxHeaderBytes > 0 {
|
||||
server.MaxHeaderBytes = a.config.HTTP.MaxHeaderBytes
|
||||
}
|
||||
return func(ctx context.Context, listener net.Listener) error {
|
||||
return server.ServeTLS(ctx, listener, tlsConfig)
|
||||
}, nil
|
||||
|
||||
@@ -1,156 +1,63 @@
|
||||
# go-omni CLI
|
||||
|
||||
首次接入见 [README](../../README.md),无需克隆框架源码。本页说明命令参数;下方 `go run ./cmd/go-omni` 示例在框架仓库中执行,并显式引用本地源码。
|
||||
新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。
|
||||
|
||||
## API 契约驱动项目
|
||||
## 创建项目
|
||||
|
||||
```sh
|
||||
# 创建包含可运行问候接口的新项目
|
||||
go run ./cmd/go-omni api new --module example.com/demo --framework-dir . ../demo
|
||||
# 仓库读取权限与 PATH 配置见根目录 README
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.13.0-preview
|
||||
go-omni new --module example.com/hello ./hello
|
||||
|
||||
# 从已有定义生成对应目录、类型、路由和业务骨架
|
||||
go run ./cmd/go-omni api generate --api examples/contracts/user.api \
|
||||
--dir ../userService --module example.com/userService --framework-dir .
|
||||
|
||||
# 校验定义;也接受 goctl 风格的 api go -api ... -dir ...
|
||||
go run ./cmd/go-omni api validate --api examples/contracts/user.api
|
||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||
go-omni new --api service.api --module example.com/example ./example
|
||||
```
|
||||
|
||||
`new --api <file>` 是契约项目的另一个入口。可以组合 `--database mysql --storage local --log-output file`,生成配置、依赖初始化与资源关闭。API 默认无数据库、无对象存储;`--database memory` 等同于不打开连接。`--force` 不允许覆盖 API 项目。初始配置选择写入 manifest,重新生成自动沿用。
|
||||
`new` 与 `api new` 统一生成契约项目;不再提供另一套 CRUD 启动目录。未实现的 Logic / 中间件骨架返回 501。进入项目执行 `go mod tidy`、`go run .`、`go build .`。
|
||||
|
||||
后续在生成项目内执行 `go-omni api generate --api api/user.api --dir .`。
|
||||
项目内可省略 `--api` / `--dir`。`api inspect` 展示路由和鉴权/业务错误,`api export` 向 stdout 导出 OpenAPI,`api generate --dry-run` 预览文件变化。`--check` 只读检查生成产物是否过期。生成器记录所有权,保留 Service / Repository / 配置 / main 的手写内容,遇到生成文件被手动修改则报冲突。新 Service 与中间件占位实现返回 501。支持 `error (...)`、具名 `auth`、路由 `@errors` / `@auth` 和多行 `@doc`;错误目录同步生成 `internal/apierrors` 调用函数。
|
||||
参数:`--database none|memory|mysql`(默认 none;memory 等同 none),`--storage none|local|s3|r2|oss`,`--log-output stdout|stderr|file`,`--log-format json|text`。基础设施按需生成,连接凭据由环境提供,不自动创建数据库、表或云资源。选择 MySQL 不会凭空生成业务表或 Repository 查询。
|
||||
|
||||
支持范围、参数语义、目录及安全更新策略见 [API 开发指南](../../API.md)。本版借鉴 go-zero 的工作流,不宣称兼容全部 goctl 语法;仍通过 Omni/httpx 保留网络层替换能力。
|
||||
存储配置和客户端位于 config/svc,业务使用统一接口。local 存储签名端点由生成器接入;上传、下载等业务路由由 `.api` 定义。认证与存储路径由项目实现。
|
||||
|
||||
## 日志与对象存储按需装配
|
||||
|
||||
新增 `--log-output stdout|stderr|file`、`--log-format json|text`、`--storage none|local|s3|r2|oss`。
|
||||
指定任意一个新选项后,生成严格的配置文件、初始化代码和项目自身的测试;不指定则保留原有轻量骨架。
|
||||
对象存储独立于 `--database memory|mysql`。只有选择文件存储才会生成文件 Handler / Service 和对应适配器。
|
||||
|
||||
开发阶段可以使用本地源码;固定远程版本的安装和验收见 [发布说明](../../RELEASE.md):
|
||||
## 日常生成
|
||||
|
||||
```sh
|
||||
# 在 go-omni 仓库中运行;目标目录须为空。
|
||||
go run ./cmd/go-omni new --framework-dir . \
|
||||
--module example.com/fileService --log-output file --log-format json \
|
||||
--storage local ../fileService
|
||||
go-omni api validate
|
||||
go-omni api inspect
|
||||
go-omni api generate --dry-run
|
||||
go-omni api generate
|
||||
go-omni api generate --check
|
||||
go-omni api export > openapi.json
|
||||
go test ./...
|
||||
```
|
||||
|
||||
生成项目包含 `etc/config.yaml`、`internal/settings`、`internal/servicecontext/storage.go`、
|
||||
文件 Service/Handler、测试和 `MODULES.md`。按生成的 MODULES.md 配置示例 Token 和本地签名密钥后,
|
||||
`go mod tidy && go run ./cmd/server` 即可体验。云存储分别选择 `--storage s3`、`r2`、`oss`,
|
||||
再设置已有桶/凭据等环境变量;不自动创建桶或修改云资源。
|
||||
项目内自动发现 manifest 中的 API 入口,也可显式 `--api ... --dir ...`。`api go -api ... -dir ...` 为命令别名,不代表兼容全部 goctl 语法。
|
||||
|
||||
配置优先级为默认值 → YAML/JSON → 环境变量;密钥仅允许环境变量。Service 依赖统一存储接口,
|
||||
组合处负责创建客户端与有界连接池,退出时关闭自己创建的资源。文件接口提供服务端上传、直传签名、
|
||||
临时下载链接及统一错误处理;日志示例携带 requestId,文件输出默认带轮转。没有固定业务身份上下文。
|
||||
main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节保留。手改生成文件会报冲突,`--check` 检查入口与所有生成产物。没有 `--force` 覆盖业务的入口。初始基础设施 profile 在重新生成时沿用,后续项目依赖由用户修改 config/svc。
|
||||
|
||||
新模板要求 `v0.7.0-preview` 或更新版本;明确指定旧版本并启用新模块会被拒绝,
|
||||
避免模板引用旧框架不存在的接口。本地 `--framework-dir` 验证不受此限制。`--force` 可重新生成兼容配置,
|
||||
但如果切换配置会遗留引用已移除依赖的旧文件,则拒绝操作,要求使用新目录;不会自动删除修改过的代码。
|
||||
生成/预览会比较上次契约快照,提示受影响的 Logic 文件、DTO 字段和 body/流式模式变化。明确的 Logic 签名错误显示文件行号、原签名和期望签名;生成后未调整业务时,`--check` 仍返回失败。REVIEW 不强制确认,类型别名/build tags 等不确定情况交给 `go test ./...` 验证。完整行为见 [契约变更提示](../../PROJECT.md#契约变更提示)。
|
||||
|
||||
验证当前生成器:
|
||||
`@app(defaultResponse: TemplateName)` 选择 App 默认响应,路由/分组可以覆盖。错误表可以只在 Go 中维护,通过 svc.AppOptions 注册一次。上传和原始 body 的自动 Logic 参数、下载输出见 [项目指南](../../PROJECT.md)。
|
||||
|
||||
## 版本与迁移
|
||||
|
||||
CLI 与框架依赖固定使用 v0.13.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
|
||||
```sh
|
||||
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./cmd/go-omni/...
|
||||
python3 scripts/verifyScaffoldModules.py
|
||||
# 在 go-omni 源码仓库中执行
|
||||
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
||||
```
|
||||
|
||||
前者覆盖两种数据库与五种文件存储选择、日志方式/格式,以及生成项目的配置、资源关闭和接口测试。
|
||||
后者实际编译启动服务,验证日志、上传/下载/直传链接、错误状态、nethttp/native 下的 SIGINT/SIGTERM 和监听端口释放。
|
||||
云端选项的测试使用本地替身,不代表 OSS/R2 实网验收;本批不访问业务服务器。报告见
|
||||
[脚手架模块验收](../../performance/scaffoldModulesLocal.json)。
|
||||
新模板需要 v0.13.0-preview 的依赖退出与端点配置能力,继续使用 manifest v2。v0.12 项目需按 [升级步骤](../../PROJECT.md#从-v012-生成项目升级) 补入 svc 生命周期字段和方法,生成器保留这些项目文件。不兼容旧目录,旧生成项目请在新目录重新生成后移入业务代码,不覆盖手写 main。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
|
||||
## 其他命令与远程安装
|
||||
## 数据库迁移命令
|
||||
|
||||
main 分支还提供 `go-omni add orderItems --dir /path/to/project`,向已有 GoOmni 项目增加
|
||||
Handler / Service / Repository / Model 与模块注册入口。模块名为 lowerCamelCase,
|
||||
目标目录存在时拒绝覆盖,已有启动代码保持原样。生成模块带可替换的内存 CRUD Repository,
|
||||
导入后使用 `orderItems.Register(application, repository, authMiddleware)` 注册。
|
||||
该命令从 `v0.7.0-preview` 提供;源码可用 `go build -o /tmp/go-omni ./cmd/go-omni` 构建。
|
||||
边界和验证方式见 [业务基础模块](../../MODULES.md)。
|
||||
|
||||
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
|
||||
|
||||
```sh
|
||||
GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.9.0-preview
|
||||
go-omni new --framework-version v0.9.0-preview --module example.com/billing ./billing
|
||||
cd billing
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
通过 `go install ...@版本` 安装的 CLI 从构建信息中读取版本,并把它固定到生成项目的
|
||||
`go.mod`。不会使用不存在的 `v0.0.0`,也不会在普通业务目录中添加本地 `replace`。
|
||||
|
||||
需要选择其他已发布版本时,使用完整版本号:
|
||||
|
||||
```sh
|
||||
go-omni new --framework-version v0.6.0-preview --module example.com/billing ./billing
|
||||
```
|
||||
|
||||
`--framework-version` 禁用自动本地替换;`main`、`latest` 等浮动引用不接受为固定版本。
|
||||
源码构建的 CLI 使用代码中声明的目标发布版本;该版本未发布时请显式选择已发布版本,或用 `--framework-dir` 验证本地源码。
|
||||
|
||||
在 GoOmni 源码目录执行时,命令会自动在生成项目的 `go.mod` 中加入本地
|
||||
`replace git.yuntuo.hk/k2safe/go-omni => ...`,便于立即运行和测试。目标目录非空时命令会停止,明确传入
|
||||
`--force` 才会覆盖脚手架文件。
|
||||
|
||||
也可以在任意目录显式指定源码路径:
|
||||
|
||||
```sh
|
||||
go-omni new --framework-dir /path/to/go-omni --module example.com/billing ./billing
|
||||
```
|
||||
|
||||
默认生成内存存储版本;选择 MySQL 时:
|
||||
|
||||
```sh
|
||||
go-omni new --database mysql --module example.com/billing ./billing
|
||||
```
|
||||
|
||||
MySQL 项目额外包含 `schema.sql`、MySQL Repository 和可选的数据库集成测试。
|
||||
先应用建表脚本,再设置 `GO_OMNI_DB_DSN` 启动。ServiceContext 负责连接池初始化、表结构检查、
|
||||
就绪探针注册和关闭。两种模式都提供用户 CRUD、分页、输入校验和统一错误响应。
|
||||
|
||||
生成服务默认包含 `/health/live`、`/health/ready` 和示例 `/users` CRUD。
|
||||
退出信号通过 `signal.NotifyContext` 传入 `RunContext`,Logger 的关闭函数注册到应用生命周期。
|
||||
|
||||
发布验收使用 Python 3 标准库脚本,不需要额外 Python 依赖:
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyScaffold.py --version v0.6.0-preview
|
||||
python3 scripts/verifyDatabase.py --version v0.6.0-preview
|
||||
```
|
||||
|
||||
脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、
|
||||
编译、请求接口,并对真实二进制分别发送 `SIGINT` 和 `SIGTERM`。同时验证框架 `Run` 的
|
||||
信号处理和清理回调。每个进程都必须退出码为 0、释放监听端口;临时项目和测试进程会被清理。
|
||||
|
||||
发布前验证当前源码:
|
||||
|
||||
```sh
|
||||
go build -o /tmp/go-omni ./cmd/go-omni
|
||||
python3 scripts/verifyScaffold.py --cli /tmp/go-omni --framework-dir .
|
||||
python3 scripts/verifyDatabase.py --cli /tmp/go-omni --framework-dir .
|
||||
```
|
||||
|
||||
数据库验收需要本地 Docker,使用独立 MySQL 8.4 容器;不会连接已有业务数据库,结束后清理测试容器与数据卷。
|
||||
|
||||
## 显式迁移(main)
|
||||
|
||||
SQL 命名为 `0001_createUsers.up.sql`。先预览版本和 SHA-256 清单:
|
||||
保留独立迁移工具,应用启动不自动迁移:
|
||||
|
||||
```sh
|
||||
go-omni migrate --dir migrations
|
||||
```
|
||||
|
||||
部署步骤配置 `GO_OMNI_DB_DSN`,显式执行:
|
||||
|
||||
```sh
|
||||
# 检查预览,配置 GO_OMNI_DB_DSN 后显式应用
|
||||
go-omni migrate --dir migrations --apply
|
||||
```
|
||||
|
||||
预览不连接数据库;应用启动不自动迁移。失败保留 dirty 状态,不提供自动 down/force。
|
||||
脚手架原有 `schema.sql` 不会自动变成有版本的迁移,已有库需要先确定基线。详见 [扩展模块](../../ADVANCED.md)。
|
||||
SQL 命名示例 `0001_createUsers.up.sql`;预览只显示版本和 SHA-256,不连接数据库。失败保留 dirty 状态,不自动 down/force。细节见 [扩展模块](../../ADVANCED.md)。
|
||||
|
||||
@@ -159,6 +159,7 @@ func runAPI(args []string) error {
|
||||
}
|
||||
out, err = apigen.Generate(o)
|
||||
}
|
||||
printAPIDiagnostics(out.Diagnostics)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -168,9 +169,32 @@ func runAPI(args []string) error {
|
||||
}
|
||||
fmt.Printf("Preview: %d changes; %d existing developer-owned files preserved; no files written\n", len(out.Changes), len(out.Preserved))
|
||||
} else if o.Check {
|
||||
fmt.Println("API generated files are current")
|
||||
fmt.Println("API generated files are current; no definite Logic signature errors found (run go test ./... for full verification)")
|
||||
} else {
|
||||
fmt.Printf("API project ready: %d generated, %d created, %d preserved, %d removed\n", len(out.Generated), len(out.Created), len(out.Preserved), len(out.Removed))
|
||||
fmt.Printf("API project generated: %d generated, %d created, %d preserved, %d removed\n", len(out.Generated), len(out.Created), len(out.Preserved), len(out.Removed))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printAPIDiagnostics(diagnostics []apigen.Diagnostic) {
|
||||
for _, d := range diagnostics {
|
||||
location := d.Path
|
||||
if d.Line > 0 {
|
||||
location += fmt.Sprintf(":%d", d.Line)
|
||||
}
|
||||
fmt.Printf("%s [%s] %s", strings.ToUpper(d.Severity), d.Code, location)
|
||||
if d.Operation != "" {
|
||||
fmt.Printf(" (%s)", d.Operation)
|
||||
}
|
||||
fmt.Printf(": %s\n", d.Message)
|
||||
if d.Before != "" {
|
||||
fmt.Printf(" before: %s\n", d.Before)
|
||||
}
|
||||
if d.After != "" {
|
||||
fmt.Printf(" after: %s\n", d.After)
|
||||
}
|
||||
for _, detail := range d.Details {
|
||||
fmt.Printf(" - %s\n", detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestAPICommands(t *testing.T) {
|
||||
if err = runNew([]string{"--api", api, "--module", "example.com/other", "--framework-dir", root, target}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(target, "internal/service/greet/greet.go")); err != nil {
|
||||
if _, err = os.Stat(filepath.Join(target, "internal/logic/greet/greet.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, args := range [][]string{{"validate"}, {"generate", "--api"}, {"new", "--api=x", "somewhere"}, {"generate", "--dir="}, {"generate", "--force"}, {"validate", "--api", api, "--dir", project}, {"validate", "--check"}} {
|
||||
@@ -40,7 +40,7 @@ func TestAPICommands(t *testing.T) {
|
||||
t.Fatalf("invalid args accepted: %v", args)
|
||||
}
|
||||
}
|
||||
if err = runNew([]string{"--api", api, "--force", target}); err == nil || !strings.Contains(err.Error(), "does not accept --force") {
|
||||
if err = runNew([]string{"--api", api, "--force", target}); err == nil || !strings.Contains(err.Error(), "do not accept --force") {
|
||||
t.Fatalf("incompatible profile: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,15 @@ func TestAPIDeveloperCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
func captureAPIOutput(t *testing.T, args []string) string {
|
||||
t.Helper()
|
||||
data, err := captureAPIResult(t, args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func captureAPIResult(t *testing.T, args []string) (string, error) {
|
||||
t.Helper()
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
@@ -100,8 +109,41 @@ func captureAPIOutput(t *testing.T, args []string) string {
|
||||
w.Close()
|
||||
os.Stdout = previous
|
||||
data := <-result
|
||||
if callErr != nil {
|
||||
t.Fatal(callErr)
|
||||
}
|
||||
return string(data)
|
||||
return string(data), callErr
|
||||
}
|
||||
|
||||
func TestAPIChangeDiagnosticsVisibleOnFailure(t *testing.T) {
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "app")
|
||||
captureAPIOutput(t, []string{"new", dir, "--module", "example.com/diagnostics", "--framework-dir", root})
|
||||
api := filepath.Join(dir, "api/service.api")
|
||||
data, err := os.ReadFile(api)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = os.WriteFile(api, []byte(strings.ReplaceAll(string(data), "GreetReq", "HelloReq")), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, flag := range []string{"--dry-run", "--check", ""} {
|
||||
args := []string{"generate", "--dir", dir}
|
||||
if flag != "" {
|
||||
args = append(args, flag)
|
||||
}
|
||||
text, err := captureAPIResult(t, args)
|
||||
if (flag == "--check") != (err != nil) {
|
||||
t.Fatalf("%s: %v", flag, err)
|
||||
}
|
||||
for _, want := range []string{"REVIEW [contractChanged]", "ERROR [logicSignature]", "internal/logic/greet/greet.go:", "types.HelloReq", "types.GreetReq"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("%s hides %q:\n%s", flag, want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
text, err := captureAPIResult(t, []string{"generate", "--dir", dir, "--check"})
|
||||
if err == nil || !strings.Contains(text, "ERROR [logicSignature]") || strings.Contains(text, "files are current") {
|
||||
t.Fatalf("check claimed success: %v\n%s", err, text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,6 @@ func TestInvalidDefinitions(t *testing.T) {
|
||||
"service user { @handler go\n @status 201\n post /go\n}",
|
||||
"type A { Name string `header:\"Name\"` }\nservice user { @handler go\n get /go returns (A)\n}",
|
||||
"@server (group: ../../bad)\nservice user { @handler go\n get /go\n}",
|
||||
"service user { @handler go\n get /health/ready\n}",
|
||||
"type A { Name string `json:\"name\"`\n Name int `json:\"other\"` }\nservice user { @handler go\n post /go (A)\n}",
|
||||
"service user { @handler go\n get /go\n @handler Go\n get /other\n}",
|
||||
"type A { Value []byte `json:\"value\"` }\nservice user { @handler go\n post /go (A)\n}",
|
||||
@@ -168,10 +167,10 @@ func TestRegenerationAndConflicts(t *testing.T) {
|
||||
if _, err := Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
servicePath := filepath.Join(dir, "internal/service/greet/greet.go")
|
||||
servicePath := filepath.Join(dir, "internal/logic/greet/greet.go")
|
||||
service := readFile(t, servicePath) + "\n// user edit must survive\n"
|
||||
writeFile(t, servicePath, service)
|
||||
for _, file := range []string{"cmd/server/main.go", "etc/config.yaml", "internal/servicecontext/context.go", "internal/repository/greet/repository.go"} {
|
||||
for _, file := range []string{"etc/config.yaml", "internal/svc/serviceContext.go", "internal/repository/greet/repository.go"} {
|
||||
body := readFile(t, filepath.Join(dir, file))
|
||||
if strings.HasSuffix(file, ".go") {
|
||||
body += "\n// handwritten extension\n"
|
||||
@@ -204,7 +203,7 @@ func TestRegenerationAndConflicts(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(dir, "internal/handler/extra/handlersGen.go")); !os.IsNotExist(err) {
|
||||
t.Fatal("stale handler remains")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "internal/service/extra/ping.go")); err != nil {
|
||||
if _, err := os.Stat(filepath.Join(dir, "internal/logic/extra/ping.go")); err != nil {
|
||||
t.Fatal("business stub removed")
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "internal/types/typesGen.go"), typesBefore+"\n// edited generated file\n")
|
||||
@@ -312,9 +311,9 @@ func TestGeneratedProjectsBuildAndRun(t *testing.T) {
|
||||
if starter {
|
||||
source = consumerStarterTest
|
||||
} else {
|
||||
writeFile(t, filepath.Join(dir, "internal/service/user/updateUser.go"), `package user
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/user/updateUser.go"), `package user
|
||||
import("context";"example.com/consumer/internal/types")
|
||||
func(s *Service) UpdateUser(ctx context.Context,req *types.UpdateUserReq)(*types.UserResp,error){return &types.UserResp{UserID:req.UserID,Name:req.Name,Active:req.Active},nil}
|
||||
func(s *Logic) UpdateUser(ctx context.Context,req *types.UpdateUserReq)(*types.UserResp,error){return &types.UserResp{UserID:req.UserID,Name:req.Name,Active:req.Active},nil}
|
||||
`)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "contract_test.go"), source)
|
||||
@@ -339,13 +338,13 @@ func(s *Service) UpdateUser(ctx context.Context,req *types.UpdateUserReq)(*types
|
||||
}
|
||||
}
|
||||
|
||||
const consumerStarterTest = `package consumer_test
|
||||
import("context";"io";"net/http/httptest";"strings";"testing";omni "git.yuntuo.hk/k2safe/go-omni";"git.yuntuo.hk/k2safe/go-omni/logx";"example.com/consumer/internal/handler";"example.com/consumer/internal/servicecontext";"example.com/consumer/internal/settings")
|
||||
func TestGreeting(t *testing.T){cfg,err:=settings.Load("etc/config.yaml");if err!=nil{t.Fatal(err)};cfg.Server.Log.Writer=io.Discard;cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{};logger,err:=logx.New(cfg.Server.Log);if err!=nil{t.Fatal(err)};defer logger.Close();deps,err:=servicecontext.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close();app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)};w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,httptest.NewRequest("GET","/api/greet/Alice",nil));if w.Code!=200||!strings.Contains(w.Body.String(),"Hello Alice")||w.Header().Get("X-Request-ID")==""{t.Fatalf("%d %s",w.Code,w.Body.String())}}
|
||||
const consumerStarterTest = `package main_test
|
||||
import("context";"io";"net/http/httptest";"strings";"testing";omni "git.yuntuo.hk/k2safe/go-omni";"git.yuntuo.hk/k2safe/go-omni/logx";"example.com/consumer/internal/handler";"example.com/consumer/internal/svc";"example.com/consumer/internal/config")
|
||||
func TestGreeting(t *testing.T){cfg,err:=config.Load("etc/config.yaml");if err!=nil{t.Fatal(err)};cfg.Server.Log.Writer=io.Discard;cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{};logger,err:=logx.New(cfg.Server.Log);if err!=nil{t.Fatal(err)};defer logger.Close();deps,err:=svc.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close();app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)};w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,httptest.NewRequest("GET","/api/greet/Alice",nil));if w.Code!=200||!strings.Contains(w.Body.String(),"Hello Alice")||w.Header().Get("X-Request-ID")==""{t.Fatalf("%d %s",w.Code,w.Body.String())}}
|
||||
`
|
||||
const consumerContractTest = `package consumer_test
|
||||
import("context";"encoding/json";"io";"net/http/httptest";"strings";"testing";omni "git.yuntuo.hk/k2safe/go-omni";"git.yuntuo.hk/k2safe/go-omni/logx";"example.com/consumer/internal/handler";"example.com/consumer/internal/servicecontext")
|
||||
func TestContract(t *testing.T){cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close();deps,err:=servicecontext.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close();app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
|
||||
const consumerContractTest = `package main_test
|
||||
import("context";"encoding/json";"io";"net/http/httptest";"strings";"testing";omni "git.yuntuo.hk/k2safe/go-omni";"git.yuntuo.hk/k2safe/go-omni/logx";"example.com/consumer/internal/handler";"example.com/consumer/internal/svc")
|
||||
func TestContract(t *testing.T){cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close();deps,err:=svc.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close();app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
|
||||
for _,tc:=range []struct{method,path,body string;status int}{
|
||||
{"PUT","/api/v1/users/7?notify=true",` + "`" + `{"name":"Alice","active":false,"age":0}` + "`" + `,200},
|
||||
{"PUT","/api/v1/users/7",` + "`" + `{"name":"Alice"}` + "`" + `,400},
|
||||
|
||||
@@ -69,14 +69,14 @@ func TestCompatibilityConsumer(t *testing.T) {
|
||||
}
|
||||
generate(false)
|
||||
files := map[string]string{
|
||||
"internal/service/demo/echo.go": "echo.go",
|
||||
"internal/logic/demo/echo.go": "echo.go",
|
||||
"internal/handler/demo/customBind.go": "hooks.go",
|
||||
"internal/handler/files/downloadManual.go": "download.go",
|
||||
"consumer_test.go": "consumer_test.go",
|
||||
}
|
||||
// The fixture implements related methods together; remove only its generated
|
||||
// one-time stubs, then fill every stub so regeneration can preserve all files.
|
||||
for _, name := range []string{"internal/service/demo/item.go", "internal/service/demo/custom.go"} {
|
||||
for _, name := range []string{"internal/logic/demo/item.go", "internal/logic/demo/custom.go"} {
|
||||
writeFile(t, filepath.Join(dir, name), "package demo\n")
|
||||
}
|
||||
for _, name := range []string{"internal/handler/demo/uploadManual.go", "internal/handler/demo/callbackManual.go"} {
|
||||
|
||||
@@ -166,8 +166,8 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
if !goName.MatchString(r.Handler) {
|
||||
return nil, fmt.Errorf("%s: invalid handler name", r.Position)
|
||||
}
|
||||
if strings.EqualFold(r.Handler, "service") {
|
||||
return nil, errors.New("handler name service conflicts with the service constructor file")
|
||||
if strings.EqualFold(r.Handler, "service") || strings.EqualFold(r.Handler, "logic") {
|
||||
return nil, errors.New("handler name conflicts with the Logic constructor file")
|
||||
}
|
||||
if r.Group == "" {
|
||||
return nil, errors.New("group required")
|
||||
@@ -218,16 +218,13 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
return nil, fmt.Errorf("duplicate route %s", pathKey)
|
||||
}
|
||||
paths[pathKey] = true
|
||||
if r.Path == "/health/live" || r.Path == "/health/ready" || r.Path == "/openapi.json" {
|
||||
return nil, fmt.Errorf("route %s is reserved by the generated server", r.Path)
|
||||
}
|
||||
if r.Method != strings.ToLower(r.Method) {
|
||||
return nil, errors.New("HTTP methods must be lowercase")
|
||||
}
|
||||
if r.Status != 200 && r.Status != 201 && r.Status != 202 && r.Status != 204 {
|
||||
return nil, errors.New("@status supports 200, 201, 202 or 204")
|
||||
}
|
||||
if !r.Manual && (r.Status == 204) != (r.Response == "") {
|
||||
if !r.Manual && r.Produces == "" && (r.Status == 204) != (r.Response == "") {
|
||||
return nil, errors.New("204 requires no response; other success statuses require a response type")
|
||||
}
|
||||
if r.Method == "head" && r.Response != "" {
|
||||
@@ -245,7 +242,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
op.Parameters = b.Parameters()
|
||||
op.Request = b.BodySchema()
|
||||
if op.Request != nil && (r.BodyMode == "raw" || r.BodyMode == "multipart") {
|
||||
return nil, errors.New("raw/multipart request DTOs may declare only path/form/header fields; body binding is manual")
|
||||
return nil, errors.New("raw/multipart request DTOs may declare only path/form/header fields; use the generated body stream for content")
|
||||
}
|
||||
if op.Request != nil && (r.Method == "get" || r.Method == "head") {
|
||||
return nil, errors.New("GET/HEAD requests cannot declare JSON bodies; use form/path/header tags")
|
||||
|
||||
244
cmd/go-omni/apigen/contractChanges.go
Normal file
244
cmd/go-omni/apigen/contractChanges.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
goparser "go/parser"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Diagnostic points to preserved project code that needs attention. Review
|
||||
// notices describe contract changes; errors describe definite source problems.
|
||||
type Diagnostic struct {
|
||||
Severity, Code, Path, Operation, Message string
|
||||
Line int
|
||||
Before, After string
|
||||
Details []string
|
||||
}
|
||||
|
||||
// Snapshots describe the last successfully generated contract, never business
|
||||
// source or absolute paths. They are persisted in the ownership transaction.
|
||||
type contractSnapshot struct {
|
||||
Routes map[string]routeContract `json:"routes"`
|
||||
}
|
||||
|
||||
type routeContract struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Group string `json:"group"`
|
||||
Handler string `json:"handler"`
|
||||
Signature string `json:"signature"`
|
||||
Request string `json:"request,omitempty"`
|
||||
Response string `json:"response,omitempty"`
|
||||
BodyMode string `json:"bodyMode,omitempty"`
|
||||
Produces string `json:"produces,omitempty"`
|
||||
MaxBodyBytes int64 `json:"maxBodyBytes,omitempty"`
|
||||
Manual bool `json:"manual,omitempty"`
|
||||
CustomBinding bool `json:"customBinding,omitempty"`
|
||||
Types map[string]typeContract `json:"types,omitempty"`
|
||||
}
|
||||
|
||||
type typeContract struct {
|
||||
Fields []contractField `json:"fields,omitempty"`
|
||||
Scalar string `json:"scalar,omitempty"`
|
||||
}
|
||||
|
||||
type contractField struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
func snapshotContract(spec *Spec) *contractSnapshot {
|
||||
definitions := map[string]typeContract{}
|
||||
for _, t := range spec.Types {
|
||||
d := typeContract{}
|
||||
for _, f := range t.Fields {
|
||||
d.Fields = append(d.Fields, contractField{f.Name, f.Type, f.Tag})
|
||||
}
|
||||
definitions[t.Name] = d
|
||||
}
|
||||
for _, s := range spec.Scalars {
|
||||
definitions[s.Name] = typeContract{Scalar: fmt.Sprintf("%s.%s (jsonInput=%s, jsonOutput=%s, text=%t)", s.GoImport, s.GoType, s.JSONInput, s.JSONOutput, s.Text)}
|
||||
}
|
||||
snapshot := &contractSnapshot{Routes: map[string]routeContract{}}
|
||||
for _, r := range spec.Routes {
|
||||
dependencies := map[string]typeContract{}
|
||||
var visit func(string)
|
||||
visit = func(name string) {
|
||||
d, ok := definitions[name]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, exists := dependencies[name]; exists {
|
||||
return
|
||||
}
|
||||
dependencies[name] = d
|
||||
for _, f := range d.Fields {
|
||||
expr, err := goparser.ParseExpr(f.Type)
|
||||
if err != nil {
|
||||
continue
|
||||
} // Compile already validates expressions.
|
||||
ast.Inspect(expr, func(n ast.Node) bool {
|
||||
if id, ok := n.(*ast.Ident); ok {
|
||||
visit(id.Name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
visit(r.Request)
|
||||
visit(r.Response)
|
||||
op := routeContract{
|
||||
Method: strings.ToUpper(r.Method), Path: r.Path, Group: r.Group, Handler: r.Handler,
|
||||
Request: r.Request, Response: r.Response, BodyMode: r.BodyMode, Produces: r.Produces,
|
||||
MaxBodyBytes: r.MaxBodyBytes, Manual: r.Manual, CustomBinding: r.CustomBinding, Types: dependencies,
|
||||
}
|
||||
if !r.Manual {
|
||||
op.Signature = expectedLogicSignature(r)
|
||||
}
|
||||
snapshot.Routes[r.Group+"/"+lower(r.Handler)] = op
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func (r routeContract) file() string {
|
||||
if r.Manual {
|
||||
return "internal/handler/" + r.Group + "/" + lower(r.Handler) + "Manual.go"
|
||||
}
|
||||
return "internal/logic/" + r.Group + "/" + lower(r.Handler) + ".go"
|
||||
}
|
||||
|
||||
func (r routeContract) operation() string { return r.Method + " " + r.Path }
|
||||
|
||||
func compareContracts(old, next *contractSnapshot) []Diagnostic {
|
||||
if old == nil {
|
||||
return nil
|
||||
}
|
||||
var diagnostics []Diagnostic
|
||||
for key, previous := range old.Routes {
|
||||
current, exists := next.Routes[key]
|
||||
if !exists {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: "review", Code: "routeRemoved", Path: previous.file(), Operation: previous.operation(), Message: "route removed or moved; its handwritten implementation is preserved; review obsolete code and types"})
|
||||
continue
|
||||
}
|
||||
d := Diagnostic{Severity: "review", Code: "contractChanged", Path: current.file(), Operation: current.operation(), Message: "contract changed; review the preserved implementation"}
|
||||
if previous.Signature != current.Signature {
|
||||
d.Before, d.After = previous.Signature, current.Signature
|
||||
d.Details = append(d.Details, "Logic call signature changed")
|
||||
}
|
||||
if previous.Method != current.Method || previous.Path != current.Path {
|
||||
d.Details = append(d.Details, previous.operation()+" -> "+current.operation())
|
||||
}
|
||||
if previous.Manual != current.Manual || previous.CustomBinding != current.CustomBinding || previous.BodyMode != current.BodyMode || previous.MaxBodyBytes != current.MaxBodyBytes || previous.Produces != current.Produces {
|
||||
d.Details = append(d.Details, fmt.Sprintf("HTTP access: manual=%t customBinding=%t body=%s maxBytes=%d produces=%q -> manual=%t customBinding=%t body=%s maxBytes=%d produces=%q", previous.Manual, previous.CustomBinding, previous.BodyMode, previous.MaxBodyBytes, previous.Produces, current.Manual, current.CustomBinding, current.BodyMode, current.MaxBodyBytes, current.Produces))
|
||||
}
|
||||
d.Details = append(d.Details, changedTypes(previous.Types, current.Types)...)
|
||||
if len(d.Details) > 0 {
|
||||
diagnostics = append(diagnostics, d)
|
||||
}
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
func changedTypes(old, next map[string]typeContract) []string {
|
||||
names := map[string]bool{}
|
||||
for name := range old {
|
||||
names[name] = true
|
||||
}
|
||||
for name := range next {
|
||||
names[name] = true
|
||||
}
|
||||
var details []string
|
||||
for name := range names {
|
||||
a, existed := old[name]
|
||||
b, exists := next[name]
|
||||
switch {
|
||||
case !existed:
|
||||
details = append(details, "DTO dependency added: "+name)
|
||||
case !exists:
|
||||
details = append(details, "DTO dependency removed: "+name)
|
||||
case a.Scalar != b.Scalar:
|
||||
details = append(details, fmt.Sprintf("scalar %s: %s -> %s", name, a.Scalar, b.Scalar))
|
||||
case !reflect.DeepEqual(a.Fields, b.Fields):
|
||||
oldFields, newFields := map[string]contractField{}, map[string]contractField{}
|
||||
for _, f := range a.Fields {
|
||||
oldFields[f.Name] = f
|
||||
}
|
||||
for _, f := range b.Fields {
|
||||
newFields[f.Name] = f
|
||||
}
|
||||
before := len(details)
|
||||
for field, f := range oldFields {
|
||||
if n, ok := newFields[field]; !ok {
|
||||
details = append(details, fmt.Sprintf("DTO %s.%s removed", name, field))
|
||||
} else if f != n {
|
||||
details = append(details, fmt.Sprintf("DTO %s.%s: %s `%s` -> %s `%s`", name, field, f.Type, f.Tag, n.Type, n.Tag))
|
||||
}
|
||||
}
|
||||
for field := range newFields {
|
||||
if _, ok := oldFields[field]; !ok {
|
||||
details = append(details, fmt.Sprintf("DTO %s.%s added", name, field))
|
||||
}
|
||||
}
|
||||
if len(details) == before {
|
||||
details = append(details, "DTO "+name+" field order changed; review positional struct literals")
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(details)
|
||||
return details
|
||||
}
|
||||
|
||||
func sortDiagnostics(diagnostics []Diagnostic) {
|
||||
sort.Slice(diagnostics, func(i, j int) bool {
|
||||
a, b := diagnostics[i], diagnostics[j]
|
||||
if a.Path != b.Path {
|
||||
return a.Path < b.Path
|
||||
}
|
||||
if a.Line != b.Line {
|
||||
return a.Line < b.Line
|
||||
}
|
||||
if a.Code != b.Code {
|
||||
return a.Code < b.Code
|
||||
}
|
||||
return a.Operation < b.Operation
|
||||
})
|
||||
}
|
||||
|
||||
func diagnosticErrors(diagnostics []Diagnostic) int {
|
||||
count := 0
|
||||
for _, d := range diagnostics {
|
||||
if d.Severity == "error" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func expectedLogicSignature(r Route) string {
|
||||
params := []string{"context.Context"}
|
||||
if r.Request != "" {
|
||||
params = append(params, "*types."+r.Request)
|
||||
}
|
||||
if r.BodyMode == "raw" || r.BodyMode == "multipart" {
|
||||
params = append(params, "*httpx.Body")
|
||||
}
|
||||
results := []string{"error"}
|
||||
if r.Produces != "" {
|
||||
results = []string{"*httpx.Stream", "error"}
|
||||
} else if r.Response != "" {
|
||||
results = []string{"*types." + r.Response, "error"}
|
||||
}
|
||||
return displaySignature(upper(r.Handler), params, results)
|
||||
}
|
||||
|
||||
func displaySignature(name string, params, results []string) string {
|
||||
result := strings.Join(results, ", ")
|
||||
if len(results) != 1 {
|
||||
result = "(" + result + ")"
|
||||
}
|
||||
return name + "(" + strings.Join(params, ", ") + ") " + result
|
||||
}
|
||||
308
cmd/go-omni/apigen/contractChanges_test.go
Normal file
308
cmd/go-omni/apigen/contractChanges_test.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func findDiagnostic(t *testing.T, out Output, code string) Diagnostic {
|
||||
t.Helper()
|
||||
for _, d := range out.Diagnostics {
|
||||
if d.Code == code {
|
||||
return d
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing %s diagnostic: %+v", code, out.Diagnostics)
|
||||
return Diagnostic{}
|
||||
}
|
||||
|
||||
func TestContractChangesPersistUntilLogicFixed(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "app")
|
||||
out, err := New(Options{Dir: dir, Module: "example.com/changes", FrameworkDir: frameworkRoot(t)})
|
||||
if err != nil || len(out.Diagnostics) > 0 {
|
||||
t.Fatalf("initial generation: %v %+v", err, out.Diagnostics)
|
||||
}
|
||||
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
|
||||
logicPath := filepath.Join(dir, "internal/logic/greet/greet.go")
|
||||
logicBefore := readFile(t, logicPath)
|
||||
writeFile(t, o.API, strings.ReplaceAll(readFile(t, o.API), "GreetReq", "HelloReq"))
|
||||
before := snapshot(t, dir)
|
||||
for _, dryRun := range []bool{true, false} {
|
||||
o.DryRun, o.Check = dryRun, !dryRun
|
||||
out, err = Generate(o)
|
||||
if dryRun && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !dryRun && err == nil {
|
||||
t.Fatal("check accepted stale project")
|
||||
}
|
||||
d := findDiagnostic(t, out, "logicSignature")
|
||||
if d.Line <= 0 || d.Path != "internal/logic/greet/greet.go" || !strings.Contains(d.After, "*types.HelloReq") || !strings.Contains(d.Before, "*types.GreetReq") {
|
||||
t.Fatalf("imprecise diagnosis: %+v", d)
|
||||
}
|
||||
findDiagnostic(t, out, "contractChanged")
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("preview/check changed files")
|
||||
}
|
||||
}
|
||||
o.DryRun, o.Check = false, false
|
||||
out, err = Generate(o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
findDiagnostic(t, out, "logicSignature")
|
||||
if readFile(t, logicPath) != logicBefore {
|
||||
t.Fatal("business source overwritten")
|
||||
}
|
||||
o.Check = true
|
||||
out, err = Generate(o)
|
||||
if err == nil || strings.Contains(err.Error(), "stale") || len(out.Changes) != 0 {
|
||||
t.Fatalf("signature drift must remain after generation: %v %+v", err, out.Changes)
|
||||
}
|
||||
findDiagnostic(t, out, "logicSignature")
|
||||
// Method location and import/parameter names are project-owned. Find the
|
||||
// method in a different file, honoring aliases and named result variables.
|
||||
writeFile(t, logicPath, "package greet\n// business split into visitor.go\n")
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/greet/visitor.go"), `package greet
|
||||
import (ctx "context"; dto "example.com/changes/internal/types")
|
||||
func(s *Logic)Greet(input ctx.Context, request *dto.HelloReq)(output *dto.GreetResp, problem error){
|
||||
return &dto.GreetResp{Message:request.Name},nil
|
||||
}
|
||||
`)
|
||||
out, err = Generate(o)
|
||||
if err != nil || len(out.Diagnostics) > 0 {
|
||||
t.Fatalf("compatible business method rejected: %v %+v", err, out.Diagnostics)
|
||||
}
|
||||
before = snapshot(t, dir)
|
||||
o.Check = false
|
||||
if _, err = Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("stable generation changed files")
|
||||
}
|
||||
// Field review notices should point to the actual moved business method.
|
||||
writeFile(t, o.API, strings.Replace(readFile(t, o.API), `max=80`, `max=90`, 1))
|
||||
o.DryRun = true
|
||||
out, err = Generate(o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := findDiagnostic(t, out, "contractChanged")
|
||||
if d.Path != "internal/logic/greet/visitor.go" || d.Line == 0 {
|
||||
t.Fatalf("wrong implementation location: %+v", d)
|
||||
}
|
||||
o.DryRun = false
|
||||
if _, err = Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./...")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
if data, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("fixed generated consumer: %v\n%s", err, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractChangesReachNestedDTOs(t *testing.T) {
|
||||
source := `syntax = "v1"
|
||||
type Address { Street string ` + "`json:\"street\"`" + ` }
|
||||
type Request { Address Address ` + "`json:\"address\"`" + ` }
|
||||
type Reply { Accepted bool ` + "`json:\"accepted\"`" + ` }
|
||||
@server(group: orders)
|
||||
service orders-api {
|
||||
@handler create
|
||||
post /orders (Request) returns (Reply)
|
||||
@handler ping
|
||||
get /ping
|
||||
}
|
||||
`
|
||||
root := t.TempDir()
|
||||
api := filepath.Join(root, "source/service.api")
|
||||
writeFile(t, api, source)
|
||||
dir := filepath.Join(root, "app")
|
||||
if _, err := Generate(Options{API: api, Dir: dir, Module: "example.com/nested", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o := Options{API: filepath.Join(dir, "api/service.api"), Dir: dir, DryRun: true}
|
||||
writeFile(t, o.API, strings.Replace(source, "Street string", "Street int64", 1))
|
||||
out, err := Generate(o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Diagnostics) != 1 {
|
||||
t.Fatalf("unrelated routes should not be warned: %+v", out.Diagnostics)
|
||||
}
|
||||
d := findDiagnostic(t, out, "contractChanged")
|
||||
if d.Path != "internal/logic/orders/create.go" || !strings.Contains(strings.Join(d.Details, "\n"), "Address.Street: string") {
|
||||
t.Fatalf("nested field not explained: %+v", d)
|
||||
}
|
||||
// Import/file/comment/formatting changes are not semantic DTO changes.
|
||||
writeFile(t, o.API, source+"\n// a harmless documentation edit\n")
|
||||
out, err = Generate(o)
|
||||
if err != nil || len(out.Diagnostics) > 0 {
|
||||
t.Fatalf("comment caused false diagnostic: %v %+v", err, out.Diagnostics)
|
||||
}
|
||||
// Removal preserves the old implementation and explicitly points it out.
|
||||
writeFile(t, o.API, strings.Replace(source, " @handler create\n post /orders (Request) returns (Reply)\n", "", 1))
|
||||
out, err = Generate(o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d = findDiagnostic(t, out, "routeRemoved")
|
||||
if d.Path != "internal/logic/orders/create.go" {
|
||||
t.Fatal(d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractSnapshotSemanticDetails(t *testing.T) {
|
||||
base := &Spec{Types: []Type{{Name: "Req", Fields: []Field{{Name: "ID", Type: "ID", Tag: `form:"id"`}}}}, Scalars: []ScalarDefinition{{Name: "ID", GoImport: "example.com/id", GoType: "Value", JSONInput: "string", JSONOutput: "string"}}, Routes: []Route{{Group: "files", Handler: "send", Method: "post", Path: "/file", Request: "Req", BodyMode: "raw", MaxBodyBytes: 64, Produces: "text/plain"}}}
|
||||
old := snapshotContract(base)
|
||||
base.Scalars[0].Text = true
|
||||
base.Routes[0].BodyMode = "multipart"
|
||||
base.Routes[0].Produces = "application/octet-stream"
|
||||
base.Routes[0].MaxBodyBytes = 128
|
||||
base.Routes[0].Path = "/files"
|
||||
diagnostics := compareContracts(old, snapshotContract(base))
|
||||
if len(diagnostics) != 1 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
details := strings.Join(diagnostics[0].Details, "\n")
|
||||
for _, want := range []string{"scalar ID", "body=raw", "body=multipart", "POST /file -> POST /files", "maxBytes=128"} {
|
||||
if !strings.Contains(details, want) {
|
||||
t.Fatal("missing", want, details)
|
||||
}
|
||||
}
|
||||
base.Routes[0].Produces = ""
|
||||
diagnostics = compareContracts(old, snapshotContract(base))
|
||||
if !strings.Contains(diagnostics[0].Before, "*httpx.Stream") || !strings.HasSuffix(diagnostics[0].After, ") error") {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogicDiagnosticsSourceBoundaries(t *testing.T) {
|
||||
for _, tc := range []struct{ name, source, code, severity string }{
|
||||
{"wrongResult", `package greet; import "context"; func(s *Logic)Greet(context.Context) error{return nil}`, "logicSignature", "error"},
|
||||
{"syntax", `package greet; func(s *Logic)Greet( {`, "logicSyntax", "error"},
|
||||
{"alias", `package greet; import("context";"example.com/test/internal/types");type Input = types.GreetReq; func(s *Logic)Greet(context.Context,*Input)(*types.GreetResp,error){return nil,nil}`, "logicUnchecked", "review"},
|
||||
{"conditional", "//go:build projectTag\n\npackage greet\n", "logicUnchecked", "review"},
|
||||
{"missing", "package greet\n", "logicNotFound", "review"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "app")
|
||||
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/greet/greet.go"), tc.source)
|
||||
before := snapshot(t, dir)
|
||||
out, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true})
|
||||
d := findDiagnostic(t, out, tc.code)
|
||||
if d.Severity != tc.severity || (tc.severity == "error") != (err != nil) {
|
||||
t.Fatalf("unexpected check result: %v %+v", err, d)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("analysis modified business source")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractSnapshotBootstrapAndDeterminism(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "app")
|
||||
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, manifestPath)
|
||||
var previous manifest
|
||||
if err := json.Unmarshal([]byte(readFile(t, path)), &previous); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous.Contract = nil
|
||||
data, _ := json.MarshalIndent(previous, "", " ")
|
||||
writeFile(t, path, string(data)+"\n")
|
||||
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), DryRun: true}
|
||||
a, err := Generate(o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := Generate(o)
|
||||
if err != nil || !reflect.DeepEqual(a, b) {
|
||||
t.Fatal("non-deterministic preview", err)
|
||||
}
|
||||
findDiagnostic(t, a, "contractBaseline")
|
||||
o.DryRun = false
|
||||
if _, err = Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o.Check = true
|
||||
out, err := Generate(o)
|
||||
if err != nil || len(out.Diagnostics) > 0 {
|
||||
t.Fatal(err, out.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogicResponseAndBodySignatureChanges(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, after string
|
||||
change func(string) string
|
||||
}{
|
||||
{"responseType", "*types.HelloResp", func(s string) string { return strings.ReplaceAll(s, "GreetResp", "HelloResp") }},
|
||||
{"bodyParameter", "*httpx.Body", func(s string) string {
|
||||
return strings.Replace(s, " get /greet", " @body (mode: raw; maxBytes: 64)\n post /greet", 1)
|
||||
}},
|
||||
{"streamResult", "*httpx.Stream", func(s string) string {
|
||||
return strings.Replace(strings.Replace(s, " returns (GreetResp)", "", 1), " get /greet", " @produces \"text/plain\"\n get /greet", 1)
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "app")
|
||||
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), DryRun: true}
|
||||
writeFile(t, o.API, tc.change(readFile(t, o.API)))
|
||||
out, err := Generate(o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := findDiagnostic(t, out, "logicSignature")
|
||||
if !strings.Contains(d.After, tc.after) {
|
||||
t.Fatal(d)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogicDuplicateAndTestFiles(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "app")
|
||||
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := readFile(t, filepath.Join(dir, "internal/logic/greet/greet.go"))
|
||||
// String literals mentioning build constraints do not make a file conditional.
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/greet/greet.go"), source+"\nconst diagnosticText=\"//go:build special\"\n")
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/greet/fixture_test.go"), source)
|
||||
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}
|
||||
out, err := Generate(o)
|
||||
if err != nil || len(out.Diagnostics) > 0 {
|
||||
t.Fatal(err, out.Diagnostics)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/greet/duplicate.go"), source)
|
||||
out, err = Generate(o)
|
||||
if err == nil {
|
||||
t.Fatal("duplicate method passed check")
|
||||
}
|
||||
d := findDiagnostic(t, out, "logicDuplicate")
|
||||
if len(d.Details) != 2 || !strings.Contains(d.Details[0], "duplicate.go:") {
|
||||
t.Fatal(d)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
@@ -20,14 +21,18 @@ var credentialName = regexp.MustCompile("^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
|
||||
func prepareMetadata(original *Spec) (*Spec, error) {
|
||||
spec := *original
|
||||
spec.Routes = append([]Route(nil), original.Routes...)
|
||||
spec.Errors = append([]ErrorDefinition(nil), original.Errors...)
|
||||
if err := validateResponses(&spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
definitions := map[string]ErrorDefinition{}
|
||||
codes, symbols := map[string]bool{}, map[string]bool{}
|
||||
codes, symbols := map[string]bool{}, map[string]bool{"Catalog": true}
|
||||
for _, e := range spec.Errors {
|
||||
if !exportedName.MatchString(e.Name) || e.Status < 400 || e.Status > 599 || !errorCode.MatchString(e.Code) || strings.TrimSpace(e.Message) == "" {
|
||||
return nil, fmt.Errorf("%s: error requires UpperCamelCase name, status 400..599, UPPER_SNAKE_CASE code and a nonempty public message", e.Position)
|
||||
if !exportedName.MatchString(e.Name) || !apierrors.ValidErrorStatus(e.Status) || strings.TrimSpace(e.Message) == "" {
|
||||
return nil, fmt.Errorf("%s: error requires UpperCamelCase name, a final body-bearing status (200..599 except 204/205/304) and a nonempty public message", e.Position)
|
||||
}
|
||||
if e.Catalog == "" && !errorCode.MatchString(e.Code) {
|
||||
return nil, fmt.Errorf("%s: legacy error declaration requires UPPER_SNAKE_CASE code", e.Position)
|
||||
}
|
||||
if codes[e.Code] {
|
||||
return nil, fmt.Errorf("%s: duplicate error code %s", e.Position, e.Code)
|
||||
@@ -41,6 +46,18 @@ func prepareMetadata(original *Spec) (*Spec, error) {
|
||||
codes[e.Code] = true
|
||||
definitions[e.Name] = e
|
||||
}
|
||||
registry, err := apierrors.NewRegistry(errorCatalog(&spec, nil))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized := registry.Catalog()
|
||||
for i := range spec.Errors {
|
||||
spec.Errors[i].BusinessCode = normalized.Definitions[spec.Errors[i].Code].Code
|
||||
spec.Errors[i].Params = normalized.Definitions[spec.Errors[i].Code].Params
|
||||
}
|
||||
if err := validateErrorPolicies(&spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
auth := map[string]AuthDefinition{}
|
||||
for _, a := range spec.Auth {
|
||||
if !exportedName.MatchString(a.Name) || !goName.MatchString(a.Middleware) || strings.EqualFold(a.Middleware, "defaults") || strings.EqualFold(a.Middleware, "registryGen") {
|
||||
@@ -84,7 +101,7 @@ func prepareMetadata(original *Spec) (*Spec, error) {
|
||||
seen := map[string]bool{}
|
||||
for _, name := range r.Errors {
|
||||
if _, ok := definitions[name]; !ok {
|
||||
return nil, fmt.Errorf("%s: unknown error %s; declare it in error (...)", r.Position, name)
|
||||
return nil, fmt.Errorf("%s: unknown error %s; declare it in errorCatalog or error (...)", r.Position, name)
|
||||
}
|
||||
if seen[name] {
|
||||
return nil, fmt.Errorf("%s: duplicate error %s", r.Position, name)
|
||||
@@ -103,6 +120,9 @@ func prepareMetadata(original *Spec) (*Spec, error) {
|
||||
}
|
||||
|
||||
func decorateDocument(doc map[string]any, spec *Spec) {
|
||||
if spec.DefaultResponse != "" {
|
||||
doc["x-omni-default-response"] = spec.DefaultResponse
|
||||
}
|
||||
if spec.Description != "" {
|
||||
doc["info"].(map[string]any)["description"] = spec.Description
|
||||
}
|
||||
@@ -124,6 +144,7 @@ func decorateDocument(doc map[string]any, spec *Spec) {
|
||||
}
|
||||
doc["components"].(map[string]any)["securitySchemes"] = schemes
|
||||
definitions := map[string]ErrorDefinition{}
|
||||
registry := apierrors.MustRegistry(errorCatalog(spec, nil))
|
||||
for _, e := range spec.Errors {
|
||||
definitions[e.Name] = e
|
||||
}
|
||||
@@ -182,15 +203,37 @@ func decorateDocument(doc map[string]any, spec *Spec) {
|
||||
response = genericErrorResponse(http.StatusText(status), cfg)
|
||||
responses[key] = response
|
||||
}
|
||||
media := response["content"].(map[string]any)["application/json"].(map[string]any)
|
||||
content, ok := response["content"].(map[string]any)
|
||||
if !ok {
|
||||
content = map[string]any{}
|
||||
response["content"] = content
|
||||
}
|
||||
media, hasJSON := content["application/json"].(map[string]any)
|
||||
if !hasJSON {
|
||||
media = map[string]any{}
|
||||
content["application/json"] = media
|
||||
}
|
||||
errorMedia := genericErrorResponse("", cfg)["content"].(map[string]any)["application/json"].(map[string]any)
|
||||
if exists && status < 400 && media["schema"] != nil {
|
||||
media["schema"] = map[string]any{"anyOf": []any{media["schema"], errorMedia["schema"]}}
|
||||
} else if media["schema"] == nil {
|
||||
media["schema"] = errorMedia["schema"]
|
||||
}
|
||||
examples := map[string]any{}
|
||||
codes := []any{}
|
||||
seenCodes := map[string]bool{}
|
||||
description := []string{}
|
||||
if exists {
|
||||
if text, ok := response["description"].(string); ok && text != "" {
|
||||
description = append(description, text)
|
||||
}
|
||||
}
|
||||
sort.Slice(errs, func(i, j int) bool { return errs[i].Name < errs[j].Name })
|
||||
for _, e := range errs {
|
||||
value := policy.Value(responseResult(e))
|
||||
examples[e.Name] = map[string]any{"summary": e.Message, "value": value}
|
||||
result := responseResult(e)
|
||||
result.Message = registry.Example(e.Code).Message
|
||||
value := policy.Value(result)
|
||||
examples[e.Name] = map[string]any{"summary": result.Message, "value": value}
|
||||
codeKey := fmt.Sprint(value[cfg.CodeField])
|
||||
if !seenCodes[codeKey] {
|
||||
codes = append(codes, value[cfg.CodeField])
|
||||
@@ -207,7 +250,7 @@ func decorateDocument(doc map[string]any, spec *Spec) {
|
||||
}
|
||||
}
|
||||
func responseResult(e ErrorDefinition) response.Result {
|
||||
return response.Result{Status: e.Status, Code: e.Code, Message: e.Message, RequestID: "exampleRequest"}
|
||||
return response.Result{Status: e.Status, Code: e.Code, Message: e.Message, BusinessCode: e.BusinessCode, Failed: true, RequestID: "exampleRequest"}
|
||||
}
|
||||
func genericErrorResponse(description string, cfg response.Config) map[string]any {
|
||||
if description == "" {
|
||||
|
||||
@@ -4,12 +4,17 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
)
|
||||
|
||||
// ErrorDefinition declares a public, stable error. Generated constructors return
|
||||
// a fresh error each time so WithCause cannot mutate a shared singleton.
|
||||
type ErrorDefinition struct {
|
||||
Name string `json:"name"`
|
||||
BusinessCode any `json:"businessCode,omitempty"`
|
||||
Params []apierrors.Parameter `json:"params,omitempty"`
|
||||
Catalog string `json:"catalog,omitempty"`
|
||||
Status int `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
|
||||
60
cmd/go-omni/apigen/endpoints_test.go
Normal file
60
cmd/go-omni/apigen/endpoints_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfigurableBuiltinRoutesConsumer(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "endpoints")
|
||||
api := filepath.Join(t.TempDir(), "service.api")
|
||||
writeFile(t, api, "service healthApi {\n @handler customReady\n get /health/ready\n @handler customDoc\n get /openapi.json\n}\n")
|
||||
_, err := Generate(Options{Dir: dir, API: api, Module: "example.com/endpoints", FrameworkDir: frameworkRoot(t)})
|
||||
if err != nil {
|
||||
t.Fatal("built-in paths must be permitted in API contracts", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "endpoints_test.go"), `package main
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"strings"
|
||||
"example.com/endpoints/internal/config"
|
||||
"example.com/endpoints/internal/svc"
|
||||
"example.com/endpoints/internal/handler"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
func TestProjectOwnsDisabledBuiltinPaths(t *testing.T){
|
||||
cfg:=config.Default();cfg.Server.Log.Writer=io.Discard
|
||||
logger,_:=logx.New(cfg.Server.Log);defer logger.Close()
|
||||
deps,err:=svc.New(context.Background(),logger,cfg);if err!=nil{t.Fatal(err)};defer deps.Close()
|
||||
if _,err=handler.NewApp(deps);err==nil || !strings.Contains(err.Error(),"overlaps business"){t.Fatal("expected preflight collision",err)}
|
||||
deps.Config.Server.Endpoints.Readiness.Enabled=false
|
||||
deps.Config.Server.Endpoints.OpenAPI.Enabled=false
|
||||
app,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app.Close()
|
||||
for _,path:=range []string{"/health/ready","/openapi.json"}{
|
||||
out:=httptest.NewRecorder();app.ServeHTTP(out,httptest.NewRequest("GET",path,nil))
|
||||
if out.Code!=501{t.Fatal("expected generated business Logic stub",path,out.Code,out.Body.String())}
|
||||
}
|
||||
deps.Config.Server.Endpoints.Readiness.Enabled=true
|
||||
deps.Config.Server.Endpoints.Readiness.Path="/readyz"
|
||||
deps.Config.Server.Endpoints.OpenAPI.Enabled=true
|
||||
deps.Config.Server.Endpoints.OpenAPI.Path="/spec.json"
|
||||
app2,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app2.Close()
|
||||
for _,path:=range []string{"/readyz","/spec.json"}{out:=httptest.NewRecorder();app2.ServeHTTP(out,httptest.NewRequest("GET",path,nil));if out.Code!=200{t.Fatal(path,out.Code)}}
|
||||
}
|
||||
`)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "-count=1", "./...")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatal(err, string(output))
|
||||
}
|
||||
}
|
||||
119
cmd/go-omni/apigen/errorCatalog.go
Normal file
119
cmd/go-omni/apigen/errorCatalog.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
// A named catalog groups project definitions under one default HTTP status.
|
||||
// Each JSON object key supplies the generated Go function's name, while key is
|
||||
// the independent logical identifier and code is the public string/integer.
|
||||
func (p *parser) parseErrorCatalog() error {
|
||||
pos := p.next().position
|
||||
name, err := p.ident()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exportedName.MatchString(name) {
|
||||
return p.err("errorCatalog requires an UpperCamelCase name")
|
||||
}
|
||||
for _, prior := range p.spec.ErrorCatalogs {
|
||||
if prior == name {
|
||||
return p.err("duplicate errorCatalog " + name)
|
||||
}
|
||||
}
|
||||
p.spec.ErrorCatalogs = append(p.spec.ErrorCatalogs, name)
|
||||
p.skip()
|
||||
props, err := p.metadata()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defaultStatus := 400
|
||||
if value, ok := props["defaultStatus"]; ok {
|
||||
defaultStatus, err = strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return p.err("defaultStatus must be an integer")
|
||||
}
|
||||
}
|
||||
if !apierrors.ValidErrorStatus(defaultStatus) {
|
||||
return p.err("invalid errorCatalog defaultStatus")
|
||||
}
|
||||
for key := range props {
|
||||
if key != "defaultStatus" && key != "errors" {
|
||||
return p.err("unsupported errorCatalog property " + key)
|
||||
}
|
||||
}
|
||||
type entry struct {
|
||||
Key string `json:"key"`
|
||||
Code any `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status int `json:"status"`
|
||||
Params []apierrors.Parameter `json:"params"`
|
||||
}
|
||||
var entries map[string]entry
|
||||
if err := apierrors.DecodeCatalogJSON([]byte(props["errors"]), &entries); err != nil {
|
||||
return fmt.Errorf("%s: catalog %s: %w", pos, name, err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return p.err("errorCatalog requires errors")
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for key := range entries {
|
||||
names = append(names, key)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, fn := range names {
|
||||
e := entries[fn]
|
||||
if e.Code == nil {
|
||||
return p.err("errorCatalog error requires code")
|
||||
}
|
||||
if e.Status == 0 {
|
||||
e.Status = defaultStatus
|
||||
}
|
||||
p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: fn, Status: e.Status, Code: e.Key, Message: e.Message, BusinessCode: e.Code, Params: e.Params, Catalog: name, Position: pos})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func errorCatalog(spec *Spec, names []string) apierrors.Catalog {
|
||||
c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{}}
|
||||
selected := map[string]bool{}
|
||||
for _, n := range names {
|
||||
selected[n] = true
|
||||
}
|
||||
for _, e := range spec.Errors {
|
||||
if names != nil && !selected[e.Name] {
|
||||
continue
|
||||
}
|
||||
c.Definitions[e.Code] = apierrors.Definition{Status: e.Status, Code: e.BusinessCode, Message: e.Message, Params: e.Params}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Validate the exact catalog/template pair used by each route. Different routes
|
||||
// can use different code types without forcing a global response representation.
|
||||
func validateErrorPolicies(spec *Spec) error {
|
||||
for _, r := range spec.Routes {
|
||||
f, err := response.New(responseConfig(spec, r))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = response.WithErrors(errorCatalog(spec, append([]string{}, r.Errors...)), f); err != nil {
|
||||
return fmt.Errorf("%s: %w", r.Position, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func errorCatalogJSON(spec *Spec, names []string) string {
|
||||
data, err := json.Marshal(errorCatalog(spec, names))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
222
cmd/go-omni/apigen/errorCatalog_test.go
Normal file
222
cmd/go-omni/apigen/errorCatalog_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestErrorCatalogDocumentAndValidation(t *testing.T) {
|
||||
path := filepath.Join(frameworkRoot(t), "examples/contracts/errors.api")
|
||||
source := readFile(t, path)
|
||||
spec, err := Parse(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compiled, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"/token", "/raw"} {
|
||||
method := "post"
|
||||
if path == "/raw" {
|
||||
method = "get"
|
||||
}
|
||||
op := operation(t, document(t, compiled), path, method)
|
||||
response := op["responses"].(map[string]any)["200"].(map[string]any)
|
||||
media := response["content"].(map[string]any)["application/json"].(map[string]any)
|
||||
alternatives := media["schema"].(map[string]any)["anyOf"].([]any)
|
||||
if len(alternatives) != 2 {
|
||||
t.Fatal("lost success or failure shape")
|
||||
}
|
||||
if path == "/raw" {
|
||||
if alternatives[0].(map[string]any)["properties"].(map[string]any)["token"] == nil {
|
||||
t.Fatal("raw success wrapped")
|
||||
}
|
||||
}
|
||||
examples := media["examples"].(map[string]any)
|
||||
if len(examples) != 2 {
|
||||
t.Fatal(examples)
|
||||
}
|
||||
for _, name := range []string{"UsernameInvalid", "PasswordInvalid"} {
|
||||
value := examples[name].(map[string]any)["value"].(map[string]any)
|
||||
if value["ok"] != false || value["code"] != float64(400002) || value["msg"] == nil {
|
||||
t.Fatal(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, change := range [][2]string{
|
||||
{`defaultStatus: 200`, `defaultStatus: 204`},
|
||||
{`"status": 403`, `"status": 304`},
|
||||
{`"PASSWORD_INVALID"`, `"USERNAME_INVALID"`},
|
||||
{`"code": 400002`, `"code": 2147483648`},
|
||||
{`"code": 400002`, `"code": "400002"`},
|
||||
{`"code": 400002`, `"code": 1.5`},
|
||||
{`"code": 400002`, `"code": null`},
|
||||
{`"code": 400002`, `"code": 400002, "code": 400003`},
|
||||
{`"message": "用户名格式不正确"`, `"message": "用户名格式不正确", "typo": true`},
|
||||
{`@handler token`, `@errors Unknown\n @handler token`},
|
||||
{`successCode: 0`, `successCode: 0\n codes: {"USERNAME_INVALID": 999}`},
|
||||
} {
|
||||
text := strings.Replace(source, change[0], strings.ReplaceAll(change[1], `\n`, "\n"), 1)
|
||||
s, err := parseText(t, text)
|
||||
if err == nil {
|
||||
_, err = Compile(s)
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("accepted %s", change[1])
|
||||
}
|
||||
}
|
||||
// The legacy declaration is also allowed to carry a HTTP 200 failure.
|
||||
old := strings.Replace(declaredAPI, "EmailTaken 409", "EmailTaken 200", 1)
|
||||
s, err := parseText(t, old)
|
||||
if err == nil {
|
||||
_, err = Compile(s)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorCatalogConsumer(t *testing.T) {
|
||||
testCatalogConsumer(t, "errors.api", "errorCatalog")
|
||||
}
|
||||
|
||||
func testCatalogConsumer(t *testing.T, apiFile, fixtureName string, extraFiles ...map[string]string) {
|
||||
t.Helper()
|
||||
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
|
||||
t.Skip("consumer integration")
|
||||
}
|
||||
root := frameworkRoot(t)
|
||||
dir := filepath.Join(t.TempDir(), "consumer")
|
||||
opts := Options{API: filepath.Join(root, "examples/contracts", apiFile), Dir: dir, Module: "example.com/errorCatalog", FrameworkDir: root}
|
||||
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
|
||||
generate := func(check bool) {
|
||||
t.Helper()
|
||||
if cli == "" {
|
||||
o := opts
|
||||
o.Check = check
|
||||
if _, err := Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
args := []string{"api", "generate", "--api", opts.API, "--dir", dir, "--module", opts.Module}
|
||||
if check {
|
||||
args = append(args, "--check")
|
||||
}
|
||||
cmd := exec.Command(cli, args...)
|
||||
cmd.Dir = filepath.Dir(dir)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("release generate %v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
generate(false)
|
||||
|
||||
files := map[string]string{"consumer_test.go": "consumer_test.go"}
|
||||
for _, extra := range extraFiles {
|
||||
for target, source := range extra {
|
||||
files[target] = source
|
||||
}
|
||||
}
|
||||
before := map[string]string{}
|
||||
for target, source := range files {
|
||||
fixture := readFile(t, filepath.Join("testdata", fixtureName, source))
|
||||
writeFile(t, filepath.Join(dir, target), fixture)
|
||||
before[target] = fixture
|
||||
}
|
||||
generate(false)
|
||||
generate(true)
|
||||
for target, fixture := range before {
|
||||
if readFile(t, filepath.Join(dir, target)) != fixture {
|
||||
t.Fatalf("regeneration overwrote %s", target)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "-count=1", "./...")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("consumer %v\n%s", err, output)
|
||||
}
|
||||
if cli != "" {
|
||||
cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatal(err, string(output))
|
||||
}
|
||||
var module struct {
|
||||
Version, Sum string
|
||||
Replace any
|
||||
}
|
||||
if err = json.Unmarshal(output, &module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if module.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || module.Replace != nil || module.Sum == "" {
|
||||
t.Fatal("not a fixed remote consumer")
|
||||
}
|
||||
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorCatalogImportsAndRouteScopes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, filepath.Join(dir, "common.api"), `errorCatalog Numeric (
|
||||
defaultStatus: 200
|
||||
errors: {"NumberInvalid":{"key":"number.invalid","code":12,"message":"number"}}
|
||||
)
|
||||
errorCatalog Text (
|
||||
defaultStatus: 409
|
||||
errors: {"TextInvalid":{"key":"textInvalid","code":"000012","message":"text"}}
|
||||
)
|
||||
response NumericResponse (
|
||||
codeType: int32
|
||||
successCode: 0
|
||||
errorCode: 9999
|
||||
)
|
||||
`)
|
||||
writeFile(t, filepath.Join(dir, "service.api"), `import "common.api"
|
||||
@server (
|
||||
response: NumericResponse
|
||||
errors: NumberInvalid
|
||||
)
|
||||
service scopes-api {
|
||||
@handler numeric
|
||||
get /number
|
||||
}
|
||||
@server (
|
||||
errors: TextInvalid
|
||||
)
|
||||
service scopes-api {
|
||||
@handler text
|
||||
get /text
|
||||
}
|
||||
`)
|
||||
s, err := Parse(filepath.Join(dir, "service.api"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compiled, err := Compile(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := document(t, compiled)
|
||||
for _, tc := range []struct {
|
||||
path, status, name string
|
||||
code any
|
||||
}{{"/number", "200", "NumberInvalid", float64(12)}, {"/text", "409", "TextInvalid", "000012"}} {
|
||||
response := operation(t, doc, tc.path, "get")["responses"].(map[string]any)[tc.status].(map[string]any)
|
||||
value := response["content"].(map[string]any)["application/json"].(map[string]any)["examples"].(map[string]any)[tc.name].(map[string]any)["value"].(map[string]any)
|
||||
if value["code"] != tc.code {
|
||||
t.Fatal(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
12
cmd/go-omni/apigen/errorInheritance_test.go
Normal file
12
cmd/go-omni/apigen/errorInheritance_test.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package apigen
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestErrorInheritanceConsumer(t *testing.T) {
|
||||
testCatalogConsumer(t, "errorInheritance.api", "errorInheritance", map[string]string{
|
||||
"internal/logic/account/plain.go": "service.go",
|
||||
"internal/logic/account/raw.go": "emptyService.go",
|
||||
"internal/logic/account/cleared.go": "emptyService.go",
|
||||
"internal/handler/account/boundedManual.go": "bounded.go",
|
||||
})
|
||||
}
|
||||
86
cmd/go-omni/apigen/errorParams_test.go
Normal file
86
cmd/go-omni/apigen/errorParams_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestErrorParamsConsumer(t *testing.T) { testCatalogConsumer(t, "errorParams.api", "errorParams") }
|
||||
|
||||
func TestErrorParamsDocumentAndValidation(t *testing.T) {
|
||||
path := filepath.Join(frameworkRoot(t), "examples/contracts/errorParams.api")
|
||||
source := readFile(t, path)
|
||||
spec, err := Parse(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compiled, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := document(t, compiled)
|
||||
definitions := doc["x-omni-errors"].([]any)
|
||||
found := false
|
||||
for _, value := range definitions {
|
||||
e := value.(map[string]any)
|
||||
if e["name"] == "TextTooLong" {
|
||||
found = true
|
||||
params := e["params"].([]any)
|
||||
if len(params) != 2 || params[0].(map[string]any)["name"] != "label" || params[1].(map[string]any)["type"] != "int" || e["message"] != "{label}不能超过{max}个字符" {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing template definition")
|
||||
}
|
||||
for _, path := range []string{"/token", "/raw"} {
|
||||
method := "post"
|
||||
if path == "/raw" {
|
||||
method = "get"
|
||||
}
|
||||
response := operation(t, doc, path, method)["responses"].(map[string]any)["200"].(map[string]any)
|
||||
media := response["content"].(map[string]any)["application/json"].(map[string]any)
|
||||
value := media["examples"].(map[string]any)["TextTooLong"].(map[string]any)["value"].(map[string]any)
|
||||
if value["msg"] != "企业名称不能超过100个字符" || value["code"] != float64(400002) {
|
||||
t.Fatal(value)
|
||||
}
|
||||
if _, ok := value["data"]; !ok {
|
||||
t.Fatal("data null omitted", value)
|
||||
}
|
||||
if len(media["schema"].(map[string]any)["anyOf"].([]any)) != 2 {
|
||||
t.Fatal("success schema lost")
|
||||
}
|
||||
}
|
||||
for _, change := range [][2]string{
|
||||
{`"{label}不能超过{max}个字符"`, `"{label}不能超过{missing}个字符"`},
|
||||
{`"type": "int"`, `"type": "unknown"`},
|
||||
{`"example": 100`, `"example": "100"`},
|
||||
{`"name": "max"`, `"name": "label"`},
|
||||
{`"name": "max"`, `"name": "type"`},
|
||||
{`successCode: 0`, `successCode: 0\n messages: {"TEXT_TOO_LONG":"{label}不能超过{max}个字符"}`},
|
||||
} {
|
||||
changed := strings.Replace(source, change[0], strings.ReplaceAll(change[1], `\n`, "\n"), 1)
|
||||
s, err := parseText(t, changed)
|
||||
if err == nil {
|
||||
_, err = Compile(s)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("accepted", change)
|
||||
}
|
||||
}
|
||||
// Removing examples still produces a concrete, deterministic example.
|
||||
noExamples := strings.ReplaceAll(strings.ReplaceAll(source, `, "example": "企业名称"`, ""), `, "example": 100`, "")
|
||||
s, err := parseText(t, noExamples)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := Compile(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(c.Document), "label不能超过1个字符") {
|
||||
t.Fatal("missing generated example")
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
const minimumVersion = "v0.9.0-preview"
|
||||
const minimumVersion = "v0.13.0-preview"
|
||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||
|
||||
type Options struct {
|
||||
@@ -35,6 +35,7 @@ type FileChange struct {
|
||||
type Output struct {
|
||||
Generated, Created, Preserved, Removed []string
|
||||
Changes []FileChange
|
||||
Diagnostics []Diagnostic
|
||||
}
|
||||
|
||||
// Generate creates or safely updates one contract-driven project. It renders
|
||||
@@ -140,13 +141,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(directory, path)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(path, "contract", "scalar.go")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(path, "lifecycle", "controller.go")); err == nil {
|
||||
localCurrent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !localCurrent {
|
||||
return Output{}, fmt.Errorf("existing framework dependency predates migration extensions; use %s or a current local replacement", minimumVersion)
|
||||
return Output{}, fmt.Errorf("existing framework dependency predates dependency shutdown and configurable endpoint support; use %s or a current local replacement", minimumVersion)
|
||||
}
|
||||
}
|
||||
if o.FrameworkDir != "" {
|
||||
@@ -226,7 +227,7 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile())
|
||||
return apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec))
|
||||
}
|
||||
|
||||
func moduleFile(o Options) ([]byte, error) {
|
||||
@@ -312,12 +313,28 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
generated, seeds := map[string][]byte{}, map[string][]byte{}
|
||||
put := func(files map[string][]byte, name, content string) { files[name] = []byte(content) }
|
||||
if len(c.Spec.Errors) > 0 {
|
||||
content := "package apierrors\nimport omnierrors " + strconv.Quote(frameworkModule+"/errors") + "\n"
|
||||
content := "package apierrors\nimport(\"encoding/json\"; omnierrors " + strconv.Quote(frameworkModule+"/errors") + ")\n"
|
||||
content += fmt.Sprintf("func Catalog()omnierrors.Catalog{var c omnierrors.Catalog;if err:=json.Unmarshal([]byte(%q),&c);err!=nil{panic(err)};return c}\nvar errorRegistry=omnierrors.MustRegistry(Catalog())\n", errorCatalogJSON(c.Spec, nil))
|
||||
for _, e := range c.Spec.Errors {
|
||||
content += fmt.Sprintf("const Code%s = %q\n// %s returns a fresh public error declared in the API contract.\nfunc %s()*omnierrors.APIError{return omnierrors.New(%d,Code%s,%q)}\n", e.Name, e.Code, e.Name, e.Name, e.Status, e.Name, e.Message)
|
||||
var args, values []string
|
||||
for _, p := range e.Params {
|
||||
name := p.Name
|
||||
switch name {
|
||||
case "string", "any", "omnierrors", "errorRegistry", "int", "int32", "int64", "float64", "bool":
|
||||
name = "_" + name
|
||||
}
|
||||
args = append(args, name+" "+p.Type)
|
||||
values = append(values, strconv.Quote(p.Name)+":"+name)
|
||||
}
|
||||
ref := fmt.Sprintf("omnierrors.ByCode(Code%s)", e.Name)
|
||||
if len(args) > 0 {
|
||||
ref += ".WithParams(map[string]any{" + strings.Join(values, ",") + "})"
|
||||
}
|
||||
content += fmt.Sprintf("const Code%s=%q\n// %s returns a fresh, resolved error declared in the API contract.\nfunc %s(%s)*omnierrors.APIError{return errorRegistry.Resolve(%s)}\n", e.Name, e.Code, e.Name, e.Name, strings.Join(args, ","), ref)
|
||||
}
|
||||
put(generated, "internal/apierrors/errorsGen.go", generatedHeader+content)
|
||||
}
|
||||
|
||||
types := "package types\n\n"
|
||||
if len(c.Spec.Scalars) > 0 {
|
||||
types += "import("
|
||||
@@ -343,7 +360,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
put(generated, "internal/types/typesGen.go", generatedHeader+types)
|
||||
put(generated, "docs/openapi.json", string(c.Document)+"\n")
|
||||
put(generated, "internal/handler/openapiGen.go", generatedHeader+fmt.Sprintf("package handler\nimport (\"net/http\"; %q)\nconst OpenAPIDocument = %q\nfunc OpenAPI(c *httpx.Context) error { return c.WriteResponse(200,http.Header{\"Content-Type\": {\"application/json; charset=utf-8\"}},[]byte(OpenAPIDocument)) }\n", frameworkModule+"/httpx", string(c.Document)+"\n"))
|
||||
policySource := fmt.Sprintf("package handler\nimport(\"encoding/json\";\"strings\";%q)\nvar responseTemplates=map[string]response.Encoder{\"\":response.Default(),\n", frameworkModule+"/response")
|
||||
policySource := fmt.Sprintf("package handler\nimport(\"encoding/json\";\"strings\";%q; omnierrors %q)\nvar responseTemplates=map[string]response.Encoder{\"\":response.Default(),\n", frameworkModule+"/response", frameworkModule+"/errors")
|
||||
for _, definition := range c.Spec.Responses {
|
||||
data, err := json.Marshal(definition.Config)
|
||||
if err != nil {
|
||||
@@ -351,10 +368,11 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
}
|
||||
policySource += fmt.Sprintf("%q: newResponseTemplate(%q),\n", definition.Name, string(data))
|
||||
}
|
||||
policySource += "}\nfunc newResponseTemplate(raw string)response.Encoder{var cfg response.Config;decoder:=json.NewDecoder(strings.NewReader(raw));decoder.UseNumber();if err:=decoder.Decode(&cfg);err!=nil{panic(err)};return response.MustNew(cfg)}\nfunc responseEncoder(name string,raw bool)response.Encoder{encoder:=responseTemplates[name];if encoder==nil{panic(\"unknown response template\")};if raw{encoder=response.RawJSON(encoder)};return encoder}\n"
|
||||
policySource += "}\nfunc newResponseTemplate(raw string)*response.Formatter{var cfg response.Config;decoder:=json.NewDecoder(strings.NewReader(raw));decoder.UseNumber();if err:=decoder.Decode(&cfg);err!=nil{panic(err)};return response.MustNew(cfg)}\n"
|
||||
policySource += "func responseEncoder(name string,raw bool,catalogJSON string)response.Encoder{encoder:=responseTemplates[name];if encoder==nil{panic(\"unknown response template\")};var catalog omnierrors.Catalog;if err:=json.Unmarshal([]byte(catalogJSON),&catalog);err!=nil{panic(err)};if len(catalog.Definitions)>0{encoder=response.MustWithErrors(catalog,encoder)};if raw{encoder=response.RawJSON(encoder)};return encoder}\n"
|
||||
put(generated, "internal/handler/responseGen.go", generatedHeader+policySource)
|
||||
imports := fmt.Sprintf("import (\"fmt\"; %q; %q; %q;", frameworkModule+"/httpx", frameworkModule+"/openapi", o.Module+"/internal/servicecontext")
|
||||
routes := "func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) error {\n if router==nil || deps==nil {return fmt.Errorf(\"router and dependencies required\")}\n"
|
||||
imports := fmt.Sprintf("import (\"fmt\"; %q; %q; %q;", frameworkModule+"/httpx", frameworkModule+"/openapi", o.Module+"/internal/svc")
|
||||
routes := "func RegisterRoutes(router openapi.Registrar, deps *svc.ServiceContext) error {\n if router==nil || deps==nil {return fmt.Errorf(\"router and dependencies required\")}\n"
|
||||
if len(c.Spec.Scalars) > 0 {
|
||||
imports += fmt.Sprintf("%q;%q;", frameworkModule+"/contract", o.Module+"/internal/types")
|
||||
routes += "if err:=contract.ValidateScalars(types.ContractScalars()...);err!=nil{return err}\n"
|
||||
@@ -374,7 +392,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
routes += fmt.Sprintf("if deps.Middlewares[%q]==nil {return fmt.Errorf(%q)}\n", m, "missing middleware "+m)
|
||||
}
|
||||
for i, group := range c.Groups {
|
||||
imports += fmt.Sprintf("h%d %q; s%d %q;", i, o.Module+"/internal/handler/"+group, i, o.Module+"/internal/service/"+group)
|
||||
imports += fmt.Sprintf("h%d %q; s%d %q;", i, o.Module+"/internal/handler/"+group, i, o.Module+"/internal/logic/"+group)
|
||||
routes += fmt.Sprintf("h%d := h%d.New(s%d.New(deps),deps)\n", i, i, i)
|
||||
groupRoutes := []Route{}
|
||||
for _, r := range c.Spec.Routes {
|
||||
@@ -393,7 +411,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
needsTypes = needsTypes || r.Request != "" || r.Response != ""
|
||||
needsBinder = needsBinder || (r.Request != "" && !r.CustomBinding)
|
||||
}
|
||||
text += fmt.Sprintf("%q;%q;", frameworkModule+"/httpx", o.Module+"/internal/servicecontext")
|
||||
text += fmt.Sprintf("%q;%q;", frameworkModule+"/httpx", o.Module+"/internal/svc")
|
||||
if needsTypes {
|
||||
text += fmt.Sprintf("%q;", o.Module+"/internal/types")
|
||||
}
|
||||
@@ -401,20 +419,20 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
text += fmt.Sprintf("%q;", frameworkModule+"/contract")
|
||||
}
|
||||
text += ")\n"
|
||||
text += "type Service interface {\n"
|
||||
text += "type Logic interface {\n"
|
||||
for _, r := range groupRoutes {
|
||||
if !r.Manual {
|
||||
text += signature(r) + "\n"
|
||||
}
|
||||
}
|
||||
text += "}\ntype Handler struct {service Service;deps *servicecontext.Context}\nfunc New(s Service,deps ...*servicecontext.Context)*Handler {h:=&Handler{service:s};if len(deps)>0{h.deps=deps[0]};return h}\n"
|
||||
text += "}\ntype Handler struct {logic Logic;deps *svc.ServiceContext}\nfunc New(s Logic,deps ...*svc.ServiceContext)*Handler {h:=&Handler{logic:s};if len(deps)>0{h.deps=deps[0]};return h}\n"
|
||||
for _, r := range groupRoutes {
|
||||
name := upper(r.Handler)
|
||||
// The response policy and body reader cap are installed before App middleware.
|
||||
if r.MaxBodyBytes > 0 {
|
||||
routes += fmt.Sprintf("httpx.HandleWithBodyLimit(router,%q,%q,%d,responseEncoder(%q,%t),h%d.%s", strings.ToUpper(r.Method), r.Path, r.MaxBodyBytes, responseName(r), r.ResponseFormat == "raw", i, name)
|
||||
routes += fmt.Sprintf("httpx.HandleWithBodyLimit(router,%q,%q,%d,responseEncoder(%q,%t,%q),h%d.%s", strings.ToUpper(r.Method), r.Path, r.MaxBodyBytes, responseName(r), r.ResponseFormat == "raw", errorCatalogJSON(c.Spec, append([]string{}, r.Errors...)), i, name)
|
||||
} else {
|
||||
routes += fmt.Sprintf("httpx.HandleWithResponse(router,%q,%q,responseEncoder(%q,%t),h%d.%s", strings.ToUpper(r.Method), r.Path, responseName(r), r.ResponseFormat == "raw", i, name)
|
||||
routes += fmt.Sprintf("httpx.HandleWithResponse(router,%q,%q,responseEncoder(%q,%t,%q),h%d.%s", strings.ToUpper(r.Method), r.Path, responseName(r), r.ResponseFormat == "raw", errorCatalogJSON(c.Spec, append([]string{}, r.Errors...)), i, name)
|
||||
}
|
||||
for _, m := range r.Middleware {
|
||||
routes += fmt.Sprintf(",deps.Middlewares[%q]", m)
|
||||
@@ -446,32 +464,53 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
}
|
||||
call += ", &req"
|
||||
}
|
||||
if r.Response != "" {
|
||||
text += fmt.Sprintf("resp,err:=h.service.%s(%s);if err!=nil{return err};if resp==nil{return fmt.Errorf(\"%s returned a nil response\")}\n", name, call, name)
|
||||
streamingBody := r.BodyMode == "raw" || r.BodyMode == "multipart"
|
||||
if streamingBody {
|
||||
text += fmt.Sprintf("body,err:=httpx.NewBody(c.Request(),%q);if err!=nil{return err}\n", r.BodyMode)
|
||||
call += ",body"
|
||||
}
|
||||
if r.Produces != "" {
|
||||
text += fmt.Sprintf("resp,err:=h.logic.%s(%s);if err!=nil{if resp!=nil{_ = resp.Close()};return err}\n", name, call)
|
||||
if streamingBody {
|
||||
text += "if err:=body.Complete();err!=nil{if resp!=nil{_ =resp.Close()};return err}\n"
|
||||
}
|
||||
text += fmt.Sprintf("return c.WriteStream(%d,%q,resp)\n}\n", r.Status, r.Produces)
|
||||
} else if r.Response != "" {
|
||||
text += fmt.Sprintf("resp,err:=h.logic.%s(%s);if err!=nil{return err};if resp==nil{return fmt.Errorf(\"%s returned a nil response\")}\n", name, call, name)
|
||||
if streamingBody {
|
||||
text += "if err:=body.Complete();err!=nil{return err}\n"
|
||||
}
|
||||
method := map[int]string{200: "OK", 201: "Created", 202: "Accepted"}[r.Status]
|
||||
text += "return c." + method + "(resp)\n}\n"
|
||||
} else {
|
||||
text += fmt.Sprintf("if err:=h.service.%s(%s);err!=nil{return err};return c.NoContent()\n}\n", name, call)
|
||||
text += fmt.Sprintf("if err:=h.logic.%s(%s);err!=nil{return err};", name, call)
|
||||
if streamingBody {
|
||||
text += "if err:=body.Complete();err!=nil{return err};"
|
||||
}
|
||||
text += "return c.NoContent()\n}\n"
|
||||
}
|
||||
service := fmt.Sprintf("package %s\nimport(\"context\"; apierrors %q;", packageName, frameworkModule+"/errors")
|
||||
if r.Request != "" || r.Response != "" {
|
||||
service += fmt.Sprintf("%q;", o.Module+"/internal/types")
|
||||
}
|
||||
if streamingBody || r.Produces != "" {
|
||||
service += fmt.Sprintf("%q;", frameworkModule+"/httpx")
|
||||
}
|
||||
service += ")\n"
|
||||
service += "// " + name + " implements the business operation. This file is never overwritten.\nfunc(s *Service) " + signature(r) + " {\n"
|
||||
service += "// " + name + " implements the business operation. This file is never overwritten.\nfunc(s *Logic) " + signature(r) + " {\n"
|
||||
if starter && r.Handler == "greet" {
|
||||
service = strings.Replace(service, "apierrors "+strconv.Quote(frameworkModule+"/errors")+";", "\"fmt\";", 1)
|
||||
service += "s.deps.Logger.InfoContext(ctx,\"greet.called\")\nreturn &types.GreetResp{Message:fmt.Sprintf(\"Hello %s\",req.Name)},nil\n}"
|
||||
} else {
|
||||
service += "// TODO: implement business behavior and use s.deps for dependencies.\n"
|
||||
if r.Response != "" {
|
||||
if r.Response != "" || r.Produces != "" {
|
||||
service += "return nil, "
|
||||
} else {
|
||||
service += "return "
|
||||
}
|
||||
service += "apierrors.New(501,\"NOT_IMPLEMENTED\",\"operation is not implemented\")\n}"
|
||||
}
|
||||
put(seeds, "internal/service/"+group+"/"+lower(r.Handler)+".go", service)
|
||||
put(seeds, "internal/logic/"+group+"/"+lower(r.Handler)+".go", service)
|
||||
}
|
||||
if !hasAutomatic {
|
||||
text = strings.Replace(text, strconv.Quote(frameworkModule+"/httpx")+";", "", 1)
|
||||
@@ -481,7 +520,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
text = strings.Replace(text, "\"fmt\";", "", 1)
|
||||
}
|
||||
put(generated, "internal/handler/"+group+"/handlersGen.go", generatedHeader+text)
|
||||
put(seeds, "internal/service/"+group+"/service.go", fmt.Sprintf("package %s\nimport %q\ntype Service struct {deps *servicecontext.Context}\nfunc New(deps *servicecontext.Context)*Service{return &Service{deps:deps}}\n", packageName, o.Module+"/internal/servicecontext"))
|
||||
put(seeds, "internal/logic/"+group+"/logic.go", fmt.Sprintf("package %s\nimport %q\ntype Logic struct {deps *svc.ServiceContext}\nfunc New(deps *svc.ServiceContext)*Logic{return &Logic{deps:deps}}\n", packageName, o.Module+"/internal/svc"))
|
||||
put(seeds, "internal/repository/"+group+"/repository.go", "// Package "+packageName+" owns persistence contracts for this business group.\n// Define repository interfaces and implementations here; HTTP DTOs do not\n// determine a database schema. This file is never overwritten.\npackage "+packageName+"\n")
|
||||
}
|
||||
put(generated, "internal/handler/routesGen.go", generatedHeader+"package handler\n"+imports+")\n"+routes+"return nil\n}\n")
|
||||
@@ -498,8 +537,12 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
if name == "etc/config.yaml" && o.LogOutput == "file" {
|
||||
content = strings.Replace(content, "output: file", "output: stderr\n file: ./var/log/server.log\n rotation:\n enabled: true\n maxSizeMb: 100\n maxBackups: 10\n maxAgeDays: 7\n compress: true", 1)
|
||||
}
|
||||
if name == "main.go" {
|
||||
put(generated, name, generatedHeader+content)
|
||||
} else {
|
||||
put(seeds, name, content)
|
||||
}
|
||||
}
|
||||
|
||||
infra, err := scaffold.APIInfrastructure(o.Module, o.Database, o.Storage, o.LogOutput, o.LogFormat)
|
||||
if err != nil {
|
||||
@@ -514,17 +557,14 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
return nil, nil, errors.New("/storage/object is reserved by local storage")
|
||||
}
|
||||
}
|
||||
main := string(seeds["cmd/server/main.go"])
|
||||
main = strings.Replace(main, " if err=handler.RegisterRoutes", ` for _,method:=range []string{"GET","HEAD","PUT"}{app.Handle(method,"/storage/object",func(c *httpx.Context)error{deps.FileHTTP.ServeHTTP(c.Raw(),c.Request());return nil})}
|
||||
if err=handler.RegisterRoutes`, 1)
|
||||
main = strings.Replace(main, `"git.yuntuo.hk/k2safe/go-omni/logx"`, `"git.yuntuo.hk/k2safe/go-omni/logx"; "git.yuntuo.hk/k2safe/go-omni/httpx"`, 1)
|
||||
seeds["cmd/server/main.go"] = []byte(main)
|
||||
}
|
||||
put(generated, "internal/handler/appGen.go", generatedHeader+appSource(o, c.Spec))
|
||||
|
||||
return generated, seeds, nil
|
||||
}
|
||||
func hasResponse(routes []Route) bool {
|
||||
for _, r := range routes {
|
||||
if r.Response != "" && !r.Manual {
|
||||
if r.Response != "" && !r.Manual && r.Produces == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -535,7 +575,13 @@ func signature(r Route) string {
|
||||
if r.Request != "" {
|
||||
s += ",req *types." + r.Request
|
||||
}
|
||||
if r.BodyMode == "raw" || r.BodyMode == "multipart" {
|
||||
s += ",body *httpx.Body"
|
||||
}
|
||||
s += ") "
|
||||
if r.Produces != "" {
|
||||
return s + "(*httpx.Stream,error)"
|
||||
}
|
||||
if r.Response != "" {
|
||||
s += "(*types." + r.Response + ",error)"
|
||||
} else {
|
||||
|
||||
240
cmd/go-omni/apigen/logicCheck.go
Normal file
240
cmd/go-omni/apigen/logicCheck.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/build"
|
||||
"go/format"
|
||||
goparser "go/parser"
|
||||
"go/scanner"
|
||||
gotoken "go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type logicMethod struct {
|
||||
path, signature string
|
||||
line int
|
||||
known bool
|
||||
}
|
||||
|
||||
// checkLogic inspects source only: no project code, init hooks, Go commands or
|
||||
// dependency downloads are executed. Seeds participate only when files do not
|
||||
// already exist, matching apply's ownership rules.
|
||||
func checkLogic(root, module string, contract *contractSnapshot, seeds map[string][]byte) ([]Diagnostic, map[string]logicMethod) {
|
||||
groups := map[string][]routeContract{}
|
||||
knownTypes := map[string]bool{}
|
||||
for _, r := range contract.Routes {
|
||||
if !r.Manual {
|
||||
groups[r.Group] = append(groups[r.Group], r)
|
||||
}
|
||||
for name := range r.Types {
|
||||
knownTypes[name] = true
|
||||
}
|
||||
}
|
||||
// Project-owned aliases may make differently named DTOs identical. Let
|
||||
// the compiler handle such cases rather than rejecting valid Go aliases.
|
||||
typeExtensions := false
|
||||
if entries, err := os.ReadDir(filepath.Join(root, "internal/types")); err == nil {
|
||||
for _, e := range entries {
|
||||
if sourceFile(e.Name()) && e.Name() != "typesGen.go" && e.Name() != "scalarsGen.go" {
|
||||
typeExtensions = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var diagnostics []Diagnostic
|
||||
locations := map[string]logicMethod{}
|
||||
for group, routes := range groups {
|
||||
directory := "internal/logic/" + group
|
||||
files := map[string][]byte{}
|
||||
uncertain := false
|
||||
entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(directory)))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: "error", Code: "logicUnreadable", Path: directory, Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !sourceFile(e.Name()) {
|
||||
continue
|
||||
}
|
||||
name := directory + "/" + e.Name()
|
||||
if err := safePath(root, name); err != nil {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: "error", Code: "logicUnreadable", Path: name, Message: err.Error()})
|
||||
uncertain = true
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
diagnostics = append(diagnostics, Diagnostic{Severity: "error", Code: "logicUnreadable", Path: name, Message: err.Error()})
|
||||
uncertain = true
|
||||
continue
|
||||
}
|
||||
files[name] = data
|
||||
}
|
||||
for name, data := range seeds {
|
||||
if filepath.ToSlash(filepath.Dir(name)) == directory && sourceFile(filepath.Base(name)) {
|
||||
if _, exists := files[name]; !exists {
|
||||
files[name] = data
|
||||
}
|
||||
}
|
||||
}
|
||||
methods := map[string][]logicMethod{}
|
||||
var names []string
|
||||
for name := range files {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
data := files[name]
|
||||
// Do not guess the user's GOFLAGS/build-tag combination. Flag the
|
||||
// group for compiler verification if conditional files are present.
|
||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(name))); err == nil {
|
||||
match, err := build.Default.MatchFile(filepath.Join(root, filepath.FromSlash(directory)), filepath.Base(name))
|
||||
if err != nil || !match {
|
||||
uncertain = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
positions := gotoken.NewFileSet()
|
||||
file, err := goparser.ParseFile(positions, name, data, goparser.SkipObjectResolution|goparser.ParseComments)
|
||||
if err != nil {
|
||||
d := Diagnostic{Severity: "error", Code: "logicSyntax", Path: name, Message: "cannot parse Logic source: " + err.Error()}
|
||||
if list, ok := err.(scanner.ErrorList); ok && len(list) > 0 {
|
||||
d.Line = list[0].Pos.Line
|
||||
}
|
||||
diagnostics = append(diagnostics, d)
|
||||
uncertain = true
|
||||
continue
|
||||
}
|
||||
conditional := false
|
||||
for _, group := range file.Comments {
|
||||
for _, comment := range group.List {
|
||||
if comment.Pos() < file.Package && (strings.HasPrefix(comment.Text, "//go:build ") || strings.HasPrefix(comment.Text, "// +build ")) {
|
||||
conditional = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if conditional {
|
||||
uncertain = true
|
||||
continue
|
||||
}
|
||||
imports := map[string]string{}
|
||||
for _, imp := range file.Imports {
|
||||
path, err := strconv.Unquote(imp.Path.Value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
alias := filepath.Base(path)
|
||||
if imp.Name != nil {
|
||||
alias = imp.Name.Name
|
||||
}
|
||||
imports[alias] = path
|
||||
}
|
||||
for _, decl := range file.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok || fn.Recv == nil || len(fn.Recv.List) != 1 {
|
||||
continue
|
||||
}
|
||||
receiver := fn.Recv.List[0].Type
|
||||
if pointer, ok := receiver.(*ast.StarExpr); ok {
|
||||
receiver = pointer.X
|
||||
}
|
||||
id, ok := receiver.(*ast.Ident)
|
||||
if !ok || id.Name != "Logic" {
|
||||
continue
|
||||
}
|
||||
params, a := canonicalFields(fn.Type.Params, imports, module, knownTypes, typeExtensions)
|
||||
results, b := canonicalFields(fn.Type.Results, imports, module, knownTypes, typeExtensions)
|
||||
methods[fn.Name.Name] = append(methods[fn.Name.Name], logicMethod{name, displaySignature(fn.Name.Name, params, results), positions.Position(fn.Pos()).Line, a && b})
|
||||
}
|
||||
}
|
||||
for _, r := range routes {
|
||||
found := methods[upper(r.Handler)]
|
||||
if len(found) == 1 && !uncertain {
|
||||
locations[r.file()] = found[0]
|
||||
}
|
||||
d := Diagnostic{Path: r.file(), Operation: r.operation(), After: r.Signature}
|
||||
switch {
|
||||
case uncertain:
|
||||
d.Severity, d.Code, d.Message = "review", "logicUnchecked", "conditional or unreadable source prevents a complete signature check; run go test ./... with your build settings"
|
||||
case len(found) == 0:
|
||||
d.Severity, d.Code, d.Message = "review", "logicNotFound", "no direct Logic method found; check embedded/aliased implementations with go test ./..."
|
||||
case len(found) > 1:
|
||||
d.Severity, d.Code, d.Message = "error", "logicDuplicate", "multiple Logic methods have this name"
|
||||
for _, m := range found {
|
||||
d.Details = append(d.Details, fmt.Sprintf("%s:%d", m.path, m.line))
|
||||
}
|
||||
case !found[0].known:
|
||||
d.Severity, d.Code, d.Message = "review", "logicUnchecked", "signature uses project-specific type names; verify aliases with go test ./..."
|
||||
d.Path, d.Line, d.Before = found[0].path, found[0].line, found[0].signature
|
||||
case found[0].signature != r.Signature:
|
||||
d.Severity, d.Code, d.Message = "error", "logicSignature", "preserved Logic signature differs from the generated call; update this method"
|
||||
d.Path, d.Line, d.Before = found[0].path, found[0].line, found[0].signature
|
||||
default:
|
||||
continue
|
||||
}
|
||||
diagnostics = append(diagnostics, d)
|
||||
}
|
||||
}
|
||||
return diagnostics, locations
|
||||
}
|
||||
|
||||
func sourceFile(name string) bool {
|
||||
return strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go") && !strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "_")
|
||||
}
|
||||
|
||||
func canonicalFields(fields *ast.FieldList, imports map[string]string, module string, knownTypes map[string]bool, extensions bool) ([]string, bool) {
|
||||
var values []string
|
||||
known := true
|
||||
if fields != nil {
|
||||
for _, field := range fields.List {
|
||||
value, ok := canonicalType(field.Type, imports, module, knownTypes, extensions)
|
||||
known = known && ok
|
||||
count := len(field.Names)
|
||||
if count == 0 {
|
||||
count = 1
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return values, known
|
||||
}
|
||||
|
||||
func canonicalType(expr ast.Expr, imports map[string]string, module string, knownTypes map[string]bool, extensions bool) (string, bool) {
|
||||
switch e := expr.(type) {
|
||||
case *ast.ParenExpr:
|
||||
return canonicalType(e.X, imports, module, knownTypes, extensions)
|
||||
case *ast.StarExpr:
|
||||
value, ok := canonicalType(e.X, imports, module, knownTypes, extensions)
|
||||
return "*" + value, ok
|
||||
case *ast.Ellipsis:
|
||||
value, ok := canonicalType(e.Elt, imports, module, knownTypes, extensions)
|
||||
return "..." + value, ok
|
||||
case *ast.Ident:
|
||||
switch e.Name {
|
||||
case "error", "bool", "string", "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "uintptr", "float32", "float64", "complex64", "complex128", "any":
|
||||
return e.Name, true
|
||||
}
|
||||
case *ast.SelectorExpr:
|
||||
if pkg, ok := e.X.(*ast.Ident); ok {
|
||||
switch imports[pkg.Name] {
|
||||
case "context":
|
||||
return "context." + e.Sel.Name, true
|
||||
case frameworkModule + "/httpx":
|
||||
return "httpx." + e.Sel.Name, true
|
||||
case module + "/internal/types":
|
||||
return "types." + e.Sel.Name, knownTypes[e.Sel.Name] || !extensions
|
||||
}
|
||||
}
|
||||
}
|
||||
var b bytes.Buffer
|
||||
_ = format.Node(&b, gotoken.NewFileSet(), expr)
|
||||
return b.String(), false
|
||||
}
|
||||
214
cmd/go-omni/apigen/managed_test.go
Normal file
214
cmd/go-omni/apigen/managed_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManagedDefaultValidation(t *testing.T) {
|
||||
source := readFile(t, filepath.Join(frameworkRoot(t), "examples/contracts/managed.api"))
|
||||
spec, err := parseText(t, source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.Routes[0].ResponseFormat != "" {
|
||||
t.Fatal("compile mutated source")
|
||||
}
|
||||
var doc map[string]any
|
||||
if err = json.Unmarshal(c.Document, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc["x-omni-default-response"] != "CommonResponse" {
|
||||
t.Fatal("missing default metadata")
|
||||
}
|
||||
for _, bad := range []string{
|
||||
strings.Replace(source, "defaultResponse: CommonResponse", "defaultResponse: Missing", 1),
|
||||
source + "\n@app (defaultResponse: CommonResponse)\n",
|
||||
strings.Replace(source, "maxBytes: 2048", "maxBytes: 0", 1),
|
||||
strings.Replace(source, "@produces \"text/plain\"", "@produces \"text/plain; charset=utf-8\"", 1),
|
||||
} {
|
||||
s, err := parseText(t, bad)
|
||||
if err == nil {
|
||||
_, err = Compile(s)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("invalid contract accepted")
|
||||
}
|
||||
}
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "service.api"), "import \"child.api\"\nservice demo {\n@handler hello\nget /hello\n}")
|
||||
writeFile(t, filepath.Join(root, "child.api"), "@app (defaultResponse: Shared)\nresponse Shared ()")
|
||||
if _, err = Parse(filepath.Join(root, "service.api")); err == nil {
|
||||
t.Fatal("import owns App defaults")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedGeneratedProject(t *testing.T) {
|
||||
root := frameworkRoot(t)
|
||||
dir := filepath.Join(t.TempDir(), "managed")
|
||||
o := Options{Dir: dir, API: filepath.Join(root, "examples/contracts/managed.api"), Module: "example.com/managed", FrameworkDir: root}
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o.API = filepath.Join(dir, "api/managed.api")
|
||||
for _, path := range []string{"cmd/server/main.go", "internal/settings", "internal/servicecontext", "internal/service"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(err) {
|
||||
t.Fatalf("old layout remains: %s", path)
|
||||
}
|
||||
}
|
||||
mainBefore := readFile(t, filepath.Join(dir, "main.go"))
|
||||
files := map[string]string{
|
||||
"consumer_test.go": "consumer_test.go", "internal/svc/options.go": "options.go", "internal/svc/client.go": "client.go", "internal/logic/demo/check.go": "operations.go",
|
||||
"shutdown_test.go": "shutdown_test.go",
|
||||
}
|
||||
for path, fixture := range files {
|
||||
writeFile(t, filepath.Join(dir, path), readFile(t, filepath.Join("testdata/managed", fixture)))
|
||||
}
|
||||
for _, name := range []string{"other", "rawJSON", "upload", "download", "callback", "slow"} {
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/demo", name+".go"), "package demo\n")
|
||||
}
|
||||
configPath := filepath.Join(dir, "internal/config/config.go")
|
||||
writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n FailInit bool `json:\"failInit\"`\n ShutdownInit bool `json:\"shutdownInit\"`", 1))
|
||||
svcPath := filepath.Join(dir, "internal/svc/serviceContext.go")
|
||||
source := strings.Replace(readFile(t, svcPath), "type ServiceContext struct {", "type ServiceContext struct {\n Client *Client", 1)
|
||||
source = strings.Replace(source, "return c, nil", "if err=initClient(ctx,c);err!=nil{return nil,err}\n return c,nil", 1)
|
||||
writeFile(t, svcPath, source)
|
||||
// Independent tooling stays outside generator ownership.
|
||||
writeFile(t, filepath.Join(dir, "cmd/migrate/main.go"), "package main\nfunc main(){}\n")
|
||||
before := snapshot(t, dir)
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("regeneration changed handwritten files")
|
||||
}
|
||||
o.Check = true
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o.Check = false
|
||||
// Add an API endpoint: handler and Logic stub appear without touching main or business code.
|
||||
api := readFile(t, o.API)
|
||||
writeFile(t, o.API, api+"\n@server (group: extra)\nservice managed-api {\n@handler extra\nget /extra\n}\n")
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if readFile(t, filepath.Join(dir, "main.go")) != mainBefore {
|
||||
t.Fatal("main requires business-specific edits")
|
||||
}
|
||||
for path := range files {
|
||||
if readFile(t, filepath.Join(dir, path)) != string(before[path]) {
|
||||
t.Fatal("business file changed", path)
|
||||
}
|
||||
}
|
||||
// Main is owned, checked, and restored when missing. Hand edits are a conflict.
|
||||
writeFile(t, filepath.Join(dir, "main.go"), mainBefore+"\n// drift\n")
|
||||
o.Check = true
|
||||
if err := generateManagedTestProject(t, o); err == nil {
|
||||
t.Fatal("main drift accepted")
|
||||
}
|
||||
o.Check = false
|
||||
if err := os.Remove(filepath.Join(dir, "main.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o.Check = true
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
for _, args := range [][]string{{"test", "-race", "-mod=mod", "-count=1", "./..."}, {"build", "-mod=mod", "."}} {
|
||||
cmd := exec.CommandContext(ctx, "go", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
|
||||
cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
data, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatal(err, string(data))
|
||||
}
|
||||
var dependency struct {
|
||||
Version, Sum string
|
||||
Replace any
|
||||
}
|
||||
if err = json.Unmarshal(data, &dependency); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" {
|
||||
t.Fatal("not a fixed remote consumer")
|
||||
}
|
||||
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func generateManagedTestProject(t *testing.T, o Options) error {
|
||||
t.Helper()
|
||||
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
|
||||
if cli == "" {
|
||||
_, err := Generate(o)
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
args := []string{"api", "generate", "--api", o.API, "--dir", o.Dir, "--module", o.Module}
|
||||
if o.Check {
|
||||
args = append(args, "--check")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, cli, args...)
|
||||
cmd.Dir = filepath.Dir(o.Dir)
|
||||
if data, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("released CLI: %w\n%s", err, data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestManagedOwnershipRejectsOldOrHandwrittenEntry(t *testing.T) {
|
||||
for _, oldLayout := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "handwrittenMain", true: "oldManifest"}[oldLayout], func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "app")
|
||||
if _, err := New(Options{Dir: dir, Module: "example.com/entry", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, manifestPath))), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if oldLayout {
|
||||
m.Version = 1
|
||||
} else {
|
||||
delete(m.Generated, "main.go")
|
||||
writeFile(t, filepath.Join(dir, "main.go"), "package main\nfunc main(){}\n")
|
||||
}
|
||||
data, _ := json.Marshal(m)
|
||||
writeFile(t, filepath.Join(dir, manifestPath), string(data))
|
||||
before := snapshot(t, dir)
|
||||
if _, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}); err == nil {
|
||||
t.Fatal("unowned layout accepted")
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("rejected migration modified project")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,7 @@ func TestDeclaredErrorsAndAuthentication(t *testing.T) {
|
||||
func TestInvalidDeclarations(t *testing.T) {
|
||||
replacements := [][2]string{
|
||||
{`"EMAIL_TAKEN"`, `"UNAUTHENTICATED"`},
|
||||
{`EmailTaken 409`, `EmailTaken 200`},
|
||||
{`EmailTaken 409`, `EmailTaken 204`},
|
||||
{`EmailTaken 409`, `emailTaken 409`},
|
||||
{`NameTaken 409`, `CodeEmailTaken 409`},
|
||||
{`auth: UserAuth`, `auth: Unknown`},
|
||||
@@ -252,7 +252,7 @@ func TestDeclaredContractConsumer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
const declaredConsumerTest = `package dx_test
|
||||
const declaredConsumerTest = `package main_test
|
||||
import (
|
||||
"context";"errors";"io";"net/http/httptest";"strings";"testing"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
@@ -260,13 +260,13 @@ import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"example.com/dx/internal/apierrors"
|
||||
"example.com/dx/internal/handler"
|
||||
"example.com/dx/internal/servicecontext"
|
||||
"example.com/dx/internal/svc"
|
||||
)
|
||||
func TestRuntimeAuthAndFreshErrors(t *testing.T){
|
||||
first:=apierrors.EmailTaken().WithCause(errors.New("private"));second:=apierrors.EmailTaken()
|
||||
if first==second||second.Cause!=nil||second.Code!=apierrors.CodeEmailTaken||second.Status!=409{t.Fatal("shared error instance")}
|
||||
cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close()
|
||||
deps,err:=servicecontext.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close()
|
||||
deps,err:=svc.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close()
|
||||
app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
|
||||
w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,httptest.NewRequest("POST","/users",nil))
|
||||
if w.Code!=501||!strings.Contains(w.Body.String(),"MIDDLEWARE_NOT_IMPLEMENTED"){t.Fatal("unimplemented auth did not fail closed")}
|
||||
|
||||
@@ -14,8 +14,11 @@ import (
|
||||
)
|
||||
|
||||
type Spec struct {
|
||||
DefaultResponse string
|
||||
appDeclared bool
|
||||
Title, Version, Service, Description string
|
||||
Errors []ErrorDefinition
|
||||
ErrorCatalogs []string
|
||||
Auth []AuthDefinition
|
||||
Responses []ResponseDefinition
|
||||
Scalars []ScalarDefinition
|
||||
@@ -266,6 +269,10 @@ func (p *parser) parse() error {
|
||||
}
|
||||
p.imports = append(p.imports, v)
|
||||
}
|
||||
case "errorCatalog":
|
||||
if err := p.parseErrorCatalog(); err != nil {
|
||||
return err
|
||||
}
|
||||
case "error":
|
||||
if err := p.parseErrors(); err != nil {
|
||||
return err
|
||||
@@ -295,6 +302,26 @@ func (p *parser) parse() error {
|
||||
}
|
||||
case "@":
|
||||
p.next()
|
||||
if p.peek() == "app" {
|
||||
if p.spec.appDeclared {
|
||||
return p.err("duplicate @app declaration")
|
||||
}
|
||||
// Defaults belong to the entry contract, never an imported module.
|
||||
if !strings.HasPrefix(p.tokens[p.at].position, p.spec.Entry+":") {
|
||||
return p.err("@app must be declared in the entry API file")
|
||||
}
|
||||
p.next()
|
||||
m, err := p.metadata()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(m) != 1 || m["defaultResponse"] == "" {
|
||||
return p.err("@app requires only defaultResponse: TemplateName")
|
||||
}
|
||||
p.spec.DefaultResponse = m["defaultResponse"]
|
||||
p.spec.appDeclared = true
|
||||
continue
|
||||
}
|
||||
if err := p.expect("server"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -550,7 +577,7 @@ func (p *parser) parseService(server map[string]string) error {
|
||||
if p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" {
|
||||
return p.err("unexpected route suffix")
|
||||
}
|
||||
if route.Response == "" && !seen["status"] && !route.Manual {
|
||||
if route.Response == "" && !seen["status"] && !route.Manual && route.Produces == "" {
|
||||
route.Status = 204
|
||||
}
|
||||
p.spec.Routes = append(p.spec.Routes, route)
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestInfrastructureProfileOwnership(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// Restoring a missing seed must use the saved profile, not current defaults.
|
||||
if err := os.Remove(filepath.Join(dir, "internal/servicecontext/storage.go")); err != nil {
|
||||
if err := os.Remove(filepath.Join(dir, "internal/svc/storage.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Generate(o); err == nil {
|
||||
@@ -62,7 +62,7 @@ func TestAPIInfrastructureConsumers(t *testing.T) {
|
||||
if _, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
test := `package settings
|
||||
test := `package config
|
||||
import("testing";"os")
|
||||
func TestConfiguration(t *testing.T){
|
||||
t.Setenv("GO_OMNI_DB_DSN","test:test@tcp(127.0.0.1:1)/test")
|
||||
@@ -82,7 +82,7 @@ func TestConfiguration(t *testing.T){
|
||||
if storage == "oss" {
|
||||
endpoint = "https://oss-cn-hangzhou.aliyuncs.com"
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "internal/settings/profile_test.go"), strings.ReplaceAll(test, `"ENDPOINT"`, `"`+endpoint+`"`))
|
||||
writeFile(t, filepath.Join(dir, "internal/config/profile_test.go"), strings.ReplaceAll(test, `"ENDPOINT"`, `"`+endpoint+`"`))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-count=1", "./...")
|
||||
|
||||
@@ -25,7 +25,7 @@ func ProjectEntry(directory string) (string, error) {
|
||||
if err = json.Unmarshal(data, &m); err != nil {
|
||||
return "", fmt.Errorf("invalid API project manifest: %w", err)
|
||||
}
|
||||
if m.Version != 1 || filepath.Ext(m.Entry) != ".api" {
|
||||
if m.Version != 2 || filepath.Ext(m.Entry) != ".api" {
|
||||
return "", fmt.Errorf("invalid API entry in %s; pass --api explicitly", manifestPath)
|
||||
}
|
||||
if err = safePath(root, m.Entry); err != nil {
|
||||
|
||||
@@ -36,9 +36,6 @@ func validateRouteExtensions(r Route) error {
|
||||
if r.BodyMode != "" && r.BodyMode != "json" && r.BodyMode != "raw" && r.BodyMode != "multipart" {
|
||||
return errors.New("body mode must be json, raw or multipart")
|
||||
}
|
||||
if (r.BodyMode == "raw" || r.BodyMode == "multipart") && !r.Manual {
|
||||
return errors.New("raw/multipart body requires @handlerMode manual")
|
||||
}
|
||||
if r.CustomBinding && (r.Manual || r.Request == "") {
|
||||
return errors.New("@binding custom requires an automatic handler with a request DTO")
|
||||
}
|
||||
@@ -46,15 +43,12 @@ func validateRouteExtensions(r Route) error {
|
||||
return errors.New("GET/HEAD cannot declare a body limit")
|
||||
}
|
||||
if r.Produces != "" {
|
||||
if !r.Manual {
|
||||
return errors.New("@produces requires @handlerMode manual")
|
||||
}
|
||||
media, params, err := mime.ParseMediaType(r.Produces)
|
||||
if err != nil || len(params) != 0 || media != r.Produces || strings.Contains(media, "*") {
|
||||
return errors.New("produces requires a concrete media type without parameters")
|
||||
}
|
||||
if r.Response != "" || r.Status == 204 || r.Method == "head" {
|
||||
return errors.New("@produces uses a manual response without a DTO, 204 or HEAD")
|
||||
return errors.New("@produces requires a stream response without a DTO, 204 or HEAD")
|
||||
}
|
||||
}
|
||||
if r.Manual && r.Status == 204 && r.Response != "" {
|
||||
|
||||
@@ -102,6 +102,18 @@ func validateResponses(spec *Spec) error {
|
||||
}
|
||||
seen[definition.Name] = true
|
||||
}
|
||||
if spec.DefaultResponse != "" && !seen[spec.DefaultResponse] {
|
||||
return fmt.Errorf("unknown App default response template %s", spec.DefaultResponse)
|
||||
}
|
||||
for i := range spec.Routes {
|
||||
r := &spec.Routes[i]
|
||||
if r.ResponseFormat == "" {
|
||||
r.ResponseFormat = spec.DefaultResponse
|
||||
}
|
||||
if r.ResponseFormat == "raw" && r.ErrorResponseFormat == "" {
|
||||
r.ErrorResponseFormat = spec.DefaultResponse
|
||||
}
|
||||
}
|
||||
for _, r := range spec.Routes {
|
||||
if r.ResponseFormat != "" && r.ResponseFormat != "raw" && !seen[r.ResponseFormat] {
|
||||
return fmt.Errorf("%s: unknown response template %s", r.Position, r.ResponseFormat)
|
||||
|
||||
@@ -262,7 +262,7 @@ func TestResponseTemplateConsumer(t *testing.T) {
|
||||
if method == "Partner" {
|
||||
imports += `;"example.com/responseConsumer/internal/apierrors"`
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "internal/service/items", strings.ToLower(method)+".go"), "package items\nimport("+imports+")\nfunc(s *Service)"+method+"(ctx context.Context)(*types.ItemResponse,error){"+body+"}\n")
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/items", strings.ToLower(method)+".go"), "package items\nimport("+imports+")\nfunc(s *Logic)"+method+"(ctx context.Context)(*types.ItemResponse,error){"+body+"}\n")
|
||||
}
|
||||
before := snapshot(t, dir)
|
||||
generate(filepath.Join(dir, "api/service.api"), false)
|
||||
@@ -310,7 +310,7 @@ func TestResponseTemplateConsumer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
const responseConsumerTest = `package responseConsumer_test
|
||||
const responseConsumerTest = `package main_test
|
||||
import (
|
||||
"bytes";"encoding/json";"io";"net/http/httptest";"testing"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
@@ -322,7 +322,7 @@ import (
|
||||
item "example.com/responseConsumer/internal/handler/items"
|
||||
"example.com/responseConsumer/internal/types"
|
||||
"context"
|
||||
"example.com/responseConsumer/internal/servicecontext"
|
||||
"example.com/responseConsumer/internal/svc"
|
||||
)
|
||||
type service struct{}
|
||||
func(service)Item(context.Context)(*types.ItemResponse,error){return &types.ItemResponse{Name:"item"},nil}
|
||||
@@ -331,7 +331,7 @@ func(service)Partner(context.Context)(*types.ItemResponse,error){return nil,apie
|
||||
func TestResponseContract(t *testing.T){
|
||||
app:=omni.New();defer app.Close()
|
||||
cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close()
|
||||
deps,err:=servicecontext.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close()
|
||||
deps,err:=svc.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close()
|
||||
deps.Middlewares["Auth"]=func(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{if c.Header("Authorization")!="Bearer valid"{return apierrors.Unauthenticated()};return next(c)}}
|
||||
if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
|
||||
// Registered routes use the compiled policy before even running authentication.
|
||||
|
||||
68
cmd/go-omni/apigen/startup.go
Normal file
68
cmd/go-omni/apigen/startup.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func appSource(o Options, spec *Spec) string {
|
||||
source := fmt.Sprintf(`package handler
|
||||
import (
|
||||
"fmt"
|
||||
omni %q
|
||||
%q
|
||||
%q
|
||||
%q
|
||||
%q
|
||||
)
|
||||
// NewApp is generated HTTP composition. The caller owns deps and its logger.
|
||||
func NewApp(deps *svc.ServiceContext)(*omni.App,error){
|
||||
if deps==nil || deps.Lifecycle==nil{return nil,fmt.Errorf("service context and lifecycle controller required; see PROJECT.md upgrade instructions")}
|
||||
cfg:=deps.Config.Server
|
||||
for _,option:=range deps.AppOptions(){if option!=nil{option(&cfg)}}
|
||||
`, frameworkModule, frameworkModule+"/httpx", frameworkModule+"/config", frameworkModule+"/health", o.Module+"/internal/svc")
|
||||
if spec.DefaultResponse != "" {
|
||||
for _, r := range spec.Responses {
|
||||
if r.Name == spec.DefaultResponse {
|
||||
data, _ := json.Marshal(r.Config)
|
||||
source += fmt.Sprintf("cfg.Response=newResponseTemplate(%q).Config();cfg.ResponseEncoder=nil\n", string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
source += "if err:=cfg.Validate();err!=nil{return nil,err}\n"
|
||||
source += "if err:=cfg.Endpoints.ValidateRoutes([]config.EndpointRoute{\n"
|
||||
for _, route := range spec.Routes {
|
||||
source += fmt.Sprintf("{Method:%q,Path:%q},\n", route.Method, route.Path)
|
||||
}
|
||||
if o.Storage == "local" {
|
||||
source += "{Method: \"GET\",Path: \"/storage/object\"},\n"
|
||||
}
|
||||
source += `}...);err!=nil{return nil,err}
|
||||
if (cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.Readiness.Enabled) && deps.Health==nil{return nil,fmt.Errorf("enabled health endpoints require a checker")}
|
||||
app:=omni.New(omni.WithConfig(cfg))
|
||||
app.Use(deps.GlobalMiddleware()...)
|
||||
if err:=RegisterRoutes(app,deps);err!=nil{_ = app.Close();return nil,err}
|
||||
if cfg.Endpoints.OpenAPI.Enabled{app.GET(cfg.Endpoints.OpenAPI.Path,OpenAPI)}
|
||||
if cfg.Endpoints.Liveness.Enabled{
|
||||
var options []health.HandlerOption
|
||||
if cfg.Endpoints.Liveness.ResponseMode=="raw"{options=append(options,health.WithRawResponse())}
|
||||
app.GET(cfg.Endpoints.Liveness.Path,deps.Health.LivenessHandler(options...))
|
||||
}
|
||||
if cfg.Endpoints.Readiness.Enabled{
|
||||
options:=[]health.HandlerOption{health.WithShutdownContext(deps.Lifecycle.Context())}
|
||||
if cfg.Endpoints.Readiness.ResponseMode=="raw"{options=append(options,health.WithRawResponse())}
|
||||
app.GET(cfg.Endpoints.Readiness.Path,deps.Health.ReadinessHandler(options...))
|
||||
}
|
||||
`
|
||||
if o.Storage == "local" {
|
||||
source += `if deps.FileHTTP==nil{return nil,fmt.Errorf("local storage handler required")}
|
||||
for _,method:=range []string{"GET","HEAD","PUT"}{app.Handle(method,"/storage/object",httpx.WrapHandler(deps.FileHTTP))}
|
||||
`
|
||||
}
|
||||
source += "return app,nil\n}\n"
|
||||
if o.Storage != "local" {
|
||||
source = strings.Replace(source, fmt.Sprintf("%q", frameworkModule+"/httpx"), "", 1)
|
||||
}
|
||||
return source
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package apigen
|
||||
import "fmt"
|
||||
|
||||
var baseTemplates = map[string]string{
|
||||
"cmd/server/main.go": `package main
|
||||
"main.go": `package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
@@ -12,35 +12,34 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"MODULE/internal/config"
|
||||
"MODULE/internal/handler"
|
||||
"MODULE/internal/servicecontext"
|
||||
"MODULE/internal/settings"
|
||||
"MODULE/internal/svc"
|
||||
)
|
||||
func main(){
|
||||
path:=flag.String("config","etc/config.yaml","configuration file; empty uses defaults and environment")
|
||||
flag.Parse()
|
||||
if err:=run(*path);err!=nil{log.Fatal(err)}
|
||||
}
|
||||
func run(path string)(runErr error){
|
||||
ctx,stop:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)
|
||||
defer stop()
|
||||
cfg,err:=settings.Load(path);if err!=nil{return err}
|
||||
if err:=run(ctx,*path);err!=nil{log.Fatal(err)}
|
||||
}
|
||||
func run(ctx context.Context,path string)(runErr error){
|
||||
cfg,err:=config.Load(path);if err!=nil{return err}
|
||||
logger,err:=logx.New(cfg.Server.Log);if err!=nil{return err}
|
||||
defer func(){runErr=errors.Join(runErr,logger.Close())}()
|
||||
logx.SetDefault(logger)
|
||||
deps,err:=servicecontext.New(ctx,logger,cfg);if err!=nil{return err}
|
||||
defer func(){runErr=errors.Join(runErr,deps.Close())}()
|
||||
app:=omni.New(omni.WithConfig(cfg.Server))
|
||||
app.OnShutdown(logger.Close)
|
||||
app.OnShutdown(deps.Close)
|
||||
if err=handler.RegisterRoutes(app,deps);err!=nil{return err}
|
||||
app.GET("/openapi.json",handler.OpenAPI)
|
||||
app.GET("/health/live",deps.Health.LivenessHandler())
|
||||
app.GET("/health/ready",deps.Health.ReadinessHandler())
|
||||
logger.InfoContext(ctx,"service.starting","address",cfg.Server.Address)
|
||||
return app.RunContext(ctx,cfg.Server.Address)
|
||||
deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
|
||||
// RunContext drains HTTP requests before returning. This scope owns dependencies
|
||||
// and closes them exactly once, before closing the borrowed logger.
|
||||
defer func(){
|
||||
runErr=errors.Join(runErr,deps.Close())
|
||||
if cause:=deps.Lifecycle.Err();cause!=nil && !errors.Is(runErr,cause){runErr=errors.Join(runErr,cause)}
|
||||
}()
|
||||
app,err:=handler.NewApp(deps);if err!=nil{return err}
|
||||
defer func(){runErr=errors.Join(runErr,app.Close())}()
|
||||
logger.InfoContext(ctx,"service.starting","address",app.Config().Address)
|
||||
return app.RunContext(deps.Lifecycle.Context())
|
||||
}
|
||||
`,
|
||||
".gitignore": "/server\n/var/\n.env\n.env.*\n!.env.example\n/.goOmni/api.lock\n",
|
||||
@@ -51,16 +50,21 @@ func projectREADME(module, entry string) string {
|
||||
|
||||
This project is generated from an editable API contract. Business code uses
|
||||
standard context.Context; generated HTTP handlers use Omni's transport-neutral httpx.
|
||||
main.go and HTTP composition are continuously generator-owned.
|
||||
|
||||
## Run
|
||||
|
||||
`+"```sh"+`
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
go run .
|
||||
`+"```"+`
|
||||
|
||||
GET /health/live and /health/ready check the running service. GET /openapi.json
|
||||
serves the same document as docs/openapi.json. Bind address defaults to localhost.
|
||||
Configure server.endpoints to disable or move each built-in route. Health
|
||||
responseMode is app by default (project format), or raw for plain health data.
|
||||
Probe HTTP statuses remain 200/503 even when business errors use HTTP 200.
|
||||
OpenAPI remains an unwrapped document; disabled endpoints reserve no paths.
|
||||
Set GO_OMNI_TRANSPORT=native to select the native transport without changing services.
|
||||
The greeting starter works immediately; other new operation stubs return 501 until
|
||||
their business logic is implemented. Authentication hooks also fail closed with 501.
|
||||
@@ -83,9 +87,9 @@ go test ./...
|
||||
Commands inside a generated project discover the recorded API entry automatically.
|
||||
--dry-run previews create/update/remove actions without writing files.
|
||||
The --check command is read-only and suitable for CI. It fails on stale or missing
|
||||
generated files and missing business scaffolds. go.mod, configuration, main,
|
||||
Service, Repository, servicecontext, middleware hook bodies and API files are
|
||||
created once and then preserved byte for byte. Gen.go files and OpenAPI are owned
|
||||
generated files and missing business scaffolds. go.mod, configuration, Logic,
|
||||
Repository, svc, middleware hook bodies and API files are created once and then
|
||||
preserved byte for byte. main.go, Gen.go files and OpenAPI are owned
|
||||
by the generator; manual edits cause a conflict instead of being overwritten.
|
||||
Commit .goOmni/apiManifest.json alongside the generated files. Do not edit it.
|
||||
Avoid editing files while generation is running; api.lock serializes generators.
|
||||
@@ -95,18 +99,30 @@ business files remain: remove/adapt obsolete implementations explicitly if an
|
||||
API change makes their old types or signatures fail to compile. Every regeneration
|
||||
must be followed by go test ./... . Do not delete service files to change contracts.
|
||||
|
||||
Generation compares the last saved contract with the new one and reports affected
|
||||
Logic files, including nested DTO field/tag changes and streaming/body changes.
|
||||
REVIEW notices require a business review. ERROR diagnostics identify definite
|
||||
Logic signature, syntax or duplicate-method problems. Generation preserves
|
||||
business code and still writes valid generated output; fix those methods next.
|
||||
--check fails on definite Logic errors even after generated files are current.
|
||||
Import aliases, parameter names and moved methods within a group are supported.
|
||||
Project type aliases, embedded methods or build tags may need compiler verification
|
||||
and produce REVIEW notices. This source scan does not execute project code or
|
||||
replace go test ./... . Snapshot-based REVIEW notices describe changes since the
|
||||
last successful generation; they do not persist as an acknowledgement checklist.
|
||||
|
||||
## Layout
|
||||
|
||||
`+"```text"+`
|
||||
api/ # editable contract and imported types
|
||||
cmd/server/main.go # startup, lifecycle and route registration
|
||||
main.go # GENERATED startup, lifecycle and route registration
|
||||
etc/config.yaml # lowerCamelCase configuration
|
||||
internal/settings/ # defaults -> file -> environment
|
||||
internal/servicecontext/ # dependency initialization and cleanup
|
||||
internal/config/ # defaults -> file -> environment
|
||||
internal/svc/ # dependency initialization and cleanup
|
||||
internal/types/typesGen.go # generated request/response types
|
||||
internal/apierrors/errorsGen.go # generated fresh error constructors, when declared
|
||||
internal/handler/<group>/ # generated binding and service adapters
|
||||
internal/service/<group>/ # developer-owned business operations
|
||||
internal/handler/<group>/ # generated binding and Logic adapters
|
||||
internal/logic/<group>/ # developer-owned business operations
|
||||
internal/repository/<group>/ # developer-owned persistence contracts
|
||||
internal/middleware/ # named hooks and generated registry
|
||||
docs/openapi.json # generated OpenAPI 3.0.3
|
||||
@@ -114,12 +130,25 @@ docs/openapi.json # generated OpenAPI 3.0.3
|
||||
`+"```"+`
|
||||
|
||||
The API response type describes business data. Declare response Name (...) and
|
||||
select it with @server(response: Name) or @response Name to share a public body;
|
||||
select the App default with @app(defaultResponse: Name) in the entry API.
|
||||
@server(response: Name) and @response Name override it for groups/routes;
|
||||
@response raw returns the business JSON directly. Without a template, the default
|
||||
envelope includes code, message, data and requestId. Generated routes and OpenAPI
|
||||
use the API contract, independently of the app's default response configuration.
|
||||
use the same contract. 404/405 and ordinary App routes use the declared App default.
|
||||
No template is selected by declaration/import order. Without @app, existing framework
|
||||
response defaults apply. An explicit API App default takes precedence over response
|
||||
options in svc, keeping generated routes and OpenAPI consistent.
|
||||
Handlers reject nil successful responses. Body DTOs use application/json and have a 1 MiB limit by default.
|
||||
Use contract.Binder.Bind's optional limit in a custom adapter for larger payloads.
|
||||
Use @body(maxBytes: ...) for route limits. @body(mode: raw|multipart; maxBytes: ...)
|
||||
adds a *httpx.Body argument to Logic, without handwritten handlers. Raw is the exact
|
||||
limited request stream; Parts supports sequential multipart reading with no temp files.
|
||||
@produces "application/octet-stream" (or another concrete media type) generates a
|
||||
Logic result of *httpx.Stream; the handler closes Content and streams it unwrapped.
|
||||
Signature verification and storage policy belong to Logic/middleware. A consumed
|
||||
request stream cannot be reread automatically; use one reader owner or explicitly
|
||||
bounded buffering. Stream reads respect configured server timeouts. Multipart parts
|
||||
are not DTO form fields: form tags remain URL query parameters. The optional manual
|
||||
handler/custom binder hooks are preserved, and are needed only for custom protocols.
|
||||
|
||||
Fields require exactly one source tag: json, path, form (URL query only), header.
|
||||
All are required by presence unless optional, omitempty or default is specified.
|
||||
@@ -132,20 +161,43 @@ and binding compiler. Responses are expected to honor their declared constraints
|
||||
they are not validated on every response. Do not return nil slices/maps for a
|
||||
non-nullable collection (initialize empty collections instead).
|
||||
|
||||
Declare public business errors in error (...) and reference them with @errors.
|
||||
Go-owned errors can be registered once through svc.ServiceContext.AppOptions with
|
||||
omni.WithErrors(projecterrors.Catalog()). Generated routes inherit them unless they
|
||||
declare an independent catalog. Do not repeat Go-owned errors in the API contract.
|
||||
Alternatively, declare public business errors in error (...) and reference them with @errors.
|
||||
Use internal/apierrors constructors to share status, code and message with OpenAPI.
|
||||
Auth declarations connect a bearer/header/cookie scheme to a real named middleware.
|
||||
The selected auth runs first; @auth none removes inherited declarative auth.
|
||||
Validation policy remains in the middleware implementation. Public messages must
|
||||
not contain secrets; error causes may be included in server logs.
|
||||
|
||||
svc.New receives the loaded Config and borrowed Logger. Add dependencies there,
|
||||
then immediately register each cleanup with OnClose. A later initialization failure
|
||||
runs registered cleanups in reverse order. Close is idempotent. GlobalMiddleware
|
||||
provides global hooks; Middlewares supplies named API hooks. AppOptions is evaluated
|
||||
once before route registration; logging is configured earlier through Config.
|
||||
The generated main runs HTTP draining before closing svc resources, then the logger.
|
||||
Do not close borrowed resources or attach duplicate cleanup hooks to App. The startup
|
||||
context cancels on signals; long-lived clients/workers that requests depend on need
|
||||
their own lifetime, canceled by OnClose after HTTP draining.
|
||||
|
||||
svc.RequestShutdown(err) lets a dependency report a fatal service failure.
|
||||
It returns immediately. Generated main stops accepting requests, drains them,
|
||||
closes dependencies and logs, then exits nonzero with the original cause.
|
||||
nil requests a normal stop. Concurrent calls keep the first non-nil failure;
|
||||
notifications during initialization or draining are retained. Close must stop
|
||||
and join project-owned monitors before the final shutdown result is returned.
|
||||
The svc lifecycle context signals draining, not permission to close resources
|
||||
still used by active requests. Liveness and readiness are different: shutdown
|
||||
makes readiness 503; it does not turn a running process's liveness into failure.
|
||||
|
||||
Named middleware is composed in declaration order (first is outermost). Implement
|
||||
the generated hooks or replace entries in servicecontext; never bypass an auth
|
||||
the generated hooks or replace entries in svc; never bypass an auth
|
||||
hook merely to make the server start. Middleware names do not imply a JWT policy.
|
||||
|
||||
This CLI supports a documented go-zero-style subset, not all goctl features.
|
||||
Database tables and repositories cannot be inferred from an HTTP contract. Add
|
||||
business repositories through servicecontext. Database and storage options are
|
||||
business repositories through svc. Database and storage options are
|
||||
composed at creation; the original profile is retained on regeneration.
|
||||
`, module, entry)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package compatibility_test
|
||||
package main_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"example.com/compatibility/internal/handler"
|
||||
"example.com/compatibility/internal/servicecontext"
|
||||
"example.com/compatibility/internal/svc"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"io"
|
||||
@@ -25,7 +25,7 @@ func TestGeneratedCompatibility(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
deps, err := servicecontext.New(context.Background(), logger)
|
||||
deps, err := svc.New(context.Background(), logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
"example.com/compatibility/internal/types"
|
||||
)
|
||||
|
||||
func (s *Service) Echo(ctx context.Context, req *types.EchoReq) (*types.EchoResp, error) {
|
||||
func (s *Logic) Echo(ctx context.Context, req *types.EchoReq) (*types.EchoResp, error) {
|
||||
return &types.EchoResp{ID: req.ID, Data: req.Data}, nil
|
||||
}
|
||||
func (s *Service) Item(ctx context.Context, req *types.ItemReq) (*types.EchoResp, error) {
|
||||
func (s *Logic) Item(ctx context.Context, req *types.ItemReq) (*types.EchoResp, error) {
|
||||
return &types.EchoResp{ID: req.ID, Data: map[string]any{"found": true}}, nil
|
||||
}
|
||||
func (s *Service) Custom(ctx context.Context, req *types.CustomReq) (*types.CustomResp, error) {
|
||||
func (s *Logic) Custom(ctx context.Context, req *types.CustomReq) (*types.CustomResp, error) {
|
||||
return &types.CustomResp{Value: req.Value}, nil
|
||||
}
|
||||
|
||||
92
cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go
vendored
Normal file
92
cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
package main_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"example.com/errorCatalog/internal/apierrors"
|
||||
"example.com/errorCatalog/internal/handler"
|
||||
"example.com/errorCatalog/internal/svc"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestGeneratedCatalog(t *testing.T) {
|
||||
first := apierrors.UsernameInvalid().WithCause(errors.New("private"))
|
||||
second := apierrors.UsernameInvalid()
|
||||
if first == second || second.Cause != nil || second.Status != 200 || second.BusinessCode != int64(400002) {
|
||||
t.Fatal("shared or incomplete generated error")
|
||||
}
|
||||
copy := apierrors.Catalog()
|
||||
delete(copy.Definitions, apierrors.CodeUsernameInvalid)
|
||||
if len(apierrors.Catalog().Definitions) != 3 {
|
||||
t.Fatal("shared catalog")
|
||||
}
|
||||
cfg := logx.Default()
|
||||
cfg.Writer = io.Discard
|
||||
logger, err := logx.New(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
deps, err := svc.New(context.Background(), logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deps.Close()
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
// Global middleware exits early: generated route policy must already be active.
|
||||
app.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
switch c.Header("X-Fixture") {
|
||||
case "username":
|
||||
return omnierrors.ByCode(apierrors.CodeUsernameInvalid).WithCause(errors.New("private"))
|
||||
case "password":
|
||||
return apierrors.PasswordInvalid()
|
||||
case "denied":
|
||||
return apierrors.PermissionDenied()
|
||||
case "unknown":
|
||||
return omnierrors.ByCode("MISSING")
|
||||
default:
|
||||
return c.OK(map[string]string{"token": "ok"})
|
||||
}
|
||||
}
|
||||
})
|
||||
if err := handler.RegisterRoutes(app, deps); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"/token", "/raw"} {
|
||||
for _, test := range []struct {
|
||||
kind string
|
||||
status int
|
||||
want string
|
||||
}{{"username", 200, "用户名格式不正确"}, {"password", 200, "密码格式不正确"}, {"denied", 403, "没有权限"}, {"unknown", 500, "internal server error"}, {"", 200, `"token":"ok"`}} {
|
||||
method := "POST"
|
||||
if path == "/raw" {
|
||||
method = "GET"
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(method, path, nil)
|
||||
r.Header.Set("X-Fixture", test.kind)
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Code != test.status || !strings.Contains(w.Body.String(), test.want) || strings.Contains(w.Body.String(), "private") {
|
||||
t.Fatal(path, test.kind, w.Code, w.Body.String())
|
||||
}
|
||||
if test.kind == "username" || test.kind == "password" {
|
||||
if !strings.Contains(w.Body.String(), `"code":400002`) || !strings.Contains(w.Body.String(), `"ok":false`) {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
}
|
||||
if path == "/raw" && test.kind == "" && strings.Contains(w.Body.String(), "code") {
|
||||
t.Fatal("raw success wrapped")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
cmd/go-omni/apigen/testdata/errorInheritance/bounded.go
vendored
Normal file
13
cmd/go-omni/apigen/testdata/errorInheritance/bounded.go
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"io"
|
||||
)
|
||||
|
||||
func (h *Handler) Bounded(c *httpx.Context) error {
|
||||
if _, err := io.Copy(io.Discard, c.Request().Body); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Text(200, "ACK")
|
||||
}
|
||||
129
cmd/go-omni/apigen/testdata/errorInheritance/consumer_test.go
vendored
Normal file
129
cmd/go-omni/apigen/testdata/errorInheritance/consumer_test.go
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
package main_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"example.com/errorCatalog/internal/handler"
|
||||
"example.com/errorCatalog/internal/svc"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
func TestGoOwnedCatalogOnGeneratedRoutes(t *testing.T) {
|
||||
catalog := apierrors.Catalog{DefaultStatus: 400, Definitions: map[string]apierrors.Definition{
|
||||
"USERNAME_INVALID": {Code: 400002, Message: "用户名格式不正确"},
|
||||
"TEXT_TOO_LONG": {Status: 200, Code: 400003, Message: "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int"}}},
|
||||
"INTERNAL_ERROR": {Status: 500, Code: 9999, Message: "系统错误"},
|
||||
"BODY_TOO_LARGE": {Status: 413, Code: 413001, Message: "请求体超限"},
|
||||
}}
|
||||
lc := logx.Default()
|
||||
lc.Writer = io.Discard
|
||||
logger, err := logx.New(lc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
deps, err := svc.New(context.Background(), logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deps.Close()
|
||||
format := response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always", RequestIDField: "-"}
|
||||
app := omni.New(omni.WithErrors(catalog), omni.WithResponse(format))
|
||||
defer app.Close()
|
||||
resolve := func(c *httpx.Context) error {
|
||||
switch c.Request().URL.Query().Get("kind") {
|
||||
case "long":
|
||||
return apierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": "企业名称", "max": 100})
|
||||
case "unknown":
|
||||
return apierrors.ByCode("NOT_REGISTERED")
|
||||
default:
|
||||
return apierrors.ByCode("USERNAME_INVALID")
|
||||
}
|
||||
}
|
||||
app.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
if c.Header("X-Early") == "true" {
|
||||
return resolve(c)
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
app.GET("/ordinary", resolve)
|
||||
if err := handler.RegisterRoutes(app, deps); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Explicit route policy stays independent of App errors and response fields.
|
||||
local := apierrors.Catalog{Definitions: map[string]apierrors.Definition{"USERNAME_INVALID": {Status: 409, Code: 409001, Message: "路由提示"}}}
|
||||
localFormat := format
|
||||
localFormat.MessageField = "detail"
|
||||
app.HandleWithResponse("GET", "/independent", response.MustWithErrors(local, response.MustNew(localFormat)), resolve)
|
||||
for _, early := range []string{"false", "true"} {
|
||||
for _, test := range []struct {
|
||||
kind string
|
||||
status, code int
|
||||
message string
|
||||
}{
|
||||
{"invalid", 400, 400002, "用户名格式不正确"}, {"long", 200, 400003, "企业名称不能超过100个字符"}, {"unknown", 500, 9999, "系统错误"},
|
||||
} {
|
||||
var ordinary string
|
||||
for _, path := range []string{"/ordinary", "/plain", "/raw", "/cleared"} {
|
||||
r := httptest.NewRequest("GET", path+"?kind="+test.kind, nil)
|
||||
r.Header.Set("X-Early", early)
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err, w.Body.String())
|
||||
}
|
||||
if w.Code != test.status || body["code"] != float64(test.code) || body["msg"] != test.message {
|
||||
t.Fatal(path, early, w.Code, body)
|
||||
}
|
||||
if value, ok := body["data"]; !ok || value != nil {
|
||||
t.Fatal("data:null missing", body)
|
||||
}
|
||||
if path == "/ordinary" {
|
||||
ordinary = w.Body.String()
|
||||
} else if w.Body.String() != ordinary {
|
||||
t.Fatal("generated response differs", ordinary, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
r := httptest.NewRequest("GET", "/independent", nil)
|
||||
r.Header.Set("X-Early", early)
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Code != 409 || !strings.Contains(w.Body.String(), `"detail":"路由提示"`) || !strings.Contains(w.Body.String(), `"code":409001`) {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/plain", "/raw"} {
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("GET", path+"?kind=success", nil))
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `"ok":true`) {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if path == "/raw" && strings.Contains(w.Body.String(), "code") {
|
||||
t.Fatal("raw success wrapped")
|
||||
}
|
||||
}
|
||||
for _, size := range []int{8, 65} {
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("POST", "/bounded", strings.NewReader(strings.Repeat("x", size))))
|
||||
if size == 8 {
|
||||
if w.Code != 200 || w.Body.String() != "ACK" {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
} else if w.Code != 413 || !strings.Contains(w.Body.String(), "请求体超限") {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
1
cmd/go-omni/apigen/testdata/errorInheritance/emptyService.go
vendored
Normal file
1
cmd/go-omni/apigen/testdata/errorInheritance/emptyService.go
vendored
Normal file
@@ -0,0 +1 @@
|
||||
package account
|
||||
29
cmd/go-omni/apigen/testdata/errorInheritance/service.go
vendored
Normal file
29
cmd/go-omni/apigen/testdata/errorInheritance/service.go
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/errorCatalog/internal/types"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
)
|
||||
|
||||
func check(kind string) (*types.CheckResponse, error) {
|
||||
switch kind {
|
||||
case "success":
|
||||
return &types.CheckResponse{OK: true}, nil
|
||||
case "long":
|
||||
return nil, apierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": "企业名称", "max": 100})
|
||||
case "unknown":
|
||||
return nil, apierrors.ByCode("NOT_REGISTERED")
|
||||
default:
|
||||
return nil, apierrors.ByCode("USERNAME_INVALID")
|
||||
}
|
||||
}
|
||||
func (s *Logic) Plain(ctx context.Context, req *types.CheckRequest) (*types.CheckResponse, error) {
|
||||
return check(req.Kind)
|
||||
}
|
||||
func (s *Logic) Raw(ctx context.Context, req *types.CheckRequest) (*types.CheckResponse, error) {
|
||||
return check(req.Kind)
|
||||
}
|
||||
func (s *Logic) Cleared(ctx context.Context, req *types.CheckRequest) (*types.CheckResponse, error) {
|
||||
return check(req.Kind)
|
||||
}
|
||||
149
cmd/go-omni/apigen/testdata/errorParams/consumer_test.go
vendored
Normal file
149
cmd/go-omni/apigen/testdata/errorParams/consumer_test.go
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
package main_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"example.com/errorCatalog/internal/apierrors"
|
||||
"example.com/errorCatalog/internal/handler"
|
||||
"example.com/errorCatalog/internal/svc"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestGeneratedTemplateErrors(t *testing.T) {
|
||||
var document map[string]any
|
||||
if err := json.Unmarshal([]byte(handler.OpenAPIDocument), &document); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
operation := document["paths"].(map[string]any)["/token"].(map[string]any)["post"].(map[string]any)
|
||||
media := operation["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
|
||||
example := media["examples"].(map[string]any)["TextTooLong"].(map[string]any)["value"].(map[string]any)
|
||||
if example["msg"] != "企业名称不能超过100个字符" || example["code"] != float64(400002) {
|
||||
t.Fatal(example)
|
||||
}
|
||||
if len(media["schema"].(map[string]any)["anyOf"].([]any)) != 2 {
|
||||
t.Fatal("missing success/failure schema")
|
||||
}
|
||||
found := false
|
||||
for _, raw := range document["x-omni-errors"].([]any) {
|
||||
entry := raw.(map[string]any)
|
||||
if entry["name"] == "TextTooLong" {
|
||||
found = true
|
||||
params := entry["params"].([]any)
|
||||
if len(params) != 2 || params[0].(map[string]any)["type"] != "string" || params[1].(map[string]any)["type"] != "int" {
|
||||
t.Fatal(params)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing template metadata")
|
||||
}
|
||||
// Assigning to a typed function also verifies declaration order and Go types.
|
||||
var typed func(string, int) *omnierrors.APIError = apierrors.TextTooLong
|
||||
private := errors.New("private cause")
|
||||
first := typed("企业名称", 100).WithCause(private)
|
||||
second := typed("备注", 500)
|
||||
if first.Message != "企业名称不能超过100个字符" || second.Message != "备注不能超过500个字符" || second.Cause != nil || !errors.Is(first, private) {
|
||||
t.Fatal(first, second)
|
||||
}
|
||||
if apierrors.UsernameInvalid().Message != "用户名格式不正确" {
|
||||
t.Fatal("fixed compatibility")
|
||||
}
|
||||
cat := apierrors.Catalog()
|
||||
cat.Definitions["TEXT_TOO_LONG"].Params[0].Type = "bool"
|
||||
if typed("企业名称", 100).Message != first.Message {
|
||||
t.Fatal("mutable catalog")
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for n := 0; n < 40; n++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
if n%2 == 0 {
|
||||
if typed("企业名称", 100).Message != first.Message {
|
||||
t.Error("isolation")
|
||||
}
|
||||
} else {
|
||||
if typed("备注", 500).Message != second.Message {
|
||||
t.Error("isolation")
|
||||
}
|
||||
}
|
||||
}(n)
|
||||
}
|
||||
wg.Wait()
|
||||
cfg := logx.Default()
|
||||
cfg.Writer = io.Discard
|
||||
logger, err := logx.New(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
deps, err := svc.New(context.Background(), logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deps.Close()
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
app.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
switch c.Header("X-Fixture") {
|
||||
case "company":
|
||||
return typed("企业名称", 100).WithCause(private)
|
||||
case "remark":
|
||||
return omnierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": "备注", "max": 500})
|
||||
case "missing":
|
||||
return omnierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": "企业名称"})
|
||||
case "type":
|
||||
return omnierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": "企业名称", "max": "100"})
|
||||
case "fixed":
|
||||
return apierrors.UsernameInvalid()
|
||||
case "denied":
|
||||
return apierrors.ResourceDenied("资源")
|
||||
default:
|
||||
return c.OK(map[string]string{"token": "ok"})
|
||||
}
|
||||
}
|
||||
})
|
||||
if err := handler.RegisterRoutes(app, deps); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"/token", "/raw"} {
|
||||
for _, test := range []struct {
|
||||
kind string
|
||||
status int
|
||||
want string
|
||||
}{
|
||||
{"company", 200, first.Message}, {"remark", 200, second.Message}, {"missing", 500, "internal server error"}, {"type", 500, "internal server error"}, {"fixed", 200, "用户名格式不正确"}, {"denied", 403, "资源不可用"}, {"", 200, `"token":"ok"`},
|
||||
} {
|
||||
method := "POST"
|
||||
if path == "/raw" {
|
||||
method = "GET"
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(method, path, nil)
|
||||
r.Header.Set("X-Fixture", test.kind)
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Code != test.status || !strings.Contains(w.Body.String(), test.want) || strings.Contains(w.Body.String(), "private") || strings.Contains(w.Body.String(), "{label}") {
|
||||
t.Fatal(test.kind, w.Code, w.Body.String())
|
||||
}
|
||||
if test.kind == "company" || test.kind == "remark" {
|
||||
if !strings.Contains(w.Body.String(), `"code":400002`) || !strings.Contains(w.Body.String(), `"data":null`) {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
}
|
||||
if path == "/raw" && test.kind == "" && strings.Contains(w.Body.String(), "code") {
|
||||
t.Fatal("raw response wrapped")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
cmd/go-omni/apigen/testdata/managed/client.go
vendored
Normal file
31
cmd/go-omni/apigen/testdata/managed/client.go
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
var Opened, Closed, DownloadClosed atomic.Int32
|
||||
var Started = make(chan struct{}, 1)
|
||||
var Release = make(chan struct{})
|
||||
var Current atomic.Pointer[ServiceContext]
|
||||
var FatalDependency = errors.New("test dependency cannot continue")
|
||||
|
||||
// This fake client exercises the same lifetime contract as a database/client.
|
||||
type Client struct{}
|
||||
|
||||
func initClient(ctx context.Context, c *ServiceContext) error {
|
||||
Current.Store(c)
|
||||
Opened.Add(1)
|
||||
c.Client = &Client{}
|
||||
c.OnClose(func() error { Closed.Add(1); c.Logger.Info("test.client.closed"); return nil })
|
||||
if c.Config.FailInit {
|
||||
return errors.New("client initialization failed")
|
||||
}
|
||||
if c.Config.ShutdownInit {
|
||||
c.RequestShutdown(FatalDependency)
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
235
cmd/go-omni/apigen/testdata/managed/consumer_test.go
vendored
Normal file
235
cmd/go-omni/apigen/testdata/managed/consumer_test.go
vendored
Normal file
@@ -0,0 +1,235 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"example.com/managed/internal/config"
|
||||
"example.com/managed/internal/handler"
|
||||
"example.com/managed/internal/svc"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func application(t *testing.T) *omni.App {
|
||||
t.Helper()
|
||||
cfg := config.Default()
|
||||
cfg.Server.Log.Writer = io.Discard
|
||||
logger, err := logx.New(cfg.Server.Log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = logger.Close() })
|
||||
deps, err := svc.New(context.Background(), logger, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = deps.Close() })
|
||||
app, err := handler.NewApp(deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = app.Close() })
|
||||
return app
|
||||
}
|
||||
func response(t *testing.T, app *omni.App, method, path string, body io.Reader, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
out := httptest.NewRecorder()
|
||||
app.ServeHTTP(out, req)
|
||||
return out
|
||||
}
|
||||
func assertError(t *testing.T, out *httptest.ResponseRecorder, status, code int, message, field string) {
|
||||
t.Helper()
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(out.Body.Bytes(), &data); err != nil {
|
||||
t.Fatal(out.Body.String(), err)
|
||||
}
|
||||
value, exists := data["data"]
|
||||
if out.Code != status || data["code"] != float64(code) || data[field] != message || !exists || value != nil {
|
||||
t.Fatalf("unexpected error: %d %s", out.Code, out.Body.String())
|
||||
}
|
||||
}
|
||||
func TestGeneratedAppPolicies(t *testing.T) {
|
||||
app := application(t)
|
||||
assertError(t, response(t, app, "GET", "/check?kind=fixed", nil, nil), 400, 400002, "用户名格式不正确", "msg")
|
||||
assertError(t, response(t, app, "GET", "/check?kind=params", nil, nil), 400, 400003, "企业名称不能超过100个字符", "msg")
|
||||
assertError(t, response(t, app, "GET", "/check?kind=unknown", nil, nil), 500, 9999, "系统错误", "msg")
|
||||
assertError(t, response(t, app, "GET", "/other?kind=fixed", nil, nil), 400, 400002, "用户名格式不正确", "detail")
|
||||
for _, path := range []string{"/check", "/missing", "/callback"} {
|
||||
assertError(t, response(t, app, "GET", path, nil, map[string]string{"X-Fail": "1"}), 400, 400002, "用户名格式不正确", "msg")
|
||||
}
|
||||
assertError(t, response(t, app, "GET", "/missing", nil, nil), 404, 404001, "接口不存在", "msg")
|
||||
assertError(t, response(t, app, "POST", "/check", nil, nil), 405, 405001, "请求方法不支持", "msg")
|
||||
out := response(t, app, "GET", "/raw", nil, nil)
|
||||
if strings.TrimSpace(out.Body.String()) != `{"bytes":3}` {
|
||||
t.Fatal(out.Body.String())
|
||||
}
|
||||
}
|
||||
func TestGeneratedTransfers(t *testing.T) {
|
||||
app := application(t)
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
file, err := writer.CreateFormFile("file", "x.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = file.Write([]byte("hello"))
|
||||
_ = writer.Close()
|
||||
out := response(t, app, "POST", "/upload", &body, map[string]string{"Content-Type": writer.FormDataContentType()})
|
||||
if out.Code != 200 || !strings.Contains(out.Body.String(), `"bytes":5`) {
|
||||
t.Fatalf("upload: %d %s", out.Code, out.Body.String())
|
||||
}
|
||||
out = response(t, app, "POST", "/upload", strings.NewReader("bad"), map[string]string{"Content-Type": "application/json"})
|
||||
if out.Code != 415 {
|
||||
t.Fatal(out.Code, out.Body.String())
|
||||
}
|
||||
// Truncated uploads must use the project's public error catalog rather
|
||||
// than succeed or be mistaken for an internal failure.
|
||||
out = response(t, app, "POST", "/upload", strings.NewReader("--b\r\nContent-Disposition: form-data; name=\"file\"\r\n\r\nprivate file"), map[string]string{"Content-Type": "multipart/form-data; boundary=b"})
|
||||
assertError(t, out, 400, 400004, "上传内容不完整", "msg")
|
||||
out = response(t, app, "POST", "/callback", strings.NewReader("{ \"exact\" : 123 }"), map[string]string{"X-Signature": "valid"})
|
||||
if out.Code != 200 || out.Body.String() != "ACK:{ \"exact\" : 123 }" || out.Header().Get("Content-Type") != "text/plain" {
|
||||
t.Fatal(out.Code, out.Body.String())
|
||||
}
|
||||
assertError(t, response(t, app, "POST", "/callback", strings.NewReader("{}"), nil), 400, 400002, "用户名格式不正确", "msg")
|
||||
// Unknown length must be limited by reads too, not just Content-Length.
|
||||
req := httptest.NewRequest("POST", "/callback", strings.NewReader(strings.Repeat("x", 65)))
|
||||
req.ContentLength = -1
|
||||
req.Header.Set("X-Signature", "valid")
|
||||
out = httptest.NewRecorder()
|
||||
app.ServeHTTP(out, req)
|
||||
assertError(t, out, 413, 413001, "请求体太大", "msg")
|
||||
before := svc.DownloadClosed.Load()
|
||||
out = response(t, app, "GET", "/download", nil, nil)
|
||||
if out.Body.String() != "file-content" || out.Header().Get("Content-Type") != "application/octet-stream" || !strings.Contains(out.Header().Get("Content-Disposition"), "attachment") || svc.DownloadClosed.Load() != before+1 {
|
||||
t.Fatal(out.Code, out.Body.String(), out.Header())
|
||||
}
|
||||
}
|
||||
func configFile(t *testing.T, address string, fail bool) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
data, _ := json.Marshal(map[string]any{"server": map[string]any{"address": address}, "failInit": fail})
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
func waitReady(t *testing.T, url string, done <-chan error) {
|
||||
t.Helper()
|
||||
client := http.Client{Timeout: 100 * time.Millisecond}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("startup failed: %v", err)
|
||||
default:
|
||||
}
|
||||
res, err := client.Get(url + "/health/ready")
|
||||
if err == nil {
|
||||
_ = res.Body.Close()
|
||||
if res.StatusCode == 200 {
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("server did not become ready")
|
||||
}
|
||||
func TestGeneratedMainLifecycle(t *testing.T) {
|
||||
t.Run("initializationFailure", func(t *testing.T) {
|
||||
before := svc.Closed.Load()
|
||||
if err := run(context.Background(), configFile(t, "127.0.0.1:0", true)); err == nil {
|
||||
t.Fatal("failure hidden")
|
||||
}
|
||||
if svc.Closed.Load() != before+1 {
|
||||
t.Fatal("initialized client leaked or closed twice")
|
||||
}
|
||||
})
|
||||
t.Run("listenFailure", func(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
before := svc.Closed.Load()
|
||||
if err = run(context.Background(), configFile(t, listener.Addr().String(), false)); err == nil {
|
||||
t.Fatal("occupied listener accepted")
|
||||
}
|
||||
if svc.Closed.Load() != before+1 {
|
||||
t.Fatal("listen failure cleanup")
|
||||
}
|
||||
})
|
||||
t.Run("drainBeforeClose", func(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := listener.Addr().String()
|
||||
_ = listener.Close()
|
||||
svc.Closed.Store(0)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
path := configFile(t, address, false)
|
||||
go func() { done <- run(ctx, path) }()
|
||||
base := "http://" + address
|
||||
waitReady(t, base, done)
|
||||
requestDone := make(chan error, 1)
|
||||
go func() {
|
||||
res, err := http.Get(base + "/slow")
|
||||
if err == nil {
|
||||
data, _ := io.ReadAll(res.Body)
|
||||
_ = res.Body.Close()
|
||||
if res.StatusCode != 200 || !strings.Contains(string(data), `"bytes":1`) {
|
||||
err = io.ErrUnexpectedEOF
|
||||
}
|
||||
}
|
||||
requestDone <- err
|
||||
}()
|
||||
select {
|
||||
case <-svc.Started:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("request did not enter logic")
|
||||
}
|
||||
cancel()
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
if svc.Closed.Load() != 0 {
|
||||
t.Fatal("dependency closed before drain")
|
||||
}
|
||||
close(svc.Release)
|
||||
select {
|
||||
case err := <-requestDone:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("request stuck")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("shutdown stuck")
|
||||
}
|
||||
if svc.Closed.Load() != 1 {
|
||||
t.Fatal("client closed more/less than once", svc.Closed.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
81
cmd/go-omni/apigen/testdata/managed/operations.go
vendored
Normal file
81
cmd/go-omni/apigen/testdata/managed/operations.go
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
package demo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"example.com/managed/internal/svc"
|
||||
"example.com/managed/internal/types"
|
||||
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
func (l *Logic) Check(ctx context.Context, req *types.CheckReq) (*types.Receipt, error) {
|
||||
switch req.Kind {
|
||||
case "fixed":
|
||||
return nil, omnierrors.ByCode("INVALID")
|
||||
case "params":
|
||||
return nil, omnierrors.ByCode("TOO_LONG").WithParams(map[string]any{"label": "企业名称", "max": 100})
|
||||
case "unknown":
|
||||
return nil, omnierrors.ByCode("UNKNOWN").WithCause(errors.New("private cause"))
|
||||
}
|
||||
return &types.Receipt{Bytes: 42}, nil
|
||||
}
|
||||
func (l *Logic) Other(ctx context.Context, req *types.CheckReq) (*types.Receipt, error) {
|
||||
return l.Check(ctx, req)
|
||||
}
|
||||
func (l *Logic) RawJSON(context.Context) (*types.Receipt, error) {
|
||||
return &types.Receipt{Bytes: 3}, nil
|
||||
}
|
||||
func (l *Logic) Upload(ctx context.Context, body *httpx.Body) (*types.Receipt, error) {
|
||||
var total int64
|
||||
for {
|
||||
part, err := body.Parts.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := io.Copy(io.Discard, part)
|
||||
closeErr := part.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, closeErr
|
||||
}
|
||||
total += n
|
||||
}
|
||||
return &types.Receipt{Bytes: total}, nil
|
||||
}
|
||||
func (l *Logic) Callback(ctx context.Context, body *httpx.Body) (*httpx.Stream, error) {
|
||||
// The route's 64-byte limit bounds this example's memory use. A real signature
|
||||
// implementation can hash body.Raw incrementally and authenticate its headers.
|
||||
data, err := io.ReadAll(body.Raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body.Header.Get("X-Signature") != "valid" {
|
||||
return nil, omnierrors.ByCode("INVALID")
|
||||
}
|
||||
return &httpx.Stream{Content: io.NopCloser(strings.NewReader("ACK:" + string(data)))}, nil
|
||||
}
|
||||
func (l *Logic) Download(context.Context) (*httpx.Stream, error) {
|
||||
return &httpx.Stream{Content: &countedReader{Reader: strings.NewReader("file-content")}, Name: "测试.txt"}, nil
|
||||
}
|
||||
|
||||
type countedReader struct{ io.Reader }
|
||||
|
||||
func (c *countedReader) Close() error { svc.DownloadClosed.Add(1); return nil }
|
||||
func (l *Logic) Slow(context.Context) (*types.Receipt, error) {
|
||||
before := svc.Closed.Load()
|
||||
svc.Started <- struct{}{}
|
||||
<-svc.Release
|
||||
if svc.Closed.Load() != before {
|
||||
return nil, errors.New("dependency closed before drain")
|
||||
}
|
||||
return &types.Receipt{Bytes: 1}, nil
|
||||
}
|
||||
34
cmd/go-omni/apigen/testdata/managed/options.go
vendored
Normal file
34
cmd/go-omni/apigen/testdata/managed/options.go
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
func (c *ServiceContext) AppOptions() []omni.Option {
|
||||
return []omni.Option{omni.WithErrors(omnierrors.Catalog{
|
||||
DefaultStatus: 400,
|
||||
Definitions: map[string]omnierrors.Definition{
|
||||
"INVALID": {Code: 400002, Message: "用户名格式不正确"},
|
||||
"TOO_LONG": {Code: 400003, Message: "{label}不能超过{max}个字符", Params: []omnierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int"}}},
|
||||
"INTERNAL_ERROR": {Status: 500, Code: 9999, Message: "系统错误"},
|
||||
"NOT_FOUND": {Status: 404, Code: 404001, Message: "接口不存在"},
|
||||
"METHOD_NOT_ALLOWED": {Status: 405, Code: 405001, Message: "请求方法不支持"},
|
||||
"BODY_TOO_LARGE": {Status: 413, Code: 413001, Message: "请求体太大"},
|
||||
"INVALID_MULTIPART": {Status: 400, Code: 400004, Message: "上传内容不完整"},
|
||||
},
|
||||
})}
|
||||
}
|
||||
func (c *ServiceContext) GlobalMiddleware() []httpx.Middleware {
|
||||
return []httpx.Middleware{
|
||||
func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
if c.Header("X-Fail") != "" {
|
||||
return omnierrors.ByCode("INVALID")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
231
cmd/go-omni/apigen/testdata/managed/shutdown_test.go
vendored
Normal file
231
cmd/go-omni/apigen/testdata/managed/shutdown_test.go
vendored
Normal file
@@ -0,0 +1,231 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"example.com/managed/internal/config"
|
||||
"example.com/managed/internal/handler"
|
||||
"example.com/managed/internal/svc"
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestGeneratedEndpointSettings(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Server.Log.Writer = io.Discard
|
||||
cfg.Server.Endpoints.Liveness.Path = "/livez"
|
||||
cfg.Server.Endpoints.Readiness.Path = "/readyz"
|
||||
cfg.Server.Endpoints.OpenAPI.Enabled = false
|
||||
logger, err := logx.New(cfg.Server.Log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
deps, err := svc.New(context.Background(), logger, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deps.Close()
|
||||
app, err := handler.NewApp(deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.Close()
|
||||
for _, path := range []string{"/health/live", "/health/ready", "/openapi.json"} {
|
||||
if out := response(t, app, "GET", path, nil, nil); out.Code != 404 {
|
||||
t.Fatal(path, out.Code)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/readyz", "/livez"} {
|
||||
out := response(t, app, "GET", path, nil, nil)
|
||||
var data map[string]any
|
||||
_ = json.Unmarshal(out.Body.Bytes(), &data)
|
||||
if out.Code != 200 || data["code"] != float64(0) || data["msg"] != "success" || data["message"] != nil || data["requestId"] != nil {
|
||||
t.Fatal(path, out.Code, out.Body.String())
|
||||
}
|
||||
}
|
||||
deps.RequestShutdown(errors.New("private shutdown cause"))
|
||||
out := response(t, app, "GET", "/readyz", nil, nil)
|
||||
if out.Code != 503 || strings.Contains(out.Body.String(), "private") {
|
||||
t.Fatal(out.Code, out.Body.String())
|
||||
}
|
||||
if out = response(t, app, "GET", "/livez", nil, nil); out.Code != 200 {
|
||||
t.Fatal("liveness should still work")
|
||||
}
|
||||
// NewApp uses the project's latest configuration, including endpoint switches.
|
||||
deps.Config.Server.Endpoints.Readiness.ResponseMode = "raw"
|
||||
deps.Config.Server.Endpoints.Liveness.Enabled = false
|
||||
deps.Config.Server.Endpoints.OpenAPI.Enabled = true
|
||||
deps.Config.Server.Endpoints.OpenAPI.Path = "/spec.json"
|
||||
app2, err := handler.NewApp(deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app2.Close()
|
||||
out = response(t, app2, "GET", "/readyz", nil, nil)
|
||||
if out.Code != 503 || strings.Contains(out.Body.String(), `"code"`) || !strings.Contains(out.Body.String(), `"status":"not_ready"`) {
|
||||
t.Fatal(out.Body.String())
|
||||
}
|
||||
out = response(t, app2, "GET", "/spec.json", nil, nil)
|
||||
if out.Code != 200 || !strings.Contains(out.Body.String(), `"openapi":`) || strings.Contains(out.Body.String(), `"msg":"操作成功"`) {
|
||||
t.Fatal("OpenAPI was wrapped")
|
||||
}
|
||||
deps.Config.Server.Endpoints.Readiness.Path = "/check"
|
||||
if _, err = handler.NewApp(deps); err == nil || !strings.Contains(err.Error(), "overlaps business") {
|
||||
t.Fatal("collision accepted", err)
|
||||
}
|
||||
deps.Config.Server.Endpoints.Readiness.Enabled = false
|
||||
if a, err := handler.NewApp(deps); err != nil {
|
||||
t.Fatal("disabled endpoint reserves path", err)
|
||||
} else {
|
||||
_ = a.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedFailedProbe(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Server.Log.Writer = io.Discard
|
||||
logger, _ := logx.New(cfg.Server.Log)
|
||||
defer logger.Close()
|
||||
deps, err := svc.New(context.Background(), logger, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deps.Close()
|
||||
if err = deps.Health.Add(health.Probe{Name: "database", Check: func(context.Context) error { return errors.New("private connection details") }}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := handler.NewApp(deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.Close()
|
||||
out := response(t, app, "GET", "/health/ready", nil, nil)
|
||||
if out.Code != 503 || strings.Contains(out.Body.String(), "private") || !strings.Contains(out.Body.String(), `"msg":`) {
|
||||
t.Fatal(out.Code, out.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedDependencyShutdown(t *testing.T) {
|
||||
for _, transport := range []string{"nethttp", "native"} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := listener.Addr().String()
|
||||
_ = listener.Close()
|
||||
t.Setenv("GO_OMNI_TRANSPORT", transport)
|
||||
svc.Started = make(chan struct{}, 1)
|
||||
svc.Release = make(chan struct{})
|
||||
before := svc.Closed.Load()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
path := configFile(t, address, false)
|
||||
go func() { done <- run(ctx, path) }()
|
||||
base := "http://" + address
|
||||
waitReady(t, base, done)
|
||||
deps := svc.Current.Load()
|
||||
requestDone := make(chan error, 1)
|
||||
go func() {
|
||||
client := http.Client{Timeout: 3 * time.Second}
|
||||
res, err := client.Get(base + "/slow")
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, res.Body)
|
||||
_ = res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
err = io.ErrUnexpectedEOF
|
||||
}
|
||||
}
|
||||
requestDone <- err
|
||||
}()
|
||||
select {
|
||||
case <-svc.Started:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("request did not start")
|
||||
}
|
||||
deps.RequestShutdown(svc.FatalDependency)
|
||||
// Inspect the same generated readiness handler during draining, even if
|
||||
// the network listener has already stopped accepting new connections.
|
||||
probe, _ := handler.NewApp(deps)
|
||||
out := httptest.NewRecorder()
|
||||
probe.ServeHTTP(out, httptest.NewRequest("GET", "/health/ready", nil))
|
||||
_ = probe.Close()
|
||||
if out.Code != 503 {
|
||||
t.Fatal("draining remained ready", out.Body.String())
|
||||
}
|
||||
if svc.Closed.Load() != before {
|
||||
t.Fatal("dependency closed before drain")
|
||||
}
|
||||
close(svc.Release)
|
||||
select {
|
||||
case err = <-requestDone:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("request stuck")
|
||||
}
|
||||
select {
|
||||
case err = <-done:
|
||||
if !errors.Is(err, svc.FatalDependency) {
|
||||
t.Fatal("fatal cause lost", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("shutdown stuck")
|
||||
}
|
||||
if svc.Closed.Load() != before+1 {
|
||||
t.Fatal("cleanup did not run once")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedFatalProcessExit(t *testing.T) {
|
||||
if path := os.Getenv("OMNI_TEST_FATAL_CONFIG"); path != "" {
|
||||
os.Args = []string{os.Args[0], "-config", path}
|
||||
main()
|
||||
return
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"shutdownInit":true,"server":{"address":"127.0.0.1:0"}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestGeneratedFatalProcessExit$")
|
||||
cmd.Env = append(os.Environ(), "OMNI_TEST_FATAL_CONFIG="+path)
|
||||
output, err := cmd.CombinedOutput()
|
||||
var exit *exec.ExitError
|
||||
if !errors.As(err, &exit) || exit.ExitCode() != 1 {
|
||||
t.Fatal("fatal shutdown must exit 1", err, string(output))
|
||||
}
|
||||
if !strings.Contains(string(output), svc.FatalDependency.Error()) || strings.Count(string(output), "test.client.closed") != 1 {
|
||||
t.Fatal("missing failure or cleanup", string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedEndpointConflictCleansUp(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"server":{"address":"127.0.0.1:0","endpoints":{"readiness":{"path":"/check"}}}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := svc.Closed.Load()
|
||||
err := run(context.Background(), path)
|
||||
if err == nil || !strings.Contains(err.Error(), "overlaps business") || svc.Closed.Load() != before+1 {
|
||||
t.Fatal("collision leaked resources or started serving", err)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Entry string `json:"entry"`
|
||||
Generated map[string]string `json:"generated"`
|
||||
Contract *contractSnapshot `json:"contract,omitempty"`
|
||||
}
|
||||
type change struct {
|
||||
name string
|
||||
@@ -29,7 +30,7 @@ type change struct {
|
||||
mode os.FileMode
|
||||
}
|
||||
|
||||
func apply(root, module, entry string, generated, seeds map[string][]byte, check, dryRun bool, profile projectProfile) (Output, error) {
|
||||
func apply(root, module, entry string, generated, seeds map[string][]byte, check, dryRun bool, profile projectProfile, contract *contractSnapshot) (Output, error) {
|
||||
var result Output
|
||||
if info, err := os.Lstat(root); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
@@ -82,7 +83,7 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
||||
if old.Profile != nil && *old.Profile != profile {
|
||||
return result, errors.New("api: profile changed during generation; retry")
|
||||
}
|
||||
if old.Version != 1 || old.Module != module || old.Entry != entry {
|
||||
if old.Version != 2 || old.Module != module || old.Entry != entry {
|
||||
return result, errors.New("api: manifest version/module/entry differs; one API entry owns this project")
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
@@ -107,7 +108,7 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
next := manifest{Profile: &profile, Version: 1, Module: module, Entry: entry, Generated: map[string]string{}}
|
||||
next := manifest{Profile: &profile, Version: 2, Module: module, Entry: entry, Generated: map[string]string{}, Contract: contract}
|
||||
for name, data := range generated {
|
||||
next.Generated[name] = digest(data)
|
||||
}
|
||||
@@ -179,6 +180,20 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
||||
}
|
||||
changes = append(changes, change{name: name, before: current, after: content, existed: exists, mode: mode})
|
||||
}
|
||||
result.Diagnostics = append(result.Diagnostics, compareContracts(old.Contract, contract)...)
|
||||
logicDiagnostics, locations := checkLogic(root, module, contract, seeds)
|
||||
for i := range result.Diagnostics {
|
||||
if d := &result.Diagnostics[i]; d.Code == "contractChanged" {
|
||||
if location, found := locations[d.Path]; found {
|
||||
d.Path, d.Line = location.path, location.line
|
||||
}
|
||||
}
|
||||
}
|
||||
result.Diagnostics = append(result.Diagnostics, logicDiagnostics...)
|
||||
if oldData != nil && old.Contract == nil {
|
||||
result.Diagnostics = append(result.Diagnostics, Diagnostic{Severity: "review", Code: "contractBaseline", Path: manifestPath, Message: "no saved contract snapshot; past contract changes cannot be compared; regeneration establishes a baseline"})
|
||||
}
|
||||
sortDiagnostics(result.Diagnostics)
|
||||
data, err := json.MarshalIndent(next, "", " ")
|
||||
if err != nil {
|
||||
return result, err
|
||||
@@ -202,15 +217,19 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
||||
return result, nil
|
||||
}
|
||||
if check {
|
||||
var checkErr error
|
||||
if len(changes) > 0 {
|
||||
var stale []string
|
||||
for _, c := range changes {
|
||||
stale = append(stale, c.name)
|
||||
}
|
||||
sort.Strings(stale)
|
||||
return result, fmt.Errorf("api: generated project is stale: %s", strings.Join(stale, ", "))
|
||||
checkErr = fmt.Errorf("api: generated project is stale: %s", strings.Join(stale, ", "))
|
||||
}
|
||||
return result, nil
|
||||
if count := diagnosticErrors(result.Diagnostics); count > 0 {
|
||||
checkErr = errors.Join(checkErr, fmt.Errorf("api: %d Logic source checks failed; see diagnostics and run go test ./... after fixing business code", count))
|
||||
}
|
||||
return result, checkErr
|
||||
}
|
||||
// A rollback restores bytes/modes if an ordinary filesystem write fails.
|
||||
// File replacement is atomic; the whole directory is not crash-atomic.
|
||||
@@ -252,7 +271,7 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
||||
}
|
||||
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
|
||||
func ownedPath(name string) bool {
|
||||
if name == "internal/handler/responseGen.go" || name == "internal/types/scalarsGen.go" {
|
||||
if name == "main.go" || name == "internal/handler/appGen.go" || name == "internal/handler/responseGen.go" || name == "internal/types/scalarsGen.go" {
|
||||
return true
|
||||
}
|
||||
if name == "internal/apierrors/errorsGen.go" || name == "docs/openapi.json" || name == "internal/types/typesGen.go" || name == "internal/handler/routesGen.go" || name == "internal/handler/openapiGen.go" || name == "internal/middleware/registryGen.go" {
|
||||
|
||||
@@ -142,26 +142,22 @@ func runNew(args []string) error {
|
||||
options.Module = filepath.Base(filepath.Clean(target))
|
||||
}
|
||||
options.Dir = target
|
||||
if apiFile != "" {
|
||||
if options.Force {
|
||||
return errors.New("--api protects existing business code and does not accept --force")
|
||||
return errors.New("managed projects do not accept --force; use api generate to update owned files")
|
||||
}
|
||||
_, err := apigen.Generate(apigen.Options{API: apiFile, Dir: target, Module: options.Module, FrameworkDir: options.FrameworkDir, FrameworkVersion: options.FrameworkVersion, LogOutput: options.LogOutput, LogFormat: options.LogFormat, Database: options.Database, Storage: options.Storage})
|
||||
o := apigen.Options{API: apiFile, Dir: target, Module: options.Module, FrameworkDir: options.FrameworkDir, FrameworkVersion: options.FrameworkVersion, LogOutput: options.LogOutput, LogFormat: options.LogFormat, Database: options.Database, Storage: options.Storage}
|
||||
var err error
|
||||
var out apigen.Output
|
||||
if apiFile != "" {
|
||||
out, err = apigen.Generate(o)
|
||||
} else {
|
||||
out, err = apigen.New(o)
|
||||
}
|
||||
printAPIDiagnostics(out.Diagnostics)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("created API project %s; implement internal/service operations\n", filepath.Clean(target))
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(target, ".goOmni", "apiManifest.json")); err == nil {
|
||||
return errors.New("this is an API project; use api generate instead of the CRUD/module starter")
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := scaffold.Generate(options); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("created GoOmni project %s\n", filepath.Clean(target))
|
||||
fmt.Printf("created API project %s; implement internal/logic operations; run with go run .\n", filepath.Clean(target))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -170,7 +166,7 @@ func usage() {
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "Usage:")
|
||||
fmt.Fprintln(os.Stderr, " go-omni migrate [--dir migrations] [--apply] [--dsn-env GO_OMNI_DB_DSN]")
|
||||
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] [--force] <directory>")
|
||||
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] <directory>")
|
||||
fmt.Fprintln(os.Stderr, " go-omni new --api <file.api> [--module <module>] <directory>")
|
||||
fmt.Fprintln(os.Stderr, " go-omni api new [--module <module>] <directory> Create a runnable API project")
|
||||
fmt.Fprintln(os.Stderr, " go-omni api generate [--api <file.api>] [--dir <project>] [--check|--dry-run]")
|
||||
@@ -180,7 +176,7 @@ func usage() {
|
||||
fmt.Fprintln(os.Stderr, " go-omni add <name> [--dir <project>] Generate an isolated business module")
|
||||
fmt.Fprintln(os.Stderr, " --framework-version <version> Pin a remote framework version (defaults to CLI version)")
|
||||
fmt.Fprintln(os.Stderr, " --framework-dir <directory> Use a local framework checkout")
|
||||
fmt.Fprintln(os.Stderr, " --database <none|memory|mysql> API defaults to none; CRUD defaults to memory")
|
||||
fmt.Fprintln(os.Stderr, " --database <none|memory|mysql> Defaults to none")
|
||||
fmt.Fprintln(os.Stderr, " --log-output <stdout|stderr|file> Generate configured logging (default stderr)")
|
||||
fmt.Fprintln(os.Stderr, " --log-format <json|text> Select structured log format (default json)")
|
||||
fmt.Fprintln(os.Stderr, " --storage <none|local|s3|r2|oss> Select optional storage adapter (default none)")
|
||||
@@ -207,6 +203,9 @@ func runAdd(args []string) error {
|
||||
return fmt.Errorf("unexpected argument %q", arg)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(directory, ".goOmni", "apiManifest.json")); err == nil {
|
||||
return errors.New("managed API projects add modules through .api imports and api generate")
|
||||
}
|
||||
if err := scaffold.AddModule(directory, name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||
for _, flag := range [][]string{{"--framework-version", "v0.1.0"}, {"--framework-version=v0.1.0"}} {
|
||||
for _, flag := range [][]string{{"--framework-version", "v0.13.0-preview"}, {"--framework-version=v0.13.0-preview"}} {
|
||||
project := filepath.Join(t.TempDir(), "billing")
|
||||
args := append([]string{"--module", "example.com/billing"}, flag...)
|
||||
if err := runNew(append(args, project)); err != nil {
|
||||
@@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "go-omni v0.1.0") || strings.Contains(string(content), "replace ") {
|
||||
if !strings.Contains(string(content), "go-omni v0.13.0-preview") || strings.Contains(string(content), "replace ") {
|
||||
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
||||
}
|
||||
}
|
||||
@@ -42,11 +42,8 @@ func TestAddModuleCLI(t *testing.T) {
|
||||
if equals {
|
||||
args = []string{"--dir=" + project, "orders"}
|
||||
}
|
||||
if err := runAdd(args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(project, "internal/orders/module.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
if err := runAdd(args); err == nil || !strings.Contains(err.Error(), ".api imports") {
|
||||
t.Fatalf("expected contract-driven module guidance: %v", err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{nil, {"orders", "--dir"}, {"orders", "--dir="}, {"orders", "extra"}, {"orders", "--unknown"}} {
|
||||
@@ -63,7 +60,7 @@ func TestNewMySQLDatabaseOptions(t *testing.T) {
|
||||
if err := runNew(append(args, project)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(project, "schema.sql")); err != nil {
|
||||
if _, err := os.Stat(filepath.Join(project, "internal/svc/serviceContext.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
func APIInfrastructure(module, database, storage, logOutput, logFormat string) (map[string][]byte, error) {
|
||||
data := templateData{Module: module, Database: database, Storage: storage, LogOutput: logOutput, LogFormat: logFormat, API: true, Modules: true}
|
||||
files := map[string]string{
|
||||
"internal/settings/config.go": "templates/modules-settings.go.tmpl",
|
||||
"internal/svc/close.go": "templates/api-close.go.tmpl",
|
||||
"internal/svc/options.go": "templates/api-options.go.tmpl",
|
||||
"internal/config/config.go": "templates/modules-settings.go.tmpl",
|
||||
"etc/config.yaml": "templates/modules-config.yaml.tmpl",
|
||||
"internal/servicecontext/context.go": "templates/api-context.go.tmpl",
|
||||
"internal/svc/serviceContext.go": "templates/api-context.go.tmpl",
|
||||
}
|
||||
if storage != "none" {
|
||||
files["internal/servicecontext/storage.go"] = "templates/modules-storage.go.tmpl"
|
||||
files["internal/svc/storage.go"] = "templates/modules-storage.go.tmpl"
|
||||
}
|
||||
result := make(map[string][]byte, len(files))
|
||||
for name, source := range files {
|
||||
@@ -23,6 +25,15 @@ func APIInfrastructure(module, database, storage, logOutput, logFormat string) (
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render %s: %w", name, err)
|
||||
}
|
||||
content = strings.ReplaceAll(content, "package settings", "package config")
|
||||
content = strings.ReplaceAll(content, "package servicecontext", "package svc")
|
||||
content = strings.ReplaceAll(content, "/internal/settings", "/internal/config")
|
||||
content = strings.ReplaceAll(content, "settings.", "config.")
|
||||
if strings.HasPrefix(name, "internal/svc/") {
|
||||
content = strings.ReplaceAll(content, "*Context", "*ServiceContext")
|
||||
content = strings.ReplaceAll(content, "&Context{", "&ServiceContext{")
|
||||
content = strings.ReplaceAll(content, "type Context struct", "type ServiceContext struct")
|
||||
}
|
||||
result[name] = []byte(strings.TrimRight(content, "\n") + "\n")
|
||||
}
|
||||
return result, nil
|
||||
|
||||
23
cmd/go-omni/scaffold/templates/api-close.go.tmpl
Normal file
23
cmd/go-omni/scaffold/templates/api-close.go.tmpl
Normal file
@@ -0,0 +1,23 @@
|
||||
package servicecontext
|
||||
import "errors"
|
||||
// Close is idempotent, including startup failure and graceful shutdown.
|
||||
func(c *Context)Close()error{
|
||||
if c==nil{return nil}
|
||||
c.closeOnce.Do(func(){
|
||||
if c.Lifecycle!=nil{c.Lifecycle.RequestShutdown(nil)}
|
||||
c.closeMu.Lock();c.closed=true;cleanups:=c.cleanups;c.cleanups=nil;c.closeMu.Unlock()
|
||||
for i:=len(cleanups)-1;i>=0;i--{c.closeErr=errors.Join(c.closeErr,cleanups[i]())}
|
||||
{{if ne .Storage "none"}}if c.storageClose!=nil{c.closeErr=errors.Join(c.closeErr,c.storageClose())}{{end}}
|
||||
{{if eq .Database "mysql"}}if c.DB!=nil{c.closeErr=errors.Join(c.closeErr,c.DB.Close())}{{end}}
|
||||
})
|
||||
return c.closeErr
|
||||
}
|
||||
|
||||
// OnClose transfers a resource cleanup to this context immediately after creation.
|
||||
// Hooks run once in reverse order, also when later initialization fails.
|
||||
func(c *Context)OnClose(cleanup func()error) {
|
||||
if cleanup==nil{return}
|
||||
c.closeMu.Lock();defer c.closeMu.Unlock()
|
||||
if c.closed{panic("svc: register cleanup after Close")}
|
||||
c.cleanups=append(c.cleanups,cleanup)
|
||||
}
|
||||
@@ -11,12 +11,16 @@ import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"{{.Module}}/internal/settings"
|
||||
"{{.Module}}/internal/middleware"
|
||||
)
|
||||
// Context owns infrastructure, while Logger is borrowed from main.
|
||||
// Add business repositories here; HTTP contracts do not define database tables.
|
||||
type Context struct {
|
||||
Lifecycle *lifecycle.Controller
|
||||
Config settings.Config
|
||||
cleanups []func()error
|
||||
Logger *logx.Logger
|
||||
Health *health.Checker
|
||||
Middlewares map[string]httpx.Middleware
|
||||
@@ -38,13 +42,14 @@ type Context struct {
|
||||
// tests without infrastructure; selected adapters still require valid settings.
|
||||
func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(result *Context,err error){
|
||||
if ctx==nil{ctx=context.Background()}
|
||||
if err=ctx.Err();err!=nil{return nil,err}
|
||||
if err=ctx.Err();err!=nil{return nil,errors.Join(err,lifecycle.Cause(ctx))}
|
||||
if len(configs)>1{return nil,errors.New("expected at most one configuration")}
|
||||
cfg:=settings.Default();if len(configs)==1{cfg=configs[0]}
|
||||
_=cfg
|
||||
checker,err:=health.New();if err!=nil{return nil,err}
|
||||
c:=&Context{Logger:logger,Health:checker,Middlewares:middleware.Defaults()}
|
||||
defer func(){if err!=nil{err=errors.Join(err,c.Close())}}()
|
||||
c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults(),Lifecycle:lifecycle.New(ctx)}
|
||||
ctx=c.Lifecycle.Context()
|
||||
defer func(){if err!=nil{err=errors.Join(err,c.Close());if cause:=c.Lifecycle.Err();cause!=nil && !errors.Is(err,cause){err=errors.Join(err,cause)}}}()
|
||||
{{if eq .Database "mysql"}}
|
||||
if err=cfg.Validate();err!=nil{return nil,err}
|
||||
dsn,parseErr:=mysql.ParseDSN(cfg.Database.DSN)
|
||||
@@ -57,13 +62,7 @@ func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(res
|
||||
{{if ne .Storage "none"}}if err=c.InitStorage(ctx,cfg.Storage);err!=nil{return nil,err}{{end}}
|
||||
return c,nil
|
||||
}
|
||||
// Close is idempotent, including startup failure and graceful shutdown.
|
||||
func(c *Context)Close()error{
|
||||
if c==nil{return nil}
|
||||
c.closeMu.Lock();defer c.closeMu.Unlock();c.closed=true
|
||||
c.closeOnce.Do(func(){
|
||||
{{if ne .Storage "none"}}if c.storageClose!=nil{c.closeErr=errors.Join(c.closeErr,c.storageClose())}{{end}}
|
||||
{{if eq .Database "mysql"}}if c.DB!=nil{c.closeErr=errors.Join(c.closeErr,c.DB.Close())}{{end}}
|
||||
})
|
||||
return c.closeErr
|
||||
}
|
||||
|
||||
// RequestShutdown reports a fatal dependency failure, or a normal stop when
|
||||
// err is nil. It returns immediately; generated main drains and cleans up.
|
||||
func(c *Context)RequestShutdown(err error){c.Lifecycle.RequestShutdown(err)}
|
||||
|
||||
11
cmd/go-omni/scaffold/templates/api-options.go.tmpl
Normal file
11
cmd/go-omni/scaffold/templates/api-options.go.tmpl
Normal file
@@ -0,0 +1,11 @@
|
||||
package servicecontext
|
||||
import (
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
// AppOptions runs before routes are registered. Register project error catalogs
|
||||
// here with omni.WithErrors. It must not create resources or another App.
|
||||
func(c *Context)AppOptions()[]omni.Option{return nil}
|
||||
// GlobalMiddleware runs for all endpoints, including App 404/405 fallbacks.
|
||||
// Named route middleware lives in Middlewares and is selected by the API contract.
|
||||
func(c *Context)GlobalMiddleware()[]httpx.Middleware{return nil}
|
||||
@@ -4,6 +4,19 @@ server:
|
||||
enableLogger: true
|
||||
slowRequestThreshold: 500ms
|
||||
shutdownTimeout: 10s
|
||||
{{if .API}} endpoints:
|
||||
liveness:
|
||||
enabled: true
|
||||
path: /health/live
|
||||
responseMode: app
|
||||
readiness:
|
||||
enabled: true
|
||||
path: /health/ready
|
||||
responseMode: app
|
||||
openapi:
|
||||
enabled: true
|
||||
path: /openapi.json
|
||||
{{end}}
|
||||
log:
|
||||
service: {{printf "%q" .Module}}
|
||||
level: info
|
||||
|
||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||
// downloaded module version, which also has a module checksum.
|
||||
const defaultFrameworkVersion = "v0.9.0-preview"
|
||||
const defaultFrameworkVersion = "v0.13.0-preview"
|
||||
|
||||
// This target is not a claim that the release already exists. Source users
|
||||
// select a local framework checkout until the module templates are published.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
@@ -28,12 +29,15 @@ type Config struct {
|
||||
Address string `json:"address"`
|
||||
Transport string `json:"transport"`
|
||||
TLS TLSConfig `json:"tls"`
|
||||
HTTP HTTPConfig `json:"http"`
|
||||
Endpoints EndpointsConfig `json:"endpoints"`
|
||||
RequestIDHeader string `json:"requestIdHeader"`
|
||||
EnableLogger bool `json:"enableLogger"`
|
||||
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
|
||||
TrustedProxies []string `json:"trustedProxies"`
|
||||
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
|
||||
Log logx.Config `json:"log"`
|
||||
Errors apierrors.Catalog `json:"errors"`
|
||||
Response response.Config `json:"response"`
|
||||
ResponseEncoder response.Encoder `json:"-"`
|
||||
}
|
||||
@@ -44,12 +48,32 @@ func Default() Config {
|
||||
Transport: TransportNetHTTP,
|
||||
RequestIDHeader: defaultRequestIDHeader,
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
Endpoints: DefaultEndpoints(),
|
||||
Log: logx.Default(),
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks configuration values before an application starts.
|
||||
func (c Config) Validate() error {
|
||||
if err := c.Endpoints.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Errors.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
encoder := c.ResponseEncoder
|
||||
if encoder == nil {
|
||||
f, err := response.New(c.Response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoder = f
|
||||
}
|
||||
// Check the same composition that App.New installs, including custom
|
||||
// encoders and their existing catalogs, before App construction.
|
||||
if _, err := response.WithErrors(c.Errors, encoder); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Response.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -74,11 +98,20 @@ func (c Config) Validate() error {
|
||||
// ValidateTransport checks built-in transport selection without opening files
|
||||
// or sockets. Empty Transport preserves the standard net/http default.
|
||||
func (c Config) ValidateTransport() error {
|
||||
if err := c.HTTP.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
switch c.Transport {
|
||||
case "", TransportNetHTTP, TransportNative, TransportHTTP2:
|
||||
default:
|
||||
return &ConfigError{Field: "transport", Message: "must be nethttp, native or http2"}
|
||||
}
|
||||
if c.Transport == TransportHTTP2 && (c.HTTP.ReadHeaderTimeout > 0 || c.HTTP.ReadTimeout > 0 || c.HTTP.WriteTimeout > 0) {
|
||||
return &ConfigError{Field: "http", Message: "readHeaderTimeout, readTimeout and writeTimeout are not supported by the experimental http2 transport"}
|
||||
}
|
||||
if c.HTTP.ErrorLog != nil && c.Transport != "" && c.Transport != TransportNetHTTP {
|
||||
return &ConfigError{Field: "http.errorLog", Message: "is supported only by the nethttp transport"}
|
||||
}
|
||||
cert, key := strings.TrimSpace(c.TLS.CertFile), strings.TrimSpace(c.TLS.KeyFile)
|
||||
if (cert == "") != (key == "") {
|
||||
return &ConfigError{Field: "tls", Message: "requires both certFile and keyFile"}
|
||||
@@ -141,11 +174,13 @@ func WithTrustedProxies(proxies ...string) Option {
|
||||
// environment. Later options can still override individual fields.
|
||||
func WithConfig(settings Config) Option {
|
||||
settings.Response = settings.Response.Clone()
|
||||
settings.Errors = settings.Errors.Clone()
|
||||
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
||||
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
|
||||
return func(cfg *Config) {
|
||||
*cfg = settings
|
||||
cfg.Response = settings.Response.Clone()
|
||||
cfg.Errors = settings.Errors.Clone()
|
||||
cfg.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
||||
cfg.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
|
||||
}
|
||||
@@ -180,3 +215,9 @@ func WithShutdownTimeout(timeout time.Duration) Option {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithErrors installs a project-owned, app-local catalog.
|
||||
func WithErrors(catalog apierrors.Catalog) Option {
|
||||
catalog = catalog.Clone()
|
||||
return func(cfg *Config) { cfg.Errors = catalog.Clone() }
|
||||
}
|
||||
|
||||
119
config/endpoints.go
Normal file
119
config/endpoints.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// EndpointsConfig controls routes installed by a generated service. A manually
|
||||
// assembled App still registers its own routes explicitly.
|
||||
type EndpointsConfig struct {
|
||||
Liveness HealthEndpointConfig `json:"liveness"`
|
||||
Readiness HealthEndpointConfig `json:"readiness"`
|
||||
OpenAPI EndpointConfig `json:"openapi"`
|
||||
}
|
||||
|
||||
type EndpointConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type HealthEndpointConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Path string `json:"path"`
|
||||
ResponseMode string `json:"responseMode"` // app (default) or raw
|
||||
}
|
||||
|
||||
func DefaultEndpoints() EndpointsConfig {
|
||||
return EndpointsConfig{
|
||||
Liveness: HealthEndpointConfig{Enabled: true, Path: "/health/live", ResponseMode: "app"},
|
||||
Readiness: HealthEndpointConfig{Enabled: true, Path: "/health/ready", ResponseMode: "app"},
|
||||
OpenAPI: EndpointConfig{Enabled: true, Path: "/openapi.json"},
|
||||
}
|
||||
}
|
||||
|
||||
func (c EndpointsConfig) enabled() []struct{ name, path string } {
|
||||
var result []struct{ name, path string }
|
||||
for _, endpoint := range []struct {
|
||||
name string
|
||||
enabled bool
|
||||
path string
|
||||
}{
|
||||
{"liveness", c.Liveness.Enabled, c.Liveness.Path},
|
||||
{"readiness", c.Readiness.Enabled, c.Readiness.Path},
|
||||
{"openapi", c.OpenAPI.Enabled, c.OpenAPI.Path},
|
||||
} {
|
||||
if endpoint.enabled {
|
||||
result = append(result, struct{ name, path string }{endpoint.name, endpoint.path})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (c EndpointsConfig) Validate() error {
|
||||
for _, mode := range []string{c.Liveness.ResponseMode, c.Readiness.ResponseMode} {
|
||||
if mode != "" && mode != "app" && mode != "raw" {
|
||||
return fmt.Errorf("endpoints: responseMode must be app or raw")
|
||||
}
|
||||
}
|
||||
paths := map[string]string{}
|
||||
for _, endpoint := range c.enabled() {
|
||||
p := endpoint.path
|
||||
if !strings.HasPrefix(p, "/") || strings.ContainsAny(p, ":*?#%\\{}") ||
|
||||
strings.IndexFunc(p, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 {
|
||||
return fmt.Errorf("endpoints.%s.path must be an absolute literal URL path", endpoint.name)
|
||||
}
|
||||
parts := strings.Split(p[1:], "/")
|
||||
for i, part := range parts {
|
||||
if part == "." || part == ".." || (part == "" && i < len(parts)-1) {
|
||||
return fmt.Errorf("endpoints.%s.path contains an invalid segment", endpoint.name)
|
||||
}
|
||||
}
|
||||
if previous := paths[p]; previous != "" {
|
||||
return fmt.Errorf("endpoints.%s and endpoints.%s share path %s", previous, endpoint.name, p)
|
||||
}
|
||||
paths[p] = endpoint.name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EndpointRoute describes an already validated business route. Only GET
|
||||
// patterns can overlap these built-in GET endpoints.
|
||||
type EndpointRoute struct{ Method, Path string }
|
||||
|
||||
// ValidateRoutes rejects a built-in route that would shadow a business route
|
||||
// or conflict with its catch-all. Disabled endpoints reserve no paths.
|
||||
func (c EndpointsConfig) ValidateRoutes(routes ...EndpointRoute) error {
|
||||
if err := c.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, endpoint := range c.enabled() {
|
||||
for _, route := range routes {
|
||||
if strings.EqualFold(route.Method, "GET") && endpointOverlap(route.Path, endpoint.path) {
|
||||
return fmt.Errorf("endpoints.%s path %s overlaps business route GET %s; change its path or disable it", endpoint.name, endpoint.path, route.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func endpointOverlap(pattern, path string) bool {
|
||||
a, b := strings.Split(strings.TrimPrefix(pattern, "/"), "/"), strings.Split(path[1:], "/")
|
||||
for i, part := range a {
|
||||
if i >= len(b) {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(part, "*") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(part, ":") {
|
||||
if b[i] == "" {
|
||||
return false
|
||||
}
|
||||
} else if part != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(a) == len(b)
|
||||
}
|
||||
66
config/endpoints_test.go
Normal file
66
config/endpoints_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEndpointConfiguration(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte("endpoints:\n liveness:\n enabled: false\n readiness:\n path: /readyz\n responseMode: raw\n openapi:\n enabled: false\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := DecodeFile(path, Default())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.OpenAPI.Enabled || !cfg.Endpoints.Readiness.Enabled || cfg.Endpoints.Readiness.ResponseMode != "raw" {
|
||||
t.Fatal(cfg.Endpoints)
|
||||
}
|
||||
t.Setenv("TEST_ENDPOINTS_READINESS_PATH", "/internal/ready")
|
||||
t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "true")
|
||||
cfg, err = ApplyEnv(cfg, "TEST_")
|
||||
if err != nil || cfg.Endpoints.Readiness.Path != "/internal/ready" || !cfg.Endpoints.OpenAPI.Enabled {
|
||||
t.Fatal(cfg.Endpoints, err)
|
||||
}
|
||||
t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "invalid")
|
||||
if _, err = ApplyEnv(cfg, "TEST_"); err == nil {
|
||||
t.Fatal("invalid bool accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointRouteConflicts(t *testing.T) {
|
||||
for _, route := range []EndpointRoute{{"GET", "/health/ready"}, {"get", "/health/:name"}, {"GET", "/health/*rest"}, {"GET", "/*rest"}} {
|
||||
cfg := DefaultEndpoints()
|
||||
if err := cfg.ValidateRoutes(route); err == nil {
|
||||
t.Fatal("overlap accepted", route)
|
||||
}
|
||||
cfg.Readiness.Enabled = false
|
||||
cfg.Liveness.Enabled = false
|
||||
cfg.OpenAPI.Enabled = false
|
||||
if err := cfg.ValidateRoutes(route); err != nil {
|
||||
t.Fatal("disabled endpoint reserves path", err)
|
||||
}
|
||||
}
|
||||
if err := DefaultEndpoints().ValidateRoutes(EndpointRoute{"POST", "/health/ready"}, EndpointRoute{"GET", "/other/*rest"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"", "ready", "//ready", "/a/../ready", "/:ready", "/ready?x=1", "/a%2fb", "/ready\n"} {
|
||||
cfg := DefaultEndpoints()
|
||||
cfg.Readiness.Path = path
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("invalid path", path)
|
||||
}
|
||||
}
|
||||
cfg := DefaultEndpoints()
|
||||
cfg.Readiness.Path = cfg.Liveness.Path
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("duplicate endpoints accepted")
|
||||
}
|
||||
cfg = DefaultEndpoints()
|
||||
cfg.Readiness.ResponseMode = "unknown"
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("invalid mode accepted")
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ func LoadFromEnv(prefix string) (Config, error) {
|
||||
// ApplyEnv overlays nonempty environment values on a caller's defaults or file
|
||||
// settings, then validates the result. Caller-owned slices are not retained.
|
||||
func ApplyEnv(defaults Config, prefix string) (Config, error) {
|
||||
defaults.Errors = defaults.Errors.Clone()
|
||||
defaults.TrustedProxies = append([]string(nil), defaults.TrustedProxies...)
|
||||
defaults.Log.RedactKeys = append([]string(nil), defaults.Log.RedactKeys...)
|
||||
return applyEnv(defaults, prefix)
|
||||
@@ -48,6 +49,48 @@ func applyEnv(cfg Config, prefix string) (Config, error) {
|
||||
cfg.Log.Environment = envString(prefix+"ENVIRONMENT", cfg.Log.Environment)
|
||||
|
||||
var err error
|
||||
for _, endpoint := range []struct {
|
||||
name string
|
||||
enabled *bool
|
||||
path *string
|
||||
mode *string
|
||||
}{
|
||||
{"LIVENESS", &cfg.Endpoints.Liveness.Enabled, &cfg.Endpoints.Liveness.Path, &cfg.Endpoints.Liveness.ResponseMode},
|
||||
{"READINESS", &cfg.Endpoints.Readiness.Enabled, &cfg.Endpoints.Readiness.Path, &cfg.Endpoints.Readiness.ResponseMode},
|
||||
{"OPENAPI", &cfg.Endpoints.OpenAPI.Enabled, &cfg.Endpoints.OpenAPI.Path, nil},
|
||||
} {
|
||||
key := prefix + "ENDPOINTS_" + endpoint.name
|
||||
*endpoint.enabled, err = envBool(key+"_ENABLED", *endpoint.enabled)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
*endpoint.path = envString(key+"_PATH", *endpoint.path)
|
||||
if endpoint.mode != nil {
|
||||
*endpoint.mode = envString(key+"_RESPONSE_MODE", *endpoint.mode)
|
||||
}
|
||||
}
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
value *time.Duration
|
||||
}{
|
||||
{"HTTP_READ_HEADER_TIMEOUT", &cfg.HTTP.ReadHeaderTimeout},
|
||||
{"HTTP_READ_TIMEOUT", &cfg.HTTP.ReadTimeout},
|
||||
{"HTTP_WRITE_TIMEOUT", &cfg.HTTP.WriteTimeout},
|
||||
{"HTTP_IDLE_TIMEOUT", &cfg.HTTP.IdleTimeout},
|
||||
} {
|
||||
if value := strings.TrimSpace(os.Getenv(prefix + field.name)); value != "" {
|
||||
*field.value, err = time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("%s%s must be a duration: %w", prefix, field.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv(prefix + "HTTP_MAX_HEADER_BYTES")); value != "" {
|
||||
cfg.HTTP.MaxHeaderBytes, err = strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("%sHTTP_MAX_HEADER_BYTES must be an integer: %w", prefix, err)
|
||||
}
|
||||
}
|
||||
if cfg.EnableLogger, err = envBool(prefix+"ENABLE_LOGGER", cfg.EnableLogger); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
75
config/errorCatalog_test.go
Normal file
75
config/errorCatalog_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestCatalogConfigFiles(t *testing.T) {
|
||||
for _, file := range []struct{ name, body string }{
|
||||
{"server.json", `{"response":{"codeType":"int64","successCode":0,"errorCode":9999},"errors":{"defaultStatus":200,"definitions":{"LIMIT":{"code":9223372036854775807,"message":"limit"}}}}`},
|
||||
{"server.yaml", "response:\n codeType: int64\n successCode: 0\n errorCode: 9999\nerrors:\n defaultStatus: 200\n definitions:\n LIMIT:\n code: 9223372036854775807\n message: limit\n"},
|
||||
} {
|
||||
t.Run(file.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), file.name)
|
||||
if err := os.WriteFile(path, []byte(file.body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.LoadFile(path, "CATALOG_FIXTURE_")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := apierrors.MustRegistry(cfg.Errors).Resolve(apierrors.ByCode("LIMIT"))
|
||||
if e.Status != 200 || e.BusinessCode != int64(9223372036854775807) {
|
||||
t.Fatal(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, body := range []string{
|
||||
`{"errors":{"definitions":{"A":{"message":"a","code":1},"A":{"message":"b","code":2}}}}`,
|
||||
`{"response":{"codeType":"int32","successCode":0,"errorCode":99},"errors":{"definitions":{"A":{"message":"a","code":2147483648}}}}`,
|
||||
`{"errors":{"definitions":{"A":{"message":"a","code":"a","status":204}}}}`,
|
||||
} {
|
||||
path := filepath.Join(t.TempDir(), "bad.json")
|
||||
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := config.LoadFile(path, "CATALOG_FIXTURE_"); err == nil {
|
||||
t.Fatal("accepted invalid catalog", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogReloadSnapshotIsolation(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
original := `{"errors":{"defaultStatus":200,"definitions":{"invalid":{"code":"invalid","message":"original"}}}}`
|
||||
if err := os.WriteFile(path, []byte(original), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger, err := logx.New(logx.Default())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
reloader, err := config.NewReloader(path, "CATALOG_RELOAD_", logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := reloader.Snapshot()
|
||||
snapshot.Errors.Definitions["invalid"] = apierrors.Definition{Message: "changed"}
|
||||
if reloader.Snapshot().Errors.Definitions["invalid"].Message != "original" {
|
||||
t.Fatal("snapshot changed live catalog")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(strings.Replace(original, "original", "new message", 1)), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reloader.Reload(); err == nil || reloader.Snapshot().Errors.Definitions["invalid"].Message != "original" {
|
||||
t.Fatal("catalog changed without restart", err)
|
||||
}
|
||||
}
|
||||
44
config/errorParams_test.go
Normal file
44
config/errorParams_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
)
|
||||
|
||||
func TestTemplateConfigFiles(t *testing.T) {
|
||||
for _, file := range []struct{ name, body string }{
|
||||
{"config.json", `{"errors":{"defaultStatus":200,"definitions":{"LIMIT":{"code":"limit","message":"{label}:{max}","params":[{"name":"label","type":"string","example":"字段"},{"name":"max","type":"int64","example":9223372036854775807}]}}}}`},
|
||||
{"config.yaml", "errors:\n defaultStatus: 200\n definitions:\n LIMIT:\n code: limit\n message: '{label}:{max}'\n params:\n - name: label\n type: string\n example: 字段\n - name: max\n type: int64\n example: 9223372036854775807\n"},
|
||||
} {
|
||||
t.Run(file.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), file.name)
|
||||
if err := os.WriteFile(path, []byte(file.body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.LoadFile(path, "TEMPLATE_TEST_")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := apierrors.MustRegistry(cfg.Errors)
|
||||
if r.Example("LIMIT").Message != "字段:9223372036854775807" {
|
||||
t.Fatal(r.Example("LIMIT"))
|
||||
}
|
||||
cfg.Errors.Definitions["LIMIT"].Params[0].Name = "mutated"
|
||||
var supplied map[string]any
|
||||
if err := apierrors.DecodeCatalogJSON([]byte(`{"label":"字段","max":9223372036854775807}`), &supplied); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := supplied["max"].(json.Number); !ok {
|
||||
t.Fatal("number precision lost")
|
||||
}
|
||||
if r.Resolve(apierrors.ByCode("LIMIT").WithParams(supplied)).Message != "字段:9223372036854775807" {
|
||||
t.Fatal("parameter decode or snapshot failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"gopkg.in/yaml.v3"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -14,6 +12,10 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// DecodeFile merges a bounded JSON/YAML document into defaults. It uses JSON
|
||||
@@ -45,15 +47,9 @@ func DecodeFile[T any](path string, defaults T) (T, error) {
|
||||
return zero, errors.New("config: one YAML document required")
|
||||
}
|
||||
case ".json":
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&value); err != nil {
|
||||
if err := apierrors.DecodeCatalogJSON(data, &value); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
var extra any
|
||||
if err := dec.Decode(&extra); err != io.EOF {
|
||||
return zero, errors.New("config: one JSON document required")
|
||||
}
|
||||
default:
|
||||
return zero, errors.New("config: expected .json, .yaml or .yml")
|
||||
}
|
||||
@@ -182,6 +178,7 @@ func (r *Reloader) Snapshot() Config {
|
||||
defer r.mu.Unlock()
|
||||
cfg := r.current
|
||||
cfg.Response = r.current.Response.Clone()
|
||||
cfg.Errors = r.current.Errors.Clone()
|
||||
cfg.TrustedProxies = append([]string(nil), cfg.TrustedProxies...)
|
||||
cfg.Log.RedactKeys = append([]string(nil), cfg.Log.RedactKeys...)
|
||||
return cfg
|
||||
|
||||
45
config/http.go
Normal file
45
config/http.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPConfig controls built-in HTTP servers. Positive values override the
|
||||
// selected transport's defaults; zero keeps its existing behavior. Timeouts
|
||||
// follow that transport's semantics, not a handler execution deadline.
|
||||
type HTTPConfig struct {
|
||||
ReadHeaderTimeout time.Duration `json:"readHeaderTimeout"`
|
||||
ReadTimeout time.Duration `json:"readTimeout"`
|
||||
WriteTimeout time.Duration `json:"writeTimeout"`
|
||||
IdleTimeout time.Duration `json:"idleTimeout"`
|
||||
MaxHeaderBytes int `json:"maxHeaderBytes"`
|
||||
// ErrorLog is a Go-only hook for the nethttp server's internal diagnostics.
|
||||
ErrorLog *log.Logger `json:"-"`
|
||||
}
|
||||
|
||||
func (c HTTPConfig) Validate() error {
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
value time.Duration
|
||||
}{
|
||||
{"readHeaderTimeout", c.ReadHeaderTimeout},
|
||||
{"readTimeout", c.ReadTimeout},
|
||||
{"writeTimeout", c.WriteTimeout},
|
||||
{"idleTimeout", c.IdleTimeout},
|
||||
} {
|
||||
if field.value < 0 {
|
||||
return &ConfigError{Field: "http." + field.name, Message: "must be nonnegative"}
|
||||
}
|
||||
}
|
||||
if c.MaxHeaderBytes < 0 {
|
||||
return &ConfigError{Field: "http.maxHeaderBytes", Message: "must be nonnegative"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithHTTPConfig sets the built-in server's HTTP options. RunWith uses a
|
||||
// caller-owned Runner and does not apply these options to that runner.
|
||||
func WithHTTPConfig(settings HTTPConfig) Option {
|
||||
return func(cfg *Config) { cfg.HTTP = settings }
|
||||
}
|
||||
87
config/http_test.go
Normal file
87
config/http_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
)
|
||||
|
||||
func TestHTTPFileAndEnvironment(t *testing.T) {
|
||||
for _, file := range []struct{ name, body string }{
|
||||
{"server.json", `{"http":{"readHeaderTimeout":"2s","readTimeout":"3s","writeTimeout":"4s","idleTimeout":"5s","maxHeaderBytes":8192}}`},
|
||||
{"server.yaml", "http:\n readHeaderTimeout: 2s\n readTimeout: 3s\n writeTimeout: 4s\n idleTimeout: 5s\n maxHeaderBytes: 8192\n"},
|
||||
} {
|
||||
t.Run(file.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), file.name)
|
||||
if err := os.WriteFile(path, []byte(file.body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.LoadFile(path, "HTTP_TEST_")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := config.HTTPConfig{ReadHeaderTimeout: 2 * time.Second, ReadTimeout: 3 * time.Second, WriteTimeout: 4 * time.Second, IdleTimeout: 5 * time.Second, MaxHeaderBytes: 8192}
|
||||
if cfg.HTTP != want {
|
||||
t.Fatalf("HTTP: %+v", cfg.HTTP)
|
||||
}
|
||||
t.Setenv("HTTP_TEST_HTTP_READ_HEADER_TIMEOUT", "6s")
|
||||
t.Setenv("HTTP_TEST_HTTP_READ_TIMEOUT", "7s")
|
||||
t.Setenv("HTTP_TEST_HTTP_WRITE_TIMEOUT", "8s")
|
||||
t.Setenv("HTTP_TEST_HTTP_IDLE_TIMEOUT", "9s")
|
||||
t.Setenv("HTTP_TEST_HTTP_MAX_HEADER_BYTES", "16384")
|
||||
got, err := config.ApplyEnv(cfg, "HTTP_TEST_")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want = config.HTTPConfig{ReadHeaderTimeout: 6 * time.Second, ReadTimeout: 7 * time.Second, WriteTimeout: 8 * time.Second, IdleTimeout: 9 * time.Second, MaxHeaderBytes: 16384}
|
||||
if got.HTTP != want {
|
||||
t.Fatalf("overlay: %+v", got.HTTP)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPInvalidAndUnsupportedSettings(t *testing.T) {
|
||||
for _, key := range []string{"READ_HEADER_TIMEOUT", "READ_TIMEOUT", "WRITE_TIMEOUT", "IDLE_TIMEOUT", "MAX_HEADER_BYTES"} {
|
||||
for _, value := range []string{"invalid", "-1"} {
|
||||
t.Run(key+value, func(t *testing.T) {
|
||||
if value == "-1" && key != "MAX_HEADER_BYTES" {
|
||||
value = "-1s"
|
||||
}
|
||||
t.Setenv("HTTP_INVALID_HTTP_"+key, value)
|
||||
if _, err := config.LoadFromEnv("HTTP_INVALID_"); err == nil {
|
||||
t.Fatal("invalid HTTP value accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, http := range []config.HTTPConfig{{ReadHeaderTimeout: time.Second}, {ReadTimeout: time.Second}, {WriteTimeout: time.Second}} {
|
||||
cfg := config.Default()
|
||||
cfg.Transport = config.TransportHTTP2
|
||||
cfg.TLS = config.TLSConfig{CertFile: "c", KeyFile: "k"}
|
||||
cfg.HTTP = http
|
||||
if err := cfg.ValidateTransport(); err == nil || !strings.Contains(err.Error(), "not supported") {
|
||||
t.Fatalf("HTTP/2: %v", err)
|
||||
}
|
||||
}
|
||||
cfg := config.Default()
|
||||
cfg.Transport = config.TransportHTTP2
|
||||
cfg.TLS = config.TLSConfig{CertFile: "c", KeyFile: "k"}
|
||||
cfg.HTTP = config.HTTPConfig{IdleTimeout: time.Minute, MaxHeaderBytes: 8192}
|
||||
if err := cfg.ValidateTransport(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, transport := range []string{config.TransportNative, config.TransportHTTP2} {
|
||||
cfg.Transport = transport
|
||||
cfg.HTTP = config.HTTPConfig{ErrorLog: log.New(io.Discard, "", 0)}
|
||||
if err := cfg.ValidateTransport(); err == nil || !strings.Contains(err.Error(), "http.errorLog") {
|
||||
t.Fatalf("ErrorLog silently ignored: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
41
config/response_test.go
Normal file
41
config/response_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
func TestValidateEffectiveResponsePolicy(t *testing.T) {
|
||||
catalog := apierrors.Catalog{DefaultStatus: 400, Definitions: map[string]apierrors.Definition{
|
||||
"INVALID": {Code: 400002, Message: "invalid input"},
|
||||
}}
|
||||
other := catalog.Clone()
|
||||
other.Definitions["INVALID"] = apierrors.Definition{Code: 400003, Message: "other input"}
|
||||
formatter := response.MustNew(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999})
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
encoder response.Encoder
|
||||
wantError string
|
||||
}{
|
||||
{"incompatibleCode", response.RawJSON(response.MustNew(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, Codes: map[string]any{"INVALID": 100}})), "conflicting"},
|
||||
{"differentCatalog", response.MustWithErrors(other, formatter), "different error catalogs"},
|
||||
{"matchingCatalog", response.MustWithErrors(catalog, formatter), ""},
|
||||
{"rawJSON", response.RawJSON(response.MustNew(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999})), ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Errors, cfg.ResponseEncoder = catalog, tc.encoder
|
||||
err := cfg.Validate()
|
||||
if tc.wantError == "" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err == nil || !strings.Contains(err.Error(), tc.wantError) {
|
||||
t.Fatalf("want %q, got %v", tc.wantError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
`database` 包提供基于标准库 `database/sql` 的连接池生命周期,不绑定 ORM 或驱动:
|
||||
|
||||
- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。
|
||||
- `Open`:创建连接池、设置连接上限并在返回前执行 `PingContext`。
|
||||
- `Open` / `OpenConnector`:通过 DSN 或标准 Connector 创建连接池、设置连接上限并在返回前执行 `PingContext`。
|
||||
- `Health`:用于就绪检查,等待连接和 Ping 均受 `PingTimeout` 限制。
|
||||
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时回滚。
|
||||
- `Close`:由嵌入的 `*sql.DB` 提供,应用退出时显式调用。
|
||||
@@ -40,7 +40,7 @@ GO_OMNI_DB_CONN_MAX_IDLE_TIME=5m
|
||||
GO_OMNI_DB_PING_TIMEOUT=5s
|
||||
```
|
||||
|
||||
`DSN` 必填,驱动默认是 `mysql`。数据库建库、迁移、表结构和具体 Repository 属于业务项目或可选模块,不会被核心包自动执行。
|
||||
使用 `ConfigFromEnv` / `Open` 时 `DSN` 必填,环境变量入口的驱动默认是 `mysql`。数据库建库、迁移、表结构和具体 Repository 属于业务项目或可选模块,不会被核心包自动执行。
|
||||
|
||||
`MaxIdleConns=0` 会禁用空闲连接。初始化 Ping 失败会关闭已创建的连接池。
|
||||
|
||||
@@ -64,3 +64,61 @@ python3 scripts/verifyDatabase.py --version v0.2.0
|
||||
`Config` 的 JSON/YAML 字段使用 lowerCamelCase,`DSN` 使用 `json:"-"`,连接密钥从环境变量加载。
|
||||
API 脚手架 `--database mysql` 使用此流程,并要求正的 `maxOpenConns`,提供数据库 readiness 与幂等清理。
|
||||
具体表结构、迁移和 Repository 留在业务项目,参考 `examples/userService`。
|
||||
|
||||
## Connector 接入(v0.9.1-preview)
|
||||
|
||||
项目已有 `database/sql/driver.Connector` 时使用 `OpenConnector`。这是标准驱动接口,核心包不增加具体驱动依赖。项目可以在同一进程创建多个使用不同证书、日志、拨号策略的连接池。
|
||||
|
||||
下面是 MySQL 的完整组装函数;连接地址、凭据、TLS 和驱动 Logger 都从调用方传入,不在框架中约定:
|
||||
|
||||
```go
|
||||
package connection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
)
|
||||
|
||||
func Open(ctx context.Context, address, user, password, name string,
|
||||
tlsConfig *tls.Config, logger mysql.Logger,
|
||||
pool database.PoolConfig) (*database.DB, error) {
|
||||
cfg := mysql.NewConfig()
|
||||
cfg.Net, cfg.Addr = "tcp", address
|
||||
cfg.User, cfg.Passwd, cfg.DBName = user, password, name
|
||||
cfg.TLS = tlsConfig // 项目设置 RootCAs、ServerName、MinVersion 等
|
||||
if logger != nil {
|
||||
cfg.Logger = logger // 项目现有的 Print(...any) 日志适配器
|
||||
}
|
||||
connector, err := mysql.NewConnector(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return database.OpenConnector(ctx, connector, pool)
|
||||
}
|
||||
```
|
||||
|
||||
调用时以 `database.DefaultPoolConfig()` 为起点,按项目需要调整。已有 `database.Config` 可调用 `.PoolConfig()` 提取池设置;`ConfigFromEnv` / `ApplyEnv` 仍是 DSN 入口,不用为 Connector 填假 DSN。`PoolConfig` 支持通过 `config.DecodeFile` 加载项目的 JSON/YAML 嵌套配置,环境覆盖由项目按自己的驱动配置组织。
|
||||
|
||||
| PoolConfig 字段 | 默认值 / 含义 |
|
||||
| --- | --- |
|
||||
| `MaxOpenConns` | `10`;`0` 表示不限制 |
|
||||
| `MaxIdleConns` | `5`;`0` 禁止保留空闲连接 |
|
||||
| `ConnMaxLifetime` | `30m`;`0` 不因连接年龄回收 |
|
||||
| `ConnMaxIdleTime` | `5m`;`0` 不因空闲时长回收 |
|
||||
| `PingTimeout` | `5s`;必须为正,约束启动 Ping 和 Health 等待 |
|
||||
|
||||
返回的 `*database.DB` 嵌入 `*sql.DB`,可使用 `QueryContext`、`ExecContext`、`Stats`,以及框架的 `Health`、`InTx`。需要标准数据库指针的 ORM/迁移器可取 `db.DB`;不要重复建立池。项目负责驱动支持的 context 取消与读写超时设置,框架不能强制中断不遵守 context 的自定义驱动。
|
||||
|
||||
**生命周期:** nil/带类型的 nil Connector、非法池参数、启动前已取消的 context 会在创建池前返回,Connector 仍归调用方。进入 `sql.OpenDB` 后由池持有;启动 Ping 失败自动关闭,成功后由调用方负责 `db.Close`,通常注册 `app.OnShutdown(db.Close)`。若 Connector 实现 `io.Closer`,遵循标准 `database/sql` 规则随池关闭;有资源的 Connector 不应跨多个池共享。资源初始化过程中、注册退出回调之前若业务校验失败,项目也须关闭池。
|
||||
|
||||
`db.PoolConfig()` 返回池配置快照。Connector 入口的 `db.Config()` 中 `Driver` / `DSN` 为空,不能用它重建 Connector。TLS、Logger、驱动版本检查、字符集、时区以及迁移规则保持由项目负责;不写入框架配置快照。
|
||||
|
||||
验收脚本使用独立本地 MySQL 8.4 容器(1 CPU / 768 MiB),自动生成测试 CA/证书与随机凭据,检查 TLS 信任/主机名、两池配置隔离、驱动日志、事务和 HTTP 超时,结束后清理本次资源:
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyConnector.py --local --report performance/connectorLocal.json
|
||||
# 发布后通过全新 module cache 验证远程依赖,无本地 replace:
|
||||
python3 scripts/verifyConnector.py --version v0.9.1-preview --report performance/connectorRemote.json
|
||||
```
|
||||
|
||||
82
database/connector.go
Normal file
82
database/connector.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"reflect"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PoolConfig contains only driver-independent settings. Connection credentials,
|
||||
// TLS, logging and dial behavior belong to the application's Connector.
|
||||
// Start from DefaultPoolConfig; zero PingTimeout is invalid.
|
||||
type PoolConfig struct {
|
||||
MaxOpenConns int `json:"maxOpenConns"`
|
||||
MaxIdleConns int `json:"maxIdleConns"`
|
||||
ConnMaxLifetime time.Duration `json:"connMaxLifetime"`
|
||||
ConnMaxIdleTime time.Duration `json:"connMaxIdleTime"`
|
||||
PingTimeout time.Duration `json:"pingTimeout"`
|
||||
}
|
||||
|
||||
func DefaultPoolConfig() PoolConfig {
|
||||
return PoolConfig{MaxOpenConns: 10, MaxIdleConns: 5, ConnMaxLifetime: 30 * time.Minute, ConnMaxIdleTime: 5 * time.Minute, PingTimeout: 5 * time.Second}
|
||||
}
|
||||
|
||||
func (c PoolConfig) Validate() error {
|
||||
if c.MaxOpenConns < 0 || c.MaxIdleConns < 0 {
|
||||
return errors.New("database pool limits cannot be negative")
|
||||
}
|
||||
if c.MaxOpenConns > 0 && c.MaxIdleConns > c.MaxOpenConns {
|
||||
return errors.New("max idle connections cannot exceed max open connections")
|
||||
}
|
||||
if c.ConnMaxLifetime < 0 || c.ConnMaxIdleTime < 0 || c.PingTimeout <= 0 {
|
||||
return errors.New("database timeouts are invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c PoolConfig) config() Config {
|
||||
return Config{MaxOpenConns: c.MaxOpenConns, MaxIdleConns: c.MaxIdleConns, ConnMaxLifetime: c.ConnMaxLifetime, ConnMaxIdleTime: c.ConnMaxIdleTime, PingTimeout: c.PingTimeout}
|
||||
}
|
||||
|
||||
// OpenConnector opens an independent pool using the supplied standard Connector.
|
||||
// No registered driver name or DSN is required. It applies cfg and completes a
|
||||
// startup Ping bounded by ctx and PingTimeout before returning.
|
||||
//
|
||||
// Validation failures and an already-canceled ctx leave the Connector untouched.
|
||||
// Once sql.OpenDB is called, the pool owns its lifecycle: a failed startup closes
|
||||
// the pool; on success the caller must Close the returned DB. As in database/sql,
|
||||
// this also closes a Connector that implements io.Closer. Use a separate Connector
|
||||
// for each pool if it owns resources. Nil ctx means context.Background.
|
||||
func OpenConnector(ctx context.Context, connector driver.Connector, cfg PoolConfig) (*DB, error) {
|
||||
if nilConnector(connector) {
|
||||
return nil, errors.New("database connector is required")
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return initializePool(ctx, sql.OpenDB(connector), cfg.config())
|
||||
}
|
||||
|
||||
func nilConnector(connector driver.Connector) bool {
|
||||
if connector == nil {
|
||||
return true
|
||||
}
|
||||
v := reflect.ValueOf(connector)
|
||||
switch v.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
return v.IsNil()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PoolConfig returns a copy of the settings used to create this pool.
|
||||
func (db *DB) PoolConfig() PoolConfig { return db.config.PoolConfig() }
|
||||
336
database/connector_test.go
Normal file
336
database/connector_test.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
)
|
||||
|
||||
type fixtureConnector struct {
|
||||
identity string
|
||||
connects, closedConnections, closedConnector, commits, rollbacks atomic.Int32
|
||||
connect func(context.Context) error
|
||||
ping func(context.Context) error
|
||||
query func(context.Context) error
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func (c *fixtureConnector) Connect(ctx context.Context) (driver.Conn, error) {
|
||||
c.connects.Add(1)
|
||||
if c.connect != nil {
|
||||
if err := c.connect(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &fixtureConn{owner: c}, nil
|
||||
}
|
||||
func (c *fixtureConnector) Driver() driver.Driver { return fixtureDriver{} }
|
||||
func (c *fixtureConnector) Close() error { c.closedConnector.Add(1); return c.closeErr }
|
||||
|
||||
type fixtureDriver struct{}
|
||||
|
||||
func (fixtureDriver) Open(string) (driver.Conn, error) {
|
||||
return nil, errors.New("unexpected driver name/DSN path")
|
||||
}
|
||||
|
||||
type fixtureConn struct{ owner *fixtureConnector }
|
||||
|
||||
func (c *fixtureConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("not used") }
|
||||
func (c *fixtureConn) Close() error { c.owner.closedConnections.Add(1); return nil }
|
||||
func (c *fixtureConn) Begin() (driver.Tx, error) { return fixtureTx{c.owner}, nil }
|
||||
func (c *fixtureConn) Ping(ctx context.Context) error {
|
||||
if c.owner.ping != nil {
|
||||
return c.owner.ping(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *fixtureConn) QueryContext(ctx context.Context, _ string, _ []driver.NamedValue) (driver.Rows, error) {
|
||||
if c.owner.query != nil {
|
||||
if err := c.owner.query(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &fixtureRows{identity: c.owner.identity}, nil
|
||||
}
|
||||
|
||||
type fixtureTx struct{ owner *fixtureConnector }
|
||||
|
||||
func (tx fixtureTx) Commit() error { tx.owner.commits.Add(1); return nil }
|
||||
func (tx fixtureTx) Rollback() error { tx.owner.rollbacks.Add(1); return nil }
|
||||
|
||||
type fixtureRows struct {
|
||||
identity string
|
||||
sent bool
|
||||
}
|
||||
|
||||
func (r *fixtureRows) Columns() []string { return []string{"identity"} }
|
||||
func (r *fixtureRows) Close() error { return nil }
|
||||
func (r *fixtureRows) Next(values []driver.Value) error {
|
||||
if r.sent {
|
||||
return io.EOF
|
||||
}
|
||||
r.sent = true
|
||||
values[0] = r.identity
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestConnectorIndependentPoolsAndContext(t *testing.T) {
|
||||
type key struct{}
|
||||
ctx := context.WithValue(context.Background(), key{}, "startup")
|
||||
cfg := database.DefaultPoolConfig()
|
||||
cfg.MaxOpenConns = 2
|
||||
cfg.MaxIdleConns = 2
|
||||
var owners []*fixtureConnector
|
||||
var pools []*database.DB
|
||||
for _, identity := range []string{"alpha", "beta"} {
|
||||
owner := &fixtureConnector{identity: identity}
|
||||
// Startup Connect/Ping and each query preserve their caller context.
|
||||
// database/sql may also open replacement connections in the background.
|
||||
owner.connect = func(ctx context.Context) error {
|
||||
if owner.connects.Load() == 1 && ctx.Value(key{}) != "startup" {
|
||||
return errors.New("lost request context")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
owner.ping = func(ctx context.Context) error {
|
||||
if ctx.Value(key{}) != "startup" {
|
||||
return errors.New("lost ping context")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
owner.query = func(ctx context.Context) error {
|
||||
if ctx.Value(key{}) != "startup" {
|
||||
return errors.New("lost query context")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
pool, err := database.OpenConnector(ctx, owner, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
if pool.PoolConfig() != cfg || pool.Config().Driver != "" || pool.Config().DSN != "" || pool.Stats().MaxOpenConnections != 2 {
|
||||
t.Fatal("pool settings or DSN boundary")
|
||||
}
|
||||
owners = append(owners, owner)
|
||||
pools = append(pools, pool)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i, pool := range pools {
|
||||
for n := 0; n < 16; n++ {
|
||||
wg.Add(1)
|
||||
go func(i int, pool *database.DB) {
|
||||
defer wg.Done()
|
||||
var identity string
|
||||
if err := pool.QueryRowContext(ctx, "identity").Scan(&identity); err != nil {
|
||||
t.Error(err)
|
||||
} else if identity != owners[i].identity {
|
||||
t.Error("crossed connectors")
|
||||
}
|
||||
}(i, pool)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
for i, pool := range pools {
|
||||
if pool.Stats().OpenConnections > cfg.MaxOpenConns {
|
||||
t.Fatal("pool cap not applied")
|
||||
}
|
||||
if err := pool.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owners[i].closedConnector.Load() != 1 || owners[i].closedConnections.Load() != owners[i].connects.Load() {
|
||||
t.Fatal("close did not release owned resources exactly once")
|
||||
}
|
||||
if pool.Health(ctx) == nil {
|
||||
t.Fatal("closed pool is healthy")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorRejectsInvalidInputWithoutTakingOwnership(t *testing.T) {
|
||||
var typedNil *fixtureConnector
|
||||
for _, connector := range []driver.Connector{nil, typedNil} {
|
||||
if db, err := database.OpenConnector(nil, connector, database.DefaultPoolConfig()); err == nil || db != nil {
|
||||
t.Fatal("nil connector accepted")
|
||||
}
|
||||
}
|
||||
for _, mutate := range []func(*database.PoolConfig){
|
||||
func(c *database.PoolConfig) { c.MaxOpenConns = -1 }, func(c *database.PoolConfig) { c.MaxIdleConns = -1 },
|
||||
func(c *database.PoolConfig) { c.MaxIdleConns = c.MaxOpenConns + 1 }, func(c *database.PoolConfig) { c.PingTimeout = 0 },
|
||||
func(c *database.PoolConfig) { c.ConnMaxLifetime = -1 }, func(c *database.PoolConfig) { c.ConnMaxIdleTime = -1 },
|
||||
} {
|
||||
cfg := database.DefaultPoolConfig()
|
||||
mutate(&cfg)
|
||||
owner := &fixtureConnector{}
|
||||
if db, err := database.OpenConnector(nil, owner, cfg); err == nil || db != nil {
|
||||
t.Fatal("invalid config accepted")
|
||||
}
|
||||
if owner.connects.Load() != 0 || owner.closedConnector.Load() != 0 {
|
||||
t.Fatal("validation consumed connector")
|
||||
}
|
||||
}
|
||||
owner := &fixtureConnector{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if db, err := database.OpenConnector(ctx, owner, database.DefaultPoolConfig()); !errors.Is(err, context.Canceled) || db != nil {
|
||||
t.Fatalf("pre-canceled context: %v", err)
|
||||
}
|
||||
if owner.connects.Load() != 0 || owner.closedConnector.Load() != 0 {
|
||||
t.Fatal("pre-cancellation consumed connector")
|
||||
}
|
||||
if err := database.DefaultConfig().Validate(); err == nil {
|
||||
t.Fatal("legacy DSN requirements changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorStartupFailuresReleaseResources(t *testing.T) {
|
||||
failure := errors.New("fixture unavailable")
|
||||
closeFailure := errors.New("fixture close failed")
|
||||
for _, phase := range []string{"connect", "ping"} {
|
||||
t.Run(phase, func(t *testing.T) {
|
||||
owner := &fixtureConnector{closeErr: closeFailure}
|
||||
if phase == "connect" {
|
||||
owner.connect = func(context.Context) error { return failure }
|
||||
} else {
|
||||
owner.ping = func(context.Context) error { return failure }
|
||||
}
|
||||
pool, err := database.OpenConnector(nil, owner, database.DefaultPoolConfig())
|
||||
if pool != nil || !errors.Is(err, failure) || !errors.Is(err, closeFailure) {
|
||||
t.Fatalf("lost failure or cleanup error: %v", err)
|
||||
}
|
||||
expectedConnections := int32(0)
|
||||
if phase == "ping" {
|
||||
expectedConnections = 1
|
||||
}
|
||||
if owner.closedConnector.Load() != 1 || owner.closedConnections.Load() != expectedConnections {
|
||||
t.Fatal("startup failure leaked resources")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorTimeoutBoundsConnectAndPing(t *testing.T) {
|
||||
for _, phase := range []string{"connect", "ping"} {
|
||||
t.Run(phase, func(t *testing.T) {
|
||||
owner := &fixtureConnector{}
|
||||
cfg := database.DefaultPoolConfig()
|
||||
cfg.PingTimeout = 20 * time.Millisecond
|
||||
wait := func(ctx context.Context) error {
|
||||
if _, ok := ctx.Deadline(); !ok {
|
||||
return errors.New("missing deadline")
|
||||
}
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
if phase == "connect" {
|
||||
owner.connect = wait
|
||||
} else {
|
||||
owner.ping = wait
|
||||
}
|
||||
pool, err := database.OpenConnector(context.Background(), owner, cfg)
|
||||
if pool != nil || !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("%s timeout: %v", phase, err)
|
||||
}
|
||||
if owner.closedConnector.Load() != 1 {
|
||||
t.Fatal("timeout leaked connector")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorHealthBoundsPoolWaitAndRecovers(t *testing.T) {
|
||||
cfg := database.DefaultPoolConfig()
|
||||
cfg.MaxOpenConns = 1
|
||||
cfg.MaxIdleConns = 1
|
||||
cfg.PingTimeout = 20 * time.Millisecond
|
||||
owner := &fixtureConnector{}
|
||||
pool, err := database.OpenConnector(nil, owner, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
conn, err := pool.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if err = pool.Health(nil); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("waiting for busy pool was not bounded: %v", err)
|
||||
}
|
||||
if err = conn.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = pool.Health(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorIdleZeroAndLifetime(t *testing.T) {
|
||||
for _, mode := range []string{"noIdle", "expired"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
cfg := database.DefaultPoolConfig()
|
||||
if mode == "noIdle" {
|
||||
cfg.MaxIdleConns = 0
|
||||
} else {
|
||||
cfg.ConnMaxLifetime = time.Nanosecond
|
||||
}
|
||||
owner := &fixtureConnector{}
|
||||
pool, err := database.OpenConnector(nil, owner, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err = pool.Health(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owner.connects.Load() < 2 || owner.closedConnections.Load() == 0 {
|
||||
t.Fatal("pool did not retire connections")
|
||||
}
|
||||
if mode == "noIdle" && pool.Stats().Idle != 0 {
|
||||
t.Fatal("zero idle not honored")
|
||||
}
|
||||
if mode == "expired" && pool.Stats().MaxLifetimeClosed == 0 {
|
||||
t.Fatal("lifetime not honored")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorTransactionsKeepCommitAndRollbackSemantics(t *testing.T) {
|
||||
owner := &fixtureConnector{}
|
||||
pool, err := database.OpenConnector(nil, owner, database.DefaultPoolConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
if err = pool.InTx(ctx, nil, func(*sql.Tx) error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failed := errors.New("business failed")
|
||||
if err = pool.InTx(ctx, nil, func(*sql.Tx) error { return failed }); !errors.Is(err, failed) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() != failed {
|
||||
t.Error("panic not preserved")
|
||||
}
|
||||
}()
|
||||
_ = pool.InTx(ctx, nil, func(*sql.Tx) error { panic(failed) })
|
||||
}()
|
||||
if owner.commits.Load() != 1 || owner.rollbacks.Load() != 2 {
|
||||
t.Fatal("transaction semantics changed")
|
||||
}
|
||||
}
|
||||
@@ -23,13 +23,14 @@ type Config struct {
|
||||
PingTimeout time.Duration `json:"pingTimeout"`
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
MaxOpenConns: 10,
|
||||
MaxIdleConns: 5,
|
||||
ConnMaxLifetime: 30 * time.Minute,
|
||||
ConnMaxIdleTime: 5 * time.Minute,
|
||||
PingTimeout: 5 * time.Second,
|
||||
func DefaultConfig() Config { return DefaultPoolConfig().config() }
|
||||
|
||||
// PoolConfig returns driver-independent connection pool settings.
|
||||
func (c Config) PoolConfig() PoolConfig {
|
||||
return PoolConfig{
|
||||
MaxOpenConns: c.MaxOpenConns, MaxIdleConns: c.MaxIdleConns,
|
||||
ConnMaxLifetime: c.ConnMaxLifetime, ConnMaxIdleTime: c.ConnMaxIdleTime,
|
||||
PingTimeout: c.PingTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,16 +84,7 @@ func (c Config) Validate() error {
|
||||
if strings.TrimSpace(c.DSN) == "" {
|
||||
return errors.New("database DSN is required")
|
||||
}
|
||||
if c.MaxOpenConns < 0 || c.MaxIdleConns < 0 {
|
||||
return errors.New("database pool limits cannot be negative")
|
||||
}
|
||||
if c.MaxIdleConns > c.MaxOpenConns && c.MaxOpenConns > 0 {
|
||||
return errors.New("max idle connections cannot exceed max open connections")
|
||||
}
|
||||
if c.ConnMaxLifetime < 0 || c.ConnMaxIdleTime < 0 || c.PingTimeout <= 0 {
|
||||
return errors.New("database timeouts are invalid")
|
||||
}
|
||||
return nil
|
||||
return c.PoolConfig().Validate()
|
||||
}
|
||||
|
||||
// DB owns a connection pool and exposes the embedded database/sql methods.
|
||||
@@ -112,23 +104,30 @@ func Open(ctx context.Context, cfg Config) (*DB, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
if cfg.MaxOpenConns > 0 {
|
||||
return initializePool(ctx, db, cfg)
|
||||
}
|
||||
|
||||
// initializePool is the shared ownership boundary for both creation paths.
|
||||
// All configuration is validated before creating the sql.DB.
|
||||
func initializePool(ctx context.Context, db *sql.DB, cfg Config) (*DB, error) {
|
||||
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
}
|
||||
// database/sql uses zero to disable idle connections; do not leave its
|
||||
// default pool size in place when the caller explicitly chooses zero.
|
||||
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
db.SetMaxIdleConns(cfg.MaxIdleConns) // Zero explicitly disables idle connections.
|
||||
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
||||
db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
|
||||
checkCtx, cancel := context.WithTimeout(ctx, cfg.PingTimeout)
|
||||
defer cancel()
|
||||
if err := db.PingContext(checkCtx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
pingErr := fmt.Errorf("ping database: %w", err)
|
||||
if closeErr := db.Close(); closeErr != nil {
|
||||
return nil, errors.Join(pingErr, fmt.Errorf("close database after failed ping: %w", closeErr))
|
||||
}
|
||||
return nil, pingErr
|
||||
}
|
||||
return &DB{DB: db, config: cfg}, nil
|
||||
}
|
||||
|
||||
// Config returns the original DSN configuration. Pools opened with a Connector
|
||||
// have empty Driver/DSN fields; their connector cannot be reconstructed from Config.
|
||||
func (db *DB) Config() Config { return db.config }
|
||||
|
||||
func (db *DB) Health(ctx context.Context) error {
|
||||
|
||||
180
database/mysqlConnector_test.go
Normal file
180
database/mysqlConnector_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
type connectorDriverLog struct{ count atomic.Int32 }
|
||||
|
||||
func (l *connectorDriverLog) Print(...any) { l.count.Add(1) }
|
||||
|
||||
func TestMySQLConnectorDriverLoggerIsolation(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(stopped)
|
||||
for {
|
||||
c, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
defer func() { listener.Close(); <-stopped }()
|
||||
logs := [2]connectorDriverLog{}
|
||||
for i := range logs {
|
||||
cfg := mysql.NewConfig()
|
||||
cfg.Net = "tcp"
|
||||
cfg.Addr = listener.Addr().String()
|
||||
cfg.Timeout = time.Second
|
||||
cfg.ReadTimeout = time.Second
|
||||
cfg.Logger = &logs[i]
|
||||
connector, err := mysql.NewConnector(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := [2]int32{logs[0].count.Load(), logs[1].count.Load()}
|
||||
db, err := database.OpenConnector(context.Background(), connector, database.DefaultPoolConfig())
|
||||
if err == nil {
|
||||
db.Close()
|
||||
t.Fatal("handshake to closed socket succeeded")
|
||||
}
|
||||
if logs[i].count.Load() <= before[i] || logs[1-i].count.Load() != before[1-i] {
|
||||
t.Fatal("driver logger did not stay on its connector")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLConnectorTLSAndPoolIsolation(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_CONNECTOR_MYSQL_TEST") != "1" {
|
||||
t.Skip("use scripts/verifyConnector.py for isolated MySQL TLS verification")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
ca, err := os.ReadFile(os.Getenv("GO_OMNI_CONNECTOR_CA_FILE"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
if !roots.AppendCertsFromPEM(ca) {
|
||||
t.Fatal("invalid test CA")
|
||||
}
|
||||
cfg := mysql.NewConfig()
|
||||
cfg.Net = "tcp"
|
||||
cfg.Addr = os.Getenv("GO_OMNI_CONNECTOR_ADDR")
|
||||
cfg.User = os.Getenv("GO_OMNI_CONNECTOR_USER")
|
||||
cfg.Passwd = os.Getenv("GO_OMNI_CONNECTOR_PASSWORD")
|
||||
cfg.DBName = os.Getenv("GO_OMNI_CONNECTOR_DATABASE")
|
||||
cfg.Timeout = time.Second
|
||||
cfg.ReadTimeout = 2 * time.Second
|
||||
cfg.WriteTimeout = 2 * time.Second
|
||||
cfg.TLS = &tls.Config{MinVersion: tls.VersionTLS12, ServerName: "localhost", RootCAs: roots}
|
||||
pool := database.DefaultPoolConfig()
|
||||
pool.MaxOpenConns = 2
|
||||
pool.MaxIdleConns = 2
|
||||
pool.PingTimeout = 2 * time.Second
|
||||
open := func(cfg *mysql.Config) (*database.DB, error) {
|
||||
connector, err := mysql.NewConnector(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return database.OpenConnector(ctx, connector, pool)
|
||||
}
|
||||
cfg.Params = map[string]string{"time_zone": "'+00:00'"}
|
||||
first, err := open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first.Close()
|
||||
other := cfg.Clone()
|
||||
other.Params["time_zone"] = "'+08:00'"
|
||||
second, err := open(other)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer second.Close()
|
||||
for i, db := range []*database.DB{first, second} {
|
||||
var name, cipher, zone string
|
||||
if err := db.QueryRowContext(ctx, "SHOW SESSION STATUS LIKE 'Ssl_cipher'").Scan(&name, &cipher); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cipher == "" {
|
||||
t.Fatal("connection is not encrypted")
|
||||
}
|
||||
if err := db.QueryRowContext(ctx, "SELECT @@session.time_zone").Scan(&zone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if zone != []string{"+00:00", "+08:00"}[i] {
|
||||
t.Fatalf("session config leaked: %s", zone)
|
||||
}
|
||||
if db.Stats().MaxOpenConnections != 2 {
|
||||
t.Fatal("pool configuration ignored")
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"untrustedCA", "wrongHostname"} {
|
||||
t.Run(bad, func(t *testing.T) {
|
||||
invalid := cfg.Clone()
|
||||
if bad == "untrustedCA" {
|
||||
invalid.TLS.RootCAs = x509.NewCertPool()
|
||||
} else {
|
||||
invalid.TLS.ServerName = "wrong.invalid"
|
||||
}
|
||||
db, err := open(invalid)
|
||||
if err == nil {
|
||||
db.Close()
|
||||
t.Fatal("invalid server certificate accepted")
|
||||
}
|
||||
var verification *tls.CertificateVerificationError
|
||||
if !errors.As(err, &verification) {
|
||||
t.Fatalf("not a TLS verification failure: %v", err)
|
||||
}
|
||||
if err := first.Health(ctx); err != nil {
|
||||
t.Fatalf("failed connector affected existing pool: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := first.ExecContext(ctx, "CREATE TABLE connector_check (id INT PRIMARY KEY)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := first.InTx(ctx, nil, func(tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "INSERT INTO connector_check VALUES (1)")
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rollbackErr := errors.New("rollback requested")
|
||||
if err := first.InTx(ctx, nil, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, "INSERT INTO connector_check VALUES (2)"); err != nil {
|
||||
return err
|
||||
}
|
||||
return rollbackErr
|
||||
}); !errors.Is(err, rollbackErr) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := second.QueryRowContext(ctx, "SELECT COUNT(*) FROM connector_check").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("transaction count=%d err=%v", count, err)
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := second.Health(ctx); err != nil {
|
||||
t.Fatal("closing first pool affected second:", err)
|
||||
}
|
||||
}
|
||||
211
errors/README.md
Normal file
211
errors/README.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# 统一错误目录
|
||||
|
||||
从 `v0.10.0-preview` 起,业务可以只返回逻辑错误标识,框架从项目目录取得 HTTP 状态、对外业务码与公开提示。目录不依赖具体业务、数据库、身份模型或网络层,也不定义固定返回体。
|
||||
|
||||
## 基本规则
|
||||
|
||||
- `key` / `Definitions` 的 map key 是唯一逻辑标识,例如 `USERNAME_INVALID`、`user.notFound`。API 新目录允许项目自己的标识命名;旧 `error (...)` 声明保持原有大写标识规则。
|
||||
- `code` 是对外业务码,支持字符串、整数,同一个码可以由多个不同标识使用。JSON/YAML 和 `.api` 读取整数时保留 int64 精度。
|
||||
- `DefaultStatus` / `defaultStatus` 只为目录中未写状态的条目提供默认值,省略时为 400。项目可设为 200;单项 `Status` / `status` 优先。
|
||||
- 目录条目允许有响应体的最终 HTTP 状态:200..599,排除 204、205、304。成功输出与错误输出分开;HTTP 200 并不把错误变成成功。
|
||||
- 数字码类型与范围遵守选中响应的 `codeType`(string/int32/int64;integer 是旧别名)。不同路由可以选择不同类型的公共响应与目录。
|
||||
- 新目录声明完整公开提示;每条错误的业务码和提示优先于统一 `errorCode` / `errorMessage` 兜底。旧的同标识 `codes` / `messages` 若与目录冲突,初始化或契约编译报错;相同值允许保留,便于逐步迁移。
|
||||
|
||||
## 手写项目
|
||||
|
||||
```go
|
||||
import (
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
catalog := omnierrors.Catalog{
|
||||
DefaultStatus: 200,
|
||||
Definitions: map[string]omnierrors.Definition{
|
||||
"USERNAME_INVALID": {Code: 400002, Message: "用户名格式不正确"},
|
||||
"PASSWORD_INVALID": {Code: 400002, Message: "密码格式不正确"},
|
||||
"FORBIDDEN": {Status: 403, Code: 403001, Message: "没有权限"},
|
||||
},
|
||||
}
|
||||
app := omni.New(
|
||||
omni.WithErrors(catalog),
|
||||
omni.WithResponse(response.Config{
|
||||
CodeType: "int32", SuccessCode: 0, ErrorCode: 9999,
|
||||
MessageField: "msg", DataMode: "always",
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Service 返回 `omnierrors.ByCode("USERNAME_INVALID")`,或 `.WithCause(err)`。业务无需接收 App/HTTP Context。目录属于 App,初始化时校验并复制;后续修改原 map 不影响正在服务的应用。配置快照也不暴露内部 map。
|
||||
|
||||
`NewRegistry(catalog)` / `MustRegistry(catalog)` 可在 CLI/任务消费者等非 HTTP 入口使用;`registry.Resolve(err)` 返回独立的 `*APIError`,包含逻辑 `Code`、对外 `BusinessCode`、Status、Message 与内部 Cause。每次调用都创建新对象;不要自行将一个可变错误对象保存为共享单例再调用 WithCause。
|
||||
|
||||
原有 `New(status, key, message)` 等明确错误也能被同标识目录条目覆盖;未在目录中的明确错误沿用原状态与提示。未配置目录时,原有行为保持。Go 目录允许 `Definition.Code == nil` 以继续使用旧 Codes/errorCode 映射;新 `.api errorCatalog` 要求显式 code。
|
||||
|
||||
## Go 维护错误目录,生成路由继承 App(v0.11.1-preview)
|
||||
|
||||
错误可以只在 Go 中定义,并在 `omni.New(omni.WithErrors(catalog), ...)` 注册一次。`.api` 只写接口、请求/响应 DTO 和响应格式,**无需再声明 errorCatalog,也无需为每条路由列出错误**。业务直接返回 `ByCode(key)` 或 `ByCode(key).WithParams(...)`。
|
||||
|
||||
选择规则:
|
||||
|
||||
- 生成路由没有有效的 `@errors` / `@server errors` 列表时,继承 App 目录,包括 App 配置的 INTERNAL_ERROR 中文兜底。
|
||||
- 路由显式引用了错误定义时,使用该路由自己的目录;不与 App 目录合并,保持独立语义。
|
||||
- `@errors none` 清空 `.api` 分组继承的错误列表;清空后按没有路由目录处理,继承 App,而不是禁用错误处理。
|
||||
- 错误目录与响应格式独立选择。继承 App 错误不覆盖 `.api` 的响应模板、RawJSON、绑定方式和请求体限额;响应 `codeType` 需能表示目录中的业务码。
|
||||
- App 全局中间件提前返回错误、普通 Handler 和 Context 克隆遵循相同规则。
|
||||
|
||||
`response.WithErrors` / `MustWithErrors` 收到空目录时返回原 Encoder,不建立空 Registry,也不在编码时再次解析原始 error。已有 v0.11.0 生成代码中的空包装因此也能通过升级运行时修复;同步升级 CLI 重新生成则不再产生空包装。
|
||||
|
||||
可生成的示例见 [errorInheritance.api](../examples/contracts/errorInheritance.api)。Go 目录是在运行时注册的,CLI 不执行项目 Go 代码;静态 OpenAPI 保留 `.api` 中的响应结构,不会自动枚举 Go 目录的具体错误码、提示及状态。这不影响运行时接入,也不要求重复维护目录。
|
||||
|
||||
## 文件配置
|
||||
|
||||
下面是生成项目的 `etc/config.yaml` 结构;直接 `config.LoadFile` 加载框架配置时去掉外面的 `server`。手写 main 需显式将加载结果传入 `WithConfig`。
|
||||
|
||||
```yaml
|
||||
server:
|
||||
errors:
|
||||
defaultStatus: 200
|
||||
definitions:
|
||||
USERNAME_INVALID:
|
||||
code: 400002
|
||||
message: 用户名格式不正确
|
||||
FORBIDDEN:
|
||||
status: 403
|
||||
code: 403001
|
||||
message: 没有权限
|
||||
response:
|
||||
codeType: int32
|
||||
successCode: 0
|
||||
errorCode: 9999
|
||||
messageField: msg
|
||||
dataMode: always
|
||||
```
|
||||
|
||||
重复 JSON/YAML key、未知字段、非法状态、非整数数值及类型/范围错误会拒绝加载。目录不支持运行时热更新;修改后重新启动,避免请求与文档在运行期间漂移。
|
||||
|
||||
## .api:定义一次,生成调用与文档
|
||||
|
||||
完整示例见 [errors.api](../examples/contracts/errors.api)。语法:
|
||||
|
||||
```text
|
||||
errorCatalog CommonErrors (
|
||||
defaultStatus: 200
|
||||
errors: {
|
||||
"UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400002, "message": "用户名格式不正确"},
|
||||
"PasswordInvalid": {"key": "PASSWORD_INVALID", "code": 400002, "message": "密码格式不正确"}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
目录可放进公共 `.api` 再 import;也可以拆成多个命名目录,各自设置默认状态。生成器合并定义时检查目录名、函数名、逻辑标识冲突。`Catalog` 是生成包的保留函数名。目录对象的键是生成的 UpperCamelCase 函数名,条目的 `key` 是运行时逻辑标识,二者用途不同。
|
||||
|
||||
在 `@server` 的 `errors` 或路由 `@errors` 中引用函数名,响应仍通过原有 `response` 声明选择:
|
||||
|
||||
```text
|
||||
@server (
|
||||
response: CommonResponse
|
||||
errors: UsernameInvalid, PasswordInvalid
|
||||
)
|
||||
service account-api {
|
||||
@handler token
|
||||
post /token returns (TokenResponse)
|
||||
}
|
||||
```
|
||||
|
||||
生成结果包括:
|
||||
|
||||
- `apierrors.UsernameInvalid()` 等无参数函数,每次返回独立错误,支持 WithCause。
|
||||
- `apierrors.CodeUsernameInvalid` 等逻辑标识常量。
|
||||
- `apierrors.Catalog()`:整个契约的可复制运行时目录,方便手写路由复用。
|
||||
- 自动绑定到生成路由的目录:只包含该路由通过 `@errors` / `@server errors` 声明的错误,在 App 全局中间件之前生效,支持认证提前返回和缓冲克隆。
|
||||
- OpenAPI 的错误响应与示例。同一 HTTP 200 下用 `anyOf` 同时描述成功 DTO 和错误体;多个错误共用数字码时保持多个示例;raw 成功体与失败公共体也分开描述。
|
||||
|
||||
业务直接 `return TokenResponse{}, apierrors.UsernameInvalid()`。也可以返回 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`;使用标识引用时,当前生效的目录必须包含该错误。采用 `.api` 管理路由独立目录时,新增错误需补充该路由的 `@errors`;采用 Go 注册 App 目录时,无需在 `.api` 重复声明。
|
||||
|
||||
显式声明了错误列表的生成路由使用 `.api` 目录,未声明的路由继承 App;响应格式始终按各自配置选择。手写路由可在 main 用 `omni.WithErrors(apierrors.Catalog())` 复用目录并选择兼容的 `WithResponse`。已有手写文件保持不动。
|
||||
|
||||
## 提示模板参数(v0.11.0-preview)
|
||||
|
||||
模板属于错误目录,业务只传具体值。参数用**有序列表**声明,顺序就是生成函数的参数顺序;每个参数均为必传,`example` 只用于文档。
|
||||
|
||||
```text
|
||||
errorCatalog CommonErrors (
|
||||
defaultStatus: 200
|
||||
errors: {
|
||||
"TextTooLong": {
|
||||
"key": "TEXT_TOO_LONG",
|
||||
"code": 400002,
|
||||
"message": "{label}不能超过{max}个字符",
|
||||
"params": [
|
||||
{"name": "label", "type": "string", "example": "企业名称"},
|
||||
{"name": "max", "type": "int", "example": 100}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
与数字码匹配的公共响应及路由声明见完整 [errorParams.api](../examples/contracts/errorParams.api)。生成后:
|
||||
|
||||
```go
|
||||
apierrors.TextTooLong("企业名称", 100) // Message:企业名称不能超过100个字符
|
||||
apierrors.TextTooLong("备注", 500) // Message:备注不能超过500个字符
|
||||
apierrors.UsernameInvalid() // 原有固定错误保持无参数
|
||||
```
|
||||
|
||||
生成函数在返回前完成解析,非 HTTP Service、任务或 CLI 同样能直接读取完整 Message。调用 `.WithCause(err)` 保留内部原因及 `errors.Is/As` 错误链,不自动把原因加入提示。每次返回新对象,不能把可变错误保存成共享单例。
|
||||
|
||||
运行时入口使用同一个解析器:
|
||||
|
||||
```go
|
||||
err := omnierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{
|
||||
"label": "企业名称",
|
||||
"max": 100,
|
||||
})
|
||||
// HTTP:将 err 返回,当前 App/路由目录负责解析。
|
||||
// 非 HTTP:使用项目自己的目录显式解析。
|
||||
registry := omnierrors.MustRegistry(apierrors.Catalog())
|
||||
resolved := registry.Resolve(err)
|
||||
fmt.Println(resolved.Message) // 企业名称不能超过100个字符
|
||||
```
|
||||
|
||||
Go 配置中的 `Definition.Params` 是 `[]omnierrors.Parameter`,JSON/YAML 使用相同的 `params` 数组。目录在初始化时复制并编译模板;`WithParams` 返回新错误并复制参数,之后修改原 map 不会改变该错误。重复解析、路由策略和 Context 缓冲克隆保留参数。
|
||||
|
||||
规则如下:
|
||||
|
||||
- 参数名使用 lowerCamelCase,并且不是 Go 关键字。顺序是调用 API 的一部分,重排或变更类型需要同步调整调用方。
|
||||
- 支持 `string`、`bool`、`int`、`int32`、`int64`、`float64`。运行时允许整数类型之间按声明范围归一化,拒绝将字符串或浮点数隐式转成整数;float64 要求有限数值。`int` 范围随 Go 目标平台变化,需要固定位宽时选 int32/int64。
|
||||
- 只接受标量,不接受 nil、切片、map、指针、任意对象或 Stringer。通过 JSON 构建运行时参数时使用 `Decoder.UseNumber()`,避免丢失大整数精度。
|
||||
- 模板使用 `{name}`;同一参数可重复出现。`{{` / `}}` 表示字面花括号。插入值不会再次解析,即使 label 本身包含 `{max}`,也只是普通文本。
|
||||
- **省略 params** 表示旧固定提示,字面花括号不变。不接受空 params 数组;重复参数、未声明占位符、未使用参数、不匹配的花括号、非法类型及错误的 example 会在目录加载/契约校验时失败。
|
||||
- 运行时缺参、多传参数、类型或范围错误转为系统错误兜底;不会将原始模板或参数值作为诊断发给客户端。内部 cause 中可通过 `errors.As` 获取 `*omnierrors.ParamError`,访问日志带 `errorParameter` 诊断,不自动记录参数值。
|
||||
- 项目可以配置固定的 INTERNAL_ERROR 兜底;若它自身也需要参数且无法解析,则回退框架固定 500,避免递归失败。
|
||||
- 模板错误的提示只放目录中;同标识再配置旧 `messages` 会被拒绝。响应字段、HTTP 状态、`data:null` 仍由项目策略决定,例如 `dataMode: always` 保留空 data。
|
||||
|
||||
OpenAPI 的 `x-omni-errors` 保留模板和有序参数定义,响应 examples 使用同一个解析器生成具体文案。未提供 example 时使用稳定样例(string 使用参数名,bool 使用 false,数值使用 1);这些样例不充当运行时默认值,也不是新增 HTTP 请求参数。
|
||||
|
||||
幂等重放保存当次**已解析的公开错误快照**,保留原响应与失败信息。目录更新后,仍在有效期内的重放不会被新文案覆盖;快照不持久化原始参数或内部 cause。整数业务码以不受 JSON 中间层浮点转换影响的方式保存。旧缓存仅保留已有错误标识,不能补推当时缺失的提示与业务码。共享存储的错误快照另受 MaxResponseBytes 限额约束,超限保留不可重放记录,避免重复执行业务。
|
||||
|
||||
## 特殊协议与失败信息
|
||||
|
||||
`response.WithErrors(catalog, encoder)` / `MustWithErrors` 可绑定路由专用策略,再通过 `HandleWithResponse` 注册。内置模板校验目录码类型与旧映射;自定义 Encoder 自行决定最终 HTTP 状态、媒体类型和字段结构,可将失败转换成第三方要求的文本/XML 回执。一个 Encoder 作用域不叠加不同目录;外层空目录或相同目录不会覆盖已有目录。
|
||||
|
||||
Encoder 的 `response.Result` 包含逻辑 `Code`、对外 `BusinessCode`、公开 `Message`、原始 `Err` 及 `Failed`。使用 `result.IsError()` 判断失败,不要仅判断 `Status >= 400`。`RawJSON` 仅让成功结果保持未包装,HTTP 200 的失败仍使用选定错误策略。`c.Text` / `c.XML` / `c.JSON` 等直接输出及已提交的标准 Handler 响应不会被目录重新包装。
|
||||
|
||||
启用访问日志时,记录最终写出的 HTTP 状态,并保留 `failed`、`errorKey` 和可用的 `businessCode`。内置指标将已处理的业务失败计入 Errors;链路记录保留 HTTP 200 和业务失败标记。标准中间件桥接和本地/共享幂等重放保留失败标识;已有缓存条目没有新增元数据时无法追溯识别业务失败,等待正常过期即可。
|
||||
|
||||
未知的 ByCode 标识、普通 error、panic 默认走 `INTERNAL_ERROR`,公开兜底不暴露 cause。项目可显式配置 INTERNAL_ERROR 条目,但目录 DefaultStatus 本身不会改变系统兜底。启用访问日志后,可通过原始 error 追查未知标识和内部原因。
|
||||
|
||||
## 升级
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.11.1-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.1-preview
|
||||
go-omni api generate --dry-run
|
||||
go-omni api generate
|
||||
go test ./...
|
||||
```
|
||||
|
||||
先升级框架依赖,再用同版 CLI 生成;新生成代码最低依赖 v0.11.0-preview。原来的错误构造函数、旧 `error (...)`、Codes/Messages 与已生成代码可继续运行。把业务中散落的状态/提示逐步替换为生成函数属于项目迁移工作,框架不会自动改写业务逻辑。
|
||||
250
errors/catalog.go
Normal file
250
errors/catalog.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package apierrors
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
stdErrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Definition belongs to the project. Code is the public string/integer code;
|
||||
// the Catalog map key is the distinct logical identifier used by ByCode.
|
||||
// Nil Code retains legacy response code mapping (useful when migrating).
|
||||
type Definition struct {
|
||||
Status int `json:"status,omitempty"`
|
||||
Code any `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Params []Parameter `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type Catalog struct {
|
||||
DefaultStatus int `json:"defaultStatus,omitempty"`
|
||||
Definitions map[string]Definition `json:"definitions"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON preserves int64 precision and rejects ambiguous duplicate keys.
|
||||
func (c *Catalog) UnmarshalJSON(data []byte) error {
|
||||
type plain Catalog
|
||||
value := plain(c.Clone())
|
||||
if err := DecodeCatalogJSON(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
*c = Catalog(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecodeCatalogJSON strictly decodes catalog metadata without float conversion.
|
||||
// It is also used by the API compiler so runtime and contracts agree.
|
||||
func DecodeCatalogJSON(data []byte, dst any) error {
|
||||
scan := json.NewDecoder(bytes.NewReader(data))
|
||||
scan.UseNumber()
|
||||
var read func() error
|
||||
read = func() error {
|
||||
token, err := scan.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d, ok := token.(json.Delim); ok {
|
||||
switch d {
|
||||
case '{':
|
||||
seen := map[string]bool{}
|
||||
for scan.More() {
|
||||
k, err := scan.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, ok := k.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("errors: object key required")
|
||||
}
|
||||
if seen[key] {
|
||||
return fmt.Errorf("errors: duplicate key %q", key)
|
||||
}
|
||||
seen[key] = true
|
||||
if err := read(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case '[':
|
||||
for scan.More() {
|
||||
if err := read(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("errors: invalid JSON delimiter")
|
||||
}
|
||||
_, err = scan.Token()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := read(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := scan.Token(); err != io.EOF {
|
||||
return fmt.Errorf("errors: one JSON value required")
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.UseNumber()
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dst)
|
||||
}
|
||||
|
||||
func (c Catalog) Clone() Catalog {
|
||||
out := c
|
||||
out.Definitions = make(map[string]Definition, len(c.Definitions))
|
||||
for key, d := range c.Definitions {
|
||||
if d.Params != nil {
|
||||
params := make([]Parameter, len(d.Params))
|
||||
copy(params, d.Params)
|
||||
d.Params = params
|
||||
}
|
||||
out.Definitions[key] = d
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Registry is immutable after construction and may be shared across requests.
|
||||
// It has no process-global state. Every Resolve call returns a fresh error.
|
||||
type Registry struct {
|
||||
catalog Catalog
|
||||
messages map[string][]messagePart
|
||||
}
|
||||
|
||||
func NewRegistry(c Catalog) (*Registry, error) {
|
||||
c = c.Clone()
|
||||
if c.DefaultStatus == 0 {
|
||||
c.DefaultStatus = 400
|
||||
}
|
||||
if !ValidErrorStatus(c.DefaultStatus) {
|
||||
return nil, fmt.Errorf("errors: invalid defaultStatus %d", c.DefaultStatus)
|
||||
}
|
||||
messages := map[string][]messagePart{}
|
||||
for key, d := range c.Definitions {
|
||||
if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || strings.ContainsAny(key, "\r\n\t") {
|
||||
return nil, fmt.Errorf("errors: invalid logical identifier %q", key)
|
||||
}
|
||||
if d.Status == 0 {
|
||||
d.Status = c.DefaultStatus
|
||||
}
|
||||
if !ValidErrorStatus(d.Status) {
|
||||
return nil, fmt.Errorf("errors: %s has invalid status %d", key, d.Status)
|
||||
}
|
||||
if strings.TrimSpace(d.Message) == "" {
|
||||
return nil, fmt.Errorf("errors: %s requires a public message", key)
|
||||
}
|
||||
if d.Code != nil {
|
||||
code, err := normalizeCatalogCode(d.Code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("errors: %s code: %w", key, err)
|
||||
}
|
||||
d.Code = code
|
||||
}
|
||||
parts, err := compileMessage(&d)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("errors: %s: %w", key, err)
|
||||
}
|
||||
messages[key] = parts
|
||||
c.Definitions[key] = d
|
||||
}
|
||||
return &Registry{catalog: c, messages: messages}, nil
|
||||
}
|
||||
|
||||
// ValidErrorStatus permits final responses with a JSON body, including HTTP 200.
|
||||
func ValidErrorStatus(status int) bool {
|
||||
return status >= 200 && status <= 599 && status != 204 && status != 205 && status != 304
|
||||
}
|
||||
|
||||
func MustRegistry(c Catalog) *Registry {
|
||||
r, err := NewRegistry(c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
func (c Catalog) Validate() error { _, err := NewRegistry(c); return err }
|
||||
func (r *Registry) Catalog() Catalog {
|
||||
if r == nil {
|
||||
return Catalog{}
|
||||
}
|
||||
return r.catalog.Clone()
|
||||
}
|
||||
|
||||
// ByCode identifies an error without coupling business code to its HTTP policy.
|
||||
// Resolution occurs in the application's/request's registry. An unknown reference
|
||||
// becomes an internal error; its identifier and cause stay available to logs.
|
||||
func ByCode(key string) *APIError { return &APIError{Code: key, reference: true} }
|
||||
|
||||
func (r *Registry) Resolve(err error) *APIError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var source *APIError
|
||||
if !stdErrors.As(err, &source) || source == nil {
|
||||
return r.internal(err)
|
||||
}
|
||||
if source.paramErr != nil {
|
||||
return r.internal(stdErrors.Join(source.paramErr, err))
|
||||
}
|
||||
if r != nil {
|
||||
if d, ok := r.catalog.Definitions[source.Code]; ok {
|
||||
message, paramErr := r.render(source.Code, d, source.params)
|
||||
if paramErr != nil {
|
||||
return r.internal(stdErrors.Join(paramErr, err))
|
||||
}
|
||||
out := source.Clone()
|
||||
out.Status, out.Code, out.Message, out.BusinessCode = d.Status, source.Code, message, d.Code
|
||||
out.reference = false
|
||||
return out
|
||||
}
|
||||
}
|
||||
if source.reference {
|
||||
return r.internal(err)
|
||||
}
|
||||
return source.Clone()
|
||||
}
|
||||
|
||||
// Internal fallback is deliberately bounded: a misconfigured parameterized
|
||||
// INTERNAL_ERROR cannot recursively fail while reporting a rendering problem.
|
||||
func (r *Registry) internal(cause error) *APIError {
|
||||
out := Internal(cause)
|
||||
if r != nil {
|
||||
if d, ok := r.catalog.Definitions[out.Code]; ok {
|
||||
if message, err := r.render(out.Code, d, nil); err == nil {
|
||||
out.Status, out.Message, out.BusinessCode = d.Status, message, d.Code
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeCatalogCode(value any) (any, error) {
|
||||
if v, ok := value.(string); ok {
|
||||
if v == "" {
|
||||
return nil, fmt.Errorf("must be nonempty")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
if v, ok := value.(json.Number); ok {
|
||||
n, err := v.Int64()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("must be an exact int64")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
v := reflect.ValueOf(value)
|
||||
switch v.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return v.Int(), nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
if v.Uint() <= math.MaxInt64 {
|
||||
return int64(v.Uint()), nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("must be a string or signed 64-bit integer")
|
||||
}
|
||||
100
errors/catalog_test.go
Normal file
100
errors/catalog_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package apierrors_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
)
|
||||
|
||||
func TestCatalogIsolationDefaultsAndCauses(t *testing.T) {
|
||||
catalog := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{
|
||||
"FIRST": {Code: 400002, Message: "first"}, "SECOND": {Code: 400002, Message: "second"}, "DENIED": {Status: 403, Code: "denied", Message: "denied"},
|
||||
}}
|
||||
r, err := apierrors.NewRegistry(catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog.Definitions["FIRST"] = apierrors.Definition{Message: "changed"}
|
||||
r.Catalog().Definitions["FIRST"] = apierrors.Definition{Message: "also changed"}
|
||||
cause := errors.New("private secret")
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 40; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ref := apierrors.ByCode("FIRST").WithCause(cause)
|
||||
e := r.Resolve(fmt.Errorf("wrapped: %w", ref))
|
||||
if e.Status != 200 || e.BusinessCode != int64(400002) || e.Message != "first" || !errors.Is(e, cause) {
|
||||
t.Error(e)
|
||||
}
|
||||
e.WithCause(errors.New("different"))
|
||||
if ref.Cause != cause || r.Resolve(apierrors.ByCode("FIRST")).Cause != nil {
|
||||
t.Error("shared state")
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if got := r.Resolve(apierrors.ByCode("SECOND")); got.Message != "second" || got.BusinessCode != int64(400002) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := r.Resolve(apierrors.ByCode("DENIED")); got.Status != 403 || got.BusinessCode != "denied" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
other := apierrors.MustRegistry(apierrors.Catalog{Definitions: map[string]apierrors.Definition{"FIRST": {Status: 409, Code: 9, Message: "other"}}})
|
||||
if got := other.Resolve(apierrors.ByCode("FIRST")); got.Status != 409 || got.Message != "other" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := r.Resolve(apierrors.ByCode("MISSING").WithCause(cause)); got.Status != 500 || got.Code != "INTERNAL_ERROR" || got.Message == cause.Error() || !errors.Is(got, cause) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := apierrors.From(apierrors.ByCode("MISSING")); got.Status != 500 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
legacy := apierrors.New(422, "OLD", "legacy")
|
||||
if got := r.Resolve(legacy); got == legacy || got.Status != 422 || got.Message != "legacy" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogStrictJSONAndValues(t *testing.T) {
|
||||
raw := `{"defaultStatus":200,"definitions":{"LARGE":{"code":9223372036854775807,"message":"large"}}}`
|
||||
var c apierrors.Catalog
|
||||
if err := json.Unmarshal([]byte(raw), &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := apierrors.MustRegistry(c).Resolve(apierrors.ByCode("LARGE"))
|
||||
if e.BusinessCode != int64(math.MaxInt64) {
|
||||
t.Fatal(e.BusinessCode)
|
||||
}
|
||||
for _, raw := range []string{
|
||||
`{"definitions":{"A":{"code":1,"message":"a"},"A":{"code":2,"message":"b"}}}`,
|
||||
`{"definitions":{"A":{"code":1,"code":2,"message":"a"}}}`,
|
||||
`{"definitions":{"A":{"code":1,"message":"a","typo":true}}}`,
|
||||
`{"defaultStatus":204,"definitions":{}}`,
|
||||
`{"definitions":{"A":{"code":1.5,"message":"a"}}}`,
|
||||
`{"definitions":{"A":{"code":9223372036854775808,"message":"a"}}}`,
|
||||
} {
|
||||
var invalid apierrors.Catalog
|
||||
err := json.Unmarshal([]byte(raw), &invalid)
|
||||
if err == nil {
|
||||
err = invalid.Validate()
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("accepted %s", raw)
|
||||
}
|
||||
}
|
||||
for _, status := range []int{0, 99, 199, 204, 205, 304, 600} {
|
||||
c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{"A": {Status: status, Code: "a", Message: "a"}}}
|
||||
if status == 0 {
|
||||
continue
|
||||
}
|
||||
if c.Validate() == nil {
|
||||
t.Fatal(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,17 @@ import (
|
||||
)
|
||||
|
||||
// APIError is the public error shape used by handlers and middleware.
|
||||
// Cause is retained for logging and unwrapping, but is never serialized.
|
||||
// Cause is retained for logging and unwrapping; default response encoders and
|
||||
// replay snapshots never serialize it.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
Cause error
|
||||
BusinessCode any // Optional public code resolved from a project catalog.
|
||||
reference bool
|
||||
params map[string]any
|
||||
paramErr error
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
@@ -68,6 +73,9 @@ func From(err error) *APIError {
|
||||
}
|
||||
var apiErr *APIError
|
||||
if stdErrors.As(err, &apiErr) && apiErr != nil {
|
||||
if apiErr.reference {
|
||||
return Internal(err)
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
return Internal(err)
|
||||
|
||||
272
errors/params.go
Normal file
272
errors/params.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package apierrors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/token"
|
||||
"math"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Parameter is ordered: declaration order is the generated function's argument
|
||||
// order. Example is documentation-only and never substitutes a missing value.
|
||||
type Parameter struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Example any `json:"example,omitempty"`
|
||||
}
|
||||
|
||||
type messagePart struct{ text, param string }
|
||||
|
||||
var parameterName = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`)
|
||||
|
||||
// ParamError describes a programming/configuration error without including the
|
||||
// supplied value. It remains available through errors.As on the internal cause.
|
||||
type ParamError struct{ Code, Param, Problem string }
|
||||
|
||||
func (e *ParamError) Error() string {
|
||||
return fmt.Sprintf("errors: %s parameter %q: %s", e.Code, e.Param, e.Problem)
|
||||
}
|
||||
|
||||
// WithParams returns a detached error. It never retains the caller's map or any
|
||||
// mutable parameter value. Catalog-specific validation happens during Resolve.
|
||||
func (e *APIError) WithParams(values map[string]any) *APIError {
|
||||
out := e.Clone()
|
||||
out.params = make(map[string]any, len(values))
|
||||
out.paramErr = nil
|
||||
for name, value := range values {
|
||||
scalar, err := copyScalar(value)
|
||||
if err != nil {
|
||||
out.paramErr = &ParamError{Code: out.Code, Param: name, Problem: "expected a scalar value"}
|
||||
continue
|
||||
}
|
||||
out.params[name] = scalar
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Clone detaches request-specific parameters; Cause remains an error chain.
|
||||
func (e *APIError) Clone() *APIError {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
out := *e
|
||||
out.params = make(map[string]any, len(e.params))
|
||||
for k, v := range e.params {
|
||||
out.params[k] = v
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
func copyScalar(v any) (any, error) {
|
||||
if n, ok := v.(json.Number); ok {
|
||||
return n, nil
|
||||
}
|
||||
if v == nil {
|
||||
return nil, fmt.Errorf("nil scalar")
|
||||
}
|
||||
r := reflect.ValueOf(v)
|
||||
switch r.Kind() {
|
||||
case reflect.String:
|
||||
return r.String(), nil
|
||||
case reflect.Bool:
|
||||
return r.Bool(), nil
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return r.Int(), nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return r.Uint(), nil
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return r.Float(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported scalar")
|
||||
}
|
||||
func paramValue(kind string, v any) (any, error) {
|
||||
value, err := copyScalar(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch kind {
|
||||
case "string":
|
||||
if s, ok := value.(string); ok {
|
||||
return s, nil
|
||||
}
|
||||
case "bool":
|
||||
if b, ok := value.(bool); ok {
|
||||
return b, nil
|
||||
}
|
||||
case "int", "int32", "int64":
|
||||
var n int64
|
||||
switch x := value.(type) {
|
||||
case int64:
|
||||
n = x
|
||||
case uint64:
|
||||
if x > math.MaxInt64 {
|
||||
return nil, fmt.Errorf("integer overflow")
|
||||
}
|
||||
n = int64(x)
|
||||
case json.Number:
|
||||
n, err = x.Int64()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expected exact integer")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("expected %s", kind)
|
||||
}
|
||||
if (kind == "int32" || kind == "int" && strconv.IntSize == 32) && (n < math.MinInt32 || n > math.MaxInt32) {
|
||||
return nil, fmt.Errorf("%s overflow", kind)
|
||||
}
|
||||
return n, nil
|
||||
case "float64":
|
||||
var n float64
|
||||
switch x := value.(type) {
|
||||
case float64:
|
||||
n = x
|
||||
case json.Number:
|
||||
n, err = x.Float64()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expected finite float64")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("expected float64")
|
||||
}
|
||||
if math.IsNaN(n) || math.IsInf(n, 0) {
|
||||
return nil, fmt.Errorf("expected finite float64")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
return nil, fmt.Errorf("expected %s", kind)
|
||||
}
|
||||
|
||||
func compileMessage(d *Definition) ([]messagePart, error) {
|
||||
if d.Params == nil {
|
||||
return []messagePart{{text: d.Message}}, nil
|
||||
}
|
||||
if len(d.Params) == 0 {
|
||||
return nil, fmt.Errorf("params must be nonempty; omit params for a fixed message")
|
||||
}
|
||||
declared := map[string]bool{}
|
||||
for i, p := range d.Params {
|
||||
if !parameterName.MatchString(p.Name) || token.Lookup(p.Name).IsKeyword() || declared[p.Name] {
|
||||
return nil, fmt.Errorf("invalid or duplicate parameter %q", p.Name)
|
||||
}
|
||||
switch p.Type {
|
||||
case "string", "bool", "int", "int32", "int64", "float64":
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported parameter type %q", p.Type)
|
||||
}
|
||||
declared[p.Name] = true
|
||||
if p.Example != nil {
|
||||
example, err := paramValue(p.Type, p.Example)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("example for %s: %w", p.Name, err)
|
||||
}
|
||||
d.Params[i].Example = example
|
||||
}
|
||||
}
|
||||
var parts []messagePart
|
||||
var literal strings.Builder
|
||||
used := map[string]bool{}
|
||||
flush := func() {
|
||||
if literal.Len() > 0 {
|
||||
parts = append(parts, messagePart{text: literal.String()})
|
||||
literal.Reset()
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(d.Message); {
|
||||
ch := d.Message[i]
|
||||
if ch != '{' && ch != '}' {
|
||||
literal.WriteByte(ch)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if i+1 < len(d.Message) && d.Message[i+1] == ch {
|
||||
literal.WriteByte(ch)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if ch == '}' {
|
||||
return nil, fmt.Errorf("unescaped closing brace")
|
||||
}
|
||||
end := strings.IndexByte(d.Message[i+1:], '}')
|
||||
if end < 0 {
|
||||
return nil, fmt.Errorf("unclosed placeholder")
|
||||
}
|
||||
name := d.Message[i+1 : i+1+end]
|
||||
if !declared[name] {
|
||||
return nil, fmt.Errorf("undeclared placeholder %q", name)
|
||||
}
|
||||
flush()
|
||||
parts = append(parts, messagePart{param: name})
|
||||
used[name] = true
|
||||
i += end + 2
|
||||
}
|
||||
flush()
|
||||
for _, p := range d.Params {
|
||||
if !used[p.Name] {
|
||||
return nil, fmt.Errorf("unused parameter %q", p.Name)
|
||||
}
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func (r *Registry) render(key string, d Definition, params map[string]any) (string, error) {
|
||||
values := make(map[string]any, len(d.Params))
|
||||
for _, p := range d.Params {
|
||||
v, ok := params[p.Name]
|
||||
if !ok {
|
||||
return "", &ParamError{key, p.Name, "missing value"}
|
||||
}
|
||||
normalized, err := paramValue(p.Type, v)
|
||||
if err != nil {
|
||||
return "", &ParamError{key, p.Name, err.Error()}
|
||||
}
|
||||
values[p.Name] = normalized
|
||||
}
|
||||
for name := range params {
|
||||
if _, ok := values[name]; !ok {
|
||||
return "", &ParamError{key, name, "not declared"}
|
||||
}
|
||||
}
|
||||
var result strings.Builder
|
||||
for _, part := range r.messages[key] {
|
||||
if part.param != "" {
|
||||
fmt.Fprint(&result, values[part.param])
|
||||
} else {
|
||||
result.WriteString(part.text)
|
||||
}
|
||||
}
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
// Example resolves documentation values using the same path as runtime errors.
|
||||
// Missing examples receive deterministic samples, never runtime defaults.
|
||||
func (r *Registry) Example(key string) *APIError {
|
||||
if r == nil {
|
||||
return r.Resolve(ByCode(key))
|
||||
}
|
||||
d, ok := r.catalog.Definitions[key]
|
||||
if !ok {
|
||||
return r.Resolve(ByCode(key))
|
||||
}
|
||||
values := map[string]any{}
|
||||
for _, p := range d.Params {
|
||||
v := p.Example
|
||||
if v == nil {
|
||||
switch p.Type {
|
||||
case "string":
|
||||
v = p.Name
|
||||
case "bool":
|
||||
v = false
|
||||
case "float64":
|
||||
v = float64(1)
|
||||
default:
|
||||
v = int64(1)
|
||||
}
|
||||
}
|
||||
values[p.Name] = v
|
||||
}
|
||||
return r.Resolve(ByCode(key).WithParams(values))
|
||||
}
|
||||
160
errors/params_test.go
Normal file
160
errors/params_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package apierrors_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
)
|
||||
|
||||
func templateCatalog() apierrors.Catalog {
|
||||
return apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{
|
||||
"TEXT_TOO_LONG": {Code: 400002, Message: "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string", Example: "企业名称"}, {Name: "max", Type: "int", Example: 100}}},
|
||||
"FIXED": {Code: 400003, Message: "literal {unchanged}"},
|
||||
}}
|
||||
}
|
||||
func TestMessageParamsIsolationAndResolution(t *testing.T) {
|
||||
catalog := templateCatalog()
|
||||
r := apierrors.MustRegistry(catalog)
|
||||
catalog.Definitions["TEXT_TOO_LONG"].Params[0].Type = "bool"
|
||||
r.Catalog().Definitions["TEXT_TOO_LONG"].Params[0].Name = "changed"
|
||||
private := errors.New("private cause")
|
||||
values := map[string]any{"label": "企业名称", "max": 100}
|
||||
reference := apierrors.ByCode("TEXT_TOO_LONG").WithParams(values).WithCause(private)
|
||||
values["label"] = "changed"
|
||||
first := r.Resolve(fmt.Errorf("wrapped: %w", reference))
|
||||
for n := 0; n < 3; n++ {
|
||||
first = r.Resolve(first)
|
||||
if first.Message != "企业名称不能超过100个字符" || first.Status != 200 || first.BusinessCode != int64(400002) || !errors.Is(first, private) {
|
||||
t.Fatal(first)
|
||||
}
|
||||
}
|
||||
second := r.Resolve(first.WithParams(map[string]any{"label": "备注", "max": 500}))
|
||||
if first.Message != "企业名称不能超过100个字符" || second.Message != "备注不能超过500个字符" {
|
||||
t.Fatal(first, second)
|
||||
}
|
||||
if r.Example("TEXT_TOO_LONG").Message != first.Message {
|
||||
t.Fatal("example and runtime differ")
|
||||
}
|
||||
if r.Resolve(apierrors.ByCode("FIXED")).Message != "literal {unchanged}" {
|
||||
t.Fatal("legacy braces changed")
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for n := 0; n < 80; n++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
label, max := "企业名称", 100
|
||||
if n%2 == 1 {
|
||||
label, max = "备注", 500
|
||||
}
|
||||
got := r.Resolve(apierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": label, "max": max}).WithCause(private))
|
||||
if got.Message != fmt.Sprintf("%s不能超过%d个字符", label, max) || !errors.Is(got, private) {
|
||||
t.Error(got)
|
||||
}
|
||||
}(n)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
func TestInvalidTemplateParamsFailClosed(t *testing.T) {
|
||||
r := apierrors.MustRegistry(templateCatalog())
|
||||
cause := errors.New("private")
|
||||
for _, values := range []map[string]any{
|
||||
nil, {"label": "x"}, {"label": "x", "max": "100"}, {"label": 100, "max": 100},
|
||||
{"label": "x", "max": 100.0}, {"label": "x", "max": 100, "extra": "private-value"},
|
||||
{"label": "x", "max": nil}, {"label": map[string]any{"secret": "private-value"}, "max": 100},
|
||||
} {
|
||||
result := r.Resolve(apierrors.ByCode("TEXT_TOO_LONG").WithParams(values).WithCause(cause))
|
||||
var diagnostic *apierrors.ParamError
|
||||
if result.Code != "INTERNAL_ERROR" || result.Status != 500 || strings.Contains(result.Message, "{") || !errors.As(result, &diagnostic) || !errors.Is(result, cause) {
|
||||
t.Fatal(result)
|
||||
}
|
||||
if strings.Contains(diagnostic.Error(), "private-value") {
|
||||
t.Fatal("diagnostic leaked value")
|
||||
}
|
||||
}
|
||||
// A parameterized fallback cannot recurse or expose its template.
|
||||
c := templateCatalog()
|
||||
c.Definitions["INTERNAL_ERROR"] = c.Definitions["TEXT_TOO_LONG"]
|
||||
if result := apierrors.MustRegistry(c).Resolve(errors.New("private")); result.Message != "internal server error" || result.Status != 500 {
|
||||
t.Fatal(result)
|
||||
}
|
||||
for _, change := range []func(*apierrors.Definition){
|
||||
func(d *apierrors.Definition) { d.Message = "{label}{missing}" },
|
||||
func(d *apierrors.Definition) { d.Message = "{label}" },
|
||||
func(d *apierrors.Definition) { d.Message = "{label}{max" },
|
||||
func(d *apierrors.Definition) { d.Message = "{label}{max}}" },
|
||||
func(d *apierrors.Definition) { d.Params[1].Name = "label" },
|
||||
func(d *apierrors.Definition) { d.Params[1].Name = "type" },
|
||||
func(d *apierrors.Definition) { d.Params[1].Type = "interface{}" },
|
||||
func(d *apierrors.Definition) { d.Params[1].Example = "100" },
|
||||
func(d *apierrors.Definition) { d.Params = []apierrors.Parameter{} },
|
||||
} {
|
||||
c := templateCatalog()
|
||||
d := c.Definitions["TEXT_TOO_LONG"]
|
||||
change(&d)
|
||||
c.Definitions["TEXT_TOO_LONG"] = d
|
||||
if _, err := apierrors.NewRegistry(c); err == nil {
|
||||
t.Fatal("accepted invalid template", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestParamTypesEscapingAndSnapshot(t *testing.T) {
|
||||
c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{"T": {Code: "t", Message: "{{literal}} {value} {value}", Params: []apierrors.Parameter{{Name: "value", Type: "string"}}}}}
|
||||
r := apierrors.MustRegistry(c)
|
||||
got := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": "{other}"}))
|
||||
if got.Message != "{literal} {other} {other}" {
|
||||
t.Fatal("recursive substitution", got)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
kind string
|
||||
good, bad any
|
||||
want string
|
||||
}{
|
||||
{"int32", int64(math.MaxInt32), int64(math.MaxInt32) + 1, "2147483647"},
|
||||
{"int64", json.Number("9223372036854775807"), json.Number("9223372036854775808"), "9223372036854775807"},
|
||||
{"bool", true, "true", "true"}, {"float64", float64(1.25), math.Inf(1), "1.25"},
|
||||
} {
|
||||
d := c.Definitions["T"]
|
||||
d.Params = []apierrors.Parameter{{Name: "value", Type: test.kind}}
|
||||
c.Definitions["T"] = d
|
||||
r = apierrors.MustRegistry(c)
|
||||
if x := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": test.good})); x.Message != "{literal} "+test.want+" "+test.want {
|
||||
t.Fatal(test.kind, x)
|
||||
}
|
||||
if x := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": test.bad})); x.Status != 500 {
|
||||
t.Fatal(test.kind, x)
|
||||
}
|
||||
}
|
||||
original := apierrors.New(200, "T", "complete").WithCause(errors.New("private"))
|
||||
original.BusinessCode = int64(math.MaxInt64)
|
||||
raw, err := json.Marshal(original.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "private") || strings.Contains(string(raw), "params") {
|
||||
t.Fatal(string(raw))
|
||||
}
|
||||
// Simulate a store rewriting its envelope with ordinary JSON numbers.
|
||||
var storage map[string]any
|
||||
if err = json.Unmarshal(raw, &storage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err = json.Marshal(storage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snapshot apierrors.Snapshot
|
||||
if err = json.Unmarshal(raw, &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored := snapshot.ErrorValue()
|
||||
if restored.Cause != nil || restored.Message != "complete" || restored.BusinessCode != int64(math.MaxInt64) {
|
||||
t.Fatal(restored)
|
||||
}
|
||||
}
|
||||
71
errors/snapshot.go
Normal file
71
errors/snapshot.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package apierrors
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Snapshot contains only the resolved public error, suitable for replay
|
||||
// metadata. Parameters and internal causes are deliberately excluded.
|
||||
type Snapshot struct {
|
||||
Status int `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
BusinessCode any `json:"businessCode,omitempty"`
|
||||
}
|
||||
|
||||
// Encode the public code as opaque JSON text so shared stores (including Redis
|
||||
// Lua/cjson) cannot round an int64 while rewriting their record envelopes.
|
||||
func (s Snapshot) MarshalJSON() ([]byte, error) {
|
||||
var encoded string
|
||||
if s.BusinessCode != nil {
|
||||
code, err := normalizeCatalogCode(s.BusinessCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := json.Marshal(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded = string(raw)
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
Status int `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
BusinessCodeJSON string `json:"businessCodeJson,omitempty"`
|
||||
}{s.Status, s.Code, s.Message, encoded})
|
||||
}
|
||||
func (s *Snapshot) UnmarshalJSON(data []byte) error {
|
||||
type plain Snapshot
|
||||
var out struct {
|
||||
plain
|
||||
BusinessCodeJSON string `json:"businessCodeJson,omitempty"`
|
||||
}
|
||||
if err := DecodeCatalogJSON(data, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if out.BusinessCodeJSON != "" {
|
||||
if err := DecodeCatalogJSON([]byte(out.BusinessCodeJSON), &out.BusinessCode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if out.BusinessCode != nil {
|
||||
code, err := normalizeCatalogCode(out.BusinessCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.BusinessCode = code
|
||||
}
|
||||
*s = Snapshot(out.plain)
|
||||
return nil
|
||||
}
|
||||
func (e *APIError) Snapshot() *Snapshot {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return &Snapshot{Status: e.Status, Code: e.Code, Message: e.Message, BusinessCode: e.BusinessCode}
|
||||
}
|
||||
func (s *Snapshot) ErrorValue() *APIError {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return &APIError{Status: s.Status, Code: s.Code, Message: s.Message, BusinessCode: s.BusinessCode}
|
||||
}
|
||||
41
examples/contracts/errorInheritance.api
Normal file
41
examples/contracts/errorInheritance.api
Normal file
@@ -0,0 +1,41 @@
|
||||
syntax = "v1"
|
||||
|
||||
// Errors are registered once in Go with omni.WithErrors.
|
||||
response CommonResponse (
|
||||
codeType: int32
|
||||
successCode: 0
|
||||
errorCode: 9999
|
||||
body: {"code": "${code}", "msg": "${message}", "data": "${data}"}
|
||||
dataMode: always
|
||||
)
|
||||
|
||||
type CheckRequest {
|
||||
Kind string `form:"kind,optional"`
|
||||
}
|
||||
type CheckResponse {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
@server (
|
||||
group: account
|
||||
response: CommonResponse
|
||||
)
|
||||
service inherited-errors-api {
|
||||
@handler plain
|
||||
get /plain (CheckRequest) returns (CheckResponse)
|
||||
|
||||
@response raw
|
||||
@handler raw
|
||||
get /raw (CheckRequest) returns (CheckResponse)
|
||||
|
||||
@errors none
|
||||
@handler cleared
|
||||
get /cleared (CheckRequest) returns (CheckResponse)
|
||||
|
||||
@handlerMode manual
|
||||
@body (mode: raw
|
||||
maxBytes: 64)
|
||||
@produces "text/plain"
|
||||
@handler bounded
|
||||
post /bounded
|
||||
}
|
||||
48
examples/contracts/errorParams.api
Normal file
48
examples/contracts/errorParams.api
Normal file
@@ -0,0 +1,48 @@
|
||||
syntax = "v1"
|
||||
|
||||
errorCatalog CommonErrors (
|
||||
defaultStatus: 200
|
||||
errors: {
|
||||
"TextTooLong": {
|
||||
"key": "TEXT_TOO_LONG",
|
||||
"code": 400002,
|
||||
"message": "{label}不能超过{max}个字符",
|
||||
"params": [
|
||||
{"name": "label", "type": "string", "example": "企业名称"},
|
||||
{"name": "max", "type": "int", "example": 100}
|
||||
]
|
||||
},
|
||||
"UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400003, "message": "用户名格式不正确"},
|
||||
"ResourceDenied": {
|
||||
"key": "RESOURCE_DENIED", "status": 403, "code": 403001,
|
||||
"message": "{label}不可用",
|
||||
"params": [{"name": "label", "type": "string", "example": "资源"}]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
response CommonResponse (
|
||||
codeType: int32
|
||||
successCode: 0
|
||||
errorCode: 9999
|
||||
body: {"code": "${code}", "msg": "${message}", "data": "${data}"}
|
||||
dataMode: always
|
||||
)
|
||||
|
||||
type TokenResponse {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
@server (
|
||||
group: account
|
||||
response: CommonResponse
|
||||
errors: TextTooLong, UsernameInvalid, ResourceDenied
|
||||
)
|
||||
service error-params-api {
|
||||
@handler token
|
||||
post /token returns (TokenResponse)
|
||||
|
||||
@response raw
|
||||
@handler raw
|
||||
get /raw returns (TokenResponse)
|
||||
}
|
||||
36
examples/contracts/errors.api
Normal file
36
examples/contracts/errors.api
Normal file
@@ -0,0 +1,36 @@
|
||||
syntax = "v1"
|
||||
|
||||
errorCatalog CommonErrors (
|
||||
defaultStatus: 200
|
||||
errors: {
|
||||
"UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400002, "message": "用户名格式不正确"},
|
||||
"PasswordInvalid": {"key": "PASSWORD_INVALID", "code": 400002, "message": "密码格式不正确"},
|
||||
"PermissionDenied": {"key": "PERMISSION_DENIED", "status": 403, "code": 403001, "message": "没有权限"}
|
||||
}
|
||||
)
|
||||
|
||||
response CommonResponse (
|
||||
codeType: int32
|
||||
successCode: 0
|
||||
errorCode: 9999
|
||||
body: {"ok": "${ok}", "code": "${code}", "data": "${data}"}
|
||||
errorBody: {"ok": "${ok}", "code": "${code}", "msg": "${message}"}
|
||||
)
|
||||
|
||||
type TokenResponse {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
@server (
|
||||
group: account
|
||||
response: CommonResponse
|
||||
errors: UsernameInvalid, PasswordInvalid, PermissionDenied
|
||||
)
|
||||
service errors-api {
|
||||
@handler token
|
||||
post /token returns (TokenResponse)
|
||||
|
||||
@response raw
|
||||
@handler raw
|
||||
get /raw returns (TokenResponse)
|
||||
}
|
||||
57
examples/contracts/managed.api
Normal file
57
examples/contracts/managed.api
Normal file
@@ -0,0 +1,57 @@
|
||||
syntax = "v1"
|
||||
|
||||
// Error definitions are intentionally owned by Go, not duplicated here.
|
||||
@app (
|
||||
defaultResponse: CommonResponse
|
||||
)
|
||||
response CommonResponse (
|
||||
codeType: int32
|
||||
successCode: 0
|
||||
errorCode: 9999
|
||||
messageField: msg
|
||||
dataMode: always
|
||||
requestIdField: "-"
|
||||
)
|
||||
response AlternateResponse (
|
||||
codeType: int32
|
||||
successCode: 0
|
||||
errorCode: 9999
|
||||
messageField: detail
|
||||
dataMode: always
|
||||
requestIdField: "-"
|
||||
)
|
||||
type CheckReq {
|
||||
Kind string `form:"kind,optional"`
|
||||
}
|
||||
type Receipt {
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
@server (group: demo)
|
||||
service managed-api {
|
||||
@handler check
|
||||
get /check (CheckReq) returns (Receipt)
|
||||
|
||||
@handler other
|
||||
@response AlternateResponse
|
||||
get /other (CheckReq) returns (Receipt)
|
||||
|
||||
@handler rawJSON
|
||||
@response raw
|
||||
get /raw returns (Receipt)
|
||||
|
||||
@handler upload
|
||||
@body (mode: multipart; maxBytes: 2048)
|
||||
post /upload returns (Receipt)
|
||||
|
||||
@handler callback
|
||||
@body (mode: raw; maxBytes: 64)
|
||||
@produces "text/plain"
|
||||
post /callback
|
||||
|
||||
@handler download
|
||||
@produces "application/octet-stream"
|
||||
get /download
|
||||
|
||||
@handler slow
|
||||
get /slow returns (Receipt)
|
||||
}
|
||||
8
examples/managedService/.gitignore
vendored
Normal file
8
examples/managedService/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/server
|
||||
/var/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/.goOmni/api.lock
|
||||
|
||||
/managed
|
||||
161
examples/managedService/.goOmni/apiManifest.json
Normal file
161
examples/managedService/.goOmni/apiManifest.json
Normal file
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"profile": {
|
||||
"database": "none",
|
||||
"storage": "none",
|
||||
"logOutput": "stderr",
|
||||
"logFormat": "json"
|
||||
},
|
||||
"version": 2,
|
||||
"module": "example.com/managed",
|
||||
"entry": "api/managed.api",
|
||||
"generated": {
|
||||
"docs/openapi.json": "9eb707aefeefd3f4881c774534e2d143949c0edb1d8a4aabd762f978f6203df1",
|
||||
"internal/handler/appGen.go": "c6f078932e1a598a46faebba397f4e753db9d8f4f29a02f9b6077a0557d6c14e",
|
||||
"internal/handler/demo/handlersGen.go": "7a6f9d5e105e44b07196cc540cee3f0562d5f3d1c5f0bc094434db1064d0339c",
|
||||
"internal/handler/openapiGen.go": "612920ed25e5dc7a4f673be56041d47b70aa3216707ff282e52dfe503c2d705a",
|
||||
"internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399",
|
||||
"internal/handler/routesGen.go": "76c1a5b2644e75e56553dc6e9e862a63128d902dd94807ce7c8aee5491cd1e3e",
|
||||
"internal/middleware/registryGen.go": "e5f47efa57a1432765ef3419bae59dbf3b4d55b3aac0a6c935ce7a52ba6b47f4",
|
||||
"internal/types/typesGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
|
||||
"main.go": "a12db5b7df0f2fdca4939a7382daee04c85ddb63ef0bccd1a2ce26b718f42579"
|
||||
},
|
||||
"contract": {
|
||||
"routes": {
|
||||
"demo/callback": {
|
||||
"method": "POST",
|
||||
"path": "/callback",
|
||||
"group": "demo",
|
||||
"handler": "callback",
|
||||
"signature": "Callback(context.Context, *httpx.Body) (*httpx.Stream, error)",
|
||||
"bodyMode": "raw",
|
||||
"produces": "text/plain",
|
||||
"maxBodyBytes": 64
|
||||
},
|
||||
"demo/check": {
|
||||
"method": "GET",
|
||||
"path": "/check",
|
||||
"group": "demo",
|
||||
"handler": "check",
|
||||
"signature": "Check(context.Context, *types.CheckReq) (*types.Receipt, error)",
|
||||
"request": "CheckReq",
|
||||
"response": "Receipt",
|
||||
"types": {
|
||||
"CheckReq": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "Kind",
|
||||
"type": "string",
|
||||
"tag": "form:\"kind,optional\""
|
||||
}
|
||||
]
|
||||
},
|
||||
"Receipt": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "Bytes",
|
||||
"type": "int64",
|
||||
"tag": "json:\"bytes\""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"demo/download": {
|
||||
"method": "GET",
|
||||
"path": "/download",
|
||||
"group": "demo",
|
||||
"handler": "download",
|
||||
"signature": "Download(context.Context) (*httpx.Stream, error)",
|
||||
"produces": "application/octet-stream"
|
||||
},
|
||||
"demo/other": {
|
||||
"method": "GET",
|
||||
"path": "/other",
|
||||
"group": "demo",
|
||||
"handler": "other",
|
||||
"signature": "Other(context.Context, *types.CheckReq) (*types.Receipt, error)",
|
||||
"request": "CheckReq",
|
||||
"response": "Receipt",
|
||||
"types": {
|
||||
"CheckReq": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "Kind",
|
||||
"type": "string",
|
||||
"tag": "form:\"kind,optional\""
|
||||
}
|
||||
]
|
||||
},
|
||||
"Receipt": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "Bytes",
|
||||
"type": "int64",
|
||||
"tag": "json:\"bytes\""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"demo/rawJSON": {
|
||||
"method": "GET",
|
||||
"path": "/raw",
|
||||
"group": "demo",
|
||||
"handler": "rawJSON",
|
||||
"signature": "RawJSON(context.Context) (*types.Receipt, error)",
|
||||
"response": "Receipt",
|
||||
"types": {
|
||||
"Receipt": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "Bytes",
|
||||
"type": "int64",
|
||||
"tag": "json:\"bytes\""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"demo/slow": {
|
||||
"method": "GET",
|
||||
"path": "/slow",
|
||||
"group": "demo",
|
||||
"handler": "slow",
|
||||
"signature": "Slow(context.Context) (*types.Receipt, error)",
|
||||
"response": "Receipt",
|
||||
"types": {
|
||||
"Receipt": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "Bytes",
|
||||
"type": "int64",
|
||||
"tag": "json:\"bytes\""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"demo/upload": {
|
||||
"method": "POST",
|
||||
"path": "/upload",
|
||||
"group": "demo",
|
||||
"handler": "upload",
|
||||
"signature": "Upload(context.Context, *httpx.Body) (*types.Receipt, error)",
|
||||
"response": "Receipt",
|
||||
"bodyMode": "multipart",
|
||||
"maxBodyBytes": 2048,
|
||||
"types": {
|
||||
"Receipt": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "Bytes",
|
||||
"type": "int64",
|
||||
"tag": "json:\"bytes\""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
182
examples/managedService/README.md
Normal file
182
examples/managedService/README.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# example.com/managed
|
||||
|
||||
This checked-in example uses a relative local framework replacement (`../..`).
|
||||
Run `go mod tidy && go run .` here. No external database or cloud account is required.
|
||||
The Go-only error catalog is `internal/apierrors/catalog.go`, registered by svc.
|
||||
The generated main and handlers are unmodified. Built-in endpoint switches and
|
||||
paths are configured in etc/config.yaml under server.endpoints. Health replies
|
||||
use the project envelope by default; responseMode: raw opts out. Readiness
|
||||
failures stay HTTP 503, independently of business error status policies.
|
||||
|
||||
Dependencies can call svc.RequestShutdown(err); the generated entry drains
|
||||
requests, closes resources and retains the failure for a nonzero exit.
|
||||
Try:
|
||||
|
||||
```sh
|
||||
curl -i 'http://127.0.0.1:8080/check?kind=fixed'
|
||||
curl -i 'http://127.0.0.1:8080/check?kind=params'
|
||||
curl -i 'http://127.0.0.1:8080/missing'
|
||||
curl -i -H 'X-Signature: valid' --data-binary ' {"hello":1} ' http://127.0.0.1:8080/callback
|
||||
curl -i -F 'file=hello' http://127.0.0.1:8080/upload
|
||||
curl -OJ http://127.0.0.1:8080/download
|
||||
```
|
||||
|
||||
The upload demonstration caps requests at 2048 bytes, so use a small test file;
|
||||
large input is intentionally rejected with HTTP 413. Replace the demonstration
|
||||
`X-Signature` check with the actual provider's signature algorithm and secret.
|
||||
From the framework root, check generated files with:
|
||||
`go run ./cmd/go-omni api generate --dir examples/managedService --check`.
|
||||
|
||||
Contract changes produce REVIEW notices for affected Logic methods and DTO fields.
|
||||
Definite Logic signature problems produce ERROR diagnostics with a file/line and
|
||||
the required signature. Regeneration preserves business code; `--check` continues
|
||||
to fail on unresolved signature errors after generation. Run `go test ./...` too,
|
||||
especially for project type aliases, build tags and changes inside method bodies.
|
||||
|
||||
|
||||
This project is generated from an editable API contract. Business code uses
|
||||
standard context.Context; generated HTTP handlers use Omni's transport-neutral httpx.
|
||||
main.go and HTTP composition are continuously generator-owned.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
go mod tidy
|
||||
go run .
|
||||
```
|
||||
|
||||
GET /health/live and /health/ready check the running service. GET /openapi.json
|
||||
serves the same document as docs/openapi.json. Bind address defaults to localhost.
|
||||
Set GO_OMNI_TRANSPORT=native to select the native transport without changing services.
|
||||
The greeting starter works immediately; other new operation stubs return 501 until
|
||||
their business logic is implemented. Authentication hooks also fail closed with 501.
|
||||
|
||||
## Develop
|
||||
|
||||
1. Edit api/managed.api and its relative imports.
|
||||
2. Run the same go-omni CLI version as the framework dependency:
|
||||
|
||||
```sh
|
||||
go-omni api validate
|
||||
go-omni api inspect
|
||||
go-omni api generate --dry-run
|
||||
go-omni api generate
|
||||
go-omni api generate --check
|
||||
go-omni api export > openapi.json
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Commands inside a generated project discover the recorded API entry automatically.
|
||||
--dry-run previews create/update/remove actions without writing files.
|
||||
The --check command is read-only and suitable for CI. It fails on stale or missing
|
||||
generated files and missing business scaffolds. go.mod, configuration, Logic,
|
||||
Repository, svc, middleware hook bodies and API files are created once and then
|
||||
preserved byte for byte. main.go, Gen.go files and OpenAPI are owned
|
||||
by the generator; manual edits cause a conflict instead of being overwritten.
|
||||
Commit .goOmni/apiManifest.json alongside the generated files. Do not edit it.
|
||||
Avoid editing files while generation is running; api.lock serializes generators.
|
||||
|
||||
Routes and types that disappear are removed from generated output. Handwritten
|
||||
business files remain: remove/adapt obsolete implementations explicitly if an
|
||||
API change makes their old types or signatures fail to compile. Every regeneration
|
||||
must be followed by go test ./... . Do not delete service files to change contracts.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
api/ # editable contract and imported types
|
||||
main.go # GENERATED startup, lifecycle and route registration
|
||||
etc/config.yaml # lowerCamelCase configuration
|
||||
internal/config/ # defaults -> file -> environment
|
||||
internal/svc/ # dependency initialization and cleanup
|
||||
internal/types/typesGen.go # generated request/response types
|
||||
internal/apierrors/errorsGen.go # generated fresh error constructors, when declared
|
||||
internal/handler/<group>/ # generated binding and Logic adapters
|
||||
internal/logic/<group>/ # developer-owned business operations
|
||||
internal/repository/<group>/ # developer-owned persistence contracts
|
||||
internal/middleware/ # named hooks and generated registry
|
||||
docs/openapi.json # generated OpenAPI 3.0.3
|
||||
.goOmni/apiManifest.json # ownership and content hashes
|
||||
```
|
||||
|
||||
The API response type describes business data. Declare response Name (...) and
|
||||
select the App default with @app(defaultResponse: Name) in the entry API.
|
||||
@server(response: Name) and @response Name override it for groups/routes;
|
||||
@response raw returns the business JSON directly. Without a template, the default
|
||||
envelope includes code, message, data and requestId. Generated routes and OpenAPI
|
||||
use the same contract. 404/405 and ordinary App routes use the declared App default.
|
||||
No template is selected by declaration/import order. Without @app, existing framework
|
||||
response defaults apply. An explicit API App default takes precedence over response
|
||||
options in svc, keeping generated routes and OpenAPI consistent.
|
||||
Handlers reject nil successful responses. Body DTOs use application/json and have a 1 MiB limit by default.
|
||||
Use @body(maxBytes: ...) for route limits. @body(mode: raw|multipart; maxBytes: ...)
|
||||
adds a *httpx.Body argument to Logic, without handwritten handlers. Raw is the exact
|
||||
limited request stream; Parts supports sequential multipart reading with no temp files.
|
||||
@produces "application/octet-stream" (or another concrete media type) generates a
|
||||
Logic result of *httpx.Stream; the handler closes Content and streams it unwrapped.
|
||||
Signature verification and storage policy belong to Logic/middleware. A consumed
|
||||
request stream cannot be reread automatically; use one reader owner or explicitly
|
||||
bounded buffering. Stream reads respect configured server timeouts. Multipart parts
|
||||
are not DTO form fields: form tags remain URL query parameters. The optional manual
|
||||
handler/custom binder hooks are preserved, and are needed only for custom protocols.
|
||||
|
||||
Fields require exactly one source tag: json, path, form (URL query only), header.
|
||||
All are required by presence unless optional, omitempty or default is specified.
|
||||
Zero and false are valid present values; binding:"required" rejects scalar zero.
|
||||
Use *T with optional when business logic needs to distinguish missing from zero.
|
||||
Pointers may carry null; non-pointer null is rejected. Unknown/duplicate JSON
|
||||
fields and duplicate scalar query/header values are rejected. Arrays in the query
|
||||
use repeated keys. Enum/range/default and nested fields share the documentation
|
||||
and binding compiler. Responses are expected to honor their declared constraints;
|
||||
they are not validated on every response. Do not return nil slices/maps for a
|
||||
non-nullable collection (initialize empty collections instead).
|
||||
|
||||
Go-owned errors can be registered once through svc.ServiceContext.AppOptions with
|
||||
omni.WithErrors(projecterrors.Catalog()). Generated routes inherit them unless they
|
||||
declare an independent catalog. Do not repeat Go-owned errors in the API contract.
|
||||
Alternatively, declare public business errors in error (...) and reference them with @errors.
|
||||
Use internal/apierrors constructors to share status, code and message with OpenAPI.
|
||||
Auth declarations connect a bearer/header/cookie scheme to a real named middleware.
|
||||
The selected auth runs first; @auth none removes inherited declarative auth.
|
||||
Validation policy remains in the middleware implementation. Public messages must
|
||||
not contain secrets; error causes may be included in server logs.
|
||||
|
||||
svc.New receives the loaded Config and borrowed Logger. Add dependencies there,
|
||||
then immediately register each cleanup with OnClose. A later initialization failure
|
||||
runs registered cleanups in reverse order. Close is idempotent. GlobalMiddleware
|
||||
provides global hooks; Middlewares supplies named API hooks. AppOptions is evaluated
|
||||
once before route registration; logging is configured earlier through Config.
|
||||
The generated main runs HTTP draining before closing svc resources, then the logger.
|
||||
Do not close borrowed resources or attach duplicate cleanup hooks to App. The startup
|
||||
context cancels on signals; long-lived clients/workers that requests depend on need
|
||||
their own lifetime, canceled by OnClose after HTTP draining.
|
||||
|
||||
Named middleware is composed in declaration order (first is outermost). Implement
|
||||
the generated hooks or replace entries in svc; never bypass an auth
|
||||
hook merely to make the server start. Middleware names do not imply a JWT policy.
|
||||
|
||||
This CLI supports a documented go-zero-style subset, not all goctl features.
|
||||
Database tables and repositories cannot be inferred from an HTTP contract. Add
|
||||
business repositories through svc. Database and storage options are
|
||||
composed at creation; the original profile is retained on regeneration.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
Selected database: none; storage: none; logging: stderr/json.
|
||||
MySQL uses database/sql through deps.DB, including InTx and a readiness probe.
|
||||
Set GO_OMNI_DB_DSN; pool settings live in etc/config.yaml and GO_OMNI_DB_*.
|
||||
No tables are invented or migrated automatically from request/response DTOs.
|
||||
The none/memory selection opens no database; implement repositories per business.
|
||||
|
||||
With storage enabled, services receive deps.Files, Uploads, Downloads and
|
||||
UploadLinks capability interfaces. Local signed GET/HEAD/PUT uses /storage/object;
|
||||
set GO_OMNI_STORAGE_SIGNING_KEY (32+ bytes) and GO_OMNI_STORAGE_PUBLIC_URL.
|
||||
Cloud credentials use GO_OMNI_STORAGE_ACCESS_KEY/SECRET_KEY; set bucket and endpoint
|
||||
or R2 accountId as appropriate. Signed URLs grant temporary access: authenticate
|
||||
and authorize before issuing them. No public upload-link route is added.
|
||||
Storage failures during startup close an already-open database; Close is idempotent.
|
||||
|
||||
Database/storage/logging selection is recorded in .goOmni/apiManifest.json.
|
||||
Changing selection through generation flags is rejected on an existing project;
|
||||
edit your owned configuration and initialization explicitly, or generate a fresh
|
||||
project for comparison. New routes still regenerate without repeating these flags.
|
||||
57
examples/managedService/api/managed.api
Normal file
57
examples/managedService/api/managed.api
Normal file
@@ -0,0 +1,57 @@
|
||||
syntax = "v1"
|
||||
|
||||
// Error definitions are intentionally owned by Go, not duplicated here.
|
||||
@app (
|
||||
defaultResponse: CommonResponse
|
||||
)
|
||||
response CommonResponse (
|
||||
codeType: int32
|
||||
successCode: 0
|
||||
errorCode: 9999
|
||||
messageField: msg
|
||||
dataMode: always
|
||||
requestIdField: "-"
|
||||
)
|
||||
response AlternateResponse (
|
||||
codeType: int32
|
||||
successCode: 0
|
||||
errorCode: 9999
|
||||
messageField: detail
|
||||
dataMode: always
|
||||
requestIdField: "-"
|
||||
)
|
||||
type CheckReq {
|
||||
Kind string `form:"kind,optional"`
|
||||
}
|
||||
type Receipt {
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
@server (group: demo)
|
||||
service managed-api {
|
||||
@handler check
|
||||
get /check (CheckReq) returns (Receipt)
|
||||
|
||||
@handler other
|
||||
@response AlternateResponse
|
||||
get /other (CheckReq) returns (Receipt)
|
||||
|
||||
@handler rawJSON
|
||||
@response raw
|
||||
get /raw returns (Receipt)
|
||||
|
||||
@handler upload
|
||||
@body (mode: multipart; maxBytes: 2048)
|
||||
post /upload returns (Receipt)
|
||||
|
||||
@handler callback
|
||||
@body (mode: raw; maxBytes: 64)
|
||||
@produces "text/plain"
|
||||
post /callback
|
||||
|
||||
@handler download
|
||||
@produces "application/octet-stream"
|
||||
get /download
|
||||
|
||||
@handler slow
|
||||
get /slow returns (Receipt)
|
||||
}
|
||||
1422
examples/managedService/docs/openapi.json
Normal file
1422
examples/managedService/docs/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
16
examples/managedService/etc/config.yaml
Normal file
16
examples/managedService/etc/config.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Non-secret settings only. Environment variables override these values.
|
||||
server:
|
||||
address: '127.0.0.1:8080'
|
||||
enableLogger: true
|
||||
slowRequestThreshold: 500ms
|
||||
shutdownTimeout: 10s
|
||||
endpoints:
|
||||
liveness: {enabled: true, path: /health/live, responseMode: app}
|
||||
readiness: {enabled: true, path: /health/ready, responseMode: app}
|
||||
openapi: {enabled: true, path: /openapi.json}
|
||||
log:
|
||||
service: "example.com/managed"
|
||||
level: info
|
||||
format: json
|
||||
output: stderr
|
||||
redactKeys: []
|
||||
22
examples/managedService/go.mod
Normal file
22
examples/managedService/go.mod
Normal file
@@ -0,0 +1,22 @@
|
||||
module example.com/managed
|
||||
|
||||
go 1.22.0
|
||||
|
||||
require git.yuntuo.hk/k2safe/go-omni v0.13.0-preview
|
||||
|
||||
require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace git.yuntuo.hk/k2safe/go-omni => ../..
|
||||
34
examples/managedService/go.sum
Normal file
34
examples/managedService/go.sum
Normal file
@@ -0,0 +1,34 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
18
examples/managedService/internal/apierrors/catalog.go
Normal file
18
examples/managedService/internal/apierrors/catalog.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package apierrors
|
||||
|
||||
import omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
|
||||
func Catalog() omnierrors.Catalog {
|
||||
return omnierrors.Catalog{
|
||||
DefaultStatus: 400,
|
||||
Definitions: map[string]omnierrors.Definition{
|
||||
"INVALID": {Code: 400002, Message: "用户名格式不正确"},
|
||||
"TOO_LONG": {Code: 400003, Message: "{label}不能超过{max}个字符", Params: []omnierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int"}}},
|
||||
"INTERNAL_ERROR": {Status: 500, Code: 9999, Message: "系统错误"},
|
||||
"NOT_FOUND": {Status: 404, Code: 404001, Message: "接口不存在"},
|
||||
"METHOD_NOT_ALLOWED": {Status: 405, Code: 405001, Message: "请求方法不支持"},
|
||||
"BODY_TOO_LARGE": {Status: 413, Code: 413001, Message: "请求体太大"},
|
||||
"INVALID_MULTIPART": {Status: 400, Code: 400004, Message: "上传内容不完整"},
|
||||
},
|
||||
}
|
||||
}
|
||||
69
examples/managedService/internal/config/config.go
Normal file
69
examples/managedService/internal/config/config.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Package settings owns this project's configuration; it adds no business identity model.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server config.Config `json:"server"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
server := config.Default()
|
||||
server.Address = "127.0.0.1:8080"
|
||||
server.EnableLogger = true
|
||||
server.SlowRequestThreshold = 500 * time.Millisecond
|
||||
server.Log.Service = "example.com/managed"
|
||||
server.Log.Format = "json"
|
||||
server.Log.Output = "stderr"
|
||||
return Config{Server: server}
|
||||
}
|
||||
|
||||
// Load uses defaults -> strict YAML/JSON -> nonempty environment variables.
|
||||
// Pass an empty path to use only defaults and environment.
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Default()
|
||||
var err error
|
||||
if path != "" {
|
||||
cfg, err = config.DecodeFile(path, cfg)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
cfg.Server, err = config.ApplyEnv(cfg.Server, "")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
// A stream explicitly selected by environment overrides a file profile.
|
||||
if output := strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT")); output != "" && strings.TrimSpace(os.Getenv("GO_OMNI_LOG_FILE")) == "" {
|
||||
cfg.Server.Log.File = ""
|
||||
cfg.Server.Log.Rotation = logx.RotationConfig{}
|
||||
}
|
||||
|
||||
if err = cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
func (c Config) Validate() error {
|
||||
|
||||
if err := c.Server.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Server.Log.Format != "json" && c.Server.Log.Format != "text" {
|
||||
return errors.New("log.format must be json or text")
|
||||
}
|
||||
switch strings.ToLower(c.Server.Log.Level) {
|
||||
case "debug", "info", "warn", "warning", "error":
|
||||
default:
|
||||
return errors.New("invalid log.level")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
67
examples/managedService/internal/handler/appGen.go
Normal file
67
examples/managedService/internal/handler/appGen.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Code generated by go-omni api. DO NOT EDIT.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
|
||||
"example.com/managed/internal/svc"
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
)
|
||||
|
||||
// NewApp is generated HTTP composition. The caller owns deps and its logger.
|
||||
func NewApp(deps *svc.ServiceContext) (*omni.App, error) {
|
||||
if deps == nil || deps.Lifecycle == nil {
|
||||
return nil, fmt.Errorf("service context and lifecycle controller required; see PROJECT.md upgrade instructions")
|
||||
}
|
||||
cfg := deps.Config.Server
|
||||
for _, option := range deps.AppOptions() {
|
||||
if option != nil {
|
||||
option(&cfg)
|
||||
}
|
||||
}
|
||||
cfg.Response = newResponseTemplate("{\"body\":null,\"errorBody\":null,\"messageField\":\"msg\",\"requestIdField\":\"-\",\"codeType\":\"int32\",\"successCode\":0,\"errorCode\":9999,\"dataMode\":\"always\"}").Config()
|
||||
cfg.ResponseEncoder = nil
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.Endpoints.ValidateRoutes([]config.EndpointRoute{
|
||||
{Method: "get", Path: "/check"},
|
||||
{Method: "get", Path: "/other"},
|
||||
{Method: "get", Path: "/raw"},
|
||||
{Method: "post", Path: "/upload"},
|
||||
{Method: "post", Path: "/callback"},
|
||||
{Method: "get", Path: "/download"},
|
||||
{Method: "get", Path: "/slow"},
|
||||
}...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if (cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.Readiness.Enabled) && deps.Health == nil {
|
||||
return nil, fmt.Errorf("enabled health endpoints require a checker")
|
||||
}
|
||||
app := omni.New(omni.WithConfig(cfg))
|
||||
app.Use(deps.GlobalMiddleware()...)
|
||||
if err := RegisterRoutes(app, deps); err != nil {
|
||||
_ = app.Close()
|
||||
return nil, err
|
||||
}
|
||||
if cfg.Endpoints.OpenAPI.Enabled {
|
||||
app.GET(cfg.Endpoints.OpenAPI.Path, OpenAPI)
|
||||
}
|
||||
if cfg.Endpoints.Liveness.Enabled {
|
||||
var options []health.HandlerOption
|
||||
if cfg.Endpoints.Liveness.ResponseMode == "raw" {
|
||||
options = append(options, health.WithRawResponse())
|
||||
}
|
||||
app.GET(cfg.Endpoints.Liveness.Path, deps.Health.LivenessHandler(options...))
|
||||
}
|
||||
if cfg.Endpoints.Readiness.Enabled {
|
||||
options := []health.HandlerOption{health.WithShutdownContext(deps.Lifecycle.Context())}
|
||||
if cfg.Endpoints.Readiness.ResponseMode == "raw" {
|
||||
options = append(options, health.WithRawResponse())
|
||||
}
|
||||
app.GET(cfg.Endpoints.Readiness.Path, deps.Health.ReadinessHandler(options...))
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user