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.
This commit is contained in:
2026-09-23 20:42:38 +08:00
parent bef7ea2641
commit 7670b222ac
47 changed files with 2689 additions and 120 deletions

10
API.md
View File

@@ -89,15 +89,15 @@ 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]`(支持开区间和单边界)。
@@ -105,7 +105,7 @@ service user-api {
暂不支持匿名嵌入、别名、递归类型、外部 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 +134,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` 提供错误目录与路由关联。文档声明不拦截、改写或穷举运行时的所有错误。

View File

@@ -1,5 +1,14 @@
# Changelog # Changelog
## 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 命名响应示例。

View File

@@ -5,7 +5,7 @@ GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认使用
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本: 远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
```sh ```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.7.0-preview.1 go get git.yuntuo.hk/k2safe/go-omni@v0.8.0-preview
``` ```
`v0.4.0` 移除了 Gin 和 Gin 专属的 `WithMode` 配置;`omni.New()` 直接使用标准库路由,已有业务 Handler / Service 无需修改。 `v0.4.0` 移除了 Gin 和 Gin 专属的 `WithMode` 配置;`omni.New()` 直接使用标准库路由,已有业务 Handler / Service 无需修改。
@@ -13,7 +13,7 @@ go get git.yuntuo.hk/k2safe/go-omni@v0.7.0-preview.1
创建新业务项目: 创建新业务项目:
```sh ```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview.1 go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.8.0-preview
go-omni new --module example.com/billing ./billing go-omni new --module example.com/billing ./billing
cd billing cd billing
go mod tidy go mod tidy
@@ -44,7 +44,7 @@ API 生成支持 `--database mysql --storage local --log-output file` 组合初
go-omni/ go-omni/
├── app/ # 应用启动和生命周期 ├── app/ # 应用启动和生命周期
├── httpx/ # 与网络框架无关的 HTTP 抽象 ├── httpx/ # 与网络框架无关的 HTTP 抽象
├── response/ # 统一响应 ├── response/ # 可配置公共体和响应编码器
├── errors/ # 统一错误 ├── errors/ # 统一错误
├── health/ # 存活和就绪检查 ├── health/ # 存活和就绪检查
├── middleware/ # 请求 ID 等基础中间件 ├── middleware/ # 请求 ID 等基础中间件
@@ -312,3 +312,5 @@ go run ./examples/reference
缓存、Redis 消息队列和进程内定时任务已作为可选模块提供。认证层提供 `Authenticator`、JWT/Session 适配器和 Policy 接口,登录流程与业务授权规则由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。 缓存、Redis 消息队列和进程内定时任务已作为可选模块提供。认证层提供 `Authenticator`、JWT/Session 适配器和 Policy 接口,登录流程与业务授权规则由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。
当前成熟度与后续验收标准见 [ROADMAP.md](ROADMAP.md),版本变更见 [CHANGELOG.md](CHANGELOG.md)。 当前成熟度与后续验收标准见 [ROADMAP.md](ROADMAP.md),版本变更见 [CHANGELOG.md](CHANGELOG.md)。
`v0.8.0-preview` 新增 [公共响应模板](RESPONSE.md):在 `.api` 中定义一次返回公共体,分组与接口引用,运行时和 OpenAPI 共用。支持数字错误码、嵌套结构、原始 JSON,以及独立的文本/XML回调编码器。源码开发可通过 `--framework-dir` 显式引用本地框架。

View File

