361 lines
15 KiB
Go
361 lines
15 KiB
Go
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())}
|
|
}
|
|
}
|
|
`
|