feat: complete API-driven projects with infrastructure and business acceptance
This commit is contained in:
@@ -1,16 +1,79 @@
|
||||
# go-omni CLI
|
||||
|
||||
## API 契约驱动项目
|
||||
|
||||
```sh
|
||||
# 创建包含可运行问候接口的新项目
|
||||
go run ./cmd/go-omni api new --module example.com/demo --framework-dir . ../demo
|
||||
|
||||
# 从已有定义生成对应目录、类型、路由和业务骨架
|
||||
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
|
||||
```
|
||||
|
||||
`new --api <file>` 是契约项目的另一个入口。可以组合 `--database mysql --storage local --log-output file`,生成配置、依赖初始化与资源关闭。API 默认无数据库、无对象存储;`--database memory` 等同于不打开连接。`--force` 不允许覆盖 API 项目。初始配置选择写入 manifest,重新生成自动沿用。
|
||||
|
||||
后续在生成项目内执行 `go-omni api generate --api api/user.api --dir .`。
|
||||
`--check` 只读检查生成产物是否过期。生成器记录所有权,保留 Service / Repository / 配置 / main 的手写内容,遇到生成文件被手动修改则报冲突。新 Service 与中间件占位实现返回 501。
|
||||
|
||||
支持范围、参数语义、目录及安全更新策略见 [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
|
||||
```
|
||||
|
||||
生成项目包含 `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`,
|
||||
再设置已有桶/凭据等环境变量;不自动创建桶或修改云资源。
|
||||
|
||||
配置优先级为默认值 → YAML/JSON → 环境变量;密钥仅允许环境变量。Service 依赖统一存储接口,
|
||||
组合处负责创建客户端与有界连接池,退出时关闭自己创建的资源。文件接口提供服务端上传、直传签名、
|
||||
临时下载链接及统一错误处理;日志示例携带 requestId,文件输出默认带轮转。没有固定业务身份上下文。
|
||||
|
||||
新模板要求 `v0.7.0-preview` 或更新版本;明确指定旧版本并启用新模块会被拒绝,
|
||||
避免模板引用旧框架不存在的接口。本地 `--framework-dir` 验证不受此限制。`--force` 可重新生成兼容配置,
|
||||
但如果切换配置会遗留引用已移除依赖的旧文件,则拒绝操作,要求使用新目录;不会自动删除修改过的代码。
|
||||
|
||||
验证当前生成器:
|
||||
|
||||
```sh
|
||||
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./cmd/go-omni/...
|
||||
python3 scripts/verifyScaffoldModules.py
|
||||
```
|
||||
|
||||
前者覆盖两种数据库与五种文件存储选择、日志方式/格式,以及生成项目的配置、资源关闭和接口测试。
|
||||
后者实际编译启动服务,验证日志、上传/下载/直传链接、错误状态、nethttp/native 下的 SIGINT/SIGTERM 和监听端口释放。
|
||||
云端选项的测试使用本地替身,不代表 OSS/R2 实网验收;本批不访问业务服务器。报告见
|
||||
[脚手架模块验收](../../performance/scaffoldModulesLocal.json)。
|
||||
|
||||
## 其他命令与远程安装
|
||||
|
||||
main 分支还提供 `go-omni add orderItems --dir /path/to/project`,向已有 GoOmni 项目增加
|
||||
Handler / Service / Repository / Model 与模块注册入口。模块名为 lowerCamelCase,
|
||||
目标目录存在时拒绝覆盖,已有启动代码保持原样。生成模块带可替换的内存 CRUD Repository,
|
||||
导入后使用 `orderItems.Register(application, repository, authMiddleware)` 注册。
|
||||
该命令尚未包含在下方历史标签中;当前源码可用 `go build -o /tmp/go-omni ./cmd/go-omni` 构建。
|
||||
该命令从 `v0.7.0-preview` 提供;源码可用 `go build -o /tmp/go-omni ./cmd/go-omni` 构建。
|
||||
边界和验证方式见 [业务基础模块](../../MODULES.md)。
|
||||
|
||||
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
|
||||
|
||||
```sh
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.6.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview
|
||||
go-omni new --module example.com/billing ./billing
|
||||
cd billing
|
||||
go mod tidy
|
||||
|
||||
127
cmd/go-omni/api.go
Normal file
127
cmd/go-omni/api.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen"
|
||||
)
|
||||
|
||||
func runAPI(args []string) error {
|
||||
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
|
||||
usage()
|
||||
return nil
|
||||
}
|
||||
command := args[0]
|
||||
if command == "go" {
|
||||
command = "generate"
|
||||
}
|
||||
if command == "check" {
|
||||
command = "validate"
|
||||
}
|
||||
if command != "new" && command != "generate" && command != "validate" {
|
||||
return fmt.Errorf("unknown api command %q", command)
|
||||
}
|
||||
o := apigen.Options{}
|
||||
for i := 1; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "--help" || arg == "-h" {
|
||||
usage()
|
||||
return nil
|
||||
}
|
||||
if arg == "--check" {
|
||||
if command != "generate" {
|
||||
return errors.New("--check belongs to api generate")
|
||||
}
|
||||
o.Check = true
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(arg, "-") {
|
||||
if command == "new" && o.Dir == "" {
|
||||
o.Dir = arg
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("unexpected argument %q", arg)
|
||||
}
|
||||
name, value, inline := strings.Cut(arg, "=")
|
||||
// goctl-style -api/-dir aliases ease migration; long forms are canonical.
|
||||
if name == "-api" {
|
||||
name = "--api"
|
||||
}
|
||||
if name == "-dir" {
|
||||
name = "--dir"
|
||||
}
|
||||
switch name {
|
||||
case "--api", "--dir", "--module", "--framework-dir", "--framework-version", "--log-output", "--log-format", "--database", "--storage":
|
||||
default:
|
||||
return fmt.Errorf("unknown api option %q", name)
|
||||
}
|
||||
if !inline {
|
||||
if i+1 >= len(args) || strings.HasPrefix(args[i+1], "-") {
|
||||
return fmt.Errorf("%s requires a value", name)
|
||||
}
|
||||
i++
|
||||
value = args[i]
|
||||
}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("%s requires a value", name)
|
||||
}
|
||||
if command == "validate" && name != "--api" {
|
||||
return fmt.Errorf("api validate does not accept %s", name)
|
||||
}
|
||||
switch name {
|
||||
case "--api":
|
||||
o.API = value
|
||||
case "--dir":
|
||||
o.Dir = value
|
||||
case "--module":
|
||||
o.Module = value
|
||||
case "--framework-dir":
|
||||
o.FrameworkDir = value
|
||||
case "--framework-version":
|
||||
o.FrameworkVersion = value
|
||||
case "--database":
|
||||
o.Database = value
|
||||
case "--storage":
|
||||
o.Storage = value
|
||||
case "--log-output":
|
||||
o.LogOutput = value
|
||||
case "--log-format":
|
||||
o.LogFormat = value
|
||||
}
|
||||
}
|
||||
if command == "validate" {
|
||||
if o.API == "" {
|
||||
return errors.New("--api is required")
|
||||
}
|
||||
spec, err := apigen.Parse(o.API)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = apigen.Compile(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("valid API: %d types, %d routes\n", len(spec.Types), len(spec.Routes))
|
||||
return nil
|
||||
}
|
||||
var out apigen.Output
|
||||
var err error
|
||||
if command == "new" {
|
||||
out, err = apigen.New(o)
|
||||
} else {
|
||||
if o.Dir == "" {
|
||||
o.Dir = "."
|
||||
}
|
||||
out, err = apigen.Generate(o)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if o.Check {
|
||||
fmt.Println("API generated files are current")
|
||||
} 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))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
44
cmd/go-omni/api_test.go
Normal file
44
cmd/go-omni/api_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAPICommands(t *testing.T) {
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project := filepath.Join(t.TempDir(), "demo")
|
||||
if err = runAPI([]string{"new", project, "--module=example.com/demo", "--framework-dir", root}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
api := filepath.Join(project, "api/service.api")
|
||||
if err = runAPI([]string{"validate", "-api", api}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = runAPI([]string{"go", "-api", api, "-dir", project, "--check"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = runNew([]string{"--force", project}); err == nil {
|
||||
t.Fatal("CRUD starter overwrote API project")
|
||||
}
|
||||
target := filepath.Join(t.TempDir(), "other")
|
||||
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 {
|
||||
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"}} {
|
||||
if err = runAPI(args); err == nil {
|
||||
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") {
|
||||
t.Fatalf("incompatible profile: %v", err)
|
||||
}
|
||||
}
|
||||
360
cmd/go-omni/apigen/apigen_test.go
Normal file
360
cmd/go-omni/apigen/apigen_test.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func frameworkRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
p, err := filepath.Abs("../../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
func writeFile(t *testing.T, name, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(name, []byte(body), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func readFile(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
func parseText(t *testing.T, source string) (*Spec, error) {
|
||||
t.Helper()
|
||||
file := filepath.Join(t.TempDir(), "service.api")
|
||||
writeFile(t, file, source)
|
||||
return Parse(file)
|
||||
}
|
||||
|
||||
func TestCompileExample(t *testing.T) {
|
||||
s, err := Parse(filepath.Join(frameworkRoot(t), "examples/contracts/user.api"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := Compile(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.Groups) != 2 || len(s.Routes) != 3 || len(s.Sources) != 2 {
|
||||
t.Fatalf("wrong spec: %+v", s)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err = json.Unmarshal(c.Document, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route := doc["paths"].(map[string]any)["/api/v1/users/{userId}"].(map[string]any)["put"].(map[string]any)
|
||||
if len(route["parameters"].([]any)) != 3 {
|
||||
t.Fatal("missing mixed parameters")
|
||||
}
|
||||
schema := route["requestBody"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
|
||||
if schema["additionalProperties"] != false || len(schema["required"].([]any)) != 2 {
|
||||
t.Fatalf("wrong body schema: %+v", schema)
|
||||
}
|
||||
if strings.Contains(string(c.Document), `"user_id"`) {
|
||||
t.Fatal("snake case wire name")
|
||||
}
|
||||
}
|
||||
func TestInvalidDefinitions(t *testing.T) {
|
||||
cases := []string{
|
||||
`syntax="v2"`,
|
||||
`@server ( jwt: Auth ) service user { @handler go get /go }`,
|
||||
"type Request { Name string `json:\"name\" binding:\"typo\"` }\nservice user { @handler run\n post /go (Request) }",
|
||||
"type A { Other *A `json:\"other,optional\"` }\nservice user { @handler run\n post /go (A) }",
|
||||
"type A { Other Missing `json:\"other\"` }\nservice user { @handler run\n post /go (A) }",
|
||||
"type A { UserID int `json:\"user_id\"` }\nservice user { @handler run\n post /go (A) }",
|
||||
"service user { @handler go\n get /go\n @handler other\n get /go\n}",
|
||||
"service user { @handler go\n get /:id\n}",
|
||||
"service user { @handler service\n get /go\n}",
|
||||
"type A { ID int `path:\"id\"` }\nservice user { @handler go\n get /go (A)\n}",
|
||||
"type A { Name string `json:\"name\"` }\nservice user { @handler go\n get /go (A)\n}",
|
||||
"service user { @handler go\n @status 201\n post /go\n}",
|
||||
"type A { Name string `header:\"Name\"` }\nservice user { @handler go\n get /go returns (A)\n}",
|
||||
"@server (group: ../../bad)\nservice user { @handler go\n get /go\n}",
|
||||
"service user { @handler go\n get /health/ready\n}",
|
||||
"type A { Name string `json:\"name\"`\n Name int `json:\"other\"` }\nservice user { @handler go\n post /go (A)\n}",
|
||||
"service user { @handler go\n get /go\n @handler Go\n get /other\n}",
|
||||
"type A { Value []byte `json:\"value\"` }\nservice user { @handler go\n post /go (A)\n}",
|
||||
}
|
||||
for i, source := range cases {
|
||||
s, err := parseText(t, source)
|
||||
if err == nil {
|
||||
_, err = Compile(s)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("case %d accepted: %s", i, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestImports(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
entry := filepath.Join(root, "api", "entry.api")
|
||||
writeFile(t, entry, "import \"types.api\"\nservice user { @handler ping\n get /ping\n}")
|
||||
writeFile(t, filepath.Join(root, "api/types.api"), `import "entry.api"`)
|
||||
if _, err := Parse(entry); err == nil || !strings.Contains(err.Error(), "circular") {
|
||||
t.Fatalf("cycle: %v", err)
|
||||
}
|
||||
writeFile(t, entry, "import \"../outside.api\"\nservice user { @handler ping\n get /ping\n}")
|
||||
writeFile(t, filepath.Join(root, "outside.api"), `type X { Value string `+"`json:\"value\"`"+` }`)
|
||||
if _, err := Parse(entry); err == nil {
|
||||
t.Fatal("escaped import root")
|
||||
}
|
||||
writeFile(t, entry, "import (\n \"shared.api\"\n \"./shared.api\"\n)\nservice user { @handler ping\n get /ping\n}")
|
||||
writeFile(t, filepath.Join(root, "api/shared.api"), `type X { Value string `+"`json:\"value\"`"+` }`)
|
||||
s, err := Parse(entry)
|
||||
if err != nil || len(s.Types) != 1 {
|
||||
t.Fatalf("duplicate import: %+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedPrefixConflict(t *testing.T) {
|
||||
source := "type A { ID int `path:\"id\"` }\ntype B { Name string `path:\"name\"` }\nservice user {\n @handler first\n get /users/:id (A)\n @handler second\n get /users/:name/items (B)\n}"
|
||||
s, err := parseText(t, source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = Compile(s); err == nil {
|
||||
t.Fatal("shared prefix conflict was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzParser(f *testing.F) {
|
||||
for _, s := range []string{starterAPI, "syntax = \"v1\"", "type (", "@server (prefix: /api)"} {
|
||||
f.Add(s)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, source string) {
|
||||
if len(source) > 16384 {
|
||||
t.Skip()
|
||||
}
|
||||
tokens, err := lex("fuzz.api", source)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
spec := &Spec{Title: "Fuzz", Version: "1"}
|
||||
p := parser{tokens: tokens, spec: spec}
|
||||
if p.parse() == nil {
|
||||
_, _ = Compile(spec)
|
||||
}
|
||||
})
|
||||
}
|
||||
func TestRegenerationAndConflicts(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")}
|
||||
if _, err := Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestBefore := readFile(t, filepath.Join(dir, manifestPath))
|
||||
typesBefore := readFile(t, filepath.Join(dir, "internal/types/typesGen.go"))
|
||||
o.Check = true
|
||||
if _, err := Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
servicePath := filepath.Join(dir, "internal/service/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"} {
|
||||
body := readFile(t, filepath.Join(dir, file))
|
||||
if strings.HasSuffix(file, ".go") {
|
||||
body += "\n// handwritten extension\n"
|
||||
} else {
|
||||
body += "\n# handwritten extension\n"
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, file), body)
|
||||
}
|
||||
api := readFile(t, o.API)
|
||||
api += "\n@server (group: extra)\nservice greet-api {\n @handler ping\n get /ping\n}\n"
|
||||
writeFile(t, o.API, api)
|
||||
if _, err := Generate(o); err == nil {
|
||||
t.Fatal("check accepted stale output")
|
||||
}
|
||||
if readFile(t, filepath.Join(dir, manifestPath)) != manifestBefore {
|
||||
t.Fatal("--check wrote manifest")
|
||||
}
|
||||
o.Check = false
|
||||
if _, err := Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if readFile(t, servicePath) != service {
|
||||
t.Fatal("business code overwritten")
|
||||
}
|
||||
// Retiring a group removes only owned handlers, keeping its business files.
|
||||
writeFile(t, o.API, strings.Split(api, "\n@server (group: extra)")[0])
|
||||
if _, err := Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 {
|
||||
t.Fatal("business stub removed")
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "internal/types/typesGen.go"), typesBefore+"\n// edited generated file\n")
|
||||
before := snapshot(t, dir)
|
||||
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "was edited") {
|
||||
t.Fatalf("did not protect edited output: %v", err)
|
||||
}
|
||||
after := snapshot(t, dir)
|
||||
if !sameFiles(before, after) {
|
||||
t.Fatal("failed regeneration changed files")
|
||||
}
|
||||
}
|
||||
func TestOutputBoundaries(t *testing.T) {
|
||||
for _, kind := range []string{"symlink", "unowned", "lock", "caseCollision"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "app")
|
||||
api := filepath.Join(root, "input/service.api")
|
||||
writeFile(t, api, starterAPI)
|
||||
switch kind {
|
||||
case "symlink":
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(root, "outside")
|
||||
os.MkdirAll(outside, 0755)
|
||||
if err := os.Symlink(outside, filepath.Join(dir, "internal")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "unowned":
|
||||
writeFile(t, filepath.Join(dir, "internal/types/typesGen.go"), "package types // mine\n")
|
||||
case "lock":
|
||||
writeFile(t, filepath.Join(dir, ".goOmni/api.lock"), "someone else")
|
||||
case "caseCollision":
|
||||
writeFile(t, api, starterAPI+"\n@server (group: gReet)\nservice greet-api {\n @handler extra\n get /extra\n}\n")
|
||||
}
|
||||
before := snapshot(t, root)
|
||||
if _, err := Generate(Options{Dir: dir, API: api, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err == nil {
|
||||
t.Fatal("unsafe output accepted")
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, root)) {
|
||||
t.Fatal("conflict changed files")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func snapshot(t *testing.T, root string) map[string]string {
|
||||
t.Helper()
|
||||
out := map[string]string{}
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Type().IsRegular() {
|
||||
data, e := os.ReadFile(path)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
out[rel] = string(data)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
func sameFiles(a, b map[string]string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k, v := range a {
|
||||
if b[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestGeneratedProjectsBuildAndRun(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("consumer module integration")
|
||||
}
|
||||
root := frameworkRoot(t)
|
||||
for _, starter := range []bool{true, false} {
|
||||
name := "contract"
|
||||
if starter {
|
||||
name = "starter"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "project")
|
||||
o := Options{Dir: dir, Module: "example.com/consumer", FrameworkDir: root, LogOutput: "file", LogFormat: "text"}
|
||||
var err error
|
||||
if starter {
|
||||
_, err = New(o)
|
||||
} else {
|
||||
o.API = filepath.Join(root, "examples/contracts/user.api")
|
||||
_, err = Generate(o)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := consumerContractTest
|
||||
if starter {
|
||||
source = consumerStarterTest
|
||||
} else {
|
||||
writeFile(t, filepath.Join(dir, "internal/service/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}
|
||||
`)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "contract_test.go"), source)
|
||||
o.API = filepath.Join(dir, "api/service.api")
|
||||
if !starter {
|
||||
o.API = filepath.Join(dir, "api/user.api")
|
||||
}
|
||||
if _, err = Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-count=1", "./...")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("consumer: %v\n%s", err, output)
|
||||
}
|
||||
t.Log(string(output))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 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)}
|
||||
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},
|
||||
{"PUT","/api/v1/users/7?notify=true¬ify=false",` + "`" + `{"name":"Alice","active":false}` + "`" + `,400},
|
||||
{"PUT","/api/v1/users/7",` + "`" + `{"name":"Alice","active":false,"age":151}` + "`" + `,400},
|
||||
{"GET","/api/v1/users?page=0","",400},
|
||||
{"GET","/api/v1/users?page=1","",501},
|
||||
{"DELETE","/admin/v1/users/7","",501},
|
||||
}{req:=httptest.NewRequest(tc.method,tc.path,strings.NewReader(tc.body));req.Header.Set("Content-Type","application/json");w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,req);if w.Code!=tc.status{t.Fatalf("%s: %d %s",tc.path,w.Code,w.Body.String())};if tc.status==200{var v struct{Data struct{UserID int64 ` + "`json:\"userId\"`" + `;Name string ` + "`json:\"name\"`" + `}};if err:=json.Unmarshal(w.Body.Bytes(),&v);err!=nil||v.Data.UserID!=7||v.Data.Name!="Alice"{t.Fatal(w.Body.String())}}}
|
||||
delete(deps.Middlewares,"Auth");if err:=handler.RegisterRoutes(omni.New(),deps);err==nil{t.Fatal("missing auth hook accepted")}
|
||||
}
|
||||
`
|
||||
332
cmd/go-omni/apigen/compile.go
Normal file
332
cmd/go-omni/apigen/compile.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
goparser "go/parser"
|
||||
gotoken "go/token"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
||||
)
|
||||
|
||||
type Compiled struct {
|
||||
Spec *Spec
|
||||
Bindings map[string]*contract.Binder
|
||||
Document []byte
|
||||
Groups []string
|
||||
}
|
||||
|
||||
var exportedName = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*$`)
|
||||
var goName = regexp.MustCompile(`^[a-zA-Z][A-Za-z0-9]*$`)
|
||||
var lowerName = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`)
|
||||
var serviceName = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-]*$`)
|
||||
var staticSegment = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`)
|
||||
|
||||
func Compile(spec *Spec) (*Compiled, error) {
|
||||
if spec == nil || len(spec.Routes) == 0 || !serviceName.MatchString(spec.Service) {
|
||||
return nil, errors.New("api: valid service and routes required")
|
||||
}
|
||||
definitions := map[string]Type{}
|
||||
for _, t := range spec.Types {
|
||||
if !exportedName.MatchString(t.Name) {
|
||||
return nil, fmt.Errorf("%s: type names must be UpperCamelCase", t.Position)
|
||||
}
|
||||
if _, ok := definitions[t.Name]; ok {
|
||||
return nil, fmt.Errorf("%s: duplicate type %s", t.Position, t.Name)
|
||||
}
|
||||
definitions[t.Name] = t
|
||||
}
|
||||
resolved := map[string]reflect.Type{}
|
||||
resolving := map[string]bool{}
|
||||
var resolve func(string, int) (reflect.Type, error)
|
||||
var expression func(ast.Expr, int) (reflect.Type, error)
|
||||
primitives := map[string]reflect.Type{"string": reflect.TypeOf(""), "bool": reflect.TypeOf(false), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0))}
|
||||
expression = func(e ast.Expr, depth int) (reflect.Type, error) {
|
||||
if depth > 32 {
|
||||
return nil, errors.New("type nesting exceeds 32")
|
||||
}
|
||||
switch e := e.(type) {
|
||||
case *ast.Ident:
|
||||
if t, ok := primitives[e.Name]; ok {
|
||||
return t, nil
|
||||
}
|
||||
return resolve(e.Name, depth+1)
|
||||
case *ast.StarExpr:
|
||||
if _, double := e.X.(*ast.StarExpr); double {
|
||||
return nil, errors.New("multiple pointer levels unsupported")
|
||||
}
|
||||
t, err := expression(e.X, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reflect.PointerTo(t), nil
|
||||
case *ast.ArrayType:
|
||||
if e.Len != nil {
|
||||
return nil, errors.New("use slices, not fixed arrays")
|
||||
}
|
||||
t, err := expression(e.Elt, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reflect.SliceOf(t), nil
|
||||
case *ast.MapType:
|
||||
key, ok := e.Key.(*ast.Ident)
|
||||
if !ok || key.Name != "string" {
|
||||
return nil, errors.New("map keys must be string")
|
||||
}
|
||||
t, err := expression(e.Value, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reflect.MapOf(reflect.TypeOf(""), t), nil
|
||||
default:
|
||||
return nil, errors.New("unsupported type expression")
|
||||
}
|
||||
}
|
||||
resolve = func(name string, depth int) (reflect.Type, error) {
|
||||
if depth > 32 {
|
||||
return nil, errors.New("type nesting exceeds 32")
|
||||
}
|
||||
if t, ok := resolved[name]; ok {
|
||||
return t, nil
|
||||
}
|
||||
if resolving[name] {
|
||||
return nil, fmt.Errorf("recursive type %s is unsupported", name)
|
||||
}
|
||||
typ, ok := definitions[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown type %s", name)
|
||||
}
|
||||
resolving[name] = true
|
||||
defer delete(resolving, name)
|
||||
var fields []reflect.StructField
|
||||
seen := map[string]bool{}
|
||||
for _, f := range typ.Fields {
|
||||
if !exportedName.MatchString(f.Name) || seen[f.Name] {
|
||||
return nil, fmt.Errorf("%s: invalid or duplicate field %s", name, f.Name)
|
||||
}
|
||||
seen[f.Name] = true
|
||||
expr, err := goparser.ParseExpr(f.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := expression(expr, depth+1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s.%s: %w", name, f.Name, err)
|
||||
}
|
||||
fields = append(fields, reflect.StructField{Name: f.Name, Type: t, Tag: reflect.StructTag(f.Tag)})
|
||||
}
|
||||
t := reflect.StructOf(fields)
|
||||
resolved[name] = t
|
||||
return t, nil
|
||||
}
|
||||
c := &Compiled{Spec: spec, Bindings: map[string]*contract.Binder{}}
|
||||
for _, t := range spec.Types {
|
||||
rt, err := resolve(t.Name, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := contract.ForType(rt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", t.Position, err)
|
||||
}
|
||||
c.Bindings[t.Name] = b
|
||||
}
|
||||
api, err := openapi.New(spec.Title, spec.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handlers, paths, shapes, groups := map[string]bool{}, map[string]bool{}, map[string]string{}, map[string]bool{}
|
||||
middlewareSymbols := map[string]string{}
|
||||
for _, r := range spec.Routes {
|
||||
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 r.Group == "" {
|
||||
return nil, errors.New("group required")
|
||||
}
|
||||
for _, part := range strings.Split(r.Group, "/") {
|
||||
if !lowerName.MatchString(part) || gotoken.Lookup(part).IsKeyword() {
|
||||
return nil, fmt.Errorf("invalid lowerCamelCase group %q", r.Group)
|
||||
}
|
||||
}
|
||||
h := r.Group + "/" + strings.ToLower(r.Handler)
|
||||
if handlers[h] {
|
||||
return nil, fmt.Errorf("duplicate handler %s in group %s", r.Handler, r.Group)
|
||||
}
|
||||
handlers[h] = true
|
||||
groups[r.Group] = true
|
||||
mseen := map[string]bool{}
|
||||
for _, m := range r.Middleware {
|
||||
if !goName.MatchString(m) || mseen[m] {
|
||||
return nil, fmt.Errorf("invalid or duplicate middleware %q", m)
|
||||
}
|
||||
mseen[m] = true
|
||||
if strings.EqualFold(m, "defaults") || strings.EqualFold(m, "registryGen") {
|
||||
return nil, fmt.Errorf("reserved middleware name %q", m)
|
||||
}
|
||||
if prior, ok := middlewareSymbols[strings.ToLower(m)]; ok && prior != m {
|
||||
return nil, fmt.Errorf("middleware names %s and %s collide", prior, m)
|
||||
}
|
||||
middlewareSymbols[strings.ToLower(m)] = m
|
||||
}
|
||||
shape, err := routeShape(r.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", r.Position, err)
|
||||
}
|
||||
for _, prior := range spec.Routes {
|
||||
if prior.Method != r.Method || prior.Path == r.Path {
|
||||
continue
|
||||
}
|
||||
if conflictPrefix(prior.Path, r.Path) {
|
||||
return nil, fmt.Errorf("conflicting parameter names at a shared route prefix: %s and %s", prior.Path, r.Path)
|
||||
}
|
||||
}
|
||||
if prior, exists := shapes[shape]; exists && prior != r.Path {
|
||||
return nil, fmt.Errorf("ambiguous route parameter names: %s and %s", prior, r.Path)
|
||||
}
|
||||
shapes[shape] = r.Path
|
||||
pathKey := r.Method + " " + shape
|
||||
if paths[pathKey] {
|
||||
return nil, fmt.Errorf("duplicate route %s", pathKey)
|
||||
}
|
||||
paths[pathKey] = true
|
||||
if r.Path == "/health/live" || r.Path == "/health/ready" || r.Path == "/openapi.json" {
|
||||
return nil, fmt.Errorf("route %s is reserved by the generated server", r.Path)
|
||||
}
|
||||
if r.Method != strings.ToLower(r.Method) {
|
||||
return nil, errors.New("HTTP methods must be lowercase")
|
||||
}
|
||||
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.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 != "" {
|
||||
return nil, errors.New("HEAD must not declare a response body")
|
||||
}
|
||||
op := openapi.Operation{Method: r.Method, Path: r.Path, OperationID: operationID(r), Summary: r.Summary, Responses: map[int]openapi.Response{}}
|
||||
if r.Request != "" {
|
||||
b, ok := c.Bindings[r.Request]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown request %s", r.Request)
|
||||
}
|
||||
op.Parameters = b.Parameters()
|
||||
op.Request = b.BodySchema()
|
||||
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")
|
||||
}
|
||||
}
|
||||
var response *openapi.Schema
|
||||
if r.Response != "" {
|
||||
b, ok := c.Bindings[r.Response]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown response %s", r.Response)
|
||||
}
|
||||
s, err := b.ResponseSchema()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response = openapi.Envelope(s)
|
||||
response.Required = append(response.Required, "data")
|
||||
}
|
||||
op.Responses[r.Status] = openapi.Response{Description: "Success", Schema: response}
|
||||
errorSchema := &openapi.Schema{Type: "object", Required: []string{"code", "message"}, Properties: map[string]*openapi.Schema{"code": {Type: "string"}, "message": {Type: "string"}, "requestId": {Type: "string"}}}
|
||||
for _, code := range []int{400, 413, 415, 500, 501} {
|
||||
op.Responses[code] = openapi.Response{Description: "Error", Schema: errorSchema}
|
||||
}
|
||||
if err = api.Handle(discardRoutes{}, op, func(*httpx.Context) error { return nil }); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", r.Position, err)
|
||||
}
|
||||
}
|
||||
for group := range groups {
|
||||
c.Groups = append(c.Groups, group)
|
||||
}
|
||||
sort.Strings(c.Groups)
|
||||
c.Document, err = api.JSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Show named hooks in documentation without claiming they implement a
|
||||
// particular authentication scheme. Applications own that policy.
|
||||
var doc map[string]any
|
||||
decoder := json.NewDecoder(bytes.NewReader(c.Document))
|
||||
decoder.UseNumber()
|
||||
if err = decoder.Decode(&doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range spec.Routes {
|
||||
if len(r.Middleware) > 0 {
|
||||
path := r.Path
|
||||
for _, part := range strings.Split(r.Path, "/") {
|
||||
if strings.HasPrefix(part, ":") {
|
||||
path = strings.ReplaceAll(path, part, "{"+part[1:]+"}")
|
||||
}
|
||||
}
|
||||
doc["paths"].(map[string]any)[path].(map[string]any)[r.Method].(map[string]any)["x-omni-middleware"] = r.Middleware
|
||||
}
|
||||
}
|
||||
c.Document, err = json.MarshalIndent(doc, "", " ")
|
||||
return c, err
|
||||
}
|
||||
|
||||
type discardRoutes struct{}
|
||||
|
||||
func (discardRoutes) Handle(string, string, httpx.Handler, ...httpx.Middleware) {}
|
||||
func operationID(r Route) string {
|
||||
return strings.ReplaceAll(r.Group, "/", "_") + "_" + upper(r.Handler)
|
||||
}
|
||||
func upper(s string) string { return strings.ToUpper(s[:1]) + s[1:] }
|
||||
func lower(s string) string { return strings.ToLower(s[:1]) + s[1:] }
|
||||
func routeShape(path string) (string, error) {
|
||||
if path == "/" {
|
||||
return path, nil
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return "", errors.New("route must start with /")
|
||||
}
|
||||
parts := strings.Split(path[1:], "/")
|
||||
seen := map[string]bool{}
|
||||
for i, part := range parts {
|
||||
if strings.HasPrefix(part, ":") {
|
||||
name := part[1:]
|
||||
if !lowerName.MatchString(name) || seen[name] {
|
||||
return "", errors.New("invalid/duplicate path parameter")
|
||||
}
|
||||
seen[name] = true
|
||||
parts[i] = ":"
|
||||
} else if !staticSegment.MatchString(part) || part == "." || part == ".." {
|
||||
return "", errors.New("invalid route segment")
|
||||
}
|
||||
}
|
||||
return "/" + strings.Join(parts, "/"), nil
|
||||
}
|
||||
|
||||
func conflictPrefix(left, right string) bool {
|
||||
a, b := strings.Split(left, "/"), strings.Split(right, "/")
|
||||
for i := 0; i < len(a) && i < len(b); i++ {
|
||||
if strings.HasPrefix(a[i], ":") && strings.HasPrefix(b[i], ":") {
|
||||
if a[i] != b[i] {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
458
cmd/go-omni/apigen/generate.go
Normal file
458
cmd/go-omni/apigen/generate.go
Normal file
@@ -0,0 +1,458 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold"
|
||||
"golang.org/x/mod/modfile"
|
||||
"golang.org/x/mod/module"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
const minimumVersion = "v0.7.0-preview"
|
||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||
|
||||
type Options struct {
|
||||
Database, Storage string
|
||||
API, Dir, Module, FrameworkDir, FrameworkVersion, LogOutput, LogFormat string
|
||||
Check bool
|
||||
}
|
||||
type Output struct{ Generated, Created, Preserved, Removed []string }
|
||||
|
||||
// Generate creates or safely updates one contract-driven project. It renders
|
||||
// and validates every output before applying an ownership-checked transaction.
|
||||
func Generate(o Options) (Output, error) {
|
||||
if o.API == "" || o.Dir == "" {
|
||||
return Output{}, errors.New("--api and --dir are required")
|
||||
}
|
||||
spec, err := Parse(o.API)
|
||||
if err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
compiled, err := Compile(spec)
|
||||
if err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
return generate(o, compiled, false)
|
||||
}
|
||||
|
||||
const starterAPI = "syntax = \"v1\"\n\ninfo (\n title: \"Greeting API\"\n version: \"1.0.0\"\n)\n\ntype GreetReq {\n Name string `path:\"name\" binding:\"required,max=80\"`\n}\ntype GreetResp {\n Message string `json:\"message\"`\n}\n\n@server (\n group: greet\n prefix: /api\n)\nservice greet-api {\n @doc \"Greet a visitor\"\n @handler greet\n get /greet/:name (GreetReq) returns (GreetResp)\n}\n"
|
||||
|
||||
// New creates a runnable greeting project, including its editable API file.
|
||||
func New(o Options) (Output, error) {
|
||||
if o.API != "" || o.Check {
|
||||
return Output{}, errors.New("api new does not accept --api or --check")
|
||||
}
|
||||
if o.Dir == "" {
|
||||
return Output{}, errors.New("project directory required")
|
||||
}
|
||||
if entries, err := os.ReadDir(o.Dir); err == nil && len(entries) > 0 {
|
||||
return Output{}, errors.New("api new requires an empty directory")
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return Output{}, err
|
||||
}
|
||||
tokens, err := lex("service.api", starterAPI)
|
||||
if err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
spec := &Spec{Version: "1.0.0", Entry: "service.api", Sources: map[string][]byte{"service.api": []byte(starterAPI)}}
|
||||
p := parser{tokens: tokens, spec: spec}
|
||||
if err = p.parse(); err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
c, err := Compile(spec)
|
||||
if err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
return generate(o, c, true)
|
||||
}
|
||||
|
||||
func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
directory, err := filepath.Abs(o.Dir)
|
||||
if err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
o.Dir = directory
|
||||
if err := resolveProfile(&o); err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
seeds := map[string][]byte{}
|
||||
moduleData, readErr := os.ReadFile(filepath.Join(directory, "go.mod"))
|
||||
if readErr == nil {
|
||||
mf, err := modfile.Parse("go.mod", moduleData, nil)
|
||||
if err != nil || mf.Module == nil {
|
||||
return Output{}, errors.New("invalid project go.mod")
|
||||
}
|
||||
if o.Module != "" && o.Module != mf.Module.Mod.Path {
|
||||
return Output{}, errors.New("--module differs from existing go.mod")
|
||||
}
|
||||
o.Module = mf.Module.Mod.Path
|
||||
found := false
|
||||
requiredVersion := ""
|
||||
for _, r := range mf.Require {
|
||||
if r.Mod.Path == frameworkModule {
|
||||
found = true
|
||||
requiredVersion = r.Mod.Version
|
||||
if o.FrameworkVersion != "" && o.FrameworkVersion != r.Mod.Version {
|
||||
return Output{}, errors.New("existing framework version differs; update go.mod explicitly")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Output{}, errors.New("existing go.mod must require go-omni")
|
||||
}
|
||||
if semver.Compare(requiredVersion, minimumVersion) < 0 {
|
||||
localCurrent := false
|
||||
for _, r := range mf.Replace {
|
||||
if r.Old.Path == frameworkModule && r.New.Version == "" {
|
||||
path := r.New.Path
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(directory, path)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(path, "contract", "contract.go")); err == nil {
|
||||
localCurrent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !localCurrent {
|
||||
return Output{}, fmt.Errorf("existing framework dependency predates API support; use %s or a current local replacement", minimumVersion)
|
||||
}
|
||||
}
|
||||
if o.FrameworkDir != "" {
|
||||
matched := false
|
||||
for _, r := range mf.Replace {
|
||||
if r.Old.Path == frameworkModule && r.New.Version == "" {
|
||||
replacement := r.New.Path
|
||||
if !filepath.IsAbs(replacement) {
|
||||
replacement = filepath.Join(directory, replacement)
|
||||
}
|
||||
matched = canonical(replacement) == canonical(o.FrameworkDir)
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return Output{}, errors.New("existing framework replacement differs; update go.mod explicitly")
|
||||
}
|
||||
}
|
||||
} else if !errors.Is(readErr, os.ErrNotExist) {
|
||||
return Output{}, readErr
|
||||
} else {
|
||||
if o.Module == "" {
|
||||
o.Module = filepath.Base(directory)
|
||||
}
|
||||
data, err := moduleFile(o)
|
||||
if err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
seeds["go.mod"] = data
|
||||
}
|
||||
if err = module.CheckImportPath(o.Module); err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
generated, base, err := renderProject(o, c, starter)
|
||||
if err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
for name, data := range base {
|
||||
seeds[name] = data
|
||||
}
|
||||
// The API source remains developer-owned. A relocated project regenerates
|
||||
// from its own api/ tree, including all imports.
|
||||
entry := "api/" + c.Spec.Entry
|
||||
for name, data := range c.Spec.Sources {
|
||||
seeds["api/"+name] = data
|
||||
}
|
||||
if o.API != "" {
|
||||
source, _ := filepath.Abs(o.API)
|
||||
sourceReal, _ := filepath.EvalSymlinks(source)
|
||||
targetReal := canonical(directory)
|
||||
rel, e := filepath.Rel(targetReal, sourceReal)
|
||||
if e == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
entry = filepath.ToSlash(rel)
|
||||
for name := range c.Spec.Sources {
|
||||
delete(seeds, "api/"+name)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Source collisions must never leave a copied contract different from the
|
||||
// generated code. On updates, use the developer-owned API in the project.
|
||||
for name, data := range seeds {
|
||||
if strings.HasSuffix(name, ".api") {
|
||||
current, e := os.ReadFile(filepath.Join(directory, filepath.FromSlash(name)))
|
||||
if e == nil && string(current) != string(data) {
|
||||
return Output{}, fmt.Errorf("%s already differs; regenerate from that project API instead", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
seeds["README.md"] = []byte(projectREADME(o.Module, entry) + infrastructureREADME(o))
|
||||
for _, files := range []map[string][]byte{generated, seeds} {
|
||||
for name, data := range files {
|
||||
if strings.HasSuffix(name, ".go") {
|
||||
data, err = format.Source(data)
|
||||
if err != nil {
|
||||
return Output{}, fmt.Errorf("format %s: %w", name, err)
|
||||
}
|
||||
files[name] = data
|
||||
}
|
||||
}
|
||||
}
|
||||
return apply(directory, o.Module, entry, generated, seeds, o.Check, o.profile())
|
||||
}
|
||||
|
||||
func moduleFile(o Options) ([]byte, error) {
|
||||
if err := module.CheckImportPath(o.Module); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root := o.FrameworkDir
|
||||
version := o.FrameworkVersion
|
||||
if root == "" && version == "" {
|
||||
wd, _ := os.Getwd()
|
||||
for wd != "" {
|
||||
data, err := os.ReadFile(filepath.Join(wd, "go.mod"))
|
||||
if err == nil {
|
||||
mf, e := modfile.Parse("go.mod", data, nil)
|
||||
if e == nil && mf.Module != nil && mf.Module.Mod.Path == frameworkModule {
|
||||
root = wd
|
||||
}
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(wd)
|
||||
if parent == wd {
|
||||
break
|
||||
}
|
||||
wd = parent
|
||||
}
|
||||
}
|
||||
if version == "" {
|
||||
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" {
|
||||
version = info.Main.Version
|
||||
} else {
|
||||
version = minimumVersion
|
||||
}
|
||||
}
|
||||
if !semver.IsValid(version) || semver.Major(version) != "v0" && semver.Major(version) != "v1" {
|
||||
return nil, errors.New("invalid framework version")
|
||||
}
|
||||
if root == "" && semver.Compare(version, minimumVersion) < 0 {
|
||||
return nil, fmt.Errorf("API generation needs %s or newer; use --framework-dir for unpublished source", minimumVersion)
|
||||
}
|
||||
text := "module " + o.Module + "\n\ngo 1.22.0\n\nrequire " + frameworkModule + " " + version + "\n"
|
||||
if root != "" {
|
||||
root = canonical(root)
|
||||
data, err := os.ReadFile(filepath.Join(root, "go.mod"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mf, err := modfile.Parse("go.mod", data, nil)
|
||||
if err != nil || mf.Module == nil || mf.Module.Mod.Path != frameworkModule {
|
||||
return nil, errors.New("--framework-dir must contain go-omni")
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(root, "contract", "contract.go")); err != nil {
|
||||
return nil, errors.New("local framework lacks API contract support")
|
||||
}
|
||||
relative, err := filepath.Rel(canonical(o.Dir), root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text += "\nreplace " + frameworkModule + " => " + strconv.Quote(filepath.ToSlash(relative)) + "\n"
|
||||
}
|
||||
return []byte(text), nil
|
||||
}
|
||||
func canonical(path string) string {
|
||||
path, _ = filepath.Abs(path)
|
||||
candidate := path
|
||||
var tail []string
|
||||
for {
|
||||
if resolved, err := filepath.EvalSymlinks(candidate); err == nil {
|
||||
for i := len(tail) - 1; i >= 0; i-- {
|
||||
resolved = filepath.Join(resolved, tail[i])
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
parent := filepath.Dir(candidate)
|
||||
if parent == candidate {
|
||||
return path
|
||||
}
|
||||
tail = append(tail, filepath.Base(candidate))
|
||||
candidate = parent
|
||||
}
|
||||
}
|
||||
|
||||
func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map[string][]byte, error) {
|
||||
generated, seeds := map[string][]byte{}, map[string][]byte{}
|
||||
put := func(files map[string][]byte, name, content string) { files[name] = []byte(content) }
|
||||
types := "package types\n\n"
|
||||
for _, t := range c.Spec.Types {
|
||||
types += "type " + t.Name + " struct {\n"
|
||||
for _, f := range t.Fields {
|
||||
types += fmt.Sprintf("%s %s `%s`\n", f.Name, f.Type, f.Tag)
|
||||
}
|
||||
types += "}\n"
|
||||
}
|
||||
put(generated, "internal/types/typesGen.go", generatedHeader+types)
|
||||
put(generated, "docs/openapi.json", string(c.Document)+"\n")
|
||||
put(generated, "internal/handler/openapiGen.go", generatedHeader+fmt.Sprintf("package handler\nimport (\"net/http\"; %q)\nconst OpenAPIDocument = %q\nfunc OpenAPI(c *httpx.Context) error { return c.WriteResponse(200,http.Header{\"Content-Type\": {\"application/json; charset=utf-8\"}},[]byte(OpenAPIDocument)) }\n", frameworkModule+"/httpx", string(c.Document)+"\n"))
|
||||
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"
|
||||
middlewareSet := map[string]bool{}
|
||||
for _, r := range c.Spec.Routes {
|
||||
for _, m := range r.Middleware {
|
||||
middlewareSet[m] = true
|
||||
}
|
||||
}
|
||||
var middlewareNames []string
|
||||
for m := range middlewareSet {
|
||||
middlewareNames = append(middlewareNames, m)
|
||||
}
|
||||
sort.Strings(middlewareNames)
|
||||
for _, m := range middlewareNames {
|
||||
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)
|
||||
routes += fmt.Sprintf("h%d := h%d.New(s%d.New(deps))\n", i, i, i)
|
||||
groupRoutes := []Route{}
|
||||
for _, r := range c.Spec.Routes {
|
||||
if r.Group == group {
|
||||
groupRoutes = append(groupRoutes, r)
|
||||
}
|
||||
}
|
||||
packageName := filepath.Base(group)
|
||||
text := "package " + packageName + "\nimport(\"context\"; \"fmt\";"
|
||||
needsTypes, needsBinder := false, false
|
||||
for _, r := range groupRoutes {
|
||||
needsTypes = needsTypes || r.Request != "" || r.Response != ""
|
||||
needsBinder = needsBinder || r.Request != ""
|
||||
}
|
||||
text += fmt.Sprintf("%q;", frameworkModule+"/httpx")
|
||||
if needsTypes {
|
||||
text += fmt.Sprintf("%q;", o.Module+"/internal/types")
|
||||
}
|
||||
if needsBinder {
|
||||
text += fmt.Sprintf("%q;", frameworkModule+"/contract")
|
||||
}
|
||||
text += ")\n"
|
||||
text += "type Service interface {\n"
|
||||
for _, r := range groupRoutes {
|
||||
text += signature(r) + "\n"
|
||||
}
|
||||
text += "}\ntype Handler struct {service Service}\nfunc New(s Service)*Handler {return &Handler{service:s}}\n"
|
||||
for _, r := range groupRoutes {
|
||||
name := upper(r.Handler)
|
||||
if r.Request != "" {
|
||||
text += fmt.Sprintf("var %sRequest = contract.MustNew[types.%s]()\n", lower(r.Handler), r.Request)
|
||||
}
|
||||
text += fmt.Sprintf("func(h *Handler) %s(c *httpx.Context)error {\n", name)
|
||||
call := "c.Context()"
|
||||
if r.Request != "" {
|
||||
text += fmt.Sprintf("var req types.%s\nif err:=%sRequest.Bind(c.Request(),httpx.RouteParams(c.Context()),&req);err!=nil{return err}\n", r.Request, lower(r.Handler))
|
||||
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)
|
||||
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)
|
||||
}
|
||||
routes += fmt.Sprintf("router.Handle(%q,%q,h%d.%s", strings.ToUpper(r.Method), r.Path, i, name)
|
||||
for _, m := range r.Middleware {
|
||||
routes += fmt.Sprintf(",deps.Middlewares[%q]", m)
|
||||
}
|
||||
routes += ")\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")
|
||||
}
|
||||
service += ")\n"
|
||||
service += "// " + name + " implements the business operation. This file is never overwritten.\nfunc(s *Service) " + 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 != "" {
|
||||
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)
|
||||
}
|
||||
if !hasResponse(groupRoutes) {
|
||||
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/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")
|
||||
}
|
||||
// Avoid unused imports when no route uses a named middleware.
|
||||
imports = strings.Replace(imports, strconv.Quote(frameworkModule+"/httpx")+";", "", 1)
|
||||
put(generated, "internal/handler/routesGen.go", generatedHeader+"package handler\n"+imports+")\n"+routes+"return nil\n}\n")
|
||||
registry := "package middleware\nimport " + strconv.Quote(frameworkModule+"/httpx") + "\nfunc Defaults()map[string]httpx.Middleware{return map[string]httpx.Middleware{\n"
|
||||
for _, m := range middlewareNames {
|
||||
registry += fmt.Sprintf("%q:%s,\n", m, upper(m))
|
||||
put(seeds, "internal/middleware/"+lower(m)+".go", fmt.Sprintf("package middleware\nimport(%q; apierrors %q)\n// %s is a developer-owned hook. Implement it before enabling this route.\nfunc %s(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error {return apierrors.New(501,\"MIDDLEWARE_NOT_IMPLEMENTED\",\"middleware is not implemented\")}}\n", frameworkModule+"/httpx", frameworkModule+"/errors", upper(m), upper(m)))
|
||||
}
|
||||
put(generated, "internal/middleware/registryGen.go", generatedHeader+registry+"}}\n")
|
||||
for name, source := range baseTemplates {
|
||||
content := strings.ReplaceAll(source, "MODULE", o.Module)
|
||||
content = strings.ReplaceAll(content, "LOGFORMAT", o.LogFormat)
|
||||
content = strings.ReplaceAll(content, "LOGOUTPUT", o.LogOutput)
|
||||
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)
|
||||
}
|
||||
|
||||
infra, err := scaffold.APIInfrastructure(o.Module, o.Database, o.Storage, o.LogOutput, o.LogFormat)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for name, data := range infra {
|
||||
seeds[name] = data
|
||||
}
|
||||
if o.Storage == "local" {
|
||||
for _, r := range c.Spec.Routes {
|
||||
if r.Path == "/storage/object" {
|
||||
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)
|
||||
}
|
||||
return generated, seeds, nil
|
||||
}
|
||||
func hasResponse(routes []Route) bool {
|
||||
for _, r := range routes {
|
||||
if r.Response != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func signature(r Route) string {
|
||||
s := upper(r.Handler) + "(ctx context.Context"
|
||||
if r.Request != "" {
|
||||
s += ",req *types." + r.Request
|
||||
}
|
||||
s += ") "
|
||||
if r.Response != "" {
|
||||
s += "(*types." + r.Response + ",error)"
|
||||
} else {
|
||||
s += "error"
|
||||
}
|
||||
return s
|
||||
}
|
||||
460
cmd/go-omni/apigen/parse.go
Normal file
460
cmd/go-omni/apigen/parse.go
Normal file
@@ -0,0 +1,460 @@
|
||||
// Package apigen implements go-omni's deliberately bounded go-zero-style API
|
||||
// dialect. It generates Omni handlers, never go-zero or transport dependencies.
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/scanner"
|
||||
)
|
||||
|
||||
type Spec struct {
|
||||
Title, Version, Service string
|
||||
Types []Type
|
||||
Routes []Route
|
||||
Sources map[string][]byte // relative to the entry API's directory
|
||||
Entry string
|
||||
}
|
||||
type Type struct {
|
||||
Name string
|
||||
Fields []Field
|
||||
Position string
|
||||
}
|
||||
type Field struct{ Name, Type, Tag string }
|
||||
type Route struct {
|
||||
Method, Path, Request, Response, Handler, Group, Summary string
|
||||
Middleware []string
|
||||
Status int
|
||||
Position string
|
||||
}
|
||||
type token struct {
|
||||
text, position string
|
||||
kind rune
|
||||
}
|
||||
type parser struct {
|
||||
tokens []token
|
||||
at int
|
||||
spec *Spec
|
||||
imports []string
|
||||
}
|
||||
|
||||
func Parse(path string) (*Spec, error) {
|
||||
entry, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, err = filepath.EvalSymlinks(entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root := filepath.Dir(entry)
|
||||
spec := &Spec{Version: "1.0.0", Sources: map[string][]byte{}, Entry: filepath.Base(entry)}
|
||||
visiting, visited := map[string]bool{}, map[string]bool{}
|
||||
total := 0
|
||||
var visit func(string, int) error
|
||||
visit = func(name string, depth int) error {
|
||||
if depth > 32 || len(visited)+len(visiting) >= 64 {
|
||||
return errors.New("api: import limit exceeded")
|
||||
}
|
||||
real, err := filepath.EvalSymlinks(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, real)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return errors.New("api: imports must stay within the entry API directory")
|
||||
}
|
||||
if filepath.Ext(real) != ".api" {
|
||||
return errors.New("api: expected .api file")
|
||||
}
|
||||
if visiting[real] {
|
||||
return fmt.Errorf("api: circular import %s", rel)
|
||||
}
|
||||
if visited[real] {
|
||||
return nil
|
||||
}
|
||||
visiting[real] = true
|
||||
defer delete(visiting, real)
|
||||
f, err := os.Open(real)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total += len(data)
|
||||
if len(data) > 1<<20 || total > 8<<20 {
|
||||
return errors.New("api: source size limit exceeded")
|
||||
}
|
||||
tokens, err := lex(rel, string(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := &parser{tokens: tokens, spec: spec}
|
||||
if err = p.parse(); err != nil {
|
||||
return err
|
||||
}
|
||||
spec.Sources[filepath.ToSlash(rel)] = data
|
||||
for _, imp := range p.imports {
|
||||
if filepath.IsAbs(imp) || strings.Contains(imp, "\\") {
|
||||
return errors.New("api: imports must use relative slash paths")
|
||||
}
|
||||
if err = visit(filepath.Join(filepath.Dir(real), filepath.FromSlash(imp)), depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
visited[real] = true
|
||||
return nil
|
||||
}
|
||||
if err = visit(entry, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec.Service == "" || len(spec.Routes) == 0 {
|
||||
return nil, errors.New("api: at least one service route required")
|
||||
}
|
||||
if spec.Title == "" {
|
||||
spec.Title = spec.Service
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
func lex(name, source string) ([]token, error) {
|
||||
var s scanner.Scanner
|
||||
s.Init(strings.NewReader(source))
|
||||
s.Filename = name
|
||||
s.Mode = scanner.ScanIdents | scanner.ScanInts | scanner.ScanStrings | scanner.ScanRawStrings | scanner.ScanComments | scanner.SkipComments
|
||||
s.Whitespace ^= 1 << '\n'
|
||||
var scanErr error
|
||||
s.Error = func(s *scanner.Scanner, message string) {
|
||||
if scanErr == nil {
|
||||
scanErr = fmt.Errorf("%s: %s", s.Position, message)
|
||||
}
|
||||
}
|
||||
var out []token
|
||||
for {
|
||||
kind := s.Scan()
|
||||
out = append(out, token{s.TokenText(), s.Position.String(), kind})
|
||||
if kind == scanner.EOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, scanErr
|
||||
}
|
||||
func (p *parser) peek() string { return p.tokens[p.at].text }
|
||||
func (p *parser) next() token {
|
||||
t := p.tokens[p.at]
|
||||
if t.kind != scanner.EOF {
|
||||
p.at++
|
||||
}
|
||||
return t
|
||||
}
|
||||
func (p *parser) skip() {
|
||||
for p.peek() == "\n" || p.peek() == ";" {
|
||||
p.next()
|
||||
}
|
||||
}
|
||||
func (p *parser) err(message string) error {
|
||||
return fmt.Errorf("%s: %s (got %q)", p.tokens[p.at].position, message, p.peek())
|
||||
}
|
||||
func (p *parser) expect(s string) error {
|
||||
if p.peek() != s {
|
||||
return p.err("expected " + strconv.Quote(s))
|
||||
}
|
||||
p.next()
|
||||
return nil
|
||||
}
|
||||
func (p *parser) ident() (string, error) {
|
||||
if p.tokens[p.at].kind != scanner.Ident {
|
||||
return "", p.err("expected identifier")
|
||||
}
|
||||
return p.next().text, nil
|
||||
}
|
||||
func (p *parser) quoted() (string, error) {
|
||||
if p.tokens[p.at].kind != scanner.String {
|
||||
return "", p.err("expected quoted string")
|
||||
}
|
||||
return strconv.Unquote(p.next().text)
|
||||
}
|
||||
func (p *parser) parse() error {
|
||||
server := map[string]string{}
|
||||
syntaxSeen := false
|
||||
for p.skip(); p.tokens[p.at].kind != scanner.EOF; p.skip() {
|
||||
switch p.peek() {
|
||||
case "syntax":
|
||||
if syntaxSeen {
|
||||
return p.err("duplicate syntax")
|
||||
}
|
||||
syntaxSeen = true
|
||||
p.next()
|
||||
if err := p.expect("="); err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := p.quoted()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v != "v1" {
|
||||
return p.err("only syntax v1 supported")
|
||||
}
|
||||
case "info":
|
||||
p.next()
|
||||
m, err := p.metadata()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range m {
|
||||
switch k {
|
||||
case "title":
|
||||
p.spec.Title = v
|
||||
case "version":
|
||||
p.spec.Version = v
|
||||
case "desc", "description", "author", "email":
|
||||
default:
|
||||
return p.err("unsupported info property " + k)
|
||||
}
|
||||
}
|
||||
case "import":
|
||||
p.next()
|
||||
if p.peek() == "(" {
|
||||
p.next()
|
||||
for p.skip(); p.peek() != ")"; p.skip() {
|
||||
v, err := p.quoted()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.imports = append(p.imports, v)
|
||||
}
|
||||
p.next()
|
||||
} else {
|
||||
v, err := p.quoted()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.imports = append(p.imports, v)
|
||||
}
|
||||
case "type":
|
||||
p.next()
|
||||
if p.peek() == "(" {
|
||||
p.next()
|
||||
for p.skip(); p.peek() != ")"; p.skip() {
|
||||
if err := p.parseType(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
p.next()
|
||||
} else {
|
||||
if err := p.parseType(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "@":
|
||||
p.next()
|
||||
if err := p.expect("server"); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(server) != 0 {
|
||||
return p.err("duplicate server block")
|
||||
}
|
||||
m, err := p.metadata()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server = m
|
||||
for k := range m {
|
||||
if k != "prefix" && k != "group" && k != "middleware" {
|
||||
return p.err("unsupported @server option " + k + "; wire authentication through named middleware")
|
||||
}
|
||||
}
|
||||
case "service":
|
||||
if err := p.parseService(server); err != nil {
|
||||
return err
|
||||
}
|
||||
server = map[string]string{}
|
||||
default:
|
||||
return p.err("unsupported declaration")
|
||||
}
|
||||
}
|
||||
if len(server) > 0 {
|
||||
return p.err("@server must precede a service")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (p *parser) metadata() (map[string]string, error) {
|
||||
if err := p.expect("("); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]string{}
|
||||
for p.skip(); p.peek() != ")"; p.skip() {
|
||||
key, err := p.ident()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = p.expect(":"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value := ""
|
||||
if p.tokens[p.at].kind == scanner.String {
|
||||
value, err = p.quoted()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
for p.peek() != "\n" && p.peek() != ";" && p.peek() != ")" && p.tokens[p.at].kind != scanner.EOF {
|
||||
value += p.next().text
|
||||
}
|
||||
}
|
||||
if value == "" {
|
||||
return nil, p.err("metadata value required")
|
||||
}
|
||||
if _, exists := out[key]; exists {
|
||||
return nil, p.err("duplicate metadata " + key)
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
p.next()
|
||||
return out, nil
|
||||
}
|
||||
func (p *parser) parseType() error {
|
||||
pos := p.tokens[p.at].position
|
||||
name, err := p.ident()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.skip()
|
||||
if err = p.expect("{"); err != nil {
|
||||
return err
|
||||
}
|
||||
typ := Type{Name: name, Position: pos}
|
||||
for p.skip(); p.peek() != "}"; p.skip() {
|
||||
name, err := p.ident()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expression := ""
|
||||
for p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" && p.tokens[p.at].kind != scanner.RawString && p.tokens[p.at].kind != scanner.EOF {
|
||||
expression += p.next().text
|
||||
}
|
||||
if expression == "" {
|
||||
return p.err("field type required; embedded types are not supported")
|
||||
}
|
||||
if p.tokens[p.at].kind != scanner.RawString {
|
||||
return p.err("field source tag required")
|
||||
}
|
||||
tag, err := strconv.Unquote(p.next().text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
typ.Fields = append(typ.Fields, Field{Name: name, Type: expression, Tag: tag})
|
||||
}
|
||||
p.next()
|
||||
p.spec.Types = append(p.spec.Types, typ)
|
||||
return nil
|
||||
}
|
||||
func (p *parser) parseService(server map[string]string) error {
|
||||
p.next()
|
||||
name, err := p.ident()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for p.peek() == "-" {
|
||||
p.next()
|
||||
part, e := p.ident()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
name += "-" + part
|
||||
}
|
||||
if p.spec.Service != "" && p.spec.Service != name {
|
||||
return p.err("one service name per entry API is supported")
|
||||
}
|
||||
p.spec.Service = name
|
||||
p.skip()
|
||||
if err = p.expect("{"); err != nil {
|
||||
return err
|
||||
}
|
||||
for p.skip(); p.peek() != "}"; p.skip() {
|
||||
route := Route{Group: server["group"], Status: 200, Position: p.tokens[p.at].position}
|
||||
if route.Group == "" {
|
||||
route.Group = "api"
|
||||
}
|
||||
if server["middleware"] != "" {
|
||||
route.Middleware = strings.Split(server["middleware"], ",")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for p.peek() == "@" {
|
||||
p.next()
|
||||
annotation, e := p.ident()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if seen[annotation] {
|
||||
return p.err("duplicate route annotation")
|
||||
}
|
||||
seen[annotation] = true
|
||||
switch annotation {
|
||||
case "handler":
|
||||
route.Handler, e = p.ident()
|
||||
case "doc":
|
||||
route.Summary, e = p.quoted()
|
||||
case "status":
|
||||
route.Status, e = strconv.Atoi(p.next().text)
|
||||
default:
|
||||
return p.err("unsupported route annotation " + annotation)
|
||||
}
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
p.skip()
|
||||
}
|
||||
if route.Handler == "" {
|
||||
return p.err("@handler required")
|
||||
}
|
||||
route.Method, err = p.ident()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := ""
|
||||
for p.peek() != "\n" && p.peek() != ";" && p.peek() != "(" && p.peek() != "returns" && p.peek() != "}" && p.tokens[p.at].kind != scanner.EOF {
|
||||
path += p.next().text
|
||||
}
|
||||
route.Path = server["prefix"] + path
|
||||
if p.peek() == "(" {
|
||||
route.Request, err = p.routeType()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.peek() == "returns" {
|
||||
p.next()
|
||||
route.Response, err = p.routeType()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" {
|
||||
return p.err("unexpected route suffix")
|
||||
}
|
||||
if route.Response == "" && !seen["status"] {
|
||||
route.Status = 204
|
||||
}
|
||||
p.spec.Routes = append(p.spec.Routes, route)
|
||||
}
|
||||
p.next()
|
||||
return nil
|
||||
}
|
||||
func (p *parser) routeType() (string, error) {
|
||||
if err := p.expect("("); err != nil {
|
||||
return "", err
|
||||
}
|
||||
name, err := p.ident()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return name, p.expect(")")
|
||||
}
|
||||
73
cmd/go-omni/apigen/profile.go
Normal file
73
cmd/go-omni/apigen/profile.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type projectProfile struct {
|
||||
Database string `json:"database"`
|
||||
Storage string `json:"storage"`
|
||||
LogOutput string `json:"logOutput"`
|
||||
LogFormat string `json:"logFormat"`
|
||||
}
|
||||
|
||||
func (o Options) profile() projectProfile {
|
||||
return projectProfile{o.Database, o.Storage, o.LogOutput, o.LogFormat}
|
||||
}
|
||||
|
||||
// Infrastructure files belong to the developer. Persist the original selection
|
||||
// so a plain regeneration cannot silently change adapters or repair a missing
|
||||
// file with settings from another profile.
|
||||
func resolveProfile(o *Options) error {
|
||||
defaults := projectProfile{"none", "none", "stderr", "json"}
|
||||
data, err := os.ReadFile(filepath.Join(o.Dir, manifestPath))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
existing := err == nil
|
||||
if existing {
|
||||
var previous manifest
|
||||
if err = json.Unmarshal(data, &previous); err != nil {
|
||||
return err
|
||||
}
|
||||
if previous.Profile != nil {
|
||||
defaults = *previous.Profile
|
||||
}
|
||||
}
|
||||
if o.Database == "memory" {
|
||||
o.Database = "none"
|
||||
}
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
value *string
|
||||
fallback string
|
||||
}{
|
||||
{"database", &o.Database, defaults.Database}, {"storage", &o.Storage, defaults.Storage},
|
||||
{"log-output", &o.LogOutput, defaults.LogOutput}, {"log-format", &o.LogFormat, defaults.LogFormat},
|
||||
} {
|
||||
if *item.value == "" {
|
||||
*item.value = item.fallback
|
||||
} else if existing && *item.value != item.fallback {
|
||||
return fmt.Errorf("--%s differs from the original project profile; infrastructure is developer-owned, generate a new project to compare and migrate it explicitly", item.name)
|
||||
}
|
||||
}
|
||||
if o.Database != "none" && o.Database != "mysql" {
|
||||
return errors.New("--database must be none, memory or mysql")
|
||||
}
|
||||
switch o.Storage {
|
||||
case "none", "local", "s3", "r2", "oss":
|
||||
default:
|
||||
return errors.New("--storage must be none, local, s3, r2 or oss")
|
||||
}
|
||||
if o.LogOutput != "stderr" && o.LogOutput != "stdout" && o.LogOutput != "file" {
|
||||
return errors.New("--log-output must be stdout, stderr or file")
|
||||
}
|
||||
if o.LogFormat != "json" && o.LogFormat != "text" {
|
||||
return errors.New("--log-format must be json or text")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
98
cmd/go-omni/apigen/profile_test.go
Normal file
98
cmd/go-omni/apigen/profile_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInfrastructureProfileOwnership(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "project")
|
||||
o := Options{Dir: dir, Module: "example.com/profile", FrameworkDir: frameworkRoot(t), Database: "mysql", Storage: "local", LogOutput: "file"}
|
||||
if _, err := New(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o = Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}
|
||||
if _, err := Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := snapshot(t, dir)
|
||||
for _, changed := range []Options{
|
||||
{Database: "none"}, {Storage: "s3"}, {LogOutput: "stdout"}, {LogFormat: "text"},
|
||||
} {
|
||||
changed.Dir, changed.API = dir, o.API
|
||||
if _, err := Generate(changed); err == nil {
|
||||
t.Fatal("profile switch accepted")
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("profile rejection changed project")
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Generate(o); err == nil {
|
||||
t.Fatal("check accepted missing infrastructure")
|
||||
}
|
||||
o.Check = false
|
||||
if _, err := Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("profile reconstruction changed project")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIInfrastructureConsumers(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
|
||||
t.Skip("consumer module integration")
|
||||
}
|
||||
for _, db := range []string{"none", "mysql"} {
|
||||
for _, storage := range []string{"none", "local", "s3", "r2", "oss"} {
|
||||
t.Run(db+"/"+storage, func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "project")
|
||||
if _, err := New(Options{Dir: dir, Module: "example.com/profile", FrameworkDir: frameworkRoot(t), Database: db, Storage: storage, LogOutput: "file"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
test := `package settings
|
||||
import("testing";"os")
|
||||
func TestConfiguration(t *testing.T){
|
||||
t.Setenv("GO_OMNI_DB_DSN","test:test@tcp(127.0.0.1:1)/test")
|
||||
t.Setenv("GO_OMNI_STORAGE_SIGNING_KEY","01234567890123456789012345678901")
|
||||
t.Setenv("GO_OMNI_STORAGE_ACCESS_KEY","test");t.Setenv("GO_OMNI_STORAGE_SECRET_KEY","test")
|
||||
t.Setenv("GO_OMNI_STORAGE_BUCKET","test");t.Setenv("GO_OMNI_STORAGE_ENDPOINT","ENDPOINT")
|
||||
t.Setenv("GO_OMNI_STORAGE_ACCOUNT_ID","01234567890123456789012345678901")
|
||||
cfg,err:=Load("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
|
||||
if cfg.Server.Log.File==""{t.Fatal("file logger absent")}
|
||||
t.Setenv("GO_OMNI_LOG_OUTPUT","stdout");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!=""{t.Fatalf("override: %v",err)}
|
||||
_=os.Getenv("unused")
|
||||
}`
|
||||
endpoint := ""
|
||||
if storage == "s3" {
|
||||
endpoint = "127.0.0.1:9000"
|
||||
}
|
||||
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+`"`))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-count=1", "./...")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
160
cmd/go-omni/apigen/templates.go
Normal file
160
cmd/go-omni/apigen/templates.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package apigen
|
||||
|
||||
import "fmt"
|
||||
|
||||
var baseTemplates = map[string]string{
|
||||
"cmd/server/main.go": `package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"MODULE/internal/handler"
|
||||
"MODULE/internal/servicecontext"
|
||||
"MODULE/internal/settings"
|
||||
)
|
||||
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}
|
||||
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}
|
||||
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)
|
||||
}
|
||||
`,
|
||||
".gitignore": "/server\n/var/\n.env\n.env.*\n!.env.example\n/.goOmni/api.lock\n",
|
||||
}
|
||||
|
||||
func projectREADME(module, entry string) string {
|
||||
return fmt.Sprintf(`# %s
|
||||
|
||||
This project is generated from an editable API contract. Business code uses
|
||||
standard context.Context; generated HTTP handlers use Omni's transport-neutral httpx.
|
||||
|
||||
## Run
|
||||
|
||||
`+"```sh"+`
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
`+"```"+`
|
||||
|
||||
GET /health/live and /health/ready check the running service. GET /openapi.json
|
||||
serves the same document as docs/openapi.json. Bind address defaults to localhost.
|
||||
Set GO_OMNI_TRANSPORT=native to select the native transport without changing services.
|
||||
The greeting starter works immediately; other new operation stubs return 501 until
|
||||
their business logic is implemented. Authentication hooks also fail closed with 501.
|
||||
|
||||
## Develop
|
||||
|
||||
1. Edit %s and its relative imports.
|
||||
2. Run the same go-omni CLI version as the framework dependency:
|
||||
|
||||
`+"```sh"+`
|
||||
go-omni api validate --api %s
|
||||
go-omni api generate --api %s --dir .
|
||||
go-omni api generate --api %s --dir . --check
|
||||
go test ./...
|
||||
`+"```"+`
|
||||
|
||||
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
|
||||
by the generator; manual edits cause a conflict instead of being overwritten.
|
||||
Commit .goOmni/apiManifest.json alongside the generated files. Do not edit it.
|
||||
Avoid editing files while generation is running; api.lock serializes generators.
|
||||
|
||||
Routes and types that disappear are removed from generated output. Handwritten
|
||||
business files remain: remove/adapt obsolete implementations explicitly if an
|
||||
API change makes their old types or signatures fail to compile. Every regeneration
|
||||
must be followed by go test ./... . Do not delete service files to change contracts.
|
||||
|
||||
## Layout
|
||||
|
||||
`+"```text"+`
|
||||
api/ # editable contract and imported types
|
||||
cmd/server/main.go # startup, lifecycle and route registration
|
||||
etc/config.yaml # lowerCamelCase configuration
|
||||
internal/settings/ # defaults -> file -> environment
|
||||
internal/servicecontext/ # dependency initialization and cleanup
|
||||
internal/types/typesGen.go # generated request/response types
|
||||
internal/handler/<group>/ # generated binding and service adapters
|
||||
internal/service/<group>/ # developer-owned business operations
|
||||
internal/repository/<group>/ # developer-owned persistence contracts
|
||||
internal/middleware/ # named hooks and generated registry
|
||||
docs/openapi.json # generated OpenAPI 3.0.3
|
||||
.goOmni/apiManifest.json # ownership and content hashes
|
||||
`+"```"+`
|
||||
|
||||
The API response describes data only. Omni adds code, message and requestId.
|
||||
Do not define the envelope again in each response. 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.
|
||||
|
||||
Fields require exactly one source tag: json, path, form (URL query only), header.
|
||||
All are required by presence unless optional, omitempty or default is specified.
|
||||
Zero and false are valid present values; binding:"required" rejects scalar zero.
|
||||
Use *T with optional when business logic needs to distinguish missing from zero.
|
||||
Pointers may carry null; non-pointer null is rejected. Unknown/duplicate JSON
|
||||
fields and duplicate scalar query/header values are rejected. Arrays in the query
|
||||
use repeated keys. Enum/range/default and nested fields share the documentation
|
||||
and binding compiler. Responses are expected to honor their declared constraints;
|
||||
they are not validated on every response. Do not return nil slices/maps for a
|
||||
non-nullable collection (initialize empty collections instead).
|
||||
|
||||
Named middleware is composed in declaration order (first is outermost). Implement
|
||||
the generated hooks or replace entries in servicecontext; 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
|
||||
composed at creation; the original profile is retained on regeneration.
|
||||
`, module, entry, entry, entry, entry)
|
||||
}
|
||||
|
||||
func infrastructureREADME(o Options) string {
|
||||
return fmt.Sprintf(`
|
||||
## Infrastructure
|
||||
|
||||
Selected database: %s; storage: %s; logging: %s/%s.
|
||||
MySQL uses database/sql through deps.DB, including InTx and a readiness probe.
|
||||
Set GO_OMNI_DB_DSN; pool settings live in etc/config.yaml and GO_OMNI_DB_*.
|
||||
No tables are invented or migrated automatically from request/response DTOs.
|
||||
The none/memory selection opens no database; implement repositories per business.
|
||||
|
||||
With storage enabled, services receive deps.Files, Uploads, Downloads and
|
||||
UploadLinks capability interfaces. Local signed GET/HEAD/PUT uses /storage/object;
|
||||
set GO_OMNI_STORAGE_SIGNING_KEY (32+ bytes) and GO_OMNI_STORAGE_PUBLIC_URL.
|
||||
Cloud credentials use GO_OMNI_STORAGE_ACCESS_KEY/SECRET_KEY; set bucket and endpoint
|
||||
or R2 accountId as appropriate. Signed URLs grant temporary access: authenticate
|
||||
and authorize before issuing them. No public upload-link route is added.
|
||||
Storage failures during startup close an already-open database; Close is idempotent.
|
||||
|
||||
Database/storage/logging selection is recorded in .goOmni/apiManifest.json.
|
||||
Changing selection through generation flags is rejected on an existing project;
|
||||
edit your owned configuration and initialization explicitly, or generate a fresh
|
||||
project for comparison. New routes still regenerate without repeating these flags.
|
||||
`, o.Database, o.Storage, o.LogOutput, o.LogFormat)
|
||||
}
|
||||
295
cmd/go-omni/apigen/write.go
Normal file
295
cmd/go-omni/apigen/write.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const manifestPath = ".goOmni/apiManifest.json"
|
||||
|
||||
type manifest struct {
|
||||
Profile *projectProfile `json:"profile,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Module string `json:"module"`
|
||||
Entry string `json:"entry"`
|
||||
Generated map[string]string `json:"generated"`
|
||||
}
|
||||
type change struct {
|
||||
name string
|
||||
before, after []byte
|
||||
existed, remove bool
|
||||
mode os.FileMode
|
||||
}
|
||||
|
||||
func apply(root, module, entry string, generated, seeds map[string][]byte, check bool, profile projectProfile) (Output, error) {
|
||||
var result Output
|
||||
if info, err := os.Lstat(root); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return result, errors.New("api: output root must be a real directory")
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return result, err
|
||||
}
|
||||
root = canonical(root)
|
||||
names := map[string]bool{}
|
||||
caseNames := map[string]string{}
|
||||
for _, files := range []map[string][]byte{generated, seeds} {
|
||||
for name := range files {
|
||||
if names[name] {
|
||||
return result, fmt.Errorf("duplicate output %s", name)
|
||||
}
|
||||
names[name] = true
|
||||
folded := strings.ToLower(name)
|
||||
if old, ok := caseNames[folded]; ok {
|
||||
return result, fmt.Errorf("case-insensitive path collision: %s and %s", old, name)
|
||||
}
|
||||
caseNames[folded] = name
|
||||
if err := safePath(root, name); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := safePath(root, manifestPath); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if !check {
|
||||
if err := os.MkdirAll(filepath.Join(root, ".goOmni"), 0755); err != nil {
|
||||
return result, err
|
||||
}
|
||||
lock := filepath.Join(root, ".goOmni", "api.lock")
|
||||
f, err := os.OpenFile(lock, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("api: acquire generation lock (remove stale api.lock only after checking no generator is running): %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintf(f, "pid=%d\n", os.Getpid())
|
||||
_ = f.Close()
|
||||
defer os.Remove(lock)
|
||||
}
|
||||
old := manifest{Generated: map[string]string{}}
|
||||
oldData, err := os.ReadFile(filepath.Join(root, manifestPath))
|
||||
if err == nil {
|
||||
if err = json.Unmarshal(oldData, &old); err != nil {
|
||||
return result, fmt.Errorf("invalid ownership manifest: %w", err)
|
||||
}
|
||||
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 {
|
||||
return result, errors.New("api: manifest version/module/entry differs; one API entry owns this project")
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return result, err
|
||||
}
|
||||
if oldData == nil {
|
||||
if err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if errors.Is(walkErr, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() && d.Name() == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if !d.IsDir() && strings.HasSuffix(d.Name(), ".go") {
|
||||
return fmt.Errorf("api: %s belongs to an existing Go layout; generate into a new directory", path)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
next := manifest{Profile: &profile, Version: 1, Module: module, Entry: entry, Generated: map[string]string{}}
|
||||
for name, data := range generated {
|
||||
next.Generated[name] = digest(data)
|
||||
}
|
||||
var changes []change
|
||||
// Check ALL previous outputs, even stale ones, before writing any new file.
|
||||
for name, hash := range old.Generated {
|
||||
if !ownedPath(name) {
|
||||
return result, fmt.Errorf("api: invalid owned path %q", name)
|
||||
}
|
||||
if err = safePath(root, name); err != nil {
|
||||
return result, err
|
||||
}
|
||||
data, e := os.ReadFile(filepath.Join(root, filepath.FromSlash(name)))
|
||||
if errors.Is(e, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if e != nil {
|
||||
return result, e
|
||||
}
|
||||
if digest(data) != hash {
|
||||
return result, fmt.Errorf("api: generated file %s was edited; move custom code into developer-owned files before regenerating", name)
|
||||
}
|
||||
if _, exists := generated[name]; !exists {
|
||||
info, _ := os.Stat(filepath.Join(root, filepath.FromSlash(name)))
|
||||
changes = append(changes, change{name: name, before: data, existed: true, remove: true, mode: info.Mode().Perm()})
|
||||
result.Removed = append(result.Removed, name)
|
||||
}
|
||||
}
|
||||
paths := make([]string, 0, len(names))
|
||||
for name := range names {
|
||||
paths = append(paths, name)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, name := range paths {
|
||||
target := filepath.Join(root, filepath.FromSlash(name))
|
||||
current, err := os.ReadFile(target)
|
||||
exists := err == nil
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return result, err
|
||||
}
|
||||
content, isGenerated := generated[name]
|
||||
if !isGenerated {
|
||||
content = seeds[name]
|
||||
}
|
||||
if exists && !isGenerated {
|
||||
result.Preserved = append(result.Preserved, name)
|
||||
continue
|
||||
}
|
||||
if exists && isGenerated {
|
||||
if _, owned := old.Generated[name]; !owned {
|
||||
return result, fmt.Errorf("api: refusing to adopt existing unowned file %s", name)
|
||||
}
|
||||
}
|
||||
if isGenerated {
|
||||
result.Generated = append(result.Generated, name)
|
||||
} else {
|
||||
result.Created = append(result.Created, name)
|
||||
}
|
||||
if exists && bytes.Equal(current, content) {
|
||||
continue
|
||||
}
|
||||
mode := os.FileMode(0644)
|
||||
if exists {
|
||||
info, e := os.Stat(target)
|
||||
if e != nil {
|
||||
return result, e
|
||||
}
|
||||
mode = info.Mode().Perm()
|
||||
}
|
||||
changes = append(changes, change{name: name, before: current, after: content, existed: exists, mode: mode})
|
||||
}
|
||||
data, err := json.MarshalIndent(next, "", " ")
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if !bytes.Equal(oldData, data) {
|
||||
changes = append(changes, change{name: manifestPath, before: oldData, after: data, existed: oldData != nil, mode: 0644})
|
||||
}
|
||||
sort.Strings(result.Removed)
|
||||
if check {
|
||||
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, ", "))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
// A rollback restores bytes/modes if an ordinary filesystem write fails.
|
||||
// File replacement is atomic; the whole directory is not crash-atomic.
|
||||
var applied []change
|
||||
for _, c := range changes {
|
||||
if err = safePath(root, c.name); err == nil {
|
||||
current, e := os.ReadFile(filepath.Join(root, filepath.FromSlash(c.name)))
|
||||
if c.existed {
|
||||
if e != nil || !bytes.Equal(current, c.before) {
|
||||
err = fmt.Errorf("file changed during generation: %s", c.name)
|
||||
}
|
||||
} else if !errors.Is(e, os.ErrNotExist) {
|
||||
err = fmt.Errorf("file appeared during generation: %s", c.name)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
if c.remove {
|
||||
err = os.Remove(filepath.Join(root, filepath.FromSlash(c.name)))
|
||||
} else {
|
||||
err = atomicWrite(root, c.name, c.after, c.mode)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
for i := len(applied) - 1; i >= 0; i-- {
|
||||
previous := applied[i]
|
||||
var rollback error
|
||||
if previous.existed {
|
||||
rollback = atomicWrite(root, previous.name, previous.before, previous.mode)
|
||||
} else {
|
||||
rollback = os.Remove(filepath.Join(root, filepath.FromSlash(previous.name)))
|
||||
}
|
||||
err = errors.Join(err, rollback)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
applied = append(applied, c)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
|
||||
func ownedPath(name string) bool {
|
||||
if name == "docs/openapi.json" || name == "internal/types/typesGen.go" || name == "internal/handler/routesGen.go" || name == "internal/handler/openapiGen.go" || name == "internal/middleware/registryGen.go" {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(name, "internal/handler/") && strings.HasSuffix(name, "/handlersGen.go")
|
||||
}
|
||||
func safePath(root, name string) error {
|
||||
if filepath.IsAbs(name) || strings.Contains(name, "\\") || filepath.ToSlash(filepath.Clean(name)) != name || name == "." || strings.HasPrefix(name, "../") {
|
||||
return fmt.Errorf("api: unsafe output path %q", name)
|
||||
}
|
||||
path := root
|
||||
parts := strings.Split(name, "/")
|
||||
for i, part := range parts {
|
||||
path = filepath.Join(path, part)
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("api: refusing symlink %s", path)
|
||||
}
|
||||
if i < len(parts)-1 && !info.IsDir() {
|
||||
return fmt.Errorf("api: expected directory %s", path)
|
||||
}
|
||||
if i == len(parts)-1 && !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("api: expected regular file %s", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func atomicWrite(root, name string, data []byte, mode os.FileMode) error {
|
||||
target := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(target), ".apiGen-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if err = f.Chmod(mode); err == nil {
|
||||
_, err = f.Write(data)
|
||||
}
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
err = errors.Join(err, f.Close())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(f.Name(), target)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen"
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold"
|
||||
)
|
||||
|
||||
@@ -17,6 +18,11 @@ func main() {
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "api":
|
||||
if err := runAPI(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "go-omni api:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "migrate":
|
||||
if err := runMigrate(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "go-omni migrate:", err)
|
||||
@@ -44,9 +50,43 @@ func main() {
|
||||
func runNew(args []string) error {
|
||||
options := scaffold.Options{}
|
||||
var target string
|
||||
var apiFile string
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case arg == "--api" || strings.HasPrefix(arg, "--api="):
|
||||
_, value, inline := strings.Cut(arg, "=")
|
||||
if !inline {
|
||||
if index+1 >= len(args) || strings.HasPrefix(args[index+1], "-") {
|
||||
return errors.New("--api requires a file")
|
||||
}
|
||||
index++
|
||||
value = args[index]
|
||||
}
|
||||
if value == "" {
|
||||
return errors.New("--api requires a file")
|
||||
}
|
||||
apiFile = value
|
||||
case arg == "--log-output" || arg == "--log-format" || arg == "--storage" || strings.HasPrefix(arg, "--log-output=") || strings.HasPrefix(arg, "--log-format=") || strings.HasPrefix(arg, "--storage="):
|
||||
name, value, hasValue := strings.Cut(arg, "=")
|
||||
if !hasValue {
|
||||
if index+1 >= len(args) || strings.HasPrefix(args[index+1], "--") {
|
||||
return fmt.Errorf("%s requires a value", name)
|
||||
}
|
||||
index++
|
||||
value = args[index]
|
||||
}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("%s requires a value", name)
|
||||
}
|
||||
switch name {
|
||||
case "--log-output":
|
||||
options.LogOutput = value
|
||||
case "--log-format":
|
||||
options.LogFormat = value
|
||||
case "--storage":
|
||||
options.Storage = value
|
||||
}
|
||||
case arg == "--force":
|
||||
options.Force = true
|
||||
case arg == "--database":
|
||||
@@ -98,10 +138,26 @@ func runNew(args []string) error {
|
||||
if target == "" {
|
||||
return errors.New("project directory is required")
|
||||
}
|
||||
if options.Module == "" {
|
||||
if options.Module == "" && apiFile == "" {
|
||||
options.Module = filepath.Base(filepath.Clean(target))
|
||||
}
|
||||
options.Dir = target
|
||||
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
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(target, ".goOmni", "apiManifest.json")); err == nil {
|
||||
return errors.New("this is an API project; use api generate instead of the CRUD/module starter")
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := scaffold.Generate(options); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -115,10 +171,17 @@ func usage() {
|
||||
fmt.Fprintln(os.Stderr, "Usage:")
|
||||
fmt.Fprintln(os.Stderr, " go-omni migrate [--dir migrations] [--apply] [--dsn-env GO_OMNI_DB_DSN]")
|
||||
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] [--force] <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]")
|
||||
fmt.Fprintln(os.Stderr, " go-omni api validate --api <file.api> Validate definitions and OpenAPI")
|
||||
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 <memory|mysql> Select generated storage (default memory)")
|
||||
fmt.Fprintln(os.Stderr, " --database <none|memory|mysql> API defaults to none; CRUD defaults to memory")
|
||||
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)")
|
||||
}
|
||||
|
||||
func runAdd(args []string) error {
|
||||
|
||||
@@ -73,3 +73,28 @@ func TestNewMySQLDatabaseOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleOptionCLI(t *testing.T) {
|
||||
for _, flags := range [][]string{
|
||||
{"--log-output", "file", "--log-format", "json", "--storage", "local"},
|
||||
{"--log-output=stdout", "--log-format=text", "--storage=none"},
|
||||
} {
|
||||
project := filepath.Join(t.TempDir(), "demo")
|
||||
args := append([]string{"--module", "example.com/demo"}, flags...)
|
||||
if err := runNew(append(args, project)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(project, "etc/config.yaml")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"--storage"}, {"--storage="}, {"--storage", "--force"},
|
||||
{"--log-output"}, {"--log-output="}, {"--log-format="},
|
||||
{"--storage", "unknown", "demo"}, {"--log-format", "xml", "demo"},
|
||||
} {
|
||||
if err := runNew(args); err == nil {
|
||||
t.Fatal("invalid module option accepted", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
26
cmd/go-omni/scaffold/apiInfrastructure.go
Normal file
26
cmd/go-omni/scaffold/apiInfrastructure.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package scaffold
|
||||
|
||||
import "fmt"
|
||||
|
||||
// APIInfrastructure renders shared infrastructure without inventing business
|
||||
// tables or routes. The API generator applies these as developer-owned seeds.
|
||||
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",
|
||||
}
|
||||
if storage != "none" {
|
||||
files["internal/servicecontext/storage.go"] = "templates/modules-storage.go.tmpl"
|
||||
}
|
||||
result := make(map[string][]byte, len(files))
|
||||
for name, source := range files {
|
||||
content, err := render(source, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render %s: %w", name, err)
|
||||
}
|
||||
result[name] = []byte(content)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
143
cmd/go-omni/scaffold/moduleProfiles_test.go
Normal file
143
cmd/go-omni/scaffold/moduleProfiles_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package scaffold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type moduleProfile struct{ database, storage, output, format string }
|
||||
|
||||
var moduleProfiles = []moduleProfile{
|
||||
{"memory", "none", "stdout", "json"},
|
||||
{"memory", "local", "file", "json"},
|
||||
{"memory", "s3", "stdout", "text"},
|
||||
{"memory", "r2", "stderr", "json"},
|
||||
{"memory", "oss", "stderr", "text"},
|
||||
{"mysql", "none", "file", "text"},
|
||||
{"mysql", "local", "stdout", "json"},
|
||||
{"mysql", "s3", "file", "text"},
|
||||
{"mysql", "r2", "stderr", "text"},
|
||||
{"mysql", "oss", "file", "json"},
|
||||
}
|
||||
|
||||
func generateProfile(t *testing.T, p moduleProfile) string {
|
||||
t.Helper()
|
||||
project := filepath.Join(t.TempDir(), "project")
|
||||
if err := Generate(Options{Dir: project, Module: "example.com/profile", FrameworkDir: frameworkRoot(t), Database: p.database, Storage: p.storage, LogOutput: p.output, LogFormat: p.format}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return project
|
||||
}
|
||||
func TestGenerateModuleProfiles(t *testing.T) {
|
||||
for _, p := range moduleProfiles {
|
||||
t.Run(p.database+"/"+p.storage, func(t *testing.T) {
|
||||
project := generateProfile(t, p)
|
||||
for _, name := range []string{"etc/config.yaml", "internal/settings/config.go", "MODULES.md", ".gitignore"} {
|
||||
if _, err := os.Stat(filepath.Join(project, name)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, err := os.Stat(filepath.Join(project, "internal/handler/files.go"))
|
||||
if p.storage == "none" && !os.IsNotExist(err) {
|
||||
t.Fatal("unselected storage was emitted")
|
||||
}
|
||||
if p.storage != "none" && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
module, err := os.ReadFile(filepath.Join(project, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pieces := strings.Split(string(module), "=> ")
|
||||
if len(pieces) != 2 {
|
||||
t.Fatal("local replace missing")
|
||||
}
|
||||
relative, err := strconv.Unquote(strings.TrimSpace(pieces[1]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(project, relative, "go.mod")); err != nil {
|
||||
t.Fatal("replacement does not resolve", err)
|
||||
}
|
||||
cfg, _ := os.ReadFile(filepath.Join(project, "etc/config.yaml"))
|
||||
if strings.Contains(string(cfg), "secretKey:") || strings.Contains(string(cfg), "filesToken:") {
|
||||
t.Fatal("secret defaults emitted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestModuleOptionsRejectBeforeWriting(t *testing.T) {
|
||||
for _, options := range []Options{
|
||||
{LogOutput: "invalid"}, {LogFormat: "xml"}, {Storage: "ftp"},
|
||||
{Storage: "local", FrameworkVersion: "v0.6.0-preview"},
|
||||
{Module: "example.com/x\nrequire malicious v1.0.0"},
|
||||
} {
|
||||
options.Dir = filepath.Join(t.TempDir(), "project")
|
||||
if options.Module == "" {
|
||||
options.Module = "example.com/profile"
|
||||
}
|
||||
if err := Generate(options); err == nil {
|
||||
t.Fatalf("invalid options accepted: %+v", options)
|
||||
}
|
||||
if _, err := os.Stat(options.Dir); !os.IsNotExist(err) {
|
||||
t.Fatal("invalid options touched target")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestCanonicalPathBeforeDirectoryExists(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
alias := filepath.Join(t.TempDir(), "alias")
|
||||
if err := os.Symlink(parent, alias); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
path := filepath.Join(alias, "new", "nested")
|
||||
if got, want := canonicalPath(path), filepath.Join(canonicalPath(parent), "new", "nested"); got != want {
|
||||
t.Fatalf("%s != %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Compile consuming modules, then run their generated tests. No provider account
|
||||
// is used: cloud constructors are checked offline, file workflows use Local.
|
||||
func TestModuleProfilesIntegration(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1 for generated-profile tests")
|
||||
}
|
||||
for _, p := range moduleProfiles {
|
||||
t.Run(p.database+"/"+p.storage, func(t *testing.T) {
|
||||
project := generateProfile(t, p)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-p=2", "-count=1", "-timeout=60s", "./...")
|
||||
cmd.Dir = project
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off", "GOMAXPROCS=2", "GO_OMNI_TEST_MYSQL_DSN=")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("generated %s/%s: %v\n%s", p.database, p.storage, err, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceRejectsRemovedDependenciesWithoutTouchingCode(t *testing.T) {
|
||||
project := generateProfile(t, moduleProfile{"mysql", "local", "file", "json"})
|
||||
path := filepath.Join(project, "cmd/server/main.go")
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = Generate(Options{Dir: project, Module: "example.com/profile", FrameworkDir: frameworkRoot(t), Force: true, Storage: "none"}); err == nil {
|
||||
t.Fatal("incompatible force overwrite accepted")
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if string(after) != string(before) {
|
||||
t.Fatal("failed profile switch changed code")
|
||||
}
|
||||
if err = Generate(Options{Dir: project, Module: "example.com/profile", FrameworkDir: frameworkRoot(t), Force: true, Database: "mysql", Storage: "local", LogOutput: "stdout"}); err != nil {
|
||||
t.Fatal("compatible force overwrite rejected", err)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"golang.org/x/mod/module"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
//go:embed templates moduleTemplates
|
||||
@@ -24,6 +27,9 @@ type Options struct {
|
||||
FrameworkDir string
|
||||
FrameworkVersion string
|
||||
Database string
|
||||
LogOutput string
|
||||
LogFormat string
|
||||
Storage string
|
||||
Force bool
|
||||
}
|
||||
|
||||
@@ -48,6 +54,31 @@ func Generate(options Options) error {
|
||||
if strings.TrimSpace(options.Module) == "" {
|
||||
return errors.New("module path is required")
|
||||
}
|
||||
if err := module.CheckImportPath(options.Module); err != nil {
|
||||
return fmt.Errorf("invalid module path: %w", err)
|
||||
}
|
||||
modules := options.LogOutput != "" || options.LogFormat != "" || options.Storage != ""
|
||||
logOutput, logFormat, storage := options.LogOutput, options.LogFormat, options.Storage
|
||||
if logOutput == "" {
|
||||
logOutput = "stderr"
|
||||
}
|
||||
if logFormat == "" {
|
||||
logFormat = "json"
|
||||
}
|
||||
if storage == "" {
|
||||
storage = "none"
|
||||
}
|
||||
if logOutput != "stdout" && logOutput != "stderr" && logOutput != "file" {
|
||||
return fmt.Errorf("unsupported log output %q: choose stdout, stderr or file", logOutput)
|
||||
}
|
||||
if logFormat != "json" && logFormat != "text" {
|
||||
return fmt.Errorf("unsupported log format %q: choose json or text", logFormat)
|
||||
}
|
||||
switch storage {
|
||||
case "none", "local", "s3", "r2", "oss":
|
||||
default:
|
||||
return fmt.Errorf("unsupported object storage %q: choose none, local, s3, r2 or oss", storage)
|
||||
}
|
||||
database := strings.ToLower(strings.TrimSpace(options.Database))
|
||||
if database == "" {
|
||||
database = "memory"
|
||||
@@ -58,6 +89,9 @@ func Generate(options Options) error {
|
||||
version := strings.TrimSpace(options.FrameworkVersion)
|
||||
if version == "" {
|
||||
version = currentFrameworkVersion()
|
||||
if modules && version == defaultFrameworkVersion {
|
||||
version = modulesFrameworkVersion
|
||||
}
|
||||
}
|
||||
if !validFrameworkVersion(version) {
|
||||
return fmt.Errorf("framework version %q must be a complete v0 or v1 semantic version", version)
|
||||
@@ -70,20 +104,20 @@ func Generate(options Options) error {
|
||||
if err := checkTarget(directory, options.Force); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
return fmt.Errorf("create project directory: %w", err)
|
||||
}
|
||||
|
||||
// An explicit remote version opts out of automatic local development mode.
|
||||
replace := ""
|
||||
if options.FrameworkDir != "" || options.FrameworkVersion == "" {
|
||||
replace = frameworkReplace(directory, options.FrameworkDir)
|
||||
}
|
||||
if modules && replace == "" && semver.Compare(version, modulesFrameworkVersion) < 0 && version != installedFrameworkVersion() {
|
||||
return fmt.Errorf("module templates require %s or newer; use --framework-dir for unpublished source", modulesFrameworkVersion)
|
||||
}
|
||||
data := templateData{
|
||||
Module: options.Module,
|
||||
FrameworkVersion: version,
|
||||
FrameworkReplace: replace,
|
||||
Database: database,
|
||||
Modules: modules, LogOutput: logOutput, LogFormat: logFormat, Storage: storage,
|
||||
}
|
||||
projectFiles := make(map[string]string, len(files)+3)
|
||||
for path, source := range files {
|
||||
@@ -94,11 +128,42 @@ func Generate(options Options) error {
|
||||
projectFiles["schema.sql"] = "templates/schema.sql.tmpl"
|
||||
projectFiles["internal/servicecontext/databaseIntegration_test.go"] = "templates/databaseIntegration_test.go.tmpl"
|
||||
}
|
||||
if modules {
|
||||
projectFiles["cmd/server/main.go"] = "templates/modules-main.go.tmpl"
|
||||
projectFiles["internal/settings/config.go"] = "templates/modules-settings.go.tmpl"
|
||||
projectFiles["internal/settings/config_test.go"] = "templates/modules-settings_test.go.tmpl"
|
||||
projectFiles["etc/config.yaml"] = "templates/modules-config.yaml.tmpl"
|
||||
projectFiles[".gitignore"] = "templates/modules-gitignore.tmpl"
|
||||
projectFiles["MODULES.md"] = "templates/modules-README.md.tmpl"
|
||||
if storage != "none" {
|
||||
projectFiles["internal/servicecontext/storage.go"] = "templates/modules-storage.go.tmpl"
|
||||
projectFiles["internal/servicecontext/storage_test.go"] = "templates/modules-storage_test.go.tmpl"
|
||||
projectFiles["internal/service/files.go"] = "templates/modules-filesService.go.tmpl"
|
||||
projectFiles["internal/handler/files.go"] = "templates/modules-filesHandler.go.tmpl"
|
||||
projectFiles["internal/handler/files_test.go"] = "templates/modules-filesHandler_test.go.tmpl"
|
||||
}
|
||||
}
|
||||
if options.Force {
|
||||
// Do not leave old generated Go files that reference dependencies omitted
|
||||
// by this profile, and do not silently delete potentially edited code.
|
||||
for _, path := range []string{"internal/repository/mysql.go", "internal/servicecontext/databaseIntegration_test.go", "schema.sql", "internal/settings/config.go", "internal/settings/config_test.go", "etc/config.yaml", "MODULES.md", "internal/servicecontext/storage.go", "internal/servicecontext/storage_test.go", "internal/service/files.go", "internal/handler/files.go", "internal/handler/files_test.go"} {
|
||||
if _, emitted := projectFiles[path]; emitted {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(directory, path)); err == nil {
|
||||
return fmt.Errorf("existing %s conflicts with the selected profile; generate into a new directory instead of removing existing code", path)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
paths := make([]string, 0, len(projectFiles))
|
||||
for path := range projectFiles {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
// Render and format the whole project before touching the target directory.
|
||||
contents := make(map[string]string, len(paths))
|
||||
for _, path := range paths {
|
||||
content, err := render(projectFiles[path], data)
|
||||
if err != nil {
|
||||
@@ -111,6 +176,9 @@ func Generate(options Options) error {
|
||||
}
|
||||
content = string(formatted)
|
||||
}
|
||||
contents[path] = content
|
||||
}
|
||||
for _, path := range paths {
|
||||
target := filepath.Join(directory, filepath.FromSlash(path))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return fmt.Errorf("create %s: %w", filepath.Dir(path), err)
|
||||
@@ -122,7 +190,7 @@ func Generate(options Options) error {
|
||||
return fmt.Errorf("check %s: %w", target, err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(target, []byte(content), 0o644); err != nil {
|
||||
if err := os.WriteFile(target, []byte(contents[path]), 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", target, err)
|
||||
}
|
||||
}
|
||||
@@ -130,10 +198,15 @@ func Generate(options Options) error {
|
||||
}
|
||||
|
||||
type templateData struct {
|
||||
API bool
|
||||
Module string
|
||||
FrameworkVersion string
|
||||
FrameworkReplace string
|
||||
Database string
|
||||
Modules bool
|
||||
LogOutput string
|
||||
LogFormat string
|
||||
Storage string
|
||||
}
|
||||
|
||||
func render(name string, data templateData) (string, error) {
|
||||
@@ -202,11 +275,28 @@ func canonicalPath(path string) string {
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(abs)
|
||||
if err == nil {
|
||||
return resolved
|
||||
// Resolve the nearest existing ancestor too: generation now renders before
|
||||
// creating directories, and macOS /var commonly resolves through /private.
|
||||
candidate := abs
|
||||
var tail []string
|
||||
for {
|
||||
resolved, err := filepath.EvalSymlinks(candidate)
|
||||
if err == nil {
|
||||
for i := len(tail) - 1; i >= 0; i-- {
|
||||
resolved = filepath.Join(resolved, tail[i])
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return abs
|
||||
}
|
||||
parent := filepath.Dir(candidate)
|
||||
if parent == candidate {
|
||||
return abs
|
||||
}
|
||||
tail = append(tail, filepath.Base(candidate))
|
||||
candidate = parent
|
||||
}
|
||||
return abs
|
||||
}
|
||||
|
||||
func findFrameworkRoot() string {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# GoOmni service
|
||||
|
||||
This service was generated with `go-omni new`.
|
||||
{{if .Modules}}
|
||||
**本项目启用了模块化配置,启动步骤和环境变量请先看 [MODULES.md](MODULES.md)。** 配置来自 `etc/config.yaml`,文件模块需要配置环境密钥后启动。
|
||||
{{end}}
|
||||
|
||||
Framework dependency: `git.yuntuo.hk/k2safe/go-omni {{.FrameworkVersion}}`.
|
||||
|
||||
Storage: `{{.Database}}`.
|
||||
Database: `{{.Database}}`.
|
||||
|
||||
{{if eq .Database "mysql"}}
|
||||
先创建业务数据库,再执行项目的建表脚本(替换示例数据库名、账号和地址):
|
||||
|
||||
69
cmd/go-omni/scaffold/templates/api-context.go.tmpl
Normal file
69
cmd/go-omni/scaffold/templates/api-context.go.tmpl
Normal file
@@ -0,0 +1,69 @@
|
||||
package servicecontext
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
{{if ne .Storage "none"}}"net/http"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"{{end}}
|
||||
{{if eq .Database "mysql"}}"time"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"github.com/go-sql-driver/mysql"{{end}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"{{.Module}}/internal/settings"
|
||||
"{{.Module}}/internal/middleware"
|
||||
)
|
||||
// Context owns infrastructure, while Logger is borrowed from main.
|
||||
// Add business repositories here; HTTP contracts do not define database tables.
|
||||
type Context struct {
|
||||
Logger *logx.Logger
|
||||
Health *health.Checker
|
||||
Middlewares map[string]httpx.Middleware
|
||||
{{if eq .Database "mysql"}}DB *database.DB{{end}}
|
||||
{{if ne .Storage "none"}}
|
||||
Files storage.Store
|
||||
Uploads *storage.Uploader
|
||||
Downloads storage.Presigner
|
||||
UploadLinks storage.UploadPresigner
|
||||
FileHTTP http.Handler
|
||||
storageClose func()error
|
||||
{{end}}
|
||||
closeMu sync.Mutex
|
||||
closed bool
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
// Pass the settings loaded by main. The optional form supports lightweight
|
||||
// tests without infrastructure; selected adapters still require valid settings.
|
||||
func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(result *Context,err error){
|
||||
if ctx==nil{ctx=context.Background()}
|
||||
if err=ctx.Err();err!=nil{return nil,err}
|
||||
if len(configs)>1{return nil,errors.New("expected at most one configuration")}
|
||||
cfg:=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()}
|
||||
defer func(){if err!=nil{err=errors.Join(err,c.Close())}}()
|
||||
{{if eq .Database "mysql"}}
|
||||
if err=cfg.Validate();err!=nil{return nil,err}
|
||||
dsn,parseErr:=mysql.ParseDSN(cfg.Database.DSN)
|
||||
if parseErr!=nil || dsn.DBName=="" {return nil,errors.New("GO_OMNI_DB_DSN must contain a valid MySQL database name")}
|
||||
dsn.ParseTime=true;dsn.Loc=time.UTC
|
||||
cfg.Database.DSN=dsn.FormatDSN()
|
||||
c.DB,err=database.Open(ctx,cfg.Database);if err!=nil{return nil,err}
|
||||
if err=checker.Add(health.Probe{Name:"database",Timeout:cfg.Database.PingTimeout,Check:c.DB.Health});err!=nil{return nil,err}
|
||||
{{end}}
|
||||
{{if ne .Storage "none"}}if err=c.InitStorage(ctx,cfg.Storage);err!=nil{return nil,err}{{end}}
|
||||
return c,nil
|
||||
}
|
||||
// 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
|
||||
}
|
||||
@@ -23,7 +23,10 @@ func (s *Users) Create(ctx context.Context, input model.UserInput) (model.User,
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
return s.services.Users.Create(ctx, input)
|
||||
user, err := s.services.Users.Create(ctx, input)
|
||||
if err != nil { s.services.Logger.ErrorContext(ctx,"user.create.failed","error",err); return model.User{},err }
|
||||
s.services.Logger.InfoContext(ctx,"user.created","userId",user.ID)
|
||||
return user,nil
|
||||
}
|
||||
|
||||
func (s *Users) Get(ctx context.Context, id int64) (model.User, error) {
|
||||
|
||||
@@ -2,6 +2,10 @@ package servicecontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
{{if and .Modules (ne .Storage "none")}}"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"{{end}}
|
||||
{{if eq .Database "mysql"}}"fmt"
|
||||
"time"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
@@ -17,6 +21,18 @@ type Context struct {
|
||||
Logger *logx.Logger
|
||||
Users repository.UserRepository
|
||||
Health *health.Checker
|
||||
{{if and .Modules (ne .Storage "none")}}
|
||||
Files storage.Store
|
||||
Uploads *storage.Uploader
|
||||
Downloads storage.Presigner
|
||||
UploadLinks storage.UploadPresigner
|
||||
FileHTTP http.Handler
|
||||
storageClose func() error
|
||||
closeMu sync.Mutex
|
||||
closed bool
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
{{end}}
|
||||
{{if eq .Database "mysql"}}DB *database.DB{{end}}
|
||||
}
|
||||
|
||||
@@ -61,6 +77,15 @@ func New(ctx context.Context, logger *logx.Logger) (*Context, error) {
|
||||
|
||||
// Close is safe to call on startup failure and again during application exit.
|
||||
func (c *Context) Close() error {
|
||||
{{if eq .Database "mysql"}}if c != nil && c.DB != nil { return c.DB.Close() }{{end}}
|
||||
return nil
|
||||
{{if and .Modules (ne .Storage "none")}}
|
||||
if c==nil{return nil}
|
||||
c.closeMu.Lock();defer c.closeMu.Unlock()
|
||||
c.closed=true
|
||||
c.closeOnce.Do(func(){
|
||||
if c.storageClose!=nil {c.closeErr=errors.Join(c.closeErr,c.storageClose())}
|
||||
{{if eq .Database "mysql"}}if c.DB!=nil{c.closeErr=errors.Join(c.closeErr,c.DB.Close())}{{end}}
|
||||
})
|
||||
return c.closeErr
|
||||
{{else}}{{if eq .Database "mysql"}}if c != nil && c.DB != nil { return c.DB.Close() }{{end}}{{end}}
|
||||
{{if not (and .Modules (ne .Storage "none"))}}return nil{{end}}
|
||||
}
|
||||
|
||||
97
cmd/go-omni/scaffold/templates/modules-README.md.tmpl
Normal file
97
cmd/go-omni/scaffold/templates/modules-README.md.tmpl
Normal file
@@ -0,0 +1,97 @@
|
||||
# 按需装配的日志与文件模块
|
||||
|
||||
本项目选用日志输出 `{{.LogOutput}}`、格式 `{{.LogFormat}}`、对象存储 `{{.Storage}}`。数据库选择 `{{.Database}}` 独立于对象存储。
|
||||
|
||||
配置入口为 `etc/config.yaml`,通过 `go run ./cmd/server -config /path/to/config.yaml` 指定其他 JSON/YAML 文件;`-config ''` 只用代码默认值和环境变量。优先级:默认值 → 文件 → 非空环境变量。未知字段、无效参数会在监听前拒绝。
|
||||
|
||||
```sh
|
||||
go mod tidy
|
||||
{{if ne .Storage "none"}}# 示例文件接口使用单一共享 Token,不是用户/租户权限系统。
|
||||
export GO_OMNI_FILES_TOKEN="$(openssl rand -hex 32)"
|
||||
{{if eq .Storage "local"}}# 本地签名密钥需要稳定保存;换密钥后旧链接失效。
|
||||
export GO_OMNI_STORAGE_SIGNING_KEY="$(openssl rand -hex 32)"
|
||||
{{else}}# 配置当前存储平台已有的桶和凭据,不会自动创建桶。
|
||||
export GO_OMNI_STORAGE_BUCKET='your-bucket'
|
||||
export GO_OMNI_STORAGE_ACCESS_KEY='your-access-key'
|
||||
export GO_OMNI_STORAGE_SECRET_KEY='your-secret-key'
|
||||
{{if eq .Storage "s3"}}export GO_OMNI_STORAGE_ENDPOINT='s3.us-east-1.amazonaws.com'
|
||||
export GO_OMNI_STORAGE_REGION='us-east-1'
|
||||
{{else if eq .Storage "r2"}}export GO_OMNI_STORAGE_ACCOUNT_ID='your-32-character-account-id'
|
||||
{{else}}export GO_OMNI_STORAGE_REGION='cn-hangzhou'
|
||||
{{end}}{{end}}{{end}}
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
{{if eq .Database "mysql"}}还需按 README 配置独立 MySQL 数据库并应用 schema.sql;应用不自动迁移。{{end}}
|
||||
|
||||
默认仅监听 `127.0.0.1:8080`。`GO_OMNI_HTTP_ADDR` 覆盖地址;本地签名 URL 的外部地址通过 `GO_OMNI_STORAGE_PUBLIC_URL` 单独配置,始终以 `/storage/object` 结尾,不使用请求 Host 自动拼接可信链接。
|
||||
|
||||
## 日志
|
||||
|
||||
业务示例在创建用户/上传文件成功时通过 logx 写结构化日志。Context 原样传递 requestId,框架不自动增加用户、租户等业务字段。文件模式已配置轮转:100 MiB、10 份、7 天、压缩。调整 `server.log` 即可更改;`redactKeys` 可增加业务敏感键。
|
||||
|
||||
```sh
|
||||
curl -H 'Content-Type: application/json' -H 'X-Request-ID: example-request' \
|
||||
-d '{"name":"Example","email":"example@example.com"}' http://127.0.0.1:8080/users
|
||||
```
|
||||
|
||||
`GO_OMNI_LOG_LEVEL`、`GO_OMNI_LOG_FORMAT`、`GO_OMNI_LOG_FILE` 覆盖对应项;显式设置 `GO_OMNI_LOG_OUTPUT=stdout` 且未设置 LOG_FILE 时,切换到控制台并关闭文件轮转。慢请求阈值为 500ms,可通过 `GO_OMNI_SLOW_REQUEST_THRESHOLD` 调整。字段脱敏不扫描 msg/error 中的自由文本;不要把凭据或签名 URL 拼到日志中。
|
||||
|
||||
{{if ne .Storage "none"}}## 文件示例
|
||||
|
||||
Handler → Service → storage 接口;底层构造与连接池集中在 `internal/servicecontext/storage.go`。示例接口要求 `Authorization: Bearer $GO_OMNI_FILES_TOKEN`,它授权这个示例的全部文件,实际业务应替换认证和对象归属检查。存储适配器不承担业务授权。
|
||||
|
||||
```sh
|
||||
printf 'hello go-omni' > /tmp/goOmniExample.txt
|
||||
curl -H "Authorization: Bearer $GO_OMNI_FILES_TOKEN" \
|
||||
-H 'Content-Type: text/plain' -H 'X-Request-ID: file-example' \
|
||||
--data-binary @/tmp/goOmniExample.txt http://127.0.0.1:8080/files
|
||||
```
|
||||
|
||||
保存响应中 `data.key`,再请求下载链接(把示例 key 替换为返回值):
|
||||
|
||||
```sh
|
||||
curl -H "Authorization: Bearer $GO_OMNI_FILES_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"key":"uploads/returned-key"}' http://127.0.0.1:8080/files/downloadLink
|
||||
```
|
||||
|
||||
返回 `data.url`,在有效期内 GET 即可下载,无需再携带 Token。签发前会检查对象存在;URL 默认 5 分钟有效,不写日志,响应设置 no-store。
|
||||
|
||||
需要直传时先申请链接:
|
||||
|
||||
```sh
|
||||
curl -H "Authorization: Bearer $GO_OMNI_FILES_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"size":13,"contentType":"text/plain"}' http://127.0.0.1:8080/files/uploadLink
|
||||
```
|
||||
|
||||
响应包括随机 `data.key` 和 `data.request`(method/url/headers/expiresAt)。按返回的 method 和 headers 用原始文件字节上传到 URL;不能额外包 FormData。浏览器自动填写 Content-Length,其余签名 Header 原样发送;云桶需配置允许业务域名的 CORS。云端直传只绑定声明类型,实际内容需上传后验证;本地和 API 中转上传会检查允许类型及前缀嗅探结果。未完成内容验证前不要把暂存文件当作可信文件公开。
|
||||
|
||||
| 参数 | 默认值 / 环境变量 |
|
||||
| --- | --- |
|
||||
| 大小 | 32 MiB / GO_OMNI_STORAGE_MAX_BYTES |
|
||||
| 类型 | text/plain、image/png、image/jpeg、application/pdf / GO_OMNI_STORAGE_CONTENT_TYPES(逗号分隔) |
|
||||
| 链接有效期 | 5m / GO_OMNI_STORAGE_LINK_TTL(1s..1h) |
|
||||
| 请求超时 | 30s / GO_OMNI_STORAGE_REQUEST_TIMEOUT(>0 且 <=5m) |
|
||||
| 本地目录 | var/files / GO_OMNI_STORAGE_ROOT |
|
||||
| 本地外部 URL | http://127.0.0.1:8080/storage/object / GO_OMNI_STORAGE_PUBLIC_URL |
|
||||
| 云配置 | GO_OMNI_STORAGE_ENDPOINT、REGION、BUCKET、ACCOUNT_ID、SECURE |
|
||||
| 环境密钥 | GO_OMNI_FILES_TOKEN、GO_OMNI_STORAGE_SIGNING_KEY、GO_OMNI_STORAGE_ACCESS_KEY、SECRET_KEY、SESSION_TOKEN |
|
||||
|
||||
全部 JSON/YAML 字段使用 lowerCamelCase。密钥字段仅允许环境变量,配置文件中写入会报未知字段;不要把运行环境文件提交到仓库。S3 的 endpoint 为 host[:port],OSS 的可选 endpoint 为 HTTPS origin;R2 从 accountId 派生官方 endpoint,region 为 auto。仅 S3/MinIO 的本地测试可设置 SECURE=false。
|
||||
|
||||
错误响应:未授权 401、无 Content-Length 411、超限 413、类型不允许 415、长度不符/非法 key 400、对象不存在 404、存储超时 504,其余内部错误统一隐藏细节。底层签名入口遵循存储协议返回 201/403 等状态,流式传输且访问日志保留实际状态。
|
||||
|
||||
云客户端只在组合处创建,使用专用有界连接池;配置正确不代表桶和网络已经可用,首次真实调用仍可能失败。不创建桶、不修改桶权限、不初始化任何业务数据库。更换平台时替换组合处适配器,业务 Service 接口保持不变。
|
||||
{{end}}
|
||||
|
||||
## 生命周期与验证
|
||||
|
||||
启动失败会清理已创建资源;SIGINT/SIGTERM 后先等待 HTTP 请求退出,再关闭服务依赖,最后关闭日志。只关闭项目自己创建的资源,不关闭全局默认 HTTP Transport。
|
||||
|
||||
```sh
|
||||
go test -race ./...
|
||||
go build -o server ./cmd/server
|
||||
./server
|
||||
```
|
||||
|
||||
生成的测试覆盖配置优先级/校验{{if ne .Storage "none"}}、资源关闭幂等性和借用日志所有权、上传与临时链接、签名篡改、限额/类型错误。业务测试使用临时 Local,不访问云账号;云端配置需另做实网验收{{end}}。基础用户 CRUD 示例仍未启用认证,部署前按业务需要替换。
|
||||
46
cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl
Normal file
46
cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl
Normal file
@@ -0,0 +1,46 @@
|
||||
# Non-secret settings only. Environment variables override these values.
|
||||
server:
|
||||
address: '127.0.0.1:8080'
|
||||
enableLogger: true
|
||||
slowRequestThreshold: 500ms
|
||||
shutdownTimeout: 10s
|
||||
log:
|
||||
service: {{printf "%q" .Module}}
|
||||
level: info
|
||||
format: {{.LogFormat}}
|
||||
{{if eq .LogOutput "file"}} file: './var/log/server.log'
|
||||
rotation:
|
||||
enabled: true
|
||||
maxSizeMb: 100
|
||||
maxBackups: 10
|
||||
maxAgeDays: 7
|
||||
compress: true
|
||||
{{else}} output: {{.LogOutput}}
|
||||
{{end}} redactKeys: []
|
||||
{{if ne .Storage "none"}}storage:
|
||||
provider: {{.Storage}}
|
||||
maxBytes: 33554432
|
||||
contentTypes: ['text/plain', 'image/png', 'image/jpeg', 'application/pdf']
|
||||
linkTtl: 5m
|
||||
requestTimeout: 30s
|
||||
{{if eq .Storage "local"}} root: './var/files'
|
||||
publicUrl: 'http://127.0.0.1:8080/storage/object'
|
||||
{{else}} bucket: '' # set GO_OMNI_STORAGE_BUCKET
|
||||
secure: true
|
||||
{{if eq .Storage "r2"}} accountId: '' # set GO_OMNI_STORAGE_ACCOUNT_ID
|
||||
region: auto
|
||||
{{else if eq .Storage "s3"}} endpoint: '' # set GO_OMNI_STORAGE_ENDPOINT (host[:port])
|
||||
region: us-east-1
|
||||
{{else}} region: cn-hangzhou
|
||||
endpoint: '' # empty uses the official regional endpoint
|
||||
{{end}}{{end}}{{end}}
|
||||
|
||||
{{if and .API (eq .Database "mysql")}}database:
|
||||
driver: mysql
|
||||
maxOpenConns: 10
|
||||
maxIdleConns: 5
|
||||
connMaxLifetime: 30m
|
||||
connMaxIdleTime: 5m
|
||||
pingTimeout: 5s
|
||||
# GO_OMNI_DB_DSN supplies the secret connection string.
|
||||
{{end}}
|
||||
86
cmd/go-omni/scaffold/templates/modules-filesHandler.go.tmpl
Normal file
86
cmd/go-omni/scaffold/templates/modules-filesHandler.go.tmpl
Normal file
@@ -0,0 +1,86 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"
|
||||
"{{.Module}}/internal/service"
|
||||
)
|
||||
|
||||
type Files struct {service *service.Files; maxBytes int64; timeout time.Duration}
|
||||
func NewFiles(service *service.Files,maxBytes int64,timeout time.Duration)*Files{return &Files{service:service,maxBytes:maxBytes,timeout:timeout}}
|
||||
|
||||
// RegisterRoutes protects example control endpoints with one shared demo token.
|
||||
// Replace this gate with project authentication and object ownership checks.
|
||||
// Signed object URLs carry their own short-lived authorization.
|
||||
func (h *Files) RegisterRoutes(application *omni.App,token string,signed http.Handler)error {
|
||||
if len(token)<32{return errors.New("file example requires a token of at least 32 bytes")}
|
||||
bounded:=func(next omni.Handler)omni.Handler{return func(c *omni.Context)error{
|
||||
ctx,cancel:=context.WithTimeout(c.Context(),h.timeout);defer cancel();c.WithContext(ctx);return next(c)
|
||||
}}
|
||||
authorized:=func(next omni.Handler)omni.Handler{return func(c *omni.Context)error{
|
||||
if len(c.Request().Header.Values("Authorization"))!=1 || subtle.ConstantTimeCompare([]byte(c.Header("Authorization")),[]byte("Bearer "+token))!=1{return omni.Unauthorized("UNAUTHORIZED","invalid file access token")}
|
||||
return next(c)
|
||||
}}
|
||||
group:=application.Group("/files",authorized,bounded)
|
||||
group.POST("",h.Upload)
|
||||
group.POST("/uploadLink",h.UploadLink)
|
||||
group.POST("/downloadLink",h.DownloadLink)
|
||||
if signed!=nil {application.Any("/storage/object",func(c *omni.Context)error{
|
||||
signed.ServeHTTP(contextWriter{c},c.Request());return nil
|
||||
},bounded)}
|
||||
return nil
|
||||
}
|
||||
func (h *Files) Upload(c *omni.Context)error {
|
||||
size:=c.Request().ContentLength
|
||||
if size<0{return omni.NewError(http.StatusLengthRequired,"LENGTH_REQUIRED","Content-Length is required")}
|
||||
if size>h.maxBytes{return omni.NewError(http.StatusRequestEntityTooLarge,"FILE_TOO_LARGE","file exceeds upload limit")}
|
||||
body:=http.MaxBytesReader(c.Raw(),c.Request().Body,h.maxBytes)
|
||||
defer body.Close()
|
||||
info,err:=h.service.Upload(c.Context(),body,size,c.Header("Content-Type"))
|
||||
if err!=nil{return fileError(err)}
|
||||
return c.Created(info)
|
||||
}
|
||||
func (h *Files) UploadLink(c *omni.Context)error {
|
||||
var input struct{Size int64 `json:"size" binding:"gte=0"`;ContentType string `json:"contentType" binding:"required,max=256"`}
|
||||
if err:=c.BindJSON(&input);err!=nil{return err}
|
||||
link,err:=h.service.UploadLink(c.Context(),input.Size,input.ContentType)
|
||||
if err!=nil{return fileError(err)}
|
||||
c.SetResponseHeader("Cache-Control","no-store")
|
||||
return c.OK(link)
|
||||
}
|
||||
func (h *Files) DownloadLink(c *omni.Context)error {
|
||||
var input struct{Key string `json:"key" binding:"required,max=1024"`}
|
||||
if err:=c.BindJSON(&input);err!=nil{return err}
|
||||
if err:=storage.Check(c.Context(),input.Key);err!=nil{return omni.BadRequest("INVALID_KEY","invalid object key")}
|
||||
link,err:=h.service.DownloadLink(c.Context(),input.Key)
|
||||
if err!=nil{return fileError(err)}
|
||||
c.SetResponseHeader("Cache-Control","no-store")
|
||||
return c.OK(map[string]string{"url":link})
|
||||
}
|
||||
func fileError(err error)error {
|
||||
var limit *http.MaxBytesError
|
||||
switch {
|
||||
case errors.Is(err,storage.ErrTooLarge),errors.As(err,&limit):return omni.NewError(413,"FILE_TOO_LARGE","file exceeds upload limit").WithCause(err)
|
||||
case errors.Is(err,storage.ErrContentType):return omni.NewError(415,"CONTENT_TYPE_NOT_ALLOWED","file content type is not allowed").WithCause(err)
|
||||
case errors.Is(err,storage.ErrSizeMismatch):return omni.BadRequest("SIZE_MISMATCH","file size does not match Content-Length").WithCause(err)
|
||||
case errors.Is(err,storage.ErrNotFound):return omni.NotFound("FILE_NOT_FOUND","file was not found").WithCause(err)
|
||||
case errors.Is(err,context.DeadlineExceeded):return omni.NewError(504,"STORAGE_TIMEOUT","storage operation timed out").WithCause(err)
|
||||
default:return err
|
||||
}
|
||||
}
|
||||
|
||||
// Track raw streaming handler responses through Omni so access logs retain the
|
||||
// actual status (including failed signatures), without buffering object bodies.
|
||||
type contextWriter struct{ctx *omni.Context}
|
||||
func (w contextWriter) Header()http.Header{return w.ctx.Raw().Header()}
|
||||
func (w contextWriter) WriteHeader(status int){_ = w.ctx.WriteResponse(status,nil,nil)}
|
||||
func (w contextWriter) Write(data []byte)(int,error){
|
||||
if !w.ctx.Responded(){if err:=w.ctx.WriteResponse(http.StatusOK,nil,nil);err!=nil{return 0,err}}
|
||||
return w.ctx.Raw().Write(data)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage/local"
|
||||
"{{.Module}}/internal/handler"
|
||||
"{{.Module}}/internal/service"
|
||||
)
|
||||
|
||||
// Exercise business code using Local even when production selects a cloud
|
||||
// adapter. These tests do not use cloud accounts or network credentials.
|
||||
func TestFileUploadAndLinks(t *testing.T){
|
||||
files,err:=local.New(t.TempDir(),64);if err!=nil{t.Fatal(err)}
|
||||
policy:=storage.UploadPolicy{MaxBytes:64,ContentTypes:[]string{"text/plain"}}
|
||||
signer,err:=storage.NewUploadSigner(files,"http://localhost/storage/object",bytes.Repeat([]byte{1},32),policy);if err!=nil{t.Fatal(err)}
|
||||
uploader,err:=storage.NewUploader(files,policy);if err!=nil{t.Fatal(err)}
|
||||
var logs bytes.Buffer
|
||||
logger,err:=logx.New(logx.Config{Writer:&logs});if err!=nil{t.Fatal(err)}
|
||||
logx.SetDefault(logger);defer logx.SetDefault(nil)
|
||||
app:=omni.New(omni.WithLogger(true))
|
||||
token:=strings.Repeat("t",32)
|
||||
svc:=service.NewFiles(files,uploader,signer,signer,logger,time.Minute)
|
||||
if err=handler.NewFiles(svc,64,time.Second).RegisterRoutes(app,token,signer);err!=nil{t.Fatal(err)}
|
||||
request:=func(method,path,body,contentType string,authorized bool)*httptest.ResponseRecorder{
|
||||
r:=httptest.NewRequest(method,path,strings.NewReader(body));r.Header.Set("Content-Type",contentType);r.Header.Set("X-Request-ID","upload-test")
|
||||
if authorized{r.Header.Set("Authorization","Bearer "+token)}
|
||||
w:=httptest.NewRecorder();app.ServeHTTP(w,r);return w
|
||||
}
|
||||
if w:=request("POST","/files","hello","text/plain",false);w.Code!=401{t.Fatal(w.Code)}
|
||||
w:=request("POST","/files","hello","text/plain",true)
|
||||
var saved struct{Data storage.Info}
|
||||
if w.Code!=201 || json.Unmarshal(w.Body.Bytes(),&saved)!=nil || saved.Data.Size!=5{t.Fatal(w.Code,w.Body.String())}
|
||||
raw,_:=json.Marshal(map[string]string{"key":saved.Data.Key})
|
||||
w=request("POST","/files/downloadLink",string(raw),"application/json",true)
|
||||
var download struct{Data struct{URL string}}
|
||||
if w.Code!=200 || json.Unmarshal(w.Body.Bytes(),&download)!=nil{t.Fatal(w.Code,w.Body.String())}
|
||||
w=request("GET",download.Data.URL,"","",false)
|
||||
if w.Code!=200 || w.Body.String()!="hello"{t.Fatal(w.Code,w.Body.String())}
|
||||
u,_:=url.Parse(download.Data.URL);q:=u.Query();q.Set("signature","tampered");u.RawQuery=q.Encode()
|
||||
if w=request("GET",u.String(),"","",false);w.Code!=403{t.Fatal(w.Code)}
|
||||
for _,tc:=range []struct{path,body,contentType string;status int}{
|
||||
{"/files",strings.Repeat("x",65),"text/plain",413},
|
||||
{"/files","hello","image/png",415},
|
||||
{"/files","<html>hello</html>","text/plain",415},
|
||||
{"/files/downloadLink",`{"key":"../bad"}`,"application/json",400},
|
||||
{"/files/downloadLink",`{"key":"missing"}`,"application/json",404},
|
||||
{"/files/uploadLink",`{"size":65,"contentType":"text/plain"}`,"application/json",413},
|
||||
{"/files/uploadLink",`{"size":5,"contentType":"image/png"}`,"application/json",415},
|
||||
}{if w=request("POST",tc.path,tc.body,tc.contentType,true);w.Code!=tc.status{t.Fatal(tc.path,w.Code,w.Body.String())}}
|
||||
w=request("POST","/files/uploadLink",`{"size":5,"contentType":"text/plain"}`,"application/json",true)
|
||||
var direct struct{Data service.FileUploadLink}
|
||||
if w.Code!=200 || json.Unmarshal(w.Body.Bytes(),&direct)!=nil{t.Fatal(w.Code,w.Body.String())}
|
||||
if w=request("PUT",direct.Data.Request.URL,"world","text/plain",false);w.Code!=201{t.Fatal(w.Code,w.Body.String())}
|
||||
object,err:=files.Get(context.Background(),direct.Data.Key);if err!=nil{t.Fatal(err)};body,err:=io.ReadAll(object.Body);object.Body.Close();if err!=nil || string(body)!="world"{t.Fatal(err,string(body))}
|
||||
decoder:=json.NewDecoder(&logs);sawBusiness,sawDenied:=false,false
|
||||
for {var entry map[string]any;err:=decoder.Decode(&entry);if err==io.EOF{break};if err!=nil{t.Fatal(err)}
|
||||
if entry["requestId"]!="upload-test"{t.Fatal("lost request correlation")}
|
||||
if entry["msg"]=="file.uploaded"{sawBusiness=true}
|
||||
if entry["msg"]=="http.request" && entry["path"]=="/storage/object" && entry["status"]==float64(http.StatusForbidden){sawDenied=true}
|
||||
}
|
||||
if !sawBusiness || !sawDenied{t.Fatal("business log or signed-handler status missing")}
|
||||
}
|
||||
61
cmd/go-omni/scaffold/templates/modules-filesService.go.tmpl
Normal file
61
cmd/go-omni/scaffold/templates/modules-filesService.go.tmpl
Normal file
@@ -0,0 +1,61 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"
|
||||
)
|
||||
|
||||
// Files borrows provider-neutral capabilities; it never creates or closes clients.
|
||||
// Authentication/ownership rules belong to this project, outside storage adapters.
|
||||
type Files struct {
|
||||
store storage.Store
|
||||
uploader *storage.Uploader
|
||||
downloads storage.Presigner
|
||||
uploadLinks storage.UploadPresigner
|
||||
logger *logx.Logger
|
||||
ttl time.Duration
|
||||
}
|
||||
func NewFiles(store storage.Store,uploader *storage.Uploader,downloads storage.Presigner,uploadLinks storage.UploadPresigner,logger *logx.Logger,ttl time.Duration)*Files{
|
||||
return &Files{store:store,uploader:uploader,downloads:downloads,uploadLinks:uploadLinks,logger:logger.With("component","files"),ttl:ttl}
|
||||
}
|
||||
func newFileKey()(string,error){
|
||||
var id [16]byte
|
||||
if _,err:=rand.Read(id[:]);err!=nil{return "",err}
|
||||
return "uploads/"+hex.EncodeToString(id[:]),nil
|
||||
}
|
||||
func (s *Files) Upload(ctx context.Context,body io.Reader,size int64,contentType string)(storage.Info,error){
|
||||
key,err:=newFileKey();if err!=nil{return storage.Info{},err}
|
||||
info,err:=s.uploader.Put(ctx,key,body,size,contentType)
|
||||
if err!=nil{s.logger.WarnContext(ctx,"file.upload.failed","error",err);return storage.Info{},err}
|
||||
s.logger.InfoContext(ctx,"file.uploaded","key",info.Key,"size",info.Size,"contentType",info.ContentType)
|
||||
return info,nil
|
||||
}
|
||||
type FileUploadLink struct {
|
||||
Key string `json:"key"`
|
||||
Request storage.UploadRequest `json:"request"`
|
||||
}
|
||||
func (s *Files) UploadLink(ctx context.Context,size int64,contentType string)(FileUploadLink,error){
|
||||
key,err:=newFileKey();if err!=nil{return FileUploadLink{},err}
|
||||
// Check the same policy before issuing a local or cloud capability.
|
||||
// Cloud byte-level checks must happen in a post-upload validation workflow.
|
||||
request,err:=s.uploadLinks.PresignPut(ctx,key,size,contentType,s.ttl)
|
||||
if err!=nil{return FileUploadLink{},err}
|
||||
s.logger.InfoContext(ctx,"file.uploadLink.issued","key",key)
|
||||
return FileUploadLink{Key:key,Request:request},nil
|
||||
}
|
||||
func (s *Files) DownloadLink(ctx context.Context,key string)(string,error){
|
||||
if err:=storage.Check(ctx,key);err!=nil{return "",err}
|
||||
// Confirm the object exists without buffering its contents.
|
||||
object,err:=s.store.Get(ctx,key);if err!=nil{return "",err}
|
||||
if err=object.Body.Close();err!=nil{return "",err}
|
||||
link,err:=s.downloads.PresignGet(ctx,key,s.ttl)
|
||||
if err!=nil{return "",err}
|
||||
s.logger.InfoContext(ctx,"file.downloadLink.issued","key",key)
|
||||
return link.String(),nil
|
||||
}
|
||||
6
cmd/go-omni/scaffold/templates/modules-gitignore.tmpl
Normal file
6
cmd/go-omni/scaffold/templates/modules-gitignore.tmpl
Normal file
@@ -0,0 +1,6 @@
|
||||
/var/
|
||||
/server
|
||||
.env
|
||||
.env.*
|
||||
*.local.yaml
|
||||
*.local.json
|
||||
50
cmd/go-omni/scaffold/templates/modules-main.go.tmpl
Normal file
50
cmd/go-omni/scaffold/templates/modules-main.go.tmpl
Normal file
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"{{.Module}}/internal/handler"
|
||||
"{{.Module}}/internal/service"
|
||||
"{{.Module}}/internal/servicecontext"
|
||||
"{{.Module}}/internal/settings"
|
||||
)
|
||||
|
||||
func main(){
|
||||
path:=flag.String("config","etc/config.yaml","JSON/YAML settings; 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}
|
||||
logger,err:=logx.New(cfg.Server.Log)
|
||||
if err!=nil{return err}
|
||||
defer func(){runErr=errors.Join(runErr,logger.Close())}()
|
||||
logx.SetDefault(logger)
|
||||
services,err:=servicecontext.New(ctx,logger)
|
||||
if err!=nil{return err}
|
||||
defer func(){runErr=errors.Join(runErr,services.Close())}()
|
||||
{{if ne .Storage "none"}}if err=services.InitStorage(ctx,cfg.Storage);err!=nil{return err}{{end}}
|
||||
application:=omni.New(omni.WithConfig(cfg.Server))
|
||||
application.OnShutdown(logger.Close)
|
||||
application.OnShutdown(services.Close)
|
||||
application.GET("/health/live",services.Health.LivenessHandler())
|
||||
application.GET("/health/ready",services.Health.ReadinessHandler())
|
||||
handler.NewUsers(service.NewUsers(services)).RegisterRoutes(application)
|
||||
{{if ne .Storage "none"}}
|
||||
files:=service.NewFiles(services.Files,services.Uploads,services.Downloads,services.UploadLinks,logger,cfg.Storage.LinkTTL)
|
||||
if err=handler.NewFiles(files,cfg.Storage.MaxBytes,cfg.Storage.RequestTimeout).RegisterRoutes(application,cfg.Storage.FilesToken,services.FileHTTP);err!=nil{return err}
|
||||
{{end}}
|
||||
logger.InfoContext(ctx,"service.starting","address",cfg.Server.Address)
|
||||
return application.RunContext(ctx,cfg.Server.Address)
|
||||
}
|
||||
140
cmd/go-omni/scaffold/templates/modules-settings.go.tmpl
Normal file
140
cmd/go-omni/scaffold/templates/modules-settings.go.tmpl
Normal file
@@ -0,0 +1,140 @@
|
||||
// Package settings owns this project's configuration; it adds no business identity model.
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
{{if ne .Storage "none"}}"fmt"
|
||||
{{if ne .Storage "r2"}}"net/url"{{else}}"regexp"{{end}}
|
||||
"strconv"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"{{end}}
|
||||
{{if and .API (eq .Database "mysql")}}"git.yuntuo.hk/k2safe/go-omni/database"{{end}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server config.Config `json:"server"`
|
||||
{{if and .API (eq .Database "mysql")}}Database database.Config `json:"database"`{{end}}
|
||||
{{if ne .Storage "none"}}Storage StorageConfig `json:"storage"`{{end}}
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
server:=config.Default()
|
||||
server.Address="127.0.0.1:8080"
|
||||
server.EnableLogger=true
|
||||
server.SlowRequestThreshold=500*time.Millisecond
|
||||
server.Log.Service={{printf "%q" .Module}}
|
||||
server.Log.Format={{printf "%q" .LogFormat}}
|
||||
{{if eq .LogOutput "file"}}server.Log.File="./var/log/server.log"
|
||||
server.Log.Rotation=logx.RotationConfig{Enabled:true,MaxSizeMB:100,MaxBackups:10,MaxAgeDays:7,Compress:true}
|
||||
{{else}}server.Log.Output={{printf "%q" .LogOutput}}{{end}}
|
||||
return Config{Server:server{{if and .API (eq .Database "mysql")}},Database:database.DefaultConfig(){{end}}{{if ne .Storage "none"}},Storage:StorageConfig{
|
||||
Provider:{{printf "%q" .Storage}}, Root:"./var/files", PublicURL:"http://127.0.0.1:8080/storage/object",
|
||||
Secure:true, MaxBytes:32<<20, ContentTypes:[]string{"text/plain","image/png","image/jpeg","application/pdf"},
|
||||
LinkTTL:5*time.Minute, RequestTimeout:30*time.Second,
|
||||
{{if eq .Storage "s3"}}Region:"us-east-1",{{end}}
|
||||
{{if eq .Storage "r2"}}Region:"auto",{{end}}
|
||||
{{if eq .Storage "oss"}}Region:"cn-hangzhou",{{end}}
|
||||
}{{end}}}
|
||||
}
|
||||
|
||||
// Load uses defaults -> strict YAML/JSON -> nonempty environment variables.
|
||||
// Pass an empty path to use only defaults and environment.
|
||||
func Load(path string)(Config,error) {
|
||||
cfg:=Default()
|
||||
var err error
|
||||
if path!="" { cfg,err=config.DecodeFile(path,cfg);if err!=nil{return Config{},err} }
|
||||
cfg.Server,err=config.ApplyEnv(cfg.Server,"")
|
||||
if err!=nil{return Config{},err}
|
||||
// A stream explicitly selected by environment overrides a file profile.
|
||||
if output:=strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT"));output!="" && strings.TrimSpace(os.Getenv("GO_OMNI_LOG_FILE"))=="" {
|
||||
cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{}
|
||||
}
|
||||
{{if ne .Storage "none"}}
|
||||
if err=cfg.Storage.applyEnv();err!=nil{return Config{},err}
|
||||
{{end}}
|
||||
{{if and .API (eq .Database "mysql")}}cfg.Database,err=database.ApplyEnv(cfg.Database,"");if err!=nil{return Config{},err}{{end}}
|
||||
if err=cfg.Validate();err!=nil{return Config{},err}
|
||||
return cfg,nil
|
||||
}
|
||||
func (c Config) Validate()error {
|
||||
{{if and .API (eq .Database "mysql")}}
|
||||
if err:=c.Database.Validate();err!=nil{return err}
|
||||
if c.Database.Driver!="mysql" || c.Database.MaxOpenConns<1 {return errors.New("database requires driver mysql and a positive maxOpenConns")}
|
||||
{{end}}
|
||||
if err:=c.Server.Validate();err!=nil{return err}
|
||||
if c.Server.Log.Format!="json" && c.Server.Log.Format!="text" {return errors.New("log.format must be json or text")}
|
||||
switch strings.ToLower(c.Server.Log.Level) {case "debug","info","warn","warning","error":default:return errors.New("invalid log.level")}
|
||||
{{if ne .Storage "none"}}return c.Storage.Validate(){{else}}return nil{{end}}
|
||||
}
|
||||
|
||||
{{if ne .Storage "none"}}
|
||||
type StorageConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
Root string `json:"root"`
|
||||
PublicURL string `json:"publicUrl"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccountID string `json:"accountId"`
|
||||
Secure bool `json:"secure"`
|
||||
MaxBytes int64 `json:"maxBytes"`
|
||||
ContentTypes []string `json:"contentTypes"`
|
||||
LinkTTL time.Duration `json:"linkTtl"`
|
||||
RequestTimeout time.Duration `json:"requestTimeout"`
|
||||
// Secrets are environment-only and cannot be populated by config files.
|
||||
FilesToken string `json:"-"`
|
||||
SigningKey string `json:"-"`
|
||||
AccessKey string `json:"-"`
|
||||
SecretKey string `json:"-"`
|
||||
SessionToken string `json:"-"`
|
||||
}
|
||||
func (s StorageConfig) Policy()storage.UploadPolicy{return storage.UploadPolicy{MaxBytes:s.MaxBytes,ContentTypes:s.ContentTypes}}
|
||||
func (s *StorageConfig) applyEnv()error {
|
||||
values:=map[string]*string{
|
||||
"GO_OMNI_STORAGE_ROOT":&s.Root,"GO_OMNI_STORAGE_PUBLIC_URL":&s.PublicURL,
|
||||
"GO_OMNI_STORAGE_ENDPOINT":&s.Endpoint,"GO_OMNI_STORAGE_REGION":&s.Region,
|
||||
"GO_OMNI_STORAGE_BUCKET":&s.Bucket,"GO_OMNI_STORAGE_ACCOUNT_ID":&s.AccountID,
|
||||
"GO_OMNI_STORAGE_SIGNING_KEY":&s.SigningKey,"GO_OMNI_FILES_TOKEN":&s.FilesToken,
|
||||
"GO_OMNI_STORAGE_ACCESS_KEY":&s.AccessKey,"GO_OMNI_STORAGE_SECRET_KEY":&s.SecretKey,
|
||||
"GO_OMNI_STORAGE_SESSION_TOKEN":&s.SessionToken,
|
||||
}
|
||||
for name,target:=range values {if value:=strings.TrimSpace(os.Getenv(name));value!=""{*target=value}}
|
||||
for name,target:=range map[string]*time.Duration{"GO_OMNI_STORAGE_LINK_TTL":&s.LinkTTL,"GO_OMNI_STORAGE_REQUEST_TIMEOUT":&s.RequestTimeout} {
|
||||
if value:=strings.TrimSpace(os.Getenv(name));value!="" {v,err:=time.ParseDuration(value);if err!=nil{return fmt.Errorf("%s must be a duration",name)};*target=v}
|
||||
}
|
||||
if value:=os.Getenv("GO_OMNI_STORAGE_MAX_BYTES");value!=""{v,err:=strconv.ParseInt(value,10,64);if err!=nil{return errors.New("GO_OMNI_STORAGE_MAX_BYTES must be an integer")};s.MaxBytes=v}
|
||||
if value:=os.Getenv("GO_OMNI_STORAGE_SECURE");value!=""{v,err:=strconv.ParseBool(value);if err!=nil{return errors.New("GO_OMNI_STORAGE_SECURE must be a boolean")};s.Secure=v}
|
||||
if value:=strings.TrimSpace(os.Getenv("GO_OMNI_STORAGE_CONTENT_TYPES"));value!="" {s.ContentTypes=strings.Split(value,",");for i:=range s.ContentTypes{s.ContentTypes[i]=strings.TrimSpace(s.ContentTypes[i])}}
|
||||
return nil
|
||||
}
|
||||
func (s StorageConfig) Validate()error {
|
||||
if s.Provider!={{printf "%q" .Storage}} {return errors.New("storage.provider must match the adapter selected at generation")}
|
||||
if err:=s.Policy().Validate();err!=nil{return err}
|
||||
if s.LinkTTL<time.Second || s.LinkTTL>time.Hour{return errors.New("storage.linkTtl must be 1s..1h")}
|
||||
if s.RequestTimeout<=0 || s.RequestTimeout>5*time.Minute{return errors.New("storage.requestTimeout must be >0 and <=5m")}
|
||||
{{if not .API}}if len(s.FilesToken)<32 || strings.ContainsAny(s.FilesToken," \t\r\n") {return errors.New("set GO_OMNI_FILES_TOKEN to a token of at least 32 bytes without whitespace")}{{end}}
|
||||
{{if eq .Storage "local"}}
|
||||
if s.Root=="" {return errors.New("storage.root is required")}
|
||||
u,err:=url.Parse(s.PublicURL)
|
||||
if err!=nil || u.Host=="" || u.Hostname()=="" || (u.Scheme!="http" && u.Scheme!="https") || u.User!=nil || u.RawQuery!="" || u.Fragment!="" || u.Path!="/storage/object" {return errors.New("storage.publicUrl must be an absolute HTTP(S) URL ending in /storage/object")}
|
||||
if len(s.SigningKey)<32 {return errors.New("set GO_OMNI_STORAGE_SIGNING_KEY to a stable secret of at least 32 bytes")}
|
||||
{{else}}
|
||||
if s.Bucket=="" || s.Region=="" || s.AccessKey=="" || s.SecretKey=="" {return errors.New("storage bucket, region and GO_OMNI_STORAGE_ACCESS_KEY/SECRET_KEY are required")}
|
||||
{{if eq .Storage "r2"}}
|
||||
if !regexp.MustCompile(`^[a-fA-F0-9]{32}$`).MatchString(s.AccountID) || s.Region!="auto" || !s.Secure {return errors.New("R2 requires a 32-hex accountId, region auto and secure=true")}
|
||||
if s.Endpoint!=""{return errors.New("this R2 profile derives endpoint from accountId; customize servicecontext for a jurisdiction-specific endpoint")}
|
||||
{{else if eq .Storage "s3"}}
|
||||
u,err:=url.Parse("https://"+s.Endpoint)
|
||||
if err!=nil || s.Endpoint=="" || u.Hostname()=="" || u.User!=nil || u.Path!="" || u.RawQuery!="" || u.Fragment!="" {return errors.New("S3 endpoint must be host[:port] without scheme or path")}
|
||||
{{else if eq .Storage "oss"}}
|
||||
if s.Endpoint!="" {u,err:=url.Parse(s.Endpoint);if err!=nil || u.Scheme!="https" || u.Hostname()=="" || u.User!=nil || u.Path!="" || u.RawQuery!="" || u.Fragment!="" {return errors.New("OSS endpoint must be an HTTPS origin")}}
|
||||
if !s.Secure{return errors.New("OSS requires secure=true")}
|
||||
{{end}}
|
||||
{{end}}
|
||||
return nil
|
||||
}
|
||||
{{end}}
|
||||
38
cmd/go-omni/scaffold/templates/modules-settings_test.go.tmpl
Normal file
38
cmd/go-omni/scaffold/templates/modules-settings_test.go.tmpl
Normal file
@@ -0,0 +1,38 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
func environment(t *testing.T){
|
||||
t.Helper()
|
||||
for _,entry:=range os.Environ(){key,_,_:=strings.Cut(entry,"=");if strings.HasPrefix(key,"GO_OMNI_"){t.Setenv(key,"")}}
|
||||
{{if ne .Storage "none"}}
|
||||
t.Setenv("GO_OMNI_FILES_TOKEN",strings.Repeat("t",32))
|
||||
t.Setenv("GO_OMNI_STORAGE_SIGNING_KEY",strings.Repeat("s",32))
|
||||
t.Setenv("GO_OMNI_STORAGE_ACCESS_KEY","fixtureAccess")
|
||||
t.Setenv("GO_OMNI_STORAGE_SECRET_KEY","fixtureSecret")
|
||||
t.Setenv("GO_OMNI_STORAGE_BUCKET","testbucket")
|
||||
{{if eq .Storage "s3"}}t.Setenv("GO_OMNI_STORAGE_ENDPOINT","127.0.0.1:9000"){{end}}
|
||||
{{if eq .Storage "r2"}}t.Setenv("GO_OMNI_STORAGE_ACCOUNT_ID",strings.Repeat("a",32)){{end}}
|
||||
{{end}}
|
||||
}
|
||||
func TestSettingsPrecedenceAndStrictness(t *testing.T){
|
||||
environment(t)
|
||||
path:=filepath.Join(t.TempDir(),"config.yaml")
|
||||
if err:=os.WriteFile(path,[]byte("server:\n address: '127.0.0.1:8123'\n log:\n level: debug\n"),0600);err!=nil{t.Fatal(err)}
|
||||
t.Setenv("GO_OMNI_HTTP_ADDR","127.0.0.1:9123")
|
||||
t.Setenv("GO_OMNI_LOG_OUTPUT","stdout")
|
||||
cfg,err:=Load(path);if err!=nil{t.Fatal(err)}
|
||||
if cfg.Server.Address!="127.0.0.1:9123" || cfg.Server.Log.Level!="debug" || cfg.Server.Log.Output!="stdout" || cfg.Server.Log.File!="" || cfg.Server.Log.Rotation.Enabled {t.Fatal("configuration precedence failed")}
|
||||
for _,body:=range []string{"unexpected: true","server:\n slowRequestThreshold: -1s","server:\n log:\n format: invalid"{{if ne .Storage "none"}},"storage:\n filesToken: forbidden","storage:\n maxBytes: -1","storage:\n provider: unsupported"{{end}}}{
|
||||
if err:=os.WriteFile(path,[]byte(body),0600);err!=nil{t.Fatal(err)}
|
||||
if _,err:=Load(path);err==nil{t.Fatal("invalid configuration accepted")}
|
||||
}
|
||||
{{if ne .Storage "none"}}
|
||||
t.Setenv("GO_OMNI_FILES_TOKEN","")
|
||||
if _,err:=Load("");err==nil{t.Fatal("missing file token accepted")}
|
||||
{{end}}
|
||||
}
|
||||
79
cmd/go-omni/scaffold/templates/modules-storage.go.tmpl
Normal file
79
cmd/go-omni/scaffold/templates/modules-storage.go.tmpl
Normal file
@@ -0,0 +1,79 @@
|
||||
package servicecontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
{{if eq .Storage "local"}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage/local"
|
||||
{{else}}
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
{{if eq .Storage "oss"}}
|
||||
ossStore "git.yuntuo.hk/k2safe/go-omni/storage/oss"
|
||||
ossSDK "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
|
||||
{{else}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage/s3"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
{{end}}{{end}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"
|
||||
"{{.Module}}/internal/settings"
|
||||
)
|
||||
|
||||
// InitStorage is called once, before serving requests. The composition root
|
||||
// owns provider clients; business services receive only capability interfaces.
|
||||
func (c *Context) InitStorage(ctx context.Context,cfg settings.StorageConfig)error {
|
||||
if c==nil || ctx==nil{return errors.New("storage initialization requires context")}
|
||||
c.closeMu.Lock();defer c.closeMu.Unlock()
|
||||
if c.closed{return errors.New("service dependencies are closed")}
|
||||
if err:=ctx.Err();err!=nil{return err}
|
||||
if c.Files!=nil{return errors.New("storage already initialized")}
|
||||
if err:=cfg.Validate();err!=nil{return err}
|
||||
{{if eq .Storage "local"}}
|
||||
files,err:=local.New(cfg.Root,cfg.MaxBytes)
|
||||
if err!=nil{return err}
|
||||
signer,err:=storage.NewUploadSigner(files,cfg.PublicURL,[]byte(cfg.SigningKey),cfg.Policy())
|
||||
if err!=nil{return err}
|
||||
uploader,err:=storage.NewUploader(files,cfg.Policy())
|
||||
if err!=nil{return err}
|
||||
c.Files,c.Uploads,c.Downloads,c.UploadLinks,c.FileHTTP=files,uploader,signer,signer,signer
|
||||
{{else}}
|
||||
// Dedicated bounded pool, never the process-wide default transport.
|
||||
transport:=&http.Transport{
|
||||
Proxy:http.ProxyFromEnvironment,
|
||||
DialContext:(&net.Dialer{Timeout:5*time.Second,KeepAlive:30*time.Second}).DialContext,
|
||||
ForceAttemptHTTP2:true, MaxIdleConns:32,MaxIdleConnsPerHost:8,MaxConnsPerHost:32,
|
||||
IdleConnTimeout:90*time.Second,TLSHandshakeTimeout:5*time.Second,
|
||||
ResponseHeaderTimeout:cfg.RequestTimeout,
|
||||
}
|
||||
initialized:=false
|
||||
defer func(){if !initialized{transport.CloseIdleConnections()}}()
|
||||
{{if eq .Storage "oss"}}
|
||||
sdkConfig:=ossSDK.LoadDefaultConfig().WithRegion(cfg.Region).
|
||||
WithSignatureVersion(ossSDK.SignatureVersionV4).
|
||||
WithAdditionalHeaders([]string{"content-length"}).
|
||||
WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKey,cfg.SecretKey,cfg.SessionToken)).
|
||||
WithHttpClient(&http.Client{Transport:transport,Timeout:cfg.RequestTimeout})
|
||||
if cfg.Endpoint!=""{sdkConfig.WithEndpoint(cfg.Endpoint)}
|
||||
files,err:=ossStore.New(ossSDK.NewClient(sdkConfig),cfg.Bucket,cfg.MaxBytes)
|
||||
{{else}}
|
||||
creds:=credentials.NewStaticV4(cfg.AccessKey,cfg.SecretKey,cfg.SessionToken)
|
||||
{{if eq .Storage "r2"}}
|
||||
options,err:=s3.R2Config(cfg.AccountID,cfg.Bucket,creds)
|
||||
if err!=nil{return err}
|
||||
options.MaxBytes,options.Transport=cfg.MaxBytes,transport
|
||||
{{else}}
|
||||
options:=s3.Config{Endpoint:cfg.Endpoint,Region:cfg.Region,Bucket:cfg.Bucket,Secure:cfg.Secure,MaxBytes:cfg.MaxBytes,Credentials:creds,Transport:transport}
|
||||
{{end}}
|
||||
files,err:=s3.Open(options)
|
||||
{{end}}
|
||||
if err!=nil{return err}
|
||||
uploader,err:=storage.NewUploader(files,cfg.Policy())
|
||||
if err!=nil{return err}
|
||||
c.Files,c.Uploads,c.Downloads,c.UploadLinks=files,uploader,files,uploader
|
||||
c.storageClose=func()error{transport.CloseIdleConnections();return nil}
|
||||
initialized=true
|
||||
{{end}}
|
||||
return nil
|
||||
}
|
||||
38
cmd/go-omni/scaffold/templates/modules-storage_test.go.tmpl
Normal file
38
cmd/go-omni/scaffold/templates/modules-storage_test.go.tmpl
Normal file
@@ -0,0 +1,38 @@
|
||||
package servicecontext
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"{{.Module}}/internal/settings"
|
||||
)
|
||||
func TestStorageInitializationAndOwnership(t *testing.T){
|
||||
cfg:=settings.Default().Storage
|
||||
cfg.FilesToken=strings.Repeat("t",32)
|
||||
cfg.SigningKey=strings.Repeat("s",32)
|
||||
cfg.Root=t.TempDir()
|
||||
cfg.AccessKey,cfg.SecretKey,cfg.Bucket="fixtureAccess","fixtureSecret","testbucket"
|
||||
{{if eq .Storage "s3"}}cfg.Endpoint="127.0.0.1:9000"{{end}}
|
||||
{{if eq .Storage "r2"}}cfg.AccountID=strings.Repeat("a",32){{end}}
|
||||
var output bytes.Buffer
|
||||
logger,err:=logx.New(logx.Config{Writer:&output});if err!=nil{t.Fatal(err)};defer logger.Close()
|
||||
c:=&Context{Logger:logger}
|
||||
canceled,cancel:=context.WithCancel(context.Background());cancel()
|
||||
if err=c.InitStorage(canceled,cfg);err==nil || c.Files!=nil{t.Fatal("canceled startup initialized storage")}
|
||||
invalid:=cfg;invalid.FilesToken=""
|
||||
if err=c.InitStorage(context.Background(),invalid);err==nil || c.Files!=nil{t.Fatal("invalid settings initialized storage")}
|
||||
if err=c.InitStorage(context.Background(),cfg);err!=nil{t.Fatal(err)}
|
||||
if c.Files==nil || c.Uploads==nil || c.Downloads==nil || c.UploadLinks==nil{t.Fatal("dependencies not assembled")}
|
||||
if err=c.InitStorage(context.Background(),cfg);err==nil{t.Fatal("duplicate initialization accepted")}
|
||||
cleanup:=c.storageClose;count:=0
|
||||
c.storageClose=func()error{count++;if cleanup!=nil{return cleanup()};return nil}
|
||||
if err=c.Close();err!=nil{t.Fatal(err)};if err=c.Close();err!=nil{t.Fatal(err)}
|
||||
logger.Info("still usable")
|
||||
if count!=1 || !strings.Contains(output.String(),"still usable"){t.Fatal("resource ownership violated")}
|
||||
closed:=&Context{Logger:logger}
|
||||
if err=closed.Close();err!=nil{t.Fatal(err)}
|
||||
if err=closed.InitStorage(context.Background(),cfg);err==nil || closed.Files!=nil{t.Fatal("closed context reopened resources")}
|
||||
}
|
||||
@@ -10,7 +10,19 @@ 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.6.0-preview"
|
||||
const defaultFrameworkVersion = "v0.7.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.
|
||||
const modulesFrameworkVersion = "v0.7.0-preview"
|
||||
|
||||
func installedFrameworkVersion() string {
|
||||
info, _ := debug.ReadBuildInfo()
|
||||
if info != nil && info.Main.Path == frameworkModule && info.Main.Sum != "" && validFrameworkVersion(info.Main.Version) {
|
||||
return info.Main.Version
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var frameworkVersionPattern = regexp.MustCompile(`^v[01]\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user