From 158b8002b355f09237cd12f49d9b66ee78d493f3 Mon Sep 17 00:00:00 2001 From: k2safe Date: Thu, 24 Sep 2026 17:34:21 +0800 Subject: [PATCH] feat: release managed service generation and contract diagnostics --- API.md | 46 +- CHANGELOG.md | 11 + PROJECT.md | 178 +++ README.md | 66 +- RELEASE.md | 46 +- cmd/go-omni/README.md | 165 +- cmd/go-omni/api.go | 28 +- cmd/go-omni/api_test.go | 54 +- cmd/go-omni/apigen/apigen_test.go | 22 +- cmd/go-omni/apigen/compatibility_test.go | 4 +- cmd/go-omni/apigen/compile.go | 8 +- cmd/go-omni/apigen/contractChanges.go | 244 +++ cmd/go-omni/apigen/contractChanges_test.go | 308 ++++ cmd/go-omni/apigen/contractMetadata.go | 3 + cmd/go-omni/apigen/errorInheritance_test.go | 6 +- cmd/go-omni/apigen/generate.go | 79 +- cmd/go-omni/apigen/logicCheck.go | 240 +++ cmd/go-omni/apigen/managed_test.go | 213 +++ cmd/go-omni/apigen/metadata_test.go | 6 +- cmd/go-omni/apigen/parse.go | 24 +- cmd/go-omni/apigen/profile_test.go | 6 +- cmd/go-omni/apigen/projectEntry.go | 2 +- cmd/go-omni/apigen/requestOptions.go | 8 +- cmd/go-omni/apigen/response.go | 12 + cmd/go-omni/apigen/response_test.go | 8 +- cmd/go-omni/apigen/startup.go | 49 + cmd/go-omni/apigen/templates.go | 101 +- .../testdata/compatibility/consumer_test.go | 6 +- .../apigen/testdata/compatibility/echo.go | 6 +- .../testdata/errorCatalog/consumer_test.go | 6 +- .../errorInheritance/consumer_test.go | 6 +- .../testdata/errorInheritance/service.go | 6 +- .../testdata/errorParams/consumer_test.go | 6 +- cmd/go-omni/apigen/testdata/managed/client.go | 24 + .../apigen/testdata/managed/consumer_test.go | 235 +++ .../apigen/testdata/managed/operations.go | 80 + .../apigen/testdata/managed/options.go | 34 + cmd/go-omni/apigen/write.go | 31 +- cmd/go-omni/main.go | 35 +- cmd/go-omni/main_test.go | 13 +- cmd/go-omni/scaffold/apiInfrastructure.go | 19 +- .../scaffold/templates/api-close.go.tmpl | 22 + .../scaffold/templates/api-context.go.tmpl | 14 +- .../scaffold/templates/api-options.go.tmpl | 11 + cmd/go-omni/scaffold/version.go | 2 +- config/config.go | 12 +- config/response_test.go | 41 + examples/contracts/managed.api | 57 + examples/managedService/.gitignore | 8 + .../managedService/.goOmni/apiManifest.json | 161 ++ examples/managedService/README.md | 175 ++ examples/managedService/api/managed.api | 57 + examples/managedService/docs/openapi.json | 1422 +++++++++++++++++ examples/managedService/etc/config.yaml | 12 + examples/managedService/go.mod | 22 + examples/managedService/go.sum | 34 + .../internal/apierrors/catalog.go | 18 + .../managedService/internal/config/config.go | 69 + .../managedService/internal/handler/appGen.go | 36 + .../internal/handler/demo/handlersGen.go | 134 ++ .../internal/handler/openapiGen.go | 13 + .../internal/handler/responseGen.go | 41 + .../internal/handler/routesGen.go | 26 + .../internal/logic/demo/callback.go | 22 + .../internal/logic/demo/check.go | 20 + .../internal/logic/demo/download.go | 12 + .../internal/logic/demo/logic.go | 7 + .../internal/logic/demo/other.go | 10 + .../internal/logic/demo/rawJSON.go | 10 + .../internal/logic/demo/slow.go | 16 + .../internal/logic/demo/upload.go | 31 + .../internal/middleware/registryGen.go | 8 + .../internal/repository/demo/repository.go | 4 + examples/managedService/internal/svc/close.go | 36 + .../managedService/internal/svc/options.go | 24 + .../internal/svc/serviceContext.go | 59 + .../managedService/internal/types/typesGen.go | 9 + examples/managedService/main.go | 52 + httpx/multipart.go | 173 ++ httpx/transfer.go | 128 ++ httpx/transfer_test.go | 271 ++++ performance/managedProjectAcceptance.md | 48 + performance/managedProjectLocal.json | 47 + scripts/verifyManagedProject.py | 167 ++ scripts/verifyManagedRelease.py | 97 ++ 85 files changed, 5722 insertions(+), 360 deletions(-) create mode 100644 PROJECT.md create mode 100644 cmd/go-omni/apigen/contractChanges.go create mode 100644 cmd/go-omni/apigen/contractChanges_test.go create mode 100644 cmd/go-omni/apigen/logicCheck.go create mode 100644 cmd/go-omni/apigen/managed_test.go create mode 100644 cmd/go-omni/apigen/startup.go create mode 100644 cmd/go-omni/apigen/testdata/managed/client.go create mode 100644 cmd/go-omni/apigen/testdata/managed/consumer_test.go create mode 100644 cmd/go-omni/apigen/testdata/managed/operations.go create mode 100644 cmd/go-omni/apigen/testdata/managed/options.go create mode 100644 cmd/go-omni/scaffold/templates/api-close.go.tmpl create mode 100644 cmd/go-omni/scaffold/templates/api-options.go.tmpl create mode 100644 config/response_test.go create mode 100644 examples/contracts/managed.api create mode 100644 examples/managedService/.gitignore create mode 100644 examples/managedService/.goOmni/apiManifest.json create mode 100644 examples/managedService/README.md create mode 100644 examples/managedService/api/managed.api create mode 100644 examples/managedService/docs/openapi.json create mode 100644 examples/managedService/etc/config.yaml create mode 100644 examples/managedService/go.mod create mode 100644 examples/managedService/go.sum create mode 100644 examples/managedService/internal/apierrors/catalog.go create mode 100644 examples/managedService/internal/config/config.go create mode 100644 examples/managedService/internal/handler/appGen.go create mode 100644 examples/managedService/internal/handler/demo/handlersGen.go create mode 100644 examples/managedService/internal/handler/openapiGen.go create mode 100644 examples/managedService/internal/handler/responseGen.go create mode 100644 examples/managedService/internal/handler/routesGen.go create mode 100644 examples/managedService/internal/logic/demo/callback.go create mode 100644 examples/managedService/internal/logic/demo/check.go create mode 100644 examples/managedService/internal/logic/demo/download.go create mode 100644 examples/managedService/internal/logic/demo/logic.go create mode 100644 examples/managedService/internal/logic/demo/other.go create mode 100644 examples/managedService/internal/logic/demo/rawJSON.go create mode 100644 examples/managedService/internal/logic/demo/slow.go create mode 100644 examples/managedService/internal/logic/demo/upload.go create mode 100644 examples/managedService/internal/middleware/registryGen.go create mode 100644 examples/managedService/internal/repository/demo/repository.go create mode 100644 examples/managedService/internal/svc/close.go create mode 100644 examples/managedService/internal/svc/options.go create mode 100644 examples/managedService/internal/svc/serviceContext.go create mode 100644 examples/managedService/internal/types/typesGen.go create mode 100644 examples/managedService/main.go create mode 100644 httpx/multipart.go create mode 100644 httpx/transfer.go create mode 100644 httpx/transfer_test.go create mode 100644 performance/managedProjectAcceptance.md create mode 100644 performance/managedProjectLocal.json create mode 100644 scripts/verifyManagedProject.py create mode 100644 scripts/verifyManagedRelease.py diff --git a/API.md b/API.md index 5217a9e..7bd405d 100644 --- a/API.md +++ b/API.md @@ -1,5 +1,7 @@ # API 契约与项目生成 +当前生成目录和启动扩展采用新布局,见 [项目维护指南](PROJECT.md)。`@app(defaultResponse: Name)` 在入口选择 App 默认响应。`@body(mode: raw|multipart)` 和 `@produces` 支持自动 Handler,手写 Handler 仅为显式选择的扩展。 + 首次接入请先看 [README](README.md):包含固定远程版本安装、可运行项目、完整 `.api` 示例和已有服务接入步骤。本页用于查阅语法与生成规则;下方带 `--framework-dir` 的命令用于框架源码开发。 本阶段目标:从 `.api` 定义生成可运行的 Go 项目;定义、绑定、校验与 OpenAPI 保持一致;重复生成不覆盖业务实现;业务代码继续独立于网络层。实现入口是 `cmd/go-omni/apigen`,运行时契约编译器是 `contract`。 @@ -15,8 +17,8 @@ | 熟悉的命令别名 | `api go -api user.api -dir .` | | 校验契约 | `api validate --api user.api` | | CI 检查生成产物是否过期 | `api generate --api user.api --dir . --check` | -| 业务逻辑目录 | `internal/service/`,沿用 Omni 的 Service 分层 | -| 依赖装配 | `internal/servicecontext` | +| 业务逻辑目录 | `internal/logic/`,使用标准 context.Context | +| 依赖装配 | `internal/svc` | | 接口文档 | 自动生成 `docs/openapi.json`,同时提供 `/openapi.json` | 这是明确限定的 go-zero 风格方言,**不是完整 goctl 兼容实现**。不支持的声明和校验标签会在生成前报错。 @@ -31,10 +33,10 @@ go run ./cmd/go-omni api new \ cd ../omniDemo 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 仓库执行: @@ -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`。 - `--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 项目不会额外生成公开的签名发放接口,鉴权和对象权限由业务实现。初始化失败会清理已打开资源,退出清理可重复调用。 -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 go-omni api validate # 发现 manifest 中的入口并校验 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 --check # 只读检查,可用于 CI +go-omni api generate --check # 只读检查产物漂移及明确的 Logic 签名错误,可用于 CI go-omni api export > openapi.json # 独立导出纯 JSON,无需启动服务 go test ./... ``` `--dry-run` 和 `--check` 不能组合。新项目预览也不创建目录;预览不是审批或锁定,实际生成会重新检查文件状态。 +契约变化会定位到相关 Logic 文件,涵盖嵌套 DTO、原始 body 和流式返回。生成后签名不一致的问题仍可由 `--check` 检出;项目类型别名/build tags 等不确定情况提示 REVIEW,最终用 `go test ./...` 验证。详见 [契约变更提示](PROJECT.md#契约变更提示)。 在项目外可显式传 `--api`;生成时传 `--dir` 可定位其他项目。不会猜测多个 .api 中哪个是入口。 路由总览的 `(undeclared)` 仅表示没有声明 auth;旧式中间件仍可能做认证。 外发文档可用 export;项目内受生成器管理的 `docs/openapi.json` 请通过 generate 更新,以同步文件哈希。 -## 迁移扩展(v0.9.0-preview) +## 迁移扩展 - 动态 JSON 字段 `any` / `interface{}`,包含切片和 map;外围契约仍严格。 - `scalar Name (...)` 显式引用项目类型,声明 JSON 输入/输出类型及文本绑定;支持已有 JSON/Text 转换方法。 - `@body (mode: json/raw/multipart; maxBytes: ...)`:按路由限制请求体。 -- `@binding custom` 生成保留的绑定 hook;`@handlerMode manual` 生成保留的整个 Handler,raw/multipart 使用这个入口。 -- manual 可用 `@produces "text/plain"` 等声明无 DTO 成功输出。默认状态 200,初始手写 stub 返回 501。 +- `@binding custom` 生成保留的绑定 hook;`@handlerMode manual` 生成保留的整个 Handler,仅用于自定义协议。raw/multipart 默认生成自动 Handler,将受限流交给 Logic。 +- `@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 api/ # 开发者维护的契约及导入 -cmd/server/main.go # 启动、日志、优雅退出 +main.go # 启动、日志、优雅退出 etc/config.yaml -internal/settings/ -internal/servicecontext/ # 依赖初始化和释放 +internal/config/ +internal/svc/ # 依赖初始化和释放 internal/types/typesGen.go internal/apierrors/errorsGen.go # 声明错误后生成 internal/handler/routesGen.go internal/handler/openapiGen.go internal/handler//handlersGen.go -internal/service//service.go -internal/service//.go +internal/logic//logic.go +internal/logic//.go internal/repository//repository.go internal/middleware/registryGen.go internal/middleware/.go @@ -292,7 +296,7 @@ docs/openapi.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,不把名称误当成已实现的认证协议。 @@ -307,7 +311,7 @@ go-omni api generate --api api/user.api --dir . --check go test ./... ``` -`Gen.go` 和 OpenAPI 由生成器管理,manifest 保存其 SHA-256。手动修改后更新会报冲突。Service、Repository、main、配置、依赖装配和中间件实现按字节保留;只补缺失文件。删除接口/分组时只移除旧的生成文件,保留业务代码;已删除类型仍被旧业务引用时,需要明确迁移该业务文件,编译器会提示。 +`main.go`、`Gen.go` 和 OpenAPI 由生成器管理,manifest 保存其 SHA-256。手动修改后更新会报冲突。Logic、Repository、配置、依赖装配和中间件实现按字节保留;只补缺失文件。删除接口/分组时只移除旧的生成文件,保留业务代码;已删除类型仍被旧业务引用时,需要明确迁移该业务文件,编译器会提示。 `--check` 只读,检查产物是否过期,以及所需骨架是否缺失。契约复制到项目后,请编辑项目内的 API;外部定义与已存在副本不一致时会拒绝覆盖。一个项目由一个 API 入口管理,可以通过导入拆分文件。 @@ -319,7 +323,7 @@ go test ./... ```sh 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)。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ddd06e..0332948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # 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 - 修复生成路由用空错误目录遮蔽 App 目录的问题;空 WithErrors 完全透传,避免 Context 与 Encoder 二次解析时丢失状态、业务码和中文提示。 diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..6be2a1d --- /dev/null +++ b/PROJECT.md @@ -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//*.go` | 业务方法,使用标准 context.Context | +| `internal/repository//*.go` | 数据访问,按需实现 | +| `internal/middleware/.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 的私有变量。 diff --git a/README.md b/README.md index adda880..235b43f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # 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` 都是示例配置,可以替换。 @@ -22,27 +24,26 @@ GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可用,并具有仓库读取权限;使用自己的 Git 凭据管理器,不把账号或 Token 写进 module 地址。 -以下命令在同一个终端执行。已有 `GOPRIVATE` 时,把 `git.yuntuo.hk` 追加到原来的逗号分隔列表。 +安装固定版本的 CLI,后面的命令继续使用同一终端。私有 module 加入 GOPRIVATE,保留已有配置: ```sh -export GOPRIVATE=git.yuntuo.hk -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.1-preview +export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk" +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.12.0-preview export PATH="$(go env GOPATH)/bin:$PATH" 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 名称。 ```sh -go-omni api new --framework-version v0.11.1-preview \ - --module example.com/hello ./hello +go-omni api new --module example.com/hello ./hello cd hello 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":"本次请求编号"} ``` -按 Ctrl+C 停止服务。项目依赖已固定为远程版本,不需要克隆框架源码或添加本地 `replace`。 +按 Ctrl+C 停止服务。进入项目目录即可 `go build .`;该示例不需要数据库或其他外部服务。 ### 生成的项目放什么 ```text hello/ ├── api/service.api # 维护接口定义,也可 import 其他 .api -├── cmd/server/main.go # 启动、路由注册、优雅退出 +├── main.go # 生成器维护:启动、路由注册、优雅退出 ├── etc/config.yaml # 项目配置 ├── internal/ -│ ├── settings/ # 配置读取 -│ ├── servicecontext/ # 数据库、日志、外部客户端等依赖 +│ ├── config/ # 配置读取 +│ ├── svc/ # 数据库、日志、外部客户端等依赖 │ ├── types/typesGen.go # 生成的请求与业务返回类型 │ ├── handler/ # 生成的绑定、响应策略、路由及文档 -│ ├── service/greet/ # 在这里实现业务逻辑 +│ ├── logic/greet/ # 在这里实现业务逻辑 │ ├── repository/greet/ # 在这里实现数据访问 │ └── middleware/ # 声明鉴权等中间件后生成对应 hook ├── docs/openapi.json # 生成的 OpenAPI 3.0.3 └── .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 syntax = "v1" +@app (defaultResponse: CommonResponse) + response CommonResponse ( body: {"code":"${code}", "msg":"${message}", "data":"${data}"} codeType: int32 @@ -126,13 +129,13 @@ service catalog-api { ```sh 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 cd catalog go mod tidy ``` -将生成的 `internal/service/item/getItem.go` 替换为下面的演示实现。实际业务中换成 Repository 查询,Service 返回业务数据或错误,不再手工包装公共体: +将生成的 `internal/logic/item/getItem.go` 替换为下面的演示实现。实际业务中换成 Repository 查询,Logic 返回业务数据或错误,不再手工包装公共体: ```go package item @@ -144,7 +147,7 @@ import ( "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" { return nil, apierrors.ItemNotFound() } @@ -156,7 +159,7 @@ func (s *Service) GetItem(ctx context.Context, req *types.GetItemReq) (*types.It ```sh # 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/missing @@ -167,24 +170,24 @@ curl -i http://127.0.0.1:8080/api/v1/items/missing 需要 MySQL、文件存储或文件日志时,**首次生成**可以使用: ```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 \ --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 所在目录**执行: ```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` 示例;迁入现有项目时,把路由调用接到自己的业务服务即可。 @@ -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)。 @@ -411,7 +414,7 @@ API 项目的数据库迁移需要单独维护并显式执行;启动不会自 例如在生成项目中切换 native: ```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。 @@ -447,9 +450,9 @@ go-omni api generate --check 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) | | 公共响应体、提示优先级、独立回调协议 | [响应参考](RESPONSE.md) | -| CLI 完整参数、本地框架开发和模块脚手架 | [CLI](cmd/go-omni/README.md) | -| 完整 API 业务项目 | [userService](examples/userService/README.md) | +| CLI 完整参数、本地框架开发与迁移 | [CLI](cmd/go-omni/README.md) | +| 当前生成项目:Go 错误目录、上传、回调和下载 | [managedService](examples/managedService/README.md) | +| 数据库/事务业务参考(旧目录示例) | [userService](examples/userService/README.md) | | 手写接口分层示例 | [reference](examples/reference/README.md) | | 框架成熟度、历史变更 | [路线图](ROADMAP.md)、[更新记录](CHANGELOG.md) | | 网络性能、测试条件与适用范围 | [性能说明](PERFORMANCE.md)、[HTTP/2 验收](transport/native/HTTP2.md) | diff --git a/RELEASE.md b/RELEASE.md index a819d04..212e0d1 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,4 +1,48 @@ -# v0.11.1-preview +# v0.12.0-preview + +本版用于固定版本开展业务接入。生成器统一维护服务根目录 main.go、路由、HTTP Handler、DTO 和 OpenAPI,业务方维护 `.api`、config、svc、logic、repository 与项目中间件。目录不再生成 cmd/server、settings、servicecontext 或 service。 + +## 安装与接入 + +仓库权限及 PATH 配置见 [README](README.md#准备环境与安装)。CLI 与框架使用同一版本: + +```sh +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.12.0-preview +go-omni new --module example.com/hello ./hello +cd hello +go mod tidy +go run . +``` + +已存在的手写项目可直接 `go get git.yuntuo.hk/k2safe/go-omni@v0.12.0-preview`。采用生成目录的旧项目在新目录生成后迁入业务代码,详见 [接入步骤](PROJECT.md#已有服务接入步骤) 和 [文件维护边界](PROJECT.md#哪些文件由用户维护)。新 manifest v2 不兼容旧 manifest;不会静默覆盖手写 main,也不会删除独立 cmd/migrate 等工具。 + +## 本版行为 + +- 根目录支持 go run / go build;入口负责配置、日志、依赖、HTTP 启动与信号退出。svc 的 AppOptions、GlobalMiddleware 和 OnClose 是显式扩展入口;初始化/监听失败会释放资源,正常退出先排空请求再关闭依赖和日志。 +- `.api` 入口通过 `@app(defaultResponse: Name)` 选择默认响应,覆盖普通路由、404/405 与相应中间件错误;多模板不会按声明顺序猜默认值。字段、状态、业务码和文案由项目配置。 +- 保持 v0.11.1 的错误目录继承:Go 中注册一次,无独立错误目录的生成路由继承 App。带参数提示、内部错误链、raw 响应和独立目录保持各自行为。 +- raw/multipart 上传及 `@produces` 下载可直接编写 Logic,由生成 Handler 完成受限读取、响应和流关闭。损坏 multipart 默认 400,超限 413;项目可以定制公开错误。 +- 契约变更提示受影响的 Logic、DTO 字段和请求模式;明确的签名/语法/重复方法错误使 `generate --check` 失败。重新生成保留业务文件,main 和其他生成文件的漂移也会被检查。 + +## 验收与范围 + +发布前全量 race 测试、go vet、go build 和本地实际生成项目进程验收通过,记录见 [生成项目验收](performance/managedProjectAcceptance.md)。远程发布后使用以下命令独立验收安装产物;结果另行提交至仓库,不改写已发布标签: + +```sh +GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./... +go vet ./... +go build ./... +python3 scripts/verifyManagedProject.py +python3 scripts/verifyManagedRelease.py --version v0.12.0-preview +``` + +远程验收使用空 module 缓存安装已发布 CLI,生成的业务项目仅依赖同版远程 module,无本地 replace;检查根目录运行/构建、重新生成、错误继承/模板参数、上传下载、nethttp/native 的真实请求与退出信号,以及初始化失败/监听失败/请求排空后的清理。 + +本版不代表具体业务项目已完成迁移验收。HTTP/2 仍为实验状态;真实数据库、云账号和业务服务器不属于本轮验收范围。轻量 Logic 检查不能代替 Go 编译器或业务测试。静态 OpenAPI 不执行 Go 代码,因此不会自动列举仅在 Go 中维护的错误目录。请求流校验不能回滚业务提前执行的副作用,关键写入应放在 `body.Complete()` 成功之后。 + +--- + +# v0.11.1-preview(历史版本) 修复生成路由使用空错误目录遮蔽 `omni.WithErrors(catalog)` 的问题。错误现在可以只在 Go 中注册一次,`.api` 无需重复定义错误目录或逐条列出错误。 diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md index 3a91cea..4531b8a 100644 --- a/cmd/go-omni/README.md +++ b/cmd/go-omni/README.md @@ -1,162 +1,63 @@ # go-omni CLI -首次接入见 [README](../../README.md),无需克隆框架源码。本页说明命令参数;下方 `go run ./cmd/go-omni` 示例在框架仓库中执行,并显式引用本地源码。 +新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。 -## API 契约驱动项目 +## 创建项目 ```sh -# 创建包含可运行问候接口的新项目 -go run ./cmd/go-omni api new --module example.com/demo --framework-dir . ../demo +# 仓库读取权限与 PATH 配置见根目录 README +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.12.0-preview +go-omni new --module example.com/hello ./hello -# 从已有定义生成对应目录、类型、路由和业务骨架 -go run ./cmd/go-omni api generate --api examples/contracts/user.api \ - --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 +# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在 +go-omni new --api service.api --module example.com/example ./example ``` -`new --api ` 是契约项目的另一个入口。可以组合 `--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 .`。 -项目内可省略 `--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` 调用函数。 +参数:`--database none|memory|mysql`(默认 none;memory 等同 none),`--storage none|local|s3|r2|oss`,`--log-output stdout|stderr|file`,`--log-format json|text`。基础设施按需生成,连接凭据由环境提供,不自动创建数据库、表或云资源。选择 MySQL 不会凭空生成业务表或 Repository 查询。 -`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 -# 在 go-omni 仓库中运行;目标目录须为空。 -go run ./cmd/go-omni new --framework-dir . \ - --module example.com/fileService --log-output file --log-format json \ - --storage local ../fileService +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 ./... ``` -生成项目包含 `etc/config.yaml`、`internal/settings`、`internal/servicecontext/storage.go`、 -文件 Service/Handler、测试和 `MODULES.md`。按生成的 MODULES.md 配置示例 Token 和本地签名密钥后, -`go mod tidy && go run ./cmd/server` 即可体验。云存储分别选择 `--storage s3`、`r2`、`oss`, -再设置已有桶/凭据等环境变量;不自动创建桶或修改云资源。 +项目内自动发现 manifest 中的 API 入口,也可显式 `--api ... --dir ...`。`api go -api ... -dir ...` 为命令别名,不代表兼容全部 goctl 语法。 -配置优先级为默认值 → YAML/JSON → 环境变量;密钥仅允许环境变量。Service 依赖统一存储接口, -组合处负责创建客户端与有界连接池,退出时关闭自己创建的资源。文件接口提供服务端上传、直传签名、 -临时下载链接及统一错误处理;日志示例携带 requestId,文件输出默认带轮转。没有固定业务身份上下文。 +main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节保留。手改生成文件会报冲突,`--check` 检查入口与所有生成产物。没有 `--force` 覆盖业务的入口。初始基础设施 profile 在重新生成时沿用,后续项目依赖由用户修改 config/svc。 -新模板要求 `v0.7.0-preview` 或更新版本;明确指定旧版本并启用新模块会被拒绝, -避免模板引用旧框架不存在的接口。本地 `--framework-dir` 验证不受此限制。`--force` 可重新生成兼容配置, -但如果切换配置会遗留引用已移除依赖的旧文件,则拒绝操作,要求使用新目录;不会自动删除修改过的代码。 +生成/预览会比较上次契约快照,提示受影响的 Logic 文件、DTO 字段和 body/流式模式变化。明确的 Logic 签名错误显示文件行号、原签名和期望签名;生成后未调整业务时,`--check` 仍返回失败。REVIEW 不强制确认,类型别名/build tags 等不确定情况交给 `go test ./...` 验证。完整行为见 [契约变更提示](../../PROJECT.md#契约变更提示)。 -验证当前生成器: +`@app(defaultResponse: TemplateName)` 选择 App 默认响应,路由/分组可以覆盖。错误表可以只在 Go 中维护,通过 svc.AppOptions 注册一次。上传和原始 body 的自动 Logic 参数、下载输出见 [项目指南](../../PROJECT.md)。 + +## 版本与迁移 + +CLI 与框架依赖固定使用 v0.12.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: ```sh -GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./cmd/go-omni/... -python3 scripts/verifyScaffoldModules.py +# 在 go-omni 源码仓库中执行 +go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello ``` -前者覆盖两种数据库与五种文件存储选择、日志方式/格式,以及生成项目的配置、资源关闭和接口测试。 -后者实际编译启动服务,验证日志、上传/下载/直传链接、错误状态、nethttp/native 下的 SIGINT/SIGTERM 和监听端口释放。 -云端选项的测试使用本地替身,不代表 OSS/R2 实网验收;本批不访问业务服务器。报告见 -[脚手架模块验收](../../performance/scaffoldModulesLocal.json)。 +新模板需要 v0.12.0-preview 的流式能力与 manifest v2。不兼容旧目录,旧生成项目请在新目录重新生成后移入业务代码,不覆盖手写 main。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 -## 其他命令与远程安装 +## 数据库迁移命令 -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 go-omni migrate --dir migrations -``` - -部署步骤配置 `GO_OMNI_DB_DSN`,显式执行: - -```sh +# 检查预览,配置 GO_OMNI_DB_DSN 后显式应用 go-omni migrate --dir migrations --apply ``` -预览不连接数据库;应用启动不自动迁移。失败保留 dirty 状态,不提供自动 down/force。 -脚手架原有 `schema.sql` 不会自动变成有版本的迁移,已有库需要先确定基线。详见 [扩展模块](../../ADVANCED.md)。 +SQL 命名示例 `0001_createUsers.up.sql`;预览只显示版本和 SHA-256,不连接数据库。失败保留 dirty 状态,不自动 down/force。细节见 [扩展模块](../../ADVANCED.md)。 diff --git a/cmd/go-omni/api.go b/cmd/go-omni/api.go index 8f61b83..de8aebd 100644 --- a/cmd/go-omni/api.go +++ b/cmd/go-omni/api.go @@ -159,6 +159,7 @@ func runAPI(args []string) error { } out, err = apigen.Generate(o) } + printAPIDiagnostics(out.Diagnostics) if err != nil { 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)) } 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 { - 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 } + +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) + } + } +} diff --git a/cmd/go-omni/api_test.go b/cmd/go-omni/api_test.go index 26629d0..407076d 100644 --- a/cmd/go-omni/api_test.go +++ b/cmd/go-omni/api_test.go @@ -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 { 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) } 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) } } - 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) } } @@ -86,6 +86,15 @@ func TestAPIDeveloperCommands(t *testing.T) { } } 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() r, w, err := os.Pipe() if err != nil { @@ -100,8 +109,41 @@ func captureAPIOutput(t *testing.T, args []string) string { w.Close() os.Stdout = previous data := <-result - if callErr != nil { - t.Fatal(callErr) - } - return string(data) + return string(data), callErr +} + +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) + } } diff --git a/cmd/go-omni/apigen/apigen_test.go b/cmd/go-omni/apigen/apigen_test.go index 8dde338..535f449 100644 --- a/cmd/go-omni/apigen/apigen_test.go +++ b/cmd/go-omni/apigen/apigen_test.go @@ -168,10 +168,10 @@ func TestRegenerationAndConflicts(t *testing.T) { if _, err := Generate(o); err != nil { 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" 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)) if strings.HasSuffix(file, ".go") { 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) { 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") } 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 { source = consumerStarterTest } 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") -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) @@ -339,13 +339,13 @@ func(s *Service) UpdateUser(ctx context.Context,req *types.UpdateUserReq)(*types } } -const consumerStarterTest = `package consumer_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") -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())}} +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/svc";"example.com/consumer/internal/config") +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 -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") -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)} +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/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:=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}{ {"PUT","/api/v1/users/7?notify=true",` + "`" + `{"name":"Alice","active":false,"age":0}` + "`" + `,200}, {"PUT","/api/v1/users/7",` + "`" + `{"name":"Alice"}` + "`" + `,400}, diff --git a/cmd/go-omni/apigen/compatibility_test.go b/cmd/go-omni/apigen/compatibility_test.go index 7cbd9f8..e7c56df 100644 --- a/cmd/go-omni/apigen/compatibility_test.go +++ b/cmd/go-omni/apigen/compatibility_test.go @@ -69,14 +69,14 @@ func TestCompatibilityConsumer(t *testing.T) { } generate(false) 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/files/downloadManual.go": "download.go", "consumer_test.go": "consumer_test.go", } // The fixture implements related methods together; remove only its generated // 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") } for _, name := range []string{"internal/handler/demo/uploadManual.go", "internal/handler/demo/callbackManual.go"} { diff --git a/cmd/go-omni/apigen/compile.go b/cmd/go-omni/apigen/compile.go index 3101985..6547ed4 100644 --- a/cmd/go-omni/apigen/compile.go +++ b/cmd/go-omni/apigen/compile.go @@ -166,8 +166,8 @@ func Compile(spec *Spec) (*Compiled, error) { if !goName.MatchString(r.Handler) { return nil, fmt.Errorf("%s: invalid handler name", r.Position) } - if strings.EqualFold(r.Handler, "service") { - return nil, errors.New("handler name service conflicts with the service constructor file") + if strings.EqualFold(r.Handler, "service") || strings.EqualFold(r.Handler, "logic") { + return nil, errors.New("handler name conflicts with the Logic constructor file") } if r.Group == "" { 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 { 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") } if r.Method == "head" && r.Response != "" { @@ -245,7 +245,7 @@ func Compile(spec *Spec) (*Compiled, error) { op.Parameters = b.Parameters() op.Request = b.BodySchema() 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") { return nil, errors.New("GET/HEAD requests cannot declare JSON bodies; use form/path/header tags") diff --git a/cmd/go-omni/apigen/contractChanges.go b/cmd/go-omni/apigen/contractChanges.go new file mode 100644 index 0000000..95bbd73 --- /dev/null +++ b/cmd/go-omni/apigen/contractChanges.go @@ -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 +} diff --git a/cmd/go-omni/apigen/contractChanges_test.go b/cmd/go-omni/apigen/contractChanges_test.go new file mode 100644 index 0000000..aad5bcd --- /dev/null +++ b/cmd/go-omni/apigen/contractChanges_test.go @@ -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) + } +} diff --git a/cmd/go-omni/apigen/contractMetadata.go b/cmd/go-omni/apigen/contractMetadata.go index 377e418..8b0a051 100644 --- a/cmd/go-omni/apigen/contractMetadata.go +++ b/cmd/go-omni/apigen/contractMetadata.go @@ -120,6 +120,9 @@ func prepareMetadata(original *Spec) (*Spec, error) { } func decorateDocument(doc map[string]any, spec *Spec) { + if spec.DefaultResponse != "" { + doc["x-omni-default-response"] = spec.DefaultResponse + } if spec.Description != "" { doc["info"].(map[string]any)["description"] = spec.Description } diff --git a/cmd/go-omni/apigen/errorInheritance_test.go b/cmd/go-omni/apigen/errorInheritance_test.go index a1d8d91..533091d 100644 --- a/cmd/go-omni/apigen/errorInheritance_test.go +++ b/cmd/go-omni/apigen/errorInheritance_test.go @@ -4,9 +4,9 @@ import "testing" func TestErrorInheritanceConsumer(t *testing.T) { testCatalogConsumer(t, "errorInheritance.api", "errorInheritance", map[string]string{ - "internal/service/account/plain.go": "service.go", - "internal/service/account/raw.go": "emptyService.go", - "internal/service/account/cleared.go": "emptyService.go", + "internal/logic/account/plain.go": "service.go", + "internal/logic/account/raw.go": "emptyService.go", + "internal/logic/account/cleared.go": "emptyService.go", "internal/handler/account/boundedManual.go": "bounded.go", }) } diff --git a/cmd/go-omni/apigen/generate.go b/cmd/go-omni/apigen/generate.go index 5a7b39e..28430e5 100644 --- a/cmd/go-omni/apigen/generate.go +++ b/cmd/go-omni/apigen/generate.go @@ -19,7 +19,7 @@ import ( ) 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" type Options struct { @@ -35,6 +35,7 @@ type FileChange struct { type Output struct { Generated, Created, Preserved, Removed []string Changes []FileChange + Diagnostics []Diagnostic } // 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) { 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 } } } 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 != "" { @@ -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) { @@ -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 += "}\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" put(generated, "internal/handler/responseGen.go", generatedHeader+policySource) - imports := fmt.Sprintf("import (\"fmt\"; %q; %q; %q;", frameworkModule+"/httpx", frameworkModule+"/openapi", o.Module+"/internal/servicecontext") - routes := "func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) error {\n if router==nil || deps==nil {return fmt.Errorf(\"router and dependencies required\")}\n" + imports := fmt.Sprintf("import (\"fmt\"; %q; %q; %q;", frameworkModule+"/httpx", frameworkModule+"/openapi", o.Module+"/internal/svc") + 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 { imports += fmt.Sprintf("%q;%q;", frameworkModule+"/contract", o.Module+"/internal/types") 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) } 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) groupRoutes := []Route{} 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 != "" 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 { 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 += ")\n" - text += "type Service interface {\n" + text += "type Logic interface {\n" for _, r := range groupRoutes { if !r.Manual { 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 { name := upper(r.Handler) // 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" } - if r.Response != "" { - 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) + streamingBody := r.BodyMode == "raw" || r.BodyMode == "multipart" + 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] text += "return c." + method + "(resp)\n}\n" } 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") if r.Request != "" || r.Response != "" { service += fmt.Sprintf("%q;", o.Module+"/internal/types") } + if streamingBody || r.Produces != "" { + service += fmt.Sprintf("%q;", frameworkModule+"/httpx") + } 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" { 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}" } else { service += "// TODO: implement business behavior and use s.deps for dependencies.\n" - if r.Response != "" { + if r.Response != "" || r.Produces != "" { service += "return nil, " } else { service += "return " } 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 { 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) } 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(generated, "internal/handler/routesGen.go", generatedHeader+"package handler\n"+imports+")\n"+routes+"return nil\n}\n") @@ -515,7 +537,11 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map if name == "etc/config.yaml" && o.LogOutput == "file" { content = strings.Replace(content, "output: file", "output: stderr\n file: ./var/log/server.log\n rotation:\n enabled: true\n maxSizeMb: 100\n maxBackups: 10\n maxAgeDays: 7\n compress: true", 1) } - put(seeds, name, content) + if name == "main.go" { + put(generated, name, generatedHeader+content) + } else { + put(seeds, name, content) + } } infra, err := scaffold.APIInfrastructure(o.Module, o.Database, o.Storage, o.LogOutput, o.LogFormat) @@ -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") } } - 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 } func hasResponse(routes []Route) bool { for _, r := range routes { - if r.Response != "" && !r.Manual { + if r.Response != "" && !r.Manual && r.Produces == "" { return true } } @@ -552,7 +575,13 @@ func signature(r Route) string { if r.Request != "" { s += ",req *types." + r.Request } + if r.BodyMode == "raw" || r.BodyMode == "multipart" { + s += ",body *httpx.Body" + } s += ") " + if r.Produces != "" { + return s + "(*httpx.Stream,error)" + } if r.Response != "" { s += "(*types." + r.Response + ",error)" } else { diff --git a/cmd/go-omni/apigen/logicCheck.go b/cmd/go-omni/apigen/logicCheck.go new file mode 100644 index 0000000..51f5359 --- /dev/null +++ b/cmd/go-omni/apigen/logicCheck.go @@ -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 +} diff --git a/cmd/go-omni/apigen/managed_test.go b/cmd/go-omni/apigen/managed_test.go new file mode 100644 index 0000000..783f24d --- /dev/null +++ b/cmd/go-omni/apigen/managed_test.go @@ -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") + } + }) + } +} diff --git a/cmd/go-omni/apigen/metadata_test.go b/cmd/go-omni/apigen/metadata_test.go index 435714b..936127f 100644 --- a/cmd/go-omni/apigen/metadata_test.go +++ b/cmd/go-omni/apigen/metadata_test.go @@ -252,7 +252,7 @@ func TestDeclaredContractConsumer(t *testing.T) { } } -const declaredConsumerTest = `package dx_test +const declaredConsumerTest = `package main_test import ( "context";"errors";"io";"net/http/httptest";"strings";"testing" omni "git.yuntuo.hk/k2safe/go-omni" @@ -260,13 +260,13 @@ import ( "git.yuntuo.hk/k2safe/go-omni/logx" "example.com/dx/internal/apierrors" "example.com/dx/internal/handler" - "example.com/dx/internal/servicecontext" + "example.com/dx/internal/svc" ) func TestRuntimeAuthAndFreshErrors(t *testing.T){ 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")} 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)} 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")} diff --git a/cmd/go-omni/apigen/parse.go b/cmd/go-omni/apigen/parse.go index df5f563..61b526a 100644 --- a/cmd/go-omni/apigen/parse.go +++ b/cmd/go-omni/apigen/parse.go @@ -14,6 +14,8 @@ import ( ) type Spec struct { + DefaultResponse string + appDeclared bool Title, Version, Service, Description string Errors []ErrorDefinition ErrorCatalogs []string @@ -300,6 +302,26 @@ func (p *parser) parse() error { } case "@": 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 { return err } @@ -555,7 +577,7 @@ func (p *parser) parseService(server map[string]string) error { if p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" { 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 } p.spec.Routes = append(p.spec.Routes, route) diff --git a/cmd/go-omni/apigen/profile_test.go b/cmd/go-omni/apigen/profile_test.go index b096dcb..9b022c3 100644 --- a/cmd/go-omni/apigen/profile_test.go +++ b/cmd/go-omni/apigen/profile_test.go @@ -33,7 +33,7 @@ func TestInfrastructureProfileOwnership(t *testing.T) { } } // 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) } 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 { t.Fatal(err) } - test := `package settings + test := `package config import("testing";"os") func TestConfiguration(t *testing.T){ 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" { 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) defer cancel() cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-count=1", "./...") diff --git a/cmd/go-omni/apigen/projectEntry.go b/cmd/go-omni/apigen/projectEntry.go index 656a6a2..8711bec 100644 --- a/cmd/go-omni/apigen/projectEntry.go +++ b/cmd/go-omni/apigen/projectEntry.go @@ -25,7 +25,7 @@ func ProjectEntry(directory string) (string, error) { if err = json.Unmarshal(data, &m); err != nil { 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) } if err = safePath(root, m.Entry); err != nil { diff --git a/cmd/go-omni/apigen/requestOptions.go b/cmd/go-omni/apigen/requestOptions.go index 0109b75..5ed7e1d 100644 --- a/cmd/go-omni/apigen/requestOptions.go +++ b/cmd/go-omni/apigen/requestOptions.go @@ -36,9 +36,6 @@ func validateRouteExtensions(r Route) error { if r.BodyMode != "" && r.BodyMode != "json" && r.BodyMode != "raw" && r.BodyMode != "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 == "") { 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") } if r.Produces != "" { - if !r.Manual { - return errors.New("@produces requires @handlerMode manual") - } media, params, err := mime.ParseMediaType(r.Produces) if err != nil || len(params) != 0 || media != r.Produces || strings.Contains(media, "*") { return errors.New("produces requires a concrete media type without parameters") } 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 != "" { diff --git a/cmd/go-omni/apigen/response.go b/cmd/go-omni/apigen/response.go index 9811619..c5d8ff3 100644 --- a/cmd/go-omni/apigen/response.go +++ b/cmd/go-omni/apigen/response.go @@ -102,6 +102,18 @@ func validateResponses(spec *Spec) error { } 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 { if r.ResponseFormat != "" && r.ResponseFormat != "raw" && !seen[r.ResponseFormat] { return fmt.Errorf("%s: unknown response template %s", r.Position, r.ResponseFormat) diff --git a/cmd/go-omni/apigen/response_test.go b/cmd/go-omni/apigen/response_test.go index 6d23302..fc766dd 100644 --- a/cmd/go-omni/apigen/response_test.go +++ b/cmd/go-omni/apigen/response_test.go @@ -262,7 +262,7 @@ func TestResponseTemplateConsumer(t *testing.T) { if method == "Partner" { 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) 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 ( "bytes";"encoding/json";"io";"net/http/httptest";"testing" omni "git.yuntuo.hk/k2safe/go-omni" @@ -322,7 +322,7 @@ import ( item "example.com/responseConsumer/internal/handler/items" "example.com/responseConsumer/internal/types" "context" - "example.com/responseConsumer/internal/servicecontext" + "example.com/responseConsumer/internal/svc" ) type service struct{} 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){ app:=omni.New();defer app.Close() cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close() - deps,err:=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)}} if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)} // Registered routes use the compiled policy before even running authentication. diff --git a/cmd/go-omni/apigen/startup.go b/cmd/go-omni/apigen/startup.go new file mode 100644 index 0000000..6d5df9c --- /dev/null +++ b/cmd/go-omni/apigen/startup.go @@ -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 +} diff --git a/cmd/go-omni/apigen/templates.go b/cmd/go-omni/apigen/templates.go index 15443c0..033be81 100644 --- a/cmd/go-omni/apigen/templates.go +++ b/cmd/go-omni/apigen/templates.go @@ -3,7 +3,7 @@ package apigen import "fmt" var baseTemplates = map[string]string{ - "cmd/server/main.go": `package main + "main.go": `package main import ( "context" "errors" @@ -12,35 +12,31 @@ import ( "os" "os/signal" "syscall" - omni "git.yuntuo.hk/k2safe/go-omni" "git.yuntuo.hk/k2safe/go-omni/logx" + "MODULE/internal/config" "MODULE/internal/handler" - "MODULE/internal/servicecontext" - "MODULE/internal/settings" + "MODULE/internal/svc" ) func main(){ path:=flag.String("config","etc/config.yaml","configuration file; empty uses defaults and environment") 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) 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} defer func(){runErr=errors.Join(runErr,logger.Close())}() 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())}() - app:=omni.New(omni.WithConfig(cfg.Server)) - app.OnShutdown(logger.Close) - app.OnShutdown(deps.Close) - if err=handler.RegisterRoutes(app,deps);err!=nil{return err} - 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) + 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) } `, ".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 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 ./cmd/server +go run . `+"```"+` 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. --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, main, -Service, Repository, servicecontext, middleware hook bodies and API files are -created once and then preserved byte for byte. Gen.go files and OpenAPI are owned +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. @@ -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 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 `+"```text"+` 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 -internal/settings/ # defaults -> file -> environment -internal/servicecontext/ # dependency initialization and cleanup +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// # generated binding and service adapters -internal/service// # developer-owned business operations +internal/handler// # generated binding and Logic adapters +internal/logic// # developer-owned business operations internal/repository// # developer-owned persistence contracts internal/middleware/ # named hooks and generated registry 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 -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 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. -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. 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 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. 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 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. 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 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. `, module, entry) } diff --git a/cmd/go-omni/apigen/testdata/compatibility/consumer_test.go b/cmd/go-omni/apigen/testdata/compatibility/consumer_test.go index 273c752..582c879 100644 --- a/cmd/go-omni/apigen/testdata/compatibility/consumer_test.go +++ b/cmd/go-omni/apigen/testdata/compatibility/consumer_test.go @@ -1,4 +1,4 @@ -package compatibility_test +package main_test import ( "bytes" @@ -8,7 +8,7 @@ import ( "encoding/hex" "encoding/json" "example.com/compatibility/internal/handler" - "example.com/compatibility/internal/servicecontext" + "example.com/compatibility/internal/svc" omni "git.yuntuo.hk/k2safe/go-omni" "git.yuntuo.hk/k2safe/go-omni/logx" "io" @@ -25,7 +25,7 @@ func TestGeneratedCompatibility(t *testing.T) { t.Fatal(err) } defer logger.Close() - deps, err := servicecontext.New(context.Background(), logger) + deps, err := svc.New(context.Background(), logger) if err != nil { t.Fatal(err) } diff --git a/cmd/go-omni/apigen/testdata/compatibility/echo.go b/cmd/go-omni/apigen/testdata/compatibility/echo.go index 0f2944e..22c7b15 100644 --- a/cmd/go-omni/apigen/testdata/compatibility/echo.go +++ b/cmd/go-omni/apigen/testdata/compatibility/echo.go @@ -5,12 +5,12 @@ import ( "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 } -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 } -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 } diff --git a/cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go b/cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go index 7d67ad1..893cf8f 100644 --- a/cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go +++ b/cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go @@ -1,4 +1,4 @@ -package consumer_test +package main_test import ( "context" @@ -10,7 +10,7 @@ import ( "example.com/errorCatalog/internal/apierrors" "example.com/errorCatalog/internal/handler" - "example.com/errorCatalog/internal/servicecontext" + "example.com/errorCatalog/internal/svc" omni "git.yuntuo.hk/k2safe/go-omni" omnierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" @@ -35,7 +35,7 @@ func TestGeneratedCatalog(t *testing.T) { t.Fatal(err) } defer logger.Close() - deps, err := servicecontext.New(context.Background(), logger) + deps, err := svc.New(context.Background(), logger) if err != nil { t.Fatal(err) } diff --git a/cmd/go-omni/apigen/testdata/errorInheritance/consumer_test.go b/cmd/go-omni/apigen/testdata/errorInheritance/consumer_test.go index d056a2c..7fc1b20 100644 --- a/cmd/go-omni/apigen/testdata/errorInheritance/consumer_test.go +++ b/cmd/go-omni/apigen/testdata/errorInheritance/consumer_test.go @@ -1,4 +1,4 @@ -package consumer_test +package main_test import ( "context" @@ -9,7 +9,7 @@ import ( "testing" "example.com/errorCatalog/internal/handler" - "example.com/errorCatalog/internal/servicecontext" + "example.com/errorCatalog/internal/svc" omni "git.yuntuo.hk/k2safe/go-omni" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" @@ -31,7 +31,7 @@ func TestGoOwnedCatalogOnGeneratedRoutes(t *testing.T) { t.Fatal(err) } defer logger.Close() - deps, err := servicecontext.New(context.Background(), logger) + deps, err := svc.New(context.Background(), logger) if err != nil { t.Fatal(err) } diff --git a/cmd/go-omni/apigen/testdata/errorInheritance/service.go b/cmd/go-omni/apigen/testdata/errorInheritance/service.go index 79da7c1..02f4b90 100644 --- a/cmd/go-omni/apigen/testdata/errorInheritance/service.go +++ b/cmd/go-omni/apigen/testdata/errorInheritance/service.go @@ -18,12 +18,12 @@ func check(kind string) (*types.CheckResponse, error) { 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) } -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) } -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) } diff --git a/cmd/go-omni/apigen/testdata/errorParams/consumer_test.go b/cmd/go-omni/apigen/testdata/errorParams/consumer_test.go index f41a786..c5d4a39 100644 --- a/cmd/go-omni/apigen/testdata/errorParams/consumer_test.go +++ b/cmd/go-omni/apigen/testdata/errorParams/consumer_test.go @@ -1,4 +1,4 @@ -package consumer_test +package main_test import ( "context" @@ -12,7 +12,7 @@ import ( "example.com/errorCatalog/internal/apierrors" "example.com/errorCatalog/internal/handler" - "example.com/errorCatalog/internal/servicecontext" + "example.com/errorCatalog/internal/svc" omni "git.yuntuo.hk/k2safe/go-omni" omnierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" @@ -87,7 +87,7 @@ func TestGeneratedTemplateErrors(t *testing.T) { t.Fatal(err) } defer logger.Close() - deps, err := servicecontext.New(context.Background(), logger) + deps, err := svc.New(context.Background(), logger) if err != nil { t.Fatal(err) } diff --git a/cmd/go-omni/apigen/testdata/managed/client.go b/cmd/go-omni/apigen/testdata/managed/client.go new file mode 100644 index 0000000..ae1fba5 --- /dev/null +++ b/cmd/go-omni/apigen/testdata/managed/client.go @@ -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() +} diff --git a/cmd/go-omni/apigen/testdata/managed/consumer_test.go b/cmd/go-omni/apigen/testdata/managed/consumer_test.go new file mode 100644 index 0000000..c54b32c --- /dev/null +++ b/cmd/go-omni/apigen/testdata/managed/consumer_test.go @@ -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()) + } + }) +} diff --git a/cmd/go-omni/apigen/testdata/managed/operations.go b/cmd/go-omni/apigen/testdata/managed/operations.go new file mode 100644 index 0000000..eead7c3 --- /dev/null +++ b/cmd/go-omni/apigen/testdata/managed/operations.go @@ -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 +} diff --git a/cmd/go-omni/apigen/testdata/managed/options.go b/cmd/go-omni/apigen/testdata/managed/options.go new file mode 100644 index 0000000..1200d16 --- /dev/null +++ b/cmd/go-omni/apigen/testdata/managed/options.go @@ -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) + } + }, + } +} diff --git a/cmd/go-omni/apigen/write.go b/cmd/go-omni/apigen/write.go index e706d14..2237a54 100644 --- a/cmd/go-omni/apigen/write.go +++ b/cmd/go-omni/apigen/write.go @@ -21,6 +21,7 @@ type manifest struct { Module string `json:"module"` Entry string `json:"entry"` Generated map[string]string `json:"generated"` + Contract *contractSnapshot `json:"contract,omitempty"` } type change struct { name string @@ -29,7 +30,7 @@ type change struct { 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 if info, err := os.Lstat(root); err == nil { 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 { 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") } } 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 } } - 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 { 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}) } + 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, "", " ") if err != nil { return result, err @@ -202,15 +217,19 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check return result, nil } if check { + var checkErr error if len(changes) > 0 { var stale []string for _, c := range changes { stale = append(stale, c.name) } 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. // 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 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 } 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" { diff --git a/cmd/go-omni/main.go b/cmd/go-omni/main.go index 1240b9f..a2c605a 100644 --- a/cmd/go-omni/main.go +++ b/cmd/go-omni/main.go @@ -142,26 +142,22 @@ func runNew(args []string) error { options.Module = filepath.Base(filepath.Clean(target)) } options.Dir = target + if options.Force { + return errors.New("managed projects do not accept --force; use api generate to update owned files") + } + o := apigen.Options{API: apiFile, Dir: target, Module: options.Module, FrameworkDir: options.FrameworkDir, FrameworkVersion: options.FrameworkVersion, LogOutput: options.LogOutput, LogFormat: options.LogFormat, Database: options.Database, Storage: options.Storage} + var err error + var out apigen.Output if apiFile != "" { - if options.Force { - return errors.New("--api protects existing business code and does not accept --force") - } - _, err := apigen.Generate(apigen.Options{API: apiFile, Dir: target, Module: options.Module, FrameworkDir: options.FrameworkDir, FrameworkVersion: options.FrameworkVersion, LogOutput: options.LogOutput, LogFormat: options.LogFormat, Database: options.Database, Storage: options.Storage}) - if err != nil { - return err - } - fmt.Printf("created API project %s; implement internal/service operations\n", filepath.Clean(target)) - return nil + out, err = apigen.Generate(o) + } else { + out, err = apigen.New(o) } - 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) { + printAPIDiagnostics(out.Diagnostics) + if err != nil { return err } - if err := scaffold.Generate(options); err != nil { - return err - } - fmt.Printf("created GoOmni project %s\n", filepath.Clean(target)) + fmt.Printf("created API project %s; implement internal/logic operations; run with go run .\n", filepath.Clean(target)) return nil } @@ -170,7 +166,7 @@ func usage() { fmt.Fprintln(os.Stderr, "") 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 new [--module ] [--force] ") + fmt.Fprintln(os.Stderr, " go-omni new [--module ] ") fmt.Fprintln(os.Stderr, " go-omni new --api [--module ] ") fmt.Fprintln(os.Stderr, " go-omni api new [--module ] Create a runnable API project") fmt.Fprintln(os.Stderr, " go-omni api generate [--api ] [--dir ] [--check|--dry-run]") @@ -180,7 +176,7 @@ func usage() { fmt.Fprintln(os.Stderr, " go-omni add [--dir ] Generate an isolated business module") fmt.Fprintln(os.Stderr, " --framework-version Pin a remote framework version (defaults to CLI version)") fmt.Fprintln(os.Stderr, " --framework-dir Use a local framework checkout") - fmt.Fprintln(os.Stderr, " --database API defaults to none; CRUD defaults to memory") + fmt.Fprintln(os.Stderr, " --database Defaults to none") fmt.Fprintln(os.Stderr, " --log-output Generate configured logging (default stderr)") fmt.Fprintln(os.Stderr, " --log-format Select structured log format (default json)") fmt.Fprintln(os.Stderr, " --storage Select optional storage adapter (default none)") @@ -207,6 +203,9 @@ func runAdd(args []string) error { 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 { return err } diff --git a/cmd/go-omni/main_test.go b/cmd/go-omni/main_test.go index 9dafdcd..67932cc 100644 --- a/cmd/go-omni/main_test.go +++ b/cmd/go-omni/main_test.go @@ -8,7 +8,7 @@ import ( ) 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") args := append([]string{"--module", "example.com/billing"}, flag...) if err := runNew(append(args, project)); err != nil { @@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(content), "go-omni v0.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) } } @@ -42,11 +42,8 @@ func TestAddModuleCLI(t *testing.T) { if equals { args = []string{"--dir=" + project, "orders"} } - if err := runAdd(args); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Join(project, "internal/orders/module.go")); err != nil { - t.Fatal(err) + if err := runAdd(args); err == nil || !strings.Contains(err.Error(), ".api imports") { + t.Fatalf("expected contract-driven module guidance: %v", err) } } 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 { 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) } } diff --git a/cmd/go-omni/scaffold/apiInfrastructure.go b/cmd/go-omni/scaffold/apiInfrastructure.go index e4d63f3..ca119f6 100644 --- a/cmd/go-omni/scaffold/apiInfrastructure.go +++ b/cmd/go-omni/scaffold/apiInfrastructure.go @@ -10,12 +10,14 @@ import ( 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} files := map[string]string{ - "internal/settings/config.go": "templates/modules-settings.go.tmpl", - "etc/config.yaml": "templates/modules-config.yaml.tmpl", - "internal/servicecontext/context.go": "templates/api-context.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", + "internal/svc/serviceContext.go": "templates/api-context.go.tmpl", } 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)) for name, source := range files { @@ -23,6 +25,15 @@ func APIInfrastructure(module, database, storage, logOutput, logFormat string) ( if err != nil { 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") } return result, nil diff --git a/cmd/go-omni/scaffold/templates/api-close.go.tmpl b/cmd/go-omni/scaffold/templates/api-close.go.tmpl new file mode 100644 index 0000000..9f15f39 --- /dev/null +++ b/cmd/go-omni/scaffold/templates/api-close.go.tmpl @@ -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) +} diff --git a/cmd/go-omni/scaffold/templates/api-context.go.tmpl b/cmd/go-omni/scaffold/templates/api-context.go.tmpl index 354e3e1..8f5d754 100644 --- a/cmd/go-omni/scaffold/templates/api-context.go.tmpl +++ b/cmd/go-omni/scaffold/templates/api-context.go.tmpl @@ -17,6 +17,8 @@ import ( // Context owns infrastructure, while Logger is borrowed from main. // Add business repositories here; HTTP contracts do not define database tables. type Context struct { + Config settings.Config + cleanups []func()error Logger *logx.Logger Health *health.Checker 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 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())}}() {{if eq .Database "mysql"}} 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}} 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 -} diff --git a/cmd/go-omni/scaffold/templates/api-options.go.tmpl b/cmd/go-omni/scaffold/templates/api-options.go.tmpl new file mode 100644 index 0000000..3e5f8c0 --- /dev/null +++ b/cmd/go-omni/scaffold/templates/api-options.go.tmpl @@ -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} diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go index 78d0ae4..32c1da8 100644 --- a/cmd/go-omni/scaffold/version.go +++ b/cmd/go-omni/scaffold/version.go @@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" // Source builds can carry an unpublished VCS version (including +dirty). // Keep this fallback in sync with the next release; installed CLIs use their // downloaded module version, which also has a module checksum. -const defaultFrameworkVersion = "v0.11.1-preview" +const defaultFrameworkVersion = "v0.12.0-preview" // This target is not a claim that the release already exists. Source users // select a local framework checkout until the module templates are published. diff --git a/config/config.go b/config/config.go index ec60585..40d2822 100644 --- a/config/config.go +++ b/config/config.go @@ -56,14 +56,18 @@ func (c Config) Validate() error { if err := c.Errors.Validate(); err != nil { return err } - if c.ResponseEncoder == nil { + encoder := c.ResponseEncoder + if encoder == nil { f, err := response.New(c.Response) if err != nil { return err } - if err = f.ValidateErrors(c.Errors); err != nil { - return err - } + encoder = f + } + // 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 { return err diff --git a/config/response_test.go b/config/response_test.go new file mode 100644 index 0000000..c92e509 --- /dev/null +++ b/config/response_test.go @@ -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) + } + }) + } +} diff --git a/examples/contracts/managed.api b/examples/contracts/managed.api new file mode 100644 index 0000000..2aac844 --- /dev/null +++ b/examples/contracts/managed.api @@ -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) +} diff --git a/examples/managedService/.gitignore b/examples/managedService/.gitignore new file mode 100644 index 0000000..690bd48 --- /dev/null +++ b/examples/managedService/.gitignore @@ -0,0 +1,8 @@ +/server +/var/ +.env +.env.* +!.env.example +/.goOmni/api.lock + +/managed diff --git a/examples/managedService/.goOmni/apiManifest.json b/examples/managedService/.goOmni/apiManifest.json new file mode 100644 index 0000000..05215c3 --- /dev/null +++ b/examples/managedService/.goOmni/apiManifest.json @@ -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\"" + } + ] + } + } + } + } + } +} diff --git a/examples/managedService/README.md b/examples/managedService/README.md new file mode 100644 index 0000000..65d7fb6 --- /dev/null +++ b/examples/managedService/README.md @@ -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// # generated binding and Logic adapters +internal/logic// # developer-owned business operations +internal/repository// # 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. diff --git a/examples/managedService/api/managed.api b/examples/managedService/api/managed.api new file mode 100644 index 0000000..2aac844 --- /dev/null +++ b/examples/managedService/api/managed.api @@ -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) +} diff --git a/examples/managedService/docs/openapi.json b/examples/managedService/docs/openapi.json new file mode 100644 index 0000000..50ab25b --- /dev/null +++ b/examples/managedService/docs/openapi.json @@ -0,0 +1,1422 @@ +{ + "components": { + "securitySchemes": {} + }, + "info": { + "title": "managed-api", + "version": "1.0.0" + }, + "openapi": "3.0.3", + "paths": { + "/callback": { + "post": { + "operationId": "demo_Callback", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "413": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "415": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "501": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + } + }, + "security": [], + "summary": "", + "tags": [ + "demo" + ], + "x-omni-max-body-bytes": 64, + "x-omni-response": "CommonResponse" + } + }, + "/check": { + "get": { + "operationId": "demo_Check", + "parameters": [ + { + "in": "query", + "name": "kind", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "additionalProperties": false, + "properties": { + "bytes": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "bytes" + ], + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "413": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "415": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "501": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + } + }, + "security": [], + "summary": "", + "tags": [ + "demo" + ], + "x-omni-response": "CommonResponse" + } + }, + "/download": { + "get": { + "operationId": "demo_Download", + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "413": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "415": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "501": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + } + }, + "security": [], + "summary": "", + "tags": [ + "demo" + ], + "x-omni-response": "CommonResponse" + } + }, + "/other": { + "get": { + "operationId": "demo_Other", + "parameters": [ + { + "in": "query", + "name": "kind", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "additionalProperties": false, + "properties": { + "bytes": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "bytes" + ], + "type": "object" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "data" + ], + "type": "object" + } + } + }, + "description": "Success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "413": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "415": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "501": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "code", + "detail", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + } + }, + "security": [], + "summary": "", + "tags": [ + "demo" + ], + "x-omni-response": "AlternateResponse" + } + }, + "/raw": { + "get": { + "operationId": "demo_RawJSON", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "bytes": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "bytes" + ], + "type": "object" + } + } + }, + "description": "Success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "413": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "415": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "501": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + } + }, + "security": [], + "summary": "", + "tags": [ + "demo" + ], + "x-omni-response": "raw" + } + }, + "/slow": { + "get": { + "operationId": "demo_Slow", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "additionalProperties": false, + "properties": { + "bytes": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "bytes" + ], + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "413": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "415": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "501": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + } + }, + "security": [], + "summary": "", + "tags": [ + "demo" + ], + "x-omni-response": "CommonResponse" + } + }, + "/upload": { + "post": { + "operationId": "demo_Upload", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "additionalProperties": false, + "properties": { + "bytes": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "bytes" + ], + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "413": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "415": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + }, + "501": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + }, + "msg": { + "type": "string" + } + }, + "required": [ + "code", + "msg", + "data" + ], + "type": "object" + } + } + }, + "description": "Error" + } + }, + "security": [], + "summary": "", + "tags": [ + "demo" + ], + "x-omni-max-body-bytes": 2048, + "x-omni-response": "CommonResponse" + } + } + }, + "x-omni-default-response": "CommonResponse" +} diff --git a/examples/managedService/etc/config.yaml b/examples/managedService/etc/config.yaml new file mode 100644 index 0000000..bb3fba0 --- /dev/null +++ b/examples/managedService/etc/config.yaml @@ -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: [] diff --git a/examples/managedService/go.mod b/examples/managedService/go.mod new file mode 100644 index 0000000..58e4002 --- /dev/null +++ b/examples/managedService/go.mod @@ -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 => ../.. diff --git a/examples/managedService/go.sum b/examples/managedService/go.sum new file mode 100644 index 0000000..4c88df8 --- /dev/null +++ b/examples/managedService/go.sum @@ -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= diff --git a/examples/managedService/internal/apierrors/catalog.go b/examples/managedService/internal/apierrors/catalog.go new file mode 100644 index 0000000..5d2889f --- /dev/null +++ b/examples/managedService/internal/apierrors/catalog.go @@ -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: "上传内容不完整"}, + }, + } +} diff --git a/examples/managedService/internal/config/config.go b/examples/managedService/internal/config/config.go new file mode 100644 index 0000000..5e83f81 --- /dev/null +++ b/examples/managedService/internal/config/config.go @@ -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 +} diff --git a/examples/managedService/internal/handler/appGen.go b/examples/managedService/internal/handler/appGen.go new file mode 100644 index 0000000..7cf1f92 --- /dev/null +++ b/examples/managedService/internal/handler/appGen.go @@ -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 +} diff --git a/examples/managedService/internal/handler/demo/handlersGen.go b/examples/managedService/internal/handler/demo/handlersGen.go new file mode 100644 index 0000000..0534401 --- /dev/null +++ b/examples/managedService/internal/handler/demo/handlersGen.go @@ -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) +} diff --git a/examples/managedService/internal/handler/openapiGen.go b/examples/managedService/internal/handler/openapiGen.go new file mode 100644 index 0000000..bbc21c0 --- /dev/null +++ b/examples/managedService/internal/handler/openapiGen.go @@ -0,0 +1,13 @@ +// Code generated by go-omni api. DO NOT EDIT. +package handler + +import ( + "git.yuntuo.hk/k2safe/go-omni/httpx" + "net/http" +) + +const OpenAPIDocument = "{\n \"components\": {\n \"securitySchemes\": {}\n },\n \"info\": {\n \"title\": \"managed-api\",\n \"version\": \"1.0.0\"\n },\n \"openapi\": \"3.0.3\",\n \"paths\": {\n \"/callback\": {\n \"post\": {\n \"operationId\": \"demo_Callback\",\n \"requestBody\": {\n \"content\": {\n \"application/octet-stream\": {\n \"schema\": {\n \"format\": \"binary\",\n \"type\": \"string\"\n }\n }\n },\n \"required\": true\n },\n \"responses\": {\n \"200\": {\n \"content\": {\n \"text/plain\": {\n \"schema\": {\n \"type\": \"string\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"413\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"415\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"500\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"501\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-max-body-bytes\": 64,\n \"x-omni-response\": \"CommonResponse\"\n }\n },\n \"/check\": {\n \"get\": {\n \"operationId\": \"demo_Check\",\n \"parameters\": [\n {\n \"in\": \"query\",\n \"name\": \"kind\",\n \"required\": false,\n \"schema\": {\n \"type\": \"string\"\n }\n }\n ],\n \"responses\": {\n \"200\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"additionalProperties\": false,\n \"properties\": {\n \"bytes\": {\n \"format\": \"int64\",\n \"type\": \"integer\"\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"413\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"415\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"500\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"501\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-response\": \"CommonResponse\"\n }\n },\n \"/download\": {\n \"get\": {\n \"operationId\": \"demo_Download\",\n \"responses\": {\n \"200\": {\n \"content\": {\n \"application/octet-stream\": {\n \"schema\": {\n \"format\": \"binary\",\n \"type\": \"string\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"413\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"415\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"500\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"501\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-response\": \"CommonResponse\"\n }\n },\n \"/other\": {\n \"get\": {\n \"operationId\": \"demo_Other\",\n \"parameters\": [\n {\n \"in\": \"query\",\n \"name\": \"kind\",\n \"required\": false,\n \"schema\": {\n \"type\": \"string\"\n }\n }\n ],\n \"responses\": {\n \"200\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"additionalProperties\": false,\n \"properties\": {\n \"bytes\": {\n \"format\": \"int64\",\n \"type\": \"integer\"\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"type\": \"object\"\n },\n \"detail\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"detail\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"detail\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"detail\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"413\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"detail\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"detail\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"415\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"detail\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"detail\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"500\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"detail\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"detail\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"501\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"detail\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"detail\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-response\": \"AlternateResponse\"\n }\n },\n \"/raw\": {\n \"get\": {\n \"operationId\": \"demo_RawJSON\",\n \"responses\": {\n \"200\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"additionalProperties\": false,\n \"properties\": {\n \"bytes\": {\n \"format\": \"int64\",\n \"type\": \"integer\"\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"413\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"415\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"500\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"501\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-response\": \"raw\"\n }\n },\n \"/slow\": {\n \"get\": {\n \"operationId\": \"demo_Slow\",\n \"responses\": {\n \"200\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"additionalProperties\": false,\n \"properties\": {\n \"bytes\": {\n \"format\": \"int64\",\n \"type\": \"integer\"\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"413\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"415\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"500\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"501\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-response\": \"CommonResponse\"\n }\n },\n \"/upload\": {\n \"post\": {\n \"operationId\": \"demo_Upload\",\n \"requestBody\": {\n \"content\": {\n \"multipart/form-data\": {\n \"schema\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n }\n }\n },\n \"required\": true\n },\n \"responses\": {\n \"200\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"additionalProperties\": false,\n \"properties\": {\n \"bytes\": {\n \"format\": \"int64\",\n \"type\": \"integer\"\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Success\"\n },\n \"400\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"413\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"415\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"500\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n },\n \"501\": {\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"properties\": {\n \"code\": {\n \"format\": \"int32\",\n \"type\": \"integer\"\n },\n \"data\": {\n \"enum\": [\n null\n ],\n \"nullable\": true,\n \"type\": \"object\"\n },\n \"msg\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"msg\",\n \"data\"\n ],\n \"type\": \"object\"\n }\n }\n },\n \"description\": \"Error\"\n }\n },\n \"security\": [],\n \"summary\": \"\",\n \"tags\": [\n \"demo\"\n ],\n \"x-omni-max-body-bytes\": 2048,\n \"x-omni-response\": \"CommonResponse\"\n }\n }\n },\n \"x-omni-default-response\": \"CommonResponse\"\n}\n" + +func OpenAPI(c *httpx.Context) error { + return c.WriteResponse(200, http.Header{"Content-Type": {"application/json; charset=utf-8"}}, []byte(OpenAPIDocument)) +} diff --git a/examples/managedService/internal/handler/responseGen.go b/examples/managedService/internal/handler/responseGen.go new file mode 100644 index 0000000..0dbbfa3 --- /dev/null +++ b/examples/managedService/internal/handler/responseGen.go @@ -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 +} diff --git a/examples/managedService/internal/handler/routesGen.go b/examples/managedService/internal/handler/routesGen.go new file mode 100644 index 0000000..b66a790 --- /dev/null +++ b/examples/managedService/internal/handler/routesGen.go @@ -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 +} diff --git a/examples/managedService/internal/logic/demo/callback.go b/examples/managedService/internal/logic/demo/callback.go new file mode 100644 index 0000000..82d6386 --- /dev/null +++ b/examples/managedService/internal/logic/demo/callback.go @@ -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 +} diff --git a/examples/managedService/internal/logic/demo/check.go b/examples/managedService/internal/logic/demo/check.go new file mode 100644 index 0000000..a627e69 --- /dev/null +++ b/examples/managedService/internal/logic/demo/check.go @@ -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 +} diff --git a/examples/managedService/internal/logic/demo/download.go b/examples/managedService/internal/logic/demo/download.go new file mode 100644 index 0000000..0e48c63 --- /dev/null +++ b/examples/managedService/internal/logic/demo/download.go @@ -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 +} diff --git a/examples/managedService/internal/logic/demo/logic.go b/examples/managedService/internal/logic/demo/logic.go new file mode 100644 index 0000000..39d34ae --- /dev/null +++ b/examples/managedService/internal/logic/demo/logic.go @@ -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} } diff --git a/examples/managedService/internal/logic/demo/other.go b/examples/managedService/internal/logic/demo/other.go new file mode 100644 index 0000000..ad12710 --- /dev/null +++ b/examples/managedService/internal/logic/demo/other.go @@ -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) +} diff --git a/examples/managedService/internal/logic/demo/rawJSON.go b/examples/managedService/internal/logic/demo/rawJSON.go new file mode 100644 index 0000000..995d444 --- /dev/null +++ b/examples/managedService/internal/logic/demo/rawJSON.go @@ -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 +} diff --git a/examples/managedService/internal/logic/demo/slow.go b/examples/managedService/internal/logic/demo/slow.go new file mode 100644 index 0000000..24b2bb5 --- /dev/null +++ b/examples/managedService/internal/logic/demo/slow.go @@ -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 + } +} diff --git a/examples/managedService/internal/logic/demo/upload.go b/examples/managedService/internal/logic/demo/upload.go new file mode 100644 index 0000000..1ffbdf2 --- /dev/null +++ b/examples/managedService/internal/logic/demo/upload.go @@ -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 +} diff --git a/examples/managedService/internal/middleware/registryGen.go b/examples/managedService/internal/middleware/registryGen.go new file mode 100644 index 0000000..30ab155 --- /dev/null +++ b/examples/managedService/internal/middleware/registryGen.go @@ -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{} +} diff --git a/examples/managedService/internal/repository/demo/repository.go b/examples/managedService/internal/repository/demo/repository.go new file mode 100644 index 0000000..6639d12 --- /dev/null +++ b/examples/managedService/internal/repository/demo/repository.go @@ -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 diff --git a/examples/managedService/internal/svc/close.go b/examples/managedService/internal/svc/close.go new file mode 100644 index 0000000..145ce66 --- /dev/null +++ b/examples/managedService/internal/svc/close.go @@ -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) +} diff --git a/examples/managedService/internal/svc/options.go b/examples/managedService/internal/svc/options.go new file mode 100644 index 0000000..1f303fb --- /dev/null +++ b/examples/managedService/internal/svc/options.go @@ -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) + } + }, + } +} diff --git a/examples/managedService/internal/svc/serviceContext.go b/examples/managedService/internal/svc/serviceContext.go new file mode 100644 index 0000000..2716452 --- /dev/null +++ b/examples/managedService/internal/svc/serviceContext.go @@ -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 +} diff --git a/examples/managedService/internal/types/typesGen.go b/examples/managedService/internal/types/typesGen.go new file mode 100644 index 0000000..3ed3c05 --- /dev/null +++ b/examples/managedService/internal/types/typesGen.go @@ -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"` +} diff --git a/examples/managedService/main.go b/examples/managedService/main.go new file mode 100644 index 0000000..1a61113 --- /dev/null +++ b/examples/managedService/main.go @@ -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) +} diff --git a/httpx/multipart.go b/httpx/multipart.go new file mode 100644 index 0000000..a0793e6 --- /dev/null +++ b/httpx/multipart.go @@ -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 + } + } +} diff --git a/httpx/transfer.go b/httpx/transfer.go new file mode 100644 index 0000000..976e7ab --- /dev/null +++ b/httpx/transfer.go @@ -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 +} diff --git a/httpx/transfer_test.go b/httpx/transfer_test.go new file mode 100644 index 0000000..67cb03d --- /dev/null +++ b/httpx/transfer_test.go @@ -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) + } + }) + } +} diff --git a/performance/managedProjectAcceptance.md b/performance/managedProjectAcceptance.md new file mode 100644 index 0000000..cc9f8ba --- /dev/null +++ b/performance/managedProjectAcceptance.md @@ -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)。 diff --git a/performance/managedProjectLocal.json b/performance/managedProjectLocal.json new file mode 100644 index 0000000..96badf0 --- /dev/null +++ b/performance/managedProjectLocal.json @@ -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" + } +} diff --git a/scripts/verifyManagedProject.py b/scripts/verifyManagedProject.py new file mode 100644 index 0000000..e9d6714 --- /dev/null +++ b/scripts/verifyManagedProject.py @@ -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() diff --git a/scripts/verifyManagedRelease.py b/scripts/verifyManagedRelease.py new file mode 100644 index 0000000..b05e2f1 --- /dev/null +++ b/scripts/verifyManagedRelease.py @@ -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()