feat: release managed service generation and contract diagnostics

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

View File

@@ -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 <file>` 是契约项目的另一个入口。可以组合 `--database mysql --storage local --log-output file`,生成配置、依赖初始化与资源关闭。API 默认无数据库、无对象存储;`--database memory` 等同于不打开连接。`--force` 不允许覆盖 API 项目。初始配置选择写入 manifest,重新生成自动沿用。
`new` 与 `api new` 统一生成契约项目;不再提供另一套 CRUD 启动目录。未实现的 Logic / 中间件骨架返回 501。进入项目执行 `go mod tidy`、`go run .`、`go build .`。
后续在生成项目内执行 `go-omni api generate --api api/user.api --dir .`。
项目内可省略 `--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)。

View File

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

View File

@@ -32,7 +32,7 @@ func TestAPICommands(t *testing.T) {
if err = runNew([]string{"--api", api, "--module", "example.com/other", "--framework-dir", root, target}); err != nil {
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)
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,244 @@
package apigen
import (
"fmt"
"go/ast"
goparser "go/parser"
"reflect"
"sort"
"strings"
)
// Diagnostic points to preserved project code that needs attention. Review
// notices describe contract changes; errors describe definite source problems.
type Diagnostic struct {
Severity, Code, Path, Operation, Message string
Line int
Before, After string
Details []string
}
// Snapshots describe the last successfully generated contract, never business
// source or absolute paths. They are persisted in the ownership transaction.
type contractSnapshot struct {
Routes map[string]routeContract `json:"routes"`
}
type routeContract struct {
Method string `json:"method"`
Path string `json:"path"`
Group string `json:"group"`
Handler string `json:"handler"`
Signature string `json:"signature"`
Request string `json:"request,omitempty"`
Response string `json:"response,omitempty"`
BodyMode string `json:"bodyMode,omitempty"`
Produces string `json:"produces,omitempty"`
MaxBodyBytes int64 `json:"maxBodyBytes,omitempty"`
Manual bool `json:"manual,omitempty"`
CustomBinding bool `json:"customBinding,omitempty"`
Types map[string]typeContract `json:"types,omitempty"`
}
type typeContract struct {
Fields []contractField `json:"fields,omitempty"`
Scalar string `json:"scalar,omitempty"`
}
type contractField struct {
Name string `json:"name"`
Type string `json:"type"`
Tag string `json:"tag"`
}
func snapshotContract(spec *Spec) *contractSnapshot {
definitions := map[string]typeContract{}
for _, t := range spec.Types {
d := typeContract{}
for _, f := range t.Fields {
d.Fields = append(d.Fields, contractField{f.Name, f.Type, f.Tag})
}
definitions[t.Name] = d
}
for _, s := range spec.Scalars {
definitions[s.Name] = typeContract{Scalar: fmt.Sprintf("%s.%s (jsonInput=%s, jsonOutput=%s, text=%t)", s.GoImport, s.GoType, s.JSONInput, s.JSONOutput, s.Text)}
}
snapshot := &contractSnapshot{Routes: map[string]routeContract{}}
for _, r := range spec.Routes {
dependencies := map[string]typeContract{}
var visit func(string)
visit = func(name string) {
d, ok := definitions[name]
if !ok {
return
}
if _, exists := dependencies[name]; exists {
return
}
dependencies[name] = d
for _, f := range d.Fields {
expr, err := goparser.ParseExpr(f.Type)
if err != nil {
continue
} // Compile already validates expressions.
ast.Inspect(expr, func(n ast.Node) bool {
if id, ok := n.(*ast.Ident); ok {
visit(id.Name)
}
return true
})
}
}
visit(r.Request)
visit(r.Response)
op := routeContract{
Method: strings.ToUpper(r.Method), Path: r.Path, Group: r.Group, Handler: r.Handler,
Request: r.Request, Response: r.Response, BodyMode: r.BodyMode, Produces: r.Produces,
MaxBodyBytes: r.MaxBodyBytes, Manual: r.Manual, CustomBinding: r.CustomBinding, Types: dependencies,
}
if !r.Manual {
op.Signature = expectedLogicSignature(r)
}
snapshot.Routes[r.Group+"/"+lower(r.Handler)] = op
}
return snapshot
}
func (r routeContract) file() string {
if r.Manual {
return "internal/handler/" + r.Group + "/" + lower(r.Handler) + "Manual.go"
}
return "internal/logic/" + r.Group + "/" + lower(r.Handler) + ".go"
}
func (r routeContract) operation() string { return r.Method + " " + r.Path }
func compareContracts(old, next *contractSnapshot) []Diagnostic {
if old == nil {
return nil
}
var diagnostics []Diagnostic
for key, previous := range old.Routes {
current, exists := next.Routes[key]
if !exists {
diagnostics = append(diagnostics, Diagnostic{Severity: "review", Code: "routeRemoved", Path: previous.file(), Operation: previous.operation(), Message: "route removed or moved; its handwritten implementation is preserved; review obsolete code and types"})
continue
}
d := Diagnostic{Severity: "review", Code: "contractChanged", Path: current.file(), Operation: current.operation(), Message: "contract changed; review the preserved implementation"}
if previous.Signature != current.Signature {
d.Before, d.After = previous.Signature, current.Signature
d.Details = append(d.Details, "Logic call signature changed")
}
if previous.Method != current.Method || previous.Path != current.Path {
d.Details = append(d.Details, previous.operation()+" -> "+current.operation())
}
if previous.Manual != current.Manual || previous.CustomBinding != current.CustomBinding || previous.BodyMode != current.BodyMode || previous.MaxBodyBytes != current.MaxBodyBytes || previous.Produces != current.Produces {
d.Details = append(d.Details, fmt.Sprintf("HTTP access: manual=%t customBinding=%t body=%s maxBytes=%d produces=%q -> manual=%t customBinding=%t body=%s maxBytes=%d produces=%q", previous.Manual, previous.CustomBinding, previous.BodyMode, previous.MaxBodyBytes, previous.Produces, current.Manual, current.CustomBinding, current.BodyMode, current.MaxBodyBytes, current.Produces))
}
d.Details = append(d.Details, changedTypes(previous.Types, current.Types)...)
if len(d.Details) > 0 {
diagnostics = append(diagnostics, d)
}
}
return diagnostics
}
func changedTypes(old, next map[string]typeContract) []string {
names := map[string]bool{}
for name := range old {
names[name] = true
}
for name := range next {
names[name] = true
}
var details []string
for name := range names {
a, existed := old[name]
b, exists := next[name]
switch {
case !existed:
details = append(details, "DTO dependency added: "+name)
case !exists:
details = append(details, "DTO dependency removed: "+name)
case a.Scalar != b.Scalar:
details = append(details, fmt.Sprintf("scalar %s: %s -> %s", name, a.Scalar, b.Scalar))
case !reflect.DeepEqual(a.Fields, b.Fields):
oldFields, newFields := map[string]contractField{}, map[string]contractField{}
for _, f := range a.Fields {
oldFields[f.Name] = f
}
for _, f := range b.Fields {
newFields[f.Name] = f
}
before := len(details)
for field, f := range oldFields {
if n, ok := newFields[field]; !ok {
details = append(details, fmt.Sprintf("DTO %s.%s removed", name, field))
} else if f != n {
details = append(details, fmt.Sprintf("DTO %s.%s: %s `%s` -> %s `%s`", name, field, f.Type, f.Tag, n.Type, n.Tag))
}
}
for field := range newFields {
if _, ok := oldFields[field]; !ok {
details = append(details, fmt.Sprintf("DTO %s.%s added", name, field))
}
}
if len(details) == before {
details = append(details, "DTO "+name+" field order changed; review positional struct literals")
}
}
}
sort.Strings(details)
return details
}
func sortDiagnostics(diagnostics []Diagnostic) {
sort.Slice(diagnostics, func(i, j int) bool {
a, b := diagnostics[i], diagnostics[j]
if a.Path != b.Path {
return a.Path < b.Path
}
if a.Line != b.Line {
return a.Line < b.Line
}
if a.Code != b.Code {
return a.Code < b.Code
}
return a.Operation < b.Operation
})
}
func diagnosticErrors(diagnostics []Diagnostic) int {
count := 0
for _, d := range diagnostics {
if d.Severity == "error" {
count++
}
}
return count
}
func expectedLogicSignature(r Route) string {
params := []string{"context.Context"}
if r.Request != "" {
params = append(params, "*types."+r.Request)
}
if r.BodyMode == "raw" || r.BodyMode == "multipart" {
params = append(params, "*httpx.Body")
}
results := []string{"error"}
if r.Produces != "" {
results = []string{"*httpx.Stream", "error"}
} else if r.Response != "" {
results = []string{"*types." + r.Response, "error"}
}
return displaySignature(upper(r.Handler), params, results)
}
func displaySignature(name string, params, results []string) string {
result := strings.Join(results, ", ")
if len(results) != 1 {
result = "(" + result + ")"
}
return name + "(" + strings.Join(params, ", ") + ") " + result
}

View File

@@ -0,0 +1,308 @@
package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func findDiagnostic(t *testing.T, out Output, code string) Diagnostic {
t.Helper()
for _, d := range out.Diagnostics {
if d.Code == code {
return d
}
}
t.Fatalf("missing %s diagnostic: %+v", code, out.Diagnostics)
return Diagnostic{}
}
func TestContractChangesPersistUntilLogicFixed(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
out, err := New(Options{Dir: dir, Module: "example.com/changes", FrameworkDir: frameworkRoot(t)})
if err != nil || len(out.Diagnostics) > 0 {
t.Fatalf("initial generation: %v %+v", err, out.Diagnostics)
}
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
logicPath := filepath.Join(dir, "internal/logic/greet/greet.go")
logicBefore := readFile(t, logicPath)
writeFile(t, o.API, strings.ReplaceAll(readFile(t, o.API), "GreetReq", "HelloReq"))
before := snapshot(t, dir)
for _, dryRun := range []bool{true, false} {
o.DryRun, o.Check = dryRun, !dryRun
out, err = Generate(o)
if dryRun && err != nil {
t.Fatal(err)
}
if !dryRun && err == nil {
t.Fatal("check accepted stale project")
}
d := findDiagnostic(t, out, "logicSignature")
if d.Line <= 0 || d.Path != "internal/logic/greet/greet.go" || !strings.Contains(d.After, "*types.HelloReq") || !strings.Contains(d.Before, "*types.GreetReq") {
t.Fatalf("imprecise diagnosis: %+v", d)
}
findDiagnostic(t, out, "contractChanged")
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("preview/check changed files")
}
}
o.DryRun, o.Check = false, false
out, err = Generate(o)
if err != nil {
t.Fatal(err)
}
findDiagnostic(t, out, "logicSignature")
if readFile(t, logicPath) != logicBefore {
t.Fatal("business source overwritten")
}
o.Check = true
out, err = Generate(o)
if err == nil || strings.Contains(err.Error(), "stale") || len(out.Changes) != 0 {
t.Fatalf("signature drift must remain after generation: %v %+v", err, out.Changes)
}
findDiagnostic(t, out, "logicSignature")
// Method location and import/parameter names are project-owned. Find the
// method in a different file, honoring aliases and named result variables.
writeFile(t, logicPath, "package greet\n// business split into visitor.go\n")
writeFile(t, filepath.Join(dir, "internal/logic/greet/visitor.go"), `package greet
import (ctx "context"; dto "example.com/changes/internal/types")
func(s *Logic)Greet(input ctx.Context, request *dto.HelloReq)(output *dto.GreetResp, problem error){
return &dto.GreetResp{Message:request.Name},nil
}
`)
out, err = Generate(o)
if err != nil || len(out.Diagnostics) > 0 {
t.Fatalf("compatible business method rejected: %v %+v", err, out.Diagnostics)
}
before = snapshot(t, dir)
o.Check = false
if _, err = Generate(o); err != nil {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("stable generation changed files")
}
// Field review notices should point to the actual moved business method.
writeFile(t, o.API, strings.Replace(readFile(t, o.API), `max=80`, `max=90`, 1))
o.DryRun = true
out, err = Generate(o)
if err != nil {
t.Fatal(err)
}
d := findDiagnostic(t, out, "contractChanged")
if d.Path != "internal/logic/greet/visitor.go" || d.Line == 0 {
t.Fatalf("wrong implementation location: %+v", d)
}
o.DryRun = false
if _, err = Generate(o); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
if data, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("fixed generated consumer: %v\n%s", err, data)
}
}
func TestContractChangesReachNestedDTOs(t *testing.T) {
source := `syntax = "v1"
type Address { Street string ` + "`json:\"street\"`" + ` }
type Request { Address Address ` + "`json:\"address\"`" + ` }
type Reply { Accepted bool ` + "`json:\"accepted\"`" + ` }
@server(group: orders)
service orders-api {
@handler create
post /orders (Request) returns (Reply)
@handler ping
get /ping
}
`
root := t.TempDir()
api := filepath.Join(root, "source/service.api")
writeFile(t, api, source)
dir := filepath.Join(root, "app")
if _, err := Generate(Options{API: api, Dir: dir, Module: "example.com/nested", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
o := Options{API: filepath.Join(dir, "api/service.api"), Dir: dir, DryRun: true}
writeFile(t, o.API, strings.Replace(source, "Street string", "Street int64", 1))
out, err := Generate(o)
if err != nil {
t.Fatal(err)
}
if len(out.Diagnostics) != 1 {
t.Fatalf("unrelated routes should not be warned: %+v", out.Diagnostics)
}
d := findDiagnostic(t, out, "contractChanged")
if d.Path != "internal/logic/orders/create.go" || !strings.Contains(strings.Join(d.Details, "\n"), "Address.Street: string") {
t.Fatalf("nested field not explained: %+v", d)
}
// Import/file/comment/formatting changes are not semantic DTO changes.
writeFile(t, o.API, source+"\n// a harmless documentation edit\n")
out, err = Generate(o)
if err != nil || len(out.Diagnostics) > 0 {
t.Fatalf("comment caused false diagnostic: %v %+v", err, out.Diagnostics)
}
// Removal preserves the old implementation and explicitly points it out.
writeFile(t, o.API, strings.Replace(source, " @handler create\n post /orders (Request) returns (Reply)\n", "", 1))
out, err = Generate(o)
if err != nil {
t.Fatal(err)
}
d = findDiagnostic(t, out, "routeRemoved")
if d.Path != "internal/logic/orders/create.go" {
t.Fatal(d)
}
}
func TestContractSnapshotSemanticDetails(t *testing.T) {
base := &Spec{Types: []Type{{Name: "Req", Fields: []Field{{Name: "ID", Type: "ID", Tag: `form:"id"`}}}}, Scalars: []ScalarDefinition{{Name: "ID", GoImport: "example.com/id", GoType: "Value", JSONInput: "string", JSONOutput: "string"}}, Routes: []Route{{Group: "files", Handler: "send", Method: "post", Path: "/file", Request: "Req", BodyMode: "raw", MaxBodyBytes: 64, Produces: "text/plain"}}}
old := snapshotContract(base)
base.Scalars[0].Text = true
base.Routes[0].BodyMode = "multipart"
base.Routes[0].Produces = "application/octet-stream"
base.Routes[0].MaxBodyBytes = 128
base.Routes[0].Path = "/files"
diagnostics := compareContracts(old, snapshotContract(base))
if len(diagnostics) != 1 {
t.Fatal(diagnostics)
}
details := strings.Join(diagnostics[0].Details, "\n")
for _, want := range []string{"scalar ID", "body=raw", "body=multipart", "POST /file -> POST /files", "maxBytes=128"} {
if !strings.Contains(details, want) {
t.Fatal("missing", want, details)
}
}
base.Routes[0].Produces = ""
diagnostics = compareContracts(old, snapshotContract(base))
if !strings.Contains(diagnostics[0].Before, "*httpx.Stream") || !strings.HasSuffix(diagnostics[0].After, ") error") {
t.Fatal(diagnostics)
}
}
func TestLogicDiagnosticsSourceBoundaries(t *testing.T) {
for _, tc := range []struct{ name, source, code, severity string }{
{"wrongResult", `package greet; import "context"; func(s *Logic)Greet(context.Context) error{return nil}`, "logicSignature", "error"},
{"syntax", `package greet; func(s *Logic)Greet( {`, "logicSyntax", "error"},
{"alias", `package greet; import("context";"example.com/test/internal/types");type Input = types.GreetReq; func(s *Logic)Greet(context.Context,*Input)(*types.GreetResp,error){return nil,nil}`, "logicUnchecked", "review"},
{"conditional", "//go:build projectTag\n\npackage greet\n", "logicUnchecked", "review"},
{"missing", "package greet\n", "logicNotFound", "review"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "internal/logic/greet/greet.go"), tc.source)
before := snapshot(t, dir)
out, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true})
d := findDiagnostic(t, out, tc.code)
if d.Severity != tc.severity || (tc.severity == "error") != (err != nil) {
t.Fatalf("unexpected check result: %v %+v", err, d)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("analysis modified business source")
}
})
}
}
func TestContractSnapshotBootstrapAndDeterminism(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, manifestPath)
var previous manifest
if err := json.Unmarshal([]byte(readFile(t, path)), &previous); err != nil {
t.Fatal(err)
}
previous.Contract = nil
data, _ := json.MarshalIndent(previous, "", " ")
writeFile(t, path, string(data)+"\n")
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), DryRun: true}
a, err := Generate(o)
if err != nil {
t.Fatal(err)
}
b, err := Generate(o)
if err != nil || !reflect.DeepEqual(a, b) {
t.Fatal("non-deterministic preview", err)
}
findDiagnostic(t, a, "contractBaseline")
o.DryRun = false
if _, err = Generate(o); err != nil {
t.Fatal(err)
}
o.Check = true
out, err := Generate(o)
if err != nil || len(out.Diagnostics) > 0 {
t.Fatal(err, out.Diagnostics)
}
}
func TestLogicResponseAndBodySignatureChanges(t *testing.T) {
for _, tc := range []struct {
name, after string
change func(string) string
}{
{"responseType", "*types.HelloResp", func(s string) string { return strings.ReplaceAll(s, "GreetResp", "HelloResp") }},
{"bodyParameter", "*httpx.Body", func(s string) string {
return strings.Replace(s, " get /greet", " @body (mode: raw; maxBytes: 64)\n post /greet", 1)
}},
{"streamResult", "*httpx.Stream", func(s string) string {
return strings.Replace(strings.Replace(s, " returns (GreetResp)", "", 1), " get /greet", " @produces \"text/plain\"\n get /greet", 1)
}},
} {
t.Run(tc.name, func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), DryRun: true}
writeFile(t, o.API, tc.change(readFile(t, o.API)))
out, err := Generate(o)
if err != nil {
t.Fatal(err)
}
d := findDiagnostic(t, out, "logicSignature")
if !strings.Contains(d.After, tc.after) {
t.Fatal(d)
}
})
}
}
func TestLogicDuplicateAndTestFiles(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
source := readFile(t, filepath.Join(dir, "internal/logic/greet/greet.go"))
// String literals mentioning build constraints do not make a file conditional.
writeFile(t, filepath.Join(dir, "internal/logic/greet/greet.go"), source+"\nconst diagnosticText=\"//go:build special\"\n")
writeFile(t, filepath.Join(dir, "internal/logic/greet/fixture_test.go"), source)
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}
out, err := Generate(o)
if err != nil || len(out.Diagnostics) > 0 {
t.Fatal(err, out.Diagnostics)
}
writeFile(t, filepath.Join(dir, "internal/logic/greet/duplicate.go"), source)
out, err = Generate(o)
if err == nil {
t.Fatal("duplicate method passed check")
}
d := findDiagnostic(t, out, "logicDuplicate")
if len(d.Details) != 2 || !strings.Contains(d.Details[0], "duplicate.go:") {
t.Fatal(d)
}
}

