Compare commits

...

11 Commits

116 changed files with 8654 additions and 480 deletions

60
API.md
View File

@@ -1,5 +1,7 @@
# API 契约与项目生成
首次接入请先看 [README](README.md):包含固定远程版本安装、可运行项目、完整 `.api` 示例和已有服务接入步骤。本页用于查阅语法与生成规则;下方带 `--framework-dir` 的命令用于框架源码开发。
本阶段目标:从 `.api` 定义生成可运行的 Go 项目;定义、绑定、校验与 OpenAPI 保持一致;重复生成不覆盖业务实现;业务代码继续独立于网络层。实现入口是 `cmd/go-omni/apigen`,运行时契约编译器是 `contract`。
## 与 go-zero 的对应关系
@@ -103,12 +105,50 @@ service user-api {
- 来源标签中的 `optional` / `omitempty`、标量 `default`、标量 `options=a|b`、数值 `range=[1:100]`(支持开区间和单边界)。
- `binding` 的 `required`、`min`、`max`、`len`、`email`。长度按 Unicode 字符或切片元素数;范围边界必须有限且位于 ±2^53 内。
暂不支持匿名嵌入、别名、递归类型、外部 Go 类型、自定义 JSON 编解码、`[]byte`、泛型、`json:",string"`、任意 validator 标签、go-zero 的 JWT/签名/SSE 声明,以及客户端 SDK 生成。认证验证通过声明关联的命名中间件实现。未列出的语法会明确报错。
暂不支持匿名嵌入、别名、递归类型、未声明的外部 Go 类型、自定义 JSON 编解码、`[]byte`、泛型、`json:",string"`、任意 validator 标签、go-zero 的 JWT/签名/SSE 声明,以及客户端 SDK 生成。认证验证通过声明关联的命名中间件实现。未列出的语法会明确报错。
业务错误、具名鉴权、多行文档及下述开发命令从 CLI `v0.7.0-preview.1` 提供。公共响应模板从 `v0.8.0-preview` 提供,CLI 与业务项目中的框架依赖需一起升级。
## 业务错误:声明、代码和文档共用一份定义
**错误目录也可以只在 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"
@@ -119,10 +159,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`:
@@ -136,7 +176,7 @@ return nil, apierrors.EmailTaken()
OpenAPI 对每个状态按选定的响应模板生成结构和命名 examples,同一 409 下多个错误不会相互覆盖;平铺模式的纯业务状态还列出 code enum。
默认框架错误状态与自动添加的鉴权 401 保留开放的 code 字段,容纳校验器/中间件实际错误。
`x-omni-errors` 提供错误目录与路由关联。文档声明不拦截、改写或穷举运行时的所有错误。
`x-omni-errors` 提供错误目录与路由关联。目录解析已定义的错误,不自动推断数据库或其他未声明错误的业务含义,也不穷举所有运行时错误。
## 鉴权:声明与路由注册一起变化
@@ -210,11 +250,21 @@ go test ./...
路由总览的 `(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。
完整语法、边界和示例见 [迁移扩展指南](MIGRATION.md)。新增生成代码要求 CLI 与框架依赖一起升级到 `v0.9.0-preview`;不兼容全部 goctl 语法。
## 参数语义
无 optional/default 的字段按“是否出现”判断必填,`false`、`0`、`""` 是已提供的值。额外声明 `binding:"required"` 才会拒绝标量零值。默认值只在字段缺失时使用,不替换显式的空值。
业务需要区分“没传”和“传了零值”时使用 `*T` 加 optional。指针允许 JSON null;非指针拒绝 null;`binding:"required"` 拒绝指针 null。缺失与 null 都会得到 nil,本版没有三态 Nullable 类型。响应端的 `optional` 不等于 Go 编码器的 `omitempty`;需要省略零值时显式使用 `omitempty`。
业务需要区分“没传”和“传了零值”时使用 `*T` 加 optional。指针允许 JSON null;普通非指针字段拒绝 null(动态 `any` 允许);`binding:"required"` 拒绝指针 null。缺失与 null 都会得到 nil,本版没有三态 Nullable 类型。响应端的 `optional` 不等于 Go 编码器的 `omitempty`;需要省略零值时显式使用 `omitempty`。
JSON 必须是一个 object,Content-Type 为 application/json。默认最多 1 MiB,包含 chunked/未知长度和尾部空白;超限返回 413。重复 JSON key、未知字段、多个 JSON 值、标量 query/header 重复值、格式和约束错误返回 400;错误 Content-Type 返回 415。失败不会部分修改目标 DTO;错误详情只保留在内部 cause,公共响应不回显原始参数。

View File

@@ -1,5 +1,47 @@
# Changelog
## 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。
- `.api` 支持动态 JSON `any` / `interface{}`,保持重复键、大小、外围未知字段校验和数字精度。
- 路由级请求体限额、自定义绑定和 manual Handler;上传、原始 body、下载可保留手写实现,重生成受保护。
- 自定义 scalar 显式对接项目 JSON/Text 方法;支持可空和集合,分别生成请求/响应 OpenAPI;不放宽其他字段。
- 新增 [迁移扩展指南](MIGRATION.md)、可运行示例和真实生成项目回归。CLI 与框架依赖一起升级。
## v0.8.0-preview
- 公共响应 `codeType` 支持 `int32` / `int64` / `string`,校验默认码及映射范围,OpenAPI 标注整数位宽;JSON/YAML 和生成代码保留 int64 精度,旧 `integer` 兼容。

182
MIGRATION.md Normal file
View File

@@ -0,0 +1,182 @@
# 已有 HTTP 项目逐步接入
从 `v0.9.0-preview` 开始,以下能力贯通运行时、`.api`、代码生成和 OpenAPI。CLI 和项目框架依赖一起升级;入口见 [README](README.md)。这些都是通用扩展点,框架不内置任何项目的 ID、支付回调、鉴权或数据库规则。
## 1. 保留标准 Handler 与中间件
```go
app := omni.New(omni.WithLogger(true))
app.Use(httpx.WrapMiddleware(existingMiddleware)) // func(http.Handler) http.Handler
// go-zero 风格的 func(http.HandlerFunc) http.HandlerFunc:
app.Use(httpx.WrapFuncMiddleware(existingFuncMiddleware))
app.POST("/legacy", httpx.WrapHandler(http.HandlerFunc(existingHandler)))
```
实际使用时先配置所有 `Use`,再注册路由。`WrapHandler` 也接受 `http.FileServer` 等标准 `http.Handler`。
- Handler 经过 App 的日志、requestId、恢复和应用中间件链,原来的 URL、状态码、Header、响应内容由它自己控制。
- `c.Raw()` 的写入也会更新 `c.Status()` / `c.Responded()`;404 会记为 404。提交响应后返回错误或 panic,不会再追加错误 JSON。103 等临时响应不算最终提交。
- 桥接器不缓存完整响应。下载继续流式复制;底层支持时保留 Flusher、Hijacker、Pusher、ReaderFrom、CloseNotifier。`http.ResponseController` 可沿 `Unwrap` 访问底层控制能力。能力仍取决于网络适配器和其他中间件。
- 请求 context、取消信号和已有值继续传递,Omni 的路径参数可用 Go 1.22 的 `r.PathValue("id")` 读取。其他框架私有的参数读取器仍需由项目适配。标准中间件应从原 `r.Context()` 派生上下文;不能换成无关的 Background,也不能在 ServeHTTP 返回后调用 next。异步 timeout middleware 不在此桥接器的契约内。
- 标准中间件在每条注册链构造一次,状态会跨请求保留。下游返回的错误在标准中间件退出前编码,因此压缩、审计等中间件能处理错误响应。它们自己的缓冲或改写行为仍由项目负责。
调用流使用 `App.Handle/GET/POST` 等入口即可,不要绕到原始 Router 注册。暂不使用 `.api` 的旧接口也能这样接入。
## 2. 动态 JSON:允许渐进补全 DTO
```go
type LegacyReply {
Data any `json:"data"`
}
```
`interface{}` 是 `any` 的等价写法,支持 `[]any`、`map[string]any` 和对应的 `interface{}` 写法。带方法的 interface 不支持,会提示明确的类型错误。
动态字段允许 object、array、string、number、bool、null;OpenAPI 字段 schema 为 `{}`,不假装知道内部结构。它放开的是**该字段的内容**,外围 DTO 未声明的字段仍被拒绝,JSON 重复键、深度、总大小限制仍生效。动态数字解析为 `json.Number`,避免大整数先转 float64 丢精度。原始 JSON 的空白、转义和字节布局不会保留;验签请走原始 body 入口。
这不会自动补齐缺少的业务路由,也不代表兼容全部 goctl 语法。能确定结构的新接口优先使用命名 DTO。
## 3. 按路由限制 body 与保留手写入口
普通 JSON 路由只声明需要的大小,不改变其他路由默认的 1 MiB:
```text
@body (mode: json
maxBytes: 2097152
)
@handler createItem
post /items (CreateItemReq) returns (ItemResp)
```
`maxBytes` 单位为字节,允许 1 至 1 TiB;不写时为 1 MiB。已知超长或读取未知长度/chunked body 超限都按 413 处理。内置 App/RouterGroup 在全局中间件读取前安装 reader 限额;同时保持接口响应策略。网络层若有更小限额,以更小值为准。手写 Handler 吞掉读取错误或已经提交响应后,框架不能重新改写 HTTP 状态,务必检查读取错误。
### 只替换绑定函数
```text
@binding custom
@body (mode: json
maxBytes: 65536
)
@handler createItem
post /items (CreateItemReq) returns (ItemResp)
```
首次生成 `internal/handler/<group>/createItemBind.go`:
```go
func (h *Handler) bindCreateItem(c *httpx.Context, req *types.CreateItemReq) error {
// 调用项目自己的解码器,填充 req;失败返回 error。
}
```
Handler 继续调用 Service 并使用统一响应;只把绑定过程交给这个函数。初始 hook 返回 501,不能忽略实现。自定义绑定需自己实现 Content-Type、未知字段、尾部数据等校验,并遵守 DTO 文档;框架负责路由总大小限制,不隐式套第二次 JSON 绑定。
### 上传、原文验签、下载
```text
@handlerMode manual
@body (mode: multipart
maxBytes: 10485760
)
@handler upload
post /files
@handlerMode manual
@body (mode: raw
maxBytes: 65536
)
@produces "text/plain"
@handler callback
post /callback
@handlerMode manual
@produces "application/octet-stream"
@handler download
get /download
```
首次生成 `<operation>Manual.go`,由项目实现整个 `func(h *Handler) Callback(c *httpx.Context) error`;依赖通过 `h.deps` 访问。生成器保留路由、中间件与文档管理,不会预读、JSON 解码或重新包装手写输出,也不生成这个路由的 Service 方法。需要调用 Service 时自行组合。
- manual 无返回类型默认成功状态 200;也可 `returns (UploadResp)` 描述 JSON DTO,手写实现按声明调用 `c.OK` 等。`@produces` 用于无 DTO 的手写输出,描述成功响应媒体类型,不会自动设置运行时 Header。
- raw 的文档为二进制请求体;multipart 为开放对象,具体文件字段与逐部分校验由项目实现。raw/multipart 请求 DTO 仅可含 path/form/header 字段,不能同时声明 JSON body 字段。`form` 始终指 URL query。
- 原文验签直接读取 `c.Request().Body`,在声明的小限额内 `io.ReadAll`;大文件用 `MultipartReader` 配合 `io.Copy` 流式处理,不能整个读入内存。
- 使用 `ParseMultipartForm` 时,其内存参数只是内存阈值,不是总上传限额。App/桥接器在请求结束(含错误和 panic)后删除临时文件;业务仍须关闭 `FormFile` 得到的文件。`MultipartReader` 不自动建临时文件,业务自行创建的文件由业务清理。
- 手写 Handler 的成功输出由它控制;返回的 error、鉴权拒绝仍走接口的错误策略。三方若要求错误也返回指定文本,使用 [自定义 Encoder](RESPONSE.md#手写路由配置文件与三方回调) 并维护对应错误文档;`@produces` 不会改变错误协议。
直接注册 Go 路由:
```go
app.HandleWithBodyLimit("POST", "/upload", 10<<20, nil, uploadHandler)
// nil encoder 沿用 App 策略,也可传项目自己的 response.Encoder。
```
通用注册器使用 `httpx.HandleWithBodyLimit(...)`;实现 `httpx.BodyLimitRegistrar` 可像 App 一样在全局中间件前限流量,未实现时只能在路由中间件入口限制。单独 `httpx.LimitBody` 同样受中间件顺序影响。
### 重新生成规则
`handlersGen.go`、`routesGen.go`、DTO、OpenAPI 继续由生成器管理;`*Bind.go`、`*Manual.go` 只首次创建,后续不会覆盖或删除。切换 manual 回自动模式时,先迁移实现并自行移除对应 Manual 文件,否则生成器明确拒绝,避免重复方法。修改 DTO 导致手写函数签名不匹配时,需要一起调整项目代码;`go test ./...` 是必要检查。
## 4. 自定义标量:项目定义行为,框架连接调用
例如输入 ID 兼容数字/字符串、输出统一为字符串:
```text
scalar BusinessID (
goImport: "example.com/billing/internal/ids"
goType: ID
jsonInput: string | int64
jsonOutput: string
text: true
)
type ItemReq {
ID BusinessID `path:"id"`
}
type ItemResp {
ID BusinessID `json:"id"`
}
```
`goImport`/`goType` 指向项目已有的具体 Go 类型,不是框架内置 ID。生成器创建类型别名和 `types.ContractScalars()`,然后把注册表显式传给每个 Binder。运行时使用标准接口:
| 项目类型的方法 | 用途 |
| --- | --- |
| 值接收者 `MarshalJSON() ([]byte, error)` | JSON 输出 |
| 指针接收者 `UnmarshalJSON([]byte) error` | JSON 输入 |
| 指针接收者 `UnmarshalText([]byte) error` | `text: true` 时的 path/query/header |
完整参考 [示例 ID 类型](examples/compatibility/ids/id.go)。CLI 校验声明并生成代码,不执行项目 Go 代码;启动时校验方法是否齐全,缺失会明确失败。
输入可选 `string`、`bool`、`int32`、`int64`、`float64`,用 `|` 声明多个允许的 JSON 类型;输出只声明一个。框架先校验 JSON 类型和数字范围,再调用项目转换方法。字符串是否是合法 ID、是否允许空串或负数由项目方法决定,不能把数字溢出静默截断。path/query/header 实际传输文本,OpenAPI 对这些位置描述 string;query 切片通过重复键传递。
普通字段不受注册影响,普通 `int64` 仍拒绝 JSON 字符串。没有全局注册表,也不会自动给所有 ID 放宽规则。标量可嵌套、放入切片/map,`*BusinessID` 允许 null;缺失默认必填,`optional`/`omitempty` 可选。标量的额外约束放在转换器中,目前不叠加 `binding`、default/options/range 等标签。
请求 OpenAPI 按 `string | int64` 生成 `anyOf`,响应按 `string` 生成,避免把输入兼容误描述为输出也有两种类型。输出方法必须遵守声明,框架不会每次重新验证响应;项目需为自定义类型测试边界行为。
不使用 `.api` 也可显式注册:
```go
binder := contract.MustNew[CreateReq](
contract.ScalarOf[ids.ID]("string|int64", "string", true),
)
// binder.Bind(c.Request(), httpx.RouteParams(c.Context()), &req)
```
## 可运行示例与验收
```sh
go run ./examples/compatibility
curl -i http://127.0.0.1:8080/legacy
curl -i -X POST http://127.0.0.1:8080/echo \
-H 'Content-Type: application/json' -d '{"id":9223372036854775807,"data":{"hello":true}}'
curl -i -F file=@README.md http://127.0.0.1:8080/upload
curl -OJ http://127.0.0.1:8080/download
```
完整契约:[compatibility.api](examples/contracts/compatibility.api)。生成项目起初的手写 hook 仍为 501;真实实现与原文 HMAC 验签回归见 [消费者 fixture](cmd/go-omni/apigen/testdata/compatibility)。
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./cmd/go-omni/apigen -run Compatibility
go test -race ./httpx ./contract
```
这些是框架验收,不代表任何业务项目已经迁移完毕;业务的路由覆盖、响应兼容、权限、资金事务和数据库行为需要业务自己的回归。

610
README.md
View File

@@ -1,316 +1,466 @@
# GoOmni
GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认使用只依赖标准库的网络适配器,业务代码只依赖 GoOmni 的 transport-neutral API,后续可以替换为自研网络层。
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Service 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
**从这份 README 开始即可接入。** 当前版本为 **`v0.11.1-preview`**,Go **1.22+**;CLI 和项目依赖使用同一固定版本。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。
## 选择接入方式
| 你的项目 | 从哪里开始 |
| --- | --- |
| 新建服务,先定义接口再开发 | [新项目:生成并运行](#新项目生成并运行) |
| 已有服务,逐步接入 HTTP 或基础模块 | [已有项目:按模块接入](#已有项目按模块接入) |
| 已有标准 Handler、动态 JSON、上传或自定义 ID 类型 | [迁移扩展指南](MIGRATION.md) |
| 想统一返回体、业务错误或三方回调 | [配置公共响应与独立协议](#配置公共响应与独立协议) |
| 要接日志、数据库、鉴权和存储 | [业务开发与依赖装配](#业务开发与依赖装配) |
| 要切换网络层、配置 TLS 和退出清理 | [配置与运行](#配置与运行) |
| 已接入,需要升级和检查生成代码 | [日常开发与升级](#日常开发与升级) |
## 准备环境与安装
module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可用,并具有仓库读取权限;使用自己的 Git 凭据管理器,不把账号或 Token 写进 module 地址。
以下命令在同一个终端执行。已有 `GOPRIVATE` 时,把 `git.yuntuo.hk` 追加到原来的逗号分隔列表。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.8.0-preview
export GOPRIVATE=git.yuntuo.hk
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.1-preview
export PATH="$(go env GOPATH)/bin:$PATH"
go-omni --help
```
`v0.4.0` 移除了 Gin 和 Gin 专属的 `WithMode` 配置;`omni.New()` 直接使用标准库路由,已有业务 Handler / Service 无需修改。
如果配置了自定义 `GOBIN`,将那个目录加入 PATH。只引用框架包、不生成项目时可以跳过 CLI 安装,直接使用下文的 `go get`。
创建新业务项目:
## 新项目:生成并运行
在准备存放业务项目的目录执行,`./hello` 必须是新目录或空目录;把 `example.com/hello` 换成自己的 Go module 名称。
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.8.0-preview
go-omni new --module example.com/billing ./billing
cd billing
go-omni api new --framework-version v0.11.1-preview \
--module example.com/hello ./hello
cd hello
go mod tidy
go run ./cmd/server
```
远程安装的 CLI 默认引用自身版本,生成的项目不需要本地 `replace`。
具体版本和本地开发选项见 [CLI 文档](cmd/go-omni/README.md)。
需要数据库持久化时,使用 `go-omni new --database mysql --module example.com/billing ./billing`。
生成项目包含完整用户 CRUD、数据库 Repository、ServiceContext、建表脚本和就绪探针。
先应用生成的 `schema.sql`,再配置 `GO_OMNI_DB_DSN`;启动时会检查连接与表结构,退出时释放连接池。
## API 定义与项目生成
当前源码新增 **API 定义驱动项目生成**:写 `.api` 定义请求、响应和路由,自动生成项目目录、DTO、Handler、Service 骨架及 OpenAPI。重复生成保留业务代码,支持只读 CI 检查,完整语法和示例见 [API 开发指南](API.md)。
另开终端验证:
```sh
go run ./cmd/go-omni api new --framework-dir . \
--module example.com/omniDemo ../omniDemo
curl -i http://127.0.0.1:8080/api/greet/Alice
curl -i http://127.0.0.1:8080/health/ready
curl -sS http://127.0.0.1:8080/openapi.json
```
API 生成支持 `--database mysql --storage local --log-output file` 组合初始化。上面的命令使用本地源码;远程安装见 [发布说明](RELEASE.md),完整业务案例见 [userService](examples/userService/README.md)。
第一个接口返回 HTTP 200,业务数据为 `{"message":"Hello Alice"}`。初始模板未配置响应格式,使用默认公共体:
## 目录结构
```json
{"code":"OK","message":"success","data":{"message":"Hello Alice"},"requestId":"本次请求编号"}
```
按 Ctrl+C 停止服务。项目依赖已固定为远程版本,不需要克隆框架源码或添加本地 `replace`。
### 生成的项目放什么
```text
go-omni/
├── app/ # 应用启动和生命周期
├── httpx/ # 与网络框架无关的 HTTP 抽象
├── response/ # 可配置公共体和响应编码器
├── errors/ # 统一错误
├── health/ # 存活和就绪检查
├── middleware/ # 请求 ID 等基础中间件
├── logx/ # 业务层结构化日志
├── metrics/ # 请求指标与状态统计
├── config/ # 配置管理
├── validate/ # 参数校验
├── contract/ # API 字段契约、严格多源绑定与文档约束
├── auth/ # 认证和权限接口
├── database/ # database/sql 生命周期和事务
├── cache/ # 有界内存缓存、Redis 适配与合并回源
├── httpc/ # 出站 HTTP 连接池、超时和重试
├── task/ # 有界后台任务与间隔调度
├── queue/ # 持久队列接口与 Redis Streams 适配
├── storage/ # Local、S3/R2、OSS 与临时下载链接
├── openapi/ # 路由和 OpenAPI 3.0.3 契约
├── telemetry/ # 可注入 OpenTelemetry 观测
├── resilience/ # 熔断与上游并发隔离
├── audit/ # 显式业务审计与可注入 Sink
├── pagination/ # 有界分页和统一结果
├── transport/nethttp/ # 标准库网络适配器
├── transport/native/ # 自研 HTTP/1.1 服务端预览
├── transport/http2/ # opt-in HTTP/2 实验适配器
├── examples/ # 示例项目
└── cmd/ # go-omni 项目脚手架
hello/
├── api/service.api # 维护接口定义,也可 import 其他 .api
├── cmd/server/main.go # 启动、路由注册、优雅退出
├── etc/config.yaml # 项目配置
├── internal/
│ ├── settings/ # 配置读取
│ ├── servicecontext/ # 数据库、日志、外部客户端等依赖
│ ├── types/typesGen.go # 生成的请求与业务返回类型
│ ├── handler/ # 生成的绑定、响应策略、路由及文档
│ ├── service/greet/ # 在这里实现业务逻辑
│ ├── repository/greet/ # 在这里实现数据访问
│ └── middleware/ # 声明鉴权等中间件后生成对应 hook
├── docs/openapi.json # 生成的 OpenAPI 3.0.3
└── .goOmni/apiManifest.json # 生成文件所有权与校验信息,一起提交 Git
```
main 分支新增缓存、出站 HTTP、限流/幂等/超时中间件、后台任务和 `go-omni add` 模块脚手架。
使用方式、默认容量和进程内能力的适用范围见 [业务基础模块](MODULES.md),
可以直接运行 [组合示例](examples/modules/README.md)。这些能力从 `v0.7.0-preview` 提供。
修改 `api/service.api` 后重新生成,业务写在 `internal/service`,数据访问写在 `internal/repository`,依赖在 `internal/servicecontext` 装配。声明业务错误后还会生成 `internal/apierrors/errorsGen.go`。
扩展模块还包括 JWT/Session、Policy、文件配置及安全热更新、Redis 共享中间件、持久队列、多平台对象存储、OpenAPI、OpenTelemetry、熔断、迁移、审计和分页。
使用与边界见 [扩展模块](ADVANCED.md),可运行 [文件服务组合示例](examples/platform/README.md)。go-omni 不集成 Orbit;需要流程编排的业务项目独立接入。
`api new` 的问候接口可以直接运行;自定义接口新生成的 Service 和鉴权 hook 返回 **501**,需要实现后才可用。生成器不会根据 HTTP 字段猜数据库表或业务规则。
## 快速开始
### 从自己的接口定义生成
保存下面的完整示例为 `catalog.api`,再执行后面的生成命令。这里的响应格式、错误码和提示都由这个业务项目定义。
```text
syntax = "v1"
response CommonResponse (
body: {"code":"${code}", "msg":"${message}", "data":"${data}"}
codeType: int32
successCode: 200
errorCode: 9999
successMessage: "操作成功"
dataMode: always
codes: {"ITEM_NOT_FOUND":404001}
)
error ItemNotFound 404 "ITEM_NOT_FOUND" "记录不存在"
type GetItemReq {
ItemID string `path:"itemId" binding:"required"`
}
type ItemResp {
ItemID string `json:"itemId"`
Name string `json:"name"`
}
@server (
group: item
prefix: /api/v1
response: CommonResponse
)
service catalog-api {
@doc "查询商品"
@errors ItemNotFound
@handler getItem
get /items/:itemId (GetItemReq) returns (ItemResp)
}
```
```sh
go-omni api validate --api catalog.api
go-omni new --api catalog.api --framework-version v0.11.1-preview \
--module example.com/catalog ./catalog
cd catalog
go mod tidy
```
将生成的 `internal/service/item/getItem.go` 替换为下面的演示实现。实际业务中换成 Repository 查询,Service 返回业务数据或错误,不再手工包装公共体:
```go
package item
import (
"context"
"example.com/catalog/internal/apierrors"
"example.com/catalog/internal/types"
)
func (s *Service) GetItem(ctx context.Context, req *types.GetItemReq) (*types.ItemResp, error) {
if req.ItemID != "1001" {
return nil, apierrors.ItemNotFound()
}
return &types.ItemResp{ItemID: req.ItemID, Name: "示例商品"}, nil
}
```
示例 import 对应上面的 `example.com/catalog`,使用其他 module 名称时同步替换。启动后,在另一个终端验证:
```sh
# catalog 项目目录中启动;先停掉前面的 hello 服务,释放 8080 端口。
go run ./cmd/server
# 另一个终端:
curl -i http://127.0.0.1:8080/api/v1/items/1001
curl -i http://127.0.0.1:8080/api/v1/items/missing
```
成功时得到 `{"code":200,"msg":"操作成功","data":{"itemId":"1001","name":"示例商品"}}`;不存在时为 HTTP 404、`{"code":404001,"msg":"记录不存在","data":null}`。
需要 MySQL、文件存储或文件日志时,**首次生成**可以使用:
```sh
go-omni new --api catalog.api --framework-version v0.11.1-preview \
--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)。
不采用 `.api` 时,也可以用 `go-omni new --framework-version v0.11.1-preview --module example.com/billing ./billing` 创建手写接口的 CRUD 示例项目;两种生成方式选择一种即可。
## 已有项目:按模块接入
在业务项目的 **go.mod 所在目录**执行:
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.11.1-preview
```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。
下面是可独立运行的 `main.go` 示例;迁入现有项目时,把路由调用接到自己的业务服务即可。
```go
package main
import omni "git.yuntuo.hk/k2safe/go-omni"
func main() {
app := omni.New()
app.GET("/health", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"status": "ok"})
})
app.Run(":8080")
}
```
成功响应默认使用统一结构:
```json
{
"code": "OK",
"message": "success",
"data": {"status": "ok"},
"requestId": "..."
}
```
业务错误可以直接返回:
```go
return omni.NotFound("USER_NOT_FOUND", "user was not found")
```
默认网络适配器位于 `transport/nethttp`。业务层不需要导入具体网络库;如果后续实现自己的网络层,只需要实现 `httpx.Router` / `httpx.RouterGroup`,然后通过 `omni.NewWithRouter` 启动应用:
```go
application := omni.NewWithRouter(myRouter)
```
仓库还提供了使用标准库路由的 `transport/nethttp` 参考实现。两种适配器已通过同一套路由、业务处理链和取消传播测试,完整用户 CRUD 复用同一份 Handler / Service。
自研适配器可以直接运行 `transport/transporttest.Run` 验收。具体规则、v0.4.0 的斜杠 / Any / 405 行为调整,以及自研网络内核与当前 HTTP 接口的边界,见 [适配层契约](transport/README.md)。
`app.Engine()` 返回标准 `http.Handler`,`ctx.Raw()` 返回适配器提供的 `http.ResponseWriter`,业务 API 不锁定具体网络实现。
需要试用第一版自研网络层时,业务注册代码无需改变,只替换启动器:
```go
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"log"
"net/http"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/transport/native"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/response"
)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
app := omni.New()
app.GET("/health", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"status": "ok"})
})
if err := app.RunWith(ctx, native.New(nil), ":8080"); err != nil {
log.Fatal(err)
func run() error {
closeLog, err := logx.Setup(logx.Config{
Service: "catalog", Level: "info", Format: "json", Output: "stdout",
})
if err != nil {
return err
}
defer closeLog()
app := omni.New(
omni.WithLogger(true),
omni.WithResponse(response.Config{
Body: map[string]any{
"code": "${code}", "msg": "${message}", "data": "${data}",
},
CodeType: "int32", SuccessCode: 200, ErrorCode: 9999,
SuccessMessage: "操作成功", DataMode: "always",
Codes: map[string]any{"ITEM_NOT_FOUND": 404001},
}),
)
app.GET("/api/v1/items/:itemId", func(c *omni.Context) error {
itemID := c.Param("itemId")
logx.InfoContext(c.Context(), "item.get", "itemId", itemID)
if itemID != "1001" {
return omni.NotFound("ITEM_NOT_FOUND", "记录不存在")
}
return c.OK(map[string]string{"itemId": itemID, "name": "示例商品"})
})
// 第三方协议可明确输出文本,不经过公共 JSON 包装。
app.GET("/ping", func(c *omni.Context) error {
return c.Text(http.StatusOK, "pong")
})
return app.Run("127.0.0.1:8080")
}
```
`transport/native` 当前支持 HTTP/1.1、Keep-Alive、`Content-Length` 和 chunked 请求体、`http.Flusher` 流式响应、可选 TLS、读写超时和优雅关闭;不支持的传输编码会被拒绝。它会复用连接状态和响应对象,并可通过 `MaxConnections`、`MaxConcurrentRequests` 限制内存峰值。它仍通过标准 HTTP 请求/响应接口连接业务层,适合在现有 Handler / Service 上做网络层验证。
运行 `go run .`,访问 `/api/v1/items/1001`、`/api/v1/items/missing` 和 `/ping`,可分别验证成功、业务错误及纯文本输出。`App` 实现标准 `http.Handler`,也可挂进现有 `http.Server` 或父路由;这时监听、TLS、信号和资源关闭由现有入口负责,结束时调用 `app.Close()`。
native TLS 当前只支持并宣告 ALPN `http/1.1`;配置 `h2` 会在启动时快速失败。需要 HTTP/2 时,可由 Nginx/Cloudflare 终止后回源 HTTP/1.1,或使用默认的 `transport/nethttp` 适配器。
现有 `http.HandlerFunc` 可直接通过 `app.POST("/legacy", httpx.WrapHandler(http.HandlerFunc(oldHandler)))` 接入;状态码与访问日志一致,下载和流式输出保持原协议。标准中间件、动态 JSON、上传/原文 body、手写绑定和自定义类型的完整用法见 [迁移扩展指南](MIGRATION.md)。
`transport/http2` 是独立的 opt-in 实验适配器,使用 `x/net/http2` 处理帧和流控,同时保留 GoOmni 的连接上限、全局请求并发闸门和生命周期边界。它不会改变 native 默认路径;实验入口、限制和验收结果见 [HTTP/2 进入条件](transport/native/HTTP2.md)。
接入现有业务时,逐项确认:
自研网络层还提供 `server.Stats()`,可读取 accepted、rejected、active connections 和 active requests,与上面的业务请求 metrics 组合后能区分连接堆积、过载拒绝和 Handler 慢请求。`rejectedConnections` 表示连接闸门拒绝数,`rejectedRequests` 表示并发请求闸门返回 503 的次数。
- **协议**:保留既有 URL、HTTP 状态、字段、分页和空值语义。已有业务错误类型需要在项目内转换为 Omni 错误,或使用 `WithResponseEncoder` 适配原错误;设置字段模板不会自动识别其他库的错误。
- **鉴权**:把现有登录态和权限校验接入中间件,继续使用原来的授权范围和业务规则。原来依赖 `gin.Context` 等类型的 HTTP 代码需要适配;标准 `context.Context` 的业务服务可以继续复用。
- **数据类型**:ID、金额、日期等按项目协议转换。用 `.api` 的 `scalar` 显式关联项目自己的类型与 JSON/Text 转换方法,分别声明输入和输出协议;普通字段保持严格绑定,见 [自定义标量](MIGRATION.md#4-自定义标量项目定义行为框架连接调用)。
- **生成器**:它不会自动接管任意已有 Go 项目。希望引入 `.api` 时,先在独立新目录生成对照,再规划路由与生成目录的接入,不要用脚手架覆盖现有业务目录。
业务代码可以直接使用 `git.yuntuo.hk/k2safe/go-omni/logx` 打结构化日志,不需要依赖具体网络库:
先通过一个接口的成功、参数错误、未登录、无权限及业务错误回归,再按模块迁移。框架不要求所有项目使用同一套业务目录或身份模型。
```go
func (s *UserService) Create(ctx *httpx.Context) error {
logx.InfoContext(ctx.Context(), "user.create", "email", input.Email)
return nil
}
```
## 配置公共响应与独立协议
需要基础运行指标时,可以注册 transport-neutral metrics middleware 和接口:
```go
requestMetrics := omni.NewMetrics()
app.Use(requestMetrics.Middleware())
app.GET("/metrics", requestMetrics.Handler())
```
接口返回请求总数、错误数、当前处理中请求、累计耗时和 HTTP 状态分布。它是进程内快照,生产环境可由业务侧定时采集到监控系统。
`WithLogger(true)` 会启用 GoOmni 的统一 HTTP 访问日志;日志内容和业务日志共用 `requestId`。应用启动时可以调用 `logx.Setup` 配置 JSON/文本格式、级别、服务名和输出文件。文件轮转、敏感字段脱敏、异常堆栈与慢请求配置见 [logx 文档](logx/README.md);文件直传与云端分片上传见 [storage 文档](storage/README.md)。 创建新业务项目时可通过 `go-omni new --log-output file --storage local` 按需生成配置、装配代码与可运行示例,当前源码使用方式见 [CLI 文档](cmd/go-omni/README.md)。
应用配置可以统一从 `GO_OMNI_*` 环境变量加载,再交给 `omni.New`:
```go
settings, err := omni.LoadConfig("")
if err != nil {
log.Fatal(err)
}
logger, err := logx.New(settings.Log)
if err != nil {
log.Fatal(err)
}
application := omni.New(omni.WithConfig(settings))
application.OnShutdown(logger.Close)
```
常用变量包括 `GO_OMNI_ENABLE_LOGGER`、`GO_OMNI_LOG_LEVEL`、
`GO_OMNI_LOG_FORMAT`、`GO_OMNI_LOG_OUTPUT`、`GO_OMNI_SERVICE`、
`GO_OMNI_ENVIRONMENT`、`GO_OMNI_HTTP_ADDR`、`GO_OMNI_SHUTDOWN_TIMEOUT` 和
`GO_OMNI_TRUSTED_PROXIES`。可信代理变量使用逗号分隔的 IP/CIDR,例如
`GO_OMNI_TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8`;只有来自这些地址的连接才会解析
`X-Forwarded-For`,默认只记录 socket 对端地址。数据库连接仍由
`database.ConfigFromEnv` 从 `GO_OMNI_DB_*` 加载。
网络层也由同一份配置选择,Handler / Service / Repository 不需要导入具体适配器:
| 配置 | 默认值 / 用途 |
| 接口来源或需求 | 配置位置 |
| --- | --- |
| `GO_OMNI_TRANSPORT` | `nethttp`;可选 `native`、`http2` |
| `GO_OMNI_TLS_CERT_FILE` | TLS 证书 PEM 文件路径 |
| `GO_OMNI_TLS_KEY_FILE` | TLS 私钥 PEM 文件路径,必须与证书一起配置 |
| `GO_OMNI_SHUTDOWN_TIMEOUT` | `10s`,三种适配器共用的请求排空时间 |
| `.api` 生成的接口 | `response Name (...)` 定义,`@server (response: Name)` 引用 |
| 多个 `.api` 共用返回体 | 将声明放到 `common.api`,使用 `import "common.api"` |
| 某个生成接口用另一种 JSON 公共体 | 路由声明 `@response OtherResponse` |
| 只要业务 JSON、无成功包装 | 路由声明 `@response raw`;失败仍沿用组级格式 |
| 手写路由和应用级 404/405 | `omni.WithResponse(response.Config{...})`,或读取配置后传入 `WithConfig` |
| 成功和失败都要遵循特殊回调协议 | `HandleWithResponse` 绑定项目自己的 `response.Encoder` |
| 单次直接输出 JSON、文本、XML、字节或空响应 | `c.JSON` / `c.Text` / `c.XML` / `c.Bytes` / `c.NoContent` |
例如运行自研 HTTP/1.1,或显式试用独立 HTTP/2 适配器:
`${data}` 是业务返回值,`${message}` 是公开提示,`${code}` 是业务码;占位符保留原值类型,不是字符串拼接。还支持 `${requestId}`、`${ok}`、`${status}`,字段名和嵌套结构由项目决定。
```sh
GO_OMNI_TRANSPORT=native go run ./examples/reference
GO_OMNI_TRANSPORT=http2 GO_OMNI_HTTP_ADDR=127.0.0.1:8443 \
GO_OMNI_TLS_CERT_FILE=./server.crt GO_OMNI_TLS_KEY_FILE=./server.key \
go run ./examples/reference
```
- `codeType` 可以是 `string`、`int32`、`int64`;整数类型需要配置 `successCode` 和 `errorCode`。
- `codes` 把逻辑错误码映射成对外业务码;未单独映射的失败使用 `errorCode`。JSON 业务码不会改变 HTTP 状态,例如 `404001` 仍对应 HTTP 404。
- `successMessage` 设置成功提示;未设置 `errorMessage` 时沿用错误的公开提示。`messages` 可按逻辑错误码单独覆盖,优先级最高。全局 `errorMessage` 会替换未单独映射的所有失败提示,只有需要统一失败文案时才设置。
- `dataMode: always` 在无数据时输出 null;默认 `omitNil` 省略模板里相应对象字段。没有 `returns` 的 `.api` 路由默认 HTTP 204、无响应体,这与 `200 + data:null` 是不同的接口契约。
`http2` 必须配置 TLS;native TLS 只协商 `http/1.1`,nethttp TLS 支持标准库的 HTTP/1.1 和 HTTP/2。
未知适配器、缺少配对证书、无法加载证书时,`Run` / `RunContext` 在监听前返回错误并执行资源清理,不会退回其他协议。
HTTP/2 仍为实验适配器,尚未合并到 native 多协议监听器。
**生成接口的格式以 `.api` 为准**,修改后重新生成,运行时与 OpenAPI 一起更新;应用 YAML 不会覆盖它。404/405 没有匹配到某个 `.api` 接口,使用应用默认配置,需要统一时也配置应用默认值。
代码配置可写成 `omni.New(omni.WithTransport(omni.TransportNative), omni.WithTLS(certFile, keyFile))`。
`ServeContext(ctx, listener)` 使用相同配置并接管已有 listener;失败或退出时也会关闭它。
`RunWith(ctx, runner)` 保留自定义网络层入口,由 runner 自己管理 TLS、超时和网络参数;连接/请求上限等适配器专属选项仍通过这个入口设置。
`NewServer` 仅创建标准库明文服务器,配置选择应用于 `Run`、`RunContext` 和 `ServeContext`。
三种适配器的配置、真实 TLS 业务和关闭行为已通过 Linux 全量 race 及 10 次重复回归,见 [统一启动验收](performance/transportStartup.md)。
文本/XML 回调用 Go 编码器或直接输出,`.api` 可用 `@handlerMode manual` 与 `@produces` 声明成功媒体类型;特殊错误协议仍需项目维护。`@response raw` 仅表示原始 JSON。前面示例的 `c.Text` 控制这次正常返回;需要连鉴权失败、业务错误和 panic 都按回调协议响应时使用 `HandleWithResponse`。完整配置和回调示例见 [响应参考](RESPONSE.md)。
`App` 同时实现标准 `http.Handler`。`Run` 监听 `SIGINT` / `SIGTERM`;`RunContext` 则由调用方的 context 控制退出,生成项目已使用 `signal.NotifyContext` 接好信号。
退出时先停止接收新请求,等待活跃请求完成,再执行资源清理。超过 `ShutdownTimeout` 时强制关闭连接、取消请求 context,并返回超时错误。业务代码应响应 context 取消;框架无法强制结束忽略取消的业务 goroutine。数据库连接池由 `database.Open` 创建,在服务退出时调用 `Close`。
## 业务开发与依赖装配
应用资源可以直接挂到 `omni.New` 返回的生命周期上:
### 日志
业务包直接使用 `logx`,不需要 HTTP 框架对象:
```go
application := omni.New()
application.OnShutdown(db.Close)
// ctx 是 Service 接收的标准 context.Context。
logx.InfoContext(ctx, "order.created", "orderId", orderID)
logx.ErrorContext(ctx, "order.failed", "error", err)
```
清理函数会按注册顺序的逆序执行,`application.Close()` 和 `RunContext` 都会触发,且重复调用安全。
入口用 `logx.Setup` 初始化,退出时关闭;生成的 API 项目已经完成日志初始化和退出清理。Service、Repository 和外部请求持续传递请求的 `context.Context`,业务日志可关联 `requestId`,同时保留取消信号。框架不规定或自动注入 `userId`、`tenantId`。
服务可以把数据库、缓存等依赖注册为就绪探针:
`WithLogger(true)` 开启 HTTP 访问日志。格式、级别、文件轮转、脱敏键和慢请求阈值均可配置,见 [日志](logx/README.md)。
### 鉴权与业务错误
`.api` 可声明认证方式和关联中间件,例如把下面的声明放入契约,并在相应 `@server` 块中加上 `auth: UserAuth`:
```text
auth UserAuth (
type: bearer
middleware: Auth
)
```
生成器会创建 `internal/middleware/auth.go`,由项目实现凭据校验和身份注入。声明会生成路由绑定与 OpenAPI 的认证描述,**不会自动实现登录或授权规则**;未实现的 hook 返回 501。手写路由可使用 `auth.RequireAuth(authenticator)` 或自己的中间件。JWT、Session 和 Policy 按需选择。
业务错误可以集中定义,业务只选择发生了哪个错误。**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
checker, err := health.New(health.Probe{
Name: "database",
Check: db.PingContext,
})
if err != nil {
log.Fatal(err)
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: "密码格式不正确"},
},
}
application.GET("/health/live", checker.LivenessHandler())
application.GET("/health/ready", checker.ReadinessHandler())
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)
```
存活检查只确认进程在线;就绪检查会执行全部探针,任意依赖失败时返回 HTTP 503。
目录也可放入 `config.Config.Errors`,由 JSON/YAML 加载;生成项目的路径为 `server.errors`,生成路由只有显式引用错误定义时才使用 `.api` 独立目录,未声明则继承 App。不同 App 独立持有配置,单路由也可通过 `response.WithErrors` 绑定专用目录与自定义 Encoder。直接输出文本、XML、下载或已提交的响应继续保留原协议。
新项目脚手架会生成项目自己的 `internal/servicecontext`。它集中保存 Logger、数据库、缓存和外部客户端,Service 只依赖这个项目上下文,不把基础设施初始化散落到各个 Handler:
访问日志保留实际 HTTP 状态,并增加失败标记、逻辑错误标识和目录业务码;指标、链路及框架标准中间件/幂等重放也保留失败语义。未知标识使用系统错误兜底,内部 cause 不写入默认公开响应。旧 `error (...)`、`New` / `BadRequest` / `Codes` / `Messages` 继续支持;新目录与旧映射冲突会报错,避免静默覆盖。
动态提示也可以集中配置,在目录中声明 `message: "{label}不能超过{max}个字符"` 和有序 `params`(label/string、max/int),生成带类型的调用:
```go
services, err := servicecontext.New(ctx, logger)
if err != nil {
return err
}
defer services.Close()
userService := service.NewUsers(services)
return apierrors.TextTooLong("企业名称", 100)
// 最终提示:企业名称不能超过100个字符
```
## 完整业务参考项目
运行时支持 `omnierrors.ByCode("TEXT_TOO_LONG").WithParams(...)`,与生成函数共用解析器。固定错误继续无参数调用;缺参或类型错误使用系统兜底。模板、参数类型及具体响应示例同步写入 OpenAPI。完整示例见 [errorParams.api](examples/contracts/errorParams.api)。
[`examples/reference`](examples/reference/README.md) 提供按 Handler / Service / Repository / Model 分层的用户 CRUD 示例。默认使用并发安全的内存存储,支持分页、邮箱唯一校验、结构化请求日志和统一错误,无需配置数据库。
更详细的配置、作用域和升级步骤见 [统一错误目录](errors/README.md)。
从远程版本生成并升级一个独立业务模块,可运行:
### 数据库、存储及其他模块
依赖在启动入口或项目自己的 `internal/servicecontext` 中创建,通过构造函数传给 Service/Repository,避免每个请求都新建连接池。只注册自己拥有的资源,例如 `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)。
| 能力 | 包与接入说明 |
| --- | --- |
| 数据库连接池、事务、显式迁移 | `database`,见 [数据库](database/README.md);表结构与驱动由项目选择 |
| 本地文件、S3、R2、OSS、直传与临时下载 | `storage`,见 [文件存储](storage/README.md);业务依赖接口,装配时选择适配器 |
| 缓存、出站 HTTP、限流、幂等、后台任务 | `cache`、`httpc`、`middleware`、`task`,见 [基础模块](MODULES.md) |
| JWT/Session、策略、Redis 队列、审计、分页、观测 | 见 [扩展模块](ADVANCED.md);按需注入实现 |
| 存活与就绪检查 | `health`,见 [健康检查](health/README.md);数据库等依赖作为 readiness 探针 |
API 项目的数据库迁移需要单独维护并显式执行;启动不会自动改表。完整示例 [userService](examples/userService/README.md) 展示 MySQL、事务、鉴权、日志和文件导出,可参考依赖装配方式后接入自己的业务规则。
## 配置与运行
生成项目从 `etc/config.yaml` 读取配置,优先级为 **默认值 → 配置文件 → 环境变量**。密钥通过环境变量提供。手写入口需显式调用配置加载函数并传给 `omni.New(omni.WithConfig(settings))`;单独调用 `omni.New()` 不会自动读取环境变量。
| 环境变量 | 用途 |
| --- | --- |
| `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,逗号分隔 |
| `GO_OMNI_DB_DSN` | 选择 MySQL 初始化时的数据库连接配置 |
例如在生成项目中切换 native:
```sh
python3 scripts/verifyBusiness.py --version v0.6.0-preview
GO_OMNI_TRANSPORT=native go run ./cmd/server
```
该验收会在临时目录中先固定 `v0.2.0`,再升级到 `v0.6.0-preview`,检查无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。
手写入口可用 `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。
标准库与 native 的并发、延迟和长连接内存对照见 [PERFORMANCE.md](PERFORMANCE.md)。`python3 scripts/verifyPerformance.py` 运行检查与 benchmark;增加 `--load --output /tmp/goOmniPerf.json` 可测量独立服务进程并保存报告。
读写超时也可以在生成项目的 `etc/config.yaml` 中声明(放在 `server.http` 下):
HTTP/2 仍是显式 opt-in 实验适配器,不会改变 native 的 HTTP/1.1 默认路径。Linux TLS 长测使用 `python3 scripts/verifyHTTP2.py --duration 30m`,默认使用普通 Go 运行模式;`--race` 默认改为 60 秒,用于并发检查,其额外内存开销不能当成生产运行基线。
```yaml
server:
http:
readHeaderTimeout: 5s
readTimeout: 30s
writeTimeout: 30s
idleTimeout: 60s
maxHeaderBytes: 1048576
```
多连接混合场景使用 `python3 scripts/verifyHTTP2.py --scenario mixed --duration 30m`:8 条独立 TLS 连接,每条连接 14 个普通请求 worker、1 个慢上传 worker、1 个慢下载 worker,共 128 个 worker。上传为 256 KiB、每 4 KiB 等待 2 ms;下载为 8 MiB、每次最多读取 64 KiB 后等待 20 ms。日志分别记录三类成功数、传输字节数、每条连接的覆盖情况,以及停止并释放测试数据后两次 GC 的采样。
代码配置使用 `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 超时当成业务执行时限。
该脚本默认把测试容器限制为 2 核、2 GiB 内存(禁用 swap)、256 个进程/线程,整个命令最多运行 40 分钟,中断和超时会清理本次容器。这些限制可用 `--cpus`、`--memory-mb`、`--pids-limit`、`--wall-timeout` 调整;共享服务器应先用 `--duration 60s` 短测,再逐步延长。每 15 秒输出的堆、RSS 和 FD 统计包含同一进程内的客户端与服务端。
自研网络层通过 `RunWith(ctx, runner)` 接入监听与连接生命周期,通过 `NewWithRouter(router)` 替换路由;业务 Handler/Service 不需要导入具体适配器。细节与验收契约见 [网络适配层](transport/README.md)。
Linux amd64 已完成 30 分钟、单 TLS 连接、128 个 worker 的普通模式长测:58,102,476 次请求,0 失败、0 协议错误,采样 RSS 峰值约 22.6 MiB,连接/活动请求归零且 FD 回到基线。完整采样和原始日志见 [HTTP/2 长测报告](performance/http2SoakLinux.json) 与 [原始日志](performance/http2SoakLinux.txt),此前的 race 对照见 [短测记录](performance/http2SmokeLinux.json)。这项结果覆盖单连接复用。
`Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。服务先停止接收新请求,等待活跃请求完成,再清理资源;超时会取消请求并关闭连接,业务代码仍需响应 context 取消。
多连接慢客户端场景也已完成 Linux amd64 的 30 分钟长测:8 条 TLS 连接、128 个 worker,53,079,128 次普通请求、106,971 次慢上传、5,588 次慢下载,共 53,191,687 次,0 失败与协议错误。采样 RSS 峰值约 116.6 MiB(包含客户端、服务端和下载缓冲);退出后连接/请求归零,goroutine 回到 2、FD 回到 6;释放测试数据并两次 GC 后存活堆约 450 KiB,RSS 不要求立即归还操作系统。完整 120 份采样见 [混合长测报告](performance/http2MixedSoakLinux.json) 和 [原始日志](performance/http2MixedSoakLinux.txt)。连接级窗口耗尽、WINDOW_UPDATE 恢复及零窗口写阻塞的关闭回收也已有独立测试;Nginx 的 HTTP/2 回源验收结果见下方,适配器保持实验状态。
## 日常开发与升级
`python3 scripts/verifyHTTP2Proxy.py --count 3` 验证真实 Nginx 双段 TLS/h2、回源连接复用、证书拒绝、超时/取消恢复与新旧实例的 GOAWAY 排空;增加 `--race` 可运行并发检查。Linux amd64、Nginx 1.30.5 的普通与 race 模式各 3 轮均已通过。编译和测试容器顺序运行,均限制为 2 核、2 GiB;测试容器禁用外部网络,全部请求在容器内完成。使用方法、报告及当前边界见 [HTTP/2 代理回源验收](transport/http2/PROXY.md)。
在框架根目录运行:
在已生成的 API 项目目录执行:
```sh
go run ./examples/reference
go-omni api validate
go-omni api inspect
go-omni api generate --dry-run
go-omni api generate
go-omni api generate --check
go test ./...
```
默认地址为 `http://127.0.0.1:8080`。示例文档提供完整的 curl 操作流程。数据只保存在内存中,重启后清空。
`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Service、Repository、中间件实现、main、配置和依赖装配属于项目,重复生成保留手写内容;契约改变后仍需自行调整业务签名并通过编译。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 可运行 `generate --check` 检查是否同步。
框架的 `App` 也实现了标准 `http.Handler`,可以直接用于 `http.Server` 和 `httptest.NewServer`;业务路由可通过 `*omni.RouterGroup` 注册,无需导入网络库。
升级时先安装目标版本的 CLI,再在业务 module 中 `go get` **同一版本**;API 项目随后执行上述生成与测试步骤,手写项目只需依赖升级及业务回归。`v0.11.1-preview` 的变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
## 当前边界
## 按需深入
缓存、Redis 消息队列和进程内定时任务已作为可选模块提供。认证层提供 `Authenticator`、JWT/Session 适配器和 Policy 接口,登录流程与业务授权规则由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。
| 需要了解 | 文档 |
| --- | --- |
| `.api` 类型、参数绑定、错误、鉴权和生成规则 | [API 开发指南](API.md) |
| 公共响应体、提示优先级、独立回调协议 | [响应参考](RESPONSE.md) |
| CLI 完整参数、本地框架开发和模块脚手架 | [CLI](cmd/go-omni/README.md) |
| 完整 API 业务项目 | [userService](examples/userService/README.md) |
| 手写接口分层示例 | [reference](examples/reference/README.md) |
| 框架成熟度、历史变更 | [路线图](ROADMAP.md)、[更新记录](CHANGELOG.md) |
| 网络性能、测试条件与适用范围 | [性能说明](PERFORMANCE.md)、[HTTP/2 验收](transport/native/HTTP2.md) |
当前成熟度与后续验收标准见 [ROADMAP.md](ROADMAP.md),版本变更见 [CHANGELOG.md](CHANGELOG.md)。
`v0.8.0-preview` 新增 [公共响应模板](RESPONSE.md):在 `.api` 中定义一次返回公共体,分组与接口引用,运行时和 OpenAPI 共用。支持数字错误码、嵌套结构、原始 JSON,以及独立的文本/XML回调编码器。源码开发可通过 `--framework-dir` 显式引用本地框架。
当前版本可用于固定版本接入与业务验证,仍标记为 preview。`.api` 借鉴 go-zero 的接口开发方式,支持范围以本仓库文档为准,不是完整 goctl 语法兼容层;HTTP/2 保持实验状态,OSS/R2 云账号实网验收不属于本次发布范围。

View File

@@ -1,55 +1,40 @@
# v0.8.0-preview
# v0.11.1-preview
这一版让业务自行定义公共返回体:在 `.api` 中配置字段、结构、成功/失败提示和错误码类型,按分组复用或由单接口覆盖,运行时与 OpenAPI 共用定义。需要特殊协议的回调可独立控制状态、Header 和内容。
修复生成路由使用空错误目录遮蔽 `omni.WithErrors(catalog)` 的问题。错误现在可以只在 Go 中注册一次,`.api` 无需重复定义错误目录或逐条列出错误。
本版继续作为预览版供固定版本接入;HTTP/2 仍是独立实验适配器。
## 行为
## 安装与升级
- 未声明路由错误列表:继承 App 目录,包括带参数提示及项目自定义 INTERNAL_ERROR 兜底。
- 显式引用路由错误:继续使用路由独立目录,不与 App 合并。
- `@errors none` 清空 `.api` 分组错误列表,随后继承 App。
- 错误继承不覆盖 `.api` 的返回格式、RawJSON、手写绑定或请求体限额;全局中间件提前返回、标准桥接与克隆保持一致。
- 空 `response.WithErrors` / `MustWithErrors` 返回原 Encoder,既不覆盖 Context 的目录,也不在编码阶段按空目录重新解析错误。
## 升级
```sh
GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.8.0-preview
# 已有业务项目中执行:
GOPRIVATE=git.yuntuo.hk go get git.yuntuo.hk/k2safe/go-omni@v0.8.0-preview
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 ./...
```
新项目可以直接 `go-omni api new --module example.com/demo ./demo`,或用 `go-omni new --api service.api --module example.com/demo ./demo`。远程安装的 CLI 会固定生成项目的框架版本,无需本地 replace。需要仓库权限时使用自己的 Git 凭据管理器,不把凭据写入 module path 或 go.mod。
运行时修复也适用于旧 v0.11.0 生成代码,因此升级运行时后即可解除空目录遮蔽。同步升级 CLI 重新生成时不再创建空目录包装;已生成业务文件继续受保护。代码生成最低运行时仍为 v0.11.0-preview。
## 本版功能
接入见 [README](README.md)、[错误目录继承](errors/README.md) 和 [只在 Go 注册错误的契约示例](examples/contracts/errorInheritance.api)。静态 OpenAPI 不执行 Go 配置,保留契约响应结构,不自动列举运行时目录内容。
- `response Name (...)` 定义公共 JSON 体,支持嵌套结构、独立 errorBody、常量及类型保持的占位符;通过 import 共享配置。
- `@server (response: Name)` 为整个 service 块选择模板,`@response Name` 单接口覆盖;`@response raw` 成功返回业务 JSON,错误保留组级格式。
- `codeType: int32 / int64 / string` 使用与业务字段一致的名称;默认码和映射都校验,int64 配置及生成代码保留整数精度。旧 `integer` 写法仍兼容。
- `successMessage` / `errorMessage` 配置默认提示,`codes` / `messages` 按逻辑错误码覆盖。业务只返回数据或错误,公共体由框架组装。
- `omni.WithResponse` 设置手写路由的应用默认格式,`WithResponseEncoder` 与 `HandleWithResponse` 支持特殊协议;Text / XML / Bytes / JSON / NoContent 可直接输出。
- 生成路由在全局中间件之前选定策略,提前鉴权拒绝、限流、panic 和 Context 克隆沿用正确格式。OpenAPI 同步模板结构与业务错误示例。
## 验收
详见 [公共响应体](RESPONSE.md)、[完整定义示例](examples/contracts/responses.api) 和 [更新记录](CHANGELOG.md)。
## 升级行为
1. CLI 与框架依赖一起升级到本版。新生成路由使用新的响应策略入口,旧运行时不能编译。旧 `.api` 可继续使用;生成器不会覆盖已有 Service、Repository、middleware、配置和 main。
2. 生成接口以 `.api` 为响应契约来源,即使没有声明模板也固定使用默认格式;应用 YAML 的 response 用于手写路由和应用兜底。需要变更生成接口的格式,请修改 `.api` 后重新生成。
3. `response.Response` DTO 保留为兼容类型,运行时不再依赖它固定结构。未配置的正常业务响应继续使用默认 code/message/data/requestId。
4. 内置 App 的 404/405 现在经过应用响应策略和中间件,默认输出 JSON;原先依赖纯文本兜底的客户端需调整。直接使用 nethttp.Router 的兜底行为不变。
5. `int32` / `int64` 按有符号整数范围校验并输出 JSON 数值;消费端需支持相应精度。需要文本码时使用 string。旧 integer 继续保留原有 ±(2^53−1) 范围。
6. `.api` 的模板和 raw 当前描述 JSON。文本/XML回调用 Go 编码器并维护对应文档;TLS/HTTP解析错误、连接级过载等发生在进入应用前的错误仍按网络协议处理。
## 验证入口
复现用例在修复前:普通路由通过,生成/RawJSON 路由和提前返回中间件共 12 项失败,均变成 500/英文系统提示。修复后同一批用例通过。
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
go vet ./...
go build ./...
python3 scripts/verifyAPIWorkflow.py --report performance/responsePoliciesWorkflowLocal.json
python3 scripts/verifyResponseRelease.py --version v0.8.0-preview \
--report performance/responsePoliciesRemote.json
python3 scripts/verifyAPIWorkflow.py --version v0.8.0-preview \
--report performance/responsePoliciesWorkflowRemote.json
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
```
远程验收分别从全新 module cache 安装 CLI,验证生成项目固定依赖且没有本地 replace,以及完整用户示例的真实 MySQL、nethttp/native、事务、日志和生命周期。数据库容器只使用本机临时资源(1 CPU / 768 MiB),结束清理,不连接业务服务器。
验收记录见 [响应模板验收](performance/responsePoliciesAcceptance.md)。OSS/R2 云账号实网验收与长时间压力测试不属于本次发布范围。
结果见 [目录继承验收](performance/errorInheritanceAcceptance.md)。本轮未修改 eTax/ePay 业务代码,没有使用业务服务器。

View File

@@ -125,7 +125,7 @@ app.HandleWithResponse("POST", "/hooks/payment", ack, paymentHandler)
编码器也可以设置 HTTP 状态、Header、XML 或任意字节。`omni.WithResponseEncoder` 设置应用默认编码器;`httpx.WithResponse` 是普通路由中间件,按中间件顺序生效。需要覆盖全局中间件提前拒绝时,应使用 `HandleWithResponse`。自定义 Registrar 可以实现 `httpx.ResponseRegistrar` 提供相同的早期挂载能力;未实现时辅助函数退化为普通路由中间件。
`.api` 当前声明的是 JSON 协议,`@response raw` 也是原始 JSON,不代表任意文本/XML协议。自定义文本/XML回调使用上述 Go 入口,并由应用维护相应文档;生成器不会猜测自定义 Go 编码器的输出。如果在命名中间件里替换编码器,业务也必须同步维护文档。
`@response raw` 表示原始 JSON,不代表任意文本/XML协议。从 v0.9.0-preview 开始,`.api` 可用 `@handlerMode manual` 配合 `@produces` 描述文本/文件等成功输出,详见 [迁移扩展](MIGRATION.md)。自定义回调错误协议仍使用上述 Go 入口,并由应用维护相应文档;生成器不会猜测自定义 Go 编码器的输出。如果在命名中间件里替换编码器,业务也必须同步维护文档。
框架内置路由的 404/405 使用应用默认格式;它们不属于某个 `.api` 分组。TLS 握手、畸形 HTTP、连接级过载等发生在进入应用前,仍按网络层协议处理。自定义编码器失败、panic 或返回非法状态/204 body 时,最终返回空 500,避免递归编码或泄露另一种响应体。
@@ -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 的成功输出保持原样,失败继续交给所选错误编码器。

View File

@@ -67,6 +67,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 +78,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 +119,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,16 +162,24 @@ 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.

32
app/body.go Normal file
View File

@@ -0,0 +1,32 @@
package app
import (
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/response"
"net/http"
)
func (a *App) bodyHandler(maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
checked := httpx.LimitBody(maxBytes)(handler)
if encoder == nil {
encoder = a.responseEncoder
}
next := a.adaptResponse(encoder, checked, middleware...)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
r = r.WithContext(r.Context())
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
}
next.ServeHTTP(w, r)
})
}
// HandleWithBodyLimit caps the reader before application middleware and preserves
// the response policy, including auth failures and parameter errors.
func (a *App) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
a.router.Handle(method, path, a.bodyHandler(maxBytes, encoder, handler, middleware...))
}
func (g *RouterGroup) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
combined := append(append([]httpx.Middleware{}, g.middlewares...), middleware...)
g.group.Handle(method, path, g.app.bodyHandler(maxBytes, encoder, handler, combined...))
}

