diff --git a/benchmarks/src/main/scala/io/finch/benchmarks/BodyBenchmark.scala b/benchmarks/src/main/scala/io/finch/benchmarks/BodyBenchmark.scala index 64ac05d4b..07813d1c1 100644 --- a/benchmarks/src/main/scala/io/finch/benchmarks/BodyBenchmark.scala +++ b/benchmarks/src/main/scala/io/finch/benchmarks/BodyBenchmark.scala @@ -1,29 +1,46 @@ package io.finch.benchmarks import com.twitter.io.Buf +import com.twitter.util.Return import io.finch._ +import io.finch.internal.BufText import org.openjdk.jmh.annotations.{Benchmark, Scope, State} @State(Scope.Benchmark) class BodyBenchmark extends FinchBenchmark { + implicit val decodeJsonAsString: Decode.Json[String] = + Decode.json((b, cs) => Return(BufText.extract(b, cs))) + val input = Input.post("/").withBody[Text.Plain](Buf.Utf8("x" * 1024)) + val bodyAsString = body.as[String] + val bodyOptionAsString = bodyOption.as[String] + + val bodyAsString2 = body[String, Application.Json] + val bodyOptionAsString2 = bodyOption[String, Application.Json] + + @Benchmark + def jsonOption: Option[String] = bodyOptionAsString(input).value.get + + @Benchmark + def json: String = bodyAsString(input).value.get + @Benchmark - def bufOption: Option[Buf] = bodyOption(input).value.get + def jsonOption2: Option[String] = bodyOptionAsString2(input).value.get @Benchmark - def buf: Buf = body(input).value.get + def json2: String = bodyAsString2(input).value.get @Benchmark - def stringOption: Option[String] = bodyStringOption(input).value.get + def stringOption: Option[String] = stringBodyOption(input).value.get @Benchmark - def string: String = bodyString(input).value.get + def string: String = stringBody(input).value.get @Benchmark - def byteArrayOption: Option[Array[Byte]] = bodyByteArrayOption(input).value.get + def byteArrayOption: Option[Array[Byte]] = binaryBodyOption(input).value.get @Benchmark - def byteArray: Array[Byte] = bodyByteArray(input).value.get + def byteArray: Array[Byte] = binaryBody(input).value.get } diff --git a/core/src/main/scala/io/finch/Endpoint.scala b/core/src/main/scala/io/finch/Endpoint.scala index 91b65bcfb..33509c5f7 100644 --- a/core/src/main/scala/io/finch/Endpoint.scala +++ b/core/src/main/scala/io/finch/Endpoint.scala @@ -411,7 +411,7 @@ object Endpoint { * Creates an [[Endpoint]] that always matches and returns a given value (evaluated eagerly). */ def const[A](a: A): Endpoint[A] = new Endpoint[A] { - def apply(input: Input): Result[A] = Some(input -> rerunnable(Output.payload(a))) + def apply(input: Input): Result[A] = Some(input -> Rs.const(Output.payload(a))) } /** diff --git a/core/src/main/scala/io/finch/Endpoints.scala b/core/src/main/scala/io/finch/Endpoints.scala index 2a08dfc30..47044098b 100644 --- a/core/src/main/scala/io/finch/Endpoints.scala +++ b/core/src/main/scala/io/finch/Endpoints.scala @@ -8,8 +8,10 @@ import com.twitter.finagle.netty3.ChannelBufferBuf import com.twitter.io.Buf import com.twitter.util.{Base64StringEncoder, Future, Try} import io.catbird.util.Rerunnable +import io.finch.endpoint._ import io.finch.internal._ import java.util.UUID +import scala.reflect.ClassTag import shapeless._ /** @@ -17,10 +19,6 @@ import shapeless._ */ trait Endpoints { - private[this] val hnilFutureOutput: Rerunnable[Output[HNil]] = new Rerunnable[Output[HNil]] { - override val run = Future.value(Output.payload(HNil)) - } - type Endpoint0 = Endpoint[HNil] type Endpoint2[A, B] = Endpoint[A :: B :: HNil] type Endpoint3[A, B, C] = Endpoint[A :: B :: C :: HNil] @@ -31,11 +29,11 @@ trait Endpoints { private[finch] class Matcher(s: String) extends Endpoint[HNil] { def apply(input: Input): Endpoint.Result[HNil] = input.headOption.flatMap { - case `s` => Some(input.drop(1) -> hnilFutureOutput) + case `s` => Some(input.drop(1) -> Rs.OutputHNil) case _ => None } - override def toString = s + override def toString: String = s } implicit def stringToMatcher(s: String): Endpoint0 = new Matcher(s) @@ -179,7 +177,7 @@ trait Endpoints { */ object * extends Endpoint[HNil] { def apply(input: Input): Endpoint.Result[HNil] = - Some(input.copy(path = Nil) -> hnilFutureOutput) + Some(input.copy(path = Nil) -> Rs.OutputHNil) override def toString: String = "*" } @@ -189,7 +187,7 @@ trait Endpoints { */ object / extends Endpoint[HNil] { def apply(input: Input): Endpoint.Result[HNil] = - Some(input -> hnilFutureOutput) + Some(input -> Rs.OutputHNil) override def toString: String = "" } @@ -294,7 +292,7 @@ trait Endpoints { } private[this] val someEmptyBuf = Some(Buf.Empty) - private[this] def requestBody(req: Request): Option[Buf] = + private[this] final def requestBody(req: Request): Option[Buf] = req.contentLength match { case Some(0) => someEmptyBuf case Some(_) => Some(req.content) @@ -417,7 +415,7 @@ trait Endpoints { * An evaluating [[Endpoint]] that reads a binary request body, interpreted as a `Array[Byte]`, * into an `Option`. The returned [[Endpoint]] only matches non-chunked (non-streamed) requests. */ - val bodyByteArrayOption: Endpoint[Option[Array[Byte]]] = + val binaryBodyOption: Endpoint[Option[Array[Byte]]] = matches(items.BodyItem)(!_.isChunked)(requestBodyByteArray) /** @@ -425,28 +423,13 @@ trait Endpoints { * `Array[Byte]`, or throws a [[Error.NotPresent]] exception. The returned [[Endpoint]] only * matches non-chunked (non-streamed) requests. */ - val bodyByteArray: Endpoint[Array[Byte]] = bodyByteArrayOption.failIfNone - - /** - * An evaluating [[Endpoint]] that reads a binary request body, interpreted as a `Array[Byte]`, - * into an `Option`. The returned [[Endpoint]] only matches non-chunked (non-streamed) requests. - */ - @deprecated("Use bodyByteArrayOption instead.", "0.11") - val binaryBodyOption: Endpoint[Option[Array[Byte]]] = bodyByteArrayOption - - /** - * An evaluating [[Endpoint]] that reads a required binary request body, interpreted as an - * `Array[Byte]`, or throws a [[Error.NotPresent]] exception. The returned [[Endpoint]] only - * matches non-chunked (non-streamed) requests. - */ - @deprecated("Use bodyByteArray instead.", "0.11") - val binaryBody: Endpoint[Array[Byte]] = bodyByteArray + val binaryBody: Endpoint[Array[Byte]] = binaryBodyOption.failIfNone /** * An evaluating [[Endpoint]] that reads an optional request body, interpreted as a `String`, into * an `Option`. The returned [[Endpoint]] only matches non-chunked (non-streamed) requests. */ - val bodyStringOption: Endpoint[Option[String]] = + val stringBodyOption: Endpoint[Option[String]] = matches(items.BodyItem)(!_.isChunked)(requestBodyString) /** @@ -454,13 +437,13 @@ trait Endpoints { * throws an [[Error.NotPresent]] exception. The returned [[Endpoint]] only matches non-chunked * (non-streamed) requests. */ - val bodyString: Endpoint[String] = bodyStringOption.failIfNone - + val stringBody: Endpoint[String] = stringBodyOption.failIfNone /** * An [[Endpoint]] that reads an optional request body, interpreted as [[Buf]], into * an `Option`. The returned [[Endpoint]] only matches non-chunked (non-streamed) requests. */ + @deprecated("Use bodyOption[A, ContentType] instead", "0.11") val bodyOption: Endpoint[Option[Buf]] = matches(items.BodyItem)(!_.isChunked)(requestBody) @@ -469,8 +452,46 @@ trait Endpoints { * throws an [[Error.NotPresent]] exception. The returned [[Endpoint]] only matches non-chunked * (non-streamed) requests. */ + @deprecated("Use body[A, ContentType] instead", "0.11") val body: Endpoint[Buf] = bodyOption.failIfNone + /** + * An [[Endpoint]] that reads an optional request body represented as `CT` (`ContentType`) and + * interpreted as `A`, into an `Option`. The returned [[Endpoint]] only matches non-chunked + * (non-streamed) requests. + */ + def bodyOption[A, CT <: String](implicit + d: Decode.Aux[A, CT], ct: ClassTag[A]): Endpoint[Option[A]] = new OptionalBody[A, CT](d, ct) + + /** + * An [[Endpoint]] that reads the required request body represented as `CT` (`ContentType`) and + * interpreted as `A`, or throws an [[Error.NotPresent]] exception. The returned [[Endpoint]] + * only matches non-chunked (non-streamed) requests. + */ + def body[A, CT <: String](implicit + d: Decode.Aux[A, CT], ct: ClassTag[A]): Endpoint[A] = new RequiredBody[A, CT](d, ct) + + /** + * Alias for `body[A, Application.Json]`. + */ + def jsonBody[A: Decode.Json : ClassTag]: Endpoint[A] = body[A, Application.Json] + + /** + * Alias for `bodyOption[A, Application.Json]`. + */ + def jsonBodyOption[A: Decode.Json : ClassTag]: Endpoint[Option[A]] = + bodyOption[A, Application.Json] + + /** + * Alias for `body[A, Text.Plain]` + */ + def textBody[A: Decode.Text : ClassTag]: Endpoint[A] = body[A, Text.Plain] + + /** + * Alias for `bodyOption[A, Text.Plain]` + */ + def textBodyOption[A: Decode.Text : ClassTag]: Endpoint[Option[A]] = bodyOption[A, Text.Plain] + /** * An evaluating [[Endpoint]] that reads a required chunked streaming binary body, interpreted as * an `AsyncStream[Buf]`. The returned [[Endpoint]] only matches chunked (streamed) requests. diff --git a/core/src/main/scala/io/finch/Error.scala b/core/src/main/scala/io/finch/Error.scala index 75ff1659e..7b6a21cf6 100644 --- a/core/src/main/scala/io/finch/Error.scala +++ b/core/src/main/scala/io/finch/Error.scala @@ -53,8 +53,7 @@ object Error { } /** - * An exception that indicates a broken [[[io.finch.request.ValidationRule ValidationRule]] on the - * request item. + * An exception that indicates a broken [[ValidationRule]] on the request item. * * @param item the invalid request item * @param rule the rule description diff --git a/core/src/main/scala/io/finch/endpoint/Body.scala b/core/src/main/scala/io/finch/endpoint/Body.scala new file mode 100644 index 000000000..317ef61d7 --- /dev/null +++ b/core/src/main/scala/io/finch/endpoint/Body.scala @@ -0,0 +1,52 @@ +package io.finch.endpoint + +import com.twitter.util.{Future, Return, Throw} +import io.catbird.util.Rerunnable +import io.finch._ +import io.finch.internal._ +import io.finch.items._ +import scala.reflect.ClassTag + +private[finch] abstract class Body[A, B, CT <: String]( + d: Decode.Aux[A, CT], ct: ClassTag[A]) extends Endpoint[B] { + + protected def whenNotPresent: Rerunnable[Output[B]] + protected def prepare(a: A): B + + private[this] def decode(i: Input): Future[Output[B]] = + d(i.request.content, i.request.charsetOrUtf8) match { + case Return(r) => Future.value(Output.payload(prepare(r))) + case Throw(t) => Future.exception(Error.NotParsed(items.BodyItem, ct, t)) + } + + final def apply(input: Input): Endpoint.Result[B] = + if (input.request.isChunked) None + else { + val rr = input.request.contentLength match { + case None => whenNotPresent + case _ => Rerunnable.fromFuture(decode(input)) + } + + Some(input -> rr) + } + + override def item: RequestItem = items.BodyItem + override def toString: String = "body" +} + +private[finch] final class RequiredBody[A, CT <: String]( + d: Decode.Aux[A, CT], ct: ClassTag[A]) extends Body[A, A, CT](d, ct) { + + protected def prepare(a: A): A = a + protected def whenNotPresent: Rerunnable[Output[A]] = + Rs.BodyNotPresent.asInstanceOf[Rerunnable[Output[A]]] +} + +private[finch] final class OptionalBody[A, CT <: String]( + d: Decode.Aux[A, CT], ct: ClassTag[A]) extends Body[A, Option[A], CT](d, ct) { + + protected def prepare(a: A): Option[A] = Some(a) + protected def whenNotPresent: Rerunnable[Output[Option[A]]] = + Rs.OutputNone.asInstanceOf[Rerunnable[Output[Option[A]]]] +} + diff --git a/core/src/main/scala/io/finch/internal/Rs.scala b/core/src/main/scala/io/finch/internal/Rs.scala new file mode 100644 index 000000000..f9c67b8df --- /dev/null +++ b/core/src/main/scala/io/finch/internal/Rs.scala @@ -0,0 +1,33 @@ +package io.finch.internal + +import com.twitter.util.Future +import io.catbird.util.Rerunnable +import io.finch._ +import io.finch.items._ +import shapeless.HNil + +/** + * Predefined, Finch-specific instances of [[Rerunnable]]. + */ +private[finch] object Rs { + // See https://github.com/travisbrown/catbird/pull/32 + final def const[A](a: A): Rerunnable[A] = new Rerunnable[A] { + override def run: Future[A] = Future.value(a) + } + + final val OutputNone: Rerunnable[Output[Option[Nothing]]] = + new Rerunnable[Output[Option[Nothing]]] { + override val run: Future[Output[Option[Nothing]]] = + Future.value(Output.None) + } + + final val BodyNotPresent: Rerunnable[Nothing] = new Rerunnable[Nothing] { + override val run: Future[Nothing] = + Future.exception(Error.NotPresent(BodyItem)) + } + + final val OutputHNil: Rerunnable[Output[HNil]] = + new Rerunnable[Output[HNil]] { + override val run: Future[Output[HNil]] = Future.value(Output.payload(HNil)) + } +} diff --git a/core/src/main/scala/io/finch/internal/package.scala b/core/src/main/scala/io/finch/internal/package.scala index dca7ae29b..ce76eef39 100644 --- a/core/src/main/scala/io/finch/internal/package.scala +++ b/core/src/main/scala/io/finch/internal/package.scala @@ -16,11 +16,6 @@ import java.nio.charset.{Charset, StandardCharsets} */ package object internal { - // See https://github.com/travisbrown/catbird/pull/32 - def rerunnable[A](a: A): Rerunnable[A] = new Rerunnable[A] { - override def run: Future[A] = Future.value(a) - } - @inline private[this] final val someTrue: Option[Boolean] = Some(true) @inline private[this] final val someFalse: Option[Boolean] = Some(false) diff --git a/core/src/test/scala/io/finch/BodySpec.scala b/core/src/test/scala/io/finch/BodySpec.scala index 4c63b8b7d..9f1756b34 100644 --- a/core/src/test/scala/io/finch/BodySpec.scala +++ b/core/src/test/scala/io/finch/BodySpec.scala @@ -2,7 +2,10 @@ package io.finch import java.util.UUID +import com.twitter.finagle.http.Request import com.twitter.io.Buf +import com.twitter.util.{Return, Throw} +import io.finch.data.Foo class BodySpec extends FinchSpec { @@ -10,11 +13,39 @@ class BodySpec extends FinchSpec { def withBody(b: String): Input = Input.post("/").withBody[Text.Plain](Buf.Utf8(b)) - checkAll("Body[String]", EntityEndpointLaws[String](bodyStringOption)(withBody).evaluating) - checkAll("Body[Int]", EntityEndpointLaws[Int](bodyStringOption)(withBody).evaluating) - checkAll("Body[Long]", EntityEndpointLaws[Long](bodyStringOption)(withBody).evaluating) - checkAll("Body[Boolean]", EntityEndpointLaws[Boolean](bodyStringOption)(withBody).evaluating) - checkAll("Body[Float]", EntityEndpointLaws[Float](bodyStringOption)(withBody).evaluating) - checkAll("Body[Double]", EntityEndpointLaws[Double](bodyStringOption)(withBody).evaluating) - checkAll("Body[UUID]", EntityEndpointLaws[UUID](bodyStringOption)(withBody).evaluating) + checkAll("Body[String]", EntityEndpointLaws[String](stringBodyOption)(withBody).evaluating) + checkAll("Body[Int]", EntityEndpointLaws[Int](stringBodyOption)(withBody).evaluating) + checkAll("Body[Long]", EntityEndpointLaws[Long](stringBodyOption)(withBody).evaluating) + checkAll("Body[Boolean]", EntityEndpointLaws[Boolean](stringBodyOption)(withBody).evaluating) + checkAll("Body[Float]", EntityEndpointLaws[Float](stringBodyOption)(withBody).evaluating) + checkAll("Body[Double]", EntityEndpointLaws[Double](stringBodyOption)(withBody).evaluating) + checkAll("Body[UUID]", EntityEndpointLaws[UUID](stringBodyOption)(withBody).evaluating) + + it should "respond with NotFound when it's required" in { + body[Foo, Text.Plain].apply(Input.get("/")).tryValue === + Some(Throw(Error.NotPresent(items.BodyItem))) + } + + it should "respond with None when it's optional" in { + body[Foo, Text.Plain].apply(Input.get("/")).tryValue === Some(Return(None)) + } + + it should "not match on streaming requests" in { + val req = Request() + req.setChunked(true) + body[Foo, Text.Plain].apply(Input.request(req)).value === None + } + + it should "respond with a value when present and required" in { + check { f: Foo => + body[Foo, Text.Plain].apply(Input.post("/").withBody[Text.Plain](f)).value === Some(f) + } + } + + it should "respond with Some(value) when it's present and optional" in { + check { f: Foo => + bodyOption[Foo, Text.Plain].apply(Input.post("/").withBody[Text.Plain](f)).value === + Some(Some(f)) + } + } } diff --git a/core/src/test/scala/io/finch/EndpointSpec.scala b/core/src/test/scala/io/finch/EndpointSpec.scala index 6f1f268f5..553ab8cd5 100644 --- a/core/src/test/scala/io/finch/EndpointSpec.scala +++ b/core/src/test/scala/io/finch/EndpointSpec.scala @@ -270,7 +270,7 @@ class EndpointSpec extends FinchSpec { Seq( param("foo"), header("foo"), body, cookie("foo").map(_.value), fileUpload("foo").map(_.fileName), paramsNel("foo").map(_.toList.mkString), - paramsNel("foor").map(_.toList.mkString), bodyByteArray.map(new String(_)) + paramsNel("foor").map(_.toList.mkString), binaryBody.map(new String(_)) ).foreach { ii => ii(i).tryValue shouldBe Some(Throw(Error.NotPresent(ii.item))) } } diff --git a/core/src/test/scala/io/finch/data/Foo.scala b/core/src/test/scala/io/finch/data/Foo.scala index 8396655c7..6594a1982 100644 --- a/core/src/test/scala/io/finch/data/Foo.scala +++ b/core/src/test/scala/io/finch/data/Foo.scala @@ -2,14 +2,23 @@ package io.finch.data import cats.Show import com.twitter.util.Return -import io.finch.DecodeEntity +import io.finch.{Decode, DecodeEntity} +import io.finch.internal._ +import org.scalacheck.{Arbitrary, Gen} case class Foo(s: String) + object Foo { implicit val showFoo: Show[Foo] = Show.show(_.s) implicit val decodeFoo: DecodeEntity[Foo] = DecodeEntity.instance(s => Return(Foo(s))) + + implicit val decodeFooAsPlainText: Decode.Text[Foo] = + Decode.text((b, cs) => Return(Foo(BufText.extract(b, cs)))) + + implicit val arbitraryFoo: Arbitrary[Foo] = + Arbitrary(Gen.alphaStr.map(Foo.apply)) } diff --git a/examples/src/main/scala/io/finch/eval/Main.scala b/examples/src/main/scala/io/finch/eval/Main.scala index 10e4e74e2..c4f568e77 100644 --- a/examples/src/main/scala/io/finch/eval/Main.scala +++ b/examples/src/main/scala/io/finch/eval/Main.scala @@ -33,7 +33,7 @@ object Main { val execute: Eval = new Eval() - def eval: Endpoint[EvalOutput] = post("eval" :: body.as[EvalInput]) { i: EvalInput => + def eval: Endpoint[EvalOutput] = post("eval" :: jsonBody[EvalInput]) { i: EvalInput => Ok(EvalOutput(execute[Any](i.expression).toString)) } handle { case e: Exception => BadRequest(e) diff --git a/examples/src/main/scala/io/finch/oauth2/InMemoryDataHandler.scala b/examples/src/main/scala/io/finch/oauth2/InMemoryDataHandler.scala index 207a67130..1a882d6d3 100644 --- a/examples/src/main/scala/io/finch/oauth2/InMemoryDataHandler.scala +++ b/examples/src/main/scala/io/finch/oauth2/InMemoryDataHandler.scala @@ -41,7 +41,7 @@ object InMemoryDataHandler extends DataHandler[OAuthUser] { private[this] val authInfosByAccessToken = new ConcurrentHashMap[String, AuthInfo[OAuthUser]]().asScala private[this] def makeToken: AccessToken = { - new AccessToken( + AccessToken( token = s"AT-${UUID.randomUUID()}", refreshToken = Some(s"RT-${UUID.randomUUID()}"), scope = None, diff --git a/examples/src/main/scala/io/finch/todo/Main.scala b/examples/src/main/scala/io/finch/todo/Main.scala index 5b7a88819..a802fb364 100644 --- a/examples/src/main/scala/io/finch/todo/Main.scala +++ b/examples/src/main/scala/io/finch/todo/Main.scala @@ -37,8 +37,7 @@ object Main extends TwitterServer { val todos: Counter = statsReceiver.counter("todos") - def postedTodo: Endpoint[Todo] = - body.as[UUID => Todo].map(_(UUID.randomUUID())) + def postedTodo: Endpoint[Todo] = jsonBody[UUID => Todo].map(_(UUID.randomUUID())) def postTodo: Endpoint[Todo] = post("todos" :: postedTodo) { t: Todo => todos.incr() @@ -47,7 +46,7 @@ object Main extends TwitterServer { Created(t) } - def patchedTodo: Endpoint[Todo => Todo] = body.as[Todo => Todo] + def patchedTodo: Endpoint[Todo => Todo] = jsonBody[Todo => Todo] def patchTodo: Endpoint[Todo] = patch("todos" :: uuid :: patchedTodo) { (id: UUID, pt: Todo => Todo) => @@ -69,7 +68,7 @@ object Main extends TwitterServer { def deleteTodo: Endpoint[Todo] = delete("todos" :: uuid) { id: UUID => Todo.get(id) match { case Some(t) => Todo.delete(id); Ok(t) - case None => throw new TodoNotFound(id) + case None => throw TodoNotFound(id) } } diff --git a/examples/src/main/scala/io/finch/wrk/Finch.scala b/examples/src/main/scala/io/finch/wrk/Finch.scala index 6e7a4cf17..d5497811a 100644 --- a/examples/src/main/scala/io/finch/wrk/Finch.scala +++ b/examples/src/main/scala/io/finch/wrk/Finch.scala @@ -3,7 +3,6 @@ package io.finch.wrk import io.circe.generic.auto._ import io.finch._ import io.finch.circe._ -import shapeless.Witness /** * How to benchmark this: @@ -18,8 +17,5 @@ import shapeless.Witness * c = t * n * 1.5 */ object Finch extends App { - - val roundTrip: Endpoint[Payload] = post(body.as[Payload]) - - serve(roundTrip.toServiceAs[Witness.`"application/json"`.T]) + serve(post(jsonBody[Payload]).toServiceAs[Application.Json]) }