diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8dfb8199f7..3915b7a793 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,31 +18,43 @@ jobs: matrix: scala-version: [ "2.12", "2.13", "3" ] target-platform: [ "JVM", "JS", "Native" ] + java: [ "11", "21" ] exclude: + - java: "21" - scala-version: "2.12" target-platform: "Native" - scala-version: "2.13" target-platform: "Native" + include: # Restricted to build only specific Loom-based modules + - scala-version: "2.13" + target-platform: "JVM" + java: "21" + - scala-version: "3" + target-platform: "JVM" + java: "21" steps: - name: Checkout uses: actions/checkout@v3 - - name: Set up JDK 11 + - name: Set up JDK uses: actions/setup-java@v3 with: distribution: 'temurin' cache: 'sbt' - java-version: 11 + java-version: ${{ matrix.java }} - name: Install sam cli + if: matrix.java == '11' run: | wget -q https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-x86_64.zip unzip -q aws-sam-cli-linux-x86_64.zip -d sam-installation sudo ./sam-installation/install --update sam --version - name: Install NPM + if: matrix.java == '11' run: | sudo apt install npm npm --version - name: Install AWS CDK + if: matrix.java == '11' run: | npm install -g aws-cdk cdk --version @@ -52,10 +64,13 @@ jobs: sudo apt-get update sudo apt-get install libidn2-dev libcurl3-dev echo "STTP_NATIVE=1" >> $GITHUB_ENV + - name: Enable Loom-specific modules + if: matrix.java == '21' + run: echo "ONLY_LOOM=1" >> $GITHUB_ENV - name: Compile run: sbt $SBT_JAVA_OPTS -v "compileScoped ${{ matrix.scala-version }} ${{ matrix.target-platform }}" - name: Compile documentation - if: matrix.target-platform == 'JVM' + if: matrix.target-platform == 'JVM' && matrix.java == '11' run: sbt $SBT_JAVA_OPTS -v compileDocumentation - name: Test if: matrix.target-platform != 'JS' @@ -109,21 +124,29 @@ jobs: needs: [ci] if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) runs-on: ubuntu-22.04 - env: - STTP_NATIVE: 1 + strategy: + matrix: + java: [ "11", "21" ] steps: - name: Checkout uses: actions/checkout@v3 - - name: Set up JDK 11 + - name: Set up JDK uses: actions/setup-java@v3 with: distribution: 'temurin' - java-version: 11 + java-version: ${{ matrix.java }} cache: 'sbt' - name: Install libidn2-dev libcurl3-dev + if: matrix.java == '11' run: | sudo apt-get update sudo apt-get install libidn2-dev libcurl3-dev + - name: Enable Native-specific modules + if: matrix.java == '11' + run: echo "STTP_NATIVE=1" >> $GITHUB_ENV + - name: Enable Loom-specific modules + if: matrix.java == '21' + run: echo "ONLY_LOOM=1" >> $GITHUB_ENV - name: Compile run: sbt $SBT_JAVA_OPTS compile - name: Publish artifacts @@ -134,12 +157,14 @@ jobs: SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} - name: Extract version from commit message + if: matrix.java == '11' run: | version=${GITHUB_REF/refs\/tags\/v/} echo "VERSION=$version" >> $GITHUB_ENV env: COMMIT_MSG: ${{ github.event.head_commit.message }} - name: Publish release notes + if: matrix.java == '11' uses: release-drafter/release-drafter@v5 with: config-name: release-drafter.yml diff --git a/build.sbt b/build.sbt index 4c87ea4737..5a5be415aa 100644 --- a/build.sbt +++ b/build.sbt @@ -221,8 +221,10 @@ lazy val rawAllAggregates = core.projectRefs ++ vertxServerZio1.projectRefs ++ jdkhttpServer.projectRefs ++ nettyServer.projectRefs ++ + nettyServerLoom.projectRefs ++ nettyServerCats.projectRefs ++ nettyServerZio.projectRefs ++ + nimaServer.projectRefs ++ zio1HttpServer.projectRefs ++ zioHttpServer.projectRefs ++ awsLambdaCore.projectRefs ++ @@ -251,13 +253,21 @@ lazy val rawAllAggregates = core.projectRefs ++ awsCdk.projectRefs lazy val allAggregates: Seq[ProjectReference] = { - if (sys.env.isDefinedAt("STTP_NATIVE")) { + val filteredByNative = if (sys.env.isDefinedAt("STTP_NATIVE")) { println("[info] STTP_NATIVE defined, including native in the aggregate projects") rawAllAggregates } else { println("[info] STTP_NATIVE *not* defined, *not* including native in the aggregate projects") rawAllAggregates.filterNot(_.toString.contains("Native")) } + if (sys.env.isDefinedAt("ONLY_LOOM")) { + println("[info] ONLY_LOOM defined, including only loom-based projects") + filteredByNative.filter(p => (p.toString.contains("Loom") || p.toString.contains("nima"))) + } else { + println("[info] ONLY_LOOM *not* defined, *not* including loom-based-projects") + filteredByNative.filterNot(p => (p.toString.contains("Loom") || p.toString.contains("nima"))) + } + } // separating testing into different Scala versions so that it's not all done at once, as it causes memory problems on CI @@ -1443,6 +1453,17 @@ lazy val nettyServer: ProjectMatrix = (projectMatrix in file("server/netty-serve .jvmPlatform(scalaVersions = scala2And3Versions) .dependsOn(serverCore, serverTests % Test) +lazy val nettyServerLoom: ProjectMatrix = + ProjectMatrix("nettyServerLoom", file(s"server/netty-server/loom")) + .settings(commonJvmSettings) + .settings( + name := "tapir-netty-server-loom", + // needed because of https://github.com/coursier/coursier/issues/2016 + useCoursier := false + ) + .jvmPlatform(scalaVersions = scala2_13And3Versions) + .dependsOn(nettyServer, serverTests % Test) + lazy val nettyServerCats: ProjectMatrix = nettyServerProject("cats", catsEffect) .settings( libraryDependencies ++= Seq( @@ -1471,6 +1492,18 @@ def nettyServerProject(proj: String, dependency: ProjectMatrix): ProjectMatrix = .jvmPlatform(scalaVersions = scala2And3Versions) .dependsOn(nettyServer, dependency, serverTests % Test) +lazy val nimaServer: ProjectMatrix = (projectMatrix in file("server/nima-server")) + .settings(commonJvmSettings) + .settings( + name := "tapir-nima-server", + libraryDependencies ++= Seq( + "io.helidon.webserver" % "helidon-webserver" % Versions.helidon, + "io.helidon.logging" % "helidon-logging-slf4j" % Versions.helidon + ) ++ loggerDependencies + ) + .jvmPlatform(scalaVersions = scala2_13And3Versions) + .dependsOn(serverCore, serverTests % Test) + lazy val vertxServer: ProjectMatrix = (projectMatrix in file("server/vertx-server")) .settings(commonJvmSettings) .settings( diff --git a/doc/contributing.md b/doc/contributing.md index 2a69325f32..f0050d74bb 100644 --- a/doc/contributing.md +++ b/doc/contributing.md @@ -18,6 +18,16 @@ that is parameterless enums or sealed traits with only case objects as children. in order to allow using OpenAPI `enum` elements and derive JSON codecs which represent them as simple values (without discriminator). Let's use the name `enumeration` in Tapir codebase to represent these "true" enumerations and avoid ambiguity. +## JDK version + +To ensure that Tapir can be used in a wide range of projects, the CI job uses JDK11 for most of the modules. There are exceptions (like `netty-server-loom` and `nima-server`) which require JDK version >= 21. This requirement is adressed by the build matrix in `.github/workflows/ci.yml`, which runs separate builds on a newer Java version, and sets a `ONLY_LOOM` env variable, used by build.sbt to recognise that it should limit the scope of an aggregated task to these projects only. +For local development, feel free to use any JDK >= 11. You can be on JDK 21, then with missing `ONLY_LOOM` variable you can still run sbt tasks on projects excluded from aggegate build, for example: +```scala +nimaServer/Test/test +nettyServerLoom/compile +// etc. +``` + ## Acknowledgments Tuple-concatenating code is copied from [akka-http](https://github.com/akka/akka-http/blob/master/akka-http/src/main/scala/akka/http/scaladsl/server/util/TupleOps.scala) diff --git a/doc/index.md b/doc/index.md index 496ac9ef84..6926ae60df 100644 --- a/doc/index.md +++ b/doc/index.md @@ -15,6 +15,7 @@ input and output parameters. An endpoint specification can be interpreted as: * [Akka HTTP](server/akkahttp.md) `Route`s/`Directive`s * [Http4s](server/http4s.md) `HttpRoutes[F]` (using cats-effect or [ZIO](server/zio-http4s.md)) * [Netty](server/netty.md) (using `Future`s, cats-effect or ZIO) + * [Helidon Níma](server/nima.md) (using JVM 21 Virtual Threads and direct style) * [Finatra](server/finatra.md) `http.Controller` * [Pekko HTTP](server/pekkohttp.md) `Route`s/`Directive`s * [Play](server/play.md) `Route` @@ -239,6 +240,7 @@ We offer commercial support for sttp and related technologies, as well as develo server/http4s server/zio-http4s server/netty + server/nima server/finatra server/pekkohttp server/play diff --git a/doc/other_interpreters.md b/doc/other_interpreters.md index 40ee9dc7fb..b795028824 100644 --- a/doc/other_interpreters.md +++ b/doc/other_interpreters.md @@ -3,10 +3,6 @@ At its core, tapir creates a data structure describing the HTTP endpoints. This data structure can be freely interpreted also by code not included in the library. Below is a list of projects, which provide tapir interpreters. -## tapir-loom - -The [tapir-loom](https://github.com/softwaremill/tapir-loom) project provides server interpreters which allow writing the server logic in the "direct" style (synchronous, using the `Id` effect). Depends on Java 19, which includes a preview of Project Loom (Virtual Threads for the JVM). - ## GraphQL [Caliban](https://github.com/ghostdogpr/caliban) allows you to easily turn your Tapir endpoints into a GraphQL API. More details in the [documentation](https://ghostdogpr.github.io/caliban/docs/interop.html#tapir). @@ -25,4 +21,4 @@ layer that allows you to create traced http endpoints from tapir Endpoint defini ## tapir-http-session -[tapir-http-session](https://github.com/SOFTNETWORK-APP/tapir-http-session) provides integration with functionality of [akka-http-session](https://github.com/softwaremill/akka-http-session), which includes client-side session management in web and mobile applications. \ No newline at end of file +[tapir-http-session](https://github.com/SOFTNETWORK-APP/tapir-http-session) provides integration with functionality of [akka-http-session](https://github.com/softwaremill/akka-http-session), which includes client-side session management in web and mobile applications. diff --git a/doc/server/netty.md b/doc/server/netty.md index f53228d530..64e1ea25bf 100644 --- a/doc/server/netty.md +++ b/doc/server/netty.md @@ -3,9 +3,12 @@ To expose an endpoint using a [Netty](https://netty.io)-based server, first add the following dependency: ```scala -// if you are using Future or just exploring +// if you are using Future or just exploring: "com.softwaremill.sttp.tapir" %% "tapir-netty-server" % "@VERSION@" +// if you want to use Java 21 Loom virtual threads in direct style: +"com.softwaremill.sttp.tapir" %% "tapir-netty-loom" % "@VERSION@" + // if you are using cats-effect: "com.softwaremill.sttp.tapir" %% "tapir-netty-server-cats" % "@VERSION@" @@ -16,6 +19,7 @@ To expose an endpoint using a [Netty](https://netty.io)-based server, first add Then, use: - `NettyFutureServer().addEndpoints` to expose `Future`-based server endpoints. +- `NettyIdServer().addEndpoints` to expose `Loom`-based server endpoints. - `NettyCatsServer().addEndpoints` to expose `F`-based server endpoints, where `F` is any cats-effect supported effect. [Streaming](../endpoint/streaming.md) request and response bodies is supported with fs2. - `NettyZioServer().addEndpoints` to expose `ZIO`-based server endpoints, where `R` represents ZIO requirements supported effect. Streaming is supported with ZIO Streams. @@ -40,6 +44,22 @@ val binding: Future[NettyFutureServerBinding] = NettyFutureServer().addEndpoint(helloWorld).start() ``` +The `tapir-netty-loom` server uses `Id[T]` as its wrapper effect for compatibility, while `Id[A]` means in fact just `A`, representing direct style. + +```scala +import sttp.tapir._ +import sttp.tapir.server.netty.loom.{Id, NettyIdServer, NettyIdServerBinding} + +val helloWorld = endpoint + .get + .in("hello").in(query[String]("name")) + .out(stringBody) + .serverLogicSuccess[Id](name => s"Hello, $name!") + +val binding: NettyIdServerBinding = + NettyIdServer().addEndpoint(helloWorld).start() +``` + ## Configuration The interpreter can be configured by providing an `NettyFutureServerOptions` value, see [server options](options.md) for diff --git a/doc/server/nima.md b/doc/server/nima.md new file mode 100644 index 0000000000..ab15ec2a35 --- /dev/null +++ b/doc/server/nima.md @@ -0,0 +1,44 @@ +# Running as a Helidon Níma server + +```eval_rst +.. note:: + Helidon Níma requires JDK supporting Project Loom threading (JDK21 or newer). +``` + +To expose an endpoint as a [Helidon Níma](https://helidon.io/nima) server, first add the following +dependency: + +```scala +"com.softwaremill.sttp.tapir" %% "tapir-nima-server" % "@VERSION@" +``` + +Loom-managed concurrency uses direct style instead of effect wrappers like `Future[T]` or `IO[T]`. Because of this, +Tapir endpoints defined for Nima server use `Id[T]`, which provides compatibility, while effectively means just `T`. + +Such endpoints are then processed through `NimaServerInterpreter` in order to obtain an `io.helidon.webserver.http.Handler`: + +```scala +import io.helidon.webserver.WebServer +import sttp.tapir._ +import sttp.tapir.server.nima.{Id, NimaServerInterpreter} + +val helloEndpoint = endpoint.get + .in("hello") + .out(stringBody) + .serverLogicSuccess[Id] { _ => + Thread.sleep(1000) + "hello, world!" + } + +val handler = NimaServerInterpreter().toHandler(List(helloEndpoint)) + +WebServer + .builder() + .routing { builder => + builder.any(handler) + () + } + .port(8080) + .build() + .start() +``` diff --git a/doc/stability.md b/doc/stability.md index f2faef89ff..27c3621feb 100644 --- a/doc/stability.md +++ b/doc/stability.md @@ -24,7 +24,8 @@ The modules are categorised using the following levels: | armeria | stabilising | | finatra | stabilising | | http4s | stabilising | -| netty | experimental | +| netty | stabilising | +| nima | experimental | | pekko-http| stabilising | | play | stabilising | | vertx | stabilising | diff --git a/project/Versions.scala b/project/Versions.scala index 39964d96f1..6f1cb5745c 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -7,6 +7,7 @@ object Versions { val circe = "0.14.6" val circeGenericExtras = "0.14.3" val circeYaml = "1.15.0" + val helidon = "4.0.0" val sttp = "3.9.0" val sttpModel = "1.7.6" val sttpShared = "1.3.16" diff --git a/server/netty-server/loom/src/main/scala/sttp/tapir/server/netty/loom/NettyIdServer.scala b/server/netty-server/loom/src/main/scala/sttp/tapir/server/netty/loom/NettyIdServer.scala new file mode 100644 index 0000000000..8609b49a36 --- /dev/null +++ b/server/netty-server/loom/src/main/scala/sttp/tapir/server/netty/loom/NettyIdServer.scala @@ -0,0 +1,162 @@ +package sttp.tapir.server.netty.loom + +import io.netty.channel.Channel +import io.netty.channel.EventLoopGroup +import io.netty.channel.group.{ChannelGroup, DefaultChannelGroup} +import io.netty.channel.unix.DomainSocketAddress +import io.netty.util.concurrent.DefaultEventExecutor +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.model.ServerResponse +import sttp.tapir.server.netty.NettyConfig +import sttp.tapir.server.netty.NettyResponse +import sttp.tapir.server.netty.Route +import sttp.tapir.server.netty.internal.NettyBootstrap +import sttp.tapir.server.netty.internal.NettyServerHandler + +import java.net.InetSocketAddress +import java.net.SocketAddress +import java.nio.file.Path +import java.nio.file.Paths +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.{Future => JFuture} +import java.util.concurrent.atomic.AtomicBoolean +import scala.concurrent.Future +import scala.concurrent.Promise +import scala.concurrent.duration.FiniteDuration +import scala.util.control.NonFatal + +case class NettyIdServer(routes: Vector[IdRoute], options: NettyIdServerOptions, config: NettyConfig) { + private val executor = Executors.newVirtualThreadPerTaskExecutor() + + def addEndpoint(se: ServerEndpoint[Any, Id]): NettyIdServer = addEndpoints(List(se)) + def addEndpoint(se: ServerEndpoint[Any, Id], overrideOptions: NettyIdServerOptions): NettyIdServer = + addEndpoints(List(se), overrideOptions) + def addEndpoints(ses: List[ServerEndpoint[Any, Id]]): NettyIdServer = addRoute(NettyIdServerInterpreter(options).toRoute(ses)) + def addEndpoints(ses: List[ServerEndpoint[Any, Id]], overrideOptions: NettyIdServerOptions): NettyIdServer = + addRoute(NettyIdServerInterpreter(overrideOptions).toRoute(ses)) + + def addRoute(r: IdRoute): NettyIdServer = copy(routes = routes :+ r) + def addRoutes(r: Iterable[IdRoute]): NettyIdServer = copy(routes = routes ++ r) + + def options(o: NettyIdServerOptions): NettyIdServer = copy(options = o) + def config(c: NettyConfig): NettyIdServer = copy(config = c) + def modifyConfig(f: NettyConfig => NettyConfig): NettyIdServer = config(f(config)) + + def host(hostname: String): NettyIdServer = modifyConfig(_.host(hostname)) + + def port(p: Int): NettyIdServer = modifyConfig(_.port(p)) + + def start(): NettyIdServerBinding = + startUsingSocketOverride[InetSocketAddress](None) match { + case (socket, stop) => + NettyIdServerBinding(socket, stop) + } + + def startUsingDomainSocket(path: Option[Path] = None): NettyIdDomainSocketBinding = + startUsingDomainSocket(path.getOrElse(Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID().toString))) + + def startUsingDomainSocket(path: Path): NettyIdDomainSocketBinding = + startUsingSocketOverride(Some(new DomainSocketAddress(path.toFile))) match { + case (socket, stop) => + NettyIdDomainSocketBinding(socket, stop) + } + + private def startUsingSocketOverride[SA <: SocketAddress](socketOverride: Option[SA]): (SA, () => Unit) = { + val eventLoopGroup = config.eventLoopConfig.initEventLoopGroup() + val route = Route.combine(routes) + + def unsafeRunF( + callToExecute: () => Id[ServerResponse[NettyResponse]] + ): (Future[ServerResponse[NettyResponse]], () => Future[Unit]) = { + val scalaPromise = Promise[ServerResponse[NettyResponse]]() + val jFuture: JFuture[?] = executor.submit(new Runnable { + override def run(): Unit = try { + val result = callToExecute() + scalaPromise.success(result) + } catch { + case NonFatal(e) => scalaPromise.failure(e) + } + }) + + ( + scalaPromise.future, + () => { + jFuture.cancel(true) + Future.unit + } + ) + } + val channelGroup = new DefaultChannelGroup(new DefaultEventExecutor()) // thread safe + val isShuttingDown: AtomicBoolean = new AtomicBoolean(false) + + val channelIdFuture = NettyBootstrap( + config, + new NettyServerHandler( + route, + unsafeRunF, + config.maxContentLength, + channelGroup, + isShuttingDown + ), + eventLoopGroup, + socketOverride + ) + channelIdFuture.await() + val channelId = channelIdFuture.channel() + + ( + channelId.localAddress().asInstanceOf[SA], + () => stop(channelId, eventLoopGroup, channelGroup, isShuttingDown, config.gracefulShutdownTimeout) + ) + } + + private def waitForClosedChannels( + channelGroup: ChannelGroup, + startNanos: Long, + gracefulShutdownTimeoutNanos: Option[Long] + ): Unit = { + while (!channelGroup.isEmpty && gracefulShutdownTimeoutNanos.exists(_ >= System.nanoTime() - startNanos)) { + Thread.sleep(100) + } + val _ = channelGroup.close().get() + } + private def stop( + ch: Channel, + eventLoopGroup: EventLoopGroup, + channelGroup: ChannelGroup, + isShuttingDown: AtomicBoolean, + gracefulShutdownTimeout: Option[FiniteDuration] + ): Unit = { + isShuttingDown.set(true) + waitForClosedChannels( + channelGroup, + startNanos = System.nanoTime(), + gracefulShutdownTimeoutNanos = gracefulShutdownTimeout.map(_.toNanos) + ) + ch.close().get() + if (config.shutdownEventLoopGroupOnClose) { + val _ = eventLoopGroup.shutdownGracefully().get() + } + } +} + +object NettyIdServer { + def apply(): NettyIdServer = NettyIdServer(Vector.empty, NettyIdServerOptions.default, NettyConfig.defaultNoStreaming) + + def apply(serverOptions: NettyIdServerOptions): NettyIdServer = + NettyIdServer(Vector.empty, serverOptions, NettyConfig.defaultNoStreaming) + + def apply(config: NettyConfig): NettyIdServer = + NettyIdServer(Vector.empty, NettyIdServerOptions.default, config) + + def apply(serverOptions: NettyIdServerOptions, config: NettyConfig): NettyIdServer = + NettyIdServer(Vector.empty, serverOptions, config) +} +case class NettyIdServerBinding(localSocket: InetSocketAddress, stop: () => Unit) { + def hostName: String = localSocket.getHostName + def port: Int = localSocket.getPort +} +case class NettyIdDomainSocketBinding(localSocket: DomainSocketAddress, stop: () => Unit) { + def path: String = localSocket.path() +} diff --git a/server/netty-server/loom/src/main/scala/sttp/tapir/server/netty/loom/NettyIdServerInterpreter.scala b/server/netty-server/loom/src/main/scala/sttp/tapir/server/netty/loom/NettyIdServerInterpreter.scala new file mode 100644 index 0000000000..1f89ab6c09 --- /dev/null +++ b/server/netty-server/loom/src/main/scala/sttp/tapir/server/netty/loom/NettyIdServerInterpreter.scala @@ -0,0 +1,33 @@ +package sttp.tapir.server.netty.loom + +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.netty.internal.{NettyServerInterpreter, RunAsync} + +trait NettyIdServerInterpreter { + def nettyServerOptions: NettyIdServerOptions + + def toRoute( + ses: List[ServerEndpoint[Any, Id]] + ): IdRoute = { + NettyServerInterpreter.toRoute[Id]( + ses, + nettyServerOptions.interceptors, + nettyServerOptions.createFile, + nettyServerOptions.deleteFile, + new RunAsync[Id] { + override def apply[T](f: => Id[T]): Unit = { + val _ = f + () + } + } + ) + } +} + +object NettyIdServerInterpreter { + def apply(serverOptions: NettyIdServerOptions = NettyIdServerOptions.default): NettyIdServerInterpreter = { + new NettyIdServerInterpreter { + override def nettyServerOptions: NettyIdServerOptions = serverOptions + } + } +} diff --git a/server/netty-server/loom/src/main/scala/sttp/tapir/server/netty/loom/NettyIdServerOptions.scala b/server/netty-server/loom/src/main/scala/sttp/tapir/server/netty/loom/NettyIdServerOptions.scala new file mode 100644 index 0000000000..5a48c90e58 --- /dev/null +++ b/server/netty-server/loom/src/main/scala/sttp/tapir/server/netty/loom/NettyIdServerOptions.scala @@ -0,0 +1,56 @@ +package sttp.tapir.server.netty.loom + +import com.typesafe.scalalogging.Logger +import sttp.tapir.model.ServerRequest +import sttp.tapir.server.interceptor.log.{DefaultServerLog, ServerLog} +import sttp.tapir.server.netty.internal.NettyDefaults +import sttp.tapir.server.interceptor.{CustomiseInterceptors, Interceptor} +import sttp.tapir.{Defaults, TapirFile} + +case class NettyIdServerOptions( + interceptors: List[Interceptor[Id]], + createFile: ServerRequest => TapirFile, + deleteFile: TapirFile => Unit +) { + def prependInterceptor(i: Interceptor[Id]): NettyIdServerOptions = copy(interceptors = i :: interceptors) + def appendInterceptor(i: Interceptor[Id]): NettyIdServerOptions = copy(interceptors = interceptors :+ i) +} + +object NettyIdServerOptions { + + /** Default options, using TCP sockets (the most common case). This can be later customised using [[NettyIdServerOptions#nettyOptions()]]. + */ + def default: NettyIdServerOptions = customiseInterceptors.options + + private def default( + interceptors: List[Interceptor[Id]] + ): NettyIdServerOptions = + NettyIdServerOptions( + interceptors, + _ => Defaults.createTempFile(), + Defaults.deleteFile() + ) + + /** Customise the interceptors that are being used when exposing endpoints as a server. By default uses TCP sockets (the most common + * case), but this can be later customised using [[NettyIdServerOptions#nettyOptions()]]. + */ + def customiseInterceptors: CustomiseInterceptors[Id, NettyIdServerOptions] = { + CustomiseInterceptors( + createOptions = (ci: CustomiseInterceptors[Id, NettyIdServerOptions]) => default(ci.interceptors) + ).serverLog(defaultServerLog) + } + + private val log = Logger[NettyIdServerInterpreter] + + lazy val defaultServerLog: ServerLog[Id] = { + DefaultServerLog[Id]( + doLogWhenReceived = debugLog(_, None), + doLogWhenHandled = debugLog, + doLogAllDecodeFailures = debugLog, + doLogExceptions = (msg: String, ex: Throwable) => log.error(msg, ex), + noLog = () + ) + } + + private def debugLog(msg: String, exOpt: Option[Throwable]): Unit = NettyDefaults.debugLog(log, msg, exOpt) +} diff --git a/server/netty-server/loom/src/main/scala/sttp/tapir/server/netty/loom/loom.scala b/server/netty-server/loom/src/main/scala/sttp/tapir/server/netty/loom/loom.scala new file mode 100644 index 0000000000..82c2c015cc --- /dev/null +++ b/server/netty-server/loom/src/main/scala/sttp/tapir/server/netty/loom/loom.scala @@ -0,0 +1,20 @@ +package sttp.tapir.server.netty + +import sttp.monad.MonadError + +package object loom { + type Id[X] = X + type IdRoute = Route[Id] + + private[loom] implicit val idMonad: MonadError[Id] = new MonadError[Id] { + override def unit[T](t: T): Id[T] = t + override def map[T, T2](fa: Id[T])(f: T => T2): Id[T2] = f(fa) + override def flatMap[T, T2](fa: Id[T])(f: T => Id[T2]): Id[T2] = f(fa) + override def error[T](t: Throwable): Id[T] = throw t + override protected def handleWrappedError[T](rt: Id[T])(h: PartialFunction[Throwable, Id[T]]): Id[T] = rt + override def eval[T](t: => T): Id[T] = t + override def ensure[T](f: Id[T], e: => Id[Unit]): Id[T] = + try f + finally e + } +} diff --git a/server/netty-server/loom/src/test/scala/sttp/tapir/server/netty/loom/NettyIdServerTest.scala b/server/netty-server/loom/src/test/scala/sttp/tapir/server/netty/loom/NettyIdServerTest.scala new file mode 100644 index 0000000000..339a30d8a4 --- /dev/null +++ b/server/netty-server/loom/src/test/scala/sttp/tapir/server/netty/loom/NettyIdServerTest.scala @@ -0,0 +1,33 @@ +package sttp.tapir.server.netty.loom + +import cats.effect.{IO, Resource} +import io.netty.channel.nio.NioEventLoopGroup +import org.scalatest.EitherValues +import sttp.tapir.server.netty.internal.FutureUtil.nettyFutureToScala +import sttp.tapir.server.tests._ +import sttp.tapir.tests.{Test, TestSuite} + +import scala.concurrent.Future +import scala.concurrent.duration.FiniteDuration + +class NettyIdServerTest extends TestSuite with EitherValues { + override def tests: Resource[IO, List[Test]] = + backendResource.flatMap { backend => + Resource + .make(IO.delay { + val eventLoopGroup = new NioEventLoopGroup() + + val interpreter = new NettyIdTestServerInterpreter(eventLoopGroup) + val createServerTest = new DefaultCreateServerTest(backend, interpreter) + val sleeper: Sleeper[Id] = (duration: FiniteDuration) => Thread.sleep(duration.toMillis) + + val tests = new AllServerTests(createServerTest, interpreter, backend, staticContent = false, multipart = false).tests() ++ + new ServerGracefulShutdownTests(createServerTest, sleeper).tests() + + (tests, eventLoopGroup) + }) { case (_, eventLoopGroup) => + IO.fromFuture(IO.delay(nettyFutureToScala(eventLoopGroup.shutdownGracefully()): Future[_])).void + } + .map { case (tests, _) => tests } + } +} diff --git a/server/netty-server/loom/src/test/scala/sttp/tapir/server/netty/loom/NettyIdTestServerInterpreter.scala b/server/netty-server/loom/src/test/scala/sttp/tapir/server/netty/loom/NettyIdTestServerInterpreter.scala new file mode 100644 index 0000000000..517f891312 --- /dev/null +++ b/server/netty-server/loom/src/test/scala/sttp/tapir/server/netty/loom/NettyIdTestServerInterpreter.scala @@ -0,0 +1,35 @@ +package sttp.tapir.server.netty.loom + +import cats.data.NonEmptyList +import cats.effect.{IO, Resource} +import io.netty.channel.nio.NioEventLoopGroup +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.netty.NettyConfig +import sttp.tapir.server.tests.TestServerInterpreter +import sttp.tapir.tests.Port + +import scala.concurrent.duration.FiniteDuration + +class NettyIdTestServerInterpreter(eventLoopGroup: NioEventLoopGroup) + extends TestServerInterpreter[Id, Any, NettyIdServerOptions, IdRoute] { + override def route(es: List[ServerEndpoint[Any, Id]], interceptors: Interceptors): IdRoute = { + val serverOptions: NettyIdServerOptions = interceptors(NettyIdServerOptions.customiseInterceptors).options + NettyIdServerInterpreter(serverOptions).toRoute(es) + } + + override def serverWithStop(routes: NonEmptyList[IdRoute], gracefulShutdownTimeout: Option[FiniteDuration] = None): Resource[IO, (Port, IO[Unit])] = { + val config = + NettyConfig.defaultNoStreaming.eventLoopGroup(eventLoopGroup).randomPort.withDontShutdownEventLoopGroupOnClose.noGracefulShutdown + val customizedConfig = gracefulShutdownTimeout.map(config.withGracefulShutdownTimeout).getOrElse(config) + val options = NettyIdServerOptions.default + val bind = IO.blocking(NettyIdServer(options, customizedConfig).addRoutes(routes.toList).start()) + + Resource + .make(bind.map(b => (b, IO.blocking(b.stop())))) { case (_, stop) => stop } + .map { case (b, stop) => (b.port, stop) } + } + + override def server(routes: NonEmptyList[IdRoute]): Resource[IO, Port] = { + serverWithStop(routes).map(_._1) + } +} diff --git a/server/nima-server/src/main/scala/sttp/tapir/server/nima/NimaServerInterpreter.scala b/server/nima-server/src/main/scala/sttp/tapir/server/nima/NimaServerInterpreter.scala new file mode 100644 index 0000000000..9a28839b32 --- /dev/null +++ b/server/nima-server/src/main/scala/sttp/tapir/server/nima/NimaServerInterpreter.scala @@ -0,0 +1,64 @@ +package sttp.tapir.server.nima + +import io.helidon.http.Status +import io.helidon.webserver.http.{Handler, ServerRequest => HelidonServerRequest, ServerResponse => HelidonServerResponse} +import sttp.tapir.capabilities.NoStreams +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.interceptor.RequestResult +import sttp.tapir.server.interceptor.reject.RejectInterceptor +import sttp.tapir.server.interpreter.{BodyListener, FilterServerEndpoints, ServerInterpreter} +import sttp.tapir.server.nima.internal.{NimaBodyListener, NimaRequestBody, NimaServerRequest, NimaToResponseBody, idMonad} + +import java.io.InputStream + +trait NimaServerInterpreter { + def nimaServerOptions: NimaServerOptions + + def toHandler(ses: List[ServerEndpoint[Any, Id]]): Handler = { + val filteredEndpoints = FilterServerEndpoints[Any, Id](ses) + val requestBody = new NimaRequestBody(nimaServerOptions.createFile) + val responseBody = new NimaToResponseBody + val interceptors = RejectInterceptor.disableWhenSingleEndpoint(nimaServerOptions.interceptors, ses) + + (helidonRequest: HelidonServerRequest, helidonResponse: HelidonServerResponse) => { + implicit val bodyListener: BodyListener[Id, InputStream] = new NimaBodyListener(helidonResponse) + + val serverInterpreter = new ServerInterpreter[Any, Id, InputStream, NoStreams]( + filteredEndpoints, + requestBody, + responseBody, + interceptors, + nimaServerOptions.deleteFile + ) + + serverInterpreter(NimaServerRequest(helidonRequest)) match { + case RequestResult.Response(tapirResponse) => + helidonResponse.status(Status.create(tapirResponse.code.code)) + tapirResponse.headers.groupBy(_.name).foreach { case (name, headers) => + helidonResponse.header(name, headers.map(_.value): _*) + } + + tapirResponse.body.fold(ifEmpty = helidonResponse.send()) { tapirInputStream => + val helidonOutputStream = helidonResponse.outputStream() + try { + val _ = tapirInputStream.transferTo(helidonOutputStream) + } finally { + helidonOutputStream.close() + } + } + + // If endpoint matching fails, we return control to Nima + case RequestResult.Failure(_) => + helidonResponse.next() + () + } + } + } +} + +object NimaServerInterpreter { + def apply(serverOptions: NimaServerOptions = NimaServerOptions.Default): NimaServerInterpreter = + new NimaServerInterpreter { + override def nimaServerOptions: NimaServerOptions = serverOptions + } +} diff --git a/server/nima-server/src/main/scala/sttp/tapir/server/nima/NimaServerOptions.scala b/server/nima-server/src/main/scala/sttp/tapir/server/nima/NimaServerOptions.scala new file mode 100644 index 0000000000..64ef7f0092 --- /dev/null +++ b/server/nima-server/src/main/scala/sttp/tapir/server/nima/NimaServerOptions.scala @@ -0,0 +1,45 @@ +package sttp.tapir.server.nima + +import sttp.tapir.{Defaults, TapirFile} +import sttp.tapir.model.ServerRequest +import sttp.tapir.server.interceptor.log.{DefaultServerLog, ServerLog} +import sttp.tapir.server.interceptor.{CustomiseInterceptors, Interceptor} + +import java.util.logging.{Level, Logger} + +case class NimaServerOptions( + interceptors: List[Interceptor[Id]], + createFile: ServerRequest => TapirFile, + deleteFile: TapirFile => Unit +) { + def prependInterceptor(i: Interceptor[Id]): NimaServerOptions = copy(interceptors = i :: interceptors) + def appendInterceptor(i: Interceptor[Id]): NimaServerOptions = copy(interceptors = interceptors :+ i) +} + +object NimaServerOptions { + val Default: NimaServerOptions = customiseInterceptors.options + + private def default(interceptors: List[Interceptor[Id]]): NimaServerOptions = + NimaServerOptions(interceptors, _ => Defaults.createTempFile(), Defaults.deleteFile()) + + def customiseInterceptors: CustomiseInterceptors[Id, NimaServerOptions] = + CustomiseInterceptors( + createOptions = (ci: CustomiseInterceptors[Id, NimaServerOptions]) => default(ci.interceptors) + ).serverLog(defaultServerLog) + + private val log = Logger.getLogger(classOf[NimaServerInterpreter].getName) + + lazy val defaultServerLog: ServerLog[Id] = + DefaultServerLog[Id]( + doLogWhenReceived = debugLog(_, None), + doLogWhenHandled = debugLog, + doLogAllDecodeFailures = debugLog, + doLogExceptions = (msg: String, ex: Throwable) => log.log(Level.SEVERE, msg, ex), + noLog = () + ) + + private def debugLog(msg: String, exOpt: Option[Throwable]): Unit = exOpt match { + case Some(e) => log.log(Level.FINE, msg, e) + case None => log.log(Level.FINE, msg) + } +} diff --git a/server/nima-server/src/main/scala/sttp/tapir/server/nima/internal/NimaBodyListener.scala b/server/nima-server/src/main/scala/sttp/tapir/server/nima/internal/NimaBodyListener.scala new file mode 100644 index 0000000000..bc7970ae9f --- /dev/null +++ b/server/nima-server/src/main/scala/sttp/tapir/server/nima/internal/NimaBodyListener.scala @@ -0,0 +1,15 @@ +package sttp.tapir.server.nima.internal + +import io.helidon.webserver.http.{ServerResponse => JavaNimaServerResponse} +import sttp.tapir.server.interpreter.BodyListener +import sttp.tapir.server.nima.Id + +import java.io.InputStream +import scala.util.{Success, Try} + +private[nima] class NimaBodyListener(res: JavaNimaServerResponse) extends BodyListener[Id, InputStream] { + override def onComplete(body: InputStream)(cb: Try[Unit] => Unit): InputStream = { + res.whenSent(() => cb(Success(()))) + body + } +} diff --git a/server/nima-server/src/main/scala/sttp/tapir/server/nima/internal/NimaRequestBody.scala b/server/nima-server/src/main/scala/sttp/tapir/server/nima/internal/NimaRequestBody.scala new file mode 100644 index 0000000000..00e39b702c --- /dev/null +++ b/server/nima-server/src/main/scala/sttp/tapir/server/nima/internal/NimaRequestBody.scala @@ -0,0 +1,39 @@ +package sttp.tapir.server.nima.internal + +import io.helidon.webserver.http.{ServerRequest => JavaNimaServerRequest} +import sttp.capabilities +import sttp.tapir.{FileRange, InputStreamRange, RawBodyType, TapirFile} +import sttp.tapir.capabilities.NoStreams +import sttp.tapir.model.ServerRequest +import sttp.tapir.server.interpreter.{RawValue, RequestBody} +import sttp.tapir.server.nima.Id + +import java.nio.ByteBuffer +import java.nio.file.{Files, StandardCopyOption} + +private[nima] class NimaRequestBody(createFile: ServerRequest => TapirFile) extends RequestBody[Id, NoStreams] { + override val streams: capabilities.Streams[NoStreams] = NoStreams + + override def toRaw[RAW](serverRequest: ServerRequest, bodyType: RawBodyType[RAW]): RawValue[RAW] = { + def asInputStream = nimaRequest(serverRequest).content().inputStream() + def asByteArray = asInputStream.readAllBytes() + + bodyType match { + case RawBodyType.StringBody(charset) => RawValue(new String(asByteArray, charset)) + case RawBodyType.ByteArrayBody => RawValue(asByteArray) + case RawBodyType.ByteBufferBody => RawValue(ByteBuffer.wrap(asByteArray)) + case RawBodyType.InputStreamBody => RawValue(asInputStream) + case RawBodyType.InputStreamRangeBody => RawValue(InputStreamRange(() => asInputStream)) + case RawBodyType.FileBody => + val file = createFile(serverRequest) + Files.copy(asInputStream, file.toPath, StandardCopyOption.REPLACE_EXISTING) + RawValue(FileRange(file), Seq(FileRange(file))) + case _: RawBodyType.MultipartBody => throw new UnsupportedOperationException("Multipart request body not supported") + } + } + + override def toStream(serverRequest: ServerRequest): streams.BinaryStream = throw new UnsupportedOperationException() + + private def nimaRequest(serverRequest: ServerRequest): JavaNimaServerRequest = + serverRequest.underlying.asInstanceOf[JavaNimaServerRequest] +} diff --git a/server/nima-server/src/main/scala/sttp/tapir/server/nima/internal/NimaServerRequest.scala b/server/nima-server/src/main/scala/sttp/tapir/server/nima/internal/NimaServerRequest.scala new file mode 100644 index 0000000000..f3d8f80e5e --- /dev/null +++ b/server/nima-server/src/main/scala/sttp/tapir/server/nima/internal/NimaServerRequest.scala @@ -0,0 +1,43 @@ +package sttp.tapir.server.nima.internal + +import io.helidon.webserver.http.{ServerRequest => JavaNimaServerRequest} +import sttp.model.{Header, Method, QueryParams, Uri} +import sttp.tapir.{AttributeKey, AttributeMap} +import sttp.tapir.model.{ConnectionInfo, ServerRequest} + +import java.net.{InetSocketAddress, SocketAddress} + +import scala.jdk.CollectionConverters.IterableHasAsScala + +private[nima] case class NimaServerRequest(r: JavaNimaServerRequest, attributes: AttributeMap = AttributeMap.Empty) extends ServerRequest { + override def protocol: String = r.prologue().protocol() + override def connectionInfo: ConnectionInfo = + ConnectionInfo(toInetSocketAddress(r.localPeer().address()), toInetSocketAddress(r.remotePeer().address()), Some(r.isSecure)) + override def underlying: Any = r + override def pathSegments: List[String] = uri.pathSegments.segments.map(_.v).filter(_.nonEmpty).toList + override def queryParameters: QueryParams = uri.params + override def method: Method = Method.unsafeApply(r.prologue().method().text()) + + override lazy val uri: Uri = { + val protocol = emptyIfNull(r.prologue().protocol()) + val authority = emptyIfNull(r.authority()) + val path = emptyIfNull(r.path().rawPath()) + val rawQuery = emptyIfNull(r.query().rawValue()) + val query = if (rawQuery.isEmpty) rawQuery else "?" + rawQuery + val fragment = emptyIfNull(r.prologue().fragment().rawValue()) + Uri.unsafeParse(s"$protocol://$authority$path$query$fragment") + } + + override def headers: Seq[Header] = r.headers().asScala.toSeq.flatMap(hv => hv.allValues().asScala.map(v => Header(hv.name(), v))) + override def attribute[T](k: AttributeKey[T]): Option[T] = attributes.get(k) + override def attribute[T](k: AttributeKey[T], v: T): NimaServerRequest = copy(attributes = attributes.put(k, v)) + override def withUnderlying(underlying: Any): ServerRequest = + NimaServerRequest(r = underlying.asInstanceOf[JavaNimaServerRequest], attributes) + + private def toInetSocketAddress(sa: SocketAddress): Option[InetSocketAddress] = sa match { + case isa: InetSocketAddress => Some(isa) + case _ => None + } + + private def emptyIfNull(s: String): String = if (s == null) "" else s +} diff --git a/server/nima-server/src/main/scala/sttp/tapir/server/nima/internal/NimaToResponseBody.scala b/server/nima-server/src/main/scala/sttp/tapir/server/nima/internal/NimaToResponseBody.scala new file mode 100644 index 0000000000..543a76f2fb --- /dev/null +++ b/server/nima-server/src/main/scala/sttp/tapir/server/nima/internal/NimaToResponseBody.scala @@ -0,0 +1,78 @@ +package sttp.tapir.server.nima.internal + +import sttp.capabilities +import sttp.model.HasHeaders +import sttp.tapir.capabilities.NoStreams +import sttp.tapir.server.interpreter.ToResponseBody +import sttp.tapir.{CodecFormat, RawBodyType, WebSocketBodyOutput} + +import java.io.{ByteArrayInputStream, FileInputStream, InputStream} +import java.nio.ByteBuffer +import java.nio.charset.Charset + +private[nima] class NimaToResponseBody extends ToResponseBody[InputStream, NoStreams] { + + override val streams: capabilities.Streams[NoStreams] = NoStreams + + override def fromRawValue[R](v: R, headers: HasHeaders, format: CodecFormat, bodyType: RawBodyType[R]): InputStream = { + bodyType match { + case RawBodyType.StringBody(charset) => new ByteArrayInputStream(v.getBytes(charset)) + case RawBodyType.ByteArrayBody => new ByteArrayInputStream(v) + case RawBodyType.ByteBufferBody => new ByteBufferBackedInputStream(v) + case RawBodyType.InputStreamBody => v + case rr @ RawBodyType.InputStreamRangeBody => + val base = v.inputStreamFromRangeStart() + v.range.flatMap(_.startAndEnd) match { + case Some((start, end)) => + new LimitedInputStream(base, end - start) + case None => base + } + case RawBodyType.FileBody => + val base = new FileInputStream(v.file) + v.range.flatMap(_.startAndEnd) match { + case Some((start, end)) => + base.skip(start) + new LimitedInputStream(base, end - start) + case None => base + } + case _: RawBodyType.MultipartBody => ??? + } + } + + override def fromStreamValue( + v: streams.BinaryStream, + headers: HasHeaders, + format: CodecFormat, + charset: Option[Charset] + ): InputStream = throw new UnsupportedOperationException + + override def fromWebSocketPipe[REQ, RESP]( + pipe: streams.Pipe[REQ, RESP], + o: WebSocketBodyOutput[streams.Pipe[REQ, RESP], REQ, RESP, _, NoStreams] + ): InputStream = throw new UnsupportedOperationException +} + +// https://stackoverflow.com/questions/4332264/wrapping-a-bytebuffer-with-an-inputstream +private class ByteBufferBackedInputStream(buf: ByteBuffer) extends InputStream { + override def read: Int = { + if (!buf.hasRemaining) return -1 + buf.get & 0xff + } + + override def read(bytes: Array[Byte], off: Int, len: Int): Int = { + if (!buf.hasRemaining) return -1 + val len2 = Math.min(len, buf.remaining) + buf.get(bytes, off, len2) + len2 + } +} + +private class LimitedInputStream(delegate: InputStream, var limit: Long) extends InputStream { + override def read(): Int = { + if (limit == 0L) -1 + else { + limit -= 1 + delegate.read() + } + } +} diff --git a/server/nima-server/src/main/scala/sttp/tapir/server/nima/internal/package.scala b/server/nima-server/src/main/scala/sttp/tapir/server/nima/internal/package.scala new file mode 100644 index 0000000000..31eea37cf9 --- /dev/null +++ b/server/nima-server/src/main/scala/sttp/tapir/server/nima/internal/package.scala @@ -0,0 +1,17 @@ +package sttp.tapir.server.nima + +import sttp.monad.MonadError + +package object internal { + private[nima] implicit val idMonad: MonadError[Id] = new MonadError[Id] { + override def unit[T](t: T): Id[T] = t + override def map[T, T2](fa: Id[T])(f: T => T2): Id[T2] = f(fa) + override def flatMap[T, T2](fa: Id[T])(f: T => Id[T2]): Id[T2] = f(fa) + override def error[T](t: Throwable): Id[T] = throw t + override protected def handleWrappedError[T](rt: Id[T])(h: PartialFunction[Throwable, Id[T]]): Id[T] = rt + override def eval[T](t: => T): Id[T] = t + override def ensure[T](f: Id[T], e: => Id[Unit]): Id[T] = + try f + finally e + } +} diff --git a/server/nima-server/src/main/scala/sttp/tapir/server/nima/nima.scala b/server/nima-server/src/main/scala/sttp/tapir/server/nima/nima.scala new file mode 100644 index 0000000000..23124a1db5 --- /dev/null +++ b/server/nima-server/src/main/scala/sttp/tapir/server/nima/nima.scala @@ -0,0 +1,5 @@ +package sttp.tapir.server + +package object nima { + type Id[X] = X +} diff --git a/server/nima-server/src/test/resources/logback.xml b/server/nima-server/src/test/resources/logback.xml new file mode 100644 index 0000000000..c964c28c2a --- /dev/null +++ b/server/nima-server/src/test/resources/logback.xml @@ -0,0 +1,19 @@ + + + + + + %d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n%rEx + + + + + + + + + + + + + diff --git a/server/nima-server/src/test/scala/sttp/tapir/server/nima/NimaServerTest.scala b/server/nima-server/src/test/scala/sttp/tapir/server/nima/NimaServerTest.scala new file mode 100644 index 0000000000..7b34ce5541 --- /dev/null +++ b/server/nima-server/src/test/scala/sttp/tapir/server/nima/NimaServerTest.scala @@ -0,0 +1,21 @@ +package sttp.tapir.server.nima + +import cats.effect.{IO, Resource} +import org.scalatest.EitherValues +import sttp.tapir.server.nima.internal.idMonad +import sttp.tapir.server.tests._ +import sttp.tapir.tests.{Test, TestSuite} + +class NimaServerTest extends TestSuite with EitherValues { + override def tests: Resource[IO, List[Test]] = + backendResource.flatMap { backend => + Resource + .eval(IO.delay { + val interpreter = new NimaTestServerInterpreter() + val createServerTest = new DefaultCreateServerTest(backend, interpreter) + // TODO uncomment static content tests when Nima starts to correctly support '*' in accept-encoding + new ServerBasicTests(createServerTest, interpreter, invulnerableToUnsanitizedHeaders = false).tests() ++ + new AllServerTests(createServerTest, interpreter, backend, basic = false, multipart = false, staticContent = false).tests() + }) + } +} diff --git a/server/nima-server/src/test/scala/sttp/tapir/server/nima/NimaTestServerInterpreter.scala b/server/nima-server/src/test/scala/sttp/tapir/server/nima/NimaTestServerInterpreter.scala new file mode 100644 index 0000000000..1af6110888 --- /dev/null +++ b/server/nima-server/src/test/scala/sttp/tapir/server/nima/NimaTestServerInterpreter.scala @@ -0,0 +1,35 @@ +package sttp.tapir.server.nima + +import cats.data.NonEmptyList +import cats.effect.{IO, Resource} +import io.helidon.webserver.WebServer +import io.helidon.webserver.http.{Handler, HttpRouting} +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.tests.TestServerInterpreter +import sttp.tapir.tests.Port + +class NimaTestServerInterpreter() extends TestServerInterpreter[Id, Any, NimaServerOptions, Handler] { + override def route(es: List[ServerEndpoint[Any, Id]], interceptors: Interceptors): Handler = { + val serverOptions: NimaServerOptions = interceptors(NimaServerOptions.customiseInterceptors).options + NimaServerInterpreter(serverOptions).toHandler(es) + } + + override def server(nimaRoutes: NonEmptyList[Handler]): Resource[IO, Port] = { + val bind = IO.blocking { + WebServer + .builder() + .routing { (builder: HttpRouting.Builder) => + nimaRoutes.iterator + .foreach(nimaHandler => builder.any(nimaHandler)) + } + .build() + .start() + } + + Resource + .make(bind) { binding => + IO.blocking(binding.stop()).map(_ => ()) + } + .map(b => b.port) + } +}