package apigen import ( "context" "encoding/json" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) const configurableResponseAPI = `syntax = "v1" response CommonResponse ( codeField: code messageField: msg dataField: data requestIdField: "-" codeType: integer successCode: 200 errorCode: 9999 successMessage: "操作成功" errorMessage: "系统错误" dataMode: always codes: { "UNAUTHENTICATED": 401001, "EMAIL_TAKEN": 409001 } messages: {"UNAUTHENTICATED":"请先登录","EMAIL_TAKEN":"邮箱已存在"} ) response PartnerResponse ( codeField: result messageField: notice dataField: payload requestIdField: trace successCode: "PASS" ) error ( Unauthenticated 401 "UNAUTHENTICATED" "authentication required" EmailTaken 409 "EMAIL_TAKEN" "email already taken" ) auth Access (type: bearer middleware: Auth ) type ItemResponse { Name string ` + "`json:\"name\"`" + ` } @server ( group: items response: CommonResponse auth: Access errors: Unauthenticated ) service response-api { @errors EmailTaken @handler item get /item returns (ItemResponse) @response raw @handler callback post /callback returns (ItemResponse) @response PartnerResponse @handler partner get /partner returns (ItemResponse) } ` func TestResponseTemplatesDocumentAndValidation(t *testing.T) { spec, err := parseText(t, configurableResponseAPI) if err != nil { t.Fatal(err) } c, err := Compile(spec) if err != nil { t.Fatal(err) } doc := document(t, c) getSchema := func(path, status string) map[string]any { method := "get" if path == "/callback" { method = "post" } return operation(t, doc, path, method)["responses"].(map[string]any)[status].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any) } properties := getSchema("/item", "200")["properties"].(map[string]any) if len(properties) != 3 || properties["code"].(map[string]any)["type"] != "integer" || properties["msg"] == nil || properties["requestId"] != nil { t.Fatal(properties) } if getSchema("/callback", "200")["properties"].(map[string]any)["name"] == nil { t.Fatal("raw JSON was wrapped") } if getSchema("/callback", "401")["properties"].(map[string]any)["msg"] == nil { t.Fatal("raw override lost group error policy") } if getSchema("/partner", "200")["properties"].(map[string]any)["payload"] == nil { t.Fatal("route override not applied") } conflict := operation(t, doc, "/item", "get")["responses"].(map[string]any)["409"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any) value := conflict["examples"].(map[string]any)["EmailTaken"].(map[string]any)["value"].(map[string]any) if value["code"] != float64(409001) || value["msg"] != "邮箱已存在" || len(value) != 3 { t.Fatal(value) } if got := conflict["schema"].(map[string]any)["properties"].(map[string]any)["code"].(map[string]any)["enum"].([]any); got[0] != float64(409001) { t.Fatal(got) } for _, change := range [][2]string{{"response: CommonResponse", "response: Missing"}, {"messageField: msg", "messageField: code"}, {"codeType: integer", "codeType: boolean"}, {"successCode: 200", "successCode: 2.5"}, {"requestIdField: \"-\"", "unknownOption: value"}, {"@response raw", "@response missing"}} { invalid, err := parseText(t, strings.Replace(configurableResponseAPI, change[0], change[1], 1)) if err == nil { _, err = Compile(invalid) } if err == nil || !strings.Contains(err.Error(), "service.api:") { t.Fatalf("bad declaration %s: %v", change[1], err) } } } func TestImportedResponseTemplate(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "common.api"), "response Shared (messageField: msg)\n") writeFile(t, filepath.Join(dir, "service.api"), "import \"common.api\"\n@server (response: Shared)\nservice shared-api {\n@handler ping\nget /ping\n}") spec, err := Parse(filepath.Join(dir, "service.api")) if err != nil { t.Fatal(err) } if _, err = Compile(spec); err != nil { t.Fatal(err) } } func TestResponseNestedBodyDocument(t *testing.T) { definition := `response Nested ( body: {"success":"${ok}","result":"${data}","meta":{"status":"${status}","trace":"${requestId}"},"version":1} errorBody: {"success":"${ok}","error":{"id":"${code}","reason":"${message}"}} codeType: integer successCode: 0 errorCode: 9999 codes: {"EMAIL_TAKEN":409001} ) ` spec, err := parseText(t, definition+strings.Replace(configurableResponseAPI, "response: CommonResponse", "response: Nested", 1)) if err != nil { t.Fatal(err) } compiled, err := Compile(spec) if err != nil { t.Fatal(err) } doc := document(t, compiled) responses := operation(t, doc, "/item", "get")["responses"].(map[string]any) success := responses["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)["properties"].(map[string]any) if success["code"] != nil || success["result"] == nil || success["success"].(map[string]any)["type"] != "boolean" { t.Fatal(success) } media := responses["409"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any) schema := media["schema"].(map[string]any)["properties"].(map[string]any) if schema["error"].(map[string]any)["properties"].(map[string]any)["id"].(map[string]any)["type"] != "integer" || schema["result"] != nil { t.Fatal(schema) } example := media["examples"].(map[string]any)["EmailTaken"].(map[string]any)["value"].(map[string]any) if example["success"] != false || example["error"].(map[string]any)["id"] != float64(409001) { t.Fatal(example) } } func TestResponseTemplateJSONTokens(t *testing.T) { for _, tc := range []struct { body string valid bool }{ {`{"scale":-1.5,"exponent":1e-3,"items":[true,null,"${data}"],"label":"a b"}`, true}, {`{"scale":1 2}`, false}, {`{"scale":- 12}`, false}, {`{"scale":tru e}`, false}, {`{"scale":1. 2}`, false}, } { text := "response Shared (\nbody: " + tc.body + "\n)\n@server (response: Shared)\nservice test-api {\n@handler ping\nget /ping\n}\n" spec, err := parseText(t, text) if err == nil { _, err = Compile(spec) } if (err == nil) != tc.valid { t.Fatalf("%s: %v", tc.body, err) } } } func TestResponseCodeTypeDeclarations(t *testing.T) { for _, kind := range []string{"int32", "int64"} { source := strings.Replace(configurableResponseAPI, "codeType: integer", "codeType: "+kind, 1) spec, err := parseText(t, source) if err != nil { t.Fatal(err) } compiled, err := Compile(spec) if err != nil { t.Fatal(err) } doc := document(t, compiled) for _, status := range []string{"200", "401", "409", "500"} { responses := operation(t, doc, "/item", "get")["responses"].(map[string]any) schema := responses[status].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any) code := schema["properties"].(map[string]any)["code"].(map[string]any) if code["type"] != "integer" || code["format"] != kind { t.Fatal(code) } } for _, bad := range []string{"2.5", "9223372036854775808", "-9223372036854775809"} { invalid := strings.Replace(source, "successCode: 200", "successCode: "+bad, 1) if _, err := parseText(t, invalid); err == nil || !strings.Contains(err.Error(), "service.api:") { t.Fatalf("%s %s: %v", kind, bad, err) } } } invalid := strings.Replace(configurableResponseAPI, "codeType: integer", "codeType: int32", 1) invalid = strings.Replace(invalid, `"EMAIL_TAKEN": 409001`, `"EMAIL_TAKEN": 2147483648`, 1) if _, err := parseText(t, invalid); err == nil { t.Fatal("overflowing int32 mapping accepted") } } func TestResponseTemplateConsumer(t *testing.T) { if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" { t.Skip("consumer integration") } dir := filepath.Join(t.TempDir(), "project") source := filepath.Join(t.TempDir(), "service.api") // Use an explicitly declared public body, rather than field-name shortcuts. sourceText := strings.Replace(configurableResponseAPI, " codeField: code\n messageField: msg\n dataField: data\n requestIdField: \"-\"", ` body: {"code":"${code}","msg":"${message}","data":"${data}"}`, 1) sourceText = strings.Replace(sourceText, "codeType: integer", "codeType: int64", 1) sourceText = strings.Replace(sourceText, "successCode: 200", "successCode: 9223372036854775807", 1) writeFile(t, source, sourceText) releaseCLI := os.Getenv("GO_OMNI_RELEASE_CLI") generate := func(api string, initial bool) { t.Helper() if releaseCLI != "" { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() cmd := exec.CommandContext(ctx, releaseCLI, "api", "generate", "--api", api, "--dir", dir, "--module", "example.com/responseConsumer") // The framework checkout intentionally enables local development mode. // Run the installed CLI outside it to exercise remote-only consumption. cmd.Dir = filepath.Dir(dir) if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("released CLI: %v\n%s", err, output) } return } o := Options{API: api, Dir: dir} if initial { o.Module = "example.com/responseConsumer" o.FrameworkDir = frameworkRoot(t) } if _, err := Generate(o); err != nil { t.Fatal(err) } } generate(source, true) writeFile(t, filepath.Join(dir, "response_test.go"), responseConsumerTest) for _, method := range []string{"Item", "Callback", "Partner"} { body := `return &types.ItemResponse{Name:"` + strings.ToLower(method) + `"},nil` if method == "Partner" { body = `return nil,apierrors.EmailTaken()` } imports := `"context";"example.com/responseConsumer/internal/types"` if method == "Partner" { imports += `;"example.com/responseConsumer/internal/apierrors"` } writeFile(t, filepath.Join(dir, "internal/logic/items", strings.ToLower(method)+".go"), "package items\nimport("+imports+")\nfunc(s *Logic)"+method+"(ctx context.Context)(*types.ItemResponse,error){"+body+"}\n") } before := snapshot(t, dir) generate(filepath.Join(dir, "api/service.api"), false) if !sameFiles(before, snapshot(t, dir)) { t.Fatal("regeneration changed hand-written files") } 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=") if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("consumer: %v\n%s", err, output) } if releaseCLI != "" { cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule) cmd.Dir = dir cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=") output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("released module: %v\n%s", err, output) } var module struct { Version, Sum string Replace any } if err = json.Unmarshal(output, &module); err != nil { t.Fatal(err) } if module.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || module.Replace != nil || module.Sum == "" { t.Fatalf("expected fixed remote module without replacement: %+v", module) } t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum) } manifestBytes, err := os.ReadFile(filepath.Join(dir, manifestPath)) if err != nil { t.Fatal(err) } var manifest manifest if err = json.Unmarshal(manifestBytes, &manifest); err != nil { t.Fatal(err) } if manifest.Generated["internal/handler/responseGen.go"] == "" { t.Fatal("response policy not ownership-managed") } } const responseConsumerTest = `package main_test import ( "bytes";"encoding/json";"io";"net/http/httptest";"testing" omni "git.yuntuo.hk/k2safe/go-omni" "git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/response" "example.com/responseConsumer/internal/apierrors" "example.com/responseConsumer/internal/handler" item "example.com/responseConsumer/internal/handler/items" "example.com/responseConsumer/internal/types" "context" "example.com/responseConsumer/internal/svc" ) type service struct{} func(service)Item(context.Context)(*types.ItemResponse,error){return &types.ItemResponse{Name:"item"},nil} func(service)Callback(context.Context)(*types.ItemResponse,error){return &types.ItemResponse{Name:"callback"},nil} func(service)Partner(context.Context)(*types.ItemResponse,error){return nil,apierrors.EmailTaken()} func TestResponseContract(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() deps.Middlewares["Auth"]=func(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{if c.Header("Authorization")!="Bearer valid"{return apierrors.Unauthenticated()};return next(c)}} if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)} // Registered routes use the compiled policy before even running authentication. for _,path:=range []string{"/item","/callback"}{ method:="GET";if path=="/callback"{method="POST"};w:=httptest.NewRecorder();app.ServeHTTP(w,httptest.NewRequest(method,path,nil)) var value map[string]any;json.Unmarshal(w.Body.Bytes(),&value) if w.Code!=401||value["code"]!=float64(401001)||value["msg"]!="请先登录"||len(value)!=3{t.Fatalf("%s: %s",path,w.Body.String())} } // Execute the real generated registration and user-owned service files. for _,tc:=range []struct{method,path string;status int;key string}{{"GET","/item",200,"data"},{"POST","/callback",200,"name"},{"GET","/partner",409,"result"}}{ w:=httptest.NewRecorder();r:=httptest.NewRequest(tc.method,tc.path,nil);r.Header.Set("Authorization","Bearer valid");app.ServeHTTP(w,r) var value map[string]any;decoder:=json.NewDecoder(bytes.NewReader(w.Body.Bytes()));decoder.UseNumber();if err:=decoder.Decode(&value);err!=nil{t.Fatal(err)} if w.Code!=tc.status||value[tc.key]==nil{t.Fatalf("generated %s: %d %s",tc.path,w.Code,w.Body.String())} if tc.path=="/item"&&value["code"]!=json.Number("9223372036854775807"){t.Fatalf("generated int64 lost precision: %s",w.Body.String())} } // Business handlers also honor the same runtime policy and raw override. h:=item.New(service{});runtime:=omni.New();defer runtime.Close() common:=response.MustNew(response.Config{CodeType:"integer",SuccessCode:200,ErrorCode:9999,MessageField:"msg",RequestIDField:"-",DataMode:"always"}) runtime.GET("/item",h.Item,httpx.WithResponse(common)) runtime.POST("/callback",h.Callback,httpx.WithResponse(response.RawJSON(common))) for _,tc:=range []struct{method,path,key string}{{"GET","/item","data"},{"POST","/callback","name"}}{ w:=httptest.NewRecorder();runtime.ServeHTTP(w,httptest.NewRequest(tc.method,tc.path,nil));var value map[string]any;json.Unmarshal(w.Body.Bytes(),&value) if w.Code!=200||value[tc.key]==nil{t.Fatal(w.Body.String())} } } `