|
|
|
|
@@ -0,0 +1,185 @@
|
|
|
|
|
package apigen
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Exercise the generated consumer, rather than matching a template substring:
|
|
|
|
|
// the optional hook must compile without changes to existing ServiceContext,
|
|
|
|
|
// survive regeneration, serve requests, and roll back App-owned resources.
|
|
|
|
|
func TestGeneratedCustomRoutesConsumer(t *testing.T) {
|
|
|
|
|
releaseCLI := os.Getenv("GO_OMNI_RELEASE_CLI")
|
|
|
|
|
releaseVersion := os.Getenv("GO_OMNI_RELEASE_VERSION")
|
|
|
|
|
if (releaseCLI == "") != (releaseVersion == "") {
|
|
|
|
|
t.Fatal("GO_OMNI_RELEASE_CLI and GO_OMNI_RELEASE_VERSION must be set together")
|
|
|
|
|
}
|
|
|
|
|
dir := filepath.Join(t.TempDir(), "customroutes")
|
|
|
|
|
api := filepath.Join(t.TempDir(), "service.api")
|
|
|
|
|
writeFile(t, api, "service customroutes {\n @handler ping\n get /ping\n}\n")
|
|
|
|
|
opts := Options{Dir: dir, API: api, Module: "example.com/customroutes", FrameworkDir: frameworkRoot(t)}
|
|
|
|
|
if releaseCLI != "" {
|
|
|
|
|
// The installed CLI must select its own published framework version.
|
|
|
|
|
// Never supply a source replacement in remote acceptance mode.
|
|
|
|
|
opts.FrameworkDir = ""
|
|
|
|
|
}
|
|
|
|
|
if err := generateManagedTestProject(t, opts); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
consumer := filepath.Join(dir, "custom_routes_test.go")
|
|
|
|
|
writeFile(t, consumer, customRoutesConsumerImports+`
|
|
|
|
|
func TestExistingServiceWithoutCustomRoutes(t *testing.T) {
|
|
|
|
|
deps:=newDependencies(t)
|
|
|
|
|
if _,ok:=any(deps).(interface{RegisterCustomRoutes(*omni.App) error});ok{t.Fatal("baseline unexpectedly implements extension")}
|
|
|
|
|
app,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app.Close()
|
|
|
|
|
response:=httptest.NewRecorder();app.ServeHTTP(response,httptest.NewRequest("GET","/ping",nil))
|
|
|
|
|
if response.Code!=http.StatusNotImplemented{t.Fatalf("generated route: %d %s",response.Code,response.Body.String())}
|
|
|
|
|
}
|
|
|
|
|
`)
|
|
|
|
|
// First compile and run the old project shape before adding any method.
|
|
|
|
|
runConsumerGo(t, dir, "test", "-race", "-mod=mod", "-count=1", "./...")
|
|
|
|
|
|
|
|
|
|
hookPath := filepath.Join(dir, "internal/svc/customRoutes.go")
|
|
|
|
|
writeFile(t, hookPath, customRoutesHook)
|
|
|
|
|
writeFile(t, consumer, customRoutesConsumerImports+customRoutesConsumerTests)
|
|
|
|
|
serviceContext := filepath.Join(dir, "internal/svc/serviceContext.go")
|
|
|
|
|
projectConfig := filepath.Join(dir, "internal/config/config.go")
|
|
|
|
|
handwritten := map[string]string{}
|
|
|
|
|
for _, path := range []string{hookPath, consumer, serviceContext, projectConfig} {
|
|
|
|
|
handwritten[path] = readFile(t, path)
|
|
|
|
|
}
|
|
|
|
|
opts.API = filepath.Join(dir, "api/service.api")
|
|
|
|
|
for _, mode := range []string{"dry-run", "generate", "check"} {
|
|
|
|
|
opts.DryRun = mode == "dry-run"
|
|
|
|
|
opts.Check = mode == "check"
|
|
|
|
|
if err := generateManagedTestProject(t, opts); err != nil {
|
|
|
|
|
t.Fatalf("%s: %v", mode, err)
|
|
|
|
|
}
|
|
|
|
|
for path, before := range handwritten {
|
|
|
|
|
if readFile(t, path) != before {
|
|
|
|
|
t.Fatalf("%s changed project-owned file %s", mode, path)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
runConsumerGo(t, dir, "test", "-race", "-mod=mod", "-count=1", "./...")
|
|
|
|
|
runConsumerGo(t, dir, "build", "-mod=mod", ".")
|
|
|
|
|
if releaseCLI != "" {
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 90*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 != releaseVersion || dependency.Replace != nil || dependency.Sum == "" {
|
|
|
|
|
t.Fatal("custom routes consumer is not using the fixed remote framework without replace")
|
|
|
|
|
}
|
|
|
|
|
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const customRoutesConsumerImports = `package main
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"example.com/customroutes/internal/config"
|
|
|
|
|
"example.com/customroutes/internal/handler"
|
|
|
|
|
"example.com/customroutes/internal/svc"
|
|
|
|
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
|
|
|
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func newDependencies(t *testing.T)*svc.ServiceContext{
|
|
|
|
|
t.Helper();cfg:=config.Default();cfg.Server.Log.Writer=io.Discard
|
|
|
|
|
logger,err:=logx.New(cfg.Server.Log);if err!=nil{t.Fatal(err)};t.Cleanup(func(){_ = logger.Close()})
|
|
|
|
|
deps,err:=svc.New(context.Background(),logger,cfg);if err!=nil{t.Fatal(err)};t.Cleanup(func(){if err:=deps.Close();err!=nil{t.Error(err)}})
|
|
|
|
|
return deps
|
|
|
|
|
}
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
const customRoutesHook = `package svc
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"net/http"
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
|
|
|
|
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
|
|
|
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var ErrCustomRegistration = errors.New("custom registration failed")
|
|
|
|
|
var ErrCustomCleanup = errors.New("custom cleanup failed")
|
|
|
|
|
var CustomCalls, CustomStopped, CustomDrained, CustomCleaned atomic.Int32
|
|
|
|
|
var CustomFailure bool
|
|
|
|
|
var CustomApp *omni.App
|
|
|
|
|
|
|
|
|
|
// This file belongs to the consuming project and must never be generated.
|
|
|
|
|
func(c *ServiceContext)RegisterCustomRoutes(app *omni.App)error{
|
|
|
|
|
CustomCalls.Add(1);CustomApp=app
|
|
|
|
|
app.OnStop("project-custom",func(context.Context)error{CustomStopped.Add(1);return nil})
|
|
|
|
|
app.OnDrain("project-custom",func(context.Context)error{CustomDrained.Add(1);return nil})
|
|
|
|
|
fail:=CustomFailure
|
|
|
|
|
app.OnShutdown(func()error{CustomCleaned.Add(1);if fail{return ErrCustomCleanup};return nil})
|
|
|
|
|
app.GET("/custom",httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter,r *http.Request){
|
|
|
|
|
w.Header().Set("Content-Type","text/plain");_,_ = w.Write([]byte("project-owned route"))
|
|
|
|
|
})))
|
|
|
|
|
if fail{return ErrCustomRegistration}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ResetCustomRoutes(){CustomCalls.Store(0);CustomStopped.Store(0);CustomDrained.Store(0);CustomCleaned.Store(0);CustomFailure=false;CustomApp=nil}
|
|
|
|
|
|
|
|
|
|
func IsCustomFailure(err error)bool{return errors.Is(err,ErrCustomRegistration)&&errors.Is(err,ErrCustomCleanup)}
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
const customRoutesConsumerTests = `
|
|
|
|
|
func TestCustomRoutesServeAndClose(t *testing.T){
|
|
|
|
|
svc.ResetCustomRoutes();deps:=newDependencies(t)
|
|
|
|
|
var extension interface{RegisterCustomRoutes(*omni.App) error}=deps
|
|
|
|
|
if extension==nil{t.Fatal("missing extension")}
|
|
|
|
|
app,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app.Close()
|
|
|
|
|
if svc.CustomCalls.Load()!=1{t.Fatal("hook called wrong number of times")}
|
|
|
|
|
server:=httptest.NewServer(app);defer server.Close()
|
|
|
|
|
for _,test:=range []struct{path string;status int;body string}{{"/custom",200,"project-owned route"},{"/ping",501,""},{"/health/ready",200,""}}{
|
|
|
|
|
response,err:=http.Get(server.URL+test.path);if err!=nil{t.Fatal(err)}
|
|
|
|
|
body,err:=io.ReadAll(response.Body);response.Body.Close();if err!=nil{t.Fatal(err)}
|
|
|
|
|
if response.StatusCode!=test.status||(test.body!=""&&string(body)!=test.body){t.Fatalf("%s: %d %s",test.path,response.StatusCode,body)}
|
|
|
|
|
}
|
|
|
|
|
if svc.CustomCleaned.Load()!=0{t.Fatal("resource closed during successful construction")}
|
|
|
|
|
if err:=app.Close();err!=nil{t.Fatal(err)}
|
|
|
|
|
if svc.CustomStopped.Load()!=1||svc.CustomDrained.Load()!=1||svc.CustomCleaned.Load()!=1{t.Fatal("custom lifecycle did not finish")}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCustomRoutesFailureRollsBackOnlyAppResources(t *testing.T){
|
|
|
|
|
svc.ResetCustomRoutes();svc.CustomFailure=true;defer func(){svc.CustomFailure=false}()
|
|
|
|
|
deps:=newDependencies(t);dependencyClosed:=false;deps.OnClose(func()error{dependencyClosed=true;return nil})
|
|
|
|
|
app,err:=handler.NewApp(deps)
|
|
|
|
|
if app!=nil||!svc.IsCustomFailure(err){t.Fatalf("expected both hook and cleanup errors, got app=%v err=%v",app,err)}
|
|
|
|
|
if svc.CustomCalls.Load()!=1||svc.CustomStopped.Load()!=1||svc.CustomDrained.Load()!=1||svc.CustomCleaned.Load()!=1{t.Fatal("failed construction leaked App resources")}
|
|
|
|
|
if dependencyClosed{t.Fatal("NewApp closed caller-owned dependencies")}
|
|
|
|
|
response:=httptest.NewRecorder();svc.CustomApp.ServeHTTP(response,httptest.NewRequest("GET","/custom",nil))
|
|
|
|
|
if response.Code!=http.StatusServiceUnavailable{t.Fatalf("failed App still accepts requests: %d",response.Code)}
|
|
|
|
|
_=svc.CustomApp.Close();if svc.CustomCleaned.Load()!=1{t.Fatal("rollback was not idempotent")}
|
|
|
|
|
if err:=deps.Close();err!=nil{t.Fatal(err)};if !dependencyClosed{t.Fatal("caller could not close dependencies")}
|
|
|
|
|
}
|
|
|
|
|
`
|