From 7670b222acbad18d1fd19d54bbbac38d9cf5030b Mon Sep 17 00:00:00 2001 From: k2safe Date: Wed, 23 Sep 2026 20:42:38 +0800 Subject: [PATCH] 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. --- API.md | 10 +- CHANGELOG.md | 9 + README.md | 8 +- RELEASE.md | 84 ++--- RESPONSE.md | 140 +++++++ app/app.go | 25 +- app/router.go | 23 ++ cmd/go-omni/api.go | 8 +- cmd/go-omni/apigen/compile.go | 15 +- cmd/go-omni/apigen/contractMetadata.go | 43 ++- cmd/go-omni/apigen/generate.go | 21 +- cmd/go-omni/apigen/parse.go | 46 ++- cmd/go-omni/apigen/response.go | 114 ++++++ cmd/go-omni/apigen/response_test.go | 357 ++++++++++++++++++ cmd/go-omni/apigen/templates.go | 9 +- cmd/go-omni/apigen/write.go | 3 + cmd/go-omni/scaffold/version.go | 2 +- config/config.go | 38 +- config/file.go | 1 + examples/contracts/responses.api | 58 +++ examples/userService/.goOmni/apiManifest.json | 3 +- examples/userService/README.md | 13 +- examples/userService/go.mod | 2 +- .../internal/handler/responseGen.go | 30 ++ .../userService/internal/handler/routesGen.go | 13 +- httpx/context.go | 22 +- httpx/response.go | 85 +++++ omni.go | 4 + openapi/codeTypes_test.go | 42 +++ openapi/envelope.go | 114 ++++++ openapi/openapi.go | 5 +- performance/responseCodeTypesTests.txt | 11 + performance/responsePoliciesAPI.json | 123 ++++++ performance/responsePoliciesAcceptance.md | 41 ++ performance/responsePoliciesTests.txt | 55 +++ .../responsePoliciesWorkflowLocal.json | 164 ++++++++ performance/responseReleaseTests.txt | 55 +++ response/codeTypes_test.go | 123 ++++++ response/config.go | 257 +++++++++++++ response/config_test.go | 75 ++++ response/encoder.go | 60 +++ response/response.go | 4 +- response/template.go | 109 ++++++ response/template_test.go | 72 ++++ response_test.go | 215 +++++++++++ scripts/verifyResponseRelease.py | 75 ++++ transport/nethttp/router.go | 23 +- 47 files changed, 2689 insertions(+), 120 deletions(-) create mode 100644 RESPONSE.md create mode 100644 cmd/go-omni/apigen/response.go create mode 100644 cmd/go-omni/apigen/response_test.go create mode 100644 examples/contracts/responses.api create mode 100644 examples/userService/internal/handler/responseGen.go create mode 100644 httpx/response.go create mode 100644 openapi/codeTypes_test.go create mode 100644 openapi/envelope.go create mode 100644 performance/responseCodeTypesTests.txt create mode 100644 performance/responsePoliciesAPI.json create mode 100644 performance/responsePoliciesAcceptance.md create mode 100644 performance/responsePoliciesTests.txt create mode 100644 performance/responsePoliciesWorkflowLocal.json create mode 100644 performance/responseReleaseTests.txt create mode 100644 response/codeTypes_test.go create mode 100644 response/config.go create mode 100644 response/config_test.go create mode 100644 response/encoder.go create mode 100644 response/template.go create mode 100644 response/template_test.go create mode 100644 response_test.go create mode 100644 scripts/verifyResponseRelease.py diff --git a/API.md b/API.md index 35a51c8..5bc77a9 100644 --- a/API.md +++ b/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 和描述性元数据。 - 单独的 `type X { ... }` 与 `type (...)`;基础标量、指针、嵌套命名类型、切片、字符串键 map。嵌套对象只使用 JSON 字段。 - 相对 `.api` 导入及 `import (...)`,重复导入去重;拒绝循环和越过入口目录的导入。单文件 1 MiB、总计 8 MiB、最多 64 个文件,限制导入/类型深度及 schema 展开规模。 -- 同一 service 名称的多个块;`@server` 的 group、prefix、middleware、auth、errors,group 可以写 `admin/user`。 -- 每个路由必须有 `@handler`;可选单行/多行 `@doc`、`@auth`、`@errors`;GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS。 +- 同一 service 名称的多个块;`@server` 的 group、prefix、middleware、auth、errors、response,group 可以写 `admin/user`。 +- 每个路由必须有 `@handler`;可选单行/多行 `@doc`、`@auth`、`@errors`、`@response`;GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS。 - 可省略请求或响应;有响应默认 200,无响应默认 204。Omni 扩展 `@status 201`、`@status 202`;204 不允许响应类型,HEAD 不允许响应体,GET/HEAD 不允许 JSON 请求体。 - 每个请求字段只使用一个来源:`json`、`path`、`form`、`header`。本版 `form` 明确表示 URL query,不解析 multipart 或表单 body。query 切片用重复键;header/path 使用标量。 - 来源标签中的 `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 生成。认证验证通过声明关联的命名中间件实现。未列出的语法会明确报错。 -业务错误、具名鉴权、多行文档及下述开发命令从 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/适配层调用这些函数。 内部 Cause 可能进入访问日志,敏感驱动报错应像 userService 示例一样先归类再转换。 -OpenAPI 对每个状态生成标准响应结构和命名 examples,同一 409 下多个错误不会相互覆盖;纯业务状态还列出 code enum。 +OpenAPI 对每个状态按选定的响应模板生成结构和命名 examples,同一 409 下多个错误不会相互覆盖;平铺模式的纯业务状态还列出 code enum。 默认框架错误状态与自动添加的鉴权 401 保留开放的 code 字段,容纳校验器/中间件实际错误。 `x-omni-errors` 提供错误目录与路由关联。文档声明不拦截、改写或穷举运行时的所有错误。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aed0c4..11e8cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 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 - `.api` 支持集中业务错误目录、组级与路由级错误声明,生成状态/错误码/提示一致的 `internal/apierrors` 函数,以及 OpenAPI 命名响应示例。 diff --git a/README.md b/README.md index 3a21a58..a113669 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认使用 远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本: ```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 无需修改。 @@ -13,7 +13,7 @@ go get git.yuntuo.hk/k2safe/go-omni@v0.7.0-preview.1 创建新业务项目: ```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 cd billing go mod tidy @@ -44,7 +44,7 @@ API 生成支持 `--database mysql --storage local --log-output file` 组合初 go-omni/ ├── app/ # 应用启动和生命周期 ├── httpx/ # 与网络框架无关的 HTTP 抽象 -├── response/ # 统一响应 +├── response/ # 可配置公共体和响应编码器 ├── errors/ # 统一错误 ├── health/ # 存活和就绪检查 ├── middleware/ # 请求 ID 等基础中间件 @@ -312,3 +312,5 @@ go run ./examples/reference 缓存、Redis 消息队列和进程内定时任务已作为可选模块提供。认证层提供 `Authenticator`、JWT/Session 适配器和 Policy 接口,登录流程与业务授权规则由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。 当前成熟度与后续验收标准见 [ROADMAP.md](ROADMAP.md),版本变更见 [CHANGELOG.md](CHANGELOG.md)。 + +`v0.8.0-preview` 新增 [公共响应模板](RESPONSE.md):在 `.api` 中定义一次返回公共体,分组与接口引用,运行时和 OpenAPI 共用。支持数字错误码、嵌套结构、原始 JSON,以及独立的文本/XML回调编码器。源码开发可通过 `--framework-dir` 显式引用本地框架。 diff --git a/RELEASE.md b/RELEASE.md index 119586c..4d317d9 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,71 +1,55 @@ -# v0.7.0-preview.1 +# v0.8.0-preview -这一版完善接口开发体验:业务错误和鉴权可在 .api 中声明,代码调用与 OpenAPI 同步;增加路由总览、文档导出和无写入的生成预览。 -预览版用于固定版本试用;API 方言有明确范围,HTTP/2 仍是独立实验适配器。 +这一版让业务自行定义公共返回体:在 `.api` 中配置字段、结构、成功/失败提示和错误码类型,按分组复用或由单接口覆盖,运行时与 OpenAPI 共用定义。需要特殊协议的回调可独立控制状态、Header 和内容。 -## 安装与开始 +本版继续作为预览版供固定版本接入;HTTP/2 仍是独立实验适配器。 + +## 安装与升级 ```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 mod tidy -go run ./cmd/server +# 已有业务项目中执行: +GOPRIVATE=git.yuntuo.hk go get git.yuntuo.hk/k2safe/go-omni@v0.8.0-preview +go-omni api generate +go test ./... ``` -需要该 Git 仓库的读取权限时,通过自己的 Git 凭据管理器配置;不要将凭据写入 module path 或 go.mod。 -CLI 会将自身下载版本固定到生成项目的 go.mod。业务项目正常使用远程模块,不需要本地 replace。 +新项目可以直接 `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。 -使用已有定义,并一次选择基础模块: +## 本版功能 -```sh -go-omni new --api user.api --module example.com/userService \ - --database mysql --storage local --log-output file ./userService -``` +- `response Name (...)` 定义公共 JSON 体,支持嵌套结构、独立 errorBody、常量及类型保持的占位符;通过 import 共享配置。 +- `@server (response: Name)` 为整个 service 块选择模板,`@response Name` 单接口覆盖;`@response raw` 成功返回业务 JSON,错误保留组级格式。 +- `codeType: int32 / int64 / string` 使用与业务字段一致的名称;默认码和映射都校验,int64 配置及生成代码保留整数精度。旧 `integer` 写法仍兼容。 +- `successMessage` / `errorMessage` 配置默认提示,`codes` / `messages` 按逻辑错误码覆盖。业务只返回数据或错误,公共体由框架组装。 +- `omni.WithResponse` 设置手写路由的应用默认格式,`WithResponseEncoder` 与 `HandleWithResponse` 支持特殊协议;Text / XML / Bytes / JSON / NoContent 可直接输出。 +- 生成路由在全局中间件之前选定策略,提前鉴权拒绝、限流、panic 和 Context 克隆沿用正确格式。OpenAPI 同步模板结构与业务错误示例。 -MySQL 与存储需要按生成 README 设置环境变量。任意契约生成的业务方法先返回 501, -由业务方实现;可直接参考 [完整用户示例](examples/userService/README.md)。 +详见 [公共响应体](RESPONSE.md)、[完整定义示例](examples/contracts/responses.api) 和 [更新记录](CHANGELOG.md)。 -## 主要变化 +## 升级行为 -- `error (...)` + `@errors` 声明业务错误,生成 `apierrors.EmailTaken()` 等全新实例构造函数和错误码常量。 -- `auth Name (...)` + `@auth` 同步 OpenAPI security 与实际路由中间件,声明本身不替代凭证验证。 -- 多行 `@doc` 支持 description/tags/deprecated;一个状态可有多个业务错误示例。 -- 项目内直接 `api inspect`、`api export`、`api generate --dry-run`,无需重复指定入口文件。 - -此前 v0.7.0-preview 已提供: - -- `.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 自动推导内存仓库或数据表。 +1. CLI 与框架依赖一起升级到本版。新生成路由使用新的响应策略入口,旧运行时不能编译。旧 `.api` 可继续使用;生成器不会覆盖已有 Service、Repository、middleware、配置和 main。 +2. 生成接口以 `.api` 为响应契约来源,即使没有声明模板也固定使用默认格式;应用 YAML 的 response 用于手写路由和应用兜底。需要变更生成接口的格式,请修改 `.api` 后重新生成。 +3. `response.Response` DTO 保留为兼容类型,运行时不再依赖它固定结构。未配置的正常业务响应继续使用默认 code/message/data/requestId。 +4. 内置 App 的 404/405 现在经过应用响应策略和中间件,默认输出 JSON;原先依赖纯文本兜底的客户端需调整。直接使用 nethttp.Router 的兜底行为不变。 +5. `int32` / `int64` 按有符号整数范围校验并输出 JSON 数值;消费端需支持相应精度。需要文本码时使用 string。旧 integer 继续保留原有 ±(2^53−1) 范围。 +6. `.api` 的模板和 raw 当前描述 JSON。文本/XML回调用 Go 编码器并维护对应文档;TLS/HTTP解析错误、连接级过载等发生在进入应用前的错误仍按网络协议处理。 ## 验证入口 ```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 build ./... -python3 scripts/verifyAPIWorkflow.py --report performance/apiDeveloperExperienceLocal.json -python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview.1 \ - --report performance/apiDeveloperExperienceRemote.json +python3 scripts/verifyAPIWorkflow.py --report performance/responsePoliciesWorkflowLocal.json +python3 scripts/verifyResponseRelease.py --version v0.8.0-preview \ + --report performance/responsePoliciesRemote.json +python3 scripts/verifyAPIWorkflow.py --version v0.8.0-preview \ + --report performance/responsePoliciesWorkflowRemote.json ``` -最后一项在全新 module cache 安装远程 CLI,并验证无本地 replace 的项目生成、 -真实 MySQL、nethttp/native 启动与业务流程。容器只使用本机临时资源,结束时清理。 -本轮验收见 [接口开发体验报告](performance/apiDeveloperExperienceAcceptance.md);此前业务闭环见 [API 业务验收](performance/apiWorkflowAcceptance.md)。 +远程验收分别从全新 module cache 安装 CLI,验证生成项目固定依赖且没有本地 replace,以及完整用户示例的真实 MySQL、nethttp/native、事务、日志和生命周期。数据库容器只使用本机临时资源(1 CPU / 768 MiB),结束清理,不连接业务服务器。 -OSS/R2 云端仍需项目自己的账号做上线环境验收;当前云端适配器检查不等同于这些账号的实网测试。 +验收记录见 [响应模板验收](performance/responsePoliciesAcceptance.md)。OSS/R2 云账号实网验收与长时间压力测试不属于本次发布范围。 diff --git a/RESPONSE.md b/RESPONSE.md new file mode 100644 index 0000000..89ff8b6 --- /dev/null +++ b/RESPONSE.md @@ -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、鉴权错误、路由覆盖与重复生成保护。 diff --git a/app/app.go b/app/app.go index 17e3468..0afa2bf 100644 --- a/app/app.go +++ b/app/app.go @@ -15,6 +15,7 @@ import ( apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/middleware" + "git.yuntuo.hk/k2safe/go-omni/response" netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp" ) @@ -28,6 +29,7 @@ type App struct { closeOnce sync.Once closeErr error clientIPResolver *httpx.ClientIPResolver + responseEncoder response.Encoder } // 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 { + 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.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...) 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.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. @@ -93,6 +113,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) { // Config returns the effective application configuration. func (a *App) Config() config.Config { settings := a.config + settings.Response = settings.Response.Clone() settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...) settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...) return settings @@ -235,5 +256,5 @@ func (a *App) writeError(ctx *httpx.Context, err error) { if err == nil || ctx.Responded() { return } - ctx.WriteError(apierrors.From(err)) + ctx.WriteError(err) } diff --git a/app/router.go b/app/router.go index e5b991c..2d0b1a0 100644 --- a/app/router.go +++ b/app/router.go @@ -4,6 +4,7 @@ import ( "net/http" "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) { @@ -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 { + return a.adaptResponse(a.responseEncoder, handler, middleware...) +} + +func (a *App) adaptResponse(encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) http.Handler { wrapped := handler all := make([]httpx.Middleware, 0, len(a.middlewares)+len(middleware)) 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)) } requestContext := httpx.NewContext(request, writer, httpx.RouteParams(request.Context())) + requestContext.SetResponseEncoder(encoder) if err := wrapped(requestContext); err != nil { 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) } +// 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) { 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) } +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) { g.route(http.MethodGet, path, handler, middleware) } diff --git a/cmd/go-omni/api.go b/cmd/go-omni/api.go index 4a67821..8f61b83 100644 --- a/cmd/go-omni/api.go +++ b/cmd/go-omni/api.go @@ -124,7 +124,7 @@ func runAPI(args []string) error { fmt.Println(string(compiled.Document)) case "inspect": 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{} for _, e := range spec.Errors { definitions[e.Name] = e @@ -139,7 +139,11 @@ func runAPI(args []string) error { e := definitions[name] 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() } diff --git a/cmd/go-omni/apigen/compile.go b/cmd/go-omni/apigen/compile.go index d8fb806..00be95d 100644 --- a/cmd/go-omni/apigen/compile.go +++ b/cmd/go-omni/apigen/compile.go @@ -245,11 +245,20 @@ func Compile(spec *Spec) (*Compiled, error) { if err != nil { return nil, err } - response = openapi.Envelope(s) - response.Required = append(response.Required, "data") + if r.ResponseFormat == "raw" { + 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} - 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} { op.Responses[code] = openapi.Response{Description: "Error", Schema: errorSchema} } diff --git a/cmd/go-omni/apigen/contractMetadata.go b/cmd/go-omni/apigen/contractMetadata.go index 30df08e..b9de4dc 100644 --- a/cmd/go-omni/apigen/contractMetadata.go +++ b/cmd/go-omni/apigen/contractMetadata.go @@ -1,11 +1,15 @@ package apigen import ( + "encoding/json" "fmt" "net/http" "regexp" "sort" "strings" + + "git.yuntuo.hk/k2safe/go-omni/openapi" + "git.yuntuo.hk/k2safe/go-omni/response" ) 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) { spec := *original spec.Routes = append([]Route(nil), original.Routes...) + if err := validateResponses(&spec); err != nil { + return nil, err + } definitions := map[string]ErrorDefinition{} codes, symbols := map[string]bool{}, map[string]bool{} for _, e := range spec.Errors { @@ -124,6 +131,8 @@ func decorateDocument(doc map[string]any, spec *Spec) { doc["x-omni-errors"] = spec.Errors } for _, r := range spec.Routes { + policy := response.MustNew(responseConfig(spec, r)) + cfg := policy.Config() parts := strings.Split(r.Path, "/") for i, part := range parts { 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) + if r.ResponseFormat != "" { + op["x-omni-response"] = r.ResponseFormat + } if len(r.Middleware) > 0 { op["x-omni-middleware"] = r.Middleware } @@ -152,7 +164,7 @@ func decorateDocument(doc map[string]any, spec *Spec) { } responses := op["responses"].(map[string]any) if r.Auth != "" && r.Auth != "none" { - responses["401"] = genericErrorResponse("Authentication required") + responses["401"] = genericErrorResponse("Authentication required", cfg) } grouped := map[int][]ErrorDefinition{} for _, name := range r.Errors { @@ -166,32 +178,43 @@ func decorateDocument(doc map[string]any, spec *Spec) { key := fmt.Sprint(status) response, exists := responses[key].(map[string]any) if !exists { - response = genericErrorResponse(http.StatusText(status)) + response = genericErrorResponse(http.StatusText(status), cfg) responses[key] = response } media := response["content"].(map[string]any)["application/json"].(map[string]any) examples := map[string]any{} codes := []any{} + seenCodes := map[string]bool{} description := []string{} sort.Slice(errs, func(i, j int) bool { return errs[i].Name < errs[j].Name }) 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"}} - codes = append(codes, e.Code) + value := policy.Value(responseResult(e)) + 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) } media["examples"] = examples - if !exists { - media["schema"].(map[string]any)["properties"].(map[string]any)["code"].(map[string]any)["enum"] = codes + if !exists && cfg.Body == nil && cfg.CodeField != "-" { + media["schema"].(map[string]any)["properties"].(map[string]any)[cfg.CodeField].(map[string]any)["enum"] = codes } 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 == "" { description = "Error" } - return map[string]any{"description": description, "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{ - "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"}}, - }}}} + schema, _ := openapi.EnvelopeWith(nil, cfg) + 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}}} } diff --git a/cmd/go-omni/apigen/generate.go b/cmd/go-omni/apigen/generate.go index 0894da7..db3eb81 100644 --- a/cmd/go-omni/apigen/generate.go +++ b/cmd/go-omni/apigen/generate.go @@ -1,6 +1,7 @@ package apigen import ( + "encoding/json" "errors" "fmt" "go/format" @@ -18,7 +19,7 @@ import ( ) 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" type Options struct { @@ -129,13 +130,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) { if !filepath.IsAbs(path) { path = filepath.Join(directory, path) } - if _, err := os.Stat(filepath.Join(path, "contract", "contract.go")); err == nil { + if _, err := os.Stat(filepath.Join(path, "httpx", "response.go")); err == nil { localCurrent = true } } } 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 != "" { @@ -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, "docs/openapi.json", string(c.Document)+"\n") put(generated, "internal/handler/openapiGen.go", generatedHeader+fmt.Sprintf("package handler\nimport (\"net/http\"; %q)\nconst OpenAPIDocument = %q\nfunc OpenAPI(c *httpx.Context) error { return c.WriteResponse(200,http.Header{\"Content-Type\": {\"application/json; charset=utf-8\"}},[]byte(OpenAPIDocument)) }\n", frameworkModule+"/httpx", string(c.Document)+"\n")) + policySource := fmt.Sprintf("package handler\nimport(\"encoding/json\";\"strings\";%q)\nvar responseTemplates=map[string]response.Encoder{\"\":response.Default(),\n", frameworkModule+"/response") + 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") 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{} @@ -381,7 +392,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map } else { text += fmt.Sprintf("if err:=h.service.%s(%s);err!=nil{return err};return c.NoContent()\n}\n", name, call) } - routes += fmt.Sprintf("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 { 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/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") registry := "package middleware\nimport " + strconv.Quote(frameworkModule+"/httpx") + "\nfunc Defaults()map[string]httpx.Middleware{return map[string]httpx.Middleware{\n" for _, m := range middlewareNames { diff --git a/cmd/go-omni/apigen/parse.go b/cmd/go-omni/apigen/parse.go index 9c3de0d..515097e 100644 --- a/cmd/go-omni/apigen/parse.go +++ b/cmd/go-omni/apigen/parse.go @@ -17,6 +17,7 @@ type Spec struct { Title, Version, Service, Description string Errors []ErrorDefinition Auth []AuthDefinition + Responses []ResponseDefinition Types []Type Routes []Route 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 Middleware []string Auth string + ResponseFormat string + ErrorResponseFormat string Errors []string Description string Tags []string @@ -42,6 +45,7 @@ type Route struct { type token struct { text, position string kind rune + space bool } type parser struct { tokens []token @@ -144,9 +148,11 @@ func lex(name, source string) ([]token, error) { } } var out []token + end := 0 for { 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 { break } @@ -260,6 +266,10 @@ func (p *parser) parse() error { if err := p.parseAuth(); err != nil { return err } + case "response": + if err := p.parseResponse(); err != nil { + return err + } case "type": p.next() if p.peek() == "(" { @@ -289,7 +299,7 @@ func (p *parser) parse() error { } server = 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") } } @@ -326,6 +336,29 @@ func (p *parser) metadata() (map[string]string, error) { if err != nil { 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 { for p.peek() != "\n" && p.peek() != ";" && p.peek() != ")" && p.tokens[p.at].kind != scanner.EOF { value += p.next().text @@ -401,7 +434,7 @@ func (p *parser) parseService(server map[string]string) error { return err } 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 == "" { route.Group = "api" } @@ -426,6 +459,13 @@ func (p *parser) parseService(server map[string]string) error { e = p.parseDoc(&route) case "auth": 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": var names []string names, e = p.names() diff --git a/cmd/go-omni/apigen/response.go b/cmd/go-omni/apigen/response.go new file mode 100644 index 0000000..9811619 --- /dev/null +++ b/cmd/go-omni/apigen/response.go @@ -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 +} diff --git a/cmd/go-omni/apigen/response_test.go b/cmd/go-omni/apigen/response_test.go new file mode 100644 index 0000000..df54f8f --- /dev/null +++ b/cmd/go-omni/apigen/response_test.go @@ -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())} + } +} +` diff --git a/cmd/go-omni/apigen/templates.go b/cmd/go-omni/apigen/templates.go index c292b86..15443c0 100644 --- a/cmd/go-omni/apigen/templates.go +++ b/cmd/go-omni/apigen/templates.go @@ -113,9 +113,12 @@ docs/openapi.json # generated OpenAPI 3.0.3 .goOmni/apiManifest.json # ownership and content hashes `+"```"+` -The API response describes data only. Omni adds code, message and requestId. -Do not define the envelope again in each response. Handlers reject nil successful -responses. Body DTOs use application/json and have a 1 MiB limit by default. +The API response type describes business data. Declare response Name (...) and +select it with @server(response: Name) or @response Name to share a public body; +@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. Fields require exactly one source tag: json, path, form (URL query only), header. diff --git a/cmd/go-omni/apigen/write.go b/cmd/go-omni/apigen/write.go index f41036e..767fbbc 100644 --- a/cmd/go-omni/apigen/write.go +++ b/cmd/go-omni/apigen/write.go @@ -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 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" { return true } diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go index a19c73b..fd1db8b 100644 --- a/cmd/go-omni/scaffold/version.go +++ b/cmd/go-omni/scaffold/version.go @@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" // Source builds can carry an unpublished VCS version (including +dirty). // Keep this fallback in sync with the next release; installed CLIs use their // downloaded module version, which also has a module checksum. -const defaultFrameworkVersion = "v0.7.0-preview.1" +const defaultFrameworkVersion = "v0.8.0-preview" // This target is not a claim that the release already exists. Source users // select a local framework checkout until the module templates are published. diff --git a/config/config.go b/config/config.go index 698775d..63eade8 100644 --- a/config/config.go +++ b/config/config.go @@ -6,6 +6,7 @@ import ( "git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/logx" + "git.yuntuo.hk/k2safe/go-omni/response" ) const defaultRequestIDHeader = "X-Request-ID" @@ -24,15 +25,17 @@ type TLSConfig struct { // Config controls the default runtime behavior of an App. type Config struct { - Address string `json:"address"` - Transport string `json:"transport"` - TLS TLSConfig `json:"tls"` - RequestIDHeader string `json:"requestIdHeader"` - EnableLogger bool `json:"enableLogger"` - SlowRequestThreshold time.Duration `json:"slowRequestThreshold"` - TrustedProxies []string `json:"trustedProxies"` - ShutdownTimeout time.Duration `json:"shutdownTimeout"` - Log logx.Config `json:"log"` + Address string `json:"address"` + Transport string `json:"transport"` + TLS TLSConfig `json:"tls"` + RequestIDHeader string `json:"requestIdHeader"` + EnableLogger bool `json:"enableLogger"` + SlowRequestThreshold time.Duration `json:"slowRequestThreshold"` + TrustedProxies []string `json:"trustedProxies"` + ShutdownTimeout time.Duration `json:"shutdownTimeout"` + Log logx.Config `json:"log"` + Response response.Config `json:"response"` + ResponseEncoder response.Encoder `json:"-"` } func Default() Config { @@ -47,6 +50,9 @@ func Default() Config { // Validate checks configuration values before an application starts. func (c Config) Validate() error { + if err := c.Response.Validate(); err != nil { + return err + } if c.SlowRequestThreshold < 0 { 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 // environment. Later options can still override individual fields. func WithConfig(settings Config) Option { + settings.Response = settings.Response.Clone() settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...) settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...) return func(cfg *Config) { *cfg = settings + cfg.Response = settings.Response.Clone() cfg.TrustedProxies = append([]string(nil), settings.TrustedProxies...) 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. func WithLogConfig(settings logx.Config) Option { settings.RedactKeys = append([]string(nil), settings.RedactKeys...) diff --git a/config/file.go b/config/file.go index 4b98e79..a130f23 100644 --- a/config/file.go +++ b/config/file.go @@ -181,6 +181,7 @@ func (r *Reloader) Snapshot() Config { r.mu.Lock() defer r.mu.Unlock() cfg := r.current + cfg.Response = r.current.Response.Clone() cfg.TrustedProxies = append([]string(nil), cfg.TrustedProxies...) cfg.Log.RedactKeys = append([]string(nil), cfg.Log.RedactKeys...) return cfg diff --git a/examples/contracts/responses.api b/examples/contracts/responses.api new file mode 100644 index 0000000..dce1161 --- /dev/null +++ b/examples/contracts/responses.api @@ -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) +} diff --git a/examples/userService/.goOmni/apiManifest.json b/examples/userService/.goOmni/apiManifest.json index ab8271a..d17197a 100644 --- a/examples/userService/.goOmni/apiManifest.json +++ b/examples/userService/.goOmni/apiManifest.json @@ -12,7 +12,8 @@ "docs/openapi.json": "05bce8951699350cc1ebca5a626c39349395dd09e395b41560c129e530583204", "internal/apierrors/errorsGen.go": "20e94bbc90ae9f4330de4c25b9a550026c4c352502719a643ede3e8912a6566e", "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/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00", "internal/types/typesGen.go": "3ab2ce9ffe467c3ea53ac3d1fec283ecebc87cef57fd592d08dca991fd964876" diff --git a/examples/userService/README.md b/examples/userService/README.md index 64250cd..0b98a30 100644 --- a/examples/userService/README.md +++ b/examples/userService/README.md @@ -18,8 +18,9 @@ go-omni migrate --dir migrations --apply go run ./cmd/server ``` -源码示例的 go.mod 使用 `replace ... => ../..`。复制到独立仓库时,删除这条 replace, -保留已发布的固定依赖版本,再执行 go mod tidy。远程版本验证脚本会自动检查没有本地替换。 +源码示例的 go.mod 使用 `replace ... => ../..`。生成代码使用 `v0.8.0-preview` 响应策略入口。 +复制到独立仓库时删除 replace,保留同版本固定依赖,再执行 go mod tidy;CLI 也固定到同一版本。 +远程版本验证脚本会检查没有本地替换。 `GO_OMNI_TRANSPORT=native` 可切换为 native,业务代码无需调整。 配置是默认值 → etc/config.yaml → 环境变量。日志默认写入 var/log/server.log 并轮转, @@ -70,10 +71,10 @@ Gen.go 和 docs/openapi.json 由生成器维护;Service、Repository、迁移 在框架仓库运行: ```sh -python3 scripts/verifyAPIWorkflow.py --report performance/apiDeveloperExperienceLocal.json -# 已发布版本:全新 module cache 安装 CLI,无本地 replace 消费框架。 -python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview.1 \ - --report performance/apiDeveloperExperienceRemote.json +python3 scripts/verifyAPIWorkflow.py --report performance/responsePoliciesWorkflowLocal.json +# 使用本版远程 CLI 和模块验收,无本地替换。 +python3 scripts/verifyAPIWorkflow.py --version v0.8.0-preview \ + --report performance/responsePoliciesWorkflowRemote.json ``` 验收创建只绑定 localhost 的临时 MySQL 容器(1 CPU / 768 MiB),使用随机密钥,结束时删除。 diff --git a/examples/userService/go.mod b/examples/userService/go.mod index 0baea0b..b9f37c6 100644 --- a/examples/userService/go.mod +++ b/examples/userService/go.mod @@ -3,7 +3,7 @@ module example.com/userService go 1.22.0 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 ) diff --git a/examples/userService/internal/handler/responseGen.go b/examples/userService/internal/handler/responseGen.go new file mode 100644 index 0000000..892c154 --- /dev/null +++ b/examples/userService/internal/handler/responseGen.go @@ -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 +} diff --git a/examples/userService/internal/handler/routesGen.go b/examples/userService/internal/handler/routesGen.go index 6fbf326..66c018d 100644 --- a/examples/userService/internal/handler/routesGen.go +++ b/examples/userService/internal/handler/routesGen.go @@ -6,6 +6,7 @@ import ( s0 "example.com/userService/internal/service/user" "example.com/userService/internal/servicecontext" "fmt" + "git.yuntuo.hk/k2safe/go-omni/httpx" "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") } h0 := h0.New(s0.New(deps)) - router.Handle("POST", "/api/v1/users", h0.CreateUser, deps.Middlewares["Auth"]) - router.Handle("GET", "/api/v1/users/:id", h0.GetUser, deps.Middlewares["Auth"]) - router.Handle("GET", "/api/v1/users", h0.ListUsers, deps.Middlewares["Auth"]) - router.Handle("PUT", "/api/v1/users/:id", h0.UpdateUser, deps.Middlewares["Auth"]) - router.Handle("DELETE", "/api/v1/users/:id", 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", responseEncoder("", false), h0.CreateUser, deps.Middlewares["Auth"]) + httpx.HandleWithResponse(router, "GET", "/api/v1/users/:id", responseEncoder("", false), h0.GetUser, deps.Middlewares["Auth"]) + httpx.HandleWithResponse(router, "GET", "/api/v1/users", responseEncoder("", false), h0.ListUsers, deps.Middlewares["Auth"]) + httpx.HandleWithResponse(router, "PUT", "/api/v1/users/:id", responseEncoder("", false), h0.UpdateUser, deps.Middlewares["Auth"]) + httpx.HandleWithResponse(router, "DELETE", "/api/v1/users/:id", responseEncoder("", false), h0.DeleteUser, deps.Middlewares["Auth"]) + httpx.HandleWithResponse(router, "POST", "/api/v1/users/:id/export", responseEncoder("", false), h0.ExportUser, deps.Middlewares["Auth"]) return nil } diff --git a/httpx/context.go b/httpx/context.go index 612298e..c6f66a1 100644 --- a/httpx/context.go +++ b/httpx/context.go @@ -30,6 +30,12 @@ type Router interface { 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. type RouterGroup interface { Handle(method, path string, handler http.Handler) @@ -87,6 +93,7 @@ type Context struct { values map[string]any responded bool status int + encoder response.Encoder } // 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. func (c *Context) Clone(ctx context.Context, writer http.ResponseWriter) *Context { clone := NewContext(c.request.Clone(ctx), writer, c.params) + clone.encoder = c.encoder for key, value := range c.values { 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 { - return c.JSON(status, response.Response{ + return c.writeResult(response.Result{ + Status: status, Code: code, Message: message, Data: data, @@ -265,13 +274,18 @@ func (c *Context) Responded() bool { return c.responded } // serialized. Responses committed through Context methods are preserved; // direct Raw writes are outside this response-tracking contract. func (c *Context) WriteError(err error) { - if c.responded { + if c.responded || err == nil { return } apiErr := apierrors.From(err) - _ = c.JSON(apiErr.Status, response.Response{ + if writeErr := c.writeResult(response.Result{ + Status: apiErr.Status, Code: apiErr.Code, Message: apiErr.Message, RequestID: c.RequestID(), - }) + Err: err, + }); writeErr != nil && !c.responded { + // An invalid custom encoder must not recurse or leak a different envelope. + _ = c.WriteResponse(http.StatusInternalServerError, nil, nil) + } } diff --git a/httpx/response.go b/httpx/response.go new file mode 100644 index 0000000..84c7d7d --- /dev/null +++ b/httpx/response.go @@ -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) +} diff --git a/omni.go b/omni.go index 85744b7..2865f83 100644 --- a/omni.go +++ b/omni.go @@ -48,6 +48,10 @@ func WithRequestIDHeader(header string) Option { return config.WithRequestIDHead 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 { return config.WithSlowRequestThreshold(threshold) } diff --git a/openapi/codeTypes_test.go b/openapi/codeTypes_test.go new file mode 100644 index 0000000..94c6f1b --- /dev/null +++ b/openapi/codeTypes_test.go @@ -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) + } + } + } + } +} diff --git a/openapi/envelope.go b/openapi/envelope.go new file mode 100644 index 0000000..304db1d --- /dev/null +++ b/openapi/envelope.go @@ -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} +} diff --git a/openapi/openapi.go b/openapi/openapi.go index 858fc4a..03ac68d 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/response" "net/http" "reflect" "regexp" @@ -36,6 +37,7 @@ type Schema struct { Enum []any `json:"enum,omitempty"` Default any `json:"default,omitempty"` Not *Schema `json:"not,omitempty"` + AnyOf []*Schema `json:"anyOf,omitempty"` } type Parameter struct { Name string `json:"name"` @@ -181,7 +183,8 @@ func (a *API) Handler() httpx.Handler { } } 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) { return schema(reflect.TypeOf((*T)(nil)).Elem(), map[reflect.Type]bool{}, 0) diff --git a/performance/responseCodeTypesTests.txt b/performance/responseCodeTypesTests.txt new file mode 100644 index 0000000..5006add --- /dev/null +++ b/performance/responseCodeTypesTests.txt @@ -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 diff --git a/performance/responsePoliciesAPI.json b/performance/responsePoliciesAPI.json new file mode 100644 index 0000000..ca7ca7a --- /dev/null +++ b/performance/responsePoliciesAPI.json @@ -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" + } +} diff --git a/performance/responsePoliciesAcceptance.md b/performance/responsePoliciesAcceptance.md new file mode 100644 index 0000000..6b18dbf --- /dev/null +++ b/performance/responsePoliciesAcceptance.md @@ -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 的模块、公共模板及完整业务流程。标签不随验收记录变动。 diff --git a/performance/responsePoliciesTests.txt b/performance/responsePoliciesTests.txt new file mode 100644 index 0000000..9404ef1 --- /dev/null +++ b/performance/responsePoliciesTests.txt @@ -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] diff --git a/performance/responsePoliciesWorkflowLocal.json b/performance/responsePoliciesWorkflowLocal.json new file mode 100644 index 0000000..7cb073d --- /dev/null +++ b/performance/responsePoliciesWorkflowLocal.json @@ -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" + } +} diff --git a/performance/responseReleaseTests.txt b/performance/responseReleaseTests.txt new file mode 100644 index 0000000..c245ee0 --- /dev/null +++ b/performance/responseReleaseTests.txt @@ -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] diff --git a/response/codeTypes_test.go b/response/codeTypes_test.go new file mode 100644 index 0000000..e28243f --- /dev/null +++ b/response/codeTypes_test.go @@ -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") + } +} diff --git a/response/config.go b/response/config.go new file mode 100644 index 0000000..c8d8707 --- /dev/null +++ b/response/config.go @@ -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 +} diff --git a/response/config_test.go b/response/config_test.go new file mode 100644 index 0000000..1fcb933 --- /dev/null +++ b/response/config_test.go @@ -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) + } + } +} diff --git a/response/encoder.go b/response/encoder.go new file mode 100644 index 0000000..0341a6f --- /dev/null +++ b/response/encoder.go @@ -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) + }) +} diff --git a/response/response.go b/response/response.go index 08bb947..92fcd29 100644 --- a/response/response.go +++ b/response/response.go @@ -1,6 +1,8 @@ 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 { Code string `json:"code"` Message string `json:"message"` diff --git a/response/template.go b/response/template.go new file mode 100644 index 0000000..686a960 --- /dev/null +++ b/response/template.go @@ -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 + } +} diff --git a/response/template_test.go b/response/template_test.go new file mode 100644 index 0000000..5cfc9cf --- /dev/null +++ b/response/template_test.go @@ -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 +} diff --git a/response_test.go b/response_test.go new file mode 100644 index 0000000..135088a --- /dev/null +++ b/response_test.go @@ -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, "accepted") || 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)) + } + } + }) + } +} diff --git a/scripts/verifyResponseRelease.py b/scripts/verifyResponseRelease.py new file mode 100644 index 0000000..d27cb69 --- /dev/null +++ b/scripts/verifyResponseRelease.py @@ -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() diff --git a/transport/nethttp/router.go b/transport/nethttp/router.go index 47d7f5a..00f210c 100644 --- a/transport/nethttp/router.go +++ b/transport/nethttp/router.go @@ -11,7 +11,10 @@ import ( "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 { method string @@ -22,6 +25,12 @@ type route struct { 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) { var selected *route var selectedParams map[string]string @@ -48,9 +57,17 @@ func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) { } sort.Strings(methods) writer.Header().Set("Allow", strings.Join(methods, ", ")) - http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + if r.methodNotAllowed != nil { + r.methodNotAllowed.ServeHTTP(writer, request) + } else { + http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } } else { - http.NotFound(writer, request) + if r.notFound != nil { + r.notFound.ServeHTTP(writer, request) + } else { + http.NotFound(writer, request) + } } return }