178
app/errorCatalog_test.go Normal file
View 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, &copy); err != nil {
return err
}
r.Response = &copy
}
s.records[key] = r
return nil
}

View 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
View 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)
}
}

199
app/httpConfig_test.go Normal file
View 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
}

View File

@@ -30,7 +30,11 @@ 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 {
a.writeError(requestContext, err)
}

View File

@@ -57,6 +57,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 +82,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

View File

@@ -1,5 +1,7 @@
# go-omni CLI
首次接入见 [README](../../README.md),无需克隆框架源码。本页说明命令参数;下方 `go run ./cmd/go-omni` 示例在框架仓库中执行,并显式引用本地源码。
## API 契约驱动项目
```sh
@@ -19,6 +21,12 @@ go run ./cmd/go-omni api validate --api examples/contracts/user.api
后续在生成项目内执行 `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` 调用函数。
`v0.10.0-preview` 新增 `errorCatalog`:集中配置业务码、提示和 HTTP 状态,支持默认 HTTP 200 与单条覆盖,生成函数、路由策略和 OpenAPI 共用一份定义。语法及配置方式见 [统一错误目录](../../errors/README.md)。
`v0.11.0-preview` 增加有序模板参数,生成带类型的错误函数和具体文档示例;示例见 [errorParams.api](../../examples/contracts/errorParams.api)。新生成代码需要同版或更新框架。
`v0.11.1-preview` 修复未声明错误的生成路由遮蔽 App 错误目录:错误可只在 Go 中注册一次,`.api` 不必重复维护;返回格式和路由参数继续按契约生成。
支持范围、参数语义、目录及安全更新策略见 [API 开发指南](../../API.md)。本版借鉴 go-zero 的工作流,不宣称兼容全部 goctl 语法;仍通过 Omni/httpx 保留网络层替换能力。
## 日志与对象存储按需装配
@@ -73,8 +81,8 @@ Handler / Service / Repository / Model 与模块注册入口。模块名为 lowe
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview.1
go-omni new --module example.com/billing ./billing
GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.1-preview
go-omni new --framework-version v0.11.1-preview --module example.com/billing ./billing
cd billing
go mod tidy
go run ./cmd/server

View File

@@ -0,0 +1,161 @@
package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestCompatibilityContract(t *testing.T) {
s, err := Parse(filepath.Join(frameworkRoot(t), "examples/contracts/compatibility.api"))
if err != nil {
t.Fatal(err)
}
c, err := Compile(s)
if err != nil {
t.Fatal(err)
}
if len(s.Scalars) != 1 || len(s.Routes) != 6 {
t.Fatal("incomplete contract")
}
if !strings.Contains(string(c.Document), `"anyOf"`) || !strings.Contains(string(c.Document), `"multipart/form-data"`) {
t.Fatal("missing schemas")
}
for _, change := range [][2]string{{"jsonInput: string | int64", "jsonInput: mystery"}, {"text: true", "text: false"}, {"mode: multipart", "mode: invalid"}, {"maxBytes: 128", "maxBytes: 0"}, {"goType: ID", "goType: id"}, {"Data any", "Data interface{ Method() }"}} {
source := readFile(t, filepath.Join(frameworkRoot(t), "examples/contracts/compatibility.api"))
spec, err := parseText(t, strings.Replace(source, change[0], change[1], 1))
if err == nil {
_, err = Compile(spec)
}
if err == nil {
t.Fatalf("accepted %s", change[1])
}
}
}
func TestCompatibilityConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
t.Skip("consumer integration")
}
root := frameworkRoot(t)
dir := filepath.Join(t.TempDir(), "consumer")
o := Options{API: filepath.Join(root, "examples/contracts/compatibility.api"), Dir: dir, Module: "example.com/compatibility", FrameworkDir: root}
releaseCLI := os.Getenv("GO_OMNI_RELEASE_CLI")
generate := func(check bool) {
t.Helper()
if releaseCLI == "" {
options := o
options.Check = check
if _, err := Generate(options); err != nil {
t.Fatal(err)
}
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
args := []string{"api", "generate", "--api", o.API, "--dir", dir, "--module", o.Module}
if check {
args = append(args, "--check")
}
cmd := exec.CommandContext(ctx, releaseCLI, args...)
cmd.Dir = filepath.Dir(dir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("released CLI: %v\n%s", err, output)
}
}
generate(false)
files := map[string]string{
"internal/service/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"} {
writeFile(t, filepath.Join(dir, name), "package demo\n")
}
for _, name := range []string{"internal/handler/demo/uploadManual.go", "internal/handler/demo/callbackManual.go"} {
writeFile(t, filepath.Join(dir, name), "package demo\n")
}
for target, source := range files {
writeFile(t, filepath.Join(dir, target), readFile(t, filepath.Join("testdata/compatibility", source)))
}
before := map[string]string{}
for target := range files {
before[target] = readFile(t, filepath.Join(dir, target))
}
generate(false)
for target, content := range before {
if readFile(t, filepath.Join(dir, target)) != content {
t.Fatalf("overwrote %s", target)
}
}
generate(true)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-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 releaseCLI != "" {
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.Fatalf("module: %v\n%s", err, 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.Fatalf("not a fixed remote dependency: %+v", module)
}
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
}
}
func TestNestedDynamicAliases(t *testing.T) {
source := readFile(t, filepath.Join(frameworkRoot(t), "examples/contracts/compatibility.api"))
for _, typ := range []string{"[]interface{}", "map[string]interface{}", "map[string][]interface{}"} {
s, err := parseText(t, strings.Replace(source, "Data any", "Data "+typ, 1))
if err == nil {
_, err = Compile(s)
}
if err != nil {
t.Fatalf("%s: %v", typ, err)
}
}
}
func TestSwitchFromManualRequiresExplicitMigration(t *testing.T) {
root := frameworkRoot(t)
o := Options{API: filepath.Join(root, "examples/contracts/compatibility.api"), Dir: filepath.Join(t.TempDir(), "consumer"), Module: "example.com/compatibility", FrameworkDir: root}
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
// The hand-written source may contain business code. Never delete it to make
// the newly automatic handler compile; fail before touching generated files.
o.API = filepath.Join(o.Dir, "api/compatibility.api")
source := readFile(t, o.API)
source = strings.Replace(source, "@handlerMode manual\n @produces \"application/octet-stream\"", "", 1)
writeFile(t, o.API, source)
before := snapshot(t, o.Dir)
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "migrate and remove") {
t.Fatalf("unsafe mode switch: %v", err)
}
if !sameFiles(before, snapshot(t, o.Dir)) {
t.Fatal("failed mode switch mutated project")
}
}

View File

@@ -42,6 +42,9 @@ func Compile(spec *Spec) (*Compiled, error) {
}
definitions := map[string]Type{}
for _, t := range spec.Types {
if t.Name == "ContractScalars" && len(spec.Scalars) > 0 {
return nil, errors.New("ContractScalars is reserved when declaring scalars")
}
if !exportedName.MatchString(t.Name) {
return nil, fmt.Errorf("%s: type names must be UpperCamelCase", t.Position)
}
@@ -50,17 +53,24 @@ func Compile(spec *Spec) (*Compiled, error) {
}
definitions[t.Name] = t
}
scalarTypes, scalarDefs, err := compileScalars(spec, definitions)
if err != nil {
return nil, err
}
resolved := map[string]reflect.Type{}
resolving := map[string]bool{}
var resolve func(string, int) (reflect.Type, error)
var expression func(ast.Expr, int) (reflect.Type, error)
primitives := map[string]reflect.Type{"string": reflect.TypeOf(""), "bool": reflect.TypeOf(false), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0))}
primitives := map[string]reflect.Type{"any": reflect.TypeOf((*any)(nil)).Elem(), "string": reflect.TypeOf(""), "bool": reflect.TypeOf(false), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0))}
expression = func(e ast.Expr, depth int) (reflect.Type, error) {
if depth > 32 {
return nil, errors.New("type nesting exceeds 32")
}
switch e := e.(type) {
case *ast.Ident:
if t, ok := scalarTypes[e.Name]; ok {
return t, nil
}
if t, ok := primitives[e.Name]; ok {
return t, nil
}
@@ -140,7 +150,7 @@ func Compile(spec *Spec) (*Compiled, error) {
if err != nil {
return nil, err
}
b, err := contract.ForType(rt)
b, err := contract.Describe(rt, scalarDefs...)
if err != nil {
return nil, fmt.Errorf("%s: %w", t.Position, err)
}
@@ -217,12 +227,15 @@ func Compile(spec *Spec) (*Compiled, error) {
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.Status == 204) != (r.Response == "") {
if !r.Manual && (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 != "" {
return nil, errors.New("HEAD must not declare a response body")
}
if err := validateRouteExtensions(r); err != nil {
return nil, fmt.Errorf("%s: %w", r.Position, err)
}
op := openapi.Operation{Method: r.Method, Path: r.Path, OperationID: operationID(r), Summary: r.Summary, Responses: map[int]openapi.Response{}}
if r.Request != "" {
b, ok := c.Bindings[r.Request]
@@ -231,6 +244,9 @@ 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")
}
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")
}

View File

@@ -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)
@@ -124,6 +141,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
}
@@ -140,6 +158,7 @@ func decorateDocument(doc map[string]any, spec *Spec) {
}
}
op := doc["paths"].(map[string]any)[strings.Join(parts, "/")].(map[string]any)[r.Method].(map[string]any)
decorateRequestOptions(op, r)
if r.ResponseFormat != "" {
op["x-omni-response"] = r.ResponseFormat
}
@@ -181,15 +200,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])
@@ -206,7 +247,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 == "" {

View File

@@ -4,16 +4,21 @@ 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"`
Status int `json:"status"`
Code string `json:"code"`
Message string `json:"message"`
Position string `json:"-"`
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"`
Position string `json:"-"`
}
type AuthDefinition struct {
Name, Type, In, KeyName, Middleware, Description, BearerFormat, Position string

View 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)
}

