feat: release managed service generation and contract diagnostics

This commit is contained in:
2026-09-24 17:34:21 +08:00
parent eb69260586
commit 158b8002b3
85 changed files with 5722 additions and 360 deletions

46
API.md
View File

@@ -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)。

View File

@@ -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
View 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 的私有变量。

View File

@@ -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) |

View File

@@ -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` 无需重复定义错误目录或逐条列出错误。

View File

@@ -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)。

View File

@@ -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)
}
}
}

View File

@@ -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)
}
} }

View File

@@ -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},

View File

@@ -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"} {

View File

@@ -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")

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

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

View File

@@ -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
} }

View File

@@ -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",
}) })
} }

View File

@@ -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,8 +537,12 @@ 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)
} }
if name == "main.go" {
put(generated, name, generatedHeader+content)
} else {
put(seeds, name, content) 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)
if err != nil { if err != nil {
@@ -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 {

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

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

View File

@@ -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")}

View File

@@ -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)

View File

@@ -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", "./...")

View File

@@ -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 {

View File

@@ -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 != "" {

View File

@@ -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)

View File

@@ -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.

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

View File

@@ -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)
} }

View File

@@ -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)
} }

View File

@@ -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
} }

View File

@@ -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)
} }

View File

@@ -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)
} }

View File

@@ -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)
} }

View File

@@ -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)
} }

View 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()
}

View 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())
}
})
}

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

View 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)
}
},
}
}

View File

@@ -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" {

View File

@@ -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 apiFile != "" {
if options.Force { if options.Force {
return errors.New("--api protects existing business code and does not accept --force") return errors.New("managed projects do not accept --force; use api generate to update owned files")
} }
_, 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}) 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 != "" {
out, err = apigen.Generate(o)
} else {
out, err = apigen.New(o)
}
printAPIDiagnostics(out.Diagnostics)
if err != nil { if err != nil {
return err return err
} }
fmt.Printf("created API project %s; implement internal/service operations\n", filepath.Clean(target)) fmt.Printf("created API project %s; implement internal/logic operations; run with go run .\n", filepath.Clean(target))
return nil
}
if _, err := os.Lstat(filepath.Join(target, ".goOmni", "apiManifest.json")); err == nil {
return errors.New("this is an API project; use api generate instead of the CRUD/module starter")
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := scaffold.Generate(options); err != nil {
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
} }

View File

@@ -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)
} }
} }

View File

@@ -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",
"internal/svc/options.go": "templates/api-options.go.tmpl",
"internal/config/config.go": "templates/modules-settings.go.tmpl",
"etc/config.yaml": "templates/modules-config.yaml.tmpl", "etc/config.yaml": "templates/modules-config.yaml.tmpl",
"internal/servicecontext/context.go": "templates/api-context.go.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

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

View File

@@ -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
}

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

View File

@@ -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.

View File

@@ -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
View 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)
}
})
}
}

View 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
View File

@@ -0,0 +1,8 @@
/server
/var/
.env
.env.*
!.env.example
/.goOmni/api.lock
/managed

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

View 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.

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

File diff suppressed because it is too large Load Diff

View 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: []

View 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 => ../..

View 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=

View 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: "上传内容不完整"},
},
}
}

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

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

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

File diff suppressed because one or more lines are too long

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

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

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

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

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

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

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

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

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

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

View File

@@ -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{}
}

View File

@@ -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

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

View 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)
}
},
}
}

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

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

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

View 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)。

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

View 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()

View 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()