Compare commits

...

6 Commits

64 changed files with 4172 additions and 63 deletions

8
AGENTS.md Normal file
View File

@@ -0,0 +1,8 @@
# 开发约定
- 框架保持通用,不内置接入项目的业务类型、字段、错误码或业务规则。
- 2026-09-25 用户要求:每次功能开发完成,必须同步维护根 README.md。README 需要覆盖当前全部公开功能,解释用途、基本用法,并提供可运行示例或链接到 examples 内的完整示例。
- 新增、调整和删除功能都必须同步文档、示例、测试与发布说明。删除功能要明确说明已移除内容、影响范围和迁移步骤,不能继续把历史用法当作当前能力推荐。
- examples 使用通用场景,避免依赖业务项目、私有数据库或线上服务。必要的外部基础设施配置应明确说明。
- 2026-09-25 用户决定:移除 mappings/mappingFiles 及结构映射代码生成机制,改用可选 copyx 工具包。不得恢复另一套 JSON 或 Go 映射声明语言。
- 深拷贝的自定义转换结果不得与输入共享可变引用;发现共享应报错,不能降级成浅拷贝或静默接受。

View File

@@ -1,5 +1,14 @@
# Changelog
## v0.19.0-preview(2026-09-25)
- 新增通用可选 copyx:构造器、浅/深拷贝、精确类型转换、字段规则、nil/零值策略、并发复用。
- 深拷贝拒绝自定义结果共享源可变引用;原子提交目标、保留错误链、限制遍历及估算分配。
- 移除 JSON 结构映射配置与生成;提供明确迁移诊断,保留公共字段组合等其他生成能力。
- 同步 README 全部能力索引、COPY.md、可运行示例和开发文档约定。
- 验收与不兼容迁移见 [发布说明](RELEASE.md)、[copyx 验收](reports/copyxAcceptance.md)。v0.14–v0.18 的完整变更保留在发布说明历史段落。
## v0.13.0-preview(2026-09-24)
- 新增通用 lifecycle.Controller,svc.RequestShutdown 支持依赖主动通知正常停止或致命故障,通知不阻塞,首个故障原因保留;生成入口排空 HTTP 后关闭依赖与日志,故障退出码为 1。

141
COPY.md Normal file
View File

@@ -0,0 +1,141 @@
# 可选拷贝工具 copyx
v0.19 用普通 Go 调用取代 JSON 结构映射生成。`copyx` 只依赖标准库,不需要注册到 App,不执行 JSON 往返,也不会自动暴露数据库模型。普通 Logic 直接使用 `.api` 生成的 DTO;只有存在独立业务类型或需要隔离可变数据时才用它。
完整可运行代码:[examples/copying](examples/copying)。在框架根目录运行:
```sh
go run ./examples/copying
go test -race ./copyx ./examples/copying
```
## 最简单的调用
```go
import "git.yuntuo.hk/k2safe/go-omni/copyx"
type Request struct { Name string; Enabled bool }
type Input struct { Name string; Enabled bool }
req := Request{Name: "sample", Enabled: false}
var input Input
if err := copyx.Copy(&input, req); err != nil { return err }
```
同名、同类型字段直接赋值。默认不会跳过 `false`、`0`、空字符串。目标字段没有来源时报错;多余源字段默认忽略,使用 `WithStrictFields()` 可要求源字段也全部使用或显式忽略。
默认是浅拷贝:同类型指针、map、slice 共享引用,符合普通 Go 赋值。JSON tag 不参与字段匹配。不会自动调用方法、SQL Scanner/Valuer,或把 `int32`、`int64`、string、命名标量互相强转。
新字段:普通接口只改 `.api` 和使用该字段的 Logic。独立 DTO/业务类型都需要此字段时,两边各声明一次;同名同类型不增加拷贝配置。检查在运行时发生,实际类型对必须有测试,不能把运行时工具当作旧生成器的编译期字段覆盖检查。
## 深拷贝与构造器
```go
snapshot, err := copyx.Clone(source) // 保持源的静态类型,始终深拷贝
if err != nil { return err }
copier, err := copyx.New(copyx.WithDeepCopy(), copyx.WithStrictFields())
if err != nil { return err }
var result Output
if err := copier.Copy(&result, source); err != nil { return err }
```
`Copy` 的目标必须是非 nil 指针;`Clone[T]` 返回 `(T, error)`。重复执行时在初始化阶段创建 Copier 并复用;配置之后不可变,每次调用独立持有遍历状态,可以并发使用。源、目标的并发读写仍由调用方同步,自定义函数也须支持并发。
深拷贝会分配指针、map、slice,递归处理结构、数组、接口中的具体值。nil 集合与非 nil 空集合保持区别;slice 结果容量为长度,不复制长度之外的隐藏元素。普通循环和重复指针可处理。循环图应保留根指针,使用 `Clone(sourcePointer)` 或 `Copy(&targetPointer, sourcePointer)`;将循环指针根压成值结构会明确报错。
它保证结果不引用输入中的可变对象,不保证保留所有底层内存布局关系:不同重叠 slice 视图会分别复制,内部指针指向某个切片元素的关系也不重建。字符串和零尺寸值可安全共享;`time.Time` 按 Go 不可变值使用(Location 不做独立复制)。不要通过 unsafe 或 uintptr 隐藏可变引用后要求工具识别。
## 字段差异与自定义转换
```go
type Input struct { Name string; UpdatedAt time.Time; InternalID string }
type View struct { DisplayName string; UpdatedAt string; LocalNote string }
copier, err := copyx.New(
copyx.WithDeepCopy(),
copyx.WithStrictFields(),
copyx.WithFieldNames[Input, View](map[string]string{"Name": "DisplayName"}),
copyx.WithIgnoreSource[Input, View]("InternalID"),
copyx.WithIgnoreTarget[Input, View]("LocalNote"),
copyx.WithConverter(func(v time.Time) (string, error) {
return v.UTC().Format(time.RFC3339), nil
}),
)
if err != nil { return err }
out := View{LocalNote: "preserve"}
if err := copier.Copy(&out, input); err != nil { return err }
```
字段规则的类型参数必须是结构类型,字段名使用导出的 Go 字段名。构造时检查不存在的字段、重名及冲突。不支持字符串路径表达式;复杂嵌套差异为该嵌套类型注册 converter,或直接写 Go。
`WithIgnoreSource` 只是声明该源字段不参与使用;目标同名字段若仍需保留,还需 `WithIgnoreTarget`。忽略目标会保留原目标值,Clone 中则为零值。深拷贝时如果保留值仍与源共享可变引用,也会拒绝。
转换器的签名是 `func(S) (T, error)`,按**精确类型对**匹配,不能重复注册。金额、编号、日期格式由项目明确转换。需要逐字段不同规则时,给整个源/目标结构注册函数;函数返回独立的新目标。无需另一份映射配置或全局注册。
精确 converter 优先于 nil/零值策略,能收到 typed nil;例如 `func(*Money) (Money, error)` 可自行决定 nil 是否允许。绑定到 `any` 字段的 converter 能接收 nil interface,但顶层未带类型的 `nil` 没有精确类型可匹配。浅拷贝中已经直接共享的同类型容器不会继续逐项转换其内部;需要遍历内部时选择深拷贝,或为该容器类型注册 converter。
## 深拷贝不能原样返回可变引用
```go
copier, err := copyx.New(
copyx.WithDeepCopy(),
copyx.WithConverter(func(v []string) ([]string, error) {
return v, nil // 会返回 ErrSharedReference
}),
)
```
检查覆盖完整源图与转换结果,包括新父对象引用旧子对象、原 map、重叠子切片、`source[:0]` 的可写容量、指向源数组元素的指针,以及未导出字段中的引用。会检查每个 converter 的结果和最终目标,不允许静默降级为浅拷贝。
返回相同字符串、整数并不会报错,它们不是可变引用。正确切片 converter 可以写:
```go
copyx.WithConverter(func(v []string) ([]string, error) {
if v == nil { return nil, nil }
out := make([]string, len(v))
copy(out, v)
return out, nil
})
```
第三方 Decimal/自定义 Money 如果含未导出的指针,默认深拷贝拒绝;注册该类型的 converter,使用它自己的构造/Clone API 创建独立值,避免转成 float64 损失精度。框架不内置某一金额库的规则。无法独立复制的类型直接返回错误。
## nil、零值和错误
| 选项 | 行为 |
| --- | --- |
| 默认 `NilError` | nil → 可空目标保留 nil;nil → 非可空目标报 `ErrNil` |
| `WithNilPolicy(NilZero)` | 显式允许 nil → 零值;可空目标仍为 nil |
| `WithNilPolicy(NilSkip)` | nil 保留已有目标;新集合元素没有旧目标,保留元素零值 |
| `WithSkipZero()` | 显式跳过 Go 零值,包括 `false`、`0`、`""`;非 nil 空集合不是 Go 零值 |
所有 nil 策略都不把不兼容类型自动变成兼容类型。非 nil 指针与值之间可以按对应元素转换;nil 到非可空目标仍需选择策略。局部更新不能默认使用 NilZero/SkipZero,它们可能改变清空字段或布尔值的业务语义。
工具只能复制绑定后的 Go 值:如果输入类型已经将“缺失”和“JSON null”合并为 nil,无法恢复两者区别。需要三态时,应先设计包含 presence 信息的类型和绑定逻辑。
赋值使用临时结果,成功后才替换目标;转换失败不会交付部分结果。`*copyx.Error` 包含 Go 字段/索引路径,`errors.Is/As` 保留内部错误链,工具不主动在错误中加入源值。converter 自己返回的错误文本仍由项目负责。
converter 必须只读输入;其外部副作用、输入修改无法回滚,panic 原样传播。框架只检查与本次输入的共享引用,无法证明 converter 未返回外部全局缓存的引用。它是受信 Go 代码扩展,不是可运行不可信代码的沙箱。
## 支持范围与资源限制
- 支持结构体、指针、interface、slice、同长度 array、map。map key 转换后冲突或不可比较则报错。
- 导出匿名结构字段可按提升后的无歧义名称访问;源嵌入指针为 nil 且需要其字段时明确报错。跨类型目标包含私有字段时拒绝;同类型私有纯值可作为整体保留。
- 不克隆文件、数据库连接、网络连接、锁、原子状态。已知标准库资源/同步结构按值复制也拒绝;深拷贝中的非 nil func、chan、unsafe.Pointer 拒绝。其他封装资源类型应由项目排除,工具不能识别所有自定义句柄。
- 单次遍历深度上限 256、节点上限 200,000;内置分配的估算预算为 64 MiB,超限返回 `ErrLimit`。预算不是进程堆内存硬限制,reflect 元数据和 converter 自行分配不计入。大对象流式读写应使用 IO/存储接口。
- 只缓存不可变类型元数据,不缓存请求值、用户 converter 或实例策略。没有 `unsafe` 写入、JSON 往返、隐式数值截断或方法调用。
- 这是运行时反射工具,会比直接 Go 赋值多检查与分配。热点应复用 Copier,并用真实数据测量;性能记录见 [本版验收](reports/copyxAcceptance.md)。
## 从旧 mapping 迁移
v0.19 **彻底移除**映射配置加载、Go 类型加载、映射代码生成及 `pointerResult` 等附属选项。公共 `.api` 字段组合、scalar、中间件工厂、Repository 开关继续存在。
1. 先检查每个转换是否必要。普通 Logic 可直接使用生成 DTO;独立核心保留自己的类型。
2. 将 `mapping.Xxx(...)` 调用改为普通 Go 或小型 `copyx` helper。字段改名、nil、金额规则写在该 helper 同一处。指针返回可直接 `var out *Response; err := copier.Copy(&out, input)`。
3. 用真实类型对测试缺失/null/零值/空数组、字段覆盖、金额精度和错误链。需要源字段新增时提醒遗漏,启用 `WithStrictFields()` 并测试。
4. 从 `omni.gen.json` 删除 `mappings` / `mappingFiles`,删除无其他用途的映射 JSON 文件。
5. 确认已无调用,再删除 `internal/mapping/mappingsGen.go`。如之前手改过文件,先迁出手写内容。生成器遇到该旧文件会阻止生成并提示迁移,**不会静默删除**;其余业务文件照常保留。
6. 升级框架与 CLI 到同版,执行 generate、generate --check、go test ./...;多个服务再执行 workspace generate/check。新 manifest 自动去掉已移除文件的所有权记录。
历史发布记录保留旧能力说明,仅用于理解旧版本,不代表 v0.19 继续支持。

View File

@@ -1,7 +1,9 @@
# 通用业务开发指南(v0.16.0-preview)
# 通用业务开发指南(v0.19.0-preview)
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
公共字段组合、自动中间件工厂和 Repository 开关,见 [生成能力指南](GENERATION.md)。必要的结构转换使用可选 [copyx](COPY.md),v0.19 已移除旧 JSON 映射生成。
## 返回数组、可空对象和 null
```text
@@ -145,40 +147,15 @@ func (c *ServiceContext) Init(ctx context.Context) error {
if err != nil { return err }
c.Client = client
c.OnClose(client.Close)
c.Middlewares["RequireAccess"] = middleware.NewRequireAccess(client)
return nil
}
```
每个资源初始化成功就登记清理;后续初始化失败自动逆序回滚。正常退出先排空 HTTP 请求,再执行后台组件的 Stop/Drain 阶段,确认收尾后逆序关闭依赖,最后关闭日志。关闭幂等,单个清理函数 panic/返回错误不会跳过其他清理;初始化 panic 清理后继续传播。后台组件按 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭) 登记;无法确认收尾完成时保留共享依赖并异常退出,不能把取消等同于任务已经结束。
中间件用业务所需的小接口接收依赖,避免反向导入 svc:
新项目使用中间件工厂:项目维护 `middleware.Dependencies` 小接口与 `New<Name>(deps) (httpx.Middleware, error)`,svc 提供对应访问器。生成的 runtime 在 Init 成功后自动调用工厂并注册,不再按名字替换 map;工厂失败会释放已经登记的依赖。示例与现有项目迁移步骤见 [中间件工厂](GENERATION.md#中间件工厂自动注册)。
```go
// internal/middleware/accessFactory.go
package middleware
import (
"context"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
type AccessChecker interface {
CheckAccess(context.Context, string) error
}
func NewRequireAccess(checker AccessChecker) httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
if err := checker.CheckAccess(c.Context(), c.Header("Authorization")); err != nil {
return err
}
return next(c)
}
}
}
```
`.api` 声明对应 middleware 后,保留生成的初始 hook,并另建上述工厂;在 Init 用工厂替换 registry 的同名项即可。该包只依赖接口和 HTTP 抽象,不导入 svc,也不把数据库藏到 context 里。
v0.16 项目及 `omni.gen.json` 中选择 `middleware: handlers` 的项目继续使用原有 `Defaults()`,可以在 Init 设置 `c.Middlewares["RequireAccess"]`。更新时不会重写这部分业务代码;只有显式切换到 factories 后才使用新流程。
生成入口为每个 App 注入独立 Logger,不修改进程默认 Logger。业务可用 `c.Logger.InfoContext(...)`,或请求链中的 `logx.InfoContext(ctx,...)`。标准库/第三方需要 `*log.Logger` 时使用 `c.Logger.Standard(slog.LevelWarn)`;适配器借用原 Logger,不负责关闭。手写多 App 用 `omni.WithLogInstance(logger)` 显式注入。
@@ -301,7 +278,7 @@ go-omni workspace check --file omni.workspace.json
## v0.13 项目升级
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.16.0-preview(或显式引用当前源码)。
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.19.0-preview(或显式引用当前源码)。
```sh
go-omni api upgrade --dir . --dry-run

116
GENERATION.md Normal file
View File

@@ -0,0 +1,116 @@
# 公共字段、中间件工厂与生成选项
本页对应 `v0.19.0-preview`。CLI 与框架依赖固定到同一版本,安装见 [README](README.md#准备环境与安装)。
## 一份项目生成配置
新项目根目录的 `omni.gen.json` 只描述生成选项:
```json
{
"repository": "none",
"middleware": "factories"
}
```
- `repository: none` 默认不创建空 Repository 文件;`group` 按 group 补齐可编辑骨架。已有文件始终保留;切回 none 不会删除业务文件。SQL、ORM 和仓储划分由项目决定。
- `middleware: factories` 是新项目默认值,自动调用带依赖的工厂。`handlers` 保留原有 `Defaults()` 及手动注册方式,供既有项目迁移。
- 该文件与 `.api` 一起提交。generate/check/dry-run 和 workspace 采用相同规则;validate/export/format 只处理接口契约。
- 旧 manifest 没有配置时补 `middleware: handlers`、`repository: none`。已有配置不能直接删除,避免意外切换中间件模式。
- v0.19 已删除 `mappings` 和 `mappingFiles`;遇到旧配置会明确报错,不读取子映射文件或执行映射生成。处理步骤见 [copyx 迁移](COPY.md#从旧-mapping-迁移)。
## 公共字段组合
```text
type EmployeeFields {
// 员工姓名。
Name string `json:"name" binding:"max=80"`
Salary Money `json:"salary"`
}
type EnterpriseRequest {
EmployeeFields
}
type AdminRequest {
EmployeeFields
CompanyID string `path:"companyId"`
}
```
`Money` 由项目按既有 scalar 语法声明。组合行只有已声明的对象类型名,不加指针或字段 tag。公共对象可以通过已有相对 import 或声明过的共享根 `@common/...` 引入。
生成器展开为普通 Go 字段。JSON 保持平铺;字段的绑定来源、校验、注释、可空性和 OpenAPI 使用同一份展开结果。不会在 JSON 中增加 `EmployeeFields` 一层。重复 Go 字段、重复来源字段名、循环组合、未知对象和过深展开均失败;即使重复字段类型相同,也不能隐式覆盖。要改变某个字段的规则,应拆出更小的公共组合。
允许多层组合,上限 32 层、单类型 4096 字段、全契约展开 65536 字段。Go 匿名指针嵌入并非本功能;生成 DTO 中不会使用匿名嵌入。
## DTO 与业务类型
普通 Logic 直接接收生成的 Request、返回生成的 Response,不额外创建相同结构。只有独立核心或明确不同的输入/输出职责才保留业务类型。
必要转换直接写 Go,或用可选 `copyx.Copy(&target, source)`。同名同类型字段自动赋值,字段改名和类型变化通过 Go 参数、转换函数明确设置,见 [完整示例](examples/copying/main.go)。无需 JSON 映射配置、包路径字符串或额外生成文件。
`copyx` 在运行时检查类型和字段,**不提供旧映射生成阶段的静态字段覆盖保证**;CI 应给实际类型对添加测试。普通业务字段新增通常只改 `.api` 和 Logic;独立 DTO/领域类型都需要此字段时,两边各加一次,同名同类型无需追加拷贝配置。
## 中间件工厂自动注册
`.api` 沿用 `middleware: RequireAccess` 或 auth 对应的中间件声明。新项目会生成可编辑的 `internal/middleware/requireAccessFactory.go`,只需实现一次:
```go
// internal/middleware/dependencies.go,由项目维护。
type AccessChecker interface {
CheckAccess(context.Context, string) error
}
type Dependencies interface {
AccessChecker() AccessChecker
}
// internal/middleware/requireAccessFactory.go,由项目维护。
func NewRequireAccess(deps Dependencies) (httpx.Middleware, error) {
checker := deps.AccessChecker()
if checker == nil { return nil, errors.New("access checker is required") }
return func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
if err := checker.CheckAccess(c.Context(), c.Header("Authorization")); err != nil {
return err
}
return next(c)
}
}, nil
}
// internal/svc/access.go,由项目维护;ServiceContext.Init 初始化 c.Auth。
func (c *ServiceContext) AccessChecker() middleware.AccessChecker { return c.Auth }
```
编译器检查 ServiceContext 是否实现依赖接口,middleware 不导入 svc。多个工厂可以共用这些小接口。所需 imports 和具体鉴权服务仍属于项目。
启动顺序为:框架基础依赖 → `svc.Init` → 生成的 `middleware.New(c)` 调用各具名工厂 → 创建 App/注册路由 → 监听。中间件实例创建一次,由请求并发复用;不要把单次请求信息存在实例字段里。工厂之间没有初始化顺序依赖;路由执行顺序仍以 `.api` 声明为准。
工厂返回错误或 nil 中间件均阻止监听;错误链保留,已登记资源按现有生命周期清理。工厂 panic 也先清理依赖再传播。资源初始化和清理登记放在 svc.Init,工厂借用资源;HTTP 请求排空之前不会关闭这些依赖。
factory 模式不再在 Init 写 `Middlewares["..."]`;残留非空手写注册表会明确报错,防止生成注册静默覆盖它。未实现的工厂让启动失败;未实现的 Logic 仍返回 501。
### 既有项目迁移
1. 首次更新保持 `middleware: handlers`,现有 hook 和手写 Configure 不变。
2. 改为 factories 并重新生成,会补 `dependencies.go` 和 `*Factory.go`,保留原来的手写文件。
3. 将旧实现迁入工厂,提供 Dependencies 的访问器;移除 Init 中按名字覆盖 map 的代码。
4. 旧函数不再需要时由项目删除。若新工厂符号与旧文件中的同名函数冲突,整理旧文件;生成器不覆盖业务代码。
5. `generate --check` 与 `go test ./...` 检查后再运行。工厂没有全部实现前,服务不会启动。
## 文件维护边界
| 项目维护 | 生成器持续维护 |
| --- | --- |
| `.api` 公共字段与接口 | 展开后的 DTO、Handler、OpenAPI |
| `omni.gen.json` 的 Repository/中间件选项 | 对应骨架和注册代码;已有手写骨架不覆盖 |
| 独立 Go 业务类型、scalar、可选 copyx 调用和测试 | 不生成结构转换文件 |
| middleware Dependencies、具名工厂、svc 访问器 | 中间件 registry、启动调用 |
| Logic、Repository、配置、svc 初始化/清理登记 | main、runtime、路由接入 |
独立三端工资契约测试位于 `cmd/go-omni/apigen/testdata/composition`,不导入任何接入项目。它验证公共字段、Money scalar、三端 Handler、copyx、重新生成保留业务内容,以及新增字段不需要更新转换规则。
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test ./cmd/go-omni/apigen -run TestCopyxThreeEndpointConsumer -count=1
```

