diff --git a/API.md b/API.md index 5848dd0..7769c1f 100644 --- a/API.md +++ b/API.md @@ -111,6 +111,27 @@ service user-api { ## 业务错误:声明、代码和文档共用一份定义 +从 `v0.10.0-preview` 起,推荐用 `errorCatalog` 同时定义逻辑标识、对外业务码、提示和 HTTP 状态: + +```text +errorCatalog CommonErrors ( + defaultStatus: 200 + errors: { + "UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400002, "message": "用户名格式不正确"}, + "PasswordInvalid": {"key": "PASSWORD_INVALID", "code": 400002, "message": "密码格式不正确"}, + "Forbidden": {"key": "FORBIDDEN", "status": 403, "code": 403001, "message": "没有权限"} + } +) +``` + +对象键是生成函数名;`key` 是唯一逻辑标识,允许项目自己的命名;`code` 是对外业务码,可以重复。数字码需搭配 `codeType: int32` 或 `int64` 的公共响应;字符串码搭配 `string`。目录不规定返回体字段。 + +省略 `defaultStatus` 时为 400;设为 200 时,只作用于目录里未单独写 `status` 的条目。生成路由自动绑定它实际声明的错误目录,在 App 中间件之前生效。业务只调用 `apierrors.UsernameInvalid()`;无需再维护 `codes` / `messages`,也无需修改 main。手写路由可复用生成的 `apierrors.Catalog()`。 + +同一 HTTP 200 下,OpenAPI 用 `anyOf` 保留成功 DTO 和错误体,日志与指标仍识别业务失败。完整语法、路由作用域与手写项目配置见 [统一错误目录](errors/README.md),可运行契约见 [errors.api](examples/contracts/errors.api)。 + +原有声明继续支持: + ```go error ( Unauthenticated 401 "UNAUTHENTICATED" "authentication required" @@ -121,7 +142,7 @@ error ( ``` 也可写单条 `error EmailTaken 409 "EMAIL_TAKEN" "email already in use"`,或把错误目录放进相对导入的 `errors.api`。 -名称用 UpperCamelCase,HTTP 状态范围 400–599,错误码用大写字母/数字/下划线且全局唯一,message 是可公开返回的稳定提示。 +名称用 UpperCamelCase,HTTP 状态允许 200–599 中可携带响应体的最终状态(排除 204/205/304),旧声明的逻辑错误码用大写字母/数字/下划线且全局唯一,message 是可公开返回的稳定提示。`Catalog` 为生成函数保留名称。 `@server (errors: Unauthenticated)` 声明组级错误;路由 `@errors UserNotFound, EmailTaken` 追加到组级列表并去重。 `@errors none` 清空继承的业务错误。未声明、重复声明或生成符号冲突会报告源码位置。 @@ -138,7 +159,7 @@ return nil, apierrors.EmailTaken() OpenAPI 对每个状态按选定的响应模板生成结构和命名 examples,同一 409 下多个错误不会相互覆盖;平铺模式的纯业务状态还列出 code enum。 默认框架错误状态与自动添加的鉴权 401 保留开放的 code 字段,容纳校验器/中间件实际错误。 -`x-omni-errors` 提供错误目录与路由关联。文档声明不拦截、改写或穷举运行时的所有错误。 +`x-omni-errors` 提供错误目录与路由关联。目录解析已定义的错误,不自动推断数据库或其他未声明错误的业务含义,也不穷举所有运行时错误。 ## 鉴权:声明与路由注册一起变化 diff --git a/CHANGELOG.md b/CHANGELOG.md index 51edd58..7c662c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v0.10.0-preview + +- 新增 App/路由独立错误目录:`omni.WithErrors`、`errors.Catalog`、`ByCode` 与不可变 Registry,业务只选逻辑标识;对外码和提示由项目配置。 +- 默认 HTTP 状态与逐项覆盖均可配置,包括 HTTP 200 的业务失败;错误模板、RawJSON、日志、业务失败指标、链路、标准中间件桥接和幂等重放保留失败语义。 +- `.api errorCatalog` 同源生成 Catalog、独立错误函数、路由目录和 OpenAPI;允许重复对外码、不同路由不同码类型、公共契约导入。旧 error 声明也允许 HTTP 200。 +- 同一 200 的成功/错误响应使用 anyOf 保留两种 schema,错误示例保持各自提示;自定义 Encoder 和直接输出继续支持。 +- 目录校验类型、范围、状态和冲突;JSON 配置拒绝重复键,整数保留 int64 精度;未知标识使用系统兜底,默认公开响应不暴露 cause。 +- 新生成代码最低要求 v0.10.0-preview;原有构造函数、Codes/Messages 与旧生成代码继续可用。Catalog 为生成错误包保留函数名。 + + ## v0.9.1-preview - 新增标准 `database.OpenConnector` 和独立 `PoolConfig`;与 DSN 入口共享池参数、限时启动 Ping、失败清理、Health、事务,不注册全局驱动配置。 diff --git a/README.md b/README.md index 7e43534..3ac3764 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Service 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。 -**从这份 README 开始即可接入。** 当前版本为 **`v0.9.1-preview`**,Go **1.22+**;CLI 和项目依赖使用同一固定版本。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。 +**从这份 README 开始即可接入。** 当前版本为 **`v0.10.0-preview`**,Go **1.22+**;CLI 和项目依赖使用同一固定版本。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。 响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。 @@ -26,7 +26,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可 ```sh export GOPRIVATE=git.yuntuo.hk -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.9.1-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.10.0-preview export PATH="$(go env GOPATH)/bin:$PATH" go-omni --help ``` @@ -38,7 +38,7 @@ go-omni --help 在准备存放业务项目的目录执行,`./hello` 必须是新目录或空目录;把 `example.com/hello` 换成自己的 Go module 名称。 ```sh -go-omni api new --framework-version v0.9.1-preview \ +go-omni api new --framework-version v0.10.0-preview \ --module example.com/hello ./hello cd hello go mod tidy @@ -126,7 +126,7 @@ service catalog-api { ```sh go-omni api validate --api catalog.api -go-omni new --api catalog.api --framework-version v0.9.1-preview \ +go-omni new --api catalog.api --framework-version v0.10.0-preview \ --module example.com/catalog ./catalog cd catalog go mod tidy @@ -167,21 +167,21 @@ curl -i http://127.0.0.1:8080/api/v1/items/missing 需要 MySQL、文件存储或文件日志时,**首次生成**可以使用: ```sh -go-omni new --api catalog.api --framework-version v0.9.1-preview \ +go-omni new --api catalog.api --framework-version v0.10.0-preview \ --module example.com/catalogFull --database mysql --storage local \ --log-output file --log-format json ./catalogFull ``` 该命令在 `catalog.api` 所在目录执行,目标仍需是新目录。数据库迁移、表结构和 Repository 由项目维护,API 模板不自动建表;MySQL DSN 通过 `GO_OMNI_DB_DSN` 提供。存储还可选 `s3`、`r2`、`oss`,按生成项目的配置说明提供桶、端点和凭据。完整装配例子见 [userService](examples/userService/README.md)。 -不采用 `.api` 时,也可以用 `go-omni new --framework-version v0.9.1-preview --module example.com/billing ./billing` 创建手写接口的 CRUD 示例项目;两种生成方式选择一种即可。 +不采用 `.api` 时,也可以用 `go-omni new --framework-version v0.10.0-preview --module example.com/billing ./billing` 创建手写接口的 CRUD 示例项目;两种生成方式选择一种即可。 ## 已有项目:按模块接入 在业务项目的 **go.mod 所在目录**执行: ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.9.1-preview +go get git.yuntuo.hk/k2safe/go-omni@v0.10.0-preview ``` 已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。 @@ -307,7 +307,62 @@ auth UserAuth ( 生成器会创建 `internal/middleware/auth.go`,由项目实现凭据校验和身份注入。声明会生成路由绑定与 OpenAPI 的认证描述,**不会自动实现登录或授权规则**;未实现的 hook 返回 501。手写路由可使用 `auth.RequireAuth(authenticator)` 或自己的中间件。JWT、Session 和 Policy 按需选择。 -业务错误在 `.api` 中用 `error` 声明,通过 `@errors` 关联路由后,生成可调用的错误函数和 OpenAPI 中对应的 401、403、409 等响应。手写路由可以直接返回 `omni.NotFound`、`omni.Forbidden` 等错误。已有项目在自己的适配层转换原错误,内部原因和公开提示分开处理。 +业务错误可以集中定义,业务只选择发生了哪个错误。**HTTP 状态由项目配置,业务失败可以返回 HTTP 200**;框架不根据 HTTP 状态单独判断成功与否。 + +API 项目推荐在公共 `.api` 中声明目录,其他契约通过 `import` 引用: + +```text +errorCatalog CommonErrors ( + defaultStatus: 200 + errors: { + "UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400002, "message": "用户名格式不正确"}, + "PasswordInvalid": {"key": "PASSWORD_INVALID", "code": 400002, "message": "密码格式不正确"}, + "PermissionDenied": {"key": "PERMISSION_DENIED", "status": 403, "code": 403001, "message": "没有权限"} + } +) +``` + +`UsernameInvalid` 是生成函数名;`key` 是唯一逻辑标识;`code` 是项目对外的字符串或整数业务码,可以重复。条目没有填写 `status` 时使用目录的 `defaultStatus`,未配置目录默认值时为 400。这个默认值只作用于该目录的条目,不会把所有框架异常都改成 200。 + +在已有公共响应中保留 `codeType: int32` / `int64` / `string`、成功码、未定义错误的兜底码和返回体模板;不再重复填写这几条错误的 `codes` / `messages`。通过 `@server (errors: UsernameInvalid, PasswordInvalid)` 或路由 `@errors UsernameInvalid` 引用它们。 + +生成后业务调用: + +```go +return TokenResponse{}, apierrors.UsernameInvalid() +// 需要保留内部原因时: +return TokenResponse{}, apierrors.UsernameInvalid().WithCause(err) +``` + +生成器同时提供 `internal/apierrors.Catalog()`、错误函数、路由错误目录及 OpenAPI。目录会在 App 中间件执行前绑定到生成路由;无需手动改已经生成的 main。HTTP 200 的错误使用失败响应模板;OpenAPI 用 `anyOf` 同时保留成功 DTO 和错误体,多条错误共用数字码时仍保留各自示例。完整契约见 [errors.api](examples/contracts/errors.api)。 + +手写项目可在启动时加载自己的目录,保留原来的响应配置: + +```go +// omnierrors 是 git.yuntuo.hk/k2safe/go-omni/errors。 +catalog := omnierrors.Catalog{ + DefaultStatus: 200, + Definitions: map[string]omnierrors.Definition{ + "USERNAME_INVALID": {Code: 400002, Message: "用户名格式不正确"}, + "PASSWORD_INVALID": {Code: 400002, Message: "密码格式不正确"}, + }, +} +app := omni.New( + omni.WithErrors(catalog), + omni.WithResponse(response.Config{ + CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, + MessageField: "msg", DataMode: "always", + }), +) +// Service 只返回标准 error,不依赖 App 或 HTTP Context: +// return omnierrors.ByCode("USERNAME_INVALID").WithCause(err) +``` + +目录也可放入 `config.Config.Errors`,由 JSON/YAML 加载;生成项目的路径为 `server.errors`,但契约生成路由以 `.api` 绑定的目录为准。不同 App 独立持有配置,单路由也可通过 `response.WithErrors` 绑定专用目录与自定义 Encoder。直接输出文本、XML、下载或已提交的响应继续保留原协议。 + +访问日志保留实际 HTTP 状态,并增加失败标记、逻辑错误标识和目录业务码;指标、链路及框架标准中间件/幂等重放也保留失败语义。未知标识使用系统错误兜底,内部 cause 不写入默认公开响应。旧 `error (...)`、`New` / `BadRequest` / `Codes` / `Messages` 继续支持;新目录与旧映射冲突会报错,避免静默覆盖。 + +更详细的配置、作用域和升级步骤见 [统一错误目录](errors/README.md)。 ### 数据库、存储及其他模块 @@ -383,7 +438,7 @@ go test ./... `Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Service、Repository、中间件实现、main、配置和依赖装配属于项目,重复生成保留手写内容;契约改变后仍需自行调整业务签名并通过编译。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 可运行 `generate --check` 检查是否同步。 -升级时先安装目标版本的 CLI,再在业务 module 中 `go get` **同一版本**;API 项目随后执行上述生成与测试步骤,手写项目只需依赖升级及业务回归。`v0.9.1-preview` 的变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 +升级时先安装目标版本的 CLI,再在业务 module 中 `go get` **同一版本**;API 项目随后执行上述生成与测试步骤,手写项目只需依赖升级及业务回归。`v0.10.0-preview` 的变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 ## 按需深入 diff --git a/RELEASE.md b/RELEASE.md index fcc403b..4b0c9da 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,31 +1,38 @@ -# v0.9.1-preview +# v0.10.0-preview -本版补齐已有项目接入的两个框架缺口:标准数据库 Connector 注入,以及应用级 HTTP 读写超时配置。TLS、数据库驱动日志、字符集/时区、迁移与业务规则继续由项目决定。 +统一错误目录:由项目集中定义逻辑标识、对外业务码、公开提示和 HTTP 状态,业务只选择错误。支持 HTTP 200 的业务失败,并贯通响应、错误函数生成、日志与 OpenAPI。 -## 安装与升级 +## 升级 ```sh -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.9.1-preview -# 在各业务 module 中: -go get git.yuntuo.hk/k2safe/go-omni@v0.9.1-preview +go get git.yuntuo.hk/k2safe/go-omni@v0.10.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.10.0-preview +# 使用 .api 的项目再执行: +go-omni api generate --dry-run +go-omni api generate +go test ./... ``` -私有仓库使用本机 Git 凭据管理器,并将 `git.yuntuo.hk` 追加到已有 `GOPRIVATE`。CLI 和项目依赖使用同一固定版本。v0.9.0-preview 生成代码继续兼容,本次无需为了这两个入口重新生成接口。 +先升级项目依赖,再使用同版 CLI。新生成代码最低运行时为本版;旧的 APIError/New/BadRequest、Codes/Messages、error 声明及旧生成代码继续支持。API 错误包增加 `Catalog()`,该名称成为保留符号。项目的手写 main、Service 和 Repository 不被重生成覆盖。 -## 接入变化 +## 接入 -- 将项目已有 `sql.OpenDB(connector)` 的池创建替换为 `database.OpenConnector(ctx, connector, poolConfig)`;不要同时保留两个池。返回对象可用于 Health/InTx;需要标准库指针时取 `.DB`。 -- 项目原有 `mysql.NewConnector`、独立 TLS、driver Logger、驱动读写时限、MySQL 版本/时区/排序规则校验继续保留,配置不进入核心框架。 -- 将原来逐项修改 `http.Server` 的读写/空闲/头大小配置放入 `omni.HTTPConfig`,通过 `WithHTTPConfig` 或 `WithConfig` 传入。Go-only `ErrorLog` 支持 nethttp。 -- 原有 `database.Open(ctx, Config)`、手动组装 `http.Server`、自定义 Runner 均可继续使用。零 HTTP 配置保留所选网络层已有默认行为;正值覆盖,负值报错。 +- 手写项目:`omni.New(omni.WithErrors(catalog), omni.WithResponse(responseConfig))`,业务返回 `omnierrors.ByCode(key)`,按需 `.WithCause(err)`。 +- 契约项目:`.api errorCatalog` 定义一次,路由 `@errors` 或 `@server errors` 引用,生成 `apierrors.UsernameInvalid()` 等函数、Catalog、路由策略及 OpenAPI。 +- 项目目录的 `defaultStatus` 可以设为 200,单条错误可设置 403、409 等;默认只作用于目录条目,不改变未配置系统错误的兜底。 +- 逻辑标识必须唯一,对外业务码可以重复,支持字符串/int32/int64;整数精度与所选响应类型在加载/编译时校验。 +- HTTP 200 失败使用错误响应模板,OpenAPI 的 200 使用 anyOf 保留成功 DTO 和错误体;同码错误保留不同示例。 +- App 和路由目录隔离、初始化复制;每次生成/解析错误都返回独立对象。未知标识转系统兜底,默认公开响应不暴露 cause。 -完整可复制示例:[数据库 Connector](database/README.md#connector-接入v091-preview)、[HTTP 配置及语义](transport/README.md#http-配置v091-preview)。其他迁移能力见 [迁移扩展指南](MIGRATION.md),从项目接入开始见 [README](README.md)。 +完整接入从 [README](README.md) 开始;目录/作用域/配置边界见 [错误目录](errors/README.md);可直接生成的例子见 [errors.api](examples/contracts/errors.api)。 -## 边界 +## 配置边界 -独立实验性 `TransportHTTP2` 本次支持 IdleTimeout/MaxHeaderBytes 的配置透传,但不支持读头、读请求和写响应超时,设置这些正值会在监听前明确报错。native 与标准库服务器的超时含义不完全相同,详见上表;网络超时不等于 Handler 执行时间限制。`RunWith` 自定义 Runner 的配置由项目管理。 +生成路由的目录和响应以 `.api` 为准,优先于 App 默认配置;ByCode 需要当前目录中存在该标识。使用生成函数后仍须声明实际路由的 @errors,保持文档准确。不同路由可使用不同码类型与返回格式。 -Connector 必须遵守驱动 context 协议,资源所有权按标准 `database/sql` 管理。有资源的 Connector 不跨池共享;启动 Ping 失败关闭池,成功后应用负责关闭。没有把 eTax/ePay 的数据库策略写进框架,也未修改这些业务项目或宣称它们已完成迁移。 +目录与响应模板分开,自定义 Encoder 继续控制最终 HTTP 状态和文本/XML 等协议。直接输出与已提交响应不会被再次包装。旧同标识 Codes/Messages 若与新目录冲突会明确拒绝。 + +访问日志记录实际提交状态,并带 failed/errorKey/可用的 businessCode。指标与链路识别 HTTP 200 业务失败;标准中间件和本地/共享幂等重放保留错误标识,旧缓存缺少标记时不补推历史状态。业务代码中的散落提示仍需业务项目按目录逐步迁移;本轮没有改写 eTax/ePay 业务逻辑。 ## 验证入口 @@ -33,13 +40,13 @@ Connector 必须遵守驱动 context 协议,资源所有权按标准 `database GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./... go vet ./... go build ./... -python3 scripts/verifyConnector.py --local --report performance/connectorLocal.json -python3 scripts/verifyConnector.py --version v0.9.1-preview --report performance/connectorRemote.json -python3 scripts/verifyCompatibilityRelease.py --version v0.9.1-preview --report performance/connectorCompatibilityRemote.json +python3 scripts/verifyErrorCatalogRelease.py --version v0.10.0-preview +python3 scripts/verifyCompatibilityRelease.py --version v0.10.0-preview \ + --report performance/errorCatalogCompatibilityRemote.json ``` -Connector 脚本只使用本地独立 MySQL 8.4 容器,限制 1 CPU、768 MiB、128 PID,生成一次性 CA/证书及凭据,结束清理自己的容器/卷/临时文件。测试验证正确 TLS、错误 CA/主机名拒绝、每池时区/日志配置隔离、事务、关闭与失败回收。HTTP 测试使用回环地址验证慢头、慢 body、空闲连接、流式写背压及请求头上限。常规单测不依赖 Docker,显式脚本要求真实 TLS 测试通过,不能跳过。 +测试覆盖多 App 隔离、并发原因隔离、精确 int64、重复/非法定义拒绝、200 失败与 403 覆盖、未知错误兜底、已提交响应不追加、文本回执、访问日志、指标/链路、标准桥接、本地与序列化共享幂等存储,以及真实生成项目的 early middleware、raw 输出和重生成保护。远程脚本从全新缓存安装 CLI,检查固定版本、模块校验和与没有本地 replace。 -历史发布及验收记录保留在 [CHANGELOG](CHANGELOG.md)、[v0.9.0 接入验收](performance/compatibilityAcceptance.md)。 +本版结果见 [统一错误目录验收](performance/errorCatalogAcceptance.md)。 -全量 race(40 个测试包)、静态检查、构建、本地隔离验收及全新缓存远程消费者/CLI 验收已通过,详见 [验收记录](performance/connectorAcceptance.md)。 +历史 Connector/HTTP 配置验收保留在 [v0.9.1 记录](performance/connectorAcceptance.md),历史版本变化见 [CHANGELOG](CHANGELOG.md)。 diff --git a/RESPONSE.md b/RESPONSE.md index f5e4835..1e624cd 100644 --- a/RESPONSE.md +++ b/RESPONSE.md @@ -138,3 +138,12 @@ go run ./cmd/go-omni api generate --api examples/contracts/responses.api \ ``` 示例业务方法起初为 501,完成 Service 后就会套用声明的模板。参考 [完整 .api](examples/contracts/responses.api)。验收测试会在临时目录生成真实项目,写入业务方法,执行已生成路由,检查数字错误码、原始 JSON、鉴权错误、路由覆盖与重复生成保护。 + + +## 错误目录与 HTTP 200 失败(v0.10.0-preview) + +错误含义由项目的 `errors.Catalog` 或 `.api errorCatalog` 集中定义,返回字段/结构仍由本页的 response 模板控制。目录提供独立的逻辑标识、对外字符串/整数码、消息、HTTP 状态,使用 `WithErrors` 或生成路由绑定。详见 [统一错误目录](errors/README.md)。 + +`response.Result.IsError()` 综合明确失败标记、Err 和 HTTP 状态判断;HTTP 200 的业务错误不会使用成功码/成功提示,仍可选择 `errorBody`。新目录的公开码与提示优先于通用兜底;与同标识 Codes/Messages 冲突会拒绝,不会静默覆盖。没有目录的旧映射保持原有行为。 + +自定义 Encoder 可读取 `Code`(逻辑标识)、`BusinessCode`(目录对外码)、Message 与 Err,并返回项目要求的最终状态和媒体类型。不要仅通过 Status 判断成功;内部原因默认只供诊断,不应由 Encoder 直接序列化 Err。RawJSON 的成功输出保持原样,失败继续交给所选错误编码器。 diff --git a/app/app.go b/app/app.go index a8352bb..2b64c1c 100644 --- a/app/app.go +++ b/app/app.go @@ -67,6 +67,7 @@ func configure(options ...config.Option) config.Config { func newApp(router httpx.Router, settings config.Config) *App { settings.Response = settings.Response.Clone() + settings.Errors = settings.Errors.Clone() encoder, responseErr := response.New(settings.Response) if responseErr != nil { panic(responseErr) @@ -77,6 +78,10 @@ func newApp(router httpx.Router, settings config.Config) *App { } settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...) settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...) + responseEncoder, responseErr = response.WithErrors(settings.Errors, responseEncoder) + if responseErr != nil { + panic(responseErr) + } resolver, err := httpx.NewClientIPResolver(settings.TrustedProxies...) if err != nil { panic("go-omni: " + err.Error()) @@ -114,6 +119,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) { func (a *App) Config() config.Config { settings := a.config settings.Response = settings.Response.Clone() + settings.Errors = settings.Errors.Clone() settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...) settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...) return settings diff --git a/app/errorCatalog_test.go b/app/errorCatalog_test.go new file mode 100644 index 0000000..b3b2489 --- /dev/null +++ b/app/errorCatalog_test.go @@ -0,0 +1,178 @@ +package app_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + omni "git.yuntuo.hk/k2safe/go-omni" + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/logx" + "git.yuntuo.hk/k2safe/go-omni/metrics" + "git.yuntuo.hk/k2safe/go-omni/middleware" + "git.yuntuo.hk/k2safe/go-omni/response" +) + +func TestAppErrorCatalogHTTP200LogsAndReplay(t *testing.T) { + var logs bytes.Buffer + logCfg := logx.Default() + logCfg.Writer = &logs + l, err := logx.New(logCfg) + if err != nil { + t.Fatal(err) + } + logx.SetDefault(l) + defer logx.SetDefault(nil) + defer l.Close() + cat := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"INVALID": {Code: 400002, Message: "项目提示"}, "DENIED": {Status: 403, Code: 9, Message: "no"}}} + policy := response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"} + app := omni.New(omni.WithErrors(cat), omni.WithResponse(policy), omni.WithLogger(true)) + defer app.Close() + meter := metrics.New() + app.Use(meter.Middleware()) + cat.Definitions["INVALID"] = apierrors.Definition{Status: 500, Code: 1, Message: "mutated"} + app.Config().Errors.Definitions["INVALID"] = apierrors.Definition{Status: 500, Code: 2, Message: "mutated"} + app.GET("/error", func(c *omni.Context) error { + return fmt.Errorf("wrapped: %w", apierrors.ByCode("INVALID").WithCause(errors.New("private"))) + }) + app.GET("/direct", func(c *omni.Context) error { c.WriteError(apierrors.ByCode("INVALID")); return nil }) + app.GET("/bridge", func(c *omni.Context) error { return apierrors.ByCode("INVALID") }, httpx.WrapMiddleware(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) }) + })) + app.GET("/missing", func(c *omni.Context) error { return apierrors.ByCode("MISSING") }) + app.GET("/denied", func(c *omni.Context) error { return apierrors.ByCode("DENIED") }) + app.GET("/committed", func(c *omni.Context) error { + if err := c.Text(201, "done"); err != nil { + return err + } + return apierrors.ByCode("INVALID") + }) + app.HandleWithResponse("GET", "/callback", response.EncoderFunc(func(r response.Result) (response.Payload, error) { + if !r.IsError() || r.BusinessCode != int64(400002) { + t.Fatal(r) + } + return response.Text(202, "ACK"), nil + }), func(c *omni.Context) error { return apierrors.ByCode("INVALID") }) + idem, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Scope: func(*httpx.Context) (string, error) { return "fixture", nil }}) + if err != nil { + t.Fatal(err) + } + calls := 0 + app.POST("/idempotent", func(c *omni.Context) error { calls++; return apierrors.ByCode("INVALID") }, idem.Middleware()) + shared, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Store: &catalogStore{records: map[string]middleware.IdempotencyRecord{}}, Scope: func(*httpx.Context) (string, error) { return "fixture", nil }}) + if err != nil { + t.Fatal(err) + } + app.POST("/shared", func(c *omni.Context) error { calls++; return apierrors.ByCode("INVALID") }, shared.Middleware()) + + for _, path := range []string{"/error", "/direct", "/bridge", "/idempotent", "/idempotent", "/shared", "/shared"} { + method := "GET" + if path == "/idempotent" || path == "/shared" { + method = "POST" + } + w := httptest.NewRecorder() + r := httptest.NewRequest(method, path, nil) + r.Header.Set("Idempotency-Key", "same") + app.ServeHTTP(w, r) + if w.Code != 200 || !strings.Contains(w.Body.String(), `"code":400002`) || !strings.Contains(w.Body.String(), "项目提示") || strings.Contains(w.Body.String(), "private") { + t.Fatal(w.Code, w.Body.String()) + } + } + if stats := meter.Snapshot(); stats.Errors != 7 || stats.StatusCounts["200"] != 7 { + t.Fatal("lost business failure metrics", stats) + } + if calls != 2 { + t.Fatal("replay executed business twice") + } + for _, test := range []struct { + path string + status int + body string + }{{"/denied", 403, `"code":9`}, {"/missing", 500, `"code":9999`}, {"/committed", 201, "done"}, {"/callback", 202, "ACK"}} { + w := httptest.NewRecorder() + app.ServeHTTP(w, httptest.NewRequest("GET", test.path, nil)) + if w.Code != test.status || !strings.Contains(w.Body.String(), test.body) { + t.Fatal(test.path, w.Code, w.Body.String()) + } + } + for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") { + var entry map[string]any + if err := json.Unmarshal([]byte(line), &entry); err != nil { + t.Fatal(err) + } + if entry["msg"] != "http.request" { + continue + } + if entry["failed"] != true { + t.Fatal("lost failure:", entry) + } + if entry["path"] == "/callback" { + if entry["status"] != float64(202) { + t.Fatal(entry) + } + } else if entry["path"] == "/error" || entry["path"] == "/direct" || entry["path"] == "/bridge" || entry["path"] == "/idempotent" || entry["path"] == "/shared" { + if entry["status"] != float64(200) || entry["businessCode"] != float64(400002) || entry["errorKey"] != "INVALID" { + t.Fatal(entry) + } + } + } + // A second application may interpret the same logical identifier differently. + app2 := omni.New(omni.WithErrors(apierrors.Catalog{Definitions: map[string]apierrors.Definition{"INVALID": {Status: 409, Code: "conflict", Message: "other"}}})) + defer app2.Close() + app2.GET("/", func(*omni.Context) error { return apierrors.ByCode("INVALID") }) + w := httptest.NewRecorder() + app2.ServeHTTP(w, httptest.NewRequest("GET", "/", nil)) + if w.Code != 409 || !strings.Contains(w.Body.String(), "other") { + t.Fatal(w.Code, w.Body.String()) + } +} + +// A serialized store exercises the shared middleware contract, including replay +// metadata. No external service or project database is used by this fixture. +type catalogStore struct { + mu sync.Mutex + records map[string]middleware.IdempotencyRecord +} + +func (s *catalogStore) Claim(_ context.Context, key, fp, owner string) (middleware.IdempotencyRecord, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.records[key]; ok { + return r, false, nil + } + r := middleware.IdempotencyRecord{Fingerprint: fp, Owner: owner, State: "running"} + s.records[key] = r + return r, true, nil +} +func (s *catalogStore) Complete(_ context.Context, key, owner string, response *middleware.StoredResponse, _ time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + r := s.records[key] + if r.Owner != owner { + return errors.New("wrong owner") + } + r.State = "failed" + if response != nil { + r.State = "completed" + encoded, err := json.Marshal(response) + if err != nil { + return err + } + var copy middleware.StoredResponse + if err := json.Unmarshal(encoded, ©); err != nil { + return err + } + r.Response = © + } + s.records[key] = r + return nil +} diff --git a/app/router.go b/app/router.go index 62cdcb6..b723ed5 100644 --- a/app/router.go +++ b/app/router.go @@ -30,6 +30,9 @@ func (a *App) adaptResponse(encoder response.Encoder, handler httpx.Handler, mid request = request.WithContext(httpx.WithClientIPResolver(request.Context(), a.clientIPResolver)) } requestContext := httpx.NewContext(request, writer, httpx.RouteParams(request.Context())) + if provider, ok := a.responseEncoder.(response.ErrorRegistryProvider); ok { + requestContext.SetErrorRegistry(provider.ErrorRegistry()) + } requestContext.SetResponseEncoder(encoder) defer func() { httpx.CleanupMultipart(requestContext.Request()) }() if err := wrapped(requestContext); err != nil { diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md index 8a74a15..b610db8 100644 --- a/cmd/go-omni/README.md +++ b/cmd/go-omni/README.md @@ -21,6 +21,8 @@ go run ./cmd/go-omni api validate --api examples/contracts/user.api 后续在生成项目内执行 `go-omni api generate --api api/user.api --dir .`。 项目内可省略 `--api` / `--dir`。`api inspect` 展示路由和鉴权/业务错误,`api export` 向 stdout 导出 OpenAPI,`api generate --dry-run` 预览文件变化。`--check` 只读检查生成产物是否过期。生成器记录所有权,保留 Service / Repository / 配置 / main 的手写内容,遇到生成文件被手动修改则报冲突。新 Service 与中间件占位实现返回 501。支持 `error (...)`、具名 `auth`、路由 `@errors` / `@auth` 和多行 `@doc`;错误目录同步生成 `internal/apierrors` 调用函数。 +`v0.10.0-preview` 新增 `errorCatalog`:集中配置业务码、提示和 HTTP 状态,支持默认 HTTP 200 与单条覆盖,生成函数、路由策略和 OpenAPI 共用一份定义。语法及配置方式见 [统一错误目录](../../errors/README.md)。 + 支持范围、参数语义、目录及安全更新策略见 [API 开发指南](../../API.md)。本版借鉴 go-zero 的工作流,不宣称兼容全部 goctl 语法;仍通过 Omni/httpx 保留网络层替换能力。 ## 日志与对象存储按需装配 @@ -75,8 +77,8 @@ Handler / Service / Repository / Model 与模块注册入口。模块名为 lowe 从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架: ```sh -GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.9.1-preview -go-omni new --framework-version v0.9.1-preview --module example.com/billing ./billing +GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.10.0-preview +go-omni new --framework-version v0.10.0-preview --module example.com/billing ./billing cd billing go mod tidy go run ./cmd/server diff --git a/cmd/go-omni/apigen/contractMetadata.go b/cmd/go-omni/apigen/contractMetadata.go index baac47b..866cf44 100644 --- a/cmd/go-omni/apigen/contractMetadata.go +++ b/cmd/go-omni/apigen/contractMetadata.go @@ -8,6 +8,7 @@ import ( "sort" "strings" + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/openapi" "git.yuntuo.hk/k2safe/go-omni/response" ) @@ -20,14 +21,18 @@ var credentialName = regexp.MustCompile("^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") func prepareMetadata(original *Spec) (*Spec, error) { spec := *original spec.Routes = append([]Route(nil), original.Routes...) + spec.Errors = append([]ErrorDefinition(nil), original.Errors...) if err := validateResponses(&spec); err != nil { return nil, err } definitions := map[string]ErrorDefinition{} - codes, symbols := map[string]bool{}, map[string]bool{} + codes, symbols := map[string]bool{}, map[string]bool{"Catalog": true} for _, e := range spec.Errors { - if !exportedName.MatchString(e.Name) || e.Status < 400 || e.Status > 599 || !errorCode.MatchString(e.Code) || strings.TrimSpace(e.Message) == "" { - return nil, fmt.Errorf("%s: error requires UpperCamelCase name, status 400..599, UPPER_SNAKE_CASE code and a nonempty public message", e.Position) + if !exportedName.MatchString(e.Name) || !apierrors.ValidErrorStatus(e.Status) || strings.TrimSpace(e.Message) == "" { + return nil, fmt.Errorf("%s: error requires UpperCamelCase name, a final body-bearing status (200..599 except 204/205/304) and a nonempty public message", e.Position) + } + if e.Catalog == "" && !errorCode.MatchString(e.Code) { + return nil, fmt.Errorf("%s: legacy error declaration requires UPPER_SNAKE_CASE code", e.Position) } if codes[e.Code] { return nil, fmt.Errorf("%s: duplicate error code %s", e.Position, e.Code) @@ -41,6 +46,17 @@ func prepareMetadata(original *Spec) (*Spec, error) { codes[e.Code] = true definitions[e.Name] = e } + registry, err := apierrors.NewRegistry(errorCatalog(&spec, nil)) + if err != nil { + return nil, err + } + normalized := registry.Catalog() + for i := range spec.Errors { + spec.Errors[i].BusinessCode = normalized.Definitions[spec.Errors[i].Code].Code + } + if err := validateErrorPolicies(&spec); err != nil { + return nil, err + } auth := map[string]AuthDefinition{} for _, a := range spec.Auth { if !exportedName.MatchString(a.Name) || !goName.MatchString(a.Middleware) || strings.EqualFold(a.Middleware, "defaults") || strings.EqualFold(a.Middleware, "registryGen") { @@ -84,7 +100,7 @@ func prepareMetadata(original *Spec) (*Spec, error) { seen := map[string]bool{} for _, name := range r.Errors { if _, ok := definitions[name]; !ok { - return nil, fmt.Errorf("%s: unknown error %s; declare it in error (...)", r.Position, name) + return nil, fmt.Errorf("%s: unknown error %s; declare it in errorCatalog or error (...)", r.Position, name) } if seen[name] { return nil, fmt.Errorf("%s: duplicate error %s", r.Position, name) @@ -182,11 +198,31 @@ func decorateDocument(doc map[string]any, spec *Spec) { response = genericErrorResponse(http.StatusText(status), cfg) responses[key] = response } - media := response["content"].(map[string]any)["application/json"].(map[string]any) + content, ok := response["content"].(map[string]any) + if !ok { + content = map[string]any{} + response["content"] = content + } + media, hasJSON := content["application/json"].(map[string]any) + if !hasJSON { + media = map[string]any{} + content["application/json"] = media + } + errorMedia := genericErrorResponse("", cfg)["content"].(map[string]any)["application/json"].(map[string]any) + if exists && status < 400 && media["schema"] != nil { + media["schema"] = map[string]any{"anyOf": []any{media["schema"], errorMedia["schema"]}} + } else if media["schema"] == nil { + media["schema"] = errorMedia["schema"] + } examples := map[string]any{} codes := []any{} seenCodes := map[string]bool{} description := []string{} + if exists { + if text, ok := response["description"].(string); ok && text != "" { + description = append(description, text) + } + } sort.Slice(errs, func(i, j int) bool { return errs[i].Name < errs[j].Name }) for _, e := range errs { value := policy.Value(responseResult(e)) @@ -207,7 +243,7 @@ func decorateDocument(doc map[string]any, spec *Spec) { } } func responseResult(e ErrorDefinition) response.Result { - return response.Result{Status: e.Status, Code: e.Code, Message: e.Message, RequestID: "exampleRequest"} + return response.Result{Status: e.Status, Code: e.Code, Message: e.Message, BusinessCode: e.BusinessCode, Failed: true, RequestID: "exampleRequest"} } func genericErrorResponse(description string, cfg response.Config) map[string]any { if description == "" { diff --git a/cmd/go-omni/apigen/declarations.go b/cmd/go-omni/apigen/declarations.go index 665de7c..86df72d 100644 --- a/cmd/go-omni/apigen/declarations.go +++ b/cmd/go-omni/apigen/declarations.go @@ -9,11 +9,13 @@ import ( // ErrorDefinition declares a public, stable error. Generated constructors return // a fresh error each time so WithCause cannot mutate a shared singleton. type ErrorDefinition struct { - Name string `json:"name"` - Status int `json:"status"` - Code string `json:"code"` - Message string `json:"message"` - Position string `json:"-"` + Name string `json:"name"` + BusinessCode any `json:"businessCode,omitempty"` + Catalog string `json:"catalog,omitempty"` + Status int `json:"status"` + Code string `json:"code"` + Message string `json:"message"` + Position string `json:"-"` } type AuthDefinition struct { Name, Type, In, KeyName, Middleware, Description, BearerFormat, Position string diff --git a/cmd/go-omni/apigen/errorCatalog.go b/cmd/go-omni/apigen/errorCatalog.go new file mode 100644 index 0000000..be69bf0 --- /dev/null +++ b/cmd/go-omni/apigen/errorCatalog.go @@ -0,0 +1,118 @@ +package apigen + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/response" +) + +// A named catalog groups project definitions under one default HTTP status. +// Each JSON object key supplies the generated Go function's name, while key is +// the independent logical identifier and code is the public string/integer. +func (p *parser) parseErrorCatalog() error { + pos := p.next().position + name, err := p.ident() + if err != nil { + return err + } + if !exportedName.MatchString(name) { + return p.err("errorCatalog requires an UpperCamelCase name") + } + for _, prior := range p.spec.ErrorCatalogs { + if prior == name { + return p.err("duplicate errorCatalog " + name) + } + } + p.spec.ErrorCatalogs = append(p.spec.ErrorCatalogs, name) + p.skip() + props, err := p.metadata() + if err != nil { + return err + } + defaultStatus := 400 + if value, ok := props["defaultStatus"]; ok { + defaultStatus, err = strconv.Atoi(value) + if err != nil { + return p.err("defaultStatus must be an integer") + } + } + if !apierrors.ValidErrorStatus(defaultStatus) { + return p.err("invalid errorCatalog defaultStatus") + } + for key := range props { + if key != "defaultStatus" && key != "errors" { + return p.err("unsupported errorCatalog property " + key) + } + } + type entry struct { + Key string `json:"key"` + Code any `json:"code"` + Message string `json:"message"` + Status int `json:"status"` + } + var entries map[string]entry + if err := apierrors.DecodeCatalogJSON([]byte(props["errors"]), &entries); err != nil { + return fmt.Errorf("%s: catalog %s: %w", pos, name, err) + } + if len(entries) == 0 { + return p.err("errorCatalog requires errors") + } + names := make([]string, 0, len(entries)) + for key := range entries { + names = append(names, key) + } + sort.Strings(names) + for _, fn := range names { + e := entries[fn] + if e.Code == nil { + return p.err("errorCatalog error requires code") + } + if e.Status == 0 { + e.Status = defaultStatus + } + p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: fn, Status: e.Status, Code: e.Key, Message: e.Message, BusinessCode: e.Code, Catalog: name, Position: pos}) + } + return nil +} + +func errorCatalog(spec *Spec, names []string) apierrors.Catalog { + c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{}} + selected := map[string]bool{} + for _, n := range names { + selected[n] = true + } + for _, e := range spec.Errors { + if names != nil && !selected[e.Name] { + continue + } + c.Definitions[e.Code] = apierrors.Definition{Status: e.Status, Code: e.BusinessCode, Message: e.Message} + } + return c +} + +// Validate the exact catalog/template pair used by each route. Different routes +// can use different code types without forcing a global response representation. +func validateErrorPolicies(spec *Spec) error { + for _, r := range spec.Routes { + f, err := response.New(responseConfig(spec, r)) + if err != nil { + return err + } + if _, err = response.WithErrors(errorCatalog(spec, append([]string{}, r.Errors...)), f); err != nil { + return fmt.Errorf("%s: %w", r.Position, err) + } + } + return nil +} + +func errorCatalogJSON(spec *Spec, names []string) string { + data, err := json.Marshal(errorCatalog(spec, names)) + if err != nil { + panic(err) + } + return string(data) +} diff --git a/cmd/go-omni/apigen/errorCatalog_test.go b/cmd/go-omni/apigen/errorCatalog_test.go new file mode 100644 index 0000000..8f14ad6 --- /dev/null +++ b/cmd/go-omni/apigen/errorCatalog_test.go @@ -0,0 +1,205 @@ +package apigen + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestErrorCatalogDocumentAndValidation(t *testing.T) { + path := filepath.Join(frameworkRoot(t), "examples/contracts/errors.api") + source := readFile(t, path) + spec, err := Parse(path) + if err != nil { + t.Fatal(err) + } + compiled, err := Compile(spec) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"/token", "/raw"} { + method := "post" + if path == "/raw" { + method = "get" + } + op := operation(t, document(t, compiled), path, method) + response := op["responses"].(map[string]any)["200"].(map[string]any) + media := response["content"].(map[string]any)["application/json"].(map[string]any) + alternatives := media["schema"].(map[string]any)["anyOf"].([]any) + if len(alternatives) != 2 { + t.Fatal("lost success or failure shape") + } + if path == "/raw" { + if alternatives[0].(map[string]any)["properties"].(map[string]any)["token"] == nil { + t.Fatal("raw success wrapped") + } + } + examples := media["examples"].(map[string]any) + if len(examples) != 2 { + t.Fatal(examples) + } + for _, name := range []string{"UsernameInvalid", "PasswordInvalid"} { + value := examples[name].(map[string]any)["value"].(map[string]any) + if value["ok"] != false || value["code"] != float64(400002) || value["msg"] == nil { + t.Fatal(value) + } + } + } + for _, change := range [][2]string{ + {`defaultStatus: 200`, `defaultStatus: 204`}, + {`"status": 403`, `"status": 304`}, + {`"PASSWORD_INVALID"`, `"USERNAME_INVALID"`}, + {`"code": 400002`, `"code": 2147483648`}, + {`"code": 400002`, `"code": "400002"`}, + {`"code": 400002`, `"code": 1.5`}, + {`"code": 400002`, `"code": null`}, + {`"code": 400002`, `"code": 400002, "code": 400003`}, + {`"message": "用户名格式不正确"`, `"message": "用户名格式不正确", "typo": true`}, + {`@handler token`, `@errors Unknown\n @handler token`}, + {`successCode: 0`, `successCode: 0\n codes: {"USERNAME_INVALID": 999}`}, + } { + text := strings.Replace(source, change[0], strings.ReplaceAll(change[1], `\n`, "\n"), 1) + s, err := parseText(t, text) + if err == nil { + _, err = Compile(s) + } + if err == nil { + t.Errorf("accepted %s", change[1]) + } + } + // The legacy declaration is also allowed to carry a HTTP 200 failure. + old := strings.Replace(declaredAPI, "EmailTaken 409", "EmailTaken 200", 1) + s, err := parseText(t, old) + if err == nil { + _, err = Compile(s) + } + if err != nil { + t.Fatal(err) + } +} + +func TestErrorCatalogConsumer(t *testing.T) { + if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" { + t.Skip("consumer integration") + } + root := frameworkRoot(t) + dir := filepath.Join(t.TempDir(), "consumer") + opts := Options{API: filepath.Join(root, "examples/contracts/errors.api"), Dir: dir, Module: "example.com/errorCatalog", FrameworkDir: root} + cli := os.Getenv("GO_OMNI_RELEASE_CLI") + generate := func(check bool) { + t.Helper() + if cli == "" { + o := opts + o.Check = check + if _, err := Generate(o); err != nil { + t.Fatal(err) + } + return + } + args := []string{"api", "generate", "--api", opts.API, "--dir", dir, "--module", opts.Module} + if check { + args = append(args, "--check") + } + cmd := exec.Command(cli, args...) + cmd.Dir = filepath.Dir(dir) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("release generate %v\n%s", err, output) + } + } + generate(false) + + fixture := readFile(t, "testdata/errorCatalog/consumer_test.go") + writeFile(t, filepath.Join(dir, "consumer_test.go"), fixture) + generate(false) + generate(true) + if readFile(t, filepath.Join(dir, "consumer_test.go")) != fixture { + t.Fatal("regeneration overwrote manual code") + } + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "-count=1", "./...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("consumer %v\n%s", err, output) + } + if cli != "" { + cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatal(err, string(output)) + } + var module struct { + Version, Sum string + Replace any + } + if err = json.Unmarshal(output, &module); err != nil { + t.Fatal(err) + } + if module.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || module.Replace != nil || module.Sum == "" { + t.Fatal("not a fixed remote consumer") + } + t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum) + } +} + +func TestErrorCatalogImportsAndRouteScopes(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "common.api"), `errorCatalog Numeric ( + defaultStatus: 200 + errors: {"NumberInvalid":{"key":"number.invalid","code":12,"message":"number"}} +) +errorCatalog Text ( + defaultStatus: 409 + errors: {"TextInvalid":{"key":"textInvalid","code":"000012","message":"text"}} +) +response NumericResponse ( + codeType: int32 + successCode: 0 + errorCode: 9999 +) +`) + writeFile(t, filepath.Join(dir, "service.api"), `import "common.api" +@server ( + response: NumericResponse + errors: NumberInvalid +) +service scopes-api { + @handler numeric + get /number +} +@server ( + errors: TextInvalid +) +service scopes-api { + @handler text + get /text +} +`) + s, err := Parse(filepath.Join(dir, "service.api")) + if err != nil { + t.Fatal(err) + } + compiled, err := Compile(s) + if err != nil { + t.Fatal(err) + } + doc := document(t, compiled) + for _, tc := range []struct { + path, status, name string + code any + }{{"/number", "200", "NumberInvalid", float64(12)}, {"/text", "409", "TextInvalid", "000012"}} { + response := operation(t, doc, tc.path, "get")["responses"].(map[string]any)[tc.status].(map[string]any) + value := response["content"].(map[string]any)["application/json"].(map[string]any)["examples"].(map[string]any)[tc.name].(map[string]any)["value"].(map[string]any) + if value["code"] != tc.code { + t.Fatal(value) + } + } +} diff --git a/cmd/go-omni/apigen/generate.go b/cmd/go-omni/apigen/generate.go index 2ce2a2a..5387654 100644 --- a/cmd/go-omni/apigen/generate.go +++ b/cmd/go-omni/apigen/generate.go @@ -19,7 +19,7 @@ import ( ) const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" -const minimumVersion = "v0.9.0-preview" +const minimumVersion = "v0.10.0-preview" const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n" type Options struct { @@ -140,13 +140,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) { if !filepath.IsAbs(path) { path = filepath.Join(directory, path) } - if _, err := os.Stat(filepath.Join(path, "contract", "scalar.go")); err == nil { + if _, err := os.Stat(filepath.Join(path, "errors", "catalog.go")); err == nil { localCurrent = true } } } if !localCurrent { - return Output{}, fmt.Errorf("existing framework dependency predates migration extensions; use %s or a current local replacement", minimumVersion) + return Output{}, fmt.Errorf("existing framework dependency predates error catalog support; use %s or a current local replacement", minimumVersion) } } if o.FrameworkDir != "" { @@ -312,12 +312,14 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map generated, seeds := map[string][]byte{}, map[string][]byte{} put := func(files map[string][]byte, name, content string) { files[name] = []byte(content) } if len(c.Spec.Errors) > 0 { - content := "package apierrors\nimport omnierrors " + strconv.Quote(frameworkModule+"/errors") + "\n" + content := "package apierrors\nimport(\"encoding/json\"; omnierrors " + strconv.Quote(frameworkModule+"/errors") + ")\n" + content += fmt.Sprintf("func Catalog()omnierrors.Catalog{var c omnierrors.Catalog;if err:=json.Unmarshal([]byte(%q),&c);err!=nil{panic(err)};return c}\nvar errorRegistry=omnierrors.MustRegistry(Catalog())\n", errorCatalogJSON(c.Spec, nil)) for _, e := range c.Spec.Errors { - content += fmt.Sprintf("const Code%s = %q\n// %s returns a fresh public error declared in the API contract.\nfunc %s()*omnierrors.APIError{return omnierrors.New(%d,Code%s,%q)}\n", e.Name, e.Code, e.Name, e.Name, e.Status, e.Name, e.Message) + content += fmt.Sprintf("const Code%s=%q\nfunc %s()*omnierrors.APIError{return errorRegistry.Resolve(omnierrors.ByCode(Code%s))}\n", e.Name, e.Code, e.Name, e.Name) } put(generated, "internal/apierrors/errorsGen.go", generatedHeader+content) } + types := "package types\n\n" if len(c.Spec.Scalars) > 0 { types += "import(" @@ -343,7 +345,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map put(generated, "internal/types/typesGen.go", generatedHeader+types) put(generated, "docs/openapi.json", string(c.Document)+"\n") put(generated, "internal/handler/openapiGen.go", generatedHeader+fmt.Sprintf("package handler\nimport (\"net/http\"; %q)\nconst OpenAPIDocument = %q\nfunc OpenAPI(c *httpx.Context) error { return c.WriteResponse(200,http.Header{\"Content-Type\": {\"application/json; charset=utf-8\"}},[]byte(OpenAPIDocument)) }\n", frameworkModule+"/httpx", string(c.Document)+"\n")) - policySource := fmt.Sprintf("package handler\nimport(\"encoding/json\";\"strings\";%q)\nvar responseTemplates=map[string]response.Encoder{\"\":response.Default(),\n", frameworkModule+"/response") + policySource := fmt.Sprintf("package handler\nimport(\"encoding/json\";\"strings\";%q; omnierrors %q)\nvar responseTemplates=map[string]response.Encoder{\"\":response.Default(),\n", frameworkModule+"/response", frameworkModule+"/errors") for _, definition := range c.Spec.Responses { data, err := json.Marshal(definition.Config) if err != nil { @@ -351,7 +353,8 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map } policySource += fmt.Sprintf("%q: newResponseTemplate(%q),\n", definition.Name, string(data)) } - policySource += "}\nfunc newResponseTemplate(raw string)response.Encoder{var cfg response.Config;decoder:=json.NewDecoder(strings.NewReader(raw));decoder.UseNumber();if err:=decoder.Decode(&cfg);err!=nil{panic(err)};return response.MustNew(cfg)}\nfunc responseEncoder(name string,raw bool)response.Encoder{encoder:=responseTemplates[name];if encoder==nil{panic(\"unknown response template\")};if raw{encoder=response.RawJSON(encoder)};return encoder}\n" + policySource += "}\nfunc newResponseTemplate(raw string)response.Encoder{var cfg response.Config;decoder:=json.NewDecoder(strings.NewReader(raw));decoder.UseNumber();if err:=decoder.Decode(&cfg);err!=nil{panic(err)};return response.MustNew(cfg)}\n" + policySource += "func responseEncoder(name string,raw bool,catalogJSON string)response.Encoder{encoder:=responseTemplates[name];if encoder==nil{panic(\"unknown response template\")};var catalog omnierrors.Catalog;if err:=json.Unmarshal([]byte(catalogJSON),&catalog);err!=nil{panic(err)};encoder=response.MustWithErrors(catalog,encoder);if raw{encoder=response.RawJSON(encoder)};return encoder}\n" put(generated, "internal/handler/responseGen.go", generatedHeader+policySource) imports := fmt.Sprintf("import (\"fmt\"; %q; %q; %q;", frameworkModule+"/httpx", frameworkModule+"/openapi", o.Module+"/internal/servicecontext") routes := "func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) error {\n if router==nil || deps==nil {return fmt.Errorf(\"router and dependencies required\")}\n" @@ -412,9 +415,9 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map name := upper(r.Handler) // The response policy and body reader cap are installed before App middleware. if r.MaxBodyBytes > 0 { - routes += fmt.Sprintf("httpx.HandleWithBodyLimit(router,%q,%q,%d,responseEncoder(%q,%t),h%d.%s", strings.ToUpper(r.Method), r.Path, r.MaxBodyBytes, responseName(r), r.ResponseFormat == "raw", i, name) + routes += fmt.Sprintf("httpx.HandleWithBodyLimit(router,%q,%q,%d,responseEncoder(%q,%t,%q),h%d.%s", strings.ToUpper(r.Method), r.Path, r.MaxBodyBytes, responseName(r), r.ResponseFormat == "raw", errorCatalogJSON(c.Spec, append([]string{}, r.Errors...)), i, name) } else { - routes += fmt.Sprintf("httpx.HandleWithResponse(router,%q,%q,responseEncoder(%q,%t),h%d.%s", strings.ToUpper(r.Method), r.Path, responseName(r), r.ResponseFormat == "raw", i, name) + routes += fmt.Sprintf("httpx.HandleWithResponse(router,%q,%q,responseEncoder(%q,%t,%q),h%d.%s", strings.ToUpper(r.Method), r.Path, responseName(r), r.ResponseFormat == "raw", errorCatalogJSON(c.Spec, append([]string{}, r.Errors...)), i, name) } for _, m := range r.Middleware { routes += fmt.Sprintf(",deps.Middlewares[%q]", m) diff --git a/cmd/go-omni/apigen/metadata_test.go b/cmd/go-omni/apigen/metadata_test.go index fe239a6..435714b 100644 --- a/cmd/go-omni/apigen/metadata_test.go +++ b/cmd/go-omni/apigen/metadata_test.go @@ -137,7 +137,7 @@ func TestDeclaredErrorsAndAuthentication(t *testing.T) { func TestInvalidDeclarations(t *testing.T) { replacements := [][2]string{ {`"EMAIL_TAKEN"`, `"UNAUTHENTICATED"`}, - {`EmailTaken 409`, `EmailTaken 200`}, + {`EmailTaken 409`, `EmailTaken 204`}, {`EmailTaken 409`, `emailTaken 409`}, {`NameTaken 409`, `CodeEmailTaken 409`}, {`auth: UserAuth`, `auth: Unknown`}, diff --git a/cmd/go-omni/apigen/parse.go b/cmd/go-omni/apigen/parse.go index 118a5fa..df5f563 100644 --- a/cmd/go-omni/apigen/parse.go +++ b/cmd/go-omni/apigen/parse.go @@ -16,6 +16,7 @@ import ( type Spec struct { Title, Version, Service, Description string Errors []ErrorDefinition + ErrorCatalogs []string Auth []AuthDefinition Responses []ResponseDefinition Scalars []ScalarDefinition @@ -266,6 +267,10 @@ func (p *parser) parse() error { } p.imports = append(p.imports, v) } + case "errorCatalog": + if err := p.parseErrorCatalog(); err != nil { + return err + } case "error": if err := p.parseErrors(); err != nil { return err diff --git a/cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go b/cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go new file mode 100644 index 0000000..7d67ad1 --- /dev/null +++ b/cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go @@ -0,0 +1,92 @@ +package consumer_test + +import ( + "context" + "errors" + "io" + "net/http/httptest" + "strings" + "testing" + + "example.com/errorCatalog/internal/apierrors" + "example.com/errorCatalog/internal/handler" + "example.com/errorCatalog/internal/servicecontext" + omni "git.yuntuo.hk/k2safe/go-omni" + omnierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/logx" +) + +func TestGeneratedCatalog(t *testing.T) { + first := apierrors.UsernameInvalid().WithCause(errors.New("private")) + second := apierrors.UsernameInvalid() + if first == second || second.Cause != nil || second.Status != 200 || second.BusinessCode != int64(400002) { + t.Fatal("shared or incomplete generated error") + } + copy := apierrors.Catalog() + delete(copy.Definitions, apierrors.CodeUsernameInvalid) + if len(apierrors.Catalog().Definitions) != 3 { + t.Fatal("shared catalog") + } + cfg := logx.Default() + cfg.Writer = io.Discard + logger, err := logx.New(cfg) + if err != nil { + t.Fatal(err) + } + defer logger.Close() + deps, err := servicecontext.New(context.Background(), logger) + if err != nil { + t.Fatal(err) + } + defer deps.Close() + app := omni.New() + defer app.Close() + // Global middleware exits early: generated route policy must already be active. + app.Use(func(next httpx.Handler) httpx.Handler { + return func(c *httpx.Context) error { + switch c.Header("X-Fixture") { + case "username": + return omnierrors.ByCode(apierrors.CodeUsernameInvalid).WithCause(errors.New("private")) + case "password": + return apierrors.PasswordInvalid() + case "denied": + return apierrors.PermissionDenied() + case "unknown": + return omnierrors.ByCode("MISSING") + default: + return c.OK(map[string]string{"token": "ok"}) + } + } + }) + if err := handler.RegisterRoutes(app, deps); err != nil { + t.Fatal(err) + } + for _, path := range []string{"/token", "/raw"} { + for _, test := range []struct { + kind string + status int + want string + }{{"username", 200, "用户名格式不正确"}, {"password", 200, "密码格式不正确"}, {"denied", 403, "没有权限"}, {"unknown", 500, "internal server error"}, {"", 200, `"token":"ok"`}} { + method := "POST" + if path == "/raw" { + method = "GET" + } + w := httptest.NewRecorder() + r := httptest.NewRequest(method, path, nil) + r.Header.Set("X-Fixture", test.kind) + app.ServeHTTP(w, r) + if w.Code != test.status || !strings.Contains(w.Body.String(), test.want) || strings.Contains(w.Body.String(), "private") { + t.Fatal(path, test.kind, w.Code, w.Body.String()) + } + if test.kind == "username" || test.kind == "password" { + if !strings.Contains(w.Body.String(), `"code":400002`) || !strings.Contains(w.Body.String(), `"ok":false`) { + t.Fatal(w.Body.String()) + } + } + if path == "/raw" && test.kind == "" && strings.Contains(w.Body.String(), "code") { + t.Fatal("raw success wrapped") + } + } + } +} diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go index 75a2b02..7fe5e59 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.9.1-preview" +const defaultFrameworkVersion = "v0.10.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 8081d55..ec60585 100644 --- a/config/config.go +++ b/config/config.go @@ -4,6 +4,7 @@ import ( "strings" "time" + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/response" @@ -25,18 +26,19 @@ type TLSConfig struct { // Config controls the default runtime behavior of an App. type Config struct { - Address string `json:"address"` - Transport string `json:"transport"` - TLS TLSConfig `json:"tls"` - HTTP HTTPConfig `json:"http"` - RequestIDHeader string `json:"requestIdHeader"` - EnableLogger bool `json:"enableLogger"` - SlowRequestThreshold time.Duration `json:"slowRequestThreshold"` - TrustedProxies []string `json:"trustedProxies"` - ShutdownTimeout time.Duration `json:"shutdownTimeout"` - Log logx.Config `json:"log"` - Response response.Config `json:"response"` - ResponseEncoder response.Encoder `json:"-"` + Address string `json:"address"` + Transport string `json:"transport"` + TLS TLSConfig `json:"tls"` + HTTP HTTPConfig `json:"http"` + RequestIDHeader string `json:"requestIdHeader"` + EnableLogger bool `json:"enableLogger"` + SlowRequestThreshold time.Duration `json:"slowRequestThreshold"` + TrustedProxies []string `json:"trustedProxies"` + ShutdownTimeout time.Duration `json:"shutdownTimeout"` + Log logx.Config `json:"log"` + Errors apierrors.Catalog `json:"errors"` + Response response.Config `json:"response"` + ResponseEncoder response.Encoder `json:"-"` } func Default() Config { @@ -51,6 +53,18 @@ func Default() Config { // Validate checks configuration values before an application starts. func (c Config) Validate() error { + if err := c.Errors.Validate(); err != nil { + return err + } + if c.ResponseEncoder == nil { + f, err := response.New(c.Response) + if err != nil { + return err + } + if err = f.ValidateErrors(c.Errors); err != nil { + return err + } + } if err := c.Response.Validate(); err != nil { return err } @@ -151,11 +165,13 @@ func WithTrustedProxies(proxies ...string) Option { // environment. Later options can still override individual fields. func WithConfig(settings Config) Option { settings.Response = settings.Response.Clone() + settings.Errors = settings.Errors.Clone() settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...) settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...) return func(cfg *Config) { *cfg = settings cfg.Response = settings.Response.Clone() + cfg.Errors = settings.Errors.Clone() cfg.TrustedProxies = append([]string(nil), settings.TrustedProxies...) cfg.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...) } @@ -190,3 +206,9 @@ func WithShutdownTimeout(timeout time.Duration) Option { } } } + +// WithErrors installs a project-owned, app-local catalog. +func WithErrors(catalog apierrors.Catalog) Option { + catalog = catalog.Clone() + return func(cfg *Config) { cfg.Errors = catalog.Clone() } +} diff --git a/config/env.go b/config/env.go index d777886..c67900e 100644 --- a/config/env.go +++ b/config/env.go @@ -19,6 +19,7 @@ func LoadFromEnv(prefix string) (Config, error) { // ApplyEnv overlays nonempty environment values on a caller's defaults or file // settings, then validates the result. Caller-owned slices are not retained. func ApplyEnv(defaults Config, prefix string) (Config, error) { + defaults.Errors = defaults.Errors.Clone() defaults.TrustedProxies = append([]string(nil), defaults.TrustedProxies...) defaults.Log.RedactKeys = append([]string(nil), defaults.Log.RedactKeys...) return applyEnv(defaults, prefix) diff --git a/config/errorCatalog_test.go b/config/errorCatalog_test.go new file mode 100644 index 0000000..92c6489 --- /dev/null +++ b/config/errorCatalog_test.go @@ -0,0 +1,75 @@ +package config_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.yuntuo.hk/k2safe/go-omni/config" + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/logx" +) + +func TestCatalogConfigFiles(t *testing.T) { + for _, file := range []struct{ name, body string }{ + {"server.json", `{"response":{"codeType":"int64","successCode":0,"errorCode":9999},"errors":{"defaultStatus":200,"definitions":{"LIMIT":{"code":9223372036854775807,"message":"limit"}}}}`}, + {"server.yaml", "response:\n codeType: int64\n successCode: 0\n errorCode: 9999\nerrors:\n defaultStatus: 200\n definitions:\n LIMIT:\n code: 9223372036854775807\n message: limit\n"}, + } { + t.Run(file.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), file.name) + if err := os.WriteFile(path, []byte(file.body), 0600); err != nil { + t.Fatal(err) + } + cfg, err := config.LoadFile(path, "CATALOG_FIXTURE_") + if err != nil { + t.Fatal(err) + } + e := apierrors.MustRegistry(cfg.Errors).Resolve(apierrors.ByCode("LIMIT")) + if e.Status != 200 || e.BusinessCode != int64(9223372036854775807) { + t.Fatal(e) + } + }) + } + for _, body := range []string{ + `{"errors":{"definitions":{"A":{"message":"a","code":1},"A":{"message":"b","code":2}}}}`, + `{"response":{"codeType":"int32","successCode":0,"errorCode":99},"errors":{"definitions":{"A":{"message":"a","code":2147483648}}}}`, + `{"errors":{"definitions":{"A":{"message":"a","code":"a","status":204}}}}`, + } { + path := filepath.Join(t.TempDir(), "bad.json") + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + if _, err := config.LoadFile(path, "CATALOG_FIXTURE_"); err == nil { + t.Fatal("accepted invalid catalog", body) + } + } +} + +func TestCatalogReloadSnapshotIsolation(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + original := `{"errors":{"defaultStatus":200,"definitions":{"invalid":{"code":"invalid","message":"original"}}}}` + if err := os.WriteFile(path, []byte(original), 0600); err != nil { + t.Fatal(err) + } + logger, err := logx.New(logx.Default()) + if err != nil { + t.Fatal(err) + } + defer logger.Close() + reloader, err := config.NewReloader(path, "CATALOG_RELOAD_", logger) + if err != nil { + t.Fatal(err) + } + snapshot := reloader.Snapshot() + snapshot.Errors.Definitions["invalid"] = apierrors.Definition{Message: "changed"} + if reloader.Snapshot().Errors.Definitions["invalid"].Message != "original" { + t.Fatal("snapshot changed live catalog") + } + if err := os.WriteFile(path, []byte(strings.Replace(original, "original", "new message", 1)), 0600); err != nil { + t.Fatal(err) + } + if err := reloader.Reload(); err == nil || reloader.Snapshot().Errors.Definitions["invalid"].Message != "original" { + t.Fatal("catalog changed without restart", err) + } +} diff --git a/config/file.go b/config/file.go index a130f23..c863398 100644 --- a/config/file.go +++ b/config/file.go @@ -5,8 +5,6 @@ import ( "context" "encoding/json" "errors" - "git.yuntuo.hk/k2safe/go-omni/logx" - "gopkg.in/yaml.v3" "io" "os" "path/filepath" @@ -14,6 +12,10 @@ import ( "strings" "sync" "time" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/logx" + "gopkg.in/yaml.v3" ) // DecodeFile merges a bounded JSON/YAML document into defaults. It uses JSON @@ -45,15 +47,9 @@ func DecodeFile[T any](path string, defaults T) (T, error) { return zero, errors.New("config: one YAML document required") } case ".json": - dec := json.NewDecoder(bytes.NewReader(data)) - dec.UseNumber() - if err := dec.Decode(&value); err != nil { + if err := apierrors.DecodeCatalogJSON(data, &value); err != nil { return zero, err } - var extra any - if err := dec.Decode(&extra); err != io.EOF { - return zero, errors.New("config: one JSON document required") - } default: return zero, errors.New("config: expected .json, .yaml or .yml") } @@ -182,6 +178,7 @@ func (r *Reloader) Snapshot() Config { defer r.mu.Unlock() cfg := r.current cfg.Response = r.current.Response.Clone() + cfg.Errors = r.current.Errors.Clone() cfg.TrustedProxies = append([]string(nil), cfg.TrustedProxies...) cfg.Log.RedactKeys = append([]string(nil), cfg.Log.RedactKeys...) return cfg diff --git a/errors/README.md b/errors/README.md new file mode 100644 index 0000000..a153dd8 --- /dev/null +++ b/errors/README.md @@ -0,0 +1,133 @@ +# 统一错误目录 + +从 `v0.10.0-preview` 起,业务可以只返回逻辑错误标识,框架从项目目录取得 HTTP 状态、对外业务码与公开提示。目录不依赖具体业务、数据库、身份模型或网络层,也不定义固定返回体。 + +## 基本规则 + +- `key` / `Definitions` 的 map key 是唯一逻辑标识,例如 `USERNAME_INVALID`、`user.notFound`。API 新目录允许项目自己的标识命名;旧 `error (...)` 声明保持原有大写标识规则。 +- `code` 是对外业务码,支持字符串、整数,同一个码可以由多个不同标识使用。JSON/YAML 和 `.api` 读取整数时保留 int64 精度。 +- `DefaultStatus` / `defaultStatus` 只为目录中未写状态的条目提供默认值,省略时为 400。项目可设为 200;单项 `Status` / `status` 优先。 +- 目录条目允许有响应体的最终 HTTP 状态:200..599,排除 204、205、304。成功输出与错误输出分开;HTTP 200 并不把错误变成成功。 +- 数字码类型与范围遵守选中响应的 `codeType`(string/int32/int64;integer 是旧别名)。不同路由可以选择不同类型的公共响应与目录。 +- 新目录声明完整公开提示;每条错误的业务码和提示优先于统一 `errorCode` / `errorMessage` 兜底。旧的同标识 `codes` / `messages` 若与目录冲突,初始化或契约编译报错;相同值允许保留,便于逐步迁移。 + +## 手写项目 + +```go +import ( + omni "git.yuntuo.hk/k2safe/go-omni" + omnierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/response" +) + +catalog := omnierrors.Catalog{ + DefaultStatus: 200, + Definitions: map[string]omnierrors.Definition{ + "USERNAME_INVALID": {Code: 400002, Message: "用户名格式不正确"}, + "PASSWORD_INVALID": {Code: 400002, Message: "密码格式不正确"}, + "FORBIDDEN": {Status: 403, Code: 403001, Message: "没有权限"}, + }, +} +app := omni.New( + omni.WithErrors(catalog), + omni.WithResponse(response.Config{ + CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, + MessageField: "msg", DataMode: "always", + }), +) +``` + +Service 返回 `omnierrors.ByCode("USERNAME_INVALID")`,或 `.WithCause(err)`。业务无需接收 App/HTTP Context。目录属于 App,初始化时校验并复制;后续修改原 map 不影响正在服务的应用。配置快照也不暴露内部 map。 + +`NewRegistry(catalog)` / `MustRegistry(catalog)` 可在 CLI/任务消费者等非 HTTP 入口使用;`registry.Resolve(err)` 返回独立的 `*APIError`,包含逻辑 `Code`、对外 `BusinessCode`、Status、Message 与内部 Cause。每次调用都创建新对象;不要自行将一个可变错误对象保存为共享单例再调用 WithCause。 + +原有 `New(status, key, message)` 等明确错误也能被同标识目录条目覆盖;未在目录中的明确错误沿用原状态与提示。未配置目录时,原有行为保持。Go 目录允许 `Definition.Code == nil` 以继续使用旧 Codes/errorCode 映射;新 `.api errorCatalog` 要求显式 code。 + +## 文件配置 + +下面是生成项目的 `etc/config.yaml` 结构;直接 `config.LoadFile` 加载框架配置时去掉外面的 `server`。手写 main 需显式将加载结果传入 `WithConfig`。 + +```yaml +server: + errors: + defaultStatus: 200 + definitions: + USERNAME_INVALID: + code: 400002 + message: 用户名格式不正确 + FORBIDDEN: + status: 403 + code: 403001 + message: 没有权限 + response: + codeType: int32 + successCode: 0 + errorCode: 9999 + messageField: msg + dataMode: always +``` + +重复 JSON/YAML key、未知字段、非法状态、非整数数值及类型/范围错误会拒绝加载。目录不支持运行时热更新;修改后重新启动,避免请求与文档在运行期间漂移。 + +## .api:定义一次,生成调用与文档 + +完整示例见 [errors.api](../examples/contracts/errors.api)。语法: + +```text +errorCatalog CommonErrors ( + defaultStatus: 200 + errors: { + "UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400002, "message": "用户名格式不正确"}, + "PasswordInvalid": {"key": "PASSWORD_INVALID", "code": 400002, "message": "密码格式不正确"} + } +) +``` + +目录可放进公共 `.api` 再 import;也可以拆成多个命名目录,各自设置默认状态。生成器合并定义时检查目录名、函数名、逻辑标识冲突。`Catalog` 是生成包的保留函数名。目录对象的键是生成的 UpperCamelCase 函数名,条目的 `key` 是运行时逻辑标识,二者用途不同。 + +在 `@server` 的 `errors` 或路由 `@errors` 中引用函数名,响应仍通过原有 `response` 声明选择: + +```text +@server ( + response: CommonResponse + errors: UsernameInvalid, PasswordInvalid +) +service account-api { + @handler token + post /token returns (TokenResponse) +} +``` + +生成结果包括: + +- `apierrors.UsernameInvalid()` 等无参数函数,每次返回独立错误,支持 WithCause。 +- `apierrors.CodeUsernameInvalid` 等逻辑标识常量。 +- `apierrors.Catalog()`:整个契约的可复制运行时目录,方便手写路由复用。 +- 自动绑定到生成路由的目录:只包含该路由通过 `@errors` / `@server errors` 声明的错误,在 App 全局中间件之前生效,支持认证提前返回和缓冲克隆。 +- OpenAPI 的错误响应与示例。同一 HTTP 200 下用 `anyOf` 同时描述成功 DTO 和错误体;多个错误共用数字码时保持多个示例;raw 成功体与失败公共体也分开描述。 + +业务直接 `return TokenResponse{}, apierrors.UsernameInvalid()`。也可以返回 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`;使用标识引用时,当前 App/路由目录必须包含该错误。不要把“函数已生成”视作“所有路由都已声明这个错误”:新增实际错误后仍应补相应 `@errors`,保持文档准确。 + +生成路由的目录和响应由 `.api` 管理;YAML 或 App 默认策略不覆盖它。手写路由可在 main 用 `omni.WithErrors(apierrors.Catalog())` 并选择兼容的 `WithResponse`。新项目和重复生成都会自动绑定路由目录,无需手动修改 main;已有手写文件保持不动。 + +## 特殊协议与失败信息 + +`response.WithErrors(catalog, encoder)` / `MustWithErrors` 可绑定路由专用策略,再通过 `HandleWithResponse` 注册。内置模板校验目录码类型与旧映射;自定义 Encoder 自行决定最终 HTTP 状态、媒体类型和字段结构,可将失败转换成第三方要求的文本/XML 回执。一个 Encoder 作用域不叠加不同目录;外层空目录或相同目录不会覆盖已有目录。 + +Encoder 的 `response.Result` 包含逻辑 `Code`、对外 `BusinessCode`、公开 `Message`、原始 `Err` 及 `Failed`。使用 `result.IsError()` 判断失败,不要仅判断 `Status >= 400`。`RawJSON` 仅让成功结果保持未包装,HTTP 200 的失败仍使用选定错误策略。`c.Text` / `c.XML` / `c.JSON` 等直接输出及已提交的标准 Handler 响应不会被目录重新包装。 + +启用访问日志时,记录最终写出的 HTTP 状态,并保留 `failed`、`errorKey` 和可用的 `businessCode`。内置指标将已处理的业务失败计入 Errors;链路记录保留 HTTP 200 和业务失败标记。标准中间件桥接和本地/共享幂等重放保留失败标识;已有缓存条目没有新增元数据时无法追溯识别业务失败,等待正常过期即可。 + +未知的 ByCode 标识、普通 error、panic 默认走 `INTERNAL_ERROR`,公开兜底不暴露 cause。项目可显式配置 INTERNAL_ERROR 条目,但目录 DefaultStatus 本身不会改变系统兜底。启用访问日志后,可通过原始 error 追查未知标识和内部原因。 + +## 升级 + +```sh +go get git.yuntuo.hk/k2safe/go-omni@v0.10.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.10.0-preview +go-omni api generate --dry-run +go-omni api generate +go test ./... +``` + +先升级框架依赖,再用同版 CLI 生成;新生成代码最低依赖 v0.10.0-preview。原来的错误构造函数、旧 `error (...)`、Codes/Messages 与已生成代码可继续运行。把业务中散落的状态/提示逐步替换为生成函数属于项目迁移工作,框架不会自动改写业务逻辑。 diff --git a/errors/catalog.go b/errors/catalog.go new file mode 100644 index 0000000..f04940e --- /dev/null +++ b/errors/catalog.go @@ -0,0 +1,217 @@ +package apierrors + +import ( + "bytes" + "encoding/json" + stdErrors "errors" + "fmt" + "io" + "math" + "reflect" + "strings" +) + +// Definition belongs to the project. Code is the public string/integer code; +// the Catalog map key is the distinct logical identifier used by ByCode. +// Nil Code retains legacy response code mapping (useful when migrating). +type Definition struct { + Status int `json:"status,omitempty"` + Code any `json:"code,omitempty"` + Message string `json:"message"` +} + +type Catalog struct { + DefaultStatus int `json:"defaultStatus,omitempty"` + Definitions map[string]Definition `json:"definitions"` +} + +// UnmarshalJSON preserves int64 precision and rejects ambiguous duplicate keys. +func (c *Catalog) UnmarshalJSON(data []byte) error { + type plain Catalog + value := plain(c.Clone()) + if err := DecodeCatalogJSON(data, &value); err != nil { + return err + } + *c = Catalog(value) + return nil +} + +// DecodeCatalogJSON strictly decodes catalog metadata without float conversion. +// It is also used by the API compiler so runtime and contracts agree. +func DecodeCatalogJSON(data []byte, dst any) error { + scan := json.NewDecoder(bytes.NewReader(data)) + scan.UseNumber() + var read func() error + read = func() error { + token, err := scan.Token() + if err != nil { + return err + } + if d, ok := token.(json.Delim); ok { + switch d { + case '{': + seen := map[string]bool{} + for scan.More() { + k, err := scan.Token() + if err != nil { + return err + } + key, ok := k.(string) + if !ok { + return fmt.Errorf("errors: object key required") + } + if seen[key] { + return fmt.Errorf("errors: duplicate key %q", key) + } + seen[key] = true + if err := read(); err != nil { + return err + } + } + case '[': + for scan.More() { + if err := read(); err != nil { + return err + } + } + default: + return fmt.Errorf("errors: invalid JSON delimiter") + } + _, err = scan.Token() + return err + } + return nil + } + if err := read(); err != nil { + return err + } + if _, err := scan.Token(); err != io.EOF { + return fmt.Errorf("errors: one JSON value required") + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + dec.DisallowUnknownFields() + return dec.Decode(dst) +} + +func (c Catalog) Clone() Catalog { + out := c + out.Definitions = make(map[string]Definition, len(c.Definitions)) + for key, d := range c.Definitions { + out.Definitions[key] = d + } + return out +} + +// Registry is immutable after construction and may be shared across requests. +// It has no process-global state. Every Resolve call returns a fresh error. +type Registry struct{ catalog Catalog } + +func NewRegistry(c Catalog) (*Registry, error) { + c = c.Clone() + if c.DefaultStatus == 0 { + c.DefaultStatus = 400 + } + if !ValidErrorStatus(c.DefaultStatus) { + return nil, fmt.Errorf("errors: invalid defaultStatus %d", c.DefaultStatus) + } + for key, d := range c.Definitions { + if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || strings.ContainsAny(key, "\r\n\t") { + return nil, fmt.Errorf("errors: invalid logical identifier %q", key) + } + if d.Status == 0 { + d.Status = c.DefaultStatus + } + if !ValidErrorStatus(d.Status) { + return nil, fmt.Errorf("errors: %s has invalid status %d", key, d.Status) + } + if strings.TrimSpace(d.Message) == "" { + return nil, fmt.Errorf("errors: %s requires a public message", key) + } + if d.Code != nil { + code, err := normalizeCatalogCode(d.Code) + if err != nil { + return nil, fmt.Errorf("errors: %s code: %w", key, err) + } + d.Code = code + } + c.Definitions[key] = d + } + return &Registry{catalog: c}, nil +} + +// ValidErrorStatus permits final responses with a JSON body, including HTTP 200. +func ValidErrorStatus(status int) bool { + return status >= 200 && status <= 599 && status != 204 && status != 205 && status != 304 +} + +func MustRegistry(c Catalog) *Registry { + r, err := NewRegistry(c) + if err != nil { + panic(err) + } + return r +} +func (c Catalog) Validate() error { _, err := NewRegistry(c); return err } +func (r *Registry) Catalog() Catalog { + if r == nil { + return Catalog{} + } + return r.catalog.Clone() +} + +// ByCode identifies an error without coupling business code to its HTTP policy. +// Resolution occurs in the application's/request's registry. An unknown reference +// becomes an internal error; its identifier and cause stay available to logs. +func ByCode(key string) *APIError { return &APIError{Code: key, reference: true} } + +func (r *Registry) Resolve(err error) *APIError { + if err == nil { + return nil + } + var source *APIError + if !stdErrors.As(err, &source) || source == nil { + source = Internal(err) + } + if r != nil { + if d, ok := r.catalog.Definitions[source.Code]; ok { + return &APIError{Status: d.Status, Code: source.Code, Message: d.Message, BusinessCode: d.Code, Cause: source.Cause} + } + } + if source.reference { + source = Internal(err) + if r != nil { + if d, ok := r.catalog.Definitions[source.Code]; ok { + return &APIError{Status: d.Status, Code: source.Code, Message: d.Message, BusinessCode: d.Code, Cause: err} + } + } + } + copy := *source + return © +} + +func normalizeCatalogCode(value any) (any, error) { + if v, ok := value.(string); ok { + if v == "" { + return nil, fmt.Errorf("must be nonempty") + } + return v, nil + } + if v, ok := value.(json.Number); ok { + n, err := v.Int64() + if err != nil { + return nil, fmt.Errorf("must be an exact int64") + } + return n, nil + } + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int(), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if v.Uint() <= math.MaxInt64 { + return int64(v.Uint()), nil + } + } + return nil, fmt.Errorf("must be a string or signed 64-bit integer") +} diff --git a/errors/catalog_test.go b/errors/catalog_test.go new file mode 100644 index 0000000..fad0209 --- /dev/null +++ b/errors/catalog_test.go @@ -0,0 +1,100 @@ +package apierrors_test + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "sync" + "testing" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" +) + +func TestCatalogIsolationDefaultsAndCauses(t *testing.T) { + catalog := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{ + "FIRST": {Code: 400002, Message: "first"}, "SECOND": {Code: 400002, Message: "second"}, "DENIED": {Status: 403, Code: "denied", Message: "denied"}, + }} + r, err := apierrors.NewRegistry(catalog) + if err != nil { + t.Fatal(err) + } + catalog.Definitions["FIRST"] = apierrors.Definition{Message: "changed"} + r.Catalog().Definitions["FIRST"] = apierrors.Definition{Message: "also changed"} + cause := errors.New("private secret") + var wg sync.WaitGroup + for i := 0; i < 40; i++ { + wg.Add(1) + go func() { + defer wg.Done() + ref := apierrors.ByCode("FIRST").WithCause(cause) + e := r.Resolve(fmt.Errorf("wrapped: %w", ref)) + if e.Status != 200 || e.BusinessCode != int64(400002) || e.Message != "first" || !errors.Is(e, cause) { + t.Error(e) + } + e.WithCause(errors.New("different")) + if ref.Cause != cause || r.Resolve(apierrors.ByCode("FIRST")).Cause != nil { + t.Error("shared state") + } + }() + } + wg.Wait() + if got := r.Resolve(apierrors.ByCode("SECOND")); got.Message != "second" || got.BusinessCode != int64(400002) { + t.Fatal(got) + } + if got := r.Resolve(apierrors.ByCode("DENIED")); got.Status != 403 || got.BusinessCode != "denied" { + t.Fatal(got) + } + other := apierrors.MustRegistry(apierrors.Catalog{Definitions: map[string]apierrors.Definition{"FIRST": {Status: 409, Code: 9, Message: "other"}}}) + if got := other.Resolve(apierrors.ByCode("FIRST")); got.Status != 409 || got.Message != "other" { + t.Fatal(got) + } + if got := r.Resolve(apierrors.ByCode("MISSING").WithCause(cause)); got.Status != 500 || got.Code != "INTERNAL_ERROR" || got.Message == cause.Error() || !errors.Is(got, cause) { + t.Fatal(got) + } + if got := apierrors.From(apierrors.ByCode("MISSING")); got.Status != 500 { + t.Fatal(got) + } + legacy := apierrors.New(422, "OLD", "legacy") + if got := r.Resolve(legacy); got == legacy || got.Status != 422 || got.Message != "legacy" { + t.Fatal(got) + } +} + +func TestCatalogStrictJSONAndValues(t *testing.T) { + raw := `{"defaultStatus":200,"definitions":{"LARGE":{"code":9223372036854775807,"message":"large"}}}` + var c apierrors.Catalog + if err := json.Unmarshal([]byte(raw), &c); err != nil { + t.Fatal(err) + } + e := apierrors.MustRegistry(c).Resolve(apierrors.ByCode("LARGE")) + if e.BusinessCode != int64(math.MaxInt64) { + t.Fatal(e.BusinessCode) + } + for _, raw := range []string{ + `{"definitions":{"A":{"code":1,"message":"a"},"A":{"code":2,"message":"b"}}}`, + `{"definitions":{"A":{"code":1,"code":2,"message":"a"}}}`, + `{"definitions":{"A":{"code":1,"message":"a","typo":true}}}`, + `{"defaultStatus":204,"definitions":{}}`, + `{"definitions":{"A":{"code":1.5,"message":"a"}}}`, + `{"definitions":{"A":{"code":9223372036854775808,"message":"a"}}}`, + } { + var invalid apierrors.Catalog + err := json.Unmarshal([]byte(raw), &invalid) + if err == nil { + err = invalid.Validate() + } + if err == nil { + t.Errorf("accepted %s", raw) + } + } + for _, status := range []int{0, 99, 199, 204, 205, 304, 600} { + c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{"A": {Status: status, Code: "a", Message: "a"}}} + if status == 0 { + continue + } + if c.Validate() == nil { + t.Fatal(status) + } + } +} diff --git a/errors/errors.go b/errors/errors.go index f4b8bd6..dd06270 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -8,10 +8,12 @@ import ( // APIError is the public error shape used by handlers and middleware. // Cause is retained for logging and unwrapping, but is never serialized. type APIError struct { - Status int - Code string - Message string - Cause error + Status int + Code string + Message string + Cause error + BusinessCode any // Optional public code resolved from a project catalog. + reference bool } func (e *APIError) Error() string { @@ -68,6 +70,9 @@ func From(err error) *APIError { } var apiErr *APIError if stdErrors.As(err, &apiErr) && apiErr != nil { + if apiErr.reference { + return Internal(err) + } return apiErr } return Internal(err) diff --git a/examples/contracts/errors.api b/examples/contracts/errors.api new file mode 100644 index 0000000..5e47575 --- /dev/null +++ b/examples/contracts/errors.api @@ -0,0 +1,36 @@ +syntax = "v1" + +errorCatalog CommonErrors ( + defaultStatus: 200 + errors: { + "UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400002, "message": "用户名格式不正确"}, + "PasswordInvalid": {"key": "PASSWORD_INVALID", "code": 400002, "message": "密码格式不正确"}, + "PermissionDenied": {"key": "PERMISSION_DENIED", "status": 403, "code": 403001, "message": "没有权限"} + } +) + +response CommonResponse ( + codeType: int32 + successCode: 0 + errorCode: 9999 + body: {"ok": "${ok}", "code": "${code}", "data": "${data}"} + errorBody: {"ok": "${ok}", "code": "${code}", "msg": "${message}"} +) + +type TokenResponse { + Token string `json:"token"` +} + +@server ( + group: account + response: CommonResponse + errors: UsernameInvalid, PasswordInvalid, PermissionDenied +) +service errors-api { + @handler token + post /token returns (TokenResponse) + + @response raw + @handler raw + get /raw returns (TokenResponse) +} diff --git a/httpx/bridge.go b/httpx/bridge.go index 89a0e80..09a2d97 100644 --- a/httpx/bridge.go +++ b/httpx/bridge.go @@ -45,9 +45,17 @@ func WrapMiddleware(middleware func(http.Handler) http.Handler) Middleware { child.writer.(interface{ inheritCommit(int) }).inheritCommit(c.Status()) } defer CleanupMultipart(r) - defer func() { c.values = child.values; c.encoder = child.encoder }() - if err := next(child); err != nil && !c.Responded() { - child.WriteError(err) + defer func() { + c.values = child.values + c.encoder = child.encoder + c.errorRegistry = child.errorRegistry + c.failure = child.failure + }() + if err := next(child); err != nil { + child.RecordError(err) + if !c.Responded() { + child.WriteError(err) + } } })) if wrapped == nil { diff --git a/httpx/context.go b/httpx/context.go index adf884b..a0f1caf 100644 --- a/httpx/context.go +++ b/httpx/context.go @@ -88,13 +88,15 @@ func RoutePattern(ctx context.Context) string { // only standard-library HTTP types, so it remains usable when the network // implementation is replaced. type Context struct { - request *http.Request - writer http.ResponseWriter - params map[string]string - values map[string]any - responded bool - status int - encoder response.Encoder + request *http.Request + writer http.ResponseWriter + params map[string]string + values map[string]any + responded bool + status int + encoder response.Encoder + errorRegistry *apierrors.Registry + failure *apierrors.APIError } // NewContext creates a transport-neutral GoOmni context. @@ -133,6 +135,7 @@ func (c *Context) Raw() http.ResponseWriter { return c.writer } func (c *Context) Clone(ctx context.Context, writer http.ResponseWriter) *Context { clone := NewContext(c.request.Clone(ctx), writer, c.params) clone.encoder = c.encoder + clone.errorRegistry = c.errorRegistry for key, value := range c.values { clone.values[key] = value } @@ -279,15 +282,43 @@ func (c *Context) WriteError(err error) { if errors.As(err, &bodyErr) { err = bodyTooLarge() } - apiErr := apierrors.From(err) + apiErr := c.ResolveError(err) + c.failure = apiErr if writeErr := c.writeResult(response.Result{ - Status: apiErr.Status, - Code: apiErr.Code, - Message: apiErr.Message, - RequestID: c.RequestID(), - Err: err, + BusinessCode: apiErr.BusinessCode, + Failed: true, + Status: apiErr.Status, + Code: apiErr.Code, + Message: apiErr.Message, + RequestID: c.RequestID(), + Err: err, }); writeErr != nil && !c.responded { // An invalid custom encoder must not recurse or leak a different envelope. _ = c.WriteResponse(http.StatusInternalServerError, nil, nil) } } + +// SetErrorRegistry sets an application fallback. Route encoders can supply their +// own registry; clones retain both policies without any process-global lookup. +func (c *Context) SetErrorRegistry(r *apierrors.Registry) { c.errorRegistry = r } +func (c *Context) ResolveError(err error) *apierrors.APIError { + registry := c.errorRegistry + if provider, ok := c.encoder.(response.ErrorRegistryProvider); ok { + if scoped := provider.ErrorRegistry(); scoped != nil { + registry = scoped + } + } + return registry.Resolve(err) +} + +// RecordError preserves failure semantics when a middleware commits buffered bytes. +func (c *Context) RecordError(err error) { c.failure = c.ResolveError(err) } + +// Failure returns a detached description of a failure already handled by Context. +func (c *Context) Failure() *apierrors.APIError { + if c.failure == nil { + return nil + } + copy := *c.failure + return © +} diff --git a/metrics/metrics.go b/metrics/metrics.go index d68e7e2..cc23f21 100644 --- a/metrics/metrics.go +++ b/metrics/metrics.go @@ -7,7 +7,6 @@ import ( "sync/atomic" "time" - apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" ) @@ -46,10 +45,10 @@ func (m *Metrics) Middleware() httpx.Middleware { err := next(ctx) status := ctx.Status() if err != nil && !ctx.Responded() { - status = apierrors.From(err).Status + status = ctx.ResolveError(err).Status } m.requests.Add(1) - if err != nil || status >= 400 { + if err != nil || ctx.Failure() != nil || status >= 400 { m.errors.Add(1) } if status >= 0 && status < len(m.statuses) { diff --git a/middleware/idempotency.go b/middleware/idempotency.go index d499a09..a65c2fc 100644 --- a/middleware/idempotency.go +++ b/middleware/idempotency.go @@ -31,6 +31,7 @@ type IdempotencyConfig struct { type replay struct { fingerprint [32]byte + errorCode string status int headers http.Header body []byte @@ -150,8 +151,12 @@ func (i *Idempotency) Middleware() httpx.Middleware { return apierrors.New(409, "IDEMPOTENCY_UNAVAILABLE", "request is in progress or its outcome cannot be replayed") } status, headers, data := saved.status, saved.headers.Clone(), append([]byte(nil), saved.body...) + errorCode := saved.errorCode i.mu.Unlock() headers.Set("Idempotency-Replayed", "true") + if errorCode != "" { + c.RecordError(apierrors.New(status, errorCode, "")) + } return c.WriteResponse(status, headers, data) } if len(i.items) >= i.config.MaxEntries { @@ -171,6 +176,9 @@ func (i *Idempotency) Middleware() httpx.Middleware { saved.failed = true saved.expires = i.now().Add(i.config.TTL) size := buffer.body.Len() + if failure := c.Failure(); failure != nil { + size += len(failure.Code) + } for k, values := range buffer.headers { size += len(k) for _, v := range values { @@ -180,6 +188,9 @@ func (i *Idempotency) Middleware() httpx.Middleware { if buffer.complete && buffer.err == nil && buffer.status < 500 && buffer.headers.Get("Set-Cookie") == "" && !strings.Contains(strings.ToLower(buffer.headers.Get("Cache-Control")), "no-store") && size <= i.config.MaxBytes-i.bytes { saved.failed = false saved.status = buffer.status + if failure := c.Failure(); failure != nil { + saved.errorCode = failure.Code + } saved.headers = buffer.headers.Clone() saved.body = append([]byte(nil), buffer.body.Bytes()...) saved.size = size @@ -202,6 +213,9 @@ func (i *Idempotency) Middleware() httpx.Middleware { if buffer.status == 0 { buffer.status = 200 } + if failure := child.Failure(); failure != nil { + c.RecordError(failure) + } buffer.complete = true return c.WriteResponse(buffer.status, buffer.headers, buffer.body.Bytes()) } diff --git a/middleware/middleware.go b/middleware/middleware.go index 025505b..79510ea 100644 --- a/middleware/middleware.go +++ b/middleware/middleware.go @@ -73,10 +73,10 @@ func AccessLog(options ...AccessLogOption) httpx.Middleware { return func(ctx *httpx.Context) error { started := time.Now() err := next(ctx) - status := ctx.Status() if err != nil && !ctx.Responded() { - status = apierrors.From(err).Status + ctx.WriteError(err) } + status := ctx.Status() duration := time.Since(started) slow := settings.slowThreshold > 0 && duration >= settings.slowThreshold attrs := []any{ @@ -87,6 +87,16 @@ func AccessLog(options ...AccessLogOption) httpx.Middleware { "durationMs", duration.Seconds() * 1000, "clientIp", ctx.ClientIP(), } + failure := ctx.Failure() + if failure == nil && err != nil { + failure = ctx.ResolveError(err) + } + if failure != nil { + attrs = append(attrs, "failed", true, "errorKey", failure.Code) + if failure.BusinessCode != nil { + attrs = append(attrs, "businessCode", failure.BusinessCode) + } + } if slow { attrs = append(attrs, "slow", true) } diff --git a/middleware/sharedIdempotency.go b/middleware/sharedIdempotency.go index 77483e5..5b8e66d 100644 --- a/middleware/sharedIdempotency.go +++ b/middleware/sharedIdempotency.go @@ -14,9 +14,10 @@ import ( ) type StoredResponse struct { - Status int `json:"status"` - Headers http.Header `json:"headers"` - Body []byte `json:"body"` + Status int `json:"status"` + ErrorCode string `json:"errorCode,omitempty"` + Headers http.Header `json:"headers"` + Body []byte `json:"body"` } type IdempotencyRecord struct { Fingerprint string `json:"fingerprint"` @@ -54,6 +55,9 @@ func (i *Idempotency) shared(c *httpx.Context, next httpx.Handler, id, fingerpri response := record.Response headers := response.Headers.Clone() headers.Set("Idempotency-Replayed", "true") + if response.ErrorCode != "" { + c.RecordError(apierrors.New(response.Status, response.ErrorCode, "")) + } return c.WriteResponse(response.Status, headers, response.Body) } completed := false @@ -84,9 +88,15 @@ func (i *Idempotency) shared(c *httpx.Context, next httpx.Handler, id, fingerpri if buffer.status == 0 { buffer.status = 200 } + if failure := child.Failure(); failure != nil { + c.RecordError(failure) + } var response *StoredResponse if buffer.status < 500 && buffer.headers.Get("Set-Cookie") == "" && !strings.Contains(strings.ToLower(buffer.headers.Get("Cache-Control")), "no-store") { response = &StoredResponse{Status: buffer.status, Headers: buffer.headers.Clone(), Body: append([]byte(nil), buffer.body.Bytes()...)} + if failure := child.Failure(); failure != nil { + response.ErrorCode = failure.Code + } } if err = finish(response); err != nil { return apierrors.New(503, "IDEMPOTENCY_UNAVAILABLE", "outcome requires reconciliation").WithCause(err) diff --git a/omni.go b/omni.go index 20a9289..74922cb 100644 --- a/omni.go +++ b/omni.go @@ -49,6 +49,8 @@ func WithRequestIDHeader(header string) Option { return config.WithRequestIDHead func WithLogger(enabled bool) Option { return config.WithLogger(enabled) } +func WithErrors(catalog apierrors.Catalog) Option { return config.WithErrors(catalog) } + func WithResponse(settings response.Config) Option { return config.WithResponse(settings) } func WithResponseEncoder(encoder response.Encoder) Option { return config.WithResponseEncoder(encoder) } diff --git a/performance/errorCatalogAcceptance.md b/performance/errorCatalogAcceptance.md new file mode 100644 index 0000000..b0b9e5c --- /dev/null +++ b/performance/errorCatalogAcceptance.md @@ -0,0 +1,32 @@ +# v0.10.0-preview 统一错误目录验收 + +## 范围 + +项目定义错误目录,框架解析逻辑标识、HTTP 状态、对外业务码及公开提示;支持 HTTP 200 的业务失败,并贯通 `.api` 生成、响应、日志、指标、链路和错误重放。没有修改 eTax/ePay 业务代码,也没有使用业务服务器压测。 + +目录与响应模板分离;不同 App、路由可选择不同目录和协议。系统兜底不受 DefaultStatus 隐式影响,自定义 Encoder 继续控制最终输出。 + +## 本地回归 + +环境:go1.27.1 darwin/arm64。验收日期:2026-09-24。 + +- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...` 全部通过,包含实际生成并编译的脚手架/契约消费者。 +- 全量之后增加配置快照隔离与 Encoder 包装策略测试,完成 `go test -race -count=1 ./response ./config ./cmd/go-omni/apigen`。 +- 本地/共享幂等错误元数据补充用例通过 `go test -race -count=1 ./app ./config ./httpx ./middleware/... ./metrics ./response ./telemetry`。 +- `go vet ./...`、`go build ./...`、gofmt、`git diff --check` 与 CLI 校验 `examples/contracts/errors.api` 通过。 + +## 专项覆盖 + +- 多 App 隔离、目录复制、配置快照隔离、静态配置变更要求重启;并发解析及 WithCause 不互相污染。 +- JSON/YAML 精确 int64、字符串/整数类型与范围、重复 key、非法状态/字段/映射冲突拒绝。重复对外业务码允许保留独立提示。 +- HTTP 200 业务失败、403 单项覆盖、未知标识 500、内部原因不进入默认公开响应、已提交响应不追加、文本 ACK 由自定义 Encoder 输出。 +- RawJSON 成功不包装、失败保持错误响应;嵌套相同/空目录保留已有策略,不同目录叠加明确拒绝。 +- 访问日志记录最终状态和业务失败;指标计入 HTTP 200 失败,链路保留状态与失败标记。标准中间件、本地/共享幂等重放保留失败语义。 +- 多文件导入、多个目录和不同码类型的路由作用域;200 成功/失败 OpenAPI anyOf、多错误同码的独立示例。 +- 实际生成项目的无参数错误函数、全局中间件提前返回时的路由目录、unknown fallback、raw 成功/失败、手写文件重生成保护。 + +## 验证边界 + +共享幂等新增用例使用 JSON 序列化的内存替身,验证字段在持久化边界上保留;未为本特性运行外部 Redis 服务。没有进行 Linux soak、代理长测或完整业务迁移验收。业务项目仍需升级框架/CLI,并逐步将散落错误改为目录调用。 + +远程固定版本验收在发布后追加,已发布标签保持不可变。 diff --git a/response/catalog_test.go b/response/catalog_test.go new file mode 100644 index 0000000..1492761 --- /dev/null +++ b/response/catalog_test.go @@ -0,0 +1,82 @@ +package response_test + +import ( + "encoding/json" + "strings" + "testing" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/response" +) + +func TestCatalogHTTP200AndRawJSON(t *testing.T) { + c := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"INVALID": {Code: 400002, Message: "invalid"}}} + cfg := response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, ErrorMessage: "fallback", Body: map[string]any{"ok": "${ok}", "code": "${code}", "data": "${data}"}, ErrorBody: map[string]any{"failed": "${ok}", "code": "${code}", "msg": "${message}"}} + enc, err := response.WithErrors(c, response.MustNew(cfg)) + if err != nil { + t.Fatal(err) + } + for _, encoder := range []response.Encoder{enc, response.RawJSON(enc)} { + p, err := encoder.Encode(response.Result{Status: 500, Err: apierrors.ByCode("INVALID")}) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if err = json.Unmarshal(p.Body, &body); err != nil { + t.Fatal(err) + } + if p.Status != 200 || body["failed"] != false || body["code"] != float64(400002) || body["msg"] != "invalid" || body["data"] != nil { + t.Fatal(string(p.Body)) + } + } + p, err := response.RawJSON(enc).Encode(response.Result{Status: 200, Data: map[string]bool{"value": true}}) + if err != nil || strings.Contains(string(p.Body), "code") { + t.Fatal(string(p.Body), err) + } + for _, change := range []response.Config{ + {CodeType: "string"}, + {CodeType: "int32", SuccessCode: 0, ErrorCode: 9, Codes: map[string]any{"INVALID": 1}}, + {CodeType: "int32", SuccessCode: 0, ErrorCode: 9, Messages: map[string]string{"INVALID": "different"}}, + } { + if _, err := response.WithErrors(c, response.MustNew(change)); err == nil { + t.Fatal("accepted conflicting catalog mapping") + } + } + enc, err = response.WithErrors(c, response.EncoderFunc(func(r response.Result) (response.Payload, error) { + if !r.IsError() || r.Status != 200 || r.BusinessCode != int64(400002) { + t.Fatal(r) + } + return response.Text(202, "ACK"), nil + })) + if err != nil { + t.Fatal(err) + } + p, err = enc.Encode(response.Result{Err: apierrors.ByCode("INVALID")}) + if err != nil || p.Status != 202 || string(p.Body) != "ACK" { + t.Fatal(p, err) + } +} + +func TestCatalogWrapperPreservesScope(t *testing.T) { + catalog := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{ + "invalid": {Code: "invalid", Message: "project message"}, + }} + inner := response.MustWithErrors(catalog, response.Default()) + for _, encoder := range []response.Encoder{inner, response.RawJSON(inner)} { + for _, outer := range []apierrors.Catalog{{}, catalog} { + bound, err := response.WithErrors(outer, encoder) + if err != nil { + t.Fatal(err) + } + payload, err := bound.Encode(response.Result{Err: apierrors.ByCode("invalid")}) + if err != nil || payload.Status != 200 || !strings.Contains(string(payload.Body), "project message") { + t.Fatal(payload, err) + } + } + other := catalog.Clone() + other.Definitions["invalid"] = apierrors.Definition{Status: 409, Code: "other", Message: "other"} + if _, err := response.WithErrors(other, encoder); err == nil { + t.Fatal("stacked conflicting policies") + } + } +} diff --git a/response/config.go b/response/config.go index c8d8707..01a34af 100644 --- a/response/config.go +++ b/response/config.go @@ -162,7 +162,7 @@ func (f *Formatter) Config() Config { 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 + isError := r.IsError() if isError { if c.ErrorCode != nil { code = c.ErrorCode @@ -184,6 +184,9 @@ func (f *Formatter) Value(r Result) map[string]any { if mapped, ok := c.Messages[r.Code]; ok { message = mapped } + if isError && r.BusinessCode != nil { + code, message = r.BusinessCode, r.Message + } body := c.Body if isError && c.ErrorBody != nil { body = c.ErrorBody @@ -206,7 +209,12 @@ func (f *Formatter) Value(r Result) map[string]any { } return value } -func (f *Formatter) Encode(r Result) (Payload, error) { return JSON(r.Status, f.Value(r)) } +func (f *Formatter) Encode(r Result) (Payload, error) { + if err := f.validateError(r); err != nil { + return Payload{}, err + } + return JSON(r.Status, f.Value(r)) +} func normalizeCode(value any, kind string) (any, error) { if kind == "string" { diff --git a/response/encoder.go b/response/encoder.go index 0341a6f..414393e 100644 --- a/response/encoder.go +++ b/response/encoder.go @@ -3,6 +3,8 @@ package response import ( "encoding/json" "net/http" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" ) // Result is the transport-independent input to an Encoder. Err is available for @@ -12,8 +14,13 @@ type Result struct { Code, Message, RequestID string Data any Err error + BusinessCode any + Failed bool } +// IsError preserves failure semantics independently of the HTTP status. +func (r Result) IsError() bool { return r.Failed || r.Err != nil || r.Status >= 400 } + // 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. @@ -47,14 +54,30 @@ func Default() Encoder { return defaultEncoder } // RawJSON leaves successful data unwrapped. Errors use the selected error // policy; callback protocols with custom acknowledgments should use EncoderFunc. +type rawJSONEncoder struct{ errors Encoder } + +func (e *rawJSONEncoder) ErrorRegistry() *apierrors.Registry { + if p, ok := e.errors.(ErrorRegistryProvider); ok { + return p.ErrorRegistry() + } + return nil +} +func (e *rawJSONEncoder) Encode(r Result) (Payload, error) { + if r.IsError() { + return e.errors.Encode(r) + } + return JSON(r.Status, r.Data) +} 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) - }) + return &rawJSONEncoder{errors: errors} +} + +func (e *rawJSONEncoder) ValidateErrors(catalog apierrors.Catalog) error { + if checker, ok := e.errors.(interface{ ValidateErrors(apierrors.Catalog) error }); ok { + return checker.ValidateErrors(catalog) + } + return nil } diff --git a/response/errors.go b/response/errors.go new file mode 100644 index 0000000..d1b071b --- /dev/null +++ b/response/errors.go @@ -0,0 +1,84 @@ +package response + +import ( + "fmt" + "reflect" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" +) + +// ErrorRegistryProvider attaches an immutable error catalog to a route policy. +// Contexts and buffered clones use it before encoding or running middleware. +type ErrorRegistryProvider interface{ ErrorRegistry() *apierrors.Registry } +type errorEncoder struct { + next Encoder + registry *apierrors.Registry +} + +func (e *errorEncoder) ErrorRegistry() *apierrors.Registry { return e.registry } +func (e *errorEncoder) Encode(r Result) (Payload, error) { + if r.Err != nil { + err := e.registry.Resolve(r.Err) + r.Status, r.Code, r.Message, r.BusinessCode, r.Failed = err.Status, err.Code, err.Message, err.BusinessCode, true + } + return e.next.Encode(r) +} + +// WithErrors binds a catalog to an encoder without imposing a response shape. +// Built-in templates are checked for incompatible code types and conflicting +// legacy mappings. Custom encoders remain responsible for their wire contract. +func WithErrors(catalog apierrors.Catalog, next Encoder) (Encoder, error) { + registry, err := apierrors.NewRegistry(catalog) + if err != nil { + return nil, err + } + if next == nil { + next = Default() + } + if provider, ok := next.(ErrorRegistryProvider); ok && provider.ErrorRegistry() != nil { + if len(catalog.Definitions) == 0 { + return next, nil + } + if reflect.DeepEqual(registry.Catalog(), provider.ErrorRegistry().Catalog()) { + return next, nil + } + return nil, fmt.Errorf("response: cannot stack different error catalogs; choose one policy per scope") + } + if checker, ok := next.(interface{ ValidateErrors(apierrors.Catalog) error }); ok { + if err := checker.ValidateErrors(registry.Catalog()); err != nil { + return nil, err + } + } + return &errorEncoder{next: next, registry: registry}, nil +} +func MustWithErrors(catalog apierrors.Catalog, next Encoder) Encoder { + e, err := WithErrors(catalog, next) + if err != nil { + panic(err) + } + return e +} +func (f *Formatter) ValidateErrors(catalog apierrors.Catalog) error { + for key, d := range catalog.Definitions { + if err := f.validateError(Result{Code: key, Message: d.Message, BusinessCode: d.Code, Failed: true}); err != nil { + return err + } + } + return nil +} +func (f *Formatter) validateError(r Result) error { + if !r.IsError() || r.BusinessCode == nil { + return nil + } + normalized, err := normalizeCode(r.BusinessCode, f.config.CodeType) + if err != nil { + return fmt.Errorf("response: catalog %s code does not match codeType %s: %w", r.Code, f.config.CodeType, err) + } + if code, ok := f.config.Codes[r.Code]; ok && !reflect.DeepEqual(code, normalized) { + return fmt.Errorf("response: conflicting catalog/code mapping for %s", r.Code) + } + if msg, ok := f.config.Messages[r.Code]; ok && msg != r.Message { + return fmt.Errorf("response: conflicting catalog/message mapping for %s", r.Code) + } + return nil +} diff --git a/scripts/verifyErrorCatalogRelease.py b/scripts/verifyErrorCatalogRelease.py new file mode 100644 index 0000000..64548f2 --- /dev/null +++ b/scripts/verifyErrorCatalogRelease.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Install the released CLI in a fresh cache, then test its generated consumer. + +The fixture tests project-owned error catalogs, HTTP 200 business failures, +route policies, generated constructors 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/errorCatalogRemote.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="goOmniErrorCatalogRelease-") 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/errors.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 error catalog generation, HTTP 200 failures and raw responses", flush=True) + output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=180s", + "./cmd/go-omni/apigen", "-run", "^TestErrorCatalogConsumer$"], 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 == ["TestErrorCatalogConsumer"], 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", "errorCatalogValidation", "generatedErrorConstructors", + "http200BusinessFailure", "duplicatePublicCodeDistinctMessages", "perErrorHTTPStatus", + "unknownErrorFallback", "causeNotExposed", "rawSuccessAndWrappedFailure", + "earlyMiddlewareCatalog", "handwrittenFilesPreserved", "generatedConsumerRaceTests", + "fixedRemoteModuleChecksum"], + "sourceHashes": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in + ("scripts/verifyErrorCatalogRelease.py", "cmd/go-omni/apigen/errorCatalog_test.go", + "examples/contracts/errors.api", + "cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go")}, + } + args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n") + print(f"PASS remote error catalog contract {args.version}; temporary files removed", flush=True) + + +if __name__ == "__main__": + main() diff --git a/telemetry/errorCatalog_test.go b/telemetry/errorCatalog_test.go new file mode 100644 index 0000000..0065ea9 --- /dev/null +++ b/telemetry/errorCatalog_test.go @@ -0,0 +1,51 @@ +package telemetry_test + +import ( + "context" + "net/http/httptest" + "testing" + + omni "git.yuntuo.hk/k2safe/go-omni" + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/response" + "git.yuntuo.hk/k2safe/go-omni/telemetry" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +func TestCatalogFailureTraceWithHTTP200(t *testing.T) { + exporter := tracetest.NewInMemoryExporter() + runtime, err := telemetry.New(telemetry.Config{Service: "fixture", SampleRatio: 1, Exporter: exporter}) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + app := omni.New(omni.WithErrors(apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"INVALID": {Code: 123, Message: "invalid"}}}), omni.WithResponse(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999})) + defer app.Close() + app.Use(runtime.Middleware()) + app.GET("/", func(c *omni.Context) error { c.WriteError(apierrors.ByCode("INVALID")); return nil }) + w := httptest.NewRecorder() + app.ServeHTTP(w, httptest.NewRequest("GET", "/", nil)) + if w.Code != 200 { + t.Fatal(w.Code) + } + if err := runtime.Flush(context.Background()); err != nil { + t.Fatal(err) + } + spans := exporter.GetSpans() + if len(spans) != 1 || spans[0].Status.Code != codes.Error { + t.Fatal(spans) + } + seenStatus, seenFailure := false, false + for _, attr := range spans[0].Attributes { + if attr.Key == "http.response.status_code" && attr.Value.AsInt64() == 200 { + seenStatus = true + } + if attr.Key == "omni.request.failed" && attr.Value.AsBool() { + seenFailure = true + } + } + if !seenStatus || !seenFailure { + t.Fatal(spans[0].Attributes) + } +} diff --git a/telemetry/telemetry.go b/telemetry/telemetry.go index b6ca4f3..e9b6e1c 100644 --- a/telemetry/telemetry.go +++ b/telemetry/telemetry.go @@ -4,7 +4,10 @@ package telemetry import ( "context" "errors" - apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "math" + "net/http" + "time" + "git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/logx" "go.opentelemetry.io/otel/attribute" @@ -15,9 +18,6 @@ import ( "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/trace" - "math" - "net/http" - "time" ) type Config struct { @@ -99,15 +99,22 @@ func (r *Runtime) Middleware() httpx.Middleware { defer func() { status := c.Status() if err != nil && !c.Responded() { - status = apierrors.From(err).Status + status = c.ResolveError(err).Status } p := recover() if p != nil { status = 500 } attrs := []attribute.KeyValue{attribute.String("http.request.method", method), attribute.String("http.route", route), attribute.Int("http.response.status_code", status)} + failure := c.Failure() + if failure == nil && err != nil { + failure = c.ResolveError(err) + } + if failure != nil { + attrs = append(attrs, attribute.Bool("omni.request.failed", true), attribute.String("omni.error.key", failure.Code)) + } span.SetAttributes(attrs...) - if status >= 500 { + if status >= 500 || (failure != nil && status < 400) { span.SetStatus(codes.Error, "request failed") } r.requests.Add(ctx, 1, metric.WithAttributes(attrs...))