View File

@@ -120,6 +120,9 @@ func prepareMetadata(original *Spec) (*Spec, error) {
}
func decorateDocument(doc map[string]any, spec *Spec) {
if spec.DefaultResponse != "" {
doc["x-omni-default-response"] = spec.DefaultResponse
}
if spec.Description != "" {
doc["info"].(map[string]any)["description"] = spec.Description
}

View File

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

View File

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

View File

@@ -0,0 +1,240 @@
package apigen
import (
"bytes"
"errors"
"fmt"
"go/ast"
"go/build"
"go/format"
goparser "go/parser"
"go/scanner"
gotoken "go/token"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
type logicMethod struct {
path, signature string
line int
known bool
}
// checkLogic inspects source only: no project code, init hooks, Go commands or
// dependency downloads are executed. Seeds participate only when files do not
// already exist, matching apply's ownership rules.
func checkLogic(root, module string, contract *contractSnapshot, seeds map[string][]byte) ([]Diagnostic, map[string]logicMethod) {
groups := map[string][]routeContract{}
knownTypes := map[string]bool{}
for _, r := range contract.Routes {
if !r.Manual {
groups[r.Group] = append(groups[r.Group], r)
}
for name := range r.Types {
knownTypes[name] = true
}
}
// Project-owned aliases may make differently named DTOs identical. Let
// the compiler handle such cases rather than rejecting valid Go aliases.
typeExtensions := false
if entries, err := os.ReadDir(filepath.Join(root, "internal/types")); err == nil {
for _, e := range entries {
if sourceFile(e.Name()) && e.Name() != "typesGen.go" && e.Name() != "scalarsGen.go" {
typeExtensions = true
}
}
}
var diagnostics []Diagnostic
locations := map[string]logicMethod{}
for group, routes := range groups {
directory := "internal/logic/" + group
files := map[string][]byte{}
uncertain := false
entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(directory)))
if err != nil && !errors.Is(err, os.ErrNotExist) {
diagnostics = append(diagnostics, Diagnostic{Severity: "error", Code: "logicUnreadable", Path: directory, Message: err.Error()})
continue
}
for _, e := range entries {
if e.IsDir() || !sourceFile(e.Name()) {
continue
}
name := directory + "/" + e.Name()
if err := safePath(root, name); err != nil {
diagnostics = append(diagnostics, Diagnostic{Severity: "error", Code: "logicUnreadable", Path: name, Message: err.Error()})
uncertain = true
continue
}
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name)))
if err != nil {
diagnostics = append(diagnostics, Diagnostic{Severity: "error", Code: "logicUnreadable", Path: name, Message: err.Error()})
uncertain = true
continue
}
files[name] = data
}
for name, data := range seeds {
if filepath.ToSlash(filepath.Dir(name)) == directory && sourceFile(filepath.Base(name)) {
if _, exists := files[name]; !exists {
files[name] = data
}
}
}
methods := map[string][]logicMethod{}
var names []string
for name := range files {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
data := files[name]
// Do not guess the user's GOFLAGS/build-tag combination. Flag the
// group for compiler verification if conditional files are present.
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(name))); err == nil {
match, err := build.Default.MatchFile(filepath.Join(root, filepath.FromSlash(directory)), filepath.Base(name))
if err != nil || !match {
uncertain = true
continue
}
}
positions := gotoken.NewFileSet()
file, err := goparser.ParseFile(positions, name, data, goparser.SkipObjectResolution|goparser.ParseComments)
if err != nil {
d := Diagnostic{Severity: "error", Code: "logicSyntax", Path: name, Message: "cannot parse Logic source: " + err.Error()}
if list, ok := err.(scanner.ErrorList); ok && len(list) > 0 {
d.Line = list[0].Pos.Line
}
diagnostics = append(diagnostics, d)
uncertain = true
continue
}
conditional := false
for _, group := range file.Comments {
for _, comment := range group.List {
if comment.Pos() < file.Package && (strings.HasPrefix(comment.Text, "//go:build ") || strings.HasPrefix(comment.Text, "// +build ")) {
conditional = true
}
}
}
if conditional {
uncertain = true
continue
}
imports := map[string]string{}
for _, imp := range file.Imports {
path, err := strconv.Unquote(imp.Path.Value)
if err != nil {
continue
}
alias := filepath.Base(path)
if imp.Name != nil {
alias = imp.Name.Name
}
imports[alias] = path
}
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Recv == nil || len(fn.Recv.List) != 1 {
continue
}
receiver := fn.Recv.List[0].Type
if pointer, ok := receiver.(*ast.StarExpr); ok {
receiver = pointer.X
}
id, ok := receiver.(*ast.Ident)
if !ok || id.Name != "Logic" {
continue
}
params, a := canonicalFields(fn.Type.Params, imports, module, knownTypes, typeExtensions)
results, b := canonicalFields(fn.Type.Results, imports, module, knownTypes, typeExtensions)
methods[fn.Name.Name] = append(methods[fn.Name.Name], logicMethod{name, displaySignature(fn.Name.Name, params, results), positions.Position(fn.Pos()).Line, a && b})
}
}
for _, r := range routes {
found := methods[upper(r.Handler)]
if len(found) == 1 && !uncertain {
locations[r.file()] = found[0]
}
d := Diagnostic{Path: r.file(), Operation: r.operation(), After: r.Signature}
switch {
case uncertain:
d.Severity, d.Code, d.Message = "review", "logicUnchecked", "conditional or unreadable source prevents a complete signature check; run go test ./... with your build settings"
case len(found) == 0:
d.Severity, d.Code, d.Message = "review", "logicNotFound", "no direct Logic method found; check embedded/aliased implementations with go test ./..."
case len(found) > 1:
d.Severity, d.Code, d.Message = "error", "logicDuplicate", "multiple Logic methods have this name"
for _, m := range found {
d.Details = append(d.Details, fmt.Sprintf("%s:%d", m.path, m.line))
}
case !found[0].known:
d.Severity, d.Code, d.Message = "review", "logicUnchecked", "signature uses project-specific type names; verify aliases with go test ./..."
d.Path, d.Line, d.Before = found[0].path, found[0].line, found[0].signature
case found[0].signature != r.Signature:
d.Severity, d.Code, d.Message = "error", "logicSignature", "preserved Logic signature differs from the generated call; update this method"
d.Path, d.Line, d.Before = found[0].path, found[0].line, found[0].signature
default:
continue
}
diagnostics = append(diagnostics, d)
}
}
return diagnostics, locations
}
func sourceFile(name string) bool {
return strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go") && !strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "_")
}
func canonicalFields(fields *ast.FieldList, imports map[string]string, module string, knownTypes map[string]bool, extensions bool) ([]string, bool) {
var values []string
known := true
if fields != nil {
for _, field := range fields.List {
value, ok := canonicalType(field.Type, imports, module, knownTypes, extensions)
known = known && ok
count := len(field.Names)
if count == 0 {
count = 1
}
for i := 0; i < count; i++ {
values = append(values, value)
}
}
}
return values, known
}
func canonicalType(expr ast.Expr, imports map[string]string, module string, knownTypes map[string]bool, extensions bool) (string, bool) {
switch e := expr.(type) {
case *ast.ParenExpr:
return canonicalType(e.X, imports, module, knownTypes, extensions)
case *ast.StarExpr:
value, ok := canonicalType(e.X, imports, module, knownTypes, extensions)
return "*" + value, ok
case *ast.Ellipsis:
value, ok := canonicalType(e.Elt, imports, module, knownTypes, extensions)
return "..." + value, ok
case *ast.Ident:
switch e.Name {
case "error", "bool", "string", "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "uintptr", "float32", "float64", "complex64", "complex128", "any":
return e.Name, true
}
case *ast.SelectorExpr:
if pkg, ok := e.X.(*ast.Ident); ok {
switch imports[pkg.Name] {
case "context":
return "context." + e.Sel.Name, true
case frameworkModule + "/httpx":
return "httpx." + e.Sel.Name, true
case module + "/internal/types":
return "types." + e.Sel.Name, knownTypes[e.Sel.Name] || !extensions
}
}
}
var b bytes.Buffer
_ = format.Node(&b, gotoken.NewFileSet(), expr)
return b.String(), false
}

