diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 99f9375756..c71355d1a9 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -6,9 +6,49 @@ concurrency: cancel-in-progress: true name: Integration jobs: + build: + name: Build JARs + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Init Hermit + uses: cashapp/activate-hermit@v1 + - name: Build JAR + run: just install-jars + - name: Archive JARs + uses: actions/upload-artifact@v2 + with: + name: jars + path: | + kotlin-runtime/ftl-generator/target/ftl-generator-1.0-SNAPSHOT.jar + kotlin-runtime/ftl-runtime/target/ftl-runtime-1.0-SNAPSHOT.jar + prepare: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-tests.outputs.matrix }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Init Hermit + uses: cashapp/activate-hermit@v1 + - name: Extract test cases + id: extract-tests + run: | + echo "::set-output name=cases::$(go test -v -list . ./integration | grep '^Test' | awk '{print $1}' | cut -d '(' -f1 | tr '\n' ',' | sed 's/,$//')" + - name: Format test matrix + id: set-tests + run: | + IFS=',' read -ra TESTS <<< "${{ steps.extract-tests.outputs.cases }}" + TEST_JSON=$(printf ',"%s"' "${TESTS[@]}") + TEST_JSON="[${TEST_JSON:1}]" + echo "::set-output name=matrix::{\"test\": $TEST_JSON}" integration: - name: Integration tests + needs: prepare runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{fromJson(needs.prepare.outputs.matrix)}} steps: - name: Checkout code uses: actions/checkout@v4 @@ -16,9 +56,13 @@ jobs: uses: cashapp/activate-hermit@v1 - name: Build Cache uses: ./.github/actions/build-cache + - name: Download artifacts + uses: actions/download-artifact@v2 + with: + name: jars - name: Docker Compose run: docker compose up -d --wait - name: Download Go Modules run: go mod download - - name: Integration tests - run: go test -v -tags integration ./integration + - name: Run ${{ matrix.test }} + run: go test -v -tags integration -run ${{ matrix.test }} ./integration \ No newline at end of file diff --git a/integration/integration_test.go b/integration/integration_test.go index 65f6ed2da2..ea36ad8826 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -1,5 +1,3 @@ -//go:build integration - package integration import ( @@ -9,9 +7,13 @@ import ( "encoding/json" "errors" "fmt" + "io" + "net/http" + "net/url" "os" "path/filepath" "regexp" + "strings" "syscall" "testing" "time" @@ -19,8 +21,8 @@ import ( "connectrpc.com/connect" "github.com/alecthomas/assert/v2" _ "github.com/amacneil/dbmate/v2/pkg/driver/postgres" + "github.com/iancoleman/strcase" _ "github.com/jackc/pgx/v5/stdlib" // SQL driver - "golang.org/x/exp/maps" "github.com/TBD54566975/ftl/backend/common/exec" "github.com/TBD54566975/ftl/backend/common/log" @@ -33,78 +35,145 @@ import ( const integrationTestTimeout = time.Second * 60 -func TestIntegration(t *testing.T) { - tmpDir := t.TempDir() +var runtimes = []string{"go", "kotlin"} + +func TestLifecycle(t *testing.T) { + runForRuntimes(t, func(modulesDir string, runtime string, rd runtimeData) []test { + return []test{ + {name: fmt.Sprintf("Init%s", rd.testSuffix), assertions: assertions{ + run("ftl", rd.initOpts...), + }}, + {name: fmt.Sprintf("Deploy%s", rd.testSuffix), assertions: assertions{ + run("ftl", "deploy", rd.moduleRoot), + deploymentExists(rd.moduleName), + }}, + {name: fmt.Sprintf("Call%s", rd.testSuffix), assertions: assertions{ + call(rd.moduleName, "echo", obj{"name": "Alice"}, func(t testing.TB, resp obj) { + message, ok := resp["message"].(string) + assert.True(t, ok, "message is not a string") + assert.True(t, regexp.MustCompile(`^Hello, Alice!`).MatchString(message), "%q does not match %q", message, `^Hello, Alice!`) + }), + }}, + } + }) +} +func TestHttpIngress(t *testing.T) { + runForRuntimes(t, func(modulesDir string, runtime string, rd runtimeData) []test { + return []test{ + {name: fmt.Sprintf("HttpIngress%s", rd.testSuffix), assertions: assertions{ + run("ftl", rd.initOpts...), + scaffoldTestData(runtime, "httpingress", rd.modulePath), + run("ftl", "deploy", rd.moduleRoot), + httpCall(rd, http.MethodGet, "/users/123/posts/456", obj{}, func(t testing.TB, resp *httpResponse) { + assert.Equal(t, 200, resp.status) + message, ok := resp.body["message"].(string) + assert.True(t, ok, "message is not a string") + assert.Equal(t, "UserID: 123, PostID: 456", message) + + }), + httpCall(rd, http.MethodPost, "/users", obj{"userID": "123", "postID": "345"}, func(t testing.TB, resp *httpResponse) { + assert.Equal(t, 201, resp.status) + }), + // contains aliased field + httpCall(rd, http.MethodPost, "/users", obj{"id": "123", "postID": "345"}, func(t testing.TB, resp *httpResponse) { + assert.Equal(t, 201, resp.status) + }), + httpCall(rd, http.MethodPut, "/users/123", obj{"postID": "346"}, func(t testing.TB, resp *httpResponse) { + assert.Equal(t, 200, resp.status) + }), + httpCall(rd, http.MethodDelete, "/users/123", obj{}, func(t testing.TB, resp *httpResponse) { + assert.Equal(t, 200, resp.status) + }), + }}, + } + }) +} - cwd, err := os.Getwd() - assert.NoError(t, err) +func TestDatabase(t *testing.T) { + runForRuntimes(t, func(modulesDir string, runtime string, rd runtimeData) []test { + dbName := "testdb" + err := os.Setenv( + fmt.Sprintf("FTL_POSTGRES_DSN_%s_TESTDB", strings.ToUpper(rd.moduleName)), + fmt.Sprintf("postgres://postgres:secret@localhost:54320/%s?sslmode=disable", dbName), + ) + assert.NoError(t, err) - rootDir := filepath.Join(cwd, "..") + requestData := fmt.Sprintf("Hello %s", runtime) + return []test{ + {name: fmt.Sprintf("Database%s", rd.testSuffix), assertions: assertions{ + setUpModuleDB(dbName), + run("ftl", rd.initOpts...), + scaffoldTestData(runtime, "database", rd.modulePath), + run("ftl", "deploy", rd.moduleRoot), + call(rd.moduleName, "insert", obj{"data": requestData}, func(t testing.TB, resp obj) {}), + validateModuleDB(dbName, requestData), + }}, + } + }) +} - modulesDir := filepath.Join(tmpDir, "modules") +func TestExternalCalls(t *testing.T) { + runForRuntimes(t, func(modulesDir string, runtime string, rd runtimeData) []test { + var tests []test + for _, callee := range runtimes { + calleeRd := getRuntimeData("echo2", modulesDir, callee) + tests = append(tests, test{ + name: fmt.Sprintf("Call%sFrom%s", strcase.ToCamel(callee), strcase.ToCamel(runtime)), + assertions: assertions{ + run("ftl", calleeRd.initOpts...), + run("ftl", "deploy", calleeRd.moduleRoot), + run("ftl", rd.initOpts...), + scaffoldTestData(runtime, "externalcalls", rd.modulePath), + run("ftl", "deploy", rd.moduleRoot), + call(rd.moduleName, "call", obj{"name": "Alice"}, func(t testing.TB, resp obj) { + message, ok := resp["message"].(string) + assert.True(t, ok, "message is not a string") + assert.True(t, regexp.MustCompile(`^Hello, Alice!`).MatchString(message), "%q does not match %q", message, `^Hello, Alice!`) + }), + run("rm", "-rf", rd.moduleRoot), + run("rm", "-rf", calleeRd.moduleRoot), + }}) + } + return tests + }) +} - tests := []struct { - name string - assertions assertions - }{ - {name: "DeployTime", assertions: assertions{ - run("examples", "ftl", "deploy", "time"), - deploymentExists("time"), - }}, - {name: "CallTime", assertions: assertions{ - call("time", "time", obj{}, func(t testing.TB, resp obj) { - assert.Equal(t, maps.Keys(resp), []string{"time"}) - }), - }}, - {name: "DeployEchoKotlin", assertions: assertions{ - run(".", "ftl", "deploy", "examples/kotlin/ftl-module-echo"), - deploymentExists("echo"), - }}, - {name: "CallEchoKotlin", assertions: assertions{ - call("echo", "echo", obj{"name": "Alice"}, func(t testing.TB, resp obj) { - message, ok := resp["message"].(string) - assert.True(t, ok, "message is not a string") - assert.True(t, regexp.MustCompile(`^Hello, Alice!`).MatchString(message), "%q does not match %q", message, `^Hello, Alice!`) - }), - }}, - {name: "InitNewKotlin", assertions: assertions{ - run(".", "ftl", "init", "kotlin", modulesDir, "echo2"), - run(".", "ftl", "init", "kotlin", modulesDir, "echo3"), - }}, - {name: "DeployNewKotlinEcho2", assertions: assertions{ - run(".", "ftl", "deploy", filepath.Join(modulesDir, "ftl-module-echo2")), - deploymentExists("echo2"), - }}, - {name: "CallEcho2", assertions: assertions{ - call("echo2", "echo", obj{"name": "Alice"}, func(t testing.TB, resp obj) { - message, ok := resp["message"].(string) - assert.True(t, ok, "message is not a string") - assert.True(t, regexp.MustCompile(`^Hello, Alice!`).MatchString(message), "%q does not match %q", message, `^Hello, Alice!`) - }), - }}, - {name: "DeployNewKotlinEcho3", assertions: assertions{ - run(".", "ftl", "deploy", filepath.Join(modulesDir, "ftl-module-echo3")), - deploymentExists("echo3"), - }}, - {name: "CallEcho3", assertions: assertions{ - call("echo3", "echo", obj{"name": "Alice"}, func(t testing.TB, resp obj) { - message, ok := resp["message"].(string) - assert.True(t, ok, "message is not a string") - assert.True(t, regexp.MustCompile(`^Hello, Alice!`).MatchString(message), "%q does not match %q", message, `^Hello, Alice!`) - }), - }}, - {name: "UseKotlinDbConn", assertions: assertions{ - run(".", "ftl", "init", "kotlin", modulesDir, "dbtest"), - setUpModuleDb(filepath.Join(modulesDir, "ftl-module-dbtest")), - run(".", "ftl", "deploy", filepath.Join(modulesDir, "ftl-module-dbtest")), - call("dbtest", "create", obj{"data": "Hello"}, func(t testing.TB, resp obj) {}), - validateModuleDb(), - }}, +func TestSchemaGenerate(t *testing.T) { + tests := []test{ {name: "SchemaGenerateJS", assertions: assertions{ - run(".", "ftl", "schema", "generate", "integration/testdata/schema-generate", "build/schema-generate"), + run("ftl", "schema", "generate", "integration/testdata/schema-generate", "build/schema-generate"), filesExist(file{"build/schema-generate/test.txt", "olleh"}), }}, } + runTests(t, t.TempDir(), tests) +} + +type testsFunc func(modulesDir string, runtime string, rd runtimeData) []test + +func runForRuntimes(t *testing.T, f testsFunc) { + t.Helper() + tmpDir := t.TempDir() + modulesDir := filepath.Join(tmpDir, "modules") + + var tests []test + for _, runtime := range runtimes { + rd := getRuntimeData("echo", modulesDir, runtime) + tests = append(tests, f(modulesDir, runtime, rd)...) + } + runTests(t, tmpDir, tests) +} + +type test struct { + name string + assertions assertions +} + +func runTests(t *testing.T, tmpDir string, tests []test) { + t.Helper() + cwd, err := os.Getwd() + assert.NoError(t, err) + + rootDir := filepath.Join(cwd, "..") // Build FTL binary logger := log.Configure(&logWriter{logger: t}, log.Config{Level: log.Debug}) @@ -117,7 +186,7 @@ func TestIntegration(t *testing.T) { controller := rpc.Dial(ftlv1connect.NewControllerServiceClient, "http://localhost:8892", log.Debug) verbs := rpc.Dial(ftlv1connect.NewVerbServiceClient, "http://localhost:8892", log.Debug) - ctx = startProcess(t, ctx, filepath.Join(binDir, "ftl"), "serve", "--recreate") + ctx = startProcess(ctx, t, filepath.Join(binDir, "ftl"), "serve", "--recreate") ic := itContext{ Context: ctx, @@ -142,17 +211,52 @@ func TestIntegration(t *testing.T) { } } +type runtimeData struct { + testSuffix string + moduleName string + moduleRoot string + modulePath string + initOpts []string +} + +func getRuntimeData(moduleName string, modulesDir string, runtime string) runtimeData { + cwd, err := os.Getwd() + if err != nil { + panic(err) + } + ftlRoot := filepath.Join(cwd, "..") + + t := runtimeData{ + testSuffix: strcase.ToCamel(runtime), + moduleName: moduleName, + } + switch runtime { + case "go": + t.moduleRoot = filepath.Join(modulesDir, t.moduleName) + t.modulePath = t.moduleRoot + // include replace flag to use local ftl in go.mod + t.initOpts = []string{"init", runtime, modulesDir, t.moduleName, "--replace", fmt.Sprintf("github.com/TBD54566975/ftl=%s", ftlRoot)} + case "kotlin": + t.moduleRoot = filepath.Join(modulesDir, fmt.Sprintf("ftl-module-%s", t.moduleName)) + t.modulePath = filepath.Join(t.moduleRoot, "src/main/kotlin/ftl", t.moduleName) + t.initOpts = []string{"init", runtime, modulesDir, t.moduleName} + default: + panic(fmt.Sprintf("unknown runtime %q", runtime)) + } + return t +} + type assertion func(t testing.TB, ic itContext) error type assertions []assertion // Assertions // Run a command in "dir" which is relative to the root directory of the project. -func run(dir, cmd string, args ...string) assertion { +func run(cmd string, args ...string) assertion { return func(t testing.TB, ic itContext) error { path := os.Getenv("PATH") path = ic.binDir + ":" + path - cmd := exec.Command(ic, log.Debug, filepath.Join(ic.rootDir, dir), cmd, args...) + cmd := exec.Command(ic, log.Debug, ic.rootDir, cmd, args...) cmd.Env = append(cmd.Env, os.Environ()...) cmd.Env = append(cmd.Env, "PATH="+path) return cmd.Run() @@ -162,11 +266,11 @@ func run(dir, cmd string, args ...string) assertion { func deploymentExists(name string) assertion { return status(func(t testing.TB, status *ftlv1.StatusResponse) { for _, deployment := range status.Deployments { - if deployment.Schema.Name == "time" { + if deployment.Schema.Name == name { return } } - t.Fatal("time deployment not found") + t.Fatal(fmt.Sprintf("%s deployment not found", name)) }) } @@ -231,8 +335,60 @@ func call[Resp any](module, verb string, req obj, onResponse func(t testing.TB, } } -func setUpModuleDb(dir string) assertion { - os.Setenv("FTL_POSTGRES_DSN_dbtest_testdb", "postgres://postgres:secret@localhost:54320/testdb?sslmode=disable") +type httpResponse struct { + status int + body map[string]any +} + +func httpCall(rd runtimeData, method string, path string, body obj, onResponse func(t testing.TB, resp *httpResponse)) assertion { + return func(t testing.TB, ic itContext) error { + b, err := json.Marshal(body) + assert.NoError(t, err) + + baseURL, err := url.Parse(fmt.Sprintf("http://localhost:8892/ingress/%s", rd.moduleName)) + assert.NoError(t, err) + + r, err := http.NewRequestWithContext(ic, method, baseURL.JoinPath(path).String(), bytes.NewReader(b)) + assert.NoError(t, err) + + r.Header.Add("Content-Type", "application/json") + + client := http.Client{} + resp, err := client.Do(r) + assert.NoError(t, err) + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + // error here so that the test retries in case the 404 is caused by the runner not being ready + return fmt.Errorf("endpoint not found: %s", path) + } + + bodyBytes, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + + var resBody map[string]any + err = json.Unmarshal(bodyBytes, &resBody) + assert.NoError(t, err) + + onResponse(t, &httpResponse{ + status: resp.StatusCode, + body: resBody, + }) + return nil + } +} + +func scaffoldTestData(runtime string, testDataDirectory string, targetModulePath string) assertion { + return func(t testing.TB, ic itContext) error { + return scaffolder.Scaffold( + filepath.Join(ic.rootDir, fmt.Sprintf("integration/testdata/%s/", runtime), testDataDirectory), + targetModulePath, + ic, + ) + } +} + +func setUpModuleDB(dbName string) assertion { return func(t testing.TB, ic itContext) error { db, err := sql.Open("pgx", "postgres://postgres:secret@localhost:54320/ftl?sslmode=disable") assert.NoError(t, err) @@ -248,27 +404,19 @@ func setUpModuleDb(dir string) assertion { var exists bool query := `SELECT EXISTS(SELECT datname FROM pg_catalog.pg_database WHERE datname = $1);` - err = db.QueryRow(query, "testdb").Scan(&exists) + err = db.QueryRow(query, dbName).Scan(&exists) assert.NoError(t, err) if !exists { - db.Exec("CREATE DATABASE testdb;") + _, err = db.Exec(fmt.Sprintf("CREATE DATABASE %s;", dbName)) + assert.NoError(t, err) } - // add DbTest.kt with a new verb that uses the db - err = scaffolder.Scaffold( - filepath.Join(ic.rootDir, "integration/testdata/database"), - filepath.Join(dir, "src/main/kotlin/ftl/dbtest"), - ic, - ) - assert.NoError(t, err) - return nil } } - -func validateModuleDb() assertion { +func validateModuleDB(dbName string, expectedRowData string) assertion { return func(t testing.TB, ic itContext) error { - db, err := sql.Open("pgx", "postgres://postgres:secret@localhost:54320/testdb?sslmode=disable") + db, err := sql.Open("pgx", fmt.Sprintf("postgres://postgres:secret@localhost:54320/%s?sslmode=disable", dbName)) assert.NoError(t, err) t.Cleanup(func() { err := db.Close() @@ -281,13 +429,14 @@ func validateModuleDb() assertion { assert.NoError(t, err) rows, err := db.Query("SELECT data FROM requests") + defer rows.Close() assert.NoError(t, err) for rows.Next() { var data string err := rows.Scan(&data) assert.NoError(t, err) - if data == "Hello" { + if data == expectedRowData { return nil } } @@ -324,7 +473,7 @@ func (i itContext) assertWithRetry(t testing.TB, assertion assertion) { } // startProcess runs a binary in the background. -func startProcess(t testing.TB, ctx context.Context, args ...string) context.Context { +func startProcess(ctx context.Context, t testing.TB, args ...string) context.Context { t.Helper() ctx, cancel := context.WithCancel(ctx) cmd := exec.Command(ctx, log.Info, "..", args[0], args[1:]...) @@ -360,11 +509,10 @@ func (l *logWriter) Write(p []byte) (n int, err error) { if index == -1 { l.buffer = append(l.buffer, p...) return n, nil - } else { - l.buffer = append(l.buffer, p[:index]...) - l.logger.Log(string(l.buffer)) - l.buffer = l.buffer[:0] - p = p[index+1:] } + l.buffer = append(l.buffer, p[:index]...) + l.logger.Log(string(l.buffer)) + l.buffer = l.buffer[:0] + p = p[index+1:] } } diff --git a/integration/testdata/go/database/echo.go b/integration/testdata/go/database/echo.go new file mode 100644 index 0000000000..43c297da06 --- /dev/null +++ b/integration/testdata/go/database/echo.go @@ -0,0 +1,43 @@ +//ftl:module echo +package echo + +import ( + "context" + + ftl "github.com/TBD54566975/ftl/go-runtime/sdk" // Import the FTL SDK. +) + +var db = ftl.PostgresDatabase("testdb") + +type InsertRequest struct { + Data string +} + +type InsertResponse struct{} + +//ftl:verb +func Insert(ctx context.Context, req InsertRequest) (InsertResponse, error) { + err := persistRequest(req) + if err != nil { + return InsertResponse{}, err + } + + return InsertResponse{}, nil +} + +func persistRequest(req InsertRequest) error { + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS requests + ( + data TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'utc'), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'utc') + );`) + if err != nil { + return err + } + _, err = db.Exec("INSERT INTO requests (data) VALUES ($1);", req.Data) + if err != nil { + return err + } + return nil +} diff --git a/integration/testdata/go/externalcalls/echo.go b/integration/testdata/go/externalcalls/echo.go new file mode 100644 index 0000000000..b335fd108d --- /dev/null +++ b/integration/testdata/go/externalcalls/echo.go @@ -0,0 +1,32 @@ +//ftl:module echo +package echo + +import ( + "context" + "fmt" + + "ftl/echo2" + ftl "github.com/TBD54566975/ftl/go-runtime/sdk" // Import the FTL SDK. +) + +type EchoRequest struct { + Name ftl.Option[string] `json:"name"` +} + +type EchoResponse struct { + Message string `json:"message"` +} + +//ftl:verb +func Echo(ctx context.Context, req EchoRequest) (EchoResponse, error) { + return EchoResponse{Message: fmt.Sprintf("Hello, %s!", req.Name.Default("anonymous"))}, nil +} + +//ftl:verb +func Call(ctx context.Context, req EchoRequest) (EchoResponse, error) { + res, err := ftl.Call(ctx, echo2.Echo, echo2.EchoRequest{Name: req.Name}) + if err != nil { + return EchoResponse{}, err + } + return EchoResponse{Message: res.Message}, nil +} diff --git a/integration/testdata/go/httpingress/echo.go b/integration/testdata/go/httpingress/echo.go new file mode 100644 index 0000000000..50a754fb75 --- /dev/null +++ b/integration/testdata/go/httpingress/echo.go @@ -0,0 +1,78 @@ +//ftl:module echo +package echo + +import ( + "context" + "fmt" + + "ftl/builtin" +) + +type GetRequest struct { + UserID string `json:"userId"` + PostID string `json:"postId"` +} + +type GetResponse struct { + Message string `json:"message"` +} + +//ftl:verb +//ftl:ingress http GET /echo/users/{userID}/posts/{postID} +func Get(ctx context.Context, req builtin.HttpRequest[GetRequest]) (builtin.HttpResponse[GetResponse], error) { + return builtin.HttpResponse[GetResponse]{ + Status: 200, + Headers: map[string][]string{"Get": {"Header from FTL"}}, + Body: GetResponse{Message: fmt.Sprintf("UserID: %s, PostID: %s", req.Body.UserID, req.Body.PostID)}, + }, nil +} + +type PostRequest struct { + UserID string `json:"userId" alias:"id"` + PostID string `json:"postId"` +} + +type PostResponse struct{} + +//ftl:verb +//ftl:ingress http POST /echo/users +func Post(ctx context.Context, req builtin.HttpRequest[PostRequest]) (builtin.HttpResponse[PostResponse], error) { + return builtin.HttpResponse[PostResponse]{ + Status: 201, + Headers: map[string][]string{"Post": {"Header from FTL"}}, + Body: PostResponse{}, + }, nil +} + +type PutRequest struct { + UserID string `json:"userId"` + PostID string `json:"postId"` +} + +type PutResponse struct{} + +//ftl:verb +//ftl:ingress http PUT /echo/users/{userID} +func Put(ctx context.Context, req builtin.HttpRequest[PutRequest]) (builtin.HttpResponse[PutResponse], error) { + return builtin.HttpResponse[PutResponse]{ + Status: 200, + Headers: map[string][]string{"Put": {"Header from FTL"}}, + Body: PutResponse{}, + }, nil +} + +type DeleteRequest struct { + UserID string `json:"userId"` +} + +type DeleteResponse struct{} + +//ftl:verb +//ftl:ingress http DELETE /echo/users/{userID} +func Delete(ctx context.Context, req builtin.HttpRequest[DeleteRequest]) (builtin.HttpResponse[DeleteResponse], error) { + return builtin.HttpResponse[DeleteResponse]{ + Status: 200, + Headers: map[string][]string{"Put": {"Header from FTL"}}, + Body: DeleteResponse{}, + }, nil +} diff --git a/integration/testdata/database/Dbtest.kt b/integration/testdata/kotlin/database/Echo.kt similarity index 70% rename from integration/testdata/database/Dbtest.kt rename to integration/testdata/kotlin/database/Echo.kt index cc074307e5..15609f015b 100644 --- a/integration/testdata/database/Dbtest.kt +++ b/integration/testdata/kotlin/database/Echo.kt @@ -1,22 +1,23 @@ -package ftl.dbtest +package ftl.echo import xyz.block.ftl.Context import xyz.block.ftl.Verb import xyz.block.ftl.Database -data class DbRequest(val data: String?) -data class DbResponse(val message: String? = "ok") +data class InsertRequest(val data: String) +typealias InsertResponse = Unit val db = Database("testdb") -class DbTest { +class Echo { + @Verb - fun create(context: Context, req: DbRequest): DbResponse { + fun insert(context: Context, req: InsertRequest): InsertResponse { persistRequest(req) - return DbResponse() + return InsertResponse } - fun persistRequest(req: DbRequest) { + fun persistRequest(req: InsertRequest) { db.conn { it.prepareStatement( """ diff --git a/integration/testdata/kotlin/externalcalls/Echo.kt b/integration/testdata/kotlin/externalcalls/Echo.kt new file mode 100644 index 0000000000..528beeb903 --- /dev/null +++ b/integration/testdata/kotlin/externalcalls/Echo.kt @@ -0,0 +1,21 @@ +package ftl.echo + +import ftl.echo2.Echo2ModuleClient +import xyz.block.ftl.Context +import xyz.block.ftl.Verb + +data class EchoRequest(val name: String) +data class EchoResponse(val message: String) + +class Echo { + @Verb + fun echo(context: Context, req: EchoRequest): EchoResponse { + return EchoResponse(message = "Hello, ${req.name}!") + } + + @Verb + fun call(context: Context, req: EchoRequest): EchoResponse { + val res = context.call(Echo2ModuleClient::echo, ftl.echo2.EchoRequest(name = req.name)) + return EchoResponse(message = res.message) + } +} diff --git a/integration/testdata/kotlin/httpingress/Echo.kt b/integration/testdata/kotlin/httpingress/Echo.kt new file mode 100644 index 0000000000..80865b74c4 --- /dev/null +++ b/integration/testdata/kotlin/httpingress/Echo.kt @@ -0,0 +1,97 @@ +package ftl.echo + +import ftl.builtin.HttpRequest +import ftl.builtin.HttpResponse +import kotlin.String +import kotlin.Unit +import xyz.block.ftl.Alias +import xyz.block.ftl.Context +import xyz.block.ftl.HttpIngress +import xyz.block.ftl.Method +import xyz.block.ftl.Verb + +data class GetRequest( + val userID: String, + val postID: String, +) + +data class GetResponse( + val message: String, +) + +data class PostRequest( + @Alias("id") val userID: String, + val postID: String, +) + +data class PostResponse( + val message: String, +) + +data class PutRequest( + val userID: String, + val postID: String, +) + +data class PutResponse( + val message: String, +) + +data class DeleteRequest( + val userID: String, +) + +data class DeleteResponse( + val message: String, +) + +class Echo { + @Verb + @HttpIngress( + Method.GET, + "/echo/users/{userID}/posts/{postID}", + ) + fun `get`(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 200, + headers = mapOf("Get" to arrayListOf("Header from FTL")), + body = GetResponse(message = "UserID: ${req.body.userID}, PostID ${req.body.postID}") + ) + } + + @Verb + @HttpIngress( + Method.POST, + "/echo/users", + ) + fun post(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 201, + headers = mapOf("Post" to arrayListOf("Header from FTL")), + body = PostResponse(message = "UserID: ${req.body.userID}, PostID ${req.body.postID}") + ) + } + + @Verb + @HttpIngress( + Method.PUT, + "/echo/users/{userID}", + ) + fun put(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 200, + headers = mapOf("Put" to arrayListOf("Header from FTL")), + body = PutResponse(message = "UserID: ${req.body.userID}, PostID ${req.body.postID}") + ) + } + + @Verb + @HttpIngress(Method.DELETE, "/echo/users/{userID}") + fun delete(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 200, + headers = mapOf("Delete" to arrayListOf("Header from FTL")), + body = DeleteResponse(message = "UserID: ${req.body.userID}") + ) + } +} diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Database.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Database.kt index 185272d55b..35e805539e 100644 --- a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Database.kt +++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Database.kt @@ -24,9 +24,9 @@ class Database(private val name: String) { fun conn(block: (c: Connection) -> R): R { return try { - val envVar = listOf(FTL_DSN_VAR_PREFIX, moduleName, name).joinToString("_") + val envVar = listOf(FTL_DSN_VAR_PREFIX, moduleName.uppercase(), name.uppercase()).joinToString("_") val dsn = System.getenv(envVar) - require(dsn != null) { "$envVar environment variable not set" } + require(dsn != null) { "missing DSN environment variable $envVar" } DriverManager.getConnection(dsnToJdbcUrl(dsn)).use { block(it) diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt index ca48652d64..ed925de7e7 100644 --- a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt +++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt @@ -42,8 +42,6 @@ import kotlin.collections.Map import kotlin.io.path.createDirectories data class ModuleData(val comments: List = emptyList(), val decls: MutableSet = mutableSetOf()) - -data class blah(val a: T) // Helpers private fun DataRef.compare(module: String, name: String): Boolean = this.name == name && this.module == module private fun DataRef.text(): String = "${this.module}.${this.name}" @@ -455,7 +453,7 @@ class SchemaExtractor( return Type( dataRef = DataRef( name = refName, - module = fqName.extractModuleName().takeIf { it != currentModuleName } ?: "", + module = fqName.extractModuleName(), pos = position, typeParameters = this.arguments.map { it.type.toSchemaType(position) }.toList(), ) diff --git a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRuleTest.kt b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRuleTest.kt index 96c41432b5..c17360792d 100644 --- a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRuleTest.kt +++ b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRuleTest.kt @@ -101,6 +101,7 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { value_ = Type( dataRef = DataRef( name = "MapValue", + module = "echo" ) ) ) @@ -148,6 +149,7 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { element = Type( dataRef = DataRef( name = "EchoMessage", + module = "echo" ) ) ) @@ -169,12 +171,14 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { name = "EchoRequest", typeParameters = listOf( Type(string = xyz.block.ftl.v1.schema.String()) - ) + ), + module = "echo" ) ), response = Type( dataRef = DataRef( name = "EchoResponse", + module = "echo" ) ), metadata = listOf( diff --git a/kotlin-runtime/scaffolding/ftl-module-{{ .Name | lower }}/src/main/kotlin/ftl/{{ .Name | camel | lower }}/{{ .Name | camel }}.kt b/kotlin-runtime/scaffolding/ftl-module-{{ .Name | lower }}/src/main/kotlin/ftl/{{ .Name | camel | lower }}/{{ .Name | camel }}.kt index e38d89ae67..229295616b 100644 --- a/kotlin-runtime/scaffolding/ftl-module-{{ .Name | lower }}/src/main/kotlin/ftl/{{ .Name | camel | lower }}/{{ .Name | camel }}.kt +++ b/kotlin-runtime/scaffolding/ftl-module-{{ .Name | lower }}/src/main/kotlin/ftl/{{ .Name | camel | lower }}/{{ .Name | camel }}.kt @@ -5,12 +5,12 @@ import xyz.block.ftl.Ingress import xyz.block.ftl.Method import xyz.block.ftl.Verb -data class {{ .Name | camel }}Request(val name: String) -data class {{ .Name | camel }}Response(val message: String) +data class EchoRequest(val name: String? = "anonymous") +data class EchoResponse(val message: String) class {{ .Name | camel }} { @Verb - fun echo(context: Context, req: {{ .Name | camel }}Request): {{ .Name | camel }}Response { - return {{ .Name | camel }}Response(message = "Hello, ${req.name}!") + fun echo(context: Context, req: EchoRequest): EchoResponse { + return EchoResponse(message = "Hello, ${req.name}!") } } diff --git a/scripts/integration-tests b/scripts/integration-tests index d472b30d4c..2b3b0d2a93 100755 --- a/scripts/integration-tests +++ b/scripts/integration-tests @@ -1,3 +1,4 @@ #!/bin/bash set -euo pipefail -go test -count 1 -v -tags integration ./integration +testName=${1:-} +go test -count 1 -v -tags integration -run "$testName" ./integration