fix: support recursive response DTOs and release v0.28.2
This commit is contained in:
4
API.md
4
API.md
@@ -109,7 +109,7 @@ service user-api {
|
|||||||
- 来源标签中的 `optional` / `omitempty`、标量 `default`、标量 `options=a|b`、数值 `range=[1:100]`(支持开区间和单边界)。
|
- 来源标签中的 `optional` / `omitempty`、标量 `default`、标量 `options=a|b`、数值 `range=[1:100]`(支持开区间和单边界)。
|
||||||
- `binding` 的 `required`、`min`、`max`、`len`、`email`。长度按 Unicode 字符或切片元素数;范围边界必须有限且位于 ±2^53 内。
|
- `binding` 的 `required`、`min`、`max`、`len`、`email`。长度按 Unicode 字符或切片元素数;范围边界必须有限且位于 ±2^53 内。
|
||||||
|
|
||||||
暂不支持匿名嵌入、别名、递归类型、未声明的外部 Go 类型、未通过 scalar 显式接入的自定义编解码、`[]byte`、泛型、`json:",string"`、任意 validator 标签、go-zero 的 JWT/签名/SSE 声明,以及客户端 SDK 生成。认证验证通过声明关联的命名中间件实现。未列出的语法会明确报错。
|
暂不支持匿名嵌入、别名、递归请求类型、未声明的外部 Go 类型、未通过 scalar 显式接入的自定义编解码、`[]byte`、泛型、`json:",string"`、任意 validator 标签、go-zero 的 JWT/签名/SSE 声明,以及客户端 SDK 生成。认证验证通过声明关联的命名中间件实现。未列出的语法会明确报错。
|
||||||
|
|
||||||
业务错误、具名鉴权、多行文档及下述开发命令从 CLI `v0.7.0-preview.1` 提供。公共响应模板从 `v0.8.0-preview` 提供,CLI 与业务项目中的框架依赖需一起升级。
|
业务错误、具名鉴权、多行文档及下述开发命令从 CLI `v0.7.0-preview.1` 提供。公共响应模板从 `v0.8.0-preview` 提供,CLI 与业务项目中的框架依赖需一起升级。
|
||||||
|
|
||||||
@@ -331,3 +331,5 @@ python3 scripts/verifyManagedProject.py
|
|||||||
Go 测试覆盖契约拒绝规则、多源绑定、零值/缺失、默认/范围/嵌套集合、文档约束、幂等更新、只读检查、业务保留、删除路由、路径边界及生成项目编译。Python 验收会在临时目录生成项目,分别启动 nethttp/native,验证真实 HTTP 请求、中间件、日志 requestId 和优雅退出;结束后清理自己创建的进程和临时文件。结果记录在 `performance/managedProjectLocal.json`;完整记录见 [第一版验收](performance/managedProjectAcceptance.md)。
|
Go 测试覆盖契约拒绝规则、多源绑定、零值/缺失、默认/范围/嵌套集合、文档约束、幂等更新、只读检查、业务保留、删除路由、路径边界及生成项目编译。Python 验收会在临时目录生成项目,分别启动 nethttp/native,验证真实 HTTP 请求、中间件、日志 requestId 和优雅退出;结束后清理自己创建的进程和临时文件。结果记录在 `performance/managedProjectLocal.json`;完整记录见 [第一版验收](performance/managedProjectAcceptance.md)。
|
||||||
|
|
||||||
内置健康与 OpenAPI 路由可通过配置禁用或修改路径,`.api` 不再固定保留 `/health/live`、`/health/ready`、`/openapi.json`。启用的内置路径与业务 GET 路由重叠时,生成的 NewApp 在监听前返回配置错误;禁用后可以由 `.api` 自己维护这些路径。见 [内置接口配置](PROJECT.md#配置内置接口)。
|
内置健康与 OpenAPI 路由可通过配置禁用或修改路径,`.api` 不再固定保留 `/health/live`、`/health/ready`、`/openapi.json`。启用的内置路径与业务 GET 路由重叠时,生成的 NewApp 在监听前返回配置错误;禁用后可以由 `.api` 自己维护这些路径。见 [内置接口配置](PROJECT.md#配置内置接口)。
|
||||||
|
|
||||||
|
响应 DTO 自引用与相互引用从 v0.28.2 起支持,保留强类型并生成 OpenAPI `$ref`;见[递归响应说明](DEVELOPMENT.md#递归响应-dto)。直接值循环与字段组合循环仍然非法。
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# v0.28.2-preview
|
||||||
|
|
||||||
|
- 修复响应 DTO 的自引用/相互引用生成,支持 slice、pointer、map;OpenAPI 使用命名组件与循环引用,保留约束、描述、示例及 scalar 输出 schema。
|
||||||
|
- 拒绝 Go 非法值循环与递归请求;保留格式化、生成、契约变更和只读检查能力。新增通用树示例与真实生成消费者回归。
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v0.27.0-preview(2026-09-29)
|
## v0.27.0-preview(2026-09-29)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 通用业务开发指南(v0.27.0-preview)
|
# 通用业务开发指南(v0.28.2-preview)
|
||||||
|
|
||||||
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
||||||
|
|
||||||
@@ -35,6 +35,30 @@ delete /item
|
|||||||
|
|
||||||
基础类型数组还支持 int/int8/int16/int32、uint/uint16/uint32/uint64、float32/float64。nil 根数组都输出 `[]`;`[]any` 在 OpenAPI 中是 `type: array, items: {}`,不限制元素形状,但元素必须可被标准 JSON 编码器序列化。`[]uint8`(Go 的 byte slice)会编码为 base64,故明确拒绝;数字数组可用 `[]int32`,二进制用下载接口。当前不支持根嵌套数组或根自定义 scalar 数组。
|
基础类型数组还支持 int/int8/int16/int32、uint/uint16/uint32/uint64、float32/float64。nil 根数组都输出 `[]`;`[]any` 在 OpenAPI 中是 `type: array, items: {}`,不限制元素形状,但元素必须可被标准 JSON 编码器序列化。`[]uint8`(Go 的 byte slice)会编码为 base64,故明确拒绝;数字数组可用 `[]int32`,二进制用下载接口。当前不支持根嵌套数组或根自定义 scalar 数组。
|
||||||
|
|
||||||
|
## 递归响应 DTO
|
||||||
|
|
||||||
|
v0.28.2 起,响应 DTO 可引用自身或相互引用;名字与字段由项目定义:
|
||||||
|
|
||||||
|
```text
|
||||||
|
type Node {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Children []Node `json:"children"`
|
||||||
|
Next *Node `json:"next,omitempty"`
|
||||||
|
Lookup map[string]Node `json:"lookup,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
@handler tree
|
||||||
|
get /tree returns (Node)
|
||||||
|
```
|
||||||
|
|
||||||
|
Go 类型保留 `[]Node`、`*Node`、`map[string]Node`,不降级为 `any`。生成器将递归响应图中的命名 DTO 写入 `components.schemas.DTO_<类型名>`,字段使用 `$ref`;对象、数组、可空根对象、raw 及自定义响应模板都支持。可空引用使用 `anyOf` 的引用与 null 分支,描述和示例通过 `allOf` 保留,遵循 [OpenAPI 3.0.3 引用规则](https://spec.openapis.org/oas/v3.0.3.html#reference-object)。
|
||||||
|
|
||||||
|
字段命名、标签、约束、重复字段与示例继续校验,自定义 scalar 使用声明的输出类型。有限 JSON 示例按实际内容检查,仍受 32 层/10000 节点的编译限制。类型引用不会无限展开;再次生成和契约变更检查会遍历引用图并保留手写代码。
|
||||||
|
|
||||||
|
范围是 `.api` 的响应类型。递归请求 DTO(包括间接包含递归类型的请求)仍不支持,`contract.New/Describe` 与手写 `openapi.SchemaOf` 的限制不变;它们不会自动获得递归请求绑定能力。直接值递归如 `Child Node`、仅由值字段形成的相互递归,以及字段组合循环仍会报错。业务返回值必须是可序列化的有限数据,不能构造对象指针环。
|
||||||
|
|
||||||
|
升级 CLI 后,在项目自己的 `.api` 中将临时 `[]any` 恢复成 `[]项目类型`,执行 `go-omni api generate`、`go-omni api generate --check` 和 `go test ./...`。修改 `.api` 后应先生成再检查;不要手改生成的 DTO。不改变项目的菜单、分类、权限或数据构造规则。完整可运行步骤见 [递归响应示例](examples/recursiveResponse/README.md)。
|
||||||
|
|
||||||
## 契约驱动的请求策略
|
## 契约驱动的请求策略
|
||||||
|
|
||||||
入口文件可以声明默认策略,单个接口按需覆盖:
|
入口文件可以声明默认策略,单个接口按需覆盖:
|
||||||
@@ -201,7 +225,7 @@ go-omni workspace check --file omni.workspace.json
|
|||||||
|
|
||||||
### 混合 API 与通用工程
|
### 混合 API 与通用工程
|
||||||
|
|
||||||
v0.28.1-preview 起,workspace 支持在同一份清单中管理 API、通用工程,也支持仅包含通用工程的工作区:
|
v0.28.2-preview 起,workspace 支持在同一份清单中管理 API、通用工程,也支持仅包含通用工程的工作区:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## 创建与运行
|
## 创建与运行
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.2-preview
|
||||||
go-omni project new demo --module example.com/demo
|
go-omni project new demo --module example.com/demo
|
||||||
cd demo
|
cd demo
|
||||||
go mod tidy
|
go mod tidy
|
||||||
@@ -78,4 +78,4 @@ go test ./...
|
|||||||
|
|
||||||
check 检查模板漂移和必要文件,不检查业务正确性,也不能替代 Go 编译与测试。新的模板版本会重新生成受管文件;不自动合并项目所有文件或更新 go.mod。未知的新清单版本会要求使用兼容 CLI。
|
check 检查模板漂移和必要文件,不检查业务正确性,也不能替代 Go 编译与测试。新的模板版本会重新生成受管文件;不自动合并项目所有文件或更新 go.mod。未知的新清单版本会要求使用兼容 CLI。
|
||||||
|
|
||||||
原有 `new` / `api new` 及 API 工程行为保留。通用工程使用自己的清单和命令,不能交给 `api generate` 或 `add` 转换;新增业务包直接写普通 Go。从 v0.28.1-preview 起,`workspace generate/check` 可以同时处理 API 与通用工程。清单可填写 `kind: api | project`,省略时按生成清单识别;format 对通用工程显示 SKIP。见 [完整混合示例](examples/mixedWorkspace/README.md)。各 module 仍独立,workspace 命令不维护 go.work 或模块依赖。
|
原有 `new` / `api new` 及 API 工程行为保留。通用工程使用自己的清单和命令,不能交给 `api generate` 或 `add` 转换;新增业务包直接写普通 Go。从 v0.28.2-preview 起,`workspace generate/check` 可以同时处理 API 与通用工程。清单可填写 `kind: api | project`,省略时按生成清单识别;format 对通用工程显示 SKIP。见 [完整混合示例](examples/mixedWorkspace/README.md)。各 module 仍独立,workspace 命令不维护 go.work 或模块依赖。
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -2,15 +2,17 @@
|
|||||||
|
|
||||||
GoOmni 是面向多个项目复用的 Go 基础框架。通用工程使用 `project new` 生成配置、日志、依赖装配、生命周期和普通 Go 运行入口;需要 HTTP 接口时可选择 `.api` 工程生成,也可以在已有项目中按需引入独立模块。运行方式、业务类型和外部依赖由项目决定。
|
GoOmni 是面向多个项目复用的 Go 基础框架。通用工程使用 `project new` 生成配置、日志、依赖装配、生命周期和普通 Go 运行入口;需要 HTTP 接口时可选择 `.api` 工程生成,也可以在已有项目中按需引入独立模块。运行方式、业务类型和外部依赖由项目决定。
|
||||||
|
|
||||||
|
**v0.28.2 改动**:API 响应 DTO 支持 `Children []Node`、`Next *Node`、递归 map 和相互引用,生成强类型 Go 字段及有限的 OpenAPI `$ref` 图。字段约束、示例校验、生成与漂移检查继续有效;请求递归和非法值递归仍会报错。见 [可运行示例](examples/recursiveResponse/README.md)与[接入说明](DEVELOPMENT.md#递归响应-dto)。
|
||||||
|
|
||||||
**v0.28.1 改动**:`workspace generate/check` 支持 API 与通用工程混合处理,可选 `kind: api | project`,省略时按生成清单识别。`format` 对通用工程显示跳过;检查只读,生成前统一预检。见 [混合工作区示例](examples/mixedWorkspace/README.md)。
|
**v0.28.1 改动**:`workspace generate/check` 支持 API 与通用工程混合处理,可选 `kind: api | project`,省略时按生成清单识别。`format` 对通用工程显示跳过;检查只读,生成前统一预检。见 [混合工作区示例](examples/mixedWorkspace/README.md)。
|
||||||
|
|
||||||
**v0.28 改动**:新增独立的 `project new/generate/check`,默认无 API、监听端口、业务任务或外部服务配置;保护手写代码,并等待主运行入口和登记的后台工作结束后释放依赖。见 [通用工程指南](PROGRAM.md)和[可运行示例](examples/program/README.md)。
|
**v0.28 改动**:新增独立的 `project new/generate/check`,默认无 API、监听端口、业务任务或外部服务配置;保护手写代码,并等待主运行入口和登记的后台工作结束后释放依赖。见 [通用工程指南](PROGRAM.md)和[可运行示例](examples/program/README.md)。
|
||||||
|
|
||||||
**v0.27 改动**:统一三种内置服务实现的默认/路由/硬请求体限额,配置冲突在启动前报错;停机以处理函数实际退出为资源清理前提,超时仍有请求时保留共享依赖。见 [HTTP 策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)和[可运行示例](examples/httpSafety/README.md)。
|
**v0.27 改动**:统一三种内置服务实现的默认/路由/硬请求体限额,配置冲突在启动前报错;停机以处理函数实际退出为资源清理前提,超时仍有请求时保留共享依赖。见 [HTTP 策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)和[可运行示例](examples/httpSafety/README.md)。
|
||||||
|
|
||||||
**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.28.1-preview。同时修复 Go 1.22 下请求体限额错误的识别。
|
**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.28.2-preview。同时修复 Go 1.22 下请求体限额错误的识别。
|
||||||
|
|
||||||
**从这份 README 开始接入。** 当前版本 **`v0.28.1-preview`**,通用工程采用根目录入口和 `config / svc / run`;API 工程使用 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
**从这份 README 开始接入。** 当前版本 **`v0.28.2-preview`**,通用工程采用根目录入口和 `config / svc / run`;API 工程使用 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
**v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。
|
**v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
|
|||||||
## 通用工程快速开始
|
## 通用工程快速开始
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.2-preview
|
||||||
go-omni project new demo --module example.com/demo
|
go-omni project new demo --module example.com/demo
|
||||||
cd demo
|
cd demo
|
||||||
go mod tidy
|
go mod tidy
|
||||||
@@ -180,7 +182,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.28.1-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.2-preview
|
||||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
go-omni --help
|
go-omni --help
|
||||||
```
|
```
|
||||||
@@ -337,7 +339,7 @@ go-omni new --api catalog.api \
|
|||||||
在业务项目的 **go.mod 所在目录**执行:
|
在业务项目的 **go.mod 所在目录**执行:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get git.yuntuo.hk/k2safe/go-omni@v0.28.1-preview
|
go get git.yuntuo.hk/k2safe/go-omni@v0.28.2-preview
|
||||||
```
|
```
|
||||||
|
|
||||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
||||||
@@ -668,7 +670,7 @@ go test ./...
|
|||||||
|
|
||||||
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
|
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
|
||||||
|
|
||||||
CLI 与业务 module 固定使用 `v0.28.1-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
CLI 与业务 module 固定使用 `v0.28.2-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
## 按需深入
|
## 按需深入
|
||||||
|
|
||||||
|
|||||||
17
RELEASE.md
17
RELEASE.md
@@ -1,3 +1,20 @@
|
|||||||
|
# v0.28.2-preview
|
||||||
|
|
||||||
|
修复 `.api` 响应 DTO 自引用与相互引用的生成限制。`Children []Node`、`Next *Node`、`map[string]Node` 保留强类型,响应文档使用有限的命名组件与 `$ref`;可空引用、字段描述、示例、约束和 scalar 输出类型保持准确。框架不引入业务模型。
|
||||||
|
|
||||||
|
请求绑定继续拒绝递归类型,Go 非法直接值循环与字段组合循环继续报错。已有非递归类型保持原有生成行为;无需修改运行时配置或响应 JSON。升级 CLI,在项目 `.api` 中恢复强类型后重新生成,再执行检查和项目回归。生成模板最低运行时版本不变。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.2-preview
|
||||||
|
go-omni api generate
|
||||||
|
go-omni api generate --check
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
[递归响应说明](DEVELOPMENT.md#递归响应-dto)、[可运行示例](examples/recursiveResponse/README.md)。56 个测试包全量 race 与 Go 1.22 集成回归、vet/build、独立 OpenAPI 校验均通过,见[验收记录](reports/recursiveResponseAcceptance.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# v0.28.1-preview
|
# v0.28.1-preview
|
||||||
|
|
||||||
workspace generate/check 支持 API 与通用工程混合分发,也可处理纯通用工程工作区。services 新增可选 kind(api/project);省略时按生成清单识别,保留无清单时显式 api 入口的原有用法。类型与清单冲突、歧义和不合法选项会报错,项目身份、路径与文件归属继续由各生成器校验。
|
workspace generate/check 支持 API 与通用工程混合分发,也可处理纯通用工程工作区。services 新增可选 kind(api/project);省略时按生成清单识别,保留无清单时显式 api 入口的原有用法。类型与清单冲突、歧义和不合法选项会报错,项目身份、路径与文件归属继续由各生成器校验。
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ API 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、hand
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 仓库读取权限与 PATH 配置见根目录 README
|
# 仓库读取权限与 PATH 配置见根目录 README
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.2-preview
|
||||||
go-omni new --module example.com/hello ./hello
|
go-omni new --module example.com/hello ./hello
|
||||||
|
|
||||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||||
@@ -33,6 +33,8 @@ go-omni new --api service.api --module example.com/example ./example
|
|||||||
|
|
||||||
存储配置和客户端位于 config/svc,业务使用统一接口。local 存储签名端点由生成器接入;上传、下载等业务路由由 `.api` 定义。认证与存储路径由项目实现。
|
存储配置和客户端位于 config/svc,业务使用统一接口。local 存储签名端点由生成器接入;上传、下载等业务路由由 `.api` 定义。认证与存储路径由项目实现。
|
||||||
|
|
||||||
|
响应 DTO 支持自引用及相互引用,生成强类型 Go 字段与 OpenAPI `$ref`;见[递归响应示例](../../examples/recursiveResponse/README.md)。请求递归仍会报错。
|
||||||
|
|
||||||
## 日常生成
|
## 日常生成
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -55,14 +57,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
|||||||
|
|
||||||
## 版本与迁移
|
## 版本与迁移
|
||||||
|
|
||||||
CLI 与框架依赖固定使用 v0.28.1-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
CLI 与框架依赖固定使用 v0.28.2-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.28.1-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
当前模板与依赖以 v0.28.2-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||||
|
|
||||||
## 数据库迁移命令
|
## 数据库迁移命令
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"go/ast"
|
|
||||||
goparser "go/parser"
|
|
||||||
gotoken "go/token"
|
gotoken "go/token"
|
||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -69,56 +67,17 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
resolved := map[string]reflect.Type{}
|
graph, err := newResponseGraph(spec, definitions, scalarTypes, scalarDefs)
|
||||||
resolving := map[string]bool{}
|
if err != nil {
|
||||||
var resolve func(string, int) (reflect.Type, error)
|
return nil, err
|
||||||
var expression func(ast.Expr, int) (reflect.Type, error)
|
}
|
||||||
|
for _, r := range spec.Routes {
|
||||||
expression = func(e ast.Expr, depth int) (reflect.Type, error) {
|
if graph.recursive[r.Request] {
|
||||||
if depth > 32 {
|
return nil, fmt.Errorf("%s: recursive request DTO %s is unsupported; recursive types are supported for responses only", r.Position, r.Request)
|
||||||
return nil, errors.New("type nesting exceeds 32")
|
|
||||||
}
|
|
||||||
switch e := e.(type) {
|
|
||||||
case *ast.Ident:
|
|
||||||
if t, ok := scalarTypes[e.Name]; ok {
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
if t, ok := primitiveTypes[e.Name]; ok {
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
return resolve(e.Name, depth+1)
|
|
||||||
case *ast.StarExpr:
|
|
||||||
if _, double := e.X.(*ast.StarExpr); double {
|
|
||||||
return nil, errors.New("multiple pointer levels unsupported")
|
|
||||||
}
|
|
||||||
t, err := expression(e.X, depth+1)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return reflect.PointerTo(t), nil
|
|
||||||
case *ast.ArrayType:
|
|
||||||
if e.Len != nil {
|
|
||||||
return nil, errors.New("use slices, not fixed arrays")
|
|
||||||
}
|
|
||||||
t, err := expression(e.Elt, depth+1)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return reflect.SliceOf(t), nil
|
|
||||||
case *ast.MapType:
|
|
||||||
key, ok := e.Key.(*ast.Ident)
|
|
||||||
if !ok || key.Name != "string" {
|
|
||||||
return nil, errors.New("map keys must be string")
|
|
||||||
}
|
|
||||||
t, err := expression(e.Value, depth+1)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return reflect.MapOf(reflect.TypeOf(""), t), nil
|
|
||||||
default:
|
|
||||||
return nil, errors.New("unsupported type expression")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
resolved := map[string]reflect.Type{}
|
||||||
|
var resolve func(string, int) (reflect.Type, error)
|
||||||
resolve = func(name string, depth int) (reflect.Type, error) {
|
resolve = func(name string, 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")
|
||||||
@@ -126,27 +85,13 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
if t, ok := resolved[name]; ok {
|
if t, ok := resolved[name]; ok {
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
if resolving[name] {
|
|
||||||
return nil, fmt.Errorf("recursive type %s is unsupported", name)
|
|
||||||
}
|
|
||||||
typ, ok := definitions[name]
|
typ, ok := definitions[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("unknown type %s", name)
|
return nil, fmt.Errorf("unknown type %s", name)
|
||||||
}
|
}
|
||||||
resolving[name] = true
|
|
||||||
defer delete(resolving, name)
|
|
||||||
var fields []reflect.StructField
|
var fields []reflect.StructField
|
||||||
seen := map[string]bool{}
|
for i, f := range typ.Fields {
|
||||||
for _, f := range typ.Fields {
|
t, err := graph.expression(graph.exprs[name][i], depth+1, resolve)
|
||||||
if !exportedName.MatchString(f.Name) || seen[f.Name] {
|
|
||||||
return nil, fmt.Errorf("%s: invalid or duplicate field %s", name, f.Name)
|
|
||||||
}
|
|
||||||
seen[f.Name] = true
|
|
||||||
expr, err := goparser.ParseExpr(f.Type)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
t, err := expression(expr, depth+1)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s.%s: %w", name, f.Name, err)
|
return nil, fmt.Errorf("%s.%s: %w", name, f.Name, err)
|
||||||
}
|
}
|
||||||
@@ -158,6 +103,12 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
}
|
}
|
||||||
c := &Compiled{Spec: spec, Bindings: map[string]*contract.Binder{}}
|
c := &Compiled{Spec: spec, Bindings: map[string]*contract.Binder{}}
|
||||||
for _, t := range spec.Types {
|
for _, t := range spec.Types {
|
||||||
|
if graph.recursive[t.Name] {
|
||||||
|
if _, err := graph.component(t.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
rt, err := resolve(t.Name, 0)
|
rt, err := resolve(t.Name, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -274,7 +225,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
}
|
}
|
||||||
var response *openapi.Schema
|
var response *openapi.Schema
|
||||||
if r.Response != "" {
|
if r.Response != "" {
|
||||||
s, err := responseDataSchema(r.Response, c.Bindings)
|
s, err := responseDataSchema(r.Response, c.Bindings, graph)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -316,6 +267,13 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
decorateDocument(doc, spec, c.Bindings)
|
decorateDocument(doc, spec, c.Bindings)
|
||||||
|
if len(graph.components) > 0 {
|
||||||
|
components := map[string]any{}
|
||||||
|
for name, schema := range graph.components {
|
||||||
|
components[name] = schema
|
||||||
|
}
|
||||||
|
doc["components"].(map[string]any)["schemas"] = components
|
||||||
|
}
|
||||||
compactDocument(doc)
|
compactDocument(doc)
|
||||||
c.Document, err = json.MarshalIndent(doc, "", " ")
|
c.Document, err = json.MarshalIndent(doc, "", " ")
|
||||||
return c, err
|
return c, err
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
|||||||
|
|
||||||
// Runtime-only releases need not raise the managed template compatibility floor.
|
// Runtime-only releases need not raise the managed template compatibility floor.
|
||||||
const minimumVersion = "v0.22.0-preview"
|
const minimumVersion = "v0.22.0-preview"
|
||||||
const currentVersion = "v0.28.1-preview"
|
const currentVersion = "v0.28.2-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 {
|
||||||
|
|||||||
299
cmd/go-omni/apigen/recursiveResponse_test.go
Normal file
299
cmd/go-omni/apigen/recursiveResponse_test.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func compileRecursive(t *testing.T, source string) *Compiled {
|
||||||
|
t.Helper()
|
||||||
|
spec, err := parseText(t, source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, err := Compile(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := Compile(spec)
|
||||||
|
if err != nil || !bytes.Equal(c.Document, again.Document) {
|
||||||
|
t.Fatal("non-deterministic compilation", err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func recursiveDocument(t *testing.T, c *Compiled) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var doc map[string]any
|
||||||
|
if err := json.Unmarshal(c.Document, &doc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Inspect reference targets without recursively inlining the graph.
|
||||||
|
var visit func(any)
|
||||||
|
visit = func(value any) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
if ref, ok := v["$ref"].(string); ok {
|
||||||
|
if len(v) != 1 {
|
||||||
|
t.Fatalf("OpenAPI 3.0 ref has ignored siblings: %v", v)
|
||||||
|
}
|
||||||
|
var target any = doc
|
||||||
|
for _, part := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") {
|
||||||
|
m, ok := target.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("bad reference", ref)
|
||||||
|
}
|
||||||
|
target = m[part]
|
||||||
|
}
|
||||||
|
if target == nil {
|
||||||
|
t.Fatal("dangling reference", ref)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for key, child := range v {
|
||||||
|
if key != "example" && key != "examples" {
|
||||||
|
visit(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range v {
|
||||||
|
visit(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(doc)
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecursiveResponseGraph(t *testing.T) {
|
||||||
|
source := readFile(t, filepath.Join(frameworkRoot(t), "examples/recursiveResponse/service.api"))
|
||||||
|
c := compileRecursive(t, source)
|
||||||
|
doc := recursiveDocument(t, c)
|
||||||
|
schemas := doc["components"].(map[string]any)["schemas"].(map[string]any)
|
||||||
|
node := schemas["DTO_Node"].(map[string]any)
|
||||||
|
props := node["properties"].(map[string]any)
|
||||||
|
children := props["children"].(map[string]any)
|
||||||
|
if children["type"] != "array" || children["maxItems"] != float64(100) || children["items"].(map[string]any)["$ref"] != "#/components/schemas/DTO_Node" {
|
||||||
|
t.Fatal(children)
|
||||||
|
}
|
||||||
|
if props["lookup"].(map[string]any)["additionalProperties"].(map[string]any)["$ref"] != "#/components/schemas/DTO_Node" {
|
||||||
|
t.Fatal(props)
|
||||||
|
}
|
||||||
|
next := props["next"].(map[string]any)["anyOf"].([]any)
|
||||||
|
if len(next) != 2 || next[0].(map[string]any)["$ref"] != "#/components/schemas/DTO_Node" || next[1].(map[string]any)["nullable"] != true {
|
||||||
|
t.Fatal(next)
|
||||||
|
}
|
||||||
|
for _, pair := range [][2]string{{"Branch", "Leaf"}, {"Leaf", "Branch"}} {
|
||||||
|
p := schemas["DTO_"+pair[0]].(map[string]any)["properties"].(map[string]any)[strings.ToLower(pair[1])].(map[string]any)
|
||||||
|
if p["anyOf"].([]any)[0].(map[string]any)["$ref"] != "#/components/schemas/DTO_"+pair[1] {
|
||||||
|
t.Fatal(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(c.Document) > 18000 {
|
||||||
|
t.Fatal("recursive graph expanded", len(c.Document))
|
||||||
|
}
|
||||||
|
first, _ := json.Marshal(doc)
|
||||||
|
compactDocument(doc)
|
||||||
|
second, _ := json.Marshal(doc)
|
||||||
|
if !bytes.Equal(first, second) {
|
||||||
|
t.Fatal("document compaction changed references")
|
||||||
|
}
|
||||||
|
// Change tracking traverses every named dependency once, including cycles.
|
||||||
|
snapshot := snapshotContract(c.Spec)
|
||||||
|
if len(snapshot.Routes) == 0 {
|
||||||
|
t.Fatal("empty contract snapshot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecursiveResponseRejections(t *testing.T) {
|
||||||
|
tests := []struct{ name, types, route, want string }{
|
||||||
|
{"valueSelf", "type Node { Child Node `json:\"child\"` }", "get /tree returns (Node)", "recursive value"},
|
||||||
|
{"valueMutual", "type Node { Child Other `json:\"child\"` }\ntype Other { Child Node `json:\"child\"` }", "get /tree returns (Node)", "recursive value"},
|
||||||
|
{"request", "type Node { Children []Node `json:\"children\"` }", "post /tree (Node) returns (Node)", "recursive request"},
|
||||||
|
{"wrappedRequest", "type Node { Children []Node `json:\"children\"` }\ntype Req { Value Node `json:\"value\"` }", "post /tree (Req)", "recursive request"},
|
||||||
|
{"unknown", "type Node { Children []Node `json:\"children\"`\n Bad Missing `json:\"bad\"` }", "get /tree returns (Node)", "unknown type"},
|
||||||
|
{"duplicate", "type Node { Children []Node `json:\"children\"`\n Again string `json:\"children\"` }", "get /tree returns (Node)", "conflicting composed field"},
|
||||||
|
{"nonJSON", "type Node { Children []Node `json:\"children\"`\n Name string `form:\"name\"` }", "get /tree returns (Node)", "response fields must use json"},
|
||||||
|
{"nestedNonJSON", "type Node { Children []Node `json:\"children\"`\n Detail Detail `json:\"detail\"` }\ntype Detail { Name string `form:\"name\"` }", "get /tree returns (Node)", "response fields must use json"},
|
||||||
|
{"doublePointer", "type Node { Next **Node `json:\"next\"` }", "get /tree returns (Node)", "multiple pointer"},
|
||||||
|
{"fixedArray", "type Node { Children [1]Node `json:\"children\"` }", "get /tree returns (Node)", "fixed arrays"},
|
||||||
|
{"mapKey", "type Node { Children map[int]Node `json:\"children\"` }", "get /tree returns (Node)", "map keys"},
|
||||||
|
{"badRule", "type Node { Next *Node `json:\"next\" binding:\"min=1\"` }", "get /tree returns (Node)", "requires a number"},
|
||||||
|
{"badDefault", "type Node { Next *Node `json:\"next,default=x\"` }", "get /tree returns (Node)", "default requires"},
|
||||||
|
{"upload", "type Node { Children []Node `json:\"children\"`\n Upload File `file:\"upload\"` }", "get /tree returns (Node)", "response fields must use json"},
|
||||||
|
{"composition", "type Node { Node }", "get /tree returns (Node)", "cycle"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
spec, err := parseText(t, tc.types+"\nservice demo {\n@handler tree\n"+tc.route+"\n}")
|
||||||
|
if err == nil {
|
||||||
|
_, err = Compile(spec)
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatalf("want %q, got %v", tc.want, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecursiveResponseExamples(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
value string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{`[]`, true}, {`[{"name":"first","children":[{"name":"second","children":[]}]}]`, true},
|
||||||
|
{`[{"name":"","children":[]}]`, false}, {`[{"name":"first"}]`, false},
|
||||||
|
{`[{"name":"first","children":[],"unknown":true}]`, false},
|
||||||
|
{`[{"name":"first","children":[]},{"name":123,"children":[]}]`, false},
|
||||||
|
{`[{"name":"first","children":[{"name":"second","children":[{"name":"","children":[]}]}]}]`, false},
|
||||||
|
{`null`, false}, {`{} {}`, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.value, func(t *testing.T) {
|
||||||
|
source := "type Node {\n Name string `json:\"name\" binding:\"min=1\"`\n Children []Node `json:\"children\" description:\"Nested nodes\" example:" + fmt.Sprintf("%q", tc.value) + "`\n}\nservice demo {\n @handler tree\nget /tree returns (Node)\n}"
|
||||||
|
spec, err := parseText(t, source)
|
||||||
|
if err == nil {
|
||||||
|
_, err = Compile(spec)
|
||||||
|
}
|
||||||
|
if (err == nil) != tc.valid {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, typ := range []string{"*Node", "map[string]*Node", "[]*Node", "*[]Node"} {
|
||||||
|
source := "type Node {\n Name string `json:\"name\"`\n Next " + typ + " `json:\"next,omitempty\" description:\"Next node\"`\n}\nservice demo {\n@handler tree\nget /tree returns (*Node)\n}"
|
||||||
|
recursiveDocument(t, compileRecursive(t, source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecursiveResponseImportsAndMetadata(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(root, "service.api"), "import \"node.api\"\nimport \"detail.api\"\nservice demo {\n@handler tree\nget /tree returns (Node)\n}")
|
||||||
|
writeFile(t, filepath.Join(root, "node.api"), "type Node {\n Children []Node `json:\"children\"`\n Detail Detail `json:\"detail\" description:\"Node detail\" example:\"{\\\"count\\\":9007199254740993,\\\"parent\\\":null}\"`\n}")
|
||||||
|
writeFile(t, filepath.Join(root, "detail.api"), "type Detail {\n Count int64 `json:\"count\"`\n Parent *Node `json:\"parent\"`\n}")
|
||||||
|
spec, err := Parse(filepath.Join(root, "service.api"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, err := Compile(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
doc := recursiveDocument(t, c)
|
||||||
|
schemas := doc["components"].(map[string]any)["schemas"].(map[string]any)
|
||||||
|
detail := schemas["DTO_Node"].(map[string]any)["properties"].(map[string]any)["detail"].(map[string]any)
|
||||||
|
if detail["description"] != "Node detail" || detail["allOf"].([]any)[0].(map[string]any)["$ref"] != "#/components/schemas/DTO_Detail" {
|
||||||
|
t.Fatal(detail)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(c.Document, []byte("9007199254740993")) {
|
||||||
|
t.Fatal("example integer lost precision")
|
||||||
|
}
|
||||||
|
// Definition order cannot change cycle classification or component schemas.
|
||||||
|
spec.Types[0], spec.Types[1] = spec.Types[1], spec.Types[0]
|
||||||
|
other, err := Compile(spec)
|
||||||
|
if err != nil || !bytes.Equal(c.Document, other.Document) {
|
||||||
|
t.Fatal("definition order changed schemas", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scalar := `scalar ID (
|
||||||
|
goImport: "example.com/scalars"
|
||||||
|
goType: ID
|
||||||
|
jsonInput: string | int64
|
||||||
|
jsonOutput: string
|
||||||
|
)
|
||||||
|
type Node {
|
||||||
|
Value ID ` + "`json:\"value\" example:\"123\"`" + `
|
||||||
|
Children []Node ` + "`json:\"children\"`" + `
|
||||||
|
}
|
||||||
|
service demo {
|
||||||
|
@handler tree
|
||||||
|
get /tree returns (Node)
|
||||||
|
}`
|
||||||
|
doc = recursiveDocument(t, compileRecursive(t, scalar))
|
||||||
|
value := doc["components"].(map[string]any)["schemas"].(map[string]any)["DTO_Node"].(map[string]any)["properties"].(map[string]any)["value"].(map[string]any)
|
||||||
|
if value["type"] != "string" || value["example"] != nil {
|
||||||
|
t.Fatal("scalar input leaked into response", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecursiveResponseConsumer(t *testing.T) {
|
||||||
|
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||||
|
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||||
|
}
|
||||||
|
root := frameworkRoot(t)
|
||||||
|
dir := filepath.Join(t.TempDir(), "recursive")
|
||||||
|
o := Options{Dir: dir, API: filepath.Join(root, "examples/recursiveResponse/service.api"), Module: "example.com/recursive", FrameworkDir: root}
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
doc := recursiveDocument(t, &Compiled{Document: []byte(readFile(t, filepath.Join(dir, "docs/openapi.json")))})
|
||||||
|
schemas := doc["components"].(map[string]any)["schemas"].(map[string]any)
|
||||||
|
children := schemas["DTO_Node"].(map[string]any)["properties"].(map[string]any)["children"].(map[string]any)
|
||||||
|
if children["items"].(map[string]any)["$ref"] != "#/components/schemas/DTO_Node" {
|
||||||
|
t.Fatal("generated response lost its recursive schema", children)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"tree", "forest", "optional", "raw", "pair"} {
|
||||||
|
writeFile(t, filepath.Join(dir, "internal/logic/trees/"+name+".go"), "package trees\n")
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, "internal/logic/trees/operations.go"), readFile(t, filepath.Join(root, "examples/recursiveResponse/operations.go.txt")))
|
||||||
|
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/recursive/consumer_test.go"))
|
||||||
|
o.API = filepath.Join(dir, "api/service.api")
|
||||||
|
before := snapshot(t, dir)
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
o.Check = true
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("regeneration/check modified business code")
|
||||||
|
}
|
||||||
|
runConsumerGo(t, dir, "test", "-mod=mod", "-race", "-count=1", "./...")
|
||||||
|
// A recursive descendant change must still be visible to read-only checking.
|
||||||
|
writeFile(t, o.API, strings.Replace(readFile(t, o.API), "max=100", "max=90", 1))
|
||||||
|
before = snapshot(t, dir)
|
||||||
|
if err := generateManagedTestProject(t, o); err == nil {
|
||||||
|
t.Fatal("check missed recursive DTO drift")
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("drift check wrote files")
|
||||||
|
}
|
||||||
|
o.Check = false
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
o.Check = true
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
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 dep struct {
|
||||||
|
Version, Sum string
|
||||||
|
Replace any
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &dep); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dep.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dep.Sum == "" || dep.Replace != nil {
|
||||||
|
t.Fatal("not a fixed remote consumer")
|
||||||
|
}
|
||||||
|
t.Logf("remote framework %s checksum %s; no local replace", dep.Version, dep.Sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
370
cmd/go-omni/apigen/responseGraph.go
Normal file
370
cmd/go-omni/apigen/responseGraph.go
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
goparser "go/parser"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Named response types form a graph, not an infinitely expanded reflect.StructOf
|
||||||
|
// tree. Compile each object's local fields with the existing contract compiler,
|
||||||
|
// then link named fields through OpenAPI references. Request binders still use
|
||||||
|
// the complete, nonrecursive reflection tree.
|
||||||
|
type responseGraph struct {
|
||||||
|
types map[string]Type
|
||||||
|
exprs map[string][]ast.Expr
|
||||||
|
recursive map[string]bool // includes types that can reach a cycle
|
||||||
|
scalars map[string]reflect.Type
|
||||||
|
scalarDefs []contract.Scalar
|
||||||
|
components map[string]*openapi.Schema
|
||||||
|
}
|
||||||
|
|
||||||
|
func newResponseGraph(spec *Spec, types map[string]Type, scalars map[string]reflect.Type, defs []contract.Scalar) (*responseGraph, error) {
|
||||||
|
g := &responseGraph{types: types, exprs: map[string][]ast.Expr{}, recursive: map[string]bool{}, scalars: scalars, scalarDefs: defs, components: map[string]*openapi.Schema{}}
|
||||||
|
dependencies, values := map[string][]string{}, map[string][]string{}
|
||||||
|
for _, typ := range spec.Types {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, f := range typ.Fields {
|
||||||
|
if !exportedName.MatchString(f.Name) || seen[f.Name] {
|
||||||
|
return nil, fmt.Errorf("%s: invalid or duplicate field %s", typ.Name, f.Name)
|
||||||
|
}
|
||||||
|
seen[f.Name] = true
|
||||||
|
e, err := goparser.ParseExpr(f.Type)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Validate every expression, including otherwise unused declarations.
|
||||||
|
if _, err = g.expression(e, 0, func(name string, _ int) (reflect.Type, error) {
|
||||||
|
dependencies[typ.Name] = append(dependencies[typ.Name], name)
|
||||||
|
return reflect.TypeOf(struct{}{}), nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s.%s: %w", typ.Name, f.Name, err)
|
||||||
|
}
|
||||||
|
g.exprs[typ.Name] = append(g.exprs[typ.Name], e)
|
||||||
|
if id, ok := e.(*ast.Ident); ok && types[id.Name].Name != "" {
|
||||||
|
values[typ.Name] = append(values[typ.Name], id.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only pointer/slice/map indirection can break a Go struct layout cycle.
|
||||||
|
state := map[string]int{}
|
||||||
|
var valueCycle func(string) error
|
||||||
|
valueCycle = func(name string) error {
|
||||||
|
if state[name] == 1 {
|
||||||
|
return fmt.Errorf("%s: invalid recursive value type; use a pointer, slice or map", name)
|
||||||
|
}
|
||||||
|
if state[name] == 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
state[name] = 1
|
||||||
|
for _, next := range values[name] {
|
||||||
|
if err := valueCycle(next); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state[name] = 2
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, typ := range spec.Types {
|
||||||
|
if err := valueCycle(typ.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state = map[string]int{}
|
||||||
|
var cyclic func(string) bool
|
||||||
|
cyclic = func(name string) bool {
|
||||||
|
if state[name] == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if state[name] == 2 {
|
||||||
|
return g.recursive[name]
|
||||||
|
}
|
||||||
|
state[name] = 1
|
||||||
|
for _, next := range dependencies[name] {
|
||||||
|
if cyclic(next) {
|
||||||
|
g.recursive[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state[name] = 2
|
||||||
|
return g.recursive[name]
|
||||||
|
}
|
||||||
|
for _, typ := range spec.Types {
|
||||||
|
cyclic(typ.Name)
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *responseGraph) expression(e ast.Expr, depth int, named func(string, int) (reflect.Type, error)) (reflect.Type, error) {
|
||||||
|
if depth > 32 {
|
||||||
|
return nil, fmt.Errorf("type nesting exceeds 32")
|
||||||
|
}
|
||||||
|
switch e := e.(type) {
|
||||||
|
case *ast.Ident:
|
||||||
|
if t := g.scalars[e.Name]; t != nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
if t := primitiveTypes[e.Name]; t != nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
if g.types[e.Name].Name == "" {
|
||||||
|
return nil, fmt.Errorf("unknown type %s", e.Name)
|
||||||
|
}
|
||||||
|
return named(e.Name, depth+1)
|
||||||
|
case *ast.StarExpr:
|
||||||
|
if _, double := e.X.(*ast.StarExpr); double {
|
||||||
|
return nil, fmt.Errorf("multiple pointer levels unsupported")
|
||||||
|
}
|
||||||
|
t, err := g.expression(e.X, depth+1, named)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reflect.PointerTo(t), nil
|
||||||
|
case *ast.ArrayType:
|
||||||
|
if e.Len != nil {
|
||||||
|
return nil, fmt.Errorf("use slices, not fixed arrays")
|
||||||
|
}
|
||||||
|
t, err := g.expression(e.Elt, depth+1, named)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reflect.SliceOf(t), nil
|
||||||
|
case *ast.MapType:
|
||||||
|
key, ok := e.Key.(*ast.Ident)
|
||||||
|
if !ok || key.Name != "string" {
|
||||||
|
return nil, fmt.Errorf("map keys must be string")
|
||||||
|
}
|
||||||
|
t, err := g.expression(e.Value, depth+1, named)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reflect.MapOf(reflect.TypeOf(""), t), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported type expression")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *responseGraph) component(name string) (*openapi.Schema, error) {
|
||||||
|
key := "DTO_" + name
|
||||||
|
ref := &openapi.Schema{Ref: "#/components/schemas/" + key}
|
||||||
|
if _, ok := g.components[key]; ok {
|
||||||
|
return ref, nil
|
||||||
|
}
|
||||||
|
// Register before following edges so both self and mutual references terminate.
|
||||||
|
g.components[key] = nil
|
||||||
|
typ := g.types[name]
|
||||||
|
var fields []reflect.StructField
|
||||||
|
for i, f := range typ.Fields {
|
||||||
|
t, err := g.expression(g.exprs[name][i], 0, func(string, int) (reflect.Type, error) { return reflect.TypeOf(struct{}{}), nil })
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tag, err := withoutExample(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fields = append(fields, reflect.StructField{Name: f.Name, Type: t, Tag: tag})
|
||||||
|
}
|
||||||
|
b, err := contract.Describe(reflect.StructOf(fields), g.scalarDefs...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", typ.Position, err)
|
||||||
|
}
|
||||||
|
s, err := b.ResponseSchema()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", typ.Position, err)
|
||||||
|
}
|
||||||
|
for i, f := range typ.Fields {
|
||||||
|
wire := strings.Split(reflect.StructTag(fieldTag(f)).Get("json"), ",")[0]
|
||||||
|
property := s.Properties[wire]
|
||||||
|
// Examples are checked against a finite projection of their actual values,
|
||||||
|
// with the same contract rules and without running application decoders.
|
||||||
|
if _, ok := reflect.StructTag(fieldTag(f)).Lookup("example"); ok {
|
||||||
|
example, err := g.example(f, g.exprs[name][i])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s.%s: %w", name, f.Name, err)
|
||||||
|
}
|
||||||
|
property.Example = example
|
||||||
|
}
|
||||||
|
property, err = g.link(g.exprs[name][i], property)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.Properties[wire] = property
|
||||||
|
}
|
||||||
|
g.components[key] = s
|
||||||
|
return ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *responseGraph) link(e ast.Expr, s *openapi.Schema) (*openapi.Schema, error) {
|
||||||
|
switch e := e.(type) {
|
||||||
|
case *ast.Ident:
|
||||||
|
if g.types[e.Name].Name == "" {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
ref, err := g.component(e.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.Nullable {
|
||||||
|
ref = nullableReference(ref)
|
||||||
|
}
|
||||||
|
if s.Description != "" || s.Example != nil {
|
||||||
|
// OpenAPI 3.0 ignores siblings on Reference Objects.
|
||||||
|
ref = &openapi.Schema{AllOf: []*openapi.Schema{ref}, Description: s.Description, Example: s.Example}
|
||||||
|
}
|
||||||
|
return ref, nil
|
||||||
|
case *ast.StarExpr:
|
||||||
|
return g.link(e.X, s)
|
||||||
|
case *ast.ArrayType:
|
||||||
|
item, err := g.link(e.Elt, s.Items)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.Items = item
|
||||||
|
case *ast.MapType:
|
||||||
|
item, err := g.link(e.Value, s.AdditionalProperties)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.AdditionalProperties = item
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableReference(s *openapi.Schema) *openapi.Schema {
|
||||||
|
return &openapi.Schema{AnyOf: []*openapi.Schema{s, {Type: "object", Nullable: true, Enum: []any{nil}}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withoutExample(f Field) (reflect.StructTag, error) {
|
||||||
|
tags, err := contract.ParseTags(fieldTag(f))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
delete(tags, "example")
|
||||||
|
keys := make([]string, 0, len(tags))
|
||||||
|
for key := range tags {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
var parts []string
|
||||||
|
for _, key := range keys {
|
||||||
|
parts = append(parts, key+":"+strconv.Quote(tags[key]))
|
||||||
|
}
|
||||||
|
return reflect.StructTag(strings.Join(parts, " ")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *responseGraph) example(f Field, e ast.Expr) (any, error) {
|
||||||
|
raw := reflect.StructTag(fieldTag(f)).Get("example")
|
||||||
|
var value any
|
||||||
|
// Plain string examples intentionally are not JSON literals.
|
||||||
|
if f.Type == "string" || f.Type == "*string" {
|
||||||
|
value = raw
|
||||||
|
} else {
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||||
|
decoder.UseNumber()
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid example JSON: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
budget := 10000
|
||||||
|
t, err := g.projectExample(e, []any{value}, 0, &budget)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, err := contract.Describe(reflect.StructOf([]reflect.StructField{{Name: f.Name, Type: t, Tag: reflect.StructTag(fieldTag(f))}}), g.scalarDefs...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s, err := b.ResponseSchema()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
wire := strings.Split(reflect.StructTag(fieldTag(f)).Get("json"), ",")[0]
|
||||||
|
return s.Properties[wire].Example, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only materialize named objects where the finite example has a non-null value.
|
||||||
|
// Empty slices/maps and null pointers stop expansion. Unioning collection values
|
||||||
|
// checks every item using one reflection type, including missing/unknown fields.
|
||||||
|
func (g *responseGraph) projectExample(e ast.Expr, values []any, depth int, budget *int) (reflect.Type, error) {
|
||||||
|
*budget--
|
||||||
|
if depth > 32 || *budget < 0 {
|
||||||
|
return nil, fmt.Errorf("example type expansion exceeds 32 levels or 10000 nodes")
|
||||||
|
}
|
||||||
|
var present []any
|
||||||
|
for _, v := range values {
|
||||||
|
if v != nil {
|
||||||
|
present = append(present, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch e := e.(type) {
|
||||||
|
case *ast.Ident:
|
||||||
|
return g.expression(e, 0, func(name string, _ int) (reflect.Type, error) {
|
||||||
|
if len(present) == 0 {
|
||||||
|
return reflect.TypeOf(struct{}{}), nil
|
||||||
|
}
|
||||||
|
var fields []reflect.StructField
|
||||||
|
for i, f := range g.types[name].Fields {
|
||||||
|
wire := strings.Split(reflect.StructTag(fieldTag(f)).Get("json"), ",")[0]
|
||||||
|
var nested []any
|
||||||
|
for _, v := range present {
|
||||||
|
if obj, ok := v.(map[string]any); ok {
|
||||||
|
if child, found := obj[wire]; found {
|
||||||
|
nested = append(nested, child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t, err := g.projectExample(g.exprs[name][i], nested, depth+1, budget)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tag, err := withoutExample(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fields = append(fields, reflect.StructField{Name: f.Name, Type: t, Tag: tag})
|
||||||
|
}
|
||||||
|
return reflect.StructOf(fields), nil
|
||||||
|
})
|
||||||
|
case *ast.StarExpr:
|
||||||
|
t, err := g.projectExample(e.X, present, depth+1, budget)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reflect.PointerTo(t), nil
|
||||||
|
case *ast.ArrayType:
|
||||||
|
var nested []any
|
||||||
|
for _, v := range present {
|
||||||
|
if list, ok := v.([]any); ok {
|
||||||
|
nested = append(nested, list...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t, err := g.projectExample(e.Elt, nested, depth+1, budget)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reflect.SliceOf(t), nil
|
||||||
|
case *ast.MapType:
|
||||||
|
var nested []any
|
||||||
|
for _, v := range present {
|
||||||
|
if obj, ok := v.(map[string]any); ok {
|
||||||
|
for _, v := range obj {
|
||||||
|
nested = append(nested, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t, err := g.projectExample(e.Value, nested, depth+1, budget)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reflect.MapOf(reflect.TypeOf(""), t), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unsupported example type")
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ func responseGoType(value string) string {
|
|||||||
}
|
}
|
||||||
return "*types." + name
|
return "*types." + name
|
||||||
}
|
}
|
||||||
func responseDataSchema(value string, bindings map[string]*contract.Binder) (*openapi.Schema, error) {
|
func responseDataSchema(value string, bindings map[string]*contract.Binder, graph *responseGraph) (*openapi.Schema, error) {
|
||||||
kind, name := responseShape(value)
|
kind, name := responseShape(value)
|
||||||
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
|
||||||
@@ -56,11 +56,15 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
|
|||||||
}
|
}
|
||||||
return s.Properties["value"], nil
|
return s.Properties["value"], nil
|
||||||
}
|
}
|
||||||
b, ok := bindings[name]
|
var s *openapi.Schema
|
||||||
if !ok {
|
var err error
|
||||||
|
if graph.recursive[name] {
|
||||||
|
s, err = graph.component(name)
|
||||||
|
} else if b := bindings[name]; b != nil {
|
||||||
|
s, err = b.ResponseSchema()
|
||||||
|
} else {
|
||||||
return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, []string/[]int64/[]bool/[]any, *Name or null", name)
|
return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, []string/[]int64/[]bool/[]any, *Name or null", name)
|
||||||
}
|
}
|
||||||
s, err := b.ResponseSchema()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -68,6 +72,9 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
|
|||||||
case "array":
|
case "array":
|
||||||
return &openapi.Schema{Type: "array", Items: s}, nil
|
return &openapi.Schema{Type: "array", Items: s}, nil
|
||||||
case "nullable":
|
case "nullable":
|
||||||
|
if s.Ref != "" {
|
||||||
|
return nullableReference(s), nil
|
||||||
|
}
|
||||||
s.Nullable = true
|
s.Nullable = true
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
|
|||||||
77
cmd/go-omni/apigen/testdata/recursive/consumer_test.go
vendored
Normal file
77
cmd/go-omni/apigen/testdata/recursive/consumer_test.go
vendored
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"example.com/recursive/internal/handler"
|
||||||
|
"example.com/recursive/internal/svc"
|
||||||
|
"example.com/recursive/internal/types"
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecursiveHTTP(t *testing.T) {
|
||||||
|
app := omni.New()
|
||||||
|
defer app.Close()
|
||||||
|
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()
|
||||||
|
if err := handler.RegisterRoutes(app, deps); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/tree", "/forest", "/optional", "/raw", "/pair"} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal(path, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
data := w.Body.Bytes()
|
||||||
|
if path != "/raw" {
|
||||||
|
var envelope struct {
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &envelope); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data = envelope.Data
|
||||||
|
}
|
||||||
|
switch path {
|
||||||
|
case "/optional":
|
||||||
|
if string(data) != "null" {
|
||||||
|
t.Fatal(string(data))
|
||||||
|
}
|
||||||
|
case "/pair":
|
||||||
|
var pair types.Branch
|
||||||
|
if err := json.Unmarshal(data, &pair); err != nil || pair.Leaf == nil || pair.Leaf.Branch != nil {
|
||||||
|
t.Fatal(err, string(data))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
var node types.Node
|
||||||
|
if path == "/forest" {
|
||||||
|
var forest []types.Node
|
||||||
|
if err := json.Unmarshal(data, &forest); err != nil || len(forest) != 1 {
|
||||||
|
t.Fatal(err, string(data))
|
||||||
|
}
|
||||||
|
node = forest[0]
|
||||||
|
} else if err := json.Unmarshal(data, &node); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(node.Children) != 1 || len(node.Children[0].Children) != 1 || node.Children[0].Children[0].Name != "leaf" || node.Lookup["entry"].Name != "lookup" {
|
||||||
|
t.Fatal(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||||
const currentVersion = "v0.28.1-preview"
|
const currentVersion = "v0.28.2-preview"
|
||||||
const minimumVersion = "v0.28.0-preview"
|
const minimumVersion = "v0.28.0-preview"
|
||||||
const manifestPath = ".goOmni/projectManifest.json"
|
const manifestPath = ".goOmni/projectManifest.json"
|
||||||
const lockPath = ".goOmni/project.lock"
|
const lockPath = ".goOmni/project.lock"
|
||||||
|
|||||||
@@ -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.28.1-preview"
|
const defaultFrameworkVersion = "v0.28.2-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.
|
||||||
|
|||||||
@@ -28,3 +28,5 @@ overrides it. Application transport read timeouts still control slow clients.
|
|||||||
See [API.md](../API.md) for source tags, validation rules, null/presence behavior,
|
See [API.md](../API.md) for source tags, validation rules, null/presence behavior,
|
||||||
strict decoding and supported type limits. This API leaves the existing
|
strict decoding and supported type limits. This API leaves the existing
|
||||||
`Context.BindJSON/BindQuery/BindURI` behavior unchanged.
|
`Context.BindJSON/BindQuery/BindURI` behavior unchanged.
|
||||||
|
|
||||||
|
Recursive response DTOs in `.api` are compiled by the CLI as named OpenAPI references; see [the response guide](../DEVELOPMENT.md#递归响应-dto). This does not change the nonrecursive request type requirement of `New`, `ForType`, or `Describe`.
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# API 与通用工程混合工作区
|
# API 与通用工程混合工作区
|
||||||
|
|
||||||
本例只定义生成工程的类型与目录,不包含业务模型、外部服务或特定调度方式。使用 v0.28.1-preview 或更新版本 CLI。
|
本例只定义生成工程的类型与目录,不包含业务模型、外部服务或特定调度方式。使用 v0.28.2-preview 或更新版本 CLI。
|
||||||
|
|
||||||
下面从一个空目录创建两个独立 Go module;API 工程来自 API 模板,通用工程默认不监听端口。
|
下面从一个空目录创建两个独立 Go module;API 工程来自 API 模板,通用工程默认不监听端口。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.2-preview
|
||||||
mkdir mixed-demo
|
mkdir mixed-demo
|
||||||
cd mixed-demo
|
cd mixed-demo
|
||||||
go-omni api new apps/interface --module example.com/interface
|
go-omni api new apps/interface --module example.com/interface
|
||||||
|
|||||||
32
examples/recursiveResponse/README.md
Normal file
32
examples/recursiveResponse/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# 递归响应类型
|
||||||
|
|
||||||
|
通用节点、树集合和相互引用对象的响应示例。没有数据库、权限或业务服务依赖。
|
||||||
|
|
||||||
|
在 go-omni 仓库根目录执行(新建独立临时目录):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.2-preview
|
||||||
|
example_dir="$(mktemp -d)/recursive"
|
||||||
|
go-omni api generate --api examples/recursiveResponse/service.api --dir "$example_dir" --module example.com/recursive
|
||||||
|
# 以下文件仅是本次新生成示例的占位实现,替换为示例自身维护的逻辑。
|
||||||
|
for name in tree forest optional raw pair; do
|
||||||
|
printf 'package trees\n' > "$example_dir/internal/logic/trees/$name.go"
|
||||||
|
done
|
||||||
|
cp examples/recursiveResponse/operations.go.txt "$example_dir/internal/logic/trees/operations.go"
|
||||||
|
cd "$example_dir"
|
||||||
|
go mod tidy
|
||||||
|
go-omni api generate --check
|
||||||
|
go test ./...
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
按启动日志显示的监听地址访问 `/tree`、`/forest`、`/optional`、`/raw`、`/pair`。前三层节点通过 `[]types.Node` 编译和返回;`/optional` 返回 null,`/raw` 直接返回节点,`/pair` 返回相互引用类型的有限实例。`docs/openapi.json` 中的 `DTO_Node` 通过 `$ref` 引用自身,`DTO_Branch`、`DTO_Leaf` 互相引用。
|
||||||
|
|
||||||
|
嵌套集合遵循普通 Go JSON 规则,示例将叶节点的 Children 初始化为空切片,使其输出 `[]`;框架不会递归改写项目返回数据。业务数据不能包含实际指针环。
|
||||||
|
|
||||||
|
框架开发时可在首次生成命令后加 `--framework-dir .` 使用本地代码。回归同时覆盖本地与已发布远程 CLI、真实 HTTP 响应、生成幂等与只读漂移检查:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 回到 go-omni 仓库根目录执行
|
||||||
|
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -count=1 ./cmd/go-omni/apigen -run Recursive
|
||||||
|
```
|
||||||
22
examples/recursiveResponse/operations.go.txt
Normal file
22
examples/recursiveResponse/operations.go.txt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package trees
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"example.com/recursive/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sample() types.Node {
|
||||||
|
return types.Node{Name: "root", Children: []types.Node{
|
||||||
|
{Name: "child", Children: []types.Node{
|
||||||
|
{Name: "leaf", Children: []types.Node{}},
|
||||||
|
}},
|
||||||
|
}, Lookup: map[string]types.Node{"entry": {Name: "lookup", Children: []types.Node{}}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Logic) Tree(context.Context) (*types.Node, error) { node := sample(); return &node, nil }
|
||||||
|
func (s *Logic) Forest(context.Context) ([]types.Node, error) { return []types.Node{sample()}, nil }
|
||||||
|
func (s *Logic) Optional(context.Context) (*types.Node, error) { return nil, nil }
|
||||||
|
func (s *Logic) Raw(context.Context) (*types.Node, error) { node := sample(); return &node, nil }
|
||||||
|
func (s *Logic) Pair(context.Context) (*types.Branch, error) {
|
||||||
|
return &types.Branch{Leaf: &types.Leaf{}}, nil
|
||||||
|
}
|
||||||
33
examples/recursiveResponse/service.api
Normal file
33
examples/recursiveResponse/service.api
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
type Node {
|
||||||
|
Name string `json:"name" binding:"min=1"`
|
||||||
|
Children []Node `json:"children" binding:"max=100"`
|
||||||
|
Next *Node `json:"next,omitempty"`
|
||||||
|
Lookup map[string]Node `json:"lookup,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Branch {
|
||||||
|
Leaf *Leaf `json:"leaf"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Leaf {
|
||||||
|
Branch *Branch `json:"branch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
@server (group: trees)
|
||||||
|
service recursive-example {
|
||||||
|
@handler tree
|
||||||
|
get /tree returns (Node)
|
||||||
|
|
||||||
|
@handler forest
|
||||||
|
get /forest returns ([]Node)
|
||||||
|
|
||||||
|
@handler optional
|
||||||
|
get /optional returns (*Node)
|
||||||
|
|
||||||
|
@handler raw
|
||||||
|
@response raw
|
||||||
|
get /raw returns (Node)
|
||||||
|
|
||||||
|
@handler pair
|
||||||
|
get /pair returns (Branch)
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Schema struct {
|
type Schema struct {
|
||||||
|
Ref string `json:"$ref,omitempty"`
|
||||||
|
AllOf []*Schema `json:"allOf,omitempty"`
|
||||||
MaxBytes *int64 `json:"x-omni-max-bytes,omitempty"`
|
MaxBytes *int64 `json:"x-omni-max-bytes,omitempty"`
|
||||||
Example any `json:"example,omitempty"`
|
Example any `json:"example,omitempty"`
|
||||||
EnumDescriptions []string `json:"x-enum-descriptions,omitempty"`
|
EnumDescriptions []string `json:"x-enum-descriptions,omitempty"`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package openapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ func TestSchemaAdditionalPropertiesAndExactNumbers(t *testing.T) {
|
|||||||
`{"type":"object","additionalProperties":true}`,
|
`{"type":"object","additionalProperties":true}`,
|
||||||
`{"type":"object","additionalProperties":{"type":"string"}}`,
|
`{"type":"object","additionalProperties":{"type":"string"}}`,
|
||||||
`{"type":"integer","default":18446744073709551615,"enum":[18446744073709551615]}`,
|
`{"type":"integer","default":18446744073709551615,"enum":[18446744073709551615]}`,
|
||||||
|
`{"$ref":"#/components/schemas/Node"}`,
|
||||||
|
`{"description":"Node","allOf":[{"anyOf":[{"$ref":"#/components/schemas/Node"},{"type":"object","nullable":true,"enum":[null]}]}]}`,
|
||||||
} {
|
} {
|
||||||
var s Schema
|
var s Schema
|
||||||
if err := json.Unmarshal([]byte(text), &s); err != nil {
|
if err := json.Unmarshal([]byte(text), &s); err != nil {
|
||||||
@@ -30,6 +33,9 @@ func TestSchemaAdditionalPropertiesAndExactNumbers(t *testing.T) {
|
|||||||
if err = json.Unmarshal(b, &roundTrip); err != nil {
|
if err = json.Unmarshal(b, &roundTrip); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if !reflect.DeepEqual(s, roundTrip) {
|
||||||
|
t.Fatalf("schema round trip changed: %s", b)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
api, _ := New("Exact numbers", "1")
|
api, _ := New("Exact numbers", "1")
|
||||||
s := &Schema{Type: "integer", Enum: []any{json.Number("18446744073709551615")}}
|
s := &Schema{Type: "integer", Enum: []any{json.Number("18446744073709551615")}}
|
||||||
|
|||||||
26
reports/recursiveResponseAcceptance.md
Normal file
26
reports/recursiveResponseAcceptance.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# v0.28.2 递归响应验收
|
||||||
|
|
||||||
|
范围:`.api` 响应 DTO 的自引用与相互引用,使用通用 Node/Branch/Leaf;不加入接入项目的业务字段或业务行为。非递归响应保持原生成路径,递归请求与非法值循环仍拒绝。
|
||||||
|
|
||||||
|
## 本地回归
|
||||||
|
|
||||||
|
2026-09-30,macOS arm64:
|
||||||
|
|
||||||
|
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -count=1 ./...`:56 个测试包通过,apigen 集成包 112.675 秒。
|
||||||
|
- `GOTOOLCHAIN=go1.22.12 GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -count=1 ./...`:56 个测试包通过,apigen 集成包 91.536 秒。
|
||||||
|
- 新增生成文档断言后,重新运行专项 `go test -race -count=1 ./cmd/go-omni/apigen -run Recursive`(开启集成):通过,4.318 秒。
|
||||||
|
- `go vet ./...`、`go build ./...`、`git diff --check`:通过。
|
||||||
|
- 示例通过实际 CLI 导出后,`@apidevtools/swagger-cli@4.0.4 validate` 确认完整 OpenAPI 3.0.3 文档有效。
|
||||||
|
|
||||||
|
## 覆盖内容
|
||||||
|
|
||||||
|
- 强类型三层树、递归 slice/map/pointer、可空对象、raw、数组根响应以及相互引用的有限值,生成消费者真实 HTTP 返回和 Go JSON 解码。
|
||||||
|
- `$ref` 目标完整、没有被忽略的引用同级属性、可空引用的 null 分支、循环引用不展开、文档压缩幂等。
|
||||||
|
- 跨源文件引用、声明顺序无关、直接值与引用混合的合法类型、scalar 输出 schema、字段约束、描述、精确 int64 示例。
|
||||||
|
- 有限递归示例、空集合、缺失/未知字段、深层非法值、异构数组错误、非法 null 与额外 JSON 值。
|
||||||
|
- 非法直接值循环、相互值循环、字段组合循环、递归请求及间接递归请求、未知类型、非法标签、重复字段、上传字段、双指针、固定数组和错误 map 键。
|
||||||
|
- 反复生成、手写代码保留、只读检查、递归字段变更诊断;请求绑定与原有生成消费者继续全量回归。
|
||||||
|
|
||||||
|
## 发布验收
|
||||||
|
|
||||||
|
远程验收使用 `scripts/verifyManagedRelease.py`;新增递归消费者与其他消费者一起用空模块缓存安装固定版本 CLI,并核对 module checksum 和无本地 replace。通用工程与混合工作区分别运行其原有发布验收脚本。结果在发布后记录。
|
||||||
@@ -124,7 +124,7 @@ def main():
|
|||||||
checks += ["sharedGoCatalogExport", "documentOnlyErrorSelection", "workspaceCheckNoWrites", "staleCatalogRejected", "twoIndependentServiceBuilds"]
|
checks += ["sharedGoCatalogExport", "documentOnlyErrorSelection", "workspaceCheckNoWrites", "staleCatalogRejected", "twoIndependentServiceBuilds"]
|
||||||
|
|
||||||
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version)
|
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version)
|
||||||
tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer", "TestCopyxThreeEndpointConsumer", "TestGeneratedConfigurationConsumer", "TestGeneratedCORSConsumer", "TestI18nGeneratedConsumer"]
|
tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer", "TestCopyxThreeEndpointConsumer", "TestGeneratedConfigurationConsumer", "TestGeneratedCORSConsumer", "TestI18nGeneratedConsumer", "TestRecursiveResponseConsumer"]
|
||||||
print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True)
|
print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True)
|
||||||
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", "./cmd/go-omni/apigen", "-run", "^(" + "|".join(tests) + ")$"], ROOT, env)
|
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", "./cmd/go-omni/apigen", "-run", "^(" + "|".join(tests) + ")$"], ROOT, env)
|
||||||
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
||||||
@@ -135,6 +135,7 @@ def main():
|
|||||||
assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch"
|
assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch"
|
||||||
checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "primitiveAndDynamicArrays", "strictRequestPolicies", "declarativeMultipartBinding", "backgroundStopDrainResourceOrder", "backgroundInitAndListenFailureCleanup", "backgroundTimeoutRetainsResourcesAndExits"]
|
checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "primitiveAndDynamicArrays", "strictRequestPolicies", "declarativeMultipartBinding", "backgroundStopDrainResourceOrder", "backgroundInitAndListenFailureCleanup", "backgroundTimeoutRetainsResourcesAndExits"]
|
||||||
checks += ["generatedIncompleteHTTPDrainRetainsDependenciesAndLogger"]
|
checks += ["generatedIncompleteHTTPDrainRetainsDependenciesAndLogger"]
|
||||||
|
checks += ["recursiveResponseTypedHTTP", "recursiveResponseReferenceGraph", "recursiveResponseRegenerationAndDrift"]
|
||||||
|
|
||||||
# Run tests from the downloaded module, not the framework checkout.
|
# Run tests from the downloaded module, not the framework checkout.
|
||||||
http_output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/httpc"], starter, env)
|
http_output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/httpc"], starter, env)
|
||||||
|
|||||||
Reference in New Issue
Block a user