diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d192af259..5d392f4913 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/build.sbt b/build.sbt index 2b8de36122..4acf04e24c 100644 --- a/build.sbt +++ b/build.sbt @@ -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") @@ -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))) @@ -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") @@ -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, @@ -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 { @@ -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")) @@ -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")) @@ -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) diff --git a/core/src/main/scalanative/sttp/tapir/CodecExtensions.scala b/core/src/main/scalanative/sttp/tapir/CodecExtensions.scala new file mode 100644 index 0000000000..1d80907b42 --- /dev/null +++ b/core/src/main/scalanative/sttp/tapir/CodecExtensions.scala @@ -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)) +} diff --git a/core/src/main/scalanative/sttp/tapir/Defaults.scala b/core/src/main/scalanative/sttp/tapir/Defaults.scala new file mode 100644 index 0000000000..d951f320c9 --- /dev/null +++ b/core/src/main/scalanative/sttp/tapir/Defaults.scala @@ -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() +} diff --git a/core/src/main/scalanative/sttp/tapir/TapirExtensions.scala b/core/src/main/scalanative/sttp/tapir/TapirExtensions.scala new file mode 100644 index 0000000000..134e9dbecd --- /dev/null +++ b/core/src/main/scalanative/sttp/tapir/TapirExtensions.scala @@ -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 +} diff --git a/core/src/main/scalanative/sttp/tapir/internal/UrlencodedData.scala b/core/src/main/scalanative/sttp/tapir/internal/UrlencodedData.scala new file mode 100644 index 0000000000..c6fc0a43d2 --- /dev/null +++ b/core/src/main/scalanative/sttp/tapir/internal/UrlencodedData.scala @@ -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) + } +} diff --git a/core/src/main/scalanative/sttp/tapir/static/TapirStaticContentEndpoints.scala b/core/src/main/scalanative/sttp/tapir/static/TapirStaticContentEndpoints.scala new file mode 100644 index 0000000000..9fffee4a8a --- /dev/null +++ b/core/src/main/scalanative/sttp/tapir/static/TapirStaticContentEndpoints.scala @@ -0,0 +1,3 @@ +package sttp.tapir.static + +trait TapirStaticContentEndpoints \ No newline at end of file diff --git a/core/src/test/scala/sttp/tapir/CodecTest.scala b/core/src/test/scala/sttp/tapir/CodecTest.scala index 3fb9f649b6..2a1ea36faf 100644 --- a/core/src/test/scala/sttp/tapir/CodecTest.scala +++ b/core/src/test/scala/sttp/tapir/CodecTest.scala @@ -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 @@ -14,15 +13,13 @@ 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)) @@ -30,38 +27,10 @@ class CodecTest extends AnyFlatSpec with Matchers with Checkers { 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] @@ -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 { diff --git a/core/src/test/scalajvm/sttp/tapir/CodecTestDateTime.scala b/core/src/test/scalajvm/sttp/tapir/CodecTestDateTime.scala new file mode 100644 index 0000000000..e503c2e592 --- /dev/null +++ b/core/src/test/scalajvm/sttp/tapir/CodecTestDateTime.scala @@ -0,0 +1,157 @@ +package sttp.tapir + +import org.scalacheck.{Arbitrary, Gen} +import org.scalatest.Assertion +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatestplus.scalacheck.Checkers +import sttp.tapir.CodecFormat.TextPlain +import sttp.tapir.DecodeResult.Value + +import java.time._ +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.Date +import scala.concurrent.duration.{Duration => SDuration} +import scala.reflect.ClassTag + +class CodecTestDateTime extends AnyFlatSpec with Matchers with Checkers { + + private implicit val arbitraryInstant: Arbitrary[Instant] = Arbitrary(Gen.posNum[Long].map(Instant.ofEpochMilli)) + + private implicit val arbitraryDuration: Arbitrary[Duration] = + Arbitrary(for { + instant1 <- arbitraryInstant.arbitrary + instant2 <- arbitraryInstant.arbitrary.suchThat(_.isAfter(instant1)) + } yield Duration.between(instant1, instant2)) + + private implicit val arbitraryScalaDuration: Arbitrary[SDuration] = + Arbitrary(arbitraryDuration.arbitrary.map(d => SDuration.fromNanos(d.toNanos): SDuration)) + + private implicit val arbitraryLocalTime: Arbitrary[LocalTime] = Arbitrary( + Gen.chooseNum[Long](0, 86399999999999L).map(LocalTime.ofNanoOfDay) + ) + + private val localDateTimeCodec: Codec[String, LocalDateTime, TextPlain] = implicitly[Codec[String, LocalDateTime, TextPlain]] + + it should "encode simple types using .toString" in { + 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 "correctly encode and decode Date" in { + // while Date works on Scala.js, ScalaCheck tests involving Date use java.util.Calendar which doesn't - hence here a normal test + val codec = implicitly[Codec[String, Date, TextPlain]] + val d = Date.from(Instant.ofEpochMilli(1619518169000L)) + val encoded = codec.encode(d) + codec.decode(encoded) == Value(d) && Date.from(Instant.parse(encoded)) == d + } + + def checkEncodeDecodeToString[T: Arbitrary](implicit c: Codec[String, T, TextPlain], ct: ClassTag[T]): Assertion = + withClue(s"Test for ${ct.runtimeClass.getName}") { + check((a: T) => { + val decodedAndReEncoded = c.decode(c.encode(a)) match { + case Value(v) => c.encode(v) + case unexpected => fail(s"Value $a got decoded to unexpected $unexpected") + } + decodedAndReEncoded == a.toString + }) + } +} diff --git a/core/src/test/scala/sttp/tapir/internal/RichEndpointOutputTest.scala b/core/src/test/scalajvm/sttp/tapir/internal/RichEndpointOutputTest.scala similarity index 85% rename from core/src/test/scala/sttp/tapir/internal/RichEndpointOutputTest.scala rename to core/src/test/scalajvm/sttp/tapir/internal/RichEndpointOutputTest.scala index 2e57cfa90b..53e4d5d6fc 100644 --- a/core/src/test/scala/sttp/tapir/internal/RichEndpointOutputTest.scala +++ b/core/src/test/scalajvm/sttp/tapir/internal/RichEndpointOutputTest.scala @@ -5,6 +5,7 @@ import org.scalatest.matchers.should.Matchers import sttp.model.MediaType import sttp.tapir._ +// TODO: move to shared test sources when https://github.com/softwaremill/sttp-model/issues/188 is fixed class RichEndpointOutputTest extends AnyFlatSpec with Matchers { "output media type" should "match content type with lower and upper case charset" in { val o = endpoint.put diff --git a/core/src/test/scalanative/sttp/tapir/EndpointTestExtensions.scala b/core/src/test/scalanative/sttp/tapir/EndpointTestExtensions.scala new file mode 100644 index 0000000000..9fea53c524 --- /dev/null +++ b/core/src/test/scalanative/sttp/tapir/EndpointTestExtensions.scala @@ -0,0 +1,3 @@ +package sttp.tapir + +trait EndpointTestExtensions \ No newline at end of file diff --git a/core/src/test/scalanative/sttp/tapir/generic/FormCodecDerivationTestExtensions.scala b/core/src/test/scalanative/sttp/tapir/generic/FormCodecDerivationTestExtensions.scala new file mode 100644 index 0000000000..973d7c3396 --- /dev/null +++ b/core/src/test/scalanative/sttp/tapir/generic/FormCodecDerivationTestExtensions.scala @@ -0,0 +1,3 @@ +package sttp.tapir.generic + +trait FormCodecDerivationTestExtensions \ No newline at end of file diff --git a/core/src/test/scalanative/sttp/tapir/generic/MultipartCodecDerivationTestExtensions.scala b/core/src/test/scalanative/sttp/tapir/generic/MultipartCodecDerivationTestExtensions.scala new file mode 100644 index 0000000000..7f1a225af2 --- /dev/null +++ b/core/src/test/scalanative/sttp/tapir/generic/MultipartCodecDerivationTestExtensions.scala @@ -0,0 +1,7 @@ +package sttp.tapir.generic + +import java.io.File + +trait MultipartCodecDerivationTestExtensions { + def createTempFile() = File.createTempFile("tapir", "test") +} \ No newline at end of file diff --git a/core/src/test/scalanative/sttp/tapir/typelevel/MatchTypeTestExtensions.scala b/core/src/test/scalanative/sttp/tapir/typelevel/MatchTypeTestExtensions.scala new file mode 100644 index 0000000000..7afd23e325 --- /dev/null +++ b/core/src/test/scalanative/sttp/tapir/typelevel/MatchTypeTestExtensions.scala @@ -0,0 +1,17 @@ +package sttp.tapir.typelevel + +trait MatchTypeTestExtensions { + val t: Byte = 0xf.toByte + val s: Short = 1 + + val matcherAndTypes: Seq[(MatchType[_], Any)] = Seq( + implicitly[MatchType[String]] -> "string", + implicitly[MatchType[Boolean]] -> true, + implicitly[MatchType[Char]] -> 'c', + implicitly[MatchType[Byte]] -> t, + implicitly[MatchType[Short]] -> s, + implicitly[MatchType[Float]] -> 42.2f, + implicitly[MatchType[Double]] -> 42.2d, + implicitly[MatchType[Int]] -> 42 + ) +} \ No newline at end of file diff --git a/project/Versions.scala b/project/Versions.scala index 2d27f93134..dfbaf6e0fb 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -32,6 +32,7 @@ object Versions { val tethys = "0.26.0" val vertx = "4.2.7" val jsScalaJavaTime = "2.3.0" + val nativeScalaJavaTime = "2.4.0-M2" val jwtScala = "5.0.0" val derevo = "0.13.0" val newtype = "0.4.4" diff --git a/project/plugins.sbt b/project/plugins.sbt index 9c381e63b9..843cf8dc1b 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -13,3 +13,4 @@ addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.2.0") addSbtPlugin("io.gatling" % "gatling-sbt" % "4.1.5") addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.4") diff --git a/server/core/src/test/scala/sttp/tapir/server/interpreter/DecodeBasicInputsResultTest.scala b/server/core/src/test/scalajvm/sttp/tapir/server/interpreter/DecodeBasicInputsResultTest.scala similarity index 95% rename from server/core/src/test/scala/sttp/tapir/server/interpreter/DecodeBasicInputsResultTest.scala rename to server/core/src/test/scalajvm/sttp/tapir/server/interpreter/DecodeBasicInputsResultTest.scala index 0e0f7132cf..66a401490b 100644 --- a/server/core/src/test/scala/sttp/tapir/server/interpreter/DecodeBasicInputsResultTest.scala +++ b/server/core/src/test/scalajvm/sttp/tapir/server/interpreter/DecodeBasicInputsResultTest.scala @@ -4,11 +4,12 @@ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import sttp.model.Uri._ import sttp.model._ -import sttp.tapir.model._ import sttp.tapir._ +import sttp.tapir.model._ import scala.collection.immutable +// TODO: move to shared sources after https://github.com/softwaremill/sttp-model/issues/188 is fixed class DecodeBasicInputsResultTest extends AnyFlatSpec with Matchers { def testRequest(testHeader: Header): ServerRequest = {