From 5f1de53bd9450b26dfe8e308c22dae48157a68b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Brunk?= Date: Tue, 17 Nov 2020 22:43:23 +0100 Subject: [PATCH] Add Scala.js support to sttp client Make client tests async so we can run them on JS. Move client testserver into its own sbt subproject so we can use it from the ScalaTest JS runner. --- build.sbt | 86 ++++++++-- .../tapir/client/play/PlayClientTests.scala | 1 - .../tapir/client/sttp/SttpClientOptions.scala | 6 +- .../TapirSttpClientAkkaHttpWebSockets.scala | 0 .../ws/akkahttp/WebSocketToAkkaPipe.scala | 0 .../client/sttp/ws/akkahttp/package.scala | 0 .../ws/fs2/TapirSttpClientFs2WebSockets.scala | 0 .../sttp/ws/fs2/WebSocketToFs2Pipe.scala | 0 .../tapir/client/sttp/ws/fs2/package.scala | 0 .../client/sttp/SttpClientRequestTests.scala | 5 +- .../tapir/client/sttp/SttpClientTests.scala | 37 +++++ .../sttp/SttpClientStreamingTests.scala | 0 .../tapir/client/sttp/SttpClientTests.scala | 0 .../sttp/SttpClientWebSocketTests.scala | 0 .../tapir/client/tests/ClientBasicTests.scala | 14 +- .../client/tests/ClientMultipartTests.scala | 32 ++-- .../sttp/tapir/client/tests/ClientTests.scala | 156 +++--------------- .../testserver/src/main/resources/logback.xml | 12 ++ .../sttp/tapir/client/tests/HttpServer.scala | 145 ++++++++++++++++ project/PollingUtils.scala | 45 +++++ project/plugins.sbt | 3 +- .../tapir/server/tests/ServerBasicTests.scala | 5 +- .../scala/sttp/tapir/tests/FruitData.scala | 5 +- .../scala/sttp/tapir/tests/TestUtil.scala | 4 +- .../main/scala/sttp/tapir/tests/package.scala | 22 +-- .../sttp/tapir/tests/TestUtilExtensions.scala | 26 +++ .../sttp/tapir/tests/TestUtilExtensions.scala | 24 +++ 27 files changed, 417 insertions(+), 211 deletions(-) rename client/sttp-client/src/main/{scala => scalajvm}/sttp/tapir/client/sttp/ws/akkahttp/TapirSttpClientAkkaHttpWebSockets.scala (100%) rename client/sttp-client/src/main/{scala => scalajvm}/sttp/tapir/client/sttp/ws/akkahttp/WebSocketToAkkaPipe.scala (100%) rename client/sttp-client/src/main/{scala => scalajvm}/sttp/tapir/client/sttp/ws/akkahttp/package.scala (100%) rename client/sttp-client/src/main/{scala => scalajvm}/sttp/tapir/client/sttp/ws/fs2/TapirSttpClientFs2WebSockets.scala (100%) rename client/sttp-client/src/main/{scala => scalajvm}/sttp/tapir/client/sttp/ws/fs2/WebSocketToFs2Pipe.scala (100%) rename client/sttp-client/src/main/{scala => scalajvm}/sttp/tapir/client/sttp/ws/fs2/package.scala (100%) create mode 100644 client/sttp-client/src/test/scalajs/sttp/tapir/client/sttp/SttpClientTests.scala rename client/sttp-client/src/test/{scala => scalajvm}/sttp/tapir/client/sttp/SttpClientStreamingTests.scala (100%) rename client/sttp-client/src/test/{scala => scalajvm}/sttp/tapir/client/sttp/SttpClientTests.scala (100%) rename client/sttp-client/src/test/{scala => scalajvm}/sttp/tapir/client/sttp/SttpClientWebSocketTests.scala (100%) create mode 100644 client/testserver/src/main/resources/logback.xml create mode 100644 client/testserver/src/main/scala/sttp/tapir/client/tests/HttpServer.scala create mode 100644 project/PollingUtils.scala create mode 100644 tests/src/main/scalajs/sttp/tapir/tests/TestUtilExtensions.scala create mode 100644 tests/src/main/scalajvm/sttp/tapir/tests/TestUtilExtensions.scala diff --git a/build.sbt b/build.sbt index e4df6327ad..834d2a468b 100644 --- a/build.sbt +++ b/build.sbt @@ -2,17 +2,7 @@ import java.util.concurrent.atomic.AtomicInteger import com.softwaremill.SbtSoftwareMillBrowserTestJS._ import sbt.Reference.display -import sbtrelease.ReleaseStateTransformations.{ - checkSnapshotDependencies, - commitReleaseVersion, - inquireVersions, - publishArtifacts, - pushChanges, - runClean, - runTest, - setReleaseVersion, - tagRelease -} +import sbtrelease.ReleaseStateTransformations.{checkSnapshotDependencies, commitReleaseVersion, inquireVersions, publishArtifacts, pushChanges, runClean, runTest, setReleaseVersion, tagRelease} import sbt.internal.ProjectMatrix val scala2_12 = "2.12.12" @@ -24,6 +14,9 @@ val documentationScalaVersion = scala2_12 // Documentation depends on finatraSer scalaVersion := scala2_12 +lazy val testServerPort = settingKey[Int]("Port to run the http test server on") +lazy val startTestServer = taskKey[Unit]("Start a http server used by tests") + val commonSettings = commonSmlBuildSettings ++ ossPublishSettings ++ Seq( organization := "com.softwaremill.sttp.tapir", libraryDependencies ++= Seq( @@ -144,6 +137,43 @@ lazy val rootProject = (project in file(".")) ) .aggregate(allAggregates: _*) +// start a test server before running tests of a backend; this is required both for JS tests run inside a +// nodejs/browser environment, as well as for JVM tests where akka-http isn't available (e.g. dotty). To simplify +// things, we always start the test server. +val testServerSettings = Seq( + test in Test := (test in Test) + .dependsOn(startTestServer in testServer2_13) + .value, + testOnly in Test := (testOnly in Test) + .dependsOn(startTestServer in testServer2_13) + .evaluated, + testOptions in Test += Tests.Setup(() => { + val port = (testServerPort in testServer2_13).value + PollingUtils.waitUntilServerAvailable(new URL(s"http://localhost:$port")) + }) +) + +lazy val testServer = (projectMatrix in file("client/testserver")) + .settings(commonJvmSettings) + .settings( + name := "testing-server", + skip in publish := true, + libraryDependencies ++= loggerDependencies ++ Seq( + "org.http4s" %% "http4s-dsl" % Versions.http4s, + "org.http4s" %% "http4s-blaze-server" % Versions.http4s, + "org.http4s" %% "http4s-circe" % Versions.http4s + ), + // the test server needs to be started before running any client tests + mainClass in reStart := Some("sttp.tapir.client.tests.HttpServer"), + reStartArgs in reStart := Seq(s"${(testServerPort in Test).value}"), + fullClasspath in reStart := (fullClasspath in Test).value, + testServerPort := 51823, + startTestServer := reStart.toTask("").value + ) + .jvmPlatform(scalaVersions = List(scala2_13)) + +lazy val testServer2_13 = testServer.jvm(scala2_13) + // core lazy val core: ProjectMatrix = (projectMatrix in file("core")) @@ -190,16 +220,20 @@ lazy val tests: ProjectMatrix = (projectMatrix in file("tests")) .settings( name := "tapir-tests", libraryDependencies ++= Seq( - "io.circe" %% "circe-generic" % Versions.circe, - "com.beachape" %% "enumeratum-circe" % Versions.enumeratum, - "com.softwaremill.common" %% "tagging" % "2.2.1", + "io.circe" %%% "circe-generic" % Versions.circe, + "com.beachape" %%% "enumeratum-circe" % Versions.enumeratum, + "com.softwaremill.common" %%% "tagging" % "2.2.1", scalaTest.value, "com.softwaremill.macwire" %% "macros" % "2.3.7" % "provided", - "org.typelevel" %% "cats-effect" % Versions.catsEffect + "org.typelevel" %%% "cats-effect" % Versions.catsEffect ), libraryDependencies ++= loggerDependencies ) .jvmPlatform(scalaVersions = allScalaVersions) + .jsPlatform( + scalaVersions = allScalaVersions, + settings = commonJsSettings + ) .dependsOn(core, circeJson, enumeratum, cats) // integrations @@ -690,21 +724,39 @@ lazy val clientTests: ProjectMatrix = (projectMatrix in file("client/tests")) ) ) .jvmPlatform(scalaVersions = allScalaVersions) + .jsPlatform( + scalaVersions = allScalaVersions, + settings = commonJsSettings + ) .dependsOn(tests) lazy val sttpClient: ProjectMatrix = (projectMatrix in file("client/sttp-client")) - .settings(commonJvmSettings) + .settings(testServerSettings) .settings( name := "tapir-sttp-client", libraryDependencies ++= Seq( "com.softwaremill.sttp.client3" %%% "core" % Versions.sttp, + ) + ) + .jvmPlatform( + scalaVersions = allScalaVersions, + settings = commonJvmSettings ++ Seq( + libraryDependencies ++= loggerDependencies ++ Seq( "com.softwaremill.sttp.client3" %% "async-http-client-backend-fs2" % Versions.sttp % Test, "com.softwaremill.sttp.shared" %% "fs2" % Versions.sttpShared % Optional, "com.softwaremill.sttp.shared" %% "akka" % Versions.sttpShared % Optional, "com.typesafe.akka" %% "akka-stream" % Versions.akkaStreams % Optional ) + ) + ) + .jsPlatform( + scalaVersions = allScalaVersions, + settings = commonJsSettings ++ Seq( + libraryDependencies ++= Seq( + "io.github.cquiroz" %%% "scala-java-time" % "2.0.0" % Test + ) + ) ) - .jvmPlatform(scalaVersions = allScalaVersions) .dependsOn(core, clientTests % Test) lazy val playClient: ProjectMatrix = (projectMatrix in file("client/play-client")) diff --git a/client/play-client/src/test/scala/sttp/tapir/client/play/PlayClientTests.scala b/client/play-client/src/test/scala/sttp/tapir/client/play/PlayClientTests.scala index b2299c5535..37152d051f 100644 --- a/client/play-client/src/test/scala/sttp/tapir/client/play/PlayClientTests.scala +++ b/client/play-client/src/test/scala/sttp/tapir/client/play/PlayClientTests.scala @@ -8,7 +8,6 @@ import play.api.libs.ws.ahc.StandaloneAhcWSClient import sttp.tapir.client.tests.ClientTests import sttp.tapir.{DecodeResult, Endpoint} -import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.{ExecutionContext, Future} abstract class PlayClientTests[R] extends ClientTests[R] { diff --git a/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/SttpClientOptions.scala b/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/SttpClientOptions.scala index e752fc5fb3..04e6043d5e 100644 --- a/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/SttpClientOptions.scala +++ b/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/SttpClientOptions.scala @@ -1,10 +1,8 @@ package sttp.tapir.client.sttp -import java.io.File +import sttp.tapir.{Defaults, TapirFile} -import sttp.tapir.Defaults - -case class SttpClientOptions(createFile: () => File) +case class SttpClientOptions(createFile: () => TapirFile) object SttpClientOptions { implicit val default: SttpClientOptions = SttpClientOptions(Defaults.createTempFile) diff --git a/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/ws/akkahttp/TapirSttpClientAkkaHttpWebSockets.scala b/client/sttp-client/src/main/scalajvm/sttp/tapir/client/sttp/ws/akkahttp/TapirSttpClientAkkaHttpWebSockets.scala similarity index 100% rename from client/sttp-client/src/main/scala/sttp/tapir/client/sttp/ws/akkahttp/TapirSttpClientAkkaHttpWebSockets.scala rename to client/sttp-client/src/main/scalajvm/sttp/tapir/client/sttp/ws/akkahttp/TapirSttpClientAkkaHttpWebSockets.scala diff --git a/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/ws/akkahttp/WebSocketToAkkaPipe.scala b/client/sttp-client/src/main/scalajvm/sttp/tapir/client/sttp/ws/akkahttp/WebSocketToAkkaPipe.scala similarity index 100% rename from client/sttp-client/src/main/scala/sttp/tapir/client/sttp/ws/akkahttp/WebSocketToAkkaPipe.scala rename to client/sttp-client/src/main/scalajvm/sttp/tapir/client/sttp/ws/akkahttp/WebSocketToAkkaPipe.scala diff --git a/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/ws/akkahttp/package.scala b/client/sttp-client/src/main/scalajvm/sttp/tapir/client/sttp/ws/akkahttp/package.scala similarity index 100% rename from client/sttp-client/src/main/scala/sttp/tapir/client/sttp/ws/akkahttp/package.scala rename to client/sttp-client/src/main/scalajvm/sttp/tapir/client/sttp/ws/akkahttp/package.scala diff --git a/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/ws/fs2/TapirSttpClientFs2WebSockets.scala b/client/sttp-client/src/main/scalajvm/sttp/tapir/client/sttp/ws/fs2/TapirSttpClientFs2WebSockets.scala similarity index 100% rename from client/sttp-client/src/main/scala/sttp/tapir/client/sttp/ws/fs2/TapirSttpClientFs2WebSockets.scala rename to client/sttp-client/src/main/scalajvm/sttp/tapir/client/sttp/ws/fs2/TapirSttpClientFs2WebSockets.scala diff --git a/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/ws/fs2/WebSocketToFs2Pipe.scala b/client/sttp-client/src/main/scalajvm/sttp/tapir/client/sttp/ws/fs2/WebSocketToFs2Pipe.scala similarity index 100% rename from client/sttp-client/src/main/scala/sttp/tapir/client/sttp/ws/fs2/WebSocketToFs2Pipe.scala rename to client/sttp-client/src/main/scalajvm/sttp/tapir/client/sttp/ws/fs2/WebSocketToFs2Pipe.scala diff --git a/client/sttp-client/src/main/scala/sttp/tapir/client/sttp/ws/fs2/package.scala b/client/sttp-client/src/main/scalajvm/sttp/tapir/client/sttp/ws/fs2/package.scala similarity index 100% rename from client/sttp-client/src/main/scala/sttp/tapir/client/sttp/ws/fs2/package.scala rename to client/sttp-client/src/main/scalajvm/sttp/tapir/client/sttp/ws/fs2/package.scala diff --git a/client/sttp-client/src/test/scala/sttp/tapir/client/sttp/SttpClientRequestTests.scala b/client/sttp-client/src/test/scala/sttp/tapir/client/sttp/SttpClientRequestTests.scala index eedd73c8a0..0a473a8b54 100644 --- a/client/sttp-client/src/test/scala/sttp/tapir/client/sttp/SttpClientRequestTests.scala +++ b/client/sttp-client/src/test/scala/sttp/tapir/client/sttp/SttpClientRequestTests.scala @@ -1,7 +1,5 @@ package sttp.tapir.client.sttp -import java.io.File - import sttp.tapir._ import sttp.client3._ import sttp.tapir.generic.auto._ @@ -9,12 +7,13 @@ import sttp.model.{Header, HeaderNames, MediaType, Part} import sttp.tapir.tests.FruitData import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers +import sttp.tapir.Defaults.createTempFile class SttpClientRequestTests extends AnyFunSuite with Matchers { test("content-type header shouldn't be duplicated when converting to a part") { // given val testEndpoint = endpoint.post.in(multipartBody[FruitData]) - val testFile = File.createTempFile("tapir-", "image") + val testFile = createTempFile() // when val sttpClientRequest = testEndpoint diff --git a/client/sttp-client/src/test/scalajs/sttp/tapir/client/sttp/SttpClientTests.scala b/client/sttp-client/src/test/scalajs/sttp/tapir/client/sttp/SttpClientTests.scala new file mode 100644 index 0000000000..89b9fe0197 --- /dev/null +++ b/client/sttp-client/src/test/scalajs/sttp/tapir/client/sttp/SttpClientTests.scala @@ -0,0 +1,37 @@ +package sttp.tapir.client.sttp + +import cats.effect.{ContextShift, IO} + +import scala.concurrent.Future +import sttp.tapir.{DecodeResult, Endpoint} +import sttp.tapir.client.tests.ClientTests +import sttp.client3._ + +abstract class SttpClientTests[R >: Any] extends ClientTests[R] { + implicit val cs: ContextShift[IO] = IO.contextShift(executionContext) + val backend: SttpBackend[Future, R] = FetchBackend() + def wsToPipe: WebSocketToPipe[R] + + override def send[I, E, O, FN[_]](e: Endpoint[I, E, O, R], port: Port, args: I, scheme: String = "http"): IO[Either[E, O]] = { + implicit val wst: WebSocketToPipe[R] = wsToPipe + val response: Future[Either[E, O]] = + e.toSttpRequestUnsafe(uri"$scheme://localhost:$port").apply(args).send(backend).map(_.body) + IO.fromFuture(IO(response)) + } + + override def safeSend[I, E, O, FN[_]]( + e: Endpoint[I, E, O, R], + port: Port, + args: I + ): IO[DecodeResult[Either[E, O]]] = { + implicit val wst: WebSocketToPipe[R] = wsToPipe + def response: Future[DecodeResult[Either[E, O]]] = + e.toSttpRequest(uri"http://localhost:$port").apply(args).send(backend).map(_.body) + IO.fromFuture(IO(response)) + } + + override protected def afterAll(): Unit = { + backend.close() + super.afterAll() + } +} diff --git a/client/sttp-client/src/test/scala/sttp/tapir/client/sttp/SttpClientStreamingTests.scala b/client/sttp-client/src/test/scalajvm/sttp/tapir/client/sttp/SttpClientStreamingTests.scala similarity index 100% rename from client/sttp-client/src/test/scala/sttp/tapir/client/sttp/SttpClientStreamingTests.scala rename to client/sttp-client/src/test/scalajvm/sttp/tapir/client/sttp/SttpClientStreamingTests.scala diff --git a/client/sttp-client/src/test/scala/sttp/tapir/client/sttp/SttpClientTests.scala b/client/sttp-client/src/test/scalajvm/sttp/tapir/client/sttp/SttpClientTests.scala similarity index 100% rename from client/sttp-client/src/test/scala/sttp/tapir/client/sttp/SttpClientTests.scala rename to client/sttp-client/src/test/scalajvm/sttp/tapir/client/sttp/SttpClientTests.scala diff --git a/client/sttp-client/src/test/scala/sttp/tapir/client/sttp/SttpClientWebSocketTests.scala b/client/sttp-client/src/test/scalajvm/sttp/tapir/client/sttp/SttpClientWebSocketTests.scala similarity index 100% rename from client/sttp-client/src/test/scala/sttp/tapir/client/sttp/SttpClientWebSocketTests.scala rename to client/sttp-client/src/test/scalajvm/sttp/tapir/client/sttp/SttpClientWebSocketTests.scala diff --git a/client/tests/src/main/scala/sttp/tapir/client/tests/ClientBasicTests.scala b/client/tests/src/main/scala/sttp/tapir/client/tests/ClientBasicTests.scala index 9ecdc728c3..0cb5d6c731 100644 --- a/client/tests/src/main/scala/sttp/tapir/client/tests/ClientBasicTests.scala +++ b/client/tests/src/main/scala/sttp/tapir/client/tests/ClientBasicTests.scala @@ -6,6 +6,7 @@ import java.nio.ByteBuffer import sttp.model.{QueryParams, StatusCode} import sttp.tapir._ import sttp.tapir.model.UsernamePassword +import sttp.tapir.tests.TestUtil.writeToFile import sttp.tapir.tests._ trait ClientBasicTests { this: ClientTests[Any] => @@ -46,14 +47,15 @@ trait ClientBasicTests { this: ClientTests[Any] => in_query_list_out_header_list, port, List("plum", "watermelon", "apple") - ).unsafeRunSync().right.get should contain theSameElementsAs List("apple", "watermelon", "plum") + ).unsafeToFuture().map(_.right.get should contain theSameElementsAs List("apple", "watermelon", "plum")) } test(in_cookie_cookie_out_header.showDetail) { send( in_cookie_cookie_out_header, port, (23, "pomegranate") - ).unsafeRunSync().right.get.head.split(" ;") should contain theSameElementsAs "etanargemop=2c ;32=1c".split(" ;") + ).unsafeToFuture().map( + _.right.get.head.split(" ;") should contain theSameElementsAs "etanargemop=2c ;32=1c".split(" ;")) } // TODO: test root path testClient(in_auth_apikey_header_out_string, "1234", Right("Authorization=None; X-Api-Key=Some(1234); Query=None")) @@ -88,21 +90,19 @@ trait ClientBasicTests { this: ClientTests[Any] => in_headers_out_headers, port, List(sttp.model.Header("X-Fruit", "apple"), sttp.model.Header("Y-Fruit", "Orange")) - ).unsafeRunSync().right.get should contain allOf (sttp.model.Header("X-Fruit", "elppa"), sttp.model.Header("Y-Fruit", "egnarO")) + ).unsafeToFuture().map(_.right.get should contain allOf (sttp.model.Header("X-Fruit", "elppa"), sttp.model.Header("Y-Fruit", "egnarO"))) } test(in_json_out_headers.showDetail) { send(in_json_out_headers, port, FruitAmount("apple", 10)) - .unsafeRunSync() - .right - .get should contain(sttp.model.Header("Content-Type", "application/json".reverse)) + .unsafeToFuture().map(_.right.get should contain(sttp.model.Header("Content-Type", "application/json".reverse))) } testClient[Unit, Unit, Unit, Nothing](in_unit_out_json_unit, (), Right(())) test(in_fixed_header_out_string.showDetail) { send(in_fixed_header_out_string, port, ()) - .unsafeRunSync() shouldBe Right("Location: secret") + .unsafeToFuture().map(_ shouldBe Right("Location: secret")) } } diff --git a/client/tests/src/main/scala/sttp/tapir/client/tests/ClientMultipartTests.scala b/client/tests/src/main/scala/sttp/tapir/client/tests/ClientMultipartTests.scala index 42719732ee..8fcdff4361 100644 --- a/client/tests/src/main/scala/sttp/tapir/client/tests/ClientMultipartTests.scala +++ b/client/tests/src/main/scala/sttp/tapir/client/tests/ClientMultipartTests.scala @@ -8,22 +8,22 @@ trait ClientMultipartTests { this: ClientTests[Any] => testClient(in_simple_multipart_out_string, FruitAmount("melon", 10), Right("melon=10")) test(in_simple_multipart_out_raw_string.showDetail) { - val result = send(in_simple_multipart_out_raw_string, port, FruitAmountWrapper(FruitAmount("apple", 10), "Now!")) - .unsafeRunSync() - .right - .get - - val indexOfJson = result.indexOf("{\"fruit") - val beforeJson = result.substring(0, indexOfJson) - val afterJson = result.substring(indexOfJson) - - beforeJson should include("""Content-Disposition: form-data; name="fruitAmount"""") - beforeJson should include("Content-Type: application/json") - beforeJson should not include ("Content-Type: text/plain") - - afterJson should include("""Content-Disposition: form-data; name="notes"""") - afterJson should include("Content-Type: text/plain; charset=UTF-8") - afterJson should not include ("Content-Type: application/json") + send(in_simple_multipart_out_raw_string, port, FruitAmountWrapper(FruitAmount("apple", 10), "Now!")) + .unsafeToFuture() + .map(_.right.get) + .map { result => + val indexOfJson = result.indexOf("{\"fruit") + val beforeJson = result.substring(0, indexOfJson) + val afterJson = result.substring(indexOfJson) + + beforeJson should include("""Content-Disposition: form-data; name="fruitAmount"""") + beforeJson should include("Content-Type: application/json") + beforeJson should not include ("Content-Type: text/plain") + + afterJson should include("""Content-Disposition: form-data; name="notes"""") + afterJson should include("Content-Type: text/plain; charset=UTF-8") + afterJson should not include ("Content-Type: application/json") + } } } diff --git a/client/tests/src/main/scala/sttp/tapir/client/tests/ClientTests.scala b/client/tests/src/main/scala/sttp/tapir/client/tests/ClientTests.scala index 07884ef57a..1a345e9827 100644 --- a/client/tests/src/main/scala/sttp/tapir/client/tests/ClientTests.scala +++ b/client/tests/src/main/scala/sttp/tapir/client/tests/ClientTests.scala @@ -1,118 +1,24 @@ package sttp.tapir.client.tests -import java.io.{File, InputStream} +import java.io.InputStream import cats.effect._ import cats.implicits._ -import com.typesafe.scalalogging.StrictLogging -import org.http4s.dsl.io._ -import org.http4s.server.Router -import org.http4s.server.blaze.BlazeServerBuilder -import org.http4s.server.websocket.WebSocketBuilder -import org.http4s.syntax.kleisli._ -import org.http4s.util.CaseInsensitiveString -import org.http4s.websocket.WebSocketFrame -import org.http4s.{multipart, _} -import org.scalatest.{BeforeAndAfterAll, ConfigMap} -import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funsuite.AsyncFunSuite import org.scalatest.matchers.should.Matchers import sttp.tapir.tests.TestUtil._ -import sttp.tapir.tests._ import sttp.tapir.{DecodeResult, _} -import scala.concurrent.ExecutionContext +import scala.concurrent.{ExecutionContext, Future} -abstract class ClientTests[R] extends AnyFunSuite with Matchers with StrictLogging with BeforeAndAfterAll { - implicit private val ec: ExecutionContext = scala.concurrent.ExecutionContext.Implicits.global - implicit private val contextShift: ContextShift[IO] = IO.contextShift(ec) - implicit private val timer: Timer[IO] = IO.timer(ec) - - // - - private object numParam extends QueryParamDecoderMatcher[Int]("num") - private object fruitParam extends QueryParamDecoderMatcher[String]("fruit") - private object amountOptParam extends OptionalQueryParamDecoderMatcher[String]("amount") - private object colorOptParam extends OptionalQueryParamDecoderMatcher[String]("color") - private object apiKeyOptParam extends OptionalQueryParamDecoderMatcher[String]("api-key") - - private val service = HttpRoutes.of[IO] { - case GET -> Root :? fruitParam(f) +& amountOptParam(amount) => - if (f == "papaya") { - Accepted("29") - } else { - Ok(s"fruit: $f${amount.map(" " + _).getOrElse("")}", Header("X-Role", f.length.toString)) - } - case GET -> Root / "fruit" / f => Ok(s"$f") - case GET -> Root / "fruit" / f / "amount" / amount :? colorOptParam(c) => Ok(s"$f $amount $c") - case _ @GET -> Root / "api" / "unit" => Ok("{}") - case r @ GET -> Root / "api" / "echo" / "params" => Ok(r.uri.query.params.toSeq.sortBy(_._1).map(p => s"${p._1}=${p._2}").mkString("&")) - case r @ GET -> Root / "api" / "echo" / "headers" => - val headers = r.headers.toList.map(h => Header(h.name.value, h.value.reverse)) - val filteredHeaders = r.headers.find(_.name.value.equalsIgnoreCase("Cookie")) match { - case Some(c) => headers.filter(_.name.value.equalsIgnoreCase("Cookie")) :+ Header("Set-Cookie", c.value.reverse) - case None => headers - } - Ok(headers = filteredHeaders: _*) - case r @ GET -> Root / "api" / "echo" / "param-to-header" => - Ok(headers = r.uri.multiParams.getOrElse("qq", Nil).reverse.map(v => Header("hh", v)): _*) - case r @ GET -> Root / "api" / "echo" / "param-to-upper-header" => - Ok(headers = r.uri.multiParams.map { case (k, v) => Header(k.toUpperCase(), v.headOption.getOrElse("?")) }.toSeq: _*) - case r @ POST -> Root / "api" / "echo" / "multipart" => - r.decode[multipart.Multipart[IO]] { mp => - val parts: Vector[multipart.Part[IO]] = mp.parts - def toString(s: fs2.Stream[IO, Byte]): IO[String] = s.through(fs2.text.utf8Decode).compile.foldMonoid - def partToString(name: String): IO[String] = parts.find(_.name.contains(name)).map(p => toString(p.body)).getOrElse(IO.pure("")) - partToString("fruit").product(partToString("amount")).flatMap { case (fruit, amount) => - Ok(s"$fruit=$amount") - } - } - case r @ POST -> Root / "api" / "echo" => r.as[String].flatMap(Ok(_)) - case r @ GET -> Root => - r.headers.get(CaseInsensitiveString("X-Role")) match { - case None => Ok() - case Some(h) => Ok("Role: " + h.value) - } - - case r @ GET -> Root / "secret" => - r.headers.get(CaseInsensitiveString("Location")) match { - case None => BadRequest() - case Some(h) => Ok("Location: " + h.value) - } - - case DELETE -> Root / "api" / "delete" => Ok() - - case r @ GET -> Root / "auth" :? apiKeyOptParam(ak) => - val authHeader = r.headers.get(CaseInsensitiveString("Authorization")).map(_.value) - val xApiKey = r.headers.get(CaseInsensitiveString("X-Api-Key")).map(_.value) - Ok(s"Authorization=$authHeader; X-Api-Key=$xApiKey; Query=$ak") - - case GET -> Root / "mapping" :? numParam(v) => - if (v % 2 == 0) Accepted("A") else Ok("B") - - case GET -> Root / "ws" / "echo" => - val echoReply: fs2.Pipe[IO, WebSocketFrame, WebSocketFrame] = - _.collect { case WebSocketFrame.Text(msg, _) => - if (msg.contains("\"f\"")) { - WebSocketFrame.Text(msg.replace("\"f\":\"", "\"f\":\"echo: ")) // json echo - } else { - WebSocketFrame.Text("echo: " + msg) // string echo - } - } - - fs2.concurrent.Queue - .unbounded[IO, WebSocketFrame] - .flatMap { q => - val d = q.dequeue.through(echoReply) - val e = q.enqueue - WebSocketBuilder[IO].build(d, e) - } - } - - private val app: HttpApp[IO] = Router("/" -> service).orNotFound - - // +abstract class ClientTests[R] extends AsyncFunSuite with Matchers with BeforeAndAfterAll { + // Using the default ScalaTest execution context seems to cause issues on JS. + // https://github.com/scalatest/scalatest/issues/1039 + implicit override val executionContext: ExecutionContext = scala.concurrent.ExecutionContext.Implicits.global type Port = Int + var port: Port = 51823 def send[I, E, O, FN[_]](e: Endpoint[I, E, O, R], port: Port, args: I, scheme: String = "http"): IO[Either[E, O]] def safeSend[I, E, O, FN[_]](e: Endpoint[I, E, O, R], port: Port, args: I): IO[DecodeResult[Either[E, O]]] @@ -120,46 +26,22 @@ abstract class ClientTests[R] extends AnyFunSuite with Matchers with StrictLoggi def testClient[I, E, O, FN[_]](e: Endpoint[I, E, O, R], args: I, expectedResult: Either[E, O]): Unit = { test(e.showDetail) { // adjust test result values to a form that is comparable by scalatest - def adjust(r: Either[Any, Any]): Either[Any, Any] = { + def adjust(r: Either[Any, Any]): Future[Either[Any, Any]] = { def doAdjust(v: Any) = v match { - case is: InputStream => inputStreamToByteArray(is).toList - case a: Array[Byte] => a.toList - case f: File => readFromFile(f) - case _ => v + case is: InputStream => Future.successful(inputStreamToByteArray(is).toList) + case a: Array[Byte] => Future.successful(a.toList) + case f: TapirFile => readFromFile(f) + case _ => Future.successful(v) } - r.map(doAdjust).left.map(doAdjust) + r.map(doAdjust).left.map(doAdjust).bisequence } - adjust(send(e, port, args).unsafeRunSync()) shouldBe adjust(expectedResult) + for { + result <- send(e, port, args).unsafeToFuture() + (adjustedResult, adjustedExpectedResult) <- adjust(result).zip(adjust(expectedResult)) + } yield adjustedResult shouldBe adjustedExpectedResult } } - - var port: Port = _ - private var stopServer: IO[Unit] = _ - - override protected def beforeAll(): Unit = { - super.beforeAll() - - val (_port, _stopServer) = BlazeServerBuilder[IO](ec) - .bindHttp(0) - .withHttpApp(app) - .resource - .map(_.address.getPort) - .allocated - .unsafeRunSync() - - port = _port - stopServer = _stopServer - - logger.info(s"Server on port $port started") - } - - override protected def afterAll(): Unit = { - stopServer.unsafeRunSync() - logger.info(s"Server on port $port stopped") - - super.afterAll() - } } diff --git a/client/testserver/src/main/resources/logback.xml b/client/testserver/src/main/resources/logback.xml new file mode 100644 index 0000000000..e6cee15ae7 --- /dev/null +++ b/client/testserver/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + + %date [%thread] %-5level %logger{36} - %msg%n + + + + + + + \ No newline at end of file diff --git a/client/testserver/src/main/scala/sttp/tapir/client/tests/HttpServer.scala b/client/testserver/src/main/scala/sttp/tapir/client/tests/HttpServer.scala new file mode 100644 index 0000000000..673ac7ead3 --- /dev/null +++ b/client/testserver/src/main/scala/sttp/tapir/client/tests/HttpServer.scala @@ -0,0 +1,145 @@ +package sttp.tapir.client.tests + +import cats.effect._ +import cats.implicits._ +import org.http4s.dsl.io._ +import org.http4s.{multipart, _} +import org.http4s.server.middleware._ +import org.http4s.server.Router +import org.http4s.server.blaze.BlazeServerBuilder +import org.http4s.server.websocket.WebSocketBuilder +import org.http4s.syntax.kleisli._ +import org.http4s.util.CaseInsensitiveString +import org.http4s.websocket.WebSocketFrame + +import scala.concurrent.ExecutionContext + +import HttpServer._ + +object HttpServer { + type Port = Int + + def main(args: Array[String]): Unit = { + val port = args.headOption.map(_.toInt).getOrElse(51823) + new HttpServer(port).start() + } +} + + +class HttpServer(port: Port) { + + private val logger = org.log4s.getLogger + + implicit private val ec: ExecutionContext = scala.concurrent.ExecutionContext.Implicits.global + implicit private val contextShift: ContextShift[IO] = IO.contextShift(ec) + implicit private val timer: Timer[IO] = IO.timer(ec) + + private var stopServer: IO[Unit] = _ + + // + + private object numParam extends QueryParamDecoderMatcher[Int]("num") + private object fruitParam extends QueryParamDecoderMatcher[String]("fruit") + private object amountOptParam extends OptionalQueryParamDecoderMatcher[String]("amount") + private object colorOptParam extends OptionalQueryParamDecoderMatcher[String]("color") + private object apiKeyOptParam extends OptionalQueryParamDecoderMatcher[String]("api-key") + + private val service = HttpRoutes.of[IO] { + case GET -> Root :? fruitParam(f) +& amountOptParam(amount) => + if (f == "papaya") { + Accepted("29") + } else { + Ok(s"fruit: $f${amount.map(" " + _).getOrElse("")}", Header("X-Role", f.length.toString)) + } + case GET -> Root / "fruit" / f => Ok(s"$f") + case GET -> Root / "fruit" / f / "amount" / amount :? colorOptParam(c) => Ok(s"$f $amount $c") + case _ @GET -> Root / "api" / "unit" => Ok("{}") + case r @ GET -> Root / "api" / "echo" / "params" => Ok(r.uri.query.params.toSeq.sortBy(_._1).map(p => s"${p._1}=${p._2}").mkString("&")) + case r @ GET -> Root / "api" / "echo" / "headers" => + val headers = r.headers.toList.map(h => Header(h.name.value, h.value.reverse)) + val filteredHeaders = r.headers.find(_.name.value.equalsIgnoreCase("Cookie")) match { + case Some(c) => headers.filter(_.name.value.equalsIgnoreCase("Cookie")) :+ Header("Set-Cookie", c.value.reverse) + case None => headers + } + Ok(headers = filteredHeaders: _*) + case r @ GET -> Root / "api" / "echo" / "param-to-header" => + Ok(headers = r.uri.multiParams.getOrElse("qq", Nil).reverse.map(v => Header("hh", v)): _*) + case r @ GET -> Root / "api" / "echo" / "param-to-upper-header" => + Ok(headers = r.uri.multiParams.map { case (k, v) => Header(k.toUpperCase(), v.headOption.getOrElse("?")) }.toSeq: _*) + case r @ POST -> Root / "api" / "echo" / "multipart" => + r.decode[multipart.Multipart[IO]] { mp => + val parts: Vector[multipart.Part[IO]] = mp.parts + def toString(s: fs2.Stream[IO, Byte]): IO[String] = s.through(fs2.text.utf8Decode).compile.foldMonoid + def partToString(name: String): IO[String] = parts.find(_.name.contains(name)).map(p => toString(p.body)).getOrElse(IO.pure("")) + partToString("fruit").product(partToString("amount")).flatMap { case (fruit, amount) => + Ok(s"$fruit=$amount") + } + } + case r @ POST -> Root / "api" / "echo" => r.as[String].flatMap(Ok(_)) + case r @ GET -> Root => + r.headers.get(CaseInsensitiveString("X-Role")) match { + case None => Ok() + case Some(h) => Ok("Role: " + h.value) + } + + case r @ GET -> Root / "secret" => + r.headers.get(CaseInsensitiveString("Location")) match { + case None => BadRequest() + case Some(h) => Ok("Location: " + h.value) + } + + case DELETE -> Root / "api" / "delete" => Ok() + + case r @ GET -> Root / "auth" :? apiKeyOptParam(ak) => + val authHeader = r.headers.get(CaseInsensitiveString("Authorization")).map(_.value) + val xApiKey = r.headers.get(CaseInsensitiveString("X-Api-Key")).map(_.value) + Ok(s"Authorization=$authHeader; X-Api-Key=$xApiKey; Query=$ak") + + case GET -> Root / "mapping" :? numParam(v) => + if (v % 2 == 0) Accepted("A") else Ok("B") + + case GET -> Root / "ws" / "echo" => + val echoReply: fs2.Pipe[IO, WebSocketFrame, WebSocketFrame] = + _.collect { case WebSocketFrame.Text(msg, _) => + if (msg.contains("\"f\"")) { + WebSocketFrame.Text(msg.replace("\"f\":\"", "\"f\":\"echo: ")) // json echo + } else { + WebSocketFrame.Text("echo: " + msg) // string echo + } + } + + fs2.concurrent.Queue + .unbounded[IO, WebSocketFrame] + .flatMap { q => + val d = q.dequeue.through(echoReply) + val e = q.enqueue + WebSocketBuilder[IO].build(d, e) + } + } + + private val corsService = CORS(service) + private val app: HttpApp[IO] = Router("/" -> corsService).orNotFound + + // + + def start(): Unit = { + val (_, _stopServer) = BlazeServerBuilder[IO](ec) + .bindHttp(port) + .withHttpApp(app) + .resource + .map(_.address.getPort) + .allocated + .unsafeRunSync() + + stopServer = _stopServer + + logger.info(s"Server on port $port started") + } + + def close(): Unit = { + stopServer.unsafeRunSync() + logger.info(s"Server on port $port stopped") + } +} + + diff --git a/project/PollingUtils.scala b/project/PollingUtils.scala new file mode 100644 index 0000000000..34b92ac7c0 --- /dev/null +++ b/project/PollingUtils.scala @@ -0,0 +1,45 @@ +import java.io.FileNotFoundException +import java.net.{ConnectException, URL} + +import scala.concurrent.TimeoutException +import scala.concurrent.duration._ + +object PollingUtils { + + def waitUntilServerAvailable(url: URL): Unit = { + val connected = poll(5.seconds, 250.milliseconds)({ + urlConnectionAvailable(url) + }) + if (!connected) { + throw new TimeoutException(s"Failed to connect to $url") + } + } + + def poll(timeout: FiniteDuration, interval: FiniteDuration)(poll: => Boolean): Boolean = { + val start = System.nanoTime() + + def go(): Boolean = { + if (poll) { + true + } else if ((System.nanoTime() - start) > timeout.toNanos) { + false + } else { + Thread.sleep(interval.toMillis) + go() + } + } + go() + } + + def urlConnectionAvailable(url: URL): Boolean = { + try { + url.openConnection() + .getInputStream + .close() + true + } catch { + case _: ConnectException => false + case _: FileNotFoundException => true // on 404 + } + } +} diff --git a/project/plugins.sbt b/project/plugins.sbt index 4f6f2773ce..2cfb56caee 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -8,4 +8,5 @@ addSbtPlugin("com.typesafe.sbt" % "sbt-twirl" % "1.5.0") addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.2.12") addSbtPlugin("com.eed3si9n" % "sbt-projectmatrix" % "0.6.0") addSbtPlugin("org.jetbrains" % "sbt-ide-settings" % "1.1.0") -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.3.0") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.3.1") +addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") diff --git a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala index ba75c9bf5c..77c4b40c13 100644 --- a/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala +++ b/server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala @@ -19,6 +19,9 @@ import sttp.tapir.tests.TestUtil._ import sttp.tapir.tests._ import org.scalatest.matchers.should.Matchers._ +import scala.concurrent.Await +import scala.concurrent.duration.DurationInt + class ServerBasicTests[F[_], ROUTE]( backend: SttpBackend[IO, Any], serverTests: ServerTests[F, Any, ROUTE], @@ -223,7 +226,7 @@ class ServerBasicTests[F[_], ROUTE]( testServer(in_file_multipart_out_multipart)((fd: FruitData) => pureResult( FruitData( - Part("", writeToFile(readFromFile(fd.data.body).reverse), fd.data.otherDispositionParams, Nil) + Part("", writeToFile(Await.result(readFromFile(fd.data.body), 3.seconds).reverse), fd.data.otherDispositionParams, Nil) .header("X-Auth", fd.data.headers.find(_.is("X-Auth")).map(_.value).toString) ).asRight[Unit] ) diff --git a/tests/src/main/scala/sttp/tapir/tests/FruitData.scala b/tests/src/main/scala/sttp/tapir/tests/FruitData.scala index 87dc3a2322..b07a149b62 100644 --- a/tests/src/main/scala/sttp/tapir/tests/FruitData.scala +++ b/tests/src/main/scala/sttp/tapir/tests/FruitData.scala @@ -1,7 +1,6 @@ package sttp.tapir.tests -import java.io.File - import sttp.model.Part +import sttp.tapir.TapirFile -case class FruitData(data: Part[File]) +case class FruitData(data: Part[TapirFile]) diff --git a/tests/src/main/scala/sttp/tapir/tests/TestUtil.scala b/tests/src/main/scala/sttp/tapir/tests/TestUtil.scala index 8927d12915..ba1f62317e 100644 --- a/tests/src/main/scala/sttp/tapir/tests/TestUtil.scala +++ b/tests/src/main/scala/sttp/tapir/tests/TestUtil.scala @@ -2,6 +2,8 @@ package sttp.tapir.tests import java.io.InputStream -object TestUtil { +trait TestUtil extends TestUtilExtensions { def inputStreamToByteArray(is: InputStream): Array[Byte] = Stream.continually(is.read).takeWhile(_ != -1).map(_.toByte).toArray } + +object TestUtil extends TestUtil \ No newline at end of file diff --git a/tests/src/main/scala/sttp/tapir/tests/package.scala b/tests/src/main/scala/sttp/tapir/tests/package.scala index ac8c1d98b0..d80b714341 100644 --- a/tests/src/main/scala/sttp/tapir/tests/package.scala +++ b/tests/src/main/scala/sttp/tapir/tests/package.scala @@ -1,6 +1,6 @@ package sttp.tapir -import java.io.{File, InputStream, PrintWriter} +import java.io.InputStream import java.nio.ByteBuffer import java.nio.charset.StandardCharsets @@ -16,8 +16,6 @@ import sttp.model.{Cookie, CookieValueWithMeta, CookieWithMeta, Header, HeaderNa import sttp.tapir.Codec.PlainCodec import sttp.tapir.model._ -import scala.io.Source - package object tests { val in_query_out_string: Endpoint[String, Unit, String, Any] = endpoint.in(query[String]("fruit")).out(stringBody) @@ -91,7 +89,7 @@ package object tests { .out(header[Option[Long]]("Content-Length")) .name("input string output stream with header") - val in_file_out_file: Endpoint[File, Unit, File, Any] = + val in_file_out_file: Endpoint[TapirFile, Unit, TapirFile, Any] = endpoint.post.in("api" / "echo").in(fileBody).out(fileBody).name("echo file") val in_unit_out_json_unit: Endpoint[Unit, Unit, Unit, Any] = @@ -434,22 +432,6 @@ package object tests { val allTestEndpoints: Set[Endpoint[_, _, _, _]] = wireSet[Endpoint[_, _, _, _]] ++ Validation.allEndpoints - def writeToFile(s: String): File = { - val f = File.createTempFile("test", "tapir") - new PrintWriter(f) { write(s); close() } - f.deleteOnExit() - f - } - - def readFromFile(f: File): String = { - val s = Source.fromFile(f) - try { - s.mkString - } finally { - s.close() - } - } - type Port = Int } diff --git a/tests/src/main/scalajs/sttp/tapir/tests/TestUtilExtensions.scala b/tests/src/main/scalajs/sttp/tapir/tests/TestUtilExtensions.scala new file mode 100644 index 0000000000..6a1c623d35 --- /dev/null +++ b/tests/src/main/scalajs/sttp/tapir/tests/TestUtilExtensions.scala @@ -0,0 +1,26 @@ +package sttp.tapir.tests + +import scala.concurrent.Future +import scala.scalajs.js +import scala.scalajs.js.JSConverters._ +import scala.scalajs.js.typedarray.AB2TA +import org.scalajs.dom.File +import sttp.tapir.dom.experimental.{File => DomFileWithBody} + +@js.native +trait Blob extends js.Object { + def text(): scala.scalajs.js.Promise[String] = js.native +} + +trait TestUtilExtensions { + def writeToFile(s: String): File = { + new DomFileWithBody( + Array(s.getBytes.toTypedArray.asInstanceOf[js.Any]).toJSArray, + "test.tapir" + ) + } + + def readFromFile(f: File): Future[String] = { + f.asInstanceOf[Blob].text().toFuture + } +} diff --git a/tests/src/main/scalajvm/sttp/tapir/tests/TestUtilExtensions.scala b/tests/src/main/scalajvm/sttp/tapir/tests/TestUtilExtensions.scala new file mode 100644 index 0000000000..9ddf47cc43 --- /dev/null +++ b/tests/src/main/scalajvm/sttp/tapir/tests/TestUtilExtensions.scala @@ -0,0 +1,24 @@ +package sttp.tapir.tests + +import java.io.{File, PrintWriter} + +import scala.concurrent.Future +import scala.io.Source + +trait TestUtilExtensions { + def writeToFile(s: String): File = { + val f = File.createTempFile("test", "tapir") + new PrintWriter(f) { write(s); close() } + f.deleteOnExit() + f + } + + def readFromFile(f: File): Future[String] = { + val s = Source.fromFile(f) + try { + Future.successful(s.mkString) + } finally { + s.close() + } + } +}