Compare commits
8 Commits
v0.14.1-pr
...
v0.18.0-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 4563a6354f | |||
| 5fc82509a1 | |||
| 8b588a24cd | |||
| 8493f9dae9 | |||
| 64e3bd6cfc | |||
| 1f2d8b6a6a | |||
| 16deead70e | |||
| adcb6f60b6 |
127
DEVELOPMENT.md
127
DEVELOPMENT.md
@@ -1,7 +1,9 @@
|
|||||||
# 通用业务开发指南(v0.14.0-preview)
|
# 通用业务开发指南(v0.18.0-preview)
|
||||||
|
|
||||||
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
||||||
|
|
||||||
|
本版新增的公共字段组合、可选结构映射、自动中间件工厂和 Repository 开关,见 [生成能力指南](GENERATION.md)。
|
||||||
|
|
||||||
## 返回数组、可空对象和 null
|
## 返回数组、可空对象和 null
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -21,12 +23,17 @@ delete /item
|
|||||||
| 契约 | Logic 返回 | 成功数据 |
|
| 契约 | Logic 返回 | 成功数据 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `returns (Item)` | `(*types.Item, error)` | 对象;nil 被视为实现错误 |
|
| `returns (Item)` | `(*types.Item, error)` | 对象;nil 被视为实现错误 |
|
||||||
| `returns ([]Item)` | `([]types.Item, error)` | 数组;nil 统一输出 `[]` |
|
| `returns ([]Item)` | `([]types.Item, error)` | 对象数组;nil 统一输出 `[]` |
|
||||||
|
| `returns ([]string)` | `([]string, error)` | 字符串数组 |
|
||||||
|
| `returns ([]int64)` / `([]bool)` | `([]int64, error)` / `([]bool, error)` | 数值 / 布尔数组 |
|
||||||
|
| `returns ([]any)` | `([]any, error)` | 动态 JSON 数组,元素允许对象、数组、标量和 null |
|
||||||
| `returns (*Item)` | `(*types.Item, error)` | 对象或 `null` |
|
| `returns (*Item)` | `(*types.Item, error)` | 对象或 `null` |
|
||||||
| `returns (null)` | `error` | HTTP 200 和空业务数据 |
|
| `returns (null)` | `error` | HTTP 200 和空业务数据 |
|
||||||
| 不写 returns | `error` | HTTP 204,无响应体 |
|
| 不写 returns | `error` | HTTP 204,无响应体 |
|
||||||
|
|
||||||
统一响应中的空数据是否显示,仍遵循项目模板的 `dataMode`;需要 `data:null` 就设置 `dataMode: always`。`@response raw` 输出业务数据本身,包括 `[]`、对象或 `null`。可用 `@status 201/202`;204 不能声明响应类型。根类型当前支持以上四种形式,嵌套字段继续支持既有 slice/map/pointer/scalar/any。
|
统一响应中的空数据是否显示,仍遵循项目模板的 `dataMode`;需要 `data:null` 就设置 `dataMode: always`。`@response raw` 输出业务数据本身,包括 `[]`、对象或 `null`。可用 `@status 201/202`;204 不能声明响应类型。嵌套字段继续支持既有 slice/map/pointer/scalar/any。
|
||||||
|
|
||||||
|
基础类型数组还支持 int/int8/int16/int32、uint/uint16/uint32/uint64、float32/float64。nil 根数组都输出 `[]`;`[]any` 在 OpenAPI 中是 `type: array, items: {}`,不限制元素形状,但元素必须可被标准 JSON 编码器序列化。`[]uint8`(Go 的 byte slice)会编码为 base64,故明确拒绝;数字数组可用 `[]int32`,二进制用下载接口。当前不支持根嵌套数组或根自定义 scalar 数组。
|
||||||
|
|
||||||
## 契约驱动的请求策略
|
## 契约驱动的请求策略
|
||||||
|
|
||||||
@@ -113,6 +120,23 @@ duration 使用 `250ms`、`3s`;slice/map 使用 JSON,如 `[1,3,5]`、`{"retr
|
|||||||
|
|
||||||
手写项目可使用 `config.LoadProject(path, defaults, config.EnvOptions{Prefix:"MY_APP_"})`,或分别调用 `ApplyDefaults/DecodeFile/OverlayEnv`。读取失败不修改输入默认值;诊断不会打印输入值。不要主动打印含密钥的整个配置,或把底层解析错误展开到公开响应。Validate 不打开日志文件、数据库或存储连接;证书的可读性和握手信任在依赖初始化阶段验证。
|
手写项目可使用 `config.LoadProject(path, defaults, config.EnvOptions{Prefix:"MY_APP_"})`,或分别调用 `ApplyDefaults/DecodeFile/OverlayEnv`。读取失败不修改输入默认值;诊断不会打印输入值。不要主动打印含密钥的整个配置,或把底层解析错误展开到公开响应。Validate 不打开日志文件、数据库或存储连接;证书的可读性和握手信任在依赖初始化阶段验证。
|
||||||
|
|
||||||
|
### 一次覆盖完整配置与 HTTP/日志短名称
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg, err = config.OverlayEnv(cfg, config.EnvOptions{
|
||||||
|
Prefix: "MY_APP_",
|
||||||
|
Empty: "reject", // allow / ignore / reject,项目自行选择
|
||||||
|
Aliases: config.ServerEnvAliases("MY_APP_", "server"),
|
||||||
|
// Lookup 可注入测试或项目配置源;默认 os.LookupEnv。
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
不要再紧接着调用 `ApplyEnv(cfg.Server, ...)`。上面的调用同时处理完整配置和 HTTP_ADDR、SERVICE、LOG_LEVEL 等框架短名称。例如 `MY_APP_SERVER_ADDRESS` 与 `MY_APP_HTTP_ADDR` 指向同一字段,**完整名称优先**;未设置时才读取短名称。完整名称显式设空仍优先:allow 交给该字段解析(string 可清空;数字等仍要求合法值),ignore 保留文件/默认值,reject 报错。不会因 ignore 再退回别名。字段 `env` 标签的 empty 规则优先于全局规则。
|
||||||
|
|
||||||
|
自定义别名用 `EnvOptions.Aliases`,键是完整环境名、值是按优先级排列的别名数组。两种名称都使用同一 Lookup、空值策略和类型转换;布尔 false、数值 0 与未设置不同。集合始终使用 JSON,包括 `MY_APP_TRUSTED_PROXIES='["127.0.0.1"]'`。
|
||||||
|
|
||||||
|
新项目模板一次调用 OverlayEnv,默认 `Empty: "ignore"`。旧 `ApplyEnv` 保留原有忽略空值、修剪空白及 TRUSTED_PROXIES 逗号列表行为。已有项目的 `internal/config` 属于开发者文件,升级不覆盖它;按上例合并原来的两次覆盖调用后,保留项目自己的 Validate。日志输出的显式完整名称也优先于短名称。
|
||||||
|
|
||||||
## 依赖、日志与中间件
|
## 依赖、日志与中间件
|
||||||
|
|
||||||
`internal/svc/runtimeGen.go` 持续由生成器维护。项目的 ServiceContext 嵌入 `*lifecycle.Runtime`,只在 `Init(ctx)` 中装配额外依赖:
|
`internal/svc/runtimeGen.go` 持续由生成器维护。项目的 ServiceContext 嵌入 `*lifecycle.Runtime`,只在 `Init(ctx)` 中装配额外依赖:
|
||||||
@@ -123,40 +147,15 @@ func (c *ServiceContext) Init(ctx context.Context) error {
|
|||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
c.Client = client
|
c.Client = client
|
||||||
c.OnClose(client.Close)
|
c.OnClose(client.Close)
|
||||||
c.Middlewares["RequireAccess"] = middleware.NewRequireAccess(client)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
每个资源初始化成功就登记清理;后续初始化失败自动逆序回滚。正常退出先排空 HTTP 请求,再逆序关闭依赖,最后关闭日志。关闭幂等,单个清理函数 panic/返回错误不会跳过其他清理;初始化 panic 清理后继续传播。项目清理函数仍须自行停止、等待后台任务并保证能返回,框架不能强制杀死任意 goroutine。
|
每个资源初始化成功就登记清理;后续初始化失败自动逆序回滚。正常退出先排空 HTTP 请求,再执行后台组件的 Stop/Drain 阶段,确认收尾后逆序关闭依赖,最后关闭日志。关闭幂等,单个清理函数 panic/返回错误不会跳过其他清理;初始化 panic 清理后继续传播。后台组件按 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭) 登记;无法确认收尾完成时保留共享依赖并异常退出,不能把取消等同于任务已经结束。
|
||||||
|
|
||||||
中间件用业务所需的小接口接收依赖,避免反向导入 svc:
|
新项目使用中间件工厂:项目维护 `middleware.Dependencies` 小接口与 `New<Name>(deps) (httpx.Middleware, error)`,svc 提供对应访问器。生成的 runtime 在 Init 成功后自动调用工厂并注册,不再按名字替换 map;工厂失败会释放已经登记的依赖。示例与现有项目迁移步骤见 [中间件工厂](GENERATION.md#中间件工厂自动注册)。
|
||||||
|
|
||||||
```go
|
v0.16 项目及 `omni.gen.json` 中选择 `middleware: handlers` 的项目继续使用原有 `Defaults()`,可以在 Init 设置 `c.Middlewares["RequireAccess"]`。更新时不会重写这部分业务代码;只有显式切换到 factories 后才使用新流程。
|
||||||
// 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 里。
|
|
||||||
|
|
||||||
生成入口为每个 App 注入独立 Logger,不修改进程默认 Logger。业务可用 `c.Logger.InfoContext(...)`,或请求链中的 `logx.InfoContext(ctx,...)`。标准库/第三方需要 `*log.Logger` 时使用 `c.Logger.Standard(slog.LevelWarn)`;适配器借用原 Logger,不负责关闭。手写多 App 用 `omni.WithLogInstance(logger)` 显式注入。
|
生成入口为每个 App 注入独立 Logger,不修改进程默认 Logger。业务可用 `c.Logger.InfoContext(...)`,或请求链中的 `logx.InfoContext(ctx,...)`。标准库/第三方需要 `*log.Logger` 时使用 `c.Logger.Standard(slog.LevelWarn)`;适配器借用原 Logger,不负责关闭。手写多 App 用 `omni.WithLogInstance(logger)` 显式注入。
|
||||||
|
|
||||||
@@ -185,21 +184,9 @@ go-omni api generate --check
|
|||||||
|
|
||||||
格式化保留注释、token 和多行字面量,规范四空格缩进、空行、行首尾空白及相邻单行字段的类型/标签/行尾注释对齐;结果幂等。先校验完整契约,check 不写文件;导入的共享目录不被某个服务的格式化命令修改。DTO 按源 `.api` 输出 `dto<Source><摘要>Gen.go`,都属于 `types` 包,跨文件引用无需改包名。
|
格式化保留注释、token 和多行字面量,规范四空格缩进、空行、行首尾空白及相邻单行字段的类型/标签/行尾注释对齐;结果幂等。先校验完整契约,check 不写文件;导入的共享目录不被某个服务的格式化命令修改。DTO 按源 `.api` 输出 `dto<Source><摘要>Gen.go`,都属于 `types` 包,跨文件引用无需改包名。
|
||||||
|
|
||||||
Go Catalog 是错误的唯一来源时,显式导出文档元数据:
|
Go Catalog 是错误的唯一来源时,优先使用 workspace 的显式导出配置,不再维护导出/复制脚本。AppOptions 仍通过 `omni.WithErrors(apierrors.Catalog())` 注册同一个 Go 函数,运行期只注册一次。单个项目也可继续使用 `errors.ExportCatalog` 导出入口目录的 `api/omni.errors.json`;此文件和下面的共享 source 二选一。
|
||||||
|
|
||||||
```go
|
OpenAPI 自动把有收益的重复响应、schema 和示例移到 `components.responses/schemas/examples`,通过标准 `$ref` 引用。HTTP 200 同时含成功和业务失败时保留两种 schema、业务码、模板参数示例和 data:null;业务响应内容不会改变。消费 OpenAPI 的工具需要支持标准本地引用。
|
||||||
// 在项目自己的导出命令中;appErrors.Catalog 与 AppOptions 注册同一函数。
|
|
||||||
if err := omnierrors.ExportCatalog(os.Stdout, appErrors.Catalog()); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go run ./cmd/exportErrors > api/omni.errors.json
|
|
||||||
go-omni api generate
|
|
||||||
```
|
|
||||||
|
|
||||||
文件是派生产物,应纳入版本控制并由业务 CI 检查导出漂移。CLI 自动读取入口目录的 `omni.errors.json`,校验错误目录、合并状态/模板参数/具体示例到 OpenAPI;不会运行项目 Go,不创建另一份运行期注册器。带独立错误目录的路由继续使用自己的目录。没有路由级精确错误列表时,文档描述 App 目录的公共错误集合;特殊自定义 Encoder 的文档由项目显式扩展。
|
|
||||||
|
|
||||||
## 两个服务共享契约
|
## 两个服务共享契约
|
||||||
|
|
||||||
@@ -241,9 +228,57 @@ go-omni workspace check --file omni.workspace.json
|
|||||||
|
|
||||||
各服务保持独立 module;可显式填写各服务的 `api`(相对服务目录)和 `module`。所有服务预检通过后再写;每个服务独立提交文件事务,不承诺跨项目的崩溃原子性。check 汇总格式与生成漂移;业务编译/测试仍在各 module 执行。
|
各服务保持独立 module;可显式填写各服务的 `api`(相对服务目录)和 `module`。所有服务预检通过后再写;每个服务独立提交文件事务,不承诺跨项目的崩溃原子性。check 汇总格式与生成漂移;业务编译/测试仍在各 module 执行。
|
||||||
|
|
||||||
|
### 同一份 Go 错误目录供多个服务生成
|
||||||
|
|
||||||
|
在 workspace 清单显式声明提供目录的 Go 包和函数:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"errorCatalogs": [{
|
||||||
|
"dir": ".",
|
||||||
|
"package": "example.com/project/internal/apierrors",
|
||||||
|
"function": "Catalog",
|
||||||
|
"output": "contracts/common/errors.json"
|
||||||
|
}],
|
||||||
|
"services": [{"dir":"services/api"}, {"dir":"services/id"}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`dir` 相对 workspace,必须包含 go.mod;函数签名为 `func Catalog() errors.Catalog`。允许 module 内的 internal 包。先准备 output 的父目录。生成器构建一个临时导出程序,使用 Go overlay、临时 go.mod/go.sum 和 `-mod=readonly`,不会往项目增加导出命令或修改 go.mod/go.sum。最多 16 个提供方,每个有 90 秒构建/执行总时限和 1 MiB stdout 限制。错误消息不回显程序输出。
|
||||||
|
|
||||||
|
只有显式运行 **workspace generate/check** 会执行清单指定的 Go 包及其依赖初始化;不要指向不信任的包。提供目录的函数应只构造目录,不连接数据库、启动服务、写 stdout 或修改项目。框架不扫描其他包,也不通过 shell 执行配置字符串。普通 api validate/export/generate 和 workspace format 只读取现有元数据。
|
||||||
|
|
||||||
|
各服务在自己的 `api/omni.imports.json` 引用共享 JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"roots": {"common":{"path":"../../../contracts/common","version":"1.0.0"}},
|
||||||
|
"errors": {
|
||||||
|
"source": "@common/errors.json",
|
||||||
|
"include": ["USERNAME_INVALID", "INTERNAL_ERROR"],
|
||||||
|
"routes": {"GET /permissions":["INTERNAL_ERROR"]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`include` 省略或 null 表示全部目录,`[]` 表示不列业务错误;routes 按 **大写方法 + 空格 + .api 原路径**(如 `GET /users/:id`)覆盖服务级列表。错误标识、路由拼错或重复标识会明确报错。路由已经通过 `.api @errors` 声明独立运行期目录时,不能再给它设置文档 routes 列表。
|
||||||
|
|
||||||
|
**这些列表只决定文档展示,不修改 App 错误目录、路由继承或运行期可返回的错误。** `.api` 不需要重复定义 Go 错误表。状态、业务码、提示和模板参数仍来自同一 Catalog;特殊自定义 Encoder 的文档由项目显式扩展。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 首次生成导出一次共享目录,所有服务预检通过才写入
|
||||||
|
# 共享 JSON 不复制到各服务;已有 api/omni.errors.json 请显式移除后改用 source。
|
||||||
|
go-omni workspace generate --file omni.workspace.json
|
||||||
|
go-omni workspace format --file omni.workspace.json
|
||||||
|
go-omni workspace generate --file omni.workspace.json
|
||||||
|
go-omni workspace check --file omni.workspace.json
|
||||||
|
```
|
||||||
|
|
||||||
|
共享 JSON 应纳入版本控制。check 在内存重新导出并比较 JSON,然后检查所有服务;缺失或过期时失败,不更新项目文件。Go 构建缓存仍可能更新;显式提供方的自有副作用不属于生成器可隔离范围。业务 CI 仍执行各 module 的编译与测试。目录函数本身有变化时重新 generate,受控共享根的固定 SHA256 如需改变仍由项目显式更新。
|
||||||
|
|
||||||
## v0.13 项目升级
|
## v0.13 项目升级
|
||||||
|
|
||||||
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.14.0-preview(或显式引用当前源码)。
|
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.18.0-preview(或显式引用当前源码)。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go-omni api upgrade --dir . --dry-run
|
go-omni api upgrade --dir . --dry-run
|
||||||
|
|||||||
279
GENERATION.md
Normal file
279
GENERATION.md
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
# 公共字段、结构映射与生成选项
|
||||||
|
|
||||||
|
本页对应 `v0.18.0-preview`。CLI 与框架依赖固定到同一版本:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.18.0-preview
|
||||||
|
# 已有业务项目内执行:
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.18.0-preview
|
||||||
|
```
|
||||||
|
|
||||||
|
私有仓库访问设置见 [安装说明](README.md#准备环境与安装)。升级不会覆盖业务文件;已有中间件保持 handlers 模式,切换工厂模式按本页迁移步骤完成。
|
||||||
|
|
||||||
|
## 一份项目生成配置
|
||||||
|
|
||||||
|
新项目根目录生成开发者维护的 `omni.gen.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"repository": "none",
|
||||||
|
"middleware": "factories"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
该文件与 `.api` 一起提交。每次 `api generate`、`--check`、`--dry-run` 和 `workspace generate/check` 都会读取;`api validate/export/format` 只处理契约,不加载业务 Go 类型。
|
||||||
|
|
||||||
|
- `repository: none` 是默认值,不创建空 Repository 文件。`group` 按 `.api` 的 group 补齐 `internal/repository/<group>/repository.go`;文件始终属于业务。切回 none 后不再补骨架,已有文件不删除、不覆盖。实际 SQL、ORM 和仓储划分由项目决定。
|
||||||
|
- `middleware: factories` 是新项目默认值,按下文自动初始化。`handlers` 保留原有 `Defaults()` 与 `func(next Handler) Handler` 接口,供既有项目逐步迁移。
|
||||||
|
- `mappings` 可省略;只有明确列出的类型对才生成映射。未启用时不会运行 Go 类型加载命令,也不增加运行时依赖。
|
||||||
|
- 升级首次遇到没有配置的旧 manifest 时,补一份 `middleware: handlers` 的配置,保留原有手写注册;Repository 默认 none。生成配置建立后不能直接删除,否则生成失败,避免误关闭映射或改变中间件模式。
|
||||||
|
|
||||||
|
### 按模块拆分映射(v0.18 新增)
|
||||||
|
|
||||||
|
主配置可以保留少量内联 `mappings`,也可以只列出文件:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"repository": "none",
|
||||||
|
"middleware": "factories",
|
||||||
|
"mappingFiles": ["mappings/requests.json", "mappings/responses.json"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
每个子文件只包含 `{"mappings": [...]}`,其中每条映射的写法与原来一致。先加载主配置内联定义,再按文件列表顺序加载;所有定义合并后统一校验,可以跨文件、向前引用 `mapping`。输出按映射名排序,文件拆分不改变函数名或语义。
|
||||||
|
|
||||||
|
- 路径相对项目根目录,必须是明确列出的 `.json` 文件。不展开通配符,不允许越界路径、符号链接、重复文件或多层导入;子文件不能覆盖 repository/middleware 等项目选项。
|
||||||
|
- 重名映射、指针辅助函数名冲突、未知映射与循环引用明确报错;文件之间不能用覆盖顺序隐藏冲突。重复定义会指出两个来源文件,字段生成错误也带来源文件。
|
||||||
|
- 文件均由项目维护,重新生成不改写它们。原来的单文件 `mappings` 继续有效。迁移时将完整条目移到子文件并删除原位置,不能复制后保留两份同名定义。
|
||||||
|
- 最多 64 个子文件、单文件 1 MiB、全部配置 4 MiB;合计仍最多 128 个映射。主配置和子文件一起提交 Git。
|
||||||
|
- `api generate`、`--check`、`--dry-run` 与 `workspace generate/check` 使用同一加载逻辑。缺少子文件或不完整配置立即失败,check/预览不写入;生成期间重新核对所有配置输入,发生变化时要求重试。
|
||||||
|
|
||||||
|
## 公共字段组合
|
||||||
|
|
||||||
|
```text
|
||||||
|
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 中不会使用匿名嵌入。
|
||||||
|
|
||||||
|
## 可选结构映射
|
||||||
|
|
||||||
|
`.api` 生成 DTO,业务独立维护输入/结果类型。项目明确选择需要自动赋值的类型对;不从数据库反向生成公开接口,不自动推断反向映射。
|
||||||
|
|
||||||
|
假设 `types.AdminRequest` 有 `Name string`、`Salary amount.Money`、`CompanyID string`,业务的 `domain.EmployeeInput` 有 `EmployeeName string` 和同类型 `Salary`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"repository": "none",
|
||||||
|
"middleware": "factories",
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"name": "ToEmployeeInput",
|
||||||
|
"source": {"package": "example.com/payroll/internal/types", "type": "AdminRequest"},
|
||||||
|
"target": {"package": "example.com/payroll/internal/domain", "type": "EmployeeInput"},
|
||||||
|
"fields": {
|
||||||
|
"EmployeeName": {"source": "Name"}
|
||||||
|
},
|
||||||
|
"ignoreSource": {
|
||||||
|
"CompanyID": "由 Logic 单独做企业范围授权,不属于请求体输入"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
执行 `go-omni api generate` 后,产生独立的生成文件 `internal/mapping/mappingsGen.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ToEmployeeInput(source types.AdminRequest) (domain.EmployeeInput, error) {
|
||||||
|
var result domain.EmployeeInput
|
||||||
|
result.EmployeeName = source.Name
|
||||||
|
result.Salary = source.Salary
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
包别名由生成器决定。业务调用 `mapping.ToEmployeeInput(*req)`;反向响应映射按另一条 source/target 规则声明。两个类型完全相同时通常无需映射。
|
||||||
|
|
||||||
|
### 类型与覆盖检查
|
||||||
|
|
||||||
|
自动赋值要求同名且 **Go 类型一致**(类型别名按 Go 类型系统处理),字段名使用 Go 名称,不按 JSON 标签猜对应关系。int32 → int64、`*Money` → Money、数值 ID → string 都必须显式转换。不会暗中补零、解引用或执行数字强转。
|
||||||
|
|
||||||
|
每个源字段必须被使用或出现在 `ignoreSource`;每个目标字段必须被赋值或出现在 `ignoreTarget`。忽略规则必须给出非空原因,目标忽略明确意味着保留零值;规则引用不存在的字段也会失败。新字段若无法自动匹配,会在生成阶段报错。两边同时添加的同名同类型字段会自动加入生成代码,`--check` 检出未重新生成的差异。
|
||||||
|
|
||||||
|
根类型是项目可导入的具名结构;支持值类型匿名嵌入,规则可用 `Employee.EmployeeName` 这样的实际 Go 字段路径。同名提升歧义、未导出字段和匿名指针嵌入拒绝处理。普通具名嵌套字段使用独立映射或转换函数,不猜测内部结构。泛型声明不能直接作为根;具体字段类型按 Go 类型系统检查。
|
||||||
|
|
||||||
|
配置变化、遗漏映射、类型错误会在写入前失败。映射生成与其他生成产物使用同一套文件所有权机制;手改映射生成文件会被拒绝覆盖。
|
||||||
|
|
||||||
|
### 显式转换函数
|
||||||
|
|
||||||
|
```json
|
||||||
|
"fields": {
|
||||||
|
"Salary": {
|
||||||
|
"source": "Salary",
|
||||||
|
"convert": {"package": "example.com/payroll/internal/convert", "name": "SalaryValue"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
支持这两种精确签名:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func SalaryValue(source *amount.Money) amount.Money
|
||||||
|
func SalaryValue(source *amount.Money) (amount.Money, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
项目选择一种实现。nil、默认值、金额精度、日期格式和错误码由函数负责;若两边已经是同一个 Money 类型,无需再次解析或序列化。转换函数应仅负责值转换,不执行 SQL 或改变授权范围。
|
||||||
|
|
||||||
|
错误通过 `%w` 加上映射名/目标字段路径,保留 `errors.Is/As` 和框架错误链;返回零目标值,不交付部分完成的结果。包装不主动添加源值或转换后的值。错误是否公开仍由项目错误目录和响应策略决定。
|
||||||
|
|
||||||
|
### 嵌套对象、数组与引用
|
||||||
|
|
||||||
|
`mapping` 指向同配置中的另一条映射,`elements` 明确要求逐项映射:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"fields": {
|
||||||
|
"Summary": {"source": "Summary", "mapping": "ToSummary"},
|
||||||
|
"Items": {"source": "Items", "mapping": "ToItem", "elements": true}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`mapping` 与 `convert` 互斥。`elements` 可以配合其中任意一种,也可以对类型相同的元素直接赋值;支持 slice→slice、等长 array→array,禁止隐式互转。nil slice 保持 nil,非 nil 空 slice 保持非 nil,逐项失败的错误包含索引,循环映射声明被拒绝。
|
||||||
|
|
||||||
|
未选择 elements 的同类型指针、map、slice 使用普通 Go 赋值,**共享引用**,不做隐式深拷贝。elements 会分配新的目标 slice,但元素内的引用仍遵循其映射规则。需要深拷贝时显式提供转换函数。
|
||||||
|
|
||||||
|
映射不保存原 JSON 的字段存在信息。普通 `*T` 在绑定后可能已把缺失和 null 合并为 nil;无法在转换阶段恢复区分。有三态需要的项目必须先使用能携带该信息的输入类型及绑定扩展;没有声明三态时,保持当前输入类型的语义。完整表单更新和局部更新由业务决定,框架不把 nil 自动解释成“保留旧值”。
|
||||||
|
|
||||||
|
### 可空嵌套映射与显式 nil 策略(v0.18 新增)
|
||||||
|
|
||||||
|
假设已有 `ToChild` 定义 `types.Child → domain.Child`。父对象的字段规则可以直接复用它:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"fields": {
|
||||||
|
"Child": {"source": "Child", "mapping": "ToChild"},
|
||||||
|
"Children": {"source": "Children", "mapping": "ToChild", "elements": true}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 源字段 → 目标字段 | 明确选择 mapping 后的行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| `S → T` | 调用原值映射 |
|
||||||
|
| `*S → *T` | 默认保留 nil;非 nil 调用值映射,并分配新的目标对象;也可配置 `nil: "error"` 拒绝 nil |
|
||||||
|
| `S → *T` | 调用值映射,成功后返回目标对象指针 |
|
||||||
|
| `*S → T` | 必须选择 `nil: "zero"` 或 `nil: "error"`,不猜默认值 |
|
||||||
|
|
||||||
|
`nil: "preserve"` 只适用于可空源和可空目标。`nil: "zero"` 只适用于可空源和非空目标:nil 直接产生目标 Go 零值,**不调用子映射或转换函数**。`nil: "error"` 在 nil 时返回带映射/字段路径的普通转换错误,HTTP 公开响应仍由项目的错误处理策略决定;需要领域错误码时使用项目 converter。
|
||||||
|
|
||||||
|
同一规则适用于显式 `elements: true` 下的 slice/等长数组元素,包括 `[]*S → []*T`。nil slice、非 nil 空 slice、nil 元素分别保留;元素失败保留索引与内部错误链,整个映射返回零目标值,不交付部分结果。只支持这里列出的单层指针,不自动处理多层指针或递归对象图。
|
||||||
|
|
||||||
|
普通同类型指针字段未指定 mapping 时仍直接赋值、共享引用;上述显式映射会创建目标子对象,但不承诺对其中所有引用深拷贝。业务不可变快照继续由项目设计。
|
||||||
|
|
||||||
|
简单的 `*T → T` 也可以单独指定 nil 策略,减少重复解引用函数:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"fields": {
|
||||||
|
"Title": {"source": "Title", "nil": "zero"},
|
||||||
|
"Count": {"source": "Count", "nil": "error"}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这里必须是完全相同的 Go 元素类型,`*int32 → int64`、枚举 → string、数字编号 → string 仍需显式 converter。非 nil 的零值正常保留。`convert` 与 `nil` 不能同时设置,现有 converter 继续完整控制自己的空值、格式和错误语义。
|
||||||
|
|
||||||
|
### 可选指针返回辅助(v0.18 新增)
|
||||||
|
|
||||||
|
在映射条目上增加 `"pointerResult": true`,生成器保留 `ToResponse(source S) (T, error)`,另加:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ToResponsePtr(source S) (*T, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Logic 在业务调用成功后可直接写 `return mapping.ToResponsePtr(result)`。转换失败时返回 `nil, err`,错误链不变;成功才返回非 nil 指针。业务调用本身的错误仍由 Logic 处理,辅助函数不执行任何业务操作。
|
||||||
|
|
||||||
|
辅助函数名称固定为映射名加 `Ptr`,与其他映射/辅助函数同名会在生成前报错。输入仍是原来的值类型 S,不用 nil 输入表达业务错误;关闭该选项会移除辅助函数,使用它的业务代码需同时调整。
|
||||||
|
|
||||||
|
### 生成顺序与 CI
|
||||||
|
|
||||||
|
1. 先从 `.api` 生成项目,添加业务结构、scalar 和转换函数,执行 `go mod tidy`。
|
||||||
|
2. 在 `omni.gen.json` 列出映射,执行 `go-omni api generate`。
|
||||||
|
3. Logic 调用生成函数。后续改字段后重复生成,不修改 `*Gen.go`。
|
||||||
|
4. CI 同时运行 `go-omni api generate --check` 与 `go test ./...`。
|
||||||
|
|
||||||
|
启用映射后,CLI 通过 `go list -deps -export` 加载明确声明的包与依赖,按当前构建环境做类型检查,不执行业务代码、init 或转换函数。采用待生成 DTO 的临时 overlay,避免“新增字段还看旧 DTO”。go.mod/go.sum 的副本及 overlay 都在临时目录,check 不修改项目文件;Go 自己的构建/模块缓存可能更新。使用当前 GOOS/GOARCH/GOFLAGS、`GOWORK=off`;依赖用 go.mod 与 replace 表达,生成超时为 90 秒。
|
||||||
|
|
||||||
|
类型包/转换函数包不能依赖输出包 `internal/mapping`;把转换函数放在独立包中,避免导入环。若包本身无法编译,先运行 `go list -deps -export <相关包>` 排查;CLI 不回显可能含私有仓库凭据的 Go 下载输出。纯值字段转换只生成标准 Go 代码,不引入运行时反射搬运、JSON 往返、ORM 或队列依赖。
|
||||||
|
|
||||||
|
## 中间件工厂自动注册
|
||||||
|
|
||||||
|
`.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` 的映射与选项 | `internal/mapping/mappingsGen.go` |
|
||||||
|
| Go 业务类型、scalar、转换函数 | 编译期可检查的赋值与调用代码 |
|
||||||
|
| middleware Dependencies、具名工厂、svc 访问器 | 中间件 registry、启动调用 |
|
||||||
|
| Logic、按需 Repository、配置、svc 初始化/清理登记 | main、runtime、路由接入 |
|
||||||
|
|
||||||
|
回归样例位于 `cmd/go-omni/apigen/testdata/composition`,为独立三端服务,不导入任何 eTax 代码。
|
||||||
19
PROJECT.md
19
PROJECT.md
@@ -1,6 +1,6 @@
|
|||||||
# 生成项目与文件维护边界
|
# 生成项目与文件维护边界
|
||||||
|
|
||||||
v0.14.1-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
v0.18.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 签名检查仍会执行。
|
这是轻量源码分析,不运行项目 init、不执行 Go 命令、不下载依赖。项目自定义类型别名、嵌入方法或 build tags 等无法可靠判断时,会给 REVIEW 并交给 Go 编译器验证;不把这些情况强行判为签名错误。它不检查方法体是否引用已删除字段,也不证明业务逻辑正确。CI 中应同时执行 `go-omni api generate --check` 和 `go test ./...`,按项目实际构建参数测试。当前版本首次接手缺少快照的 manifest v2 时,会明确提示建立基线,不猜测旧契约;Logic 签名检查仍会执行。
|
||||||
|
|
||||||
|
本版新增生成选项、公共字段组合和映射,详见 [生成能力指南](GENERATION.md)。
|
||||||
|
|
||||||
## 哪些文件由用户维护
|
## 哪些文件由用户维护
|
||||||
|
|
||||||
| 文件 | 首次生成后由项目维护 |
|
| 文件 | 首次生成后由项目维护 |
|
||||||
@@ -50,15 +52,16 @@ ERROR [logicSignature] internal/logic/greet/greet.go:12 (GET /api/greet/:name):
|
|||||||
| `internal/svc/databaseOptions.go` | 可选 MySQL 驱动与项目启动检查扩展 |
|
| `internal/svc/databaseOptions.go` | 可选 MySQL 驱动与项目启动检查扩展 |
|
||||||
| `internal/svc/storage.go` | 选用存储时生成,之后保留 |
|
| `internal/svc/storage.go` | 选用存储时生成,之后保留 |
|
||||||
| `internal/logic/<group>/*.go` | 业务方法,使用标准 context.Context |
|
| `internal/logic/<group>/*.go` | 业务方法,使用标准 context.Context |
|
||||||
| `internal/repository/<group>/*.go` | 数据访问,按需实现 |
|
| `internal/repository/<group>/*.go` | 数据访问;默认不补空骨架,可选 group 模式 |
|
||||||
| `internal/middleware/<name>.go` | 项目鉴权/权限等具名中间件实现 |
|
| `internal/middleware/*Factory.go`、`dependencies.go` | 中间件工厂与依赖小接口,svc 实现访问器 |
|
||||||
|
| `omni.gen.json`、映射转换函数(支持 `mappingFiles` 引用子文件) | 生成选项、显式类型对与差异规则 |
|
||||||
| 项目 Go 错误包 | 自行维护 Catalog 和常量,可跨服务共用 |
|
| 项目 Go 错误包 | 自行维护 Catalog 和常量,可跨服务共用 |
|
||||||
| 自定义标量文件、`*Bind.go`、`*Manual.go` | 只有显式选择相应扩展时才生成/使用 |
|
| 自定义标量文件、`*Bind.go`、`*Manual.go` | 只有显式选择相应扩展时才生成/使用 |
|
||||||
| 项目测试、迁移、部署脚本、go.mod/go.sum | 项目所有,生成器不重置其内容 |
|
| 项目测试、迁移、部署脚本、go.mod/go.sum | 项目所有,生成器不重置其内容 |
|
||||||
|
|
||||||
以上骨架只补缺失文件,既有内容按字节保留。删除已废弃接口后,对应业务文件也不会被自动删除;由开发者整理编译器指出的失效类型或签名。
|
以上骨架只补缺失文件,既有内容按字节保留。删除已废弃接口后,对应业务文件也不会被自动删除;由开发者整理编译器指出的失效类型或签名。
|
||||||
|
|
||||||
生成器持续管理:`internal/svc/runtimeGen.go`、`main.go`、`internal/handler/*Gen.go`、分组的 `handlersGen.go`、`internal/types/*Gen.go`、中间件 `registryGen.go`、`docs/openapi.json`、`.goOmni/apiManifest.json`。如果在 `.api` 里选择声明错误,还会管理 `internal/apierrors/errorsGen.go`。文件标注 DO NOT EDIT;手改会被拒绝覆盖,需先移走自定义内容。
|
生成器持续管理:`internal/svc/runtimeGen.go`、`main.go`、`internal/handler/*Gen.go`、分组的 `handlersGen.go`、`internal/types/*Gen.go`、可选映射 `internal/mapping/mappingsGen.go`、中间件 `registryGen.go`、`docs/openapi.json`、`.goOmni/apiManifest.json`。如果在 `.api` 里选择声明错误,还会管理 `internal/apierrors/errorsGen.go`。文件标注 DO NOT EDIT;手改会被拒绝覆盖,需先移走自定义内容。
|
||||||
|
|
||||||
Logic 实例在注册时创建并由请求并发复用;请求数据用方法参数和局部变量传递,不存入共享 Logic/svc 字段。
|
Logic 实例在注册时创建并由请求并发复用;请求数据用方法参数和局部变量传递,不存入共享 Logic/svc 字段。
|
||||||
|
|
||||||
@@ -85,7 +88,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
业务调用 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`,可接 `WithParams` / `WithCause`。无需在 `.api` 重复错误目录。未声明独立错误目录的路由继承 App 目录;独立目录不与 App 合并。静态 OpenAPI 不执行 Go;可从同一 Catalog 显式导出 `api/omni.errors.json`,详见 [元数据导出](DEVELOPMENT.md#字段文档格式化go-错误目录导出)。
|
业务调用 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`,可接 `WithParams` / `WithCause`。无需在 `.api` 重复错误目录。未声明独立错误目录的路由继承 App 目录;独立目录不与 App 合并。普通 `.api` 编译不执行 Go;可在 workspace 显式配置 Catalog 函数,统一导出共享文档元数据,也可沿用 `api/omni.errors.json`,详见 [元数据导出](DEVELOPMENT.md#字段文档格式化go-错误目录导出)。
|
||||||
|
|
||||||
`GlobalMiddleware()` 提供全局中间件;`Middlewares` 映射提供 `.api` 声明的具名中间件。全部显式调用,不依赖项目 init 注册,也不要求 svc 导入 handler。框架请求链按原有次序运行。
|
`GlobalMiddleware()` 提供全局中间件;`Middlewares` 映射提供 `.api` 声明的具名中间件。全部显式调用,不依赖项目 init 注册,也不要求 svc 导入 handler。框架请求链按原有次序运行。
|
||||||
|
|
||||||
@@ -93,7 +96,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
|
|||||||
|
|
||||||
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。
|
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。
|
||||||
|
|
||||||
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,在 OnClose 中取消。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
|
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,使用 WorkContext 并登记成对的 OnStop/OnDrain,详见 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭)。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
|
||||||
|
|
||||||
## 依赖主动申请退出
|
## 依赖主动申请退出
|
||||||
|
|
||||||
@@ -106,7 +109,7 @@ c.RequestShutdown(err)
|
|||||||
|
|
||||||
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
|
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
|
||||||
|
|
||||||
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。业务应在 Close/OnClose 中停止并等待自己启动的监控任务,再结束资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
|
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。后台监控任务应通过 OnStop/OnDrain 停止并等待,再进入资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
|
||||||
|
|
||||||
`Lifecycle *lifecycle.Controller` 是 svc 持有的通用控制器,初始化时创建,生成入口使用它的 Context。该 Context 表示服务正在停止,不代表数据库已经关闭。不能把它当作在途请求的 Context,也不要在它取消时立刻关闭请求仍依赖的资源。发号器失去租约后拒绝发号等业务安全规则仍由项目实现。
|
`Lifecycle *lifecycle.Controller` 是 svc 持有的通用控制器,初始化时创建,生成入口使用它的 Context。该 Context 表示服务正在停止,不代表数据库已经关闭。不能把它当作在途请求的 Context,也不要在它取消时立刻关闭请求仍依赖的资源。发号器失去租约后拒绝发号等业务安全规则仍由项目实现。
|
||||||
|
|
||||||
@@ -208,7 +211,7 @@ get /download (DownloadReq)
|
|||||||
|
|
||||||
## 已有服务接入步骤
|
## 已有服务接入步骤
|
||||||
|
|
||||||
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.14.1-preview:
|
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.18.0-preview:
|
||||||
|
|
||||||
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
||||||
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -2,13 +2,21 @@
|
|||||||
|
|
||||||
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
||||||
|
|
||||||
**从这份 README 开始接入。** 当前版本 **`v0.14.1-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
**从这份 README 开始接入。** 当前版本 **`v0.18.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
|
**v0.17 提供**:公共字段组合、可选结构映射、带依赖的中间件工厂、按需 Repository 骨架,使用与迁移见 [生成能力指南](GENERATION.md)。
|
||||||
|
|
||||||
|
**本版新增**:可空嵌套映射、映射配置按文件拆分、显式 nil 策略和可选指针返回辅助,见 [生成能力指南](GENERATION.md)。
|
||||||
|
|
||||||
Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
|
Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
|
||||||
|
|
||||||
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。
|
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。
|
||||||
|
|
||||||
新增通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。
|
本版补齐 `returns([]string/[]int64/[]bool/[]any)`、OpenAPI 公共引用、workspace 一次导出共享 Go 错误目录,以及统一环境变量别名覆盖。详细配置与升级步骤见 [业务开发指南](DEVELOPMENT.md)。
|
||||||
|
|
||||||
|
已有通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。
|
||||||
|
|
||||||
|
后台组件可在 svc 登记 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭),先停止接收工作并等待收尾,再关闭共享资源;无需绑定队列或 ORM。
|
||||||
|
|
||||||
使用 GORM 的项目可以选择 [gormx 适配器](database/gormx/README.md),复用数据库初始化和连接池,并将 SQL 日志接入 logx。
|
使用 GORM 的项目可以选择 [gormx 适配器](database/gormx/README.md),复用数据库初始化和连接池,并将 SQL 日志接入 logx。
|
||||||
|
|
||||||
@@ -32,7 +40,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.18.0-preview
|
||||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
go-omni --help
|
go-omni --help
|
||||||
```
|
```
|
||||||
@@ -73,21 +81,22 @@ hello/
|
|||||||
├── api/service.api # 维护接口定义,也可 import 其他 .api
|
├── api/service.api # 维护接口定义,也可 import 其他 .api
|
||||||
├── main.go # 生成器维护:启动、路由注册、优雅退出
|
├── main.go # 生成器维护:启动、路由注册、优雅退出
|
||||||
├── etc/config.yaml # 项目配置
|
├── etc/config.yaml # 项目配置
|
||||||
|
├── omni.gen.json # 项目维护的生成选项与可选映射
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── config/ # 配置读取
|
│ ├── config/ # 配置读取
|
||||||
│ ├── svc/ # 数据库、日志、外部客户端等依赖
|
│ ├── svc/ # 数据库、日志、外部客户端等依赖
|
||||||
│ ├── types/*Gen.go # 按 .api 文件生成 DTO
|
│ ├── types/*Gen.go # 按 .api 文件生成 DTO
|
||||||
│ ├── handler/ # 生成的绑定、响应策略、路由及文档
|
│ ├── handler/ # 生成的绑定、响应策略、路由及文档
|
||||||
│ ├── logic/greet/ # 在这里实现业务逻辑
|
│ ├── logic/greet/ # 在这里实现业务逻辑
|
||||||
│ ├── repository/greet/ # 在这里实现数据访问
|
│ ├── repository/ # 按需创建,默认不生成空骨架
|
||||||
│ └── middleware/ # 声明鉴权等中间件后生成对应 hook
|
│ └── middleware/ # 声明鉴权等中间件后生成工厂
|
||||||
├── docs/openapi.json # 生成的 OpenAPI 3.0.3
|
├── docs/openapi.json # 生成的 OpenAPI 3.0.3
|
||||||
└── .goOmni/apiManifest.json # 生成文件所有权与校验信息,一起提交 Git
|
└── .goOmni/apiManifest.json # 生成文件所有权与校验信息,一起提交 Git
|
||||||
```
|
```
|
||||||
|
|
||||||
修改 `api/service.api` 后重新生成,业务写在 `internal/logic`,数据访问写在 `internal/repository`,依赖在 `internal/svc` 装配。声明业务错误后还会生成 `internal/apierrors/errorsGen.go`。
|
修改 `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 字段猜数据库表或业务规则。
|
||||||
|
|
||||||
### 从自己的接口定义生成
|
### 从自己的接口定义生成
|
||||||
|
|
||||||
@@ -188,7 +197,7 @@ go-omni new --api catalog.api \
|
|||||||
在业务项目的 **go.mod 所在目录**执行:
|
在业务项目的 **go.mod 所在目录**执行:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview
|
go get git.yuntuo.hk/k2safe/go-omni@v0.18.0-preview
|
||||||
```
|
```
|
||||||
|
|
||||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
||||||
@@ -312,7 +321,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 状态单独判断成功与否。
|
业务错误可以集中定义,业务只选择发生了哪个错误。**HTTP 状态由项目配置,业务失败可以返回 HTTP 200**;框架不根据 HTTP 状态单独判断成功与否。
|
||||||
|
|
||||||
@@ -456,9 +465,9 @@ go-omni api generate --check
|
|||||||
go test ./...
|
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.14.1-preview`。v0.14.0 项目直接升级依赖与 CLI,GORM 按需导入;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
CLI 与业务 module 固定使用 `v0.18.0-preview`。v0.14.x 项目升级依赖与 CLI 后重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
## 按需深入
|
## 按需深入
|
||||||
|
|
||||||
|
|||||||
99
RELEASE.md
99
RELEASE.md
@@ -1,4 +1,99 @@
|
|||||||
# v0.14.1-preview
|
# v0.18.0-preview
|
||||||
|
|
||||||
|
补齐可空嵌套映射和按模块拆分配置,减少重复转换代码,业务空值规则仍由项目显式选择:
|
||||||
|
|
||||||
|
- 嵌套 mapping 支持 `*S → *T` 保留 nil、`S → *T`,以及指定 nil zero/error 后的 `*S → T`;同样适用于 slice/等长数组元素。错误保留字段路径、索引和内部错误链,失败不返回部分结果。
|
||||||
|
- `mappingFiles` 显式引用项目内的映射文件,支持跨文件向前引用;统一检查重名、循环、类型与字段覆盖。禁止多层导入、路径越界和隐式覆盖,生成期间核对全部配置输入。
|
||||||
|
- 同类型 `*T → T` 可显式配置 nil zero/error;现有 converter 保持完整控制,不默认放宽类型或混入领域规则。
|
||||||
|
- `pointerResult: true` 额外生成 `<Name>Ptr(S) (*T,error)`,原值函数保留;失败返回 nil 与原错误,辅助函数名冲突在生成前拒绝。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.18.0-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.18.0-preview
|
||||||
|
go-omni api generate
|
||||||
|
go-omni api generate --check
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
v0.17 的单文件 mappings、现有 converter 和业务文件继续保留。配置拆分时移动完整映射条目,主配置显式列出子文件;不保留重复定义。新能力均为可选生成配置,不增加运行时依赖。nil zero 表示 Go 零值并跳过子映射;业务必填、金额精度、编号格式和快照复制仍由项目负责。
|
||||||
|
|
||||||
|
使用与迁移见 [生成能力指南](GENERATION.md),回归和发布后验收见 [验收记录](reports/nullableMappingAcceptance.md)。本版仍为 preview。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.17.0-preview(历史版本)
|
||||||
|
|
||||||
|
减少项目里的重复声明、字段赋值和中间件装配代码,业务规则继续由项目控制:
|
||||||
|
|
||||||
|
- `.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(历史版本)
|
||||||
|
|
||||||
|
补齐通用契约与生成体验,业务响应协议继续由项目定义:
|
||||||
|
|
||||||
|
- `returns([]string/[]int64/[]bool/[]any)` 及其他支持的基础类型数组,贯通 Logic、Handler、OpenAPI 和生成检查。nil 根数组输出 `[]`;DTO 数组、nullable、null、raw 和 204 保持原语义。字节数组明确拒绝 base64/数组歧义。
|
||||||
|
- OpenAPI 以标准 components / `$ref` 共享重复响应、schema、示例;保留同一 HTTP 200 下的成功与失败、模板参数、数字业务码和 data:null。
|
||||||
|
- workspace 显式配置 Go Catalog 提供方,一次导出共享 JSON,多服务通过受控根引用。支持服务/路由的文档错误列表,运行期 App 目录继承不变。check 核对导出漂移并保持项目文件不变。
|
||||||
|
- EnvOptions.Aliases + ServerEnvAliases 统一完整配置与框架短名称的 Lookup、类型转换、空值规则。完整名称优先,显式空值不等于未设置;旧 ApplyEnv 的空值/逗号列表行为保留。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.16.0-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview
|
||||||
|
go-omni api generate
|
||||||
|
go-omni api generate --check
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
旧项目 config/Logic/svc 继续由开发者维护,不会被重新生成覆盖。已有环境配置需按 [一次覆盖完整配置](DEVELOPMENT.md#一次覆盖完整配置与-http日志短名称) 合并 OverlayEnv 与 ApplyEnv;共享错误目录接入见 [workspace 配置](DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。已有手写权限 Handler 切换自动生成前,先迁出实现并显式移除 Manual 文件,生成器不会静默删除。
|
||||||
|
|
||||||
|
全量 race(含实际生成消费者)、vet、build、根入口/退出/再生成验证通过。eTax 只读契约验收:API 文档 20,751,149 → 847,171 字节,ID 文档 1,008,210 → 392,481 字节;70 个操作展开引用后与原文档完全一致。空缓存远程 CLI / 固定版本消费者验收也已通过,没有本地 replace;标签固定在 `64e3bd6cfced225476d58e95037249cd0a4fe695`。详见 [验收记录](reports/contractFollowupAcceptance.md)。这不代表已完成业务项目迁移。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.15.0-preview(历史版本)
|
||||||
|
|
||||||
|
增加通用后台分阶段关闭:生成入口先完成 HTTP 排空,再运行全部 OnStop、全部 OnDrain,确认组件收尾后逆序释放 OnClose 资源,最后关闭日志。适用于任意后台组件;不引入 Orbit,不改变业务事务或任务一致性策略。
|
||||||
|
|
||||||
|
- svc 登记成对、同名的 OnStop/OnDrain,Context 用于早期退出通知,WorkContext 供已接受工作在收尾期间使用。同一阶段并发执行,存在内部先后依赖的组件组合登记。
|
||||||
|
- server.lifecycle 配置 stopTimeout、drainTimeout、cancelTimeout。阶段超时取消工作并给未返回钩子收尾窗口;错误与阶段/组件名称可通过错误链追踪。
|
||||||
|
- 不能确认后台已经结束时,返回 ErrResourcesRetained,保留共享资源和借用日志,生成进程非零退出。未返回的 Stop 不会与自己的 Drain 重叠;错误、panic 不阻止其他组件尝试收尾。
|
||||||
|
- task.Runner 增加 StopAccepting/Wait,原 Shutdown/Close 继续可用。核心 lifecycle 不依赖 task 或其他适配器。
|
||||||
|
- 初始化和监听失败复用同一流程;重复关闭只执行一次。生成器继续保留配置、svc 和业务代码。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.15.0-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.15.0-preview
|
||||||
|
go-omni api generate
|
||||||
|
go-omni api generate --check
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
v0.14.x 无需重写 svc/config;生成器更新 main/runtimeGen,未配置时使用默认时限。也可 api upgrade --dry-run/--apply 更新依赖与生成文件,保留已有项目扩展。需要阶段保证的旧后台 OnClose 登记,应显式迁到 Stop/Drain;v0.13 及更早版本仍按原迁移指南处理。
|
||||||
|
|
||||||
|
全量 race(含实际生成项目)、vet、build 和隔离 MySQL/GORM 验收已通过。发布后的空缓存远程生成项目与真实 MySQL/GORM 验收也通过,CLI/框架同版且无本地 replace;标签固定在 `16deead70e860052c5b629511207578c03f9b2cb`。详见 [生命周期接入](lifecycle/README.md#后台组件分阶段关闭) 与 [验收记录](reports/phasedLifecycleAcceptance.md)。Go 无法强制结束任意 goroutine;OnClose 仍须能返回,HTTP 处理器仍须响应取消。此版不会将未知的第三方组件自动改造成优雅关闭组件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.14.1-preview(历史版本)
|
||||||
|
|
||||||
增加可选 `database/gormx`:GORM 复用 go-omni 已初始化的标准池、TLS、限额、健康检查和关闭流程,业务继续使用原生 GORM 模型与事务。核心 App/数据库包的编译依赖链不引入 GORM。
|
增加可选 `database/gormx`:GORM 复用 go-omni 已初始化的标准池、TLS、限额、健康检查和关闭流程,业务继续使用原生 GORM 模型与事务。核心 App/数据库包的编译依赖链不引入 GORM。
|
||||||
|
|
||||||
@@ -11,7 +106,7 @@ go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview
|
|||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
|
||||||
```
|
```
|
||||||
|
|
||||||
全量 race(含生成消费者)、vet、build,以及隔离 MySQL 的事务/取消/日志/生成项目接入通过,详见 [GORM 验收](reports/gormAcceptance.md)。
|
全量 race(含生成消费者)、vet、build,以及隔离 MySQL 的事务/取消/日志/生成项目接入通过。发布后的空缓存远程 MySQL/GORM 和生成项目验收均通过,无本地 replace,CLI 与框架使用同版。标签固定在 `b8ab6b35219d53d6de392c920fe7ecbe9a76dade`,详见 [GORM 验收](reports/gormAcceptance.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# go-omni CLI
|
# go-omni CLI
|
||||||
|
|
||||||
|
v0.18 新增:可空嵌套映射、`mappingFiles` 配置拆分、显式 nil 策略和可选 `pointerResult`。原 v0.17 配置继续有效,见 [生成能力指南](../../GENERATION.md)。
|
||||||
|
|
||||||
|
v0.17 增加公共字段组合、可选结构映射、自动中间件工厂和按需 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)。
|
v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目录>|--apply]`,以及 `workspace format|generate|check --file omni.workspace.json`。详见 [通用开发指南](../../DEVELOPMENT.md)。
|
||||||
|
|
||||||
新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。
|
新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。
|
||||||
@@ -8,14 +14,14 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 仓库读取权限与 PATH 配置见根目录 README
|
# 仓库读取权限与 PATH 配置见根目录 README
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.18.0-preview
|
||||||
go-omni new --module example.com/hello ./hello
|
go-omni new --module example.com/hello ./hello
|
||||||
|
|
||||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||||
go-omni new --api service.api --module example.com/example ./example
|
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 查询。
|
参数:`--database none|memory|mysql`(默认 none;memory 等同 none),`--storage none|local|s3|r2|oss`,`--log-output stdout|stderr|file`,`--log-format json|text`。基础设施按需生成,连接凭据由环境提供,不自动创建数据库、表或云资源。选择 MySQL 不会凭空生成业务表或 Repository 查询。
|
||||||
|
|
||||||
@@ -43,14 +49,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
|||||||
|
|
||||||
## 版本与迁移
|
## 版本与迁移
|
||||||
|
|
||||||
CLI 与框架依赖固定使用 v0.14.1-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
CLI 与框架依赖固定使用 v0.18.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 在 go-omni 源码仓库中执行
|
# 在 go-omni 源码仓库中执行
|
||||||
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
||||||
```
|
```
|
||||||
|
|
||||||
模板最低需要 v0.14.0-preview 的通用 Runtime、配置和绑定能力,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
当前模板与依赖以 v0.18.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||||
|
|
||||||
## 数据库迁移命令
|
## 数据库迁移命令
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ func TestRegenerationAndConflicts(t *testing.T) {
|
|||||||
servicePath := filepath.Join(dir, "internal/logic/greet/greet.go")
|
servicePath := filepath.Join(dir, "internal/logic/greet/greet.go")
|
||||||
service := readFile(t, servicePath) + "\n// user edit must survive\n"
|
service := readFile(t, servicePath) + "\n// user edit must survive\n"
|
||||||
writeFile(t, servicePath, service)
|
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"} {
|
for _, file := range []string{"etc/config.yaml", "internal/svc/serviceContext.go", "internal/repository/greet/repository.go"} {
|
||||||
body := readFile(t, filepath.Join(dir, file))
|
body := readFile(t, filepath.Join(dir, file))
|
||||||
if strings.HasSuffix(file, ".go") {
|
if strings.HasSuffix(file, ".go") {
|
||||||
@@ -301,6 +302,7 @@ func TestGeneratedProjectsBuildAndRun(t *testing.T) {
|
|||||||
if starter {
|
if starter {
|
||||||
_, err = New(o)
|
_, err = New(o)
|
||||||
} else {
|
} else {
|
||||||
|
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
|
||||||
o.API = filepath.Join(root, "examples/contracts/user.api")
|
o.API = filepath.Join(root, "examples/contracts/user.api")
|
||||||
_, err = Generate(o)
|
_, err = Generate(o)
|
||||||
}
|
}
|
||||||
|
|||||||
104
cmd/go-omni/apigen/arrayResponse_test.go
Normal file
104
cmd/go-omni/apigen/arrayResponse_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrimitiveArrayContracts(t *testing.T) {
|
||||||
|
for _, name := range []string{"string", "bool", "int", "int8", "int16", "int32", "int64", "uint", "uint16", "uint32", "uint64", "float32", "float64", "any"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
spec, err := parseText(t, fmt.Sprintf("service arrays{\n@handler list\nget /list returns ([]%s)\n}", name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, err := Compile(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
schema := operation(t, document(t, c), "/list", "get")["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
|
||||||
|
data := schema["properties"].(map[string]any)["data"].(map[string]any)
|
||||||
|
if data["type"] != "array" || data["items"] == nil {
|
||||||
|
t.Fatal(data)
|
||||||
|
}
|
||||||
|
if name == "any" && len(data["items"].(map[string]any)) != 0 {
|
||||||
|
t.Fatal("dynamic JSON was constrained")
|
||||||
|
}
|
||||||
|
if responseGoType("[]"+name) != "[]"+name || responseNeedsTypes("[]"+name) {
|
||||||
|
t.Fatal("wrong Go signature/import")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, name := range []string{"uint8", "File", "Unknown", "[]string"} {
|
||||||
|
spec, err := parseText(t, fmt.Sprintf("service arrays{\n@handler list\nget /list returns ([]%s)\n}", name))
|
||||||
|
if err == nil {
|
||||||
|
_, err = Compile(spec)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("unsupported root accepted", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrimitiveArrayConsumer(t *testing.T) {
|
||||||
|
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||||
|
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||||
|
}
|
||||||
|
dir := filepath.Join(t.TempDir(), "arrays")
|
||||||
|
o := Options{Dir: dir, API: filepath.Join(frameworkRoot(t), "examples/contracts/arrays.api"), Module: "example.com/arrays", FrameworkDir: frameworkRoot(t)}
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"strings", "integers", "dynamic", "empty", "raw"} {
|
||||||
|
writeFile(t, filepath.Join(dir, "internal/logic/arrays/"+name+".go"), "package arrays\n")
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, "internal/logic/arrays/values.go"), readFile(t, "testdata/arrays/values.go"))
|
||||||
|
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/arrays/consumer_test.go"))
|
||||||
|
o.API = filepath.Join(dir, "api/arrays.api")
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(readFile(t, filepath.Join(dir, "internal/logic/arrays/values.go")), "企业") {
|
||||||
|
t.Fatal("logic overwritten")
|
||||||
|
}
|
||||||
|
o.Check = true
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "-count=1", "./...")
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("%v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
|
||||||
|
cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||||
|
raw, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err, string(raw))
|
||||||
|
}
|
||||||
|
var module struct {
|
||||||
|
Version, Sum string
|
||||||
|
Replace any
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(raw, &module); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if module.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || module.Replace != nil || module.Sum == "" {
|
||||||
|
t.Fatal("not a fixed remote consumer")
|
||||||
|
}
|
||||||
|
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ func TestBusinessGeneratedProject(t *testing.T) {
|
|||||||
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||||
}
|
}
|
||||||
dir := filepath.Join(t.TempDir(), "business")
|
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)}
|
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 {
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -2,41 +2,73 @@ package apigen
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const catalogDocumentFile = "omni.errors.json"
|
const catalogDocumentFile = "omni.errors.json"
|
||||||
|
|
||||||
func readDocumentCatalog(root string, spec *Spec) error {
|
// DocumentErrors selects documentation only. Runtime App inheritance and
|
||||||
path := filepath.Join(root, catalogDocumentFile)
|
// route-local @errors declarations are never changed by this metadata.
|
||||||
stat, err := os.Lstat(path)
|
// A nil Include lists all errors; [] lists none. Routes override Include.
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
type DocumentErrors struct {
|
||||||
return nil
|
Source string `json:"source"`
|
||||||
|
Include []string `json:"include"`
|
||||||
|
Routes map[string][]string `json:"routes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDocumentCatalog(root string, spec *Spec, overrides map[string][]byte) error {
|
||||||
|
name := catalogDocumentFile
|
||||||
|
path := filepath.Join(root, name)
|
||||||
|
// Shared metadata uses the same explicitly authorized roots as .api imports.
|
||||||
|
|
||||||
|
if selection := spec.DocumentErrors; selection != nil {
|
||||||
|
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return errors.New("api: use either local omni.errors.json or shared errors.source, not both")
|
||||||
|
}
|
||||||
|
name = selection.Source
|
||||||
|
alias, sub, ok := strings.Cut(strings.TrimPrefix(name, "@"), "/")
|
||||||
|
declared, found := spec.SharedRoots[alias]
|
||||||
|
if !strings.HasPrefix(name, "@") || !ok || !found || sub == "" || filepath.Ext(sub) != ".json" {
|
||||||
|
return errors.New("api: errors.source must name a JSON file in a declared @root")
|
||||||
|
}
|
||||||
|
if err := safePath(declared.Path, sub); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path = filepath.Join(declared.Path, filepath.FromSlash(sub))
|
||||||
}
|
}
|
||||||
if err != nil {
|
raw, ok := overrides[path]
|
||||||
return err
|
if !ok {
|
||||||
}
|
stat, err := os.Lstat(path)
|
||||||
if !stat.Mode().IsRegular() {
|
if errors.Is(err, os.ErrNotExist) && spec.DocumentErrors == nil {
|
||||||
return errors.New("api: error metadata must be a regular file")
|
return nil
|
||||||
}
|
}
|
||||||
file, err := os.Open(path)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
if !stat.Mode().IsRegular() {
|
||||||
defer file.Close()
|
return errors.New("api: error metadata must be a regular file")
|
||||||
raw, err := io.ReadAll(io.LimitReader(file, (1<<20)+1))
|
}
|
||||||
if err != nil {
|
f, err := os.Open(path)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
raw, err = io.ReadAll(io.LimitReader(f, (1<<20)+1))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(raw) > 1<<20 {
|
if len(raw) > 1<<20 {
|
||||||
return errors.New("api: error metadata exceeds 1 MiB")
|
return errors.New("api: error metadata exceeds 1 MiB")
|
||||||
}
|
}
|
||||||
var catalog apierrors.Catalog
|
var catalog apierrors.Catalog
|
||||||
if err = apierrors.DecodeCatalogJSON(raw, &catalog); err != nil {
|
if err := apierrors.DecodeCatalogJSON(raw, &catalog); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
registry, err := apierrors.NewRegistry(catalog)
|
registry, err := apierrors.NewRegistry(catalog)
|
||||||
@@ -45,10 +77,70 @@ func readDocumentCatalog(root string, spec *Spec) error {
|
|||||||
}
|
}
|
||||||
normalized := registry.Catalog()
|
normalized := registry.Catalog()
|
||||||
spec.DocumentCatalog = &normalized
|
spec.DocumentCatalog = &normalized
|
||||||
spec.Sources[catalogDocumentFile] = raw
|
spec.Sources[name] = raw
|
||||||
spec.SourcePaths[catalogDocumentFile] = path
|
spec.SourcePaths[name] = path
|
||||||
|
return validateDocumentSelection(spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentRouteCatalog(spec *Spec, route Route) apierrors.Catalog {
|
||||||
|
catalog := spec.DocumentCatalog.Clone()
|
||||||
|
if spec.DocumentErrors == nil {
|
||||||
|
return catalog
|
||||||
|
}
|
||||||
|
include := spec.DocumentErrors.Include
|
||||||
|
if selected, ok := spec.DocumentErrors.Routes[strings.ToUpper(route.Method)+" "+route.Path]; ok {
|
||||||
|
include = selected
|
||||||
|
}
|
||||||
|
if include != nil {
|
||||||
|
selected := map[string]apierrors.Definition{}
|
||||||
|
for _, key := range include {
|
||||||
|
selected[key] = catalog.Definitions[key]
|
||||||
|
}
|
||||||
|
catalog.Definitions = selected
|
||||||
|
}
|
||||||
|
return catalog
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDocumentSelection(spec *Spec) error {
|
||||||
|
cfg := spec.DocumentErrors
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
validate := func(keys []string) error {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, key := range keys {
|
||||||
|
if _, ok := spec.DocumentCatalog.Definitions[key]; !ok {
|
||||||
|
return fmt.Errorf("api: unknown documentation error %q", key)
|
||||||
|
}
|
||||||
|
if seen[key] {
|
||||||
|
return fmt.Errorf("api: duplicate documentation error %q", key)
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := validate(cfg.Include); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
routes := map[string]Route{}
|
||||||
|
for _, r := range spec.Routes {
|
||||||
|
routes[strings.ToUpper(r.Method)+" "+r.Path] = r
|
||||||
|
}
|
||||||
|
for key, keys := range cfg.Routes {
|
||||||
|
r, ok := routes[key]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("api: unknown documentation route %q", key)
|
||||||
|
}
|
||||||
|
if len(r.Errors) > 0 {
|
||||||
|
return fmt.Errorf("api: %s already declares route-local errors", key)
|
||||||
|
}
|
||||||
|
if err := validate(keys); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateDocumentCatalog(spec *Spec) error {
|
func validateDocumentCatalog(spec *Spec) error {
|
||||||
if spec.DocumentCatalog == nil {
|
if spec.DocumentCatalog == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -61,7 +153,7 @@ func validateDocumentCatalog(spec *Spec) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = policy.ValidateErrors(*spec.DocumentCatalog); err != nil {
|
if err = policy.ValidateErrors(documentRouteCatalog(spec, r)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
if prepareErr != nil {
|
if prepareErr != nil {
|
||||||
return nil, prepareErr
|
return nil, prepareErr
|
||||||
}
|
}
|
||||||
|
if err := expandFields(spec); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := validateDocumentCatalog(spec); err != nil {
|
if err := validateDocumentCatalog(spec); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -70,7 +73,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
resolving := map[string]bool{}
|
resolving := map[string]bool{}
|
||||||
var resolve func(string, int) (reflect.Type, error)
|
var resolve func(string, int) (reflect.Type, error)
|
||||||
var expression func(ast.Expr, int) (reflect.Type, error)
|
var expression func(ast.Expr, int) (reflect.Type, error)
|
||||||
primitives := map[string]reflect.Type{"File": reflect.TypeOf(contract.File{}), "any": reflect.TypeOf((*any)(nil)).Elem(), "string": reflect.TypeOf(""), "bool": reflect.TypeOf(false), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0))}
|
|
||||||
expression = func(e ast.Expr, depth int) (reflect.Type, error) {
|
expression = func(e ast.Expr, depth int) (reflect.Type, error) {
|
||||||
if depth > 32 {
|
if depth > 32 {
|
||||||
return nil, errors.New("type nesting exceeds 32")
|
return nil, errors.New("type nesting exceeds 32")
|
||||||
@@ -80,7 +83,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
if t, ok := scalarTypes[e.Name]; ok {
|
if t, ok := scalarTypes[e.Name]; ok {
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
if t, ok := primitives[e.Name]; ok {
|
if t, ok := primitiveTypes[e.Name]; ok {
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
return resolve(e.Name, depth+1)
|
return resolve(e.Name, depth+1)
|
||||||
@@ -313,6 +316,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
decorateDocument(doc, spec, c.Bindings)
|
decorateDocument(doc, spec, c.Bindings)
|
||||||
|
compactDocument(doc)
|
||||||
c.Document, err = json.MarshalIndent(doc, "", " ")
|
c.Document, err = json.MarshalIndent(doc, "", " ")
|
||||||
return c, err
|
return c, err
|
||||||
}
|
}
|
||||||
|
|||||||
102
cmd/go-omni/apigen/composition.go
Normal file
102
cmd/go-omni/apigen/composition.go
Normal 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
|
||||||
|
}
|
||||||
163
cmd/go-omni/apigen/composition_test.go
Normal file
163
cmd/go-omni/apigen/composition_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -213,7 +213,7 @@ func decorateDocument(doc map[string]any, spec *Spec, bindings map[string]*contr
|
|||||||
if len(r.Errors) == 0 && documentRegistry != nil {
|
if len(r.Errors) == 0 && documentRegistry != nil {
|
||||||
registry = documentRegistry
|
registry = documentRegistry
|
||||||
op["x-omni-error-catalog-scope"] = "app"
|
op["x-omni-error-catalog-scope"] = "app"
|
||||||
for key, definition := range registry.Catalog().Definitions {
|
for key, definition := range documentRouteCatalog(spec, r).Definitions {
|
||||||
grouped[definition.Status] = append(grouped[definition.Status], ErrorDefinition{Name: key, Code: key, Status: definition.Status, Message: definition.Message, BusinessCode: definition.Code, Params: definition.Params})
|
grouped[definition.Status] = append(grouped[definition.Status], ErrorDefinition{Name: key, Code: key, Status: definition.Status, Message: definition.Message, BusinessCode: definition.Code, Params: definition.Params})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
86
cmd/go-omni/apigen/documentRefs.go
Normal file
86
cmd/go-omni/apigen/documentRefs.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// compactDocument only replaces standard OpenAPI Reference Object positions.
|
||||||
|
// Payloads (including examples containing a literal "$ref") are never walked.
|
||||||
|
// Mixed HTTP 200 responses retain both success and error alternatives.
|
||||||
|
func compactDocument(doc map[string]any) {
|
||||||
|
type slot struct {
|
||||||
|
parent map[string]any
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
components := doc["components"].(map[string]any)
|
||||||
|
var responses, schemas, examples []slot
|
||||||
|
for _, path := range doc["paths"].(map[string]any) {
|
||||||
|
for _, value := range path.(map[string]any) {
|
||||||
|
op, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rs, _ := op["responses"].(map[string]any)
|
||||||
|
for status := range rs {
|
||||||
|
responses = append(responses, slot{rs, status})
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
factor := func(kind string, slots []slot) {
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, s := range slots {
|
||||||
|
raw, _ := json.Marshal(s.parent[s.key])
|
||||||
|
counts[string(raw)]++
|
||||||
|
}
|
||||||
|
shared, _ := components[kind].(map[string]any)
|
||||||
|
if shared == nil {
|
||||||
|
shared = map[string]any{}
|
||||||
|
}
|
||||||
|
for _, s := range slots {
|
||||||
|
raw, _ := json.Marshal(s.parent[s.key])
|
||||||
|
count := counts[string(raw)]
|
||||||
|
if count < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := "Omni_" + digest(raw)
|
||||||
|
ref := map[string]any{"$ref": "#/components/" + kind + "/" + name}
|
||||||
|
refJSON, _ := json.Marshal(ref)
|
||||||
|
// Keep small objects inline if components would increase size.
|
||||||
|
if (count-1)*len(raw) <= count*len(refJSON)+len(name)+4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
shared[name] = s.parent[s.key]
|
||||||
|
s.parent[s.key] = map[string]any{"$ref": "#/components/" + kind + "/" + name}
|
||||||
|
}
|
||||||
|
if len(shared) > 0 {
|
||||||
|
components[kind] = shared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share whole responses before counting inner objects. Otherwise a catalog
|
||||||
|
// copied across 100 routes can create hundreds of unnecessary components
|
||||||
|
// that only one shared response ultimately uses.
|
||||||
|
factor("responses", responses)
|
||||||
|
collectMedia := func(value any) {
|
||||||
|
resp := value.(map[string]any)
|
||||||
|
content, _ := resp["content"].(map[string]any)
|
||||||
|
for _, value := range content {
|
||||||
|
media := value.(map[string]any)
|
||||||
|
if media["schema"] != nil {
|
||||||
|
schemas = append(schemas, slot{media, "schema"})
|
||||||
|
}
|
||||||
|
es, _ := media["examples"].(map[string]any)
|
||||||
|
for name := range es {
|
||||||
|
examples = append(examples, slot{es, name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range responses {
|
||||||
|
collectMedia(s.parent[s.key])
|
||||||
|
}
|
||||||
|
sharedResponses, _ := components["responses"].(map[string]any)
|
||||||
|
for _, resp := range sharedResponses {
|
||||||
|
collectMedia(resp)
|
||||||
|
}
|
||||||
|
factor("examples", examples)
|
||||||
|
factor("schemas", schemas)
|
||||||
|
}
|
||||||
107
cmd/go-omni/apigen/documentRefs_test.go
Normal file
107
cmd/go-omni/apigen/documentRefs_test.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSharedErrorDocumentReferences(t *testing.T) {
|
||||||
|
source := `@app(defaultResponse: Common)
|
||||||
|
response Common(codeType:int32;successCode:0;errorCode:9999;messageField:msg;dataMode:always)
|
||||||
|
service demo {
|
||||||
|
`
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
kind := "string"
|
||||||
|
if i%2 == 0 {
|
||||||
|
kind = "int64"
|
||||||
|
}
|
||||||
|
source += fmt.Sprintf("@handler list%d\nget /list%d returns ([]%s)\n", i, i, kind)
|
||||||
|
}
|
||||||
|
source += "}\n"
|
||||||
|
spec, err := parseText(t, source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog := apierrors.Catalog{DefaultStatus: 400, Definitions: map[string]apierrors.Definition{}}
|
||||||
|
for i := 0; i < 80; i++ {
|
||||||
|
status := 400
|
||||||
|
if i%2 == 0 {
|
||||||
|
status = 200
|
||||||
|
}
|
||||||
|
catalog.Definitions[fmt.Sprintf("ERROR_%d", i)] = apierrors.Definition{Status: status, Code: 400000 + i, Message: strings.Repeat("说明", 40) + "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string", Example: "企业名称"}, {Name: "max", Type: "int", Example: 100}}}
|
||||||
|
}
|
||||||
|
spec.DocumentCatalog = &catalog
|
||||||
|
compiled, err := Compile(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var raw map[string]any
|
||||||
|
if err = json.Unmarshal(compiled.Document, &raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
components := raw["components"].(map[string]any)
|
||||||
|
for _, kind := range []string{"responses", "examples"} {
|
||||||
|
if len(components[kind].(map[string]any)) == 0 {
|
||||||
|
t.Fatal("missing shared", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expanded := document(t, compiled)
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
responses := operation(t, expanded, fmt.Sprintf("/list%d", i), "get")["responses"].(map[string]any)
|
||||||
|
for _, status := range []string{"200", "400"} {
|
||||||
|
media := responses[status].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
|
||||||
|
if len(media["examples"].(map[string]any)) != 40 {
|
||||||
|
t.Fatal("examples lost")
|
||||||
|
}
|
||||||
|
if status == "200" {
|
||||||
|
choices := media["schema"].(map[string]any)["anyOf"].([]any)
|
||||||
|
if len(choices) != 2 || choices[0].(map[string]any)["properties"].(map[string]any)["data"].(map[string]any)["type"] != "array" {
|
||||||
|
t.Fatal("200 success shape lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, example := range media["examples"].(map[string]any) {
|
||||||
|
value := example.(map[string]any)["value"].(map[string]any)
|
||||||
|
if _, ok := value["data"]; !ok || value["data"] != nil || !strings.Contains(value["msg"].(string), "企业名称不能超过100个字符") {
|
||||||
|
t.Fatal(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expandedBytes, _ := json.MarshalIndent(expanded, "", " ")
|
||||||
|
if len(compiled.Document)*5 > len(expandedBytes) {
|
||||||
|
t.Fatalf("poor dedup: %d vs %d", len(compiled.Document), len(expandedBytes))
|
||||||
|
}
|
||||||
|
again, err := Compile(spec)
|
||||||
|
if err != nil || string(again.Document) != string(compiled.Document) {
|
||||||
|
t.Fatal("non-deterministic document", err)
|
||||||
|
}
|
||||||
|
before, _ := json.Marshal(raw)
|
||||||
|
compactDocument(raw)
|
||||||
|
data, _ := json.Marshal(raw)
|
||||||
|
if string(data) != string(before) {
|
||||||
|
t.Fatal("compaction not idempotent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedSchemasAndLiteralExampleReference(t *testing.T) {
|
||||||
|
doc := map[string]any{"components": map[string]any{}, "paths": map[string]any{}}
|
||||||
|
paths := doc["paths"].(map[string]any)
|
||||||
|
for _, name := range []string{"first", "second", "third"} {
|
||||||
|
schema := map[string]any{"type": "object", "description": strings.Repeat("schema description ", 40)}
|
||||||
|
example := map[string]any{"summary": strings.Repeat("example ", 50), "value": map[string]any{"$ref": "literal business field"}}
|
||||||
|
paths["/"+name] = map[string]any{"get": map[string]any{"responses": map[string]any{"200": map[string]any{"description": name, "content": map[string]any{"application/json": map[string]any{"schema": schema, "examples": map[string]any{"same": example}}}}}}}
|
||||||
|
}
|
||||||
|
compactDocument(doc)
|
||||||
|
components := doc["components"].(map[string]any)
|
||||||
|
if len(components["schemas"].(map[string]any)) != 1 {
|
||||||
|
t.Fatal("schema not shared")
|
||||||
|
}
|
||||||
|
for _, example := range components["examples"].(map[string]any) {
|
||||||
|
if example.(map[string]any)["value"].(map[string]any)["$ref"] != "literal business field" {
|
||||||
|
t.Fatal("payload reference interpreted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,11 +19,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||||
const minimumVersion = "v0.14.0-preview"
|
const minimumVersion = "v0.18.0-preview"
|
||||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
|
generation GeneratorConfig
|
||||||
lockHeld bool
|
lockHeld bool
|
||||||
|
documentCatalogs map[string][]byte
|
||||||
Database, Storage string
|
Database, Storage string
|
||||||
API, Dir, Module, FrameworkDir, FrameworkVersion, LogOutput, LogFormat string
|
API, Dir, Module, FrameworkDir, FrameworkVersion, LogOutput, LogFormat string
|
||||||
Check bool
|
Check bool
|
||||||
@@ -48,7 +50,7 @@ func Generate(o Options) (Output, error) {
|
|||||||
if o.API == "" || o.Dir == "" {
|
if o.API == "" || o.Dir == "" {
|
||||||
return Output{}, errors.New("--api and --dir are required")
|
return Output{}, errors.New("--api and --dir are required")
|
||||||
}
|
}
|
||||||
spec, err := Parse(o.API)
|
spec, err := parseWithCatalogs(o.API, o.documentCatalogs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Output{}, err
|
return Output{}, err
|
||||||
}
|
}
|
||||||
@@ -96,6 +98,11 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
|||||||
return Output{}, err
|
return Output{}, err
|
||||||
}
|
}
|
||||||
o.Dir = directory
|
o.Dir = directory
|
||||||
|
genConfig, hasGenConfig, err := readGeneratorConfig(directory)
|
||||||
|
if err != nil {
|
||||||
|
return Output{}, err
|
||||||
|
}
|
||||||
|
o.generation = genConfig
|
||||||
if err = checkRuntimeExtension(directory); err != nil {
|
if err = checkRuntimeExtension(directory); err != nil {
|
||||||
return Output{}, err
|
return Output{}, err
|
||||||
}
|
}
|
||||||
@@ -145,13 +152,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
|||||||
if !filepath.IsAbs(path) {
|
if !filepath.IsAbs(path) {
|
||||||
path = filepath.Join(directory, path)
|
path = filepath.Join(directory, path)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(path, "lifecycle", "runtime.go")); err == nil {
|
if source, err := os.ReadFile(filepath.Join(path, "config", "env.go")); err == nil && strings.Contains(string(source), "func ServerEnvAliases(") {
|
||||||
localCurrent = true
|
localCurrent = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !localCurrent {
|
if !localCurrent {
|
||||||
return Output{}, fmt.Errorf("existing framework dependency predates the generated Runtime, request policy and structured MySQL support; use %s or a current local replacement", minimumVersion)
|
return Output{}, fmt.Errorf("existing framework dependency predates the generated configuration helpers; use %s or a current local replacement", minimumVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if o.FrameworkDir != "" {
|
if o.FrameworkDir != "" {
|
||||||
@@ -191,6 +198,10 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
|||||||
for name, data := range base {
|
for name, data := range base {
|
||||||
seeds[name] = data
|
seeds[name] = data
|
||||||
}
|
}
|
||||||
|
if !hasGenConfig {
|
||||||
|
raw, _ := json.MarshalIndent(genConfig, "", " ")
|
||||||
|
seeds[generatorConfigFile] = append(raw, '\n')
|
||||||
|
}
|
||||||
// The API source remains developer-owned. A relocated project regenerates
|
// The API source remains developer-owned. A relocated project regenerates
|
||||||
// from its own api/ tree, including all imports.
|
// from its own api/ tree, including all imports.
|
||||||
entry := "api/" + c.Spec.Entry
|
entry := "api/" + c.Spec.Entry
|
||||||
@@ -241,6 +252,18 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := renderMappings(o, generated, seeds); err != nil {
|
||||||
|
return Output{}, err
|
||||||
|
}
|
||||||
|
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)
|
out, err := apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec), o.lockHeld)
|
||||||
expectedVersion := minimumVersion
|
expectedVersion := minimumVersion
|
||||||
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) {
|
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) {
|
||||||
@@ -454,7 +477,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
hasAutomatic = true
|
hasAutomatic = true
|
||||||
needsTypes = needsTypes || r.Request != "" || responseGoType(r.Response) != ""
|
needsTypes = needsTypes || r.Request != "" || responseNeedsTypes(r.Response)
|
||||||
needsBinder = needsBinder || !r.CustomBinding
|
needsBinder = needsBinder || !r.CustomBinding
|
||||||
}
|
}
|
||||||
text += fmt.Sprintf("%q;%q;", frameworkModule+"/httpx", o.Module+"/internal/svc")
|
text += fmt.Sprintf("%q;%q;", frameworkModule+"/httpx", o.Module+"/internal/svc")
|
||||||
@@ -570,7 +593,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
service := fmt.Sprintf("package %s\nimport(\"context\"; apierrors %q;", packageName, frameworkModule+"/errors")
|
service := fmt.Sprintf("package %s\nimport(\"context\"; apierrors %q;", packageName, frameworkModule+"/errors")
|
||||||
if r.Request != "" || responseGoType(r.Response) != "" {
|
if r.Request != "" || responseNeedsTypes(r.Response) {
|
||||||
service += fmt.Sprintf("%q;", o.Module+"/internal/types")
|
service += fmt.Sprintf("%q;", o.Module+"/internal/types")
|
||||||
}
|
}
|
||||||
if streamingBody || r.Produces != "" {
|
if streamingBody || r.Produces != "" {
|
||||||
@@ -601,7 +624,9 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
|||||||
}
|
}
|
||||||
put(generated, "internal/handler/"+group+"/handlersGen.go", generatedHeader+text)
|
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"))
|
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"))
|
||||||
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")
|
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")
|
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"
|
registry := "package middleware\nimport " + strconv.Quote(frameworkModule+"/httpx") + "\nfunc Defaults()map[string]httpx.Middleware{return map[string]httpx.Middleware{\n"
|
||||||
@@ -610,6 +635,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(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")
|
put(generated, "internal/middleware/registryGen.go", generatedHeader+registry+"}}\n")
|
||||||
|
if o.generation.Middleware == "factories" {
|
||||||
|
renderMiddlewareFactories(middlewareNames, generated, seeds)
|
||||||
|
}
|
||||||
for name, source := range baseTemplates {
|
for name, source := range baseTemplates {
|
||||||
content := strings.ReplaceAll(source, "MODULE", o.Module)
|
content := strings.ReplaceAll(source, "MODULE", o.Module)
|
||||||
content = strings.ReplaceAll(content, "LOGFORMAT", o.LogFormat)
|
content = strings.ReplaceAll(content, "LOGFORMAT", o.LogFormat)
|
||||||
@@ -629,6 +657,16 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
for name, data := range infra {
|
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") {
|
if strings.HasSuffix(name, "Gen.go") {
|
||||||
generated[name] = append([]byte(generatedHeader), data...)
|
generated[name] = append([]byte(generatedHeader), data...)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
149
cmd/go-omni/apigen/generatorConfig.go
Normal file
149
cmd/go-omni/apigen/generatorConfig.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
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 is developer-owned and checked on every generation. Mapping
|
||||||
|
// rules never change the public API or infer a persistence model.
|
||||||
|
type GeneratorConfig struct {
|
||||||
|
Repository string `json:"repository,omitempty"`
|
||||||
|
Middleware string `json:"middleware,omitempty"`
|
||||||
|
Mappings []Mapping `json:"mappings,omitempty"`
|
||||||
|
MappingFiles []string `json:"mappingFiles,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 and mappings 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 := readMappingConfigFile(root, generatorConfigFile)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, false, err
|
||||||
|
}
|
||||||
|
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
|
||||||
|
return cfg, false, fmt.Errorf("omni.gen.json: %w", err)
|
||||||
|
}
|
||||||
|
// Child files only declare mappings. No recursive imports, globs, implicit
|
||||||
|
// directory scans or access outside the project are supported.
|
||||||
|
if len(cfg.MappingFiles) > 64 {
|
||||||
|
return cfg, false, errors.New("omni.gen.json: at most 64 mappingFiles supported")
|
||||||
|
}
|
||||||
|
digest := sha256.New()
|
||||||
|
fmt.Fprintf(digest, "%s:%d:", generatorConfigFile, len(raw))
|
||||||
|
digest.Write(raw)
|
||||||
|
total := len(raw)
|
||||||
|
for i := range cfg.Mappings {
|
||||||
|
cfg.Mappings[i].origin = generatorConfigFile
|
||||||
|
}
|
||||||
|
seen := map[string]bool{generatorConfigFile: true}
|
||||||
|
for _, name := range cfg.MappingFiles {
|
||||||
|
key := strings.ToLower(name)
|
||||||
|
if seen[key] {
|
||||||
|
return cfg, false, fmt.Errorf("omni.gen.json: duplicate mapping file %q", name)
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
if !strings.HasSuffix(name, ".json") {
|
||||||
|
return cfg, false, fmt.Errorf("mapping file %q must have a .json extension", name)
|
||||||
|
}
|
||||||
|
data, err := readMappingConfigFile(root, name)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, false, err
|
||||||
|
}
|
||||||
|
total += len(data)
|
||||||
|
if total > 4<<20 {
|
||||||
|
return cfg, false, errors.New("mapping configuration exceeds 4 MiB in total")
|
||||||
|
}
|
||||||
|
var file struct {
|
||||||
|
Mappings []Mapping `json:"mappings"`
|
||||||
|
}
|
||||||
|
if err = apierrors.DecodeCatalogJSON(data, &file); err != nil {
|
||||||
|
return cfg, false, fmt.Errorf("%s: %w (mapping files only support mappings; nested imports are not supported)", name, err)
|
||||||
|
}
|
||||||
|
for i := range file.Mappings {
|
||||||
|
file.Mappings[i].origin = name
|
||||||
|
}
|
||||||
|
cfg.Mappings = append(cfg.Mappings, file.Mappings...)
|
||||||
|
fmt.Fprintf(digest, "%s:%d:", name, len(data))
|
||||||
|
digest.Write(data)
|
||||||
|
}
|
||||||
|
cfg.inputDigest = fmt.Sprintf("%x", digest.Sum(nil))
|
||||||
|
if cfg.Repository != "none" && cfg.Repository != "group" {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
if len(cfg.Mappings) > 128 {
|
||||||
|
return cfg, false, errors.New("omni.gen.json: at most 128 mappings supported")
|
||||||
|
}
|
||||||
|
return cfg, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read through a bounded reader as well as checking stat: a growing file cannot
|
||||||
|
// make configuration loading unbounded. safePath also rejects symlink escapes.
|
||||||
|
func readMappingConfigFile(root, name string) ([]byte, error) {
|
||||||
|
if err := safePath(root, name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f, err := os.Open(filepath.Join(root, filepath.FromSlash(name)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
stat, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !stat.Mode().IsRegular() || stat.Size() > 1<<20 {
|
||||||
|
return nil, fmt.Errorf("%s must be a regular file of at most 1 MiB", name)
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(raw) > 1<<20 {
|
||||||
|
return nil, fmt.Errorf("%s exceeds 1 MiB", name)
|
||||||
|
}
|
||||||
|
if trimmed := strings.TrimSpace(string(raw)); !strings.HasPrefix(trimmed, "{") {
|
||||||
|
return nil, fmt.Errorf("%s must contain a JSON object", name)
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
141
cmd/go-omni/apigen/generatorConfig_test.go
Normal file
141
cmd/go-omni/apigen/generatorConfig_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMappingFileConfiguration(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
defs := nullableMappings()
|
||||||
|
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", Mappings: defs[:1], MappingFiles: []string{"mappings/a.json", "mappings/b.json"}})
|
||||||
|
writeMappingFile(t, dir, "mappings/a.json", defs[1:5])
|
||||||
|
writeMappingFile(t, dir, "mappings/b.json", defs[5:])
|
||||||
|
first, exists, err := readGeneratorConfig(dir)
|
||||||
|
if err != nil || !exists || len(first.Mappings) != len(defs) {
|
||||||
|
t.Fatal(first, err)
|
||||||
|
}
|
||||||
|
for i, m := range first.Mappings {
|
||||||
|
if m.Name != defs[i].Name {
|
||||||
|
t.Fatal("non-deterministic load order")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
second, _, err := readGeneratorConfig(dir)
|
||||||
|
if err != nil || first.inputDigest != second.inputDigest {
|
||||||
|
t.Fatal("unstable config digest", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "mappings/a.json")
|
||||||
|
writeFile(t, path, readFile(t, path)+"\n")
|
||||||
|
changed, _, err := readGeneratorConfig(dir)
|
||||||
|
if err != nil || changed.inputDigest == first.inputDigest {
|
||||||
|
t.Fatal("child file change invisible to generation guard", err)
|
||||||
|
}
|
||||||
|
// Duplicate definitions name both files instead of silently overriding.
|
||||||
|
writeMappingFile(t, dir, "mappings/b.json", defs[1:2])
|
||||||
|
cfg, _, err := readGeneratorConfig(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = validateMappings(cfg.Mappings, "example.com/nullable")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "mappings/a.json") || !strings.Contains(err.Error(), "mappings/b.json") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMappingFileRejection(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ name, root, child, want string }{
|
||||||
|
{"missing", `{"mappingFiles":["maps.json"]}`, "", "maps.json"},
|
||||||
|
{"escape", `{"mappingFiles":["../maps.json"]}`, "", "unsafe"},
|
||||||
|
{"absolute", `{"mappingFiles":["/maps.json"]}`, "", "unsafe"},
|
||||||
|
{"nonCanonical", `{"mappingFiles":["sub/../maps.json"]}`, "", "unsafe"},
|
||||||
|
{"duplicate", `{"mappingFiles":["maps.json","maps.json"]}`, `{"mappings":[]}`, "duplicate mapping file"},
|
||||||
|
{"caseDuplicate", `{"mappingFiles":["maps.json","MAPS.json"]}`, `{"mappings":[]}`, "duplicate mapping file"},
|
||||||
|
{"self", `{"mappingFiles":["omni.gen.json"]}`, "", "duplicate mapping file"},
|
||||||
|
{"recursive", `{"mappingFiles":["maps.json"]}`, `{"mappingFiles":["omni.gen.json"]}`, "nested imports are not supported"},
|
||||||
|
{"childSettings", `{"mappingFiles":["maps.json"]}`, `{"repository":"none","mappings":[]}`, "maps.json"},
|
||||||
|
{"duplicateKeys", `{"mappingFiles":["maps.json"]}`, `{"mappings":[],"mappings":[]}`, "duplicate key"},
|
||||||
|
{"rootNull", `null`, "", "JSON object"},
|
||||||
|
{"childNull", `{"mappingFiles":["maps.json"]}`, `null`, "JSON object"},
|
||||||
|
{"unknownPolicy", `{"mappingFiles":["maps.json"]}`, `{"mappings":[{"unknown":true}]}`, "unknown field"},
|
||||||
|
{"trailingValue", `{"mappingFiles":["maps.json"]}`, `{"mappings":[]} {}`, "maps.json"},
|
||||||
|
{"tooLarge", `{"mappingFiles":["maps.json"]}`, strings.Repeat(" ", 1<<20) + `{}`, "1 MiB"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(dir, generatorConfigFile), tc.root)
|
||||||
|
if tc.child != "" {
|
||||||
|
writeFile(t, filepath.Join(dir, "maps.json"), tc.child)
|
||||||
|
}
|
||||||
|
before := snapshot(t, dir)
|
||||||
|
_, _, err := readGeneratorConfig(dir)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("read changed configuration")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, directory := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("symlinkDirectory%t", directory), func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
writeMappingFile(t, outside, "maps.json", nil)
|
||||||
|
name := "maps.json"
|
||||||
|
target := filepath.Join(outside, name)
|
||||||
|
link := filepath.Join(dir, name)
|
||||||
|
if directory {
|
||||||
|
name = "linked/maps.json"
|
||||||
|
target = outside
|
||||||
|
link = filepath.Join(dir, "linked")
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: []string{name}})
|
||||||
|
if _, _, err := readGeneratorConfig(dir); err == nil || !strings.Contains(err.Error(), "symlink") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMappingFileBounds(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
files := make([]string, 65)
|
||||||
|
for i := range files {
|
||||||
|
files[i] = fmt.Sprintf("maps%d.json", i)
|
||||||
|
}
|
||||||
|
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: files})
|
||||||
|
if _, _, err := readGeneratorConfig(dir); err == nil || !strings.Contains(err.Error(), "64") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: files[:4]})
|
||||||
|
for _, name := range files[:4] {
|
||||||
|
writeFile(t, filepath.Join(dir, name), `{"mappings":[]}`+strings.Repeat(" ", (1<<20)-len(`{"mappings":[]}`)))
|
||||||
|
}
|
||||||
|
if _, _, err := readGeneratorConfig(dir); err == nil || !strings.Contains(err.Error(), "4 MiB") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The definition limit applies after merging, not separately to each file.
|
||||||
|
defs := make([]Mapping, 129)
|
||||||
|
for i := range defs {
|
||||||
|
defs[i] = nullableMappings()[0]
|
||||||
|
defs[i].Name = fmt.Sprintf("Map%d", i)
|
||||||
|
}
|
||||||
|
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: files[:2]})
|
||||||
|
writeMappingFile(t, dir, files[0], defs[:65])
|
||||||
|
writeMappingFile(t, dir, files[1], defs[65:])
|
||||||
|
if _, _, err := readGeneratorConfig(dir); err == nil || !strings.Contains(err.Error(), "128") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Origins/digests are loader metadata, never serialized into user configuration.
|
||||||
|
raw, err := json.Marshal(GeneratorConfig{inputDigest: "private"})
|
||||||
|
if err != nil || strings.Contains(string(raw), "private") {
|
||||||
|
t.Fatal(string(raw), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ func TestManagedGeneratedProject(t *testing.T) {
|
|||||||
files := map[string]string{
|
files := map[string]string{
|
||||||
"consumer_test.go": "consumer_test.go", "internal/svc/options.go": "options.go", "internal/svc/client.go": "client.go", "internal/logic/demo/check.go": "operations.go",
|
"consumer_test.go": "consumer_test.go", "internal/svc/options.go": "options.go", "internal/svc/client.go": "client.go", "internal/logic/demo/check.go": "operations.go",
|
||||||
"shutdown_test.go": "shutdown_test.go",
|
"shutdown_test.go": "shutdown_test.go",
|
||||||
|
"phases_test.go": "phases_test.go", "internal/svc/background.go": "background.go",
|
||||||
}
|
}
|
||||||
for path, fixture := range files {
|
for path, fixture := range files {
|
||||||
writeFile(t, filepath.Join(dir, path), readFile(t, filepath.Join("testdata/managed", fixture)))
|
writeFile(t, filepath.Join(dir, path), readFile(t, filepath.Join("testdata/managed", fixture)))
|
||||||
@@ -79,7 +80,7 @@ func TestManagedGeneratedProject(t *testing.T) {
|
|||||||
writeFile(t, filepath.Join(dir, "internal/logic/demo", name+".go"), "package demo\n")
|
writeFile(t, filepath.Join(dir, "internal/logic/demo", name+".go"), "package demo\n")
|
||||||
}
|
}
|
||||||
configPath := filepath.Join(dir, "internal/config/config.go")
|
configPath := filepath.Join(dir, "internal/config/config.go")
|
||||||
writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n FailInit bool `json:\"failInit\"`\n ShutdownInit bool `json:\"shutdownInit\"`", 1))
|
writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n BackgroundMode string `json:\"backgroundMode\"`\n FailInit bool `json:\"failInit\"`\n ShutdownInit bool `json:\"shutdownInit\"`", 1))
|
||||||
svcPath := filepath.Join(dir, "internal/svc/serviceContext.go")
|
svcPath := filepath.Join(dir, "internal/svc/serviceContext.go")
|
||||||
source := strings.Replace(readFile(t, svcPath), "type ServiceContext struct {", "type ServiceContext struct {\n Client *Client", 1)
|
source := strings.Replace(readFile(t, svcPath), "type ServiceContext struct {", "type ServiceContext struct {\n Client *Client", 1)
|
||||||
source = strings.Replace(source, "return nil", "return initClient(ctx,c)", 1)
|
source = strings.Replace(source, "return nil", "return initClient(ctx,c)", 1)
|
||||||
@@ -169,12 +170,15 @@ func generateManagedTestProject(t *testing.T, o Options) error {
|
|||||||
_, err := Generate(o)
|
_, err := Generate(o)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
args := []string{"api", "generate", "--api", o.API, "--dir", o.Dir, "--module", o.Module}
|
args := []string{"api", "generate", "--api", o.API, "--dir", o.Dir, "--module", o.Module}
|
||||||
if o.Check {
|
if o.Check {
|
||||||
args = append(args, "--check")
|
args = append(args, "--check")
|
||||||
}
|
}
|
||||||
|
if o.DryRun {
|
||||||
|
args = append(args, "--dry-run")
|
||||||
|
}
|
||||||
cmd := exec.CommandContext(ctx, cli, args...)
|
cmd := exec.CommandContext(ctx, cli, args...)
|
||||||
cmd.Dir = filepath.Dir(o.Dir)
|
cmd.Dir = filepath.Dir(o.Dir)
|
||||||
if data, err := cmd.CombinedOutput(); err != nil {
|
if data, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
|||||||
453
cmd/go-omni/apigen/mapping.go
Normal file
453
cmd/go-omni/apigen/mapping.go
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/format"
|
||||||
|
goparser "go/parser"
|
||||||
|
gotoken "go/token"
|
||||||
|
"go/types"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/mod/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GoTypeRef names an existing, exported, non-generic Go struct.
|
||||||
|
type GoTypeRef struct {
|
||||||
|
Package string `json:"package"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
type GoFunctionRef struct {
|
||||||
|
Package string `json:"package"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
type MappingField struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Convert *GoFunctionRef `json:"convert,omitempty"`
|
||||||
|
Mapping string `json:"mapping,omitempty"`
|
||||||
|
Elements bool `json:"elements,omitempty"`
|
||||||
|
Nil string `json:"nil,omitempty"`
|
||||||
|
}
|
||||||
|
type Mapping struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
PointerResult bool `json:"pointerResult,omitempty"`
|
||||||
|
origin string
|
||||||
|
Source GoTypeRef `json:"source"`
|
||||||
|
Target GoTypeRef `json:"target"`
|
||||||
|
Fields map[string]MappingField `json:"fields,omitempty"`
|
||||||
|
IgnoreSource map[string]string `json:"ignoreSource,omitempty"`
|
||||||
|
IgnoreTarget map[string]string `json:"ignoreTarget,omitempty"`
|
||||||
|
}
|
||||||
|
type mappingField struct {
|
||||||
|
path, name string
|
||||||
|
typ types.Type
|
||||||
|
}
|
||||||
|
type mappingTypes struct{ source, target types.Type }
|
||||||
|
type mappingRenderer struct {
|
||||||
|
importer types.Importer
|
||||||
|
imports map[string]string
|
||||||
|
definitions map[string]Mapping
|
||||||
|
types map[string]mappingTypes
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMappings(o Options, generated, seeds map[string][]byte) error {
|
||||||
|
if len(o.generation.Mappings) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
refs, err := validateMappings(o.generation.Mappings, o.Module)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
importer, close, err := loadMappingTypes(o, refs, generated, seeds)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer close()
|
||||||
|
r := mappingRenderer{importer: importer, imports: map[string]string{}, definitions: map[string]Mapping{}, types: map[string]mappingTypes{}}
|
||||||
|
for _, m := range o.generation.Mappings {
|
||||||
|
source, err := r.lookup(m.Source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target, err := r.lookup(m.Target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.definitions[m.Name] = m
|
||||||
|
r.types[m.Name] = mappingTypes{source, target}
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(r.definitions))
|
||||||
|
for name := range r.definitions {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
var body strings.Builder
|
||||||
|
for _, name := range names {
|
||||||
|
source, err := r.render(r.definitions[name])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: mapping %s: %w", mappingOrigin(r.definitions[name]), name, err)
|
||||||
|
}
|
||||||
|
body.WriteString(source)
|
||||||
|
}
|
||||||
|
imports := make([]string, 0, len(r.imports))
|
||||||
|
for path := range r.imports {
|
||||||
|
imports = append(imports, path)
|
||||||
|
}
|
||||||
|
sort.Strings(imports)
|
||||||
|
text := generatedHeader + "package mapping\nimport(\n"
|
||||||
|
for _, path := range imports {
|
||||||
|
text += r.imports[path] + " " + strconv.Quote(path) + "\n"
|
||||||
|
}
|
||||||
|
text += ")\n" + body.String()
|
||||||
|
code, err := format.Source([]byte(text))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("format mappings: %w", err)
|
||||||
|
}
|
||||||
|
// The generated package is type-checked before it is written. No application
|
||||||
|
// code (including init or converters) is executed during generation.
|
||||||
|
fset := gotoken.NewFileSet()
|
||||||
|
file, err := goparser.ParseFile(fset, "mappingsGen.go", code, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
check := types.Config{Importer: importer}
|
||||||
|
if _, err = check.Check(o.Module+"/internal/mapping", fset, []*ast.File{file}, nil); err != nil {
|
||||||
|
return fmt.Errorf("generated mapping type check: %w", err)
|
||||||
|
}
|
||||||
|
generated["internal/mapping/mappingsGen.go"] = code
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMappings(defs []Mapping, project string) ([]string, error) {
|
||||||
|
refs := map[string]bool{}
|
||||||
|
names := map[string]string{}
|
||||||
|
validPackage := func(path string) bool {
|
||||||
|
if module.CheckImportPath(path) != nil || path == project+"/internal/mapping" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// go/types does not enforce the compiler's internal import visibility.
|
||||||
|
if strings.HasPrefix(path, "internal/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if i := strings.LastIndex(path, "/internal/"); i >= 0 {
|
||||||
|
return strings.HasPrefix(project+"/internal/mapping", path[:i]+"/")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, m := range defs {
|
||||||
|
if !gotoken.IsIdentifier(m.Name) || !gotoken.IsExported(m.Name) {
|
||||||
|
return nil, fmt.Errorf("mapping name %q must be unique and exported", m.Name)
|
||||||
|
}
|
||||||
|
functions := []string{m.Name}
|
||||||
|
if m.PointerResult {
|
||||||
|
functions = append(functions, m.Name+"Ptr")
|
||||||
|
}
|
||||||
|
for _, name := range functions {
|
||||||
|
if prior, exists := names[name]; exists {
|
||||||
|
return nil, fmt.Errorf("mapping function %q must be unique: %s conflicts with %s", name, mappingOrigin(m), prior)
|
||||||
|
}
|
||||||
|
names[name] = mappingOrigin(m)
|
||||||
|
}
|
||||||
|
for _, ref := range []GoTypeRef{m.Source, m.Target} {
|
||||||
|
if !validPackage(ref.Package) || !gotoken.IsIdentifier(ref.Type) || !gotoken.IsExported(ref.Type) {
|
||||||
|
return nil, fmt.Errorf("mapping %s: invalid Go type reference", m.Name)
|
||||||
|
}
|
||||||
|
refs[ref.Package] = true
|
||||||
|
}
|
||||||
|
if len(m.Fields) > 4096 || len(m.IgnoreSource) > 4096 || len(m.IgnoreTarget) > 4096 {
|
||||||
|
return nil, fmt.Errorf("mapping %s: field rule limit exceeded", m.Name)
|
||||||
|
}
|
||||||
|
for target, f := range m.Fields {
|
||||||
|
if target == "" || f.Source == "" {
|
||||||
|
return nil, fmt.Errorf("mapping %s: explicit field rule requires target and source paths", m.Name)
|
||||||
|
}
|
||||||
|
if f.Nil != "" && f.Nil != "preserve" && f.Nil != "zero" && f.Nil != "error" {
|
||||||
|
return nil, fmt.Errorf("mapping %s.%s: nil must be preserve, zero or error", m.Name, target)
|
||||||
|
}
|
||||||
|
if f.Convert != nil && f.Nil != "" {
|
||||||
|
return nil, fmt.Errorf("mapping %s.%s: nil policy cannot be combined with a converter; the converter owns nil behavior", m.Name, target)
|
||||||
|
}
|
||||||
|
if f.Convert != nil {
|
||||||
|
if f.Mapping != "" || !validPackage(f.Convert.Package) || !gotoken.IsIdentifier(f.Convert.Name) || !gotoken.IsExported(f.Convert.Name) {
|
||||||
|
return nil, fmt.Errorf("mapping %s.%s: invalid or conflicting converter", m.Name, target)
|
||||||
|
}
|
||||||
|
refs[f.Convert.Package] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visiting, done := map[string]bool{}, map[string]bool{}
|
||||||
|
byName := map[string]Mapping{}
|
||||||
|
for _, m := range defs {
|
||||||
|
byName[m.Name] = m
|
||||||
|
}
|
||||||
|
var visit func(string) error
|
||||||
|
visit = func(name string) error {
|
||||||
|
if done[name] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if visiting[name] {
|
||||||
|
return fmt.Errorf("mapping cycle at %s", name)
|
||||||
|
}
|
||||||
|
m, ok := byName[name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown mapping %s", name)
|
||||||
|
}
|
||||||
|
visiting[name] = true
|
||||||
|
for _, f := range m.Fields {
|
||||||
|
if f.Mapping != "" {
|
||||||
|
if err := visit(f.Mapping); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(visiting, name)
|
||||||
|
done[name] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, m := range defs {
|
||||||
|
if err := visit(m.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(refs))
|
||||||
|
for path := range refs {
|
||||||
|
out = append(out, path)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mappingRenderer) qualifier(p *types.Package) string {
|
||||||
|
if alias, ok := r.imports[p.Path()]; ok {
|
||||||
|
return alias
|
||||||
|
}
|
||||||
|
alias := fmt.Sprintf("pkg%d", len(r.imports))
|
||||||
|
r.imports[p.Path()] = alias
|
||||||
|
return alias
|
||||||
|
}
|
||||||
|
func (r *mappingRenderer) typeName(t types.Type) string { return types.TypeString(t, r.qualifier) }
|
||||||
|
func (r *mappingRenderer) lookup(ref GoTypeRef) (types.Type, error) {
|
||||||
|
pkg, err := r.importer.Import(ref.Package)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mapping type %s.%s: %w", ref.Package, ref.Type, err)
|
||||||
|
}
|
||||||
|
obj, ok := pkg.Scope().Lookup(ref.Type).(*types.TypeName)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("mapping type %s.%s not found", ref.Package, ref.Type)
|
||||||
|
}
|
||||||
|
typ := obj.Type()
|
||||||
|
if n, ok := typ.(*types.Named); ok && n.TypeParams() != nil && n.TypeParams().Len() > 0 {
|
||||||
|
return nil, fmt.Errorf("mapping root %s must be a non-generic struct", ref.Type)
|
||||||
|
}
|
||||||
|
if _, ok := typ.Underlying().(*types.Struct); !ok {
|
||||||
|
return nil, fmt.Errorf("mapping root %s must be a struct", ref.Type)
|
||||||
|
}
|
||||||
|
return typ, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mappingFields(typ types.Type) (map[string]mappingField, error) {
|
||||||
|
out := map[string]mappingField{}
|
||||||
|
leaves := map[string]string{}
|
||||||
|
var walk func(types.Type, string, int) error
|
||||||
|
walk = func(typ types.Type, prefix string, depth int) error {
|
||||||
|
if depth > 32 {
|
||||||
|
return fmt.Errorf("embedded field nesting exceeds 32")
|
||||||
|
}
|
||||||
|
st, ok := typ.Underlying().(*types.Struct)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("embedded field %s must be a value struct", prefix)
|
||||||
|
}
|
||||||
|
for i := 0; i < st.NumFields(); i++ {
|
||||||
|
f := st.Field(i)
|
||||||
|
path := prefix + f.Name()
|
||||||
|
if !f.Exported() {
|
||||||
|
return fmt.Errorf("unexported field %s cannot be mapped; use an explicit converter on the containing value", path)
|
||||||
|
}
|
||||||
|
if f.Embedded() {
|
||||||
|
if err := walk(f.Type(), path+".", depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if previous, exists := leaves[f.Name()]; exists {
|
||||||
|
return fmt.Errorf("ambiguous embedded fields %s and %s", previous, path)
|
||||||
|
}
|
||||||
|
leaves[f.Name()] = path
|
||||||
|
out[path] = mappingField{path, f.Name(), f.Type()}
|
||||||
|
if len(out) > 4096 {
|
||||||
|
return fmt.Errorf("mapping field limit exceeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := walk(typ, "", 0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mappingRenderer) render(m Mapping) (string, error) {
|
||||||
|
mt := r.types[m.Name]
|
||||||
|
source, err := mappingFields(mt.source)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
target, err := mappingFields(mt.target)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for key, reason := range m.IgnoreSource {
|
||||||
|
if _, ok := source[key]; !ok || strings.TrimSpace(reason) == "" {
|
||||||
|
return "", fmt.Errorf("ignoreSource %s requires an existing field and reason", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, reason := range m.IgnoreTarget {
|
||||||
|
if _, ok := target[key]; !ok || strings.TrimSpace(reason) == "" {
|
||||||
|
return "", fmt.Errorf("ignoreTarget %s requires an existing field and reason", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key := range m.Fields {
|
||||||
|
if _, ok := target[key]; !ok {
|
||||||
|
return "", fmt.Errorf("unknown target field %s", key)
|
||||||
|
}
|
||||||
|
if _, ignored := m.IgnoreTarget[key]; ignored {
|
||||||
|
return "", fmt.Errorf("target %s is both mapped and ignored", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(target))
|
||||||
|
for name := range target {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
used := map[string]bool{}
|
||||||
|
text := fmt.Sprintf("// %s maps one value. Pointer/map/slice assignments retain Go reference semantics.\nfunc %s(source %s)(%s,error){\nvar result %s\n", m.Name, m.Name, r.typeName(mt.source), r.typeName(mt.target), r.typeName(mt.target))
|
||||||
|
for _, path := range names {
|
||||||
|
dst := target[path]
|
||||||
|
if reason, ignored := m.IgnoreTarget[path]; ignored {
|
||||||
|
text += "// Target " + path + " is intentionally left at zero; see omni.gen.json.\n"
|
||||||
|
_ = reason
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rule, explicit := m.Fields[path]
|
||||||
|
if !explicit {
|
||||||
|
for key, f := range source {
|
||||||
|
if f.name == dst.name {
|
||||||
|
rule.Source = key
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src, ok := source[rule.Source]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("target %s has no source; add a field rule or ignoreTarget reason", path)
|
||||||
|
}
|
||||||
|
if _, ignored := m.IgnoreSource[src.path]; ignored {
|
||||||
|
return "", fmt.Errorf("source %s is both mapped and ignored", src.path)
|
||||||
|
}
|
||||||
|
used[src.path] = true
|
||||||
|
from, to := src.typ, dst.typ
|
||||||
|
srcExpr, dstExpr := "source."+src.path, "result."+path
|
||||||
|
if rule.Elements {
|
||||||
|
switch d := to.Underlying().(type) {
|
||||||
|
case *types.Slice:
|
||||||
|
s, ok := from.Underlying().(*types.Slice)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s: elements requires slice to slice or equal-length array to array", path)
|
||||||
|
}
|
||||||
|
text += fmt.Sprintf("if %s!=nil {\n%s=make(%s,len(%s))\nfor index:=range %s {\n", srcExpr, dstExpr, r.typeName(to), srcExpr, srcExpr)
|
||||||
|
from, to = s.Elem(), d.Elem()
|
||||||
|
case *types.Array:
|
||||||
|
s, ok := from.Underlying().(*types.Array)
|
||||||
|
if !ok || s.Len() != d.Len() {
|
||||||
|
return "", fmt.Errorf("%s: array lengths must match", path)
|
||||||
|
}
|
||||||
|
text += fmt.Sprintf("for index:=range %s {\n", srcExpr)
|
||||||
|
from, to = s.Elem(), d.Elem()
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("%s: elements requires a slice or array", path)
|
||||||
|
}
|
||||||
|
srcExpr += "[index]"
|
||||||
|
dstExpr += "[index]"
|
||||||
|
}
|
||||||
|
expr, fallible, err := r.expression(rule, from, to, srcExpr)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s -> %s: %w", src.path, path, err)
|
||||||
|
}
|
||||||
|
if fallible {
|
||||||
|
fmtPkg, err := r.importer.Import("fmt")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
alias := r.qualifier(fmtPkg)
|
||||||
|
message := m.Name + "." + path + ": %w"
|
||||||
|
args := "err"
|
||||||
|
if rule.Elements {
|
||||||
|
message = m.Name + "." + path + "[%d]: %w"
|
||||||
|
args = "index,err"
|
||||||
|
}
|
||||||
|
text += fmt.Sprintf("{ value,err:=%s\nif err!=nil{var zero %s;return zero,%s.Errorf(%q,%s)}\n%s=value\n}\n", expr, r.typeName(mt.target), alias, message, args, dstExpr)
|
||||||
|
} else {
|
||||||
|
text += dstExpr + "=" + expr + "\n"
|
||||||
|
}
|
||||||
|
if rule.Elements {
|
||||||
|
text += "}\n"
|
||||||
|
if _, ok := dst.typ.Underlying().(*types.Slice); ok {
|
||||||
|
text += "}\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(source))
|
||||||
|
for key := range source {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
if !used[key] {
|
||||||
|
if _, ignored := m.IgnoreSource[key]; !ignored {
|
||||||
|
return "", fmt.Errorf("unmapped source field %s; map it or provide an ignoreSource reason", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text += "return result,nil\n}\n"
|
||||||
|
if m.PointerResult {
|
||||||
|
text += fmt.Sprintf("// %sPtr returns nil on conversion failure and otherwise a pointer to the result.\nfunc %sPtr(source %s)(*%s,error){\nvalue,err:=%s(source)\nif err!=nil{return nil,err}\nreturn &value,nil\n}\n", m.Name, m.Name, r.typeName(mt.source), r.typeName(mt.target), m.Name)
|
||||||
|
}
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mappingRenderer) expression(rule MappingField, from, to types.Type, source string) (string, bool, error) {
|
||||||
|
if rule.Mapping != "" {
|
||||||
|
return r.nestedExpression(rule, from, to, source)
|
||||||
|
}
|
||||||
|
if rule.Nil != "" {
|
||||||
|
return r.dereferenceExpression(rule, from, to, source)
|
||||||
|
}
|
||||||
|
if rule.Convert != nil {
|
||||||
|
pkg, err := r.importer.Import(rule.Convert.Package)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
fn, ok := pkg.Scope().Lookup(rule.Convert.Name).(*types.Func)
|
||||||
|
if !ok {
|
||||||
|
return "", false, fmt.Errorf("converter function %s not found", rule.Convert.Name)
|
||||||
|
}
|
||||||
|
sig := fn.Type().(*types.Signature)
|
||||||
|
if sig.Variadic() || (sig.TypeParams() != nil && sig.TypeParams().Len() > 0) || sig.Params().Len() != 1 || sig.Results().Len() < 1 || sig.Results().Len() > 2 {
|
||||||
|
return "", false, fmt.Errorf("converter must have signature func(Source) Target or func(Source) (Target,error)")
|
||||||
|
}
|
||||||
|
if !types.Identical(sig.Params().At(0).Type(), from) || !types.Identical(sig.Results().At(0).Type(), to) {
|
||||||
|
return "", false, fmt.Errorf("converter signature does not match field types")
|
||||||
|
}
|
||||||
|
fallible := sig.Results().Len() == 2
|
||||||
|
if fallible && !types.Identical(sig.Results().At(1).Type(), types.Universe.Lookup("error").Type()) {
|
||||||
|
return "", false, fmt.Errorf("converter second result must be error")
|
||||||
|
}
|
||||||
|
return r.qualifier(pkg) + "." + rule.Convert.Name + "(" + source + ")", fallible, nil
|
||||||
|
}
|
||||||
|
if !types.Identical(from, to) {
|
||||||
|
return "", false, fmt.Errorf("types %s and %s differ; provide an explicit converter", types.TypeString(from, nil), types.TypeString(to, nil))
|
||||||
|
}
|
||||||
|
return source, false, nil
|
||||||
|
}
|
||||||
175
cmd/go-omni/apigen/mappingLoad.go
Normal file
175
cmd/go-omni/apigen/mappingLoad.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"go/importer"
|
||||||
|
gotoken "go/token"
|
||||||
|
"go/types"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/mod/modfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadMappingTypes uses the Go compiler's export data, including the pending
|
||||||
|
// DTOs via an overlay. Module files and sum updates remain in a temporary dir.
|
||||||
|
// It compiles only explicitly selected packages and their dependencies; never
|
||||||
|
// runs project code. Build tags/GOOS follow the caller; GOWORK is disabled.
|
||||||
|
func loadMappingTypes(o Options, refs []string, generated, seeds map[string][]byte) (types.Importer, func(), error) {
|
||||||
|
// go list resolves its working directory (notably /var -> /private/var on
|
||||||
|
// macOS). Overlay keys must name that same physical path or stale DTOs win.
|
||||||
|
o.Dir = canonical(o.Dir)
|
||||||
|
temp, err := os.MkdirTemp("", "go-omni-mapping-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
cleanup := func() { _ = os.RemoveAll(temp) }
|
||||||
|
fail := func(err error) (types.Importer, func(), error) { cleanup(); return nil, nil, err }
|
||||||
|
raw, err := os.ReadFile(filepath.Join(o.Dir, "go.mod"))
|
||||||
|
if err != nil {
|
||||||
|
return fail(errors.New("mapping generation requires an existing Go module; scaffold first and resolve dependencies"))
|
||||||
|
}
|
||||||
|
mf, err := modfile.Parse("go.mod", raw, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
replacements := append([]*modfile.Replace(nil), mf.Replace...)
|
||||||
|
for _, r := range replacements {
|
||||||
|
if r.New.Version == "" && !filepath.IsAbs(r.New.Path) {
|
||||||
|
if err = mf.AddReplace(r.Old.Path, r.Old.Version, filepath.Join(o.Dir, r.New.Path), ""); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, err = mf.Format()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
modPath := filepath.Join(temp, "go.mod")
|
||||||
|
if err = os.WriteFile(modPath, raw, 0600); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
if sums, e := os.ReadFile(filepath.Join(o.Dir, "go.sum")); e == nil {
|
||||||
|
if err = os.WriteFile(filepath.Join(temp, "go.sum"), sums, 0600); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
} else if !errors.Is(e, os.ErrNotExist) {
|
||||||
|
return fail(e)
|
||||||
|
}
|
||||||
|
overlay := map[string]string{}
|
||||||
|
// Hide stale owned Go files, which may otherwise redeclare a renamed DTO.
|
||||||
|
var previous manifest
|
||||||
|
if data, e := os.ReadFile(filepath.Join(o.Dir, manifestPath)); e == nil {
|
||||||
|
if err = json.Unmarshal(data, &previous); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
for name := range previous.Generated {
|
||||||
|
if strings.HasSuffix(name, ".go") {
|
||||||
|
if !ownedPath(name) {
|
||||||
|
return fail(fmt.Errorf("invalid owned path %s", name))
|
||||||
|
}
|
||||||
|
if err = safePath(o.Dir, name); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
if _, ok := generated[name]; !ok {
|
||||||
|
overlay[filepath.Join(o.Dir, filepath.FromSlash(name))] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !errors.Is(e, os.ErrNotExist) {
|
||||||
|
return fail(e)
|
||||||
|
}
|
||||||
|
for i, files := range []map[string][]byte{generated, seeds} {
|
||||||
|
names := make([]string, 0, len(files))
|
||||||
|
for name := range files {
|
||||||
|
if strings.HasSuffix(name, ".go") {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, name := range names {
|
||||||
|
if err = safePath(o.Dir, name); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(o.Dir, filepath.FromSlash(name))
|
||||||
|
if i == 1 {
|
||||||
|
if _, e := os.Stat(path); e == nil {
|
||||||
|
continue
|
||||||
|
} else if !errors.Is(e, os.ErrNotExist) {
|
||||||
|
return fail(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
backing := filepath.Join(temp, fmt.Sprintf("overlay%d.go", len(overlay)))
|
||||||
|
if err = os.WriteFile(backing, files[name], 0600); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
overlay[path] = backing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, err = json.Marshal(map[string]any{"Replace": overlay})
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
overlayPath := filepath.Join(temp, "overlay.json")
|
||||||
|
if err = os.WriteFile(overlayPath, raw, 0600); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
args := []string{"list", "-deps", "-export", "-json", "-mod=readonly", "-modfile", modPath, "-overlay", overlayPath}
|
||||||
|
// fmt is used for fallible conversions even if neither struct imports it.
|
||||||
|
args = append(args, "fmt")
|
||||||
|
args = append(args, refs...)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "go", args...)
|
||||||
|
cmd.Dir = o.Dir
|
||||||
|
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||||
|
cmd.WaitDelay = 2 * time.Second
|
||||||
|
stdout := &limitedCatalogBuffer{limit: 32 << 20}
|
||||||
|
stderr := &limitedCatalogBuffer{limit: 64 << 10}
|
||||||
|
cmd.Stdout = stdout
|
||||||
|
cmd.Stderr = stderr
|
||||||
|
if err = cmd.Run(); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return fail(fmt.Errorf("mapping type loading: %w", ctx.Err()))
|
||||||
|
}
|
||||||
|
// Go download errors may contain authenticated remote URLs. Keep arbitrary
|
||||||
|
// command output out of diagnostics; exact mapping errors follow type loading.
|
||||||
|
return fail(fmt.Errorf("mapping type loading failed (%v); run go list -deps -export for the configured source/target/converter packages; check dependencies, build tags and import cycles", err))
|
||||||
|
}
|
||||||
|
exports := map[string]string{}
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(stdout.Bytes()))
|
||||||
|
for {
|
||||||
|
var p struct {
|
||||||
|
ImportPath, Export string
|
||||||
|
Error *struct{ Err string }
|
||||||
|
}
|
||||||
|
if err = dec.Decode(&p); err == io.EOF {
|
||||||
|
break
|
||||||
|
} else if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
if p.Error != nil {
|
||||||
|
return fail(fmt.Errorf("mapping package %s could not be compiled", p.ImportPath))
|
||||||
|
}
|
||||||
|
if p.Export != "" {
|
||||||
|
exports[p.ImportPath] = p.Export
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imp := importer.ForCompiler(gotoken.NewFileSet(), "gc", func(path string) (io.ReadCloser, error) {
|
||||||
|
file, ok := exports[path]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no export data for mapping dependency %s", path)
|
||||||
|
}
|
||||||
|
return os.Open(file)
|
||||||
|
})
|
||||||
|
return imp, cleanup, nil
|
||||||
|
}
|
||||||
250
cmd/go-omni/apigen/mappingNullable_test.go
Normal file
250
cmd/go-omni/apigen/mappingNullable_test.go
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func nullableMappings() []Mapping {
|
||||||
|
pkg := "example.com/nullable/internal/"
|
||||||
|
ref := func(p, name string) GoTypeRef { return GoTypeRef{pkg + p, name} }
|
||||||
|
child := Mapping{Name: "ToChild", Source: ref("types", "Child"), Target: ref("domain", "Child"), Fields: map[string]MappingField{
|
||||||
|
"Name": {Source: "Name", Convert: &GoFunctionRef{pkg + "convert", "Name"}},
|
||||||
|
}}
|
||||||
|
parent := Mapping{Name: "ToParent", PointerResult: true, Source: ref("types", "Parent"), Target: ref("domain", "Parent"), Fields: map[string]MappingField{
|
||||||
|
"Child": {Source: "Child", Mapping: "ToChild"}, "Children": {Source: "Children", Mapping: "ToChild", Elements: true},
|
||||||
|
}}
|
||||||
|
defs := []Mapping{child, parent,
|
||||||
|
{Name: "ToWireChild", Source: ref("domain", "Child"), Target: ref("types", "Child")},
|
||||||
|
{Name: "ToWire", PointerResult: true, Source: ref("domain", "Parent"), Target: ref("types", "Parent"), Fields: map[string]MappingField{
|
||||||
|
"Child": {Source: "Child", Mapping: "ToWireChild"}, "Children": {Source: "Children", Mapping: "ToWireChild", Elements: true},
|
||||||
|
}},
|
||||||
|
{Name: "ToChildDTO", Source: ref("domain", "Child"), Target: ref("domain", "ChildDTO"), Fields: map[string]MappingField{"Name": {Source: "Name", Convert: &GoFunctionRef{pkg + "convert", "Name"}}}},
|
||||||
|
{Name: "Lift", Source: ref("domain", "ValueHolder"), Target: ref("domain", "PointerHolder"), Fields: map[string]MappingField{"Child": {Source: "Child", Mapping: "ToChildDTO"}}},
|
||||||
|
{Name: "Batch", Source: ref("domain", "InputBatch"), Target: ref("domain", "OutputBatch"), Fields: map[string]MappingField{"Fixed": {Source: "Fixed", Mapping: "ToChildDTO", Elements: true}}},
|
||||||
|
{Name: "ShallowCopy", Source: ref("domain", "Parent"), Target: ref("domain", "Parent")},
|
||||||
|
{Name: "RequirePointer", Source: ref("types", "Parent"), Target: ref("domain", "Parent"), Fields: map[string]MappingField{
|
||||||
|
"Child": {Source: "Child", Mapping: "ToChild", Nil: "error"}, "Children": {Source: "Children", Mapping: "ToChild", Elements: true},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, p := range []struct{ name, policy string }{{"RequiredZero", "zero"}, {"RequiredError", "error"}} {
|
||||||
|
defs = append(defs, Mapping{Name: p.name, Source: ref("types", "Parent"), Target: ref("domain", "Required"), Fields: map[string]MappingField{
|
||||||
|
"Child": {Source: "Child", Mapping: "ToChild", Nil: p.policy},
|
||||||
|
}, IgnoreSource: map[string]string{"Children": "this explicit projection only includes the child"}})
|
||||||
|
}
|
||||||
|
for _, p := range []struct{ name, policy string }{{"ScalarZero", "zero"}, {"ScalarError", "error"}} {
|
||||||
|
m := Mapping{Name: p.name, Source: ref("domain", "ScalarInput"), Target: ref("domain", "ScalarOutput"), Fields: map[string]MappingField{}}
|
||||||
|
for _, name := range []string{"Text", "Count", "Enabled"} {
|
||||||
|
m.Fields[name] = MappingField{Source: name, Nil: p.policy}
|
||||||
|
}
|
||||||
|
defs = append(defs, m)
|
||||||
|
}
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMappingFile(t *testing.T, root, name string, defs []Mapping) {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.MarshalIndent(struct {
|
||||||
|
Mappings []Mapping `json:"mappings"`
|
||||||
|
}{defs}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(root, name), string(raw)+"\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNullableMappingConsumer(t *testing.T) {
|
||||||
|
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||||
|
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||||
|
}
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "app")
|
||||||
|
o := Options{API: filepath.Join(frameworkRoot(t), "cmd/go-omni/apigen/testdata/nullable/service.api"), Dir: dir, Module: "example.com/nullable", FrameworkDir: frameworkRoot(t)}
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for path, fixture := range map[string]string{"internal/domain/model.go": "domain.go", "internal/convert/value.go": "convert.go"} {
|
||||||
|
writeFile(t, filepath.Join(dir, path), readFile(t, "testdata/nullable/"+fixture))
|
||||||
|
}
|
||||||
|
runCompositionGo(t, dir, "mod", "tidy")
|
||||||
|
defs := nullableMappings()
|
||||||
|
// References go across files, including forward references to ToChild.
|
||||||
|
writeMappingFile(t, dir, "mappings/parents.json", defs[1:])
|
||||||
|
writeMappingFile(t, dir, "mappings/children.json", defs[:1])
|
||||||
|
cfg := GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: []string{"mappings/parents.json", "mappings/children.json"}}
|
||||||
|
writeGeneratorConfig(t, dir, cfg)
|
||||||
|
o.API = filepath.Join(dir, "api/service.api")
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, "internal/logic/probe/roundTrip.go"), `package probe
|
||||||
|
import("context";"example.com/nullable/internal/types";"example.com/nullable/internal/mapping")
|
||||||
|
func(s *Logic)RoundTrip(ctx context.Context,req *types.Parent)(*types.Parent,error){
|
||||||
|
value,err:=mapping.ToParent(*req);if err!=nil{return nil,err}
|
||||||
|
return mapping.ToWirePtr(value)
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/nullable/consumer_test.go"))
|
||||||
|
before := snapshot(t, dir)
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("regeneration changed business/config files")
|
||||||
|
}
|
||||||
|
o.Check = true
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("check wrote files")
|
||||||
|
}
|
||||||
|
runCompositionGo(t, dir, "test", "-race", "-count=1", "./...")
|
||||||
|
runCompositionGo(t, dir, "build", ".")
|
||||||
|
// Workspace preflight and check use the same split configuration loader.
|
||||||
|
workspace := filepath.Join(root, "omni.workspace.json")
|
||||||
|
writeFile(t, workspace, `{"services":[{"dir":"app"}]}`)
|
||||||
|
for _, command := range []string{"format", "generate", "check"} {
|
||||||
|
if err := nullableWorkspace(t, workspace, command); err != nil {
|
||||||
|
t.Fatal(command, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All invalid configurations fail before either writing or previewing output.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, want string
|
||||||
|
edit func([]Mapping) []Mapping
|
||||||
|
}{
|
||||||
|
{"missingNilPolicy", "requires explicit nil", func(d []Mapping) []Mapping {
|
||||||
|
d[9].Fields["Child"] = MappingField{Source: "Child", Mapping: "ToChild"}
|
||||||
|
return d
|
||||||
|
}},
|
||||||
|
{"preserveValue", "requires explicit nil", func(d []Mapping) []Mapping {
|
||||||
|
d[9].Fields["Child"] = MappingField{Source: "Child", Mapping: "ToChild", Nil: "preserve"}
|
||||||
|
return d
|
||||||
|
}},
|
||||||
|
{"zeroPointer", "pointer target", func(d []Mapping) []Mapping {
|
||||||
|
d[1].Fields["Child"] = MappingField{Source: "Child", Mapping: "ToChild", Nil: "zero"}
|
||||||
|
return d
|
||||||
|
}},
|
||||||
|
{"scalarMismatch", "identical *T to T", func(d []Mapping) []Mapping {
|
||||||
|
d[11].Fields["Text"] = MappingField{Source: "Count", Nil: "zero"}
|
||||||
|
return d
|
||||||
|
}},
|
||||||
|
{"valueNil", "requires a pointer source", func(d []Mapping) []Mapping {
|
||||||
|
d[5].Fields["Child"] = MappingField{Source: "Child", Mapping: "ToChildDTO", Nil: "zero"}
|
||||||
|
return d
|
||||||
|
}},
|
||||||
|
{"converterNil", "cannot be combined", func(d []Mapping) []Mapping {
|
||||||
|
f := d[0].Fields["Name"]
|
||||||
|
f.Nil = "error"
|
||||||
|
d[0].Fields["Name"] = f
|
||||||
|
return d
|
||||||
|
}},
|
||||||
|
{"pointerNameCollision", "must be unique", func(d []Mapping) []Mapping { m := d[0]; m.Name = "ToParentPtr"; return append(d, m) }},
|
||||||
|
{"mappingCycle", "cycle", func(d []Mapping) []Mapping {
|
||||||
|
d[0].Fields["Name"] = MappingField{Source: "Name", Mapping: "ToParent"}
|
||||||
|
return d
|
||||||
|
}},
|
||||||
|
{"unknownMapping", "unknown mapping", func(d []Mapping) []Mapping {
|
||||||
|
d[1].Fields["Child"] = MappingField{Source: "Child", Mapping: "Missing"}
|
||||||
|
return d
|
||||||
|
}},
|
||||||
|
{"unhandledField", "unmapped source", func(d []Mapping) []Mapping { d[9].IgnoreSource = nil; return d }},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
changed := tc.edit(nullableMappings())
|
||||||
|
writeMappingFile(t, dir, "mappings/parents.json", changed[1:])
|
||||||
|
writeMappingFile(t, dir, "mappings/children.json", changed[:1])
|
||||||
|
defer writeMappingFile(t, dir, "mappings/parents.json", defs[1:])
|
||||||
|
defer writeMappingFile(t, dir, "mappings/children.json", defs[:1])
|
||||||
|
before := snapshot(t, dir)
|
||||||
|
for _, mode := range []string{"check", "preview", "generate"} {
|
||||||
|
current := o
|
||||||
|
current.Check = mode == "check"
|
||||||
|
current.DryRun = mode == "preview"
|
||||||
|
err := generateManagedTestProject(t, current)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatal(mode, err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("invalid mapping wrote files")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// An edit inside a child file is picked up by --check; the config is never rewritten.
|
||||||
|
changed := nullableMappings()
|
||||||
|
changed[0].PointerResult = true
|
||||||
|
writeMappingFile(t, dir, "mappings/children.json", changed[:1])
|
||||||
|
before = snapshot(t, dir)
|
||||||
|
if err := generateManagedTestProject(t, o); err == nil {
|
||||||
|
t.Fatal("check missed child mapping drift")
|
||||||
|
}
|
||||||
|
if err := nullableWorkspace(t, workspace, "check"); err == nil {
|
||||||
|
t.Fatal("workspace missed child mapping drift")
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("drift check wrote files")
|
||||||
|
}
|
||||||
|
preview := o
|
||||||
|
preview.Check = false
|
||||||
|
preview.DryRun = true
|
||||||
|
if err := generateManagedTestProject(t, preview); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("preview wrote files")
|
||||||
|
}
|
||||||
|
if err := nullableWorkspace(t, workspace, "generate"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := nullableWorkspace(t, workspace, "check"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||||
|
data, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err, string(data))
|
||||||
|
}
|
||||||
|
var dependency struct {
|
||||||
|
Version, Sum string
|
||||||
|
Replace any
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(data, &dependency); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" {
|
||||||
|
t.Fatal("not a fixed remote consumer")
|
||||||
|
}
|
||||||
|
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableWorkspace(t *testing.T, path, command string) error {
|
||||||
|
t.Helper()
|
||||||
|
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
|
||||||
|
if cli == "" {
|
||||||
|
return Workspace(path, command, io.Discard)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, cli, "workspace", command, "--file", path)
|
||||||
|
cmd.Dir = filepath.Dir(path)
|
||||||
|
if data, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("released workspace CLI: %w\n%s", err, data)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
97
cmd/go-omni/apigen/mappingPointer.go
Normal file
97
cmd/go-omni/apigen/mappingPointer.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mappingOrigin(m Mapping) string {
|
||||||
|
if m.origin != "" {
|
||||||
|
return m.origin
|
||||||
|
}
|
||||||
|
return generatorConfigFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer adaptation is only selected by an explicit nested mapping. Ordinary
|
||||||
|
// same-type pointer assignments retain their existing reference semantics.
|
||||||
|
func (r *mappingRenderer) nestedExpression(rule MappingField, from, to types.Type, source string) (string, bool, error) {
|
||||||
|
m := r.types[rule.Mapping]
|
||||||
|
fromValue, toValue := from, to
|
||||||
|
src, sourcePointer := from.Underlying().(*types.Pointer)
|
||||||
|
dst, targetPointer := to.Underlying().(*types.Pointer)
|
||||||
|
if sourcePointer {
|
||||||
|
fromValue = src.Elem()
|
||||||
|
}
|
||||||
|
if targetPointer {
|
||||||
|
toValue = dst.Elem()
|
||||||
|
}
|
||||||
|
if !types.Identical(fromValue, m.source) || !types.Identical(toValue, m.target) {
|
||||||
|
return "", false, fmt.Errorf("nested mapping %s has incompatible types", rule.Mapping)
|
||||||
|
}
|
||||||
|
if !sourcePointer && rule.Nil != "" {
|
||||||
|
return "", false, fmt.Errorf("nil policy requires a pointer source")
|
||||||
|
}
|
||||||
|
if !sourcePointer && !targetPointer {
|
||||||
|
return rule.Mapping + "(" + source + ")", true, nil
|
||||||
|
}
|
||||||
|
policy := rule.Nil
|
||||||
|
if sourcePointer {
|
||||||
|
if targetPointer {
|
||||||
|
if policy == "" {
|
||||||
|
policy = "preserve"
|
||||||
|
}
|
||||||
|
if policy != "preserve" && policy != "error" {
|
||||||
|
return "", false, fmt.Errorf("pointer target requires nil preserve or error")
|
||||||
|
}
|
||||||
|
} else if policy != "zero" && policy != "error" {
|
||||||
|
return "", false, fmt.Errorf("pointer to value mapping requires explicit nil policy zero or error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep a typed local input so the source is evaluated once. The surrounding
|
||||||
|
// renderer adds the mapping/field/index error path and returns a zero parent.
|
||||||
|
body := "var zero " + r.typeName(to) + "\n"
|
||||||
|
input := "input"
|
||||||
|
if sourcePointer {
|
||||||
|
nilResult, err := r.nilResult(policy)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
body += "if input==nil {" + nilResult + "}\n"
|
||||||
|
input = "*input"
|
||||||
|
}
|
||||||
|
body += "value,err:=" + rule.Mapping + "(" + input + ")\nif err!=nil{return zero,err}\n"
|
||||||
|
if targetPointer {
|
||||||
|
body += "return &value,nil\n"
|
||||||
|
} else {
|
||||||
|
body += "return value,nil\n"
|
||||||
|
}
|
||||||
|
return "func(input " + r.typeName(from) + ")(" + r.typeName(to) + ",error){\n" + body + "}(" + source + ")", true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A nil policy without a converter/mapping is deliberately limited to *T -> T.
|
||||||
|
// It never casts numbers, changes named types, or invents business defaults.
|
||||||
|
func (r *mappingRenderer) dereferenceExpression(rule MappingField, from, to types.Type, source string) (string, bool, error) {
|
||||||
|
pointer, ok := from.Underlying().(*types.Pointer)
|
||||||
|
if !ok || !types.Identical(pointer.Elem(), to) {
|
||||||
|
return "", false, fmt.Errorf("nil policy without a mapping requires identical *T to T types")
|
||||||
|
}
|
||||||
|
if rule.Nil != "zero" && rule.Nil != "error" {
|
||||||
|
return "", false, fmt.Errorf("pointer to value conversion requires nil zero or error")
|
||||||
|
}
|
||||||
|
nilResult, err := r.nilResult(rule.Nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return "func(input " + r.typeName(from) + ")(" + r.typeName(to) + ",error){\nvar zero " + r.typeName(to) + "\nif input==nil{" + nilResult + "}\nreturn *input,nil\n}(" + source + ")", true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mappingRenderer) nilResult(policy string) (string, error) {
|
||||||
|
if policy != "error" {
|
||||||
|
return "return zero,nil", nil
|
||||||
|
}
|
||||||
|
pkg, err := r.importer.Import("fmt")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "return zero," + r.qualifier(pkg) + ".Errorf(\"nil source is not allowed\")", nil
|
||||||
|
}
|
||||||
266
cmd/go-omni/apigen/mapping_test.go
Normal file
266
cmd/go-omni/apigen/mapping_test.go
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func compositionConfig() GeneratorConfig {
|
||||||
|
pkg := "example.com/composition/internal/"
|
||||||
|
cfg := GeneratorConfig{Repository: "none", Middleware: "factories"}
|
||||||
|
for _, name := range []string{"Enterprise", "Tenant", "Admin"} {
|
||||||
|
fields := map[string]MappingField{
|
||||||
|
"EmployeeName": {Source: "Name", Convert: &GoFunctionRef{pkg + "convert", "Name"}},
|
||||||
|
"Salary": {Source: "Salary", Convert: &GoFunctionRef{pkg + "convert", "Money"}},
|
||||||
|
"Enabled": {Source: "Enabled", Convert: &GoFunctionRef{pkg + "convert", "Bool"}},
|
||||||
|
}
|
||||||
|
m := Mapping{Name: "From" + name, Source: GoTypeRef{pkg + "types", name + "Request"}, Target: GoTypeRef{pkg + "domain", "Employee"}, Fields: fields}
|
||||||
|
if name != "Enterprise" {
|
||||||
|
m.IgnoreSource = map[string]string{"CompanyID": "Authorized separately by Logic; never copy path scope into body"}
|
||||||
|
}
|
||||||
|
cfg.Mappings = append(cfg.Mappings, m)
|
||||||
|
}
|
||||||
|
cfg.Mappings = append(cfg.Mappings, Mapping{Name: "ToResponse", Source: GoTypeRef{pkg + "domain", "Result"}, Target: GoTypeRef{pkg + "types", "EmployeeResponse"}, Fields: map[string]MappingField{"ID": {Source: "ID", Convert: &GoFunctionRef{pkg + "convert", "ID"}}}})
|
||||||
|
cfg.Mappings = append(cfg.Mappings, Mapping{Name: "ToRow", Source: GoTypeRef{pkg + "domain", "Row"}, Target: GoTypeRef{pkg + "domain", "RowDTO"}})
|
||||||
|
cfg.Mappings = append(cfg.Mappings, Mapping{Name: "ToBatch", Source: GoTypeRef{pkg + "domain", "Batch"}, Target: GoTypeRef{pkg + "domain", "BatchDTO"}, Fields: map[string]MappingField{
|
||||||
|
"Rows": {Source: "Rows", Mapping: "ToRow", Elements: true}, "Fixed": {Source: "Fixed", Mapping: "ToRow", Elements: true},
|
||||||
|
}})
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
func writeGeneratorConfig(t *testing.T, dir string, cfg GeneratorConfig) {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, generatorConfigFile), string(raw)+"\n")
|
||||||
|
}
|
||||||
|
func runCompositionGo(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 TestCompositionMappingConsumer(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)
|
||||||
|
}
|
||||||
|
fixtures := map[string]string{
|
||||||
|
"internal/amount/money.go": "amount.go", "internal/domain/employee.go": "domain.go", "internal/convert/fields.go": "converters.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",
|
||||||
|
}
|
||||||
|
for path, fixture := range fixtures {
|
||||||
|
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))
|
||||||
|
// Resolve dependencies before opting into compile-time mapping. The first
|
||||||
|
// generation creates DTOs; the second sees current DTOs through an overlay.
|
||||||
|
// Logic does not yet import the generated mapping package.
|
||||||
|
testSource := readFile(t, filepath.Join(dir, "consumer_test.go"))
|
||||||
|
if err := os.Remove(filepath.Join(dir, "consumer_test.go")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runCompositionGo(t, dir, "mod", "tidy")
|
||||||
|
cfg := compositionConfig()
|
||||||
|
writeGeneratorConfig(t, dir, cfg)
|
||||||
|
o.API = filepath.Join(dir, "api/service.api")
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, "consumer_test.go"), testSource)
|
||||||
|
for _, group := range []string{"Enterprise", "Tenant", "Admin"} {
|
||||||
|
text := fmt.Sprintf(`package %s
|
||||||
|
import("context";"example.com/composition/internal/types";"example.com/composition/internal/mapping";"example.com/composition/internal/domain")
|
||||||
|
func(s *Logic)Save(ctx context.Context,req *types.%sRequest)(*types.EmployeeResponse,error){
|
||||||
|
input,err:=mapping.From%s(*req);if err!=nil{return nil,err}
|
||||||
|
result,err:=mapping.ToResponse(domain.Result{Employee:input,ID:9007199254740993});if err!=nil{return nil,err};return &result,nil
|
||||||
|
}
|
||||||
|
`, strings.ToLower(group), group, group)
|
||||||
|
writeFile(t, filepath.Join(dir, "internal/logic", strings.ToLower(group), "save.go"), text)
|
||||||
|
}
|
||||||
|
before := snapshot(t, dir)
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("regeneration changed project")
|
||||||
|
}
|
||||||
|
o.Check = true
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("check changed project")
|
||||||
|
}
|
||||||
|
runCompositionGo(t, dir, "test", "-race", "-count=1", "./...")
|
||||||
|
runCompositionGo(t, dir, "build", ".")
|
||||||
|
generated := readFile(t, filepath.Join(dir, "internal/mapping/mappingsGen.go"))
|
||||||
|
if strings.Contains(generated, "encoding/json") || strings.Contains(generated, "reflect") || !strings.Contains(generated, "result.EmployeeName = value") {
|
||||||
|
t.Fatal("mapping does not emit typed assignments")
|
||||||
|
}
|
||||||
|
// Adding a source or target field is detected before any files are written.
|
||||||
|
for _, tc := range []struct{ name, path, before, after, want string }{
|
||||||
|
{"source", o.API, "Name *string", "NewField *string `json:\"newField,optional\"`\n Name *string", "unmapped source field NewField"},
|
||||||
|
{"target", filepath.Join(dir, "internal/domain/employee.go"), "EmployeeName string", "Unmapped string\n EmployeeName string", "target Unmapped has no source"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
original := readFile(t, tc.path)
|
||||||
|
writeFile(t, tc.path, strings.Replace(original, tc.before, tc.after, 1))
|
||||||
|
defer writeFile(t, tc.path, original)
|
||||||
|
snap := snapshot(t, dir)
|
||||||
|
if err := generateManagedTestProject(t, o); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(snap, snapshot(t, dir)) {
|
||||||
|
t.Fatal("failed check wrote files")
|
||||||
|
}
|
||||||
|
o.Check = false
|
||||||
|
defer func() { o.Check = true }()
|
||||||
|
if err := generateManagedTestProject(t, o); err == nil {
|
||||||
|
t.Fatal("generate accepted missing mapping")
|
||||||
|
}
|
||||||
|
if !sameFiles(snap, snapshot(t, dir)) {
|
||||||
|
t.Fatal("failed generate wrote files")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
edit func(*GeneratorConfig)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"pointerPolicy", func(c *GeneratorConfig) { delete(c.Mappings[0].Fields, "Salary") }, "provide an explicit converter"},
|
||||||
|
{"unknownSource", func(c *GeneratorConfig) { c.Mappings[0].Fields["Salary"] = MappingField{Source: "Missing"} }, "has no source"},
|
||||||
|
{"wrongConverter", func(c *GeneratorConfig) {
|
||||||
|
f := c.Mappings[0].Fields["Salary"]
|
||||||
|
f.Convert.Name = "ID"
|
||||||
|
c.Mappings[0].Fields["Salary"] = f
|
||||||
|
}, "signature does not match"},
|
||||||
|
{"unhandledScope", func(c *GeneratorConfig) { c.Mappings[1].IgnoreSource = nil }, "unmapped source field CompanyID"},
|
||||||
|
{"badIgnore", func(c *GeneratorConfig) { c.Mappings[1].IgnoreSource["Missing"] = "stale rule" }, "ignoreSource Missing"},
|
||||||
|
{"arrayLength", func(c *GeneratorConfig) {
|
||||||
|
f := c.Mappings[5].Fields["Fixed"]
|
||||||
|
f.Source = "Rows"
|
||||||
|
c.Mappings[5].Fields["Fixed"] = f
|
||||||
|
}, "array lengths must match"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
changed := compositionConfig()
|
||||||
|
tc.edit(&changed)
|
||||||
|
writeGeneratorConfig(t, dir, changed)
|
||||||
|
defer writeGeneratorConfig(t, dir, cfg)
|
||||||
|
snap := snapshot(t, dir)
|
||||||
|
if err := generateManagedTestProject(t, o); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(snap, snapshot(t, dir)) {
|
||||||
|
t.Fatal("invalid rule wrote files")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// All diagnostics exercise current DTOs, not stale exported generated types.
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Exercise the repository switch with the same actual CLI as the mapper.
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "internal/repository")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("default repository skeleton: %v", err)
|
||||||
|
}
|
||||||
|
o.Check = false
|
||||||
|
cfg.Repository = "group"
|
||||||
|
writeGeneratorConfig(t, dir, cfg)
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repo := filepath.Join(dir, "internal/repository/enterprise/repository.go")
|
||||||
|
writeFile(t, repo, readFile(t, repo)+"\n// Project-owned persistence.\n")
|
||||||
|
cfg.Repository = "none"
|
||||||
|
writeGeneratorConfig(t, dir, cfg)
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(readFile(t, repo), "Project-owned persistence.") {
|
||||||
|
t.Fatal("disabling skeleton overwrote business code")
|
||||||
|
}
|
||||||
|
o.Check = true
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||||
|
data, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err, string(data))
|
||||||
|
}
|
||||||
|
var dependency struct {
|
||||||
|
Version, Sum string
|
||||||
|
Replace any
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(data, &dependency); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" {
|
||||||
|
t.Fatal("not a fixed remote consumer")
|
||||||
|
}
|
||||||
|
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMappingConfigurationRejection(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
edit func(*GeneratorConfig)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"cycle", func(c *GeneratorConfig) {
|
||||||
|
c.Mappings[4].Fields = map[string]MappingField{"Value": {Source: "Value", Mapping: "ToRow"}}
|
||||||
|
}, "cycle"},
|
||||||
|
{"unknown", func(c *GeneratorConfig) {
|
||||||
|
c.Mappings[5].Fields["Rows"] = MappingField{Source: "Rows", Mapping: "Missing", Elements: true}
|
||||||
|
}, "unknown mapping"},
|
||||||
|
{"conflict", func(c *GeneratorConfig) {
|
||||||
|
f := c.Mappings[0].Fields["Salary"]
|
||||||
|
f.Mapping = "ToRow"
|
||||||
|
c.Mappings[0].Fields["Salary"] = f
|
||||||
|
}, "conflicting converter"},
|
||||||
|
{"internal", func(c *GeneratorConfig) { c.Mappings[0].Source.Package = "example.net/private/internal/types" }, "invalid Go type"},
|
||||||
|
{"outputCycle", func(c *GeneratorConfig) { c.Mappings[0].Source.Package = "example.com/composition/internal/mapping" }, "invalid Go type"},
|
||||||
|
{"duplicates", func(c *GeneratorConfig) { c.Mappings[1].Name = c.Mappings[0].Name }, "must be unique"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cfg := compositionConfig()
|
||||||
|
tc.edit(&cfg)
|
||||||
|
if _, err := validateMappings(cfg.Mappings, "example.com/composition"); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ func document(t *testing.T, c *Compiled) map[string]any {
|
|||||||
if err := json.Unmarshal(c.Document, &doc); err != nil {
|
if err := json.Unmarshal(c.Document, &doc); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return doc
|
return expandDocumentRefs(t, doc, doc).(map[string]any)
|
||||||
}
|
}
|
||||||
func operation(t *testing.T, doc map[string]any, path, method string) map[string]any {
|
func operation(t *testing.T, doc map[string]any, path, method string) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -238,6 +238,7 @@ func TestDeclaredContractConsumer(t *testing.T) {
|
|||||||
source := filepath.Join(root, "input/service.api")
|
source := filepath.Join(root, "input/service.api")
|
||||||
writeFile(t, source, declaredAPI)
|
writeFile(t, source, declaredAPI)
|
||||||
dir := filepath.Join(root, "project")
|
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 {
|
if _, err := Generate(Options{API: source, Dir: dir, Module: "example.com/dx", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -303,3 +304,21 @@ func TestAuthMetadataWithParameterNamePrefixes(t *testing.T) {
|
|||||||
t.Fatal("auth metadata lost")
|
t.Fatal("auth metadata lost")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Existing semantic assertions resolve the standard local references emitted
|
||||||
|
// by the compiler. documentRefs_test additionally checks the actual references.
|
||||||
|
func expandDocumentRefs(t *testing.T, doc map[string]any, v any) any {
|
||||||
|
t.Helper()
|
||||||
|
switch v:=v.(type){
|
||||||
|
case map[string]any:
|
||||||
|
if ref,ok:=v["$ref"].(string);ok && strings.HasPrefix(ref,"#/components/") && len(v)==1 {
|
||||||
|
var target any=doc
|
||||||
|
for _,part:=range strings.Split(strings.TrimPrefix(ref,"#/"),"/"){m,ok:=target.(map[string]any);if !ok{t.Fatal("invalid reference",ref)};target=m[part]}
|
||||||
|
if target==nil{t.Fatal("dangling reference",ref)};return expandDocumentRefs(t,doc,target)
|
||||||
|
}
|
||||||
|
out:=map[string]any{};for k,x:=range v{out[k]=expandDocumentRefs(t,doc,x)};return out
|
||||||
|
case []any:
|
||||||
|
out:=make([]any,len(v));for i,x:=range v{out[i]=expandDocumentRefs(t,doc,x)};return out
|
||||||
|
default:return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
19
cmd/go-omni/apigen/middlewareFactory.go
Normal file
19
cmd/go-omni/apigen/middlewareFactory.go
Normal 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")
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
type Spec struct {
|
type Spec struct {
|
||||||
DocumentCatalog *apierrors.Catalog
|
DocumentCatalog *apierrors.Catalog
|
||||||
|
DocumentErrors *DocumentErrors
|
||||||
SharedRoots map[string]SharedRoot
|
SharedRoots map[string]SharedRoot
|
||||||
SourcePaths map[string]string
|
SourcePaths map[string]string
|
||||||
RequestPolicy contract.RequestPolicy
|
RequestPolicy contract.RequestPolicy
|
||||||
@@ -38,6 +39,8 @@ type Type struct {
|
|||||||
Fields []Field
|
Fields []Field
|
||||||
Position string
|
Position string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An empty Type denotes a named field composition, expanded before compilation.
|
||||||
type Field struct{ Name, Type, Tag, Description string }
|
type Field struct{ Name, Type, Tag, Description string }
|
||||||
type Route struct {
|
type Route struct {
|
||||||
BoundMultipart bool
|
BoundMultipart bool
|
||||||
@@ -72,7 +75,9 @@ type parser struct {
|
|||||||
imports []string
|
imports []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Parse(path string) (*Spec, error) {
|
func Parse(path string) (*Spec, error) { return parseWithCatalogs(path, nil) }
|
||||||
|
|
||||||
|
func parseWithCatalogs(path string, catalogs map[string][]byte) (*Spec, error) {
|
||||||
entry, err := filepath.Abs(path)
|
entry, err := filepath.Abs(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -82,11 +87,12 @@ func Parse(path string) (*Spec, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
root := filepath.Dir(entry)
|
root := filepath.Dir(entry)
|
||||||
roots, err := readSharedRoots(root)
|
shared, err := readSharedConfig(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
spec := &Spec{Version: "1.0.0", SharedRoots: roots, SourcePaths: map[string]string{}, Sources: map[string][]byte{}, Entry: filepath.Base(entry)}
|
roots := shared.Roots
|
||||||
|
spec := &Spec{Version: "1.0.0", DocumentErrors: shared.Errors, SharedRoots: roots, SourcePaths: map[string]string{}, Sources: map[string][]byte{}, Entry: filepath.Base(entry)}
|
||||||
visiting, visited := map[string]bool{}, map[string]bool{}
|
visiting, visited := map[string]bool{}, map[string]bool{}
|
||||||
total := 0
|
total := 0
|
||||||
var visit func(string, string, int) error
|
var visit func(string, string, int) error
|
||||||
@@ -169,7 +175,7 @@ func Parse(path string) (*Spec, error) {
|
|||||||
if err = visit(entry, "", 0); err != nil {
|
if err = visit(entry, "", 0); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err = readDocumentCatalog(root, spec); err != nil {
|
if err = readDocumentCatalog(root, spec, catalogs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err = validateSharedPins(spec); err != nil {
|
if err = validateSharedPins(spec); err != nil {
|
||||||
@@ -513,7 +519,11 @@ func (p *parser) parseType() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if expression == "" {
|
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 {
|
if p.tokens[p.at].kind != scanner.RawString {
|
||||||
return p.err("field source tag required")
|
return p.err("field source tag required")
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ func TestConfiguration(t *testing.T){
|
|||||||
cfg,err:=Load("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
|
cfg,err:=Load("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
|
||||||
if cfg.Server.Log.File==""{t.Fatal("file logger absent")}
|
if cfg.Server.Log.File==""{t.Fatal("file logger absent")}
|
||||||
t.Setenv("GO_OMNI_LOG_OUTPUT","stdout");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!=""{t.Fatalf("override: %v",err)}
|
t.Setenv("GO_OMNI_LOG_OUTPUT","stdout");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!=""{t.Fatalf("override: %v",err)}
|
||||||
|
t.Setenv("GO_OMNI_SERVER_LOG_FILE","./explicit.log");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!="./explicit.log"{t.Fatalf("canonical log file: %v",err)}
|
||||||
|
t.Setenv("GO_OMNI_SERVER_LOG_OUTPUT","stderr");cfg,err=Load("");if err!=nil||cfg.Server.Log.Output!="stderr"{t.Fatalf("canonical output: %v",err)}
|
||||||
_=os.Getenv("unused")
|
_=os.Getenv("unused")
|
||||||
}`
|
}`
|
||||||
endpoint := ""
|
endpoint := ""
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Response roots deliberately allow named DTOs, []DTO, *DTO and null. Keeping
|
// Response roots allow named DTOs, []DTO, []primitive, *DTO and null.
|
||||||
// these four shapes explicit makes nil/empty behavior uniform across encoders.
|
// A nil root slice is always encoded as an empty JSON array.
|
||||||
func responseShape(value string) (kind, name string) {
|
func responseShape(value string) (kind, name string) {
|
||||||
if value == "null" {
|
if value == "null" {
|
||||||
return "null", ""
|
return "null", ""
|
||||||
@@ -27,6 +28,9 @@ func responseGoType(value string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if kind == "array" {
|
if kind == "array" {
|
||||||
|
if primitiveTypes[name] != nil {
|
||||||
|
return "[]" + name
|
||||||
|
}
|
||||||
return "[]types." + name
|
return "[]types." + name
|
||||||
}
|
}
|
||||||
return "*types." + name
|
return "*types." + name
|
||||||
@@ -36,9 +40,25 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
|
|||||||
if kind == "null" {
|
if kind == "null" {
|
||||||
return &openapi.Schema{Type: "object", Nullable: true, Enum: []any{nil}}, nil
|
return &openapi.Schema{Type: "object", Nullable: true, Enum: []any{nil}}, nil
|
||||||
}
|
}
|
||||||
|
if kind == "array" && primitiveTypes[name] != nil {
|
||||||
|
if name == "File" {
|
||||||
|
return nil, fmt.Errorf("File is request-only; use a response DTO")
|
||||||
|
}
|
||||||
|
// Reuse the contract compiler, including integer bounds and byte-slice rules.
|
||||||
|
t := reflect.StructOf([]reflect.StructField{{Name: "Value", Type: reflect.SliceOf(primitiveTypes[name]), Tag: `json:"value"`}})
|
||||||
|
b, err := contract.Describe(t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s, err := b.ResponseSchema()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.Properties["value"], nil
|
||||||
|
}
|
||||||
b, ok := bindings[name]
|
b, ok := bindings[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, *Name or null", name)
|
return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, []string/[]int64/[]bool/[]any, *Name or null", name)
|
||||||
}
|
}
|
||||||
s, err := b.ResponseSchema()
|
s, err := b.ResponseSchema()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -52,3 +72,7 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
|
|||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var primitiveTypes = map[string]reflect.Type{"File": reflect.TypeOf(contract.File{}), "any": reflect.TypeOf((*any)(nil)).Elem(), "string": reflect.TypeOf(""), "bool": reflect.TypeOf(false), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0))}
|
||||||
|
|
||||||
|
func responseNeedsTypes(value string) bool { return strings.Contains(responseGoType(value), "types.") }
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ func TestResponseTemplateConsumer(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
|
||||||
generate(source, true)
|
generate(source, true)
|
||||||
writeFile(t, filepath.Join(dir, "response_test.go"), responseConsumerTest)
|
writeFile(t, filepath.Join(dir, "response_test.go"), responseConsumerTest)
|
||||||
for _, method := range []string{"Item", "Callback", "Partner"} {
|
for _, method := range []string{"Item", "Callback", "Partner"} {
|
||||||
|
|||||||
@@ -23,48 +23,49 @@ type SharedRoot struct {
|
|||||||
SHA256 string `json:"sha256,omitempty"`
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
}
|
}
|
||||||
type sharedConfig struct {
|
type sharedConfig struct {
|
||||||
Roots map[string]SharedRoot `json:"roots"`
|
Roots map[string]SharedRoot `json:"roots"`
|
||||||
|
Errors *DocumentErrors `json:"errors,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func readSharedRoots(root string) (map[string]SharedRoot, error) {
|
func readSharedConfig(root string) (sharedConfig, error) {
|
||||||
stat, err := os.Lstat(filepath.Join(root, importsFile))
|
stat, err := os.Lstat(filepath.Join(root, importsFile))
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, nil
|
return sharedConfig{}, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return sharedConfig{}, err
|
||||||
}
|
}
|
||||||
if !stat.Mode().IsRegular() {
|
if !stat.Mode().IsRegular() {
|
||||||
return nil, errors.New("api: shared root configuration must be a regular file")
|
return sharedConfig{}, errors.New("api: shared root configuration must be a regular file")
|
||||||
}
|
}
|
||||||
f, err := os.Open(filepath.Join(root, importsFile))
|
f, err := os.Open(filepath.Join(root, importsFile))
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, nil
|
return sharedConfig{}, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return sharedConfig{}, err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
raw, err := io.ReadAll(io.LimitReader(f, 65537))
|
raw, err := io.ReadAll(io.LimitReader(f, 65537))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return sharedConfig{}, err
|
||||||
}
|
}
|
||||||
if len(raw) > 65536 {
|
if len(raw) > 65536 {
|
||||||
return nil, errors.New("api: shared root config too large")
|
return sharedConfig{}, errors.New("api: shared root config too large")
|
||||||
}
|
}
|
||||||
var cfg sharedConfig
|
var cfg sharedConfig
|
||||||
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
|
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
|
||||||
return nil, err
|
return sharedConfig{}, err
|
||||||
}
|
}
|
||||||
if len(cfg.Roots) > 16 {
|
if len(cfg.Roots) > 16 {
|
||||||
return nil, errors.New("api: at most 16 shared roots")
|
return sharedConfig{}, errors.New("api: at most 16 shared roots")
|
||||||
}
|
}
|
||||||
for name, r := range cfg.Roots {
|
for name, r := range cfg.Roots {
|
||||||
if !lowerName.MatchString(name) || r.Path == "" || len(r.Version) > 128 {
|
if !lowerName.MatchString(name) || r.Path == "" || len(r.Version) > 128 {
|
||||||
return nil, errors.New("api: invalid shared root")
|
return sharedConfig{}, errors.New("api: invalid shared root")
|
||||||
}
|
}
|
||||||
if r.SHA256 != "" && (len(r.SHA256) != 64 || strings.Trim(r.SHA256, "0123456789abcdef") != "") {
|
if r.SHA256 != "" && (len(r.SHA256) != 64 || strings.Trim(r.SHA256, "0123456789abcdef") != "") {
|
||||||
return nil, errors.New("api: shared sha256 must be 64 lowercase hex characters")
|
return sharedConfig{}, errors.New("api: shared sha256 must be 64 lowercase hex characters")
|
||||||
}
|
}
|
||||||
path := r.Path
|
path := r.Path
|
||||||
if !filepath.IsAbs(path) {
|
if !filepath.IsAbs(path) {
|
||||||
@@ -72,22 +73,22 @@ func readSharedRoots(root string) (map[string]SharedRoot, error) {
|
|||||||
}
|
}
|
||||||
r.Path, err = filepath.EvalSymlinks(path)
|
r.Path, err = filepath.EvalSymlinks(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return sharedConfig{}, err
|
||||||
}
|
}
|
||||||
r.Path, err = filepath.Abs(r.Path)
|
r.Path, err = filepath.Abs(r.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return sharedConfig{}, err
|
||||||
}
|
}
|
||||||
stat, err := os.Stat(r.Path)
|
stat, err := os.Stat(r.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return sharedConfig{}, err
|
||||||
}
|
}
|
||||||
if !stat.IsDir() {
|
if !stat.IsDir() {
|
||||||
return nil, errors.New("api: shared root must be a directory")
|
return sharedConfig{}, errors.New("api: shared root must be a directory")
|
||||||
}
|
}
|
||||||
cfg.Roots[name] = r
|
cfg.Roots[name] = r
|
||||||
}
|
}
|
||||||
return cfg.Roots, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
func sourceWithin(root, name string) (string, error) {
|
func sourceWithin(root, name string) (string, error) {
|
||||||
real, err := filepath.EvalSymlinks(name)
|
real, err := filepath.EvalSymlinks(name)
|
||||||
@@ -122,7 +123,7 @@ func sourceMetadata(spec *Spec) map[string]any {
|
|||||||
return map[string]any{"files": hashes, "shared": shared}
|
return map[string]any{"files": hashes, "shared": shared}
|
||||||
}
|
}
|
||||||
func sharedSeeds(spec *Spec, directory string) ([]byte, error) {
|
func sharedSeeds(spec *Spec, directory string) ([]byte, error) {
|
||||||
cfg := sharedConfig{Roots: map[string]SharedRoot{}}
|
cfg := sharedConfig{Roots: map[string]SharedRoot{}, Errors: spec.DocumentErrors}
|
||||||
for name, r := range spec.SharedRoots {
|
for name, r := range spec.SharedRoots {
|
||||||
rel, err := filepath.Rel(filepath.Join(directory, "api"), r.Path)
|
rel, err := filepath.Rel(filepath.Join(directory, "api"), r.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
"MODULE/internal/config"
|
"MODULE/internal/config"
|
||||||
"MODULE/internal/handler"
|
"MODULE/internal/handler"
|
||||||
"MODULE/internal/svc"
|
"MODULE/internal/svc"
|
||||||
@@ -27,7 +28,9 @@ func main(){
|
|||||||
func run(ctx context.Context,path string)(runErr error){
|
func run(ctx context.Context,path string)(runErr error){
|
||||||
cfg,err:=config.Load(path);if err!=nil{return err}
|
cfg,err:=config.Load(path);if err!=nil{return err}
|
||||||
logger,err:=logx.New(cfg.Server.Log);if err!=nil{return err}
|
logger,err:=logx.New(cfg.Server.Log);if err!=nil{return err}
|
||||||
defer func(){runErr=errors.Join(runErr,logger.Close())}()
|
// Unjoined background work may still log. The owning process exits on this
|
||||||
|
// error; do not close its borrowed logger before that work has finished.
|
||||||
|
defer func(){if !errors.Is(runErr,lifecycle.ErrResourcesRetained){runErr=errors.Join(runErr,logger.Close())}}()
|
||||||
deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
|
deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
|
||||||
// RunContext drains HTTP requests before returning. This scope owns dependencies
|
// RunContext drains HTTP requests before returning. This scope owns dependencies
|
||||||
// and closes them exactly once, before closing the borrowed logger.
|
// and closes them exactly once, before closing the borrowed logger.
|
||||||
@@ -48,6 +51,16 @@ This project is generated from an editable API contract. Business code uses
|
|||||||
standard context.Context; generated HTTP handlers use Omni's transport-neutral httpx.
|
standard context.Context; generated HTTP handlers use Omni's transport-neutral httpx.
|
||||||
main.go and HTTP composition are continuously generator-owned.
|
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.
|
||||||
|
Optional mappings generate typed assignments in internal/mapping/mappingsGen.go;
|
||||||
|
configure source/target types and explicit conversion rules after resolving module
|
||||||
|
dependencies. Missing fields fail generation and --check. See GENERATION.md in
|
||||||
|
the framework repository for composition, null/reference semantics and migration.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
`+"```sh"+`
|
`+"```sh"+`
|
||||||
@@ -130,8 +143,10 @@ internal/svc/runtimeGen.go # GENERATED lifecycle and infrastructure startup
|
|||||||
internal/apierrors/errorsGen.go # generated fresh error constructors, when declared
|
internal/apierrors/errorsGen.go # generated fresh error constructors, when declared
|
||||||
internal/handler/<group>/ # generated binding and Logic adapters
|
internal/handler/<group>/ # generated binding and Logic adapters
|
||||||
internal/logic/<group>/ # developer-owned business operations
|
internal/logic/<group>/ # developer-owned business operations
|
||||||
internal/repository/<group>/ # developer-owned persistence contracts
|
internal/repository/<group>/ # optional; repository: group creates editable seeds
|
||||||
internal/middleware/ # named hooks and generated registry
|
internal/middleware/ # typed dependency interface, factories and registry
|
||||||
|
omni.gen.json # developer-owned generator options and mappings
|
||||||
|
internal/mapping/*Gen.go # optional compile-checked struct mappings
|
||||||
docs/openapi.json # generated OpenAPI 3.0.3
|
docs/openapi.json # generated OpenAPI 3.0.3
|
||||||
.goOmni/apiManifest.json # ownership and content hashes
|
.goOmni/apiManifest.json # ownership and content hashes
|
||||||
`+"```"+`
|
`+"```"+`
|
||||||
@@ -182,7 +197,16 @@ then immediately register each cleanup with OnClose. A later initialization fail
|
|||||||
runs registered cleanups in reverse order. Close is idempotent. GlobalMiddleware
|
runs registered cleanups in reverse order. Close is idempotent. GlobalMiddleware
|
||||||
provides global hooks; Middlewares supplies named API hooks. AppOptions is evaluated
|
provides global hooks; Middlewares supplies named API hooks. AppOptions is evaluated
|
||||||
once before route registration; logging is configured earlier through Config.
|
once before route registration; logging is configured earlier through Config.
|
||||||
The generated main runs HTTP draining before closing svc resources, then the logger.
|
The generated main runs HTTP draining, all OnStop hooks, all OnDrain hooks, resource
|
||||||
|
OnClose callbacks, then logger close. Stop/drain hooks within a phase run concurrently;
|
||||||
|
combine components with ordering dependencies into one pair. Pair OnStop/OnDrain
|
||||||
|
with the same name. OnDrain returns nil only after final work and acknowledgements
|
||||||
|
have finished. Use WorkContext for accepted background work; Context is an early
|
||||||
|
shutdown notification. server.lifecycle configures stopTimeout (5s), drainTimeout
|
||||||
|
(10s), and cancelTimeout (5s grace per timed-out phase). Zero uses defaults.
|
||||||
|
Timeout/failure cancels WorkContext. If hooks cannot prove completion, resources
|
||||||
|
and the borrowed logger are retained and the generated process exits unsuccessfully.
|
||||||
|
Errors retain their causes. No automatic retry of close or force killing of goroutines.
|
||||||
Do not close borrowed resources or attach duplicate cleanup hooks to App. The startup
|
Do not close borrowed resources or attach duplicate cleanup hooks to App. The startup
|
||||||
context cancels on signals; long-lived clients/workers that requests depend on need
|
context cancels on signals; long-lived clients/workers that requests depend on need
|
||||||
their own lifetime, canceled by OnClose after HTTP draining.
|
their own lifetime, canceled by OnClose after HTTP draining.
|
||||||
|
|||||||
54
cmd/go-omni/apigen/testdata/arrays/consumer_test.go
vendored
Normal file
54
cmd/go-omni/apigen/testdata/arrays/consumer_test.go
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"example.com/arrays/internal/config"
|
||||||
|
"example.com/arrays/internal/handler"
|
||||||
|
"example.com/arrays/internal/svc"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestArrayWire(t *testing.T) {
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.Server.Log.Writer = io.Discard
|
||||||
|
logger, err := logx.New(cfg.Server.Log)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer logger.Close()
|
||||||
|
deps, err := svc.New(context.Background(), logger, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer deps.Close()
|
||||||
|
app, err := handler.NewApp(deps)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer app.Close()
|
||||||
|
for path, want := range map[string]string{
|
||||||
|
"/strings": `["企业","备注"]`, "/integers": `[9223372036854775807,-3]`, "/dynamic": `["text",7,true,null,{"name":"企业"},[false,2]]`, "/empty": `[]`, "/raw": `[true,false]`,
|
||||||
|
} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||||||
|
got := strings.TrimSpace(w.Body.String())
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
if path != "/raw" {
|
||||||
|
var obj map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &obj); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got = string(obj["data"])
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("%s got %s want %s", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
cmd/go-omni/apigen/testdata/arrays/values.go
vendored
Normal file
13
cmd/go-omni/apigen/testdata/arrays/values.go
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package arrays
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func (s *Logic) Strings(context.Context) ([]string, error) { return []string{"企业", "备注"}, nil }
|
||||||
|
func (s *Logic) Integers(context.Context) ([]int64, error) {
|
||||||
|
return []int64{9223372036854775807, -3}, nil
|
||||||
|
}
|
||||||
|
func (s *Logic) Dynamic(context.Context) ([]any, error) {
|
||||||
|
return []any{"text", 7, true, nil, map[string]any{"name": "企业"}, []any{false, 2}}, nil
|
||||||
|
}
|
||||||
|
func (s *Logic) Empty(context.Context) ([]any, error) { return nil, nil }
|
||||||
|
func (s *Logic) Raw(context.Context) ([]bool, error) { return []bool{true, false}, nil }
|
||||||
30
cmd/go-omni/apigen/testdata/composition/accessFactory.go
vendored
Normal file
30
cmd/go-omni/apigen/testdata/composition/accessFactory.go
vendored
Normal 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
|
||||||
|
}
|
||||||
52
cmd/go-omni/apigen/testdata/composition/amount.go
vendored
Normal file
52
cmd/go-omni/apigen/testdata/composition/amount.go
vendored
Normal 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
|
||||||
|
}
|
||||||
36
cmd/go-omni/apigen/testdata/composition/client.go
vendored
Normal file
36
cmd/go-omni/apigen/testdata/composition/client.go
vendored
Normal 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 }
|
||||||
218
cmd/go-omni/apigen/testdata/composition/consumer_test.go
vendored
Normal file
218
cmd/go-omni/apigen/testdata/composition/consumer_test.go
vendored
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
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/mapping"
|
||||||
|
"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 TestFullFormAndMappingSemantics(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 := mapping.FromEnterprise(req)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
second, e := mapping.FromEnterprise(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 = mapping.FromEnterprise(req)
|
||||||
|
if e != nil || first.Employer != req.Employer {
|
||||||
|
t.Fatal("nullable pointer changed", e)
|
||||||
|
}
|
||||||
|
bad := "reject"
|
||||||
|
failed, e := mapping.FromEnterprise(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 := mapping.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 := mapping.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 = mapping.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 := mapping.FromEnterprise(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")
|
||||||
|
}
|
||||||
|
}
|
||||||
29
cmd/go-omni/apigen/testdata/composition/converters.go
vendored
Normal file
29
cmd/go-omni/apigen/testdata/composition/converters.go
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"example.com/composition/internal/amount"
|
||||||
|
"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 Money(value *amount.Money) amount.Money {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
func Bool(value *bool) bool { return value != nil && *value }
|
||||||
|
func ID(value int64) string { return strconv.FormatInt(value, 10) }
|
||||||
9
cmd/go-omni/apigen/testdata/composition/dependencies.go
vendored
Normal file
9
cmd/go-omni/apigen/testdata/composition/dependencies.go
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type AccessChecker interface{ Check(context.Context) error }
|
||||||
|
type Dependencies interface {
|
||||||
|
Access() AccessChecker
|
||||||
|
FactoryMode() string
|
||||||
|
}
|
||||||
29
cmd/go-omni/apigen/testdata/composition/domain.go
vendored
Normal file
29
cmd/go-omni/apigen/testdata/composition/domain.go
vendored
Normal 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 explicitly selected element mappings.
|
||||||
|
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
|
||||||
|
}
|
||||||
63
cmd/go-omni/apigen/testdata/composition/service.api
vendored
Normal file
63
cmd/go-omni/apigen/testdata/composition/service.api
vendored
Normal 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)
|
||||||
|
}
|
||||||
49
cmd/go-omni/apigen/testdata/managed/background.go
vendored
Normal file
49
cmd/go-omni/apigen/testdata/managed/background.go
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package svc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/task"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BackgroundState struct {
|
||||||
|
Stop, Drain, Release, Started chan struct{}
|
||||||
|
Wrote atomic.Bool
|
||||||
|
Future *task.Future
|
||||||
|
}
|
||||||
|
|
||||||
|
var Background atomic.Pointer[BackgroundState]
|
||||||
|
|
||||||
|
func initBackground(c *ServiceContext) error {
|
||||||
|
state := &BackgroundState{Stop: make(chan struct{}), Drain: make(chan struct{}), Release: make(chan struct{}), Started: make(chan struct{})}
|
||||||
|
baseline := Closed.Load()
|
||||||
|
runner, err := task.New(task.Config{Workers: 1, QueueSize: 1, TaskTimeout: time.Minute, OnError: func(string, error) {}})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.OnStop("jobs", func(ctx context.Context) error { close(state.Stop); return runner.StopAccepting(ctx) })
|
||||||
|
c.OnDrain("jobs", func(ctx context.Context) error { close(state.Drain); return runner.Wait(ctx) })
|
||||||
|
state.Future, err = runner.Submit(c.WorkContext(), "finish", func(ctx context.Context) error {
|
||||||
|
close(state.Started)
|
||||||
|
if c.Config.BackgroundMode == "auto" {
|
||||||
|
<-state.Stop
|
||||||
|
} else {
|
||||||
|
<-state.Release
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
if Closed.Load() != baseline {
|
||||||
|
return errors.New("shared client closed before background write")
|
||||||
|
}
|
||||||
|
state.Wrote.Store(true)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
<-state.Started
|
||||||
|
}
|
||||||
|
Background.Store(state)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@ func initClient(ctx context.Context, c *ServiceContext) error {
|
|||||||
Opened.Add(1)
|
Opened.Add(1)
|
||||||
c.Client = &Client{}
|
c.Client = &Client{}
|
||||||
c.OnClose(func() error { Closed.Add(1); c.Logger.Info("test.client.closed"); return nil })
|
c.OnClose(func() error { Closed.Add(1); c.Logger.Info("test.client.closed"); return nil })
|
||||||
|
if c.Config.BackgroundMode != "" {
|
||||||
|
if err := initBackground(c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.Config.FailInit {
|
if c.Config.FailInit {
|
||||||
return errors.New("client initialization failed")
|
return errors.New("client initialization failed")
|
||||||
}
|
}
|
||||||
|
|||||||
155
cmd/go-omni/apigen/testdata/managed/phases_test.go
vendored
Normal file
155
cmd/go-omni/apigen/testdata/managed/phases_test.go
vendored
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"example.com/managed/internal/svc"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func phasedConfig(t *testing.T, address, mode string, fail bool) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
drainTimeout := "1s"
|
||||||
|
if mode == "stuck" {
|
||||||
|
drainTimeout = "50ms"
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(map[string]any{"backgroundMode": mode, "failInit": fail, "server": map[string]any{"address": address, "lifecycle": map[string]string{"stopTimeout": "1s", "drainTimeout": drainTimeout, "cancelTimeout": "50ms"}}})
|
||||||
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
func await(t *testing.T, ch <-chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("phase did not arrive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedBackgroundPhases(t *testing.T) {
|
||||||
|
for _, transport := range []string{"nethttp", "native"} {
|
||||||
|
t.Run(transport, func(t *testing.T) {
|
||||||
|
t.Setenv("GO_OMNI_TRANSPORT", transport)
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := listener.Addr().String()
|
||||||
|
_ = listener.Close()
|
||||||
|
svc.Started = make(chan struct{}, 1)
|
||||||
|
svc.Release = make(chan struct{})
|
||||||
|
before := svc.Closed.Load()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
path := phasedConfig(t, addr, "hold", false)
|
||||||
|
go func() { done <- run(ctx, path) }()
|
||||||
|
waitReady(t, "http://"+addr, done)
|
||||||
|
state := svc.Background.Load()
|
||||||
|
defer func() {
|
||||||
|
select {
|
||||||
|
case <-state.Release:
|
||||||
|
default:
|
||||||
|
close(state.Release)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
requestDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
client := http.Client{Timeout: 3 * time.Second}
|
||||||
|
resp, e := client.Get("http://" + addr + "/slow")
|
||||||
|
if e == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
e = errors.New("HTTP drain failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestDone <- e
|
||||||
|
}()
|
||||||
|
await(t, svc.Started)
|
||||||
|
deps := svc.Current.Load()
|
||||||
|
deps.RequestShutdown(svc.FatalDependency)
|
||||||
|
if deps.WorkContext().Err() != nil {
|
||||||
|
t.Fatal("accepted work canceled at signal")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-state.Stop:
|
||||||
|
t.Fatal("stopped background before HTTP finished")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
close(svc.Release)
|
||||||
|
if err := <-requestDone; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
await(t, state.Drain)
|
||||||
|
if svc.Closed.Load() != before {
|
||||||
|
t.Fatal("client closed during background drain")
|
||||||
|
}
|
||||||
|
close(state.Release)
|
||||||
|
if err := <-done; !errors.Is(err, svc.FatalDependency) || errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !state.Wrote.Load() || svc.Closed.Load() != before+1 {
|
||||||
|
t.Fatal("missing final write or cleanup")
|
||||||
|
}
|
||||||
|
if err := state.Future.Wait(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedBackgroundFailureCleanup(t *testing.T) {
|
||||||
|
for _, failInit := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{true: "initialization", false: "listen"}[failInit], func(t *testing.T) {
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer l.Close()
|
||||||
|
before := svc.Closed.Load()
|
||||||
|
err = run(context.Background(), phasedConfig(t, l.Addr().String(), "auto", failInit))
|
||||||
|
if err == nil || errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
state := svc.Background.Load()
|
||||||
|
if !state.Wrote.Load() || svc.Closed.Load() != before+1 {
|
||||||
|
t.Fatal("failure cleanup did not join background first")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedBackgroundTimeoutProcess(t *testing.T) {
|
||||||
|
if path := os.Getenv("OMNI_PHASE_TIMEOUT_CONFIG"); path != "" {
|
||||||
|
os.Args = []string{os.Args[0], "-config", path}
|
||||||
|
main()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := phasedConfig(t, "127.0.0.1:0", "stuck", true)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestGeneratedBackgroundTimeoutProcess$")
|
||||||
|
cmd.Env = append(os.Environ(), "OMNI_PHASE_TIMEOUT_CONFIG="+path)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if !errors.As(err, &exit) || exit.ExitCode() != 1 || ctx.Err() != nil {
|
||||||
|
t.Fatal(err, string(output))
|
||||||
|
}
|
||||||
|
if strings.Contains(string(output), "test.client.closed") || !strings.Contains(string(output), "shared resources retained") || !strings.Contains(string(output), "client initialization failed") {
|
||||||
|
t.Fatal(string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
170
cmd/go-omni/apigen/testdata/nullable/consumer_test.go
vendored
Normal file
170
cmd/go-omni/apigen/testdata/nullable/consumer_test.go
vendored
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"example.com/nullable/internal/convert"
|
||||||
|
"example.com/nullable/internal/domain"
|
||||||
|
"example.com/nullable/internal/handler"
|
||||||
|
"example.com/nullable/internal/mapping"
|
||||||
|
"example.com/nullable/internal/svc"
|
||||||
|
"example.com/nullable/internal/types"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNullableMappings(t *testing.T) {
|
||||||
|
for _, src := range []types.Parent{{}, {Children: []*types.Child{}}, {Child: &types.Child{}, Children: []*types.Child{nil, {Name: "two"}}}} {
|
||||||
|
out, err := mapping.ToParent(src)
|
||||||
|
if err != nil || (src.Child == nil) != (out.Child == nil) || (src.Children == nil) != (out.Children == nil) || len(src.Children) != len(out.Children) {
|
||||||
|
t.Fatal(src, out, err)
|
||||||
|
}
|
||||||
|
if src.Child != nil && out.Child.Name != src.Child.Name {
|
||||||
|
t.Fatal("zero child changed")
|
||||||
|
}
|
||||||
|
if len(out.Children) > 0 && (out.Children[0] != nil || out.Children[1].Name != "two") {
|
||||||
|
t.Fatal(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src := types.Parent{Child: &types.Child{Name: "one"}, Children: []*types.Child{{Name: "two"}}}
|
||||||
|
out, err := mapping.ToParentPtr(src)
|
||||||
|
if err != nil || out == nil || out.Child.Name != "one" {
|
||||||
|
t.Fatal(out, err)
|
||||||
|
}
|
||||||
|
out.Child.Name = "changed"
|
||||||
|
out.Children[0].Name = "changed"
|
||||||
|
if src.Child.Name != "one" || src.Children[0].Name != "two" {
|
||||||
|
t.Fatal("nested mapped values alias input")
|
||||||
|
}
|
||||||
|
plain := domain.Parent{Child: &domain.Child{Name: "shared"}}
|
||||||
|
copy, err := mapping.ShallowCopy(plain)
|
||||||
|
if err != nil || copy.Child != plain.Child {
|
||||||
|
t.Fatal("ordinary assignment semantics changed", err)
|
||||||
|
}
|
||||||
|
zero, err := mapping.RequiredZero(types.Parent{})
|
||||||
|
if err != nil || zero.Child.Name != "" {
|
||||||
|
t.Fatal(zero, err)
|
||||||
|
}
|
||||||
|
for _, fn := range []func(types.Parent) (domain.Required, error){mapping.RequiredZero, mapping.RequiredError} {
|
||||||
|
out, err := fn(types.Parent{Child: &types.Child{Name: "required"}})
|
||||||
|
if err != nil || out.Child.Name != "required" {
|
||||||
|
t.Fatal(out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = mapping.RequiredError(types.Parent{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "RequiredError.Child") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = mapping.RequirePointer(types.Parent{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "RequirePointer.Child") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lift, err := mapping.Lift(domain.ValueHolder{Child: domain.Child{Name: "lift"}})
|
||||||
|
if err != nil || lift.Child == nil || lift.Child.Name != "lift" {
|
||||||
|
t.Fatal(lift, err)
|
||||||
|
}
|
||||||
|
batch, err := mapping.Batch(domain.InputBatch{Fixed: [2]*domain.Child{nil, {Name: "array"}}})
|
||||||
|
if err != nil || batch.Fixed[0] != nil || batch.Fixed[1].Name != "array" {
|
||||||
|
t.Fatal(batch, err)
|
||||||
|
}
|
||||||
|
_, err = mapping.Batch(domain.InputBatch{Fixed: [2]*domain.Child{nil, {Name: "bad"}}})
|
||||||
|
if !errors.Is(err, convert.ErrValue) || !strings.Contains(err.Error(), "Batch.Fixed[1]") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, input := range []types.Parent{{Child: &types.Child{Name: "bad"}}, {Children: []*types.Child{nil, {Name: "bad"}}}} {
|
||||||
|
failed, err := mapping.ToParentPtr(input)
|
||||||
|
if failed != nil || !errors.Is(err, convert.ErrValue) || !strings.Contains(err.Error(), "ToChild.Name") {
|
||||||
|
t.Fatal(failed, err)
|
||||||
|
}
|
||||||
|
if len(input.Children) > 0 && !strings.Contains(err.Error(), "ToParent.Children[1]") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
value, err := mapping.ToParent(input)
|
||||||
|
if value.Child != nil || value.Children != nil || !errors.Is(err, convert.ErrValue) {
|
||||||
|
t.Fatal("partial result escaped", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExplicitNilAndConcurrentCalls(t *testing.T) {
|
||||||
|
zero, err := mapping.ScalarZero(domain.ScalarInput{})
|
||||||
|
if err != nil || zero != (domain.ScalarOutput{}) {
|
||||||
|
t.Fatal(zero, err)
|
||||||
|
}
|
||||||
|
_, err = mapping.ScalarError(domain.ScalarInput{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("nil accepted")
|
||||||
|
}
|
||||||
|
text, count, enabled := "", int64(0), false
|
||||||
|
out, err := mapping.ScalarError(domain.ScalarInput{Text: &text, Count: &count, Enabled: &enabled})
|
||||||
|
if err != nil || out != (domain.ScalarOutput{}) {
|
||||||
|
t.Fatal("non-nil zero rejected", out, err)
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 32; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
out, err := mapping.ToParent(types.Parent{Child: &types.Child{Name: "parallel"}})
|
||||||
|
if err != nil || out.Child == nil || out.Child.Name != "parallel" {
|
||||||
|
t.Error(out, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPNullableMapping(t *testing.T) {
|
||||||
|
cfg := logx.Default()
|
||||||
|
cfg.Writer = io.Discard
|
||||||
|
logger, err := logx.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer logger.Close()
|
||||||
|
deps, err := svc.New(context.Background(), logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer deps.Close()
|
||||||
|
app, err := handler.NewApp(deps)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer app.Close()
|
||||||
|
for _, body := range []string{`{}`, `{"child":null}`, `{"child":{"name":""}}`, `{"child":{"name":"one"},"children":[null,{"name":"two"}]}`, `{"children":[]}`} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
app.Engine().ServeHTTP(w, req)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var input, output types.Parent
|
||||||
|
if err := json.Unmarshal([]byte(body), &input); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if (input.Child == nil) != (output.Child == nil) || (input.Children == nil) != (output.Children == nil) || len(input.Children) != len(output.Children) {
|
||||||
|
t.Fatal(body, w.Body.String())
|
||||||
|
}
|
||||||
|
if input.Child != nil && input.Child.Name != output.Child.Name {
|
||||||
|
t.Fatal(w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("POST", "/", strings.NewReader(`{"child":{"name":"bad"}}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
app.Engine().ServeHTTP(w, req)
|
||||||
|
if w.Code != 500 || strings.Contains(w.Body.String(), convert.ErrValue.Error()) || strings.Contains(w.Body.String(), "ToChild") {
|
||||||
|
t.Fatal("private cause leaked", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
15
cmd/go-omni/apigen/testdata/nullable/convert.go
vendored
Normal file
15
cmd/go-omni/apigen/testdata/nullable/convert.go
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrValue = errors.New("private conversion cause")
|
||||||
|
|
||||||
|
func Name(value string) (string, error) {
|
||||||
|
if value == "bad" {
|
||||||
|
return "", fmt.Errorf("conversion refused: %w", ErrValue)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
23
cmd/go-omni/apigen/testdata/nullable/domain.go
vendored
Normal file
23
cmd/go-omni/apigen/testdata/nullable/domain.go
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
type Child struct{ Name string }
|
||||||
|
type ChildDTO struct{ Name string }
|
||||||
|
type Parent struct {
|
||||||
|
Child *Child
|
||||||
|
Children []*Child
|
||||||
|
}
|
||||||
|
type Required struct{ Child Child }
|
||||||
|
type ValueHolder struct{ Child Child }
|
||||||
|
type PointerHolder struct{ Child *ChildDTO }
|
||||||
|
type InputBatch struct{ Fixed [2]*Child }
|
||||||
|
type OutputBatch struct{ Fixed [2]*ChildDTO }
|
||||||
|
type ScalarInput struct {
|
||||||
|
Text *string
|
||||||
|
Count *int64
|
||||||
|
Enabled *bool
|
||||||
|
}
|
||||||
|
type ScalarOutput struct {
|
||||||
|
Text string
|
||||||
|
Count int64
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
15
cmd/go-omni/apigen/testdata/nullable/service.api
vendored
Normal file
15
cmd/go-omni/apigen/testdata/nullable/service.api
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
type Child {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
type Parent {
|
||||||
|
Child *Child `json:"child,optional"`
|
||||||
|
Children []*Child `json:"children,optional"`
|
||||||
|
}
|
||||||
|
@server (
|
||||||
|
group: probe
|
||||||
|
response: raw
|
||||||
|
)
|
||||||
|
service probe {
|
||||||
|
@handler roundTrip
|
||||||
|
post / (Parent) returns (Parent)
|
||||||
|
}
|
||||||
@@ -78,6 +78,12 @@ func PlanUpgrade(dir string) (UpgradePlan, error) {
|
|||||||
names[name] = true
|
names[name] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// v0.14 already owns config/svc extensions. Only bump the module here;
|
||||||
|
// Generate updates generator-owned main/runtime files without replaying the
|
||||||
|
// v0.13 seed migration over developer configuration and dependency code.
|
||||||
|
if semver.IsValid(m.TemplateVersion) && semver.Compare(m.TemplateVersion, "v0.14.0-preview") >= 0 {
|
||||||
|
names = map[string]bool{}
|
||||||
|
}
|
||||||
sorted := make([]string, 0, len(names))
|
sorted := make([]string, 0, len(names))
|
||||||
for name := range names {
|
for name := range names {
|
||||||
sorted = append(sorted, name)
|
sorted = append(sorted, name)
|
||||||
|
|||||||
@@ -60,6 +60,49 @@ func TestUpgradeRejectsEscapingEntry(t *testing.T) {
|
|||||||
t.Fatal("invalid upgrade wrote project")
|
t.Fatal("invalid upgrade wrote project")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpgradeV014PreservesExtensions(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "service")
|
||||||
|
if _, err := New(Options{Dir: dir, Module: "example.com/upgrade", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m manifest
|
||||||
|
if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, manifestPath))), &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.TemplateVersion = "v0.14.0-preview"
|
||||||
|
raw, _ := json.Marshal(m)
|
||||||
|
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
|
||||||
|
mod := filepath.Join(dir, "go.mod")
|
||||||
|
writeFile(t, mod, strings.ReplaceAll(readFile(t, mod), minimumVersion, "v0.14.1-preview"))
|
||||||
|
owned := map[string]string{}
|
||||||
|
for _, name := range []string{"internal/svc/serviceContext.go", "internal/config/config.go", "etc/config.yaml"} {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
value := readFile(t, path)
|
||||||
|
if strings.HasSuffix(name, ".go") {
|
||||||
|
value += "\n// project extension kept\n"
|
||||||
|
} else {
|
||||||
|
value += "\n# project configuration kept\n"
|
||||||
|
}
|
||||||
|
writeFile(t, path, value)
|
||||||
|
owned[path] = value
|
||||||
|
}
|
||||||
|
plan, err := PlanUpgrade(dir)
|
||||||
|
if err != nil || len(plan.Changes) != 1 || plan.Changes[0].Path != "go.mod" {
|
||||||
|
t.Fatal(plan, err)
|
||||||
|
}
|
||||||
|
if _, err := ApplyUpgrade(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for path, want := range owned {
|
||||||
|
if readFile(t, path) != want {
|
||||||
|
t.Fatal("developer file modified", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
func TestSafeInfrastructureUpgrade(t *testing.T) {
|
func TestSafeInfrastructureUpgrade(t *testing.T) {
|
||||||
dir := oldInfrastructureProject(t)
|
dir := oldInfrastructureProject(t)
|
||||||
before := snapshot(t, dir)
|
before := snapshot(t, dir)
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ type WorkspaceService struct {
|
|||||||
Module string `json:"module,omitempty"`
|
Module string `json:"module,omitempty"`
|
||||||
}
|
}
|
||||||
type WorkspaceConfig struct {
|
type WorkspaceConfig struct {
|
||||||
Services []WorkspaceService `json:"services"`
|
Services []WorkspaceService `json:"services"`
|
||||||
|
ErrorCatalogs []WorkspaceCatalog `json:"errorCatalogs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workspace operates independent Go modules listed in an explicit local file.
|
// Workspace operates independent Go modules listed in an explicit local file.
|
||||||
@@ -83,6 +84,16 @@ func Workspace(path, command string, w io.Writer) error {
|
|||||||
}
|
}
|
||||||
options = append(options, Options{API: entry, Dir: dir, Module: service.Module, DryRun: true})
|
options = append(options, Options{API: entry, Dir: dir, Module: service.Module, DryRun: true})
|
||||||
}
|
}
|
||||||
|
var catalogs map[string][]byte
|
||||||
|
if command != "format" {
|
||||||
|
catalogs, err = prepareWorkspaceCatalogs(root, cfg.ErrorCatalogs, command == "check")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range options {
|
||||||
|
options[i].documentCatalogs = catalogs
|
||||||
|
}
|
||||||
|
}
|
||||||
var failures error
|
var failures error
|
||||||
for _, o := range options {
|
for _, o := range options {
|
||||||
if command == "format" {
|
if command == "format" {
|
||||||
@@ -101,6 +112,13 @@ func Workspace(path, command string, w io.Writer) error {
|
|||||||
if failures != nil {
|
if failures != nil {
|
||||||
return failures
|
return failures
|
||||||
}
|
}
|
||||||
|
if command == "generate" {
|
||||||
|
for path, data := range catalogs {
|
||||||
|
if err = writeWorkspaceCatalog(path, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, o := range options {
|
for _, o := range options {
|
||||||
switch command {
|
switch command {
|
||||||
case "format":
|
case "format":
|
||||||
|
|||||||
217
cmd/go-omni/apigen/workspaceCatalog.go
Normal file
217
cmd/go-omni/apigen/workspaceCatalog.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
gotoken "go/token"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
"golang.org/x/mod/modfile"
|
||||||
|
"golang.org/x/mod/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkspaceCatalog is an explicitly trusted Go provider: func() errors.Catalog.
|
||||||
|
// Only workspace generate/check execute providers. Parsing an .api never does.
|
||||||
|
type WorkspaceCatalog struct {
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
Package string `json:"package"`
|
||||||
|
Function string `json:"function"`
|
||||||
|
Output string `json:"output"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitedCatalogBuffer struct {
|
||||||
|
bytes.Buffer
|
||||||
|
limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *limitedCatalogBuffer) Write(p []byte) (int, error) {
|
||||||
|
if len(p) > b.limit-b.Len() {
|
||||||
|
return 0, errors.New("catalog provider output exceeds limit")
|
||||||
|
}
|
||||||
|
return b.Buffer.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportWorkspaceCatalog(root string, cfg WorkspaceCatalog) ([]byte, error) {
|
||||||
|
if cfg.Dir == "" || cfg.Output == "" || !gotoken.IsIdentifier(cfg.Function) || !gotoken.IsExported(cfg.Function) || module.CheckImportPath(cfg.Package) != nil {
|
||||||
|
return nil, errors.New("workspace catalog requires dir, an importable package, an exported function and output")
|
||||||
|
}
|
||||||
|
if err := safePath(root, filepath.ToSlash(filepath.Join(cfg.Dir, ".goOmniExportCheck"))); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := safePath(root, cfg.Output); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if filepath.Ext(cfg.Output) != ".json" {
|
||||||
|
return nil, errors.New("workspace catalog output must be .json")
|
||||||
|
}
|
||||||
|
dir := filepath.Join(root, filepath.FromSlash(cfg.Dir))
|
||||||
|
if stat, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil || !stat.Mode().IsRegular() {
|
||||||
|
return nil, errors.New("workspace catalog dir must contain go.mod")
|
||||||
|
}
|
||||||
|
temp, err := os.MkdirTemp("", "go-omni-catalog-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(temp)
|
||||||
|
// Overlay a virtual Go file under the provider module. This allows internal
|
||||||
|
// packages without creating files or changing go.mod/go.sum in that module.
|
||||||
|
virtual := filepath.Join(dir, ".goOmniCatalogExport", "main.go")
|
||||||
|
if _, err := os.Lstat(filepath.Dir(virtual)); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, errors.New("reserved catalog export path already exists")
|
||||||
|
}
|
||||||
|
source := fmt.Sprintf("package main\nimport (\"os\"; provider %q; omnierrors %q)\nfunc main(){if err:=omnierrors.ExportCatalog(os.Stdout,provider.%s());err!=nil{os.Exit(1)}}\n", cfg.Package, frameworkModule+"/errors", cfg.Function)
|
||||||
|
backing := filepath.Join(temp, "main.go")
|
||||||
|
if err = os.WriteFile(backing, []byte(source), 0600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
overlay, _ := json.Marshal(map[string]any{"Replace": map[string]string{virtual: backing}})
|
||||||
|
overlayPath := filepath.Join(temp, "overlay.json")
|
||||||
|
if err = os.WriteFile(overlayPath, overlay, 0600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Keep all module metadata writes in the temporary directory as well.
|
||||||
|
// -mod=readonly alone must not be relied on to protect every go.sum update.
|
||||||
|
moduleData, err := os.ReadFile(filepath.Join(dir, "go.mod"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mf, err := modfile.Parse("go.mod", moduleData, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("workspace catalog has invalid go.mod")
|
||||||
|
}
|
||||||
|
replacements := append([]*modfile.Replace(nil), mf.Replace...)
|
||||||
|
for _, r := range replacements {
|
||||||
|
if r.New.Version == "" && !filepath.IsAbs(r.New.Path) {
|
||||||
|
if err = mf.AddReplace(r.Old.Path, r.Old.Version, filepath.Join(dir, r.New.Path), ""); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moduleData, err = mf.Format()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
modPath := filepath.Join(temp, "go.mod")
|
||||||
|
if err = os.WriteFile(modPath, moduleData, 0600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if sums, e := os.ReadFile(filepath.Join(dir, "go.sum")); e == nil {
|
||||||
|
if err = os.WriteFile(filepath.Join(temp, "go.sum"), sums, 0600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if !errors.Is(e, os.ErrNotExist) {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
executable := filepath.Join(temp, "catalogExport")
|
||||||
|
// Build first, then execute the binary directly: cancellation kills the
|
||||||
|
// provider itself instead of only terminating a go-run wrapper process.
|
||||||
|
build := exec.CommandContext(ctx, "go", "build", "-mod=readonly", "-modfile", modPath, "-overlay", overlayPath, "-o", executable, virtual)
|
||||||
|
build.Dir = dir
|
||||||
|
build.Env = append(os.Environ(), "GOWORK=off")
|
||||||
|
build.WaitDelay = 2 * time.Second
|
||||||
|
build.Stdout = &limitedCatalogBuffer{limit: 64 << 10}
|
||||||
|
build.Stderr = &limitedCatalogBuffer{limit: 64 << 10}
|
||||||
|
if err = build.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("workspace catalog %s.%s: build failed (%v); check module dependencies and func() errors.Catalog signature", cfg.Package, cfg.Function, err)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, executable)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||||
|
cmd.WaitDelay = 2 * time.Second
|
||||||
|
stdout := &limitedCatalogBuffer{limit: 1 << 20}
|
||||||
|
stderr := &limitedCatalogBuffer{limit: 64 << 10}
|
||||||
|
cmd.Stdout = stdout
|
||||||
|
cmd.Stderr = stderr
|
||||||
|
if err = cmd.Run(); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, fmt.Errorf("workspace catalog %s: %w", cfg.Package, ctx.Err())
|
||||||
|
}
|
||||||
|
// Provider output can contain arbitrary application data. Do not echo it.
|
||||||
|
return nil, fmt.Errorf("workspace catalog %s.%s: execution failed (%v); provider must return errors.Catalog without writing stdout", cfg.Package, cfg.Function, err)
|
||||||
|
}
|
||||||
|
var catalog apierrors.Catalog
|
||||||
|
if err = apierrors.DecodeCatalogJSON(stdout.Bytes(), &catalog); err != nil {
|
||||||
|
return nil, errors.New("workspace catalog provider did not emit a valid catalog")
|
||||||
|
}
|
||||||
|
var normalized bytes.Buffer
|
||||||
|
if err = apierrors.ExportCatalog(&normalized, catalog); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return normalized.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareWorkspaceCatalogs(root string, configs []WorkspaceCatalog, check bool) (map[string][]byte, error) {
|
||||||
|
if len(configs) > 16 {
|
||||||
|
return nil, errors.New("workspace supports at most 16 catalog providers")
|
||||||
|
}
|
||||||
|
result := map[string][]byte{}
|
||||||
|
for _, cfg := range configs {
|
||||||
|
if err := safePath(root, cfg.Output); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path := filepath.Join(root, filepath.FromSlash(cfg.Output))
|
||||||
|
for prior := range result {
|
||||||
|
if strings.EqualFold(prior, path) {
|
||||||
|
return nil, errors.New("workspace catalog outputs overlap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := exportWorkspaceCatalog(root, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if check {
|
||||||
|
old, err := os.ReadFile(path)
|
||||||
|
if err != nil || !bytes.Equal(old, data) {
|
||||||
|
return nil, fmt.Errorf("workspace catalog drift: %s; run workspace generate", cfg.Output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[path] = data
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeWorkspaceCatalog(path string, data []byte) error {
|
||||||
|
mode := os.FileMode(0644)
|
||||||
|
if stat, err := os.Lstat(path); err == nil {
|
||||||
|
if !stat.Mode().IsRegular() {
|
||||||
|
return errors.New("workspace catalog output must be a regular file")
|
||||||
|
}
|
||||||
|
mode = stat.Mode().Perm()
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
old, err := os.ReadFile(path)
|
||||||
|
if err == nil && bytes.Equal(old, data) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.CreateTemp(filepath.Dir(path), ".omni-catalog-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(f.Name())
|
||||||
|
if err = f.Chmod(mode); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, writeErr := io.Copy(f, bytes.NewReader(data))
|
||||||
|
err = errors.Join(writeErr, f.Close())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(f.Name(), path)
|
||||||
|
}
|
||||||
168
cmd/go-omni/apigen/workspaceCatalog_test.go
Normal file
168
cmd/go-omni/apigen/workspaceCatalog_test.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWorkspaceSharedGoCatalog(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(root, "go.mod"), fmt.Sprintf("module example.com/catalogs\n\ngo 1.24.0\nrequire %s v0.15.0-preview\nreplace %s => %s\n", frameworkModule, frameworkModule, strconvQuote(frameworkRoot(t))))
|
||||||
|
writeFile(t, filepath.Join(root, "internal/apierrors/catalog.go"), `package apierrors
|
||||||
|
import errors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitions:map[string]errors.Definition{
|
||||||
|
"INVALID":{Code:"INVALID",Message:"invalid input"},"MISSING":{Status:404,Code:"MISSING",Message:"missing"},
|
||||||
|
}}}
|
||||||
|
`)
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "contracts"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"api", "id"} {
|
||||||
|
dir := filepath.Join(root, "services", name)
|
||||||
|
if _, err := New(Options{Dir: dir, Module: "example.com/" + name, FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, "api/omni.imports.json"), `{"roots":{"common":{"path":"../../../contracts"}},"errors":{"source":"@common/errors.json","include":["INVALID"],"routes":{"GET /api/greet/:name":["MISSING"]}}}`)
|
||||||
|
}
|
||||||
|
workspace := filepath.Join(root, "omni.workspace.json")
|
||||||
|
writeFile(t, workspace, `{"errorCatalogs":[{"dir":".","package":"example.com/catalogs/internal/apierrors","function":"Catalog","output":"contracts/errors.json"}],"services":[{"dir":"services/api"},{"dir":"services/id"}]}`)
|
||||||
|
if err := Workspace(workspace, "generate", io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := Workspace(workspace, "format", io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := Workspace(workspace, "generate", io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := snapshot(t, root)
|
||||||
|
if err := Workspace(workspace, "check", io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("check wrote project files")
|
||||||
|
}
|
||||||
|
for _, name := range []string{"api", "id"} {
|
||||||
|
entry := filepath.Join(root, "services", name, "api/service.api")
|
||||||
|
spec, err := Parse(entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, err := Compile(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
op := operation(t, document(t, c), "/api/greet/{name}", "get")
|
||||||
|
if op["responses"].(map[string]any)["404"] == nil || len(spec.Errors) != 0 || len(spec.Routes[0].Errors) != 0 {
|
||||||
|
t.Fatal("selection changed runtime catalog")
|
||||||
|
}
|
||||||
|
if _, err = os.Stat(filepath.Join(filepath.Dir(entry), catalogDocumentFile)); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("shared catalog was copied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shared := filepath.Join(root, "contracts/errors.json")
|
||||||
|
data := readFile(t, shared)
|
||||||
|
writeFile(t, shared, strings.Replace(data, "invalid input", "stale", 1))
|
||||||
|
before = snapshot(t, root)
|
||||||
|
if err := Workspace(workspace, "check", io.Discard); err == nil || !strings.Contains(err.Error(), "catalog drift") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("drift check wrote files")
|
||||||
|
}
|
||||||
|
if err := Workspace(workspace, "generate", io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := readFile(t, shared); got != data {
|
||||||
|
t.Fatal("catalog not refreshed")
|
||||||
|
}
|
||||||
|
// Failed provider/preflight may not update even the shared metadata.
|
||||||
|
source := filepath.Join(root, "internal/apierrors/catalog.go")
|
||||||
|
writeFile(t, source, readFile(t, source)+"\ninvalid go source")
|
||||||
|
before = snapshot(t, root)
|
||||||
|
if err := Workspace(workspace, "generate", io.Discard); err == nil {
|
||||||
|
t.Fatal("provider failure ignored")
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("provider failure mutated project")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentSelectionBoundaries(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
local := filepath.Join(root, "api")
|
||||||
|
shared := filepath.Join(root, "shared")
|
||||||
|
entry := filepath.Join(local, "service.api")
|
||||||
|
writeFile(t, entry, "service demo {\n@handler list\nget /list returns ([]string)\n}\n")
|
||||||
|
writeFile(t, filepath.Join(shared, "errors.json"), `{"defaultStatus":400,"definitions":{"INVALID":{"code":400002,"message":"invalid"}}}`)
|
||||||
|
for _, cfg := range []string{
|
||||||
|
`{"source":"@common/errors.json","include":["UNKNOWN"]}`,
|
||||||
|
`{"source":"@common/errors.json","routes":{"GET /absent":["INVALID"]}}`,
|
||||||
|
`{"source":"@common/../api/service.api"}`,
|
||||||
|
`{"source":"../shared/errors.json"}`,
|
||||||
|
} {
|
||||||
|
writeFile(t, filepath.Join(local, importsFile), `{"roots":{"common":{"path":"../shared"}},"errors":`+cfg+`}`)
|
||||||
|
if _, err := Parse(entry); err == nil {
|
||||||
|
t.Fatal("bad selection accepted", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(local, importsFile), `{"roots":{"common":{"path":"../shared"}},"errors":{"source":"@common/errors.json","include":[]}}`)
|
||||||
|
spec, err := Parse(entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(documentRouteCatalog(spec, spec.Routes[0]).Definitions) != 0 {
|
||||||
|
t.Fatal("empty include ignored")
|
||||||
|
}
|
||||||
|
seeded, err := sharedSeeds(spec, filepath.Join(root, "generated"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var cfg sharedConfig
|
||||||
|
if err = json.Unmarshal(seeded, &cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Errors.Include == nil || !bytes.Contains(seeded, []byte(`"include": []`)) {
|
||||||
|
t.Fatal("empty selection lost during relocation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceCatalogPathsAndProviderValidation(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(root, "go.mod"), "module example.com/test\ngo 1.24.0\n")
|
||||||
|
good := WorkspaceCatalog{Dir: ".", Package: "example.com/test/internal/errors", Function: "Catalog", Output: "errors.json"}
|
||||||
|
cases := []WorkspaceCatalog{}
|
||||||
|
for _, change := range []func(*WorkspaceCatalog){
|
||||||
|
func(c *WorkspaceCatalog) { c.Dir = "../outside" },
|
||||||
|
func(c *WorkspaceCatalog) { c.Output = "../outside.json" },
|
||||||
|
func(c *WorkspaceCatalog) { c.Package = "example.com/pkg;bad" },
|
||||||
|
func(c *WorkspaceCatalog) { c.Function = "Catalog()" },
|
||||||
|
func(c *WorkspaceCatalog) { c.Output = "go.mod" },
|
||||||
|
} {
|
||||||
|
c := good
|
||||||
|
change(&c)
|
||||||
|
cases = append(cases, c)
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if _, err := exportWorkspaceCatalog(root, c); err == nil {
|
||||||
|
t.Fatal("bad provider accepted", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outside := filepath.Join(t.TempDir(), "errors.json")
|
||||||
|
writeFile(t, outside, `{}`)
|
||||||
|
if err := os.Symlink(outside, filepath.Join(root, "errors.json")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := prepareWorkspaceCatalogs(root, []WorkspaceCatalog{good}, false); err == nil {
|
||||||
|
t.Fatal("symlink output accepted")
|
||||||
|
}
|
||||||
|
if readFile(t, outside) != `{}` {
|
||||||
|
t.Fatal("outside file modified")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
const manifestPath = ".goOmni/apiManifest.json"
|
const manifestPath = ".goOmni/apiManifest.json"
|
||||||
|
|
||||||
type manifest struct {
|
type manifest struct {
|
||||||
|
GeneratorConfig bool `json:"generatorConfig,omitempty"`
|
||||||
TemplateVersion string `json:"templateVersion,omitempty"`
|
TemplateVersion string `json:"templateVersion,omitempty"`
|
||||||
Profile *projectProfile `json:"profile,omitempty"`
|
Profile *projectProfile `json:"profile,omitempty"`
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
@@ -109,7 +110,7 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
|||||||
return result, err
|
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 {
|
for name, data := range generated {
|
||||||
next.Generated[name] = digest(data)
|
next.Generated[name] = digest(data)
|
||||||
}
|
}
|
||||||
@@ -272,6 +273,9 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
|||||||
}
|
}
|
||||||
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
|
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
|
||||||
func ownedPath(name string) bool {
|
func ownedPath(name string) bool {
|
||||||
|
if name == "internal/mapping/mappingsGen.go" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if strings.HasPrefix(name, "internal/types/dto") && strings.HasSuffix(name, "Gen.go") && goName.MatchString(strings.TrimSuffix(filepath.Base(name), ".go")) {
|
if strings.HasPrefix(name, "internal/types/dto") && strings.HasSuffix(name, "Gen.go") && goName.MatchString(strings.TrimSuffix(filepath.Base(name), ".go")) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||||
for _, flag := range [][]string{{"--framework-version", "v0.14.0-preview"}, {"--framework-version=v0.14.0-preview"}} {
|
for _, flag := range [][]string{{"--framework-version", "v0.18.0-preview"}, {"--framework-version=v0.18.0-preview"}} {
|
||||||
project := filepath.Join(t.TempDir(), "billing")
|
project := filepath.Join(t.TempDir(), "billing")
|
||||||
args := append([]string{"--module", "example.com/billing"}, flag...)
|
args := append([]string{"--module", "example.com/billing"}, flag...)
|
||||||
if err := runNew(append(args, project)); err != nil {
|
if err := runNew(append(args, project)); err != nil {
|
||||||
@@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(content), "go-omni v0.14.0-preview") || strings.Contains(string(content), "replace ") {
|
if !strings.Contains(string(content), "go-omni v0.18.0-preview") || strings.Contains(string(content), "replace ") {
|
||||||
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package settings
|
package settings
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
{{if and (not .API) (ne .Storage "none")}}"strings"{{end}}
|
||||||
"time"
|
"time"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
@@ -41,10 +41,10 @@ func Load(path string)(Config,error){
|
|||||||
cfg:=Default();var err error
|
cfg:=Default();var err error
|
||||||
cfg,err=config.ApplyDefaults(cfg);if err!=nil{return Config{},err}
|
cfg,err=config.ApplyDefaults(cfg);if err!=nil{return Config{},err}
|
||||||
if path!="" {cfg,err=config.DecodeFile(path,cfg);if err!=nil{return Config{},err}}
|
if path!="" {cfg,err=config.DecodeFile(path,cfg);if err!=nil{return Config{},err}}
|
||||||
cfg,err=config.OverlayEnv(cfg,config.EnvOptions{Prefix:"GO_OMNI_"});if err!=nil{return Config{},err}
|
cfg,err=config.OverlayEnv(cfg,config.EnvOptions{Prefix:"GO_OMNI_",Empty:"ignore",Aliases:config.ServerEnvAliases("GO_OMNI_","server")});if err!=nil{return Config{},err}
|
||||||
// Preserve the documented framework aliases (HTTP_ADDR, LOG_LEVEL, etc).
|
output,outputSet:=os.LookupEnv("GO_OMNI_SERVER_LOG_OUTPUT");if !outputSet {output=os.Getenv("GO_OMNI_LOG_OUTPUT")}
|
||||||
cfg.Server,err=config.ApplyEnv(cfg.Server,"");if err!=nil{return Config{},err}
|
file,fileSet:=os.LookupEnv("GO_OMNI_SERVER_LOG_FILE");if !fileSet {file=os.Getenv("GO_OMNI_LOG_FILE")}
|
||||||
if output:=strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT"));output!="" && strings.TrimSpace(os.Getenv("GO_OMNI_LOG_FILE"))=="" {cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{}}
|
if output!="" && file=="" {cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{}}
|
||||||
return cfg,cfg.Validate()
|
return cfg,cfg.Validate()
|
||||||
}
|
}
|
||||||
func(c Config)Validate()error{
|
func(c Config)Validate()error{
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type Context struct {
|
|||||||
{{end}}
|
{{end}}
|
||||||
}
|
}
|
||||||
// Init is project-owned. Add dependencies here and immediately register each
|
// Init is project-owned. Add dependencies here and immediately register each
|
||||||
// cleanup with c.OnClose. Returning an error automatically rolls everything back.
|
// cleanup with c.OnClose. Background components use paired OnStop/OnDrain hooks
|
||||||
|
// and WorkContext. Failed initialization stops/drains before releasing resources;
|
||||||
|
// unjoined background work returns ErrResourcesRetained instead of closing its dependencies.
|
||||||
// Generated infrastructure is ready here. Never create another App or logger.
|
// Generated infrastructure is ready here. Never create another App or logger.
|
||||||
func(c *Context)Init(ctx context.Context)error {return nil}
|
func(c *Context)Init(ctx context.Context)error {return nil}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(res
|
|||||||
cfg:=settings.Default();if len(configs)==1{cfg=configs[0]}
|
cfg:=settings.Default();if len(configs)==1{cfg=configs[0]}
|
||||||
if err=cfg.Validate();err!=nil{return nil,err}
|
if err=cfg.Validate();err!=nil{return nil,err}
|
||||||
checker,err:=health.New();if err!=nil{return nil,err}
|
checker,err:=health.New();if err!=nil{return nil,err}
|
||||||
c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults(),Runtime:lifecycle.NewRuntime(ctx)}
|
runtime,err:=lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx,logger),cfg.Server.Lifecycle);if err!=nil{return nil,err}
|
||||||
|
c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults(),Runtime:runtime}
|
||||||
ctx=logx.WithLogger(c.Context(),logger)
|
ctx=logx.WithLogger(c.Context(),logger)
|
||||||
defer func(){if p:=recover();p!=nil{_ = c.Close();panic(p)};if err!=nil{err=c.Finish(err)}}()
|
defer func(){if p:=recover();p!=nil{_ = c.Close();panic(p)};if err!=nil{err=c.Finish(err)}}()
|
||||||
{{if eq .Database "mysql"}}
|
{{if eq .Database "mysql"}}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ server:
|
|||||||
enableLogger: true
|
enableLogger: true
|
||||||
slowRequestThreshold: 500ms
|
slowRequestThreshold: 500ms
|
||||||
shutdownTimeout: 10s
|
shutdownTimeout: 10s
|
||||||
{{if .API}} endpoints:
|
{{if .API}} lifecycle:
|
||||||
|
stopTimeout: 5s
|
||||||
|
drainTimeout: 10s
|
||||||
|
cancelTimeout: 5s
|
||||||
|
endpoints:
|
||||||
liveness:
|
liveness:
|
||||||
enabled: true
|
enabled: true
|
||||||
path: /health/live
|
path: /health/live
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
|||||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||||
// downloaded module version, which also has a module checksum.
|
// downloaded module version, which also has a module checksum.
|
||||||
const defaultFrameworkVersion = "v0.14.1-preview"
|
const defaultFrameworkVersion = "v0.18.0-preview"
|
||||||
|
|
||||||
// This target is not a claim that the release already exists. Source users
|
// This target is not a claim that the release already exists. Source users
|
||||||
// select a local framework checkout until the module templates are published.
|
// select a local framework checkout until the module templates are published.
|
||||||
|
|||||||
66
config/aliases_test.go
Normal file
66
config/aliases_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnifiedEnvAliases(t *testing.T) {
|
||||||
|
type project struct {
|
||||||
|
Server Config `json:"server"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
base := project{Server: Default(), Label: "base"}
|
||||||
|
for _, policy := range []string{"allow", "ignore", "reject"} {
|
||||||
|
t.Run(policy, func(t *testing.T) {
|
||||||
|
env := map[string]string{"TEST_HTTP_ADDR": "127.0.0.1:8099", "TEST_LOG_SERVICE": "", "TEST_SERVICE": "", "TEST_LABEL": ""}
|
||||||
|
opts := EnvOptions{Prefix: "TEST_", Empty: policy, Aliases: ServerEnvAliases("TEST_", "server"), Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }}
|
||||||
|
got, err := OverlayEnv(base, opts)
|
||||||
|
if policy == "reject" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Server.Address != "127.0.0.1:8099" {
|
||||||
|
t.Fatal("alias ignored")
|
||||||
|
}
|
||||||
|
if policy == "allow" && got.Label != "" || policy == "ignore" && got.Label != "base" {
|
||||||
|
t.Fatal("empty policy differs")
|
||||||
|
}
|
||||||
|
env["TEST_SERVER_ADDRESS"] = "127.0.0.1:9001"
|
||||||
|
env["TEST_HTTP_READ_TIMEOUT"] = "3s"
|
||||||
|
env["TEST_ENABLE_LOGGER"] = "false"
|
||||||
|
env["TEST_TRUSTED_PROXIES"] = `["127.0.0.1"]`
|
||||||
|
got, err = OverlayEnv(base, opts)
|
||||||
|
if err != nil || got.Server.Address != "127.0.0.1:9001" || got.Server.HTTP.ReadTimeout != 3*time.Second || got.Server.EnableLogger || len(got.Server.TrustedProxies) != 1 {
|
||||||
|
t.Fatal(got, err)
|
||||||
|
}
|
||||||
|
env["TEST_SERVER_ADDRESS"] = ""
|
||||||
|
got, err = OverlayEnv(base, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expected := ""
|
||||||
|
if policy == "ignore" {
|
||||||
|
expected = base.Server.Address
|
||||||
|
}
|
||||||
|
if got.Server.Address != expected {
|
||||||
|
t.Fatal("empty canonical unexpectedly fell through to alias")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if base.Server.Address != ":8080" || base.Label != "base" {
|
||||||
|
t.Fatal("defaults mutated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestAliasTypeErrorsDoNotExposeValue(t *testing.T) {
|
||||||
|
opts := EnvOptions{Prefix: "X_", Empty: "reject", Aliases: ServerEnvAliases("X_", ""), Lookup: func(k string) (string, bool) { return "private-invalid", k == "X_HTTP_READ_TIMEOUT" }}
|
||||||
|
if _, err := OverlayEnv(Default(), opts); err == nil || strings.Contains(err.Error(), "private-invalid") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
)
|
)
|
||||||
@@ -27,22 +28,23 @@ type TLSConfig struct {
|
|||||||
|
|
||||||
// Config controls the default runtime behavior of an App.
|
// Config controls the default runtime behavior of an App.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Request contract.RequestPolicy `json:"request"`
|
Request contract.RequestPolicy `json:"request"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
Transport string `json:"transport"`
|
Transport string `json:"transport"`
|
||||||
TLS TLSConfig `json:"tls"`
|
TLS TLSConfig `json:"tls"`
|
||||||
HTTP HTTPConfig `json:"http"`
|
HTTP HTTPConfig `json:"http"`
|
||||||
Endpoints EndpointsConfig `json:"endpoints"`
|
Endpoints EndpointsConfig `json:"endpoints"`
|
||||||
RequestIDHeader string `json:"requestIdHeader"`
|
RequestIDHeader string `json:"requestIdHeader"`
|
||||||
EnableLogger bool `json:"enableLogger"`
|
EnableLogger bool `json:"enableLogger"`
|
||||||
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
|
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
|
||||||
TrustedProxies []string `json:"trustedProxies"`
|
TrustedProxies []string `json:"trustedProxies"`
|
||||||
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
|
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
|
||||||
Log logx.Config `json:"log"`
|
Lifecycle lifecycle.ShutdownConfig `json:"lifecycle"`
|
||||||
Errors apierrors.Catalog `json:"errors"`
|
Log logx.Config `json:"log"`
|
||||||
Response response.Config `json:"response"`
|
Errors apierrors.Catalog `json:"errors"`
|
||||||
ResponseEncoder response.Encoder `json:"-"`
|
Response response.Config `json:"response"`
|
||||||
LogInstance *logx.Logger `json:"-"`
|
ResponseEncoder response.Encoder `json:"-"`
|
||||||
|
LogInstance *logx.Logger `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
@@ -51,6 +53,7 @@ func Default() Config {
|
|||||||
Transport: TransportNetHTTP,
|
Transport: TransportNetHTTP,
|
||||||
RequestIDHeader: defaultRequestIDHeader,
|
RequestIDHeader: defaultRequestIDHeader,
|
||||||
ShutdownTimeout: 10 * time.Second,
|
ShutdownTimeout: 10 * time.Second,
|
||||||
|
Lifecycle: lifecycle.DefaultShutdownConfig(),
|
||||||
Endpoints: DefaultEndpoints(),
|
Endpoints: DefaultEndpoints(),
|
||||||
Log: logx.Default(),
|
Log: logx.Default(),
|
||||||
}
|
}
|
||||||
@@ -58,6 +61,9 @@ func Default() Config {
|
|||||||
|
|
||||||
// Validate checks configuration values before an application starts.
|
// Validate checks configuration values before an application starts.
|
||||||
func (c Config) Validate() error {
|
func (c Config) Validate() error {
|
||||||
|
if err := c.Lifecycle.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := c.Request.Validate(); err != nil {
|
if err := c.Request.Validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
168
config/env.go
168
config/env.go
@@ -1,135 +1,83 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultEnvPrefix = "GO_OMNI_"
|
const defaultEnvPrefix = "GO_OMNI_"
|
||||||
|
|
||||||
// LoadFromEnv loads application settings from GO_OMNI_* variables. The
|
// ServerEnvAliases returns the documented short names for Config fields.
|
||||||
// database settings remain owned by database.ConfigFromEnv.
|
// field is the JSON path of Config in a project (usually "server"), or ""
|
||||||
func LoadFromEnv(prefix string) (Config, error) {
|
// for Config itself. Canonical names win over aliases. Both use the same
|
||||||
return applyEnv(Default(), prefix)
|
// EnvOptions.Empty/Lookup and typed parser; collections use JSON syntax.
|
||||||
|
func ServerEnvAliases(prefix, field string) map[string][]string {
|
||||||
|
root := prefix
|
||||||
|
if field != "" {
|
||||||
|
for _, part := range strings.Split(field, ".") {
|
||||||
|
root += envName(part) + "_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names := map[string]string{
|
||||||
|
"ADDRESS": "HTTP_ADDR", "TRANSPORT": "TRANSPORT", "TLS_CERT_FILE": "TLS_CERT_FILE", "TLS_KEY_FILE": "TLS_KEY_FILE",
|
||||||
|
"REQUEST_ID_HEADER": "REQUEST_ID_HEADER", "ENABLE_LOGGER": "ENABLE_LOGGER", "SLOW_REQUEST_THRESHOLD": "SLOW_REQUEST_THRESHOLD",
|
||||||
|
"TRUSTED_PROXIES": "TRUSTED_PROXIES", "SHUTDOWN_TIMEOUT": "SHUTDOWN_TIMEOUT",
|
||||||
|
"LOG_LEVEL": "LOG_LEVEL", "LOG_FORMAT": "LOG_FORMAT", "LOG_OUTPUT": "LOG_OUTPUT", "LOG_FILE": "LOG_FILE", "LOG_SERVICE": "SERVICE", "LOG_ENVIRONMENT": "ENVIRONMENT", "LOG_ADD_SOURCE": "LOG_ADD_SOURCE",
|
||||||
|
"HTTP_READ_HEADER_TIMEOUT": "HTTP_READ_HEADER_TIMEOUT", "HTTP_READ_TIMEOUT": "HTTP_READ_TIMEOUT", "HTTP_WRITE_TIMEOUT": "HTTP_WRITE_TIMEOUT", "HTTP_IDLE_TIMEOUT": "HTTP_IDLE_TIMEOUT", "HTTP_MAX_HEADER_BYTES": "HTTP_MAX_HEADER_BYTES",
|
||||||
|
"LIFECYCLE_STOP_TIMEOUT": "LIFECYCLE_STOP_TIMEOUT", "LIFECYCLE_DRAIN_TIMEOUT": "LIFECYCLE_DRAIN_TIMEOUT", "LIFECYCLE_CANCEL_TIMEOUT": "LIFECYCLE_CANCEL_TIMEOUT",
|
||||||
|
}
|
||||||
|
for _, endpoint := range []string{"LIVENESS", "READINESS", "OPENAPI"} {
|
||||||
|
for _, part := range []string{"ENABLED", "PATH", "RESPONSE_MODE"} {
|
||||||
|
if endpoint == "OPENAPI" && part == "RESPONSE_MODE" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := "ENDPOINTS_" + endpoint + "_" + part
|
||||||
|
names[name] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aliases := map[string][]string{}
|
||||||
|
for key, alias := range names {
|
||||||
|
if root+key != prefix+alias {
|
||||||
|
aliases[root+key] = []string{prefix + alias}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aliases
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyEnv overlays nonempty environment values on a caller's defaults or file
|
// LoadFromEnv loads defaults using the legacy short-name conventions.
|
||||||
// settings, then validates the result. Caller-owned slices are not retained.
|
func LoadFromEnv(prefix string) (Config, error) { return ApplyEnv(Default(), prefix) }
|
||||||
|
|
||||||
|
// ApplyEnv retains legacy whitespace trimming, ignored empty variables and
|
||||||
|
// comma-separated TRUSTED_PROXIES. New projects should use OverlayEnv with
|
||||||
|
// ServerEnvAliases for one explicit empty-value policy across the whole config.
|
||||||
func ApplyEnv(defaults Config, prefix string) (Config, error) {
|
func ApplyEnv(defaults Config, prefix string) (Config, error) {
|
||||||
defaults.Errors = defaults.Errors.Clone()
|
|
||||||
defaults.TrustedProxies = append([]string(nil), defaults.TrustedProxies...)
|
|
||||||
defaults.Log.RedactKeys = append([]string(nil), defaults.Log.RedactKeys...)
|
|
||||||
return applyEnv(defaults, prefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyEnv(cfg Config, prefix string) (Config, error) {
|
|
||||||
prefix = strings.TrimSpace(prefix)
|
prefix = strings.TrimSpace(prefix)
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
prefix = defaultEnvPrefix
|
prefix = defaultEnvPrefix
|
||||||
}
|
}
|
||||||
cfg.Address = envString(prefix+"HTTP_ADDR", cfg.Address)
|
lookup := func(key string) (string, bool) {
|
||||||
cfg.Transport = envString(prefix+"TRANSPORT", cfg.Transport)
|
raw, ok := os.LookupEnv(key)
|
||||||
cfg.TLS.CertFile = envString(prefix+"TLS_CERT_FILE", cfg.TLS.CertFile)
|
raw = strings.TrimSpace(raw)
|
||||||
cfg.TLS.KeyFile = envString(prefix+"TLS_KEY_FILE", cfg.TLS.KeyFile)
|
if ok && raw != "" && key == prefix+"TRUSTED_PROXIES" && !strings.HasPrefix(raw, "[") {
|
||||||
cfg.RequestIDHeader = envString(prefix+"REQUEST_ID_HEADER", cfg.RequestIDHeader)
|
values := strings.Split(raw, ",")
|
||||||
if value := strings.TrimSpace(os.Getenv(prefix + "TRUSTED_PROXIES")); value != "" {
|
for i := range values {
|
||||||
cfg.TrustedProxies = strings.Split(value, ",")
|
values[i] = strings.TrimSpace(values[i])
|
||||||
for i := range cfg.TrustedProxies {
|
|
||||||
cfg.TrustedProxies[i] = strings.TrimSpace(cfg.TrustedProxies[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cfg.Log.Level = envString(prefix+"LOG_LEVEL", cfg.Log.Level)
|
|
||||||
cfg.Log.Format = envString(prefix+"LOG_FORMAT", cfg.Log.Format)
|
|
||||||
cfg.Log.Output = envString(prefix+"LOG_OUTPUT", cfg.Log.Output)
|
|
||||||
cfg.Log.File = envString(prefix+"LOG_FILE", cfg.Log.File)
|
|
||||||
cfg.Log.Service = envString(prefix+"SERVICE", cfg.Log.Service)
|
|
||||||
cfg.Log.Environment = envString(prefix+"ENVIRONMENT", cfg.Log.Environment)
|
|
||||||
|
|
||||||
var err error
|
|
||||||
for _, endpoint := range []struct {
|
|
||||||
name string
|
|
||||||
enabled *bool
|
|
||||||
path *string
|
|
||||||
mode *string
|
|
||||||
}{
|
|
||||||
{"LIVENESS", &cfg.Endpoints.Liveness.Enabled, &cfg.Endpoints.Liveness.Path, &cfg.Endpoints.Liveness.ResponseMode},
|
|
||||||
{"READINESS", &cfg.Endpoints.Readiness.Enabled, &cfg.Endpoints.Readiness.Path, &cfg.Endpoints.Readiness.ResponseMode},
|
|
||||||
{"OPENAPI", &cfg.Endpoints.OpenAPI.Enabled, &cfg.Endpoints.OpenAPI.Path, nil},
|
|
||||||
} {
|
|
||||||
key := prefix + "ENDPOINTS_" + endpoint.name
|
|
||||||
*endpoint.enabled, err = envBool(key+"_ENABLED", *endpoint.enabled)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
*endpoint.path = envString(key+"_PATH", *endpoint.path)
|
|
||||||
if endpoint.mode != nil {
|
|
||||||
*endpoint.mode = envString(key+"_RESPONSE_MODE", *endpoint.mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, field := range []struct {
|
|
||||||
name string
|
|
||||||
value *time.Duration
|
|
||||||
}{
|
|
||||||
{"HTTP_READ_HEADER_TIMEOUT", &cfg.HTTP.ReadHeaderTimeout},
|
|
||||||
{"HTTP_READ_TIMEOUT", &cfg.HTTP.ReadTimeout},
|
|
||||||
{"HTTP_WRITE_TIMEOUT", &cfg.HTTP.WriteTimeout},
|
|
||||||
{"HTTP_IDLE_TIMEOUT", &cfg.HTTP.IdleTimeout},
|
|
||||||
} {
|
|
||||||
if value := strings.TrimSpace(os.Getenv(prefix + field.name)); value != "" {
|
|
||||||
*field.value, err = time.ParseDuration(value)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, fmt.Errorf("%s%s must be a duration: %w", prefix, field.name, err)
|
|
||||||
}
|
}
|
||||||
|
data, _ := json.Marshal(values)
|
||||||
|
raw = string(data)
|
||||||
}
|
}
|
||||||
|
return raw, ok
|
||||||
}
|
}
|
||||||
if value := strings.TrimSpace(os.Getenv(prefix + "HTTP_MAX_HEADER_BYTES")); value != "" {
|
defaults.Errors = defaults.Errors.Clone()
|
||||||
cfg.HTTP.MaxHeaderBytes, err = strconv.Atoi(value)
|
defaults.TrustedProxies = append([]string(nil), defaults.TrustedProxies...)
|
||||||
if err != nil {
|
defaults.Log.RedactKeys = append([]string(nil), defaults.Log.RedactKeys...)
|
||||||
return Config{}, fmt.Errorf("%sHTTP_MAX_HEADER_BYTES must be an integer: %w", prefix, err)
|
cfg, err := OverlayEnv(defaults, EnvOptions{Prefix: prefix, Empty: "ignore", Lookup: lookup, Aliases: ServerEnvAliases(prefix, "")})
|
||||||
}
|
if err != nil {
|
||||||
}
|
|
||||||
if cfg.EnableLogger, err = envBool(prefix+"ENABLE_LOGGER", cfg.EnableLogger); err != nil {
|
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
if cfg.Log.AddSource, err = envBool(prefix+"LOG_ADD_SOURCE", cfg.Log.AddSource); err != nil {
|
if err = cfg.Validate(); err != nil {
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(os.Getenv(prefix + "SLOW_REQUEST_THRESHOLD")); value != "" {
|
|
||||||
cfg.SlowRequestThreshold, err = time.ParseDuration(value)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, fmt.Errorf("%sSLOW_REQUEST_THRESHOLD must be a duration: %w", prefix, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(os.Getenv(prefix + "SHUTDOWN_TIMEOUT")); value != "" {
|
|
||||||
cfg.ShutdownTimeout, err = time.ParseDuration(value)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, fmt.Errorf("%sSHUTDOWN_TIMEOUT must be a duration: %w", prefix, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := cfg.Validate(); err != nil {
|
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func envString(key, fallback string) string {
|
|
||||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
func envBool(key string, fallback bool) (bool, error) {
|
|
||||||
value := strings.TrimSpace(os.Getenv(key))
|
|
||||||
if value == "" {
|
|
||||||
return fallback, nil
|
|
||||||
}
|
|
||||||
parsed, err := strconv.ParseBool(value)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("%s must be a boolean: %w", key, err)
|
|
||||||
}
|
|
||||||
return parsed, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ func LoadFile(path, prefix string) (Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
return applyEnv(cfg, prefix)
|
return ApplyEnv(cfg, prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reloader only accepts log.level changes. Static changes and invalid files
|
// Reloader only accepts log.level changes. Static changes and invalid files
|
||||||
|
|||||||
37
config/lifecycle_test.go
Normal file
37
config/lifecycle_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLifecycleConfiguration(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte("lifecycle:\n stopTimeout: 2s\n drainTimeout: 3s\n cancelTimeout: 4s\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.LoadFile(path, "PHASE_TEST_")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Lifecycle != (lifecycle.ShutdownConfig{StopTimeout: 2 * time.Second, DrainTimeout: 3 * time.Second, CancelTimeout: 4 * time.Second}) {
|
||||||
|
t.Fatal(cfg.Lifecycle)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"STOP_TIMEOUT", "DRAIN_TIMEOUT", "CANCEL_TIMEOUT"} {
|
||||||
|
t.Setenv("PHASE_TEST_LIFECYCLE_"+key, "6s")
|
||||||
|
}
|
||||||
|
cfg, err = config.ApplyEnv(cfg, "PHASE_TEST_")
|
||||||
|
if err != nil || cfg.Lifecycle.StopTimeout != 6*time.Second || cfg.Lifecycle.DrainTimeout != 6*time.Second || cfg.Lifecycle.CancelTimeout != 6*time.Second {
|
||||||
|
t.Fatal(cfg.Lifecycle, err)
|
||||||
|
}
|
||||||
|
for _, v := range []string{"bad", "-1s"} {
|
||||||
|
t.Setenv("PHASE_TEST_LIFECYCLE_DRAIN_TIMEOUT", v)
|
||||||
|
if _, err := config.ApplyEnv(cfg, "PHASE_TEST_"); err == nil {
|
||||||
|
t.Fatal("invalid duration accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ type EnvOptions struct {
|
|||||||
Prefix string
|
Prefix string
|
||||||
Empty string
|
Empty string
|
||||||
Lookup func(string) (string, bool)
|
Lookup func(string) (string, bool)
|
||||||
|
// Aliases maps canonical absolute environment names to ordered fallback names.
|
||||||
|
// A present canonical value wins, including an explicit empty value.
|
||||||
|
Aliases map[string][]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyDefaults applies envDefault tags to zero fields before file overlays.
|
// ApplyDefaults applies envDefault tags to zero fields before file overlays.
|
||||||
@@ -162,13 +165,22 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
|
|||||||
}
|
}
|
||||||
seen[key] = true
|
seen[key] = true
|
||||||
raw, present := options.Lookup(key)
|
raw, present := options.Lookup(key)
|
||||||
|
selectedKey := key
|
||||||
|
if !present && !defaults {
|
||||||
|
for _, alias := range options.Aliases[key] {
|
||||||
|
if raw, present = options.Lookup(alias); present {
|
||||||
|
selectedKey = alias
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if defaults {
|
if defaults {
|
||||||
raw, present = field.Tag.Lookup("envDefault")
|
raw, present = field.Tag.Lookup("envDefault")
|
||||||
present = present && dst.IsZero()
|
present = present && dst.IsZero()
|
||||||
}
|
}
|
||||||
if present && raw == "" && !defaults {
|
if present && raw == "" && !defaults {
|
||||||
if empty == "reject" {
|
if empty == "reject" {
|
||||||
return fmt.Errorf("config: %s must not be empty", key)
|
return fmt.Errorf("config: %s must not be empty", selectedKey)
|
||||||
}
|
}
|
||||||
if empty == "ignore" {
|
if empty == "ignore" {
|
||||||
present = false
|
present = false
|
||||||
@@ -177,7 +189,7 @@ func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error)
|
|||||||
if present {
|
if present {
|
||||||
parsed := reflect.New(dst.Type()).Elem()
|
parsed := reflect.New(dst.Type()).Elem()
|
||||||
if err := parseEnvValue(parsed, raw); err != nil {
|
if err := parseEnvValue(parsed, raw); err != nil {
|
||||||
return fmt.Errorf("config: %s must be a valid %s", key, dst.Type())
|
return fmt.Errorf("config: %s must be a valid %s", selectedKey, dst.Type())
|
||||||
}
|
}
|
||||||
dst.Set(parsed)
|
dst.Set(parsed)
|
||||||
}
|
}
|
||||||
|
|||||||
23
examples/contracts/arrays.api
Normal file
23
examples/contracts/arrays.api
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
syntax = "v1"
|
||||||
|
@app (defaultResponse: Common)
|
||||||
|
response Common (
|
||||||
|
codeType: int32
|
||||||
|
successCode: 0
|
||||||
|
errorCode: 9999
|
||||||
|
messageField: msg
|
||||||
|
dataMode: always
|
||||||
|
)
|
||||||
|
@server (group: arrays)
|
||||||
|
service arrays {
|
||||||
|
@handler strings
|
||||||
|
get /strings returns ([]string)
|
||||||
|
@handler integers
|
||||||
|
get /integers returns ([]int64)
|
||||||
|
@handler dynamic
|
||||||
|
get /dynamic returns ([]any)
|
||||||
|
@handler empty
|
||||||
|
get /empty returns ([]any)
|
||||||
|
@handler raw
|
||||||
|
@response raw
|
||||||
|
get /raw returns ([]bool)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"templateVersion": "v0.14.0-preview",
|
"generatorConfig": true,
|
||||||
|
"templateVersion": "v0.18.0-preview",
|
||||||
"profile": {
|
"profile": {
|
||||||
"database": "none",
|
"database": "none",
|
||||||
"storage": "none",
|
"storage": "none",
|
||||||
@@ -10,17 +11,17 @@
|
|||||||
"module": "example.com/managed",
|
"module": "example.com/managed",
|
||||||
"entry": "api/managed.api",
|
"entry": "api/managed.api",
|
||||||
"generated": {
|
"generated": {
|
||||||
"docs/openapi.json": "68c7e9e0294bf62937aa953a44732288c51571108c922a36a72faa72c4360b5f",
|
"docs/openapi.json": "0370fd6bd6a387e2ef21f6b42b009ce66749846b8bea3d481009a34f3af039a5",
|
||||||
"internal/handler/appGen.go": "f5251b4dc33e2312b0bdb71ead70d2171977d851265e124f640458d705a2cc9d",
|
"internal/handler/appGen.go": "f5251b4dc33e2312b0bdb71ead70d2171977d851265e124f640458d705a2cc9d",
|
||||||
"internal/handler/demo/handlersGen.go": "3c76d85dbe89eb5127d9d134dc625203ecf9719dd11d174c1f335691b99e1375",
|
"internal/handler/demo/handlersGen.go": "3c76d85dbe89eb5127d9d134dc625203ecf9719dd11d174c1f335691b99e1375",
|
||||||
"internal/handler/openapiGen.go": "ae9b794c63e0f3c8f9d200a5fc6977350139d4a6f00b42a26102c8630d907d03",
|
"internal/handler/openapiGen.go": "f24cab3abbb8557a3fc4dd93fba23cc0b093f3e0c944d3ba1bd5b0eeff2d96f3",
|
||||||
"internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399",
|
"internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399",
|
||||||
"internal/handler/routesGen.go": "76c1a5b2644e75e56553dc6e9e862a63128d902dd94807ce7c8aee5491cd1e3e",
|
"internal/handler/routesGen.go": "76c1a5b2644e75e56553dc6e9e862a63128d902dd94807ce7c8aee5491cd1e3e",
|
||||||
"internal/middleware/registryGen.go": "e5f47efa57a1432765ef3419bae59dbf3b4d55b3aac0a6c935ce7a52ba6b47f4",
|
"internal/middleware/registryGen.go": "e5f47efa57a1432765ef3419bae59dbf3b4d55b3aac0a6c935ce7a52ba6b47f4",
|
||||||
"internal/svc/runtimeGen.go": "78ffd0b2a196fbc29e29473919d1341e8a2f508f6594ad148c0b399fbcc40018",
|
"internal/svc/runtimeGen.go": "29f6afb6614881935099c1845ecb3f7902ee773caa077940f566cb8dfdbcdbb1",
|
||||||
"internal/types/dtoManagedd28645a2af1cGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
|
"internal/types/dtoManagedd28645a2af1cGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
|
||||||
"internal/types/typesGen.go": "372d67f23a313b3e77d2a3b7a236315db9721bc63350bf1f71db1293e57570d1",
|
"internal/types/typesGen.go": "372d67f23a313b3e77d2a3b7a236315db9721bc63350bf1f71db1293e57570d1",
|
||||||
"main.go": "8a4051c0d51fc14ddf22c36d09de706f02499817accc937f0785e3d0fb5e2aa5"
|
"main.go": "64e26d97de7e7c6c17f47186785edc73d43c23d6c20b50bfc7fff6ef2e914f17"
|
||||||
},
|
},
|
||||||
"contract": {
|
"contract": {
|
||||||
"routes": {
|
"routes": {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ module example.com/managed
|
|||||||
|
|
||||||
go 1.22.0
|
go 1.22.0
|
||||||
|
|
||||||
require git.yuntuo.hk/k2safe/go-omni v0.14.0-preview
|
require git.yuntuo.hk/k2safe/go-omni v0.18.0-preview
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,16 +37,19 @@ func Load(path string) (Config, error) {
|
|||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cfg, err = config.OverlayEnv(cfg, config.EnvOptions{Prefix: "GO_OMNI_"})
|
cfg, err = config.OverlayEnv(cfg, config.EnvOptions{Prefix: "GO_OMNI_", Empty: "ignore", Aliases: config.ServerEnvAliases("GO_OMNI_", "server")})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
// Preserve the documented framework aliases (HTTP_ADDR, LOG_LEVEL, etc).
|
output, outputSet := os.LookupEnv("GO_OMNI_SERVER_LOG_OUTPUT")
|
||||||
cfg.Server, err = config.ApplyEnv(cfg.Server, "")
|
if !outputSet {
|
||||||
if err != nil {
|
output = os.Getenv("GO_OMNI_LOG_OUTPUT")
|
||||||
return Config{}, err
|
|
||||||
}
|
}
|
||||||
if output := strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT")); output != "" && strings.TrimSpace(os.Getenv("GO_OMNI_LOG_FILE")) == "" {
|
file, fileSet := os.LookupEnv("GO_OMNI_SERVER_LOG_FILE")
|
||||||
|
if !fileSet {
|
||||||
|
file = os.Getenv("GO_OMNI_LOG_FILE")
|
||||||
|
}
|
||||||
|
if output != "" && file == "" {
|
||||||
cfg.Server.Log.File = ""
|
cfg.Server.Log.File = ""
|
||||||
cfg.Server.Log.Rotation = logx.RotationConfig{}
|
cfg.Server.Log.Rotation = logx.RotationConfig{}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -37,7 +37,11 @@ func New(ctx context.Context, logger *logx.Logger, configs ...config.Config) (re
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c := &ServiceContext{Config: cfg, Logger: logger, Health: checker, Middlewares: middleware.Defaults(), Runtime: lifecycle.NewRuntime(ctx)}
|
runtime, err := lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx, logger), cfg.Server.Lifecycle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c := &ServiceContext{Config: cfg, Logger: logger, Health: checker, Middlewares: middleware.Defaults(), Runtime: runtime}
|
||||||
ctx = logx.WithLogger(c.Context(), logger)
|
ctx = logx.WithLogger(c.Context(), logger)
|
||||||
defer func() {
|
defer func() {
|
||||||
if p := recover(); p != nil {
|
if p := recover(); p != nil {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"example.com/managed/internal/handler"
|
"example.com/managed/internal/handler"
|
||||||
"example.com/managed/internal/svc"
|
"example.com/managed/internal/svc"
|
||||||
"flag"
|
"flag"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -33,7 +34,13 @@ func run(ctx context.Context, path string) (runErr error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() { runErr = errors.Join(runErr, logger.Close()) }()
|
// Unjoined background work may still log. The owning process exits on this
|
||||||
|
// error; do not close its borrowed logger before that work has finished.
|
||||||
|
defer func() {
|
||||||
|
if !errors.Is(runErr, lifecycle.ErrResourcesRetained) {
|
||||||
|
runErr = errors.Join(runErr, logger.Close())
|
||||||
|
}
|
||||||
|
}()
|
||||||
deps, err := svc.New(ctx, logger, cfg)
|
deps, err := svc.New(ctx, logger, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
4
examples/managedService/omni.gen.json
Normal file
4
examples/managedService/omni.gen.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"repository": "none",
|
||||||
|
"middleware": "handlers"
|
||||||
|
}
|
||||||
@@ -1,5 +1,67 @@
|
|||||||
# lifecycle
|
# lifecycle
|
||||||
|
|
||||||
|
## 后台组件分阶段关闭
|
||||||
|
|
||||||
|
从 `v0.15.0-preview` 起,生成项目使用以下顺序:HTTP 停止接收并排空 → 全部 OnStop → 全部 OnDrain → 逆序 OnClose → 关闭日志。初始化、创建 App 或监听失败也经过后台收尾。Runtime 不导入 Orbit、数据库、ORM 或 Redis。
|
||||||
|
|
||||||
|
在项目维护的 svc.Init 中登记即可,main/runtimeGen 由生成器更新。例如使用可选的内存 task.Runner:
|
||||||
|
|
||||||
|
```go
|
||||||
|
jobs, err := task.New(task.Config{Workers: 4, QueueSize: 128})
|
||||||
|
if err != nil { return err }
|
||||||
|
c.OnStop("jobs", jobs.StopAccepting)
|
||||||
|
c.OnDrain("jobs", jobs.Wait)
|
||||||
|
// 把 jobs 保存到项目的 svc 字段,供 Logic 使用。
|
||||||
|
// 已接受任务使用 c.WorkContext(),保留到 HTTP 和后台正常排空结束。
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库、Redis 或第三方客户端仍用已有的 `c.OnClose(client.Close)`;框架创建的 DB 已登记,不要再次登记。这里的 jobs 不再额外登记 Close:StopAccepting 停止定时调度/提交,Wait 等待 worker 与回调全部退出,已完成其生命周期。
|
||||||
|
|
||||||
|
任何自定义组件都可以登记 `func(context.Context) error`,不必实现或依赖框架任务接口:
|
||||||
|
|
||||||
|
- OnStop 只停止接收新工作,不能关闭正在执行的工作所需的资源。每个 OnStop 必须有同名 OnDrain;缺失时拒绝释放共享资源。
|
||||||
|
- OnDrain 返回 nil 表示已经收尾,包含最终写入、确认和监控协程退出。返回错误不作为已完成证明;运行中的业务错误可以先通过 RequestShutdown(err) 上报,再等完成后由 Drain 返回 nil。
|
||||||
|
- 同一阶段的回调并发执行,不按登记顺序执行;框架等待整个阶段结束后才进入下一阶段。有组件之间的先后依赖时,组合为一个 Stop/Drain 登记项,由该项明确组织内部顺序。Stop 不能等待 Drain 才会完成的工作。
|
||||||
|
- OnClose 保留原来的同步逆序执行;普通项目无后台组件时无需登记新钩子。生命周期钩子不能再次调用 Runtime.Close/Finish,否则会等待自身;可以调用 RequestShutdown。
|
||||||
|
- 所有依赖初始化成功后立即登记,再启动使用它们的工作。构造器交付前失败的内部资源仍由构造器释放。
|
||||||
|
|
||||||
|
### 两种 context
|
||||||
|
|
||||||
|
`c.Context()` / `c.Lifecycle.Context()` 是早期退出通知,会随外部信号或后台故障取消。`c.WorkContext()` 保留初始化 context 的值但不继承其取消/截止时间,允许已接受工作在 HTTP 收尾期间继续使用依赖。
|
||||||
|
|
||||||
|
停止接收工作由 OnStop 负责,不能仅等待 WorkContext 取消。正常情况下 WorkContext 在后台排空完成后取消;阶段失败或超时会请求取消工作。框架不假设取消已经意味着 goroutine 结束,仍以 Drain 的完成为准。每个业务任务的执行时限继续由项目/任务组件设置。
|
||||||
|
|
||||||
|
### 时限与失败
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
shutdownTimeout: 10s # HTTP 排空时限,保持原语义
|
||||||
|
lifecycle:
|
||||||
|
stopTimeout: 5s
|
||||||
|
drainTimeout: 10s
|
||||||
|
cancelTimeout: 5s
|
||||||
|
```
|
||||||
|
|
||||||
|
这些是各阶段的整体预算,不是每个回调分别等待这么久。零使用默认值,负值在启动前拒绝。可用 `GO_OMNI_LIFECYCLE_STOP_TIMEOUT`、`GO_OMNI_LIFECYCLE_DRAIN_TIMEOUT`、`GO_OMNI_LIFECYCLE_CANCEL_TIMEOUT` 覆盖;生成配置还支持完整的 `GO_OMNI_SERVER_LIFECYCLE_*` 路径,别名优先。配置应在 config 中设置,不放在创建 App 时才执行的 AppOptions 中。
|
||||||
|
|
||||||
|
阶段超时会取消阶段 context 和 WorkContext,再给尚未返回的钩子一个 cancelTimeout 收尾窗口。若所有钩子都返回 nil,可以释放依赖,但仍返回超时错误,进程非零退出。task.Wait 收到取消会取消其任务并返回错误;这不证明全部任务已退出,因此仍按以下保守规则处理。
|
||||||
|
|
||||||
|
Stop 超时且仍未返回的组件,不会再并发调用它同名的 Drain;其他组件仍可继续收尾。此时共享资源始终保留,避免未退出的 Stop 和 Drain 同时操作同一个组件。
|
||||||
|
|
||||||
|
钩子返回错误、panic、缺少匹配 Drain,或窗口结束后仍有钩子未返回时,Runtime 返回可由 `errors.Is(err, lifecycle.ErrResourcesRetained)` 判断的错误,**跳过全部 OnClose**;生成入口也保留借用的日志实例并非零退出,由进程退出释放剩余 OS 资源。不会悄悄重试 Close,也不会让晚到的后台结果触发自动关闭。错误链保留初始化/监听/后台故障及具体 HookError 的阶段、名称和原始原因。
|
||||||
|
|
||||||
|
手写入口必须处理这个错误并终止该服务,不能继续运行半关闭的 Runtime;嵌入式宿主需自行安排隔离/终止策略。Go 不能强杀不响应取消的 goroutine,有限等待与保证它已经结束不能同时无条件成立。OnClose 仍要求自身能返回;本版阶段时限不会强制中断资源 Close。HTTP 超时沿用现有传输层规则,业务处理器仍须响应请求取消。
|
||||||
|
|
||||||
|
手写项目使用 `lifecycle.NewRuntimeWithConfig(ctx, cfg)`;完成 HTTP 排空后调用 `runtime.Finish(runErr)`。库本身不执行 os.Exit。
|
||||||
|
|
||||||
|
### 升级
|
||||||
|
|
||||||
|
v0.14.x 受管理项目将 module 和 CLI 一起升级到 v0.15.0-preview,再运行 `go-omni api generate`、`go-omni api generate --check` 和业务测试。生成器更新 main/runtimeGen,保留 svc、Logic 和配置。已有配置不添加新字段也会使用默认时限,需要调整时再编辑 yaml。
|
||||||
|
|
||||||
|
要获得后台分阶段保证,项目需把原先 `OnClose(worker.Close)` 的后台组件改成对应 Stop/Drain,并使用适合的工作 context;框架不会猜测旧 Close 回调负责数据库还是 worker。仅在 OnClose 中登记的旧任务继续遵循旧的逆序清理规则。
|
||||||
|
|
||||||
|
## 退出通知
|
||||||
|
|
||||||
Controller 是服务退出通知入口,不管理具体数据库、租约或后台任务。生成项目已经将它接入 svc 与 main,业务在 svc 调用 `c.RequestShutdown(err)` 即可,见 [项目指南](../PROJECT.md#依赖主动申请退出)。
|
Controller 是服务退出通知入口,不管理具体数据库、租约或后台任务。生成项目已经将它接入 svc 与 main,业务在 svc 调用 `c.RequestShutdown(err)` 即可,见 [项目指南](../PROJECT.md#依赖主动申请退出)。
|
||||||
|
|
||||||
手写服务也可组合现有 App:
|
手写服务也可组合现有 App:
|
||||||
|
|||||||
184
lifecycle/phases.go
Normal file
184
lifecycle/phases.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
package lifecycle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrResourcesRetained means background quiescence could not be established.
|
||||||
|
// OnClose callbacks were deliberately skipped. Close is not retried; an owning
|
||||||
|
// process should exit unsuccessfully instead of continuing with a stopped Runtime.
|
||||||
|
var ErrResourcesRetained = errors.New("lifecycle: background shutdown incomplete; shared resources retained")
|
||||||
|
|
||||||
|
type ShutdownConfig struct {
|
||||||
|
StopTimeout time.Duration `json:"stopTimeout"`
|
||||||
|
DrainTimeout time.Duration `json:"drainTimeout"`
|
||||||
|
CancelTimeout time.Duration `json:"cancelTimeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultShutdownConfig() ShutdownConfig {
|
||||||
|
return ShutdownConfig{StopTimeout: 5 * time.Second, DrainTimeout: 10 * time.Second, CancelTimeout: 5 * time.Second}
|
||||||
|
}
|
||||||
|
func (c ShutdownConfig) Validate() error {
|
||||||
|
if c.StopTimeout < 0 || c.DrainTimeout < 0 || c.CancelTimeout < 0 {
|
||||||
|
return errors.New("lifecycle: shutdown timeouts must be nonnegative")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (c ShutdownConfig) defaults() ShutdownConfig {
|
||||||
|
d := DefaultShutdownConfig()
|
||||||
|
if c.StopTimeout == 0 {
|
||||||
|
c.StopTimeout = d.StopTimeout
|
||||||
|
}
|
||||||
|
if c.DrainTimeout == 0 {
|
||||||
|
c.DrainTimeout = d.DrainTimeout
|
||||||
|
}
|
||||||
|
if c.CancelTimeout == 0 {
|
||||||
|
c.CancelTimeout = d.CancelTimeout
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// HookError identifies the failed phase/component and preserves the original error.
|
||||||
|
type HookError struct {
|
||||||
|
Phase, Name string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HookError) Error() string { return fmt.Sprintf("lifecycle %s %q: %v", e.Phase, e.Name, e.Err) }
|
||||||
|
func (e *HookError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
// phaseError keeps diagnostics separate from proof that every hook succeeded.
|
||||||
|
// A deadline is still reported when all callbacks finish during cancellation grace.
|
||||||
|
type phaseError struct {
|
||||||
|
err error
|
||||||
|
unsafe bool
|
||||||
|
unjoined map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *phaseError) Error() string { return e.err.Error() }
|
||||||
|
func (e *phaseError) Unwrap() error { return e.err }
|
||||||
|
func unsafePhase(err error) bool { var p *phaseError; return errors.As(err, &p) && p.unsafe }
|
||||||
|
|
||||||
|
type shutdownHook struct {
|
||||||
|
name string
|
||||||
|
fn func(context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnStop registers a prompt, idempotent stop-admission callback. It must not
|
||||||
|
// close resources used by accepted work. All stop hooks precede all drain hooks.
|
||||||
|
// Hooks within a phase run concurrently: combine dependent components in one hook.
|
||||||
|
// Every OnStop requires a matching OnDrain with the same name.
|
||||||
|
func (r *Runtime) OnStop(name string, fn func(context.Context) error) { r.addHook(&r.stops, name, fn) }
|
||||||
|
|
||||||
|
// OnDrain registers proof of quiescence. Return nil only after the component can
|
||||||
|
// no longer use shared resources, including final acknowledgements/cleanup.
|
||||||
|
// Deadline cancellation asks the component to stop; returning ctx.Err alone does
|
||||||
|
// not prove completion. A callback may finish during the cancellation grace.
|
||||||
|
func (r *Runtime) OnDrain(name string, fn func(context.Context) error) {
|
||||||
|
r.addHook(&r.drains, name, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runtime) addHook(list *[]shutdownHook, name string, fn func(context.Context) error) {
|
||||||
|
if fn == nil || name == "" || len(name) > 128 || strings.TrimSpace(name) != name || strings.IndexFunc(name, unicode.IsControl) >= 0 {
|
||||||
|
panic("lifecycle: valid hook name and function required")
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.closed {
|
||||||
|
panic("lifecycle: register hook after Close")
|
||||||
|
}
|
||||||
|
for _, h := range *list {
|
||||||
|
if h.name == name {
|
||||||
|
panic("lifecycle: duplicate phase hook name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*list = append(*list, shutdownHook{name, fn})
|
||||||
|
}
|
||||||
|
|
||||||
|
func callHook(ctx context.Context, h shutdownHook) (err error) {
|
||||||
|
defer func() {
|
||||||
|
if p := recover(); p != nil {
|
||||||
|
if cause, ok := p.(error); ok {
|
||||||
|
err = fmt.Errorf("hook panic: %w", cause)
|
||||||
|
} else {
|
||||||
|
err = fmt.Errorf("hook panic: %v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return h.fn(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runtime) runPhase(phase string, hooks []shutdownHook, timeout time.Duration) error {
|
||||||
|
if len(hooks) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.work), timeout)
|
||||||
|
defer cancel()
|
||||||
|
type result struct {
|
||||||
|
index int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
results := make(chan result, len(hooks))
|
||||||
|
pending := make([]bool, len(hooks))
|
||||||
|
for i, h := range hooks {
|
||||||
|
pending[i] = true
|
||||||
|
go func(i int, h shutdownHook) { results <- result{i, callHook(ctx, h)} }(i, h)
|
||||||
|
}
|
||||||
|
remaining := len(hooks)
|
||||||
|
var errs error
|
||||||
|
unsafe := false
|
||||||
|
accept := func(v result) {
|
||||||
|
pending[v.index] = false
|
||||||
|
remaining--
|
||||||
|
if v.err != nil {
|
||||||
|
unsafe = true
|
||||||
|
errs = errors.Join(errs, &HookError{phase, hooks[v.index].name, v.err})
|
||||||
|
r.cancelWork()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for remaining > 0 {
|
||||||
|
select {
|
||||||
|
case v := <-results:
|
||||||
|
accept(v)
|
||||||
|
case <-ctx.Done():
|
||||||
|
r.cancelWork()
|
||||||
|
errs = errors.Join(errs, &HookError{phase, "deadline", ctx.Err()})
|
||||||
|
grace := time.NewTimer(r.shutdown.CancelTimeout)
|
||||||
|
defer grace.Stop()
|
||||||
|
for remaining > 0 {
|
||||||
|
select {
|
||||||
|
case v := <-results:
|
||||||
|
accept(v)
|
||||||
|
case <-grace.C:
|
||||||
|
// Collect callbacks already returned at the deadline before marking pending ones.
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case v := <-results:
|
||||||
|
accept(v)
|
||||||
|
default:
|
||||||
|
goto collected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collected:
|
||||||
|
unjoined := make(map[string]bool)
|
||||||
|
for i, active := range pending {
|
||||||
|
if active {
|
||||||
|
unjoined[hooks[i].name] = true
|
||||||
|
errs = errors.Join(errs, &HookError{phase, hooks[i].name, context.DeadlineExceeded})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &phaseError{err: errs, unsafe: unsafe || remaining > 0, unjoined: unjoined}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errs != nil {
|
||||||
|
return &phaseError{err: errs, unsafe: unsafe}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
234
lifecycle/phases_test.go
Normal file
234
lifecycle/phases_test.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
package lifecycle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testRuntime(t *testing.T) *Runtime {
|
||||||
|
t.Helper()
|
||||||
|
r, err := NewRuntimeWithConfig(context.Background(), ShutdownConfig{StopTimeout: time.Second, DrainTimeout: time.Second, CancelTimeout: time.Second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhasesBarrierAndWorkLifetime(t *testing.T) {
|
||||||
|
parent, cancel := context.WithCancel(context.WithValue(context.Background(), struct{}{}, "value"))
|
||||||
|
defer cancel()
|
||||||
|
r := NewRuntime(parent)
|
||||||
|
var stops, drains atomic.Int32
|
||||||
|
var mu sync.Mutex
|
||||||
|
var closed []int
|
||||||
|
started := make(chan struct{}, 2)
|
||||||
|
release := make(chan struct{})
|
||||||
|
// Deliberately interleave registration. Phases take precedence over order.
|
||||||
|
r.OnClose(func() error { mu.Lock(); defer mu.Unlock(); closed = append(closed, 1); return nil })
|
||||||
|
for _, name := range []string{"producer", "consumer"} {
|
||||||
|
r.OnStop(name, func(ctx context.Context) error {
|
||||||
|
if r.WorkContext().Err() != nil || ctx.Err() != nil {
|
||||||
|
return errors.New("work canceled before drain")
|
||||||
|
}
|
||||||
|
stops.Add(1)
|
||||||
|
started <- struct{}{}
|
||||||
|
<-release
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
r.OnDrain(name, func(ctx context.Context) error {
|
||||||
|
if stops.Load() != 2 || r.WorkContext().Err() != nil || ctx.Err() != nil {
|
||||||
|
return errors.New("stage barrier failed")
|
||||||
|
}
|
||||||
|
drains.Add(1)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
r.OnClose(func() error {
|
||||||
|
if drains.Load() != 2 {
|
||||||
|
return errors.New("dependency closed early")
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
closed = append(closed, 2)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
if r.WorkContext().Err() != nil || r.WorkContext().Value(struct{}{}) != "value" {
|
||||||
|
t.Fatal("work context inherited cancellation or lost values")
|
||||||
|
}
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- r.Finish(nil) }()
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("stop callbacks did not run concurrently")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if drains.Load() != 0 {
|
||||||
|
t.Fatal("drain ran before stop barrier")
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(closed, []int{2, 1}) || r.WorkContext().Err() == nil {
|
||||||
|
t.Fatal(closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhasesFailuresRetainDependenciesAndCauses(t *testing.T) {
|
||||||
|
for _, phase := range []string{"stop", "drain"} {
|
||||||
|
for _, panics := range []bool{false, true} {
|
||||||
|
t.Run(phase+map[bool]string{false: "Error", true: "Panic"}[panics], func(t *testing.T) {
|
||||||
|
r := testRuntime(t)
|
||||||
|
cause := errors.New("component cause")
|
||||||
|
fatal := errors.New("fatal monitor")
|
||||||
|
cleanup := atomic.Int32{}
|
||||||
|
other := atomic.Int32{}
|
||||||
|
r.OnClose(func() error { cleanup.Add(1); return nil })
|
||||||
|
hook := func(context.Context) error {
|
||||||
|
r.RequestShutdown(fatal)
|
||||||
|
if panics {
|
||||||
|
panic(cause)
|
||||||
|
}
|
||||||
|
return cause
|
||||||
|
}
|
||||||
|
if phase == "stop" {
|
||||||
|
r.OnStop("broken", hook)
|
||||||
|
} else {
|
||||||
|
r.OnDrain("broken", hook)
|
||||||
|
}
|
||||||
|
r.OnDrain("other", func(context.Context) error { other.Add(1); return nil })
|
||||||
|
err := r.Finish(nil)
|
||||||
|
var detail *HookError
|
||||||
|
if !errors.Is(err, ErrResourcesRetained) || !errors.Is(err, cause) || !errors.Is(err, fatal) || !errors.As(err, &detail) || detail.Phase != phase {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cleanup.Load() != 0 || other.Load() != 1 || r.WorkContext().Err() == nil {
|
||||||
|
t.Fatal("unsafe cleanup or omitted drain")
|
||||||
|
}
|
||||||
|
if !errors.Is(r.Close(), ErrResourcesRetained) || other.Load() != 1 {
|
||||||
|
t.Fatal("close retried hooks")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhasesDeadlineCancelsWorkAndJoinsBeforeClose(t *testing.T) {
|
||||||
|
r, err := NewRuntimeWithConfig(context.Background(), ShutdownConfig{DrainTimeout: 20 * time.Millisecond, CancelTimeout: time.Second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var joined atomic.Bool
|
||||||
|
var closed atomic.Bool
|
||||||
|
r.OnDrain("worker", func(ctx context.Context) error {
|
||||||
|
<-ctx.Done()
|
||||||
|
<-r.WorkContext().Done()
|
||||||
|
// Cleanup can still use shared resources after forced cancellation.
|
||||||
|
if closed.Load() {
|
||||||
|
return errors.New("dependency closed during cancellation")
|
||||||
|
}
|
||||||
|
joined.Store(true)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
r.OnClose(func() error {
|
||||||
|
if !joined.Load() {
|
||||||
|
t.Error("worker not joined")
|
||||||
|
}
|
||||||
|
closed.Store(true)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
err = r.Close()
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrResourcesRetained) || !closed.Load() {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhasesUncooperativeHookIsBoundedAndNeverClosesDependencies(t *testing.T) {
|
||||||
|
for _, phase := range []string{"stop", "drain"} {
|
||||||
|
t.Run(phase, func(t *testing.T) {
|
||||||
|
r, err := NewRuntimeWithConfig(context.Background(), ShutdownConfig{StopTimeout: 20 * time.Millisecond, DrainTimeout: 20 * time.Millisecond, CancelTimeout: 20 * time.Millisecond})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
release := make(chan struct{})
|
||||||
|
finished := make(chan struct{})
|
||||||
|
var closed atomic.Int32
|
||||||
|
hook := func(context.Context) error { defer close(finished); <-release; return nil }
|
||||||
|
if phase == "stop" {
|
||||||
|
r.OnStop("stuck", hook)
|
||||||
|
r.OnDrain("stuck", func(context.Context) error { t.Error("Drain overlapped unfinished Stop"); return nil })
|
||||||
|
} else {
|
||||||
|
r.OnDrain("stuck", hook)
|
||||||
|
}
|
||||||
|
r.OnClose(func() error { closed.Add(1); return nil })
|
||||||
|
began := time.Now()
|
||||||
|
err = r.Finish(errors.New("listen failed"))
|
||||||
|
close(release)
|
||||||
|
<-finished
|
||||||
|
if time.Since(began) > time.Second || !errors.Is(err, ErrResourcesRetained) || !errors.Is(err, context.DeadlineExceeded) || closed.Load() != 0 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = r.Close()
|
||||||
|
if closed.Load() != 0 {
|
||||||
|
t.Fatal("late cleanup raced work")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhasesConcurrentFinishAndValidation(t *testing.T) {
|
||||||
|
if _, err := NewRuntimeWithConfig(nil, ShutdownConfig{DrainTimeout: -1}); err == nil {
|
||||||
|
t.Fatal("negative timeout accepted")
|
||||||
|
}
|
||||||
|
r := testRuntime(t)
|
||||||
|
var called atomic.Int32
|
||||||
|
r.OnStop("worker", func(context.Context) error { called.Add(1); return nil })
|
||||||
|
r.OnDrain("worker", func(context.Context) error { called.Add(1); return nil })
|
||||||
|
r.OnClose(func() error { called.Add(1); return nil })
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := r.Finish(nil); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if called.Load() != 3 {
|
||||||
|
t.Fatal(called.Load())
|
||||||
|
}
|
||||||
|
for _, fn := range []func(){func() { r.OnStop("late", func(context.Context) error { return nil }) }, func() { NewRuntime(nil).OnDrain("bad\nname", func(context.Context) error { return nil }) }, func() {
|
||||||
|
x := NewRuntime(nil)
|
||||||
|
x.OnStop("duplicate", func(context.Context) error { return nil })
|
||||||
|
x.OnStop("duplicate", func(context.Context) error { return nil })
|
||||||
|
}} {
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Error("invalid registration accepted")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingDrainRetainsResources(t *testing.T) {
|
||||||
|
r := testRuntime(t)
|
||||||
|
r.OnStop("worker", func(context.Context) error { return nil })
|
||||||
|
closed := false
|
||||||
|
r.OnClose(func() error { closed = true; return nil })
|
||||||
|
if err := r.Close(); !errors.Is(err, ErrResourcesRetained) || closed {
|
||||||
|
t.Fatal("missing completion proof accepted", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,17 +11,42 @@ import (
|
|||||||
// must finish draining before calling Close. Embed *Runtime in the project's svc.
|
// must finish draining before calling Close. Embed *Runtime in the project's svc.
|
||||||
// The logger and any other borrowed resources must not be registered here.
|
// The logger and any other borrowed resources must not be registered here.
|
||||||
type Runtime struct {
|
type Runtime struct {
|
||||||
Lifecycle *Controller
|
Lifecycle *Controller
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cleanups []func() error
|
cleanups []func() error
|
||||||
closed bool
|
closed bool
|
||||||
once sync.Once
|
once sync.Once
|
||||||
closeErr error
|
closeErr error
|
||||||
|
stops, drains []shutdownHook
|
||||||
|
shutdown ShutdownConfig
|
||||||
|
work context.Context
|
||||||
|
cancelWork context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRuntime(ctx context.Context) *Runtime { return &Runtime{Lifecycle: New(ctx)} }
|
func NewRuntime(ctx context.Context) *Runtime {
|
||||||
func (r *Runtime) Context() context.Context { return r.Lifecycle.Context() }
|
r, _ := NewRuntimeWithConfig(ctx, DefaultShutdownConfig())
|
||||||
func (r *Runtime) RequestShutdown(err error) { r.Lifecycle.RequestShutdown(err) }
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRuntimeWithConfig separates the stop notification from the lifetime of
|
||||||
|
// accepted background work. Zero timeout fields use documented defaults.
|
||||||
|
func NewRuntimeWithConfig(ctx context.Context, cfg ShutdownConfig) (*Runtime, error) {
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
work, cancel := context.WithCancel(context.WithoutCancel(ctx))
|
||||||
|
return &Runtime{Lifecycle: New(ctx), shutdown: cfg.defaults(), work: work, cancelWork: cancel}, nil
|
||||||
|
}
|
||||||
|
func (r *Runtime) Context() context.Context { return r.Lifecycle.Context() }
|
||||||
|
func (r *Runtime) RequestShutdown(err error) { r.Lifecycle.RequestShutdown(err) }
|
||||||
|
|
||||||
|
// WorkContext stays alive during HTTP draining and graceful background draining.
|
||||||
|
// It is canceled on a shutdown failure/deadline, or after successful draining.
|
||||||
|
// Context() remains the early stop notification; do not use it for accepted work.
|
||||||
|
func (r *Runtime) WorkContext() context.Context { return r.work }
|
||||||
|
|
||||||
// OnClose transfers cleanup ownership. Register immediately after creation,
|
// OnClose transfers cleanup ownership. Register immediately after creation,
|
||||||
// before exposing resources to other goroutines. Registration after Close is a
|
// before exposing resources to other goroutines. Registration after Close is a
|
||||||
@@ -38,8 +63,10 @@ func (r *Runtime) OnClose(cleanup func() error) {
|
|||||||
r.cleanups = append(r.cleanups, cleanup)
|
r.cleanups = append(r.cleanups, cleanup)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close releases all resources in reverse order, including after a failed
|
// Close runs stop and drain barriers before releasing resources in reverse
|
||||||
// initialization. All callbacks are attempted even if one fails or panics.
|
// order, including after failed initialization. If quiescence is unproven it
|
||||||
|
// skips resource callbacks and returns ErrResourcesRetained. Once release is
|
||||||
|
// safe, all OnClose callbacks are attempted even if one fails or panics.
|
||||||
func (r *Runtime) Close() error {
|
func (r *Runtime) Close() error {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -48,9 +75,43 @@ func (r *Runtime) Close() error {
|
|||||||
r.RequestShutdown(nil)
|
r.RequestShutdown(nil)
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.closed = true
|
r.closed = true
|
||||||
cleanups := r.cleanups
|
cleanups, stops, drains := r.cleanups, r.stops, r.drains
|
||||||
r.cleanups = nil
|
r.cleanups = nil
|
||||||
|
r.stops, r.drains = nil, nil
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
stopErr := r.runPhase("stop", stops, r.shutdown.StopTimeout)
|
||||||
|
var stopped *phaseError
|
||||||
|
errors.As(stopErr, &stopped)
|
||||||
|
for _, stop := range stops {
|
||||||
|
found := false
|
||||||
|
for _, drain := range drains {
|
||||||
|
if drain.name == stop.name {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
stopErr = &phaseError{err: errors.Join(stopErr, &HookError{"drain", stop.name, errors.New("matching OnDrain required")}), unsafe: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stopErr != nil {
|
||||||
|
r.cancelWork()
|
||||||
|
}
|
||||||
|
// A timed-out Stop can still be executing. Never overlap its own Drain;
|
||||||
|
// other components can still establish their completion independently.
|
||||||
|
eligible := make([]shutdownHook, 0, len(drains))
|
||||||
|
for _, drain := range drains {
|
||||||
|
if stopped == nil || !stopped.unjoined[drain.name] {
|
||||||
|
eligible = append(eligible, drain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drainErr := r.runPhase("drain", eligible, r.shutdown.DrainTimeout)
|
||||||
|
r.cancelWork()
|
||||||
|
r.closeErr = errors.Join(stopErr, drainErr)
|
||||||
|
if unsafePhase(stopErr) || unsafePhase(drainErr) {
|
||||||
|
r.closeErr = errors.Join(r.closeErr, ErrResourcesRetained)
|
||||||
|
return
|
||||||
|
}
|
||||||
for i := len(cleanups) - 1; i >= 0; i-- {
|
for i := len(cleanups) - 1; i >= 0; i-- {
|
||||||
r.closeErr = errors.Join(r.closeErr, runCleanup(cleanups[i]))
|
r.closeErr = errors.Join(r.closeErr, runCleanup(cleanups[i]))
|
||||||
}
|
}
|
||||||
|
|||||||
40
reports/contractFollowupAcceptance.md
Normal file
40
reports/contractFollowupAcceptance.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# v0.16.0-preview 契约与生成体验验收
|
||||||
|
|
||||||
|
日期:2026-09-25。范围是通用数组响应、OpenAPI 引用、共享 Go 错误目录与环境覆盖;没有引入业务专属错误或外部服务依赖。
|
||||||
|
|
||||||
|
## 验证结果
|
||||||
|
|
||||||
|
- 字符串、布尔、各支持整数/浮点类型与 any 数组可以校验并生成 Go 类型;实际生成消费者验证中文字符串、int64 精度、混合对象/数组/null/标量、nil 根数组归一和 raw 数组。未知类型、byte/File/根嵌套数组明确拒绝。
|
||||||
|
- 重复响应先共享,再依据体积收益共享 schema/示例,避免组件表本身膨胀。引用全部可解析,编译与压缩可重复;示例业务数据中的字面 `$ref` 不被解释。HTTP 200 同时含成功/业务错误时,两种 schema、所有模板示例和 data:null 保留。
|
||||||
|
- 两个独立生成服务共享一次 Go Catalog 导出;服务/路由文档筛选不生成运行期目录。支持 internal 包,临时 Go overlay、模块文件及导出程序在工作结束后删除。check 完整文件快照不变;过期目录、编译错误、非法路径和 symlink 输出拒绝。
|
||||||
|
- 重新生成保留 Logic;生成 --check、独立消费者 race 与编译通过。根 go run/go build、信号退出、nethttp/native、下载/原始回调/multipart 和生命周期清理继续通过。
|
||||||
|
- 环境覆盖验证同一个 Lookup、allow/ignore/reject、完整名优先、显式空与未设置区别、false/0、JSON 集合、默认值不被修改、错误不带输入值。框架旧短名称入口兼容;存储/MySQL 生成组合验证了新版模板。
|
||||||
|
|
||||||
|
## 实际契约文档
|
||||||
|
|
||||||
|
| 服务 | 原字节数 | 新字节数 | 操作数 | 展开后内容 |
|
||||||
|
| --- | ---: | ---: | ---: | --- |
|
||||||
|
| eTax API | 20,751,149 | 847,171 | 67 | 完全一致 |
|
||||||
|
| eTax ID | 1,008,210 | 392,481 | 3 | 完全一致 |
|
||||||
|
|
||||||
|
[文档报告](contractFollowupDocuments.json) 保存原/新 SHA256 与生成源码摘要。通过只读 api export 写入临时目录,逐项比较完整 paths 操作;业务项目未做迁移或修改。
|
||||||
|
|
||||||
|
[全量回归报告](contractFollowupRegression.json) 与 [进程验收](contractFollowupLocal.json) 保存本次检查结果。
|
||||||
|
|
||||||
|
## 复验
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s
|
||||||
|
go vet ./...
|
||||||
|
go build ./...
|
||||||
|
python3 scripts/verifyManagedProject.py --report reports/contractFollowupLocal.json
|
||||||
|
python3 scripts/verifyManagedRelease.py --version v0.16.0-preview --report reports/contractFollowupRemote.json
|
||||||
|
```
|
||||||
|
|
||||||
|
共享 Catalog 提供方是清单显式指定的可信代码。框架隔离自己的文件操作,但不能保证用户提供方自身没有副作用;提供方应保持纯粹。该版本仍为 preview,未承诺支持完整 Go/goctl 类型语法或替业务完成数据库、权限、事务迁移。
|
||||||
|
|
||||||
|
## 远程固定版本验收
|
||||||
|
|
||||||
|
[远程报告](contractFollowupRemote.json) 已通过:空 module 缓存安装 v0.16.0-preview CLI,消费者也固定同版,没有本地 replace。包含六组真实生成消费者的 race 测试,以及通过远程 CLI 导出一次 Go Catalog、两个独立服务引用、文档筛选、只读 check、过期拒绝与刷新后编译。
|
||||||
|
|
||||||
|
耗时 37.1 秒,框架校验值 `h1:9JGAuXRXLb9rc1FR9jc0OBxRvj2abmuOecvFpKMcBnc=`。发布标签固定在 `64e3bd6cfced225476d58e95037249cd0a4fe695`;远程验收报告后续单独提交,不移动标签。
|
||||||
40
reports/contractFollowupDocuments.json
Normal file
40
reports/contractFollowupDocuments.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.16.0-preview",
|
||||||
|
"date": "2026-09-25",
|
||||||
|
"method": "Read-only api export against existing contracts; expand standard local references and compare every path operation to the existing documents. No business migration performed.",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"service": "api",
|
||||||
|
"beforeBytes": 20751149,
|
||||||
|
"afterBytes": 847171,
|
||||||
|
"operations": 67,
|
||||||
|
"expandedOperationsIdentical": true,
|
||||||
|
"beforeSHA256": "e9e7fa5de073f1c1747d83e8144dead47080ad720f1c0daf9f0ef6af330eff02",
|
||||||
|
"afterSHA256": "b1276c7cec2ce7bcaba9a73c475dcc3f5b485306cc6b9a66a4ccf5c6077a13cb",
|
||||||
|
"components": {
|
||||||
|
"responses": 25,
|
||||||
|
"schemas": 1,
|
||||||
|
"securitySchemes": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service": "id",
|
||||||
|
"beforeBytes": 1008210,
|
||||||
|
"afterBytes": 392481,
|
||||||
|
"operations": 3,
|
||||||
|
"expandedOperationsIdentical": true,
|
||||||
|
"beforeSHA256": "0ab7f09e18604e402ef372cfe71720c60d8e744b43c7fcbf83211ce1c1a5b04b",
|
||||||
|
"afterSHA256": "31c1b14754dc863431bf56ef3f6820b019428d8982011503ac91ea87aaecdf9d",
|
||||||
|
"components": {
|
||||||
|
"responses": 14,
|
||||||
|
"schemas": 1,
|
||||||
|
"securitySchemes": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
|
||||||
|
"cmd/go-omni/apigen/contractMetadata.go": "392fca7c015d17e0799019f62c9ed82904a7531a9779eb28c12ec37f8ab93fd7"
|
||||||
|
}
|
||||||
|
}
|
||||||
59
reports/contractFollowupLocal.json
Normal file
59
reports/contractFollowupLocal.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"mode": "localSource",
|
||||||
|
"durationSeconds": 4.91,
|
||||||
|
"checks": [
|
||||||
|
"rootGoRun",
|
||||||
|
"rootGoBuild",
|
||||||
|
"stableRegeneration",
|
||||||
|
"checkMainOwnership",
|
||||||
|
"contractChangePreview",
|
||||||
|
"logicSignatureCheckPersists",
|
||||||
|
"businessSourcePreserved",
|
||||||
|
"fixedConsumerBuild",
|
||||||
|
"nethttpSIGINT",
|
||||||
|
"nethttpSIGTERM",
|
||||||
|
"nativeSIGINT",
|
||||||
|
"nativeSIGTERM",
|
||||||
|
"listenerReleased",
|
||||||
|
"temporaryProcessesRemoved",
|
||||||
|
"nethttpGeneratedTransfers",
|
||||||
|
"nativeGeneratedTransfers",
|
||||||
|
"malformedMultipart400",
|
||||||
|
"goOwnedErrors",
|
||||||
|
"default404And405"
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"cmd/go-omni/apigen/generate.go": "9d81a3a4cd7905bd1829626e960512d86ac628d981be47c1d0cacc0648c0f33a",
|
||||||
|
"cmd/go-omni/apigen/templates.go": "6a98c26329d4c54d97abc170a324016931baac0e21028f649f1660347f5dd971",
|
||||||
|
"cmd/go-omni/apigen/startup.go": "e3322fa3e2aff9f45d66427f44187535c7c0960edc0f5fdfd5d7b4943b349334",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "8b1b86d38151e78a1a18d8ecf001b9635d32c37276abaf2c9e28af10aa983e9d",
|
||||||
|
"cmd/go-omni/apigen/contractChanges.go": "aeb83990e908738faaa3cdfa20c3b8938023db05b6e55f2d24352c44144056a8",
|
||||||
|
"cmd/go-omni/apigen/logicCheck.go": "ca1af4f876a3a0db7b7b9ba9b82c1d6399fd89d29f667dad39cfd3de526eb1cb",
|
||||||
|
"cmd/go-omni/apigen/write.go": "180c5f5daee1441fe936cb376b1645a3d0c802cc78bf4b451dbf7e7b3e4ac0f4",
|
||||||
|
"cmd/go-omni/api.go": "5203a17c5f66977a9eba838d61cf5a7356de53d5f72a1196fd4f2046aa43ac85",
|
||||||
|
"config/config.go": "b4935ebb452c9df34b733842687ba11eec7e006a54ce42ff767273dd74d6e672",
|
||||||
|
"httpx/transfer.go": "c9fe4e98c0e599d367eaf19ebecf367db914edfca9ad3a65527496d1dea80a3d",
|
||||||
|
"httpx/multipart.go": "b942ede7a810b268cd43b395c3bddcfaaacad92812bbb60e653466b8d86bc875",
|
||||||
|
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||||
|
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||||
|
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||||
|
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||||
|
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
|
||||||
|
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
|
||||||
|
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-config.go.tmpl": "715d29a9be33ba232eac339547cc0a13ae09c8bcb38a65a69a4f32f09fea5346",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-close.go.tmpl": "3f22d272e92f3b64b62123b3d80ec1a6ce8834257d64752fd75facf12623902b",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-databaseOptions.go.tmpl": "6e5395b4a787c77a6cabfd85f16049a39578d6ec751a9b78203f3a03baabdf28",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-options.go.tmpl": "11fe34ce45e8c98f7b5c9c0a2c512eb0b3abf86f95b4b576e083bcb6f5d0f824",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "6cf67d37ef64f388b23a98781c3ee3abb71a2e0b9e71eeb70f1e5492ff7e32b4",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "4d826d7133027ac2399f8209f03efa46ed54aa45dcd0a35274a9009314941dca",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "9e103c9de7a1a906903bc09787e1b38905dc5bb4d891c88938028da5dee98f6f",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1"
|
||||||
|
}
|
||||||
|
}
|
||||||
59
reports/contractFollowupRegression.json
Normal file
59
reports/contractFollowupRegression.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.16.0-preview",
|
||||||
|
"date": "2026-09-25",
|
||||||
|
"command": "GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s",
|
||||||
|
"testedPackages": [
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/app",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/audit",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/auth",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cache",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cache/redis",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/contract",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/database",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/database/gormx",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/database/migrate",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/database/mysql",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/errors",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/examples/modules",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/examples/platform",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/examples/reference",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/examples/reference/repository",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/health",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpc",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/internal/perfcheck",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/metrics",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/middleware",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/middleware/redis",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/openapi",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/pagination",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/queue/redis",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/resilience",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/response",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/storage",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/storage/local",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/storage/oss",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/storage/s3",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/task",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/telemetry",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/transport",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/transport/http2",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/transport/native",
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
||||||
|
],
|
||||||
|
"logSHA256": "ea70707ea4c160e9ed56edab99d040d9959dfa402a3e8f6d090d2b30ccef1513",
|
||||||
|
"additionalChecks": [
|
||||||
|
"go vet ./...",
|
||||||
|
"go build ./...",
|
||||||
|
"example managedService generate --check",
|
||||||
|
"example managedService go test ./..."
|
||||||
|
]
|
||||||
|
}
|
||||||
92
reports/contractFollowupRemote.json
Normal file
92
reports/contractFollowupRemote.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
44
reports/generationExperienceAcceptance.md
Normal file
44
reports/generationExperienceAcceptance.md
Normal 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)。
|
||||||
59
reports/generationExperienceLocal.json
Normal file
59
reports/generationExperienceLocal.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
63
reports/generationExperienceRegression.json
Normal file
63
reports/generationExperienceRegression.json
Normal 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"
|
||||||
|
}
|
||||||
114
reports/generationExperienceRemote.json
Normal file
114
reports/generationExperienceRemote.json
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.17.0-preview",
|
||||||
|
"frameworkSum": "h1:CH1axmZEcf8sMTHp3dRVAN0DMmkg9nURFd22H9LXASQ=",
|
||||||
|
"freshModuleCache": true,
|
||||||
|
"noLocalReplace": true,
|
||||||
|
"durationSeconds": 44.56,
|
||||||
|
"checks": [
|
||||||
|
"remoteCLIInstall",
|
||||||
|
"freshModuleCache",
|
||||||
|
"fixedVersionNoReplace",
|
||||||
|
"contractChangePreview",
|
||||||
|
"persistentLogicSignatureFailure",
|
||||||
|
"businessSourcePreserved",
|
||||||
|
"fixedConsumerBuild",
|
||||||
|
"rootGoRun",
|
||||||
|
"nethttpAndNativeSignals",
|
||||||
|
"listenerReleased",
|
||||||
|
"default404And405",
|
||||||
|
"goOwnedErrorCatalog",
|
||||||
|
"unwrappedDownloads",
|
||||||
|
"rawCallback",
|
||||||
|
"multipart400And413",
|
||||||
|
"nethttpAndNativeTransfers",
|
||||||
|
"sharedGoCatalogExport",
|
||||||
|
"documentOnlyErrorSelection",
|
||||||
|
"workspaceCheckNoWrites",
|
||||||
|
"staleCatalogRejected",
|
||||||
|
"twoIndependentServiceBuilds",
|
||||||
|
"initializationAndListenFailureCleanup",
|
||||||
|
"drainBeforeDependencyClose",
|
||||||
|
"generatedConsumerRaceTests",
|
||||||
|
"errorParamsAndInheritance",
|
||||||
|
"manualHooksAndScalars",
|
||||||
|
"dependencyFatalShutdown",
|
||||||
|
"fatalProcessExitCode",
|
||||||
|
"initializationShutdownNotification",
|
||||||
|
"configuredBuiltinEndpoints",
|
||||||
|
"projectHealthEnvelope",
|
||||||
|
"endpointConflictCleanup",
|
||||||
|
"responseArrayNullableNull",
|
||||||
|
"primitiveAndDynamicArrays",
|
||||||
|
"strictRequestPolicies",
|
||||||
|
"declarativeMultipartBinding",
|
||||||
|
"backgroundStopDrainResourceOrder",
|
||||||
|
"backgroundInitAndListenFailureCleanup",
|
||||||
|
"backgroundTimeoutRetainsResourcesAndExits",
|
||||||
|
"flatFieldComposition",
|
||||||
|
"typedStructureMapping",
|
||||||
|
"explicitNullAndMoneyPolicies",
|
||||||
|
"mappingFailureBeforeWrites",
|
||||||
|
"mappingConsumerRaceTests",
|
||||||
|
"middlewareFactoryInjectionAndCleanup",
|
||||||
|
"optionalRepositoryPreservesBusiness"
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
|
||||||
|
"lifecycle/phases.go": "1436856c708d004ebb28704a867316bf7a44553de56b3e29152d41475a2f793a",
|
||||||
|
"config/lifecycle_test.go": "8e5fccf0f7432c64d7fa8a52687002307d3ebb1fd039adb6ae09da0c9a2891bf",
|
||||||
|
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
|
||||||
|
"cmd/go-omni/apigen/templates.go": "e3177491934aef8cec783bed4cbeb2cfa3cb9b0815c9f7f9c63b85270bb07d24",
|
||||||
|
"config/project.go": "0917888f4e471761b897ddd59426eb098bf586edb5c5dc856320b84b3bdb5f7f",
|
||||||
|
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
|
||||||
|
"contract/policy.go": "73a3a55fb88195b32f22b2bc066d9b13dccd7fd520e98c5a1b2979a3fd2447eb",
|
||||||
|
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
|
||||||
|
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
|
||||||
|
"cmd/go-omni/apigen/business_test.go": "3b484352282d945f5a58047682079e3d64d4b0bf6ec6bafc23151b1197bb3942",
|
||||||
|
"cmd/go-omni/apigen/generate.go": "5a25134afc402c62ca6afe04320b5ef14141a5ff4caedb9a5eeca4952b302f03",
|
||||||
|
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||||
|
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||||
|
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||||
|
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
|
||||||
|
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
|
||||||
|
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
|
||||||
|
"scripts/verifyManagedRelease.py": "3a736a308d9dbe3a51555e2864dbcb39dc38cd01bd4e103095f4305a41bde958",
|
||||||
|
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "52c8ab8a0919c6503cb8835eb258f91f237204c9fc13d9dc735bb913944168b0",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "4d826d7133027ac2399f8209f03efa46ed54aa45dcd0a35274a9009314941dca",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "9e103c9de7a1a906903bc09787e1b38905dc5bb4d891c88938028da5dee98f6f",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c",
|
||||||
|
"cmd/go-omni/apigen/responseShape.go": "5b705130b66a3d4aaed842025b75abe54e6427acac8366366ca88ae9862429e1",
|
||||||
|
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
|
||||||
|
"cmd/go-omni/apigen/catalogDocument.go": "94df60328b2f105c35b73d9c6362ea14abadce6cb18e4d9921193d2d46854252",
|
||||||
|
"cmd/go-omni/apigen/workspaceCatalog.go": "c00784220b379ed80deb07c060532b756cbc47434ecb8b1ca10bc12b12159328",
|
||||||
|
"config/env.go": "ea84bccdcd39e3f6b4958a0c8ac8e7d6a0be516b24930f93039248165361bf35",
|
||||||
|
"cmd/go-omni/apigen/arrayResponse_test.go": "9e3e4475f76683d3346d349cdfa94e9e75cf913ac6979bd7a4ed86628466d879",
|
||||||
|
"cmd/go-omni/apigen/testdata/arrays/values.go": "d5e7f3adf12aabb301f46c296e8c894a7b2f6f70a6b6e7e02c74da7f1fd17bc5",
|
||||||
|
"cmd/go-omni/apigen/testdata/arrays/consumer_test.go": "435932f2952ad897943e2cda83b04eccb2950579d8d88772661541e1e2a2333d",
|
||||||
|
"cmd/go-omni/apigen/composition.go": "7ed3761b27d9c932d8ddc43e63c2aca798bdbe38f2b40571af8916c8401ea650",
|
||||||
|
"cmd/go-omni/apigen/generatorConfig.go": "467112eeb5180fc1571e811e38e90e6dee3f128fe39e17bb978591cee0fa1b22",
|
||||||
|
"cmd/go-omni/apigen/mapping.go": "1d28f3b1300f50654b392735be546ff9c588664bfc0563e4ff5f838f495e091e",
|
||||||
|
"cmd/go-omni/apigen/mappingLoad.go": "b301bd49e4b67160ba70be4163c8ab1f3620f5963ec26f4b28af5df185a75170",
|
||||||
|
"cmd/go-omni/apigen/mapping_test.go": "6660f3183e6078ae5d6baecc654b0d2bcdfc243b6bc1da28811af8d25fa73efa",
|
||||||
|
"cmd/go-omni/apigen/middlewareFactory.go": "cc0220edb3eff25d7f66b2c37e206455f8730a2b909613713322bee7caf72303",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/accessFactory.go": "e1e5057ff79f806b3218e93249e8191d097b5c4844977204a8c32848443cba33",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/domain.go": "19bc978d173081f18efda643ffad3510287c03b4e47436c7019ea91973f32b72",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/amount.go": "f2d1834502d71d9ecf72ebe3b1077e5ef41f44fd816abd4617ac19f9bb962fdd",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/client.go": "19102e191c0471cae9e7405623906d0d804865c40b80ff1d5b240a41ceab6a4e",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/service.api": "01d230d5409756e2f3eb602a7fe065efa7d9599f7a4089da4bf8f07a2474444e",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/consumer_test.go": "9021c47c8af7d0c338c005d9f0a1f97ecdaaffc0b17b01c09d58023cc8c69fd7",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/dependencies.go": "edc153204b06da00c0e45c52db43a4a22f8ffe22c4a6d3f3ebec0137b861f84d",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/converters.go": "3ded306c68182d0c2184e44eecb6307c366f0ae43a4d23a6a3ef70fc14f45833"
|
||||||
|
},
|
||||||
|
"releaseCommit": "8b588a24cd653dac77dd68f6777e0db367981f19"
|
||||||
|
}
|
||||||
@@ -22,14 +22,20 @@ python3 scripts/verifyConnector.py --local --gorm --report reports/gormLocal.jso
|
|||||||
|
|
||||||
[本地数据库报告](gormLocal.json) 的 MySQL 限制为 1 CPU、768 MiB、128 PIDs,只绑定 localhost;测试使用临时凭据/证书,结束后清理容器和消费者。实库用例通过脚本显式启用,不能把普通 go test 下的 skip 当作实库通过。
|
[本地数据库报告](gormLocal.json) 的 MySQL 限制为 1 CPU、768 MiB、128 PIDs,只绑定 localhost;测试使用临时凭据/证书,结束后清理容器和消费者。实库用例通过脚本显式启用,不能把普通 go test 下的 skip 当作实库通过。
|
||||||
|
|
||||||
发布后的远程验收命令:
|
发布后的远程验收也已通过:
|
||||||
|
|
||||||
|
- [远程 MySQL/GORM 报告](gormRemote.json):25.42 秒,实际下载固定版本,执行 TLS、事务、取消、日志、生成项目接入及已有数据库/HTTP 回归。
|
||||||
|
- [远程生成项目报告](gormManagedRemote.json):30.45 秒,验证根目录运行/构建、重新生成、错误目录与参数、上传下载、内置端点及初始化失败/监听失败/正常退出清理。
|
||||||
|
- 两份报告均使用空 module 缓存,无本地 replace,CLI 和消费者版本均为 `v0.14.1-preview`;module 校验值一致为 `h1:qJ5EPUfSKe8KOHK8aU5f78Fg24HXCgbDfe2z1XXTmf4=`。同版 CLI 的生成检查没有错误版本提示。
|
||||||
|
|
||||||
|
复验命令:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 scripts/verifyConnector.py --version v0.14.1-preview --gorm --report reports/gormRemote.json
|
python3 scripts/verifyConnector.py --version v0.14.1-preview --gorm --report reports/gormRemote.json
|
||||||
python3 scripts/verifyManagedRelease.py --version v0.14.1-preview --report reports/gormManagedRemote.json
|
python3 scripts/verifyManagedRelease.py --version v0.14.1-preview --report reports/gormManagedRemote.json
|
||||||
```
|
```
|
||||||
|
|
||||||
远程脚本从空缓存读取固定版本,并检查 CLI/消费者版本、module 校验值和没有本地 replace;结果发布后单独归档,代码标签保持不变。
|
代码标签固定在 `b8ab6b35219d53d6de392c920fe7ecbe9a76dade`。远程验收结果单独提交,标签保持不变;临时容器、证书、消费者与进程均已清理。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
|
|||||||
68
reports/gormManagedRemote.json
Normal file
68
reports/gormManagedRemote.json
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.14.1-preview",
|
||||||
|
"frameworkSum": "h1:qJ5EPUfSKe8KOHK8aU5f78Fg24HXCgbDfe2z1XXTmf4=",
|
||||||
|
"freshModuleCache": true,
|
||||||
|
"noLocalReplace": true,
|
||||||
|
"durationSeconds": 30.45,
|
||||||
|
"checks": [
|
||||||
|
"remoteCLIInstall",
|
||||||
|
"freshModuleCache",
|
||||||
|
"fixedVersionNoReplace",
|
||||||
|
"contractChangePreview",
|
||||||
|
"persistentLogicSignatureFailure",
|
||||||
|
"businessSourcePreserved",
|
||||||
|
"fixedConsumerBuild",
|
||||||
|
"rootGoRun",
|
||||||
|
"nethttpAndNativeSignals",
|
||||||
|
"listenerReleased",
|
||||||
|
"default404And405",
|
||||||
|
"goOwnedErrorCatalog",
|
||||||
|
"unwrappedDownloads",
|
||||||
|
"rawCallback",
|
||||||
|
"multipart400And413",
|
||||||
|
"nethttpAndNativeTransfers",
|
||||||
|
"initializationAndListenFailureCleanup",
|
||||||
|
"drainBeforeDependencyClose",
|
||||||
|
"generatedConsumerRaceTests",
|
||||||
|
"errorParamsAndInheritance",
|
||||||
|
"manualHooksAndScalars",
|
||||||
|
"dependencyFatalShutdown",
|
||||||
|
"fatalProcessExitCode",
|
||||||
|
"initializationShutdownNotification",
|
||||||
|
"configuredBuiltinEndpoints",
|
||||||
|
"projectHealthEnvelope",
|
||||||
|
"endpointConflictCleanup",
|
||||||
|
"responseArrayNullableNull",
|
||||||
|
"strictRequestPolicies",
|
||||||
|
"declarativeMultipartBinding"
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"lifecycle/runtime.go": "be9d3a483adc322b9c2633efbc87609f4b4a7aeebc2bee062e89ec32368466a3",
|
||||||
|
"config/project.go": "54b4d6f4d3a2e7a172e7f446af22067735b7c02ba253dd2483f42190616e90d3",
|
||||||
|
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
|
||||||
|
"contract/policy.go": "73a3a55fb88195b32f22b2bc066d9b13dccd7fd520e98c5a1b2979a3fd2447eb",
|
||||||
|
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
|
||||||
|
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
|
||||||
|
"cmd/go-omni/apigen/business_test.go": "c0af26cd270f1c971dc5886316b144b776fdb886e930f883f0ee0c94b0909226",
|
||||||
|
"cmd/go-omni/apigen/generate.go": "138ea2809df839a5283c1f545d94e9a13d3476d149e7bfb62f73fb9c84ad1d92",
|
||||||
|
"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": "ac2ef8f03d4c01fa793bc381c152d5d09f76227659cd6399ef8d43087785295c",
|
||||||
|
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "2b6bdf62a06cf25b85cbf06d60a5284ba24256c6705611fad4c012f63661a03d",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/client.go": "db886c2d4dd27e67f7446265c20147335d4898d2d2091518e67403806ccd9b69",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
82
reports/gormRemote.json
Normal file
82
reports/gormRemote.json
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.14.1-preview",
|
||||||
|
"localSource": false,
|
||||||
|
"gormAdapter": true,
|
||||||
|
"frameworkSum": "h1:qJ5EPUfSKe8KOHK8aU5f78Fg24HXCgbDfe2z1XXTmf4=",
|
||||||
|
"freshModuleCache": true,
|
||||||
|
"noLocalReplace": true,
|
||||||
|
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
|
||||||
|
"mysqlLimits": {
|
||||||
|
"cpus": 1,
|
||||||
|
"memoryMiB": 768,
|
||||||
|
"pids": 128
|
||||||
|
},
|
||||||
|
"durationSeconds": 25.42,
|
||||||
|
"tests": [
|
||||||
|
"TestHTTPFileAndEnvironment/server.json",
|
||||||
|
"TestHTTPFileAndEnvironment/server.yaml",
|
||||||
|
"TestHTTPFileAndEnvironment",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings",
|
||||||
|
"TestHTTPConfiguredStandardServer",
|
||||||
|
"TestHTTPInvalidStartupReleasesResources",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
|
||||||
|
"TestStructuredMySQLAdapterTLS",
|
||||||
|
"TestGORMMySQLTransactionsCancellationAndLogs",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp",
|
||||||
|
"TestConnectorIndependentPoolsAndContext",
|
||||||
|
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources/connect",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources/ping",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing/connect",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing/ping",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing",
|
||||||
|
"TestConnectorHealthBoundsPoolWaitAndRecovers",
|
||||||
|
"TestConnectorIdleZeroAndLifetime/noIdle",
|
||||||
|
"TestConnectorIdleZeroAndLifetime/expired",
|
||||||
|
"TestConnectorIdleZeroAndLifetime",
|
||||||
|
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
|
||||||
|
"TestMySQLConnectorDriverLoggerIsolation",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/slowBody",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/idle",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native",
|
||||||
|
"TestHTTPConfiguredNetworkLimits",
|
||||||
|
"TestGeneratedOptionalGORM",
|
||||||
|
"TestGeneratedMySQLConfigurationAndLifecycle"
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
|
||||||
|
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
|
||||||
|
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
|
||||||
|
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
|
||||||
|
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
|
||||||
|
"database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb",
|
||||||
|
"scripts/verifyConnector.py": "58d94e869e6faab1e49bd31ccaebab6d46b28e40058cf7805ad906057229149e",
|
||||||
|
"scripts/testdata/mysqlGenerated_test.go": "5630123add5ed17f261c1a1bf63ba773f7fa6ea613fd573d5c5cb72da3e42645",
|
||||||
|
"scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251",
|
||||||
|
"database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2",
|
||||||
|
"database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d",
|
||||||
|
"database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d"
|
||||||
|
}
|
||||||
|
}
|
||||||
38
reports/nullableMappingAcceptance.md
Normal file
38
reports/nullableMappingAcceptance.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# 可空映射与配置拆分验收(v0.18.0-preview)
|
||||||
|
|
||||||
|
基于 v0.17.0-preview 补齐通用生成能力,未添加业务模型、权限、金额默认值或领域校验。运行时和依赖不变,旧单文件配置与已有映射函数保留。
|
||||||
|
|
||||||
|
## 本轮完成
|
||||||
|
|
||||||
|
- 显式嵌套 mapping 支持 `S → T`、`*S → *T`、`S → *T`、显式 nil 策略下的 `*S → T`,也适用于 elements 下的 slice/等长数组。默认保留可空对象;非空目标必须明确选 zero/error。
|
||||||
|
- 同类型 `*T → T` 可显式选择 nil zero/error,省去重复解引用 converter。自定义 converter 继续独立控制行为,禁止同时配置 nil 策略。
|
||||||
|
- `pointerResult: true` 额外生成 `<Name>Ptr(S) (*T,error)`,原值函数不变。转换失败返回 nil 与原错误链;辅助函数名统一查重。
|
||||||
|
- 主配置以 mappingFiles 列出项目内子文件。子文件只含 mappings,统一解析跨文件引用、严格类型和字段覆盖;不做递归导入或隐式覆盖。
|
||||||
|
- 配置均为项目文件,生成不会改写。check、dry-run、workspace generate/check 共享加载过程;对子文件变化重新核对,保留边界与大小限制。
|
||||||
|
|
||||||
|
## 回归证据
|
||||||
|
|
||||||
|
[回归记录](nullableMappingRegression.json):全仓库 44 个有测试的包通过 `-race`,包含实际生成消费者;go vet、go build、diff 检查通过。
|
||||||
|
|
||||||
|
`TestNullableMappingConsumer` 生成独立项目,配置跨文件向前引用与项目自定义转换函数,验证:
|
||||||
|
|
||||||
|
- nil、缺失、非 nil 零值对象、nil/非 nil 空 slice、nil 元素,以及 slice/array 内的嵌套转换。
|
||||||
|
- zero/error 空值策略、值到指针、可选指针返回;未显式映射的同类型指针仍共享引用。
|
||||||
|
- 子转换失败时父结果归零,指针辅助返回 nil,errors.Is 保留原因,错误含子字段与数组索引;内部原因和映射路径不进入公开 HTTP 提示。
|
||||||
|
- 32 个并发调用;通过实际生成 Handler 和 Logic 测试 JSON 请求与响应;生成模块执行 race 测试和根目录 build。
|
||||||
|
- 主配置与子文件、业务 Logic 在重新生成后逐字保留;check/preview 只读。子文件修改触发漂移,workspace generate 后 workspace check 通过。
|
||||||
|
- 未声明 nil 行为、错误类型/策略、辅助函数重名、未知映射、跨文件循环和字段遗漏均在写入前拒绝。
|
||||||
|
|
||||||
|
`TestMappingFileConfiguration/Rejection/Bounds` 覆盖文件加载顺序、来源诊断、配置变化摘要、重复文件/定义/JSON key、路径越界、符号链接、嵌套导入拒绝,以及单文件/总大小/文件数/合并映射数限制。
|
||||||
|
|
||||||
|
[原始最小复现](nullableMappingRepro.json):在隔离副本中,用源码构建的真实 CLI 成功生成原来报 `nested mapping Child has incompatible types` 的项目;生成检查、模块编译测试和 build 通过。原项目文件快照一致,临时目录已清理。
|
||||||
|
|
||||||
|
[实际进程回归](nullableMappingLocal.json):原有根目录启动、nethttp/native 信号退出、监听端口释放、上传下载、原始回调、错误响应与生成文件保护通过。
|
||||||
|
|
||||||
|
## 使用与边界
|
||||||
|
|
||||||
|
使用及迁移见 [生成能力指南](../GENERATION.md)。子文件仅拆分配置,不生成需要人工补齐的赋值代码;普通映射仍为编译器检查的 Go 代码,无运行时反射或 JSON 往返。
|
||||||
|
|
||||||
|
zero 表示目标 Go 零值,并跳过子映射;领域必填、金额精度、时间/编号格式和快照深拷贝仍由项目负责。普通指针输入已合并的 missing/null 不能在映射阶段恢复。nil error 是普通转换错误,特定业务错误码用自定义 converter 表达。
|
||||||
|
|
||||||
|
当前已完成发布前验收;远程验收将在固定标签发布后补充。框架验收不代表完整业务项目已迁移,本轮没有修改 eTax 业务代码。
|
||||||
59
reports/nullableMappingLocal.json
Normal file
59
reports/nullableMappingLocal.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"mode": "localSource",
|
||||||
|
"durationSeconds": 5.28,
|
||||||
|
"checks": [
|
||||||
|
"rootGoRun",
|
||||||
|
"rootGoBuild",
|
||||||
|
"stableRegeneration",
|
||||||
|
"checkMainOwnership",
|
||||||
|
"contractChangePreview",
|
||||||
|
"logicSignatureCheckPersists",
|
||||||
|
"businessSourcePreserved",
|
||||||
|
"fixedConsumerBuild",
|
||||||
|
"nethttpSIGINT",
|
||||||
|
"nethttpSIGTERM",
|
||||||
|
"nativeSIGINT",
|
||||||
|
"nativeSIGTERM",
|
||||||
|
"listenerReleased",
|
||||||
|
"temporaryProcessesRemoved",
|
||||||
|
"nethttpGeneratedTransfers",
|
||||||
|
"nativeGeneratedTransfers",
|
||||||
|
"malformedMultipart400",
|
||||||
|
"goOwnedErrors",
|
||||||
|
"default404And405"
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"cmd/go-omni/apigen/generate.go": "a977803a6f8f4c64a30bc8156decfb072301f4084a5cf8821f4fe98da80cd7af",
|
||||||
|
"cmd/go-omni/apigen/templates.go": "e3177491934aef8cec783bed4cbeb2cfa3cb9b0815c9f7f9c63b85270bb07d24",
|
||||||
|
"cmd/go-omni/apigen/startup.go": "e3322fa3e2aff9f45d66427f44187535c7c0960edc0f5fdfd5d7b4943b349334",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
|
||||||
|
"cmd/go-omni/apigen/contractChanges.go": "aeb83990e908738faaa3cdfa20c3b8938023db05b6e55f2d24352c44144056a8",
|
||||||
|
"cmd/go-omni/apigen/logicCheck.go": "ca1af4f876a3a0db7b7b9ba9b82c1d6399fd89d29f667dad39cfd3de526eb1cb",
|
||||||
|
"cmd/go-omni/apigen/write.go": "eab29555906c1d6675ffdf07a30f00536065ec8d406cc1a4e53a05d8db720ff8",
|
||||||
|
"cmd/go-omni/api.go": "5203a17c5f66977a9eba838d61cf5a7356de53d5f72a1196fd4f2046aa43ac85",
|
||||||
|
"config/config.go": "b4935ebb452c9df34b733842687ba11eec7e006a54ce42ff767273dd74d6e672",
|
||||||
|
"httpx/transfer.go": "c9fe4e98c0e599d367eaf19ebecf367db914edfca9ad3a65527496d1dea80a3d",
|
||||||
|
"httpx/multipart.go": "b942ede7a810b268cd43b395c3bddcfaaacad92812bbb60e653466b8d86bc875",
|
||||||
|
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||||
|
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||||
|
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||||
|
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||||
|
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
|
||||||
|
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
|
||||||
|
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-config.go.tmpl": "715d29a9be33ba232eac339547cc0a13ae09c8bcb38a65a69a4f32f09fea5346",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-close.go.tmpl": "3f22d272e92f3b64b62123b3d80ec1a6ce8834257d64752fd75facf12623902b",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-databaseOptions.go.tmpl": "6e5395b4a787c77a6cabfd85f16049a39578d6ec751a9b78203f3a03baabdf28",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-options.go.tmpl": "11fe34ce45e8c98f7b5c9c0a2c512eb0b3abf86f95b4b576e083bcb6f5d0f824",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "6cf67d37ef64f388b23a98781c3ee3abb71a2e0b9e71eeb70f1e5492ff7e32b4",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "4d826d7133027ac2399f8209f03efa46ed54aa45dcd0a35274a9009314941dca",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "9e103c9de7a1a906903bc09787e1b38905dc5bb4d891c88938028da5dee98f6f",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1"
|
||||||
|
}
|
||||||
|
}
|
||||||
79
reports/nullableMappingRegression.json
Normal file
79
reports/nullableMappingRegression.json
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"status": "releaseCandidateValidated",
|
||||||
|
"baseRelease": "v0.17.0-preview",
|
||||||
|
"testedPackages": 44,
|
||||||
|
"goVersion": "go version go1.27.1 darwin/arm64",
|
||||||
|
"commands": [
|
||||||
|
"GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s",
|
||||||
|
"go vet ./...",
|
||||||
|
"go build ./...",
|
||||||
|
"python3 scripts/verifyManagedProject.py --report reports/nullableMappingLocal.json",
|
||||||
|
"source CLI: api generate / api generate --check / go mod tidy / go test ./... / go build . on isolated original reproduction",
|
||||||
|
"git diff --check",
|
||||||
|
"go run ./cmd/go-omni api generate --dir examples/managedService --check",
|
||||||
|
"cd examples/managedService && go test ./..."
|
||||||
|
],
|
||||||
|
"checks": [
|
||||||
|
"nullableNestedObject",
|
||||||
|
"nilPointerPreserved",
|
||||||
|
"nilAndEmptySlices",
|
||||||
|
"nullableArrayElements",
|
||||||
|
"explicitNilZeroAndError",
|
||||||
|
"nonNilZeroPreserved",
|
||||||
|
"nestedFailureReturnsZero",
|
||||||
|
"errorChainAndFieldIndex",
|
||||||
|
"privateCauseNotInHTTP",
|
||||||
|
"pointerResultHelper",
|
||||||
|
"ordinaryPointerAliasingUnchanged",
|
||||||
|
"concurrentCalls",
|
||||||
|
"splitConfigForwardReferences",
|
||||||
|
"readOnlyGenerateCheckAndPreview",
|
||||||
|
"workspaceGenerateCheck",
|
||||||
|
"childFileDriftDetection",
|
||||||
|
"businessFilesPreserved",
|
||||||
|
"duplicateMappingsAndHelpersRejected",
|
||||||
|
"cyclesAndUnknownMappingsRejected",
|
||||||
|
"invalidPoliciesRejectedBeforeWrites",
|
||||||
|
"symlinksAndTraversalRejected",
|
||||||
|
"configSizeAndCountLimits",
|
||||||
|
"configChangeDigest",
|
||||||
|
"originalFeedbackReproducedAndFixed"
|
||||||
|
],
|
||||||
|
"logs": {
|
||||||
|
"fullRace": {
|
||||||
|
"path": "/tmp/go-omni-v018-race.log",
|
||||||
|
"sha256": "1f0f9a55354c041c0035ae82e0f21a2a913544d46ae40e05795a9ae5b707250f"
|
||||||
|
},
|
||||||
|
"vet": {
|
||||||
|
"path": "/tmp/go-omni-v018-vet.log",
|
||||||
|
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"path": "/tmp/go-omni-v018-build.log",
|
||||||
|
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
},
|
||||||
|
"process": {
|
||||||
|
"path": "/tmp/go-omni-v018-process.log",
|
||||||
|
"sha256": "748bf31b88405f7f1d550b1d74bbbc5543ea09c5d09b59ec98da6ad040694b12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sourceHashes": {
|
||||||
|
"cmd/go-omni/apigen/generate.go": "a977803a6f8f4c64a30bc8156decfb072301f4084a5cf8821f4fe98da80cd7af",
|
||||||
|
"cmd/go-omni/apigen/generatorConfig.go": "6eaa70821312ce670e5f8067f99f5133d1789e8558140203520230179da083d0",
|
||||||
|
"cmd/go-omni/apigen/generatorConfig_test.go": "f22a55dfa5c8188cf7bce56a2b53a778cbe3feb581cd551f16ebfc6a10932e21",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
|
||||||
|
"cmd/go-omni/apigen/mapping.go": "fb4f4bf39137921c3fc8f793c636c8c29f68e962060a56c82b10869d6a200243",
|
||||||
|
"cmd/go-omni/apigen/mappingNullable_test.go": "cc22182e5a68138b97817f7dda7a8a5a10ca3aab6c690f8926b6a6cc2ba2a8cb",
|
||||||
|
"cmd/go-omni/apigen/mappingPointer.go": "5b4aab84c2ea9371d2a295f8d8cc9a7f82a88b412daa07970c9ac9ea32e504e6",
|
||||||
|
"cmd/go-omni/apigen/mapping_test.go": "94a7927e2237f60c157a7dfc4cc793e17537559ed65c1468d3b8b8b8e3f39394",
|
||||||
|
"cmd/go-omni/apigen/testdata/nullable/consumer_test.go": "5b6353c09fe61c39602071cc7072b6d640cf1a6cab1ae55f9490199ca62102b2",
|
||||||
|
"cmd/go-omni/apigen/testdata/nullable/convert.go": "4465a51d0eb7efde66eca08ab7e0e8fd6565e04a8447c2d6d8c65cea47c5a87f",
|
||||||
|
"cmd/go-omni/apigen/testdata/nullable/domain.go": "cb31f4939efbc9ddbb9b68a9e0c72302b93c2c365b1023f65bf7079862936a76",
|
||||||
|
"cmd/go-omni/apigen/testdata/nullable/service.api": "6a5e76d1b56efc5e89d89579710b06465a7c18accbd08ed58d88ccb9c59828d9",
|
||||||
|
"cmd/go-omni/main_test.go": "0c709d4f8c81df91e3ad318afc02f1534a1062218dbbb10adbaa5a28c6cb2021",
|
||||||
|
"cmd/go-omni/scaffold/version.go": "dc875b37247ebea3890cd3c6ecf614875301ca2d3c92036abd313dea6d29418c",
|
||||||
|
"scripts/verifyManagedRelease.py": "28d883e933a131e8df8e798b00e4b4a6af097a9b880ef1aa7b753456a90c6137"
|
||||||
|
},
|
||||||
|
"frameworkVersion": "v0.18.0-preview"
|
||||||
|
}
|
||||||
14
reports/nullableMappingRepro.json
Normal file
14
reports/nullableMappingRepro.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"status": "localSourceUnreleased",
|
||||||
|
"durationSeconds": 2.8,
|
||||||
|
"checks": [
|
||||||
|
"originalNullablePointerRepro",
|
||||||
|
"actualCLIGenerate",
|
||||||
|
"actualCLICheck",
|
||||||
|
"consumerCompile",
|
||||||
|
"consumerBuild",
|
||||||
|
"originalProjectUnchanged",
|
||||||
|
"temporaryResourcesRemoved"
|
||||||
|
]
|
||||||
|
}
|
||||||
48
reports/phasedLifecycleAcceptance.md
Normal file
48
reports/phasedLifecycleAcceptance.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# v0.15.0-preview 后台分阶段关闭验收
|
||||||
|
|
||||||
|
日期:2026-09-25。范围为通用生命周期、task 可选接入、生成项目装配和升级;没有修改业务项目,也没有引入事务任务一致性实现。
|
||||||
|
|
||||||
|
## 已验证行为
|
||||||
|
|
||||||
|
- 早期退出通知不取消 WorkContext;父 context 的值保留,截止时间不传入后台工作生命周期。阶段钩子有独立时限。
|
||||||
|
- 所有 Stop 完成才正常进入 Drain,同一阶段并发;仅确认收尾完成后才逆序释放共享资源。OnStop 要求匹配 OnDrain。
|
||||||
|
- 阶段错误、panic、超时和未响应取消都会保留原因;不能证明结束时跳过资源关闭,返回 ErrResourcesRetained。超时后在取消窗口成功完成的组件允许释放资源,同时仍报告超时。
|
||||||
|
- 未返回的 Stop 不与同名 Drain 并发;其他组件继续尝试收尾。重复/并发 Finish 不重复调用钩子。
|
||||||
|
- 实际生成项目在 nethttp/native 上验证:HTTP 请求仍在处理时后台不提前停止,后台最终操作完成后才关闭客户端;后台致命原因贯穿返回错误。
|
||||||
|
- 初始化失败和监听失败仍等待已创建后台任务收尾。独立子进程验证未结束任务不会导致共享客户端提前关闭,进程退出码为 1,原始初始化错误保留。
|
||||||
|
- 配置 YAML/环境覆盖、非法时限拒绝;重新生成保留业务文件,v0.14 项目的定点升级只更新依赖/生成文件,保留自定义配置与 svc。
|
||||||
|
- 实际 MySQL 8.4 TLS:后台在退出通知后创建临时表、完成 INSERT/SELECT 并释放借用连接,再关闭池;无法证明完成时池仍通过健康检查。GORM 同池接入及既有 Connector/TLS/HTTP 限额回归同时通过。
|
||||||
|
|
||||||
|
本地进程记录:[生成项目](phasedManagedLocal.json)。数据库记录:[MySQL 与 GORM](phasedMySQLLocal.json)。容器限制为 1 CPU / 768 MiB / 128 PIDs,只使用本机临时数据库,已清理容器、凭据、证书和临时消费者。
|
||||||
|
|
||||||
|
全量 race(启用生成消费者)、go vet、go build、示例生成漂移检查和依赖隔离检查均通过。
|
||||||
|
|
||||||
|
## 复验
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s
|
||||||
|
go vet ./...
|
||||||
|
go build ./...
|
||||||
|
python3 scripts/verifyManagedProject.py --report reports/phasedManagedLocal.json
|
||||||
|
python3 scripts/verifyConnector.py --local --phases --gorm --report reports/phasedMySQLLocal.json
|
||||||
|
```
|
||||||
|
|
||||||
|
发布后空缓存远程验收也已通过:
|
||||||
|
|
||||||
|
- [生成项目远程报告](phasedManagedRemote.json):31.93 秒,包含后台阶段顺序、初始化/监听失败、超时退出及既有接口功能。
|
||||||
|
- [真实 MySQL/GORM 远程报告](phasedMySQLRemote.json):25.14 秒,包含后台最终写入后关池、收尾失败保留池与 GORM 同池回归。
|
||||||
|
- 两份报告均为固定版本 v0.15.0-preview、空 module 缓存、无本地 replace;module 校验值一致为 `h1:llp5XZcLAsv9tckUQsZlwBDAO2RzoyxeygxZZ4/ZW1c=`。
|
||||||
|
- 发布标签固定在 `16deead70e860052c5b629511207578c03f9b2cb`,报告单独提交,标签不移动。
|
||||||
|
|
||||||
|
远程复验命令:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/verifyManagedRelease.py --version v0.15.0-preview --report reports/phasedManagedRemote.json
|
||||||
|
python3 scripts/verifyConnector.py --version v0.15.0-preview --phases --gorm --report reports/phasedMySQLRemote.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用边界
|
||||||
|
|
||||||
|
普通 OnClose 保留原有逆序语义;需要阶段保证的后台组件必须迁入对应钩子,框架不会猜测任意 Close 内部行为。钩子负责真实停止与等待,nil 是完成证明。未结束 goroutine 不能被 Go 强杀;资源被保留后由生成入口异常退出,手写宿主须自行终止服务。OnClose 的同步回调仍须能返回;HTTP 超时保留原传输层行为,业务必须响应请求取消。同一阶段有顺序依赖时由一个登记项组合管理。
|
||||||
|
|
||||||
|
lifecycle 本身仅依赖标准库;核心 App/database/task 的编译依赖不包含 Orbit/GORM/Redis。第三方可选适配依赖仍记录于同一 module,不声称已经拆成独立 module。
|
||||||
59
reports/phasedManagedLocal.json
Normal file
59
reports/phasedManagedLocal.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"mode": "localSource",
|
||||||
|
"durationSeconds": 4.31,
|
||||||
|
"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": "819ce227f1041fa9447d7fbff4bfc2d4ea60dcb73e8667a21a41a090b40f0dd3",
|
||||||
|
"cmd/go-omni/apigen/templates.go": "6a98c26329d4c54d97abc170a324016931baac0e21028f649f1660347f5dd971",
|
||||||
|
"cmd/go-omni/apigen/startup.go": "e3322fa3e2aff9f45d66427f44187535c7c0960edc0f5fdfd5d7b4943b349334",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "8b1b86d38151e78a1a18d8ecf001b9635d32c37276abaf2c9e28af10aa983e9d",
|
||||||
|
"cmd/go-omni/apigen/contractChanges.go": "aeb83990e908738faaa3cdfa20c3b8938023db05b6e55f2d24352c44144056a8",
|
||||||
|
"cmd/go-omni/apigen/logicCheck.go": "ca1af4f876a3a0db7b7b9ba9b82c1d6399fd89d29f667dad39cfd3de526eb1cb",
|
||||||
|
"cmd/go-omni/apigen/write.go": "180c5f5daee1441fe936cb376b1645a3d0c802cc78bf4b451dbf7e7b3e4ac0f4",
|
||||||
|
"cmd/go-omni/api.go": "5203a17c5f66977a9eba838d61cf5a7356de53d5f72a1196fd4f2046aa43ac85",
|
||||||
|
"config/config.go": "b4935ebb452c9df34b733842687ba11eec7e006a54ce42ff767273dd74d6e672",
|
||||||
|
"httpx/transfer.go": "c9fe4e98c0e599d367eaf19ebecf367db914edfca9ad3a65527496d1dea80a3d",
|
||||||
|
"httpx/multipart.go": "b942ede7a810b268cd43b395c3bddcfaaacad92812bbb60e653466b8d86bc875",
|
||||||
|
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||||
|
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||||
|
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||||
|
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||||
|
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
|
||||||
|
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
|
||||||
|
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-config.go.tmpl": "fa37bd0a71587e2f821ec1595eb1f1a371677c90428001ea75f61815243c3b7b",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
78
reports/phasedManagedRemote.json
Normal file
78
reports/phasedManagedRemote.json
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.15.0-preview",
|
||||||
|
"frameworkSum": "h1:llp5XZcLAsv9tckUQsZlwBDAO2RzoyxeygxZZ4/ZW1c=",
|
||||||
|
"freshModuleCache": true,
|
||||||
|
"noLocalReplace": true,
|
||||||
|
"durationSeconds": 31.93,
|
||||||
|
"checks": [
|
||||||
|
"remoteCLIInstall",
|
||||||
|
"freshModuleCache",
|
||||||
|
"fixedVersionNoReplace",
|
||||||
|
"contractChangePreview",
|
||||||
|
"persistentLogicSignatureFailure",
|
||||||
|
"businessSourcePreserved",
|
||||||
|
"fixedConsumerBuild",
|
||||||
|
"rootGoRun",
|
||||||
|
"nethttpAndNativeSignals",
|
||||||
|
"listenerReleased",
|
||||||
|
"default404And405",
|
||||||
|
"goOwnedErrorCatalog",
|
||||||
|
"unwrappedDownloads",
|
||||||
|
"rawCallback",
|
||||||
|
"multipart400And413",
|
||||||
|
"nethttpAndNativeTransfers",
|
||||||
|
"initializationAndListenFailureCleanup",
|
||||||
|
"drainBeforeDependencyClose",
|
||||||
|
"generatedConsumerRaceTests",
|
||||||
|
"errorParamsAndInheritance",
|
||||||
|
"manualHooksAndScalars",
|
||||||
|
"dependencyFatalShutdown",
|
||||||
|
"fatalProcessExitCode",
|
||||||
|
"initializationShutdownNotification",
|
||||||
|
"configuredBuiltinEndpoints",
|
||||||
|
"projectHealthEnvelope",
|
||||||
|
"endpointConflictCleanup",
|
||||||
|
"responseArrayNullableNull",
|
||||||
|
"strictRequestPolicies",
|
||||||
|
"declarativeMultipartBinding",
|
||||||
|
"backgroundStopDrainResourceOrder",
|
||||||
|
"backgroundInitAndListenFailureCleanup",
|
||||||
|
"backgroundTimeoutRetainsResourcesAndExits"
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
|
||||||
|
"lifecycle/phases.go": "1436856c708d004ebb28704a867316bf7a44553de56b3e29152d41475a2f793a",
|
||||||
|
"config/lifecycle_test.go": "8e5fccf0f7432c64d7fa8a52687002307d3ebb1fd039adb6ae09da0c9a2891bf",
|
||||||
|
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
|
||||||
|
"cmd/go-omni/apigen/templates.go": "6a98c26329d4c54d97abc170a324016931baac0e21028f649f1660347f5dd971",
|
||||||
|
"config/project.go": "54b4d6f4d3a2e7a172e7f446af22067735b7c02ba253dd2483f42190616e90d3",
|
||||||
|
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
|
||||||
|
"contract/policy.go": "73a3a55fb88195b32f22b2bc066d9b13dccd7fd520e98c5a1b2979a3fd2447eb",
|
||||||
|
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
|
||||||
|
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
|
||||||
|
"cmd/go-omni/apigen/business_test.go": "c0af26cd270f1c971dc5886316b144b776fdb886e930f883f0ee0c94b0909226",
|
||||||
|
"cmd/go-omni/apigen/generate.go": "819ce227f1041fa9447d7fbff4bfc2d4ea60dcb73e8667a21a41a090b40f0dd3",
|
||||||
|
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||||
|
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||||
|
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||||
|
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
|
||||||
|
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
|
||||||
|
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
|
||||||
|
"scripts/verifyManagedRelease.py": "8bc3ce425ca93cbea370fa94ff855922bdb60b2213330dec1cddd25d73d6f12b",
|
||||||
|
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "8b1b86d38151e78a1a18d8ecf001b9635d32c37276abaf2c9e28af10aa983e9d",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "4d826d7133027ac2399f8209f03efa46ed54aa45dcd0a35274a9009314941dca",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "9e103c9de7a1a906903bc09787e1b38905dc5bb4d891c88938028da5dee98f6f",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user