View File

@@ -1,6 +1,6 @@
# 生成项目与文件维护边界
v0.16.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
v0.19.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
## 创建和日常开发
@@ -38,6 +38,8 @@ ERROR [logicSignature] internal/logic/greet/greet.go:12 (GET /api/greet/:name):
这是轻量源码分析,不运行项目 init、不执行 Go 命令、不下载依赖。项目自定义类型别名、嵌入方法或 build tags 等无法可靠判断时,会给 REVIEW 并交给 Go 编译器验证;不把这些情况强行判为签名错误。它不检查方法体是否引用已删除字段,也不证明业务逻辑正确。CI 中应同时执行 `go-omni api generate --check` 和 `go test ./...`,按项目实际构建参数测试。当前版本首次接手缺少快照的 manifest v2 时,会明确提示建立基线,不猜测旧契约;Logic 签名检查仍会执行。
本版新增生成选项、公共字段组合和映射,详见 [生成能力指南](GENERATION.md)。
## 哪些文件由用户维护
| 文件 | 首次生成后由项目维护 |
@@ -50,8 +52,10 @@ ERROR [logicSignature] internal/logic/greet/greet.go:12 (GET /api/greet/:name):
| `internal/svc/databaseOptions.go` | 可选 MySQL 驱动与项目启动检查扩展 |
| `internal/svc/storage.go` | 选用存储时生成,之后保留 |
| `internal/logic/<group>/*.go` | 业务方法,使用标准 context.Context |
| `internal/repository/<group>/*.go` | 数据访问,按需实现 |
| `internal/middleware/<name>.go` | 项目鉴权/权限等具名中间件实现 |
| `internal/repository/<group>/*.go` | 数据访问;默认不补空骨架,可选 group 模式 |
| `internal/middleware/*Factory.go`、`dependencies.go` | 中间件工厂与依赖小接口,svc 实现访问器 |
| `omni.gen.json` | Repository 和中间件生成选项;不再声明结构映射 |
| 可选的 Go 转换函数 / copyx 调用 | 只有独立类型确实需要转换时维护,见 [copyx](COPY.md) |
| 项目 Go 错误包 | 自行维护 Catalog 和常量,可跨服务共用 |
| 自定义标量文件、`*Bind.go`、`*Manual.go` | 只有显式选择相应扩展时才生成/使用 |
| 项目测试、迁移、部署脚本、go.mod/go.sum | 项目所有,生成器不重置其内容 |
@@ -208,7 +212,7 @@ get /download (DownloadReq)
## 已有服务接入步骤
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.16.0-preview:
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.19.0-preview:
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。

View File

