From cd9d06a153824f4cbd02af5f647145c36fd4b3f5 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 1/9] 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 | 1 + .../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, 416 insertions(+), 210 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 4c9d05b6f6..a02a0e2bb0 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") + concurrentRestrictions in Global += Tags.limit(Tags.Test, 1) excludeLintKeys in Global ++= Set(ideSkipProject) @@ -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 1fe493edfe..18c095d3ea 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -9,3 +9,4 @@ addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.2.13") 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.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 74045f61cc..876a77345d 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 @@ -15,8 +15,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) @@ -90,7 +88,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] = @@ -432,22 +430,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() + } + } +} From 6de1a257d7201475b7a488e2cb871ac43e44d3e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Brunk?= Date: Tue, 24 Nov 2020 22:03:09 +0100 Subject: [PATCH 2/9] Fix multipart tests on Scala.js, in part through updating sttp --- .../sttp/tapir/client/tests/ClientMultipartTests.scala | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 8fcdff4361..e61e37ea90 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 @@ -21,7 +21,11 @@ trait ClientMultipartTests { this: ClientTests[Any] => 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") + // We can't control the charset in Scala.js because dom.FormData sets the content-type in this case + if (System.getProperty("java.vm.name") == "Scala.js") + afterJson should include("Content-Type: text/plain") + else + afterJson should include("Content-Type: text/plain; charset=UTF-8") afterJson should not include ("Content-Type: application/json") } } From c47d9730064547a0641be03cb87ca47b75aceb16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Brunk?= Date: Wed, 25 Nov 2020 22:38:45 +0100 Subject: [PATCH 3/9] Disable tests that aren't supported in a JavaScript environment --- .../tapir/client/tests/ClientBasicTests.scala | 32 ++++++++++++------- .../client/tests/ClientMultipartTests.scala | 2 +- .../sttp/tapir/client/tests/ClientTests.scala | 2 ++ 3 files changed, 23 insertions(+), 13 deletions(-) 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 0cb5d6c731..25b959973e 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 @@ -49,13 +49,15 @@ trait ClientBasicTests { this: ClientTests[Any] => List("plum", "watermelon", "apple") ).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") - ).unsafeToFuture().map( - _.right.get.head.split(" ;") should contain theSameElementsAs "etanargemop=2c ;32=1c".split(" ;")) + // cookie support in sttp is currently only available on the JVM + if (!platformIsScalaJS) { + test(in_cookie_cookie_out_header.showDetail) { + send( + in_cookie_cookie_out_header, + port, + (23, "pomegranate") + ).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")) @@ -90,19 +92,25 @@ trait ClientBasicTests { this: ClientTests[Any] => in_headers_out_headers, port, List(sttp.model.Header("X-Fruit", "apple"), sttp.model.Header("Y-Fruit", "Orange")) - ).unsafeToFuture().map(_.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)) - .unsafeToFuture().map(_.right.get should contain(sttp.model.Header("Content-Type", "application/json".reverse))) + // the fetch API doesn't allow bodies in get requests + if (!platformIsScalaJS) { + test(in_json_out_headers.showDetail) { + send(in_json_out_headers, port, FruitAmount("apple", 10)) + .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, ()) - .unsafeToFuture().map(_ 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 e61e37ea90..63a77a3030 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 @@ -22,7 +22,7 @@ trait ClientMultipartTests { this: ClientTests[Any] => afterJson should include("""Content-Disposition: form-data; name="notes"""") // We can't control the charset in Scala.js because dom.FormData sets the content-type in this case - if (System.getProperty("java.vm.name") == "Scala.js") + if (platformIsScalaJS) afterJson should include("Content-Type: text/plain") else afterJson should include("Content-Type: text/plain; charset=UTF-8") 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 1a345e9827..86198216e0 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 @@ -44,4 +44,6 @@ abstract class ClientTests[R] extends AsyncFunSuite with Matchers with BeforeAnd } yield adjustedResult shouldBe adjustedExpectedResult } } + + def platformIsScalaJS: Boolean = System.getProperty("java.vm.name") == "Scala.js" } From 24d8972cee16078a4badfccc3f8ad1dac0f8b160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Brunk?= Date: Tue, 1 Dec 2020 22:20:50 +0100 Subject: [PATCH 4/9] Embrace the header merging behaviour of the Fetch API --- .../scala/sttp/tapir/client/tests/ClientBasicTests.scala | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 25b959973e..40f5fb1346 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 @@ -47,7 +47,13 @@ trait ClientBasicTests { this: ClientTests[Any] => in_query_list_out_header_list, port, List("plum", "watermelon", "apple") - ).unsafeToFuture().map(_.right.get should contain theSameElementsAs List("apple", "watermelon", "plum")) + ).unsafeToFuture().map( + _.right.get should contain theSameElementsAs ( + // The fetch API merges multiple header values having the same name into a single comma separated value + if (platformIsScalaJS) + List("apple, watermelon, plum") + else + List("apple", "watermelon", "plum"))) } // cookie support in sttp is currently only available on the JVM if (!platformIsScalaJS) { From 419f891fcc405cf597ea09e9c8afedb04ed408de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Brunk?= Date: Wed, 2 Dec 2020 22:00:50 +0100 Subject: [PATCH 5/9] Switch to Firefox for Scala.js tests to avoid a chrome related issue Issue is tracked here: https://github.com/scala-js/scala-js-env-selenium/issues/119 --- build.sbt | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/build.sbt b/build.sbt index a02a0e2bb0..2f492c5083 100644 --- a/build.sbt +++ b/build.sbt @@ -1,3 +1,5 @@ +import java.io.File +import java.net.URL import java.util.concurrent.atomic.AtomicInteger import com.softwaremill.SbtSoftwareMillBrowserTestJS._ @@ -54,12 +56,68 @@ val commonSettings = commonSmlBuildSettings ++ ossPublishSettings ++ Seq( val commonJvmSettings: Seq[Def.Setting[_]] = commonSettings +lazy val downloadGeckoDriver: TaskKey[Unit] = taskKey[Unit]( + "Download gecko driver" +) + +val downloadGeckoDriverSettings: Seq[Def.Setting[Task[Unit]]] = Seq( + Global / downloadGeckoDriver := { + if ( + java.nio.file.Files.notExists(new File("target", "geckodriver").toPath) + ) { + val version = "v0.28.0" + println(s"geckodriver binary file not found") + import sys.process._ + val osName = sys.props("os.name") + val isMac = osName.toLowerCase.contains("mac") + val isWin = osName.toLowerCase.contains("win") + val platformDependentName = if (isMac) { + "macos.tar.gz" + } else if (isWin) { + "win64.zip" + } else { + "linux64.tar.gz" + } + println(s"Downloading gecko driver version $version for $osName") + val geckoDriverUrl = s"https://github.com/mozilla/geckodriver/releases/download/$version/geckodriver-$version-$platformDependentName" + if (!isWin) { + url(geckoDriverUrl) #> file("target/geckodriver.tar.gz") #&& + "tar -xz -C target -f target/geckodriver.tar.gz" #&& + "rm target/geckodriver.tar.gz" ! + } else { + IO.unzipURL(new URL(geckoDriverUrl), new File("target")) + } + IO.chmod("rwxrwxr-x", new File("target", "geckodriver")) + } else { + println("Detected geckodriver binary file, skipping downloading.") + } + } +) + // run JS tests inside Chrome, due to jsdom not supporting fetch and to avoid having to install node -val commonJsSettings = commonSettings ++ browserTestSettings ++ Seq( +val commonJsSettings = commonSettings ++ downloadGeckoDriverSettings ++ Seq( // https://github.com/scalaz/scalaz/pull/1734#issuecomment-385627061 scalaJSLinkerConfig ~= { _.withBatchMode(System.getenv("CONTINUOUS_INTEGRATION") == "true") - } + }, + jsEnv in Test := { + val debugging = false // set to true to help debugging + System.setProperty("webdriver.gecko.driver", "target/geckodriver") + new org.scalajs.jsenv.selenium.SeleniumJSEnv( + { + val options = new org.openqa.selenium.firefox.FirefoxOptions() + val args = (if (debugging) Seq("--devtools") else Seq("-headless")) + options.addArguments(args: _*) + options + }, + org.scalajs.jsenv.selenium.SeleniumJSEnv + .Config() + .withKeepAlive(debugging) + ) + }, + test in Test := (test in Test) + .dependsOn(downloadGeckoDriver) + .value ) def dependenciesFor(version: String)(deps: (Option[(Long, Long)] => ModuleID)*): Seq[ModuleID] = From 0b00138f636e14b76605ab06f6e6db72da4ccebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Brunk?= Date: Sun, 6 Dec 2020 16:10:49 +0100 Subject: [PATCH 6/9] Temporarily call JS tests for each subproject explicitly Workaround until https://github.com/scala-js/scala-js/issues/4317 has a solution --- .github/workflows/ci.yml | 10 +++++++++- build.sbt | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 445bada92e..cbb130f94a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: matrix: target-platform: [ "JVM", "JS" ] env: - JAVA_OPTS: -Xmx4G + JAVA_OPTS: -Xmx5G steps: - name: Checkout uses: actions/checkout@v2 @@ -29,7 +29,15 @@ jobs: - name: Compile run: sbt -v compile compileDocumentation - name: Test + if: matrix.target-platform != 'JS' run: sbt -v mimaReportBinaryIssues test${{ matrix.target-platform }} + # Temporarily call JS tests for each subproject explicitly as a workaround until + # https://github.com/scala-js/scala-js/issues/4317 has a solution + - name: Test Scala.js + if: matrix.target-platform == 'JS' + run: sbt -v mimaReportBinaryIssues coreJS/test catsJS/test enumeratumJS/test refinedJS/test circeJsonJS/test playJsonJS/test uPickleJsonJS/test jsoniterScalaJS/test sttpClientJS/test + env: + JAVA_OPTS: ${{ matrix.test-heapsize }} - name: Cleanup run: | rm -rf "$HOME/.ivy2/local" || true diff --git a/build.sbt b/build.sbt index 2f492c5083..dca1505339 100644 --- a/build.sbt +++ b/build.sbt @@ -98,7 +98,7 @@ val downloadGeckoDriverSettings: Seq[Def.Setting[Task[Unit]]] = Seq( val commonJsSettings = commonSettings ++ downloadGeckoDriverSettings ++ Seq( // https://github.com/scalaz/scalaz/pull/1734#issuecomment-385627061 scalaJSLinkerConfig ~= { - _.withBatchMode(System.getenv("CONTINUOUS_INTEGRATION") == "true") + _.withBatchMode(System.getenv("GITHUB_ACTIONS") == "true") }, jsEnv in Test := { val debugging = false // set to true to help debugging From 230cd9e5c0258f4702f597f79eccd734cf6cfbef Mon Sep 17 00:00:00 2001 From: adamw Date: Mon, 7 Dec 2020 13:38:08 +0100 Subject: [PATCH 7/9] Rename sbt configuration keys & clarify descriptions --- build.sbt | 51 +++++++++++++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/build.sbt b/build.sbt index dca1505339..f036e8c900 100644 --- a/build.sbt +++ b/build.sbt @@ -4,7 +4,17 @@ 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" @@ -16,8 +26,8 @@ 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") +lazy val clientTestServerPort = settingKey[Int]("Port to run the client interpreter test server on") +lazy val startClientTestServer = taskKey[Unit]("Start a http server used by client interpreter tests") concurrentRestrictions in Global += Tags.limit(Tags.Test, 1) @@ -62,9 +72,7 @@ lazy val downloadGeckoDriver: TaskKey[Unit] = taskKey[Unit]( val downloadGeckoDriverSettings: Seq[Def.Setting[Task[Unit]]] = Seq( Global / downloadGeckoDriver := { - if ( - java.nio.file.Files.notExists(new File("target", "geckodriver").toPath) - ) { + if (java.nio.file.Files.notExists(new File("target", "geckodriver").toPath)) { val version = "v0.28.0" println(s"geckodriver binary file not found") import sys.process._ @@ -195,18 +203,17 @@ 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. +// start a test server before running tests of a client interpreter; 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). val testServerSettings = Seq( test in Test := (test in Test) - .dependsOn(startTestServer in testServer2_13) + .dependsOn(startClientTestServer in testServer2_13) .value, testOnly in Test := (testOnly in Test) - .dependsOn(startTestServer in testServer2_13) + .dependsOn(startClientTestServer in testServer2_13) .evaluated, testOptions in Test += Tests.Setup(() => { - val port = (testServerPort in testServer2_13).value + val port = (clientTestServerPort in testServer2_13).value PollingUtils.waitUntilServerAvailable(new URL(s"http://localhost:$port")) }) ) @@ -223,10 +230,10 @@ lazy val testServer = (projectMatrix in file("client/testserver")) ), // 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}"), + reStartArgs in reStart := Seq(s"${(clientTestServerPort in Test).value}"), fullClasspath in reStart := (fullClasspath in Test).value, - testServerPort := 51823, - startTestServer := reStart.toTask("").value + clientTestServerPort := 51823, + startClientTestServer := reStart.toTask("").value ) .jvmPlatform(scalaVersions = List(scala2_13)) @@ -793,18 +800,18 @@ lazy val sttpClient: ProjectMatrix = (projectMatrix in file("client/sttp-client" .settings( name := "tapir-sttp-client", libraryDependencies ++= Seq( - "com.softwaremill.sttp.client3" %%% "core" % Versions.sttp, + "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 - ) + 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( From 2102606cf8027a9aa07b62205f4abef7b7842a39 Mon Sep 17 00:00:00 2001 From: adamw Date: Mon, 7 Dec 2020 13:38:49 +0100 Subject: [PATCH 8/9] Try removing a GH actions option --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbb130f94a..a31c7fbb51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,6 @@ jobs: - name: Test Scala.js if: matrix.target-platform == 'JS' run: sbt -v mimaReportBinaryIssues coreJS/test catsJS/test enumeratumJS/test refinedJS/test circeJsonJS/test playJsonJS/test uPickleJsonJS/test jsoniterScalaJS/test sttpClientJS/test - env: - JAVA_OPTS: ${{ matrix.test-heapsize }} - name: Cleanup run: | rm -rf "$HOME/.ivy2/local" || true From fd871c6c9b52cecd304e539108b93d5b46853440 Mon Sep 17 00:00:00 2001 From: adamw Date: Mon, 7 Dec 2020 14:21:36 +0100 Subject: [PATCH 9/9] Add test server to aggregates, more renaming --- build.sbt | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/build.sbt b/build.sbt index f036e8c900..75ddc65992 100644 --- a/build.sbt +++ b/build.sbt @@ -184,7 +184,8 @@ lazy val allAggregates = core.projectRefs ++ examples.projectRefs ++ playground.projectRefs ++ documentation.projectRefs ++ - openapiCodegen.projectRefs + openapiCodegen.projectRefs ++ + clientTestServer.projectRefs val testJVM = taskKey[Unit]("Test JVM projects") val testJS = taskKey[Unit]("Test JS projects") @@ -205,20 +206,20 @@ lazy val rootProject = (project in file(".")) // start a test server before running tests of a client interpreter; 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). -val testServerSettings = Seq( +val clientTestServerSettings = Seq( test in Test := (test in Test) - .dependsOn(startClientTestServer in testServer2_13) + .dependsOn(startClientTestServer in clientTestServer2_13) .value, testOnly in Test := (testOnly in Test) - .dependsOn(startClientTestServer in testServer2_13) + .dependsOn(startClientTestServer in clientTestServer2_13) .evaluated, testOptions in Test += Tests.Setup(() => { - val port = (clientTestServerPort in testServer2_13).value + val port = (clientTestServerPort in clientTestServer2_13).value PollingUtils.waitUntilServerAvailable(new URL(s"http://localhost:$port")) }) ) -lazy val testServer = (projectMatrix in file("client/testserver")) +lazy val clientTestServer = (projectMatrix in file("client/testserver")) .settings(commonJvmSettings) .settings( name := "testing-server", @@ -237,7 +238,7 @@ lazy val testServer = (projectMatrix in file("client/testserver")) ) .jvmPlatform(scalaVersions = List(scala2_13)) -lazy val testServer2_13 = testServer.jvm(scala2_13) +lazy val clientTestServer2_13 = clientTestServer.jvm(scala2_13) // core @@ -796,7 +797,7 @@ lazy val clientTests: ProjectMatrix = (projectMatrix in file("client/tests")) .dependsOn(tests) lazy val sttpClient: ProjectMatrix = (projectMatrix in file("client/sttp-client")) - .settings(testServerSettings) + .settings(clientTestServerSettings) .settings( name := "tapir-sttp-client", libraryDependencies ++= Seq(