361 lines
14 KiB
Go
361 lines
14 KiB
Go
package apigen
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func frameworkRoot(t *testing.T) string {
|
|
t.Helper()
|
|
p, err := filepath.Abs("../../..")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
func writeFile(t *testing.T, name, body string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(name, []byte(body), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
func readFile(t *testing.T, name string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
func parseText(t *testing.T, source string) (*Spec, error) {
|
|
t.Helper()
|
|
file := filepath.Join(t.TempDir(), "service.api")
|
|
writeFile(t, file, source)
|
|
return Parse(file)
|
|
}
|
|
|
|
func TestCompileExample(t *testing.T) {
|
|
s, err := Parse(filepath.Join(frameworkRoot(t), "examples/contracts/user.api"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, err := Compile(s)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(c.Groups) != 2 || len(s.Routes) != 3 || len(s.Sources) != 2 {
|
|
t.Fatalf("wrong spec: %+v", s)
|
|
}
|
|
var doc map[string]any
|
|
if err = json.Unmarshal(c.Document, &doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
route := doc["paths"].(map[string]any)["/api/v1/users/{userId}"].(map[string]any)["put"].(map[string]any)
|
|
if len(route["parameters"].([]any)) != 3 {
|
|
t.Fatal("missing mixed parameters")
|
|
}
|
|
schema := route["requestBody"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
|
|
if schema["additionalProperties"] != false || len(schema["required"].([]any)) != 2 {
|
|
t.Fatalf("wrong body schema: %+v", schema)
|
|
}
|
|
if strings.Contains(string(c.Document), `"user_id"`) {
|
|
t.Fatal("snake case wire name")
|
|
}
|
|
}
|
|
func TestInvalidDefinitions(t *testing.T) {
|
|
cases := []string{
|
|
`syntax="v2"`,
|
|
`@server ( jwt: Auth ) service user { @handler go get /go }`,
|
|
"type Request { Name string `json:\"name\" binding:\"typo\"` }\nservice user { @handler run\n post /go (Request) }",
|
|
"type A { Other *A `json:\"other,optional\"` }\nservice user { @handler run\n post /go (A) }",
|
|
"type A { Other Missing `json:\"other\"` }\nservice user { @handler run\n post /go (A) }",
|
|
"type A { UserID int `json:\"user_id\"` }\nservice user { @handler run\n post /go (A) }",
|
|
"service user { @handler go\n get /go\n @handler other\n get /go\n}",
|
|
"service user { @handler go\n get /:id\n}",
|
|
"service user { @handler service\n get /go\n}",
|
|
"type A { ID int `path:\"id\"` }\nservice user { @handler go\n get /go (A)\n}",
|
|
"type A { Name string `json:\"name\"` }\nservice user { @handler go\n get /go (A)\n}",
|
|
"service user { @handler go\n @status 201\n post /go\n}",
|
|
"type A { Name string `header:\"Name\"` }\nservice user { @handler go\n get /go returns (A)\n}",
|
|
"@server (group: ../../bad)\nservice user { @handler go\n get /go\n}",
|
|
"service user { @handler go\n get /health/ready\n}",
|
|
"type A { Name string `json:\"name\"`\n Name int `json:\"other\"` }\nservice user { @handler go\n post /go (A)\n}",
|
|
"service user { @handler go\n get /go\n @handler Go\n get /other\n}",
|
|
"type A { Value []byte `json:\"value\"` }\nservice user { @handler go\n post /go (A)\n}",
|
|
}
|
|
for i, source := range cases {
|
|
s, err := parseText(t, source)
|
|
if err == nil {
|
|
_, err = Compile(s)
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("case %d accepted: %s", i, source)
|
|
}
|
|
}
|
|
}
|
|
func TestImports(t *testing.T) {
|
|
root := t.TempDir()
|
|
entry := filepath.Join(root, "api", "entry.api")
|
|
writeFile(t, entry, "import \"types.api\"\nservice user { @handler ping\n get /ping\n}")
|
|
writeFile(t, filepath.Join(root, "api/types.api"), `import "entry.api"`)
|
|
if _, err := Parse(entry); err == nil || !strings.Contains(err.Error(), "circular") {
|
|
t.Fatalf("cycle: %v", err)
|
|
}
|
|
writeFile(t, entry, "import \"../outside.api\"\nservice user { @handler ping\n get /ping\n}")
|
|
writeFile(t, filepath.Join(root, "outside.api"), `type X { Value string `+"`json:\"value\"`"+` }`)
|
|
if _, err := Parse(entry); err == nil {
|
|
t.Fatal("escaped import root")
|
|
}
|
|
writeFile(t, entry, "import (\n \"shared.api\"\n \"./shared.api\"\n)\nservice user { @handler ping\n get /ping\n}")
|
|
writeFile(t, filepath.Join(root, "api/shared.api"), `type X { Value string `+"`json:\"value\"`"+` }`)
|
|
s, err := Parse(entry)
|
|
if err != nil || len(s.Types) != 1 {
|
|
t.Fatalf("duplicate import: %+v %v", s, err)
|
|
}
|
|
}
|
|
|
|
func TestSharedPrefixConflict(t *testing.T) {
|
|
source := "type A { ID int `path:\"id\"` }\ntype B { Name string `path:\"name\"` }\nservice user {\n @handler first\n get /users/:id (A)\n @handler second\n get /users/:name/items (B)\n}"
|
|
s, err := parseText(t, source)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = Compile(s); err == nil {
|
|
t.Fatal("shared prefix conflict was accepted")
|
|
}
|
|
}
|
|
|
|
func FuzzParser(f *testing.F) {
|
|
for _, s := range []string{starterAPI, declaredAPI, "error Broken", "auth Auth (type: bearer)", "syntax = \"v1\"", "type (", "@server (prefix: /api)"} {
|
|
f.Add(s)
|
|
}
|
|
f.Fuzz(func(t *testing.T, source string) {
|
|
if len(source) > 16384 {
|
|
t.Skip()
|
|
}
|
|
tokens, err := lex("fuzz.api", source)
|
|
if err != nil {
|
|
return
|
|
}
|
|
spec := &Spec{Title: "Fuzz", Version: "1"}
|
|
p := parser{tokens: tokens, spec: spec}
|
|
if p.parse() == nil {
|
|
_, _ = Compile(spec)
|
|
}
|
|
})
|
|
}
|
|
func TestRegenerationAndConflicts(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "app")
|
|
if _, err := New(Options{Dir: dir, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
|
|
if _, err := Generate(o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
manifestBefore := readFile(t, filepath.Join(dir, manifestPath))
|
|
typesBefore := readFile(t, filepath.Join(dir, "internal/types/typesGen.go"))
|
|
o.Check = true
|
|
if _, err := Generate(o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
servicePath := filepath.Join(dir, "internal/service/greet/greet.go")
|
|
service := readFile(t, servicePath) + "\n// user edit must survive\n"
|
|
writeFile(t, servicePath, service)
|
|
for _, file := range []string{"cmd/server/main.go", "etc/config.yaml", "internal/servicecontext/context.go", "internal/repository/greet/repository.go"} {
|
|
body := readFile(t, filepath.Join(dir, file))
|
|
if strings.HasSuffix(file, ".go") {
|
|
body += "\n// handwritten extension\n"
|
|
} else {
|
|
body += "\n# handwritten extension\n"
|
|
}
|
|
writeFile(t, filepath.Join(dir, file), body)
|
|
}
|
|
api := readFile(t, o.API)
|
|
api += "\n@server (group: extra)\nservice greet-api {\n @handler ping\n get /ping\n}\n"
|
|
writeFile(t, o.API, api)
|
|
if _, err := Generate(o); err == nil {
|
|
t.Fatal("check accepted stale output")
|
|
}
|
|
if readFile(t, filepath.Join(dir, manifestPath)) != manifestBefore {
|
|
t.Fatal("--check wrote manifest")
|
|
}
|
|
o.Check = false
|
|
if _, err := Generate(o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if readFile(t, servicePath) != service {
|
|
t.Fatal("business code overwritten")
|
|
}
|
|
// Retiring a group removes only owned handlers, keeping its business files.
|
|
writeFile(t, o.API, strings.Split(api, "\n@server (group: extra)")[0])
|
|
if _, err := Generate(o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "internal/handler/extra/handlersGen.go")); !os.IsNotExist(err) {
|
|
t.Fatal("stale handler remains")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "internal/service/extra/ping.go")); err != nil {
|
|
t.Fatal("business stub removed")
|
|
}
|
|
writeFile(t, filepath.Join(dir, "internal/types/typesGen.go"), typesBefore+"\n// edited generated file\n")
|
|
before := snapshot(t, dir)
|
|
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "was edited") {
|
|
t.Fatalf("did not protect edited output: %v", err)
|
|
}
|
|
after := snapshot(t, dir)
|
|
if !sameFiles(before, after) {
|
|
t.Fatal("failed regeneration changed files")
|
|
}
|
|
}
|
|
func TestOutputBoundaries(t *testing.T) {
|
|
for _, kind := range []string{"symlink", "unowned", "lock", "caseCollision"} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
dir := filepath.Join(root, "app")
|
|
api := filepath.Join(root, "input/service.api")
|
|
writeFile(t, api, starterAPI)
|
|
switch kind {
|
|
case "symlink":
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
outside := filepath.Join(root, "outside")
|
|
os.MkdirAll(outside, 0755)
|
|
if err := os.Symlink(outside, filepath.Join(dir, "internal")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case "unowned":
|
|
writeFile(t, filepath.Join(dir, "internal/types/typesGen.go"), "package types // mine\n")
|
|
case "lock":
|
|
writeFile(t, filepath.Join(dir, ".goOmni/api.lock"), "someone else")
|
|
case "caseCollision":
|
|
writeFile(t, api, starterAPI+"\n@server (group: gReet)\nservice greet-api {\n @handler extra\n get /extra\n}\n")
|
|
}
|
|
before := snapshot(t, root)
|
|
if _, err := Generate(Options{Dir: dir, API: api, Module: "example.com/test", FrameworkDir: frameworkRoot(t)}); err == nil {
|
|
t.Fatal("unsafe output accepted")
|
|
}
|
|
if !sameFiles(before, snapshot(t, root)) {
|
|
t.Fatal("conflict changed files")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func snapshot(t *testing.T, root string) map[string]string {
|
|
t.Helper()
|
|
out := map[string]string{}
|
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.Type().IsRegular() {
|
|
data, e := os.ReadFile(path)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
rel, _ := filepath.Rel(root, path)
|
|
out[rel] = string(data)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
func sameFiles(a, b map[string]string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for k, v := range a {
|
|
if b[k] != v {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func TestGeneratedProjectsBuildAndRun(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("consumer module integration")
|
|
}
|
|
root := frameworkRoot(t)
|
|
for _, starter := range []bool{true, false} {
|
|
name := "contract"
|
|
if starter {
|
|
name = "starter"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "project")
|
|
o := Options{Dir: dir, Module: "example.com/consumer", FrameworkDir: root, LogOutput: "file", LogFormat: "text"}
|
|
var err error
|
|
if starter {
|
|
_, err = New(o)
|
|
} else {
|
|
o.API = filepath.Join(root, "examples/contracts/user.api")
|
|
_, err = Generate(o)
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
source := consumerContractTest
|
|
if starter {
|
|
source = consumerStarterTest
|
|
} else {
|
|
writeFile(t, filepath.Join(dir, "internal/service/user/updateUser.go"), `package user
|
|
import("context";"example.com/consumer/internal/types")
|
|
func(s *Service) UpdateUser(ctx context.Context,req *types.UpdateUserReq)(*types.UserResp,error){return &types.UserResp{UserID:req.UserID,Name:req.Name,Active:req.Active},nil}
|
|
`)
|
|
}
|
|
writeFile(t, filepath.Join(dir, "contract_test.go"), source)
|
|
o.API = filepath.Join(dir, "api/service.api")
|
|
if !starter {
|
|
o.API = filepath.Join(dir, "api/user.api")
|
|
}
|
|
if _, err = Generate(o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-count=1", "./...")
|
|
cmd.Dir = dir
|
|
cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("consumer: %v\n%s", err, output)
|
|
}
|
|
t.Log(string(output))
|
|
})
|
|
}
|
|
}
|
|
|
|
const consumerStarterTest = `package consumer_test
|
|
import("context";"io";"net/http/httptest";"strings";"testing";omni "git.yuntuo.hk/k2safe/go-omni";"git.yuntuo.hk/k2safe/go-omni/logx";"example.com/consumer/internal/handler";"example.com/consumer/internal/servicecontext";"example.com/consumer/internal/settings")
|
|
func TestGreeting(t *testing.T){cfg,err:=settings.Load("etc/config.yaml");if err!=nil{t.Fatal(err)};cfg.Server.Log.Writer=io.Discard;cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{};logger,err:=logx.New(cfg.Server.Log);if err!=nil{t.Fatal(err)};defer logger.Close();deps,err:=servicecontext.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close();app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)};w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,httptest.NewRequest("GET","/api/greet/Alice",nil));if w.Code!=200||!strings.Contains(w.Body.String(),"Hello Alice")||w.Header().Get("X-Request-ID")==""{t.Fatalf("%d %s",w.Code,w.Body.String())}}
|
|
`
|
|
const consumerContractTest = `package consumer_test
|
|
import("context";"encoding/json";"io";"net/http/httptest";"strings";"testing";omni "git.yuntuo.hk/k2safe/go-omni";"git.yuntuo.hk/k2safe/go-omni/logx";"example.com/consumer/internal/handler";"example.com/consumer/internal/servicecontext")
|
|
func TestContract(t *testing.T){cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close();deps,err:=servicecontext.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close();app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
|
|
for _,tc:=range []struct{method,path,body string;status int}{
|
|
{"PUT","/api/v1/users/7?notify=true",` + "`" + `{"name":"Alice","active":false,"age":0}` + "`" + `,200},
|
|
{"PUT","/api/v1/users/7",` + "`" + `{"name":"Alice"}` + "`" + `,400},
|
|
{"PUT","/api/v1/users/7?notify=true¬ify=false",` + "`" + `{"name":"Alice","active":false}` + "`" + `,400},
|
|
{"PUT","/api/v1/users/7",` + "`" + `{"name":"Alice","active":false,"age":151}` + "`" + `,400},
|
|
{"GET","/api/v1/users?page=0","",400},
|
|
{"GET","/api/v1/users?page=1","",501},
|
|
{"DELETE","/admin/v1/users/7","",501},
|
|
}{req:=httptest.NewRequest(tc.method,tc.path,strings.NewReader(tc.body));req.Header.Set("Content-Type","application/json");w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,req);if w.Code!=tc.status{t.Fatalf("%s: %d %s",tc.path,w.Code,w.Body.String())};if tc.status==200{var v struct{Data struct{UserID int64 ` + "`json:\"userId\"`" + `;Name string ` + "`json:\"name\"`" + `}};if err:=json.Unmarshal(w.Body.Bytes(),&v);err!=nil||v.Data.UserID!=7||v.Data.Name!="Alice"{t.Fatal(w.Body.String())}}}
|
|
delete(deps.Middlewares,"Auth");if err:=handler.RegisterRoutes(omni.New(),deps);err==nil{t.Fatal("missing auth hook accepted")}
|
|
}
|
|
`
|