diff --git a/README.md b/README.md index 5f587db83..e031faca0 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Every Finch module is published at Maven Central. Use the following _sbt_ snippe ```scala libraryDependencies ++= Seq( - "com.github.finagle" %% "[finch-module]" % "0.11.0-M2" + "com.github.finagle" %% "[finch-module]" % "0.11.0-M3" ) ``` @@ -51,7 +51,7 @@ libraryDependencies ++= Seq( Hello World! ------------ -This "Hello World!" example is built with the `0.11.0-M2` version of `finch-core`. +This "Hello World!" example is built with the `0.11.0-M3` version of `finch-core`. ```scala import io.finch._ diff --git a/benchmarks/src/main/scala/io/finch/benchmarks/EndpointBenchmark.scala b/benchmarks/src/main/scala/io/finch/benchmarks/EndpointBenchmark.scala index 284b417d4..110d89cd5 100644 --- a/benchmarks/src/main/scala/io/finch/benchmarks/EndpointBenchmark.scala +++ b/benchmarks/src/main/scala/io/finch/benchmarks/EndpointBenchmark.scala @@ -31,10 +31,10 @@ class SuccessfulEndpointBenchmark extends FinchBenchmark with FooEndpointsAndReq @State(Scope.Benchmark) class FailingEndpointBenchmark extends FinchBenchmark with FooEndpointsAndRequests { @Benchmark - def hlistGenericEndpoint: Try[Foo] = hlistGenericFooReader(badFooRequest).poll.get + def hlistGenericEndpoint: Try[Foo] = hlistGenericFooReader(badFooRequest).tryValue.get @Benchmark - def derivedEndpoint: Try[Foo] = derivedFooReader(badFooRequest).poll.get + def derivedEndpoint: Try[Foo] = derivedFooReader(badFooRequest).tryValue.get } /** diff --git a/build.sbt b/build.sbt index dfaac4f25..8b25232cb 100644 --- a/build.sbt +++ b/build.sbt @@ -3,7 +3,7 @@ import ScoverageSbtPlugin._ lazy val buildSettings = Seq( organization := "com.github.finagle", - version := "0.11.0-M2", + version := "0.11.0-M3", scalaVersion := "2.11.8", crossScalaVersions := Seq("2.10.6", "2.11.8") ) diff --git a/core/src/main/scala/io/finch/Output.scala b/core/src/main/scala/io/finch/Output.scala index 51e47ad60..32257524d 100644 --- a/core/src/main/scala/io/finch/Output.scala +++ b/core/src/main/scala/io/finch/Output.scala @@ -3,7 +3,7 @@ package io.finch import cats.Eq import com.twitter.finagle.http.{Cookie, Response, Status, Version} import com.twitter.io.Charsets -import com.twitter.util.{Await, Future, Try} +import com.twitter.util.{Await, Duration, Future, Try} import io.finch.internal.ToResponse import java.nio.charset.Charset @@ -167,10 +167,66 @@ object Output { case (_, _) => false } + /** + * Exposes an API for testing [[Endpoint]]s. + */ implicit class EndpointResultOps[A](val o: Endpoint.Result[A]) extends AnyVal { - def poll: Option[Try[A]] = o.flatMap(_._2.run.poll.map(_.map(_.value))) - def output: Option[Output[A]] = o.map({ case (_, oa) => Await.result(oa.run) }) + + /** + * Queries an [[Output]] wrapped with [[Try]] (indicating if the [[Future]] is failed). + * + * @note This method is blocking and awaits on the underlying [[Future]] with the upper + * bound of 10 seconds. + * + * @return `Some(output)` if this endpoint was matched on a given input, + * `None` otherwise. + */ + def tryOutput: Option[Try[Output[A]]] = + o.map({ case (_, oa) => Await.result(oa.liftToTry.run, Duration.fromSeconds(10)) }) + + /** + * Queries a value from the [[Output]] wrapped with [[Try]] (indicating if either the + * [[Future]] is failed or [[Output]] wasn't a payload). + * + * @note This method is blocking and awaits on the underlying [[Future]] with the upper + * bound of 10 seconds. + * + * @return `Some(value)` if this endpoint was matched on a given input, + * `None` otherwise. + */ + def tryValue: Option[Try[A]] = + tryOutput.map(toa => toa.flatMap(oa => Try(oa.value))) + + /** + * Queries an [[Output]] of the [[Endpoint]] result or throws an exception if an underlying + * [[Future]] is failed. + * + * @note This method is blocking and awaits on the underlying [[Future]] with the upper + * bound of 10 seconds. + * + * @return `Some(output)` if this endpoint was matched on a given input, + * `None` otherwise. + */ + def output: Option[Output[A]] = tryOutput.map(tao => tao.get) + + /** + * Queries the value from the [[Output]] or throws an exception if either an underlying + * [[Future]] is failed or [[Output]] wasn't a payload. + * + * @note This method is blocking and awaits on the underlying [[Future]] with the upper + * bound of 10 seconds. + * + * @return `Some(value)` if this endpoint was matched on a given input, + * `None` otherwise. + */ def value: Option[A] = output.map(oa => oa.value) + + /** + * Returns the remainder of the [[Input]] after an [[Endpoint]] is matched. + * + * @return `Some(remainder)` if this endpoint was matched on a given input, + * `None` otherwise. + */ def remainder: Option[Input] = o.map(_._1) } diff --git a/core/src/test/scala/io/finch/EndpointSpec.scala b/core/src/test/scala/io/finch/EndpointSpec.scala index 86bc9c5ce..a1072f7a6 100644 --- a/core/src/test/scala/io/finch/EndpointSpec.scala +++ b/core/src/test/scala/io/finch/EndpointSpec.scala @@ -236,7 +236,7 @@ class EndpointSpec extends FinchSpec { param("foo"), header("foo"), body, cookie("foo").map(_.value), fileUpload("foo").map(_.fileName), paramsNonEmpty("foo").map(_.mkString), paramsNel("foor").map(_.toList.mkString), binaryBody.map(new String(_)) - ).foreach { ii => ii(i).poll shouldBe Some(Throw(Error.NotPresent(ii.item))) } + ).foreach { ii => ii(i).tryValue shouldBe Some(Throw(Error.NotPresent(ii.item))) } } it should "maps lazily to values" in { diff --git a/docs/best-practices.md b/docs/best-practices.md index 649e2d654..5c5a13784 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -7,7 +7,7 @@ * [Monitor your application](best-practices.md#monitor-your-application) * [Picking HTTP statuses for responses](best-practices.md#picking-http-statuses-for-responses) * [Configuring Finagle](best-practices.md#configuring-finagle) -* [Finagle Filters vs. Finch Endpoints](best-practices.md#finagle-filters-vs-finc-endpoints) +* [Finagle Filters vs. Finch Endpoints](best-practices.md#finagle-filters-vs-finch-endpoints) * [Pulling it all Together](best-practices.md#pulling-it-all-together) -- diff --git a/docs/endpoint.md b/docs/endpoint.md index ca14e9fa3..cd0250fcb 100644 --- a/docs/endpoint.md +++ b/docs/endpoint.md @@ -19,6 +19,7 @@ * [Validation](endpoint.md#validation) * [Errors](endpoint.md#errors) * [Error Handling](endpoint.md#error-handling) +* [Testing Endpoints](endpoint.md#testing-endpoints) -- @@ -606,6 +607,68 @@ implicit val e: Encode.Text[Exception] = Encode.text((exception, charset) => ) ``` +### Testing Endpoints + +One of the advantages of typeful endpoints in Finch is that they can be unit-tested independently in +a way similar to how functions are tested. The machinery is pretty straightforward: an endpoint +takes an `Input` and returns `Output` (wrapping a payload). + +There is a lightweight API around `Input`s to make them easy to build. For example, the following +call builds a `GET /foo?a=1&b=2` request: + +```scala +import io.finch._ + +val foo: Input = Input.get("/foo", "a" -> "2", "b" -> "3") +``` + +Similarly a payload (`application/x-www-form-urlencoded` in this case) with headers may be added +to an input: + +```scala +import io.finch._ +val bar: Input = Input.post("/bar") + .withForm("a" -> "1", "b" -> "2") + .withHeaders("X-Header" -> "Y") +``` + +At this point, there is no JSON-specific support encoded in the `Input` API (but will be in 0.11) +so a generic `withBody` method may be used instead. + +```scala +import io.circe._ +import io.circe.syntax._ +import io.finch._ +import com.twitter.io.Buf + +val baz: Input = Input.put("/baz") + .withBody(Buf.Utf8(Map("a" -> "b").asJson.toString), Some("application/json;charset=utf8")) +``` + +Similarly when an `Output` is returned form the `Endpoint` it might be queried with a number of +methods: `tryValue`, `tryOutput`, `output`, and `value`. Please, note that all of those are blocking +on the underlying `Future` with the upper bound of 10 seconds. + +In general, it's recommended to use `try*` variants (since they don't throw exceptions), but for the +sake of simplicity, in this user guide `value` and `output` serve sufficiently. + +``` +scala> val divOrFail: Endpoint[Int] = post(int :: int) { (a: Int, b: Int) => + | if (b == 0) BadRequest(new Exception("div by 0")) + | else Ok(a / b) + | } +divOrFail: io.finch.Endpoint[Int] = POST /:int/:int + +scala> divOrFail(Input.post("/20/10")).value == Some(2) +res3: Boolean = true + +scala> divOrFail(Input.get("/20/10")).value == None +res4: Boolean = true + +scala> divOrFail(Input.post("/20/0")).output.map(_.status) == Some(Status.BadRequest) +res6: Boolean = true +``` + -- Read Next: [Authentication](auth.md) diff --git a/docs/index.md b/docs/index.md index 3676d7c76..84005feda 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,8 +16,8 @@ build.sbt: ```scala libraryDependencies ++= Seq( - "com.github.finagle" %% "finch-core" % "0.11.0-M2", - "com.github.finagle" %% "finch-circe" % "0.11.0-M2", + "com.github.finagle" %% "finch-core" % "0.11.0-M3", + "com.github.finagle" %% "finch-circe" % "0.11.0-M3", "io.circe" %% "circe-generic" % "0.5.0-M2" ) ``` diff --git a/examples/src/main/scala/io/finch/oauth2/Main.scala b/examples/src/main/scala/io/finch/oauth2/Main.scala index 7890797cd..1d302655f 100644 --- a/examples/src/main/scala/io/finch/oauth2/Main.scala +++ b/examples/src/main/scala/io/finch/oauth2/Main.scala @@ -43,7 +43,8 @@ object Main extends App { ai: AuthInfo[OAuthUser] => Ok(ai.user) } - val tokens: Endpoint[GrantHandlerResult] = post("users" :: "auth" :: issueAccessToken(InMemoryDataHandler)) + val tokens: Endpoint[GrantHandlerResult] = + post("users" :: "auth" :: issueAccessToken(InMemoryDataHandler)) val unprotected: Endpoint[UnprotectedUser] = get("users" :: "unprotected") { Ok(UnprotectedUser("unprotected"))