Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Scala.js support to sttp client #860

Merged
merged 9 commits into from
Dec 7, 2020
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
matrix:
target-platform: [ "JVM", "JS" ]
env:
JAVA_OPTS: -Xmx4G
JAVA_OPTS: -Xmx5G
steps:
- name: Checkout
uses: actions/checkout@v2
Expand All @@ -29,7 +29,13 @@ 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
- name: Cleanup
run: |
rm -rf "$HOME/.ivy2/local" || true
Expand Down
148 changes: 133 additions & 15 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import java.io.File
import java.net.URL
import java.util.concurrent.atomic.AtomicInteger

import com.softwaremill.SbtSoftwareMillBrowserTestJS._
Expand All @@ -24,6 +26,9 @@ val documentationScalaVersion = scala2_12 // Documentation depends on finatraSer

scalaVersion := scala2_12

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)

excludeLintKeys in Global ++= Set(ideSkipProject)
Expand Down Expand Up @@ -61,12 +66,66 @@ 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")
}
_.withBatchMode(System.getenv("GITHUB_ACTIONS") == "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] =
Expand Down Expand Up @@ -125,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")
Expand All @@ -144,6 +204,42 @@ lazy val rootProject = (project in file("."))
)
.aggregate(allAggregates: _*)

// 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 clientTestServerSettings = Seq(
test in Test := (test in Test)
.dependsOn(startClientTestServer in clientTestServer2_13)
.value,
testOnly in Test := (testOnly in Test)
.dependsOn(startClientTestServer in clientTestServer2_13)
.evaluated,
testOptions in Test += Tests.Setup(() => {
val port = (clientTestServerPort in clientTestServer2_13).value
PollingUtils.waitUntilServerAvailable(new URL(s"http://localhost:$port"))
})
)

lazy val clientTestServer = (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"${(clientTestServerPort in Test).value}"),
fullClasspath in reStart := (fullClasspath in Test).value,
clientTestServerPort := 51823,
startClientTestServer := reStart.toTask("").value
)
.jvmPlatform(scalaVersions = List(scala2_13))

lazy val clientTestServer2_13 = clientTestServer.jvm(scala2_13)

// core

lazy val core: ProjectMatrix = (projectMatrix in file("core"))
Expand Down Expand Up @@ -190,16 +286,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 +790,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(clientTestServerSettings)
.settings(
name := "tapir-sttp-client",
libraryDependencies ++= Seq(
"com.softwaremill.sttp.client3" %%% "core" % Versions.sttp,
"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
"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,23 @@ 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 (
// 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")))
}
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(" ;")
// 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"))
Expand Down Expand Up @@ -88,21 +98,25 @@ 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))
// 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, ())
.unsafeRunSync() shouldBe Right("Location: secret")
.unsafeToFuture()
.map(_ shouldBe Right("Location: secret"))
}
}

Expand Down
Loading