diff --git a/benchmarks/src/main/scala/pl/iterators/kebs_benchmarks/SprayJsonFormatBenchmark.scala b/benchmarks/src/main/scala/pl/iterators/kebs_benchmarks/SprayJsonFormatBenchmark.scala index 68d5b6b9..459d85b7 100644 --- a/benchmarks/src/main/scala/pl/iterators/kebs_benchmarks/SprayJsonFormatBenchmark.scala +++ b/benchmarks/src/main/scala/pl/iterators/kebs_benchmarks/SprayJsonFormatBenchmark.scala @@ -4,12 +4,12 @@ import java.time.format.DateTimeFormatter import java.time.{LocalDate, LocalTime} import java.util.concurrent.TimeUnit -import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport -import akka.http.scaladsl.marshalling.ToResponseMarshallable -import akka.http.scaladsl.model.StatusCodes._ -import akka.http.scaladsl.model.{ContentTypes, HttpEntity} -import akka.http.scaladsl.server.Directives._ -import akka.http.scaladsl.testkit.ScalatestRouteTest +import org.apache.pekko.http.scaladsl.marshallers.sprayjson.SprayJsonSupport +import org.apache.pekko.http.scaladsl.marshalling.ToResponseMarshallable +import org.apache.pekko.http.scaladsl.model.StatusCodes._ +import org.apache.pekko.http.scaladsl.model.{ContentTypes, HttpEntity} +import org.apache.pekko.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.testkit.ScalatestRouteTest import org.openjdk.jmh.annotations._ import org.scalatest.{FunSpec, Matchers} import pl.iterators.kebs.json.KebsSpray diff --git a/build.sbt b/build.sbt index 9087bd0b..9bafdc15 100644 --- a/build.sbt +++ b/build.sbt @@ -145,8 +145,21 @@ val akkaHttpTestkit = "com.typesafe.akka" %% "akka-http-testkit" % akkaHttpVer def akkaHttpInExamples = { val akkaHttpSprayJson = "com.typesafe.akka" %% "akka-http-spray-json" % akkaHttpVersion Seq(akkaStream.cross(CrossVersion.for3Use2_13), - akkaHttp.cross(CrossVersion.for3Use2_13), - akkaHttpSprayJson.cross(CrossVersion.for3Use2_13)) + akkaHttp.cross(CrossVersion.for3Use2_13), + akkaHttpSprayJson.cross(CrossVersion.for3Use2_13)) +} + +val pekkoVersion = "1.0.1" +val pekkoHttpVersion = "1.0.0" +val pekkoHttpJsonV = "2.0.0" +val pekkoStream = "org.apache.pekko" %% "pekko-stream" % pekkoVersion +val pekkoStreamTestkit = "org.apache.pekko" %% "pekko-stream-testkit" % pekkoVersion +val pekkoHttp = "org.apache.pekko" %% "pekko-http" % pekkoHttpVersion +val pekkoHttpTestkit = "org.apache.pekko" %% "pekko-http-testkit" % pekkoHttpVersion + +def pekkoHttpInExamples = { + val pekkoHttpSprayJson = "org.apache.pekko" %% "pekko-http-spray-json" % pekkoHttpVersion + Seq(pekkoStream, pekkoHttp, pekkoHttpSprayJson) } val http4sVersion = "0.23.23" @@ -158,6 +171,8 @@ val http4sStirTestkit = "pl.iterators" %% "http4s-stir-testkit" % http4sStirVers def akkaHttpInBenchmarks = akkaHttpInExamples :+ (akkaHttpTestkit).cross(CrossVersion.for3Use2_13) +def pekkoHttpInBenchmarks = pekkoHttpInExamples :+ pekkoHttpTestkit + lazy val commonSettings = baseSettings ++ Seq( scalacOptions ++= (if (scalaVersion.value.startsWith("3")) @@ -214,6 +229,16 @@ lazy val akkaHttpSettings = commonSettings ++ Seq( scalacOptions ++= paradiseFlag(scalaVersion.value) ) +lazy val pekkoHttpSettings = commonSettings ++ Seq( + libraryDependencies += pekkoHttp, + libraryDependencies += pekkoStream, + libraryDependencies += pekkoStreamTestkit % "test", + libraryDependencies += pekkoHttpTestkit % "test", + libraryDependencies += optionalEnumeratum, + libraryDependencies ++= paradisePlugin(scalaVersion.value), + scalacOptions ++= paradiseFlag(scalaVersion.value) +) + lazy val http4sSettings = commonSettings ++ Seq( libraryDependencies += http4s, libraryDependencies += optionalEnumeratum, @@ -253,13 +278,14 @@ lazy val examplesSettings = commonSettings ++ Seq( libraryDependencies += slickPg.cross(CrossVersion.for3Use2_13), libraryDependencies += circeParser, libraryDependencies ++= enumeratumInExamples, - libraryDependencies ++= akkaHttpInExamples, + libraryDependencies ++= pekkoHttpInExamples, libraryDependencies ++= paradisePlugin(scalaVersion.value), scalacOptions ++= paradiseFlag(scalaVersion.value) ) lazy val benchmarkSettings = commonSettings ++ Seq( libraryDependencies += scalaTest.value, + libraryDependencies ++= pekkoHttpInBenchmarks, libraryDependencies += enumeratum, libraryDependencies ++= akkaHttpInBenchmarks ) @@ -389,6 +415,18 @@ lazy val akkaHttpSupport = project crossScalaVersions := supportedScalaVersions ) +lazy val pekkoHttpSupport = project + .in(file("pekko-http")) + .dependsOn(core.jvm, instances % "test -> test", tagged.jvm % "test -> test", taggedMeta % "test -> test") + .settings(pekkoHttpSettings: _*) + .settings(publishSettings: _*) + .settings( + name := "pekko-http", + description := "Automatic generation of pekko-http deserializers for 1-element case classes", + moduleName := "kebs-pekko-http", + crossScalaVersions := supportedScalaVersions + ) + lazy val http4sSupport = project .in(file("http4s")) .dependsOn(core.jvm, instances, opaque.jvm % "test -> test", tagged.jvm % "test -> test", taggedMeta % "test -> test") @@ -491,7 +529,7 @@ lazy val taggedMeta = project lazy val examples = project .in(file("examples")) - .dependsOn(slickSupport, sprayJsonSupport, playJsonSupport, akkaHttpSupport, taggedMeta, circeSupport, instances) + .dependsOn(slickSupport, sprayJsonSupport, playJsonSupport, pekkoHttpSupport, taggedMeta, circeSupport, instances) .settings(examplesSettings: _*) .settings(noPublishSettings: _*) .settings(disableScala(List("3"))) @@ -542,6 +580,7 @@ lazy val kebs = project jsonschemaSupport, scalacheckSupport, akkaHttpSupport, + pekkoHttpSupport, http4sSupport, http4sStirSupport, taggedMeta, diff --git a/examples/src/main/scala/pl/iterators/kebs_examples/CirceExample.scala b/examples/src/main/scala/pl/iterators/kebs_examples/CirceExample.scala index 75564ea9..9840a610 100644 --- a/examples/src/main/scala/pl/iterators/kebs_examples/CirceExample.scala +++ b/examples/src/main/scala/pl/iterators/kebs_examples/CirceExample.scala @@ -3,14 +3,14 @@ package pl.iterators.kebs_examples import java.net.URL import java.util.UUID -import akka.http.scaladsl.marshalling.{ToResponseMarshallable, _} -import akka.http.scaladsl.model.MediaTypes.`application/json` -import akka.http.scaladsl.model.StatusCodes._ -import akka.http.scaladsl.model.{ContentType, ContentTypeRange, HttpEntity, MediaType} -import akka.http.scaladsl.server.Directives._ -import akka.http.scaladsl.server.Route -import akka.http.scaladsl.unmarshalling._ -import akka.util.ByteString +import org.apache.pekko.http.scaladsl.marshalling.{ToResponseMarshallable, _} +import org.apache.pekko.http.scaladsl.model.MediaTypes.`application/json` +import org.apache.pekko.http.scaladsl.model.StatusCodes._ +import org.apache.pekko.http.scaladsl.model.{ContentType, ContentTypeRange, HttpEntity, MediaType} +import org.apache.pekko.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.server.Route +import org.apache.pekko.http.scaladsl.unmarshalling._ +import org.apache.pekko.util.ByteString import cats.data.NonEmptyList import io.circe.jawn.parseByteBuffer import io.circe._ @@ -27,7 +27,7 @@ object CirceExample { } object BeforeKebs { - object ThingProtocol extends CirceProtocol with CirceAkkaHttpSupport { + object ThingProtocol extends CirceProtocol with CircePekkoHttpSupport { import io.circe._ import io.circe.generic.semiauto._ implicit val thingCreateRequestEncoder: Encoder[ThingCreateRequest] = deriveEncoder @@ -62,7 +62,7 @@ object CirceExample { } object AfterKebs { - object ThingProtocol extends KebsCirce with CirceProtocol with CirceAkkaHttpSupport + object ThingProtocol extends KebsCirce with CirceProtocol with CircePekkoHttpSupport import ThingProtocol._ class ThingRouter(thingsService: ThingsService)(implicit ec: ExecutionContext) { @@ -105,7 +105,7 @@ object CirceExample { } } -trait CirceAkkaHttpSupport { +trait CircePekkoHttpSupport { implicit def jsonUnmarshaller[T](implicit reader: Decoder[T]): FromEntityUnmarshaller[T] = jsonUnmarshaller diff --git a/examples/src/main/scala/pl/iterators/kebs_examples/EnumSprayJsonFormat.scala b/examples/src/main/scala/pl/iterators/kebs_examples/EnumSprayJsonFormat.scala index 2245f293..ba6812af 100644 --- a/examples/src/main/scala/pl/iterators/kebs_examples/EnumSprayJsonFormat.scala +++ b/examples/src/main/scala/pl/iterators/kebs_examples/EnumSprayJsonFormat.scala @@ -3,10 +3,10 @@ package pl.iterators.kebs_examples import java.net.URL import java.util.UUID -import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport -import akka.http.scaladsl.marshalling.ToResponseMarshallable -import akka.http.scaladsl.model.StatusCodes._ -import akka.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.marshallers.sprayjson.SprayJsonSupport +import org.apache.pekko.http.scaladsl.marshalling.ToResponseMarshallable +import org.apache.pekko.http.scaladsl.model.StatusCodes._ +import org.apache.pekko.http.scaladsl.server.Directives._ import enumeratum.{Enum, EnumEntry} import pl.iterators.kebs.json.{KebsEnumFormats, KebsSpray} import spray.json.{DefaultJsonProtocol, JsString, JsValue, JsonFormat, JsonReader, JsonWriter, deserializationError} diff --git a/examples/src/main/scala/pl/iterators/kebs_examples/AkkaHttpUnmarshallers.scala b/examples/src/main/scala/pl/iterators/kebs_examples/PekkoHttpUnmarshallers.scala similarity index 91% rename from examples/src/main/scala/pl/iterators/kebs_examples/AkkaHttpUnmarshallers.scala rename to examples/src/main/scala/pl/iterators/kebs_examples/PekkoHttpUnmarshallers.scala index ef32e9db..6090cfe9 100644 --- a/examples/src/main/scala/pl/iterators/kebs_examples/AkkaHttpUnmarshallers.scala +++ b/examples/src/main/scala/pl/iterators/kebs_examples/PekkoHttpUnmarshallers.scala @@ -1,13 +1,13 @@ package pl.iterators.kebs_examples -import akka.http.scaladsl.model.StatusCodes -import akka.http.scaladsl.server.Directives._ -import akka.http.scaladsl.unmarshalling.{FromStringUnmarshaller, Unmarshaller} -import akka.http.scaladsl.util.FastFuture +import org.apache.pekko.http.scaladsl.model.StatusCodes +import org.apache.pekko.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.unmarshalling.{FromStringUnmarshaller, Unmarshaller} +import org.apache.pekko.http.scaladsl.util.FastFuture import enumeratum.values._ import enumeratum.{Enum, EnumEntry} -object AkkaHttpUnmarshallers { +object PekkoHttpUnmarshallers { sealed abstract class Column(val value: Int) extends IntEnumEntry object Column extends IntEnum[Column] { case object Name extends Column(1) diff --git a/examples/src/main/scala/pl/iterators/kebs_examples/SprayJsonFormat.scala b/examples/src/main/scala/pl/iterators/kebs_examples/SprayJsonFormat.scala index 4a1c220b..68c29b08 100644 --- a/examples/src/main/scala/pl/iterators/kebs_examples/SprayJsonFormat.scala +++ b/examples/src/main/scala/pl/iterators/kebs_examples/SprayJsonFormat.scala @@ -1,9 +1,9 @@ package pl.iterators.kebs_examples -import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport -import akka.http.scaladsl.marshalling.ToResponseMarshallable -import akka.http.scaladsl.model.StatusCodes._ -import akka.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.marshallers.sprayjson.SprayJsonSupport +import org.apache.pekko.http.scaladsl.marshalling.ToResponseMarshallable +import org.apache.pekko.http.scaladsl.model.StatusCodes._ +import org.apache.pekko.http.scaladsl.server.Directives._ import pl.iterators.kebs.instances.net.URIString import pl.iterators.kebs.instances.util.UUIDString import pl.iterators.kebs.json.KebsSpray diff --git a/examples/src/main/scala/pl/iterators/kebs_examples/SprayJsonWithAkkaHttpExample.scala b/examples/src/main/scala/pl/iterators/kebs_examples/SprayJsonWithPekkoHttpExample.scala similarity index 95% rename from examples/src/main/scala/pl/iterators/kebs_examples/SprayJsonWithAkkaHttpExample.scala rename to examples/src/main/scala/pl/iterators/kebs_examples/SprayJsonWithPekkoHttpExample.scala index 53fdcd7a..b7332a3c 100644 --- a/examples/src/main/scala/pl/iterators/kebs_examples/SprayJsonWithAkkaHttpExample.scala +++ b/examples/src/main/scala/pl/iterators/kebs_examples/SprayJsonWithPekkoHttpExample.scala @@ -4,17 +4,17 @@ import java.io.IOException import java.time.format.DateTimeFormatter import java.time.{LocalDate, LocalTime} -import akka.actor.ActorSystem -import akka.event.LoggingAdapter -import akka.http.scaladsl.Http -import akka.http.scaladsl.client.RequestBuilding -import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport -import akka.http.scaladsl.model.StatusCodes._ -import akka.http.scaladsl.model.Uri.Query -import akka.http.scaladsl.model._ -import akka.http.scaladsl.model.headers.RawHeader -import akka.http.scaladsl.unmarshalling.Unmarshal -import akka.stream.Materializer +import org.apache.pekko.actor.ActorSystem +import org.apache.pekko.event.LoggingAdapter +import org.apache.pekko.http.scaladsl.Http +import org.apache.pekko.http.scaladsl.client.RequestBuilding +import org.apache.pekko.http.scaladsl.marshallers.sprayjson.SprayJsonSupport +import org.apache.pekko.http.scaladsl.model.StatusCodes._ +import org.apache.pekko.http.scaladsl.model.Uri.Query +import org.apache.pekko.http.scaladsl.model._ +import org.apache.pekko.http.scaladsl.model.headers.RawHeader +import org.apache.pekko.http.scaladsl.unmarshalling.Unmarshal +import org.apache.pekko.stream.Materializer import pl.iterators.kebs.json.KebsSpray import spray.json._ @@ -22,7 +22,7 @@ import scala.concurrent.duration._ import scala.concurrent.{ExecutionContext, Future} import scala.util.Try -object SprayJsonWithAkkaHttpExample { +object SprayJsonWithPekkoHttpExample { trait Protocol extends DefaultJsonProtocol with SprayJsonSupport with KebsSpray { implicit val localTimeFormat = new JsonFormat[LocalTime] { override def write(obj: LocalTime): JsValue = JsString(formatter.format(obj)) diff --git a/http4s-stir/src/test/scala-3/pl/iterators/kebs/Http4sStirTagsDomain.scala b/http4s-stir/src/test/scala-3/pl/iterators/kebs/Http4sStirTagsDomain.scala index ffb938b8..62cccb5b 100644 --- a/http4s-stir/src/test/scala-3/pl/iterators/kebs/Http4sStirTagsDomain.scala +++ b/http4s-stir/src/test/scala-3/pl/iterators/kebs/Http4sStirTagsDomain.scala @@ -1,13 +1,18 @@ package pl.iterators.kebs +import pl.iterators.kebs.opaque.Opaque + import java.net.URI import java.util.UUID import pl.iterators.kebs.enums.ValueEnum object Domain { - case class TestTaggedUri(uri: URI) - case class TestId(id: UUID) - case class Id(id: Long) + opaque type TestTaggedUri = URI + object TestTaggedUri extends Opaque[TestTaggedUri, URI] + opaque type TestId = UUID + object TestId extends Opaque[TestId, UUID] + opaque type Id = Long + object Id extends Opaque[Id, Long] case class I(i: Int) case class S(s: String) case class P[A](a: A) diff --git a/http4s-stir/src/test/scala-3/pl/iterators/kebs/matchers/Http4sStirMatchersTests.scala b/http4s-stir/src/test/scala-3/pl/iterators/kebs/matchers/Http4sStirMatchersTests.scala index 5ca6e119..ad6e3b41 100644 --- a/http4s-stir/src/test/scala-3/pl/iterators/kebs/matchers/Http4sStirMatchersTests.scala +++ b/http4s-stir/src/test/scala-3/pl/iterators/kebs/matchers/Http4sStirMatchersTests.scala @@ -23,7 +23,7 @@ class Http4sStirMatchersTests with DayOfWeekInt with InstantEpochMilliLong with URIString { - implicit def runtime: cats.effect.unsafe.IORuntime = cats.effect.unsafe.IORuntime.global + implicit def runtime: cats.effect.unsafe.IORuntime = cats.effect.unsafe.IORuntime.global test("No CaseClass1Rep implicits derived") { import pl.iterators.kebs.macros.CaseClass1Rep @@ -77,7 +77,7 @@ class Http4sStirMatchersTests complete(id.toString) } Get("/test/ce7a7cf1-8c00-49a9-a963-9fd119dd0642") ~> testRoute ~> check { - responseAs[String] shouldEqual "TestTaggedUri(ce7a7cf1-8c00-49a9-a963-9fd119dd0642)" + responseAs[String] shouldEqual "ce7a7cf1-8c00-49a9-a963-9fd119dd0642" } } } diff --git a/http4s-stir/src/test/scala-3/pl/iterators/kebs/unmarshallers/Http4sStirUnmarshallersTests.scala b/http4s-stir/src/test/scala-3/pl/iterators/kebs/unmarshallers/Http4sStirUnmarshallersTests.scala index 3851cdc5..a0dabbed 100644 --- a/http4s-stir/src/test/scala-3/pl/iterators/kebs/unmarshallers/Http4sStirUnmarshallersTests.scala +++ b/http4s-stir/src/test/scala-3/pl/iterators/kebs/unmarshallers/Http4sStirUnmarshallersTests.scala @@ -24,7 +24,7 @@ class Http4sStirUnmarshallersTests with URIString with YearMonthString with DayOfWeekInt { - implicit def runtime: cats.effect.unsafe.IORuntime = cats.effect.unsafe.IORuntime.global + implicit def runtime: cats.effect.unsafe.IORuntime = cats.effect.unsafe.IORuntime.global test("No CaseClass1Rep implicits derived") { import pl.iterators.kebs.macros.CaseClass1Rep @@ -167,7 +167,7 @@ class Http4sStirUnmarshallersTests } Get("/test_tagged?tagged=123456") ~> route ~> check { - responseAs[String] shouldBe "Id(123456)" + responseAs[String] shouldBe "123456" } } @@ -180,7 +180,7 @@ class Http4sStirUnmarshallersTests } Get("/test_tagged?tagged=ce7a7cf1-8c00-49a9-a963-9fd119dd0642") ~> route ~> check { - responseAs[String] shouldBe "TestId(ce7a7cf1-8c00-49a9-a963-9fd119dd0642)" + responseAs[String] shouldBe "ce7a7cf1-8c00-49a9-a963-9fd119dd0642" } } @@ -193,7 +193,7 @@ class Http4sStirUnmarshallersTests } Get("/test_tagged?tagged=www.test.pl") ~> route ~> check { - responseAs[String] shouldBe "TestTaggedUri(www.test.pl)" + responseAs[String] shouldBe "www.test.pl" } } } diff --git a/pekko-http/src/main/scala-2/matchers/KebsMatchers.scala b/pekko-http/src/main/scala-2/matchers/KebsMatchers.scala new file mode 100644 index 00000000..dfa33142 --- /dev/null +++ b/pekko-http/src/main/scala-2/matchers/KebsMatchers.scala @@ -0,0 +1,26 @@ +package pl.iterators.kebs.matchers + +import org.apache.pekko.http.scaladsl.server.{PathMatcher1, PathMatchers} +import enumeratum.{Enum, EnumEntry} +import pl.iterators.kebs.instances.InstanceConverter +import pl.iterators.kebs.macros.CaseClass1Rep + +import scala.language.implicitConversions + +trait KebsMatchers extends PathMatchers { + + implicit class SegmentIsomorphism[U](segment: PathMatcher1[U]) { + def as[T](implicit rep: CaseClass1Rep[T, U]): PathMatcher1[T] = segment.map(rep.apply) + } + + implicit class SegmentConversion[Source](segment: PathMatcher1[Source]) { + def to[Type](implicit ico: InstanceConverter[Type, Source]): PathMatcher1[Type] = segment.map(ico.decode) + } + + object EnumSegment { + def as[T <: EnumEntry: Enum]: PathMatcher1[T] = { + val enumCompanion = implicitly[Enum[T]] + Segment.map(enumCompanion.withNameInsensitive) + } + } +} diff --git a/pekko-http/src/main/scala-2/unmarshallers/enums/KebsEnumUnmarshallers.scala b/pekko-http/src/main/scala-2/unmarshallers/enums/KebsEnumUnmarshallers.scala new file mode 100644 index 00000000..ca127d1b --- /dev/null +++ b/pekko-http/src/main/scala-2/unmarshallers/enums/KebsEnumUnmarshallers.scala @@ -0,0 +1,48 @@ +package pl.iterators.kebs.unmarshallers.enums + +import org.apache.pekko.http.scaladsl.unmarshalling.PredefinedFromStringUnmarshallers._ +import org.apache.pekko.http.scaladsl.unmarshalling.{FromStringUnmarshaller, Unmarshaller} +import org.apache.pekko.http.scaladsl.util.FastFuture +import enumeratum.values._ +import enumeratum.{Enum, EnumEntry} +import pl.iterators.kebs.macros.enums.{EnumOf, ValueEnumOf} + +trait EnumUnmarshallers { + final def enumUnmarshaller[E <: EnumEntry](`enum`: Enum[E]): FromStringUnmarshaller[E] = Unmarshaller { _ => name => + `enum`.withNameInsensitiveOption(name) match { + case Some(enumEntry) => FastFuture.successful(enumEntry) + case None => + FastFuture.failed(new IllegalArgumentException(s"""Invalid value '$name'. Expected one of: ${`enum`.namesToValuesMap.keysIterator + .mkString(", ")}""")) + } + } + + implicit def kebsEnumUnmarshaller[E <: EnumEntry](implicit ev: EnumOf[E]): FromStringUnmarshaller[E] = + enumUnmarshaller(ev.`enum`) +} + +trait ValueEnumUnmarshallers { + final def valueEnumUnmarshaller[V, E <: ValueEnumEntry[V]](`enum`: ValueEnum[V, E]): Unmarshaller[V, E] = Unmarshaller { _ =>v => + `enum`.withValueOpt(v) match { + case Some(enumEntry) => FastFuture.successful(enumEntry) + case None => + FastFuture.failed(new IllegalArgumentException(s"""Invalid value '$v'. Expected one of: ${`enum`.valuesToEntriesMap.keysIterator + .mkString(", ")}""")) + } + } + + implicit def kebsValueEnumUnmarshaller[V, E <: ValueEnumEntry[V]](implicit ev: ValueEnumOf[V, E]): Unmarshaller[V, E] = + valueEnumUnmarshaller(ev.valueEnum) + + implicit def kebsIntValueEnumFromStringUnmarshaller[E <: IntEnumEntry](implicit ev: ValueEnumOf[Int, E]): FromStringUnmarshaller[E] = + intFromStringUnmarshaller andThen valueEnumUnmarshaller(ev.valueEnum) + implicit def kebsLongValueEnumFromStringUnmarshaller[E <: LongEnumEntry](implicit ev: ValueEnumOf[Long, E]): FromStringUnmarshaller[E] = + longFromStringUnmarshaller andThen valueEnumUnmarshaller(ev.valueEnum) + implicit def kebsShortValueEnumFromStringUnmarshaller[E <: ShortEnumEntry]( + implicit ev: ValueEnumOf[Short, E]): FromStringUnmarshaller[E] = + shortFromStringUnmarshaller andThen valueEnumUnmarshaller(ev.valueEnum) + implicit def kebsByteValueEnumFromStringUnmarshaller[E <: ByteEnumEntry](implicit ev: ValueEnumOf[Byte, E]): FromStringUnmarshaller[E] = + byteFromStringUnmarshaller andThen valueEnumUnmarshaller(ev.valueEnum) +} + +trait KebsEnumUnmarshallers extends EnumUnmarshallers with ValueEnumUnmarshallers {} diff --git a/pekko-http/src/main/scala-2/unmarshallers/enums/package.scala b/pekko-http/src/main/scala-2/unmarshallers/enums/package.scala new file mode 100644 index 00000000..757c8e7e --- /dev/null +++ b/pekko-http/src/main/scala-2/unmarshallers/enums/package.scala @@ -0,0 +1,3 @@ +package pl.iterators.kebs.unmarshallers + +package object enums extends KebsEnumUnmarshallers \ No newline at end of file diff --git a/pekko-http/src/main/scala-3/matchers/KebsMatchers.scala b/pekko-http/src/main/scala-3/matchers/KebsMatchers.scala new file mode 100644 index 00000000..9615081c --- /dev/null +++ b/pekko-http/src/main/scala-3/matchers/KebsMatchers.scala @@ -0,0 +1,27 @@ +package pl.iterators.kebs.matchers + +import org.apache.pekko.http.scaladsl.server.PathMatcher1 +import pl.iterators.kebs.instances.InstanceConverter +import pl.iterators.kebs.macros.CaseClass1Rep +import pl.iterators.kebs.macros.enums.EnumOf +import org.apache.pekko.stream.Materializer +import scala.reflect.Enum + +import scala.language.implicitConversions + +trait KebsMatchers extends org.apache.pekko.http.scaladsl.server.PathMatchers { + + implicit class SegmentIsomorphism[U](segment: PathMatcher1[U]) { + def as[T](implicit rep: CaseClass1Rep[T, U]): PathMatcher1[T] = segment.map(rep.apply) + } + + implicit class SegmentConversion[Source](segment: PathMatcher1[Source]) { + def to[Type](implicit ico: InstanceConverter[Type, Source]): PathMatcher1[Type] = segment.map(ico.decode) + } + + object EnumSegment { + def as[T <: Enum](using e: EnumOf[T]): PathMatcher1[T] = { + Segment.map(s => e.`enum`.values.find(_.toString().toLowerCase() == s.toLowerCase()).getOrElse(throw new IllegalArgumentException(s"""Invalid value '$s'. Expected one of: ${e.`enum`.values.mkString(", ")}"""))) + } + } +} diff --git a/pekko-http/src/main/scala-3/unmarshallers/enums/KebsEnumUnmarshallers.scala b/pekko-http/src/main/scala-3/unmarshallers/enums/KebsEnumUnmarshallers.scala new file mode 100644 index 00000000..914c15e1 --- /dev/null +++ b/pekko-http/src/main/scala-3/unmarshallers/enums/KebsEnumUnmarshallers.scala @@ -0,0 +1,50 @@ +package pl.iterators.kebs.unmarshallers.enums + +import org.apache.pekko.http.scaladsl.unmarshalling.PredefinedFromStringUnmarshallers._ +import org.apache.pekko.http.scaladsl.unmarshalling.{FromStringUnmarshaller, Unmarshaller} +import org.apache.pekko.http.scaladsl.util.FastFuture + +import pl.iterators.kebs.macros.enums.{EnumOf, ValueEnumOf} +import pl.iterators.kebs.enums.ValueEnum +import scala.reflect.Enum +import scala.reflect.ClassTag + +trait EnumUnmarshallers { + final def enumUnmarshaller[E <: Enum](using e: EnumOf[E]): FromStringUnmarshaller[E] = org.apache.pekko.http.scaladsl.unmarshalling.Unmarshaller { _ => name => + e.`enum`.values.find(_.toString().toLowerCase() == name.toLowerCase()) match { + case Some(enumEntry) => FastFuture.successful(enumEntry) + case None => + FastFuture.failed(new IllegalArgumentException(s"""Invalid value '$name'. Expected one of: ${e.`enum`.values.mkString(", ")}""")) + } + } + + given kebsEnumUnmarshaller[E <: Enum](using e: EnumOf[E]): FromStringUnmarshaller[E] = + enumUnmarshaller +} + +trait ValueEnumUnmarshallers extends EnumUnmarshallers { + final def valueEnumUnmarshaller[V, E <: ValueEnum[V] with Enum](using `enum`: ValueEnumOf[V, E], cls: ClassTag[V]): Unmarshaller[V, E] = Unmarshaller { _ => v => + `enum`.`enum`.values.find(e => e.value == v) match { + case Some(enumEntry) => FastFuture.successful(enumEntry) + case None => + FastFuture.failed(new IllegalArgumentException(s"""Invalid value '$v'. Expected one of: ${`enum`.`enum`.values.map(_.value).mkString(", ")}""")) + } + } + + given kebsValueEnumUnmarshaller[V, E <: ValueEnum[V] with Enum](using `enum`: ValueEnumOf[V, E], cls: ClassTag[V]): Unmarshaller[V, E] = + valueEnumUnmarshaller + + given kebsIntValueEnumFromStringUnmarshaller[E <: ValueEnum[Int] with Enum](using ev: ValueEnumOf[Int, E]): FromStringUnmarshaller[E] = + intFromStringUnmarshaller andThen valueEnumUnmarshaller + + given kebsLongValueEnumFromStringUnmarshaller[E <: ValueEnum[Long] with Enum](using ev: ValueEnumOf[Long, E]): FromStringUnmarshaller[E] = + longFromStringUnmarshaller andThen valueEnumUnmarshaller + + given kebsShortValueEnumFromStringUnmarshaller[E <: ValueEnum[Short] with Enum](using ev: ValueEnumOf[Short, E]): FromStringUnmarshaller[E] = + shortFromStringUnmarshaller andThen valueEnumUnmarshaller + + given kebsByteValueEnumFromStringUnmarshaller[E <: ValueEnum[Byte] with Enum](using ev: ValueEnumOf[Byte, E]): FromStringUnmarshaller[E] = + byteFromStringUnmarshaller andThen valueEnumUnmarshaller +} + +trait KebsEnumUnmarshallers extends ValueEnumUnmarshallers {} diff --git a/pekko-http/src/main/scala-3/unmarshallers/enums/package.scala b/pekko-http/src/main/scala-3/unmarshallers/enums/package.scala new file mode 100644 index 00000000..757c8e7e --- /dev/null +++ b/pekko-http/src/main/scala-3/unmarshallers/enums/package.scala @@ -0,0 +1,3 @@ +package pl.iterators.kebs.unmarshallers + +package object enums extends KebsEnumUnmarshallers \ No newline at end of file diff --git a/pekko-http/src/main/scala/pl/iterators/kebs/matchers/package.scala b/pekko-http/src/main/scala/pl/iterators/kebs/matchers/package.scala new file mode 100644 index 00000000..610a2da7 --- /dev/null +++ b/pekko-http/src/main/scala/pl/iterators/kebs/matchers/package.scala @@ -0,0 +1,3 @@ +package pl.iterators.kebs + +package object matchers extends KebsMatchers diff --git a/pekko-http/src/main/scala/pl/iterators/kebs/unmarshallers/KebsUnmarshallers.scala b/pekko-http/src/main/scala/pl/iterators/kebs/unmarshallers/KebsUnmarshallers.scala new file mode 100644 index 00000000..696051a8 --- /dev/null +++ b/pekko-http/src/main/scala/pl/iterators/kebs/unmarshallers/KebsUnmarshallers.scala @@ -0,0 +1,27 @@ +package pl.iterators.kebs.unmarshallers + +import org.apache.pekko.http.scaladsl.unmarshalling.{FromStringUnmarshaller, Unmarshaller} +import pl.iterators.kebs.instances.InstanceConverter +import pl.iterators.kebs.macros.CaseClass1Rep + +trait KebsUnmarshallers extends LowPriorityKebsUnmarshallers { + @inline + implicit def kebsFromStringUnmarshaller[A, B](implicit rep: CaseClass1Rep[B, A], + fsu: FromStringUnmarshaller[A]): FromStringUnmarshaller[B] = + fsu andThen kebsUnmarshaller(rep) + + + @inline + implicit def kebsInstancesFromStringUnmarshaller[A, B](implicit ico: InstanceConverter[B, A], + fsu: FromStringUnmarshaller[A]): FromStringUnmarshaller[B] = + fsu andThen kebsInstancesUnmarshaller(ico) + +} + +trait LowPriorityKebsUnmarshallers { + implicit def kebsInstancesUnmarshaller[A, B](implicit ico: InstanceConverter[B, A]): Unmarshaller[A, B] = + Unmarshaller.strict[A, B](ico.decode) + + implicit def kebsUnmarshaller[A, B](implicit rep: CaseClass1Rep[B, A]): Unmarshaller[A, B] = + Unmarshaller.strict[A, B](rep.apply) +} \ No newline at end of file diff --git a/pekko-http/src/main/scala/pl/iterators/kebs/unmarshallers/package.scala b/pekko-http/src/main/scala/pl/iterators/kebs/unmarshallers/package.scala new file mode 100644 index 00000000..6fd93de3 --- /dev/null +++ b/pekko-http/src/main/scala/pl/iterators/kebs/unmarshallers/package.scala @@ -0,0 +1,3 @@ +package pl.iterators.kebs + +package object unmarshallers extends KebsUnmarshallers diff --git a/pekko-http/src/test/scala-2/pl/iterators/kebs/PekkoHttpTagsDomain.scala b/pekko-http/src/test/scala-2/pl/iterators/kebs/PekkoHttpTagsDomain.scala new file mode 100644 index 00000000..f0661a1e --- /dev/null +++ b/pekko-http/src/test/scala-2/pl/iterators/kebs/PekkoHttpTagsDomain.scala @@ -0,0 +1,83 @@ +package pl.iterators.kebs + +import enumeratum.values.{IntEnum, IntEnumEntry, StringEnum, StringEnumEntry} +import enumeratum.{Enum, EnumEntry} +import pl.iterators.kebs.tag.meta.tagged +import pl.iterators.kebs.tagged._ + +import java.net.URI +import java.util.UUID + +@tagged trait Tags { + trait IdTag + type Id = Long @@ IdTag + + trait TestIdTag + type TestId = UUIDId @@ TestIdTag + + trait TestDoubleTag + type TestDouble = Double @@ TestDoubleTag + + type UUIDId = UUID + object UUIDId { + def generate[T]: UUIDId @@ T = UUID.randomUUID().taggedWith[T] + def fromString[T](str: String): UUIDId @@ T = + UUID.fromString(str).taggedWith[T] + } + + trait TestTaggedUriTag + type TestTaggedUri = URI @@ TestTaggedUriTag + +} + +object Domain extends Tags { + case class I(i: Int) + case class S(s: String) + case class P[A](a: A) + case class CantUnmarshall(s: String, i: Int) + case object O + + sealed trait Greeting extends EnumEntry + object Greeting extends Enum[Greeting] { + case object Hello extends Greeting + case object GoodBye extends Greeting + case object Hi extends Greeting + case object Bye extends Greeting + + val values = findValues + } + + sealed abstract class LibraryItem(val value: Int) extends IntEnumEntry + + object LibraryItem extends IntEnum[LibraryItem] { + case object Book extends LibraryItem(1) + case object Movie extends LibraryItem(2) + case object Magazine extends LibraryItem(3) + case object CD extends LibraryItem(4) + + val values = findValues + } + + case class Red(value: Int) + case class Green(value: Int) + case class Blue(value: Int) + case class Color(red: Red, green: Green, blue: Blue) + + sealed abstract class ShirtSize(val value: String) extends StringEnumEntry + object ShirtSize extends StringEnum[ShirtSize] { + case object Small extends ShirtSize("S") + case object Medium extends ShirtSize("M") + case object Large extends ShirtSize("L") + + val values = findValues + } + + sealed trait SortOrder extends EnumEntry + object SortOrder extends Enum[SortOrder] { + case object Asc extends SortOrder + case object Desc extends SortOrder + + override val values = findValues + } + +} diff --git a/pekko-http/src/test/scala-2/pl/iterators/kebs/matchers/PekkoHttpMatchersTests.scala b/pekko-http/src/test/scala-2/pl/iterators/kebs/matchers/PekkoHttpMatchersTests.scala new file mode 100644 index 00000000..97ca9919 --- /dev/null +++ b/pekko-http/src/test/scala-2/pl/iterators/kebs/matchers/PekkoHttpMatchersTests.scala @@ -0,0 +1,108 @@ +package pl.iterators.kebs.matchers + +import org.apache.pekko.http.scaladsl.server.Directives +import org.apache.pekko.http.scaladsl.testkit.ScalatestRouteTest +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.kebs.Domain._ +import pl.iterators.kebs.instances.net.URIString +import pl.iterators.kebs.instances.time.mixins.InstantEpochMilliLong +import pl.iterators.kebs.instances.time.{DayOfWeekInt, ZonedDateTimeString} + +import java.net.URI +import java.time.{DayOfWeek, Instant, ZonedDateTime} + +class PekkoHttpMatchersTests + extends AnyFunSuite + with Matchers + with Directives + with ScalatestRouteTest + with ScalaFutures + with ZonedDateTimeString + with DayOfWeekInt + with InstantEpochMilliLong + with URIString { + + test("No CaseClass1Rep implicits derived") { + import pl.iterators.kebs.macros.CaseClass1Rep + + "implicitly[CaseClass1Rep[DayOfWeek, Int]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Int, DayOfWeek]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Instant, Long]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Long, Instant]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[URI, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, URI]]" shouldNot typeCheck + } + + test("Extract String to ZonedDateTime") { + val testRoute = path("test" / Segment.to[ZonedDateTime]) { zdt => + complete(zdt.toString) + } + Get("/test/2011-12-03T10:15:30+01:00") ~> testRoute ~> check { + responseAs[String] shouldEqual "2011-12-03T10:15:30+01:00" + } + } + + test("Extract Int to DayOfWeek") { + val testRoute = path("test" / IntNumber.to[DayOfWeek]) { dayOfWeek => + complete(dayOfWeek.getValue.toString) + } + Get("/test/1") ~> testRoute ~> check { + responseAs[String] shouldEqual "1" + } + } + + test("Extract Long to Instant ") { + val testRoute = path("test" / LongNumber.to[Instant]) { instant => + complete(instant.toEpochMilli.toString) + } + Get("/test/1621258399") ~> testRoute ~> check { + responseAs[String] shouldEqual "1621258399" + } + } + test("Extract Double as tagged Double") { + val testRoute = path("test" / DoubleNumber.as[TestDouble]) { test => + complete(test.toString) + } + Get("/test/1.23") ~> testRoute ~> check { + responseAs[String] shouldEqual "1.23" + } + } + + test("Extract Long as tagged Long") { + val testRoute = path("test" / LongNumber.as[Id]) { id => + complete(id.toString) + } + Get("/test/123456") ~> testRoute ~> check { + responseAs[String] shouldEqual "123456" + } + } + + test("Extract UUID as tagged UUID") { + val testRoute = path("test" / JavaUUID.as[TestId]) { id => + complete(id.toString) + } + Get("/test/ce7a7cf1-8c00-49a9-a963-9fd119dd0642") ~> testRoute ~> check { + responseAs[String] shouldEqual "ce7a7cf1-8c00-49a9-a963-9fd119dd0642" + } + } + + test("Extract String as Enum") { + val testRoute = path("test" / EnumSegment.as[Greeting]) { greeting => + complete(greeting.toString) + } + Get("/test/hello") ~> testRoute ~> check { + responseAs[String] shouldEqual "Hello" + } + } + + test("Extract String to URI as tagged URI") { + val testRoute = path("test" / Segment.to[URI].as[TestTaggedUri]) { id => + complete(id.toString) + } + Get("/test/ce7a7cf1-8c00-49a9-a963-9fd119dd0642") ~> testRoute ~> check { + responseAs[String] shouldEqual "ce7a7cf1-8c00-49a9-a963-9fd119dd0642" + } + } +} diff --git a/pekko-http/src/test/scala-2/pl/iterators/kebs/unmarshallers/PekkoHttpUnmarshallersTests.scala b/pekko-http/src/test/scala-2/pl/iterators/kebs/unmarshallers/PekkoHttpUnmarshallersTests.scala new file mode 100644 index 00000000..e65f8c52 --- /dev/null +++ b/pekko-http/src/test/scala-2/pl/iterators/kebs/unmarshallers/PekkoHttpUnmarshallersTests.scala @@ -0,0 +1,234 @@ +package pl.iterators.kebs.unmarshallers + +import org.apache.pekko.http.scaladsl.model.FormData +import org.apache.pekko.http.scaladsl.server.{Directives, MalformedQueryParamRejection} +import org.apache.pekko.http.scaladsl.testkit.ScalatestRouteTest +import org.apache.pekko.http.scaladsl.unmarshalling.Unmarshal +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.kebs.Domain._ +import pl.iterators.kebs.instances.net.URIString +import pl.iterators.kebs.instances.time.{DayOfWeekInt, YearMonthString} +import pl.iterators.kebs.unmarshallers.enums.KebsEnumUnmarshallers + +import java.time.{DayOfWeek, YearMonth} + +class PekkoHttpUnmarshallersTests + extends AnyFunSuite + with Matchers + with ScalatestRouteTest + with ScalaFutures + with Directives + with KebsUnmarshallers + with KebsEnumUnmarshallers + with URIString + with YearMonthString + with DayOfWeekInt { + + test("No CaseClass1Rep implicits derived") { + import pl.iterators.kebs.macros.CaseClass1Rep + + "implicitly[CaseClass1Rep[URI, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, URI]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[YearMonth, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, YearMonth]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[DayOfWeek, Int]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Int, DayOfWeek]]" shouldNot typeCheck + } + + test("Unmarshal") { + Unmarshal(42).to[I].futureValue shouldBe I(42) + Unmarshal("42").to[S].futureValue shouldBe S("42") + } + + test("Unmarshal parametrized") { + Unmarshal("42").to[P[String]].futureValue shouldBe P("42") + } + + test("Unmarshal case object") { + """Unmarshal(42).to[O.type]""" shouldNot typeCheck + } + + test("Unmarshal enum") { + Unmarshal("hello").to[Greeting].futureValue shouldBe Greeting.Hello + Unmarshal("blah").to[Greeting].failed.futureValue shouldBe a[IllegalArgumentException] + } + + test("Unmarshal value enum") { + Unmarshal(3).to[LibraryItem].futureValue shouldBe LibraryItem.Magazine + Unmarshal(5).to[LibraryItem].failed.futureValue shouldBe a[IllegalArgumentException] + } + + test("No unmarshaller for case-classes of arity > 1") { + """Unmarshal("42").to[CantUnmarshall]""" shouldNot typeCheck + } + + test("Unmarshalling value enums is type-safe") { + """Unmarshal(1L).to[LibraryItem]""" shouldNot typeCheck + } + + test("Unmarshal from string") { + Unmarshal("42").to[I].futureValue shouldBe I(42) + } + + test("Unmarshalling parameter") { + val testRoute = parameters(Symbol("i").as[I]) { i => + complete(i.toString) + } + Get("/?i=42") ~> testRoute ~> check { + responseAs[String] shouldEqual "I(42)" + } + } + + test("Unmarshalling optional parameter") { + val testRoute = parameters(Symbol("i").as[I].?) { i => + complete(i.toString) + } + Get("/?i=42") ~> testRoute ~> check { + responseAs[String] shouldEqual "Some(I(42))" + } + } + + test("Unmarshalling enum parameter") { + val testRoute = parameters(Symbol("greeting").as[Greeting]) { greeting => + complete(greeting.toString) + } + Get("/?greeting=hi") ~> testRoute ~> check { + responseAs[String] shouldEqual "Hi" + } + Get("/?greeting=blah") ~> testRoute ~> check { + rejection shouldEqual MalformedQueryParamRejection("greeting", + "Invalid value 'blah'. Expected one of: Hello, GoodBye, Hi, Bye", + None) + } + } + + test("Unmarshalling value enum parameter") { + val testRoute = parameters(Symbol("libraryItem").as[LibraryItem]) { item => + complete(item.toString) + } + Get("/?libraryItem=1") ~> testRoute ~> check { + responseAs[String] shouldEqual "Book" + } + Get("/?libraryItem=10") ~> testRoute ~> check { + rejection shouldEqual MalformedQueryParamRejection("libraryItem", "Invalid value '10'. Expected one of: 1, 2, 3, 4", None) + } + } + + test("Case class extraction") { + val route = + path("color") { + parameters(Symbol("red").as[Red], Symbol("green").as[Green], Symbol("blue").as[Blue]).as(Color) { color => + complete(color.toString) + } + } + Get("/color?red=1&green=2&blue=3") ~> route ~> check { responseAs[String] shouldEqual "Color(Red(1),Green(2),Blue(3))" } + } + + test("Unmarshalling instances parameter") { + val testRoute = path("instances") { + parameters(Symbol("year").as[YearMonth]) { year => + complete(year.toString) + } + } + Get("/instances?year=2021-05") ~> testRoute ~> check { + responseAs[String] shouldEqual "2021-05" + } + } + + test("Unmarshalling string value enum parameter") { + val testRoute = parameters(Symbol("shirtSize").as[ShirtSize]) { shirtSize => + complete(shirtSize.toString) + } + Get("/?shirtSize=M") ~> testRoute ~> check { + responseAs[String] shouldEqual "Medium" + } + Get("/?shirtSize=XL") ~> testRoute ~> check { + rejection shouldEqual MalformedQueryParamRejection("shirtSize", "Invalid value 'XL'. Expected one of: S, M, L", None) + } + } + + test("bug: work with default enum values") { + val route = + path("test_enum") { + parameter(Symbol("sort").as[SortOrder] ? (SortOrder.Desc: SortOrder)) { sort => + complete { + s"Sort was $sort" + } + } + } + + Get("/test_enum?sort=Asc") ~> route ~> check { + responseAs[String] shouldBe "Sort was Asc" + } + Get("/test_enum") ~> route ~> check { + responseAs[String] shouldBe "Sort was Desc" + } + } + + test("Unmarshal form field from String") { + val route = + path("test_form_fields") { + formFields("yearMonth".as[YearMonth]) { yearMonth => + complete(yearMonth.toString) + } + } + + Post("/test_form_fields", FormData("yearMonth" -> "2021-05")) ~> route ~> check { + responseAs[String] shouldEqual "2021-05" + } + } + + test("Unmarshal form fields from Int") { + val route = + path("test_form_fields") { + formFields("dayOfWeek".as[DayOfWeek]) { dayOfWeek => + complete(dayOfWeek.getValue.toString) + } + } + + Post("/test_form_fields", FormData("dayOfWeek" -> "1")) ~> route ~> check { + responseAs[String] shouldEqual "1" + } + } + + test("Unmarshal tagged parameters from Long") { + val route = + path("test_tagged") { + parameter("tagged".as[Id]) { id => + complete(id.toString) + } + } + + Get("/test_tagged?tagged=123456") ~> route ~> check { + responseAs[String] shouldBe "123456" + } + } + + test("Unmarshal tagged parameters from UUID") { + val route = + path("test_tagged") { + parameter("tagged".as[TestId]) { id => + complete(id.toString) + } + } + + Get("/test_tagged?tagged=ce7a7cf1-8c00-49a9-a963-9fd119dd0642") ~> route ~> check { + responseAs[String] shouldBe "ce7a7cf1-8c00-49a9-a963-9fd119dd0642" + } + } + + test("Unmarshal tagged URI") { + val route = + path("test_tagged") { + parameter("tagged".as[TestTaggedUri]) { id => + complete(id.toString) + } + } + + Get("/test_tagged?tagged=www.test.pl") ~> route ~> check { + responseAs[String] shouldBe "www.test.pl" + } + } +} diff --git a/pekko-http/src/test/scala-3/pl/iterators/kebs/PekkoHttpTagsDomain.scala b/pekko-http/src/test/scala-3/pl/iterators/kebs/PekkoHttpTagsDomain.scala new file mode 100644 index 00000000..39701de2 --- /dev/null +++ b/pekko-http/src/test/scala-3/pl/iterators/kebs/PekkoHttpTagsDomain.scala @@ -0,0 +1,50 @@ +package pl.iterators.kebs + +import pl.iterators.kebs.opaque.Opaque + +import java.net.URI +import java.util.UUID +import pl.iterators.kebs.enums.ValueEnum + +object Domain { + opaque type TestTaggedUri = URI + object TestTaggedUri extends Opaque[TestTaggedUri, URI] + opaque type TestId = UUID + object TestId extends Opaque[TestId, UUID] + opaque type Id = Long + object Id extends Opaque[Id, Long] + case class I(i: Int) + case class S(s: String) + case class P[A](a: A) + case class CantUnmarshall(s: String, i: Int) + case object O + + enum Greeting { + case Hello, GoodBye, Hi, Bye + } + + + enum LibraryItem(val value: Int) extends ValueEnum[Int] { + case Book extends LibraryItem(1) + case Movie extends LibraryItem(2) + case Magazine extends LibraryItem(3) + case CD extends LibraryItem(4) + } + + case class Red(value: Int) + case class Green(value: Int) + case class Blue(value: Int) + case class Color(red: Red, green: Green, blue: Blue) + + enum ShirtSize(val value: String) extends ValueEnum[String] { + case Small extends ShirtSize("S") + case Medium extends ShirtSize("M") + case Large extends ShirtSize("L") + } + + enum SortOrder { + case Asc + case Desc + } + +} diff --git a/pekko-http/src/test/scala-3/pl/iterators/kebs/matchers/PekkoHttpMatchersTests.scala b/pekko-http/src/test/scala-3/pl/iterators/kebs/matchers/PekkoHttpMatchersTests.scala new file mode 100644 index 00000000..3ceb6c95 --- /dev/null +++ b/pekko-http/src/test/scala-3/pl/iterators/kebs/matchers/PekkoHttpMatchersTests.scala @@ -0,0 +1,82 @@ +package pl.iterators.kebs.matchers + +import org.apache.pekko.http.scaladsl.server.Directives +import org.apache.pekko.http.scaladsl.testkit.ScalatestRouteTest +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.kebs.Domain._ +import pl.iterators.kebs.instances.net.URIString +import pl.iterators.kebs.instances.time.mixins.InstantEpochMilliLong +import pl.iterators.kebs.instances.time.{DayOfWeekInt, ZonedDateTimeString} + +import java.net.URI +import java.time.{DayOfWeek, Instant, ZonedDateTime} + +class PekkoHttpMatchersTests + extends AnyFunSuite + with Matchers + with Directives + with ScalatestRouteTest + with ScalaFutures + with ZonedDateTimeString + with DayOfWeekInt + with InstantEpochMilliLong + with URIString { + + test("No CaseClass1Rep implicits derived") { + import pl.iterators.kebs.macros.CaseClass1Rep + + "implicitly[CaseClass1Rep[DayOfWeek, Int]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Int, DayOfWeek]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Instant, Long]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Long, Instant]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[URI, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, URI]]" shouldNot typeCheck + } + + test("Extract String to ZonedDateTime") { + val testRoute = path("test" / Segment.to[ZonedDateTime]) { zdt => + complete(zdt.toString) + } + Get("/test/2011-12-03T10:15:30+01:00") ~> testRoute ~> check { + responseAs[String] shouldEqual "2011-12-03T10:15:30+01:00" + } + } + + test("Extract Int to DayOfWeek") { + val testRoute = path("test" / IntNumber.to[DayOfWeek]) { dayOfWeek => + complete(dayOfWeek.getValue.toString) + } + Get("/test/1") ~> testRoute ~> check { + responseAs[String] shouldEqual "1" + } + } + + test("Extract Long to Instant ") { + val testRoute = path("test" / LongNumber.to[Instant]) { instant => + complete(instant.toEpochMilli.toString) + } + Get("/test/1621258399") ~> testRoute ~> check { + responseAs[String] shouldEqual "1621258399" + } + } + + test("Extract String as Enum") { + val testRoute = path("test" / EnumSegment.as[Greeting]) { greeting => + complete(greeting.toString) + } + Get("/test/hello") ~> testRoute ~> check { + responseAs[String] shouldEqual "Hello" + } + } + + test("Extract String to URI as tagged URI") { + val testRoute = path("test" / Segment.to[URI].as[TestTaggedUri]) { id => + complete(id.toString) + } + Get("/test/ce7a7cf1-8c00-49a9-a963-9fd119dd0642") ~> testRoute ~> check { + responseAs[String] shouldEqual "ce7a7cf1-8c00-49a9-a963-9fd119dd0642" + } + } +} diff --git a/pekko-http/src/test/scala-3/pl/iterators/kebs/unmarshallers/PekkoHttpUnmarshallersTests.scala b/pekko-http/src/test/scala-3/pl/iterators/kebs/unmarshallers/PekkoHttpUnmarshallersTests.scala new file mode 100644 index 00000000..3fcf4e49 --- /dev/null +++ b/pekko-http/src/test/scala-3/pl/iterators/kebs/unmarshallers/PekkoHttpUnmarshallersTests.scala @@ -0,0 +1,234 @@ +package pl.iterators.kebs.unmarshallers + +import org.apache.pekko.http.scaladsl.model.FormData +import org.apache.pekko.http.scaladsl.server.{Directives, MalformedQueryParamRejection} +import org.apache.pekko.http.scaladsl.testkit.ScalatestRouteTest +import org.apache.pekko.http.scaladsl.unmarshalling.Unmarshal +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.kebs.Domain._ +import pl.iterators.kebs.instances.net.URIString +import pl.iterators.kebs.instances.time.{DayOfWeekInt, YearMonthString} +import pl.iterators.kebs.unmarshallers.enums.KebsEnumUnmarshallers + +import java.time.{DayOfWeek, YearMonth} + +class PekkoHttpUnmarshallersTests + extends AnyFunSuite + with Matchers + with ScalatestRouteTest + with ScalaFutures + with Directives + with KebsUnmarshallers + with KebsEnumUnmarshallers + with URIString + with YearMonthString + with DayOfWeekInt { + + test("No CaseClass1Rep implicits derived") { + import pl.iterators.kebs.macros.CaseClass1Rep + + "implicitly[CaseClass1Rep[URI, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, URI]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[YearMonth, String]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[String, YearMonth]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[DayOfWeek, Int]]" shouldNot typeCheck + "implicitly[CaseClass1Rep[Int, DayOfWeek]]" shouldNot typeCheck + } + + test("Unmarshal") { + Unmarshal(42).to[I].futureValue shouldBe I(42) + Unmarshal("42").to[S].futureValue shouldBe S("42") + } + + test("Unmarshal parametrized") { + Unmarshal("42").to[P[String]].futureValue shouldBe P("42") + } + + test("Unmarshal case object") { + """Unmarshal(42).to[O.type]""" shouldNot typeCheck + } + + test("Unmarshal enum") { + Unmarshal("hello").to[Greeting].futureValue shouldBe Greeting.Hello + Unmarshal("blah").to[Greeting].failed.futureValue shouldBe a[IllegalArgumentException] + } + + test("Unmarshal value enum") { + Unmarshal(3).to[LibraryItem].futureValue shouldBe LibraryItem.Magazine + Unmarshal(5).to[LibraryItem].failed.futureValue shouldBe a[IllegalArgumentException] + } + + test("No unmarshaller for case-classes of arity > 1") { + """Unmarshal("42").to[CantUnmarshall]""" shouldNot typeCheck + } + + test("Unmarshalling value enums is type-safe") { + """Unmarshal(1L).to[LibraryItem]""" shouldNot typeCheck + } + + test("Unmarshal from string") { + Unmarshal("42").to[I].futureValue shouldBe I(42) + } + + test("Unmarshalling parameter") { + val testRoute = parameters(Symbol("i").as[I]) { i => + complete(i.toString) + } + Get("/?i=42") ~> testRoute ~> check { + responseAs[String] shouldEqual "I(42)" + } + } + + test("Unmarshalling optional parameter") { + val testRoute = parameters(Symbol("i").as[I].?) { i => + complete(i.toString) + } + Get("/?i=42") ~> testRoute ~> check { + responseAs[String] shouldEqual "Some(I(42))" + } + } + + test("Unmarshalling enum parameter") { + val testRoute = parameters(Symbol("greeting").as[Greeting]) { greeting => + complete(greeting.toString) + } + Get("/?greeting=hi") ~> testRoute ~> check { + responseAs[String] shouldEqual "Hi" + } + Get("/?greeting=blah") ~> testRoute ~> check { + rejection shouldEqual MalformedQueryParamRejection("greeting", + "Invalid value 'blah'. Expected one of: Hello, GoodBye, Hi, Bye", + None) + } + } + + test("Unmarshalling value enum parameter") { + val testRoute = parameters(Symbol("libraryItem").as[LibraryItem]) { item => + complete(item.toString) + } + Get("/?libraryItem=1") ~> testRoute ~> check { + responseAs[String] shouldEqual "Book" + } + Get("/?libraryItem=10") ~> testRoute ~> check { + rejection shouldEqual MalformedQueryParamRejection("libraryItem", "Invalid value '10'. Expected one of: 1, 2, 3, 4", None) + } + } + + test("Case class extraction") { + val route = + path("color") { + parameters(Symbol("red").as[Red], Symbol("green").as[Green], Symbol("blue").as[Blue]).as(Color.apply) { color => + complete(color.toString) + } + } + Get("/color?red=1&green=2&blue=3") ~> route ~> check { responseAs[String] shouldEqual "Color(Red(1),Green(2),Blue(3))" } + } + + test("Unmarshalling instances parameter") { + val testRoute = path("instances") { + parameters(Symbol("year").as[YearMonth]) { year => + complete(year.toString) + } + } + Get("/instances?year=2021-05") ~> testRoute ~> check { + responseAs[String] shouldEqual "2021-05" + } + } + + test("Unmarshalling string value enum parameter") { + val testRoute = parameters(Symbol("shirtSize").as[ShirtSize]) { shirtSize => + complete(shirtSize.toString) + } + Get("/?shirtSize=M") ~> testRoute ~> check { + responseAs[String] shouldEqual "Medium" + } + Get("/?shirtSize=XL") ~> testRoute ~> check { + rejection shouldEqual MalformedQueryParamRejection("shirtSize", "Invalid value 'XL'. Expected one of: S, M, L", None) + } + } + + test("bug: work with default enum values") { + val route = + path("test_enum") { + parameter(Symbol("sort").as[SortOrder] ? (SortOrder.Desc: SortOrder)) { sort => + complete { + s"Sort was $sort" + } + } + } + + Get("/test_enum?sort=Asc") ~> route ~> check { + responseAs[String] shouldBe "Sort was Asc" + } + Get("/test_enum") ~> route ~> check { + responseAs[String] shouldBe "Sort was Desc" + } + } + + test("Unmarshal form field from String") { + val route = + path("test_form_fields") { + formFields("yearMonth".as[YearMonth]) { yearMonth => + complete(yearMonth.toString) + } + } + + Post("/test_form_fields", FormData("yearMonth" -> "2021-05")) ~> route ~> check { + responseAs[String] shouldEqual "2021-05" + } + } + + test("Unmarshal form fields from Int") { + val route = + path("test_form_fields") { + formFields("dayOfWeek".as[DayOfWeek]) { dayOfWeek => + complete(dayOfWeek.getValue.toString) + } + } + + Post("/test_form_fields", FormData("dayOfWeek" -> "1")) ~> route ~> check { + responseAs[String] shouldEqual "1" + } + } + + test("Unmarshal tagged parameters from Long") { + val route = + path("test_tagged") { + parameter("tagged".as[Id]) { id => + complete(id.toString) + } + } + + Get("/test_tagged?tagged=123456") ~> route ~> check { + responseAs[String] shouldBe "123456" + } + } + + test("Unmarshal tagged parameters from UUID") { + val route = + path("test_tagged") { + parameter("tagged".as[TestId]) { id => + complete(id.toString) + } + } + + Get("/test_tagged?tagged=ce7a7cf1-8c00-49a9-a963-9fd119dd0642") ~> route ~> check { + responseAs[String] shouldBe "ce7a7cf1-8c00-49a9-a963-9fd119dd0642" + } + } + + test("Unmarshal tagged URI") { + val route = + path("test_tagged") { + parameter("tagged".as[TestTaggedUri]) { id => + complete(id.toString) + } + } + + Get("/test_tagged?tagged=www.test.pl") ~> route ~> check { + responseAs[String] shouldBe "www.test.pl" + } + } +}