Skip to content

Commit

Permalink
Merge pull request #2104 from softwaremill/scala-native
Browse files Browse the repository at this point in the history
Support scala native
  • Loading branch information
adamw authored May 4, 2022
2 parents 5b56b80 + a0439f9 commit 0ba37fa
Show file tree
Hide file tree
Showing 17 changed files with 306 additions and 148 deletions.
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
target-platform: [ "JVM", "JS" ]
target-platform: [ "JVM", "JS", "Native" ]
steps:
- name: Checkout
uses: actions/checkout@v2
Expand All @@ -35,6 +35,11 @@ jobs:
unzip -q aws-sam-cli-linux-x86_64.zip -d sam-installation
sudo ./sam-installation/install --update
sam --version
- name: Install libidn11-dev
if: matrix.target-platform == 'Native'
run: |
sudo apt-get update
sudo apt-get install libidn11-dev
- name: Compile
run: sbt -v Test/compile compileDocumentation
- name: Test JVM 2.12
Expand All @@ -55,6 +60,9 @@ jobs:
- name: Test Scala.js
if: matrix.target-platform == 'JS'
run: sbt coreJS/test coreJS2_12/test catsJS/test catsJS2_12/test enumeratumJS/test enumeratumJS2_12/test refinedJS/test refinedJS2_12/test circeJsonJS/test circeJsonJS2_12/test playJsonJS/test playJsonJS2_12/test uPickleJsonJS/test uPickleJsonJS2_12/test jsoniterScalaJS/test jsoniterScalaJS2_12/test sttpClientJS/test sttpClientJS2_12/test
- name: Test Native
if: matrix.target-platform == 'Native'
run: sbt -v testNative
- name: Check MiMa # disable for major releases
if: matrix.target-platform == 'JVM'
run: sbt -v mimaReportBinaryIssues
Expand Down Expand Up @@ -117,6 +125,11 @@ jobs:
~/.ivy2/cache
~/.coursier
key: sbt-cache-${{ runner.os }}-JVM-${{ hashFiles('project/build.properties') }}
- name: Install libidn11-dev
if: matrix.target-platform == 'Native'
run: |
sudo apt-get update
sudo apt-get install libidn11-dev
- name: Compile
run: sbt compile
- name: Publish artifacts
Expand Down
43 changes: 30 additions & 13 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ val scala2And3Versions = scala2Versions ++ List(scala3)
val codegenScalaVersions = List(scala2_12)
val examplesScalaVersions = List(scala2_13)
val documentationScalaVersion = scala2_13
val nativeScalaVersions = List(scala2_13)

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")
Expand Down Expand Up @@ -96,6 +97,8 @@ val commonJvmSettings: Seq[Def.Setting[_]] = commonSettings ++ Seq(
// run JS tests inside Gecko, due to jsdom not supporting fetch and to avoid having to install node
val commonJsSettings = commonSettings ++ browserGeckoTestSettings

val commonNativeSettings = commonSettings

def dependenciesFor(version: String)(deps: (Option[(Long, Long)] => ModuleID)*): Seq[ModuleID] =
deps.map(_.apply(CrossVersion.partialVersion(version)))

Expand Down Expand Up @@ -186,6 +189,7 @@ val testJVM_2_12 = taskKey[Unit]("Test JVM Scala 2.12 projects, without Finatra"
val testJVM_2_13 = taskKey[Unit]("Test JVM Scala 2.13 projects, without Finatra")
val testJVM_3 = taskKey[Unit]("Test JVM Scala 3 projects, without Finatra")
val testJS = taskKey[Unit]("Test JS projects")
val testNative = taskKey[Unit]("Test native projects")
val testDocs = taskKey[Unit]("Test docs projects")
val testServers = taskKey[Unit]("Test server projects")
val testClients = taskKey[Unit]("Test client projects")
Expand Down Expand Up @@ -224,12 +228,19 @@ lazy val rootProject = (project in file("."))
.settings(
publishArtifact := false,
name := "tapir",
testJVM_2_12 := (Test / test).all(filterProject(p => !p.contains("JS") && !p.contains("finatra") && p.contains("2_12"))).value,
testJVM_2_12 := (Test / test)
.all(filterProject(p => !p.contains("JS") && !p.contains("Native") && !p.contains("finatra") && p.contains("2_12")))
.value,
testJVM_2_13 := (Test / test)
.all(filterProject(p => !p.contains("JS") && !p.contains("finatra") && !p.contains("2_12") && !p.contains("3")))
.all(
filterProject(p => !p.contains("JS") && !p.contains("Native") && !p.contains("finatra") && !p.contains("2_12") && !p.contains("3"))
)
.value,
testJVM_3 := (Test / test)
.all(filterProject(p => !p.contains("JS") && !p.contains("Native") && !p.contains("finatra") && p.contains("3")))
.value,
testJVM_3 := (Test / test).all(filterProject(p => !p.contains("JS") && !p.contains("finatra") && p.contains("3"))).value,
testJS := (Test / test).all(filterProject(_.contains("JS"))).value,
testNative := (Test / test).all(filterProject(_.contains("Native"))).value,
testDocs := (Test / test).all(filterProject(p => p.contains("Docs") || p.contains("openapi") || p.contains("asyncapi"))).value,
testServers := (Test / test).all(filterProject(p => p.contains("Server"))).value,
testClients := (Test / test).all(filterProject(p => p.contains("Client"))).value,
Expand Down Expand Up @@ -295,8 +306,7 @@ lazy val core: ProjectMatrix = (projectMatrix in file("core"))
"com.softwaremill.sttp.shared" %%% "ws" % Versions.sttpShared,
scalaTest.value % Test,
scalaCheck.value % Test,
scalaTestPlusScalaCheck.value % Test,
"com.47deg" %%% "scalacheck-toolbox-datetime" % "0.6.0" % Test
scalaTestPlusScalaCheck.value % Test
),
libraryDependencies ++= {
CrossVersion.partialVersion(scalaVersion.value) match {
Expand Down Expand Up @@ -331,6 +341,17 @@ lazy val core: ProjectMatrix = (projectMatrix in file("core"))
)
)
)
.nativePlatform(
scalaVersions = nativeScalaVersions,
settings = {
commonNativeSettings ++ Seq(
libraryDependencies ++= Seq(
"io.github.cquiroz" %%% "scala-java-time" % Versions.nativeScalaJavaTime,
"io.github.cquiroz" %%% "scala-java-time-tzdb" % Versions.nativeScalaJavaTime % Test
)
)
}
)
//.enablePlugins(spray.boilerplate.BoilerplatePlugin)

lazy val testing: ProjectMatrix = (projectMatrix in file("testing"))
Expand All @@ -341,6 +362,7 @@ lazy val testing: ProjectMatrix = (projectMatrix in file("testing"))
)
.jvmPlatform(scalaVersions = scala2And3Versions)
.jsPlatform(scalaVersions = scala2And3Versions, settings = commonJsSettings)
.nativePlatform(scalaVersions = nativeScalaVersions, settings = commonNativeSettings)
.dependsOn(core)

