diff --git a/examples/README.md b/examples/README.md index 8d1b62c8..6ec7c2c2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,7 +7,7 @@ For a sample project configuration and more elaborated examples, check out the [ Available examples: * [`Counter`](src/main/java/my/restate/sdk/examples/Counter.java): Shows a simple virtual object using state primitives. -* [`Counter`](src/main/kotlin/my/restate/sdk/examples/Counter.kt): Same as `Counter` but using Kotlin. +* [`CounterKt`](src/main/kotlin/my/restate/sdk/examples/CounterKt.kt): Same as `Counter` but using Kotlin. * [`LoanWorkflow`](src/main/java/my/restate/sdk/examples/LoanWorkflow.java): Shows a simple workflow example using the Workflow API. ## Package the examples for Lambda @@ -35,7 +35,7 @@ You can run the Java Counter example via: You can modify the class to run setting `-PmainClass=`, for example, in order to run the Kotlin implementation: ```shell -./gradlew :examples:run -PmainClass=my.restate.sdk.examples.CounterKt +./gradlew :examples:run -PmainClass=my.restate.sdk.examples.CounterKtKt ``` ## Invoking the counter bindableComponent diff --git a/examples/build.gradle.kts b/examples/build.gradle.kts index 6937d0a5..c75d67d5 100644 --- a/examples/build.gradle.kts +++ b/examples/build.gradle.kts @@ -1,12 +1,17 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import com.github.jengelman.gradle.plugins.shadow.transformers.ServiceFileTransformer + plugins { java kotlin("jvm") kotlin("plugin.serialization") application - id("com.github.johnrengelman.shadow").version("7.1.2") + alias(kotlinLibs.plugins.ksp) + id("com.github.johnrengelman.shadow").version("8.1.1") } dependencies { + ksp(project(":sdk-api-kotlin-gen")) annotationProcessor(project(":sdk-api-gen")) implementation(project(":sdk-api")) @@ -19,13 +24,6 @@ dependencies { implementation(platform(jacksonLibs.jackson.bom)) implementation(jacksonLibs.jackson.jsr310) - implementation(coreLibs.protobuf.java) - implementation(coreLibs.protobuf.kotlin) - - implementation(platform(vertxLibs.vertx.bom)) - implementation(vertxLibs.vertx.core) - implementation(vertxLibs.vertx.kotlin.coroutines) - implementation(kotlinLibs.kotlinx.coroutines) implementation(kotlinLibs.kotlinx.serialization.core) implementation(kotlinLibs.kotlinx.serialization.json) @@ -38,3 +36,7 @@ application { project.findProperty("mainClass")?.toString() ?: "my.restate.sdk.examples.Counter" mainClass.set(mainClassValue) } + +tasks.withType { this.enabled = false } + +tasks.withType { transform(ServiceFileTransformer::class.java) } diff --git a/examples/src/main/java/my/restate/sdk/examples/LambdaHandler.java b/examples/src/main/java/my/restate/sdk/examples/LambdaHandler.java index ce1be35d..264bddb1 100644 --- a/examples/src/main/java/my/restate/sdk/examples/LambdaHandler.java +++ b/examples/src/main/java/my/restate/sdk/examples/LambdaHandler.java @@ -24,7 +24,7 @@ public void register(RestateLambdaEndpointBuilder builder) { if (Counter.class.getCanonicalName().equals(serviceClass)) { builder.with(new Counter()); } else if (CounterKt.class.getCanonicalName().equals(serviceClass)) { - builder.with(CounterKt.getCounter()); + builder.with(new CounterKt()); } else { throw new IllegalArgumentException( "Bad \"LAMBDA_FACTORY_SERVICE_CLASS\" env: " + serviceClass); diff --git a/examples/src/main/kotlin/my/restate/sdk/examples/Counter.kt b/examples/src/main/kotlin/my/restate/sdk/examples/Counter.kt deleted file mode 100644 index 417b6719..00000000 --- a/examples/src/main/kotlin/my/restate/sdk/examples/Counter.kt +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH -// -// This file is part of the Restate Java SDK, -// which is released under the MIT license. -// -// You can find a copy of the license in file LICENSE in the root -// directory of this repository or package, or at -// https://github.com/restatedev/sdk-java/blob/main/LICENSE -package my.restate.sdk.examples - -import dev.restate.sdk.common.StateKey -import dev.restate.sdk.http.vertx.RestateHttpEndpointBuilder -import dev.restate.sdk.kotlin.Component -import dev.restate.sdk.kotlin.KtSerdes -import kotlinx.serialization.Serializable - -@Serializable data class CounterUpdate(var oldValue: Long, val newValue: Long) - -private val totalKey = StateKey.of("total", KtSerdes.json()) - -val counter = - Component.virtualObject("Counter") { - handler("reset") { ctx, _: Unit -> ctx.clear(totalKey) } - handler("add") { ctx, value: Long -> - val currentValue = ctx.get(totalKey) ?: 0L - val newValue = currentValue + value - ctx.set(totalKey, newValue) - } - handler("get") { ctx, _: Unit -> ctx.get(totalKey) ?: 0L } - handler("getAndAdd") { ctx, value: Long -> - val currentValue = ctx.get(totalKey) ?: 0L - val newValue = currentValue + value - ctx.set(totalKey, newValue) - CounterUpdate(currentValue, newValue) - } - } - -fun main() { - RestateHttpEndpointBuilder.builder().with(counter).buildAndListen() -} diff --git a/examples/src/main/kotlin/my/restate/sdk/examples/CounterKt.kt b/examples/src/main/kotlin/my/restate/sdk/examples/CounterKt.kt new file mode 100644 index 00000000..c17c3e68 --- /dev/null +++ b/examples/src/main/kotlin/my/restate/sdk/examples/CounterKt.kt @@ -0,0 +1,56 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package my.restate.sdk.examples + +import dev.restate.sdk.annotation.Handler +import dev.restate.sdk.annotation.VirtualObject +import dev.restate.sdk.common.StateKey +import dev.restate.sdk.http.vertx.RestateHttpEndpointBuilder +import dev.restate.sdk.kotlin.KtSerdes +import dev.restate.sdk.kotlin.ObjectContext +import kotlinx.serialization.Serializable + +@Serializable data class CounterUpdate(var oldValue: Long, val newValue: Long) + +@VirtualObject +class CounterKt { + + companion object { + private val TOTAL = StateKey.of("total", KtSerdes.json()) + } + + @Handler + suspend fun reset(ctx: ObjectContext) { + ctx.clear(TOTAL) + } + + @Handler + suspend fun add(ctx: ObjectContext, value: Long) { + val currentValue = ctx.get(TOTAL) ?: 0L + val newValue = currentValue + value + ctx.set(TOTAL, newValue) + } + + @Handler + suspend fun get(ctx: ObjectContext): Long { + return ctx.get(TOTAL) ?: 0L + } + + @Handler + suspend fun getAndAdd(ctx: ObjectContext, value: Long): CounterUpdate { + val currentValue = ctx.get(TOTAL) ?: 0L + val newValue = currentValue + value + ctx.set(TOTAL, newValue) + return CounterUpdate(currentValue, newValue) + } +} + +fun main() { + RestateHttpEndpointBuilder.builder().with(CounterKt()).buildAndListen() +} diff --git a/sdk-api-gen-common/build.gradle.kts b/sdk-api-gen-common/build.gradle.kts new file mode 100644 index 00000000..6d69e5b9 --- /dev/null +++ b/sdk-api-gen-common/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + `java-library` + `library-publishing-conventions` +} + +description = "Restate SDK API Gen Common" + +dependencies { + compileOnly(coreLibs.jspecify) + + api("com.github.jknack:handlebars:4.3.1") + api(project(":sdk-common")) +} diff --git a/sdk-api-gen-common/src/main/java/dev/restate/sdk/gen/model/Component.java b/sdk-api-gen-common/src/main/java/dev/restate/sdk/gen/model/Component.java new file mode 100644 index 00000000..04939368 --- /dev/null +++ b/sdk-api-gen-common/src/main/java/dev/restate/sdk/gen/model/Component.java @@ -0,0 +1,140 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.gen.model; + +import dev.restate.sdk.common.ComponentType; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +public class Component { + + private final CharSequence targetPkg; + private final CharSequence targetFqcn; + private final String componentName; + private final ComponentType componentType; + private final List handlers; + + public Component( + CharSequence targetPkg, + CharSequence targetFqcn, + String componentName, + ComponentType componentType, + List handlers) { + this.targetPkg = targetPkg; + this.targetFqcn = targetFqcn; + this.componentName = componentName; + + this.componentType = componentType; + this.handlers = handlers; + } + + public CharSequence getTargetPkg() { + return this.targetPkg; + } + + public CharSequence getTargetFqcn() { + return this.targetFqcn; + } + + public String getFullyQualifiedComponentName() { + return this.componentName; + } + + public String getSimpleComponentName() { + return this.componentName.substring(this.componentName.lastIndexOf('.') + 1); + } + + public CharSequence getGeneratedClassFqcnPrefix() { + if (this.targetPkg == null || this.targetPkg.length() == 0) { + return getSimpleComponentName(); + } + return this.targetPkg + "." + getSimpleComponentName(); + } + + public ComponentType getComponentType() { + return componentType; + } + + public List getMethods() { + return handlers; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private CharSequence targetPkg; + private CharSequence targetFqcn; + private String componentName; + private ComponentType componentType; + private final List handlers = new ArrayList<>(); + + public Builder withTargetPkg(CharSequence targetPkg) { + this.targetPkg = targetPkg; + return this; + } + + public Builder withTargetFqcn(CharSequence targetFqcn) { + this.targetFqcn = targetFqcn; + return this; + } + + public Builder withComponentName(String componentName) { + this.componentName = componentName; + return this; + } + + public Builder withComponentType(ComponentType componentType) { + this.componentType = componentType; + return this; + } + + public Builder withHandlers(Collection handlers) { + this.handlers.addAll(handlers); + return this; + } + + public Builder withHandler(Handler handler) { + this.handlers.add(handler); + return this; + } + + public CharSequence getTargetPkg() { + return targetPkg; + } + + public CharSequence getTargetFqcn() { + return targetFqcn; + } + + public String getComponentName() { + return componentName; + } + + public ComponentType getComponentType() { + return componentType; + } + + public List getHandlers() { + return handlers; + } + + public Component build() { + return new Component( + Objects.requireNonNull(targetPkg), + Objects.requireNonNull(targetFqcn), + Objects.requireNonNull(componentName), + Objects.requireNonNull(componentType), + handlers); + } + } +} diff --git a/sdk-api-gen-common/src/main/java/dev/restate/sdk/gen/model/Handler.java b/sdk-api-gen-common/src/main/java/dev/restate/sdk/gen/model/Handler.java new file mode 100644 index 00000000..4093b5af --- /dev/null +++ b/sdk-api-gen-common/src/main/java/dev/restate/sdk/gen/model/Handler.java @@ -0,0 +1,95 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.gen.model; + +import java.util.Objects; + +public class Handler { + + private final CharSequence name; + private final HandlerType handlerType; + private final PayloadType inputType; + private final PayloadType outputType; + + public Handler( + CharSequence name, HandlerType handlerType, PayloadType inputType, PayloadType outputType) { + this.name = name; + this.handlerType = handlerType; + this.inputType = inputType; + this.outputType = outputType; + } + + public CharSequence getName() { + return name; + } + + public HandlerType getHandlerType() { + return handlerType; + } + + public PayloadType getInputType() { + return inputType; + } + + public PayloadType getOutputType() { + return outputType; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private CharSequence name; + private HandlerType handlerType; + private PayloadType inputType; + private PayloadType outputType; + + public Builder withName(CharSequence name) { + this.name = name; + return this; + } + + public Builder withHandlerType(HandlerType handlerType) { + this.handlerType = handlerType; + return this; + } + + public Builder withInputType(PayloadType inputType) { + this.inputType = inputType; + return this; + } + + public Builder withOutputType(PayloadType outputType) { + this.outputType = outputType; + return this; + } + + public CharSequence getName() { + return name; + } + + public HandlerType getHandlerType() { + return handlerType; + } + + public PayloadType getInputType() { + return inputType; + } + + public PayloadType getOutputType() { + return outputType; + } + + public Handler build() { + return new Handler( + Objects.requireNonNull(name), Objects.requireNonNull(handlerType), inputType, outputType); + } + } +} diff --git a/sdk-api-gen/src/main/java/dev/restate/sdk/gen/model/MethodType.java b/sdk-api-gen-common/src/main/java/dev/restate/sdk/gen/model/HandlerType.java similarity index 94% rename from sdk-api-gen/src/main/java/dev/restate/sdk/gen/model/MethodType.java rename to sdk-api-gen-common/src/main/java/dev/restate/sdk/gen/model/HandlerType.java index 9586523e..af8c4288 100644 --- a/sdk-api-gen/src/main/java/dev/restate/sdk/gen/model/MethodType.java +++ b/sdk-api-gen-common/src/main/java/dev/restate/sdk/gen/model/HandlerType.java @@ -8,7 +8,7 @@ // https://github.com/restatedev/sdk-java/blob/main/LICENSE package dev.restate.sdk.gen.model; -public enum MethodType { +public enum HandlerType { SHARED, EXCLUSIVE, STATELESS, diff --git a/sdk-api-gen-common/src/main/java/dev/restate/sdk/gen/model/PayloadType.java b/sdk-api-gen-common/src/main/java/dev/restate/sdk/gen/model/PayloadType.java new file mode 100644 index 00000000..136d8d9d --- /dev/null +++ b/sdk-api-gen-common/src/main/java/dev/restate/sdk/gen/model/PayloadType.java @@ -0,0 +1,75 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.gen.model; + +import java.util.Objects; + +public class PayloadType { + + private final boolean isEmpty; + private final String name; + private final String boxed; + private final String serdeDecl; + + public PayloadType(boolean isEmpty, String name, String boxed, String serdeDecl) { + this.isEmpty = isEmpty; + this.name = name; + this.boxed = boxed; + this.serdeDecl = serdeDecl; + } + + public boolean isEmpty() { + return isEmpty; + } + + public String getName() { + return name; + } + + public String getBoxed() { + return boxed; + } + + public String getSerdeDecl() { + return serdeDecl; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + PayloadType that = (PayloadType) o; + + if (isEmpty != that.isEmpty) return false; + if (!Objects.equals(name, that.name)) return false; + if (!Objects.equals(boxed, that.boxed)) return false; + return Objects.equals(serdeDecl, that.serdeDecl); + } + + @Override + public int hashCode() { + return Objects.hash(name, boxed, serdeDecl); + } + + @Override + public String toString() { + return "PayloadType{" + + "name='" + + name + + '\'' + + ", boxed='" + + boxed + + '\'' + + ", serdeDecl='" + + serdeDecl + + '\'' + + '}'; + } +} diff --git a/sdk-api-gen-common/src/main/java/dev/restate/sdk/gen/template/HandlebarsTemplateEngine.java b/sdk-api-gen-common/src/main/java/dev/restate/sdk/gen/template/HandlebarsTemplateEngine.java new file mode 100644 index 00000000..ca17a9eb --- /dev/null +++ b/sdk-api-gen-common/src/main/java/dev/restate/sdk/gen/template/HandlebarsTemplateEngine.java @@ -0,0 +1,147 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.gen.template; + +import com.github.jknack.handlebars.Context; +import com.github.jknack.handlebars.Handlebars; +import com.github.jknack.handlebars.Template; +import com.github.jknack.handlebars.context.FieldValueResolver; +import com.github.jknack.handlebars.helper.StringHelpers; +import com.github.jknack.handlebars.io.TemplateLoader; +import dev.restate.sdk.common.ComponentType; +import dev.restate.sdk.common.function.ThrowingFunction; +import dev.restate.sdk.gen.model.Component; +import dev.restate.sdk.gen.model.Handler; +import dev.restate.sdk.gen.model.HandlerType; +import java.io.IOException; +import java.io.Writer; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class HandlebarsTemplateEngine { + + private final String baseTemplateName; + private final Map templates; + + public HandlebarsTemplateEngine( + String baseTemplateName, + TemplateLoader templateLoader, + Map templates) { + this.baseTemplateName = baseTemplateName; + + Handlebars handlebars = new Handlebars(templateLoader); + handlebars.registerHelpers(StringHelpers.class); + + this.templates = + templates.entrySet().stream() + .collect( + Collectors.toMap( + Map.Entry::getKey, + e -> { + try { + return handlebars.compile(e.getValue()); + } catch (IOException ex) { + throw new RuntimeException( + "Can't compile template for " + + e.getKey() + + " with base template name " + + baseTemplateName, + ex); + } + })); + } + + public void generate(ThrowingFunction createFile, Component component) + throws Throwable { + String fileName = component.getGeneratedClassFqcnPrefix() + this.baseTemplateName; + try (Writer out = createFile.apply(fileName)) { + this.templates + .get(component.getComponentType()) + .apply( + Context.newBuilder(new ComponentTemplateModel(component, this.baseTemplateName)) + .resolver(FieldValueResolver.INSTANCE) + .build(), + out); + } + } + + // --- classes to interact with the handlebars template + + static class ComponentTemplateModel { + public final String originalClassPkg; + public final String originalClassFqcn; + public final String generatedClassSimpleNamePrefix; + public final String generatedClassSimpleName; + public final String componentName; + public final String componentType; + public final boolean isWorkflow; + public final boolean isObject; + public final boolean isService; + public final List handlers; + + private ComponentTemplateModel(Component inner, String baseTemplateName) { + this.originalClassPkg = inner.getTargetPkg().toString(); + this.originalClassFqcn = inner.getTargetFqcn().toString(); + this.generatedClassSimpleNamePrefix = inner.getSimpleComponentName(); + this.generatedClassSimpleName = this.generatedClassSimpleNamePrefix + baseTemplateName; + this.componentName = inner.getFullyQualifiedComponentName(); + + this.componentType = inner.getComponentType().toString(); + this.isWorkflow = inner.getComponentType() == ComponentType.WORKFLOW; + this.isObject = inner.getComponentType() == ComponentType.VIRTUAL_OBJECT; + this.isService = inner.getComponentType() == ComponentType.SERVICE; + + this.handlers = + inner.getMethods().stream().map(HandlerTemplateModel::new).collect(Collectors.toList()); + } + } + + static class HandlerTemplateModel { + public final String name; + public final String handlerType; + public final boolean isWorkflow; + public final boolean isShared; + public final boolean isStateless; + public final boolean isExclusive; + + public final boolean inputEmpty; + public final String inputFqcn; + public final String inputSerdeDecl; + public final String boxedInputFqcn; + public final String inputSerdeFieldName; + + public final boolean outputEmpty; + public final String outputFqcn; + public final String outputSerdeDecl; + public final String boxedOutputFqcn; + public final String outputSerdeFieldName; + + private HandlerTemplateModel(Handler inner) { + this.name = inner.getName().toString(); + this.handlerType = inner.getHandlerType().toString(); + this.isWorkflow = inner.getHandlerType() == HandlerType.WORKFLOW; + this.isShared = inner.getHandlerType() == HandlerType.SHARED; + this.isExclusive = inner.getHandlerType() == HandlerType.EXCLUSIVE; + this.isStateless = inner.getHandlerType() == HandlerType.STATELESS; + + this.inputEmpty = inner.getInputType().isEmpty(); + this.inputFqcn = inner.getInputType().getName(); + this.inputSerdeDecl = inner.getInputType().getSerdeDecl(); + this.boxedInputFqcn = inner.getInputType().getBoxed(); + this.inputSerdeFieldName = "SERDE_" + this.name.toUpperCase() + "_INPUT"; + + this.outputEmpty = inner.getOutputType().isEmpty(); + this.outputFqcn = inner.getOutputType().getName(); + this.outputSerdeDecl = inner.getOutputType().getSerdeDecl(); + this.boxedOutputFqcn = inner.getOutputType().getBoxed(); + this.outputSerdeFieldName = "SERDE_" + this.name.toUpperCase() + "_OUTPUT"; + } + } +} diff --git a/sdk-api-gen/build.gradle.kts b/sdk-api-gen/build.gradle.kts index c82306ec..0b43cfd5 100644 --- a/sdk-api-gen/build.gradle.kts +++ b/sdk-api-gen/build.gradle.kts @@ -9,12 +9,10 @@ description = "Restate SDK API Gen" dependencies { compileOnly(coreLibs.jspecify) - implementation(project(":sdk-common")) + implementation(project(":sdk-api-gen-common")) + implementation(project(":sdk-api")) implementation(project(":sdk-workflow-api")) - implementation(project(":sdk-serde-jackson")) - - implementation("com.github.jknack:handlebars:4.3.1") testAnnotationProcessor(project(":sdk-api-gen")) testImplementation(project(":sdk-core")) @@ -22,6 +20,9 @@ dependencies { testImplementation(testingLibs.assertj) testImplementation(coreLibs.protobuf.java) testImplementation(coreLibs.log4j.core) + testImplementation(platform(jacksonLibs.jackson.bom)) + testImplementation(jacksonLibs.jackson.databind) + testImplementation(project(":sdk-serde-jackson")) // Import test suites from sdk-core testImplementation(project(":sdk-core", "testArchive")) diff --git a/sdk-api-gen/src/main/java/dev/restate/sdk/gen/ComponentProcessor.java b/sdk-api-gen/src/main/java/dev/restate/sdk/gen/ComponentProcessor.java index 4002fac8..254215dd 100644 --- a/sdk-api-gen/src/main/java/dev/restate/sdk/gen/ComponentProcessor.java +++ b/sdk-api-gen/src/main/java/dev/restate/sdk/gen/ComponentProcessor.java @@ -10,7 +10,9 @@ import dev.restate.sdk.common.ComponentAdapter; import dev.restate.sdk.common.ComponentType; -import dev.restate.sdk.gen.model.Service; +import dev.restate.sdk.common.function.ThrowingFunction; +import dev.restate.sdk.gen.model.Component; +import dev.restate.sdk.gen.template.HandlebarsTemplateEngine; import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -20,6 +22,7 @@ import java.util.stream.Collectors; import javax.annotation.processing.*; import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; import javax.lang.model.element.TypeElement; import javax.tools.FileObject; import javax.tools.StandardLocation; @@ -32,59 +35,65 @@ @SupportedSourceVersion(SourceVersion.RELEASE_11) public class ComponentProcessor extends AbstractProcessor { - private HandlebarsCodegen serviceAdapterCodegen; - private HandlebarsCodegen clientCodegen; + private HandlebarsTemplateEngine serviceAdapterCodegen; + private HandlebarsTemplateEngine clientCodegen; @Override public synchronized void init(ProcessingEnvironment processingEnv) { super.init(processingEnv); + FilerTemplateLoader filerTemplateLoader = new FilerTemplateLoader(processingEnv.getFiler()); + this.serviceAdapterCodegen = - new HandlebarsCodegen( - processingEnv.getFiler(), + new HandlebarsTemplateEngine( "ComponentAdapter", + filerTemplateLoader, Map.of( ComponentType.WORKFLOW, - "templates.workflow", + "templates/workflow/ComponentAdapter.hbs", ComponentType.SERVICE, - "templates", + "templates/ComponentAdapter.hbs", ComponentType.VIRTUAL_OBJECT, - "templates")); + "templates/ComponentAdapter.hbs")); this.clientCodegen = - new HandlebarsCodegen( - processingEnv.getFiler(), + new HandlebarsTemplateEngine( "Client", + filerTemplateLoader, Map.of( ComponentType.WORKFLOW, - "templates.workflow", + "templates/workflow/Client.hbs", ComponentType.SERVICE, - "templates", + "templates/Client.hbs", ComponentType.VIRTUAL_OBJECT, - "templates")); + "templates/Client.hbs")); } @Override public boolean process(Set annotations, RoundEnvironment roundEnv) { + ElementConverter converter = + new ElementConverter( + processingEnv.getMessager(), + processingEnv.getElementUtils(), + processingEnv.getTypeUtils()); + // Parsing phase - List parsedServices = + List> parsedServices = annotations.stream() .flatMap(annotation -> roundEnv.getElementsAnnotatedWith(annotation).stream()) .filter(e -> e.getKind().isClass() || e.getKind().isInterface()) - .map( - e -> - Service.fromTypeElement( - (TypeElement) e, - processingEnv.getMessager(), - processingEnv.getElementUtils(), - processingEnv.getTypeUtils())) + .map(e -> Map.entry((Element) e, converter.fromTypeElement((TypeElement) e))) .collect(Collectors.toList()); + Filer filer = processingEnv.getFiler(); + // Run code generation - for (Service e : parsedServices) { + for (Map.Entry e : parsedServices) { try { - this.serviceAdapterCodegen.generate(e); - this.clientCodegen.generate(e); - } catch (IOException ex) { + ThrowingFunction fileCreator = + name -> filer.createSourceFile(name, e.getKey()).openWriter(); + this.serviceAdapterCodegen.generate(fileCreator, e.getValue()); + this.clientCodegen.generate(fileCreator, e.getValue()); + } catch (Throwable ex) { throw new RuntimeException(ex); } } @@ -108,8 +117,8 @@ public boolean process(Set annotations, RoundEnvironment StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) { - for (Service svc : parsedServices) { - writer.write(svc.getGeneratedClassFqcnPrefix() + "ComponentAdapter"); + for (Map.Entry e : parsedServices) { + writer.write(e.getValue().getGeneratedClassFqcnPrefix() + "ComponentAdapter"); writer.write('\n'); } } catch (IOException e) { diff --git a/sdk-api-gen/src/main/java/dev/restate/sdk/gen/ElementConverter.java b/sdk-api-gen/src/main/java/dev/restate/sdk/gen/ElementConverter.java new file mode 100644 index 00000000..2dbfab9d --- /dev/null +++ b/sdk-api-gen/src/main/java/dev/restate/sdk/gen/ElementConverter.java @@ -0,0 +1,342 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.gen; + +import dev.restate.sdk.Context; +import dev.restate.sdk.ObjectContext; +import dev.restate.sdk.annotation.Exclusive; +import dev.restate.sdk.annotation.Shared; +import dev.restate.sdk.annotation.Workflow; +import dev.restate.sdk.common.ComponentType; +import dev.restate.sdk.gen.model.*; +import dev.restate.sdk.workflow.WorkflowContext; +import dev.restate.sdk.workflow.WorkflowSharedContext; +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import javax.annotation.processing.Messager; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; +import javax.tools.Diagnostic; + +public class ElementConverter { + + private static final PayloadType EMPTY_PAYLOAD = + new PayloadType(true, "", "Void", "dev.restate.sdk.common.CoreSerdes.VOID"); + + private final Messager messager; + private final Elements elements; + private final Types types; + + public ElementConverter(Messager messager, Elements elements, Types types) { + this.messager = messager; + this.elements = elements; + this.types = types; + } + + public Component fromTypeElement(TypeElement element) { + validateType(element); + + dev.restate.sdk.annotation.Service serviceAnnotation = + element.getAnnotation(dev.restate.sdk.annotation.Service.class); + dev.restate.sdk.annotation.VirtualObject virtualObjectAnnotation = + element.getAnnotation(dev.restate.sdk.annotation.VirtualObject.class); + dev.restate.sdk.annotation.Workflow workflowAnnotation = + element.getAnnotation(dev.restate.sdk.annotation.Workflow.class); + boolean isAnnotatedWithService = serviceAnnotation != null; + boolean isAnnotatedWithVirtualObject = virtualObjectAnnotation != null; + boolean isAnnotatedWithWorkflow = workflowAnnotation != null; + + // Should be guaranteed by the caller + assert isAnnotatedWithWorkflow || isAnnotatedWithVirtualObject || isAnnotatedWithService; + + // Check there's no more than one annotation + if (!Boolean.logicalXor( + isAnnotatedWithService, + Boolean.logicalXor(isAnnotatedWithWorkflow, isAnnotatedWithVirtualObject))) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "The type can be annotated only with one annotation between @VirtualObject, @Workflow and @Service", + element); + } + + ComponentType type = + isAnnotatedWithWorkflow + ? ComponentType.WORKFLOW + : isAnnotatedWithService ? ComponentType.SERVICE : ComponentType.VIRTUAL_OBJECT; + + // Infer names + + CharSequence targetPkg = elements.getPackageOf(element).getQualifiedName(); + CharSequence targetFqcn = element.getQualifiedName(); + + String componentName = + isAnnotatedWithService + ? serviceAnnotation.name() + : isAnnotatedWithVirtualObject + ? virtualObjectAnnotation.name() + : workflowAnnotation.name(); + if (componentName.isEmpty()) { + // Use FQCN + // With this logic we make sure we flatten subclasses names + String simpleComponentName = + targetFqcn.toString().substring(targetPkg.length()).replaceAll(Pattern.quote("."), ""); + componentName = + targetPkg.length() > 0 ? targetPkg + "." + simpleComponentName : simpleComponentName; + } + + // Compute handlers + List handlers = + elements.getAllMembers(element).stream() + .filter(e -> e instanceof ExecutableElement) + .filter( + e -> + e.getAnnotation(dev.restate.sdk.annotation.Handler.class) != null + || e.getAnnotation(Workflow.class) != null + || e.getAnnotation(Exclusive.class) != null + || e.getAnnotation(Shared.class) != null) + .map(e -> fromExecutableElement(type, ((ExecutableElement) e))) + .collect(Collectors.toList()); + validateHandlers(type, handlers, element); + + if (handlers.isEmpty()) { + messager.printMessage( + Diagnostic.Kind.WARNING, "The component " + componentName + " has no handlers", element); + } + + return new Component.Builder() + .withTargetPkg(targetPkg) + .withTargetFqcn(targetFqcn) + .withComponentName(componentName) + .withComponentType(type) + .withHandlers(handlers) + .build(); + } + + private void validateType(TypeElement element) { + if (!element.getTypeParameters().isEmpty()) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "The ComponentProcessor doesn't support components with generics", + element); + } + if (element.getKind().equals(ElementKind.ENUM)) { + messager.printMessage( + Diagnostic.Kind.ERROR, "The EntityProcessor doesn't support enums", element); + } + + if (element.getModifiers().contains(Modifier.PRIVATE)) { + messager.printMessage(Diagnostic.Kind.ERROR, "The annotated class is private", element); + } + } + + private void validateHandlers( + ComponentType componentType, List handlers, TypeElement element) { + // Additional validation for Workflow types + if (componentType.equals(ComponentType.WORKFLOW)) { + if (handlers.stream().filter(m -> m.getHandlerType().equals(HandlerType.WORKFLOW)).count() + != 1) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "Workflow services must have exactly one method annotated as @Workflow", + element); + } + } + } + + private Handler fromExecutableElement(ComponentType componentType, ExecutableElement element) { + if (!element.getTypeParameters().isEmpty()) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "The EntityProcessor doesn't support methods with generics", + element); + } + if (element.getKind().equals(ElementKind.CONSTRUCTOR)) { + messager.printMessage( + Diagnostic.Kind.ERROR, "You cannot annotate a constructor as Restate method"); + } + if (element.getKind().equals(ElementKind.STATIC_INIT)) { + messager.printMessage( + Diagnostic.Kind.ERROR, "You cannot annotate a static init as Restate method"); + } + + boolean isAnnotatedWithShared = element.getAnnotation(Shared.class) != null; + boolean isAnnotatedWithExclusive = element.getAnnotation(Exclusive.class) != null; + boolean isAnnotatedWithWorkflow = element.getAnnotation(Workflow.class) != null; + + // Check there's no more than one annotation + boolean hasAnyAnnotation = + isAnnotatedWithExclusive || isAnnotatedWithShared || isAnnotatedWithWorkflow; + boolean hasExactlyOneAnnotation = + Boolean.logicalXor( + isAnnotatedWithShared, + Boolean.logicalXor(isAnnotatedWithWorkflow, isAnnotatedWithExclusive)); + if (!(!hasAnyAnnotation || hasExactlyOneAnnotation)) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "You can have only one annotation between @Shared, @Exclusive and @Workflow to a method", + element); + } + + HandlerType handlerType = + isAnnotatedWithWorkflow + ? HandlerType.WORKFLOW + : isAnnotatedWithShared + ? HandlerType.SHARED + : isAnnotatedWithExclusive + ? HandlerType.EXCLUSIVE + : defaultHandlerType(componentType, element); + + validateMethodSignature(componentType, handlerType, element); + + return new Handler.Builder() + .withName(element.getSimpleName()) + .withHandlerType(handlerType) + .withInputType( + element.getParameters().size() > 1 + ? payloadFromType(element.getParameters().get(1).asType()) + : EMPTY_PAYLOAD) + .withOutputType( + !element.getReturnType().getKind().equals(TypeKind.VOID) + ? payloadFromType(element.getReturnType()) + : EMPTY_PAYLOAD) + .build(); + } + + private HandlerType defaultHandlerType(ComponentType componentType, ExecutableElement element) { + switch (componentType) { + case SERVICE: + return HandlerType.STATELESS; + case VIRTUAL_OBJECT: + return HandlerType.EXCLUSIVE; + case WORKFLOW: + messager.printMessage( + Diagnostic.Kind.ERROR, + "Workflow methods MUST be annotated with either @Shared or @Workflow", + element); + } + throw new IllegalStateException("Unexpected"); + } + + private void validateMethodSignature( + ComponentType componentType, HandlerType handlerType, ExecutableElement element) { + switch (handlerType) { + case SHARED: + if (componentType == ComponentType.WORKFLOW) { + validateFirstParameterType(WorkflowSharedContext.class, element); + } else { + messager.printMessage( + Diagnostic.Kind.ERROR, + "The annotation @Shared is not supported by the service type " + componentType, + element); + } + break; + case EXCLUSIVE: + if (componentType == ComponentType.VIRTUAL_OBJECT) { + validateFirstParameterType(ObjectContext.class, element); + } else { + messager.printMessage( + Diagnostic.Kind.ERROR, + "The annotation @Exclusive is not supported by the service type " + componentType, + element); + } + break; + case STATELESS: + validateFirstParameterType(Context.class, element); + break; + case WORKFLOW: + if (componentType == ComponentType.WORKFLOW) { + validateFirstParameterType(WorkflowContext.class, element); + } else { + messager.printMessage( + Diagnostic.Kind.ERROR, + "The annotation @Shared is not supported by the service type " + componentType, + element); + } + break; + } + } + + private void validateFirstParameterType(Class clazz, ExecutableElement element) { + if (!types.isSameType( + element.getParameters().get(0).asType(), + elements.getTypeElement(clazz.getCanonicalName()).asType())) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "The method signature must have " + clazz.getCanonicalName() + " as first parameter", + element); + } + } + + private PayloadType payloadFromType(TypeMirror typeMirror) { + Objects.requireNonNull(typeMirror); + return new PayloadType( + false, typeMirror.toString(), boxedType(typeMirror), serdeDecl(typeMirror)); + } + + private static String serdeDecl(TypeMirror ty) { + switch (ty.getKind()) { + case BOOLEAN: + return "dev.restate.sdk.common.CoreSerdes.JSON_BOOLEAN"; + case BYTE: + return "dev.restate.sdk.common.CoreSerdes.JSON_BYTE"; + case SHORT: + return "dev.restate.sdk.common.CoreSerdes.JSON_SHORT"; + case INT: + return "dev.restate.sdk.common.CoreSerdes.JSON_INT"; + case LONG: + return "dev.restate.sdk.common.CoreSerdes.JSON_LONG"; + case CHAR: + return "dev.restate.sdk.common.CoreSerdes.JSON_CHAR"; + case FLOAT: + return "dev.restate.sdk.common.CoreSerdes.JSON_FLOAT"; + case DOUBLE: + return "dev.restate.sdk.common.CoreSerdes.JSON_DOUBLE"; + case VOID: + return "dev.restate.sdk.common.CoreSerdes.VOID"; + default: + // Default to Jackson type reference serde + return "dev.restate.sdk.serde.jackson.JacksonSerdes.of(new com.fasterxml.jackson.core.type.TypeReference<" + + ty + + ">() {})"; + } + } + + private static String boxedType(TypeMirror ty) { + switch (ty.getKind()) { + case BOOLEAN: + return "Boolean"; + case BYTE: + return "Byte"; + case SHORT: + return "Short"; + case INT: + return "Integer"; + case LONG: + return "Long"; + case CHAR: + return "Char"; + case FLOAT: + return "Float"; + case DOUBLE: + return "Double"; + case VOID: + return "Void"; + default: + return ty.toString(); + } + } +} diff --git a/sdk-api-gen/src/main/java/dev/restate/sdk/gen/FilerTemplateLoader.java b/sdk-api-gen/src/main/java/dev/restate/sdk/gen/FilerTemplateLoader.java new file mode 100644 index 00000000..ef6707d1 --- /dev/null +++ b/sdk-api-gen/src/main/java/dev/restate/sdk/gen/FilerTemplateLoader.java @@ -0,0 +1,57 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.gen; + +import com.github.jknack.handlebars.io.AbstractTemplateLoader; +import com.github.jknack.handlebars.io.TemplateSource; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Path; +import java.nio.file.Paths; +import javax.annotation.processing.Filer; +import javax.tools.StandardLocation; + +/** + * We need this because the built-in ClassLoaderTemplateLoader is not reliable in the annotation + * processor context + */ +class FilerTemplateLoader extends AbstractTemplateLoader { + private final Filer filer; + + public FilerTemplateLoader(Filer filer) { + this.filer = filer; + } + + @Override + public TemplateSource sourceAt(String location) { + Path path = Paths.get(location); + return new TemplateSource() { + @Override + public String content(Charset charset) throws IOException { + return filer + .getResource( + StandardLocation.ANNOTATION_PROCESSOR_PATH, + path.getParent().toString().replace('/', '.'), + path.getFileName().toString()) + .getCharContent(true) + .toString(); + } + + @Override + public String filename() { + return "/" + location; + } + + @Override + public long lastModified() { + return 0; + } + }; + } +} diff --git a/sdk-api-gen/src/main/java/dev/restate/sdk/gen/HandlebarsCodegen.java b/sdk-api-gen/src/main/java/dev/restate/sdk/gen/HandlebarsCodegen.java deleted file mode 100644 index b9713898..00000000 --- a/sdk-api-gen/src/main/java/dev/restate/sdk/gen/HandlebarsCodegen.java +++ /dev/null @@ -1,247 +0,0 @@ -// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH -// -// This file is part of the Restate Java SDK, -// which is released under the MIT license. -// -// You can find a copy of the license in file LICENSE in the root -// directory of this repository or package, or at -// https://github.com/restatedev/sdk-java/blob/main/LICENSE -package dev.restate.sdk.gen; - -import com.github.jknack.handlebars.Context; -import com.github.jknack.handlebars.Handlebars; -import com.github.jknack.handlebars.Template; -import com.github.jknack.handlebars.context.FieldValueResolver; -import com.github.jknack.handlebars.helper.StringHelpers; -import com.github.jknack.handlebars.io.AbstractTemplateLoader; -import com.github.jknack.handlebars.io.TemplateSource; -import dev.restate.sdk.common.ComponentType; -import dev.restate.sdk.gen.model.Method; -import dev.restate.sdk.gen.model.MethodType; -import dev.restate.sdk.gen.model.Service; -import java.io.IOException; -import java.io.Writer; -import java.nio.charset.Charset; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import javax.annotation.processing.Filer; -import javax.lang.model.type.TypeMirror; -import javax.tools.JavaFileObject; -import javax.tools.StandardLocation; -import org.jspecify.annotations.Nullable; - -public class HandlebarsCodegen { - - private final Filer filer; - private final String baseTemplateName; - private final Map templates; - - public HandlebarsCodegen( - Filer filer, String baseTemplateName, Map templates) { - this.filer = filer; - this.baseTemplateName = baseTemplateName; - - Handlebars handlebars = new Handlebars(new FilerTemplateLoader(filer, this.baseTemplateName)); - handlebars.registerHelpers(StringHelpers.class); - - this.templates = - templates.entrySet().stream() - .collect( - Collectors.toMap( - Map.Entry::getKey, - e -> { - try { - return handlebars.compile(e.getValue()); - } catch (IOException ex) { - throw new RuntimeException( - "Can't compile template for service " - + e.getKey() - + " with base template name " - + baseTemplateName, - ex); - } - })); - } - - public void generate(Service service) throws IOException { - JavaFileObject entityAdapterFile = - filer.createSourceFile(service.getGeneratedClassFqcnPrefix() + this.baseTemplateName); - try (Writer out = entityAdapterFile.openWriter()) { - this.templates - .get(service.getComponentType()) - .apply( - Context.newBuilder(new EntityTemplateModel(service, this.baseTemplateName)) - .resolver(FieldValueResolver.INSTANCE) - .build(), - out); - } - } - - // --- classes to interact with the handlebars template - - static class EntityTemplateModel { - public final String originalClassPkg; - public final String originalClassFqcn; - public final String generatedClassSimpleNamePrefix; - public final String generatedClassSimpleName; - public final String componentName; - public final String componentType; - public final boolean isWorkflow; - public final boolean isObject; - public final boolean isService; - public final List methods; - - private EntityTemplateModel(Service inner, String baseTemplateName) { - this.originalClassPkg = inner.getTargetPkg().toString(); - this.originalClassFqcn = inner.getTargetFqcn().toString(); - this.generatedClassSimpleNamePrefix = inner.getSimpleComponentName(); - this.generatedClassSimpleName = this.generatedClassSimpleNamePrefix + baseTemplateName; - this.componentName = inner.getFullyQualifiedComponentName(); - - this.componentType = inner.getComponentType().toString(); - this.isWorkflow = inner.getComponentType() == ComponentType.WORKFLOW; - this.isObject = inner.getComponentType() == ComponentType.VIRTUAL_OBJECT; - this.isService = inner.getComponentType() == ComponentType.SERVICE; - - this.methods = - inner.getMethods().stream().map(MethodTemplateModel::new).collect(Collectors.toList()); - } - } - - static class MethodTemplateModel { - public final String name; - public final String methodType; - public final boolean isWorkflow; - public final boolean isShared; - public final boolean isStateless; - public final boolean isExclusive; - - public final boolean inputEmpty; - public final String inputFqcn; - public final String inputSerdeDecl; - public final String boxedInputFqcn; - public final String inputSerdeFieldName; - - public final boolean outputEmpty; - public final String outputFqcn; - public final String outputSerdeDecl; - public final String boxedOutputFqcn; - public final String outputSerdeFieldName; - - private MethodTemplateModel(Method inner) { - this.name = inner.getName().toString(); - this.methodType = inner.getMethodType().toString(); - this.isWorkflow = inner.getMethodType() == MethodType.WORKFLOW; - this.isShared = inner.getMethodType() == MethodType.SHARED; - this.isExclusive = inner.getMethodType() == MethodType.EXCLUSIVE; - this.isStateless = inner.getMethodType() == MethodType.STATELESS; - - this.inputEmpty = inner.getInputType() == null; - this.inputFqcn = this.inputEmpty ? "" : inner.getInputType().toString(); - this.inputSerdeDecl = serdeDecl(inner.getInputType()); - this.boxedInputFqcn = boxedType(inner.getInputType()); - this.inputSerdeFieldName = "SERDE_" + this.name.toUpperCase() + "_INPUT"; - - this.outputEmpty = inner.getOutputType() == null; - this.outputFqcn = this.outputEmpty ? "" : inner.getOutputType().toString(); - this.outputSerdeDecl = serdeDecl(inner.getOutputType()); - this.boxedOutputFqcn = boxedType(inner.getOutputType()); - this.outputSerdeFieldName = "SERDE_" + this.name.toUpperCase() + "_OUTPUT"; - } - - private static String serdeDecl(@Nullable TypeMirror ty) { - if (ty == null) { - return "dev.restate.sdk.common.CoreSerdes.VOID"; - } - switch (ty.getKind()) { - case BOOLEAN: - return "dev.restate.sdk.common.CoreSerdes.JSON_BOOLEAN"; - case BYTE: - return "dev.restate.sdk.common.CoreSerdes.JSON_BYTE"; - case SHORT: - return "dev.restate.sdk.common.CoreSerdes.JSON_SHORT"; - case INT: - return "dev.restate.sdk.common.CoreSerdes.JSON_INT"; - case LONG: - return "dev.restate.sdk.common.CoreSerdes.JSON_LONG"; - case CHAR: - return "dev.restate.sdk.common.CoreSerdes.JSON_CHAR"; - case FLOAT: - return "dev.restate.sdk.common.CoreSerdes.JSON_FLOAT"; - case DOUBLE: - return "dev.restate.sdk.common.CoreSerdes.JSON_DOUBLE"; - case VOID: - return "dev.restate.sdk.common.CoreSerdes.VOID"; - default: - // Default to Jackson type reference serde - return "dev.restate.sdk.serde.jackson.JacksonSerdes.of(new com.fasterxml.jackson.core.type.TypeReference<" - + ty - + ">() {})"; - } - } - - private static String boxedType(@Nullable TypeMirror ty) { - if (ty == null) { - return "Void"; - } - switch (ty.getKind()) { - case BOOLEAN: - return "Boolean"; - case BYTE: - return "Byte"; - case SHORT: - return "Short"; - case INT: - return "Integer"; - case LONG: - return "Long"; - case CHAR: - return "Char"; - case FLOAT: - return "Float"; - case DOUBLE: - return "Double"; - case VOID: - return "Void"; - default: - return ty.toString(); - } - } - } - - // We need this because the built-in ClassLoaderTemplateLoader is not reliable in the annotation - // processor context - private static class FilerTemplateLoader extends AbstractTemplateLoader { - private final Filer filer; - private final String templateName; - - public FilerTemplateLoader(Filer filer, String baseTemplateName) { - this.filer = filer; - this.templateName = baseTemplateName + ".hbs"; - } - - @Override - public TemplateSource sourceAt(String location) { - return new TemplateSource() { - @Override - public String content(Charset charset) throws IOException { - return filer - .getResource(StandardLocation.ANNOTATION_PROCESSOR_PATH, location, templateName) - .getCharContent(true) - .toString(); - } - - @Override - public String filename() { - return "/" + location.replace('.', '/') + "/" + templateName; - } - - @Override - public long lastModified() { - return 0; - } - }; - } - } -} diff --git a/sdk-api-gen/src/main/java/dev/restate/sdk/gen/model/Method.java b/sdk-api-gen/src/main/java/dev/restate/sdk/gen/model/Method.java deleted file mode 100644 index 4d54f4ff..00000000 --- a/sdk-api-gen/src/main/java/dev/restate/sdk/gen/model/Method.java +++ /dev/null @@ -1,197 +0,0 @@ -// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH -// -// This file is part of the Restate Java SDK, -// which is released under the MIT license. -// -// You can find a copy of the license in file LICENSE in the root -// directory of this repository or package, or at -// https://github.com/restatedev/sdk-java/blob/main/LICENSE -package dev.restate.sdk.gen.model; - -import dev.restate.sdk.Context; -import dev.restate.sdk.ObjectContext; -import dev.restate.sdk.annotation.*; -import dev.restate.sdk.common.ComponentType; -import dev.restate.sdk.workflow.WorkflowContext; -import dev.restate.sdk.workflow.WorkflowSharedContext; -import javax.annotation.processing.Messager; -import javax.lang.model.element.ElementKind; -import javax.lang.model.element.ExecutableElement; -import javax.lang.model.type.TypeKind; -import javax.lang.model.type.TypeMirror; -import javax.lang.model.util.Elements; -import javax.lang.model.util.Types; -import javax.tools.Diagnostic; -import org.jspecify.annotations.Nullable; - -public class Method { - - private final CharSequence name; - private final MethodType methodType; - private final @Nullable TypeMirror inputType; - private final @Nullable TypeMirror outputType; - - public Method( - CharSequence name, - MethodType methodType, - @Nullable TypeMirror inputType, - @Nullable TypeMirror outputType) { - this.name = name; - this.methodType = methodType; - this.inputType = inputType; - this.outputType = outputType; - } - - public CharSequence getName() { - return name; - } - - public MethodType getMethodType() { - return methodType; - } - - @Nullable - public TypeMirror getInputType() { - return inputType; - } - - @Nullable - public TypeMirror getOutputType() { - return outputType; - } - - public static Method fromExecutableElement( - ComponentType componentType, - ExecutableElement element, - Messager messager, - Elements elements, - Types types) { - if (!element.getTypeParameters().isEmpty()) { - messager.printMessage( - Diagnostic.Kind.ERROR, - "The EntityProcessor doesn't support methods with generics", - element); - } - if (element.getKind().equals(ElementKind.CONSTRUCTOR)) { - messager.printMessage( - Diagnostic.Kind.ERROR, "You cannot annotate a constructor as Restate method"); - } - if (element.getKind().equals(ElementKind.STATIC_INIT)) { - messager.printMessage( - Diagnostic.Kind.ERROR, "You cannot annotate a static init as Restate method"); - } - - boolean isAnnotatedWithShared = element.getAnnotation(Shared.class) != null; - boolean isAnnotatedWithExclusive = element.getAnnotation(Exclusive.class) != null; - boolean isAnnotatedWithWorkflow = element.getAnnotation(Workflow.class) != null; - - // Check there's no more than one annotation - boolean hasAnyAnnotation = - isAnnotatedWithExclusive || isAnnotatedWithShared || isAnnotatedWithWorkflow; - boolean hasExactlyOneAnnotation = - Boolean.logicalXor( - isAnnotatedWithShared, - Boolean.logicalXor(isAnnotatedWithWorkflow, isAnnotatedWithExclusive)); - if (!(!hasAnyAnnotation || hasExactlyOneAnnotation)) { - messager.printMessage( - Diagnostic.Kind.ERROR, - "You can have only one annotation between @Shared, @Exclusive and @Workflow to a method", - element); - } - - MethodType methodType = - isAnnotatedWithWorkflow - ? MethodType.WORKFLOW - : isAnnotatedWithShared - ? MethodType.SHARED - : isAnnotatedWithExclusive - ? MethodType.EXCLUSIVE - : defaultMethodType(componentType, element, messager); - - validateMethodSignature(componentType, methodType, element, messager, elements, types); - - return new Method( - element.getSimpleName(), - methodType, - element.getParameters().size() > 1 ? element.getParameters().get(1).asType() : null, - !element.getReturnType().getKind().equals(TypeKind.VOID) ? element.getReturnType() : null); - } - - private static MethodType defaultMethodType( - ComponentType componentType, ExecutableElement element, Messager messager) { - switch (componentType) { - case SERVICE: - return MethodType.STATELESS; - case VIRTUAL_OBJECT: - return MethodType.EXCLUSIVE; - case WORKFLOW: - messager.printMessage( - Diagnostic.Kind.ERROR, - "Workflow methods MUST be annotated with either @Shared or @Workflow", - element); - } - throw new IllegalStateException( - "Workflow methods MUST be annotated with either @Shared or @Workflow"); - } - - private static void validateMethodSignature( - ComponentType componentType, - MethodType methodType, - ExecutableElement element, - Messager messager, - Elements elements, - Types types) { - switch (methodType) { - case SHARED: - if (componentType == ComponentType.WORKFLOW) { - validateFirstParameterType( - WorkflowSharedContext.class, element, messager, elements, types); - } else { - messager.printMessage( - Diagnostic.Kind.ERROR, - "The annotation @Shared is not supported by the service type " + componentType, - element); - } - break; - case EXCLUSIVE: - if (componentType == ComponentType.VIRTUAL_OBJECT) { - validateFirstParameterType(ObjectContext.class, element, messager, elements, types); - } else { - messager.printMessage( - Diagnostic.Kind.ERROR, - "The annotation @Exclusive is not supported by the service type " + componentType, - element); - } - break; - case STATELESS: - validateFirstParameterType(Context.class, element, messager, elements, types); - break; - case WORKFLOW: - if (componentType == ComponentType.WORKFLOW) { - validateFirstParameterType(WorkflowContext.class, element, messager, elements, types); - } else { - messager.printMessage( - Diagnostic.Kind.ERROR, - "The annotation @Shared is not supported by the service type " + componentType, - element); - } - break; - } - } - - private static void validateFirstParameterType( - Class clazz, - ExecutableElement element, - Messager messager, - Elements elements, - Types types) { - if (!types.isSameType( - element.getParameters().get(0).asType(), - elements.getTypeElement(clazz.getCanonicalName()).asType())) { - messager.printMessage( - Diagnostic.Kind.ERROR, - "The method signature must have " + clazz.getCanonicalName() + " as first parameter", - element); - } - } -} diff --git a/sdk-api-gen/src/main/java/dev/restate/sdk/gen/model/Service.java b/sdk-api-gen/src/main/java/dev/restate/sdk/gen/model/Service.java deleted file mode 100644 index 76b17c08..00000000 --- a/sdk-api-gen/src/main/java/dev/restate/sdk/gen/model/Service.java +++ /dev/null @@ -1,180 +0,0 @@ -// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH -// -// This file is part of the Restate Java SDK, -// which is released under the MIT license. -// -// You can find a copy of the license in file LICENSE in the root -// directory of this repository or package, or at -// https://github.com/restatedev/sdk-java/blob/main/LICENSE -package dev.restate.sdk.gen.model; - -import dev.restate.sdk.annotation.*; -import dev.restate.sdk.common.ComponentType; -import java.util.List; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import javax.annotation.processing.Messager; -import javax.lang.model.element.ElementKind; -import javax.lang.model.element.ExecutableElement; -import javax.lang.model.element.Modifier; -import javax.lang.model.element.TypeElement; -import javax.lang.model.util.Elements; -import javax.lang.model.util.Types; -import javax.tools.Diagnostic; - -public class Service { - - private final CharSequence targetPkg; - private final CharSequence targetFqcn; - private final String componentName; - private final ComponentType componentType; - private final List methods; - - Service( - CharSequence targetPkg, - CharSequence targetFqcn, - String componentName, - ComponentType componentType, - List methods) { - this.targetPkg = targetPkg; - this.targetFqcn = targetFqcn; - this.componentName = componentName; - - this.componentType = componentType; - this.methods = methods; - } - - public CharSequence getTargetPkg() { - return this.targetPkg; - } - - public CharSequence getTargetFqcn() { - return this.targetFqcn; - } - - public String getFullyQualifiedComponentName() { - return this.componentName; - } - - public String getSimpleComponentName() { - return this.componentName.substring(this.componentName.lastIndexOf('.') + 1); - } - - public CharSequence getGeneratedClassFqcnPrefix() { - if (this.targetPkg == null || this.targetPkg.length() == 0) { - return getSimpleComponentName(); - } - return this.targetPkg + "." + getSimpleComponentName(); - } - - public ComponentType getComponentType() { - return componentType; - } - - public List getMethods() { - return methods; - } - - public static Service fromTypeElement( - TypeElement element, Messager messager, Elements elements, Types types) { - validateType(element, messager); - - dev.restate.sdk.annotation.Service serviceAnnotation = - element.getAnnotation(dev.restate.sdk.annotation.Service.class); - dev.restate.sdk.annotation.VirtualObject virtualObjectAnnotation = - element.getAnnotation(dev.restate.sdk.annotation.VirtualObject.class); - dev.restate.sdk.annotation.Workflow workflowAnnotation = - element.getAnnotation(dev.restate.sdk.annotation.Workflow.class); - boolean isAnnotatedWithService = serviceAnnotation != null; - boolean isAnnotatedWithVirtualObject = virtualObjectAnnotation != null; - boolean isAnnotatedWithWorkflow = workflowAnnotation != null; - - // Should be guaranteed by the caller - assert isAnnotatedWithWorkflow || isAnnotatedWithVirtualObject || isAnnotatedWithService; - - // Check there's no more than one annotation - if (!Boolean.logicalXor( - isAnnotatedWithService, - Boolean.logicalXor(isAnnotatedWithWorkflow, isAnnotatedWithVirtualObject))) { - messager.printMessage( - Diagnostic.Kind.ERROR, - "The type can be annotated only with one annotation between @VirtualObject, @Workflow and @Service", - element); - } - - ComponentType type = - isAnnotatedWithWorkflow - ? ComponentType.WORKFLOW - : isAnnotatedWithService ? ComponentType.SERVICE : ComponentType.VIRTUAL_OBJECT; - - // Infer names - - CharSequence targetPkg = elements.getPackageOf(element).getQualifiedName(); - CharSequence targetFqcn = element.getQualifiedName(); - - String componentName = - isAnnotatedWithService - ? serviceAnnotation.name() - : isAnnotatedWithVirtualObject - ? virtualObjectAnnotation.name() - : workflowAnnotation.name(); - if (componentName.isEmpty()) { - // Use FQCN - // With this logic we make sure we flatten subclasses names - String simpleComponentName = - targetFqcn.toString().substring(targetPkg.length()).replaceAll(Pattern.quote("."), ""); - componentName = - targetPkg.length() > 0 ? targetPkg + "." + simpleComponentName : simpleComponentName; - } - - // Compute methods - List methods = - elements.getAllMembers(element).stream() - .filter(e -> e instanceof ExecutableElement) - .filter( - e -> - e.getAnnotation(Handler.class) != null - || e.getAnnotation(Workflow.class) != null - || e.getAnnotation(Exclusive.class) != null - || e.getAnnotation(Shared.class) != null) - .map( - e -> - Method.fromExecutableElement( - type, ((ExecutableElement) e), messager, elements, types)) - .collect(Collectors.toList()); - validateMethods(type, methods, element, messager); - - return new Service(targetPkg, targetFqcn, componentName, type, methods); - } - - private static void validateType(TypeElement element, Messager messager) { - if (!element.getTypeParameters().isEmpty()) { - messager.printMessage( - Diagnostic.Kind.ERROR, - "The EntityProcessor doesn't support services with generics", - element); - } - if (element.getKind().equals(ElementKind.ENUM)) { - messager.printMessage( - Diagnostic.Kind.ERROR, "The EntityProcessor doesn't support enums", element); - } - - if (element.getModifiers().contains(Modifier.PRIVATE)) { - messager.printMessage(Diagnostic.Kind.ERROR, "The annotated class is private", element); - } - } - - private static void validateMethods( - ComponentType componentType, List methods, TypeElement element, Messager messager) { - // Additional validation for Workflow types - if (componentType.equals(ComponentType.WORKFLOW)) { - if (methods.stream().filter(m -> m.getMethodType().equals(MethodType.WORKFLOW)).count() - != 1) { - messager.printMessage( - Diagnostic.Kind.ERROR, - "Workflow services must have exactly one method annotated as @Workflow", - element); - } - } - } -} diff --git a/sdk-api-gen/src/main/resources/templates/Client.hbs b/sdk-api-gen/src/main/resources/templates/Client.hbs index 3b50d24e..d90dff2f 100644 --- a/sdk-api-gen/src/main/resources/templates/Client.hbs +++ b/sdk-api-gen/src/main/resources/templates/Client.hbs @@ -12,10 +12,10 @@ public class {{generatedClassSimpleName}} { public static final String COMPONENT_NAME = "{{componentName}}"; - {{#methods}} + {{#handlers}} private static final Serde<{{{boxedInputFqcn}}}> {{inputSerdeFieldName}} = {{{inputSerdeDecl}}}; private static final Serde<{{{boxedOutputFqcn}}}> {{outputSerdeFieldName}} = {{{outputSerdeDecl}}}; - {{/methods}} + {{/handlers}} public static ContextClient fromContext(Context ctx{{#isObject}}, String key{{/isObject}}) { return new ContextClient(ctx{{#isObject}}, key{{/isObject}}); @@ -39,14 +39,14 @@ public class {{generatedClassSimpleName}} { {{#isObject}}this.key = key;{{/isObject}} } - {{#methods}} + {{#handlers}} public Awaitable<{{{boxedOutputFqcn}}}> {{name}}({{^inputEmpty}}{{{inputFqcn}}} req{{/inputEmpty}}) { return this.ctx.call( {{#if isObject}}Target.virtualObject(COMPONENT_NAME, this.key, "{{name}}"){{else}}Target.service(COMPONENT_NAME, "{{name}}"){{/if}}, {{inputSerdeFieldName}}, {{outputSerdeFieldName}}, {{#if inputEmpty}}null{{else}}req{{/if}}); - }{{/methods}} + }{{/handlers}} public Send send() { return new Send(); @@ -57,13 +57,13 @@ public class {{generatedClassSimpleName}} { } public class Send { - {{#methods}} + {{#handlers}} public void {{name}}({{^inputEmpty}}{{{inputFqcn}}} req{{/inputEmpty}}) { ContextClient.this.ctx.send( {{#if isObject}}Target.virtualObject(COMPONENT_NAME, ContextClient.this.key, "{{name}}"){{else}}Target.service(COMPONENT_NAME, "{{name}}"){{/if}}, {{inputSerdeFieldName}}, {{#if inputEmpty}}null{{else}}req{{/if}}); - }{{/methods}} + }{{/handlers}} } public class SendDelayed { @@ -74,14 +74,14 @@ public class {{generatedClassSimpleName}} { this.delay = delay; } - {{#methods}} + {{#handlers}} public void {{name}}({{^inputEmpty}}{{{inputFqcn}}} req{{/inputEmpty}}) { ContextClient.this.ctx.sendDelayed( {{#if isObject}}Target.virtualObject(COMPONENT_NAME, ContextClient.this.key, "{{name}}"){{else}}Target.service(COMPONENT_NAME, "{{name}}"){{/if}}, {{inputSerdeFieldName}}, {{#if inputEmpty}}null{{else}}req{{/if}}, this.delay); - }{{/methods}} + }{{/handlers}} } } @@ -95,27 +95,27 @@ public class {{generatedClassSimpleName}} { {{#isObject}}this.key = key;{{/isObject}} } - {{#methods}} + {{#handlers}} public {{#if outputEmpty}}void{{else}}{{{outputFqcn}}}{{/if}} {{name}}({{^inputEmpty}}{{{inputFqcn}}} req{{/inputEmpty}}) { {{^outputEmpty}}return {{/outputEmpty}}this.ingressClient.call( {{#if isObject}}Target.virtualObject(COMPONENT_NAME, this.key, "{{name}}"){{else}}Target.service(COMPONENT_NAME, "{{name}}"){{/if}}, {{inputSerdeFieldName}}, {{outputSerdeFieldName}}, {{#if inputEmpty}}null{{else}}req{{/if}}); - }{{/methods}} + }{{/handlers}} public Send send() { return new Send(); } public class Send { - {{#methods}} + {{#handlers}} public String {{name}}({{^inputEmpty}}{{{inputFqcn}}} req{{/inputEmpty}}) { return IngressClient.this.ingressClient.send( {{#if isObject}}Target.virtualObject(COMPONENT_NAME, IngressClient.this.key, "{{name}}"){{else}}Target.service(COMPONENT_NAME, "{{name}}"){{/if}}, {{inputSerdeFieldName}}, {{#if inputEmpty}}null{{else}}req{{/if}}); - }{{/methods}} + }{{/handlers}} } } } \ No newline at end of file diff --git a/sdk-api-gen/src/main/resources/templates/ComponentAdapter.hbs b/sdk-api-gen/src/main/resources/templates/ComponentAdapter.hbs index f66131a5..0919efdc 100644 --- a/sdk-api-gen/src/main/resources/templates/ComponentAdapter.hbs +++ b/sdk-api-gen/src/main/resources/templates/ComponentAdapter.hbs @@ -7,7 +7,7 @@ public class {{generatedClassSimpleName}} implements dev.restate.sdk.common.Comp @java.lang.Override public dev.restate.sdk.common.BindableComponent adapt({{originalClassFqcn}} bindableComponent) { return dev.restate.sdk.Component.{{#if isObject}}virtualObject{{else}}service{{/if}}(COMPONENT_NAME) - {{#methods}} + {{#handlers}} .with( dev.restate.sdk.Component.HandlerSignature.of("{{name}}", {{{inputSerdeDecl}}}, {{{outputSerdeDecl}}}), (ctx, req) -> { @@ -18,7 +18,7 @@ public class {{generatedClassSimpleName}} implements dev.restate.sdk.common.Comp return {{#if inputEmpty}}bindableComponent.{{name}}(ctx){{else}}bindableComponent.{{name}}(ctx, req){{/if}}; {{/if}} }) - {{/methods}} + {{/handlers}} .build(); } diff --git a/sdk-api-gen/src/main/resources/templates/workflow/Client.hbs b/sdk-api-gen/src/main/resources/templates/workflow/Client.hbs index ef1dcec9..cf704374 100644 --- a/sdk-api-gen/src/main/resources/templates/workflow/Client.hbs +++ b/sdk-api-gen/src/main/resources/templates/workflow/Client.hbs @@ -12,10 +12,10 @@ public class {{generatedClassSimpleName}} { public static final String WORKFLOW_NAME = "{{componentName}}"; - {{#methods}} + {{#handlers}} private static final Serde<{{{boxedInputFqcn}}}> {{inputSerdeFieldName}} = {{{inputSerdeDecl}}}; private static final Serde<{{{boxedOutputFqcn}}}> {{outputSerdeFieldName}} = {{{outputSerdeDecl}}}; - {{/methods}} + {{/handlers}} public static ContextClient fromContext(Context ctx, String key) { return new ContextClient(ctx, key); @@ -39,7 +39,7 @@ public class {{generatedClassSimpleName}} { this.workflowKey = workflowKey; } - {{#methods}}{{#if isWorkflow}} + {{#handlers}}{{#if isWorkflow}} public Awaitable submit({{^inputEmpty}}{{{inputFqcn}}} req{{/inputEmpty}}) { return WorkflowCodegenUtil.RestateClient.submit(ctx, WORKFLOW_NAME, workflowKey, {{#if inputEmpty}}null{{else}}req{{/if}}); } @@ -52,17 +52,17 @@ public class {{generatedClassSimpleName}} { public Awaitable> getOutput() { return WorkflowCodegenUtil.RestateClient.getOutput(ctx, WORKFLOW_NAME, workflowKey, {{outputSerdeFieldName}}); }{{/outputEmpty}} - {{/if}}{{/methods}} + {{/if}}{{/handlers}} public Awaitable> getState(StateKey key) { return WorkflowCodegenUtil.RestateClient.getState(ctx, WORKFLOW_NAME, workflowKey, key); } - {{#methods}}{{#if isShared}} + {{#handlers}}{{#if isShared}} public Awaitable<{{{boxedOutputFqcn}}}> {{name}}({{^inputEmpty}}{{{inputFqcn}}} req{{/inputEmpty}}) { return WorkflowCodegenUtil.RestateClient.invokeShared(ctx, WORKFLOW_NAME, "{{name}}", workflowKey, {{#if inputEmpty}}null{{else}}req{{/if}}, {{outputSerdeFieldName}}); } - {{/if}}{{/methods}} + {{/if}}{{/handlers}} public Send send() { return new Send(); @@ -74,10 +74,10 @@ public class {{generatedClassSimpleName}} { public class Send { - {{#methods}}{{#if isShared}} + {{#handlers}}{{#if isShared}} public void {{name}}({{^inputEmpty}}{{{inputFqcn}}} req{{/inputEmpty}}) { WorkflowCodegenUtil.RestateClient.invokeSharedSend(ContextClient.this.ctx, WORKFLOW_NAME, "{{name}}", ContextClient.this.workflowKey, {{#if inputEmpty}}null{{else}}req{{/if}}); - }{{/if}}{{/methods}} + }{{/if}}{{/handlers}} } @@ -89,10 +89,10 @@ public class {{generatedClassSimpleName}} { this.delay = delay; } - {{#methods}}{{#if isShared}} + {{#handlers}}{{#if isShared}} public void {{name}}({{^inputEmpty}}{{{inputFqcn}}} req{{/inputEmpty}}) { WorkflowCodegenUtil.RestateClient.invokeSharedSendDelayed(ContextClient.this.ctx, WORKFLOW_NAME, "{{name}}", ContextClient.this.workflowKey, {{#if inputEmpty}}null{{else}}req{{/if}}, delay); - }{{/if}}{{/methods}} + }{{/if}}{{/handlers}} } } @@ -106,7 +106,7 @@ public class {{generatedClassSimpleName}} { this.workflowKey = workflowKey; } - {{#methods}}{{#if isWorkflow}} + {{#handlers}}{{#if isWorkflow}} public dev.restate.sdk.workflow.WorkflowExecutionState submit({{^inputEmpty}}{{{inputFqcn}}} req{{/inputEmpty}}) { return WorkflowCodegenUtil.ExternalClient.submit(ingressClient, WORKFLOW_NAME, workflowKey, {{#if inputEmpty}}null{{else}}req{{/if}}); } @@ -119,17 +119,17 @@ public class {{generatedClassSimpleName}} { public Optional<{{{outputFqcn}}}> getOutput() { return WorkflowCodegenUtil.ExternalClient.getOutput(ingressClient, WORKFLOW_NAME, workflowKey, {{outputSerdeFieldName}}); }{{/outputEmpty}} - {{/if}}{{/methods}} + {{/if}}{{/handlers}} public Optional getState(StateKey key) { return WorkflowCodegenUtil.ExternalClient.getState(ingressClient, WORKFLOW_NAME, workflowKey, key); } - {{#methods}}{{#if isShared}} + {{#handlers}}{{#if isShared}} public {{#if outputEmpty}}void{{else}}{{{outputFqcn}}}{{/if}} {{name}}({{^inputEmpty}}{{{inputFqcn}}} req{{/inputEmpty}}) { {{^outputEmpty}}return {{/outputEmpty}}WorkflowCodegenUtil.ExternalClient.invokeShared(IngressClient.this.ingressClient, WORKFLOW_NAME, "{{name}}", IngressClient.this.workflowKey, {{#if inputEmpty}}null{{else}}req{{/if}}, {{outputSerdeFieldName}}); } - {{/if}}{{/methods}} + {{/if}}{{/handlers}} public Send send() { return new Send(); @@ -137,11 +137,11 @@ public class {{generatedClassSimpleName}} { public class Send { - {{#methods}}{{#if isShared}} + {{#handlers}}{{#if isShared}} public void {{name}}({{^inputEmpty}}{{{inputFqcn}}} req{{/inputEmpty}}) { WorkflowCodegenUtil.ExternalClient.invokeSharedSend(IngressClient.this.ingressClient, WORKFLOW_NAME, "{{name}}", IngressClient.this.workflowKey, {{#if inputEmpty}}null{{else}}req{{/if}}); } - {{/if}}{{/methods}} + {{/if}}{{/handlers}} } } diff --git a/sdk-api-gen/src/main/resources/templates/workflow/ComponentAdapter.hbs b/sdk-api-gen/src/main/resources/templates/workflow/ComponentAdapter.hbs index f6e7f04f..2fa54aae 100644 --- a/sdk-api-gen/src/main/resources/templates/workflow/ComponentAdapter.hbs +++ b/sdk-api-gen/src/main/resources/templates/workflow/ComponentAdapter.hbs @@ -8,7 +8,7 @@ public class {{generatedClassSimpleName}} implements dev.restate.sdk.common.Comp public dev.restate.sdk.common.BindableComponent adapt({{originalClassFqcn}} bindableComponent) { return dev.restate.sdk.workflow.WorkflowBuilder.named( COMPONENT_NAME, - {{#methods}}{{#if isWorkflow}} + {{#handlers}}{{#if isWorkflow}} dev.restate.sdk.Component.HandlerSignature.of("{{name}}", {{{inputSerdeDecl}}}, {{{outputSerdeDecl}}}), (ctx, req) -> { {{#if outputEmpty}} @@ -18,9 +18,9 @@ public class {{generatedClassSimpleName}} implements dev.restate.sdk.common.Comp return {{#if inputEmpty}}bindableComponent.{{name}}(ctx){{else}}bindableComponent.{{name}}(ctx, req){{/if}}; {{/if}} } - {{/if}}{{/methods}}) - {{#methods}}{{#if isShared}} - .with{{capitalizeFirst (lower methodType)}}( + {{/if}}{{/handlers}}) + {{#handlers}}{{#if isShared}} + .with{{capitalizeFirst (lower handlerType)}}( dev.restate.sdk.Component.HandlerSignature.of("{{name}}", {{{inputSerdeDecl}}}, {{{outputSerdeDecl}}}), (ctx, req) -> { {{#if outputEmpty}} @@ -30,7 +30,7 @@ public class {{generatedClassSimpleName}} implements dev.restate.sdk.common.Comp return {{#if inputEmpty}}bindableComponent.{{name}}(ctx){{else}}bindableComponent.{{name}}(ctx, req){{/if}}; {{/if}} }) - {{/if}}{{/methods}} + {{/if}}{{/handlers}} .build(); } diff --git a/sdk-api-kotlin-gen/build.gradle.kts b/sdk-api-kotlin-gen/build.gradle.kts new file mode 100644 index 00000000..fe64cad9 --- /dev/null +++ b/sdk-api-kotlin-gen/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + java + kotlin("jvm") + `library-publishing-conventions` + alias(kotlinLibs.plugins.ksp) +} + +description = "Restate SDK API Kotlin Gen" + +dependencies { + compileOnly(coreLibs.jspecify) + + implementation(kotlinLibs.symbol.processing.api) + implementation(project(":sdk-api-gen-common")) + + implementation(project(":sdk-api-kotlin")) + + kspTest(project(":sdk-api-kotlin-gen")) + testImplementation(project(":sdk-core")) + testImplementation(testingLibs.junit.jupiter) + testImplementation(testingLibs.assertj) + testImplementation(coreLibs.protobuf.java) + testImplementation(coreLibs.log4j.core) + + // Import test suites from sdk-core + testImplementation(project(":sdk-core", "testArchive")) +} + +// Generate test jar + +configurations { register("testArchive") } + +tasks.register("testJar") { + archiveClassifier.set("tests") + + from(project.the()["test"].output) +} + +artifacts { add("testArchive", tasks["testJar"]) } diff --git a/sdk-api-kotlin-gen/src/main/kotlin/dev/restate/sdk/kotlin/gen/ComponentProcessor.kt b/sdk-api-kotlin-gen/src/main/kotlin/dev/restate/sdk/kotlin/gen/ComponentProcessor.kt new file mode 100644 index 00000000..52a9db93 --- /dev/null +++ b/sdk-api-kotlin-gen/src/main/kotlin/dev/restate/sdk/kotlin/gen/ComponentProcessor.kt @@ -0,0 +1,122 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.kotlin.gen + +import com.github.jknack.handlebars.io.ClassPathTemplateLoader +import com.google.devtools.ksp.containingFile +import com.google.devtools.ksp.processing.* +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.Origin +import dev.restate.sdk.common.ComponentAdapter +import dev.restate.sdk.common.ComponentType +import dev.restate.sdk.gen.model.Component +import dev.restate.sdk.gen.template.HandlebarsTemplateEngine +import java.io.BufferedWriter +import java.io.IOException +import java.io.Writer +import java.nio.charset.Charset + +class ComponentProcessor(private val logger: KSPLogger, private val codeGenerator: CodeGenerator) : + SymbolProcessor { + + private val serviceAdapterCodegen: HandlebarsTemplateEngine = + HandlebarsTemplateEngine( + "ComponentAdapter", + ClassPathTemplateLoader(), + mapOf( + ComponentType.SERVICE to "templates/ComponentAdapter", + ComponentType.VIRTUAL_OBJECT to "templates/ComponentAdapter")) + private val clientCodegen: HandlebarsTemplateEngine = + HandlebarsTemplateEngine( + "Client", + ClassPathTemplateLoader(), + mapOf( + ComponentType.SERVICE to "templates/Client", + ComponentType.VIRTUAL_OBJECT to "templates/Client")) + + override fun process(resolver: Resolver): List { + val converter = KElementConverter(logger, resolver.builtIns) + + val resolved = + resolver + .getSymbolsWithAnnotation(dev.restate.sdk.annotation.Service::class.qualifiedName!!) + .toSet() + + resolver + .getSymbolsWithAnnotation( + dev.restate.sdk.annotation.VirtualObject::class.qualifiedName!!) + .toSet() + + resolver + .getSymbolsWithAnnotation( + dev.restate.sdk.annotation.Workflow::class.qualifiedName!!) + .toSet() + + val components = + resolved + .filter { it.containingFile!!.origin == Origin.KOTLIN } + .map { + val componentBuilder = Component.builder() + converter.visitAnnotated(it, componentBuilder) + (it to componentBuilder.build()!!) + } + .toList() + + // Run code generation + for (component in components) { + try { + val fileCreator: (String) -> Writer = { name: String -> + codeGenerator + .createNewFile( + Dependencies(false, component.first.containingFile!!), + component.second.targetPkg.toString(), + name) + .writer(Charset.defaultCharset()) + } + this.serviceAdapterCodegen.generate(fileCreator, component.second) + this.clientCodegen.generate(fileCreator, component.second) + } catch (ex: Throwable) { + throw RuntimeException(ex) + } + } + + // META-INF + if (components.isNotEmpty()) { + generateMetaINF(components) + } + + return emptyList() + } + + private fun generateMetaINF(components: List>) { + val resourceFile = "META-INF/services/${ComponentAdapter::class.java.canonicalName}" + val dependencies = + Dependencies(true, *(components.map { it.first.containingFile!! }.toTypedArray())) + + val writer: BufferedWriter = + try { + codeGenerator.createNewFileByPath(dependencies, resourceFile, "").bufferedWriter() + } catch (e: FileSystemException) { + val existingFile = e.file + val currentValues = existingFile.readText() + val newWriter = e.file.bufferedWriter() + newWriter.write(currentValues) + newWriter + } + + try { + writer.use { + for (component in components) { + it.write("${component.second.generatedClassFqcnPrefix}ComponentAdapter") + it.newLine() + } + } + } catch (e: IOException) { + logger.error("Unable to create $resourceFile: $e") + } + } +} diff --git a/sdk-api-kotlin-gen/src/main/kotlin/dev/restate/sdk/kotlin/gen/ComponentProcessorProvider.kt b/sdk-api-kotlin-gen/src/main/kotlin/dev/restate/sdk/kotlin/gen/ComponentProcessorProvider.kt new file mode 100644 index 00000000..7103d0ae --- /dev/null +++ b/sdk-api-kotlin-gen/src/main/kotlin/dev/restate/sdk/kotlin/gen/ComponentProcessorProvider.kt @@ -0,0 +1,21 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.kotlin.gen + +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider + +class ComponentProcessorProvider : SymbolProcessorProvider { + + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return ComponentProcessor( + logger = environment.logger, codeGenerator = environment.codeGenerator) + } +} diff --git a/sdk-api-kotlin-gen/src/main/kotlin/dev/restate/sdk/kotlin/gen/KElementConverter.kt b/sdk-api-kotlin-gen/src/main/kotlin/dev/restate/sdk/kotlin/gen/KElementConverter.kt new file mode 100644 index 00000000..37f56a5c --- /dev/null +++ b/sdk-api-kotlin-gen/src/main/kotlin/dev/restate/sdk/kotlin/gen/KElementConverter.kt @@ -0,0 +1,239 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.kotlin.gen + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getAnnotationsByType +import com.google.devtools.ksp.getVisibility +import com.google.devtools.ksp.isAnnotationPresent +import com.google.devtools.ksp.processing.KSBuiltIns +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.symbol.* +import com.google.devtools.ksp.visitor.KSDefaultVisitor +import dev.restate.sdk.common.ComponentType +import dev.restate.sdk.gen.model.Component +import dev.restate.sdk.gen.model.Handler +import dev.restate.sdk.gen.model.HandlerType +import dev.restate.sdk.gen.model.PayloadType +import dev.restate.sdk.kotlin.Context +import dev.restate.sdk.kotlin.ObjectContext +import java.util.regex.Pattern +import kotlin.reflect.KClass + +class KElementConverter(private val logger: KSPLogger, private val builtIns: KSBuiltIns) : + KSDefaultVisitor() { + companion object { + private val SUPPORTED_CLASS_KIND: Set = setOf(ClassKind.CLASS, ClassKind.INTERFACE) + private val EMPTY_PAYLOAD: PayloadType = + PayloadType(true, "", "Unit", "dev.restate.sdk.kotlin.KtSerdes.UNIT") + } + + override fun defaultHandler(node: KSNode, data: Component.Builder) {} + + override fun visitAnnotated(annotated: KSAnnotated, data: Component.Builder) { + if (annotated !is KSClassDeclaration) { + logger.error( + "Only classes or interfaces can be annotated with @Service or @VirtualObject or @Workflow") + } + visitClassDeclaration(annotated as KSClassDeclaration, data) + } + + @OptIn(KspExperimental::class) + override fun visitClassDeclaration( + classDeclaration: KSClassDeclaration, + data: Component.Builder + ) { + // Validate class declaration + if (classDeclaration.typeParameters.isNotEmpty()) { + logger.error( + "The ComponentProcessor doesn't support components with generics", classDeclaration) + } + if (!SUPPORTED_CLASS_KIND.contains(classDeclaration.classKind)) { + logger.error( + "The ComponentProcessor supports only class declarations of kind $SUPPORTED_CLASS_KIND", + classDeclaration) + } + if (classDeclaration.getVisibility() == Visibility.PRIVATE) { + logger.error("The annotated class is private", classDeclaration) + } + if (classDeclaration.isAnnotationPresent(dev.restate.sdk.annotation.Workflow::class)) { + logger.error("sdk-api-kotlin doesn't support workflows yet", classDeclaration) + } + + // Figure out component type annotations + val serviceAnnotation = + classDeclaration + .getAnnotationsByType(dev.restate.sdk.annotation.Service::class) + .firstOrNull() + val virtualObjectAnnotation = + classDeclaration + .getAnnotationsByType(dev.restate.sdk.annotation.VirtualObject::class) + .firstOrNull() + val isAnnotatedWithService = serviceAnnotation != null + val isAnnotatedWithVirtualObject = virtualObjectAnnotation != null + + // Check there's exactly one annotation + if (!(isAnnotatedWithService xor isAnnotatedWithVirtualObject)) { + logger.error( + "The type can be annotated only with one annotation between @VirtualObject and @Service", + classDeclaration) + } + + data.withComponentType( + if (isAnnotatedWithService) ComponentType.SERVICE else ComponentType.VIRTUAL_OBJECT) + + // Infer names + val targetPkg = classDeclaration.packageName.asString() + val targetFqcn = classDeclaration.qualifiedName!!.asString() + var componentName = + if (isAnnotatedWithService) serviceAnnotation!!.name else virtualObjectAnnotation!!.name + if (componentName.isEmpty()) { + // Use Simple class name + // With this logic we make sure we flatten subclasses names + componentName = + targetFqcn.substring(targetPkg.length).replace(Pattern.quote(".").toRegex(), "") + } + data.withTargetPkg(targetPkg).withTargetFqcn(targetFqcn).withComponentName(componentName) + + // Compute handlers + classDeclaration + .getAllFunctions() + .filter { + it.isAnnotationPresent(dev.restate.sdk.annotation.Handler::class) || + it.isAnnotationPresent(dev.restate.sdk.annotation.Workflow::class) || + it.isAnnotationPresent(dev.restate.sdk.annotation.Exclusive::class) || + it.isAnnotationPresent(dev.restate.sdk.annotation.Shared::class) + } + .forEach { visitFunctionDeclaration(it, data) } + + if (data.handlers.isEmpty()) { + logger.warn( + "The class declaration $targetFqcn has no methods annotated as handlers", + classDeclaration) + } + } + + @OptIn(KspExperimental::class) + override fun visitFunctionDeclaration(function: KSFunctionDeclaration, data: Component.Builder) { + // Validate function declaration + if (function.typeParameters.isNotEmpty()) { + logger.error("The ComponentProcessor doesn't support methods with generics", function) + } + if (function.functionKind != FunctionKind.MEMBER) { + logger.error("Only member function declarations are supported as Restate handlers") + } + if (function.isAnnotationPresent(dev.restate.sdk.annotation.Workflow::class)) { + logger.error("sdk-api-kotlin doesn't support workflows yet", function) + } + + val isAnnotatedWithShared = + function.isAnnotationPresent(dev.restate.sdk.annotation.Service::class) + val isAnnotatedWithExclusive = + function.isAnnotationPresent(dev.restate.sdk.annotation.Exclusive::class) + + // Check there's no more than one annotation + val hasAnyAnnotation = isAnnotatedWithExclusive || isAnnotatedWithShared + val hasExactlyOneAnnotation = isAnnotatedWithShared xor isAnnotatedWithExclusive + if (!(!hasAnyAnnotation || hasExactlyOneAnnotation)) { + logger.error( + "You can have only one annotation between @Shared and @Exclusive to a method", function) + } + + val handlerBuilder = Handler.builder() + + // Set handler type + val handlerType = + if (isAnnotatedWithShared) HandlerType.SHARED + else if (isAnnotatedWithExclusive) HandlerType.EXCLUSIVE + else defaultHandlerType(data.componentType, function) + handlerBuilder.withHandlerType(handlerType) + + validateMethodSignature(data.componentType, handlerType, function) + + data.withHandler( + handlerBuilder + .withName(function.simpleName.asString()) + .withHandlerType(handlerType) + .withInputType( + if (function.parameters.size == 2) payloadFromType(function.parameters[1].type) + else EMPTY_PAYLOAD) + .withOutputType( + if (function.returnType != null) payloadFromType(function.returnType!!) + else EMPTY_PAYLOAD) + .build()) + } + + private fun defaultHandlerType(componentType: ComponentType, node: KSNode): HandlerType { + when (componentType) { + ComponentType.SERVICE -> return HandlerType.STATELESS + ComponentType.VIRTUAL_OBJECT -> return HandlerType.EXCLUSIVE + ComponentType.WORKFLOW -> + logger.error("Workflow handlers MUST be annotated with either @Shared or @Workflow", node) + } + throw IllegalStateException("Unexpected") + } + + private fun validateMethodSignature( + componentType: ComponentType, + handlerType: HandlerType, + function: KSFunctionDeclaration + ) { + if (function.parameters.isEmpty()) { + logger.error( + "The annotated method has no parameters. There must be at least the context parameter as first parameter", + function) + } + when (handlerType) { + HandlerType.SHARED -> + logger.error( + "The annotation @Shared is not supported by the component type $componentType", + function) + HandlerType.EXCLUSIVE -> + if (componentType == ComponentType.VIRTUAL_OBJECT) { + validateFirstParameterType(ObjectContext::class, function) + } else { + logger.error( + "The annotation @Exclusive is not supported by the component type $componentType", + function) + } + HandlerType.STATELESS -> validateFirstParameterType(Context::class, function) + HandlerType.WORKFLOW -> + logger.error( + "The annotation @Workflow is currently not supported in sdk-api-kotlin", function) + } + } + + private fun validateFirstParameterType(clazz: KClass<*>, function: KSFunctionDeclaration) { + if (function.parameters[0].type.resolve().declaration.qualifiedName!!.asString() != + clazz.qualifiedName) { + logger.error( + "The method signature must have ${clazz.qualifiedName} as first parameter, was ${function.parameters[0].type.resolve().declaration.qualifiedName!!.asString()}", + function) + } + } + + private fun payloadFromType(typeRef: KSTypeReference): PayloadType { + val ty = typeRef.resolve() + return PayloadType(false, typeRef.toString(), boxedType(ty), serdeDecl(ty)) + } + + private fun serdeDecl(ty: KSType): String { + return when (ty) { + builtIns.unitType -> "dev.restate.sdk.kotlin.KtSerdes.UNIT" + else -> "dev.restate.sdk.kotlin.KtSerdes.json<${boxedType(ty)}>()" + } + } + + private fun boxedType(ty: KSType): String { + return when (ty) { + builtIns.unitType -> "Unit" + else -> ty.toString() + } + } +} diff --git a/sdk-api-kotlin-gen/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/sdk-api-kotlin-gen/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 00000000..05d0b596 --- /dev/null +++ b/sdk-api-kotlin-gen/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +dev.restate.sdk.kotlin.gen.ComponentProcessorProvider \ No newline at end of file diff --git a/sdk-api-kotlin-gen/src/main/resources/templates/Client.hbs b/sdk-api-kotlin-gen/src/main/resources/templates/Client.hbs new file mode 100644 index 00000000..f0d88264 --- /dev/null +++ b/sdk-api-kotlin-gen/src/main/resources/templates/Client.hbs @@ -0,0 +1,97 @@ +{{#if originalClassPkg}}package {{originalClassPkg}};{{/if}} + +import dev.restate.sdk.kotlin.Awaitable +import dev.restate.sdk.kotlin.Context +import dev.restate.sdk.common.StateKey +import dev.restate.sdk.common.Serde +import dev.restate.sdk.common.Target +import kotlin.time.Duration + +object {{generatedClassSimpleName}} { + + const val COMPONENT_NAME: String = "{{componentName}}" + + {{#handlers}} + private val {{inputSerdeFieldName}}: Serde<{{{boxedInputFqcn}}}> = {{{inputSerdeDecl}}} + private val {{outputSerdeFieldName}}: Serde<{{{boxedOutputFqcn}}}> = {{{outputSerdeDecl}}} + {{/handlers}} + + fun fromContext(ctx: Context{{#isObject}}, key: String{{/isObject}}): ContextClient { + return ContextClient(ctx{{#isObject}}, key{{/isObject}}) + } + + fun fromIngress(ingressClient: dev.restate.sdk.client.IngressClient{{#isObject}}, key: String{{/isObject}}): IngressClient { + return IngressClient(ingressClient{{#isObject}}, key{{/isObject}}); + } + + fun fromIngress(baseUri: String{{#isObject}}, key: String{{/isObject}}): IngressClient { + return IngressClient(dev.restate.sdk.client.IngressClient.defaultClient(baseUri){{#isObject}}, key{{/isObject}}); + } + + class ContextClient(private val ctx: Context{{#isObject}}, private val key: String{{/isObject}}){ + {{#handlers}} + suspend fun {{name}}({{^inputEmpty}}req: {{{inputFqcn}}}{{/inputEmpty}}): Awaitable<{{{boxedOutputFqcn}}}> { + return this.ctx.callAsync( + {{#if isObject}}Target.virtualObject(COMPONENT_NAME, this.key, "{{name}}"){{else}}Target.service(COMPONENT_NAME, "{{name}}"){{/if}}, + {{inputSerdeFieldName}}, + {{outputSerdeFieldName}}, + {{#if inputEmpty}}Unit{{else}}req{{/if}}) + }{{/handlers}} + + fun send(): Send { + return Send() + } + + fun sendDelayed(delay: Duration): SendDelayed { + return SendDelayed(delay) + } + + inner class Send { + {{#handlers}} + suspend fun {{name}}({{^inputEmpty}}req: {{{inputFqcn}}}{{/inputEmpty}}) { + this@ContextClient.ctx.send( + {{#if isObject}}Target.virtualObject(COMPONENT_NAME, this@ContextClient.key, "{{name}}"){{else}}Target.service(COMPONENT_NAME, "{{name}}"){{/if}}, + {{inputSerdeFieldName}}, + {{#if inputEmpty}}Unit{{else}}req{{/if}}); + }{{/handlers}} + } + + inner class SendDelayed(private val delay: Duration) { + + {{#handlers}} + suspend fun {{name}}({{^inputEmpty}}req: {{{inputFqcn}}}{{/inputEmpty}}) { + this@ContextClient.ctx.sendDelayed( + {{#if isObject}}Target.virtualObject(COMPONENT_NAME, this@ContextClient.key, "{{name}}"){{else}}Target.service(COMPONENT_NAME, "{{name}}"){{/if}}, + {{inputSerdeFieldName}}, + {{#if inputEmpty}}Unit{{else}}req{{/if}}, + this.delay); + }{{/handlers}} + } + } + + class IngressClient(private val ingressClient: dev.restate.sdk.client.IngressClient{{#isObject}}, private val key: String{{/isObject}}) { + + {{#handlers}} + suspend fun {{name}}({{^inputEmpty}}req: {{{inputFqcn}}}{{/inputEmpty}}): {{{boxedOutputFqcn}}} { + return this.ingressClient.call( + {{#if isObject}}Target.virtualObject(COMPONENT_NAME, this.key, "{{name}}"){{else}}Target.service(COMPONENT_NAME, "{{name}}"){{/if}}, + {{inputSerdeFieldName}}, + {{outputSerdeFieldName}}, + {{#if inputEmpty}}null{{else}}req{{/if}}); + }{{/handlers}} + + fun send(): Send { + return Send() + } + + inner class Send { + {{#handlers}} + suspend fun {{name}}({{^inputEmpty}}req: {{{inputFqcn}}}{{/inputEmpty}}): String { + return this@IngressClient.ingressClient.send( + {{#if isObject}}Target.virtualObject(COMPONENT_NAME, this@IngressClient.key, "{{name}}"){{else}}Target.service(COMPONENT_NAME, "{{name}}"){{/if}}, + {{inputSerdeFieldName}}, + {{#if inputEmpty}}null{{else}}req{{/if}}); + }{{/handlers}} + } + } +} \ No newline at end of file diff --git a/sdk-api-kotlin-gen/src/main/resources/templates/ComponentAdapter.hbs b/sdk-api-kotlin-gen/src/main/resources/templates/ComponentAdapter.hbs new file mode 100644 index 00000000..d950ac09 --- /dev/null +++ b/sdk-api-kotlin-gen/src/main/resources/templates/ComponentAdapter.hbs @@ -0,0 +1,23 @@ +{{#if originalClassPkg}}package {{originalClassPkg}}{{/if}} + +class {{generatedClassSimpleName}}: dev.restate.sdk.common.ComponentAdapter<{{originalClassFqcn}}> { + + companion object { + const val COMPONENT_NAME: String = "{{componentName}}"; + } + + override fun adapt(bindableComponent: {{originalClassFqcn}}): dev.restate.sdk.common.BindableComponent { + return dev.restate.sdk.kotlin.Component.{{#if isObject}}virtualObject{{else}}service{{/if}}(COMPONENT_NAME) { + {{#handlers}} + handler(dev.restate.sdk.kotlin.Component.HandlerSignature("{{name}}", {{{inputSerdeDecl}}}, {{{outputSerdeDecl}}})) { ctx, req -> + {{#if inputEmpty}}bindableComponent.{{name}}(ctx){{else}}bindableComponent.{{name}}(ctx, req){{/if}} + } + {{/handlers}} + } + } + + override fun supportsObject(serviceObject: Any?): Boolean { + return serviceObject is {{originalClassFqcn}}; + } + +} \ No newline at end of file diff --git a/sdk-api-kotlin-gen/src/test/kotlin/dev/restate/sdk/kotlin/CodegenTest.kt b/sdk-api-kotlin-gen/src/test/kotlin/dev/restate/sdk/kotlin/CodegenTest.kt new file mode 100644 index 00000000..34bd3031 --- /dev/null +++ b/sdk-api-kotlin-gen/src/test/kotlin/dev/restate/sdk/kotlin/CodegenTest.kt @@ -0,0 +1,153 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.kotlin + +import com.google.protobuf.ByteString +import dev.restate.sdk.annotation.Exclusive +import dev.restate.sdk.annotation.Handler +import dev.restate.sdk.annotation.Service +import dev.restate.sdk.annotation.VirtualObject +import dev.restate.sdk.common.CoreSerdes +import dev.restate.sdk.common.Target +import dev.restate.sdk.core.ProtoUtils.* +import dev.restate.sdk.core.TestDefinitions +import dev.restate.sdk.core.TestDefinitions.TestDefinition +import dev.restate.sdk.core.TestDefinitions.testInvocation +import java.util.stream.Stream + +class CodegenTest : TestDefinitions.TestSuite { + @Service + class ServiceGreeter { + @Handler + suspend fun greet(context: Context, request: String): String { + return request + } + } + + @VirtualObject + class ObjectGreeter { + @Exclusive + suspend fun greet(context: ObjectContext, request: String): String { + return request + } + } + + @VirtualObject + interface GreeterInterface { + @Exclusive suspend fun greet(context: ObjectContext, request: String): String + } + + private class ObjectGreeterImplementedFromInterface : GreeterInterface { + override suspend fun greet(context: ObjectContext, request: String): String { + return request + } + } + + @Service(name = "Empty") + class Empty { + @Handler + suspend fun emptyInput(context: Context): String { + val client = EmptyClient.fromContext(context) + return client.emptyInput().await() + } + + @Handler + suspend fun emptyOutput(context: Context, request: String) { + val client = EmptyClient.fromContext(context) + client.emptyOutput(request).await() + } + + @Handler + suspend fun emptyInputOutput(context: Context) { + val client = EmptyClient.fromContext(context) + client.emptyInputOutput().await() + } + } + + @Service(name = "PrimitiveTypes") + class PrimitiveTypes { + @Handler + suspend fun primitiveOutput(context: Context): Int { + val client = PrimitiveTypesClient.fromContext(context) + return client.primitiveOutput().await() + } + + @Handler + suspend fun primitiveInput(context: Context, input: Int) { + val client = PrimitiveTypesClient.fromContext(context) + client.primitiveInput(input).await() + } + } + + override fun definitions(): Stream { + return Stream.of( + testInvocation({ ServiceGreeter() }, "greet") + .withInput(startMessage(1), inputMessage("Francesco")) + .onlyUnbuffered() + .expectingOutput(outputMessage("Francesco"), END_MESSAGE), + testInvocation({ ObjectGreeter() }, "greet") + .withInput(startMessage(1, "slinkydeveloper"), inputMessage("Francesco")) + .onlyUnbuffered() + .expectingOutput(outputMessage("Francesco"), END_MESSAGE), + testInvocation({ ObjectGreeterImplementedFromInterface() }, "greet") + .withInput(startMessage(1, "slinkydeveloper"), inputMessage("Francesco")) + .onlyUnbuffered() + .expectingOutput(outputMessage("Francesco"), END_MESSAGE), + testInvocation({ Empty() }, "emptyInput") + .withInput(startMessage(1), inputMessage(), completionMessage(1, "Till")) + .onlyUnbuffered() + .expectingOutput( + invokeMessage(Target.service("Empty", "emptyInput")), + outputMessage("Till"), + END_MESSAGE) + .named("empty output"), + testInvocation({ Empty() }, "emptyOutput") + .withInput( + startMessage(1), + inputMessage("Francesco"), + completionMessage(1).setValue(ByteString.EMPTY)) + .onlyUnbuffered() + .expectingOutput( + invokeMessage(Target.service("Empty", "emptyOutput"), "Francesco"), + outputMessage(), + END_MESSAGE) + .named("empty output"), + testInvocation({ Empty() }, "emptyInputOutput") + .withInput( + startMessage(1), + inputMessage("Francesco"), + completionMessage(1).setValue(ByteString.EMPTY)) + .onlyUnbuffered() + .expectingOutput( + invokeMessage(Target.service("Empty", "emptyInputOutput")), + outputMessage(), + END_MESSAGE) + .named("empty input and empty output"), + testInvocation({ PrimitiveTypes() }, "primitiveOutput") + .withInput( + startMessage(1), inputMessage(), completionMessage(1, CoreSerdes.JSON_INT, 10)) + .onlyUnbuffered() + .expectingOutput( + invokeMessage( + Target.service("PrimitiveTypes", "primitiveOutput"), CoreSerdes.VOID, null), + outputMessage(CoreSerdes.JSON_INT, 10), + END_MESSAGE) + .named("primitive output"), + testInvocation({ PrimitiveTypes() }, "primitiveInput") + .withInput( + startMessage(1), inputMessage(10), completionMessage(1).setValue(ByteString.EMPTY)) + .onlyUnbuffered() + .expectingOutput( + invokeMessage( + Target.service("PrimitiveTypes", "primitiveInput"), CoreSerdes.JSON_INT, 10), + outputMessage(), + END_MESSAGE) + .named("primitive input")) + } +} diff --git a/sdk-api-kotlin-gen/src/test/kotlin/dev/restate/sdk/kotlin/KtCodegenTests.kt b/sdk-api-kotlin-gen/src/test/kotlin/dev/restate/sdk/kotlin/KtCodegenTests.kt new file mode 100644 index 00000000..f76303ae --- /dev/null +++ b/sdk-api-kotlin-gen/src/test/kotlin/dev/restate/sdk/kotlin/KtCodegenTests.kt @@ -0,0 +1,26 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.kotlin + +import dev.restate.sdk.core.MockMultiThreaded +import dev.restate.sdk.core.MockSingleThread +import dev.restate.sdk.core.TestDefinitions +import dev.restate.sdk.core.TestDefinitions.TestExecutor +import dev.restate.sdk.core.TestRunner +import java.util.stream.Stream + +class KtCodegenTests : TestRunner() { + override fun executors(): Stream { + return Stream.of(MockSingleThread.INSTANCE, MockMultiThreaded.INSTANCE) + } + + public override fun definitions(): Stream { + return Stream.of(CodegenTest()) + } +} diff --git a/sdk-http-vertx/build.gradle.kts b/sdk-http-vertx/build.gradle.kts index 19288be9..67b619a6 100644 --- a/sdk-http-vertx/build.gradle.kts +++ b/sdk-http-vertx/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { testImplementation(project(":sdk-api", "testArchive")) testImplementation(project(":sdk-api-gen", "testArchive")) testImplementation(project(":sdk-api-kotlin", "testArchive")) + testImplementation(project(":sdk-api-kotlin-gen", "testArchive")) testImplementation(testingLibs.junit.jupiter) testImplementation(testingLibs.assertj) testImplementation(vertxLibs.vertx.junit5) diff --git a/sdk-http-vertx/src/test/kotlin/dev/restate/sdk/http/vertx/HttpVertxTests.kt b/sdk-http-vertx/src/test/kotlin/dev/restate/sdk/http/vertx/HttpVertxTests.kt index 5909f161..ebdf2933 100644 --- a/sdk-http-vertx/src/test/kotlin/dev/restate/sdk/http/vertx/HttpVertxTests.kt +++ b/sdk-http-vertx/src/test/kotlin/dev/restate/sdk/http/vertx/HttpVertxTests.kt @@ -13,6 +13,7 @@ import dev.restate.sdk.JavaCodegenTests import dev.restate.sdk.core.TestDefinitions.TestExecutor import dev.restate.sdk.core.TestDefinitions.TestSuite import dev.restate.sdk.kotlin.KotlinCoroutinesTests +import dev.restate.sdk.kotlin.KtCodegenTests import io.vertx.core.Vertx import java.util.stream.Stream import org.junit.jupiter.api.AfterAll @@ -40,8 +41,7 @@ class HttpVertxTests : dev.restate.sdk.core.TestRunner() { return Stream.concat( Stream.concat( Stream.concat(JavaBlockingTests().definitions(), JavaCodegenTests().definitions()), - KotlinCoroutinesTests().definitions(), - ), + Stream.concat(KotlinCoroutinesTests().definitions(), KtCodegenTests().definitions())), Stream.of(VertxExecutorsTest())) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index ba63afce..b9f9d1d4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,6 +12,7 @@ rootProject.name = "sdk-java" plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "0.7.0" } include( + "admin-client", "sdk-common", "sdk-api", "sdk-api-kotlin", @@ -20,10 +21,11 @@ include( "sdk-http-vertx", "sdk-lambda", "sdk-testing", - "examples", - "admin-client", + "sdk-api-gen-common", "sdk-api-gen", + "sdk-api-kotlin-gen", "sdk-workflow-api", + "examples", ) dependencyResolutionManagement { @@ -80,6 +82,11 @@ dependencyResolutionManagement { .version("1.6.2") library("kotlinx-serialization-json", "org.jetbrains.kotlinx", "kotlinx-serialization-json") .version("1.6.2") + + version("ksp", "1.9.22-1.0.18") + library("symbol-processing-api", "com.google.devtools.ksp", "symbol-processing-api") + .versionRef("ksp") + plugin("ksp", "com.google.devtools.ksp").versionRef("ksp") } create("testingLibs") { version("junit-jupiter", "5.9.1")