Compare commits
2 Commits
v0.17.0-pr
...
v0.18.0-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 4563a6354f | |||
| 5fc82509a1 |
@@ -1,4 +1,4 @@
|
|||||||
# 通用业务开发指南(v0.17.0-preview)
|
# 通用业务开发指南(v0.18.0-preview)
|
||||||
|
|
||||||
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ go-omni workspace check --file omni.workspace.json
|
|||||||
|
|
||||||
## v0.13 项目升级
|
## v0.13 项目升级
|
||||||
|
|
||||||
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.17.0-preview(或显式引用当前源码)。
|
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.18.0-preview(或显式引用当前源码)。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go-omni api upgrade --dir . --dry-run
|
go-omni api upgrade --dir . --dry-run
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# 公共字段、结构映射与生成选项
|
# 公共字段、结构映射与生成选项
|
||||||
|
|
||||||
本页对应 `v0.17.0-preview`。CLI 与框架依赖固定到同一版本:
|
本页对应 `v0.18.0-preview`。CLI 与框架依赖固定到同一版本:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.17.0-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.18.0-preview
|
||||||
# 已有业务项目内执行:
|
# 已有业务项目内执行:
|
||||||
go get git.yuntuo.hk/k2safe/go-omni@v0.17.0-preview
|
go get git.yuntuo.hk/k2safe/go-omni@v0.18.0-preview
|
||||||
```
|
```
|
||||||
|
|
||||||
私有仓库访问设置见 [安装说明](README.md#准备环境与安装)。升级不会覆盖业务文件;已有中间件保持 handlers 模式,切换工厂模式按本页迁移步骤完成。
|
私有仓库访问设置见 [安装说明](README.md#准备环境与安装)。升级不会覆盖业务文件;已有中间件保持 handlers 模式,切换工厂模式按本页迁移步骤完成。
|
||||||
@@ -28,6 +28,26 @@ go get git.yuntuo.hk/k2safe/go-omni@v0.17.0-preview
|
|||||||
- `mappings` 可省略;只有明确列出的类型对才生成映射。未启用时不会运行 Go 类型加载命令,也不增加运行时依赖。
|
- `mappings` 可省略;只有明确列出的类型对才生成映射。未启用时不会运行 Go 类型加载命令,也不增加运行时依赖。
|
||||||
- 升级首次遇到没有配置的旧 manifest 时,补一份 `middleware: handlers` 的配置,保留原有手写注册;Repository 默认 none。生成配置建立后不能直接删除,否则生成失败,避免误关闭映射或改变中间件模式。
|
- 升级首次遇到没有配置的旧 manifest 时,补一份 `middleware: handlers` 的配置,保留原有手写注册;Repository 默认 none。生成配置建立后不能直接删除,否则生成失败,避免误关闭映射或改变中间件模式。
|
||||||
|
|
||||||
|
### 按模块拆分映射(v0.18 新增)
|
||||||
|
|
||||||
|
主配置可以保留少量内联 `mappings`,也可以只列出文件:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"repository": "none",
|
||||||
|
"middleware": "factories",
|
||||||
|
"mappingFiles": ["mappings/requests.json", "mappings/responses.json"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
每个子文件只包含 `{"mappings": [...]}`,其中每条映射的写法与原来一致。先加载主配置内联定义,再按文件列表顺序加载;所有定义合并后统一校验,可以跨文件、向前引用 `mapping`。输出按映射名排序,文件拆分不改变函数名或语义。
|
||||||
|
|
||||||
|
- 路径相对项目根目录,必须是明确列出的 `.json` 文件。不展开通配符,不允许越界路径、符号链接、重复文件或多层导入;子文件不能覆盖 repository/middleware 等项目选项。
|
||||||
|
- 重名映射、指针辅助函数名冲突、未知映射与循环引用明确报错;文件之间不能用覆盖顺序隐藏冲突。重复定义会指出两个来源文件,字段生成错误也带来源文件。
|
||||||
|
- 文件均由项目维护,重新生成不改写它们。原来的单文件 `mappings` 继续有效。迁移时将完整条目移到子文件并删除原位置,不能复制后保留两份同名定义。
|
||||||
|
- 最多 64 个子文件、单文件 1 MiB、全部配置 4 MiB;合计仍最多 128 个映射。主配置和子文件一起提交 Git。
|
||||||
|
- `api generate`、`--check`、`--dry-run` 与 `workspace generate/check` 使用同一加载逻辑。缺少子文件或不完整配置立即失败,check/预览不写入;生成期间重新核对所有配置输入,发生变化时要求重试。
|
||||||
|
|
||||||
## 公共字段组合
|
## 公共字段组合
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -141,6 +161,53 @@ func SalaryValue(source *amount.Money) (amount.Money, error)
|
|||||||
|
|
||||||
映射不保存原 JSON 的字段存在信息。普通 `*T` 在绑定后可能已把缺失和 null 合并为 nil;无法在转换阶段恢复区分。有三态需要的项目必须先使用能携带该信息的输入类型及绑定扩展;没有声明三态时,保持当前输入类型的语义。完整表单更新和局部更新由业务决定,框架不把 nil 自动解释成“保留旧值”。
|
映射不保存原 JSON 的字段存在信息。普通 `*T` 在绑定后可能已把缺失和 null 合并为 nil;无法在转换阶段恢复区分。有三态需要的项目必须先使用能携带该信息的输入类型及绑定扩展;没有声明三态时,保持当前输入类型的语义。完整表单更新和局部更新由业务决定,框架不把 nil 自动解释成“保留旧值”。
|
||||||
|
|
||||||
|
### 可空嵌套映射与显式 nil 策略(v0.18 新增)
|
||||||
|
|
||||||
|
假设已有 `ToChild` 定义 `types.Child → domain.Child`。父对象的字段规则可以直接复用它:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"fields": {
|
||||||
|
"Child": {"source": "Child", "mapping": "ToChild"},
|
||||||
|
"Children": {"source": "Children", "mapping": "ToChild", "elements": true}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 源字段 → 目标字段 | 明确选择 mapping 后的行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| `S → T` | 调用原值映射 |
|
||||||
|
| `*S → *T` | 默认保留 nil;非 nil 调用值映射,并分配新的目标对象;也可配置 `nil: "error"` 拒绝 nil |
|
||||||
|
| `S → *T` | 调用值映射,成功后返回目标对象指针 |
|
||||||
|
| `*S → T` | 必须选择 `nil: "zero"` 或 `nil: "error"`,不猜默认值 |
|
||||||
|
|
||||||
|
`nil: "preserve"` 只适用于可空源和可空目标。`nil: "zero"` 只适用于可空源和非空目标:nil 直接产生目标 Go 零值,**不调用子映射或转换函数**。`nil: "error"` 在 nil 时返回带映射/字段路径的普通转换错误,HTTP 公开响应仍由项目的错误处理策略决定;需要领域错误码时使用项目 converter。
|
||||||
|
|
||||||
|
同一规则适用于显式 `elements: true` 下的 slice/等长数组元素,包括 `[]*S → []*T`。nil slice、非 nil 空 slice、nil 元素分别保留;元素失败保留索引与内部错误链,整个映射返回零目标值,不交付部分结果。只支持这里列出的单层指针,不自动处理多层指针或递归对象图。
|
||||||
|
|
||||||
|
普通同类型指针字段未指定 mapping 时仍直接赋值、共享引用;上述显式映射会创建目标子对象,但不承诺对其中所有引用深拷贝。业务不可变快照继续由项目设计。
|
||||||
|
|
||||||
|
简单的 `*T → T` 也可以单独指定 nil 策略,减少重复解引用函数:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"fields": {
|
||||||
|
"Title": {"source": "Title", "nil": "zero"},
|
||||||
|
"Count": {"source": "Count", "nil": "error"}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这里必须是完全相同的 Go 元素类型,`*int32 → int64`、枚举 → string、数字编号 → string 仍需显式 converter。非 nil 的零值正常保留。`convert` 与 `nil` 不能同时设置,现有 converter 继续完整控制自己的空值、格式和错误语义。
|
||||||
|
|
||||||
|
### 可选指针返回辅助(v0.18 新增)
|
||||||
|
|
||||||
|
在映射条目上增加 `"pointerResult": true`,生成器保留 `ToResponse(source S) (T, error)`,另加:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ToResponsePtr(source S) (*T, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Logic 在业务调用成功后可直接写 `return mapping.ToResponsePtr(result)`。转换失败时返回 `nil, err`,错误链不变;成功才返回非 nil 指针。业务调用本身的错误仍由 Logic 处理,辅助函数不执行任何业务操作。
|
||||||
|
|
||||||
|
辅助函数名称固定为映射名加 `Ptr`,与其他映射/辅助函数同名会在生成前报错。输入仍是原来的值类型 S,不用 nil 输入表达业务错误;关闭该选项会移除辅助函数,使用它的业务代码需同时调整。
|
||||||
|
|
||||||
### 生成顺序与 CI
|
### 生成顺序与 CI
|
||||||
|
|
||||||
1. 先从 `.api` 生成项目,添加业务结构、scalar 和转换函数,执行 `go mod tidy`。
|
1. 先从 `.api` 生成项目,添加业务结构、scalar 和转换函数,执行 `go mod tidy`。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 生成项目与文件维护边界
|
# 生成项目与文件维护边界
|
||||||
|
|
||||||
v0.17.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
v0.18.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||||
|
|
||||||
## 创建和日常开发
|
## 创建和日常开发
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ ERROR [logicSignature] internal/logic/greet/greet.go:12 (GET /api/greet/:name):
|
|||||||
| `internal/logic/<group>/*.go` | 业务方法,使用标准 context.Context |
|
| `internal/logic/<group>/*.go` | 业务方法,使用标准 context.Context |
|
||||||
| `internal/repository/<group>/*.go` | 数据访问;默认不补空骨架,可选 group 模式 |
|
| `internal/repository/<group>/*.go` | 数据访问;默认不补空骨架,可选 group 模式 |
|
||||||
| `internal/middleware/*Factory.go`、`dependencies.go` | 中间件工厂与依赖小接口,svc 实现访问器 |
|
| `internal/middleware/*Factory.go`、`dependencies.go` | 中间件工厂与依赖小接口,svc 实现访问器 |
|
||||||
| `omni.gen.json`、映射转换函数 | 生成选项、显式类型对与差异规则 |
|
| `omni.gen.json`、映射转换函数(支持 `mappingFiles` 引用子文件) | 生成选项、显式类型对与差异规则 |
|
||||||
| 项目 Go 错误包 | 自行维护 Catalog 和常量,可跨服务共用 |
|
| 项目 Go 错误包 | 自行维护 Catalog 和常量,可跨服务共用 |
|
||||||
| 自定义标量文件、`*Bind.go`、`*Manual.go` | 只有显式选择相应扩展时才生成/使用 |
|
| 自定义标量文件、`*Bind.go`、`*Manual.go` | 只有显式选择相应扩展时才生成/使用 |
|
||||||
| 项目测试、迁移、部署脚本、go.mod/go.sum | 项目所有,生成器不重置其内容 |
|
| 项目测试、迁移、部署脚本、go.mod/go.sum | 项目所有,生成器不重置其内容 |
|
||||||
@@ -211,7 +211,7 @@ get /download (DownloadReq)
|
|||||||
|
|
||||||
## 已有服务接入步骤
|
## 已有服务接入步骤
|
||||||
|
|
||||||
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.17.0-preview:
|
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.18.0-preview:
|
||||||
|
|
||||||
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
||||||
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
||||||
|
|
||||||
**从这份 README 开始接入。** 当前版本 **`v0.17.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
**从这份 README 开始接入。** 当前版本 **`v0.18.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
**本版新增**:公共字段组合、可选结构映射、带依赖的中间件工厂、按需 Repository 骨架,使用与迁移见 [生成能力指南](GENERATION.md)。
|
**v0.17 提供**:公共字段组合、可选结构映射、带依赖的中间件工厂、按需 Repository 骨架,使用与迁移见 [生成能力指南](GENERATION.md)。
|
||||||
|
|
||||||
|
**本版新增**:可空嵌套映射、映射配置按文件拆分、显式 nil 策略和可选指针返回辅助,见 [生成能力指南](GENERATION.md)。
|
||||||
|
|
||||||
Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
|
Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
|
||||||
|
|
||||||
@@ -38,7 +40,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.17.0-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.18.0-preview
|
||||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
go-omni --help
|
go-omni --help
|
||||||
```
|
```
|
||||||
@@ -195,7 +197,7 @@ go-omni new --api catalog.api \
|
|||||||
在业务项目的 **go.mod 所在目录**执行:
|
在业务项目的 **go.mod 所在目录**执行:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get git.yuntuo.hk/k2safe/go-omni@v0.17.0-preview
|
go get git.yuntuo.hk/k2safe/go-omni@v0.18.0-preview
|
||||||
```
|
```
|
||||||
|
|
||||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
||||||
@@ -465,7 +467,7 @@ go test ./...
|
|||||||
|
|
||||||
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
|
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
|
||||||
|
|
||||||
CLI 与业务 module 固定使用 `v0.17.0-preview`。v0.14.x 项目升级依赖与 CLI 后重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
CLI 与业务 module 固定使用 `v0.18.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.17.0-preview
|
# v0.18.0-preview
|
||||||
|
|
||||||
|
补齐可空嵌套映射和按模块拆分配置,减少重复转换代码,业务空值规则仍由项目显式选择:
|
||||||
|
|
||||||
|
- 嵌套 mapping 支持 `*S → *T` 保留 nil、`S → *T`,以及指定 nil zero/error 后的 `*S → T`;同样适用于 slice/等长数组元素。错误保留字段路径、索引和内部错误链,失败不返回部分结果。
|
||||||
|
- `mappingFiles` 显式引用项目内的映射文件,支持跨文件向前引用;统一检查重名、循环、类型与字段覆盖。禁止多层导入、路径越界和隐式覆盖,生成期间核对全部配置输入。
|
||||||
|
- 同类型 `*T → T` 可显式配置 nil zero/error;现有 converter 保持完整控制,不默认放宽类型或混入领域规则。
|
||||||
|
- `pointerResult: true` 额外生成 `<Name>Ptr(S) (*T,error)`,原值函数保留;失败返回 nil 与原错误,辅助函数名冲突在生成前拒绝。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.18.0-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.18.0-preview
|
||||||
|
go-omni api generate
|
||||||
|
go-omni api generate --check
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
v0.17 的单文件 mappings、现有 converter 和业务文件继续保留。配置拆分时移动完整映射条目,主配置显式列出子文件;不保留重复定义。新能力均为可选生成配置,不增加运行时依赖。nil zero 表示 Go 零值并跳过子映射;业务必填、金额精度、编号格式和快照复制仍由项目负责。
|
||||||
|
|
||||||
|
使用与迁移见 [生成能力指南](GENERATION.md),回归和发布后验收见 [验收记录](reports/nullableMappingAcceptance.md)。本版仍为 preview。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.17.0-preview(历史版本)
|
||||||
|
|
||||||
减少项目里的重复声明、字段赋值和中间件装配代码,业务规则继续由项目控制:
|
减少项目里的重复声明、字段赋值和中间件装配代码,业务规则继续由项目控制:
|
||||||
|
|
||||||
@@ -19,6 +42,8 @@ go test ./...
|
|||||||
|
|
||||||
使用方法、文件归属和升级步骤见 [生成能力指南](GENERATION.md),回归与发布验收见 [验收记录](reports/generationExperienceAcceptance.md)。本版仍为 preview;框架验收不等于业务项目完成迁移。
|
使用方法、文件归属和升级步骤见 [生成能力指南](GENERATION.md),回归与发布验收见 [验收记录](reports/generationExperienceAcceptance.md)。本版仍为 preview;框架验收不等于业务项目完成迁移。
|
||||||
|
|
||||||
|
全量 44 个测试包的 race、vet、build 和实际进程验收通过;发布后的空缓存远程 CLI 与 7 组生成消费者回归也通过,同版依赖且无本地 replace。标签固定在 `8b588a24cd653dac77dd68f6777e0db367981f19`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# v0.16.0-preview(历史版本)
|
# v0.16.0-preview(历史版本)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# go-omni CLI
|
# go-omni CLI
|
||||||
|
|
||||||
|
v0.18 新增:可空嵌套映射、`mappingFiles` 配置拆分、显式 nil 策略和可选 `pointerResult`。原 v0.17 配置继续有效,见 [生成能力指南](../../GENERATION.md)。
|
||||||
|
|
||||||
v0.17 增加公共字段组合、可选结构映射、自动中间件工厂和按需 Repository。配置和迁移见 [生成能力指南](../../GENERATION.md)。
|
v0.17 增加公共字段组合、可选结构映射、自动中间件工厂和按需 Repository。配置和迁移见 [生成能力指南](../../GENERATION.md)。
|
||||||
|
|
||||||
v0.16 增加基础类型/动态 JSON 数组返回、OpenAPI 公共引用和 workspace Go Catalog 统一导出。见 [接入与筛选规则](../../DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。
|
v0.16 增加基础类型/动态 JSON 数组返回、OpenAPI 公共引用和 workspace Go Catalog 统一导出。见 [接入与筛选规则](../../DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。
|
||||||
@@ -12,7 +14,7 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 仓库读取权限与 PATH 配置见根目录 README
|
# 仓库读取权限与 PATH 配置见根目录 README
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.17.0-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.18.0-preview
|
||||||
go-omni new --module example.com/hello ./hello
|
go-omni new --module example.com/hello ./hello
|
||||||
|
|
||||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||||
@@ -47,14 +49,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
|||||||
|
|
||||||
## 版本与迁移
|
## 版本与迁移
|
||||||
|
|
||||||
CLI 与框架依赖固定使用 v0.17.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
CLI 与框架依赖固定使用 v0.18.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 在 go-omni 源码仓库中执行
|
# 在 go-omni 源码仓库中执行
|
||||||
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
||||||
```
|
```
|
||||||
|
|
||||||
当前模板与依赖以 v0.17.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
当前模板与依赖以 v0.18.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||||
|
|
||||||
## 数据库迁移命令
|
## 数据库迁移命令
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||||
const minimumVersion = "v0.17.0-preview"
|
const minimumVersion = "v0.18.0-preview"
|
||||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
@@ -261,8 +261,8 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
|||||||
}
|
}
|
||||||
beforeConfig, _ := json.Marshal(genConfig)
|
beforeConfig, _ := json.Marshal(genConfig)
|
||||||
afterConfig, _ := json.Marshal(currentConfig)
|
afterConfig, _ := json.Marshal(currentConfig)
|
||||||
if hasGenConfig != stillExists || string(beforeConfig) != string(afterConfig) {
|
if hasGenConfig != stillExists || genConfig.inputDigest != currentConfig.inputDigest || string(beforeConfig) != string(afterConfig) {
|
||||||
return Output{}, errors.New("omni.gen.json changed during generation; retry")
|
return Output{}, errors.New("generation configuration changed during generation; retry")
|
||||||
}
|
}
|
||||||
out, err := apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec), o.lockHeld)
|
out, err := apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec), o.lockHeld)
|
||||||
expectedVersion := minimumVersion
|
expectedVersion := minimumVersion
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package apigen
|
package apigen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
)
|
)
|
||||||
@@ -15,9 +18,11 @@ const generatorConfigFile = "omni.gen.json"
|
|||||||
// GeneratorConfig is developer-owned and checked on every generation. Mapping
|
// GeneratorConfig is developer-owned and checked on every generation. Mapping
|
||||||
// rules never change the public API or infer a persistence model.
|
// rules never change the public API or infer a persistence model.
|
||||||
type GeneratorConfig struct {
|
type GeneratorConfig struct {
|
||||||
Repository string `json:"repository,omitempty"`
|
Repository string `json:"repository,omitempty"`
|
||||||
Middleware string `json:"middleware,omitempty"`
|
Middleware string `json:"middleware,omitempty"`
|
||||||
Mappings []Mapping `json:"mappings,omitempty"`
|
Mappings []Mapping `json:"mappings,omitempty"`
|
||||||
|
MappingFiles []string `json:"mappingFiles,omitempty"`
|
||||||
|
inputDigest string
|
||||||
}
|
}
|
||||||
|
|
||||||
func readGeneratorConfig(root string) (GeneratorConfig, bool, error) {
|
func readGeneratorConfig(root string) (GeneratorConfig, bool, error) {
|
||||||
@@ -49,13 +54,57 @@ func readGeneratorConfig(root string) (GeneratorConfig, bool, error) {
|
|||||||
if !stat.Mode().IsRegular() || stat.Size() > 1<<20 {
|
if !stat.Mode().IsRegular() || stat.Size() > 1<<20 {
|
||||||
return cfg, false, errors.New("omni.gen.json must be a regular file of at most 1 MiB")
|
return cfg, false, errors.New("omni.gen.json must be a regular file of at most 1 MiB")
|
||||||
}
|
}
|
||||||
raw, err := os.ReadFile(filepath.Join(root, generatorConfigFile))
|
raw, err := readMappingConfigFile(root, generatorConfigFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cfg, false, err
|
return cfg, false, err
|
||||||
}
|
}
|
||||||
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
|
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
|
||||||
return cfg, false, fmt.Errorf("omni.gen.json: %w", err)
|
return cfg, false, fmt.Errorf("omni.gen.json: %w", err)
|
||||||
}
|
}
|
||||||
|
// Child files only declare mappings. No recursive imports, globs, implicit
|
||||||
|
// directory scans or access outside the project are supported.
|
||||||
|
if len(cfg.MappingFiles) > 64 {
|
||||||
|
return cfg, false, errors.New("omni.gen.json: at most 64 mappingFiles supported")
|
||||||
|
}
|
||||||
|
digest := sha256.New()
|
||||||
|
fmt.Fprintf(digest, "%s:%d:", generatorConfigFile, len(raw))
|
||||||
|
digest.Write(raw)
|
||||||
|
total := len(raw)
|
||||||
|
for i := range cfg.Mappings {
|
||||||
|
cfg.Mappings[i].origin = generatorConfigFile
|
||||||
|
}
|
||||||
|
seen := map[string]bool{generatorConfigFile: true}
|
||||||
|
for _, name := range cfg.MappingFiles {
|
||||||
|
key := strings.ToLower(name)
|
||||||
|
if seen[key] {
|
||||||
|
return cfg, false, fmt.Errorf("omni.gen.json: duplicate mapping file %q", name)
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
if !strings.HasSuffix(name, ".json") {
|
||||||
|
return cfg, false, fmt.Errorf("mapping file %q must have a .json extension", name)
|
||||||
|
}
|
||||||
|
data, err := readMappingConfigFile(root, name)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, false, err
|
||||||
|
}
|
||||||
|
total += len(data)
|
||||||
|
if total > 4<<20 {
|
||||||
|
return cfg, false, errors.New("mapping configuration exceeds 4 MiB in total")
|
||||||
|
}
|
||||||
|
var file struct {
|
||||||
|
Mappings []Mapping `json:"mappings"`
|
||||||
|
}
|
||||||
|
if err = apierrors.DecodeCatalogJSON(data, &file); err != nil {
|
||||||
|
return cfg, false, fmt.Errorf("%s: %w (mapping files only support mappings; nested imports are not supported)", name, err)
|
||||||
|
}
|
||||||
|
for i := range file.Mappings {
|
||||||
|
file.Mappings[i].origin = name
|
||||||
|
}
|
||||||
|
cfg.Mappings = append(cfg.Mappings, file.Mappings...)
|
||||||
|
fmt.Fprintf(digest, "%s:%d:", name, len(data))
|
||||||
|
digest.Write(data)
|
||||||
|
}
|
||||||
|
cfg.inputDigest = fmt.Sprintf("%x", digest.Sum(nil))
|
||||||
if cfg.Repository != "none" && cfg.Repository != "group" {
|
if cfg.Repository != "none" && cfg.Repository != "group" {
|
||||||
return cfg, false, errors.New("omni.gen.json: repository must be none or group")
|
return cfg, false, errors.New("omni.gen.json: repository must be none or group")
|
||||||
}
|
}
|
||||||
@@ -67,3 +116,34 @@ func readGeneratorConfig(root string) (GeneratorConfig, bool, error) {
|
|||||||
}
|
}
|
||||||
return cfg, true, nil
|
return cfg, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read through a bounded reader as well as checking stat: a growing file cannot
|
||||||
|
// make configuration loading unbounded. safePath also rejects symlink escapes.
|
||||||
|
func readMappingConfigFile(root, name string) ([]byte, error) {
|
||||||
|
if err := safePath(root, name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f, err := os.Open(filepath.Join(root, filepath.FromSlash(name)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
stat, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !stat.Mode().IsRegular() || stat.Size() > 1<<20 {
|
||||||
|
return nil, fmt.Errorf("%s must be a regular file of at most 1 MiB", name)
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(raw) > 1<<20 {
|
||||||
|
return nil, fmt.Errorf("%s exceeds 1 MiB", name)
|
||||||
|
}
|
||||||
|
if trimmed := strings.TrimSpace(string(raw)); !strings.HasPrefix(trimmed, "{") {
|
||||||
|
return nil, fmt.Errorf("%s must contain a JSON object", name)
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|||||||
141
cmd/go-omni/apigen/generatorConfig_test.go
Normal file
141
cmd/go-omni/apigen/generatorConfig_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMappingFileConfiguration(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
defs := nullableMappings()
|
||||||
|
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", Mappings: defs[:1], MappingFiles: []string{"mappings/a.json", "mappings/b.json"}})
|
||||||
|
writeMappingFile(t, dir, "mappings/a.json", defs[1:5])
|
||||||
|
writeMappingFile(t, dir, "mappings/b.json", defs[5:])
|
||||||
|
first, exists, err := readGeneratorConfig(dir)
|
||||||
|
if err != nil || !exists || len(first.Mappings) != len(defs) {
|
||||||
|
t.Fatal(first, err)
|
||||||
|
}
|
||||||
|
for i, m := range first.Mappings {
|
||||||
|
if m.Name != defs[i].Name {
|
||||||
|
t.Fatal("non-deterministic load order")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
second, _, err := readGeneratorConfig(dir)
|
||||||
|
if err != nil || first.inputDigest != second.inputDigest {
|
||||||
|
t.Fatal("unstable config digest", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "mappings/a.json")
|
||||||
|
writeFile(t, path, readFile(t, path)+"\n")
|
||||||
|
changed, _, err := readGeneratorConfig(dir)
|
||||||
|
if err != nil || changed.inputDigest == first.inputDigest {
|
||||||
|
t.Fatal("child file change invisible to generation guard", err)
|
||||||
|
}
|
||||||
|
// Duplicate definitions name both files instead of silently overriding.
|
||||||
|
writeMappingFile(t, dir, "mappings/b.json", defs[1:2])
|
||||||
|
cfg, _, err := readGeneratorConfig(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = validateMappings(cfg.Mappings, "example.com/nullable")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "mappings/a.json") || !strings.Contains(err.Error(), "mappings/b.json") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMappingFileRejection(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ name, root, child, want string }{
|
||||||
|
{"missing", `{"mappingFiles":["maps.json"]}`, "", "maps.json"},
|
||||||
|
{"escape", `{"mappingFiles":["../maps.json"]}`, "", "unsafe"},
|
||||||
|
{"absolute", `{"mappingFiles":["/maps.json"]}`, "", "unsafe"},
|
||||||
|
{"nonCanonical", `{"mappingFiles":["sub/../maps.json"]}`, "", "unsafe"},
|
||||||
|
{"duplicate", `{"mappingFiles":["maps.json","maps.json"]}`, `{"mappings":[]}`, "duplicate mapping file"},
|
||||||
|
{"caseDuplicate", `{"mappingFiles":["maps.json","MAPS.json"]}`, `{"mappings":[]}`, "duplicate mapping file"},
|
||||||
|
{"self", `{"mappingFiles":["omni.gen.json"]}`, "", "duplicate mapping file"},
|
||||||
|
{"recursive", `{"mappingFiles":["maps.json"]}`, `{"mappingFiles":["omni.gen.json"]}`, "nested imports are not supported"},
|
||||||
|
{"childSettings", `{"mappingFiles":["maps.json"]}`, `{"repository":"none","mappings":[]}`, "maps.json"},
|
||||||
|
{"duplicateKeys", `{"mappingFiles":["maps.json"]}`, `{"mappings":[],"mappings":[]}`, "duplicate key"},
|
||||||
|
{"rootNull", `null`, "", "JSON object"},
|
||||||
|
{"childNull", `{"mappingFiles":["maps.json"]}`, `null`, "JSON object"},
|
||||||
|
{"unknownPolicy", `{"mappingFiles":["maps.json"]}`, `{"mappings":[{"unknown":true}]}`, "unknown field"},
|
||||||
|
{"trailingValue", `{"mappingFiles":["maps.json"]}`, `{"mappings":[]} {}`, "maps.json"},
|
||||||
|
{"tooLarge", `{"mappingFiles":["maps.json"]}`, strings.Repeat(" ", 1<<20) + `{}`, "1 MiB"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(dir, generatorConfigFile), tc.root)
|
||||||
|
if tc.child != "" {
|
||||||
|
writeFile(t, filepath.Join(dir, "maps.json"), tc.child)
|
||||||
|
}
|
||||||
|
before := snapshot(t, dir)
|
||||||
|
_, _, err := readGeneratorConfig(dir)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("read changed configuration")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, directory := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("symlinkDirectory%t", directory), func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
writeMappingFile(t, outside, "maps.json", nil)
|
||||||
|
name := "maps.json"
|
||||||
|
target := filepath.Join(outside, name)
|
||||||
|
link := filepath.Join(dir, name)
|
||||||
|
if directory {
|
||||||
|
name = "linked/maps.json"
|
||||||
|
target = outside
|
||||||
|
link = filepath.Join(dir, "linked")
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: []string{name}})
|
||||||
|
if _, _, err := readGeneratorConfig(dir); err == nil || !strings.Contains(err.Error(), "symlink") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMappingFileBounds(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
files := make([]string, 65)
|
||||||
|
for i := range files {
|
||||||
|
files[i] = fmt.Sprintf("maps%d.json", i)
|
||||||
|
}
|
||||||
|
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: files})
|
||||||
|
if _, _, err := readGeneratorConfig(dir); err == nil || !strings.Contains(err.Error(), "64") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: files[:4]})
|
||||||
|
for _, name := range files[:4] {
|
||||||
|
writeFile(t, filepath.Join(dir, name), `{"mappings":[]}`+strings.Repeat(" ", (1<<20)-len(`{"mappings":[]}`)))
|
||||||
|
}
|
||||||
|
if _, _, err := readGeneratorConfig(dir); err == nil || !strings.Contains(err.Error(), "4 MiB") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The definition limit applies after merging, not separately to each file.
|
||||||
|
defs := make([]Mapping, 129)
|
||||||
|
for i := range defs {
|
||||||
|
defs[i] = nullableMappings()[0]
|
||||||
|
defs[i].Name = fmt.Sprintf("Map%d", i)
|
||||||
|
}
|
||||||
|
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: files[:2]})
|
||||||
|
writeMappingFile(t, dir, files[0], defs[:65])
|
||||||
|
writeMappingFile(t, dir, files[1], defs[65:])
|
||||||
|
if _, _, err := readGeneratorConfig(dir); err == nil || !strings.Contains(err.Error(), "128") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Origins/digests are loader metadata, never serialized into user configuration.
|
||||||
|
raw, err := json.Marshal(GeneratorConfig{inputDigest: "private"})
|
||||||
|
if err != nil || strings.Contains(string(raw), "private") {
|
||||||
|
t.Fatal(string(raw), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,6 +176,9 @@ func generateManagedTestProject(t *testing.T, o Options) error {
|
|||||||
if o.Check {
|
if o.Check {
|
||||||
args = append(args, "--check")
|
args = append(args, "--check")
|
||||||
}
|
}
|
||||||
|
if o.DryRun {
|
||||||
|
args = append(args, "--dry-run")
|
||||||
|
}
|
||||||
cmd := exec.CommandContext(ctx, cli, args...)
|
cmd := exec.CommandContext(ctx, cli, args...)
|
||||||
cmd.Dir = filepath.Dir(o.Dir)
|
cmd.Dir = filepath.Dir(o.Dir)
|
||||||
if data, err := cmd.CombinedOutput(); err != nil {
|
if data, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
|||||||
@@ -28,14 +28,17 @@ type MappingField struct {
|
|||||||
Convert *GoFunctionRef `json:"convert,omitempty"`
|
Convert *GoFunctionRef `json:"convert,omitempty"`
|
||||||
Mapping string `json:"mapping,omitempty"`
|
Mapping string `json:"mapping,omitempty"`
|
||||||
Elements bool `json:"elements,omitempty"`
|
Elements bool `json:"elements,omitempty"`
|
||||||
|
Nil string `json:"nil,omitempty"`
|
||||||
}
|
}
|
||||||
type Mapping struct {
|
type Mapping struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Source GoTypeRef `json:"source"`
|
PointerResult bool `json:"pointerResult,omitempty"`
|
||||||
Target GoTypeRef `json:"target"`
|
origin string
|
||||||
Fields map[string]MappingField `json:"fields,omitempty"`
|
Source GoTypeRef `json:"source"`
|
||||||
IgnoreSource map[string]string `json:"ignoreSource,omitempty"`
|
Target GoTypeRef `json:"target"`
|
||||||
IgnoreTarget map[string]string `json:"ignoreTarget,omitempty"`
|
Fields map[string]MappingField `json:"fields,omitempty"`
|
||||||
|
IgnoreSource map[string]string `json:"ignoreSource,omitempty"`
|
||||||
|
IgnoreTarget map[string]string `json:"ignoreTarget,omitempty"`
|
||||||
}
|
}
|
||||||
type mappingField struct {
|
type mappingField struct {
|
||||||
path, name string
|
path, name string
|
||||||
@@ -84,7 +87,7 @@ func renderMappings(o Options, generated, seeds map[string][]byte) error {
|
|||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
source, err := r.render(r.definitions[name])
|
source, err := r.render(r.definitions[name])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("mapping %s: %w", name, err)
|
return fmt.Errorf("%s: mapping %s: %w", mappingOrigin(r.definitions[name]), name, err)
|
||||||
}
|
}
|
||||||
body.WriteString(source)
|
body.WriteString(source)
|
||||||
}
|
}
|
||||||
@@ -119,7 +122,7 @@ func renderMappings(o Options, generated, seeds map[string][]byte) error {
|
|||||||
|
|
||||||
func validateMappings(defs []Mapping, project string) ([]string, error) {
|
func validateMappings(defs []Mapping, project string) ([]string, error) {
|
||||||
refs := map[string]bool{}
|
refs := map[string]bool{}
|
||||||
names := map[string]bool{}
|
names := map[string]string{}
|
||||||
validPackage := func(path string) bool {
|
validPackage := func(path string) bool {
|
||||||
if module.CheckImportPath(path) != nil || path == project+"/internal/mapping" {
|
if module.CheckImportPath(path) != nil || path == project+"/internal/mapping" {
|
||||||
return false
|
return false
|
||||||
@@ -134,10 +137,19 @@ func validateMappings(defs []Mapping, project string) ([]string, error) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for _, m := range defs {
|
for _, m := range defs {
|
||||||
if !gotoken.IsIdentifier(m.Name) || !gotoken.IsExported(m.Name) || names[m.Name] {
|
if !gotoken.IsIdentifier(m.Name) || !gotoken.IsExported(m.Name) {
|
||||||
return nil, fmt.Errorf("mapping name %q must be unique and exported", m.Name)
|
return nil, fmt.Errorf("mapping name %q must be unique and exported", m.Name)
|
||||||
}
|
}
|
||||||
names[m.Name] = true
|
functions := []string{m.Name}
|
||||||
|
if m.PointerResult {
|
||||||
|
functions = append(functions, m.Name+"Ptr")
|
||||||
|
}
|
||||||
|
for _, name := range functions {
|
||||||
|
if prior, exists := names[name]; exists {
|
||||||
|
return nil, fmt.Errorf("mapping function %q must be unique: %s conflicts with %s", name, mappingOrigin(m), prior)
|
||||||
|
}
|
||||||
|
names[name] = mappingOrigin(m)
|
||||||
|
}
|
||||||
for _, ref := range []GoTypeRef{m.Source, m.Target} {
|
for _, ref := range []GoTypeRef{m.Source, m.Target} {
|
||||||
if !validPackage(ref.Package) || !gotoken.IsIdentifier(ref.Type) || !gotoken.IsExported(ref.Type) {
|
if !validPackage(ref.Package) || !gotoken.IsIdentifier(ref.Type) || !gotoken.IsExported(ref.Type) {
|
||||||
return nil, fmt.Errorf("mapping %s: invalid Go type reference", m.Name)
|
return nil, fmt.Errorf("mapping %s: invalid Go type reference", m.Name)
|
||||||
@@ -151,6 +163,12 @@ func validateMappings(defs []Mapping, project string) ([]string, error) {
|
|||||||
if target == "" || f.Source == "" {
|
if target == "" || f.Source == "" {
|
||||||
return nil, fmt.Errorf("mapping %s: explicit field rule requires target and source paths", m.Name)
|
return nil, fmt.Errorf("mapping %s: explicit field rule requires target and source paths", m.Name)
|
||||||
}
|
}
|
||||||
|
if f.Nil != "" && f.Nil != "preserve" && f.Nil != "zero" && f.Nil != "error" {
|
||||||
|
return nil, fmt.Errorf("mapping %s.%s: nil must be preserve, zero or error", m.Name, target)
|
||||||
|
}
|
||||||
|
if f.Convert != nil && f.Nil != "" {
|
||||||
|
return nil, fmt.Errorf("mapping %s.%s: nil policy cannot be combined with a converter; the converter owns nil behavior", m.Name, target)
|
||||||
|
}
|
||||||
if f.Convert != nil {
|
if f.Convert != nil {
|
||||||
if f.Mapping != "" || !validPackage(f.Convert.Package) || !gotoken.IsIdentifier(f.Convert.Name) || !gotoken.IsExported(f.Convert.Name) {
|
if f.Mapping != "" || !validPackage(f.Convert.Package) || !gotoken.IsIdentifier(f.Convert.Name) || !gotoken.IsExported(f.Convert.Name) {
|
||||||
return nil, fmt.Errorf("mapping %s.%s: invalid or conflicting converter", m.Name, target)
|
return nil, fmt.Errorf("mapping %s.%s: invalid or conflicting converter", m.Name, target)
|
||||||
@@ -393,16 +411,18 @@ func (r *mappingRenderer) render(m Mapping) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
text += "return result,nil\n}\n"
|
text += "return result,nil\n}\n"
|
||||||
|
if m.PointerResult {
|
||||||
|
text += fmt.Sprintf("// %sPtr returns nil on conversion failure and otherwise a pointer to the result.\nfunc %sPtr(source %s)(*%s,error){\nvalue,err:=%s(source)\nif err!=nil{return nil,err}\nreturn &value,nil\n}\n", m.Name, m.Name, r.typeName(mt.source), r.typeName(mt.target), m.Name)
|
||||||
|
}
|
||||||
return text, nil
|
return text, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mappingRenderer) expression(rule MappingField, from, to types.Type, source string) (string, bool, error) {
|
func (r *mappingRenderer) expression(rule MappingField, from, to types.Type, source string) (string, bool, error) {
|
||||||
if rule.Mapping != "" {
|
if rule.Mapping != "" {
|
||||||
m := r.types[rule.Mapping]
|
return r.nestedExpression(rule, from, to, source)
|
||||||
if !types.Identical(from, m.source) || !types.Identical(to, m.target) {
|
}
|
||||||
return "", false, fmt.Errorf("nested mapping %s has incompatible types", rule.Mapping)
|
if rule.Nil != "" {
|
||||||
}
|
return r.dereferenceExpression(rule, from, to, source)
|
||||||
return rule.Mapping + "(" + source + ")", true, nil
|
|
||||||
}
|
}
|
||||||
if rule.Convert != nil {
|
if rule.Convert != nil {
|
||||||
pkg, err := r.importer.Import(rule.Convert.Package)
|
pkg, err := r.importer.Import(rule.Convert.Package)
|
||||||
|
|||||||
250
cmd/go-omni/apigen/mappingNullable_test.go
Normal file
250
cmd/go-omni/apigen/mappingNullable_test.go
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func nullableMappings() []Mapping {
|
||||||
|
pkg := "example.com/nullable/internal/"
|
||||||
|
ref := func(p, name string) GoTypeRef { return GoTypeRef{pkg + p, name} }
|
||||||
|
child := Mapping{Name: "ToChild", Source: ref("types", "Child"), Target: ref("domain", "Child"), Fields: map[string]MappingField{
|
||||||
|
"Name": {Source: "Name", Convert: &GoFunctionRef{pkg + "convert", "Name"}},
|
||||||
|
}}
|
||||||
|
parent := Mapping{Name: "ToParent", PointerResult: true, Source: ref("types", "Parent"), Target: ref("domain", "Parent"), Fields: map[string]MappingField{
|
||||||
|
"Child": {Source: "Child", Mapping: "ToChild"}, "Children": {Source: "Children", Mapping: "ToChild", Elements: true},
|
||||||
|
}}
|
||||||
|
defs := []Mapping{child, parent,
|
||||||
|
{Name: "ToWireChild", Source: ref("domain", "Child"), Target: ref("types", "Child")},
|
||||||
|
{Name: "ToWire", PointerResult: true, Source: ref("domain", "Parent"), Target: ref("types", "Parent"), Fields: map[string]MappingField{
|
||||||
|
"Child": {Source: "Child", Mapping: "ToWireChild"}, "Children": {Source: "Children", Mapping: "ToWireChild", Elements: true},
|
||||||
|
}},
|
||||||
|
{Name: "ToChildDTO", Source: ref("domain", "Child"), Target: ref("domain", "ChildDTO"), Fields: map[string]MappingField{"Name": {Source: "Name", Convert: &GoFunctionRef{pkg + "convert", "Name"}}}},
|
||||||
|
{Name: "Lift", Source: ref("domain", "ValueHolder"), Target: ref("domain", "PointerHolder"), Fields: map[string]MappingField{"Child": {Source: "Child", Mapping: "ToChildDTO"}}},
|
||||||
|
{Name: "Batch", Source: ref("domain", "InputBatch"), Target: ref("domain", "OutputBatch"), Fields: map[string]MappingField{"Fixed": {Source: "Fixed", Mapping: "ToChildDTO", Elements: true}}},
|
||||||
|
{Name: "ShallowCopy", Source: ref("domain", "Parent"), Target: ref("domain", "Parent")},
|
||||||
|
{Name: "RequirePointer", Source: ref("types", "Parent"), Target: ref("domain", "Parent"), Fields: map[string]MappingField{
|
||||||
|
"Child": {Source: "Child", Mapping: "ToChild", Nil: "error"}, "Children": {Source: "Children", Mapping: "ToChild", Elements: true},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, p := range []struct{ name, policy string }{{"RequiredZero", "zero"}, {"RequiredError", "error"}} {
|
||||||
|
defs = append(defs, Mapping{Name: p.name, Source: ref("types", "Parent"), Target: ref("domain", "Required"), Fields: map[string]MappingField{
|
||||||
|
"Child": {Source: "Child", Mapping: "ToChild", Nil: p.policy},
|
||||||
|
}, IgnoreSource: map[string]string{"Children": "this explicit projection only includes the child"}})
|
||||||
|
}
|
||||||
|
for _, p := range []struct{ name, policy string }{{"ScalarZero", "zero"}, {"ScalarError", "error"}} {
|
||||||
|
m := Mapping{Name: p.name, Source: ref("domain", "ScalarInput"), Target: ref("domain", "ScalarOutput"), Fields: map[string]MappingField{}}
|
||||||
|
for _, name := range []string{"Text", "Count", "Enabled"} {
|
||||||
|
m.Fields[name] = MappingField{Source: name, Nil: p.policy}
|
||||||
|
}
|
||||||
|
defs = append(defs, m)
|
||||||
|
}
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMappingFile(t *testing.T, root, name string, defs []Mapping) {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.MarshalIndent(struct {
|
||||||
|
Mappings []Mapping `json:"mappings"`
|
||||||
|
}{defs}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(root, name), string(raw)+"\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNullableMappingConsumer(t *testing.T) {
|
||||||
|
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||||
|
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||||
|
}
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "app")
|
||||||
|
o := Options{API: filepath.Join(frameworkRoot(t), "cmd/go-omni/apigen/testdata/nullable/service.api"), Dir: dir, Module: "example.com/nullable", FrameworkDir: frameworkRoot(t)}
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for path, fixture := range map[string]string{"internal/domain/model.go": "domain.go", "internal/convert/value.go": "convert.go"} {
|
||||||
|
writeFile(t, filepath.Join(dir, path), readFile(t, "testdata/nullable/"+fixture))
|
||||||
|
}
|
||||||
|
runCompositionGo(t, dir, "mod", "tidy")
|
||||||
|
defs := nullableMappings()
|
||||||
|
// References go across files, including forward references to ToChild.
|
||||||
|
writeMappingFile(t, dir, "mappings/parents.json", defs[1:])
|
||||||
|
writeMappingFile(t, dir, "mappings/children.json", defs[:1])
|
||||||
|
cfg := GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: []string{"mappings/parents.json", "mappings/children.json"}}
|
||||||
|
writeGeneratorConfig(t, dir, cfg)
|
||||||
|
o.API = filepath.Join(dir, "api/service.api")
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, "internal/logic/probe/roundTrip.go"), `package probe
|
||||||
|
import("context";"example.com/nullable/internal/types";"example.com/nullable/internal/mapping")
|
||||||
|
func(s *Logic)RoundTrip(ctx context.Context,req *types.Parent)(*types.Parent,error){
|
||||||
|
value,err:=mapping.ToParent(*req);if err!=nil{return nil,err}
|
||||||
|
return mapping.ToWirePtr(value)
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/nullable/consumer_test.go"))
|
||||||
|
before := snapshot(t, dir)
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("regeneration changed business/config files")
|
||||||
|
}
|
||||||
|
o.Check = true
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("check wrote files")
|
||||||
|
}
|
||||||
|
runCompositionGo(t, dir, "test", "-race", "-count=1", "./...")
|
||||||
|
runCompositionGo(t, dir, "build", ".")
|
||||||
|
// Workspace preflight and check use the same split configuration loader.
|
||||||
|
workspace := filepath.Join(root, "omni.workspace.json")
|
||||||
|
writeFile(t, workspace, `{"services":[{"dir":"app"}]}`)
|
||||||
|
for _, command := range []string{"format", "generate", "check"} {
|
||||||
|
if err := nullableWorkspace(t, workspace, command); err != nil {
|
||||||
|
t.Fatal(command, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All invalid configurations fail before either writing or previewing output.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, want string
|
||||||
|
edit func([]Mapping) []Mapping
|
||||||
|
}{
|
||||||
|
{"missingNilPolicy", "requires explicit nil", func(d []Mapping) []Mapping {
|
||||||
|
d[9].Fields["Child"] = MappingField{Source: "Child", Mapping: "ToChild"}
|
||||||
|
return d
|
||||||
|
}},
|
||||||
|
{"preserveValue", "requires explicit nil", func(d []Mapping) []Mapping {
|
||||||
|
d[9].Fields["Child"] = MappingField{Source: "Child", Mapping: "ToChild", Nil: "preserve"}
|
||||||
|
return d
|
||||||
|
}},
|
||||||
|
{"zeroPointer", "pointer target", func(d []Mapping) []Mapping {
|
||||||
|
d[1].Fields["Child"] = MappingField{Source: "Child", Mapping: "ToChild", Nil: "zero"}
|
||||||
|
return d
|
||||||
|
}},
|
||||||
|
{"scalarMismatch", "identical *T to T", func(d []Mapping) []Mapping {
|
||||||
|
d[11].Fields["Text"] = MappingField{Source: "Count", Nil: "zero"}
|
||||||
|
return d
|
||||||
|
}},
|
||||||
|
{"valueNil", "requires a pointer source", func(d []Mapping) []Mapping {
|
||||||
|
d[5].Fields["Child"] = MappingField{Source: "Child", Mapping: "ToChildDTO", Nil: "zero"}
|
||||||
|
return d
|
||||||
|
}},
|
||||||
|
{"converterNil", "cannot be combined", func(d []Mapping) []Mapping {
|
||||||
|
f := d[0].Fields["Name"]
|
||||||
|
f.Nil = "error"
|
||||||
|
d[0].Fields["Name"] = f
|
||||||
|
return d
|
||||||
|
}},
|
||||||
|
{"pointerNameCollision", "must be unique", func(d []Mapping) []Mapping { m := d[0]; m.Name = "ToParentPtr"; return append(d, m) }},
|
||||||
|
{"mappingCycle", "cycle", func(d []Mapping) []Mapping {
|
||||||
|
d[0].Fields["Name"] = MappingField{Source: "Name", Mapping: "ToParent"}
|
||||||
|
return d
|
||||||
|
}},
|
||||||
|
{"unknownMapping", "unknown mapping", func(d []Mapping) []Mapping {
|
||||||
|
d[1].Fields["Child"] = MappingField{Source: "Child", Mapping: "Missing"}
|
||||||
|
return d
|
||||||
|
}},
|
||||||
|
{"unhandledField", "unmapped source", func(d []Mapping) []Mapping { d[9].IgnoreSource = nil; return d }},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
changed := tc.edit(nullableMappings())
|
||||||
|
writeMappingFile(t, dir, "mappings/parents.json", changed[1:])
|
||||||
|
writeMappingFile(t, dir, "mappings/children.json", changed[:1])
|
||||||
|
defer writeMappingFile(t, dir, "mappings/parents.json", defs[1:])
|
||||||
|
defer writeMappingFile(t, dir, "mappings/children.json", defs[:1])
|
||||||
|
before := snapshot(t, dir)
|
||||||
|
for _, mode := range []string{"check", "preview", "generate"} {
|
||||||
|
current := o
|
||||||
|
current.Check = mode == "check"
|
||||||
|
current.DryRun = mode == "preview"
|
||||||
|
err := generateManagedTestProject(t, current)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatal(mode, err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("invalid mapping wrote files")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// An edit inside a child file is picked up by --check; the config is never rewritten.
|
||||||
|
changed := nullableMappings()
|
||||||
|
changed[0].PointerResult = true
|
||||||
|
writeMappingFile(t, dir, "mappings/children.json", changed[:1])
|
||||||
|
before = snapshot(t, dir)
|
||||||
|
if err := generateManagedTestProject(t, o); err == nil {
|
||||||
|
t.Fatal("check missed child mapping drift")
|
||||||
|
}
|
||||||
|
if err := nullableWorkspace(t, workspace, "check"); err == nil {
|
||||||
|
t.Fatal("workspace missed child mapping drift")
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("drift check wrote files")
|
||||||
|
}
|
||||||
|
preview := o
|
||||||
|
preview.Check = false
|
||||||
|
preview.DryRun = true
|
||||||
|
if err := generateManagedTestProject(t, preview); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("preview wrote files")
|
||||||
|
}
|
||||||
|
if err := nullableWorkspace(t, workspace, "generate"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := nullableWorkspace(t, workspace, "check"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||||
|
data, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err, string(data))
|
||||||
|
}
|
||||||
|
var dependency struct {
|
||||||
|
Version, Sum string
|
||||||
|
Replace any
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(data, &dependency); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" {
|
||||||
|
t.Fatal("not a fixed remote consumer")
|
||||||
|
}
|
||||||
|
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableWorkspace(t *testing.T, path, command string) error {
|
||||||
|
t.Helper()
|
||||||
|
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
|
||||||
|
if cli == "" {
|
||||||
|
return Workspace(path, command, io.Discard)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, cli, "workspace", command, "--file", path)
|
||||||
|
cmd.Dir = filepath.Dir(path)
|
||||||
|
if data, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("released workspace CLI: %w\n%s", err, data)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
97
cmd/go-omni/apigen/mappingPointer.go
Normal file
97
cmd/go-omni/apigen/mappingPointer.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mappingOrigin(m Mapping) string {
|
||||||
|
if m.origin != "" {
|
||||||
|
return m.origin
|
||||||
|
}
|
||||||
|
return generatorConfigFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer adaptation is only selected by an explicit nested mapping. Ordinary
|
||||||
|
// same-type pointer assignments retain their existing reference semantics.
|
||||||
|
func (r *mappingRenderer) nestedExpression(rule MappingField, from, to types.Type, source string) (string, bool, error) {
|
||||||
|
m := r.types[rule.Mapping]
|
||||||
|
fromValue, toValue := from, to
|
||||||
|
src, sourcePointer := from.Underlying().(*types.Pointer)
|
||||||
|
dst, targetPointer := to.Underlying().(*types.Pointer)
|
||||||
|
if sourcePointer {
|
||||||
|
fromValue = src.Elem()
|
||||||
|
}
|
||||||
|
if targetPointer {
|
||||||
|
toValue = dst.Elem()
|
||||||
|
}
|
||||||
|
if !types.Identical(fromValue, m.source) || !types.Identical(toValue, m.target) {
|
||||||
|
return "", false, fmt.Errorf("nested mapping %s has incompatible types", rule.Mapping)
|
||||||
|
}
|
||||||
|
if !sourcePointer && rule.Nil != "" {
|
||||||
|
return "", false, fmt.Errorf("nil policy requires a pointer source")
|
||||||
|
}
|
||||||
|
if !sourcePointer && !targetPointer {
|
||||||
|
return rule.Mapping + "(" + source + ")", true, nil
|
||||||
|
}
|
||||||
|
policy := rule.Nil
|
||||||
|
if sourcePointer {
|
||||||
|
if targetPointer {
|
||||||
|
if policy == "" {
|
||||||
|
policy = "preserve"
|
||||||
|
}
|
||||||
|
if policy != "preserve" && policy != "error" {
|
||||||
|
return "", false, fmt.Errorf("pointer target requires nil preserve or error")
|
||||||
|
}
|
||||||
|
} else if policy != "zero" && policy != "error" {
|
||||||
|
return "", false, fmt.Errorf("pointer to value mapping requires explicit nil policy zero or error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep a typed local input so the source is evaluated once. The surrounding
|
||||||
|
// renderer adds the mapping/field/index error path and returns a zero parent.
|
||||||
|
body := "var zero " + r.typeName(to) + "\n"
|
||||||
|
input := "input"
|
||||||
|
if sourcePointer {
|
||||||
|
nilResult, err := r.nilResult(policy)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
body += "if input==nil {" + nilResult + "}\n"
|
||||||
|
input = "*input"
|
||||||
|
}
|
||||||
|
body += "value,err:=" + rule.Mapping + "(" + input + ")\nif err!=nil{return zero,err}\n"
|
||||||
|
if targetPointer {
|
||||||
|
body += "return &value,nil\n"
|
||||||
|
} else {
|
||||||
|
body += "return value,nil\n"
|
||||||
|
}
|
||||||
|
return "func(input " + r.typeName(from) + ")(" + r.typeName(to) + ",error){\n" + body + "}(" + source + ")", true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A nil policy without a converter/mapping is deliberately limited to *T -> T.
|
||||||
|
// It never casts numbers, changes named types, or invents business defaults.
|
||||||
|
func (r *mappingRenderer) dereferenceExpression(rule MappingField, from, to types.Type, source string) (string, bool, error) {
|
||||||
|
pointer, ok := from.Underlying().(*types.Pointer)
|
||||||
|
if !ok || !types.Identical(pointer.Elem(), to) {
|
||||||
|
return "", false, fmt.Errorf("nil policy without a mapping requires identical *T to T types")
|
||||||
|
}
|
||||||
|
if rule.Nil != "zero" && rule.Nil != "error" {
|
||||||
|
return "", false, fmt.Errorf("pointer to value conversion requires nil zero or error")
|
||||||
|
}
|
||||||
|
nilResult, err := r.nilResult(rule.Nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return "func(input " + r.typeName(from) + ")(" + r.typeName(to) + ",error){\nvar zero " + r.typeName(to) + "\nif input==nil{" + nilResult + "}\nreturn *input,nil\n}(" + source + ")", true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mappingRenderer) nilResult(policy string) (string, error) {
|
||||||
|
if policy != "error" {
|
||||||
|
return "return zero,nil", nil
|
||||||
|
}
|
||||||
|
pkg, err := r.importer.Import("fmt")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "return zero," + r.qualifier(pkg) + ".Errorf(\"nil source is not allowed\")", nil
|
||||||
|
}
|
||||||
@@ -252,7 +252,7 @@ func TestMappingConfigurationRejection(t *testing.T) {
|
|||||||
}, "conflicting converter"},
|
}, "conflicting converter"},
|
||||||
{"internal", func(c *GeneratorConfig) { c.Mappings[0].Source.Package = "example.net/private/internal/types" }, "invalid Go type"},
|
{"internal", func(c *GeneratorConfig) { c.Mappings[0].Source.Package = "example.net/private/internal/types" }, "invalid Go type"},
|
||||||
{"outputCycle", func(c *GeneratorConfig) { c.Mappings[0].Source.Package = "example.com/composition/internal/mapping" }, "invalid Go type"},
|
{"outputCycle", func(c *GeneratorConfig) { c.Mappings[0].Source.Package = "example.com/composition/internal/mapping" }, "invalid Go type"},
|
||||||
{"duplicates", func(c *GeneratorConfig) { c.Mappings[1].Name = c.Mappings[0].Name }, "unique and exported"},
|
{"duplicates", func(c *GeneratorConfig) { c.Mappings[1].Name = c.Mappings[0].Name }, "must be unique"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
170
cmd/go-omni/apigen/testdata/nullable/consumer_test.go
vendored
Normal file
170
cmd/go-omni/apigen/testdata/nullable/consumer_test.go
vendored
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"example.com/nullable/internal/convert"
|
||||||
|
"example.com/nullable/internal/domain"
|
||||||
|
"example.com/nullable/internal/handler"
|
||||||
|
"example.com/nullable/internal/mapping"
|
||||||
|
"example.com/nullable/internal/svc"
|
||||||
|
"example.com/nullable/internal/types"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNullableMappings(t *testing.T) {
|
||||||
|
for _, src := range []types.Parent{{}, {Children: []*types.Child{}}, {Child: &types.Child{}, Children: []*types.Child{nil, {Name: "two"}}}} {
|
||||||
|
out, err := mapping.ToParent(src)
|
||||||
|
if err != nil || (src.Child == nil) != (out.Child == nil) || (src.Children == nil) != (out.Children == nil) || len(src.Children) != len(out.Children) {
|
||||||
|
t.Fatal(src, out, err)
|
||||||
|
}
|
||||||
|
if src.Child != nil && out.Child.Name != src.Child.Name {
|
||||||
|
t.Fatal("zero child changed")
|
||||||
|
}
|
||||||
|
if len(out.Children) > 0 && (out.Children[0] != nil || out.Children[1].Name != "two") {
|
||||||
|
t.Fatal(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src := types.Parent{Child: &types.Child{Name: "one"}, Children: []*types.Child{{Name: "two"}}}
|
||||||
|
out, err := mapping.ToParentPtr(src)
|
||||||
|
if err != nil || out == nil || out.Child.Name != "one" {
|
||||||
|
t.Fatal(out, err)
|
||||||
|
}
|
||||||
|
out.Child.Name = "changed"
|
||||||
|
out.Children[0].Name = "changed"
|
||||||
|
if src.Child.Name != "one" || src.Children[0].Name != "two" {
|
||||||
|
t.Fatal("nested mapped values alias input")
|
||||||
|
}
|
||||||
|
plain := domain.Parent{Child: &domain.Child{Name: "shared"}}
|
||||||
|
copy, err := mapping.ShallowCopy(plain)
|
||||||
|
if err != nil || copy.Child != plain.Child {
|
||||||
|
t.Fatal("ordinary assignment semantics changed", err)
|
||||||
|
}
|
||||||
|
zero, err := mapping.RequiredZero(types.Parent{})
|
||||||
|
if err != nil || zero.Child.Name != "" {
|
||||||
|
t.Fatal(zero, err)
|
||||||
|
}
|
||||||
|
for _, fn := range []func(types.Parent) (domain.Required, error){mapping.RequiredZero, mapping.RequiredError} {
|
||||||
|
out, err := fn(types.Parent{Child: &types.Child{Name: "required"}})
|
||||||
|
if err != nil || out.Child.Name != "required" {
|
||||||
|
t.Fatal(out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = mapping.RequiredError(types.Parent{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "RequiredError.Child") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = mapping.RequirePointer(types.Parent{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "RequirePointer.Child") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lift, err := mapping.Lift(domain.ValueHolder{Child: domain.Child{Name: "lift"}})
|
||||||
|
if err != nil || lift.Child == nil || lift.Child.Name != "lift" {
|
||||||
|
t.Fatal(lift, err)
|
||||||
|
}
|
||||||
|
batch, err := mapping.Batch(domain.InputBatch{Fixed: [2]*domain.Child{nil, {Name: "array"}}})
|
||||||
|
if err != nil || batch.Fixed[0] != nil || batch.Fixed[1].Name != "array" {
|
||||||
|
t.Fatal(batch, err)
|
||||||
|
}
|
||||||
|
_, err = mapping.Batch(domain.InputBatch{Fixed: [2]*domain.Child{nil, {Name: "bad"}}})
|
||||||
|
if !errors.Is(err, convert.ErrValue) || !strings.Contains(err.Error(), "Batch.Fixed[1]") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, input := range []types.Parent{{Child: &types.Child{Name: "bad"}}, {Children: []*types.Child{nil, {Name: "bad"}}}} {
|
||||||
|
failed, err := mapping.ToParentPtr(input)
|
||||||
|
if failed != nil || !errors.Is(err, convert.ErrValue) || !strings.Contains(err.Error(), "ToChild.Name") {
|
||||||
|
t.Fatal(failed, err)
|
||||||
|
}
|
||||||
|
if len(input.Children) > 0 && !strings.Contains(err.Error(), "ToParent.Children[1]") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
value, err := mapping.ToParent(input)
|
||||||
|
if value.Child != nil || value.Children != nil || !errors.Is(err, convert.ErrValue) {
|
||||||
|
t.Fatal("partial result escaped", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExplicitNilAndConcurrentCalls(t *testing.T) {
|
||||||
|
zero, err := mapping.ScalarZero(domain.ScalarInput{})
|
||||||
|
if err != nil || zero != (domain.ScalarOutput{}) {
|
||||||
|
t.Fatal(zero, err)
|
||||||
|
}
|
||||||
|
_, err = mapping.ScalarError(domain.ScalarInput{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("nil accepted")
|
||||||
|
}
|
||||||
|
text, count, enabled := "", int64(0), false
|
||||||
|
out, err := mapping.ScalarError(domain.ScalarInput{Text: &text, Count: &count, Enabled: &enabled})
|
||||||
|
if err != nil || out != (domain.ScalarOutput{}) {
|
||||||
|
t.Fatal("non-nil zero rejected", out, err)
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 32; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
out, err := mapping.ToParent(types.Parent{Child: &types.Child{Name: "parallel"}})
|
||||||
|
if err != nil || out.Child == nil || out.Child.Name != "parallel" {
|
||||||
|
t.Error(out, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPNullableMapping(t *testing.T) {
|
||||||
|
cfg := logx.Default()
|
||||||
|
cfg.Writer = io.Discard
|
||||||
|
logger, err := logx.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer logger.Close()
|
||||||
|
deps, err := svc.New(context.Background(), logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer deps.Close()
|
||||||
|
app, err := handler.NewApp(deps)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer app.Close()
|
||||||
|
for _, body := range []string{`{}`, `{"child":null}`, `{"child":{"name":""}}`, `{"child":{"name":"one"},"children":[null,{"name":"two"}]}`, `{"children":[]}`} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
app.Engine().ServeHTTP(w, req)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var input, output types.Parent
|
||||||
|
if err := json.Unmarshal([]byte(body), &input); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if (input.Child == nil) != (output.Child == nil) || (input.Children == nil) != (output.Children == nil) || len(input.Children) != len(output.Children) {
|
||||||
|
t.Fatal(body, w.Body.String())
|
||||||
|
}
|
||||||
|
if input.Child != nil && input.Child.Name != output.Child.Name {
|
||||||
|
t.Fatal(w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("POST", "/", strings.NewReader(`{"child":{"name":"bad"}}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
app.Engine().ServeHTTP(w, req)
|
||||||
|
if w.Code != 500 || strings.Contains(w.Body.String(), convert.ErrValue.Error()) || strings.Contains(w.Body.String(), "ToChild") {
|
||||||
|
t.Fatal("private cause leaked", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
15
cmd/go-omni/apigen/testdata/nullable/convert.go
vendored
Normal file
15
cmd/go-omni/apigen/testdata/nullable/convert.go
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrValue = errors.New("private conversion cause")
|
||||||
|
|
||||||
|
func Name(value string) (string, error) {
|
||||||
|
if value == "bad" {
|
||||||
|
return "", fmt.Errorf("conversion refused: %w", ErrValue)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
23
cmd/go-omni/apigen/testdata/nullable/domain.go
vendored
Normal file
23
cmd/go-omni/apigen/testdata/nullable/domain.go
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
type Child struct{ Name string }
|
||||||
|
type ChildDTO struct{ Name string }
|
||||||
|
type Parent struct {
|
||||||
|
Child *Child
|
||||||
|
Children []*Child
|
||||||
|
}
|
||||||
|
type Required struct{ Child Child }
|
||||||
|
type ValueHolder struct{ Child Child }
|
||||||
|
type PointerHolder struct{ Child *ChildDTO }
|
||||||
|
type InputBatch struct{ Fixed [2]*Child }
|
||||||
|
type OutputBatch struct{ Fixed [2]*ChildDTO }
|
||||||
|
type ScalarInput struct {
|
||||||
|
Text *string
|
||||||
|
Count *int64
|
||||||
|
Enabled *bool
|
||||||
|
}
|
||||||
|
type ScalarOutput struct {
|
||||||
|
Text string
|
||||||
|
Count int64
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
15
cmd/go-omni/apigen/testdata/nullable/service.api
vendored
Normal file
15
cmd/go-omni/apigen/testdata/nullable/service.api
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
type Child {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
type Parent {
|
||||||
|
Child *Child `json:"child,optional"`
|
||||||
|
Children []*Child `json:"children,optional"`
|
||||||
|
}
|
||||||
|
@server (
|
||||||
|
group: probe
|
||||||
|
response: raw
|
||||||
|
)
|
||||||
|
service probe {
|
||||||
|
@handler roundTrip
|
||||||
|
post / (Parent) returns (Parent)
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||||
for _, flag := range [][]string{{"--framework-version", "v0.17.0-preview"}, {"--framework-version=v0.17.0-preview"}} {
|
for _, flag := range [][]string{{"--framework-version", "v0.18.0-preview"}, {"--framework-version=v0.18.0-preview"}} {
|
||||||
project := filepath.Join(t.TempDir(), "billing")
|
project := filepath.Join(t.TempDir(), "billing")
|
||||||
args := append([]string{"--module", "example.com/billing"}, flag...)
|
args := append([]string{"--module", "example.com/billing"}, flag...)
|
||||||
if err := runNew(append(args, project)); err != nil {
|
if err := runNew(append(args, project)); err != nil {
|
||||||
@@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(content), "go-omni v0.17.0-preview") || strings.Contains(string(content), "replace ") {
|
if !strings.Contains(string(content), "go-omni v0.18.0-preview") || strings.Contains(string(content), "replace ") {
|
||||||
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
|||||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||||
// downloaded module version, which also has a module checksum.
|
// downloaded module version, which also has a module checksum.
|
||||||
const defaultFrameworkVersion = "v0.17.0-preview"
|
const defaultFrameworkVersion = "v0.18.0-preview"
|
||||||
|
|
||||||
// This target is not a claim that the release already exists. Source users
|
// This target is not a claim that the release already exists. Source users
|
||||||
// select a local framework checkout until the module templates are published.
|
// select a local framework checkout until the module templates are published.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"generatorConfig": true,
|
"generatorConfig": true,
|
||||||
"templateVersion": "v0.17.0-preview",
|
"templateVersion": "v0.18.0-preview",
|
||||||
"profile": {
|
"profile": {
|
||||||
"database": "none",
|
"database": "none",
|
||||||
"storage": "none",
|
"storage": "none",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module example.com/managed
|
|||||||
|
|
||||||
go 1.22.0
|
go 1.22.0
|
||||||
|
|
||||||
require git.yuntuo.hk/k2safe/go-omni v0.17.0-preview
|
require git.yuntuo.hk/k2safe/go-omni v0.18.0-preview
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
|
|||||||
@@ -23,7 +23,21 @@
|
|||||||
|
|
||||||
[实际进程验收](generationExperienceLocal.json):根目录 go run/go build、重复生成、生成检查、业务保留、nethttp/native 的 SIGINT/SIGTERM、监听端口释放、下载/原始回调/multipart、错误目录与默认 404/405 通过,耗时 4.99 秒;临时进程和目录均由脚本清理。
|
[实际进程验收](generationExperienceLocal.json):根目录 go run/go build、重复生成、生成检查、业务保留、nethttp/native 的 SIGINT/SIGTERM、监听端口释放、下载/原始回调/multipart、错误目录与默认 404/405 通过,耗时 4.99 秒;临时进程和目录均由脚本清理。
|
||||||
|
|
||||||
现有 `examples/managedService` 更新生成配置基线后 `generate --check` 与模块编译测试通过。远程发布验收将在固定标签发布后补充;本轮不涉及 eTax 业务迁移。
|
现有 `examples/managedService` 更新生成配置基线后 `generate --check` 与模块编译测试通过。本轮不涉及 eTax 业务迁移。
|
||||||
|
|
||||||
|
## 远程发布验收
|
||||||
|
|
||||||
|
`v0.17.0-preview` 标签固定在 `8b588a24cd653dac77dd68f6777e0db367981f19`,已推送 main 与标签。使用空模块缓存从远程安装 CLI;框架校验和为 `h1:CH1axmZEcf8sMTHp3dRVAN0DMmkg9nURFd22H9LXASQ=`,所有消费者固定同版且没有本地 replace。
|
||||||
|
|
||||||
|
[远程验收报告](generationExperienceRemote.json):46 项检查通过,耗时 44.56 秒。真实进程验证根目录启动、nethttp/native 信号退出、下载、multipart、原始回调、默认错误响应和共享错误目录;7 组实际生成消费者通过 race,包括公共字段/结构映射/工厂依赖与失败清理/Repository 开关,以及原有参数、错误目录和生命周期行为。参数不兼容或映射遗漏时写入前失败;重新生成保留业务实现,`--check` 保持只读。
|
||||||
|
|
||||||
|
复验命令:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/verifyManagedRelease.py --version v0.17.0-preview --report /tmp/goOmniRemote.json
|
||||||
|
```
|
||||||
|
|
||||||
|
验收临时目录和服务进程已清理。远程报告以独立提交补充,发布标签不移动。
|
||||||
|
|
||||||
## 使用边界
|
## 使用边界
|
||||||
|
|
||||||
|
|||||||
114
reports/generationExperienceRemote.json
Normal file
114
reports/generationExperienceRemote.json
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.17.0-preview",
|
||||||
|
"frameworkSum": "h1:CH1axmZEcf8sMTHp3dRVAN0DMmkg9nURFd22H9LXASQ=",
|
||||||
|
"freshModuleCache": true,
|
||||||
|
"noLocalReplace": true,
|
||||||
|
"durationSeconds": 44.56,
|
||||||
|
"checks": [
|
||||||
|
"remoteCLIInstall",
|
||||||
|
"freshModuleCache",
|
||||||
|
"fixedVersionNoReplace",
|
||||||
|
"contractChangePreview",
|
||||||
|
"persistentLogicSignatureFailure",
|
||||||
|
"businessSourcePreserved",
|
||||||
|
"fixedConsumerBuild",
|
||||||
|
"rootGoRun",
|
||||||
|
"nethttpAndNativeSignals",
|
||||||
|
"listenerReleased",
|
||||||
|
"default404And405",
|
||||||
|
"goOwnedErrorCatalog",
|
||||||
|
"unwrappedDownloads",
|
||||||
|
"rawCallback",
|
||||||
|
"multipart400And413",
|
||||||
|
"nethttpAndNativeTransfers",
|
||||||
|
"sharedGoCatalogExport",
|
||||||
|
"documentOnlyErrorSelection",
|
||||||
|
"workspaceCheckNoWrites",
|
||||||
|
"staleCatalogRejected",
|
||||||
|
"twoIndependentServiceBuilds",
|
||||||
|
"initializationAndListenFailureCleanup",
|
||||||
|
"drainBeforeDependencyClose",
|
||||||
|
"generatedConsumerRaceTests",
|
||||||
|
"errorParamsAndInheritance",
|
||||||
|
"manualHooksAndScalars",
|
||||||
|
"dependencyFatalShutdown",
|
||||||
|
"fatalProcessExitCode",
|
||||||
|
"initializationShutdownNotification",
|
||||||
|
"configuredBuiltinEndpoints",
|
||||||
|
"projectHealthEnvelope",
|
||||||
|
"endpointConflictCleanup",
|
||||||
|
"responseArrayNullableNull",
|
||||||
|
"primitiveAndDynamicArrays",
|
||||||
|
"strictRequestPolicies",
|
||||||
|
"declarativeMultipartBinding",
|
||||||
|
"backgroundStopDrainResourceOrder",
|
||||||
|
"backgroundInitAndListenFailureCleanup",
|
||||||
|
"backgroundTimeoutRetainsResourcesAndExits",
|
||||||
|
"flatFieldComposition",
|
||||||
|
"typedStructureMapping",
|
||||||
|
"explicitNullAndMoneyPolicies",
|
||||||
|
"mappingFailureBeforeWrites",
|
||||||
|
"mappingConsumerRaceTests",
|
||||||
|
"middlewareFactoryInjectionAndCleanup",
|
||||||
|
"optionalRepositoryPreservesBusiness"
|
||||||
|
],
|
||||||
|
"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": "e3177491934aef8cec783bed4cbeb2cfa3cb9b0815c9f7f9c63b85270bb07d24",
|
||||||
|
"config/project.go": "0917888f4e471761b897ddd59426eb098bf586edb5c5dc856320b84b3bdb5f7f",
|
||||||
|
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
|
||||||
|
"contract/policy.go": "73a3a55fb88195b32f22b2bc066d9b13dccd7fd520e98c5a1b2979a3fd2447eb",
|
||||||
|
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
|
||||||
|
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
|
||||||
|
"cmd/go-omni/apigen/business_test.go": "3b484352282d945f5a58047682079e3d64d4b0bf6ec6bafc23151b1197bb3942",
|
||||||
|
"cmd/go-omni/apigen/generate.go": "5a25134afc402c62ca6afe04320b5ef14141a5ff4caedb9a5eeca4952b302f03",
|
||||||
|
"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": "3a736a308d9dbe3a51555e2864dbcb39dc38cd01bd4e103095f4305a41bde958",
|
||||||
|
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "52c8ab8a0919c6503cb8835eb258f91f237204c9fc13d9dc735bb913944168b0",
|
||||||
|
"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",
|
||||||
|
"cmd/go-omni/apigen/responseShape.go": "5b705130b66a3d4aaed842025b75abe54e6427acac8366366ca88ae9862429e1",
|
||||||
|
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
|
||||||
|
"cmd/go-omni/apigen/catalogDocument.go": "94df60328b2f105c35b73d9c6362ea14abadce6cb18e4d9921193d2d46854252",
|
||||||
|
"cmd/go-omni/apigen/workspaceCatalog.go": "c00784220b379ed80deb07c060532b756cbc47434ecb8b1ca10bc12b12159328",
|
||||||
|
"config/env.go": "ea84bccdcd39e3f6b4958a0c8ac8e7d6a0be516b24930f93039248165361bf35",
|
||||||
|
"cmd/go-omni/apigen/arrayResponse_test.go": "9e3e4475f76683d3346d349cdfa94e9e75cf913ac6979bd7a4ed86628466d879",
|
||||||
|
"cmd/go-omni/apigen/testdata/arrays/values.go": "d5e7f3adf12aabb301f46c296e8c894a7b2f6f70a6b6e7e02c74da7f1fd17bc5",
|
||||||
|
"cmd/go-omni/apigen/testdata/arrays/consumer_test.go": "435932f2952ad897943e2cda83b04eccb2950579d8d88772661541e1e2a2333d",
|
||||||
|
"cmd/go-omni/apigen/composition.go": "7ed3761b27d9c932d8ddc43e63c2aca798bdbe38f2b40571af8916c8401ea650",
|
||||||
|
"cmd/go-omni/apigen/generatorConfig.go": "467112eeb5180fc1571e811e38e90e6dee3f128fe39e17bb978591cee0fa1b22",
|
||||||
|
"cmd/go-omni/apigen/mapping.go": "1d28f3b1300f50654b392735be546ff9c588664bfc0563e4ff5f838f495e091e",
|
||||||
|
"cmd/go-omni/apigen/mappingLoad.go": "b301bd49e4b67160ba70be4163c8ab1f3620f5963ec26f4b28af5df185a75170",
|
||||||
|
"cmd/go-omni/apigen/mapping_test.go": "6660f3183e6078ae5d6baecc654b0d2bcdfc243b6bc1da28811af8d25fa73efa",
|
||||||
|
"cmd/go-omni/apigen/middlewareFactory.go": "cc0220edb3eff25d7f66b2c37e206455f8730a2b909613713322bee7caf72303",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/accessFactory.go": "e1e5057ff79f806b3218e93249e8191d097b5c4844977204a8c32848443cba33",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/domain.go": "19bc978d173081f18efda643ffad3510287c03b4e47436c7019ea91973f32b72",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/amount.go": "f2d1834502d71d9ecf72ebe3b1077e5ef41f44fd816abd4617ac19f9bb962fdd",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/client.go": "19102e191c0471cae9e7405623906d0d804865c40b80ff1d5b240a41ceab6a4e",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/service.api": "01d230d5409756e2f3eb602a7fe065efa7d9599f7a4089da4bf8f07a2474444e",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/consumer_test.go": "9021c47c8af7d0c338c005d9f0a1f97ecdaaffc0b17b01c09d58023cc8c69fd7",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/dependencies.go": "edc153204b06da00c0e45c52db43a4a22f8ffe22c4a6d3f3ebec0137b861f84d",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/converters.go": "3ded306c68182d0c2184e44eecb6307c366f0ae43a4d23a6a3ef70fc14f45833"
|
||||||
|
},
|
||||||
|
"releaseCommit": "8b588a24cd653dac77dd68f6777e0db367981f19"
|
||||||
|
}
|
||||||
38
reports/nullableMappingAcceptance.md
Normal file
38
reports/nullableMappingAcceptance.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# 可空映射与配置拆分验收(v0.18.0-preview)
|
||||||
|
|
||||||
|
基于 v0.17.0-preview 补齐通用生成能力,未添加业务模型、权限、金额默认值或领域校验。运行时和依赖不变,旧单文件配置与已有映射函数保留。
|
||||||
|
|
||||||
|
## 本轮完成
|
||||||
|
|
||||||
|
- 显式嵌套 mapping 支持 `S → T`、`*S → *T`、`S → *T`、显式 nil 策略下的 `*S → T`,也适用于 elements 下的 slice/等长数组。默认保留可空对象;非空目标必须明确选 zero/error。
|
||||||
|
- 同类型 `*T → T` 可显式选择 nil zero/error,省去重复解引用 converter。自定义 converter 继续独立控制行为,禁止同时配置 nil 策略。
|
||||||
|
- `pointerResult: true` 额外生成 `<Name>Ptr(S) (*T,error)`,原值函数不变。转换失败返回 nil 与原错误链;辅助函数名统一查重。
|
||||||
|
- 主配置以 mappingFiles 列出项目内子文件。子文件只含 mappings,统一解析跨文件引用、严格类型和字段覆盖;不做递归导入或隐式覆盖。
|
||||||
|
- 配置均为项目文件,生成不会改写。check、dry-run、workspace generate/check 共享加载过程;对子文件变化重新核对,保留边界与大小限制。
|
||||||
|
|
||||||
|
## 回归证据
|
||||||
|
|
||||||
|
[回归记录](nullableMappingRegression.json):全仓库 44 个有测试的包通过 `-race`,包含实际生成消费者;go vet、go build、diff 检查通过。
|
||||||
|
|
||||||
|
`TestNullableMappingConsumer` 生成独立项目,配置跨文件向前引用与项目自定义转换函数,验证:
|
||||||
|
|
||||||
|
- nil、缺失、非 nil 零值对象、nil/非 nil 空 slice、nil 元素,以及 slice/array 内的嵌套转换。
|
||||||
|
- zero/error 空值策略、值到指针、可选指针返回;未显式映射的同类型指针仍共享引用。
|
||||||
|
- 子转换失败时父结果归零,指针辅助返回 nil,errors.Is 保留原因,错误含子字段与数组索引;内部原因和映射路径不进入公开 HTTP 提示。
|
||||||
|
- 32 个并发调用;通过实际生成 Handler 和 Logic 测试 JSON 请求与响应;生成模块执行 race 测试和根目录 build。
|
||||||
|
- 主配置与子文件、业务 Logic 在重新生成后逐字保留;check/preview 只读。子文件修改触发漂移,workspace generate 后 workspace check 通过。
|
||||||
|
- 未声明 nil 行为、错误类型/策略、辅助函数重名、未知映射、跨文件循环和字段遗漏均在写入前拒绝。
|
||||||
|
|
||||||
|
`TestMappingFileConfiguration/Rejection/Bounds` 覆盖文件加载顺序、来源诊断、配置变化摘要、重复文件/定义/JSON key、路径越界、符号链接、嵌套导入拒绝,以及单文件/总大小/文件数/合并映射数限制。
|
||||||
|
|
||||||
|
[原始最小复现](nullableMappingRepro.json):在隔离副本中,用源码构建的真实 CLI 成功生成原来报 `nested mapping Child has incompatible types` 的项目;生成检查、模块编译测试和 build 通过。原项目文件快照一致,临时目录已清理。
|
||||||
|
|
||||||
|
[实际进程回归](nullableMappingLocal.json):原有根目录启动、nethttp/native 信号退出、监听端口释放、上传下载、原始回调、错误响应与生成文件保护通过。
|
||||||
|
|
||||||
|
## 使用与边界
|
||||||
|
|
||||||
|
使用及迁移见 [生成能力指南](../GENERATION.md)。子文件仅拆分配置,不生成需要人工补齐的赋值代码;普通映射仍为编译器检查的 Go 代码,无运行时反射或 JSON 往返。
|
||||||
|
|
||||||
|
zero 表示目标 Go 零值,并跳过子映射;领域必填、金额精度、时间/编号格式和快照深拷贝仍由项目负责。普通指针输入已合并的 missing/null 不能在映射阶段恢复。nil error 是普通转换错误,特定业务错误码用自定义 converter 表达。
|
||||||
|
|
||||||
|
当前已完成发布前验收;远程验收将在固定标签发布后补充。框架验收不代表完整业务项目已迁移,本轮没有修改 eTax 业务代码。
|
||||||
59
reports/nullableMappingLocal.json
Normal file
59
reports/nullableMappingLocal.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"mode": "localSource",
|
||||||
|
"durationSeconds": 5.28,
|
||||||
|
"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": "a977803a6f8f4c64a30bc8156decfb072301f4084a5cf8821f4fe98da80cd7af",
|
||||||
|
"cmd/go-omni/apigen/templates.go": "e3177491934aef8cec783bed4cbeb2cfa3cb9b0815c9f7f9c63b85270bb07d24",
|
||||||
|
"cmd/go-omni/apigen/startup.go": "e3322fa3e2aff9f45d66427f44187535c7c0960edc0f5fdfd5d7b4943b349334",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
|
||||||
|
"cmd/go-omni/apigen/contractChanges.go": "aeb83990e908738faaa3cdfa20c3b8938023db05b6e55f2d24352c44144056a8",
|
||||||
|
"cmd/go-omni/apigen/logicCheck.go": "ca1af4f876a3a0db7b7b9ba9b82c1d6399fd89d29f667dad39cfd3de526eb1cb",
|
||||||
|
"cmd/go-omni/apigen/write.go": "eab29555906c1d6675ffdf07a30f00536065ec8d406cc1a4e53a05d8db720ff8",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
79
reports/nullableMappingRegression.json
Normal file
79
reports/nullableMappingRegression.json
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"status": "releaseCandidateValidated",
|
||||||
|
"baseRelease": "v0.17.0-preview",
|
||||||
|
"testedPackages": 44,
|
||||||
|
"goVersion": "go version go1.27.1 darwin/arm64",
|
||||||
|
"commands": [
|
||||||
|
"GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s",
|
||||||
|
"go vet ./...",
|
||||||
|
"go build ./...",
|
||||||
|
"python3 scripts/verifyManagedProject.py --report reports/nullableMappingLocal.json",
|
||||||
|
"source CLI: api generate / api generate --check / go mod tidy / go test ./... / go build . on isolated original reproduction",
|
||||||
|
"git diff --check",
|
||||||
|
"go run ./cmd/go-omni api generate --dir examples/managedService --check",
|
||||||
|
"cd examples/managedService && go test ./..."
|
||||||
|
],
|
||||||
|
"checks": [
|
||||||
|
"nullableNestedObject",
|
||||||
|
"nilPointerPreserved",
|
||||||
|
"nilAndEmptySlices",
|
||||||
|
"nullableArrayElements",
|
||||||
|
"explicitNilZeroAndError",
|
||||||
|
"nonNilZeroPreserved",
|
||||||
|
"nestedFailureReturnsZero",
|
||||||
|
"errorChainAndFieldIndex",
|
||||||
|
"privateCauseNotInHTTP",
|
||||||
|
"pointerResultHelper",
|
||||||
|
"ordinaryPointerAliasingUnchanged",
|
||||||
|
"concurrentCalls",
|
||||||
|
"splitConfigForwardReferences",
|
||||||
|
"readOnlyGenerateCheckAndPreview",
|
||||||
|
"workspaceGenerateCheck",
|
||||||
|
"childFileDriftDetection",
|
||||||
|
"businessFilesPreserved",
|
||||||
|
"duplicateMappingsAndHelpersRejected",
|
||||||
|
"cyclesAndUnknownMappingsRejected",
|
||||||
|
"invalidPoliciesRejectedBeforeWrites",
|
||||||
|
"symlinksAndTraversalRejected",
|
||||||
|
"configSizeAndCountLimits",
|
||||||
|
"configChangeDigest",
|
||||||
|
"originalFeedbackReproducedAndFixed"
|
||||||
|
],
|
||||||
|
"logs": {
|
||||||
|
"fullRace": {
|
||||||
|
"path": "/tmp/go-omni-v018-race.log",
|
||||||
|
"sha256": "1f0f9a55354c041c0035ae82e0f21a2a913544d46ae40e05795a9ae5b707250f"
|
||||||
|
},
|
||||||
|
"vet": {
|
||||||
|
"path": "/tmp/go-omni-v018-vet.log",
|
||||||
|
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"path": "/tmp/go-omni-v018-build.log",
|
||||||
|
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
},
|
||||||
|
"process": {
|
||||||
|
"path": "/tmp/go-omni-v018-process.log",
|
||||||
|
"sha256": "748bf31b88405f7f1d550b1d74bbbc5543ea09c5d09b59ec98da6ad040694b12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sourceHashes": {
|
||||||
|
"cmd/go-omni/apigen/generate.go": "a977803a6f8f4c64a30bc8156decfb072301f4084a5cf8821f4fe98da80cd7af",
|
||||||
|
"cmd/go-omni/apigen/generatorConfig.go": "6eaa70821312ce670e5f8067f99f5133d1789e8558140203520230179da083d0",
|
||||||
|
"cmd/go-omni/apigen/generatorConfig_test.go": "f22a55dfa5c8188cf7bce56a2b53a778cbe3feb581cd551f16ebfc6a10932e21",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
|
||||||
|
"cmd/go-omni/apigen/mapping.go": "fb4f4bf39137921c3fc8f793c636c8c29f68e962060a56c82b10869d6a200243",
|
||||||
|
"cmd/go-omni/apigen/mappingNullable_test.go": "cc22182e5a68138b97817f7dda7a8a5a10ca3aab6c690f8926b6a6cc2ba2a8cb",
|
||||||
|
"cmd/go-omni/apigen/mappingPointer.go": "5b4aab84c2ea9371d2a295f8d8cc9a7f82a88b412daa07970c9ac9ea32e504e6",
|
||||||
|
"cmd/go-omni/apigen/mapping_test.go": "94a7927e2237f60c157a7dfc4cc793e17537559ed65c1468d3b8b8b8e3f39394",
|
||||||
|
"cmd/go-omni/apigen/testdata/nullable/consumer_test.go": "5b6353c09fe61c39602071cc7072b6d640cf1a6cab1ae55f9490199ca62102b2",
|
||||||
|
"cmd/go-omni/apigen/testdata/nullable/convert.go": "4465a51d0eb7efde66eca08ab7e0e8fd6565e04a8447c2d6d8c65cea47c5a87f",
|
||||||
|
"cmd/go-omni/apigen/testdata/nullable/domain.go": "cb31f4939efbc9ddbb9b68a9e0c72302b93c2c365b1023f65bf7079862936a76",
|
||||||
|
"cmd/go-omni/apigen/testdata/nullable/service.api": "6a5e76d1b56efc5e89d89579710b06465a7c18accbd08ed58d88ccb9c59828d9",
|
||||||
|
"cmd/go-omni/main_test.go": "0c709d4f8c81df91e3ad318afc02f1534a1062218dbbb10adbaa5a28c6cb2021",
|
||||||
|
"cmd/go-omni/scaffold/version.go": "dc875b37247ebea3890cd3c6ecf614875301ca2d3c92036abd313dea6d29418c",
|
||||||
|
"scripts/verifyManagedRelease.py": "28d883e933a131e8df8e798b00e4b4a6af097a9b880ef1aa7b753456a90c6137"
|
||||||
|
},
|
||||||
|
"frameworkVersion": "v0.18.0-preview"
|
||||||
|
}
|
||||||
14
reports/nullableMappingRepro.json
Normal file
14
reports/nullableMappingRepro.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"status": "localSourceUnreleased",
|
||||||
|
"durationSeconds": 2.8,
|
||||||
|
"checks": [
|
||||||
|
"originalNullablePointerRepro",
|
||||||
|
"actualCLIGenerate",
|
||||||
|
"actualCLICheck",
|
||||||
|
"consumerCompile",
|
||||||
|
"consumerBuild",
|
||||||
|
"originalProjectUnchanged",
|
||||||
|
"temporaryResourcesRemoved"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -124,7 +124,7 @@ def main():
|
|||||||
checks += ["sharedGoCatalogExport", "documentOnlyErrorSelection", "workspaceCheckNoWrites", "staleCatalogRejected", "twoIndependentServiceBuilds"]
|
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)
|
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", "TestPrimitiveArrayConsumer", "TestCompositionMappingConsumer"]
|
tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer", "TestCompositionMappingConsumer", "TestNullableMappingConsumer"]
|
||||||
print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True)
|
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)
|
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("{")]
|
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
||||||
@@ -137,6 +137,8 @@ def main():
|
|||||||
|
|
||||||
checks += ["flatFieldComposition", "typedStructureMapping", "explicitNullAndMoneyPolicies", "mappingFailureBeforeWrites", "mappingConsumerRaceTests", "middlewareFactoryInjectionAndCleanup", "optionalRepositoryPreservesBusiness"]
|
checks += ["flatFieldComposition", "typedStructureMapping", "explicitNullAndMoneyPolicies", "mappingFailureBeforeWrites", "mappingConsumerRaceTests", "middlewareFactoryInjectionAndCleanup", "optionalRepositoryPreservesBusiness"]
|
||||||
|
|
||||||
|
checks += ["nullableNestedMappings", "splitMappingFiles", "explicitNilPolicies", "pointerResultHelpers", "mappingWorkspaceRegeneration", "nestedErrorChainAndPrivateCause", "mappingPreviewNoWrites"]
|
||||||
|
|
||||||
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 = ["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/managed").glob("*.go")]
|
||||||
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/business").glob("*.go")]
|
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/business").glob("*.go")]
|
||||||
@@ -144,6 +146,8 @@ def main():
|
|||||||
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/arrays").glob("*.go")]
|
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/arrays").glob("*.go")]
|
||||||
sources += ["cmd/go-omni/apigen/" + name for name in ("composition.go", "generatorConfig.go", "mapping.go", "mappingLoad.go", "mapping_test.go", "middlewareFactory.go")]
|
sources += ["cmd/go-omni/apigen/" + name for name in ("composition.go", "generatorConfig.go", "mapping.go", "mappingLoad.go", "mapping_test.go", "middlewareFactory.go")]
|
||||||
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/composition").iterdir() if p.is_file()]
|
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/composition").iterdir() if p.is_file()]
|
||||||
|
sources += ["cmd/go-omni/apigen/" + name for name in ("mappingPointer.go", "mappingNullable_test.go", "generatorConfig_test.go")]
|
||||||
|
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/nullable").iterdir() if p.is_file()]
|
||||||
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
|
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
|
||||||
"freshModuleCache": True, "noLocalReplace": True,
|
"freshModuleCache": True, "noLocalReplace": True,
|
||||||
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,
|
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,
|
||||||
|
|||||||
Reference in New Issue
Block a user