feat: release nullable mappings and split mapping configuration
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# go-omni CLI
|
||||
|
||||
v0.18 新增:可空嵌套映射、`mappingFiles` 配置拆分、显式 nil 策略和可选 `pointerResult`。原 v0.17 配置继续有效,见 [生成能力指南](../../GENERATION.md)。
|
||||
|
||||
v0.17 增加公共字段组合、可选结构映射、自动中间件工厂和按需 Repository。配置和迁移见 [生成能力指南](../../GENERATION.md)。
|
||||
|
||||
v0.16 增加基础类型/动态 JSON 数组返回、OpenAPI 公共引用和 workspace Go Catalog 统一导出。见 [接入与筛选规则](../../DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。
|
||||
@@ -12,7 +14,7 @@ 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.17.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.18.0-preview
|
||||
go-omni new --module example.com/hello ./hello
|
||||
|
||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||
@@ -47,14 +49,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
||||
|
||||
## 版本与迁移
|
||||
|
||||
CLI 与框架依赖固定使用 v0.17.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
CLI 与框架依赖固定使用 v0.18.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.17.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
当前模板与依赖以 v0.18.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
|
||||
## 数据库迁移命令
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
const minimumVersion = "v0.17.0-preview"
|
||||
const minimumVersion = "v0.18.0-preview"
|
||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||
|
||||
type Options struct {
|
||||
@@ -261,8 +261,8 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
}
|
||||
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")
|
||||
if hasGenConfig != stillExists || genConfig.inputDigest != currentConfig.inputDigest || string(beforeConfig) != string(afterConfig) {
|
||||
return Output{}, errors.New("generation configuration 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
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
)
|
||||
@@ -15,9 +18,11 @@ 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"`
|
||||
Repository string `json:"repository,omitempty"`
|
||||
Middleware string `json:"middleware,omitempty"`
|
||||
Mappings []Mapping `json:"mappings,omitempty"`
|
||||
MappingFiles []string `json:"mappingFiles,omitempty"`
|
||||
inputDigest string
|
||||
}
|
||||
|
||||
func readGeneratorConfig(root string) (GeneratorConfig, bool, error) {
|
||||
@@ -49,13 +54,57 @@ func readGeneratorConfig(root string) (GeneratorConfig, bool, error) {
|
||||
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))
|
||||
raw, err := readMappingConfigFile(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)
|
||||
}
|
||||
// Child files only declare mappings. No recursive imports, globs, implicit
|
||||
// directory scans or access outside the project are supported.
|
||||
if len(cfg.MappingFiles) > 64 {
|
||||
return cfg, false, errors.New("omni.gen.json: at most 64 mappingFiles supported")
|
||||
}
|
||||
digest := sha256.New()
|
||||
fmt.Fprintf(digest, "%s:%d:", generatorConfigFile, len(raw))
|
||||
digest.Write(raw)
|
||||
total := len(raw)
|
||||
for i := range cfg.Mappings {
|
||||
cfg.Mappings[i].origin = generatorConfigFile
|
||||
}
|
||||
seen := map[string]bool{generatorConfigFile: true}
|
||||
for _, name := range cfg.MappingFiles {
|
||||
key := strings.ToLower(name)
|
||||
if seen[key] {
|
||||
return cfg, false, fmt.Errorf("omni.gen.json: duplicate mapping file %q", name)
|
||||
}
|
||||
seen[key] = true
|
||||
if !strings.HasSuffix(name, ".json") {
|
||||
return cfg, false, fmt.Errorf("mapping file %q must have a .json extension", name)
|
||||
}
|
||||
data, err := readMappingConfigFile(root, name)
|
||||
if err != nil {
|
||||
return cfg, false, err
|
||||
}
|
||||
total += len(data)
|
||||
if total > 4<<20 {
|
||||
return cfg, false, errors.New("mapping configuration exceeds 4 MiB in total")
|
||||
}
|
||||
var file struct {
|
||||
Mappings []Mapping `json:"mappings"`
|
||||
}
|
||||
if err = apierrors.DecodeCatalogJSON(data, &file); err != nil {
|
||||
return cfg, false, fmt.Errorf("%s: %w (mapping files only support mappings; nested imports are not supported)", name, err)
|
||||
}
|
||||
for i := range file.Mappings {
|
||||
file.Mappings[i].origin = name
|
||||
}
|
||||
cfg.Mappings = append(cfg.Mappings, file.Mappings...)
|
||||
fmt.Fprintf(digest, "%s:%d:", name, len(data))
|
||||
digest.Write(data)
|
||||
}
|
||||
cfg.inputDigest = fmt.Sprintf("%x", digest.Sum(nil))
|
||||
if cfg.Repository != "none" && cfg.Repository != "group" {
|
||||
return cfg, false, errors.New("omni.gen.json: repository must be none or group")
|
||||
}
|
||||
@@ -67,3 +116,34 @@ func readGeneratorConfig(root string) (GeneratorConfig, bool, error) {
|
||||
}
|
||||
return cfg, true, nil
|
||||
}
|
||||
|
||||
// Read through a bounded reader as well as checking stat: a growing file cannot
|
||||
// make configuration loading unbounded. safePath also rejects symlink escapes.
|
||||
func readMappingConfigFile(root, name string) ([]byte, error) {
|
||||
if err := safePath(root, name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(filepath.Join(root, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
defer f.Close()
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !stat.Mode().IsRegular() || stat.Size() > 1<<20 {
|
||||
return nil, fmt.Errorf("%s must be a regular file of at most 1 MiB", name)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) > 1<<20 {
|
||||
return nil, fmt.Errorf("%s exceeds 1 MiB", name)
|
||||
}
|
||||
if trimmed := strings.TrimSpace(string(raw)); !strings.HasPrefix(trimmed, "{") {
|
||||
return nil, fmt.Errorf("%s must contain a JSON object", name)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
141
cmd/go-omni/apigen/generatorConfig_test.go
Normal file
141
cmd/go-omni/apigen/generatorConfig_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMappingFileConfiguration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
defs := nullableMappings()
|
||||
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", Mappings: defs[:1], MappingFiles: []string{"mappings/a.json", "mappings/b.json"}})
|
||||
writeMappingFile(t, dir, "mappings/a.json", defs[1:5])
|
||||
writeMappingFile(t, dir, "mappings/b.json", defs[5:])
|
||||
first, exists, err := readGeneratorConfig(dir)
|
||||
if err != nil || !exists || len(first.Mappings) != len(defs) {
|
||||
t.Fatal(first, err)
|
||||
}
|
||||
for i, m := range first.Mappings {
|
||||
if m.Name != defs[i].Name {
|
||||
t.Fatal("non-deterministic load order")
|
||||
}
|
||||
}
|
||||
second, _, err := readGeneratorConfig(dir)
|
||||
if err != nil || first.inputDigest != second.inputDigest {
|
||||
t.Fatal("unstable config digest", err)
|
||||
}
|
||||
path := filepath.Join(dir, "mappings/a.json")
|
||||
writeFile(t, path, readFile(t, path)+"\n")
|
||||
changed, _, err := readGeneratorConfig(dir)
|
||||
if err != nil || changed.inputDigest == first.inputDigest {
|
||||
t.Fatal("child file change invisible to generation guard", err)
|
||||
}
|
||||
// Duplicate definitions name both files instead of silently overriding.
|
||||
writeMappingFile(t, dir, "mappings/b.json", defs[1:2])
|
||||
cfg, _, err := readGeneratorConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = validateMappings(cfg.Mappings, "example.com/nullable")
|
||||
if err == nil || !strings.Contains(err.Error(), "mappings/a.json") || !strings.Contains(err.Error(), "mappings/b.json") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMappingFileRejection(t *testing.T) {
|
||||
for _, tc := range []struct{ name, root, child, want string }{
|
||||
{"missing", `{"mappingFiles":["maps.json"]}`, "", "maps.json"},
|
||||
{"escape", `{"mappingFiles":["../maps.json"]}`, "", "unsafe"},
|
||||
{"absolute", `{"mappingFiles":["/maps.json"]}`, "", "unsafe"},
|
||||
{"nonCanonical", `{"mappingFiles":["sub/../maps.json"]}`, "", "unsafe"},
|
||||
{"duplicate", `{"mappingFiles":["maps.json","maps.json"]}`, `{"mappings":[]}`, "duplicate mapping file"},
|
||||
{"caseDuplicate", `{"mappingFiles":["maps.json","MAPS.json"]}`, `{"mappings":[]}`, "duplicate mapping file"},
|
||||
{"self", `{"mappingFiles":["omni.gen.json"]}`, "", "duplicate mapping file"},
|
||||
{"recursive", `{"mappingFiles":["maps.json"]}`, `{"mappingFiles":["omni.gen.json"]}`, "nested imports are not supported"},
|
||||
{"childSettings", `{"mappingFiles":["maps.json"]}`, `{"repository":"none","mappings":[]}`, "maps.json"},
|
||||
{"duplicateKeys", `{"mappingFiles":["maps.json"]}`, `{"mappings":[],"mappings":[]}`, "duplicate key"},
|
||||
{"rootNull", `null`, "", "JSON object"},
|
||||
{"childNull", `{"mappingFiles":["maps.json"]}`, `null`, "JSON object"},
|
||||
{"unknownPolicy", `{"mappingFiles":["maps.json"]}`, `{"mappings":[{"unknown":true}]}`, "unknown field"},
|
||||
{"trailingValue", `{"mappingFiles":["maps.json"]}`, `{"mappings":[]} {}`, "maps.json"},
|
||||
{"tooLarge", `{"mappingFiles":["maps.json"]}`, strings.Repeat(" ", 1<<20) + `{}`, "1 MiB"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, filepath.Join(dir, generatorConfigFile), tc.root)
|
||||
if tc.child != "" {
|
||||
writeFile(t, filepath.Join(dir, "maps.json"), tc.child)
|
||||
}
|
||||
before := snapshot(t, dir)
|
||||
_, _, err := readGeneratorConfig(dir)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("read changed configuration")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, directory := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("symlinkDirectory%t", directory), func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
writeMappingFile(t, outside, "maps.json", nil)
|
||||
name := "maps.json"
|
||||
target := filepath.Join(outside, name)
|
||||
link := filepath.Join(dir, name)
|
||||
if directory {
|
||||
name = "linked/maps.json"
|
||||
target = outside
|
||||
link = filepath.Join(dir, "linked")
|
||||
}
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: []string{name}})
|
||||
if _, _, err := readGeneratorConfig(dir); err == nil || !strings.Contains(err.Error(), "symlink") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMappingFileBounds(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := make([]string, 65)
|
||||
for i := range files {
|
||||
files[i] = fmt.Sprintf("maps%d.json", i)
|
||||
}
|
||||
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: files})
|
||||
if _, _, err := readGeneratorConfig(dir); err == nil || !strings.Contains(err.Error(), "64") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: files[:4]})
|
||||
for _, name := range files[:4] {
|
||||
writeFile(t, filepath.Join(dir, name), `{"mappings":[]}`+strings.Repeat(" ", (1<<20)-len(`{"mappings":[]}`)))
|
||||
}
|
||||
if _, _, err := readGeneratorConfig(dir); err == nil || !strings.Contains(err.Error(), "4 MiB") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The definition limit applies after merging, not separately to each file.
|
||||
defs := make([]Mapping, 129)
|
||||
for i := range defs {
|
||||
defs[i] = nullableMappings()[0]
|
||||
defs[i].Name = fmt.Sprintf("Map%d", i)
|
||||
}
|
||||
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: files[:2]})
|
||||
writeMappingFile(t, dir, files[0], defs[:65])
|
||||
writeMappingFile(t, dir, files[1], defs[65:])
|
||||
if _, _, err := readGeneratorConfig(dir); err == nil || !strings.Contains(err.Error(), "128") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Origins/digests are loader metadata, never serialized into user configuration.
|
||||
raw, err := json.Marshal(GeneratorConfig{inputDigest: "private"})
|
||||
if err != nil || strings.Contains(string(raw), "private") {
|
||||
t.Fatal(string(raw), err)
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,9 @@ func generateManagedTestProject(t *testing.T, o Options) error {
|
||||
if o.Check {
|
||||
args = append(args, "--check")
|
||||
}
|
||||
if o.DryRun {
|
||||
args = append(args, "--dry-run")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, cli, args...)
|
||||
cmd.Dir = filepath.Dir(o.Dir)
|
||||
if data, err := cmd.CombinedOutput(); err != nil {
|
||||
|
||||
@@ -28,14 +28,17 @@ type MappingField struct {
|
||||
Convert *GoFunctionRef `json:"convert,omitempty"`
|
||||
Mapping string `json:"mapping,omitempty"`
|
||||
Elements bool `json:"elements,omitempty"`
|
||||
Nil string `json:"nil,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"`
|
||||
Name string `json:"name"`
|
||||
PointerResult bool `json:"pointerResult,omitempty"`
|
||||
origin string
|
||||
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
|
||||
@@ -84,7 +87,7 @@ func renderMappings(o Options, generated, seeds map[string][]byte) error {
|
||||
for _, name := range names {
|
||||
source, err := r.render(r.definitions[name])
|
||||
if err != nil {
|
||||
return fmt.Errorf("mapping %s: %w", name, err)
|
||||
return fmt.Errorf("%s: mapping %s: %w", mappingOrigin(r.definitions[name]), name, err)
|
||||
}
|
||||
body.WriteString(source)
|
||||
}
|
||||
@@ -119,7 +122,7 @@ func renderMappings(o Options, generated, seeds map[string][]byte) error {
|
||||
|
||||
func validateMappings(defs []Mapping, project string) ([]string, error) {
|
||||
refs := map[string]bool{}
|
||||
names := map[string]bool{}
|
||||
names := map[string]string{}
|
||||
validPackage := func(path string) bool {
|
||||
if module.CheckImportPath(path) != nil || path == project+"/internal/mapping" {
|
||||
return false
|
||||
@@ -134,10 +137,19 @@ func validateMappings(defs []Mapping, project string) ([]string, error) {
|
||||
return true
|
||||
}
|
||||
for _, m := range defs {
|
||||
if !gotoken.IsIdentifier(m.Name) || !gotoken.IsExported(m.Name) || names[m.Name] {
|
||||
if !gotoken.IsIdentifier(m.Name) || !gotoken.IsExported(m.Name) {
|
||||
return nil, fmt.Errorf("mapping name %q must be unique and exported", m.Name)
|
||||
}
|
||||
names[m.Name] = true
|
||||
functions := []string{m.Name}
|
||||
if m.PointerResult {
|
||||
functions = append(functions, m.Name+"Ptr")
|
||||
}
|
||||
for _, name := range functions {
|
||||
if prior, exists := names[name]; exists {
|
||||
return nil, fmt.Errorf("mapping function %q must be unique: %s conflicts with %s", name, mappingOrigin(m), prior)
|
||||
}
|
||||
names[name] = mappingOrigin(m)
|
||||
}
|
||||
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)
|
||||
@@ -151,6 +163,12 @@ func validateMappings(defs []Mapping, project string) ([]string, error) {
|
||||
if target == "" || f.Source == "" {
|
||||
return nil, fmt.Errorf("mapping %s: explicit field rule requires target and source paths", m.Name)
|
||||
}
|
||||
if f.Nil != "" && f.Nil != "preserve" && f.Nil != "zero" && f.Nil != "error" {
|
||||
return nil, fmt.Errorf("mapping %s.%s: nil must be preserve, zero or error", m.Name, target)
|
||||
}
|
||||
if f.Convert != nil && f.Nil != "" {
|
||||
return nil, fmt.Errorf("mapping %s.%s: nil policy cannot be combined with a converter; the converter owns nil behavior", m.Name, target)
|
||||
}
|
||||
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)
|
||||
@@ -393,16 +411,18 @@ func (r *mappingRenderer) render(m Mapping) (string, error) {
|
||||
}
|
||||
}
|
||||
text += "return result,nil\n}\n"
|
||||
if m.PointerResult {
|
||||
text += fmt.Sprintf("// %sPtr returns nil on conversion failure and otherwise a pointer to the result.\nfunc %sPtr(source %s)(*%s,error){\nvalue,err:=%s(source)\nif err!=nil{return nil,err}\nreturn &value,nil\n}\n", m.Name, m.Name, r.typeName(mt.source), r.typeName(mt.target), m.Name)
|
||||
}
|
||||
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
|
||||
return r.nestedExpression(rule, from, to, source)
|
||||
}
|
||||
if rule.Nil != "" {
|
||||
return r.dereferenceExpression(rule, from, to, source)
|
||||
}
|
||||
if rule.Convert != nil {
|
||||
pkg, err := r.importer.Import(rule.Convert.Package)
|
||||
|
||||
250
cmd/go-omni/apigen/mappingNullable_test.go
Normal file
250
cmd/go-omni/apigen/mappingNullable_test.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func nullableMappings() []Mapping {
|
||||
pkg := "example.com/nullable/internal/"
|
||||
ref := func(p, name string) GoTypeRef { return GoTypeRef{pkg + p, name} }
|
||||
child := Mapping{Name: "ToChild", Source: ref("types", "Child"), Target: ref("domain", "Child"), Fields: map[string]MappingField{
|
||||
"Name": {Source: "Name", Convert: &GoFunctionRef{pkg + "convert", "Name"}},
|
||||
}}
|
||||
parent := Mapping{Name: "ToParent", PointerResult: true, Source: ref("types", "Parent"), Target: ref("domain", "Parent"), Fields: map[string]MappingField{
|
||||
"Child": {Source: "Child", Mapping: "ToChild"}, "Children": {Source: "Children", Mapping: "ToChild", Elements: true},
|
||||
}}
|
||||
defs := []Mapping{child, parent,
|
||||
{Name: "ToWireChild", Source: ref("domain", "Child"), Target: ref("types", "Child")},
|
||||
{Name: "ToWire", PointerResult: true, Source: ref("domain", "Parent"), Target: ref("types", "Parent"), Fields: map[string]MappingField{
|
||||
"Child": {Source: "Child", Mapping: "ToWireChild"}, "Children": {Source: "Children", Mapping: "ToWireChild", Elements: true},
|
||||
}},
|
||||
{Name: "ToChildDTO", Source: ref("domain", "Child"), Target: ref("domain", "ChildDTO"), Fields: map[string]MappingField{"Name": {Source: "Name", Convert: &GoFunctionRef{pkg + "convert", "Name"}}}},
|
||||
{Name: "Lift", Source: ref("domain", "ValueHolder"), Target: ref("domain", "PointerHolder"), Fields: map[string]MappingField{"Child": {Source: "Child", Mapping: "ToChildDTO"}}},
|
||||
{Name: "Batch", Source: ref("domain", "InputBatch"), Target: ref("domain", "OutputBatch"), Fields: map[string]MappingField{"Fixed": {Source: "Fixed", Mapping: "ToChildDTO", Elements: true}}},
|
||||
{Name: "ShallowCopy", Source: ref("domain", "Parent"), Target: ref("domain", "Parent")},
|
||||
{Name: "RequirePointer", Source: ref("types", "Parent"), Target: ref("domain", "Parent"), Fields: map[string]MappingField{
|
||||
"Child": {Source: "Child", Mapping: "ToChild", Nil: "error"}, "Children": {Source: "Children", Mapping: "ToChild", Elements: true},
|
||||
}},
|
||||
}
|
||||
for _, p := range []struct{ name, policy string }{{"RequiredZero", "zero"}, {"RequiredError", "error"}} {
|
||||
defs = append(defs, Mapping{Name: p.name, Source: ref("types", "Parent"), Target: ref("domain", "Required"), Fields: map[string]MappingField{
|
||||
"Child": {Source: "Child", Mapping: "ToChild", Nil: p.policy},
|
||||
}, IgnoreSource: map[string]string{"Children": "this explicit projection only includes the child"}})
|
||||
}
|
||||
for _, p := range []struct{ name, policy string }{{"ScalarZero", "zero"}, {"ScalarError", "error"}} {
|
||||
m := Mapping{Name: p.name, Source: ref("domain", "ScalarInput"), Target: ref("domain", "ScalarOutput"), Fields: map[string]MappingField{}}
|
||||
for _, name := range []string{"Text", "Count", "Enabled"} {
|
||||
m.Fields[name] = MappingField{Source: name, Nil: p.policy}
|
||||
}
|
||||
defs = append(defs, m)
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
func writeMappingFile(t *testing.T, root, name string, defs []Mapping) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(struct {
|
||||
Mappings []Mapping `json:"mappings"`
|
||||
}{defs}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(root, name), string(raw)+"\n")
|
||||
}
|
||||
|
||||
func TestNullableMappingConsumer(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||
}
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "app")
|
||||
o := Options{API: filepath.Join(frameworkRoot(t), "cmd/go-omni/apigen/testdata/nullable/service.api"), Dir: dir, Module: "example.com/nullable", FrameworkDir: frameworkRoot(t)}
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, fixture := range map[string]string{"internal/domain/model.go": "domain.go", "internal/convert/value.go": "convert.go"} {
|
||||
writeFile(t, filepath.Join(dir, path), readFile(t, "testdata/nullable/"+fixture))
|
||||
}
|
||||
runCompositionGo(t, dir, "mod", "tidy")
|
||||
defs := nullableMappings()
|
||||
// References go across files, including forward references to ToChild.
|
||||
writeMappingFile(t, dir, "mappings/parents.json", defs[1:])
|
||||
writeMappingFile(t, dir, "mappings/children.json", defs[:1])
|
||||
cfg := GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: []string{"mappings/parents.json", "mappings/children.json"}}
|
||||
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, "internal/logic/probe/roundTrip.go"), `package probe
|
||||
import("context";"example.com/nullable/internal/types";"example.com/nullable/internal/mapping")
|
||||
func(s *Logic)RoundTrip(ctx context.Context,req *types.Parent)(*types.Parent,error){
|
||||
value,err:=mapping.ToParent(*req);if err!=nil{return nil,err}
|
||||
return mapping.ToWirePtr(value)
|
||||
}
|
||||
`)
|
||||
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/nullable/consumer_test.go"))
|
||||
before := snapshot(t, dir)
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("regeneration changed business/config files")
|
||||
}
|
||||
o.Check = true
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("check wrote files")
|
||||
}
|
||||
runCompositionGo(t, dir, "test", "-race", "-count=1", "./...")
|
||||
runCompositionGo(t, dir, "build", ".")
|
||||
// Workspace preflight and check use the same split configuration loader.
|
||||
workspace := filepath.Join(root, "omni.workspace.json")
|
||||
writeFile(t, workspace, `{"services":[{"dir":"app"}]}`)
|
||||
for _, command := range []string{"format", "generate", "check"} {
|
||||
if err := nullableWorkspace(t, workspace, command); err != nil {
|
||||
t.Fatal(command, err)
|
||||
}
|
||||
}
|
||||
// All invalid configurations fail before either writing or previewing output.
|
||||
for _, tc := range []struct {
|
||||
name, want string
|
||||
edit func([]Mapping) []Mapping
|
||||
}{
|
||||
{"missingNilPolicy", "requires explicit nil", func(d []Mapping) []Mapping {
|
||||
d[9].Fields["Child"] = MappingField{Source: "Child", Mapping: "ToChild"}
|
||||
return d
|
||||
}},
|
||||
{"preserveValue", "requires explicit nil", func(d []Mapping) []Mapping {
|
||||
d[9].Fields["Child"] = MappingField{Source: "Child", Mapping: "ToChild", Nil: "preserve"}
|
||||
return d
|
||||
}},
|
||||
{"zeroPointer", "pointer target", func(d []Mapping) []Mapping {
|
||||
d[1].Fields["Child"] = MappingField{Source: "Child", Mapping: "ToChild", Nil: "zero"}
|
||||
return d
|
||||
}},
|
||||
{"scalarMismatch", "identical *T to T", func(d []Mapping) []Mapping {
|
||||
d[11].Fields["Text"] = MappingField{Source: "Count", Nil: "zero"}
|
||||
return d
|
||||
}},
|
||||
{"valueNil", "requires a pointer source", func(d []Mapping) []Mapping {
|
||||
d[5].Fields["Child"] = MappingField{Source: "Child", Mapping: "ToChildDTO", Nil: "zero"}
|
||||
return d
|
||||
}},
|
||||
{"converterNil", "cannot be combined", func(d []Mapping) []Mapping {
|
||||
f := d[0].Fields["Name"]
|
||||
f.Nil = "error"
|
||||
d[0].Fields["Name"] = f
|
||||
return d
|
||||
}},
|
||||
{"pointerNameCollision", "must be unique", func(d []Mapping) []Mapping { m := d[0]; m.Name = "ToParentPtr"; return append(d, m) }},
|
||||
{"mappingCycle", "cycle", func(d []Mapping) []Mapping {
|
||||
d[0].Fields["Name"] = MappingField{Source: "Name", Mapping: "ToParent"}
|
||||
return d
|
||||
}},
|
||||
{"unknownMapping", "unknown mapping", func(d []Mapping) []Mapping {
|
||||
d[1].Fields["Child"] = MappingField{Source: "Child", Mapping: "Missing"}
|
||||
return d
|
||||
}},
|
||||
{"unhandledField", "unmapped source", func(d []Mapping) []Mapping { d[9].IgnoreSource = nil; return d }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
changed := tc.edit(nullableMappings())
|
||||
writeMappingFile(t, dir, "mappings/parents.json", changed[1:])
|
||||
writeMappingFile(t, dir, "mappings/children.json", changed[:1])
|
||||
defer writeMappingFile(t, dir, "mappings/parents.json", defs[1:])
|
||||
defer writeMappingFile(t, dir, "mappings/children.json", defs[:1])
|
||||
before := snapshot(t, dir)
|
||||
for _, mode := range []string{"check", "preview", "generate"} {
|
||||
current := o
|
||||
current.Check = mode == "check"
|
||||
current.DryRun = mode == "preview"
|
||||
err := generateManagedTestProject(t, current)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatal(mode, err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("invalid mapping wrote files")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// An edit inside a child file is picked up by --check; the config is never rewritten.
|
||||
changed := nullableMappings()
|
||||
changed[0].PointerResult = true
|
||||
writeMappingFile(t, dir, "mappings/children.json", changed[:1])
|
||||
before = snapshot(t, dir)
|
||||
if err := generateManagedTestProject(t, o); err == nil {
|
||||
t.Fatal("check missed child mapping drift")
|
||||
}
|
||||
if err := nullableWorkspace(t, workspace, "check"); err == nil {
|
||||
t.Fatal("workspace missed child mapping drift")
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("drift check wrote files")
|
||||
}
|
||||
preview := o
|
||||
preview.Check = false
|
||||
preview.DryRun = true
|
||||
if err := generateManagedTestProject(t, preview); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("preview wrote files")
|
||||
}
|
||||
if err := nullableWorkspace(t, workspace, "generate"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := nullableWorkspace(t, workspace, "check"); 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 nullableWorkspace(t *testing.T, path, command string) error {
|
||||
t.Helper()
|
||||
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
|
||||
if cli == "" {
|
||||
return Workspace(path, command, io.Discard)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, cli, "workspace", command, "--file", path)
|
||||
cmd.Dir = filepath.Dir(path)
|
||||
if data, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("released workspace CLI: %w\n%s", err, data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
97
cmd/go-omni/apigen/mappingPointer.go
Normal file
97
cmd/go-omni/apigen/mappingPointer.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/types"
|
||||
)
|
||||
|
||||
func mappingOrigin(m Mapping) string {
|
||||
if m.origin != "" {
|
||||
return m.origin
|
||||
}
|
||||
return generatorConfigFile
|
||||
}
|
||||
|
||||
// Pointer adaptation is only selected by an explicit nested mapping. Ordinary
|
||||
// same-type pointer assignments retain their existing reference semantics.
|
||||
func (r *mappingRenderer) nestedExpression(rule MappingField, from, to types.Type, source string) (string, bool, error) {
|
||||
m := r.types[rule.Mapping]
|
||||
fromValue, toValue := from, to
|
||||
src, sourcePointer := from.Underlying().(*types.Pointer)
|
||||
dst, targetPointer := to.Underlying().(*types.Pointer)
|
||||
if sourcePointer {
|
||||
fromValue = src.Elem()
|
||||
}
|
||||
if targetPointer {
|
||||
toValue = dst.Elem()
|
||||
}
|
||||
if !types.Identical(fromValue, m.source) || !types.Identical(toValue, m.target) {
|
||||
return "", false, fmt.Errorf("nested mapping %s has incompatible types", rule.Mapping)
|
||||
}
|
||||
if !sourcePointer && rule.Nil != "" {
|
||||
return "", false, fmt.Errorf("nil policy requires a pointer source")
|
||||
}
|
||||
if !sourcePointer && !targetPointer {
|
||||
return rule.Mapping + "(" + source + ")", true, nil
|
||||
}
|
||||
policy := rule.Nil
|
||||
if sourcePointer {
|
||||
if targetPointer {
|
||||
if policy == "" {
|
||||
policy = "preserve"
|
||||
}
|
||||
if policy != "preserve" && policy != "error" {
|
||||
return "", false, fmt.Errorf("pointer target requires nil preserve or error")
|
||||
}
|
||||
} else if policy != "zero" && policy != "error" {
|
||||
return "", false, fmt.Errorf("pointer to value mapping requires explicit nil policy zero or error")
|
||||
}
|
||||
}
|
||||
// Keep a typed local input so the source is evaluated once. The surrounding
|
||||
// renderer adds the mapping/field/index error path and returns a zero parent.
|
||||
body := "var zero " + r.typeName(to) + "\n"
|
||||
input := "input"
|
||||
if sourcePointer {
|
||||
nilResult, err := r.nilResult(policy)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
body += "if input==nil {" + nilResult + "}\n"
|
||||
input = "*input"
|
||||
}
|
||||
body += "value,err:=" + rule.Mapping + "(" + input + ")\nif err!=nil{return zero,err}\n"
|
||||
if targetPointer {
|
||||
body += "return &value,nil\n"
|
||||
} else {
|
||||
body += "return value,nil\n"
|
||||
}
|
||||
return "func(input " + r.typeName(from) + ")(" + r.typeName(to) + ",error){\n" + body + "}(" + source + ")", true, nil
|
||||
}
|
||||
|
||||
// A nil policy without a converter/mapping is deliberately limited to *T -> T.
|
||||
// It never casts numbers, changes named types, or invents business defaults.
|
||||
func (r *mappingRenderer) dereferenceExpression(rule MappingField, from, to types.Type, source string) (string, bool, error) {
|
||||
pointer, ok := from.Underlying().(*types.Pointer)
|
||||
if !ok || !types.Identical(pointer.Elem(), to) {
|
||||
return "", false, fmt.Errorf("nil policy without a mapping requires identical *T to T types")
|
||||
}
|
||||
if rule.Nil != "zero" && rule.Nil != "error" {
|
||||
return "", false, fmt.Errorf("pointer to value conversion requires nil zero or error")
|
||||
}
|
||||
nilResult, err := r.nilResult(rule.Nil)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return "func(input " + r.typeName(from) + ")(" + r.typeName(to) + ",error){\nvar zero " + r.typeName(to) + "\nif input==nil{" + nilResult + "}\nreturn *input,nil\n}(" + source + ")", true, nil
|
||||
}
|
||||
|
||||
func (r *mappingRenderer) nilResult(policy string) (string, error) {
|
||||
if policy != "error" {
|
||||
return "return zero,nil", nil
|
||||
}
|
||||
pkg, err := r.importer.Import("fmt")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "return zero," + r.qualifier(pkg) + ".Errorf(\"nil source is not allowed\")", nil
|
||||
}
|
||||
@@ -252,7 +252,7 @@ func TestMappingConfigurationRejection(t *testing.T) {
|
||||
}, "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"},
|
||||
{"duplicates", func(c *GeneratorConfig) { c.Mappings[1].Name = c.Mappings[0].Name }, "must be unique"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
170
cmd/go-omni/apigen/testdata/nullable/consumer_test.go
vendored
Normal file
170
cmd/go-omni/apigen/testdata/nullable/consumer_test.go
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"example.com/nullable/internal/convert"
|
||||
"example.com/nullable/internal/domain"
|
||||
"example.com/nullable/internal/handler"
|
||||
"example.com/nullable/internal/mapping"
|
||||
"example.com/nullable/internal/svc"
|
||||
"example.com/nullable/internal/types"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestNullableMappings(t *testing.T) {
|
||||
for _, src := range []types.Parent{{}, {Children: []*types.Child{}}, {Child: &types.Child{}, Children: []*types.Child{nil, {Name: "two"}}}} {
|
||||
out, err := mapping.ToParent(src)
|
||||
if err != nil || (src.Child == nil) != (out.Child == nil) || (src.Children == nil) != (out.Children == nil) || len(src.Children) != len(out.Children) {
|
||||
t.Fatal(src, out, err)
|
||||
}
|
||||
if src.Child != nil && out.Child.Name != src.Child.Name {
|
||||
t.Fatal("zero child changed")
|
||||
}
|
||||
if len(out.Children) > 0 && (out.Children[0] != nil || out.Children[1].Name != "two") {
|
||||
t.Fatal(out)
|
||||
}
|
||||
}
|
||||
src := types.Parent{Child: &types.Child{Name: "one"}, Children: []*types.Child{{Name: "two"}}}
|
||||
out, err := mapping.ToParentPtr(src)
|
||||
if err != nil || out == nil || out.Child.Name != "one" {
|
||||
t.Fatal(out, err)
|
||||
}
|
||||
out.Child.Name = "changed"
|
||||
out.Children[0].Name = "changed"
|
||||
if src.Child.Name != "one" || src.Children[0].Name != "two" {
|
||||
t.Fatal("nested mapped values alias input")
|
||||
}
|
||||
plain := domain.Parent{Child: &domain.Child{Name: "shared"}}
|
||||
copy, err := mapping.ShallowCopy(plain)
|
||||
if err != nil || copy.Child != plain.Child {
|
||||
t.Fatal("ordinary assignment semantics changed", err)
|
||||
}
|
||||
zero, err := mapping.RequiredZero(types.Parent{})
|
||||
if err != nil || zero.Child.Name != "" {
|
||||
t.Fatal(zero, err)
|
||||
}
|
||||
for _, fn := range []func(types.Parent) (domain.Required, error){mapping.RequiredZero, mapping.RequiredError} {
|
||||
out, err := fn(types.Parent{Child: &types.Child{Name: "required"}})
|
||||
if err != nil || out.Child.Name != "required" {
|
||||
t.Fatal(out, err)
|
||||
}
|
||||
}
|
||||
_, err = mapping.RequiredError(types.Parent{})
|
||||
if err == nil || !strings.Contains(err.Error(), "RequiredError.Child") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = mapping.RequirePointer(types.Parent{})
|
||||
if err == nil || !strings.Contains(err.Error(), "RequirePointer.Child") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lift, err := mapping.Lift(domain.ValueHolder{Child: domain.Child{Name: "lift"}})
|
||||
if err != nil || lift.Child == nil || lift.Child.Name != "lift" {
|
||||
t.Fatal(lift, err)
|
||||
}
|
||||
batch, err := mapping.Batch(domain.InputBatch{Fixed: [2]*domain.Child{nil, {Name: "array"}}})
|
||||
if err != nil || batch.Fixed[0] != nil || batch.Fixed[1].Name != "array" {
|
||||
t.Fatal(batch, err)
|
||||
}
|
||||
_, err = mapping.Batch(domain.InputBatch{Fixed: [2]*domain.Child{nil, {Name: "bad"}}})
|
||||
if !errors.Is(err, convert.ErrValue) || !strings.Contains(err.Error(), "Batch.Fixed[1]") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, input := range []types.Parent{{Child: &types.Child{Name: "bad"}}, {Children: []*types.Child{nil, {Name: "bad"}}}} {
|
||||
failed, err := mapping.ToParentPtr(input)
|
||||
if failed != nil || !errors.Is(err, convert.ErrValue) || !strings.Contains(err.Error(), "ToChild.Name") {
|
||||
t.Fatal(failed, err)
|
||||
}
|
||||
if len(input.Children) > 0 && !strings.Contains(err.Error(), "ToParent.Children[1]") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := mapping.ToParent(input)
|
||||
if value.Child != nil || value.Children != nil || !errors.Is(err, convert.ErrValue) {
|
||||
t.Fatal("partial result escaped", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitNilAndConcurrentCalls(t *testing.T) {
|
||||
zero, err := mapping.ScalarZero(domain.ScalarInput{})
|
||||
if err != nil || zero != (domain.ScalarOutput{}) {
|
||||
t.Fatal(zero, err)
|
||||
}
|
||||
_, err = mapping.ScalarError(domain.ScalarInput{})
|
||||
if err == nil {
|
||||
t.Fatal("nil accepted")
|
||||
}
|
||||
text, count, enabled := "", int64(0), false
|
||||
out, err := mapping.ScalarError(domain.ScalarInput{Text: &text, Count: &count, Enabled: &enabled})
|
||||
if err != nil || out != (domain.ScalarOutput{}) {
|
||||
t.Fatal("non-nil zero rejected", out, err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 32; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
out, err := mapping.ToParent(types.Parent{Child: &types.Child{Name: "parallel"}})
|
||||
if err != nil || out.Child == nil || out.Child.Name != "parallel" {
|
||||
t.Error(out, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestHTTPNullableMapping(t *testing.T) {
|
||||
cfg := logx.Default()
|
||||
cfg.Writer = io.Discard
|
||||
logger, err := logx.New(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
deps, err := svc.New(context.Background(), logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deps.Close()
|
||||
app, err := handler.NewApp(deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.Close()
|
||||
for _, body := range []string{`{}`, `{"child":null}`, `{"child":{"name":""}}`, `{"child":{"name":"one"},"children":[null,{"name":"two"}]}`, `{"children":[]}`} {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
app.Engine().ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
var input, output types.Parent
|
||||
if err := json.Unmarshal([]byte(body), &input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if (input.Child == nil) != (output.Child == nil) || (input.Children == nil) != (output.Children == nil) || len(input.Children) != len(output.Children) {
|
||||
t.Fatal(body, w.Body.String())
|
||||
}
|
||||
if input.Child != nil && input.Child.Name != output.Child.Name {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader(`{"child":{"name":"bad"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
app.Engine().ServeHTTP(w, req)
|
||||
if w.Code != 500 || strings.Contains(w.Body.String(), convert.ErrValue.Error()) || strings.Contains(w.Body.String(), "ToChild") {
|
||||
t.Fatal("private cause leaked", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
15
cmd/go-omni/apigen/testdata/nullable/convert.go
vendored
Normal file
15
cmd/go-omni/apigen/testdata/nullable/convert.go
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var ErrValue = errors.New("private conversion cause")
|
||||
|
||||
func Name(value string) (string, error) {
|
||||
if value == "bad" {
|
||||
return "", fmt.Errorf("conversion refused: %w", ErrValue)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
23
cmd/go-omni/apigen/testdata/nullable/domain.go
vendored
Normal file
23
cmd/go-omni/apigen/testdata/nullable/domain.go
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
package domain
|
||||
|
||||
type Child struct{ Name string }
|
||||
type ChildDTO struct{ Name string }
|
||||
type Parent struct {
|
||||
Child *Child
|
||||
Children []*Child
|
||||
}
|
||||
type Required struct{ Child Child }
|
||||
type ValueHolder struct{ Child Child }
|
||||
type PointerHolder struct{ Child *ChildDTO }
|
||||
type InputBatch struct{ Fixed [2]*Child }
|
||||
type OutputBatch struct{ Fixed [2]*ChildDTO }
|
||||
type ScalarInput struct {
|
||||
Text *string
|
||||
Count *int64
|
||||
Enabled *bool
|
||||
}
|
||||
type ScalarOutput struct {
|
||||
Text string
|
||||
Count int64
|
||||
Enabled bool
|
||||
}
|
||||
15
cmd/go-omni/apigen/testdata/nullable/service.api
vendored
Normal file
15
cmd/go-omni/apigen/testdata/nullable/service.api
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
type Child {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type Parent {
|
||||
Child *Child `json:"child,optional"`
|
||||
Children []*Child `json:"children,optional"`
|
||||
}
|
||||
@server (
|
||||
group: probe
|
||||
response: raw
|
||||
)
|
||||
service probe {
|
||||
@handler roundTrip
|
||||
post / (Parent) returns (Parent)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||
for _, flag := range [][]string{{"--framework-version", "v0.17.0-preview"}, {"--framework-version=v0.17.0-preview"}} {
|
||||
for _, flag := range [][]string{{"--framework-version", "v0.18.0-preview"}, {"--framework-version=v0.18.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.17.0-preview") || strings.Contains(string(content), "replace ") {
|
||||
if !strings.Contains(string(content), "go-omni v0.18.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.17.0-preview"
|
||||
const defaultFrameworkVersion = "v0.18.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