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.
  • Loading branch information
stuartwdouglas authored Aug 13, 2024
1 parent 9e82265 commit 907731e
Show file tree
Hide file tree
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
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
23 changes: 19 additions & 4 deletions integration/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) {
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
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
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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())
Expand Down
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"
Loading

0 comments on commit 907731e

Please sign in to comment.