View 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)
}
}
}

View File

@@ -0,0 +1,12 @@
package apigen
import "testing"
func TestErrorInheritanceConsumer(t *testing.T) {
testCatalogConsumer(t, "errorInheritance.api", "errorInheritance", map[string]string{
"internal/service/account/plain.go": "service.go",
"internal/service/account/raw.go": "emptyService.go",
"internal/service/account/cleared.go": "emptyService.go",
"internal/handler/account/boundedManual.go": "bounded.go",
})
}

View 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")
}
}

View File

@@ -19,7 +19,7 @@ import (
)
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
const minimumVersion = "v0.8.0-preview"
const minimumVersion = "v0.11.0-preview"
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct {
@@ -94,6 +94,16 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
return Output{}, err
}
o.Dir = directory
for _, r := range c.Spec.Routes {
if !r.Manual {
path := filepath.Join(directory, "internal/handler", r.Group, lower(r.Handler)+"Manual.go")
if _, err := os.Lstat(path); err == nil {
return Output{}, fmt.Errorf("manual handler %s exists; migrate and remove it explicitly before switching to automatic generation", path)
} else if !errors.Is(err, os.ErrNotExist) {
return Output{}, err
}
}
}
if err := resolveProfile(&o); err != nil {
return Output{}, err
}
@@ -130,13 +140,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, "httpx", "response.go")); err == nil {
if _, err := os.Stat(filepath.Join(path, "errors", "params.go")); err == nil {
localCurrent = true
}
}
}
if !localCurrent {
return Output{}, fmt.Errorf("existing framework dependency predates configurable responses; use %s or a current local replacement", minimumVersion)
return Output{}, fmt.Errorf("existing framework dependency predates error template parameter support; use %s or a current local replacement", minimumVersion)
}
}
if o.FrameworkDir != "" {
@@ -302,13 +312,43 @@ 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("
for i, d := range c.Spec.Scalars {
types += fmt.Sprintf("scalar%d %q;", i, d.GoImport)
}
types += ")\n"
registry := fmt.Sprintf("package types\nimport %q\nfunc ContractScalars()[]contract.Scalar{return []contract.Scalar{", frameworkModule+"/contract")
for i, d := range c.Spec.Scalars {
types += fmt.Sprintf("type %s = scalar%d.%s\n", d.Name, i, d.GoType)
registry += fmt.Sprintf("contract.ScalarOf[%s](%q,%q,%t),", d.Name, d.JSONInput, d.JSONOutput, d.Text)
}
registry += "}}\n"
put(generated, "internal/types/scalarsGen.go", generatedHeader+registry)
}
for _, t := range c.Spec.Types {
types += "type " + t.Name + " struct {\n"
for _, f := range t.Fields {
@@ -319,7 +359,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 {
@@ -327,10 +367,15 @@ 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.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)}\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"
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"
}
middlewareSet := map[string]bool{}
for _, r := range c.Spec.Routes {
for _, m := range r.Middleware {
@@ -347,7 +392,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
}
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)
routes += fmt.Sprintf("h%d := h%d.New(s%d.New(deps))\n", i, i, i)
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 {
if r.Group == group {
@@ -356,12 +401,16 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
}
packageName := filepath.Base(group)
text := "package " + packageName + "\nimport(\"context\"; \"fmt\";"
needsTypes, needsBinder := false, false
needsTypes, needsBinder, hasAutomatic := false, false, false
for _, r := range groupRoutes {
if r.Manual {
continue
}
hasAutomatic = true
needsTypes = needsTypes || r.Request != "" || r.Response != ""
needsBinder = needsBinder || r.Request != ""
needsBinder = needsBinder || (r.Request != "" && !r.CustomBinding)
}
text += fmt.Sprintf("%q;", frameworkModule+"/httpx")
text += fmt.Sprintf("%q;%q;", frameworkModule+"/httpx", o.Module+"/internal/servicecontext")
if needsTypes {
text += fmt.Sprintf("%q;", o.Module+"/internal/types")
}
@@ -371,18 +420,47 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
text += ")\n"
text += "type Service interface {\n"
for _, r := range groupRoutes {
text += signature(r) + "\n"
if !r.Manual {
text += signature(r) + "\n"
}
}
text += "}\ntype Handler struct {service Service}\nfunc New(s Service)*Handler {return &Handler{service:s}}\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"
for _, r := range groupRoutes {
name := upper(r.Handler)
if r.Request != "" {
text += fmt.Sprintf("var %sRequest = contract.MustNew[types.%s]()\n", lower(r.Handler), r.Request)
// 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,%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,%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)
}
routes += ")\n"
if r.Manual {
put(seeds, "internal/handler/"+group+"/"+lower(r.Handler)+"Manual.go", fmt.Sprintf("package %s\nimport(%q;apierrors %q)\n// %s owns raw/multipart binding and response output. h.deps holds project dependencies.\n// This file is preserved on regeneration.\nfunc(h *Handler)%s(c *httpx.Context)error{return apierrors.New(501,\"NOT_IMPLEMENTED\",\"manual handler is not implemented\")}\n", packageName, frameworkModule+"/httpx", frameworkModule+"/errors", name, name))
continue
}
if r.Request != "" && !r.CustomBinding {
scalarArgs := ""
if len(c.Spec.Scalars) > 0 {
scalarArgs = "types.ContractScalars()..."
}
text += fmt.Sprintf("var %sRequest = contract.MustNew[types.%s](%s)\n", lower(r.Handler), r.Request, scalarArgs)
}
text += fmt.Sprintf("func(h *Handler) %s(c *httpx.Context)error {\n", name)
call := "c.Context()"
if r.Request != "" {
text += fmt.Sprintf("var req types.%s\nif err:=%sRequest.Bind(c.Request(),httpx.RouteParams(c.Context()),&req);err!=nil{return err}\n", r.Request, lower(r.Handler))
if r.CustomBinding {
text += fmt.Sprintf("var req types.%s\nif err:=h.bind%s(c,&req);err!=nil{return err}\n", r.Request, name)
put(seeds, "internal/handler/"+group+"/"+lower(r.Handler)+"Bind.go", fmt.Sprintf("package %s\nimport(%q;%q;apierrors %q)\n// bind%s is project-owned; repeated generation preserves it.\nfunc(h *Handler)bind%s(c *httpx.Context,req *types.%s)error{return apierrors.New(501,\"BINDING_NOT_IMPLEMENTED\",\"custom binding is not implemented\")}\n", packageName, frameworkModule+"/httpx", o.Module+"/internal/types", frameworkModule+"/errors", name, name, r.Request))
} else {
limit := ""
if r.MaxBodyBytes > 0 {
limit = fmt.Sprintf(",%d", r.MaxBodyBytes)
}
text += fmt.Sprintf("var req types.%s\nif err:=%sRequest.Bind(c.Request(),httpx.RouteParams(c.Context()),&req%s);err!=nil{return err}\n", r.Request, lower(r.Handler), limit)
}
call += ", &req"
}
if r.Response != "" {
@@ -392,11 +470,6 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
} else {
text += fmt.Sprintf("if err:=h.service.%s(%s);err!=nil{return err};return c.NoContent()\n}\n", name, call)
}
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)
for _, m := range r.Middleware {
routes += fmt.Sprintf(",deps.Middlewares[%q]", m)
}
routes += ")\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")
@@ -417,6 +490,10 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
}
put(seeds, "internal/service/"+group+"/"+lower(r.Handler)+".go", service)
}
if !hasAutomatic {
text = strings.Replace(text, strconv.Quote(frameworkModule+"/httpx")+";", "", 1)
text = strings.Replace(text, "\"context\";", "", 1)
}
if !hasResponse(groupRoutes) {
text = strings.Replace(text, "\"fmt\";", "", 1)
}
@@ -464,7 +541,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
}
func hasResponse(routes []Route) bool {
for _, r := range routes {
if r.Response != "" {
if r.Response != "" && !r.Manual {
return true
}
}

View File

@@ -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`},

View File

@@ -16,8 +16,10 @@ import (
type Spec struct {
Title, Version, Service, Description string
Errors []ErrorDefinition
ErrorCatalogs []string
Auth []AuthDefinition
Responses []ResponseDefinition
Scalars []ScalarDefinition
Types []Type
Routes []Route
Sources map[string][]byte // relative to the entry API's directory
@@ -30,6 +32,9 @@ type Type struct {
}
type Field struct{ Name, Type, Tag string }
type Route struct {
Manual, CustomBinding bool
BodyMode, Produces string
MaxBodyBytes int64
Method, Path, Request, Response, Handler, Group, Summary string
Middleware []string
Auth string
@@ -199,6 +204,10 @@ func (p *parser) parse() error {
syntaxSeen := false
for p.skip(); p.tokens[p.at].kind != scanner.EOF; p.skip() {
switch p.peek() {
case "scalar":
if err := p.parseScalar(); err != nil {
return err
}
case "syntax":
if syntaxSeen {
return p.err("duplicate syntax")
@@ -258,6 +267,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
@@ -393,7 +406,15 @@ func (p *parser) parseType() error {
}
expression := ""
for p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" && p.tokens[p.at].kind != scanner.RawString && p.tokens[p.at].kind != scanner.EOF {
expression += p.next().text
if p.peek() == "interface" {
p.next()
if p.expect("{") != nil || p.expect("}") != nil {
return p.err("only empty interface{} is supported; use any for dynamic JSON")
}
expression += "any"
} else {
expression += p.next().text
}
}
if expression == "" {
return p.err("field type required; embedded types are not supported")
@@ -453,6 +474,28 @@ func (p *parser) parseService(server map[string]string) error {
}
seen[annotation] = true
switch annotation {
case "handlerMode":
mode, err := p.ident()
if err != nil {
return err
}
if mode != "manual" {
return p.err("handlerMode must be manual")
}
route.Manual = true
case "binding":
mode, err := p.ident()
if err != nil {
return err
}
if mode != "custom" {
return p.err("binding must be custom")
}
route.CustomBinding = true
case "produces":
route.Produces, e = p.quoted()
case "body":
e = p.parseBody(&route)
case "handler":
route.Handler, e = p.ident()
case "doc":
@@ -512,7 +555,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"] {
if route.Response == "" && !seen["status"] && !route.Manual {
route.Status = 204
}
p.spec.Routes = append(p.spec.Routes, route)

View File

@@ -0,0 +1,95 @@
package apigen
import (
"errors"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/contract"
"mime"
"strconv"
"strings"
)
func (p *parser) parseBody(r *Route) error {
props, err := p.metadata()
if err != nil {
return err
}
for key, value := range props {
switch key {
case "mode":
r.BodyMode = value
case "maxBytes":
r.MaxBodyBytes, err = strconv.ParseInt(value, 10, 64)
if err != nil || r.MaxBodyBytes <= 0 || r.MaxBodyBytes > 1<<40 {
return p.err("maxBytes must be 1..1099511627776 bytes")
}
default:
return fmt.Errorf("unknown body property %s", key)
}
}
if r.MaxBodyBytes == 0 {
r.MaxBodyBytes = contract.DefaultMaxBodyBytes
}
return nil
}
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")
}
if r.MaxBodyBytes > 0 && (r.Method == "get" || r.Method == "head") {
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")
}
}
if r.Manual && r.Status == 204 && r.Response != "" {
return errors.New("204 cannot declare a response")
}
return nil
}
func decorateRequestOptions(op map[string]any, r Route) {
if r.Manual {
op["x-omni-handler-mode"] = "manual"
}
if r.CustomBinding {
op["x-omni-binding"] = "custom"
}
if r.MaxBodyBytes > 0 {
op["x-omni-max-body-bytes"] = r.MaxBodyBytes
}
if r.BodyMode == "raw" || r.BodyMode == "multipart" {
media := "application/octet-stream"
schema := map[string]any{"type": "string", "format": "binary"}
if r.BodyMode == "multipart" {
media = "multipart/form-data"
schema = map[string]any{"type": "object", "additionalProperties": true}
}
op["requestBody"] = map[string]any{"required": true, "content": map[string]any{media: map[string]any{"schema": schema}}}
}
if r.Produces != "" {
schema := map[string]any{"type": "string"}
if r.Produces == "application/octet-stream" {
schema["format"] = "binary"
}
if r.Produces == "application/json" {
schema = map[string]any{}
}
op["responses"].(map[string]any)[strconv.Itoa(r.Status)] = map[string]any{"description": "Success", "content": map[string]any{r.Produces: map[string]any{"schema": schema}}}
}
}

View File

@@ -234,6 +234,9 @@ func TestResponseTemplateConsumer(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, releaseCLI, "api", "generate", "--api", api, "--dir", dir, "--module", "example.com/responseConsumer")
// The framework checkout intentionally enables local development mode.
// Run the installed CLI outside it to exercise remote-only consumption.
cmd.Dir = filepath.Dir(dir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("released CLI: %v\n%s", err, output)
}

View File

@@ -0,0 +1,74 @@
package apigen
import (
"fmt"
"git.yuntuo.hk/k2safe/go-omni/contract"
"golang.org/x/mod/module"
"reflect"
"strings"
)
type ScalarDefinition struct {
Name, GoImport, GoType, JSONInput, JSONOutput, Position string
Text bool
}
func (p *parser) parseScalar() error {
pos := p.next().position
name, err := p.ident()
if err != nil {
return err
}
p.skip()
props, err := p.metadata()
if err != nil {
return err
}
d := ScalarDefinition{Name: name, Position: pos}
for key, value := range props {
switch key {
case "goImport":
d.GoImport = value
case "goType":
d.GoType = value
case "jsonInput":
d.JSONInput = value
case "jsonOutput":
d.JSONOutput = value
case "text":
if value != "true" && value != "false" {
return p.err("text must be true or false")
}
d.Text = value == "true"
default:
return fmt.Errorf("%s: unknown scalar property %s", pos, key)
}
}
p.spec.Scalars = append(p.spec.Scalars, d)
return nil
}
func compileScalars(spec *Spec, types map[string]Type) (map[string]reflect.Type, []contract.Scalar, error) {
out := map[string]reflect.Type{}
var defs []contract.Scalar
goTypes := map[string]bool{}
for _, d := range spec.Scalars {
key := d.GoImport + "." + d.GoType
if d.Name == "ContractScalars" || !exportedName.MatchString(d.Name) || !exportedName.MatchString(d.GoType) || out[d.Name] != nil || types[d.Name].Name != "" || goTypes[key] {
return nil, nil, fmt.Errorf("%s: scalar names and Go types must be unique; names must be UpperCamelCase", d.Position)
}
if err := module.CheckImportPath(d.GoImport); err != nil || strings.Contains(d.GoImport, "\\") {
return nil, nil, fmt.Errorf("%s: invalid scalar goImport", d.Position)
}
// Unique stand-in types allow CLI schema checks without executing project code.
t := reflect.StructOf([]reflect.StructField{{Name: d.Name, Type: reflect.TypeOf(false)}})
def := contract.Scalar{Type: t, JSONInput: d.JSONInput, JSONOutput: d.JSONOutput, Text: d.Text}
probe := reflect.StructOf([]reflect.StructField{{Name: "Value", Type: t, Tag: `json:"value"`}})
if _, err := contract.Describe(probe, def); err != nil {
return nil, nil, fmt.Errorf("%s: %w", d.Position, err)
}
out[d.Name] = t
defs = append(defs, def)
goTypes[key] = true
}
return out, defs, nil
}

View File

@@ -0,0 +1,117 @@
package compatibility_test
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"example.com/compatibility/internal/handler"
"example.com/compatibility/internal/servicecontext"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/logx"
"io"
"mime/multipart"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestGeneratedCompatibility(t *testing.T) {
logger, err := logx.New(logx.Config{Writer: io.Discard})
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)
}
send := func(method, path, body, contentType string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
app.ServeHTTP(w, r)
return w
}
for _, id := range []string{`9223372036854775807`, `"9223372036854775807"`} {
w := send("POST", "/echo", `{"id":`+id+`,"data":{"n":9223372036854775807}}`, "application/json")
if w.Code != 200 || !strings.Contains(w.Body.String(), `"id":"9223372036854775807"`) || !strings.Contains(w.Body.String(), `"n":9223372036854775807`) {
t.Fatalf("scalar/dynamic: %d %s", w.Code, w.Body)
}
}
for _, body := range []string{`{"id":true,"data":null}`, `{"id":9223372036854775808,"data":null}`, `{"id":1,"data":{"x":1,"x":2}}`, `{"id":1,"data":null,"extra":1}`} {
if w := send("POST", "/echo", body, "application/json"); w.Code != 400 {
t.Fatalf("accepted %s: %d", body, w.Code)
}
}
payload := `{"id":1,"data":"` + strings.Repeat("x", (1<<20)+1) + `"}`
if w := send("POST", "/echo", payload, "application/json"); w.Code != 200 {
t.Fatalf("route limit not passed to Binder: %d", w.Code)
}
if w := send("POST", "/echo", `{"id":1,"data":"`+strings.Repeat("x", 2<<20)+`"}`, "application/json"); w.Code != 413 {
t.Fatalf("large JSON: %d", w.Code)
}
if w := send("GET", "/items/9223372036854775807", "", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"id":"9223372036854775807"`) {
t.Fatalf("text scalar: %d %s", w.Code, w.Body)
}
if w := send("POST", "/custom", `{"value":"hello"}`, "application/json"); w.Code != 200 || !strings.Contains(w.Body.String(), "HELLO") {
t.Fatalf("custom binder: %d %s", w.Code, w.Body)
}
raw := `{ "event": 1, "id": "original bytes" }`
mac := hmac.New(sha256.New, []byte("fixture-only-key"))
mac.Write([]byte(raw))
r := httptest.NewRequest("POST", "/callback", strings.NewReader(raw))
r.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil)))
w := httptest.NewRecorder()
app.ServeHTTP(w, r)
if w.Code != 200 || w.Body.String() != "accepted" {
t.Fatalf("raw callback: %d %s", w.Code, w.Body)
}
r = httptest.NewRequest("POST", "/callback", strings.NewReader(strings.Repeat("x", 129)))
r.ContentLength = -1
w = httptest.NewRecorder()
app.ServeHTTP(w, r)
if w.Code != 413 {
t.Fatalf("chunked body limit: %d %s", w.Code, w.Body)
}
temp := t.TempDir()
t.Setenv("TMPDIR", temp)
var form bytes.Buffer
writer := multipart.NewWriter(&form)
part, err := writer.CreateFormFile("file", "sample.bin")
if err != nil {
t.Fatal(err)
}
part.Write(bytes.Repeat([]byte("x"), 8192))
writer.Close()
w = send("POST", "/upload", form.String(), writer.FormDataContentType())
if w.Code != 200 || !strings.Contains(w.Body.String(), `"bytes":8192`) {
t.Fatalf("upload: %d %s", w.Code, w.Body)
}
entries, err := os.ReadDir(temp)
if err != nil || len(entries) != 0 {
t.Fatalf("multipart temp files leaked: %v %v", entries, err)
}
w = send("GET", "/download", "", "")
if w.Code != 200 || w.Body.Len() != 128<<10 || w.Header().Get("Content-Disposition") == "" {
t.Fatalf("download wrapped: %d %d", w.Code, w.Body.Len())
}
var doc map[string]any
if err = json.Unmarshal([]byte(handler.OpenAPIDocument), &doc); err != nil {
t.Fatal(err)
}
paths := doc["paths"].(map[string]any)
media := paths["/download"].(map[string]any)["get"].(map[string]any)["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)
if media["application/octet-stream"] == nil || media["application/json"] != nil {
t.Fatal("download schema is JSON")
}
}

