Skip to content

Commit

Permalink
circe#207: generic encoding for streams
Browse files Browse the repository at this point in the history
  • Loading branch information
soujiro32167 committed Feb 10, 2021
1 parent 74ed678 commit f0d1934
Show file tree
Hide file tree
Showing 5 changed files with 135 additions and 2 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ target/
.project
.classpath
tmp/
.bsp
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ language: scala

scala:
- 2.12.10
- 2.13.0
- 2.13.4

jdk:
- openjdk8
Expand Down
4 changes: 3 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
85 changes: 85 additions & 0 deletions src/main/scala/io/circe/fs2/encoding.scala
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
45 changes: 45 additions & 0 deletions src/test/scala/io/circe/fs2/EncodingSuite.scala
Original file line number Diff line number Diff line change
@@ -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))
}
}

0 comments on commit f0d1934

Please sign in to comment.