Compare commits
2 Commits
v0.15.0-pr
...
v0.16.0-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 64e3bd6cfc | |||
| 1f2d8b6a6a |
@@ -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<Source><摘要>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
|
||||
|
||||
@@ -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 和提前返回的中间件一起验收。
|
||||
|
||||
12
README.md
12
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)。
|
||||
|
||||
## 按需深入
|
||||
|
||||
|
||||
27
RELEASE.md
27
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,不改变业务事务或任务一致性策略。
|
||||
|
||||
@@ -18,7 +41,7 @@ go test ./...
|
||||
|
||||
v0.14.x 无需重写 svc/config;生成器更新 main/runtimeGen,未配置时使用默认时限。也可 api upgrade --dry-run/--apply 更新依赖与生成文件,保留已有项目扩展。需要阶段保证的旧后台 OnClose 登记,应显式迁到 Stop/Drain;v0.13 及更早版本仍按原迁移指南处理。
|
||||
|
||||
全量 race(含实际生成项目)、vet、build 和隔离 MySQL/GORM 验收已通过。详见 [生命周期接入](lifecycle/README.md#后台组件分阶段关闭) 与 [验收记录](reports/phasedLifecycleAcceptance.md)。Go 无法强制结束任意 goroutine;OnClose 仍须能返回,HTTP 处理器仍须响应取消。此版不会将未知的第三方组件自动改造成优雅关闭组件。
|
||||
全量 race(含实际生成项目)、vet、build 和隔离 MySQL/GORM 验收已通过。发布后的空缓存远程生成项目与真实 MySQL/GORM 验收也通过,CLI/框架同版且无本地 replace;标签固定在 `16deead70e860052c5b629511207578c03f9b2cb`。详见 [生命周期接入](lifecycle/README.md#后台组件分阶段关闭) 与 [验收记录](reports/phasedLifecycleAcceptance.md)。Go 无法强制结束任意 goroutine;OnClose 仍须能返回,HTTP 处理器仍须响应取消。此版不会将未知的第三方组件自动改造成优雅关闭组件。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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` 命令的手动路由装配。
|
||||
|
||||
## 数据库迁移命令
|
||||
|
||||
|
||||
104
cmd/go-omni/apigen/arrayResponse_test.go
Normal file
104
cmd/go-omni/apigen/arrayResponse_test.go
Normal file
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
86
cmd/go-omni/apigen/documentRefs.go
Normal file
86
cmd/go-omni/apigen/documentRefs.go
Normal file
@@ -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)
|
||||
}
|
||||
107
cmd/go-omni/apigen/documentRefs_test.go
Normal file
107
cmd/go-omni/apigen/documentRefs_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 := ""
|
||||
|
||||
@@ -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.") }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
54
cmd/go-omni/apigen/testdata/arrays/consumer_test.go
vendored
Normal file
54
cmd/go-omni/apigen/testdata/arrays/consumer_test.go
vendored
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
13
cmd/go-omni/apigen/testdata/arrays/values.go
vendored
Normal file
13
cmd/go-omni/apigen/testdata/arrays/values.go
vendored
Normal file
@@ -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 }
|
||||
@@ -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":
|
||||
|
||||
217
cmd/go-omni/apigen/workspaceCatalog.go
Normal file
217
cmd/go-omni/apigen/workspaceCatalog.go
Normal file
@@ -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)
|
||||
}
|
||||
168
cmd/go-omni/apigen/workspaceCatalog_test.go
Normal file
168
cmd/go-omni/apigen/workspaceCatalog_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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.
|
||||
|
||||
66
config/aliases_test.go
Normal file
66
config/aliases_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
171
config/env.go
171
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
23
examples/contracts/arrays.api
Normal file
23
examples/contracts/arrays.api
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
34
reports/contractFollowupAcceptance.md
Normal file
34
reports/contractFollowupAcceptance.md
Normal file
@@ -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 类型语法或替业务完成数据库、权限、事务迁移。
|
||||
40
reports/contractFollowupDocuments.json
Normal file
40
reports/contractFollowupDocuments.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
59
reports/contractFollowupLocal.json
Normal file
59
reports/contractFollowupLocal.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
59
reports/contractFollowupRegression.json
Normal file
59
reports/contractFollowupRegression.json
Normal file
@@ -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 ./..."
|
||||
]
|
||||
}
|
||||
@@ -27,7 +27,14 @@ python3 scripts/verifyManagedProject.py --report reports/phasedManagedLocal.json
|
||||
python3 scripts/verifyConnector.py --local --phases --gorm --report reports/phasedMySQLLocal.json
|
||||
```
|
||||
|
||||
发布后使用空缓存、固定远程 module、无 replace 再验收:
|
||||
发布后空缓存远程验收也已通过:
|
||||
|
||||
- [生成项目远程报告](phasedManagedRemote.json):31.93 秒,包含后台阶段顺序、初始化/监听失败、超时退出及既有接口功能。
|
||||
- [真实 MySQL/GORM 远程报告](phasedMySQLRemote.json):25.14 秒,包含后台最终写入后关池、收尾失败保留池与 GORM 同池回归。
|
||||
- 两份报告均为固定版本 v0.15.0-preview、空 module 缓存、无本地 replace;module 校验值一致为 `h1:llp5XZcLAsv9tckUQsZlwBDAO2RzoyxeygxZZ4/ZW1c=`。
|
||||
- 发布标签固定在 `16deead70e860052c5b629511207578c03f9b2cb`,报告单独提交,标签不移动。
|
||||
|
||||
远程复验命令:
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyManagedRelease.py --version v0.15.0-preview --report reports/phasedManagedRemote.json
|
||||
|
||||
78
reports/phasedManagedRemote.json
Normal file
78
reports/phasedManagedRemote.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.15.0-preview",
|
||||
"frameworkSum": "h1:llp5XZcLAsv9tckUQsZlwBDAO2RzoyxeygxZZ4/ZW1c=",
|
||||
"freshModuleCache": true,
|
||||
"noLocalReplace": true,
|
||||
"durationSeconds": 31.93,
|
||||
"checks": [
|
||||
"remoteCLIInstall",
|
||||
"freshModuleCache",
|
||||
"fixedVersionNoReplace",
|
||||
"contractChangePreview",
|
||||
"persistentLogicSignatureFailure",
|
||||
"businessSourcePreserved",
|
||||
"fixedConsumerBuild",
|
||||
"rootGoRun",
|
||||
"nethttpAndNativeSignals",
|
||||
"listenerReleased",
|
||||
"default404And405",
|
||||
"goOwnedErrorCatalog",
|
||||
"unwrappedDownloads",
|
||||
"rawCallback",
|
||||
"multipart400And413",
|
||||
"nethttpAndNativeTransfers",
|
||||
"initializationAndListenFailureCleanup",
|
||||
"drainBeforeDependencyClose",
|
||||
"generatedConsumerRaceTests",
|
||||
"errorParamsAndInheritance",
|
||||
"manualHooksAndScalars",
|
||||
"dependencyFatalShutdown",
|
||||
"fatalProcessExitCode",
|
||||
"initializationShutdownNotification",
|
||||
"configuredBuiltinEndpoints",
|
||||
"projectHealthEnvelope",
|
||||
"endpointConflictCleanup",
|
||||
"responseArrayNullableNull",
|
||||
"strictRequestPolicies",
|
||||
"declarativeMultipartBinding",
|
||||
"backgroundStopDrainResourceOrder",
|
||||
"backgroundInitAndListenFailureCleanup",
|
||||
"backgroundTimeoutRetainsResourcesAndExits"
|
||||
],
|
||||
"sourceHashes": {
|
||||
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
|
||||
"lifecycle/phases.go": "1436856c708d004ebb28704a867316bf7a44553de56b3e29152d41475a2f793a",
|
||||
"config/lifecycle_test.go": "8e5fccf0f7432c64d7fa8a52687002307d3ebb1fd039adb6ae09da0c9a2891bf",
|
||||
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
|
||||
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
|
||||
"cmd/go-omni/apigen/templates.go": "6a98c26329d4c54d97abc170a324016931baac0e21028f649f1660347f5dd971",
|
||||
"config/project.go": "54b4d6f4d3a2e7a172e7f446af22067735b7c02ba253dd2483f42190616e90d3",
|
||||
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
|
||||
"contract/policy.go": "73a3a55fb88195b32f22b2bc066d9b13dccd7fd520e98c5a1b2979a3fd2447eb",
|
||||
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
|
||||
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
|
||||
"cmd/go-omni/apigen/business_test.go": "c0af26cd270f1c971dc5886316b144b776fdb886e930f883f0ee0c94b0909226",
|
||||
"cmd/go-omni/apigen/generate.go": "819ce227f1041fa9447d7fbff4bfc2d4ea60dcb73e8667a21a41a090b40f0dd3",
|
||||
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
|
||||
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
|
||||
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
|
||||
"scripts/verifyManagedRelease.py": "8bc3ce425ca93cbea370fa94ff855922bdb60b2213330dec1cddd25d73d6f12b",
|
||||
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||
"cmd/go-omni/apigen/managed_test.go": "8b1b86d38151e78a1a18d8ecf001b9635d32c37276abaf2c9e28af10aa983e9d",
|
||||
"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",
|
||||
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
|
||||
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
|
||||
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
|
||||
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c"
|
||||
}
|
||||
}
|
||||
87
reports/phasedMySQLRemote.json
Normal file
87
reports/phasedMySQLRemote.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.15.0-preview",
|
||||
"localSource": false,
|
||||
"gormAdapter": true,
|
||||
"phasedLifecycle": true,
|
||||
"frameworkSum": "h1:llp5XZcLAsv9tckUQsZlwBDAO2RzoyxeygxZZ4/ZW1c=",
|
||||
"freshModuleCache": true,
|
||||
"noLocalReplace": true,
|
||||
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
|
||||
"mysqlLimits": {
|
||||
"cpus": 1,
|
||||
"memoryMiB": 768,
|
||||
"pids": 128
|
||||
},
|
||||
"durationSeconds": 25.14,
|
||||
"tests": [
|
||||
"TestHTTPConfiguredStandardServer",
|
||||
"TestHTTPInvalidStartupReleasesResources",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
|
||||
"TestStructuredMySQLAdapterTLS",
|
||||
"TestHTTPFileAndEnvironment/server.json",
|
||||
"TestHTTPFileAndEnvironment/server.yaml",
|
||||
"TestHTTPFileAndEnvironment",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
|
||||
"TestHTTPInvalidAndUnsupportedSettings",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp",
|
||||
"TestConnectorIndependentPoolsAndContext",
|
||||
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
|
||||
"TestConnectorStartupFailuresReleaseResources/connect",
|
||||
"TestConnectorStartupFailuresReleaseResources/ping",
|
||||
"TestConnectorStartupFailuresReleaseResources",
|
||||
"TestConnectorTimeoutBoundsConnectAndPing/connect",
|
||||
"TestConnectorTimeoutBoundsConnectAndPing/ping",
|
||||
"TestConnectorTimeoutBoundsConnectAndPing",
|
||||
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
|
||||
"TestConnectorHealthBoundsPoolWaitAndRecovers",
|
||||
"TestConnectorIdleZeroAndLifetime/noIdle",
|
||||
"TestConnectorIdleZeroAndLifetime/expired",
|
||||
"TestConnectorIdleZeroAndLifetime",
|
||||
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
|
||||
"TestMySQLConnectorDriverLoggerIsolation",
|
||||
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
|
||||
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
|
||||
"TestHTTPConfiguredNetworkLimits/native/slowBody",
|
||||
"TestMySQLConnectorTLSAndPoolIsolation",
|
||||
"TestHTTPConfiguredNetworkLimits/native/idle",
|
||||
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
|
||||
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
|
||||
"TestHTTPConfiguredNetworkLimits/native",
|
||||
"TestHTTPConfiguredNetworkLimits",
|
||||
"TestGORMMySQLTransactionsCancellationAndLogs",
|
||||
"TestGeneratedOptionalGORM",
|
||||
"TestGeneratedMySQLConfigurationAndLifecycle",
|
||||
"TestGeneratedMySQLBackgroundDrain"
|
||||
],
|
||||
"sourceHashes": {
|
||||
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
|
||||
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
|
||||
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
|
||||
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
|
||||
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
|
||||
"database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb",
|
||||
"scripts/verifyConnector.py": "fba3064dc4611eef5a7cc25369d4523b485e633448fa14a7a1fea0fbca3796fe",
|
||||
"scripts/testdata/mysqlGenerated_test.go": "5630123add5ed17f261c1a1bf63ba773f7fa6ea613fd573d5c5cb72da3e42645",
|
||||
"scripts/testdata/phasedMySQL_test.go": "925ca9ce0036ce6d3833cc8be9e72f8a097c689b8a270051fa2b6471f2edb6bb",
|
||||
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
|
||||
"lifecycle/phases.go": "1436856c708d004ebb28704a867316bf7a44553de56b3e29152d41475a2f793a",
|
||||
"scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251",
|
||||
"database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2",
|
||||
"database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d",
|
||||
"database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user