View File

@@ -0,0 +1,213 @@
package apigen
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestManagedDefaultValidation(t *testing.T) {
source := readFile(t, filepath.Join(frameworkRoot(t), "examples/contracts/managed.api"))
spec, err := parseText(t, source)
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
if spec.Routes[0].ResponseFormat != "" {
t.Fatal("compile mutated source")
}
var doc map[string]any
if err = json.Unmarshal(c.Document, &doc); err != nil {
t.Fatal(err)
}
if doc["x-omni-default-response"] != "CommonResponse" {
t.Fatal("missing default metadata")
}
for _, bad := range []string{
strings.Replace(source, "defaultResponse: CommonResponse", "defaultResponse: Missing", 1),
source + "\n@app (defaultResponse: CommonResponse)\n",
strings.Replace(source, "maxBytes: 2048", "maxBytes: 0", 1),
strings.Replace(source, "@produces \"text/plain\"", "@produces \"text/plain; charset=utf-8\"", 1),
} {
s, err := parseText(t, bad)
if err == nil {
_, err = Compile(s)
}
if err == nil {
t.Fatal("invalid contract accepted")
}
}
root := t.TempDir()
writeFile(t, filepath.Join(root, "service.api"), "import \"child.api\"\nservice demo {\n@handler hello\nget /hello\n}")
writeFile(t, filepath.Join(root, "child.api"), "@app (defaultResponse: Shared)\nresponse Shared ()")
if _, err = Parse(filepath.Join(root, "service.api")); err == nil {
t.Fatal("import owns App defaults")
}
}
func TestManagedGeneratedProject(t *testing.T) {
root := frameworkRoot(t)
dir := filepath.Join(t.TempDir(), "managed")
o := Options{Dir: dir, API: filepath.Join(root, "examples/contracts/managed.api"), Module: "example.com/managed", FrameworkDir: root}
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
o.API = filepath.Join(dir, "api/managed.api")
for _, path := range []string{"cmd/server/main.go", "internal/settings", "internal/servicecontext", "internal/service"} {
if _, err := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(err) {
t.Fatalf("old layout remains: %s", path)
}
}
mainBefore := readFile(t, filepath.Join(dir, "main.go"))
files := map[string]string{
"consumer_test.go": "consumer_test.go", "internal/svc/options.go": "options.go", "internal/svc/client.go": "client.go", "internal/logic/demo/check.go": "operations.go",
}
for path, fixture := range files {
writeFile(t, filepath.Join(dir, path), readFile(t, filepath.Join("testdata/managed", fixture)))
}
for _, name := range []string{"other", "rawJSON", "upload", "download", "callback", "slow"} {
writeFile(t, filepath.Join(dir, "internal/logic/demo", name+".go"), "package demo\n")
}
configPath := filepath.Join(dir, "internal/config/config.go")
writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n FailInit bool `json:\"failInit\"`", 1))
svcPath := filepath.Join(dir, "internal/svc/serviceContext.go")
source := strings.Replace(readFile(t, svcPath), "type ServiceContext struct {", "type ServiceContext struct {\n Client *Client", 1)
source = strings.Replace(source, "return c, nil", "if err=initClient(ctx,c);err!=nil{return nil,err}\n return c,nil", 1)
writeFile(t, svcPath, source)
// Independent tooling stays outside generator ownership.
writeFile(t, filepath.Join(dir, "cmd/migrate/main.go"), "package main\nfunc main(){}\n")
before := snapshot(t, dir)
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("regeneration changed handwritten files")
}
o.Check = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
o.Check = false
// Add an API endpoint: handler and Logic stub appear without touching main or business code.
api := readFile(t, o.API)
writeFile(t, o.API, api+"\n@server (group: extra)\nservice managed-api {\n@handler extra\nget /extra\n}\n")
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
if readFile(t, filepath.Join(dir, "main.go")) != mainBefore {
t.Fatal("main requires business-specific edits")
}
for path := range files {
if readFile(t, filepath.Join(dir, path)) != string(before[path]) {
t.Fatal("business file changed", path)
}
}
// Main is owned, checked, and restored when missing. Hand edits are a conflict.
writeFile(t, filepath.Join(dir, "main.go"), mainBefore+"\n// drift\n")
o.Check = true
if err := generateManagedTestProject(t, o); err == nil {
t.Fatal("main drift accepted")
}
o.Check = false
if err := os.Remove(filepath.Join(dir, "main.go")); err != nil {
t.Fatal(err)
}
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
o.Check = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
for _, args := range [][]string{{"test", "-race", "-mod=mod", "-count=1", "./..."}, {"build", "-mod=mod", "."}} {
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%v: %v\n%s", args, err, out)
}
}
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
data, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(data))
}
var dependency struct {
Version, Sum string
Replace any
}
if err = json.Unmarshal(data, &dependency); err != nil {
t.Fatal(err)
}
if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" {
t.Fatal("not a fixed remote consumer")
}
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
}
}
func generateManagedTestProject(t *testing.T, o Options) error {
t.Helper()
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
if cli == "" {
_, err := Generate(o)
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
args := []string{"api", "generate", "--api", o.API, "--dir", o.Dir, "--module", o.Module}
if o.Check {
args = append(args, "--check")
}
cmd := exec.CommandContext(ctx, cli, args...)
cmd.Dir = filepath.Dir(o.Dir)
if data, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("released CLI: %w\n%s", err, data)
}
return nil
}
func TestManagedOwnershipRejectsOldOrHandwrittenEntry(t *testing.T) {
for _, oldLayout := range []bool{false, true} {
t.Run(map[bool]string{false: "handwrittenMain", true: "oldManifest"}[oldLayout], func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/entry", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, manifestPath))), &m); err != nil {
t.Fatal(err)
}
if oldLayout {
m.Version = 1
} else {
delete(m.Generated, "main.go")
writeFile(t, filepath.Join(dir, "main.go"), "package main\nfunc main(){}\n")
}
data, _ := json.Marshal(m)
writeFile(t, filepath.Join(dir, manifestPath), string(data))
before := snapshot(t, dir)
if _, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}); err == nil {
t.Fatal("unowned layout accepted")
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("rejected migration modified project")
}
})
}
}