View File

@@ -0,0 +1,16 @@
package files
import (
"git.yuntuo.hk/k2safe/go-omni/httpx"
"io"
"net/http"
"strings"
)
func (h *Handler) Download(c *httpx.Context) error {
return httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", `attachment; filename="demo.bin"`)
_, _ = io.Copy(w, strings.NewReader(strings.Repeat("x", 128<<10)))
}))(c)
}

View File

@@ -0,0 +1,16 @@
package demo
import (
"context"
"example.com/compatibility/internal/types"
)
func (s *Service) 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) {
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) {
return &types.CustomResp{Value: req.Value}, nil
}

View File

@@ -0,0 +1,58 @@
package demo
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"example.com/compatibility/internal/types"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"io"
"net/http"
"strings"
)
func (h *Handler) bindCustom(c *httpx.Context, req *types.CustomReq) error {
decoder := json.NewDecoder(c.Request().Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(req); err != nil {
return apierrors.BadRequest("INVALID_REQUEST", "invalid request").WithCause(err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return apierrors.BadRequest("INVALID_REQUEST", "one JSON value required").WithCause(err)
}
req.Value = strings.ToUpper(req.Value)
return nil
}
func (h *Handler) Upload(c *httpx.Context) error {
// The small memory threshold deliberately exercises disk spill and cleanup.
if err := c.Request().ParseMultipartForm(8); err != nil {
return err
}
file, _, err := c.Request().FormFile("file")
if err != nil {
return err
}
defer file.Close()
count, err := io.Copy(io.Discard, file)
if err != nil {
return err
}
return c.OK(map[string]any{"bytes": count})
}
func (h *Handler) Callback(c *httpx.Context) error {
raw, err := io.ReadAll(c.Request().Body)
if err != nil {
return err
}
// This fixed key belongs only to the test fixture. Applications inject secrets.
mac := hmac.New(sha256.New, []byte("fixture-only-key"))
mac.Write(raw)
signature, err := hex.DecodeString(c.Header("X-Signature"))
if err != nil || !hmac.Equal(signature, mac.Sum(nil)) {
return c.Text(http.StatusUnauthorized, "invalid signature")
}
return c.Text(http.StatusOK, "accepted")
}

View File

@@ -0,0 +1,92 @@
package consumer_test
import (
"context"
"errors"
"io"
"net/http/httptest"
"strings"
"testing"
"example.com/errorCatalog/internal/apierrors"
"example.com/errorCatalog/internal/handler"
"example.com/errorCatalog/internal/servicecontext"
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 := servicecontext.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")
}
}
}
}

View 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")
}

View File

@@ -0,0 +1,129 @@
package consumer_test
import (
"context"
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
"example.com/errorCatalog/internal/handler"
"example.com/errorCatalog/internal/servicecontext"
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 := servicecontext.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())
}
}
}

View File

@@ -0,0 +1 @@
package account

View 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 *Service) Plain(ctx context.Context, req *types.CheckRequest) (*types.CheckResponse, error) {
return check(req.Kind)
}
func (s *Service) Raw(ctx context.Context, req *types.CheckRequest) (*types.CheckResponse, error) {
return check(req.Kind)
}
func (s *Service) Cleared(ctx context.Context, req *types.CheckRequest) (*types.CheckResponse, error) {
return check(req.Kind)
}

View File

@@ -0,0 +1,149 @@
package consumer_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/servicecontext"
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 := servicecontext.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")
}
}
}
}

View File

@@ -252,7 +252,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" {
if 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" {

View File

@@ -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.8.0-preview"
const defaultFrameworkVersion = "v0.11.1-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.

View File