lazy val tests: ProjectMatrix = (projectMatrix in file("tests"))
Expand Down Expand Up @@ -929,14 +951,9 @@ lazy val serverCore: ProjectMatrix = (projectMatrix in file("server/core"))
libraryDependencies ++= Seq(scalaTest.value % Test)
)
.dependsOn(core % CompileAndTest)
.jvmPlatform(
scalaVersions = scala2And3Versions,
settings = commonJvmSettings
)
.jsPlatform(
scalaVersions = scala2And3Versions,
settings = commonJsSettings
)
.jvmPlatform(scalaVersions = scala2And3Versions, settings = commonJvmSettings)
.jsPlatform(scalaVersions = scala2And3Versions, settings = commonJsSettings)
.nativePlatform(scalaVersions = nativeScalaVersions, settings = commonNativeSettings)

lazy val serverTests: ProjectMatrix = (projectMatrix in file("server/tests"))
.settings(commonJvmSettings)
Expand Down
10 changes: 10 additions & 0 deletions core/src/main/scalanative/sttp/tapir/CodecExtensions.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package sttp.tapir

import sttp.tapir.Codec.fileRange
import sttp.tapir.CodecFormat.OctetStream

import java.nio.file.Path

trait CodecExtensions {
implicit lazy val path: Codec[FileRange, Path, OctetStream] = fileRange.map(d => d.file.toPath)(e => FileRange(e.toFile))
}
8 changes: 8 additions & 0 deletions core/src/main/scalanative/sttp/tapir/Defaults.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package sttp.tapir

