feat: release generic composition mapping and middleware factories
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# go-omni CLI
|
||||
|
||||
v0.17 增加公共字段组合、可选结构映射、自动中间件工厂和按需 Repository。配置和迁移见 [生成能力指南](../../GENERATION.md)。
|
||||
|
||||
v0.16 增加基础类型/动态 JSON 数组返回、OpenAPI 公共引用和 workspace Go Catalog 统一导出。见 [接入与筛选规则](../../DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。
|
||||
|
||||
v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目录>|--apply]`,以及 `workspace format|generate|check --file omni.workspace.json`。详见 [通用开发指南](../../DEVELOPMENT.md)。
|
||||
@@ -10,14 +12,14 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目
|
||||
|
||||
```sh
|
||||
# 仓库读取权限与 PATH 配置见根目录 README
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.17.0-preview
|
||||
go-omni new --module example.com/hello ./hello
|
||||
|
||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||
go-omni new --api service.api --module example.com/example ./example
|
||||
```
|
||||
|
||||
`new` 与 `api new` 统一生成契约项目;不再提供另一套 CRUD 启动目录。未实现的 Logic / 中间件骨架返回 501。进入项目执行 `go mod tidy`、`go run .`、`go build .`。
|
||||
`new` 与 `api new` 统一生成契约项目;不再提供另一套 CRUD 启动目录。未实现的 Logic 返回 501;中间件工厂未实现时阻止启动,既有 handler 模式仍返回 501。进入项目执行 `go mod tidy`、`go run .`、`go build .`。
|
||||
|
||||
参数:`--database none|memory|mysql`(默认 none;memory 等同 none),`--storage none|local|s3|r2|oss`,`--log-output stdout|stderr|file`,`--log-format json|text`。基础设施按需生成,连接凭据由环境提供,不自动创建数据库、表或云资源。选择 MySQL 不会凭空生成业务表或 Repository 查询。
|
||||
|
||||
@@ -45,14 +47,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
||||
|
||||
## 版本与迁移
|
||||
|
||||
CLI 与框架依赖固定使用 v0.16.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
CLI 与框架依赖固定使用 v0.17.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
|
||||
```sh
|
||||
# 在 go-omni 源码仓库中执行
|
||||
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
||||
```
|
||||
|
||||
模板最低需要 v0.16.0-preview 的统一环境变量别名能力,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
当前模板与依赖以 v0.17.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
|
||||
## 数据库迁移命令
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@ func TestRegenerationAndConflicts(t *testing.T) {
|
||||
servicePath := filepath.Join(dir, "internal/logic/greet/greet.go")
|
||||
service := readFile(t, servicePath) + "\n// user edit must survive\n"
|
||||
writeFile(t, servicePath, service)
|
||||
writeFile(t, filepath.Join(dir, "internal/repository/greet/repository.go"), "package greet\n")
|
||||
for _, file := range []string{"etc/config.yaml", "internal/svc/serviceContext.go", "internal/repository/greet/repository.go"} {
|
||||
body := readFile(t, filepath.Join(dir, file))
|
||||
if strings.HasSuffix(file, ".go") {
|
||||
@@ -301,6 +302,7 @@ func TestGeneratedProjectsBuildAndRun(t *testing.T) {
|
||||
if starter {
|
||||
_, err = New(o)
|
||||
} else {
|
||||
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
|
||||
o.API = filepath.Join(root, "examples/contracts/user.api")
|
||||
_, err = Generate(o)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ func TestBusinessGeneratedProject(t *testing.T) {
|
||||
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "business")
|
||||
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
|
||||
o := Options{Dir: dir, API: filepath.Join(frameworkRoot(t), "examples/contracts/business.api"), Module: "example.com/business", FrameworkDir: frameworkRoot(t)}
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -40,6 +40,9 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
if prepareErr != nil {
|
||||
return nil, prepareErr
|
||||
}
|
||||
if err := expandFields(spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDocumentCatalog(spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
102
cmd/go-omni/apigen/composition.go
Normal file
102
cmd/go-omni/apigen/composition.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// expandFields copies definitions rather than embedding Go structs. Binding,
|
||||
// validation, DTOs, snapshots and OpenAPI all consume the same flattened fields.
|
||||
func expandFields(spec *Spec) error {
|
||||
definitions := map[string]Type{}
|
||||
for _, t := range spec.Types {
|
||||
if _, exists := definitions[t.Name]; exists {
|
||||
return fmt.Errorf("%s: duplicate type %s", t.Position, t.Name)
|
||||
}
|
||||
definitions[t.Name] = t
|
||||
}
|
||||
expanded := map[string]Type{}
|
||||
heights := map[string]int{}
|
||||
visiting := map[string]bool{}
|
||||
total := 0
|
||||
var expand func(string, int) (Type, error)
|
||||
expand = func(name string, depth int) (Type, error) {
|
||||
if t, ok := expanded[name]; ok {
|
||||
if depth+heights[name] > 32 {
|
||||
return Type{}, fmt.Errorf("field composition exceeds 32 levels at %s", name)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
if visiting[name] {
|
||||
return Type{}, fmt.Errorf("field composition cycle at %s", name)
|
||||
}
|
||||
if depth > 32 {
|
||||
return Type{}, fmt.Errorf("field composition exceeds 32 levels at %s", name)
|
||||
}
|
||||
t, ok := definitions[name]
|
||||
if !ok {
|
||||
return Type{}, fmt.Errorf("unknown composed object type %s", name)
|
||||
}
|
||||
visiting[name] = true
|
||||
defer delete(visiting, name)
|
||||
fields := []Field{}
|
||||
height := 0
|
||||
seen, wire := map[string]bool{}, map[string]bool{}
|
||||
for _, f := range t.Fields {
|
||||
add := []Field{f}
|
||||
if f.Type == "" {
|
||||
child, err := expand(f.Name, depth+1)
|
||||
if err != nil {
|
||||
return Type{}, fmt.Errorf("%s.%s: %w", name, f.Name, err)
|
||||
}
|
||||
add = child.Fields
|
||||
if heights[f.Name]+1 > height {
|
||||
height = heights[f.Name] + 1
|
||||
}
|
||||
if height > 32 {
|
||||
return Type{}, fmt.Errorf("field composition exceeds 32 levels at %s", name)
|
||||
}
|
||||
}
|
||||
for _, field := range add {
|
||||
if seen[field.Name] {
|
||||
return Type{}, fmt.Errorf("%s: conflicting composed field %s", name, field.Name)
|
||||
}
|
||||
seen[field.Name] = true
|
||||
for _, source := range []string{"json", "form", "path", "header", "multipart", "multipartJSON", "file"} {
|
||||
tag := strings.Split(reflect.StructTag(field.Tag).Get(source), ",")[0]
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
if source == "header" {
|
||||
tag = strings.ToLower(tag)
|
||||
}
|
||||
key := source + ":" + tag
|
||||
if wire[key] {
|
||||
return Type{}, fmt.Errorf("%s: conflicting composed field tag %s", name, key)
|
||||
}
|
||||
wire[key] = true
|
||||
}
|
||||
fields = append(fields, field)
|
||||
total++
|
||||
if len(fields) > 4096 || total > 65536 {
|
||||
return Type{}, fmt.Errorf("field composition expansion limit exceeded")
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fields = fields
|
||||
expanded[name] = t
|
||||
heights[name] = height
|
||||
return t, nil
|
||||
}
|
||||
result := make([]Type, 0, len(spec.Types))
|
||||
for _, t := range spec.Types {
|
||||
out, err := expand(t.Name, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result = append(result, out)
|
||||
}
|
||||
spec.Types = result
|
||||
return nil
|
||||
}
|
||||
163
cmd/go-omni/apigen/composition_test.go
Normal file
163
cmd/go-omni/apigen/composition_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFieldComposition(t *testing.T) {
|
||||
source := readFile(t, "testdata/composition/service.api")
|
||||
spec, err := parseText(t, source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(first.Document, second.Document) || len(spec.Types[1].Fields) != 1 {
|
||||
t.Fatal("composition mutates input")
|
||||
}
|
||||
for _, name := range []string{"EnterpriseRequest", "TenantRequest", "AdminRequest"} {
|
||||
schema := first.Bindings[name].BodySchema()
|
||||
raw, _ := json.Marshal(schema)
|
||||
if !bytes.Contains(raw, []byte("Employee display name.")) || bytes.Contains(raw, []byte("SalaryFields")) {
|
||||
t.Fatalf("flattened schema/comment missing: %s", raw)
|
||||
}
|
||||
}
|
||||
cases := []struct{ name, decl, want string }{
|
||||
{"unknown", "type Req {\n Missing\n}", "unknown composed"},
|
||||
{"cycle", "type Req {\n Other\n}\ntype Other {\n Req\n}", "cycle"},
|
||||
{"conflict", "type Base {\n Name string `json:\"name\"`\n}\ntype Req {\n Base\n Name string `json:\"other\"`\n}", "conflicting"},
|
||||
{"wireConflict", "type Base {\n Name string `json:\"name\"`\n}\ntype Req {\n Base\n Other string `json:\"name\"`\n}", "conflicting"},
|
||||
{"tagged", "type Req {\n Other `json:\"other\"`\n}", "cannot carry"},
|
||||
{"diamond", "type Base {\n Name string `json:\"name\"`\n}\ntype Left {\n Base\n}\ntype Right {\n Base\n}\ntype Req {\n Left\n Right\n}", "conflicting"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, e := parseText(t, tc.decl+"\nservice demo{\n@handler post\npost / (Req)\n}")
|
||||
if e == nil {
|
||||
_, e = Compile(s)
|
||||
}
|
||||
if e == nil || !strings.Contains(e.Error(), tc.want) {
|
||||
t.Fatal(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Imports can contain reusable definitions and retain the existing root policy.
|
||||
dir := t.TempDir()
|
||||
writeFile(t, filepath.Join(dir, "base.api"), "type Base {\n Name string `json:\"name\"`\n}\n")
|
||||
writeFile(t, filepath.Join(dir, "service.api"), "import \"base.api\"\ntype Req {\n Base\n}\nservice demo{\n@handler post\npost / (Req)\n}")
|
||||
if _, err := FormatProject(filepath.Join(dir, "service.api"), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := FormatProject(filepath.Join(dir, "service.api"), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryGenerationOptions(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "app")
|
||||
if _, err := New(Options{Dir: dir, Module: "example.com/repository", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "internal/repository")); !os.IsNotExist(err) {
|
||||
t.Fatal("default creates repository")
|
||||
}
|
||||
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
|
||||
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"repository":"group","middleware":"factories"}`)
|
||||
if _, err := Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := filepath.Join(dir, "internal/repository/greet/repository.go")
|
||||
original := readFile(t, repo) + "\n// business SQL belongs here\n"
|
||||
writeFile(t, repo, original)
|
||||
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"repository":"none","middleware":"factories"}`)
|
||||
if _, err := Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o.Check = true
|
||||
if _, err := Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if readFile(t, repo) != original {
|
||||
t.Fatal("repository removed or overwritten")
|
||||
}
|
||||
for _, raw := range []string{`{"repository":"oops"}`, `{"middleware":"oops"}`, `{"unknown":true}`, `{"repository":"none","repository":"group"}`} {
|
||||
writeFile(t, filepath.Join(dir, generatorConfigFile), raw)
|
||||
before := snapshot(t, dir)
|
||||
if _, err := Generate(o); err == nil {
|
||||
t.Fatal("accepted", raw)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("invalid config wrote files")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositionExpansionBound(t *testing.T) {
|
||||
var source strings.Builder
|
||||
source.WriteString("type Base {\n Value string `json:\"value\"`\n}\n")
|
||||
last := "Base"
|
||||
for i := 0; i < 35; i++ {
|
||||
name := fmt.Sprintf("Level%d", i)
|
||||
fmt.Fprintf(&source, "type %s {\n %s\n}\n", name, last)
|
||||
last = name
|
||||
}
|
||||
// Reverse order forces traversal instead of hitting pre-expanded definitions.
|
||||
spec, err := parseText(t, source.String()+"service demo{\n@handler post\npost / ("+last+")\n}")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, j := 0, len(spec.Types)-1; i < j; i, j = i+1, j-1 {
|
||||
spec.Types[i], spec.Types[j] = spec.Types[j], spec.Types[i]
|
||||
}
|
||||
if _, err = Compile(spec); err == nil {
|
||||
t.Fatal("unbounded composition accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationConfigMissingAndLegacy(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "app")
|
||||
if _, err := New(Options{Dir: dir, Module: "example.com/config", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
|
||||
if err := os.Remove(filepath.Join(dir, generatorConfigFile)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := snapshot(t, dir)
|
||||
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "restore") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("missing config changed project")
|
||||
}
|
||||
// A genuine old manifest has never tracked a generator config.
|
||||
var old manifest
|
||||
if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, manifestPath))), &old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old.GeneratorConfig = false
|
||||
raw, _ := json.Marshal(old)
|
||||
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
|
||||
if _, err := Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, exists, err := readGeneratorConfig(dir)
|
||||
if err != nil || !exists || cfg.Middleware != "handlers" || cfg.Repository != "none" {
|
||||
t.Fatal(cfg, exists, err)
|
||||
}
|
||||
if !strings.Contains(readFile(t, filepath.Join(dir, "internal/svc/runtimeGen.go")), "middleware.Defaults()") {
|
||||
t.Fatal("legacy registration replaced")
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,11 @@ import (
|
||||
)
|
||||
|
||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
const minimumVersion = "v0.16.0-preview"
|
||||
const minimumVersion = "v0.17.0-preview"
|
||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||
|
||||
type Options struct {
|
||||
generation GeneratorConfig
|
||||
lockHeld bool
|
||||
documentCatalogs map[string][]byte
|
||||
Database, Storage string
|
||||
@@ -97,6 +98,11 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
return Output{}, err
|
||||
}
|
||||
o.Dir = directory
|
||||
genConfig, hasGenConfig, err := readGeneratorConfig(directory)
|
||||
if err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
o.generation = genConfig
|
||||
if err = checkRuntimeExtension(directory); err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
@@ -192,6 +198,10 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
for name, data := range base {
|
||||
seeds[name] = data
|
||||
}
|
||||
if !hasGenConfig {
|
||||
raw, _ := json.MarshalIndent(genConfig, "", " ")
|
||||
seeds[generatorConfigFile] = append(raw, '\n')
|
||||
}
|
||||
// The API source remains developer-owned. A relocated project regenerates
|
||||
// from its own api/ tree, including all imports.
|
||||
entry := "api/" + c.Spec.Entry
|
||||
@@ -242,6 +252,18 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := renderMappings(o, generated, seeds); err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
currentConfig, stillExists, err := readGeneratorConfig(directory)
|
||||
if err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
beforeConfig, _ := json.Marshal(genConfig)
|
||||
afterConfig, _ := json.Marshal(currentConfig)
|
||||
if hasGenConfig != stillExists || string(beforeConfig) != string(afterConfig) {
|
||||
return Output{}, errors.New("omni.gen.json changed during generation; retry")
|
||||
}
|
||||
out, err := apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec), o.lockHeld)
|
||||
expectedVersion := minimumVersion
|
||||
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) {
|
||||
@@ -602,7 +624,9 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
}
|
||||
put(generated, "internal/handler/"+group+"/handlersGen.go", generatedHeader+text)
|
||||
put(seeds, "internal/logic/"+group+"/logic.go", fmt.Sprintf("package %s\nimport %q\ntype Logic struct {deps *svc.ServiceContext}\nfunc New(deps *svc.ServiceContext)*Logic{return &Logic{deps:deps}}\n", packageName, o.Module+"/internal/svc"))
|
||||
put(seeds, "internal/repository/"+group+"/repository.go", "// Package "+packageName+" owns persistence contracts for this business group.\n// Define repository interfaces and implementations here; HTTP DTOs do not\n// determine a database schema. This file is never overwritten.\npackage "+packageName+"\n")
|
||||
if o.generation.Repository == "group" {
|
||||
put(seeds, "internal/repository/"+group+"/repository.go", "// Package "+packageName+" owns persistence contracts for this business group.\n// Define repository interfaces and implementations here; HTTP DTOs do not\n// determine a database schema. This file is never overwritten.\npackage "+packageName+"\n")
|
||||
}
|
||||
}
|
||||
put(generated, "internal/handler/routesGen.go", generatedHeader+"package handler\n"+imports+")\n"+routes+"return nil\n}\n")
|
||||
registry := "package middleware\nimport " + strconv.Quote(frameworkModule+"/httpx") + "\nfunc Defaults()map[string]httpx.Middleware{return map[string]httpx.Middleware{\n"
|
||||
@@ -611,6 +635,9 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
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")
|
||||
if o.generation.Middleware == "factories" {
|
||||
renderMiddlewareFactories(middlewareNames, generated, seeds)
|
||||
}
|
||||
for name, source := range baseTemplates {
|
||||
content := strings.ReplaceAll(source, "MODULE", o.Module)
|
||||
content = strings.ReplaceAll(content, "LOGFORMAT", o.LogFormat)
|
||||
@@ -630,6 +657,16 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
return nil, nil, err
|
||||
}
|
||||
for name, data := range infra {
|
||||
if o.generation.Middleware == "factories" && name == "internal/svc/runtimeGen.go" {
|
||||
formatted, err := format.Source(data)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
source := string(formatted)
|
||||
source = strings.Replace(source, "Middlewares: middleware.Defaults(),", "", 1)
|
||||
source = strings.Replace(source, "return c, nil", "if len(c.Middlewares) != 0 { return nil, errors.New(\"factory middleware is registered automatically; remove manual Middlewares assignments from Init\") }\nc.Middlewares, err = middleware.New(c)\nif err != nil { return nil, err }\nreturn c, nil", 1)
|
||||
data = []byte(source)
|
||||
}
|
||||
if strings.HasSuffix(name, "Gen.go") {
|
||||
generated[name] = append([]byte(generatedHeader), data...)
|
||||
} else {
|
||||
|
||||
69
cmd/go-omni/apigen/generatorConfig.go
Normal file
69
cmd/go-omni/apigen/generatorConfig.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
)
|
||||
|
||||
const generatorConfigFile = "omni.gen.json"
|
||||
|
||||
// GeneratorConfig is developer-owned and checked on every generation. Mapping
|
||||
// rules never change the public API or infer a persistence model.
|
||||
type GeneratorConfig struct {
|
||||
Repository string `json:"repository,omitempty"`
|
||||
Middleware string `json:"middleware,omitempty"`
|
||||
Mappings []Mapping `json:"mappings,omitempty"`
|
||||
}
|
||||
|
||||
func readGeneratorConfig(root string) (GeneratorConfig, bool, error) {
|
||||
cfg := GeneratorConfig{Repository: "none", Middleware: "factories"}
|
||||
if err := safePath(root, generatorConfigFile); err != nil {
|
||||
return cfg, false, err
|
||||
}
|
||||
stat, err := os.Lstat(filepath.Join(root, generatorConfigFile))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// Existing projects keep their handwritten middleware signature until they
|
||||
// explicitly select factories; do not rewrite or adopt developer files.
|
||||
if raw, e := os.ReadFile(filepath.Join(root, manifestPath)); e == nil {
|
||||
var previous manifest
|
||||
if err := json.Unmarshal(raw, &previous); err != nil {
|
||||
return cfg, false, err
|
||||
}
|
||||
if previous.GeneratorConfig {
|
||||
return cfg, false, errors.New("omni.gen.json is missing; restore the project-owned generation options and mappings before regenerating")
|
||||
}
|
||||
cfg.Middleware = "handlers"
|
||||
} else if !errors.Is(e, os.ErrNotExist) {
|
||||
return cfg, false, e
|
||||
}
|
||||
return cfg, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return cfg, false, err
|
||||
}
|
||||
if !stat.Mode().IsRegular() || stat.Size() > 1<<20 {
|
||||
return cfg, false, errors.New("omni.gen.json must be a regular file of at most 1 MiB")
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, generatorConfigFile))
|
||||
if err != nil {
|
||||
return cfg, false, err
|
||||
}
|
||||
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
|
||||
return cfg, false, fmt.Errorf("omni.gen.json: %w", err)
|
||||
}
|
||||
if cfg.Repository != "none" && cfg.Repository != "group" {
|
||||
return cfg, false, errors.New("omni.gen.json: repository must be none or group")
|
||||
}
|
||||
if cfg.Middleware != "factories" && cfg.Middleware != "handlers" {
|
||||
return cfg, false, errors.New("omni.gen.json: middleware must be factories or handlers")
|
||||
}
|
||||
if len(cfg.Mappings) > 128 {
|
||||
return cfg, false, errors.New("omni.gen.json: at most 128 mappings supported")
|
||||
}
|
||||
return cfg, true, nil
|
||||
}
|
||||
@@ -170,7 +170,7 @@ func generateManagedTestProject(t *testing.T, o Options) error {
|
||||
_, err := Generate(o)
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
args := []string{"api", "generate", "--api", o.API, "--dir", o.Dir, "--module", o.Module}
|
||||
if o.Check {
|
||||
|
||||
433
cmd/go-omni/apigen/mapping.go
Normal file
433
cmd/go-omni/apigen/mapping.go
Normal file
@@ -0,0 +1,433 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/format"
|
||||
goparser "go/parser"
|
||||
gotoken "go/token"
|
||||
"go/types"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/module"
|
||||
)
|
||||
|
||||
// GoTypeRef names an existing, exported, non-generic Go struct.
|
||||
type GoTypeRef struct {
|
||||
Package string `json:"package"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
type GoFunctionRef struct {
|
||||
Package string `json:"package"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type MappingField struct {
|
||||
Source string `json:"source"`
|
||||
Convert *GoFunctionRef `json:"convert,omitempty"`
|
||||
Mapping string `json:"mapping,omitempty"`
|
||||
Elements bool `json:"elements,omitempty"`
|
||||
}
|
||||
type Mapping struct {
|
||||
Name string `json:"name"`
|
||||
Source GoTypeRef `json:"source"`
|
||||
Target GoTypeRef `json:"target"`
|
||||
Fields map[string]MappingField `json:"fields,omitempty"`
|
||||
IgnoreSource map[string]string `json:"ignoreSource,omitempty"`
|
||||
IgnoreTarget map[string]string `json:"ignoreTarget,omitempty"`
|
||||
}
|
||||
type mappingField struct {
|
||||
path, name string
|
||||
typ types.Type
|
||||
}
|
||||
type mappingTypes struct{ source, target types.Type }
|
||||
type mappingRenderer struct {
|
||||
importer types.Importer
|
||||
imports map[string]string
|
||||
definitions map[string]Mapping
|
||||
types map[string]mappingTypes
|
||||
}
|
||||
|
||||
func renderMappings(o Options, generated, seeds map[string][]byte) error {
|
||||
if len(o.generation.Mappings) == 0 {
|
||||
return nil
|
||||
}
|
||||
refs, err := validateMappings(o.generation.Mappings, o.Module)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
importer, close, err := loadMappingTypes(o, refs, generated, seeds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer close()
|
||||
r := mappingRenderer{importer: importer, imports: map[string]string{}, definitions: map[string]Mapping{}, types: map[string]mappingTypes{}}
|
||||
for _, m := range o.generation.Mappings {
|
||||
source, err := r.lookup(m.Source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := r.lookup(m.Target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.definitions[m.Name] = m
|
||||
r.types[m.Name] = mappingTypes{source, target}
|
||||
}
|
||||
names := make([]string, 0, len(r.definitions))
|
||||
for name := range r.definitions {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var body strings.Builder
|
||||
for _, name := range names {
|
||||
source, err := r.render(r.definitions[name])
|
||||
if err != nil {
|
||||
return fmt.Errorf("mapping %s: %w", name, err)
|
||||
}
|
||||
body.WriteString(source)
|
||||
}
|
||||
imports := make([]string, 0, len(r.imports))
|
||||
for path := range r.imports {
|
||||
imports = append(imports, path)
|
||||
}
|
||||
sort.Strings(imports)
|
||||
text := generatedHeader + "package mapping\nimport(\n"
|
||||
for _, path := range imports {
|
||||
text += r.imports[path] + " " + strconv.Quote(path) + "\n"
|
||||
}
|
||||
text += ")\n" + body.String()
|
||||
code, err := format.Source([]byte(text))
|
||||
if err != nil {
|
||||
return fmt.Errorf("format mappings: %w", err)
|
||||
}
|
||||
// The generated package is type-checked before it is written. No application
|
||||
// code (including init or converters) is executed during generation.
|
||||
fset := gotoken.NewFileSet()
|
||||
file, err := goparser.ParseFile(fset, "mappingsGen.go", code, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
check := types.Config{Importer: importer}
|
||||
if _, err = check.Check(o.Module+"/internal/mapping", fset, []*ast.File{file}, nil); err != nil {
|
||||
return fmt.Errorf("generated mapping type check: %w", err)
|
||||
}
|
||||
generated["internal/mapping/mappingsGen.go"] = code
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMappings(defs []Mapping, project string) ([]string, error) {
|
||||
refs := map[string]bool{}
|
||||
names := map[string]bool{}
|
||||
validPackage := func(path string) bool {
|
||||
if module.CheckImportPath(path) != nil || path == project+"/internal/mapping" {
|
||||
return false
|
||||
}
|
||||
// go/types does not enforce the compiler's internal import visibility.
|
||||
if strings.HasPrefix(path, "internal/") {
|
||||
return false
|
||||
}
|
||||
if i := strings.LastIndex(path, "/internal/"); i >= 0 {
|
||||
return strings.HasPrefix(project+"/internal/mapping", path[:i]+"/")
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, m := range defs {
|
||||
if !gotoken.IsIdentifier(m.Name) || !gotoken.IsExported(m.Name) || names[m.Name] {
|
||||
return nil, fmt.Errorf("mapping name %q must be unique and exported", m.Name)
|
||||
}
|
||||
names[m.Name] = true
|
||||
for _, ref := range []GoTypeRef{m.Source, m.Target} {
|
||||
if !validPackage(ref.Package) || !gotoken.IsIdentifier(ref.Type) || !gotoken.IsExported(ref.Type) {
|
||||
return nil, fmt.Errorf("mapping %s: invalid Go type reference", m.Name)
|
||||
}
|
||||
refs[ref.Package] = true
|
||||
}
|
||||
if len(m.Fields) > 4096 || len(m.IgnoreSource) > 4096 || len(m.IgnoreTarget) > 4096 {
|
||||
return nil, fmt.Errorf("mapping %s: field rule limit exceeded", m.Name)
|
||||
}
|
||||
for target, f := range m.Fields {
|
||||
if target == "" || f.Source == "" {
|
||||
return nil, fmt.Errorf("mapping %s: explicit field rule requires target and source paths", m.Name)
|
||||
}
|
||||
if f.Convert != nil {
|
||||
if f.Mapping != "" || !validPackage(f.Convert.Package) || !gotoken.IsIdentifier(f.Convert.Name) || !gotoken.IsExported(f.Convert.Name) {
|
||||
return nil, fmt.Errorf("mapping %s.%s: invalid or conflicting converter", m.Name, target)
|
||||
}
|
||||
refs[f.Convert.Package] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
visiting, done := map[string]bool{}, map[string]bool{}
|
||||
byName := map[string]Mapping{}
|
||||
for _, m := range defs {
|
||||
byName[m.Name] = m
|
||||
}
|
||||
var visit func(string) error
|
||||
visit = func(name string) error {
|
||||
if done[name] {
|
||||
return nil
|
||||
}
|
||||
if visiting[name] {
|
||||
return fmt.Errorf("mapping cycle at %s", name)
|
||||
}
|
||||
m, ok := byName[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown mapping %s", name)
|
||||
}
|
||||
visiting[name] = true
|
||||
for _, f := range m.Fields {
|
||||
if f.Mapping != "" {
|
||||
if err := visit(f.Mapping); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(visiting, name)
|
||||
done[name] = true
|
||||
return nil
|
||||
}
|
||||
for _, m := range defs {
|
||||
if err := visit(m.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(refs))
|
||||
for path := range refs {
|
||||
out = append(out, path)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *mappingRenderer) qualifier(p *types.Package) string {
|
||||
if alias, ok := r.imports[p.Path()]; ok {
|
||||
return alias
|
||||
}
|
||||
alias := fmt.Sprintf("pkg%d", len(r.imports))
|
||||
r.imports[p.Path()] = alias
|
||||
return alias
|
||||
}
|
||||
func (r *mappingRenderer) typeName(t types.Type) string { return types.TypeString(t, r.qualifier) }
|
||||
func (r *mappingRenderer) lookup(ref GoTypeRef) (types.Type, error) {
|
||||
pkg, err := r.importer.Import(ref.Package)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mapping type %s.%s: %w", ref.Package, ref.Type, err)
|
||||
}
|
||||
obj, ok := pkg.Scope().Lookup(ref.Type).(*types.TypeName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("mapping type %s.%s not found", ref.Package, ref.Type)
|
||||
}
|
||||
typ := obj.Type()
|
||||
if n, ok := typ.(*types.Named); ok && n.TypeParams() != nil && n.TypeParams().Len() > 0 {
|
||||
return nil, fmt.Errorf("mapping root %s must be a non-generic struct", ref.Type)
|
||||
}
|
||||
if _, ok := typ.Underlying().(*types.Struct); !ok {
|
||||
return nil, fmt.Errorf("mapping root %s must be a struct", ref.Type)
|
||||
}
|
||||
return typ, nil
|
||||
}
|
||||
|
||||
func mappingFields(typ types.Type) (map[string]mappingField, error) {
|
||||
out := map[string]mappingField{}
|
||||
leaves := map[string]string{}
|
||||
var walk func(types.Type, string, int) error
|
||||
walk = func(typ types.Type, prefix string, depth int) error {
|
||||
if depth > 32 {
|
||||
return fmt.Errorf("embedded field nesting exceeds 32")
|
||||
}
|
||||
st, ok := typ.Underlying().(*types.Struct)
|
||||
if !ok {
|
||||
return fmt.Errorf("embedded field %s must be a value struct", prefix)
|
||||
}
|
||||
for i := 0; i < st.NumFields(); i++ {
|
||||
f := st.Field(i)
|
||||
path := prefix + f.Name()
|
||||
if !f.Exported() {
|
||||
return fmt.Errorf("unexported field %s cannot be mapped; use an explicit converter on the containing value", path)
|
||||
}
|
||||
if f.Embedded() {
|
||||
if err := walk(f.Type(), path+".", depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if previous, exists := leaves[f.Name()]; exists {
|
||||
return fmt.Errorf("ambiguous embedded fields %s and %s", previous, path)
|
||||
}
|
||||
leaves[f.Name()] = path
|
||||
out[path] = mappingField{path, f.Name(), f.Type()}
|
||||
if len(out) > 4096 {
|
||||
return fmt.Errorf("mapping field limit exceeded")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := walk(typ, "", 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *mappingRenderer) render(m Mapping) (string, error) {
|
||||
mt := r.types[m.Name]
|
||||
source, err := mappingFields(mt.source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
target, err := mappingFields(mt.target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for key, reason := range m.IgnoreSource {
|
||||
if _, ok := source[key]; !ok || strings.TrimSpace(reason) == "" {
|
||||
return "", fmt.Errorf("ignoreSource %s requires an existing field and reason", key)
|
||||
}
|
||||
}
|
||||
for key, reason := range m.IgnoreTarget {
|
||||
if _, ok := target[key]; !ok || strings.TrimSpace(reason) == "" {
|
||||
return "", fmt.Errorf("ignoreTarget %s requires an existing field and reason", key)
|
||||
}
|
||||
}
|
||||
for key := range m.Fields {
|
||||
if _, ok := target[key]; !ok {
|
||||
return "", fmt.Errorf("unknown target field %s", key)
|
||||
}
|
||||
if _, ignored := m.IgnoreTarget[key]; ignored {
|
||||
return "", fmt.Errorf("target %s is both mapped and ignored", key)
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(target))
|
||||
for name := range target {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
used := map[string]bool{}
|
||||
text := fmt.Sprintf("// %s maps one value. Pointer/map/slice assignments retain Go reference semantics.\nfunc %s(source %s)(%s,error){\nvar result %s\n", m.Name, m.Name, r.typeName(mt.source), r.typeName(mt.target), r.typeName(mt.target))
|
||||
for _, path := range names {
|
||||
dst := target[path]
|
||||
if reason, ignored := m.IgnoreTarget[path]; ignored {
|
||||
text += "// Target " + path + " is intentionally left at zero; see omni.gen.json.\n"
|
||||
_ = reason
|
||||
continue
|
||||
}
|
||||
rule, explicit := m.Fields[path]
|
||||
if !explicit {
|
||||
for key, f := range source {
|
||||
if f.name == dst.name {
|
||||
rule.Source = key
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
src, ok := source[rule.Source]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("target %s has no source; add a field rule or ignoreTarget reason", path)
|
||||
}
|
||||
if _, ignored := m.IgnoreSource[src.path]; ignored {
|
||||
return "", fmt.Errorf("source %s is both mapped and ignored", src.path)
|
||||
}
|
||||
used[src.path] = true
|
||||
from, to := src.typ, dst.typ
|
||||
srcExpr, dstExpr := "source."+src.path, "result."+path
|
||||
if rule.Elements {
|
||||
switch d := to.Underlying().(type) {
|
||||
case *types.Slice:
|
||||
s, ok := from.Underlying().(*types.Slice)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s: elements requires slice to slice or equal-length array to array", path)
|
||||
}
|
||||
text += fmt.Sprintf("if %s!=nil {\n%s=make(%s,len(%s))\nfor index:=range %s {\n", srcExpr, dstExpr, r.typeName(to), srcExpr, srcExpr)
|
||||
from, to = s.Elem(), d.Elem()
|
||||
case *types.Array:
|
||||
s, ok := from.Underlying().(*types.Array)
|
||||
if !ok || s.Len() != d.Len() {
|
||||
return "", fmt.Errorf("%s: array lengths must match", path)
|
||||
}
|
||||
text += fmt.Sprintf("for index:=range %s {\n", srcExpr)
|
||||
from, to = s.Elem(), d.Elem()
|
||||
default:
|
||||
return "", fmt.Errorf("%s: elements requires a slice or array", path)
|
||||
}
|
||||
srcExpr += "[index]"
|
||||
dstExpr += "[index]"
|
||||
}
|
||||
expr, fallible, err := r.expression(rule, from, to, srcExpr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s -> %s: %w", src.path, path, err)
|
||||
}
|
||||
if fallible {
|
||||
fmtPkg, err := r.importer.Import("fmt")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
alias := r.qualifier(fmtPkg)
|
||||
message := m.Name + "." + path + ": %w"
|
||||
args := "err"
|
||||
if rule.Elements {
|
||||
message = m.Name + "." + path + "[%d]: %w"
|
||||
args = "index,err"
|
||||
}
|
||||
text += fmt.Sprintf("{ value,err:=%s\nif err!=nil{var zero %s;return zero,%s.Errorf(%q,%s)}\n%s=value\n}\n", expr, r.typeName(mt.target), alias, message, args, dstExpr)
|
||||
} else {
|
||||
text += dstExpr + "=" + expr + "\n"
|
||||
}
|
||||
if rule.Elements {
|
||||
text += "}\n"
|
||||
if _, ok := dst.typ.Underlying().(*types.Slice); ok {
|
||||
text += "}\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(source))
|
||||
for key := range source {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if !used[key] {
|
||||
if _, ignored := m.IgnoreSource[key]; !ignored {
|
||||
return "", fmt.Errorf("unmapped source field %s; map it or provide an ignoreSource reason", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
text += "return result,nil\n}\n"
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func (r *mappingRenderer) expression(rule MappingField, from, to types.Type, source string) (string, bool, error) {
|
||||
if rule.Mapping != "" {
|
||||
m := r.types[rule.Mapping]
|
||||
if !types.Identical(from, m.source) || !types.Identical(to, m.target) {
|
||||
return "", false, fmt.Errorf("nested mapping %s has incompatible types", rule.Mapping)
|
||||
}
|
||||
return rule.Mapping + "(" + source + ")", true, nil
|
||||
}
|
||||
if rule.Convert != nil {
|
||||
pkg, err := r.importer.Import(rule.Convert.Package)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
fn, ok := pkg.Scope().Lookup(rule.Convert.Name).(*types.Func)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("converter function %s not found", rule.Convert.Name)
|
||||
}
|
||||
sig := fn.Type().(*types.Signature)
|
||||
if sig.Variadic() || (sig.TypeParams() != nil && sig.TypeParams().Len() > 0) || sig.Params().Len() != 1 || sig.Results().Len() < 1 || sig.Results().Len() > 2 {
|
||||
return "", false, fmt.Errorf("converter must have signature func(Source) Target or func(Source) (Target,error)")
|
||||
}
|
||||
if !types.Identical(sig.Params().At(0).Type(), from) || !types.Identical(sig.Results().At(0).Type(), to) {
|
||||
return "", false, fmt.Errorf("converter signature does not match field types")
|
||||
}
|
||||
fallible := sig.Results().Len() == 2
|
||||
if fallible && !types.Identical(sig.Results().At(1).Type(), types.Universe.Lookup("error").Type()) {
|
||||
return "", false, fmt.Errorf("converter second result must be error")
|
||||
}
|
||||
return r.qualifier(pkg) + "." + rule.Convert.Name + "(" + source + ")", fallible, nil
|
||||
}
|
||||
if !types.Identical(from, to) {
|
||||
return "", false, fmt.Errorf("types %s and %s differ; provide an explicit converter", types.TypeString(from, nil), types.TypeString(to, nil))
|
||||
}
|
||||
return source, false, nil
|
||||
}
|
||||
175
cmd/go-omni/apigen/mappingLoad.go
Normal file
175
cmd/go-omni/apigen/mappingLoad.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/importer"
|
||||
gotoken "go/token"
|
||||
"go/types"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/mod/modfile"
|
||||
)
|
||||
|
||||
// loadMappingTypes uses the Go compiler's export data, including the pending
|
||||
// DTOs via an overlay. Module files and sum updates remain in a temporary dir.
|
||||
// It compiles only explicitly selected packages and their dependencies; never
|
||||
// runs project code. Build tags/GOOS follow the caller; GOWORK is disabled.
|
||||
func loadMappingTypes(o Options, refs []string, generated, seeds map[string][]byte) (types.Importer, func(), error) {
|
||||
// go list resolves its working directory (notably /var -> /private/var on
|
||||
// macOS). Overlay keys must name that same physical path or stale DTOs win.
|
||||
o.Dir = canonical(o.Dir)
|
||||
temp, err := os.MkdirTemp("", "go-omni-mapping-*")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cleanup := func() { _ = os.RemoveAll(temp) }
|
||||
fail := func(err error) (types.Importer, func(), error) { cleanup(); return nil, nil, err }
|
||||
raw, err := os.ReadFile(filepath.Join(o.Dir, "go.mod"))
|
||||
if err != nil {
|
||||
return fail(errors.New("mapping generation requires an existing Go module; scaffold first and resolve dependencies"))
|
||||
}
|
||||
mf, err := modfile.Parse("go.mod", raw, nil)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
replacements := append([]*modfile.Replace(nil), mf.Replace...)
|
||||
for _, r := range replacements {
|
||||
if r.New.Version == "" && !filepath.IsAbs(r.New.Path) {
|
||||
if err = mf.AddReplace(r.Old.Path, r.Old.Version, filepath.Join(o.Dir, r.New.Path), ""); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
raw, err = mf.Format()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
modPath := filepath.Join(temp, "go.mod")
|
||||
if err = os.WriteFile(modPath, raw, 0600); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if sums, e := os.ReadFile(filepath.Join(o.Dir, "go.sum")); e == nil {
|
||||
if err = os.WriteFile(filepath.Join(temp, "go.sum"), sums, 0600); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
} else if !errors.Is(e, os.ErrNotExist) {
|
||||
return fail(e)
|
||||
}
|
||||
overlay := map[string]string{}
|
||||
// Hide stale owned Go files, which may otherwise redeclare a renamed DTO.
|
||||
var previous manifest
|
||||
if data, e := os.ReadFile(filepath.Join(o.Dir, manifestPath)); e == nil {
|
||||
if err = json.Unmarshal(data, &previous); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
for name := range previous.Generated {
|
||||
if strings.HasSuffix(name, ".go") {
|
||||
if !ownedPath(name) {
|
||||
return fail(fmt.Errorf("invalid owned path %s", name))
|
||||
}
|
||||
if err = safePath(o.Dir, name); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if _, ok := generated[name]; !ok {
|
||||
overlay[filepath.Join(o.Dir, filepath.FromSlash(name))] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !errors.Is(e, os.ErrNotExist) {
|
||||
return fail(e)
|
||||
}
|
||||
for i, files := range []map[string][]byte{generated, seeds} {
|
||||
names := make([]string, 0, len(files))
|
||||
for name := range files {
|
||||
if strings.HasSuffix(name, ".go") {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
if err = safePath(o.Dir, name); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
path := filepath.Join(o.Dir, filepath.FromSlash(name))
|
||||
if i == 1 {
|
||||
if _, e := os.Stat(path); e == nil {
|
||||
continue
|
||||
} else if !errors.Is(e, os.ErrNotExist) {
|
||||
return fail(e)
|
||||
}
|
||||
}
|
||||
backing := filepath.Join(temp, fmt.Sprintf("overlay%d.go", len(overlay)))
|
||||
if err = os.WriteFile(backing, files[name], 0600); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
overlay[path] = backing
|
||||
}
|
||||
}
|
||||
raw, err = json.Marshal(map[string]any{"Replace": overlay})
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
overlayPath := filepath.Join(temp, "overlay.json")
|
||||
if err = os.WriteFile(overlayPath, raw, 0600); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
args := []string{"list", "-deps", "-export", "-json", "-mod=readonly", "-modfile", modPath, "-overlay", overlayPath}
|
||||
// fmt is used for fallible conversions even if neither struct imports it.
|
||||
args = append(args, "fmt")
|
||||
args = append(args, refs...)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", args...)
|
||||
cmd.Dir = o.Dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
stdout := &limitedCatalogBuffer{limit: 32 << 20}
|
||||
stderr := &limitedCatalogBuffer{limit: 64 << 10}
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
if err = cmd.Run(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return fail(fmt.Errorf("mapping type loading: %w", ctx.Err()))
|
||||
}
|
||||
// Go download errors may contain authenticated remote URLs. Keep arbitrary
|
||||
// command output out of diagnostics; exact mapping errors follow type loading.
|
||||
return fail(fmt.Errorf("mapping type loading failed (%v); run go list -deps -export for the configured source/target/converter packages; check dependencies, build tags and import cycles", err))
|
||||
}
|
||||
exports := map[string]string{}
|
||||
dec := json.NewDecoder(bytes.NewReader(stdout.Bytes()))
|
||||
for {
|
||||
var p struct {
|
||||
ImportPath, Export string
|
||||
Error *struct{ Err string }
|
||||
}
|
||||
if err = dec.Decode(&p); err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if p.Error != nil {
|
||||
return fail(fmt.Errorf("mapping package %s could not be compiled", p.ImportPath))
|
||||
}
|
||||
if p.Export != "" {
|
||||
exports[p.ImportPath] = p.Export
|
||||
}
|
||||
}
|
||||
imp := importer.ForCompiler(gotoken.NewFileSet(), "gc", func(path string) (io.ReadCloser, error) {
|
||||
file, ok := exports[path]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no export data for mapping dependency %s", path)
|
||||
}
|
||||
return os.Open(file)
|
||||
})
|
||||
return imp, cleanup, nil
|
||||
}
|
||||
266
cmd/go-omni/apigen/mapping_test.go
Normal file
266
cmd/go-omni/apigen/mapping_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func compositionConfig() GeneratorConfig {
|
||||
pkg := "example.com/composition/internal/"
|
||||
cfg := GeneratorConfig{Repository: "none", Middleware: "factories"}
|
||||
for _, name := range []string{"Enterprise", "Tenant", "Admin"} {
|
||||
fields := map[string]MappingField{
|
||||
"EmployeeName": {Source: "Name", Convert: &GoFunctionRef{pkg + "convert", "Name"}},
|
||||
"Salary": {Source: "Salary", Convert: &GoFunctionRef{pkg + "convert", "Money"}},
|
||||
"Enabled": {Source: "Enabled", Convert: &GoFunctionRef{pkg + "convert", "Bool"}},
|
||||
}
|
||||
m := Mapping{Name: "From" + name, Source: GoTypeRef{pkg + "types", name + "Request"}, Target: GoTypeRef{pkg + "domain", "Employee"}, Fields: fields}
|
||||
if name != "Enterprise" {
|
||||
m.IgnoreSource = map[string]string{"CompanyID": "Authorized separately by Logic; never copy path scope into body"}
|
||||
}
|
||||
cfg.Mappings = append(cfg.Mappings, m)
|
||||
}
|
||||
cfg.Mappings = append(cfg.Mappings, Mapping{Name: "ToResponse", Source: GoTypeRef{pkg + "domain", "Result"}, Target: GoTypeRef{pkg + "types", "EmployeeResponse"}, Fields: map[string]MappingField{"ID": {Source: "ID", Convert: &GoFunctionRef{pkg + "convert", "ID"}}}})
|
||||
cfg.Mappings = append(cfg.Mappings, Mapping{Name: "ToRow", Source: GoTypeRef{pkg + "domain", "Row"}, Target: GoTypeRef{pkg + "domain", "RowDTO"}})
|
||||
cfg.Mappings = append(cfg.Mappings, Mapping{Name: "ToBatch", Source: GoTypeRef{pkg + "domain", "Batch"}, Target: GoTypeRef{pkg + "domain", "BatchDTO"}, Fields: map[string]MappingField{
|
||||
"Rows": {Source: "Rows", Mapping: "ToRow", Elements: true}, "Fixed": {Source: "Fixed", Mapping: "ToRow", Elements: true},
|
||||
}})
|
||||
return cfg
|
||||
}
|
||||
func writeGeneratorConfig(t *testing.T, dir string, cfg GeneratorConfig) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, generatorConfigFile), string(raw)+"\n")
|
||||
}
|
||||
func runCompositionGo(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("go %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositionMappingConsumer(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "app")
|
||||
o := Options{API: filepath.Join(frameworkRoot(t), "cmd/go-omni/apigen/testdata/composition/service.api"), Dir: dir, Module: "example.com/composition", FrameworkDir: frameworkRoot(t)}
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixtures := map[string]string{
|
||||
"internal/amount/money.go": "amount.go", "internal/domain/employee.go": "domain.go", "internal/convert/fields.go": "converters.go",
|
||||
"internal/middleware/dependencies.go": "dependencies.go", "internal/middleware/accessFactory.go": "accessFactory.go", "internal/svc/client.go": "client.go", "consumer_test.go": "consumer_test.go",
|
||||
}
|
||||
for path, fixture := range fixtures {
|
||||
writeFile(t, filepath.Join(dir, path), readFile(t, "testdata/composition/"+fixture))
|
||||
}
|
||||
svcFile := filepath.Join(dir, "internal/svc/serviceContext.go")
|
||||
source := strings.Replace(readFile(t, svcFile), "type ServiceContext struct {", "type ServiceContext struct {\n client *Client", 1)
|
||||
source = strings.Replace(source, "return nil", "return c.initClient(ctx)", 1)
|
||||
writeFile(t, svcFile, source)
|
||||
configFile := filepath.Join(dir, "internal/config/config.go")
|
||||
writeFile(t, configFile, strings.Replace(readFile(t, configFile), "type Config struct {", "type Config struct {\n FactoryMode string `json:\"factoryMode\"`", 1))
|
||||
// Resolve dependencies before opting into compile-time mapping. The first
|
||||
// generation creates DTOs; the second sees current DTOs through an overlay.
|
||||
// Logic does not yet import the generated mapping package.
|
||||
testSource := readFile(t, filepath.Join(dir, "consumer_test.go"))
|
||||
if err := os.Remove(filepath.Join(dir, "consumer_test.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runCompositionGo(t, dir, "mod", "tidy")
|
||||
cfg := compositionConfig()
|
||||
writeGeneratorConfig(t, dir, cfg)
|
||||
o.API = filepath.Join(dir, "api/service.api")
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "consumer_test.go"), testSource)
|
||||
for _, group := range []string{"Enterprise", "Tenant", "Admin"} {
|
||||
text := fmt.Sprintf(`package %s
|
||||
import("context";"example.com/composition/internal/types";"example.com/composition/internal/mapping";"example.com/composition/internal/domain")
|
||||
func(s *Logic)Save(ctx context.Context,req *types.%sRequest)(*types.EmployeeResponse,error){
|
||||
input,err:=mapping.From%s(*req);if err!=nil{return nil,err}
|
||||
result,err:=mapping.ToResponse(domain.Result{Employee:input,ID:9007199254740993});if err!=nil{return nil,err};return &result,nil
|
||||
}
|
||||
`, strings.ToLower(group), group, group)
|
||||
writeFile(t, filepath.Join(dir, "internal/logic", strings.ToLower(group), "save.go"), text)
|
||||
}
|
||||
before := snapshot(t, dir)
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("regeneration changed project")
|
||||
}
|
||||
o.Check = true
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("check changed project")
|
||||
}
|
||||
runCompositionGo(t, dir, "test", "-race", "-count=1", "./...")
|
||||
runCompositionGo(t, dir, "build", ".")
|
||||
generated := readFile(t, filepath.Join(dir, "internal/mapping/mappingsGen.go"))
|
||||
if strings.Contains(generated, "encoding/json") || strings.Contains(generated, "reflect") || !strings.Contains(generated, "result.EmployeeName = value") {
|
||||
t.Fatal("mapping does not emit typed assignments")
|
||||
}
|
||||
// Adding a source or target field is detected before any files are written.
|
||||
for _, tc := range []struct{ name, path, before, after, want string }{
|
||||
{"source", o.API, "Name *string", "NewField *string `json:\"newField,optional\"`\n Name *string", "unmapped source field NewField"},
|
||||
{"target", filepath.Join(dir, "internal/domain/employee.go"), "EmployeeName string", "Unmapped string\n EmployeeName string", "target Unmapped has no source"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
original := readFile(t, tc.path)
|
||||
writeFile(t, tc.path, strings.Replace(original, tc.before, tc.after, 1))
|
||||
defer writeFile(t, tc.path, original)
|
||||
snap := snapshot(t, dir)
|
||||
if err := generateManagedTestProject(t, o); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameFiles(snap, snapshot(t, dir)) {
|
||||
t.Fatal("failed check wrote files")
|
||||
}
|
||||
o.Check = false
|
||||
defer func() { o.Check = true }()
|
||||
if err := generateManagedTestProject(t, o); err == nil {
|
||||
t.Fatal("generate accepted missing mapping")
|
||||
}
|
||||
if !sameFiles(snap, snapshot(t, dir)) {
|
||||
t.Fatal("failed generate wrote files")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
edit func(*GeneratorConfig)
|
||||
want string
|
||||
}{
|
||||
{"pointerPolicy", func(c *GeneratorConfig) { delete(c.Mappings[0].Fields, "Salary") }, "provide an explicit converter"},
|
||||
{"unknownSource", func(c *GeneratorConfig) { c.Mappings[0].Fields["Salary"] = MappingField{Source: "Missing"} }, "has no source"},
|
||||
{"wrongConverter", func(c *GeneratorConfig) {
|
||||
f := c.Mappings[0].Fields["Salary"]
|
||||
f.Convert.Name = "ID"
|
||||
c.Mappings[0].Fields["Salary"] = f
|
||||
}, "signature does not match"},
|
||||
{"unhandledScope", func(c *GeneratorConfig) { c.Mappings[1].IgnoreSource = nil }, "unmapped source field CompanyID"},
|
||||
{"badIgnore", func(c *GeneratorConfig) { c.Mappings[1].IgnoreSource["Missing"] = "stale rule" }, "ignoreSource Missing"},
|
||||
{"arrayLength", func(c *GeneratorConfig) {
|
||||
f := c.Mappings[5].Fields["Fixed"]
|
||||
f.Source = "Rows"
|
||||
c.Mappings[5].Fields["Fixed"] = f
|
||||
}, "array lengths must match"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
changed := compositionConfig()
|
||||
tc.edit(&changed)
|
||||
writeGeneratorConfig(t, dir, changed)
|
||||
defer writeGeneratorConfig(t, dir, cfg)
|
||||
snap := snapshot(t, dir)
|
||||
if err := generateManagedTestProject(t, o); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameFiles(snap, snapshot(t, dir)) {
|
||||
t.Fatal("invalid rule wrote files")
|
||||
}
|
||||
})
|
||||
}
|
||||
// All diagnostics exercise current DTOs, not stale exported generated types.
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Exercise the repository switch with the same actual CLI as the mapper.
|
||||
if _, err := os.Stat(filepath.Join(dir, "internal/repository")); !os.IsNotExist(err) {
|
||||
t.Fatalf("default repository skeleton: %v", err)
|
||||
}
|
||||
o.Check = false
|
||||
cfg.Repository = "group"
|
||||
writeGeneratorConfig(t, dir, cfg)
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := filepath.Join(dir, "internal/repository/enterprise/repository.go")
|
||||
writeFile(t, repo, readFile(t, repo)+"\n// Project-owned persistence.\n")
|
||||
cfg.Repository = "none"
|
||||
writeGeneratorConfig(t, dir, cfg)
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(readFile(t, repo), "Project-owned persistence.") {
|
||||
t.Fatal("disabling skeleton overwrote business code")
|
||||
}
|
||||
o.Check = true
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
data, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatal(err, string(data))
|
||||
}
|
||||
var dependency struct {
|
||||
Version, Sum string
|
||||
Replace any
|
||||
}
|
||||
if err = json.Unmarshal(data, &dependency); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" {
|
||||
t.Fatal("not a fixed remote consumer")
|
||||
}
|
||||
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMappingConfigurationRejection(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
edit func(*GeneratorConfig)
|
||||
want string
|
||||
}{
|
||||
{"cycle", func(c *GeneratorConfig) {
|
||||
c.Mappings[4].Fields = map[string]MappingField{"Value": {Source: "Value", Mapping: "ToRow"}}
|
||||
}, "cycle"},
|
||||
{"unknown", func(c *GeneratorConfig) {
|
||||
c.Mappings[5].Fields["Rows"] = MappingField{Source: "Rows", Mapping: "Missing", Elements: true}
|
||||
}, "unknown mapping"},
|
||||
{"conflict", func(c *GeneratorConfig) {
|
||||
f := c.Mappings[0].Fields["Salary"]
|
||||
f.Mapping = "ToRow"
|
||||
c.Mappings[0].Fields["Salary"] = f
|
||||
}, "conflicting converter"},
|
||||
{"internal", func(c *GeneratorConfig) { c.Mappings[0].Source.Package = "example.net/private/internal/types" }, "invalid Go type"},
|
||||
{"outputCycle", func(c *GeneratorConfig) { c.Mappings[0].Source.Package = "example.com/composition/internal/mapping" }, "invalid Go type"},
|
||||
{"duplicates", func(c *GeneratorConfig) { c.Mappings[1].Name = c.Mappings[0].Name }, "unique and exported"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := compositionConfig()
|
||||
tc.edit(&cfg)
|
||||
if _, err := validateMappings(cfg.Mappings, "example.com/composition"); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,7 @@ func TestDeclaredContractConsumer(t *testing.T) {
|
||||
source := filepath.Join(root, "input/service.api")
|
||||
writeFile(t, source, declaredAPI)
|
||||
dir := filepath.Join(root, "project")
|
||||
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
|
||||
if _, err := Generate(Options{API: source, Dir: dir, Module: "example.com/dx", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
19
cmd/go-omni/apigen/middlewareFactory.go
Normal file
19
cmd/go-omni/apigen/middlewareFactory.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package apigen
|
||||
|
||||
import "fmt"
|
||||
|
||||
func renderMiddlewareFactories(names []string, generated, seeds map[string][]byte) {
|
||||
// All project-specific types live behind this project-owned interface. svc
|
||||
// implements it; middleware never imports svc (and cannot form that cycle).
|
||||
seeds["internal/middleware/dependencies.go"] = []byte("package middleware\n// Dependencies is implemented by *svc.ServiceContext. Add typed accessors here\n// and implement them on ServiceContext. Factories borrow dependencies; register\n// their cleanup in ServiceContext.Init, not in a middleware factory.\ntype Dependencies interface {}\n")
|
||||
text := fmt.Sprintf("package middleware\nimport(\"fmt\";%q)\n// New constructs declared middleware after ServiceContext.Init succeeds.\nfunc New(deps Dependencies)(map[string]httpx.Middleware,error){\nresult:=map[string]httpx.Middleware{}\n", frameworkModule+"/httpx")
|
||||
if len(names) == 0 {
|
||||
text = fmt.Sprintf("package middleware\nimport %q\nfunc New(deps Dependencies)(map[string]httpx.Middleware,error){\nresult:=map[string]httpx.Middleware{}\n", frameworkModule+"/httpx")
|
||||
}
|
||||
for i, name := range names {
|
||||
delete(seeds, "internal/middleware/"+lower(name)+".go")
|
||||
text += fmt.Sprintf("m%d,err:=New%s(deps)\nif err!=nil{return nil,fmt.Errorf(%q,err)}\nif m%d==nil{return nil,fmt.Errorf(%q)}\nresult[%q]=m%d\n", i, upper(name), "middleware "+name+": %w", i, "middleware "+name+": factory returned nil", name, i)
|
||||
seeds["internal/middleware/"+lower(name)+"Factory.go"] = []byte(fmt.Sprintf("package middleware\nimport(\"errors\";%q)\n// New%s is project-owned. Implement this factory once; registration is generated.\n// Dependencies have already been initialized. Do not acquire owned resources here.\nfunc New%s(deps Dependencies)(httpx.Middleware,error){\nreturn nil,errors.New(%q)\n}\n", frameworkModule+"/httpx", upper(name), upper(name), "implement "+name+" middleware factory"))
|
||||
}
|
||||
generated["internal/middleware/registryGen.go"] = []byte(generatedHeader + text + "return result,nil\n}\n")
|
||||
}
|
||||
@@ -39,6 +39,8 @@ type Type struct {
|
||||
Fields []Field
|
||||
Position string
|
||||
}
|
||||
|
||||
// An empty Type denotes a named field composition, expanded before compilation.
|
||||
type Field struct{ Name, Type, Tag, Description string }
|
||||
type Route struct {
|
||||
BoundMultipart bool
|
||||
@@ -517,7 +519,11 @@ func (p *parser) parseType() error {
|
||||
}
|
||||
}
|
||||
if expression == "" {
|
||||
return p.err("field type required; embedded types are not supported")
|
||||
if p.tokens[p.at].kind == scanner.RawString {
|
||||
return p.err("composed types cannot carry field tags")
|
||||
}
|
||||
typ.Fields = append(typ.Fields, Field{Name: name, Description: description})
|
||||
continue
|
||||
}
|
||||
if p.tokens[p.at].kind != scanner.RawString {
|
||||
return p.err("field source tag required")
|
||||
|
||||
@@ -251,6 +251,7 @@ func TestResponseTemplateConsumer(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
|
||||
generate(source, true)
|
||||
writeFile(t, filepath.Join(dir, "response_test.go"), responseConsumerTest)
|
||||
for _, method := range []string{"Item", "Callback", "Partner"} {
|
||||
|
||||
@@ -51,6 +51,16 @@ This project is generated from an editable API contract. Business code uses
|
||||
standard context.Context; generated HTTP handlers use Omni's transport-neutral httpx.
|
||||
main.go and HTTP composition are continuously generator-owned.
|
||||
|
||||
omni.gen.json selects repository: none (default) or group, and middleware:
|
||||
factories for new projects. Named middleware factories run after svc.Init;
|
||||
implement Dependencies accessors on ServiceContext and New<Name>(deps) once.
|
||||
Unimplemented/nil factories fail startup and clean registered dependencies.
|
||||
Existing projects retain middleware: handlers until explicitly migrated.
|
||||
Optional mappings generate typed assignments in internal/mapping/mappingsGen.go;
|
||||
configure source/target types and explicit conversion rules after resolving module
|
||||
dependencies. Missing fields fail generation and --check. See GENERATION.md in
|
||||
the framework repository for composition, null/reference semantics and migration.
|
||||
|
||||
## Run
|
||||
|
||||
`+"```sh"+`
|
||||
@@ -133,8 +143,10 @@ internal/svc/runtimeGen.go # GENERATED lifecycle and infrastructure startup
|
||||
internal/apierrors/errorsGen.go # generated fresh error constructors, when declared
|
||||
internal/handler/<group>/ # generated binding and Logic adapters
|
||||
internal/logic/<group>/ # developer-owned business operations
|
||||
internal/repository/<group>/ # developer-owned persistence contracts
|
||||
internal/middleware/ # named hooks and generated registry
|
||||
internal/repository/<group>/ # optional; repository: group creates editable seeds
|
||||
internal/middleware/ # typed dependency interface, factories and registry
|
||||
omni.gen.json # developer-owned generator options and mappings
|
||||
internal/mapping/*Gen.go # optional compile-checked struct mappings
|
||||
docs/openapi.json # generated OpenAPI 3.0.3
|
||||
.goOmni/apiManifest.json # ownership and content hashes
|
||||
`+"```"+`
|
||||
|
||||
30
cmd/go-omni/apigen/testdata/composition/accessFactory.go
vendored
Normal file
30
cmd/go-omni/apigen/testdata/composition/accessFactory.go
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
var ErrFactory = errors.New("factory fault")
|
||||
|
||||
func NewAccess(deps Dependencies) (httpx.Middleware, error) {
|
||||
if deps.Access() == nil {
|
||||
return nil, errors.New("dependencies not initialized")
|
||||
}
|
||||
switch deps.FactoryMode() {
|
||||
case "fail":
|
||||
return nil, ErrFactory
|
||||
case "nil":
|
||||
return nil, nil
|
||||
case "panic":
|
||||
panic(ErrFactory)
|
||||
}
|
||||
return func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
if err := deps.Access().Check(c.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
52
cmd/go-omni/apigen/testdata/composition/amount.go
vendored
Normal file
52
cmd/go-omni/apigen/testdata/composition/amount.go
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
package amount
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// This fixture's business policy is exact decimal HALF_UP. The framework does
|
||||
// not choose its precision, rounding mode or currency.
|
||||
type Money string
|
||||
|
||||
var decimalPattern = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?$`)
|
||||
|
||||
func (m Money) String() string {
|
||||
if m == "" {
|
||||
return "0.00"
|
||||
}
|
||||
return string(m)
|
||||
}
|
||||
func (m Money) MarshalJSON() ([]byte, error) { return json.Marshal(m.String()) }
|
||||
func (m *Money) UnmarshalJSON(data []byte) error {
|
||||
var text string
|
||||
if err := json.Unmarshal(data, &text); err != nil {
|
||||
return err
|
||||
}
|
||||
if !decimalPattern.MatchString(text) {
|
||||
return errors.New("invalid decimal")
|
||||
}
|
||||
value, ok := new(big.Rat).SetString(text)
|
||||
if !ok {
|
||||
return errors.New("invalid decimal")
|
||||
}
|
||||
value.Mul(value, big.NewRat(100, 1))
|
||||
sign := value.Sign()
|
||||
value.Abs(value)
|
||||
cents, rem := new(big.Int), new(big.Int)
|
||||
cents.QuoRem(value.Num(), value.Denom(), rem)
|
||||
if rem.Mul(rem, big.NewInt(2)).Cmp(value.Denom()) >= 0 {
|
||||
cents.Add(cents, big.NewInt(1))
|
||||
}
|
||||
whole, fraction := new(big.Int), new(big.Int)
|
||||
whole.QuoRem(cents, big.NewInt(100), fraction)
|
||||
prefix := ""
|
||||
if sign < 0 && cents.Sign() != 0 {
|
||||
prefix = "-"
|
||||
}
|
||||
*m = Money(fmt.Sprintf("%s%s.%02d", prefix, whole.String(), fraction.Int64()))
|
||||
return nil
|
||||
}
|
||||
36
cmd/go-omni/apigen/testdata/composition/client.go
vendored
Normal file
36
cmd/go-omni/apigen/testdata/composition/client.go
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"example.com/composition/internal/middleware"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
var Closes atomic.Int64
|
||||
var ErrInit = errors.New("init fault")
|
||||
|
||||
type Client struct{ closed atomic.Bool }
|
||||
|
||||
func (c *Client) Check(ctx context.Context) error {
|
||||
if c.closed.Load() {
|
||||
return errors.New("client closed before HTTP drained")
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
func (c *ServiceContext) initClient(ctx context.Context) error {
|
||||
c.client = &Client{}
|
||||
c.OnClose(func() error {
|
||||
if !c.client.closed.CompareAndSwap(false, true) {
|
||||
return errors.New("double close")
|
||||
}
|
||||
Closes.Add(1)
|
||||
return nil
|
||||
})
|
||||
if c.Config.FactoryMode == "initFail" {
|
||||
return ErrInit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *ServiceContext) Access() middleware.AccessChecker { return c.client }
|
||||
func (c *ServiceContext) FactoryMode() string { return c.Config.FactoryMode }
|
||||
218
cmd/go-omni/apigen/testdata/composition/consumer_test.go
vendored
Normal file
218
cmd/go-omni/apigen/testdata/composition/consumer_test.go
vendored
Normal file
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"example.com/composition/internal/amount"
|
||||
"example.com/composition/internal/config"
|
||||
"example.com/composition/internal/convert"
|
||||
"example.com/composition/internal/domain"
|
||||
"example.com/composition/internal/handler"
|
||||
"example.com/composition/internal/mapping"
|
||||
"example.com/composition/internal/middleware"
|
||||
"example.com/composition/internal/svc"
|
||||
"example.com/composition/internal/types"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func logger(t *testing.T) *logx.Logger {
|
||||
t.Helper()
|
||||
cfg := logx.Default()
|
||||
cfg.Writer = io.Discard
|
||||
l, e := logx.New(cfg)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
t.Cleanup(func() { _ = l.Close() })
|
||||
return l
|
||||
}
|
||||
func TestHTTPComposition(t *testing.T) {
|
||||
deps, e := svc.New(context.Background(), logger(t))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer deps.Close()
|
||||
app, e := handler.NewApp(deps)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer app.Close()
|
||||
for _, path := range []string{"/enterprise", "/tenant/123", "/admin/123"} {
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
status int
|
||||
salary, nameValue string
|
||||
employer any
|
||||
}{
|
||||
{"exact", `{"name":"employee","salary":"90071992547409.91","employer":null,"enabled":false}`, 200, "90071992547409.91", "employee", nil},
|
||||
{"round", `{"salary":"1.005","employer":"0"}`, 200, "1.01", "", "0.00"},
|
||||
{"missing", `{}`, 200, "0.00", "", nil},
|
||||
{"null", `{"name":null,"salary":null,"employer":null,"enabled":null}`, 200, "0.00", "", nil},
|
||||
{"zero", `{"salary":"0","enabled":false}`, 200, "0.00", "", nil},
|
||||
{"invalid", `{"salary":"abc"}`, 400, "", "", nil},
|
||||
{"empty", `{"salary":""}`, 400, "", "", nil},
|
||||
{"numeric", `{"salary":100}`, 400, "", "", nil},
|
||||
{"flat", `{"SalaryFields":{"salary":"1"}}`, 400, "", "", nil},
|
||||
{"validation", `{"name":"` + strings.Repeat("x", 81) + `"}`, 400, "", "", nil},
|
||||
} {
|
||||
t.Run(path+tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", 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("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Code == 200 {
|
||||
var out map[string]any
|
||||
if e := json.Unmarshal(w.Body.Bytes(), &out); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if out["salary"] != tc.salary || out["employeeName"] != tc.nameValue || out["employer"] != tc.employer || out["id"] != "9007199254740993" {
|
||||
t.Fatal(out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestFullFormAndMappingSemantics(t *testing.T) {
|
||||
name := "employee"
|
||||
money := amount.Money("2.00")
|
||||
yes := true
|
||||
req := types.EnterpriseRequest{Name: &name, Salary: &money, Enabled: &yes, Labels: []string{"one"}}
|
||||
first, e := mapping.FromEnterprise(req)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
second, e := mapping.FromEnterprise(types.EnterpriseRequest{})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if first.EmployeeName != "employee" || !first.Enabled || second.EmployeeName != "" || second.Enabled || second.Salary.String() != "0.00" || second.Employer != nil {
|
||||
t.Fatal("full-form update became patch behavior")
|
||||
}
|
||||
// Direct assignments retain standard Go aliasing, including scalar pointers.
|
||||
first.Labels[0] = "shared"
|
||||
if req.Labels[0] != "shared" {
|
||||
t.Fatal("unexpected implicit deep copy")
|
||||
}
|
||||
req.Employer = &money
|
||||
first, e = mapping.FromEnterprise(req)
|
||||
if e != nil || first.Employer != req.Employer {
|
||||
t.Fatal("nullable pointer changed", e)
|
||||
}
|
||||
bad := "reject"
|
||||
failed, e := mapping.FromEnterprise(types.EnterpriseRequest{Name: &bad})
|
||||
if !errors.Is(e, convert.ErrName) || failed.EmployeeName != "" || !strings.Contains(e.Error(), "EmployeeName") {
|
||||
t.Fatal("converter cause/zero result lost", e)
|
||||
}
|
||||
empty, e := mapping.ToBatch(domain.Batch{})
|
||||
if e != nil || empty.Rows != nil {
|
||||
t.Fatal("nil slice changed", e)
|
||||
}
|
||||
src := domain.Batch{Rows: []domain.Row{}, Fixed: [2]domain.Row{{Value: "a"}, {Value: "b"}}, Optional: &name}
|
||||
out, e := mapping.ToBatch(src)
|
||||
if e != nil || out.Rows == nil || len(out.Rows) != 0 || out.Fixed[1].Value != "b" || out.Optional != src.Optional {
|
||||
t.Fatal(out, e)
|
||||
}
|
||||
src.Rows = []domain.Row{{Value: "one"}}
|
||||
out, e = mapping.ToBatch(src)
|
||||
if e != nil || out.Rows[0].Value != "one" {
|
||||
t.Fatal(out, e)
|
||||
}
|
||||
out.Rows[0].Value = "changed"
|
||||
if src.Rows[0].Value != "one" {
|
||||
t.Fatal("element mapping reused input slice")
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 32; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result, err := mapping.FromEnterprise(types.EnterpriseRequest{Name: &name, Salary: &money})
|
||||
if err != nil || result.EmployeeName != name || result.Salary != money {
|
||||
t.Error(result, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
func TestFactoryLifecycle(t *testing.T) {
|
||||
for _, mode := range []string{"fail", "nil", "initFail", "panic"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
before := svc.Closes.Load()
|
||||
cfg := config.Default()
|
||||
cfg.FactoryMode = mode
|
||||
if mode == "panic" {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() != middleware.ErrFactory {
|
||||
t.Error("panic cause changed")
|
||||
}
|
||||
}()
|
||||
_, _ = svc.New(context.Background(), logger(t), cfg)
|
||||
}()
|
||||
} else {
|
||||
deps, err := svc.New(context.Background(), logger(t), cfg)
|
||||
if deps != nil || err == nil {
|
||||
t.Fatal("factory did not fail closed")
|
||||
}
|
||||
if mode == "fail" && !errors.Is(err, middleware.ErrFactory) {
|
||||
t.Fatal("factory cause lost", err)
|
||||
}
|
||||
if mode == "initFail" && !errors.Is(err, svc.ErrInit) {
|
||||
t.Fatal("init cause lost", err)
|
||||
}
|
||||
}
|
||||
if svc.Closes.Load() != before+1 {
|
||||
t.Fatal("dependency not closed exactly once")
|
||||
}
|
||||
})
|
||||
}
|
||||
deps, e := svc.New(context.Background(), logger(t))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = deps.Access().Check(context.Background()); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
before := svc.Closes.Load()
|
||||
if e = deps.Close(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = deps.Close(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if svc.Closes.Load() != before+1 {
|
||||
t.Fatal("normal close repeated")
|
||||
}
|
||||
}
|
||||
func TestGeneratedEntryListenFailure(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err = os.WriteFile(path, []byte("server:\n address: "+listener.Addr().String()+"\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := svc.Closes.Load()
|
||||
if err = run(context.Background(), path); err == nil {
|
||||
t.Fatal("listen should fail")
|
||||
}
|
||||
if svc.Closes.Load() != before+1 {
|
||||
t.Fatal("listen failure did not close dependencies")
|
||||
}
|
||||
}
|
||||
29
cmd/go-omni/apigen/testdata/composition/converters.go
vendored
Normal file
29
cmd/go-omni/apigen/testdata/composition/converters.go
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"example.com/composition/internal/amount"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var ErrName = errors.New("rejected name")
|
||||
|
||||
// Explicit project rules: full-form omission clears strings/bools and uses zero
|
||||
// for required money. Nullable employer amounts remain nil by direct assignment.
|
||||
func Name(value *string) (string, error) {
|
||||
if value == nil {
|
||||
return "", nil
|
||||
}
|
||||
if *value == "reject" {
|
||||
return "", ErrName
|
||||
}
|
||||
return *value, nil
|
||||
}
|
||||
func Money(value *amount.Money) amount.Money {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
func Bool(value *bool) bool { return value != nil && *value }
|
||||
func ID(value int64) string { return strconv.FormatInt(value, 10) }
|
||||
9
cmd/go-omni/apigen/testdata/composition/dependencies.go
vendored
Normal file
9
cmd/go-omni/apigen/testdata/composition/dependencies.go
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
package middleware
|
||||
|
||||
import "context"
|
||||
|
||||
type AccessChecker interface{ Check(context.Context) error }
|
||||
type Dependencies interface {
|
||||
Access() AccessChecker
|
||||
FactoryMode() string
|
||||
}
|
||||
29
cmd/go-omni/apigen/testdata/composition/domain.go
vendored
Normal file
29
cmd/go-omni/apigen/testdata/composition/domain.go
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
package domain
|
||||
|
||||
import "example.com/composition/internal/amount"
|
||||
|
||||
type Employee struct {
|
||||
EmployeeName string
|
||||
Salary amount.Money
|
||||
Employer *amount.Money
|
||||
Enabled bool
|
||||
Labels []string
|
||||
}
|
||||
type Result struct {
|
||||
Employee
|
||||
ID int64
|
||||
}
|
||||
|
||||
// Distinct container item types exercise explicitly selected element mappings.
|
||||
type Row struct{ Value string }
|
||||
type RowDTO struct{ Value string }
|
||||
type Batch struct {
|
||||
Rows []Row
|
||||
Fixed [2]Row
|
||||
Optional *string
|
||||
}
|
||||
type BatchDTO struct {
|
||||
Rows []RowDTO
|
||||
Fixed [2]RowDTO
|
||||
Optional *string
|
||||
}
|
||||
63
cmd/go-omni/apigen/testdata/composition/service.api
vendored
Normal file
63
cmd/go-omni/apigen/testdata/composition/service.api
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
scalar Money (
|
||||
goImport: "example.com/composition/internal/amount"
|
||||
goType: Money
|
||||
jsonInput: string
|
||||
jsonOutput: string
|
||||
)
|
||||
|
||||
type SalaryFields {
|
||||
// Employee display name.
|
||||
Name *string `json:"name,optional" binding:"max=80"`
|
||||
Salary *Money `json:"salary,optional"`
|
||||
Employer *Money `json:"employer,optional"`
|
||||
Enabled *bool `json:"enabled,optional"`
|
||||
Labels []string `json:"labels,optional"`
|
||||
}
|
||||
|
||||
type EnterpriseRequest {
|
||||
SalaryFields
|
||||
}
|
||||
type TenantRequest {
|
||||
SalaryFields
|
||||
CompanyID string `path:"companyId"`
|
||||
}
|
||||
type AdminRequest {
|
||||
SalaryFields
|
||||
CompanyID string `path:"companyId"`
|
||||
}
|
||||
type EmployeeResponse {
|
||||
ID string `json:"id"`
|
||||
EmployeeName string `json:"employeeName"`
|
||||
Salary Money `json:"salary"`
|
||||
Employer *Money `json:"employer"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Labels []string `json:"labels"`
|
||||
}
|
||||
|
||||
@server (
|
||||
group: enterprise
|
||||
middleware: Access
|
||||
response: raw
|
||||
)
|
||||
service composition {
|
||||
@handler save
|
||||
post /enterprise (EnterpriseRequest) returns (EmployeeResponse)
|
||||
}
|
||||
@server (
|
||||
group: tenant
|
||||
middleware: Access
|
||||
response: raw
|
||||
)
|
||||
service composition {
|
||||
@handler save
|
||||
post /tenant/:companyId (TenantRequest) returns (EmployeeResponse)
|
||||
}
|
||||
@server (
|
||||
group: admin
|
||||
middleware: Access
|
||||
response: raw
|
||||
)
|
||||
service composition {
|
||||
@handler save
|
||||
post /admin/:companyId (AdminRequest) returns (EmployeeResponse)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
const manifestPath = ".goOmni/apiManifest.json"
|
||||
|
||||
type manifest struct {
|
||||
GeneratorConfig bool `json:"generatorConfig,omitempty"`
|
||||
TemplateVersion string `json:"templateVersion,omitempty"`
|
||||
Profile *projectProfile `json:"profile,omitempty"`
|
||||
Version int `json:"version"`
|
||||
@@ -109,7 +110,7 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
next := manifest{TemplateVersion: minimumVersion, Profile: &profile, Version: 2, Module: module, Entry: entry, Generated: map[string]string{}, Contract: contract}
|
||||
next := manifest{GeneratorConfig: true, TemplateVersion: minimumVersion, Profile: &profile, Version: 2, Module: module, Entry: entry, Generated: map[string]string{}, Contract: contract}
|
||||
for name, data := range generated {
|
||||
next.Generated[name] = digest(data)
|
||||
}
|
||||
@@ -272,6 +273,9 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
||||
}
|
||||
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
|
||||
func ownedPath(name string) bool {
|
||||
if name == "internal/mapping/mappingsGen.go" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(name, "internal/types/dto") && strings.HasSuffix(name, "Gen.go") && goName.MatchString(strings.TrimSuffix(filepath.Base(name), ".go")) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||
for _, flag := range [][]string{{"--framework-version", "v0.16.0-preview"}, {"--framework-version=v0.16.0-preview"}} {
|
||||
for _, flag := range [][]string{{"--framework-version", "v0.17.0-preview"}, {"--framework-version=v0.17.0-preview"}} {
|
||||
project := filepath.Join(t.TempDir(), "billing")
|
||||
args := append([]string{"--module", "example.com/billing"}, flag...)
|
||||
if err := runNew(append(args, project)); err != nil {
|
||||
@@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "go-omni v0.16.0-preview") || strings.Contains(string(content), "replace ") {
|
||||
if !strings.Contains(string(content), "go-omni v0.17.0-preview") || strings.Contains(string(content), "replace ") {
|
||||
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||
// downloaded module version, which also has a module checksum.
|
||||
const defaultFrameworkVersion = "v0.16.0-preview"
|
||||
const defaultFrameworkVersion = "v0.17.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.
|
||||
|
||||
Reference in New Issue
Block a user