@@ -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"
@@ -25,17 +26,19 @@ type TLSConfig struct {
// Config controls the default runtime behavior of an App.
type Config struct {
Address string `json:"address"`
Transport string `json:"transport"`
TLS TLSConfig `json:"tls"`
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"`
Response response.Config `json:"response"`
ResponseEncoder response.Encoder `json:"-"`
Address string `json:"address"`
Transport string `json:"transport"`
TLS TLSConfig `json:"tls"`
HTTP HTTPConfig `json:"http"`
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:"-"`
}
func Default() Config {
@@ -50,6 +53,18 @@ func Default() Config {
// Validate checks configuration values before an application starts.
func (c Config) Validate() error {
if err := c.Errors.Validate(); err != nil {
return err
}
if c.ResponseEncoder == nil {
f, err := response.New(c.Response)
if err != nil {
return err
}
if err = f.ValidateErrors(c.Errors); err != nil {
return err
}
}
if err := c.Response.Validate(); err != nil {
return err
}
@@ -74,11 +89,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 +165,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 +206,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() }
}

View File

@@ -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,28 @@ func applyEnv(cfg Config, prefix string) (Config, error) {
cfg.Log.Environment = envString(prefix+"ENVIRONMENT", cfg.Log.Environment)
var err error
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
}

View 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)
}
}

View 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")
}
})
}
}

View File

@@ -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
View 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
View 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)
}
}
}

View File

@@ -19,7 +19,7 @@ import (
// strictly decoded, and limited to 1 MiB unless maxBytes is supplied. Query
// parameters never overwrite path/body/header values. form means URL query only.
func (b *Binder) Bind(r *http.Request, params map[string]string, target any, maxBytes ...int64) error {
if b == nil || r == nil {
if b == nil || r == nil || b.schemaOnly {
return apierrors.Internal(errors.New("contract: nil binder or request"))
}
v := reflect.ValueOf(target)
@@ -65,6 +65,10 @@ func (b *Binder) Bind(r *http.Request, params map[string]string, target any, max
return tooLarge()
}
if err != io.EOF {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
return tooLarge()
}
return invalid(errors.New("exactly one JSON value required"))
}
var ok bool
@@ -126,10 +130,16 @@ func tooLarge() error { return apierrors.New(413, "BODY_TOO_LARGE", "request bod
func fromStrings(n *node, values []string) (any, error) {
n = base(n)
if n.scalar != nil {
if len(values) != 1 {
return nil, errors.New("duplicate scalar parameter")
}
return scalarText(values[0]), nil
}
if n.typ.Kind() == reflect.Slice {
out := make([]any, 0, len(values))
for _, s := range values {
v, err := parseScalar(s, base(n.elem).typ)
v, err := fromStrings(n.elem, []string{s})
if err != nil {
return nil, err
}
@@ -158,6 +168,17 @@ func assignField(f field, dst reflect.Value, value any, present bool) error {
return checkField(f, dst)
}
func setValue(n *node, dst reflect.Value, value any) error {
if n.scalar != nil {
return n.scalar.decode(dst, value)
}
if n.typ.Kind() == reflect.Interface && n.typ.NumMethod() == 0 {
if value == nil {
dst.SetZero()
} else {
dst.Set(reflect.ValueOf(value))
}
return nil
}
if value == nil {
if n.typ.Kind() == reflect.Pointer {
dst.SetZero()

View File

@@ -23,10 +23,12 @@ var valuesValidator = validator.New()
// Binder is immutable after compilation and safe for concurrent requests.
type Binder struct {
root *node
typ reflect.Type
root *node
typ reflect.Type
schemaOnly bool
}
type node struct {
scalar *Scalar
typ reflect.Type
schema *openapi.Schema
fields []field
@@ -42,9 +44,11 @@ type field struct {
binding string
}
func New[T any]() (*Binder, error) { return ForType(reflect.TypeOf((*T)(nil)).Elem()) }
func MustNew[T any]() *Binder {
b, err := New[T]()
func New[T any](scalars ...Scalar) (*Binder, error) {
return ForType(reflect.TypeOf((*T)(nil)).Elem(), scalars...)
}
func MustNew[T any](scalars ...Scalar) *Binder {
b, err := New[T](scalars...)
if err != nil {
panic(err)
}
@@ -52,20 +56,36 @@ func MustNew[T any]() *Binder {
}
// ForType also lets generators check the exact runtime contract before writing files.
// The root must be a struct; recursive and custom-marshaling types are rejected.
func ForType(t reflect.Type) (*Binder, error) {
// The root must be a struct; custom marshalers require explicit scalar registration.
func ForType(t reflect.Type, scalars ...Scalar) (*Binder, error) { return build(t, false, scalars) }
// Describe compiles schemas without loading or executing application Go types.
// The returned Binder cannot bind requests; it is for generators only.
func Describe(t reflect.Type, scalars ...Scalar) (*Binder, error) { return build(t, true, scalars) }
func build(t reflect.Type, schemaOnly bool, scalars []Scalar) (*Binder, error) {
if t == nil || t.Kind() != reflect.Struct {
return nil, errors.New("contract: root must be a struct")
}
c := compiler{scalars: map[reflect.Type]Scalar{}}
for _, scalar := range scalars {
if err := scalar.validate(schemaOnly); err != nil {
return nil, err
}
if _, exists := c.scalars[scalar.Type]; exists {
return nil, fmt.Errorf("contract: duplicate scalar type %s", scalar.Type)
}
c.scalars[scalar.Type] = scalar
}
budget := 10000
n, err := compile(t, true, map[reflect.Type]bool{}, 0, &budget)
n, err := c.compile(t, true, map[reflect.Type]bool{}, 0, &budget)
if err != nil {
return nil, err
}
return &Binder{root: n, typ: t}, nil
return &Binder{root: n, typ: t, schemaOnly: schemaOnly}, nil
}
func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int, budget *int) (*node, error) {
func (c compiler) compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int, budget *int) (*node, error) {
*budget--
if *budget < 0 {
return nil, errors.New("contract: schema expansion exceeds 10000 nodes")
@@ -79,21 +99,32 @@ func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int,
stack[t] = true
defer delete(stack, t)
n := &node{typ: t, schema: &openapi.Schema{}}
if scalar, ok := c.scalars[t]; ok {
if root {
return nil, errors.New("contract: root cannot be a scalar")
}
n.scalar = &scalar
n.schema = scalar.inputSchema()
return n, nil
}
// Wire behavior must be described completely by these tags.
marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem()
unmarshaler := reflect.TypeOf((*json.Unmarshaler)(nil)).Elem()
if t.Implements(marshaler) || reflect.PointerTo(t).Implements(marshaler) || t.Implements(unmarshaler) || reflect.PointerTo(t).Implements(unmarshaler) {
return nil, errors.New("contract: custom JSON marshalers are unsupported")
if t.Kind() != reflect.Pointer && (t.Implements(marshaler) || reflect.PointerTo(t).Implements(marshaler) || t.Implements(unmarshaler) || reflect.PointerTo(t).Implements(unmarshaler)) {
return nil, errors.New("contract: custom JSON marshalers require explicit Scalar registration")
}
switch t.Kind() {
case reflect.Interface:
if t.NumMethod() != 0 {
return nil, errors.New("contract: only any / interface{} is supported")
}
case reflect.Pointer:
e, err := compile(t.Elem(), false, stack, depth+1, budget)
e, err := c.compile(t.Elem(), false, stack, depth+1, budget)
if err != nil {
return nil, err
}
n.elem = e
*n.schema = *e.schema
n.schema.Nullable = true
n.schema = nullableSchema(e.schema)
case reflect.String:
n.schema.Type = "string"
case reflect.Bool:
@@ -132,10 +163,10 @@ func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int,
if t.Kind() == reflect.Map && t.Key().Kind() != reflect.String {
return nil, errors.New("contract: map keys must be strings")
}
if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 {
if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 && c.scalars[t.Elem()].Type == nil {
return nil, errors.New("contract: byte slices use base64 in Go; use string for binary data")
}
e, err := compile(t.Elem(), false, stack, depth+1, budget)
e, err := c.compile(t.Elem(), false, stack, depth+1, budget)
if err != nil {
return nil, err
}
@@ -158,7 +189,7 @@ func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int,
if sf.PkgPath != "" || sf.Anonymous {
return nil, fmt.Errorf("contract: %s must be an exported named field", sf.Name)
}
f, err := compileField(sf, root, stack, depth+1, budget)
f, err := c.compileField(sf, root, stack, depth+1, budget)
if err != nil {
return nil, fmt.Errorf("contract: %s: %w", sf.Name, err)
}
@@ -182,7 +213,7 @@ func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int,
return n, nil
}
func compileField(sf reflect.StructField, root bool, stack map[reflect.Type]bool, depth int, budget *int) (field, error) {
func (c compiler) compileField(sf reflect.StructField, root bool, stack map[reflect.Type]bool, depth int, budget *int) (field, error) {
f := field{}
// Reject typos and unsupported tags instead of silently losing validation.
tags, err := ParseTags(string(sf.Tag))
@@ -218,11 +249,37 @@ func compileField(sf reflect.StructField, root bool, stack map[reflect.Type]bool
if (f.source == "header" && !headerName.MatchString(f.name)) || (f.source != "header" && !wireName.MatchString(f.name)) {
return f, errors.New("wire names must be lowerCamelCase (headers use HTTP names)")
}
n, err := compile(sf.Type, false, stack, depth, budget)
n, err := c.compile(sf.Type, false, stack, depth, budget)
if err != nil {
return f, err
}
f.value = n
opaque := base(n).scalar
isList := base(n).typ.Kind() == reflect.Slice && opaque == nil
if isList {
opaque = base(base(n).elem).scalar
}
if opaque != nil {
if isList && (f.source == "path" || f.source == "header") {
return f, errors.New("path/header values must be scalars")
}
if f.source != "json" && !opaque.Text {
return f, errors.New("custom scalar needs text: true and UnmarshalText for path/query/header")
}
if f.binding != "" {
return f, errors.New("custom scalar constraints belong in its decoder; use optional/pointer for presence and null")
}
for _, option := range parts[1:] {
if option != "optional" && option != "omitempty" {
return f, errors.New("custom scalar supports only optional/omitempty field options")
}
f.optional = true
}
if f.source == "path" && (f.optional || sf.Type.Kind() == reflect.Pointer) {
return f, errors.New("path fields must be required non-pointer values")
}
return f, nil
}
scalar := base(n).typ.Kind()
if f.source != "json" && !isScalar(scalar) && !(scalar == reflect.Slice && isScalar(base(base(n).elem).typ.Kind())) {
return f, errors.New("path/query/header values must be scalars or scalar slices")
@@ -370,7 +427,7 @@ func (b *Binder) ResponseSchema() (*openapi.Schema, error) {
return nil, errors.New("contract: response fields must use json tags")
}
}
return copySchema(b.root.schema), nil
return responseSchema(b.root), nil
}
func (b *Binder) Parameters() []openapi.Parameter {
var out []openapi.Parameter
@@ -382,7 +439,13 @@ func (b *Binder) Parameters() []openapi.Parameter {
if in == "form" {
in = "query"
}
out = append(out, openapi.Parameter{Name: f.name, In: in, Required: !f.optional && !f.hasDefault, Schema: copySchema(f.value.schema)})
schema := copySchema(f.value.schema)
if base(f.value).scalar != nil {
schema = &openapi.Schema{Type: "string"}
} else if base(f.value).typ.Kind() == reflect.Slice && base(base(f.value).elem).scalar != nil {
schema.Items = &openapi.Schema{Type: "string"}
}
out = append(out, openapi.Parameter{Name: f.name, In: in, Required: !f.optional && !f.hasDefault, Schema: schema})
}
return out
}

199
contract/scalar.go Normal file
View File

@@ -0,0 +1,199 @@
package contract
import (
"encoding"
"encoding/json"
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"git.yuntuo.hk/k2safe/go-omni/openapi"
)
// Scalar explicitly opts one application Go type into custom wire conversion.
// Input is a | separated union of scalar wire types; Output is one wire type.
// The value must implement json.Marshaler, its pointer json.Unmarshaler, and
// Text=true additionally requires encoding.TextUnmarshaler on the pointer.
// No process-global registry or mutation of other binders is involved.
type Scalar struct {
Type reflect.Type
JSONInput, JSONOutput string
Text bool
}
func ScalarOf[T any](input, output string, text bool) Scalar {
return Scalar{Type: reflect.TypeOf((*T)(nil)).Elem(), JSONInput: input, JSONOutput: output, Text: text}
}
type compiler struct {
scalars map[reflect.Type]Scalar
}
type scalarText string
func (s Scalar) validate(schemaOnly bool) error {
if s.Type == nil || s.Type.Kind() == reflect.Pointer || s.Type.Kind() == reflect.Interface {
return errors.New("contract: scalar requires a concrete non-pointer type")
}
seen := map[string]bool{}
for _, kind := range strings.Split(s.JSONInput, "|") {
kind = strings.TrimSpace(kind)
if wireSchema(kind) == nil || seen[kind] {
return fmt.Errorf("contract: invalid/duplicate scalar input %q", kind)
}
seen[kind] = true
}
if wireSchema(s.JSONOutput) == nil {
return fmt.Errorf("contract: invalid scalar output %q", s.JSONOutput)
}
if !schemaOnly {
if !s.Type.Implements(reflect.TypeOf((*json.Marshaler)(nil)).Elem()) || !reflect.PointerTo(s.Type).Implements(reflect.TypeOf((*json.Unmarshaler)(nil)).Elem()) {
return fmt.Errorf("contract: %s needs value MarshalJSON and pointer UnmarshalJSON", s.Type)
}
if s.Text && !reflect.PointerTo(s.Type).Implements(reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()) {
return fmt.Errorf("contract: %s needs UnmarshalText", s.Type)
}
}
return nil
}
func wireSchema(kind string) *openapi.Schema {
switch kind {
case "string":
return &openapi.Schema{Type: "string"}
case "bool":
return &openapi.Schema{Type: "boolean"}
case "int32", "int64":
return &openapi.Schema{Type: "integer", Format: kind}
case "float64":
return &openapi.Schema{Type: "number", Format: "double"}
}
return nil
}
func (s Scalar) inputSchema() *openapi.Schema {
var alternatives []*openapi.Schema
for _, kind := range strings.Split(s.JSONInput, "|") {
alternatives = append(alternatives, wireSchema(strings.TrimSpace(kind)))
}
if len(alternatives) == 1 {
return alternatives[0]
}
return &openapi.Schema{AnyOf: alternatives}
}
func (s Scalar) decode(dst reflect.Value, value any) (err error) {
// User decoders may fail; do not allow their panic to escape a binder.
defer func() {
if r := recover(); r != nil {
err = errors.New("custom scalar decoder panicked")
}
}()
if text, ok := value.(scalarText); ok {
decoder, ok := dst.Addr().Interface().(encoding.TextUnmarshaler)
if !s.Text || !ok {
return errors.New("text decoder not available")
}
return decoder.UnmarshalText([]byte(text))
}
allowed := false
for _, kind := range strings.Split(s.JSONInput, "|") {
if scalarAccepts(strings.TrimSpace(kind), value) {
allowed = true
break
}
}
if !allowed {
return errors.New("value does not match declared scalar input")
}
decoder, ok := dst.Addr().Interface().(json.Unmarshaler)
if !ok {
return errors.New("JSON decoder not available")
}
raw, err := json.Marshal(value) // json.Number preserves exact integer tokens.
if err != nil {
return err
}
return decoder.UnmarshalJSON(raw)
}
func scalarAccepts(kind string, value any) bool {
switch kind {
case "string":
_, ok := value.(string)
return ok
case "bool":
_, ok := value.(bool)
return ok
case "int32", "int64":
n, ok := value.(json.Number)
if !ok {
return false
}
bits := 64
if kind == "int32" {
bits = 32
}
_, err := strconv.ParseInt(string(n), 10, bits)
return err == nil
case "float64":
n, ok := value.(json.Number)
if !ok {
return false
}
_, err := strconv.ParseFloat(string(n), 64)
return err == nil
}
return false
}
func responseSchema(n *node) *openapi.Schema {
b := base(n)
if b.scalar != nil {
s := wireSchema(b.scalar.JSONOutput)
if n.typ.Kind() == reflect.Pointer {
s = nullableSchema(s)
}
return s
}
// Preserve the field's own constraints (including pointer range/enum and
// collection length) while changing only nested custom scalar wire types.
s := copySchema(n.schema)
if b.typ.Kind() == reflect.Slice {
s.Items = responseSchema(b.elem)
}
if b.typ.Kind() == reflect.Map {
s.AdditionalProperties = responseSchema(b.elem)
}
for _, f := range b.fields {
if f.source == "json" {
s.Properties[f.name] = responseSchema(f.value)
}
}
return s
}
// ValidateScalars checks the application's explicitly registered method contracts.
func ValidateScalars(scalars ...Scalar) error {
seen := map[reflect.Type]bool{}
for _, s := range scalars {
if err := s.validate(false); err != nil {
return err
}
if seen[s.Type] {
return fmt.Errorf("contract: duplicate scalar type %s", s.Type)
}
seen[s.Type] = true
}
return nil
}
// OpenAPI 3.0 nullable only applies alongside an explicit type. A union needs
// an explicit null alternative; an unconstrained dynamic schema already accepts null.
func nullableSchema(schema *openapi.Schema) *openapi.Schema {
s := copySchema(schema)
if s.Type != "" {
s.Nullable = true
return s
}
if len(s.AnyOf) > 0 {
s.AnyOf = append(s.AnyOf, &openapi.Schema{Type: "string", Nullable: true, Enum: []any{nil}})
}
return s
}

222
contract/scalar_test.go Normal file
View File

@@ -0,0 +1,222 @@
package contract
import (
"encoding/json"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
)
type flexibleID int64
func (v flexibleID) MarshalJSON() ([]byte, error) {
return json.Marshal(strconv.FormatInt(int64(v), 10))
}
func (v *flexibleID) UnmarshalJSON(b []byte) error {
var s string
if len(b) > 0 && b[0] == '"' {
if err := json.Unmarshal(b, &s); err != nil {
return err
}
} else {
s = string(b)
}
return v.UnmarshalText([]byte(s))
}
func (v *flexibleID) UnmarshalText(b []byte) error {
n, err := strconv.ParseInt(string(b), 10, 64)
if err == nil {
*v = flexibleID(n)
}
return err
}
func TestDynamicJSONAndCustomScalars(t *testing.T) {
type request struct {
ID flexibleID `json:"id"`
Dynamic any `json:"dynamic"`
IDs []flexibleID `json:"ids,optional"`
}
scalar := ScalarOf[flexibleID]("string|int64", "string", true)
b, err := New[request](scalar)
if err != nil {
t.Fatal(err)
}
for _, id := range []string{`"9223372036854775807"`, `9223372036854775807`} {
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"id":`+id+`,"dynamic":{"n":9223372036854775807},"ids":["1",2]}`))
r.Header.Set("Content-Type", "application/json")
var req request
if err = b.Bind(r, nil, &req); err != nil {
t.Fatal(err)
}
if req.ID != flexibleID(9223372036854775807) || req.Dynamic.(map[string]any)["n"] != json.Number("9223372036854775807") {
t.Fatalf("precision: %+v", req)
}
encoded, _ := json.Marshal(req)
if !strings.Contains(string(encoded), `"id":"9223372036854775807"`) {
t.Fatal(string(encoded))
}
}
for _, body := range []string{`{"id":true,"dynamic":null}`, `{"id":1.1,"dynamic":null}`, `{"id":9223372036854775808,"dynamic":null}`, `{"id":1,"dynamic":{"x":1,"x":2}}`, `{"id":1,"dynamic":null,"unexpected":1}`} {
r := httptest.NewRequest("POST", "/", strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
req := request{ID: 7}
if b.Bind(r, nil, &req) == nil || req.ID != 7 {
t.Fatalf("accepted or mutated %s", body)
}
}
for _, value := range []string{`null`, `[1,"x"]`, `false`, `"text"`} {
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"id":1,"dynamic":`+value+`}`))
r.Header.Set("Content-Type", "application/json")
if err := b.Bind(r, nil, &request{}); err != nil {
t.Fatal(err)
}
}
if _, err := New[request](); err == nil {
t.Fatal("implicit custom type registration")
}
response, err := b.ResponseSchema()
if err != nil {
t.Fatal(err)
}
if response.Properties["id"].Type != "string" || response.Properties["ids"].Items.Type != "string" || len(b.BodySchema().Properties["id"].AnyOf) != 2 {
t.Fatal("input/output schemas conflated")
}
type path struct {
ID flexibleID `path:"id"`
}
p, err := New[path](scalar)
if err != nil {
t.Fatal(err)
}
var req path
if err := p.Bind(httptest.NewRequest("GET", "/", nil), map[string]string{"id": "9223372036854775807"}, &req); err != nil {
t.Fatal(err)
}
if p.Parameters()[0].Schema.Type != "string" {
t.Fatal("text parameters need text schema")
}
if _, err := New[path](ScalarOf[flexibleID]("string|int64", "string", false)); err == nil {
t.Fatal("text support not explicit")
}
schemaOnly, err := Describe(reflect.TypeOf(request{}), scalar)
if err != nil {
t.Fatal(err)
}
if schemaOnly.Bind(httptest.NewRequest("POST", "/", nil), nil, &request{}) == nil {
t.Fatal("schema-only binder executed")
}
}
func TestScalarPointersCollectionsAndRegistration(t *testing.T) {
scalar := ScalarOf[flexibleID]("string|int64", "string", true)
type nullable struct {
ID *flexibleID `json:"id,optional"`
Values map[string]*flexibleID `json:"values,optional"`
}
b, err := New[nullable](scalar)
if err != nil {
t.Fatal(err)
}
for _, body := range []string{`{}`, `{"id":null}`, `{"id":1,"values":{"a":"2","b":null}}`} {
r := httptest.NewRequest("POST", "/", strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
if err := b.Bind(r, nil, &nullable{}); err != nil {
t.Fatal(err)
}
}
input := b.BodySchema().Properties["id"]
if len(input.AnyOf) != 3 || !input.AnyOf[2].Nullable || len(input.AnyOf[2].Enum) != 1 || input.AnyOf[2].Enum[0] != nil {
t.Fatal("nullable input union does not describe null")
}
out, err := b.ResponseSchema()
if err != nil {
t.Fatal(err)
}
if out.Properties["id"].Type != "string" || !out.Properties["id"].Nullable {
t.Fatal("nullable output type")
}
type query struct {
IDs []flexibleID `form:"ids"`
}
q, err := New[query](scalar)
if err != nil {
t.Fatal(err)
}
var req query
if err := q.Bind(httptest.NewRequest("GET", "/?ids=1&ids=2", nil), nil, &req); err != nil || !reflect.DeepEqual(req.IDs, []flexibleID{1, 2}) {
t.Fatalf("query scalar collection: %+v %v", req, err)
}
if q.Parameters()[0].Schema.Items.Type != "string" {
t.Fatal("text array schema")
}
if _, err := New[query](ScalarOf[flexibleID]("string|int64", "string", false)); err == nil {
t.Fatal("array bypassed text declaration")
}
if err := ValidateScalars(ScalarOf[int64]("int64", "string", true)); err == nil {
t.Fatal("missing methods accepted")
}
if err := ValidateScalars(scalar, scalar); err == nil {
t.Fatal("duplicate registration")
}
type strict struct {
ID int64 `json:"id"`
}
strictBinder := MustNew[strict]()
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"id":"1"}`))
r.Header.Set("Content-Type", "application/json")
if strictBinder.Bind(r, nil, &strict{}) == nil {
t.Fatal("other binder globally loosened")
}
}
type panicScalar string
func (v panicScalar) MarshalJSON() ([]byte, error) { return json.Marshal(string(v)) }
func (v *panicScalar) UnmarshalJSON([]byte) error { panic("decoder failure") }
func TestScalarPanicIsBindingError(t *testing.T) {
type req struct {
Value panicScalar `json:"value"`
}
b := MustNew[req](ScalarOf[panicScalar]("string", "string", false))
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"value":"bad"}`))
r.Header.Set("Content-Type", "application/json")
dst := req{Value: "original"}
if err := b.Bind(r, nil, &dst); err == nil || dst.Value != "original" {
t.Fatalf("decoder panic: %+v %v", dst, err)
}
}
func TestLimitedJSONTrailingWhitespace(t *testing.T) {
type req struct {
Value any `json:"value"`
}
b := MustNew[req]()
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"value":null}`+strings.Repeat(" ", 65)))
r.Header.Set("Content-Type", "application/json")
r.ContentLength = -1
r.Body = http.MaxBytesReader(httptest.NewRecorder(), r.Body, 64)
err := b.Bind(r, nil, &req{}, 64)
if apierrors.From(err).Status != 413 {
t.Fatalf("tail limit error: %v", err)
}
}
func TestResponseSchemaPreservesOrdinaryPointerConstraints(t *testing.T) {
type dto struct {
Count *int `json:"count,optional,range=[1:10]"`
Choice *string `json:"choice,options=a|b"`
Values *[]int `json:"values,optional" binding:"max=3"`
}
b := MustNew[dto]()
response, err := b.ResponseSchema()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(response, b.BodySchema()) {
t.Fatalf("changed ordinary response constraints: %#v", response)
}
}

View File

@@ -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
View 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
View 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")
}
}

View File

@@ -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 {
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)
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)
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 {

View 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
View 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
View 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
View 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)
}
}
}

View File

@@ -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
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
View 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
View 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
View 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}
}

View File

@@ -0,0 +1,29 @@
// Package ids demonstrates an application-owned scalar. The framework does not
// impose this ID policy on any other type or project.
package ids
import (
"encoding/json"
"strconv"
)
type ID int64
func (id ID) MarshalJSON() ([]byte, error) { return json.Marshal(strconv.FormatInt(int64(id), 10)) }
func (id *ID) UnmarshalJSON(raw []byte) error {
value := string(raw)
if len(raw) > 0 && raw[0] == '"' {
if err := json.Unmarshal(raw, &value); err != nil {
return err
}
}
return id.UnmarshalText([]byte(value))
}
func (id *ID) UnmarshalText(raw []byte) error {
value, err := strconv.ParseInt(string(raw), 10, 64)
if err != nil {
return err
}
*id = ID(value)
return nil
}

View File

@@ -0,0 +1,75 @@
// This example only uses local HTTP and bounded streaming; it has no database.
package main
import (
"io"
"log"
"net/http"
"strings"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/contract"
"git.yuntuo.hk/k2safe/go-omni/examples/compatibility/ids"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
type echoRequest struct {
ID ids.ID `json:"id"`
Data any `json:"data"`
}
func main() {
app := omni.New(omni.WithLogger(true))
defer app.Close()
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Example", "compatibility")
next.ServeHTTP(w, r)
})
}))
app.GET("/legacy", httpx.WrapHandler(http.HandlerFunc(http.NotFound)))
binder := contract.MustNew[echoRequest](contract.ScalarOf[ids.ID]("string|int64", "string", true))
app.POST("/echo", func(c *httpx.Context) error {
var req echoRequest
if err := binder.Bind(c.Request(), nil, &req); err != nil {
return err
}
return c.OK(req)
})
app.HandleWithBodyLimit("POST", "/upload", 10<<20, nil, func(c *httpx.Context) error {
reader, err := c.Request().MultipartReader()
if err != nil {
return omni.BadRequest("INVALID_MULTIPART", "multipart body required")
}
var total int64
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
return err
}
// Replace io.Discard with a streaming storage writer. Do not trust file names
// as filesystem paths. io.Copy never buffers the complete uploaded file.
n, copyErr := io.Copy(io.Discard, part)
closeErr := part.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
total += n
}
return c.OK(map[string]int64{"bytes": total})
})
app.GET("/download", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", `attachment; filename="example.txt"`)
_, _ = io.Copy(w, strings.NewReader("A standard Handler keeps its original download response.\n"))
})))
if err := app.Run("127.0.0.1:8080"); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,64 @@
syntax = "v1"
scalar BusinessID (
goImport: "git.yuntuo.hk/k2safe/go-omni/examples/compatibility/ids"
goType: ID
jsonInput: string | int64
jsonOutput: string
text: true
)
type EchoReq {
ID BusinessID `json:"id"`
Data any `json:"data"`
Legacy interface{} `json:"legacy,optional"`
}
type EchoResp {
ID BusinessID `json:"id"`
Data any `json:"data"`
}
type ItemReq {
ID BusinessID `path:"id"`
}
type CustomReq {
Value string `json:"value"`
}
type CustomResp {
Value string `json:"value"`
}
@server (group: demo)
service compatibility-api {
@body (maxBytes: 2097152)
@handler echo
post /echo (EchoReq) returns (EchoResp)
@handler item
get /items/:id (ItemReq) returns (EchoResp)
@binding custom
@body (maxBytes: 512)
@handler custom
post /custom (CustomReq) returns (CustomResp)
@handlerMode manual
@body (mode: multipart
maxBytes: 65536)
@handler upload
post /upload
@handlerMode manual
@body (mode: raw
maxBytes: 128)
@produces "text/plain"
@handler callback
post /callback
}
@server (group: files)
service compatibility-api {
@handlerMode manual
@produces "application/octet-stream"
@handler download
get /download
}

View 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
}

View 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)
}

View 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)
}

View File

