From 5fc4c748dd686551fe6a54d166affca7252af0ec Mon Sep 17 00:00:00 2001 From: Vladimir Kostyukov Date: Mon, 12 Jun 2017 16:42:58 -0700 Subject: [PATCH] Introduce Bootstrap --- core/src/main/scala/io/finch/Bootstrap.scala | 33 ++++++++++ core/src/main/scala/io/finch/Endpoint.scala | 6 +- .../scala/io/finch/internal/ToService.scala | 61 ++++++++++++++++--- 3 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 core/src/main/scala/io/finch/Bootstrap.scala diff --git a/core/src/main/scala/io/finch/Bootstrap.scala b/core/src/main/scala/io/finch/Bootstrap.scala new file mode 100644 index 000000000..3684db38e --- /dev/null +++ b/core/src/main/scala/io/finch/Bootstrap.scala @@ -0,0 +1,33 @@ +package io.finch + +import com.twitter.finagle.Service +import com.twitter.finagle.http.{Request, Response} +import io.finch.internal.ToService +import shapeless._ + +/** + * Bootstraps a Finagle HTTP service out of the collection of Finch endpoints. + * + * {{{ + * + * val api: Service[Request, Response] = Bootstrap + * .serve[Application.Json](getUser :+: postUser) + * .serve[Text.Plain](healthcheck) + * .toService + * }}} + * + * @note This API is experimental/unstable. Use it with caution. + */ +case class Bootstrap[ES <: HList, CTS <: HList](es: ES) { self => + + class Serve[CT <: String] { + def apply[E](e: Endpoint[E]): Bootstrap[Endpoint[E] :: ES, CT :: CTS] = + self.copy(e :: self.es) + } + + def serve[CT <: String]: Serve[CT] = new Serve[CT] + + def toService(implicit ts: ToService[ES, CTS]): Service[Request, Response] = ts(es) +} + +object Bootstrap extends Bootstrap[HNil, HNil](HNil) diff --git a/core/src/main/scala/io/finch/Endpoint.scala b/core/src/main/scala/io/finch/Endpoint.scala index d87ede39a..1a4926ad0 100644 --- a/core/src/main/scala/io/finch/Endpoint.scala +++ b/core/src/main/scala/io/finch/Endpoint.scala @@ -246,6 +246,8 @@ trait Endpoint[A] { self => /** * Converts this endpoint to a Finagle service `Request => Future[Response]` that serves JSON. + * + * Consider using [[Bootstrap]] instead. */ final def toService(implicit tr: ToResponse.Aux[A, Application.Json], @@ -255,11 +257,13 @@ trait Endpoint[A] { self => /** * Converts this endpoint to a Finagle service `Request => Future[Response]` that serves custom * content-type `CT`. + * + * Consider using [[Bootstrap]] instead. */ final def toServiceAs[CT <: String](implicit tr: ToResponse.Aux[A, CT], tre: ToResponse.Aux[Exception, CT] - ): Service[Request, Response] = new ToService(self, tr, tre) + ): Service[Request, Response] = Bootstrap.serve[CT](this).toService /** * Recovers from any exception occurred in this endpoint by creating a new endpoint that will diff --git a/core/src/main/scala/io/finch/internal/ToService.scala b/core/src/main/scala/io/finch/internal/ToService.scala index b64dda75d..5a259b57a 100644 --- a/core/src/main/scala/io/finch/internal/ToService.scala +++ b/core/src/main/scala/io/finch/internal/ToService.scala @@ -4,6 +4,33 @@ import com.twitter.finagle.Service import com.twitter.finagle.http.{Request, Response, Status, Version} import com.twitter.util.Future import io.finch.{Endpoint, EndpointResult, Input, Output} +import scala.annotation.implicitNotFound +import shapeless._ + +/** + * Wraps a given list of [[Endpoint]]s and their content-types with a Finagle [[Service]]. + * + * Guarantees to: + * + * - handle Finch's own errors (i.e., [[Error]] and [[Error]]) as 400s + * - supply the date header to each response + * - copy requests's HTTP version onto a response + * - respond with 404 when en endpoint is not matched + */ +@implicitNotFound( + """An Endpoint you're trying to convert into a Finagle service is missing one or more encoders. + + Make sure ${A} is one of the following: + + * A com.twitter.finagle.http.Response + * A value of a type with an io.finch.Encode instance (with the corresponding content-type) + * A coproduct made up of some combination of the above + + See https://github.com/finagle/finch/blob/master/docs/cookbook.md#fixing-the-toservice-compile-error +""") +trait ToService[ES <: HList, CTS <: HList] { + def apply(es: ES): Service[Request, Response] +} /** * Wraps a given [[Endpoint]] with a Finagle [[Service]]. @@ -14,25 +41,39 @@ import io.finch.{Endpoint, EndpointResult, Input, Output} * - copy requests's HTTP version onto a response * - respond with 404 when en endpoint is not matched */ -private[finch] final class ToService[A, CT <: String]( - e: Endpoint[A], - tr: ToResponse.Aux[A, CT], - tre: ToResponse.Aux[Exception, CT]) extends Service[Request, Response] { +object ToService { - private[this] val underlying = e.handle { + private val respond400OnErrors: PartialFunction[Throwable, Output[Nothing]] = { case e: io.finch.Error => Output.failure(e, Status.BadRequest) case es: io.finch.Errors => Output.failure(es, Status.BadRequest) } - private[this] def conformHttp(rep: Response, version: Version): Response = { + private def conformHttp(rep: Response, version: Version): Response = { rep.version = version rep.date = currentTime() rep } - def apply(req: Request): Future[Response] = underlying(Input.fromRequest(req)) match { - case EndpointResult.Matched(rem, out) if rem.route.isEmpty => - out.map(oa => conformHttp(oa.toResponse(tr, tre), req.version)).run - case _ => Future.value(conformHttp(Response(Status.NotFound), req.version)) + implicit val hnilTS: ToService[HNil, HNil] = new ToService[HNil, HNil] { + def apply(es: HNil): Service[Request, Response] = new Service[Request, Response] { + def apply(req: Request): Future[Response] = + Future.value(conformHttp(Response(Status.NotFound), req.version)) + } + } + + implicit def hlistTS[A, EH <: Endpoint[A], ET <: HList, CTH <: String, CTT <: HList](implicit + trA: ToResponse.Aux[A, CTH], + trE: ToResponse.Aux[Exception, CTH], + tsT: ToService[ET, CTT] + ): ToService[Endpoint[A] :: ET, CTH :: CTT] = new ToService[Endpoint[A] :: ET, CTH :: CTT] { + def apply(es: Endpoint[A] :: ET): Service[Request, Response] = new Service[Request, Response] { + private[this] val underlying = es.head.handle(respond400OnErrors) + + def apply(req: Request): Future[Response] = underlying(Input.fromRequest(req)) match { + case EndpointResult.Matched(rem, out) if rem.route.isEmpty => + out.map(oa => conformHttp(oa.toResponse(trA, trE), req.version)).run + case _ => tsT(es.tail)(req) + } + } } }