diff --git a/build.sbt b/build.sbt index 0100569e..a2ee371e 100644 --- a/build.sbt +++ b/build.sbt @@ -26,7 +26,8 @@ lazy val root = `http-scala-fx`, documentation, `sttp-scala-fx`, - `java-net-multipart-body-publisher` + `java-net-multipart-body-publisher`, + `http4s-scala-fx` ) lazy val `scala-fx` = project.settings(scalafxSettings: _*) @@ -79,6 +80,14 @@ lazy val `sttp-scala-fx` = (project in file("./sttp-scala-fx")) `http-scala-fx`, `munit-scala-fx` % "test -> compile") +lazy val `http4s-scala-fx` = (project in file("./http4s-scala-fx")) + .settings(http4sScalaFXSettings) + .dependsOn( + `cats-scala-fx`, + `scala-fx`, + `http-scala-fx` % "test -> test", + `munit-scala-fx` % "test -> compile") + lazy val commonSettings = Seq( javaOptions ++= javaOptionsSettings, autoAPIMappings := true, @@ -145,6 +154,10 @@ lazy val sttpScalaFXSettings = commonSettings ++ Seq( libraryDependencies += hedgehog % Test ) +lazy val http4sScalaFXSettings = commonSettings ++ Seq( + libraryDependencies += http4sBlaze +) + lazy val javaOptionsSettings = Seq( "-XX:+IgnoreUnrecognizedVMOptions", "-XX:-DetectLocksInCompiledFrames", diff --git a/http4s-scala-fx/src/main/scala/http4s/fx/BlazeServerFXBackend.scala b/http4s-scala-fx/src/main/scala/http4s/fx/BlazeServerFXBackend.scala new file mode 100644 index 00000000..f2e95986 --- /dev/null +++ b/http4s-scala-fx/src/main/scala/http4s/fx/BlazeServerFXBackend.scala @@ -0,0 +1,51 @@ +package http4s +package fx + +import _root_.fx.handle +import _root_.fx.instances.StructuredF +import _root_.fx.instances.FxAsync.asyncInstance +import _root_.fx.{Control, Errors, Nullable, Resource, Resources, Structured} +import org.http4s.HttpApp +import org.http4s.blaze.server.BlazeServerBuilder +import org.http4s.server.{defaults, Server} + +class BlazeServerFXBackend private ( + app: HttpApp[StructuredF] +)( + using structured: Structured, + control: Control[Throwable], + config: HttpServerConfig +) { + private var serverFinalizers: Nullable[(Server, StructuredF[Unit])] = Nullable.none + + private val builder: BlazeServerBuilder[StructuredF] = + BlazeServerBuilder[StructuredF] + .bindHttp( + config.port.getOrElse(defaults.HttpPort), + config.host.getOrElse(defaults.IPv4Host)) + .withIdleTimeout(config.idleTimeout.getOrElse(defaults.IdleTimeout)) + .withSelectorThreadFactory(structured.threadFactory) + .withHttpApp(app) + + def start(): BlazeServerFXBackend = + serverFinalizers = handle( + Nullable(builder.resource.allocated) + )((e: Throwable) => e.shift) + this + + def close(): Unit = + serverFinalizers.map { case (_, finalizers) => finalizers }.getOrElse(()) + + def server(): Server = + handle( + serverFinalizers.map { case (server, _) => server }.value + )((e: Throwable) => e.shift) +} + +object BlazeServerFXBackend: + def apply(app: HttpApp[StructuredF])( + using structured: Structured, + control: Control[Throwable], + config: HttpServerConfig + ): Resource[BlazeServerFXBackend] = + Resource(new BlazeServerFXBackend(app).start(), (server, _) => server.close()) diff --git a/http4s-scala-fx/src/main/scala/http4s/fx/HttpServerConfig.scala b/http4s-scala-fx/src/main/scala/http4s/fx/HttpServerConfig.scala new file mode 100644 index 00000000..a79a4f8c --- /dev/null +++ b/http4s-scala-fx/src/main/scala/http4s/fx/HttpServerConfig.scala @@ -0,0 +1,20 @@ +package http4s +package fx + +import _root_.fx.Nullable + +import java.net.InetSocketAddress +import scala.concurrent.duration.Duration + +/** + * Defines the server configuration options for an http server. Not open for extension. + */ +final class HttpServerConfig( + val port: Nullable[Int], + val host: Nullable[String], + val idleTimeout: Nullable[Duration] +) + +object HttpServerConfig: + given HttpServerConfig = + HttpServerConfig(Nullable.none, Nullable.none, Nullable.none) diff --git a/http4s-scala-fx/src/test/scala/http4s/fx/BlazeServerFXBackendSuite.scala b/http4s-scala-fx/src/test/scala/http4s/fx/BlazeServerFXBackendSuite.scala new file mode 100644 index 00000000..913f9960 --- /dev/null +++ b/http4s-scala-fx/src/test/scala/http4s/fx/BlazeServerFXBackendSuite.scala @@ -0,0 +1,37 @@ +package http4s +package fx + +import _root_.fx.{defaultRetryPolicy, parallel, structured, GET, POST, Structured} +import munit.fx.ScalaFXSuite + +import java.net.URI +import java.net.http.HttpResponse + +class BlazeServerFXBackendSuite extends ScalaFXSuite, HttpServerFixtures: + + httpServerHttp4s(pingPongApp).testFX( + "GET requests should be returned in non-blocking fibers") { serverResource => + assertEqualsFX( + structured { + serverResource.use { baseServerAddress => + parallel( + () => URI.create(s"$baseServerAddress/ping/1").GET[String]().body, + () => URI.create(s"$baseServerAddress/ping/2").GET[String]().body, + () => URI.create(s"$baseServerAddress/ping/3").GET[String]().body + ) + } + }, + ("pong", "pong", "pong") + ) + } + + httpServerHttp4s(echoApp).testFX("POST requests should be returned") { serverResource => + val response: HttpResponse[String] = structured { + serverResource.use { baseServerAddress => + URI.create(s"$baseServerAddress/echo").POST[String, String]("hello") + } + } + + assertEqualsFX(response.body, "hello") + assertEqualsFX(response.statusCode, 200) + } diff --git a/http4s-scala-fx/src/test/scala/http4s/fx/HttpServerFixtures.scala b/http4s-scala-fx/src/test/scala/http4s/fx/HttpServerFixtures.scala new file mode 100644 index 00000000..6df5e1b9 --- /dev/null +++ b/http4s-scala-fx/src/test/scala/http4s/fx/HttpServerFixtures.scala @@ -0,0 +1,33 @@ +package http4s +package fx + +import _root_.fx.{handle, structured, toEither, Control, Resource, Structured} +import _root_.fx.instances.FxAsync.asyncInstance +import _root_.fx.instances.StructuredF +import _root_.fx.HttpExecutionException +import fx.BlazeServerFXBackend +import munit.FunSuite +import org.http4s.Method.{GET, POST} +import org.http4s.{HttpApp, HttpRoutes, Response} + +trait HttpServerFixtures: + self: FunSuite => + + val pingPongApp: HttpApp[StructuredF] = + HttpApp.apply { + case r if r.method == GET && r.uri.path.renderString.contains("ping") => + Response[StructuredF]().withEntity[String]("pong") + } + + val echoApp: HttpApp[StructuredF] = + HttpApp.apply { + case r if r.method == POST && r.uri.path.renderString.contains("echo") => + Response[StructuredF]().withBodyStream(r.body) + } + + def httpServerHttp4s(app: HttpApp[StructuredF]) + : FunFixture[(Structured, Control[Throwable]) ?=> Resource[String]] = + FunFixture( + setup = _ => BlazeServerFXBackend(app).map(server => s"http:/${server.server().address}"), + teardown = _ => () + ) diff --git a/project/project/Dependencies.scala b/project/project/Dependencies.scala index df97a4e0..90abeff7 100644 --- a/project/project/Dependencies.scala +++ b/project/project/Dependencies.scala @@ -32,6 +32,7 @@ object Dependencies { val sttp = "3.6.2" val httpCore5 = "5.1.4" val hedgehog = "0.9.0" + val http4sBlaze = "0.23.12" } object Compile { @@ -47,7 +48,7 @@ object Dependencies { val logback = "ch.qos.logback" % "logback-classic" % Versions.logback val sttp = "com.softwaremill.sttp.client3" %% "core" % Versions.sttp val httpCore5 = "org.apache.httpcomponents.core5" % "httpcore5" % Versions.httpCore5 - + val http4sBlaze = "org.http4s" %% "http4s-blaze-server" % Versions.http4sBlaze } object Test { diff --git a/scala-fx/src/main/scala/fx/Continuation.scala b/scala-fx/src/main/scala/fx/Continuation.scala index 9eea16aa..b294c3fb 100644 --- a/scala-fx/src/main/scala/fx/Continuation.scala +++ b/scala-fx/src/main/scala/fx/Continuation.scala @@ -4,7 +4,7 @@ import scala.annotation.implicitNotFound import scala.util.control.ControlThrowable import java.util.UUID import scala.util.control.NonFatal -import java.util.concurrent.ExecutionException +import java.util.concurrent.{CompletionException, ExecutionException} import scala.annotation.tailrec object Continuation: @@ -30,7 +30,7 @@ object Continuation: @tailrec def handleControl(control: Control[_], e: Throwable): Any = e match - case e: ExecutionException => + case e: (ExecutionException | CompletionException) => handleControl(control, e.getCause) case e @ ControlToken(token, shifted, recover) => if (control.token == token)