feat: release managed service generation and contract diagnostics
This commit is contained in:
46
API.md
46
API.md
@@ -1,5 +1,7 @@
|
|||||||
# API 契约与项目生成
|
# API 契约与项目生成
|
||||||
|
|
||||||
|
当前生成目录和启动扩展采用新布局,见 [项目维护指南](PROJECT.md)。`@app(defaultResponse: Name)` 在入口选择 App 默认响应。`@body(mode: raw|multipart)` 和 `@produces` 支持自动 Handler,手写 Handler 仅为显式选择的扩展。
|
||||||
|
|
||||||
首次接入请先看 [README](README.md):包含固定远程版本安装、可运行项目、完整 `.api` 示例和已有服务接入步骤。本页用于查阅语法与生成规则;下方带 `--framework-dir` 的命令用于框架源码开发。
|
首次接入请先看 [README](README.md):包含固定远程版本安装、可运行项目、完整 `.api` 示例和已有服务接入步骤。本页用于查阅语法与生成规则;下方带 `--framework-dir` 的命令用于框架源码开发。
|
||||||
|
|
||||||
本阶段目标:从 `.api` 定义生成可运行的 Go 项目;定义、绑定、校验与 OpenAPI 保持一致;重复生成不覆盖业务实现;业务代码继续独立于网络层。实现入口是 `cmd/go-omni/apigen`,运行时契约编译器是 `contract`。
|
本阶段目标:从 `.api` 定义生成可运行的 Go 项目;定义、绑定、校验与 OpenAPI 保持一致;重复生成不覆盖业务实现;业务代码继续独立于网络层。实现入口是 `cmd/go-omni/apigen`,运行时契约编译器是 `contract`。
|
||||||
@@ -15,8 +17,8 @@
|
|||||||
| 熟悉的命令别名 | `api go -api user.api -dir .` |
|
| 熟悉的命令别名 | `api go -api user.api -dir .` |
|
||||||
| 校验契约 | `api validate --api user.api` |
|
| 校验契约 | `api validate --api user.api` |
|
||||||
| CI 检查生成产物是否过期 | `api generate --api user.api --dir . --check` |
|
| CI 检查生成产物是否过期 | `api generate --api user.api --dir . --check` |
|
||||||
| 业务逻辑目录 | `internal/service/<group>`,沿用 Omni 的 Service 分层 |
|
| 业务逻辑目录 | `internal/logic/<group>`,使用标准 context.Context |
|
||||||
| 依赖装配 | `internal/servicecontext` |
|
| 依赖装配 | `internal/svc` |
|
||||||
| 接口文档 | 自动生成 `docs/openapi.json`,同时提供 `/openapi.json` |
|
| 接口文档 | 自动生成 `docs/openapi.json`,同时提供 `/openapi.json` |
|
||||||
|
|
||||||
这是明确限定的 go-zero 风格方言,**不是完整 goctl 兼容实现**。不支持的声明和校验标签会在生成前报错。
|
这是明确限定的 go-zero 风格方言,**不是完整 goctl 兼容实现**。不支持的声明和校验标签会在生成前报错。
|
||||||
@@ -31,10 +33,10 @@ go run ./cmd/go-omni api new \
|
|||||||
|
|
||||||
cd ../omniDemo
|
cd ../omniDemo
|
||||||
go mod tidy
|
go mod tidy
|
||||||
go run ./cmd/server
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
访问 `http://127.0.0.1:8080/api/greet/Alice` 可得到带 `data.message` 的统一响应。`api new` 的问候示例包含可运行逻辑;自定义契约新生成的 Service 会明确返回 501,等待业务实现。
|
访问 `http://127.0.0.1:8080/api/greet/Alice` 可得到带 `data.message` 的统一响应。`api new` 的问候示例包含可运行逻辑;自定义契约新生成的 Logic 会明确返回 501,等待业务实现。
|
||||||
|
|
||||||
用现成的多分组定义生成项目,在 go-omni 仓库执行:
|
用现成的多分组定义生成项目,在 go-omni 仓库执行:
|
||||||
|
|
||||||
@@ -57,11 +59,11 @@ go-omni new --api user.api --module example.com/userService \
|
|||||||
- `--storage none|local|s3|r2|oss`:默认 none,生成统一 Files / Uploads / Downloads / UploadLinks 依赖;本地签名对象端点为 `/storage/object`。
|
- `--storage none|local|s3|r2|oss`:默认 none,生成统一 Files / Uploads / Downloads / UploadLinks 依赖;本地签名对象端点为 `/storage/object`。
|
||||||
- `--log-output stdout|stderr|file` 与 `--log-format json|text`:配置和无配置文件启动使用一致默认值。
|
- `--log-output stdout|stderr|file` 与 `--log-format json|text`:配置和无配置文件启动使用一致默认值。
|
||||||
|
|
||||||
初次选项记录在 `.goOmni/apiManifest.json`,以后直接 `api generate` 会继承。已有项目通过参数切换选项会报错,因为初始化代码属于业务方;可在新目录生成对照后手动迁移。`--force` 不允许覆盖 API 项目。旧式 CRUD 脚手架仍保留。
|
初次选项记录在 `.goOmni/apiManifest.json`,以后直接 `api generate` 会继承。已有项目通过参数切换选项会报错,因为初始化代码属于业务方;可在新目录生成对照后手动迁移。`--force` 不允许覆盖 API 项目。new 与 api new 统一生成契约项目,不再使用旧式 CRUD 目录。
|
||||||
|
|
||||||
MySQL 密钥只从 `GO_OMNI_DB_DSN` 获取,非秘密池配置位于 YAML,环境变量优先。存储使用原有 `GO_OMNI_STORAGE_*`;API 项目不会额外生成公开的签名发放接口,鉴权和对象权限由业务实现。初始化失败会清理已打开资源,退出清理可重复调用。
|
MySQL 密钥只从 `GO_OMNI_DB_DSN` 获取,非秘密池配置位于 YAML,环境变量优先。存储使用原有 `GO_OMNI_STORAGE_*`;API 项目不会额外生成公开的签名发放接口,鉴权和对象权限由业务实现。初始化失败会清理已打开资源,退出清理可重复调用。
|
||||||
|
|
||||||
HTTP 请求类型不能推导数据库表结构;迁移和 Repository 由业务自己维护。完整案例见 [用户管理示例](examples/userService/README.md),覆盖真实 MySQL、事务审计、鉴权、文件导出和日志。
|
HTTP 请求类型不能推导数据库表结构;迁移和 Repository 由业务自己维护。当前目录的完整示例见 [managedService](examples/managedService/README.md)。MySQL、事务审计和鉴权实现可参考 [旧目录用户管理示例](examples/userService/README.md),接入时按 [项目指南](PROJECT.md) 装配到 config/svc/logic。
|
||||||
|
|
||||||
## 接口定义
|
## 接口定义
|
||||||
|
|
||||||
@@ -238,27 +240,29 @@ service user-api {
|
|||||||
```sh
|
```sh
|
||||||
go-omni api validate # 发现 manifest 中的入口并校验
|
go-omni api validate # 发现 manifest 中的入口并校验
|
||||||
go-omni api inspect # 方法、路径、Handler、鉴权与业务错误总览
|
go-omni api inspect # 方法、路径、Handler、鉴权与业务错误总览
|
||||||
go-omni api generate --dry-run # 预览新增/更新/移除,完全不写文件
|
go-omni api generate --dry-run # 预览文件变化、契约变更和 Logic 提示,完全不写文件
|
||||||
go-omni api generate # 实际生成,保留手写业务
|
go-omni api generate # 实际生成,保留手写业务
|
||||||
go-omni api generate --check # 只读检查,可用于 CI
|
go-omni api generate --check # 只读检查产物漂移及明确的 Logic 签名错误,可用于 CI
|
||||||
go-omni api export > openapi.json # 独立导出纯 JSON,无需启动服务
|
go-omni api export > openapi.json # 独立导出纯 JSON,无需启动服务
|
||||||
go test ./...
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
`--dry-run` 和 `--check` 不能组合。新项目预览也不创建目录;预览不是审批或锁定,实际生成会重新检查文件状态。
|
`--dry-run` 和 `--check` 不能组合。新项目预览也不创建目录;预览不是审批或锁定,实际生成会重新检查文件状态。
|
||||||
|
契约变化会定位到相关 Logic 文件,涵盖嵌套 DTO、原始 body 和流式返回。生成后签名不一致的问题仍可由 `--check` 检出;项目类型别名/build tags 等不确定情况提示 REVIEW,最终用 `go test ./...` 验证。详见 [契约变更提示](PROJECT.md#契约变更提示)。
|
||||||
在项目外可显式传 `--api`;生成时传 `--dir` 可定位其他项目。不会猜测多个 .api 中哪个是入口。
|
在项目外可显式传 `--api`;生成时传 `--dir` 可定位其他项目。不会猜测多个 .api 中哪个是入口。
|
||||||
路由总览的 `(undeclared)` 仅表示没有声明 auth;旧式中间件仍可能做认证。
|
路由总览的 `(undeclared)` 仅表示没有声明 auth;旧式中间件仍可能做认证。
|
||||||
外发文档可用 export;项目内受生成器管理的 `docs/openapi.json` 请通过 generate 更新,以同步文件哈希。
|
外发文档可用 export;项目内受生成器管理的 `docs/openapi.json` 请通过 generate 更新,以同步文件哈希。
|
||||||
|
|
||||||
## 迁移扩展(v0.9.0-preview)
|
## 迁移扩展
|
||||||
|
|
||||||
- 动态 JSON 字段 `any` / `interface{}`,包含切片和 map;外围契约仍严格。
|
- 动态 JSON 字段 `any` / `interface{}`,包含切片和 map;外围契约仍严格。
|
||||||
- `scalar Name (...)` 显式引用项目类型,声明 JSON 输入/输出类型及文本绑定;支持已有 JSON/Text 转换方法。
|
- `scalar Name (...)` 显式引用项目类型,声明 JSON 输入/输出类型及文本绑定;支持已有 JSON/Text 转换方法。
|
||||||
- `@body (mode: json/raw/multipart; maxBytes: ...)`:按路由限制请求体。
|
- `@body (mode: json/raw/multipart; maxBytes: ...)`:按路由限制请求体。
|
||||||
- `@binding custom` 生成保留的绑定 hook;`@handlerMode manual` 生成保留的整个 Handler,raw/multipart 使用这个入口。
|
- `@binding custom` 生成保留的绑定 hook;`@handlerMode manual` 生成保留的整个 Handler,仅用于自定义协议。raw/multipart 默认生成自动 Handler,将受限流交给 Logic。
|
||||||
- manual 可用 `@produces "text/plain"` 等声明无 DTO 成功输出。默认状态 200,初始手写 stub 返回 501。
|
- `@produces "text/plain"` 等声明无 DTO 成功输出;自动模式的 Logic 返回 `*httpx.Stream`,Handler 负责流式发送和关闭。默认状态 200。manual 模式保留项目自己的输出实现。
|
||||||
|
- 自动 multipart Handler 检查未读部分和结束边界;损坏/截断默认返回 400,超限为 413。公共状态、码和文案仍可用项目错误目录配置,见 [流式接入与错误处理](PROJECT.md#自动上传回调与下载)。
|
||||||
|
|
||||||
完整语法、边界和示例见 [迁移扩展指南](MIGRATION.md)。新增生成代码要求 CLI 与框架依赖一起升级到 `v0.9.0-preview`;不兼容全部 goctl 语法。
|
迁移扩展起始于 v0.9.0,原有手写扩展说明见 [迁移扩展指南](MIGRATION.md)。当前根目录入口和自动流式 Handler 要求 CLI 与框架依赖一起使用 v0.12.0-preview;版本说明和新目录维护规则见 [项目指南](PROJECT.md)。不兼容全部 goctl 语法。
|
||||||
|
|
||||||
## 参数语义
|
## 参数语义
|
||||||
|
|
||||||
@@ -274,17 +278,17 @@ JSON 必须是一个 object,Content-Type 为 application/json。默认最多 1
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
api/ # 开发者维护的契约及导入
|
api/ # 开发者维护的契约及导入
|
||||||
cmd/server/main.go # 启动、日志、优雅退出
|
main.go # 启动、日志、优雅退出
|
||||||
etc/config.yaml
|
etc/config.yaml
|
||||||
internal/settings/
|
internal/config/
|
||||||
internal/servicecontext/ # 依赖初始化和释放
|
internal/svc/ # 依赖初始化和释放
|
||||||
internal/types/typesGen.go
|
internal/types/typesGen.go
|
||||||
internal/apierrors/errorsGen.go # 声明错误后生成
|
internal/apierrors/errorsGen.go # 声明错误后生成
|
||||||
internal/handler/routesGen.go
|
internal/handler/routesGen.go
|
||||||
internal/handler/openapiGen.go
|
internal/handler/openapiGen.go
|
||||||
internal/handler/<group>/handlersGen.go
|
internal/handler/<group>/handlersGen.go
|
||||||
internal/service/<group>/service.go
|
internal/logic/<group>/logic.go
|
||||||
internal/service/<group>/<operation>.go
|
internal/logic/<group>/<operation>.go
|
||||||
internal/repository/<group>/repository.go
|
internal/repository/<group>/repository.go
|
||||||
internal/middleware/registryGen.go
|
internal/middleware/registryGen.go
|
||||||
internal/middleware/<name>.go
|
internal/middleware/<name>.go
|
||||||
@@ -292,7 +296,7 @@ docs/openapi.json
|
|||||||
.goOmni/apiManifest.json
|
.goOmni/apiManifest.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Service 方法接收标准 `context.Context` 与生成的请求 DTO;Handler 调用 Service 并完成响应包装。网络适配器仍经 Omni/httpx 接入。Repository 是业务所有的扩展位置,不自动生成虚构 CRUD 查询。
|
Logic 方法接收标准 `context.Context` 与生成的请求 DTO;Handler 调用 Logic 并完成响应包装。网络适配器仍经 Omni/httpx 接入。Repository 是业务所有的扩展位置,不自动生成虚构 CRUD 查询。
|
||||||
|
|
||||||
命名中间件按声明顺序组合,第一个在最外层。新 hook 初始返回 501,必须实现后才放行;registry 自动维护,hook 文件只创建一次。缺失 hook 在注册任何路由前报错。文档通过 `x-omni-middleware` 标出命名 hook,不把名称误当成已实现的认证协议。
|
命名中间件按声明顺序组合,第一个在最外层。新 hook 初始返回 501,必须实现后才放行;registry 自动维护,hook 文件只创建一次。缺失 hook 在注册任何路由前报错。文档通过 `x-omni-middleware` 标出命名 hook,不把名称误当成已实现的认证协议。
|
||||||
|
|
||||||
@@ -307,7 +311,7 @@ go-omni api generate --api api/user.api --dir . --check
|
|||||||
go test ./...
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
`Gen.go` 和 OpenAPI 由生成器管理,manifest 保存其 SHA-256。手动修改后更新会报冲突。Service、Repository、main、配置、依赖装配和中间件实现按字节保留;只补缺失文件。删除接口/分组时只移除旧的生成文件,保留业务代码;已删除类型仍被旧业务引用时,需要明确迁移该业务文件,编译器会提示。
|
`main.go`、`Gen.go` 和 OpenAPI 由生成器管理,manifest 保存其 SHA-256。手动修改后更新会报冲突。Logic、Repository、配置、依赖装配和中间件实现按字节保留;只补缺失文件。删除接口/分组时只移除旧的生成文件,保留业务代码;已删除类型仍被旧业务引用时,需要明确迁移该业务文件,编译器会提示。
|
||||||
|
|
||||||
`--check` 只读,检查产物是否过期,以及所需骨架是否缺失。契约复制到项目后,请编辑项目内的 API;外部定义与已存在副本不一致时会拒绝覆盖。一个项目由一个 API 入口管理,可以通过导入拆分文件。
|
`--check` 只读,检查产物是否过期,以及所需骨架是否缺失。契约复制到项目后,请编辑项目内的 API;外部定义与已存在副本不一致时会拒绝覆盖。一个项目由一个 API 入口管理,可以通过导入拆分文件。
|
||||||
|
|
||||||
@@ -319,7 +323,7 @@ go test ./...
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test -race -p=2 ./contract ./openapi ./cmd/go-omni/...
|
go test -race -p=2 ./contract ./openapi ./cmd/go-omni/...
|
||||||
python3 scripts/verifyAPI.py
|
python3 scripts/verifyManagedProject.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Go 测试覆盖契约拒绝规则、多源绑定、零值/缺失、默认/范围/嵌套集合、文档约束、幂等更新、只读检查、业务保留、删除路由、路径边界及生成项目编译。Python 验收会在临时目录生成项目,分别启动 nethttp/native,验证真实 HTTP 请求、中间件、日志 requestId 和优雅退出;结束后清理自己创建的进程和临时文件。结果记录在 `performance/apiGenerationLocal.json`。
|
Go 测试覆盖契约拒绝规则、多源绑定、零值/缺失、默认/范围/嵌套集合、文档约束、幂等更新、只读检查、业务保留、删除路由、路径边界及生成项目编译。Python 验收会在临时目录生成项目,分别启动 nethttp/native,验证真实 HTTP 请求、中间件、日志 requestId 和优雅退出;结束后清理自己创建的进程和临时文件。结果记录在 `performance/managedProjectLocal.json`;完整记录见 [第一版验收](performance/managedProjectAcceptance.md)。
|
||||||
|
|||||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.12.0-preview(2026-09-24)
|
||||||
|
|
||||||
|
- 生成项目统一采用根目录 main.go 和 config/svc/logic/handler;new 与 api new 共用受管理生成器,manifest v2 不兼容旧目录。
|
||||||
|
- main 和 HTTP 装配持续生成;svc 提供 AppOptions、GlobalMiddleware、OnClose 和幂等 Close,初始化失败回收资源,正常退出先排空 HTTP 再关闭项目依赖及日志。
|
||||||
|
- 入口 @app(defaultResponse: Name) 自动接入 App 默认响应;生成路由、全局中间件及 404/405 共用策略,Go 错误目录保持单处注册。
|
||||||
|
- raw/multipart 输入和 @produces 流式输出支持自动 Handler,Logic 接收受限请求流/返回可关闭内容流,保留显式手写扩展。
|
||||||
|
- 新增真实生成项目生命周期、流式输入输出、文件保留和入口漂移检查,提供可运行 managedService 示例与 PROJECT.md。
|
||||||
|
- multipart 流式读取保留解析错误,检查未消费部分与最终边界;损坏请求默认 400,超限保持 413,错误目录可定制公开响应。配置校验与实际 App 响应编码器/错误目录装配使用同一规则,冲突在构造前报错。
|
||||||
|
- manifest 保存契约快照;生成/预览/检查提供接口类型、嵌套 DTO、scalar、body/流式模式变更提示,定位受影响的 Logic。轻量源码检查识别签名、语法和重复方法问题;--check 在重新生成后仍拒绝明确的业务签名错误,保留所有手写文件,类型别名/build tags 等不确定情况提示人工复查。
|
||||||
|
|
||||||
|
|
||||||
## v0.11.1-preview
|
## v0.11.1-preview
|
||||||
|
|
||||||
- 修复生成路由用空错误目录遮蔽 App 目录的问题;空 WithErrors 完全透传,避免 Context 与 Encoder 二次解析时丢失状态、业务码和中文提示。
|
- 修复生成路由用空错误目录遮蔽 App 目录的问题;空 WithErrors 完全透传,避免 Context 与 Encoder 二次解析时丢失状态、业务码和中文提示。
|
||||||
|
|||||||
178
PROJECT.md
Normal file
178
PROJECT.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# 生成项目与文件维护边界
|
||||||
|
|
||||||
|
v0.12.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||||
|
|
||||||
|
## 创建和日常开发
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 在存放业务项目的目录执行,目标目录必须为空。
|
||||||
|
go-omni new --module example.com/hello ./hello
|
||||||
|
cd hello
|
||||||
|
go mod tidy
|
||||||
|
go run .
|
||||||
|
go build .
|
||||||
|
```
|
||||||
|
|
||||||
|
在项目目录使用同版 CLI:编辑 `api/service.api`,运行 `go-omni api generate`,填写 `internal/logic` 的业务方法。增加接口、分组或具名中间件会自动更新接入代码,无需修改入口。`--dry-run` 预览;`--check` 只读检查,包括 main、HTTP 接入和 OpenAPI。API 文件通过相对 import 拆分模块。
|
||||||
|
|
||||||
|
## 契约变更提示
|
||||||
|
|
||||||
|
生成器在 manifest 中保存上次成功生成的接口快照,比较请求/返回类型、嵌套 DTO 字段与标签、自定义 scalar、路径、body 模式/限额和流式输出类型。只提示受影响的接口,并标出业务文件;删除或移动接口会提醒整理保留的旧实现。注释、文件排版、Logic 参数名与导入别名不会被当成接口变化。
|
||||||
|
|
||||||
|
例如把 `GreetReq` 改名为 `HelloReq`,旧业务方法尚未修改时:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ERROR [logicSignature] internal/logic/greet/greet.go:12 (GET /api/greet/:name): ...
|
||||||
|
before: Greet(context.Context, *types.GreetReq) (*types.GreetResp, error)
|
||||||
|
after: Greet(context.Context, *types.HelloReq) (*types.GreetResp, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 命令 | 变更提示与检查行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| `api generate --dry-run` | 显示计划、契约变化和业务签名问题,不写文件;诊断本身不让预览失败 |
|
||||||
|
| `api generate` | 更新生成文件和契约快照,保留业务代码,并显示需要处理的诊断;生成成功不代表业务已通过编译 |
|
||||||
|
| `api generate --check` | 只读检查;生成产物过期或发现明确的 Logic 签名/语法/重复方法错误时返回非零退出码 |
|
||||||
|
| `api validate` | 只验证 `.api`,不检查项目 Go 实现 |
|
||||||
|
|
||||||
|
`REVIEW` 提醒复查业务语义;`ERROR` 表示已发现源代码问题。生成后,旧签名仍会在后续 `--check` 中报错,直到你改好;DTO 字段变化的 REVIEW 与上次生成快照比较,生成后不持续要求确认,也不会替你修改业务。业务方法移到同组其他 Go 文件时仍能定位。
|
||||||
|
|
||||||
|
这是轻量源码分析,不运行项目 init、不执行 Go 命令、不下载依赖。项目自定义类型别名、嵌入方法或 build tags 等无法可靠判断时,会给 REVIEW 并交给 Go 编译器验证;不把这些情况强行判为签名错误。它不检查方法体是否引用已删除字段,也不证明业务逻辑正确。CI 中应同时执行 `go-omni api generate --check` 和 `go test ./...`,按项目实际构建参数测试。当前版本首次接手缺少快照的 manifest v2 时,会明确提示建立基线,不猜测旧契约;Logic 签名检查仍会执行。
|
||||||
|
|
||||||
|
## 哪些文件由用户维护
|
||||||
|
|
||||||
|
| 文件 | 首次生成后由项目维护 |
|
||||||
|
| --- | --- |
|
||||||
|
| `api/*.api` | 路由、DTO、具名中间件、响应模板、请求/响应模式 |
|
||||||
|
| `etc/config.yaml` | 运行参数;密钥通过环境变量提供 |
|
||||||
|
| `internal/config/config.go` | 自定义配置字段、加载、默认值与校验 |
|
||||||
|
| `internal/svc/serviceContext.go` | 依赖字段和初始化,持有加载后的 Config |
|
||||||
|
| `internal/svc/options.go` | AppOptions、GlobalMiddleware;注册 Go 错误目录和全局扩展 |
|
||||||
|
| `internal/svc/close.go` | 资源关闭;OnClose 为自定义资源提供标准清理入口 |
|
||||||
|
| `internal/svc/storage.go` | 选用存储时生成,之后保留 |
|
||||||
|
| `internal/logic/<group>/*.go` | 业务方法,使用标准 context.Context |
|
||||||
|
| `internal/repository/<group>/*.go` | 数据访问,按需实现 |
|
||||||
|
| `internal/middleware/<name>.go` | 项目鉴权/权限等具名中间件实现 |
|
||||||
|
| 项目 Go 错误包 | 自行维护 Catalog 和常量,可跨服务共用 |
|
||||||
|
| 自定义标量文件、`*Bind.go`、`*Manual.go` | 只有显式选择相应扩展时才生成/使用 |
|
||||||
|
| 项目测试、迁移、部署脚本、go.mod/go.sum | 项目所有,生成器不重置其内容 |
|
||||||
|
|
||||||
|
以上骨架只补缺失文件,既有内容按字节保留。删除已废弃接口后,对应业务文件也不会被自动删除;由开发者整理编译器指出的失效类型或签名。
|
||||||
|
|
||||||
|
生成器持续管理:`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;手改会被拒绝覆盖,需先移走自定义内容。
|
||||||
|
|
||||||
|
Logic 实例在注册时创建并由请求并发复用;请求数据用方法参数和局部变量传递,不存入共享 Logic/svc 字段。
|
||||||
|
|
||||||
|
普通 Handler 不需要维护。目录内可以同时有生成文件和单独的手写扩展文件,所有权按文件区分。没有额外 server 包或第二层 service。
|
||||||
|
|
||||||
|
## 依赖与启动扩展
|
||||||
|
|
||||||
|
`svc.New(ctx, logger, cfg)` 初始化依赖,可返回错误;Logger 由入口创建并借用。新增客户端时只需要扩展配置和 svc:
|
||||||
|
|
||||||
|
```go
|
||||||
|
client, err := newClient(cfg.Client)
|
||||||
|
if err != nil { return nil, err }
|
||||||
|
c.Client = client
|
||||||
|
c.OnClose(client.Close) // 创建成功后立即登记,后续失败也能释放
|
||||||
|
```
|
||||||
|
|
||||||
|
`New` 的失败清理骨架已生成。OnClose 按登记的相反顺序执行;Close 幂等。项目不要重复关闭同一资源,借用的 Logger 也不应由 svc 关闭。构造器在返回错误前若创建了尚未交付的资源,该构造器自己负责释放。
|
||||||
|
|
||||||
|
错误注册写在 `internal/svc/options.go`,错误表可以来自任意项目共用包:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *ServiceContext) AppOptions() []omni.Option {
|
||||||
|
return []omni.Option{omni.WithErrors(apierrors.Catalog())}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
业务调用 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`,可接 `WithParams` / `WithCause`。无需在 `.api` 重复错误目录。未声明独立错误目录的路由继承 App 目录;独立目录不与 App 合并。静态 OpenAPI 不执行 Go,不能自动枚举仅存在于 Go 中的错误目录。
|
||||||
|
|
||||||
|
`GlobalMiddleware()` 提供全局中间件;`Middlewares` 映射提供 `.api` 声明的具名中间件。全部显式调用,不依赖项目 init 注册,也不要求 svc 导入 handler。框架请求链按原有次序运行。
|
||||||
|
|
||||||
|
生成入口执行:配置加载/校验 → 日志 → svc.New → 生成的 handler.NewApp(应用 AppOptions、响应策略、校验最终 HTTP 配置、全局中间件、全部路由)→ App.RunContext → 依赖关闭 → 日志关闭。初始化失败、接入配置失败和监听失败也清理资源。
|
||||||
|
|
||||||
|
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。
|
||||||
|
|
||||||
|
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,在 OnClose 中取消。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
|
||||||
|
|
||||||
|
## App 默认响应格式
|
||||||
|
|
||||||
|
在 **入口** `.api` 声明一次:
|
||||||
|
|
||||||
|
```text
|
||||||
|
@app (
|
||||||
|
defaultResponse: CommonResponse
|
||||||
|
)
|
||||||
|
response CommonResponse (
|
||||||
|
codeType: int32
|
||||||
|
successCode: 0
|
||||||
|
errorCode: 9999
|
||||||
|
messageField: msg
|
||||||
|
dataMode: always
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
优先级:路由 `@response` → 分组 `@server(response: ...)` → App 默认。模板可以定义在 import 文件里,`@app` 只能在入口声明。重复声明、引用不存在的模板会报错;不会按文件顺序猜默认值。未声明 `@app` 时使用框架现有默认格式。
|
||||||
|
|
||||||
|
生成的 App 普通路由、404/405、未单独指定格式的生成路由共用默认模板。已匹配路由的中间件提前报错也使用路由策略。`@response raw` 的成功 JSON 不包装,错误沿用有效模板。
|
||||||
|
|
||||||
|
显式 `.api` 默认响应优先于 Config/AppOptions 中的响应配置,保持接口文档和实际响应一致。其他 AppOptions 继续生效;要改变格式就修改 `.api`。项目无需访问 handler 私有变量。
|
||||||
|
|
||||||
|
## 自动上传、回调与下载
|
||||||
|
|
||||||
|
以下声明生成完整 Handler,用户填写 Logic:
|
||||||
|
|
||||||
|
```text
|
||||||
|
@handler upload
|
||||||
|
@body (mode: multipart; maxBytes: 33554432)
|
||||||
|
post /upload returns (UploadResp)
|
||||||
|
|
||||||
|
@handler callback
|
||||||
|
@body (mode: raw; maxBytes: 65536)
|
||||||
|
@produces "text/plain"
|
||||||
|
post /callback
|
||||||
|
|
||||||
|
@handler download
|
||||||
|
@produces "application/octet-stream"
|
||||||
|
get /download (DownloadReq)
|
||||||
|
```
|
||||||
|
|
||||||
|
- raw/multipart 的 Logic 增加 `body *httpx.Body` 参数。`body.Raw` 是受路由上限约束的原始字节流;`body.Header` 是请求头副本。
|
||||||
|
- multipart 用 `body.Parts.NextPart()` 顺序读取,关闭每个 Part;不调用 ParseMultipartForm,不产生上传临时文件,也不把整个文件放入内存。每个字段/文件的业务要求由 Logic 校验。请求 DTO 可定义 path/form/header;`form` 仍是 URL 查询参数,不能用 json DTO 重复读取上传体。
|
||||||
|
- Parts 是 `*httpx.MultipartReader`,返回的 `*httpx.MultipartPart` 提供 Read、Close、Header、FormName、FileName;NextRawPart 可读取未做 quoted-printable 解码的部分。NextPart 自动关闭上一个未读完的 Part;Close 会报告读取错误。不要混用 Parts 与 Raw 读取同一个 multipart 请求。
|
||||||
|
- multipart 缺少结束边界、头部/内容损坏默认返回 `400 / INVALID_MULTIPART`;超过路由限额仍为 413,媒体类型/缺少 boundary 为 415。框架只提供通用标识和默认提示,项目可以在 Go 错误目录中配置自己的状态、业务码和文案。内部解析原因保留在错误链,不进入公开响应;Logic 自己返回的数据库等错误不会被统一改成 400。
|
||||||
|
- raw 数据不经过 JSON 解码。签名算法、密钥及回执内容由业务提供;同一个流只消费一次。若中间件先读取验签,项目必须显式使用受限缓冲/重放,生成器不会自动缓存大文件。
|
||||||
|
- `@produces` 使 Logic 返回 `(*httpx.Stream, error)`,其中 Content 是 `io.ReadCloser`,可选 Name 生成附件下载头。ContentType 可携带 charset,但必须与契约媒体类型匹配。Handler 负责关闭内容流,无公共体包装;写出后发生错误不会追加 JSON。
|
||||||
|
- Handler 在成功响应前检查剩余请求流,包含未读完的 multipart 部分、最终边界和请求体上限;已经遇到的 multipart 读取错误不会因业务忽略它而变成成功。读取失败和 413 不能撤回 Logic 已产生的外部副作用;重要业务应先调用 `body.Complete()` 确认完整读取/验证,再提交事务或发布文件。Complete 可重复调用。
|
||||||
|
- 自动 Handler 采用先完成输入、再发送输出的顺序;不要把正在读取的 body.Raw 直接当作 Stream.Content 返回。需要同时读写请求/响应的协议时使用手写扩展。
|
||||||
|
- 大文件的耗时仍受 HTTP 读写超时约束。按接口需要配置,框架不自动无限延长。
|
||||||
|
|
||||||
|
默认请求体上限为 1 MiB,按路由设置 maxBytes,不能通过统一放大全局限制替代。`@produces` 仅声明媒体类型,不保证业务返回的内容符合该媒体的语义。Range、SSE 刷新、特殊协议等需要时仍可显式选 `@handlerMode manual`;它是可选扩展,不是普通上传下载的前置条件。
|
||||||
|
|
||||||
|
## 旧结构处理
|
||||||
|
|
||||||
|
新生成器使用 manifest v2,不兼容原 settings/servicecontext/service 与 cmd/server 布局。已有旧目录会报错,不能通过覆盖 main 自动迁移。试验代码需保留时,在新目录生成,移动 `.api`、Logic、配置和 svc 扩展并调整导入;独立的 `cmd/migrate` 等工具由项目维护。不会静默覆盖手写 main 或删除无关命令。
|
||||||
|
|
||||||
|
`go-omni new` 与 `api new` 统一使用此结构。受管理项目通过 `.api` 增加模块,旧的 `add` 手动模块装配方式不适用于该结构。直接引用 go-omni 框架包的项目不受这些目录约定约束。
|
||||||
|
|
||||||
|
## 已有服务接入步骤
|
||||||
|
|
||||||
|
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.12.0-preview:
|
||||||
|
|
||||||
|
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
||||||
|
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
||||||
|
3. 把配置放入 `internal/config` 和 `etc/config.yaml`;数据库、自定义 Connector、Redis、ID Client 等依赖放入 `internal/svc`,初始化成功后立即 `OnClose`。现有事务和迁移工具继续由项目维护;数据库扩展见 [database](database/README.md)。
|
||||||
|
4. 业务错误目录只在项目 Go 包维护,通过 `svc.AppOptions()` 返回 `omni.WithErrors(apierrors.Catalog())` 注册一次。需要继承 App 目录的路由不要重复声明独立错误目录。项目鉴权放入中间件,业务实现迁入 Logic/Repository;无需修改 main 或生成的 Handler。
|
||||||
|
5. 上传、原始请求体验签和下载优先使用自动 Handler;只有特殊协议才选择手写扩展。保留实际验签算法、流式处理、大小限制和存储权限,按 [迁移扩展](MIGRATION.md) 处理旧 Handler 与自定义标量。
|
||||||
|
6. 每个服务运行下面的检查,再用实际配置验证依赖连通、启动失败清理和正常退出。业务侧补充原有接口兼容、鉴权、事务回滚与实际上传下载回归;框架测试不能代替这些业务验收。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go mod tidy
|
||||||
|
go-omni api validate
|
||||||
|
go-omni api generate --check
|
||||||
|
go test ./...
|
||||||
|
go build .
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
后续增加接口修改 `.api` 并重新生成;增加依赖修改配置和 svc。将契约、生成文件、manifest 与业务代码一起提交。完整流程不需要项目另外维护启动器,也不需要读取 handler 的私有变量。
|
||||||
66
README.md
66
README.md
@@ -1,8 +1,10 @@
|
|||||||
# GoOmni
|
# GoOmni
|
||||||
|
|
||||||
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Service 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
||||||
|
|
||||||
**从这份 README 开始即可接入。** 当前版本为 **`v0.11.1-preview`**,Go **1.22+**;CLI 和项目依赖使用同一固定版本。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
|
**从这份 README 开始接入。** 当前版本 **`v0.12.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
|
Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
|
||||||
|
|
||||||
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。
|
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。
|
||||||
|
|
||||||
@@ -22,27 +24,26 @@ GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义
|
|||||||
|
|
||||||
module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可用,并具有仓库读取权限;使用自己的 Git 凭据管理器,不把账号或 Token 写进 module 地址。
|
module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可用,并具有仓库读取权限;使用自己的 Git 凭据管理器,不把账号或 Token 写进 module 地址。
|
||||||
|
|
||||||
以下命令在同一个终端执行。已有 `GOPRIVATE` 时,把 `git.yuntuo.hk` 追加到原来的逗号分隔列表。
|
安装固定版本的 CLI,后面的命令继续使用同一终端。私有 module 加入 GOPRIVATE,保留已有配置:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export GOPRIVATE=git.yuntuo.hk
|
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.1-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.12.0-preview
|
||||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
go-omni --help
|
go-omni --help
|
||||||
```
|
```
|
||||||
|
|
||||||
如果配置了自定义 `GOBIN`,将那个目录加入 PATH。只引用框架包、不生成项目时可以跳过 CLI 安装,直接使用下文的 `go get`。
|
如果设置过 GOBIN,将该目录加入 PATH。远程 CLI 默认引用自身版本的框架,生成的 go.mod 不需要本地 replace。开发者与 CI 使用同版 CLI;框架源码开发方式见 [CLI 文档](cmd/go-omni/README.md#版本与迁移)。
|
||||||
|
|
||||||
## 新项目:生成并运行
|
## 新项目:生成并运行
|
||||||
|
|
||||||
在准备存放业务项目的目录执行,`./hello` 必须是新目录或空目录;把 `example.com/hello` 换成自己的 Go module 名称。
|
在准备存放业务项目的目录执行,`./hello` 必须是新目录或空目录;把 `example.com/hello` 换成自己的 Go module 名称。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go-omni api new --framework-version v0.11.1-preview \
|
go-omni api new --module example.com/hello ./hello
|
||||||
--module example.com/hello ./hello
|
|
||||||
cd hello
|
cd hello
|
||||||
go mod tidy
|
go mod tidy
|
||||||
go run ./cmd/server
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
另开终端验证:
|
另开终端验证:
|
||||||
@@ -59,30 +60,30 @@ curl -sS http://127.0.0.1:8080/openapi.json
|
|||||||
{"code":"OK","message":"success","data":{"message":"Hello Alice"},"requestId":"本次请求编号"}
|
{"code":"OK","message":"success","data":{"message":"Hello Alice"},"requestId":"本次请求编号"}
|
||||||
```
|
```
|
||||||
|
|
||||||
按 Ctrl+C 停止服务。项目依赖已固定为远程版本,不需要克隆框架源码或添加本地 `replace`。
|
按 Ctrl+C 停止服务。进入项目目录即可 `go build .`;该示例不需要数据库或其他外部服务。
|
||||||
|
|
||||||
### 生成的项目放什么
|
### 生成的项目放什么
|
||||||
|
|
||||||
```text
|
```text
|
||||||
hello/
|
hello/
|
||||||
├── api/service.api # 维护接口定义,也可 import 其他 .api
|
├── api/service.api # 维护接口定义,也可 import 其他 .api
|
||||||
├── cmd/server/main.go # 启动、路由注册、优雅退出
|
├── main.go # 生成器维护:启动、路由注册、优雅退出
|
||||||
├── etc/config.yaml # 项目配置
|
├── etc/config.yaml # 项目配置
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── settings/ # 配置读取
|
│ ├── config/ # 配置读取
|
||||||
│ ├── servicecontext/ # 数据库、日志、外部客户端等依赖
|
│ ├── svc/ # 数据库、日志、外部客户端等依赖
|
||||||
│ ├── types/typesGen.go # 生成的请求与业务返回类型
|
│ ├── types/typesGen.go # 生成的请求与业务返回类型
|
||||||
│ ├── handler/ # 生成的绑定、响应策略、路由及文档
|
│ ├── handler/ # 生成的绑定、响应策略、路由及文档
|
||||||
│ ├── service/greet/ # 在这里实现业务逻辑
|
│ ├── logic/greet/ # 在这里实现业务逻辑
|
||||||
│ ├── repository/greet/ # 在这里实现数据访问
|
│ ├── repository/greet/ # 在这里实现数据访问
|
||||||
│ └── middleware/ # 声明鉴权等中间件后生成对应 hook
|
│ └── middleware/ # 声明鉴权等中间件后生成对应 hook
|
||||||
├── 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/service`,数据访问写在 `internal/repository`,依赖在 `internal/servicecontext` 装配。声明业务错误后还会生成 `internal/apierrors/errorsGen.go`。
|
修改 `api/service.api` 后重新生成,业务写在 `internal/logic`,数据访问写在 `internal/repository`,依赖在 `internal/svc` 装配。声明业务错误后还会生成 `internal/apierrors/errorsGen.go`。
|
||||||
|
|
||||||
`api new` 的问候接口可以直接运行;自定义接口新生成的 Service 和鉴权 hook 返回 **501**,需要实现后才可用。生成器不会根据 HTTP 字段猜数据库表或业务规则。
|
`api new` 的问候接口可以直接运行;自定义接口新生成的 Logic 和鉴权 hook 返回 **501**,需要实现后才可用。生成器不会根据 HTTP 字段猜数据库表或业务规则。
|
||||||
|
|
||||||
### 从自己的接口定义生成
|
### 从自己的接口定义生成
|
||||||
|
|
||||||
@@ -91,6 +92,8 @@ hello/
|
|||||||
```text
|
```text
|
||||||
syntax = "v1"
|
syntax = "v1"
|
||||||
|
|
||||||
|
@app (defaultResponse: CommonResponse)
|
||||||
|
|
||||||
response CommonResponse (
|
response CommonResponse (
|
||||||
body: {"code":"${code}", "msg":"${message}", "data":"${data}"}
|
body: {"code":"${code}", "msg":"${message}", "data":"${data}"}
|
||||||
codeType: int32
|
codeType: int32
|
||||||
@@ -126,13 +129,13 @@ service catalog-api {
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
go-omni api validate --api catalog.api
|
go-omni api validate --api catalog.api
|
||||||
go-omni new --api catalog.api --framework-version v0.11.1-preview \
|
go-omni new --api catalog.api \
|
||||||
--module example.com/catalog ./catalog
|
--module example.com/catalog ./catalog
|
||||||
cd catalog
|
cd catalog
|
||||||
go mod tidy
|
go mod tidy
|
||||||
```
|
```
|
||||||
|
|
||||||
将生成的 `internal/service/item/getItem.go` 替换为下面的演示实现。实际业务中换成 Repository 查询,Service 返回业务数据或错误,不再手工包装公共体:
|
将生成的 `internal/logic/item/getItem.go` 替换为下面的演示实现。实际业务中换成 Repository 查询,Logic 返回业务数据或错误,不再手工包装公共体:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package item
|
package item
|
||||||
@@ -144,7 +147,7 @@ import (
|
|||||||
"example.com/catalog/internal/types"
|
"example.com/catalog/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) GetItem(ctx context.Context, req *types.GetItemReq) (*types.ItemResp, error) {
|
func (s *Logic) GetItem(ctx context.Context, req *types.GetItemReq) (*types.ItemResp, error) {
|
||||||
if req.ItemID != "1001" {
|
if req.ItemID != "1001" {
|
||||||
return nil, apierrors.ItemNotFound()
|
return nil, apierrors.ItemNotFound()
|
||||||
}
|
}
|
||||||
@@ -156,7 +159,7 @@ func (s *Service) GetItem(ctx context.Context, req *types.GetItemReq) (*types.It
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# catalog 项目目录中启动;先停掉前面的 hello 服务,释放 8080 端口。
|
# catalog 项目目录中启动;先停掉前面的 hello 服务,释放 8080 端口。
|
||||||
go run ./cmd/server
|
go run .
|
||||||
# 另一个终端:
|
# 另一个终端:
|
||||||
curl -i http://127.0.0.1:8080/api/v1/items/1001
|
curl -i http://127.0.0.1:8080/api/v1/items/1001
|
||||||
curl -i http://127.0.0.1:8080/api/v1/items/missing
|
curl -i http://127.0.0.1:8080/api/v1/items/missing
|
||||||
@@ -167,24 +170,24 @@ curl -i http://127.0.0.1:8080/api/v1/items/missing
|
|||||||
需要 MySQL、文件存储或文件日志时,**首次生成**可以使用:
|
需要 MySQL、文件存储或文件日志时,**首次生成**可以使用:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go-omni new --api catalog.api --framework-version v0.11.1-preview \
|
go-omni new --api catalog.api \
|
||||||
--module example.com/catalogFull --database mysql --storage local \
|
--module example.com/catalogFull --database mysql --storage local \
|
||||||
--log-output file --log-format json ./catalogFull
|
--log-output file --log-format json ./catalogFull
|
||||||
```
|
```
|
||||||
|
|
||||||
该命令在 `catalog.api` 所在目录执行,目标仍需是新目录。数据库迁移、表结构和 Repository 由项目维护,API 模板不自动建表;MySQL DSN 通过 `GO_OMNI_DB_DSN` 提供。存储还可选 `s3`、`r2`、`oss`,按生成项目的配置说明提供桶、端点和凭据。完整装配例子见 [userService](examples/userService/README.md)。
|
该命令在 `catalog.api` 所在目录执行,目标仍需是新目录。数据库迁移、表结构和 Repository 由项目维护,API 模板不自动建表;MySQL DSN 通过 `GO_OMNI_DB_DSN` 提供。存储还可选 `s3`、`r2`、`oss`,按生成项目的配置说明提供桶、端点和凭据。完整生成项目示例见 [managedService](examples/managedService/README.md)。
|
||||||
|
|
||||||
不采用 `.api` 时,也可以用 `go-omni new --framework-version v0.11.1-preview --module example.com/billing ./billing` 创建手写接口的 CRUD 示例项目;两种生成方式选择一种即可。
|
`go-omni new` 与 `go-omni api new` 统一生成契约项目。需要手写项目时,可直接引用框架包;生成器不再提供另一套 CRUD 启动目录。
|
||||||
|
|
||||||
## 已有项目:按模块接入
|
## 已有项目:按模块接入
|
||||||
|
|
||||||
在业务项目的 **go.mod 所在目录**执行:
|
在业务项目的 **go.mod 所在目录**执行:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get git.yuntuo.hk/k2safe/go-omni@v0.11.1-preview
|
go get git.yuntuo.hk/k2safe/go-omni@v0.12.0-preview
|
||||||
```
|
```
|
||||||
|
|
||||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。
|
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
||||||
|
|
||||||
下面是可独立运行的 `main.go` 示例;迁入现有项目时,把路由调用接到自己的业务服务即可。
|
下面是可独立运行的 `main.go` 示例;迁入现有项目时,把路由调用接到自己的业务服务即可。
|
||||||
|
|
||||||
@@ -377,7 +380,7 @@ return apierrors.TextTooLong("企业名称", 100)
|
|||||||
|
|
||||||
### 数据库、存储及其他模块
|
### 数据库、存储及其他模块
|
||||||
|
|
||||||
依赖在启动入口或项目自己的 `internal/servicecontext` 中创建,通过构造函数传给 Service/Repository,避免每个请求都新建连接池。只注册自己拥有的资源,例如 `app.OnShutdown(db.Close)`;关闭按注册的逆序执行。
|
生成项目在 `internal/svc` 中创建依赖,提供给 Logic/Repository,并用 `c.OnClose(db.Close)` 登记自有资源。生成的 main 在 HTTP 排空后关闭 svc,再关闭日志。直接使用框架的手写项目则可以用 `app.OnShutdown(db.Close)`;不要对同一资源重复登记。
|
||||||
|
|
||||||
已有项目使用 `driver.Connector` 时,直接 `database.OpenConnector(ctx, connector, database.DefaultPoolConfig())`。MySQL 等驱动的独立 TLS、Logger、时区与拨号参数由项目构造 Connector;框架负责连接池、启动 Ping、健康检查及事务。无需转成 DSN,也无需注册全局 TLS/Logger。具体示例和资源归属见 [Connector 接入](database/README.md#connector-接入v091-preview)。
|
已有项目使用 `driver.Connector` 时,直接 `database.OpenConnector(ctx, connector, database.DefaultPoolConfig())`。MySQL 等驱动的独立 TLS、Logger、时区与拨号参数由项目构造 Connector;框架负责连接池、启动 Ping、健康检查及事务。无需转成 DSN,也无需注册全局 TLS/Logger。具体示例和资源归属见 [Connector 接入](database/README.md#connector-接入v091-preview)。
|
||||||
|
|
||||||
@@ -411,7 +414,7 @@ API 项目的数据库迁移需要单独维护并显式执行;启动不会自
|
|||||||
例如在生成项目中切换 native:
|
例如在生成项目中切换 native:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
GO_OMNI_TRANSPORT=native go run ./cmd/server
|
GO_OMNI_TRANSPORT=native go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
手写入口可用 `omni.WithTransport(omni.TransportNative)`;TLS 配置为 `omni.WithTLS(certFile, keyFile)`。native 提供 HTTP/1.1、Keep-Alive、TLS、超时、连接/请求并发上限和对象复用;HTTP/2 是单独的实验适配器,必须配置 TLS。默认 nethttp 的 TLS 支持 HTTP/1.1 与 HTTP/2。
|
手写入口可用 `omni.WithTransport(omni.TransportNative)`;TLS 配置为 `omni.WithTLS(certFile, keyFile)`。native 提供 HTTP/1.1、Keep-Alive、TLS、超时、连接/请求并发上限和对象复用;HTTP/2 是单独的实验适配器,必须配置 TLS。默认 nethttp 的 TLS 支持 HTTP/1.1 与 HTTP/2。
|
||||||
@@ -447,9 +450,9 @@ go-omni api generate --check
|
|||||||
go test ./...
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Service、Repository、中间件实现、main、配置和依赖装配属于项目,重复生成保留手写内容;契约改变后仍需自行调整业务签名并通过编译。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 可运行 `generate --check` 检查是否同步。
|
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
|
||||||
|
|
||||||
升级时先安装目标版本的 CLI,再在业务 module 中 `go get` **同一版本**;API 项目随后执行上述生成与测试步骤,手写项目只需依赖升级及业务回归。`v0.11.1-preview` 的变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
CLI 与业务 module 固定使用 `v0.12.0-preview`。新布局不兼容旧 manifest;旧试验项目需在新目录生成并搬入业务文件,不能直接覆盖手写入口。新布局内部升级再执行上述生成与测试步骤,手写框架项目只需依赖升级及业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
## 按需深入
|
## 按需深入
|
||||||
|
|
||||||
@@ -457,8 +460,9 @@ go test ./...
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `.api` 类型、参数绑定、错误、鉴权和生成规则 | [API 开发指南](API.md) |
|
| `.api` 类型、参数绑定、错误、鉴权和生成规则 | [API 开发指南](API.md) |
|
||||||
| 公共响应体、提示优先级、独立回调协议 | [响应参考](RESPONSE.md) |
|
| 公共响应体、提示优先级、独立回调协议 | [响应参考](RESPONSE.md) |
|
||||||
| CLI 完整参数、本地框架开发和模块脚手架 | [CLI](cmd/go-omni/README.md) |
|
| CLI 完整参数、本地框架开发与迁移 | [CLI](cmd/go-omni/README.md) |
|
||||||
| 完整 API 业务项目 | [userService](examples/userService/README.md) |
|
| 当前生成项目:Go 错误目录、上传、回调和下载 | [managedService](examples/managedService/README.md) |
|
||||||
|
| 数据库/事务业务参考(旧目录示例) | [userService](examples/userService/README.md) |
|
||||||
| 手写接口分层示例 | [reference](examples/reference/README.md) |
|
| 手写接口分层示例 | [reference](examples/reference/README.md) |
|
||||||
| 框架成熟度、历史变更 | [路线图](ROADMAP.md)、[更新记录](CHANGELOG.md) |
|
| 框架成熟度、历史变更 | [路线图](ROADMAP.md)、[更新记录](CHANGELOG.md) |
|
||||||
| 网络性能、测试条件与适用范围 | [性能说明](PERFORMANCE.md)、[HTTP/2 验收](transport/native/HTTP2.md) |
|
| 网络性能、测试条件与适用范围 | [性能说明](PERFORMANCE.md)、[HTTP/2 验收](transport/native/HTTP2.md) |
|
||||||
|
|||||||
46
RELEASE.md
46
RELEASE.md
@@ -1,4 +1,48 @@
|
|||||||
# v0.11.1-preview
|
# v0.12.0-preview
|
||||||
|
|
||||||
|
本版用于固定版本开展业务接入。生成器统一维护服务根目录 main.go、路由、HTTP Handler、DTO 和 OpenAPI,业务方维护 `.api`、config、svc、logic、repository 与项目中间件。目录不再生成 cmd/server、settings、servicecontext 或 service。
|
||||||
|
|
||||||
|
## 安装与接入
|
||||||
|
|
||||||
|
仓库权限及 PATH 配置见 [README](README.md#准备环境与安装)。CLI 与框架使用同一版本:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.12.0-preview
|
||||||
|
go-omni new --module example.com/hello ./hello
|
||||||
|
cd hello
|
||||||
|
go mod tidy
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
已存在的手写项目可直接 `go get git.yuntuo.hk/k2safe/go-omni@v0.12.0-preview`。采用生成目录的旧项目在新目录生成后迁入业务代码,详见 [接入步骤](PROJECT.md#已有服务接入步骤) 和 [文件维护边界](PROJECT.md#哪些文件由用户维护)。新 manifest v2 不兼容旧 manifest;不会静默覆盖手写 main,也不会删除独立 cmd/migrate 等工具。
|
||||||
|
|
||||||
|
## 本版行为
|
||||||
|
|
||||||
|
- 根目录支持 go run / go build;入口负责配置、日志、依赖、HTTP 启动与信号退出。svc 的 AppOptions、GlobalMiddleware 和 OnClose 是显式扩展入口;初始化/监听失败会释放资源,正常退出先排空请求再关闭依赖和日志。
|
||||||
|
- `.api` 入口通过 `@app(defaultResponse: Name)` 选择默认响应,覆盖普通路由、404/405 与相应中间件错误;多模板不会按声明顺序猜默认值。字段、状态、业务码和文案由项目配置。
|
||||||
|
- 保持 v0.11.1 的错误目录继承:Go 中注册一次,无独立错误目录的生成路由继承 App。带参数提示、内部错误链、raw 响应和独立目录保持各自行为。
|
||||||
|
- raw/multipart 上传及 `@produces` 下载可直接编写 Logic,由生成 Handler 完成受限读取、响应和流关闭。损坏 multipart 默认 400,超限 413;项目可以定制公开错误。
|
||||||
|
- 契约变更提示受影响的 Logic、DTO 字段和请求模式;明确的签名/语法/重复方法错误使 `generate --check` 失败。重新生成保留业务文件,main 和其他生成文件的漂移也会被检查。
|
||||||
|
|
||||||
|
## 验收与范围
|
||||||
|
|
||||||
|
发布前全量 race 测试、go vet、go build 和本地实际生成项目进程验收通过,记录见 [生成项目验收](performance/managedProjectAcceptance.md)。远程发布后使用以下命令独立验收安装产物;结果另行提交至仓库,不改写已发布标签:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
|
||||||
|
go vet ./...
|
||||||
|
go build ./...
|
||||||
|
python3 scripts/verifyManagedProject.py
|
||||||
|
python3 scripts/verifyManagedRelease.py --version v0.12.0-preview
|
||||||
|
```
|
||||||
|
|
||||||
|
远程验收使用空 module 缓存安装已发布 CLI,生成的业务项目仅依赖同版远程 module,无本地 replace;检查根目录运行/构建、重新生成、错误继承/模板参数、上传下载、nethttp/native 的真实请求与退出信号,以及初始化失败/监听失败/请求排空后的清理。
|
||||||
|
|
||||||
|
本版不代表具体业务项目已完成迁移验收。HTTP/2 仍为实验状态;真实数据库、云账号和业务服务器不属于本轮验收范围。轻量 Logic 检查不能代替 Go 编译器或业务测试。静态 OpenAPI 不执行 Go 代码,因此不会自动列举仅在 Go 中维护的错误目录。请求流校验不能回滚业务提前执行的副作用,关键写入应放在 `body.Complete()` 成功之后。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.11.1-preview(历史版本)
|
||||||
|
|
||||||
修复生成路由使用空错误目录遮蔽 `omni.WithErrors(catalog)` 的问题。错误现在可以只在 Go 中注册一次,`.api` 无需重复定义错误目录或逐条列出错误。
|
修复生成路由使用空错误目录遮蔽 `omni.WithErrors(catalog)` 的问题。错误现在可以只在 Go 中注册一次,`.api` 无需重复定义错误目录或逐条列出错误。
|
||||||
|
|
||||||
|
|||||||
@@ -1,162 +1,63 @@
|
|||||||
# go-omni CLI
|
# go-omni CLI
|
||||||
|
|
||||||
首次接入见 [README](../../README.md),无需克隆框架源码。本页说明命令参数;下方 `go run ./cmd/go-omni` 示例在框架仓库中执行,并显式引用本地源码。
|
新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。
|
||||||
|
|
||||||
## API 契约驱动项目
|
## 创建项目
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 创建包含可运行问候接口的新项目
|
# 仓库读取权限与 PATH 配置见根目录 README
|
||||||
go run ./cmd/go-omni api new --module example.com/demo --framework-dir . ../demo
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.12.0-preview
|
||||||
|
go-omni new --module example.com/hello ./hello
|
||||||
|
|
||||||
# 从已有定义生成对应目录、类型、路由和业务骨架
|
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||||
go run ./cmd/go-omni api generate --api examples/contracts/user.api \
|
go-omni new --api service.api --module example.com/example ./example
|
||||||
--dir ../userService --module example.com/userService --framework-dir .
|
|
||||||
|
|
||||||
# 校验定义;也接受 goctl 风格的 api go -api ... -dir ...
|
|
||||||
go run ./cmd/go-omni api validate --api examples/contracts/user.api
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`new --api <file>` 是契约项目的另一个入口。可以组合 `--database mysql --storage local --log-output file`,生成配置、依赖初始化与资源关闭。API 默认无数据库、无对象存储;`--database memory` 等同于不打开连接。`--force` 不允许覆盖 API 项目。初始配置选择写入 manifest,重新生成自动沿用。
|
`new` 与 `api new` 统一生成契约项目;不再提供另一套 CRUD 启动目录。未实现的 Logic / 中间件骨架返回 501。进入项目执行 `go mod tidy`、`go run .`、`go build .`。
|
||||||
|
|
||||||
后续在生成项目内执行 `go-omni api generate --api api/user.api --dir .`。
|
参数:`--database none|memory|mysql`(默认 none;memory 等同 none),`--storage none|local|s3|r2|oss`,`--log-output stdout|stderr|file`,`--log-format json|text`。基础设施按需生成,连接凭据由环境提供,不自动创建数据库、表或云资源。选择 MySQL 不会凭空生成业务表或 Repository 查询。
|
||||||
项目内可省略 `--api` / `--dir`。`api inspect` 展示路由和鉴权/业务错误,`api export` 向 stdout 导出 OpenAPI,`api generate --dry-run` 预览文件变化。`--check` 只读检查生成产物是否过期。生成器记录所有权,保留 Service / Repository / 配置 / main 的手写内容,遇到生成文件被手动修改则报冲突。新 Service 与中间件占位实现返回 501。支持 `error (...)`、具名 `auth`、路由 `@errors` / `@auth` 和多行 `@doc`;错误目录同步生成 `internal/apierrors` 调用函数。
|
|
||||||
|
|
||||||
`v0.10.0-preview` 新增 `errorCatalog`:集中配置业务码、提示和 HTTP 状态,支持默认 HTTP 200 与单条覆盖,生成函数、路由策略和 OpenAPI 共用一份定义。语法及配置方式见 [统一错误目录](../../errors/README.md)。
|
存储配置和客户端位于 config/svc,业务使用统一接口。local 存储签名端点由生成器接入;上传、下载等业务路由由 `.api` 定义。认证与存储路径由项目实现。
|
||||||
|
|
||||||
`v0.11.0-preview` 增加有序模板参数,生成带类型的错误函数和具体文档示例;示例见 [errorParams.api](../../examples/contracts/errorParams.api)。新生成代码需要同版或更新框架。
|
## 日常生成
|
||||||
|
|
||||||
`v0.11.1-preview` 修复未声明错误的生成路由遮蔽 App 错误目录:错误可只在 Go 中注册一次,`.api` 不必重复维护;返回格式和路由参数继续按契约生成。
|
|
||||||
|
|
||||||
支持范围、参数语义、目录及安全更新策略见 [API 开发指南](../../API.md)。本版借鉴 go-zero 的工作流,不宣称兼容全部 goctl 语法;仍通过 Omni/httpx 保留网络层替换能力。
|
|
||||||
|
|
||||||
## 日志与对象存储按需装配
|
|
||||||
|
|
||||||
新增 `--log-output stdout|stderr|file`、`--log-format json|text`、`--storage none|local|s3|r2|oss`。
|
|
||||||
指定任意一个新选项后,生成严格的配置文件、初始化代码和项目自身的测试;不指定则保留原有轻量骨架。
|
|
||||||
对象存储独立于 `--database memory|mysql`。只有选择文件存储才会生成文件 Handler / Service 和对应适配器。
|
|
||||||
|
|
||||||
开发阶段可以使用本地源码;固定远程版本的安装和验收见 [发布说明](../../RELEASE.md):
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 在 go-omni 仓库中运行;目标目录须为空。
|
go-omni api validate
|
||||||
go run ./cmd/go-omni new --framework-dir . \
|
go-omni api inspect
|
||||||
--module example.com/fileService --log-output file --log-format json \
|
go-omni api generate --dry-run
|
||||||
--storage local ../fileService
|
go-omni api generate
|
||||||
|
go-omni api generate --check
|
||||||
|
go-omni api export > openapi.json
|
||||||
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
生成项目包含 `etc/config.yaml`、`internal/settings`、`internal/servicecontext/storage.go`、
|
项目内自动发现 manifest 中的 API 入口,也可显式 `--api ... --dir ...`。`api go -api ... -dir ...` 为命令别名,不代表兼容全部 goctl 语法。
|
||||||
文件 Service/Handler、测试和 `MODULES.md`。按生成的 MODULES.md 配置示例 Token 和本地签名密钥后,
|
|
||||||
`go mod tidy && go run ./cmd/server` 即可体验。云存储分别选择 `--storage s3`、`r2`、`oss`,
|
|
||||||
再设置已有桶/凭据等环境变量;不自动创建桶或修改云资源。
|
|
||||||
|
|
||||||
配置优先级为默认值 → YAML/JSON → 环境变量;密钥仅允许环境变量。Service 依赖统一存储接口,
|
main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节保留。手改生成文件会报冲突,`--check` 检查入口与所有生成产物。没有 `--force` 覆盖业务的入口。初始基础设施 profile 在重新生成时沿用,后续项目依赖由用户修改 config/svc。
|
||||||
组合处负责创建客户端与有界连接池,退出时关闭自己创建的资源。文件接口提供服务端上传、直传签名、
|
|
||||||
临时下载链接及统一错误处理;日志示例携带 requestId,文件输出默认带轮转。没有固定业务身份上下文。
|
|
||||||
|
|
||||||
新模板要求 `v0.7.0-preview` 或更新版本;明确指定旧版本并启用新模块会被拒绝,
|
生成/预览会比较上次契约快照,提示受影响的 Logic 文件、DTO 字段和 body/流式模式变化。明确的 Logic 签名错误显示文件行号、原签名和期望签名;生成后未调整业务时,`--check` 仍返回失败。REVIEW 不强制确认,类型别名/build tags 等不确定情况交给 `go test ./...` 验证。完整行为见 [契约变更提示](../../PROJECT.md#契约变更提示)。
|
||||||
避免模板引用旧框架不存在的接口。本地 `--framework-dir` 验证不受此限制。`--force` 可重新生成兼容配置,
|
|
||||||
但如果切换配置会遗留引用已移除依赖的旧文件,则拒绝操作,要求使用新目录;不会自动删除修改过的代码。
|
|
||||||
|
|
||||||
验证当前生成器:
|
`@app(defaultResponse: TemplateName)` 选择 App 默认响应,路由/分组可以覆盖。错误表可以只在 Go 中维护,通过 svc.AppOptions 注册一次。上传和原始 body 的自动 Logic 参数、下载输出见 [项目指南](../../PROJECT.md)。
|
||||||
|
|
||||||
|
## 版本与迁移
|
||||||
|
|
||||||
|
CLI 与框架依赖固定使用 v0.12.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./cmd/go-omni/...
|
# 在 go-omni 源码仓库中执行
|
||||||
python3 scripts/verifyScaffoldModules.py
|
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
||||||
```
|
```
|
||||||
|
|
||||||
前者覆盖两种数据库与五种文件存储选择、日志方式/格式,以及生成项目的配置、资源关闭和接口测试。
|
新模板需要 v0.12.0-preview 的流式能力与 manifest v2。不兼容旧目录,旧生成项目请在新目录重新生成后移入业务代码,不覆盖手写 main。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||||
后者实际编译启动服务,验证日志、上传/下载/直传链接、错误状态、nethttp/native 下的 SIGINT/SIGTERM 和监听端口释放。
|
|
||||||
云端选项的测试使用本地替身,不代表 OSS/R2 实网验收;本批不访问业务服务器。报告见
|
|
||||||
[脚手架模块验收](../../performance/scaffoldModulesLocal.json)。
|
|
||||||
|
|
||||||
## 其他命令与远程安装
|
## 数据库迁移命令
|
||||||
|
|
||||||
main 分支还提供 `go-omni add orderItems --dir /path/to/project`,向已有 GoOmni 项目增加
|
保留独立迁移工具,应用启动不自动迁移:
|
||||||
Handler / Service / Repository / Model 与模块注册入口。模块名为 lowerCamelCase,
|
|
||||||
目标目录存在时拒绝覆盖,已有启动代码保持原样。生成模块带可替换的内存 CRUD Repository,
|
|
||||||
导入后使用 `orderItems.Register(application, repository, authMiddleware)` 注册。
|
|
||||||
该命令从 `v0.7.0-preview` 提供;源码可用 `go build -o /tmp/go-omni ./cmd/go-omni` 构建。
|
|
||||||
边界和验证方式见 [业务基础模块](../../MODULES.md)。
|
|
||||||
|
|
||||||
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.1-preview
|
|
||||||
go-omni new --framework-version v0.11.1-preview --module example.com/billing ./billing
|
|
||||||
cd billing
|
|
||||||
go mod tidy
|
|
||||||
go run ./cmd/server
|
|
||||||
```
|
|
||||||
|
|
||||||
通过 `go install ...@版本` 安装的 CLI 从构建信息中读取版本,并把它固定到生成项目的
|
|
||||||
`go.mod`。不会使用不存在的 `v0.0.0`,也不会在普通业务目录中添加本地 `replace`。
|
|
||||||
|
|
||||||
需要选择其他已发布版本时,使用完整版本号:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go-omni new --framework-version v0.6.0-preview --module example.com/billing ./billing
|
|
||||||
```
|
|
||||||
|
|
||||||
`--framework-version` 禁用自动本地替换;`main`、`latest` 等浮动引用不接受为固定版本。
|
|
||||||
源码构建的 CLI 使用代码中声明的目标发布版本;该版本未发布时请显式选择已发布版本,或用 `--framework-dir` 验证本地源码。
|
|
||||||
|
|
||||||
在 GoOmni 源码目录执行时,命令会自动在生成项目的 `go.mod` 中加入本地
|
|
||||||
`replace git.yuntuo.hk/k2safe/go-omni => ...`,便于立即运行和测试。目标目录非空时命令会停止,明确传入
|
|
||||||
`--force` 才会覆盖脚手架文件。
|
|
||||||
|
|
||||||
也可以在任意目录显式指定源码路径:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go-omni new --framework-dir /path/to/go-omni --module example.com/billing ./billing
|
|
||||||
```
|
|
||||||
|
|
||||||
默认生成内存存储版本;选择 MySQL 时:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go-omni new --database mysql --module example.com/billing ./billing
|
|
||||||
```
|
|
||||||
|
|
||||||
MySQL 项目额外包含 `schema.sql`、MySQL Repository 和可选的数据库集成测试。
|
|
||||||
先应用建表脚本,再设置 `GO_OMNI_DB_DSN` 启动。ServiceContext 负责连接池初始化、表结构检查、
|
|
||||||
就绪探针注册和关闭。两种模式都提供用户 CRUD、分页、输入校验和统一错误响应。
|
|
||||||
|
|
||||||
生成服务默认包含 `/health/live`、`/health/ready` 和示例 `/users` CRUD。
|
|
||||||
退出信号通过 `signal.NotifyContext` 传入 `RunContext`,Logger 的关闭函数注册到应用生命周期。
|
|
||||||
|
|
||||||
发布验收使用 Python 3 标准库脚本,不需要额外 Python 依赖:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 scripts/verifyScaffold.py --version v0.6.0-preview
|
|
||||||
python3 scripts/verifyDatabase.py --version v0.6.0-preview
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、
|
|
||||||
编译、请求接口,并对真实二进制分别发送 `SIGINT` 和 `SIGTERM`。同时验证框架 `Run` 的
|
|
||||||
信号处理和清理回调。每个进程都必须退出码为 0、释放监听端口;临时项目和测试进程会被清理。
|
|
||||||
|
|
||||||
发布前验证当前源码:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go build -o /tmp/go-omni ./cmd/go-omni
|
|
||||||
python3 scripts/verifyScaffold.py --cli /tmp/go-omni --framework-dir .
|
|
||||||
python3 scripts/verifyDatabase.py --cli /tmp/go-omni --framework-dir .
|
|
||||||
```
|
|
||||||
|
|
||||||
数据库验收需要本地 Docker,使用独立 MySQL 8.4 容器;不会连接已有业务数据库,结束后清理测试容器与数据卷。
|
|
||||||
|
|
||||||
## 显式迁移(main)
|
|
||||||
|
|
||||||
SQL 命名为 `0001_createUsers.up.sql`。先预览版本和 SHA-256 清单:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go-omni migrate --dir migrations
|
go-omni migrate --dir migrations
|
||||||
```
|
# 检查预览,配置 GO_OMNI_DB_DSN 后显式应用
|
||||||
|
|
||||||
部署步骤配置 `GO_OMNI_DB_DSN`,显式执行:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go-omni migrate --dir migrations --apply
|
go-omni migrate --dir migrations --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
预览不连接数据库;应用启动不自动迁移。失败保留 dirty 状态,不提供自动 down/force。
|
SQL 命名示例 `0001_createUsers.up.sql`;预览只显示版本和 SHA-256,不连接数据库。失败保留 dirty 状态,不自动 down/force。细节见 [扩展模块](../../ADVANCED.md)。
|
||||||
脚手架原有 `schema.sql` 不会自动变成有版本的迁移,已有库需要先确定基线。详见 [扩展模块](../../ADVANCED.md)。
|
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ func runAPI(args []string) error {
|
|||||||
}
|
}
|
||||||
out, err = apigen.Generate(o)
|
out, err = apigen.Generate(o)
|
||||||
}
|
}
|
||||||
|
printAPIDiagnostics(out.Diagnostics)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -168,9 +169,32 @@ func runAPI(args []string) error {
|
|||||||
}
|
}
|
||||||
fmt.Printf("Preview: %d changes; %d existing developer-owned files preserved; no files written\n", len(out.Changes), len(out.Preserved))
|
fmt.Printf("Preview: %d changes; %d existing developer-owned files preserved; no files written\n", len(out.Changes), len(out.Preserved))
|
||||||
} else if o.Check {
|
} else if o.Check {
|
||||||
fmt.Println("API generated files are current")
|
fmt.Println("API generated files are current; no definite Logic signature errors found (run go test ./... for full verification)")
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("API project ready: %d generated, %d created, %d preserved, %d removed\n", len(out.Generated), len(out.Created), len(out.Preserved), len(out.Removed))
|
fmt.Printf("API project generated: %d generated, %d created, %d preserved, %d removed\n", len(out.Generated), len(out.Created), len(out.Preserved), len(out.Removed))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func printAPIDiagnostics(diagnostics []apigen.Diagnostic) {
|
||||||
|
for _, d := range diagnostics {
|
||||||
|
location := d.Path
|
||||||
|
if d.Line > 0 {
|
||||||
|
location += fmt.Sprintf(":%d", d.Line)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s [%s] %s", strings.ToUpper(d.Severity), d.Code, location)
|
||||||
|
if d.Operation != "" {
|
||||||
|
fmt.Printf(" (%s)", d.Operation)
|
||||||
|
}
|
||||||
|
fmt.Printf(": %s\n", d.Message)
|
||||||
|
if d.Before != "" {
|
||||||
|
fmt.Printf(" before: %s\n", d.Before)
|
||||||
|
}
|
||||||
|
if d.After != "" {
|
||||||
|
fmt.Printf(" after: %s\n", d.After)
|
||||||
|
}
|
||||||
|
for _, detail := range d.Details {
|
||||||
|
fmt.Printf(" - %s\n", detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func TestAPICommands(t *testing.T) {
|
|||||||
if err = runNew([]string{"--api", api, "--module", "example.com/other", "--framework-dir", root, target}); err != nil {
|
if err = runNew([]string{"--api", api, "--module", "example.com/other", "--framework-dir", root, target}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err = os.Stat(filepath.Join(target, "internal/service/greet/greet.go")); err != nil {
|
if _, err = os.Stat(filepath.Join(target, "internal/logic/greet/greet.go")); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, args := range [][]string{{"validate"}, {"generate", "--api"}, {"new", "--api=x", "somewhere"}, {"generate", "--dir="}, {"generate", "--force"}, {"validate", "--api", api, "--dir", project}, {"validate", "--check"}} {
|
for _, args := range [][]string{{"validate"}, {"generate", "--api"}, {"new", "--api=x", "somewhere"}, {"generate", "--dir="}, {"generate", "--force"}, {"validate", "--api", api, "--dir", project}, {"validate", "--check"}} {
|
||||||
@@ -40,7 +40,7 @@ func TestAPICommands(t *testing.T) {
|
|||||||
t.Fatalf("invalid args accepted: %v", args)
|
t.Fatalf("invalid args accepted: %v", args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err = runNew([]string{"--api", api, "--force", target}); err == nil || !strings.Contains(err.Error(), "does not accept --force") {
|
if err = runNew([]string{"--api", api, "--force", target}); err == nil || !strings.Contains(err.Error(), "do not accept --force") {
|
||||||
t.Fatalf("incompatible profile: %v", err)
|
t.Fatalf("incompatible profile: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,6 +86,15 @@ func TestAPIDeveloperCommands(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
func captureAPIOutput(t *testing.T, args []string) string {
|
func captureAPIOutput(t *testing.T, args []string) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := captureAPIResult(t, args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureAPIResult(t *testing.T, args []string) (string, error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
r, w, err := os.Pipe()
|
r, w, err := os.Pipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -100,8 +109,41 @@ func captureAPIOutput(t *testing.T, args []string) string {
|
|||||||
w.Close()
|
w.Close()
|
||||||
os.Stdout = previous
|
os.Stdout = previous
|
||||||
data := <-result
|
data := <-result
|
||||||
if callErr != nil {
|
return string(data), callErr
|
||||||
t.Fatal(callErr)
|
}
|
||||||
}
|
|
||||||
return string(data)
|
func TestAPIChangeDiagnosticsVisibleOnFailure(t *testing.T) {
|
||||||
|
root, err := filepath.Abs("../..")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir := filepath.Join(t.TempDir(), "app")
|
||||||
|
captureAPIOutput(t, []string{"new", dir, "--module", "example.com/diagnostics", "--framework-dir", root})
|
||||||
|
api := filepath.Join(dir, "api/service.api")
|
||||||
|
data, err := os.ReadFile(api)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = os.WriteFile(api, []byte(strings.ReplaceAll(string(data), "GreetReq", "HelloReq")), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, flag := range []string{"--dry-run", "--check", ""} {
|
||||||
|
args := []string{"generate", "--dir", dir}
|
||||||
|
if flag != "" {
|
||||||
|
args = append(args, flag)
|
||||||
|
}
|
||||||
|
text, err := captureAPIResult(t, args)
|
||||||
|
if (flag == "--check") != (err != nil) {
|
||||||
|
t.Fatalf("%s: %v", flag, err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"REVIEW [contractChanged]", "ERROR [logicSignature]", "internal/logic/greet/greet.go:", "types.HelloReq", "types.GreetReq"} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("%s hides %q:\n%s", flag, want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text, err := captureAPIResult(t, []string{"generate", "--dir", dir, "--check"})
|
||||||
|
if err == nil || !strings.Contains(text, "ERROR [logicSignature]") || strings.Contains(text, "files are current") {
|
||||||
|
t.Fatalf("check claimed success: %v\n%s", err, text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,10 +168,10 @@ func TestRegenerationAndConflicts(t *testing.T) {
|
|||||||
if _, err := Generate(o); err != nil {
|
if _, err := Generate(o); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
servicePath := filepath.Join(dir, "internal/service/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)
|
||||||
for _, file := range []string{"cmd/server/main.go", "etc/config.yaml", "internal/servicecontext/context.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") {
|
||||||
body += "\n// handwritten extension\n"
|
body += "\n// handwritten extension\n"
|
||||||
@@ -204,7 +204,7 @@ func TestRegenerationAndConflicts(t *testing.T) {
|
|||||||
if _, err := os.Stat(filepath.Join(dir, "internal/handler/extra/handlersGen.go")); !os.IsNotExist(err) {
|
if _, err := os.Stat(filepath.Join(dir, "internal/handler/extra/handlersGen.go")); !os.IsNotExist(err) {
|
||||||
t.Fatal("stale handler remains")
|
t.Fatal("stale handler remains")
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(dir, "internal/service/extra/ping.go")); err != nil {
|
if _, err := os.Stat(filepath.Join(dir, "internal/logic/extra/ping.go")); err != nil {
|
||||||
t.Fatal("business stub removed")
|
t.Fatal("business stub removed")
|
||||||
}
|
}
|
||||||
writeFile(t, filepath.Join(dir, "internal/types/typesGen.go"), typesBefore+"\n// edited generated file\n")
|
writeFile(t, filepath.Join(dir, "internal/types/typesGen.go"), typesBefore+"\n// edited generated file\n")
|
||||||
@@ -312,9 +312,9 @@ func TestGeneratedProjectsBuildAndRun(t *testing.T) {
|
|||||||
if starter {
|
if starter {
|
||||||
source = consumerStarterTest
|
source = consumerStarterTest
|
||||||
} else {
|
} else {
|
||||||
writeFile(t, filepath.Join(dir, "internal/service/user/updateUser.go"), `package user
|
writeFile(t, filepath.Join(dir, "internal/logic/user/updateUser.go"), `package user
|
||||||
import("context";"example.com/consumer/internal/types")
|
import("context";"example.com/consumer/internal/types")
|
||||||
func(s *Service) UpdateUser(ctx context.Context,req *types.UpdateUserReq)(*types.UserResp,error){return &types.UserResp{UserID:req.UserID,Name:req.Name,Active:req.Active},nil}
|
func(s *Logic) UpdateUser(ctx context.Context,req *types.UpdateUserReq)(*types.UserResp,error){return &types.UserResp{UserID:req.UserID,Name:req.Name,Active:req.Active},nil}
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
writeFile(t, filepath.Join(dir, "contract_test.go"), source)
|
writeFile(t, filepath.Join(dir, "contract_test.go"), source)
|
||||||
@@ -339,13 +339,13 @@ func(s *Service) UpdateUser(ctx context.Context,req *types.UpdateUserReq)(*types
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const consumerStarterTest = `package consumer_test
|
const consumerStarterTest = `package main_test
|
||||||
import("context";"io";"net/http/httptest";"strings";"testing";omni "git.yuntuo.hk/k2safe/go-omni";"git.yuntuo.hk/k2safe/go-omni/logx";"example.com/consumer/internal/handler";"example.com/consumer/internal/servicecontext";"example.com/consumer/internal/settings")
|
import("context";"io";"net/http/httptest";"strings";"testing";omni "git.yuntuo.hk/k2safe/go-omni";"git.yuntuo.hk/k2safe/go-omni/logx";"example.com/consumer/internal/handler";"example.com/consumer/internal/svc";"example.com/consumer/internal/config")
|
||||||
func TestGreeting(t *testing.T){cfg,err:=settings.Load("etc/config.yaml");if err!=nil{t.Fatal(err)};cfg.Server.Log.Writer=io.Discard;cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{};logger,err:=logx.New(cfg.Server.Log);if err!=nil{t.Fatal(err)};defer logger.Close();deps,err:=servicecontext.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close();app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)};w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,httptest.NewRequest("GET","/api/greet/Alice",nil));if w.Code!=200||!strings.Contains(w.Body.String(),"Hello Alice")||w.Header().Get("X-Request-ID")==""{t.Fatalf("%d %s",w.Code,w.Body.String())}}
|
func TestGreeting(t *testing.T){cfg,err:=config.Load("etc/config.yaml");if err!=nil{t.Fatal(err)};cfg.Server.Log.Writer=io.Discard;cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{};logger,err:=logx.New(cfg.Server.Log);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:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)};w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,httptest.NewRequest("GET","/api/greet/Alice",nil));if w.Code!=200||!strings.Contains(w.Body.String(),"Hello Alice")||w.Header().Get("X-Request-ID")==""{t.Fatalf("%d %s",w.Code,w.Body.String())}}
|
||||||
`
|
`
|
||||||
const consumerContractTest = `package consumer_test
|
const consumerContractTest = `package main_test
|
||||||
import("context";"encoding/json";"io";"net/http/httptest";"strings";"testing";omni "git.yuntuo.hk/k2safe/go-omni";"git.yuntuo.hk/k2safe/go-omni/logx";"example.com/consumer/internal/handler";"example.com/consumer/internal/servicecontext")
|
import("context";"encoding/json";"io";"net/http/httptest";"strings";"testing";omni "git.yuntuo.hk/k2safe/go-omni";"git.yuntuo.hk/k2safe/go-omni/logx";"example.com/consumer/internal/handler";"example.com/consumer/internal/svc")
|
||||||
func TestContract(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:=servicecontext.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close();app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
|
func TestContract(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:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
|
||||||
for _,tc:=range []struct{method,path,body string;status int}{
|
for _,tc:=range []struct{method,path,body string;status int}{
|
||||||
{"PUT","/api/v1/users/7?notify=true",` + "`" + `{"name":"Alice","active":false,"age":0}` + "`" + `,200},
|
{"PUT","/api/v1/users/7?notify=true",` + "`" + `{"name":"Alice","active":false,"age":0}` + "`" + `,200},
|
||||||
{"PUT","/api/v1/users/7",` + "`" + `{"name":"Alice"}` + "`" + `,400},
|
{"PUT","/api/v1/users/7",` + "`" + `{"name":"Alice"}` + "`" + `,400},
|
||||||
|
|||||||
@@ -69,14 +69,14 @@ func TestCompatibilityConsumer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
generate(false)
|
generate(false)
|
||||||
files := map[string]string{
|
files := map[string]string{
|
||||||
"internal/service/demo/echo.go": "echo.go",
|
"internal/logic/demo/echo.go": "echo.go",
|
||||||
"internal/handler/demo/customBind.go": "hooks.go",
|
"internal/handler/demo/customBind.go": "hooks.go",
|
||||||
"internal/handler/files/downloadManual.go": "download.go",
|
"internal/handler/files/downloadManual.go": "download.go",
|
||||||
"consumer_test.go": "consumer_test.go",
|
"consumer_test.go": "consumer_test.go",
|
||||||
}
|
}
|
||||||
// The fixture implements related methods together; remove only its generated
|
// The fixture implements related methods together; remove only its generated
|
||||||
// one-time stubs, then fill every stub so regeneration can preserve all files.
|
// one-time stubs, then fill every stub so regeneration can preserve all files.
|
||||||
for _, name := range []string{"internal/service/demo/item.go", "internal/service/demo/custom.go"} {
|
for _, name := range []string{"internal/logic/demo/item.go", "internal/logic/demo/custom.go"} {
|
||||||
writeFile(t, filepath.Join(dir, name), "package demo\n")
|
writeFile(t, filepath.Join(dir, name), "package demo\n")
|
||||||
}
|
}
|
||||||
for _, name := range []string{"internal/handler/demo/uploadManual.go", "internal/handler/demo/callbackManual.go"} {
|
for _, name := range []string{"internal/handler/demo/uploadManual.go", "internal/handler/demo/callbackManual.go"} {
|
||||||
|
|||||||
@@ -166,8 +166,8 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
if !goName.MatchString(r.Handler) {
|
if !goName.MatchString(r.Handler) {
|
||||||
return nil, fmt.Errorf("%s: invalid handler name", r.Position)
|
return nil, fmt.Errorf("%s: invalid handler name", r.Position)
|
||||||
}
|
}
|
||||||
if strings.EqualFold(r.Handler, "service") {
|
if strings.EqualFold(r.Handler, "service") || strings.EqualFold(r.Handler, "logic") {
|
||||||
return nil, errors.New("handler name service conflicts with the service constructor file")
|
return nil, errors.New("handler name conflicts with the Logic constructor file")
|
||||||
}
|
}
|
||||||
if r.Group == "" {
|
if r.Group == "" {
|
||||||
return nil, errors.New("group required")
|
return nil, errors.New("group required")
|
||||||
@@ -227,7 +227,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
if r.Status != 200 && r.Status != 201 && r.Status != 202 && r.Status != 204 {
|
if r.Status != 200 && r.Status != 201 && r.Status != 202 && r.Status != 204 {
|
||||||
return nil, errors.New("@status supports 200, 201, 202 or 204")
|
return nil, errors.New("@status supports 200, 201, 202 or 204")
|
||||||
}
|
}
|
||||||
if !r.Manual && (r.Status == 204) != (r.Response == "") {
|
if !r.Manual && r.Produces == "" && (r.Status == 204) != (r.Response == "") {
|
||||||
return nil, errors.New("204 requires no response; other success statuses require a response type")
|
return nil, errors.New("204 requires no response; other success statuses require a response type")
|
||||||
}
|
}
|
||||||
if r.Method == "head" && r.Response != "" {
|
if r.Method == "head" && r.Response != "" {
|
||||||
@@ -245,7 +245,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
op.Parameters = b.Parameters()
|
op.Parameters = b.Parameters()
|
||||||
op.Request = b.BodySchema()
|
op.Request = b.BodySchema()
|
||||||
if op.Request != nil && (r.BodyMode == "raw" || r.BodyMode == "multipart") {
|
if op.Request != nil && (r.BodyMode == "raw" || r.BodyMode == "multipart") {
|
||||||
return nil, errors.New("raw/multipart request DTOs may declare only path/form/header fields; body binding is manual")
|
return nil, errors.New("raw/multipart request DTOs may declare only path/form/header fields; use the generated body stream for content")
|
||||||
}
|
}
|
||||||
if op.Request != nil && (r.Method == "get" || r.Method == "head") {
|
if op.Request != nil && (r.Method == "get" || r.Method == "head") {
|
||||||
return nil, errors.New("GET/HEAD requests cannot declare JSON bodies; use form/path/header tags")
|
return nil, errors.New("GET/HEAD requests cannot declare JSON bodies; use form/path/header tags")
|
||||||
|
|||||||
244
cmd/go-omni/apigen/contractChanges.go
Normal file
244
cmd/go-omni/apigen/contractChanges.go
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
goparser "go/parser"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Diagnostic points to preserved project code that needs attention. Review
|
||||||
|
// notices describe contract changes; errors describe definite source problems.
|
||||||
|
type Diagnostic struct {
|
||||||
|
Severity, Code, Path, Operation, Message string
|
||||||
|
Line int
|
||||||
|
Before, After string
|
||||||
|
Details []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshots describe the last successfully generated contract, never business
|
||||||
|
// source or absolute paths. They are persisted in the ownership transaction.
|
||||||
|
type contractSnapshot struct {
|
||||||
|
Routes map[string]routeContract `json:"routes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type routeContract struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Handler string `json:"handler"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
Request string `json:"request,omitempty"`
|
||||||
|
Response string `json:"response,omitempty"`
|
||||||
|
BodyMode string `json:"bodyMode,omitempty"`
|
||||||
|
Produces string `json:"produces,omitempty"`
|
||||||
|
MaxBodyBytes int64 `json:"maxBodyBytes,omitempty"`
|
||||||
|
Manual bool `json:"manual,omitempty"`
|
||||||
|
CustomBinding bool `json:"customBinding,omitempty"`
|
||||||
|
Types map[string]typeContract `json:"types,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type typeContract struct {
|
||||||
|
Fields []contractField `json:"fields,omitempty"`
|
||||||
|
Scalar string `json:"scalar,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type contractField struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotContract(spec *Spec) *contractSnapshot {
|
||||||
|
definitions := map[string]typeContract{}
|
||||||
|
for _, t := range spec.Types {
|
||||||
|
d := typeContract{}
|
||||||
|
for _, f := range t.Fields {
|
||||||
|
d.Fields = append(d.Fields, contractField{f.Name, f.Type, f.Tag})
|
||||||
|
}
|
||||||
|
definitions[t.Name] = d
|
||||||
|
}
|
||||||
|
for _, s := range spec.Scalars {
|
||||||
|
definitions[s.Name] = typeContract{Scalar: fmt.Sprintf("%s.%s (jsonInput=%s, jsonOutput=%s, text=%t)", s.GoImport, s.GoType, s.JSONInput, s.JSONOutput, s.Text)}
|
||||||
|
}
|
||||||
|
snapshot := &contractSnapshot{Routes: map[string]routeContract{}}
|
||||||
|
for _, r := range spec.Routes {
|
||||||
|
dependencies := map[string]typeContract{}
|
||||||
|
var visit func(string)
|
||||||
|
visit = func(name string) {
|
||||||
|
d, ok := definitions[name]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := dependencies[name]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dependencies[name] = d
|
||||||
|
for _, f := range d.Fields {
|
||||||
|
expr, err := goparser.ParseExpr(f.Type)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
} // Compile already validates expressions.
|
||||||
|
ast.Inspect(expr, func(n ast.Node) bool {
|
||||||
|
if id, ok := n.(*ast.Ident); ok {
|
||||||
|
visit(id.Name)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(r.Request)
|
||||||
|
visit(r.Response)
|
||||||
|
op := routeContract{
|
||||||
|
Method: strings.ToUpper(r.Method), Path: r.Path, Group: r.Group, Handler: r.Handler,
|
||||||
|
Request: r.Request, Response: r.Response, BodyMode: r.BodyMode, Produces: r.Produces,
|
||||||
|
MaxBodyBytes: r.MaxBodyBytes, Manual: r.Manual, CustomBinding: r.CustomBinding, Types: dependencies,
|
||||||
|
}
|
||||||
|
if !r.Manual {
|
||||||
|
op.Signature = expectedLogicSignature(r)
|
||||||
|
}
|
||||||
|
snapshot.Routes[r.Group+"/"+lower(r.Handler)] = op
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r routeContract) file() string {
|
||||||
|
if r.Manual {
|
||||||
|
return "internal/handler/" + r.Group + "/" + lower(r.Handler) + "Manual.go"
|
||||||
|
}
|
||||||
|
return "internal/logic/" + r.Group + "/" + lower(r.Handler) + ".go"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r routeContract) operation() string { return r.Method + " " + r.Path }
|
||||||
|
|
||||||
|
func compareContracts(old, next *contractSnapshot) []Diagnostic {
|
||||||
|
if old == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
for key, previous := range old.Routes {
|
||||||
|
current, exists := next.Routes[key]
|
||||||
|
if !exists {
|
||||||
|
diagnostics = append(diagnostics, Diagnostic{Severity: "review", Code: "routeRemoved", Path: previous.file(), Operation: previous.operation(), Message: "route removed or moved; its handwritten implementation is preserved; review obsolete code and types"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d := Diagnostic{Severity: "review", Code: "contractChanged", Path: current.file(), Operation: current.operation(), Message: "contract changed; review the preserved implementation"}
|
||||||
|
if previous.Signature != current.Signature {
|
||||||
|
d.Before, d.After = previous.Signature, current.Signature
|
||||||
|
d.Details = append(d.Details, "Logic call signature changed")
|
||||||
|
}
|
||||||
|
if previous.Method != current.Method || previous.Path != current.Path {
|
||||||
|
d.Details = append(d.Details, previous.operation()+" -> "+current.operation())
|
||||||
|
}
|
||||||
|
if previous.Manual != current.Manual || previous.CustomBinding != current.CustomBinding || previous.BodyMode != current.BodyMode || previous.MaxBodyBytes != current.MaxBodyBytes || previous.Produces != current.Produces {
|
||||||
|
d.Details = append(d.Details, fmt.Sprintf("HTTP access: manual=%t customBinding=%t body=%s maxBytes=%d produces=%q -> manual=%t customBinding=%t body=%s maxBytes=%d produces=%q", previous.Manual, previous.CustomBinding, previous.BodyMode, previous.MaxBodyBytes, previous.Produces, current.Manual, current.CustomBinding, current.BodyMode, current.MaxBodyBytes, current.Produces))
|
||||||
|
}
|
||||||
|
d.Details = append(d.Details, changedTypes(previous.Types, current.Types)...)
|
||||||
|
if len(d.Details) > 0 {
|
||||||
|
diagnostics = append(diagnostics, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
func changedTypes(old, next map[string]typeContract) []string {
|
||||||
|
names := map[string]bool{}
|
||||||
|
for name := range old {
|
||||||
|
names[name] = true
|
||||||
|
}
|
||||||
|
for name := range next {
|
||||||
|
names[name] = true
|
||||||
|
}
|
||||||
|
var details []string
|
||||||
|
for name := range names {
|
||||||
|
a, existed := old[name]
|
||||||
|
b, exists := next[name]
|
||||||
|
switch {
|
||||||
|
case !existed:
|
||||||
|
details = append(details, "DTO dependency added: "+name)
|
||||||
|
case !exists:
|
||||||
|
details = append(details, "DTO dependency removed: "+name)
|
||||||
|
case a.Scalar != b.Scalar:
|
||||||
|
details = append(details, fmt.Sprintf("scalar %s: %s -> %s", name, a.Scalar, b.Scalar))
|
||||||
|
case !reflect.DeepEqual(a.Fields, b.Fields):
|
||||||
|
oldFields, newFields := map[string]contractField{}, map[string]contractField{}
|
||||||
|
for _, f := range a.Fields {
|
||||||
|
oldFields[f.Name] = f
|
||||||
|
}
|
||||||
|
for _, f := range b.Fields {
|
||||||
|
newFields[f.Name] = f
|
||||||
|
}
|
||||||
|
before := len(details)
|
||||||
|
for field, f := range oldFields {
|
||||||
|
if n, ok := newFields[field]; !ok {
|
||||||
|
details = append(details, fmt.Sprintf("DTO %s.%s removed", name, field))
|
||||||
|
} else if f != n {
|
||||||
|
details = append(details, fmt.Sprintf("DTO %s.%s: %s `%s` -> %s `%s`", name, field, f.Type, f.Tag, n.Type, n.Tag))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for field := range newFields {
|
||||||
|
if _, ok := oldFields[field]; !ok {
|
||||||
|
details = append(details, fmt.Sprintf("DTO %s.%s added", name, field))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(details) == before {
|
||||||
|
details = append(details, "DTO "+name+" field order changed; review positional struct literals")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(details)
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortDiagnostics(diagnostics []Diagnostic) {
|
||||||
|
sort.Slice(diagnostics, func(i, j int) bool {
|
||||||
|
a, b := diagnostics[i], diagnostics[j]
|
||||||
|
if a.Path != b.Path {
|
||||||
|
return a.Path < b.Path
|
||||||
|
}
|
||||||
|
if a.Line != b.Line {
|
||||||
|
return a.Line < b.Line
|
||||||
|
}
|
||||||
|
if a.Code != b.Code {
|
||||||
|
return a.Code < b.Code
|
||||||
|
}
|
||||||
|
return a.Operation < b.Operation
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnosticErrors(diagnostics []Diagnostic) int {
|
||||||
|
count := 0
|
||||||
|
for _, d := range diagnostics {
|
||||||
|
if d.Severity == "error" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectedLogicSignature(r Route) string {
|
||||||
|
params := []string{"context.Context"}
|
||||||
|
if r.Request != "" {
|
||||||
|
params = append(params, "*types."+r.Request)
|
||||||
|
}
|
||||||
|
if r.BodyMode == "raw" || r.BodyMode == "multipart" {
|
||||||
|
params = append(params, "*httpx.Body")
|
||||||
|
}
|
||||||
|
results := []string{"error"}
|
||||||
|
if r.Produces != "" {
|
||||||
|
results = []string{"*httpx.Stream", "error"}
|
||||||
|
} else if r.Response != "" {
|
||||||
|
results = []string{"*types." + r.Response, "error"}
|
||||||
|
}
|
||||||
|
return displaySignature(upper(r.Handler), params, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displaySignature(name string, params, results []string) string {
|
||||||
|
result := strings.Join(results, ", ")
|
||||||
|
if len(results) != 1 {
|
||||||
|
result = "(" + result + ")"
|
||||||
|
}
|
||||||
|
return name + "(" + strings.Join(params, ", ") + ") " + result
|
||||||
|
}
|
||||||
308
cmd/go-omni/apigen/contractChanges_test.go
Normal file
308
cmd/go-omni/apigen/contractChanges_test.go
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func findDiagnostic(t *testing.T, out Output, code string) Diagnostic {
|
||||||
|
t.Helper()
|
||||||
|
for _, d := range out.Diagnostics {
|
||||||
|
if d.Code == code {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing %s diagnostic: %+v", code, out.Diagnostics)
|
||||||
|
return Diagnostic{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractChangesPersistUntilLogicFixed(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "app")
|
||||||
|
out, err := New(Options{Dir: dir, Module: "example.com/changes", FrameworkDir: frameworkRoot(t)})
|
||||||
|
if err != nil || len(out.Diagnostics) > 0 {
|
||||||
|
t.Fatalf("initial generation: %v %+v", err, out.Diagnostics)
|
||||||
|
}
|
||||||
|
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
|
||||||
|
logicPath := filepath.Join(dir, "internal/logic/greet/greet.go")
|
||||||
|
logicBefore := readFile(t, logicPath)
|
||||||
|
writeFile(t, o.API, strings.ReplaceAll(readFile(t, o.API), "GreetReq", "HelloReq"))
|
||||||
|
before := snapshot(t, dir)
|
||||||
|
for _, dryRun := range []bool{true, false} {
|
||||||
|
o.DryRun, o.Check = dryRun, !dryRun
|
||||||
|
out, err = Generate(o)
|
||||||
|
if dryRun && err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !dryRun && err == nil {
|
||||||
|
t.Fatal("check accepted stale project")
|
||||||
|
}
|
||||||
|
d := findDiagnostic(t, out, "logicSignature")
|
||||||
|
if d.Line <= 0 || d.Path != "internal/logic/greet/greet.go" || !strings.Contains(d.After, "*types.HelloReq") || !strings.Contains(d.Before, "*types.GreetReq") {
|
||||||
|
t.Fatalf("imprecise diagnosis: %+v", d)
|
||||||
|
}
|
||||||
|
findDiagnostic(t, out, "contractChanged")
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("preview/check changed files")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.DryRun, o.Check = false, false
|
||||||
|
out, err = Generate(o)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
findDiagnostic(t, out, "logicSignature")
|
||||||
|
if readFile(t, logicPath) != logicBefore {
|
||||||
|
t.Fatal("business source overwritten")
|
||||||
|
}
|
||||||
|
o.Check = true
|
||||||
|
out, err = Generate(o)
|
||||||
|
if err == nil || strings.Contains(err.Error(), "stale") || len(out.Changes) != 0 {
|
||||||
|
t.Fatalf("signature drift must remain after generation: %v %+v", err, out.Changes)
|
||||||
|
}
|
||||||
|
findDiagnostic(t, out, "logicSignature")
|
||||||
|
// Method location and import/parameter names are project-owned. Find the
|
||||||
|
// method in a different file, honoring aliases and named result variables.
|
||||||
|
writeFile(t, logicPath, "package greet\n// business split into visitor.go\n")
|
||||||
|
writeFile(t, filepath.Join(dir, "internal/logic/greet/visitor.go"), `package greet
|
||||||
|
import (ctx "context"; dto "example.com/changes/internal/types")
|
||||||
|
func(s *Logic)Greet(input ctx.Context, request *dto.HelloReq)(output *dto.GreetResp, problem error){
|
||||||
|
return &dto.GreetResp{Message:request.Name},nil
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
out, err = Generate(o)
|
||||||
|
if err != nil || len(out.Diagnostics) > 0 {
|
||||||
|
t.Fatalf("compatible business method rejected: %v %+v", err, out.Diagnostics)
|
||||||
|
}
|
||||||
|
before = snapshot(t, dir)
|
||||||
|
o.Check = false
|
||||||
|
if _, err = Generate(o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("stable generation changed files")
|
||||||
|
}
|
||||||
|
// Field review notices should point to the actual moved business method.
|
||||||
|
writeFile(t, o.API, strings.Replace(readFile(t, o.API), `max=80`, `max=90`, 1))
|
||||||
|
o.DryRun = true
|
||||||
|
out, err = Generate(o)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d := findDiagnostic(t, out, "contractChanged")
|
||||||
|
if d.Path != "internal/logic/greet/visitor.go" || d.Line == 0 {
|
||||||
|
t.Fatalf("wrong implementation location: %+v", d)
|
||||||
|
}
|
||||||
|
o.DryRun = false
|
||||||
|
if _, err = Generate(o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./...")
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||||
|
if data, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("fixed generated consumer: %v\n%s", err, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractChangesReachNestedDTOs(t *testing.T) {
|
||||||
|
source := `syntax = "v1"
|
||||||
|
type Address { Street string ` + "`json:\"street\"`" + ` }
|
||||||
|
type Request { Address Address ` + "`json:\"address\"`" + ` }
|
||||||
|
type Reply { Accepted bool ` + "`json:\"accepted\"`" + ` }
|
||||||
|
@server(group: orders)
|
||||||
|
service orders-api {
|
||||||
|
@handler create
|
||||||
|
post /orders (Request) returns (Reply)
|
||||||
|
@handler ping
|
||||||
|
get /ping
|
||||||
|
}
|
||||||
|
`
|
||||||
|
root := t.TempDir()
|
||||||
|
api := filepath.Join(root, "source/service.api")
|
||||||
|
writeFile(t, api, source)
|
||||||
|
dir := filepath.Join(root, "app")
|
||||||
|
if _, err := Generate(Options{API: api, Dir: dir, Module: "example.com/nested", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
o := Options{API: filepath.Join(dir, "api/service.api"), Dir: dir, DryRun: true}
|
||||||
|
writeFile(t, o.API, strings.Replace(source, "Street string", "Street int64", 1))
|
||||||
|
out, err := Generate(o)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out.Diagnostics) != 1 {
|
||||||
|
t.Fatalf("unrelated routes should not be warned: %+v", out.Diagnostics)
|
||||||
|
}
|
||||||
|
d := findDiagnostic(t, out, "contractChanged")
|
||||||
|
if d.Path != "internal/logic/orders/create.go" || !strings.Contains(strings.Join(d.Details, "\n"), "Address.Street: string") {
|
||||||
|
t.Fatalf("nested field not explained: %+v", d)
|
||||||
|
}
|
||||||
|
// Import/file/comment/formatting changes are not semantic DTO changes.
|
||||||
|
writeFile(t, o.API, source+"\n// a harmless documentation edit\n")
|
||||||
|
out, err = Generate(o)
|
||||||
|
if err != nil || len(out.Diagnostics) > 0 {
|
||||||
|
t.Fatalf("comment caused false diagnostic: %v %+v", err, out.Diagnostics)
|
||||||
|
}
|
||||||
|
// Removal preserves the old implementation and explicitly points it out.
|
||||||
|
writeFile(t, o.API, strings.Replace(source, " @handler create\n post /orders (Request) returns (Reply)\n", "", 1))
|
||||||
|
out, err = Generate(o)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d = findDiagnostic(t, out, "routeRemoved")
|
||||||
|
if d.Path != "internal/logic/orders/create.go" {
|
||||||
|
t.Fatal(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractSnapshotSemanticDetails(t *testing.T) {
|
||||||
|
base := &Spec{Types: []Type{{Name: "Req", Fields: []Field{{Name: "ID", Type: "ID", Tag: `form:"id"`}}}}, Scalars: []ScalarDefinition{{Name: "ID", GoImport: "example.com/id", GoType: "Value", JSONInput: "string", JSONOutput: "string"}}, Routes: []Route{{Group: "files", Handler: "send", Method: "post", Path: "/file", Request: "Req", BodyMode: "raw", MaxBodyBytes: 64, Produces: "text/plain"}}}
|
||||||
|
old := snapshotContract(base)
|
||||||
|
base.Scalars[0].Text = true
|
||||||
|
base.Routes[0].BodyMode = "multipart"
|
||||||
|
base.Routes[0].Produces = "application/octet-stream"
|
||||||
|
base.Routes[0].MaxBodyBytes = 128
|
||||||
|
base.Routes[0].Path = "/files"
|
||||||
|
diagnostics := compareContracts(old, snapshotContract(base))
|
||||||
|
if len(diagnostics) != 1 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
details := strings.Join(diagnostics[0].Details, "\n")
|
||||||
|
for _, want := range []string{"scalar ID", "body=raw", "body=multipart", "POST /file -> POST /files", "maxBytes=128"} {
|
||||||
|
if !strings.Contains(details, want) {
|
||||||
|
t.Fatal("missing", want, details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base.Routes[0].Produces = ""
|
||||||
|
diagnostics = compareContracts(old, snapshotContract(base))
|
||||||
|
if !strings.Contains(diagnostics[0].Before, "*httpx.Stream") || !strings.HasSuffix(diagnostics[0].After, ") error") {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogicDiagnosticsSourceBoundaries(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ name, source, code, severity string }{
|
||||||
|
{"wrongResult", `package greet; import "context"; func(s *Logic)Greet(context.Context) error{return nil}`, "logicSignature", "error"},
|
||||||
|
{"syntax", `package greet; func(s *Logic)Greet( {`, "logicSyntax", "error"},
|
||||||
|
{"alias", `package greet; import("context";"example.com/test/internal/types");type Input = types.GreetReq; func(s *Logic)Greet(context.Context,*Input)(*types.GreetResp,error){return nil,nil}`, "logicUnchecked", "review"},
|
||||||
|
{"conditional", "//go:build projectTag\n\npackage greet\n", "logicUnchecked", "review"},
|
||||||
|
{"missing", "package greet\n", "logicNotFound", "review"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "app")
|
||||||
|
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, "internal/logic/greet/greet.go"), tc.source)
|
||||||
|
before := snapshot(t, dir)
|
||||||
|
out, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true})
|
||||||
|
d := findDiagnostic(t, out, tc.code)
|
||||||
|
if d.Severity != tc.severity || (tc.severity == "error") != (err != nil) {
|
||||||
|
t.Fatalf("unexpected check result: %v %+v", err, d)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("analysis modified business source")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractSnapshotBootstrapAndDeterminism(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "app")
|
||||||
|
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, manifestPath)
|
||||||
|
var previous manifest
|
||||||
|
if err := json.Unmarshal([]byte(readFile(t, path)), &previous); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
previous.Contract = nil
|
||||||
|
data, _ := json.MarshalIndent(previous, "", " ")
|
||||||
|
writeFile(t, path, string(data)+"\n")
|
||||||
|
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), DryRun: true}
|
||||||
|
a, err := Generate(o)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := Generate(o)
|
||||||
|
if err != nil || !reflect.DeepEqual(a, b) {
|
||||||
|
t.Fatal("non-deterministic preview", err)
|
||||||
|
}
|
||||||
|
findDiagnostic(t, a, "contractBaseline")
|
||||||
|
o.DryRun = false
|
||||||
|
if _, err = Generate(o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
o.Check = true
|
||||||
|
out, err := Generate(o)
|
||||||
|
if err != nil || len(out.Diagnostics) > 0 {
|
||||||
|
t.Fatal(err, out.Diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogicResponseAndBodySignatureChanges(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, after string
|
||||||
|
change func(string) string
|
||||||
|
}{
|
||||||
|
{"responseType", "*types.HelloResp", func(s string) string { return strings.ReplaceAll(s, "GreetResp", "HelloResp") }},
|
||||||
|
{"bodyParameter", "*httpx.Body", func(s string) string {
|
||||||
|
return strings.Replace(s, " get /greet", " @body (mode: raw; maxBytes: 64)\n post /greet", 1)
|
||||||
|
}},
|
||||||
|
{"streamResult", "*httpx.Stream", func(s string) string {
|
||||||
|
return strings.Replace(strings.Replace(s, " returns (GreetResp)", "", 1), " get /greet", " @produces \"text/plain\"\n get /greet", 1)
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "app")
|
||||||
|
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), DryRun: true}
|
||||||
|
writeFile(t, o.API, tc.change(readFile(t, o.API)))
|
||||||
|
out, err := Generate(o)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d := findDiagnostic(t, out, "logicSignature")
|
||||||
|
if !strings.Contains(d.After, tc.after) {
|
||||||
|
t.Fatal(d)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogicDuplicateAndTestFiles(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "app")
|
||||||
|
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source := readFile(t, filepath.Join(dir, "internal/logic/greet/greet.go"))
|
||||||
|
// String literals mentioning build constraints do not make a file conditional.
|
||||||
|
writeFile(t, filepath.Join(dir, "internal/logic/greet/greet.go"), source+"\nconst diagnosticText=\"//go:build special\"\n")
|
||||||
|
writeFile(t, filepath.Join(dir, "internal/logic/greet/fixture_test.go"), source)
|
||||||
|
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}
|
||||||
|
out, err := Generate(o)
|
||||||
|
if err != nil || len(out.Diagnostics) > 0 {
|
||||||
|
t.Fatal(err, out.Diagnostics)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, "internal/logic/greet/duplicate.go"), source)
|
||||||
|
out, err = Generate(o)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("duplicate method passed check")
|
||||||
|
}
|
||||||
|
d := findDiagnostic(t, out, "logicDuplicate")
|
||||||
|
if len(d.Details) != 2 || !strings.Contains(d.Details[0], "duplicate.go:") {
|
||||||
|
t.Fatal(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,6 +120,9 @@ func prepareMetadata(original *Spec) (*Spec, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func decorateDocument(doc map[string]any, spec *Spec) {
|
func decorateDocument(doc map[string]any, spec *Spec) {
|
||||||
|
if spec.DefaultResponse != "" {
|
||||||
|
doc["x-omni-default-response"] = spec.DefaultResponse
|
||||||
|
}
|
||||||
if spec.Description != "" {
|
if spec.Description != "" {
|
||||||
doc["info"].(map[string]any)["description"] = spec.Description
|
doc["info"].(map[string]any)["description"] = spec.Description
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import "testing"
|
|||||||
|
|
||||||
func TestErrorInheritanceConsumer(t *testing.T) {
|
func TestErrorInheritanceConsumer(t *testing.T) {
|
||||||
testCatalogConsumer(t, "errorInheritance.api", "errorInheritance", map[string]string{
|
testCatalogConsumer(t, "errorInheritance.api", "errorInheritance", map[string]string{
|
||||||
"internal/service/account/plain.go": "service.go",
|
"internal/logic/account/plain.go": "service.go",
|
||||||
"internal/service/account/raw.go": "emptyService.go",
|
"internal/logic/account/raw.go": "emptyService.go",
|
||||||
"internal/service/account/cleared.go": "emptyService.go",
|
"internal/logic/account/cleared.go": "emptyService.go",
|
||||||
"internal/handler/account/boundedManual.go": "bounded.go",
|
"internal/handler/account/boundedManual.go": "bounded.go",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||||
const minimumVersion = "v0.11.0-preview"
|
const minimumVersion = "v0.12.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 {
|
||||||
@@ -35,6 +35,7 @@ type FileChange struct {
|
|||||||
type Output struct {
|
type Output struct {
|
||||||
Generated, Created, Preserved, Removed []string
|
Generated, Created, Preserved, Removed []string
|
||||||
Changes []FileChange
|
Changes []FileChange
|
||||||
|
Diagnostics []Diagnostic
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate creates or safely updates one contract-driven project. It renders
|
// Generate creates or safely updates one contract-driven project. It renders
|
||||||
@@ -140,13 +141,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, "errors", "params.go")); err == nil {
|
if _, err := os.Stat(filepath.Join(path, "httpx", "transfer.go")); err == nil {
|
||||||
localCurrent = true
|
localCurrent = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !localCurrent {
|
if !localCurrent {
|
||||||
return Output{}, fmt.Errorf("existing framework dependency predates error template parameter support; use %s or a current local replacement", minimumVersion)
|
return Output{}, fmt.Errorf("existing framework dependency predates managed startup and streaming support; use %s or a current local replacement", minimumVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if o.FrameworkDir != "" {
|
if o.FrameworkDir != "" {
|
||||||
@@ -226,7 +227,7 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile())
|
return apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec))
|
||||||
}
|
}
|
||||||
|
|
||||||
func moduleFile(o Options) ([]byte, error) {
|
func moduleFile(o Options) ([]byte, error) {
|
||||||
@@ -367,11 +368,11 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
|||||||
}
|
}
|
||||||
policySource += fmt.Sprintf("%q: newResponseTemplate(%q),\n", definition.Name, string(data))
|
policySource += fmt.Sprintf("%q: newResponseTemplate(%q),\n", definition.Name, string(data))
|
||||||
}
|
}
|
||||||
policySource += "}\nfunc newResponseTemplate(raw string)response.Encoder{var cfg response.Config;decoder:=json.NewDecoder(strings.NewReader(raw));decoder.UseNumber();if err:=decoder.Decode(&cfg);err!=nil{panic(err)};return response.MustNew(cfg)}\n"
|
policySource += "}\nfunc newResponseTemplate(raw string)*response.Formatter{var cfg response.Config;decoder:=json.NewDecoder(strings.NewReader(raw));decoder.UseNumber();if err:=decoder.Decode(&cfg);err!=nil{panic(err)};return response.MustNew(cfg)}\n"
|
||||||
policySource += "func responseEncoder(name string,raw bool,catalogJSON string)response.Encoder{encoder:=responseTemplates[name];if encoder==nil{panic(\"unknown response template\")};var catalog omnierrors.Catalog;if err:=json.Unmarshal([]byte(catalogJSON),&catalog);err!=nil{panic(err)};if len(catalog.Definitions)>0{encoder=response.MustWithErrors(catalog,encoder)};if raw{encoder=response.RawJSON(encoder)};return encoder}\n"
|
policySource += "func responseEncoder(name string,raw bool,catalogJSON string)response.Encoder{encoder:=responseTemplates[name];if encoder==nil{panic(\"unknown response template\")};var catalog omnierrors.Catalog;if err:=json.Unmarshal([]byte(catalogJSON),&catalog);err!=nil{panic(err)};if len(catalog.Definitions)>0{encoder=response.MustWithErrors(catalog,encoder)};if raw{encoder=response.RawJSON(encoder)};return encoder}\n"
|
||||||
put(generated, "internal/handler/responseGen.go", generatedHeader+policySource)
|
put(generated, "internal/handler/responseGen.go", generatedHeader+policySource)
|
||||||
imports := fmt.Sprintf("import (\"fmt\"; %q; %q; %q;", frameworkModule+"/httpx", frameworkModule+"/openapi", o.Module+"/internal/servicecontext")
|
imports := fmt.Sprintf("import (\"fmt\"; %q; %q; %q;", frameworkModule+"/httpx", frameworkModule+"/openapi", o.Module+"/internal/svc")
|
||||||
routes := "func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) error {\n if router==nil || deps==nil {return fmt.Errorf(\"router and dependencies required\")}\n"
|
routes := "func RegisterRoutes(router openapi.Registrar, deps *svc.ServiceContext) error {\n if router==nil || deps==nil {return fmt.Errorf(\"router and dependencies required\")}\n"
|
||||||
if len(c.Spec.Scalars) > 0 {
|
if len(c.Spec.Scalars) > 0 {
|
||||||
imports += fmt.Sprintf("%q;%q;", frameworkModule+"/contract", o.Module+"/internal/types")
|
imports += fmt.Sprintf("%q;%q;", frameworkModule+"/contract", o.Module+"/internal/types")
|
||||||
routes += "if err:=contract.ValidateScalars(types.ContractScalars()...);err!=nil{return err}\n"
|
routes += "if err:=contract.ValidateScalars(types.ContractScalars()...);err!=nil{return err}\n"
|
||||||
@@ -391,7 +392,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
|||||||
routes += fmt.Sprintf("if deps.Middlewares[%q]==nil {return fmt.Errorf(%q)}\n", m, "missing middleware "+m)
|
routes += fmt.Sprintf("if deps.Middlewares[%q]==nil {return fmt.Errorf(%q)}\n", m, "missing middleware "+m)
|
||||||
}
|
}
|
||||||
for i, group := range c.Groups {
|
for i, group := range c.Groups {
|
||||||
imports += fmt.Sprintf("h%d %q; s%d %q;", i, o.Module+"/internal/handler/"+group, i, o.Module+"/internal/service/"+group)
|
imports += fmt.Sprintf("h%d %q; s%d %q;", i, o.Module+"/internal/handler/"+group, i, o.Module+"/internal/logic/"+group)
|
||||||
routes += fmt.Sprintf("h%d := h%d.New(s%d.New(deps),deps)\n", i, i, i)
|
routes += fmt.Sprintf("h%d := h%d.New(s%d.New(deps),deps)\n", i, i, i)
|
||||||
groupRoutes := []Route{}
|
groupRoutes := []Route{}
|
||||||
for _, r := range c.Spec.Routes {
|
for _, r := range c.Spec.Routes {
|
||||||
@@ -410,7 +411,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
|||||||
needsTypes = needsTypes || r.Request != "" || r.Response != ""
|
needsTypes = needsTypes || r.Request != "" || r.Response != ""
|
||||||
needsBinder = needsBinder || (r.Request != "" && !r.CustomBinding)
|
needsBinder = needsBinder || (r.Request != "" && !r.CustomBinding)
|
||||||
}
|
}
|
||||||
text += fmt.Sprintf("%q;%q;", frameworkModule+"/httpx", o.Module+"/internal/servicecontext")
|
text += fmt.Sprintf("%q;%q;", frameworkModule+"/httpx", o.Module+"/internal/svc")
|
||||||
if needsTypes {
|
if needsTypes {
|
||||||
text += fmt.Sprintf("%q;", o.Module+"/internal/types")
|
text += fmt.Sprintf("%q;", o.Module+"/internal/types")
|
||||||
}
|
}
|
||||||
@@ -418,13 +419,13 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
|||||||
text += fmt.Sprintf("%q;", frameworkModule+"/contract")
|
text += fmt.Sprintf("%q;", frameworkModule+"/contract")
|
||||||
}
|
}
|
||||||
text += ")\n"
|
text += ")\n"
|
||||||
text += "type Service interface {\n"
|
text += "type Logic interface {\n"
|
||||||
for _, r := range groupRoutes {
|
for _, r := range groupRoutes {
|
||||||
if !r.Manual {
|
if !r.Manual {
|
||||||
text += signature(r) + "\n"
|
text += signature(r) + "\n"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
text += "}\ntype Handler struct {service Service;deps *servicecontext.Context}\nfunc New(s Service,deps ...*servicecontext.Context)*Handler {h:=&Handler{service:s};if len(deps)>0{h.deps=deps[0]};return h}\n"
|
text += "}\ntype Handler struct {logic Logic;deps *svc.ServiceContext}\nfunc New(s Logic,deps ...*svc.ServiceContext)*Handler {h:=&Handler{logic:s};if len(deps)>0{h.deps=deps[0]};return h}\n"
|
||||||
for _, r := range groupRoutes {
|
for _, r := range groupRoutes {
|
||||||
name := upper(r.Handler)
|
name := upper(r.Handler)
|
||||||
// The response policy and body reader cap are installed before App middleware.
|
// The response policy and body reader cap are installed before App middleware.
|
||||||
@@ -463,32 +464,53 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
|||||||
}
|
}
|
||||||
call += ", &req"
|
call += ", &req"
|
||||||
}
|
}
|
||||||
if r.Response != "" {
|
streamingBody := r.BodyMode == "raw" || r.BodyMode == "multipart"
|
||||||
text += fmt.Sprintf("resp,err:=h.service.%s(%s);if err!=nil{return err};if resp==nil{return fmt.Errorf(\"%s returned a nil response\")}\n", name, call, name)
|
if streamingBody {
|
||||||
|
text += fmt.Sprintf("body,err:=httpx.NewBody(c.Request(),%q);if err!=nil{return err}\n", r.BodyMode)
|
||||||
|
call += ",body"
|
||||||
|
}
|
||||||
|
if r.Produces != "" {
|
||||||
|
text += fmt.Sprintf("resp,err:=h.logic.%s(%s);if err!=nil{if resp!=nil{_ = resp.Close()};return err}\n", name, call)
|
||||||
|
if streamingBody {
|
||||||
|
text += "if err:=body.Complete();err!=nil{if resp!=nil{_ =resp.Close()};return err}\n"
|
||||||
|
}
|
||||||
|
text += fmt.Sprintf("return c.WriteStream(%d,%q,resp)\n}\n", r.Status, r.Produces)
|
||||||
|
} else if r.Response != "" {
|
||||||
|
text += fmt.Sprintf("resp,err:=h.logic.%s(%s);if err!=nil{return err};if resp==nil{return fmt.Errorf(\"%s returned a nil response\")}\n", name, call, name)
|
||||||
|
if streamingBody {
|
||||||
|
text += "if err:=body.Complete();err!=nil{return err}\n"
|
||||||
|
}
|
||||||
method := map[int]string{200: "OK", 201: "Created", 202: "Accepted"}[r.Status]
|
method := map[int]string{200: "OK", 201: "Created", 202: "Accepted"}[r.Status]
|
||||||
text += "return c." + method + "(resp)\n}\n"
|
text += "return c." + method + "(resp)\n}\n"
|
||||||
} else {
|
} else {
|
||||||
text += fmt.Sprintf("if err:=h.service.%s(%s);err!=nil{return err};return c.NoContent()\n}\n", name, call)
|
text += fmt.Sprintf("if err:=h.logic.%s(%s);err!=nil{return err};", name, call)
|
||||||
|
if streamingBody {
|
||||||
|
text += "if err:=body.Complete();err!=nil{return err};"
|
||||||
|
}
|
||||||
|
text += "return c.NoContent()\n}\n"
|
||||||
}
|
}
|
||||||
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 != "" || r.Response != "" {
|
if r.Request != "" || r.Response != "" {
|
||||||
service += fmt.Sprintf("%q;", o.Module+"/internal/types")
|
service += fmt.Sprintf("%q;", o.Module+"/internal/types")
|
||||||
}
|
}
|
||||||
|
if streamingBody || r.Produces != "" {
|
||||||
|
service += fmt.Sprintf("%q;", frameworkModule+"/httpx")
|
||||||
|
}
|
||||||
service += ")\n"
|
service += ")\n"
|
||||||
service += "// " + name + " implements the business operation. This file is never overwritten.\nfunc(s *Service) " + signature(r) + " {\n"
|
service += "// " + name + " implements the business operation. This file is never overwritten.\nfunc(s *Logic) " + signature(r) + " {\n"
|
||||||
if starter && r.Handler == "greet" {
|
if starter && r.Handler == "greet" {
|
||||||
service = strings.Replace(service, "apierrors "+strconv.Quote(frameworkModule+"/errors")+";", "\"fmt\";", 1)
|
service = strings.Replace(service, "apierrors "+strconv.Quote(frameworkModule+"/errors")+";", "\"fmt\";", 1)
|
||||||
service += "s.deps.Logger.InfoContext(ctx,\"greet.called\")\nreturn &types.GreetResp{Message:fmt.Sprintf(\"Hello %s\",req.Name)},nil\n}"
|
service += "s.deps.Logger.InfoContext(ctx,\"greet.called\")\nreturn &types.GreetResp{Message:fmt.Sprintf(\"Hello %s\",req.Name)},nil\n}"
|
||||||
} else {
|
} else {
|
||||||
service += "// TODO: implement business behavior and use s.deps for dependencies.\n"
|
service += "// TODO: implement business behavior and use s.deps for dependencies.\n"
|
||||||
if r.Response != "" {
|
if r.Response != "" || r.Produces != "" {
|
||||||
service += "return nil, "
|
service += "return nil, "
|
||||||
} else {
|
} else {
|
||||||
service += "return "
|
service += "return "
|
||||||
}
|
}
|
||||||
service += "apierrors.New(501,\"NOT_IMPLEMENTED\",\"operation is not implemented\")\n}"
|
service += "apierrors.New(501,\"NOT_IMPLEMENTED\",\"operation is not implemented\")\n}"
|
||||||
}
|
}
|
||||||
put(seeds, "internal/service/"+group+"/"+lower(r.Handler)+".go", service)
|
put(seeds, "internal/logic/"+group+"/"+lower(r.Handler)+".go", service)
|
||||||
}
|
}
|
||||||
if !hasAutomatic {
|
if !hasAutomatic {
|
||||||
text = strings.Replace(text, strconv.Quote(frameworkModule+"/httpx")+";", "", 1)
|
text = strings.Replace(text, strconv.Quote(frameworkModule+"/httpx")+";", "", 1)
|
||||||
@@ -498,7 +520,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
|||||||
text = strings.Replace(text, "\"fmt\";", "", 1)
|
text = strings.Replace(text, "\"fmt\";", "", 1)
|
||||||
}
|
}
|
||||||
put(generated, "internal/handler/"+group+"/handlersGen.go", generatedHeader+text)
|
put(generated, "internal/handler/"+group+"/handlersGen.go", generatedHeader+text)
|
||||||
put(seeds, "internal/service/"+group+"/service.go", fmt.Sprintf("package %s\nimport %q\ntype Service struct {deps *servicecontext.Context}\nfunc New(deps *servicecontext.Context)*Service{return &Service{deps:deps}}\n", packageName, o.Module+"/internal/servicecontext"))
|
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")
|
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")
|
||||||
@@ -515,7 +537,11 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
|||||||
if name == "etc/config.yaml" && o.LogOutput == "file" {
|
if name == "etc/config.yaml" && o.LogOutput == "file" {
|
||||||
content = strings.Replace(content, "output: file", "output: stderr\n file: ./var/log/server.log\n rotation:\n enabled: true\n maxSizeMb: 100\n maxBackups: 10\n maxAgeDays: 7\n compress: true", 1)
|
content = strings.Replace(content, "output: file", "output: stderr\n file: ./var/log/server.log\n rotation:\n enabled: true\n maxSizeMb: 100\n maxBackups: 10\n maxAgeDays: 7\n compress: true", 1)
|
||||||
}
|
}
|
||||||
put(seeds, name, content)
|
if name == "main.go" {
|
||||||
|
put(generated, name, generatedHeader+content)
|
||||||
|
} else {
|
||||||
|
put(seeds, name, content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
infra, err := scaffold.APIInfrastructure(o.Module, o.Database, o.Storage, o.LogOutput, o.LogFormat)
|
infra, err := scaffold.APIInfrastructure(o.Module, o.Database, o.Storage, o.LogOutput, o.LogFormat)
|
||||||
@@ -531,17 +557,14 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
|||||||
return nil, nil, errors.New("/storage/object is reserved by local storage")
|
return nil, nil, errors.New("/storage/object is reserved by local storage")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
main := string(seeds["cmd/server/main.go"])
|
|
||||||
main = strings.Replace(main, " if err=handler.RegisterRoutes", ` for _,method:=range []string{"GET","HEAD","PUT"}{app.Handle(method,"/storage/object",func(c *httpx.Context)error{deps.FileHTTP.ServeHTTP(c.Raw(),c.Request());return nil})}
|
|
||||||
if err=handler.RegisterRoutes`, 1)
|
|
||||||
main = strings.Replace(main, `"git.yuntuo.hk/k2safe/go-omni/logx"`, `"git.yuntuo.hk/k2safe/go-omni/logx"; "git.yuntuo.hk/k2safe/go-omni/httpx"`, 1)
|
|
||||||
seeds["cmd/server/main.go"] = []byte(main)
|
|
||||||
}
|
}
|
||||||
|
put(generated, "internal/handler/appGen.go", generatedHeader+appSource(o, c.Spec))
|
||||||
|
|
||||||
return generated, seeds, nil
|
return generated, seeds, nil
|
||||||
}
|
}
|
||||||
func hasResponse(routes []Route) bool {
|
func hasResponse(routes []Route) bool {
|
||||||
for _, r := range routes {
|
for _, r := range routes {
|
||||||
if r.Response != "" && !r.Manual {
|
if r.Response != "" && !r.Manual && r.Produces == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -552,7 +575,13 @@ func signature(r Route) string {
|
|||||||
if r.Request != "" {
|
if r.Request != "" {
|
||||||
s += ",req *types." + r.Request
|
s += ",req *types." + r.Request
|
||||||
}
|
}
|
||||||
|
if r.BodyMode == "raw" || r.BodyMode == "multipart" {
|
||||||
|
s += ",body *httpx.Body"
|
||||||
|
}
|
||||||
s += ") "
|
s += ") "
|
||||||
|
if r.Produces != "" {
|
||||||
|
return s + "(*httpx.Stream,error)"
|
||||||
|
}
|
||||||
if r.Response != "" {
|
if r.Response != "" {
|
||||||
s += "(*types." + r.Response + ",error)"
|
s += "(*types." + r.Response + ",error)"
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
240
cmd/go-omni/apigen/logicCheck.go
Normal file
240
cmd/go-omni/apigen/logicCheck.go
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/build"
|
||||||
|
"go/format"
|
||||||
|
goparser "go/parser"
|
||||||
|
"go/scanner"
|
||||||
|
gotoken "go/token"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type logicMethod struct {
|
||||||
|
path, signature string
|
||||||
|
line int
|
||||||
|
known bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkLogic inspects source only: no project code, init hooks, Go commands or
|
||||||
|
// dependency downloads are executed. Seeds participate only when files do not
|
||||||
|
// already exist, matching apply's ownership rules.
|
||||||
|
func checkLogic(root, module string, contract *contractSnapshot, seeds map[string][]byte) ([]Diagnostic, map[string]logicMethod) {
|
||||||
|
groups := map[string][]routeContract{}
|
||||||
|
knownTypes := map[string]bool{}
|
||||||
|
for _, r := range contract.Routes {
|
||||||
|
if !r.Manual {
|
||||||
|
groups[r.Group] = append(groups[r.Group], r)
|
||||||
|
}
|
||||||
|
for name := range r.Types {
|
||||||
|
knownTypes[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Project-owned aliases may make differently named DTOs identical. Let
|
||||||
|
// the compiler handle such cases rather than rejecting valid Go aliases.
|
||||||
|
typeExtensions := false
|
||||||
|
if entries, err := os.ReadDir(filepath.Join(root, "internal/types")); err == nil {
|
||||||
|
for _, e := range entries {
|
||||||
|
if sourceFile(e.Name()) && e.Name() != "typesGen.go" && e.Name() != "scalarsGen.go" {
|
||||||
|
typeExtensions = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
locations := map[string]logicMethod{}
|
||||||
|
for group, routes := range groups {
|
||||||
|
directory := "internal/logic/" + group
|
||||||
|
files := map[string][]byte{}
|
||||||
|
uncertain := false
|
||||||
|
entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(directory)))
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
diagnostics = append(diagnostics, Diagnostic{Severity: "error", Code: "logicUnreadable", Path: directory, Message: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !sourceFile(e.Name()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := directory + "/" + e.Name()
|
||||||
|
if err := safePath(root, name); err != nil {
|
||||||
|
diagnostics = append(diagnostics, Diagnostic{Severity: "error", Code: "logicUnreadable", Path: name, Message: err.Error()})
|
||||||
|
uncertain = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name)))
|
||||||
|
if err != nil {
|
||||||
|
diagnostics = append(diagnostics, Diagnostic{Severity: "error", Code: "logicUnreadable", Path: name, Message: err.Error()})
|
||||||
|
uncertain = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files[name] = data
|
||||||
|
}
|
||||||
|
for name, data := range seeds {
|
||||||
|
if filepath.ToSlash(filepath.Dir(name)) == directory && sourceFile(filepath.Base(name)) {
|
||||||
|
if _, exists := files[name]; !exists {
|
||||||
|
files[name] = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
methods := map[string][]logicMethod{}
|
||||||
|
var names []string
|
||||||
|
for name := range files {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, name := range names {
|
||||||
|
data := files[name]
|
||||||
|
// Do not guess the user's GOFLAGS/build-tag combination. Flag the
|
||||||
|
// group for compiler verification if conditional files are present.
|
||||||
|
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(name))); err == nil {
|
||||||
|
match, err := build.Default.MatchFile(filepath.Join(root, filepath.FromSlash(directory)), filepath.Base(name))
|
||||||
|
if err != nil || !match {
|
||||||
|
uncertain = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
positions := gotoken.NewFileSet()
|
||||||
|
file, err := goparser.ParseFile(positions, name, data, goparser.SkipObjectResolution|goparser.ParseComments)
|
||||||
|
if err != nil {
|
||||||
|
d := Diagnostic{Severity: "error", Code: "logicSyntax", Path: name, Message: "cannot parse Logic source: " + err.Error()}
|
||||||
|
if list, ok := err.(scanner.ErrorList); ok && len(list) > 0 {
|
||||||
|
d.Line = list[0].Pos.Line
|
||||||
|
}
|
||||||
|
diagnostics = append(diagnostics, d)
|
||||||
|
uncertain = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
conditional := false
|
||||||
|
for _, group := range file.Comments {
|
||||||
|
for _, comment := range group.List {
|
||||||
|
if comment.Pos() < file.Package && (strings.HasPrefix(comment.Text, "//go:build ") || strings.HasPrefix(comment.Text, "// +build ")) {
|
||||||
|
conditional = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if conditional {
|
||||||
|
uncertain = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
imports := map[string]string{}
|
||||||
|
for _, imp := range file.Imports {
|
||||||
|
path, err := strconv.Unquote(imp.Path.Value)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alias := filepath.Base(path)
|
||||||
|
if imp.Name != nil {
|
||||||
|
alias = imp.Name.Name
|
||||||
|
}
|
||||||
|
imports[alias] = path
|
||||||
|
}
|
||||||
|
for _, decl := range file.Decls {
|
||||||
|
fn, ok := decl.(*ast.FuncDecl)
|
||||||
|
if !ok || fn.Recv == nil || len(fn.Recv.List) != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
receiver := fn.Recv.List[0].Type
|
||||||
|
if pointer, ok := receiver.(*ast.StarExpr); ok {
|
||||||
|
receiver = pointer.X
|
||||||
|
}
|
||||||
|
id, ok := receiver.(*ast.Ident)
|
||||||
|
if !ok || id.Name != "Logic" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
params, a := canonicalFields(fn.Type.Params, imports, module, knownTypes, typeExtensions)
|
||||||
|
results, b := canonicalFields(fn.Type.Results, imports, module, knownTypes, typeExtensions)
|
||||||
|
methods[fn.Name.Name] = append(methods[fn.Name.Name], logicMethod{name, displaySignature(fn.Name.Name, params, results), positions.Position(fn.Pos()).Line, a && b})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range routes {
|
||||||
|
found := methods[upper(r.Handler)]
|
||||||
|
if len(found) == 1 && !uncertain {
|
||||||
|
locations[r.file()] = found[0]
|
||||||
|
}
|
||||||
|
d := Diagnostic{Path: r.file(), Operation: r.operation(), After: r.Signature}
|
||||||
|
switch {
|
||||||
|
case uncertain:
|
||||||
|
d.Severity, d.Code, d.Message = "review", "logicUnchecked", "conditional or unreadable source prevents a complete signature check; run go test ./... with your build settings"
|
||||||
|
case len(found) == 0:
|
||||||
|
d.Severity, d.Code, d.Message = "review", "logicNotFound", "no direct Logic method found; check embedded/aliased implementations with go test ./..."
|
||||||
|
case len(found) > 1:
|
||||||
|
d.Severity, d.Code, d.Message = "error", "logicDuplicate", "multiple Logic methods have this name"
|
||||||
|
for _, m := range found {
|
||||||
|
d.Details = append(d.Details, fmt.Sprintf("%s:%d", m.path, m.line))
|
||||||
|
}
|
||||||
|
case !found[0].known:
|
||||||
|
d.Severity, d.Code, d.Message = "review", "logicUnchecked", "signature uses project-specific type names; verify aliases with go test ./..."
|
||||||
|
d.Path, d.Line, d.Before = found[0].path, found[0].line, found[0].signature
|
||||||
|
case found[0].signature != r.Signature:
|
||||||
|
d.Severity, d.Code, d.Message = "error", "logicSignature", "preserved Logic signature differs from the generated call; update this method"
|
||||||
|
d.Path, d.Line, d.Before = found[0].path, found[0].line, found[0].signature
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
diagnostics = append(diagnostics, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diagnostics, locations
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceFile(name string) bool {
|
||||||
|
return strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go") && !strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalFields(fields *ast.FieldList, imports map[string]string, module string, knownTypes map[string]bool, extensions bool) ([]string, bool) {
|
||||||
|
var values []string
|
||||||
|
known := true
|
||||||
|
if fields != nil {
|
||||||
|
for _, field := range fields.List {
|
||||||
|
value, ok := canonicalType(field.Type, imports, module, knownTypes, extensions)
|
||||||
|
known = known && ok
|
||||||
|
count := len(field.Names)
|
||||||
|
if count == 0 {
|
||||||
|
count = 1
|
||||||
|
}
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values, known
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalType(expr ast.Expr, imports map[string]string, module string, knownTypes map[string]bool, extensions bool) (string, bool) {
|
||||||
|
switch e := expr.(type) {
|
||||||
|
case *ast.ParenExpr:
|
||||||
|
return canonicalType(e.X, imports, module, knownTypes, extensions)
|
||||||
|
case *ast.StarExpr:
|
||||||
|
value, ok := canonicalType(e.X, imports, module, knownTypes, extensions)
|
||||||
|
return "*" + value, ok
|
||||||
|
case *ast.Ellipsis:
|
||||||
|
value, ok := canonicalType(e.Elt, imports, module, knownTypes, extensions)
|
||||||
|
return "..." + value, ok
|
||||||
|
case *ast.Ident:
|
||||||
|
switch e.Name {
|
||||||
|
case "error", "bool", "string", "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "uintptr", "float32", "float64", "complex64", "complex128", "any":
|
||||||
|
return e.Name, true
|
||||||
|
}
|
||||||
|
case *ast.SelectorExpr:
|
||||||
|
if pkg, ok := e.X.(*ast.Ident); ok {
|
||||||
|
switch imports[pkg.Name] {
|
||||||
|
case "context":
|
||||||
|
return "context." + e.Sel.Name, true
|
||||||
|
case frameworkModule + "/httpx":
|
||||||
|
return "httpx." + e.Sel.Name, true
|
||||||
|
case module + "/internal/types":
|
||||||
|
return "types." + e.Sel.Name, knownTypes[e.Sel.Name] || !extensions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var b bytes.Buffer
|
||||||
|
_ = format.Node(&b, gotoken.NewFileSet(), expr)
|
||||||
|
return b.String(), false
|
||||||
|
}
|
||||||
213
cmd/go-omni/apigen/managed_test.go
Normal file
213
cmd/go-omni/apigen/managed_test.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManagedDefaultValidation(t *testing.T) {
|
||||||
|
source := readFile(t, filepath.Join(frameworkRoot(t), "examples/contracts/managed.api"))
|
||||||
|
spec, err := parseText(t, source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, err := Compile(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if spec.Routes[0].ResponseFormat != "" {
|
||||||
|
t.Fatal("compile mutated source")
|
||||||
|
}
|
||||||
|
var doc map[string]any
|
||||||
|
if err = json.Unmarshal(c.Document, &doc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if doc["x-omni-default-response"] != "CommonResponse" {
|
||||||
|
t.Fatal("missing default metadata")
|
||||||
|
}
|
||||||
|
for _, bad := range []string{
|
||||||
|
strings.Replace(source, "defaultResponse: CommonResponse", "defaultResponse: Missing", 1),
|
||||||
|
source + "\n@app (defaultResponse: CommonResponse)\n",
|
||||||
|
strings.Replace(source, "maxBytes: 2048", "maxBytes: 0", 1),
|
||||||
|
strings.Replace(source, "@produces \"text/plain\"", "@produces \"text/plain; charset=utf-8\"", 1),
|
||||||
|
} {
|
||||||
|
s, err := parseText(t, bad)
|
||||||
|
if err == nil {
|
||||||
|
_, err = Compile(s)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("invalid contract accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(root, "service.api"), "import \"child.api\"\nservice demo {\n@handler hello\nget /hello\n}")
|
||||||
|
writeFile(t, filepath.Join(root, "child.api"), "@app (defaultResponse: Shared)\nresponse Shared ()")
|
||||||
|
if _, err = Parse(filepath.Join(root, "service.api")); err == nil {
|
||||||
|
t.Fatal("import owns App defaults")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagedGeneratedProject(t *testing.T) {
|
||||||
|
root := frameworkRoot(t)
|
||||||
|
dir := filepath.Join(t.TempDir(), "managed")
|
||||||
|
o := Options{Dir: dir, API: filepath.Join(root, "examples/contracts/managed.api"), Module: "example.com/managed", FrameworkDir: root}
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
o.API = filepath.Join(dir, "api/managed.api")
|
||||||
|
for _, path := range []string{"cmd/server/main.go", "internal/settings", "internal/servicecontext", "internal/service"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("old layout remains: %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mainBefore := readFile(t, filepath.Join(dir, "main.go"))
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
for path, fixture := range files {
|
||||||
|
writeFile(t, filepath.Join(dir, path), readFile(t, filepath.Join("testdata/managed", fixture)))
|
||||||
|
}
|
||||||
|
for _, name := range []string{"other", "rawJSON", "upload", "download", "callback", "slow"} {
|
||||||
|
writeFile(t, filepath.Join(dir, "internal/logic/demo", name+".go"), "package demo\n")
|
||||||
|
}
|
||||||
|
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\"`", 1))
|
||||||
|
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(source, "return c, nil", "if err=initClient(ctx,c);err!=nil{return nil,err}\n return c,nil", 1)
|
||||||
|
writeFile(t, svcPath, source)
|
||||||
|
// Independent tooling stays outside generator ownership.
|
||||||
|
writeFile(t, filepath.Join(dir, "cmd/migrate/main.go"), "package main\nfunc main(){}\n")
|
||||||
|
before := snapshot(t, dir)
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("regeneration changed handwritten files")
|
||||||
|
}
|
||||||
|
o.Check = true
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
o.Check = false
|
||||||
|
// Add an API endpoint: handler and Logic stub appear without touching main or business code.
|
||||||
|
api := readFile(t, o.API)
|
||||||
|
writeFile(t, o.API, api+"\n@server (group: extra)\nservice managed-api {\n@handler extra\nget /extra\n}\n")
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if readFile(t, filepath.Join(dir, "main.go")) != mainBefore {
|
||||||
|
t.Fatal("main requires business-specific edits")
|
||||||
|
}
|
||||||
|
for path := range files {
|
||||||
|
if readFile(t, filepath.Join(dir, path)) != string(before[path]) {
|
||||||
|
t.Fatal("business file changed", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Main is owned, checked, and restored when missing. Hand edits are a conflict.
|
||||||
|
writeFile(t, filepath.Join(dir, "main.go"), mainBefore+"\n// drift\n")
|
||||||
|
o.Check = true
|
||||||
|
if err := generateManagedTestProject(t, o); err == nil {
|
||||||
|
t.Fatal("main drift accepted")
|
||||||
|
}
|
||||||
|
o.Check = false
|
||||||
|
if err := os.Remove(filepath.Join(dir, "main.go")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
o.Check = true
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
for _, args := range [][]string{{"test", "-race", "-mod=mod", "-count=1", "./..."}, {"build", "-mod=mod", "."}} {
|
||||||
|
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("%v: %v\n%s", args, 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")
|
||||||
|
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 generateManagedTestProject(t *testing.T, o Options) error {
|
||||||
|
t.Helper()
|
||||||
|
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
|
||||||
|
if cli == "" {
|
||||||
|
_, err := Generate(o)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
args := []string{"api", "generate", "--api", o.API, "--dir", o.Dir, "--module", o.Module}
|
||||||
|
if o.Check {
|
||||||
|
args = append(args, "--check")
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, cli, args...)
|
||||||
|
cmd.Dir = filepath.Dir(o.Dir)
|
||||||
|
if data, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("released CLI: %w\n%s", err, data)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagedOwnershipRejectsOldOrHandwrittenEntry(t *testing.T) {
|
||||||
|
for _, oldLayout := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "handwrittenMain", true: "oldManifest"}[oldLayout], func(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "app")
|
||||||
|
if _, err := New(Options{Dir: dir, Module: "example.com/entry", 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)
|
||||||
|
}
|
||||||
|
if oldLayout {
|
||||||
|
m.Version = 1
|
||||||
|
} else {
|
||||||
|
delete(m.Generated, "main.go")
|
||||||
|
writeFile(t, filepath.Join(dir, "main.go"), "package main\nfunc main(){}\n")
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(m)
|
||||||
|
writeFile(t, filepath.Join(dir, manifestPath), string(data))
|
||||||
|
before := snapshot(t, dir)
|
||||||
|
if _, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}); err == nil {
|
||||||
|
t.Fatal("unowned layout accepted")
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("rejected migration modified project")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -252,7 +252,7 @@ func TestDeclaredContractConsumer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const declaredConsumerTest = `package dx_test
|
const declaredConsumerTest = `package main_test
|
||||||
import (
|
import (
|
||||||
"context";"errors";"io";"net/http/httptest";"strings";"testing"
|
"context";"errors";"io";"net/http/httptest";"strings";"testing"
|
||||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
@@ -260,13 +260,13 @@ import (
|
|||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
"example.com/dx/internal/apierrors"
|
"example.com/dx/internal/apierrors"
|
||||||
"example.com/dx/internal/handler"
|
"example.com/dx/internal/handler"
|
||||||
"example.com/dx/internal/servicecontext"
|
"example.com/dx/internal/svc"
|
||||||
)
|
)
|
||||||
func TestRuntimeAuthAndFreshErrors(t *testing.T){
|
func TestRuntimeAuthAndFreshErrors(t *testing.T){
|
||||||
first:=apierrors.EmailTaken().WithCause(errors.New("private"));second:=apierrors.EmailTaken()
|
first:=apierrors.EmailTaken().WithCause(errors.New("private"));second:=apierrors.EmailTaken()
|
||||||
if first==second||second.Cause!=nil||second.Code!=apierrors.CodeEmailTaken||second.Status!=409{t.Fatal("shared error instance")}
|
if first==second||second.Cause!=nil||second.Code!=apierrors.CodeEmailTaken||second.Status!=409{t.Fatal("shared error instance")}
|
||||||
cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close()
|
cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close()
|
||||||
deps,err:=servicecontext.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close()
|
deps,err:=svc.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close()
|
||||||
app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
|
app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
|
||||||
w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,httptest.NewRequest("POST","/users",nil))
|
w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,httptest.NewRequest("POST","/users",nil))
|
||||||
if w.Code!=501||!strings.Contains(w.Body.String(),"MIDDLEWARE_NOT_IMPLEMENTED"){t.Fatal("unimplemented auth did not fail closed")}
|
if w.Code!=501||!strings.Contains(w.Body.String(),"MIDDLEWARE_NOT_IMPLEMENTED"){t.Fatal("unimplemented auth did not fail closed")}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Spec struct {
|
type Spec struct {
|
||||||
|
DefaultResponse string
|
||||||
|
appDeclared bool
|
||||||
Title, Version, Service, Description string
|
Title, Version, Service, Description string
|
||||||
Errors []ErrorDefinition
|
Errors []ErrorDefinition
|
||||||
ErrorCatalogs []string
|
ErrorCatalogs []string
|
||||||
@@ -300,6 +302,26 @@ func (p *parser) parse() error {
|
|||||||
}
|
}
|
||||||
case "@":
|
case "@":
|
||||||
p.next()
|
p.next()
|
||||||
|
if p.peek() == "app" {
|
||||||
|
if p.spec.appDeclared {
|
||||||
|
return p.err("duplicate @app declaration")
|
||||||
|
}
|
||||||
|
// Defaults belong to the entry contract, never an imported module.
|
||||||
|
if !strings.HasPrefix(p.tokens[p.at].position, p.spec.Entry+":") {
|
||||||
|
return p.err("@app must be declared in the entry API file")
|
||||||
|
}
|
||||||
|
p.next()
|
||||||
|
m, err := p.metadata()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(m) != 1 || m["defaultResponse"] == "" {
|
||||||
|
return p.err("@app requires only defaultResponse: TemplateName")
|
||||||
|
}
|
||||||
|
p.spec.DefaultResponse = m["defaultResponse"]
|
||||||
|
p.spec.appDeclared = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := p.expect("server"); err != nil {
|
if err := p.expect("server"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -555,7 +577,7 @@ func (p *parser) parseService(server map[string]string) error {
|
|||||||
if p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" {
|
if p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" {
|
||||||
return p.err("unexpected route suffix")
|
return p.err("unexpected route suffix")
|
||||||
}
|
}
|
||||||
if route.Response == "" && !seen["status"] && !route.Manual {
|
if route.Response == "" && !seen["status"] && !route.Manual && route.Produces == "" {
|
||||||
route.Status = 204
|
route.Status = 204
|
||||||
}
|
}
|
||||||
p.spec.Routes = append(p.spec.Routes, route)
|
p.spec.Routes = append(p.spec.Routes, route)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestInfrastructureProfileOwnership(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Restoring a missing seed must use the saved profile, not current defaults.
|
// Restoring a missing seed must use the saved profile, not current defaults.
|
||||||
if err := os.Remove(filepath.Join(dir, "internal/servicecontext/storage.go")); err != nil {
|
if err := os.Remove(filepath.Join(dir, "internal/svc/storage.go")); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := Generate(o); err == nil {
|
if _, err := Generate(o); err == nil {
|
||||||
@@ -62,7 +62,7 @@ func TestAPIInfrastructureConsumers(t *testing.T) {
|
|||||||
if _, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}); err != nil {
|
if _, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
test := `package settings
|
test := `package config
|
||||||
import("testing";"os")
|
import("testing";"os")
|
||||||
func TestConfiguration(t *testing.T){
|
func TestConfiguration(t *testing.T){
|
||||||
t.Setenv("GO_OMNI_DB_DSN","test:test@tcp(127.0.0.1:1)/test")
|
t.Setenv("GO_OMNI_DB_DSN","test:test@tcp(127.0.0.1:1)/test")
|
||||||
@@ -82,7 +82,7 @@ func TestConfiguration(t *testing.T){
|
|||||||
if storage == "oss" {
|
if storage == "oss" {
|
||||||
endpoint = "https://oss-cn-hangzhou.aliyuncs.com"
|
endpoint = "https://oss-cn-hangzhou.aliyuncs.com"
|
||||||
}
|
}
|
||||||
writeFile(t, filepath.Join(dir, "internal/settings/profile_test.go"), strings.ReplaceAll(test, `"ENDPOINT"`, `"`+endpoint+`"`))
|
writeFile(t, filepath.Join(dir, "internal/config/profile_test.go"), strings.ReplaceAll(test, `"ENDPOINT"`, `"`+endpoint+`"`))
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-count=1", "./...")
|
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-count=1", "./...")
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func ProjectEntry(directory string) (string, error) {
|
|||||||
if err = json.Unmarshal(data, &m); err != nil {
|
if err = json.Unmarshal(data, &m); err != nil {
|
||||||
return "", fmt.Errorf("invalid API project manifest: %w", err)
|
return "", fmt.Errorf("invalid API project manifest: %w", err)
|
||||||
}
|
}
|
||||||
if m.Version != 1 || filepath.Ext(m.Entry) != ".api" {
|
if m.Version != 2 || filepath.Ext(m.Entry) != ".api" {
|
||||||
return "", fmt.Errorf("invalid API entry in %s; pass --api explicitly", manifestPath)
|
return "", fmt.Errorf("invalid API entry in %s; pass --api explicitly", manifestPath)
|
||||||
}
|
}
|
||||||
if err = safePath(root, m.Entry); err != nil {
|
if err = safePath(root, m.Entry); err != nil {
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ func validateRouteExtensions(r Route) error {
|
|||||||
if r.BodyMode != "" && r.BodyMode != "json" && r.BodyMode != "raw" && r.BodyMode != "multipart" {
|
if r.BodyMode != "" && r.BodyMode != "json" && r.BodyMode != "raw" && r.BodyMode != "multipart" {
|
||||||
return errors.New("body mode must be json, raw or multipart")
|
return errors.New("body mode must be json, raw or multipart")
|
||||||
}
|
}
|
||||||
if (r.BodyMode == "raw" || r.BodyMode == "multipart") && !r.Manual {
|
|
||||||
return errors.New("raw/multipart body requires @handlerMode manual")
|
|
||||||
}
|
|
||||||
if r.CustomBinding && (r.Manual || r.Request == "") {
|
if r.CustomBinding && (r.Manual || r.Request == "") {
|
||||||
return errors.New("@binding custom requires an automatic handler with a request DTO")
|
return errors.New("@binding custom requires an automatic handler with a request DTO")
|
||||||
}
|
}
|
||||||
@@ -46,15 +43,12 @@ func validateRouteExtensions(r Route) error {
|
|||||||
return errors.New("GET/HEAD cannot declare a body limit")
|
return errors.New("GET/HEAD cannot declare a body limit")
|
||||||
}
|
}
|
||||||
if r.Produces != "" {
|
if r.Produces != "" {
|
||||||
if !r.Manual {
|
|
||||||
return errors.New("@produces requires @handlerMode manual")
|
|
||||||
}
|
|
||||||
media, params, err := mime.ParseMediaType(r.Produces)
|
media, params, err := mime.ParseMediaType(r.Produces)
|
||||||
if err != nil || len(params) != 0 || media != r.Produces || strings.Contains(media, "*") {
|
if err != nil || len(params) != 0 || media != r.Produces || strings.Contains(media, "*") {
|
||||||
return errors.New("produces requires a concrete media type without parameters")
|
return errors.New("produces requires a concrete media type without parameters")
|
||||||
}
|
}
|
||||||
if r.Response != "" || r.Status == 204 || r.Method == "head" {
|
if r.Response != "" || r.Status == 204 || r.Method == "head" {
|
||||||
return errors.New("@produces uses a manual response without a DTO, 204 or HEAD")
|
return errors.New("@produces requires a stream response without a DTO, 204 or HEAD")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if r.Manual && r.Status == 204 && r.Response != "" {
|
if r.Manual && r.Status == 204 && r.Response != "" {
|
||||||
|
|||||||
@@ -102,6 +102,18 @@ func validateResponses(spec *Spec) error {
|
|||||||
}
|
}
|
||||||
seen[definition.Name] = true
|
seen[definition.Name] = true
|
||||||
}
|
}
|
||||||
|
if spec.DefaultResponse != "" && !seen[spec.DefaultResponse] {
|
||||||
|
return fmt.Errorf("unknown App default response template %s", spec.DefaultResponse)
|
||||||
|
}
|
||||||
|
for i := range spec.Routes {
|
||||||
|
r := &spec.Routes[i]
|
||||||
|
if r.ResponseFormat == "" {
|
||||||
|
r.ResponseFormat = spec.DefaultResponse
|
||||||
|
}
|
||||||
|
if r.ResponseFormat == "raw" && r.ErrorResponseFormat == "" {
|
||||||
|
r.ErrorResponseFormat = spec.DefaultResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, r := range spec.Routes {
|
for _, r := range spec.Routes {
|
||||||
if r.ResponseFormat != "" && r.ResponseFormat != "raw" && !seen[r.ResponseFormat] {
|
if r.ResponseFormat != "" && r.ResponseFormat != "raw" && !seen[r.ResponseFormat] {
|
||||||
return fmt.Errorf("%s: unknown response template %s", r.Position, r.ResponseFormat)
|
return fmt.Errorf("%s: unknown response template %s", r.Position, r.ResponseFormat)
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ func TestResponseTemplateConsumer(t *testing.T) {
|
|||||||
if method == "Partner" {
|
if method == "Partner" {
|
||||||
imports += `;"example.com/responseConsumer/internal/apierrors"`
|
imports += `;"example.com/responseConsumer/internal/apierrors"`
|
||||||
}
|
}
|
||||||
writeFile(t, filepath.Join(dir, "internal/service/items", strings.ToLower(method)+".go"), "package items\nimport("+imports+")\nfunc(s *Service)"+method+"(ctx context.Context)(*types.ItemResponse,error){"+body+"}\n")
|
writeFile(t, filepath.Join(dir, "internal/logic/items", strings.ToLower(method)+".go"), "package items\nimport("+imports+")\nfunc(s *Logic)"+method+"(ctx context.Context)(*types.ItemResponse,error){"+body+"}\n")
|
||||||
}
|
}
|
||||||
before := snapshot(t, dir)
|
before := snapshot(t, dir)
|
||||||
generate(filepath.Join(dir, "api/service.api"), false)
|
generate(filepath.Join(dir, "api/service.api"), false)
|
||||||
@@ -310,7 +310,7 @@ func TestResponseTemplateConsumer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseConsumerTest = `package responseConsumer_test
|
const responseConsumerTest = `package main_test
|
||||||
import (
|
import (
|
||||||
"bytes";"encoding/json";"io";"net/http/httptest";"testing"
|
"bytes";"encoding/json";"io";"net/http/httptest";"testing"
|
||||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
@@ -322,7 +322,7 @@ import (
|
|||||||
item "example.com/responseConsumer/internal/handler/items"
|
item "example.com/responseConsumer/internal/handler/items"
|
||||||
"example.com/responseConsumer/internal/types"
|
"example.com/responseConsumer/internal/types"
|
||||||
"context"
|
"context"
|
||||||
"example.com/responseConsumer/internal/servicecontext"
|
"example.com/responseConsumer/internal/svc"
|
||||||
)
|
)
|
||||||
type service struct{}
|
type service struct{}
|
||||||
func(service)Item(context.Context)(*types.ItemResponse,error){return &types.ItemResponse{Name:"item"},nil}
|
func(service)Item(context.Context)(*types.ItemResponse,error){return &types.ItemResponse{Name:"item"},nil}
|
||||||
@@ -331,7 +331,7 @@ func(service)Partner(context.Context)(*types.ItemResponse,error){return nil,apie
|
|||||||
func TestResponseContract(t *testing.T){
|
func TestResponseContract(t *testing.T){
|
||||||
app:=omni.New();defer app.Close()
|
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()
|
cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close()
|
||||||
deps,err:=servicecontext.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close()
|
deps,err:=svc.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close()
|
||||||
deps.Middlewares["Auth"]=func(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{if c.Header("Authorization")!="Bearer valid"{return apierrors.Unauthenticated()};return next(c)}}
|
deps.Middlewares["Auth"]=func(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{if c.Header("Authorization")!="Bearer valid"{return apierrors.Unauthenticated()};return next(c)}}
|
||||||
if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
|
if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
|
||||||
// Registered routes use the compiled policy before even running authentication.
|
// Registered routes use the compiled policy before even running authentication.
|
||||||
|
|||||||
49
cmd/go-omni/apigen/startup.go
Normal file
49
cmd/go-omni/apigen/startup.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func appSource(o Options, spec *Spec) string {
|
||||||
|
source := fmt.Sprintf(`package handler
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
omni %q
|
||||||
|
%q
|
||||||
|
%q
|
||||||
|
)
|
||||||
|
// NewApp is generated HTTP composition. The caller owns deps and its logger.
|
||||||
|
func NewApp(deps *svc.ServiceContext)(*omni.App,error){
|
||||||
|
if deps==nil || deps.Health==nil{return nil,fmt.Errorf("service context and health checker required")}
|
||||||
|
cfg:=deps.Config.Server
|
||||||
|
for _,option:=range deps.AppOptions(){if option!=nil{option(&cfg)}}
|
||||||
|
`, frameworkModule, frameworkModule+"/httpx", o.Module+"/internal/svc")
|
||||||
|
if spec.DefaultResponse != "" {
|
||||||
|
for _, r := range spec.Responses {
|
||||||
|
if r.Name == spec.DefaultResponse {
|
||||||
|
data, _ := json.Marshal(r.Config)
|
||||||
|
source += fmt.Sprintf("cfg.Response=newResponseTemplate(%q).Config();cfg.ResponseEncoder=nil\n", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
source += `if err:=cfg.Validate();err!=nil{return nil,err}
|
||||||
|
app:=omni.New(omni.WithConfig(cfg))
|
||||||
|
app.Use(deps.GlobalMiddleware()...)
|
||||||
|
if err:=RegisterRoutes(app,deps);err!=nil{return nil,err}
|
||||||
|
app.GET("/openapi.json",OpenAPI)
|
||||||
|
app.GET("/health/live",deps.Health.LivenessHandler())
|
||||||
|
app.GET("/health/ready",deps.Health.ReadinessHandler())
|
||||||
|
`
|
||||||
|
if o.Storage == "local" {
|
||||||
|
source += `if deps.FileHTTP==nil{return nil,fmt.Errorf("local storage handler required")}
|
||||||
|
for _,method:=range []string{"GET","HEAD","PUT"}{app.Handle(method,"/storage/object",httpx.WrapHandler(deps.FileHTTP))}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
source += "return app,nil\n}\n"
|
||||||
|
if o.Storage != "local" {
|
||||||
|
source = strings.Replace(source, fmt.Sprintf("%q", frameworkModule+"/httpx"), "", 1)
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package apigen
|
|||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
var baseTemplates = map[string]string{
|
var baseTemplates = map[string]string{
|
||||||
"cmd/server/main.go": `package main
|
"main.go": `package main
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -12,35 +12,31 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"MODULE/internal/config"
|
||||||
"MODULE/internal/handler"
|
"MODULE/internal/handler"
|
||||||
"MODULE/internal/servicecontext"
|
"MODULE/internal/svc"
|
||||||
"MODULE/internal/settings"
|
|
||||||
)
|
)
|
||||||
func main(){
|
func main(){
|
||||||
path:=flag.String("config","etc/config.yaml","configuration file; empty uses defaults and environment")
|
path:=flag.String("config","etc/config.yaml","configuration file; empty uses defaults and environment")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
if err:=run(*path);err!=nil{log.Fatal(err)}
|
|
||||||
}
|
|
||||||
func run(path string)(runErr error){
|
|
||||||
ctx,stop:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)
|
ctx,stop:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
cfg,err:=settings.Load(path);if err!=nil{return err}
|
if err:=run(ctx,*path);err!=nil{log.Fatal(err)}
|
||||||
|
}
|
||||||
|
func run(ctx context.Context,path string)(runErr error){
|
||||||
|
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())}()
|
defer func(){runErr=errors.Join(runErr,logger.Close())}()
|
||||||
logx.SetDefault(logger)
|
logx.SetDefault(logger)
|
||||||
deps,err:=servicecontext.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
|
||||||
|
// and closes them exactly once, before closing the borrowed logger.
|
||||||
defer func(){runErr=errors.Join(runErr,deps.Close())}()
|
defer func(){runErr=errors.Join(runErr,deps.Close())}()
|
||||||
app:=omni.New(omni.WithConfig(cfg.Server))
|
app,err:=handler.NewApp(deps);if err!=nil{return err}
|
||||||
app.OnShutdown(logger.Close)
|
defer func(){runErr=errors.Join(runErr,app.Close())}()
|
||||||
app.OnShutdown(deps.Close)
|
logger.InfoContext(ctx,"service.starting","address",app.Config().Address)
|
||||||
if err=handler.RegisterRoutes(app,deps);err!=nil{return err}
|
return app.RunContext(ctx)
|
||||||
app.GET("/openapi.json",handler.OpenAPI)
|
|
||||||
app.GET("/health/live",deps.Health.LivenessHandler())
|
|
||||||
app.GET("/health/ready",deps.Health.ReadinessHandler())
|
|
||||||
logger.InfoContext(ctx,"service.starting","address",cfg.Server.Address)
|
|
||||||
return app.RunContext(ctx,cfg.Server.Address)
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
".gitignore": "/server\n/var/\n.env\n.env.*\n!.env.example\n/.goOmni/api.lock\n",
|
".gitignore": "/server\n/var/\n.env\n.env.*\n!.env.example\n/.goOmni/api.lock\n",
|
||||||
@@ -51,12 +47,13 @@ func projectREADME(module, entry string) string {
|
|||||||
|
|
||||||
This project is generated from an editable API contract. Business code uses
|
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.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
`+"```sh"+`
|
`+"```sh"+`
|
||||||
go mod tidy
|
go mod tidy
|
||||||
go run ./cmd/server
|
go run .
|
||||||
`+"```"+`
|
`+"```"+`
|
||||||
|
|
||||||
GET /health/live and /health/ready check the running service. GET /openapi.json
|
GET /health/live and /health/ready check the running service. GET /openapi.json
|
||||||
@@ -83,9 +80,9 @@ go test ./...
|
|||||||
Commands inside a generated project discover the recorded API entry automatically.
|
Commands inside a generated project discover the recorded API entry automatically.
|
||||||
--dry-run previews create/update/remove actions without writing files.
|
--dry-run previews create/update/remove actions without writing files.
|
||||||
The --check command is read-only and suitable for CI. It fails on stale or missing
|
The --check command is read-only and suitable for CI. It fails on stale or missing
|
||||||
generated files and missing business scaffolds. go.mod, configuration, main,
|
generated files and missing business scaffolds. go.mod, configuration, Logic,
|
||||||
Service, Repository, servicecontext, middleware hook bodies and API files are
|
Repository, svc, middleware hook bodies and API files are created once and then
|
||||||
created once and then preserved byte for byte. Gen.go files and OpenAPI are owned
|
preserved byte for byte. main.go, Gen.go files and OpenAPI are owned
|
||||||
by the generator; manual edits cause a conflict instead of being overwritten.
|
by the generator; manual edits cause a conflict instead of being overwritten.
|
||||||
Commit .goOmni/apiManifest.json alongside the generated files. Do not edit it.
|
Commit .goOmni/apiManifest.json alongside the generated files. Do not edit it.
|
||||||
Avoid editing files while generation is running; api.lock serializes generators.
|
Avoid editing files while generation is running; api.lock serializes generators.
|
||||||
@@ -95,18 +92,30 @@ business files remain: remove/adapt obsolete implementations explicitly if an
|
|||||||
API change makes their old types or signatures fail to compile. Every regeneration
|
API change makes their old types or signatures fail to compile. Every regeneration
|
||||||
must be followed by go test ./... . Do not delete service files to change contracts.
|
must be followed by go test ./... . Do not delete service files to change contracts.
|
||||||
|
|
||||||
|
Generation compares the last saved contract with the new one and reports affected
|
||||||
|
Logic files, including nested DTO field/tag changes and streaming/body changes.
|
||||||
|
REVIEW notices require a business review. ERROR diagnostics identify definite
|
||||||
|
Logic signature, syntax or duplicate-method problems. Generation preserves
|
||||||
|
business code and still writes valid generated output; fix those methods next.
|
||||||
|
--check fails on definite Logic errors even after generated files are current.
|
||||||
|
Import aliases, parameter names and moved methods within a group are supported.
|
||||||
|
Project type aliases, embedded methods or build tags may need compiler verification
|
||||||
|
and produce REVIEW notices. This source scan does not execute project code or
|
||||||
|
replace go test ./... . Snapshot-based REVIEW notices describe changes since the
|
||||||
|
last successful generation; they do not persist as an acknowledgement checklist.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
`+"```text"+`
|
`+"```text"+`
|
||||||
api/ # editable contract and imported types
|
api/ # editable contract and imported types
|
||||||
cmd/server/main.go # startup, lifecycle and route registration
|
main.go # GENERATED startup, lifecycle and route registration
|
||||||
etc/config.yaml # lowerCamelCase configuration
|
etc/config.yaml # lowerCamelCase configuration
|
||||||
internal/settings/ # defaults -> file -> environment
|
internal/config/ # defaults -> file -> environment
|
||||||
internal/servicecontext/ # dependency initialization and cleanup
|
internal/svc/ # dependency initialization and cleanup
|
||||||
internal/types/typesGen.go # generated request/response types
|
internal/types/typesGen.go # generated request/response types
|
||||||
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 service adapters
|
internal/handler/<group>/ # generated binding and Logic adapters
|
||||||
internal/service/<group>/ # developer-owned business operations
|
internal/logic/<group>/ # developer-owned business operations
|
||||||
internal/repository/<group>/ # developer-owned persistence contracts
|
internal/repository/<group>/ # developer-owned persistence contracts
|
||||||
internal/middleware/ # named hooks and generated registry
|
internal/middleware/ # named hooks and generated registry
|
||||||
docs/openapi.json # generated OpenAPI 3.0.3
|
docs/openapi.json # generated OpenAPI 3.0.3
|
||||||
@@ -114,12 +123,25 @@ docs/openapi.json # generated OpenAPI 3.0.3
|
|||||||
`+"```"+`
|
`+"```"+`
|
||||||
|
|
||||||
The API response type describes business data. Declare response Name (...) and
|
The API response type describes business data. Declare response Name (...) and
|
||||||
select it with @server(response: Name) or @response Name to share a public body;
|
select the App default with @app(defaultResponse: Name) in the entry API.
|
||||||
|
@server(response: Name) and @response Name override it for groups/routes;
|
||||||
@response raw returns the business JSON directly. Without a template, the default
|
@response raw returns the business JSON directly. Without a template, the default
|
||||||
envelope includes code, message, data and requestId. Generated routes and OpenAPI
|
envelope includes code, message, data and requestId. Generated routes and OpenAPI
|
||||||
use the API contract, independently of the app's default response configuration.
|
use the same contract. 404/405 and ordinary App routes use the declared App default.
|
||||||
|
No template is selected by declaration/import order. Without @app, existing framework
|
||||||
|
response defaults apply. An explicit API App default takes precedence over response
|
||||||
|
options in svc, keeping generated routes and OpenAPI consistent.
|
||||||
Handlers reject nil successful responses. Body DTOs use application/json and have a 1 MiB limit by default.
|
Handlers reject nil successful responses. Body DTOs use application/json and have a 1 MiB limit by default.
|
||||||
Use contract.Binder.Bind's optional limit in a custom adapter for larger payloads.
|
Use @body(maxBytes: ...) for route limits. @body(mode: raw|multipart; maxBytes: ...)
|
||||||
|
adds a *httpx.Body argument to Logic, without handwritten handlers. Raw is the exact
|
||||||
|
limited request stream; Parts supports sequential multipart reading with no temp files.
|
||||||
|
@produces "application/octet-stream" (or another concrete media type) generates a
|
||||||
|
Logic result of *httpx.Stream; the handler closes Content and streams it unwrapped.
|
||||||
|
Signature verification and storage policy belong to Logic/middleware. A consumed
|
||||||
|
request stream cannot be reread automatically; use one reader owner or explicitly
|
||||||
|
bounded buffering. Stream reads respect configured server timeouts. Multipart parts
|
||||||
|
are not DTO form fields: form tags remain URL query parameters. The optional manual
|
||||||
|
handler/custom binder hooks are preserved, and are needed only for custom protocols.
|
||||||
|
|
||||||
Fields require exactly one source tag: json, path, form (URL query only), header.
|
Fields require exactly one source tag: json, path, form (URL query only), header.
|
||||||
All are required by presence unless optional, omitempty or default is specified.
|
All are required by presence unless optional, omitempty or default is specified.
|
||||||
@@ -132,20 +154,33 @@ and binding compiler. Responses are expected to honor their declared constraints
|
|||||||
they are not validated on every response. Do not return nil slices/maps for a
|
they are not validated on every response. Do not return nil slices/maps for a
|
||||||
non-nullable collection (initialize empty collections instead).
|
non-nullable collection (initialize empty collections instead).
|
||||||
|
|
||||||
Declare public business errors in error (...) and reference them with @errors.
|
Go-owned errors can be registered once through svc.ServiceContext.AppOptions with
|
||||||
|
omni.WithErrors(projecterrors.Catalog()). Generated routes inherit them unless they
|
||||||
|
declare an independent catalog. Do not repeat Go-owned errors in the API contract.
|
||||||
|
Alternatively, declare public business errors in error (...) and reference them with @errors.
|
||||||
Use internal/apierrors constructors to share status, code and message with OpenAPI.
|
Use internal/apierrors constructors to share status, code and message with OpenAPI.
|
||||||
Auth declarations connect a bearer/header/cookie scheme to a real named middleware.
|
Auth declarations connect a bearer/header/cookie scheme to a real named middleware.
|
||||||
The selected auth runs first; @auth none removes inherited declarative auth.
|
The selected auth runs first; @auth none removes inherited declarative auth.
|
||||||
Validation policy remains in the middleware implementation. Public messages must
|
Validation policy remains in the middleware implementation. Public messages must
|
||||||
not contain secrets; error causes may be included in server logs.
|
not contain secrets; error causes may be included in server logs.
|
||||||
|
|
||||||
|
svc.New receives the loaded Config and borrowed Logger. Add dependencies there,
|
||||||
|
then immediately register each cleanup with OnClose. A later initialization failure
|
||||||
|
runs registered cleanups in reverse order. Close is idempotent. GlobalMiddleware
|
||||||
|
provides global hooks; Middlewares supplies named API hooks. AppOptions is evaluated
|
||||||
|
once before route registration; logging is configured earlier through Config.
|
||||||
|
The generated main runs HTTP draining before closing svc resources, then the logger.
|
||||||
|
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
|
||||||
|
their own lifetime, canceled by OnClose after HTTP draining.
|
||||||
|
|
||||||
Named middleware is composed in declaration order (first is outermost). Implement
|
Named middleware is composed in declaration order (first is outermost). Implement
|
||||||
the generated hooks or replace entries in servicecontext; never bypass an auth
|
the generated hooks or replace entries in svc; never bypass an auth
|
||||||
hook merely to make the server start. Middleware names do not imply a JWT policy.
|
hook merely to make the server start. Middleware names do not imply a JWT policy.
|
||||||
|
|
||||||
This CLI supports a documented go-zero-style subset, not all goctl features.
|
This CLI supports a documented go-zero-style subset, not all goctl features.
|
||||||
Database tables and repositories cannot be inferred from an HTTP contract. Add
|
Database tables and repositories cannot be inferred from an HTTP contract. Add
|
||||||
business repositories through servicecontext. Database and storage options are
|
business repositories through svc. Database and storage options are
|
||||||
composed at creation; the original profile is retained on regeneration.
|
composed at creation; the original profile is retained on regeneration.
|
||||||
`, module, entry)
|
`, module, entry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package compatibility_test
|
package main_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"example.com/compatibility/internal/handler"
|
"example.com/compatibility/internal/handler"
|
||||||
"example.com/compatibility/internal/servicecontext"
|
"example.com/compatibility/internal/svc"
|
||||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
"io"
|
"io"
|
||||||
@@ -25,7 +25,7 @@ func TestGeneratedCompatibility(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer logger.Close()
|
defer logger.Close()
|
||||||
deps, err := servicecontext.New(context.Background(), logger)
|
deps, err := svc.New(context.Background(), logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import (
|
|||||||
"example.com/compatibility/internal/types"
|
"example.com/compatibility/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) Echo(ctx context.Context, req *types.EchoReq) (*types.EchoResp, error) {
|
func (s *Logic) Echo(ctx context.Context, req *types.EchoReq) (*types.EchoResp, error) {
|
||||||
return &types.EchoResp{ID: req.ID, Data: req.Data}, nil
|
return &types.EchoResp{ID: req.ID, Data: req.Data}, nil
|
||||||
}
|
}
|
||||||
func (s *Service) Item(ctx context.Context, req *types.ItemReq) (*types.EchoResp, error) {
|
func (s *Logic) Item(ctx context.Context, req *types.ItemReq) (*types.EchoResp, error) {
|
||||||
return &types.EchoResp{ID: req.ID, Data: map[string]any{"found": true}}, nil
|
return &types.EchoResp{ID: req.ID, Data: map[string]any{"found": true}}, nil
|
||||||
}
|
}
|
||||||
func (s *Service) Custom(ctx context.Context, req *types.CustomReq) (*types.CustomResp, error) {
|
func (s *Logic) Custom(ctx context.Context, req *types.CustomReq) (*types.CustomResp, error) {
|
||||||
return &types.CustomResp{Value: req.Value}, nil
|
return &types.CustomResp{Value: req.Value}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package consumer_test
|
package main_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
"example.com/errorCatalog/internal/apierrors"
|
"example.com/errorCatalog/internal/apierrors"
|
||||||
"example.com/errorCatalog/internal/handler"
|
"example.com/errorCatalog/internal/handler"
|
||||||
"example.com/errorCatalog/internal/servicecontext"
|
"example.com/errorCatalog/internal/svc"
|
||||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
@@ -35,7 +35,7 @@ func TestGeneratedCatalog(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer logger.Close()
|
defer logger.Close()
|
||||||
deps, err := servicecontext.New(context.Background(), logger)
|
deps, err := svc.New(context.Background(), logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package consumer_test
|
package main_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"example.com/errorCatalog/internal/handler"
|
"example.com/errorCatalog/internal/handler"
|
||||||
"example.com/errorCatalog/internal/servicecontext"
|
"example.com/errorCatalog/internal/svc"
|
||||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
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"
|
||||||
@@ -31,7 +31,7 @@ func TestGoOwnedCatalogOnGeneratedRoutes(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer logger.Close()
|
defer logger.Close()
|
||||||
deps, err := servicecontext.New(context.Background(), logger)
|
deps, err := svc.New(context.Background(), logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ func check(kind string) (*types.CheckResponse, error) {
|
|||||||
return nil, apierrors.ByCode("USERNAME_INVALID")
|
return nil, apierrors.ByCode("USERNAME_INVALID")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func (s *Service) Plain(ctx context.Context, req *types.CheckRequest) (*types.CheckResponse, error) {
|
func (s *Logic) Plain(ctx context.Context, req *types.CheckRequest) (*types.CheckResponse, error) {
|
||||||
return check(req.Kind)
|
return check(req.Kind)
|
||||||
}
|
}
|
||||||
func (s *Service) Raw(ctx context.Context, req *types.CheckRequest) (*types.CheckResponse, error) {
|
func (s *Logic) Raw(ctx context.Context, req *types.CheckRequest) (*types.CheckResponse, error) {
|
||||||
return check(req.Kind)
|
return check(req.Kind)
|
||||||
}
|
}
|
||||||
func (s *Service) Cleared(ctx context.Context, req *types.CheckRequest) (*types.CheckResponse, error) {
|
func (s *Logic) Cleared(ctx context.Context, req *types.CheckRequest) (*types.CheckResponse, error) {
|
||||||
return check(req.Kind)
|
return check(req.Kind)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package consumer_test
|
package main_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
"example.com/errorCatalog/internal/apierrors"
|
"example.com/errorCatalog/internal/apierrors"
|
||||||
"example.com/errorCatalog/internal/handler"
|
"example.com/errorCatalog/internal/handler"
|
||||||
"example.com/errorCatalog/internal/servicecontext"
|
"example.com/errorCatalog/internal/svc"
|
||||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
@@ -87,7 +87,7 @@ func TestGeneratedTemplateErrors(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer logger.Close()
|
defer logger.Close()
|
||||||
deps, err := servicecontext.New(context.Background(), logger)
|
deps, err := svc.New(context.Background(), logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
24
cmd/go-omni/apigen/testdata/managed/client.go
vendored
Normal file
24
cmd/go-omni/apigen/testdata/managed/client.go
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package svc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Opened, Closed, DownloadClosed atomic.Int32
|
||||||
|
var Started = make(chan struct{}, 1)
|
||||||
|
var Release = make(chan struct{})
|
||||||
|
|
||||||
|
// This fake client exercises the same lifetime contract as a database/client.
|
||||||
|
type Client struct{}
|
||||||
|
|
||||||
|
func initClient(ctx context.Context, c *ServiceContext) error {
|
||||||
|
Opened.Add(1)
|
||||||
|
c.Client = &Client{}
|
||||||
|
c.OnClose(func() error { Closed.Add(1); return nil })
|
||||||
|
if c.Config.FailInit {
|
||||||
|
return errors.New("client initialization failed")
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
235
cmd/go-omni/apigen/testdata/managed/consumer_test.go
vendored
Normal file
235
cmd/go-omni/apigen/testdata/managed/consumer_test.go
vendored
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"example.com/managed/internal/config"
|
||||||
|
"example.com/managed/internal/handler"
|
||||||
|
"example.com/managed/internal/svc"
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func application(t *testing.T) *omni.App {
|
||||||
|
t.Helper()
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.Server.Log.Writer = io.Discard
|
||||||
|
logger, err := logx.New(cfg.Server.Log)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = logger.Close() })
|
||||||
|
deps, err := svc.New(context.Background(), logger, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = deps.Close() })
|
||||||
|
app, err := handler.NewApp(deps)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = app.Close() })
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
func response(t *testing.T, app *omni.App, method, path string, body io.Reader, headers map[string]string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, path, body)
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
out := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(out, req)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
func assertError(t *testing.T, out *httptest.ResponseRecorder, status, code int, message, field string) {
|
||||||
|
t.Helper()
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal(out.Body.Bytes(), &data); err != nil {
|
||||||
|
t.Fatal(out.Body.String(), err)
|
||||||
|
}
|
||||||
|
value, exists := data["data"]
|
||||||
|
if out.Code != status || data["code"] != float64(code) || data[field] != message || !exists || value != nil {
|
||||||
|
t.Fatalf("unexpected error: %d %s", out.Code, out.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestGeneratedAppPolicies(t *testing.T) {
|
||||||
|
app := application(t)
|
||||||
|
assertError(t, response(t, app, "GET", "/check?kind=fixed", nil, nil), 400, 400002, "用户名格式不正确", "msg")
|
||||||
|
assertError(t, response(t, app, "GET", "/check?kind=params", nil, nil), 400, 400003, "企业名称不能超过100个字符", "msg")
|
||||||
|
assertError(t, response(t, app, "GET", "/check?kind=unknown", nil, nil), 500, 9999, "系统错误", "msg")
|
||||||
|
assertError(t, response(t, app, "GET", "/other?kind=fixed", nil, nil), 400, 400002, "用户名格式不正确", "detail")
|
||||||
|
for _, path := range []string{"/check", "/missing", "/callback"} {
|
||||||
|
assertError(t, response(t, app, "GET", path, nil, map[string]string{"X-Fail": "1"}), 400, 400002, "用户名格式不正确", "msg")
|
||||||
|
}
|
||||||
|
assertError(t, response(t, app, "GET", "/missing", nil, nil), 404, 404001, "接口不存在", "msg")
|
||||||
|
assertError(t, response(t, app, "POST", "/check", nil, nil), 405, 405001, "请求方法不支持", "msg")
|
||||||
|
out := response(t, app, "GET", "/raw", nil, nil)
|
||||||
|
if strings.TrimSpace(out.Body.String()) != `{"bytes":3}` {
|
||||||
|
t.Fatal(out.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestGeneratedTransfers(t *testing.T) {
|
||||||
|
app := application(t)
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
file, err := writer.CreateFormFile("file", "x.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = file.Write([]byte("hello"))
|
||||||
|
_ = writer.Close()
|
||||||
|
out := response(t, app, "POST", "/upload", &body, map[string]string{"Content-Type": writer.FormDataContentType()})
|
||||||
|
if out.Code != 200 || !strings.Contains(out.Body.String(), `"bytes":5`) {
|
||||||
|
t.Fatalf("upload: %d %s", out.Code, out.Body.String())
|
||||||
|
}
|
||||||
|
out = response(t, app, "POST", "/upload", strings.NewReader("bad"), map[string]string{"Content-Type": "application/json"})
|
||||||
|
if out.Code != 415 {
|
||||||
|
t.Fatal(out.Code, out.Body.String())
|
||||||
|
}
|
||||||
|
// Truncated uploads must use the project's public error catalog rather
|
||||||
|
// than succeed or be mistaken for an internal failure.
|
||||||
|
out = response(t, app, "POST", "/upload", strings.NewReader("--b\r\nContent-Disposition: form-data; name=\"file\"\r\n\r\nprivate file"), map[string]string{"Content-Type": "multipart/form-data; boundary=b"})
|
||||||
|
assertError(t, out, 400, 400004, "上传内容不完整", "msg")
|
||||||
|
out = response(t, app, "POST", "/callback", strings.NewReader("{ \"exact\" : 123 }"), map[string]string{"X-Signature": "valid"})
|
||||||
|
if out.Code != 200 || out.Body.String() != "ACK:{ \"exact\" : 123 }" || out.Header().Get("Content-Type") != "text/plain" {
|
||||||
|
t.Fatal(out.Code, out.Body.String())
|
||||||
|
}
|
||||||
|
assertError(t, response(t, app, "POST", "/callback", strings.NewReader("{}"), nil), 400, 400002, "用户名格式不正确", "msg")
|
||||||
|
// Unknown length must be limited by reads too, not just Content-Length.
|
||||||
|
req := httptest.NewRequest("POST", "/callback", strings.NewReader(strings.Repeat("x", 65)))
|
||||||
|
req.ContentLength = -1
|
||||||
|
req.Header.Set("X-Signature", "valid")
|
||||||
|
out = httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(out, req)
|
||||||
|
assertError(t, out, 413, 413001, "请求体太大", "msg")
|
||||||
|
before := svc.DownloadClosed.Load()
|
||||||
|
out = response(t, app, "GET", "/download", nil, nil)
|
||||||
|
if out.Body.String() != "file-content" || out.Header().Get("Content-Type") != "application/octet-stream" || !strings.Contains(out.Header().Get("Content-Disposition"), "attachment") || svc.DownloadClosed.Load() != before+1 {
|
||||||
|
t.Fatal(out.Code, out.Body.String(), out.Header())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func configFile(t *testing.T, address string, fail bool) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
data, _ := json.Marshal(map[string]any{"server": map[string]any{"address": address}, "failInit": fail})
|
||||||
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
func waitReady(t *testing.T, url string, done <-chan error) {
|
||||||
|
t.Helper()
|
||||||
|
client := http.Client{Timeout: 100 * time.Millisecond}
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatalf("startup failed: %v", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
res, err := client.Get(url + "/health/ready")
|
||||||
|
if err == nil {
|
||||||
|
_ = res.Body.Close()
|
||||||
|
if res.StatusCode == 200 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("server did not become ready")
|
||||||
|
}
|
||||||
|
func TestGeneratedMainLifecycle(t *testing.T) {
|
||||||
|
t.Run("initializationFailure", func(t *testing.T) {
|
||||||
|
before := svc.Closed.Load()
|
||||||
|
if err := run(context.Background(), configFile(t, "127.0.0.1:0", true)); err == nil {
|
||||||
|
t.Fatal("failure hidden")
|
||||||
|
}
|
||||||
|
if svc.Closed.Load() != before+1 {
|
||||||
|
t.Fatal("initialized client leaked or closed twice")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("listenFailure", func(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
before := svc.Closed.Load()
|
||||||
|
if err = run(context.Background(), configFile(t, listener.Addr().String(), false)); err == nil {
|
||||||
|
t.Fatal("occupied listener accepted")
|
||||||
|
}
|
||||||
|
if svc.Closed.Load() != before+1 {
|
||||||
|
t.Fatal("listen failure cleanup")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("drainBeforeClose", func(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
address := listener.Addr().String()
|
||||||
|
_ = listener.Close()
|
||||||
|
svc.Closed.Store(0)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
path := configFile(t, address, false)
|
||||||
|
go func() { done <- run(ctx, path) }()
|
||||||
|
base := "http://" + address
|
||||||
|
waitReady(t, base, done)
|
||||||
|
requestDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
res, err := http.Get(base + "/slow")
|
||||||
|
if err == nil {
|
||||||
|
data, _ := io.ReadAll(res.Body)
|
||||||
|
_ = res.Body.Close()
|
||||||
|
if res.StatusCode != 200 || !strings.Contains(string(data), `"bytes":1`) {
|
||||||
|
err = io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestDone <- err
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-svc.Started:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("request did not enter logic")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
if svc.Closed.Load() != 0 {
|
||||||
|
t.Fatal("dependency closed before drain")
|
||||||
|
}
|
||||||
|
close(svc.Release)
|
||||||
|
select {
|
||||||
|
case err := <-requestDone:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("request stuck")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("shutdown stuck")
|
||||||
|
}
|
||||||
|
if svc.Closed.Load() != 1 {
|
||||||
|
t.Fatal("client closed more/less than once", svc.Closed.Load())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
80
cmd/go-omni/apigen/testdata/managed/operations.go
vendored
Normal file
80
cmd/go-omni/apigen/testdata/managed/operations.go
vendored
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package demo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"example.com/managed/internal/svc"
|
||||||
|
"example.com/managed/internal/types"
|
||||||
|
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l *Logic) Check(ctx context.Context, req *types.CheckReq) (*types.Receipt, error) {
|
||||||
|
switch req.Kind {
|
||||||
|
case "fixed":
|
||||||
|
return nil, omnierrors.ByCode("INVALID")
|
||||||
|
case "params":
|
||||||
|
return nil, omnierrors.ByCode("TOO_LONG").WithParams(map[string]any{"label": "企业名称", "max": 100})
|
||||||
|
case "unknown":
|
||||||
|
return nil, omnierrors.ByCode("UNKNOWN").WithCause(errors.New("private cause"))
|
||||||
|
}
|
||||||
|
return &types.Receipt{Bytes: 42}, nil
|
||||||
|
}
|
||||||
|
func (l *Logic) Other(ctx context.Context, req *types.CheckReq) (*types.Receipt, error) {
|
||||||
|
return l.Check(ctx, req)
|
||||||
|
}
|
||||||
|
func (l *Logic) RawJSON(context.Context) (*types.Receipt, error) {
|
||||||
|
return &types.Receipt{Bytes: 3}, nil
|
||||||
|
}
|
||||||
|
func (l *Logic) Upload(ctx context.Context, body *httpx.Body) (*types.Receipt, error) {
|
||||||
|
var total int64
|
||||||
|
for {
|
||||||
|
part, err := body.Parts.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n, err := io.Copy(io.Discard, part)
|
||||||
|
closeErr := part.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return nil, closeErr
|
||||||
|
}
|
||||||
|
total += n
|
||||||
|
}
|
||||||
|
return &types.Receipt{Bytes: total}, nil
|
||||||
|
}
|
||||||
|
func (l *Logic) Callback(ctx context.Context, body *httpx.Body) (*httpx.Stream, error) {
|
||||||
|
// The route's 64-byte limit bounds this example's memory use. A real signature
|
||||||
|
// implementation can hash body.Raw incrementally and authenticate its headers.
|
||||||
|
data, err := io.ReadAll(body.Raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if body.Header.Get("X-Signature") != "valid" {
|
||||||
|
return nil, omnierrors.ByCode("INVALID")
|
||||||
|
}
|
||||||
|
return &httpx.Stream{Content: io.NopCloser(strings.NewReader("ACK:" + string(data)))}, nil
|
||||||
|
}
|
||||||
|
func (l *Logic) Download(context.Context) (*httpx.Stream, error) {
|
||||||
|
return &httpx.Stream{Content: &countedReader{Reader: strings.NewReader("file-content")}, Name: "测试.txt"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type countedReader struct{ io.Reader }
|
||||||
|
|
||||||
|
func (c *countedReader) Close() error { svc.DownloadClosed.Add(1); return nil }
|
||||||
|
func (l *Logic) Slow(context.Context) (*types.Receipt, error) {
|
||||||
|
svc.Started <- struct{}{}
|
||||||
|
<-svc.Release
|
||||||
|
if svc.Closed.Load() != 0 {
|
||||||
|
return nil, errors.New("dependency closed before drain")
|
||||||
|
}
|
||||||
|
return &types.Receipt{Bytes: 1}, nil
|
||||||
|
}
|
||||||
34
cmd/go-omni/apigen/testdata/managed/options.go
vendored
Normal file
34
cmd/go-omni/apigen/testdata/managed/options.go
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package svc
|
||||||
|
|
||||||
|
import (
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *ServiceContext) AppOptions() []omni.Option {
|
||||||
|
return []omni.Option{omni.WithErrors(omnierrors.Catalog{
|
||||||
|
DefaultStatus: 400,
|
||||||
|
Definitions: map[string]omnierrors.Definition{
|
||||||
|
"INVALID": {Code: 400002, Message: "用户名格式不正确"},
|
||||||
|
"TOO_LONG": {Code: 400003, Message: "{label}不能超过{max}个字符", Params: []omnierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int"}}},
|
||||||
|
"INTERNAL_ERROR": {Status: 500, Code: 9999, Message: "系统错误"},
|
||||||
|
"NOT_FOUND": {Status: 404, Code: 404001, Message: "接口不存在"},
|
||||||
|
"METHOD_NOT_ALLOWED": {Status: 405, Code: 405001, Message: "请求方法不支持"},
|
||||||
|
"BODY_TOO_LARGE": {Status: 413, Code: 413001, Message: "请求体太大"},
|
||||||
|
"INVALID_MULTIPART": {Status: 400, Code: 400004, Message: "上传内容不完整"},
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
}
|
||||||
|
func (c *ServiceContext) GlobalMiddleware() []httpx.Middleware {
|
||||||
|
return []httpx.Middleware{
|
||||||
|
func(next httpx.Handler) httpx.Handler {
|
||||||
|
return func(c *httpx.Context) error {
|
||||||
|
if c.Header("X-Fail") != "" {
|
||||||
|
return omnierrors.ByCode("INVALID")
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ type manifest struct {
|
|||||||
Module string `json:"module"`
|
Module string `json:"module"`
|
||||||
Entry string `json:"entry"`
|
Entry string `json:"entry"`
|
||||||
Generated map[string]string `json:"generated"`
|
Generated map[string]string `json:"generated"`
|
||||||
|
Contract *contractSnapshot `json:"contract,omitempty"`
|
||||||
}
|
}
|
||||||
type change struct {
|
type change struct {
|
||||||
name string
|
name string
|
||||||
@@ -29,7 +30,7 @@ type change struct {
|
|||||||
mode os.FileMode
|
mode os.FileMode
|
||||||
}
|
}
|
||||||
|
|
||||||
func apply(root, module, entry string, generated, seeds map[string][]byte, check, dryRun bool, profile projectProfile) (Output, error) {
|
func apply(root, module, entry string, generated, seeds map[string][]byte, check, dryRun bool, profile projectProfile, contract *contractSnapshot) (Output, error) {
|
||||||
var result Output
|
var result Output
|
||||||
if info, err := os.Lstat(root); err == nil {
|
if info, err := os.Lstat(root); err == nil {
|
||||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||||
@@ -82,7 +83,7 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
|||||||
if old.Profile != nil && *old.Profile != profile {
|
if old.Profile != nil && *old.Profile != profile {
|
||||||
return result, errors.New("api: profile changed during generation; retry")
|
return result, errors.New("api: profile changed during generation; retry")
|
||||||
}
|
}
|
||||||
if old.Version != 1 || old.Module != module || old.Entry != entry {
|
if old.Version != 2 || old.Module != module || old.Entry != entry {
|
||||||
return result, errors.New("api: manifest version/module/entry differs; one API entry owns this project")
|
return result, errors.New("api: manifest version/module/entry differs; one API entry owns this project")
|
||||||
}
|
}
|
||||||
} else if !errors.Is(err, os.ErrNotExist) {
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
@@ -107,7 +108,7 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
next := manifest{Profile: &profile, Version: 1, Module: module, Entry: entry, Generated: map[string]string{}}
|
next := manifest{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)
|
||||||
}
|
}
|
||||||
@@ -179,6 +180,20 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
|||||||
}
|
}
|
||||||
changes = append(changes, change{name: name, before: current, after: content, existed: exists, mode: mode})
|
changes = append(changes, change{name: name, before: current, after: content, existed: exists, mode: mode})
|
||||||
}
|
}
|
||||||
|
result.Diagnostics = append(result.Diagnostics, compareContracts(old.Contract, contract)...)
|
||||||
|
logicDiagnostics, locations := checkLogic(root, module, contract, seeds)
|
||||||
|
for i := range result.Diagnostics {
|
||||||
|
if d := &result.Diagnostics[i]; d.Code == "contractChanged" {
|
||||||
|
if location, found := locations[d.Path]; found {
|
||||||
|
d.Path, d.Line = location.path, location.line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.Diagnostics = append(result.Diagnostics, logicDiagnostics...)
|
||||||
|
if oldData != nil && old.Contract == nil {
|
||||||
|
result.Diagnostics = append(result.Diagnostics, Diagnostic{Severity: "review", Code: "contractBaseline", Path: manifestPath, Message: "no saved contract snapshot; past contract changes cannot be compared; regeneration establishes a baseline"})
|
||||||
|
}
|
||||||
|
sortDiagnostics(result.Diagnostics)
|
||||||
data, err := json.MarshalIndent(next, "", " ")
|
data, err := json.MarshalIndent(next, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
@@ -202,15 +217,19 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
if check {
|
if check {
|
||||||
|
var checkErr error
|
||||||
if len(changes) > 0 {
|
if len(changes) > 0 {
|
||||||
var stale []string
|
var stale []string
|
||||||
for _, c := range changes {
|
for _, c := range changes {
|
||||||
stale = append(stale, c.name)
|
stale = append(stale, c.name)
|
||||||
}
|
}
|
||||||
sort.Strings(stale)
|
sort.Strings(stale)
|
||||||
return result, fmt.Errorf("api: generated project is stale: %s", strings.Join(stale, ", "))
|
checkErr = fmt.Errorf("api: generated project is stale: %s", strings.Join(stale, ", "))
|
||||||
}
|
}
|
||||||
return result, nil
|
if count := diagnosticErrors(result.Diagnostics); count > 0 {
|
||||||
|
checkErr = errors.Join(checkErr, fmt.Errorf("api: %d Logic source checks failed; see diagnostics and run go test ./... after fixing business code", count))
|
||||||
|
}
|
||||||
|
return result, checkErr
|
||||||
}
|
}
|
||||||
// A rollback restores bytes/modes if an ordinary filesystem write fails.
|
// A rollback restores bytes/modes if an ordinary filesystem write fails.
|
||||||
// File replacement is atomic; the whole directory is not crash-atomic.
|
// File replacement is atomic; the whole directory is not crash-atomic.
|
||||||
@@ -252,7 +271,7 @@ 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/handler/responseGen.go" || name == "internal/types/scalarsGen.go" {
|
if name == "main.go" || name == "internal/handler/appGen.go" || name == "internal/handler/responseGen.go" || name == "internal/types/scalarsGen.go" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if name == "internal/apierrors/errorsGen.go" || name == "docs/openapi.json" || name == "internal/types/typesGen.go" || name == "internal/handler/routesGen.go" || name == "internal/handler/openapiGen.go" || name == "internal/middleware/registryGen.go" {
|
if name == "internal/apierrors/errorsGen.go" || name == "docs/openapi.json" || name == "internal/types/typesGen.go" || name == "internal/handler/routesGen.go" || name == "internal/handler/openapiGen.go" || name == "internal/middleware/registryGen.go" {
|
||||||
|
|||||||
@@ -142,26 +142,22 @@ func runNew(args []string) error {
|
|||||||
options.Module = filepath.Base(filepath.Clean(target))
|
options.Module = filepath.Base(filepath.Clean(target))
|
||||||
}
|
}
|
||||||
options.Dir = target
|
options.Dir = target
|
||||||
|
if options.Force {
|
||||||
|
return errors.New("managed projects do not accept --force; use api generate to update owned files")
|
||||||
|
}
|
||||||
|
o := apigen.Options{API: apiFile, Dir: target, Module: options.Module, FrameworkDir: options.FrameworkDir, FrameworkVersion: options.FrameworkVersion, LogOutput: options.LogOutput, LogFormat: options.LogFormat, Database: options.Database, Storage: options.Storage}
|
||||||
|
var err error
|
||||||
|
var out apigen.Output
|
||||||
if apiFile != "" {
|
if apiFile != "" {
|
||||||
if options.Force {
|
out, err = apigen.Generate(o)
|
||||||
return errors.New("--api protects existing business code and does not accept --force")
|
} else {
|
||||||
}
|
out, err = apigen.New(o)
|
||||||
_, err := apigen.Generate(apigen.Options{API: apiFile, Dir: target, Module: options.Module, FrameworkDir: options.FrameworkDir, FrameworkVersion: options.FrameworkVersion, LogOutput: options.LogOutput, LogFormat: options.LogFormat, Database: options.Database, Storage: options.Storage})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("created API project %s; implement internal/service operations\n", filepath.Clean(target))
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
if _, err := os.Lstat(filepath.Join(target, ".goOmni", "apiManifest.json")); err == nil {
|
printAPIDiagnostics(out.Diagnostics)
|
||||||
return errors.New("this is an API project; use api generate instead of the CRUD/module starter")
|
if err != nil {
|
||||||
} else if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := scaffold.Generate(options); err != nil {
|
fmt.Printf("created API project %s; implement internal/logic operations; run with go run .\n", filepath.Clean(target))
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("created GoOmni project %s\n", filepath.Clean(target))
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +166,7 @@ func usage() {
|
|||||||
fmt.Fprintln(os.Stderr, "")
|
fmt.Fprintln(os.Stderr, "")
|
||||||
fmt.Fprintln(os.Stderr, "Usage:")
|
fmt.Fprintln(os.Stderr, "Usage:")
|
||||||
fmt.Fprintln(os.Stderr, " go-omni migrate [--dir migrations] [--apply] [--dsn-env GO_OMNI_DB_DSN]")
|
fmt.Fprintln(os.Stderr, " go-omni migrate [--dir migrations] [--apply] [--dsn-env GO_OMNI_DB_DSN]")
|
||||||
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] [--force] <directory>")
|
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] <directory>")
|
||||||
fmt.Fprintln(os.Stderr, " go-omni new --api <file.api> [--module <module>] <directory>")
|
fmt.Fprintln(os.Stderr, " go-omni new --api <file.api> [--module <module>] <directory>")
|
||||||
fmt.Fprintln(os.Stderr, " go-omni api new [--module <module>] <directory> Create a runnable API project")
|
fmt.Fprintln(os.Stderr, " go-omni api new [--module <module>] <directory> Create a runnable API project")
|
||||||
fmt.Fprintln(os.Stderr, " go-omni api generate [--api <file.api>] [--dir <project>] [--check|--dry-run]")
|
fmt.Fprintln(os.Stderr, " go-omni api generate [--api <file.api>] [--dir <project>] [--check|--dry-run]")
|
||||||
@@ -180,7 +176,7 @@ func usage() {
|
|||||||
fmt.Fprintln(os.Stderr, " go-omni add <name> [--dir <project>] Generate an isolated business module")
|
fmt.Fprintln(os.Stderr, " go-omni add <name> [--dir <project>] Generate an isolated business module")
|
||||||
fmt.Fprintln(os.Stderr, " --framework-version <version> Pin a remote framework version (defaults to CLI version)")
|
fmt.Fprintln(os.Stderr, " --framework-version <version> Pin a remote framework version (defaults to CLI version)")
|
||||||
fmt.Fprintln(os.Stderr, " --framework-dir <directory> Use a local framework checkout")
|
fmt.Fprintln(os.Stderr, " --framework-dir <directory> Use a local framework checkout")
|
||||||
fmt.Fprintln(os.Stderr, " --database <none|memory|mysql> API defaults to none; CRUD defaults to memory")
|
fmt.Fprintln(os.Stderr, " --database <none|memory|mysql> Defaults to none")
|
||||||
fmt.Fprintln(os.Stderr, " --log-output <stdout|stderr|file> Generate configured logging (default stderr)")
|
fmt.Fprintln(os.Stderr, " --log-output <stdout|stderr|file> Generate configured logging (default stderr)")
|
||||||
fmt.Fprintln(os.Stderr, " --log-format <json|text> Select structured log format (default json)")
|
fmt.Fprintln(os.Stderr, " --log-format <json|text> Select structured log format (default json)")
|
||||||
fmt.Fprintln(os.Stderr, " --storage <none|local|s3|r2|oss> Select optional storage adapter (default none)")
|
fmt.Fprintln(os.Stderr, " --storage <none|local|s3|r2|oss> Select optional storage adapter (default none)")
|
||||||
@@ -207,6 +203,9 @@ func runAdd(args []string) error {
|
|||||||
return fmt.Errorf("unexpected argument %q", arg)
|
return fmt.Errorf("unexpected argument %q", arg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(directory, ".goOmni", "apiManifest.json")); err == nil {
|
||||||
|
return errors.New("managed API projects add modules through .api imports and api generate")
|
||||||
|
}
|
||||||
if err := scaffold.AddModule(directory, name); err != nil {
|
if err := scaffold.AddModule(directory, name); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||||
for _, flag := range [][]string{{"--framework-version", "v0.1.0"}, {"--framework-version=v0.1.0"}} {
|
for _, flag := range [][]string{{"--framework-version", "v0.12.0-preview"}, {"--framework-version=v0.12.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.1.0") || strings.Contains(string(content), "replace ") {
|
if !strings.Contains(string(content), "go-omni v0.12.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,11 +42,8 @@ func TestAddModuleCLI(t *testing.T) {
|
|||||||
if equals {
|
if equals {
|
||||||
args = []string{"--dir=" + project, "orders"}
|
args = []string{"--dir=" + project, "orders"}
|
||||||
}
|
}
|
||||||
if err := runAdd(args); err != nil {
|
if err := runAdd(args); err == nil || !strings.Contains(err.Error(), ".api imports") {
|
||||||
t.Fatal(err)
|
t.Fatalf("expected contract-driven module guidance: %v", err)
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(project, "internal/orders/module.go")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, args := range [][]string{nil, {"orders", "--dir"}, {"orders", "--dir="}, {"orders", "extra"}, {"orders", "--unknown"}} {
|
for _, args := range [][]string{nil, {"orders", "--dir"}, {"orders", "--dir="}, {"orders", "extra"}, {"orders", "--unknown"}} {
|
||||||
@@ -63,7 +60,7 @@ func TestNewMySQLDatabaseOptions(t *testing.T) {
|
|||||||
if err := runNew(append(args, project)); err != nil {
|
if err := runNew(append(args, project)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(project, "schema.sql")); err != nil {
|
if _, err := os.Stat(filepath.Join(project, "internal/svc/serviceContext.go")); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
func APIInfrastructure(module, database, storage, logOutput, logFormat string) (map[string][]byte, error) {
|
func APIInfrastructure(module, database, storage, logOutput, logFormat string) (map[string][]byte, error) {
|
||||||
data := templateData{Module: module, Database: database, Storage: storage, LogOutput: logOutput, LogFormat: logFormat, API: true, Modules: true}
|
data := templateData{Module: module, Database: database, Storage: storage, LogOutput: logOutput, LogFormat: logFormat, API: true, Modules: true}
|
||||||
files := map[string]string{
|
files := map[string]string{
|
||||||
"internal/settings/config.go": "templates/modules-settings.go.tmpl",
|
"internal/svc/close.go": "templates/api-close.go.tmpl",
|
||||||
"etc/config.yaml": "templates/modules-config.yaml.tmpl",
|
"internal/svc/options.go": "templates/api-options.go.tmpl",
|
||||||
"internal/servicecontext/context.go": "templates/api-context.go.tmpl",
|
"internal/config/config.go": "templates/modules-settings.go.tmpl",
|
||||||
|
"etc/config.yaml": "templates/modules-config.yaml.tmpl",
|
||||||
|
"internal/svc/serviceContext.go": "templates/api-context.go.tmpl",
|
||||||
}
|
}
|
||||||
if storage != "none" {
|
if storage != "none" {
|
||||||
files["internal/servicecontext/storage.go"] = "templates/modules-storage.go.tmpl"
|
files["internal/svc/storage.go"] = "templates/modules-storage.go.tmpl"
|
||||||
}
|
}
|
||||||
result := make(map[string][]byte, len(files))
|
result := make(map[string][]byte, len(files))
|
||||||
for name, source := range files {
|
for name, source := range files {
|
||||||
@@ -23,6 +25,15 @@ func APIInfrastructure(module, database, storage, logOutput, logFormat string) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("render %s: %w", name, err)
|
return nil, fmt.Errorf("render %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
content = strings.ReplaceAll(content, "package settings", "package config")
|
||||||
|
content = strings.ReplaceAll(content, "package servicecontext", "package svc")
|
||||||
|
content = strings.ReplaceAll(content, "/internal/settings", "/internal/config")
|
||||||
|
content = strings.ReplaceAll(content, "settings.", "config.")
|
||||||
|
if strings.HasPrefix(name, "internal/svc/") {
|
||||||
|
content = strings.ReplaceAll(content, "*Context", "*ServiceContext")
|
||||||
|
content = strings.ReplaceAll(content, "&Context{", "&ServiceContext{")
|
||||||
|
content = strings.ReplaceAll(content, "type Context struct", "type ServiceContext struct")
|
||||||
|
}
|
||||||
result[name] = []byte(strings.TrimRight(content, "\n") + "\n")
|
result[name] = []byte(strings.TrimRight(content, "\n") + "\n")
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
22
cmd/go-omni/scaffold/templates/api-close.go.tmpl
Normal file
22
cmd/go-omni/scaffold/templates/api-close.go.tmpl
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package servicecontext
|
||||||
|
import "errors"
|
||||||
|
// Close is idempotent, including startup failure and graceful shutdown.
|
||||||
|
func(c *Context)Close()error{
|
||||||
|
if c==nil{return nil}
|
||||||
|
c.closeOnce.Do(func(){
|
||||||
|
c.closeMu.Lock();c.closed=true;cleanups:=c.cleanups;c.cleanups=nil;c.closeMu.Unlock()
|
||||||
|
for i:=len(cleanups)-1;i>=0;i--{c.closeErr=errors.Join(c.closeErr,cleanups[i]())}
|
||||||
|
{{if ne .Storage "none"}}if c.storageClose!=nil{c.closeErr=errors.Join(c.closeErr,c.storageClose())}{{end}}
|
||||||
|
{{if eq .Database "mysql"}}if c.DB!=nil{c.closeErr=errors.Join(c.closeErr,c.DB.Close())}{{end}}
|
||||||
|
})
|
||||||
|
return c.closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnClose transfers a resource cleanup to this context immediately after creation.
|
||||||
|
// Hooks run once in reverse order, also when later initialization fails.
|
||||||
|
func(c *Context)OnClose(cleanup func()error) {
|
||||||
|
if cleanup==nil{return}
|
||||||
|
c.closeMu.Lock();defer c.closeMu.Unlock()
|
||||||
|
if c.closed{panic("svc: register cleanup after Close")}
|
||||||
|
c.cleanups=append(c.cleanups,cleanup)
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ import (
|
|||||||
// Context owns infrastructure, while Logger is borrowed from main.
|
// Context owns infrastructure, while Logger is borrowed from main.
|
||||||
// Add business repositories here; HTTP contracts do not define database tables.
|
// Add business repositories here; HTTP contracts do not define database tables.
|
||||||
type Context struct {
|
type Context struct {
|
||||||
|
Config settings.Config
|
||||||
|
cleanups []func()error
|
||||||
Logger *logx.Logger
|
Logger *logx.Logger
|
||||||
Health *health.Checker
|
Health *health.Checker
|
||||||
Middlewares map[string]httpx.Middleware
|
Middlewares map[string]httpx.Middleware
|
||||||
@@ -43,7 +45,7 @@ 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]}
|
||||||
_=cfg
|
_=cfg
|
||||||
checker,err:=health.New();if err!=nil{return nil,err}
|
checker,err:=health.New();if err!=nil{return nil,err}
|
||||||
c:=&Context{Logger:logger,Health:checker,Middlewares:middleware.Defaults()}
|
c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults()}
|
||||||
defer func(){if err!=nil{err=errors.Join(err,c.Close())}}()
|
defer func(){if err!=nil{err=errors.Join(err,c.Close())}}()
|
||||||
{{if eq .Database "mysql"}}
|
{{if eq .Database "mysql"}}
|
||||||
if err=cfg.Validate();err!=nil{return nil,err}
|
if err=cfg.Validate();err!=nil{return nil,err}
|
||||||
@@ -57,13 +59,3 @@ func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(res
|
|||||||
{{if ne .Storage "none"}}if err=c.InitStorage(ctx,cfg.Storage);err!=nil{return nil,err}{{end}}
|
{{if ne .Storage "none"}}if err=c.InitStorage(ctx,cfg.Storage);err!=nil{return nil,err}{{end}}
|
||||||
return c,nil
|
return c,nil
|
||||||
}
|
}
|
||||||
// Close is idempotent, including startup failure and graceful shutdown.
|
|
||||||
func(c *Context)Close()error{
|
|
||||||
if c==nil{return nil}
|
|
||||||
c.closeMu.Lock();defer c.closeMu.Unlock();c.closed=true
|
|
||||||
c.closeOnce.Do(func(){
|
|
||||||
{{if ne .Storage "none"}}if c.storageClose!=nil{c.closeErr=errors.Join(c.closeErr,c.storageClose())}{{end}}
|
|
||||||
{{if eq .Database "mysql"}}if c.DB!=nil{c.closeErr=errors.Join(c.closeErr,c.DB.Close())}{{end}}
|
|
||||||
})
|
|
||||||
return c.closeErr
|
|
||||||
}
|
|
||||||
|
|||||||
11
cmd/go-omni/scaffold/templates/api-options.go.tmpl
Normal file
11
cmd/go-omni/scaffold/templates/api-options.go.tmpl
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package servicecontext
|
||||||
|
import (
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
)
|
||||||
|
// AppOptions runs before routes are registered. Register project error catalogs
|
||||||
|
// here with omni.WithErrors. It must not create resources or another App.
|
||||||
|
func(c *Context)AppOptions()[]omni.Option{return nil}
|
||||||
|
// GlobalMiddleware runs for all endpoints, including App 404/405 fallbacks.
|
||||||
|
// Named route middleware lives in Middlewares and is selected by the API contract.
|
||||||
|
func(c *Context)GlobalMiddleware()[]httpx.Middleware{return nil}
|
||||||
@@ -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.11.1-preview"
|
const defaultFrameworkVersion = "v0.12.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.
|
||||||
|
|||||||
@@ -56,14 +56,18 @@ func (c Config) Validate() error {
|
|||||||
if err := c.Errors.Validate(); err != nil {
|
if err := c.Errors.Validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if c.ResponseEncoder == nil {
|
encoder := c.ResponseEncoder
|
||||||
|
if encoder == nil {
|
||||||
f, err := response.New(c.Response)
|
f, err := response.New(c.Response)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = f.ValidateErrors(c.Errors); err != nil {
|
encoder = f
|
||||||
return err
|
}
|
||||||
}
|
// Check the same composition that App.New installs, including custom
|
||||||
|
// encoders and their existing catalogs, before App construction.
|
||||||
|
if _, err := response.WithErrors(c.Errors, encoder); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if err := c.Response.Validate(); err != nil {
|
if err := c.Response.Validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
41
config/response_test.go
Normal file
41
config/response_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateEffectiveResponsePolicy(t *testing.T) {
|
||||||
|
catalog := apierrors.Catalog{DefaultStatus: 400, Definitions: map[string]apierrors.Definition{
|
||||||
|
"INVALID": {Code: 400002, Message: "invalid input"},
|
||||||
|
}}
|
||||||
|
other := catalog.Clone()
|
||||||
|
other.Definitions["INVALID"] = apierrors.Definition{Code: 400003, Message: "other input"}
|
||||||
|
formatter := response.MustNew(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999})
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
encoder response.Encoder
|
||||||
|
wantError string
|
||||||
|
}{
|
||||||
|
{"incompatibleCode", response.RawJSON(response.MustNew(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, Codes: map[string]any{"INVALID": 100}})), "conflicting"},
|
||||||
|
{"differentCatalog", response.MustWithErrors(other, formatter), "different error catalogs"},
|
||||||
|
{"matchingCatalog", response.MustWithErrors(catalog, formatter), ""},
|
||||||
|
{"rawJSON", response.RawJSON(response.MustNew(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999})), ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cfg := Default()
|
||||||
|
cfg.Errors, cfg.ResponseEncoder = catalog, tc.encoder
|
||||||
|
err := cfg.Validate()
|
||||||
|
if tc.wantError == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
} else if err == nil || !strings.Contains(err.Error(), tc.wantError) {
|
||||||
|
t.Fatalf("want %q, got %v", tc.wantError, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
57
examples/contracts/managed.api
Normal file
57
examples/contracts/managed.api
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
syntax = "v1"
|
||||||
|
|
||||||
|
// Error definitions are intentionally owned by Go, not duplicated here.
|
||||||
|
@app (
|
||||||
|
defaultResponse: CommonResponse
|
||||||
|
)
|
||||||
|
response CommonResponse (
|
||||||
|
codeType: int32
|
||||||
|
successCode: 0
|
||||||
|
errorCode: 9999
|
||||||
|
messageField: msg
|
||||||
|
dataMode: always
|
||||||
|
requestIdField: "-"
|
||||||
|
)
|
||||||
|
response AlternateResponse (
|
||||||
|
codeType: int32
|
||||||
|
successCode: 0
|
||||||
|
errorCode: 9999
|
||||||
|
messageField: detail
|
||||||
|
dataMode: always
|
||||||
|
requestIdField: "-"
|
||||||
|
)
|
||||||
|
type CheckReq {
|
||||||
|
Kind string `form:"kind,optional"`
|
||||||
|
}
|
||||||
|
type Receipt {
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
}
|
||||||
|
@server (group: demo)
|
||||||
|
service managed-api {
|
||||||
|
@handler check
|
||||||
|
get /check (CheckReq) returns (Receipt)
|
||||||
|
|
||||||
|
@handler other
|
||||||
|
@response AlternateResponse
|
||||||
|
get /other (CheckReq) returns (Receipt)
|
||||||
|
|
||||||
|
@handler rawJSON
|
||||||
|
@response raw
|
||||||
|
get /raw returns (Receipt)
|
||||||
|
|
||||||
|
@handler upload
|
||||||
|
@body (mode: multipart; maxBytes: 2048)
|
||||||
|
post /upload returns (Receipt)
|
||||||
|
|
||||||
|
@handler callback
|
||||||
|
@body (mode: raw; maxBytes: 64)
|
||||||
|
@produces "text/plain"
|
||||||
|
post /callback
|
||||||
|
|
||||||
|
@handler download
|
||||||
|
@produces "application/octet-stream"
|
||||||
|
get /download
|
||||||
|
|
||||||
|
@handler slow
|
||||||
|
get /slow returns (Receipt)
|
||||||
|
}
|
||||||
8
examples/managedService/.gitignore
vendored
Normal file
8
examples/managedService/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/server
|
||||||
|
/var/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
/.goOmni/api.lock
|
||||||
|
|
||||||
|
/managed
|
||||||
161
examples/managedService/.goOmni/apiManifest.json
Normal file
161
examples/managedService/.goOmni/apiManifest.json
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
{
|
||||||
|
"profile": {
|
||||||
|
"database": "none",
|
||||||
|
"storage": "none",
|
||||||
|
"logOutput": "stderr",
|
||||||
|
"logFormat": "json"
|
||||||
|
},
|
||||||
|
"version": 2,
|
||||||
|
"module": "example.com/managed",
|
||||||
|
"entry": "api/managed.api",
|
||||||
|
"generated": {
|
||||||
|
"docs/openapi.json": "9eb707aefeefd3f4881c774534e2d143949c0edb1d8a4aabd762f978f6203df1",
|
||||||
|
"internal/handler/appGen.go": "dbadfb73165e0e6ef10e8277dd1b8a0edc2992dac3690d634808ac92829c2654",
|
||||||
|
"internal/handler/demo/handlersGen.go": "7a6f9d5e105e44b07196cc540cee3f0562d5f3d1c5f0bc094434db1064d0339c",
|
||||||
|
"internal/handler/openapiGen.go": "612920ed25e5dc7a4f673be56041d47b70aa3216707ff282e52dfe503c2d705a",
|
||||||
|
"internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399",
|
||||||
|
"internal/handler/routesGen.go": "76c1a5b2644e75e56553dc6e9e862a63128d902dd94807ce7c8aee5491cd1e3e",
|
||||||
|
"internal/middleware/registryGen.go": "e5f47efa57a1432765ef3419bae59dbf3b4d55b3aac0a6c935ce7a52ba6b47f4",
|
||||||
|
"internal/types/typesGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
|
||||||
|
"main.go": "09ae81857b806f3f7dca608399b1881d94bc9c4b2258e774e77d694fcadbab65"
|
||||||
|
},
|
||||||
|
"contract": {
|
||||||
|
"routes": {
|
||||||
|
"demo/callback": {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/callback",
|
||||||
|
"group": "demo",
|
||||||
|
"handler": "callback",
|
||||||
|
"signature": "Callback(context.Context, *httpx.Body) (*httpx.Stream, error)",
|
||||||
|
"bodyMode": "raw",
|
||||||
|
"produces": "text/plain",
|
||||||
|
"maxBodyBytes": 64
|
||||||
|
},
|
||||||
|
"demo/check": {
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/check",
|
||||||
|
"group": "demo",
|
||||||
|
"handler": "check",
|
||||||
|
"signature": "Check(context.Context, *types.CheckReq) (*types.Receipt, error)",
|
||||||
|
"request": "CheckReq",
|
||||||
|
"response": "Receipt",
|
||||||
|
"types": {
|
||||||
|
"CheckReq": {
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "Kind",
|
||||||
|
"type": "string",
|
||||||
|
"tag": "form:\"kind,optional\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Receipt": {
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "Bytes",
|
||||||
|
"type": "int64",
|
||||||
|
"tag": "json:\"bytes\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"demo/download": {
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/download",
|
||||||
|
"group": "demo",
|
||||||
|
"handler": "download",
|
||||||
|
"signature": "Download(context.Context) (*httpx.Stream, error)",
|
||||||
|
"produces": "application/octet-stream"
|
||||||
|
},
|
||||||
|
"demo/other": {
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/other",
|
||||||
|
"group": "demo",
|
||||||
|
"handler": "other",
|
||||||
|
"signature": "Other(context.Context, *types.CheckReq) (*types.Receipt, error)",
|
||||||
|
"request": "CheckReq",
|
||||||
|
"response": "Receipt",
|
||||||
|
"types": {
|
||||||
|
"CheckReq": {
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "Kind",
|
||||||
|
"type": "string",
|
||||||
|
"tag": "form:\"kind,optional\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Receipt": {
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "Bytes",
|
||||||
|
"type": "int64",
|
||||||
|
"tag": "json:\"bytes\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"demo/rawJSON": {
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/raw",
|
||||||
|
"group": "demo",
|
||||||
|
"handler": "rawJSON",
|
||||||
|
"signature": "RawJSON(context.Context) (*types.Receipt, error)",
|
||||||
|
"response": "Receipt",
|
||||||
|
"types": {
|
||||||
|
"Receipt": {
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "Bytes",
|
||||||
|
"type": "int64",
|
||||||
|
"tag": "json:\"bytes\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"demo/slow": {
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/slow",
|
||||||
|
"group": "demo",
|
||||||
|
"handler": "slow",
|
||||||
|
"signature": "Slow(context.Context) (*types.Receipt, error)",
|
||||||
|
"response": "Receipt",
|
||||||
|
"types": {
|
||||||
|
"Receipt": {
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "Bytes",
|
||||||
|
"type": "int64",
|
||||||
|
"tag": "json:\"bytes\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"demo/upload": {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/upload",
|
||||||
|
"group": "demo",
|
||||||
|
"handler": "upload",
|
||||||
|
"signature": "Upload(context.Context, *httpx.Body) (*types.Receipt, error)",
|
||||||
|
"response": "Receipt",
|
||||||
|
"bodyMode": "multipart",
|
||||||
|
"maxBodyBytes": 2048,
|
||||||
|
"types": {
|
||||||
|
"Receipt": {
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "Bytes",
|
||||||
|
"type": "int64",
|
||||||
|
"tag": "json:\"bytes\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
examples/managedService/README.md
Normal file
175
examples/managedService/README.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# example.com/managed
|
||||||
|
|
||||||
|
This checked-in example uses a relative local framework replacement (`../..`).
|
||||||
|
Run `go mod tidy && go run .` here. No external database or cloud account is required.
|
||||||
|
The Go-only error catalog is `internal/apierrors/catalog.go`, registered by svc.
|
||||||
|
The generated main and handlers are unmodified. Try:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -i 'http://127.0.0.1:8080/check?kind=fixed'
|
||||||
|
curl -i 'http://127.0.0.1:8080/check?kind=params'
|
||||||
|
curl -i 'http://127.0.0.1:8080/missing'
|
||||||
|
curl -i -H 'X-Signature: valid' --data-binary ' {"hello":1} ' http://127.0.0.1:8080/callback
|
||||||
|
curl -i -F 'file=hello' http://127.0.0.1:8080/upload
|
||||||
|
curl -OJ http://127.0.0.1:8080/download
|
||||||
|
```
|
||||||
|
|
||||||
|
The upload demonstration caps requests at 2048 bytes, so use a small test file;
|
||||||
|
large input is intentionally rejected with HTTP 413. Replace the demonstration
|
||||||
|
`X-Signature` check with the actual provider's signature algorithm and secret.
|
||||||
|
From the framework root, check generated files with:
|
||||||
|
`go run ./cmd/go-omni api generate --dir examples/managedService --check`.
|
||||||
|
|
||||||
|
Contract changes produce REVIEW notices for affected Logic methods and DTO fields.
|
||||||
|
Definite Logic signature problems produce ERROR diagnostics with a file/line and
|
||||||
|
the required signature. Regeneration preserves business code; `--check` continues
|
||||||
|
to fail on unresolved signature errors after generation. Run `go test ./...` too,
|
||||||
|
especially for project type aliases, build tags and changes inside method bodies.
|
||||||
|
|
||||||
|
|
||||||
|
This project is generated from an editable API contract. Business code uses
|
||||||
|
standard context.Context; generated HTTP handlers use Omni's transport-neutral httpx.
|
||||||
|
main.go and HTTP composition are continuously generator-owned.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go mod tidy
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
GET /health/live and /health/ready check the running service. GET /openapi.json
|
||||||
|
serves the same document as docs/openapi.json. Bind address defaults to localhost.
|
||||||
|
Set GO_OMNI_TRANSPORT=native to select the native transport without changing services.
|
||||||
|
The greeting starter works immediately; other new operation stubs return 501 until
|
||||||
|
their business logic is implemented. Authentication hooks also fail closed with 501.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
1. Edit api/managed.api and its relative imports.
|
||||||
|
2. Run the same go-omni CLI version as the framework dependency:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go-omni api validate
|
||||||
|
go-omni api inspect
|
||||||
|
go-omni api generate --dry-run
|
||||||
|
go-omni api generate
|
||||||
|
go-omni api generate --check
|
||||||
|
go-omni api export > openapi.json
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Commands inside a generated project discover the recorded API entry automatically.
|
||||||
|
--dry-run previews create/update/remove actions without writing files.
|
||||||
|
The --check command is read-only and suitable for CI. It fails on stale or missing
|
||||||
|
generated files and missing business scaffolds. go.mod, configuration, Logic,
|
||||||
|
Repository, svc, middleware hook bodies and API files are created once and then
|
||||||
|
preserved byte for byte. main.go, Gen.go files and OpenAPI are owned
|
||||||
|
by the generator; manual edits cause a conflict instead of being overwritten.
|
||||||
|
Commit .goOmni/apiManifest.json alongside the generated files. Do not edit it.
|
||||||
|
Avoid editing files while generation is running; api.lock serializes generators.
|
||||||
|
|
||||||
|
Routes and types that disappear are removed from generated output. Handwritten
|
||||||
|
business files remain: remove/adapt obsolete implementations explicitly if an
|
||||||
|
API change makes their old types or signatures fail to compile. Every regeneration
|
||||||
|
must be followed by go test ./... . Do not delete service files to change contracts.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
api/ # editable contract and imported types
|
||||||
|
main.go # GENERATED startup, lifecycle and route registration
|
||||||
|
etc/config.yaml # lowerCamelCase configuration
|
||||||
|
internal/config/ # defaults -> file -> environment
|
||||||
|
internal/svc/ # dependency initialization and cleanup
|
||||||
|
internal/types/typesGen.go # generated request/response types
|
||||||
|
internal/apierrors/errorsGen.go # generated fresh error constructors, when declared
|
||||||
|
internal/handler/<group>/ # generated binding and Logic adapters
|
||||||
|
internal/logic/<group>/ # developer-owned business operations
|
||||||
|
internal/repository/<group>/ # developer-owned persistence contracts
|
||||||
|
internal/middleware/ # named hooks and generated registry
|
||||||
|
docs/openapi.json # generated OpenAPI 3.0.3
|
||||||
|
.goOmni/apiManifest.json # ownership and content hashes
|
||||||
|
```
|
||||||
|
|
||||||
|
The API response type describes business data. Declare response Name (...) and
|
||||||
|
select the App default with @app(defaultResponse: Name) in the entry API.
|
||||||
|
@server(response: Name) and @response Name override it for groups/routes;
|
||||||
|
@response raw returns the business JSON directly. Without a template, the default
|
||||||
|
envelope includes code, message, data and requestId. Generated routes and OpenAPI
|
||||||
|
use the same contract. 404/405 and ordinary App routes use the declared App default.
|
||||||
|
No template is selected by declaration/import order. Without @app, existing framework
|
||||||
|
response defaults apply. An explicit API App default takes precedence over response
|
||||||
|
options in svc, keeping generated routes and OpenAPI consistent.
|
||||||
|
Handlers reject nil successful responses. Body DTOs use application/json and have a 1 MiB limit by default.
|
||||||
|
Use @body(maxBytes: ...) for route limits. @body(mode: raw|multipart; maxBytes: ...)
|
||||||
|
adds a *httpx.Body argument to Logic, without handwritten handlers. Raw is the exact
|
||||||
|
limited request stream; Parts supports sequential multipart reading with no temp files.
|
||||||
|
@produces "application/octet-stream" (or another concrete media type) generates a
|
||||||
|
Logic result of *httpx.Stream; the handler closes Content and streams it unwrapped.
|
||||||
|
Signature verification and storage policy belong to Logic/middleware. A consumed
|
||||||
|
request stream cannot be reread automatically; use one reader owner or explicitly
|
||||||
|
bounded buffering. Stream reads respect configured server timeouts. Multipart parts
|
||||||
|
are not DTO form fields: form tags remain URL query parameters. The optional manual
|
||||||
|
handler/custom binder hooks are preserved, and are needed only for custom protocols.
|
||||||
|
|
||||||
|
Fields require exactly one source tag: json, path, form (URL query only), header.
|
||||||
|
All are required by presence unless optional, omitempty or default is specified.
|
||||||
|
Zero and false are valid present values; binding:"required" rejects scalar zero.
|
||||||
|
Use *T with optional when business logic needs to distinguish missing from zero.
|
||||||
|
Pointers may carry null; non-pointer null is rejected. Unknown/duplicate JSON
|
||||||
|
fields and duplicate scalar query/header values are rejected. Arrays in the query
|
||||||
|
use repeated keys. Enum/range/default and nested fields share the documentation
|
||||||
|
and binding compiler. Responses are expected to honor their declared constraints;
|
||||||
|
they are not validated on every response. Do not return nil slices/maps for a
|
||||||
|
non-nullable collection (initialize empty collections instead).
|
||||||
|
|
||||||
|
Go-owned errors can be registered once through svc.ServiceContext.AppOptions with
|
||||||
|
omni.WithErrors(projecterrors.Catalog()). Generated routes inherit them unless they
|
||||||
|
declare an independent catalog. Do not repeat Go-owned errors in the API contract.
|
||||||
|
Alternatively, declare public business errors in error (...) and reference them with @errors.
|
||||||
|
Use internal/apierrors constructors to share status, code and message with OpenAPI.
|
||||||
|
Auth declarations connect a bearer/header/cookie scheme to a real named middleware.
|
||||||
|
The selected auth runs first; @auth none removes inherited declarative auth.
|
||||||
|
Validation policy remains in the middleware implementation. Public messages must
|
||||||
|
not contain secrets; error causes may be included in server logs.
|
||||||
|
|
||||||
|
svc.New receives the loaded Config and borrowed Logger. Add dependencies there,
|
||||||
|
then immediately register each cleanup with OnClose. A later initialization failure
|
||||||
|
runs registered cleanups in reverse order. Close is idempotent. GlobalMiddleware
|
||||||
|
provides global hooks; Middlewares supplies named API hooks. AppOptions is evaluated
|
||||||
|
once before route registration; logging is configured earlier through Config.
|
||||||
|
The generated main runs HTTP draining before closing svc resources, then the logger.
|
||||||
|
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
|
||||||
|
their own lifetime, canceled by OnClose after HTTP draining.
|
||||||
|
|
||||||
|
Named middleware is composed in declaration order (first is outermost). Implement
|
||||||
|
the generated hooks or replace entries in svc; never bypass an auth
|
||||||
|
hook merely to make the server start. Middleware names do not imply a JWT policy.
|
||||||
|
|
||||||
|
This CLI supports a documented go-zero-style subset, not all goctl features.
|
||||||
|
Database tables and repositories cannot be inferred from an HTTP contract. Add
|
||||||
|
business repositories through svc. Database and storage options are
|
||||||
|
composed at creation; the original profile is retained on regeneration.
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
Selected database: none; storage: none; logging: stderr/json.
|
||||||
|
MySQL uses database/sql through deps.DB, including InTx and a readiness probe.
|
||||||
|
Set GO_OMNI_DB_DSN; pool settings live in etc/config.yaml and GO_OMNI_DB_*.
|
||||||
|
No tables are invented or migrated automatically from request/response DTOs.
|
||||||
|
The none/memory selection opens no database; implement repositories per business.
|
||||||
|
|
||||||
|
With storage enabled, services receive deps.Files, Uploads, Downloads and
|
||||||
|
UploadLinks capability interfaces. Local signed GET/HEAD/PUT uses /storage/object;
|
||||||
|
set GO_OMNI_STORAGE_SIGNING_KEY (32+ bytes) and GO_OMNI_STORAGE_PUBLIC_URL.
|
||||||
|
Cloud credentials use GO_OMNI_STORAGE_ACCESS_KEY/SECRET_KEY; set bucket and endpoint
|
||||||
|
or R2 accountId as appropriate. Signed URLs grant temporary access: authenticate
|
||||||
|
and authorize before issuing them. No public upload-link route is added.
|
||||||
|
Storage failures during startup close an already-open database; Close is idempotent.
|
||||||
|
|
||||||
|
Database/storage/logging selection is recorded in .goOmni/apiManifest.json.
|
||||||
|
Changing selection through generation flags is rejected on an existing project;
|
||||||
|
edit your owned configuration and initialization explicitly, or generate a fresh
|
||||||
|
project for comparison. New routes still regenerate without repeating these flags.
|
||||||
57
examples/managedService/api/managed.api
Normal file
57
examples/managedService/api/managed.api
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
syntax = "v1"
|
||||||
|
|
||||||
|
// Error definitions are intentionally owned by Go, not duplicated here.
|
||||||
|
@app (
|
||||||
|
defaultResponse: CommonResponse
|
||||||
|
)
|
||||||
|
response CommonResponse (
|
||||||
|
codeType: int32
|
||||||
|
successCode: 0
|
||||||
|
errorCode: 9999
|
||||||
|
messageField: msg
|
||||||
|
dataMode: always
|
||||||
|
requestIdField: "-"
|
||||||
|
)
|
||||||
|
response AlternateResponse (
|
||||||
|
codeType: int32
|
||||||
|
successCode: 0
|
||||||
|
errorCode: 9999
|
||||||
|
messageField: detail
|
||||||
|
dataMode: always
|
||||||
|
requestIdField: "-"
|
||||||
|
)
|
||||||
|
type CheckReq {
|
||||||
|
Kind string `form:"kind,optional"`
|
||||||
|
}
|
||||||
|
type Receipt {
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
}
|
||||||
|
@server (group: demo)
|
||||||
|
service managed-api {
|
||||||
|
@handler check
|
||||||
|
get /check (CheckReq) returns (Receipt)
|
||||||
|
|
||||||
|
@handler other
|
||||||
|
@response AlternateResponse
|
||||||
|
get /other (CheckReq) returns (Receipt)
|
||||||
|
|
||||||
|
@handler rawJSON
|
||||||
|
@response raw
|
||||||
|
get /raw returns (Receipt)
|
||||||
|
|
||||||
|
@handler upload
|
||||||
|
@body (mode: multipart; maxBytes: 2048)
|
||||||
|
post /upload returns (Receipt)
|
||||||
|
|
||||||
|
@handler callback
|
||||||
|
@body (mode: raw; maxBytes: 64)
|
||||||
|
@produces "text/plain"
|
||||||
|
post /callback
|
||||||
|
|
||||||
|
@handler download
|
||||||
|
@produces "application/octet-stream"
|
||||||
|
get /download
|
||||||
|
|
||||||
|
@handler slow
|
||||||
|
get /slow returns (Receipt)
|
||||||
|
}
|
||||||
1422
examples/managedService/docs/openapi.json
Normal file
1422
examples/managedService/docs/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
12
examples/managedService/etc/config.yaml
Normal file
12
examples/managedService/etc/config.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Non-secret settings only. Environment variables override these values.
|
||||||
|
server:
|
||||||
|
address: '127.0.0.1:8080'
|
||||||
|
enableLogger: true
|
||||||
|
slowRequestThreshold: 500ms
|
||||||
|
shutdownTimeout: 10s
|
||||||
|
log:
|
||||||
|
service: "example.com/managed"
|
||||||
|
level: info
|
||||||
|
format: json
|
||||||
|
output: stderr
|
||||||
|
redactKeys: []
|
||||||
22
examples/managedService/go.mod
Normal file
22
examples/managedService/go.mod
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
module example.com/managed
|
||||||
|
|
||||||
|
go 1.22.0
|
||||||
|
|
||||||
|
require git.yuntuo.hk/k2safe/go-omni v0.12.0-preview
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
golang.org/x/crypto v0.31.0 // indirect
|
||||||
|
golang.org/x/net v0.33.0 // indirect
|
||||||
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
|
golang.org/x/text v0.21.0 // indirect
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace git.yuntuo.hk/k2safe/go-omni => ../..
|
||||||
34
examples/managedService/go.sum
Normal file
34
examples/managedService/go.sum
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
18
examples/managedService/internal/apierrors/catalog.go
Normal file
18
examples/managedService/internal/apierrors/catalog.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package apierrors
|
||||||
|
|
||||||
|
import omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
|
||||||
|
func Catalog() omnierrors.Catalog {
|
||||||
|
return omnierrors.Catalog{
|
||||||
|
DefaultStatus: 400,
|
||||||
|
Definitions: map[string]omnierrors.Definition{
|
||||||
|
"INVALID": {Code: 400002, Message: "用户名格式不正确"},
|
||||||
|
"TOO_LONG": {Code: 400003, Message: "{label}不能超过{max}个字符", Params: []omnierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int"}}},
|
||||||
|
"INTERNAL_ERROR": {Status: 500, Code: 9999, Message: "系统错误"},
|
||||||
|
"NOT_FOUND": {Status: 404, Code: 404001, Message: "接口不存在"},
|
||||||
|
"METHOD_NOT_ALLOWED": {Status: 405, Code: 405001, Message: "请求方法不支持"},
|
||||||
|
"BODY_TOO_LARGE": {Status: 413, Code: 413001, Message: "请求体太大"},
|
||||||
|
"INVALID_MULTIPART": {Status: 400, Code: 400004, Message: "上传内容不完整"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
69
examples/managedService/internal/config/config.go
Normal file
69
examples/managedService/internal/config/config.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// Package settings owns this project's configuration; it adds no business identity model.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server config.Config `json:"server"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Default() Config {
|
||||||
|
server := config.Default()
|
||||||
|
server.Address = "127.0.0.1:8080"
|
||||||
|
server.EnableLogger = true
|
||||||
|
server.SlowRequestThreshold = 500 * time.Millisecond
|
||||||
|
server.Log.Service = "example.com/managed"
|
||||||
|
server.Log.Format = "json"
|
||||||
|
server.Log.Output = "stderr"
|
||||||
|
return Config{Server: server}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load uses defaults -> strict YAML/JSON -> nonempty environment variables.
|
||||||
|
// Pass an empty path to use only defaults and environment.
|
||||||
|
func Load(path string) (Config, error) {
|
||||||
|
cfg := Default()
|
||||||
|
var err error
|
||||||
|
if path != "" {
|
||||||
|
cfg, err = config.DecodeFile(path, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.Server, err = config.ApplyEnv(cfg.Server, "")
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
// A stream explicitly selected by environment overrides a file profile.
|
||||||
|
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 err = cfg.Validate(); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
func (c Config) Validate() error {
|
||||||
|
|
||||||
|
if err := c.Server.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.Server.Log.Format != "json" && c.Server.Log.Format != "text" {
|
||||||
|
return errors.New("log.format must be json or text")
|
||||||
|
}
|
||||||
|
switch strings.ToLower(c.Server.Log.Level) {
|
||||||
|
case "debug", "info", "warn", "warning", "error":
|
||||||
|
default:
|
||||||
|
return errors.New("invalid log.level")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
36
examples/managedService/internal/handler/appGen.go
Normal file
36
examples/managedService/internal/handler/appGen.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Code generated by go-omni api. DO NOT EDIT.
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
|
||||||
|
"example.com/managed/internal/svc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewApp is generated HTTP composition. The caller owns deps and its logger.
|
||||||
|
func NewApp(deps *svc.ServiceContext) (*omni.App, error) {
|
||||||
|
if deps == nil || deps.Health == nil {
|
||||||
|
return nil, fmt.Errorf("service context and health checker required")
|
||||||
|
}
|
||||||
|
cfg := deps.Config.Server
|
||||||
|
for _, option := range deps.AppOptions() {
|
||||||
|
if option != nil {
|
||||||
|
option(&cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.Response = newResponseTemplate("{\"body\":null,\"errorBody\":null,\"messageField\":\"msg\",\"requestIdField\":\"-\",\"codeType\":\"int32\",\"successCode\":0,\"errorCode\":9999,\"dataMode\":\"always\"}").Config()
|
||||||
|
cfg.ResponseEncoder = nil
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
app := omni.New(omni.WithConfig(cfg))
|
||||||
|
app.Use(deps.GlobalMiddleware()...)
|
||||||
|
if err := RegisterRoutes(app, deps); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
app.GET("/openapi.json", OpenAPI)
|
||||||
|
app.GET("/health/live", deps.Health.LivenessHandler())
|
||||||
|
app.GET("/health/ready", deps.Health.ReadinessHandler())
|
||||||
|
return app, nil
|
||||||
|
}
|
||||||
134
examples/managedService/internal/handler/demo/handlersGen.go
Normal file
134
examples/managedService/internal/handler/demo/handlersGen.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
// Code generated by go-omni api. DO NOT EDIT.
|
||||||
|
package demo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"example.com/managed/internal/svc"
|
||||||
|
"example.com/managed/internal/types"
|
||||||
|
"fmt"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Logic interface {
|
||||||
|
Check(ctx context.Context, req *types.CheckReq) (*types.Receipt, error)
|
||||||
|
Other(ctx context.Context, req *types.CheckReq) (*types.Receipt, error)
|
||||||
|
RawJSON(ctx context.Context) (*types.Receipt, error)
|
||||||
|
Upload(ctx context.Context, body *httpx.Body) (*types.Receipt, error)
|
||||||
|
Callback(ctx context.Context, body *httpx.Body) (*httpx.Stream, error)
|
||||||
|
Download(ctx context.Context) (*httpx.Stream, error)
|
||||||
|
Slow(ctx context.Context) (*types.Receipt, error)
|
||||||
|
}
|
||||||
|
type Handler struct {
|
||||||
|
logic Logic
|
||||||
|
deps *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(s Logic, deps ...*svc.ServiceContext) *Handler {
|
||||||
|
h := &Handler{logic: s}
|
||||||
|
if len(deps) > 0 {
|
||||||
|
h.deps = deps[0]
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkRequest = contract.MustNew[types.CheckReq]()
|
||||||
|
|
||||||
|
func (h *Handler) Check(c *httpx.Context) error {
|
||||||
|
var req types.CheckReq
|
||||||
|
if err := checkRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := h.logic.Check(c.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return fmt.Errorf("Check returned a nil response")
|
||||||
|
}
|
||||||
|
return c.OK(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var otherRequest = contract.MustNew[types.CheckReq]()
|
||||||
|
|
||||||
|
func (h *Handler) Other(c *httpx.Context) error {
|
||||||
|
var req types.CheckReq
|
||||||
|
if err := otherRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := h.logic.Other(c.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return fmt.Errorf("Other returned a nil response")
|
||||||
|
}
|
||||||
|
return c.OK(resp)
|
||||||
|
}
|
||||||
|
func (h *Handler) RawJSON(c *httpx.Context) error {
|
||||||
|
resp, err := h.logic.RawJSON(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return fmt.Errorf("RawJSON returned a nil response")
|
||||||
|
}
|
||||||
|
return c.OK(resp)
|
||||||
|
}
|
||||||
|
func (h *Handler) Upload(c *httpx.Context) error {
|
||||||
|
body, err := httpx.NewBody(c.Request(), "multipart")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := h.logic.Upload(c.Context(), body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return fmt.Errorf("Upload returned a nil response")
|
||||||
|
}
|
||||||
|
if err := body.Complete(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.OK(resp)
|
||||||
|
}
|
||||||
|
func (h *Handler) Callback(c *httpx.Context) error {
|
||||||
|
body, err := httpx.NewBody(c.Request(), "raw")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := h.logic.Callback(c.Context(), body)
|
||||||
|
if err != nil {
|
||||||
|
if resp != nil {
|
||||||
|
_ = resp.Close()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := body.Complete(); err != nil {
|
||||||
|
if resp != nil {
|
||||||
|
_ = resp.Close()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.WriteStream(200, "text/plain", resp)
|
||||||
|
}
|
||||||
|
func (h *Handler) Download(c *httpx.Context) error {
|
||||||
|
resp, err := h.logic.Download(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
if resp != nil {
|
||||||
|
_ = resp.Close()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.WriteStream(200, "application/octet-stream", resp)
|
||||||
|
}
|
||||||
|
func (h *Handler) Slow(c *httpx.Context) error {
|
||||||
|
resp, err := h.logic.Slow(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return fmt.Errorf("Slow returned a nil response")
|
||||||
|
}
|
||||||
|
return c.OK(resp)
|
||||||
|
}
|
||||||
13
examples/managedService/internal/handler/openapiGen.go
Normal file
13
examples/managedService/internal/handler/openapiGen.go
Normal file
File diff suppressed because one or more lines are too long
41
examples/managedService/internal/handler/responseGen.go
Normal file
41
examples/managedService/internal/handler/responseGen.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Code generated by go-omni api. DO NOT EDIT.
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var responseTemplates = map[string]response.Encoder{"": response.Default(),
|
||||||
|
"CommonResponse": newResponseTemplate("{\"body\":null,\"errorBody\":null,\"messageField\":\"msg\",\"requestIdField\":\"-\",\"codeType\":\"int32\",\"successCode\":0,\"errorCode\":9999,\"dataMode\":\"always\"}"),
|
||||||
|
"AlternateResponse": newResponseTemplate("{\"body\":null,\"errorBody\":null,\"messageField\":\"detail\",\"requestIdField\":\"-\",\"codeType\":\"int32\",\"successCode\":0,\"errorCode\":9999,\"dataMode\":\"always\"}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
func newResponseTemplate(raw string) *response.Formatter {
|
||||||
|
var cfg response.Config
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||||
|
decoder.UseNumber()
|
||||||
|
if err := decoder.Decode(&cfg); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return response.MustNew(cfg)
|
||||||
|
}
|
||||||
|
func responseEncoder(name string, raw bool, catalogJSON string) response.Encoder {
|
||||||
|
encoder := responseTemplates[name]
|
||||||
|
if encoder == nil {
|
||||||
|
panic("unknown response template")
|
||||||
|
}
|
||||||
|
var catalog omnierrors.Catalog
|
||||||
|
if err := json.Unmarshal([]byte(catalogJSON), &catalog); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if len(catalog.Definitions) > 0 {
|
||||||
|
encoder = response.MustWithErrors(catalog, encoder)
|
||||||
|
}
|
||||||
|
if raw {
|
||||||
|
encoder = response.RawJSON(encoder)
|
||||||
|
}
|
||||||
|
return encoder
|
||||||
|
}
|
||||||
26
examples/managedService/internal/handler/routesGen.go
Normal file
26
examples/managedService/internal/handler/routesGen.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Code generated by go-omni api. DO NOT EDIT.
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
h0 "example.com/managed/internal/handler/demo"
|
||||||
|
s0 "example.com/managed/internal/logic/demo"
|
||||||
|
"example.com/managed/internal/svc"
|
||||||
|
"fmt"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterRoutes(router openapi.Registrar, deps *svc.ServiceContext) error {
|
||||||
|
if router == nil || deps == nil {
|
||||||
|
return fmt.Errorf("router and dependencies required")
|
||||||
|
}
|
||||||
|
h0 := h0.New(s0.New(deps), deps)
|
||||||
|
httpx.HandleWithResponse(router, "GET", "/check", responseEncoder("CommonResponse", false, "{\"definitions\":{}}"), h0.Check)
|
||||||
|
httpx.HandleWithResponse(router, "GET", "/other", responseEncoder("AlternateResponse", false, "{\"definitions\":{}}"), h0.Other)
|
||||||
|
httpx.HandleWithResponse(router, "GET", "/raw", responseEncoder("CommonResponse", true, "{\"definitions\":{}}"), h0.RawJSON)
|
||||||
|
httpx.HandleWithBodyLimit(router, "POST", "/upload", 2048, responseEncoder("CommonResponse", false, "{\"definitions\":{}}"), h0.Upload)
|
||||||
|
httpx.HandleWithBodyLimit(router, "POST", "/callback", 64, responseEncoder("CommonResponse", false, "{\"definitions\":{}}"), h0.Callback)
|
||||||
|
httpx.HandleWithResponse(router, "GET", "/download", responseEncoder("CommonResponse", false, "{\"definitions\":{}}"), h0.Download)
|
||||||
|
httpx.HandleWithResponse(router, "GET", "/slow", responseEncoder("CommonResponse", false, "{\"definitions\":{}}"), h0.Slow)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
22
examples/managedService/internal/logic/demo/callback.go
Normal file
22
examples/managedService/internal/logic/demo/callback.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package demo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l *Logic) Callback(ctx context.Context, body *httpx.Body) (*httpx.Stream, error) {
|
||||||
|
// The route's 64-byte limit bounds this example's memory use. A real signature
|
||||||
|
// implementation can hash body.Raw incrementally and authenticate its headers.
|
||||||
|
data, err := io.ReadAll(body.Raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if body.Header.Get("X-Signature") != "valid" {
|
||||||
|
return nil, omnierrors.ByCode("INVALID")
|
||||||
|
}
|
||||||
|
return &httpx.Stream{Content: io.NopCloser(strings.NewReader("ACK:" + string(data)))}, nil
|
||||||
|
}
|
||||||
20
examples/managedService/internal/logic/demo/check.go
Normal file
20
examples/managedService/internal/logic/demo/check.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package demo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"example.com/managed/internal/types"
|
||||||
|
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l *Logic) Check(ctx context.Context, req *types.CheckReq) (*types.Receipt, error) {
|
||||||
|
switch req.Kind {
|
||||||
|
case "fixed":
|
||||||
|
return nil, omnierrors.ByCode("INVALID")
|
||||||
|
case "params":
|
||||||
|
return nil, omnierrors.ByCode("TOO_LONG").WithParams(map[string]any{"label": "企业名称", "max": 100})
|
||||||
|
case "unknown":
|
||||||
|
return nil, omnierrors.ByCode("UNKNOWN").WithCause(errors.New("private cause"))
|
||||||
|
}
|
||||||
|
return &types.Receipt{Bytes: 42}, nil
|
||||||
|
}
|
||||||
12
examples/managedService/internal/logic/demo/download.go
Normal file
12
examples/managedService/internal/logic/demo/download.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package demo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l *Logic) Download(context.Context) (*httpx.Stream, error) {
|
||||||
|
return &httpx.Stream{Content: io.NopCloser(strings.NewReader("file-content")), Name: "example.txt"}, nil
|
||||||
|
}
|
||||||
7
examples/managedService/internal/logic/demo/logic.go
Normal file
7
examples/managedService/internal/logic/demo/logic.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package demo
|
||||||
|
|
||||||
|
import "example.com/managed/internal/svc"
|
||||||
|
|
||||||
|
type Logic struct{ deps *svc.ServiceContext }
|
||||||
|
|
||||||
|
func New(deps *svc.ServiceContext) *Logic { return &Logic{deps: deps} }
|
||||||
10
examples/managedService/internal/logic/demo/other.go
Normal file
10
examples/managedService/internal/logic/demo/other.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package demo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"example.com/managed/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l *Logic) Other(ctx context.Context, req *types.CheckReq) (*types.Receipt, error) {
|
||||||
|
return l.Check(ctx, req)
|
||||||
|
}
|
||||||
10
examples/managedService/internal/logic/demo/rawJSON.go
Normal file
10
examples/managedService/internal/logic/demo/rawJSON.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package demo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"example.com/managed/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l *Logic) RawJSON(context.Context) (*types.Receipt, error) {
|
||||||
|
return &types.Receipt{Bytes: 3}, nil
|
||||||
|
}
|
||||||
16
examples/managedService/internal/logic/demo/slow.go
Normal file
16
examples/managedService/internal/logic/demo/slow.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package demo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"example.com/managed/internal/types"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l *Logic) Slow(ctx context.Context) (*types.Receipt, error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
return &types.Receipt{Bytes: 1}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
31
examples/managedService/internal/logic/demo/upload.go
Normal file
31
examples/managedService/internal/logic/demo/upload.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package demo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"example.com/managed/internal/types"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l *Logic) Upload(ctx context.Context, body *httpx.Body) (*types.Receipt, error) {
|
||||||
|
var total int64
|
||||||
|
for {
|
||||||
|
part, err := body.Parts.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n, err := io.Copy(io.Discard, part)
|
||||||
|
closeErr := part.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return nil, closeErr
|
||||||
|
}
|
||||||
|
total += n
|
||||||
|
}
|
||||||
|
return &types.Receipt{Bytes: total}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Code generated by go-omni api. DO NOT EDIT.
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import "git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
|
||||||
|
func Defaults() map[string]httpx.Middleware {
|
||||||
|
return map[string]httpx.Middleware{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Package demo owns persistence contracts for this business group.
|
||||||
|
// Define repository interfaces and implementations here; HTTP DTOs do not
|
||||||
|
// determine a database schema. This file is never overwritten.
|
||||||
|
package demo
|
||||||
36
examples/managedService/internal/svc/close.go
Normal file
36
examples/managedService/internal/svc/close.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package svc
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Close is idempotent, including startup failure and graceful shutdown.
|
||||||
|
func (c *ServiceContext) Close() error {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.closeOnce.Do(func() {
|
||||||
|
c.closeMu.Lock()
|
||||||
|
c.closed = true
|
||||||
|
cleanups := c.cleanups
|
||||||
|
c.cleanups = nil
|
||||||
|
c.closeMu.Unlock()
|
||||||
|
for i := len(cleanups) - 1; i >= 0; i-- {
|
||||||
|
c.closeErr = errors.Join(c.closeErr, cleanups[i]())
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
return c.closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnClose transfers a resource cleanup to this context immediately after creation.
|
||||||
|
// Hooks run once in reverse order, also when later initialization fails.
|
||||||
|
func (c *ServiceContext) OnClose(cleanup func() error) {
|
||||||
|
if cleanup == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.closeMu.Lock()
|
||||||
|
defer c.closeMu.Unlock()
|
||||||
|
if c.closed {
|
||||||
|
panic("svc: register cleanup after Close")
|
||||||
|
}
|
||||||
|
c.cleanups = append(c.cleanups, cleanup)
|
||||||
|
}
|
||||||
24
examples/managedService/internal/svc/options.go
Normal file
24
examples/managedService/internal/svc/options.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package svc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"example.com/managed/internal/apierrors"
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *ServiceContext) AppOptions() []omni.Option {
|
||||||
|
return []omni.Option{omni.WithErrors(apierrors.Catalog())}
|
||||||
|
}
|
||||||
|
func (c *ServiceContext) GlobalMiddleware() []httpx.Middleware {
|
||||||
|
return []httpx.Middleware{
|
||||||
|
func(next httpx.Handler) httpx.Handler {
|
||||||
|
return func(c *httpx.Context) error {
|
||||||
|
if c.Header("X-Fail") != "" {
|
||||||
|
return omnierrors.ByCode("INVALID")
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
59
examples/managedService/internal/svc/serviceContext.go
Normal file
59
examples/managedService/internal/svc/serviceContext.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package svc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"example.com/managed/internal/config"
|
||||||
|
"example.com/managed/internal/middleware"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Context owns infrastructure, while Logger is borrowed from main.
|
||||||
|
// Add business repositories here; HTTP contracts do not define database tables.
|
||||||
|
type ServiceContext struct {
|
||||||
|
Config config.Config
|
||||||
|
cleanups []func() error
|
||||||
|
Logger *logx.Logger
|
||||||
|
Health *health.Checker
|
||||||
|
Middlewares map[string]httpx.Middleware
|
||||||
|
|
||||||
|
closeMu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
closeOnce sync.Once
|
||||||
|
closeErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass the settings loaded by main. The optional form supports lightweight
|
||||||
|
// tests without infrastructure; selected adapters still require valid config.
|
||||||
|
func New(ctx context.Context, logger *logx.Logger, configs ...config.Config) (result *ServiceContext, err error) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if err = ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(configs) > 1 {
|
||||||
|
return nil, errors.New("expected at most one configuration")
|
||||||
|
}
|
||||||
|
cfg := config.Default()
|
||||||
|
if len(configs) == 1 {
|
||||||
|
cfg = configs[0]
|
||||||
|
}
|
||||||
|
_ = cfg
|
||||||
|
checker, err := health.New()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c := &ServiceContext{Config: cfg, Logger: logger, Health: checker, Middlewares: middleware.Defaults()}
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
err = errors.Join(err, c.Close())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
9
examples/managedService/internal/types/typesGen.go
Normal file
9
examples/managedService/internal/types/typesGen.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Code generated by go-omni api. DO NOT EDIT.
|
||||||
|
package types
|
||||||
|
|
||||||
|
type CheckReq struct {
|
||||||
|
Kind string `form:"kind,optional"`
|
||||||
|
}
|
||||||
|
type Receipt struct {
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
}
|
||||||
52
examples/managedService/main.go
Normal file
52
examples/managedService/main.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Code generated by go-omni api. DO NOT EDIT.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"example.com/managed/internal/config"
|
||||||
|
"example.com/managed/internal/handler"
|
||||||
|
"example.com/managed/internal/svc"
|
||||||
|
"flag"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
path := flag.String("config", "etc/config.yaml", "configuration file; empty uses defaults and environment")
|
||||||
|
flag.Parse()
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
if err := run(ctx, *path); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func run(ctx context.Context, path string) (runErr error) {
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
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()) }()
|
||||||
|
logx.SetDefault(logger)
|
||||||
|
deps, err := svc.New(ctx, logger, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// RunContext drains HTTP requests before returning. This scope owns dependencies
|
||||||
|
// and closes them exactly once, before closing the borrowed logger.
|
||||||
|
defer func() { runErr = errors.Join(runErr, deps.Close()) }()
|
||||||
|
app, err := handler.NewApp(deps)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { runErr = errors.Join(runErr, app.Close()) }()
|
||||||
|
logger.InfoContext(ctx, "service.starting", "address", app.Config().Address)
|
||||||
|
return app.RunContext(ctx)
|
||||||
|
}
|
||||||
173
httpx/multipart.go
Normal file
173
httpx/multipart.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
|
|
||||||
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MultipartReader streams request parts and retains parsing failures, so a
|
||||||
|
// partially consumed or invalid upload cannot later complete successfully.
|
||||||
|
// It belongs to a single request and must be read sequentially.
|
||||||
|
type MultipartReader struct {
|
||||||
|
reader *multipart.Reader
|
||||||
|
input *multipartInput
|
||||||
|
current *MultipartPart
|
||||||
|
err error
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextPart closes the preceding part and returns the next part. Like the
|
||||||
|
// standard library, it decodes quoted-printable content; NextRawPart does not.
|
||||||
|
// Only a valid final boundary yields io.EOF; malformed input yields an error
|
||||||
|
// with status 400. Body limits remain detectable as *http.MaxBytesError.
|
||||||
|
func (r *MultipartReader) NextPart() (*MultipartPart, error) { return r.next(false) }
|
||||||
|
|
||||||
|
func (r *MultipartReader) NextRawPart() (*MultipartPart, error) { return r.next(true) }
|
||||||
|
|
||||||
|
func (r *MultipartReader) next(raw bool) (*MultipartPart, error) {
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, r.err
|
||||||
|
}
|
||||||
|
if r.done {
|
||||||
|
return nil, io.EOF
|
||||||
|
}
|
||||||
|
if r.current != nil {
|
||||||
|
if err := r.current.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var part *multipart.Part
|
||||||
|
var err error
|
||||||
|
if raw {
|
||||||
|
part, err = r.reader.NextRawPart()
|
||||||
|
} else {
|
||||||
|
part, err = r.reader.NextPart()
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
// The standard parser also returns bare EOF for truncated MIME
|
||||||
|
// headers. Require an actual closing delimiter before treating EOF
|
||||||
|
// as successful completion.
|
||||||
|
if !r.input.ended() {
|
||||||
|
return nil, r.record(io.ErrUnexpectedEOF)
|
||||||
|
}
|
||||||
|
r.done = true
|
||||||
|
return nil, io.EOF
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, r.record(err)
|
||||||
|
}
|
||||||
|
r.current = &MultipartPart{Header: part.Header, part: part, owner: r}
|
||||||
|
return r.current, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MultipartReader) record(err error) error {
|
||||||
|
if err == nil || err == io.EOF {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.err == nil {
|
||||||
|
var limit *http.MaxBytesError
|
||||||
|
if errors.As(err, &limit) {
|
||||||
|
r.err = err
|
||||||
|
} else {
|
||||||
|
r.err = apierrors.New(400, "INVALID_MULTIPART", "invalid multipart request body").WithCause(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultipartPart is a streaming request part. Close drains unread content and
|
||||||
|
// reports read/parse errors; it never buffers the whole file or creates a file.
|
||||||
|
type MultipartPart struct {
|
||||||
|
Header textproto.MIMEHeader
|
||||||
|
part *multipart.Part
|
||||||
|
owner *MultipartReader
|
||||||
|
closed bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MultipartPart) FormName() string { return p.part.FormName() }
|
||||||
|
func (p *MultipartPart) FileName() string { return p.part.FileName() }
|
||||||
|
|
||||||
|
func (p *MultipartPart) Read(buf []byte) (int, error) {
|
||||||
|
if p.closed {
|
||||||
|
if p.err != nil {
|
||||||
|
return 0, p.err
|
||||||
|
}
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
n, err := p.part.Read(buf)
|
||||||
|
return n, p.owner.record(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MultipartPart) Close() error {
|
||||||
|
if !p.closed {
|
||||||
|
_, p.err = io.Copy(io.Discard, p)
|
||||||
|
p.closed = true
|
||||||
|
}
|
||||||
|
return p.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// multipartInput observes closing-delimiter lines without buffering file
|
||||||
|
// content or changing bytes. The MIME parser still validates their syntax and
|
||||||
|
// placement. Keep only a prefix offset, including across reader boundaries.
|
||||||
|
type multipartInput struct {
|
||||||
|
io.Reader
|
||||||
|
boundary string
|
||||||
|
matched int
|
||||||
|
carriage, final, eof bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *multipartInput) Read(p []byte) (int, error) {
|
||||||
|
n, err := r.Reader.Read(p)
|
||||||
|
r.observe(p[:n])
|
||||||
|
if err == io.EOF {
|
||||||
|
r.eof = true
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *multipartInput) ended() bool {
|
||||||
|
return r.final || (r.eof && r.matched == len(r.boundary) && !r.carriage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *multipartInput) observe(data []byte) {
|
||||||
|
for len(data) > 0 && !r.final {
|
||||||
|
if r.matched < 0 {
|
||||||
|
newline := bytes.IndexByte(data, '\n')
|
||||||
|
if newline < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, r.matched, r.carriage = data[newline+1:], 0, false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c := data[0]
|
||||||
|
data = data[1:]
|
||||||
|
if r.matched < len(r.boundary) {
|
||||||
|
if c == r.boundary[r.matched] {
|
||||||
|
r.matched++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch {
|
||||||
|
case c == '\n':
|
||||||
|
r.final = true
|
||||||
|
continue
|
||||||
|
case !r.carriage && (c == ' ' || c == '\t'):
|
||||||
|
continue
|
||||||
|
case !r.carriage && c == '\r':
|
||||||
|
r.carriage = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.matched, r.carriage = -1, false
|
||||||
|
if c == '\n' {
|
||||||
|
r.matched = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
httpx/transfer.go
Normal file
128
httpx/transfer.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Body borrows the limited request stream for the duration of a Logic call.
|
||||||
|
// Raw contains the exact body bytes (never JSON-decoded). For multipart input,
|
||||||
|
// consume Parts sequentially with NextPart and close each part. No temporary
|
||||||
|
// files are created. Do not retain Body or its readers after Logic returns.
|
||||||
|
type Body struct {
|
||||||
|
Raw io.Reader
|
||||||
|
Parts *MultipartReader
|
||||||
|
Header http.Header
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBody(r *http.Request, mode string) (*Body, error) {
|
||||||
|
if r == nil {
|
||||||
|
return nil, errors.New("httpx: request required")
|
||||||
|
}
|
||||||
|
b := &Body{Raw: r.Body, Header: r.Header.Clone()}
|
||||||
|
if b.Raw == nil {
|
||||||
|
b.Raw = http.NoBody
|
||||||
|
}
|
||||||
|
switch mode {
|
||||||
|
case "raw":
|
||||||
|
case "multipart":
|
||||||
|
media, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||||
|
if err != nil || media != "multipart/form-data" || params["boundary"] == "" {
|
||||||
|
return nil, apierrors.New(415, "UNSUPPORTED_MEDIA_TYPE", "multipart/form-data with a valid boundary required").WithCause(err)
|
||||||
|
}
|
||||||
|
input := &multipartInput{Reader: b.Raw, boundary: "--" + params["boundary"] + "--"}
|
||||||
|
request := *r
|
||||||
|
request.Body = io.NopCloser(input)
|
||||||
|
parts, err := request.MultipartReader()
|
||||||
|
if err != nil {
|
||||||
|
return nil, apierrors.New(415, "UNSUPPORTED_MEDIA_TYPE", "multipart/form-data with a valid boundary required").WithCause(err)
|
||||||
|
}
|
||||||
|
b.Parts = &MultipartReader{reader: parts, input: input}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("httpx: unsupported body mode %q", mode)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete validates remaining multipart parts and checks unread bytes against
|
||||||
|
// the route limit before committing a success response. Slow clients remain
|
||||||
|
// bounded by the server's read timeout. Do not read Raw alongside Parts.
|
||||||
|
func (b *Body) Complete() error {
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if b.Parts != nil {
|
||||||
|
for {
|
||||||
|
part, err := b.Parts.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = part.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.Raw == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := io.Copy(io.Discard, b.Raw)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream transfers ownership of Content to the generated handler. The handler
|
||||||
|
// closes it even on failure, and writes it without the JSON response envelope.
|
||||||
|
// Name enables an attachment filename; leave it empty for raw callback output.
|
||||||
|
// ContentType may refine the declared type with parameters (such as charset).
|
||||||
|
type Stream struct {
|
||||||
|
Content io.ReadCloser
|
||||||
|
Name string
|
||||||
|
ContentType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) Close() error {
|
||||||
|
if s == nil || s.Content == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Context) WriteStream(status int, declaredType string, stream *Stream) (err error) {
|
||||||
|
if stream == nil {
|
||||||
|
return errors.New("httpx: nil stream response")
|
||||||
|
}
|
||||||
|
defer func() { err = errors.Join(err, stream.Close()) }()
|
||||||
|
if stream.Content == nil {
|
||||||
|
return errors.New("httpx: stream content required")
|
||||||
|
}
|
||||||
|
if c.Responded() {
|
||||||
|
return errors.New("httpx: response already committed")
|
||||||
|
}
|
||||||
|
if status < 200 || status > 599 || status == 204 || status == 205 || status == 304 {
|
||||||
|
return errors.New("httpx: stream requires body-bearing status")
|
||||||
|
}
|
||||||
|
contentType := stream.ContentType
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = declaredType
|
||||||
|
}
|
||||||
|
media, _, parseErr := mime.ParseMediaType(contentType)
|
||||||
|
if parseErr != nil || media != declaredType || strings.ContainsAny(contentType, "\r\n") {
|
||||||
|
return errors.New("httpx: stream content type differs from API contract")
|
||||||
|
}
|
||||||
|
writer := c.Raw()
|
||||||
|
writer.Header().Set("Content-Type", contentType)
|
||||||
|
if stream.Name != "" {
|
||||||
|
writer.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": stream.Name}))
|
||||||
|
}
|
||||||
|
writer.WriteHeader(status)
|
||||||
|
_, err = io.Copy(writer, stream.Content)
|
||||||
|
return err
|
||||||
|
}
|
||||||
271
httpx/transfer_test.go
Normal file
271
httpx/transfer_test.go
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
package httpx_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/iotest"
|
||||||
|
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testStream struct {
|
||||||
|
io.Reader
|
||||||
|
closed int
|
||||||
|
closeErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStream) Close() error { s.closed++; return s.closeErr }
|
||||||
|
|
||||||
|
type brokenReader struct{ read bool }
|
||||||
|
|
||||||
|
func (r *brokenReader) Read(p []byte) (int, error) {
|
||||||
|
if r.read {
|
||||||
|
return 0, errors.New("source failed")
|
||||||
|
}
|
||||||
|
r.read = true
|
||||||
|
return copy(p, "partial"), nil
|
||||||
|
}
|
||||||
|
func TestStreamOwnershipAndCommit(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, media string
|
||||||
|
reader io.Reader
|
||||||
|
wantStatus int
|
||||||
|
wantBody string
|
||||||
|
closeErr error
|
||||||
|
}{
|
||||||
|
{name: "download", media: "application/octet-stream", reader: strings.NewReader("bytes"), wantStatus: 200, wantBody: "bytes"},
|
||||||
|
{name: "invalidType", media: "text/html", reader: strings.NewReader("secret"), wantStatus: 500},
|
||||||
|
{name: "readError", media: "application/octet-stream", reader: &brokenReader{}, wantStatus: 200, wantBody: "partial"},
|
||||||
|
{name: "closeError", media: "application/octet-stream", reader: strings.NewReader("done"), wantStatus: 200, wantBody: "done", closeErr: errors.New("close failed")},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
stream := &testStream{Reader: tc.reader, closeErr: tc.closeErr}
|
||||||
|
app := omni.New()
|
||||||
|
app.GET("/file", func(c *httpx.Context) error {
|
||||||
|
return c.WriteStream(200, "application/octet-stream", &httpx.Stream{Content: stream, ContentType: tc.media, Name: "文件.txt"})
|
||||||
|
})
|
||||||
|
out := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(out, httptest.NewRequest("GET", "/file", nil))
|
||||||
|
if stream.closed != 1 || out.Code != tc.wantStatus {
|
||||||
|
t.Fatalf("closed=%d status=%d body=%s", stream.closed, out.Code, out.Body.String())
|
||||||
|
}
|
||||||
|
if tc.wantBody != "" && out.Body.String() != tc.wantBody {
|
||||||
|
t.Fatal("error JSON appended to stream", out.Body.String())
|
||||||
|
}
|
||||||
|
if tc.wantStatus == 500 && strings.Contains(out.Body.String(), "secret") {
|
||||||
|
t.Fatal("stream written before validation")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestBodyCompletionEnforcesUnreadLimit(t *testing.T) {
|
||||||
|
app := omni.New()
|
||||||
|
app.HandleWithBodyLimit("POST", "/raw", 4, nil, func(c *httpx.Context) error {
|
||||||
|
body, err := httpx.NewBody(c.Request(), "raw")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
if _, err = io.ReadFull(body.Raw, buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = body.Complete(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.OK("accepted")
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/raw", strings.NewReader("12345"))
|
||||||
|
req.ContentLength = -1
|
||||||
|
out := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(out, req)
|
||||||
|
if out.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Fatal(out.Code, out.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestRawBodyPreservesBytesAndHeaders(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("POST", "/", strings.NewReader(" {\n \"a\":1} "))
|
||||||
|
req.Header.Set("X-Signature", "original")
|
||||||
|
body, err := httpx.NewBody(req, "raw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body.Header.Set("X-Signature", "changed")
|
||||||
|
if req.Header.Get("X-Signature") != "original" {
|
||||||
|
t.Fatal("aliased headers")
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(body.Raw)
|
||||||
|
if err != nil || string(data) != " {\n \"a\":1} " {
|
||||||
|
t.Fatal(string(data), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartValidationBeforeSuccess(t *testing.T) {
|
||||||
|
const prefix = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"x.txt\"\r\n\r\n"
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, wire, action string
|
||||||
|
status int
|
||||||
|
limit int64
|
||||||
|
}{
|
||||||
|
{"unreadValid", prefix + "hello\r\n--boundary--\r\n", "skip", 200, 1024},
|
||||||
|
{"finalWithoutNewline", prefix + "hello\r\n--boundary--", "skip", 200, 1024},
|
||||||
|
{"finalWithWhitespace", prefix + "hello\r\n--boundary-- \t", "skip", 200, 1024},
|
||||||
|
{"epilogue", prefix + "hello\r\n--boundary--\r\nignored epilogue", "skip", 200, 1024},
|
||||||
|
{"epilogueOverLimit", prefix + "hello\r\n--boundary--\r\n" + strings.Repeat("x", 1024), "skip", 413, 1024},
|
||||||
|
{"unreadTruncated", prefix + "private file bytes", "skip", 400, 1024},
|
||||||
|
{"empty", "", "skip", 400, 1024},
|
||||||
|
{"emptyMultipart", "--boundary--\r\n", "skip", 200, 1024},
|
||||||
|
{"unterminatedHeader", "--boundary\r\nContent-Disposition: form-data", "read", 400, 1024},
|
||||||
|
{"skippedUnterminatedHeader", "--boundary\r\nContent-Disposition: form-data", "skip", 400, 1024},
|
||||||
|
{"boundaryInsideHeader", "--boundary\r\nX-Header: x\r\n--boundary--", "skip", 400, 1024},
|
||||||
|
{"readTruncated", prefix + "private file bytes", "read", 400, 1024},
|
||||||
|
{"closeTruncated", prefix + "private file bytes", "close", 400, 1024},
|
||||||
|
{"swallowedReadError", prefix + "private file bytes", "ignore", 400, 1024},
|
||||||
|
{"badHeader", "--boundary\r\nbroken header\r\n\r\nprivate file bytes", "read", 400, 1024},
|
||||||
|
{"overLimit", prefix + strings.Repeat("x", 150), "read", 413, 128},
|
||||||
|
{"unreadOverLimit", prefix + strings.Repeat("x", 150), "skip", 413, 128},
|
||||||
|
{"businessFailure", prefix + "hello\r\n--boundary--\r\n", "business", 500, 1024},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
app := omni.New()
|
||||||
|
app.HandleWithBodyLimit("POST", "/upload", tc.limit, nil, func(c *httpx.Context) error {
|
||||||
|
body, err := httpx.NewBody(c.Request(), "multipart")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tc.action == "business" {
|
||||||
|
return errors.New("private database error")
|
||||||
|
}
|
||||||
|
if tc.action != "skip" {
|
||||||
|
part, err := body.Parts.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tc.action == "close" {
|
||||||
|
if err = part.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, err = io.Copy(io.Discard, part)
|
||||||
|
if err != nil && tc.action != "ignore" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = body.Complete(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.OK("accepted")
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/upload", strings.NewReader(tc.wire))
|
||||||
|
req.ContentLength = -1
|
||||||
|
req.Header.Set("Content-Type", "multipart/form-data; boundary=boundary")
|
||||||
|
out := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(out, req)
|
||||||
|
if out.Code != tc.status {
|
||||||
|
t.Fatalf("want %d, got %d: %s", tc.status, out.Code, out.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(out.Body.String(), "private") {
|
||||||
|
t.Fatal("internal contents leaked", out.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartIterationAndRawParts(t *testing.T) {
|
||||||
|
const wire = "--b\r\nContent-Disposition: form-data; name=\"first\"; filename=\"x.txt\"\r\n\r\nskipped\r\n--b\r\nContent-Disposition: form-data; name=\"second\"\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\na=3Db\r\n--b--\r\n"
|
||||||
|
for _, raw := range []bool{false, true} {
|
||||||
|
req := httptest.NewRequest("POST", "/", strings.NewReader(wire))
|
||||||
|
req.Header.Set("Content-Type", "multipart/form-data; boundary=b")
|
||||||
|
body, err := httpx.NewBody(req, "multipart")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, err := body.Parts.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first.FormName() != "first" || first.FileName() != "x.txt" {
|
||||||
|
t.Fatal("part metadata lost")
|
||||||
|
}
|
||||||
|
// NextPart must drain/close the unread first part without treating a
|
||||||
|
// valid part's EOF as the end of the entire request.
|
||||||
|
var second *httpx.MultipartPart
|
||||||
|
want := "a=b"
|
||||||
|
if raw {
|
||||||
|
second, err = body.Parts.NextRawPart()
|
||||||
|
want = "a=3Db"
|
||||||
|
} else {
|
||||||
|
second, err = body.Parts.NextPart()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(second)
|
||||||
|
if err != nil || string(data) != want || second.FormName() != "second" {
|
||||||
|
t.Fatal(string(data), err)
|
||||||
|
}
|
||||||
|
if err = body.Complete(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = body.Complete(); err != nil {
|
||||||
|
t.Fatal("second Complete", err)
|
||||||
|
}
|
||||||
|
if err = first.Close(); err != nil {
|
||||||
|
t.Fatal("second Close", err)
|
||||||
|
}
|
||||||
|
if _, err = body.Parts.NextPart(); err != io.EOF {
|
||||||
|
t.Fatal("expected final EOF", err)
|
||||||
|
}
|
||||||
|
if _, err = first.Read(make([]byte, 1)); err != io.EOF {
|
||||||
|
t.Fatal("closed part remained readable", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartRequiresFormData(t *testing.T) {
|
||||||
|
for _, media := range []string{"multipart/mixed; boundary=b", "multipart/form-data", "multipart/form-data; boundary=\"\"", "application/json"} {
|
||||||
|
req := httptest.NewRequest("POST", "/", strings.NewReader("--b--\r\n"))
|
||||||
|
req.Header.Set("Content-Type", media)
|
||||||
|
_, err := httpx.NewBody(req, "multipart")
|
||||||
|
var public *apierrors.APIError
|
||||||
|
if !errors.As(err, &public) || public.Status != 415 {
|
||||||
|
t.Fatalf("media %q accepted: %v", media, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartBoundariesAcrossReads(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, wire string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"crlf", "--b\r\nContent-Disposition: form-data; name=x\r\n\r\nabc\r\n--b--\r\n", true},
|
||||||
|
{"lf", "--b\nContent-Disposition: form-data; name=x\n\nabc\n--b--\n", true},
|
||||||
|
{"noNewline", "--b\r\nContent-Disposition: form-data; name=x\r\n\r\nabc\r\n--b--", true},
|
||||||
|
{"headerEOF", "--b\r\nContent-Disposition: form-data; name=x", false},
|
||||||
|
{"secondHeaderEOF", "--b\r\nContent-Disposition: form-data; name=x\r\n\r\nabc\r\n--b\r\nContent-Disposition: form-data; name=y", false},
|
||||||
|
{"partialBoundary", "--b\r\nContent-Disposition: form-data; name=x\r\n\r\nabc\r\n--b-", false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("POST", "/", iotest.OneByteReader(strings.NewReader(tc.wire)))
|
||||||
|
req.Header.Set("Content-Type", "multipart/form-data; boundary=b")
|
||||||
|
body, err := httpx.NewBody(req, "multipart")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = body.Complete()
|
||||||
|
if tc.valid && err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !tc.valid && (err == nil || apierrors.From(err).Status != 400) {
|
||||||
|
t.Fatal("expected malformed upload", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
48
performance/managedProjectAcceptance.md
Normal file
48
performance/managedProjectAcceptance.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# 受管理生成项目第一版验收
|
||||||
|
|
||||||
|
日期:2026-09-24;环境:本地 darwin/arm64。版本 v0.12.0-preview;下面记录发布前源码验收,远程产物另用 `scripts/verifyManagedRelease.py` 验证。
|
||||||
|
|
||||||
|
## 完成范围
|
||||||
|
|
||||||
|
- new / api new 共用契约生成器,根目录 main.go,config/svc/logic/handler 目录;不生成 cmd/server 入口。
|
||||||
|
- main、HTTP 装配、DTO、路由和 OpenAPI 纳入 manifest v2 的生成所有权与漂移检查。
|
||||||
|
- svc 显式提供 AppOptions、GlobalMiddleware、命名中间件依赖、OnClose 和幂等 Close。初始化失败和监听失败释放资源,正常退出先排空再关闭依赖,最后关闭日志。
|
||||||
|
- 入口 @app(defaultResponse: Name),路由/分组可覆盖;Go 错误目录单处注册,404/405 和全局中间件使用有效响应策略。
|
||||||
|
- 自动 raw/multipart 输入、@produces 输出;Logic 接收受限字节流/返回内容流,Handler 负责输出与关闭,无需手写普通上传下载接入。
|
||||||
|
- 开发者文件按字节保留,手写 main / 旧 manifest 拒绝静默接管;独立 cmd/migrate 文件保留。
|
||||||
|
|
||||||
|
## 测试结果
|
||||||
|
|
||||||
|
1. `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...` 全部通过,含两种数据库与五种存储生成配置、原错误目录/参数和兼容性生成消费者回归。
|
||||||
|
2. `go vet ./...`、`go build ./...`、gofmt 与 `git diff --check` 通过。
|
||||||
|
3. `TestManagedGeneratedProject` 用实际生成的项目添加自定义配置/客户端,只修改 config/svc/Logic;main 保持生成版本。子项目执行 race 测试和根目录 go build。
|
||||||
|
4. 子项目验证固定错误、参数提示、未知兜底、路由覆盖、raw JSON、404/405、提前返回中间件、multipart 上传、原始 body、未知长度请求体超限、下载内容及内容流关闭。
|
||||||
|
5. 子项目验证初始化失败/监听失败只关闭一次、在途请求排空前依赖仍可用;追加接口自动生成、连续生成稳定、main 漂移被 --check 拒绝。
|
||||||
|
6. 新增所有权专项 `go test -race ./cmd/go-omni/apigen -run '^TestManagedOwnership' -count=1` 通过:旧 manifest 和无所有权 main 均拒绝且不改写项目。
|
||||||
|
7. [真实进程报告](managedProjectLocal.json) 记录最近一轮耗时与源码哈希;根目录 go run/go build,nethttp/native 二进制 SIGINT/SIGTERM 退出码为 0、监听端口释放。go run 的包装进程可由信号退出,子服务必须退出并释放端口。
|
||||||
|
8. nethttp/native 均实际请求 managedService 示例:Go 错误目录、默认 404/405、raw JSON、下载、原文回执、上传及 413 均通过。所有临时进程、端口、文件已回收。
|
||||||
|
9. 仓库中的 examples/managedService 单独执行 go mod tidy、go test -race ./...、go build .;生成器 --check 通过。它用相对 replace 指向当前源码;远程产物验收会重新生成项目,不复制这个 replace。
|
||||||
|
|
||||||
|
## 第一版复查补强
|
||||||
|
|
||||||
|
- 上传内容和 MIME 头截断、缺少关闭边界、未读部分和被忽略的读取错误均拒绝成功;默认错误为 400 / INVALID_MULTIPART,项目目录可定制。数据库等 Logic 错误保留原语义,内部文件内容和解析原因不出现在公开提示。
|
||||||
|
- 覆盖逐字节输入、CRLF/LF、无尾部换行、合法边界空白、多文件顺序、跳过/重复关闭 Part、quoted-printable 与 NextRawPart、epilogue 及未知长度超限。保持标准库 MIME 解析,附加关闭边界观察不缓存文件内容,也不创建临时文件。
|
||||||
|
- Config.Validate 检查实际选中的编码器与 App 错误目录,拒绝冲突目录和冲突业务码映射;相同目录和兼容 RawJSON 仍通过。
|
||||||
|
- 实际生成项目验证定制的 400 / 400004 /「上传内容不完整」及 data:null;真实本地 nethttp/native 请求覆盖内容截断和 MIME 头截断。
|
||||||
|
|
||||||
|
## 契约变更诊断验收
|
||||||
|
|
||||||
|
- manifest v2 增加可选契约快照,与生成产物在同一次受保护写入中更新;新项目初次生成不报无意义变更,缺少快照的已有 v2 项目明确提示建立基线。
|
||||||
|
- 验证请求/返回类型替换、body 参数增加、DTO 改为流式返回、嵌套字段/标签、scalar 映射、路径/限额/媒体类型变化及接口删除。无关接口和注释修改不误报;提示顺序稳定。
|
||||||
|
- 业务签名检查支持导入别名、命名返回值和同组跨文件方法定位;覆盖语法错误、重复方法、测试文件排除和不确定的项目类型别名/build tags。方法体语义和复杂类型关系由 Go 编译器验证。
|
||||||
|
- 实际临时项目修改 GreetReq 为 HelloReq:dry-run/check 保持文件不变,generate 保留业务文件原字节,重新生成后 check 仍拒绝旧签名;修复业务签名后 check、go test、go build 通过。CLI 失败时也能看到文件/行号与前后签名。
|
||||||
|
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./cmd/go-omni/...` 通过,覆盖生成器与脚手架全部消费者;新增诊断专项 race 回归通过。`go vet ./cmd/go-omni/...`、`go build ./...` 通过。
|
||||||
|
- 本轮 [真实进程报告](managedProjectLocal.json) 包含 contractChangePreview、logicSignatureCheckPersists、businessSourcePreserved、fixedConsumerBuild,并保留 nethttp/native 的真实请求与退出验证。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
未修改 eTax/ePay;未连接业务服务器、真实数据库或云存储;没有负载/soak 测试。以上源码验收不代替远程产物验收。数据库/云存储配置组合通过生成项目编译与既有测试,不能据此声称真实环境集成完成。
|
||||||
|
|
||||||
|
multipart 通过流式 Parts 处理,不产生临时文件。业务负责字段/文件内容校验、存储和实际签名算法。请求读取后再提交业务副作用;超限或读取失败不能回滚业务自行提前写出的数据。Range、SSE 等特殊行为仍可显式选择手写 Handler。
|
||||||
|
|
||||||
|
项目维护边界见 [PROJECT.md](../PROJECT.md),可运行示例见 [managedService](../examples/managedService/README.md)。
|
||||||
47
performance/managedProjectLocal.json
Normal file
47
performance/managedProjectLocal.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"mode": "localSource",
|
||||||
|
"durationSeconds": 4.29,
|
||||||
|
"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": "2a26adb2b700d264a9acb3ff59d8f2cc6b4a15da25bf10ffcf1c15e2bb3e2ad0",
|
||||||
|
"cmd/go-omni/apigen/templates.go": "315cb4ae848fd02d36e110c77a46184a323ee72631baf6089a45e06324603887",
|
||||||
|
"cmd/go-omni/apigen/startup.go": "d26d511c20742e0b75407d4a9df9048158e895c15e5d9fc7141c822b2a4a27ff",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "a591f7c8c799100f05be71b09a8088a29f21d64612394af20f4711a24a7cb32c",
|
||||||
|
"cmd/go-omni/apigen/contractChanges.go": "ea190447e4260e0b3f3d28554ae61eac73f289aab601a16c7521dabc59fd736f",
|
||||||
|
"cmd/go-omni/apigen/logicCheck.go": "9fa1aa8cfef3a7b54dc5afb9b4320ec4e86ea5b46fc03705505216921279fa04",
|
||||||
|
"cmd/go-omni/apigen/write.go": "e415bb1e2db4674c5de7c81681b26a71925ddadbd647903b72674555e54911f1",
|
||||||
|
"cmd/go-omni/api.go": "eac8ed809000a939e3dfa7e3c01af67a26c705306143654a6b5a8e8733f2ab13",
|
||||||
|
"config/config.go": "da7c36b976530bd007bbac979c3714999f4da4da57c7a9074cd9289e85c987bc",
|
||||||
|
"httpx/transfer.go": "c9fe4e98c0e599d367eaf19ebecf367db914edfca9ad3a65527496d1dea80a3d",
|
||||||
|
"httpx/multipart.go": "b942ede7a810b268cd43b395c3bddcfaaacad92812bbb60e653466b8d86bc875",
|
||||||
|
"scripts/verifyManagedProject.py": "bed710ae5d78017069e8bfd3e2df4943089641cfde179f67a3a8f1883369c41b",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-close.go.tmpl": "83930db9fe99f4903ed77182f50e05ea89bb6e71a9f127cb2b1c03a39e2b7c39",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-options.go.tmpl": "11fe34ce45e8c98f7b5c9c0a2c512eb0b3abf86f95b4b576e083bcb6f5d0f824",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "4c41cc6c134ee1185a56293ccce791f32af1397774817ba51d693e8bec3c8fb4",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/client.go": "272ad9d980cd1b6e8de02298b5d273e693e2abcb902a96ea8f3b2ff53d7b0082",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "9e103c9de7a1a906903bc09787e1b38905dc5bb4d891c88938028da5dee98f6f",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/operations.go": "d20e792380b9c8f8bb37f12522de78ff2dcc986005ab999dbb0e8bfedf0ec9c4"
|
||||||
|
}
|
||||||
|
}
|
||||||
167
scripts/verifyManagedProject.py
Normal file
167
scripts/verifyManagedProject.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify source-generated root entrypoints with real local processes only."""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def run(args, cwd, env):
|
||||||
|
result = subprocess.run(args, cwd=cwd, env=env, capture_output=True, text=True, timeout=120)
|
||||||
|
if result.returncode:
|
||||||
|
output = re.sub(r'https://[^/@\s]+:[^/@\s]+@', 'https://', result.stdout + result.stderr)
|
||||||
|
raise RuntimeError(f'{args[0]} failed: {output[-8000:]}')
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def free_port():
|
||||||
|
with socket.socket() as s:
|
||||||
|
s.bind(('127.0.0.1', 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def request(url, data=None, headers=None, method=None):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
|
||||||
|
with urllib.request.build_opener(urllib.request.ProxyHandler({})).open(req, timeout=0.4) as response:
|
||||||
|
return response.status, response.read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return exc.code, exc.read()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_contract_changes(cli, project, env):
|
||||||
|
api = project/'api/service.api'
|
||||||
|
logic = project/'internal/logic/greet/greet.go'
|
||||||
|
original = logic.read_bytes()
|
||||||
|
api.write_text(api.read_text().replace('GreetReq', 'HelloReq'))
|
||||||
|
def sources():
|
||||||
|
return {str(p.relative_to(project)): p.read_bytes() for p in project.rglob('*') if p.is_file()}
|
||||||
|
before = sources()
|
||||||
|
preview = run([str(cli), 'api', 'generate', '--dry-run'], project, env)
|
||||||
|
assert 'ERROR [logicSignature]' in preview and '*types.HelloReq' in preview
|
||||||
|
assert before == sources(), 'preview modified project'
|
||||||
|
for generated in (False, True):
|
||||||
|
if generated:
|
||||||
|
output = run([str(cli), 'api', 'generate'], project, env)
|
||||||
|
assert 'ERROR [logicSignature]' in output and logic.read_bytes() == original
|
||||||
|
before = sources()
|
||||||
|
result = subprocess.run([str(cli), 'api', 'generate', '--check'], cwd=project, env=env, capture_output=True, text=True, timeout=30)
|
||||||
|
assert result.returncode != 0 and 'ERROR [logicSignature]' in result.stdout
|
||||||
|
assert 'internal/logic/greet/greet.go:' in result.stdout and before == sources()
|
||||||
|
logic.write_bytes(original.replace(b'GreetReq', b'HelloReq'))
|
||||||
|
run([str(cli), 'api', 'generate', '--check'], project, env)
|
||||||
|
run(['go', 'test', '-mod=mod', './...'], project, env)
|
||||||
|
run(['go', 'build', '.'], project, env)
|
||||||
|
|
||||||
|
|
||||||
|
def serve(args, project, env, transport, sig, strict_exit, contract=False, logs=None):
|
||||||
|
port = free_port()
|
||||||
|
env = dict(env, GO_OMNI_HTTP_ADDR=f'127.0.0.1:{port}', GO_OMNI_TRANSPORT=transport)
|
||||||
|
log_path = (logs or project.parent) / f'{transport}-{sig.name}-{strict_exit}-{contract}.log'
|
||||||
|
with log_path.open('wb') as log:
|
||||||
|
proc = subprocess.Popen(args, cwd=project, env=env, stdout=log, stderr=log, start_new_session=True)
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 30
|
||||||
|
base = f'http://127.0.0.1:{port}'
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
raise RuntimeError('server exited before readiness: ' + log_path.read_text()[-2000:])
|
||||||
|
try:
|
||||||
|
if request(base + '/health/ready')[0] == 200:
|
||||||
|
break
|
||||||
|
except (OSError, urllib.error.URLError):
|
||||||
|
pass
|
||||||
|
time.sleep(0.05)
|
||||||
|
else:
|
||||||
|
raise RuntimeError('server readiness timed out')
|
||||||
|
if contract:
|
||||||
|
status, body = request(base + '/check?kind=fixed')
|
||||||
|
assert status == 400 and json.loads(body) == {'code': 400002, 'msg': '用户名格式不正确', 'data': None}
|
||||||
|
assert json.loads(request(base + '/missing')[1])['msg'] == '接口不存在'
|
||||||
|
assert request(base + '/check', method='POST')[0] == 405
|
||||||
|
assert json.loads(request(base + '/raw')[1]) == {'bytes': 3}
|
||||||
|
assert request(base + '/download') == (200, b'file-content')
|
||||||
|
assert request(base + '/callback', b'{ "raw" : 1 }', {'X-Signature': 'valid'}) == (200, b'ACK:{ "raw" : 1 }')
|
||||||
|
assert request(base + '/callback', b'x' * 65, {'X-Signature': 'valid'})[0] == 413
|
||||||
|
upload = b'--omniBoundary\r\nContent-Disposition: form-data; name="file"; filename="test.txt"\r\nContent-Type: text/plain\r\n\r\nhello\r\n--omniBoundary--\r\n'
|
||||||
|
status, body = request(base + '/upload', upload, {'Content-Type': 'multipart/form-data; boundary=omniBoundary'})
|
||||||
|
assert status == 200 and json.loads(body)['data'] == {'bytes': 5}
|
||||||
|
# Exercise malformed multipart over real HTTP on both transports.
|
||||||
|
for broken in (upload.split(b'hello')[0] + b'private file bytes', b'--omniBoundary\r\nContent-Disposition: form-data'):
|
||||||
|
status, body = request(base + '/upload', broken, {'Content-Type': 'multipart/form-data; boundary=omniBoundary'})
|
||||||
|
assert status == 400 and json.loads(body) == {'code': 400004, 'msg': '上传内容不完整', 'data': None}
|
||||||
|
else:
|
||||||
|
status, body = request(base + '/api/greet/Alice')
|
||||||
|
assert status == 200 and json.loads(body)['data']['message'] == 'Hello Alice'
|
||||||
|
assert request(base + '/missing')[0] == 404
|
||||||
|
os.killpg(proc.pid, sig)
|
||||||
|
exit_code = proc.wait(timeout=10)
|
||||||
|
if strict_exit:
|
||||||
|
assert exit_code == 0, exit_code
|
||||||
|
# A go run parent can exit from the signal; its compiled child must also release its listener.
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
with socket.create_connection(('127.0.0.1', port), timeout=0.1):
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
else:
|
||||||
|
raise RuntimeError('listener remains after shutdown')
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.killpg(proc.pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
started = time.monotonic()
|
||||||
|
env = dict(os.environ, GOWORK='off', GOFLAGS='')
|
||||||
|
# Prevent inherited project configuration from binding a real server or loading secrets.
|
||||||
|
env = {k: v for k, v in env.items() if not k.startswith('GO_OMNI_')}
|
||||||
|
with tempfile.TemporaryDirectory(prefix='omni-managed-') as temp:
|
||||||
|
work = Path(temp)
|
||||||
|
cli = work / 'go-omni'
|
||||||
|
project = work / 'service'
|
||||||
|
run(['go', 'build', '-o', str(cli), './cmd/go-omni'], ROOT, env)
|
||||||
|
run([str(cli), 'new', '--framework-dir', str(ROOT), '--module', 'example.com/managedSmoke', str(project)], ROOT, env)
|
||||||
|
run(['go', 'mod', 'tidy'], project, env)
|
||||||
|
run(['go', 'build', '.'], project, env)
|
||||||
|
assert (project / 'main.go').is_file() and not (project / 'cmd/server/main.go').exists()
|
||||||
|
before = {str(p.relative_to(project)): hashlib.sha256(p.read_bytes()).hexdigest() for p in project.rglob('*.go')}
|
||||||
|
run([str(cli), 'api', 'generate'], project, env)
|
||||||
|
run([str(cli), 'api', 'generate', '--check'], project, env)
|
||||||
|
after = {str(p.relative_to(project)): hashlib.sha256(p.read_bytes()).hexdigest() for p in project.rglob('*.go')}
|
||||||
|
assert before == after
|
||||||
|
verify_contract_changes(cli, project, env)
|
||||||
|
serve(['go', 'run', '.'], project, env, 'nethttp', signal.SIGTERM, False)
|
||||||
|
for transport in ('nethttp', 'native'):
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
serve([str(project / 'managedSmoke')], project, env, transport, sig, True)
|
||||||
|
example = ROOT/'examples/managedService'
|
||||||
|
run(['go', 'build', '-o', str(work/'managedExample'), '.'], example, env)
|
||||||
|
for transport in ('nethttp', 'native'):
|
||||||
|
serve([str(work/'managedExample')], example, env, transport, signal.SIGTERM, True, contract=True, logs=work)
|
||||||
|
sources = ['cmd/go-omni/apigen/generate.go', 'cmd/go-omni/apigen/templates.go', 'cmd/go-omni/apigen/startup.go', 'cmd/go-omni/apigen/managed_test.go', 'cmd/go-omni/apigen/contractChanges.go', 'cmd/go-omni/apigen/logicCheck.go', 'cmd/go-omni/apigen/write.go', 'cmd/go-omni/api.go', 'config/config.go', 'httpx/transfer.go', 'httpx/multipart.go', 'scripts/verifyManagedProject.py']
|
||||||
|
sources += [str(p.relative_to(ROOT)) for p in (ROOT/'cmd/go-omni/scaffold/templates').glob('api-*.tmpl')]
|
||||||
|
sources += [str(p.relative_to(ROOT)) for p in (ROOT/'cmd/go-omni/apigen/testdata/managed').glob('*.go')]
|
||||||
|
report = {'passed': True, 'mode': 'localSource', 'durationSeconds': round(time.monotonic()-started, 2), 'checks': ['rootGoRun', 'rootGoBuild', 'stableRegeneration', 'checkMainOwnership', 'contractChangePreview', 'logicSignatureCheckPersists', 'businessSourcePreserved', 'fixedConsumerBuild', 'nethttpSIGINT', 'nethttpSIGTERM', 'nativeSIGINT', 'nativeSIGTERM', 'listenerReleased', 'temporaryProcessesRemoved', 'nethttpGeneratedTransfers', 'nativeGeneratedTransfers', 'malformedMultipart400', 'goOwnedErrors', 'default404And405'], 'sourceHashes': {p: hashlib.sha256((ROOT/p).read_bytes()).hexdigest() for p in sources}}
|
||||||
|
(ROOT/'performance/managedProjectLocal.json').write_text(json.dumps(report, indent=2)+'\n')
|
||||||
|
print('PASS generated root project: go run/build, regeneration, nethttp/native signals; temporary files removed')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
97
scripts/verifyManagedRelease.py
Normal file
97
scripts/verifyManagedRelease.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Accept a fixed managed-project release using a fresh remote module cache."""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
from verifyErrorInheritanceRelease import run
|
||||||
|
from verifyManagedProject import serve, verify_contract_changes
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
MODULE = "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--version", required=True)
|
||||||
|
parser.add_argument("--report", type=Path, default=ROOT / "performance/managedProjectRemote.json")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version):
|
||||||
|
parser.error("a fixed release version is required")
|
||||||
|
env = {k: v for k, v in os.environ.items() if not k.startswith("GO_OMNI_")}
|
||||||
|
env.update(GOWORK="off", GOFLAGS="", GOPRIVATE="git.yuntuo.hk", GONOSUMDB="git.yuntuo.hk")
|
||||||
|
started = time.monotonic()
|
||||||
|
checks = ["remoteCLIInstall", "freshModuleCache", "fixedVersionNoReplace"]
|
||||||
|
with tempfile.TemporaryDirectory(prefix="goOmniManagedRelease-") as directory:
|
||||||
|
temp = Path(directory)
|
||||||
|
(temp / "bin").mkdir()
|
||||||
|
env.update(GOBIN=str(temp / "bin"), GOMODCACHE=str(temp / "modcache"))
|
||||||
|
print(f"Installing remote CLI {args.version} in an empty module cache", flush=True)
|
||||||
|
run(["go", "install", MODULE + "/cmd/go-omni@" + args.version], temp, env)
|
||||||
|
cli = temp / "bin/go-omni"
|
||||||
|
# Do not pass --framework-version: verify that the installed CLI pins
|
||||||
|
# its own downloaded version, without a source-directory replacement.
|
||||||
|
starter = temp / "starter"
|
||||||
|
run([cli, "new", "--module", "example.com/releaseStarter", starter], temp, env)
|
||||||
|
run(["go", "mod", "tidy"], starter, env)
|
||||||
|
dependency = json.loads(run(["go", "list", "-m", "-json", MODULE], starter, env))
|
||||||
|
assert dependency["Version"] == args.version and dependency.get("Sum") and not dependency.get("Replace")
|
||||||
|
verify_contract_changes(cli, starter, env)
|
||||||
|
checks += ["contractChangePreview", "persistentLogicSignatureFailure", "businessSourcePreserved", "fixedConsumerBuild"]
|
||||||
|
serve(["go", "run", "."], starter, env, "nethttp", signal.SIGTERM, False, logs=temp)
|
||||||
|
for transport in ("nethttp", "native"):
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
serve([str(starter / "releaseStarter")], starter, env, transport, sig, True, logs=temp)
|
||||||
|
checks += ["rootGoRun", "nethttpAndNativeSignals", "listenerReleased"]
|
||||||
|
|
||||||
|
# Only project-owned business implementations come from the example.
|
||||||
|
# Main, handlers, DTOs and startup are emitted by the downloaded CLI.
|
||||||
|
managed = temp / "managed"
|
||||||
|
run([cli, "new", "--api", ROOT / "examples/contracts/managed.api", "--module", "example.com/managed", managed], temp, env)
|
||||||
|
example = ROOT / "examples/managedService"
|
||||||
|
sources = list((example / "internal/logic/demo").glob("*.go"))
|
||||||
|
sources += [example / "internal/svc/options.go", example / "internal/apierrors/catalog.go"]
|
||||||
|
for source in sources:
|
||||||
|
target = managed / source.relative_to(example)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(source, target)
|
||||||
|
run([cli, "api", "generate", "--check"], managed, env)
|
||||||
|
run(["go", "mod", "tidy"], managed, env)
|
||||||
|
run(["go", "build", "-o", temp / "managedServer", "."], managed, env)
|
||||||
|
managed_dependency = json.loads(run(["go", "list", "-m", "-json", MODULE], managed, env))
|
||||||
|
assert managed_dependency["Sum"] == dependency["Sum"] and not managed_dependency.get("Replace")
|
||||||
|
for transport in ("nethttp", "native"):
|
||||||
|
serve([str(temp / "managedServer")], managed, env, transport, signal.SIGTERM, True, contract=True, logs=temp)
|
||||||
|
checks += ["default404And405", "goOwnedErrorCatalog", "unwrappedDownloads", "rawCallback", "multipart400And413", "nethttpAndNativeTransfers"]
|
||||||
|
|
||||||
|
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version)
|
||||||
|
tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer"]
|
||||||
|
print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True)
|
||||||
|
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", "./cmd/go-omni/apigen", "-run", "^(" + "|".join(tests) + ")$"], ROOT, env)
|
||||||
|
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
||||||
|
passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e]
|
||||||
|
assert sorted(passed) == sorted(tests), output
|
||||||
|
evidence = "".join(e.get("Output", "") for e in events)
|
||||||
|
sums = re.findall(r"checksum (h1:\S+); no local replace", evidence)
|
||||||
|
assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch"
|
||||||
|
checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars"]
|
||||||
|
|
||||||
|
sources = ["scripts/verifyManagedRelease.py", "scripts/verifyManagedProject.py", "cmd/go-omni/apigen/managed_test.go"]
|
||||||
|
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/managed").glob("*.go")]
|
||||||
|
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
|
||||||
|
"freshModuleCache": True, "noLocalReplace": True,
|
||||||
|
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,
|
||||||
|
"sourceHashes": {p: hashlib.sha256((ROOT / p).read_bytes()).hexdigest() for p in sources}}
|
||||||
|
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
|
||||||
|
print(f"PASS remote managed project release {args.version}; temporary files and processes removed", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user