Compare commits

...

5 Commits

Author SHA1 Message Date
a8c973d8a2 feat: add HTTP migration bridges and extensible API binding 2026-09-24 01:47:42 +08:00
7b8eca1a61 docs: make README the complete project integration entry point 2026-09-23 21:10:43 +08:00
35bd724326 test: isolate and record remote response release acceptance 2026-09-23 20:47:28 +08:00
7670b222ac feat: release configurable API response contracts
Define reusable response bodies in .api with typed code mappings, per-route overrides and matching OpenAPI schemas. Add application and callback encoders, early response policy selection, and isolated remote-consumer release verification.

Release v0.8.0-preview after full race, vet, build and local MySQL workflow acceptance.
2026-09-23 20:42:38 +08:00
bef7ea2641 docs: record remote API developer experience acceptance 2026-09-23 18:19:12 +08:00
76 changed files with 5896 additions and 417 deletions

26
API.md
View File

@@ -1,5 +1,7 @@
# API 契约与项目生成 # API 契约与项目生成
首次接入请先看 [README](README.md):包含固定远程版本安装、可运行项目、完整 `.api` 示例和已有服务接入步骤。本页用于查阅语法与生成规则;下方带 `--framework-dir` 的命令用于框架源码开发。
本阶段目标:从 `.api` 定义生成可运行的 Go 项目;定义、绑定、校验与 OpenAPI 保持一致;重复生成不覆盖业务实现;业务代码继续独立于网络层。实现入口是 `cmd/go-omni/apigen`,运行时契约编译器是 `contract`。 本阶段目标:从 `.api` 定义生成可运行的 Go 项目;定义、绑定、校验与 OpenAPI 保持一致;重复生成不覆盖业务实现;业务代码继续独立于网络层。实现入口是 `cmd/go-omni/apigen`,运行时契约编译器是 `contract`。
## 与 go-zero 的对应关系 ## 与 go-zero 的对应关系
@@ -89,23 +91,23 @@ service user-api {
} }
``` ```
类型和 Go 字段使用导出命名;JSON、query、path 名称使用 lowerCamelCase;Header 使用 HTTP 标准名称。响应类型只定义 `data`,统一响应的 `code`、`message`、`requestId` 由框架添加。 类型和 Go 字段使用导出命名;JSON、query、path 名称使用 lowerCamelCase;Header 使用 HTTP 标准名称。响应类型定义业务返回值。未配置时使用默认公共体;当前源码支持在 `.api` 中声明公共响应模板,选择字段和结构,并按分组复用或由单接口覆盖,详见 [公共响应体](RESPONSE.md)。
支持: 支持:
- 可省略的 `syntax = "v1"`;`info` 中的 title、version 和描述性元数据。 - 可省略的 `syntax = "v1"`;`info` 中的 title、version 和描述性元数据。
- 单独的 `type X { ... }` 与 `type (...)`;基础标量、指针、嵌套命名类型、切片、字符串键 map。嵌套对象只使用 JSON 字段。 - 单独的 `type X { ... }` 与 `type (...)`;基础标量、指针、嵌套命名类型、切片、字符串键 map。嵌套对象只使用 JSON 字段。
- 相对 `.api` 导入及 `import (...)`,重复导入去重;拒绝循环和越过入口目录的导入。单文件 1 MiB、总计 8 MiB、最多 64 个文件,限制导入/类型深度及 schema 展开规模。 - 相对 `.api` 导入及 `import (...)`,重复导入去重;拒绝循环和越过入口目录的导入。单文件 1 MiB、总计 8 MiB、最多 64 个文件,限制导入/类型深度及 schema 展开规模。
- 同一 service 名称的多个块;`@server` 的 group、prefix、middleware、auth、errors,group 可以写 `admin/user`。 - 同一 service 名称的多个块;`@server` 的 group、prefix、middleware、auth、errors、response,group 可以写 `admin/user`。
- 每个路由必须有 `@handler`;可选单行/多行 `@doc`、`@auth`、`@errors`;GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS。 - 每个路由必须有 `@handler`;可选单行/多行 `@doc`、`@auth`、`@errors`、`@response`;GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS。
- 可省略请求或响应;有响应默认 200,无响应默认 204。Omni 扩展 `@status 201`、`@status 202`;204 不允许响应类型,HEAD 不允许响应体,GET/HEAD 不允许 JSON 请求体。 - 可省略请求或响应;有响应默认 200,无响应默认 204。Omni 扩展 `@status 201`、`@status 202`;204 不允许响应类型,HEAD 不允许响应体,GET/HEAD 不允许 JSON 请求体。
- 每个请求字段只使用一个来源:`json`、`path`、`form`、`header`。本版 `form` 明确表示 URL query,不解析 multipart 或表单 body。query 切片用重复键;header/path 使用标量。 - 每个请求字段只使用一个来源:`json`、`path`、`form`、`header`。本版 `form` 明确表示 URL query,不解析 multipart 或表单 body。query 切片用重复键;header/path 使用标量。
- 来源标签中的 `optional` / `omitempty`、标量 `default`、标量 `options=a|b`、数值 `range=[1:100]`(支持开区间和单边界)。 - 来源标签中的 `optional` / `omitempty`、标量 `default`、标量 `options=a|b`、数值 `range=[1:100]`(支持开区间和单边界)。
- `binding` 的 `required`、`min`、`max`、`len`、`email`。长度按 Unicode 字符或切片元素数;范围边界必须有限且位于 ±2^53 内。 - `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` 提供。 业务错误、具名鉴权、多行文档及下述开发命令从 CLI `v0.7.0-preview.1` 提供。公共响应模板从 `v0.8.0-preview` 提供,CLI 与业务项目中的框架依赖需一起升级。
## 业务错误:声明、代码和文档共用一份定义 ## 业务错误:声明、代码和文档共用一份定义
@@ -134,7 +136,7 @@ return nil, apierrors.EmailTaken()
每次调用返回新错误,避免并发修改共享错误对象。生成器不会自动把任意数据库错误转成业务错误;由 Service/适配层调用这些函数。 每次调用返回新错误,避免并发修改共享错误对象。生成器不会自动把任意数据库错误转成业务错误;由 Service/适配层调用这些函数。
内部 Cause 可能进入访问日志,敏感驱动报错应像 userService 示例一样先归类再转换。 内部 Cause 可能进入访问日志,敏感驱动报错应像 userService 示例一样先归类再转换。
OpenAPI 对每个状态生成标准响应结构和命名 examples,同一 409 下多个错误不会相互覆盖;纯业务状态还列出 code enum。 OpenAPI 对每个状态按选定的响应模板生成结构和命名 examples,同一 409 下多个错误不会相互覆盖;平铺模式的纯业务状态还列出 code enum。
默认框架错误状态与自动添加的鉴权 401 保留开放的 code 字段,容纳校验器/中间件实际错误。 默认框架错误状态与自动添加的鉴权 401 保留开放的 code 字段,容纳校验器/中间件实际错误。
`x-omni-errors` 提供错误目录与路由关联。文档声明不拦截、改写或穷举运行时的所有错误。 `x-omni-errors` 提供错误目录与路由关联。文档声明不拦截、改写或穷举运行时的所有错误。
@@ -210,11 +212,21 @@ go test ./...
路由总览的 `(undeclared)` 仅表示没有声明 auth;旧式中间件仍可能做认证。 路由总览的 `(undeclared)` 仅表示没有声明 auth;旧式中间件仍可能做认证。
外发文档可用 export;项目内受生成器管理的 `docs/openapi.json` 请通过 generate 更新,以同步文件哈希。 外发文档可用 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"` 才会拒绝标量零值。默认值只在字段缺失时使用,不替换显式的空值。 无 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,公共响应不回显原始参数。 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,22 @@
# Changelog # Changelog
## 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` 兼容。
- 公共 JSON 响应体改为可配置模板:支持嵌套结构、独立错误体、错误码类型/映射、提示与空值行为;旧 DTO 仅保留源代码兼容。
- `.api` 支持 `response Name (...)`、分组引用和单路由覆盖;`@response raw` 输出原始业务 JSON。生成的模板、路由和 OpenAPI 使用同一份配置。
- 提供应用级/接口级编码器,以及 Text / XML / Bytes 直接输出,允许三方回调独立控制成功和失败的状态、Header、内容。
- 接口响应策略可在全局中间件之前挂载,覆盖提前鉴权/限流失败和 Context 克隆;内置路由 404/405 使用应用配置。
- 详见 [公共响应体](RESPONSE.md)。CLI 与项目框架依赖需要一起升级到本版。
## v0.7.0-preview.1 ## v0.7.0-preview.1
- `.api` 支持集中业务错误目录、组级与路由级错误声明,生成状态/错误码/提示一致的 `internal/apierrors` 函数,以及 OpenAPI 命名响应示例。 - `.api` 支持集中业务错误目录、组级与路由级错误声明,生成状态/错误码/提示一致的 `internal/apierrors` 函数,以及 OpenAPI 命名响应示例。

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
```
这些是框架验收,不代表任何业务项目已经迁移完毕;业务的路由覆盖、响应兼容、权限、资金事务和数据库行为需要业务自己的回归。

537
README.md
View File

@@ -1,314 +1,381 @@
# GoOmni # 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.9.0-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 ```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.7.0-preview.1 export GOPRIVATE=git.yuntuo.hk
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.9.0-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 ```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview.1 go-omni api new --framework-version v0.9.0-preview \
go-omni new --module example.com/billing ./billing --module example.com/hello ./hello
cd billing cd hello
go mod tidy go mod tidy
go run ./cmd/server 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 ```sh
go run ./cmd/go-omni api new --framework-dir . \ curl -i http://127.0.0.1:8080/api/greet/Alice
--module example.com/omniDemo ../omniDemo 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 ```text
go-omni/ hello/
├── app/ # 应用启动和生命周期 ├── api/service.api # 维护接口定义,也可 import 其他 .api
├── httpx/ # 与网络框架无关的 HTTP 抽象 ├── cmd/server/main.go # 启动、路由注册、优雅退出
├── response/ # 统一响应 ├── etc/config.yaml # 项目配置
├── errors/ # 统一错误 ├── internal/
├── health/ # 存活和就绪检查 │ ├── settings/ # 配置读取
├── middleware/ # 请求 ID 等基础中间件 │ ├── servicecontext/ # 数据库、日志、外部客户端等依赖
├── logx/ # 业务层结构化日志 │ ├── types/typesGen.go # 生成的请求与业务返回类型
├── metrics/ # 请求指标与状态统计 │ ├── handler/ # 生成的绑定、响应策略、路由及文档
├── config/ # 配置管理 │ ├── service/greet/ # 在这里实现业务逻辑
├── validate/ # 参数校验 │ ├── repository/greet/ # 在这里实现数据访问
├── contract/ # API 字段契约、严格多源绑定与文档约束 │ └── middleware/ # 声明鉴权等中间件后生成对应 hook
├── auth/ # 认证和权限接口 ├── docs/openapi.json # 生成的 OpenAPI 3.0.3
├── database/ # database/sql 生命周期和事务 └── .goOmni/apiManifest.json # 生成文件所有权与校验信息,一起提交 Git
├── 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 项目脚手架
``` ```
main 分支新增缓存、出站 HTTP、限流/幂等/超时中间件、后台任务和 `go-omni add` 模块脚手架。 修改 `api/service.api` 后重新生成,业务写在 `internal/service`,数据访问写在 `internal/repository`,依赖在 `internal/servicecontext` 装配。声明业务错误后还会生成 `internal/apierrors/errorsGen.go`。
使用方式、默认容量和进程内能力的适用范围见 [业务基础模块](MODULES.md),
可以直接运行 [组合示例](examples/modules/README.md)。这些能力从 `v0.7.0-preview` 提供。
扩展模块还包括 JWT/Session、Policy、文件配置及安全热更新、Redis 共享中间件、持久队列、多平台对象存储、OpenAPI、OpenTelemetry、熔断、迁移、审计和分页。 `api new` 的问候接口可以直接运行;自定义接口新生成的 Service 和鉴权 hook 返回 **501**,需要实现后才可用。生成器不会根据 HTTP 字段猜数据库表或业务规则。
使用与边界见 [扩展模块](ADVANCED.md),可运行 [文件服务组合示例](examples/platform/README.md)。go-omni 不集成 Orbit;需要流程编排的业务项目独立接入。
## 快速开始 ### 从自己的接口定义生成
保存下面的完整示例为 `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.9.0-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.9.0-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.9.0-preview --module example.com/billing ./billing` 创建手写接口的 CRUD 示例项目;两种生成方式选择一种即可。
## 已有项目:按模块接入
在业务项目的 **go.mod 所在目录**执行:
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.9.0-preview
```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。
下面是可独立运行的 `main.go` 示例;迁入现有项目时,把路由调用接到自己的业务服务即可。
```go ```go
package main 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 ( import (
"context" "log"
"log" "net/http"
"os"
"os/signal"
"syscall"
omni "git.yuntuo.hk/k2safe/go-omni" omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/transport/native" "git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/response"
) )
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) func main() {
defer cancel() if err := run(); err != nil {
log.Fatal(err)
}
}
app := omni.New() func run() error {
app.GET("/health", func(ctx *omni.Context) error { closeLog, err := logx.Setup(logx.Config{
return ctx.OK(map[string]string{"status": "ok"}) Service: "catalog", Level: "info", Format: "json", Output: "stdout",
}) })
if err := app.RunWith(ctx, native.New(nil), ":8080"); err != nil { if err != nil {
log.Fatal(err) 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` | | `.api` 生成的接口 | `response Name (...)` 定义,`@server (response: Name)` 引用 |
| `GO_OMNI_TLS_CERT_FILE` | TLS 证书 PEM 文件路径 | | 多个 `.api` 共用返回体 | 将声明放到 `common.api`,使用 `import "common.api"` |
| `GO_OMNI_TLS_KEY_FILE` | TLS 私钥 PEM 文件路径,必须与证书一起配置 | | 某个生成接口用另一种 JSON 公共体 | 路由声明 `@response OtherResponse` |
| `GO_OMNI_SHUTDOWN_TIMEOUT` | `10s`,三种适配器共用的请求排空时间 | | 只要业务 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 - `codeType` 可以是 `string`、`int32`、`int64`;整数类型需要配置 `successCode` 和 `errorCode`。
GO_OMNI_TRANSPORT=native go run ./examples/reference - `codes` 把逻辑错误码映射成对外业务码;未单独映射的失败使用 `errorCode`。JSON 业务码不会改变 HTTP 状态,例如 `404001` 仍对应 HTTP 404。
GO_OMNI_TRANSPORT=http2 GO_OMNI_HTTP_ADDR=127.0.0.1:8443 \ - `successMessage` 设置成功提示;未设置 `errorMessage` 时沿用错误的公开提示。`messages` 可按逻辑错误码单独覆盖,优先级最高。全局 `errorMessage` 会替换未单独映射的所有失败提示,只有需要统一失败文案时才设置。
GO_OMNI_TLS_CERT_FILE=./server.crt GO_OMNI_TLS_KEY_FILE=./server.key \ - `dataMode: always` 在无数据时输出 null;默认 `omitNil` 省略模板里相应对象字段。没有 `returns` 的 `.api` 路由默认 HTTP 204、无响应体,这与 `200 + data:null` 是不同的接口契约。
go run ./examples/reference
```
`http2` 必须配置 TLS;native TLS 只协商 `http/1.1`,nethttp TLS 支持标准库的 HTTP/1.1 和 HTTP/2。 **生成接口的格式以 `.api` 为准**,修改后重新生成,运行时与 OpenAPI 一起更新;应用 YAML 不会覆盖它。404/405 没有匹配到某个 `.api` 接口,使用应用默认配置,需要统一时也配置应用默认值。
未知适配器、缺少配对证书、无法加载证书时,`Run` / `RunContext` 在监听前返回错误并执行资源清理,不会退回其他协议。
HTTP/2 仍为实验适配器,尚未合并到 native 多协议监听器。
代码配置可写成 `omni.New(omni.WithTransport(omni.TransportNative), omni.WithTLS(certFile, keyFile))`。 文本/XML 回调用 Go 编码器或直接输出,`.api` 可用 `@handlerMode manual` 与 `@produces` 声明成功媒体类型;特殊错误协议仍需项目维护。`@response raw` 仅表示原始 JSON。前面示例的 `c.Text` 控制这次正常返回;需要连鉴权失败、业务错误和 panic 都按回调协议响应时使用 `HandleWithResponse`。完整配置和回调示例见 [响应参考](RESPONSE.md)。
`ServeContext(ctx, listener)` 使用相同配置并接管已有 listener;失败或退出时也会关闭它。
`RunWith(ctx, runner)` 保留自定义网络层入口,由 runner 自己管理 TLS、超时和网络参数;连接/请求上限等适配器专属选项仍通过这个入口设置。
`NewServer` 仅创建标准库明文服务器,配置选择应用于 `Run`、`RunContext` 和 `ServeContext`。
三种适配器的配置、真实 TLS 业务和关闭行为已通过 Linux 全量 race 及 10 次重复回归,见 [统一启动验收](performance/transportStartup.md)。
`App` 同时实现标准 `http.Handler`。`Run` 监听 `SIGINT` / `SIGTERM`;`RunContext` 则由调用方的 context 控制退出,生成项目已使用 `signal.NotifyContext` 接好信号。 ## 业务开发与依赖装配
退出时先停止接收新请求,等待活跃请求完成,再执行资源清理。超过 `ShutdownTimeout` 时强制关闭连接、取消请求 context,并返回超时错误。业务代码应响应 context 取消;框架无法强制结束忽略取消的业务 goroutine。数据库连接池由 `database.Open` 创建,在服务退出时调用 `Close`。
应用资源可以直接挂到 `omni.New` 返回的生命周期上: ### 日志
业务包直接使用 `logx`,不需要 HTTP 框架对象:
```go ```go
application := omni.New() // ctx 是 Service 接收的标准 context.Context。
application.OnShutdown(db.Close) 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)。
```go ### 鉴权与业务错误
checker, err := health.New(health.Probe{
Name: "database", `.api` 可声明认证方式和关联中间件,例如把下面的声明放入契约,并在相应 `@server` 块中加上 `auth: UserAuth`:
Check: db.PingContext,
}) ```text
if err != nil { auth UserAuth (
log.Fatal(err) type: bearer
} middleware: Auth
application.GET("/health/live", checker.LivenessHandler()) )
application.GET("/health/ready", checker.ReadinessHandler())
``` ```
存活检查只确认进程在线;就绪检查会执行全部探针,任意依赖失败时返回 HTTP 503。 生成器会创建 `internal/middleware/auth.go`,由项目实现凭据校验和身份注入。声明会生成路由绑定与 OpenAPI 的认证描述,**不会自动实现登录或授权规则**;未实现的 hook 返回 501。手写路由可使用 `auth.RequireAuth(authenticator)` 或自己的中间件。JWT、Session 和 Policy 按需选择。
新项目脚手架会生成项目自己的 `internal/servicecontext`。它集中保存 Logger、数据库、缓存和外部客户端,Service 只依赖这个项目上下文,不把基础设施初始化散落到各个 Handler: 业务错误在 `.api` 中用 `error` 声明,通过 `@errors` 关联路由后,生成可调用的错误函数和 OpenAPI 中对应的 401、403、409 等响应。手写路由可以直接返回 `omni.NotFound`、`omni.Forbidden` 等错误。已有项目在自己的适配层转换原错误,内部原因和公开提示分开处理。
```go ### 数据库、存储及其他模块
services, err := servicecontext.New(ctx, logger)
if err != nil {
return err
}
defer services.Close()
userService := service.NewUsers(services)
```
## 完整业务参考项目 依赖在启动入口或项目自己的 `internal/servicecontext` 中创建,通过构造函数传给 Service/Repository,避免每个请求都新建连接池。只注册自己拥有的资源,例如 `app.OnShutdown(db.Close)`;关闭按注册的逆序执行。
[`examples/reference`](examples/reference/README.md) 提供按 Handler / Service / Repository / Model 分层的用户 CRUD 示例。默认使用并发安全的内存存储,支持分页、邮箱唯一校验、结构化请求日志和统一错误,无需配置数据库。 | 能力 | 包与接入说明 |
| --- | --- |
| 数据库连接池、事务、显式迁移 | `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_SHUTDOWN_TIMEOUT` | 优雅退出等待时间,默认 `10s` |
| `GO_OMNI_ENABLE_LOGGER` / `GO_OMNI_LOG_LEVEL` | 访问日志开关、日志级别 |
| `GO_OMNI_TRUSTED_PROXIES` | 可解析转发地址头的可信代理 IP/CIDR,逗号分隔 |
| `GO_OMNI_DB_DSN` | 选择 MySQL 初始化时的数据库连接配置 |
例如在生成项目中切换 native:
```sh ```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` 可测量独立服务进程并保存报告。 自研网络层通过 `RunWith(ctx, runner)` 接入监听与连接生命周期,通过 `NewWithRouter(router)` 替换路由;业务 Handler/Service 不需要导入具体适配器。细节与验收契约见 [网络适配层](transport/README.md)。
HTTP/2 仍是显式 opt-in 实验适配器,不会改变 native 的 HTTP/1.1 默认路径。Linux TLS 长测使用 `python3 scripts/verifyHTTP2.py --duration 30m`,默认使用普通 Go 运行模式;`--race` 默认改为 60 秒,用于并发检查,其额外内存开销不能当成生产运行基线。 `Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。服务先停止接收新请求,等待活跃请求完成,再清理资源;超时会取消请求并关闭连接,业务代码仍需响应 context 取消。
多连接混合场景使用 `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 的采样。 ## 日常开发与升级
该脚本默认把测试容器限制为 2 核、2 GiB 内存(禁用 swap)、256 个进程/线程,整个命令最多运行 40 分钟,中断和超时会清理本次容器。这些限制可用 `--cpus`、`--memory-mb`、`--pids-limit`、`--wall-timeout` 调整;共享服务器应先用 `--duration 60s` 短测,再逐步延长。每 15 秒输出的堆、RSS 和 FD 统计包含同一进程内的客户端与服务端。 在已生成的 API 项目目录执行:
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)。这项结果覆盖单连接复用。
多连接慢客户端场景也已完成 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)。
在框架根目录运行:
```sh ```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.9.0-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)。 当前版本可用于固定版本接入与业务验证,仍标记为 preview。`.api` 借鉴 go-zero 的接口开发方式,支持范围以本仓库文档为准,不是完整 goctl 语法兼容层;HTTP/2 保持实验状态,OSS/R2 云账号实网验收不属于本次发布范围。

View File

@@ -1,71 +1,43 @@
# v0.7.0-preview.1 # v0.9.0-preview
这一版完善接口开发体验:业务错误和鉴权可在 .api 中声明,代码调用与 OpenAPI 同步;增加路由总览、文档导出和无写入的生成预览。 本版补齐已有 HTTP 项目的迁移接入能力:标准 Handler/Middleware 桥接、动态 JSON、路由级请求体限额与手写绑定、自定义标量转换。业务的响应格式、身份、资金事务和数据库实现仍由项目决定。
预览版用于固定版本试用;API 方言有明确范围,HTTP/2 仍是独立实验适配器。
## 安装与开始 ## 安装与升级
```sh ```sh
GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview.1 export GOPRIVATE=git.yuntuo.hk
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.9.0-preview
go-omni api new --module example.com/demo ./demo # 在业务 module 中:
cd demo go get git.yuntuo.hk/k2safe/go-omni@v0.9.0-preview
GOPRIVATE=git.yuntuo.hk go mod tidy
go run ./cmd/server
``` ```
需要该 Git 仓库的读取权限时,通过自己的 Git 凭据管理器配置;不要将凭据写入 module path 或 go.mod。 将私有域名追加到已有 GOPRIVATE;凭据通过本机 Git 凭据管理器配置。CLI 与项目框架依赖一起升级。进入已生成的项目,再运行 `go-omni api generate --dry-run`、`go-omni api generate`、`go test ./...`。
CLI 会将自身下载版本固定到生成项目的 go.mod。业务项目正常使用远程模块,不需要本地 replace。
使用已有定义,并一次选择基础模块: ## 本次变化
```sh - `httpx.WrapHandler`、`WrapMiddleware`、`WrapFuncMiddleware` 将现有标准代码接入 App 中间件链。Raw 写入会记录真实状态和提交;已经输出的响应不再追加错误 JSON。下载/流式输出不整体缓存,保留底层支持的 HTTP 可选能力和取消信号。
go-omni new --api user.api --module example.com/userService \ - `.api` 的 `any` / `interface{}` 支持动态 JSON。数字保留为 json.Number,外围 DTO 严格校验不变。
--database mysql --storage local --log-output file ./userService - `@body` 按路由声明限额;内置 App 在全局中间件读取前限制 body。`@binding custom`、`@handlerMode manual` 生成保留的项目实现文件;上传、原文验签、下载可自行处理。`@produces` 描述手写成功输出的媒体类型。
``` - `scalar` 显式引用项目 Go 类型,调用标准 JSON/Text 方法;输入和输出各自生成 OpenAPI。普通基础类型不会自动放宽。
MySQL 与存储需要按生成 README 设置环境变量。任意契约生成的业务方法先返回 501, 详见 [迁移扩展指南](MIGRATION.md)、[完整契约](examples/contracts/compatibility.api)、[运行示例](examples/compatibility/main.go) 和 [历史更新](CHANGELOG.md)。
由业务方实现;可直接参考 [完整用户示例](examples/userService/README.md)。
## 主要变化 ## 兼容与边界
- `error (...)` + `@errors` 声明业务错误,生成 `apierrors.EmailTaken()` 等全新实例构造函数和错误码常量。 原有 `.api`、公共响应配置和普通 Handler 可以继续使用。新生成代码最低运行时版本为本版。`Raw()` 现在返回跟踪包装器;直接断言某个具体 writer 类型的代码需改用标准接口/ResponseController。
- `auth Name (...)` + `@auth` 同步 OpenAPI security 与实际路由中间件,声明本身不替代凭证验证。
- 多行 `@doc` 支持 description/tags/deprecated;一个状态可有多个业务错误示例。
- 项目内直接 `api inspect`、`api export`、`api generate --dry-run`,无需重复指定入口文件。
此前 v0.7.0-preview 已提供: 标准中间件须保留 incoming request context 并同步调用 next;第三方框架私有的参数读取器仍由项目适配。手写 Handler/绑定 hook 初始返回 501;实现和文档一致性由项目负责,未实现不能上线。切换 manual 回自动模式时须显式迁移并移除对应手写文件,生成器不会删除它。
- `.api` 生成项目、绑定/校验、路由、业务骨架和 OpenAPI;支持分组、导入、命名中间件。 新增能力没有引入 Gin 或 go-zero 依赖。不是完整 goctl 语法兼容层,不代表任何业务项目已完成迁移。HTTP/2 仍为实验功能,本轮没有重新做 Linux 长测、代理验收或云存储账号实网验收。
- 同一入口初始化 MySQL、日志、Local / S3 / R2 / OSS;标准 context 与 httpx 保持业务/网络分离。
- 生成器保护手写业务文件,校验自有文件哈希,支持只读 --check;基础设施选项持久化到 manifest。
- 完整用户示例包含数据库迁移、CRUD / 分页、事务审计、鉴权、文件导出与 requestId 日志。
- 日志文件轮转/结构化脱敏、上传策略/直传/分片,以及此前 main 分支的通用业务模块一并提供。
所有变化及网络层验收记录见 [CHANGELOG](CHANGELOG.md)。
## 升级注意
1. 将 CLI 和项目框架依赖固定为相同版本。旧版 CRUD 项目继续使用现有目录;不能直接用 API 生成器接管已有 Go 目录。
2. Gen.go 由生成器维护,业务实现/配置/迁移由项目维护。新增 API 后执行 generate 和 go test;修改或删除类型需要同步处理自己的业务代码。
3. `.goOmni/apiManifest.json` 需随代码提交。切换数据库或存储选项时,在新目录生成对照后迁移;重新生成不会改写现有基础设施文件。
4. 配置使用 lowerCamelCase。DSN、Token、存储密钥由环境变量提供;`database.Config` 的 DSN 不会被 JSON/YAML 序列化。
5. 旧 `.api` 仍可解析。新语法需要本版 CLI;生成的错误函数沿用已有框架错误类型。旧 CLI 无法解析新增声明,请一起升级。鉴权引用的已有 hook 和业务函数仍保留,不会自动重写。
6. API 的 none/memory 数据库选项仅表示不打开连接,不会从 HTTP DTO 自动推导内存仓库或数据表。
## 验证入口 ## 验证入口
```sh ```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=2 -count=1 -timeout=300s ./... GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
go vet ./... go vet ./...
go build ./... go build ./...
python3 scripts/verifyAPIWorkflow.py python3 scripts/verifyCompatibilityRelease.py --version v0.9.0-preview \
python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview.1 \ --report performance/compatibilityRemote.json
--report performance/apiDeveloperExperienceRemote.json
``` ```
最后一项在全新 module cache 安装远程 CLI,并验证无本地 replace 的项目生成、 测试覆盖旧 Handler 404 与访问日志、提交后错误/panic、标准中间件状态/取消/提前写响应、nethttp/native 实际流式下载与请求体超限、multipart 临时文件清理、自定义标量精度/越界/nullable/集合,以及真实生成项目的验签与重生成保护。远程脚本从全新 module cache 安装 CLI,并检查生成项目的固定版本、校验和以及没有本地 replace。
真实 MySQL、nethttp/native 启动与业务流程。容器只使用本机临时资源,结束时清理。
本轮验收见 [接口开发体验报告](performance/apiDeveloperExperienceAcceptance.md);此前业务闭环见 [API 业务验收](performance/apiWorkflowAcceptance.md)。
OSS/R2 云端仍需项目自己的账号做上线环境验收;当前云端适配器检查不等同于这些账号的实网测试。

140
RESPONSE.md Normal file
View File

@@ -0,0 +1,140 @@
# 公共响应体与独立接口协议
这些功能从 `v0.8.0-preview` 提供。CLI 与项目框架依赖请固定到同一版本;源码开发可显式使用 `--framework-dir`。`v0.7.0-preview.1` 不包含响应模板声明。
go-omni 的响应编码由应用或接口配置。没有配置时保留旧版默认值;业务可以定义自己的字段、嵌套结构、错误码类型、提示和空值行为。`response.Response` 仅保留为兼容 DTO,运行时与生成器不再通过它固定响应结构。
## 在 .api 中定义一次、到处引用
```go
response CommonResponse (
body: {
"code": "${code}",
"msg": "${message}",
"data": "${data}"
}
codeType: int32
successCode: 200
errorCode: 9999
successMessage: "操作成功"
dataMode: always
codes: {"USER_NOT_FOUND":404001}
messages: {"USER_NOT_FOUND":"用户不存在"}
)
@server (
group: user
response: CommonResponse
)
service user-api {
@handler getUser
get /users/:userId (GetUserReq) returns (UserResp)
@response raw
@handler rawUser
get /rawUsers/:userId (GetUserReq) returns (UserResp)
}
```
请求/业务返回类型照常在 `type` 中定义。上例 `UserResp` 是业务数据,由模板中的 `${data}` 放入公共体。请求失败时,同一模板输出映射后的业务错误。错误本身的 HTTP 状态保持不变,业务 `code` 与 HTTP 状态相互独立。
- 公共声明可以放进 `common.api`,其他契约通过 `import "common.api"` 复用。
- `@server (response: CommonResponse)` 指定整个 service 块的公共体;不同块可以引用同一个模板。
- `@response PartnerResponse` 为单接口替换模板,成功和错误一起生效。
- `@response raw` 让成功 JSON 直接返回业务对象,错误沿用组级模板;组本身为 raw 时,错误使用默认模板。
- 模板名使用 UpperCamelCase;未知模板、未知配置、错误的占位符和冲突设置在生成前报错。
**生成接口以 `.api` 为响应契约来源。** 生成器把选定模板编译进 `internal/handler/responseGen.go`,在全局鉴权、限流等应用中间件之前选定,然后再执行路由中间件和业务。OpenAPI 与运行时共用配置和映射规则。即使 `.api` 没有声明模板,生成接口也明确使用默认契约,不会被应用的 YAML 设置静默改变。
业务只需返回业务数据或业务错误,无需每个 Handler 手工组装公共体。生成器维护模板、路由及文档文件,重复生成不覆盖手写 Service、Repository、middleware 和配置。
## 自定义结构与占位符
公共体没有规定必须出现的字段,也可以把成功和失败分开:
```go
response PartnerResponse (
body: {
"accepted": "${ok}",
"payload": "${data}",
"meta": {"trace":"${requestId}","httpStatus":"${status}"},
"version": 1
}
errorBody: {
"accepted": "${ok}",
"error": {"reason":"${message}","id":"${code}"}
}
)
```
`body` 是 JSON 对象,可嵌套对象、数组和常量;`errorBody` 可选,需要同时定义 `body`。空对象 `{}` 也是有效模板。
| 整值占位符 | 输出值 |
| --- | --- |
| `${data}` | 当前接口的业务返回值,保留对象/数组/数值等原类型 |
| `${code}` | 映射后的 string / int32 / int64 业务错误码 |
| `${message}` | 公开提示,经 message 配置映射 |
| `${requestId}` | 请求标识;为空时省略所在对象字段 |
| `${ok}` | 成功为 true;有错误或 HTTP 状态 ≥400 时为 false |
| `${status}` | HTTP 状态整数 |
占位符只替换整个值,不执行表达式或字符串拼接。字面量 `${data}` 写成 `$${data}`。数据为 nil 时,`dataMode: omitNil`(默认)省略所在对象字段;`always` 输出 null。数组中的占位符保留位置。模板最多嵌套 24 层。
`codeType` 使用与业务字段一致的类型名,默认 `string`:
| 类型 | 含义 | OpenAPI |
| --- | --- | --- |
| `string` | 字符串业务码,例如 `"OK"`、`"USER_NOT_FOUND"` | string |
| `int32` | 32 位有符号整数,−2147483648 到 2147483647 | integer / int32 |
| `int64` | 64 位有符号整数,−9223372036854775808 到 9223372036854775807 | integer / int64 |
整数类型必须提供 `successCode` 和 `errorCode`,支持 0 和负数;默认码和 `codes` 映射都会校验范围,越界或小数会报错。`int64` 的配置读取和生成代码保留完整整数精度,并按 JSON 数值输出;消费端也需要支持对应的整数精度。需要文本形式时可用 `string` 并配置字符串码。
旧写法 `integer` 保留兼容,沿用 ±(2^53−1) 的数值范围;新配置优先写 `int32` 或 `int64`。
`codes` 按逻辑错误码覆盖默认码;`messages` 优先于 `successMessage` / `errorMessage`。未配置的公开提示沿用原错误消息;内部 cause 不会被默认编码器序列化。
简单的平铺响应也可使用 `codeField` / `messageField` / `dataField` / `requestIdField` 快捷配置,`"-"` 禁用字段。空名称沿用旧默认值。这些字段名快捷配置不能和 `body` 混用,以免两个定义冲突。
## 手写路由、配置文件与三方回调
手写路由的应用默认配置可以放在 JSON/YAML 的 `response` 字段,也可用 Go 选项:
```go
app := omni.New(omni.WithResponse(response.Config{
Body: map[string]any{"code":"${code}", "msg":"${message}", "data":"${data}"},
CodeType: "int32", SuccessCode: 200, ErrorCode: 9999, DataMode: "always",
}))
```
应用之间的配置独立,模板在创建时验证并复制;没有全局可变的响应格式。应用配置快照也会复制模板和映射。响应配置属于静态配置,变更后需重启。
单次显式输出可使用 `c.JSON(status, value)`、`c.Text(status, text)`、`c.XML(status, value)`、`c.Bytes(status, contentType, bytes)` 或 `c.NoContent()`,都绕过公共体。已经提交的响应不会再追加错误 JSON。
三方回调可能要求成功与失败都返回 HTTP 200,并分别输出 `success` / `fail`。通过接口编码器统一处理成功、错误和 panic:
```go
ack := response.EncoderFunc(func(r response.Result) (response.Payload, error) {
if r.Err != nil || r.Status >= 400 {
return response.Text(200, "fail"), nil
}
return response.Text(200, "success"), nil
})
app.HandleWithResponse("POST", "/hooks/payment", ack, paymentHandler)
```
编码器也可以设置 HTTP 状态、Header、XML 或任意字节。`omni.WithResponseEncoder` 设置应用默认编码器;`httpx.WithResponse` 是普通路由中间件,按中间件顺序生效。需要覆盖全局中间件提前拒绝时,应使用 `HandleWithResponse`。自定义 Registrar 可以实现 `httpx.ResponseRegistrar` 提供相同的早期挂载能力;未实现时辅助函数退化为普通路由中间件。
`@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,避免递归编码或泄露另一种响应体。
## 运行示例与验证
```sh
go run ./cmd/go-omni api validate --api examples/contracts/responses.api
go run ./cmd/go-omni api generate --api examples/contracts/responses.api \
--module example.com/responseDemo --framework-dir . --dir ../responseDemo
```
示例业务方法起初为 501,完成 Service 后就会套用声明的模板。参考 [完整 .api](examples/contracts/responses.api)。验收测试会在临时目录生成真实项目,写入业务方法,执行已生成路由,检查数字错误码、原始 JSON、鉴权错误、路由覆盖与重复生成保护。

View File

@@ -15,6 +15,7 @@ import (
apierrors "git.yuntuo.hk/k2safe/go-omni/errors" apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/middleware" "git.yuntuo.hk/k2safe/go-omni/middleware"
"git.yuntuo.hk/k2safe/go-omni/response"
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp" netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
) )
@@ -28,6 +29,7 @@ type App struct {
closeOnce sync.Once closeOnce sync.Once
closeErr error closeErr error
clientIPResolver *httpx.ClientIPResolver clientIPResolver *httpx.ClientIPResolver
responseEncoder response.Encoder
} }
// Runner is the small lifecycle boundary used by an HTTP server // Runner is the small lifecycle boundary used by an HTTP server
@@ -64,6 +66,15 @@ func configure(options ...config.Option) config.Config {
} }
func newApp(router httpx.Router, settings config.Config) *App { func newApp(router httpx.Router, settings config.Config) *App {
settings.Response = settings.Response.Clone()
encoder, responseErr := response.New(settings.Response)
if responseErr != nil {
panic(responseErr)
}
var responseEncoder response.Encoder = encoder
if settings.ResponseEncoder != nil {
responseEncoder = settings.ResponseEncoder
}
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...) settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...) settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
resolver, err := httpx.NewClientIPResolver(settings.TrustedProxies...) resolver, err := httpx.NewClientIPResolver(settings.TrustedProxies...)
@@ -75,7 +86,16 @@ func newApp(router httpx.Router, settings config.Config) *App {
middlewares = append(middlewares, middleware.AccessLog(middleware.WithSlowRequestThreshold(settings.SlowRequestThreshold))) middlewares = append(middlewares, middleware.AccessLog(middleware.WithSlowRequestThreshold(settings.SlowRequestThreshold)))
} }
middlewares = append(middlewares, middleware.Recovery()) middlewares = append(middlewares, middleware.Recovery())
return &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver} application := &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver, responseEncoder: responseEncoder}
if fallback, ok := router.(httpx.FallbackRouter); ok {
adaptError := func(status int, code, message string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
application.adapt(func(*httpx.Context) error { return apierrors.New(status, code, message) }).ServeHTTP(w, r)
})
}
fallback.SetFallbacks(adaptError(404, "NOT_FOUND", "route not found"), adaptError(405, "METHOD_NOT_ALLOWED", "method not allowed"))
}
return application
} }
// Router returns the transport contract used by this application. // Router returns the transport contract used by this application.
@@ -93,6 +113,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
// Config returns the effective application configuration. // Config returns the effective application configuration.
func (a *App) Config() config.Config { func (a *App) Config() config.Config {
settings := a.config settings := a.config
settings.Response = settings.Response.Clone()
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...) settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...) settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
return settings return settings
@@ -235,5 +256,5 @@ func (a *App) writeError(ctx *httpx.Context, err error) {
if err == nil || ctx.Responded() { if err == nil || ctx.Responded() {
return return
} }
ctx.WriteError(apierrors.From(err)) ctx.WriteError(err)
} }

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

