diff --git a/sdk-spring-boot-starter/build.gradle.kts b/sdk-spring-boot-starter/build.gradle.kts new file mode 100644 index 00000000..aa8d0d69 --- /dev/null +++ b/sdk-spring-boot-starter/build.gradle.kts @@ -0,0 +1,74 @@ +plugins { + `java-conventions` + `java-library` + `test-jar-conventions` + `library-publishing-conventions` + id("io.spring.dependency-management") version "1.1.6" +} + +description = "Restate SDK Spring Boot starter" + +val springBootVersion = "3.3.5" + +java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } + +dependencies { + compileOnly(coreLibs.jspecify) + + api(project(":sdk-common")) { + // Let spring bring jackson in + exclude(group = "com.fasterxml.jackson") + exclude(group = "com.fasterxml.jackson.core") + exclude(group = "com.fasterxml.jackson.datatype") + } + api(project(":sdk-api")) { + // Let spring bring jackson in + exclude(group = "com.fasterxml.jackson") + exclude(group = "com.fasterxml.jackson.core") + exclude(group = "com.fasterxml.jackson.datatype") + } + api(project(":sdk-serde-jackson")) { + // Let spring bring jackson in + exclude(group = "com.fasterxml.jackson") + exclude(group = "com.fasterxml.jackson.core") + exclude(group = "com.fasterxml.jackson.datatype") + } + + implementation(project(":sdk-http-vertx")) { + // Let spring bring jackson in + exclude(group = "com.fasterxml.jackson") + exclude(group = "com.fasterxml.jackson.core") + exclude(group = "com.fasterxml.jackson.datatype") + } + implementation(project(":sdk-request-identity")) + implementation(platform(vertxLibs.vertx.bom)) { + // Let spring bring jackson in + exclude(group = "com.fasterxml.jackson") + exclude(group = "com.fasterxml.jackson.core") + exclude(group = "com.fasterxml.jackson.datatype") + } + implementation(vertxLibs.vertx.core) { + // Let spring bring jackson in + exclude(group = "com.fasterxml.jackson") + exclude(group = "com.fasterxml.jackson.core") + exclude(group = "com.fasterxml.jackson.datatype") + } + + implementation("org.springframework.boot:spring-boot-starter:$springBootVersion") + + // Spring is going to bring jackson in with this + implementation("org.springframework.boot:spring-boot-starter-json:$springBootVersion") + + // We need these for the deployment manifest + testImplementation(project(":sdk-core")) + testImplementation(platform(jacksonLibs.jackson.bom)) + testImplementation(jacksonLibs.jackson.annotations) + testImplementation(jacksonLibs.jackson.databind) + + testAnnotationProcessor(project(":sdk-api-gen")) + testImplementation(project(":sdk-serde-jackson")) + + testImplementation("org.springframework.boot:spring-boot-starter-test:$springBootVersion") +} + +tasks.withType { options.compilerArgs.add("-parameters") } diff --git a/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/EnableRestate.java b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/EnableRestate.java new file mode 100644 index 00000000..b9534f75 --- /dev/null +++ b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/EnableRestate.java @@ -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.springboot; + +import java.lang.annotation.*; +import org.springframework.context.annotation.Import; + +/** + * Add this annotation in your application class next to the {@link + * org.springframework.boot.autoconfigure.SpringBootApplication} annotation to enable the Restate + * Spring features. + * + * @see RestateComponent + * @see RestateClientAutoConfiguration + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Import({RestateHttpEndpointBean.class, RestateClientAutoConfiguration.class}) +public @interface EnableRestate {} diff --git a/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateClientAutoConfiguration.java b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateClientAutoConfiguration.java new file mode 100644 index 00000000..93a5b310 --- /dev/null +++ b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateClientAutoConfiguration.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 dev.restate.sdk.client.Client; +import java.util.Collections; +import java.util.Map; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for Restate's {@link Client}, to send requests to Restate services. + * + * @see RestateClientProperties + */ +@Configuration +@EnableConfigurationProperties(RestateClientProperties.class) +public class RestateClientAutoConfiguration { + + @Bean + public Client client(RestateClientProperties restateClientProperties) { + Map headers = restateClientProperties.getHeaders(); + if (headers == null) { + headers = Collections.emptyMap(); + } + return Client.connect(restateClientProperties.getBaseUri(), headers); + } +} diff --git a/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateClientProperties.java b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateClientProperties.java new file mode 100644 index 00000000..a678d0fc --- /dev/null +++ b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateClientProperties.java @@ -0,0 +1,38 @@ +// 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 java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; +import org.springframework.boot.context.properties.bind.DefaultValue; + +@ConfigurationProperties(prefix = "restate.client") +public class RestateClientProperties { + + private final String baseUri; + private final Map headers; + + @ConstructorBinding + public RestateClientProperties( + @DefaultValue(value = "http://localhost:8080") String baseUri, Map headers) { + this.baseUri = baseUri; + this.headers = headers; + } + + /** Base uri of the Restate client, e.g. {@code http://localhost:8080}. */ + public String getBaseUri() { + return baseUri; + } + + /** Headers added to each request sent to Restate. */ + public Map getHeaders() { + return headers; + } +} diff --git a/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateComponent.java b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateComponent.java new file mode 100644 index 00000000..2b80e0a5 --- /dev/null +++ b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateComponent.java @@ -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.springboot; + +import java.lang.annotation.*; +import org.springframework.stereotype.Component; + +/** + * Add this annotation to a class annotated with Restate's {@link + * dev.restate.sdk.annotation.Service} or {@link dev.restate.sdk.annotation.VirtualObject} or {@link + * dev.restate.sdk.annotation.Workflow} to bind them to the Restate HTTP Endpoint. + * + *

You can configure the Restate HTTP Endpoint using {@link RestateEndpointProperties} and {@link + * RestateEndpointHttpServerProperties}. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface RestateComponent {} diff --git a/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateEndpointHttpServerProperties.java b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateEndpointHttpServerProperties.java new file mode 100644 index 00000000..5ef97d30 --- /dev/null +++ b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateEndpointHttpServerProperties.java @@ -0,0 +1,30 @@ +// 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 org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; +import org.springframework.boot.context.properties.bind.DefaultValue; +import org.springframework.boot.context.properties.bind.Name; + +@ConfigurationProperties(prefix = "restate.sdk.http") +public class RestateEndpointHttpServerProperties { + + private final int port; + + @ConstructorBinding + public RestateEndpointHttpServerProperties(@Name("port") @DefaultValue(value = "9080") int port) { + this.port = port; + } + + /** Port to expose the HTTP server. */ + public int getPort() { + return port; + } +} diff --git a/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateEndpointProperties.java b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateEndpointProperties.java new file mode 100644 index 00000000..30b2b0be --- /dev/null +++ b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateEndpointProperties.java @@ -0,0 +1,43 @@ +// 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.auth.RequestIdentityVerifier; +import dev.restate.sdk.http.vertx.RestateHttpEndpointBuilder; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; +import org.springframework.boot.context.properties.bind.DefaultValue; + +@ConfigurationProperties(prefix = "restate.sdk") +public class RestateEndpointProperties { + + private final boolean enablePreviewContext; + private final String identityKey; + + @ConstructorBinding + public RestateEndpointProperties( + @DefaultValue(value = "false") boolean enablePreviewContext, String identityKey) { + this.enablePreviewContext = enablePreviewContext; + this.identityKey = identityKey; + } + + /** + * @see RestateHttpEndpointBuilder#enablePreviewContext() + */ + public boolean isEnablePreviewContext() { + return enablePreviewContext; + } + + /** + * @see RestateHttpEndpointBuilder#withRequestIdentityVerifier(RequestIdentityVerifier) + */ + public String getIdentityKey() { + return identityKey; + } +} diff --git a/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateHttpEndpointBean.java b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateHttpEndpointBean.java new file mode 100644 index 00000000..e10de70a --- /dev/null +++ b/sdk-spring-boot-starter/src/main/java/dev/restate/sdk/springboot/RestateHttpEndpointBean.java @@ -0,0 +1,126 @@ +// 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.auth.signing.RestateRequestIdentityVerifier; +import dev.restate.sdk.http.vertx.RestateHttpEndpointBuilder; +import io.vertx.core.http.HttpServer; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.SmartLifecycle; +import org.springframework.stereotype.Component; + +/** + * Restate HTTP Endpoint serving {@link RestateComponent}. + * + * @see Component + */ +@Component +@EnableConfigurationProperties({ + RestateEndpointHttpServerProperties.class, + RestateEndpointProperties.class +}) +public class RestateHttpEndpointBean implements InitializingBean, SmartLifecycle { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final ApplicationContext applicationContext; + private final RestateEndpointProperties restateEndpointProperties; + private final RestateEndpointHttpServerProperties restateEndpointHttpServerProperties; + + private volatile boolean running; + + private HttpServer server; + + public RestateHttpEndpointBean( + ApplicationContext applicationContext, + RestateEndpointProperties restateEndpointProperties, + RestateEndpointHttpServerProperties restateEndpointHttpServerProperties) { + this.applicationContext = applicationContext; + this.restateEndpointProperties = restateEndpointProperties; + this.restateEndpointHttpServerProperties = restateEndpointHttpServerProperties; + } + + @Override + public void afterPropertiesSet() { + Map restateComponents = + applicationContext.getBeansWithAnnotation(RestateComponent.class); + + if (restateComponents.isEmpty()) { + logger.info("No @RestateComponent discovered"); + // Don't start anything, if no service is registered + return; + } + + var builder = RestateHttpEndpointBuilder.builder(); + for (Object component : restateComponents.values()) { + builder = builder.bind(component); + } + + if (restateEndpointProperties.isEnablePreviewContext()) { + builder = builder.enablePreviewContext(); + } + + if (restateEndpointProperties.getIdentityKey() != null) { + builder.withRequestIdentityVerifier( + RestateRequestIdentityVerifier.fromKey(restateEndpointProperties.getIdentityKey())); + } + + this.server = builder.build(); + } + + @Override + public void start() { + if (this.server != null) { + try { + this.server + .listen(this.restateEndpointHttpServerProperties.getPort()) + .toCompletionStage() + .toCompletableFuture() + .get(); + logger.info("Started Restate Spring HTTP server on port {}", this.server.actualPort()); + } catch (Exception e) { + logger.error( + "Error when starting Restate Spring HTTP server on port {}", + this.restateEndpointHttpServerProperties.getPort(), + e); + } + this.running = true; + } + } + + @Override + public void stop() { + if (this.server != null) { + try { + this.server.close().toCompletionStage().toCompletableFuture().get(); + logger.info("Stopped Restate Spring HTTP server"); + } catch (Exception e) { + logger.error("Error when stopping the Restate Spring HTTP server", e); + } + this.running = false; + } + } + + @Override + public boolean isRunning() { + return this.running; + } + + public int actualPort() { + if (this.server == null) { + return -1; + } + return this.server.actualPort(); + } +} 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 new file mode 100644 index 00000000..9ccc7534 --- /dev/null +++ b/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/Greeter.java @@ -0,0 +1,27 @@ +// 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.Context; +import dev.restate.sdk.annotation.Handler; +import dev.restate.sdk.annotation.Service; +import org.springframework.beans.factory.annotation.Value; + +@RestateComponent +@Service +public class Greeter { + + @Value("${greetingPrefix}") + private String greetingPrefix; + + @Handler + public String greet(Context ctx, String person) { + return greetingPrefix + person; + } +} diff --git a/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/RestateClientAutoConfigurationTest.java b/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/RestateClientAutoConfigurationTest.java new file mode 100644 index 00000000..101b4cbd --- /dev/null +++ b/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/RestateClientAutoConfigurationTest.java @@ -0,0 +1,29 @@ +// 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 org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest( + classes = RestateClientAutoConfiguration.class, + properties = {"restate.client.base-uri=http://localhost:10000"}) +public class RestateClientAutoConfigurationTest { + + @Autowired private Client client; + + @Test + public void clientShouldNotBeNull() { + assertThat(client).isNotNull(); + } +} 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 new file mode 100644 index 00000000..741a93ca --- /dev/null +++ b/sdk-spring-boot-starter/src/test/java/dev/restate/sdk/springboot/RestateHttpEndpointBeanTest.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.springboot; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.restate.sdk.core.manifest.EndpointManifestSchema; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest( + classes = {RestateHttpEndpointBean.class, Greeter.class}, + properties = {"restate.sdk.http.port=0"}) +public class RestateHttpEndpointBeanTest { + + @Autowired private RestateHttpEndpointBean restateHttpEndpointBean; + + @Test + public void httpEndpointShouldBeRunning() throws IOException, InterruptedException { + assertThat(restateHttpEndpointBean.isRunning()).isTrue(); + assertThat(restateHttpEndpointBean.actualPort()).isPositive(); + + // Check if discovery replies containing the Greeter service + var client = HttpClient.newHttpClient(); + var response = + client.send( + HttpRequest.newBuilder() + .GET() + .uri( + URI.create( + "http://localhost:" + restateHttpEndpointBean.actualPort() + "/discover")) + .header("Accept", "application/vnd.restate.endpointmanifest.v1+json") + .build(), + HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(200); + + var endpointManifest = + new ObjectMapper().readValue(response.body(), EndpointManifestSchema.class); + + assertThat(endpointManifest.getServices()) + .map(dev.restate.sdk.core.manifest.Service::getName) + .containsOnly("Greeter"); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index b1986cf7..96e4a7c1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,6 +26,7 @@ include( "sdk-api-gen-common", "sdk-api-gen", "sdk-api-kotlin-gen", + "sdk-spring-boot-starter", "examples", "sdk-aggregated-javadocs", "test-services")