diff --git a/README.md b/README.md index 726098d..214faa8 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ - [Configuring Scala Jackson and the addon-on "Enum" module for JSON support](#configuring-scala-jackson-and-the-addon-on-enum-module-for-json-support) - [Scala DSL for rest-assured (similar to Kotlin DSL)](#scala-dsl-for-rest-assured-similar-to-kotlin-dsl) - [Functional HTTP routes (Vert.x handlers)](#functional-http-routes-vertx-handlers) +- [Quarkus - Scala3 - Futures](#quarkus---scala3---futures) ## Introduction @@ -133,7 +134,7 @@ In your `pom.xml` file, add: io.quarkiverse.scala quarkus-scala3 - 0.0.1 + 1.0.0 ``` @@ -440,6 +441,50 @@ def mkRoutes(router: Router) = }) ``` +# Quarkus - Scala3 - Futures + +# `Future[T]` and `Promise[T]` support in REST endpoints + +The `quarkus-scala3-futures` extension allows you to return `Future[T]` and `Promise[T]` from your REST endpoints. + +```scala + +@Path("/") +class GreetingResource + + @GET + @Path("/greet/future") + @Produces(Array(TEXT_PLAIN)) + def futureGreeting(): Future[String] = + Future.successful("Hello from the future") + end futureGreeting + + @GET + @Path("/greet/promise") + def promiseGreeting(): Promise[String] = + Promise.successful("Hello from the promise") + end promiseGreeting + +end GreetingResource +``` + +If the `Future[T]` or `Promise[T]` fails, the normal exception handling is invoked. + +Make sure to have the following dependency in your `pom.xml` to make it work: + +```xml + + io.quarkus + quarkus-rest + + + io.quarkiverse.scala + quarkus-scala3-futures + ${project.version} + +``` + + ## Contributors ✨ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): @@ -462,3 +507,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! + +## TODOs + - correctly generate OpenAPI Spec for methods returning Future[T] or Promise[T], e.g. similar to [Quarkus #8499](https://github.com/quarkusio/quarkus/issues/8499) + - ArC (Quarkus' CDI implementation) has special handling for CompletionStage[T], maybe we should add similar handling for Future[T] and Promise[T], see [ActiveRequestContextInterceptor](https://github.com/quarkusio/quarkus/blob/24d3e5262d20fdaa8c056d59f012f8c7b5b1c5c8/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/ActivateRequestContextInterceptor.java) ? diff --git a/futures/deployment/pom.xml b/futures/deployment/pom.xml new file mode 100644 index 0000000..8472692 --- /dev/null +++ b/futures/deployment/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + + + io.quarkiverse.scala + quarkus-scala3-futures-parent + 999-SNAPSHOT + + quarkus-scala3-futures-deployment + Quarkus Scala3 futures - Deployment + + + + + io.quarkus + quarkus-arc-deployment + + + ${project.groupId} + quarkus-scala3-futures + ${project.version} + + + io.quarkus + quarkus-junit5-internal + test + + + + io.quarkus + quarkus-rest-spi-deployment + + + io.quarkus.resteasy.reactive + resteasy-reactive-processor + + + io.quarkus + quarkus-rest-server-spi-deployment + + + + + + + src/main/scala + src/test/scala + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + net.alchim31.maven + scala-maven-plugin + + + + diff --git a/futures/deployment/src/main/scala/io/quarkiverse/scala/scala3/futures/deployment/Scala3FuturesJavaProcessor.java b/futures/deployment/src/main/scala/io/quarkiverse/scala/scala3/futures/deployment/Scala3FuturesJavaProcessor.java new file mode 100644 index 0000000..f5f12c7 --- /dev/null +++ b/futures/deployment/src/main/scala/io/quarkiverse/scala/scala3/futures/deployment/Scala3FuturesJavaProcessor.java @@ -0,0 +1,19 @@ +package io.quarkiverse.scala.scala3.futures.deployment; + +import io.quarkiverse.scala.scala3.futures.runtime.Scala3FutureReturnTypeMethodScanner; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.resteasy.reactive.server.spi.MethodScannerBuildItem; + +public class Scala3FuturesJavaProcessor { + + @BuildStep + public FeatureBuildItem feature() { + return new FeatureBuildItem("scala3-futures"); + } + + @BuildStep + public MethodScannerBuildItem registerFuturesRestReturnTypes() { + return new MethodScannerBuildItem(new Scala3FutureReturnTypeMethodScanner()); + } +} diff --git a/futures/deployment/src/test/scala/io/quarkiverse/scala/scala3/futures/test/Scala3FuturesDevModeTest.scala b/futures/deployment/src/test/scala/io/quarkiverse/scala/scala3/futures/test/Scala3FuturesDevModeTest.scala new file mode 100644 index 0000000..bda5be6 --- /dev/null +++ b/futures/deployment/src/test/scala/io/quarkiverse/scala/scala3/futures/test/Scala3FuturesDevModeTest.scala @@ -0,0 +1,23 @@ +package io.quarkiverse.scala.scala3.futures.test + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusDevModeTest; + +class Scala3FuturesDevModeTest { + + // Start hot reload (DevMode) test with your extension loaded + @RegisterExtension + val devModeTest: QuarkusDevModeTest = new QuarkusDevModeTest() + .setArchiveProducer(() => ShrinkWrap.create(classOf[JavaArchive])) + + @Test + def writeYourOwnDevModeTest(): Unit = { + // Write your dev mode tests here - see the testing extension guide https://quarkus.io/guides/writing-extensions#testing-hot-reload for more information + Assertions.assertTrue(true, "Add dev mode assertions to " + getClass().getName()); + } +} diff --git a/futures/deployment/src/test/scala/io/quarkiverse/scala/scala3/futures/test/Scala3FuturesTest.scala b/futures/deployment/src/test/scala/io/quarkiverse/scala/scala3/futures/test/Scala3FuturesTest.scala new file mode 100644 index 0000000..6e670d8 --- /dev/null +++ b/futures/deployment/src/test/scala/io/quarkiverse/scala/scala3/futures/test/Scala3FuturesTest.scala @@ -0,0 +1,23 @@ +package io.quarkiverse.scala.scala3.futures.test + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +class Scala3FuturesTest { + + // Start unit test with your extension loaded + @RegisterExtension + def unitTest: QuarkusUnitTest = new QuarkusUnitTest() + .setArchiveProducer(() => ShrinkWrap.create(classOf[JavaArchive])) + + @Test + def writeYourOwnUnitTest(): Unit = { + // Write your unit tests here - see the testing extension guide https://quarkus.io/guides/writing-extensions#testing-extensions for more information + Assertions.assertTrue(true, "Add some assertions to " + getClass().getName()); + } +} diff --git a/futures/integration-tests/pom.xml b/futures/integration-tests/pom.xml new file mode 100644 index 0000000..751b4f4 --- /dev/null +++ b/futures/integration-tests/pom.xml @@ -0,0 +1,127 @@ + + + 4.0.0 + + + io.quarkiverse.scala + quarkus-scala3-futures-parent + 999-SNAPSHOT + + quarkus-scala3-futures-integration-tests + Quarkus Scala3 futures - Integration Tests + + + false + + + + + io.quarkus + quarkus-rest + + + io.quarkiverse.scala + quarkus-scala3-futures + ${project.version} + + + + + + + + org.scala-lang + scala3-compiler_3 + ${scala.version} + test + + + org.scala-lang + scala3-library_3 + ${scala.version} + test + + + io.quarkus + quarkus-junit5 + test + + + io.quarkus + quarkus-junit5-internal + test + + + io.rest-assured + rest-assured + test + + + + + + src/main/scala + src/test/scala + + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + maven-failsafe-plugin + + + + integration-test + verify + + + + + + ${project.build.directory}/${project.build.finalName}-runner + org.jboss.logmanager.LogManager + ${maven.home} + + + + + net.alchim31.maven + scala-maven-plugin + + + + + + + native-image + + + native + + + + + + maven-surefire-plugin + + ${native.surefire.skip} + + + + + + false + true + + + + diff --git a/futures/integration-tests/src/main/resources/application.properties b/futures/integration-tests/src/main/resources/application.properties new file mode 100644 index 0000000..e69de29 diff --git a/futures/integration-tests/src/main/scala/io/quarkiverse/scala/scala3/futures/it/Scala3FuturesResource.scala b/futures/integration-tests/src/main/scala/io/quarkiverse/scala/scala3/futures/it/Scala3FuturesResource.scala new file mode 100644 index 0000000..6f2ec04 --- /dev/null +++ b/futures/integration-tests/src/main/scala/io/quarkiverse/scala/scala3/futures/it/Scala3FuturesResource.scala @@ -0,0 +1,38 @@ +package io.quarkiverse.scala.scala3.futures.it + +import jakarta.ws.rs.GET +import jakarta.ws.rs.Path +import jakarta.ws.rs.Produces +import jakarta.ws.rs.core.MediaType.TEXT_PLAIN + +import scala.concurrent.Future +import scala.concurrent.Promise +import scala.concurrent.ExecutionContext.Implicits.global + +@Path("") +class Scala3FuturesResource { + + @GET + @Path("/hello") + def hello(): String = "Hello from Scala 3.4.1" + + @GET + @Path("/simple-future") + @Produces(Array(TEXT_PLAIN)) + def simpleFuture: Future[String] = for { + _ <- Future { Thread.sleep(2000L) } + s <- Future.successful("Hello from a Future in Scala 3.4.1") + } yield s + + @GET + @Path("simple-promise") + @Produces(Array(TEXT_PLAIN)) + def simplePromise: Promise[String] = Promise.successful("Promise returned") + + + @GET + @Path("future-failure") + @Produces(Array(TEXT_PLAIN)) + def futureFailure: Future[String] = Future.failed(new RuntimeException("Future failed")) + +} diff --git a/futures/integration-tests/src/test/scala/io/quarkiverse/scala/scala3/futures/it/Given.scala b/futures/integration-tests/src/test/scala/io/quarkiverse/scala/scala3/futures/it/Given.scala new file mode 100644 index 0000000..68da5c0 --- /dev/null +++ b/futures/integration-tests/src/test/scala/io/quarkiverse/scala/scala3/futures/it/Given.scala @@ -0,0 +1,26 @@ +package io.quarkiverse.scala.scala3.futures.it + +import io.restassured.RestAssured.* +import io.restassured.internal.{ResponseSpecificationImpl, ValidatableResponseImpl} +import io.restassured.response.{ExtractableResponse, Response, ValidatableResponse} +import io.restassured.specification.{RequestSender, RequestSpecification, ResponseSpecification} + +class GivenConstructor(givenBlock: RequestSpecification => RequestSpecification): + def When(whenBlock: RequestSpecification => Response): ExpectationConstructor = + ExpectationConstructor(givenBlock, whenBlock) + + class ExpectationConstructor( + givenBlock: RequestSpecification => RequestSpecification, + whenBlock: RequestSpecification => Response + ): + def Then(validatable: ValidatableResponse => Unit) = + val appliedGiven: RequestSpecification = givenBlock.apply(`given`()) + val appliedWhen: Response = whenBlock.apply(appliedGiven) + validatable.apply(appliedWhen.`then`()) + +object Given: + def apply(givenBlock: RequestSpecification => RequestSpecification): GivenConstructor = GivenConstructor(givenBlock) + +def When(whenBlock: RequestSpecification => Response) = + def blankGiven(givenBlock: RequestSpecification): RequestSpecification = `given`() + Given(blankGiven).When(whenBlock) \ No newline at end of file diff --git a/futures/integration-tests/src/test/scala/io/quarkiverse/scala/scala3/futures/it/NativeScala3FuturesResourceIT.scala b/futures/integration-tests/src/test/scala/io/quarkiverse/scala/scala3/futures/it/NativeScala3FuturesResourceIT.scala new file mode 100644 index 0000000..f6702cf --- /dev/null +++ b/futures/integration-tests/src/test/scala/io/quarkiverse/scala/scala3/futures/it/NativeScala3FuturesResourceIT.scala @@ -0,0 +1,8 @@ +package io.quarkiverse.scala.scala3.futures.it + +import io.quarkus.test.junit.QuarkusIntegrationTest + +@QuarkusIntegrationTest +class NativeScala3FuturesResourceIT extends Scala3FuturesResourceTest { + +} diff --git a/futures/integration-tests/src/test/scala/io/quarkiverse/scala/scala3/futures/it/Scala3FuturesResourceTest.scala b/futures/integration-tests/src/test/scala/io/quarkiverse/scala/scala3/futures/it/Scala3FuturesResourceTest.scala new file mode 100644 index 0000000..26cb2c0 --- /dev/null +++ b/futures/integration-tests/src/test/scala/io/quarkiverse/scala/scala3/futures/it/Scala3FuturesResourceTest.scala @@ -0,0 +1,60 @@ +package io.quarkiverse.scala.scala3.futures.it + +import org.hamcrest.Matchers.is +import org.junit.jupiter.api.Test +import io.quarkus.test.junit.QuarkusTest +import org.hamcrest.Matchers + +@QuarkusTest +class Scala3FuturesResourceTest { + + + + @Test + def `test Hello Endpoint`(): Unit = { + Given { + _.params("something", "value") + }.When { + _.get("/hello") + }.Then { + _.statusCode(200).body(is("Hello from Scala 3.4.1")) + } + } + + @Test + def `test future Endpoint`(): Unit = { + Given { + _.params("something", "value") + }.When { + _.get("/simple-future") + }.Then { + _.statusCode(200).body(is("Hello from a Future in Scala 3.4.1")) + } + } + + @Test + def `test promise Endpoint`(): Unit = { + Given { + _.params("something", "value") + }.When { + _.get("/simple-promise") + }.Then { + _.statusCode(200).body(is("Promise returned")) + } + } + + @Test + def `future should fail`(): Unit = { + Given { + _.params("something", "value") + }.When { + _.get("/future-failure").prettyPeek() + }.Then { + _.statusCode(500) + // body/stack not available in native image? + //.body(Matchers.containsString("Future failed")) + } + } + + +} diff --git a/futures/pom.xml b/futures/pom.xml new file mode 100644 index 0000000..363aa5e --- /dev/null +++ b/futures/pom.xml @@ -0,0 +1,117 @@ + + + 4.0.0 + + + io.quarkiverse.scala + quarkus-scala3-parent + 999-SNAPSHOT + + quarkus-scala3-futures-parent + 999-SNAPSHOT + pom + Quarkus Scala3 futures - Parent + + + deployment + runtime + + + + 3.12.1 + 17 + UTF-8 + UTF-8 + 3.10.0 + 4.9.1 + 3.3.3 + + + + + + io.quarkus + quarkus-bom + ${quarkus.version} + pom + import + + + org.scala-lang + scala3-library_3 + ${scala.version} + + + + + + src/main/scala + src/test/scala + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + + + maven-compiler-plugin + ${compiler-plugin.version} + + + -parameters + + + + + net.alchim31.maven + scala-maven-plugin + ${scala-maven-plugin.version} + + + + scala-compile-first + process-resources + + add-source + compile + + + + + scala-test-compile + process-test-resources + + add-source + testCompile + + + + + + -Wunused:all + -feature + -deprecation + -Ysemanticdb + + + + + + + + + + it + + + performRelease + !true + + + + integration-tests + + + + diff --git a/futures/runtime/pom.xml b/futures/runtime/pom.xml new file mode 100644 index 0000000..ff4c544 --- /dev/null +++ b/futures/runtime/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + + + io.quarkiverse.scala + quarkus-scala3-futures-parent + 999-SNAPSHOT + + quarkus-scala3-futures + Quarkus Scala3 futures - Runtime + + + + io.quarkus + quarkus-arc + + + io.quarkus.resteasy.reactive + resteasy-reactive-processor + + + + + + + org.scala-lang + scala3-library_3 + compile + + + + + src/main/scala + src/test/scala + + + io.quarkus + quarkus-extension-maven-plugin + ${quarkus.version} + + + compile + + extension-descriptor + + + ${project.groupId}:quarkus-scala3-futures-deployment:${project.version} + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + net.alchim31.maven + scala-maven-plugin + + + + diff --git a/futures/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/futures/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000..e004d80 --- /dev/null +++ b/futures/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,11 @@ +name: Scala 3 Futures Support +description: Support for using Future[T] and Promise[T] in REST Endpoints. +metadata: + keywords: + - scala + - scala3 + - futures + guide: https://docs.quarkiverse.io/quarkus-scala3/dev/index.html + categories: + - "alt-languages" + status: "preview" diff --git a/futures/runtime/src/main/scala/io/quarkiverse/scala/scala3/futures/runtime/Scala3FutureResponseHandler.java b/futures/runtime/src/main/scala/io/quarkiverse/scala/scala3/futures/runtime/Scala3FutureResponseHandler.java new file mode 100644 index 0000000..d20d80b --- /dev/null +++ b/futures/runtime/src/main/scala/io/quarkiverse/scala/scala3/futures/runtime/Scala3FutureResponseHandler.java @@ -0,0 +1,34 @@ +package io.quarkiverse.scala.scala3.futures.runtime; + +import org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext; +import org.jboss.resteasy.reactive.server.spi.ServerRestHandler; + +public class Scala3FutureResponseHandler implements ServerRestHandler { + + private void handleFuture(ResteasyReactiveRequestContext requestContext, + scala.concurrent.Future f) { + + requestContext.suspend(); + + f.onComplete(tryValue -> { + if (tryValue.isSuccess()) { + requestContext.setResult(tryValue.get()); + } else { + requestContext.handleException(tryValue.failed().get(), true); + } + requestContext.resume(); + return null; + }, scala.concurrent.ExecutionContext.global()); + + } + + @Override + public void handle(ResteasyReactiveRequestContext requestContext) throws Exception { + if (requestContext.getResult() instanceof scala.concurrent.Future future) { + handleFuture(requestContext, future); + } else if (requestContext.getResult() instanceof scala.concurrent.Promise promise) { + handleFuture(requestContext, promise.future()); + } + } + +} diff --git a/futures/runtime/src/main/scala/io/quarkiverse/scala/scala3/futures/runtime/Scala3FutureReturnTypeMethodScanner.java b/futures/runtime/src/main/scala/io/quarkiverse/scala/scala3/futures/runtime/Scala3FutureReturnTypeMethodScanner.java new file mode 100644 index 0000000..6137c99 --- /dev/null +++ b/futures/runtime/src/main/scala/io/quarkiverse/scala/scala3/futures/runtime/Scala3FutureReturnTypeMethodScanner.java @@ -0,0 +1,50 @@ +package io.quarkiverse.scala.scala3.futures.runtime; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; +import org.jboss.jandex.MethodInfo; +import org.jboss.resteasy.reactive.server.model.FixedHandlerChainCustomizer; +import org.jboss.resteasy.reactive.server.model.HandlerChainCustomizer; +import org.jboss.resteasy.reactive.server.model.HandlerChainCustomizer.Phase; +import org.jboss.resteasy.reactive.server.processor.scanning.MethodScanner; + +public class Scala3FutureReturnTypeMethodScanner implements MethodScanner { + private static final DotName FUTURE = DotName.createSimple("scala.concurrent.Future"); + private static final DotName PROMISE = DotName.createSimple("scala.concurrent.Promise"); + private static final DotName BLOCKING_ANNOTATION = DotName.createSimple("io.smallrye.common.annotation.Blocking"); + + private void ensureNotBlocking(MethodInfo method) { + if (method.annotation(BLOCKING_ANNOTATION) != null) { + String format = String.format("Suspendable @Blocking methods are not supported yet: %s.%s", + method.declaringClass().name(), method.name()); + throw new IllegalStateException(format); + } + } + + public Scala3FutureReturnTypeMethodScanner() { + } + + @Override + public List scan(MethodInfo method, ClassInfo actualEndpointClass, + Map methodContext) { + + if (isMethodSignatureAsync(method)) { + ensureNotBlocking(method); + + return Collections.singletonList(new FixedHandlerChainCustomizer( + new Scala3FutureResponseHandler(), Phase.AFTER_METHOD_INVOKE)); + } + return Collections.emptyList(); + } + + @Override + public boolean isMethodSignatureAsync(MethodInfo info) { + DotName name = info.returnType().name(); + return name.equals(FUTURE) || name.equals(PROMISE); + } + +} diff --git a/pom.xml b/pom.xml index b80da21..2192b6b 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ io.quarkiverse quarkiverse-parent - 15 + 16 io.quarkiverse.scala quarkus-scala3-parent @@ -14,6 +14,7 @@ deployment runtime + futures :git:git@github.com:quarkiverse/quarkus-scala3.git @@ -24,7 +25,7 @@ UTF-8 UTF-8 - 3.6.4 + 3.10.0