forked from finagle/finch
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[finagle#1078] Streaming with Monix's Observable
- Loading branch information
Showing
5 changed files
with
298 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
package io.finch.monix | ||
|
||
import _root_.monix.eval.{Task, TaskApp} | ||
import _root_.monix.execution.Scheduler | ||
import _root_.monix.reactive.Observable | ||
import cats.effect.{ExitCode, Resource} | ||
import cats.implicits._ | ||
import com.twitter.finagle.{Http, ListeningServer} | ||
import com.twitter.util.Future | ||
import io.circe.generic.auto._ | ||
import io.finch._ | ||
import io.finch.circe._ | ||
import scala.util.Random | ||
|
||
/** | ||
* A Finch application featuring Monix Observable-based streaming support. | ||
* This approach is more advanced and performant then basic [[com.twitter.concurrent.AsyncStream]] | ||
* | ||
* There are three endpoints in this example: | ||
* | ||
* 1. `sumJson` - streaming request | ||
* 2. `streamJson` - streaming response | ||
* 3. `isPrime` - end-to-end (request - response) streaming | ||
* | ||
* Use the following sbt command to run the application. | ||
* | ||
* {{{ | ||
* $ sbt 'examples/runMain io.finch.monix.Main' | ||
* }}} | ||
* | ||
* Use the following HTTPie/curl commands to test endpoints. | ||
* | ||
* {{{ | ||
* $ curl -X POST --header "Transfer-Encoding: chunked" -d '{"i": 40} {"i": 2}' localhost:8081/sumJson | ||
* | ||
* $ http --stream GET :8081/streamJson | ||
* | ||
* $ curl -X POST --header "Transfer-Encoding: chunked" -d '{"i": 40} {"i": 42}' localhost:8081/streamPrime | ||
* }}} | ||
*/ | ||
object Main extends TaskApp with EndpointModule[Task] { | ||
|
||
override implicit def scheduler: Scheduler = super.scheduler | ||
|
||
final case class Result(result: Int) { | ||
def add(n: Number): Result = copy(result = result + n.i) | ||
} | ||
|
||
final case class Number(i: Int) { | ||
def isPrime: IsPrime = IsPrime(!(2 :: (3 to Math.sqrt(i.toDouble).toInt by 2).toList exists (i % _ == 0))) | ||
} | ||
|
||
final case class IsPrime(isPrime: Boolean) | ||
|
||
private def stream: Stream[Int] = Stream.continually(Random.nextInt()) | ||
|
||
val sumJson: Endpoint[Task, Result] = post("sumJson" :: jsonBodyStream[ObservableF, Number]) { | ||
o: Observable[Number] => | ||
o.foldLeftL(Result(0))(_ add _).map(Ok) | ||
} | ||
|
||
val streamJson: Endpoint[Task, ObservableF[Task, Number]] = get("streamJson") { | ||
Ok(Observable.fromIterable(stream).map(Number.apply)) | ||
} | ||
|
||
val isPrime: Endpoint[Task, ObservableF[Task, IsPrime]] = | ||
post("streamPrime" :: jsonBodyStream[ObservableF, Number]) { o: Observable[Number] => | ||
Ok(o.map(_.isPrime)) | ||
} | ||
|
||
def serve: Task[ListeningServer] = Task( | ||
Http.server | ||
.withStreaming(enabled = true) | ||
.serve(":8081", (sumJson :+: streamJson :+: isPrime).toServiceAs[Application.Json]) | ||
) | ||
|
||
def run(args: List[String]): Task[ExitCode] = { | ||
val server = Resource.make(serve)(s => | ||
Task.suspend(implicitly[ToAsync[Future, Task]].apply(s.close())) | ||
) | ||
|
||
server.use(_ => Task.never).as(ExitCode.Success) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
package io.finch | ||
|
||
import java.nio.charset.Charset | ||
|
||
import _root_.monix.eval.TaskLift | ||
import _root_.monix.reactive.Observable | ||
import cats.effect._ | ||
import com.twitter.io.{Buf, Pipe, Reader} | ||
import com.twitter.util.Future | ||
import io.finch.internal.newLine | ||
import io.finch.monix.ObservableF | ||
|
||
package object monix extends ObservableConcurrentEffectInstances { | ||
|
||
type ObservableF[F[_], A] = Observable[A] | ||
|
||
implicit def aliasResponseToRealResponse[F[_], A, CT <: Application.Json](implicit | ||
tr: ToResponse.Aux[F, ObservableF[F, A], CT] | ||
): ToResponse.Aux[F, Observable[A], CT] = tr | ||
|
||
implicit def observableLiftReader[F[_]](implicit | ||
F: Effect[F], | ||
TA: ToAsync[Future, F] | ||
): LiftReader[ObservableF, F] = | ||
new LiftReader[ObservableF, F] { | ||
final def apply[A](reader: Reader[Buf], process: Buf => A): ObservableF[F, A] = { | ||
Observable | ||
.repeatEvalF(F.suspend(TA(reader.read()))) | ||
.takeWhile(_.isDefined) | ||
.collect { case Some(buf) => process(buf) } | ||
.guaranteeF(F.delay(reader.discard())) | ||
} | ||
} | ||
|
||
implicit def encodeBufConcurrentEffectObservable[F[_] : ConcurrentEffect : TaskLift, CT <: String]: EncodeStream.Aux[F, ObservableF, Buf, CT] = | ||
new EncodeConcurrentEffectObservable[F, Buf, CT] { | ||
protected def encodeChunk(chunk: Buf, cs: Charset): Buf = chunk | ||
} | ||
} | ||
|
||
trait ObservableConcurrentEffectInstances extends ObservableEffectInstances { | ||
|
||
implicit def encodeJsonConcurrentObservable[F[_] : ConcurrentEffect : TaskLift, A](implicit | ||
A: Encode.Json[A] | ||
): EncodeStream.Json[F, ObservableF, A] = | ||
new EncodeNewLineDelimitedConcurrentEffectObservable[F, A, Application.Json] | ||
|
||
implicit def encodeSseConcurrentEffectObservable[F[_] : ConcurrentEffect : TaskLift, A](implicit | ||
A: Encode.Aux[A, Text.EventStream] | ||
): EncodeStream.Aux[F, ObservableF, A, Text.EventStream] = | ||
new EncodeNewLineDelimitedConcurrentEffectObservable[F, A, Text.EventStream] | ||
|
||
implicit def encodeTextConcurrentEffectObservable[F[_] : ConcurrentEffect : TaskLift, A](implicit | ||
A: Encode.Text[A] | ||
): EncodeStream.Text[F, ObservableF, A] = | ||
new EncodeConcurrentEffectObservable[F, A, Text.Plain] { | ||
override protected def encodeChunk(chunk: A, cs: Charset): Buf = | ||
A(chunk, cs) | ||
} | ||
|
||
implicit def encodeBufEffectObservable[F[_] : Effect : TaskLift, CT <: String]: EncodeStream.Aux[F, ObservableF, Buf, CT] = | ||
new EncodeEffectObservable[F, Buf, CT] { | ||
protected def encodeChunk(chunk: Buf, cs: Charset): Buf = chunk | ||
} | ||
} | ||
|
||
trait ObservableEffectInstances extends ObservableInstances { | ||
|
||
implicit def encodeJsonEffectObservable[F[_] : Effect : TaskLift, A](implicit | ||
A: Encode.Json[A] | ||
): EncodeStream.Json[F, ObservableF, A] = | ||
new EncodeNewLineDelimitedEffectObservable[F, A, Application.Json] | ||
|
||
implicit def encodeSseEffectObservable[F[_] : Effect : TaskLift, A](implicit | ||
A: Encode.Aux[A, Text.EventStream] | ||
): EncodeStream.Aux[F, ObservableF, A, Text.EventStream] = | ||
new EncodeNewLineDelimitedEffectObservable[F, A, Text.EventStream] | ||
|
||
implicit def encodeTextEffectObservable[F[_] : Effect : TaskLift, A](implicit | ||
A: Encode.Text[A] | ||
): EncodeStream.Text[F, ObservableF, A] = | ||
new EncodeEffectObservable[F, A, Text.Plain] { | ||
override protected def encodeChunk(chunk: A, cs: Charset): Buf = | ||
A(chunk, cs) | ||
} | ||
} | ||
|
||
trait ObservableInstances { | ||
|
||
protected final class EncodeNewLineDelimitedConcurrentEffectObservable[F[_] : ConcurrentEffect : TaskLift, A, CT <: String](implicit | ||
A: Encode.Aux[A, CT] | ||
) extends EncodeConcurrentEffectObservable[F, A, CT] { | ||
protected def encodeChunk(chunk: A, cs: Charset): Buf = | ||
A(chunk, cs).concat(newLine(cs)) | ||
} | ||
|
||
protected final class EncodeNewLineDelimitedEffectObservable[F[_] : Effect : TaskLift, A, CT <: String](implicit | ||
A: Encode.Aux[A, CT] | ||
) extends EncodeEffectObservable[F, A, CT] { | ||
protected def encodeChunk(chunk: A, cs: Charset): Buf = | ||
A(chunk, cs).concat(newLine(cs)) | ||
} | ||
|
||
protected abstract class EncodeConcurrentEffectObservable[F[_] : TaskLift, A, CT <: String](implicit | ||
F: ConcurrentEffect[F], | ||
TA: ToAsync[Future, F] | ||
) extends EncodeObservable[F, A, CT] { | ||
protected def dispatch(reader: Reader[Buf], run: F[Unit]): F[Reader[Buf]] = | ||
F.bracketCase(F.start(run))(_ => F.pure(reader)) { | ||
case (f, ExitCase.Canceled) => f.cancel | ||
case _ => F.unit | ||
} | ||
} | ||
|
||
protected abstract class EncodeEffectObservable[F[_]: TaskLift, A, CT <: String](implicit | ||
F: Effect[F], | ||
TA: ToAsync[Future, F] | ||
) extends EncodeObservable[F, A, CT] with (Either[Throwable, Unit] => IO[Unit]) { | ||
|
||
def apply(cb: Either[Throwable, Unit]): IO[Unit] = IO.unit | ||
|
||
protected def dispatch(reader: Reader[Buf], run: F[Unit]): F[Reader[Buf]] = | ||
F.productR(F.runAsync(run)(this).to[F])(F.pure(reader)) | ||
} | ||
|
||
protected abstract class EncodeObservable[F[_]: TaskLift, A, CT <: String](implicit | ||
F: Effect[F], | ||
TA: ToAsync[Future, F] | ||
) extends EncodeStream[F, ObservableF, A] { | ||
|
||
type ContentType = CT | ||
|
||
protected def encodeChunk(chunk: A, cs: Charset): Buf | ||
|
||
protected def dispatch(reader: Reader[Buf], run: F[Unit]): F[Reader[Buf]] | ||
|
||
override def apply(s: ObservableF[F, A], cs: Charset): F[Reader[Buf]] = | ||
F.suspend { | ||
val p = new Pipe[Buf] | ||
|
||
val run = s | ||
.map(chunk => encodeChunk(chunk, cs)) | ||
.mapEvalF(chunk => TA(p.write(chunk))) | ||
.guaranteeF(F.suspend(TA(p.close()))) | ||
.completedL | ||
.to[F] | ||
|
||
dispatch(p, run) | ||
|
||
} | ||
} | ||
|
||
} | ||
|
29 changes: 29 additions & 0 deletions
29
monix/src/test/scala/io/finch/monix/MonixObservableStreamingSpec.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
package io.finch.monix | ||
|
||
import cats.effect.{ConcurrentEffect, Effect, IO} | ||
import com.twitter.io.Buf | ||
import io.finch.{FinchSpec, StreamingLaws} | ||
import monix.eval.TaskLift | ||
import monix.execution.Scheduler | ||
import monix.reactive.Observable | ||
import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks | ||
|
||
class MonixObservableStreamingSpec extends FinchSpec with ScalaCheckDrivenPropertyChecks { | ||
|
||
implicit val s = Scheduler.global | ||
|
||
checkEffect[IO] | ||
checkConcurrentEffect[IO] | ||
|
||
def checkEffect[F[_]: TaskLift](implicit F: Effect[F]): Unit = | ||
checkAll("monixObservable.streamBody[F[_]: Effect]", StreamingLaws[ObservableF, F]( | ||
list => Observable(list:_*), | ||
stream => F.toIO(stream.map(array => Buf.ByteArray.Owned(array)).toListL.to[F]).unsafeRunSync() | ||
).all) | ||
|
||
def checkConcurrentEffect[F[_]: TaskLift](implicit F: ConcurrentEffect[F]): Unit = | ||
checkAll("monixObservable.streamBody[F[_]: ConcurrentEffect]", StreamingLaws[ObservableF, F]( | ||
list => Observable(list:_*), | ||
stream => F.toIO(stream.map(array => Buf.ByteArray.Owned(array)).toListL.to[F]).unsafeRunSync() | ||
).all) | ||
} |