diff --git a/README.md b/README.md index 232bdff..4433c81 100644 --- a/README.md +++ b/README.md @@ -20,14 +20,10 @@ This will not bloat your project. It only has an effect if you explicitly depend Get latest release: ```scala -libraryDependencies += "com.github.cornerman" %%% "chameleon" % "0.3.5" +libraryDependencies += "com.github.cornerman" %%% "chameleon" % "0.4.0" ``` -Or get development snapshots via jitpack: -```scala -resolvers += "jitpack" at "https://jitpack.io" -libraryDependencies += "com.github.cornerman.chameleon" %%% "chameleon" % "master-SNAPSHOT" -``` +We additionally publish sonatype snapshots for every commit on master. # Usage @@ -54,6 +50,39 @@ Have typeclasses for cats (`Contravariant`, `Functor`, `Invariant`): import chameleon.ext.cats._ ``` +## http4s + +We have an extra package to integrate `http4s` with chameleon `Serializer` and `Deserializer`. Specifically to create `EntityEncoder` and `EntityDecoder`. + +Usage: +```scala +libraryDependencies += "com.github.cornerman" %%% "chameleon-http4s" % "0.4.0" +``` + +To work with json inside your API: +```scala +import chameleon.ext.http4s.JsonStringCodec.* +import chameleon.ext.upickle.* + +// serialize case class as json response +Ok(json(MyCaseClass("hallo"))) + +// deserialize json request into case class +jsonAs[MyCaseClass](someRequest) +``` + +You can also use the auto import, which provides implicit `EntityEncoder` / `EntityDecoder` for all types with `Serializer` / `Deserializer`. This should only be imported when exclusively working with json. +```scala +import chameleon.ext.http4s.JsonStringCodec.auto.* +import chameleon.ext.upickle.* + +// serialize +Ok(MyCaseClass("hallo")) + +// deserialize +someRequest.as[MyCaseClass] +``` + # Motivation Say, you want to write a library that needs serialization but abstracts over the type of serialization. Then you might end up with something like this: diff --git a/build.sbt b/build.sbt index b96dbf3..71807c4 100644 --- a/build.sbt +++ b/build.sbt @@ -64,3 +64,14 @@ lazy val chameleon = crossProject(JSPlatform, JVMPlatform) ) }) ) + +lazy val http4s = crossProject(JSPlatform, JVMPlatform) + .crossType(CrossType.Pure) + .dependsOn(chameleon) + .settings(commonSettings) + .settings( + name := "chameleon-http4s", + libraryDependencies ++= + Deps.http4s.value :: + Nil, + ) diff --git a/chameleon/src/main/scala/ext/jsoniter.scala b/chameleon/src/main/scala/ext/jsoniter.scala index d16883f..bf17dd7 100644 --- a/chameleon/src/main/scala/ext/jsoniter.scala +++ b/chameleon/src/main/scala/ext/jsoniter.scala @@ -5,8 +5,9 @@ import com.github.plokhotnyuk.jsoniter_scala.core import scala.util.{Try, Success, Failure} +//TODO with CodecMakerConfig object jsoniter { - implicit def jsoniterSerializerDeserializer2[T: core.JsonValueCodec]: SerializerDeserializer[T, String] = new Serializer[T, String] with Deserializer[T, String] { + implicit def jsoniterSerializerDeserializerString[T: core.JsonValueCodec]: SerializerDeserializer[T, String] = new Serializer[T, String] with Deserializer[T, String] { override def serialize(arg: T): String = core.writeToString(arg) override def deserialize(arg: String): Either[Throwable, T] = @@ -15,4 +16,14 @@ object jsoniter { case Failure(t) => Left(t) } } + + implicit def jsoniterSerializerDeserializerByteArray[T: core.JsonValueCodec]: SerializerDeserializer[T, Array[Byte]] = new Serializer[T, Array[Byte]] with Deserializer[T, Array[Byte]] { + override def serialize(arg: T): Array[Byte] = core.writeToArray(arg) + + override def deserialize(arg: Array[Byte]): Either[Throwable, T] = + Try(core.readFromArray[T](arg)) match { + case Success(arg) => Right(arg) + case Failure(t) => Left(t) + } + } } diff --git a/chameleon/src/main/scala/ext/scalapb.scala b/chameleon/src/main/scala/ext/scalapb.scala index 0fd2500..1bd7905 100644 --- a/chameleon/src/main/scala/ext/scalapb.scala +++ b/chameleon/src/main/scala/ext/scalapb.scala @@ -6,7 +6,7 @@ import java.nio.ByteBuffer import scala.util.{Failure, Success} object scalapb { - implicit def scalapbSerializerDeserializer[T <: GeneratedMessage](implicit companion: GeneratedMessageCompanion[T]): SerializerDeserializer[T, ByteBuffer] = new Serializer[T, ByteBuffer] with Deserializer[T, ByteBuffer] { + implicit def scalapbSerializerDeserializerByteBuffer[T <: GeneratedMessage](implicit companion: GeneratedMessageCompanion[T]): SerializerDeserializer[T, ByteBuffer] = new Serializer[T, ByteBuffer] with Deserializer[T, ByteBuffer] { override def serialize(arg: T): ByteBuffer = ByteBuffer.wrap(companion.toByteArray(arg)) override def deserialize(arg: ByteBuffer): Either[Throwable, T] = { val bytes = new Array[Byte](arg.remaining) @@ -17,4 +17,13 @@ object scalapb { } } } + + implicit def scalapbSerializerDeserializerByteArray[T <: GeneratedMessage](implicit companion: GeneratedMessageCompanion[T]): SerializerDeserializer[T, Array[Byte]] = new Serializer[T, Array[Byte]] with Deserializer[T, Array[Byte]] { + override def serialize(arg: T): Array[Byte] = companion.toByteArray(arg) + override def deserialize(bytes: Array[Byte]): Either[Throwable, T] = + companion.validate(bytes) match { + case Success(arg) => Right(arg) + case Failure(t) => Left(t) + } + } } diff --git a/chameleon/src/main/scala/ext/scodec.scala b/chameleon/src/main/scala/ext/scodec.scala index 8d73b7e..ee92233 100644 --- a/chameleon/src/main/scala/ext/scodec.scala +++ b/chameleon/src/main/scala/ext/scodec.scala @@ -9,7 +9,7 @@ import java.nio.ByteBuffer object scodec { case class DeserializeException(msg: String) extends Exception(msg) - implicit def scodecSerializerDeserializer[T : Codec]: SerializerDeserializer[T, ByteBuffer] = new Serializer[T, ByteBuffer] with Deserializer[T, ByteBuffer] { + implicit def scodecSerializerDeserializerByteBuffer[T : Codec]: SerializerDeserializer[T, ByteBuffer] = new Serializer[T, ByteBuffer] with Deserializer[T, ByteBuffer] { override def serialize(arg: T): ByteBuffer = Codec[T].encode(arg).require.toByteBuffer override def deserialize(arg: ByteBuffer): Either[Throwable, T] = Codec[T].decode(BitVector(arg)).toEither match { @@ -17,4 +17,13 @@ object scodec { case Left(err) => Left(DeserializeException(err.message)) } } + + implicit def scodecSerializerDeserializerByteArray[T : Codec]: SerializerDeserializer[T, Array[Byte]] = new Serializer[T, Array[Byte]] with Deserializer[T, Array[Byte]] { + override def serialize(arg: T): Array[Byte] = Codec[T].encode(arg).require.toByteArray + override def deserialize(arg: Array[Byte]): Either[Throwable, T] = + Codec[T].decode(BitVector(arg)).toEither match { + case Right(result) => Right(result.value) + case Left(err) => Left(DeserializeException(err.message)) + } + } } diff --git a/chameleon/src/main/scala/ext/upickle.scala b/chameleon/src/main/scala/ext/upickle.scala index f6c3354..4ab1ffd 100644 --- a/chameleon/src/main/scala/ext/upickle.scala +++ b/chameleon/src/main/scala/ext/upickle.scala @@ -7,15 +7,18 @@ import _root_.upickle.default import scala.util.{Try, Success, Failure} trait upickle { api: Api => - implicit def upickleSerializer[T: api.Writer]: chameleon.Serializer[T, String] = upickle.upickleSerializerOfApi(api) - implicit def upickleDeserializer[T: api.Reader]: chameleon.Deserializer[T, String] = upickle.upickleDeserializerOfApi(api) + implicit def upickleSerializerString[T: api.Writer]: chameleon.Serializer[T, String] = upickle.upickleSerializerStringOfApi(api) + implicit def upickleDeserializerString[T: api.Reader]: chameleon.Deserializer[T, String] = upickle.upickleDeserializerStringOfApi(api) + + implicit def upickleSerializerByteArray[T: api.Writer]: chameleon.Serializer[T, Array[Byte]] = upickle.upickleSerializerByteArrayOfApi(api) + implicit def upickleDeserializerByteArray[T: api.Reader]: chameleon.Deserializer[T, Array[Byte]] = upickle.upickleDeserializerByteArrayOfApi(api) } object upickle { - def upickleSerializerOfApi[T](api: Api)(implicit writer: api.Writer[T]): Serializer[T, String] = new Serializer[T, String] { + def upickleSerializerStringOfApi[T](api: Api)(implicit writer: api.Writer[T]): Serializer[T, String] = new Serializer[T, String] { override def serialize(arg: T): String = api.write(arg) } - def upickleDeserializerOfApi[T](api: Api)(implicit reader: api.Reader[T]): Deserializer[T, String] = new Deserializer[T, String] { + def upickleDeserializerStringOfApi[T](api: Api)(implicit reader: api.Reader[T]): Deserializer[T, String] = new Deserializer[T, String] { override def deserialize(arg: String): Either[Throwable, T] = Try(api.read[T](arg)) match { case Success(arg) => Right(arg) @@ -23,6 +26,20 @@ object upickle { } } - implicit def upickleSerializer[T: default.Writer]: Serializer[T, String] = upickleSerializerOfApi(default) - implicit def upickleDeserializer[T : default.Reader]: Deserializer[T, String] = upickleDeserializerOfApi(default) + def upickleSerializerByteArrayOfApi[T](api: Api)(implicit writer: api.Writer[T]): Serializer[T, Array[Byte]] = new Serializer[T, Array[Byte]] { + override def serialize(arg: T): Array[Byte] = api.writeBinary(arg) + } + def upickleDeserializerByteArrayOfApi[T](api: Api)(implicit reader: api.Reader[T]): Deserializer[T, Array[Byte]] = new Deserializer[T, Array[Byte]] { + override def deserialize(arg: Array[Byte]): Either[Throwable, T] = + Try(api.readBinary[T](arg)) match { + case Success(arg) => Right(arg) + case Failure(t) => Left(t) + } + } + + implicit def upickleSerializerString[T: default.Writer]: Serializer[T, String] = upickleSerializerStringOfApi(default) + implicit def upickleDeserializerString[T : default.Reader]: Deserializer[T, String] = upickleDeserializerStringOfApi(default) + + implicit def upickleSerializerByteArray[T: default.Writer]: Serializer[T, Array[Byte]] = upickleSerializerByteArrayOfApi(default) + implicit def upickleDeserializerByteArray[T : default.Reader]: Deserializer[T, Array[Byte]] = upickleDeserializerByteArrayOfApi(default) } \ No newline at end of file diff --git a/http4s/src/main/scala/chameleon/ext/http4s/JsonByteArrayCodec.scala b/http4s/src/main/scala/chameleon/ext/http4s/JsonByteArrayCodec.scala new file mode 100644 index 0000000..598360d --- /dev/null +++ b/http4s/src/main/scala/chameleon/ext/http4s/JsonByteArrayCodec.scala @@ -0,0 +1,42 @@ +package chameleon.ext.http4s + +import cats.effect.Concurrent +import cats.implicits._ +import chameleon.{Deserializer, Serializer} +import org.http4s.headers.`Content-Type` +import org.http4s.{DecodeResult, EntityDecoder, EntityEncoder, Headers, MalformedMessageBodyFailure, Media, MediaType} + +object JsonByteArrayCodec { + def jsonDecoderOf[F[_]: Concurrent, A](implicit deserializer: Deserializer[A, Array[Byte]]): EntityDecoder[F, A] = + EntityDecoder.decodeBy(MediaType.application.json) { media => + EntityDecoder.collectBinary(media).flatMap { chunk => + DecodeResult[F, A](Concurrent[F].pure( + deserializer.deserialize(chunk.toArray).leftMap(error => MalformedMessageBodyFailure("Invalid JSON", Some(error))) + )) + } + } + + def jsonEncoderOf[F[_], A](implicit serializer: Serializer[A, Array[Byte]]): EntityEncoder[F, A] = + EntityEncoder.encodeBy[F, A](Headers(`Content-Type`(MediaType.application.json))) { + EntityEncoder.byteArrayEncoder[F].contramap(serializer.serialize).toEntity(_) + } + + case class json[A](value: A) + + def jsonAs[A] = new JsonAsPartialApplied[A] + class JsonAsPartialApplied[A] { + def apply[F[_]: Concurrent](media: Media[F])(implicit deserializer: Deserializer[A, Array[Byte]]): F[A] = media.as[A](implicitly, jsonDecoderOf[F, A]) + } + + implicit def jsonEntityDecoderJson[F[_]: Concurrent, A](implicit deserializer: Deserializer[A, Array[Byte]]): EntityDecoder[F, json[A]] = + jsonDecoderOf[F, A].map(json(_)) + + implicit def jsonEntityEncoderJson[F[_], A](implicit serializer: Serializer[A, Array[Byte]]): EntityEncoder[F, json[A]] = + jsonEncoderOf[F, A].contramap(_.value) + + object auto { + implicit def jsonEntityDecoder[F[_] : Concurrent, A](implicit deserializer: Deserializer[A, Array[Byte]]): EntityDecoder[F, A] = jsonDecoderOf[F, A] + + implicit def jsonEntityEncoder[F[_], A](implicit serializer: Serializer[A, Array[Byte]]): EntityEncoder[F, A] = jsonEncoderOf[F, A] + } +} diff --git a/http4s/src/main/scala/chameleon/ext/http4s/JsonStringCodec.scala b/http4s/src/main/scala/chameleon/ext/http4s/JsonStringCodec.scala new file mode 100644 index 0000000..505f22d --- /dev/null +++ b/http4s/src/main/scala/chameleon/ext/http4s/JsonStringCodec.scala @@ -0,0 +1,42 @@ +package chameleon.ext.http4s + +import cats.effect.Concurrent +import cats.implicits._ +import chameleon.{Deserializer, Serializer} +import org.http4s.headers.`Content-Type` +import org.http4s.{DecodeResult, EntityDecoder, EntityEncoder, Headers, MalformedMessageBodyFailure, Media, MediaType} + +object JsonStringCodec { + def jsonDecoderOf[F[_] : Concurrent, A](implicit deserializer: Deserializer[A, String]): EntityDecoder[F, A] = + EntityDecoder.decodeBy(MediaType.application.json) { media => + DecodeResult[F, A]( + EntityDecoder.decodeText(media).map { json => + deserializer.deserialize(json).leftMap(error => MalformedMessageBodyFailure("Invalid JSON", Some(error))) + } + ) + } + + def jsonEncoderOf[F[_], A](implicit serializer: Serializer[A, String]): EntityEncoder[F, A] = + EntityEncoder.encodeBy[F, A](Headers(`Content-Type`(MediaType.application.json))) { + EntityEncoder.stringEncoder[F].contramap(serializer.serialize).toEntity(_) + } + + case class json[A](value: A) + + def jsonAs[A] = new JsonAsPartialApplied[A] + class JsonAsPartialApplied[A] { + def apply[F[_]: Concurrent](media: Media[F])(implicit deserializer: Deserializer[A, String]): F[A] = media.as[A](implicitly, jsonDecoderOf[F, A]) + } + + implicit def jsonEntityDecoderJson[F[_]: Concurrent, A](implicit serializer: Deserializer[A, String]): EntityDecoder[F, json[A]] = + jsonDecoderOf[F, A].map(json(_)) + + implicit def jsonEntityEncoderJson[F[_], A](implicit serializer: Serializer[A, String]): EntityEncoder[F, json[A]] = + jsonEncoderOf[F, A].contramap(_.value) + + object auto { + implicit def jsonEntityDecoder[F[_] : Concurrent, A](implicit deserializer: Deserializer[A, String]): EntityDecoder[F, A] = jsonDecoderOf[F, A] + + implicit def jsonEntityEncoder[F[_], A](implicit serializer: Serializer[A, String]): EntityEncoder[F, A] = jsonEncoderOf[F, A] + } +} diff --git a/project/Deps.scala b/project/Deps.scala index 7bf1c2e..2a8f78c 100644 --- a/project/Deps.scala +++ b/project/Deps.scala @@ -21,10 +21,12 @@ object Deps { } val scodec = new { val core = dep("org.scodec" %%% "scodec-core" % "1.11.10") - val core2 = dep("org.scodec" %%% "scodec-core" % "2.2.2") + val core2 = dep("org.scodec" %%% "scodec-core" % "2.3.0") val bits = dep("org.scodec" %%% "scodec-bits" % "1.2.0") } val upickle = dep("com.lihaoyi" %%% "upickle" % "3.3.1") val jsoniter = dep("com.github.plokhotnyuk.jsoniter-scala" %%% "jsoniter-scala-core" % "2.30.1") val zioJson = dep("dev.zio" %%% "zio-json" % "0.6.2") + + val http4s = dep("org.http4s" %%% "http4s-core" % "0.23.24") }