Skip to content

Commit

Permalink
Extractors now use Try
Browse files Browse the repository at this point in the history
  • Loading branch information
IainHull committed May 31, 2014
1 parent 87a3f70 commit 4906230
Show file tree
Hide file tree
Showing 4 changed files with 81 additions and 46 deletions.
96 changes: 59 additions & 37 deletions src/main/scala/org/iainhull/resttest/Dsl.scala
@@ -1,21 +1,23 @@
package org.iainhull.resttest

import java.net.URI
import scala.util.Success
import scala.util.Failure

/**
* Provides a DSL for simplifying REST system tests. This is meant to be used with ScalaTest or similar testing framework.
*
*
* For example to post a json document to a REST endpoint and check the statusCode:
* {{{
* val personJson = """{ "name": "fred" }"""
* POST url "http://api.rest.org/person" body personJson asserting (statusCode is Status.Created)
* }}}
*
* Or to get a json document from a REST endpoint and convert the json array to a List of Person objects:
*
* Or to get a json document from a REST endpoint and convert the json array to a List of Person objects:
* {{{
* val people = GET url "http://api.rest.org/person" returning (jsonBodyAsList[Person])
* }}}
*
*
* Finally a more complete example that using a ScalaTest Spec to verify a simple REST API.
* {{{
* class DslSpec extends FlatSpec with Dsl {
Expand All @@ -28,15 +30,15 @@ import java.net.URI
* DELETE / id asserting (statusCode is Status.OK)
* GET / id asserting (statusCode is Status.NotFound)
* GET asserting (statusCode is Status.OK, jsonBodyAsList[Person] is EmptyList)
* }
* }
* }
* }
* }}}
*
* }}}
*
* == Configuring a Request ==
*
*
* The DSL centers around the [[Api.RequestBuilder]], which specifies the properties
* of the request. Most expressions begin with the HTTP [[Api.Method]] followed by a
* of the request. Most expressions begin with the HTTP [[Api.Method]] followed by a
* call to [[RichRequestBuilder]], this converts the `Method` to a [[Api.RequestBuilder]].
* The resulting `RequestBuilder` contains both the `Method` and secondary property.
* For example:
Expand All @@ -47,7 +49,7 @@ import java.net.URI
* {{{
* RequestBuilder().withMethod(GET).withUrl("http://api.rest.org/person")
* }}}
*
*
* The `RequestBuilder` DSL also supports default values passed implicitly into expressions,
* for example:
* {{{
Expand All @@ -56,35 +58,35 @@ import java.net.URI
* }}}
* creates a `RequestBuilder` with a method, url and accept header set. The default values
* are normal expressed the with the [[using]] expression.
*
*
* == Executing a Request ==
*
*
* There are three ways to execute a request: [[RichRequestBuilder]]`.execute`, [[RichResponse]]`.returning`,
* [[RichRequestBuilder]]`.asserting`, these can all be applied to `RequestBuilder` instances.
*
*
* The `execute` method executes the request with the implicit [[Api.HttpClient]] and returns the `Response`.
* {{{
* val response: Response = GET url "http://api.rest.org/person" execute ()
* }}}
*
*
* The `returning` method executes the request like the `execute` method, except it applies one or more
* [[Extractor]]s to the `Response` to return only the extracted information.
* [[Extractor]]s to the `Response` to return only the extracted information.
* {{{
* val code1 = GET url "http://api.rest.org/person" returning (StatusCode)
* val (code2, people) = GET url "http://api.rest.org/person" returning (StatusCode, jsonBodyAsList[Person])
* }}}
*
*
* The `asserting` method executes the request like the `execute` method, except it verifies the specified
* value of one or more `Response` values. `asserting` is normally used with extractors, see [RichExtractor]
* value of one or more `Response` values. `asserting` is normally used with extractors, see [RichExtractor]
* for more information. `asserting` and `returning` methods can be used in the same expression.
* {{{
* GET url "http://api.rest.org/person" asserting (statusCode is Status.OK)
* val people = GET url "http://api.rest.org/person" asserting (statusCode is Status.OK) returning (jsonBodyAsList[Person])
* }}}
*
*
*
*
* == Working with Extractors ==
*
*
* Extractors are simply functions that take a [[Api.Response]] are extract or convert part of its contents.
* Extracts are written to assume that the data they require is in the response, if it is not they throw an
* Exception (failing the test). See [[Extractors]] for more information on the available default `Extractor`s
Expand All @@ -100,7 +102,7 @@ trait Dsl extends Api with Extractors {
trait Assertion {
def result(res: Response): Option[String]
}

def assertionFailed(assertionResults: Seq[String]): Throwable = {
new AssertionError(assertionResults.mkString(","))
}
Expand All @@ -126,7 +128,7 @@ trait Dsl extends Api with Extractors {
a <- assertions
r <- a.result(res)
} yield r
if(assertionResults.nonEmpty) {
if (assertionResults.nonEmpty) {
throw assertionFailed(assertionResults)
}
res
Expand All @@ -140,16 +142,16 @@ trait Dsl extends Api with Extractors {

/**
* Extend the default request's configuration so that partially configured requests to be reused. Foe example:
*
*
* {{{
* using(_ url "http://api.rest.org/person") { implicit rb =>
* GET asserting (StatusCode === Status.OK, jsonBodyAsList[Person] === EmptyList)
* val id = POST body personJson asserting (StatusCode === Status.Created) returning (Header("X-Person-Id"))
* GET / id asserting (StatusCode === Status.OK, jsonBodyAs[Person] === Jason)
* }
* }}}
*
* @param config
*
* @param config
* a function to configure the default request
* @param block
* the block of code where the the newly configured request is applied
Expand All @@ -162,19 +164,34 @@ trait Dsl extends Api with Extractors {

implicit class RichResponse(response: Response) {
def returning[T1](ext1: ExtractorLike[T1])(implicit client: HttpClient): T1 = {
ext1.value(response)
ext1.value(response).get
}

def returning[T1, T2](ext1: ExtractorLike[T1], ext2: ExtractorLike[T2]): (T1, T2) = {
(ext1.value(response), ext2.value(response))
val tryValue = for {
r1 <- ext1.value(response)
r2 <- ext2.value(response)
} yield (r1, r2)
tryValue.get
}

def returning[T1, T2, T3](ext1: ExtractorLike[T1], ext2: ExtractorLike[T2], ext3: ExtractorLike[T3]): (T1, T2, T3) = {
(ext1.value(response), ext2.value(response), ext3.value(response))
val tryValue = for {
r1 <- ext1.value(response)
r2 <- ext2.value(response)
r3 <- ext3.value(response)
} yield (r1, r2, r3)
tryValue.get
}

def returning[T1, T2, T3, T4](ext1: ExtractorLike[T1], ext2: ExtractorLike[T2], ext3: ExtractorLike[T3], ext4: ExtractorLike[T4]): (T1, T2, T3, T4) = {
(ext1.value(response), ext2.value(response), ext3.value(response), ext4.value(response))
val tryValue = for {
r1 <- ext1.value(response)
r2 <- ext2.value(response)
r3 <- ext3.value(response)
r4 <- ext4.value(response)
} yield (r1, r2, r3, r4)
tryValue.get
}
}

Expand All @@ -183,11 +200,11 @@ trait Dsl extends Api with Extractors {

/**
* Add operator support to `Extractor`s these are used to generate an `Assertion` using the extracted value.
*
*
* {{{
* GET url "http://api.rest.org/person" assert (StatusCode === Status.Ok)
* }}}
*
*
* == Operations ==
*
* The following operations are added to all `Extractors`
Expand All @@ -210,18 +227,23 @@ trait Dsl extends Api with Extractors {
def ===[B >: A](expected: B): Assertion = makeAssertion(_ == expected, expected, "did not equal")
def !==[B >: A](expected: B): Assertion = makeAssertion(_ != expected, expected, "did equal")

def < [B >: A](expected: B)(implicit ord: math.Ordering[B]): Assertion = makeAssertion(ord.lt (_, expected), expected, "was not less than")
def <[B >: A](expected: B)(implicit ord: math.Ordering[B]): Assertion = makeAssertion(ord.lt(_, expected), expected, "was not less than")
def <=[B >: A](expected: B)(implicit ord: math.Ordering[B]): Assertion = makeAssertion(ord.lteq(_, expected), expected, "was not less than or equal")
def > [B >: A](expected: B)(implicit ord: math.Ordering[B]): Assertion = makeAssertion(ord.gt( _, expected), expected, "was not greater than")
def >=[B >: A](expected: B)(implicit ord: math.Ordering[B]): Assertion = makeAssertion(ord.gteq(_, expected), expected, "was not greater than or equal")
def >[B >: A](expected: B)(implicit ord: math.Ordering[B]): Assertion = makeAssertion(ord.gt(_, expected), expected, "was not greater than")
def >=[B >: A](expected: B)(implicit ord: math.Ordering[B]): Assertion = makeAssertion(ord.gteq(_, expected), expected, "was not greater than or equal")

def in[B >: A](expectedVals: B*): Assertion = makeAssertion(expectedVals.contains(_), expectedVals.mkString("(", ", ", ")"), "was not in")
def notIn[B >: A](expectedVals: B*): Assertion = makeAssertion(!expectedVals.contains(_), expectedVals.mkString("(", ", ", ")"), "was in")

private def makeAssertion[B](pred: A => Boolean, expected: Any, text: String) = new Assertion {
override def result(res: Response): Option[String] = {
val actual = ext.value(res)
if (!pred(actual)) Some(s"${ext.name}: $actual $text $expected") else None
actual match {
case Success(a) =>
if (!pred(a)) Some(s"${ext.name}: a $text $expected") else None
case Failure(e) =>
Some(e.getMessage)
}
}
}

Expand Down
22 changes: 17 additions & 5 deletions src/main/scala/org/iainhull/resttest/Extractors.scala
@@ -1,8 +1,8 @@
package org.iainhull.resttest

import Api._

import scala.util.Try
import scala.util.Failure

trait Extractors {
import language.implicitConversions
Expand Down Expand Up @@ -30,8 +30,8 @@ object Extractors {
*/
trait ExtractorLike[+A] {
def name: String
def unapply(res: Response): Option[A] = Try { value(res) }.toOption
def value(implicit res: Response): A
def unapply(res: Response): Option[A] = value(res).toOption
def value(implicit res: Response): Try[A]
}