View File

@@ -252,7 +252,7 @@ func TestDeclaredContractConsumer(t *testing.T) {
}
}
const declaredConsumerTest = `package dx_test
const declaredConsumerTest = `package main_test
import (
"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")}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
package apigen
import (
"encoding/json"
"fmt"
"strings"
)
func appSource(o Options, spec *Spec) string {
source := fmt.Sprintf(`package handler
import (
"fmt"
omni %q
%q
%q
)
// NewApp is generated HTTP composition. The caller owns deps and its logger.
func NewApp(deps *svc.ServiceContext)(*omni.App,error){
if deps==nil || deps.Health==nil{return nil,fmt.Errorf("service context and health checker required")}
cfg:=deps.Config.Server
for _,option:=range deps.AppOptions(){if option!=nil{option(&cfg)}}
`, frameworkModule, frameworkModule+"/httpx", o.Module+"/internal/svc")
if spec.DefaultResponse != "" {
for _, r := range spec.Responses {
if r.Name == spec.DefaultResponse {
data, _ := json.Marshal(r.Config)
source += fmt.Sprintf("cfg.Response=newResponseTemplate(%q).Config();cfg.ResponseEncoder=nil\n", string(data))
}
}
}
source += `if err:=cfg.Validate();err!=nil{return nil,err}
app:=omni.New(omni.WithConfig(cfg))
app.Use(deps.GlobalMiddleware()...)
if err:=RegisterRoutes(app,deps);err!=nil{return nil,err}
app.GET("/openapi.json",OpenAPI)
app.GET("/health/live",deps.Health.LivenessHandler())
app.GET("/health/ready",deps.Health.ReadinessHandler())
`
if o.Storage == "local" {
source += `if deps.FileHTTP==nil{return nil,fmt.Errorf("local storage handler required")}
for _,method:=range []string{"GET","HEAD","PUT"}{app.Handle(method,"/storage/object",httpx.WrapHandler(deps.FileHTTP))}
`
}
source += "return app,nil\n}\n"
if o.Storage != "local" {
source = strings.Replace(source, fmt.Sprintf("%q", frameworkModule+"/httpx"), "", 1)
}
return source
}