import java.io.File

object Defaults {
def createTempFile: () => TapirFile = () => File.createTempFile("tapir", "tmp")
def deleteFile(): TapirFile => Unit = file => file.delete()
}
12 changes: 12 additions & 0 deletions core/src/main/scalanative/sttp/tapir/TapirExtensions.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package sttp.tapir

import java.nio.file.Path

trait TapirExtensions {
type TapirFile = java.io.File
def pathBody: EndpointIO.Body[FileRange, Path] = binaryBody(RawBodyType.FileBody)[Path]
}

object TapirFile {
def name(f: TapirFile): String = f.getName
}
34 changes: 34 additions & 0 deletions core/src/main/scalanative/sttp/tapir/internal/UrlencodedData.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package sttp.tapir.internal

import sttp.model.internal.Rfc3986

import java.net.{URLDecoder, URLEncoder}
import java.nio.charset.Charset

private[tapir] object UrlencodedData {
def decode(s: String, charset: Charset): Seq[(String, String)] = {
s.split("&")
.toList
.flatMap(kv =>
kv.split("=", 2) match {
case Array(k, v) =>
Some((URLDecoder.decode(k, charset.toString), URLDecoder.decode(v, charset.toString)))
case _ => None
}
)
}

def encode(s: Seq[(String, String)], charset: Charset): String = {
s.map { case (k, v) =>
s"${URLEncoder.encode(k, charset.toString)}=${URLEncoder.encode(v, charset.toString)}"
}.mkString("&")
}

def encode(s: String): String = {
URLEncoder.encode(s, "UTF-8")
}

def encodePathSegment(s: String): String = {
Rfc3986.encode(Rfc3986.PathSegment)(s)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package sttp.tapir.static

trait TapirStaticContentEndpoints
136 changes: 3 additions & 133 deletions core/src/test/scala/sttp/tapir/CodecTest.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package sttp.tapir

import com.fortysevendeg.scalacheck.datetime.jdk8.ArbitraryJdk8.{arbLocalDateJdk8, arbLocalDateTimeJdk8, genZonedDateTimeWithZone}
import org.scalacheck.{Arbitrary, Gen}
import org.scalatest.Assertion
import org.scalatest.flatspec.AnyFlatSpec
Expand All @@ -14,54 +13,24 @@ import sttp.tapir.DecodeResult.Value
import java.math.{BigDecimal => JBigDecimal}
import java.nio.charset.StandardCharsets
import java.time._
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.util.{Date, UUID}
import scala.concurrent.duration.{Duration => SDuration}
import scala.reflect.ClassTag

// see also CodecTestDateTime
class CodecTest extends AnyFlatSpec with Matchers with Checkers {

implicit val arbitraryUri: Arbitrary[Uri] = Arbitrary(for {
private implicit val arbitraryUri: Arbitrary[Uri] = Arbitrary(for {
scheme <- Gen.alphaLowerStr if scheme.nonEmpty
host <- Gen.identifier.map(_.take(5)) // schemes may not be too long
port <- Gen.option[Int](Gen.chooseNum(1, Short.MaxValue))
path <- Gen.listOfN(5, Gen.identifier)
query <- Gen.mapOf(Gen.zip(Gen.identifier, Gen.identifier))
} yield uri"$scheme://$host:$port/${path.mkString("/")}?$query")

implicit val arbitraryJBigDecimal: Arbitrary[JBigDecimal] = Arbitrary(
private implicit val arbitraryJBigDecimal: Arbitrary[JBigDecimal] = Arbitrary(
implicitly[Arbitrary[BigDecimal]].arbitrary.map(bd => new JBigDecimal(bd.toString))
)

implicit val arbitraryZonedDateTime: Arbitrary[ZonedDateTime] = Arbitrary(
genZonedDateTimeWithZone(Some(ZoneOffset.ofHoursMinutes(3, 30)))
)

implicit val arbitraryOffsetDateTime: Arbitrary[OffsetDateTime] =
Arbitrary(arbitraryZonedDateTime.arbitrary.map(_.toOffsetDateTime))

implicit val arbitraryInstant: Arbitrary[Instant] = Arbitrary(Gen.posNum[Long].map(Instant.ofEpochMilli))

implicit val arbitraryDuration: Arbitrary[Duration] =
Arbitrary(for {
instant1 <- arbitraryInstant.arbitrary
instant2 <- arbitraryInstant.arbitrary.suchThat(_.isAfter(instant1))
} yield Duration.between(instant1, instant2))

implicit val arbitraryScalaDuration: Arbitrary[SDuration] =
Arbitrary(arbitraryDuration.arbitrary.map(d => SDuration.fromNanos(d.toNanos): SDuration))

implicit val arbitraryZoneOffset: Arbitrary[ZoneOffset] = Arbitrary(
arbitraryOffsetDateTime.arbitrary.map(_.getOffset)
)

implicit val arbitraryLocalTime: Arbitrary[LocalTime] = Arbitrary(Gen.chooseNum[Long](0, 86399999999999L).map(LocalTime.ofNanoOfDay))

implicit val arbitraryOffsetTime: Arbitrary[OffsetTime] = Arbitrary(arbitraryOffsetDateTime.arbitrary.map(_.toOffsetTime))

val localDateTimeCodec: Codec[String, LocalDateTime, TextPlain] = implicitly[Codec[String, LocalDateTime, TextPlain]]

it should "encode simple types using .toString" in {
checkEncodeDecodeToString[String]
checkEncodeDecodeToString[Byte]
Expand All @@ -74,105 +43,6 @@ class CodecTest extends AnyFlatSpec with Matchers with Checkers {
checkEncodeDecodeToString[Uri]
checkEncodeDecodeToString[BigDecimal]
checkEncodeDecodeToString[JBigDecimal]
checkEncodeDecodeToString[Duration]
checkEncodeDecodeToString[SDuration]
}

// Because there is no separate standard for local date time, we encode it WITH timezone set to "Z"
// https://swagger.io/docs/specification/data-models/data-types/#string
it should "encode LocalDateTime with timezone" in {
check((ldt: LocalDateTime) => localDateTimeCodec.encode(ldt) == OffsetDateTime.of(ldt, ZoneOffset.UTC).toString)
}

it should "decode LocalDateTime from string with timezone" in {
check((zdt: ZonedDateTime) =>
localDateTimeCodec.decode(DateTimeFormatter.ISO_ZONED_DATE_TIME.format(zdt)) == Value(zdt.toLocalDateTime)
)
}

it should "correctly encode and decode ZonedDateTime" in {
val codec = implicitly[Codec[String, ZonedDateTime, TextPlain]]
codec.encode(ZonedDateTime.of(LocalDateTime.of(2010, 9, 22, 14, 32, 1), ZoneOffset.ofHours(5))) shouldBe "2010-09-22T14:32:01+05:00"
codec.encode(ZonedDateTime.of(LocalDateTime.of(2010, 9, 22, 14, 32, 1), ZoneOffset.UTC)) shouldBe "2010-09-22T14:32:01Z"
check { (zdt: ZonedDateTime) =>
val encoded = codec.encode(zdt)
codec.decode(encoded) == Value(zdt) && ZonedDateTime.parse(encoded) == zdt
}
}

it should "correctly encode and decode OffsetDateTime" in {
val codec = implicitly[Codec[String, OffsetDateTime, TextPlain]]
codec.encode(OffsetDateTime.of(LocalDateTime.of(2019, 12, 31, 23, 59, 14), ZoneOffset.ofHours(5))) shouldBe "2019-12-31T23:59:14+05:00"
codec.encode(OffsetDateTime.of(LocalDateTime.of(2020, 9, 22, 14, 32, 1), ZoneOffset.ofHours(0))) shouldBe "2020-09-22T14:32:01Z"
check { (odt: OffsetDateTime) =>
val encoded = codec.encode(odt)
codec.decode(encoded) == Value(odt) && OffsetDateTime.parse(encoded) == odt
}
}

it should "correctly encode and decode Instant" in {
val codec = implicitly[Codec[String, Instant, TextPlain]]
codec.encode(Instant.ofEpochMilli(1583760958000L)) shouldBe "2020-03-09T13:35:58Z"
codec.encode(Instant.EPOCH) shouldBe "1970-01-01T00:00:00Z"
codec.decode("2020-02-19T12:35:58Z") shouldBe Value(Instant.ofEpochMilli(1582115758000L))
check { (i: Instant) =>
val encoded = codec.encode(i)
codec.decode(encoded) == Value(i) && Instant.parse(encoded) == i
}
}

it should "correctly encode and decode example Durations" in {
val codec = implicitly[Codec[String, Duration, TextPlain]]
val start = OffsetDateTime.parse("2020-02-19T12:35:58Z")
codec.encode(Duration.between(start, start.plusDays(791).plusDays(12).plusSeconds(3))) shouldBe "PT19272H3S"
codec.decode("PT3H15S") shouldBe Value(Duration.of(10815000, ChronoUnit.MILLIS))
check { (d: Duration) =>
val encoded = codec.encode(d)
codec.decode(encoded) == Value(d) && Duration.parse(encoded) == d
}
}

it should "correctly encode and decode example ZoneOffsets" in {
val codec = implicitly[Codec[String, ZoneOffset, TextPlain]]
codec.encode(ZoneOffset.UTC) shouldBe "Z"
codec.encode(ZoneId.of("Europe/Moscow").getRules.getOffset(Instant.ofEpochMilli(1582115758000L))) shouldBe "+03:00"
codec.decode("-04:30") shouldBe Value(ZoneOffset.ofHoursMinutes(-4, -30))
}

it should "correctly encode and decode example OffsetTime" in {
val codec = implicitly[Codec[String, OffsetTime, TextPlain]]
codec.encode(OffsetTime.parse("13:45:30.123456789+02:00")) shouldBe "13:45:30.123456789+02:00"
codec.decode("12:00-11:30") shouldBe Value(OffsetTime.of(12, 0, 0, 0, ZoneOffset.ofHoursMinutes(-11, -30)))
codec.decode("06:15Z") shouldBe Value(OffsetTime.of(6, 15, 0, 0, ZoneOffset.UTC))
check { (ot: OffsetTime) =>
val encoded = codec.encode(ot)
codec.decode(encoded) == Value(ot) && OffsetTime.parse(encoded) == ot
}
}

it should "decode LocalDateTime from string without timezone" in {
check((ldt: LocalDateTime) => localDateTimeCodec.decode(ldt.toString) == Value(ldt))
}

it should "correctly encode and decode LocalTime" in {
val codec = implicitly[Codec[String, LocalTime, TextPlain]]
codec.encode(LocalTime.of(22, 59, 31, 3)) shouldBe "22:59:31.000000003"
codec.encode(LocalTime.of(13, 30)) shouldBe "13:30:00"
codec.decode("22:59:31.000000003") shouldBe Value(LocalTime.of(22, 59, 31, 3))
check { (lt: LocalTime) =>
val encoded = codec.encode(lt)
codec.decode(encoded) == Value(lt) && LocalTime.parse(encoded) == lt
}
}

it should "correctly encode and decode LocalDate" in {
val codec = implicitly[Codec[String, LocalDate, TextPlain]]
codec.encode(LocalDate.of(2019, 12, 31)) shouldBe "2019-12-31"
codec.encode(LocalDate.of(2020, 9, 22)) shouldBe "2020-09-22"
check { (ld: LocalDate) =>
val encoded = codec.encode(ld)
codec.decode(encoded) == Value(ld) && LocalDate.parse(encoded) == ld
}
}

it should "use default, when available" in {
Expand Down
Loading

0 comments on commit 0ba37fa

Please sign in to comment.