/**
Expand All @@ -43,7 +43,14 @@ object Extractors {
* The operation to extract the value of type `A` from the `Response`
*/
case class Extractor[+A](name: String, op: Response => A) extends ExtractorLike[A] {
def value(implicit res: Response): A = op(res)
override def value(implicit res: Response): Try[A] = {
Try { op(res) } recoverWith {
case e =>
Failure[A](new ExtractorFailedException(
"Cannot extract $name from Response: ${e.getMessage}",
e))
}
}

/**
* Create a new `Extractor` by executing a new function to modify the result.
Expand All @@ -60,6 +67,11 @@ object Extractors {
*/
def as(newName: String) = copy(name = newName)
}

class ExtractorFailedException(message: String, cause: Throwable) extends Exception(message, cause) {
def this(message: String) = this(message, null)

}

val StatusCode = Extractor[Int]("StatusCode", r => r.statusCode)

Expand Down Expand Up @@ -105,7 +117,7 @@ object Extractors {

override def name: String = asText.name
override def unapply(res: Response): Option[String] = asText.unapply(res)
override def value(implicit res: Response): String = asText.value
override def value(implicit res: Response): Try[String] = asText.value
}

/**
Expand Down
7 changes: 4 additions & 3 deletions src/main/scala/org/iainhull/resttest/RestMatchers.scala
Expand Up @@ -63,7 +63,7 @@ trait RestMatchers {

implicit def extractorToShouldWrapper[T](extractor: ExtractorLike[T])(implicit response: Response): AnyShouldWrapper[T] = {
Assertions.withClue(extractor.name) {
val v: T = extractor.value
val v: T = extractor.value.get
new AnyShouldWrapper[T](v)
}
}
Expand Down Expand Up @@ -105,11 +105,12 @@ trait RestMatchers {
implicit class OptionExtractorToHavePropertyMatcher(extractor: ExtractorLike[Option[_]]) extends HavePropertyMatcher[Response, String] {
def apply(response: Response) = {
val actual = extractor.value(response)
val isDefined = actual.map(a => a.isDefined).getOrElse(false)
new HavePropertyMatchResult(
actual.isDefined,
isDefined,
extractor.name,
"defined",
(if (actual.isDefined) "" else "not") + " defined")
(if (isDefined) "" else "not") + " defined")
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/test/scala/org/iainhull/resttest/ExtractorsSpec.scala
Expand Up @@ -11,7 +11,7 @@ class ExtractorsSpec extends FlatSpec with Matchers {

val response = Response(Status.OK, toHeaders("SimpleHeader" -> "SimpleValue", "MultiHeader" -> "Value1", "MultiHeader" -> "Value2"), Some("body"))

def returning[T](ext: ExtractorLike[T]): T = ext.value(response)
def returning[T](ext: ExtractorLike[T]): T = ext.value(response).get

"statusCode" should "return the responses statusCode" in {
returning(StatusCode) should be(Status.OK)
Expand Down

0 comments on commit 4906230

Please sign in to comment.