@@ -1,71 +1,55 @@
# v0.7.0-preview.1 # v0.8.0-preview
这一版完善接口开发体验:业务错误和鉴权可在 .api 中声明,代码调用与 OpenAPI 同步;增加路由总览、文档导出和无写入的生成预览。 这一版让业务自行定义公共返回体:在 `.api` 中配置字段、结构、成功/失败提示和错误码类型,按分组复用或由单接口覆盖,运行时与 OpenAPI 共用定义。需要特殊协议的回调可独立控制状态、Header 和内容。
预览版用于固定版本试用;API 方言有明确范围,HTTP/2 仍是独立实验适配器。
## 安装与开始 本版继续作为预览版供固定版本接入;HTTP/2 仍是独立实验适配器。
## 安装与升级
```sh ```sh
GOPRIVATE=git.yuntuo.hk 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.8.0-preview
go-omni api new --module example.com/demo ./demo # 已有业务项目中执行:
cd demo GOPRIVATE=git.yuntuo.hk go get git.yuntuo.hk/k2safe/go-omni@v0.8.0-preview
GOPRIVATE=git.yuntuo.hk go mod tidy go-omni api generate
go run ./cmd/server go test ./...
``` ```
需要该 Git 仓库的读取权限时,通过自己的 Git 凭据管理器配置;不要将凭据写入 module path 或 go.mod。 新项目可以直接 `go-omni api new --module example.com/demo ./demo`,或用 `go-omni new --api service.api --module example.com/demo ./demo`。远程安装的 CLI 会固定生成项目的框架版本,无需本地 replace。需要仓库权限时使用自己的 Git 凭据管理器,不把凭据写入 module path 或 go.mod。
CLI 会将自身下载版本固定到生成项目的 go.mod。业务项目正常使用远程模块,不需要本地 replace。
使用已有定义,并一次选择基础模块: ## 本版功能
```sh - `response Name (...)` 定义公共 JSON 体,支持嵌套结构、独立 errorBody、常量及类型保持的占位符;通过 import 共享配置。
go-omni new --api user.api --module example.com/userService \ - `@server (response: Name)` 为整个 service 块选择模板,`@response Name` 单接口覆盖;`@response raw` 成功返回业务 JSON,错误保留组级格式。
--database mysql --storage local --log-output file ./userService - `codeType: int32 / int64 / string` 使用与业务字段一致的名称;默认码和映射都校验,int64 配置及生成代码保留整数精度。旧 `integer` 写法仍兼容。
``` - `successMessage` / `errorMessage` 配置默认提示,`codes` / `messages` 按逻辑错误码覆盖。业务只返回数据或错误,公共体由框架组装。
- `omni.WithResponse` 设置手写路由的应用默认格式,`WithResponseEncoder` 与 `HandleWithResponse` 支持特殊协议;Text / XML / Bytes / JSON / NoContent 可直接输出。
- 生成路由在全局中间件之前选定策略,提前鉴权拒绝、限流、panic 和 Context 克隆沿用正确格式。OpenAPI 同步模板结构与业务错误示例。
MySQL 与存储需要按生成 README 设置环境变量。任意契约生成的业务方法先返回 501, 详见 [公共响应体](RESPONSE.md)、[完整定义示例](examples/contracts/responses.api) 和 [更新记录](CHANGELOG.md)。
由业务方实现;可直接参考 [完整用户示例](examples/userService/README.md)。
## 主要变化 ## 升级行为
- `error (...)` + `@errors` 声明业务错误,生成 `apierrors.EmailTaken()` 等全新实例构造函数和错误码常量。 1. CLI 与框架依赖一起升级到本版。新生成路由使用新的响应策略入口,旧运行时不能编译。旧 `.api` 可继续使用;生成器不会覆盖已有 Service、Repository、middleware、配置和 main。
- `auth Name (...)` + `@auth` 同步 OpenAPI security 与实际路由中间件,声明本身不替代凭证验证。 2. 生成接口以 `.api` 为响应契约来源,即使没有声明模板也固定使用默认格式;应用 YAML 的 response 用于手写路由和应用兜底。需要变更生成接口的格式,请修改 `.api` 后重新生成。
- 多行 `@doc` 支持 description/tags/deprecated;一个状态可有多个业务错误示例。 3. `response.Response` DTO 保留为兼容类型,运行时不再依赖它固定结构。未配置的正常业务响应继续使用默认 code/message/data/requestId。
- 项目内直接 `api inspect`、`api export`、`api generate --dry-run`,无需重复指定入口文件。 4. 内置 App 的 404/405 现在经过应用响应策略和中间件,默认输出 JSON;原先依赖纯文本兜底的客户端需调整。直接使用 nethttp.Router 的兜底行为不变。
5. `int32` / `int64` 按有符号整数范围校验并输出 JSON 数值;消费端需支持相应精度。需要文本码时使用 string。旧 integer 继续保留原有 ±(2^53−1) 范围。
此前 v0.7.0-preview 已提供: 6. `.api` 的模板和 raw 当前描述 JSON。文本/XML回调用 Go 编码器并维护对应文档;TLS/HTTP解析错误、连接级过载等发生在进入应用前的错误仍按网络协议处理。
- `.api` 生成项目、绑定/校验、路由、业务骨架和 OpenAPI;支持分组、导入、命名中间件。
- 同一入口初始化 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 --report performance/apiDeveloperExperienceLocal.json python3 scripts/verifyAPIWorkflow.py --report performance/responsePoliciesWorkflowLocal.json
python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview.1 \ python3 scripts/verifyResponseRelease.py --version v0.8.0-preview \
--report performance/apiDeveloperExperienceRemote.json --report performance/responsePoliciesRemote.json
python3 scripts/verifyAPIWorkflow.py --version v0.8.0-preview \
--report performance/responsePoliciesWorkflowRemote.json
``` ```
最后一项在全新 module cache 安装远程 CLI,并验证无本地 replace 的项目生成、 远程验收分别从全新 module cache 安装 CLI,验证生成项目固定依赖且没有本地 replace,以及完整用户示例的真实 MySQL、nethttp/native、事务、日志和生命周期。数据库容器只使用本机临时资源(1 CPU / 768 MiB),结束清理,不连接业务服务器。
真实 MySQL、nethttp/native 启动与业务流程。容器只使用本机临时资源,结束时清理。
本轮验收见 [接口开发体验报告](performance/apiDeveloperExperienceAcceptance.md);此前业务闭环见 [API 业务验收](performance/apiWorkflowAcceptance.md)。
OSS/R2 云端仍需项目自己的账号做上线环境验收;当前云端适配器检查不等同于这些账号的实网测试。 验收记录见 [响应模板验收](performance/responsePoliciesAcceptance.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` 提供相同的早期挂载能力;未实现时辅助函数退化为普通路由中间件。
`.api` 当前声明的是 JSON 协议,`@response raw` 也是原始 JSON,不代表任意文本/XML协议。自定义文本/XML回调使用上述 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)
} }

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,7 @@ 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)
if err := wrapped(requestContext); err != nil { if err := wrapped(requestContext); err != nil {
a.writeError(requestContext, err) a.writeError(requestContext, err)
} }
@@ -37,6 +43,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 +108,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

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

@@ -245,11 +245,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,9 @@ 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)
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 +164,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 +178,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.8.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 {
@@ -129,13 +130,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, "httpx", "response.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 configurable responses; use %s or a current local replacement", minimumVersion)
} }
} }
if o.FrameworkDir != "" { if o.FrameworkDir != "" {
@@ -318,6 +319,16 @@ 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"
middlewareSet := map[string]bool{} middlewareSet := map[string]bool{}
@@ -381,7 +392,7 @@ 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) 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 { for _, m := range r.Middleware {
routes += fmt.Sprintf(",deps.Middlewares[%q]", m) routes += fmt.Sprintf(",deps.Middlewares[%q]", m)
} }
@@ -413,8 +424,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 {

View File

@@ -17,6 +17,7 @@ type Spec struct {
Title, Version, Service, Description string Title, Version, Service, Description string
Errors []ErrorDefinition Errors []ErrorDefinition
Auth []AuthDefinition Auth []AuthDefinition
Responses []ResponseDefinition
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
@@ -32,6 +33,8 @@ type Route struct {
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 +45,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 +148,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
} }
@@ -260,6 +266,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 +299,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 +336,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
@@ -401,7 +434,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"
} }
@@ -426,6 +459,13 @@ func (p *parser) parseService(server map[string]string) error {
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()

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,357 @@
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")
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

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

@@ -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" {
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.8.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"
@@ -33,6 +34,8 @@ type Config struct {
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

@@ -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,7 +12,8 @@
"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/routesGen.go": "0ff429a8aa7f8643e0e844f7c43a63a1f82cca5ce6bfe917f06edd860ebe923f",
"internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e", "internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e",
"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 --report performance/apiDeveloperExperienceLocal.json 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/apiDeveloperExperienceRemote.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"
) )
@@ -17,11 +18,11 @@ func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) erro
return fmt.Errorf("missing middleware Auth") return fmt.Errorf("missing middleware Auth")
} }
h0 := h0.New(s0.New(deps)) h0 := h0.New(s0.New(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

@@ -30,6 +30,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 +93,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.
@@ -121,6 +128,7 @@ func (c *Context) Raw() http.ResponseWriter { return c.writer }
// 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
} }
@@ -242,7 +250,8 @@ func (c *Context) NoContent() error {
} }
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,
@@ -265,13 +274,18 @@ func (c *Context) Responded() bool { return c.responded }
// serialized. Responses committed through Context methods are preserved; // serialized. Responses committed through Context methods are preserved;
// direct Raw writes are outside this response-tracking contract. // 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
} }
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)
}

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

@@ -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,41 @@
# 公共响应模板验收
版本 `v0.8.0-preview`。本报告记录实现验收与发布验证,旧版本标签保持不变。
## 实现范围
- `.api` 定义具名公共响应体,可跨文件导入、分组引用、接口覆盖;业务类型放入 `${data}`。支持嵌套 JSON、独立 errorBody、固定字段与类型保持的占位符。
- 数字/字符串错误码、默认码、按逻辑码映射、消息映射、null/省略行为可配置;OpenAPI 同步结构、类型和真实错误示例。
- `@response raw` 成功返回原始业务 JSON,错误继承组级模板。文本、XML及特殊状态的回调通过 Go 编码器实现,不冒充 `.api` 已支持任意媒体类型。
- 应用之间的配置独立;创建/快照深复制;空对象模板序列化后保持有效,非法占位符、配置冲突、循环和过深结构拒绝。
- 接口策略在全局中间件前设置;提前拒绝、panic、Context 克隆和错误边界使用正确策略。显式 JSON/Text/XML/Bytes/NoContent 绕过包装,已提交响应不追加内容。
- 内置路由 404/405 使用应用默认格式。网络层进入应用前的 TLS/HTTP协议错误仍由 transport 处理。
## 验证结果
1. `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...`:40 个有测试的包全部通过。
2. 首轮 `-p=2` 的原有 S3 fixture 测试遇到 10 秒 deadline;单包复跑通过,随后上述串行全量复跑通过,未修改 S3 实现或放宽断言。
3. 专项集成生成临时消费者项目,执行真实生成路由和业务 Service:具名公共体、数字 401、路由覆盖、raw JSON、错误文档,以及重复生成业务文件保护通过。
4. App 在 nethttp/native 的真实 TCP 请求中验证配置响应、callback panic 和超时;并发应用配置隔离、克隆、全局中间件提前拒绝及已提交响应保护通过。
5. 原有 API 实际 HTTP 回归 54 项通过:starter 两种 transport 各 6 项,contract 两种 transport 各 21 项;见 [回归报告](responsePoliciesAPI.json)。
6. `go vet ./...`、`go build ./...`、示例 `.api` validate/inspect、差异空白检查通过。既有用户示例重新生成及 `--check` 通过,28 份原有业务文件保持不变;go.mod 显式升级目标版本并继续使用本地 replace。
完整框架记录见 [race 测试](responsePoliciesTests.txt)。本轮仅使用本机临时目录和回环监听,没有连接业务服务器,没有执行远程云存储/数据库验收或压力长测。远程模块下载验收需发布后另行完成。
## 使用入口
[公共响应说明](../RESPONSE.md) 与 [可生成的 .api 示例](../examples/contracts/responses.api)。远程使用请安装同版本 CLI 和框架;源码开发可带 `--framework-dir`。
## 后续:类型名与业务字段统一
`codeType` 新增 `int32` / `int64`,保留 `string` 与旧 `integer`;int32/int64 使用各自有符号整数范围,默认码和映射都校验。OpenAPI 为 integer + 对应 format。配置 JSON/YAML、生成项目的 int64 数值保留完整精度。
本次增量运行根包、response、config、httpx、openapi、CLI/apigen/scaffold 共 8 个包的 race 测试及生成消费者集成,全部通过。覆盖边界/越界、小数拒绝、int64 最大值真实输出、配置精度、平铺/嵌套文档和旧 integer 兼容。vet、全模块 build、示例 validate 与差异检查通过;见 [类型测试记录](responseCodeTypesTests.txt)。上方全量及 HTTP 报告是此前公共响应体验收,此次未重复远程或数据库验收。
## v0.8.0-preview 封版前验证
- 最终代码全量 race 通过:40 个有测试的包;包括源码 CLI 的生成消费者、完整 int64 输出和模板配置回归,见 [封版测试记录](responseReleaseTests.txt)。
- `go vet ./...`、`go build ./...`、格式检查、差异检查和 userService `api generate --check` 通过。
- 本机隔离 MySQL 下,nethttp/native 共 34 项 HTTP、业务和生命周期检查通过;数据库事务提交/错误回滚/panic回滚/取消回滚、初始化清理和迁移通过。
- 重生成保留 29 份开发者文件,见 [封版本地业务报告](responsePoliciesWorkflowLocal.json)。临时容器已清理。
- 发布后将使用 `verifyResponseRelease.py` 和 `verifyAPIWorkflow.py --version v0.8.0-preview` 验证真实远程 CLI、无本地 replace 的模块、公共模板及完整业务流程。标签不随验收记录变动。

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,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,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,10 +57,18 @@ 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, ", "))
if r.methodNotAllowed != nil {
r.methodNotAllowed.ServeHTTP(writer, request)
} else {
http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}
} else {
if r.notFound != nil {
r.notFound.ServeHTTP(writer, request)
} else { } else {
http.NotFound(writer, request) http.NotFound(writer, request)
} }
}
return return
} }
ctx := httpx.WithRouteParams(request.Context(), selectedParams) ctx := httpx.WithRouteParams(request.Context(), selectedParams)