View File

@@ -3,7 +3,7 @@ package apigen
import "fmt"
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/<group>/ # generated binding and service adapters
internal/service/<group>/ # developer-owned business operations
internal/handler/<group>/ # generated binding and Logic adapters
internal/logic/<group>/ # developer-owned business operations
internal/repository/<group>/ # developer-owned persistence contracts
internal/middleware/ # named hooks and generated registry
docs/openapi.json # generated OpenAPI 3.0.3
@@ -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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
package svc
import (
"context"
"errors"
"sync/atomic"
)
var Opened, Closed, DownloadClosed atomic.Int32
var Started = make(chan struct{}, 1)
var Release = make(chan struct{})
// This fake client exercises the same lifetime contract as a database/client.
type Client struct{}
func initClient(ctx context.Context, c *ServiceContext) error {
Opened.Add(1)
c.Client = &Client{}
c.OnClose(func() error { Closed.Add(1); return nil })
if c.Config.FailInit {
return errors.New("client initialization failed")
}
return ctx.Err()
}

View File

@@ -0,0 +1,235 @@
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"example.com/managed/internal/config"
"example.com/managed/internal/handler"
"example.com/managed/internal/svc"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func application(t *testing.T) *omni.App {
t.Helper()
cfg := config.Default()
cfg.Server.Log.Writer = io.Discard
logger, err := logx.New(cfg.Server.Log)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = logger.Close() })
deps, err := svc.New(context.Background(), logger, cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = deps.Close() })
app, err := handler.NewApp(deps)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = app.Close() })
return app
}
func response(t *testing.T, app *omni.App, method, path string, body io.Reader, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, body)
for k, v := range headers {
req.Header.Set(k, v)
}
out := httptest.NewRecorder()
app.ServeHTTP(out, req)
return out
}
func assertError(t *testing.T, out *httptest.ResponseRecorder, status, code int, message, field string) {
t.Helper()
var data map[string]any
if err := json.Unmarshal(out.Body.Bytes(), &data); err != nil {
t.Fatal(out.Body.String(), err)
}
value, exists := data["data"]
if out.Code != status || data["code"] != float64(code) || data[field] != message || !exists || value != nil {
t.Fatalf("unexpected error: %d %s", out.Code, out.Body.String())
}
}
func TestGeneratedAppPolicies(t *testing.T) {
app := application(t)
assertError(t, response(t, app, "GET", "/check?kind=fixed", nil, nil), 400, 400002, "用户名格式不正确", "msg")
assertError(t, response(t, app, "GET", "/check?kind=params", nil, nil), 400, 400003, "企业名称不能超过100个字符", "msg")
assertError(t, response(t, app, "GET", "/check?kind=unknown", nil, nil), 500, 9999, "系统错误", "msg")
assertError(t, response(t, app, "GET", "/other?kind=fixed", nil, nil), 400, 400002, "用户名格式不正确", "detail")
for _, path := range []string{"/check", "/missing", "/callback"} {
assertError(t, response(t, app, "GET", path, nil, map[string]string{"X-Fail": "1"}), 400, 400002, "用户名格式不正确", "msg")
}
assertError(t, response(t, app, "GET", "/missing", nil, nil), 404, 404001, "接口不存在", "msg")
assertError(t, response(t, app, "POST", "/check", nil, nil), 405, 405001, "请求方法不支持", "msg")
out := response(t, app, "GET", "/raw", nil, nil)
if strings.TrimSpace(out.Body.String()) != `{"bytes":3}` {
t.Fatal(out.Body.String())
}
}
func TestGeneratedTransfers(t *testing.T) {
app := application(t)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
file, err := writer.CreateFormFile("file", "x.txt")
if err != nil {
t.Fatal(err)
}
_, _ = file.Write([]byte("hello"))
_ = writer.Close()
out := response(t, app, "POST", "/upload", &body, map[string]string{"Content-Type": writer.FormDataContentType()})
if out.Code != 200 || !strings.Contains(out.Body.String(), `"bytes":5`) {
t.Fatalf("upload: %d %s", out.Code, out.Body.String())
}
out = response(t, app, "POST", "/upload", strings.NewReader("bad"), map[string]string{"Content-Type": "application/json"})
if out.Code != 415 {
t.Fatal(out.Code, out.Body.String())
}
// Truncated uploads must use the project's public error catalog rather
// than succeed or be mistaken for an internal failure.
out = response(t, app, "POST", "/upload", strings.NewReader("--b\r\nContent-Disposition: form-data; name=\"file\"\r\n\r\nprivate file"), map[string]string{"Content-Type": "multipart/form-data; boundary=b"})
assertError(t, out, 400, 400004, "上传内容不完整", "msg")
out = response(t, app, "POST", "/callback", strings.NewReader("{ \"exact\" : 123 }"), map[string]string{"X-Signature": "valid"})
if out.Code != 200 || out.Body.String() != "ACK:{ \"exact\" : 123 }" || out.Header().Get("Content-Type") != "text/plain" {
t.Fatal(out.Code, out.Body.String())
}
assertError(t, response(t, app, "POST", "/callback", strings.NewReader("{}"), nil), 400, 400002, "用户名格式不正确", "msg")
// Unknown length must be limited by reads too, not just Content-Length.
req := httptest.NewRequest("POST", "/callback", strings.NewReader(strings.Repeat("x", 65)))
req.ContentLength = -1
req.Header.Set("X-Signature", "valid")
out = httptest.NewRecorder()
app.ServeHTTP(out, req)
assertError(t, out, 413, 413001, "请求体太大", "msg")
before := svc.DownloadClosed.Load()
out = response(t, app, "GET", "/download", nil, nil)
if out.Body.String() != "file-content" || out.Header().Get("Content-Type") != "application/octet-stream" || !strings.Contains(out.Header().Get("Content-Disposition"), "attachment") || svc.DownloadClosed.Load() != before+1 {
t.Fatal(out.Code, out.Body.String(), out.Header())
}
}
func configFile(t *testing.T, address string, fail bool) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
data, _ := json.Marshal(map[string]any{"server": map[string]any{"address": address}, "failInit": fail})
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
return path
}
func waitReady(t *testing.T, url string, done <-chan error) {
t.Helper()
client := http.Client{Timeout: 100 * time.Millisecond}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
select {
case err := <-done:
t.Fatalf("startup failed: %v", err)
default:
}
res, err := client.Get(url + "/health/ready")
if err == nil {
_ = res.Body.Close()
if res.StatusCode == 200 {
return
}
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("server did not become ready")
}
func TestGeneratedMainLifecycle(t *testing.T) {
t.Run("initializationFailure", func(t *testing.T) {
before := svc.Closed.Load()
if err := run(context.Background(), configFile(t, "127.0.0.1:0", true)); err == nil {
t.Fatal("failure hidden")
}
if svc.Closed.Load() != before+1 {
t.Fatal("initialized client leaked or closed twice")
}
})
t.Run("listenFailure", func(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
before := svc.Closed.Load()
if err = run(context.Background(), configFile(t, listener.Addr().String(), false)); err == nil {
t.Fatal("occupied listener accepted")
}
if svc.Closed.Load() != before+1 {
t.Fatal("listen failure cleanup")
}
})
t.Run("drainBeforeClose", func(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
address := listener.Addr().String()
_ = listener.Close()
svc.Closed.Store(0)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
path := configFile(t, address, false)
go func() { done <- run(ctx, path) }()
base := "http://" + address
waitReady(t, base, done)
requestDone := make(chan error, 1)
go func() {
res, err := http.Get(base + "/slow")
if err == nil {
data, _ := io.ReadAll(res.Body)
_ = res.Body.Close()
if res.StatusCode != 200 || !strings.Contains(string(data), `"bytes":1`) {
err = io.ErrUnexpectedEOF
}
}
requestDone <- err
}()
select {
case <-svc.Started:
case <-time.After(3 * time.Second):
t.Fatal("request did not enter logic")
}
cancel()
time.Sleep(30 * time.Millisecond)
if svc.Closed.Load() != 0 {
t.Fatal("dependency closed before drain")
}
close(svc.Release)
select {
case err := <-requestDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("request stuck")
}
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("shutdown stuck")
}
if svc.Closed.Load() != 1 {
t.Fatal("client closed more/less than once", svc.Closed.Load())
}
})
}

