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:
10
API.md
10
API.md
@@ -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` 提供错误目录与路由关联。文档声明不拦截、改写或穷举运行时的所有错误。
|
||||||
|
|
||||||
|
|||||||
@@ -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 命名响应示例。
|
||||||
|
|||||||
@@ -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` 显式引用本地框架。
|
||||||
|
|||||||
84
RELEASE.md
84
RELEASE.md
@@ -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
140
RESPONSE.md
Normal 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、鉴权错误、路由覆盖与重复生成保护。
|
||||||
25
app/app.go
25
app/app.go
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
114
cmd/go-omni/apigen/response.go
Normal file
114
cmd/go-omni/apigen/response.go
Normal 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
|
||||||
|
}
|
||||||
357
cmd/go-omni/apigen/response_test.go
Normal file
357
cmd/go-omni/apigen/response_test.go
Normal 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())}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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...)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
58
examples/contracts/responses.api
Normal file
58
examples/contracts/responses.api
Normal 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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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),使用随机密钥,结束时删除。
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
30
examples/userService/internal/handler/responseGen.go
Normal file
30
examples/userService/internal/handler/responseGen.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
85
httpx/response.go
Normal 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)
|
||||||
|
}
|
||||||
4
omni.go
4
omni.go
@@ -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
42
openapi/codeTypes_test.go
Normal 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
114
openapi/envelope.go
Normal 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}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
11
performance/responseCodeTypesTests.txt
Normal file
11
performance/responseCodeTypesTests.txt
Normal 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
|
||||||
123
performance/responsePoliciesAPI.json
Normal file
123
performance/responsePoliciesAPI.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
performance/responsePoliciesAcceptance.md
Normal file
41
performance/responsePoliciesAcceptance.md
Normal 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 的模块、公共模板及完整业务流程。标签不随验收记录变动。
|
||||||
55
performance/responsePoliciesTests.txt
Normal file
55
performance/responsePoliciesTests.txt
Normal 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]
|
||||||
164
performance/responsePoliciesWorkflowLocal.json
Normal file
164
performance/responsePoliciesWorkflowLocal.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
performance/responseReleaseTests.txt
Normal file
55
performance/responseReleaseTests.txt
Normal 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
123
response/codeTypes_test.go
Normal 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
257
response/config.go
Normal 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
75
response/config_test.go
Normal 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
60
response/encoder.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
109
response/template.go
Normal 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
72
response/template_test.go
Normal 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, ©); 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
215
response_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
75
scripts/verifyResponseRelease.py
Normal file
75
scripts/verifyResponseRelease.py
Normal 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()
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user