From b0465e3c2d14cb72400b8724058db1445c663619 Mon Sep 17 00:00:00 2001 From: Tom Daffurn Date: Thu, 10 Oct 2024 09:46:19 +1000 Subject: [PATCH] feat: Enums in JVM (#2842) For #2514 Tasks: - [x] Test cases for all enum variants - [x] Generate Java classes for enums in schema - [x] Serialize/deserialise enums for JVM verb server - [x] Serialize/deserialise enums for JVM verb clients - [x] Generate schema for enums in Java code - ~~Generate schema a nd classes for enums in Kotlin code~~ #3062 - ~~Update comment extraction code to include enums~~ #3061 --- .../ftl/deployment/DatasourceProcessor.java | 6 +- .../block/ftl/deployment/EnumProcessor.java | 189 ++++++++++++++++ .../xyz/block/ftl/deployment/FTLDotNames.java | 6 + .../ftl/deployment/JVMCodeGenerator.java | 20 +- .../block/ftl/deployment/ModuleBuilder.java | 195 +++++++++++----- .../block/ftl/deployment/ModuleProcessor.java | 22 +- .../ftl/deployment/SubscriptionProcessor.java | 7 +- .../block/ftl/deployment/TopicsProcessor.java | 3 + .../ftl/deployment/TypeAliasProcessor.java | 14 +- .../block/ftl/deployment/VerbProcessor.java | 19 +- .../src/main/java/xyz/block/ftl/Enum.java | 14 ++ .../main/java/xyz/block/ftl/EnumHolder.java | 14 ++ .../xyz/block/ftl/runtime/FTLRecorder.java | 16 ++ .../ftl/runtime/JsonSerializationConfig.java | 129 ++++++++++- .../java/xyz/block/ftl/runtime/Animal.java | 17 ++ .../test/java/xyz/block/ftl/runtime/Cat.java | 44 ++++ .../java/xyz/block/ftl/runtime/ColorInt.java | 20 ++ .../test/java/xyz/block/ftl/runtime/Dog.java | 16 ++ .../runtime/JsonSerializationConfigTest.java | 49 ++++ .../java/xyz/block/ftl/runtime/Shape.java | 20 ++ .../deployment/JavaCodeGenerator.java | 212 +++++++++++++++--- .../deployment/KotlinCodeGenerator.java | 5 +- jvm-runtime/jvm_integration_test.go | 147 ++++++++---- jvm-runtime/testdata/go/gomodule/server.go | 87 +++++++ jvm-runtime/testdata/java/javaclient/ftl.toml | 2 + .../java/{javamodule => javaclient}/pom.xml | 2 +- .../xyz/block/ftl/test/AnySerializedType.java | 0 .../ftl/test/AnySerializedTypeMapper.java | 0 .../xyz/block/ftl/test/ConfigEndpoint.java | 0 .../block/ftl/test/CustomSerializedType.java | 0 .../ftl/test/CustomSerializedTypeMapper.java | 0 .../java/xyz/block/ftl/test/DidMapper.java | 0 .../block/ftl/test/TestInvokeGoFromJava.java | 50 +++++ .../testdata/java/javacomments/ftl.toml | 2 - .../testdata/java/javacomments/pom.xml | 22 -- .../xyz/block/ftl/javacomments/EnumType.java | 14 -- jvm-runtime/testdata/java/javamodule/ftl.toml | 2 - jvm-runtime/testdata/java/javaserver/ftl.toml | 2 + .../java/{verbs => javaserver}/pom.xml | 2 +- .../main/java/xyz/block/ftl/enums/Animal.java | 20 ++ .../xyz/block/ftl/enums/AnimalWrapper.java | 23 ++ .../main/java/xyz/block/ftl/enums/Cat.java | 48 ++++ .../java/xyz/block/ftl/enums/ColorInt.java | 20 ++ .../xyz/block/ftl/enums/ColorWrapper.java | 27 +++ .../main/java/xyz/block/ftl/enums/Dog.java | 19 ++ .../main/java/xyz/block/ftl/enums/List.java | 16 ++ .../main/java/xyz/block/ftl/enums/Scalar.java | 16 ++ .../xyz/block/ftl/enums/ScalarOrList.java | 8 + .../main/java/xyz/block/ftl/enums/Shape.java | 20 ++ .../xyz/block/ftl/enums/ShapeWrapper.java | 23 ++ .../main/java/xyz/block/ftl/enums}/Verbs.java | 19 +- .../ftl/javacomments/CommentedModule.java | 0 .../xyz/block/ftl/javacomments/DataClass.java | 0 .../xyz/block/ftl/javacomments/EnumType.java | 26 +++ jvm-runtime/testdata/java/verbs/ftl.toml | 2 - 55 files changed, 1456 insertions(+), 200 deletions(-) create mode 100644 jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/EnumProcessor.java create mode 100644 jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/Enum.java create mode 100644 jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/EnumHolder.java create mode 100644 jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/Animal.java create mode 100644 jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/Cat.java create mode 100644 jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/ColorInt.java create mode 100644 jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/Dog.java create mode 100644 jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/Shape.java create mode 100644 jvm-runtime/testdata/java/javaclient/ftl.toml rename jvm-runtime/testdata/java/{javamodule => javaclient}/pom.xml (94%) rename jvm-runtime/testdata/java/{javamodule => javaclient}/src/main/java/xyz/block/ftl/test/AnySerializedType.java (100%) rename jvm-runtime/testdata/java/{javamodule => javaclient}/src/main/java/xyz/block/ftl/test/AnySerializedTypeMapper.java (100%) rename jvm-runtime/testdata/java/{javamodule => javaclient}/src/main/java/xyz/block/ftl/test/ConfigEndpoint.java (100%) rename jvm-runtime/testdata/java/{javamodule => javaclient}/src/main/java/xyz/block/ftl/test/CustomSerializedType.java (100%) rename jvm-runtime/testdata/java/{javamodule => javaclient}/src/main/java/xyz/block/ftl/test/CustomSerializedTypeMapper.java (100%) rename jvm-runtime/testdata/java/{javamodule => javaclient}/src/main/java/xyz/block/ftl/test/DidMapper.java (100%) rename jvm-runtime/testdata/java/{javamodule => javaclient}/src/main/java/xyz/block/ftl/test/TestInvokeGoFromJava.java (79%) delete mode 100644 jvm-runtime/testdata/java/javacomments/ftl.toml delete mode 100644 jvm-runtime/testdata/java/javacomments/pom.xml delete mode 100644 jvm-runtime/testdata/java/javacomments/src/main/java/xyz/block/ftl/javacomments/EnumType.java delete mode 100644 jvm-runtime/testdata/java/javamodule/ftl.toml create mode 100644 jvm-runtime/testdata/java/javaserver/ftl.toml rename jvm-runtime/testdata/java/{verbs => javaserver}/pom.xml (95%) create mode 100644 jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Animal.java create mode 100644 jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/AnimalWrapper.java create mode 100644 jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Cat.java create mode 100644 jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/ColorInt.java create mode 100644 jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/ColorWrapper.java create mode 100644 jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Dog.java create mode 100644 jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/List.java create mode 100644 jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Scalar.java create mode 100644 jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/ScalarOrList.java create mode 100644 jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Shape.java create mode 100644 jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/ShapeWrapper.java rename jvm-runtime/testdata/java/{verbs/src/main/java/xyz/block/ftl/test => javaserver/src/main/java/xyz/block/ftl/enums}/Verbs.java (50%) rename jvm-runtime/testdata/java/{javacomments => javaserver}/src/main/java/xyz/block/ftl/javacomments/CommentedModule.java (100%) rename jvm-runtime/testdata/java/{javacomments => javaserver}/src/main/java/xyz/block/ftl/javacomments/DataClass.java (100%) create mode 100644 jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/javacomments/EnumType.java delete mode 100644 jvm-runtime/testdata/java/verbs/ftl.toml diff --git a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/DatasourceProcessor.java b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/DatasourceProcessor.java index 0f3aaf767..b0f72df4b 100644 --- a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/DatasourceProcessor.java +++ b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/DatasourceProcessor.java @@ -4,6 +4,8 @@ import java.util.ArrayList; import java.util.List; +import org.jboss.logging.Logger; + import io.quarkus.agroal.spi.JdbcDataSourceBuildItem; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; @@ -16,12 +18,14 @@ public class DatasourceProcessor { + private static final Logger log = Logger.getLogger(DatasourceProcessor.class); + @BuildStep public SchemaContributorBuildItem registerDatasources( List datasources, BuildProducer systemPropProducer, BuildProducer generatedResourceBuildItemBuildProducer) { - + log.infof("Processing %d datasource annotations into decls", datasources.size()); List decls = new ArrayList<>(); List namedDatasources = new ArrayList<>(); for (var ds : datasources) { diff --git a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/EnumProcessor.java b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/EnumProcessor.java new file mode 100644 index 000000000..04a1db79a --- /dev/null +++ b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/EnumProcessor.java @@ -0,0 +1,189 @@ +package xyz.block.ftl.deployment; + +import static org.jboss.jandex.PrimitiveType.Primitive.BYTE; +import static org.jboss.jandex.PrimitiveType.Primitive.INT; +import static org.jboss.jandex.PrimitiveType.Primitive.LONG; +import static org.jboss.jandex.PrimitiveType.Primitive.SHORT; +import static xyz.block.ftl.deployment.FTLDotNames.ENUM_HOLDER; +import static xyz.block.ftl.deployment.FTLDotNames.GENERATED_REF; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.ClassType; +import org.jboss.jandex.DotName; +import org.jboss.jandex.FieldInfo; +import org.jboss.jandex.PrimitiveType; +import org.jboss.jandex.Type; +import org.jboss.logging.Logger; + +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import xyz.block.ftl.runtime.FTLRecorder; +import xyz.block.ftl.v1.schema.Decl; +import xyz.block.ftl.v1.schema.Enum; +import xyz.block.ftl.v1.schema.EnumVariant; +import xyz.block.ftl.v1.schema.Int; +import xyz.block.ftl.v1.schema.IntValue; +import xyz.block.ftl.v1.schema.StringValue; +import xyz.block.ftl.v1.schema.TypeValue; +import xyz.block.ftl.v1.schema.Value; + +public class EnumProcessor { + + private static final Logger log = Logger.getLogger(EnumProcessor.class); + public static final Set INT_TYPES = Set.of(INT, LONG, BYTE, SHORT); + + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + SchemaContributorBuildItem handleEnums(CombinedIndexBuildItem index, FTLRecorder recorder) { + var enumAnnotations = index.getIndex().getAnnotations(FTLDotNames.ENUM); + log.infof("Processing %d enum annotations into decls", enumAnnotations.size()); + return new SchemaContributorBuildItem(moduleBuilder -> { + try { + var decls = extractEnumDecls(index, enumAnnotations, recorder, moduleBuilder); + for (var decl : decls) { + moduleBuilder.addDecls(decl); + } + } catch (ClassNotFoundException | NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + }); + } + + /** + * Extract all enums for this module, returning a Decl for each. Also registers the enums with the recorder, which + * sets up Jackson serialization in the runtime. + * ModuleBuilder.buildType is used, and has the side effect of adding child Decls to the module. + */ + private List extractEnumDecls(CombinedIndexBuildItem index, Collection enumAnnotations, + FTLRecorder recorder, ModuleBuilder moduleBuilder) + throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException { + List decls = new ArrayList<>(); + for (var enumAnnotation : enumAnnotations) { + boolean exported = enumAnnotation.target().hasAnnotation(FTLDotNames.EXPORT); + ClassInfo classInfo = enumAnnotation.target().asClass(); + Class clazz = Class.forName(classInfo.name().toString(), false, + Thread.currentThread().getContextClassLoader()); + var isLocalToModule = !classInfo.hasDeclaredAnnotation(GENERATED_REF); + + if (classInfo.isEnum()) { + // Value enum + recorder.registerEnum(clazz); + if (isLocalToModule) { + decls.add(extractValueEnum(classInfo, clazz, exported)); + } + } else { + var typeEnum = extractTypeEnum(index, moduleBuilder, classInfo, exported); + recorder.registerEnum(clazz, typeEnum.variantClasses); + if (isLocalToModule) { + decls.add(typeEnum.decl); + } + } + } + return decls; + } + + /** + * Value enums are Java language enums with a single field 'value' + */ + private Decl extractValueEnum(ClassInfo classInfo, Class clazz, boolean exported) + throws NoSuchFieldException, IllegalAccessException { + Enum.Builder enumBuilder = Enum.newBuilder() + .setName(classInfo.simpleName()) + .setExport(exported); + FieldInfo valueField = classInfo.field("value"); + if (valueField == null) { + throw new RuntimeException("Enum must have a 'value' field: " + classInfo.name()); + } + Type type = valueField.type(); + xyz.block.ftl.v1.schema.Type.Builder typeBuilder = xyz.block.ftl.v1.schema.Type.newBuilder(); + if (isInt(type)) { + typeBuilder.setInt(Int.newBuilder().build()).build(); + } else if (type.name().equals(DotName.STRING_NAME)) { + typeBuilder.setString(xyz.block.ftl.v1.schema.String.newBuilder().build()); + } else { + throw new RuntimeException( + "Enum value type must be String, int, long, short, or byte: " + classInfo.name()); + } + enumBuilder.setType(typeBuilder.build()); + + for (var constant : clazz.getEnumConstants()) { + Field value = constant.getClass().getDeclaredField("value"); + value.setAccessible(true); + Value.Builder valueBuilder = Value.newBuilder(); + if (isInt(type)) { + long aLong = value.getLong(constant); + valueBuilder.setIntValue(IntValue.newBuilder().setValue(aLong).build()); + } else { + String aString = (String) value.get(constant); + valueBuilder.setStringValue(StringValue.newBuilder().setValue(aString).build()); + } + EnumVariant variant = EnumVariant.newBuilder() + .setName(constant.toString()) + .setValue(valueBuilder) + .build(); + enumBuilder.addVariants(variant); + } + return Decl.newBuilder().setEnum(enumBuilder).build(); + } + + private record TypeEnum(Decl decl, List> variantClasses) { + } + + /** + * Type Enums are an interface with 1+ implementing classes. The classes may be:
+ * - a wrapper for a FTL native type e.g. string, [string]. Has @EnumHolder annotation
+ * - a class with arbitrary fields
+ */ + private TypeEnum extractTypeEnum(CombinedIndexBuildItem index, ModuleBuilder moduleBuilder, + ClassInfo classInfo, boolean exported) throws ClassNotFoundException { + Enum.Builder enumBuilder = Enum.newBuilder() + .setName(classInfo.simpleName()) + .setExport(exported); + var variants = index.getComputingIndex().getAllKnownImplementors(classInfo.name()); + if (variants.isEmpty()) { + throw new RuntimeException("No variants found for enum: " + enumBuilder.getName()); + } + var variantClasses = new ArrayList>(); + for (var variant : variants) { + Type variantType; + if (variant.hasAnnotation(ENUM_HOLDER)) { + // Enum value holder class + FieldInfo valueField = variant.field("value"); + if (valueField == null) { + throw new RuntimeException("Enum variant must have a 'value' field: " + variant.name()); + } + variantType = valueField.type(); + // TODO add to variantClasses; write serialization code for holder classes + } else { + // Class is the enum variant type + variantType = ClassType.builder(variant.name()).build(); + Class variantClazz = Class.forName(variantType.name().toString(), false, + Thread.currentThread().getContextClassLoader()); + variantClasses.add(variantClazz); + } + xyz.block.ftl.v1.schema.Type declType = moduleBuilder.buildType(variantType, exported, + Nullability.NOT_NULL); + TypeValue typeValue = TypeValue.newBuilder().setValue(declType).build(); + + EnumVariant.Builder variantBuilder = EnumVariant.newBuilder() + .setName(variant.simpleName()) + .setValue(Value.newBuilder().setTypeValue(typeValue).build()); + enumBuilder.addVariants(variantBuilder.build()); + } + return new TypeEnum(Decl.newBuilder().setEnum(enumBuilder).build(), variantClasses); + } + + private boolean isInt(Type type) { + return type.kind() == Type.Kind.PRIMITIVE && INT_TYPES.contains(type.asPrimitiveType().primitive()); + } + +} diff --git a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/FTLDotNames.java b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/FTLDotNames.java index 42de5a1d6..2bc6d65a4 100644 --- a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/FTLDotNames.java +++ b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/FTLDotNames.java @@ -4,7 +4,10 @@ import xyz.block.ftl.Config; import xyz.block.ftl.Cron; +import xyz.block.ftl.Enum; +import xyz.block.ftl.EnumHolder; import xyz.block.ftl.Export; +import xyz.block.ftl.GeneratedRef; import xyz.block.ftl.LeaseClient; import xyz.block.ftl.Secret; import xyz.block.ftl.Subscription; @@ -21,10 +24,13 @@ private FTLDotNames() { public static final DotName SECRET = DotName.createSimple(Secret.class); public static final DotName CONFIG = DotName.createSimple(Config.class); public static final DotName EXPORT = DotName.createSimple(Export.class); + public static final DotName ENUM = DotName.createSimple(Enum.class); + public static final DotName ENUM_HOLDER = DotName.createSimple(EnumHolder.class); public static final DotName VERB = DotName.createSimple(Verb.class); public static final DotName CRON = DotName.createSimple(Cron.class); public static final DotName TYPE_ALIAS_MAPPER = DotName.createSimple(TypeAliasMapper.class); public static final DotName TYPE_ALIAS = DotName.createSimple(TypeAlias.class); public static final DotName SUBSCRIPTION = DotName.createSimple(Subscription.class); public static final DotName LEASE_CLIENT = DotName.createSimple(LeaseClient.class); + public static final DotName GENERATED_REF = DotName.createSimple(GeneratedRef.class); } diff --git a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/JVMCodeGenerator.java b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/JVMCodeGenerator.java index ec793af63..47a4f1ef4 100644 --- a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/JVMCodeGenerator.java +++ b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/JVMCodeGenerator.java @@ -11,12 +11,14 @@ import java.util.stream.Stream; import org.eclipse.microprofile.config.Config; +import org.jboss.logging.Logger; import io.quarkus.bootstrap.prebuild.CodeGenException; import io.quarkus.deployment.CodeGenContext; import io.quarkus.deployment.CodeGenProvider; import xyz.block.ftl.v1.schema.Data; import xyz.block.ftl.v1.schema.Enum; +import xyz.block.ftl.v1.schema.EnumVariant; import xyz.block.ftl.v1.schema.Module; import xyz.block.ftl.v1.schema.Topic; import xyz.block.ftl.v1.schema.Type; @@ -26,6 +28,7 @@ public abstract class JVMCodeGenerator implements CodeGenProvider { public static final String PACKAGE_PREFIX = "ftl."; public static final String TYPE_MAPPER = "TypeAliasMapper"; + private static final Logger log = Logger.getLogger(JVMCodeGenerator.class); @Override public String providerId() { @@ -39,12 +42,14 @@ public String inputDirectory() { @Override public boolean trigger(CodeGenContext context) throws CodeGenException { + log.info("Generating JVM clients, data, enums from schema"); if (!Files.isDirectory(context.inputDir())) { return false; } List modules = new ArrayList<>(); Map typeAliasMap = new HashMap<>(); Map nativeTypeAliasMap = new HashMap<>(); + Map> enumVariantInfoMap = new HashMap<>(); try (Stream pathStream = Files.list(context.inputDir())) { for (var file : pathStream.toList()) { String fileName = file.getFileName().toString(); @@ -109,14 +114,16 @@ public boolean trigger(CodeGenContext context) throws CodeGenException { if (!data.getExport()) { continue; } - generateDataObject(module, data, packageName, typeAliasMap, nativeTypeAliasMap, context.outDir()); + generateDataObject(module, data, packageName, typeAliasMap, nativeTypeAliasMap, enumVariantInfoMap, + context.outDir()); } else if (decl.hasEnum()) { var data = decl.getEnum(); if (!data.getExport()) { continue; } - generateEnum(module, data, packageName, typeAliasMap, nativeTypeAliasMap, context.outDir()); + generateEnum(module, data, packageName, typeAliasMap, nativeTypeAliasMap, enumVariantInfoMap, + context.outDir()); } else if (decl.hasTopic()) { var data = decl.getTopic(); if (!data.getExport()) { @@ -141,10 +148,12 @@ protected abstract void generateTopicSubscription(Module module, Topic data, Str Map typeAliasMap, Map nativeTypeAliasMap, Path outputDir) throws IOException; protected abstract void generateEnum(Module module, Enum data, String packageName, Map typeAliasMap, - Map nativeTypeAliasMap, Path outputDir) throws IOException; + Map nativeTypeAliasMap, Map> enumVariantInfoMap, Path outputDir) + throws IOException; protected abstract void generateDataObject(Module module, Data data, String packageName, Map typeAliasMap, - Map nativeTypeAliasMap, Path outputDir) throws IOException; + Map nativeTypeAliasMap, Map> enumVariantInfoMap, Path outputDir) + throws IOException; protected abstract void generateVerb(Module module, Verb verb, String packageName, Map typeAliasMap, Map nativeTypeAliasMap, Path outputDir) throws IOException; @@ -157,6 +166,9 @@ public boolean shouldRun(Path sourceDir, Config config) { public record DeclRef(String module, String name) { } + public record EnumInfo(String interfaceType, EnumVariant variant, List otherVariants) { + } + protected static String className(String in) { return Character.toUpperCase(in.charAt(0)) + in.substring(1); } 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 f2aa76081..2fb6d9d42 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 @@ -1,5 +1,9 @@ package xyz.block.ftl.deployment; +import static xyz.block.ftl.deployment.FTLDotNames.ENUM; +import static xyz.block.ftl.deployment.FTLDotNames.EXPORT; +import static xyz.block.ftl.deployment.FTLDotNames.GENERATED_REF; + import java.io.IOException; import java.io.OutputStream; import java.lang.reflect.Modifier; @@ -35,8 +39,6 @@ import io.quarkus.arc.processor.DotNames; import xyz.block.ftl.Config; -import xyz.block.ftl.Export; -import xyz.block.ftl.GeneratedRef; import xyz.block.ftl.LeaseClient; import xyz.block.ftl.Secret; import xyz.block.ftl.VerbName; @@ -80,13 +82,13 @@ public class ModuleBuilder { 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); - public static final DotName EXPORT = DotName.createSimple(Export.class); + private static final Pattern NAME_PATTERN = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$"); private final IndexView index; - private final Module.Builder moduleBuilder; - private final Map dataElements; + private final Module.Builder protoModuleBuilder; + private final Map decls = new HashMap<>(); + private final Map externalRefs = new HashMap<>(); private final String moduleName; private final Set knownSecrets = new HashSet<>(); private final Set knownConfig = new HashSet<>(); @@ -99,17 +101,16 @@ public class ModuleBuilder { public ModuleBuilder(IndexView index, String moduleName, Map knownTopics, Map verbClients, FTLRecorder recorder, - Map> comments, Map typeAliases, boolean defaultToOptional) { + Map> comments, boolean defaultToOptional) { this.index = index; this.moduleName = moduleName; - this.moduleBuilder = Module.newBuilder() + this.protoModuleBuilder = Module.newBuilder() .setName(moduleName) .setBuiltin(false); this.knownTopics = knownTopics; this.verbClients = verbClients; this.recorder = recorder; this.comments = comments; - this.dataElements = new HashMap<>(typeAliases); this.defaultToOptional = defaultToOptional; } @@ -390,37 +391,38 @@ public Type buildType(org.jboss.jandex.Type type, boolean export, Nullability nu return handleNullabilityAnnotations(Type.newBuilder().setTime(Time.newBuilder().build()).build(), nullability); } - var existing = dataElements.get(new TypeKey(clazz.name().toString(), List.of())); - if (existing != null) { - if (existing.exported() || !export || !existing.ref().getModule().equals(moduleName)) { - return Type.newBuilder().setRef(existing.ref()).build(); - } - //bit of an edge case, we have an existing non-exported object that we need to export - for (var i = 0; i < moduleBuilder.getDeclsCount(); ++i) { - var decl = moduleBuilder.getDecls(i); - if (!decl.hasData()) { - continue; - } - if (decl.getData().getName().equals(existing.ref().getName())) { - moduleBuilder.setDecls(i, - decl.toBuilder().setData(decl.getData().toBuilder().setExport(true)).build()); - break; - } + + String name = clazz.name().local(); + if (externalRefs.containsKey(name)) { + // Ref is to another module. Don't need a Decl + return Type.newBuilder().setRef(externalRefs.get(name)).build(); + } + var ref = Type.newBuilder().setRef( + Ref.newBuilder().setName(name).setModule(moduleName).build()).build(); + + if (info.isEnum() || info.hasAnnotation(ENUM)) { + // Set only the name and export here. EnumProcessor will fill in the rest + xyz.block.ftl.v1.schema.Enum ennum = xyz.block.ftl.v1.schema.Enum.newBuilder() + .setName(name) + .setExport(type.hasAnnotation(EXPORT) || export) + .build(); + addDecls(Decl.newBuilder().setEnum(ennum).build()); + return ref; + } else { + // If this data was processed already, skip early + if (updateData(name, type.hasAnnotation(EXPORT) || export)) { + return ref; } - return Type.newBuilder().setRef(existing.ref()).build(); + Data.Builder data = Data.newBuilder(); + data.setPos(PositionUtils.forClass(clazz.name().toString())); + data.setName(name); + data.setExport(type.hasAnnotation(EXPORT) || export); + Optional.ofNullable(comments.get(CommentKey.ofData(name))) + .ifPresent(data::addAllComments); + buildDataElement(data, clazz.name()); + addDecls(Decl.newBuilder().setData(data).build()); + return ref; } - Data.Builder data = Data.newBuilder(); - data.setPos(PositionUtils.forClass(clazz.name().toString())); - data.setName(clazz.name().local()); - data.setExport(type.hasAnnotation(EXPORT) || export); - Optional.ofNullable(comments.get(CommentKey.ofData(clazz.name().local()))) - .ifPresent(data::addAllComments); - buildDataElement(data, clazz.name()); - moduleBuilder.addDecls(Decl.newBuilder().setData(data).build()); - Ref ref = Ref.newBuilder().setName(data.getName()).setModule(moduleName).build(); - dataElements.put(new TypeKey(clazz.name().toString(), List.of()), - new ExistingRef(ref, export || data.getExport())); - return Type.newBuilder().setRef(ref).build(); } case PARAMETERIZED_TYPE -> { var paramType = type.asParameterizedType(); @@ -501,32 +503,46 @@ private void buildDataElement(Data.Builder data, DotName className) { } public ModuleBuilder addDecls(Decl decl) { - if (decl.hasDatabase()) { - validateName(decl.getDatabase().getPos(), decl.getDatabase().getName()); - } else if (decl.hasData()) { - validateName(decl.getData().getPos(), decl.getData().getName()); - } else if (decl.hasConfig()) { - validateName(decl.getConfig().getPos(), decl.getConfig().getName()); + if (decl.hasData()) { + Data data = decl.getData(); + if (updateData(data.getName(), data.getExport())) { + return this; + } + addDecl(decl, data.getPos(), data.getName()); } else if (decl.hasEnum()) { - validateName(decl.getEnum().getPos(), decl.getEnum().getName()); + xyz.block.ftl.v1.schema.Enum enuum = decl.getEnum(); + if (updateEnum(enuum.getName(), decl)) { + return this; + } + addDecl(decl, enuum.getPos(), enuum.getName()); + } else if (decl.hasDatabase()) { + addDecl(decl, decl.getDatabase().getPos(), decl.getDatabase().getName()); + } else if (decl.hasConfig()) { + addDecl(decl, decl.getConfig().getPos(), decl.getConfig().getName()); } else if (decl.hasSecret()) { - validateName(decl.getSecret().getPos(), decl.getSecret().getName()); + addDecl(decl, decl.getSecret().getPos(), decl.getSecret().getName()); } else if (decl.hasVerb()) { - validateName(decl.getVerb().getPos(), decl.getVerb().getName()); + addDecl(decl, decl.getVerb().getPos(), decl.getVerb().getName()); } else if (decl.hasTypeAlias()) { - validateName(decl.getTypeAlias().getPos(), decl.getTypeAlias().getName()); + addDecl(decl, decl.getTypeAlias().getPos(), decl.getTypeAlias().getName()); } else if (decl.hasTopic()) { - validateName(decl.getTopic().getPos(), decl.getTopic().getName()); + addDecl(decl, decl.getTopic().getPos(), decl.getTopic().getName()); } else if (decl.hasFsm()) { - validateName(decl.getFsm().getPos(), decl.getFsm().getName()); + addDecl(decl, decl.getFsm().getPos(), decl.getFsm().getName()); } else if (decl.hasSubscription()) { - validateName(decl.getSubscription().getPos(), decl.getSubscription().getName()); + addDecl(decl, decl.getSubscription().getPos(), decl.getSubscription().getName()); } - moduleBuilder.addDecls(decl); + return this; } + public int getDeclsCount() { + return decls.size(); + } + public void writeTo(OutputStream out) throws IOException { + decls.values().stream().forEachOrdered(protoModuleBuilder::addDecls); + if (!validationFailures.isEmpty()) { StringBuilder sb = new StringBuilder(); for (var failure : validationFailures) { @@ -535,7 +551,7 @@ public void writeTo(OutputStream out) throws IOException { } throw new RuntimeException(sb.toString()); } - moduleBuilder.build().writeTo(out); + protoModuleBuilder.build().writeTo(out); } public void registerTypeAlias(String name, org.jboss.jandex.Type finalT, org.jboss.jandex.Type finalS, boolean exported, @@ -552,13 +568,82 @@ public void registerTypeAlias(String name, org.jboss.jandex.Type finalT, org.jbo typeAlias.addMetadata(Metadata.newBuilder().setTypeMap(MetadataTypeMap.newBuilder().setRuntime(entry.getKey()) .setNativeName(entry.getValue()).build()).build()); } - moduleBuilder.addDecls(Decl.newBuilder() + addDecls(Decl.newBuilder() .setTypeAlias(typeAlias) .build()); } - record ExistingRef(Ref ref, boolean exported) { + /** + * Types from other modules don't need a Decl. We store Ref for it, and prevent a Decl being created next + * time we see this name + */ + public void registerExternalType(String module, String name) { + Ref ref = Ref.newBuilder() + .setModule(module) + .setName(name) + .build(); + externalRefs.put(name, ref); + } + + private void addDecl(Decl decl, Position pos, String name) { + validateName(pos, name); + if (decls.containsKey(name)) { + duplicateNameValidationError(name, pos); + } + decls.put(name, decl); + } + + /** + * Check if an enum with the given name already exists in the module. If it does, merge fields from both into one + */ + private boolean updateEnum(String name, Decl decl) { + if (decls.containsKey(name)) { + var existing = decls.get(name); + if (!existing.hasEnum()) { + duplicateNameValidationError(name, decl.getEnum().getPos()); + } + var moreComplete = decl.getEnum().getVariantsCount() > 0 ? decl : existing; + var lessComplete = decl.getEnum().getVariantsCount() > 0 ? existing : decl; + boolean export = lessComplete.getEnum().getExport() || existing.getEnum().getExport(); + var merged = moreComplete.getEnum().toBuilder() + .setExport(export) + .build(); + decls.put(name, Decl.newBuilder().setEnum(merged).build()); + if (export) { + // Need to update export on variants too + for (var childDecl : merged.getVariantsList()) { + if (childDecl.getValue().hasTypeValue() && childDecl.getValue().getTypeValue().getValue().hasRef()) { + var ref = childDecl.getValue().getTypeValue().getValue().getRef(); + updateData(ref.getName(), true); + } + } + } + return true; + } + return false; + } + + /** + * Check if a data with the given name already exists in the module. If it does, update its export field to + * match export, and return true + */ + private boolean updateData(String name, boolean export) { + if (decls.containsKey(name)) { + var existing = decls.get(name); + if (!existing.hasData()) { + return true; + } + var merged = existing.getData().toBuilder().setExport(export).build(); + decls.put(name, Decl.newBuilder().setData(merged).build()); + return true; + } + return false; + } + private void duplicateNameValidationError(String name, Position pos) { + validationFailures.add(new ValidationFailure(name, String.format( + "schema declaration with name \"%s\" already exists for module \"%s\"; previously declared at \"%s\"", + name, moduleName, pos.getFilename() + ":" + pos.getLine()))); } public enum BodyType { 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 3a3ddee87..41f230d26 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 @@ -15,7 +15,6 @@ import java.util.stream.Collectors; import org.jboss.jandex.DotName; -import org.jboss.jandex.ParameterizedType; import org.jboss.logging.Logger; import org.tomlj.Toml; import org.tomlj.TomlParseResult; @@ -44,7 +43,6 @@ import xyz.block.ftl.runtime.VerbRegistry; import xyz.block.ftl.runtime.config.FTLConfigSourceFactoryBuilder; import xyz.block.ftl.runtime.http.FTLHttpHandler; -import xyz.block.ftl.v1.schema.Ref; public class ModuleProcessor { @@ -111,36 +109,20 @@ public void generateSchema(CombinedIndexBuildItem index, ModuleNameBuildItem moduleNameBuildItem, TopicsBuildItem topicsBuildItem, VerbClientBuildItem verbClientBuildItem, - List typeAliasBuildItems, DefaultOptionalBuildItem defaultOptionalBuildItem, List schemaContributorBuildItems) throws Exception { String moduleName = moduleNameBuildItem.getModuleName(); Map> comments = readComments(); - Map existingRefs = new HashMap<>(); - for (var i : typeAliasBuildItems) { - String mn; - if (i.getModule().isEmpty()) { - mn = moduleNameBuildItem.getModuleName(); - } else { - mn = i.getModule(); - } - if (i.getLocalType() instanceof ParameterizedType) { - //TODO: we can't handle this yet - // existingRefs.put(new TypeKey(i.getLocalType().name().toString(), i.getLocalType().asParameterizedType().arguments().stream().map(i.)), new ModuleBuilder.ExistingRef(Ref.newBuilder().setModule(moduleName).setName(i.getName()).build(), i.isExported())); - } else { - existingRefs.put(new TypeKey(i.getLocalType().name().toString(), List.of()), new ModuleBuilder.ExistingRef( - Ref.newBuilder().setModule(mn).setName(i.getName()).build(), i.isExported())); - } - } ModuleBuilder moduleBuilder = new ModuleBuilder(index.getComputingIndex(), moduleName, topicsBuildItem.getTopics(), - verbClientBuildItem.getVerbClients(), recorder, comments, existingRefs, + verbClientBuildItem.getVerbClients(), recorder, comments, defaultOptionalBuildItem.isDefaultToOptional()); for (var i : schemaContributorBuildItems) { i.getSchemaContributor().accept(moduleBuilder); } + log.infof("Generating module '%s' schema from %d decls", moduleName, moduleBuilder.getDeclsCount()); Path output = outputTargetBuildItem.getOutputDirectory().resolve(SCHEMA_OUT); try (var out = Files.newOutputStream(output)) { moduleBuilder.writeTo(out); diff --git a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/SubscriptionProcessor.java b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/SubscriptionProcessor.java index a2214461c..bfd17e212 100644 --- a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/SubscriptionProcessor.java +++ b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/SubscriptionProcessor.java @@ -1,8 +1,10 @@ package xyz.block.ftl.deployment; +import java.util.Collection; import java.util.HashMap; import java.util.Map; +import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationTarget; import org.jboss.jandex.DotName; import org.jboss.jandex.MethodInfo; @@ -27,8 +29,11 @@ public class SubscriptionProcessor { @BuildStep SubscriptionMetaAnnotationsBuildItem subscriptionAnnotations(CombinedIndexBuildItem combinedIndexBuildItem, ModuleNameBuildItem moduleNameBuildItem) { + Collection subscriptionAnnotations = combinedIndexBuildItem.getComputingIndex() + .getAnnotations(Subscription.class); + log.infof("Processing %s subscription annotations into decls", subscriptionAnnotations.size()); Map annotations = new HashMap<>(); - for (var subscriptions : combinedIndexBuildItem.getComputingIndex().getAnnotations(Subscription.class)) { + for (var subscriptions : subscriptionAnnotations) { if (subscriptions.target().kind() != AnnotationTarget.Kind.CLASS) { continue; } 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 cc0d51faa..dda47644d 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 @@ -8,6 +8,7 @@ import org.jboss.jandex.DotName; import org.jboss.jandex.Type; +import org.jboss.logging.Logger; import io.quarkus.deployment.GeneratedClassGizmoAdaptor; import io.quarkus.deployment.annotations.BuildProducer; @@ -25,10 +26,12 @@ public class TopicsProcessor { public static final DotName TOPIC = DotName.createSimple(Topic.class); + private static final Logger log = Logger.getLogger(TopicsProcessor.class); @BuildStep TopicsBuildItem handleTopics(CombinedIndexBuildItem index, BuildProducer generatedTopicProducer) { var topicDefinitions = index.getComputingIndex().getAnnotations(TopicDefinition.class); + log.infof("Processing %d topic definition annotations into decls", topicDefinitions.size()); Map topics = new HashMap<>(); Set names = new HashSet<>(); for (var topicDefinition : topicDefinitions) { diff --git a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/TypeAliasProcessor.java b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/TypeAliasProcessor.java index 9645af17a..801665107 100644 --- a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/TypeAliasProcessor.java +++ b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/TypeAliasProcessor.java @@ -1,11 +1,14 @@ package xyz.block.ftl.deployment; +import java.util.Collection; import java.util.HashMap; import java.util.Map; +import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationValue; import org.jboss.jandex.Type; import org.jboss.jandex.TypeVariable; +import org.jboss.logging.Logger; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.deployment.annotations.BuildProducer; @@ -14,13 +17,17 @@ public class TypeAliasProcessor { + private static final Logger log = Logger.getLogger(TypeAliasProcessor.class); + @BuildStep public void processTypeAlias(CombinedIndexBuildItem index, BuildProducer schemaContributorBuildItemBuildProducer, BuildProducer additionalBeanBuildItem, BuildProducer typeAliasBuildItemBuildProducer) { + Collection typeAliasAnnotations = index.getIndex().getAnnotations(FTLDotNames.TYPE_ALIAS); + log.infof("Processing %d type alias annotations into decls", typeAliasAnnotations.size()); var beans = new AdditionalBeanBuildItem.Builder().setUnremovable(); - for (var mapper : index.getIndex().getAnnotations(FTLDotNames.TYPE_ALIAS)) { + for (var mapper : typeAliasAnnotations) { boolean exported = mapper.target().hasAnnotation(FTLDotNames.EXPORT); // This may or may not be the actual mapper, it may be a subclass @@ -83,6 +90,11 @@ public void processTypeAlias(CombinedIndexBuildItem index, } schemaContributorBuildItemBuildProducer.produce(new SchemaContributorBuildItem(moduleBuilder -> moduleBuilder .registerTypeAlias(name, finalT, finalS, exported, languageMappings))); + } else { + // If the 'module' field of the annotation is non-empty, we have a mapper for a type alias defined in + // another module. Don't need a Decl + schemaContributorBuildItemBuildProducer.produce(new SchemaContributorBuildItem(moduleBuilder -> moduleBuilder + .registerExternalType(module, name))); } } diff --git a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/VerbProcessor.java b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/VerbProcessor.java index e746bd0e9..0fc435ecd 100644 --- a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/VerbProcessor.java +++ b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/VerbProcessor.java @@ -1,14 +1,18 @@ package xyz.block.ftl.deployment; +import java.util.Collection; import java.util.HashMap; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import jakarta.inject.Singleton; +import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationValue; import org.jboss.jandex.DotName; import org.jboss.jandex.Type; +import org.jboss.logging.Logger; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.GeneratedBeanBuildItem; @@ -38,6 +42,7 @@ public class VerbProcessor { 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"; + private static final Logger log = Logger.getLogger(VerbProcessor.class); @BuildStep VerbClientBuildItem handleVerbClients(CombinedIndexBuildItem index, BuildProducer generatedClients, @@ -45,6 +50,7 @@ VerbClientBuildItem handleVerbClients(CombinedIndexBuildItem index, BuildProduce ModuleNameBuildItem moduleNameBuildItem, LaunchModeBuildItem launchModeBuildItem) { var clientDefinitions = index.getComputingIndex().getAnnotations(VerbClientDefinition.class); + log.infof("Processing %d verb clients", clientDefinitions.size()); Map clients = new HashMap<>(); for (var clientDefinition : clientDefinitions) { var iface = clientDefinition.target().asClass(); @@ -225,9 +231,13 @@ VerbClientBuildItem handleVerbClients(CombinedIndexBuildItem index, BuildProduce @BuildStep public void verbsAndCron(CombinedIndexBuildItem index, BuildProducer additionalBeanBuildItem, - BuildProducer schemaContributorBuildItemBuildProducer) { + BuildProducer schemaContributorBuildItemBuildProducer, + List typeAliasBuildItems // included to force typealias processing before this + ) { + Collection verbAnnotations = index.getIndex().getAnnotations(FTLDotNames.VERB); + log.infof("Processing %d verb annotations into decls", verbAnnotations.size()); var beans = AdditionalBeanBuildItem.builder().setUnremovable(); - for (var verb : index.getIndex().getAnnotations(FTLDotNames.VERB)) { + for (var verb : verbAnnotations) { boolean exported = verb.target().hasAnnotation(FTLDotNames.EXPORT); var method = verb.target().asMethod(); String className = method.declaringClass().name().toString(); @@ -235,7 +245,10 @@ public void verbsAndCron(CombinedIndexBuildItem index, schemaContributorBuildItemBuildProducer.produce(new SchemaContributorBuildItem(moduleBuilder -> moduleBuilder .registerVerbMethod(method, className, exported, ModuleBuilder.BodyType.ALLOWED, null))); } - for (var cron : index.getIndex().getAnnotations(FTLDotNames.CRON)) { + + Collection cronAnnotations = index.getIndex().getAnnotations(FTLDotNames.CRON); + log.infof("Processing %d cron job annotations into decls", cronAnnotations.size()); + for (var cron : cronAnnotations) { var method = cron.target().asMethod(); String className = method.declaringClass().name().toString(); beans.addBeanClass(className); diff --git a/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/Enum.java b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/Enum.java new file mode 100644 index 000000000..a5966aa70 --- /dev/null +++ b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/Enum.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 an enum type + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE }) +public @interface Enum { +} diff --git a/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/EnumHolder.java b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/EnumHolder.java new file mode 100644 index 000000000..8ab7f45ed --- /dev/null +++ b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/EnumHolder.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 a class as holder for an enum variant with a primitive type + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE }) +public @interface EnumHolder { +} diff --git a/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/FTLRecorder.java b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/FTLRecorder.java index 5fd81a623..a3a88a46c 100644 --- a/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/FTLRecorder.java +++ b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/FTLRecorder.java @@ -46,6 +46,22 @@ public void registerHttpIngress(String module, String verbName, boolean base64En } } + public void registerEnum(Class ennum) { + try { + Arc.container().instance(JsonSerializationConfig.class).get().registerValueEnum(ennum); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public void registerEnum(Class ennum, List> variants) { + try { + Arc.container().instance(JsonSerializationConfig.class).get().registerTypeEnum(ennum, variants); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + public BiFunction topicSupplier(String className, String callingVerb) { try { var cls = Thread.currentThread().getContextClassLoader().loadClass(className.replace("/", ".")); diff --git a/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/JsonSerializationConfig.java b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/JsonSerializationConfig.java index ff8e68367..b92a75d5b 100644 --- a/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/JsonSerializationConfig.java +++ b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/JsonSerializationConfig.java @@ -1,10 +1,15 @@ package xyz.block.ftl.runtime; import java.io.IOException; +import java.lang.reflect.Field; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.lang.reflect.TypeVariable; +import java.util.ArrayList; import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; @@ -22,6 +27,7 @@ import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.ser.std.StdSerializer; import io.quarkus.arc.Unremovable; @@ -35,13 +41,23 @@ @Unremovable public class JsonSerializationConfig implements ObjectMapperCustomizer { - final Instance> instances; + final Iterable> instances; + + private record TypeEnumDefn(Class type, List> variants) { + } + + final List valueEnums = new ArrayList<>(); + final List typeEnums = new ArrayList<>(); @Inject public JsonSerializationConfig(Instance> instances) { this.instances = instances; } + JsonSerializationConfig() { + this.instances = List.of(); + } + @Override public void customize(ObjectMapper mapper) { mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); @@ -55,9 +71,27 @@ public void customize(ObjectMapper mapper) { module.addSerializer(object, new TypeAliasSerializer(object, serialized, i)); module.addDeserializer(object, new TypeAliasDeSerializer(object, serialized, i)); } + for (var i : valueEnums) { + module.addSerializer(i, new ValueEnumSerializer(i)); + module.addDeserializer(i, new ValueEnumDeserializer(i)); + } + + ObjectMapper cleanMapper = mapper.copy(); + for (var i : typeEnums) { + module.addSerializer(i.type, new TypeEnumSerializer<>(i.type, cleanMapper)); + module.addDeserializer(i.type, new TypeEnumDeserializer<>(i.type, i.variants)); + } mapper.registerModule(module); } + public > void registerValueEnum(Class enumClass) { + valueEnums.add(enumClass); + } + + public void registerTypeEnum(Class type, List> variants) { + typeEnums.add(new TypeEnumDefn<>(type, variants)); + } + static Class extractTypeAliasParam(Class target, int no) { return (Class) extractTypeAliasParamImpl(target, no); } @@ -116,7 +150,6 @@ public byte[] deserialize(JsonParser p, DeserializationContext ctxt) throws IOEx String base64 = node.asText(); return Base64.getDecoder().decode(base64); } - } public static class TypeAliasDeSerializer extends StdDeserializer { @@ -135,7 +168,6 @@ public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOExcepti var s = ctxt.readValue(p, serializedType); return mapper.decode(s); } - } public static class TypeAliasSerializer extends StdSerializer { @@ -156,4 +188,95 @@ public void serialize(T value, JsonGenerator gen, SerializerProvider provider) t } } + public static class ValueEnumSerializer extends StdSerializer { + private final Field valueField; + + public ValueEnumSerializer(Class type) { + super(type); + try { + this.valueField = type.getDeclaredField("value"); + valueField.setAccessible(true); + } catch (NoSuchFieldException e) { + throw new RuntimeException(e); + } + } + + @Override + public void serialize(T value, JsonGenerator gen, SerializerProvider provider) throws IOException { + try { + gen.writeObject(valueField.get(value)); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + } + + public static class ValueEnumDeserializer extends StdDeserializer { + private final Map wireToEnum = new HashMap<>(); + private final Class valueClass; + + public ValueEnumDeserializer(Class type) { + super(type); + try { + Field valueField = type.getDeclaredField("value"); + valueField.setAccessible(true); + valueClass = valueField.getType(); + for (T ennum : type.getEnumConstants()) { + wireToEnum.put(valueField.get(ennum), ennum); + } + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + @Override + public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + Object wireVal = ctxt.readValue(p, valueClass); + return wireToEnum.get(wireVal); + } + } + + public static class TypeEnumSerializer extends StdSerializer { + private final ObjectMapper defaultMapper; + + public TypeEnumSerializer(Class type, ObjectMapper mapper) { + super(type); + defaultMapper = mapper; + } + + @Override + public void serialize(T value, JsonGenerator gen, SerializerProvider provider) throws IOException { + gen.writeStartObject(); + gen.writeStringField("name", value.getClass().getSimpleName()); + gen.writeFieldName("value"); + // Avoid infinite recursion by using a mapper without this serializer registered + defaultMapper.writeValue(gen, value); + gen.writeEndObject(); + } + } + + public static class TypeEnumDeserializer extends StdDeserializer { + private final Map> nameToVariant = new HashMap<>(); + + public TypeEnumDeserializer(Class type, List> variants) { + super(type); + for (var variant : variants) { + nameToVariant.put(variant.getSimpleName(), variant); + } + } + + @Override + public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + ObjectNode wireValue = p.readValueAsTree(); + if (!wireValue.has("name") || !wireValue.has("value")) { + throw new RuntimeException("Enum missing 'name' or 'value' fields"); + } + String name = wireValue.get("name").asText(); + Class variant = nameToVariant.get(name); + if (variant == null) { + throw new RuntimeException("Unknown variant " + name); + } + return (T) wireValue.get("value").traverse(p.getCodec()).readValueAs(variant); + } + } } diff --git a/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/Animal.java b/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/Animal.java new file mode 100644 index 000000000..e3b17c031 --- /dev/null +++ b/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/Animal.java @@ -0,0 +1,17 @@ +package xyz.block.ftl.runtime; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import xyz.block.ftl.Enum; + +@Enum +public interface Animal { + @JsonIgnore + boolean isCat(); + + @JsonIgnore + boolean isDog(); + + @JsonIgnore + Cat getCat(); +} diff --git a/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/Cat.java b/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/Cat.java new file mode 100644 index 000000000..6f9cd5ec3 --- /dev/null +++ b/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/Cat.java @@ -0,0 +1,44 @@ +package xyz.block.ftl.runtime; + +import org.jetbrains.annotations.NotNull; + +public class Cat implements Animal { + private @NotNull String name; + + private @NotNull String breed; + + private long furLength; + + public Cat() { + } + + public Cat(@NotNull String breed, long furLength, @NotNull String name) { + this.breed = breed; + this.furLength = furLength; + this.name = name; + } + + public boolean isCat() { + return true; + } + + public boolean isDog() { + return false; + } + + public Cat getCat() { + return this; + } + + public @NotNull String getName() { + return name; + } + + public @NotNull String getBreed() { + return breed; + } + + public long getFurLength() { + return furLength; + } +} diff --git a/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/ColorInt.java b/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/ColorInt.java new file mode 100644 index 000000000..f6d7b9181 --- /dev/null +++ b/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/ColorInt.java @@ -0,0 +1,20 @@ +package xyz.block.ftl.runtime; + +import xyz.block.ftl.Enum; + +@Enum +public enum ColorInt { + RED(0), + GREEN(1), + BLUE(2); + + private final int value; + + ColorInt(int value) { + this.value = value; + } + + public int getValue() { + return value; + } +} diff --git a/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/Dog.java b/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/Dog.java new file mode 100644 index 000000000..d0c171102 --- /dev/null +++ b/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/Dog.java @@ -0,0 +1,16 @@ +package xyz.block.ftl.runtime; + +public class Dog implements Animal { + public boolean isCat() { + return false; + } + + public boolean isDog() { + return true; + } + + @Override + public Cat getCat() { + throw new UnsupportedOperationException("Not implemented"); + } +} diff --git a/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/JsonSerializationConfigTest.java b/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/JsonSerializationConfigTest.java index ea6f3f8f5..73a1b8adf 100644 --- a/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/JsonSerializationConfigTest.java +++ b/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/JsonSerializationConfigTest.java @@ -1,10 +1,14 @@ package xyz.block.ftl.runtime; +import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + import xyz.block.ftl.TypeAliasMapper; class JsonSerializationConfigTest { @@ -27,6 +31,51 @@ public void testExtraction() { JsonSerializationConfig.extractTypeAliasParamImpl(AtomicIntTypeMapping.class, 1)); } + @Test + public void testTypeEnumSerialization() throws JsonProcessingException { + JsonSerializationConfig config = new JsonSerializationConfig(); + ObjectMapper mapper = new ObjectMapper(); + config.registerTypeEnum(Animal.class, List.of(Dog.class, Cat.class)); + config.customize(mapper); + + String serializedDog = mapper.writeValueAsString(new Dog()); + Assertions.assertEquals("{\"name\":\"Dog\",\"value\":{}}", serializedDog); + + Animal animal = mapper.readValue(serializedDog, Animal.class); + Assertions.assertTrue(animal instanceof Dog); + + String serializedCat = mapper.writeValueAsString(new Cat("Siamese", 10, "Fluffy")); + Assertions.assertEquals("{\"name\":\"Cat\",\"value\":{\"name\":\"Fluffy\",\"breed\":\"Siamese\",\"furLength\":10}}", + serializedCat); + + Animal cat = mapper.readValue(serializedCat, Animal.class); + Assertions.assertTrue(cat instanceof Cat); + Assertions.assertEquals("Fluffy", cat.getCat().getName()); + } + + @Test + public void testValueEnumSerialization() throws JsonProcessingException { + JsonSerializationConfig config = new JsonSerializationConfig(); + ObjectMapper mapper = new ObjectMapper(); + config.registerValueEnum(ColorInt.class); + config.registerValueEnum(Shape.class); + config.customize(mapper); + + String serializedRed = mapper.writeValueAsString(ColorInt.RED); + Assertions.assertEquals("0", serializedRed); + String serializedBlue = mapper.writeValueAsString(ColorInt.BLUE); + Assertions.assertEquals("2", serializedBlue); + + ColorInt deserialized = mapper.readValue(serializedBlue, ColorInt.class); + Assertions.assertEquals(ColorInt.BLUE, deserialized); + + String serializedCircle = mapper.writeValueAsString(Shape.CIRCLE); + Assertions.assertEquals("\"circle\"", serializedCircle); + + Shape deserializedShape = mapper.readValue(serializedCircle, Shape.class); + Assertions.assertEquals(Shape.CIRCLE, deserializedShape); + } + public static class AtomicIntTypeMapping implements TypeAliasMapper { @Override public Integer encode(AtomicInteger object) { diff --git a/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/Shape.java b/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/Shape.java new file mode 100644 index 000000000..a7ffcf9ea --- /dev/null +++ b/jvm-runtime/ftl-runtime/common/runtime/src/test/java/xyz/block/ftl/runtime/Shape.java @@ -0,0 +1,20 @@ +package xyz.block.ftl.runtime; + +import xyz.block.ftl.Enum; + +@Enum +public enum Shape { + CIRCLE("circle"), + SQUARE("square"), + TRIANGLE("triangle"); + + private final String value; + + Shape(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/jvm-runtime/ftl-runtime/java/deployment/src/main/java/xyz/block/ftl/javalang/deployment/JavaCodeGenerator.java b/jvm-runtime/ftl-runtime/java/deployment/src/main/java/xyz/block/ftl/javalang/deployment/JavaCodeGenerator.java index c332343bd..7123bf7d8 100644 --- a/jvm-runtime/ftl-runtime/java/deployment/src/main/java/xyz/block/ftl/javalang/deployment/JavaCodeGenerator.java +++ b/jvm-runtime/ftl-runtime/java/deployment/src/main/java/xyz/block/ftl/javalang/deployment/JavaCodeGenerator.java @@ -4,16 +4,19 @@ import java.lang.annotation.Retention; import java.nio.file.Path; import java.time.ZonedDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.TreeMap; +import java.util.stream.Collectors; import javax.lang.model.element.Modifier; import org.jetbrains.annotations.NotNull; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.squareup.javapoet.AnnotationSpec; import com.squareup.javapoet.ArrayTypeName; import com.squareup.javapoet.ClassName; @@ -25,6 +28,7 @@ import com.squareup.javapoet.TypeVariableName; import com.squareup.javapoet.WildcardTypeName; +import xyz.block.ftl.EnumHolder; import xyz.block.ftl.GeneratedRef; import xyz.block.ftl.Subscription; import xyz.block.ftl.TypeAlias; @@ -37,9 +41,11 @@ import xyz.block.ftl.deployment.JVMCodeGenerator; import xyz.block.ftl.v1.schema.Data; import xyz.block.ftl.v1.schema.Enum; +import xyz.block.ftl.v1.schema.EnumVariant; import xyz.block.ftl.v1.schema.Module; import xyz.block.ftl.v1.schema.Topic; import xyz.block.ftl.v1.schema.Type; +import xyz.block.ftl.v1.schema.Value; import xyz.block.ftl.v1.schema.Verb; public class JavaCodeGenerator extends JVMCodeGenerator { @@ -99,37 +105,125 @@ protected void generateTopicSubscription(Module module, Topic data, String packa } protected void generateEnum(Module module, Enum data, String packageName, Map typeAliasMap, - Map nativeTypeAliasMap, Path outputDir) + Map nativeTypeAliasMap, Map> enumVariantInfoMap, Path outputDir) throws IOException { - 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()); - } + String interfaceType = className(data.getName()); + if (data.hasType()) { + //Enums with a type are "value enums" - Java natively supports these + TypeSpec.Builder dataBuilder = TypeSpec.enumBuilder(interfaceType) + .addAnnotation(getGeneratedRefAnnotation(module.getName(), data.getName())) + .addAnnotation(AnnotationSpec.builder(xyz.block.ftl.Enum.class).build()) + .addModifiers(Modifier.PUBLIC); + + TypeName enumType = toAnnotatedJavaTypeName(data.getType(), typeAliasMap, nativeTypeAliasMap); + dataBuilder.addField(enumType, "value", Modifier.PRIVATE, Modifier.FINAL); + dataBuilder.addMethod(MethodSpec.constructorBuilder() + .addParameter(enumType, "value") + .addStatement("this.value = value") + .build()); + dataBuilder.addMethod(MethodSpec.methodBuilder("getValue") + .addModifiers(Modifier.PUBLIC) + .addAnnotation(JsonIgnore.class) + .returns(enumType) + .addStatement("return value") + .build()); - JavaFile javaFile = JavaFile.builder(packageName, dataBuilder.build()) - .build(); + var format = data.getType().hasString() ? "$S" : "$L"; + for (var i : data.getVariantsList()) { + Object value = toJavaValue(i.getValue()); + dataBuilder.addEnumConstant(i.getName(), TypeSpec.anonymousClassBuilder(format, value).build()); + } + JavaFile javaFile = JavaFile.builder(packageName, dataBuilder.build()) + .build(); + javaFile.writeTo(outputDir); + } else { + // Enums without a type are (confusingly) "type enums". Java can't represent these directly, so we use a + // sealed class + + // TODO JavaPoet doesn't support 'sealed' or 'permits' syntax yet, so we can't seal the interface + // https://github.com/square/javapoet/issues/823 + TypeSpec.Builder interfaceBuilder = TypeSpec.interfaceBuilder(interfaceType) + .addAnnotation(getGeneratedRefAnnotation(module.getName(), data.getName())) + .addAnnotation(AnnotationSpec.builder(xyz.block.ftl.Enum.class).build()) + .addModifiers(Modifier.PUBLIC); + + Map variantValuesTypes = data.getVariantsList().stream().collect( + Collectors.toMap(EnumVariant::getName, v -> toAnnotatedJavaTypeName(v.getValue().getTypeValue().getValue(), + typeAliasMap, nativeTypeAliasMap))); + for (var variant : data.getVariantsList()) { + // Interface has isX and getX methods for each variant + String name = variant.getName(); + TypeName valueTypeName = variantValuesTypes.get(name); + interfaceBuilder.addMethod(MethodSpec.methodBuilder("is" + name) + .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT) + .addAnnotation(JsonIgnore.class) + .returns(TypeName.BOOLEAN) + .build()); + interfaceBuilder.addMethod(MethodSpec.methodBuilder("get" + name) + .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT) + .addAnnotation(JsonIgnore.class) + .returns(valueTypeName) + .build()); - javaFile.writeTo(outputDir); + if (variant.getValue().getTypeValue().getValue().hasRef()) { + // Value type is a Ref, so it will have a class generated by generateDataObject + // Store this variant in enumVariantInfoMap so we can fetch it later + DeclRef key = new DeclRef(module.getName(), name); + List variantInfos = enumVariantInfoMap.computeIfAbsent(key, k -> new ArrayList<>()); + variantInfos.add(new EnumInfo(interfaceType, variant, data.getVariantsList())); + } else { + // Value type isn't a Ref, so we make a wrapper class that implements our interface + TypeSpec.Builder dataBuilder = TypeSpec.classBuilder(className(name)) + .addAnnotation(getGeneratedRefAnnotation(module.getName(), name)) + .addAnnotation(AnnotationSpec.builder(EnumHolder.class).build()) + .addModifiers(Modifier.PUBLIC, Modifier.FINAL); + dataBuilder.addField(valueTypeName, "value", Modifier.PRIVATE, Modifier.FINAL); + dataBuilder.addMethod(MethodSpec.constructorBuilder() + .addStatement("this.value = null") + .addModifiers(Modifier.PRIVATE) + .build()); + dataBuilder.addMethod(MethodSpec.constructorBuilder() + .addParameter(valueTypeName, "value") + .addStatement("this.value = value") + .addModifiers(Modifier.PUBLIC) + .build()); + addTypeEnumInterfaceMethods(packageName, interfaceType, dataBuilder, name, valueTypeName, + variantValuesTypes, false); + JavaFile javaFile = JavaFile.builder(packageName, dataBuilder.build()) + .build(); + javaFile.writeTo(outputDir); + } + JavaFile javaFile = JavaFile.builder(packageName, interfaceBuilder.build()) + .build(); + javaFile.writeTo(outputDir); + } + } } protected void generateDataObject(Module module, Data data, String packageName, Map typeAliasMap, - Map nativeTypeAliasMap, Path outputDir) throws IOException { + Map nativeTypeAliasMap, Map> enumVariantInfoMap, Path outputDir) + throws IOException { 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()) + .addAnnotation(getGeneratedRefAnnotation(module.getName(), data.getName())) .addModifiers(Modifier.PUBLIC); - MethodSpec.Builder allConstructor = MethodSpec.constructorBuilder().addModifiers(Modifier.PUBLIC); + // if data is part of a type enum, generate the interface methods for each variant + DeclRef key = new DeclRef(module.getName(), data.getName()); + if (enumVariantInfoMap.containsKey(key)) { + for (var enumVariantInfo : enumVariantInfoMap.get(key)) { + String name = enumVariantInfo.variant().getName(); + TypeName variantTypeName = ClassName.get(packageName, name); + Map variantValuesTypes = enumVariantInfo.otherVariants().stream().collect( + Collectors.toMap(EnumVariant::getName, + v -> toAnnotatedJavaTypeName(v.getValue().getTypeValue().getValue(), + typeAliasMap, nativeTypeAliasMap))); + addTypeEnumInterfaceMethods(packageName, enumVariantInfo.interfaceType(), dataBuilder, name, + variantTypeName, variantValuesTypes, true); + } + } + + MethodSpec.Builder allConstructor = MethodSpec.constructorBuilder().addModifiers(Modifier.PUBLIC); dataBuilder.addMethod(allConstructor.build()); for (var param : data.getTypeParametersList()) { dataBuilder.addTypeVariable(TypeVariableName.get(param.getName())); @@ -197,13 +291,13 @@ protected void generateVerb(Module module, Verb verb, String packageName, Map typeAliasMap, Map< } else if (type.hasString()) { return ClassName.get(String.class); } else if (type.hasOptional()) { - // Always box for optional, as normal primities can't be null + // Always box for optional, as normal primitives can't be null return toJavaTypeName(type.getOptional().getType(), typeAliasMap, nativeTypeAliasMap, true); } else if (type.hasRef()) { if (type.getRef().getModule().isEmpty()) { @@ -293,6 +387,74 @@ private TypeName toJavaTypeName(Type type, Map typeAliasMap, Map< throw new RuntimeException("Cannot generate Java type name: " + type); } + /** + * Get concrete value from a Value + */ + private Object toJavaValue(Value value) { + if (value.hasIntValue()) { + return value.getIntValue().getValue(); + } else if (value.hasStringValue()) { + return value.getStringValue().getValue(); + } else if (value.hasTypeValue()) { + // Can't instantiate a TypeValue now. Cannot happen because it's only used in type enums + throw new RuntimeException("Cannot generate TypeValue: " + value); + } + throw new RuntimeException("Cannot generate Java value: " + value); + } + + /** + * Adds the super interface and isX, getX methods to the dataBuilder for a type enum variant + */ + private static void addTypeEnumInterfaceMethods(String packageName, String interfaceType, TypeSpec.Builder dataBuilder, + String enumVariantName, TypeName variantTypeName, Map variantValuesTypes, boolean returnSelf) { + + dataBuilder.addSuperinterface(ClassName.get(packageName, interfaceType)); + + // Positive implementation of isX, getX for its type + dataBuilder.addMethod(MethodSpec.methodBuilder("is" + enumVariantName) + .addModifiers(Modifier.PUBLIC) + .addAnnotation(JsonIgnore.class) + .returns(TypeName.BOOLEAN) + .addStatement("return true") + .build()); + + MethodSpec.Builder getMethod = MethodSpec.methodBuilder("get" + enumVariantName) + .addModifiers(Modifier.PUBLIC) + .addAnnotation(JsonIgnore.class) + .returns(variantTypeName); + if (returnSelf) { + getMethod.addStatement("return this"); + } else { + getMethod.addStatement("return value"); + } + dataBuilder.addMethod(getMethod.build()); + + for (var thingIAmNot : variantValuesTypes.entrySet()) { + if (thingIAmNot.getKey().equals(enumVariantName)) { + continue; + } + // Negative implementation of isX, getX for other types + dataBuilder.addMethod(MethodSpec.methodBuilder("is" + thingIAmNot.getKey()) + .addModifiers(Modifier.PUBLIC) + .addAnnotation(JsonIgnore.class) + .returns(TypeName.BOOLEAN) + .addStatement("return false") + .build()); + dataBuilder.addMethod(MethodSpec.methodBuilder("get" + thingIAmNot.getKey()) + .addModifiers(Modifier.PUBLIC) + .addAnnotation(JsonIgnore.class) + .returns(thingIAmNot.getValue()) + .addStatement("throw new UnsupportedOperationException()") + .build()); + } + } + + private static @NotNull AnnotationSpec getGeneratedRefAnnotation(String module, String name) { + return AnnotationSpec.builder(GeneratedRef.class) + .addMember("name", "\"" + name + "\"") + .addMember("module", "\"" + module + "\"").build(); + } + protected 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", 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 ab78bef0e..7d39810ec 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 @@ -93,7 +93,7 @@ protected void generateTopicSubscription(Module module, Topic data, String packa } protected void generateEnum(Module module, Enum data, String packageName, Map typeAliasMap, - Map nativeTypeAliasMap, Path outputDir) + Map nativeTypeAliasMap, Map> enumVariantInfoMap, Path outputDir) throws IOException { String thisType = className(data.getName()); TypeSpec.Builder dataBuilder = TypeSpec.enumBuilder(thisType) @@ -114,7 +114,8 @@ protected void generateEnum(Module module, Enum data, String packageName, Map typeAliasMap, - Map nativeTypeAliasMap, Path outputDir) throws IOException { + Map nativeTypeAliasMap, Map> enumVariantInfoMap, Path outputDir) + throws IOException { String thisType = className(data.getName()); TypeSpec.Builder dataBuilder = TypeSpec.classBuilder(thisType) .addAnnotation( diff --git a/jvm-runtime/jvm_integration_test.go b/jvm-runtime/jvm_integration_test.go index 63d774155..b83025ecc 100644 --- a/jvm-runtime/jvm_integration_test.go +++ b/jvm-runtime/jvm_integration_test.go @@ -36,12 +36,12 @@ func TestLifecycleJVM(t *testing.T) { func TestVerbCalls(t *testing.T) { in.Run(t, in.WithLanguages("java"), - in.CopyModule("verbs"), - in.Deploy("verbs"), - in.Call("verbs", "anyInput", map[string]string{"name": "Jimmy"}, func(t testing.TB, response string) { + in.CopyModule("javaserver"), + in.Deploy("javaserver"), + in.Call("javaserver", "anyInput", map[string]string{"name": "Jimmy"}, func(t testing.TB, response string) { assert.Equal(t, "Jimmy", response) }), - in.Call("verbs", "anyOutput", "Jimmy", func(t testing.TB, response map[string]string) { + in.Call("javaserver", "anyOutput", "Jimmy", func(t testing.TB, response map[string]string) { assert.Equal(t, map[string]string{"name": "Jimmy"}, response) }), ) @@ -77,35 +77,35 @@ func TestJVMCoreFunctionality(t *testing.T) { } tests := []in.SubTest{} - tests = append(tests, PairedTest("emptyVerb", func(module string) in.Action { + tests = append(tests, AllRuntimesTest("emptyVerb", func(module string) in.Action { return in.Call(module, "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)) }) })...) - tests = append(tests, PairedTest("sinkVerb", func(module string) in.Action { + tests = append(tests, AllRuntimesTest("sinkVerb", func(module string) in.Action { return in.Call(module, "sinkVerb", "ignored", func(t testing.TB, response in.Obj) { assert.Equal(t, map[string]any{}, response, "expecting empty response, got %s", repr.String(response)) }) })...) - tests = append(tests, PairedTest("sourceVerb", func(module string) in.Action { + tests = append(tests, AllRuntimesTest("sourceVerb", func(module string) in.Action { return in.Call(module, "sourceVerb", in.Obj{}, func(t testing.TB, response string) { assert.Equal(t, "Source Verb", response, "expecting empty response, got %s", response) }) })...) - tests = append(tests, PairedTest("errorEmptyVerb", func(module string) in.Action { + tests = append(tests, AllRuntimesTest("errorEmptyVerb", func(module string) in.Action { return in.Fail( in.Call(module, "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") })...) - tests = append(tests, PairedVerbTest("intVerb", 124)...) - tests = append(tests, PairedVerbTest("floatVerb", 0.123)...) - tests = append(tests, PairedVerbTest("stringVerb", "Hello World")...) - tests = append(tests, PairedVerbTest("bytesVerb", []byte{1, 2, 3, 0, 1})...) - tests = append(tests, PairedVerbTest("boolVerb", true)...) - tests = append(tests, PairedVerbTest("stringArrayVerb", []string{"Hello World"})...) - tests = append(tests, PairedVerbTest("stringMapVerb", map[string]string{"Hello": "World"})...) - tests = append(tests, PairedTest("timeVerb", func(module string) in.Action { + tests = append(tests, AllRuntimesVerbTest("intVerb", 124)...) + tests = append(tests, AllRuntimesVerbTest("floatVerb", 0.123)...) + tests = append(tests, AllRuntimesVerbTest("stringVerb", "Hello World")...) + tests = append(tests, AllRuntimesVerbTest("bytesVerb", []byte{1, 2, 3, 0, 1})...) + tests = append(tests, AllRuntimesVerbTest("boolVerb", true)...) + tests = append(tests, AllRuntimesVerbTest("stringArrayVerb", []string{"Hello World"})...) + tests = append(tests, AllRuntimesVerbTest("stringMapVerb", map[string]string{"Hello": "World"})...) + tests = append(tests, AllRuntimesTest("timeVerb", func(module string) in.Action { now := time.Now().UTC() return in.Call(module, "timeVerb", now.Format(time.RFC3339Nano), func(t testing.TB, response string) { result, err := time.Parse(time.RFC3339Nano, response) @@ -113,19 +113,19 @@ func TestJVMCoreFunctionality(t *testing.T) { assert.Equal(t, now, result, "times not equal %s %s", now, result) }) })...) - tests = append(tests, PairedVerbTest("testObjectVerb", exampleObject)...) - tests = append(tests, PairedVerbTest("testObjectOptionalFieldsVerb", exampleOptionalFieldsObject)...) - tests = append(tests, PairedVerbTest("objectMapVerb", map[string]TestObject{"hello": exampleObject})...) - tests = append(tests, PairedVerbTest("objectArrayVerb", []TestObject{exampleObject})...) - tests = append(tests, PairedVerbTest("parameterizedObjectVerb", parameterizedObject)...) - tests = append(tests, PairedVerbTest("optionalIntVerb", -3)...) - tests = append(tests, PairedVerbTest("optionalFloatVerb", -7.6)...) - tests = append(tests, PairedVerbTest("optionalStringVerb", "foo")...) - tests = append(tests, PairedVerbTest("optionalBytesVerb", []byte{134, 255, 0})...) - tests = append(tests, PairedVerbTest("optionalBoolVerb", false)...) - tests = append(tests, PairedVerbTest("optionalStringArrayVerb", []string{"foo"})...) - tests = append(tests, PairedVerbTest("optionalStringMapVerb", map[string]string{"Hello": "World"})...) - tests = append(tests, PairedTest("optionalTimeVerb", func(module string) in.Action { + tests = append(tests, AllRuntimesVerbTest("testObjectVerb", exampleObject)...) + tests = append(tests, AllRuntimesVerbTest("testObjectOptionalFieldsVerb", exampleOptionalFieldsObject)...) + tests = append(tests, AllRuntimesVerbTest("objectMapVerb", map[string]TestObject{"hello": exampleObject})...) + tests = append(tests, AllRuntimesVerbTest("objectArrayVerb", []TestObject{exampleObject})...) + tests = append(tests, AllRuntimesVerbTest("parameterizedObjectVerb", parameterizedObject)...) + tests = append(tests, AllRuntimesVerbTest("optionalIntVerb", -3)...) + tests = append(tests, AllRuntimesVerbTest("optionalFloatVerb", -7.6)...) + tests = append(tests, AllRuntimesVerbTest("optionalStringVerb", "foo")...) + tests = append(tests, AllRuntimesVerbTest("optionalBytesVerb", []byte{134, 255, 0})...) + tests = append(tests, AllRuntimesVerbTest("optionalBoolVerb", false)...) + tests = append(tests, AllRuntimesVerbTest("optionalStringArrayVerb", []string{"foo"})...) + tests = append(tests, AllRuntimesVerbTest("optionalStringMapVerb", map[string]string{"Hello": "World"})...) + tests = append(tests, AllRuntimesTest("optionalTimeVerb", func(module string) in.Action { now := time.Now().UTC() return in.Call(module, "optionalTimeVerb", now.Format(time.RFC3339Nano), func(t testing.TB, response string) { result, err := time.Parse(time.RFC3339Nano, response) @@ -134,9 +134,20 @@ func TestJVMCoreFunctionality(t *testing.T) { }) })...) - tests = append(tests, PairedVerbTest("optionalTestObjectVerb", exampleObject)...) - tests = append(tests, PairedVerbTest("optionalTestObjectOptionalFieldsVerb", exampleOptionalFieldsObject)...) - tests = append(tests, PairedVerbTest("externalTypeVerb", "did:web:abc123")...) + tests = append(tests, AllRuntimesVerbTest("optionalTestObjectVerb", exampleObject)...) + tests = append(tests, AllRuntimesVerbTest("optionalTestObjectOptionalFieldsVerb", exampleOptionalFieldsObject)...) + tests = append(tests, AllRuntimesVerbTest("externalTypeVerb", "did:web:abc123")...) + tests = append(tests, JavaAndGoVerbTest("typeEnumVerb", AnimalWrapper{Animal: Animal{ + Name: "Cat", + Value: Cat{ + Name: "Fluffy", + FurLength: 10, + Breed: "Siamese", + }, + }})...) + tests = append(tests, JavaAndGoVerbTest("valueEnumVerb", ColorWrapper{Color: Red})...) + //tests = append(tests, AllRuntimesVerbTest("typeWrapperEnumVerb", "hello")...) + //tests = append(tests, AllRuntimesVerbTest("mixedEnumVerb", Thing{})...) // tests = append(tests, PairedPrefixVerbTest("nilvalue", "optionalIntVerb", ftl.None[int]())...) // tests = append(tests, PairedPrefixVerbTest("nilvalue", "optionalFloatVerb", ftl.None[float64]())...) // tests = append(tests, PairedPrefixVerbTest("nilvalue", "optionalStringVerb", ftl.None[string]())...) @@ -250,10 +261,10 @@ func TestJVMCoreFunctionality(t *testing.T) { in.Run(t, in.WithJavaBuild(), in.CopyModuleWithLanguage("gomodule", "go"), - in.CopyModuleWithLanguage("javamodule", "java"), + in.CopyModuleWithLanguage("javaclient", "java"), in.CopyModuleWithLanguage("kotlinmodule", "kotlin"), in.Deploy("gomodule"), - in.Deploy("javamodule"), + in.Deploy("javaclient"), in.Deploy("kotlinmodule"), in.SubTests(tests...), ) @@ -270,7 +281,20 @@ func TestGradle(t *testing.T) { ) } -func PairedTest(name string, testFunc func(module string) in.Action) []in.SubTest { +func JavaAndGoTest(name string, testFunc func(module string) in.Action) []in.SubTest { + return []in.SubTest{ + { + Name: name + "-go", + Action: testFunc("gomodule"), + }, + { + Name: name + "-java", + Action: testFunc("javaclient"), + }, + } +} + +func AllRuntimesTest(name string, testFunc func(module string) in.Action) []in.SubTest { return []in.SubTest{ { Name: name + "-go", @@ -278,7 +302,7 @@ func PairedTest(name string, testFunc func(module string) in.Action) []in.SubTes }, { Name: name + "-java", - Action: testFunc("javamodule"), + Action: testFunc("javaclient"), }, { Name: name + "-kotlin", @@ -291,7 +315,7 @@ func JVMTest(name string, testFunc func(name string, module string) in.Action) [ return []in.SubTest{ { Name: name + "-java", - Action: testFunc(name, "javamodule"), + Action: testFunc(name, "javaclient"), }, { Name: name + "-kotlin", @@ -308,12 +332,15 @@ func VerbTest[T any](verb string, value T) func(module string) in.Action { } } -func PairedVerbTest[T any](verb string, value T) []in.SubTest { - return PairedTest(verb, VerbTest[T](verb, value)) +func AllRuntimesVerbTest[T any](verb string, value T) []in.SubTest { + return AllRuntimesTest(verb, VerbTest[T](verb, value)) +} +func JavaAndGoVerbTest[T any](verb string, value T) []in.SubTest { + return JavaAndGoTest(verb, VerbTest[T](verb, value)) } func PairedPrefixVerbTest[T any](prefex string, verb string, value T) []in.SubTest { - return PairedTest(prefex+"-"+verb, VerbTest[T](verb, value)) + return AllRuntimesTest(prefex+"-"+verb, VerbTest[T](verb, value)) } type TestObject struct { @@ -362,3 +389,43 @@ func verifyNonOptionalVerb(name string, module string) in.Action { assert.True(t, verb.Request.GetOptional() == nil, "request was optional") }) } + +type ColorInt int + +const ( + Red ColorInt = 0 + Green ColorInt = 1 + Blue ColorInt = 2 +) + +type ColorWrapper struct { + Color ColorInt `json:"color"` +} + +type TypeWrapperEnum interface{ typeEnum() } +type Scalar string +type StringList []string + +func (Scalar) typeEnum() {} +func (StringList) typeEnum() {} + +type Animal struct { + Name string `json:"name"` + Value Cat `json:"value"` +} +type Cat struct { + Name string `json:"name"` + FurLength int `json:"furLength"` + Breed string `json:"breed"` +} + +type AnimalWrapper struct { + Animal Animal `json:"animal"` +} + +type Mixed interface{ tag() } +type Word string +type Thing struct{} + +func (Word) tag() {} +func (Thing) tag() {} diff --git a/jvm-runtime/testdata/go/gomodule/server.go b/jvm-runtime/testdata/go/gomodule/server.go index 5fb717f6c..7a6d9cd73 100644 --- a/jvm-runtime/testdata/go/gomodule/server.go +++ b/jvm-runtime/testdata/go/gomodule/server.go @@ -39,6 +39,68 @@ type ParameterizedType[T any] struct { Map map[string]T } +//ftl:enum export +type ColorInt int + +const ( + Red ColorInt = 0 + Green ColorInt = 1 + Blue ColorInt = 2 +) + +type ColorWrapper struct { + Color ColorInt +} + +//ftl:enum export +type Shape string + +const ( + Circle Shape = "circle" + Square Shape = "square" + Triangle Shape = "triangle" +) + +type ShapeWrapper struct { + Shape Shape +} + +//ftl:enum export +type TypeWrapperEnum interface{ typeEnum() } +type Scalar string +type StringList []string + +func (Scalar) typeEnum() {} +func (StringList) typeEnum() {} + +type TypeEnumWrapper struct { + Type TypeWrapperEnum +} + +//ftl:enum +type Animal interface{ animal() } +type Cat struct { + Name string + Breed string + FurLength int +} +type Dog struct{} + +func (Cat) animal() {} +func (Dog) animal() {} + +type AnimalWrapper struct { + Animal Animal +} + +//TODO this doesn't work yet: https://github.com/TBD54566975/ftl/issues/2857 +////ftl:enum +//type Mixed interface{ mixed() } +//type Word string +// +//func (Word) mixed() {} +//func (Dog) mixed() {} + //ftl:typealias //ftl:typemap kotlin "web5.sdk.dids.didcore.Did" type DID = did.DID @@ -193,3 +255,28 @@ func OptionalTestObjectOptionalFieldsVerb(ctx context.Context, val ftl.Option[Te func ExternalTypeVerb(ctx context.Context, did DID) (DID, error) { return did, nil } + +//ftl:verb export +func ValueEnumVerb(ctx context.Context, val ColorWrapper) (ColorWrapper, error) { + return val, nil +} + +//ftl:verb export +func StringEnumVerb(ctx context.Context, val ShapeWrapper) (ShapeWrapper, error) { + return val, nil +} + +//ftl:verb export +func TypeWrapperEnumVerb(ctx context.Context, val TypeEnumWrapper) (TypeEnumWrapper, error) { + return val, nil +} + +//ftl:verb export +func TypeEnumVerb(ctx context.Context, val AnimalWrapper) (AnimalWrapper, error) { + return val, nil +} + +////ftl:verb export +//func MixedEnumVerb(ctx context.Context, val Mixed) (Mixed, error) { +// return val, nil +//} diff --git a/jvm-runtime/testdata/java/javaclient/ftl.toml b/jvm-runtime/testdata/java/javaclient/ftl.toml new file mode 100644 index 000000000..42c33899c --- /dev/null +++ b/jvm-runtime/testdata/java/javaclient/ftl.toml @@ -0,0 +1,2 @@ +module = "javaclient" +language = "java" diff --git a/jvm-runtime/testdata/java/javamodule/pom.xml b/jvm-runtime/testdata/java/javaclient/pom.xml similarity index 94% rename from jvm-runtime/testdata/java/javamodule/pom.xml rename to jvm-runtime/testdata/java/javaclient/pom.xml index 31d53c833..91784be87 100644 --- a/jvm-runtime/testdata/java/javamodule/pom.xml +++ b/jvm-runtime/testdata/java/javaclient/pom.xml @@ -2,7 +2,7 @@ 4.0.0 xyz.block.ftl.examples - javamodule + javaclient 1.0-SNAPSHOT diff --git a/jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/AnySerializedType.java b/jvm-runtime/testdata/java/javaclient/src/main/java/xyz/block/ftl/test/AnySerializedType.java similarity index 100% rename from jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/AnySerializedType.java rename to jvm-runtime/testdata/java/javaclient/src/main/java/xyz/block/ftl/test/AnySerializedType.java diff --git a/jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/AnySerializedTypeMapper.java b/jvm-runtime/testdata/java/javaclient/src/main/java/xyz/block/ftl/test/AnySerializedTypeMapper.java similarity index 100% rename from jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/AnySerializedTypeMapper.java rename to jvm-runtime/testdata/java/javaclient/src/main/java/xyz/block/ftl/test/AnySerializedTypeMapper.java diff --git a/jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/ConfigEndpoint.java b/jvm-runtime/testdata/java/javaclient/src/main/java/xyz/block/ftl/test/ConfigEndpoint.java similarity index 100% rename from jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/ConfigEndpoint.java rename to jvm-runtime/testdata/java/javaclient/src/main/java/xyz/block/ftl/test/ConfigEndpoint.java diff --git a/jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/CustomSerializedType.java b/jvm-runtime/testdata/java/javaclient/src/main/java/xyz/block/ftl/test/CustomSerializedType.java similarity index 100% rename from jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/CustomSerializedType.java rename to jvm-runtime/testdata/java/javaclient/src/main/java/xyz/block/ftl/test/CustomSerializedType.java diff --git a/jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/CustomSerializedTypeMapper.java b/jvm-runtime/testdata/java/javaclient/src/main/java/xyz/block/ftl/test/CustomSerializedTypeMapper.java similarity index 100% rename from jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/CustomSerializedTypeMapper.java rename to jvm-runtime/testdata/java/javaclient/src/main/java/xyz/block/ftl/test/CustomSerializedTypeMapper.java diff --git a/jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/DidMapper.java b/jvm-runtime/testdata/java/javaclient/src/main/java/xyz/block/ftl/test/DidMapper.java similarity index 100% rename from jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/DidMapper.java rename to jvm-runtime/testdata/java/javaclient/src/main/java/xyz/block/ftl/test/DidMapper.java diff --git a/jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/TestInvokeGoFromJava.java b/jvm-runtime/testdata/java/javaclient/src/main/java/xyz/block/ftl/test/TestInvokeGoFromJava.java similarity index 79% rename from jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/TestInvokeGoFromJava.java rename to jvm-runtime/testdata/java/javaclient/src/main/java/xyz/block/ftl/test/TestInvokeGoFromJava.java index d7c513028..26bcfb436 100644 --- a/jvm-runtime/testdata/java/javamodule/src/main/java/xyz/block/ftl/test/TestInvokeGoFromJava.java +++ b/jvm-runtime/testdata/java/javaclient/src/main/java/xyz/block/ftl/test/TestInvokeGoFromJava.java @@ -7,8 +7,10 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import ftl.gomodule.AnimalWrapper; import ftl.gomodule.BoolVerbClient; import ftl.gomodule.BytesVerbClient; +import ftl.gomodule.ColorWrapper; import ftl.gomodule.EmptyVerbClient; import ftl.gomodule.ErrorEmptyVerbClient; import ftl.gomodule.ExternalTypeVerbClient; @@ -28,9 +30,13 @@ import ftl.gomodule.OptionalTimeVerbClient; import ftl.gomodule.ParameterizedObjectVerbClient; import ftl.gomodule.ParameterizedType; +import ftl.gomodule.Scalar; +import ftl.gomodule.ShapeWrapper; import ftl.gomodule.SinkVerbClient; import ftl.gomodule.SourceVerbClient; import ftl.gomodule.StringArrayVerbClient; +import ftl.gomodule.StringEnumVerbClient; +import ftl.gomodule.StringList; import ftl.gomodule.StringMapVerbClient; import ftl.gomodule.StringVerbClient; import ftl.gomodule.TestObject; @@ -38,6 +44,10 @@ import ftl.gomodule.TestObjectOptionalFieldsVerbClient; import ftl.gomodule.TestObjectVerbClient; import ftl.gomodule.TimeVerbClient; +import ftl.gomodule.TypeEnumVerbClient; +import ftl.gomodule.TypeEnumWrapper; +import ftl.gomodule.TypeWrapperEnumVerbClient; +import ftl.gomodule.ValueEnumVerbClient; import web5.sdk.dids.didcore.Did; import xyz.block.ftl.Export; import xyz.block.ftl.Verb; @@ -232,4 +242,44 @@ public CustomSerializedType stringAliasedType(CustomSerializedType type) { public AnySerializedType anyAliasedType(AnySerializedType type) { return type; } + + @Export + @Verb + public AnimalWrapper typeEnumVerb(AnimalWrapper animal, TypeEnumVerbClient client) { + if (animal.getAnimal().isCat()) { + return client.call(new AnimalWrapper(animal.getAnimal().getCat())); + } else { + return client.call(new AnimalWrapper(animal.getAnimal().getDog())); + } + } + + @Export + @Verb + public ColorWrapper valueEnumVerb(ColorWrapper color, ValueEnumVerbClient client) { + return client.call(color); + } + + @Export + @Verb + public ShapeWrapper stringEnumVerb(ShapeWrapper shape, StringEnumVerbClient client) { + return client.call(shape); + } + + @Export + @Verb + public TypeEnumWrapper typeWrapperEnumVerb(TypeEnumWrapper value, TypeWrapperEnumVerbClient client) { + if (value.getType().isScalar()) { + return client.call(new TypeEnumWrapper(new StringList(List.of("a", "b", "c")))); + } else if (value.getType().isStringList()) { + return client.call(new TypeEnumWrapper(new Scalar("scalar"))); + } else { + throw new IllegalArgumentException("unexpected value"); + } + } + + // @Export + // @Verb + // public Mixed mixedEnumVerb(Mixed mixed, MixedEnumVerbClient client) { + // return client.call(mixed); + // } } diff --git a/jvm-runtime/testdata/java/javacomments/ftl.toml b/jvm-runtime/testdata/java/javacomments/ftl.toml deleted file mode 100644 index ee2f93e1f..000000000 --- a/jvm-runtime/testdata/java/javacomments/ftl.toml +++ /dev/null @@ -1,2 +0,0 @@ -module = "javacomments" -language = "java" diff --git a/jvm-runtime/testdata/java/javacomments/pom.xml b/jvm-runtime/testdata/java/javacomments/pom.xml deleted file mode 100644 index f872bb7f7..000000000 --- a/jvm-runtime/testdata/java/javacomments/pom.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - 4.0.0 - xyz.block.ftl.examples - javacomments - 1.0-SNAPSHOT - - - xyz.block.ftl - ftl-build-parent-java - 1.0-SNAPSHOT - - - - - xyz.block - web5-dids - 2.0.1-debug1 - - - - diff --git a/jvm-runtime/testdata/java/javacomments/src/main/java/xyz/block/ftl/javacomments/EnumType.java b/jvm-runtime/testdata/java/javacomments/src/main/java/xyz/block/ftl/javacomments/EnumType.java deleted file mode 100644 index 2c36f4282..000000000 --- a/jvm-runtime/testdata/java/javacomments/src/main/java/xyz/block/ftl/javacomments/EnumType.java +++ /dev/null @@ -1,14 +0,0 @@ -package xyz.block.ftl.javacomments; - -import xyz.block.ftl.Export; - -/** - * Comment on an enum type - */ -@Export -public enum EnumType { - /** - * Comment on an enum value - */ - PORTENTOUS -} diff --git a/jvm-runtime/testdata/java/javamodule/ftl.toml b/jvm-runtime/testdata/java/javamodule/ftl.toml deleted file mode 100644 index 88d8a88c9..000000000 --- a/jvm-runtime/testdata/java/javamodule/ftl.toml +++ /dev/null @@ -1,2 +0,0 @@ -module = "javamodule" -language = "java" diff --git a/jvm-runtime/testdata/java/javaserver/ftl.toml b/jvm-runtime/testdata/java/javaserver/ftl.toml new file mode 100644 index 000000000..16dc208d3 --- /dev/null +++ b/jvm-runtime/testdata/java/javaserver/ftl.toml @@ -0,0 +1,2 @@ +module = "javaserver" +language = "java" diff --git a/jvm-runtime/testdata/java/verbs/pom.xml b/jvm-runtime/testdata/java/javaserver/pom.xml similarity index 95% rename from jvm-runtime/testdata/java/verbs/pom.xml rename to jvm-runtime/testdata/java/javaserver/pom.xml index 17cc01f69..411a251bd 100644 --- a/jvm-runtime/testdata/java/verbs/pom.xml +++ b/jvm-runtime/testdata/java/javaserver/pom.xml @@ -2,7 +2,7 @@ 4.0.0 xyz.block.ftl.examples - verbs-module + javaserver 1.0-SNAPSHOT diff --git a/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Animal.java b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Animal.java new file mode 100644 index 000000000..2e86fb158 --- /dev/null +++ b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Animal.java @@ -0,0 +1,20 @@ +package xyz.block.ftl.enums; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import xyz.block.ftl.Enum; + +@Enum +public interface Animal { + @JsonIgnore + boolean isCat(); + + @JsonIgnore + boolean isDog(); + + @JsonIgnore + Cat getCat(); + + @JsonIgnore + Dog getDog(); +} diff --git a/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/AnimalWrapper.java b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/AnimalWrapper.java new file mode 100644 index 000000000..10e40bc6a --- /dev/null +++ b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/AnimalWrapper.java @@ -0,0 +1,23 @@ +package xyz.block.ftl.enums; + +import org.jetbrains.annotations.NotNull; + +public class AnimalWrapper { + private @NotNull Animal animal; + + public AnimalWrapper() { + } + + public AnimalWrapper(@NotNull Animal animal) { + this.animal = animal; + } + + public AnimalWrapper setAnimal(@NotNull Animal animal) { + this.animal = animal; + return this; + } + + public @NotNull Animal getAnimal() { + return animal; + } +} diff --git a/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Cat.java b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Cat.java new file mode 100644 index 000000000..3c7bce3e8 --- /dev/null +++ b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Cat.java @@ -0,0 +1,48 @@ +package xyz.block.ftl.enums; + +import org.jetbrains.annotations.NotNull; + +public class Cat implements Animal { + private @NotNull String name; + + private @NotNull String breed; + + private long furLength; + + public Cat() { + } + + public Cat(@NotNull String breed, long furLength, @NotNull String name) { + this.breed = breed; + this.furLength = furLength; + this.name = name; + } + + public boolean isCat() { + return true; + } + + public boolean isDog() { + return false; + } + + public Cat getCat() { + return this; + } + + public Dog getDog() { + return null; + } + + public @NotNull String getName() { + return name; + } + + public @NotNull String getBreed() { + return breed; + } + + public long getFurLength() { + return furLength; + } +} diff --git a/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/ColorInt.java b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/ColorInt.java new file mode 100644 index 000000000..fb8d3e79f --- /dev/null +++ b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/ColorInt.java @@ -0,0 +1,20 @@ +package xyz.block.ftl.enums; + +import xyz.block.ftl.Enum; + +@Enum +public enum ColorInt { + RED(0), + GREEN(1), + BLUE(2); + + private final int value; + + ColorInt(int value) { + this.value = value; + } + + public int getValue() { + return value; + } +} diff --git a/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/ColorWrapper.java b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/ColorWrapper.java new file mode 100644 index 000000000..797f854e6 --- /dev/null +++ b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/ColorWrapper.java @@ -0,0 +1,27 @@ +package xyz.block.ftl.enums; + +import org.jetbrains.annotations.NotNull; + +public class ColorWrapper { + private @NotNull ColorInt color; + + public ColorWrapper() { + } + + public ColorWrapper(@NotNull ColorInt color) { + this.color = color; + } + + public ColorWrapper setColor(@NotNull ColorInt color) { + this.color = color; + return this; + } + + public @NotNull ColorInt getColor() { + return color; + } + + public String toString() { + return "ColorWrapper(color=" + this.color + ")"; + } +} diff --git a/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Dog.java b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Dog.java new file mode 100644 index 000000000..fcd41680e --- /dev/null +++ b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Dog.java @@ -0,0 +1,19 @@ +package xyz.block.ftl.enums; + +public class Dog implements Animal { + public boolean isCat() { + return false; + } + + public boolean isDog() { + return true; + } + + public Cat getCat() { + return null; + } + + public Dog getDog() { + return this; + } +} diff --git a/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/List.java b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/List.java new file mode 100644 index 000000000..e97dac460 --- /dev/null +++ b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/List.java @@ -0,0 +1,16 @@ +package xyz.block.ftl.enums; + +import xyz.block.ftl.EnumHolder; + +@EnumHolder +public final class List implements ScalarOrList { + public final java.util.List value; + + public List() { + this.value = null; + } + + public List(java.util.List value) { + this.value = value; + } +} diff --git a/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Scalar.java b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Scalar.java new file mode 100644 index 000000000..8fc4698cf --- /dev/null +++ b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Scalar.java @@ -0,0 +1,16 @@ +package xyz.block.ftl.enums; + +import xyz.block.ftl.EnumHolder; + +@EnumHolder +public final class Scalar implements ScalarOrList { + public final String value; + + public Scalar() { + this.value = null; + } + + public Scalar(String value) { + this.value = value; + } +} diff --git a/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/ScalarOrList.java b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/ScalarOrList.java new file mode 100644 index 000000000..c985ff2ab --- /dev/null +++ b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/ScalarOrList.java @@ -0,0 +1,8 @@ +package xyz.block.ftl.enums; + +import xyz.block.ftl.Enum; + +@Enum +public sealed interface ScalarOrList permits Scalar, List { + +} diff --git a/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Shape.java b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Shape.java new file mode 100644 index 000000000..3c45a283f --- /dev/null +++ b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Shape.java @@ -0,0 +1,20 @@ +package xyz.block.ftl.enums; + +import xyz.block.ftl.Enum; + +@Enum +public enum Shape { + CIRCLE("circle"), + SQUARE("square"), + TRIANGLE("triangle"); + + private final String value; + + Shape(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/ShapeWrapper.java b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/ShapeWrapper.java new file mode 100644 index 000000000..76ba6a607 --- /dev/null +++ b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/ShapeWrapper.java @@ -0,0 +1,23 @@ +package xyz.block.ftl.enums; + +import org.jetbrains.annotations.NotNull; + +public class ShapeWrapper { + private @NotNull Shape shape; + + public ShapeWrapper() { + } + + public ShapeWrapper(@NotNull Shape shape) { + this.shape = shape; + } + + public ShapeWrapper setShape(@NotNull Shape shape) { + this.shape = shape; + return this; + } + + public @NotNull Shape getShape() { + return shape; + } +} diff --git a/jvm-runtime/testdata/java/verbs/src/main/java/xyz/block/ftl/test/Verbs.java b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Verbs.java similarity index 50% rename from jvm-runtime/testdata/java/verbs/src/main/java/xyz/block/ftl/test/Verbs.java rename to jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Verbs.java index f271004ef..fbfce4286 100644 --- a/jvm-runtime/testdata/java/verbs/src/main/java/xyz/block/ftl/test/Verbs.java +++ b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/enums/Verbs.java @@ -1,4 +1,4 @@ -package xyz.block.ftl.test; +package xyz.block.ftl.enums; import java.util.Map; @@ -21,4 +21,21 @@ public Object anyOutput(String name) { return Map.of("name", name); } + @Export + @Verb + public ColorWrapper valueEnumVerb(ColorWrapper color) { + return color; + } + + @Export + @Verb + public ShapeWrapper stringEnumVerb(ShapeWrapper shape) { + return shape; + } + + @Export + @Verb + public AnimalWrapper typeEnumVerb(AnimalWrapper animal) { + return animal; + } } diff --git a/jvm-runtime/testdata/java/javacomments/src/main/java/xyz/block/ftl/javacomments/CommentedModule.java b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/javacomments/CommentedModule.java similarity index 100% rename from jvm-runtime/testdata/java/javacomments/src/main/java/xyz/block/ftl/javacomments/CommentedModule.java rename to jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/javacomments/CommentedModule.java diff --git a/jvm-runtime/testdata/java/javacomments/src/main/java/xyz/block/ftl/javacomments/DataClass.java b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/javacomments/DataClass.java similarity index 100% rename from jvm-runtime/testdata/java/javacomments/src/main/java/xyz/block/ftl/javacomments/DataClass.java rename to jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/javacomments/DataClass.java diff --git a/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/javacomments/EnumType.java b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/javacomments/EnumType.java new file mode 100644 index 000000000..f9508dbe9 --- /dev/null +++ b/jvm-runtime/testdata/java/javaserver/src/main/java/xyz/block/ftl/javacomments/EnumType.java @@ -0,0 +1,26 @@ +package xyz.block.ftl.javacomments; + +import xyz.block.ftl.Enum; +import xyz.block.ftl.Export; + +/** + * Comment on an enum type + */ +@Enum +@Export +public enum EnumType { + /** + * Comment on an enum value + */ + PORTENTOUS("portentous"); + + private final String value; + + EnumType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/jvm-runtime/testdata/java/verbs/ftl.toml b/jvm-runtime/testdata/java/verbs/ftl.toml deleted file mode 100644 index f059634b9..000000000 --- a/jvm-runtime/testdata/java/verbs/ftl.toml +++ /dev/null @@ -1,2 +0,0 @@ -module = "verbs" -language = "java"