From 7b6a42160b8bfa80098cc867f0f78ecc1f641f82 Mon Sep 17 00:00:00 2001 From: Stuart Douglas Date: Tue, 13 Aug 2024 11:31:51 +1000 Subject: [PATCH] feat: Initial Java runtime implementation (#2318) This is still very much a work in progress, however it contains a lot of basic functionality. So far this includes support for: - Verb invocations - HTTP ingress - Cron - Topics and Subscriptions - Basic testing of Verbs The existing Kotlin example has been migrated over to the new approach. At the moment the module is called Java even though it supports both, in future this will provide a base layer of functionality with some small language dependent features in separate Java/Kotlin modules. --- Justfile | 6 +- .../scaling/localscaling/local_scaling.go | 2 +- backend/runner/runner.go | 2 +- backend/schema/metadatatypemap.go | 2 +- buildengine/build.go | 2 + buildengine/build_java.go | 23 + buildengine/build_kotlin.go | 4 +- buildengine/build_test.go | 2 +- buildengine/deps.go | 52 ++ buildengine/discover_test.go | 4 +- buildengine/stubs.go | 41 +- buildengine/stubs_test.go | 7 +- common/moduleconfig/moduleconfig.go | 31 +- deployment/base/ftl-runner/ftl-runner.yml | 2 +- examples/go/echo/echo.go | 2 +- examples/kotlin/echo/ftl.toml | 2 +- examples/kotlin/echo/pom.xml | 309 ++++---- .../src/main/ftl-module-schema/builtin.pb | Bin 0 -> 832 bytes .../echo/src/main/ftl-module-schema/time.pb | Bin 0 -> 96 bytes .../echo/src/main/kotlin/ftl/echo/Echo.kt | 13 +- examples/kotlin/time/ftl.toml | 2 +- examples/kotlin/time/pom.xml | 309 ++++---- .../time/src/main/kotlin/ftl/time/Time.kt | 6 +- ftl-project.toml | 3 + go-runtime/compile/build.go | 25 +- go-runtime/schema/common/directive.go | 2 +- java-runtime/.gitignore | 6 + java-runtime/README.md | 22 + java-runtime/ftl-runtime/deployment/pom.xml | 60 ++ .../ftl/deployment/FTLCodeGenerator.java | 349 +++++++++ .../block/ftl/deployment/FtlProcessor.java | 696 ++++++++++++++++++ .../ftl/deployment/ModuleNameBuildItem.java | 16 + .../xyz/block/ftl/deployment/RetryRecord.java | 19 + .../SubscriptionMetaAnnotationsBuildItem.java | 35 + .../block/ftl/deployment/TopicsBuildItem.java | 26 + .../block/ftl/deployment/TopicsProcessor.java | 97 +++ .../ftl/deployment/VerbClientBuildItem.java | 25 + .../ftl/deployment/VerbClientsProcessor.java | 212 ++++++ .../io.quarkus.deployment.CodeGenProvider | 1 + .../test/FtlJavaRuntimeDevModeTest.java | 25 + .../java/runtime/test/FtlJavaRuntimeTest.java | 25 + .../ftl-runtime/integration-tests/pom.xml | 98 +++ .../src/main/ftl-module-schema/builtin.pb | Bin 0 -> 832 bytes .../src/main/ftl-module-schema/echo.pb | Bin 0 -> 1440 bytes .../src/main/ftl-module-schema/time.pb | Bin 0 -> 695 bytes .../runtime/it/FtlJavaRuntimeResource.java | 46 ++ .../block/ftl/java/runtime/it/MyTopic.java | 10 + .../xyz/block/ftl/java/runtime/it/Person.java | 5 + .../src/main/resources/application.properties | 0 .../it/FtlJavaRuntimeResourceTest.java | 62 ++ java-runtime/ftl-runtime/pom.xml | 264 +++++++ java-runtime/ftl-runtime/runtime/pom.xml | 137 ++++ .../src/main/java/xyz/block/ftl/Config.java | 12 + .../src/main/java/xyz/block/ftl/Cron.java | 14 + .../src/main/java/xyz/block/ftl/Export.java | 14 + .../main/java/xyz/block/ftl/GeneratedRef.java | 11 + .../src/main/java/xyz/block/ftl/Retry.java | 20 + .../src/main/java/xyz/block/ftl/Secret.java | 12 + .../main/java/xyz/block/ftl/Subscription.java | 28 + .../src/main/java/xyz/block/ftl/Topic.java | 12 + .../java/xyz/block/ftl/TopicDefinition.java | 17 + .../src/main/java/xyz/block/ftl/Verb.java | 15 + .../main/java/xyz/block/ftl/VerbClient.java | 17 + .../xyz/block/ftl/VerbClientDefinition.java | 18 + .../java/xyz/block/ftl/VerbClientEmpty.java | 5 + .../java/xyz/block/ftl/VerbClientSink.java | 5 + .../java/xyz/block/ftl/VerbClientSource.java | 5 + .../src/main/java/xyz/block/ftl/VerbName.java | 12 + .../block/ftl/runtime/FTLConfigSource.java | 65 ++ .../xyz/block/ftl/runtime/FTLController.java | 182 +++++ .../xyz/block/ftl/runtime/FTLHttpHandler.java | 245 ++++++ .../xyz/block/ftl/runtime/FTLRecorder.java | 110 +++ .../xyz/block/ftl/runtime/TopicHelper.java | 32 + .../block/ftl/runtime/VerbClientHelper.java | 48 ++ .../xyz/block/ftl/runtime/VerbHandler.java | 51 ++ .../xyz/block/ftl/runtime/VerbInvoker.java | 9 + .../xyz/block/ftl/runtime/VerbRegistry.java | 179 +++++ .../ftl/runtime/builtin/HttpRequest.java | 66 ++ .../ftl/runtime/builtin/HttpResponse.java | 49 ++ .../resources/META-INF/quarkus-extension.yaml | 9 + ...lipse.microprofile.config.spi.ConfigSource | 1 + .../ftl-runtime/test-framework/pom.xml | 32 + .../xyz/block/ftl/java/test/FTLManaged.java | 8 + .../java/xyz/block/ftl/java/test/TestFTL.java | 16 + .../java/test/internal/FTLTestResource.java | 27 + .../ftl/java/test/internal/FTLTestServer.java | 33 + .../java/test/internal/TestVerbServer.java | 109 +++ .../src/main/resources/application.properties | 0 88 files changed, 4228 insertions(+), 349 deletions(-) create mode 100644 buildengine/build_java.go create mode 100644 examples/kotlin/echo/src/main/ftl-module-schema/builtin.pb create mode 100644 examples/kotlin/echo/src/main/ftl-module-schema/time.pb create mode 100644 java-runtime/.gitignore create mode 100644 java-runtime/README.md create mode 100644 java-runtime/ftl-runtime/deployment/pom.xml create mode 100644 java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/FTLCodeGenerator.java create mode 100644 java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/FtlProcessor.java create mode 100644 java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/ModuleNameBuildItem.java create mode 100644 java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/RetryRecord.java create mode 100644 java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/SubscriptionMetaAnnotationsBuildItem.java create mode 100644 java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/TopicsBuildItem.java create mode 100644 java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/TopicsProcessor.java create mode 100644 java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/VerbClientBuildItem.java create mode 100644 java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/VerbClientsProcessor.java create mode 100644 java-runtime/ftl-runtime/deployment/src/main/resources/META-INF/services/io.quarkus.deployment.CodeGenProvider create mode 100644 java-runtime/ftl-runtime/deployment/src/test/java/xyz/block/ftl/java/runtime/test/FtlJavaRuntimeDevModeTest.java create mode 100644 java-runtime/ftl-runtime/deployment/src/test/java/xyz/block/ftl/java/runtime/test/FtlJavaRuntimeTest.java create mode 100644 java-runtime/ftl-runtime/integration-tests/pom.xml create mode 100644 java-runtime/ftl-runtime/integration-tests/src/main/ftl-module-schema/builtin.pb create mode 100644 java-runtime/ftl-runtime/integration-tests/src/main/ftl-module-schema/echo.pb create mode 100644 java-runtime/ftl-runtime/integration-tests/src/main/ftl-module-schema/time.pb create mode 100644 java-runtime/ftl-runtime/integration-tests/src/main/java/xyz/block/ftl/java/runtime/it/FtlJavaRuntimeResource.java create mode 100644 java-runtime/ftl-runtime/integration-tests/src/main/java/xyz/block/ftl/java/runtime/it/MyTopic.java create mode 100644 java-runtime/ftl-runtime/integration-tests/src/main/java/xyz/block/ftl/java/runtime/it/Person.java create mode 100644 java-runtime/ftl-runtime/integration-tests/src/main/resources/application.properties create mode 100644 java-runtime/ftl-runtime/integration-tests/src/test/java/xyz/block/ftl/java/runtime/it/FtlJavaRuntimeResourceTest.java create mode 100644 java-runtime/ftl-runtime/pom.xml create mode 100644 java-runtime/ftl-runtime/runtime/pom.xml create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Config.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Cron.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Export.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/GeneratedRef.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Retry.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Secret.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Subscription.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Topic.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/TopicDefinition.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Verb.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClient.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClientDefinition.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClientEmpty.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClientSink.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClientSource.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbName.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/FTLConfigSource.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/FTLController.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/FTLHttpHandler.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/FTLRecorder.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/TopicHelper.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/VerbClientHelper.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/VerbHandler.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/VerbInvoker.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/VerbRegistry.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/builtin/HttpRequest.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/builtin/HttpResponse.java create mode 100644 java-runtime/ftl-runtime/runtime/src/main/resources/META-INF/quarkus-extension.yaml create mode 100644 java-runtime/ftl-runtime/runtime/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource create mode 100644 java-runtime/ftl-runtime/test-framework/pom.xml create mode 100644 java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/FTLManaged.java create mode 100644 java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/TestFTL.java create mode 100644 java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/internal/FTLTestResource.java create mode 100644 java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/internal/FTLTestServer.java create mode 100644 java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/internal/TestVerbServer.java create mode 100644 java-runtime/ftl-runtime/test-framework/src/main/resources/application.properties diff --git a/Justfile b/Justfile index 01acf1b203..1d1441cc7a 100644 --- a/Justfile +++ b/Justfile @@ -31,6 +31,7 @@ clean: rm -rf frontend/node_modules find . -name '*.zip' -exec rm {} \; mvn -f kotlin-runtime/ftl-runtime clean + mvn -f java-runtime/ftl-runtime clean # Live rebuild the ftl binary whenever source changes. live-rebuild: @@ -41,7 +42,7 @@ dev *args: watchexec -r {{WATCHEXEC_ARGS}} -- "just build-sqlc && ftl dev {{args}}" # Build everything -build-all: build-protos-unconditionally build-frontend build-generate build-sqlc build-zips lsp-generate +build-all: build-protos-unconditionally build-frontend build-generate build-sqlc build-zips lsp-generate build-java @just build ftl ftl-controller ftl-runner ftl-initdb # Run "go generate" on all packages @@ -64,6 +65,9 @@ build +tools: build-protos build-zips build-frontend build-backend: just build ftl ftl-controller ftl-runner +build-java: + mvn -f java-runtime/ftl-runtime install + export DATABASE_URL := "postgres://postgres:secret@localhost:15432/ftl?sslmode=disable" # Explicitly initialise the database diff --git a/backend/controller/scaling/localscaling/local_scaling.go b/backend/controller/scaling/localscaling/local_scaling.go index f8ba1850ae..99eaf1bb7d 100644 --- a/backend/controller/scaling/localscaling/local_scaling.go +++ b/backend/controller/scaling/localscaling/local_scaling.go @@ -92,7 +92,7 @@ func (l *LocalScaling) SetReplicas(ctx context.Context, replicas int, idleRunner simpleName := fmt.Sprintf("runner%d", keySuffix) if err := kong.ApplyDefaults(&config, kong.Vars{ "deploymentdir": filepath.Join(l.cacheDir, "ftl-runner", simpleName, "deployments"), - "language": "go,kotlin,rust", + "language": "go,kotlin,rust,java", }); err != nil { return err } diff --git a/backend/runner/runner.go b/backend/runner/runner.go index fc68bb8b6a..d48d0c8636 100644 --- a/backend/runner/runner.go +++ b/backend/runner/runner.go @@ -49,7 +49,7 @@ type Config struct { TemplateDir string `help:"Template directory to copy into each deployment, if any." type:"existingdir"` DeploymentDir string `help:"Directory to store deployments in." default:"${deploymentdir}"` DeploymentKeepHistory int `help:"Number of deployments to keep history for." default:"3"` - Language []string `short:"l" help:"Languages the runner supports." env:"FTL_LANGUAGE" default:"go,kotlin,rust"` + Language []string `short:"l" help:"Languages the runner supports." env:"FTL_LANGUAGE" default:"go,kotlin,rust,java"` HeartbeatPeriod time.Duration `help:"Minimum period between heartbeats." default:"3s"` HeartbeatJitter time.Duration `help:"Jitter to add to heartbeat period." default:"2s"` RunnerStartDelay time.Duration `help:"Time in seconds for a runner to wait before contacting the controller. This can be needed in istio environments to work around initialization races." env:"FTL_RUNNER_START_DELAY" default:"0s"` diff --git a/backend/schema/metadatatypemap.go b/backend/schema/metadatatypemap.go index 432fd4f3c3..997e16c952 100644 --- a/backend/schema/metadatatypemap.go +++ b/backend/schema/metadatatypemap.go @@ -11,7 +11,7 @@ import ( type MetadataTypeMap struct { Pos Position `parser:"" protobuf:"1,optional"` - Runtime string `parser:"'+' 'typemap' @('go' | 'kotlin')" protobuf:"2"` + Runtime string `parser:"'+' 'typemap' @('go' | 'kotlin' | 'java')" protobuf:"2"` NativeName string `parser:"@String" protobuf:"3"` } diff --git a/buildengine/build.go b/buildengine/build.go index a2a32a43df..aff120ed65 100644 --- a/buildengine/build.go +++ b/buildengine/build.go @@ -47,6 +47,8 @@ func buildModule(ctx context.Context, projectRootDir string, sch *schema.Schema, switch module.Config.Language { case "go": err = buildGoModule(ctx, projectRootDir, sch, module, filesTransaction) + case "java": + err = buildJavaModule(ctx, module) case "kotlin": err = buildKotlinModule(ctx, sch, module) case "rust": diff --git a/buildengine/build_java.go b/buildengine/build_java.go new file mode 100644 index 0000000000..57b3d79c44 --- /dev/null +++ b/buildengine/build_java.go @@ -0,0 +1,23 @@ +package buildengine + +import ( + "context" + "fmt" + + "github.com/TBD54566975/ftl/internal/exec" + "github.com/TBD54566975/ftl/internal/log" +) + +func buildJavaModule(ctx context.Context, module Module) error { + logger := log.FromContext(ctx) + if err := SetPOMProperties(ctx, module.Config.Dir); err != nil { + return fmt.Errorf("unable to update ftl.version in %s: %w", module.Config.Dir, err) + } + logger.Infof("Using build command '%s'", module.Config.Build) + err := exec.Command(ctx, log.Debug, module.Config.Dir, "bash", "-c", module.Config.Build).RunBuffered(ctx) + if err != nil { + return fmt.Errorf("failed to build module %q: %w", module.Config.Module, err) + } + + return nil +} diff --git a/buildengine/build_kotlin.go b/buildengine/build_kotlin.go index 595976186f..5e81c1664c 100644 --- a/buildengine/build_kotlin.go +++ b/buildengine/build_kotlin.go @@ -43,16 +43,14 @@ func buildKotlinModule(ctx context.Context, sch *schema.Schema, module Module) e if err := SetPOMProperties(ctx, module.Config.Dir); err != nil { return fmt.Errorf("unable to update ftl.version in %s: %w", module.Config.Dir, err) } - if err := generateExternalModules(ctx, module, sch); err != nil { return fmt.Errorf("unable to generate external modules for %s: %w", module.Config.Module, err) } - if err := prepareFTLRoot(module); err != nil { return fmt.Errorf("unable to prepare FTL root for %s: %w", module.Config.Module, err) } - logger.Debugf("Using build command '%s'", module.Config.Build) + logger.Infof("Using build command '%s'", module.Config.Build) err := exec.Command(ctx, log.Debug, module.Config.Dir, "bash", "-c", module.Config.Build).RunBuffered(ctx) if err != nil { return fmt.Errorf("failed to build module %q: %w", module.Config.Module, err) diff --git a/buildengine/build_test.go b/buildengine/build_test.go index e29e244ea2..fba4cf4089 100644 --- a/buildengine/build_test.go +++ b/buildengine/build_test.go @@ -93,7 +93,7 @@ func testBuildClearsBuildDir(t *testing.T, bctx buildContext) { projectRoot := t.TempDir() // generate stubs to create the shared modules directory - err = GenerateStubs(ctx, projectRoot, bctx.sch.Modules, []moduleconfig.ModuleConfig{{Dir: bctx.moduleDir}}) + err = GenerateStubs(ctx, projectRoot, bctx.sch.Modules, []moduleconfig.ModuleConfig{{Dir: bctx.moduleDir, Language: "go"}}) assert.NoError(t, err) // build to generate the build directory diff --git a/buildengine/deps.go b/buildengine/deps.go index c989c45fc7..2f4bf4a5a1 100644 --- a/buildengine/deps.go +++ b/buildengine/deps.go @@ -51,6 +51,9 @@ func extractDependencies(module Module) ([]string, error) { case "kotlin": return extractKotlinFTLImports(module.Config.Module, module.Config.Dir) + case "java": + return extractJavaFTLImports(module.Config.Module, module.Config.Dir) + case "rust": return extractRustFTLImports(module.Config.Module, module.Config.Dir) @@ -140,6 +143,55 @@ func extractKotlinFTLImports(self, dir string) ([]string, error) { return modules, nil } +func extractJavaFTLImports(self, dir string) ([]string, error) { + dependencies := map[string]bool{} + // We also attempt to look at kotlin files + // As the Java module supports both + kotin, kotlinErr := extractKotlinFTLImports(self, dir) + if kotlinErr == nil { + // We don't really care about the error case, its probably a Java project + for _, imp := range kotin { + dependencies[imp] = true + } + } + javaImportRegex := regexp.MustCompile(`^import ftl\.([A-Za-z0-9_.]+)`) + + err := filepath.WalkDir(filepath.Join(dir, "src/main/java"), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return fmt.Errorf("failed to walk directory: %w", err) + } + if d.IsDir() || !(strings.HasSuffix(path, ".java")) { + return nil + } + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + matches := javaImportRegex.FindStringSubmatch(scanner.Text()) + if len(matches) > 1 { + module := strings.Split(matches[1], ".")[0] + if module == self { + continue + } + dependencies[module] = true + } + } + return scanner.Err() + }) + + // We only error out if they both failed + if err != nil && kotlinErr != nil { + return nil, fmt.Errorf("%s: failed to extract dependencies from Java module: %w", self, err) + } + modules := maps.Keys(dependencies) + sort.Strings(modules) + return modules, nil +} + func extractRustFTLImports(self, dir string) ([]string, error) { fmt.Fprintf(os.Stderr, "RUST TODO extractRustFTLImports\n") diff --git a/buildengine/discover_test.go b/buildengine/discover_test.go index dc7b241daa..26dbb8db02 100644 --- a/buildengine/discover_test.go +++ b/buildengine/discover_test.go @@ -73,7 +73,7 @@ func TestDiscoverModules(t *testing.T) { Language: "kotlin", Realm: "home", Module: "echo", - Build: "mvn -B compile", + Build: "mvn -B package", Deploy: []string{ "main", "classes", @@ -116,7 +116,7 @@ func TestDiscoverModules(t *testing.T) { Language: "kotlin", Realm: "home", Module: "externalkotlin", - Build: "mvn -B compile", + Build: "mvn -B package", Deploy: []string{ "main", "classes", diff --git a/buildengine/stubs.go b/buildengine/stubs.go index 646348b4d4..5992e4ff65 100644 --- a/buildengine/stubs.go +++ b/buildengine/stubs.go @@ -3,6 +3,8 @@ package buildengine import ( "context" "fmt" + "os" + "path/filepath" "github.com/TBD54566975/ftl/backend/schema" "github.com/TBD54566975/ftl/common/moduleconfig" @@ -13,7 +15,11 @@ import ( // // Currently, only Go stubs are supported. Kotlin and other language stubs can be added in the future. func GenerateStubs(ctx context.Context, projectRoot string, modules []*schema.Module, moduleConfigs []moduleconfig.ModuleConfig) error { - return generateGoStubs(ctx, projectRoot, modules, moduleConfigs) + err := generateGoStubs(ctx, projectRoot, modules, moduleConfigs) + if err != nil { + return err + } + return writeGenericSchemaFiles(ctx, projectRoot, modules, moduleConfigs) } // CleanStubs removes all generated stubs. @@ -37,6 +43,39 @@ func generateGoStubs(ctx context.Context, projectRoot string, modules []*schema. return nil } +func writeGenericSchemaFiles(ctx context.Context, projectRoot string, modules []*schema.Module, moduleConfigs []moduleconfig.ModuleConfig) error { + sch := &schema.Schema{Modules: modules} + for _, module := range moduleConfigs { + if module.GeneratedSchemaDir == "" { + continue + } + + modPath := module.Abs().GeneratedSchemaDir + err := os.MkdirAll(modPath, 0750) + if err != nil { + return fmt.Errorf("failed to create directory %s: %w", modPath, err) + } + + for _, mod := range sch.Modules { + if mod.Name == module.Module { + continue + } + data, err := schema.ModuleToBytes(mod) + if err != nil { + return fmt.Errorf("failed to export module schema for module %s %w", mod.Name, err) + } + err = os.WriteFile(filepath.Join(modPath, mod.Name+".pb"), data, 0600) + if err != nil { + return fmt.Errorf("failed to write schema file for module %s %w", mod.Name, err) + } + } + } + err := compile.GenerateStubsForModules(ctx, projectRoot, moduleConfigs, sch) + if err != nil { + return fmt.Errorf("failed to generate go stubs: %w", err) + } + return nil +} func cleanGoStubs(ctx context.Context, projectRoot string) error { err := compile.CleanStubs(ctx, projectRoot) if err != nil { diff --git a/buildengine/stubs_test.go b/buildengine/stubs_test.go index c4c1efe5b5..3f3d32856d 100644 --- a/buildengine/stubs_test.go +++ b/buildengine/stubs_test.go @@ -6,10 +6,11 @@ import ( "path/filepath" "testing" + "github.com/alecthomas/assert/v2" + "github.com/TBD54566975/ftl/backend/schema" "github.com/TBD54566975/ftl/common/moduleconfig" "github.com/TBD54566975/ftl/internal/log" - "github.com/alecthomas/assert/v2" ) func TestGenerateGoStubs(t *testing.T) { @@ -180,7 +181,7 @@ func init() { ctx := log.ContextWithNewDefaultLogger(context.Background()) projectRoot := t.TempDir() - err := GenerateStubs(ctx, projectRoot, modules, []moduleconfig.ModuleConfig{}) + err := GenerateStubs(ctx, projectRoot, modules, []moduleconfig.ModuleConfig{{Language: "go"}}) assert.NoError(t, err) generatedPath := filepath.Join(projectRoot, ".ftl/go/modules/other/external_module.go") @@ -240,7 +241,7 @@ func Call(context.Context, Req) (Resp, error) { ` ctx := log.ContextWithNewDefaultLogger(context.Background()) projectRoot := t.TempDir() - err := GenerateStubs(ctx, projectRoot, modules, []moduleconfig.ModuleConfig{}) + err := GenerateStubs(ctx, projectRoot, modules, []moduleconfig.ModuleConfig{{Language: "go"}}) assert.NoError(t, err) generatedPath := filepath.Join(projectRoot, ".ftl/go/modules/test/external_module.go") diff --git a/common/moduleconfig/moduleconfig.go b/common/moduleconfig/moduleconfig.go index 990e96b732..53ff6b2246 100644 --- a/common/moduleconfig/moduleconfig.go +++ b/common/moduleconfig/moduleconfig.go @@ -21,6 +21,9 @@ type ModuleGoConfig struct{} // ModuleKotlinConfig is language-specific configuration for Kotlin modules. type ModuleKotlinConfig struct{} +// ModuleJavaConfig is language-specific configuration for Java modules. +type ModuleJavaConfig struct{} + // ModuleConfig is the configuration for an FTL module. // // Module config files are currently TOML. @@ -37,6 +40,8 @@ type ModuleConfig struct { Deploy []string `toml:"deploy"` // DeployDir is the directory to deploy from, relative to the module directory. DeployDir string `toml:"deploy-dir"` + // GeneratedSchemaDir is the directory to generate protobuf schema files into. These can be picked up by language specific build tools + GeneratedSchemaDir string `toml:"generated-schema-dir"` // Schema is the name of the schema file relative to the DeployDir. Schema string `toml:"schema"` // Errors is the name of the error file relative to the DeployDir. @@ -46,6 +51,7 @@ type ModuleConfig struct { Go ModuleGoConfig `toml:"go,optional"` Kotlin ModuleKotlinConfig `toml:"kotlin,optional"` + Java ModuleJavaConfig `toml:"java,optional"` } // AbsModuleConfig is a ModuleConfig with all paths made absolute. @@ -84,6 +90,12 @@ func (c ModuleConfig) Abs() AbsModuleConfig { if !strings.HasPrefix(clone.DeployDir, clone.Dir) { panic(fmt.Sprintf("deploy-dir %q is not beneath module directory %q", clone.DeployDir, clone.Dir)) } + if clone.GeneratedSchemaDir != "" { + clone.GeneratedSchemaDir = filepath.Clean(filepath.Join(clone.Dir, clone.GeneratedSchemaDir)) + if !strings.HasPrefix(clone.GeneratedSchemaDir, clone.Dir) { + panic(fmt.Sprintf("generated-schema-dir %q is not beneath module directory %q", clone.GeneratedSchemaDir, clone.Dir)) + } + } clone.Schema = filepath.Clean(filepath.Join(clone.DeployDir, clone.Schema)) if !strings.HasPrefix(clone.Schema, clone.DeployDir) { panic(fmt.Sprintf("schema %q is not beneath deploy directory %q", clone.Schema, clone.DeployDir)) @@ -119,7 +131,7 @@ func setConfigDefaults(moduleDir string, config *ModuleConfig) error { switch config.Language { case "kotlin": if config.Build == "" { - config.Build = "mvn -B compile" + config.Build = "mvn -B package" } if config.DeployDir == "" { config.DeployDir = "target" @@ -130,7 +142,22 @@ func setConfigDefaults(moduleDir string, config *ModuleConfig) error { if len(config.Watch) == 0 { config.Watch = []string{"pom.xml", "src/**", "target/generated-sources"} } - + case "java": + if config.Build == "" { + config.Build = "mvn -B package" + } + if config.DeployDir == "" { + config.DeployDir = "target" + } + if config.GeneratedSchemaDir == "" { + config.GeneratedSchemaDir = "src/main/ftl-module-schema" + } + if len(config.Deploy) == 0 { + config.Deploy = []string{"main", "quarkus-app"} + } + if len(config.Watch) == 0 { + config.Watch = []string{"pom.xml", "src/**", "target/generated-sources"} + } case "go": if config.DeployDir == "" { config.DeployDir = ".ftl" diff --git a/deployment/base/ftl-runner/ftl-runner.yml b/deployment/base/ftl-runner/ftl-runner.yml index 365f806fef..71063b0b22 100644 --- a/deployment/base/ftl-runner/ftl-runner.yml +++ b/deployment/base/ftl-runner/ftl-runner.yml @@ -31,7 +31,7 @@ spec: - name: FTL_RUNNER_ADVERTISE value: "http://$(MY_POD_IP):8893" - name: FTL_LANGUAGE - value: "go,kotlin" + value: "go,kotlin,java" ports: - containerPort: 8893 readinessProbe: diff --git a/examples/go/echo/echo.go b/examples/go/echo/echo.go index 233b4850cb..8f4776749f 100644 --- a/examples/go/echo/echo.go +++ b/examples/go/echo/echo.go @@ -23,7 +23,7 @@ type EchoResponse struct { // Echo returns a greeting with the current time. // -//ftl:verb +//ftl:verb export func Echo(ctx context.Context, req EchoRequest) (EchoResponse, error) { tresp, err := ftl.Call(ctx, time.Time, time.TimeRequest{}) if err != nil { diff --git a/examples/kotlin/echo/ftl.toml b/examples/kotlin/echo/ftl.toml index 700b9d8833..de92e831e1 100644 --- a/examples/kotlin/echo/ftl.toml +++ b/examples/kotlin/echo/ftl.toml @@ -1,2 +1,2 @@ module = "echo" -language = "kotlin" +language = "java" diff --git a/examples/kotlin/echo/pom.xml b/examples/kotlin/echo/pom.xml index 3945bbad4d..f4a3a46581 100644 --- a/examples/kotlin/echo/pom.xml +++ b/examples/kotlin/echo/pom.xml @@ -1,185 +1,182 @@ - + 4.0.0 - - ftl + xyz.block.ftl.examples echo - 1.0-SNAPSHOT + 1.0.0-SNAPSHOT 1.0-SNAPSHOT - 1.8 - 1.9.22 - true - ${java.version} - ${java.version} + 3.13.0 + 2.0.0 + 17 + UTF-8 + UTF-8 + quarkus-bom + io.quarkus.platform + 3.12.3 + true + 3.2.5 + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + - org.jetbrains.kotlin - kotlin-stdlib - ${kotlin.version} + xyz.block + ftl-java-runtime + 1.0.0-SNAPSHOT - xyz.block - ftl-runtime - ${ftl.version} + io.quarkus + quarkus-kotlin - org.postgresql - postgresql - 42.7.2 + io.quarkus + quarkus-jackson + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-junit5 + test + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + + + io.rest-assured + kotlin-extensions + test - - - - kotlin-maven-plugin - org.jetbrains.kotlin - ${kotlin.version} - - - compile - - compile - - - - ${project.basedir}/src/main/kotlin - - - - - test-compile - - test-compile - - - - ${project.basedir}/src/test/kotlin - - - - - - - org.apache.maven.plugins - maven-dependency-plugin - 3.6.1 - - - - copy-dependencies - compile - - copy-dependencies - - - ${project.build.directory}/dependency - runtime - - - - - build-classpath - compile - - build-classpath - - - ${project.build.directory}/classpath.txt - dependency - - - - build-classpath-property - compile - - build-classpath - - - generated.classpath - ${project.build.directory}/dependency - - - - - - - org.codehaus.mojo - build-helper-maven-plugin - 3.5.0 - - - generate-sources - - add-source - - - - ${project.build.directory}/generated-sources/ftl - - - - - - - com.github.ozsie - detekt-maven-plugin - 1.23.5 - - true - ${generated.classpath} - ${java.version} - ${java.home} - ${project.build.directory}/detekt.yml - - - ${project.build.directory}/dependency/ftl-runtime-${ftl.version}.jar - - - ${project.basedir}/src/main/kotlin,${project.build.directory}/generated-sources - - - - compile - - check-with-type-resolution - - - - - - xyz.block - ftl-runtime - ${ftl.version} - - - - - + src/main/kotlin + src/test/kotlin - kotlin-maven-plugin - org.jetbrains.kotlin + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + native-image-agent + + + - org.apache.maven.plugins - maven-dependency-plugin + maven-compiler-plugin + ${compiler-plugin.version} + + + -parameters + + - - org.codehaus.mojo - build-helper-maven-plugin + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + ${maven.home} + + - - com.github.ozsie - detekt-maven-plugin + maven-failsafe-plugin + ${surefire-plugin.version} + + + + integration-test + verify + + + + + + ${project.build.directory}/${project.build.finalName}-runner + org.jboss.logmanager.LogManager + ${maven.home} + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + compile + + + + test-compile + + test-compile + + + + + + org.jetbrains.kotlin + kotlin-maven-allopen + ${kotlin.version} + + + + true + 17 + + all-open + + + + + + + + - \ No newline at end of file + + + + native + + + native + + + + false + true + + + + diff --git a/examples/kotlin/echo/src/main/ftl-module-schema/builtin.pb b/examples/kotlin/echo/src/main/ftl-module-schema/builtin.pb new file mode 100644 index 0000000000000000000000000000000000000000..af6286fa85cb5cef958287c4392d7909446742b3 GIT binary patch literal 832 zcmah{O^?$s5T$L>be;6e+ir)nAj{%HfF81(I2IM{fx`+_Lh3DP4T-c(g6$ynZy@nI zK=20{Cn;T|mAH9ko}b=(GsGITp+#q_GQHEU(v0b2Ar$+ROZIN_ehDq^#AcLc!S@#V z)*{yEF?8tbw~^i@AHP)~{jHhLp4criD_k zZ#J9T2h^2H>??`YJk35!q10Kad!u#H7z{k*vSCiGRlYxlfK?ZjbPqx&`M#0T1uwk9 ziGvfXh;-8Q9U$Qn`#tOrO#e2L5&jfX@vGH@)<$|T^-r%S;d8`a7_XSGJaT^p4eSCu z-+M#;R@3kWLJ2~hL_~YTc?J_0+yfJsg$o3`Mqi9R_+V>%xj<@P!LK&?VNx5I1vjaw zBF~gy=cWE9Hm{DY{#NBS#06Km1%2cm0;l%cY_N)Pgkri%N4iwJN6bb?7^Cqte%RCa zA5UZS^bx`I`%uUr&;!@yZP~r&zvV&5K3-a@dQ;qnz7=*yGN-Rc9Tnt)9LIzw65{ytC YEI~jqBZ(+6pehu7QeZt+EL=c&0PLa^;s5{u literal 0 HcmV?d00001 diff --git a/examples/kotlin/echo/src/main/kotlin/ftl/echo/Echo.kt b/examples/kotlin/echo/src/main/kotlin/ftl/echo/Echo.kt index 3bebf67f3d..ecf114f4a9 100644 --- a/examples/kotlin/echo/src/main/kotlin/ftl/echo/Echo.kt +++ b/examples/kotlin/echo/src/main/kotlin/ftl/echo/Echo.kt @@ -1,18 +1,15 @@ package ftl.echo -import ftl.builtin.Empty -import ftl.time.time -import xyz.block.ftl.Context +import ftl.time.TimeClient import xyz.block.ftl.Export - -class InvalidInput(val field: String) : Exception() +import xyz.block.ftl.Verb data class EchoRequest(val name: String?) data class EchoResponse(val message: String) -@Throws(InvalidInput::class) @Export -fun echo(context: Context, req: EchoRequest): EchoResponse { - val response = context.call(::time, Empty()) +@Verb +fun echo(req: EchoRequest, time: TimeClient): EchoResponse { + val response = time.call() return EchoResponse(message = "Hello, ${req.name ?: "anonymous"}! The time is ${response.time}.") } diff --git a/examples/kotlin/time/ftl.toml b/examples/kotlin/time/ftl.toml index 48033f28f8..e89ed11377 100644 --- a/examples/kotlin/time/ftl.toml +++ b/examples/kotlin/time/ftl.toml @@ -1,2 +1,2 @@ module = "time" -language = "kotlin" +language = "java" diff --git a/examples/kotlin/time/pom.xml b/examples/kotlin/time/pom.xml index 86748eceec..92190cd367 100644 --- a/examples/kotlin/time/pom.xml +++ b/examples/kotlin/time/pom.xml @@ -1,185 +1,182 @@ - + 4.0.0 - - ftl + xyz.block.ftl.examples time - 1.0-SNAPSHOT + 1.0.0-SNAPSHOT 1.0-SNAPSHOT - 1.8 - 1.9.22 - true - ${java.version} - ${java.version} + 3.13.0 + 2.0.0 + 17 + UTF-8 + UTF-8 + quarkus-bom + io.quarkus.platform + 3.12.3 + true + 3.2.5 + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + - org.jetbrains.kotlin - kotlin-stdlib - ${kotlin.version} + xyz.block + ftl-java-runtime + 1.0.0-SNAPSHOT - xyz.block - ftl-runtime - ${ftl.version} + io.quarkus + quarkus-kotlin - org.postgresql - postgresql - 42.7.2 + io.quarkus + quarkus-jackson + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-junit5 + test + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + + + io.rest-assured + kotlin-extensions + test - - - - kotlin-maven-plugin - org.jetbrains.kotlin - ${kotlin.version} - - - compile - - compile - - - - ${project.basedir}/src/main/kotlin - - - - - test-compile - - test-compile - - - - ${project.basedir}/src/test/kotlin - - - - - - - org.apache.maven.plugins - maven-dependency-plugin - 3.6.1 - - - - copy-dependencies - compile - - copy-dependencies - - - ${project.build.directory}/dependency - runtime - - - - - build-classpath - compile - - build-classpath - - - ${project.build.directory}/classpath.txt - dependency - - - - build-classpath-property - compile - - build-classpath - - - generated.classpath - ${project.build.directory}/dependency - - - - - - - org.codehaus.mojo - build-helper-maven-plugin - 3.5.0 - - - generate-sources - - add-source - - - - ${project.build.directory}/generated-sources/ftl - - - - - - - com.github.ozsie - detekt-maven-plugin - 1.23.5 - - true - ${generated.classpath} - ${java.version} - ${java.home} - ${project.build.directory}/detekt.yml - - - ${project.build.directory}/dependency/ftl-runtime-${ftl.version}.jar - - - ${project.basedir}/src/main/kotlin,${project.build.directory}/generated-sources - - - - compile - - check-with-type-resolution - - - - - - xyz.block - ftl-runtime - ${ftl.version} - - - - - + src/main/kotlin + src/test/kotlin - kotlin-maven-plugin - org.jetbrains.kotlin + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + native-image-agent + + + - org.apache.maven.plugins - maven-dependency-plugin + maven-compiler-plugin + ${compiler-plugin.version} + + + -parameters + + - - org.codehaus.mojo - build-helper-maven-plugin + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + ${maven.home} + + - - com.github.ozsie - detekt-maven-plugin + maven-failsafe-plugin + ${surefire-plugin.version} + + + + integration-test + verify + + + + + + ${project.build.directory}/${project.build.finalName}-runner + org.jboss.logmanager.LogManager + ${maven.home} + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + compile + + + + test-compile + + test-compile + + + + + + org.jetbrains.kotlin + kotlin-maven-allopen + ${kotlin.version} + + + + true + 17 + + all-open + + + + + + + + - \ No newline at end of file + + + + native + + + native + + + + false + true + + + + diff --git a/examples/kotlin/time/src/main/kotlin/ftl/time/Time.kt b/examples/kotlin/time/src/main/kotlin/ftl/time/Time.kt index 7158bc5a38..c6dd7f271c 100644 --- a/examples/kotlin/time/src/main/kotlin/ftl/time/Time.kt +++ b/examples/kotlin/time/src/main/kotlin/ftl/time/Time.kt @@ -1,13 +1,13 @@ package ftl.time -import ftl.builtin.Empty -import xyz.block.ftl.Context import xyz.block.ftl.Export +import xyz.block.ftl.Verb import java.time.OffsetDateTime data class TimeResponse(val time: OffsetDateTime) +@Verb @Export -fun time(context: Context, req: Empty): TimeResponse { +fun time(): TimeResponse { return TimeResponse(time = OffsetDateTime.now()) } diff --git a/ftl-project.toml b/ftl-project.toml index f196dc2f02..62f6c086db 100644 --- a/ftl-project.toml +++ b/ftl-project.toml @@ -1,9 +1,12 @@ name = "ftl" module-dirs = ["examples/go"] ftl-min-version = "" +hermit = false +no-git = false [global] [global.configuration] + CUSTOMER_TOKEN_TBD = "inline://InNvbWVDb25maWci" key = "inline://InZhbHVlIg" [modules] diff --git a/go-runtime/compile/build.go b/go-runtime/compile/build.go index f0d1e60382..638a89b4f5 100644 --- a/go-runtime/compile/build.go +++ b/go-runtime/compile/build.go @@ -267,7 +267,7 @@ func CleanStubs(ctx context.Context, projectRoot string) error { // GenerateStubsForModules generates stubs for all modules in the schema. func GenerateStubsForModules(ctx context.Context, projectRoot string, moduleConfigs []moduleconfig.ModuleConfig, sch *schema.Schema) error { logger := log.FromContext(ctx) - logger.Debugf("Generating module stubs") + logger.Debugf("Generating go module stubs") sharedFtlDir := filepath.Join(projectRoot, buildDirName) @@ -275,6 +275,15 @@ func GenerateStubsForModules(ctx context.Context, projectRoot string, moduleConf if ftl.IsRelease(ftl.Version) { ftlVersion = ftl.Version } + hasGo := false + for _, mc := range moduleConfigs { + if mc.Language == "go" && mc.Module != "builtin" { + hasGo = true + } + } + if !hasGo { + return nil + } for _, module := range sch.Modules { var moduleConfig *moduleconfig.ModuleConfig @@ -292,12 +301,18 @@ func GenerateStubsForModules(ctx context.Context, projectRoot string, moduleConf // If there's no module config, use the go.mod file for the first config we find. if moduleConfig == nil { - if len(moduleConfigs) > 0 { - _, goModVersion, err = updateGoModule(filepath.Join(moduleConfigs[0].Dir, "go.mod")) + for _, mod := range moduleConfigs { + if mod.Language != "go" { + continue + } + goModPath := filepath.Join(mod.Dir, "go.mod") + _, goModVersion, err = updateGoModule(goModPath) if err != nil { - return err + logger.Debugf("could not read go.mod %s", goModPath) + continue } - } else { + } + if goModVersion == "" { // The best we can do here if we don't have a module to read from is to use the current Go version. goModVersion = runtime.Version()[2:] } diff --git a/go-runtime/schema/common/directive.go b/go-runtime/schema/common/directive.go index 64db1e164d..ad5c37d92f 100644 --- a/go-runtime/schema/common/directive.go +++ b/go-runtime/schema/common/directive.go @@ -303,7 +303,7 @@ func (d *DirectiveExport) IsExported() bool { type DirectiveTypeMap struct { Pos token.Pos - Runtime string `parser:"'typemap' @('go' | 'kotlin')"` + Runtime string `parser:"'typemap' @('go' | 'kotlin' | 'java')"` NativeName string `parser:"@String"` } diff --git a/java-runtime/.gitignore b/java-runtime/.gitignore new file mode 100644 index 0000000000..08b1802abf --- /dev/null +++ b/java-runtime/.gitignore @@ -0,0 +1,6 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build +generated \ No newline at end of file diff --git a/java-runtime/README.md b/java-runtime/README.md new file mode 100644 index 0000000000..ffe6385512 --- /dev/null +++ b/java-runtime/README.md @@ -0,0 +1,22 @@ +# FTL Java Runtime + +This contains the code for the FTL Java runtime environment. + +## Tips + +### Debugging Maven commands with IntelliJ + +The Java runtime is built and packaged using Maven. If you would like to debug Maven commands using Intellij: + +1. Click `Run->Edit Configurations...` to bring up the run configurations window. + +2. Hit `+` to add a new configuration and select `Remove JVM Debug`. Provide the following configurations and save: +- `Debugger Mode`: `Attach to remote JVM` +- `Host`: `localhost` +- `Port`: `8000` +- `Command line arguments for remote JVM`: `-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000` + +3. Run any `mvn` command, substituting `mvnDebug` in place of `mvn` (e.g. `mvnDebug compile`). This command should hang in the terminal, awaiting your remote debugger invocation. +4. Select the newly created debugger from the `Run / Debug Configurations` drop-down in the top right of your IntelliJ +window. With the debugger selected, hit the debug icon to run it. From here the `mvn` command will +execute, stopping at any breakpoints specified. \ No newline at end of file diff --git a/java-runtime/ftl-runtime/deployment/pom.xml b/java-runtime/ftl-runtime/deployment/pom.xml new file mode 100644 index 0000000000..db056f496e --- /dev/null +++ b/java-runtime/ftl-runtime/deployment/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + + xyz.block + ftl-java-runtime-parent + 1.0.0-SNAPSHOT + + ftl-java-runtime-deployment + Ftl Java Runtime - Deployment + + + + io.quarkus + quarkus-arc-deployment + + + io.quarkus + quarkus-grpc-deployment + + + io.quarkus + quarkus-rest-jackson-deployment + + + + com.squareup + javapoet + + + + xyz.block + ftl-java-runtime + ${project.version} + + + io.quarkus + quarkus-junit5-internal + test + + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + diff --git a/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/FTLCodeGenerator.java b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/FTLCodeGenerator.java new file mode 100644 index 0000000000..4e3c4f7b59 --- /dev/null +++ b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/FTLCodeGenerator.java @@ -0,0 +1,349 @@ +package xyz.block.ftl.deployment; + +import java.io.IOException; +import java.lang.annotation.Retention; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Stream; + +import javax.lang.model.element.Modifier; + +import org.eclipse.microprofile.config.Config; +import org.jboss.logging.Logger; +import org.jetbrains.annotations.NotNull; + +import com.squareup.javapoet.AnnotationSpec; +import com.squareup.javapoet.ArrayTypeName; +import com.squareup.javapoet.ClassName; +import com.squareup.javapoet.JavaFile; +import com.squareup.javapoet.MethodSpec; +import com.squareup.javapoet.ParameterizedTypeName; +import com.squareup.javapoet.TypeName; +import com.squareup.javapoet.TypeSpec; +import com.squareup.javapoet.TypeVariableName; +import com.squareup.javapoet.WildcardTypeName; + +import io.quarkus.bootstrap.model.ApplicationModel; +import io.quarkus.bootstrap.prebuild.CodeGenException; +import io.quarkus.deployment.CodeGenContext; +import io.quarkus.deployment.CodeGenProvider; +import xyz.block.ftl.GeneratedRef; +import xyz.block.ftl.Subscription; +import xyz.block.ftl.VerbClient; +import xyz.block.ftl.VerbClientDefinition; +import xyz.block.ftl.VerbClientEmpty; +import xyz.block.ftl.VerbClientSink; +import xyz.block.ftl.VerbClientSource; +import xyz.block.ftl.v1.schema.Module; +import xyz.block.ftl.v1.schema.Type; + +public class FTLCodeGenerator implements CodeGenProvider { + + private static final Logger log = Logger.getLogger(FTLCodeGenerator.class); + + public static final String CLIENT = "Client"; + public static final String PACKAGE_PREFIX = "ftl."; + String moduleName; + + @Override + public void init(ApplicationModel model, Map properties) { + CodeGenProvider.super.init(model, properties); + moduleName = model.getAppArtifact().getArtifactId(); + } + + @Override + public String providerId() { + return "ftl-clients"; + } + + @Override + public String inputDirectory() { + return "ftl-module-schema"; + } + + @Override + public boolean trigger(CodeGenContext context) throws CodeGenException { + if (!Files.isDirectory(context.inputDir())) { + return false; + } + + List modules = new ArrayList<>(); + + Map typeAliasMap = new HashMap<>(); + + try (Stream pathStream = Files.list(context.inputDir())) { + for (var file : pathStream.toList()) { + String fileName = file.getFileName().toString(); + if (!fileName.endsWith(".pb")) { + continue; + } + var module = Module.parseFrom(Files.readAllBytes(file)); + for (var decl : module.getDeclsList()) { + if (decl.hasTypeAlias()) { + var data = decl.getTypeAlias(); + typeAliasMap.put(new Key(module.getName(), data.getName()), data.getType()); + } + } + modules.add(module); + } + } catch (IOException e) { + throw new CodeGenException(e); + } + try { + for (var module : modules) { + String packageName = PACKAGE_PREFIX + module.getName(); + for (var decl : module.getDeclsList()) { + if (decl.hasVerb()) { + var verb = decl.getVerb(); + if (!verb.getExport()) { + continue; + } + + TypeSpec.Builder typeBuilder = TypeSpec.interfaceBuilder(className(verb.getName()) + CLIENT) + .addAnnotation(AnnotationSpec.builder(VerbClientDefinition.class) + .addMember("name", "\"" + verb.getName() + "\"") + .addMember("module", "\"" + module.getName() + "\"") + .build()) + .addModifiers(Modifier.PUBLIC); + if (verb.getRequest().hasUnit() && verb.getResponse().hasUnit()) { + typeBuilder.addSuperinterface(ParameterizedTypeName.get(ClassName.get(VerbClientEmpty.class))); + } else if (verb.getRequest().hasUnit()) { + typeBuilder.addSuperinterface(ParameterizedTypeName.get(ClassName.get(VerbClientSource.class), + toJavaTypeName(verb.getResponse(), typeAliasMap))); + typeBuilder.addMethod(MethodSpec.methodBuilder("call") + .returns(toAnnotatedJavaTypeName(verb.getResponse(), typeAliasMap)) + .addModifiers(Modifier.ABSTRACT).addModifiers(Modifier.PUBLIC).build()); + } else if (verb.getResponse().hasUnit()) { + typeBuilder.addSuperinterface(ParameterizedTypeName.get(ClassName.get(VerbClientSink.class), + toJavaTypeName(verb.getRequest(), typeAliasMap))); + typeBuilder.addMethod(MethodSpec.methodBuilder("call").returns(TypeName.VOID) + .addParameter(toAnnotatedJavaTypeName(verb.getRequest(), typeAliasMap), "value") + .addModifiers(Modifier.ABSTRACT).addModifiers(Modifier.PUBLIC).build()); + } else { + typeBuilder.addSuperinterface(ParameterizedTypeName.get(ClassName.get(VerbClient.class), + toJavaTypeName(verb.getRequest(), typeAliasMap), + toJavaTypeName(verb.getResponse(), typeAliasMap))); + typeBuilder.addMethod(MethodSpec.methodBuilder("call") + .returns(toAnnotatedJavaTypeName(verb.getResponse(), typeAliasMap)) + .addParameter(toAnnotatedJavaTypeName(verb.getRequest(), typeAliasMap), "value") + .addModifiers(Modifier.ABSTRACT).addModifiers(Modifier.PUBLIC).build()); + } + + TypeSpec helloWorld = typeBuilder + .build(); + + JavaFile javaFile = JavaFile.builder(packageName, helloWorld) + .build(); + + javaFile.writeTo(context.outDir()); + + } else if (decl.hasData()) { + var data = decl.getData(); + if (!data.getExport()) { + continue; + } + String thisType = className(data.getName()); + TypeSpec.Builder dataBuilder = TypeSpec.classBuilder(thisType) + .addAnnotation( + AnnotationSpec.builder(GeneratedRef.class) + .addMember("name", "\"" + data.getName() + "\"") + .addMember("module", "\"" + module.getName() + "\"").build()) + .addModifiers(Modifier.PUBLIC); + MethodSpec.Builder allConstructor = MethodSpec.constructorBuilder().addModifiers(Modifier.PUBLIC); + + dataBuilder.addMethod(allConstructor.build()); + for (var param : data.getTypeParametersList()) { + dataBuilder.addTypeVariable(TypeVariableName.get(param.getName())); + } + Map sortedFields = new TreeMap<>(); + + for (var i : data.getFieldsList()) { + TypeName dataType = toAnnotatedJavaTypeName(i.getType(), typeAliasMap); + String name = i.getName(); + var fieldName = toJavaName(name); + dataBuilder.addField(dataType, fieldName, Modifier.PRIVATE); + sortedFields.put(fieldName, () -> { + allConstructor.addParameter(dataType, fieldName); + allConstructor.addCode("this.$L = $L;\n", fieldName, fieldName); + }); + String methodName = Character.toUpperCase(name.charAt(0)) + name.substring(1); + dataBuilder.addMethod(MethodSpec.methodBuilder("set" + methodName) + .addModifiers(Modifier.PUBLIC) + .addParameter(dataType, fieldName) + .returns(ClassName.get(packageName, thisType)) + .addCode("this.$L = $L;\n", fieldName, fieldName) + .addCode("return this;") + .build()); + if (i.getType().hasBool()) { + dataBuilder.addMethod(MethodSpec.methodBuilder("is" + methodName) + .addModifiers(Modifier.PUBLIC) + .returns(dataType) + .addCode("return $L;", fieldName) + .build()); + } else { + dataBuilder.addMethod(MethodSpec.methodBuilder("get" + methodName) + .addModifiers(Modifier.PUBLIC) + .returns(dataType) + .addCode("return $L;", fieldName) + .build()); + } + } + if (!sortedFields.isEmpty()) { + + for (var v : sortedFields.values()) { + v.run(); + } + dataBuilder.addMethod(allConstructor.build()); + + } + JavaFile javaFile = JavaFile.builder(packageName, dataBuilder.build()) + .build(); + + javaFile.writeTo(context.outDir()); + + } else if (decl.hasEnum()) { + var data = decl.getEnum(); + if (!data.getExport()) { + continue; + } + String thisType = className(data.getName()); + TypeSpec.Builder dataBuilder = TypeSpec.enumBuilder(thisType) + .addAnnotation( + AnnotationSpec.builder(GeneratedRef.class) + .addMember("name", "\"" + data.getName() + "\"") + .addMember("module", "\"" + module.getName() + "\"").build()) + .addModifiers(Modifier.PUBLIC); + + for (var i : data.getVariantsList()) { + dataBuilder.addEnumConstant(i.getName()); + } + + JavaFile javaFile = JavaFile.builder(packageName, dataBuilder.build()) + .build(); + + javaFile.writeTo(context.outDir()); + + } else if (decl.hasTopic()) { + var data = decl.getTopic(); + if (!data.getExport()) { + continue; + } + String thisType = className(data.getName() + "Subscription"); + + TypeSpec.Builder dataBuilder = TypeSpec.annotationBuilder(thisType) + .addModifiers(Modifier.PUBLIC); + if (data.getEvent().hasRef()) { + dataBuilder.addJavadoc("Subscription to the topic of type {@link $L}", + data.getEvent().getRef().getName()); + } + dataBuilder.addAnnotation(AnnotationSpec.builder(Retention.class) + .addMember("value", "java.lang.annotation.RetentionPolicy.RUNTIME").build()); + dataBuilder.addAnnotation(AnnotationSpec.builder(Subscription.class) + .addMember("topic", "\"" + data.getName() + "\"") + .addMember("module", "\"" + module.getName() + "\"") + .addMember("name", "\"" + data.getName() + "Subscription\"") + .build()); + + JavaFile javaFile = JavaFile.builder(packageName, dataBuilder.build()) + .build(); + + javaFile.writeTo(context.outDir()); + + } + } + } + + } catch (Exception e) { + throw new CodeGenException(e); + } + return true; + } + + private String toJavaName(String name) { + if (JAVA_KEYWORDS.contains(name)) { + return name + "_"; + } + return name; + } + + private TypeName toAnnotatedJavaTypeName(Type type, Map typeAliasMap) { + var results = toJavaTypeName(type, typeAliasMap); + if (type.hasRef() || type.hasArray() || type.hasBytes() || type.hasString() || type.hasMap() || type.hasTime()) { + return results.annotated(AnnotationSpec.builder(NotNull.class).build()); + } + return results; + } + + private TypeName toJavaTypeName(Type type, Map typeAliasMap) { + if (type.hasArray()) { + return ParameterizedTypeName.get(ClassName.get(List.class), + toJavaTypeName(type.getArray().getElement(), typeAliasMap)); + } else if (type.hasString()) { + return ClassName.get(String.class); + } else if (type.hasOptional()) { + return toJavaTypeName(type.getOptional().getType(), typeAliasMap); + } else if (type.hasRef()) { + if (type.getRef().getModule().isEmpty()) { + return TypeVariableName.get(type.getRef().getName()); + } + + Key key = new Key(type.getRef().getModule(), type.getRef().getName()); + if (typeAliasMap.containsKey(key)) { + return toJavaTypeName(typeAliasMap.get(key), typeAliasMap); + } + var params = type.getRef().getTypeParametersList(); + ClassName className = ClassName.get(PACKAGE_PREFIX + type.getRef().getModule(), type.getRef().getName()); + if (params.isEmpty()) { + return className; + } + List javaTypes = params.stream() + .map(s -> s.hasUnit() ? WildcardTypeName.subtypeOf(Object.class) : toJavaTypeName(s, typeAliasMap)) + .toList(); + return ParameterizedTypeName.get(className, javaTypes.toArray(new TypeName[javaTypes.size()])); + } else if (type.hasMap()) { + return ParameterizedTypeName.get(ClassName.get(Map.class), toJavaTypeName(type.getMap().getKey(), typeAliasMap), + toJavaTypeName(type.getMap().getValue(), typeAliasMap)); + } else if (type.hasTime()) { + return ClassName.get(Instant.class); + } else if (type.hasInt()) { + return TypeName.LONG; + } else if (type.hasUnit()) { + return TypeName.VOID; + } else if (type.hasBool()) { + return TypeName.BOOLEAN; + } else if (type.hasFloat()) { + return TypeName.DOUBLE; + } else if (type.hasBytes()) { + return ArrayTypeName.of(TypeName.BYTE); + } else if (type.hasAny()) { + return TypeName.OBJECT; + } + + throw new RuntimeException("Cannot generate Java type name: " + type); + } + + @Override + public boolean shouldRun(Path sourceDir, Config config) { + return true; + } + + record Key(String module, String name) { + } + + static String className(String in) { + return Character.toUpperCase(in.charAt(0)) + in.substring(1); + } + + private static final Set JAVA_KEYWORDS = Set.of("abstract", "continue", "for", "new", "switch", "assert", + "default", "goto", "package", "synchronized", "boolean", "do", "if", "private", "this", "break", "double", + "implements", "protected", "throw", "byte", "else", "import", "public", "throws", "case", "enum", "instanceof", + "return", "transient", "catch", "extends", "int", "short", "try", "char", "final", "interface", "static", "void", + "class", "finally", "long", "strictfp", "volatile", "const", "float", "native", "super", "while"); +} diff --git a/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/FtlProcessor.java b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/FtlProcessor.java new file mode 100644 index 0000000000..529d4ed36e --- /dev/null +++ b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/FtlProcessor.java @@ -0,0 +1,696 @@ +package xyz.block.ftl.deployment; + +import java.io.IOException; +import java.lang.reflect.Modifier; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.ClassType; +import org.jboss.jandex.DotName; +import org.jboss.jandex.MethodInfo; +import org.jboss.jandex.VoidType; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.common.model.MethodParameter; +import org.jboss.resteasy.reactive.common.model.ParameterType; +import org.jboss.resteasy.reactive.server.core.parameters.ParameterExtractor; +import org.jboss.resteasy.reactive.server.mapping.URITemplate; +import org.jboss.resteasy.reactive.server.processor.scanning.MethodScanner; +import org.jetbrains.annotations.NotNull; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.processor.DotNames; +import io.quarkus.deployment.Capabilities; +import io.quarkus.deployment.Capability; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.ApplicationInfoBuildItem; +import io.quarkus.deployment.builditem.ApplicationStartBuildItem; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.LaunchModeBuildItem; +import io.quarkus.deployment.builditem.ShutdownContextBuildItem; +import io.quarkus.deployment.builditem.SystemPropertyBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; +import io.quarkus.deployment.pkg.builditem.OutputTargetBuildItem; +import io.quarkus.grpc.deployment.BindableServiceBuildItem; +import io.quarkus.netty.runtime.virtual.VirtualServerChannel; +import io.quarkus.resteasy.reactive.server.deployment.ResteasyReactiveResourceMethodEntriesBuildItem; +import io.quarkus.resteasy.reactive.server.spi.MethodScannerBuildItem; +import io.quarkus.vertx.core.deployment.CoreVertxBuildItem; +import io.quarkus.vertx.core.deployment.EventLoopCountBuildItem; +import io.quarkus.vertx.http.deployment.RequireVirtualHttpBuildItem; +import io.quarkus.vertx.http.deployment.WebsocketSubProtocolsBuildItem; +import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; +import io.quarkus.vertx.http.runtime.VertxHttpRecorder; +import xyz.block.ftl.Config; +import xyz.block.ftl.Cron; +import xyz.block.ftl.Export; +import xyz.block.ftl.GeneratedRef; +import xyz.block.ftl.Retry; +import xyz.block.ftl.Secret; +import xyz.block.ftl.Subscription; +import xyz.block.ftl.Verb; +import xyz.block.ftl.VerbName; +import xyz.block.ftl.runtime.FTLController; +import xyz.block.ftl.runtime.FTLHttpHandler; +import xyz.block.ftl.runtime.FTLRecorder; +import xyz.block.ftl.runtime.TopicHelper; +import xyz.block.ftl.runtime.VerbClientHelper; +import xyz.block.ftl.runtime.VerbHandler; +import xyz.block.ftl.runtime.VerbRegistry; +import xyz.block.ftl.runtime.builtin.HttpRequest; +import xyz.block.ftl.runtime.builtin.HttpResponse; +import xyz.block.ftl.v1.CallRequest; +import xyz.block.ftl.v1.schema.Array; +import xyz.block.ftl.v1.schema.Bool; +import xyz.block.ftl.v1.schema.Data; +import xyz.block.ftl.v1.schema.Decl; +import xyz.block.ftl.v1.schema.Field; +import xyz.block.ftl.v1.schema.Float; +import xyz.block.ftl.v1.schema.IngressPathComponent; +import xyz.block.ftl.v1.schema.IngressPathLiteral; +import xyz.block.ftl.v1.schema.IngressPathParameter; +import xyz.block.ftl.v1.schema.Int; +import xyz.block.ftl.v1.schema.Metadata; +import xyz.block.ftl.v1.schema.MetadataCalls; +import xyz.block.ftl.v1.schema.MetadataCronJob; +import xyz.block.ftl.v1.schema.MetadataIngress; +import xyz.block.ftl.v1.schema.MetadataRetry; +import xyz.block.ftl.v1.schema.MetadataSubscriber; +import xyz.block.ftl.v1.schema.Module; +import xyz.block.ftl.v1.schema.Optional; +import xyz.block.ftl.v1.schema.Ref; +import xyz.block.ftl.v1.schema.Time; +import xyz.block.ftl.v1.schema.Type; +import xyz.block.ftl.v1.schema.Unit; + +class FtlProcessor { + + private static final Logger log = Logger.getLogger(FtlProcessor.class); + + private static final String SCHEMA_OUT = "schema.pb"; + private static final String FEATURE = "ftl-java-runtime"; + public static final DotName EXPORT = DotName.createSimple(Export.class); + public static final DotName VERB = DotName.createSimple(Verb.class); + public static final DotName CRON = DotName.createSimple(Cron.class); + public static final DotName SUBSCRIPTION = DotName.createSimple(Subscription.class); + public static final String BUILTIN = "builtin"; + public static final DotName CONSUMER = DotName.createSimple(Consumer.class); + public static final DotName SECRET = DotName.createSimple(Secret.class); + public static final DotName CONFIG = DotName.createSimple(Config.class); + public static final DotName OFFSET_DATE_TIME = DotName.createSimple(OffsetDateTime.class.getName()); + public static final DotName GENERATED_REF = DotName.createSimple(GeneratedRef.class); + + @BuildStep + ModuleNameBuildItem moduleName(ApplicationInfoBuildItem applicationInfoBuildItem) { + return new ModuleNameBuildItem(applicationInfoBuildItem.getName()); + + } + + @BuildStep + FeatureBuildItem feature() { + return new FeatureBuildItem(FEATURE); + } + + @BuildStep + BindableServiceBuildItem verbService() { + var ret = new BindableServiceBuildItem(DotName.createSimple(VerbHandler.class)); + ret.registerBlockingMethod("call"); + ret.registerBlockingMethod("publishEvent"); + ret.registerBlockingMethod("sendFSMEvent"); + ret.registerBlockingMethod("acquireLease"); + ret.registerBlockingMethod("getModuleContext"); + ret.registerBlockingMethod("ping"); + return ret; + } + + @BuildStep + AdditionalBeanBuildItem beans() { + return AdditionalBeanBuildItem.builder() + .addBeanClasses(VerbHandler.class, VerbRegistry.class, FTLHttpHandler.class, FTLController.class, + TopicHelper.class, VerbClientHelper.class) + .setUnremovable().build(); + } + + @BuildStep + AdditionalBeanBuildItem verbBeans(CombinedIndexBuildItem index) { + + var beans = AdditionalBeanBuildItem.builder().setUnremovable(); + for (var verb : index.getIndex().getAnnotations(VERB)) { + beans.addBeanClasses(verb.target().asMethod().declaringClass().name().toString()); + } + return beans.build(); + } + + @BuildStep + public SystemPropertyBuildItem moduleNameConfig(ApplicationInfoBuildItem applicationInfoBuildItem) { + return new SystemPropertyBuildItem("ftl.module.name", applicationInfoBuildItem.getName()); + } + + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + public MethodScannerBuildItem methodScanners(TopicsBuildItem topics, + VerbClientBuildItem verbClients, FTLRecorder recorder) { + return new MethodScannerBuildItem(new MethodScanner() { + @Override + public ParameterExtractor handleCustomParameter(org.jboss.jandex.Type type, + Map annotations, boolean field, Map methodContext) { + try { + + if (annotations.containsKey(SECRET)) { + Class paramType = loadClass(type); + String name = annotations.get(SECRET).value().asString(); + return new VerbRegistry.SecretSupplier(name, paramType); + } else if (annotations.containsKey(CONFIG)) { + Class paramType = loadClass(type); + String name = annotations.get(CONFIG).value().asString(); + return new VerbRegistry.ConfigSupplier(name, paramType); + } else if (topics.getTopics().containsKey(type.name())) { + var topic = topics.getTopics().get(type.name()); + Class paramType = loadClass(type); + return recorder.topicParamExtractor(topic.generatedProducer()); + } else if (verbClients.getVerbClients().containsKey(type.name())) { + var client = verbClients.getVerbClients().get(type.name()); + Class paramType = loadClass(type); + return recorder.verbParamExtractor(client.generatedClient()); + } + return null; + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + }); + } + + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + public void registerVerbs(CombinedIndexBuildItem index, + FTLRecorder recorder, + OutputTargetBuildItem outputTargetBuildItem, + ResteasyReactiveResourceMethodEntriesBuildItem restEndpoints, + TopicsBuildItem topics, + VerbClientBuildItem verbClients, + ModuleNameBuildItem moduleNameBuildItem, + SubscriptionMetaAnnotationsBuildItem subscriptionMetaAnnotationsBuildItem) throws Exception { + String moduleName = moduleNameBuildItem.getModuleName(); + Module.Builder moduleBuilder = Module.newBuilder() + .setName(moduleName) + .setBuiltin(false); + Map dataElements = new HashMap<>(); + ExtractionContext extractionContext = new ExtractionContext(moduleName, index, recorder, moduleBuilder, dataElements, + new HashSet<>(), new HashSet<>(), topics.getTopics(), verbClients.getVerbClients()); + var beans = AdditionalBeanBuildItem.builder().setUnremovable(); + + //register all the topics we are defining in the module definition + + for (var topic : topics.getTopics().values()) { + extractionContext.moduleBuilder.addDecls(Decl.newBuilder().setTopic(xyz.block.ftl.v1.schema.Topic.newBuilder() + .setExport(topic.exported()) + .setName(topic.topicName()) + .setEvent(buildType(extractionContext, topic.eventType())).build())); + } + + handleVerbAnnotations(index, beans, extractionContext); + handleCronAnnotations(index, beans, extractionContext); + handleSubscriptionAnnotations(index, subscriptionMetaAnnotationsBuildItem, moduleName, moduleBuilder, extractionContext, + beans); + + //TODO: make this composable so it is not just one big method, build items should contribute to the schema + for (var endpoint : restEndpoints.getEntries()) { + //TODO: naming + var verbName = methodToName(endpoint.getMethodInfo()); + recorder.registerHttpIngress(moduleName, verbName); + + //TODO: handle type parameters properly + org.jboss.jandex.Type bodyParamType = VoidType.VOID; + MethodParameter[] parameters = endpoint.getResourceMethod().getParameters(); + for (int i = 0, parametersLength = parameters.length; i < parametersLength; i++) { + var param = parameters[i]; + if (param.parameterType.equals(ParameterType.BODY)) { + bodyParamType = endpoint.getMethodInfo().parameterType(i); + break; + } + } + + StringBuilder pathBuilder = new StringBuilder(); + if (endpoint.getBasicResourceClassInfo().getPath() != null) { + pathBuilder.append(endpoint.getBasicResourceClassInfo().getPath()); + } + if (endpoint.getResourceMethod().getPath() != null && !endpoint.getResourceMethod().getPath().isEmpty()) { + if (pathBuilder.charAt(pathBuilder.length() - 1) != '/' + && !endpoint.getResourceMethod().getPath().startsWith("/")) { + pathBuilder.append('/'); + } + pathBuilder.append(endpoint.getResourceMethod().getPath()); + } + String path = pathBuilder.toString(); + URITemplate template = new URITemplate(path, false); + List pathComponents = new ArrayList<>(); + for (var i : template.components) { + if (i.type == URITemplate.Type.CUSTOM_REGEX) { + throw new RuntimeException( + "Invalid path " + path + " on HTTP endpoint: " + endpoint.getActualClassInfo().name() + "." + + methodToName(endpoint.getMethodInfo()) + + " FTL does not support custom regular expressions"); + } else if (i.type == URITemplate.Type.LITERAL) { + if (i.literalText.equals("/")) { + continue; + } + pathComponents.add(IngressPathComponent.newBuilder() + .setIngressPathLiteral(IngressPathLiteral.newBuilder().setText(i.literalText.replace("/", ""))) + .build()); + } else { + pathComponents.add(IngressPathComponent.newBuilder() + .setIngressPathParameter(IngressPathParameter.newBuilder().setName(i.name.replace("/", ""))) + .build()); + } + } + + //TODO: process path properly + MetadataIngress.Builder ingressBuilder = MetadataIngress.newBuilder() + .setMethod(endpoint.getResourceMethod().getHttpMethod()); + for (var i : pathComponents) { + ingressBuilder.addPath(i); + } + Metadata ingressMetadata = Metadata.newBuilder() + .setIngress(ingressBuilder + .build()) + .build(); + Type requestTypeParam = buildType(extractionContext, bodyParamType); + Type responseTypeParam = buildType(extractionContext, endpoint.getMethodInfo().returnType()); + moduleBuilder + .addDecls(Decl.newBuilder().setVerb(xyz.block.ftl.v1.schema.Verb.newBuilder() + .addMetadata(ingressMetadata) + .setName(verbName) + .setExport(true) + .setRequest(Type.newBuilder() + .setRef(Ref.newBuilder().setModule(BUILTIN).setName(HttpRequest.class.getSimpleName()) + .addTypeParameters(requestTypeParam)) + .build()) + .setResponse(Type.newBuilder() + .setRef(Ref.newBuilder().setModule(BUILTIN).setName(HttpResponse.class.getSimpleName()) + .addTypeParameters(responseTypeParam) + .addTypeParameters(Type.newBuilder().setUnit(Unit.newBuilder()))) + .build())) + .build()); + } + + Path output = outputTargetBuildItem.getOutputDirectory().resolve(SCHEMA_OUT); + try (var out = Files.newOutputStream(output)) { + moduleBuilder.build().writeTo(out); + } + + output = outputTargetBuildItem.getOutputDirectory().resolve("main"); + try (var out = Files.newOutputStream(output)) { + out.write(""" + #!/bin/bash + exec java -jar quarkus-app/quarkus-run.jar""".getBytes(StandardCharsets.UTF_8)); + } + var perms = Files.getPosixFilePermissions(output); + EnumSet newPerms = EnumSet.copyOf(perms); + newPerms.add(PosixFilePermission.GROUP_EXECUTE); + newPerms.add(PosixFilePermission.OWNER_EXECUTE); + Files.setPosixFilePermissions(output, newPerms); + } + + private void handleVerbAnnotations(CombinedIndexBuildItem index, AdditionalBeanBuildItem.Builder beans, + ExtractionContext extractionContext) { + for (var verb : index.getIndex().getAnnotations(VERB)) { + boolean exported = verb.target().hasAnnotation(EXPORT); + var method = verb.target().asMethod(); + String className = method.declaringClass().name().toString(); + beans.addBeanClass(className); + + handleVerbMethod(extractionContext, method, className, exported, BodyType.ALLOWED, null); + } + } + + private void handleSubscriptionAnnotations(CombinedIndexBuildItem index, + SubscriptionMetaAnnotationsBuildItem subscriptionMetaAnnotationsBuildItem, String moduleName, + Module.Builder moduleBuilder, ExtractionContext extractionContext, AdditionalBeanBuildItem.Builder beans) { + for (var subscription : index.getIndex().getAnnotations(SUBSCRIPTION)) { + var info = SubscriptionMetaAnnotationsBuildItem.fromJandex(subscription, moduleName); + if (subscription.target().kind() != AnnotationTarget.Kind.METHOD) { + continue; + } + var method = subscription.target().asMethod(); + String className = method.declaringClass().name().toString(); + generateSubscription(moduleBuilder, extractionContext, beans, method, className, info); + } + for (var metaSub : subscriptionMetaAnnotationsBuildItem.getAnnotations().entrySet()) { + for (var subscription : index.getIndex().getAnnotations(metaSub.getKey())) { + if (subscription.target().kind() != AnnotationTarget.Kind.METHOD) { + log.warnf("Subscription annotation on non-method target: %s", subscription.target()); + continue; + } + var method = subscription.target().asMethod(); + generateSubscription(moduleBuilder, extractionContext, beans, method, + method.declaringClass().name().toString(), + metaSub.getValue()); + } + + } + } + + private void handleCronAnnotations(CombinedIndexBuildItem index, AdditionalBeanBuildItem.Builder beans, + ExtractionContext extractionContext) { + for (var cron : index.getIndex().getAnnotations(CRON)) { + var method = cron.target().asMethod(); + String className = method.declaringClass().name().toString(); + beans.addBeanClass(className); + handleVerbMethod(extractionContext, method, className, false, BodyType.DISALLOWED, (builder -> { + builder.addMetadata(Metadata.newBuilder() + .setCronJob(MetadataCronJob.newBuilder().setCron(cron.value().asString())).build()); + })); + } + } + + private void generateSubscription(Module.Builder moduleBuilder, ExtractionContext extractionContext, + AdditionalBeanBuildItem.Builder beans, MethodInfo method, String className, + SubscriptionMetaAnnotationsBuildItem.SubscriptionAnnotation info) { + beans.addBeanClass(className); + moduleBuilder.addDecls(Decl.newBuilder().setSubscription(xyz.block.ftl.v1.schema.Subscription.newBuilder() + .setName(info.name()).setTopic(Ref.newBuilder().setName(info.topic()).setModule(info.module()).build())) + .build()); + handleVerbMethod(extractionContext, method, className, false, BodyType.REQUIRED, (builder -> { + builder.addMetadata(Metadata.newBuilder().setSubscriber(MetadataSubscriber.newBuilder().setName(info.name()))); + if (method.hasAnnotation(Retry.class)) { + RetryRecord retry = RetryRecord.fromJandex(method.annotation(Retry.class), extractionContext.moduleName); + + MetadataRetry.Builder retryBuilder = MetadataRetry.newBuilder(); + if (!retry.catchVerb().isEmpty()) { + retryBuilder.setCatch(Ref.newBuilder().setModule(retry.catchModule()) + .setName(retry.catchVerb()).build()); + } + retryBuilder.setCount(retry.count()) + .setMaxBackoff(retry.maxBackoff()) + .setMinBackoff(retry.minBackoff()); + builder.addMetadata(Metadata.newBuilder().setRetry(retryBuilder).build()); + } + })); + } + + private void handleVerbMethod(ExtractionContext context, MethodInfo method, String className, + boolean exported, BodyType bodyType, Consumer metadataCallback) { + try { + List> parameterTypes = new ArrayList<>(); + List> paramMappers = new ArrayList<>(); + org.jboss.jandex.Type bodyParamType = null; + xyz.block.ftl.v1.schema.Verb.Builder verbBuilder = xyz.block.ftl.v1.schema.Verb.newBuilder(); + String verbName = methodToName(method); + MetadataCalls.Builder callsMetadata = MetadataCalls.newBuilder(); + for (var param : method.parameters()) { + if (param.hasAnnotation(Secret.class)) { + Class paramType = loadClass(param.type()); + parameterTypes.add(paramType); + String name = param.annotation(Secret.class).value().asString(); + paramMappers.add(new VerbRegistry.SecretSupplier(name, paramType)); + if (!context.knownSecrets.contains(name)) { + context.moduleBuilder.addDecls(Decl.newBuilder().setSecret(xyz.block.ftl.v1.schema.Secret.newBuilder() + .setType(buildType(context, param.type())).setName(name))); + context.knownSecrets.add(name); + } + } else if (param.hasAnnotation(Config.class)) { + Class paramType = loadClass(param.type()); + parameterTypes.add(paramType); + String name = param.annotation(Config.class).value().asString(); + paramMappers.add(new VerbRegistry.ConfigSupplier(name, paramType)); + if (!context.knownConfig.contains(name)) { + context.moduleBuilder.addDecls(Decl.newBuilder().setConfig(xyz.block.ftl.v1.schema.Config.newBuilder() + .setType(buildType(context, param.type())).setName(name))); + context.knownConfig.add(name); + } + } else if (context.knownTopics.containsKey(param.type().name())) { + var topic = context.knownTopics.get(param.type().name()); + Class paramType = loadClass(param.type()); + parameterTypes.add(paramType); + paramMappers.add(context.recorder().topicSupplier(topic.generatedProducer(), verbName)); + } else if (context.verbClients.containsKey(param.type().name())) { + var client = context.verbClients.get(param.type().name()); + Class paramType = loadClass(param.type()); + parameterTypes.add(paramType); + paramMappers.add(context.recorder().verbClientSupplier(client.generatedClient())); + callsMetadata.addCalls(Ref.newBuilder().setName(client.name()).setModule(client.module()).build()); + } else if (bodyType != BodyType.DISALLOWED && bodyParamType == null) { + bodyParamType = param.type(); + Class paramType = loadClass(param.type()); + parameterTypes.add(paramType); + //TODO: map and list types + paramMappers.add(new VerbRegistry.BodySupplier(paramType)); + } else { + throw new RuntimeException("Unknown parameter type " + param.type() + " on FTL method: " + + method.declaringClass().name() + "." + method.name()); + } + } + if (bodyParamType == null) { + if (bodyType == BodyType.REQUIRED) { + throw new RuntimeException("Missing required payload parameter"); + } + bodyParamType = VoidType.VOID; + } + if (callsMetadata.getCallsCount() > 0) { + verbBuilder.addMetadata(Metadata.newBuilder().setCalls(callsMetadata)); + } + + context.recorder.registerVerb(context.moduleName(), verbName, method.name(), parameterTypes, + Class.forName(className, false, Thread.currentThread().getContextClassLoader()), paramMappers); + verbBuilder + .setName(verbName) + .setExport(exported) + .setRequest(buildType(context, bodyParamType)) + .setResponse(buildType(context, method.returnType())); + + if (metadataCallback != null) { + metadataCallback.accept(verbBuilder); + } + context.moduleBuilder + .addDecls(Decl.newBuilder().setVerb(verbBuilder) + .build()); + + } catch (Exception e) { + throw new RuntimeException("Failed to process FTL method " + method.declaringClass().name() + "." + method.name(), + e); + } + } + + private static @NotNull String methodToName(MethodInfo method) { + if (method.hasAnnotation(VerbName.class)) { + return method.annotation(VerbName.class).value().asString(); + } + return method.name(); + } + + private static Class loadClass(org.jboss.jandex.Type param) throws ClassNotFoundException { + if (param.kind() == org.jboss.jandex.Type.Kind.PARAMETERIZED_TYPE) { + return Class.forName(param.asParameterizedType().name().toString(), false, + Thread.currentThread().getContextClassLoader()); + } else if (param.kind() == org.jboss.jandex.Type.Kind.CLASS) { + return Class.forName(param.name().toString(), false, Thread.currentThread().getContextClassLoader()); + } else if (param.kind() == org.jboss.jandex.Type.Kind.PRIMITIVE) { + switch (param.asPrimitiveType().primitive()) { + case BOOLEAN: + return Boolean.TYPE; + case BYTE: + return Byte.TYPE; + case SHORT: + return Short.TYPE; + case INT: + return Integer.TYPE; + case LONG: + return Long.TYPE; + case FLOAT: + return java.lang.Float.TYPE; + case DOUBLE: + return java.lang.Double.TYPE; + case CHAR: + return Character.TYPE; + default: + throw new RuntimeException("Unknown primitive type " + param.asPrimitiveType().primitive()); + } + } else { + throw new RuntimeException("Unknown type " + param.kind()); + } + } + + /** + * This is a huge hack that is needed until Quarkus supports both virtual and socket based HTTP + */ + @Record(ExecutionTime.RUNTIME_INIT) + @BuildStep + void openSocket(ApplicationStartBuildItem start, + LaunchModeBuildItem launchMode, + CoreVertxBuildItem vertx, + ShutdownContextBuildItem shutdown, + BuildProducer reflectiveClass, + HttpBuildTimeConfig httpBuildTimeConfig, + java.util.Optional requireVirtual, + EventLoopCountBuildItem eventLoopCount, + List websocketSubProtocols, + Capabilities capabilities, + VertxHttpRecorder recorder) throws IOException { + reflectiveClass + .produce(ReflectiveClassBuildItem.builder(VirtualServerChannel.class) + .build()); + recorder.startServer(vertx.getVertx(), shutdown, + launchMode.getLaunchMode(), true, false, + eventLoopCount.getEventLoopCount(), + websocketSubProtocols.stream().map(bi -> bi.getWebsocketSubProtocols()) + .collect(Collectors.toList()), + launchMode.isAuxiliaryApplication(), !capabilities.isPresent(Capability.VERTX_WEBSOCKETS)); + } + + private Type buildType(ExtractionContext context, org.jboss.jandex.Type type) { + switch (type.kind()) { + case PRIMITIVE -> { + var prim = type.asPrimitiveType(); + switch (prim.primitive()) { + case INT, LONG, BYTE, SHORT -> { + return Type.newBuilder().setInt(Int.newBuilder().build()).build(); + } + case FLOAT, DOUBLE -> { + return Type.newBuilder().setFloat(Float.newBuilder().build()).build(); + } + case BOOLEAN -> { + return Type.newBuilder().setBool(Bool.newBuilder().build()).build(); + } + case CHAR -> { + return Type.newBuilder().setString(xyz.block.ftl.v1.schema.String.newBuilder().build()).build(); + } + default -> throw new RuntimeException("unknown primitive type: " + prim.primitive()); + } + } + case VOID -> { + return Type.newBuilder().setUnit(Unit.newBuilder().build()).build(); + } + case ARRAY -> { + return Type.newBuilder() + .setArray(Array.newBuilder().setElement(buildType(context, type.asArrayType().componentType())).build()) + .build(); + } + case CLASS -> { + var clazz = type.asClassType(); + var info = context.index().getComputingIndex().getClassByName(clazz.name()); + if (info != null && info.hasDeclaredAnnotation(GENERATED_REF)) { + var ref = info.declaredAnnotation(GENERATED_REF); + return Type.newBuilder() + .setRef(Ref.newBuilder().setName(ref.value("name").asString()) + .setModule(ref.value("module").asString())) + .build(); + } + if (clazz.name().equals(DotName.STRING_NAME)) { + return Type.newBuilder().setString(xyz.block.ftl.v1.schema.String.newBuilder().build()).build(); + } + if (clazz.name().equals(OFFSET_DATE_TIME)) { + return Type.newBuilder().setTime(Time.newBuilder().build()).build(); + } + var existing = context.dataElements.get(new TypeKey(clazz.name().toString(), List.of())); + if (existing != null) { + return Type.newBuilder().setRef(existing).build(); + } + Data.Builder data = Data.newBuilder(); + data.setName(clazz.name().local()); + data.setExport(type.hasAnnotation(EXPORT)); + buildDataElement(context, data, clazz.name()); + context.moduleBuilder.addDecls(Decl.newBuilder().setData(data).build()); + Ref ref = Ref.newBuilder().setName(data.getName()).setModule(context.moduleName).build(); + context.dataElements.put(new TypeKey(clazz.name().toString(), List.of()), ref); + return Type.newBuilder().setRef(ref).build(); + } + case PARAMETERIZED_TYPE -> { + var paramType = type.asParameterizedType(); + if (paramType.name().equals(DotName.createSimple(List.class))) { + return Type.newBuilder() + .setArray(Array.newBuilder().setElement(buildType(context, paramType.arguments().get(0)))).build(); + } else if (paramType.name().equals(DotName.createSimple(Map.class))) { + return Type.newBuilder().setMap(xyz.block.ftl.v1.schema.Map.newBuilder() + .setKey(buildType(context, paramType.arguments().get(0))) + .setValue(buildType(context, paramType.arguments().get(0)))) + .build(); + } else if (paramType.name().equals(DotNames.OPTIONAL)) { + return Type.newBuilder() + .setOptional(Optional.newBuilder().setType(buildType(context, paramType.arguments().get(0)))) + .build(); + } else if (paramType.name().equals(DotName.createSimple(HttpRequest.class))) { + return Type.newBuilder() + .setRef(Ref.newBuilder().setModule(BUILTIN).setName(HttpRequest.class.getSimpleName()) + .addTypeParameters(buildType(context, paramType.arguments().get(0)))) + .build(); + } else if (paramType.name().equals(DotName.createSimple(HttpResponse.class))) { + return Type.newBuilder() + .setRef(Ref.newBuilder().setModule(BUILTIN).setName(HttpResponse.class.getSimpleName()) + .addTypeParameters(buildType(context, paramType.arguments().get(0))) + .addTypeParameters(Type.newBuilder().setUnit(Unit.newBuilder().build()))) + .build(); + } else { + ClassInfo classByName = context.index().getComputingIndex().getClassByName(paramType.name()); + var cb = ClassType.builder(classByName.name()); + var main = buildType(context, cb.build()); + var builder = main.toBuilder(); + var refBuilder = builder.getRef().toBuilder(); + + for (var arg : paramType.arguments()) { + refBuilder.addTypeParameters(buildType(context, arg)); + } + builder.setRef(refBuilder); + return builder.build(); + } + } + } + + throw new RuntimeException("NOT YET IMPLEMENTED"); + } + + private void buildDataElement(ExtractionContext context, Data.Builder data, DotName className) { + if (className == null || className.equals(DotName.OBJECT_NAME)) { + return; + } + var clazz = context.index.getComputingIndex().getClassByName(className); + if (clazz == null) { + return; + } + //TODO: handle getters and setters properly, also Jackson annotations etc + for (var field : clazz.fields()) { + if (!Modifier.isStatic(field.flags())) { + data.addFields(Field.newBuilder().setName(field.name()).setType(buildType(context, field.type())).build()); + } + } + buildDataElement(context, data, clazz.superName()); + } + + private record TypeKey(String name, List typeParams) { + + } + + record ExtractionContext(String moduleName, CombinedIndexBuildItem index, FTLRecorder recorder, + Module.Builder moduleBuilder, + Map dataElements, Set knownSecrets, Set knownConfig, + Map knownTopics, + Map verbClients) { + } + + enum BodyType { + DISALLOWED, + ALLOWED, + REQUIRED + } +} diff --git a/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/ModuleNameBuildItem.java b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/ModuleNameBuildItem.java new file mode 100644 index 0000000000..c47c10bcf5 --- /dev/null +++ b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/ModuleNameBuildItem.java @@ -0,0 +1,16 @@ +package xyz.block.ftl.deployment; + +import io.quarkus.builder.item.SimpleBuildItem; + +public final class ModuleNameBuildItem extends SimpleBuildItem { + + final String moduleName; + + public ModuleNameBuildItem(String moduleName) { + this.moduleName = moduleName; + } + + public String getModuleName() { + return moduleName; + } +} diff --git a/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/RetryRecord.java b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/RetryRecord.java new file mode 100644 index 0000000000..8cf3621b68 --- /dev/null +++ b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/RetryRecord.java @@ -0,0 +1,19 @@ +package xyz.block.ftl.deployment; + +import org.jboss.jandex.AnnotationInstance; + +public record RetryRecord(int count, String minBackoff, String maxBackoff, String catchModule, String catchVerb) { + + public static RetryRecord fromJandex(AnnotationInstance nested, String currentModuleName) { + return new RetryRecord( + nested.value("count") != null ? nested.value("count").asInt() : 0, + nested.value("minBackoff") != null ? nested.value("minBackoff").asString() : "", + nested.value("maxBackoff") != null ? nested.value("maxBackoff").asString() : "", + nested.value("catchModule") != null ? nested.value("catchModule").asString() : currentModuleName, + nested.value("catchVerb") != null ? nested.value("catchVerb").asString() : ""); + } + + public boolean isEmpty() { + return count == 0 && minBackoff.isEmpty() && maxBackoff.isEmpty() && catchVerb.isEmpty(); + } +} diff --git a/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/SubscriptionMetaAnnotationsBuildItem.java b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/SubscriptionMetaAnnotationsBuildItem.java new file mode 100644 index 0000000000..83e2d35cf5 --- /dev/null +++ b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/SubscriptionMetaAnnotationsBuildItem.java @@ -0,0 +1,35 @@ +package xyz.block.ftl.deployment; + +import java.util.Map; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationValue; +import org.jboss.jandex.DotName; + +import io.quarkus.builder.item.SimpleBuildItem; + +public final class SubscriptionMetaAnnotationsBuildItem extends SimpleBuildItem { + + private final Map annotations; + + public SubscriptionMetaAnnotationsBuildItem(Map annotations) { + this.annotations = annotations; + } + + public Map getAnnotations() { + return annotations; + } + + public record SubscriptionAnnotation(String module, String topic, String name) { + } + + public static SubscriptionAnnotation fromJandex(AnnotationInstance subscriptions, String currentModuleName) { + AnnotationValue moduleValue = subscriptions.value("module"); + + return new SubscriptionMetaAnnotationsBuildItem.SubscriptionAnnotation( + moduleValue == null || moduleValue.asString().isEmpty() ? currentModuleName + : moduleValue.asString(), + subscriptions.value("topic").asString(), + subscriptions.value("name").asString()); + } +} diff --git a/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/TopicsBuildItem.java b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/TopicsBuildItem.java new file mode 100644 index 0000000000..fcbbd78275 --- /dev/null +++ b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/TopicsBuildItem.java @@ -0,0 +1,26 @@ +package xyz.block.ftl.deployment; + +import java.util.HashMap; +import java.util.Map; + +import org.jboss.jandex.DotName; +import org.jboss.jandex.Type; + +import io.quarkus.builder.item.SimpleBuildItem; + +public final class TopicsBuildItem extends SimpleBuildItem { + + final Map topics; + + public TopicsBuildItem(Map topics) { + this.topics = new HashMap<>(topics); + } + + public Map getTopics() { + return topics; + } + + public record DiscoveredTopic(String topicName, String generatedProducer, Type eventType, boolean exported) { + + } +} diff --git a/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/TopicsProcessor.java b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/TopicsProcessor.java new file mode 100644 index 0000000000..367393890a --- /dev/null +++ b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/TopicsProcessor.java @@ -0,0 +1,97 @@ +package xyz.block.ftl.deployment; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.DotName; +import org.jboss.jandex.Type; + +import io.quarkus.deployment.GeneratedClassGizmoAdaptor; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.deployment.builditem.GeneratedClassBuildItem; +import io.quarkus.gizmo.ClassCreator; +import io.quarkus.gizmo.MethodDescriptor; +import xyz.block.ftl.Export; +import xyz.block.ftl.Subscription; +import xyz.block.ftl.Topic; +import xyz.block.ftl.TopicDefinition; +import xyz.block.ftl.runtime.TopicHelper; + +public class TopicsProcessor { + + public static final DotName TOPIC = DotName.createSimple(Topic.class); + + @BuildStep + TopicsBuildItem handleTopics(CombinedIndexBuildItem index, BuildProducer generatedTopicProducer) { + var topicDefinitions = index.getComputingIndex().getAnnotations(TopicDefinition.class); + Map topics = new HashMap<>(); + Set names = new HashSet<>(); + for (var topicDefinition : topicDefinitions) { + var iface = topicDefinition.target().asClass(); + if (!iface.isInterface()) { + throw new RuntimeException( + "@TopicDefinition can only be applied to interfaces " + iface.name() + " is not an interface"); + } + Type paramType = null; + for (var i : iface.interfaceTypes()) { + if (i.name().equals(TOPIC)) { + if (i.kind() == Type.Kind.PARAMETERIZED_TYPE) { + paramType = i.asParameterizedType().arguments().get(0); + } + } + + } + if (paramType == null) { + throw new RuntimeException("@TopicDefinition can only be applied to interfaces that directly extend " + TOPIC + + " with a concrete type parameter " + iface.name() + " does not extend this interface"); + } + + String name = topicDefinition.value("name").asString(); + if (names.contains(name)) { + throw new RuntimeException("Multiple topic definitions found for topic " + name); + } + names.add(name); + try (ClassCreator cc = new ClassCreator(new GeneratedClassGizmoAdaptor(generatedTopicProducer, true), + iface.name().toString() + "_fit_topic", null, Object.class.getName(), iface.name().toString())) { + var verb = cc.getFieldCreator("verb", String.class); + var constructor = cc.getConstructorCreator(String.class); + constructor.invokeSpecialMethod(MethodDescriptor.ofMethod(Object.class, "", void.class), + constructor.getThis()); + constructor.writeInstanceField(verb.getFieldDescriptor(), constructor.getThis(), constructor.getMethodParam(0)); + constructor.returnVoid(); + var publish = cc.getMethodCreator("publish", void.class, Object.class); + var helper = publish + .invokeStaticMethod(MethodDescriptor.ofMethod(TopicHelper.class, "instance", TopicHelper.class)); + publish.invokeVirtualMethod( + MethodDescriptor.ofMethod(TopicHelper.class, "publish", void.class, String.class, String.class, + Object.class), + helper, publish.load(name), publish.readInstanceField(verb.getFieldDescriptor(), publish.getThis()), + publish.getMethodParam(0)); + publish.returnVoid(); + topics.put(iface.name(), new TopicsBuildItem.DiscoveredTopic(name, cc.getClassName(), paramType, + iface.hasAnnotation(Export.class))); + } + } + return new TopicsBuildItem(topics); + } + + @BuildStep + SubscriptionMetaAnnotationsBuildItem subscriptionAnnotations(CombinedIndexBuildItem combinedIndexBuildItem, + ModuleNameBuildItem moduleNameBuildItem) { + + Map annotations = new HashMap<>(); + for (var subscriptions : combinedIndexBuildItem.getComputingIndex().getAnnotations(Subscription.class)) { + if (subscriptions.target().kind() != AnnotationTarget.Kind.CLASS) { + continue; + } + annotations.put(subscriptions.target().asClass().name(), + SubscriptionMetaAnnotationsBuildItem.fromJandex(subscriptions, moduleNameBuildItem.getModuleName())); + } + return new SubscriptionMetaAnnotationsBuildItem(annotations); + } +} diff --git a/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/VerbClientBuildItem.java b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/VerbClientBuildItem.java new file mode 100644 index 0000000000..669e005bf5 --- /dev/null +++ b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/VerbClientBuildItem.java @@ -0,0 +1,25 @@ +package xyz.block.ftl.deployment; + +import java.util.HashMap; +import java.util.Map; + +import org.jboss.jandex.DotName; + +import io.quarkus.builder.item.SimpleBuildItem; + +public final class VerbClientBuildItem extends SimpleBuildItem { + + final Map verbClients; + + public VerbClientBuildItem(Map verbClients) { + this.verbClients = new HashMap<>(verbClients); + } + + public Map getVerbClients() { + return verbClients; + } + + public record DiscoveredClients(String name, String module, String generatedClient) { + + } +} diff --git a/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/VerbClientsProcessor.java b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/VerbClientsProcessor.java new file mode 100644 index 0000000000..7d590a9a0f --- /dev/null +++ b/java-runtime/ftl-runtime/deployment/src/main/java/xyz/block/ftl/deployment/VerbClientsProcessor.java @@ -0,0 +1,212 @@ +package xyz.block.ftl.deployment; + +import java.util.HashMap; +import java.util.Map; + +import jakarta.inject.Singleton; + +import org.jboss.jandex.AnnotationValue; +import org.jboss.jandex.DotName; +import org.jboss.jandex.Type; + +import io.quarkus.arc.deployment.GeneratedBeanBuildItem; +import io.quarkus.arc.deployment.GeneratedBeanGizmoAdaptor; +import io.quarkus.deployment.GeneratedClassGizmoAdaptor; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.deployment.builditem.GeneratedClassBuildItem; +import io.quarkus.deployment.builditem.LaunchModeBuildItem; +import io.quarkus.gizmo.ClassCreator; +import io.quarkus.gizmo.ClassOutput; +import io.quarkus.gizmo.MethodDescriptor; +import xyz.block.ftl.VerbClient; +import xyz.block.ftl.VerbClientDefinition; +import xyz.block.ftl.VerbClientEmpty; +import xyz.block.ftl.VerbClientSink; +import xyz.block.ftl.VerbClientSource; +import xyz.block.ftl.runtime.VerbClientHelper; + +public class VerbClientsProcessor { + + public static final DotName VERB_CLIENT = DotName.createSimple(VerbClient.class); + public static final DotName VERB_CLIENT_SINK = DotName.createSimple(VerbClientSink.class); + public static final DotName VERB_CLIENT_SOURCE = DotName.createSimple(VerbClientSource.class); + public static final DotName VERB_CLIENT_EMPTY = DotName.createSimple(VerbClientEmpty.class); + public static final String TEST_ANNOTATION = "xyz.block.ftl.java.test.FTLManaged"; + + @BuildStep + VerbClientBuildItem handleTopics(CombinedIndexBuildItem index, BuildProducer generatedClients, + BuildProducer generatedBeanBuildItemBuildProducer, + ModuleNameBuildItem moduleNameBuildItem, + LaunchModeBuildItem launchModeBuildItem) { + var clientDefinitions = index.getComputingIndex().getAnnotations(VerbClientDefinition.class); + Map clients = new HashMap<>(); + for (var clientDefinition : clientDefinitions) { + var iface = clientDefinition.target().asClass(); + if (!iface.isInterface()) { + throw new RuntimeException( + "@VerbClientDefinition can only be applied to interfaces and " + iface.name() + " is not an interface"); + } + String name = clientDefinition.value("name").asString(); + AnnotationValue moduleValue = clientDefinition.value("module"); + String module = moduleValue == null || moduleValue.asString().isEmpty() ? moduleNameBuildItem.getModuleName() + : moduleValue.asString(); + boolean found = false; + ClassOutput classOutput; + if (launchModeBuildItem.isTest()) { + //when running in tests we actually make these beans, so they can be injected into the tests + //the @TestResource qualifier is used so they can only be injected into test code + //TODO: is this the best way of handling this? revisit later + + classOutput = new GeneratedBeanGizmoAdaptor(generatedBeanBuildItemBuildProducer); + } else { + classOutput = new GeneratedClassGizmoAdaptor(generatedClients, true); + } + //TODO: map and list return types + for (var i : iface.interfaceTypes()) { + if (i.name().equals(VERB_CLIENT)) { + if (i.kind() == Type.Kind.PARAMETERIZED_TYPE) { + var returnType = i.asParameterizedType().arguments().get(1); + var paramType = i.asParameterizedType().arguments().get(0); + try (ClassCreator cc = new ClassCreator(classOutput, iface.name().toString() + "_fit_verbclient", null, + Object.class.getName(), iface.name().toString())) { + if (launchModeBuildItem.isTest()) { + cc.addAnnotation(TEST_ANNOTATION); + cc.addAnnotation(Singleton.class); + } + var publish = cc.getMethodCreator("call", returnType.name().toString(), + paramType.name().toString()); + var helper = publish.invokeStaticMethod( + MethodDescriptor.ofMethod(VerbClientHelper.class, "instance", VerbClientHelper.class)); + var results = publish.invokeVirtualMethod( + MethodDescriptor.ofMethod(VerbClientHelper.class, "call", Object.class, String.class, + String.class, Object.class, Class.class, boolean.class, boolean.class), + helper, publish.load(name), publish.load(module), publish.getMethodParam(0), + publish.loadClass(returnType.name().toString()), publish.load(false), publish.load(false)); + publish.returnValue(results); + publish = cc.getMethodCreator("call", Object.class, Object.class); + helper = publish.invokeStaticMethod( + MethodDescriptor.ofMethod(VerbClientHelper.class, "instance", VerbClientHelper.class)); + results = publish.invokeVirtualMethod( + MethodDescriptor.ofMethod(VerbClientHelper.class, "call", Object.class, String.class, + String.class, Object.class, Class.class, boolean.class, boolean.class), + helper, publish.load(name), publish.load(module), publish.getMethodParam(0), + publish.loadClass(returnType.name().toString()), publish.load(false), publish.load(false)); + publish.returnValue(results); + clients.put(iface.name(), + new VerbClientBuildItem.DiscoveredClients(name, module, cc.getClassName())); + } + found = true; + break; + } else { + throw new RuntimeException( + "@VerbClientDefinition can only be applied to interfaces that directly extend a verb client type with concrete type parameters and " + + iface.name() + " does not have concrete type parameters"); + } + } else if (i.name().equals(VERB_CLIENT_SINK)) { + if (i.kind() == Type.Kind.PARAMETERIZED_TYPE) { + var paramType = i.asParameterizedType().arguments().get(0); + try (ClassCreator cc = new ClassCreator(classOutput, iface.name().toString() + "_fit_verbclient", null, + Object.class.getName(), iface.name().toString())) { + if (launchModeBuildItem.isTest()) { + cc.addAnnotation(TEST_ANNOTATION); + cc.addAnnotation(Singleton.class); + } + var publish = cc.getMethodCreator("call", void.class, paramType.name().toString()); + var helper = publish.invokeStaticMethod( + MethodDescriptor.ofMethod(VerbClientHelper.class, "instance", VerbClientHelper.class)); + publish.invokeVirtualMethod( + MethodDescriptor.ofMethod(VerbClientHelper.class, "call", Object.class, String.class, + String.class, Object.class, Class.class, boolean.class, boolean.class), + helper, publish.load(name), publish.load(module), publish.getMethodParam(0), + publish.loadClass(Void.class), publish.load(false), publish.load(false)); + publish.returnVoid(); + publish = cc.getMethodCreator("call", void.class, Object.class); + helper = publish.invokeStaticMethod( + MethodDescriptor.ofMethod(VerbClientHelper.class, "instance", VerbClientHelper.class)); + publish.invokeVirtualMethod( + MethodDescriptor.ofMethod(VerbClientHelper.class, "call", Object.class, String.class, + String.class, Object.class, Class.class, boolean.class, boolean.class), + helper, publish.load(name), publish.load(module), publish.getMethodParam(0), + publish.loadClass(Void.class), publish.load(false), publish.load(false)); + publish.returnVoid(); + clients.put(iface.name(), + new VerbClientBuildItem.DiscoveredClients(name, module, cc.getClassName())); + } + found = true; + break; + } else { + throw new RuntimeException( + "@VerbClientDefinition can only be applied to interfaces that directly extend a verb client type with concrete type parameters and " + + iface.name() + " does not have concrete type parameters"); + } + } else if (i.name().equals(VERB_CLIENT_SOURCE)) { + if (i.kind() == Type.Kind.PARAMETERIZED_TYPE) { + var returnType = i.asParameterizedType().arguments().get(0); + try (ClassCreator cc = new ClassCreator(classOutput, iface.name().toString() + "_fit_verbclient", null, + Object.class.getName(), iface.name().toString())) { + if (launchModeBuildItem.isTest()) { + cc.addAnnotation(TEST_ANNOTATION); + cc.addAnnotation(Singleton.class); + } + var publish = cc.getMethodCreator("call", returnType.name().toString()); + var helper = publish.invokeStaticMethod( + MethodDescriptor.ofMethod(VerbClientHelper.class, "instance", VerbClientHelper.class)); + var results = publish.invokeVirtualMethod( + MethodDescriptor.ofMethod(VerbClientHelper.class, "call", Object.class, String.class, + String.class, Object.class, Class.class, boolean.class, boolean.class), + helper, publish.load(name), publish.load(module), publish.loadNull(), + publish.loadClass(returnType.name().toString()), publish.load(false), publish.load(false)); + publish.returnValue(results); + + publish = cc.getMethodCreator("call", Object.class); + helper = publish.invokeStaticMethod( + MethodDescriptor.ofMethod(VerbClientHelper.class, "instance", VerbClientHelper.class)); + results = publish.invokeVirtualMethod( + MethodDescriptor.ofMethod(VerbClientHelper.class, "call", Object.class, String.class, + String.class, Object.class, Class.class, boolean.class, boolean.class), + helper, publish.load(name), publish.load(module), publish.loadNull(), + publish.loadClass(returnType.name().toString()), publish.load(false), publish.load(false)); + publish.returnValue(results); + clients.put(iface.name(), + new VerbClientBuildItem.DiscoveredClients(name, module, cc.getClassName())); + } + found = true; + break; + } else { + throw new RuntimeException( + "@VerbClientDefinition can only be applied to interfaces that directly extend a verb client type with concrete type parameters and " + + iface.name() + " does not have concrete type parameters"); + } + } else if (i.name().equals(VERB_CLIENT_EMPTY)) { + try (ClassCreator cc = new ClassCreator(classOutput, iface.name().toString() + "_fit_verbclient", null, + Object.class.getName(), iface.name().toString())) { + if (launchModeBuildItem.isTest()) { + cc.addAnnotation(TEST_ANNOTATION); + cc.addAnnotation(Singleton.class); + } + var publish = cc.getMethodCreator("call", void.class); + var helper = publish.invokeStaticMethod( + MethodDescriptor.ofMethod(VerbClientHelper.class, "instance", VerbClientHelper.class)); + publish.invokeVirtualMethod( + MethodDescriptor.ofMethod(VerbClientHelper.class, "call", Object.class, String.class, + String.class, Object.class, Class.class, boolean.class, boolean.class), + helper, publish.load(name), publish.load(module), publish.loadNull(), + publish.loadClass(Void.class), publish.load(false), publish.load(false)); + publish.returnVoid(); + clients.put(iface.name(), new VerbClientBuildItem.DiscoveredClients(name, module, cc.getClassName())); + } + found = true; + break; + } + } + if (!found) { + throw new RuntimeException( + "@VerbClientDefinition can only be applied to interfaces that directly extend a verb client type with concrete type parameters and " + + iface.name() + " does not extend a verb client type"); + } + } + return new VerbClientBuildItem(clients); + } +} diff --git a/java-runtime/ftl-runtime/deployment/src/main/resources/META-INF/services/io.quarkus.deployment.CodeGenProvider b/java-runtime/ftl-runtime/deployment/src/main/resources/META-INF/services/io.quarkus.deployment.CodeGenProvider new file mode 100644 index 0000000000..98be2f6c6f --- /dev/null +++ b/java-runtime/ftl-runtime/deployment/src/main/resources/META-INF/services/io.quarkus.deployment.CodeGenProvider @@ -0,0 +1 @@ +xyz.block.ftl.deployment.FTLCodeGenerator \ No newline at end of file diff --git a/java-runtime/ftl-runtime/deployment/src/test/java/xyz/block/ftl/java/runtime/test/FtlJavaRuntimeDevModeTest.java b/java-runtime/ftl-runtime/deployment/src/test/java/xyz/block/ftl/java/runtime/test/FtlJavaRuntimeDevModeTest.java new file mode 100644 index 0000000000..b2f045f4aa --- /dev/null +++ b/java-runtime/ftl-runtime/deployment/src/test/java/xyz/block/ftl/java/runtime/test/FtlJavaRuntimeDevModeTest.java @@ -0,0 +1,25 @@ +package xyz.block.ftl.java.runtime.test; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusDevModeTest; + +@Disabled +public class FtlJavaRuntimeDevModeTest { + + // Start hot reload (DevMode) test with your extension loaded + @RegisterExtension + static final QuarkusDevModeTest devModeTest = new QuarkusDevModeTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)); + + @Test + public void writeYourOwnDevModeTest() { + // Write your dev mode tests here - see the testing extension guide https://quarkus.io/guides/writing-extensions#testing-hot-reload for more information + Assertions.assertTrue(true, "Add dev mode assertions to " + getClass().getName()); + } +} diff --git a/java-runtime/ftl-runtime/deployment/src/test/java/xyz/block/ftl/java/runtime/test/FtlJavaRuntimeTest.java b/java-runtime/ftl-runtime/deployment/src/test/java/xyz/block/ftl/java/runtime/test/FtlJavaRuntimeTest.java new file mode 100644 index 0000000000..45c2c2eef7 --- /dev/null +++ b/java-runtime/ftl-runtime/deployment/src/test/java/xyz/block/ftl/java/runtime/test/FtlJavaRuntimeTest.java @@ -0,0 +1,25 @@ +package xyz.block.ftl.java.runtime.test; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +@Disabled +public class FtlJavaRuntimeTest { + + // Start unit test with your extension loaded + @RegisterExtension + static final QuarkusUnitTest unitTest = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)); + + @Test + public void writeYourOwnUnitTest() { + // Write your unit tests here - see the testing extension guide https://quarkus.io/guides/writing-extensions#testing-extensions for more information + Assertions.assertTrue(true, "Add some assertions to " + getClass().getName()); + } +} diff --git a/java-runtime/ftl-runtime/integration-tests/pom.xml b/java-runtime/ftl-runtime/integration-tests/pom.xml new file mode 100644 index 0000000000..11c116b7c7 --- /dev/null +++ b/java-runtime/ftl-runtime/integration-tests/pom.xml @@ -0,0 +1,98 @@ + + + 4.0.0 + + + xyz.block + ftl-java-runtime-parent + 1.0.0-SNAPSHOT + + ftl-java-runtime-integration-tests + Ftl Java Runtime - Integration Tests + + + true + + + + + xyz.block + ftl-java-runtime + + + xyz.block + ftl-java-test-framework + test + + + io.rest-assured + rest-assured + test + + + io.quarkus + quarkus-junit5-mockito + + + + + + + io.quarkus + quarkus-maven-plugin + + + + build + generate-code + generate-code-tests + + + + + + maven-failsafe-plugin + + + + integration-test + verify + + + + + + ${project.build.directory}/${project.build.finalName}-runner + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + + + native-image + + + native + + + + + + maven-surefire-plugin + + ${native.surefire.skip} + + + + + + false + true + + + + diff --git a/java-runtime/ftl-runtime/integration-tests/src/main/ftl-module-schema/builtin.pb b/java-runtime/ftl-runtime/integration-tests/src/main/ftl-module-schema/builtin.pb new file mode 100644 index 0000000000000000000000000000000000000000..83a40d59ed78a6001fcd9193855beb39946e4846 GIT binary patch literal 832 zcma)4O>fgc5G9G7q-*EPO`5?8Xtm;kDLJ^DI0gmvz@b8skaDw=1zT!tv%5yh-$3f` z0Kp$**6TE_)B`7bX5Ku{dw#?kHlg87mt}gZ-liEftCp*Intc;oO8SF~SZXfkTB+i**$Q0b zvSH>~%Y1hT0joCHUVG0~lJ8j8Z&w{RyufD%Csq-ur0#csgbVC|CjV-cs)P&5DuooY!MOe!qx%wn zf6Qrt|Cwj%l4d#9xk?sn9DC%MXY4pX+7Z4&;xQ82hF}t(BWSSED#qZ{DuNLiU}Fj` z%#6L-7wQn)HOS8fHlJ%BKSXMq)H$N5q|p;m)Dhll+$nzU!@a;kHXnU#AyN1ei@ literal 0 HcmV?d00001 diff --git a/java-runtime/ftl-runtime/integration-tests/src/main/ftl-module-schema/echo.pb b/java-runtime/ftl-runtime/integration-tests/src/main/ftl-module-schema/echo.pb new file mode 100644 index 0000000000000000000000000000000000000000..6f55e044cd45cffe47bed9cc276c5c3783b1e4c1 GIT binary patch literal 1440 zcmcJP&q~8U5XMbgT8Ikms<5OWOZ1dO7J{vKR9it2D$zM0XN(AHrZ0AY{-_ap>ld{2(tQ=9Y#u6ku+Uns673q^;XqitBUhBaUC`H6H=5xINW3)q>VLm zxt~7_%e(VgS%7LS@nSHawDF^AD_XdL)H~3^$QoIf>PZ-L64rM`|5D9)9oKU&S4uC% zEKLMB4+{`MjTnUR7%ZUkH?VG9TUUy?=VXlEPg@@q^sbsp8*9iez~@sXGoA?I5yk=J yG-2d6Uob?5K>UnzNFcng_Y)g|;&R(~8~q(=V=m-`qlt1M2mR}}Qt1a?bkdsu literal 0 HcmV?d00001 diff --git a/java-runtime/ftl-runtime/integration-tests/src/main/ftl-module-schema/time.pb b/java-runtime/ftl-runtime/integration-tests/src/main/ftl-module-schema/time.pb new file mode 100644 index 0000000000000000000000000000000000000000..5388d67667f6b803bee1b8617387572791565c8c GIT binary patch literal 695 zcmd;L5K?x|Pf1lsPt8j$N-RlDQAny(a0~I#Q*iND@beE*aCPwv(Nkh6$;?gFI?Bj( zh> { +} diff --git a/java-runtime/ftl-runtime/integration-tests/src/main/java/xyz/block/ftl/java/runtime/it/Person.java b/java-runtime/ftl-runtime/integration-tests/src/main/java/xyz/block/ftl/java/runtime/it/Person.java new file mode 100644 index 0000000000..d7233db37a --- /dev/null +++ b/java-runtime/ftl-runtime/integration-tests/src/main/java/xyz/block/ftl/java/runtime/it/Person.java @@ -0,0 +1,5 @@ +package xyz.block.ftl.java.runtime.it; + +public record Person(String first, String last) { + +} diff --git a/java-runtime/ftl-runtime/integration-tests/src/main/resources/application.properties b/java-runtime/ftl-runtime/integration-tests/src/main/resources/application.properties new file mode 100644 index 0000000000..e69de29bb2 diff --git a/java-runtime/ftl-runtime/integration-tests/src/test/java/xyz/block/ftl/java/runtime/it/FtlJavaRuntimeResourceTest.java b/java-runtime/ftl-runtime/integration-tests/src/test/java/xyz/block/ftl/java/runtime/it/FtlJavaRuntimeResourceTest.java new file mode 100644 index 0000000000..ef47c2b6a1 --- /dev/null +++ b/java-runtime/ftl-runtime/integration-tests/src/test/java/xyz/block/ftl/java/runtime/it/FtlJavaRuntimeResourceTest.java @@ -0,0 +1,62 @@ +package xyz.block.ftl.java.runtime.it; + +import java.util.function.Function; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import ftl.echo.EchoClient; +import ftl.echo.EchoRequest; +import ftl.echo.EchoResponse; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTest; +import xyz.block.ftl.VerbClient; +import xyz.block.ftl.VerbClientDefinition; +import xyz.block.ftl.VerbClientSink; +import xyz.block.ftl.java.test.FTLManaged; +import xyz.block.ftl.java.test.internal.FTLTestResource; +import xyz.block.ftl.java.test.internal.TestVerbServer; + +@QuarkusTest +@QuarkusTestResource(FTLTestResource.class) +public class FtlJavaRuntimeResourceTest { + + @FTLManaged + @Inject + PublishVerbClient myVerbClient; + + @FTLManaged + @Inject + HelloClient helloClient; + + @Test + public void testHelloEndpoint() { + TestVerbServer.registerFakeVerb("echo", "echo", new Function() { + @Override + public EchoResponse apply(EchoRequest s) { + return new EchoResponse(s.getName()); + } + }); + EchoClient echoClient = Mockito.mock(EchoClient.class); + Mockito.when(echoClient.call(Mockito.any())).thenReturn(new EchoResponse().setMessage("Stuart")); + Assertions.assertEquals("Hello Stuart", helloClient.call("Stuart")); + } + + @Test + @Disabled + public void testTopic() { + myVerbClient.call(new Person("Stuart", "Douglas")); + } + + @VerbClientDefinition(name = "publish") + interface PublishVerbClient extends VerbClientSink { + } + + @VerbClientDefinition(name = "hello") + interface HelloClient extends VerbClient { + } +} diff --git a/java-runtime/ftl-runtime/pom.xml b/java-runtime/ftl-runtime/pom.xml new file mode 100644 index 0000000000..4ff40b3f4f --- /dev/null +++ b/java-runtime/ftl-runtime/pom.xml @@ -0,0 +1,264 @@ + + + 4.0.0 + xyz.block + ftl-java-runtime-parent + 1.0.0-SNAPSHOT + pom + Ftl Java Runtime - Parent + + + deployment + runtime + integration-tests + test-framework + + + + 3.13.0 + ${surefire-plugin.version} + 17 + UTF-8 + UTF-8 + 3.12.3 + 3.2.5 + ${basedir}/../../.. + 1.65.1 + 1.13.0 + 2.24.1 + 1.11.0 + + + + + + io.quarkus + quarkus-bom + ${quarkus.version} + pom + import + + + xyz.block + ftl-java-runtime + ${project.version} + + + xyz.block + ftl-java-runtime-deployment + ${project.version} + + + xyz.block + ftl-java-test-framework + ${project.version} + + + io.grpc + grpc-bom + ${grpc.version} + pom + import + + + + com.squareup + javapoet + ${javapoet.version} + + + + com.squareup.wire + wire-runtime-jvm + ${wire.version} + + + com.squareup.wire + wire-grpc-server + ${wire.version} + + + com.squareup.wire + wire-grpc-client-jvm + ${wire.version} + + + + + io.grpc + grpc-netty + ${grpc.version} + + + io.grpc + grpc-protobuf + ${grpc.version} + + + io.grpc + grpc-stub + ${grpc.version} + + + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + ${maven.home} + ${settings.localRepository} + + + + + maven-failsafe-plugin + ${failsafe-plugin.version} + + + org.jboss.logmanager.LogManager + ${maven.home} + ${settings.localRepository} + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + + -parameters + + + + + net.revelc.code.formatter + formatter-maven-plugin + ${version.formatter.plugin} + + + quarkus-ide-config + io.quarkus + ${quarkus.version} + + + + + .cache/formatter-maven-plugin-${version.formatter.plugin} + eclipse-format.xml + LF + ${format.skip} + + + + net.revelc.code + impsort-maven-plugin + ${version.impsort.plugin} + + + .cache/impsort-maven-plugin-${version.impsort.plugin} + java.,javax.,jakarta.,org.,com. + * + ${format.skip} + true + + + + + + + + + + format + + true + + !no-format + + + + + + net.revelc.code.formatter + formatter-maven-plugin + + + process-sources + + format + + + + + + net.revelc.code + impsort-maven-plugin + + true + + + + sort-imports + + sort + + + + + + + + + validate + + true + + no-format + + + + + + net.revelc.code.formatter + formatter-maven-plugin + + + process-sources + + validate + + + + + + net.revelc.code + impsort-maven-plugin + + true + + + + check-imports + + check + + + + + + + + + diff --git a/java-runtime/ftl-runtime/runtime/pom.xml b/java-runtime/ftl-runtime/runtime/pom.xml new file mode 100644 index 0000000000..9ea5ec0c64 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/pom.xml @@ -0,0 +1,137 @@ + + + 4.0.0 + + + xyz.block + ftl-java-runtime-parent + 1.0.0-SNAPSHOT + + ftl-java-runtime + Ftl Java Runtime - Runtime + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-grpc + + + io.quarkus + quarkus-rest-jackson + + + com.fasterxml.jackson.module + jackson-module-kotlin + + + io.grpc + grpc-stub + + + io.grpc + grpc-services + + + + io.grpc + grpc-netty + + + io.grpc + grpc-protobuf + + + javax.annotation + javax.annotation-api + + + org.jetbrains + annotations + + + + + + + kr.motd.maven + os-maven-plugin + 1.6.0 + + + + + io.quarkus + quarkus-extension-maven-plugin + ${quarkus.version} + + + compile + + extension-descriptor + + + ${project.groupId}:${project.artifactId}-deployment:${project.version} + + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + generate-sources + + add-source + + + + ${project.basedir}/target/generated-sources/protoc + + + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + 0.6.1 + + + + compile + compile-custom + test-compile + test-compile-custom + + + + + com.google.protobuf:protoc:3.25.4:exe:${os.detected.classifier} + ${rootDir}/backend/protos + grpc-java + io.grpc:protoc-gen-grpc-java:1.65.1:exe:${os.detected.classifier} + + + + + diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Config.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Config.java new file mode 100644 index 0000000000..f757472983 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Config.java @@ -0,0 +1,12 @@ +package xyz.block.ftl; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface Config { + String value(); +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Cron.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Cron.java new file mode 100644 index 0000000000..31ffc34218 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Cron.java @@ -0,0 +1,14 @@ +package xyz.block.ftl; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Cron { + + String value(); + +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Export.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Export.java new file mode 100644 index 0000000000..63354ad057 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Export.java @@ -0,0 +1,14 @@ +package xyz.block.ftl; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks the given item as exported in the FTL schema. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE, ElementType.PARAMETER }) +public @interface Export { +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/GeneratedRef.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/GeneratedRef.java new file mode 100644 index 0000000000..2b24f59af4 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/GeneratedRef.java @@ -0,0 +1,11 @@ +package xyz.block.ftl; + +/** + * Indicates that the class was generated from an external module. + */ +public @interface GeneratedRef { + + String name(); + + String module(); +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Retry.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Retry.java new file mode 100644 index 0000000000..19752db1e6 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Retry.java @@ -0,0 +1,20 @@ +package xyz.block.ftl; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD }) +public @interface Retry { + int count() default 0; + + String minBackoff() default ""; + + String maxBackoff() default ""; + + String catchModule() default ""; + + String catchVerb() default ""; +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Secret.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Secret.java new file mode 100644 index 0000000000..12d61ce1d2 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Secret.java @@ -0,0 +1,12 @@ +package xyz.block.ftl; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface Secret { + String value(); +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Subscription.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Subscription.java new file mode 100644 index 0000000000..9b2283f5e0 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Subscription.java @@ -0,0 +1,28 @@ +package xyz.block.ftl; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +public @interface Subscription { + /** + * @return The module of the topic to subscribe to, if empty then the topic is assumed to be in the current module. + */ + String module() default ""; + + /** + * + * @return The name of the topic to subscribe to. + */ + String topic(); + + /** + * + * @return The subscription name + */ + String name(); + +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Topic.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Topic.java new file mode 100644 index 0000000000..16170fe24e --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Topic.java @@ -0,0 +1,12 @@ +package xyz.block.ftl; + +/** + * A concrete definition of a topic. Extend this interface and annotate with {@code @TopicDefinition} to define a topic, + * then inject this into verb methods to publish to the topic. + * + * @param + */ +public interface Topic { + + void publish(T object); +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/TopicDefinition.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/TopicDefinition.java new file mode 100644 index 0000000000..a6482006d6 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/TopicDefinition.java @@ -0,0 +1,17 @@ +package xyz.block.ftl; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface TopicDefinition { + /** + * + * @return The name of the topic + */ + String name(); + +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Verb.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Verb.java new file mode 100644 index 0000000000..c421a34fd1 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/Verb.java @@ -0,0 +1,15 @@ +package xyz.block.ftl; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * A FTL verb. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Verb { + +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClient.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClient.java new file mode 100644 index 0000000000..b179357c89 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClient.java @@ -0,0 +1,17 @@ +package xyz.block.ftl; + +/** + * A client for a specific verb. + * + * The sink source and empty interfaces allow for different call signatures. + * + * TODO: should these be top level + * + * @param

The verb parameter type + * @param The verb return type + */ +public interface VerbClient { + + R call(P param); + +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClientDefinition.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClientDefinition.java new file mode 100644 index 0000000000..1e258f7fa6 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClientDefinition.java @@ -0,0 +1,18 @@ +package xyz.block.ftl; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation that is used to define a verb client + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface VerbClientDefinition { + + String module() default ""; + + String name(); +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClientEmpty.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClientEmpty.java new file mode 100644 index 0000000000..2d68c8d88d --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClientEmpty.java @@ -0,0 +1,5 @@ +package xyz.block.ftl; + +public interface VerbClientEmpty { + void call(); +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClientSink.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClientSink.java new file mode 100644 index 0000000000..cb05af1038 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClientSink.java @@ -0,0 +1,5 @@ +package xyz.block.ftl; + +public interface VerbClientSink

{ + void call(P param); +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClientSource.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClientSource.java new file mode 100644 index 0000000000..95efc04c78 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbClientSource.java @@ -0,0 +1,5 @@ +package xyz.block.ftl; + +public interface VerbClientSource { + R call(); +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbName.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbName.java new file mode 100644 index 0000000000..1a9a7e2548 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/VerbName.java @@ -0,0 +1,12 @@ +package xyz.block.ftl; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Used to override the name of a verb. Without this annotation it defaults to the method name. + */ +@Retention(RetentionPolicy.RUNTIME) +public @interface VerbName { + String value(); +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/FTLConfigSource.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/FTLConfigSource.java new file mode 100644 index 0000000000..15ff77949d --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/FTLConfigSource.java @@ -0,0 +1,65 @@ +package xyz.block.ftl.runtime; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Set; + +import org.eclipse.microprofile.config.spi.ConfigSource; + +public class FTLConfigSource implements ConfigSource { + + final static String SEPARATE_SERVER = "quarkus.grpc.server.use-separate-server"; + final static String PORT = "quarkus.http.port"; + final static String HOST = "quarkus.http.host"; + + final static String FTL_BIND = "FTL_BIND"; + + @Override + public Set getPropertyNames() { + return Set.of(SEPARATE_SERVER, PORT, HOST); + } + + @Override + public int getOrdinal() { + return 1; + } + + @Override + public String getValue(String s) { + switch (s) { + case SEPARATE_SERVER -> { + return "false"; + } + case PORT -> { + String bind = System.getenv(FTL_BIND); + if (bind == null) { + return null; + } + try { + URI uri = new URI(bind); + return Integer.toString(uri.getPort()); + } catch (URISyntaxException e) { + return null; + } + } + case HOST -> { + String bind = System.getenv(FTL_BIND); + if (bind == null) { + return null; + } + try { + URI uri = new URI(bind); + return uri.getHost(); + } catch (URISyntaxException e) { + return null; + } + } + } + return null; + } + + @Override + public String getName() { + return "FTL Config"; + } +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/FTLController.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/FTLController.java new file mode 100644 index 0000000000..28b65e3cd9 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/FTLController.java @@ -0,0 +1,182 @@ +package xyz.block.ftl.runtime; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import jakarta.inject.Singleton; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +import com.google.protobuf.ByteString; + +import io.grpc.ManagedChannelBuilder; +import io.grpc.stub.StreamObserver; +import io.quarkus.runtime.Startup; +import xyz.block.ftl.v1.CallRequest; +import xyz.block.ftl.v1.CallResponse; +import xyz.block.ftl.v1.ModuleContextRequest; +import xyz.block.ftl.v1.ModuleContextResponse; +import xyz.block.ftl.v1.PublishEventRequest; +import xyz.block.ftl.v1.PublishEventResponse; +import xyz.block.ftl.v1.VerbServiceGrpc; +import xyz.block.ftl.v1.schema.Ref; + +@Singleton +@Startup +public class FTLController { + private static final Logger log = Logger.getLogger(FTLController.class); + final String moduleName; + + private Throwable currentError; + private volatile ModuleContextResponse moduleContextResponse; + private boolean waiters = false; + + final VerbServiceGrpc.VerbServiceStub verbService; + final StreamObserver moduleObserver = new StreamObserver<>() { + @Override + public void onNext(ModuleContextResponse moduleContextResponse) { + synchronized (this) { + currentError = null; + FTLController.this.moduleContextResponse = moduleContextResponse; + if (waiters) { + this.notifyAll(); + waiters = false; + } + } + + } + + @Override + public void onError(Throwable throwable) { + log.error("GRPC connection error", throwable); + synchronized (this) { + currentError = throwable; + if (waiters) { + this.notifyAll(); + waiters = false; + } + } + } + + @Override + public void onCompleted() { + verbService.getModuleContext(ModuleContextRequest.newBuilder().setModule(moduleName).build(), moduleObserver); + } + }; + + public FTLController(@ConfigProperty(name = "ftl.endpoint", defaultValue = "http://localhost:8892") URI uri, + @ConfigProperty(name = "ftl.module.name") String moduleName) { + this.moduleName = moduleName; + var channelBuilder = ManagedChannelBuilder.forAddress(uri.getHost(), uri.getPort()); + if (uri.getScheme().equals("http")) { + channelBuilder.usePlaintext(); + } + var channel = channelBuilder.build(); + verbService = VerbServiceGrpc.newStub(channel); + verbService.getModuleContext(ModuleContextRequest.newBuilder().setModule(moduleName).build(), moduleObserver); + + } + + public byte[] getSecret(String secretName) { + var context = getModuleContext(); + if (context.containsSecrets(secretName)) { + return context.getSecretsMap().get(secretName).toByteArray(); + } + throw new RuntimeException("Secret not found: " + secretName); + } + + public byte[] getConfig(String secretName) { + var context = getModuleContext(); + if (context.containsConfigs(secretName)) { + return context.getConfigsMap().get(secretName).toByteArray(); + } + throw new RuntimeException("Config not found: " + secretName); + } + + public byte[] callVerb(String name, String module, byte[] payload) { + CompletableFuture cf = new CompletableFuture<>(); + verbService.call(CallRequest.newBuilder().setVerb(Ref.newBuilder().setModule(module).setName(name)) + .setBody(ByteString.copyFrom(payload)).build(), new StreamObserver<>() { + + @Override + public void onNext(CallResponse callResponse) { + if (callResponse.hasError()) { + cf.completeExceptionally(new RuntimeException(callResponse.getError().getMessage())); + } else { + cf.complete(callResponse.getBody().toByteArray()); + } + } + + @Override + public void onError(Throwable throwable) { + cf.completeExceptionally(throwable); + } + + @Override + public void onCompleted() { + + } + }); + try { + return cf.get(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public void publishEvent(String topic, String callingVerbName, byte[] event) { + CompletableFuture cf = new CompletableFuture<>(); + verbService.publishEvent(PublishEventRequest.newBuilder() + .setCaller(callingVerbName).setBody(ByteString.copyFrom(event)) + .setTopic(Ref.newBuilder().setModule(moduleName).setName(topic).build()).build(), + new StreamObserver() { + @Override + public void onNext(PublishEventResponse publishEventResponse) { + cf.complete(null); + } + + @Override + public void onError(Throwable throwable) { + cf.completeExceptionally(throwable); + } + + @Override + public void onCompleted() { + cf.complete(null); + } + }); + try { + cf.get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + } + + private ModuleContextResponse getModuleContext() { + var moduleContext = moduleContextResponse; + if (moduleContext != null) { + return moduleContext; + } + synchronized (moduleObserver) { + for (;;) { + moduleContext = moduleContextResponse; + if (moduleContext != null) { + return moduleContext; + } + if (currentError != null) { + throw new RuntimeException(currentError); + } + waiters = true; + try { + moduleObserver.wait(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + } + } + +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/FTLHttpHandler.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/FTLHttpHandler.java new file mode 100644 index 0000000000..48596bffe5 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/FTLHttpHandler.java @@ -0,0 +1,245 @@ +package xyz.block.ftl.runtime; + +import java.io.ByteArrayOutputStream; +import java.net.InetSocketAddress; +import java.nio.channels.Channels; +import java.nio.channels.WritableByteChannel; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +import jakarta.inject.Singleton; +import jakarta.ws.rs.core.MediaType; + +import org.jboss.logging.Logger; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.protobuf.ByteString; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.FileRegion; +import io.netty.handler.codec.http.*; +import io.netty.util.ReferenceCountUtil; +import io.quarkus.netty.runtime.virtual.VirtualClientConnection; +import io.quarkus.netty.runtime.virtual.VirtualResponseHandler; +import io.quarkus.vertx.http.runtime.QuarkusHttpHeaders; +import io.quarkus.vertx.http.runtime.VertxHttpRecorder; +import xyz.block.ftl.v1.CallRequest; +import xyz.block.ftl.v1.CallResponse; + +@SuppressWarnings("unused") +@Singleton +public class FTLHttpHandler implements VerbInvoker { + + public static final String CONTENT_TYPE = "Content-Type"; + final ObjectMapper mapper; + private static final Logger log = Logger.getLogger("quarkus.amazon.lambda.http"); + + private static final int BUFFER_SIZE = 8096; + + private static final Map> ERROR_HEADERS = Map.of(); + + private static final String COOKIE_HEADER = "Cookie"; + + // comma headers for headers that have comma in value and we don't want to split it up into + // multiple headers + private static final Set COMMA_HEADERS = Set.of("access-control-request-headers"); + + public FTLHttpHandler(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public CallResponse handle(CallRequest in) { + try { + var body = mapper.createParser(in.getBody().newInput()) + .readValueAs(xyz.block.ftl.runtime.builtin.HttpRequest.class); + body.getHeaders().put(FTLRecorder.X_FTL_VERB, List.of(in.getVerb().getName())); + var ret = handleRequest(body); + var mappedResponse = mapper.writer().writeValueAsBytes(ret); + return CallResponse.newBuilder().setBody(ByteString.copyFrom(mappedResponse)).build(); + } catch (Exception e) { + return CallResponse.newBuilder().setError(CallResponse.Error.newBuilder().setMessage(e.getMessage()).build()) + .build(); + } + + } + + public xyz.block.ftl.runtime.builtin.HttpResponse handleRequest(xyz.block.ftl.runtime.builtin.HttpRequest request) { + InetSocketAddress clientAddress = null; + try { + return nettyDispatch(clientAddress, request); + } catch (Exception e) { + log.error("Request Failure", e); + xyz.block.ftl.runtime.builtin.HttpResponse res = new xyz.block.ftl.runtime.builtin.HttpResponse(); + res.setStatus(500); + res.setError(e); + res.setHeaders(ERROR_HEADERS); + return res; + } + + } + + private class NettyResponseHandler implements VirtualResponseHandler { + xyz.block.ftl.runtime.builtin.HttpResponse responseBuilder = new xyz.block.ftl.runtime.builtin.HttpResponse(); + ByteArrayOutputStream baos; + WritableByteChannel byteChannel; + final xyz.block.ftl.runtime.builtin.HttpRequest request; + CompletableFuture future = new CompletableFuture<>(); + + public NettyResponseHandler(xyz.block.ftl.runtime.builtin.HttpRequest request) { + this.request = request; + } + + public CompletableFuture getFuture() { + return future; + } + + @Override + public void handleMessage(Object msg) { + try { + //log.info("Got message: " + msg.getClass().getName()); + + if (msg instanceof HttpResponse) { + HttpResponse res = (HttpResponse) msg; + responseBuilder.setStatus(res.status().code()); + + final Map> headers = new HashMap<>(); + responseBuilder.setHeaders(headers); + for (String name : res.headers().names()) { + final List allForName = res.headers().getAll(name); + if (allForName == null || allForName.isEmpty()) { + continue; + } + headers.put(name, allForName); + } + } + if (msg instanceof HttpContent) { + HttpContent content = (HttpContent) msg; + int readable = content.content().readableBytes(); + if (baos == null && readable > 0) { + baos = createByteStream(); + } + for (int i = 0; i < readable; i++) { + baos.write(content.content().readByte()); + } + } + if (msg instanceof FileRegion) { + FileRegion file = (FileRegion) msg; + if (file.count() > 0 && file.transferred() < file.count()) { + if (baos == null) + baos = createByteStream(); + if (byteChannel == null) + byteChannel = Channels.newChannel(baos); + file.transferTo(byteChannel, file.transferred()); + } + } + if (msg instanceof LastHttpContent) { + if (baos != null) { + List ct = responseBuilder.getHeaders().get(CONTENT_TYPE); + if (ct == null || ct.isEmpty()) { + //TODO: how to handle this + responseBuilder.setBody(baos.toString(StandardCharsets.UTF_8)); + } else if (ct.get(0).contains(MediaType.TEXT_PLAIN)) { + // need to encode as JSON string + responseBuilder.setBody(mapper.writer().writeValueAsString(baos.toString(StandardCharsets.UTF_8))); + } else { + responseBuilder.setBody(baos.toString(StandardCharsets.UTF_8)); + } + } + future.complete(responseBuilder); + } + } catch (Throwable ex) { + future.completeExceptionally(ex); + } finally { + if (msg != null) { + ReferenceCountUtil.release(msg); + } + } + } + + @Override + public void close() { + if (!future.isDone()) + future.completeExceptionally(new RuntimeException("Connection closed")); + } + } + + private xyz.block.ftl.runtime.builtin.HttpResponse nettyDispatch(InetSocketAddress clientAddress, + xyz.block.ftl.runtime.builtin.HttpRequest request) + throws Exception { + QuarkusHttpHeaders quarkusHeaders = new QuarkusHttpHeaders(); + quarkusHeaders.setContextObject(xyz.block.ftl.runtime.builtin.HttpRequest.class, request); + HttpMethod httpMethod = HttpMethod.valueOf(request.getMethod()); + if (httpMethod == null) { + throw new IllegalStateException("Missing HTTP method in request event"); + } + //TODO: encoding schenanigans + StringBuilder path = new StringBuilder(request.getPath()); + if (request.getQuery() != null && !request.getQuery().isEmpty()) { + path.append("?"); + var first = true; + for (var entry : request.getQuery().entrySet()) { + for (var val : entry.getValue()) { + if (first) { + first = false; + } else { + path.append("&"); + } + path.append(entry.getKey()).append("=").append(val); + } + } + } + DefaultHttpRequest nettyRequest = new DefaultHttpRequest(HttpVersion.HTTP_1_1, + httpMethod, path.toString(), quarkusHeaders); + if (request.getHeaders() != null) { + for (Map.Entry> header : request.getHeaders().entrySet()) { + if (header.getValue() != null) { + for (String val : header.getValue()) { + nettyRequest.headers().add(header.getKey(), val); + } + } + } + } + nettyRequest.headers().add(CONTENT_TYPE, MediaType.APPLICATION_JSON); + + if (!nettyRequest.headers().contains(HttpHeaderNames.HOST)) { + nettyRequest.headers().add(HttpHeaderNames.HOST, "localhost"); + } + + HttpContent requestContent = LastHttpContent.EMPTY_LAST_CONTENT; + if (request.getBody() != null) { + // See https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.3 + nettyRequest.headers().add(HttpHeaderNames.TRANSFER_ENCODING, "chunked"); + ByteBuf body = Unpooled.copiedBuffer(request.getBody().toString(), StandardCharsets.UTF_8); //TODO: do we need to look at the request encoding? + requestContent = new DefaultLastHttpContent(body); + } + NettyResponseHandler handler = new NettyResponseHandler(request); + VirtualClientConnection connection = VirtualClientConnection.connect(handler, VertxHttpRecorder.VIRTUAL_HTTP, + clientAddress); + + connection.sendMessage(nettyRequest); + connection.sendMessage(requestContent); + try { + return handler.getFuture().get(); + } finally { + connection.close(); + } + } + + private ByteArrayOutputStream createByteStream() { + ByteArrayOutputStream baos; + baos = new ByteArrayOutputStream(BUFFER_SIZE); + return baos; + } + + private boolean isBinary(String contentType) { + if (contentType != null) { + String ct = contentType.toLowerCase(Locale.ROOT); + return !(ct.startsWith("text") || ct.contains("json") || ct.contains("xml") || ct.contains("yaml")); + } + return false; + } + +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/FTLRecorder.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/FTLRecorder.java new file mode 100644 index 0000000000..6b839518c1 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/FTLRecorder.java @@ -0,0 +1,110 @@ +package xyz.block.ftl.runtime; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.util.List; +import java.util.function.BiFunction; + +import org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext; +import org.jboss.resteasy.reactive.server.core.parameters.ParameterExtractor; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.quarkus.arc.Arc; +import io.quarkus.runtime.annotations.Recorder; +import xyz.block.ftl.v1.CallRequest; + +@Recorder +public class FTLRecorder { + + public static final String X_FTL_VERB = "X-ftl-verb"; + + public void registerVerb(String module, String verbName, String methodName, List> parameterTypes, + Class verbHandlerClass, List> paramMappers) { + //TODO: this sucks + try { + var method = verbHandlerClass.getDeclaredMethod(methodName, parameterTypes.toArray(new Class[0])); + var handlerInstance = Arc.container().instance(verbHandlerClass); + Arc.container().instance(VerbRegistry.class).get().register(module, verbName, handlerInstance, method, + paramMappers); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public void registerHttpIngress(String module, String verbName) { + try { + Arc.container().instance(VerbRegistry.class).get().register(module, verbName, + Arc.container().instance(FTLHttpHandler.class).get()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public BiFunction topicSupplier(String className, String callingVerb) { + try { + var cls = Thread.currentThread().getContextClassLoader().loadClass(className.replace("/", ".")); + var topic = cls.getDeclaredConstructor(String.class).newInstance(callingVerb); + return new BiFunction() { + @Override + public Object apply(ObjectMapper mapper, CallRequest callRequest) { + return topic; + } + }; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public BiFunction verbClientSupplier(String className) { + try { + var cls = Thread.currentThread().getContextClassLoader().loadClass(className.replace("/", ".")); + var client = cls.getDeclaredConstructor().newInstance(); + return new BiFunction() { + @Override + public Object apply(ObjectMapper mapper, CallRequest callRequest) { + return client; + } + }; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public ParameterExtractor topicParamExtractor(String className) { + + try { + var cls = Thread.currentThread().getContextClassLoader().loadClass(className.replace("/", ".")); + Constructor ctor = cls.getDeclaredConstructor(String.class); + return new ParameterExtractor() { + @Override + public Object extractParameter(ResteasyReactiveRequestContext context) { + + try { + Object topic = ctor.newInstance(context.getHeader(X_FTL_VERB, true)); + return topic; + } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + } + }; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public ParameterExtractor verbParamExtractor(String className) { + try { + var cls = Thread.currentThread().getContextClassLoader().loadClass(className.replace("/", ".")); + var client = cls.getDeclaredConstructor().newInstance(); + return new ParameterExtractor() { + @Override + public Object extractParameter(ResteasyReactiveRequestContext context) { + return client; + } + }; + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/TopicHelper.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/TopicHelper.java new file mode 100644 index 0000000000..aa1e0fb20b --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/TopicHelper.java @@ -0,0 +1,32 @@ +package xyz.block.ftl.runtime; + +import jakarta.inject.Singleton; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.quarkus.arc.Arc; + +@Singleton +public class TopicHelper { + + final FTLController controller; + final ObjectMapper mapper; + + public TopicHelper(FTLController controller, ObjectMapper mapper) { + this.controller = controller; + this.mapper = mapper; + } + + public void publish(String topic, String verb, Object message) { + try { + controller.publishEvent(topic, verb, mapper.writeValueAsBytes(message)); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public static TopicHelper instance() { + return Arc.container().instance(TopicHelper.class).get(); + } +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/VerbClientHelper.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/VerbClientHelper.java new file mode 100644 index 0000000000..b28037c76c --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/VerbClientHelper.java @@ -0,0 +1,48 @@ +package xyz.block.ftl.runtime; + +import java.util.Map; + +import jakarta.inject.Singleton; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.quarkus.arc.Arc; + +@Singleton +public class VerbClientHelper { + + final FTLController controller; + final ObjectMapper mapper; + + public VerbClientHelper(FTLController controller, ObjectMapper mapper) { + this.controller = controller; + this.mapper = mapper; + } + + public Object call(String verb, String module, Object message, Class returnType, boolean listReturnType, + boolean mapReturnType) { + try { + if (message == null) { + //Unit must be an empty map + //TODO: what about optional? + message = Map.of(); + } + var result = controller.callVerb(verb, module, mapper.writeValueAsBytes(message)); + if (listReturnType) { + return mapper.readerForArrayOf(returnType).readValue(result); + } else if (mapReturnType) { + return mapper.readerForMapOf(returnType).readValue(result); + } + if (result == null) { + return null; + } + return mapper.readerFor(returnType).readValue(result); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static VerbClientHelper instance() { + return Arc.container().instance(VerbClientHelper.class).get(); + } +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/VerbHandler.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/VerbHandler.java new file mode 100644 index 0000000000..714f197a05 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/VerbHandler.java @@ -0,0 +1,51 @@ +package xyz.block.ftl.runtime; + +import jakarta.inject.Singleton; + +import io.grpc.stub.StreamObserver; +import io.quarkus.grpc.GrpcService; +import xyz.block.ftl.v1.*; + +@Singleton +@GrpcService +public class VerbHandler extends VerbServiceGrpc.VerbServiceImplBase { + + final VerbRegistry registry; + + public VerbHandler(VerbRegistry registry) { + this.registry = registry; + } + + @Override + public void call(CallRequest request, StreamObserver responseObserver) { + var response = registry.invoke(request); + responseObserver.onNext(response); + responseObserver.onCompleted(); + } + + @Override + public void publishEvent(PublishEventRequest request, StreamObserver responseObserver) { + super.publishEvent(request, responseObserver); + } + + @Override + public void sendFSMEvent(SendFSMEventRequest request, StreamObserver responseObserver) { + super.sendFSMEvent(request, responseObserver); + } + + @Override + public StreamObserver acquireLease(StreamObserver responseObserver) { + return super.acquireLease(responseObserver); + } + + @Override + public void getModuleContext(ModuleContextRequest request, StreamObserver responseObserver) { + super.getModuleContext(request, responseObserver); + } + + @Override + public void ping(PingRequest request, StreamObserver responseObserver) { + responseObserver.onNext(PingResponse.newBuilder().build()); + responseObserver.onCompleted(); + } +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/VerbInvoker.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/VerbInvoker.java new file mode 100644 index 0000000000..d27c233dcd --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/VerbInvoker.java @@ -0,0 +1,9 @@ +package xyz.block.ftl.runtime; + +import xyz.block.ftl.v1.CallRequest; +import xyz.block.ftl.v1.CallResponse; + +public interface VerbInvoker { + + CallResponse handle(CallRequest in); +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/VerbRegistry.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/VerbRegistry.java new file mode 100644 index 0000000000..a0202690b4 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/VerbRegistry.java @@ -0,0 +1,179 @@ +package xyz.block.ftl.runtime; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; + +import jakarta.inject.Singleton; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext; +import org.jboss.resteasy.reactive.server.core.parameters.ParameterExtractor; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.protobuf.ByteString; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.InstanceHandle; +import xyz.block.ftl.v1.CallRequest; +import xyz.block.ftl.v1.CallResponse; + +@Singleton +public class VerbRegistry { + + private static final Logger log = Logger.getLogger(VerbRegistry.class); + + final ObjectMapper mapper; + + private final Map verbs = new ConcurrentHashMap<>(); + + public VerbRegistry(ObjectMapper mapper) { + this.mapper = mapper; + } + + public void register(String module, String name, InstanceHandle verbHandlerClass, Method method, + List> paramMappers) { + verbs.put(new Key(module, name), new AnnotatedEndpointHandler(verbHandlerClass, method, paramMappers)); + } + + public void register(String module, String name, VerbInvoker verbInvoker) { + verbs.put(new Key(module, name), verbInvoker); + } + + public CallResponse invoke(CallRequest request) { + VerbInvoker handler = verbs.get(new Key(request.getVerb().getModule(), request.getVerb().getName())); + if (handler == null) { + return CallResponse.newBuilder().setError(CallResponse.Error.newBuilder().setMessage("Verb not found").build()) + .build(); + } + return handler.handle(request); + } + + private record Key(String module, String name) { + + } + + private class AnnotatedEndpointHandler implements VerbInvoker { + final InstanceHandle verbHandlerClass; + final Method method; + final List> parameterSuppliers; + + private AnnotatedEndpointHandler(InstanceHandle verbHandlerClass, Method method, + List> parameterSuppliers) { + this.verbHandlerClass = verbHandlerClass; + this.method = method; + this.parameterSuppliers = parameterSuppliers; + } + + public CallResponse handle(CallRequest in) { + try { + Object[] params = new Object[parameterSuppliers.size()]; + for (int i = 0; i < parameterSuppliers.size(); i++) { + params[i] = parameterSuppliers.get(i).apply(mapper, in); + } + Object ret; + ret = method.invoke(verbHandlerClass.get(), params); + var mappedResponse = mapper.writer().writeValueAsBytes(ret); + return CallResponse.newBuilder().setBody(ByteString.copyFrom(mappedResponse)).build(); + } catch (Exception e) { + log.errorf(e, "Failed to invoke verb %s.%s", in.getVerb().getModule(), in.getVerb().getName()); + return CallResponse.newBuilder().setError(CallResponse.Error.newBuilder().setMessage(e.getMessage()).build()) + .build(); + } + } + } + + public record BodySupplier(Class inputClass) implements BiFunction { + + @Override + public Object apply(ObjectMapper mapper, CallRequest in) { + try { + return mapper.createParser(in.getBody().newInput()).readValueAs(inputClass); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + public static class SecretSupplier implements BiFunction, ParameterExtractor { + + final String name; + final Class inputClass; + + volatile FTLController ftlController; + + public SecretSupplier(String name, Class inputClass) { + this.name = name; + this.inputClass = inputClass; + } + + @Override + public Object apply(ObjectMapper mapper, CallRequest in) { + if (ftlController == null) { + ftlController = Arc.container().instance(FTLController.class).get(); + } + var secret = ftlController.getSecret(name); + try { + return mapper.createParser(secret).readValueAs(inputClass); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public String getName() { + return name; + } + + public Class getInputClass() { + return inputClass; + } + + @Override + public Object extractParameter(ResteasyReactiveRequestContext context) { + return apply(Arc.container().instance(ObjectMapper.class).get(), null); + } + } + + public static class ConfigSupplier implements BiFunction, ParameterExtractor { + + final String name; + final Class inputClass; + + volatile FTLController ftlController; + + public ConfigSupplier(String name, Class inputClass) { + this.name = name; + this.inputClass = inputClass; + } + + @Override + public Object apply(ObjectMapper mapper, CallRequest in) { + if (ftlController == null) { + ftlController = Arc.container().instance(FTLController.class).get(); + } + var secret = ftlController.getConfig(name); + try { + return mapper.createParser(secret).readValueAs(inputClass); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public Object extractParameter(ResteasyReactiveRequestContext context) { + return apply(Arc.container().instance(ObjectMapper.class).get(), null); + } + + public Class getInputClass() { + return inputClass; + } + + public String getName() { + return name; + } + } + +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/builtin/HttpRequest.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/builtin/HttpRequest.java new file mode 100644 index 0000000000..59b5d97c60 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/builtin/HttpRequest.java @@ -0,0 +1,66 @@ +package xyz.block.ftl.runtime.builtin; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * TODO: should this be generated? + */ +public class HttpRequest { + private String method; + private String path; + private Map pathParameters; + private Map> query; + private Map> headers; + private JsonNode body; + + public String getMethod() { + return method; + } + + public void setMethod(String method) { + this.method = method; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public Map getPathParameters() { + return pathParameters; + } + + public void setPathParameters(Map pathParameters) { + this.pathParameters = pathParameters; + } + + public Map> getQuery() { + return query; + } + + public void setQuery(Map> query) { + this.query = query; + } + + public Map> getHeaders() { + return headers; + } + + public void setHeaders(Map> headers) { + this.headers = headers; + } + + public JsonNode getBody() { + return body; + } + + public void setBody(JsonNode body) { + this.body = body; + } +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/builtin/HttpResponse.java b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/builtin/HttpResponse.java new file mode 100644 index 0000000000..6db4d8aaf9 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/java/xyz/block/ftl/runtime/builtin/HttpResponse.java @@ -0,0 +1,49 @@ +package xyz.block.ftl.runtime.builtin; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonRawValue; + +/** + * TODO: should this be generated + */ +public class HttpResponse { + private long status; + private Map> headers; + @JsonRawValue + private String body; + private Throwable error; + + public long getStatus() { + return status; + } + + public void setStatus(long status) { + this.status = status; + } + + public Map> getHeaders() { + return headers; + } + + public void setHeaders(Map> headers) { + this.headers = headers; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public Throwable getError() { + return error; + } + + public void setError(Throwable error) { + this.error = error; + } +} diff --git a/java-runtime/ftl-runtime/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/java-runtime/ftl-runtime/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000000..6f5b0bb2b5 --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,9 @@ +name: Ftl Java Runtime +#description: Do something useful. +metadata: +# keywords: +# - ftl-java-runtime +# guide: ... # To create and publish this guide, see https://github.com/quarkiverse/quarkiverse/wiki#documenting-your-extension +# categories: +# - "miscellaneous" +# status: "preview" diff --git a/java-runtime/ftl-runtime/runtime/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource b/java-runtime/ftl-runtime/runtime/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource new file mode 100644 index 0000000000..28ef804d0b --- /dev/null +++ b/java-runtime/ftl-runtime/runtime/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource @@ -0,0 +1 @@ +xyz.block.ftl.runtime.FTLConfigSource \ No newline at end of file diff --git a/java-runtime/ftl-runtime/test-framework/pom.xml b/java-runtime/ftl-runtime/test-framework/pom.xml new file mode 100644 index 0000000000..c66028219f --- /dev/null +++ b/java-runtime/ftl-runtime/test-framework/pom.xml @@ -0,0 +1,32 @@ + + + 4.0.0 + + + xyz.block + ftl-java-runtime-parent + 1.0.0-SNAPSHOT + + ftl-java-test-framework + Ftl Java Runtime - Test Framework + + + + + xyz.block + ftl-java-runtime + + + io.quarkus + quarkus-junit5 + compile + + + io.rest-assured + rest-assured + test + + + + + diff --git a/java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/FTLManaged.java b/java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/FTLManaged.java new file mode 100644 index 0000000000..bfcf6f7b98 --- /dev/null +++ b/java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/FTLManaged.java @@ -0,0 +1,8 @@ +package xyz.block.ftl.java.test; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +public @interface FTLManaged { +} diff --git a/java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/TestFTL.java b/java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/TestFTL.java new file mode 100644 index 0000000000..0259f26c9a --- /dev/null +++ b/java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/TestFTL.java @@ -0,0 +1,16 @@ +package xyz.block.ftl.java.test; + +public class TestFTL { + + public static TestFTL FTL = new TestFTL(); + + public static TestFTL ftl() { + return FTL; + } + + public TestFTL setSecret(String secret, byte[] value) { + + return this; + } + +} diff --git a/java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/internal/FTLTestResource.java b/java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/internal/FTLTestResource.java new file mode 100644 index 0000000000..f7fb23accf --- /dev/null +++ b/java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/internal/FTLTestResource.java @@ -0,0 +1,27 @@ +package xyz.block.ftl.java.test.internal; + +import java.util.Map; + +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; + +public class FTLTestResource implements QuarkusTestResourceLifecycleManager { + + FTLTestServer server; + + @Override + public Map start() { + server = new FTLTestServer(); + server.start(); + return Map.of("ftl.endpoint", "http://127.0.0.1:" + server.getPort()); + } + + @Override + public void stop() { + server.stop(); + } + + @Override + public void inject(TestInjector testInjector) { + + } +} diff --git a/java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/internal/FTLTestServer.java b/java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/internal/FTLTestServer.java new file mode 100644 index 0000000000..163a3ccad6 --- /dev/null +++ b/java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/internal/FTLTestServer.java @@ -0,0 +1,33 @@ +package xyz.block.ftl.java.test.internal; + +import java.io.IOException; +import java.net.InetSocketAddress; + +import io.grpc.Server; +import io.grpc.netty.NettyServerBuilder; + +public class FTLTestServer { + + Server grpcServer; + + public void start() { + + var addr = new InetSocketAddress("127.0.0.1", 0); + grpcServer = NettyServerBuilder.forAddress(addr) + .addService(new TestVerbServer()) + .build(); + try { + grpcServer.start(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public int getPort() { + return grpcServer.getPort(); + } + + public void stop() { + grpcServer.shutdown(); + } +} diff --git a/java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/internal/TestVerbServer.java b/java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/internal/TestVerbServer.java new file mode 100644 index 0000000000..bd46c97b0c --- /dev/null +++ b/java-runtime/ftl-runtime/test-framework/src/main/java/xyz/block/ftl/java/test/internal/TestVerbServer.java @@ -0,0 +1,109 @@ +package xyz.block.ftl.java.test.internal; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.protobuf.ByteString; + +import io.grpc.ManagedChannelBuilder; +import io.grpc.stub.StreamObserver; +import io.quarkus.arc.Arc; +import xyz.block.ftl.v1.AcquireLeaseRequest; +import xyz.block.ftl.v1.AcquireLeaseResponse; +import xyz.block.ftl.v1.CallRequest; +import xyz.block.ftl.v1.CallResponse; +import xyz.block.ftl.v1.ModuleContextRequest; +import xyz.block.ftl.v1.ModuleContextResponse; +import xyz.block.ftl.v1.PingRequest; +import xyz.block.ftl.v1.PingResponse; +import xyz.block.ftl.v1.PublishEventRequest; +import xyz.block.ftl.v1.PublishEventResponse; +import xyz.block.ftl.v1.SendFSMEventRequest; +import xyz.block.ftl.v1.SendFSMEventResponse; +import xyz.block.ftl.v1.VerbServiceGrpc; + +public class TestVerbServer extends VerbServiceGrpc.VerbServiceImplBase { + + final VerbServiceGrpc.VerbServiceStub verbService; + + /** + * TODO: this is so hacked up + */ + static final Map> fakeVerbs = new HashMap<>(); + + public TestVerbServer() { + var channelBuilder = ManagedChannelBuilder.forAddress("127.0.0.1", 8081); + channelBuilder.usePlaintext(); + var channel = channelBuilder.build(); + verbService = VerbServiceGrpc.newStub(channel); + } + + @Override + public void call(CallRequest request, StreamObserver responseObserver) { + Key key = new Key(request.getVerb().getModule(), request.getVerb().getName()); + if (fakeVerbs.containsKey(key)) { + //TODO: YUCK YUCK YUCK + //This all needs a refactor + ObjectMapper mapper = Arc.container().instance(ObjectMapper.class).get(); + + Function function = fakeVerbs.get(key); + Class type = null; + for (var m : function.getClass().getMethods()) { + if (m.getName().equals("apply") && m.getParameterCount() == 1) { + type = m.getParameterTypes()[0]; + if (type != Object.class) { + break; + } + } + } + try { + var result = function.apply(mapper.readerFor(type).readValue(request.getBody().newInput())); + responseObserver.onNext( + CallResponse.newBuilder().setBody(ByteString.copyFrom(mapper.writeValueAsBytes(result))).build()); + responseObserver.onCompleted(); + } catch (IOException e) { + responseObserver.onError(e); + } + return; + } + verbService.call(request, responseObserver); + } + + @Override + public void publishEvent(PublishEventRequest request, StreamObserver responseObserver) { + super.publishEvent(request, responseObserver); + } + + @Override + public void sendFSMEvent(SendFSMEventRequest request, StreamObserver responseObserver) { + super.sendFSMEvent(request, responseObserver); + } + + @Override + public StreamObserver acquireLease(StreamObserver responseObserver) { + return super.acquireLease(responseObserver); + } + + @Override + public void getModuleContext(ModuleContextRequest request, StreamObserver responseObserver) { + responseObserver.onNext(ModuleContextResponse.newBuilder().setModule("test") + .putConfigs("test", ByteString.copyFrom("test", StandardCharsets.UTF_8)).build()); + } + + @Override + public void ping(PingRequest request, StreamObserver responseObserver) { + responseObserver.onNext(PingResponse.newBuilder().build()); + responseObserver.onCompleted(); + } + + public static void registerFakeVerb(String module, String verb, Function verbFunction) { + fakeVerbs.put(new Key(module, verb), verbFunction); + } + + record Key(String module, String verb) { + } +} diff --git a/java-runtime/ftl-runtime/test-framework/src/main/resources/application.properties b/java-runtime/ftl-runtime/test-framework/src/main/resources/application.properties new file mode 100644 index 0000000000..e69de29bb2