@@ -13,8 +13,8 @@
"internal/apierrors/errorsGen.go": "20e94bbc90ae9f4330de4c25b9a550026c4c352502719a643ede3e8912a6566e",
"internal/handler/openapiGen.go": "e4130f5c7494f7db4830bfef203fa8154a9cd087c95954f64c29d74693ead433",
"internal/handler/responseGen.go": "d742efc2a3d1d168fae7849e905297bd440c3d6b3802b6150f494c07e6097f4f",
"internal/handler/routesGen.go": "0ff429a8aa7f8643e0e844f7c43a63a1f82cca5ce6bfe917f06edd860ebe923f",
"internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e",
"internal/handler/routesGen.go": "4e0c6879cea40e9c0155439fa1fce2cd19b5f83ea6e304523c41f8dc9c3a28e0",
"internal/handler/user/handlersGen.go": "1808afc8e4e8ca9785a8552eff64fad39bd5fe4e6e92971f35afc9006e23d76e",
"internal/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00",
"internal/types/typesGen.go": "3ab2ce9ffe467c3ea53ac3d1fec283ecebc87cef57fd592d08dca991fd964876"
}

View File

@@ -17,7 +17,7 @@ func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) erro
if deps.Middlewares["Auth"] == nil {
return fmt.Errorf("missing middleware Auth")
}
h0 := h0.New(s0.New(deps))
h0 := h0.New(s0.New(deps), deps)
httpx.HandleWithResponse(router, "POST", "/api/v1/users", responseEncoder("", false), h0.CreateUser, deps.Middlewares["Auth"])
httpx.HandleWithResponse(router, "GET", "/api/v1/users/:id", responseEncoder("", false), h0.GetUser, deps.Middlewares["Auth"])
httpx.HandleWithResponse(router, "GET", "/api/v1/users", responseEncoder("", false), h0.ListUsers, deps.Middlewares["Auth"])

View File

@@ -3,6 +3,7 @@ package user
import (
"context"
"example.com/userService/internal/servicecontext"
"example.com/userService/internal/types"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/contract"
@@ -17,9 +18,18 @@ type Service interface {
DeleteUser(ctx context.Context, req *types.UserIDReq) error
ExportUser(ctx context.Context, req *types.UserIDReq) (*types.ExportResp, error)
}
type Handler struct{ service Service }
type Handler struct {
service Service
deps *servicecontext.Context
}
func New(s Service) *Handler { return &Handler{service: s} }
func New(s Service, deps ...*servicecontext.Context) *Handler {
h := &Handler{service: s}
if len(deps) > 0 {
h.deps = deps[0]
}
return h
}
var createUserRequest = contract.MustNew[types.CreateUserReq]()

60
httpx/body.go Normal file
View File

@@ -0,0 +1,60 @@
package httpx
import (
"errors"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/response"
"net/http"
)
// LimitBody is useful for handwritten routes. Built-in App.HandleWithBodyLimit
// additionally installs the reader cap before global middleware can read it.
func LimitBody(maxBytes int64) Middleware {
if maxBytes <= 0 || maxBytes > 1<<40 {
panic("httpx: body limit must be 1..1 TiB")
}
return func(next Handler) Handler {
return func(c *Context) error {
r := c.Request()
if r.ContentLength > maxBytes {
return bodyTooLarge()
}
if r.Body != nil {
r.Body = http.MaxBytesReader(c.Raw(), r.Body, maxBytes)
}
defer CleanupMultipart(r)
err := next(c)
var limitErr *http.MaxBytesError
if errors.As(err, &limitErr) {
return bodyTooLarge()
}
return err
}
}
}
func bodyTooLarge() error { return apierrors.New(413, "BODY_TOO_LARGE", "request body exceeds limit") }
// CleanupMultipart removes disk spill files created by ParseMultipartForm.
// Callers still close individual multipart files. Streaming MultipartReader
// handlers own any files they create themselves.
func CleanupMultipart(r *http.Request) {
if r != nil && r.MultipartForm != nil {
_ = r.MultipartForm.RemoveAll()
}
}
type BodyLimitRegistrar interface {
HandleWithBodyLimit(string, string, int64, response.Encoder, Handler, ...Middleware)
}
func HandleWithBodyLimit(router interface {
Handle(string, string, Handler, ...Middleware)
}, method, path string, maxBytes int64, encoder response.Encoder, handler Handler, middleware ...Middleware) {
if r, ok := router.(BodyLimitRegistrar); ok {
r.HandleWithBodyLimit(method, path, maxBytes, encoder, handler, middleware...)
return
}
// Custom registrars without the early hook cap at route middleware entry.
all := append([]Middleware{LimitBody(maxBytes)}, middleware...)
HandleWithResponse(router, method, path, encoder, handler, all...)
}

88
httpx/bridge.go Normal file
View File

@@ -0,0 +1,88 @@
package httpx
import (
"context"
"net/http"
)
type bridgeState struct{ parent *Context }
// WrapHandler runs an existing standard handler inside the App middleware chain.
// It preserves the original response, including downloads and streaming output.
func WrapHandler(handler http.Handler) Handler {
if handler == nil {
panic("httpx: nil HTTP handler")
}
return func(c *Context) error {
r := bridgeRequest(c)
defer CleanupMultipart(r)
handler.ServeHTTP(c.Raw(), r)
return nil
}
}
// WrapMiddleware adapts synchronous standard HTTP middleware. The middleware is
// constructed once per chain, preserving its shared state across requests.
// Errors from next are encoded before unwinding so response-transforming
// middleware sees them. Preserve the incoming request context (derive with
// WithContext), and never call next after ServeHTTP returns, as with net/http.
func WrapMiddleware(middleware func(http.Handler) http.Handler) Middleware {
if middleware == nil {
panic("httpx: nil HTTP middleware")
}
return func(next Handler) Handler {
// Each nested bridge has its own key, including repeated use of one adapter.
key := &bridgeState{}
wrapped := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
state, ok := r.Context().Value(key).(*bridgeState)
if !ok {
panic("httpx: standard middleware must preserve the incoming request context")
}
c := state.parent
child := c.Clone(r.Context(), w)
child.request = r
if c.Responded() {
child.writer.(interface{ inheritCommit(int) }).inheritCommit(c.Status())
}
defer CleanupMultipart(r)
defer func() {
c.values = child.values
c.encoder = child.encoder
c.errorRegistry = child.errorRegistry
c.failure = child.failure
}()
if err := next(child); err != nil {
child.RecordError(err)
if !c.Responded() {
child.WriteError(err)
}
}
}))
if wrapped == nil {
panic("httpx: HTTP middleware returned nil handler")
}
return func(c *Context) error {
r := bridgeRequest(c).WithContext(context.WithValue(c.Context(), key, &bridgeState{parent: c}))
defer CleanupMultipart(r)
wrapped.ServeHTTP(c.Raw(), r)
return nil
}
}
}
// WrapFuncMiddleware supports middleware shaped as func(http.HandlerFunc) http.HandlerFunc.
func WrapFuncMiddleware(middleware func(http.HandlerFunc) http.HandlerFunc) Middleware {
if middleware == nil {
panic("httpx: nil HTTP middleware")
}
return WrapMiddleware(func(next http.Handler) http.Handler { return middleware(next.ServeHTTP) })
}
// Populate Go 1.22 path values without mutating a parent router's request maps.
func bridgeRequest(c *Context) *http.Request {
r := c.Request().Clone(c.Context())
for name, value := range c.params {
r.SetPathValue(name, value)
}
return r
}

363
httpx/bridge_test.go Normal file
View File

@@ -0,0 +1,363 @@
package httpx_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func TestLegacyResponseTrackingAndMiddleware(t *testing.T) {
var logs bytes.Buffer
closeLog, err := logx.Setup(logx.Config{Writer: &logs, Format: "json"})
if err != nil {
t.Fatal(err)
}
defer closeLog()
app := omni.New(omni.WithLogger(true))
app.GET("/legacy", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })))
app.GET("/late", func(c *httpx.Context) error {
c.Raw().WriteHeader(404)
_, _ = c.Raw().Write([]byte("original"))
return errors.New("late error")
})
app.GET("/panic", func(c *httpx.Context) error { _, _ = c.Raw().Write([]byte("partial")); panic("late panic") })
for _, tc := range []struct {
path string
status int
body string
}{{"/legacy", 404, "404 page not found\n"}, {"/late", 404, "original"}, {"/panic", 200, "partial"}} {
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("GET", tc.path, nil))
if w.Code != tc.status || w.Body.String() != tc.body {
t.Fatalf("%s: %d %q", tc.path, w.Code, w.Body.String())
}
}
count := 0
for _, line := range strings.Split(logs.String(), "\n") {
var e map[string]any
if json.Unmarshal([]byte(line), &e) != nil || e["msg"] != "http.request" {
continue
}
count++
if e["path"] != "/panic" && e["status"] != float64(404) {
t.Fatalf("wrong log: %s", line)
}
}
if count != 3 {
t.Fatalf("access logs: %d", count)
}
type key struct{}
app = omni.New()
app.Use(httpx.WrapFuncMiddleware(func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Before", "yes")
next(w, r.WithContext(context.WithValue(r.Context(), key{}, "identity")))
}
}))
app.GET("/context", func(c *httpx.Context) error {
if c.Context().Value(key{}) != "identity" || c.RequestID() == "" {
t.Error("lost context")
}
return omni.NotFound("MISSING", "missing")
})
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("GET", "/context", nil))
if w.Code != 404 || w.Header().Get("X-Before") != "yes" || strings.Count(w.Body.String(), "MISSING") != 1 {
t.Fatalf("middleware response: %d %s", w.Code, w.Body)
}
}
type plainWriter struct {
header http.Header
status int
}
func (w *plainWriter) Header() http.Header { return w.header }
func (w *plainWriter) WriteHeader(status int) { w.status = status }
func (w *plainWriter) Write(p []byte) (int, error) { return len(p), nil }
func TestWriterCapabilitiesAndInformationalStatus(t *testing.T) {
w := &plainWriter{header: http.Header{}}
c := httpx.NewContext(httptest.NewRequest("GET", "/", nil), w, nil)
if _, ok := c.Raw().(http.Flusher); ok {
t.Fatal("invented Flusher")
}
if _, ok := c.Raw().(http.Hijacker); ok {
t.Fatal("invented Hijacker")
}
if err := http.NewResponseController(c.Raw()).Flush(); !errors.Is(err, http.ErrNotSupported) {
t.Fatalf("Flush: %v", err)
}
c.Raw().WriteHeader(103)
if c.Responded() {
t.Fatal("103 committed final response")
}
c.Raw().WriteHeader(201)
c.Raw().WriteHeader(500)
if !c.Responded() || c.Status() != 201 || w.status != 201 {
t.Fatal("first final status not preserved")
}
recorder := httptest.NewRecorder()
c = httpx.NewContext(httptest.NewRequest("GET", "/", nil), recorder, nil)
if err := http.NewResponseController(c.Raw()).Flush(); err != nil {
t.Fatal(err)
}
if !c.Responded() || c.Status() != 200 || !recorder.Flushed {
t.Fatal("flush not tracked")
}
}
func TestLegacyStreamingAcrossTransports(t *testing.T) {
for _, transport := range []string{omni.TransportNetHTTP, omni.TransportNative} {
t.Run(transport, func(t *testing.T) {
release := make(chan struct{})
defer close(release)
app := omni.New(omni.WithTransport(transport))
app.GET("/stream", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = io.WriteString(w, "first\n")
if err := http.NewResponseController(w).Flush(); err != nil {
t.Error(err)
return
}
select {
case <-release:
case <-r.Context().Done():
}
})))
app.GET("/file", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", `attachment; filename="data.bin"`)
_, _ = io.Copy(w, strings.NewReader(strings.Repeat("x", 2<<20)))
})))
app.HandleWithBodyLimit("POST", "/bounded", 32, nil, func(c *httpx.Context) error {
_, err := io.Copy(io.Discard, c.Request().Body)
if err != nil {
return err
}
return c.NoContent()
})
listener, 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 <- app.ServeContext(ctx, listener) }()
defer func() {
cancel()
select {
case <-done:
case <-time.After(15 * time.Second):
t.Error("shutdown timed out")
}
}()
client := &http.Client{Timeout: 3 * time.Second}
defer client.CloseIdleConnections()
for _, body := range []io.Reader{strings.NewReader(strings.Repeat("x", 33)), io.NopCloser(strings.NewReader(strings.Repeat("x", 33)))} {
res, err := client.Post("http://"+listener.Addr().String()+"/bounded", "application/octet-stream", body)
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(io.Discard, res.Body)
res.Body.Close()
if res.StatusCode != 413 {
t.Fatalf("real transport body limit: %d", res.StatusCode)
}
}
res, err := client.Get("http://" + listener.Addr().String() + "/stream")
if err != nil {
t.Fatal(err)
}
first := make([]byte, 6)
_, err = io.ReadFull(res.Body, first)
res.Body.Close()
if err != nil || string(first) != "first\n" {
t.Fatalf("stream was buffered: %s %v", first, err)
}
res, err = client.Get("http://" + listener.Addr().String() + "/file")
if err != nil {
t.Fatal(err)
}
size, err := io.Copy(io.Discard, res.Body)
res.Body.Close()
if err != nil || size != 2<<20 || res.Header.Get("Content-Disposition") == "" {
t.Fatalf("file: %d %v", size, err)
}
})
}
}
func TestStandardMiddlewareStateAndShortCircuit(t *testing.T) {
constructions, calls := 0, 0
app := omni.New()
defer app.Close()
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
constructions++
count := 0
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count++
if count == 2 {
http.Error(w, "denied", 401)
return
}
next.ServeHTTP(w, r)
})
}))
app.GET("/", func(c *httpx.Context) error { calls++; return c.NoContent() })
for _, status := range []int{204, 401, 204} {
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
if w.Code != status {
t.Fatalf("got %d want %d", w.Code, status)
}
}
if constructions != 1 || calls != 2 {
t.Fatalf("middleware lifecycle: %d constructions, %d calls", constructions, calls)
}
}
func TestStandardMiddlewareTransformsErrorAndPreservesCancellation(t *testing.T) {
app := omni.New()
defer app.Close()
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// A legacy middleware may buffer its own small response. The bridge must
// report its eventual physical status, and encode errors before it unwinds.
temp := httptest.NewRecorder()
next.ServeHTTP(temp, r)
if temp.Code != 404 {
t.Errorf("inner error not encoded: %d", temp.Code)
}
w.WriteHeader(409)
_, _ = io.WriteString(w, strings.ToUpper(temp.Body.String()))
})
}))
app.GET("/", func(c *httpx.Context) error {
if !errors.Is(c.Context().Err(), context.Canceled) {
t.Error("cancellation lost")
}
return omni.NotFound("missing", "missing")
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("GET", "/", nil).WithContext(ctx))
if w.Code != 409 || !strings.Contains(w.Body.String(), "MISSING") {
t.Fatalf("%d %s", w.Code, w.Body)
}
}
func TestRouteBodyLimitBeforeMiddleware(t *testing.T) {
for _, length := range []int64{-1, 33} {
t.Run(fmt.Sprint(length), func(t *testing.T) {
app := omni.New()
defer app.Close()
read := 0
app.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
body, err := io.ReadAll(c.Request().Body)
read = len(body)
if err != nil {
return err
}
return next(c)
}
})
app.Group("/api").HandleWithBodyLimit("POST", "/upload", 32, nil, func(c *httpx.Context) error { t.Error("called oversized handler"); return nil })
r := httptest.NewRequest("POST", "/api/upload", strings.NewReader(strings.Repeat("x", 33)))
r.ContentLength = length
w := httptest.NewRecorder()
app.ServeHTTP(w, r)
if w.Code != 413 || read > 32 {
t.Fatalf("limit: status=%d read=%d", w.Code, read)
}
})
}
}
func TestMultipartCleanupOnPanic(t *testing.T) {
temp := t.TempDir()
t.Setenv("TMPDIR", temp)
var body bytes.Buffer
form := multipart.NewWriter(&body)
part, err := form.CreateFormFile("file", "x.bin")
if err != nil {
t.Fatal(err)
}
_, _ = part.Write(bytes.Repeat([]byte("x"), 4096))
_ = form.Close()
app := omni.New()
defer app.Close()
app.HandleWithBodyLimit("POST", "/", 8192, nil, httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1); err != nil {
t.Error(err)
return
}
panic("after disk spill")
})))
r := httptest.NewRequest("POST", "/", &body)
r.Header.Set("Content-Type", form.FormDataContentType())
w := httptest.NewRecorder()
app.ServeHTTP(w, r)
entries, err := os.ReadDir(temp)
if w.Code != 500 || err != nil || len(entries) != 0 {
t.Fatalf("cleanup: %d %v %v", w.Code, entries, err)
}
}
func TestStandardMiddlewareCommitsBeforeCallingNext(t *testing.T) {
app := omni.New()
defer app.Close()
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(202)
_, _ = io.WriteString(w, "accepted")
next.ServeHTTP(w, r)
})
}))
app.GET("/", func(c *httpx.Context) error {
if !c.Responded() || c.Status() != 202 {
t.Error("lost middleware commit")
}
return errors.New("late failure")
})
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
if w.Code != 202 || w.Body.String() != "accepted" {
t.Fatalf("late append: %d %q", w.Code, w.Body.String())
}
}
func TestBridgeStandardPathValues(t *testing.T) {
app := omni.New()
defer app.Close()
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.PathValue("itemId") != "abc" {
t.Error("middleware path value lost")
}
next.ServeHTTP(w, r)
})
}))
app.GET("/items/:itemId", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, r.PathValue("itemId")) })))
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("GET", "/items/abc", nil))
if w.Body.String() != "abc" {
t.Fatal("handler path value lost")
}
}

View File

