diff --git a/core/src/main/scala/io/finch/Endpoint.scala b/core/src/main/scala/io/finch/Endpoint.scala index 4be9c8435..d41df6f0d 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,96 @@ 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 FromInputStream[F](stream) + + /** + * 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] = { + 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 + * `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] = { + 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. */ @@ -592,7 +683,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/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/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/main/scala/io/finch/endpoint/endpoint.scala b/core/src/main/scala/io/finch/endpoint/endpoint.scala new file mode 100644 index 000000000..d3998dc15 --- /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 = s"GET /$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/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 + } + } } 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 @@ - +