Skip to content

Commit

Permalink
feat: initial java <-> go integration test (#2339)
Browse files Browse the repository at this point in the history
Initial PR that tests simple interop. This will be expanded to cover as
many different types and features as possible.
stuartwdouglas authored Aug 13, 2024
1 parent 9e82265 commit 907731e
Showing 15 changed files with 364 additions and 16 deletions.
5 changes: 4 additions & 1 deletion go-runtime/compile/build.go
Original file line number Diff line number Diff line change
@@ -289,7 +289,7 @@ func GenerateStubsForModules(ctx context.Context, projectRoot string, moduleConf
var moduleConfig *moduleconfig.ModuleConfig
for _, mc := range moduleConfigs {
mcCopy := mc
if mc.Module == module.Name {
if mc.Module == module.Name && mc.Language == "go" {
moduleConfig = &mcCopy
break
}
@@ -362,6 +362,9 @@ func SyncGeneratedStubReferences(ctx context.Context, projectRootDir string, stu
}
sharedModulesPaths = append(sharedModulesPaths, filepath.Join(projectRootDir, buildDirName, "go", "modules", mod))
}
if moduleConfig.Language != "go" {
continue
}

_, goModVersion, err := updateGoModule(filepath.Join(moduleConfig.Dir, "go.mod"))
if err != nil {
23 changes: 19 additions & 4 deletions integration/harness.go
Original file line number Diff line number Diff line change
@@ -51,7 +51,18 @@ var buildOnce sync.Once
// "database/ftl-project.toml" would set FTL_CONFIG to
// "integration/testdata/go/database/ftl-project.toml").
func Run(t *testing.T, ftlConfigPath string, actions ...Action) {
run(t, ftlConfigPath, true, actions...)
run(t, ftlConfigPath, true, false, actions...)
}

// RunWithJava runs an integration test after building the Java runtime.
// ftlConfigPath: if FTL_CONFIG should be set for this test, then pass in the relative
//
// path based on ./testdata/go/ where "." denotes the directory containing the
// integration test (e.g. for "integration/harness_test.go" supplying
// "database/ftl-project.toml" would set FTL_CONFIG to
// "integration/testdata/go/database/ftl-project.toml").
func RunWithJava(t *testing.T, ftlConfigPath string, actions ...Action) {
run(t, ftlConfigPath, true, true, actions...)
}

// RunWithoutController runs an integration test without starting the controller.
@@ -62,7 +73,7 @@ func Run(t *testing.T, ftlConfigPath string, actions ...Action) {
// "database/ftl-project.toml" would set FTL_CONFIG to
// "integration/testdata/go/database/ftl-project.toml").
func RunWithoutController(t *testing.T, ftlConfigPath string, actions ...Action) {
run(t, ftlConfigPath, false, actions...)
run(t, ftlConfigPath, false, false, actions...)
}

func RunWithEncryption(t *testing.T, ftlConfigPath string, actions ...Action) {
@@ -72,10 +83,10 @@ func RunWithEncryption(t *testing.T, ftlConfigPath string, actions ...Action) {
t.Setenv("FTL_LOG_ENCRYPTION_KEY", logKey)
t.Setenv("FTL_ASYNC_ENCRYPTION_KEY", asyncKey)

run(t, ftlConfigPath, true, actions...)
run(t, ftlConfigPath, true, false, actions...)
}

func run(t *testing.T, ftlConfigPath string, startController bool, actions ...Action) {
func run(t *testing.T, ftlConfigPath string, startController bool, requireJava bool, actions ...Action) {
tmpDir := t.TempDir()

cwd, err := os.Getwd()
@@ -116,6 +127,10 @@ func run(t *testing.T, ftlConfigPath string, startController bool, actions ...Ac
Infof("Building ftl")
err = ftlexec.Command(ctx, log.Debug, rootDir, "just", "build", "ftl").RunBuffered(ctx)
assert.NoError(t, err)
if requireJava {
err = ftlexec.Command(ctx, log.Debug, rootDir, "just", "build-java").RunBuffered(ctx)
assert.NoError(t, err)
}
})

verbs := rpc.Dial(ftlv1connect.NewVerbServiceClient, "http://localhost:8892", log.Debug)
Original file line number Diff line number Diff line change
@@ -113,7 +113,7 @@ public boolean trigger(CodeGenContext context) throws CodeGenException {
.build())
.addModifiers(Modifier.PUBLIC);
if (verb.getRequest().hasUnit() && verb.getResponse().hasUnit()) {
typeBuilder.addSuperinterface(ParameterizedTypeName.get(ClassName.get(VerbClientEmpty.class)));
typeBuilder.addSuperinterface(ClassName.get(VerbClientEmpty.class));
} else if (verb.getRequest().hasUnit()) {
typeBuilder.addSuperinterface(ParameterizedTypeName.get(ClassName.get(VerbClientSource.class),
toJavaTypeName(verb.getResponse(), typeAliasMap)));
Original file line number Diff line number Diff line change
@@ -75,6 +75,7 @@
import xyz.block.ftl.runtime.FTLController;
import xyz.block.ftl.runtime.FTLHttpHandler;
import xyz.block.ftl.runtime.FTLRecorder;
import xyz.block.ftl.runtime.JsonSerializationConfig;
import xyz.block.ftl.runtime.TopicHelper;
import xyz.block.ftl.runtime.VerbClientHelper;
import xyz.block.ftl.runtime.VerbHandler;
@@ -149,8 +150,9 @@ BindableServiceBuildItem verbService() {
@BuildStep
AdditionalBeanBuildItem beans() {
return AdditionalBeanBuildItem.builder()
.addBeanClasses(VerbHandler.class, VerbRegistry.class, FTLHttpHandler.class, FTLController.class,
TopicHelper.class, VerbClientHelper.class)
.addBeanClasses(VerbHandler.class,
VerbRegistry.class, FTLHttpHandler.class, FTLController.class,
TopicHelper.class, VerbClientHelper.class, JsonSerializationConfig.class)
.setUnremovable().build();
}

@@ -477,8 +479,10 @@ private void handleVerbMethod(ExtractionContext context, MethodInfo method, Stri
verbBuilder.addMetadata(Metadata.newBuilder().setCalls(callsMetadata));
}

//TODO: we need better handling around Optional
context.recorder.registerVerb(context.moduleName(), verbName, method.name(), parameterTypes,
Class.forName(className, false, Thread.currentThread().getContextClassLoader()), paramMappers);
Class.forName(className, false, Thread.currentThread().getContextClassLoader()), paramMappers,
method.returnType() == VoidType.VOID);
verbBuilder
.setName(verbName)
.setExport(exported)
Original file line number Diff line number Diff line change
@@ -21,13 +21,14 @@ public class FTLRecorder {
public static final String X_FTL_VERB = "X-ftl-verb";

public void registerVerb(String module, String verbName, String methodName, List<Class<?>> parameterTypes,
Class<?> verbHandlerClass, List<BiFunction<ObjectMapper, CallRequest, Object>> paramMappers) {
Class<?> verbHandlerClass, List<BiFunction<ObjectMapper, CallRequest, Object>> paramMappers,
boolean allowNullReturn) {
//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);
paramMappers, allowNullReturn);
} catch (Exception e) {
throw new RuntimeException(e);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package xyz.block.ftl.runtime;

import jakarta.enterprise.event.Observes;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;

import io.quarkus.runtime.StartupEvent;

/**
* This class configures the FTL serialization
*/
public class JsonSerializationConfig {

void startup(@Observes StartupEvent event, ObjectMapper mapper) {
mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
}
}
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@

import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -35,8 +36,8 @@ public VerbRegistry(ObjectMapper mapper) {
}

public void register(String module, String name, InstanceHandle<?> verbHandlerClass, Method method,
List<BiFunction<ObjectMapper, CallRequest, Object>> paramMappers) {
verbs.put(new Key(module, name), new AnnotatedEndpointHandler(verbHandlerClass, method, paramMappers));
List<BiFunction<ObjectMapper, CallRequest, Object>> paramMappers, boolean allowNullReturn) {
verbs.put(new Key(module, name), new AnnotatedEndpointHandler(verbHandlerClass, method, paramMappers, allowNullReturn));
}

public void register(String module, String name, VerbInvoker verbInvoker) {
@@ -60,12 +61,14 @@ private class AnnotatedEndpointHandler implements VerbInvoker {
final InstanceHandle<?> verbHandlerClass;
final Method method;
final List<BiFunction<ObjectMapper, CallRequest, Object>> parameterSuppliers;
final boolean allowNull;

private AnnotatedEndpointHandler(InstanceHandle<?> verbHandlerClass, Method method,
List<BiFunction<ObjectMapper, CallRequest, Object>> parameterSuppliers) {
List<BiFunction<ObjectMapper, CallRequest, Object>> parameterSuppliers, boolean allowNull) {
this.verbHandlerClass = verbHandlerClass;
this.method = method;
this.parameterSuppliers = parameterSuppliers;
this.allowNull = allowNull;
}

public CallResponse handle(CallRequest in) {
@@ -76,8 +79,18 @@ public CallResponse handle(CallRequest in) {
}
Object ret;
ret = method.invoke(verbHandlerClass.get(), params);
var mappedResponse = mapper.writer().writeValueAsBytes(ret);
return CallResponse.newBuilder().setBody(ByteString.copyFrom(mappedResponse)).build();
if (ret == null) {
if (allowNull) {
return CallResponse.newBuilder().setBody(ByteString.copyFrom("{}", StandardCharsets.UTF_8)).build();
} else {
return CallResponse.newBuilder().setError(
CallResponse.Error.newBuilder().setMessage("Verb returned an unexpected null response").build())
.build();
}
} else {
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())
58 changes: 58 additions & 0 deletions java-runtime/java_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//go:build integration

package ftl_test

import (
"testing"
"time"

"github.com/alecthomas/assert/v2"

in "github.com/TBD54566975/ftl/integration"

"github.com/alecthomas/repr"
)

func TestJavaToGoCall(t *testing.T) {
in.RunWithJava(t, "",
in.CopyModule("gomodule"),
in.CopyDir("javamodule", "javamodule"),
in.Deploy("gomodule"),
in.Deploy("javamodule"),
in.Call("javamodule", "timeVerb", in.Obj{}, func(t testing.TB, response in.Obj) {
message, ok := response["time"].(string)
assert.True(t, ok, "time is not a string: %s", repr.String(response))
result, err := time.Parse(time.RFC3339, message)
assert.NoError(t, err, "time is not a valid RFC3339 time: %s", message)
assert.True(t, result.After(time.Now().Add(-time.Minute)), "time is not recent: %s", message)
}),
// We call both the go and pass through Java versions
// To make sure the response is the same
in.Call("gomodule", "emptyVerb", in.Obj{}, func(t testing.TB, response in.Obj) {
assert.Equal(t, map[string]any{}, response, "expecting empty response, got %s", repr.String(response))
}),
in.Call("javamodule", "emptyVerb", in.Obj{}, func(t testing.TB, response in.Obj) {
assert.Equal(t, map[string]any{}, response, "expecting empty response, got %s", repr.String(response))
}),
in.Call("gomodule", "sinkVerb", "ignored", func(t testing.TB, response in.Obj) {
assert.Equal(t, map[string]any{}, response, "expecting empty response, got %s", repr.String(response))
}),
in.Call("javamodule", "sinkVerb", "ignored", func(t testing.TB, response in.Obj) {
assert.Equal(t, map[string]any{}, response, "expecting empty response, got %s", repr.String(response))
}),
in.Call("gomodule", "sourceVerb", in.Obj{}, func(t testing.TB, response string) {
assert.Equal(t, "Source Verb", response, "expecting empty response, got %s", response)
}),
in.Call("javamodule", "sourceVerb", in.Obj{}, func(t testing.TB, response string) {
assert.Equal(t, "Source Verb", response, "expecting empty response, got %s", response)
}),
in.Fail(
in.Call("gomodule", "errorEmptyVerb", in.Obj{}, func(t testing.TB, response in.Obj) {
assert.Equal(t, map[string]any{}, response, "expecting empty response, got %s", repr.String(response))
}), "verb failed"),
in.Fail(
in.Call("gomodule", "errorEmptyVerb", in.Obj{}, func(t testing.TB, response in.Obj) {
assert.Equal(t, map[string]any{}, response, "expecting empty response, got %s", repr.String(response))
}), "verb failed"),
)
}
5 changes: 5 additions & 0 deletions java-runtime/testdata/go/gomodule/ftl.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module = "gomodule"
language = "go"

[go.replace]
"github.com/TBD54566975/ftl" = "../.."
5 changes: 5 additions & 0 deletions java-runtime/testdata/go/gomodule/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module ftl/gomodule

go 1.22.2

replace github.com/TBD54566975/ftl => ./../../../..
Empty file.
38 changes: 38 additions & 0 deletions java-runtime/testdata/go/gomodule/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package gomodule

import (
"context"
"fmt"
"time"
)

type TimeRequest struct {
}
type TimeResponse struct {
Time time.Time
}

//ftl:verb export
func SourceVerb(ctx context.Context) (string, error) {
return "Source Verb", nil
}

//ftl:verb export
func SinkVerb(ctx context.Context, req string) error {
return nil
}

//ftl:verb export
func EmptyVerb(ctx context.Context) error {
return nil
}

//ftl:verb export
func ErrorEmptyVerb(ctx context.Context) error {
return fmt.Errorf("verb failed")
}

//ftl:verb export
func Time(ctx context.Context, req TimeRequest) (TimeResponse, error) {
return TimeResponse{Time: time.Now()}, nil
}
2 changes: 2 additions & 0 deletions java-runtime/testdata/go/javamodule/ftl.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module = "echoclient"
language = "java"
141 changes: 141 additions & 0 deletions java-runtime/testdata/go/javamodule/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>xyz.block.ftl.examples</groupId>
<artifactId>javamodule</artifactId>
<version>1.0.0-SNAPSHOT</version>

<properties>
<ftl.version>1.0-SNAPSHOT</ftl.version>
<compiler-plugin.version>3.13.0</compiler-plugin.version>
<kotlin.version>2.0.0</kotlin.version>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
<quarkus.platform.version>3.12.3</quarkus.platform.version>
<skipITs>true</skipITs>
<surefire-plugin.version>3.2.5</surefire-plugin.version>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>${quarkus.platform.artifact-id}</artifactId>
<version>${quarkus.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<dependency>
<groupId>xyz.block</groupId>
<artifactId>ftl-java-runtime</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-kotlin</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>kotlin-extensions</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.platform.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
<goal>generate-code-tests</goal>
<goal>native-image-agent</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>${compiler-plugin.version}</version>
<configuration>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>${surefire-plugin.version}</version>
<configuration>
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<maven.home>${maven.home}</maven.home>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${surefire-plugin.version}</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
<configuration>
<systemPropertyVariables>
<native.image.path>${project.build.directory}/${project.build.finalName}-runner</native.image.path>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<maven.home>${maven.home}</maven.home>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>

<profiles>
<profile>
<id>native</id>
<activation>
<property>
<name>native</name>
</property>
</activation>
<properties>
<skipITs>false</skipITs>
<quarkus.native.enabled>true</quarkus.native.enabled>
</properties>
</profile>
</profiles>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package xyz.block.ftl.java.test;

import ftl.gomodule.EmptyVerbClient;
import ftl.gomodule.ErrorEmptyVerbClient;
import ftl.gomodule.SinkVerbClient;
import ftl.gomodule.SourceVerbClient;
import ftl.gomodule.TimeClient;
import ftl.gomodule.TimeRequest;
import ftl.gomodule.TimeResponse;
import org.jetbrains.annotations.NotNull;
import xyz.block.ftl.Export;
import xyz.block.ftl.Verb;

public class TestInvokeGo {

@Export
@Verb
public void emptyVerb(EmptyVerbClient emptyVerbClient) {
emptyVerbClient.call();
}

@Export
@Verb
public void sinkVerb(String input, SinkVerbClient sinkVerbClient) {
sinkVerbClient.call(input);
}

@Export
@Verb
public String sourceVerb(SourceVerbClient sourceVerbClient) {
return sourceVerbClient.call();
}
@Export
@Verb
public void errorEmptyVerb(ErrorEmptyVerbClient client) {
client.call();
}

@Export
@Verb
public @NotNull TimeResponse timeVerb(TimeClient client) {
return client.call(new TimeRequest());
}

}

0 comments on commit 907731e

Please sign in to comment.