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 index fbd0a582..ba39da95 100644 --- 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 @@ -31,7 +31,7 @@ import javax.tools.Diagnostic; import org.jspecify.annotations.Nullable; -public class ElementConverter { +class ElementConverter { private static final PayloadType EMPTY_PAYLOAD = new PayloadType(true, "", "Void", "dev.restate.sdk.common.Serde.VOID"); @@ -47,49 +47,29 @@ public ElementConverter(Messager messager, Elements elements, Types types) { this.types = types; } - public Service fromTypeElement(TypeElement element) { + Service fromTypeElement(MetaRestateAnnotation metaAnnotation, 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); - } - - ServiceType type = - isAnnotatedWithWorkflow - ? ServiceType.WORKFLOW - : isAnnotatedWithService ? ServiceType.SERVICE : ServiceType.VIRTUAL_OBJECT; + // Find annotation mirror + AnnotationMirror metaAnnotationMirror = + element.getAnnotationMirrors().stream() + .filter( + a -> + a.getAnnotationType() + .asElement() + .equals(metaAnnotation.getAnnotationTypeElement())) + .findFirst() + .orElseThrow( + () -> + new IllegalStateException( + "Cannot find the annotation mirror for meta annotation " + + metaAnnotation.getAnnotationTypeElement().getQualifiedName())); // Infer names - CharSequence targetPkg = elements.getPackageOf(element).getQualifiedName(); CharSequence targetFqcn = element.getQualifiedName(); - - String serviceName = - isAnnotatedWithService - ? serviceAnnotation.name() - : isAnnotatedWithVirtualObject - ? virtualObjectAnnotation.name() - : workflowAnnotation.name(); - if (serviceName.isEmpty()) { + String serviceName = metaAnnotation.resolveName(metaAnnotationMirror); + if (serviceName == null || serviceName.isEmpty()) { // Use simple class name, flattening subclasses names serviceName = targetFqcn.toString().substring(targetPkg.length()).replaceAll(Pattern.quote("."), ""); @@ -105,7 +85,9 @@ public Service fromTypeElement(TypeElement element) { || e.getAnnotation(Workflow.class) != null || e.getAnnotation(Exclusive.class) != null || e.getAnnotation(Shared.class) != null) - .map(e -> fromExecutableElement(type, ((ExecutableElement) e))) + .map( + e -> + fromExecutableElement(metaAnnotation.getServiceType(), ((ExecutableElement) e))) .collect(Collectors.toList()); if (handlers.isEmpty()) { @@ -118,7 +100,7 @@ public Service fromTypeElement(TypeElement element) { .withTargetPkg(targetPkg) .withTargetFqcn(targetFqcn) .withServiceName(serviceName) - .withServiceType(type) + .withServiceType(metaAnnotation.getServiceType()) .withHandlers(handlers) .validateAndBuild(); } catch (Exception e) { diff --git a/sdk-api-gen/src/main/java/dev/restate/sdk/gen/MetaRestateAnnotation.java b/sdk-api-gen/src/main/java/dev/restate/sdk/gen/MetaRestateAnnotation.java new file mode 100644 index 00000000..b3a24720 --- /dev/null +++ b/sdk-api-gen/src/main/java/dev/restate/sdk/gen/MetaRestateAnnotation.java @@ -0,0 +1,61 @@ +// 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.common.ServiceType; +import java.util.Map; +import javax.lang.model.element.*; +import org.jspecify.annotations.Nullable; + +class MetaRestateAnnotation { + + private final TypeElement annotation; + private final ServiceType serviceType; + + private MetaRestateAnnotation(TypeElement annotation, ServiceType serviceType) { + this.annotation = annotation; + this.serviceType = serviceType; + } + + TypeElement getAnnotationTypeElement() { + return annotation; + } + + ServiceType getServiceType() { + return serviceType; + } + + @Nullable String resolveName(AnnotationMirror mirror) { + for (Map.Entry entry : + mirror.getElementValues().entrySet()) { + if (entry.getKey().getSimpleName().contentEquals("name")) { + // Got a match, this is the name of the service + return (String) entry.getValue().getValue(); + } + } + // No name parameter found! + return null; + } + + static @Nullable MetaRestateAnnotation metaRestateAnnotationOrNull(TypeElement elem) { + if (elem.getQualifiedName().toString().equals("dev.restate.sdk.annotation.Service") + || elem.getAnnotation(dev.restate.sdk.annotation.Service.class) != null) { + return new MetaRestateAnnotation(elem, ServiceType.SERVICE); + } + if (elem.getQualifiedName().toString().equals("dev.restate.sdk.annotation.VirtualObject") + || elem.getAnnotation(dev.restate.sdk.annotation.VirtualObject.class) != null) { + return new MetaRestateAnnotation(elem, ServiceType.VIRTUAL_OBJECT); + } + if (elem.getQualifiedName().toString().equals("dev.restate.sdk.annotation.Workflow") + || elem.getAnnotation(dev.restate.sdk.annotation.Workflow.class) != null) { + return new MetaRestateAnnotation(elem, ServiceType.WORKFLOW); + } + return null; + } +} diff --git a/sdk-api-gen/src/main/java/dev/restate/sdk/gen/ServiceProcessor.java b/sdk-api-gen/src/main/java/dev/restate/sdk/gen/ServiceProcessor.java index ae80c3ec..ab0986e7 100644 --- a/sdk-api-gen/src/main/java/dev/restate/sdk/gen/ServiceProcessor.java +++ b/sdk-api-gen/src/main/java/dev/restate/sdk/gen/ServiceProcessor.java @@ -27,11 +27,7 @@ import javax.tools.FileObject; import javax.tools.StandardLocation; -@SupportedAnnotationTypes({ - "dev.restate.sdk.annotation.Service", - "dev.restate.sdk.annotation.Workflow", - "dev.restate.sdk.annotation.VirtualObject" -}) +@SupportedAnnotationTypes("*") @SupportedSourceVersion(SourceVersion.RELEASE_11) public class ServiceProcessor extends AbstractProcessor { @@ -97,9 +93,19 @@ public boolean process(Set annotations, RoundEnvironment // Parsing phase List> parsedServices = annotations.stream() - .flatMap(annotation -> roundEnv.getElementsAnnotatedWith(annotation).stream()) - .filter(e -> e.getKind().isClass() || e.getKind().isInterface()) - .map(e -> Map.entry((Element) e, converter.fromTypeElement((TypeElement) e))) + .map(MetaRestateAnnotation::metaRestateAnnotationOrNull) + .filter(Objects::nonNull) + .flatMap( + metaAnnotation -> + roundEnv + .getElementsAnnotatedWith(metaAnnotation.getAnnotationTypeElement()) + .stream() + .filter(e -> e.getKind().isClass() || e.getKind().isInterface()) + .map( + e -> + Map.entry( + (Element) e, + converter.fromTypeElement(metaAnnotation, (TypeElement) e)))) .collect(Collectors.toList()); Filer filer = processingEnv.getFiler(); diff --git a/sdk-common/src/main/java/dev/restate/sdk/annotation/Service.java b/sdk-common/src/main/java/dev/restate/sdk/annotation/Service.java index 2e5d721b..327dbd86 100644 --- a/sdk-common/src/main/java/dev/restate/sdk/annotation/Service.java +++ b/sdk-common/src/main/java/dev/restate/sdk/annotation/Service.java @@ -8,10 +8,7 @@ // https://github.com/restatedev/sdk-java/blob/main/LICENSE package dev.restate.sdk.annotation; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; /** * Annotation to define a class/interface as Restate Service. This triggers the code generation of @@ -19,7 +16,8 @@ * dev.restate.sdk.common.syscalls.ServiceDefinitionFactory}. */ @Target(ElementType.TYPE) -@Retention(RetentionPolicy.SOURCE) +@Retention(RetentionPolicy.RUNTIME) +@Documented public @interface Service { /** * Name of the Service for Restate. If not provided, it will be the simple class name of the diff --git a/sdk-common/src/main/java/dev/restate/sdk/annotation/VirtualObject.java b/sdk-common/src/main/java/dev/restate/sdk/annotation/VirtualObject.java index 85f446d8..e1c1cb60 100644 --- a/sdk-common/src/main/java/dev/restate/sdk/annotation/VirtualObject.java +++ b/sdk-common/src/main/java/dev/restate/sdk/annotation/VirtualObject.java @@ -8,10 +8,7 @@ // https://github.com/restatedev/sdk-java/blob/main/LICENSE package dev.restate.sdk.annotation; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; /** * Annotation to define a class/interface as Restate VirtualObject. This triggers the code @@ -19,7 +16,8 @@ * dev.restate.sdk.common.syscalls.ServiceDefinitionFactory}. */ @Target(ElementType.TYPE) -@Retention(RetentionPolicy.SOURCE) +@Retention(RetentionPolicy.RUNTIME) +@Documented public @interface VirtualObject { /** * Name of the VirtualObject for Restate. If not provided, it will be the simple class name of the diff --git a/sdk-common/src/main/java/dev/restate/sdk/annotation/Workflow.java b/sdk-common/src/main/java/dev/restate/sdk/annotation/Workflow.java index 087f6254..cb20899f 100644 --- a/sdk-common/src/main/java/dev/restate/sdk/annotation/Workflow.java +++ b/sdk-common/src/main/java/dev/restate/sdk/annotation/Workflow.java @@ -8,10 +8,7 @@ // https://github.com/restatedev/sdk-java/blob/main/LICENSE package dev.restate.sdk.annotation; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; /** * Annotation to define a class/interface as Restate Workflow. This triggers the code generation of @@ -20,7 +17,8 @@ * workflow, you must annotate one of its methods too as {@link Workflow}. */ @Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.SOURCE) +@Retention(RetentionPolicy.RUNTIME) +@Documented public @interface Workflow { /** * Name of the Workflow for Restate. If not provided, it will be the simple class name of the diff --git a/sdk-spring-boot-starter/build.gradle.kts b/sdk-spring-boot-starter/build.gradle.kts index aa8d0d69..5b5bfa6a 100644 --- a/sdk-spring-boot-starter/build.gradle.kts +++ b/sdk-spring-boot-starter/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { testAnnotationProcessor(project(":sdk-api-gen")) testImplementation(project(":sdk-serde-jackson")) + testImplementation(project(":sdk-testing")) testImplementation("org.springframework.boot:spring-boot-starter-test:$springBootVersion") } diff --git a/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateService.java b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateService.java new file mode 100644 index 00000000..4c3b0148 --- /dev/null +++ b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateService.java @@ -0,0 +1,32 @@ +// 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.springboot; + +import dev.restate.sdk.annotation.Service; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Proxy annotation for {@link Service}, to avoid naming clashes with Spring's built in annotations + * + * @see Service + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Service +@RestateComponent +public @interface RestateService { + /** + * Name of the Service for Restate. If not provided, it will be the simple class name of the + * annotated element. + */ + String name() default ""; +} diff --git a/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateVirtualObject.java b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateVirtualObject.java new file mode 100644 index 00000000..6005a7b6 --- /dev/null +++ b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateVirtualObject.java @@ -0,0 +1,33 @@ +// 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.springboot; + +import dev.restate.sdk.annotation.Service; +import dev.restate.sdk.annotation.VirtualObject; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Proxy annotation for {@link VirtualObject}. + * + * @see Service + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@VirtualObject +@RestateComponent +public @interface RestateVirtualObject { + /** + * Name of the VirtualObject for Restate. If not provided, it will be the simple class name of the + * annotated element. + */ + String name() default ""; +} diff --git a/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateWorkflow.java b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateWorkflow.java new file mode 100644 index 00000000..f1564a30 --- /dev/null +++ b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateWorkflow.java @@ -0,0 +1,33 @@ +// 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.springboot; + +import dev.restate.sdk.annotation.Service; +import dev.restate.sdk.annotation.Workflow; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Proxy annotation for {@link Workflow}. + * + * @see Service + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Workflow +@RestateComponent +public @interface RestateWorkflow { + /** + * Name of the Workflow for Restate. If not provided, it will be the simple class name of the + * annotated element. + */ + String name() default ""; +} diff --git a/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/Greeter.java b/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/Greeter.java index 9ccc7534..1f075ffd 100644 --- a/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/Greeter.java +++ b/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/Greeter.java @@ -10,11 +10,9 @@ import dev.restate.sdk.Context; import dev.restate.sdk.annotation.Handler; -import dev.restate.sdk.annotation.Service; import org.springframework.beans.factory.annotation.Value; -@RestateComponent -@Service +@RestateService(name = "greeter") public class Greeter { @Value("${greetingPrefix}") diff --git a/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/RestateHttpEndpointBeanTest.java b/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/RestateHttpEndpointBeanTest.java index 741a93ca..0e48e694 100644 --- a/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/RestateHttpEndpointBeanTest.java +++ b/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/RestateHttpEndpointBeanTest.java @@ -52,6 +52,6 @@ public void httpEndpointShouldBeRunning() throws IOException, InterruptedExcepti assertThat(endpointManifest.getServices()) .map(dev.restate.sdk.core.manifest.Service::getName) - .containsOnly("Greeter"); + .containsOnly("greeter"); } } diff --git a/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/SdkTestingIntegrationTest.java b/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/SdkTestingIntegrationTest.java new file mode 100644 index 00000000..b08c70ec --- /dev/null +++ b/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/SdkTestingIntegrationTest.java @@ -0,0 +1,35 @@ +// 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.springboot; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.restate.sdk.client.Client; +import dev.restate.sdk.testing.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest( + classes = Greeter.class, + properties = {"greetingPrefix=Something something "}) +@RestateTest(restateContainerImage = "ghcr.io/restatedev/restate:main") +public class SdkTestingIntegrationTest { + + @Autowired @BindService private Greeter greeter; + + @Test + @Timeout(value = 10) + void greet(@RestateClient Client ingressClient) { + var client = greeterClient.fromClient(ingressClient); + + assertThat(client.greet("Francesco")).isEqualTo("Something something Francesco"); + } +} diff --git a/sdk-testing/src/main/java/dev/restate/sdk/testing/BaseRestateRunner.java b/sdk-testing/src/main/java/dev/restate/sdk/testing/BaseRestateRunner.java deleted file mode 100644 index 1e1d2337..00000000 --- a/sdk-testing/src/main/java/dev/restate/sdk/testing/BaseRestateRunner.java +++ /dev/null @@ -1,64 +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.testing; - -import dev.restate.admin.client.ApiClient; -import dev.restate.sdk.client.Client; -import java.net.URL; -import org.junit.jupiter.api.extension.ExtensionContext; -import org.junit.jupiter.api.extension.ExtensionContext.Namespace; -import org.junit.jupiter.api.extension.ParameterContext; -import org.junit.jupiter.api.extension.ParameterResolutionException; -import org.junit.jupiter.api.extension.ParameterResolver; - -abstract class BaseRestateRunner implements ParameterResolver { - - static final Namespace NAMESPACE = Namespace.create(BaseRestateRunner.class); - static final String DEPLOYER_KEY = "Deployer"; - - @Override - public boolean supportsParameter( - ParameterContext parameterContext, ExtensionContext extensionContext) - throws ParameterResolutionException { - return (parameterContext.isAnnotated(RestateAdminClient.class) - && ApiClient.class.isAssignableFrom(parameterContext.getParameter().getType())) - || (parameterContext.isAnnotated(RestateClient.class) - && Client.class.isAssignableFrom(parameterContext.getParameter().getType())) - || (parameterContext.isAnnotated(RestateURL.class) - && (String.class.isAssignableFrom(parameterContext.getParameter().getType()) - || URL.class.isAssignableFrom(parameterContext.getParameter().getType()))); - } - - @Override - public Object resolveParameter( - ParameterContext parameterContext, ExtensionContext extensionContext) - throws ParameterResolutionException { - if (parameterContext.isAnnotated(RestateAdminClient.class)) { - return getDeployer(extensionContext).getAdminClient(); - } else if (parameterContext.isAnnotated(RestateClient.class)) { - return resolveClient(extensionContext); - } else if (parameterContext.isAnnotated(RestateURL.class)) { - URL url = getDeployer(extensionContext).getIngressUrl(); - if (parameterContext.getParameter().getType().equals(String.class)) { - return url.toString(); - } - return url; - } - throw new ParameterResolutionException("The parameter is not supported"); - } - - private Client resolveClient(ExtensionContext extensionContext) { - URL url = getDeployer(extensionContext).getIngressUrl(); - return Client.connect(url.toString()); - } - - private ManualRestateRunner getDeployer(ExtensionContext extensionContext) { - return (ManualRestateRunner) extensionContext.getStore(NAMESPACE).get(DEPLOYER_KEY); - } -} diff --git a/sdk-testing/src/main/java/dev/restate/sdk/testing/BindService.java b/sdk-testing/src/main/java/dev/restate/sdk/testing/BindService.java new file mode 100644 index 00000000..83ac52ca --- /dev/null +++ b/sdk-testing/src/main/java/dev/restate/sdk/testing/BindService.java @@ -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.testing; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * @see RestateTest + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface BindService {} diff --git a/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateExtension.java b/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateExtension.java new file mode 100644 index 00000000..e1a8a80a --- /dev/null +++ b/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateExtension.java @@ -0,0 +1,80 @@ +// 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.testing; + +import java.util.List; +import org.junit.jupiter.api.extension.*; +import org.junit.platform.commons.support.AnnotationSupport; + +/** + * @see RestateTest + */ +public class RestateExtension implements BeforeAllCallback, ParameterResolver { + + static final ExtensionContext.Namespace NAMESPACE = + ExtensionContext.Namespace.create(RestateExtension.class); + static final String RUNNER = "Runner"; + + @Override + public void beforeAll(ExtensionContext extensionContext) { + extensionContext + .getStore(NAMESPACE) + .getOrComputeIfAbsent( + RUNNER, ignored -> initializeRestateRunner(extensionContext), RestateRunner.class) + .beforeAll(extensionContext); + } + + @Override + public boolean supportsParameter( + ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + return extensionContext + .getStore(NAMESPACE) + .getOrComputeIfAbsent( + RUNNER, ignored -> initializeRestateRunner(extensionContext), RestateRunner.class) + .supportsParameter(parameterContext, extensionContext); + } + + @Override + public Object resolveParameter( + ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + return extensionContext + .getStore(NAMESPACE) + .getOrComputeIfAbsent( + RUNNER, ignored -> initializeRestateRunner(extensionContext), RestateRunner.class) + .resolveParameter(parameterContext, extensionContext); + } + + private RestateRunner initializeRestateRunner(ExtensionContext extensionContext) { + // Discover services + List servicesToBind = + AnnotationSupport.findAnnotatedFieldValues( + extensionContext.getRequiredTestInstance(), BindService.class); + if (servicesToBind.isEmpty()) { + throw new IllegalStateException( + "The class " + + extensionContext.getRequiredTestClass().getName() + + " is annotated with @RestateTest, but there are no fields annotated with @BindService"); + } + + RestateTest testAnnotation = + AnnotationSupport.findAnnotation(extensionContext.getRequiredTestClass(), RestateTest.class) + .orElseThrow( + () -> + new IllegalStateException( + "Expecting @RestateTest annotation on the test class")); + + // Build runner discovering services to bind + var runnerBuilder = RestateRunnerBuilder.create(); + servicesToBind.forEach(runnerBuilder::bind); + runnerBuilder.withRestateContainerImage(testAnnotation.restateContainerImage()); + return runnerBuilder.buildRunner(); + } +} diff --git a/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateRunner.java b/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateRunner.java index e70594a5..4e9eb1ab 100644 --- a/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateRunner.java +++ b/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateRunner.java @@ -8,8 +8,11 @@ // https://github.com/restatedev/sdk-java/blob/main/LICENSE package dev.restate.sdk.testing; -import org.junit.jupiter.api.extension.BeforeAllCallback; -import org.junit.jupiter.api.extension.ExtensionContext; +import dev.restate.sdk.client.Client; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import org.junit.jupiter.api.extension.*; /** * Restate runner for JUnit 5. Example: @@ -39,8 +42,16 @@ * long response = client.get(); * assertThat(response).isEqualTo(0L); * } + * + * @deprecated We now recommend using {@link RestateTest}. */ -public class RestateRunner extends BaseRestateRunner implements BeforeAllCallback { +@Deprecated +public class RestateRunner implements BeforeAllCallback, ParameterResolver { + + static final ExtensionContext.Namespace NAMESPACE = + ExtensionContext.Namespace.create(RestateRunner.class); + static final String DEPLOYER_KEY = "Deployer"; + private final ManualRestateRunner deployer; RestateRunner(ManualRestateRunner deployer) { @@ -52,4 +63,56 @@ public void beforeAll(ExtensionContext context) { deployer.start(); context.getStore(NAMESPACE).put(DEPLOYER_KEY, deployer); } + + @Override + public boolean supportsParameter( + ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + return supportsParameter(parameterContext); + } + + static boolean supportsParameter(ParameterContext parameterContext) { + return (parameterContext.isAnnotated(RestateAdminClient.class) + && dev.restate.admin.client.ApiClient.class.isAssignableFrom( + parameterContext.getParameter().getType())) + || (parameterContext.isAnnotated(RestateClient.class) + && Client.class.isAssignableFrom(parameterContext.getParameter().getType())) + || (parameterContext.isAnnotated(RestateURL.class) + && (String.class.isAssignableFrom(parameterContext.getParameter().getType()) + || URL.class.isAssignableFrom(parameterContext.getParameter().getType()))); + } + + @Override + public Object resolveParameter( + ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + if (parameterContext.isAnnotated(RestateAdminClient.class)) { + return getDeployer(extensionContext).getAdminClient(); + } else if (parameterContext.isAnnotated(RestateClient.class)) { + return resolveClient(extensionContext); + } else if (parameterContext.isAnnotated(RestateURL.class)) { + URL url = getDeployer(extensionContext).getIngressUrl(); + if (parameterContext.getParameter().getType().equals(String.class)) { + return url.toString(); + } + if (parameterContext.getParameter().getType().equals(URI.class)) { + try { + return url.toURI(); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + return url; + } + throw new ParameterResolutionException("The parameter is not supported"); + } + + private Client resolveClient(ExtensionContext extensionContext) { + URL url = getDeployer(extensionContext).getIngressUrl(); + return Client.connect(url.toString()); + } + + private ManualRestateRunner getDeployer(ExtensionContext extensionContext) { + return (ManualRestateRunner) extensionContext.getStore(NAMESPACE).get(DEPLOYER_KEY); + } } diff --git a/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateRunnerBuilder.java b/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateRunnerBuilder.java index 126943b4..e2e21be6 100644 --- a/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateRunnerBuilder.java +++ b/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateRunnerBuilder.java @@ -20,7 +20,7 @@ */ public class RestateRunnerBuilder { - private static final String DEFAULT_RESTATE_CONTAINER = "docker.io/restatedev/restate"; + private static final String DEFAULT_RESTATE_CONTAINER = "docker.io/restatedev/restate:latest"; private final RestateHttpEndpointBuilder endpointBuilder; private String restateContainerImage = DEFAULT_RESTATE_CONTAINER; private final Map additionalEnv = new HashMap<>(); @@ -90,7 +90,9 @@ public ManualRestateRunner buildManualRunner() { /** * @return a {@link RestateRunner} to be used as JUnit 5 Extension. + * @deprecated If you use JUnit 5, use {@link RestateTest} */ + @Deprecated public RestateRunner buildRunner() { return new RestateRunner(this.buildManualRunner()); } diff --git a/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateTest.java b/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateTest.java new file mode 100644 index 00000000..0f13f761 --- /dev/null +++ b/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateTest.java @@ -0,0 +1,71 @@ +// 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.testing; + +import java.lang.annotation.*; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Annotation to enable the Restate extension for JUnit 5. The annotation will bootstrap a Restate + * environment using TestContainers, and will automatically register all services field of the class + * annotated with {@link BindService}. + * + *

Example: + * + *

+ * // Annotate the class as RestateTest to start a Restate environment
+ * {@code @RestateTest}
+ * class CounterTest {
+ *
+ *   // Annotate the service to bind
+ *   {@code @BindService} private final Counter counter = new Counter();
+ *
+ *   // Inject the client to send requests
+ *   {@code @Test}
+ *   void testGreet({@code @RestateClient} Client ingressClient) {
+ *     var client = CounterClient.fromClient(ingressClient, "my-counter");
+ *
+ *     long response = client.get();
+ *     assertThat(response).isEqualTo(0L);
+ *   }
+ * }
+ * 
+ * + *

The runner will deploy the services locally, execute Restate as container using Testcontainers, and register the services. + * + *

This extension is scoped per test class, meaning that the restate runner will be shared among + * test methods. Because of the aforementioned issue, the extension sets the {@link TestInstance} + * {@link TestInstance.Lifecycle#PER_CLASS} automatically. + * + *

Use the annotations {@link RestateClient}, {@link RestateURL} and {@link RestateAdminClient} + * to interact with the deployed environment: + * + *

+ * {@code @Test}
+ * void initialCountIsZero({@code @RestateClient} Client client) {
+ *     var client = CounterClient.fromClient(ingressClient, "my-counter");
+ *
+ *     // Use client as usual
+ *     long response = client.get();
+ *     assertThat(response).isEqualTo(0L);
+ * }
+ */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@ExtendWith(RestateExtension.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public @interface RestateTest { + + /** Restate container image to use */ + String restateContainerImage() default "docker.io/restatedev/restate:latest"; +} diff --git a/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateURL.java b/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateURL.java index 60e7f8e6..b10dbd51 100644 --- a/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateURL.java +++ b/sdk-testing/src/main/java/dev/restate/sdk/testing/RestateURL.java @@ -12,10 +12,12 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.net.URI; import java.net.URL; /** - * Inject Restate's URL (either {@link String} or {@link URL}) to interact with the deployed server. + * Inject Restate's URL (either {@link String} or {@link URL} or {@link URI}) to interact with the + * deployed server. */ @Target(value = ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) diff --git a/sdk-testing/src/test/java/dev/restate/sdk/testing/CounterOldExtensionTest.java b/sdk-testing/src/test/java/dev/restate/sdk/testing/CounterOldExtensionTest.java new file mode 100644 index 00000000..b50b8130 --- /dev/null +++ b/sdk-testing/src/test/java/dev/restate/sdk/testing/CounterOldExtensionTest.java @@ -0,0 +1,36 @@ +// 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.testing; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.restate.sdk.client.Client; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.extension.RegisterExtension; + +class CounterOldExtensionTest { + + @RegisterExtension + private static final RestateRunner RESTATE_RUNNER = + RestateRunnerBuilder.create() + .withRestateContainerImage( + "ghcr.io/restatedev/restate:main") // test against the latest main Restate image + .bind(new Counter()) + .buildRunner(); + + @Test + @Timeout(value = 10) + void testGreet(@RestateClient Client ingressClient) { + var client = CounterClient.fromClient(ingressClient, "my-counter"); + + long response = client.get(); + assertThat(response).isEqualTo(0L); + } +} diff --git a/sdk-testing/src/test/java/dev/restate/sdk/testing/CounterTest.java b/sdk-testing/src/test/java/dev/restate/sdk/testing/CounterTest.java index 42acd73b..d1d182b1 100644 --- a/sdk-testing/src/test/java/dev/restate/sdk/testing/CounterTest.java +++ b/sdk-testing/src/test/java/dev/restate/sdk/testing/CounterTest.java @@ -13,17 +13,11 @@ import dev.restate.sdk.client.Client; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; -import org.junit.jupiter.api.extension.RegisterExtension; +@RestateTest(restateContainerImage = "ghcr.io/restatedev/restate:main") class CounterTest { - @RegisterExtension - private static final RestateRunner RESTATE_RUNNER = - RestateRunnerBuilder.create() - .withRestateContainerImage( - "ghcr.io/restatedev/restate:main") // test against the latest main Restate image - .bind(new Counter()) - .buildRunner(); + @BindService private final Counter counter = new Counter(); @Test @Timeout(value = 10) diff --git a/sdk-testing/src/test/proto/counter.proto b/sdk-testing/src/test/proto/counter.proto deleted file mode 100644 index 065e1ac2..00000000 --- a/sdk-testing/src/test/proto/counter.proto +++ /dev/null @@ -1,45 +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 -syntax = "proto3"; - -package counter; - -import "google/protobuf/empty.proto"; -import "dev/restate/ext.proto"; - -option java_multiple_files = true; -option java_package = "dev.restate.sdk.examples.generated"; -option java_outer_classname = "CounterProto"; - -service Counter { - option (dev.restate.ext.service_type) = KEYED; - - rpc Reset (CounterRequest) returns (google.protobuf.Empty); - rpc Add (CounterAddRequest) returns (google.protobuf.Empty); - rpc Get (CounterRequest) returns (GetResponse); - rpc GetAndAdd (CounterAddRequest) returns (CounterUpdateResult); -} - -message CounterRequest { - string counter_name = 1 [(dev.restate.ext.field) = KEY]; -} - -message CounterAddRequest { - string counter_name = 1 [(dev.restate.ext.field) = KEY]; - int64 value = 2; -} - -message GetResponse { - int64 value = 1; -} - -message CounterUpdateResult { - int64 old_value = 1; - int64 new_value = 2; -} \ No newline at end of file