View File

@@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/response"
) )
func (a *App) handle(routes httpx.RouterGroup, method, path string, handler httpx.Handler, middleware []httpx.Middleware) { func (a *App) handle(routes httpx.RouterGroup, method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
@@ -11,6 +12,10 @@ func (a *App) handle(routes httpx.RouterGroup, method, path string, handler http
} }
func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.Handler { func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
return a.adaptResponse(a.responseEncoder, handler, middleware...)
}
func (a *App) adaptResponse(encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
wrapped := handler wrapped := handler
all := make([]httpx.Middleware, 0, len(a.middlewares)+len(middleware)) all := make([]httpx.Middleware, 0, len(a.middlewares)+len(middleware))
all = append(all, a.middlewares...) all = append(all, a.middlewares...)
@@ -25,6 +30,8 @@ func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), a.clientIPResolver)) request = request.WithContext(httpx.WithClientIPResolver(request.Context(), a.clientIPResolver))
} }
requestContext := httpx.NewContext(request, writer, httpx.RouteParams(request.Context())) requestContext := httpx.NewContext(request, writer, httpx.RouteParams(request.Context()))
requestContext.SetResponseEncoder(encoder)
defer func() { httpx.CleanupMultipart(requestContext.Request()) }()
if err := wrapped(requestContext); err != nil { if err := wrapped(requestContext); err != nil {
a.writeError(requestContext, err) a.writeError(requestContext, err)
} }
@@ -37,6 +44,14 @@ func (a *App) Handle(method, path string, handler httpx.Handler, middleware ...h
a.handle(a.router, method, path, handler, middleware) a.handle(a.router, method, path, handler, middleware)
} }
// HandleWithResponse selects the route policy before all application middleware.
func (a *App) HandleWithResponse(method, path string, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
if encoder == nil {
encoder = a.responseEncoder
}
a.router.Handle(method, path, a.adaptResponse(encoder, handler, middleware...))
}
func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) { func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
a.handle(a.router, http.MethodGet, path, handler, middleware) a.handle(a.router, http.MethodGet, path, handler, middleware)
} }
@@ -94,6 +109,15 @@ func (g *RouterGroup) Handle(method, path string, handler httpx.Handler, middlew
g.route(method, path, handler, middleware) g.route(method, path, handler, middleware)
} }
func (g *RouterGroup) HandleWithResponse(method, path string, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
if encoder == nil {
encoder = g.app.responseEncoder
}
combined := append([]httpx.Middleware{}, g.middlewares...)
combined = append(combined, middleware...)
g.group.Handle(method, path, g.app.adaptResponse(encoder, handler, combined...))
}
func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) { func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
g.route(http.MethodGet, path, handler, middleware) g.route(http.MethodGet, path, handler, middleware)
} }

View File

@@ -1,5 +1,7 @@
# go-omni CLI # go-omni CLI
首次接入见 [README](../../README.md),无需克隆框架源码。本页说明命令参数;下方 `go run ./cmd/go-omni` 示例在框架仓库中执行,并显式引用本地源码。
## API 契约驱动项目 ## API 契约驱动项目
```sh ```sh
@@ -73,8 +75,8 @@ Handler / Service / Repository / Model 与模块注册入口。模块名为 lowe
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架: 从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
```sh ```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview.1 GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.9.0-preview
go-omni new --module example.com/billing ./billing go-omni new --framework-version v0.9.0-preview --module example.com/billing ./billing
cd billing cd billing
go mod tidy go mod tidy
go run ./cmd/server go run ./cmd/server

View File