@@ -3,6 +3,7 @@ package httpx
import (
"context"
"encoding/json"
"errors"
"net/http"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
@@ -87,13 +88,15 @@ func RoutePattern(ctx context.Context) string {
// only standard-library HTTP types, so it remains usable when the network
// implementation is replaced.
type Context struct {
request *http.Request
writer http.ResponseWriter
params map[string]string
values map[string]any
responded bool
status int
encoder response.Encoder
request *http.Request
writer http.ResponseWriter
params map[string]string
values map[string]any
responded bool
status int
encoder response.Encoder
errorRegistry *apierrors.Registry
failure *apierrors.APIError
}
// NewContext creates a transport-neutral GoOmni context.
@@ -101,12 +104,15 @@ func NewContext(request *http.Request, writer http.ResponseWriter, params map[st
if request == nil {
request = &http.Request{}
}
return &Context{
c := &Context{
request: request,
writer: writer,
params: params,
values: make(map[string]any),
}
if writer != nil {
c.writer = trackWriter(writer, c)
}
return c
}
func (c *Context) Request() *http.Request { return c.request }
@@ -120,8 +126,8 @@ func (c *Context) Context() context.Context {
return c.request.Context()
}
// Raw returns the response writer supplied by the active network adapter.
// Prefer the transport-neutral methods on Context for portable handlers.
// Raw returns a streaming response writer that tracks commits and status.
// Optional HTTP capabilities are preserved only when the underlying writer supports them.
func (c *Context) Raw() http.ResponseWriter { return c.writer }
// Clone gives a buffering middleware its own response state and values map.
@@ -129,6 +135,7 @@ func (c *Context) Raw() http.ResponseWriter { return c.writer }
func (c *Context) Clone(ctx context.Context, writer http.ResponseWriter) *Context {
clone := NewContext(c.request.Clone(ctx), writer, c.params)
clone.encoder = c.encoder
clone.errorRegistry = c.errorRegistry
for key, value := range c.values {
clone.values[key] = value
}
@@ -146,7 +153,6 @@ func (c *Context) WriteResponse(status int, headers http.Header, body []byte) er
for key, values := range headers {
c.writer.Header()[key] = append([]string(nil), values...)
}
c.responded, c.status = true, status
c.writer.WriteHeader(status)
if len(body) > 0 {
if _, err := c.writer.Write(body); err != nil {
@@ -216,8 +222,6 @@ func (c *Context) JSON(status int, value any) error {
if err != nil {
return apierrors.Internal(err)
}
c.responded = true
c.status = status
c.writer.Header().Set("Content-Type", "application/json; charset=utf-8")
c.writer.WriteHeader(status)
if _, err := c.writer.Write(append(data, '\n')); err != nil {
@@ -243,8 +247,6 @@ func (c *Context) NoContent() error {
if c.writer == nil {
return apierrors.Internal(nil)
}
c.responded = true
c.status = http.StatusNoContent
c.writer.WriteHeader(http.StatusNoContent)
return nil
}
@@ -271,21 +273,56 @@ func (c *Context) Status() int {
func (c *Context) Responded() bool { return c.responded }
// WriteError writes a stable public error response. Internal causes are not
// serialized. Responses committed through Context methods are preserved;
// direct Raw writes are outside this response-tracking contract.
// serialized. Responses committed through Context methods or Raw are preserved.
func (c *Context) WriteError(err error) {
if c.responded || err == nil {
return
}
apiErr := apierrors.From(err)
var bodyErr *http.MaxBytesError
if errors.As(err, &bodyErr) {
err = bodyTooLarge()
}
apiErr := c.ResolveError(err)
c.failure = apiErr
if writeErr := c.writeResult(response.Result{
Status: apiErr.Status,
Code: apiErr.Code,
Message: apiErr.Message,
RequestID: c.RequestID(),
Err: err,
BusinessCode: apiErr.BusinessCode,
Failed: true,
Status: apiErr.Status,
Code: apiErr.Code,
Message: apiErr.Message,
RequestID: c.RequestID(),
Err: err,
}); writeErr != nil && !c.responded {
// An invalid custom encoder must not recurse or leak a different envelope.
_ = c.WriteResponse(http.StatusInternalServerError, nil, nil)
}
}
// SetErrorRegistry sets an application fallback. Route encoders can supply their
// own nonempty registry; an encoder without a registry inherits this fallback.
// Clones retain both policies without any process-global lookup.
func (c *Context) SetErrorRegistry(r *apierrors.Registry) { c.errorRegistry = r }
func (c *Context) ResolveError(err error) *apierrors.APIError {
registry := c.errorRegistry
if provider, ok := c.encoder.(response.ErrorRegistryProvider); ok {
if scoped := provider.ErrorRegistry(); scoped != nil {
registry = scoped
}
}
return registry.Resolve(err)
}
// RecordError preserves failure semantics when a middleware commits buffered bytes.
func (c *Context) RecordError(err error) { c.failure = c.ResolveError(err) }
// RecordFailure propagates an already resolved failure without reapplying a
// different parent/catalog policy. Used with committed or buffered responses.
func (c *Context) RecordFailure(err *apierrors.APIError) { c.failure = err.Clone() }
// Failure returns a detached description of a failure already handled by Context.
func (c *Context) Failure() *apierrors.APIError {
if c.failure == nil {
return nil
}
return c.failure.Clone()
}

315
httpx/trackingWriter.go Normal file
View File

@@ -0,0 +1,315 @@
package httpx
import (
"bufio"
"io"
"net"
"net/http"
)
// trackingWriter does not buffer response bodies. Each wrapper has its own
// commit flag, so a standard middleware may safely wrap an already tracked writer.
type trackingWriter struct {
http.ResponseWriter
context *Context
committed bool
}
func (w *trackingWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func (w *trackingWriter) inheritCommit(status int) { w.mark(status) }
func (w *trackingWriter) mark(status int) {
w.committed = true
w.context.responded = true
w.context.status = status
}
func (w *trackingWriter) WriteHeader(status int) {
if w.committed {
return
}
// Let the server validate the status before updating the observation.
w.ResponseWriter.WriteHeader(status)
if status == 101 || status >= 200 {
w.mark(status)
}
}
func (w *trackingWriter) Write(p []byte) (int, error) {
if !w.committed {
w.mark(http.StatusOK)
}
return w.ResponseWriter.Write(p)
}
type flushWriter struct{ *trackingWriter }
func (w flushWriter) Flush() { _ = w.FlushError() }
func (w flushWriter) FlushError() error {
if !w.committed {
w.WriteHeader(http.StatusOK)
}
return http.NewResponseController(w.ResponseWriter).Flush()
}
type hijackWriter struct{ *trackingWriter }
func (w hijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
conn, rw, err := w.ResponseWriter.(http.Hijacker).Hijack()
if err == nil {
w.context.responded = true
w.committed = true
}
return conn, rw, err
}
type pushWriter struct{ *trackingWriter }
func (w pushWriter) Push(target string, options *http.PushOptions) error {
return w.ResponseWriter.(http.Pusher).Push(target, options)
}
type readFromWriter struct{ *trackingWriter }
func (w readFromWriter) ReadFrom(r io.Reader) (int64, error) {
// A writer-only view makes every write observable, including partial failures,
// while io.Copy retains bounded streaming memory and source WriterTo support.
return io.Copy(struct{ io.Writer }{w.trackingWriter}, r)
}
type closeNotifyWriter struct{ *trackingWriter }
func (w closeNotifyWriter) CloseNotify() <-chan bool {
return w.ResponseWriter.(http.CloseNotifier).CloseNotify()
}
func trackWriter(writer http.ResponseWriter, c *Context) http.ResponseWriter {
w := &trackingWriter{ResponseWriter: writer, context: c}
mask := 0
if _, ok := writer.(http.Flusher); ok {
mask |= 1
} else if _, ok := writer.(interface{ FlushError() error }); ok {
mask |= 1
}
if _, ok := writer.(http.Hijacker); ok {
mask |= 2
}
if _, ok := writer.(http.Pusher); ok {
mask |= 4
}
if _, ok := writer.(io.ReaderFrom); ok {
mask |= 8
}
if _, ok := writer.(http.CloseNotifier); ok {
mask |= 16
}
switch mask {
case 1:
return struct {
*trackingWriter
flushCapability
}{w, flushWriter{w}}
case 2:
return struct {
*trackingWriter
http.Hijacker
}{w, hijackWriter{w}}
case 3:
return struct {
*trackingWriter
flushCapability
http.Hijacker
}{w, flushWriter{w}, hijackWriter{w}}
case 4:
return struct {
*trackingWriter
http.Pusher
}{w, pushWriter{w}}
case 5:
return struct {
*trackingWriter
flushCapability
http.Pusher
}{w, flushWriter{w}, pushWriter{w}}
case 6:
return struct {
*trackingWriter
http.Hijacker
http.Pusher
}{w, hijackWriter{w}, pushWriter{w}}
case 7:
return struct {
*trackingWriter
flushCapability
http.Hijacker
http.Pusher
}{w, flushWriter{w}, hijackWriter{w}, pushWriter{w}}
case 8:
return struct {
*trackingWriter
io.ReaderFrom
}{w, readFromWriter{w}}
case 9:
return struct {
*trackingWriter
flushCapability
io.ReaderFrom
}{w, flushWriter{w}, readFromWriter{w}}
case 10:
return struct {
*trackingWriter
http.Hijacker
io.ReaderFrom
}{w, hijackWriter{w}, readFromWriter{w}}
case 11:
return struct {
*trackingWriter
flushCapability
http.Hijacker
io.ReaderFrom
}{w, flushWriter{w}, hijackWriter{w}, readFromWriter{w}}
case 12:
return struct {
*trackingWriter
http.Pusher
io.ReaderFrom
}{w, pushWriter{w}, readFromWriter{w}}
case 13:
return struct {
*trackingWriter
flushCapability
http.Pusher
io.ReaderFrom
}{w, flushWriter{w}, pushWriter{w}, readFromWriter{w}}
case 14:
return struct {
*trackingWriter
http.Hijacker
http.Pusher
io.ReaderFrom
}{w, hijackWriter{w}, pushWriter{w}, readFromWriter{w}}
case 15:
return struct {
*trackingWriter
flushCapability
http.Hijacker
http.Pusher
io.ReaderFrom
}{w, flushWriter{w}, hijackWriter{w}, pushWriter{w}, readFromWriter{w}}
case 16:
return struct {
*trackingWriter
http.CloseNotifier
}{w, closeNotifyWriter{w}}
case 17:
return struct {
*trackingWriter
flushCapability
http.CloseNotifier
}{w, flushWriter{w}, closeNotifyWriter{w}}
case 18:
return struct {
*trackingWriter
http.Hijacker
http.CloseNotifier
}{w, hijackWriter{w}, closeNotifyWriter{w}}
case 19:
return struct {
*trackingWriter
flushCapability
http.Hijacker
http.CloseNotifier
}{w, flushWriter{w}, hijackWriter{w}, closeNotifyWriter{w}}
case 20:
return struct {
*trackingWriter
http.Pusher
http.CloseNotifier
}{w, pushWriter{w}, closeNotifyWriter{w}}
case 21:
return struct {
*trackingWriter
flushCapability
http.Pusher
http.CloseNotifier
}{w, flushWriter{w}, pushWriter{w}, closeNotifyWriter{w}}
case 22:
return struct {
*trackingWriter
http.Hijacker
http.Pusher
http.CloseNotifier
}{w, hijackWriter{w}, pushWriter{w}, closeNotifyWriter{w}}
case 23:
return struct {
*trackingWriter
flushCapability
http.Hijacker
http.Pusher
http.CloseNotifier
}{w, flushWriter{w}, hijackWriter{w}, pushWriter{w}, closeNotifyWriter{w}}
case 24:
return struct {
*trackingWriter
io.ReaderFrom
http.CloseNotifier
}{w, readFromWriter{w}, closeNotifyWriter{w}}
case 25:
return struct {
*trackingWriter
flushCapability
io.ReaderFrom
http.CloseNotifier
}{w, flushWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
case 26:
return struct {
*trackingWriter
http.Hijacker
io.ReaderFrom
http.CloseNotifier
}{w, hijackWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
case 27:
return struct {
*trackingWriter
flushCapability
http.Hijacker
io.ReaderFrom
http.CloseNotifier
}{w, flushWriter{w}, hijackWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
case 28:
return struct {
*trackingWriter
http.Pusher
io.ReaderFrom
http.CloseNotifier
}{w, pushWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
case 29:
return struct {
*trackingWriter
flushCapability
http.Pusher
io.ReaderFrom
http.CloseNotifier
}{w, flushWriter{w}, pushWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
case 30:
return struct {
*trackingWriter
http.Hijacker
http.Pusher
io.ReaderFrom
http.CloseNotifier
}{w, hijackWriter{w}, pushWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
case 31:
return struct {
*trackingWriter
flushCapability
http.Hijacker
http.Pusher
io.ReaderFrom
http.CloseNotifier
}{w, flushWriter{w}, hijackWriter{w}, pushWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
}
return w
}
type flushCapability interface {
Flush()
FlushError() error
}

View File

@@ -30,7 +30,7 @@ if err != nil { return err }
defer closeLog()
```
也可用 `omni.WithLogConfig(...)` 交给应用运行生命周期管理。子日志共用输出、级别和关闭状态。独立 `logx.New` 返回的实例由创建者负责关闭。
`omni.WithLogConfig(...)` 只保存日志配置,`omni.New()` 不会因此创建或关闭 Logger。手写入口需要像上例一样调用 `logx.Setup` 并负责关闭,或使用 `logx.New`、`logx.SetDefault` 和 `app.OnShutdown(logger.Close)` 显式装配。生成的 API 项目已在入口完成这些步骤。子日志共用输出、级别和关闭状态;独立 `logx.New` 返回的实例由创建者负责关闭。
轮转显式开启;三个数值为 0 时分别取 100 MiB、10 份、7 天。超过大小后轮转,旧文件按份数/年龄清理,可 gzip 压缩;保留天数不表示每天定时切割。压缩和历史清理由底层异步执行,不承诺 Close 等待压缩完成。轮转使用 [lumberjack v2](https://github.com/natefinch/lumberjack/tree/v2.2.1),**一个日志文件只能由一个进程拥有**。容器通常直接用 stdout/stderr,让运行平台管理日志文件。

View File

@@ -7,7 +7,6 @@ import (
"sync/atomic"
"time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
@@ -46,10 +45,10 @@ func (m *Metrics) Middleware() httpx.Middleware {
err := next(ctx)
status := ctx.Status()
if err != nil && !ctx.Responded() {
status = apierrors.From(err).Status
status = ctx.ResolveError(err).Status
}
m.requests.Add(1)
if err != nil || status >= 400 {
if err != nil || ctx.Failure() != nil || status >= 400 {
m.errors.Add(1)
}
if status >= 0 && status < len(m.statuses) {

View File

@@ -31,6 +31,8 @@ type IdempotencyConfig struct {
type replay struct {
fingerprint [32]byte
errorCode string
failure *apierrors.Snapshot
status int
headers http.Header
body []byte
@@ -150,8 +152,14 @@ func (i *Idempotency) Middleware() httpx.Middleware {
return apierrors.New(409, "IDEMPOTENCY_UNAVAILABLE", "request is in progress or its outcome cannot be replayed")
}
status, headers, data := saved.status, saved.headers.Clone(), append([]byte(nil), saved.body...)
errorCode, failure := saved.errorCode, saved.failure
i.mu.Unlock()
headers.Set("Idempotency-Replayed", "true")
if failure != nil {
c.RecordFailure(failure.ErrorValue())
} else if errorCode != "" {
c.RecordFailure(apierrors.New(status, errorCode, ""))
}
return c.WriteResponse(status, headers, data)
}
if len(i.items) >= i.config.MaxEntries {
@@ -171,15 +179,26 @@ func (i *Idempotency) Middleware() httpx.Middleware {
saved.failed = true
saved.expires = i.now().Add(i.config.TTL)
size := buffer.body.Len()
failure := c.Failure().Snapshot()
var metadataErr error
if failure != nil {
var metadata []byte
metadata, metadataErr = json.Marshal(failure)
size += len(metadata) + len(failure.Code)
}
for k, values := range buffer.headers {
size += len(k)
for _, v := range values {
size += len(v)
}
}
if buffer.complete && buffer.err == nil && buffer.status < 500 && buffer.headers.Get("Set-Cookie") == "" && !strings.Contains(strings.ToLower(buffer.headers.Get("Cache-Control")), "no-store") && size <= i.config.MaxBytes-i.bytes {
if metadataErr == nil && buffer.complete && buffer.err == nil && buffer.status < 500 && buffer.headers.Get("Set-Cookie") == "" && !strings.Contains(strings.ToLower(buffer.headers.Get("Cache-Control")), "no-store") && size <= i.config.MaxBytes-i.bytes {
saved.failed = false
saved.status = buffer.status
if failure := c.Failure(); failure != nil {
saved.errorCode = failure.Code
saved.failure = failure.Snapshot()
}
saved.headers = buffer.headers.Clone()
saved.body = append([]byte(nil), buffer.body.Bytes()...)
saved.size = size
@@ -202,6 +221,9 @@ func (i *Idempotency) Middleware() httpx.Middleware {
if buffer.status == 0 {
buffer.status = 200
}
if failure := child.Failure(); failure != nil {
c.RecordFailure(failure)
}
buffer.complete = true
return c.WriteResponse(buffer.status, buffer.headers, buffer.body.Bytes())
}

View File

@@ -1,6 +1,7 @@
package middleware
import (
"errors"
"fmt"
"runtime/debug"
"time"
@@ -73,10 +74,10 @@ func AccessLog(options ...AccessLogOption) httpx.Middleware {
return func(ctx *httpx.Context) error {
started := time.Now()
err := next(ctx)
status := ctx.Status()
if err != nil && !ctx.Responded() {
status = apierrors.From(err).Status
ctx.WriteError(err)
}
status := ctx.Status()
duration := time.Since(started)
slow := settings.slowThreshold > 0 && duration >= settings.slowThreshold
attrs := []any{
@@ -87,6 +88,20 @@ func AccessLog(options ...AccessLogOption) httpx.Middleware {
"durationMs", duration.Seconds() * 1000,
"clientIp", ctx.ClientIP(),
}
failure := ctx.Failure()
if failure == nil && err != nil {
failure = ctx.ResolveError(err)
}
if failure != nil {
attrs = append(attrs, "failed", true, "errorKey", failure.Code)
var parameterErr *apierrors.ParamError
if errors.As(failure, &parameterErr) {
attrs = append(attrs, "errorParameter", parameterErr.Error())
}
if failure.BusinessCode != nil {
attrs = append(attrs, "businessCode", failure.BusinessCode)
}
}
if slow {
attrs = append(attrs, "slow", true)
}

View File

@@ -0,0 +1,81 @@
package redis
import (
"context"
"errors"
"fmt"
"math"
"net/http/httptest"
"os"
"strings"
"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/middleware"
"git.yuntuo.hk/k2safe/go-omni/response"
goredis "github.com/redis/go-redis/v9"
)
func TestTemplateErrorRedisReplay(t *testing.T) {
address := os.Getenv("GO_OMNI_REDIS_TEST_ADDR")
if address == "" {
t.Skip("requires isolated Redis")
}
client := goredis.NewClient(&goredis.Options{Addr: address, PoolSize: 2, MaxRetries: -1})
defer client.Close()
backend, err := NewIdempotencyStore(client, fmt.Sprint("template:", time.Now().UnixNano()), IdempotencyConfig{MaxEntries: 4})
if err != nil {
t.Fatal(err)
}
defer client.Del(context.Background(), backend.keys...)
var observed []*apierrors.APIError
calls := 0
var bodies []string
for _, template := range []string{"{label}不能超过{max}个字符", "changed: {label}/{max}"} {
cat := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"LIMIT": {Code: int64(math.MaxInt64), Message: template, Params: []apierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int64"}}}}}
app := omni.New(omni.WithErrors(cat), omni.WithResponse(response.Config{CodeType: "int64", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"}))
idem, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Store: backend, Scope: func(*httpx.Context) (string, error) { return "fixture", nil }})
if err != nil {
t.Fatal(err)
}
app.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error { err := next(c); observed = append(observed, c.Failure()); return err }
})
app.POST("/", func(c *httpx.Context) error {
calls++
return apierrors.ByCode("LIMIT").WithParams(map[string]any{"label": "备注", "max": int64(math.MaxInt64)}).WithCause(errors.New("privateCause"))
}, idem.Middleware())
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/", nil)
r.Header.Set("Idempotency-Key", "same")
app.ServeHTTP(w, r)
app.Close()
if w.Code != 200 || !strings.Contains(w.Body.String(), "备注不能超过9223372036854775807个字符") || !strings.Contains(w.Body.String(), `"code":9223372036854775807`) || !strings.Contains(w.Body.String(), `"data":null`) || strings.Contains(w.Body.String(), "privateCause") {
t.Fatal(w.Code, w.Body.String())
}
bodies = append(bodies, w.Body.String())
}
if calls != 1 || bodies[0] != bodies[1] || len(observed) != 2 {
t.Fatal(calls, bodies, observed)
}
for _, e := range observed {
if e == nil || e.BusinessCode != int64(math.MaxInt64) || e.Message != "备注不能超过9223372036854775807个字符" {
t.Fatal(e)
}
}
if observed[1].Cause != nil {
t.Fatal("persisted internal cause")
}
records, err := client.HVals(context.Background(), backend.keys[0]).Result()
if err != nil {
t.Fatal(err)
}
for _, raw := range records {
if strings.Contains(raw, "privateCause") || strings.Contains(raw, `"params"`) {
t.Fatal("private replay metadata")
}
}
}

View File

@@ -4,19 +4,23 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"net/http"
"strings"
"time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
type StoredResponse struct {
Status int `json:"status"`
Headers http.Header `json:"headers"`
Body []byte `json:"body"`
Status int `json:"status"`
ErrorCode string `json:"errorCode,omitempty"`
Failure *apierrors.Snapshot `json:"failure,omitempty"`
Headers http.Header `json:"headers"`
Body []byte `json:"body"`
}
type IdempotencyRecord struct {
Fingerprint string `json:"fingerprint"`
@@ -54,6 +58,11 @@ func (i *Idempotency) shared(c *httpx.Context, next httpx.Handler, id, fingerpri
response := record.Response
headers := response.Headers.Clone()
headers.Set("Idempotency-Replayed", "true")
if response.Failure != nil {
c.RecordFailure(response.Failure.ErrorValue())
} else if response.ErrorCode != "" {
c.RecordFailure(apierrors.New(response.Status, response.ErrorCode, ""))
}
return c.WriteResponse(response.Status, headers, response.Body)
}
completed := false
@@ -84,9 +93,22 @@ func (i *Idempotency) shared(c *httpx.Context, next httpx.Handler, id, fingerpri
if buffer.status == 0 {
buffer.status = 200
}
if failure := child.Failure(); failure != nil {
c.RecordFailure(failure)
}
var response *StoredResponse
if buffer.status < 500 && buffer.headers.Get("Set-Cookie") == "" && !strings.Contains(strings.ToLower(buffer.headers.Get("Cache-Control")), "no-store") {
response = &StoredResponse{Status: buffer.status, Headers: buffer.headers.Clone(), Body: append([]byte(nil), buffer.body.Bytes()...)}
if failure := child.Failure(); failure != nil {
response.ErrorCode = failure.Code
response.Failure = failure.Snapshot()
}
}
if response != nil && response.Failure != nil {
metadata, encodeErr := json.Marshal(response.Failure)
if encodeErr != nil || len(metadata) > i.config.MaxResponseBytes {
response = nil
}
}
if err = finish(response); err != nil {
return apierrors.New(503, "IDEMPOTENCY_UNAVAILABLE", "outcome requires reconciliation").WithCause(err)

View File

@@ -26,6 +26,7 @@ type HTTPRouterGroup = httpx.RouterGroup
type APIError = apierrors.APIError
type Config = config.Config
type TLSConfig = config.TLSConfig
type HTTPConfig = config.HTTPConfig
type Option = config.Option
type Response = response.Response
type LogConfig = logx.Config
@@ -48,6 +49,8 @@ func WithRequestIDHeader(header string) Option { return config.WithRequestIDHead
func WithLogger(enabled bool) Option { return config.WithLogger(enabled) }
func WithErrors(catalog apierrors.Catalog) Option { return config.WithErrors(catalog) }
func WithResponse(settings response.Config) Option { return config.WithResponse(settings) }
func WithResponseEncoder(encoder response.Encoder) Option { return config.WithResponseEncoder(encoder) }
@@ -62,6 +65,8 @@ func WithConfig(settings Config) Option { return config.WithConfig(settings) }
func WithTransport(name string) Option { return config.WithTransport(name) }
func WithHTTPConfig(settings HTTPConfig) Option { return config.WithHTTPConfig(settings) }
func WithTLS(certFile, keyFile string) Option { return config.WithTLS(certFile, keyFile) }
func WithLogConfig(settings LogConfig) Option { return config.WithLogConfig(settings) }

View File

@@ -0,0 +1,32 @@
# v0.9.0-preview 迁移扩展验收
本轮只使用本机临时服务与生成项目;没有运行业务服务器压测,没有修改 ePay/eTax 业务代码。
## 本地验收
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...`:40 个有测试的包通过;包含真实生成消费者、标准 Handler/Middleware、动态 JSON、路由限额和 scalar。
- `go vet ./...`、`go build ./...`、gofmt、`git diff --check` 通过。
- 独立 `examples/userService` module 的 race/vet/build 通过;生成文件 `--check` 通过,手写业务保持原样。
- nethttp/native 本机真实 TCP 测试:首块数据立即 Flush、2 MiB 文件保持原响应、已知长度和 chunked 超限返回 413,并能继续处理正常请求。
- 404 访问日志、提交后错误/panic 不追加 JSON、标准中间件提前写入/跨请求状态/取消/PathValue 通过。
- multipart 落盘临时文件在成功和 panic 后清理;原始请求体 HMAC 验签、定制绑定和生成代码保护通过。
- scalar 最大 int64、数字/字符串兼容、溢出/错误类型拒绝、指针 null、query 集合、输入/输出 schema 分离通过;普通字段仍严格。
- ePay 原始契约只读校验:26 types、43 routes、0 errors、0 auth schemes。后两项是声明数量,不代表无需鉴权或没有业务错误;不代表 144 条注册路由已全部迁移。
环境:`go version go1.27.1 darwin/arm64`。记录时间:2026-09-23T17:47:26+00:00。
## 远程验收
已执行 `scripts/verifyCompatibilityRelease.py --version v0.9.0-preview`,通过。详见 [远程验收记录](compatibilityRemote.json)。
- 从全新 module cache 安装发布版 CLI,消费者固定依赖 `v0.9.0-preview`,无本地 replace。
- 消费者实际生成、race 测试、自定义 ID/动态 JSON、请求体限额、上传清理、原文签名、下载和重生成保护通过。
- 发布源码:`a8c973d8a2c24f3cc572f32a976349d9a97427a1`。
- module 校验和:`h1:cYpW6HwZnLHsxq7thO1C2pR/H3vyoUQQhuBVuEHhtHw=`。
- 耗时 13.51 秒;临时目录和缓存已清理。脚本与 fixture 哈希保存在 JSON 中。
验收结果在 main 单独记录,发布标签保持不可变。
## 范围
本次不是完整 ePay 迁移验收;资金事务、原权限体系、业务响应细节仍需业务自己的回归。未重跑 Linux soak、Nginx/Cloudflare 代理或云存储账号实网验收。

View File

@@ -0,0 +1,31 @@
{
"passed": true,
"frameworkVersion": "v0.9.0-preview",
"frameworkSum": "h1:cYpW6HwZnLHsxq7thO1C2pR/H3vyoUQQhuBVuEHhtHw=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 13.51,
"checks": [
"remoteCLIInstall",
"scalarContractValidation",
"manualProjectGeneration",
"int64MaxValueOnWire",
"dynamicJSON",
"rawBodySignature",
"routeBodyLimits",
"multipartTempCleanup",
"unwrappedDownloads",
"handwrittenFilesPreserved",
"generatedConsumerRaceTests",
"fixedRemoteModuleChecksum"
],
"sourceHashes": {
"scripts/verifyCompatibilityRelease.py": "90e487d3203ed4eb4bc721da8086356413345b3e05193e473250a834aeb8d293",
"cmd/go-omni/apigen/compatibility_test.go": "98cf5fbe6cb6413c12f709806d385726f3dec1468575f76967786de4707667ba",
"examples/contracts/compatibility.api": "3ede909e7f60386012e55c916a8e4887dd08562e7f023665f663288c809873d4",
"cmd/go-omni/apigen/testdata/compatibility/consumer_test.go": "0a99ffd6a5cc16d47ae8208d9140c8d6afd9c19106fac8e5e61096cb90d8c463",
"cmd/go-omni/apigen/testdata/compatibility/hooks.go": "435baf37c7f8adffd18d4be0353a791124e0bc5dcb79abdcc345276b6f9867f4",
"cmd/go-omni/apigen/testdata/compatibility/echo.go": "9e13cb18a0951974e723a1fd915c0f4610d823c95eadb752c3d1cd75c44fa293",
"cmd/go-omni/apigen/testdata/compatibility/download.go": "e7f4bcdb34c90ec63fdab1f3c4e524964ac153944352d8a9053a9f19b74030fd"
}
}

View File

@@ -0,0 +1,36 @@
# v0.9.1-preview Connector 与 HTTP 配置验收
## 范围
新增标准 `driver.Connector` 注入和 HTTP 服务器读写配置;没有修改 eTax/ePay 业务代码。数据库驱动/TLS/Logger/时区/版本校验仍归项目。网络测试只监听本地回环地址,MySQL 使用独立本地容器(1 CPU / 768 MiB / 128 PID),没有使用业务服务器压测。
## 全量回归
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...`:40 个有测试的包全部通过,包含真实脚手架/契约生成的集成消费者。
- `go vet ./...`、`go build ./...`、gofmt、`git diff --check` 通过。
## 专项验收
- `go test -race -count=3 ./database ./app ./config` 通过。
- 两个 Connector 并发读写保持独立;启动 Connect/Ping 与查询 context 保留,后台补连接遵循 database/sql 行为;最大连接数、禁用 idle、连接生命周期、健康检查等待上限及恢复验证通过。
- nil/带类型的 nil、无效参数、启动前取消不取得 Connector 所有权;Connect/Ping 失败与超时关闭池,清理错误和原错误可同时检查;成功关闭幂等,事务提交/错误/panic 回滚通过。
- 标准库服务器字段、ErrorLog 透传、无效配置启动前返回并清理资源、JSON/YAML/环境变量覆盖通过;不支持的独立 http2 选项明确拒绝。
- nethttp/native 的实际 TCP 慢请求头、慢 body、Keep-Alive 空闲关闭、Flush 后客户端停止读取触发写超时、超限头 431 通过。测试不假定两种网络层有完全相同的超时响应格式。
- `scripts/verifyConnector.py --local` 的独立消费者通过:真实 MySQL 8.4 TLS、错误 CA/主机名拒绝、不同 session time_zone、每个 Connector 的驱动 Logger、两池独立关闭、事务提交/回滚。所有专项测试无跳过,详情见 [本地记录](connectorLocal.json)。
- MySQL 镜像固定记录为 `sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c`。临时容器、卷、证书、凭据及消费者目录已清理。
环境:`go version go1.27.1 darwin/arm64`。记录时间:2026-09-23T18:17:16.897323+00:00。
独立 `TransportHTTP2` 的逐请求读写时限未实现,本次没有进行 Linux soak、代理长测或完整业务迁移验收。
## 远程验收
发布后两条独立远程验收均通过;各自使用全新 module cache,消费者依赖固定版本,无本地 replace。
- 发布标签:`v0.9.1-preview`;源码:`20e84e5ff86d872f42301cd3c256030fc41c8ce2`。
- module 校验和:`h1:yCqjVGv4rH8UN2M3WzjraNEfdEI6WJD2A64F4HFP0FI=`。
- Connector/HTTP 远程消费者执行全部专项 race 测试及真实 MySQL TLS 用例,无跳过;耗时 16.49 秒,详见 [Connector 远程记录](connectorRemote.json)。
- 从远程安装 CLI,生成消费者,验证标量、动态 JSON、上传/原文签名、下载、请求体上限和重生成保护;耗时 13.27 秒,详见 [CLI 远程记录](connectorCompatibilityRemote.json)。
- 两条验收脚本确认相同框架校验和;临时消费者、缓存、数据库容器与测试证书/凭据已清理。fixture 哈希与当前文件核对一致。
结果在 main 单独追加,已发布标签保持不可变。

View File

@@ -0,0 +1,31 @@
{
"passed": true,
"frameworkVersion": "v0.9.1-preview",
"frameworkSum": "h1:yCqjVGv4rH8UN2M3WzjraNEfdEI6WJD2A64F4HFP0FI=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 13.27,
"checks": [
"remoteCLIInstall",
"scalarContractValidation",
"manualProjectGeneration",
"int64MaxValueOnWire",
"dynamicJSON",
"rawBodySignature",
"routeBodyLimits",
"multipartTempCleanup",
"unwrappedDownloads",
"handwrittenFilesPreserved",
"generatedConsumerRaceTests",
"fixedRemoteModuleChecksum"
],
"sourceHashes": {
"scripts/verifyCompatibilityRelease.py": "90e487d3203ed4eb4bc721da8086356413345b3e05193e473250a834aeb8d293",
"cmd/go-omni/apigen/compatibility_test.go": "98cf5fbe6cb6413c12f709806d385726f3dec1468575f76967786de4707667ba",
"examples/contracts/compatibility.api": "3ede909e7f60386012e55c916a8e4887dd08562e7f023665f663288c809873d4",
"cmd/go-omni/apigen/testdata/compatibility/consumer_test.go": "0a99ffd6a5cc16d47ae8208d9140c8d6afd9c19106fac8e5e61096cb90d8c463",
"cmd/go-omni/apigen/testdata/compatibility/hooks.go": "435baf37c7f8adffd18d4be0353a791124e0bc5dcb79abdcc345276b6f9867f4",
"cmd/go-omni/apigen/testdata/compatibility/echo.go": "9e13cb18a0951974e723a1fd915c0f4610d823c95eadb752c3d1cd75c44fa293",
"cmd/go-omni/apigen/testdata/compatibility/download.go": "e7f4bcdb34c90ec63fdab1f3c4e524964ac153944352d8a9053a9f19b74030fd"
}
}

View File

@@ -0,0 +1,70 @@
{
"passed": true,
"frameworkVersion": "v0.9.1-preview",
"localSource": true,
"frameworkSum": null,
"freshModuleCache": false,
"noLocalReplace": false,
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
"mysqlLimits": {
"cpus": 1,
"memoryMiB": 768,
"pids": 128
},
"durationSeconds": 9.61,
"tests": [
"TestHTTPFileAndEnvironment/server.json",
"TestHTTPFileAndEnvironment/server.yaml",
"TestHTTPFileAndEnvironment",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
"TestHTTPInvalidAndUnsupportedSettings",
"TestHTTPConfiguredStandardServer",
"TestHTTPInvalidStartupReleasesResources",
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
"TestConnectorIndependentPoolsAndContext",
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
"TestConnectorStartupFailuresReleaseResources/connect",
"TestConnectorStartupFailuresReleaseResources/ping",
"TestConnectorStartupFailuresReleaseResources",
"TestConnectorTimeoutBoundsConnectAndPing/connect",
"TestConnectorTimeoutBoundsConnectAndPing/ping",
"TestConnectorTimeoutBoundsConnectAndPing",
"TestConnectorHealthBoundsPoolWaitAndRecovers",
"TestConnectorIdleZeroAndLifetime/noIdle",
"TestConnectorIdleZeroAndLifetime/expired",
"TestConnectorIdleZeroAndLifetime",
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
"TestMySQLConnectorDriverLoggerIsolation",
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
"TestMySQLConnectorTLSAndPoolIsolation",
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
"TestHTTPConfiguredNetworkLimits/nethttp",
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
"TestHTTPConfiguredNetworkLimits/native/slowBody",
"TestHTTPConfiguredNetworkLimits/native/idle",
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
"TestHTTPConfiguredNetworkLimits/native",
"TestHTTPConfiguredNetworkLimits"
],
"sourceHashes": {
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
"scripts/verifyConnector.py": "a0ba3fa29a4d1efcbed5dad82c09afb97d8ddecd904f20fee1e3aa74ec0f9e06"
}
}

View File

@@ -0,0 +1,70 @@
{
"passed": true,
"frameworkVersion": "v0.9.1-preview",
"localSource": false,
"frameworkSum": "h1:yCqjVGv4rH8UN2M3WzjraNEfdEI6WJD2A64F4HFP0FI=",
"freshModuleCache": true,
"noLocalReplace": true,
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
"mysqlLimits": {
"cpus": 1,
"memoryMiB": 768,
"pids": 128
},
"durationSeconds": 16.49,
"tests": [
"TestHTTPConfiguredStandardServer",
"TestHTTPInvalidStartupReleasesResources",
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
"TestHTTPFileAndEnvironment/server.json",
"TestHTTPFileAndEnvironment/server.yaml",
"TestHTTPFileAndEnvironment",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
"TestHTTPInvalidAndUnsupportedSettings",
"TestConnectorIndependentPoolsAndContext",
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
"TestConnectorStartupFailuresReleaseResources/connect",
"TestConnectorStartupFailuresReleaseResources/ping",
"TestConnectorStartupFailuresReleaseResources",
"TestConnectorTimeoutBoundsConnectAndPing/connect",
"TestConnectorTimeoutBoundsConnectAndPing/ping",
"TestConnectorTimeoutBoundsConnectAndPing",
"TestConnectorHealthBoundsPoolWaitAndRecovers",
"TestConnectorIdleZeroAndLifetime/noIdle",
"TestConnectorIdleZeroAndLifetime/expired",
"TestConnectorIdleZeroAndLifetime",
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
"TestMySQLConnectorDriverLoggerIsolation",
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
"TestMySQLConnectorTLSAndPoolIsolation",
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
"TestHTTPConfiguredNetworkLimits/nethttp",
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
"TestHTTPConfiguredNetworkLimits/native/slowBody",
"TestHTTPConfiguredNetworkLimits/native/idle",
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
"TestHTTPConfiguredNetworkLimits/native",
"TestHTTPConfiguredNetworkLimits"
],
"sourceHashes": {
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
"scripts/verifyConnector.py": "a0ba3fa29a4d1efcbed5dad82c09afb97d8ddecd904f20fee1e3aa74ec0f9e06"
}
}

View File

@@ -0,0 +1,42 @@
# v0.10.0-preview 统一错误目录验收
## 范围
项目定义错误目录,框架解析逻辑标识、HTTP 状态、对外业务码及公开提示;支持 HTTP 200 的业务失败,并贯通 `.api` 生成、响应、日志、指标、链路和错误重放。没有修改 eTax/ePay 业务代码,也没有使用业务服务器压测。
目录与响应模板分离;不同 App、路由可选择不同目录和协议。系统兜底不受 DefaultStatus 隐式影响,自定义 Encoder 继续控制最终输出。
## 本地回归
环境:go1.27.1 darwin/arm64。验收日期:2026-09-24。
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...` 全部通过,包含实际生成并编译的脚手架/契约消费者。
- 全量之后增加配置快照隔离与 Encoder 包装策略测试,完成 `go test -race -count=1 ./response ./config ./cmd/go-omni/apigen`。
- 本地/共享幂等错误元数据补充用例通过 `go test -race -count=1 ./app ./config ./httpx ./middleware/... ./metrics ./response ./telemetry`。
- `go vet ./...`、`go build ./...`、gofmt、`git diff --check` 与 CLI 校验 `examples/contracts/errors.api` 通过。
## 专项覆盖
- 多 App 隔离、目录复制、配置快照隔离、静态配置变更要求重启;并发解析及 WithCause 不互相污染。
- JSON/YAML 精确 int64、字符串/整数类型与范围、重复 key、非法状态/字段/映射冲突拒绝。重复对外业务码允许保留独立提示。
- HTTP 200 业务失败、403 单项覆盖、未知标识 500、内部原因不进入默认公开响应、已提交响应不追加、文本 ACK 由自定义 Encoder 输出。
- RawJSON 成功不包装、失败保持错误响应;嵌套相同/空目录保留已有策略,不同目录叠加明确拒绝。
- 访问日志记录最终状态和业务失败;指标计入 HTTP 200 失败,链路保留状态与失败标记。标准中间件、本地/共享幂等重放保留失败语义。
- 多文件导入、多个目录和不同码类型的路由作用域;200 成功/失败 OpenAPI anyOf、多错误同码的独立示例。
- 实际生成项目的无参数错误函数、全局中间件提前返回时的路由目录、unknown fallback、raw 成功/失败、手写文件重生成保护。
## 验证边界
共享幂等新增用例使用 JSON 序列化的内存替身,验证字段在持久化边界上保留;未为本特性运行外部 Redis 服务。没有进行 Linux soak、代理长测或完整业务迁移验收。业务项目仍需升级框架/CLI,并逐步将散落错误改为目录调用。
## 远程固定版本验收
发布后两条独立远程验收均通过;各自从全新 module cache 安装远程 CLI,生成的消费者依赖固定版本,没有本地 replace。
- 发布标签:`v0.10.0-preview`;源码:`ebfd279f9ada059bcb5d229e463759122b8a3669`。
- module 校验和:`h1:bbBDhMtp6ax+krphTGu9M1xnHGFORSg/ahFxWbEILyQ=`,两条验收结果一致。
- 错误目录远程消费者:实际生成代码编译、HTTP 200 失败、同码不同提示、状态覆盖、未知标识兜底、cause 隔离、raw 输出、中间件提前返回、重生成保护与 race 测试通过;耗时 14.15 秒,详见 [错误目录远程记录](errorCatalogRemote.json)。
- 迁移兼容性消费者:标量转换、int64 精度、动态 JSON、原始请求体验签、路由体积限制、上传临时文件清理、下载及重生成保护通过;耗时 14.48 秒,详见 [兼容性远程记录](errorCatalogCompatibilityRemote.json)。
- 验收 fixture 哈希与当前文件一致;临时目录及缓存已清理。
结果在 main 单独追加,已发布标签保持不可变。

View File

@@ -0,0 +1,31 @@
{
"passed": true,
"frameworkVersion": "v0.10.0-preview",
"frameworkSum": "h1:bbBDhMtp6ax+krphTGu9M1xnHGFORSg/ahFxWbEILyQ=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 14.48,
"checks": [
"remoteCLIInstall",
"scalarContractValidation",
"manualProjectGeneration",
"int64MaxValueOnWire",
"dynamicJSON",
"rawBodySignature",
"routeBodyLimits",
"multipartTempCleanup",
"unwrappedDownloads",
"handwrittenFilesPreserved",
"generatedConsumerRaceTests",
"fixedRemoteModuleChecksum"
],
"sourceHashes": {
"scripts/verifyCompatibilityRelease.py": "90e487d3203ed4eb4bc721da8086356413345b3e05193e473250a834aeb8d293",
"cmd/go-omni/apigen/compatibility_test.go": "98cf5fbe6cb6413c12f709806d385726f3dec1468575f76967786de4707667ba",
"examples/contracts/compatibility.api": "3ede909e7f60386012e55c916a8e4887dd08562e7f023665f663288c809873d4",
"cmd/go-omni/apigen/testdata/compatibility/consumer_test.go": "0a99ffd6a5cc16d47ae8208d9140c8d6afd9c19106fac8e5e61096cb90d8c463",
"cmd/go-omni/apigen/testdata/compatibility/hooks.go": "435baf37c7f8adffd18d4be0353a791124e0bc5dcb79abdcc345276b6f9867f4",
"cmd/go-omni/apigen/testdata/compatibility/echo.go": "9e13cb18a0951974e723a1fd915c0f4610d823c95eadb752c3d1cd75c44fa293",
"cmd/go-omni/apigen/testdata/compatibility/download.go": "e7f4bcdb34c90ec63fdab1f3c4e524964ac153944352d8a9053a9f19b74030fd"
}
}

View File

@@ -0,0 +1,29 @@
{
"passed": true,
"frameworkVersion": "v0.10.0-preview",
"frameworkSum": "h1:bbBDhMtp6ax+krphTGu9M1xnHGFORSg/ahFxWbEILyQ=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 14.15,
"checks": [
"remoteCLIInstall",
"errorCatalogValidation",
"generatedErrorConstructors",
"http200BusinessFailure",
"duplicatePublicCodeDistinctMessages",
"perErrorHTTPStatus",
"unknownErrorFallback",
"causeNotExposed",
"rawSuccessAndWrappedFailure",
"earlyMiddlewareCatalog",
"handwrittenFilesPreserved",
"generatedConsumerRaceTests",
"fixedRemoteModuleChecksum"
],
"sourceHashes": {
"scripts/verifyErrorCatalogRelease.py": "53b00d6069296903ba8392c68dfd6d9ae6d8c83e9b82555e55e8be0ec0f2b828",
"cmd/go-omni/apigen/errorCatalog_test.go": "9053ca2b103c32d6374ca013580b9f9f18d2319e15e445477a5c61795b32f2ae",
"examples/contracts/errors.api": "bfbe0e81c6d0f87981f511f21d4d3e138cdf9287c5f6615856ac04a825b14435",
"cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go": "804cee8cdbca5fe05e6340b8ac21e5bbb7d2a5c38fa6b06b121fc745aaea62ac"
}
}

View File

@@ -0,0 +1,27 @@
# v0.11.1-preview App 错误目录继承验收
## 问题与修复
在 Go 中用 `omni.WithErrors` 注册目录后,v0.11.0 生成路由无条件创建空目录 Encoder。Context 选择了该空目录,编码阶段又按空目录解析原始错误,造成已配置错误变成 500/英文系统提示。
修复让空 `WithErrors` 保持 Encoder 不变,生成器仅为非空路由目录绑定策略。Context 原有的 App fallback 因而能够生效;错误目录与响应格式仍独立选择。兼容旧生成代码中的空包装,非空路由目录保持独立、不与 App 合并。
## 复现与回归
- 修复前,`TestRouteInheritsAppErrorCatalog` 的普通路由通过;生成/RawJSON 路由在 Handler 与提前返回中间件两种入口、固定/模板/未知错误三种情况,共 12 项失败,均输出 500 和英文系统提示。
- 修复后该用例全部通过;包括 HTTP 400/数字 400002/中文提示/data:null,HTTP 200 模板提示,未知错误使用项目配置的中文 INTERNAL_ERROR。
- 独立路由用例验证相同错误标识使用路由自己的 409/业务码/提示,App 独有标识不渗入;路由的 detail 字段保持独立。
- EmptyCatalog 编码用例验证完全透传已解析结果,保留原始 error 时也不会二次查空表;非法空目录配置仍拒绝。
- 实际生成项目的 `.api` 没有任何 errorCatalog/error 定义。生成 Handler 调用手写 Service;普通路由、生成路由和全局中间件返回体完全一致,含模板参数、未知兜底和 data:null。
- 生成消费者还覆盖 RawJSON 成功不包装、@errors none 继承 App、手写原文回执、路由 body limit 及超限中文错误、手写 Service 的重生成保护。
## 本地验证
环境:go1.27.1 darwin/arm64;日期:2026-09-24。
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...` 全部通过。
- `go vet ./...`、`go build ./...`、gofmt、`git diff --check` 通过。
本轮仅本地测试及独立生成消费者,没有修改 eTax/ePay 业务代码,没有访问业务服务器;未重跑外部 Redis/MySQL、Linux soak 或完整业务迁移。
远程固定版本验收在发布后追加,标签保持不可变。

View File

@@ -0,0 +1,38 @@
# v0.11.0-preview 提示模板参数验收
## 范围与环境
通用错误目录增加模板参数及生成能力;字段名称、限制值、业务码、提示和 HTTP 状态均由项目提供。本轮没有修改 eTax/ePay 业务代码,没有访问业务服务器。
环境为 go1.27.1 darwin/arm64,验收日期 2026-09-24。网络消费者使用本地测试请求;Redis 使用独立本地临时容器,1 CPU、128 MiB、64 PID,只绑定回环地址,关闭持久化,完成后删除。
## 本地验收
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...` 全部通过,包含真实生成并编译的项目。
- `go vet ./...`、`go build ./...`、gofmt、`git diff --check` 通过。
- 模板专项覆盖:企业名称/100 与备注/500、缺参/多参/类型错误、整型范围/精度、并发隔离、修改原 map/目录副本、重复解析、WithCause 错误链、转义及非递归替换。
- 目录加载覆盖:重复/非法参数名、Go 关键字、不支持类型、未声明/未使用占位符、非法花括号、空 params、错误 example、同标识旧 messages 冲突。旧固定提示的字面花括号保持不变。
- JSON/YAML 配置保留 int64 示例与运行时参数精度;固定错误函数兼容。
- HTTP 测试覆盖:生成错误与运行时错误共用逻辑,HTTP 200、data:null、403 覆盖、500 兜底、cause 不进入公开响应、RawJSON 成功与失败策略;标准中间件、路由策略及 Context 缓冲克隆保留参数。
- 访问日志保持实际 HTTP 状态、业务码及失败标记;参数诊断不包含参数值。HTTP 200 失败计入指标。原有链路回归通过。
- 本地/共享幂等重放不重复执行业务,保存已解析公开快照;更新目录后重放仍保持原响应及错误信息。
- 最后补充生成消费者对 OpenAPI 参数类型、具体提示示例和 200 成功/失败 schema 的校验,再执行 `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -count=1 ./cmd/go-omni/apigen -run '^TestErrorParams'`。
## 真实 Redis 验收
`scripts/verifyErrorParamsRedis.py` 在独立容器中执行 `middleware/redis` 全部测试,3 项通过、0 跳过。确认模板重放、目录更新后快照不变、int64 最大值业务码通过 Lua/cjson 仍保持精度、原始参数和内部 cause 不进入持久化快照。原有共享存储并发/过期回归同时通过。
镜像、时间及资源限制见 [Redis 记录](errorParamsRedis.json)。临时容器已删除。未进行 Linux soak、代理长测或完整业务迁移验收。
## 远程固定版本验收
三条独立远程验收均通过,各自从全新 module cache 安装固定版本 CLI。生成消费者固定依赖远程版本,没有本地 replace。
- 发布标签:`v0.11.0-preview`;源码:`6e3f025aad4e498217ef4e7346725fc7d892940c`。
- module 校验和:`h1:arCeNRbzXsP3/Ocic2zTmm6Cr2nUWR1AcdzlOtoCBfo=`,三条验收一致。
- 提示模板消费者:参数顺序和类型、企业名称/100 与备注/500、生成函数/运行时一致、并发隔离、缺参/类型错误兜底、内部 cause 隔离、HTTP 200/data:null、403 覆盖、raw 输出、OpenAPI 具体示例和参数定义通过。耗时 16.91 秒,见 [模板远程记录](errorParamsRemote.json)。
- 旧固定错误目录消费者:无参数调用、同码不同提示、HTTP 状态、未知错误兜底、中间件提前返回和重生成保护通过。耗时 16.30 秒,见 [目录兼容记录](errorParamsCatalogRemote.json)。
- 迁移消费者:自定义标量、动态 JSON、上传、下载、原始请求体验签、请求体限制及重生成保护通过。耗时 16.61 秒,见 [迁移兼容记录](errorParamsCompatibilityRemote.json)。
- 三条消费者的 race 测试通过,fixture 哈希与当前文件一致;临时项目和 module cache 已清理。
验收记录在 main 单独追加,已发布标签保持不可变。

View File

@@ -0,0 +1,29 @@
{
"passed": true,
"frameworkVersion": "v0.11.0-preview",
"frameworkSum": "h1:arCeNRbzXsP3/Ocic2zTmm6Cr2nUWR1AcdzlOtoCBfo=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 16.3,
"checks": [
"remoteCLIInstall",
"errorCatalogValidation",
"generatedErrorConstructors",
"http200BusinessFailure",
"duplicatePublicCodeDistinctMessages",
"perErrorHTTPStatus",
"unknownErrorFallback",
"causeNotExposed",
"rawSuccessAndWrappedFailure",
"earlyMiddlewareCatalog",
"handwrittenFilesPreserved",
"generatedConsumerRaceTests",
"fixedRemoteModuleChecksum"
],
"sourceHashes": {
"scripts/verifyErrorCatalogRelease.py": "53b00d6069296903ba8392c68dfd6d9ae6d8c83e9b82555e55e8be0ec0f2b828",
"cmd/go-omni/apigen/errorCatalog_test.go": "0ee4365dc46dfb4ac4a3fafd2f2f0196a1e10db777ff9d931ea8f9fae4dda384",
"examples/contracts/errors.api": "bfbe0e81c6d0f87981f511f21d4d3e138cdf9287c5f6615856ac04a825b14435",
"cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go": "804cee8cdbca5fe05e6340b8ac21e5bbb7d2a5c38fa6b06b121fc745aaea62ac"
}
}

View File

@@ -0,0 +1,31 @@
{
"passed": true,
"frameworkVersion": "v0.11.0-preview",
"frameworkSum": "h1:arCeNRbzXsP3/Ocic2zTmm6Cr2nUWR1AcdzlOtoCBfo=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 16.61,
"checks": [
"remoteCLIInstall",
"scalarContractValidation",
"manualProjectGeneration",
"int64MaxValueOnWire",
"dynamicJSON",
"rawBodySignature",
"routeBodyLimits",
"multipartTempCleanup",
"unwrappedDownloads",
"handwrittenFilesPreserved",
"generatedConsumerRaceTests",
"fixedRemoteModuleChecksum"
],
"sourceHashes": {
"scripts/verifyCompatibilityRelease.py": "90e487d3203ed4eb4bc721da8086356413345b3e05193e473250a834aeb8d293",
"cmd/go-omni/apigen/compatibility_test.go": "98cf5fbe6cb6413c12f709806d385726f3dec1468575f76967786de4707667ba",
"examples/contracts/compatibility.api": "3ede909e7f60386012e55c916a8e4887dd08562e7f023665f663288c809873d4",
"cmd/go-omni/apigen/testdata/compatibility/consumer_test.go": "0a99ffd6a5cc16d47ae8208d9140c8d6afd9c19106fac8e5e61096cb90d8c463",
"cmd/go-omni/apigen/testdata/compatibility/hooks.go": "435baf37c7f8adffd18d4be0353a791124e0bc5dcb79abdcc345276b6f9867f4",
"cmd/go-omni/apigen/testdata/compatibility/echo.go": "9e13cb18a0951974e723a1fd915c0f4610d823c95eadb752c3d1cd75c44fa293",
"cmd/go-omni/apigen/testdata/compatibility/download.go": "e7f4bcdb34c90ec63fdab1f3c4e524964ac153944352d8a9053a9f19b74030fd"
}
}

View File

@@ -0,0 +1,25 @@
{
"passed": true,
"image": "sha256:f5c58b2e6e6cba5589931e8822c0ced59c507e96603d370bae3913e143f57546",
"timestamp": "2026-09-24T07:00:14.532535+00:00",
"goVersion": "go version go1.27.1 darwin/arm64",
"durationSeconds": 2.35,
"passedTests": [
"TestTemplateErrorRedisReplay",
"TestSharedRedisIntegration",
"TestExpiredTargetBeyondCleanupBatch"
],
"skippedTests": [],
"checks": [
"templateReplay",
"int64CodePrecisionThroughLua",
"catalogChangeKeepsSnapshot",
"noPersistedCauseOrParams",
"http200Failure",
"dataNull",
"oneBusinessExecution"
],
"cpuLimit": 1,
"memoryMiB": 128,
"loopbackOnly": true
}

View File

@@ -0,0 +1,35 @@
{
"passed": true,
"frameworkVersion": "v0.11.0-preview",
"frameworkSum": "h1:arCeNRbzXsP3/Ocic2zTmm6Cr2nUWR1AcdzlOtoCBfo=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 16.91,
"checks": [
"remoteCLIInstall",
"errorCatalogValidation",
"generatedErrorConstructors",
"http200BusinessFailure",
"openAPIConcreteExampleAndParameters",
"typedParameterOrder",
"companyAndRemarkMessages",
"runtimeAndGeneratedAgreement",
"missingAndWrongParameterFallback",
"concurrentIsolation",
"fixedErrorCompatibility",
"perErrorHTTPStatus",
"causeNotExposed",
"rawSuccessAndWrappedFailure",
"earlyMiddlewareCatalog",
"handwrittenFilesPreserved",
"generatedConsumerRaceTests",
"fixedRemoteModuleChecksum"
],
"sourceHashes": {
"scripts/verifyErrorParamsRelease.py": "4e9ed06a1e86a701da4eaed813ef5a2456ccfb6e8bdf88f657cb7abfede9d3c3",
"cmd/go-omni/apigen/errorParams_test.go": "0e6f79d3dec842c07cdac9d1e247f5593f189a0984ed25be7b0f9b6da6cba47d",
"cmd/go-omni/apigen/errorCatalog_test.go": "0ee4365dc46dfb4ac4a3fafd2f2f0196a1e10db777ff9d931ea8f9fae4dda384",
"examples/contracts/errorParams.api": "d6d09d60eb8079283e6622983e62a8a8c2ad2e070843e99bae6f8c8da491a44c",
"cmd/go-omni/apigen/testdata/errorParams/consumer_test.go": "01d0826cbf85126ad3c6d25e45d881655385931c6f97f242818f4d49c3416f4a"
}
}

Some files were not shown because too many files have changed in this diff Show More