View File

@@ -0,0 +1,80 @@
package demo
import (
"context"
"errors"
"io"
"strings"
"example.com/managed/internal/svc"
"example.com/managed/internal/types"
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
func (l *Logic) Check(ctx context.Context, req *types.CheckReq) (*types.Receipt, error) {
switch req.Kind {
case "fixed":
return nil, omnierrors.ByCode("INVALID")
case "params":
return nil, omnierrors.ByCode("TOO_LONG").WithParams(map[string]any{"label": "企业名称", "max": 100})
case "unknown":
return nil, omnierrors.ByCode("UNKNOWN").WithCause(errors.New("private cause"))
}
return &types.Receipt{Bytes: 42}, nil
}
func (l *Logic) Other(ctx context.Context, req *types.CheckReq) (*types.Receipt, error) {
return l.Check(ctx, req)
}
func (l *Logic) RawJSON(context.Context) (*types.Receipt, error) {
return &types.Receipt{Bytes: 3}, nil
}
func (l *Logic) Upload(ctx context.Context, body *httpx.Body) (*types.Receipt, error) {
var total int64
for {
part, err := body.Parts.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
n, err := io.Copy(io.Discard, part)
closeErr := part.Close()
if err != nil {
return nil, err
}
if closeErr != nil {
return nil, closeErr
}
total += n
}
return &types.Receipt{Bytes: total}, nil
}
func (l *Logic) Callback(ctx context.Context, body *httpx.Body) (*httpx.Stream, error) {
// The route's 64-byte limit bounds this example's memory use. A real signature
// implementation can hash body.Raw incrementally and authenticate its headers.
data, err := io.ReadAll(body.Raw)
if err != nil {
return nil, err
}
if body.Header.Get("X-Signature") != "valid" {
return nil, omnierrors.ByCode("INVALID")
}
return &httpx.Stream{Content: io.NopCloser(strings.NewReader("ACK:" + string(data)))}, nil
}
func (l *Logic) Download(context.Context) (*httpx.Stream, error) {
return &httpx.Stream{Content: &countedReader{Reader: strings.NewReader("file-content")}, Name: "测试.txt"}, nil
}
type countedReader struct{ io.Reader }
func (c *countedReader) Close() error { svc.DownloadClosed.Add(1); return nil }
func (l *Logic) Slow(context.Context) (*types.Receipt, error) {
svc.Started <- struct{}{}
<-svc.Release
if svc.Closed.Load() != 0 {
return nil, errors.New("dependency closed before drain")
}
return &types.Receipt{Bytes: 1}, nil
}

View File

@@ -0,0 +1,34 @@
package svc
import (
omni "git.yuntuo.hk/k2safe/go-omni"
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
func (c *ServiceContext) AppOptions() []omni.Option {
return []omni.Option{omni.WithErrors(omnierrors.Catalog{
DefaultStatus: 400,
Definitions: map[string]omnierrors.Definition{
"INVALID": {Code: 400002, Message: "用户名格式不正确"},
"TOO_LONG": {Code: 400003, Message: "{label}不能超过{max}个字符", Params: []omnierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int"}}},
"INTERNAL_ERROR": {Status: 500, Code: 9999, Message: "系统错误"},
"NOT_FOUND": {Status: 404, Code: 404001, Message: "接口不存在"},
"METHOD_NOT_ALLOWED": {Status: 405, Code: 405001, Message: "请求方法不支持"},
"BODY_TOO_LARGE": {Status: 413, Code: 413001, Message: "请求体太大"},
"INVALID_MULTIPART": {Status: 400, Code: 400004, Message: "上传内容不完整"},
},
})}
}
func (c *ServiceContext) GlobalMiddleware() []httpx.Middleware {
return []httpx.Middleware{
func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
if c.Header("X-Fail") != "" {
return omnierrors.ByCode("INVALID")
}
return next(c)
}
},
}
}

View File

@@ -21,6 +21,7 @@ type manifest struct {
Module string `json:"module"`
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" {

View File

@@ -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 <module>] [--force] <directory>")
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] <directory>")
fmt.Fprintln(os.Stderr, " go-omni new --api <file.api> [--module <module>] <directory>")
fmt.Fprintln(os.Stderr, " go-omni api new [--module <module>] <directory> Create a runnable API project")
fmt.Fprintln(os.Stderr, " go-omni api generate [--api <file.api>] [--dir <project>] [--check|--dry-run]")
@@ -180,7 +176,7 @@ func usage() {
fmt.Fprintln(os.Stderr, " go-omni add <name> [--dir <project>] Generate an isolated business module")
fmt.Fprintln(os.Stderr, " --framework-version <version> Pin a remote framework version (defaults to CLI version)")
fmt.Fprintln(os.Stderr, " --framework-dir <directory> Use a local framework checkout")
fmt.Fprintln(os.Stderr, " --database <none|memory|mysql> API defaults to none; CRUD defaults to memory")
fmt.Fprintln(os.Stderr, " --database <none|memory|mysql> Defaults to none")
fmt.Fprintln(os.Stderr, " --log-output <stdout|stderr|file> Generate configured logging (default stderr)")
fmt.Fprintln(os.Stderr, " --log-format <json|text> Select structured log format (default json)")
fmt.Fprintln(os.Stderr, " --storage <none|local|s3|r2|oss> Select optional storage adapter (default none)")
@@ -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
}

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
package servicecontext
import "errors"
// Close is idempotent, including startup failure and graceful shutdown.
func(c *Context)Close()error{
if c==nil{return nil}
c.closeOnce.Do(func(){
c.closeMu.Lock();c.closed=true;cleanups:=c.cleanups;c.cleanups=nil;c.closeMu.Unlock()
for i:=len(cleanups)-1;i>=0;i--{c.closeErr=errors.Join(c.closeErr,cleanups[i]())}
{{if ne .Storage "none"}}if c.storageClose!=nil{c.closeErr=errors.Join(c.closeErr,c.storageClose())}{{end}}
{{if eq .Database "mysql"}}if c.DB!=nil{c.closeErr=errors.Join(c.closeErr,c.DB.Close())}{{end}}
})
return c.closeErr
}
// OnClose transfers a resource cleanup to this context immediately after creation.
// Hooks run once in reverse order, also when later initialization fails.
func(c *Context)OnClose(cleanup func()error) {
if cleanup==nil{return}
c.closeMu.Lock();defer c.closeMu.Unlock()
if c.closed{panic("svc: register cleanup after Close")}
c.cleanups=append(c.cleanups,cleanup)
}

View File

@@ -17,6 +17,8 @@ import (
// Context owns infrastructure, while Logger is borrowed from main.
// 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
}

View File

@@ -0,0 +1,11 @@
package servicecontext
import (
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
// AppOptions runs before routes are registered. Register project error catalogs
// here with omni.WithErrors. It must not create resources or another App.
func(c *Context)AppOptions()[]omni.Option{return nil}
// GlobalMiddleware runs for all endpoints, including App 404/405 fallbacks.
// Named route middleware lives in Middlewares and is selected by the API contract.
func(c *Context)GlobalMiddleware()[]httpx.Middleware{return nil}

View File

@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Source builds can carry an unpublished VCS version (including +dirty).
// 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.