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 0000000000..af6286fa85 Binary files /dev/null and b/examples/kotlin/echo/src/main/ftl-module-schema/builtin.pb differ diff --git a/examples/kotlin/echo/src/main/ftl-module-schema/time.pb b/examples/kotlin/echo/src/main/ftl-module-schema/time.pb new file mode 100644 index 0000000000..05389c8baa Binary files /dev/null and b/examples/kotlin/echo/src/main/ftl-module-schema/time.pb differ 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 0000000000..83a40d59ed Binary files /dev/null and b/java-runtime/ftl-runtime/integration-tests/src/main/ftl-module-schema/builtin.pb differ 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 0000000000..6f55e044cd Binary files /dev/null and b/java-runtime/ftl-runtime/integration-tests/src/main/ftl-module-schema/echo.pb differ 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 0000000000..5388d67667 Binary files /dev/null and b/java-runtime/ftl-runtime/integration-tests/src/main/ftl-module-schema/time.pb differ diff --git a/java-runtime/ftl-runtime/integration-tests/src/main/java/xyz/block/ftl/java/runtime/it/FtlJavaRuntimeResource.java b/java-runtime/ftl-runtime/integration-tests/src/main/java/xyz/block/ftl/java/runtime/it/FtlJavaRuntimeResource.java new file mode 100644 index 0000000000..cf91946a90 --- /dev/null +++ b/java-runtime/ftl-runtime/integration-tests/src/main/java/xyz/block/ftl/java/runtime/it/FtlJavaRuntimeResource.java @@ -0,0 +1,46 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. See the NOTICE file distributed with +* this work for additional information regarding copyright ownership. +* The ASF licenses this file to You under the Apache License, Version 2.0 +* (the "License"); you may not use this file except in compliance with +* the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package xyz.block.ftl.java.runtime.it; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.core.MediaType; + +import ftl.echo.EchoClient; +import ftl.echo.EchoRequest; +import xyz.block.ftl.Verb; + +@ApplicationScoped +public class FtlJavaRuntimeResource { + + @POST + @Consumes(MediaType.APPLICATION_JSON) + public String post(Person person) { + return "Hello " + person.first() + " " + person.last(); + } + + @Verb + public String hello(String name, EchoClient echoClient) { + return "Hello " + echoClient.call(new EchoRequest().setName(name)).getMessage(); + } + + @Verb + public void publish(Person person, MyTopic topic) { + topic.publish(person); + } +} diff --git a/java-runtime/ftl-runtime/integration-tests/src/main/java/xyz/block/ftl/java/runtime/it/MyTopic.java b/java-runtime/ftl-runtime/integration-tests/src/main/java/xyz/block/ftl/java/runtime/it/MyTopic.java new file mode 100644 index 0000000000..f5b381f0da --- /dev/null +++ b/java-runtime/ftl-runtime/integration-tests/src/main/java/xyz/block/ftl/java/runtime/it/MyTopic.java @@ -0,0 +1,10 @@ +package xyz.block.ftl.java.runtime.it; + +import xyz.block.ftl.Export; +import xyz.block.ftl.Topic; +import xyz.block.ftl.TopicDefinition; + +@Export +@TopicDefinition(name = "testTopic") +public interface MyTopic extends Topic { +} 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