fix: support recursive response DTOs and release v0.28.2
This commit is contained in:
@@ -20,7 +20,7 @@ API 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、hand
|
||||
|
||||
```sh
|
||||
# 仓库读取权限与 PATH 配置见根目录 README
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.2-preview
|
||||
go-omni new --module example.com/hello ./hello
|
||||
|
||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||
@@ -33,6 +33,8 @@ go-omni new --api service.api --module example.com/example ./example
|
||||
|
||||
存储配置和客户端位于 config/svc,业务使用统一接口。local 存储签名端点由生成器接入;上传、下载等业务路由由 `.api` 定义。认证与存储路径由项目实现。
|
||||
|
||||
响应 DTO 支持自引用及相互引用,生成强类型 Go 字段与 OpenAPI `$ref`;见[递归响应示例](../../examples/recursiveResponse/README.md)。请求递归仍会报错。
|
||||
|
||||
## 日常生成
|
||||
|
||||
```sh
|
||||
@@ -55,14 +57,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
||||
|
||||
## 版本与迁移
|
||||
|
||||
CLI 与框架依赖固定使用 v0.28.1-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
CLI 与框架依赖固定使用 v0.28.2-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
|
||||
```sh
|
||||
# 在 go-omni 源码仓库中执行
|
||||
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
||||
```
|
||||
|
||||
当前模板与依赖以 v0.28.1-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
当前模板与依赖以 v0.28.2-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
|
||||
## 数据库迁移命令
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
goparser "go/parser"
|
||||
gotoken "go/token"
|
||||
"reflect"
|
||||
"regexp"
|
||||
@@ -69,56 +67,17 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved := map[string]reflect.Type{}
|
||||
resolving := map[string]bool{}
|
||||
var resolve func(string, int) (reflect.Type, error)
|
||||
var expression func(ast.Expr, int) (reflect.Type, error)
|
||||
|
||||
expression = func(e ast.Expr, depth int) (reflect.Type, error) {
|
||||
if depth > 32 {
|
||||
return nil, errors.New("type nesting exceeds 32")
|
||||
}
|
||||
switch e := e.(type) {
|
||||
case *ast.Ident:
|
||||
if t, ok := scalarTypes[e.Name]; ok {
|
||||
return t, nil
|
||||
}
|
||||
if t, ok := primitiveTypes[e.Name]; ok {
|
||||
return t, nil
|
||||
}
|
||||
return resolve(e.Name, depth+1)
|
||||
case *ast.StarExpr:
|
||||
if _, double := e.X.(*ast.StarExpr); double {
|
||||
return nil, errors.New("multiple pointer levels unsupported")
|
||||
}
|
||||
t, err := expression(e.X, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reflect.PointerTo(t), nil
|
||||
case *ast.ArrayType:
|
||||
if e.Len != nil {
|
||||
return nil, errors.New("use slices, not fixed arrays")
|
||||
}
|
||||
t, err := expression(e.Elt, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reflect.SliceOf(t), nil
|
||||
case *ast.MapType:
|
||||
key, ok := e.Key.(*ast.Ident)
|
||||
if !ok || key.Name != "string" {
|
||||
return nil, errors.New("map keys must be string")
|
||||
}
|
||||
t, err := expression(e.Value, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reflect.MapOf(reflect.TypeOf(""), t), nil
|
||||
default:
|
||||
return nil, errors.New("unsupported type expression")
|
||||
graph, err := newResponseGraph(spec, definitions, scalarTypes, scalarDefs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range spec.Routes {
|
||||
if graph.recursive[r.Request] {
|
||||
return nil, fmt.Errorf("%s: recursive request DTO %s is unsupported; recursive types are supported for responses only", r.Position, r.Request)
|
||||
}
|
||||
}
|
||||
resolved := map[string]reflect.Type{}
|
||||
var resolve func(string, int) (reflect.Type, error)
|
||||
resolve = func(name string, depth int) (reflect.Type, error) {
|
||||
if depth > 32 {
|
||||
return nil, errors.New("type nesting exceeds 32")
|
||||
@@ -126,27 +85,13 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
if t, ok := resolved[name]; ok {
|
||||
return t, nil
|
||||
}
|
||||
if resolving[name] {
|
||||
return nil, fmt.Errorf("recursive type %s is unsupported", name)
|
||||
}
|
||||
typ, ok := definitions[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown type %s", name)
|
||||
}
|
||||
resolving[name] = true
|
||||
defer delete(resolving, name)
|
||||
var fields []reflect.StructField
|
||||
seen := map[string]bool{}
|
||||
for _, f := range typ.Fields {
|
||||
if !exportedName.MatchString(f.Name) || seen[f.Name] {
|
||||
return nil, fmt.Errorf("%s: invalid or duplicate field %s", name, f.Name)
|
||||
}
|
||||
seen[f.Name] = true
|
||||
expr, err := goparser.ParseExpr(f.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := expression(expr, depth+1)
|
||||
for i, f := range typ.Fields {
|
||||
t, err := graph.expression(graph.exprs[name][i], depth+1, resolve)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s.%s: %w", name, f.Name, err)
|
||||
}
|
||||
@@ -158,6 +103,12 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
}
|
||||
c := &Compiled{Spec: spec, Bindings: map[string]*contract.Binder{}}
|
||||
for _, t := range spec.Types {
|
||||
if graph.recursive[t.Name] {
|
||||
if _, err := graph.component(t.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
rt, err := resolve(t.Name, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -274,7 +225,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
}
|
||||
var response *openapi.Schema
|
||||
if r.Response != "" {
|
||||
s, err := responseDataSchema(r.Response, c.Bindings)
|
||||
s, err := responseDataSchema(r.Response, c.Bindings, graph)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -316,6 +267,13 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
return nil, err
|
||||
}
|
||||
decorateDocument(doc, spec, c.Bindings)
|
||||
if len(graph.components) > 0 {
|
||||
components := map[string]any{}
|
||||
for name, schema := range graph.components {
|
||||
components[name] = schema
|
||||
}
|
||||
doc["components"].(map[string]any)["schemas"] = components
|
||||
}
|
||||
compactDocument(doc)
|
||||
c.Document, err = json.MarshalIndent(doc, "", " ")
|
||||
return c, err
|
||||
|
||||
@@ -23,7 +23,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
|
||||
// Runtime-only releases need not raise the managed template compatibility floor.
|
||||
const minimumVersion = "v0.22.0-preview"
|
||||
const currentVersion = "v0.28.1-preview"
|
||||
const currentVersion = "v0.28.2-preview"
|
||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||
|
||||
type Options struct {
|
||||
|
||||
299
cmd/go-omni/apigen/recursiveResponse_test.go
Normal file
299
cmd/go-omni/apigen/recursiveResponse_test.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func compileRecursive(t *testing.T, source string) *Compiled {
|
||||
t.Helper()
|
||||
spec, err := parseText(t, source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := Compile(spec)
|
||||
if err != nil || !bytes.Equal(c.Document, again.Document) {
|
||||
t.Fatal("non-deterministic compilation", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func recursiveDocument(t *testing.T, c *Compiled) map[string]any {
|
||||
t.Helper()
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(c.Document, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Inspect reference targets without recursively inlining the graph.
|
||||
var visit func(any)
|
||||
visit = func(value any) {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
if ref, ok := v["$ref"].(string); ok {
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("OpenAPI 3.0 ref has ignored siblings: %v", v)
|
||||
}
|
||||
var target any = doc
|
||||
for _, part := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") {
|
||||
m, ok := target.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("bad reference", ref)
|
||||
}
|
||||
target = m[part]
|
||||
}
|
||||
if target == nil {
|
||||
t.Fatal("dangling reference", ref)
|
||||
}
|
||||
return
|
||||
}
|
||||
for key, child := range v {
|
||||
if key != "example" && key != "examples" {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range v {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(doc)
|
||||
return doc
|
||||
}
|
||||
|
||||
func TestRecursiveResponseGraph(t *testing.T) {
|
||||
source := readFile(t, filepath.Join(frameworkRoot(t), "examples/recursiveResponse/service.api"))
|
||||
c := compileRecursive(t, source)
|
||||
doc := recursiveDocument(t, c)
|
||||
schemas := doc["components"].(map[string]any)["schemas"].(map[string]any)
|
||||
node := schemas["DTO_Node"].(map[string]any)
|
||||
props := node["properties"].(map[string]any)
|
||||
children := props["children"].(map[string]any)
|
||||
if children["type"] != "array" || children["maxItems"] != float64(100) || children["items"].(map[string]any)["$ref"] != "#/components/schemas/DTO_Node" {
|
||||
t.Fatal(children)
|
||||
}
|
||||
if props["lookup"].(map[string]any)["additionalProperties"].(map[string]any)["$ref"] != "#/components/schemas/DTO_Node" {
|
||||
t.Fatal(props)
|
||||
}
|
||||
next := props["next"].(map[string]any)["anyOf"].([]any)
|
||||
if len(next) != 2 || next[0].(map[string]any)["$ref"] != "#/components/schemas/DTO_Node" || next[1].(map[string]any)["nullable"] != true {
|
||||
t.Fatal(next)
|
||||
}
|
||||
for _, pair := range [][2]string{{"Branch", "Leaf"}, {"Leaf", "Branch"}} {
|
||||
p := schemas["DTO_"+pair[0]].(map[string]any)["properties"].(map[string]any)[strings.ToLower(pair[1])].(map[string]any)
|
||||
if p["anyOf"].([]any)[0].(map[string]any)["$ref"] != "#/components/schemas/DTO_"+pair[1] {
|
||||
t.Fatal(p)
|
||||
}
|
||||
}
|
||||
if len(c.Document) > 18000 {
|
||||
t.Fatal("recursive graph expanded", len(c.Document))
|
||||
}
|
||||
first, _ := json.Marshal(doc)
|
||||
compactDocument(doc)
|
||||
second, _ := json.Marshal(doc)
|
||||
if !bytes.Equal(first, second) {
|
||||
t.Fatal("document compaction changed references")
|
||||
}
|
||||
// Change tracking traverses every named dependency once, including cycles.
|
||||
snapshot := snapshotContract(c.Spec)
|
||||
if len(snapshot.Routes) == 0 {
|
||||
t.Fatal("empty contract snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecursiveResponseRejections(t *testing.T) {
|
||||
tests := []struct{ name, types, route, want string }{
|
||||
{"valueSelf", "type Node { Child Node `json:\"child\"` }", "get /tree returns (Node)", "recursive value"},
|
||||
{"valueMutual", "type Node { Child Other `json:\"child\"` }\ntype Other { Child Node `json:\"child\"` }", "get /tree returns (Node)", "recursive value"},
|
||||
{"request", "type Node { Children []Node `json:\"children\"` }", "post /tree (Node) returns (Node)", "recursive request"},
|
||||
{"wrappedRequest", "type Node { Children []Node `json:\"children\"` }\ntype Req { Value Node `json:\"value\"` }", "post /tree (Req)", "recursive request"},
|
||||
{"unknown", "type Node { Children []Node `json:\"children\"`\n Bad Missing `json:\"bad\"` }", "get /tree returns (Node)", "unknown type"},
|
||||
{"duplicate", "type Node { Children []Node `json:\"children\"`\n Again string `json:\"children\"` }", "get /tree returns (Node)", "conflicting composed field"},
|
||||
{"nonJSON", "type Node { Children []Node `json:\"children\"`\n Name string `form:\"name\"` }", "get /tree returns (Node)", "response fields must use json"},
|
||||
{"nestedNonJSON", "type Node { Children []Node `json:\"children\"`\n Detail Detail `json:\"detail\"` }\ntype Detail { Name string `form:\"name\"` }", "get /tree returns (Node)", "response fields must use json"},
|
||||
{"doublePointer", "type Node { Next **Node `json:\"next\"` }", "get /tree returns (Node)", "multiple pointer"},
|
||||
{"fixedArray", "type Node { Children [1]Node `json:\"children\"` }", "get /tree returns (Node)", "fixed arrays"},
|
||||
{"mapKey", "type Node { Children map[int]Node `json:\"children\"` }", "get /tree returns (Node)", "map keys"},
|
||||
{"badRule", "type Node { Next *Node `json:\"next\" binding:\"min=1\"` }", "get /tree returns (Node)", "requires a number"},
|
||||
{"badDefault", "type Node { Next *Node `json:\"next,default=x\"` }", "get /tree returns (Node)", "default requires"},
|
||||
{"upload", "type Node { Children []Node `json:\"children\"`\n Upload File `file:\"upload\"` }", "get /tree returns (Node)", "response fields must use json"},
|
||||
{"composition", "type Node { Node }", "get /tree returns (Node)", "cycle"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
spec, err := parseText(t, tc.types+"\nservice demo {\n@handler tree\n"+tc.route+"\n}")
|
||||
if err == nil {
|
||||
_, err = Compile(spec)
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("want %q, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecursiveResponseExamples(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
value string
|
||||
valid bool
|
||||
}{
|
||||
{`[]`, true}, {`[{"name":"first","children":[{"name":"second","children":[]}]}]`, true},
|
||||
{`[{"name":"","children":[]}]`, false}, {`[{"name":"first"}]`, false},
|
||||
{`[{"name":"first","children":[],"unknown":true}]`, false},
|
||||
{`[{"name":"first","children":[]},{"name":123,"children":[]}]`, false},
|
||||
{`[{"name":"first","children":[{"name":"second","children":[{"name":"","children":[]}]}]}]`, false},
|
||||
{`null`, false}, {`{} {}`, false},
|
||||
} {
|
||||
t.Run(tc.value, func(t *testing.T) {
|
||||
source := "type Node {\n Name string `json:\"name\" binding:\"min=1\"`\n Children []Node `json:\"children\" description:\"Nested nodes\" example:" + fmt.Sprintf("%q", tc.value) + "`\n}\nservice demo {\n @handler tree\nget /tree returns (Node)\n}"
|
||||
spec, err := parseText(t, source)
|
||||
if err == nil {
|
||||
_, err = Compile(spec)
|
||||
}
|
||||
if (err == nil) != tc.valid {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, typ := range []string{"*Node", "map[string]*Node", "[]*Node", "*[]Node"} {
|
||||
source := "type Node {\n Name string `json:\"name\"`\n Next " + typ + " `json:\"next,omitempty\" description:\"Next node\"`\n}\nservice demo {\n@handler tree\nget /tree returns (*Node)\n}"
|
||||
recursiveDocument(t, compileRecursive(t, source))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecursiveResponseImportsAndMetadata(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "service.api"), "import \"node.api\"\nimport \"detail.api\"\nservice demo {\n@handler tree\nget /tree returns (Node)\n}")
|
||||
writeFile(t, filepath.Join(root, "node.api"), "type Node {\n Children []Node `json:\"children\"`\n Detail Detail `json:\"detail\" description:\"Node detail\" example:\"{\\\"count\\\":9007199254740993,\\\"parent\\\":null}\"`\n}")
|
||||
writeFile(t, filepath.Join(root, "detail.api"), "type Detail {\n Count int64 `json:\"count\"`\n Parent *Node `json:\"parent\"`\n}")
|
||||
spec, err := Parse(filepath.Join(root, "service.api"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := recursiveDocument(t, c)
|
||||
schemas := doc["components"].(map[string]any)["schemas"].(map[string]any)
|
||||
detail := schemas["DTO_Node"].(map[string]any)["properties"].(map[string]any)["detail"].(map[string]any)
|
||||
if detail["description"] != "Node detail" || detail["allOf"].([]any)[0].(map[string]any)["$ref"] != "#/components/schemas/DTO_Detail" {
|
||||
t.Fatal(detail)
|
||||
}
|
||||
if !bytes.Contains(c.Document, []byte("9007199254740993")) {
|
||||
t.Fatal("example integer lost precision")
|
||||
}
|
||||
// Definition order cannot change cycle classification or component schemas.
|
||||
spec.Types[0], spec.Types[1] = spec.Types[1], spec.Types[0]
|
||||
other, err := Compile(spec)
|
||||
if err != nil || !bytes.Equal(c.Document, other.Document) {
|
||||
t.Fatal("definition order changed schemas", err)
|
||||
}
|
||||
|
||||
scalar := `scalar ID (
|
||||
goImport: "example.com/scalars"
|
||||
goType: ID
|
||||
jsonInput: string | int64
|
||||
jsonOutput: string
|
||||
)
|
||||
type Node {
|
||||
Value ID ` + "`json:\"value\" example:\"123\"`" + `
|
||||
Children []Node ` + "`json:\"children\"`" + `
|
||||
}
|
||||
service demo {
|
||||
@handler tree
|
||||
get /tree returns (Node)
|
||||
}`
|
||||
doc = recursiveDocument(t, compileRecursive(t, scalar))
|
||||
value := doc["components"].(map[string]any)["schemas"].(map[string]any)["DTO_Node"].(map[string]any)["properties"].(map[string]any)["value"].(map[string]any)
|
||||
if value["type"] != "string" || value["example"] != nil {
|
||||
t.Fatal("scalar input leaked into response", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecursiveResponseConsumer(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||
}
|
||||
root := frameworkRoot(t)
|
||||
dir := filepath.Join(t.TempDir(), "recursive")
|
||||
o := Options{Dir: dir, API: filepath.Join(root, "examples/recursiveResponse/service.api"), Module: "example.com/recursive", FrameworkDir: root}
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := recursiveDocument(t, &Compiled{Document: []byte(readFile(t, filepath.Join(dir, "docs/openapi.json")))})
|
||||
schemas := doc["components"].(map[string]any)["schemas"].(map[string]any)
|
||||
children := schemas["DTO_Node"].(map[string]any)["properties"].(map[string]any)["children"].(map[string]any)
|
||||
if children["items"].(map[string]any)["$ref"] != "#/components/schemas/DTO_Node" {
|
||||
t.Fatal("generated response lost its recursive schema", children)
|
||||
}
|
||||
for _, name := range []string{"tree", "forest", "optional", "raw", "pair"} {
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/trees/"+name+".go"), "package trees\n")
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/trees/operations.go"), readFile(t, filepath.Join(root, "examples/recursiveResponse/operations.go.txt")))
|
||||
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/recursive/consumer_test.go"))
|
||||
o.API = filepath.Join(dir, "api/service.api")
|
||||
before := snapshot(t, dir)
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o.Check = true
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("regeneration/check modified business code")
|
||||
}
|
||||
runConsumerGo(t, dir, "test", "-mod=mod", "-race", "-count=1", "./...")
|
||||
// A recursive descendant change must still be visible to read-only checking.
|
||||
writeFile(t, o.API, strings.Replace(readFile(t, o.API), "max=100", "max=90", 1))
|
||||
before = snapshot(t, dir)
|
||||
if err := generateManagedTestProject(t, o); err == nil {
|
||||
t.Fatal("check missed recursive DTO drift")
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("drift check wrote files")
|
||||
}
|
||||
o.Check = false
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 dep struct {
|
||||
Version, Sum string
|
||||
Replace any
|
||||
}
|
||||
if err := json.Unmarshal(data, &dep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dep.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dep.Sum == "" || dep.Replace != nil {
|
||||
t.Fatal("not a fixed remote consumer")
|
||||
}
|
||||
t.Logf("remote framework %s checksum %s; no local replace", dep.Version, dep.Sum)
|
||||
}
|
||||
}
|
||||
370
cmd/go-omni/apigen/responseGraph.go
Normal file
370
cmd/go-omni/apigen/responseGraph.go
Normal file
@@ -0,0 +1,370 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
goparser "go/parser"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
||||
)
|
||||
|
||||
// Named response types form a graph, not an infinitely expanded reflect.StructOf
|
||||
// tree. Compile each object's local fields with the existing contract compiler,
|
||||
// then link named fields through OpenAPI references. Request binders still use
|
||||
// the complete, nonrecursive reflection tree.
|
||||
type responseGraph struct {
|
||||
types map[string]Type
|
||||
exprs map[string][]ast.Expr
|
||||
recursive map[string]bool // includes types that can reach a cycle
|
||||
scalars map[string]reflect.Type
|
||||
scalarDefs []contract.Scalar
|
||||
components map[string]*openapi.Schema
|
||||
}
|
||||
|
||||
func newResponseGraph(spec *Spec, types map[string]Type, scalars map[string]reflect.Type, defs []contract.Scalar) (*responseGraph, error) {
|
||||
g := &responseGraph{types: types, exprs: map[string][]ast.Expr{}, recursive: map[string]bool{}, scalars: scalars, scalarDefs: defs, components: map[string]*openapi.Schema{}}
|
||||
dependencies, values := map[string][]string{}, map[string][]string{}
|
||||
for _, typ := range spec.Types {
|
||||
seen := map[string]bool{}
|
||||
for _, f := range typ.Fields {
|
||||
if !exportedName.MatchString(f.Name) || seen[f.Name] {
|
||||
return nil, fmt.Errorf("%s: invalid or duplicate field %s", typ.Name, f.Name)
|
||||
}
|
||||
seen[f.Name] = true
|
||||
e, err := goparser.ParseExpr(f.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Validate every expression, including otherwise unused declarations.
|
||||
if _, err = g.expression(e, 0, func(name string, _ int) (reflect.Type, error) {
|
||||
dependencies[typ.Name] = append(dependencies[typ.Name], name)
|
||||
return reflect.TypeOf(struct{}{}), nil
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("%s.%s: %w", typ.Name, f.Name, err)
|
||||
}
|
||||
g.exprs[typ.Name] = append(g.exprs[typ.Name], e)
|
||||
if id, ok := e.(*ast.Ident); ok && types[id.Name].Name != "" {
|
||||
values[typ.Name] = append(values[typ.Name], id.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only pointer/slice/map indirection can break a Go struct layout cycle.
|
||||
state := map[string]int{}
|
||||
var valueCycle func(string) error
|
||||
valueCycle = func(name string) error {
|
||||
if state[name] == 1 {
|
||||
return fmt.Errorf("%s: invalid recursive value type; use a pointer, slice or map", name)
|
||||
}
|
||||
if state[name] == 2 {
|
||||
return nil
|
||||
}
|
||||
state[name] = 1
|
||||
for _, next := range values[name] {
|
||||
if err := valueCycle(next); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
state[name] = 2
|
||||
return nil
|
||||
}
|
||||
for _, typ := range spec.Types {
|
||||
if err := valueCycle(typ.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
state = map[string]int{}
|
||||
var cyclic func(string) bool
|
||||
cyclic = func(name string) bool {
|
||||
if state[name] == 1 {
|
||||
return true
|
||||
}
|
||||
if state[name] == 2 {
|
||||
return g.recursive[name]
|
||||
}
|
||||
state[name] = 1
|
||||
for _, next := range dependencies[name] {
|
||||
if cyclic(next) {
|
||||
g.recursive[name] = true
|
||||
}
|
||||
}
|
||||
state[name] = 2
|
||||
return g.recursive[name]
|
||||
}
|
||||
for _, typ := range spec.Types {
|
||||
cyclic(typ.Name)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (g *responseGraph) expression(e ast.Expr, depth int, named func(string, int) (reflect.Type, error)) (reflect.Type, error) {
|
||||
if depth > 32 {
|
||||
return nil, fmt.Errorf("type nesting exceeds 32")
|
||||
}
|
||||
switch e := e.(type) {
|
||||
case *ast.Ident:
|
||||
if t := g.scalars[e.Name]; t != nil {
|
||||
return t, nil
|
||||
}
|
||||
if t := primitiveTypes[e.Name]; t != nil {
|
||||
return t, nil
|
||||
}
|
||||
if g.types[e.Name].Name == "" {
|
||||
return nil, fmt.Errorf("unknown type %s", e.Name)
|
||||
}
|
||||
return named(e.Name, depth+1)
|
||||
case *ast.StarExpr:
|
||||
if _, double := e.X.(*ast.StarExpr); double {
|
||||
return nil, fmt.Errorf("multiple pointer levels unsupported")
|
||||
}
|
||||
t, err := g.expression(e.X, depth+1, named)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reflect.PointerTo(t), nil
|
||||
case *ast.ArrayType:
|
||||
if e.Len != nil {
|
||||
return nil, fmt.Errorf("use slices, not fixed arrays")
|
||||
}
|
||||
t, err := g.expression(e.Elt, depth+1, named)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reflect.SliceOf(t), nil
|
||||
case *ast.MapType:
|
||||
key, ok := e.Key.(*ast.Ident)
|
||||
if !ok || key.Name != "string" {
|
||||
return nil, fmt.Errorf("map keys must be string")
|
||||
}
|
||||
t, err := g.expression(e.Value, depth+1, named)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reflect.MapOf(reflect.TypeOf(""), t), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported type expression")
|
||||
}
|
||||
}
|
||||
|
||||
func (g *responseGraph) component(name string) (*openapi.Schema, error) {
|
||||
key := "DTO_" + name
|
||||
ref := &openapi.Schema{Ref: "#/components/schemas/" + key}
|
||||
if _, ok := g.components[key]; ok {
|
||||
return ref, nil
|
||||
}
|
||||
// Register before following edges so both self and mutual references terminate.
|
||||
g.components[key] = nil
|
||||
typ := g.types[name]
|
||||
var fields []reflect.StructField
|
||||
for i, f := range typ.Fields {
|
||||
t, err := g.expression(g.exprs[name][i], 0, func(string, int) (reflect.Type, error) { return reflect.TypeOf(struct{}{}), nil })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tag, err := withoutExample(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields = append(fields, reflect.StructField{Name: f.Name, Type: t, Tag: tag})
|
||||
}
|
||||
b, err := contract.Describe(reflect.StructOf(fields), g.scalarDefs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", typ.Position, err)
|
||||
}
|
||||
s, err := b.ResponseSchema()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", typ.Position, err)
|
||||
}
|
||||
for i, f := range typ.Fields {
|
||||
wire := strings.Split(reflect.StructTag(fieldTag(f)).Get("json"), ",")[0]
|
||||
property := s.Properties[wire]
|
||||
// Examples are checked against a finite projection of their actual values,
|
||||
// with the same contract rules and without running application decoders.
|
||||
if _, ok := reflect.StructTag(fieldTag(f)).Lookup("example"); ok {
|
||||
example, err := g.example(f, g.exprs[name][i])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s.%s: %w", name, f.Name, err)
|
||||
}
|
||||
property.Example = example
|
||||
}
|
||||
property, err = g.link(g.exprs[name][i], property)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Properties[wire] = property
|
||||
}
|
||||
g.components[key] = s
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func (g *responseGraph) link(e ast.Expr, s *openapi.Schema) (*openapi.Schema, error) {
|
||||
switch e := e.(type) {
|
||||
case *ast.Ident:
|
||||
if g.types[e.Name].Name == "" {
|
||||
return s, nil
|
||||
}
|
||||
ref, err := g.component(e.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.Nullable {
|
||||
ref = nullableReference(ref)
|
||||
}
|
||||
if s.Description != "" || s.Example != nil {
|
||||
// OpenAPI 3.0 ignores siblings on Reference Objects.
|
||||
ref = &openapi.Schema{AllOf: []*openapi.Schema{ref}, Description: s.Description, Example: s.Example}
|
||||
}
|
||||
return ref, nil
|
||||
case *ast.StarExpr:
|
||||
return g.link(e.X, s)
|
||||
case *ast.ArrayType:
|
||||
item, err := g.link(e.Elt, s.Items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Items = item
|
||||
case *ast.MapType:
|
||||
item, err := g.link(e.Value, s.AdditionalProperties)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.AdditionalProperties = item
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func nullableReference(s *openapi.Schema) *openapi.Schema {
|
||||
return &openapi.Schema{AnyOf: []*openapi.Schema{s, {Type: "object", Nullable: true, Enum: []any{nil}}}}
|
||||
}
|
||||
|
||||
func withoutExample(f Field) (reflect.StructTag, error) {
|
||||
tags, err := contract.ParseTags(fieldTag(f))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
delete(tags, "example")
|
||||
keys := make([]string, 0, len(tags))
|
||||
for key := range tags {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var parts []string
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key+":"+strconv.Quote(tags[key]))
|
||||
}
|
||||
return reflect.StructTag(strings.Join(parts, " ")), nil
|
||||
}
|
||||
|
||||
func (g *responseGraph) example(f Field, e ast.Expr) (any, error) {
|
||||
raw := reflect.StructTag(fieldTag(f)).Get("example")
|
||||
var value any
|
||||
// Plain string examples intentionally are not JSON literals.
|
||||
if f.Type == "string" || f.Type == "*string" {
|
||||
value = raw
|
||||
} else {
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, fmt.Errorf("invalid example JSON: %w", err)
|
||||
}
|
||||
}
|
||||
budget := 10000
|
||||
t, err := g.projectExample(e, []any{value}, 0, &budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := contract.Describe(reflect.StructOf([]reflect.StructField{{Name: f.Name, Type: t, Tag: reflect.StructTag(fieldTag(f))}}), g.scalarDefs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s, err := b.ResponseSchema()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wire := strings.Split(reflect.StructTag(fieldTag(f)).Get("json"), ",")[0]
|
||||
return s.Properties[wire].Example, nil
|
||||
}
|
||||
|
||||
// Only materialize named objects where the finite example has a non-null value.
|
||||
// Empty slices/maps and null pointers stop expansion. Unioning collection values
|
||||
// checks every item using one reflection type, including missing/unknown fields.
|
||||
func (g *responseGraph) projectExample(e ast.Expr, values []any, depth int, budget *int) (reflect.Type, error) {
|
||||
*budget--
|
||||
if depth > 32 || *budget < 0 {
|
||||
return nil, fmt.Errorf("example type expansion exceeds 32 levels or 10000 nodes")
|
||||
}
|
||||
var present []any
|
||||
for _, v := range values {
|
||||
if v != nil {
|
||||
present = append(present, v)
|
||||
}
|
||||
}
|
||||
switch e := e.(type) {
|
||||
case *ast.Ident:
|
||||
return g.expression(e, 0, func(name string, _ int) (reflect.Type, error) {
|
||||
if len(present) == 0 {
|
||||
return reflect.TypeOf(struct{}{}), nil
|
||||
}
|
||||
var fields []reflect.StructField
|
||||
for i, f := range g.types[name].Fields {
|
||||
wire := strings.Split(reflect.StructTag(fieldTag(f)).Get("json"), ",")[0]
|
||||
var nested []any
|
||||
for _, v := range present {
|
||||
if obj, ok := v.(map[string]any); ok {
|
||||
if child, found := obj[wire]; found {
|
||||
nested = append(nested, child)
|
||||
}
|
||||
}
|
||||
}
|
||||
t, err := g.projectExample(g.exprs[name][i], nested, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tag, err := withoutExample(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields = append(fields, reflect.StructField{Name: f.Name, Type: t, Tag: tag})
|
||||
}
|
||||
return reflect.StructOf(fields), nil
|
||||
})
|
||||
case *ast.StarExpr:
|
||||
t, err := g.projectExample(e.X, present, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reflect.PointerTo(t), nil
|
||||
case *ast.ArrayType:
|
||||
var nested []any
|
||||
for _, v := range present {
|
||||
if list, ok := v.([]any); ok {
|
||||
nested = append(nested, list...)
|
||||
}
|
||||
}
|
||||
t, err := g.projectExample(e.Elt, nested, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reflect.SliceOf(t), nil
|
||||
case *ast.MapType:
|
||||
var nested []any
|
||||
for _, v := range present {
|
||||
if obj, ok := v.(map[string]any); ok {
|
||||
for _, v := range obj {
|
||||
nested = append(nested, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
t, err := g.projectExample(e.Value, nested, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reflect.MapOf(reflect.TypeOf(""), t), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported example type")
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func responseGoType(value string) string {
|
||||
}
|
||||
return "*types." + name
|
||||
}
|
||||
func responseDataSchema(value string, bindings map[string]*contract.Binder) (*openapi.Schema, error) {
|
||||
func responseDataSchema(value string, bindings map[string]*contract.Binder, graph *responseGraph) (*openapi.Schema, error) {
|
||||
kind, name := responseShape(value)
|
||||
if kind == "null" {
|
||||
return &openapi.Schema{Type: "object", Nullable: true, Enum: []any{nil}}, nil
|
||||
@@ -56,11 +56,15 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
|
||||
}
|
||||
return s.Properties["value"], nil
|
||||
}
|
||||
b, ok := bindings[name]
|
||||
if !ok {
|
||||
var s *openapi.Schema
|
||||
var err error
|
||||
if graph.recursive[name] {
|
||||
s, err = graph.component(name)
|
||||
} else if b := bindings[name]; b != nil {
|
||||
s, err = b.ResponseSchema()
|
||||
} else {
|
||||
return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, []string/[]int64/[]bool/[]any, *Name or null", name)
|
||||
}
|
||||
s, err := b.ResponseSchema()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -68,6 +72,9 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
|
||||
case "array":
|
||||
return &openapi.Schema{Type: "array", Items: s}, nil
|
||||
case "nullable":
|
||||
if s.Ref != "" {
|
||||
return nullableReference(s), nil
|
||||
}
|
||||
s.Nullable = true
|
||||
}
|
||||
return s, nil
|
||||
|
||||
77
cmd/go-omni/apigen/testdata/recursive/consumer_test.go
vendored
Normal file
77
cmd/go-omni/apigen/testdata/recursive/consumer_test.go
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
package main_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"example.com/recursive/internal/handler"
|
||||
"example.com/recursive/internal/svc"
|
||||
"example.com/recursive/internal/types"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestRecursiveHTTP(t *testing.T) {
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
cfg := logx.Default()
|
||||
cfg.Writer = io.Discard
|
||||
logger, err := logx.New(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
deps, err := svc.New(context.Background(), logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deps.Close()
|
||||
if err := handler.RegisterRoutes(app, deps); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"/tree", "/forest", "/optional", "/raw", "/pair"} {
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||||
if w.Code != 200 {
|
||||
t.Fatal(path, w.Code, w.Body.String())
|
||||
}
|
||||
data := w.Body.Bytes()
|
||||
if path != "/raw" {
|
||||
var envelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = envelope.Data
|
||||
}
|
||||
switch path {
|
||||
case "/optional":
|
||||
if string(data) != "null" {
|
||||
t.Fatal(string(data))
|
||||
}
|
||||
case "/pair":
|
||||
var pair types.Branch
|
||||
if err := json.Unmarshal(data, &pair); err != nil || pair.Leaf == nil || pair.Leaf.Branch != nil {
|
||||
t.Fatal(err, string(data))
|
||||
}
|
||||
default:
|
||||
var node types.Node
|
||||
if path == "/forest" {
|
||||
var forest []types.Node
|
||||
if err := json.Unmarshal(data, &forest); err != nil || len(forest) != 1 {
|
||||
t.Fatal(err, string(data))
|
||||
}
|
||||
node = forest[0]
|
||||
} else if err := json.Unmarshal(data, &node); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(node.Children) != 1 || len(node.Children[0].Children) != 1 || node.Children[0].Children[0].Name != "leaf" || node.Lookup["entry"].Name != "lookup" {
|
||||
t.Fatal(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
)
|
||||
|
||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
const currentVersion = "v0.28.1-preview"
|
||||
const currentVersion = "v0.28.2-preview"
|
||||
const minimumVersion = "v0.28.0-preview"
|
||||
const manifestPath = ".goOmni/projectManifest.json"
|
||||
const lockPath = ".goOmni/project.lock"
|
||||
|
||||
@@ -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.28.1-preview"
|
||||
const defaultFrameworkVersion = "v0.28.2-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