@@ -124,7 +124,7 @@ func runAPI(args []string) error {
fmt.Println(string(compiled.Document)) fmt.Println(string(compiled.Document))
case "inspect": case "inspect":
writer := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) writer := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(writer, "METHOD\tPATH\tHANDLER\tAUTH\tERRORS") fmt.Fprintln(writer, "METHOD\tPATH\tHANDLER\tAUTH\tRESPONSE\tERRORS")
definitions := map[string]apigen.ErrorDefinition{} definitions := map[string]apigen.ErrorDefinition{}
for _, e := range spec.Errors { for _, e := range spec.Errors {
definitions[e.Name] = e definitions[e.Name] = e
@@ -139,7 +139,11 @@ func runAPI(args []string) error {
e := definitions[name] e := definitions[name]
errs = append(errs, fmt.Sprintf("%d:%s", e.Status, e.Code)) errs = append(errs, fmt.Sprintf("%d:%s", e.Status, e.Code))
} }
fmt.Fprintf(writer, "%s\t%s\t%s/%s\t%s\t%s\n", strings.ToUpper(r.Method), r.Path, r.Group, r.Handler, auth, strings.Join(errs, ", ")) format := r.ResponseFormat
if format == "" {
format = "default"
}
fmt.Fprintf(writer, "%s\t%s\t%s/%s\t%s\t%s\t%s\n", strings.ToUpper(r.Method), r.Path, r.Group, r.Handler, auth, format, strings.Join(errs, ", "))
} }
return writer.Flush() return writer.Flush()
} }

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{} definitions := map[string]Type{}
for _, t := range spec.Types { 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) { if !exportedName.MatchString(t.Name) {
return nil, fmt.Errorf("%s: type names must be UpperCamelCase", t.Position) 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 definitions[t.Name] = t
} }
scalarTypes, scalarDefs, err := compileScalars(spec, definitions)
if err != nil {
return nil, err
}
resolved := map[string]reflect.Type{} resolved := map[string]reflect.Type{}
resolving := map[string]bool{} resolving := map[string]bool{}
var resolve func(string, int) (reflect.Type, error) var resolve func(string, int) (reflect.Type, error)
var expression func(ast.Expr, 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) { expression = func(e ast.Expr, depth int) (reflect.Type, error) {
if depth > 32 { if depth > 32 {
return nil, errors.New("type nesting exceeds 32") return nil, errors.New("type nesting exceeds 32")
} }
switch e := e.(type) { switch e := e.(type) {
case *ast.Ident: case *ast.Ident:
if t, ok := scalarTypes[e.Name]; ok {
return t, nil
}
if t, ok := primitives[e.Name]; ok { if t, ok := primitives[e.Name]; ok {
return t, nil return t, nil
} }
@@ -140,7 +150,7 @@ func Compile(spec *Spec) (*Compiled, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
b, err := contract.ForType(rt) b, err := contract.Describe(rt, scalarDefs...)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s: %w", t.Position, err) 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 { if r.Status != 200 && r.Status != 201 && r.Status != 202 && r.Status != 204 {
return nil, errors.New("@status supports 200, 201, 202 or 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") return nil, errors.New("204 requires no response; other success statuses require a response type")
} }
if r.Method == "head" && r.Response != "" { if r.Method == "head" && r.Response != "" {
return nil, errors.New("HEAD must not declare a response body") 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{}} op := openapi.Operation{Method: r.Method, Path: r.Path, OperationID: operationID(r), Summary: r.Summary, Responses: map[int]openapi.Response{}}
if r.Request != "" { if r.Request != "" {
b, ok := c.Bindings[r.Request] b, ok := c.Bindings[r.Request]
@@ -231,6 +244,9 @@ func Compile(spec *Spec) (*Compiled, error) {
} }
op.Parameters = b.Parameters() op.Parameters = b.Parameters()
op.Request = b.BodySchema() 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") { 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") return nil, errors.New("GET/HEAD requests cannot declare JSON bodies; use form/path/header tags")
} }
@@ -245,11 +261,20 @@ func Compile(spec *Spec) (*Compiled, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
response = openapi.Envelope(s) if r.ResponseFormat == "raw" {
response.Required = append(response.Required, "data") response = s
} else {
response, err = openapi.EnvelopeWith(s, responseConfig(spec, r))
if err != nil {
return nil, err
}
}
} }
op.Responses[r.Status] = openapi.Response{Description: "Success", Schema: response} op.Responses[r.Status] = openapi.Response{Description: "Success", Schema: response}
errorSchema := &openapi.Schema{Type: "object", Required: []string{"code", "message"}, Properties: map[string]*openapi.Schema{"code": {Type: "string"}, "message": {Type: "string"}, "requestId": {Type: "string"}}} errorSchema, err := openapi.EnvelopeWith(nil, responseConfig(spec, r))
if err != nil {
return nil, err
}
for _, code := range []int{400, 413, 415, 500, 501} { for _, code := range []int{400, 413, 415, 500, 501} {
op.Responses[code] = openapi.Response{Description: "Error", Schema: errorSchema} op.Responses[code] = openapi.Response{Description: "Error", Schema: errorSchema}
} }

View File

@@ -1,11 +1,15 @@
package apigen package apigen
import ( import (
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
"git.yuntuo.hk/k2safe/go-omni/openapi"
"git.yuntuo.hk/k2safe/go-omni/response"
) )
var errorCode = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) var errorCode = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
@@ -16,6 +20,9 @@ var credentialName = regexp.MustCompile("^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
func prepareMetadata(original *Spec) (*Spec, error) { func prepareMetadata(original *Spec) (*Spec, error) {
spec := *original spec := *original
spec.Routes = append([]Route(nil), original.Routes...) spec.Routes = append([]Route(nil), original.Routes...)
if err := validateResponses(&spec); err != nil {
return nil, err
}
definitions := map[string]ErrorDefinition{} definitions := map[string]ErrorDefinition{}
codes, symbols := map[string]bool{}, map[string]bool{} codes, symbols := map[string]bool{}, map[string]bool{}
for _, e := range spec.Errors { for _, e := range spec.Errors {
@@ -124,6 +131,8 @@ func decorateDocument(doc map[string]any, spec *Spec) {
doc["x-omni-errors"] = spec.Errors doc["x-omni-errors"] = spec.Errors
} }
for _, r := range spec.Routes { for _, r := range spec.Routes {
policy := response.MustNew(responseConfig(spec, r))
cfg := policy.Config()
parts := strings.Split(r.Path, "/") parts := strings.Split(r.Path, "/")
for i, part := range parts { for i, part := range parts {
if strings.HasPrefix(part, ":") { if strings.HasPrefix(part, ":") {
@@ -131,6 +140,10 @@ 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) 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
}
if len(r.Middleware) > 0 { if len(r.Middleware) > 0 {
op["x-omni-middleware"] = r.Middleware op["x-omni-middleware"] = r.Middleware
} }
@@ -152,7 +165,7 @@ func decorateDocument(doc map[string]any, spec *Spec) {
} }
responses := op["responses"].(map[string]any) responses := op["responses"].(map[string]any)
if r.Auth != "" && r.Auth != "none" { if r.Auth != "" && r.Auth != "none" {
responses["401"] = genericErrorResponse("Authentication required") responses["401"] = genericErrorResponse("Authentication required", cfg)
} }
grouped := map[int][]ErrorDefinition{} grouped := map[int][]ErrorDefinition{}
for _, name := range r.Errors { for _, name := range r.Errors {
@@ -166,32 +179,43 @@ func decorateDocument(doc map[string]any, spec *Spec) {
key := fmt.Sprint(status) key := fmt.Sprint(status)
response, exists := responses[key].(map[string]any) response, exists := responses[key].(map[string]any)
if !exists { if !exists {
response = genericErrorResponse(http.StatusText(status)) response = genericErrorResponse(http.StatusText(status), cfg)
responses[key] = response responses[key] = response
} }
media := response["content"].(map[string]any)["application/json"].(map[string]any) media := response["content"].(map[string]any)["application/json"].(map[string]any)
examples := map[string]any{} examples := map[string]any{}
codes := []any{} codes := []any{}
seenCodes := map[string]bool{}
description := []string{} description := []string{}
sort.Slice(errs, func(i, j int) bool { return errs[i].Name < errs[j].Name }) sort.Slice(errs, func(i, j int) bool { return errs[i].Name < errs[j].Name })
for _, e := range errs { for _, e := range errs {
examples[e.Name] = map[string]any{"summary": e.Message, "value": map[string]any{"code": e.Code, "message": e.Message, "requestId": "exampleRequest"}} value := policy.Value(responseResult(e))
codes = append(codes, e.Code) examples[e.Name] = map[string]any{"summary": e.Message, "value": value}
codeKey := fmt.Sprint(value[cfg.CodeField])
if !seenCodes[codeKey] {
codes = append(codes, value[cfg.CodeField])
seenCodes[codeKey] = true
}
description = append(description, e.Code+": "+e.Message) description = append(description, e.Code+": "+e.Message)
} }
media["examples"] = examples media["examples"] = examples
if !exists { if !exists && cfg.Body == nil && cfg.CodeField != "-" {
media["schema"].(map[string]any)["properties"].(map[string]any)["code"].(map[string]any)["enum"] = codes media["schema"].(map[string]any)["properties"].(map[string]any)[cfg.CodeField].(map[string]any)["enum"] = codes
} }
response["description"] = strings.Join(description, "\n") response["description"] = strings.Join(description, "\n")
} }
} }
} }
func genericErrorResponse(description string) map[string]any { func responseResult(e ErrorDefinition) response.Result {
return response.Result{Status: e.Status, Code: e.Code, Message: e.Message, RequestID: "exampleRequest"}
}
func genericErrorResponse(description string, cfg response.Config) map[string]any {
if description == "" { if description == "" {
description = "Error" description = "Error"
} }
return map[string]any{"description": description, "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{ schema, _ := openapi.EnvelopeWith(nil, cfg)
"type": "object", "required": []string{"code", "message"}, "properties": map[string]any{"code": map[string]any{"type": "string"}, "message": map[string]any{"type": "string"}, "requestId": map[string]any{"type": "string"}}, data, _ := json.Marshal(schema)
}}}} var value map[string]any
_ = json.Unmarshal(data, &value)
return map[string]any{"description": description, "content": map[string]any{"application/json": map[string]any{"schema": value}}}
} }

View File

@@ -1,6 +1,7 @@
package apigen package apigen
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"go/format" "go/format"
@@ -18,7 +19,7 @@ import (
) )
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
const minimumVersion = "v0.7.0-preview" const minimumVersion = "v0.9.0-preview"
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n" const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct { type Options struct {
@@ -93,6 +94,16 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
return Output{}, err return Output{}, err
} }
o.Dir = directory 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 { if err := resolveProfile(&o); err != nil {
return Output{}, err return Output{}, err
} }
@@ -129,13 +140,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
if !filepath.IsAbs(path) { if !filepath.IsAbs(path) {
path = filepath.Join(directory, path) path = filepath.Join(directory, path)
} }
if _, err := os.Stat(filepath.Join(path, "contract", "contract.go")); err == nil { if _, err := os.Stat(filepath.Join(path, "contract", "scalar.go")); err == nil {
localCurrent = true localCurrent = true
} }
} }
} }
if !localCurrent { if !localCurrent {
return Output{}, fmt.Errorf("existing framework dependency predates API support; use %s or a current local replacement", minimumVersion) return Output{}, fmt.Errorf("existing framework dependency predates migration extensions; use %s or a current local replacement", minimumVersion)
} }
} }
if o.FrameworkDir != "" { if o.FrameworkDir != "" {
@@ -308,6 +319,20 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
put(generated, "internal/apierrors/errorsGen.go", generatedHeader+content) put(generated, "internal/apierrors/errorsGen.go", generatedHeader+content)
} }
types := "package types\n\n" 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 { for _, t := range c.Spec.Types {
types += "type " + t.Name + " struct {\n" types += "type " + t.Name + " struct {\n"
for _, f := range t.Fields { for _, f := range t.Fields {
@@ -318,8 +343,22 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
put(generated, "internal/types/typesGen.go", generatedHeader+types) put(generated, "internal/types/typesGen.go", generatedHeader+types)
put(generated, "docs/openapi.json", string(c.Document)+"\n") 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")) 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")
for _, definition := range c.Spec.Responses {
data, err := json.Marshal(definition.Config)
if err != nil {
return nil, nil, err
}
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"
put(generated, "internal/handler/responseGen.go", generatedHeader+policySource)
imports := fmt.Sprintf("import (\"fmt\"; %q; %q; %q;", frameworkModule+"/httpx", frameworkModule+"/openapi", o.Module+"/internal/servicecontext") 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" 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{} middlewareSet := map[string]bool{}
for _, r := range c.Spec.Routes { for _, r := range c.Spec.Routes {
for _, m := range r.Middleware { for _, m := range r.Middleware {
@@ -336,7 +375,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
} }
for i, group := range c.Groups { for i, group := range c.Groups {
imports += fmt.Sprintf("h%d %q; s%d %q;", i, o.Module+"/internal/handler/"+group, i, o.Module+"/internal/service/"+group) imports += fmt.Sprintf("h%d %q; s%d %q;", i, o.Module+"/internal/handler/"+group, i, o.Module+"/internal/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{} groupRoutes := []Route{}
for _, r := range c.Spec.Routes { for _, r := range c.Spec.Routes {
if r.Group == group { if r.Group == group {
@@ -345,12 +384,16 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
} }
packageName := filepath.Base(group) packageName := filepath.Base(group)
text := "package " + packageName + "\nimport(\"context\"; \"fmt\";" text := "package " + packageName + "\nimport(\"context\"; \"fmt\";"
needsTypes, needsBinder := false, false needsTypes, needsBinder, hasAutomatic := false, false, false
for _, r := range groupRoutes { for _, r := range groupRoutes {
if r.Manual {
continue
}
hasAutomatic = true
needsTypes = needsTypes || r.Request != "" || r.Response != "" 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 { if needsTypes {
text += fmt.Sprintf("%q;", o.Module+"/internal/types") text += fmt.Sprintf("%q;", o.Module+"/internal/types")
} }
@@ -360,18 +403,47 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
text += ")\n" text += ")\n"
text += "type Service interface {\n" text += "type Service interface {\n"
for _, r := range groupRoutes { 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 { for _, r := range groupRoutes {
name := upper(r.Handler) name := upper(r.Handler)
if r.Request != "" { // The response policy and body reader cap are installed before App middleware.
text += fmt.Sprintf("var %sRequest = contract.MustNew[types.%s]()\n", lower(r.Handler), r.Request) if r.MaxBodyBytes > 0 {
routes += fmt.Sprintf("httpx.HandleWithBodyLimit(router,%q,%q,%d,responseEncoder(%q,%t),h%d.%s", strings.ToUpper(r.Method), r.Path, r.MaxBodyBytes, responseName(r), r.ResponseFormat == "raw", i, name)
} else {
routes += fmt.Sprintf("httpx.HandleWithResponse(router,%q,%q,responseEncoder(%q,%t),h%d.%s", strings.ToUpper(r.Method), r.Path, responseName(r), r.ResponseFormat == "raw", i, name)
}
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) text += fmt.Sprintf("func(h *Handler) %s(c *httpx.Context)error {\n", name)
call := "c.Context()" call := "c.Context()"
if r.Request != "" { 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" call += ", &req"
} }
if r.Response != "" { if r.Response != "" {
@@ -381,11 +453,6 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
} else { } else {
text += fmt.Sprintf("if err:=h.service.%s(%s);err!=nil{return err};return c.NoContent()\n}\n", name, call) text += fmt.Sprintf("if err:=h.service.%s(%s);err!=nil{return err};return c.NoContent()\n}\n", name, call)
} }
routes += fmt.Sprintf("router.Handle(%q,%q,h%d.%s", strings.ToUpper(r.Method), r.Path, 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") service := fmt.Sprintf("package %s\nimport(\"context\"; apierrors %q;", packageName, frameworkModule+"/errors")
if r.Request != "" || r.Response != "" { if r.Request != "" || r.Response != "" {
service += fmt.Sprintf("%q;", o.Module+"/internal/types") service += fmt.Sprintf("%q;", o.Module+"/internal/types")
@@ -406,6 +473,10 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
} }
put(seeds, "internal/service/"+group+"/"+lower(r.Handler)+".go", service) 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) { if !hasResponse(groupRoutes) {
text = strings.Replace(text, "\"fmt\";", "", 1) text = strings.Replace(text, "\"fmt\";", "", 1)
} }
@@ -413,8 +484,6 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
put(seeds, "internal/service/"+group+"/service.go", fmt.Sprintf("package %s\nimport %q\ntype Service struct {deps *servicecontext.Context}\nfunc New(deps *servicecontext.Context)*Service{return &Service{deps:deps}}\n", packageName, o.Module+"/internal/servicecontext")) put(seeds, "internal/service/"+group+"/service.go", fmt.Sprintf("package %s\nimport %q\ntype Service struct {deps *servicecontext.Context}\nfunc New(deps *servicecontext.Context)*Service{return &Service{deps:deps}}\n", packageName, o.Module+"/internal/servicecontext"))
put(seeds, "internal/repository/"+group+"/repository.go", "// Package "+packageName+" owns persistence contracts for this business group.\n// Define repository interfaces and implementations here; HTTP DTOs do not\n// determine a database schema. This file is never overwritten.\npackage "+packageName+"\n") put(seeds, "internal/repository/"+group+"/repository.go", "// Package "+packageName+" owns persistence contracts for this business group.\n// Define repository interfaces and implementations here; HTTP DTOs do not\n// determine a database schema. This file is never overwritten.\npackage "+packageName+"\n")
} }
// Avoid unused imports when no route uses a named middleware.
imports = strings.Replace(imports, strconv.Quote(frameworkModule+"/httpx")+";", "", 1)
put(generated, "internal/handler/routesGen.go", generatedHeader+"package handler\n"+imports+")\n"+routes+"return nil\n}\n") put(generated, "internal/handler/routesGen.go", generatedHeader+"package handler\n"+imports+")\n"+routes+"return nil\n}\n")
registry := "package middleware\nimport " + strconv.Quote(frameworkModule+"/httpx") + "\nfunc Defaults()map[string]httpx.Middleware{return map[string]httpx.Middleware{\n" registry := "package middleware\nimport " + strconv.Quote(frameworkModule+"/httpx") + "\nfunc Defaults()map[string]httpx.Middleware{return map[string]httpx.Middleware{\n"
for _, m := range middlewareNames { for _, m := range middlewareNames {
@@ -455,7 +524,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
} }
func hasResponse(routes []Route) bool { func hasResponse(routes []Route) bool {
for _, r := range routes { for _, r := range routes {
if r.Response != "" { if r.Response != "" && !r.Manual {
return true return true
} }
} }

View File

@@ -17,6 +17,8 @@ type Spec struct {
Title, Version, Service, Description string Title, Version, Service, Description string
Errors []ErrorDefinition Errors []ErrorDefinition
Auth []AuthDefinition Auth []AuthDefinition
Responses []ResponseDefinition
Scalars []ScalarDefinition
Types []Type Types []Type
Routes []Route Routes []Route
Sources map[string][]byte // relative to the entry API's directory Sources map[string][]byte // relative to the entry API's directory
@@ -29,9 +31,14 @@ type Type struct {
} }
type Field struct{ Name, Type, Tag string } type Field struct{ Name, Type, Tag string }
type Route struct { type Route struct {
Manual, CustomBinding bool
BodyMode, Produces string
MaxBodyBytes int64
Method, Path, Request, Response, Handler, Group, Summary string Method, Path, Request, Response, Handler, Group, Summary string
Middleware []string Middleware []string
Auth string Auth string
ResponseFormat string
ErrorResponseFormat string
Errors []string Errors []string
Description string Description string
Tags []string Tags []string
@@ -42,6 +49,7 @@ type Route struct {
type token struct { type token struct {
text, position string text, position string
kind rune kind rune
space bool
} }
type parser struct { type parser struct {
tokens []token tokens []token
@@ -144,9 +152,11 @@ func lex(name, source string) ([]token, error) {
} }
} }
var out []token var out []token
end := 0
for { for {
kind := s.Scan() kind := s.Scan()
out = append(out, token{s.TokenText(), s.Position.String(), kind}) out = append(out, token{s.TokenText(), s.Position.String(), kind, s.Position.Offset > end})
end = s.Position.Offset + len(s.TokenText())
if kind == scanner.EOF { if kind == scanner.EOF {
break break
} }
@@ -193,6 +203,10 @@ func (p *parser) parse() error {
syntaxSeen := false syntaxSeen := false
for p.skip(); p.tokens[p.at].kind != scanner.EOF; p.skip() { for p.skip(); p.tokens[p.at].kind != scanner.EOF; p.skip() {
switch p.peek() { switch p.peek() {
case "scalar":
if err := p.parseScalar(); err != nil {
return err
}
case "syntax": case "syntax":
if syntaxSeen { if syntaxSeen {
return p.err("duplicate syntax") return p.err("duplicate syntax")
@@ -260,6 +274,10 @@ func (p *parser) parse() error {
if err := p.parseAuth(); err != nil { if err := p.parseAuth(); err != nil {
return err return err
} }
case "response":
if err := p.parseResponse(); err != nil {
return err
}
case "type": case "type":
p.next() p.next()
if p.peek() == "(" { if p.peek() == "(" {
@@ -289,7 +307,7 @@ func (p *parser) parse() error {
} }
server = m server = m
for k := range m { for k := range m {
if k != "prefix" && k != "group" && k != "middleware" && k != "auth" && k != "errors" { if k != "prefix" && k != "group" && k != "middleware" && k != "auth" && k != "errors" && k != "response" {
return p.err("unsupported @server option " + k + "; wire authentication through named middleware") return p.err("unsupported @server option " + k + "; wire authentication through named middleware")
} }
} }
@@ -326,6 +344,29 @@ func (p *parser) metadata() (map[string]string, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
} else if p.peek() == "{" {
depth := 0
var object strings.Builder
for {
t := p.next()
if t.kind == scanner.EOF {
return nil, p.err("unterminated metadata object")
}
if t.text == "{" {
depth++
}
if t.text == "}" {
depth--
}
if t.space {
object.WriteByte(' ')
}
object.WriteString(t.text)
if depth == 0 {
break
}
}
value = object.String()
} else { } else {
for p.peek() != "\n" && p.peek() != ";" && p.peek() != ")" && p.tokens[p.at].kind != scanner.EOF { for p.peek() != "\n" && p.peek() != ";" && p.peek() != ")" && p.tokens[p.at].kind != scanner.EOF {
value += p.next().text value += p.next().text
@@ -360,7 +401,15 @@ func (p *parser) parseType() error {
} }
expression := "" expression := ""
for p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" && p.tokens[p.at].kind != scanner.RawString && p.tokens[p.at].kind != scanner.EOF { 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 == "" { if expression == "" {
return p.err("field type required; embedded types are not supported") return p.err("field type required; embedded types are not supported")
@@ -401,7 +450,7 @@ func (p *parser) parseService(server map[string]string) error {
return err return err
} }
for p.skip(); p.peek() != "}"; p.skip() { for p.skip(); p.peek() != "}"; p.skip() {
route := Route{Auth: server["auth"], Errors: commaList(server["errors"]), Group: server["group"], Status: 200, Position: p.tokens[p.at].position} route := Route{ResponseFormat: server["response"], Auth: server["auth"], Errors: commaList(server["errors"]), Group: server["group"], Status: 200, Position: p.tokens[p.at].position}
if route.Group == "" { if route.Group == "" {
route.Group = "api" route.Group = "api"
} }
@@ -420,12 +469,41 @@ func (p *parser) parseService(server map[string]string) error {
} }
seen[annotation] = true seen[annotation] = true
switch annotation { 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": case "handler":
route.Handler, e = p.ident() route.Handler, e = p.ident()
case "doc": case "doc":
e = p.parseDoc(&route) e = p.parseDoc(&route)
case "auth": case "auth":
route.Auth, e = p.ident() route.Auth, e = p.ident()
case "response":
var format string
format, e = p.ident()
if format == "raw" && route.ResponseFormat != "raw" {
route.ErrorResponseFormat = route.ResponseFormat
}
route.ResponseFormat = format
case "errors": case "errors":
var names []string var names []string
names, e = p.names() names, e = p.names()
@@ -472,7 +550,7 @@ func (p *parser) parseService(server map[string]string) error {
if p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" { if p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" {
return p.err("unexpected route suffix") return p.err("unexpected route suffix")
} }
if route.Response == "" && !seen["status"] { if route.Response == "" && !seen["status"] && !route.Manual {
route.Status = 204 route.Status = 204
} }
p.spec.Routes = append(p.spec.Routes, route) 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

@@ -0,0 +1,114 @@
package apigen
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
"git.yuntuo.hk/k2safe/go-omni/response"
)
type ResponseDefinition struct {
Name string
Config response.Config
Position string
}
func (p *parser) parseResponse() 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
}
values := map[string]any{}
for key, value := range props {
switch key {
case "successCode", "errorCode":
if kind := props["codeType"]; kind == "integer" || kind == "int32" || kind == "int64" {
var number json.Number
if err = json.Unmarshal([]byte(value), &number); err != nil {
return fmt.Errorf("%s: %s must be an integer", pos, key)
}
values[key] = number
} else {
values[key] = value
}
case "codes", "messages", "body", "errorBody":
decoder := json.NewDecoder(strings.NewReader(value))
decoder.UseNumber()
var object map[string]any
if err = decoder.Decode(&object); err != nil || object == nil {
return fmt.Errorf("%s: %s must be a JSON object", pos, key)
}
var extra any
if decoder.Decode(&extra) != io.EOF {
return fmt.Errorf("%s: invalid %s mapping", pos, key)
}
values[key] = object
default:
values[key] = value
}
}
data, err := json.Marshal(values)
if err != nil {
return err
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
decoder.DisallowUnknownFields()
var cfg response.Config
if err = decoder.Decode(&cfg); err != nil {
return fmt.Errorf("%s: response %s: %w", pos, name, err)
}
if err = cfg.Validate(); err != nil {
return fmt.Errorf("%s: %w", pos, err)
}
p.spec.Responses = append(p.spec.Responses, ResponseDefinition{Name: name, Config: cfg, Position: pos})
return nil
}
func responseConfig(spec *Spec, route Route) response.Config {
name := responseName(route)
for _, definition := range spec.Responses {
if definition.Name == name {
return definition.Config
}
}
return response.Config{}
}
func responseName(route Route) string {
if route.ResponseFormat == "raw" {
return route.ErrorResponseFormat
}
return route.ResponseFormat
}
func validateResponses(spec *Spec) error {
seen := map[string]bool{}
for _, definition := range spec.Responses {
if !exportedName.MatchString(definition.Name) || seen[definition.Name] {
return fmt.Errorf("%s: response template requires a unique UpperCamelCase name", definition.Position)
}
if err := definition.Config.Validate(); err != nil {
return fmt.Errorf("%s: %w", definition.Position, err)
}
seen[definition.Name] = true
}
for _, r := range spec.Routes {
if r.ResponseFormat != "" && r.ResponseFormat != "raw" && !seen[r.ResponseFormat] {
return fmt.Errorf("%s: unknown response template %s", r.Position, r.ResponseFormat)
}
if r.ErrorResponseFormat != "" && !seen[r.ErrorResponseFormat] {
return fmt.Errorf("%s: unknown error response template %s", r.Position, r.ErrorResponseFormat)
}
}
return nil
}

View File

@@ -0,0 +1,360 @@
package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
const configurableResponseAPI = `syntax = "v1"
response CommonResponse (
codeField: code
messageField: msg
dataField: data
requestIdField: "-"
codeType: integer
successCode: 200
errorCode: 9999
successMessage: "操作成功"
errorMessage: "系统错误"
dataMode: always
codes: {
"UNAUTHENTICATED": 401001,
"EMAIL_TAKEN": 409001
}
messages: {"UNAUTHENTICATED":"请先登录","EMAIL_TAKEN":"邮箱已存在"}
)
response PartnerResponse (
codeField: result
messageField: notice
dataField: payload
requestIdField: trace
successCode: "PASS"
)
error (
Unauthenticated 401 "UNAUTHENTICATED" "authentication required"
EmailTaken 409 "EMAIL_TAKEN" "email already taken"
)
auth Access (type: bearer
middleware: Auth
)
type ItemResponse {
Name string ` + "`json:\"name\"`" + `
}
@server (
group: items
response: CommonResponse
auth: Access
errors: Unauthenticated
)
service response-api {
@errors EmailTaken
@handler item
get /item returns (ItemResponse)
@response raw
@handler callback
post /callback returns (ItemResponse)
@response PartnerResponse
@handler partner
get /partner returns (ItemResponse)
}
`
func TestResponseTemplatesDocumentAndValidation(t *testing.T) {
spec, err := parseText(t, configurableResponseAPI)
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
doc := document(t, c)
getSchema := func(path, status string) map[string]any {
method := "get"
if path == "/callback" {
method = "post"
}
return operation(t, doc, path, method)["responses"].(map[string]any)[status].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
}
properties := getSchema("/item", "200")["properties"].(map[string]any)
if len(properties) != 3 || properties["code"].(map[string]any)["type"] != "integer" || properties["msg"] == nil || properties["requestId"] != nil {
t.Fatal(properties)
}
if getSchema("/callback", "200")["properties"].(map[string]any)["name"] == nil {
t.Fatal("raw JSON was wrapped")
}
if getSchema("/callback", "401")["properties"].(map[string]any)["msg"] == nil {
t.Fatal("raw override lost group error policy")
}
if getSchema("/partner", "200")["properties"].(map[string]any)["payload"] == nil {
t.Fatal("route override not applied")
}
conflict := operation(t, doc, "/item", "get")["responses"].(map[string]any)["409"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
value := conflict["examples"].(map[string]any)["EmailTaken"].(map[string]any)["value"].(map[string]any)
if value["code"] != float64(409001) || value["msg"] != "邮箱已存在" || len(value) != 3 {
t.Fatal(value)
}
if got := conflict["schema"].(map[string]any)["properties"].(map[string]any)["code"].(map[string]any)["enum"].([]any); got[0] != float64(409001) {
t.Fatal(got)
}
for _, change := range [][2]string{{"response: CommonResponse", "response: Missing"}, {"messageField: msg", "messageField: code"}, {"codeType: integer", "codeType: boolean"}, {"successCode: 200", "successCode: 2.5"}, {"requestIdField: \"-\"", "unknownOption: value"}, {"@response raw", "@response missing"}} {
invalid, err := parseText(t, strings.Replace(configurableResponseAPI, change[0], change[1], 1))
if err == nil {
_, err = Compile(invalid)
}
if err == nil || !strings.Contains(err.Error(), "service.api:") {
t.Fatalf("bad declaration %s: %v", change[1], err)
}
}
}
func TestImportedResponseTemplate(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "common.api"), "response Shared (messageField: msg)\n")
writeFile(t, filepath.Join(dir, "service.api"), "import \"common.api\"\n@server (response: Shared)\nservice shared-api {\n@handler ping\nget /ping\n}")
spec, err := Parse(filepath.Join(dir, "service.api"))
if err != nil {
t.Fatal(err)
}
if _, err = Compile(spec); err != nil {
t.Fatal(err)
}
}
func TestResponseNestedBodyDocument(t *testing.T) {
definition := `response Nested (
body: {"success":"${ok}","result":"${data}","meta":{"status":"${status}","trace":"${requestId}"},"version":1}
errorBody: {"success":"${ok}","error":{"id":"${code}","reason":"${message}"}}
codeType: integer
successCode: 0
errorCode: 9999
codes: {"EMAIL_TAKEN":409001}
)
`
spec, err := parseText(t, definition+strings.Replace(configurableResponseAPI, "response: CommonResponse", "response: Nested", 1))
if err != nil {
t.Fatal(err)
}
compiled, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
doc := document(t, compiled)
responses := operation(t, doc, "/item", "get")["responses"].(map[string]any)
success := responses["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)["properties"].(map[string]any)
if success["code"] != nil || success["result"] == nil || success["success"].(map[string]any)["type"] != "boolean" {
t.Fatal(success)
}
media := responses["409"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
schema := media["schema"].(map[string]any)["properties"].(map[string]any)
if schema["error"].(map[string]any)["properties"].(map[string]any)["id"].(map[string]any)["type"] != "integer" || schema["result"] != nil {
t.Fatal(schema)
}
example := media["examples"].(map[string]any)["EmailTaken"].(map[string]any)["value"].(map[string]any)
if example["success"] != false || example["error"].(map[string]any)["id"] != float64(409001) {
t.Fatal(example)
}
}
func TestResponseTemplateJSONTokens(t *testing.T) {
for _, tc := range []struct {
body string
valid bool
}{
{`{"scale":-1.5,"exponent":1e-3,"items":[true,null,"${data}"],"label":"a b"}`, true},
{`{"scale":1 2}`, false}, {`{"scale":- 12}`, false},
{`{"scale":tru e}`, false}, {`{"scale":1. 2}`, false},
} {
text := "response Shared (\nbody: " + tc.body + "\n)\n@server (response: Shared)\nservice test-api {\n@handler ping\nget /ping\n}\n"
spec, err := parseText(t, text)
if err == nil {
_, err = Compile(spec)
}
if (err == nil) != tc.valid {
t.Fatalf("%s: %v", tc.body, err)
}
}
}
func TestResponseCodeTypeDeclarations(t *testing.T) {
for _, kind := range []string{"int32", "int64"} {
source := strings.Replace(configurableResponseAPI, "codeType: integer", "codeType: "+kind, 1)
spec, err := parseText(t, source)
if err != nil {
t.Fatal(err)
}
compiled, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
doc := document(t, compiled)
for _, status := range []string{"200", "401", "409", "500"} {
responses := operation(t, doc, "/item", "get")["responses"].(map[string]any)
schema := responses[status].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
code := schema["properties"].(map[string]any)["code"].(map[string]any)
if code["type"] != "integer" || code["format"] != kind {
t.Fatal(code)
}
}
for _, bad := range []string{"2.5", "9223372036854775808", "-9223372036854775809"} {
invalid := strings.Replace(source, "successCode: 200", "successCode: "+bad, 1)
if _, err := parseText(t, invalid); err == nil || !strings.Contains(err.Error(), "service.api:") {
t.Fatalf("%s %s: %v", kind, bad, err)
}
}
}
invalid := strings.Replace(configurableResponseAPI, "codeType: integer", "codeType: int32", 1)
invalid = strings.Replace(invalid, `"EMAIL_TAKEN": 409001`, `"EMAIL_TAKEN": 2147483648`, 1)
if _, err := parseText(t, invalid); err == nil {
t.Fatal("overflowing int32 mapping accepted")
}
}
func TestResponseTemplateConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
t.Skip("consumer integration")
}
dir := filepath.Join(t.TempDir(), "project")
source := filepath.Join(t.TempDir(), "service.api")
// Use an explicitly declared public body, rather than field-name shortcuts.
sourceText := strings.Replace(configurableResponseAPI, " codeField: code\n messageField: msg\n dataField: data\n requestIdField: \"-\"", ` body: {"code":"${code}","msg":"${message}","data":"${data}"}`, 1)
sourceText = strings.Replace(sourceText, "codeType: integer", "codeType: int64", 1)
sourceText = strings.Replace(sourceText, "successCode: 200", "successCode: 9223372036854775807", 1)
writeFile(t, source, sourceText)
releaseCLI := os.Getenv("GO_OMNI_RELEASE_CLI")
generate := func(api string, initial bool) {
t.Helper()
if releaseCLI != "" {
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)
}
return
}
o := Options{API: api, Dir: dir}
if initial {
o.Module = "example.com/responseConsumer"
o.FrameworkDir = frameworkRoot(t)
}
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
}
generate(source, true)
writeFile(t, filepath.Join(dir, "response_test.go"), responseConsumerTest)
for _, method := range []string{"Item", "Callback", "Partner"} {
body := `return &types.ItemResponse{Name:"` + strings.ToLower(method) + `"},nil`
if method == "Partner" {
body = `return nil,apierrors.EmailTaken()`
}
imports := `"context";"example.com/responseConsumer/internal/types"`
if method == "Partner" {
imports += `;"example.com/responseConsumer/internal/apierrors"`
}
writeFile(t, filepath.Join(dir, "internal/service/items", strings.ToLower(method)+".go"), "package items\nimport("+imports+")\nfunc(s *Service)"+method+"(ctx context.Context)(*types.ItemResponse,error){"+body+"}\n")
}
before := snapshot(t, dir)
generate(filepath.Join(dir, "api/service.api"), false)
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("regeneration changed hand-written files")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*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", "GOFLAGS=")
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", "GOFLAGS=")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("released 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("expected fixed remote module without replacement: %+v", module)
}
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
}
manifestBytes, err := os.ReadFile(filepath.Join(dir, manifestPath))
if err != nil {
t.Fatal(err)
}
var manifest manifest
if err = json.Unmarshal(manifestBytes, &manifest); err != nil {
t.Fatal(err)
}
if manifest.Generated["internal/handler/responseGen.go"] == "" {
t.Fatal("response policy not ownership-managed")
}
}
const responseConsumerTest = `package responseConsumer_test
import (
"bytes";"encoding/json";"io";"net/http/httptest";"testing"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/response"
"example.com/responseConsumer/internal/apierrors"
"example.com/responseConsumer/internal/handler"
item "example.com/responseConsumer/internal/handler/items"
"example.com/responseConsumer/internal/types"
"context"
"example.com/responseConsumer/internal/servicecontext"
)
type service struct{}
func(service)Item(context.Context)(*types.ItemResponse,error){return &types.ItemResponse{Name:"item"},nil}
func(service)Callback(context.Context)(*types.ItemResponse,error){return &types.ItemResponse{Name:"callback"},nil}
func(service)Partner(context.Context)(*types.ItemResponse,error){return nil,apierrors.EmailTaken()}
func TestResponseContract(t *testing.T){
app:=omni.New();defer app.Close()
cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close()
deps,err:=servicecontext.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close()
deps.Middlewares["Auth"]=func(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{if c.Header("Authorization")!="Bearer valid"{return apierrors.Unauthenticated()};return next(c)}}
if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
// Registered routes use the compiled policy before even running authentication.
for _,path:=range []string{"/item","/callback"}{
method:="GET";if path=="/callback"{method="POST"};w:=httptest.NewRecorder();app.ServeHTTP(w,httptest.NewRequest(method,path,nil))
var value map[string]any;json.Unmarshal(w.Body.Bytes(),&value)
if w.Code!=401||value["code"]!=float64(401001)||value["msg"]!="请先登录"||len(value)!=3{t.Fatalf("%s: %s",path,w.Body.String())}
}
// Execute the real generated registration and user-owned service files.
for _,tc:=range []struct{method,path string;status int;key string}{{"GET","/item",200,"data"},{"POST","/callback",200,"name"},{"GET","/partner",409,"result"}}{
w:=httptest.NewRecorder();r:=httptest.NewRequest(tc.method,tc.path,nil);r.Header.Set("Authorization","Bearer valid");app.ServeHTTP(w,r)
var value map[string]any;decoder:=json.NewDecoder(bytes.NewReader(w.Body.Bytes()));decoder.UseNumber();if err:=decoder.Decode(&value);err!=nil{t.Fatal(err)}
if w.Code!=tc.status||value[tc.key]==nil{t.Fatalf("generated %s: %d %s",tc.path,w.Code,w.Body.String())}
if tc.path=="/item"&&value["code"]!=json.Number("9223372036854775807"){t.Fatalf("generated int64 lost precision: %s",w.Body.String())}
}
// Business handlers also honor the same runtime policy and raw override.
h:=item.New(service{});runtime:=omni.New();defer runtime.Close()
common:=response.MustNew(response.Config{CodeType:"integer",SuccessCode:200,ErrorCode:9999,MessageField:"msg",RequestIDField:"-",DataMode:"always"})
runtime.GET("/item",h.Item,httpx.WithResponse(common))
runtime.POST("/callback",h.Callback,httpx.WithResponse(response.RawJSON(common)))
for _,tc:=range []struct{method,path,key string}{{"GET","/item","data"},{"POST","/callback","name"}}{
w:=httptest.NewRecorder();runtime.ServeHTTP(w,httptest.NewRequest(tc.method,tc.path,nil));var value map[string]any;json.Unmarshal(w.Body.Bytes(),&value)
if w.Code!=200||value[tc.key]==nil{t.Fatal(w.Body.String())}
}
}
`

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

@@ -113,9 +113,12 @@ docs/openapi.json # generated OpenAPI 3.0.3
.goOmni/apiManifest.json # ownership and content hashes .goOmni/apiManifest.json # ownership and content hashes
`+"```"+` `+"```"+`
The API response describes data only. Omni adds code, message and requestId. The API response type describes business data. Declare response Name (...) and
Do not define the envelope again in each response. Handlers reject nil successful select it with @server(response: Name) or @response Name to share a public body;
responses. Body DTOs use application/json and have a 1 MiB limit by default. @response raw returns the business JSON directly. Without a template, the default
envelope includes code, message, data and requestId. Generated routes and OpenAPI
use the API contract, independently of the app's default response configuration.
Handlers reject nil successful responses. Body DTOs use application/json and have a 1 MiB limit by default.
Use contract.Binder.Bind's optional limit in a custom adapter for larger payloads. Use contract.Binder.Bind's optional limit in a custom adapter for larger payloads.
Fields require exactly one source tag: json, path, form (URL query only), header. Fields require exactly one source tag: json, path, form (URL query only), header.

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

@@ -252,6 +252,9 @@ 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 digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
func ownedPath(name string) bool { func ownedPath(name string) bool {
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" { 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" {
return true return true
} }

View File

@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Source builds can carry an unpublished VCS version (including +dirty). // Source builds can carry an unpublished VCS version (including +dirty).
// Keep this fallback in sync with the next release; installed CLIs use their // Keep this fallback in sync with the next release; installed CLIs use their
// downloaded module version, which also has a module checksum. // downloaded module version, which also has a module checksum.
const defaultFrameworkVersion = "v0.7.0-preview.1" const defaultFrameworkVersion = "v0.9.0-preview"
// This target is not a claim that the release already exists. Source users // This target is not a claim that the release already exists. Source users
// select a local framework checkout until the module templates are published. // select a local framework checkout until the module templates are published.

View File

@@ -6,6 +6,7 @@ import (
"git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/response"
) )
const defaultRequestIDHeader = "X-Request-ID" const defaultRequestIDHeader = "X-Request-ID"
@@ -24,15 +25,17 @@ type TLSConfig struct {
// Config controls the default runtime behavior of an App. // Config controls the default runtime behavior of an App.
type Config struct { type Config struct {
Address string `json:"address"` Address string `json:"address"`
Transport string `json:"transport"` Transport string `json:"transport"`
TLS TLSConfig `json:"tls"` TLS TLSConfig `json:"tls"`
RequestIDHeader string `json:"requestIdHeader"` RequestIDHeader string `json:"requestIdHeader"`
EnableLogger bool `json:"enableLogger"` EnableLogger bool `json:"enableLogger"`
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"` SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
TrustedProxies []string `json:"trustedProxies"` TrustedProxies []string `json:"trustedProxies"`
ShutdownTimeout time.Duration `json:"shutdownTimeout"` ShutdownTimeout time.Duration `json:"shutdownTimeout"`
Log logx.Config `json:"log"` Log logx.Config `json:"log"`
Response response.Config `json:"response"`
ResponseEncoder response.Encoder `json:"-"`
} }
func Default() Config { func Default() Config {
@@ -47,6 +50,9 @@ func Default() Config {
// Validate checks configuration values before an application starts. // Validate checks configuration values before an application starts.
func (c Config) Validate() error { func (c Config) Validate() error {
if err := c.Response.Validate(); err != nil {
return err
}
if c.SlowRequestThreshold < 0 { if c.SlowRequestThreshold < 0 {
return &ConfigError{Field: "slowRequestThreshold", Message: "must be nonnegative"} return &ConfigError{Field: "slowRequestThreshold", Message: "must be nonnegative"}
} }
@@ -134,15 +140,29 @@ func WithTrustedProxies(proxies ...string) Option {
// WithConfig applies a complete configuration loaded from a file or // WithConfig applies a complete configuration loaded from a file or
// environment. Later options can still override individual fields. // environment. Later options can still override individual fields.
func WithConfig(settings Config) Option { func WithConfig(settings Config) Option {
settings.Response = settings.Response.Clone()
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...) settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...) settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
return func(cfg *Config) { return func(cfg *Config) {
*cfg = settings *cfg = settings
cfg.Response = settings.Response.Clone()
cfg.TrustedProxies = append([]string(nil), settings.TrustedProxies...) cfg.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
cfg.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...) cfg.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
} }
} }
// WithResponse configures this application's default JSON envelope.
func WithResponse(settings response.Config) Option {
settings = settings.Clone()
return func(cfg *Config) { cfg.Response = settings.Clone(); cfg.ResponseEncoder = nil }
}
// WithResponseEncoder replaces the default encoder with application code.
// OpenAPI for custom encoders must describe their actual wire contract explicitly.
func WithResponseEncoder(encoder response.Encoder) Option {
return func(cfg *Config) { cfg.ResponseEncoder = encoder }
}
// WithLogConfig configures the logger used by the application runtime. // WithLogConfig configures the logger used by the application runtime.
func WithLogConfig(settings logx.Config) Option { func WithLogConfig(settings logx.Config) Option {
settings.RedactKeys = append([]string(nil), settings.RedactKeys...) settings.RedactKeys = append([]string(nil), settings.RedactKeys...)

View File

@@ -181,6 +181,7 @@ func (r *Reloader) Snapshot() Config {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
cfg := r.current cfg := r.current
cfg.Response = r.current.Response.Clone()
cfg.TrustedProxies = append([]string(nil), cfg.TrustedProxies...) cfg.TrustedProxies = append([]string(nil), cfg.TrustedProxies...)
cfg.Log.RedactKeys = append([]string(nil), cfg.Log.RedactKeys...) cfg.Log.RedactKeys = append([]string(nil), cfg.Log.RedactKeys...)
return cfg return cfg

View File

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

View File

@@ -23,10 +23,12 @@ var valuesValidator = validator.New()
// Binder is immutable after compilation and safe for concurrent requests. // Binder is immutable after compilation and safe for concurrent requests.
type Binder struct { type Binder struct {
root *node root *node
typ reflect.Type typ reflect.Type
schemaOnly bool
} }
type node struct { type node struct {
scalar *Scalar
typ reflect.Type typ reflect.Type
schema *openapi.Schema schema *openapi.Schema
fields []field fields []field
@@ -42,9 +44,11 @@ type field struct {
binding string binding string
} }
func New[T any]() (*Binder, error) { return ForType(reflect.TypeOf((*T)(nil)).Elem()) } func New[T any](scalars ...Scalar) (*Binder, error) {
func MustNew[T any]() *Binder { return ForType(reflect.TypeOf((*T)(nil)).Elem(), scalars...)
b, err := New[T]() }
func MustNew[T any](scalars ...Scalar) *Binder {
b, err := New[T](scalars...)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@@ -52,20 +56,36 @@ func MustNew[T any]() *Binder {
} }
// ForType also lets generators check the exact runtime contract before writing files. // 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. // The root must be a struct; custom marshalers require explicit scalar registration.
func ForType(t reflect.Type) (*Binder, error) { 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 { if t == nil || t.Kind() != reflect.Struct {
return nil, errors.New("contract: root must be a 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 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 { if err != nil {
return nil, err 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-- *budget--
if *budget < 0 { if *budget < 0 {
return nil, errors.New("contract: schema expansion exceeds 10000 nodes") 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 stack[t] = true
defer delete(stack, t) defer delete(stack, t)
n := &node{typ: t, schema: &openapi.Schema{}} 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. // Wire behavior must be described completely by these tags.
marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem() marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem()
unmarshaler := reflect.TypeOf((*json.Unmarshaler)(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) { 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 are unsupported") return nil, errors.New("contract: custom JSON marshalers require explicit Scalar registration")
} }
switch t.Kind() { switch t.Kind() {
case reflect.Interface:
if t.NumMethod() != 0 {
return nil, errors.New("contract: only any / interface{} is supported")
}
case reflect.Pointer: 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 { if err != nil {
return nil, err return nil, err
} }
n.elem = e n.elem = e
*n.schema = *e.schema n.schema = nullableSchema(e.schema)
n.schema.Nullable = true
case reflect.String: case reflect.String:
n.schema.Type = "string" n.schema.Type = "string"
case reflect.Bool: 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 { if t.Kind() == reflect.Map && t.Key().Kind() != reflect.String {
return nil, errors.New("contract: map keys must be strings") 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") 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 { if err != nil {
return nil, err 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 { if sf.PkgPath != "" || sf.Anonymous {
return nil, fmt.Errorf("contract: %s must be an exported named field", sf.Name) 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 { if err != nil {
return nil, fmt.Errorf("contract: %s: %w", sf.Name, err) 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 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{} f := field{}
// Reject typos and unsupported tags instead of silently losing validation. // Reject typos and unsupported tags instead of silently losing validation.
tags, err := ParseTags(string(sf.Tag)) 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)) { 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)") 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 { if err != nil {
return f, err return f, err
} }
f.value = n 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() scalar := base(n).typ.Kind()
if f.source != "json" && !isScalar(scalar) && !(scalar == reflect.Slice && isScalar(base(base(n).elem).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") 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 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 { func (b *Binder) Parameters() []openapi.Parameter {
var out []openapi.Parameter var out []openapi.Parameter
@@ -382,7 +439,13 @@ func (b *Binder) Parameters() []openapi.Parameter {
if in == "form" { if in == "form" {
in = "query" 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 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

@@ -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,58 @@
syntax = "v1"
info (
title: "Configurable response example"
version: "1.0.0"
)
// Define the public body once. No field names are required by the framework.
response CommonResponse (
body: {
"code": "${code}",
"msg": "${message}",
"data": "${data}"
}
codeType: int32
successCode: 200
errorCode: 9999
successMessage: "操作成功"
dataMode: always
codes: {"ITEM_NOT_FOUND":404001}
messages: {"ITEM_NOT_FOUND":"记录不存在"}
)
response PartnerResponse (
body: {"accepted":"${ok}","payload":"${data}","meta":{"trace":"${requestId}"}}
errorBody: {"accepted":"${ok}","error":{"reason":"${message}"}}
)
error ItemNotFound 404 "ITEM_NOT_FOUND" "item not found"
type ItemReq {
ItemID int64 `path:"itemId" binding:"min=1"`
}
type ItemResp {
ItemID int64 `json:"itemId"`
Name string `json:"name"`
}
@server (
group: items
prefix: /api
response: CommonResponse
)
service response-api {
@errors ItemNotFound
@handler getItem
get /items/:itemId (ItemReq) returns (ItemResp)
// Success is the ItemResp object itself; errors still use CommonResponse.
@response raw
@handler rawItem
get /rawItems/:itemId (ItemReq) returns (ItemResp)
@response PartnerResponse
@handler partnerItem
get /partnerItems/:itemId (ItemReq) returns (ItemResp)
}

View File

@@ -12,8 +12,9 @@
"docs/openapi.json": "05bce8951699350cc1ebca5a626c39349395dd09e395b41560c129e530583204", "docs/openapi.json": "05bce8951699350cc1ebca5a626c39349395dd09e395b41560c129e530583204",
"internal/apierrors/errorsGen.go": "20e94bbc90ae9f4330de4c25b9a550026c4c352502719a643ede3e8912a6566e", "internal/apierrors/errorsGen.go": "20e94bbc90ae9f4330de4c25b9a550026c4c352502719a643ede3e8912a6566e",
"internal/handler/openapiGen.go": "e4130f5c7494f7db4830bfef203fa8154a9cd087c95954f64c29d74693ead433", "internal/handler/openapiGen.go": "e4130f5c7494f7db4830bfef203fa8154a9cd087c95954f64c29d74693ead433",
"internal/handler/routesGen.go": "7f45030e7eb9f055c4b4d2ff12ad855d7c726aad4aee63c5b3c3f97559a64257", "internal/handler/responseGen.go": "d742efc2a3d1d168fae7849e905297bd440c3d6b3802b6150f494c07e6097f4f",
"internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e", "internal/handler/routesGen.go": "4e0c6879cea40e9c0155439fa1fce2cd19b5f83ea6e304523c41f8dc9c3a28e0",
"internal/handler/user/handlersGen.go": "1808afc8e4e8ca9785a8552eff64fad39bd5fe4e6e92971f35afc9006e23d76e",
"internal/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00", "internal/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00",
"internal/types/typesGen.go": "3ab2ce9ffe467c3ea53ac3d1fec283ecebc87cef57fd592d08dca991fd964876" "internal/types/typesGen.go": "3ab2ce9ffe467c3ea53ac3d1fec283ecebc87cef57fd592d08dca991fd964876"
} }

View File

@@ -18,8 +18,9 @@ go-omni migrate --dir migrations --apply
go run ./cmd/server go run ./cmd/server
``` ```
源码示例的 go.mod 使用 `replace ... => ../..`。复制到独立仓库时,删除这条 replace, 源码示例的 go.mod 使用 `replace ... => ../..`。生成代码使用 `v0.8.0-preview` 响应策略入口。
保留已发布的固定依赖版本,再执行 go mod tidy。远程版本验证脚本会自动检查没有本地替换。 复制到独立仓库时删除 replace,保留同版本固定依赖,再执行 go mod tidy;CLI 也固定到同一版本。
远程版本验证脚本会检查没有本地替换。
`GO_OMNI_TRANSPORT=native` 可切换为 native,业务代码无需调整。 `GO_OMNI_TRANSPORT=native` 可切换为 native,业务代码无需调整。
配置是默认值 → etc/config.yaml → 环境变量。日志默认写入 var/log/server.log 并轮转, 配置是默认值 → etc/config.yaml → 环境变量。日志默认写入 var/log/server.log 并轮转,
@@ -70,10 +71,10 @@ Gen.go 和 docs/openapi.json 由生成器维护;Service、Repository、迁移
在框架仓库运行: 在框架仓库运行:
```sh ```sh
python3 scripts/verifyAPIWorkflow.py python3 scripts/verifyAPIWorkflow.py --report performance/responsePoliciesWorkflowLocal.json
# 已发布版本:全新 module cache 安装 CLI,无本地 replace 消费框架。 # 使用本版远程 CLI 和模块验收,无本地替换。
python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview.1 \ python3 scripts/verifyAPIWorkflow.py --version v0.8.0-preview \
--report performance/apiWorkflowRemote.json --report performance/responsePoliciesWorkflowRemote.json
``` ```
验收创建只绑定 localhost 的临时 MySQL 容器(1 CPU / 768 MiB),使用随机密钥,结束时删除。 验收创建只绑定 localhost 的临时 MySQL 容器(1 CPU / 768 MiB),使用随机密钥,结束时删除。

View File

@@ -3,7 +3,7 @@ module example.com/userService
go 1.22.0 go 1.22.0
require ( require (
git.yuntuo.hk/k2safe/go-omni v0.7.0-preview.1 git.yuntuo.hk/k2safe/go-omni v0.8.0-preview
github.com/go-sql-driver/mysql v1.9.3 github.com/go-sql-driver/mysql v1.9.3
) )

View File

@@ -0,0 +1,30 @@
// Code generated by go-omni api. DO NOT EDIT.
package handler
import (
"encoding/json"
"git.yuntuo.hk/k2safe/go-omni/response"
"strings"
)
var responseTemplates = map[string]response.Encoder{"": response.Default()}
func 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)
}
func 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
}

View File

@@ -6,6 +6,7 @@ import (
s0 "example.com/userService/internal/service/user" s0 "example.com/userService/internal/service/user"
"example.com/userService/internal/servicecontext" "example.com/userService/internal/servicecontext"
"fmt" "fmt"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/openapi" "git.yuntuo.hk/k2safe/go-omni/openapi"
) )
@@ -16,12 +17,12 @@ func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) erro
if deps.Middlewares["Auth"] == nil { if deps.Middlewares["Auth"] == nil {
return fmt.Errorf("missing middleware Auth") return fmt.Errorf("missing middleware Auth")
} }
h0 := h0.New(s0.New(deps)) h0 := h0.New(s0.New(deps), deps)
router.Handle("POST", "/api/v1/users", h0.CreateUser, deps.Middlewares["Auth"]) httpx.HandleWithResponse(router, "POST", "/api/v1/users", responseEncoder("", false), h0.CreateUser, deps.Middlewares["Auth"])
router.Handle("GET", "/api/v1/users/:id", h0.GetUser, deps.Middlewares["Auth"]) httpx.HandleWithResponse(router, "GET", "/api/v1/users/:id", responseEncoder("", false), h0.GetUser, deps.Middlewares["Auth"])
router.Handle("GET", "/api/v1/users", h0.ListUsers, deps.Middlewares["Auth"]) httpx.HandleWithResponse(router, "GET", "/api/v1/users", responseEncoder("", false), h0.ListUsers, deps.Middlewares["Auth"])
router.Handle("PUT", "/api/v1/users/:id", h0.UpdateUser, deps.Middlewares["Auth"]) httpx.HandleWithResponse(router, "PUT", "/api/v1/users/:id", responseEncoder("", false), h0.UpdateUser, deps.Middlewares["Auth"])
router.Handle("DELETE", "/api/v1/users/:id", h0.DeleteUser, deps.Middlewares["Auth"]) httpx.HandleWithResponse(router, "DELETE", "/api/v1/users/:id", responseEncoder("", false), h0.DeleteUser, deps.Middlewares["Auth"])
router.Handle("POST", "/api/v1/users/:id/export", h0.ExportUser, deps.Middlewares["Auth"]) httpx.HandleWithResponse(router, "POST", "/api/v1/users/:id/export", responseEncoder("", false), h0.ExportUser, deps.Middlewares["Auth"])
return nil return nil
} }

View File

@@ -3,6 +3,7 @@ package user
import ( import (
"context" "context"
"example.com/userService/internal/servicecontext"
"example.com/userService/internal/types" "example.com/userService/internal/types"
"fmt" "fmt"
"git.yuntuo.hk/k2safe/go-omni/contract" "git.yuntuo.hk/k2safe/go-omni/contract"
@@ -17,9 +18,18 @@ type Service interface {
DeleteUser(ctx context.Context, req *types.UserIDReq) error DeleteUser(ctx context.Context, req *types.UserIDReq) error
ExportUser(ctx context.Context, req *types.UserIDReq) (*types.ExportResp, 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]() 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...)
}

80
httpx/bridge.go Normal file
View File

@@ -0,0 +1,80 @@
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 }()
if err := next(child); err != nil && !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 ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors" apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
@@ -30,6 +31,12 @@ type Router interface {
Group(path string) RouterGroup Group(path string) RouterGroup
} }
// FallbackRouter optionally routes 404/405 through the application's response
// policy and middleware. Custom adapters can implement it without changing Router.
type FallbackRouter interface {
SetFallbacks(notFound, methodNotAllowed http.Handler)
}
// RouterGroup is the route-registration portion of Router. // RouterGroup is the route-registration portion of Router.
type RouterGroup interface { type RouterGroup interface {
Handle(method, path string, handler http.Handler) Handle(method, path string, handler http.Handler)
@@ -87,6 +94,7 @@ type Context struct {
values map[string]any values map[string]any
responded bool responded bool
status int status int
encoder response.Encoder
} }
// NewContext creates a transport-neutral GoOmni context. // NewContext creates a transport-neutral GoOmni context.
@@ -94,12 +102,15 @@ func NewContext(request *http.Request, writer http.ResponseWriter, params map[st
if request == nil { if request == nil {
request = &http.Request{} request = &http.Request{}
} }
return &Context{ c := &Context{
request: request, request: request,
writer: writer,
params: params, params: params,
values: make(map[string]any), values: make(map[string]any),
} }
if writer != nil {
c.writer = trackWriter(writer, c)
}
return c
} }
func (c *Context) Request() *http.Request { return c.request } func (c *Context) Request() *http.Request { return c.request }
@@ -113,14 +124,15 @@ func (c *Context) Context() context.Context {
return c.request.Context() return c.request.Context()
} }
// Raw returns the response writer supplied by the active network adapter. // Raw returns a streaming response writer that tracks commits and status.
// Prefer the transport-neutral methods on Context for portable handlers. // Optional HTTP capabilities are preserved only when the underlying writer supports them.
func (c *Context) Raw() http.ResponseWriter { return c.writer } func (c *Context) Raw() http.ResponseWriter { return c.writer }
// Clone gives a buffering middleware its own response state and values map. // Clone gives a buffering middleware its own response state and values map.
// Stored values themselves are shared; middleware must not mutate them concurrently. // Stored values themselves are shared; middleware must not mutate them concurrently.
func (c *Context) Clone(ctx context.Context, writer http.ResponseWriter) *Context { func (c *Context) Clone(ctx context.Context, writer http.ResponseWriter) *Context {
clone := NewContext(c.request.Clone(ctx), writer, c.params) clone := NewContext(c.request.Clone(ctx), writer, c.params)
clone.encoder = c.encoder
for key, value := range c.values { for key, value := range c.values {
clone.values[key] = value clone.values[key] = value
} }
@@ -138,7 +150,6 @@ func (c *Context) WriteResponse(status int, headers http.Header, body []byte) er
for key, values := range headers { for key, values := range headers {
c.writer.Header()[key] = append([]string(nil), values...) c.writer.Header()[key] = append([]string(nil), values...)
} }
c.responded, c.status = true, status
c.writer.WriteHeader(status) c.writer.WriteHeader(status)
if len(body) > 0 { if len(body) > 0 {
if _, err := c.writer.Write(body); err != nil { if _, err := c.writer.Write(body); err != nil {
@@ -208,8 +219,6 @@ func (c *Context) JSON(status int, value any) error {
if err != nil { if err != nil {
return apierrors.Internal(err) return apierrors.Internal(err)
} }
c.responded = true
c.status = status
c.writer.Header().Set("Content-Type", "application/json; charset=utf-8") c.writer.Header().Set("Content-Type", "application/json; charset=utf-8")
c.writer.WriteHeader(status) c.writer.WriteHeader(status)
if _, err := c.writer.Write(append(data, '\n')); err != nil { if _, err := c.writer.Write(append(data, '\n')); err != nil {
@@ -235,14 +244,13 @@ func (c *Context) NoContent() error {
if c.writer == nil { if c.writer == nil {
return apierrors.Internal(nil) return apierrors.Internal(nil)
} }
c.responded = true
c.status = http.StatusNoContent
c.writer.WriteHeader(http.StatusNoContent) c.writer.WriteHeader(http.StatusNoContent)
return nil return nil
} }
func (c *Context) respond(status int, code, message string, data any) error { func (c *Context) respond(status int, code, message string, data any) error {
return c.JSON(status, response.Response{ return c.writeResult(response.Result{
Status: status,
Code: code, Code: code,
Message: message, Message: message,
Data: data, Data: data,
@@ -262,16 +270,24 @@ func (c *Context) Status() int {
func (c *Context) Responded() bool { return c.responded } func (c *Context) Responded() bool { return c.responded }
// WriteError writes a stable public error response. Internal causes are not // WriteError writes a stable public error response. Internal causes are not
// serialized. Responses committed through Context methods are preserved; // serialized. Responses committed through Context methods or Raw are preserved.
// direct Raw writes are outside this response-tracking contract.
func (c *Context) WriteError(err error) { func (c *Context) WriteError(err error) {
if c.responded { if c.responded || err == nil {
return return
} }
var bodyErr *http.MaxBytesError
if errors.As(err, &bodyErr) {
err = bodyTooLarge()
}
apiErr := apierrors.From(err) apiErr := apierrors.From(err)
_ = c.JSON(apiErr.Status, response.Response{ if writeErr := c.writeResult(response.Result{
Status: apiErr.Status,
Code: apiErr.Code, Code: apiErr.Code,
Message: apiErr.Message, Message: apiErr.Message,
RequestID: c.RequestID(), 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)
}
} }

85
httpx/response.go Normal file
View File

@@ -0,0 +1,85 @@
package httpx
import (
"encoding/xml"
"fmt"
"net/http"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/response"
)
// SetResponseEncoder overrides this request's success AND error encoding. A
// cloned context inherits it, including buffered/idempotent requests.
func (c *Context) SetResponseEncoder(encoder response.Encoder) { c.encoder = encoder }
// WithResponse applies a policy to one route/group. It deliberately stays set
// until the outer error boundary finishes handling this request.
func WithResponse(encoder response.Encoder) Middleware {
return func(next Handler) Handler {
return func(c *Context) error { c.SetResponseEncoder(encoder); return next(c) }
}
}
// ResponseRegistrar installs the response policy before application middleware,
// so early auth/protection failures and buffered clones use the route contract.
type ResponseRegistrar interface {
HandleWithResponse(string, string, response.Encoder, Handler, ...Middleware)
}
// HandleWithResponse uses the registrar's early policy hook when available.
// Custom registrars without this hook get an ordinary leading route middleware.
func HandleWithResponse(router interface {
Handle(string, string, Handler, ...Middleware)
}, method, path string, encoder response.Encoder, handler Handler, middleware ...Middleware) {
if r, ok := router.(ResponseRegistrar); ok {
r.HandleWithResponse(method, path, encoder, handler, middleware...)
return
}
all := append([]Middleware{WithResponse(encoder)}, middleware...)
router.Handle(method, path, handler, all...)
}
func (c *Context) writeResult(result response.Result) (err error) {
if c.responded {
return nil
}
defer func() {
if recovered := recover(); recovered != nil {
err = apierrors.Internal(fmt.Errorf("response encoder panic: %v", recovered))
}
}()
encoder := c.encoder
if encoder == nil {
encoder = response.Default()
}
payload, err := encoder.Encode(result)
if err != nil {
return apierrors.Internal(err)
}
if payload.Status < 200 || payload.Status > 599 {
return apierrors.Internal(fmt.Errorf("response encoder returned invalid status %d", payload.Status))
}
if (payload.Status == 204 || payload.Status == 304) && len(payload.Body) > 0 {
return apierrors.Internal(fmt.Errorf("response encoder returned a body for status %d", payload.Status))
}
return c.WriteResponse(payload.Status, payload.Header, payload.Body)
}
// Bytes, Text and XML are explicit wire responses; they bypass any envelope.
func (c *Context) Bytes(status int, contentType string, body []byte) error {
return c.WriteResponse(status, http.Header{"Content-Type": {contentType}}, body)
}
func (c *Context) Text(status int, body string) error {
return c.Bytes(status, "text/plain; charset=utf-8", []byte(body))
}
func (c *Context) XML(status int, value any) error {
if c.responded {
return nil
}
body, err := xml.Marshal(value)
if err != nil {
return apierrors.Internal(err)
}
return c.Bytes(status, "application/xml; charset=utf-8", body)
}

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() 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,让运行平台管理日志文件。 轮转显式开启;三个数值为 0 时分别取 100 MiB、10 份、7 天。超过大小后轮转,旧文件按份数/年龄清理,可 gzip 压缩;保留天数不表示每天定时切割。压缩和历史清理由底层异步执行,不承诺 Close 等待压缩完成。轮转使用 [lumberjack v2](https://github.com/natefinch/lumberjack/tree/v2.2.1),**一个日志文件只能由一个进程拥有**。容器通常直接用 stdout/stderr,让运行平台管理日志文件。

View File

@@ -48,6 +48,10 @@ func WithRequestIDHeader(header string) Option { return config.WithRequestIDHead
func WithLogger(enabled bool) Option { return config.WithLogger(enabled) } func WithLogger(enabled bool) Option { return config.WithLogger(enabled) }
func WithResponse(settings response.Config) Option { return config.WithResponse(settings) }
func WithResponseEncoder(encoder response.Encoder) Option { return config.WithResponseEncoder(encoder) }
func WithSlowRequestThreshold(threshold time.Duration) Option { func WithSlowRequestThreshold(threshold time.Duration) Option {
return config.WithSlowRequestThreshold(threshold) return config.WithSlowRequestThreshold(threshold)
} }

42
openapi/codeTypes_test.go Normal file
View File

@@ -0,0 +1,42 @@
package openapi_test
import (
"testing"
"git.yuntuo.hk/k2safe/go-omni/openapi"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestResponseCodeSchemaUsesGoWidths(t *testing.T) {
for _, kind := range []string{"string", "int32", "int64", "integer"} {
cfg := response.Config{CodeType: kind}
wantType, wantFormat := "string", ""
if kind != "string" {
cfg.SuccessCode = 200
cfg.ErrorCode = 9999
wantType = "integer"
}
if kind == "int32" || kind == "int64" {
wantFormat = kind
}
for _, nested := range []bool{false, true} {
if nested {
cfg.Body = map[string]any{"result": map[string]any{"code": "${code}"}}
cfg.ErrorBody = cfg.Body
}
for _, data := range []*openapi.Schema{nil, {Type: "object"}} {
schema, err := openapi.EnvelopeWith(data, cfg)
if err != nil {
t.Fatal(err)
}
if nested {
schema = schema.Properties["result"]
}
code := schema.Properties["code"]
if code.Type != wantType || code.Format != wantFormat {
t.Fatalf("%s: %+v", kind, code)
}
}
}
}
}

114
openapi/envelope.go Normal file
View File

@@ -0,0 +1,114 @@
package openapi
import (
"encoding/json"
"git.yuntuo.hk/k2safe/go-omni/response"
"sort"
"strings"
)
// EnvelopeWith describes the same configurable envelope used at runtime. data
// describes the business value; it is never embedded under a fixed field name.
// A nil data schema describes an error response and selects ErrorBody if set.
func EnvelopeWith(data *Schema, cfg response.Config) (*Schema, error) {
f, err := response.New(cfg)
if err != nil {
return nil, err
}
c := f.Config()
body := c.Body
if data == nil && c.ErrorBody != nil {
body = c.ErrorBody
}
if body != nil {
return templateSchema(body, data, c), nil
}
s := &Schema{Type: "object", Properties: map[string]*Schema{}}
if c.CodeField != "-" {
s.Properties[c.CodeField] = codeSchema(c.CodeType)
s.Required = append(s.Required, c.CodeField)
}
if c.MessageField != "-" {
s.Properties[c.MessageField] = &Schema{Type: "string"}
s.Required = append(s.Required, c.MessageField)
}
if c.RequestIDField != "-" {
s.Properties[c.RequestIDField] = &Schema{Type: "string"}
}
if c.DataField != "-" && (data != nil || c.DataMode == "always") {
if data == nil {
data = &Schema{Type: "object", Nullable: true, Enum: []any{nil}}
}
s.Properties[c.DataField] = data
s.Required = append(s.Required, c.DataField)
}
return s, nil
}
func templateSchema(value any, data *Schema, cfg response.Config) *Schema {
switch response.Slot(value) {
case "code":
return codeSchema(cfg.CodeType)
case "message", "requestId":
return &Schema{Type: "string"}
case "ok":
return &Schema{Type: "boolean"}
case "status":
return &Schema{Type: "integer"}
case "data":
if data != nil {
return data
}
return &Schema{Type: "object", Nullable: true, Enum: []any{nil}}
}
switch v := value.(type) {
case map[string]any:
s := &Schema{Type: "object", Properties: map[string]*Schema{}}
keys := make([]string, 0, len(v))
for key := range v {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
item := v[key]
if response.Slot(item) == "data" && data == nil && cfg.DataMode != "always" {
continue
}
s.Properties[key] = templateSchema(item, data, cfg)
if response.Slot(item) != "requestId" {
s.Required = append(s.Required, key)
}
}
return s
case []any:
items := &Schema{}
for _, item := range v {
items.AnyOf = append(items.AnyOf, templateSchema(item, data, cfg))
}
size := len(v)
return &Schema{Type: "array", Items: items, MinItems: &size, MaxItems: &size}
case string:
if strings.HasPrefix(v, "$${") {
v = v[1:]
}
return &Schema{Type: "string", Enum: []any{v}}
case bool:
return &Schema{Type: "boolean", Enum: []any{v}}
case nil:
return &Schema{Type: "object", Nullable: true, Enum: []any{nil}}
default:
typeName := "number"
encoded, _ := json.Marshal(v)
if _, err := json.Number(string(encoded)).Int64(); err == nil {
typeName = "integer"
}
return &Schema{Type: typeName, Enum: []any{v}}
}
}
func codeSchema(kind string) *Schema {
if kind == "int32" || kind == "int64" {
return &Schema{Type: "integer", Format: kind}
}
return &Schema{Type: kind}
}

View File

@@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/response"
"net/http" "net/http"
"reflect" "reflect"
"regexp" "regexp"
@@ -36,6 +37,7 @@ type Schema struct {
Enum []any `json:"enum,omitempty"` Enum []any `json:"enum,omitempty"`
Default any `json:"default,omitempty"` Default any `json:"default,omitempty"`
Not *Schema `json:"not,omitempty"` Not *Schema `json:"not,omitempty"`
AnyOf []*Schema `json:"anyOf,omitempty"`
} }
type Parameter struct { type Parameter struct {
Name string `json:"name"` Name string `json:"name"`
@@ -181,7 +183,8 @@ func (a *API) Handler() httpx.Handler {
} }
} }
func Envelope(data *Schema) *Schema { func Envelope(data *Schema) *Schema {
return &Schema{Type: "object", Required: []string{"code", "message"}, Properties: map[string]*Schema{"code": {Type: "string"}, "message": {Type: "string"}, "requestId": {Type: "string"}, "data": data}} schema, _ := EnvelopeWith(data, response.Config{})
return schema
} }
func SchemaOf[T any]() (*Schema, error) { func SchemaOf[T any]() (*Schema, error) {
return schema(reflect.TypeOf((*T)(nil)).Elem(), map[reflect.Type]bool{}, 0) return schema(reflect.TypeOf((*T)(nil)).Elem(), map[reflect.Type]bool{}, 0)

View File

@@ -25,14 +25,23 @@
## 远程版本验证 ## 远程版本验证
发布后执行以下命令,从全新 module cache 下载 CLI 和框架,验证固定版本且没有本地 replace 的业务项目: 已发布 `v0.7.0-preview.1`,标签指向 `2913e94a0a4764ca70f0d19d198bd6c4852d8149`。
执行以下命令,从全新 module cache 下载 CLI 和框架,验证固定版本且没有本地 replace 的业务项目:
```sh ```sh
python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview.1 \ python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview.1 \
--report performance/apiDeveloperExperienceRemote.json --report performance/apiDeveloperExperienceRemote.json
``` ```
远程结果在实际完成后记录;本地通过不代替远程下载验证。 远程验证通过:
- 远程安装的 CLI 在新目录生成 MySQL + local 项目,依赖固定为本版,无本地 replace,编译和测试通过。
- 用户示例从远程下载框架;module checksum 为 `h1:Eavj6gW9/v0y/knsQne5eNYIHxZZ9+e/r4Nu5PRUQfI=`。
- nethttp/native 共 34 项 HTTP、业务及生命周期检查通过,包括真实 401/403/409 与对应 OpenAPI 文档。
- 数据库迁移、事务提交/回滚、初始化失败清理测试通过;重新生成前后 29 份开发者拥有的文件逐字节保持一致。
- [远程业务报告](apiDeveloperExperienceRemote.json) 记录版本、检查列表与实际源码哈希;源码哈希已与本轮最终代码核对一致。
验收全部结束,临时服务、数据库容器及测试目录已清理。发布标签保持不变,后续验收记录维护在 main。
## 边界 ## 边界

View File

@@ -0,0 +1,161 @@
{
"passed": true,
"frameworkVersion": "v0.7.0-preview.1",
"noLocalReplace": true,
"durationSeconds": 27.37,
"regenerationPreservedHandwrittenFiles": 29,
"groups": [
{
"transport": "nethttp",
"checks": [
"authBeforeService",
"readOnlyPermission403",
"emptyPageAndQueryDefaults",
"contractValidation",
"createReadNormalize",
"uniqueEmailConflict",
"updateAndPagination",
"createUpdateDeleteRollbackOnAuditFailure",
"storageUploadAndSignedDownload",
"signedDownloadTamperRejected",
"openAPIMatchesContract",
"declaredAuthAndBusinessErrorDocumentation",
"readinessOutageAndRecovery",
"persistenceAfterServiceRestart",
"deleteAndTransactionalAudit",
"gracefulStopAndListenerRelease",
"businessFileLogsAndSecretExclusion"
]
},
{
"transport": "native",
"checks": [
"authBeforeService",
"readOnlyPermission403",
"emptyPageAndQueryDefaults",
"contractValidation",
"createReadNormalize",
"uniqueEmailConflict",
"updateAndPagination",
"createUpdateDeleteRollbackOnAuditFailure",
"storageUploadAndSignedDownload",
"signedDownloadTamperRejected",
"openAPIMatchesContract",
"declaredAuthAndBusinessErrorDocumentation",
"readinessOutageAndRecovery",
"persistenceAfterServiceRestart",
"deleteAndTransactionalAudit",
"gracefulStopAndListenerRelease",
"businessFileLogsAndSecretExclusion"
]
}
],
"transactionTests": [
"commit",
"errorRollback",
"panicRollback",
"cancellationRollback"
],
"startupTests": [
"storageFailureClosesDatabase",
"idempotentClose",
"closedContextCannotReopen"
],
"migrationCLI": true,
"frameworkSum": "h1:Eavj6gW9/v0y/knsQne5eNYIHxZZ9+e/r4Nu5PRUQfI=",
"sourceHashes": {
"cmd/go-omni/api.go": "4e2722bdf6f0973df2f91d23ba3a8ade9cf5d8ec8dba68c188382a3c98415109",
"cmd/go-omni/api_test.go": "172e4e2ba4984820ba729f88273b1ae133415081344e51c3654f297ef33216d4",
"cmd/go-omni/apigen/apigen_test.go": "7cbe654e84c60f7b7f3a9631472bb66c1999ff4a9eaacaec224d26e78d08a38c",
"cmd/go-omni/apigen/compile.go": "c1334934f4f329b20efe9bfd8903f18125355d4a21111cdcd3fa0d2c8e88bcd9",
"cmd/go-omni/apigen/contractMetadata.go": "87239fa7d1610963d1c812f79074f9ceb6a71c263d16c1b1ed257fbc553a4fac",
"cmd/go-omni/apigen/declarations.go": "3f8073fb0b0bfa775f7712ed28e670f8dc98571636656726e18a6d592692bc81",
"cmd/go-omni/apigen/generate.go": "b6c10d5e7f22a36d436a4b720da9a123e4caec82ca09b349b589ac578ccfdbb8",
"cmd/go-omni/apigen/metadata_test.go": "ab0c55e90e9b7a8bdaf260415e4c8564f6b2378de625fd9379a2b801cb4bc0e5",
"cmd/go-omni/apigen/parse.go": "82ff76277478d62b7df291eec911070da168cd3739c9006f58d367e6276b77b5",
"cmd/go-omni/apigen/profile.go": "877939398384c44316e5e052d92c4a93fb3b74a6742df2414a0054913f3fca27",
"cmd/go-omni/apigen/profile_test.go": "5f3c091da241fe0f8b99ac19e9efd2c3177aea72e63a346f99fecc0f548aad1a",
"cmd/go-omni/apigen/projectEntry.go": "817936d391ec7dc76f7b5b9f7d134537947ea238d2cd281eb3df536511cb5486",
"cmd/go-omni/apigen/templates.go": "36b4946acc636960bf7ca1d95ab364135432392829c12be523ef6b514e433f63",
"cmd/go-omni/apigen/write.go": "241c8a4d386070ffee25aa5b5ce03129fe739fe3fc78c0ba65d2f48bdb075b6d",
"cmd/go-omni/main.go": "060b064973f2316b6f01c7b9a716d57c3dbc8ad33a5d6153f6cfddaf649cd5fc",
"cmd/go-omni/main_test.go": "b8040d2b19a1c86672fe1b1be7fa1bf7f885aece79ded6ffa0cc670887b7136f",
"cmd/go-omni/migrate.go": "f1ce89fbe419205eb82172da2b90e8888905d2b85d1a9db2a72b568bcf849a70",
"cmd/go-omni/migrate_test.go": "15d9a4aaebf9f899e523e908f1fa3c2282eb1e6c8cadbefae6b68f1ae4e00e6e",
"cmd/go-omni/scaffold/addModule.go": "8f43bd68c2b3820cf1d036c4f2c5f9d06d1c7c8e5114f5081597d905f8d8cdcc",
"cmd/go-omni/scaffold/addModule_test.go": "667c45312f628f0fb5b8206ddef7ccc02a75f33b72f73fd6a17b03da0cb62e91",
"cmd/go-omni/scaffold/apiInfrastructure.go": "f493364448254cae2d8e658bf5c69e96a3264c2bf3fa50fd48a5ebdb20edabd8",
"cmd/go-omni/scaffold/moduleProfiles_test.go": "ae9790e5d3549d32fc72739a10a31ea858e5a8f8189cf1f5c1840bf80b3f8ad6",
"cmd/go-omni/scaffold/moduleTemplates/README.md.tmpl": "9d3bfc46cac5618bcff7d8a6b2cc9f123e818aeb3dee9bcdfe0f91db584994bd",
"cmd/go-omni/scaffold/moduleTemplates/handler-handler.go.tmpl": "729cd0f6faba53d5d2c1492935e12ac2adcefde4490748e25c18be7ccb3f7139",
"cmd/go-omni/scaffold/moduleTemplates/model-model.go.tmpl": "a005611f6b455d4d8469e316bc26fa571043b2da1834db2b3dd14a96d66221d7",
"cmd/go-omni/scaffold/moduleTemplates/module.go.tmpl": "fb0be7fd571ba2c0edb01d70d68cdbbfe53561caab98346bf1ac7cd8df07205a",
"cmd/go-omni/scaffold/moduleTemplates/repository-repository.go.tmpl": "902da3d9f12395fb226178c4428ff547e1c2fce3261882dba98d1400d8c90747",
"cmd/go-omni/scaffold/moduleTemplates/service-service.go.tmpl": "49d26044a69a43e983aa5ba4b5b2399b26b77898b23e230751ad1372c2af3178",
"cmd/go-omni/scaffold/scaffold.go": "71b3091b83c3e45ac1981164e8c819c997ad96607d535f1492333370f4872548",
"cmd/go-omni/scaffold/scaffold_test.go": "58aab9251b01e394bbc90aeb538ff035dd32c73c0d4815132ac6cbc086527cda",
"cmd/go-omni/scaffold/templates/README.md.tmpl": "8ec215775fcafd437480022307fffd2ffe43685bc2b5d328b0c1d58402986e0a",
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "d2d67c3d235c4b1c965364f40f49c441d92764ad7ab8fd6848f56b1028e93b1b",
"cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl": "07c4f81109ece43fcf15bb807ada5ae6dc7e732babc7293269514a21cd389955",
"cmd/go-omni/scaffold/templates/databaseIntegration_test.go.tmpl": "81c4f7fbc919d74b738d3a7338248cbd83cfa0803b2058e8ee184e3ce51e7e9b",
"cmd/go-omni/scaffold/templates/go.mod.tmpl": "7d3bb15d2292e36939cc1905258d2800aeaf0e37d7d041b2ff8cfdb6fc84c57a",
"cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl": "7f8dbcd2f9b0129a90160d6059e47d19d4758274060a1a3e5160055d0592d34f",
"cmd/go-omni/scaffold/templates/internal-model-errors.go.tmpl": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
"cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
"cmd/go-omni/scaffold/templates/internal-repository-memory.go.tmpl": "148abc273c1fab3f28536706792762ae41e8687dfe9986c374372f162600feac",
"cmd/go-omni/scaffold/templates/internal-repository-mysql.go.tmpl": "34bd333e29211a0025ff6ebaea18c7f142f18f1cc451d692f9d360e8c8f6f635",
"cmd/go-omni/scaffold/templates/internal-repository-users.go.tmpl": "6d7dd80f34a9c215daeb4a86bfbe11e845e7ff7e9864c8af43e1c2ec3a8b28ec",
"cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl": "33fdee3a2c3d98e7ca6a323b5e888a73582f94e327b6c24a34c09bf180db7b3d",
"cmd/go-omni/scaffold/templates/internal-servicecontext-context.go.tmpl": "de70ecc4194db8a2da5d65297f452a40b453652cb814cd228ce03d465761c856",
"cmd/go-omni/scaffold/templates/modules-README.md.tmpl": "4b84fbc2310b856d86c6fcd9a47d97d2261019c74ea8e0cdbc41229086aa3527",
"cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl": "b8734080ae6d533a175bcad946ce2cb55d8bac1729918291753b9ca03064910f",
"cmd/go-omni/scaffold/templates/modules-filesHandler.go.tmpl": "426867fbef5b99a85394fa4457dd38b9a194adf4bdfb293384b2fbbc799091da",
"cmd/go-omni/scaffold/templates/modules-filesHandler_test.go.tmpl": "9571e39bd0a330739eb1fd7e636cd58961b41b44772b2b49162e41deda98547f",
"cmd/go-omni/scaffold/templates/modules-filesService.go.tmpl": "6460ee9a873af45ca6cec779bf53302acfce21499610425a4704c2f179eddb8f",
"cmd/go-omni/scaffold/templates/modules-gitignore.tmpl": "2579f2a63ff2962239b92dd841b0d0d26e783d85c9d0100011936c8bcbf5d0d3",
"cmd/go-omni/scaffold/templates/modules-main.go.tmpl": "cb8daddd4e12afd238540be7022187c74a548d9e409b3a7f1eaf84a9139218bf",
"cmd/go-omni/scaffold/templates/modules-settings.go.tmpl": "91084caaa1fd994dde714f52b242d23e41adb731383b12d74399247c87ac5597",
"cmd/go-omni/scaffold/templates/modules-settings_test.go.tmpl": "76e55f8954b7606d7146eb1e965065c521a2b81234a47742ed69a93aac300406",
"cmd/go-omni/scaffold/templates/modules-storage.go.tmpl": "a775acf2d2490288a8e5482bb0a8769be6b0a8c9cfcc9bb10b62aabbd05fa0c7",
"cmd/go-omni/scaffold/templates/modules-storage_test.go.tmpl": "dacca42d228bd9352948054eb2d071829a8a61d8c6fa24d95ff00fc39a74309e",
"cmd/go-omni/scaffold/templates/schema.sql.tmpl": "7c5108f18fc7d8ff75c97afd96fcaa9f8ed026c79c71c5612f55572d73354abb",
"cmd/go-omni/scaffold/version.go": "a389f1e0651544b104481309272722ad8736d7f5a4e27484c61dc8eceba84283",
"database/configOverlay_test.go": "01c9b44fded226494ca12f7d0f41a9ff417d8135d263a5833727c1c028b6b8e3",
"database/database.go": "3d8615e1bf58ecdae21558591e745c241e499ba6e85fc07acfa2344e04ce95db",
"database/database_test.go": "18eac1e4c6dbd6a09216168839ba3511aac8a38c2ddf6fdd5267f35435b09f67",
"database/lifecycle_test.go": "e141b52edfb9867184dedac0e79feed56a5b137acbe64280ad69647653bbea21",
"database/migrate/migrate.go": "fd6cd27d2dce5b35ffe80eb5442c47c657b93ffc6545bbcc1ee4651a70a4f80b",
"database/migrate/migrate_test.go": "d7ba91eed8cfd97e4ef0741d582f493dd411b65ad3192f05b167f75d9b9dcf90",
"examples/userService/api/user.api": "ed4d45cb796d9a017af9d341076553ca576918faef155aeea528718515582c95",
"examples/userService/cmd/server/main.go": "80ba4721f677947b7f2ece21d5f3e89ee0c4d2cc653412d83aef2b3a45bd3ccb",
"examples/userService/etc/config.yaml": "f50df3cddbc3c23f233405e7a9d8895b9d83d2a0607ccf001261fcd4b8978ee9",
"examples/userService/internal/apierrors/errorsGen.go": "20e94bbc90ae9f4330de4c25b9a550026c4c352502719a643ede3e8912a6566e",
"examples/userService/internal/handler/openapiGen.go": "e4130f5c7494f7db4830bfef203fa8154a9cd087c95954f64c29d74693ead433",
"examples/userService/internal/handler/routesGen.go": "7f45030e7eb9f055c4b4d2ff12ad855d7c726aad4aee63c5b3c3f97559a64257",
"examples/userService/internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e",
"examples/userService/internal/middleware/auth.go": "fbfe8ff8b12f698218b9ef5f77fd85b612b3aed362a73dc26c3627f2d688c040",
"examples/userService/internal/middleware/auth_test.go": "63d6117dadf43b766e0aa485a63c4725a5109ab814c2b4a1fa7a59ee6d1c4cf6",
"examples/userService/internal/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00",
"examples/userService/internal/model/errors.go": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
"examples/userService/internal/model/user.go": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
"examples/userService/internal/repository/user/mysql.go": "924cae233a68e506e9b0b953529e0ccb13a0e71d24723309383c464e1852b833",
"examples/userService/internal/repository/user/repository.go": "2aca07788d8d59831b10ac665882ca8157b20f384115bb90742f235f8352a69d",
"examples/userService/internal/repository/user/store.go": "f776832e4f3b26fb889dad53b65742833ddb7f43b7dddc72915ad3fe47ec4d3e",
"examples/userService/internal/repository/user/transaction_test.go": "9af4cb3a03b71026b80aa5078ddda4e3c4f72a7ab14bed6c87603a4db2cc2a22",
"examples/userService/internal/service/user/createUser.go": "53d0ea7371e10a0bd3f32f57b9bff9b48b5f9a3315218f95a258fa45b81d9368",
"examples/userService/internal/service/user/deleteUser.go": "0cd0b038d5a11429de438311980ef19a8f6e28ea0d7cf5ebaaf78858daf3a124",
"examples/userService/internal/service/user/errors_test.go": "c0193bdc674cbe15c4871a91e288323a99193d19ba8fd874f9bf17c390917e5c",
"examples/userService/internal/service/user/exportUser.go": "dac2adb0c9ea43a4af15511ae488afaa6d9c9b351e68e9b8a54f4014d29de14d",
"examples/userService/internal/service/user/getUser.go": "a307fa716c70286a2a6c5d5c0a6c35ec6d258c3278e2a7fe86b74b324d7b7283",
"examples/userService/internal/service/user/listUsers.go": "f0bf8b9c3a4c4a699112e3493b7fe611e2dd4f6e8a4437106396c47d484f1383",
"examples/userService/internal/service/user/service.go": "cd6c203459fccbc030e88eccfa785d5db029e45784f8b242be68ea1c9394a736",
"examples/userService/internal/service/user/updateUser.go": "9b0e3e4296c0e0df98956961a928612d9203c0fa09aa71bb0683e430e1133ff0",
"examples/userService/internal/servicecontext/context.go": "5527ca07049f5c5f5deffa65e5759494b2e0a77804b629b3ecb1705e7557ee25",
"examples/userService/internal/servicecontext/context_test.go": "04d008b6cf5b7894752bc5222abb8f4120f1ebe060034ac55ec9663c1f7bc613",
"examples/userService/internal/servicecontext/storage.go": "9402e1ee7a258b2db4b6dbf38fcb0674ad8907fb2a948d47dd8374f78bec444d",
"examples/userService/internal/settings/config.go": "721c3744a61a2d2807edcdfa190336901368966a0fb952561d0e00da5730cd3a",
"examples/userService/internal/types/typesGen.go": "3ab2ce9ffe467c3ea53ac3d1fec283ecebc87cef57fd592d08dca991fd964876",
"examples/userService/migrations/000001_users.down.sql": "64b47d3d2644745a2ba1197b73760e63fe0f2c1d12c193888c4417b3d5abb75e",
"examples/userService/migrations/000001_users.up.sql": "c6ea5cb92ee53fe20e43373d084ff360d49921b785602145e55380e4adbee1dc"
}
}

View File

@@ -0,0 +1,24 @@
# 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`,结果生成到 `performance/compatibilityRemote.json`,校验从全新 module cache 安装的 CLI 与消费者固定依赖、无本地 replace、重生成保护和运行时行为。该结果在 main 单独记录,发布标签保持不可变。
## 范围
本次不是完整 ePay 迁移验收;资金事务、原权限体系、业务响应细节仍需业务自己的回归。未重跑 Linux soak、Nginx/Cloudflare 代理或云存储账号实网验收。

View File

@@ -0,0 +1,11 @@
Command: GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=2 -count=1 . ./response ./config ./httpx ./openapi ./cmd/go-omni/...
Exit status: 0
ok git.yuntuo.hk/k2safe/go-omni 1.535s
ok git.yuntuo.hk/k2safe/go-omni/response 1.607s
ok git.yuntuo.hk/k2safe/go-omni/config 1.248s
ok git.yuntuo.hk/k2safe/go-omni/httpx 1.330s
ok git.yuntuo.hk/k2safe/go-omni/openapi 1.236s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 1.895s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 31.378s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 37.686s

View File

@@ -0,0 +1,123 @@
{
"passed": true,
"durationSeconds": 4.4,
"cases": [
{
"project": "starter",
"transport": "nethttp",
"checks": [
"readiness",
"servedOpenApiMatchesFile",
"greeting",
"requestId",
"gracefulShutdown",
"listenerReleased"
],
"passed": true
},
{
"project": "starter",
"transport": "native",
"checks": [
"readiness",
"servedOpenApiMatchesFile",
"greeting",
"requestId",
"gracefulShutdown",
"listenerReleased"
],
"passed": true
},
{
"project": "contract",
"transport": "nethttp",
"checks": [
"readiness",
"servedOpenApiMatchesFile",
"mixedSources",
"zeroAndFalse",
"pathRange",
"defaultAndQuery",
"requiredFields",
"unknownFields",
"duplicateScalar",
"trailingJson",
"rangeValidation",
"bodyLimit413",
"auth401",
"status201",
"status202",
"contentType415",
"middlewareComposition",
"noContent204",
"gracefulShutdown",
"listenerReleased",
"businessLogCorrelation"
],
"passed": true
},
{
"project": "contract",
"transport": "native",
"checks": [
"readiness",
"servedOpenApiMatchesFile",
"mixedSources",
"zeroAndFalse",
"pathRange",
"defaultAndQuery",
"requiredFields",
"unknownFields",
"duplicateScalar",
"trailingJson",
"rangeValidation",
"bodyLimit413",
"auth401",
"status201",
"status202",
"contentType415",
"middlewareComposition",
"noContent204",
"gracefulShutdown",
"listenerReleased",
"businessLogCorrelation"
],
"passed": true
}
],
"sourceSha256": {
"cmd/go-omni/api.go": "d313526cf9f67f6b1a36260748f2c21bf53c19a01c41f640386cad08bb5538b1",
"cmd/go-omni/apigen/apigen_test.go": "7cbe654e84c60f7b7f3a9631472bb66c1999ff4a9eaacaec224d26e78d08a38c",
"cmd/go-omni/apigen/compile.go": "cc801f744ed18ffd34a9cecc1b01d869ccb609448f7fcb705fbf5a7183637c25",
"cmd/go-omni/apigen/contractMetadata.go": "a7dcacde64d95eff32b0a93e7d87b60cdc7f9b4056587a77a1f20f5a9ef5231e",
"cmd/go-omni/apigen/declarations.go": "3f8073fb0b0bfa775f7712ed28e670f8dc98571636656726e18a6d592692bc81",
"cmd/go-omni/apigen/generate.go": "93c028e31bd59b50e57ab59826366a2adbc621cccda545bde065116a40d8d905",
"cmd/go-omni/apigen/metadata_test.go": "ab0c55e90e9b7a8bdaf260415e4c8564f6b2378de625fd9379a2b801cb4bc0e5",
"cmd/go-omni/apigen/parse.go": "73576fca857a94c21ffb3636ffb318297f10f6c41f67949ce37c0b9415bbb78d",
"cmd/go-omni/apigen/profile.go": "877939398384c44316e5e052d92c4a93fb3b74a6742df2414a0054913f3fca27",
"cmd/go-omni/apigen/profile_test.go": "5f3c091da241fe0f8b99ac19e9efd2c3177aea72e63a346f99fecc0f548aad1a",
"cmd/go-omni/apigen/projectEntry.go": "817936d391ec7dc76f7b5b9f7d134537947ea238d2cd281eb3df536511cb5486",
"cmd/go-omni/apigen/response.go": "f22f8d76663243cea4af5c0f043981fe577ad74155c7436f8a14f8cd31095cf2",
"cmd/go-omni/apigen/response_test.go": "fdd496b31bbfcebcabaf1b68f5e149a2a3b9b02e6d193b2c9260213301a63f4a",
"cmd/go-omni/apigen/templates.go": "36b4946acc636960bf7ca1d95ab364135432392829c12be523ef6b514e433f63",
"cmd/go-omni/apigen/write.go": "a995ffd412a98620dd9db5244433abb3a44a20ae7f09513e710ab6d4c52a78eb",
"cmd/go-omni/main.go": "060b064973f2316b6f01c7b9a716d57c3dbc8ad33a5d6153f6cfddaf649cd5fc",
"contract/README.md": "ff3b56398cf676df401d84d1169dfff19fc83ee074b07fb4c16fdf5af2e8fe17",
"contract/bind.go": "8a6be9046c9a489a4bed78bca1a017f53e89ca8209b4c0f971b032709f0a8228",
"contract/contract.go": "e496e7a57a6e97c482d25bb7ad6c9d07186328d97095f4bdca84965956bd9912",
"contract/contract_test.go": "0b651bb4305baed8b78ac764786af11e3538858783c6e117c7294fde50ec0873",
"contract/rules.go": "3a8df741b4c5074fe8589acaf5bb584b9b779afbe0412f4ef1b8315c222706b7",
"examples/contracts/README.md": "a7ebe6983c8d89d8a745f924f3d6fb3aba72d31dccd197112c18f50bd17456a9",
"examples/contracts/responses.api": "4018af67935963cdc9f847c5bc0c5106b0ad91d1f0de73ff893102f356909213",
"examples/contracts/shared.api": "cb0096bb1565916b13ae8d2a0bdad1e2ffe7410a417d25ac347e67c7b9d77dfc",
"examples/contracts/user.api": "59b98806a142b65f782e83a60f0723bc554833cec113f1c7fb20e09540870ae8",
"openapi/envelope.go": "298d891d8ed79321d854db87c601f7dd1c2c2f5955eb67844c5905186dd63976",
"openapi/openapi.go": "914a38458aa54da6130ea2df546a5724a7e237690a361d8dd920aec1155c6b5f",
"openapi/openapi_test.go": "c92f4a2f548f0304708ac157402fb8641273473e04c524226f2429c2b64b99ab",
"openapi/schemaJSON.go": "f400cbba429cd02dc4f5bc70810a99c5d5841fc2995e6380e5de7c0e1f79d3b0",
"openapi/schemaJSON_test.go": "e0e1c7c9380350e67d8e209ba4041e3e52fe2bd86af829ff770affa8814e9c28",
"scripts/verifyAPI.py": "8fb41860ccc4320b64e26dbb001a8bae588b95641332e84a34f2d7f00fa86c3e",
"transport/native/earlyReject_test.go": "58c7b67d7c2dbb8867dec6075b891acb2c1bbbf0ad886fa0cb061c7c5ba95b0e",
"transport/native/server.go": "3b2b33d2a4c0a25034f61c54797bd9e143d994b48e6a689bb3ebd2b8c08050ce"
}
}

View File

@@ -0,0 +1,40 @@
# v0.8.0-preview 公共响应模板验收
已发布 `v0.8.0-preview`,固定标签提交为 `7670b222acbad18d1fd19d54bbbac38d9cf5030b`。发布后只在 main 更新验收工具与记录,没有移动标签。
## 完成范围
- `.api` 具名公共体支持导入、分组引用、单接口覆盖、嵌套 JSON、独立 errorBody、常量与类型保持的占位符。业务返回类型放入 `${data}`。
- `int32` / `int64` / `string` 业务码、默认成功/失败码与提示、按逻辑码映射和 null/省略行为可配置;OpenAPI 同步结构、整数位宽和错误示例,旧 integer 写法保持兼容。
- `@response raw` 成功返回原始业务 JSON,错误继承组级模板。文本/XML及特殊状态回调使用 Go 编码器,由应用维护对应文档。
- 配置在创建与快照时复制,应用间独立;空模板往返序列化有效,非法占位符、冲突、越界和过深模板拒绝。
- 接口策略在全局中间件前生效,提前拒绝、panic、Context 克隆与错误边界使用正确格式;已提交响应不追加内容。
- 内置 App 的 404/405 使用应用策略;TLS/HTTP解析与连接级过载发生在进入应用之前,仍由 transport 处理。
## 发布前验证
- 最终代码 `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...`:40 个有测试的包全部通过,见 [封版测试](responseReleaseTests.txt)。
- `go vet ./...`、`go build ./...`、格式/差异检查和 userService `api generate --check` 通过。
- 临时生成项目执行真实路由和业务 Service,验证数字 401、raw JSON、路由覆盖、完整 int64 输出、OpenAPI 和重复生成保护;nethttp/native 真实 TCP 检查配置响应、callback panic 和超时。
- 本机隔离 MySQL 下,nethttp/native 共 34 项 HTTP/业务/生命周期检查通过;包含事务提交、错误/panic/取消回滚、初始化清理、迁移、日志与优雅退出,见 [本地业务报告](responsePoliciesWorkflowLocal.json)。
- 开发阶段额外完成 54 项 API 实际 HTTP 回归与类型专项检查,见 [API 回归](responsePoliciesAPI.json)、[类型专项](responseCodeTypesTests.txt)。这些历史报告分别记录当时的源码哈希。
- 开发阶段原有 S3 fixture 曾出现一次 10 秒 deadline,单包及后续全量复跑通过;本次封版全量也通过,未修改 S3 逻辑或放宽断言。
## 远程发布验证
两个验收分别使用全新的 module cache 安装 `v0.8.0-preview`,实际消费者都无本地 replace,模块校验和一致:
`h1:lB4rU4FPsoFr3IedKx/K9xvvsGBO5SO/qdjnjKJ2mvQ=`
1. [公共响应远程报告](responsePoliciesRemote.json):实际下载 CLI,解析 int32 示例,生成并运行公共模板消费者;完整 int64 最大值、数字鉴权错误、raw JSON、路由覆盖、业务文件保护、race 检查和固定版本依赖校验全部通过。
2. [完整业务远程报告](responsePoliciesWorkflowRemote.json):远程 CLI 新建 MySQL + local 项目并编译;用户示例在 nethttp/native 共 34 项 HTTP/业务/生命周期检查通过,事务/初始化清理测试同时通过,重生成保留 29 份开发者文件。
首次公共响应远程验收从框架源码目录启动 CLI,触发原有本地开发检测并加入 replace;验收正确拒绝了这种结果。main 的测试工具已改为从独立临时目录启动,随后上述纯远程校验通过。此修复只涉及验收测试的工作目录,不改变已发布 CLI 或框架;复现远程验收请使用 main 的验收脚本和测试。
报告中保留各自实际测试源码哈希,已逐项核对;通过 module version、checksum 和无 Replace 三项确认消费者使用的是固定发布模块。
## 资源与使用
只使用本机回环监听、临时目录和受限 MySQL 容器(1 CPU / 768 MiB),结束均清理,没有连接业务服务器。未执行 OSS/R2 云账号实网验收或网络压力长测。
[发布说明](../RELEASE.md)、[公共响应说明](../RESPONSE.md) 与 [完整 .api 示例](../examples/contracts/responses.api)。CLI 与框架依赖一起固定到 `v0.8.0-preview`;源码开发可使用 `--framework-dir`。

View File

@@ -0,0 +1,25 @@
{
"passed": true,
"frameworkVersion": "v0.8.0-preview",
"frameworkSum": "h1:lB4rU4FPsoFr3IedKx/K9xvvsGBO5SO/qdjnjKJ2mvQ=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 13.23,
"checks": [
"remoteCLIInstall",
"int32ContractValidation",
"templateProjectGeneration",
"int64MaxValueOnWire",
"numericAuthErrors",
"rawJSON",
"routeOverride",
"handwrittenFilesPreserved",
"generatedConsumerRaceTests",
"fixedRemoteModuleChecksum"
],
"sourceHashes": {
"scripts/verifyResponseRelease.py": "0cd47ba265ee6a9f545e1be321742aa5807dd5040522653011ff91653fc89ac1",
"cmd/go-omni/apigen/response_test.go": "bdaea21a94297607172ab173f20640aa5c27e2d7e417376e9fe695d4c02ba524",
"examples/contracts/responses.api": "d0fcc8d417a2c00bbea4e8d586379b5fd2443518817960f271804b3813e96eb1"
}
}

View File

@@ -0,0 +1,55 @@
Command: GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
Exit status: 0
ok git.yuntuo.hk/k2safe/go-omni 1.501s
ok git.yuntuo.hk/k2safe/go-omni/app 1.947s
ok git.yuntuo.hk/k2safe/go-omni/audit 1.275s
ok git.yuntuo.hk/k2safe/go-omni/auth 1.313s
ok git.yuntuo.hk/k2safe/go-omni/cache 1.430s
ok git.yuntuo.hk/k2safe/go-omni/cache/redis 1.452s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 2.065s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 30.496s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 40.140s
ok git.yuntuo.hk/k2safe/go-omni/config 1.310s
ok git.yuntuo.hk/k2safe/go-omni/contract 1.321s
ok git.yuntuo.hk/k2safe/go-omni/database 1.371s
ok git.yuntuo.hk/k2safe/go-omni/database/migrate 1.740s
? git.yuntuo.hk/k2safe/go-omni/errors [no test files]
? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files]
ok git.yuntuo.hk/k2safe/go-omni/examples/modules 1.332s
ok git.yuntuo.hk/k2safe/go-omni/examples/platform 1.415s
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 1.482s
? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files]
? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files]
ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository 1.346s
? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files]
ok git.yuntuo.hk/k2safe/go-omni/health 1.320s
ok git.yuntuo.hk/k2safe/go-omni/httpc 1.466s
ok git.yuntuo.hk/k2safe/go-omni/httpx 1.312s
ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck 1.357s
? git.yuntuo.hk/k2safe/go-omni/internal/storagefixture [no test files]
? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files]
ok git.yuntuo.hk/k2safe/go-omni/logx 1.573s
ok git.yuntuo.hk/k2safe/go-omni/metrics 1.305s
ok git.yuntuo.hk/k2safe/go-omni/middleware 1.418s
ok git.yuntuo.hk/k2safe/go-omni/middleware/redis 1.388s
ok git.yuntuo.hk/k2safe/go-omni/openapi 1.303s
ok git.yuntuo.hk/k2safe/go-omni/pagination 1.286s
? git.yuntuo.hk/k2safe/go-omni/queue [no test files]
ok git.yuntuo.hk/k2safe/go-omni/queue/redis 1.285s
ok git.yuntuo.hk/k2safe/go-omni/resilience 1.287s
ok git.yuntuo.hk/k2safe/go-omni/response 1.321s
ok git.yuntuo.hk/k2safe/go-omni/storage 1.331s
ok git.yuntuo.hk/k2safe/go-omni/storage/local 1.325s
ok git.yuntuo.hk/k2safe/go-omni/storage/oss 1.440s
ok git.yuntuo.hk/k2safe/go-omni/storage/s3 10.162s
? git.yuntuo.hk/k2safe/go-omni/storage/storagetest [no test files]
ok git.yuntuo.hk/k2safe/go-omni/task 1.364s
ok git.yuntuo.hk/k2safe/go-omni/telemetry 1.320s
ok git.yuntuo.hk/k2safe/go-omni/transport 1.314s
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 12.547s
? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files]
ok git.yuntuo.hk/k2safe/go-omni/transport/native 2.016s
ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 1.305s
? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files]
? git.yuntuo.hk/k2safe/go-omni/validate [no test files]

View File

@@ -0,0 +1,164 @@
{
"passed": true,
"frameworkVersion": "local",
"noLocalReplace": false,
"durationSeconds": 20.9,
"regenerationPreservedHandwrittenFiles": 29,
"groups": [
{
"transport": "nethttp",
"checks": [
"authBeforeService",
"readOnlyPermission403",
"emptyPageAndQueryDefaults",
"contractValidation",
"createReadNormalize",
"uniqueEmailConflict",
"updateAndPagination",
"createUpdateDeleteRollbackOnAuditFailure",
"storageUploadAndSignedDownload",
"signedDownloadTamperRejected",
"openAPIMatchesContract",
"declaredAuthAndBusinessErrorDocumentation",
"readinessOutageAndRecovery",
"persistenceAfterServiceRestart",
"deleteAndTransactionalAudit",
"gracefulStopAndListenerRelease",
"businessFileLogsAndSecretExclusion"
]
},
{
"transport": "native",
"checks": [
"authBeforeService",
"readOnlyPermission403",
"emptyPageAndQueryDefaults",
"contractValidation",
"createReadNormalize",
"uniqueEmailConflict",
"updateAndPagination",
"createUpdateDeleteRollbackOnAuditFailure",
"storageUploadAndSignedDownload",
"signedDownloadTamperRejected",
"openAPIMatchesContract",
"declaredAuthAndBusinessErrorDocumentation",
"readinessOutageAndRecovery",
"persistenceAfterServiceRestart",
"deleteAndTransactionalAudit",
"gracefulStopAndListenerRelease",
"businessFileLogsAndSecretExclusion"
]
}
],
"transactionTests": [
"commit",
"errorRollback",
"panicRollback",
"cancellationRollback"
],
"startupTests": [
"storageFailureClosesDatabase",
"idempotentClose",
"closedContextCannotReopen"
],
"migrationCLI": true,
"frameworkSum": null,
"sourceHashes": {
"cmd/go-omni/api.go": "d313526cf9f67f6b1a36260748f2c21bf53c19a01c41f640386cad08bb5538b1",
"cmd/go-omni/api_test.go": "172e4e2ba4984820ba729f88273b1ae133415081344e51c3654f297ef33216d4",
"cmd/go-omni/apigen/apigen_test.go": "7cbe654e84c60f7b7f3a9631472bb66c1999ff4a9eaacaec224d26e78d08a38c",
"cmd/go-omni/apigen/compile.go": "cc801f744ed18ffd34a9cecc1b01d869ccb609448f7fcb705fbf5a7183637c25",
"cmd/go-omni/apigen/contractMetadata.go": "a7dcacde64d95eff32b0a93e7d87b60cdc7f9b4056587a77a1f20f5a9ef5231e",
"cmd/go-omni/apigen/declarations.go": "3f8073fb0b0bfa775f7712ed28e670f8dc98571636656726e18a6d592692bc81",
"cmd/go-omni/apigen/generate.go": "93c028e31bd59b50e57ab59826366a2adbc621cccda545bde065116a40d8d905",
"cmd/go-omni/apigen/metadata_test.go": "ab0c55e90e9b7a8bdaf260415e4c8564f6b2378de625fd9379a2b801cb4bc0e5",
"cmd/go-omni/apigen/parse.go": "73576fca857a94c21ffb3636ffb318297f10f6c41f67949ce37c0b9415bbb78d",
"cmd/go-omni/apigen/profile.go": "877939398384c44316e5e052d92c4a93fb3b74a6742df2414a0054913f3fca27",
"cmd/go-omni/apigen/profile_test.go": "5f3c091da241fe0f8b99ac19e9efd2c3177aea72e63a346f99fecc0f548aad1a",
"cmd/go-omni/apigen/projectEntry.go": "817936d391ec7dc76f7b5b9f7d134537947ea238d2cd281eb3df536511cb5486",
"cmd/go-omni/apigen/response.go": "2448a09472ebe87c57c2dd48ccae1691e94f8cd029fcae72a85490a0405907e3",
"cmd/go-omni/apigen/response_test.go": "cb8daee5eacd5d158837283be41c39d4245bb2db7a4983800cdb809918b898ee",
"cmd/go-omni/apigen/templates.go": "f5ed976109c2a227329635644774c24cc8f34caede86dc0dabb7295845352803",
"cmd/go-omni/apigen/write.go": "a995ffd412a98620dd9db5244433abb3a44a20ae7f09513e710ab6d4c52a78eb",
"cmd/go-omni/main.go": "060b064973f2316b6f01c7b9a716d57c3dbc8ad33a5d6153f6cfddaf649cd5fc",
"cmd/go-omni/main_test.go": "b8040d2b19a1c86672fe1b1be7fa1bf7f885aece79ded6ffa0cc670887b7136f",
"cmd/go-omni/migrate.go": "f1ce89fbe419205eb82172da2b90e8888905d2b85d1a9db2a72b568bcf849a70",
"cmd/go-omni/migrate_test.go": "15d9a4aaebf9f899e523e908f1fa3c2282eb1e6c8cadbefae6b68f1ae4e00e6e",
"cmd/go-omni/scaffold/addModule.go": "8f43bd68c2b3820cf1d036c4f2c5f9d06d1c7c8e5114f5081597d905f8d8cdcc",
"cmd/go-omni/scaffold/addModule_test.go": "667c45312f628f0fb5b8206ddef7ccc02a75f33b72f73fd6a17b03da0cb62e91",
"cmd/go-omni/scaffold/apiInfrastructure.go": "f493364448254cae2d8e658bf5c69e96a3264c2bf3fa50fd48a5ebdb20edabd8",
"cmd/go-omni/scaffold/moduleProfiles_test.go": "ae9790e5d3549d32fc72739a10a31ea858e5a8f8189cf1f5c1840bf80b3f8ad6",
"cmd/go-omni/scaffold/moduleTemplates/README.md.tmpl": "9d3bfc46cac5618bcff7d8a6b2cc9f123e818aeb3dee9bcdfe0f91db584994bd",
"cmd/go-omni/scaffold/moduleTemplates/handler-handler.go.tmpl": "729cd0f6faba53d5d2c1492935e12ac2adcefde4490748e25c18be7ccb3f7139",
"cmd/go-omni/scaffold/moduleTemplates/model-model.go.tmpl": "a005611f6b455d4d8469e316bc26fa571043b2da1834db2b3dd14a96d66221d7",
"cmd/go-omni/scaffold/moduleTemplates/module.go.tmpl": "fb0be7fd571ba2c0edb01d70d68cdbbfe53561caab98346bf1ac7cd8df07205a",
"cmd/go-omni/scaffold/moduleTemplates/repository-repository.go.tmpl": "902da3d9f12395fb226178c4428ff547e1c2fce3261882dba98d1400d8c90747",
"cmd/go-omni/scaffold/moduleTemplates/service-service.go.tmpl": "49d26044a69a43e983aa5ba4b5b2399b26b77898b23e230751ad1372c2af3178",
"cmd/go-omni/scaffold/scaffold.go": "71b3091b83c3e45ac1981164e8c819c997ad96607d535f1492333370f4872548",
"cmd/go-omni/scaffold/scaffold_test.go": "58aab9251b01e394bbc90aeb538ff035dd32c73c0d4815132ac6cbc086527cda",
"cmd/go-omni/scaffold/templates/README.md.tmpl": "8ec215775fcafd437480022307fffd2ffe43685bc2b5d328b0c1d58402986e0a",
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "d2d67c3d235c4b1c965364f40f49c441d92764ad7ab8fd6848f56b1028e93b1b",
"cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl": "07c4f81109ece43fcf15bb807ada5ae6dc7e732babc7293269514a21cd389955",
"cmd/go-omni/scaffold/templates/databaseIntegration_test.go.tmpl": "81c4f7fbc919d74b738d3a7338248cbd83cfa0803b2058e8ee184e3ce51e7e9b",
"cmd/go-omni/scaffold/templates/go.mod.tmpl": "7d3bb15d2292e36939cc1905258d2800aeaf0e37d7d041b2ff8cfdb6fc84c57a",
"cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl": "7f8dbcd2f9b0129a90160d6059e47d19d4758274060a1a3e5160055d0592d34f",
"cmd/go-omni/scaffold/templates/internal-model-errors.go.tmpl": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
"cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
"cmd/go-omni/scaffold/templates/internal-repository-memory.go.tmpl": "148abc273c1fab3f28536706792762ae41e8687dfe9986c374372f162600feac",
"cmd/go-omni/scaffold/templates/internal-repository-mysql.go.tmpl": "34bd333e29211a0025ff6ebaea18c7f142f18f1cc451d692f9d360e8c8f6f635",
"cmd/go-omni/scaffold/templates/internal-repository-users.go.tmpl": "6d7dd80f34a9c215daeb4a86bfbe11e845e7ff7e9864c8af43e1c2ec3a8b28ec",
"cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl": "33fdee3a2c3d98e7ca6a323b5e888a73582f94e327b6c24a34c09bf180db7b3d",
"cmd/go-omni/scaffold/templates/internal-servicecontext-context.go.tmpl": "de70ecc4194db8a2da5d65297f452a40b453652cb814cd228ce03d465761c856",
"cmd/go-omni/scaffold/templates/modules-README.md.tmpl": "4b84fbc2310b856d86c6fcd9a47d97d2261019c74ea8e0cdbc41229086aa3527",
"cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl": "b8734080ae6d533a175bcad946ce2cb55d8bac1729918291753b9ca03064910f",
"cmd/go-omni/scaffold/templates/modules-filesHandler.go.tmpl": "426867fbef5b99a85394fa4457dd38b9a194adf4bdfb293384b2fbbc799091da",
"cmd/go-omni/scaffold/templates/modules-filesHandler_test.go.tmpl": "9571e39bd0a330739eb1fd7e636cd58961b41b44772b2b49162e41deda98547f",
"cmd/go-omni/scaffold/templates/modules-filesService.go.tmpl": "6460ee9a873af45ca6cec779bf53302acfce21499610425a4704c2f179eddb8f",
"cmd/go-omni/scaffold/templates/modules-gitignore.tmpl": "2579f2a63ff2962239b92dd841b0d0d26e783d85c9d0100011936c8bcbf5d0d3",
"cmd/go-omni/scaffold/templates/modules-main.go.tmpl": "cb8daddd4e12afd238540be7022187c74a548d9e409b3a7f1eaf84a9139218bf",
"cmd/go-omni/scaffold/templates/modules-settings.go.tmpl": "91084caaa1fd994dde714f52b242d23e41adb731383b12d74399247c87ac5597",
"cmd/go-omni/scaffold/templates/modules-settings_test.go.tmpl": "76e55f8954b7606d7146eb1e965065c521a2b81234a47742ed69a93aac300406",
"cmd/go-omni/scaffold/templates/modules-storage.go.tmpl": "a775acf2d2490288a8e5482bb0a8769be6b0a8c9cfcc9bb10b62aabbd05fa0c7",
"cmd/go-omni/scaffold/templates/modules-storage_test.go.tmpl": "dacca42d228bd9352948054eb2d071829a8a61d8c6fa24d95ff00fc39a74309e",
"cmd/go-omni/scaffold/templates/schema.sql.tmpl": "7c5108f18fc7d8ff75c97afd96fcaa9f8ed026c79c71c5612f55572d73354abb",
"cmd/go-omni/scaffold/version.go": "1d1bd1fc0be25d7ce479587d13a2ffc978e879eb36f1c23a6e58be831b776a2d",
"database/configOverlay_test.go": "01c9b44fded226494ca12f7d0f41a9ff417d8135d263a5833727c1c028b6b8e3",
"database/database.go": "3d8615e1bf58ecdae21558591e745c241e499ba6e85fc07acfa2344e04ce95db",
"database/database_test.go": "18eac1e4c6dbd6a09216168839ba3511aac8a38c2ddf6fdd5267f35435b09f67",
"database/lifecycle_test.go": "e141b52edfb9867184dedac0e79feed56a5b137acbe64280ad69647653bbea21",
"database/migrate/migrate.go": "fd6cd27d2dce5b35ffe80eb5442c47c657b93ffc6545bbcc1ee4651a70a4f80b",
"database/migrate/migrate_test.go": "d7ba91eed8cfd97e4ef0741d582f493dd411b65ad3192f05b167f75d9b9dcf90",
"examples/userService/api/user.api": "ed4d45cb796d9a017af9d341076553ca576918faef155aeea528718515582c95",
"examples/userService/cmd/server/main.go": "80ba4721f677947b7f2ece21d5f3e89ee0c4d2cc653412d83aef2b3a45bd3ccb",
"examples/userService/etc/config.yaml": "f50df3cddbc3c23f233405e7a9d8895b9d83d2a0607ccf001261fcd4b8978ee9",
"examples/userService/internal/apierrors/errorsGen.go": "20e94bbc90ae9f4330de4c25b9a550026c4c352502719a643ede3e8912a6566e",
"examples/userService/internal/handler/openapiGen.go": "e4130f5c7494f7db4830bfef203fa8154a9cd087c95954f64c29d74693ead433",
"examples/userService/internal/handler/responseGen.go": "d742efc2a3d1d168fae7849e905297bd440c3d6b3802b6150f494c07e6097f4f",
"examples/userService/internal/handler/routesGen.go": "0ff429a8aa7f8643e0e844f7c43a63a1f82cca5ce6bfe917f06edd860ebe923f",
"examples/userService/internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e",
"examples/userService/internal/middleware/auth.go": "fbfe8ff8b12f698218b9ef5f77fd85b612b3aed362a73dc26c3627f2d688c040",
"examples/userService/internal/middleware/auth_test.go": "63d6117dadf43b766e0aa485a63c4725a5109ab814c2b4a1fa7a59ee6d1c4cf6",
"examples/userService/internal/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00",
"examples/userService/internal/model/errors.go": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
"examples/userService/internal/model/user.go": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
"examples/userService/internal/repository/user/mysql.go": "924cae233a68e506e9b0b953529e0ccb13a0e71d24723309383c464e1852b833",
"examples/userService/internal/repository/user/repository.go": "2aca07788d8d59831b10ac665882ca8157b20f384115bb90742f235f8352a69d",
"examples/userService/internal/repository/user/store.go": "f776832e4f3b26fb889dad53b65742833ddb7f43b7dddc72915ad3fe47ec4d3e",
"examples/userService/internal/repository/user/transaction_test.go": "9af4cb3a03b71026b80aa5078ddda4e3c4f72a7ab14bed6c87603a4db2cc2a22",
"examples/userService/internal/service/user/createUser.go": "53d0ea7371e10a0bd3f32f57b9bff9b48b5f9a3315218f95a258fa45b81d9368",
"examples/userService/internal/service/user/deleteUser.go": "0cd0b038d5a11429de438311980ef19a8f6e28ea0d7cf5ebaaf78858daf3a124",
"examples/userService/internal/service/user/errors_test.go": "c0193bdc674cbe15c4871a91e288323a99193d19ba8fd874f9bf17c390917e5c",
"examples/userService/internal/service/user/exportUser.go": "dac2adb0c9ea43a4af15511ae488afaa6d9c9b351e68e9b8a54f4014d29de14d",
"examples/userService/internal/service/user/getUser.go": "a307fa716c70286a2a6c5d5c0a6c35ec6d258c3278e2a7fe86b74b324d7b7283",
"examples/userService/internal/service/user/listUsers.go": "f0bf8b9c3a4c4a699112e3493b7fe611e2dd4f6e8a4437106396c47d484f1383",
"examples/userService/internal/service/user/service.go": "cd6c203459fccbc030e88eccfa785d5db029e45784f8b242be68ea1c9394a736",
"examples/userService/internal/service/user/updateUser.go": "9b0e3e4296c0e0df98956961a928612d9203c0fa09aa71bb0683e430e1133ff0",
"examples/userService/internal/servicecontext/context.go": "5527ca07049f5c5f5deffa65e5759494b2e0a77804b629b3ecb1705e7557ee25",
"examples/userService/internal/servicecontext/context_test.go": "04d008b6cf5b7894752bc5222abb8f4120f1ebe060034ac55ec9663c1f7bc613",
"examples/userService/internal/servicecontext/storage.go": "9402e1ee7a258b2db4b6dbf38fcb0674ad8907fb2a948d47dd8374f78bec444d",
"examples/userService/internal/settings/config.go": "721c3744a61a2d2807edcdfa190336901368966a0fb952561d0e00da5730cd3a",
"examples/userService/internal/types/typesGen.go": "3ab2ce9ffe467c3ea53ac3d1fec283ecebc87cef57fd592d08dca991fd964876",
"examples/userService/migrations/000001_users.down.sql": "64b47d3d2644745a2ba1197b73760e63fe0f2c1d12c193888c4417b3d5abb75e",
"examples/userService/migrations/000001_users.up.sql": "c6ea5cb92ee53fe20e43373d084ff360d49921b785602145e55380e4adbee1dc"
}
}

View File

@@ -0,0 +1,164 @@
{
"passed": true,
"frameworkVersion": "v0.8.0-preview",
"noLocalReplace": true,
"durationSeconds": 28.28,
"regenerationPreservedHandwrittenFiles": 29,
"groups": [
{
"transport": "nethttp",
"checks": [
"authBeforeService",
"readOnlyPermission403",
"emptyPageAndQueryDefaults",
"contractValidation",
"createReadNormalize",
"uniqueEmailConflict",
"updateAndPagination",
"createUpdateDeleteRollbackOnAuditFailure",
"storageUploadAndSignedDownload",
"signedDownloadTamperRejected",
"openAPIMatchesContract",
"declaredAuthAndBusinessErrorDocumentation",
"readinessOutageAndRecovery",
"persistenceAfterServiceRestart",
"deleteAndTransactionalAudit",
"gracefulStopAndListenerRelease",
"businessFileLogsAndSecretExclusion"
]
},
{
"transport": "native",
"checks": [
"authBeforeService",
"readOnlyPermission403",
"emptyPageAndQueryDefaults",
"contractValidation",
"createReadNormalize",
"uniqueEmailConflict",
"updateAndPagination",
"createUpdateDeleteRollbackOnAuditFailure",
"storageUploadAndSignedDownload",
"signedDownloadTamperRejected",
"openAPIMatchesContract",
"declaredAuthAndBusinessErrorDocumentation",
"readinessOutageAndRecovery",
"persistenceAfterServiceRestart",
"deleteAndTransactionalAudit",
"gracefulStopAndListenerRelease",
"businessFileLogsAndSecretExclusion"
]
}
],
"transactionTests": [
"commit",
"errorRollback",
"panicRollback",
"cancellationRollback"
],
"startupTests": [
"storageFailureClosesDatabase",
"idempotentClose",
"closedContextCannotReopen"
],
"migrationCLI": true,
"frameworkSum": "h1:lB4rU4FPsoFr3IedKx/K9xvvsGBO5SO/qdjnjKJ2mvQ=",
"sourceHashes": {
"cmd/go-omni/api.go": "d313526cf9f67f6b1a36260748f2c21bf53c19a01c41f640386cad08bb5538b1",
"cmd/go-omni/api_test.go": "172e4e2ba4984820ba729f88273b1ae133415081344e51c3654f297ef33216d4",
"cmd/go-omni/apigen/apigen_test.go": "7cbe654e84c60f7b7f3a9631472bb66c1999ff4a9eaacaec224d26e78d08a38c",
"cmd/go-omni/apigen/compile.go": "cc801f744ed18ffd34a9cecc1b01d869ccb609448f7fcb705fbf5a7183637c25",
"cmd/go-omni/apigen/contractMetadata.go": "a7dcacde64d95eff32b0a93e7d87b60cdc7f9b4056587a77a1f20f5a9ef5231e",
"cmd/go-omni/apigen/declarations.go": "3f8073fb0b0bfa775f7712ed28e670f8dc98571636656726e18a6d592692bc81",
"cmd/go-omni/apigen/generate.go": "93c028e31bd59b50e57ab59826366a2adbc621cccda545bde065116a40d8d905",
"cmd/go-omni/apigen/metadata_test.go": "ab0c55e90e9b7a8bdaf260415e4c8564f6b2378de625fd9379a2b801cb4bc0e5",
"cmd/go-omni/apigen/parse.go": "73576fca857a94c21ffb3636ffb318297f10f6c41f67949ce37c0b9415bbb78d",
"cmd/go-omni/apigen/profile.go": "877939398384c44316e5e052d92c4a93fb3b74a6742df2414a0054913f3fca27",
"cmd/go-omni/apigen/profile_test.go": "5f3c091da241fe0f8b99ac19e9efd2c3177aea72e63a346f99fecc0f548aad1a",
"cmd/go-omni/apigen/projectEntry.go": "817936d391ec7dc76f7b5b9f7d134537947ea238d2cd281eb3df536511cb5486",
"cmd/go-omni/apigen/response.go": "2448a09472ebe87c57c2dd48ccae1691e94f8cd029fcae72a85490a0405907e3",
"cmd/go-omni/apigen/response_test.go": "bdaea21a94297607172ab173f20640aa5c27e2d7e417376e9fe695d4c02ba524",
"cmd/go-omni/apigen/templates.go": "f5ed976109c2a227329635644774c24cc8f34caede86dc0dabb7295845352803",
"cmd/go-omni/apigen/write.go": "a995ffd412a98620dd9db5244433abb3a44a20ae7f09513e710ab6d4c52a78eb",
"cmd/go-omni/main.go": "060b064973f2316b6f01c7b9a716d57c3dbc8ad33a5d6153f6cfddaf649cd5fc",
"cmd/go-omni/main_test.go": "b8040d2b19a1c86672fe1b1be7fa1bf7f885aece79ded6ffa0cc670887b7136f",
"cmd/go-omni/migrate.go": "f1ce89fbe419205eb82172da2b90e8888905d2b85d1a9db2a72b568bcf849a70",
"cmd/go-omni/migrate_test.go": "15d9a4aaebf9f899e523e908f1fa3c2282eb1e6c8cadbefae6b68f1ae4e00e6e",
"cmd/go-omni/scaffold/addModule.go": "8f43bd68c2b3820cf1d036c4f2c5f9d06d1c7c8e5114f5081597d905f8d8cdcc",
"cmd/go-omni/scaffold/addModule_test.go": "667c45312f628f0fb5b8206ddef7ccc02a75f33b72f73fd6a17b03da0cb62e91",
"cmd/go-omni/scaffold/apiInfrastructure.go": "f493364448254cae2d8e658bf5c69e96a3264c2bf3fa50fd48a5ebdb20edabd8",
"cmd/go-omni/scaffold/moduleProfiles_test.go": "ae9790e5d3549d32fc72739a10a31ea858e5a8f8189cf1f5c1840bf80b3f8ad6",
"cmd/go-omni/scaffold/moduleTemplates/README.md.tmpl": "9d3bfc46cac5618bcff7d8a6b2cc9f123e818aeb3dee9bcdfe0f91db584994bd",
"cmd/go-omni/scaffold/moduleTemplates/handler-handler.go.tmpl": "729cd0f6faba53d5d2c1492935e12ac2adcefde4490748e25c18be7ccb3f7139",
"cmd/go-omni/scaffold/moduleTemplates/model-model.go.tmpl": "a005611f6b455d4d8469e316bc26fa571043b2da1834db2b3dd14a96d66221d7",
"cmd/go-omni/scaffold/moduleTemplates/module.go.tmpl": "fb0be7fd571ba2c0edb01d70d68cdbbfe53561caab98346bf1ac7cd8df07205a",
"cmd/go-omni/scaffold/moduleTemplates/repository-repository.go.tmpl": "902da3d9f12395fb226178c4428ff547e1c2fce3261882dba98d1400d8c90747",
"cmd/go-omni/scaffold/moduleTemplates/service-service.go.tmpl": "49d26044a69a43e983aa5ba4b5b2399b26b77898b23e230751ad1372c2af3178",
"cmd/go-omni/scaffold/scaffold.go": "71b3091b83c3e45ac1981164e8c819c997ad96607d535f1492333370f4872548",
"cmd/go-omni/scaffold/scaffold_test.go": "58aab9251b01e394bbc90aeb538ff035dd32c73c0d4815132ac6cbc086527cda",
"cmd/go-omni/scaffold/templates/README.md.tmpl": "8ec215775fcafd437480022307fffd2ffe43685bc2b5d328b0c1d58402986e0a",
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "d2d67c3d235c4b1c965364f40f49c441d92764ad7ab8fd6848f56b1028e93b1b",
"cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl": "07c4f81109ece43fcf15bb807ada5ae6dc7e732babc7293269514a21cd389955",
"cmd/go-omni/scaffold/templates/databaseIntegration_test.go.tmpl": "81c4f7fbc919d74b738d3a7338248cbd83cfa0803b2058e8ee184e3ce51e7e9b",
"cmd/go-omni/scaffold/templates/go.mod.tmpl": "7d3bb15d2292e36939cc1905258d2800aeaf0e37d7d041b2ff8cfdb6fc84c57a",
"cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl": "7f8dbcd2f9b0129a90160d6059e47d19d4758274060a1a3e5160055d0592d34f",
"cmd/go-omni/scaffold/templates/internal-model-errors.go.tmpl": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
"cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
"cmd/go-omni/scaffold/templates/internal-repository-memory.go.tmpl": "148abc273c1fab3f28536706792762ae41e8687dfe9986c374372f162600feac",
"cmd/go-omni/scaffold/templates/internal-repository-mysql.go.tmpl": "34bd333e29211a0025ff6ebaea18c7f142f18f1cc451d692f9d360e8c8f6f635",
"cmd/go-omni/scaffold/templates/internal-repository-users.go.tmpl": "6d7dd80f34a9c215daeb4a86bfbe11e845e7ff7e9864c8af43e1c2ec3a8b28ec",
"cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl": "33fdee3a2c3d98e7ca6a323b5e888a73582f94e327b6c24a34c09bf180db7b3d",
"cmd/go-omni/scaffold/templates/internal-servicecontext-context.go.tmpl": "de70ecc4194db8a2da5d65297f452a40b453652cb814cd228ce03d465761c856",
"cmd/go-omni/scaffold/templates/modules-README.md.tmpl": "4b84fbc2310b856d86c6fcd9a47d97d2261019c74ea8e0cdbc41229086aa3527",
"cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl": "b8734080ae6d533a175bcad946ce2cb55d8bac1729918291753b9ca03064910f",
"cmd/go-omni/scaffold/templates/modules-filesHandler.go.tmpl": "426867fbef5b99a85394fa4457dd38b9a194adf4bdfb293384b2fbbc799091da",
"cmd/go-omni/scaffold/templates/modules-filesHandler_test.go.tmpl": "9571e39bd0a330739eb1fd7e636cd58961b41b44772b2b49162e41deda98547f",
"cmd/go-omni/scaffold/templates/modules-filesService.go.tmpl": "6460ee9a873af45ca6cec779bf53302acfce21499610425a4704c2f179eddb8f",
"cmd/go-omni/scaffold/templates/modules-gitignore.tmpl": "2579f2a63ff2962239b92dd841b0d0d26e783d85c9d0100011936c8bcbf5d0d3",
"cmd/go-omni/scaffold/templates/modules-main.go.tmpl": "cb8daddd4e12afd238540be7022187c74a548d9e409b3a7f1eaf84a9139218bf",
"cmd/go-omni/scaffold/templates/modules-settings.go.tmpl": "91084caaa1fd994dde714f52b242d23e41adb731383b12d74399247c87ac5597",
"cmd/go-omni/scaffold/templates/modules-settings_test.go.tmpl": "76e55f8954b7606d7146eb1e965065c521a2b81234a47742ed69a93aac300406",
"cmd/go-omni/scaffold/templates/modules-storage.go.tmpl": "a775acf2d2490288a8e5482bb0a8769be6b0a8c9cfcc9bb10b62aabbd05fa0c7",
"cmd/go-omni/scaffold/templates/modules-storage_test.go.tmpl": "dacca42d228bd9352948054eb2d071829a8a61d8c6fa24d95ff00fc39a74309e",
"cmd/go-omni/scaffold/templates/schema.sql.tmpl": "7c5108f18fc7d8ff75c97afd96fcaa9f8ed026c79c71c5612f55572d73354abb",
"cmd/go-omni/scaffold/version.go": "1d1bd1fc0be25d7ce479587d13a2ffc978e879eb36f1c23a6e58be831b776a2d",
"database/configOverlay_test.go": "01c9b44fded226494ca12f7d0f41a9ff417d8135d263a5833727c1c028b6b8e3",
"database/database.go": "3d8615e1bf58ecdae21558591e745c241e499ba6e85fc07acfa2344e04ce95db",
"database/database_test.go": "18eac1e4c6dbd6a09216168839ba3511aac8a38c2ddf6fdd5267f35435b09f67",
"database/lifecycle_test.go": "e141b52edfb9867184dedac0e79feed56a5b137acbe64280ad69647653bbea21",
"database/migrate/migrate.go": "fd6cd27d2dce5b35ffe80eb5442c47c657b93ffc6545bbcc1ee4651a70a4f80b",
"database/migrate/migrate_test.go": "d7ba91eed8cfd97e4ef0741d582f493dd411b65ad3192f05b167f75d9b9dcf90",
"examples/userService/api/user.api": "ed4d45cb796d9a017af9d341076553ca576918faef155aeea528718515582c95",
"examples/userService/cmd/server/main.go": "80ba4721f677947b7f2ece21d5f3e89ee0c4d2cc653412d83aef2b3a45bd3ccb",
"examples/userService/etc/config.yaml": "f50df3cddbc3c23f233405e7a9d8895b9d83d2a0607ccf001261fcd4b8978ee9",
"examples/userService/internal/apierrors/errorsGen.go": "20e94bbc90ae9f4330de4c25b9a550026c4c352502719a643ede3e8912a6566e",
"examples/userService/internal/handler/openapiGen.go": "e4130f5c7494f7db4830bfef203fa8154a9cd087c95954f64c29d74693ead433",
"examples/userService/internal/handler/responseGen.go": "d742efc2a3d1d168fae7849e905297bd440c3d6b3802b6150f494c07e6097f4f",
"examples/userService/internal/handler/routesGen.go": "0ff429a8aa7f8643e0e844f7c43a63a1f82cca5ce6bfe917f06edd860ebe923f",
"examples/userService/internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e",
"examples/userService/internal/middleware/auth.go": "fbfe8ff8b12f698218b9ef5f77fd85b612b3aed362a73dc26c3627f2d688c040",
"examples/userService/internal/middleware/auth_test.go": "63d6117dadf43b766e0aa485a63c4725a5109ab814c2b4a1fa7a59ee6d1c4cf6",
"examples/userService/internal/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00",
"examples/userService/internal/model/errors.go": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
"examples/userService/internal/model/user.go": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
"examples/userService/internal/repository/user/mysql.go": "924cae233a68e506e9b0b953529e0ccb13a0e71d24723309383c464e1852b833",
"examples/userService/internal/repository/user/repository.go": "2aca07788d8d59831b10ac665882ca8157b20f384115bb90742f235f8352a69d",
"examples/userService/internal/repository/user/store.go": "f776832e4f3b26fb889dad53b65742833ddb7f43b7dddc72915ad3fe47ec4d3e",
"examples/userService/internal/repository/user/transaction_test.go": "9af4cb3a03b71026b80aa5078ddda4e3c4f72a7ab14bed6c87603a4db2cc2a22",
"examples/userService/internal/service/user/createUser.go": "53d0ea7371e10a0bd3f32f57b9bff9b48b5f9a3315218f95a258fa45b81d9368",
"examples/userService/internal/service/user/deleteUser.go": "0cd0b038d5a11429de438311980ef19a8f6e28ea0d7cf5ebaaf78858daf3a124",
"examples/userService/internal/service/user/errors_test.go": "c0193bdc674cbe15c4871a91e288323a99193d19ba8fd874f9bf17c390917e5c",
"examples/userService/internal/service/user/exportUser.go": "dac2adb0c9ea43a4af15511ae488afaa6d9c9b351e68e9b8a54f4014d29de14d",
"examples/userService/internal/service/user/getUser.go": "a307fa716c70286a2a6c5d5c0a6c35ec6d258c3278e2a7fe86b74b324d7b7283",
"examples/userService/internal/service/user/listUsers.go": "f0bf8b9c3a4c4a699112e3493b7fe611e2dd4f6e8a4437106396c47d484f1383",
"examples/userService/internal/service/user/service.go": "cd6c203459fccbc030e88eccfa785d5db029e45784f8b242be68ea1c9394a736",
"examples/userService/internal/service/user/updateUser.go": "9b0e3e4296c0e0df98956961a928612d9203c0fa09aa71bb0683e430e1133ff0",
"examples/userService/internal/servicecontext/context.go": "5527ca07049f5c5f5deffa65e5759494b2e0a77804b629b3ecb1705e7557ee25",
"examples/userService/internal/servicecontext/context_test.go": "04d008b6cf5b7894752bc5222abb8f4120f1ebe060034ac55ec9663c1f7bc613",
"examples/userService/internal/servicecontext/storage.go": "9402e1ee7a258b2db4b6dbf38fcb0674ad8907fb2a948d47dd8374f78bec444d",
"examples/userService/internal/settings/config.go": "721c3744a61a2d2807edcdfa190336901368966a0fb952561d0e00da5730cd3a",
"examples/userService/internal/types/typesGen.go": "3ab2ce9ffe467c3ea53ac3d1fec283ecebc87cef57fd592d08dca991fd964876",
"examples/userService/migrations/000001_users.down.sql": "64b47d3d2644745a2ba1197b73760e63fe0f2c1d12c193888c4417b3d5abb75e",
"examples/userService/migrations/000001_users.up.sql": "c6ea5cb92ee53fe20e43373d084ff360d49921b785602145e55380e4adbee1dc"
}
}

View File

@@ -0,0 +1,55 @@
Command: GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
Exit status: 0
ok git.yuntuo.hk/k2safe/go-omni 2.660s
ok git.yuntuo.hk/k2safe/go-omni/app 2.005s
ok git.yuntuo.hk/k2safe/go-omni/audit 1.338s
ok git.yuntuo.hk/k2safe/go-omni/auth 1.346s
ok git.yuntuo.hk/k2safe/go-omni/cache 1.399s
ok git.yuntuo.hk/k2safe/go-omni/cache/redis 1.304s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 1.960s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 30.753s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 37.406s
ok git.yuntuo.hk/k2safe/go-omni/config 1.334s
ok git.yuntuo.hk/k2safe/go-omni/contract 1.341s
ok git.yuntuo.hk/k2safe/go-omni/database 1.298s
ok git.yuntuo.hk/k2safe/go-omni/database/migrate 1.336s
? git.yuntuo.hk/k2safe/go-omni/errors [no test files]
? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files]
ok git.yuntuo.hk/k2safe/go-omni/examples/modules 1.356s
ok git.yuntuo.hk/k2safe/go-omni/examples/platform 1.396s
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 1.423s
? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files]
? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files]
ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository 1.305s
? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files]
ok git.yuntuo.hk/k2safe/go-omni/health 1.338s
ok git.yuntuo.hk/k2safe/go-omni/httpc 1.388s
ok git.yuntuo.hk/k2safe/go-omni/httpx 1.330s
ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck 1.407s
? git.yuntuo.hk/k2safe/go-omni/internal/storagefixture [no test files]
? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files]
ok git.yuntuo.hk/k2safe/go-omni/logx 1.473s
ok git.yuntuo.hk/k2safe/go-omni/metrics 1.324s
ok git.yuntuo.hk/k2safe/go-omni/middleware 1.392s
ok git.yuntuo.hk/k2safe/go-omni/middleware/redis 1.319s
ok git.yuntuo.hk/k2safe/go-omni/openapi 1.333s
ok git.yuntuo.hk/k2safe/go-omni/pagination 1.296s
? git.yuntuo.hk/k2safe/go-omni/queue [no test files]
ok git.yuntuo.hk/k2safe/go-omni/queue/redis 1.713s
ok git.yuntuo.hk/k2safe/go-omni/resilience 1.530s
ok git.yuntuo.hk/k2safe/go-omni/response 1.324s
ok git.yuntuo.hk/k2safe/go-omni/storage 1.336s
ok git.yuntuo.hk/k2safe/go-omni/storage/local 1.311s
ok git.yuntuo.hk/k2safe/go-omni/storage/oss 1.466s
ok git.yuntuo.hk/k2safe/go-omni/storage/s3 10.741s
? git.yuntuo.hk/k2safe/go-omni/storage/storagetest [no test files]
ok git.yuntuo.hk/k2safe/go-omni/task 1.365s
ok git.yuntuo.hk/k2safe/go-omni/telemetry 1.328s
ok git.yuntuo.hk/k2safe/go-omni/transport 1.347s
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 12.542s
? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files]
ok git.yuntuo.hk/k2safe/go-omni/transport/native 2.099s
ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 1.331s
? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files]
? git.yuntuo.hk/k2safe/go-omni/validate [no test files]

123
response/codeTypes_test.go Normal file
View File

@@ -0,0 +1,123 @@
package response_test
import (
"bytes"
"encoding/json"
"math"
"os"
"path/filepath"
"strconv"
"testing"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestCodeTypeWidthsAndWireValues(t *testing.T) {
for _, tc := range []struct {
kind string
min, max int64
}{
{"int32", math.MinInt32, math.MaxInt32}, {"int64", math.MinInt64, math.MaxInt64},
{"integer", -9007199254740991, 9007199254740991},
} {
t.Run(tc.kind, func(t *testing.T) {
cfg := response.Config{CodeType: tc.kind, SuccessCode: tc.max, ErrorCode: tc.min, Codes: map[string]any{"ZERO": 0}}
formatter, err := response.New(cfg)
if err != nil {
t.Fatal(err)
}
for _, sample := range []struct {
result response.Result
want int64
}{
{response.Result{Status: 200, Code: "OK"}, tc.max},
{response.Result{Status: 500, Code: "INTERNAL"}, tc.min},
{response.Result{Status: 409, Code: "ZERO"}, 0},
} {
payload, err := formatter.Encode(sample.result)
if err != nil {
t.Fatal(err)
}
decoder := json.NewDecoder(bytes.NewReader(payload.Body))
decoder.UseNumber()
var body map[string]any
if err = decoder.Decode(&body); err != nil {
t.Fatal(err)
}
code, ok := body["code"].(json.Number)
if !ok || code.String() != strconv.FormatInt(sample.want, 10) {
t.Fatalf("precision/type lost: %s", payload.Body)
}
}
})
}
for _, tc := range []struct {
kind string
value any
}{
{"int32", int64(math.MaxInt32) + 1}, {"int32", int64(math.MinInt32) - 1},
{"int64", json.Number("9223372036854775808")}, {"int64", json.Number("-9223372036854775809")},
{"int64", uint64(math.MaxUint64)}, {"int64", float64(math.MaxInt64)},
{"int32", 1.5}, {"int64", json.Number("1.5")}, {"int64", "200"},
{"string", 200},
} {
for _, field := range []string{"successCode", "errorCode", "codes"} {
cfg := response.Config{CodeType: tc.kind, SuccessCode: 0, ErrorCode: 1}
if tc.kind == "string" {
cfg.SuccessCode = "OK"
cfg.ErrorCode = "ERROR"
}
switch field {
case "successCode":
cfg.SuccessCode = tc.value
case "errorCode":
cfg.ErrorCode = tc.value
case "codes":
cfg.Codes = map[string]any{"MAPPED": tc.value}
}
if _, err := response.New(cfg); err == nil {
t.Fatalf("%s %s accepted %#v", tc.kind, field, tc.value)
}
}
}
for _, kind := range []string{"int32", "int64"} {
if _, err := response.New(response.Config{CodeType: kind}); err == nil {
t.Fatalf("%s accepted missing numeric fallbacks", kind)
}
}
f := response.MustNew(response.Config{CodeType: "string", SuccessCode: "SUCCESS", ErrorCode: "ERROR", Codes: map[string]any{"DENIED": "403"}})
if got := f.Value(response.Result{Status: 403, Code: "DENIED"})["code"]; got != "403" {
t.Fatal(got)
}
}
func TestInt64CodesFromConfigurationKeepPrecision(t *testing.T) {
for ext, body := range map[string]string{
"json": `{"response":{"codeType":"int64","successCode":9223372036854775807,"errorCode":-9223372036854775808,"codes":{"EXACT":9007199254740993}}}`,
"yaml": "response:\n codeType: int64\n successCode: 9223372036854775807\n errorCode: -9223372036854775808\n codes:\n EXACT: 9007199254740993\n",
} {
path := filepath.Join(t.TempDir(), "config."+ext)
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.LoadFile(path, "RESPONSE_TEST_")
if err != nil {
t.Fatalf("%s: %v", ext, err)
}
f := response.MustNew(cfg.Response)
if f.Config().SuccessCode != int64(math.MaxInt64) || f.Config().ErrorCode != int64(math.MinInt64) || f.Config().Codes["EXACT"] != int64(9007199254740993) {
t.Fatalf("%s: precision lost: %#v", ext, f.Config())
}
}
var cfg response.Config
if err := json.Unmarshal([]byte(`{"codeType":"int64","successCode":9223372036854775807,"errorCode":0}`), &cfg); err != nil {
t.Fatal(err)
}
if response.MustNew(cfg).Config().SuccessCode != int64(math.MaxInt64) {
t.Fatal("json.Unmarshal lost precision")
}
if err := json.Unmarshal([]byte(`{"unknownOption":1}`), &cfg); err == nil {
t.Fatal("custom decoder bypassed unknown-field validation")
}
}

257
response/config.go Normal file
View File

@@ -0,0 +1,257 @@
package response
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"math"
"reflect"
"strings"
)
// Config defines a JSON response template or a convenient flat envelope.
// Body values can contain typed slots; ErrorBody optionally overrides errors.
// Without Body, empty field names retain defaults and "-" disables a field.
// This is application configuration, never a process-global response policy.
type Config struct {
Body map[string]any `json:"body"`
ErrorBody map[string]any `json:"errorBody"`
CodeField string `json:"codeField,omitempty"`
MessageField string `json:"messageField,omitempty"`
DataField string `json:"dataField,omitempty"`
RequestIDField string `json:"requestIdField,omitempty"`
CodeType string `json:"codeType,omitempty"` // string, int32, int64; integer is legacy
SuccessCode any `json:"successCode,omitempty"`
ErrorCode any `json:"errorCode,omitempty"`
SuccessMessage string `json:"successMessage,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
DataMode string `json:"dataMode,omitempty"`
Codes map[string]any `json:"codes,omitempty"`
Messages map[string]string `json:"messages,omitempty"`
}
type Formatter struct{ config Config }
// UnmarshalJSON preserves int64 values in interface fields, including mappings
// and body constants. DecodeFile and ordinary json.Unmarshal share this path.
func (c *Config) UnmarshalJSON(data []byte) error {
type plain Config
value := plain(*c)
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return err
}
*c = Config(value)
return nil
}
// New validates and detaches all configuration before serving requests.
func New(c Config) (*Formatter, error) {
if c.ErrorBody != nil && c.Body == nil {
return nil, errors.New("response: errorBody requires body")
}
if c.Body != nil {
if c.CodeField != "" || c.MessageField != "" || c.DataField != "" || c.RequestIDField != "" {
return nil, errors.New("response: body cannot be combined with field-name shortcuts")
}
for _, body := range []map[string]any{c.Body, c.ErrorBody} {
if err := validateTemplate(body, 0); err != nil {
return nil, err
}
}
}
c = c.Clone()
defaults := []struct {
field *string
value string
}{{&c.CodeField, "code"}, {&c.MessageField, "message"}, {&c.DataField, "data"}, {&c.RequestIDField, "requestId"}, {&c.CodeType, "string"}, {&c.DataMode, "omitNil"}}
for _, d := range defaults {
if *d.field == "" {
*d.field = d.value
}
}
if c.CodeType != "string" && c.CodeType != "int32" && c.CodeType != "int64" && c.CodeType != "integer" {
return nil, errors.New("response: codeType must be string, int32 or int64 (legacy integer is also accepted)")
}
if c.DataMode != "omitNil" && c.DataMode != "always" {
return nil, errors.New("response: dataMode must be omitNil or always")
}
seen := map[string]bool{}
for _, field := range []string{c.CodeField, c.MessageField, c.DataField, c.RequestIDField} {
if field == "-" {
continue
}
if strings.TrimSpace(field) != field || strings.ContainsAny(field, "\r\n\t") || seen[field] {
return nil, errors.New("response: field names must be distinct nonempty names without surrounding whitespace")
}
seen[field] = true
}
if c.CodeType != "string" && (c.SuccessCode == nil || c.ErrorCode == nil) {
return nil, errors.New("response: integer codes require successCode and errorCode")
}
for _, item := range []struct {
name string
value *any
}{{"successCode", &c.SuccessCode}, {"errorCode", &c.ErrorCode}} {
if *item.value == nil {
continue
}
value, err := normalizeCode(*item.value, c.CodeType)
if err != nil {
return nil, fmt.Errorf("response: %s: %w", item.name, err)
}
*item.value = value
}
for name, value := range c.Codes {
if strings.TrimSpace(name) == "" {
return nil, errors.New("response: code mapping requires a logical code")
}
v, err := normalizeCode(value, c.CodeType)
if err != nil {
return nil, fmt.Errorf("response: codes.%s: %w", name, err)
}
c.Codes[name] = v
}
return &Formatter{config: c}, nil
}
func MustNew(c Config) *Formatter {
f, err := New(c)
if err != nil {
panic(err)
}
return f
}
func (c Config) Validate() error { _, err := New(c); return err }
func (c Config) Clone() Config {
if c.Body != nil {
c.Body = cloneTemplate(c.Body, 0).(map[string]any)
}
if c.ErrorBody != nil {
c.ErrorBody = cloneTemplate(c.ErrorBody, 0).(map[string]any)
}
if c.Codes != nil {
values := make(map[string]any, len(c.Codes))
for k, v := range c.Codes {
values[k] = v
}
c.Codes = values
}
if c.Messages != nil {
values := make(map[string]string, len(c.Messages))
for k, v := range c.Messages {
values[k] = v
}
c.Messages = values
}
return c
}
func (f *Formatter) Config() Config {
c := f.config.Clone()
if c.Body != nil {
c.CodeField, c.MessageField, c.DataField, c.RequestIDField = "", "", "", ""
}
return c
}
// Value is also used by documentation generation, keeping code/message mapping
// and field omission identical to the actual response encoder.
func (f *Formatter) Value(r Result) map[string]any {
c := f.config
code, message := any(r.Code), r.Message
isError := r.Err != nil || r.Status >= 400
if isError {
if c.ErrorCode != nil {
code = c.ErrorCode
}
if c.ErrorMessage != "" {
message = c.ErrorMessage
}
} else {
if c.SuccessCode != nil {
code = c.SuccessCode
}
if c.SuccessMessage != "" {
message = c.SuccessMessage
}
}
if mapped, ok := c.Codes[r.Code]; ok {
code = mapped
}
if mapped, ok := c.Messages[r.Code]; ok {
message = mapped
}
body := c.Body
if isError && c.ErrorBody != nil {
body = c.ErrorBody
}
if body != nil {
return renderTemplate(body, map[string]any{"code": code, "message": message, "data": r.Data, "requestId": r.RequestID, "ok": !isError, "status": r.Status}, c.DataMode).(map[string]any)
}
value := map[string]any{}
if c.CodeField != "-" {
value[c.CodeField] = code
}
if c.MessageField != "-" {
value[c.MessageField] = message
}
if c.DataField != "-" && (r.Data != nil || c.DataMode == "always") {
value[c.DataField] = r.Data
}
if c.RequestIDField != "-" && r.RequestID != "" {
value[c.RequestIDField] = r.RequestID
}
return value
}
func (f *Formatter) Encode(r Result) (Payload, error) { return JSON(r.Status, f.Value(r)) }
func normalizeCode(value any, kind string) (any, error) {
if kind == "string" {
if s, ok := value.(string); ok && s != "" {
return s, nil
}
return nil, errors.New("expected a nonempty string")
}
if value == nil {
return nil, errors.New("expected an integer")
}
if n, ok := value.(json.Number); ok {
v, err := n.Int64()
if err != nil {
return nil, errors.New("expected a signed 64-bit integer")
}
value = v
}
v := reflect.ValueOf(value)
var n int64
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n = v.Int()
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
if v.Uint() > math.MaxInt64 {
return nil, errors.New("integer code exceeds int64 range")
}
n = int64(v.Uint())
case reflect.Float32, reflect.Float64:
x := v.Float()
if math.IsNaN(x) || math.IsInf(x, 0) || math.Trunc(x) != x || math.Abs(x) > 9007199254740991 {
return nil, errors.New("expected an exact integer code")
}
n = int64(x)
default:
return nil, errors.New("expected an integer")
}
if kind == "int32" {
if n < math.MinInt32 || n > math.MaxInt32 {
return nil, errors.New("integer code exceeds int32 range")
}
return int32(n), nil
}
if kind == "integer" && (n < -9007199254740991 || n > 9007199254740991) {
return nil, errors.New("integer code exceeds exact JSON range")
}
return n, nil
}

75
response/config_test.go Normal file
View File

@@ -0,0 +1,75 @@
package response_test
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestConfigurableWireContract(t *testing.T) {
cfg := response.Config{CodeType: "integer", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", RequestIDField: "-", DataMode: "always", SuccessMessage: "操作成功", Codes: map[string]any{"EMAIL_TAKEN": 409001}, Messages: map[string]string{"EMAIL_TAKEN": "邮箱已存在"}}
f, err := response.New(cfg)
if err != nil {
t.Fatal(err)
}
cfg.Codes["EMAIL_TAKEN"] = 123
cfg.Messages["EMAIL_TAKEN"] = "changed"
f.Config().Codes["EMAIL_TAKEN"] = 456
for _, tc := range []struct {
result response.Result
code int64
message string
data any
}{
{response.Result{Status: 200, Code: "OK", Message: "success"}, 0, "操作成功", nil},
{response.Result{Status: 409, Code: "EMAIL_TAKEN", Message: "email taken", RequestID: "request", Err: errors.New("private")}, 409001, "邮箱已存在", nil},
{response.Result{Status: 200, Code: "OK", Data: []string{}}, 0, "操作成功", []string{}},
} {
value := f.Value(tc.result)
if !reflect.DeepEqual(value, map[string]any{"code": tc.code, "msg": tc.message, "data": tc.data}) {
t.Fatalf("unexpected envelope: %#v", value)
}
}
for _, bad := range []response.Config{
{CodeType: "integer"}, {CodeType: "bool"}, {MessageField: "code"}, {DataMode: "sometimes"},
{CodeType: "integer", SuccessCode: 200, ErrorCode: 9.5},
{CodeType: "integer", SuccessCode: json.Number("9007199254740992"), ErrorCode: 9999},
{Codes: map[string]any{"ERROR": 42}},
} {
if _, err := response.New(bad); err == nil {
t.Fatalf("invalid config accepted: %+v", bad)
}
}
}
func TestResponseConfigLoadsFromJSONAndYAML(t *testing.T) {
for filename, body := range map[string]string{
"flat.json": `{"response":{"codeType":"integer","successCode":0,"errorCode":9999,"messageField":"msg","requestIdField":"-","dataMode":"always","codes":{"DENIED":403001}}}`,
"flat.yaml": "response:\n codeType: integer\n successCode: 0\n errorCode: 9999\n messageField: msg\n requestIdField: '-'\n dataMode: always\n codes:\n DENIED: 403001\n",
"body.json": `{"response":{"body":{"code":"${code}","msg":"${message}","data":"${data}"},"codeType":"integer","successCode":0,"errorCode":9999,"dataMode":"always","codes":{"DENIED":403001}}}`,
"body.yaml": "response:\n body:\n code: '${code}'\n msg: '${message}'\n data: '${data}'\n codeType: integer\n successCode: 0\n errorCode: 9999\n dataMode: always\n codes:\n DENIED: 403001\n",
} {
path := filepath.Join(t.TempDir(), filename)
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.LoadFile(path, "RESPONSE_TEST_")
if err != nil {
t.Fatal(err)
}
f, err := response.New(cfg.Response)
if err != nil {
t.Fatal(err)
}
value := f.Value(response.Result{Status: 403, Code: "DENIED"})
if value["code"] != int64(403001) || len(value) != 3 {
t.Fatalf("%s: %v", filename, value)
}
}
}

60
response/encoder.go Normal file
View File

@@ -0,0 +1,60 @@
package response
import (
"encoding/json"
"net/http"
)
// Result is the transport-independent input to an Encoder. Err is available for
// application error mapping; default encoders never expose it or its causes.
type Result struct {
Status int
Code, Message, RequestID string
Data any
Err error
}
// Payload is a complete response. Encoders must be safe for concurrent use.
// Status and headers are intentionally part of the contract: callback protocols
// may acknowledge an error using a different HTTP status or media type.
type Payload struct {
Status int
Header http.Header
Body []byte
}
type Encoder interface{ Encode(Result) (Payload, error) }
type EncoderFunc func(Result) (Payload, error)
func (f EncoderFunc) Encode(r Result) (Payload, error) { return f(r) }
func JSON(status int, value any) (Payload, error) {
data, err := json.Marshal(value)
if err != nil {
return Payload{}, err
}
return Payload{Status: status, Header: http.Header{"Content-Type": {"application/json; charset=utf-8"}}, Body: append(data, '\n')}, nil
}
func Text(status int, value string) Payload {
return Payload{Status: status, Header: http.Header{"Content-Type": {"text/plain; charset=utf-8"}}, Body: []byte(value)}
}
// Default is immutable; applications can use different encoders in one process.
var defaultEncoder = MustNew(Config{})
func Default() Encoder { return defaultEncoder }
// RawJSON leaves successful data unwrapped. Errors use the selected error
// policy; callback protocols with custom acknowledgments should use EncoderFunc.
func RawJSON(errors Encoder) Encoder {
if errors == nil {
errors = Default()
}
return EncoderFunc(func(r Result) (Payload, error) {
if r.Err != nil || r.Status >= 400 {
return errors.Encode(r)
}
return JSON(r.Status, r.Data)
})
}

View File

@@ -1,6 +1,8 @@
package response package response
// Response is the standard JSON envelope returned by GoOmni helpers. // Response describes the legacy default JSON envelope only.
// Deprecated: configure a response.Config or Encoder for application contracts.
// The runtime and code generator do not serialize through this fixed DTO.
type Response struct { type Response struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`

109
response/template.go Normal file
View File

@@ -0,0 +1,109 @@
package response
import (
"encoding/json"
"fmt"
"strings"
)
// Slot returns the name of a whole-value placeholder, or empty for constants.
// Slots preserve the value's JSON type; they are not string interpolation.
func Slot(value any) string {
s, ok := value.(string)
if ok && strings.HasPrefix(s, "${") && strings.HasSuffix(s, "}") {
return s[2 : len(s)-1]
}
return ""
}
func validateTemplate(value any, depth int) error {
if depth > 24 {
return fmt.Errorf("response: body nesting exceeds 24 levels")
}
switch v := value.(type) {
case map[string]any:
for key, item := range v {
if strings.TrimSpace(key) == "" {
return fmt.Errorf("response: body keys must not be empty")
}
if err := validateTemplate(item, depth+1); err != nil {
return err
}
}
case []any:
for _, item := range v {
if err := validateTemplate(item, depth+1); err != nil {
return err
}
}
case string:
if strings.HasPrefix(v, "${") {
switch Slot(v) {
case "code", "message", "data", "requestId", "ok", "status":
default:
return fmt.Errorf("response: unknown body slot %q", v)
}
}
case nil, bool, json.Number, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
if _, err := json.Marshal(v); err != nil {
return fmt.Errorf("response: invalid body constant: %w", err)
}
default:
return fmt.Errorf("response: body accepts JSON values, got %T", value)
}
return nil
}
func cloneTemplate(value any, depth int) any {
// Invalid cyclic Go configuration must still reach validation, not overflow.
if depth > 24 {
return value
}
switch v := value.(type) {
case map[string]any:
copy := make(map[string]any, len(v))
for key, item := range v {
copy[key] = cloneTemplate(item, depth+1)
}
return copy
case []any:
copy := make([]any, len(v))
for i, item := range v {
copy[i] = cloneTemplate(item, depth+1)
}
return copy
default:
return value
}
}
func renderTemplate(value any, slots map[string]any, dataMode string) any {
if name := Slot(value); name != "" {
return slots[name]
}
switch v := value.(type) {
case map[string]any:
result := make(map[string]any, len(v))
for key, item := range v {
slot := Slot(item)
if slot == "requestId" && slots[slot] == "" || slot == "data" && slots[slot] == nil && dataMode != "always" {
continue
}
result[key] = renderTemplate(item, slots, dataMode)
}
return result
case []any:
result := make([]any, len(v))
for i, item := range v {
result[i] = renderTemplate(item, slots, dataMode)
}
return result
case string:
if strings.HasPrefix(v, "$${") {
return v[1:]
}
return v
default:
return value
}
}

72
response/template_test.go Normal file
View File

@@ -0,0 +1,72 @@
package response_test
import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestResponseBodyTemplates(t *testing.T) {
config := response.Config{
Body: map[string]any{"success": "${ok}", "payload": "${data}", "meta": map[string]any{"trace": "${requestId}", "status": "${status}"}, "literal": "$${data}", "version": 1},
ErrorBody: map[string]any{"success": "${ok}", "error": map[string]any{"id": "${code}", "reason": "${message}"}},
CodeType: "integer", SuccessCode: 0, ErrorCode: 9999, Codes: map[string]any{"DENIED": 403001},
}
f := response.MustNew(config)
config.Body["meta"].(map[string]any)["status"] = "mutated"
f.Config().ErrorBody["error"].(map[string]any)["reason"] = "mutated"
good := f.Value(response.Result{Status: 201, Code: "CREATED", Data: []string{"a"}})
want := map[string]any{"success": true, "payload": []string{"a"}, "meta": map[string]any{"status": 201}, "literal": "${data}", "version": 1}
if !reflect.DeepEqual(good, want) {
t.Fatalf("%#v", good)
}
bad := f.Value(response.Result{Status: 403, Code: "DENIED", Message: "denied", Err: errors.New("PRIVATE")})
want = map[string]any{"success": false, "error": map[string]any{"id": int64(403001), "reason": "denied"}}
if !reflect.DeepEqual(bad, want) {
t.Fatalf("%#v", bad)
}
// Returning or serializing configuration preserves reusable templates, even {}.
for _, cfg := range []response.Config{f.Config(), {Body: map[string]any{}, ErrorBody: map[string]any{}}, {Body: map[string]any{"values": []any{"${data}", true, nil}}}} {
before := response.MustNew(cfg).Value(response.Result{Status: 200})
data, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
var copy response.Config
if err = json.Unmarshal(data, &copy); err != nil {
t.Fatal(err)
}
a, _ := json.Marshal(before)
b, _ := json.Marshal(response.MustNew(copy).Value(response.Result{Status: 200}))
if string(a) != string(b) {
t.Fatalf("round trip: %s / %s", a, b)
}
}
cyclic := map[string]any{}
cyclic["cycle"] = cyclic
for _, cfg := range []response.Config{
{Body: map[string]any{"value": "${missing}"}}, {Body: map[string]any{"value": "${data}-suffix"}},
{Body: cyclic}, {Body: map[string]any{"value": make(chan int)}}, {Body: map[string]any{}, DataField: "data"},
{ErrorBody: map[string]any{}},
} {
if _, err := response.New(cfg.Clone()); err == nil {
t.Fatal("invalid template accepted")
}
}
if strings.Contains(string(mustJSON(t, bad)), "PRIVATE") {
t.Fatal("private cause leaked")
}
}
func mustJSON(t *testing.T, value any) []byte {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return data
}

215
response_test.go Normal file
View File

@@ -0,0 +1,215 @@
package omni_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/middleware"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestResponsePoliciesAndCallbackBoundaries(t *testing.T) {
type acknowledgment struct {
Message string `xml:"message"`
}
cfg := response.Config{CodeType: "integer", SuccessCode: 200, ErrorCode: 9999, MessageField: "msg", RequestIDField: "-", DataMode: "always", SuccessMessage: "成功", ErrorMessage: "系统错误", Codes: map[string]any{"DENIED": 403001}}
app := omni.New(omni.WithResponse(cfg))
defer app.Close()
app.GET("/business", func(c *httpx.Context) error { return c.OK(nil) })
app.GET("/denied", func(c *httpx.Context) error { return omni.Forbidden("DENIED", "denied") })
app.GET("/plain", func(c *httpx.Context) error { return c.Text(200, "success") })
app.GET("/xml", func(c *httpx.Context) error { return c.XML(200, acknowledgment{Message: "accepted"}) })
app.GET("/raw", func(c *httpx.Context) error { return c.JSON(202, map[string]any{"accepted": true}) })
app.GET("/committed", func(c *httpx.Context) error {
if err := c.Text(200, "success"); err != nil {
return err
}
return errors.New("late error")
})
callback := response.EncoderFunc(func(r response.Result) (response.Payload, error) {
if r.Err != nil {
return response.Text(200, "fail"), nil
}
return response.Text(200, "success"), nil
})
app.POST("/callback", func(c *httpx.Context) error { return c.OK(map[string]any{"ignored": true}) }, httpx.WithResponse(callback))
app.POST("/callbackError", func(c *httpx.Context) error { return errors.New("private callback error") }, httpx.WithResponse(callback))
app.POST("/callbackPanic", func(c *httpx.Context) error { panic("private callback panic") }, httpx.WithResponse(callback))
broken := response.EncoderFunc(func(response.Result) (response.Payload, error) { panic("broken formatter") })
app.GET("/broken", func(c *httpx.Context) error { return c.OK(nil) }, httpx.WithResponse(broken))
for _, tc := range []struct {
method, path string
status int
body, contentType string
}{
{"GET", "/business", 200, `{"code":200,"data":null,"msg":"成功"}` + "\n", "application/json"},
{"GET", "/denied", 403, `{"code":403001,"data":null,"msg":"系统错误"}` + "\n", "application/json"},
{"GET", "/plain", 200, "success", "text/plain"},
{"GET", "/xml", 200, "<", "application/xml"},
{"GET", "/raw", 202, `{"accepted":true}` + "\n", "application/json"},
{"GET", "/committed", 200, "success", "text/plain"},
{"POST", "/callback", 200, "success", "text/plain"},
{"POST", "/callbackError", 200, "fail", "text/plain"},
{"POST", "/callbackPanic", 200, "fail", "text/plain"},
{"GET", "/broken", 500, "", ""},
{"GET", "/missing", 404, `{"code":9999,"data":null,"msg":"系统错误"}` + "\n", "application/json"},
{"PUT", "/business", 405, `{"code":9999,"data":null,"msg":"系统错误"}` + "\n", "application/json"},
} {
t.Run(tc.path, func(t *testing.T) {
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil))
body := w.Body.String()
if w.Code != tc.status || !strings.HasPrefix(w.Header().Get("Content-Type"), tc.contentType) {
t.Fatalf("%d %s %s", w.Code, w.Header().Get("Content-Type"), body)
}
if tc.path == "/xml" {
if !strings.Contains(body, "<message>accepted</message>") || strings.Contains(body, "code") {
t.Fatal(body)
}
} else if body != tc.body {
t.Fatalf("body %q, want %q", body, tc.body)
}
})
}
}
func TestResponsePolicyIsolationAndClones(t *testing.T) {
cfg := response.Config{CodeField: "result", MessageField: "notice", DataField: "payload", RequestIDField: "-", Codes: map[string]any{"OK": "SUCCESS"}}
app := omni.New(omni.WithResponse(cfg))
defer app.Close()
cfg.Codes["OK"] = "MUTATED"
app.Config().Response.Codes["OK"] = "CHANGED"
app.GET("/clone", func(c *httpx.Context) error {
buffer := httptest.NewRecorder()
child := c.Clone(context.Background(), buffer)
if err := child.OK("value"); err != nil {
return err
}
return c.WriteResponse(buffer.Code, buffer.Header(), buffer.Body.Bytes())
})
other := omni.New()
defer other.Close()
other.GET("/clone", func(c *httpx.Context) error { return c.OK("value") })
var wg sync.WaitGroup
for n := 0; n < 32; n++ {
wg.Add(1)
go func() {
defer wg.Done()
for _, item := range []struct {
handler http.Handler
key, value string
}{{app, "result", "SUCCESS"}, {other, "code", "OK"}} {
w := httptest.NewRecorder()
item.handler.ServeHTTP(w, httptest.NewRequest("GET", "/clone", nil))
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body[item.key] != item.value {
t.Errorf("policy leaked: %s %v", w.Body.String(), err)
}
}
}()
}
wg.Wait()
}
func TestResponsePolicyPrecedesGlobalMiddleware(t *testing.T) {
app := omni.New()
defer app.Close()
app.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
switch c.Header("Case") {
case "reject":
return omni.Forbidden("DENIED", "denied")
case "panic":
panic("global panic")
case "clone":
buffer := httptest.NewRecorder()
child := c.Clone(c.Context(), buffer)
if err := next(child); err != nil {
child.WriteError(err)
}
return c.WriteResponse(buffer.Code, buffer.Header(), buffer.Body.Bytes())
}
return next(c)
}
})
encoder := response.EncoderFunc(func(r response.Result) (response.Payload, error) {
if r.Err != nil {
return response.Text(200, "fail"), nil
}
return response.Text(200, "success"), nil
})
httpx.HandleWithResponse(app.Group("/hooks"), "POST", "/payment", encoder, func(c *httpx.Context) error { return c.OK(nil) })
for _, tc := range []struct{ mode, want string }{{"", "success"}, {"reject", "fail"}, {"panic", "fail"}, {"clone", "success"}} {
r := httptest.NewRequest("POST", "/hooks/payment", nil)
r.Header.Set("Case", tc.mode)
w := httptest.NewRecorder()
app.ServeHTTP(w, r)
if w.Code != 200 || w.Body.String() != tc.want {
t.Fatalf("%s: %d %s", tc.mode, w.Code, w.Body.String())
}
}
}
func TestResponsePolicyOnRealTransports(t *testing.T) {
for _, transport := range []string{"nethttp", "native"} {
t.Run(transport, func(t *testing.T) {
app := omni.New(omni.WithTransport(transport), omni.WithResponse(response.Config{CodeType: "integer", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", RequestIDField: "-", DataMode: "always"}))
app.GET("/ok", func(c *httpx.Context) error { return c.OK(nil) })
app.GET("/panic", func(c *httpx.Context) error { panic("fault") }, httpx.WithResponse(response.EncoderFunc(func(response.Result) (response.Payload, error) { return response.Text(200, "FAIL"), nil })))
app.GET("/timeout", func(c *httpx.Context) error { <-c.Context().Done(); return c.Context().Err() }, middleware.Timeout(10*time.Millisecond))
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 err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(5 * time.Second):
t.Error("server did not stop")
}
}()
client := &http.Client{Timeout: 3 * time.Second}
defer client.CloseIdleConnections()
for _, path := range []string{"/ok", "/panic", "/timeout"} {
r, err := client.Get("http://" + listener.Addr().String() + path)
if err != nil {
t.Fatal(err)
}
data, _ := io.ReadAll(r.Body)
r.Body.Close()
if path == "/panic" {
if string(data) != "FAIL" || r.StatusCode != 200 {
t.Fatal(string(data))
}
continue
}
var value map[string]any
if err = json.Unmarshal(data, &value); err != nil || len(value) != 3 || value["msg"] == nil {
t.Fatalf("%s: %s %v", path, data, err)
}
if path == "/timeout" && fmt.Sprint(value["code"]) != "9999" {
t.Fatal(string(data))
}
}
})
}
}

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Install the released CLI in a fresh cache, then test its generated consumer.
The fixture tests custom scalars, dynamic JSON, uploads, raw callback signatures,
downloads, body limits and regeneration protection. Only local temporary resources
are used; the framework and CLI are fetched from the specified remote version.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile
import time
ROOT = Path(__file__).resolve().parents[1]
MODULE = "git.yuntuo.hk/k2safe/go-omni"
def run(args, cwd, env, timeout=300):
result = subprocess.run([str(arg) for arg in args], cwd=cwd, env=env,
capture_output=True, text=True, timeout=timeout)
output = re.sub(r"https://[^/@\s]+:[^/@\s]+@", "https://", result.stdout + result.stderr)
if result.returncode:
raise RuntimeError(f"{args[0]} failed ({result.returncode}):\n{output}")
return output
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--report", type=Path, default=ROOT / "performance/compatibilityRemote.json")
args = parser.parse_args()
if not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version):
parser.error("a fixed release version is required")
env = {k: v for k, v in os.environ.items() if not k.startswith("GO_OMNI_")}
env.update(GOWORK="off", GOFLAGS="", GOPRIVATE="git.yuntuo.hk", GONOSUMDB="git.yuntuo.hk")
start = time.monotonic()
with tempfile.TemporaryDirectory(prefix="goOmniCompatibilityRelease-") as directory:
temp = Path(directory)
(temp / "bin").mkdir()
env.update(GOBIN=str(temp / "bin"), GOMODCACHE=str(temp / "modcache"))
print(f"Installing CLI {args.version} in an empty module cache", flush=True)
run(["go", "install", MODULE + "/cmd/go-omni@" + args.version], temp, env)
cli = temp / "bin/go-omni"
run([cli, "api", "validate", "--api", ROOT / "examples/contracts/compatibility.api"], temp, env)
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version)
print("Testing remote generation, scalar binding, uploads and callback signatures", flush=True)
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=180s",
"./cmd/go-omni/apigen", "-run", "^TestCompatibilityConsumer$"], ROOT, env)
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e]
assert passed == ["TestCompatibilityConsumer"], output
evidence = "".join(e.get("Output", "") for e in events)
checksum = re.search(r"checksum (h1:\S+); no local replace", evidence)
assert checksum, "consumer did not verify its remote dependency"
report = {
"passed": True, "frameworkVersion": args.version, "frameworkSum": checksum.group(1),
"freshModuleCache": True, "noLocalReplace": True,
"durationSeconds": round(time.monotonic() - start, 2),
"checks": ["remoteCLIInstall", "scalarContractValidation", "manualProjectGeneration",
"int64MaxValueOnWire", "dynamicJSON", "rawBodySignature", "routeBodyLimits",
"multipartTempCleanup", "unwrappedDownloads",
"handwrittenFilesPreserved", "generatedConsumerRaceTests", "fixedRemoteModuleChecksum"],
"sourceHashes": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in
("scripts/verifyCompatibilityRelease.py", "cmd/go-omni/apigen/compatibility_test.go",
"examples/contracts/compatibility.api",
"cmd/go-omni/apigen/testdata/compatibility/consumer_test.go",
"cmd/go-omni/apigen/testdata/compatibility/hooks.go",
"cmd/go-omni/apigen/testdata/compatibility/echo.go",
"cmd/go-omni/apigen/testdata/compatibility/download.go")},
}
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
print(f"PASS remote compatibility contract {args.version}; temporary files removed", flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Install the released CLI in a fresh cache, then test its generated consumer.
The fixture tests public templates, numeric auth errors, raw JSON, route override,
full int64 precision and regeneration protection. Only local temporary resources
are used; the framework and CLI are fetched from the specified remote version.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile
import time
ROOT = Path(__file__).resolve().parents[1]
MODULE = "git.yuntuo.hk/k2safe/go-omni"
def run(args, cwd, env, timeout=300):
result = subprocess.run([str(arg) for arg in args], cwd=cwd, env=env,
capture_output=True, text=True, timeout=timeout)
output = re.sub(r"https://[^/@\s]+:[^/@\s]+@", "https://", result.stdout + result.stderr)
if result.returncode:
raise RuntimeError(f"{args[0]} failed ({result.returncode}):\n{output}")
return output
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--report", type=Path, default=ROOT / "performance/responsePoliciesRemote.json")
args = parser.parse_args()
if not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version):
parser.error("a fixed release version is required")
env = {k: v for k, v in os.environ.items() if not k.startswith("GO_OMNI_")}
env.update(GOWORK="off", GOFLAGS="", GOPRIVATE="git.yuntuo.hk", GONOSUMDB="git.yuntuo.hk")
start = time.monotonic()
with tempfile.TemporaryDirectory(prefix="goOmniResponseRelease-") as directory:
temp = Path(directory)
(temp / "bin").mkdir()
env.update(GOBIN=str(temp / "bin"), GOMODCACHE=str(temp / "modcache"))
print(f"Installing CLI {args.version} in an empty module cache", flush=True)
run(["go", "install", MODULE + "/cmd/go-omni@" + args.version], temp, env)
cli = temp / "bin/go-omni"
run([cli, "api", "validate", "--api", ROOT / "examples/contracts/responses.api"], temp, env)
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version)
print("Testing remote generation, int64 precision and consumer runtime", flush=True)
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=180s",
"./cmd/go-omni/apigen", "-run", "^TestResponseTemplateConsumer$"], ROOT, env)
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e]
assert passed == ["TestResponseTemplateConsumer"], output
evidence = "".join(e.get("Output", "") for e in events)
checksum = re.search(r"checksum (h1:\S+); no local replace", evidence)
assert checksum, "consumer did not verify its remote dependency"
report = {
"passed": True, "frameworkVersion": args.version, "frameworkSum": checksum.group(1),
"freshModuleCache": True, "noLocalReplace": True,
"durationSeconds": round(time.monotonic() - start, 2),
"checks": ["remoteCLIInstall", "int32ContractValidation", "templateProjectGeneration",
"int64MaxValueOnWire", "numericAuthErrors", "rawJSON", "routeOverride",
"handwrittenFilesPreserved", "generatedConsumerRaceTests", "fixedRemoteModuleChecksum"],
"sourceHashes": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in
("scripts/verifyResponseRelease.py", "cmd/go-omni/apigen/response_test.go",
"examples/contracts/responses.api")},
}
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
print(f"PASS remote response contract {args.version}; temporary files removed", flush=True)
if __name__ == "__main__":
main()

View File

@@ -11,7 +11,10 @@ import (
"git.yuntuo.hk/k2safe/go-omni/transport/internal/routing" "git.yuntuo.hk/k2safe/go-omni/transport/internal/routing"
) )
type Router struct{ routes []route } type Router struct {
routes []route
notFound, methodNotAllowed http.Handler
}
type route struct { type route struct {
method string method string
@@ -22,6 +25,12 @@ type route struct {
func New() *Router { return &Router{} } func New() *Router { return &Router{} }
// SetFallbacks must be called before serving requests. Nil preserves the plain
// net/http fallback, useful when using this router without an Omni application.
func (r *Router) SetFallbacks(notFound, methodNotAllowed http.Handler) {
r.notFound, r.methodNotAllowed = notFound, methodNotAllowed
}
func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) { func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
var selected *route var selected *route
var selectedParams map[string]string var selectedParams map[string]string
@@ -48,9 +57,17 @@ func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
} }
sort.Strings(methods) sort.Strings(methods)
writer.Header().Set("Allow", strings.Join(methods, ", ")) writer.Header().Set("Allow", strings.Join(methods, ", "))
http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) if r.methodNotAllowed != nil {
r.methodNotAllowed.ServeHTTP(writer, request)
} else {
http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}
} else { } else {
http.NotFound(writer, request) if r.notFound != nil {
r.notFound.ServeHTTP(writer, request)
} else {
http.NotFound(writer, request)
}
} }
return return
} }