Skip to content

Commit

Permalink
Add Scala.js support to sttp client
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sbrunk committed Nov 19, 2020
1 parent 4a2ce5a commit 5f1de53
Show file tree
Hide file tree
Showing 27 changed files with 417 additions and 211 deletions.
86 changes: 69 additions & 17 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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(
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
package sttp.tapir.client.sttp

import java.io.File

import sttp.tapir._
import sttp.client3._
import sttp.tapir.generic.auto._
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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] =>
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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"))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}

Expand Down
Loading

0 comments on commit 5f1de53

Please sign in to comment.