diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9a7d7ed..a7672b1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,4 +1,4 @@ -# 通用业务开发指南(v0.15.0-preview) +# 通用业务开发指南(v0.16.0-preview) 从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。 @@ -21,12 +21,17 @@ delete /item | 契约 | Logic 返回 | 成功数据 | | --- | --- | --- | | `returns (Item)` | `(*types.Item, error)` | 对象;nil 被视为实现错误 | -| `returns ([]Item)` | `([]types.Item, error)` | 数组;nil 统一输出 `[]` | +| `returns ([]Item)` | `([]types.Item, error)` | 对象数组;nil 统一输出 `[]` | +| `returns ([]string)` | `([]string, error)` | 字符串数组 | +| `returns ([]int64)` / `([]bool)` | `([]int64, error)` / `([]bool, error)` | 数值 / 布尔数组 | +| `returns ([]any)` | `([]any, error)` | 动态 JSON 数组,元素允许对象、数组、标量和 null | | `returns (*Item)` | `(*types.Item, error)` | 对象或 `null` | | `returns (null)` | `error` | HTTP 200 和空业务数据 | | 不写 returns | `error` | HTTP 204,无响应体 | -统一响应中的空数据是否显示,仍遵循项目模板的 `dataMode`;需要 `data:null` 就设置 `dataMode: always`。`@response raw` 输出业务数据本身,包括 `[]`、对象或 `null`。可用 `@status 201/202`;204 不能声明响应类型。根类型当前支持以上四种形式,嵌套字段继续支持既有 slice/map/pointer/scalar/any。 +统一响应中的空数据是否显示,仍遵循项目模板的 `dataMode`;需要 `data:null` 就设置 `dataMode: always`。`@response raw` 输出业务数据本身,包括 `[]`、对象或 `null`。可用 `@status 201/202`;204 不能声明响应类型。嵌套字段继续支持既有 slice/map/pointer/scalar/any。 + +基础类型数组还支持 int/int8/int16/int32、uint/uint16/uint32/uint64、float32/float64。nil 根数组都输出 `[]`;`[]any` 在 OpenAPI 中是 `type: array, items: {}`,不限制元素形状,但元素必须可被标准 JSON 编码器序列化。`[]uint8`(Go 的 byte slice)会编码为 base64,故明确拒绝;数字数组可用 `[]int32`,二进制用下载接口。当前不支持根嵌套数组或根自定义 scalar 数组。 ## 契约驱动的请求策略 @@ -113,6 +118,23 @@ duration 使用 `250ms`、`3s`;slice/map 使用 JSON,如 `[1,3,5]`、`{"retr 手写项目可使用 `config.LoadProject(path, defaults, config.EnvOptions{Prefix:"MY_APP_"})`,或分别调用 `ApplyDefaults/DecodeFile/OverlayEnv`。读取失败不修改输入默认值;诊断不会打印输入值。不要主动打印含密钥的整个配置,或把底层解析错误展开到公开响应。Validate 不打开日志文件、数据库或存储连接;证书的可读性和握手信任在依赖初始化阶段验证。 +### 一次覆盖完整配置与 HTTP/日志短名称 + +```go +cfg, err = config.OverlayEnv(cfg, config.EnvOptions{ + Prefix: "MY_APP_", + Empty: "reject", // allow / ignore / reject,项目自行选择 + Aliases: config.ServerEnvAliases("MY_APP_", "server"), + // Lookup 可注入测试或项目配置源;默认 os.LookupEnv。 +}) +``` + +不要再紧接着调用 `ApplyEnv(cfg.Server, ...)`。上面的调用同时处理完整配置和 HTTP_ADDR、SERVICE、LOG_LEVEL 等框架短名称。例如 `MY_APP_SERVER_ADDRESS` 与 `MY_APP_HTTP_ADDR` 指向同一字段,**完整名称优先**;未设置时才读取短名称。完整名称显式设空仍优先:allow 交给该字段解析(string 可清空;数字等仍要求合法值),ignore 保留文件/默认值,reject 报错。不会因 ignore 再退回别名。字段 `env` 标签的 empty 规则优先于全局规则。 + +自定义别名用 `EnvOptions.Aliases`,键是完整环境名、值是按优先级排列的别名数组。两种名称都使用同一 Lookup、空值策略和类型转换;布尔 false、数值 0 与未设置不同。集合始终使用 JSON,包括 `MY_APP_TRUSTED_PROXIES='["127.0.0.1"]'`。 + +新项目模板一次调用 OverlayEnv,默认 `Empty: "ignore"`。旧 `ApplyEnv` 保留原有忽略空值、修剪空白及 TRUSTED_PROXIES 逗号列表行为。已有项目的 `internal/config` 属于开发者文件,升级不覆盖它;按上例合并原来的两次覆盖调用后,保留项目自己的 Validate。日志输出的显式完整名称也优先于短名称。 + ## 依赖、日志与中间件 `internal/svc/runtimeGen.go` 持续由生成器维护。项目的 ServiceContext 嵌入 `*lifecycle.Runtime`,只在 `Init(ctx)` 中装配额外依赖: @@ -185,21 +207,9 @@ go-omni api generate --check 格式化保留注释、token 和多行字面量,规范四空格缩进、空行、行首尾空白及相邻单行字段的类型/标签/行尾注释对齐;结果幂等。先校验完整契约,check 不写文件;导入的共享目录不被某个服务的格式化命令修改。DTO 按源 `.api` 输出 `dto<摘要>Gen.go`,都属于 `types` 包,跨文件引用无需改包名。 -Go Catalog 是错误的唯一来源时,显式导出文档元数据: +Go Catalog 是错误的唯一来源时,优先使用 workspace 的显式导出配置,不再维护导出/复制脚本。AppOptions 仍通过 `omni.WithErrors(apierrors.Catalog())` 注册同一个 Go 函数,运行期只注册一次。单个项目也可继续使用 `errors.ExportCatalog` 导出入口目录的 `api/omni.errors.json`;此文件和下面的共享 source 二选一。 -```go -// 在项目自己的导出命令中;appErrors.Catalog 与 AppOptions 注册同一函数。 -if err := omnierrors.ExportCatalog(os.Stdout, appErrors.Catalog()); err != nil { - log.Fatal(err) -} -``` - -```sh -go run ./cmd/exportErrors > api/omni.errors.json -go-omni api generate -``` - -文件是派生产物,应纳入版本控制并由业务 CI 检查导出漂移。CLI 自动读取入口目录的 `omni.errors.json`,校验错误目录、合并状态/模板参数/具体示例到 OpenAPI;不会运行项目 Go,不创建另一份运行期注册器。带独立错误目录的路由继续使用自己的目录。没有路由级精确错误列表时,文档描述 App 目录的公共错误集合;特殊自定义 Encoder 的文档由项目显式扩展。 +OpenAPI 自动把有收益的重复响应、schema 和示例移到 `components.responses/schemas/examples`,通过标准 `$ref` 引用。HTTP 200 同时含成功和业务失败时保留两种 schema、业务码、模板参数示例和 data:null;业务响应内容不会改变。消费 OpenAPI 的工具需要支持标准本地引用。 ## 两个服务共享契约 @@ -241,9 +251,57 @@ go-omni workspace check --file omni.workspace.json 各服务保持独立 module;可显式填写各服务的 `api`(相对服务目录)和 `module`。所有服务预检通过后再写;每个服务独立提交文件事务,不承诺跨项目的崩溃原子性。check 汇总格式与生成漂移;业务编译/测试仍在各 module 执行。 +### 同一份 Go 错误目录供多个服务生成 + +在 workspace 清单显式声明提供目录的 Go 包和函数: + +```json +{ + "errorCatalogs": [{ + "dir": ".", + "package": "example.com/project/internal/apierrors", + "function": "Catalog", + "output": "contracts/common/errors.json" + }], + "services": [{"dir":"services/api"}, {"dir":"services/id"}] +} +``` + +`dir` 相对 workspace,必须包含 go.mod;函数签名为 `func Catalog() errors.Catalog`。允许 module 内的 internal 包。先准备 output 的父目录。生成器构建一个临时导出程序,使用 Go overlay、临时 go.mod/go.sum 和 `-mod=readonly`,不会往项目增加导出命令或修改 go.mod/go.sum。最多 16 个提供方,每个有 90 秒构建/执行总时限和 1 MiB stdout 限制。错误消息不回显程序输出。 + +只有显式运行 **workspace generate/check** 会执行清单指定的 Go 包及其依赖初始化;不要指向不信任的包。提供目录的函数应只构造目录,不连接数据库、启动服务、写 stdout 或修改项目。框架不扫描其他包,也不通过 shell 执行配置字符串。普通 api validate/export/generate 和 workspace format 只读取现有元数据。 + +各服务在自己的 `api/omni.imports.json` 引用共享 JSON: + +```json +{ + "roots": {"common":{"path":"../../../contracts/common","version":"1.0.0"}}, + "errors": { + "source": "@common/errors.json", + "include": ["USERNAME_INVALID", "INTERNAL_ERROR"], + "routes": {"GET /permissions":["INTERNAL_ERROR"]} + } +} +``` + +`include` 省略或 null 表示全部目录,`[]` 表示不列业务错误;routes 按 **大写方法 + 空格 + .api 原路径**(如 `GET /users/:id`)覆盖服务级列表。错误标识、路由拼错或重复标识会明确报错。路由已经通过 `.api @errors` 声明独立运行期目录时,不能再给它设置文档 routes 列表。 + +**这些列表只决定文档展示,不修改 App 错误目录、路由继承或运行期可返回的错误。** `.api` 不需要重复定义 Go 错误表。状态、业务码、提示和模板参数仍来自同一 Catalog;特殊自定义 Encoder 的文档由项目显式扩展。 + +```sh +# 首次生成导出一次共享目录,所有服务预检通过才写入 +# 共享 JSON 不复制到各服务;已有 api/omni.errors.json 请显式移除后改用 source。 +go-omni workspace generate --file omni.workspace.json +go-omni workspace format --file omni.workspace.json +go-omni workspace generate --file omni.workspace.json +go-omni workspace check --file omni.workspace.json +``` + +共享 JSON 应纳入版本控制。check 在内存重新导出并比较 JSON,然后检查所有服务;缺失或过期时失败,不更新项目文件。Go 构建缓存仍可能更新;显式提供方的自有副作用不属于生成器可隔离范围。业务 CI 仍执行各 module 的编译与测试。目录函数本身有变化时重新 generate,受控共享根的固定 SHA256 如需改变仍由项目显式更新。 + ## v0.13 项目升级 -先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.15.0-preview(或显式引用当前源码)。 +先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.16.0-preview(或显式引用当前源码)。 ```sh go-omni api upgrade --dir . --dry-run diff --git a/PROJECT.md b/PROJECT.md index 76ea9c7..2b287e2 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1,6 +1,6 @@ # 生成项目与文件维护边界 -v0.15.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。 +v0.16.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。 ## 创建和日常开发 @@ -85,7 +85,7 @@ func (c *ServiceContext) AppOptions() []omni.Option { } ``` -业务调用 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`,可接 `WithParams` / `WithCause`。无需在 `.api` 重复错误目录。未声明独立错误目录的路由继承 App 目录;独立目录不与 App 合并。静态 OpenAPI 不执行 Go;可从同一 Catalog 显式导出 `api/omni.errors.json`,详见 [元数据导出](DEVELOPMENT.md#字段文档格式化go-错误目录导出)。 +业务调用 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`,可接 `WithParams` / `WithCause`。无需在 `.api` 重复错误目录。未声明独立错误目录的路由继承 App 目录;独立目录不与 App 合并。普通 `.api` 编译不执行 Go;可在 workspace 显式配置 Catalog 函数,统一导出共享文档元数据,也可沿用 `api/omni.errors.json`,详见 [元数据导出](DEVELOPMENT.md#字段文档格式化go-错误目录导出)。 `GlobalMiddleware()` 提供全局中间件;`Middlewares` 映射提供 `.api` 声明的具名中间件。全部显式调用,不依赖项目 init 注册,也不要求 svc 导入 handler。框架请求链按原有次序运行。 @@ -208,7 +208,7 @@ get /download (DownloadReq) ## 已有服务接入步骤 -每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.15.0-preview: +每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.16.0-preview: 1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。 2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。 diff --git a/README.md b/README.md index 1174c70..dbed247 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,15 @@ GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。 -**从这份 README 开始接入。** 当前版本 **`v0.15.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 +**从这份 README 开始接入。** 当前版本 **`v0.16.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。 响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。 -新增通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。 +本版补齐 `returns([]string/[]int64/[]bool/[]any)`、OpenAPI 公共引用、workspace 一次导出共享 Go 错误目录,以及统一环境变量别名覆盖。详细配置与升级步骤见 [业务开发指南](DEVELOPMENT.md)。 + +已有通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。 后台组件可在 svc 登记 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭),先停止接收工作并等待收尾,再关闭共享资源;无需绑定队列或 ORM。 @@ -34,7 +36,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可 ```sh export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk" -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.15.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview export PATH="$(go env GOPATH)/bin:$PATH" go-omni --help ``` @@ -190,7 +192,7 @@ go-omni new --api catalog.api \ 在业务项目的 **go.mod 所在目录**执行: ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.15.0-preview +go get git.yuntuo.hk/k2safe/go-omni@v0.16.0-preview ``` 已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。 @@ -460,7 +462,7 @@ go test ./... `main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。 -CLI 与业务 module 固定使用 `v0.15.0-preview`。v0.14.x 项目升级依赖与 CLI 后重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 +CLI 与业务 module 固定使用 `v0.16.0-preview`。v0.14.x 项目升级依赖与 CLI 后重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 ## 按需深入 diff --git a/RELEASE.md b/RELEASE.md index 78534d4..b77b7ea 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,4 +1,27 @@ -# v0.15.0-preview +# v0.16.0-preview + +补齐通用契约与生成体验,业务响应协议继续由项目定义: + +- `returns([]string/[]int64/[]bool/[]any)` 及其他支持的基础类型数组,贯通 Logic、Handler、OpenAPI 和生成检查。nil 根数组输出 `[]`;DTO 数组、nullable、null、raw 和 204 保持原语义。字节数组明确拒绝 base64/数组歧义。 +- OpenAPI 以标准 components / `$ref` 共享重复响应、schema、示例;保留同一 HTTP 200 下的成功与失败、模板参数、数字业务码和 data:null。 +- workspace 显式配置 Go Catalog 提供方,一次导出共享 JSON,多服务通过受控根引用。支持服务/路由的文档错误列表,运行期 App 目录继承不变。check 核对导出漂移并保持项目文件不变。 +- EnvOptions.Aliases + ServerEnvAliases 统一完整配置与框架短名称的 Lookup、类型转换、空值规则。完整名称优先,显式空值不等于未设置;旧 ApplyEnv 的空值/逗号列表行为保留。 + +```sh +go get git.yuntuo.hk/k2safe/go-omni@v0.16.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview +go-omni api generate +go-omni api generate --check +go test ./... +``` + +旧项目 config/Logic/svc 继续由开发者维护,不会被重新生成覆盖。已有环境配置需按 [一次覆盖完整配置](DEVELOPMENT.md#一次覆盖完整配置与-http日志短名称) 合并 OverlayEnv 与 ApplyEnv;共享错误目录接入见 [workspace 配置](DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。已有手写权限 Handler 切换自动生成前,先迁出实现并显式移除 Manual 文件,生成器不会静默删除。 + +全量 race(含实际生成消费者)、vet、build、根入口/退出/再生成验证通过。eTax 只读契约验收:API 文档 20,751,149 → 847,171 字节,ID 文档 1,008,210 → 392,481 字节;70 个操作展开引用后与原文档完全一致。详见 [验收记录](reports/contractFollowupAcceptance.md)。这不代表已完成业务项目迁移。 + +--- + +# v0.15.0-preview(历史版本) 增加通用后台分阶段关闭:生成入口先完成 HTTP 排空,再运行全部 OnStop、全部 OnDrain,确认组件收尾后逆序释放 OnClose 资源,最后关闭日志。适用于任意后台组件;不引入 Orbit,不改变业务事务或任务一致性策略。 diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md index bc180df..b6d81fb 100644 --- a/cmd/go-omni/README.md +++ b/cmd/go-omni/README.md @@ -1,5 +1,7 @@ # go-omni CLI +v0.16 增加基础类型/动态 JSON 数组返回、OpenAPI 公共引用和 workspace Go Catalog 统一导出。见 [接入与筛选规则](../../DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。 + v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目录>|--apply]`,以及 `workspace format|generate|check --file omni.workspace.json`。详见 [通用开发指南](../../DEVELOPMENT.md)。 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。 @@ -8,7 +10,7 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目 ```sh # 仓库读取权限与 PATH 配置见根目录 README -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.15.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview go-omni new --module example.com/hello ./hello # 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在 @@ -43,14 +45,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节 ## 版本与迁移 -CLI 与框架依赖固定使用 v0.15.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: +CLI 与框架依赖固定使用 v0.16.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: ```sh # 在 go-omni 源码仓库中执行 go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello ``` -模板最低需要 v0.15.0-preview 的分阶段 Runtime 能力,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 +模板最低需要 v0.16.0-preview 的统一环境变量别名能力,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 ## 数据库迁移命令 diff --git a/cmd/go-omni/apigen/arrayResponse_test.go b/cmd/go-omni/apigen/arrayResponse_test.go new file mode 100644 index 0000000..9962b8a --- /dev/null +++ b/cmd/go-omni/apigen/arrayResponse_test.go @@ -0,0 +1,104 @@ +package apigen + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestPrimitiveArrayContracts(t *testing.T) { + for _, name := range []string{"string", "bool", "int", "int8", "int16", "int32", "int64", "uint", "uint16", "uint32", "uint64", "float32", "float64", "any"} { + t.Run(name, func(t *testing.T) { + spec, err := parseText(t, fmt.Sprintf("service arrays{\n@handler list\nget /list returns ([]%s)\n}", name)) + if err != nil { + t.Fatal(err) + } + c, err := Compile(spec) + if err != nil { + t.Fatal(err) + } + schema := operation(t, document(t, c), "/list", "get")["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any) + data := schema["properties"].(map[string]any)["data"].(map[string]any) + if data["type"] != "array" || data["items"] == nil { + t.Fatal(data) + } + if name == "any" && len(data["items"].(map[string]any)) != 0 { + t.Fatal("dynamic JSON was constrained") + } + if responseGoType("[]"+name) != "[]"+name || responseNeedsTypes("[]"+name) { + t.Fatal("wrong Go signature/import") + } + }) + } + for _, name := range []string{"uint8", "File", "Unknown", "[]string"} { + spec, err := parseText(t, fmt.Sprintf("service arrays{\n@handler list\nget /list returns ([]%s)\n}", name)) + if err == nil { + _, err = Compile(spec) + } + if err == nil { + t.Fatal("unsupported root accepted", name) + } + } +} + +func TestPrimitiveArrayConsumer(t *testing.T) { + if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" { + t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1") + } + dir := filepath.Join(t.TempDir(), "arrays") + o := Options{Dir: dir, API: filepath.Join(frameworkRoot(t), "examples/contracts/arrays.api"), Module: "example.com/arrays", FrameworkDir: frameworkRoot(t)} + if err := generateManagedTestProject(t, o); err != nil { + t.Fatal(err) + } + for _, name := range []string{"strings", "integers", "dynamic", "empty", "raw"} { + writeFile(t, filepath.Join(dir, "internal/logic/arrays/"+name+".go"), "package arrays\n") + } + writeFile(t, filepath.Join(dir, "internal/logic/arrays/values.go"), readFile(t, "testdata/arrays/values.go")) + writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/arrays/consumer_test.go")) + o.API = filepath.Join(dir, "api/arrays.api") + if err := generateManagedTestProject(t, o); err != nil { + t.Fatal(err) + } + if !strings.Contains(readFile(t, filepath.Join(dir, "internal/logic/arrays/values.go")), "企业") { + t.Fatal("logic overwritten") + } + o.Check = true + if err := generateManagedTestProject(t, o); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 90*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 out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v\n%s", err, out) + } + if os.Getenv("GO_OMNI_RELEASE_CLI") != "" { + cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + raw, err := cmd.CombinedOutput() + if err != nil { + t.Fatal(err, string(raw)) + } + var module struct { + Version, Sum string + Replace any + } + if err = json.Unmarshal(raw, &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) + } + +} diff --git a/cmd/go-omni/apigen/catalogDocument.go b/cmd/go-omni/apigen/catalogDocument.go index a18bc7d..fd28a2c 100644 --- a/cmd/go-omni/apigen/catalogDocument.go +++ b/cmd/go-omni/apigen/catalogDocument.go @@ -2,41 +2,73 @@ package apigen import ( "errors" + "fmt" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/response" "io" "os" "path/filepath" + "strings" ) const catalogDocumentFile = "omni.errors.json" -func readDocumentCatalog(root string, spec *Spec) error { - path := filepath.Join(root, catalogDocumentFile) - stat, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) { - return nil +// DocumentErrors selects documentation only. Runtime App inheritance and +// route-local @errors declarations are never changed by this metadata. +// A nil Include lists all errors; [] lists none. Routes override Include. +type DocumentErrors struct { + Source string `json:"source"` + Include []string `json:"include"` + Routes map[string][]string `json:"routes,omitempty"` +} + +func readDocumentCatalog(root string, spec *Spec, overrides map[string][]byte) error { + name := catalogDocumentFile + path := filepath.Join(root, name) + // Shared metadata uses the same explicitly authorized roots as .api imports. + + if selection := spec.DocumentErrors; selection != nil { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + return errors.New("api: use either local omni.errors.json or shared errors.source, not both") + } + name = selection.Source + alias, sub, ok := strings.Cut(strings.TrimPrefix(name, "@"), "/") + declared, found := spec.SharedRoots[alias] + if !strings.HasPrefix(name, "@") || !ok || !found || sub == "" || filepath.Ext(sub) != ".json" { + return errors.New("api: errors.source must name a JSON file in a declared @root") + } + if err := safePath(declared.Path, sub); err != nil { + return err + } + path = filepath.Join(declared.Path, filepath.FromSlash(sub)) } - if err != nil { - return err - } - if !stat.Mode().IsRegular() { - return errors.New("api: error metadata must be a regular file") - } - file, err := os.Open(path) - if err != nil { - return err - } - defer file.Close() - raw, err := io.ReadAll(io.LimitReader(file, (1<<20)+1)) - if err != nil { - return err + raw, ok := overrides[path] + if !ok { + stat, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) && spec.DocumentErrors == nil { + return nil + } + if err != nil { + return err + } + if !stat.Mode().IsRegular() { + return errors.New("api: error metadata must be a regular file") + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + raw, err = io.ReadAll(io.LimitReader(f, (1<<20)+1)) + if err != nil { + return err + } } if len(raw) > 1<<20 { return errors.New("api: error metadata exceeds 1 MiB") } var catalog apierrors.Catalog - if err = apierrors.DecodeCatalogJSON(raw, &catalog); err != nil { + if err := apierrors.DecodeCatalogJSON(raw, &catalog); err != nil { return err } registry, err := apierrors.NewRegistry(catalog) @@ -45,10 +77,70 @@ func readDocumentCatalog(root string, spec *Spec) error { } normalized := registry.Catalog() spec.DocumentCatalog = &normalized - spec.Sources[catalogDocumentFile] = raw - spec.SourcePaths[catalogDocumentFile] = path + spec.Sources[name] = raw + spec.SourcePaths[name] = path + return validateDocumentSelection(spec) +} + +func documentRouteCatalog(spec *Spec, route Route) apierrors.Catalog { + catalog := spec.DocumentCatalog.Clone() + if spec.DocumentErrors == nil { + return catalog + } + include := spec.DocumentErrors.Include + if selected, ok := spec.DocumentErrors.Routes[strings.ToUpper(route.Method)+" "+route.Path]; ok { + include = selected + } + if include != nil { + selected := map[string]apierrors.Definition{} + for _, key := range include { + selected[key] = catalog.Definitions[key] + } + catalog.Definitions = selected + } + return catalog +} + +func validateDocumentSelection(spec *Spec) error { + cfg := spec.DocumentErrors + if cfg == nil { + return nil + } + validate := func(keys []string) error { + seen := map[string]bool{} + for _, key := range keys { + if _, ok := spec.DocumentCatalog.Definitions[key]; !ok { + return fmt.Errorf("api: unknown documentation error %q", key) + } + if seen[key] { + return fmt.Errorf("api: duplicate documentation error %q", key) + } + seen[key] = true + } + return nil + } + if err := validate(cfg.Include); err != nil { + return err + } + routes := map[string]Route{} + for _, r := range spec.Routes { + routes[strings.ToUpper(r.Method)+" "+r.Path] = r + } + for key, keys := range cfg.Routes { + r, ok := routes[key] + if !ok { + return fmt.Errorf("api: unknown documentation route %q", key) + } + if len(r.Errors) > 0 { + return fmt.Errorf("api: %s already declares route-local errors", key) + } + if err := validate(keys); err != nil { + return err + } + } return nil } + func validateDocumentCatalog(spec *Spec) error { if spec.DocumentCatalog == nil { return nil @@ -61,7 +153,7 @@ func validateDocumentCatalog(spec *Spec) error { if err != nil { return err } - if err = policy.ValidateErrors(*spec.DocumentCatalog); err != nil { + if err = policy.ValidateErrors(documentRouteCatalog(spec, r)); err != nil { return err } } diff --git a/cmd/go-omni/apigen/compile.go b/cmd/go-omni/apigen/compile.go index 659dfbf..8130112 100644 --- a/cmd/go-omni/apigen/compile.go +++ b/cmd/go-omni/apigen/compile.go @@ -70,7 +70,7 @@ func Compile(spec *Spec) (*Compiled, error) { resolving := map[string]bool{} var resolve func(string, int) (reflect.Type, error) var expression func(ast.Expr, int) (reflect.Type, error) - primitives := map[string]reflect.Type{"File": reflect.TypeOf(contract.File{}), "any": reflect.TypeOf((*any)(nil)).Elem(), "string": reflect.TypeOf(""), "bool": reflect.TypeOf(false), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0))} + expression = func(e ast.Expr, depth int) (reflect.Type, error) { if depth > 32 { return nil, errors.New("type nesting exceeds 32") @@ -80,7 +80,7 @@ func Compile(spec *Spec) (*Compiled, error) { if t, ok := scalarTypes[e.Name]; ok { return t, nil } - if t, ok := primitives[e.Name]; ok { + if t, ok := primitiveTypes[e.Name]; ok { return t, nil } return resolve(e.Name, depth+1) @@ -313,6 +313,7 @@ func Compile(spec *Spec) (*Compiled, error) { return nil, err } decorateDocument(doc, spec, c.Bindings) + compactDocument(doc) c.Document, err = json.MarshalIndent(doc, "", " ") return c, err } diff --git a/cmd/go-omni/apigen/contractMetadata.go b/cmd/go-omni/apigen/contractMetadata.go index 24dee16..a50a3c4 100644 --- a/cmd/go-omni/apigen/contractMetadata.go +++ b/cmd/go-omni/apigen/contractMetadata.go @@ -213,7 +213,7 @@ func decorateDocument(doc map[string]any, spec *Spec, bindings map[string]*contr if len(r.Errors) == 0 && documentRegistry != nil { registry = documentRegistry op["x-omni-error-catalog-scope"] = "app" - for key, definition := range registry.Catalog().Definitions { + for key, definition := range documentRouteCatalog(spec, r).Definitions { grouped[definition.Status] = append(grouped[definition.Status], ErrorDefinition{Name: key, Code: key, Status: definition.Status, Message: definition.Message, BusinessCode: definition.Code, Params: definition.Params}) } } diff --git a/cmd/go-omni/apigen/documentRefs.go b/cmd/go-omni/apigen/documentRefs.go new file mode 100644 index 0000000..d3036e7 --- /dev/null +++ b/cmd/go-omni/apigen/documentRefs.go @@ -0,0 +1,86 @@ +package apigen + +import "encoding/json" + +// compactDocument only replaces standard OpenAPI Reference Object positions. +// Payloads (including examples containing a literal "$ref") are never walked. +// Mixed HTTP 200 responses retain both success and error alternatives. +func compactDocument(doc map[string]any) { + type slot struct { + parent map[string]any + key string + } + components := doc["components"].(map[string]any) + var responses, schemas, examples []slot + for _, path := range doc["paths"].(map[string]any) { + for _, value := range path.(map[string]any) { + op, ok := value.(map[string]any) + if !ok { + continue + } + rs, _ := op["responses"].(map[string]any) + for status := range rs { + responses = append(responses, slot{rs, status}) + + } + } + } + factor := func(kind string, slots []slot) { + counts := map[string]int{} + for _, s := range slots { + raw, _ := json.Marshal(s.parent[s.key]) + counts[string(raw)]++ + } + shared, _ := components[kind].(map[string]any) + if shared == nil { + shared = map[string]any{} + } + for _, s := range slots { + raw, _ := json.Marshal(s.parent[s.key]) + count := counts[string(raw)] + if count < 2 { + continue + } + name := "Omni_" + digest(raw) + ref := map[string]any{"$ref": "#/components/" + kind + "/" + name} + refJSON, _ := json.Marshal(ref) + // Keep small objects inline if components would increase size. + if (count-1)*len(raw) <= count*len(refJSON)+len(name)+4 { + continue + } + shared[name] = s.parent[s.key] + s.parent[s.key] = map[string]any{"$ref": "#/components/" + kind + "/" + name} + } + if len(shared) > 0 { + components[kind] = shared + } + } + + // Share whole responses before counting inner objects. Otherwise a catalog + // copied across 100 routes can create hundreds of unnecessary components + // that only one shared response ultimately uses. + factor("responses", responses) + collectMedia := func(value any) { + resp := value.(map[string]any) + content, _ := resp["content"].(map[string]any) + for _, value := range content { + media := value.(map[string]any) + if media["schema"] != nil { + schemas = append(schemas, slot{media, "schema"}) + } + es, _ := media["examples"].(map[string]any) + for name := range es { + examples = append(examples, slot{es, name}) + } + } + } + for _, s := range responses { + collectMedia(s.parent[s.key]) + } + sharedResponses, _ := components["responses"].(map[string]any) + for _, resp := range sharedResponses { + collectMedia(resp) + } + factor("examples", examples) + factor("schemas", schemas) +} diff --git a/cmd/go-omni/apigen/documentRefs_test.go b/cmd/go-omni/apigen/documentRefs_test.go new file mode 100644 index 0000000..9e6cde2 --- /dev/null +++ b/cmd/go-omni/apigen/documentRefs_test.go @@ -0,0 +1,107 @@ +package apigen + +import ( + "encoding/json" + "fmt" + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "strings" + "testing" +) + +func TestSharedErrorDocumentReferences(t *testing.T) { + source := `@app(defaultResponse: Common) +response Common(codeType:int32;successCode:0;errorCode:9999;messageField:msg;dataMode:always) +service demo { +` + for i := 0; i < 16; i++ { + kind := "string" + if i%2 == 0 { + kind = "int64" + } + source += fmt.Sprintf("@handler list%d\nget /list%d returns ([]%s)\n", i, i, kind) + } + source += "}\n" + spec, err := parseText(t, source) + if err != nil { + t.Fatal(err) + } + catalog := apierrors.Catalog{DefaultStatus: 400, Definitions: map[string]apierrors.Definition{}} + for i := 0; i < 80; i++ { + status := 400 + if i%2 == 0 { + status = 200 + } + catalog.Definitions[fmt.Sprintf("ERROR_%d", i)] = apierrors.Definition{Status: status, Code: 400000 + i, Message: strings.Repeat("说明", 40) + "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string", Example: "企业名称"}, {Name: "max", Type: "int", Example: 100}}} + } + spec.DocumentCatalog = &catalog + compiled, err := Compile(spec) + if err != nil { + t.Fatal(err) + } + var raw map[string]any + if err = json.Unmarshal(compiled.Document, &raw); err != nil { + t.Fatal(err) + } + components := raw["components"].(map[string]any) + for _, kind := range []string{"responses", "examples"} { + if len(components[kind].(map[string]any)) == 0 { + t.Fatal("missing shared", kind) + } + } + expanded := document(t, compiled) + for i := 0; i < 16; i++ { + responses := operation(t, expanded, fmt.Sprintf("/list%d", i), "get")["responses"].(map[string]any) + for _, status := range []string{"200", "400"} { + media := responses[status].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any) + if len(media["examples"].(map[string]any)) != 40 { + t.Fatal("examples lost") + } + if status == "200" { + choices := media["schema"].(map[string]any)["anyOf"].([]any) + if len(choices) != 2 || choices[0].(map[string]any)["properties"].(map[string]any)["data"].(map[string]any)["type"] != "array" { + t.Fatal("200 success shape lost") + } + } + for _, example := range media["examples"].(map[string]any) { + value := example.(map[string]any)["value"].(map[string]any) + if _, ok := value["data"]; !ok || value["data"] != nil || !strings.Contains(value["msg"].(string), "企业名称不能超过100个字符") { + t.Fatal(value) + } + } + } + } + expandedBytes, _ := json.MarshalIndent(expanded, "", " ") + if len(compiled.Document)*5 > len(expandedBytes) { + t.Fatalf("poor dedup: %d vs %d", len(compiled.Document), len(expandedBytes)) + } + again, err := Compile(spec) + if err != nil || string(again.Document) != string(compiled.Document) { + t.Fatal("non-deterministic document", err) + } + before, _ := json.Marshal(raw) + compactDocument(raw) + data, _ := json.Marshal(raw) + if string(data) != string(before) { + t.Fatal("compaction not idempotent") + } +} + +func TestSharedSchemasAndLiteralExampleReference(t *testing.T) { + doc := map[string]any{"components": map[string]any{}, "paths": map[string]any{}} + paths := doc["paths"].(map[string]any) + for _, name := range []string{"first", "second", "third"} { + schema := map[string]any{"type": "object", "description": strings.Repeat("schema description ", 40)} + example := map[string]any{"summary": strings.Repeat("example ", 50), "value": map[string]any{"$ref": "literal business field"}} + paths["/"+name] = map[string]any{"get": map[string]any{"responses": map[string]any{"200": map[string]any{"description": name, "content": map[string]any{"application/json": map[string]any{"schema": schema, "examples": map[string]any{"same": example}}}}}}} + } + compactDocument(doc) + components := doc["components"].(map[string]any) + if len(components["schemas"].(map[string]any)) != 1 { + t.Fatal("schema not shared") + } + for _, example := range components["examples"].(map[string]any) { + if example.(map[string]any)["value"].(map[string]any)["$ref"] != "literal business field" { + t.Fatal("payload reference interpreted") + } + } +} diff --git a/cmd/go-omni/apigen/generate.go b/cmd/go-omni/apigen/generate.go index c645833..42e319b 100644 --- a/cmd/go-omni/apigen/generate.go +++ b/cmd/go-omni/apigen/generate.go @@ -19,11 +19,12 @@ import ( ) const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" -const minimumVersion = "v0.15.0-preview" +const minimumVersion = "v0.16.0-preview" const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n" type Options struct { lockHeld bool + documentCatalogs map[string][]byte Database, Storage string API, Dir, Module, FrameworkDir, FrameworkVersion, LogOutput, LogFormat string Check bool @@ -48,7 +49,7 @@ func Generate(o Options) (Output, error) { if o.API == "" || o.Dir == "" { return Output{}, errors.New("--api and --dir are required") } - spec, err := Parse(o.API) + spec, err := parseWithCatalogs(o.API, o.documentCatalogs) if err != nil { return Output{}, err } @@ -145,13 +146,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, "lifecycle", "runtime.go")); err == nil { + if source, err := os.ReadFile(filepath.Join(path, "config", "env.go")); err == nil && strings.Contains(string(source), "func ServerEnvAliases(") { localCurrent = true } } } if !localCurrent { - return Output{}, fmt.Errorf("existing framework dependency predates the generated phased lifecycle support; use %s or a current local replacement", minimumVersion) + return Output{}, fmt.Errorf("existing framework dependency predates the generated configuration helpers; use %s or a current local replacement", minimumVersion) } } if o.FrameworkDir != "" { @@ -454,7 +455,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map continue } hasAutomatic = true - needsTypes = needsTypes || r.Request != "" || responseGoType(r.Response) != "" + needsTypes = needsTypes || r.Request != "" || responseNeedsTypes(r.Response) needsBinder = needsBinder || !r.CustomBinding } text += fmt.Sprintf("%q;%q;", frameworkModule+"/httpx", o.Module+"/internal/svc") @@ -570,7 +571,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map } } service := fmt.Sprintf("package %s\nimport(\"context\"; apierrors %q;", packageName, frameworkModule+"/errors") - if r.Request != "" || responseGoType(r.Response) != "" { + if r.Request != "" || responseNeedsTypes(r.Response) { service += fmt.Sprintf("%q;", o.Module+"/internal/types") } if streamingBody || r.Produces != "" { diff --git a/cmd/go-omni/apigen/metadata_test.go b/cmd/go-omni/apigen/metadata_test.go index 936127f..3cc23b0 100644 --- a/cmd/go-omni/apigen/metadata_test.go +++ b/cmd/go-omni/apigen/metadata_test.go @@ -74,7 +74,7 @@ func document(t *testing.T, c *Compiled) map[string]any { if err := json.Unmarshal(c.Document, &doc); err != nil { t.Fatal(err) } - return doc + return expandDocumentRefs(t, doc, doc).(map[string]any) } func operation(t *testing.T, doc map[string]any, path, method string) map[string]any { t.Helper() @@ -303,3 +303,21 @@ func TestAuthMetadataWithParameterNamePrefixes(t *testing.T) { t.Fatal("auth metadata lost") } } + +// Existing semantic assertions resolve the standard local references emitted +// by the compiler. documentRefs_test additionally checks the actual references. +func expandDocumentRefs(t *testing.T, doc map[string]any, v any) any { + t.Helper() + switch v:=v.(type){ + case map[string]any: + if ref,ok:=v["$ref"].(string);ok && strings.HasPrefix(ref,"#/components/") && len(v)==1 { + var target any=doc + for _,part:=range strings.Split(strings.TrimPrefix(ref,"#/"),"/"){m,ok:=target.(map[string]any);if !ok{t.Fatal("invalid reference",ref)};target=m[part]} + if target==nil{t.Fatal("dangling reference",ref)};return expandDocumentRefs(t,doc,target) + } + out:=map[string]any{};for k,x:=range v{out[k]=expandDocumentRefs(t,doc,x)};return out + case []any: + out:=make([]any,len(v));for i,x:=range v{out[i]=expandDocumentRefs(t,doc,x)};return out + default:return v + } +} diff --git a/cmd/go-omni/apigen/parse.go b/cmd/go-omni/apigen/parse.go index 296bed8..1b0f1f4 100644 --- a/cmd/go-omni/apigen/parse.go +++ b/cmd/go-omni/apigen/parse.go @@ -17,6 +17,7 @@ import ( type Spec struct { DocumentCatalog *apierrors.Catalog + DocumentErrors *DocumentErrors SharedRoots map[string]SharedRoot SourcePaths map[string]string RequestPolicy contract.RequestPolicy @@ -72,7 +73,9 @@ type parser struct { imports []string } -func Parse(path string) (*Spec, error) { +func Parse(path string) (*Spec, error) { return parseWithCatalogs(path, nil) } + +func parseWithCatalogs(path string, catalogs map[string][]byte) (*Spec, error) { entry, err := filepath.Abs(path) if err != nil { return nil, err @@ -82,11 +85,12 @@ func Parse(path string) (*Spec, error) { return nil, err } root := filepath.Dir(entry) - roots, err := readSharedRoots(root) + shared, err := readSharedConfig(root) if err != nil { return nil, err } - spec := &Spec{Version: "1.0.0", SharedRoots: roots, SourcePaths: map[string]string{}, Sources: map[string][]byte{}, Entry: filepath.Base(entry)} + roots := shared.Roots + spec := &Spec{Version: "1.0.0", DocumentErrors: shared.Errors, SharedRoots: roots, SourcePaths: map[string]string{}, Sources: map[string][]byte{}, Entry: filepath.Base(entry)} visiting, visited := map[string]bool{}, map[string]bool{} total := 0 var visit func(string, string, int) error @@ -169,7 +173,7 @@ func Parse(path string) (*Spec, error) { if err = visit(entry, "", 0); err != nil { return nil, err } - if err = readDocumentCatalog(root, spec); err != nil { + if err = readDocumentCatalog(root, spec, catalogs); err != nil { return nil, err } if err = validateSharedPins(spec); err != nil { diff --git a/cmd/go-omni/apigen/profile_test.go b/cmd/go-omni/apigen/profile_test.go index 9b022c3..571733b 100644 --- a/cmd/go-omni/apigen/profile_test.go +++ b/cmd/go-omni/apigen/profile_test.go @@ -73,6 +73,8 @@ func TestConfiguration(t *testing.T){ cfg,err:=Load("../../etc/config.yaml");if err!=nil{t.Fatal(err)} if cfg.Server.Log.File==""{t.Fatal("file logger absent")} t.Setenv("GO_OMNI_LOG_OUTPUT","stdout");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!=""{t.Fatalf("override: %v",err)} + t.Setenv("GO_OMNI_SERVER_LOG_FILE","./explicit.log");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!="./explicit.log"{t.Fatalf("canonical log file: %v",err)} + t.Setenv("GO_OMNI_SERVER_LOG_OUTPUT","stderr");cfg,err=Load("");if err!=nil||cfg.Server.Log.Output!="stderr"{t.Fatalf("canonical output: %v",err)} _=os.Getenv("unused") }` endpoint := "" diff --git a/cmd/go-omni/apigen/responseShape.go b/cmd/go-omni/apigen/responseShape.go index c7cea59..cf23a15 100644 --- a/cmd/go-omni/apigen/responseShape.go +++ b/cmd/go-omni/apigen/responseShape.go @@ -4,11 +4,12 @@ import ( "fmt" "git.yuntuo.hk/k2safe/go-omni/contract" "git.yuntuo.hk/k2safe/go-omni/openapi" + "reflect" "strings" ) -// Response roots deliberately allow named DTOs, []DTO, *DTO and null. Keeping -// these four shapes explicit makes nil/empty behavior uniform across encoders. +// Response roots allow named DTOs, []DTO, []primitive, *DTO and null. +// A nil root slice is always encoded as an empty JSON array. func responseShape(value string) (kind, name string) { if value == "null" { return "null", "" @@ -27,6 +28,9 @@ func responseGoType(value string) string { return "" } if kind == "array" { + if primitiveTypes[name] != nil { + return "[]" + name + } return "[]types." + name } return "*types." + name @@ -36,9 +40,25 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op if kind == "null" { return &openapi.Schema{Type: "object", Nullable: true, Enum: []any{nil}}, nil } + if kind == "array" && primitiveTypes[name] != nil { + if name == "File" { + return nil, fmt.Errorf("File is request-only; use a response DTO") + } + // Reuse the contract compiler, including integer bounds and byte-slice rules. + t := reflect.StructOf([]reflect.StructField{{Name: "Value", Type: reflect.SliceOf(primitiveTypes[name]), Tag: `json:"value"`}}) + b, err := contract.Describe(t) + if err != nil { + return nil, err + } + s, err := b.ResponseSchema() + if err != nil { + return nil, err + } + return s.Properties["value"], nil + } b, ok := bindings[name] if !ok { - return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, *Name or null", name) + return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, []string/[]int64/[]bool/[]any, *Name or null", name) } s, err := b.ResponseSchema() if err != nil { @@ -52,3 +72,7 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op } return s, nil } + +var primitiveTypes = map[string]reflect.Type{"File": reflect.TypeOf(contract.File{}), "any": reflect.TypeOf((*any)(nil)).Elem(), "string": reflect.TypeOf(""), "bool": reflect.TypeOf(false), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0))} + +func responseNeedsTypes(value string) bool { return strings.Contains(responseGoType(value), "types.") } diff --git a/cmd/go-omni/apigen/shared.go b/cmd/go-omni/apigen/shared.go index e788fad..5d7aa62 100644 --- a/cmd/go-omni/apigen/shared.go +++ b/cmd/go-omni/apigen/shared.go @@ -23,48 +23,49 @@ type SharedRoot struct { SHA256 string `json:"sha256,omitempty"` } type sharedConfig struct { - Roots map[string]SharedRoot `json:"roots"` + Roots map[string]SharedRoot `json:"roots"` + Errors *DocumentErrors `json:"errors,omitempty"` } -func readSharedRoots(root string) (map[string]SharedRoot, error) { +func readSharedConfig(root string) (sharedConfig, error) { stat, err := os.Lstat(filepath.Join(root, importsFile)) if errors.Is(err, os.ErrNotExist) { - return nil, nil + return sharedConfig{}, nil } if err != nil { - return nil, err + return sharedConfig{}, err } if !stat.Mode().IsRegular() { - return nil, errors.New("api: shared root configuration must be a regular file") + return sharedConfig{}, errors.New("api: shared root configuration must be a regular file") } f, err := os.Open(filepath.Join(root, importsFile)) if errors.Is(err, os.ErrNotExist) { - return nil, nil + return sharedConfig{}, nil } if err != nil { - return nil, err + return sharedConfig{}, err } defer f.Close() raw, err := io.ReadAll(io.LimitReader(f, 65537)) if err != nil { - return nil, err + return sharedConfig{}, err } if len(raw) > 65536 { - return nil, errors.New("api: shared root config too large") + return sharedConfig{}, errors.New("api: shared root config too large") } var cfg sharedConfig if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil { - return nil, err + return sharedConfig{}, err } if len(cfg.Roots) > 16 { - return nil, errors.New("api: at most 16 shared roots") + return sharedConfig{}, errors.New("api: at most 16 shared roots") } for name, r := range cfg.Roots { if !lowerName.MatchString(name) || r.Path == "" || len(r.Version) > 128 { - return nil, errors.New("api: invalid shared root") + return sharedConfig{}, errors.New("api: invalid shared root") } if r.SHA256 != "" && (len(r.SHA256) != 64 || strings.Trim(r.SHA256, "0123456789abcdef") != "") { - return nil, errors.New("api: shared sha256 must be 64 lowercase hex characters") + return sharedConfig{}, errors.New("api: shared sha256 must be 64 lowercase hex characters") } path := r.Path if !filepath.IsAbs(path) { @@ -72,22 +73,22 @@ func readSharedRoots(root string) (map[string]SharedRoot, error) { } r.Path, err = filepath.EvalSymlinks(path) if err != nil { - return nil, err + return sharedConfig{}, err } r.Path, err = filepath.Abs(r.Path) if err != nil { - return nil, err + return sharedConfig{}, err } stat, err := os.Stat(r.Path) if err != nil { - return nil, err + return sharedConfig{}, err } if !stat.IsDir() { - return nil, errors.New("api: shared root must be a directory") + return sharedConfig{}, errors.New("api: shared root must be a directory") } cfg.Roots[name] = r } - return cfg.Roots, nil + return cfg, nil } func sourceWithin(root, name string) (string, error) { real, err := filepath.EvalSymlinks(name) @@ -122,7 +123,7 @@ func sourceMetadata(spec *Spec) map[string]any { return map[string]any{"files": hashes, "shared": shared} } func sharedSeeds(spec *Spec, directory string) ([]byte, error) { - cfg := sharedConfig{Roots: map[string]SharedRoot{}} + cfg := sharedConfig{Roots: map[string]SharedRoot{}, Errors: spec.DocumentErrors} for name, r := range spec.SharedRoots { rel, err := filepath.Rel(filepath.Join(directory, "api"), r.Path) if err != nil { diff --git a/cmd/go-omni/apigen/testdata/arrays/consumer_test.go b/cmd/go-omni/apigen/testdata/arrays/consumer_test.go new file mode 100644 index 0000000..28ca853 --- /dev/null +++ b/cmd/go-omni/apigen/testdata/arrays/consumer_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "encoding/json" + "example.com/arrays/internal/config" + "example.com/arrays/internal/handler" + "example.com/arrays/internal/svc" + "git.yuntuo.hk/k2safe/go-omni/logx" + "io" + "net/http/httptest" + "strings" + "testing" +) + +func TestArrayWire(t *testing.T) { + cfg := config.Default() + cfg.Server.Log.Writer = io.Discard + logger, err := logx.New(cfg.Server.Log) + if err != nil { + t.Fatal(err) + } + defer logger.Close() + deps, err := svc.New(context.Background(), logger, cfg) + if err != nil { + t.Fatal(err) + } + defer deps.Close() + app, err := handler.NewApp(deps) + if err != nil { + t.Fatal(err) + } + defer app.Close() + for path, want := range map[string]string{ + "/strings": `["企业","备注"]`, "/integers": `[9223372036854775807,-3]`, "/dynamic": `["text",7,true,null,{"name":"企业"},[false,2]]`, "/empty": `[]`, "/raw": `[true,false]`, + } { + w := httptest.NewRecorder() + app.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + got := strings.TrimSpace(w.Body.String()) + if w.Code != 200 { + t.Fatal(got) + } + if path != "/raw" { + var obj map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &obj); err != nil { + t.Fatal(err) + } + got = string(obj["data"]) + } + if got != want { + t.Fatalf("%s got %s want %s", path, got, want) + } + } +} diff --git a/cmd/go-omni/apigen/testdata/arrays/values.go b/cmd/go-omni/apigen/testdata/arrays/values.go new file mode 100644 index 0000000..727a528 --- /dev/null +++ b/cmd/go-omni/apigen/testdata/arrays/values.go @@ -0,0 +1,13 @@ +package arrays + +import "context" + +func (s *Logic) Strings(context.Context) ([]string, error) { return []string{"企业", "备注"}, nil } +func (s *Logic) Integers(context.Context) ([]int64, error) { + return []int64{9223372036854775807, -3}, nil +} +func (s *Logic) Dynamic(context.Context) ([]any, error) { + return []any{"text", 7, true, nil, map[string]any{"name": "企业"}, []any{false, 2}}, nil +} +func (s *Logic) Empty(context.Context) ([]any, error) { return nil, nil } +func (s *Logic) Raw(context.Context) ([]bool, error) { return []bool{true, false}, nil } diff --git a/cmd/go-omni/apigen/workspace.go b/cmd/go-omni/apigen/workspace.go index 39feef8..4cccc3b 100644 --- a/cmd/go-omni/apigen/workspace.go +++ b/cmd/go-omni/apigen/workspace.go @@ -16,7 +16,8 @@ type WorkspaceService struct { Module string `json:"module,omitempty"` } type WorkspaceConfig struct { - Services []WorkspaceService `json:"services"` + Services []WorkspaceService `json:"services"` + ErrorCatalogs []WorkspaceCatalog `json:"errorCatalogs,omitempty"` } // Workspace operates independent Go modules listed in an explicit local file. @@ -83,6 +84,16 @@ func Workspace(path, command string, w io.Writer) error { } options = append(options, Options{API: entry, Dir: dir, Module: service.Module, DryRun: true}) } + var catalogs map[string][]byte + if command != "format" { + catalogs, err = prepareWorkspaceCatalogs(root, cfg.ErrorCatalogs, command == "check") + if err != nil { + return err + } + for i := range options { + options[i].documentCatalogs = catalogs + } + } var failures error for _, o := range options { if command == "format" { @@ -101,6 +112,13 @@ func Workspace(path, command string, w io.Writer) error { if failures != nil { return failures } + if command == "generate" { + for path, data := range catalogs { + if err = writeWorkspaceCatalog(path, data); err != nil { + return err + } + } + } for _, o := range options { switch command { case "format": diff --git a/cmd/go-omni/apigen/workspaceCatalog.go b/cmd/go-omni/apigen/workspaceCatalog.go new file mode 100644 index 0000000..0caa5d1 --- /dev/null +++ b/cmd/go-omni/apigen/workspaceCatalog.go @@ -0,0 +1,217 @@ +package apigen + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + gotoken "go/token" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" +) + +// WorkspaceCatalog is an explicitly trusted Go provider: func() errors.Catalog. +// Only workspace generate/check execute providers. Parsing an .api never does. +type WorkspaceCatalog struct { + Dir string `json:"dir"` + Package string `json:"package"` + Function string `json:"function"` + Output string `json:"output"` +} + +type limitedCatalogBuffer struct { + bytes.Buffer + limit int +} + +func (b *limitedCatalogBuffer) Write(p []byte) (int, error) { + if len(p) > b.limit-b.Len() { + return 0, errors.New("catalog provider output exceeds limit") + } + return b.Buffer.Write(p) +} + +func exportWorkspaceCatalog(root string, cfg WorkspaceCatalog) ([]byte, error) { + if cfg.Dir == "" || cfg.Output == "" || !gotoken.IsIdentifier(cfg.Function) || !gotoken.IsExported(cfg.Function) || module.CheckImportPath(cfg.Package) != nil { + return nil, errors.New("workspace catalog requires dir, an importable package, an exported function and output") + } + if err := safePath(root, filepath.ToSlash(filepath.Join(cfg.Dir, ".goOmniExportCheck"))); err != nil { + return nil, err + } + if err := safePath(root, cfg.Output); err != nil { + return nil, err + } + if filepath.Ext(cfg.Output) != ".json" { + return nil, errors.New("workspace catalog output must be .json") + } + dir := filepath.Join(root, filepath.FromSlash(cfg.Dir)) + if stat, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil || !stat.Mode().IsRegular() { + return nil, errors.New("workspace catalog dir must contain go.mod") + } + temp, err := os.MkdirTemp("", "go-omni-catalog-*") + if err != nil { + return nil, err + } + defer os.RemoveAll(temp) + // Overlay a virtual Go file under the provider module. This allows internal + // packages without creating files or changing go.mod/go.sum in that module. + virtual := filepath.Join(dir, ".goOmniCatalogExport", "main.go") + if _, err := os.Lstat(filepath.Dir(virtual)); !errors.Is(err, os.ErrNotExist) { + return nil, errors.New("reserved catalog export path already exists") + } + source := fmt.Sprintf("package main\nimport (\"os\"; provider %q; omnierrors %q)\nfunc main(){if err:=omnierrors.ExportCatalog(os.Stdout,provider.%s());err!=nil{os.Exit(1)}}\n", cfg.Package, frameworkModule+"/errors", cfg.Function) + backing := filepath.Join(temp, "main.go") + if err = os.WriteFile(backing, []byte(source), 0600); err != nil { + return nil, err + } + overlay, _ := json.Marshal(map[string]any{"Replace": map[string]string{virtual: backing}}) + overlayPath := filepath.Join(temp, "overlay.json") + if err = os.WriteFile(overlayPath, overlay, 0600); err != nil { + return nil, err + } + // Keep all module metadata writes in the temporary directory as well. + // -mod=readonly alone must not be relied on to protect every go.sum update. + moduleData, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err != nil { + return nil, err + } + mf, err := modfile.Parse("go.mod", moduleData, nil) + if err != nil { + return nil, errors.New("workspace catalog has invalid go.mod") + } + replacements := append([]*modfile.Replace(nil), mf.Replace...) + for _, r := range replacements { + if r.New.Version == "" && !filepath.IsAbs(r.New.Path) { + if err = mf.AddReplace(r.Old.Path, r.Old.Version, filepath.Join(dir, r.New.Path), ""); err != nil { + return nil, err + } + } + } + moduleData, err = mf.Format() + if err != nil { + return nil, err + } + modPath := filepath.Join(temp, "go.mod") + if err = os.WriteFile(modPath, moduleData, 0600); err != nil { + return nil, err + } + if sums, e := os.ReadFile(filepath.Join(dir, "go.sum")); e == nil { + if err = os.WriteFile(filepath.Join(temp, "go.sum"), sums, 0600); err != nil { + return nil, err + } + } else if !errors.Is(e, os.ErrNotExist) { + return nil, e + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + executable := filepath.Join(temp, "catalogExport") + // Build first, then execute the binary directly: cancellation kills the + // provider itself instead of only terminating a go-run wrapper process. + build := exec.CommandContext(ctx, "go", "build", "-mod=readonly", "-modfile", modPath, "-overlay", overlayPath, "-o", executable, virtual) + build.Dir = dir + build.Env = append(os.Environ(), "GOWORK=off") + build.WaitDelay = 2 * time.Second + build.Stdout = &limitedCatalogBuffer{limit: 64 << 10} + build.Stderr = &limitedCatalogBuffer{limit: 64 << 10} + if err = build.Run(); err != nil { + return nil, fmt.Errorf("workspace catalog %s.%s: build failed (%v); check module dependencies and func() errors.Catalog signature", cfg.Package, cfg.Function, err) + } + cmd := exec.CommandContext(ctx, executable) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + cmd.WaitDelay = 2 * time.Second + stdout := &limitedCatalogBuffer{limit: 1 << 20} + stderr := &limitedCatalogBuffer{limit: 64 << 10} + cmd.Stdout = stdout + cmd.Stderr = stderr + if err = cmd.Run(); err != nil { + if ctx.Err() != nil { + return nil, fmt.Errorf("workspace catalog %s: %w", cfg.Package, ctx.Err()) + } + // Provider output can contain arbitrary application data. Do not echo it. + return nil, fmt.Errorf("workspace catalog %s.%s: execution failed (%v); provider must return errors.Catalog without writing stdout", cfg.Package, cfg.Function, err) + } + var catalog apierrors.Catalog + if err = apierrors.DecodeCatalogJSON(stdout.Bytes(), &catalog); err != nil { + return nil, errors.New("workspace catalog provider did not emit a valid catalog") + } + var normalized bytes.Buffer + if err = apierrors.ExportCatalog(&normalized, catalog); err != nil { + return nil, err + } + return normalized.Bytes(), nil +} + +func prepareWorkspaceCatalogs(root string, configs []WorkspaceCatalog, check bool) (map[string][]byte, error) { + if len(configs) > 16 { + return nil, errors.New("workspace supports at most 16 catalog providers") + } + result := map[string][]byte{} + for _, cfg := range configs { + if err := safePath(root, cfg.Output); err != nil { + return nil, err + } + path := filepath.Join(root, filepath.FromSlash(cfg.Output)) + for prior := range result { + if strings.EqualFold(prior, path) { + return nil, errors.New("workspace catalog outputs overlap") + } + } + data, err := exportWorkspaceCatalog(root, cfg) + if err != nil { + return nil, err + } + if check { + old, err := os.ReadFile(path) + if err != nil || !bytes.Equal(old, data) { + return nil, fmt.Errorf("workspace catalog drift: %s; run workspace generate", cfg.Output) + } + } + result[path] = data + } + return result, nil +} + +func writeWorkspaceCatalog(path string, data []byte) error { + mode := os.FileMode(0644) + if stat, err := os.Lstat(path); err == nil { + if !stat.Mode().IsRegular() { + return errors.New("workspace catalog output must be a regular file") + } + mode = stat.Mode().Perm() + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + old, err := os.ReadFile(path) + if err == nil && bytes.Equal(old, data) { + return nil + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + f, err := os.CreateTemp(filepath.Dir(path), ".omni-catalog-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if err = f.Chmod(mode); err != nil { + _ = f.Close() + return err + } + _, writeErr := io.Copy(f, bytes.NewReader(data)) + err = errors.Join(writeErr, f.Close()) + if err != nil { + return err + } + return os.Rename(f.Name(), path) +} diff --git a/cmd/go-omni/apigen/workspaceCatalog_test.go b/cmd/go-omni/apigen/workspaceCatalog_test.go new file mode 100644 index 0000000..ce2b1aa --- /dev/null +++ b/cmd/go-omni/apigen/workspaceCatalog_test.go @@ -0,0 +1,168 @@ +package apigen + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWorkspaceSharedGoCatalog(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "go.mod"), fmt.Sprintf("module example.com/catalogs\n\ngo 1.24.0\nrequire %s v0.15.0-preview\nreplace %s => %s\n", frameworkModule, frameworkModule, strconvQuote(frameworkRoot(t)))) + writeFile(t, filepath.Join(root, "internal/apierrors/catalog.go"), `package apierrors +import errors "git.yuntuo.hk/k2safe/go-omni/errors" +func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitions:map[string]errors.Definition{ +"INVALID":{Code:"INVALID",Message:"invalid input"},"MISSING":{Status:404,Code:"MISSING",Message:"missing"}, +}}} +`) + if err := os.MkdirAll(filepath.Join(root, "contracts"), 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"api", "id"} { + dir := filepath.Join(root, "services", name) + if _, err := New(Options{Dir: dir, Module: "example.com/" + name, FrameworkDir: frameworkRoot(t)}); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(dir, "api/omni.imports.json"), `{"roots":{"common":{"path":"../../../contracts"}},"errors":{"source":"@common/errors.json","include":["INVALID"],"routes":{"GET /api/greet/:name":["MISSING"]}}}`) + } + workspace := filepath.Join(root, "omni.workspace.json") + writeFile(t, workspace, `{"errorCatalogs":[{"dir":".","package":"example.com/catalogs/internal/apierrors","function":"Catalog","output":"contracts/errors.json"}],"services":[{"dir":"services/api"},{"dir":"services/id"}]}`) + if err := Workspace(workspace, "generate", io.Discard); err != nil { + t.Fatal(err) + } + if err := Workspace(workspace, "format", io.Discard); err != nil { + t.Fatal(err) + } + if err := Workspace(workspace, "generate", io.Discard); err != nil { + t.Fatal(err) + } + before := snapshot(t, root) + if err := Workspace(workspace, "check", io.Discard); err != nil { + t.Fatal(err) + } + if !sameFiles(before, snapshot(t, root)) { + t.Fatal("check wrote project files") + } + for _, name := range []string{"api", "id"} { + entry := filepath.Join(root, "services", name, "api/service.api") + spec, err := Parse(entry) + if err != nil { + t.Fatal(err) + } + c, err := Compile(spec) + if err != nil { + t.Fatal(err) + } + op := operation(t, document(t, c), "/api/greet/{name}", "get") + if op["responses"].(map[string]any)["404"] == nil || len(spec.Errors) != 0 || len(spec.Routes[0].Errors) != 0 { + t.Fatal("selection changed runtime catalog") + } + if _, err = os.Stat(filepath.Join(filepath.Dir(entry), catalogDocumentFile)); !os.IsNotExist(err) { + t.Fatal("shared catalog was copied") + } + } + shared := filepath.Join(root, "contracts/errors.json") + data := readFile(t, shared) + writeFile(t, shared, strings.Replace(data, "invalid input", "stale", 1)) + before = snapshot(t, root) + if err := Workspace(workspace, "check", io.Discard); err == nil || !strings.Contains(err.Error(), "catalog drift") { + t.Fatal(err) + } + if !sameFiles(before, snapshot(t, root)) { + t.Fatal("drift check wrote files") + } + if err := Workspace(workspace, "generate", io.Discard); err != nil { + t.Fatal(err) + } + if got := readFile(t, shared); got != data { + t.Fatal("catalog not refreshed") + } + // Failed provider/preflight may not update even the shared metadata. + source := filepath.Join(root, "internal/apierrors/catalog.go") + writeFile(t, source, readFile(t, source)+"\ninvalid go source") + before = snapshot(t, root) + if err := Workspace(workspace, "generate", io.Discard); err == nil { + t.Fatal("provider failure ignored") + } + if !sameFiles(before, snapshot(t, root)) { + t.Fatal("provider failure mutated project") + } +} + +func TestDocumentSelectionBoundaries(t *testing.T) { + root := t.TempDir() + local := filepath.Join(root, "api") + shared := filepath.Join(root, "shared") + entry := filepath.Join(local, "service.api") + writeFile(t, entry, "service demo {\n@handler list\nget /list returns ([]string)\n}\n") + writeFile(t, filepath.Join(shared, "errors.json"), `{"defaultStatus":400,"definitions":{"INVALID":{"code":400002,"message":"invalid"}}}`) + for _, cfg := range []string{ + `{"source":"@common/errors.json","include":["UNKNOWN"]}`, + `{"source":"@common/errors.json","routes":{"GET /absent":["INVALID"]}}`, + `{"source":"@common/../api/service.api"}`, + `{"source":"../shared/errors.json"}`, + } { + writeFile(t, filepath.Join(local, importsFile), `{"roots":{"common":{"path":"../shared"}},"errors":`+cfg+`}`) + if _, err := Parse(entry); err == nil { + t.Fatal("bad selection accepted", cfg) + } + } + writeFile(t, filepath.Join(local, importsFile), `{"roots":{"common":{"path":"../shared"}},"errors":{"source":"@common/errors.json","include":[]}}`) + spec, err := Parse(entry) + if err != nil { + t.Fatal(err) + } + if len(documentRouteCatalog(spec, spec.Routes[0]).Definitions) != 0 { + t.Fatal("empty include ignored") + } + seeded, err := sharedSeeds(spec, filepath.Join(root, "generated")) + if err != nil { + t.Fatal(err) + } + var cfg sharedConfig + if err = json.Unmarshal(seeded, &cfg); err != nil { + t.Fatal(err) + } + if cfg.Errors.Include == nil || !bytes.Contains(seeded, []byte(`"include": []`)) { + t.Fatal("empty selection lost during relocation") + } +} + +func TestWorkspaceCatalogPathsAndProviderValidation(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "go.mod"), "module example.com/test\ngo 1.24.0\n") + good := WorkspaceCatalog{Dir: ".", Package: "example.com/test/internal/errors", Function: "Catalog", Output: "errors.json"} + cases := []WorkspaceCatalog{} + for _, change := range []func(*WorkspaceCatalog){ + func(c *WorkspaceCatalog) { c.Dir = "../outside" }, + func(c *WorkspaceCatalog) { c.Output = "../outside.json" }, + func(c *WorkspaceCatalog) { c.Package = "example.com/pkg;bad" }, + func(c *WorkspaceCatalog) { c.Function = "Catalog()" }, + func(c *WorkspaceCatalog) { c.Output = "go.mod" }, + } { + c := good + change(&c) + cases = append(cases, c) + } + for _, c := range cases { + if _, err := exportWorkspaceCatalog(root, c); err == nil { + t.Fatal("bad provider accepted", c) + } + } + outside := filepath.Join(t.TempDir(), "errors.json") + writeFile(t, outside, `{}`) + if err := os.Symlink(outside, filepath.Join(root, "errors.json")); err != nil { + t.Fatal(err) + } + if _, err := prepareWorkspaceCatalogs(root, []WorkspaceCatalog{good}, false); err == nil { + t.Fatal("symlink output accepted") + } + if readFile(t, outside) != `{}` { + t.Fatal("outside file modified") + } +} diff --git a/cmd/go-omni/main_test.go b/cmd/go-omni/main_test.go index 894823e..c1f68dc 100644 --- a/cmd/go-omni/main_test.go +++ b/cmd/go-omni/main_test.go @@ -8,7 +8,7 @@ import ( ) func TestNewWithRemoteFrameworkVersion(t *testing.T) { - for _, flag := range [][]string{{"--framework-version", "v0.15.0-preview"}, {"--framework-version=v0.15.0-preview"}} { + for _, flag := range [][]string{{"--framework-version", "v0.16.0-preview"}, {"--framework-version=v0.16.0-preview"}} { project := filepath.Join(t.TempDir(), "billing") args := append([]string{"--module", "example.com/billing"}, flag...) if err := runNew(append(args, project)); err != nil { @@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(content), "go-omni v0.15.0-preview") || strings.Contains(string(content), "replace ") { + if !strings.Contains(string(content), "go-omni v0.16.0-preview") || strings.Contains(string(content), "replace ") { t.Fatalf("CLI did not pin remote dependency: %s", content) } } diff --git a/cmd/go-omni/scaffold/templates/api-config.go.tmpl b/cmd/go-omni/scaffold/templates/api-config.go.tmpl index 4daa7f7..8471d4f 100644 --- a/cmd/go-omni/scaffold/templates/api-config.go.tmpl +++ b/cmd/go-omni/scaffold/templates/api-config.go.tmpl @@ -1,7 +1,7 @@ package settings import ( "os" - "strings" + {{if and (not .API) (ne .Storage "none")}}"strings"{{end}} "time" "git.yuntuo.hk/k2safe/go-omni/config" "git.yuntuo.hk/k2safe/go-omni/logx" @@ -41,10 +41,10 @@ func Load(path string)(Config,error){ cfg:=Default();var err error cfg,err=config.ApplyDefaults(cfg);if err!=nil{return Config{},err} if path!="" {cfg,err=config.DecodeFile(path,cfg);if err!=nil{return Config{},err}} - cfg,err=config.OverlayEnv(cfg,config.EnvOptions{Prefix:"GO_OMNI_"});if err!=nil{return Config{},err} - // Preserve the documented framework aliases (HTTP_ADDR, LOG_LEVEL, etc). - cfg.Server,err=config.ApplyEnv(cfg.Server,"");if err!=nil{return Config{},err} - if output:=strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT"));output!="" && strings.TrimSpace(os.Getenv("GO_OMNI_LOG_FILE"))=="" {cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{}} + cfg,err=config.OverlayEnv(cfg,config.EnvOptions{Prefix:"GO_OMNI_",Empty:"ignore",Aliases:config.ServerEnvAliases("GO_OMNI_","server")});if err!=nil{return Config{},err} + output,outputSet:=os.LookupEnv("GO_OMNI_SERVER_LOG_OUTPUT");if !outputSet {output=os.Getenv("GO_OMNI_LOG_OUTPUT")} + file,fileSet:=os.LookupEnv("GO_OMNI_SERVER_LOG_FILE");if !fileSet {file=os.Getenv("GO_OMNI_LOG_FILE")} + if output!="" && file=="" {cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{}} return cfg,cfg.Validate() } func(c Config)Validate()error{ diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go index 3e09492..4c889ca 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.15.0-preview" +const defaultFrameworkVersion = "v0.16.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/aliases_test.go b/config/aliases_test.go new file mode 100644 index 0000000..5b9ac6c --- /dev/null +++ b/config/aliases_test.go @@ -0,0 +1,66 @@ +package config + +import ( + "strings" + "testing" + "time" +) + +func TestUnifiedEnvAliases(t *testing.T) { + type project struct { + Server Config `json:"server"` + Label string `json:"label"` + } + base := project{Server: Default(), Label: "base"} + for _, policy := range []string{"allow", "ignore", "reject"} { + t.Run(policy, func(t *testing.T) { + env := map[string]string{"TEST_HTTP_ADDR": "127.0.0.1:8099", "TEST_LOG_SERVICE": "", "TEST_SERVICE": "", "TEST_LABEL": ""} + opts := EnvOptions{Prefix: "TEST_", Empty: policy, Aliases: ServerEnvAliases("TEST_", "server"), Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }} + got, err := OverlayEnv(base, opts) + if policy == "reject" { + if err == nil || !strings.Contains(err.Error(), "empty") { + t.Fatal(err) + } + return + } + if err != nil { + t.Fatal(err) + } + if got.Server.Address != "127.0.0.1:8099" { + t.Fatal("alias ignored") + } + if policy == "allow" && got.Label != "" || policy == "ignore" && got.Label != "base" { + t.Fatal("empty policy differs") + } + env["TEST_SERVER_ADDRESS"] = "127.0.0.1:9001" + env["TEST_HTTP_READ_TIMEOUT"] = "3s" + env["TEST_ENABLE_LOGGER"] = "false" + env["TEST_TRUSTED_PROXIES"] = `["127.0.0.1"]` + got, err = OverlayEnv(base, opts) + if err != nil || got.Server.Address != "127.0.0.1:9001" || got.Server.HTTP.ReadTimeout != 3*time.Second || got.Server.EnableLogger || len(got.Server.TrustedProxies) != 1 { + t.Fatal(got, err) + } + env["TEST_SERVER_ADDRESS"] = "" + got, err = OverlayEnv(base, opts) + if err != nil { + t.Fatal(err) + } + expected := "" + if policy == "ignore" { + expected = base.Server.Address + } + if got.Server.Address != expected { + t.Fatal("empty canonical unexpectedly fell through to alias") + } + }) + } + if base.Server.Address != ":8080" || base.Label != "base" { + t.Fatal("defaults mutated") + } +} +func TestAliasTypeErrorsDoNotExposeValue(t *testing.T) { + opts := EnvOptions{Prefix: "X_", Empty: "reject", Aliases: ServerEnvAliases("X_", ""), Lookup: func(k string) (string, bool) { return "private-invalid", k == "X_HTTP_READ_TIMEOUT" }} + if _, err := OverlayEnv(Default(), opts); err == nil || strings.Contains(err.Error(), "private-invalid") { + t.Fatal(err) + } +} diff --git a/config/env.go b/config/env.go index 9412d05..f0cce01 100644 --- a/config/env.go +++ b/config/env.go @@ -1,138 +1,83 @@ package config import ( - "fmt" + "encoding/json" "os" - "strconv" "strings" - "time" ) const defaultEnvPrefix = "GO_OMNI_" -// LoadFromEnv loads application settings from GO_OMNI_* variables. The -// database settings remain owned by database.ConfigFromEnv. -func LoadFromEnv(prefix string) (Config, error) { - return applyEnv(Default(), prefix) +// ServerEnvAliases returns the documented short names for Config fields. +// field is the JSON path of Config in a project (usually "server"), or "" +// for Config itself. Canonical names win over aliases. Both use the same +// EnvOptions.Empty/Lookup and typed parser; collections use JSON syntax. +func ServerEnvAliases(prefix, field string) map[string][]string { + root := prefix + if field != "" { + for _, part := range strings.Split(field, ".") { + root += envName(part) + "_" + } + } + names := map[string]string{ + "ADDRESS": "HTTP_ADDR", "TRANSPORT": "TRANSPORT", "TLS_CERT_FILE": "TLS_CERT_FILE", "TLS_KEY_FILE": "TLS_KEY_FILE", + "REQUEST_ID_HEADER": "REQUEST_ID_HEADER", "ENABLE_LOGGER": "ENABLE_LOGGER", "SLOW_REQUEST_THRESHOLD": "SLOW_REQUEST_THRESHOLD", + "TRUSTED_PROXIES": "TRUSTED_PROXIES", "SHUTDOWN_TIMEOUT": "SHUTDOWN_TIMEOUT", + "LOG_LEVEL": "LOG_LEVEL", "LOG_FORMAT": "LOG_FORMAT", "LOG_OUTPUT": "LOG_OUTPUT", "LOG_FILE": "LOG_FILE", "LOG_SERVICE": "SERVICE", "LOG_ENVIRONMENT": "ENVIRONMENT", "LOG_ADD_SOURCE": "LOG_ADD_SOURCE", + "HTTP_READ_HEADER_TIMEOUT": "HTTP_READ_HEADER_TIMEOUT", "HTTP_READ_TIMEOUT": "HTTP_READ_TIMEOUT", "HTTP_WRITE_TIMEOUT": "HTTP_WRITE_TIMEOUT", "HTTP_IDLE_TIMEOUT": "HTTP_IDLE_TIMEOUT", "HTTP_MAX_HEADER_BYTES": "HTTP_MAX_HEADER_BYTES", + "LIFECYCLE_STOP_TIMEOUT": "LIFECYCLE_STOP_TIMEOUT", "LIFECYCLE_DRAIN_TIMEOUT": "LIFECYCLE_DRAIN_TIMEOUT", "LIFECYCLE_CANCEL_TIMEOUT": "LIFECYCLE_CANCEL_TIMEOUT", + } + for _, endpoint := range []string{"LIVENESS", "READINESS", "OPENAPI"} { + for _, part := range []string{"ENABLED", "PATH", "RESPONSE_MODE"} { + if endpoint == "OPENAPI" && part == "RESPONSE_MODE" { + continue + } + name := "ENDPOINTS_" + endpoint + "_" + part + names[name] = name + } + } + aliases := map[string][]string{} + for key, alias := range names { + if root+key != prefix+alias { + aliases[root+key] = []string{prefix + alias} + } + } + return aliases } -// ApplyEnv overlays nonempty environment values on a caller's defaults or file -// settings, then validates the result. Caller-owned slices are not retained. +// LoadFromEnv loads defaults using the legacy short-name conventions. +func LoadFromEnv(prefix string) (Config, error) { return ApplyEnv(Default(), prefix) } + +// ApplyEnv retains legacy whitespace trimming, ignored empty variables and +// comma-separated TRUSTED_PROXIES. New projects should use OverlayEnv with +// ServerEnvAliases for one explicit empty-value policy across the whole config. 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) -} - -func applyEnv(cfg Config, prefix string) (Config, error) { prefix = strings.TrimSpace(prefix) if prefix == "" { prefix = defaultEnvPrefix } - cfg.Address = envString(prefix+"HTTP_ADDR", cfg.Address) - cfg.Transport = envString(prefix+"TRANSPORT", cfg.Transport) - cfg.TLS.CertFile = envString(prefix+"TLS_CERT_FILE", cfg.TLS.CertFile) - cfg.TLS.KeyFile = envString(prefix+"TLS_KEY_FILE", cfg.TLS.KeyFile) - cfg.RequestIDHeader = envString(prefix+"REQUEST_ID_HEADER", cfg.RequestIDHeader) - if value := strings.TrimSpace(os.Getenv(prefix + "TRUSTED_PROXIES")); value != "" { - cfg.TrustedProxies = strings.Split(value, ",") - for i := range cfg.TrustedProxies { - cfg.TrustedProxies[i] = strings.TrimSpace(cfg.TrustedProxies[i]) - } - } - cfg.Log.Level = envString(prefix+"LOG_LEVEL", cfg.Log.Level) - cfg.Log.Format = envString(prefix+"LOG_FORMAT", cfg.Log.Format) - cfg.Log.Output = envString(prefix+"LOG_OUTPUT", cfg.Log.Output) - cfg.Log.File = envString(prefix+"LOG_FILE", cfg.Log.File) - cfg.Log.Service = envString(prefix+"SERVICE", cfg.Log.Service) - cfg.Log.Environment = envString(prefix+"ENVIRONMENT", cfg.Log.Environment) - - var err error - for _, endpoint := range []struct { - name string - enabled *bool - path *string - mode *string - }{ - {"LIVENESS", &cfg.Endpoints.Liveness.Enabled, &cfg.Endpoints.Liveness.Path, &cfg.Endpoints.Liveness.ResponseMode}, - {"READINESS", &cfg.Endpoints.Readiness.Enabled, &cfg.Endpoints.Readiness.Path, &cfg.Endpoints.Readiness.ResponseMode}, - {"OPENAPI", &cfg.Endpoints.OpenAPI.Enabled, &cfg.Endpoints.OpenAPI.Path, nil}, - } { - key := prefix + "ENDPOINTS_" + endpoint.name - *endpoint.enabled, err = envBool(key+"_ENABLED", *endpoint.enabled) - if err != nil { - return Config{}, err - } - *endpoint.path = envString(key+"_PATH", *endpoint.path) - if endpoint.mode != nil { - *endpoint.mode = envString(key+"_RESPONSE_MODE", *endpoint.mode) - } - } - for _, field := range []struct { - name string - value *time.Duration - }{ - {"HTTP_READ_HEADER_TIMEOUT", &cfg.HTTP.ReadHeaderTimeout}, - {"HTTP_READ_TIMEOUT", &cfg.HTTP.ReadTimeout}, - {"HTTP_WRITE_TIMEOUT", &cfg.HTTP.WriteTimeout}, - {"HTTP_IDLE_TIMEOUT", &cfg.HTTP.IdleTimeout}, - {"LIFECYCLE_STOP_TIMEOUT", &cfg.Lifecycle.StopTimeout}, - {"LIFECYCLE_DRAIN_TIMEOUT", &cfg.Lifecycle.DrainTimeout}, - {"LIFECYCLE_CANCEL_TIMEOUT", &cfg.Lifecycle.CancelTimeout}, - } { - if value := strings.TrimSpace(os.Getenv(prefix + field.name)); value != "" { - *field.value, err = time.ParseDuration(value) - if err != nil { - return Config{}, fmt.Errorf("%s%s must be a duration: %w", prefix, field.name, err) + lookup := func(key string) (string, bool) { + raw, ok := os.LookupEnv(key) + raw = strings.TrimSpace(raw) + if ok && raw != "" && key == prefix+"TRUSTED_PROXIES" && !strings.HasPrefix(raw, "[") { + values := strings.Split(raw, ",") + for i := range values { + values[i] = strings.TrimSpace(values[i]) } + data, _ := json.Marshal(values) + raw = string(data) } + return raw, ok } - if value := strings.TrimSpace(os.Getenv(prefix + "HTTP_MAX_HEADER_BYTES")); value != "" { - cfg.HTTP.MaxHeaderBytes, err = strconv.Atoi(value) - if err != nil { - return Config{}, fmt.Errorf("%sHTTP_MAX_HEADER_BYTES must be an integer: %w", prefix, err) - } - } - if cfg.EnableLogger, err = envBool(prefix+"ENABLE_LOGGER", cfg.EnableLogger); err != nil { + defaults.Errors = defaults.Errors.Clone() + defaults.TrustedProxies = append([]string(nil), defaults.TrustedProxies...) + defaults.Log.RedactKeys = append([]string(nil), defaults.Log.RedactKeys...) + cfg, err := OverlayEnv(defaults, EnvOptions{Prefix: prefix, Empty: "ignore", Lookup: lookup, Aliases: ServerEnvAliases(prefix, "")}) + if err != nil { return Config{}, err } - if cfg.Log.AddSource, err = envBool(prefix+"LOG_ADD_SOURCE", cfg.Log.AddSource); err != nil { - return Config{}, err - } - if value := strings.TrimSpace(os.Getenv(prefix + "SLOW_REQUEST_THRESHOLD")); value != "" { - cfg.SlowRequestThreshold, err = time.ParseDuration(value) - if err != nil { - return Config{}, fmt.Errorf("%sSLOW_REQUEST_THRESHOLD must be a duration: %w", prefix, err) - } - } - if value := strings.TrimSpace(os.Getenv(prefix + "SHUTDOWN_TIMEOUT")); value != "" { - cfg.ShutdownTimeout, err = time.ParseDuration(value) - if err != nil { - return Config{}, fmt.Errorf("%sSHUTDOWN_TIMEOUT must be a duration: %w", prefix, err) - } - } - if err := cfg.Validate(); err != nil { + if err = cfg.Validate(); err != nil { return Config{}, err } return cfg, nil } - -func envString(key, fallback string) string { - if value := strings.TrimSpace(os.Getenv(key)); value != "" { - return value - } - return fallback -} - -func envBool(key string, fallback bool) (bool, error) { - value := strings.TrimSpace(os.Getenv(key)) - if value == "" { - return fallback, nil - } - parsed, err := strconv.ParseBool(value) - if err != nil { - return false, fmt.Errorf("%s must be a boolean: %w", key, err) - } - return parsed, nil -} diff --git a/config/file.go b/config/file.go index 624ec57..160e4e6 100644 --- a/config/file.go +++ b/config/file.go @@ -159,7 +159,7 @@ func LoadFile(path, prefix string) (Config, error) { if err != nil { return Config{}, err } - return applyEnv(cfg, prefix) + return ApplyEnv(cfg, prefix) } // Reloader only accepts log.level changes. Static changes and invalid files diff --git a/config/project.go b/config/project.go index 74921ee..2a4fbdd 100644 --- a/config/project.go +++ b/config/project.go @@ -23,6 +23,9 @@ type EnvOptions struct { Prefix string Empty string Lookup func(string) (string, bool) + // Aliases maps canonical absolute environment names to ordered fallback names. + // A present canonical value wins, including an explicit empty value. + Aliases map[string][]string } // ApplyDefaults applies envDefault tags to zero fields before file overlays. @@ -162,13 +165,22 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error) } seen[key] = true raw, present := options.Lookup(key) + selectedKey := key + if !present && !defaults { + for _, alias := range options.Aliases[key] { + if raw, present = options.Lookup(alias); present { + selectedKey = alias + break + } + } + } if defaults { raw, present = field.Tag.Lookup("envDefault") present = present && dst.IsZero() } if present && raw == "" && !defaults { if empty == "reject" { - return fmt.Errorf("config: %s must not be empty", key) + return fmt.Errorf("config: %s must not be empty", selectedKey) } if empty == "ignore" { present = false @@ -177,7 +189,7 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error) if present { parsed := reflect.New(dst.Type()).Elem() if err := parseEnvValue(parsed, raw); err != nil { - return fmt.Errorf("config: %s must be a valid %s", key, dst.Type()) + return fmt.Errorf("config: %s must be a valid %s", selectedKey, dst.Type()) } dst.Set(parsed) } diff --git a/examples/contracts/arrays.api b/examples/contracts/arrays.api new file mode 100644 index 0000000..8bb9407 --- /dev/null +++ b/examples/contracts/arrays.api @@ -0,0 +1,23 @@ +syntax = "v1" +@app (defaultResponse: Common) +response Common ( + codeType: int32 + successCode: 0 + errorCode: 9999 + messageField: msg + dataMode: always +) +@server (group: arrays) +service arrays { + @handler strings + get /strings returns ([]string) + @handler integers + get /integers returns ([]int64) + @handler dynamic + get /dynamic returns ([]any) + @handler empty + get /empty returns ([]any) + @handler raw + @response raw + get /raw returns ([]bool) +} diff --git a/examples/managedService/.goOmni/apiManifest.json b/examples/managedService/.goOmni/apiManifest.json index 4359fd8..c588208 100644 --- a/examples/managedService/.goOmni/apiManifest.json +++ b/examples/managedService/.goOmni/apiManifest.json @@ -1,5 +1,5 @@ { - "templateVersion": "v0.15.0-preview", + "templateVersion": "v0.16.0-preview", "profile": { "database": "none", "storage": "none", @@ -10,10 +10,10 @@ "module": "example.com/managed", "entry": "api/managed.api", "generated": { - "docs/openapi.json": "68c7e9e0294bf62937aa953a44732288c51571108c922a36a72faa72c4360b5f", + "docs/openapi.json": "0370fd6bd6a387e2ef21f6b42b009ce66749846b8bea3d481009a34f3af039a5", "internal/handler/appGen.go": "f5251b4dc33e2312b0bdb71ead70d2171977d851265e124f640458d705a2cc9d", "internal/handler/demo/handlersGen.go": "3c76d85dbe89eb5127d9d134dc625203ecf9719dd11d174c1f335691b99e1375", - "internal/handler/openapiGen.go": "ae9b794c63e0f3c8f9d200a5fc6977350139d4a6f00b42a26102c8630d907d03", + "internal/handler/openapiGen.go": "f24cab3abbb8557a3fc4dd93fba23cc0b093f3e0c944d3ba1bd5b0eeff2d96f3", "internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399", "internal/handler/routesGen.go": "76c1a5b2644e75e56553dc6e9e862a63128d902dd94807ce7c8aee5491cd1e3e", "internal/middleware/registryGen.go": "e5f47efa57a1432765ef3419bae59dbf3b4d55b3aac0a6c935ce7a52ba6b47f4", diff --git a/examples/managedService/docs/openapi.json b/examples/managedService/docs/openapi.json index e251a9f..88ea990 100644 --- a/examples/managedService/docs/openapi.json +++ b/examples/managedService/docs/openapi.json @@ -1,5 +1,106 @@ { "components": { + "responses": { + "Omni_3d61a2a5b54758a4bc415ded644ec6b810886cde14dfc81a959f7df8637cbec8": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "Omni_b88ffe07042df23a7de28aa8c37463792c6aeba2ab13b601d1bfac7f1d7ac5ab": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "additionalProperties": false, + "properties": { + "bytes": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "bytes" + ], + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Success" + } + }, "securitySchemes": {} }, "info": { @@ -34,159 +135,19 @@ "description": "Success" }, "400": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "413": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "415": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "500": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "501": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" } }, "security": [], @@ -219,196 +180,22 @@ ], "responses": { "200": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "additionalProperties": false, - "properties": { - "bytes": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "bytes" - ], - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Success" + "$ref": "#/components/responses/Omni_b88ffe07042df23a7de28aa8c37463792c6aeba2ab13b601d1bfac7f1d7ac5ab" }, "400": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "413": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "415": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "500": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "501": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" } }, "security": [], @@ -441,159 +228,19 @@ "description": "Success" }, "400": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "413": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "415": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "500": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "501": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" } }, "security": [], @@ -662,159 +309,19 @@ "description": "Success" }, "400": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "detail": { - "type": "string" - } - }, - "required": [ - "code", - "detail", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_3d61a2a5b54758a4bc415ded644ec6b810886cde14dfc81a959f7df8637cbec8" }, "413": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "detail": { - "type": "string" - } - }, - "required": [ - "code", - "detail", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_3d61a2a5b54758a4bc415ded644ec6b810886cde14dfc81a959f7df8637cbec8" }, "415": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "detail": { - "type": "string" - } - }, - "required": [ - "code", - "detail", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_3d61a2a5b54758a4bc415ded644ec6b810886cde14dfc81a959f7df8637cbec8" }, "500": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "detail": { - "type": "string" - } - }, - "required": [ - "code", - "detail", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_3d61a2a5b54758a4bc415ded644ec6b810886cde14dfc81a959f7df8637cbec8" }, "501": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "detail": { - "type": "string" - } - }, - "required": [ - "code", - "detail", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_3d61a2a5b54758a4bc415ded644ec6b810886cde14dfc81a959f7df8637cbec8" } }, "security": [], @@ -856,159 +363,19 @@ "description": "Success" }, "400": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "413": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "415": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "500": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "501": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" } }, "security": [], @@ -1030,196 +397,22 @@ "operationId": "demo_Slow", "responses": { "200": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "additionalProperties": false, - "properties": { - "bytes": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "bytes" - ], - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Success" + "$ref": "#/components/responses/Omni_b88ffe07042df23a7de28aa8c37463792c6aeba2ab13b601d1bfac7f1d7ac5ab" }, "400": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "413": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "415": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "500": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "501": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" } }, "security": [], @@ -1252,196 +445,22 @@ }, "responses": { "200": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "additionalProperties": false, - "properties": { - "bytes": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "bytes" - ], - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Success" + "$ref": "#/components/responses/Omni_b88ffe07042df23a7de28aa8c37463792c6aeba2ab13b601d1bfac7f1d7ac5ab" }, "400": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "413": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "415": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "500": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" }, "501": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "format": "int32", - "type": "integer" - }, - "data": { - "enum": [ - null - ], - "nullable": true, - "type": "object" - }, - "msg": { - "type": "string" - } - }, - "required": [ - "code", - "msg", - "data" - ], - "type": "object" - } - } - }, - "description": "Error" + "$ref": "#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e" } }, "security": [], diff --git a/examples/managedService/go.mod b/examples/managedService/go.mod index d47e79c..9c1e01f 100644 --- a/examples/managedService/go.mod +++ b/examples/managedService/go.mod @@ -2,7 +2,7 @@ module example.com/managed go 1.22.0 -require git.yuntuo.hk/k2safe/go-omni v0.15.0-preview +require git.yuntuo.hk/k2safe/go-omni v0.16.0-preview require ( github.com/gabriel-vasile/mimetype v1.4.3 // indirect diff --git a/examples/managedService/internal/config/config.go b/examples/managedService/internal/config/config.go index 1fee979..9d37b18 100644 --- a/examples/managedService/internal/config/config.go +++ b/examples/managedService/internal/config/config.go @@ -4,7 +4,6 @@ import ( "git.yuntuo.hk/k2safe/go-omni/config" "git.yuntuo.hk/k2safe/go-omni/logx" "os" - "strings" "time" ) @@ -38,16 +37,19 @@ func Load(path string) (Config, error) { return Config{}, err } } - cfg, err = config.OverlayEnv(cfg, config.EnvOptions{Prefix: "GO_OMNI_"}) + cfg, err = config.OverlayEnv(cfg, config.EnvOptions{Prefix: "GO_OMNI_", Empty: "ignore", Aliases: config.ServerEnvAliases("GO_OMNI_", "server")}) if err != nil { return Config{}, err } - // Preserve the documented framework aliases (HTTP_ADDR, LOG_LEVEL, etc). - cfg.Server, err = config.ApplyEnv(cfg.Server, "") - if err != nil { - return Config{}, err + output, outputSet := os.LookupEnv("GO_OMNI_SERVER_LOG_OUTPUT") + if !outputSet { + output = os.Getenv("GO_OMNI_LOG_OUTPUT") } - if output := strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT")); output != "" && strings.TrimSpace(os.Getenv("GO_OMNI_LOG_FILE")) == "" { + file, fileSet := os.LookupEnv("GO_OMNI_SERVER_LOG_FILE") + if !fileSet { + file = os.Getenv("GO_OMNI_LOG_FILE") + } + if output != "" && file == "" { cfg.Server.Log.File = "" cfg.Server.Log.Rotation = logx.RotationConfig{} } diff --git a/examples/managedService/internal/handler/openapiGen.go b/examples/managedService/internal/handler/openapiGen.go index ff7521e..8951217 100644 --- a/examples/managedService/internal/handler/openapiGen.go +++ b/examples/managedService/internal/handler/openapiGen.go @@ -6,7 +6,7 @@ import ( "net/http" ) -const OpenAPIDocument = "{\n \"components\": {\n \"securitySchemes\": {}\n },\n \"info\": {\n \"title\": \"managed-api\",\n \"version\": \"1.0.0\"\n },\n \"openapi\": \"3.0.3\",\n \"paths\": {\n \"/callback\": {\n \"post\": {\n \"operationId\": \"demo_Callback\",\n \"requestBody\": {\n \"content\": {\n \"application/octet-stream\": {\n \"schema\": {\n \"format\": \"binary\",\n \"type\": \"string\"\n }\n }\n },\n \"required\": true\n },\n \"responses\": {\n \"200\": {\n \"content\": {\n \"text/plain\": {\n \"schema\": {\n \"type\": \"string\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"413\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"415\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"500\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"501\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-max-body-bytes\": 64,\n \"x-omni-request-policy\": {\n \"unknownQuery\": \"\",\n \"utf8\": \"\",\n \"charset\": \"\",\n \"body\": \"auto\"\n },\n \"x-omni-response\": \"CommonResponse\"\n }\n },\n \"/check\": {\n \"get\": {\n \"operationId\": \"demo_Check\",\n \"parameters\": [\n {\n \"in\": \"query\",\n \"name\": \"kind\",\n \"required\": false,\n \"schema\": {\n \"type\": \"string\"\n }\n }\n ],\n \"responses\": {\n \"200\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"additionalProperties\": false,\n \"properties\": {\n \"bytes\": {\n \"format\": \"int64\",\n \"type\": \"integer\"\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"413\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"415\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"500\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"501\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-request-policy\": {\n \"unknownQuery\": \"\",\n \"utf8\": \"\",\n \"charset\": \"\",\n \"body\": \"\"\n },\n \"x-omni-response\": \"CommonResponse\"\n }\n },\n \"/download\": {\n \"get\": {\n \"operationId\": \"demo_Download\",\n \"responses\": {\n \"200\": {\n \"content\": {\n \"application/octet-stream\": {\n \"schema\": {\n \"format\": \"binary\",\n \"type\": \"string\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"413\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"415\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"500\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"501\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-request-policy\": {\n \"unknownQuery\": \"\",\n \"utf8\": \"\",\n \"charset\": \"\",\n \"body\": \"\"\n },\n \"x-omni-response\": \"CommonResponse\"\n }\n },\n \"/other\": {\n \"get\": {\n \"operationId\": \"demo_Other\",\n \"parameters\": [\n {\n \"in\": \"query\",\n \"name\": \"kind\",\n \"required\": false,\n \"schema\": {\n \"type\": \"string\"\n }\n }\n ],\n \"responses\": {\n \"200\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"additionalProperties\": false,\n \"properties\": {\n \"bytes\": {\n \"format\": \"int64\",\n \"type\": \"integer\"\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"type\": \"object\"\n },\n \"detail\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"detail\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"detail\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"detail\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"413\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"detail\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"detail\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"415\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"detail\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"detail\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"500\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"detail\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"detail\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"501\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"detail\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"detail\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-request-policy\": {\n \"unknownQuery\": \"\",\n \"utf8\": \"\",\n \"charset\": \"\",\n \"body\": \"\"\n },\n \"x-omni-response\": \"AlternateResponse\"\n }\n },\n \"/raw\": {\n \"get\": {\n \"operationId\": \"demo_RawJSON\",\n \"responses\": {\n \"200\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"additionalProperties\": false,\n \"properties\": {\n \"bytes\": {\n \"format\": \"int64\",\n \"type\": \"integer\"\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"413\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"415\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"500\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"501\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-request-policy\": {\n \"unknownQuery\": \"\",\n \"utf8\": \"\",\n \"charset\": \"\",\n \"body\": \"\"\n },\n \"x-omni-response\": \"raw\"\n }\n },\n \"/slow\": {\n \"get\": {\n \"operationId\": \"demo_Slow\",\n \"responses\": {\n \"200\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"additionalProperties\": false,\n \"properties\": {\n \"bytes\": {\n \"format\": \"int64\",\n \"type\": \"integer\"\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"413\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"415\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"500\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"501\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-request-policy\": {\n \"unknownQuery\": \"\",\n \"utf8\": \"\",\n \"charset\": \"\",\n \"body\": \"\"\n },\n \"x-omni-response\": \"CommonResponse\"\n }\n },\n \"/upload\": {\n \"post\": {\n \"operationId\": \"demo_Upload\",\n \"requestBody\": {\n \"content\": {\n \"multipart/form-data\": {\n \"schema\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n }\n }\n },\n \"required\": true\n },\n \"responses\": {\n \"200\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"additionalProperties\": false,\n \"properties\": {\n \"bytes\": {\n \"format\": \"int64\",\n \"type\": \"integer\"\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"413\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"415\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"500\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"501\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-max-body-bytes\": 2048,\n \"x-omni-multipart-limits\": {\n \"maxBytes\": 2048,\n \"maxFieldBytes\": 65536,\n \"maxFileBytes\": 2048,\n \"maxParts\": 128\n },\n \"x-omni-request-policy\": {\n \"unknownQuery\": \"\",\n \"utf8\": \"\",\n \"charset\": \"\",\n \"body\": \"auto\"\n },\n \"x-omni-response\": \"CommonResponse\"\n }\n }\n },\n \"x-omni-contract-sources\": {\n \"files\": {\n \"managed.api\": \"cec14fe0da47a83804bf461be047df3e004ee470cbc3ea330dabc1537d596672\"\n },\n \"shared\": {}\n },\n \"x-omni-default-response\": \"CommonResponse\"\n}\n" +const OpenAPIDocument = "{\n \"components\": {\n \"responses\": {\n \"Omni_3d61a2a5b54758a4bc415ded644ec6b810886cde14dfc81a959f7df8637cbec8\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"detail\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"detail\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"Omni_b88ffe07042df23a7de28aa8c37463792c6aeba2ab13b601d1bfac7f1d7ac5ab\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"additionalProperties\": false,\n \"properties\": {\n \"bytes\": {\n \"format\": \"int64\",\n \"type\": \"integer\"\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Success\"\n }\n },\n \"securitySchemes\": {}\n },\n \"info\": {\n \"title\": \"managed-api\",\n \"version\": \"1.0.0\"\n },\n \"openapi\": \"3.0.3\",\n \"paths\": {\n \"/callback\": {\n \"post\": {\n \"operationId\": \"demo_Callback\",\n \"requestBody\": {\n \"content\": {\n \"application/octet-stream\": {\n \"schema\": {\n \"format\": \"binary\",\n \"type\": \"string\"\n }\n }\n },\n \"required\": true\n },\n \"responses\": {\n \"200\": {\n \"content\": {\n \"text/plain\": {\n \"schema\": {\n \"type\": \"string\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"413\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"415\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"500\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"501\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-max-body-bytes\": 64,\n \"x-omni-request-policy\": {\n \"unknownQuery\": \"\",\n \"utf8\": \"\",\n \"charset\": \"\",\n \"body\": \"auto\"\n },\n \"x-omni-response\": \"CommonResponse\"\n }\n },\n \"/check\": {\n \"get\": {\n \"operationId\": \"demo_Check\",\n \"parameters\": [\n {\n \"in\": \"query\",\n \"name\": \"kind\",\n \"required\": false,\n \"schema\": {\n \"type\": \"string\"\n }\n }\n ],\n \"responses\": {\n \"200\": {\n \"$ref\": \"#/components/responses/Omni_b88ffe07042df23a7de28aa8c37463792c6aeba2ab13b601d1bfac7f1d7ac5ab\"\n },\n \"400\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"413\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"415\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"500\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"501\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-request-policy\": {\n \"unknownQuery\": \"\",\n \"utf8\": \"\",\n \"charset\": \"\",\n \"body\": \"\"\n },\n \"x-omni-response\": \"CommonResponse\"\n }\n },\n \"/download\": {\n \"get\": {\n \"operationId\": \"demo_Download\",\n \"responses\": {\n \"200\": {\n \"content\": {\n \"application/octet-stream\": {\n \"schema\": {\n \"format\": \"binary\",\n \"type\": \"string\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"413\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"415\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"500\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"501\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-request-policy\": {\n \"unknownQuery\": \"\",\n \"utf8\": \"\",\n \"charset\": \"\",\n \"body\": \"\"\n },\n \"x-omni-response\": \"CommonResponse\"\n }\n },\n \"/other\": {\n \"get\": {\n \"operationId\": \"demo_Other\",\n \"parameters\": [\n {\n \"in\": \"query\",\n \"name\": \"kind\",\n \"required\": false,\n \"schema\": {\n \"type\": \"string\"\n }\n }\n ],\n \"responses\": {\n \"200\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"additionalProperties\": false,\n \"properties\": {\n \"bytes\": {\n \"format\": \"int64\",\n \"type\": \"integer\"\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"type\": \"object\"\n },\n \"detail\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"detail\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"$ref\": \"#/components/responses/Omni_3d61a2a5b54758a4bc415ded644ec6b810886cde14dfc81a959f7df8637cbec8\"\n },\n \"413\": {\n \"$ref\": \"#/components/responses/Omni_3d61a2a5b54758a4bc415ded644ec6b810886cde14dfc81a959f7df8637cbec8\"\n },\n \"415\": {\n \"$ref\": \"#/components/responses/Omni_3d61a2a5b54758a4bc415ded644ec6b810886cde14dfc81a959f7df8637cbec8\"\n },\n \"500\": {\n \"$ref\": \"#/components/responses/Omni_3d61a2a5b54758a4bc415ded644ec6b810886cde14dfc81a959f7df8637cbec8\"\n },\n \"501\": {\n \"$ref\": \"#/components/responses/Omni_3d61a2a5b54758a4bc415ded644ec6b810886cde14dfc81a959f7df8637cbec8\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-request-policy\": {\n \"unknownQuery\": \"\",\n \"utf8\": \"\",\n \"charset\": \"\",\n \"body\": \"\"\n },\n \"x-omni-response\": \"AlternateResponse\"\n }\n },\n \"/raw\": {\n \"get\": {\n \"operationId\": \"demo_RawJSON\",\n \"responses\": {\n \"200\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"additionalProperties\": false,\n \"properties\": {\n \"bytes\": {\n \"format\": \"int64\",\n \"type\": \"integer\"\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"413\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"415\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"500\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"501\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-request-policy\": {\n \"unknownQuery\": \"\",\n \"utf8\": \"\",\n \"charset\": \"\",\n \"body\": \"\"\n },\n \"x-omni-response\": \"raw\"\n }\n },\n \"/slow\": {\n \"get\": {\n \"operationId\": \"demo_Slow\",\n \"responses\": {\n \"200\": {\n \"$ref\": \"#/components/responses/Omni_b88ffe07042df23a7de28aa8c37463792c6aeba2ab13b601d1bfac7f1d7ac5ab\"\n },\n \"400\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"413\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"415\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"500\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"501\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-request-policy\": {\n \"unknownQuery\": \"\",\n \"utf8\": \"\",\n \"charset\": \"\",\n \"body\": \"\"\n },\n \"x-omni-response\": \"CommonResponse\"\n }\n },\n \"/upload\": {\n \"post\": {\n \"operationId\": \"demo_Upload\",\n \"requestBody\": {\n \"content\": {\n \"multipart/form-data\": {\n \"schema\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n }\n }\n },\n \"required\": true\n },\n \"responses\": {\n \"200\": {\n \"$ref\": \"#/components/responses/Omni_b88ffe07042df23a7de28aa8c37463792c6aeba2ab13b601d1bfac7f1d7ac5ab\"\n },\n \"400\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"413\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"415\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"500\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n },\n \"501\": {\n \"$ref\": \"#/components/responses/Omni_b0165b9c9454000720a301f833e7e1da93727ef4f9814b589f0d22f932c7e72e\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-max-body-bytes\": 2048,\n \"x-omni-multipart-limits\": {\n \"maxBytes\": 2048,\n \"maxFieldBytes\": 65536,\n \"maxFileBytes\": 2048,\n \"maxParts\": 128\n },\n \"x-omni-request-policy\": {\n \"unknownQuery\": \"\",\n \"utf8\": \"\",\n \"charset\": \"\",\n \"body\": \"auto\"\n },\n \"x-omni-response\": \"CommonResponse\"\n }\n }\n },\n \"x-omni-contract-sources\": {\n \"files\": {\n \"managed.api\": \"cec14fe0da47a83804bf461be047df3e004ee470cbc3ea330dabc1537d596672\"\n },\n \"shared\": {}\n },\n \"x-omni-default-response\": \"CommonResponse\"\n}\n" func OpenAPI(c *httpx.Context) error { return c.WriteResponse(200, http.Header{"Content-Type": {"application/json; charset=utf-8"}}, []byte(OpenAPIDocument)) diff --git a/reports/contractFollowupAcceptance.md b/reports/contractFollowupAcceptance.md new file mode 100644 index 0000000..6910302 --- /dev/null +++ b/reports/contractFollowupAcceptance.md @@ -0,0 +1,34 @@ +# v0.16.0-preview 契约与生成体验验收 + +日期:2026-09-25。范围是通用数组响应、OpenAPI 引用、共享 Go 错误目录与环境覆盖;没有引入业务专属错误或外部服务依赖。 + +## 验证结果 + +- 字符串、布尔、各支持整数/浮点类型与 any 数组可以校验并生成 Go 类型;实际生成消费者验证中文字符串、int64 精度、混合对象/数组/null/标量、nil 根数组归一和 raw 数组。未知类型、byte/File/根嵌套数组明确拒绝。 +- 重复响应先共享,再依据体积收益共享 schema/示例,避免组件表本身膨胀。引用全部可解析,编译与压缩可重复;示例业务数据中的字面 `$ref` 不被解释。HTTP 200 同时含成功/业务错误时,两种 schema、所有模板示例和 data:null 保留。 +- 两个独立生成服务共享一次 Go Catalog 导出;服务/路由文档筛选不生成运行期目录。支持 internal 包,临时 Go overlay、模块文件及导出程序在工作结束后删除。check 完整文件快照不变;过期目录、编译错误、非法路径和 symlink 输出拒绝。 +- 重新生成保留 Logic;生成 --check、独立消费者 race 与编译通过。根 go run/go build、信号退出、nethttp/native、下载/原始回调/multipart 和生命周期清理继续通过。 +- 环境覆盖验证同一个 Lookup、allow/ignore/reject、完整名优先、显式空与未设置区别、false/0、JSON 集合、默认值不被修改、错误不带输入值。框架旧短名称入口兼容;存储/MySQL 生成组合验证了新版模板。 + +## 实际契约文档 + +| 服务 | 原字节数 | 新字节数 | 操作数 | 展开后内容 | +| --- | ---: | ---: | ---: | --- | +| eTax API | 20,751,149 | 847,171 | 67 | 完全一致 | +| eTax ID | 1,008,210 | 392,481 | 3 | 完全一致 | + +[文档报告](contractFollowupDocuments.json) 保存原/新 SHA256 与生成源码摘要。通过只读 api export 写入临时目录,逐项比较完整 paths 操作;业务项目未做迁移或修改。 + +[全量回归报告](contractFollowupRegression.json) 与 [进程验收](contractFollowupLocal.json) 保存本次检查结果。 + +## 复验 + +```sh +GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s +go vet ./... +go build ./... +python3 scripts/verifyManagedProject.py --report reports/contractFollowupLocal.json +python3 scripts/verifyManagedRelease.py --version v0.16.0-preview --report reports/contractFollowupRemote.json +``` + +共享 Catalog 提供方是清单显式指定的可信代码。框架隔离自己的文件操作,但不能保证用户提供方自身没有副作用;提供方应保持纯粹。该版本仍为 preview,未承诺支持完整 Go/goctl 类型语法或替业务完成数据库、权限、事务迁移。 diff --git a/reports/contractFollowupDocuments.json b/reports/contractFollowupDocuments.json new file mode 100644 index 0000000..218e366 --- /dev/null +++ b/reports/contractFollowupDocuments.json @@ -0,0 +1,40 @@ +{ + "passed": true, + "frameworkVersion": "v0.16.0-preview", + "date": "2026-09-25", + "method": "Read-only api export against existing contracts; expand standard local references and compare every path operation to the existing documents. No business migration performed.", + "services": [ + { + "service": "api", + "beforeBytes": 20751149, + "afterBytes": 847171, + "operations": 67, + "expandedOperationsIdentical": true, + "beforeSHA256": "e9e7fa5de073f1c1747d83e8144dead47080ad720f1c0daf9f0ef6af330eff02", + "afterSHA256": "b1276c7cec2ce7bcaba9a73c475dcc3f5b485306cc6b9a66a4ccf5c6077a13cb", + "components": { + "responses": 25, + "schemas": 1, + "securitySchemes": 1 + } + }, + { + "service": "id", + "beforeBytes": 1008210, + "afterBytes": 392481, + "operations": 3, + "expandedOperationsIdentical": true, + "beforeSHA256": "0ab7f09e18604e402ef372cfe71720c60d8e744b43c7fcbf83211ce1c1a5b04b", + "afterSHA256": "31c1b14754dc863431bf56ef3f6820b019428d8982011503ac91ea87aaecdf9d", + "components": { + "responses": 14, + "schemas": 1, + "securitySchemes": 0 + } + } + ], + "sourceHashes": { + "cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891", + "cmd/go-omni/apigen/contractMetadata.go": "392fca7c015d17e0799019f62c9ed82904a7531a9779eb28c12ec37f8ab93fd7" + } +} diff --git a/reports/contractFollowupLocal.json b/reports/contractFollowupLocal.json new file mode 100644 index 0000000..a1b1d5d --- /dev/null +++ b/reports/contractFollowupLocal.json @@ -0,0 +1,59 @@ +{ + "passed": true, + "mode": "localSource", + "durationSeconds": 4.91, + "checks": [ + "rootGoRun", + "rootGoBuild", + "stableRegeneration", + "checkMainOwnership", + "contractChangePreview", + "logicSignatureCheckPersists", + "businessSourcePreserved", + "fixedConsumerBuild", + "nethttpSIGINT", + "nethttpSIGTERM", + "nativeSIGINT", + "nativeSIGTERM", + "listenerReleased", + "temporaryProcessesRemoved", + "nethttpGeneratedTransfers", + "nativeGeneratedTransfers", + "malformedMultipart400", + "goOwnedErrors", + "default404And405" + ], + "sourceHashes": { + "cmd/go-omni/apigen/generate.go": "9d81a3a4cd7905bd1829626e960512d86ac628d981be47c1d0cacc0648c0f33a", + "cmd/go-omni/apigen/templates.go": "6a98c26329d4c54d97abc170a324016931baac0e21028f649f1660347f5dd971", + "cmd/go-omni/apigen/startup.go": "e3322fa3e2aff9f45d66427f44187535c7c0960edc0f5fdfd5d7b4943b349334", + "cmd/go-omni/apigen/managed_test.go": "8b1b86d38151e78a1a18d8ecf001b9635d32c37276abaf2c9e28af10aa983e9d", + "cmd/go-omni/apigen/contractChanges.go": "aeb83990e908738faaa3cdfa20c3b8938023db05b6e55f2d24352c44144056a8", + "cmd/go-omni/apigen/logicCheck.go": "ca1af4f876a3a0db7b7b9ba9b82c1d6399fd89d29f667dad39cfd3de526eb1cb", + "cmd/go-omni/apigen/write.go": "180c5f5daee1441fe936cb376b1645a3d0c802cc78bf4b451dbf7e7b3e4ac0f4", + "cmd/go-omni/api.go": "5203a17c5f66977a9eba838d61cf5a7356de53d5f72a1196fd4f2046aa43ac85", + "config/config.go": "b4935ebb452c9df34b733842687ba11eec7e006a54ce42ff767273dd74d6e672", + "httpx/transfer.go": "c9fe4e98c0e599d367eaf19ebecf367db914edfca9ad3a65527496d1dea80a3d", + "httpx/multipart.go": "b942ede7a810b268cd43b395c3bddcfaaacad92812bbb60e653466b8d86bc875", + "scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18", + "lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31", + "health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866", + "config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d", + "httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb", + "app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0", + "app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73", + "cmd/go-omni/scaffold/templates/api-config.go.tmpl": "715d29a9be33ba232eac339547cc0a13ae09c8bcb38a65a69a4f32f09fea5346", + "cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969", + "cmd/go-omni/scaffold/templates/api-close.go.tmpl": "3f22d272e92f3b64b62123b3d80ec1a6ce8834257d64752fd75facf12623902b", + "cmd/go-omni/scaffold/templates/api-databaseOptions.go.tmpl": "6e5395b4a787c77a6cabfd85f16049a39578d6ec751a9b78203f3a03baabdf28", + "cmd/go-omni/scaffold/templates/api-options.go.tmpl": "11fe34ce45e8c98f7b5c9c0a2c512eb0b3abf86f95b4b576e083bcb6f5d0f824", + "cmd/go-omni/scaffold/templates/api-context.go.tmpl": "6cf67d37ef64f388b23a98781c3ee3abb71a2e0b9e71eeb70f1e5492ff7e32b4", + "cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247", + "cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167", + "cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a", + "cmd/go-omni/apigen/testdata/managed/phases_test.go": "4d826d7133027ac2399f8209f03efa46ed54aa45dcd0a35274a9009314941dca", + "cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319", + "cmd/go-omni/apigen/testdata/managed/consumer_test.go": "9e103c9de7a1a906903bc09787e1b38905dc5bb4d891c88938028da5dee98f6f", + "cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1" + } +} diff --git a/reports/contractFollowupRegression.json b/reports/contractFollowupRegression.json new file mode 100644 index 0000000..8cd4d38 --- /dev/null +++ b/reports/contractFollowupRegression.json @@ -0,0 +1,59 @@ +{ + "passed": true, + "frameworkVersion": "v0.16.0-preview", + "date": "2026-09-25", + "command": "GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s", + "testedPackages": [ + "git.yuntuo.hk/k2safe/go-omni", + "git.yuntuo.hk/k2safe/go-omni/app", + "git.yuntuo.hk/k2safe/go-omni/audit", + "git.yuntuo.hk/k2safe/go-omni/auth", + "git.yuntuo.hk/k2safe/go-omni/cache", + "git.yuntuo.hk/k2safe/go-omni/cache/redis", + "git.yuntuo.hk/k2safe/go-omni/cmd/go-omni", + "git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen", + "git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold", + "git.yuntuo.hk/k2safe/go-omni/config", + "git.yuntuo.hk/k2safe/go-omni/contract", + "git.yuntuo.hk/k2safe/go-omni/database", + "git.yuntuo.hk/k2safe/go-omni/database/gormx", + "git.yuntuo.hk/k2safe/go-omni/database/migrate", + "git.yuntuo.hk/k2safe/go-omni/database/mysql", + "git.yuntuo.hk/k2safe/go-omni/errors", + "git.yuntuo.hk/k2safe/go-omni/examples/modules", + "git.yuntuo.hk/k2safe/go-omni/examples/platform", + "git.yuntuo.hk/k2safe/go-omni/examples/reference", + "git.yuntuo.hk/k2safe/go-omni/examples/reference/repository", + "git.yuntuo.hk/k2safe/go-omni/health", + "git.yuntuo.hk/k2safe/go-omni/httpc", + "git.yuntuo.hk/k2safe/go-omni/httpx", + "git.yuntuo.hk/k2safe/go-omni/internal/perfcheck", + "git.yuntuo.hk/k2safe/go-omni/lifecycle", + "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/middleware/redis", + "git.yuntuo.hk/k2safe/go-omni/openapi", + "git.yuntuo.hk/k2safe/go-omni/pagination", + "git.yuntuo.hk/k2safe/go-omni/queue/redis", + "git.yuntuo.hk/k2safe/go-omni/resilience", + "git.yuntuo.hk/k2safe/go-omni/response", + "git.yuntuo.hk/k2safe/go-omni/storage", + "git.yuntuo.hk/k2safe/go-omni/storage/local", + "git.yuntuo.hk/k2safe/go-omni/storage/oss", + "git.yuntuo.hk/k2safe/go-omni/storage/s3", + "git.yuntuo.hk/k2safe/go-omni/task", + "git.yuntuo.hk/k2safe/go-omni/telemetry", + "git.yuntuo.hk/k2safe/go-omni/transport", + "git.yuntuo.hk/k2safe/go-omni/transport/http2", + "git.yuntuo.hk/k2safe/go-omni/transport/native", + "git.yuntuo.hk/k2safe/go-omni/transport/nethttp" + ], + "logSHA256": "ea70707ea4c160e9ed56edab99d040d9959dfa402a3e8f6d090d2b30ccef1513", + "additionalChecks": [ + "go vet ./...", + "go build ./...", + "example managedService generate --check", + "example managedService go test ./..." + ] +} diff --git a/scripts/verifyManagedRelease.py b/scripts/verifyManagedRelease.py index 862ae2e..fd4bd97 100644 --- a/scripts/verifyManagedRelease.py +++ b/scripts/verifyManagedRelease.py @@ -18,6 +18,55 @@ ROOT = Path(__file__).resolve().parents[1] MODULE = "git.yuntuo.hk/k2safe/go-omni" +def verify_shared_catalog(cli, temp, env, version): + workspace = temp / "workspace" + (workspace / "internal/apierrors").mkdir(parents=True) + (workspace / "contracts").mkdir() + (workspace / "go.mod").write_text(f"module example.com/releaseWorkspace\n\ngo 1.24.0\nrequire {MODULE} {version}\n") + provider = workspace / "internal/apierrors/catalog.go" + provider.write_text('package apierrors\nimport errors "' + MODULE + '/errors"\n' + 'func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitions:map[string]errors.Definition{' + '"INVALID":{Code:"INVALID",Message:"invalid"},"MISSING":{Status:404,Code:"MISSING",Message:"missing"}}}}\n') + run(["go", "mod", "tidy"], workspace, env) + for name in ("api", "id"): + service = workspace / "services" / name + run([cli, "new", "--module", "example.com/" + name, service], workspace, env) + (service / "api/omni.imports.json").write_text(json.dumps({ + "roots": {"common": {"path": "../../../contracts"}}, + "errors": {"source": "@common/errors.json", "include": ["INVALID"], + "routes": {"GET /api/greet/:name": ["MISSING"]}}})) + manifest = workspace / "omni.workspace.json" + manifest.write_text(json.dumps({"errorCatalogs": [{"dir": ".", "package": "example.com/releaseWorkspace/internal/apierrors", + "function": "Catalog", "output": "contracts/errors.json"}], + "services": [{"dir": "services/api"}, {"dir": "services/id"}]})) + for command in ("generate", "format", "generate", "check"): + run([cli, "workspace", command, "--file", manifest], workspace, env) + for name in ("api", "id"): + service = workspace / "services" / name + assert not (service / "api/omni.errors.json").exists() + assert not (service / "internal/apierrors/errorsGen.go").exists() + doc = json.loads((service / "docs/openapi.json").read_text()) + responses = doc["paths"]["/api/greet/{name}"]["get"]["responses"] + assert "404" in responses + run(["go", "test", "-mod=mod", "./..."], service, env) + snapshot = lambda: {str(p.relative_to(workspace)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in workspace.rglob("*") if p.is_file()} + before = snapshot() + run([cli, "workspace", "check", "--file", manifest], workspace, env) + assert before == snapshot(), "workspace check changed project files" + provider.write_text(provider.read_text().replace('Message:"invalid"', 'Message:"new message"')) + before = snapshot() + try: + run([cli, "workspace", "check", "--file", manifest], workspace, env) + except RuntimeError as err: + assert "catalog drift" in str(err) + else: + raise AssertionError("stale catalog accepted") + assert before == snapshot(), "drift check changed project files" + run([cli, "workspace", "generate", "--file", manifest], workspace, env) + run([cli, "workspace", "check", "--file", manifest], workspace, env) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--version", required=True) @@ -71,8 +120,11 @@ def main(): serve([str(temp / "managedServer")], managed, env, transport, signal.SIGTERM, True, contract=True, logs=temp) checks += ["default404And405", "goOwnedErrorCatalog", "unwrappedDownloads", "rawCallback", "multipart400And413", "nethttpAndNativeTransfers"] + verify_shared_catalog(cli, temp, env, args.version) + checks += ["sharedGoCatalogExport", "documentOnlyErrorSelection", "workspaceCheckNoWrites", "staleCatalogRejected", "twoIndependentServiceBuilds"] + env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version) - tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject"] + tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer"] print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True) output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", "./cmd/go-omni/apigen", "-run", "^(" + "|".join(tests) + ")$"], ROOT, env) events = [json.loads(line) for line in output.splitlines() if line.startswith("{")] @@ -81,11 +133,13 @@ def main(): evidence = "".join(e.get("Output", "") for e in events) sums = re.findall(r"checksum (h1:\S+); no local replace", evidence) assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch" - checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "strictRequestPolicies", "declarativeMultipartBinding", "backgroundStopDrainResourceOrder", "backgroundInitAndListenFailureCleanup", "backgroundTimeoutRetainsResourcesAndExits"] + checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "primitiveAndDynamicArrays", "strictRequestPolicies", "declarativeMultipartBinding", "backgroundStopDrainResourceOrder", "backgroundInitAndListenFailureCleanup", "backgroundTimeoutRetainsResourcesAndExits"] sources = ["lifecycle/runtime.go", "lifecycle/phases.go", "config/lifecycle_test.go", "task/task.go", "cmd/go-omni/scaffold/templates/api-runtime.go.tmpl", "cmd/go-omni/apigen/templates.go", "config/project.go", "contract/bind.go", "contract/policy.go", "contract/multipart.go", "contract/file.go", "cmd/go-omni/apigen/business_test.go", "cmd/go-omni/apigen/generate.go", "lifecycle/controller.go", "config/endpoints.go", "health/health.go", "httpx/response.go", "app/app.go", "app/transport.go", "scripts/verifyManagedRelease.py", "scripts/verifyManagedProject.py", "cmd/go-omni/apigen/managed_test.go"] sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/managed").glob("*.go")] sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/business").glob("*.go")] + sources += ["cmd/go-omni/apigen/responseShape.go", "cmd/go-omni/apigen/documentRefs.go", "cmd/go-omni/apigen/catalogDocument.go", "cmd/go-omni/apigen/workspaceCatalog.go", "config/env.go", "cmd/go-omni/apigen/arrayResponse_test.go"] + sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/arrays").glob("*.go")] report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"], "freshModuleCache": True, "noLocalReplace": True, "durationSeconds": round(time.monotonic() - started, 2), "checks": checks,