@@ -2,13 +2,17 @@
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
**从这份 README 开始接入。** 当前版本 **`v0.16.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**从这份 README 开始接入。** 当前版本 **`v0.19.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**v0.19 改动**:新增可选 `copyx` 工具,用普通 Go 调用完成浅拷贝、深拷贝和显式类型转换;深拷贝会拒绝自定义转换中残留的源对象可变引用。**移除了 `mappings` / `mappingFiles` 和映射代码生成**,已有使用者须按 [迁移步骤](COPY.md#从旧-mapping-迁移) 调整,不能直接删除仍在调用的旧文件。
普通接口的流程是:**修改 `.api` → 生成 Request/Response → 写 Logic → 测试**。有独立职责的业务类型才需要转换;可直接写 Go,也可以按需使用 [copyx](COPY.md)。公共字段组合、中间件工厂、Repository 开关继续支持,见 [生成能力指南](GENERATION.md)。
Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。
本版补齐 `returns([]string/[]int64/[]bool/[]any)`、OpenAPI 公共引用、workspace 一次导出共享 Go 错误目录,以及统一环境变量别名覆盖。详细配置与升级步骤见 [业务开发指南](DEVELOPMENT.md)。
契约支持 `returns([]string/[]int64/[]bool/[]any)`、OpenAPI 公共引用、workspace 一次导出共享 Go 错误目录,以及统一环境变量别名覆盖。详细配置与升级步骤见 [业务开发指南](DEVELOPMENT.md)。
已有通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。
@@ -16,6 +20,40 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
使用 GORM 的项目可以选择 [gormx 适配器](database/gormx/README.md),复用数据库初始化和连接池,并将 SQL 日志接入 logx。
## 当前功能与使用入口
下表是当前公开能力的索引。每项给出调用入口和完整说明;模块按需使用,`omni.New()` 不会自动初始化数据库、Redis、ORM、队列或云存储。示例中的 import 统一以 `git.yuntuo.hk/k2safe/go-omni/` 开头。
| 功能与用途 | 怎么用 | 解释与示例 |
| --- | --- | --- |
| 项目、接口与文档生成 | `go-omni new`;改 `.api` 后 `api generate`;`--check` 检查漂移 | [下方快速开始](#新项目生成并运行)、[CLI](cmd/go-omni/README.md)、[完整生成项目](examples/managedService/README.md) |
| 公共字段与跨服务共享契约 | `.api` 组合对象字段;`omni.imports.json` 声明共享根;workspace 批量生成 | [公共字段示例](GENERATION.md#公共字段组合)、[共享契约与 workspace](DEVELOPMENT.md) |
| 中间件依赖工厂、可选 Repository | `omni.gen.json` 选择 factories / none;在 `New<Name>(deps)` 写一次实现 | [配置与完整工厂示例](GENERATION.md) |
| 路由、分组、请求上下文 | `omni.New()`、`app.GET` / `Group` / `Use`;业务使用 `context.Context` | [基本服务](examples/basic/main.go)、[已有项目接入](#已有项目按模块接入) |
| HTTP 网络与扩展适配 | 配置 transport 为 nethttp / native / http2;自定义 Router/Runner | [网络层配置与契约](transport/README.md)、[性能与限制](PERFORMANCE.md) |
| HTTP 老接口桥接 | 通过官方 Handler/Middleware 适配器接入标准 `net/http`,跟踪实际响应状态 | [接入说明](MIGRATION.md)、[桥接/上传/自定义 ID 示例](examples/compatibility/main.go) |
| 请求校验、动态 JSON、自定义标量 | `.api` 声明来源/校验/scalar;手写契约用 `contract.New[T]`;简单绑定可用 `validate.JSON/Query/URI` | [语法与例子](API.md)、[请求策略](DEVELOPMENT.md) |
| 数组、null、原始请求、上传下载 | `.api` 声明数组/可空返回、body/multipart 与大小限制;必要时使用 Bind/Manual 扩展 | [契约示例](examples/contracts)、[含上传/回调/下载的项目](examples/managedService/README.md) |
| 公共响应与特殊协议 | `.api` 定义 response 并 `@app(defaultResponse: ...)`;Go 用 `WithResponse` / Encoder | [响应配置](RESPONSE.md)、[响应模板契约](examples/contracts/responses.api) |
| 错误目录与动态提示 | `WithErrors(catalog)` 注册一次;`ByCode(...).WithParams(...).WithCause(err)` | [错误目录](errors/README.md)、[模板参数示例](examples/contracts/errorParams.api) |
| OpenAPI 与错误公共引用 | `.api` 自动生成;手写接口可使用 `openapi` 注册器;Go 错误目录显式导出 | [契约工具](DEVELOPMENT.md)、[注册示例](examples/platform/main.go) |
| 结构化业务日志 | `logx.Info/Error`、`logx.From(ctx)`、`logx.New`;支持轮转、脱敏、慢请求和级别更新 | [下方日志示例](#日志)、[完整配置](logx/README.md) |
| 文件配置与环境覆盖 | 默认值→JSON/YAML→环境;完整业务结构用 `config.LoadProject[T]` / 环境别名 | [配置详解与例子](DEVELOPMENT.md)、[动态级别](ADVANCED.md#配置与日志热更新) |
| 生命周期与健康检查 | svc 登记 OnClose/OnStop/OnDrain,RequestShutdown 通知故障;健康路径/开关可配 | [生命周期用法](lifecycle/README.md)、[健康检查](health/README.md)、[生成项目扩展](PROJECT.md) |
| 数据库、连接池与事务 | `database.Open` / `OpenConnector`;事务与检查由项目显式调用 | [通用数据库](database/README.md)、[MySQL TLS/Logger](database/mysql/README.md) |
| 可选 GORM 与迁移 | gormx 复用已有连接池;`go-omni migrate` 预览,显式 apply | [GORM 示例](database/gormx/README.md)、[迁移用法](ADVANCED.md#迁移审计与分页) |
| 认证与权限接口 | 注入 Authenticator / Policy;JWT 或 Session 只是可选实现 | [认证说明](auth/README.md)、[鉴权完整例子](examples/platform/README.md) |
| 缓存 | `cache.NewMemory` 或 Redis 实现;业务面向 Store 的 Get/Set/Delete | [缓存配置/代码](MODULES.md)、[可运行模块服务](examples/modules/README.md) |
| 出站 HTTP、熔断 | `httpc.New` 复用连接池与截止时间;`resilience.New` 隔离故障依赖 | [HTTP 用法](MODULES.md)、[熔断/观测](ADVANCED.md#熔断与观测) |
| CORS、日志、恢复、限流、幂等、超时 | `app.Use(...)` 按需组合 middleware;跨副本选择 Redis 实现 | [模块说明与限制](MODULES.md)、[分布式实现](ADVANCED.md#分布式限流与幂等) |
| 本地后台任务与调度 | `task.New`、Submit、Every;有界队列、排空关闭 | [task 示例与生命周期](MODULES.md#后台执行task) |
| 持久队列 | 业务使用 Publisher / Consumer;Redis Streams 提供至少一次交付 | [队列初始化/消费示例](ADVANCED.md#可靠队列) |
| 文件存储、直传、云端分片 | Store 的 Put/Get/Delete;Local、S3/R2、OSS 按配置装配;扩展接口提供分片续传 | [存储接口与完整例子](storage/README.md)、[本地存储服务](examples/platform/README.md) |
| 日志关联、指标与链路 | `metrics.New().Middleware()`;telemetry 注入 OTel exporter/reader | [观测用法](ADVANCED.md#熔断与观测)、[完整接入](examples/platform/main.go) |
| 审计与分页 | `audit.New` 注入 Sink/白名单;`pagination.Parse` / `NewResult` | [解释与调用例子](ADVANCED.md#迁移审计与分页) |
| 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) |
| 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) |
## 选择接入方式
| 你的项目 | 从哪里开始 |
@@ -36,7 +74,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
```sh
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.19.0-preview
export PATH="$(go env GOPATH)/bin:$PATH"
go-omni --help
```
@@ -77,21 +115,22 @@ hello/
├── api/service.api # 维护接口定义,也可 import 其他 .api
├── main.go # 生成器维护:启动、路由注册、优雅退出
├── etc/config.yaml # 项目配置
├── omni.gen.json # 项目维护的生成选项与可选映射
├── internal/
│ ├── config/ # 配置读取
│ ├── svc/ # 数据库、日志、外部客户端等依赖
│ ├── types/*Gen.go # 按 .api 文件生成 DTO
│ ├── handler/ # 生成的绑定、响应策略、路由及文档
│ ├── logic/greet/ # 在这里实现业务逻辑
│ ├── repository/greet/ # 在这里实现数据访问
│ └── middleware/ # 声明鉴权等中间件后生成对应 hook
│ ├── repository/ # 按需创建,默认不生成空骨架
│ └── middleware/ # 声明鉴权等中间件后生成工厂
├── docs/openapi.json # 生成的 OpenAPI 3.0.3
└── .goOmni/apiManifest.json # 生成文件所有权与校验信息,一起提交 Git
```
修改 `api/service.api` 后重新生成,业务写在 `internal/logic`,数据访问写在 `internal/repository`,依赖在 `internal/svc` 装配。声明业务错误后还会生成 `internal/apierrors/errorsGen.go`。
`api new` 的问候接口可以直接运行;自定义接口新生成的 Logic 和鉴权 hook 返回 **501**,需要实现后才可用。生成器不会根据 HTTP 字段猜数据库表或业务规则。
`api new` 的问候接口可以直接运行;自定义接口未实现的 Logic 返回 **501**。新项目的具名中间件工厂未实现时直接阻止启动;v0.16 的原有 handler 模式仍返回 501。生成器不会根据 HTTP 字段猜数据库表或业务规则。
### 从自己的接口定义生成
@@ -192,7 +231,7 @@ go-omni new --api catalog.api \
在业务项目的 **go.mod 所在目录**执行:
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.16.0-preview
go get git.yuntuo.hk/k2safe/go-omni@v0.19.0-preview
```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
@@ -316,7 +355,7 @@ auth UserAuth (
)
```
生成器会创建 `internal/middleware/auth.go`,由项目实现凭据校验和身份注入。声明会生成路由绑定与 OpenAPI 的认证描述,**不会自动实现登录或授权规则**;未实现的 hook 返回 501。手写路由可使用 `auth.RequireAuth(authenticator)` 或自己的中间件。JWT、Session 和 Policy 按需选择。
新项目生成 `internal/middleware/authFactory.go`,由项目实现 `NewAuth(deps)`;生成器负责初始化和注册。v0.16 或 handler 模式继续使用 `internal/middleware/auth.go`。凭据校验和身份注入由项目实现。声明会生成路由绑定与 OpenAPI 的认证描述,**不会自动实现登录或授权规则**;未实现的工厂阻止启动,原有 hook 模式返回 501。手写路由可使用 `auth.RequireAuth(authenticator)` 或自己的中间件。JWT、Session 和 Policy 按需选择。
业务错误可以集中定义,业务只选择发生了哪个错误。**HTTP 状态由项目配置,业务失败可以返回 HTTP 200**;框架不根据 HTTP 状态单独判断成功与否。
@@ -386,6 +425,38 @@ return apierrors.TextTooLong("企业名称", 100)
更详细的配置、作用域和升级步骤见 [统一错误目录](errors/README.md)。
### 可选结构转换与深拷贝
普通接口不需要任何拷贝配置,直接使用生成的 Request/Response。独立业务结构之间有转换需求时:
```go
// import "git.yuntuo.hk/k2safe/go-omni/copyx"
var input DomainInput
if err := copyx.Copy(&input, req); err != nil { return err }
// 源与目标之间的指针、map、slice 需要隔离时:
snapshot, err := copyx.Clone(source)
if err != nil { return err }
```
默认浅拷贝,同名同类型字段自动赋值,保留零值;默认不会把 nil 填成非可空目标的零值。不要为使用工具新增一套重复模型。
需要重复使用或自定义规则时创建 Copier:
```go
copier, err := copyx.New(
copyx.WithDeepCopy(),
copyx.WithFieldNames[Input, View](map[string]string{"Name": "DisplayName"}),
copyx.WithConverter(func(v time.Time) (string, error) {
return v.UTC().Format(time.RFC3339), nil
}),
)
if err != nil { return err }
if err := copier.Copy(&view, input); err != nil { return err }
```
自定义函数的源/目标类型由 Go 泛型确定。深拷贝时返回原指针、原 map、子切片或嵌套旧引用都会报错;字符串/整数原值可以返回。差异规则、nil 策略、字段覆盖、资源限制和原 mapping 移除步骤见 [copyx 指南](COPY.md),完整类型及可运行代码见 [examples/copying](examples/copying/main.go)。工具使用运行时检查,类型对需测试;深拷贝不适用于连接、锁等资源。
### 数据库、存储及其他模块
生成项目在 `internal/svc` 中创建依赖,提供给 Logic/Repository,并用 `c.OnClose(db.Close)` 登记自有资源。生成的 main 在 HTTP 排空后关闭 svc,再关闭日志。直接使用框架的手写项目则可以用 `app.OnShutdown(db.Close)`;不要对同一资源重复登记。
@@ -460,9 +531,9 @@ go-omni api generate --check
go test ./...
```
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
`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.16.0-preview`。v0.14.x 项目升级依赖与 CLI 后重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
CLI 与业务 module 固定使用 `v0.19.0-preview`。v0.14.x 项目升级依赖与 CLI 后重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
## 按需深入
@@ -478,3 +549,7 @@ CLI 与业务 module 固定使用 `v0.16.0-preview`。v0.14.x 项目升级依赖
| 网络性能、测试条件与适用范围 | [性能说明](PERFORMANCE.md)、[HTTP/2 验收](transport/native/HTTP2.md) |
当前版本可用于固定版本接入与业务验证,仍标记为 preview。`.api` 借鉴 go-zero 的接口开发方式,支持范围以本仓库文档为准,不是完整 goctl 语法兼容层;HTTP/2 保持实验状态,OSS/R2 云账号实网验收不属于本次发布范围。
## 后续开发约定
每次功能开发完成,同步更新这份功能索引、用法说明、`examples/` 和发布记录;删除或改变功能必须注明影响与迁移。代码、示例和文档一起回归后再发布。约定已记录在 [AGENTS.md](AGENTS.md),后续开发继续遵循。

View File

@@ -1,4 +1,81 @@
# v0.16.0-preview
# v0.19.0-preview
将可选结构转换改为直接 Go 调用,减少业务项目需要维护的配置。普通接口继续使用 `.api` 生成 DTO,不要求增加独立模型或任何拷贝规则。
- 新增只依赖标准库的 `copyx`:Copy、Clone、可复用 Copier;浅/深拷贝、严格字段覆盖、nil/零值策略、字段改名、忽略字段、精确类型 converter。没有全局注册和隐式数字强转。
- 深拷贝检查整个输入和输出图。自定义 converter 原样返回指针、map、slice,或新对象仍嵌套旧引用时返回错误;保留普通循环图,拒绝资源对象及超出遍历/估算分配预算的输入。
- 转换失败不提交部分目标;保留错误路径和原始错误链。并发复用只共享不可变类型元数据;源/目标读写和 converter 副作用由调用方负责。
- **不兼容变更:删除 mappings / mappingFiles、Go 类型加载与映射代码生成。** 检出旧配置/旧生成文件时给出迁移提示;不会静默删除业务正在使用的代码。公共字段组合、scalar、中间件工厂、Repository 选项保留。
- README 补齐当前能力入口、解释和用法,新增 [完整 copyx 指南](COPY.md)、[可运行示例](examples/copying);[AGENTS.md](AGENTS.md) 记录每次修改同步文档/示例/移除说明的约定。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.19.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.19.0-preview
# 如用过旧 mapping,先按 COPY.md 完成迁移:
go-omni api generate
go-omni api generate --check
go test ./...
```
迁移步骤和语义边界见 [copyx](COPY.md#从旧-mapping-迁移)。新工具做运行时检查,不能声称具备旧生成器的编译期字段覆盖保证;实际类型对必须有测试。独立三端工资示例验证金额精度、缺失/null/零值、空集合、错误链和字段新增;不修改或代替接入项目的完整业务回归。
46 个测试包的全量 race(含实际生成项目)、vet/build、Go 1.22 兼容测试通过;30 秒 fuzz 无失败,14 种基准场景各测量三轮。验收条件、性能数据及本轮未覆盖的外部环境见 [v0.19 验收记录](reports/copyxAcceptance.md)。本版仍为 preview。
---
以下是历史版本记录,其中 mapping 功能已在 v0.19 移除,不应继续作为当前接入方式。
# 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。
全量 44 个测试包的 race、vet、build 和实际进程验收通过;空缓存远程 CLI 与 8 组实际生成消费者回归也通过,同版依赖且无本地 replace。标签固定在 `4563a6354fac19f22cd8d407fd76d98b8cc9c406`。
---
# v0.17.0-preview(历史版本)
减少项目里的重复声明、字段赋值和中间件装配代码,业务规则继续由项目控制:
- `.api` 公共字段组合:保持 JSON 平铺,DTO、绑定、校验和 OpenAPI 同步;字段冲突、循环和超限明确报错。
- 可选结构映射:在 `omni.gen.json` 显式声明源与目标,生成编译期检查的普通 Go 赋值。不同字段名、金额、时间和指针转换由项目指定函数;遗漏字段与类型错误在写文件前失败。不使用 JSON 往返或运行时反射。
- 带依赖的中间件工厂:svc 初始化依赖后自动调用 `New<Name>(deps)` 并注册;错误、nil 或 panic 阻止启动,已登记资源按生命周期清理。
- Repository 骨架默认关闭,可按 group 启用;已有业务文件始终保留。生成选项与映射纳入重新生成和 `--check`。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.17.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.17.0-preview
go-omni api generate
go-omni api generate --check
go test ./...
```
新项目默认使用 factories;已有项目首次升级保留 handlers 模式,需要显式迁移后再切换。映射按需开启,先准备业务类型、转换函数和依赖,再声明映射;普通引用赋值保留 Go 语义,缺失/null 语义由项目类型与转换函数定义。
使用方法、文件归属和升级步骤见 [生成能力指南](GENERATION.md),回归与发布验收见 [验收记录](reports/generationExperienceAcceptance.md)。本版仍为 preview;框架验收不等于业务项目完成迁移。
全量 44 个测试包的 race、vet、build 和实际进程验收通过;发布后的空缓存远程 CLI 与 7 组生成消费者回归也通过,同版依赖且无本地 replace。标签固定在 `8b588a24cd653dac77dd68f6777e0db367981f19`。
---
# v0.16.0-preview(历史版本)
补齐通用契约与生成体验,业务响应协议继续由项目定义:
@@ -17,7 +94,7 @@ go test ./...
旧项目 config/Logic/svc 继续由开发者维护,不会被重新生成覆盖。已有环境配置需按 [一次覆盖完整配置](DEVELOPMENT.md#一次覆盖完整配置与-http日志短名称) 合并 OverlayEnv 与 ApplyEnv;共享错误目录接入见 [workspace 配置](DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。已有手写权限 Handler 切换自动生成前,先迁出实现并显式移除 Manual 文件,生成器不会静默删除。
全量 race(含实际生成消费者)、vet、build、根入口/退出/再生成验证通过。eTax 只读契约验收:API 文档 20,751,149 → 847,171 字节,ID 文档 1,008,210 → 392,481 字节;70 个操作展开引用后与原文档完全一致。详见 [验收记录](reports/contractFollowupAcceptance.md)。这不代表已完成业务项目迁移。
全量 race(含实际生成消费者)、vet、build、根入口/退出/再生成验证通过。eTax 只读契约验收:API 文档 20,751,149 → 847,171 字节,ID 文档 1,008,210 → 392,481 字节;70 个操作展开引用后与原文档完全一致。空缓存远程 CLI / 固定版本消费者验收也已通过,没有本地 replace;标签固定在 `64e3bd6cfced225476d58e95037249cd0a4fe695`。详见 [验收记录](reports/contractFollowupAcceptance.md)。这不代表已完成业务项目迁移。
---

View File

@@ -1,5 +1,8 @@
# GoOmni 成熟度计划
当前发布基线为 v0.19.0-preview:普通接口通过 `.api` → DTO → Logic 开发;独立类型需要转换时可选 copyx。旧 JSON mapping 生成已移除,见 [迁移说明](COPY.md)。当前能力、使用入口和限制以 [README](README.md) 与 [发布记录](RELEASE.md) 为准。下文保留阶段性建设记录。
当前进展:`v0.14.0-preview` 已完成契约驱动的业务开发能力、通用 Runtime/配置和官方 MySQL 适配器,并通过全量 race、实际生成服务及空缓存远程验收。新项目/升级以 [README](README.md) 和 [开发指南](DEVELOPMENT.md) 为准;下方为历史阶段记录,完整本轮验收见 [报告](reports/businessDevelopmentAcceptance.md)。具体 eTax/ePay 业务迁移与业务回归仍由接入项目完成。
## 1. 新项目启动与发布流程(已完成,v0.1.1)

View File

@@ -1,5 +1,9 @@
# go-omni CLI
v0.19 移除结构映射生成:`omni.gen.json` 只保留 Repository 和中间件选项。普通接口直接使用生成 DTO;需要类型转换时用普通 Go 或可选 [copyx](../../COPY.md)。旧 `mappings` / `mappingFiles` 必须按迁移步骤清理,CLI 不会静默删除仍在调用的旧生成文件。
公共字段组合、中间件工厂和按需 Repository 继续支持,见 [生成能力指南](../../GENERATION.md)。
v0.16 增加基础类型/动态 JSON 数组返回、OpenAPI 公共引用和 workspace Go Catalog 统一导出。见 [接入与筛选规则](../../DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。
v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目录>|--apply]`,以及 `workspace format|generate|check --file omni.workspace.json`。详见 [通用开发指南](../../DEVELOPMENT.md)。
@@ -10,14 +14,14 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目
```sh
# 仓库读取权限与 PATH 配置见根目录 README
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.19.0-preview
go-omni new --module example.com/hello ./hello
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
go-omni new --api service.api --module example.com/example ./example
```
`new` 与 `api new` 统一生成契约项目;不再提供另一套 CRUD 启动目录。未实现的 Logic / 中间件骨架返回 501。进入项目执行 `go mod tidy`、`go run .`、`go build .`。
`new` 与 `api new` 统一生成契约项目;不再提供另一套 CRUD 启动目录。未实现的 Logic 返回 501;中间件工厂未实现时阻止启动,既有 handler 模式仍返回 501。进入项目执行 `go mod tidy`、`go run .`、`go build .`。
参数:`--database none|memory|mysql`(默认 none;memory 等同 none),`--storage none|local|s3|r2|oss`,`--log-output stdout|stderr|file`,`--log-format json|text`。基础设施按需生成,连接凭据由环境提供,不自动创建数据库、表或云资源。选择 MySQL 不会凭空生成业务表或 Repository 查询。
@@ -45,14 +49,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
## 版本与迁移
CLI 与框架依赖固定使用 v0.16.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
CLI 与框架依赖固定使用 v0.19.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
```sh
# 在 go-omni 源码仓库中执行
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
```
模板最低需要 v0.16.0-preview 的统一环境变量别名能力,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
当前模板与依赖以 v0.19.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
## 数据库迁移命令

View File

@@ -170,6 +170,7 @@ func TestRegenerationAndConflicts(t *testing.T) {
servicePath := filepath.Join(dir, "internal/logic/greet/greet.go")
service := readFile(t, servicePath) + "\n// user edit must survive\n"
writeFile(t, servicePath, service)
writeFile(t, filepath.Join(dir, "internal/repository/greet/repository.go"), "package greet\n")
for _, file := range []string{"etc/config.yaml", "internal/svc/serviceContext.go", "internal/repository/greet/repository.go"} {
body := readFile(t, filepath.Join(dir, file))
if strings.HasSuffix(file, ".go") {
@@ -301,6 +302,7 @@ func TestGeneratedProjectsBuildAndRun(t *testing.T) {
if starter {
_, err = New(o)
} else {
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
o.API = filepath.Join(root, "examples/contracts/user.api")
_, err = Generate(o)
}

View File

@@ -40,6 +40,7 @@ func TestBusinessGeneratedProject(t *testing.T) {
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
}
dir := filepath.Join(t.TempDir(), "business")
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
o := Options{Dir: dir, API: filepath.Join(frameworkRoot(t), "examples/contracts/business.api"), Module: "example.com/business", FrameworkDir: frameworkRoot(t)}
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)

View File

@@ -40,6 +40,9 @@ func Compile(spec *Spec) (*Compiled, error) {
if prepareErr != nil {
return nil, prepareErr
}
if err := expandFields(spec); err != nil {
return nil, err
}
if err := validateDocumentCatalog(spec); err != nil {
return nil, err
}

View File

@@ -0,0 +1,102 @@
package apigen
import (
"fmt"
"reflect"
"strings"
)
// expandFields copies definitions rather than embedding Go structs. Binding,
// validation, DTOs, snapshots and OpenAPI all consume the same flattened fields.
func expandFields(spec *Spec) error {
definitions := map[string]Type{}
for _, t := range spec.Types {
if _, exists := definitions[t.Name]; exists {
return fmt.Errorf("%s: duplicate type %s", t.Position, t.Name)
}
definitions[t.Name] = t
}
expanded := map[string]Type{}
heights := map[string]int{}
visiting := map[string]bool{}
total := 0
var expand func(string, int) (Type, error)
expand = func(name string, depth int) (Type, error) {
if t, ok := expanded[name]; ok {
if depth+heights[name] > 32 {
return Type{}, fmt.Errorf("field composition exceeds 32 levels at %s", name)
}
return t, nil
}
if visiting[name] {
return Type{}, fmt.Errorf("field composition cycle at %s", name)
}
if depth > 32 {
return Type{}, fmt.Errorf("field composition exceeds 32 levels at %s", name)
}
t, ok := definitions[name]
if !ok {
return Type{}, fmt.Errorf("unknown composed object type %s", name)
}
visiting[name] = true
defer delete(visiting, name)
fields := []Field{}
height := 0
seen, wire := map[string]bool{}, map[string]bool{}
for _, f := range t.Fields {
add := []Field{f}
if f.Type == "" {
child, err := expand(f.Name, depth+1)
if err != nil {
return Type{}, fmt.Errorf("%s.%s: %w", name, f.Name, err)
}
add = child.Fields
if heights[f.Name]+1 > height {
height = heights[f.Name] + 1
}
if height > 32 {
return Type{}, fmt.Errorf("field composition exceeds 32 levels at %s", name)
}
}
for _, field := range add {
if seen[field.Name] {
return Type{}, fmt.Errorf("%s: conflicting composed field %s", name, field.Name)
}
seen[field.Name] = true
for _, source := range []string{"json", "form", "path", "header", "multipart", "multipartJSON", "file"} {
tag := strings.Split(reflect.StructTag(field.Tag).Get(source), ",")[0]
if tag == "" || tag == "-" {
continue
}
if source == "header" {
tag = strings.ToLower(tag)
}
key := source + ":" + tag
if wire[key] {
return Type{}, fmt.Errorf("%s: conflicting composed field tag %s", name, key)
}
wire[key] = true
}
fields = append(fields, field)
total++
if len(fields) > 4096 || total > 65536 {
return Type{}, fmt.Errorf("field composition expansion limit exceeded")
}
}
}
t.Fields = fields
expanded[name] = t
heights[name] = height
return t, nil
}
result := make([]Type, 0, len(spec.Types))
for _, t := range spec.Types {
out, err := expand(t.Name, 0)
if err != nil {
return err
}
result = append(result, out)
}
spec.Types = result
return nil
}

View File

@@ -0,0 +1,163 @@
package apigen
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestFieldComposition(t *testing.T) {
source := readFile(t, "testdata/composition/service.api")
spec, err := parseText(t, source)
if err != nil {
t.Fatal(err)
}
first, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
second, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(first.Document, second.Document) || len(spec.Types[1].Fields) != 1 {
t.Fatal("composition mutates input")
}
for _, name := range []string{"EnterpriseRequest", "TenantRequest", "AdminRequest"} {
schema := first.Bindings[name].BodySchema()
raw, _ := json.Marshal(schema)
if !bytes.Contains(raw, []byte("Employee display name.")) || bytes.Contains(raw, []byte("SalaryFields")) {
t.Fatalf("flattened schema/comment missing: %s", raw)
}
}
cases := []struct{ name, decl, want string }{
{"unknown", "type Req {\n Missing\n}", "unknown composed"},
{"cycle", "type Req {\n Other\n}\ntype Other {\n Req\n}", "cycle"},
{"conflict", "type Base {\n Name string `json:\"name\"`\n}\ntype Req {\n Base\n Name string `json:\"other\"`\n}", "conflicting"},
{"wireConflict", "type Base {\n Name string `json:\"name\"`\n}\ntype Req {\n Base\n Other string `json:\"name\"`\n}", "conflicting"},
{"tagged", "type Req {\n Other `json:\"other\"`\n}", "cannot carry"},
{"diamond", "type Base {\n Name string `json:\"name\"`\n}\ntype Left {\n Base\n}\ntype Right {\n Base\n}\ntype Req {\n Left\n Right\n}", "conflicting"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s, e := parseText(t, tc.decl+"\nservice demo{\n@handler post\npost / (Req)\n}")
if e == nil {
_, e = Compile(s)
}
if e == nil || !strings.Contains(e.Error(), tc.want) {
t.Fatal(e)
}
})
}
// Imports can contain reusable definitions and retain the existing root policy.
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "base.api"), "type Base {\n Name string `json:\"name\"`\n}\n")
writeFile(t, filepath.Join(dir, "service.api"), "import \"base.api\"\ntype Req {\n Base\n}\nservice demo{\n@handler post\npost / (Req)\n}")
if _, err := FormatProject(filepath.Join(dir, "service.api"), false); err != nil {
t.Fatal(err)
}
if _, err := FormatProject(filepath.Join(dir, "service.api"), true); err != nil {
t.Fatal(err)
}
}
func TestRepositoryGenerationOptions(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/repository", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "internal/repository")); !os.IsNotExist(err) {
t.Fatal("default creates repository")
}
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"repository":"group","middleware":"factories"}`)
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
repo := filepath.Join(dir, "internal/repository/greet/repository.go")
original := readFile(t, repo) + "\n// business SQL belongs here\n"
writeFile(t, repo, original)
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"repository":"none","middleware":"factories"}`)
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
o.Check = true
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
if readFile(t, repo) != original {
t.Fatal("repository removed or overwritten")
}
for _, raw := range []string{`{"repository":"oops"}`, `{"middleware":"oops"}`, `{"unknown":true}`, `{"repository":"none","repository":"group"}`} {
writeFile(t, filepath.Join(dir, generatorConfigFile), raw)
before := snapshot(t, dir)
if _, err := Generate(o); err == nil {
t.Fatal("accepted", raw)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("invalid config wrote files")
}
}
}
func TestCompositionExpansionBound(t *testing.T) {
var source strings.Builder
source.WriteString("type Base {\n Value string `json:\"value\"`\n}\n")
last := "Base"
for i := 0; i < 35; i++ {
name := fmt.Sprintf("Level%d", i)
fmt.Fprintf(&source, "type %s {\n %s\n}\n", name, last)
last = name
}
// Reverse order forces traversal instead of hitting pre-expanded definitions.
spec, err := parseText(t, source.String()+"service demo{\n@handler post\npost / ("+last+")\n}")
if err != nil {
t.Fatal(err)
}
for i, j := 0, len(spec.Types)-1; i < j; i, j = i+1, j-1 {
spec.Types[i], spec.Types[j] = spec.Types[j], spec.Types[i]
}
if _, err = Compile(spec); err == nil {
t.Fatal("unbounded composition accepted")
}
}
func TestGenerationConfigMissingAndLegacy(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/config", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
if err := os.Remove(filepath.Join(dir, generatorConfigFile)); err != nil {
t.Fatal(err)
}
before := snapshot(t, dir)
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "restore") {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("missing config changed project")
}
// A genuine old manifest has never tracked a generator config.
var old manifest
if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, manifestPath))), &old); err != nil {
t.Fatal(err)
}
old.GeneratorConfig = false
raw, _ := json.Marshal(old)
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
cfg, exists, err := readGeneratorConfig(dir)
if err != nil || !exists || cfg.Middleware != "handlers" || cfg.Repository != "none" {
t.Fatal(cfg, exists, err)
}
if !strings.Contains(readFile(t, filepath.Join(dir, "internal/svc/runtimeGen.go")), "middleware.Defaults()") {
t.Fatal("legacy registration replaced")
}
}

View File

@@ -0,0 +1,95 @@
package apigen
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func runConsumerGo(t *testing.T, dir string, args ...string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("go %v: %v\n%s", args, err, out)
}
}
func TestCopyxThreeEndpointConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
}
dir := filepath.Join(t.TempDir(), "app")
o := Options{API: filepath.Join(frameworkRoot(t), "cmd/go-omni/apigen/testdata/composition/service.api"), Dir: dir, Module: "example.com/composition", FrameworkDir: frameworkRoot(t)}
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
for path, fixture := range map[string]string{
"internal/amount/money.go": "amount.go", "internal/domain/employee.go": "domain.go", "internal/convert/fields.go": "converters.go", "internal/convert/copy.go": "copy.go",
"internal/middleware/dependencies.go": "dependencies.go", "internal/middleware/accessFactory.go": "accessFactory.go", "internal/svc/client.go": "client.go", "consumer_test.go": "consumer_test.go",
} {
writeFile(t, filepath.Join(dir, path), readFile(t, "testdata/composition/"+fixture))
}
svcFile := filepath.Join(dir, "internal/svc/serviceContext.go")
source := strings.Replace(readFile(t, svcFile), "type ServiceContext struct {", "type ServiceContext struct {\n client *Client", 1)
source = strings.Replace(source, "return nil", "return c.initClient(ctx)", 1)
writeFile(t, svcFile, source)
configFile := filepath.Join(dir, "internal/config/config.go")
writeFile(t, configFile, strings.Replace(readFile(t, configFile), "type Config struct {", "type Config struct {\n FactoryMode string `json:\"factoryMode\"`", 1))
for _, group := range []string{"Enterprise", "Tenant", "Admin"} {
text := fmt.Sprintf(`package %s
import("context";"example.com/composition/internal/types";"example.com/composition/internal/convert";"example.com/composition/internal/domain")
func(s *Logic)Save(ctx context.Context,req *types.%sRequest)(*types.EmployeeResponse,error){
input,err:=convert.Employee(*req);if err!=nil{return nil,err}
result,err:=convert.Response(domain.Result{Employee:input,ID:9007199254740993});if err!=nil{return nil,err};return &result,nil
}
`, strings.ToLower(group), group)
writeFile(t, filepath.Join(dir, "internal/logic", strings.ToLower(group), "save.go"), text)
}
runConsumerGo(t, dir, "mod", "tidy")
runConsumerGo(t, dir, "test", "-race", "-count=1", "./...")
o.API = filepath.Join(dir, "api/service.api")
before := snapshot(t, dir)
o.DryRun = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
o.DryRun = false
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
o.Check = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("generation changed business files")
}
if _, err := os.Stat(filepath.Join(dir, "internal/mapping")); !os.IsNotExist(err) {
t.Fatal("mapping directory generated")
}
// Adding a common field requires one contract edit and one independent
// business field. Three endpoint converters need no new rules.
contract := readFile(t, o.API)
contract = strings.Replace(contract, "type SalaryFields {", "type SalaryFields {\n Note string `json:\"note,optional\"`", 1)
writeFile(t, o.API, contract)
domainPath := filepath.Join(dir, "internal/domain/employee.go")
writeFile(t, domainPath, strings.Replace(readFile(t, domainPath), "type Employee struct {", "type Employee struct {\n Note string", 1))
o.Check = false
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
o.Check = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
runConsumerGo(t, dir, "test", "-race", "-count=1", "./...")
runConsumerGo(t, dir, "build", ".")
}

View File

@@ -19,10 +19,11 @@ import (
)
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
const minimumVersion = "v0.16.0-preview"
const minimumVersion = "v0.19.0-preview"
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct {
generation GeneratorConfig
lockHeld bool
documentCatalogs map[string][]byte
Database, Storage string
@@ -97,6 +98,11 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
return Output{}, err
}
o.Dir = directory
genConfig, hasGenConfig, err := readGeneratorConfig(directory)
if err != nil {
return Output{}, err
}
o.generation = genConfig
if err = checkRuntimeExtension(directory); err != nil {
return Output{}, err
}
@@ -192,6 +198,10 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
for name, data := range base {
seeds[name] = data
}
if !hasGenConfig {
raw, _ := json.MarshalIndent(genConfig, "", " ")
seeds[generatorConfigFile] = append(raw, '\n')
}
// The API source remains developer-owned. A relocated project regenerates
// from its own api/ tree, including all imports.
entry := "api/" + c.Spec.Entry
@@ -242,6 +252,15 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
}
}
}
currentConfig, stillExists, err := readGeneratorConfig(directory)
if err != nil {
return Output{}, err
}
beforeConfig, _ := json.Marshal(genConfig)
afterConfig, _ := json.Marshal(currentConfig)
if hasGenConfig != stillExists || genConfig.inputDigest != currentConfig.inputDigest || string(beforeConfig) != string(afterConfig) {
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)
expectedVersion := minimumVersion
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) {
@@ -602,8 +621,10 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
}
put(generated, "internal/handler/"+group+"/handlersGen.go", generatedHeader+text)
put(seeds, "internal/logic/"+group+"/logic.go", fmt.Sprintf("package %s\nimport %q\ntype Logic struct {deps *svc.ServiceContext}\nfunc New(deps *svc.ServiceContext)*Logic{return &Logic{deps:deps}}\n", packageName, o.Module+"/internal/svc"))
if o.generation.Repository == "group" {
put(seeds, "internal/repository/"+group+"/repository.go", "// Package "+packageName+" owns persistence contracts for this business group.\n// Define repository interfaces and implementations here; HTTP DTOs do not\n// determine a database schema. This file is never overwritten.\npackage "+packageName+"\n")
}
}
put(generated, "internal/handler/routesGen.go", generatedHeader+"package handler\n"+imports+")\n"+routes+"return nil\n}\n")
registry := "package middleware\nimport " + strconv.Quote(frameworkModule+"/httpx") + "\nfunc Defaults()map[string]httpx.Middleware{return map[string]httpx.Middleware{\n"
for _, m := range middlewareNames {
@@ -611,6 +632,9 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
put(seeds, "internal/middleware/"+lower(m)+".go", fmt.Sprintf("package middleware\nimport(%q; apierrors %q)\n// %s is a developer-owned hook. Implement it before enabling this route.\nfunc %s(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error {return apierrors.New(501,\"MIDDLEWARE_NOT_IMPLEMENTED\",\"middleware is not implemented\")}}\n", frameworkModule+"/httpx", frameworkModule+"/errors", upper(m), upper(m)))
}
put(generated, "internal/middleware/registryGen.go", generatedHeader+registry+"}}\n")
if o.generation.Middleware == "factories" {
renderMiddlewareFactories(middlewareNames, generated, seeds)
}
for name, source := range baseTemplates {
content := strings.ReplaceAll(source, "MODULE", o.Module)
content = strings.ReplaceAll(content, "LOGFORMAT", o.LogFormat)
@@ -630,6 +654,16 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
return nil, nil, err
}
for name, data := range infra {
if o.generation.Middleware == "factories" && name == "internal/svc/runtimeGen.go" {
formatted, err := format.Source(data)
if err != nil {
return nil, nil, err
}
source := string(formatted)
source = strings.Replace(source, "Middlewares: middleware.Defaults(),", "", 1)
source = strings.Replace(source, "return c, nil", "if len(c.Middlewares) != 0 { return nil, errors.New(\"factory middleware is registered automatically; remove manual Middlewares assignments from Init\") }\nc.Middlewares, err = middleware.New(c)\nif err != nil { return nil, err }\nreturn c, nil", 1)
data = []byte(source)
}
if strings.HasSuffix(name, "Gen.go") {
generated[name] = append([]byte(generatedHeader), data...)
} else {

View File

@@ -0,0 +1,110 @@
package apigen
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
const generatorConfigFile = "omni.gen.json"
// GeneratorConfig contains developer-owned scaffolding options only.
type GeneratorConfig struct {
Repository string `json:"repository,omitempty"`
Middleware string `json:"middleware,omitempty"`
inputDigest string
}
func readGeneratorConfig(root string) (GeneratorConfig, bool, error) {
cfg := GeneratorConfig{Repository: "none", Middleware: "factories"}
if err := safePath(root, generatorConfigFile); err != nil {
return cfg, false, err
}
stat, err := os.Lstat(filepath.Join(root, generatorConfigFile))
if errors.Is(err, os.ErrNotExist) {
// Existing projects keep their handwritten middleware signature until they
// explicitly select factories; do not rewrite or adopt developer files.
if raw, e := os.ReadFile(filepath.Join(root, manifestPath)); e == nil {
var previous manifest
if err := json.Unmarshal(raw, &previous); err != nil {
return cfg, false, err
}
if previous.GeneratorConfig {
return cfg, false, errors.New("omni.gen.json is missing; restore the project-owned generation options before regenerating")
}
cfg.Middleware = "handlers"
} else if !errors.Is(e, os.ErrNotExist) {
return cfg, false, e
}
return cfg, false, nil
}
if err != nil {
return cfg, false, err
}
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")
}
raw, err := readGeneratorConfigFile(root, generatorConfigFile)
if err != nil {
return cfg, false, err
}
var keys map[string]json.RawMessage
if err = apierrors.DecodeCatalogJSON(raw, &keys); err != nil {
return cfg, false, fmt.Errorf("omni.gen.json: %w", err)
}
for _, key := range []string{"mappings", "mappingFiles"} {
if _, exists := keys[key]; exists {
return cfg, false, errors.New("mapping generation was removed: migrate callers to copyx or ordinary Go, remove mappings/mappingFiles from omni.gen.json, then remove internal/mapping/mappingsGen.go; see COPY.md")
}
}
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
return cfg, false, fmt.Errorf("omni.gen.json: %w", err)
}
digest := sha256.Sum256(raw)
cfg.inputDigest = fmt.Sprintf("%x", digest)
if cfg.Repository != "none" && cfg.Repository != "group" {
return cfg, false, errors.New("omni.gen.json: repository must be none or group")
}
if cfg.Middleware != "factories" && cfg.Middleware != "handlers" {
return cfg, false, errors.New("omni.gen.json: middleware must be factories or handlers")
}
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 readGeneratorConfigFile(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
}

View File

@@ -0,0 +1,96 @@
package apigen
import (
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func TestWorkspaceRejectsRemovedMappingWithoutWrites(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"one", "two"} {
if _, err := New(Options{Dir: filepath.Join(root, name), Module: "example.com/" + name, FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
}
firstAPI := filepath.Join(root, "one/api/service.api")
writeFile(t, firstAPI, strings.Replace(readFile(t, firstAPI), "Greet a visitor", "Changed description", 1))
writeFile(t, filepath.Join(root, "two", generatorConfigFile), `{"mappingFiles":["../../private.json"]}`)
workspace := filepath.Join(root, "omni.workspace.json")
writeFile(t, workspace, `{"services":[{"dir":"one"},{"dir":"two"}]}`)
before := snapshot(t, root)
for _, operation := range []string{"generate", "check"} {
if err := Workspace(workspace, operation, io.Discard); err == nil || !strings.Contains(err.Error(), "mapping generation was removed") {
t.Fatal(operation, err)
}
if !sameFiles(before, snapshot(t, root)) {
t.Fatal("workspace migration failure wrote files")
}
}
}
func TestRemovedMappingConfiguration(t *testing.T) {
dir := filepath.Join(t.TempDir(), "project")
if _, err := New(Options{Dir: dir, Module: "example.com/removed", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
for _, raw := range []string{`{"mappings":[]}`, `{"mappingFiles":[]}`, `{"mappings":null}`, `{"mappingFiles":["../../private.json"]}`} {
writeFile(t, filepath.Join(dir, generatorConfigFile), raw)
before := snapshot(t, dir)
for _, o := range []Options{{Dir: dir}, {Dir: dir, Check: true}, {Dir: dir, DryRun: true}} {
o.API = filepath.Join(dir, "api/service.api")
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "mapping generation was removed") {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("rejected config wrote project")
}
}
}
}
func TestOldMappingFileRequiresExplicitMigration(t *testing.T) {
dir := filepath.Join(t.TempDir(), "project")
if _, err := New(Options{Dir: dir, Module: "example.com/removed", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
name := "internal/mapping/mappingsGen.go"
body := generatedHeader + "package mapping\n"
writeFile(t, filepath.Join(dir, name), body)
var m manifest
if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, manifestPath))), &m); err != nil {
t.Fatal(err)
}
m.Generated[name] = digest([]byte(body))
raw, _ := json.Marshal(m)
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
before := snapshot(t, dir)
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
for _, mode := range []string{"generate", "check", "dry-run"} {
o.Check = mode == "check"
o.DryRun = mode == "dry-run"
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "mapping generation was removed") {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("legacy file deleted or project changed")
}
}
if err := os.Remove(filepath.Join(dir, name)); err != nil {
t.Fatal(err)
}
o.Check = false
o.DryRun = false
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
o.Check = true
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
if strings.Contains(readFile(t, filepath.Join(dir, manifestPath)), name) {
t.Fatal("old ownership retained")
}
}

View File

@@ -170,12 +170,15 @@ func generateManagedTestProject(t *testing.T, o Options) error {
_, err := Generate(o)
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
args := []string{"api", "generate", "--api", o.API, "--dir", o.Dir, "--module", o.Module}
if o.Check {
args = append(args, "--check")
}
if o.DryRun {
args = append(args, "--dry-run")
}
cmd := exec.CommandContext(ctx, cli, args...)
cmd.Dir = filepath.Dir(o.Dir)
if data, err := cmd.CombinedOutput(); err != nil {

View File

@@ -238,6 +238,7 @@ func TestDeclaredContractConsumer(t *testing.T) {
source := filepath.Join(root, "input/service.api")
writeFile(t, source, declaredAPI)
dir := filepath.Join(root, "project")
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
if _, err := Generate(Options{API: source, Dir: dir, Module: "example.com/dx", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}

View File

@@ -0,0 +1,19 @@
package apigen
import "fmt"
func renderMiddlewareFactories(names []string, generated, seeds map[string][]byte) {
// All project-specific types live behind this project-owned interface. svc
// implements it; middleware never imports svc (and cannot form that cycle).
seeds["internal/middleware/dependencies.go"] = []byte("package middleware\n// Dependencies is implemented by *svc.ServiceContext. Add typed accessors here\n// and implement them on ServiceContext. Factories borrow dependencies; register\n// their cleanup in ServiceContext.Init, not in a middleware factory.\ntype Dependencies interface {}\n")
text := fmt.Sprintf("package middleware\nimport(\"fmt\";%q)\n// New constructs declared middleware after ServiceContext.Init succeeds.\nfunc New(deps Dependencies)(map[string]httpx.Middleware,error){\nresult:=map[string]httpx.Middleware{}\n", frameworkModule+"/httpx")
if len(names) == 0 {
text = fmt.Sprintf("package middleware\nimport %q\nfunc New(deps Dependencies)(map[string]httpx.Middleware,error){\nresult:=map[string]httpx.Middleware{}\n", frameworkModule+"/httpx")
}
for i, name := range names {
delete(seeds, "internal/middleware/"+lower(name)+".go")
text += fmt.Sprintf("m%d,err:=New%s(deps)\nif err!=nil{return nil,fmt.Errorf(%q,err)}\nif m%d==nil{return nil,fmt.Errorf(%q)}\nresult[%q]=m%d\n", i, upper(name), "middleware "+name+": %w", i, "middleware "+name+": factory returned nil", name, i)
seeds["internal/middleware/"+lower(name)+"Factory.go"] = []byte(fmt.Sprintf("package middleware\nimport(\"errors\";%q)\n// New%s is project-owned. Implement this factory once; registration is generated.\n// Dependencies have already been initialized. Do not acquire owned resources here.\nfunc New%s(deps Dependencies)(httpx.Middleware,error){\nreturn nil,errors.New(%q)\n}\n", frameworkModule+"/httpx", upper(name), upper(name), "implement "+name+" middleware factory"))
}
generated["internal/middleware/registryGen.go"] = []byte(generatedHeader + text + "return result,nil\n}\n")
}

View File

@@ -39,6 +39,8 @@ type Type struct {
Fields []Field
Position string
}
// An empty Type denotes a named field composition, expanded before compilation.
type Field struct{ Name, Type, Tag, Description string }
type Route struct {
BoundMultipart bool
@@ -517,7 +519,11 @@ func (p *parser) parseType() error {
}
}
if expression == "" {
return p.err("field type required; embedded types are not supported")
if p.tokens[p.at].kind == scanner.RawString {
return p.err("composed types cannot carry field tags")
}
typ.Fields = append(typ.Fields, Field{Name: name, Description: description})
continue
}
if p.tokens[p.at].kind != scanner.RawString {
return p.err("field source tag required")

View File

@@ -251,6 +251,7 @@ func TestResponseTemplateConsumer(t *testing.T) {
t.Fatal(err)
}
}
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
generate(source, true)
writeFile(t, filepath.Join(dir, "response_test.go"), responseConsumerTest)
for _, method := range []string{"Item", "Callback", "Partner"} {

View File

@@ -51,6 +51,17 @@ This project is generated from an editable API contract. Business code uses
standard context.Context; generated HTTP handlers use Omni's transport-neutral httpx.
main.go and HTTP composition are continuously generator-owned.
omni.gen.json selects repository: none (default) or group, and middleware:
factories for new projects. Named middleware factories run after svc.Init;
implement Dependencies accessors on ServiceContext and New<Name>(deps) once.
Unimplemented/nil factories fail startup and clean registered dependencies.
Existing projects retain middleware: handlers until explicitly migrated.
Ordinary Logic uses the generated Request/Response types directly. Where an
independent business type is needed, use ordinary Go or the optional copyx package.
There are no mappings/mappingFiles settings or generated mapping files in v0.19.
See COPY.md in the framework repository for copy policies and migration steps,
and GENERATION.md for field composition and middleware factories.
## Run
`+"```sh"+`
@@ -133,8 +144,9 @@ internal/svc/runtimeGen.go # GENERATED lifecycle and infrastructure startup
internal/apierrors/errorsGen.go # generated fresh error constructors, when declared
internal/handler/<group>/ # generated binding and Logic adapters
internal/logic/<group>/ # developer-owned business operations
internal/repository/<group>/ # developer-owned persistence contracts
internal/middleware/ # named hooks and generated registry
internal/repository/<group>/ # optional; repository: group creates editable seeds
internal/middleware/ # typed dependency interface, factories and registry
omni.gen.json # developer-owned repository/middleware options
docs/openapi.json # generated OpenAPI 3.0.3
.goOmni/apiManifest.json # ownership and content hashes
`+"```"+`

View File

@@ -0,0 +1,30 @@
package middleware
import (
"errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
var ErrFactory = errors.New("factory fault")
func NewAccess(deps Dependencies) (httpx.Middleware, error) {
if deps.Access() == nil {
return nil, errors.New("dependencies not initialized")
}
switch deps.FactoryMode() {
case "fail":
return nil, ErrFactory
case "nil":
return nil, nil
case "panic":
panic(ErrFactory)
}
return func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
if err := deps.Access().Check(c.Context()); err != nil {
return err
}
return next(c)
}
}, nil
}

View File

@@ -0,0 +1,52 @@
package amount
import (
"encoding/json"
"errors"
"fmt"
"math/big"
"regexp"
)
// This fixture's business policy is exact decimal HALF_UP. The framework does
// not choose its precision, rounding mode or currency.
type Money string
var decimalPattern = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?$`)
func (m Money) String() string {
if m == "" {
return "0.00"
}
return string(m)
}
func (m Money) MarshalJSON() ([]byte, error) { return json.Marshal(m.String()) }
func (m *Money) UnmarshalJSON(data []byte) error {
var text string
if err := json.Unmarshal(data, &text); err != nil {
return err
}
if !decimalPattern.MatchString(text) {
return errors.New("invalid decimal")
}
value, ok := new(big.Rat).SetString(text)
if !ok {
return errors.New("invalid decimal")
}
value.Mul(value, big.NewRat(100, 1))
sign := value.Sign()
value.Abs(value)
cents, rem := new(big.Int), new(big.Int)
cents.QuoRem(value.Num(), value.Denom(), rem)
if rem.Mul(rem, big.NewInt(2)).Cmp(value.Denom()) >= 0 {
cents.Add(cents, big.NewInt(1))
}
whole, fraction := new(big.Int), new(big.Int)
whole.QuoRem(cents, big.NewInt(100), fraction)
prefix := ""
if sign < 0 && cents.Sign() != 0 {
prefix = "-"
}
*m = Money(fmt.Sprintf("%s%s.%02d", prefix, whole.String(), fraction.Int64()))
return nil
}

View File

@@ -0,0 +1,36 @@
package svc
import (
"context"
"errors"
"example.com/composition/internal/middleware"
"sync/atomic"
)
var Closes atomic.Int64
var ErrInit = errors.New("init fault")
type Client struct{ closed atomic.Bool }
func (c *Client) Check(ctx context.Context) error {
if c.closed.Load() {
return errors.New("client closed before HTTP drained")
}
return ctx.Err()
}
func (c *ServiceContext) initClient(ctx context.Context) error {
c.client = &Client{}
c.OnClose(func() error {
if !c.client.closed.CompareAndSwap(false, true) {
return errors.New("double close")
}
Closes.Add(1)
return nil
})
if c.Config.FactoryMode == "initFail" {
return ErrInit
}
return nil
}
func (c *ServiceContext) Access() middleware.AccessChecker { return c.client }
func (c *ServiceContext) FactoryMode() string { return c.Config.FactoryMode }

View File

@@ -0,0 +1,217 @@
package main
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"example.com/composition/internal/amount"
"example.com/composition/internal/config"
"example.com/composition/internal/convert"
"example.com/composition/internal/domain"
"example.com/composition/internal/handler"
"example.com/composition/internal/middleware"
"example.com/composition/internal/svc"
"example.com/composition/internal/types"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func logger(t *testing.T) *logx.Logger {
t.Helper()
cfg := logx.Default()
cfg.Writer = io.Discard
l, e := logx.New(cfg)
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { _ = l.Close() })
return l
}
func TestHTTPComposition(t *testing.T) {
deps, e := svc.New(context.Background(), logger(t))
if e != nil {
t.Fatal(e)
}
defer deps.Close()
app, e := handler.NewApp(deps)
if e != nil {
t.Fatal(e)
}
defer app.Close()
for _, path := range []string{"/enterprise", "/tenant/123", "/admin/123"} {
for _, tc := range []struct {
name, body string
status int
salary, nameValue string
employer any
}{
{"exact", `{"name":"employee","salary":"90071992547409.91","employer":null,"enabled":false}`, 200, "90071992547409.91", "employee", nil},
{"round", `{"salary":"1.005","employer":"0"}`, 200, "1.01", "", "0.00"},
{"missing", `{}`, 200, "0.00", "", nil},
{"null", `{"name":null,"salary":null,"employer":null,"enabled":null}`, 200, "0.00", "", nil},
{"zero", `{"salary":"0","enabled":false}`, 200, "0.00", "", nil},
{"invalid", `{"salary":"abc"}`, 400, "", "", nil},
{"empty", `{"salary":""}`, 400, "", "", nil},
{"numeric", `{"salary":100}`, 400, "", "", nil},
{"flat", `{"SalaryFields":{"salary":"1"}}`, 400, "", "", nil},
{"validation", `{"name":"` + strings.Repeat("x", 81) + `"}`, 400, "", "", nil},
} {
t.Run(path+tc.name, func(t *testing.T) {
req := httptest.NewRequest("POST", path, strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
app.Engine().ServeHTTP(w, req)
if w.Code != tc.status {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if w.Code == 200 {
var out map[string]any
if e := json.Unmarshal(w.Body.Bytes(), &out); e != nil {
t.Fatal(e)
}
if out["salary"] != tc.salary || out["employeeName"] != tc.nameValue || out["employer"] != tc.employer || out["id"] != "9007199254740993" {
t.Fatal(out)
}
}
})
}
}
}
func TestFullFormAndCopySemantics(t *testing.T) {
name := "employee"
money := amount.Money("2.00")
yes := true
req := types.EnterpriseRequest{Name: &name, Salary: &money, Enabled: &yes, Labels: []string{"one"}}
first, e := convert.Employee(req)
if e != nil {
t.Fatal(e)
}
second, e := convert.Employee(types.EnterpriseRequest{})
if e != nil {
t.Fatal(e)
}
if first.EmployeeName != "employee" || !first.Enabled || second.EmployeeName != "" || second.Enabled || second.Salary.String() != "0.00" || second.Employer != nil {
t.Fatal("full-form update became patch behavior")
}
// Direct assignments retain standard Go aliasing, including scalar pointers.
first.Labels[0] = "shared"
if req.Labels[0] != "shared" {
t.Fatal("unexpected implicit deep copy")
}
req.Employer = &money
first, e = convert.Employee(req)
if e != nil || first.Employer != req.Employer {
t.Fatal("nullable pointer changed", e)
}
bad := "reject"
failed, e := convert.Employee(types.EnterpriseRequest{Name: &bad})
if !errors.Is(e, convert.ErrName) || failed.EmployeeName != "" || !strings.Contains(e.Error(), "EmployeeName") {
t.Fatal("converter cause/zero result lost", e)
}
empty, e := convert.ToBatch(domain.Batch{})
if e != nil || empty.Rows != nil {
t.Fatal("nil slice changed", e)
}
src := domain.Batch{Rows: []domain.Row{}, Fixed: [2]domain.Row{{Value: "a"}, {Value: "b"}}, Optional: &name}
out, e := convert.ToBatch(src)
if e != nil || out.Rows == nil || len(out.Rows) != 0 || out.Fixed[1].Value != "b" || out.Optional != src.Optional {
t.Fatal(out, e)
}
src.Rows = []domain.Row{{Value: "one"}}
out, e = convert.ToBatch(src)
if e != nil || out.Rows[0].Value != "one" {
t.Fatal(out, e)
}
out.Rows[0].Value = "changed"
if src.Rows[0].Value != "one" {
t.Fatal("element mapping reused input slice")
}
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
result, err := convert.Employee(types.EnterpriseRequest{Name: &name, Salary: &money})
if err != nil || result.EmployeeName != name || result.Salary != money {
t.Error(result, err)
}
}()
}
wg.Wait()
}
func TestFactoryLifecycle(t *testing.T) {
for _, mode := range []string{"fail", "nil", "initFail", "panic"} {
t.Run(mode, func(t *testing.T) {
before := svc.Closes.Load()
cfg := config.Default()
cfg.FactoryMode = mode
if mode == "panic" {
func() {
defer func() {
if recover() != middleware.ErrFactory {
t.Error("panic cause changed")
}
}()
_, _ = svc.New(context.Background(), logger(t), cfg)
}()
} else {
deps, err := svc.New(context.Background(), logger(t), cfg)
if deps != nil || err == nil {
t.Fatal("factory did not fail closed")
}
if mode == "fail" && !errors.Is(err, middleware.ErrFactory) {
t.Fatal("factory cause lost", err)
}
if mode == "initFail" && !errors.Is(err, svc.ErrInit) {
t.Fatal("init cause lost", err)
}
}
if svc.Closes.Load() != before+1 {
t.Fatal("dependency not closed exactly once")
}
})
}
deps, e := svc.New(context.Background(), logger(t))
if e != nil {
t.Fatal(e)
}
if e = deps.Access().Check(context.Background()); e != nil {
t.Fatal(e)
}
before := svc.Closes.Load()
if e = deps.Close(); e != nil {
t.Fatal(e)
}
if e = deps.Close(); e != nil {
t.Fatal(e)
}
if svc.Closes.Load() != before+1 {
t.Fatal("normal close repeated")
}
}
func TestGeneratedEntryListenFailure(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
path := filepath.Join(t.TempDir(), "config.yaml")
if err = os.WriteFile(path, []byte("server:\n address: "+listener.Addr().String()+"\n"), 0600); err != nil {
t.Fatal(err)
}
before := svc.Closes.Load()
if err = run(context.Background(), path); err == nil {
t.Fatal("listen should fail")
}
if svc.Closes.Load() != before+1 {
t.Fatal("listen failure did not close dependencies")
}
}

View File

@@ -0,0 +1,21 @@
package convert
import (
"errors"
"strconv"
)
var ErrName = errors.New("rejected name")
// Explicit project rules: full-form omission clears strings/bools and uses zero
// for required money. Nullable employer amounts remain nil by direct assignment.
func Name(value *string) (string, error) {
if value == nil {
return "", nil
}
if *value == "reject" {
return "", ErrName
}
return *value, nil
}
func ID(value int64) string { return strconv.FormatInt(value, 10) }

View File

@@ -0,0 +1,29 @@
package convert
import (
"example.com/composition/internal/domain"
"example.com/composition/internal/types"
"git.yuntuo.hk/k2safe/go-omni/copyx"
)
// The three endpoints reuse one ordinary Go helper and their existing scalar.
// There is no field list or package/type name JSON to keep in sync.
func Employee[S any](source S) (domain.Employee, error) {
var target domain.Employee
err := copyx.Copy(&target, source,
copyx.WithFieldNames[S, domain.Employee](map[string]string{"Name": "EmployeeName"}),
copyx.WithNilPolicy(copyx.NilZero),
copyx.WithConverter(Name),
)
return target, err
}
func Response(source domain.Result) (types.EmployeeResponse, error) {
var target types.EmployeeResponse
err := copyx.Copy(&target, source, copyx.WithConverter(func(v int64) (string, error) { return ID(v), nil }))
return target, err
}
func ToBatch(source domain.Batch) (domain.BatchDTO, error) {
var target domain.BatchDTO
err := copyx.Copy(&target, source)
return target, err
}

View File

@@ -0,0 +1,9 @@
package middleware
import "context"
type AccessChecker interface{ Check(context.Context) error }
type Dependencies interface {
Access() AccessChecker
FactoryMode() string
}

View File

@@ -0,0 +1,29 @@
package domain
import "example.com/composition/internal/amount"
type Employee struct {
EmployeeName string
Salary amount.Money
Employer *amount.Money
Enabled bool
Labels []string
}
type Result struct {
Employee
ID int64
}
// Distinct container item types exercise explicit Go collection copying.
type Row struct{ Value string }
type RowDTO struct{ Value string }
type Batch struct {
Rows []Row
Fixed [2]Row
Optional *string
}
type BatchDTO struct {
Rows []RowDTO
Fixed [2]RowDTO
Optional *string
}

View File

@@ -0,0 +1,63 @@
scalar Money (
goImport: "example.com/composition/internal/amount"
goType: Money
jsonInput: string
jsonOutput: string
)
type SalaryFields {
// Employee display name.
Name *string `json:"name,optional" binding:"max=80"`
Salary *Money `json:"salary,optional"`
Employer *Money `json:"employer,optional"`
Enabled *bool `json:"enabled,optional"`
Labels []string `json:"labels,optional"`
}
type EnterpriseRequest {
SalaryFields
}
type TenantRequest {
SalaryFields
CompanyID string `path:"companyId"`
}
type AdminRequest {
SalaryFields
CompanyID string `path:"companyId"`
}
type EmployeeResponse {
ID string `json:"id"`
EmployeeName string `json:"employeeName"`
Salary Money `json:"salary"`
Employer *Money `json:"employer"`
Enabled bool `json:"enabled"`
Labels []string `json:"labels"`
}
@server (
group: enterprise
middleware: Access
response: raw
)
service composition {
@handler save
post /enterprise (EnterpriseRequest) returns (EmployeeResponse)
}
@server (
group: tenant
middleware: Access
response: raw
)
service composition {
@handler save
post /tenant/:companyId (TenantRequest) returns (EmployeeResponse)
}
@server (
group: admin
middleware: Access
response: raw
)
service composition {
@handler save
post /admin/:companyId (AdminRequest) returns (EmployeeResponse)
}

View File

@@ -16,6 +16,7 @@ import (
const manifestPath = ".goOmni/apiManifest.json"
type manifest struct {
GeneratorConfig bool `json:"generatorConfig,omitempty"`
TemplateVersion string `json:"templateVersion,omitempty"`
Profile *projectProfile `json:"profile,omitempty"`
Version int `json:"version"`
@@ -109,13 +110,24 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
return result, err
}
}
next := manifest{TemplateVersion: minimumVersion, Profile: &profile, Version: 2, Module: module, Entry: entry, Generated: map[string]string{}, Contract: contract}
next := manifest{GeneratorConfig: true, TemplateVersion: minimumVersion, Profile: &profile, Version: 2, Module: module, Entry: entry, Generated: map[string]string{}, Contract: contract}
for name, data := range generated {
next.Generated[name] = digest(data)
}
var changes []change
// Check ALL previous outputs, even stale ones, before writing any new file.
for name, hash := range old.Generated {
// Migration guard only; old mappings are never regenerated or silently
// deleted. Once the project removes them, forget the stale ownership.
if name == "internal/mapping/mappingsGen.go" {
if err = safePath(root, name); err != nil {
return result, err
}
if _, e := os.Lstat(filepath.Join(root, name)); !errors.Is(e, os.ErrNotExist) {
return result, errors.New("mapping generation was removed: migrate callers to copyx or ordinary Go and remove internal/mapping/mappingsGen.go before generating; see COPY.md")
}
continue
}
if !ownedPath(name) {
return result, fmt.Errorf("api: invalid owned path %q", name)
}

View File

@@ -8,7 +8,7 @@ import (
)
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
for _, flag := range [][]string{{"--framework-version", "v0.16.0-preview"}, {"--framework-version=v0.16.0-preview"}} {
for _, flag := range [][]string{{"--framework-version", "v0.19.0-preview"}, {"--framework-version=v0.19.0-preview"}} {
project := filepath.Join(t.TempDir(), "billing")
args := append([]string{"--module", "example.com/billing"}, flag...)
if err := runNew(append(args, project)); err != nil {
@@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "go-omni v0.16.0-preview") || strings.Contains(string(content), "replace ") {
if !strings.Contains(string(content), "go-omni v0.19.0-preview") || strings.Contains(string(content), "replace ") {
t.Fatalf("CLI did not pin remote dependency: %s", content)
}
}

View File

@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Source builds can carry an unpublished VCS version (including +dirty).
// Keep this fallback in sync with the next release; installed CLIs use their
// downloaded module version, which also has a module checksum.
const defaultFrameworkVersion = "v0.16.0-preview"
const defaultFrameworkVersion = "v0.19.0-preview"
// This target is not a claim that the release already exists. Source users
// select a local framework checkout until the module templates are published.

117
copyx/benchmark_test.go Normal file
View File

@@ -0,0 +1,117 @@
package copyx_test
import (
"errors"
"fmt"
"testing"
"git.yuntuo.hk/k2safe/go-omni/copyx"
)
func BenchmarkScenarios(b *testing.B) {
type request struct {
Name string
Count int64
Active bool
}
type result struct {
Label string
Count int64
Active bool
}
source := request{"sample", 9007199254740993, true}
option := copyx.WithFieldNames[request, result](map[string]string{"Name": "Label"})
copier, err := copyx.New(option)
if err != nil {
b.Fatal(err)
}
for _, reuse := range []bool{false, true} {
b.Run(fmt.Sprintf("struct/reuse%t", reuse), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var target result
var err error
if reuse {
err = copier.Copy(&target, source)
} else {
err = copyx.Copy(&target, source, option)
}
if err != nil {
b.Fatal(err)
}
}
})
}
deep, _ := copyx.New(copyx.WithDeepCopy())
b.Run("bytes64KiB", func(b *testing.B) {
src := make([]byte, 64<<10)
b.SetBytes(int64(len(src)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var dst []byte
if err := deep.Copy(&dst, src); err != nil {
b.Fatal(err)
}
}
})
src := map[string][]int{}
for i := 0; i < 100; i++ {
src[fmt.Sprint(i)] = []int{1, 2, 3}
}
b.Run("map100", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var dst map[string][]int
if err := deep.Copy(&dst, src); err != nil {
b.Fatal(err)
}
}
})
cycle := &detail{Values: []int{1, 2, 3}}
cycle.Next = cycle
b.Run("cycle", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var dst *detail
if err := deep.Copy(&dst, cycle); err != nil {
b.Fatal(err)
}
}
})
b.Run("parallelCycle", func(b *testing.B) {
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
var dst *detail
if err := deep.Copy(&dst, cycle); err != nil {
b.Error(err)
return
}
}
})
})
for _, fresh := range []bool{false, true} {
b.Run(fmt.Sprintf("converter/fresh%t", fresh), func(b *testing.B) {
c, err := copyx.New(copyx.WithDeepCopy(), copyx.WithConverter(func(v []int) ([]int, error) {
if fresh {
return append([]int(nil), v...), nil
}
return v, nil
}))
if err != nil {
b.Fatal(err)
}
src := []int{1, 2, 3}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var dst []int
err := c.Copy(&dst, src)
if fresh && err != nil || !fresh && !errors.Is(err, copyx.ErrSharedReference) {
b.Fatal(err)
}
}
})
}
}

537
copyx/copy.go Normal file
View File

@@ -0,0 +1,537 @@
package copyx
import (
"errors"
"fmt"
"reflect"
"runtime"
"sort"
"sync"
)
var (
ErrType = errors.New("copyx: incompatible types")
ErrNil = errors.New("copyx: nil cannot fill a non-nullable target")
ErrField = errors.New("copyx: unmatched field")
ErrUnsupported = errors.New("copyx: unsupported value; provide an explicit data converter")
ErrSharedReference = errors.New("copyx: deep copy retains a mutable source reference")
ErrLimit = errors.New("copyx: traversal limit exceeded")
)
// Error describes a field/index path without recording the source value.
type Error struct {
Path string
Cause error
}
func (e *Error) Error() string { return e.Path + ": " + e.Cause.Error() }
func (e *Error) Unwrap() error { return e.Cause }
func at(path string, err error) error { return &Error{Path: path, Cause: err} }
func Copy(target, source any, options ...Option) error {
c, err := New(options...)
if err != nil {
return err
}
return c.Copy(target, source)
}
// Clone always makes a deep copy. Nil and empty collections stay distinct.
func Clone[T any](source T, options ...Option) (T, error) {
var target T
options = append(append([]Option(nil), options...), WithDeepCopy())
err := Copy(&target, source, options...)
return target, err
}
// Copy stages the complete result and only assigns target on success. User
// converter side effects (including mutation of source) cannot be rolled back.
// Caller must synchronize concurrent access to source and target themselves.
func (c *Copier) Copy(target, source any) error {
// Alias indexes contain addresses only. Keep the original graph alive until
// validation and commit finish so the GC cannot recycle any indexed address.
defer func() { runtime.KeepAlive(source) }()
if c == nil {
return errors.New("copyx: nil Copier")
}
dst := reflect.ValueOf(target)
if !dst.IsValid() || dst.Kind() != reflect.Pointer || dst.IsNil() {
return errors.New("copyx: target must be a non-nil pointer")
}
src := reflect.ValueOf(source)
s := state{options: &c.options, seen: map[visit]reflect.Value{}}
if c.options.deep {
var err error
s.graph, err = sourceGraph(src)
if err != nil {
return err
}
if src.IsValid() && src.Kind() == reflect.Pointer && !src.IsNil() && dst.Elem().Kind() == reflect.Struct {
id := identity(src)
s.valueRoot = &id
}
}
out, err := s.walk(src, dst.Elem(), dst.Elem().Type(), "$", 0)
if err != nil {
return err
}
if s.graph != nil {
if err = s.graph.audit(out); err != nil {
return err
}
}
dst.Elem().Set(out)
return nil
}
type visit struct {
reference
target reflect.Type
}
type state struct {
options *settings
seen map[visit]reflect.Value
graph *graph
nodes int
allocated uint64
valueRoot *reference
}
func (s *state) reserve(count int, size uintptr, path string) error {
if count < 0 || (count > 0 && uint64(size) > maxCopyBytes/uint64(count)) {
return at(path, ErrLimit)
}
s.allocated += uint64(count) * uint64(size)
if s.allocated > maxCopyBytes {
return at(path, ErrLimit)
}
return nil
}
func nullable(k reflect.Kind) bool {
switch k {
case reflect.Pointer, reflect.Map, reflect.Slice, reflect.Interface, reflect.Func, reflect.Chan:
return true
}
return false
}
func baseValue(base reflect.Value, t reflect.Type) reflect.Value {
if base.IsValid() && base.Type() == t {
return base
}
return reflect.Zero(t)
}
func (s *state) walk(src, base reflect.Value, t reflect.Type, path string, depth int) (reflect.Value, error) {
s.nodes++
if depth > maxDepth || s.nodes > maxNodes {
return reflect.Value{}, at(path, ErrLimit)
}
base = baseValue(base, t)
if !shallowSafe(t) {
return reflect.Value{}, at(path, ErrUnsupported)
}
if src.IsValid() && !src.CanInterface() {
return reflect.Value{}, at(path, ErrUnsupported)
}
// Exact converters also receive typed nils and zeros. No method calls,
// Scanner/Valuer invocation or implicit numeric conversion is attempted.
if src.IsValid() {
if convert, ok := s.options.converters[pair{src.Type(), t}]; ok {
out, err := convert(src)
if err != nil {
return reflect.Value{}, at(path, err)
}
if s.graph != nil {
if err = s.graph.audit(out); err != nil {
return reflect.Value{}, at(path, err)
}
}
return out, nil
}
}
if !src.IsValid() || (nullable(src.Kind()) && src.IsNil()) {
if src.IsValid() && !s.nilCompatible(src.Type(), t, map[pair]bool{}) {
return reflect.Value{}, at(path, ErrType)
}
if s.options.nilPolicy == NilSkip {
return base, nil
}
if nullable(t.Kind()) || s.options.nilPolicy == NilZero {
return reflect.Zero(t), nil
}
return reflect.Value{}, at(path, ErrNil)
}
if s.options.skipZero && src.IsZero() {
if !s.nilCompatible(src.Type(), t, map[pair]bool{}) {
return reflect.Value{}, at(path, ErrType)
}
return base, nil
}
if src.Type() == t && len(s.options.converters) == 0 && len(s.options.fields) == 0 && !s.options.skipZero && s.options.nilPolicy != NilSkip {
if !s.options.deep || simpleValue(t) {
return src, nil
}
}
if src.Kind() == reflect.Interface {
return s.walk(src.Elem(), base, t, path, depth+1)
}
if t.Kind() == reflect.Interface {
if !src.Type().Implements(t) {
return reflect.Value{}, at(path, ErrType)
}
out, err := s.walk(src, reflect.Value{}, src.Type(), path, depth+1)
if err != nil {
return reflect.Value{}, err
}
v := reflect.New(t).Elem()
v.Set(out)
return v, nil
}
if s.options.deep && forbidden(t) {
return reflect.Value{}, at(path, ErrUnsupported)
}
if src.Kind() == reflect.Pointer && t.Kind() != reflect.Pointer {
return s.walk(src.Elem(), base, t, path, depth+1)
}
if t.Kind() == reflect.Pointer {
if !s.options.deep && src.Type() == t {
return src, nil
}
if src.Kind() == reflect.Pointer {
key := visit{identity(src), t}
if s.valueRoot != nil && key.reference == *s.valueRoot {
return reflect.Value{}, at(path, fmt.Errorf("%w: cyclic root must retain its pointer; use Clone(source) or Copy(&targetPointer, source)", ErrUnsupported))
}
if prior, ok := s.seen[key]; ok {
return prior, nil
}
}
if err := s.reserve(1, t.Elem().Size(), path); err != nil {
return reflect.Value{}, err
}
out := reflect.New(t.Elem())
if out.Type() != t {
out = out.Convert(t)
}
if src.Kind() == reflect.Pointer {
key := visit{identity(src), t}
s.seen[key] = out
src = src.Elem()
}
var childBase reflect.Value
if !base.IsNil() {
childBase = base.Elem()
}
value, err := s.walk(src, childBase, t.Elem(), path, depth+1)
if err != nil {
return reflect.Value{}, err
}
out.Elem().Set(value)
return out, nil
}
if src.Kind() != t.Kind() {
return reflect.Value{}, at(path, fmt.Errorf("%w: %s -> %s", ErrType, src.Type(), t))
}
if t == timeType && src.Type() == t {
return src, nil
}
switch t.Kind() {
case reflect.Struct:
return s.structure(src, base, t, path, depth)
case reflect.Slice:
if !s.options.deep && src.Type() == t {
return src, nil
}
key := visit{identity(src), t}
if prior, ok := s.seen[key]; ok {
return prior, nil
}
if err := s.reserve(src.Len(), t.Elem().Size(), path); err != nil {
return reflect.Value{}, err
}
out := reflect.MakeSlice(t, src.Len(), src.Len())
s.seen[key] = out
if src.Type() == t && len(s.options.converters) == 0 && len(s.options.fields) == 0 && simpleValue(t.Elem()) {
reflect.Copy(out, src)
return out, nil
}
for i := 0; i < src.Len(); i++ {
v, err := s.walk(src.Index(i), reflect.Value{}, t.Elem(), fmt.Sprintf("%s[%d]", path, i), depth+1)
if err != nil {
return reflect.Value{}, err
}
out.Index(i).Set(v)
}
return out, nil
case reflect.Array:
if src.Len() != t.Len() {
return reflect.Value{}, at(path, ErrType)
}
if err := s.reserve(1, t.Size(), path); err != nil {
return reflect.Value{}, err
}
out := reflect.New(t).Elem()
for i := 0; i < src.Len(); i++ {
v, err := s.walk(src.Index(i), base.Index(i), t.Elem(), fmt.Sprintf("%s[%d]", path, i), depth+1)
if err != nil {
return reflect.Value{}, err
}
out.Index(i).Set(v)
}
return out, nil
case reflect.Map:
if !s.options.deep && src.Type() == t {
return src, nil
}
key := visit{identity(src), t}
if prior, ok := s.seen[key]; ok {
return prior, nil
}
if err := s.reserve(src.Len(), t.Key().Size()+t.Elem().Size()+32, path); err != nil {
return reflect.Value{}, err
}
out := reflect.MakeMapWithSize(t, src.Len())
s.seen[key] = out
it := src.MapRange()
for it.Next() {
k, err := s.walk(it.Key(), reflect.Value{}, t.Key(), path+"[key]", depth+1)
if err != nil {
return reflect.Value{}, err
}
if !k.Comparable() {
return reflect.Value{}, at(path+"[key]", ErrType)
}
if out.MapIndex(k).IsValid() {
return reflect.Value{}, at(path+"[key]", errors.New("copyx: key conversion collision"))
}
v, err := s.walk(it.Value(), reflect.Value{}, t.Elem(), path+"[value]", depth+1)
if err != nil {
return reflect.Value{}, err
}
out.SetMapIndex(k, v)
}
return out, nil
case reflect.Func, reflect.Chan, reflect.UnsafePointer:
if s.options.deep {
return reflect.Value{}, at(path, ErrUnsupported)
}
}
if src.Type() != t {
return reflect.Value{}, at(path, fmt.Errorf("%w: %s -> %s", ErrType, src.Type(), t))
}
return src, nil
}
var fieldTypes sync.Map
func exportedFields(t reflect.Type) map[string]reflect.StructField {
if known, ok := fieldTypes.Load(t); ok {
return known.(map[string]reflect.StructField)
}
out := map[string]reflect.StructField{}
for _, f := range reflect.VisibleFields(t) {
if f.PkgPath != "" {
continue
}
kind := f.Type.Kind()
if kind == reflect.Pointer {
kind = f.Type.Elem().Kind()
}
if f.Anonymous && kind == reflect.Struct {
continue
}
out[f.Name] = f
}
actual, _ := fieldTypes.LoadOrStore(t, out)
return actual.(map[string]reflect.StructField)
}
func (s *state) nilCompatible(from, to reflect.Type, seen map[pair]bool) bool {
key := pair{from, to}
if seen[key] {
return true
}
seen[key] = true
if from == to {
return true
}
if _, ok := s.options.converters[key]; ok {
return true
}
if to.Kind() == reflect.Interface {
return from.Implements(to)
}
if from.Kind() == reflect.Pointer {
return s.nilCompatible(from.Elem(), to, seen)
}
if to.Kind() == reflect.Pointer {
return s.nilCompatible(from, to.Elem(), seen)
}
if from.Kind() != to.Kind() {
return false
}
switch from.Kind() {
case reflect.Struct:
// Nil must not hide a structurally incompatible type pair. There are no
// field values to visit, but coverage and conversion types still apply.
for i := 0; i < to.NumField(); i++ {
if to.Field(i).PkgPath != "" {
return false
}
}
sources, targets := exportedFields(from), exportedFields(to)
rules := s.options.fields[key]
if rules == nil {
rules = &fields{}
}
reverse, used := map[string]string{}, map[string]bool{}
for a, b := range rules.names {
reverse[b] = a
}
for name, target := range targets {
if rules.ignoreTarget[name] {
continue
}
srcName := name
if renamed, ok := reverse[name]; ok {
srcName = renamed
}
source, ok := sources[srcName]
if !ok || rules.ignoreSource[srcName] || !s.nilCompatible(source.Type, target.Type, seen) {
return false
}
used[srcName] = true
}
if s.options.strict {
for name := range sources {
if !used[name] && !rules.ignoreSource[name] {
return false
}
}
}
return true
case reflect.Slice:
return s.nilCompatible(from.Elem(), to.Elem(), seen)
case reflect.Map:
return s.nilCompatible(from.Key(), to.Key(), seen) && s.nilCompatible(from.Elem(), to.Elem(), seen)
case reflect.Array:
return from.Len() == to.Len() && s.nilCompatible(from.Elem(), to.Elem(), seen)
}
return false
}
func fieldValue(v reflect.Value, index []int) (reflect.Value, bool) {
for _, i := range index {
if v.Kind() == reflect.Pointer {
if v.IsNil() {
return reflect.Value{}, false
}
v = v.Elem()
}
v = v.Field(i)
}
return v, true
}
func setField(v reflect.Value, index []int, value reflect.Value) error {
for j, i := range index {
field := v.Field(i)
if !field.CanSet() {
return ErrUnsupported
}
if j == len(index)-1 {
field.Set(value)
return nil
}
if field.Kind() == reflect.Pointer {
// Detach embedded destination pointers before editing promoted fields.
fresh := reflect.New(field.Type().Elem())
if !field.IsNil() {
fresh.Elem().Set(field.Elem())
}
field.Set(fresh)
v = fresh.Elem()
} else {
v = field
}
}
return nil
}
func (s *state) structure(src, base reflect.Value, t reflect.Type, path string, depth int) (reflect.Value, error) {
if err := s.reserve(1, t.Size(), path); err != nil {
return reflect.Value{}, err
}
out := reflect.New(t).Elem()
out.Set(base)
if src.Type() == t {
// Private scalar state may be assigned as part of its whole value; mutable
// private state requires a converter, never unsafe writes or silent loss.
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if f.PkgPath != "" && s.options.deep && privateMutable(f.Type) {
return reflect.Value{}, at(path+"."+f.Name, ErrUnsupported)
}
}
out.Set(src)
} else {
for i := 0; i < t.NumField(); i++ {
if t.Field(i).PkgPath != "" {
return reflect.Value{}, at(path, ErrUnsupported)
}
}
}
from, to := exportedFields(src.Type()), exportedFields(t)
rules := s.options.fields[pair{src.Type(), t}]
if rules == nil {
rules = &fields{map[string]string{}, map[string]bool{}, map[string]bool{}}
}
reverse := map[string]string{}
for a, b := range rules.names {
reverse[b] = a
}
names := make([]string, 0, len(to))
for n := range to {
names = append(names, n)
}
sort.Strings(names)
used := map[string]bool{}
for _, name := range names {
target := to[name]
old, _ := fieldValue(base, target.Index)
if rules.ignoreTarget[name] {
if !old.IsValid() {
old = reflect.Zero(target.Type)
}
if err := setField(out, target.Index, old); err != nil {
return reflect.Value{}, at(path+"."+name, err)
}
continue
}
srcName := name
if renamed, ok := reverse[name]; ok {
srcName = renamed
}
source, ok := from[srcName]
if !ok || rules.ignoreSource[srcName] {
return reflect.Value{}, at(path+"."+name, ErrField)
}
used[srcName] = true
v, ok := fieldValue(src, source.Index)
if !ok {
return reflect.Value{}, at(path+"."+name, ErrNil)
}
value, err := s.walk(v, old, target.Type, path+"."+name, depth+1)
if err != nil {
return reflect.Value{}, err
}
if err = setField(out, target.Index, value); err != nil {
return reflect.Value{}, at(path+"."+name, err)
}
}
if s.options.strict {
for name := range from {
if !used[name] && !rules.ignoreSource[name] {
return reflect.Value{}, at(path+"."+name, ErrField)
}
}
}
return out, nil
}

448
copyx/copy_test.go Normal file
View File

@@ -0,0 +1,448 @@
package copyx_test
import (
"errors"
"fmt"
"reflect"
"strings"
"sync"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/copyx"
)
type detail struct {
Values []int
Index map[string][]int
Next *detail
}
func TestDeepGraph(t *testing.T) {
src := &detail{Values: []int{1, 2}, Index: map[string][]int{"a": {3}}}
src.Next = src
dst, err := copyx.Clone(src)
if err != nil {
t.Fatal(err)
}
if dst == src || dst.Next != dst {
t.Fatal("pointer cycle not preserved")
}
dst.Values[0] = 9
dst.Index["a"][0] = 8
if src.Values[0] != 1 || src.Index["a"][0] != 3 {
t.Fatal("deep alias")
}
m := map[string]any{}
m["self"] = m
n, err := copyx.Clone(m)
if err != nil {
t.Fatal(err)
}
n["self"].(map[string]any)["new"] = 1
if n["new"] != 1 || m["new"] != nil {
t.Fatal("map cycle changed")
}
x := []any{nil}
x[0] = x
y, err := copyx.Clone(x)
if err != nil {
t.Fatal(err)
}
y[0].([]any)[0] = "changed"
if y[0] != "changed" {
t.Fatal("slice cycle changed")
}
if _, ok := x[0].([]any); !ok {
t.Fatal("source slice changed")
}
}
func TestShallowAndNil(t *testing.T) {
src := detail{Values: []int{1}}
var dst detail
if err := copyx.Copy(&dst, src); err != nil {
t.Fatal(err)
}
dst.Values[0] = 2
if src.Values[0] != 2 {
t.Fatal("shallow unexpectedly detached")
}
for _, slice := range [][]int{nil, {}} {
out, err := copyx.Clone(slice)
if err != nil || (out == nil) != (slice == nil) {
t.Fatal(out, err)
}
}
for _, m := range []map[string]int{nil, {}} {
out, err := copyx.Clone(m)
if err != nil || (out == nil) != (m == nil) {
t.Fatal(out, err)
}
}
var p *int
n := 7
if err := copyx.Copy(&n, p); !errors.Is(err, copyx.ErrNil) || n != 7 {
t.Fatal(n, err)
}
if err := copyx.Copy(&n, p, copyx.WithNilPolicy(copyx.NilSkip)); err != nil || n != 7 {
t.Fatal(n, err)
}
if err := copyx.Copy(&n, p, copyx.WithNilPolicy(copyx.NilZero)); err != nil || n != 0 {
t.Fatal(n, err)
}
x := 3
out := &x
if err := copyx.Copy(&out, p); err != nil || out != nil {
t.Fatal(out, err)
}
flag := true
if err := copyx.Copy(&flag, false); err != nil || flag {
t.Fatal(flag, err)
}
flag = true
if err := copyx.Copy(&flag, false, copyx.WithSkipZero()); err != nil || !flag {
t.Fatal(flag, err)
}
}
func TestConverterAliasesRejected(t *testing.T) {
for _, kind := range []string{"same", "nested", "subslice", "capacity", "fieldPointer", "private", "map", "sibling"} {
t.Run(kind, func(t *testing.T) {
src := &detail{Values: []int{1, 2, 3}, Index: map[string][]int{"x": {1}}}
var err error
switch kind {
case "same":
_, err = copyx.Clone(src, copyx.WithConverter(func(v *detail) (*detail, error) { return v, nil }))
case "nested":
_, err = copyx.Clone(src, copyx.WithConverter(func(v *detail) (*detail, error) { return &detail{Values: v.Values}, nil }))
case "subslice":
_, err = copyx.Clone(src.Values, copyx.WithConverter(func(v []int) ([]int, error) { return v[1:], nil }))
case "capacity":
_, err = copyx.Clone(src.Values, copyx.WithConverter(func(v []int) ([]int, error) { return v[:0], nil }))
case "map":
_, err = copyx.Clone(src.Index, copyx.WithConverter(func(v map[string][]int) (map[string][]int, error) { return v, nil }))
case "fieldPointer":
var dst *int
err = copyx.Copy(&dst, src.Values, copyx.WithDeepCopy(), copyx.WithConverter(func(v []int) (*int, error) { return &v[1], nil }))
case "private":
type opaque struct{ data []int }
v := opaque{src.Values}
_, err = copyx.Clone(v, copyx.WithConverter(func(v opaque) (opaque, error) { return v, nil }))
case "sibling":
type object struct{ A, B []int }
v := object{[]int{1}, []int{2}}
_, err = copyx.Clone(v, copyx.WithConverter(func(_ []int) ([]int, error) { return v.B, nil }))
}
if !errors.Is(err, copyx.ErrSharedReference) {
t.Fatalf("got %v", err)
}
})
}
_, err := copyx.Clone(4, copyx.WithConverter(func(v int) (int, error) { return v, nil }))
if err != nil {
t.Fatal("equal primitive rejected", err)
}
_, err = copyx.Clone("same", copyx.WithConverter(func(v string) (string, error) { return v, nil }))
if err != nil {
t.Fatal(err)
}
good, err := copyx.Clone([]int{1}, copyx.WithConverter(func(v []int) ([]int, error) { return append([]int(nil), v...), nil }))
if err != nil || good[0] != 1 {
t.Fatal(good, err)
}
}
func TestPrivateAndUnsupported(t *testing.T) {
type money struct{ cents *int64 }
n := int64(9007199254740991)
src := money{&n}
if _, err := copyx.Clone(src); !errors.Is(err, copyx.ErrUnsupported) {
t.Fatal(err)
}
out, err := copyx.Clone(src, copyx.WithConverter(func(v money) (money, error) { n := *v.cents; return money{&n}, nil }))
if err != nil || *out.cents != n || out.cents == src.cents {
t.Fatal(out, err)
}
tm := time.Now()
cloned, err := copyx.Clone(tm)
if err != nil || !cloned.Equal(tm) {
t.Fatal(cloned, err)
}
for _, v := range []any{make(chan int), func() {}, &sync.Mutex{}, sync.Mutex{}} {
if _, err := copyx.Clone(v); !errors.Is(err, copyx.ErrUnsupported) {
t.Fatalf("%T: %v", v, err)
}
}
}
func TestNilTypeMismatchAndInterfaceConverter(t *testing.T) {
var source *string
target := 7
if err := copyx.Copy(&target, source, copyx.WithNilPolicy(copyx.NilZero)); !errors.Is(err, copyx.ErrType) || target != 7 {
t.Fatal(target, err)
}
var list []int
var targetMap map[string]int
if err := copyx.Copy(&targetMap, list); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
type input struct{ Value any }
type output struct{ Value string }
var out output
err := copyx.Copy(&out, input{}, copyx.WithConverter(func(v any) (string, error) {
if v == nil {
return "nil handled", nil
}
return "value", nil
}))
if err != nil || out.Value != "nil handled" {
t.Fatal(out, err)
}
var lock sync.Mutex
if err := copyx.Copy(&lock, sync.Mutex{}); !errors.Is(err, copyx.ErrUnsupported) {
t.Fatal(err)
}
type locked struct {
mu sync.Mutex
Value int
}
var l locked
if err := copyx.Copy(&l, locked{}); !errors.Is(err, copyx.ErrUnsupported) {
t.Fatal(err)
}
}
func TestAllocationLimitAndPointerRoot(t *testing.T) {
src := make([]byte, (64<<20)+1)
if _, err := copyx.Clone(src); !errors.Is(err, copyx.ErrLimit) {
t.Fatal(err)
}
n := &detail{}
n.Next = n
var value detail
if err := copyx.Copy(&value, n, copyx.WithDeepCopy()); !errors.Is(err, copyx.ErrUnsupported) {
t.Fatal(err)
}
var p *detail
if err := copyx.Copy(&p, n, copyx.WithDeepCopy()); err != nil || p.Next != p || p == n {
t.Fatal(p, err)
}
}
func TestFieldsConvertersAndAtomicity(t *testing.T) {
type input struct {
Name string
Age int
Extra string
}
type output struct {
Label string
Age int
Local string
}
rename := map[string]string{"Name": "Label"}
c, err := copyx.New(copyx.WithFieldNames[input, output](rename), copyx.WithIgnoreTarget[input, output]("Local"))
if err != nil {
t.Fatal(err)
}
rename["Name"] = "Missing"
dst := output{Local: "preserved"}
if err = c.Copy(&dst, input{Name: "hello", Age: 2}); err != nil || dst.Label != "hello" || dst.Local != "preserved" {
t.Fatal(dst, err)
}
if err = copyx.Copy(&dst, input{}, copyx.WithStrictFields(), copyx.WithFieldNames[input, output](map[string]string{"Name": "Label"}), copyx.WithIgnoreTarget[input, output]("Local")); !errors.Is(err, copyx.ErrField) {
t.Fatal(err)
}
if err = copyx.Copy(&dst, input{}, copyx.WithStrictFields(), copyx.WithFieldNames[input, output](map[string]string{"Name": "Label"}), copyx.WithIgnoreTarget[input, output]("Local"), copyx.WithIgnoreSource[input, output]("Extra")); err != nil {
t.Fatal(err)
}
if err = copyx.Copy(&dst, input{}); !errors.Is(err, copyx.ErrField) {
t.Fatal(err)
}
if _, err = copyx.New(copyx.WithFieldNames[input, output](map[string]string{"Nope": "Age"})); err == nil {
t.Fatal("invalid rule accepted")
}
if _, err = copyx.New(copyx.WithConverter(func(v int) (string, error) { return "", nil }), copyx.WithConverter(func(v int) (string, error) { return "", nil })); err == nil {
t.Fatal("duplicate converter accepted")
}
sentinel := errors.New("conversion failed")
type node struct {
A []int
Z string
}
old := node{[]int{8}, "old"}
before := old.A
err = copyx.Copy(&old, node{[]int{1}, "bad"}, copyx.WithDeepCopy(), copyx.WithConverter(func(v string) (string, error) { return "", sentinel }))
if !errors.Is(err, sentinel) || !strings.Contains(err.Error(), "Z") || old.Z != "old" || old.A[0] != 8 || &old.A[0] != &before[0] {
t.Fatal(old, err)
}
// Exact typed nil converter must get control before the global nil policy.
var ptr *int
var value int
err = copyx.Copy(&value, ptr, copyx.WithConverter(func(v *int) (int, error) {
if v == nil {
return 0, sentinel
}
return *v, nil
}))
if !errors.Is(err, sentinel) {
t.Fatal(err)
}
var small int8 = 3
if err = copyx.Copy(&small, int64(300)); !errors.Is(err, copyx.ErrType) || small != 3 {
t.Fatal(small, err)
}
}
type Embedded struct {
A int
Z string
}
func TestEmbeddedDestinationDetached(t *testing.T) {
type target struct{ *Embedded }
type source struct {
A int
Z int
}
ptr := &Embedded{5, "keep"}
dst := target{ptr}
if err := copyx.Copy(&dst, source{6, 7}); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
if ptr.A != 5 || dst.Embedded != ptr {
t.Fatal("failure mutated embedded target")
}
if err := copyx.Copy(&dst, Embedded{6, "new"}); err != nil {
t.Fatal(err)
}
if ptr.A != 5 || dst.A != 6 {
t.Fatal("success mutated old embedded object")
}
}
func TestConcurrentCopier(t *testing.T) {
c, err := copyx.New(copyx.WithDeepCopy())
if err != nil {
t.Fatal(err)
}
src := &detail{Values: []int{7}, Index: map[string][]int{"a": {9}}}
var wg sync.WaitGroup
for i := 0; i < 64; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for n := 0; n < 20; n++ {
var dst *detail
if err := c.Copy(&dst, src); err != nil {
t.Error(err)
return
}
dst.Values[0] = n
if src.Values[0] != 7 {
t.Error("shared")
}
}
}()
}
wg.Wait()
}
func TestBoundAndCollections(t *testing.T) {
src := &detail{}
p := src
for i := 0; i < 300; i++ {
p.Next = &detail{}
p = p.Next
}
if _, err := copyx.Clone(src); !errors.Is(err, copyx.ErrLimit) {
t.Fatal(err)
}
type from struct{ Value string }
type to struct{ Value string }
var out []to
if err := copyx.Copy(&out, []from{{"a"}, {"b"}}); err != nil || len(out) != 2 || out[1].Value != "b" {
t.Fatal(out, err)
}
var array [2]to
if err := copyx.Copy(&array, [2]from{{"a"}, {"b"}}); err != nil {
t.Fatal(err)
}
if err := copyx.Copy(&array, [1]from{{"a"}}); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
var outMap map[string]int
err := copyx.Copy(&outMap, map[int]int{1: 1, 2: 2}, copyx.WithConverter(func(int) (string, error) { return "same", nil }))
if err == nil || outMap != nil {
t.Fatal(outMap, err)
}
}
func FuzzClone(f *testing.F) {
f.Add([]byte{1, 2, 3})
f.Add([]byte{})
f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > 1024 {
data = data[:1024]
}
src := map[string]any{"bytes": data, "other": []string{"x"}}
out, err := copyx.Clone(src)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(src, out) {
t.Fatal("changed value")
}
if len(data) > 0 {
out["bytes"].([]byte)[0] ^= 255
if data[0] == out["bytes"].([]byte)[0] {
t.Fatal("alias")
}
}
})
}
func TestTraversalNodeLimit(t *testing.T) {
source := make([]any, 200001)
if _, err := copyx.Clone(source); !errors.Is(err, copyx.ErrLimit) {
t.Fatal("expected bounded traversal", err)
}
}
func TestNilCannotHideFieldMismatch(t *testing.T) {
type source struct{ Value string }
type target struct{ Value int }
var src *source
var dst *target
for _, policy := range []copyx.NilPolicy{copyx.NilError, copyx.NilZero, copyx.NilSkip} {
if err := copyx.Copy(&dst, src, copyx.WithNilPolicy(policy)); !errors.Is(err, copyx.ErrType) {
t.Fatal("nil concealed incompatible fields", err)
}
}
if err := copyx.Copy(&dst, src, copyx.WithConverter(func(string) (int, error) { return 0, nil })); err != nil {
t.Fatal(err)
}
text := "keep"
if err := copyx.Copy(&text, 0, copyx.WithSkipZero()); !errors.Is(err, copyx.ErrType) || text != "keep" {
t.Fatal("zero concealed incompatible types", text, err)
}
}
func BenchmarkCopy(b *testing.B) {
for _, n := range []int{1, 100, 1000} {
for _, deep := range []bool{false, true} {
b.Run(fmt.Sprintf("fields%d/deep%t", n, deep), func(b *testing.B) {
src := detail{Values: make([]int, n), Index: map[string][]int{"one": {1, 2}}}
var opts []copyx.Option
if deep {
opts = append(opts, copyx.WithDeepCopy())
}
c, _ := copyx.New(opts...)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var dst detail
if err := c.Copy(&dst, src); err != nil {
b.Fatal(err)
}
}
})
}
}
}

270
copyx/graph.go Normal file
View File

@@ -0,0 +1,270 @@
package copyx
import (
"fmt"
"reflect"
"sort"
"sync"
"time"
)
const maxDepth = 256
const maxNodes = 200000
const maxCopyBytes = 64 << 20
var timeType = reflect.TypeFor[time.Time]()
// Immutable type metadata is shared, never values or converter registrations.
var valueTypes sync.Map
func simpleValue(t reflect.Type) bool {
if known, ok := valueTypes.Load(t); ok {
return known.(bool)
}
result := t == timeType
if !result {
switch t.Kind() {
case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr,
reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128, reflect.String:
result = true
case reflect.Array:
result = simpleValue(t.Elem())
case reflect.Struct:
result = !forbidden(t)
for i := 0; result && i < t.NumField(); i++ {
result = simpleValue(t.Field(i).Type)
}
}
}
valueTypes.Store(t, result)
return result
}
type reference struct {
kind reflect.Kind
typ reflect.Type
ptr uintptr
length, capacity int
}
func identity(v reflect.Value) reference {
r := reference{kind: v.Kind(), typ: v.Type(), ptr: uintptr(v.UnsafePointer())}
if v.Kind() == reflect.Slice {
r.length = v.Len()
r.capacity = v.Cap()
}
return r
}
type span struct{ start, end uintptr }
type graph struct {
maps map[uintptr]bool
spans []span
maxEnd []uintptr
}
// References are inspected without reading private values through Interface or
// using unsafe writes. Slice capacity matters: returning source[:0] is an alias.
func scanGraph(v reflect.Value, visit func(reflect.Value, string) error) error {
seen := map[reference]bool{}
nodes := 0
var walk func(reflect.Value, string, int) error
walk = func(v reflect.Value, path string, depth int) error {
nodes++
if depth > maxDepth || nodes > maxNodes {
return at(path, ErrLimit)
}
if !v.IsValid() {
return nil
}
if err := visit(v, path); err != nil {
return err
}
if simpleValue(v.Type()) {
return nil
} // time.Time is a documented immutable value.
switch v.Kind() {
case reflect.Interface:
if !v.IsNil() {
return walk(v.Elem(), path, depth+1)
}
case reflect.Pointer, reflect.Map, reflect.Slice:
if v.IsNil() {
return nil
}
id := identity(v)
if seen[id] {
return nil
}
seen[id] = true
switch v.Kind() {
case reflect.Pointer:
return walk(v.Elem(), path, depth+1)
case reflect.Map:
it := v.MapRange()
for it.Next() {
if err := walk(it.Key(), path+"[key]", depth+1); err != nil {
return err
}
if err := walk(it.Value(), path+"[value]", depth+1); err != nil {
return err
}
}
case reflect.Slice:
if simpleValue(v.Type().Elem()) {
return nil
}
all := v.Slice(0, v.Cap())
for i := 0; i < all.Len(); i++ {
if err := walk(all.Index(i), fmt.Sprintf("%s[%d]", path, i), depth+1); err != nil {
return err
}
}
}
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
if err := walk(v.Field(i), path+"."+v.Type().Field(i).Name, depth+1); err != nil {
return err
}
}
case reflect.Array:
for i := 0; i < v.Len(); i++ {
if err := walk(v.Index(i), fmt.Sprintf("%s[%d]", path, i), depth+1); err != nil {
return err
}
}
}
return nil
}
return walk(v, "$", 0)
}
func mutableSpan(v reflect.Value) (span, bool) {
var n uintptr
switch v.Kind() {
case reflect.Pointer:
if v.IsNil() {
return span{}, false
}
n = v.Type().Elem().Size()
case reflect.Slice:
if v.IsNil() {
return span{}, false
}
n = uintptr(v.Cap()) * v.Type().Elem().Size()
default:
return span{}, false
}
if n == 0 {
return span{}, false
}
start := uintptr(v.UnsafePointer())
return span{start, start + n}, true
}
func sourceGraph(v reflect.Value) (*graph, error) {
g := &graph{maps: map[uintptr]bool{}}
err := scanGraph(v, func(v reflect.Value, _ string) error {
if r, ok := mutableSpan(v); ok {
g.spans = append(g.spans, r)
}
if v.Kind() == reflect.Map && !v.IsNil() {
g.maps[uintptr(v.UnsafePointer())] = true
}
return nil
})
sort.Slice(g.spans, func(i, j int) bool { return g.spans[i].start < g.spans[j].start })
var end uintptr
for _, r := range g.spans {
if r.end > end {
end = r.end
}
g.maxEnd = append(g.maxEnd, end)
}
return g, err
}
func (g *graph) audit(v reflect.Value) error {
return scanGraph(v, func(v reflect.Value, path string) error {
if forbidden(v.Type()) {
return at(path, ErrUnsupported)
}
switch v.Kind() {
case reflect.Func, reflect.Chan:
if !v.IsNil() {
return at(path, ErrUnsupported)
}
case reflect.UnsafePointer:
if v.Pointer() != 0 {
return at(path, ErrUnsupported)
}
}
if v.Kind() == reflect.Map && !v.IsNil() && g.maps[uintptr(v.UnsafePointer())] {
return at(path, ErrSharedReference)
}
if r, ok := mutableSpan(v); ok {
i := sort.Search(len(g.spans), func(i int) bool { return g.spans[i].start >= r.end })
if i > 0 && g.maxEnd[i-1] > r.start {
return at(path, ErrSharedReference)
}
}
return nil
})
}
func forbidden(t reflect.Type) bool {
// Lock/resource implementations are not data. A custom converter may turn
// them into a different data type but may not manufacture cloned handles.
switch t.PkgPath() {
case "sync", "sync/atomic", "os", "net", "database/sql":
return t.Kind() == reflect.Struct
}
return false
}
// Shallow copies may share handles through pointers, but must not copy lock or
// resource state by value, including state inside a containing struct/array.
var safeValueTypes sync.Map
func shallowSafe(t reflect.Type) bool {
if known, ok := safeValueTypes.Load(t); ok {
return known.(bool)
}
result := true
if forbidden(t) {
safeValueTypes.Store(t, false)
return false
}
switch t.Kind() {
case reflect.Struct:
for i := 0; i < t.NumField(); i++ {
if !shallowSafe(t.Field(i).Type) {
result = false
break
}
}
case reflect.Array:
result = shallowSafe(t.Elem())
}
safeValueTypes.Store(t, result)
return result
}
func privateMutable(t reflect.Type) bool {
if t == timeType {
return false
}
switch t.Kind() {
case reflect.Pointer, reflect.Map, reflect.Slice, reflect.Interface, reflect.Func, reflect.Chan, reflect.UnsafePointer:
return true
case reflect.Array:
return privateMutable(t.Elem())
case reflect.Struct:
if forbidden(t) {
return true
}
for i := 0; i < t.NumField(); i++ {
if privateMutable(t.Field(i).Type) {
return true
}
}
}
return false
}

169
copyx/options.go Normal file
View File

@@ -0,0 +1,169 @@
// Package copyx copies Go values without JSON, generated code or global registration.
package copyx
import (
"errors"
"fmt"
"reflect"
)
// NilPolicy controls nil input. NilError preserves nil for nullable targets and
// rejects nil to non-nullable targets. NilSkip retains the existing target.
type NilPolicy uint8
const (
NilError NilPolicy = iota
NilZero
NilSkip
)
type pair struct{ from, to reflect.Type }
type converter func(reflect.Value) (reflect.Value, error)
type fields struct {
names map[string]string
ignoreSource, ignoreTarget map[string]bool
}
type settings struct {
deep, skipZero, strict bool
nilPolicy NilPolicy
converters map[pair]converter
fields map[pair]*fields
}
// Option configures one immutable Copier. Options never change global behavior.
type Option func(*settings) error
func WithDeepCopy() Option { return func(s *settings) error { s.deep = true; return nil } }
// WithSkipZero explicitly skips Go zero values, including false, 0 and "".
func WithSkipZero() Option { return func(s *settings) error { s.skipZero = true; return nil } }
// WithStrictFields additionally rejects unconsumed exported source fields.
// Target fields must have a source by default, unless explicitly ignored.
func WithStrictFields() Option { return func(s *settings) error { s.strict = true; return nil } }
func WithNilPolicy(p NilPolicy) Option {
return func(s *settings) error {
if p > NilSkip {
return errors.New("copyx: invalid nil policy")
}
s.nilPolicy = p
return nil
}
}
// WithConverter registers an exact source/target type pair. Typed nils are
// delivered to converters before default nil handling. The function must not
// mutate its input and must be safe for concurrent calls when Copier is shared.
func WithConverter[S, T any](fn func(S) (T, error)) Option {
return func(s *settings) error {
if fn == nil {
return errors.New("copyx: nil converter")
}
key := pair{reflect.TypeFor[S](), reflect.TypeFor[T]()}
if _, exists := s.converters[key]; exists {
return fmt.Errorf("copyx: duplicate converter %s -> %s", key.from, key.to)
}
s.converters[key] = func(v reflect.Value) (reflect.Value, error) {
var input S
reflect.ValueOf(&input).Elem().Set(v)
out, err := fn(input)
return reflect.ValueOf(&out).Elem(), err
}
return nil
}
}
// WithFieldNames declares source -> target Go field names for one struct pair.
// It is not a JSON tag mapping. Only actual, unambiguous exported fields qualify.
func WithFieldNames[S, T any](names map[string]string) Option {
copyNames := make(map[string]string, len(names))
for k, v := range names {
copyNames[k] = v
}
return fieldOption[S, T](func(f *fields) error {
for from, to := range copyNames {
if _, exists := f.names[from]; exists {
return fmt.Errorf("copyx: duplicate field rule %s", from)
}
f.names[from] = to
}
return nil
})
}
func WithIgnoreSource[S, T any](names ...string) Option {
names = append([]string(nil), names...)
return fieldOption[S, T](func(f *fields) error {
for _, n := range names {
f.ignoreSource[n] = true
}
return nil
})
}
// WithIgnoreTarget preserves the corresponding existing target fields in Copy.
// Deep mode rejects that preservation if it would retain a source reference.
func WithIgnoreTarget[S, T any](names ...string) Option {
names = append([]string(nil), names...)
return fieldOption[S, T](func(f *fields) error {
for _, n := range names {
f.ignoreTarget[n] = true
}
return nil
})
}
func fieldOption[S, T any](apply func(*fields) error) Option {
return func(s *settings) error {
key := pair{reflect.TypeFor[S](), reflect.TypeFor[T]()}
if key.from.Kind() != reflect.Struct || key.to.Kind() != reflect.Struct {
return errors.New("copyx: field options require struct types")
}
f := s.fields[key]
if f == nil {
f = &fields{map[string]string{}, map[string]bool{}, map[string]bool{}}
s.fields[key] = f
}
return apply(f)
}
}
// Copier is immutable after construction. Each Copy owns its traversal state.
type Copier struct{ options settings }
func New(options ...Option) (*Copier, error) {
s := settings{converters: map[pair]converter{}, fields: map[pair]*fields{}}
for _, option := range options {
if option == nil {
return nil, errors.New("copyx: nil option")
}
if err := option(&s); err != nil {
return nil, err
}
}
for key, f := range s.fields {
from, to := exportedFields(key.from), exportedFields(key.to)
used := map[string]bool{}
for src, dst := range f.names {
if _, ok := from[src]; !ok {
return nil, fmt.Errorf("copyx: unknown source field %s.%s", key.from, src)
}
if _, ok := to[dst]; !ok {
return nil, fmt.Errorf("copyx: unknown target field %s.%s", key.to, dst)
}
if used[dst] || f.ignoreSource[src] || f.ignoreTarget[dst] {
return nil, fmt.Errorf("copyx: conflicting field rule %s", dst)
}
used[dst] = true
}
for n := range f.ignoreSource {
if _, ok := from[n]; !ok {
return nil, fmt.Errorf("copyx: unknown source field %s", n)
}
}
for n := range f.ignoreTarget {
if _, ok := to[n]; !ok {
return nil, fmt.Errorf("copyx: unknown target field %s", n)
}
}
}
return &Copier{options: s}, nil
}

View File

@@ -0,0 +1,23 @@
# 结构复制示例
在框架仓库根目录运行 `go run ./examples/copying`;不需要数据库、网络服务或 JSON 映射配置。
示例包含:可复用构造器、Go 字段改名、时间自定义转换、保留 false、深拷贝切片、同类型 Clone,以及拒绝自定义转换器返回输入的原切片。
预期输出:
```text
name=sample enabled=false time=2026-01-01T00:00:00Z sourceTag=original
clone detached: true
shared converter rejected: true
```
测试:`go test -race ./examples/copying`。完整策略、限制与旧生成映射迁移见 [COPY.md](../../COPY.md)。
独立工资三端的实际生成、HTTP 与金额用例见 [集成测试](../../cmd/go-omni/apigen/copyConsumer_test.go) 和 [公共契约](../../cmd/go-omni/apigen/testdata/composition/service.api)。框架根目录运行:
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test ./cmd/go-omni/apigen -run TestCopyxThreeEndpointConsumer -count=1
```
该测试在临时独立项目中执行,三个入口共用一个普通 Go copyx helper。公共字段新增时,只修改公共契约和独立业务结构,三个入口的转换规则不增加。

61
examples/copying/main.go Normal file
View File

@@ -0,0 +1,61 @@
// copying demonstrates optional Go-to-Go conversion and checked deep copying.
package main
import (
"errors"
"fmt"
"log"
"time"
"git.yuntuo.hk/k2safe/go-omni/copyx"
)
type Input struct {
Name string
Enabled bool
Tags []string
CreatedAt time.Time
}
type View struct {
DisplayName string
Enabled bool
Tags []string
CreatedAt string
}
func run() error {
converter, err := copyx.New(
copyx.WithDeepCopy(),
copyx.WithFieldNames[Input, View](map[string]string{"Name": "DisplayName"}),
copyx.WithConverter(func(v time.Time) (string, error) { return v.UTC().Format(time.RFC3339), nil }),
)
if err != nil {
return err
}
source := Input{Name: "sample", Enabled: false, Tags: []string{"original"}, CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
var target View
if err = converter.Copy(&target, source); err != nil {
return err
}
target.Tags[0] = "changed"
fmt.Printf("name=%s enabled=%t time=%s sourceTag=%s\n", target.DisplayName, target.Enabled, target.CreatedAt, source.Tags[0])
cloned, err := copyx.Clone(source)
if err != nil {
return err
}
cloned.Tags[0] = "separate"
fmt.Println("clone detached:", source.Tags[0] == "original")
_, err = copyx.Clone(source.Tags, copyx.WithConverter(func(v []string) ([]string, error) { return v, nil }))
fmt.Println("shared converter rejected:", errors.Is(err, copyx.ErrSharedReference))
if !errors.Is(err, copyx.ErrSharedReference) {
return errors.New("expected alias rejection")
}
return nil
}
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,9 @@
package main
import "testing"
func TestExample(t *testing.T) {
if err := run(); err != nil {
t.Fatal(err)
}
}

View File

@@ -1,5 +1,6 @@
{
"templateVersion": "v0.16.0-preview",
"generatorConfig": true,
"templateVersion": "v0.19.0-preview",
"profile": {
"database": "none",
"storage": "none",

View File

@@ -2,7 +2,7 @@ module example.com/managed
go 1.22.0
require git.yuntuo.hk/k2safe/go-omni v0.16.0-preview
require git.yuntuo.hk/k2safe/go-omni v0.19.0-preview
require (
github.com/gabriel-vasile/mimetype v1.4.3 // indirect

View File

@@ -0,0 +1,4 @@
{
"repository": "none",
"middleware": "handlers"
}

View File

@@ -32,3 +32,9 @@ python3 scripts/verifyManagedRelease.py --version v0.16.0-preview --report repor
```
共享 Catalog 提供方是清单显式指定的可信代码。框架隔离自己的文件操作,但不能保证用户提供方自身没有副作用;提供方应保持纯粹。该版本仍为 preview,未承诺支持完整 Go/goctl 类型语法或替业务完成数据库、权限、事务迁移。
## 远程固定版本验收
[远程报告](contractFollowupRemote.json) 已通过:空 module 缓存安装 v0.16.0-preview CLI,消费者也固定同版,没有本地 replace。包含六组真实生成消费者的 race 测试,以及通过远程 CLI 导出一次 Go Catalog、两个独立服务引用、文档筛选、只读 check、过期拒绝与刷新后编译。
耗时 37.1 秒,框架校验值 `h1:9JGAuXRXLb9rc1FR9jc0OBxRvj2abmuOecvFpKMcBnc=`。发布标签固定在 `64e3bd6cfced225476d58e95037249cd0a4fe695`;远程验收报告后续单独提交,不移动标签。

View File

@@ -0,0 +1,92 @@
{
"passed": true,
"frameworkVersion": "v0.16.0-preview",
"frameworkSum": "h1:9JGAuXRXLb9rc1FR9jc0OBxRvj2abmuOecvFpKMcBnc=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 37.1,
"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"
],
"sourceHashes": {
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
"lifecycle/phases.go": "1436856c708d004ebb28704a867316bf7a44553de56b3e29152d41475a2f793a",
"config/lifecycle_test.go": "8e5fccf0f7432c64d7fa8a52687002307d3ebb1fd039adb6ae09da0c9a2891bf",
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
"cmd/go-omni/apigen/templates.go": "6a98c26329d4c54d97abc170a324016931baac0e21028f649f1660347f5dd971",
"config/project.go": "0917888f4e471761b897ddd59426eb098bf586edb5c5dc856320b84b3bdb5f7f",
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
"contract/policy.go": "73a3a55fb88195b32f22b2bc066d9b13dccd7fd520e98c5a1b2979a3fd2447eb",
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
"cmd/go-omni/apigen/business_test.go": "c0af26cd270f1c971dc5886316b144b776fdb886e930f883f0ee0c94b0909226",
"cmd/go-omni/apigen/generate.go": "9d81a3a4cd7905bd1829626e960512d86ac628d981be47c1d0cacc0648c0f33a",
"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": "583d4b50d50d38602f75d274ec0fec675fd04e754f8f6571bec0e52183374e32",
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
"cmd/go-omni/apigen/managed_test.go": "8b1b86d38151e78a1a18d8ecf001b9635d32c37276abaf2c9e28af10aa983e9d",
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "4d826d7133027ac2399f8209f03efa46ed54aa45dcd0a35274a9009314941dca",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "9e103c9de7a1a906903bc09787e1b38905dc5bb4d891c88938028da5dee98f6f",
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1",
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c",
"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"
}
}

View File

@@ -0,0 +1,70 @@
# v0.19 copyx 与映射移除验收
日期:2026-09-25。环境:Apple M2 Max、darwin/arm64、Go 1.27.1,基准默认 GOMAXPROCS=12。最低支持版本另用 Go 1.22.12 验证 copyx 与可运行示例。
## 范围与行为
本轮新增可选 Go 工具包并删除原映射配置/生成机制,没有修改接入项目代码、数据库或线上服务。没有新增第三方依赖,没有内置工资、金额或权限规则;工资只是生成器的独立通用验收 fixture。
| 场景 | 验证内容 |
| --- | --- |
| 浅/深复制 | 值、结构、指针、数组、map、slice、interface;浅共享与深隔离分别验证 |
| 空值与精度 | nil 与空集合、默认保留 false/0、显式 nil/skip 策略;不兼容类型不能借 nil/零值绕过检查 |
| 可变引用 | 原指针/map、子切片、零长度但有容量的视图、源元素指针、私有子引用、其他字段的引用均拒绝 |
| 自定义函数 | 字段改名、精确类型对、金额私有状态复制、nil interface;重复/冲突配置拒绝 |
| 失败语义 | errors.Is 保留 cause、目标不提交部分结果、嵌入目标指针不提前修改 |
| 对象图 | 指针/map/slice 循环,重复引用;循环指针根压成值结构拒绝 |
| 并发与资源 | 同一 Copier 64 goroutine 各复制 20 次,race;深度/节点/估算分配超限拒绝;锁/资源/函数等明确限制 |
| 真正生成的消费者 | 三端工资公共字段,Money scalar,大数精度/舍入、非法金额、缺失/null/零值、完整表单、错误码与错误链 |
| 维护成本 | 公共字段新增:改一处契约和一处独立业务结构;三个接口共用 helper,不增加拷贝规则 |
| 生成与生命周期 | 重复 generate/check/dry-run 保留业务文件;工厂异常、初始化/监听失败及资源关闭由既有回归验证 |
| 移除与 workspace | 旧配置/旧生成文件阻断且不写入,明确迁移后所有权清理;workspace 失败不部分更新服务 |
FuzzClone 使用动态 JSON 容器、随机 bytes 和嵌套 slice,验证值一致与引用隔离。输入超过 1 KiB 时截取继续验证,运行 30 秒、4 workers:**4,237,917 次执行,未发现失败**。这不等于所有形状都已 fuzz;循环、类型转换及资源拒绝另外用定向用例覆盖。
全量 46 个测试包通过 race 回归(含实际生成项目),vet/build、Go 1.22.12、示例运行与生成漂移检查均通过。
## 可复现命令
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -count=1 ./...
go vet ./...
go build ./...
GOTOOLCHAIN=go1.22.12 go test ./copyx ./examples/copying
go test ./copyx -run '^$' -fuzz FuzzClone -fuzztime 30s -parallel 4
go test ./copyx -run '^$' -bench . -benchmem -benchtime 300ms -count 3
go run ./examples/copying
go run ./cmd/go-omni api generate --dir examples/managedService --check
# examples/managedService 中另执行 go test -race ./... 与 go build .
```
全量回归包含真实临时生成项目,集成测试的子进程使用 go test -race / go build,并不只比较生成字符串。基准测量时没有同时运行本轮 fuzz 或全量测试;原始三轮结果见 [copyxBenchmark.txt](copyxBenchmark.txt)。
## 性能与分配
下表取三轮中位数。构造器复用与一次性调用都包含真实复制;`fields1000` 指整数 slice 的长度,不是 1,000 个结构字段。所有深拷贝保留引用检查。
| 场景 | 中位耗时 | B/op | allocs/op |
| --- | ---: | ---: | ---: |
| 相同结构浅拷贝(含引用字段) | 85.8 ns | 96 | 2 |
| 3 字段跨结构改名,复用 Copier | 697.5 ns | 168 | 7 |
| 相同跨结构,每次构建 Copier | 1,181 ns | 1,120 | 17 |
| 嵌套结构 + 1 个整数 + map,深拷贝 | 2,758 ns | 1,368 | 42 |
| 嵌套结构 + 100 个整数 + map,深拷贝 | 2,787 ns | 2,256 | 42 |
| 嵌套结构 + 1,000 个整数 + map,深拷贝 | 3,806 ns | 9,552 | 42 |
| 64 KiB byte slice 深拷贝 | 5,780 ns | 65,768 | 9 |
| 100 项 map,每项含整数 slice | 88,859 ns | 87,704 | 1,554 |
| 自引用循环对象深拷贝 | 2,128 ns | 640 | 26 |
| converter 新建 slice 并通过检查 | 539.7 ns | 280 | 10 |
| converter 返回原 slice 被拒绝 | 383.8 ns | 320 | 11 |
并行循环对象基准中位 515.2 ns/op 是多核聚合吞吐均摊,**不是单请求延迟**。复杂 map 的多次图扫描、字段路径和反射分配明显更贵;不据此宣称运行时工具快于手写赋值。热点复用 Copier;大对象和高频映射应按实际业务结构衡量是否改用直接 Go。
优化只包括不可变类型元数据缓存、纯值快速路径、纯值 slice 批量复制;没有去掉深拷贝别名检查。内置估算分配上限 64 MiB 不是硬堆内存隔离,converter 的外部分配及副作用不受该预算控制。
## 边界
- 新方式在运行时做类型与覆盖检查,不再有旧映射生成时的静态保证;字段变更要运行实际类型对测试。
- converter 是受信项目代码,必须只读输入;不能自动回滚其副作用、证明其未共享外部全局对象,或强杀无限循环。
- 当前记录是本机库/生成器验收,不是线上服务吞吐、Linux soak、真实业务数据库回归或 OSS/R2 云账号验收。本轮没有对线上服务器施压。
- 旧消费者仍需按 [COPY.md](../COPY.md#从旧-mapping-迁移) 迁移,不会被这次框架发布自动修改。

View File

@@ -0,0 +1,48 @@
goos: darwin
goarch: arm64
pkg: git.yuntuo.hk/k2safe/go-omni/copyx
cpu: Apple M2 Max
BenchmarkScenarios/struct/reusefalse-12 251924 1209 ns/op 1120 B/op 17 allocs/op
BenchmarkScenarios/struct/reusefalse-12 316450 1181 ns/op 1120 B/op 17 allocs/op
BenchmarkScenarios/struct/reusefalse-12 314466 1140 ns/op 1120 B/op 17 allocs/op
BenchmarkScenarios/struct/reusetrue-12 517544 703.0 ns/op 168 B/op 7 allocs/op
BenchmarkScenarios/struct/reusetrue-12 517842 691.9 ns/op 168 B/op 7 allocs/op
BenchmarkScenarios/struct/reusetrue-12 522170 697.5 ns/op 168 B/op 7 allocs/op
BenchmarkScenarios/bytes64KiB-12 67933 5857 ns/op 11189.69 MB/s 65768 B/op 9 allocs/op
BenchmarkScenarios/bytes64KiB-12 61680 5780 ns/op 11337.56 MB/s 65768 B/op 9 allocs/op
BenchmarkScenarios/bytes64KiB-12 61843 5771 ns/op 11355.66 MB/s 65768 B/op 9 allocs/op
BenchmarkScenarios/map100-12 3808 88598 ns/op 87704 B/op 1554 allocs/op
BenchmarkScenarios/map100-12 4136 88859 ns/op 87704 B/op 1554 allocs/op
BenchmarkScenarios/map100-12 4051 89614 ns/op 87704 B/op 1554 allocs/op
BenchmarkScenarios/cycle-12 168308 2128 ns/op 640 B/op 26 allocs/op
BenchmarkScenarios/cycle-12 169057 2163 ns/op 640 B/op 26 allocs/op
BenchmarkScenarios/cycle-12 169892 2120 ns/op 640 B/op 26 allocs/op
BenchmarkScenarios/parallelCycle-12 963274 660.6 ns/op 640 B/op 26 allocs/op
BenchmarkScenarios/parallelCycle-12 582906 515.1 ns/op 640 B/op 26 allocs/op
BenchmarkScenarios/parallelCycle-12 748388 515.2 ns/op 640 B/op 26 allocs/op
BenchmarkScenarios/converter/freshfalse-12 828752 382.8 ns/op 320 B/op 11 allocs/op
BenchmarkScenarios/converter/freshfalse-12 897363 383.8 ns/op 320 B/op 11 allocs/op
BenchmarkScenarios/converter/freshfalse-12 1000000 387.5 ns/op 320 B/op 11 allocs/op
BenchmarkScenarios/converter/freshtrue-12 665454 541.2 ns/op 280 B/op 10 allocs/op
BenchmarkScenarios/converter/freshtrue-12 647089 535.0 ns/op 280 B/op 10 allocs/op
BenchmarkScenarios/converter/freshtrue-12 704719 539.7 ns/op 280 B/op 10 allocs/op
BenchmarkCopy/fields1/deepfalse-12 4058166 87.96 ns/op 96 B/op 2 allocs/op
BenchmarkCopy/fields1/deepfalse-12 4137807 85.79 ns/op 96 B/op 2 allocs/op
BenchmarkCopy/fields1/deepfalse-12 4201804 85.07 ns/op 96 B/op 2 allocs/op
BenchmarkCopy/fields1/deeptrue-12 131413 2739 ns/op 1368 B/op 42 allocs/op
BenchmarkCopy/fields1/deeptrue-12 131658 2771 ns/op 1368 B/op 42 allocs/op
BenchmarkCopy/fields1/deeptrue-12 131880 2758 ns/op 1368 B/op 42 allocs/op
BenchmarkCopy/fields100/deepfalse-12 4209133 85.61 ns/op 96 B/op 2 allocs/op
BenchmarkCopy/fields100/deepfalse-12 4239586 84.30 ns/op 96 B/op 2 allocs/op
BenchmarkCopy/fields100/deepfalse-12 4248972 84.59 ns/op 96 B/op 2 allocs/op
BenchmarkCopy/fields100/deeptrue-12 127617 2787 ns/op 2256 B/op 42 allocs/op
BenchmarkCopy/fields100/deeptrue-12 129816 2826 ns/op 2256 B/op 42 allocs/op
BenchmarkCopy/fields100/deeptrue-12 124041 2780 ns/op 2256 B/op 42 allocs/op
BenchmarkCopy/fields1000/deepfalse-12 4214600 85.59 ns/op 96 B/op 2 allocs/op
BenchmarkCopy/fields1000/deepfalse-12 4250719 85.20 ns/op 96 B/op 2 allocs/op
BenchmarkCopy/fields1000/deepfalse-12 4247498 85.05 ns/op 96 B/op 2 allocs/op
BenchmarkCopy/fields1000/deeptrue-12 92329 3818 ns/op 9552 B/op 42 allocs/op
BenchmarkCopy/fields1000/deeptrue-12 93945 3806 ns/op 9552 B/op 42 allocs/op
BenchmarkCopy/fields1000/deeptrue-12 96010 3782 ns/op 9552 B/op 42 allocs/op
PASS
ok git.yuntuo.hk/k2safe/go-omni/copyx 17.513s

15
reports/copyxFuzz.txt Normal file
View File

@@ -0,0 +1,15 @@
fuzz: elapsed: 0s, gathering baseline coverage: 0/10 completed
fuzz: elapsed: 0s, gathering baseline coverage: 10/10 completed, now fuzzing with 4 workers
fuzz: elapsed: 3s, execs: 426851 (142240/sec), new interesting: 5 (total: 15)
fuzz: elapsed: 6s, execs: 837791 (137019/sec), new interesting: 5 (total: 15)
fuzz: elapsed: 9s, execs: 1281087 (147767/sec), new interesting: 5 (total: 15)
fuzz: elapsed: 12s, execs: 1705531 (141463/sec), new interesting: 5 (total: 15)
fuzz: elapsed: 15s, execs: 2125995 (140169/sec), new interesting: 6 (total: 16)
fuzz: elapsed: 18s, execs: 2547285 (140432/sec), new interesting: 6 (total: 16)
fuzz: elapsed: 21s, execs: 2954940 (135854/sec), new interesting: 6 (total: 16)
fuzz: elapsed: 24s, execs: 3389762 (144941/sec), new interesting: 6 (total: 16)
fuzz: elapsed: 27s, execs: 3808273 (139491/sec), new interesting: 6 (total: 16)
fuzz: elapsed: 30s, execs: 4237917 (143257/sec), new interesting: 6 (total: 16)
fuzz: elapsed: 30s, execs: 4237917 (0/sec), new interesting: 6 (total: 16)
PASS
ok git.yuntuo.hk/k2safe/go-omni/copyx 30.363s

View File

@@ -0,0 +1,59 @@
ok git.yuntuo.hk/k2safe/go-omni 1.519s
ok git.yuntuo.hk/k2safe/go-omni/app 3.503s
ok git.yuntuo.hk/k2safe/go-omni/audit 1.970s
ok git.yuntuo.hk/k2safe/go-omni/auth 2.265s
ok git.yuntuo.hk/k2safe/go-omni/cache 2.566s
ok git.yuntuo.hk/k2safe/go-omni/cache/redis 2.712s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 5.321s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 76.921s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 47.757s
ok git.yuntuo.hk/k2safe/go-omni/config 4.374s
ok git.yuntuo.hk/k2safe/go-omni/contract 4.702s
ok git.yuntuo.hk/k2safe/go-omni/copyx 5.171s
ok git.yuntuo.hk/k2safe/go-omni/database 4.141s
ok git.yuntuo.hk/k2safe/go-omni/database/gormx 3.916s
ok git.yuntuo.hk/k2safe/go-omni/database/migrate 3.856s
ok git.yuntuo.hk/k2safe/go-omni/database/mysql 3.816s
ok git.yuntuo.hk/k2safe/go-omni/errors 3.930s
? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files]
? git.yuntuo.hk/k2safe/go-omni/examples/compatibility [no test files]
? git.yuntuo.hk/k2safe/go-omni/examples/compatibility/ids [no test files]
ok git.yuntuo.hk/k2safe/go-omni/examples/copying 3.198s
ok git.yuntuo.hk/k2safe/go-omni/examples/modules 2.930s
ok git.yuntuo.hk/k2safe/go-omni/examples/platform 2.766s
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 2.794s
? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files]
? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files]
ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository 2.880s
? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files]
ok git.yuntuo.hk/k2safe/go-omni/health 3.091s
ok git.yuntuo.hk/k2safe/go-omni/httpc 3.345s
ok git.yuntuo.hk/k2safe/go-omni/httpx 3.870s
ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck 3.284s
? git.yuntuo.hk/k2safe/go-omni/internal/storagefixture [no test files]
? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files]
ok git.yuntuo.hk/k2safe/go-omni/lifecycle 3.390s
ok git.yuntuo.hk/k2safe/go-omni/logx 3.459s
ok git.yuntuo.hk/k2safe/go-omni/metrics 2.732s
ok git.yuntuo.hk/k2safe/go-omni/middleware 2.718s
ok git.yuntuo.hk/k2safe/go-omni/middleware/redis 3.086s
ok git.yuntuo.hk/k2safe/go-omni/openapi 3.319s
ok git.yuntuo.hk/k2safe/go-omni/pagination 3.356s
? git.yuntuo.hk/k2safe/go-omni/queue [no test files]
ok git.yuntuo.hk/k2safe/go-omni/queue/redis 3.060s
ok git.yuntuo.hk/k2safe/go-omni/resilience 2.898s
ok git.yuntuo.hk/k2safe/go-omni/response 3.157s
ok git.yuntuo.hk/k2safe/go-omni/storage 3.414s
ok git.yuntuo.hk/k2safe/go-omni/storage/local 3.617s
ok git.yuntuo.hk/k2safe/go-omni/storage/oss 3.572s
ok git.yuntuo.hk/k2safe/go-omni/storage/s3 3.752s
? git.yuntuo.hk/k2safe/go-omni/storage/storagetest [no test files]
ok git.yuntuo.hk/k2safe/go-omni/task 3.094s
ok git.yuntuo.hk/k2safe/go-omni/telemetry 3.089s
ok git.yuntuo.hk/k2safe/go-omni/transport 3.311s
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 14.938s
? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files]
ok git.yuntuo.hk/k2safe/go-omni/transport/native 4.422s
ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 3.401s
? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files]
? git.yuntuo.hk/k2safe/go-omni/validate [no test files]

View File

@@ -0,0 +1,44 @@
# 通用生成体验验收(v0.17.0-preview)
本轮完成公共字段组合、可选结构映射、带依赖的中间件工厂与 Repository 骨架开关。没有添加项目特定金额、授权或数据库规则。
## 实际生成项目
`TestCompositionMappingConsumer` 从 `.api` 生成独立模块,安装项目自己的 Money scalar、业务类型、转换函数、依赖与中间件,然后生成企业/租户/管理三端的请求和响应映射。在生成模块内执行 `go test -race ./...`、`go build .`,用真实生成 Handler 处理 HTTP 请求。
验证:
- 公共字段生成平铺 DTO;字段注释、长度校验、绑定和 OpenAPI 同步;未知组合、同名字段/来源冲突、循环和展开上限均拒绝。
- 三端正确处理正常输入、缺失、null、零值、非法金额、空金额、数值金额拒绝和完整表单更新。项目示例的精确十进制保留 `90071992547409.91`,`1.005` 按项目规则得到 `1.01`;可空单位金额保持 nil,ID 字符串保持大整数精度。
- 同名同类型直接赋值;重命名、指针取值、金额默认值和 ID 字符串由显式转换函数控制;Go 值嵌入按明确路径映射。
- slice/等长 array 的显式逐项映射、nil 与非 nil 空 slice、普通引用赋值、错误链、零结果与 32 个并发调用通过 race 检查。
- 新增源/目标字段未处理、错误转换签名、指针差异未声明、遗漏企业范围字段处理、无效忽略规则、数组长度不匹配均在写入前失败;Go 文件、模块文件和 manifest 快照不变。未知/循环映射、冲突转换声明、不可访问 internal 包等在类型加载前拒绝。
- 重复生成字节一致、业务文件保留,`--check` 只读。macOS `/var` 别名统一到真实路径,新增 DTO 字段不会被旧 Go 导出数据掩盖。
- 依赖先初始化,再调用工厂自动注册。工厂失败、返回 nil、panic、依赖初始化失败和监听失败都会正确释放已登记资源,真实错误链保留;正常重复 Close 不重复释放资源。
- Repository 默认关闭,group 模式补齐骨架,切回 none 保留已有业务实现。旧项目默认保持 handlers 模式;新配置建立后若丢失,明确报错,防止静默切换生成策略。
## 回归结果
[回归记录](generationExperienceRegression.json):全仓库 **44 个有测试的包**通过 race,发布版本的 go vet、go build 通过。记录包含命令、日志摘要和变更 Go 源码 SHA256。
[实际进程验收](generationExperienceLocal.json):根目录 go run/go build、重复生成、生成检查、业务保留、nethttp/native 的 SIGINT/SIGTERM、监听端口释放、下载/原始回调/multipart、错误目录与默认 404/405 通过,耗时 4.99 秒;临时进程和目录均由脚本清理。
现有 `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
```
验收临时目录和服务进程已清理。远程报告以独立提交补充,发布标签不移动。
## 使用边界
映射是显式启用的 CLI 能力,使用 Go 编译器类型信息,不执行项目初始化或转换函数;运行时只有生成的赋值/调用代码。转换函数包应独立于生成输出包,类型依赖需提前解析。普通指针输入中已经合并的缺失/null 不会被重新推断;普通指针/map/slice 赋值保持 Go 共享引用语义。完整使用与迁移见 [生成能力指南](../GENERATION.md)。

View File

@@ -0,0 +1,59 @@
{
"passed": true,
"mode": "localSource",
"durationSeconds": 4.99,
"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": "5a25134afc402c62ca6afe04320b5ef14141a5ff4caedb9a5eeca4952b302f03",
"cmd/go-omni/apigen/templates.go": "e3177491934aef8cec783bed4cbeb2cfa3cb9b0815c9f7f9c63b85270bb07d24",
"cmd/go-omni/apigen/startup.go": "e3322fa3e2aff9f45d66427f44187535c7c0960edc0f5fdfd5d7b4943b349334",
"cmd/go-omni/apigen/managed_test.go": "52c8ab8a0919c6503cb8835eb258f91f237204c9fc13d9dc735bb913944168b0",
"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"
}
}

View File

@@ -0,0 +1,63 @@
{
"passed": true,
"status": "releaseCandidateValidated",
"testedPackages": 44,
"commands": [
"GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s",
"go vet ./...",
"go build ./...",
"python3 scripts/verifyManagedProject.py --report reports/generationExperienceLocal.json",
"go run ./cmd/go-omni api generate --dir examples/managedService --check",
"cd examples/managedService && go test ./...",
"git diff --check"
],
"goVersion": "go version go1.27.1 darwin/arm64",
"logs": {
"fullRace": {
"path": "/tmp/go-omni-v017-race.log",
"sha256": "8c7a1c60dc33d11105eac09181888e2f53af1e78027cc8c4b25bc14f766855d4"
},
"vet": {
"path": "/tmp/go-omni-v017-vet.log",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"build": {
"path": "/tmp/go-omni-v017-build.log",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"process": {
"path": "/tmp/go-omni-v017-process.log",
"sha256": "748bf31b88405f7f1d550b1d74bbbc5543ea09c5d09b59ec98da6ad040694b12"
}
},
"sourceHashes": {
"cmd/go-omni/apigen/apigen_test.go": "cc94763c3d55b7cb1025f2d0ac7946be42f7f274234437ead8a29cf1a98f2021",
"cmd/go-omni/apigen/business_test.go": "3b484352282d945f5a58047682079e3d64d4b0bf6ec6bafc23151b1197bb3942",
"cmd/go-omni/apigen/compile.go": "442d1b8c8e9c16e23dc4125c49141e15391f34ff726543d860af849f8c3e1bab",
"cmd/go-omni/apigen/composition.go": "7ed3761b27d9c932d8ddc43e63c2aca798bdbe38f2b40571af8916c8401ea650",
"cmd/go-omni/apigen/composition_test.go": "ea3a907dfb48c12d54fda79ee8a0d0fa9c17f17db9f1b46b344e9b62fcc9c94f",
"cmd/go-omni/apigen/generate.go": "5a25134afc402c62ca6afe04320b5ef14141a5ff4caedb9a5eeca4952b302f03",
"cmd/go-omni/apigen/generatorConfig.go": "467112eeb5180fc1571e811e38e90e6dee3f128fe39e17bb978591cee0fa1b22",
"cmd/go-omni/apigen/managed_test.go": "52c8ab8a0919c6503cb8835eb258f91f237204c9fc13d9dc735bb913944168b0",
"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/metadata_test.go": "00086cdf08296ced7ebe2d377bf0dbe3be833bb7e3bfa37216a94adbda50ef31",
"cmd/go-omni/apigen/middlewareFactory.go": "cc0220edb3eff25d7f66b2c37e206455f8730a2b909613713322bee7caf72303",
"cmd/go-omni/apigen/parse.go": "b3f25a260b976dbe0f82e029e5d76ecf35beb9da1e06b0b1235ff5d8a211f015",
"cmd/go-omni/apigen/response_test.go": "789c8e009108e2b7d9d787771bead423b50b80f486c41894c91222097fadf3ec",
"cmd/go-omni/apigen/templates.go": "e3177491934aef8cec783bed4cbeb2cfa3cb9b0815c9f7f9c63b85270bb07d24",
"cmd/go-omni/apigen/testdata/composition/accessFactory.go": "e1e5057ff79f806b3218e93249e8191d097b5c4844977204a8c32848443cba33",
"cmd/go-omni/apigen/testdata/composition/amount.go": "f2d1834502d71d9ecf72ebe3b1077e5ef41f44fd816abd4617ac19f9bb962fdd",
"cmd/go-omni/apigen/testdata/composition/client.go": "19102e191c0471cae9e7405623906d0d804865c40b80ff1d5b240a41ceab6a4e",
"cmd/go-omni/apigen/testdata/composition/consumer_test.go": "9021c47c8af7d0c338c005d9f0a1f97ecdaaffc0b17b01c09d58023cc8c69fd7",
"cmd/go-omni/apigen/testdata/composition/converters.go": "3ded306c68182d0c2184e44eecb6307c366f0ae43a4d23a6a3ef70fc14f45833",
"cmd/go-omni/apigen/testdata/composition/dependencies.go": "edc153204b06da00c0e45c52db43a4a22f8ffe22c4a6d3f3ebec0137b861f84d",
"cmd/go-omni/apigen/testdata/composition/domain.go": "19bc978d173081f18efda643ffad3510287c03b4e47436c7019ea91973f32b72",
"cmd/go-omni/apigen/write.go": "eab29555906c1d6675ffdf07a30f00536065ec8d406cc1a4e53a05d8db720ff8",
"cmd/go-omni/main_test.go": "6f3dd191f94094bb2406b4c25cba1b6b6effd871984a4f75a4fdfa1b8dccbc9e",
"cmd/go-omni/scaffold/version.go": "1b93322fe3156a325ffec8ff33e0c5b054c90625618a52485628691ff2c40ad5",
"scripts/verifyManagedRelease.py": "3a736a308d9dbe3a51555e2864dbcb39dc38cd01bd4e103095f4305a41bde958"
},
"frameworkVersion": "v0.17.0-preview"
}

View 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"
}

View File

@@ -0,0 +1,52 @@
# 可空映射与配置拆分验收(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 业务代码。
## 远程发布验收
`v0.18.0-preview` 标签固定在 `4563a6354fac19f22cd8d407fd76d98b8cc9c406`,main 与标签已推送。空模块缓存从远程安装 CLI,所有实际生成消费者固定同版框架且没有本地 replace;校验和为 `h1:VV1T2i+TG5SmXfAO8ii3y0QunNXbKA8x8YR319CWsHg=`。
[远程报告](nullableMappingRemote.json):53 项检查通过,耗时 51.27 秒。8 组生成消费者通过 race,包括可空映射、配置拆分、显式 nil 策略、指针辅助、错误链、检查/预览只读、workspace 再生成及旧能力回归。配置拆分验收的 generate、check、dry-run 和 workspace 操作均使用远程安装的 CLI。临时目录与服务进程已清理。
复验命令:
```sh
python3 scripts/verifyManagedRelease.py --version v0.18.0-preview --report /tmp/goOmniRemote.json
```
验收报告以独立提交补充,发布标签保持不变。

View 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"
}
}

View 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"
}

View File

@@ -0,0 +1,128 @@
{
"passed": true,
"frameworkVersion": "v0.18.0-preview",
"frameworkSum": "h1:VV1T2i+TG5SmXfAO8ii3y0QunNXbKA8x8YR319CWsHg=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 51.27,
"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",
"nullableNestedMappings",
"splitMappingFiles",
"explicitNilPolicies",
"pointerResultHelpers",
"mappingWorkspaceRegeneration",
"nestedErrorChainAndPrivateCause",
"mappingPreviewNoWrites"
],
"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": "a977803a6f8f4c64a30bc8156decfb072301f4084a5cf8821f4fe98da80cd7af",
"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": "28d883e933a131e8df8e798b00e4b4a6af097a9b880ef1aa7b753456a90c6137",
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
"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": "6eaa70821312ce670e5f8067f99f5133d1789e8558140203520230179da083d0",
"cmd/go-omni/apigen/mapping.go": "fb4f4bf39137921c3fc8f793c636c8c29f68e962060a56c82b10869d6a200243",
"cmd/go-omni/apigen/mappingLoad.go": "b301bd49e4b67160ba70be4163c8ab1f3620f5963ec26f4b28af5df185a75170",
"cmd/go-omni/apigen/mapping_test.go": "94a7927e2237f60c157a7dfc4cc793e17537559ed65c1468d3b8b8b8e3f39394",
"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",
"cmd/go-omni/apigen/mappingPointer.go": "5b4aab84c2ea9371d2a295f8d8cc9a7f82a88b412daa07970c9ac9ea32e504e6",
"cmd/go-omni/apigen/mappingNullable_test.go": "cc22182e5a68138b97817f7dda7a8a5a10ca3aab6c690f8926b6a6cc2ba2a8cb",
"cmd/go-omni/apigen/generatorConfig_test.go": "f22a55dfa5c8188cf7bce56a2b53a778cbe3feb581cd551f16ebfc6a10932e21",
"cmd/go-omni/apigen/testdata/nullable/domain.go": "cb31f4939efbc9ddbb9b68a9e0c72302b93c2c365b1023f65bf7079862936a76",
"cmd/go-omni/apigen/testdata/nullable/convert.go": "4465a51d0eb7efde66eca08ab7e0e8fd6565e04a8447c2d6d8c65cea47c5a87f",
"cmd/go-omni/apigen/testdata/nullable/service.api": "6a5e76d1b56efc5e89d89579710b06465a7c18accbd08ed58d88ccb9c59828d9",
"cmd/go-omni/apigen/testdata/nullable/consumer_test.go": "5b6353c09fe61c39602071cc7072b6d640cf1a6cab1ae55f9490199ca62102b2"
},
"releaseCommit": "4563a6354fac19f22cd8d407fd76d98b8cc9c406"
}

View File

@@ -0,0 +1,14 @@
{
"passed": true,
"status": "localSourceUnreleased",
"durationSeconds": 2.8,
"checks": [
"originalNullablePointerRepro",
"actualCLIGenerate",
"actualCLICheck",
"consumerCompile",
"consumerBuild",
"originalProjectUnchanged",
"temporaryResourcesRemoved"
]
}

View File

@@ -124,22 +124,30 @@ def main():
checks += ["sharedGoCatalogExport", "documentOnlyErrorSelection", "workspaceCheckNoWrites", "staleCatalogRejected", "twoIndependentServiceBuilds"]
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version)
tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer"]
tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer", "TestCompositionMappingConsumer", "TestNullableMappingConsumer"]
print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True)
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", "./cmd/go-omni/apigen", "-run", "^(" + "|".join(tests) + ")$"], ROOT, env)
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e]
passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e and "/" not in e["Test"]]
assert sorted(passed) == sorted(tests), output
evidence = "".join(e.get("Output", "") for e in events)
sums = re.findall(r"checksum (h1:\S+); no local replace", evidence)
assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch"
checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "primitiveAndDynamicArrays", "strictRequestPolicies", "declarativeMultipartBinding", "backgroundStopDrainResourceOrder", "backgroundInitAndListenFailureCleanup", "backgroundTimeoutRetainsResourcesAndExits"]
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 += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/managed").glob("*.go")]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/business").glob("*.go")]
sources += ["cmd/go-omni/apigen/responseShape.go", "cmd/go-omni/apigen/documentRefs.go", "cmd/go-omni/apigen/catalogDocument.go", "cmd/go-omni/apigen/workspaceCatalog.go", "config/env.go", "cmd/go-omni/apigen/arrayResponse_test.go"]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/arrays").glob("*.go")]
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 += ["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"],
"freshModuleCache": True, "noLocalReplace": True,
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,