From a50ad4d73773d8418ca67e70aff5dd888ff1ff33 Mon Sep 17 00:00:00 2001 From: Vladimir Kostyukov Date: Tue, 30 Oct 2018 16:43:52 -0700 Subject: [PATCH 1/4] Introduce Endpoints for serving assets --- core/src/main/scala/io/finch/Endpoint.scala | 106 +++++++++++++++++- .../main/scala/io/finch/EndpointModule.scala | 31 ++++- .../main/scala/io/finch/endpoint/asset.scala | 18 +++ core/src/test/resources/test.txt | 1 + .../test/scala/io/finch/EndpointSpec.scala | 21 +++- .../scala/io/finch/MissingInstances.scala | 4 + scalastyle-config.xml | 2 +- 7 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 core/src/main/scala/io/finch/endpoint/asset.scala create mode 100644 core/src/test/resources/test.txt diff --git a/core/src/main/scala/io/finch/Endpoint.scala b/core/src/main/scala/io/finch/Endpoint.scala index 4be9c8435..060fff301 100644 --- a/core/src/main/scala/io/finch/Endpoint.scala +++ b/core/src/main/scala/io/finch/Endpoint.scala @@ -2,7 +2,7 @@ package io.finch import cats.{Alternative, Applicative, ApplicativeError, Id, Monad, MonadError} import cats.data.NonEmptyList -import cats.effect.{Effect, Sync} +import cats.effect.{ContextShift, Effect, Resource, Sync} import cats.syntax.all._ import com.twitter.concurrent.AsyncStream import com.twitter.finagle.Service @@ -17,6 +17,7 @@ import com.twitter.io.{Buf, Reader} import io.finch.endpoint._ import io.finch.internal._ import io.finch.items.RequestItem +import java.io.{File, FileInputStream, InputStream} import scala.reflect.ClassTag import shapeless._ import shapeless.ops.adjoin.Adjoin @@ -575,6 +576,109 @@ object Endpoint { EndpointResult.Matched(input, Trace.empty, F.suspend(foa)) } + /** + * Creates an [[Endpoint]] from a given [[InputStream]]. Uses [[Resource]] for safer resource + * management and [[ContextShift]] for offloading blocking work from a worker pool. + * + * @see [[fromFile]] + */ + def fromInputStream[F[_]](stream: Resource[F, InputStream])( + implicit F: Effect[F], S: ContextShift[F] + ): Endpoint[F, Buf] = + new Endpoint[F, Buf] { + private def readLoop(left: Buf, stream: InputStream): F[Buf] = F.suspend { + val buffer = new Array[Byte](1024) + val n = stream.read(buffer) + if (n == -1) F.pure(left) + else readLoop(left.concat(Buf.ByteArray.Owned(buffer, 0, n)), stream) + } + + final def apply(input: Input): Result[F, Buf] = { + val output = stream.use(s => + S.shift.flatMap(_ => readLoop(Buf.Empty, s)).map(buf => Output.payload(buf)) + ) + + EndpointResult.Matched(input.withRoute(Nil), Trace.empty, output) + } + } + + /** + * Creates an [[Endpoint]] from a given [[File]]. Uses [[Resource]] for safer resource + * management and [[ContextShift]] for offloading blocking work from a worker pool. + * + * @see [[fromInputStream]] + */ + def fromFile[F[_]](file: File)( + implicit F: Effect[F], S: ContextShift[F] + ): Endpoint[F, Buf] = + fromInputStream[F]( + Resource.fromAutoCloseable(F.delay(new FileInputStream(file))) + ) + + /** + * Creates an [[Endpoint]] that serves an asset (static content) from a Java classpath resource, + * located at `path`, as a static content. The returned endpoint will only match `GET` requests + * with path identical to asset's. + * + * This could be especially useful in local development, when throughput and latency matter less + * than quick iterations. These means, however, are not recommended for production usage. Web + * servers (Nginx, Apache) will do much better job serving static files. + * + * Example project structure: + * + * {{{ + * ├── scala + * │ └── Main.scala + * └── resources + * ├── index.html + * └── script.js + * }}} + * + * Example bootstrap: + * + * {{{ + * Bootstrap + * ... + * .serve[Text.Html](Endpoint[IO].classpathAsset("/index.html")) + * .serve[Application.Javascript](Endpoint[IO].classpathAsset("/script.js")) + * ... + * }}} + * + * @see https://docs.oracle.com/javase/8/docs/technotes/guides/lang/resources.html + */ + def classpathAsset[F[_]](path: String)(implicit + F: Effect[F], + S: ContextShift[F] + ): Endpoint[F, Buf] = + new Asset[F]( + path, + fromInputStream[F](Resource.fromAutoCloseable(F.delay(getClass.getResourceAsStream(path)))) + ) + + /** + * Creates an [[Endpoint]] that serves an asset (static content) from a filesystem, located at + * `path`, as a static content. The returned endpoint will only match `GET` requests with path + * identical to asset's. + * + * Example bootstrap: + * + * {{{ + * Bootstrap + * ... + * .serve[Text.Html](Endpoint[IO].filesystemAsset("index.html")) + * .serve[Application.Javascript](Endpoint[IO].filesystemAsset("script.js")) + * ... + * }}} + */ + def filesystemAsset[F[_]](path: String)(implicit + F: Effect[F], + S: ContextShift[F] + ): Endpoint[F, Buf] = + new Asset[F]( + path, + fromFile[F](new File(path)) + ) + /** * A root [[Endpoint]] that always matches and extracts the current request. */ diff --git a/core/src/main/scala/io/finch/EndpointModule.scala b/core/src/main/scala/io/finch/EndpointModule.scala index 93618075a..2f1e57774 100644 --- a/core/src/main/scala/io/finch/EndpointModule.scala +++ b/core/src/main/scala/io/finch/EndpointModule.scala @@ -2,11 +2,12 @@ package io.finch import cats.Applicative import cats.data.NonEmptyList -import cats.effect.{Effect, Sync} +import cats.effect.{ContextShift, Effect, Resource, Sync} import com.twitter.concurrent.AsyncStream import com.twitter.finagle.http.{Cookie, Request} import com.twitter.finagle.http.exp.Multipart import com.twitter.io.Buf +import java.io.{File, InputStream} import scala.reflect.ClassTag import shapeless.HNil @@ -95,6 +96,34 @@ trait EndpointModule[F[_]] { def liftOutputAsync[A](foa: => F[Output[A]])(implicit F: Sync[F]): Endpoint[F, A] = Endpoint.liftOutputAsync[F, A](foa) + /** + * An alias for [[Endpoint.fromInputStream]]. + */ + def fromInputStream(stream: Resource[F, InputStream])( + implicit F: Effect[F], S: ContextShift[F] + ): Endpoint[F, Buf] = + Endpoint.fromInputStream[F](stream) + + /** + * An alias for [[Endpoint.fromFile]]. + */ + def fromFile(file: File)( + implicit F: Effect[F], S: ContextShift[F] + ): Endpoint[F, Buf] = + Endpoint.fromFile[F](file) + + /** + * An alias for [[Endpoint.classpathAsset]]. + */ + def classpathAsset(path: String)(implicit F: Effect[F], S: ContextShift[F]): Endpoint[F, Buf] = + Endpoint.classpathAsset[F](path) + + /** + * An alias for [[Endpoint.classpathAsset]]. + */ + def filesystemAsset(path: String)(implicit F: Effect[F], S: ContextShift[F]): Endpoint[F, Buf] = + Endpoint.filesystemAsset[F](path) + /** * An alias for [[Endpoint.root]]. */ diff --git a/core/src/main/scala/io/finch/endpoint/asset.scala b/core/src/main/scala/io/finch/endpoint/asset.scala new file mode 100644 index 000000000..88baed626 --- /dev/null +++ b/core/src/main/scala/io/finch/endpoint/asset.scala @@ -0,0 +1,18 @@ +package io.finch.endpoint + +import com.twitter.finagle.http.{Method => FinagleMethod} +import com.twitter.io.Buf +import io.finch.{Endpoint, EndpointResult, Input} + +private[finch] class Asset[F[_]]( + path: String, + resource: Endpoint[F, Buf] +) extends Endpoint[F, Buf] { + final def apply(input: Input): Endpoint.Result[F, Buf] = { + val req = input.request + if (req.method != FinagleMethod.Get || req.uri != path) EndpointResult.NotMatched[F] + else resource(input) + } + + final override def toString: String = path +} diff --git a/core/src/test/resources/test.txt b/core/src/test/resources/test.txt new file mode 100644 index 000000000..1aeaedbf4 --- /dev/null +++ b/core/src/test/resources/test.txt @@ -0,0 +1 @@ +foo bar baz diff --git a/core/src/test/scala/io/finch/EndpointSpec.scala b/core/src/test/scala/io/finch/EndpointSpec.scala index 6dec721d1..0a5ca03fc 100644 --- a/core/src/test/scala/io/finch/EndpointSpec.scala +++ b/core/src/test/scala/io/finch/EndpointSpec.scala @@ -4,11 +4,13 @@ import java.util.UUID import java.util.concurrent.TimeUnit import cats.data.NonEmptyList -import cats.effect.IO +import cats.effect.{IO, Resource} import cats.laws.discipline.AlternativeTests import cats.laws.discipline.SemigroupalTests.Isomorphisms import com.twitter.finagle.http.{Cookie, Method, Request} +import com.twitter.io.Buf import io.finch.data.Foo +import java.io.{ByteArrayInputStream, InputStream} import scala.concurrent.duration.Duration import shapeless._ @@ -363,4 +365,21 @@ class EndpointSpec extends FinchSpec { endpoint(Input.get("/index", "testEndpoint" -> "a")).awaitValueUnsafe(Duration(10, TimeUnit.SECONDS)) ) } + + it should "fromInputStream" in { + val bytes = Array[Byte](1, 2, 3, 4, 5) + val bis = Resource.fromAutoCloseable[IO, InputStream](IO.delay(new ByteArrayInputStream(bytes))) + + val is = fromInputStream(bis) + + is(Input.get("/")).awaitValueUnsafe() shouldBe Some(Buf.ByteArray.Owned(bytes)) + } + + it should "classpathAsset" in { + val r = classpathAsset("/test.txt") + + r(Input.get("/foo")).awaitOutputUnsafe() shouldBe None + r(Input.post("/")).awaitOutputUnsafe() shouldBe None + r(Input.get("/test.txt")).awaitValueUnsafe() shouldBe Some(Buf.Utf8("foo bar baz\n")) + } } diff --git a/core/src/test/scala/io/finch/MissingInstances.scala b/core/src/test/scala/io/finch/MissingInstances.scala index 4aedb980b..22a9bedb4 100644 --- a/core/src/test/scala/io/finch/MissingInstances.scala +++ b/core/src/test/scala/io/finch/MissingInstances.scala @@ -1,7 +1,9 @@ package io.finch import cats.Eq +import cats.effect.{ContextShift, IO} import com.twitter.io.Buf +import scala.concurrent.ExecutionContext /** * Type class instances for non-Finch types. @@ -14,4 +16,6 @@ trait MissingInstances { } implicit def eqBuf: Eq[Buf] = Eq.fromUniversalEquals + + implicit val shift: ContextShift[IO] = IO.contextShift(ExecutionContext.global) } diff --git a/scalastyle-config.xml b/scalastyle-config.xml index a15ea5cab..6a632a4ff 100644 --- a/scalastyle-config.xml +++ b/scalastyle-config.xml @@ -9,7 +9,7 @@ - + From e7e736f8789568dba46187648ca706886c0b8dbc Mon Sep 17 00:00:00 2001 From: Vladimir Kostyukov Date: Tue, 13 Nov 2018 07:32:16 -0800 Subject: [PATCH 2/4] Adding Trace.fromRoute --- core/src/main/scala/io/finch/Endpoint.scala | 8 +++++-- core/src/main/scala/io/finch/Trace.scala | 23 ++++++++++++++++++++ core/src/test/scala/io/finch/TraceSpec.scala | 6 +++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/core/src/main/scala/io/finch/Endpoint.scala b/core/src/main/scala/io/finch/Endpoint.scala index 060fff301..99381e19d 100644 --- a/core/src/main/scala/io/finch/Endpoint.scala +++ b/core/src/main/scala/io/finch/Endpoint.scala @@ -598,7 +598,7 @@ object Endpoint { S.shift.flatMap(_ => readLoop(Buf.Empty, s)).map(buf => Output.payload(buf)) ) - EndpointResult.Matched(input.withRoute(Nil), Trace.empty, output) + EndpointResult.Matched(input, Trace.empty, output) } } @@ -696,7 +696,11 @@ object Endpoint { def pathAny[F[_]](implicit F: Applicative[F]): Endpoint[F, HNil] = new Endpoint[F, HNil] { final def apply(input: Input): Result[F, HNil] = - EndpointResult.Matched(input.withRoute(Nil), Trace.empty, F.pure(Output.HNil)) + EndpointResult.Matched( + input.withRoute(Nil), + Trace.fromRoute(input.route), + F.pure(Output.HNil) + ) final override def toString: String = "*" } diff --git a/core/src/main/scala/io/finch/Trace.scala b/core/src/main/scala/io/finch/Trace.scala index fbf2110c5..214261742 100644 --- a/core/src/main/scala/io/finch/Trace.scala +++ b/core/src/main/scala/io/finch/Trace.scala @@ -63,6 +63,29 @@ object Trace { def empty: Trace = Empty def segment(s: String): Trace = Segment(s, empty) + def fromRoute(r: Seq[String]): Trace = { + var result = empty + var current: Segment = null + + def prepend(segment: Segment): Unit = { + if (result == empty) { + result = segment + current = segment + } else { + current.next = segment + current = segment + } + } + + var rs = r + while (rs.nonEmpty) { + prepend(Segment(rs.head, empty)) + rs = rs.tail + } + + result + } + /** * Within a given context `fn`, capture the [[Trace]] instance under `Trace.captured` for each * matched endpoint. diff --git a/core/src/test/scala/io/finch/TraceSpec.scala b/core/src/test/scala/io/finch/TraceSpec.scala index 6f8b44782..31fc5f95f 100644 --- a/core/src/test/scala/io/finch/TraceSpec.scala +++ b/core/src/test/scala/io/finch/TraceSpec.scala @@ -16,4 +16,10 @@ class TraceSpec extends FinchSpec { a.concat(b).toList === (a.toList ++ b.toList) } } + + it should "create fromRoute" in { + check { l: List[String] => + Trace.fromRoute(l).toList === l + } + } } From 91610b5b5242b701fcda160b119e31feb6ce7751 Mon Sep 17 00:00:00 2001 From: Vladimir Kostyukov Date: Tue, 13 Nov 2018 08:08:44 -0800 Subject: [PATCH 3/4] Properly override trace and route for assets --- core/src/main/scala/io/finch/Endpoint.scala | 39 +++++---------- .../main/scala/io/finch/endpoint/asset.scala | 18 ------- .../scala/io/finch/endpoint/endpoint.scala | 47 +++++++++++++++++++ 3 files changed, 60 insertions(+), 44 deletions(-) delete mode 100644 core/src/main/scala/io/finch/endpoint/asset.scala create mode 100644 core/src/main/scala/io/finch/endpoint/endpoint.scala diff --git a/core/src/main/scala/io/finch/Endpoint.scala b/core/src/main/scala/io/finch/Endpoint.scala index 99381e19d..d41df6f0d 100644 --- a/core/src/main/scala/io/finch/Endpoint.scala +++ b/core/src/main/scala/io/finch/Endpoint.scala @@ -584,23 +584,7 @@ object Endpoint { */ def fromInputStream[F[_]](stream: Resource[F, InputStream])( implicit F: Effect[F], S: ContextShift[F] - ): Endpoint[F, Buf] = - new Endpoint[F, Buf] { - private def readLoop(left: Buf, stream: InputStream): F[Buf] = F.suspend { - val buffer = new Array[Byte](1024) - val n = stream.read(buffer) - if (n == -1) F.pure(left) - else readLoop(left.concat(Buf.ByteArray.Owned(buffer, 0, n)), stream) - } - - final def apply(input: Input): Result[F, Buf] = { - val output = stream.use(s => - S.shift.flatMap(_ => readLoop(Buf.Empty, s)).map(buf => Output.payload(buf)) - ) - - EndpointResult.Matched(input, Trace.empty, output) - } - } + ): Endpoint[F, Buf] = new FromInputStream[F](stream) /** * Creates an [[Endpoint]] from a given [[File]]. Uses [[Resource]] for safer resource @@ -649,11 +633,13 @@ object Endpoint { def classpathAsset[F[_]](path: String)(implicit F: Effect[F], S: ContextShift[F] - ): Endpoint[F, Buf] = - new Asset[F]( - path, + ): Endpoint[F, Buf] = { + val asset = new Asset[F](path) + val stream = fromInputStream[F](Resource.fromAutoCloseable(F.delay(getClass.getResourceAsStream(path)))) - ) + + asset :: stream + } /** * Creates an [[Endpoint]] that serves an asset (static content) from a filesystem, located at @@ -673,11 +659,12 @@ object Endpoint { def filesystemAsset[F[_]](path: String)(implicit F: Effect[F], S: ContextShift[F] - ): Endpoint[F, Buf] = - new Asset[F]( - path, - fromFile[F](new File(path)) - ) + ): Endpoint[F, Buf] = { + val asset = new Asset[F](path) + val file = fromFile[F](new File(path)) + + asset :: file + } /** * A root [[Endpoint]] that always matches and extracts the current request. diff --git a/core/src/main/scala/io/finch/endpoint/asset.scala b/core/src/main/scala/io/finch/endpoint/asset.scala deleted file mode 100644 index 88baed626..000000000 --- a/core/src/main/scala/io/finch/endpoint/asset.scala +++ /dev/null @@ -1,18 +0,0 @@ -package io.finch.endpoint - -import com.twitter.finagle.http.{Method => FinagleMethod} -import com.twitter.io.Buf -import io.finch.{Endpoint, EndpointResult, Input} - -private[finch] class Asset[F[_]]( - path: String, - resource: Endpoint[F, Buf] -) extends Endpoint[F, Buf] { - final def apply(input: Input): Endpoint.Result[F, Buf] = { - val req = input.request - if (req.method != FinagleMethod.Get || req.uri != path) EndpointResult.NotMatched[F] - else resource(input) - } - - final override def toString: String = path -} diff --git a/core/src/main/scala/io/finch/endpoint/endpoint.scala b/core/src/main/scala/io/finch/endpoint/endpoint.scala new file mode 100644 index 000000000..90b860c2e --- /dev/null +++ b/core/src/main/scala/io/finch/endpoint/endpoint.scala @@ -0,0 +1,47 @@ +package io.finch + +import cats.effect.{ContextShift, Effect, Resource} +import cats.syntax.all._ +import com.twitter.finagle.http.{Method => FinagleMethod} +import com.twitter.io.Buf +import java.io.InputStream +import shapeless.HNil + +package object endpoint { + + private[finch] class FromInputStream[F[_]](stream: Resource[F, InputStream])( + implicit F: Effect[F], S: ContextShift[F] + ) extends Endpoint[F, Buf] { + + private def readLoop(left: Buf, stream: InputStream): F[Buf] = F.suspend { + val buffer = new Array[Byte](1024) + val n = stream.read(buffer) + if (n == -1) F.pure(left) + else readLoop(left.concat(Buf.ByteArray.Owned(buffer, 0, n)), stream) + } + + final def apply(input: Input): Endpoint.Result[F, Buf] = + EndpointResult.Matched( + input, + Trace.empty, + stream.use(s => + S.shift.flatMap(_ => readLoop(Buf.Empty, s)).map(buf => Output.payload(buf)) + ) + ) + } + + private[finch] class Asset[F[_]](path: String)(implicit F: Effect[F]) extends Endpoint[F, HNil] { + final def apply(input: Input): Endpoint.Result[F, HNil] = { + val req = input.request + if (req.method != FinagleMethod.Get || req.path != path) EndpointResult.NotMatched[F] + else + EndpointResult.Matched( + input.withRoute(Nil), + Trace.fromRoute(input.route), + F.pure(Output.HNil) + ) + } + + final override def toString: String = path + } +} From 15eb10bcf18f68c5436904678f9e855b82cb5a6e Mon Sep 17 00:00:00 2001 From: Vladimir Kostyukov Date: Tue, 13 Nov 2018 08:18:04 -0800 Subject: [PATCH 4/4] Better toString for Asset --- core/src/main/scala/io/finch/endpoint/endpoint.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/scala/io/finch/endpoint/endpoint.scala b/core/src/main/scala/io/finch/endpoint/endpoint.scala index 90b860c2e..d3998dc15 100644 --- a/core/src/main/scala/io/finch/endpoint/endpoint.scala +++ b/core/src/main/scala/io/finch/endpoint/endpoint.scala @@ -42,6 +42,6 @@ package object endpoint { ) } - final override def toString: String = path + final override def toString: String = s"GET /$path" } }