diff --git a/.gitignore b/.gitignore index d0fbd3d..1e87abc 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ target/ .project .classpath tmp/ +.bsp diff --git a/.travis.yml b/.travis.yml index 7fa622f..f47944d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,7 @@ language: scala scala: - 2.12.10 - - 2.13.0 + - 2.13.4 jdk: - openjdk8 diff --git a/build.sbt b/build.sbt index 6d30b23..f9644c0 100644 --- a/build.sbt +++ b/build.sbt @@ -16,6 +16,7 @@ val circeVersion = "0.13.0" val fs2Version = "2.5.0" val jawnVersion = "1.0.3" val previousCirceFs2Version = "0.11.0" +val shapelessVersion = "2.3.3" val scalaTestVersion = "3.2.3" val scalaTestPlusVersion = "3.2.2.0" @@ -69,7 +70,8 @@ val fs2 = project "io.circe" %% "circe-testing" % circeVersion % Test, "org.scalatest" %% "scalatest" % scalaTestVersion % Test, "org.scalatestplus" %% "scalacheck-1-14" % scalaTestPlusVersion % Test, - "org.typelevel" %% "jawn-parser" % jawnVersion + "org.typelevel" %% "jawn-parser" % jawnVersion, + "com.chuusai" %% "shapeless" % shapelessVersion ), ghpagesNoJekyll := true, docMappingsApiDir := "api", diff --git a/src/main/scala/io/circe/fs2/encoding.scala b/src/main/scala/io/circe/fs2/encoding.scala new file mode 100644 index 0000000..8ce6c9b --- /dev/null +++ b/src/main/scala/io/circe/fs2/encoding.scala @@ -0,0 +1,85 @@ +package io.circe.fs2 + +import fs2.{Pipe, Stream} +import io.circe.Encoder +import io.circe.syntax._ +import shapeless._ +import shapeless.labelled.FieldType + +object encoding { + def jsonArrayString[F[_], T: Encoder]: Pipe[F, T, String] = + stream => Stream.emit("[") ++ stream.map(t => t.asJson.noSpaces).intersperse(",") ++ Stream.emit("]") + + trait StreamEncoder[F[_], A] { + def encode: A => Stream[F, String] + } + + object StreamEncoder extends LowPriorityImplicits { + + def instance[F[_], A](f: A => Stream[F, String]): StreamEncoder[F, A] = + new StreamEncoder[F, A] { def encode: A => Stream[F, String] = f } + + def apply[F[_], A](implicit enc: StreamEncoder[F, A]): StreamEncoder[F, A] = enc + + implicit def stream[F[_], A: Encoder]: StreamEncoder[F, Stream[F, A]] = StreamEncoder.instance(jsonArrayString) + + implicit def fromOption[F[_], A](implicit enc: StreamEncoder[F, A]): StreamEncoder[F, Option[A]] = + StreamEncoder.instance(_.fold[Stream[F, String]](Stream("null"))(enc.encode)) + + implicit def fromEncoder[F[_], A: Encoder]: StreamEncoder[F, A] = StreamEncoder.instance(a => Stream.emit(a.asJson.noSpaces)) + } + + trait LowPriorityImplicits { + + // TODO: make coproducts work + // implicit def cnilEncoder[F[_]]: StreamEncoder[F, CNil] = + // StreamEncoder.instance(_ => throw new Exception("Inconceivable!")) + // + // implicit def coproductEncoder[F[_], H, T <: Coproduct]( + // implicit + // hEncoder: Lazy[StreamEncoder[F, H]], + // tEncoder: StreamEncoder[F, T] + // ): StreamEncoder[F, H :+: T] = StreamEncoder.instance { + // case Inl(h) => hEncoder.value.encode(h) + // case Inr(t) => tEncoder.encode(t) + // } + + implicit def hnilEncoder[F[_]]: StreamEncoder[F, HNil] = + StreamEncoder.instance(_ => Stream.empty) + + implicit def hlistObjectEncoder[F[_], K <: Symbol, H, T <: HList]( + implicit + witness: Witness.Aux[K], + hEncoder: Lazy[StreamEncoder[F, H]], + tEncoder: StreamEncoder[F, T] + ): StreamEncoder[F, FieldType[K, H] :: T] = { + val fieldName = witness.value.name + StreamEncoder.instance { + case h :: t => + val head = hEncoder.value.encode(h) + val tail = tEncoder.encode(t) + val comma = t match { + case HNil => Stream.empty + case _ => Stream.emit(",") + } + Stream.emit(s""""$fieldName":""") ++ head ++ comma ++ tail + } + } + + implicit def genericObjectEncoder[F[_], A, H]( + implicit + generic: LabelledGeneric.Aux[A, H], + hEncoder: Lazy[StreamEncoder[F, H]] + ): StreamEncoder[F, A] = + StreamEncoder.instance { value => + hEncoder.value.encode(generic.to(value)).cons1("{") ++ Stream("}") + } + + } + + object syntax { + implicit class StreamEncoderSyntax[A](self: A) { + def asJsonStream[F[_]](implicit enc: StreamEncoder[F, A]): Stream[F, String] = enc.encode(self) + } + } +} diff --git a/src/test/scala/io/circe/fs2/EncodingSuite.scala b/src/test/scala/io/circe/fs2/EncodingSuite.scala new file mode 100644 index 0000000..86fe87d --- /dev/null +++ b/src/test/scala/io/circe/fs2/EncodingSuite.scala @@ -0,0 +1,45 @@ +package io.circe.fs2 + +import fs2.{Pure, Stream} +import io.circe.Codec +import io.circe.generic.semiauto._ +import org.scalatest.matchers.should.Matchers +import encoding.syntax._ +import io.circe.jawn.parse +import org.scalatest.flatspec.AnyFlatSpec + +class EncodingSuite extends AnyFlatSpec with Matchers { + case class Simple(s: String) + + object Simple { + implicit val enc: Codec[Simple] = deriveCodec + } + + case class Streamed[F[_], A](a: Int, b: Stream[Pure, A], c: Simple) + + case class Listed[A](a: Int, b: List[A], c: Simple) + + object Listed { + def fromStreamed[F[_], A](s: Streamed[F, A]): Listed[A] = Listed(s.a, s.b.compile.toList, s.c) + implicit def codec[A: Codec]: Codec[Listed[A]] = deriveCodec + } + + it should "encode a case class containing a stream" in { + val streamed = Streamed(1, Stream(Simple("a"), Simple("b"), Simple("c")), Simple("2")) + parse(streamed.asJsonStream[Pure].compile.string).flatMap(_.as[Listed[Simple]]) shouldBe Right(Listed.fromStreamed(streamed)) + } + + it should "encode a nested case class containing a stream" in { + case class Nested(a: Stream[Pure, Int], b: Option[Nested]) + case class NestedL(a: List[Int], b: Option[NestedL]) + object NestedL { + def fromStreamed(s: Nested): NestedL = NestedL(s.a.compile.toList, s.b.map(fromStreamed)) + implicit val codec: Codec[NestedL] = deriveCodec + } + + val streamed = Nested(Stream(1), Some(Nested(Stream(2), None))) + val string = streamed.asJsonStream[Pure].compile.string + + parse(string).flatMap(_.as[NestedL]) shouldBe Right(NestedL.fromStreamed(streamed)) + } +}