diff --git a/internal/integration/actions.go b/internal/integration/actions.go index eacc70969e..34e6f501ce 100644 --- a/internal/integration/actions.go +++ b/internal/integration/actions.go @@ -417,6 +417,32 @@ func VerifySchema(check func(ctx context.Context, t testing.TB, sch *schemapb.Sc } } +// VerifySchemaVerb lets you test the current schema for a specific verb +func VerifySchemaVerb(module string, verb string, check func(ctx context.Context, t testing.TB, sch *schemapb.Verb)) Action { + return func(t testing.TB, ic TestContext) { + sch, err := ic.Controller.GetSchema(ic, connect.NewRequest(&ftlv1.GetSchemaRequest{})) + if err != nil { + t.Errorf("failed to get schema: %v", err) + return + } + if err != nil { + t.Errorf("failed to deserialize schema: %v", err) + return + } + for _, m := range sch.Msg.GetSchema().Modules { + if m.Name == module { + for _, v := range m.Decls { + if v.GetVerb() != nil && v.GetVerb().Name == verb { + check(ic.Context, t, v.GetVerb()) + return + } + } + } + + } + } +} + // Fail expects the next action to Fail. func Fail(next Action, msg string, args ...any) Action { return func(t testing.TB, ic TestContext) { diff --git a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/DefaultOptionalBuildItem.java b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/DefaultOptionalBuildItem.java new file mode 100644 index 0000000000..0d3a54ca1f --- /dev/null +++ b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/DefaultOptionalBuildItem.java @@ -0,0 +1,20 @@ +package xyz.block.ftl.deployment; + +import io.quarkus.builder.item.SimpleBuildItem; + +/** + * Build item that indicates if a type with no nullability information should default to optional. + * + * This is different between Kotlin and Java + */ +public final class DefaultOptionalBuildItem extends SimpleBuildItem { + final boolean defaultToOptional; + + public DefaultOptionalBuildItem(boolean defaultToOptional) { + this.defaultToOptional = defaultToOptional; + } + + public boolean isDefaultToOptional() { + return defaultToOptional; + } +} diff --git a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/HTTPProcessor.java b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/HTTPProcessor.java index e81f4ddf3a..b82867b272 100644 --- a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/HTTPProcessor.java +++ b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/HTTPProcessor.java @@ -157,8 +157,9 @@ public void accept(ModuleBuilder moduleBuilder) { .setIngress(ingressBuilder .build()) .build(); - Type requestTypeParam = moduleBuilder.buildType(bodyParamType, true); - Type responseTypeParam = moduleBuilder.buildType(endpoint.getMethodInfo().returnType(), true); + Type requestTypeParam = moduleBuilder.buildType(bodyParamType, true, Nullability.NOT_NULL); + Type responseTypeParam = moduleBuilder.buildType(endpoint.getMethodInfo().returnType(), true, + Nullability.NOT_NULL); Type stringType = Type.newBuilder().setString(xyz.block.ftl.v1.schema.String.newBuilder().build()).build(); Type pathParamType = Type.newBuilder() .setMap(xyz.block.ftl.v1.schema.Map.newBuilder().setKey(stringType) diff --git a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/ModuleBuilder.java b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/ModuleBuilder.java index 607a7dcf3c..6a14d13aa8 100644 --- a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/ModuleBuilder.java +++ b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/ModuleBuilder.java @@ -17,6 +17,7 @@ import java.util.function.Consumer; import java.util.regex.Pattern; +import org.jboss.jandex.AnnotationTarget; import org.jboss.jandex.ArrayType; import org.jboss.jandex.ClassInfo; import org.jboss.jandex.ClassType; @@ -26,6 +27,7 @@ import org.jboss.jandex.PrimitiveType; import org.jboss.jandex.VoidType; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.databind.JsonNode; @@ -72,6 +74,7 @@ public class ModuleBuilder { public static final DotName INSTANT = DotName.createSimple(Instant.class); public static final DotName ZONED_DATE_TIME = DotName.createSimple(ZonedDateTime.class); public static final DotName NOT_NULL = DotName.createSimple(NotNull.class); + public static final DotName NULLABLE = DotName.createSimple(Nullable.class); public static final DotName JSON_NODE = DotName.createSimple(JsonNode.class.getName()); public static final DotName OFFSET_DATE_TIME = DotName.createSimple(OffsetDateTime.class.getName()); public static final DotName GENERATED_REF = DotName.createSimple(GeneratedRef.class); @@ -89,10 +92,11 @@ public class ModuleBuilder { private final FTLRecorder recorder; private final Map> comments; private final List validationFailures = new ArrayList<>(); + private final boolean defaultToOptional; public ModuleBuilder(IndexView index, String moduleName, Map knownTopics, Map verbClients, FTLRecorder recorder, - Map> comments, Map typeAliases) { + Map> comments, Map typeAliases, boolean defaultToOptional) { this.index = index; this.moduleName = moduleName; this.moduleBuilder = Module.newBuilder() @@ -103,6 +107,7 @@ public ModuleBuilder(IndexView index, String moduleName, Map(typeAliases); + this.defaultToOptional = defaultToOptional; } public static @NotNull String methodToName(MethodInfo method) { @@ -178,6 +183,8 @@ public void registerVerbMethod(MethodInfo method, String className, List> parameterTypes = new ArrayList<>(); List> paramMappers = new ArrayList<>(); org.jboss.jandex.Type bodyParamType = null; + Nullability bodyParamNullability = Nullability.MISSING; + xyz.block.ftl.v1.schema.Verb.Builder verbBuilder = xyz.block.ftl.v1.schema.Verb.newBuilder(); String verbName = validateName(className, ModuleBuilder.methodToName(method)); MetadataCalls.Builder callsMetadata = MetadataCalls.newBuilder(); @@ -189,7 +196,7 @@ public void registerVerbMethod(MethodInfo method, String className, paramMappers.add(new VerbRegistry.SecretSupplier(name, paramType)); if (!knownSecrets.contains(name)) { xyz.block.ftl.v1.schema.Secret.Builder secretBuilder = xyz.block.ftl.v1.schema.Secret.newBuilder() - .setType(buildType(param.type(), false)) + .setType(buildType(param.type(), false, param)) .setName(name); Optional.ofNullable(comments.get(CommentKey.ofSecret(name))) .ifPresent(secretBuilder::addAllComments); @@ -203,7 +210,7 @@ public void registerVerbMethod(MethodInfo method, String className, paramMappers.add(new VerbRegistry.ConfigSupplier(name, paramType)); if (!knownConfig.contains(name)) { xyz.block.ftl.v1.schema.Config.Builder configBuilder = xyz.block.ftl.v1.schema.Config.newBuilder() - .setType(buildType(param.type(), false)) + .setType(buildType(param.type(), false, param)) .setName(name); Optional.ofNullable(comments.get(CommentKey.ofConfig(name))) .ifPresent(configBuilder::addAllComments); @@ -226,6 +233,7 @@ public void registerVerbMethod(MethodInfo method, String className, paramMappers.add(recorder.leaseClientSupplier()); } else if (bodyType != BodyType.DISALLOWED && bodyParamType == null) { bodyParamType = param.type(); + bodyParamNullability = nullability(param); Class paramType = ModuleBuilder.loadClass(param.type()); parameterTypes.add(paramType); //TODO: map and list types @@ -245,15 +253,15 @@ public void registerVerbMethod(MethodInfo method, String className, verbBuilder.addMetadata(Metadata.newBuilder().setCalls(callsMetadata)); } - //TODO: we need better handling around Optional recorder.registerVerb(moduleName, verbName, method.name(), parameterTypes, Class.forName(className, false, Thread.currentThread().getContextClassLoader()), paramMappers, method.returnType() == VoidType.VOID); + verbBuilder .setName(verbName) .setExport(exported) - .setRequest(buildType(bodyParamType, exported)) - .setResponse(buildType(method.returnType(), exported)); + .setRequest(buildType(bodyParamType, exported, bodyParamNullability)) + .setResponse(buildType(method.returnType(), exported, method)); Optional.ofNullable(comments.get(CommentKey.ofVerb(verbName))) .ifPresent(verbBuilder::addAllComments); @@ -269,7 +277,31 @@ public void registerVerbMethod(MethodInfo method, String className, } } - public Type buildType(org.jboss.jandex.Type type, boolean export) { + private Nullability nullability(org.jboss.jandex.AnnotationTarget type) { + if (type.hasAnnotation(NULLABLE)) { + return Nullability.NULLABLE; + } else if (type.hasAnnotation(NOT_NULL)) { + return Nullability.NOT_NULL; + } + return Nullability.MISSING; + } + + private Type handleNullabilityAnnotations(Type res, Nullability nullability) { + if (nullability == Nullability.NOT_NULL) { + return res; + } else if (nullability == Nullability.NULLABLE || defaultToOptional) { + return Type.newBuilder().setOptional(xyz.block.ftl.v1.schema.Optional.newBuilder() + .setType(res)) + .build(); + } + return res; + } + + public Type buildType(org.jboss.jandex.Type type, boolean export, AnnotationTarget target) { + return buildType(type, export, nullability(target)); + } + + public Type buildType(org.jboss.jandex.Type type, boolean export, Nullability nullability) { switch (type.kind()) { case PRIMITIVE -> { var prim = type.asPrimitiveType(); @@ -296,11 +328,13 @@ public Type buildType(org.jboss.jandex.Type type, boolean export) { ArrayType arrayType = type.asArrayType(); if (arrayType.componentType().kind() == org.jboss.jandex.Type.Kind.PRIMITIVE && arrayType .componentType().asPrimitiveType().primitive() == PrimitiveType.Primitive.BYTE) { - return Type.newBuilder().setBytes(Bytes.newBuilder().build()).build(); + return handleNullabilityAnnotations(Type.newBuilder().setBytes(Bytes.newBuilder().build()).build(), + nullability); } - return Type.newBuilder() - .setArray(Array.newBuilder().setElement(buildType(arrayType.componentType(), export)).build()) - .build(); + return handleNullabilityAnnotations(Type.newBuilder() + .setArray(Array.newBuilder() + .setElement(buildType(arrayType.componentType(), export, Nullability.NOT_NULL)).build()) + .build(), nullability); } case CLASS -> { var clazz = type.asClassType(); @@ -313,35 +347,34 @@ public Type buildType(org.jboss.jandex.Type type, boolean export) { PrimitiveType unboxed = PrimitiveType.unbox(clazz); if (unboxed != null) { - Type primitive = buildType(unboxed, export); - if (type.hasAnnotation(NOT_NULL)) { + Type primitive = buildType(unboxed, export, Nullability.NOT_NULL); + if (nullability == Nullability.NOT_NULL) { return primitive; } return Type.newBuilder().setOptional(xyz.block.ftl.v1.schema.Optional.newBuilder() .setType(primitive)) .build(); } - if (info != null && info.hasDeclaredAnnotation(GENERATED_REF)) { + if (info.hasDeclaredAnnotation(GENERATED_REF)) { var ref = info.declaredAnnotation(GENERATED_REF); - return Type.newBuilder() + return handleNullabilityAnnotations(Type.newBuilder() .setRef(Ref.newBuilder().setName(ref.value("name").asString()) .setModule(ref.value("module").asString())) - .build(); + .build(), nullability); } if (clazz.name().equals(DotName.STRING_NAME)) { - return Type.newBuilder().setString(xyz.block.ftl.v1.schema.String.newBuilder().build()).build(); + return handleNullabilityAnnotations( + Type.newBuilder().setString(xyz.block.ftl.v1.schema.String.newBuilder().build()).build(), + nullability); } if (clazz.name().equals(DotName.OBJECT_NAME) || clazz.name().equals(JSON_NODE)) { - return Type.newBuilder().setAny(Any.newBuilder().build()).build(); - } - if (clazz.name().equals(OFFSET_DATE_TIME)) { - return Type.newBuilder().setTime(Time.newBuilder().build()).build(); - } - if (clazz.name().equals(INSTANT)) { - return Type.newBuilder().setTime(Time.newBuilder().build()).build(); + return handleNullabilityAnnotations(Type.newBuilder().setAny(Any.newBuilder().build()).build(), + nullability); } - if (clazz.name().equals(ZONED_DATE_TIME)) { - return Type.newBuilder().setTime(Time.newBuilder().build()).build(); + if (clazz.name().equals(OFFSET_DATE_TIME) || clazz.name().equals(INSTANT) + || clazz.name().equals(ZONED_DATE_TIME)) { + return handleNullabilityAnnotations(Type.newBuilder().setTime(Time.newBuilder().build()).build(), + nullability); } var existing = dataElements.get(new TypeKey(clazz.name().toString(), List.of())); if (existing != null) { @@ -377,43 +410,45 @@ public Type buildType(org.jboss.jandex.Type type, boolean export) { case PARAMETERIZED_TYPE -> { var paramType = type.asParameterizedType(); if (paramType.name().equals(DotName.createSimple(List.class))) { - return Type.newBuilder() - .setArray(Array.newBuilder().setElement(buildType(paramType.arguments().get(0), export))) - .build(); + return handleNullabilityAnnotations(Type.newBuilder() + .setArray(Array.newBuilder() + .setElement(buildType(paramType.arguments().get(0), export, Nullability.NOT_NULL))) + .build(), nullability); } else if (paramType.name().equals(DotName.createSimple(Map.class))) { - return Type.newBuilder().setMap(xyz.block.ftl.v1.schema.Map.newBuilder() - .setKey(buildType(paramType.arguments().get(0), export)) - .setValue(buildType(paramType.arguments().get(1), export))) - .build(); + return handleNullabilityAnnotations(Type.newBuilder().setMap(xyz.block.ftl.v1.schema.Map.newBuilder() + .setKey(buildType(paramType.arguments().get(0), export, Nullability.NOT_NULL)) + .setValue(buildType(paramType.arguments().get(1), export, Nullability.NOT_NULL))) + .build(), nullability); } else if (paramType.name().equals(DotNames.OPTIONAL)) { //TODO: optional kinda sucks return Type.newBuilder().setOptional(xyz.block.ftl.v1.schema.Optional.newBuilder() - .setType(buildType(paramType.arguments().get(0), export))) + .setType(buildType(paramType.arguments().get(0), export, Nullability.NOT_NULL))) .build(); } else if (paramType.name().equals(DotName.createSimple(HttpRequest.class))) { return Type.newBuilder() .setRef(Ref.newBuilder().setModule(BUILTIN).setName(HttpRequest.class.getSimpleName()) - .addTypeParameters(buildType(paramType.arguments().get(0), export))) + .addTypeParameters(buildType(paramType.arguments().get(0), export, Nullability.NOT_NULL))) .build(); } else if (paramType.name().equals(DotName.createSimple(HttpResponse.class))) { return Type.newBuilder() .setRef(Ref.newBuilder().setModule(BUILTIN).setName(HttpResponse.class.getSimpleName()) - .addTypeParameters(buildType(paramType.arguments().get(0), export)) + .addTypeParameters(buildType(paramType.arguments().get(0), export, Nullability.NOT_NULL)) .addTypeParameters(Type.newBuilder().setUnit(Unit.newBuilder().build()))) .build(); } else { ClassInfo classByName = index.getClassByName(paramType.name()); validateName(classByName.name().toString(), classByName.name().local()); var cb = ClassType.builder(classByName.name()); - var main = buildType(cb.build(), export); + var main = buildType(cb.build(), export, Nullability.NOT_NULL); var builder = main.toBuilder(); var refBuilder = builder.getRef().toBuilder(); for (var arg : paramType.arguments()) { - refBuilder.addTypeParameters(buildType(arg, export)); + refBuilder.addTypeParameters(buildType(arg, export, Nullability.NOT_NULL)); } + builder.setRef(refBuilder); - return builder.build(); + return handleNullabilityAnnotations(builder.build(), nullability); } } } @@ -433,7 +468,7 @@ private void buildDataElement(Data.Builder data, DotName className) { for (var field : clazz.fields()) { if (!Modifier.isStatic(field.flags())) { Field.Builder builder = Field.newBuilder().setName(field.name()) - .setType(buildType(field.type(), data.getExport())); + .setType(buildType(field.type(), data.getExport(), field)); if (field.hasAnnotation(JsonAlias.class)) { var aliases = field.annotation(JsonAlias.class); if (aliases.value() != null) { @@ -492,10 +527,12 @@ public void writeTo(OutputStream out) throws IOException { public void registerTypeAlias(String name, org.jboss.jandex.Type finalT, org.jboss.jandex.Type finalS, boolean exported) { validateName(finalT.name().toString(), name); moduleBuilder.addDecls(Decl.newBuilder() - .setTypeAlias(TypeAlias.newBuilder().setType(buildType(finalS, exported)).setName(name).addMetadata(Metadata - .newBuilder() - .setTypeMap(MetadataTypeMap.newBuilder().setRuntime("java").setNativeName(finalT.toString()).build()) - .build())) + .setTypeAlias(TypeAlias.newBuilder().setType(buildType(finalS, exported, Nullability.NOT_NULL)).setName(name) + .addMetadata(Metadata + .newBuilder() + .setTypeMap(MetadataTypeMap.newBuilder().setRuntime("java").setNativeName(finalT.toString()) + .build()) + .build())) .build()); } diff --git a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/ModuleProcessor.java b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/ModuleProcessor.java index bd2c31c4c9..3a3ddee870 100644 --- a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/ModuleProcessor.java +++ b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/ModuleProcessor.java @@ -112,6 +112,7 @@ public void generateSchema(CombinedIndexBuildItem index, TopicsBuildItem topicsBuildItem, VerbClientBuildItem verbClientBuildItem, List typeAliasBuildItems, + DefaultOptionalBuildItem defaultOptionalBuildItem, List schemaContributorBuildItems) throws Exception { String moduleName = moduleNameBuildItem.getModuleName(); Map> comments = readComments(); @@ -133,7 +134,8 @@ public void generateSchema(CombinedIndexBuildItem index, } ModuleBuilder moduleBuilder = new ModuleBuilder(index.getComputingIndex(), moduleName, topicsBuildItem.getTopics(), - verbClientBuildItem.getVerbClients(), recorder, comments, existingRefs); + verbClientBuildItem.getVerbClients(), recorder, comments, existingRefs, + defaultOptionalBuildItem.isDefaultToOptional()); for (var i : schemaContributorBuildItems) { i.getSchemaContributor().accept(moduleBuilder); diff --git a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/Nullability.java b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/Nullability.java new file mode 100644 index 0000000000..6e0c2eb070 --- /dev/null +++ b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/Nullability.java @@ -0,0 +1,7 @@ +package xyz.block.ftl.deployment; + +public enum Nullability { + MISSING, + NULLABLE, + NOT_NULL +} diff --git a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/TopicsProcessor.java b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/TopicsProcessor.java index 5f70c350e9..9d0e39d62a 100644 --- a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/TopicsProcessor.java +++ b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/TopicsProcessor.java @@ -90,7 +90,8 @@ public void accept(ModuleBuilder moduleBuilder) { moduleBuilder.addDecls(Decl.newBuilder().setTopic(xyz.block.ftl.v1.schema.Topic.newBuilder() .setExport(topic.exported()) .setName(topic.topicName()) - .setEvent(moduleBuilder.buildType(topic.eventType(), topic.exported())).build()).build()); + .setEvent(moduleBuilder.buildType(topic.eventType(), topic.exported(), Nullability.NOT_NULL)) + .build()).build()); } } }); diff --git a/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/config/FTLConfigSource.java b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/config/FTLConfigSource.java index a3d85f5064..ae7d941c9e 100644 --- a/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/config/FTLConfigSource.java +++ b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/config/FTLConfigSource.java @@ -96,7 +96,6 @@ public String getValue(String s) { } } if (s.startsWith("quarkus.datasource")) { - System.out.println("prop: " + s); switch (s) { case DEFAULT_USER -> { return Optional.ofNullable(controller.getDatasource("default")).map(FTLController.Datasource::username) @@ -115,19 +114,16 @@ public String getValue(String s) { } Matcher m = USER_PATTERN.matcher(s); if (m.matches()) { - System.out.println("match: " + s); return Optional.ofNullable(controller.getDatasource(m.group(1))).map(FTLController.Datasource::username) .orElse(null); } m = PASSWORD_PATTERN.matcher(s); if (m.matches()) { - System.out.println("match: " + s); return Optional.ofNullable(controller.getDatasource(m.group(1))).map(FTLController.Datasource::password) .orElse(null); } m = URL_PATTERN.matcher(s); if (m.matches()) { - System.out.println("match: " + s); return Optional.ofNullable(controller.getDatasource(m.group(1))).map(FTLController.Datasource::connectionString) .orElse(null); } diff --git a/jvm-runtime/ftl-runtime/java/deployment/src/main/java/xyz/block/ftl/javalang/deployment/FTLJavaProcessor.java b/jvm-runtime/ftl-runtime/java/deployment/src/main/java/xyz/block/ftl/javalang/deployment/FTLJavaProcessor.java new file mode 100644 index 0000000000..b60c463ad3 --- /dev/null +++ b/jvm-runtime/ftl-runtime/java/deployment/src/main/java/xyz/block/ftl/javalang/deployment/FTLJavaProcessor.java @@ -0,0 +1,11 @@ +package xyz.block.ftl.javalang.deployment; + +import io.quarkus.deployment.annotations.BuildStep; +import xyz.block.ftl.deployment.DefaultOptionalBuildItem; + +public class FTLJavaProcessor { + @BuildStep + public DefaultOptionalBuildItem defaultOptional() { + return new DefaultOptionalBuildItem(false); + } +} diff --git a/jvm-runtime/ftl-runtime/kotlin/deployment/src/main/java/xyz/block/ftl/kotlin/deployment/FTLKotlinProcessor.java b/jvm-runtime/ftl-runtime/kotlin/deployment/src/main/java/xyz/block/ftl/kotlin/deployment/FTLKotlinProcessor.java new file mode 100644 index 0000000000..07f1eb05b8 --- /dev/null +++ b/jvm-runtime/ftl-runtime/kotlin/deployment/src/main/java/xyz/block/ftl/kotlin/deployment/FTLKotlinProcessor.java @@ -0,0 +1,11 @@ +package xyz.block.ftl.kotlin.deployment; + +import io.quarkus.deployment.annotations.BuildStep; +import xyz.block.ftl.deployment.DefaultOptionalBuildItem; + +public class FTLKotlinProcessor { + @BuildStep + public DefaultOptionalBuildItem defaultOptional() { + return new DefaultOptionalBuildItem(true); + } +} diff --git a/jvm-runtime/ftl-runtime/kotlin/deployment/src/main/java/xyz/block/ftl/kotlin/deployment/KotlinCodeGenerator.java b/jvm-runtime/ftl-runtime/kotlin/deployment/src/main/java/xyz/block/ftl/kotlin/deployment/KotlinCodeGenerator.java index 038d8f164e..ab78bef0ef 100644 --- a/jvm-runtime/ftl-runtime/kotlin/deployment/src/main/java/xyz/block/ftl/kotlin/deployment/KotlinCodeGenerator.java +++ b/jvm-runtime/ftl-runtime/kotlin/deployment/src/main/java/xyz/block/ftl/kotlin/deployment/KotlinCodeGenerator.java @@ -210,8 +210,7 @@ private TypeName toKotlinTypeName(Type type, Map typeAliasMap, Ma } else if (type.hasString()) { return new ClassName("kotlin", "String"); } else if (type.hasOptional()) { - // Always box for optional, as normal primities can't be null - return toKotlinTypeName(type.getOptional().getType(), typeAliasMap, nativeTypeAliasMap); + return toKotlinTypeName(type.getOptional().getType(), typeAliasMap, nativeTypeAliasMap).copy(true, List.of()); } else if (type.hasRef()) { if (type.getRef().getModule().isEmpty()) { return TypeVariableName.get(type.getRef().getName()); diff --git a/jvm-runtime/jvm_integration_test.go b/jvm-runtime/jvm_integration_test.go index 4870858039..fa6e76b3c4 100644 --- a/jvm-runtime/jvm_integration_test.go +++ b/jvm-runtime/jvm_integration_test.go @@ -148,29 +148,36 @@ func TestJVMCoreFunctionality(t *testing.T) { // tests = append(tests, PairedPrefixVerbTest("nilvalue", "optionalTestObjectOptionalFieldsVerb", ftl.None[any]())...) // Schema comments - tests = append(tests, in.SubTest{Name: "schemaComments", Action: in.VerifySchema(func(ctx context.Context, t testing.TB, sch *schemapb.Schema) { - javaOk := false - kotlinOk := false - for _, module := range sch.Modules { - if module.Name == "gomodule" { - continue - } - for _, decl := range module.Decls { - if decl.GetVerb() != nil { - for _, comment := range decl.GetVerb().GetComments() { - if strings.Contains(comment, "JAVA COMMENT") { - javaOk = true - } - if strings.Contains(comment, "KOTLIN COMMENT") { - kotlinOk = true - } - } + tests = append(tests, JVMTest("schemaComments", func(name string, module string) in.Action { + return in.VerifySchemaVerb(module, "emptyVerb", func(ctx context.Context, t testing.TB, verb *schemapb.Verb) { + ok := false + for _, comment := range verb.GetComments() { + if strings.Contains(comment, "JAVA COMMENT") { + ok = true + } + if strings.Contains(comment, "KOTLIN COMMENT") { + ok = true } } - } - assert.True(t, javaOk, "java comment not found") - assert.True(t, kotlinOk, "kotlin comment not found") - })}) + assert.True(t, ok, "comment not found") + }) + })...) + tests = append(tests, JVMTest("optionalIntVerb", verifyOptionalVerb)...) + tests = append(tests, JVMTest("optionalFloatVerb", verifyOptionalVerb)...) + tests = append(tests, JVMTest("optionalStringVerb", verifyOptionalVerb)...) + tests = append(tests, JVMTest("optionalBytesVerb", verifyOptionalVerb)...) + tests = append(tests, JVMTest("optionalStringArrayVerb", verifyOptionalVerb)...) + tests = append(tests, JVMTest("optionalStringMapVerb", verifyOptionalVerb)...) + tests = append(tests, JVMTest("optionalTimeVerb", verifyOptionalVerb)...) + tests = append(tests, JVMTest("optionalTestObjectVerb", verifyOptionalVerb)...) + tests = append(tests, JVMTest("intVerb", verifyNonOptionalVerb)...) + tests = append(tests, JVMTest("floatVerb", verifyNonOptionalVerb)...) + tests = append(tests, JVMTest("stringVerb", verifyNonOptionalVerb)...) + tests = append(tests, JVMTest("bytesVerb", verifyNonOptionalVerb)...) + tests = append(tests, JVMTest("stringArrayVerb", verifyNonOptionalVerb)...) + tests = append(tests, JVMTest("stringMapVerb", verifyNonOptionalVerb)...) + tests = append(tests, JVMTest("timeVerb", verifyNonOptionalVerb)...) + tests = append(tests, JVMTest("testObjectVerb", verifyNonOptionalVerb)...) in.Run(t, in.WithJavaBuild(), @@ -212,6 +219,19 @@ func PairedTest(name string, testFunc func(module string) in.Action) []in.SubTes } } +func JVMTest(name string, testFunc func(name string, module string) in.Action) []in.SubTest { + return []in.SubTest{ + { + Name: name + "-java", + Action: testFunc(name, "javamodule"), + }, + { + Name: name + "-kotlin", + Action: testFunc(name, "kotlinmodule"), + }, + } +} + func VerbTest[T any](verb string, value T) func(module string) in.Action { return func(module string) in.Action { return in.Call(module, verb, value, func(t testing.TB, response T) { @@ -256,3 +276,21 @@ type ParameterizedType[T any] struct { Option ftl.Option[T] `json:"option"` Map map[string]T `json:"map"` } + +func subTest(name string, test in.Action) in.Action { + return in.SubTests(in.SubTest{Name: name, Action: test}) +} + +func verifyOptionalVerb(name string, module string) in.Action { + return in.VerifySchemaVerb(module, name, func(ctx context.Context, t testing.TB, verb *schemapb.Verb) { + assert.True(t, verb.Response.GetOptional() != nil, "response not optional") + assert.True(t, verb.Request.GetOptional() != nil, "request not optional") + }) +} + +func verifyNonOptionalVerb(name string, module string) in.Action { + return in.VerifySchemaVerb(module, name, func(ctx context.Context, t testing.TB, verb *schemapb.Verb) { + assert.True(t, verb.Response.GetOptional() == nil, "response was optional") + assert.True(t, verb.Request.GetOptional() == nil, "request was optional") + }) +} diff --git a/jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/TestInvokeGoFromJava.java b/jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/TestInvokeGoFromJava.java index 294b37880d..4c785994e3 100644 --- a/jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/TestInvokeGoFromJava.java +++ b/jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/TestInvokeGoFromJava.java @@ -5,6 +5,7 @@ import java.util.Map; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import ftl.gomodule.BoolVerbClient; import ftl.gomodule.BytesVerbClient; @@ -43,6 +44,9 @@ public class TestInvokeGoFromJava { + /** + * JAVA COMMENT + */ @Export @Verb public void emptyVerb(EmptyVerbClient emptyVerbClient) { @@ -61,9 +65,6 @@ public String sourceVerb(SourceVerbClient sourceVerbClient) { return sourceVerbClient.call(); } - /** - * JAVA COMMENT - */ @Export @Verb public void errorEmptyVerb(ErrorEmptyVerbClient client) { @@ -166,43 +167,43 @@ public Double optionalFloatVerb(Double val, OptionalFloatVerbClient client) { @Export @Verb - public String optionalStringVerb(String val, OptionalStringVerbClient client) { + public @Nullable String optionalStringVerb(@Nullable String val, OptionalStringVerbClient client) { return client.call(val); } @Export @Verb - public byte[] optionalBytesVerb(byte[] val, OptionalBytesVerbClient client) { + public byte @Nullable [] optionalBytesVerb(byte @Nullable [] val, OptionalBytesVerbClient client) { return client.call(val); } @Export @Verb - public boolean optionalBoolVerb(boolean val, OptionalBoolVerbClient client) { + public Boolean optionalBoolVerb(Boolean val, OptionalBoolVerbClient client) { return client.call(val); } @Export @Verb - public List optionalStringArrayVerb(List val, OptionalStringArrayVerbClient client) { + public @Nullable List optionalStringArrayVerb(@Nullable List val, OptionalStringArrayVerbClient client) { return client.call(val); } @Export @Verb - public Map optionalStringMapVerb(Map val, OptionalStringMapVerbClient client) { + public @Nullable Map optionalStringMapVerb(@Nullable Map val, OptionalStringMapVerbClient client) { return client.call(val); } @Export @Verb - public ZonedDateTime optionalTimeVerb(ZonedDateTime instant, OptionalTimeVerbClient client) { + public @Nullable ZonedDateTime optionalTimeVerb(@Nullable ZonedDateTime instant, OptionalTimeVerbClient client) { return client.call(instant); } @Export @Verb - public TestObject optionalTestObjectVerb(TestObject val, OptionalTestObjectVerbClient client) { + public @Nullable TestObject optionalTestObjectVerb(@Nullable TestObject val, OptionalTestObjectVerbClient client) { return client.call(val); } diff --git a/jvm-runtime/testdata/kotlin/kotlinmodule/src/main/kotlin/xyz/block/ftl/test/TestInvokeGoFromKotlin.kt b/jvm-runtime/testdata/kotlin/kotlinmodule/src/main/kotlin/xyz/block/ftl/test/TestInvokeGoFromKotlin.kt index b54d638831..909d8aac6b 100644 --- a/jvm-runtime/testdata/kotlin/kotlinmodule/src/main/kotlin/xyz/block/ftl/test/TestInvokeGoFromKotlin.kt +++ b/jvm-runtime/testdata/kotlin/kotlinmodule/src/main/kotlin/xyz/block/ftl/test/TestInvokeGoFromKotlin.kt @@ -120,55 +120,55 @@ fun testObjectOptionalFieldsVerb( // now the same again but with option return / input types @Export @Verb -fun optionalIntVerb(payload: Long, client: OptionalIntVerbClient): Long { +fun optionalIntVerb(payload: Long?, client: OptionalIntVerbClient): Long? { return client.call(payload) } @Export @Verb -fun optionalFloatVerb(payload: Double, client: OptionalFloatVerbClient): Double { +fun optionalFloatVerb(payload: Double?, client: OptionalFloatVerbClient): Double? { return client.call(payload) } @Export @Verb -fun optionalStringVerb(payload: String, client: OptionalStringVerbClient): String { +fun optionalStringVerb(payload: String?, client: OptionalStringVerbClient): String? { return client.call(payload) } @Export @Verb -fun optionalBytesVerb(payload: ByteArray?, client: OptionalBytesVerbClient): ByteArray { +fun optionalBytesVerb(payload: ByteArray?, client: OptionalBytesVerbClient): ByteArray? { return client.call(payload!!) } @Export @Verb -fun optionalBoolVerb(payload: Boolean, client: OptionalBoolVerbClient): Boolean { +fun optionalBoolVerb(payload: Boolean?, client: OptionalBoolVerbClient): Boolean? { return client.call(payload) } @Export @Verb -fun optionalStringArrayVerb(payload: List, client: OptionalStringArrayVerbClient): List { +fun optionalStringArrayVerb(payload: List?, client: OptionalStringArrayVerbClient): List? { return client.call(payload) } @Export @Verb -fun optionalStringMapVerb(payload: Map, client: OptionalStringMapVerbClient): Map { +fun optionalStringMapVerb(payload: Map?, client: OptionalStringMapVerbClient): Map? { return client.call(payload) } @Export @Verb -fun optionalTimeVerb(instant: ZonedDateTime?, client: OptionalTimeVerbClient): ZonedDateTime { +fun optionalTimeVerb(instant: ZonedDateTime?, client: OptionalTimeVerbClient): ZonedDateTime? { return client.call(instant!!) } @Export @Verb -fun optionalTestObjectVerb(payload: TestObject?, client: OptionalTestObjectVerbClient): TestObject { +fun optionalTestObjectVerb(payload: TestObject?, client: OptionalTestObjectVerbClient): TestObject? { return client.call(payload!!) } @@ -177,7 +177,7 @@ fun optionalTestObjectVerb(payload: TestObject?, client: OptionalTestObjectVerbC fun optionalTestObjectOptionalFieldsVerb( payload: TestObjectOptionalFields?, client: OptionalTestObjectOptionalFieldsVerbClient -): TestObjectOptionalFields { +): TestObjectOptionalFields? { return client.call(payload!!) }