├── project ├── build.properties ├── plugins.sbt └── dependencies.scala ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── src ├── main │ └── scala │ │ └── org │ │ └── iainhull │ │ └── resttest │ │ ├── TestDriver.scala │ │ ├── driver │ │ ├── Spray.scala │ │ └── Jersey.scala │ │ ├── JsonExtractors.scala │ │ ├── RestMatchers.scala │ │ ├── Extractors.scala │ │ ├── Api.scala │ │ └── Dsl.scala └── test │ └── scala │ └── org │ └── iainhull │ └── resttest │ ├── JsonExtractorsSpec.scala │ ├── ExtractorsSpec.scala │ ├── TestData.scala │ ├── ApiSpec.scala │ ├── RestMatchersSpec.scala │ └── DslSpec.scala ├── gradlew.bat ├── README.md ├── gradlew └── LICENSE.txt /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.2 -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IainHull/resttest/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .classpath 3 | .gradle 4 | .project 5 | .scala_dependencies 6 | .settings 7 | .worksheet 8 | bin/ 9 | build/ 10 | target/ 11 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "2.5.0") 2 | 3 | addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.7.4") -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri May 23 17:04:05 IST 2014 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=http\://services.gradle.org/distributions/gradle-1.11-all.zip 7 | -------------------------------------------------------------------------------- /project/dependencies.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | object Dependencies { 4 | val jerseyVersion = "1.17.+" 5 | val scalatestVersion = "2.1.6" 6 | val sprayVersion = "1.3.1-20140423" 7 | val playVersion = "2.3.0-RC2" 8 | 9 | val jersey = "com.sun.jersey" % "jersey-core" % jerseyVersion 10 | val jerseyClient = "com.sun.jersey" % "jersey-client" % jerseyVersion 11 | val scalatest = "org.scalatest" %% "scalatest" % scalatestVersion 12 | val playJson = "com.typesafe.play" %% "play-json" % playVersion 13 | 14 | val sprayCan = "io.spray" %% "spray-can" % sprayVersion 15 | val sprayRouting = "io.spray" %% "spray-routing" % sprayVersion 16 | val sprayTestkit = "io.spray" %% "spray-testkit" % sprayVersion 17 | 18 | val resttestDependencies = Seq(jersey, jerseyClient, scalatest, playJson, sprayCan, sprayRouting, sprayTestkit) 19 | } 20 | -------------------------------------------------------------------------------- /src/main/scala/org/iainhull/resttest/TestDriver.scala: -------------------------------------------------------------------------------- 1 | package org.iainhull.resttest 2 | 3 | /** 4 | * The test driver defines the [[Api.HttpClient]] and initial [[Api.RequestBuilder]] 5 | * used to execute Rest Tests. 6 | * 7 | * Users mix-in their preferred TestDriver implementation, to execute their tests. 8 | * 9 | * {{{ 10 | * class PersonSpec extends FlatSpec { 11 | * this: TestDriver => 12 | * 13 | * val EmptyList = Seq[Person]() 14 | * 15 | * "/person (collection)" should "be empty" in { 16 | * GET / "person" asserting (StatusCode === Status.OK, BodyAsPersonList === EmptyList) 17 | * } 18 | * } 19 | * 20 | * class PersonUnitSpec extends PersonSpec with SprayUnitTestDriver with MyService 21 | * 22 | * class PersonSystemSpec extends PersonSpec with JerseySystemTestDriver { 23 | * override val baseUrl = "http://localhost:9000" 24 | * } 25 | * }}} 26 | * 27 | * Subclasses implement the interface supplying a httpClient to execute the 28 | * Requests and a defBuilder which provides the base configuration for all 29 | * tests. All tests should support relative paths, this enables the same test 30 | * code to be executed as a unit test and a system test. To support this 31 | * the defBuilder for system test drivers should supply the baseUrl some how. 32 | */ 33 | trait TestDriver { 34 | import Api._ 35 | 36 | /** 37 | * The httpClient to execute Requests 38 | */ 39 | implicit def httpClient: HttpClient 40 | 41 | /** 42 | * The default RequestBuilder, common to all tests 43 | */ 44 | implicit def defBuilder: RequestBuilder 45 | } 46 | -------------------------------------------------------------------------------- /src/main/scala/org/iainhull/resttest/driver/Spray.scala: -------------------------------------------------------------------------------- 1 | package org.iainhull.resttest.driver 2 | 3 | import spray.testkit.ScalatestRouteTest 4 | import org.iainhull.resttest.TestDriver 5 | import org.scalatest.Suite 6 | import scala.collection.JavaConversions 7 | import spray.http.HttpHeaders.RawHeader 8 | import spray.http.MediaType 9 | import spray.http.HttpEntity 10 | import spray.http.ContentType 11 | import spray.routing.Route 12 | import org.iainhull.resttest.Api 13 | 14 | trait SprayUnitTestDriver extends TestDriver with ScalatestRouteTest { 15 | this: Suite => 16 | 17 | import Api._ 18 | 19 | override implicit val httpClient: HttpClient = { req => 20 | import JavaConversions._ 21 | 22 | val rb1 = req.method match { 23 | case GET => Get(req.url.getPath()) 24 | case POST => Post(req.url.getPath()) 25 | case PUT => Put(req.url.getPath()) 26 | case DELETE => Delete(req.url.getPath()) 27 | case HEAD => Head(req.url.getPath()) 28 | case PATCH => Patch(req.url.getPath()) 29 | } 30 | 31 | val headers = req.headers.map { case (name, list) => RawHeader(name, list.mkString(", ")) }.toList 32 | val contentType = headers.find(_.lowercaseName == "content-type").map(_.value).getOrElse("text/plain") 33 | val mediaType = MediaType.custom(contentType) 34 | val rb2 = rb1.withHeadersAndEntity(headers, req.body.map(HttpEntity(ContentType(mediaType),_)).getOrElse(HttpEntity.Empty)) 35 | 36 | rb2 ~> myRoute ~> check { 37 | val responseHeaders = response.headers.map( h => h.name -> h.value.split(", ").toList).toSeq 38 | Response(status.intValue, Map() ++ responseHeaders, Some(responseAs[String])) 39 | } 40 | } 41 | 42 | override implicit def defBuilder: Api.RequestBuilder = Api.RequestBuilder.emptyBuilder withUrl ("") 43 | 44 | def actorRefFactory = system 45 | def myRoute: Route 46 | } -------------------------------------------------------------------------------- /src/test/scala/org/iainhull/resttest/JsonExtractorsSpec.scala: -------------------------------------------------------------------------------- 1 | package org.iainhull.resttest 2 | 3 | import org.scalatest.FlatSpec 4 | import org.scalatest.Matchers 5 | import play.api.libs.json._ 6 | import play.api.libs.functional.syntax._ 7 | 8 | class JsonExtractorsSpec extends FlatSpec with Matchers { 9 | import Dsl._ 10 | import JsonExtractors._ 11 | import TestData._ 12 | 13 | "jsonToList" should "deserialise to scala types" in { 14 | jsonToList[String](jsonList, __ \\ "name") should be(List("toto", "tata")) 15 | jsonToList[String](jsonDoc, __ \ "user" \ "favorite" \ "colors") should be(List("red", "green")) 16 | 17 | } 18 | 19 | it should "deserialise to custom types" in { 20 | jsonToList[Person](jsonList, __) should be(List(Toto, Tata)) 21 | jsonToList[Person](jsonDoc, __ \ "users") should be(List(Toto)) 22 | } 23 | 24 | "jsonToValue" should "deserialise to scala types" in { 25 | jsonToValue[String](jsonDoc, __ \ "user" \ "name") should be("toto") 26 | jsonToValue[String](jsonDoc, __ \ "user" \ "favorite" \ "colors" apply (0)) should be("red") 27 | 28 | } 29 | 30 | it should "deserialise to custom types" in { 31 | jsonToValue[Person](jsonList, __ apply (0)) should be(Toto) 32 | jsonToValue[Person](jsonDoc, __ \ "user") should be(Toto) 33 | } 34 | 35 | def evaluate[T](ext: Extractor[T], json: JsValue): T = ext.op(Response(Status.OK, Map(), Some(Json.stringify(json)))) 36 | 37 | 38 | "jsonBodyAsList" should "deserialise to scala types" in { 39 | evaluate(jsonBodyAsList[Person], jsonList) should be(List(Toto, Tata)) 40 | evaluate(jsonBodyAsList[Int](__ \\ "age"), jsonList) should be(List(25, 20)) 41 | } 42 | 43 | it should "include the type in its name" in { 44 | jsonBodyAsList[Person].name should be ("jsonBodyAsList[Person]") 45 | } 46 | 47 | 48 | "jsonBodyAs" should "deserialise to scala types" in { 49 | evaluate(jsonBodyAs[Person], Json parse personJson) should be(Jason) 50 | evaluate(jsonBodyAs[String](__ \ "user" \ "name"), jsonDoc) should be("toto") 51 | } 52 | 53 | it should "include the type in its name" in { 54 | jsonBodyAs[Person].name should be ("jsonBodyAs[Person]") 55 | } 56 | } -------------------------------------------------------------------------------- /src/main/scala/org/iainhull/resttest/JsonExtractors.scala: -------------------------------------------------------------------------------- 1 | package org.iainhull.resttest 2 | 3 | import play.api.libs.json.JsValue 4 | import play.api.libs.json.JsPath 5 | import play.api.libs.json.Reads 6 | import play.api.libs.json.JsArray 7 | import play.api.libs.json.Json 8 | 9 | trait JsonExtractors { 10 | import Extractors._ 11 | 12 | /** 13 | * Extract a path from a json document and deserialise it to a List 14 | */ 15 | def jsonToList[T: Reads](json: JsValue, path: JsPath = JsPath): Seq[T] = { 16 | path(json) match { 17 | case Seq(array: JsArray) => array.as[List[T]](Reads.list[T]) 18 | case seq: Seq[JsValue] => seq map (_.as[T]) 19 | } 20 | } 21 | 22 | /** 23 | * Extract a path from a json document and deserialise it to a value 24 | */ 25 | def jsonToValue[T: Reads](json: JsValue, path: JsPath = JsPath): T = { 26 | path.asSingleJson(json).as[T] 27 | } 28 | 29 | /** 30 | * Extract the response body as a json document 31 | */ 32 | val JsonBody = BodyText andThen Json.parse as "JsonBody" 33 | 34 | /** 35 | * Extract the response body as an object. 36 | */ 37 | def jsonBodyAs[T: Reads : reflect.ClassTag]: Extractor[T] = jsonBodyAs(JsPath) 38 | 39 | /** 40 | * Extract a portion of the response body as an object. 41 | * 42 | * @param path the path for the portion of the response to use 43 | */ 44 | def jsonBodyAs[T: Reads : reflect.ClassTag](path: JsPath) = { 45 | val tag = implicitly[reflect.ClassTag[T]] 46 | JsonBody andThen (jsonToValue(_, path)) as (s"jsonBodyAs[${tag.runtimeClass.getSimpleName}]") 47 | } 48 | 49 | /** 50 | * Extract the response body as a List of objects. 51 | */ 52 | def jsonBodyAsList[T: Reads](implicit tag : reflect.ClassTag[T]): Extractor[Seq[T]] = jsonBodyAsList(JsPath) 53 | 54 | /** 55 | * Extract a portion of the response body as a List of objects. 56 | * 57 | * @param path the path for the portion of the response to use 58 | */ 59 | def jsonBodyAsList[T: Reads : reflect.ClassTag](path: JsPath) = { 60 | val tag = implicitly[reflect.ClassTag[T]] 61 | JsonBody andThen (jsonToList(_, path)) as (s"jsonBodyAsList[${tag.runtimeClass.getSimpleName}]") 62 | } 63 | } 64 | 65 | object JsonExtractors extends JsonExtractors 66 | -------------------------------------------------------------------------------- /src/test/scala/org/iainhull/resttest/ExtractorsSpec.scala: -------------------------------------------------------------------------------- 1 | package org.iainhull.resttest 2 | 3 | import org.scalatest.Matchers 4 | import org.scalatest.FlatSpec 5 | 6 | class ExtractorsSpec extends FlatSpec with Matchers { 7 | import Dsl._ 8 | 9 | val response = Response(Status.OK, toHeaders("SimpleHeader" -> "SimpleValue", "MultiHeader" -> "Value1", "MultiHeader" -> "Value2"), Some("body")) 10 | 11 | def returning[T](ext: ExtractorLike[T]): T = ext.value(response).get 12 | 13 | "statusCode" should "return the responses statusCode" in { 14 | returning(StatusCode) should be(Status.OK) 15 | } 16 | 17 | "body" should "return the responses body as a Option[String]" in { 18 | returning(Body) should be(Some("body")) 19 | } 20 | 21 | "bodyText" should "return the responses body as a String" in { 22 | returning(BodyText) should be("body") 23 | } 24 | 25 | "bodyOption" should "return the responses body as an Option" in { 26 | returning(Body) should be(Option("body")) 27 | } 28 | 29 | "Header" should "return the responses header value as a String" in { 30 | returning(Header("SimpleHeader")) should be("SimpleValue") 31 | returning(Header("MultiHeader")) should be("Value1,Value2") 32 | 33 | val ex = the [ExtractorFailedException] thrownBy { returning(Header("NotAHeader")) } 34 | ex.getMessage should include("Header(NotAHeader)") 35 | ex.getCause.getClass should be(classOf[NoSuchElementException]) 36 | } 37 | 38 | "Header.asOption" should "return the responses header value as an Option[List[String]]" in { 39 | returning(Header("SimpleHeader").asOption) should be(Some(List("SimpleValue"))) 40 | returning(Header("MultiHeader").asOption) should be(Some(List("Value1","Value2"))) 41 | 42 | returning(Header("NotAHeader").asOption) should be(None) 43 | } 44 | 45 | 46 | "Header.asList" should "return the responses header as a list" in { 47 | returning(Header("SimpleHeader").asList) should be(List("SimpleValue")) 48 | returning(Header("MultiHeader").asList) should be(List("Value1","Value2")) 49 | 50 | val ex = the [ExtractorFailedException] thrownBy { returning(Header("NotAHeader").asList ) } 51 | ex.getMessage should include("Header(NotAHeader)") 52 | ex.getCause.getClass should be(classOf[NoSuchElementException]) 53 | } 54 | } -------------------------------------------------------------------------------- /src/test/scala/org/iainhull/resttest/TestData.scala: -------------------------------------------------------------------------------- 1 | package org.iainhull.resttest 2 | 3 | import play.api.libs.json._ 4 | import play.api.libs.functional.syntax._ 5 | 6 | object TestData { 7 | import Api._ 8 | 9 | val personJson = """{ "name": "Jason", "age": 27, "email": "jason@json.org" }""" 10 | 11 | val jsonDoc = Json parse """ 12 | { 13 | "user": { 14 | "name" : "toto", 15 | "age" : 25, 16 | "email" : "toto@jmail.com", 17 | "isAlive" : true, 18 | "favorite" : { 19 | "colors": [ "red", "green" ] 20 | }, 21 | "friend" : { 22 | "name" : "tata", 23 | "age" : 20, 24 | "email" : "tata@coldmail.com" 25 | } 26 | }, 27 | "users": [ 28 | { 29 | "name" : "toto", 30 | "age" : 25, 31 | "email" : "toto@jmail.com" 32 | } 33 | ] 34 | } 35 | """ 36 | 37 | val jsonEmptyList = "[]" 38 | 39 | val jsonPersonList = "[" + personJson + "]" 40 | 41 | val jsonList = Json parse """ 42 | [ 43 | { 44 | "name" : "toto", 45 | "age" : 25, 46 | "email" : "toto@jmail.com" 47 | }, 48 | { 49 | "name" : "tata", 50 | "age" : 20, 51 | "email" : "tata@coldmail.com" 52 | } 53 | ] 54 | """ 55 | 56 | case class Person(name: String, age: Int, email: String) 57 | 58 | val Jason = Person("Jason", 27, "jason@json.org") 59 | val Toto = Person("toto", 25, "toto@jmail.com") 60 | val Tata = Person("tata", 20, "tata@coldmail.com") 61 | 62 | implicit val personReads: Reads[Person] = ( 63 | (__ \ "name").read[String] and 64 | (__ \ "age").read[Int] and 65 | (__ \ "email").read[String])(Person) 66 | 67 | 68 | object TestClient extends HttpClient { 69 | val defaultResponse = Response(200, Map("X-Person-Id" -> List("1234")), Some("body")) 70 | var responses = List[Response]() 71 | var requests = List[Request]() 72 | 73 | def lastRequest: Request = requests.head 74 | def nextResponse = responses.headOption.getOrElse(defaultResponse) 75 | def nextResponse_=(response: Response) = responses = List(response) 76 | 77 | override def apply(request: Request): Response = { 78 | requests = request :: requests 79 | if (!responses.isEmpty) { 80 | val response = responses.head 81 | responses = responses.tail 82 | response 83 | } else { 84 | defaultResponse 85 | } 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /src/test/scala/org/iainhull/resttest/ApiSpec.scala: -------------------------------------------------------------------------------- 1 | package org.iainhull.resttest 2 | 3 | import java.net.URI 4 | import org.scalatest.FlatSpec 5 | import org.scalatest.Matchers 6 | 7 | class ApiSpec extends FlatSpec with Matchers { 8 | import Api._ 9 | import TestData._ 10 | 11 | "A Simple Driver" should "take a request and return a static response" in { 12 | val response = TestClient(Request(method = GET, url = new URI("http://api.rest.org/person"))) 13 | response.statusCode should be(Status.OK) 14 | } 15 | 16 | "The Api" should "support a simple rest use case, if a little long winded" in { 17 | val personJson = """{ "name": "Jason" }""" 18 | val r1 = TestClient(Request(GET, new URI("http://api.rest.org/person/"), Map(), None)) 19 | val r2 = TestClient(Request(POST, new URI("http://api.rest.org/person/"), Map(), Some(personJson))) 20 | val id = r2.headers("X-Person-Id").head 21 | val r3 = TestClient(Request(GET, new URI("http://api.rest.org/person/" + id), Map(), None)) 22 | val r4 = TestClient(Request(GET, new URI("http://api.rest.org/person/"), Map(), None)) 23 | val r5 = TestClient(Request(DELETE, new URI("http://api.rest.org/person/" + id), Map(), None)) 24 | val r6 = TestClient(Request(GET, new URI("http://api.rest.org/person/"), Map(), None)) 25 | } 26 | 27 | "A RequestBuilder" should "simplify the creation of request objects" in { 28 | val request1: Request = RequestBuilder().withMethod(GET).withUrl("http://localhost/").withBody("body").toRequest 29 | request1 should have('method(GET), 'url(new URI("http://localhost/")), 'body(Some("body"))) 30 | 31 | val request2: Request = RequestBuilder().withMethod(GET).withUrl("http://localhost/").addPath("foo").addPath("bar").toRequest 32 | request2 should have('method(GET), 'url(new URI("http://localhost/foo/bar"))) 33 | } 34 | 35 | it should "support reuse of partialy constructed builders (ensure builder is immutable)" in { 36 | val base = RequestBuilder().withMethod(GET).withUrl("http://localhost/").withBody("body") 37 | val rb1 = base.withMethod(POST).addPath("foo") 38 | val rb2 = base.withBody("everybody") 39 | 40 | base.toRequest should have('method(GET), 'url(new URI("http://localhost/")), 'body(Some("body"))) 41 | rb1.toRequest should have('method(POST), 'url(new URI("http://localhost/foo")), 'body(Some("body"))) 42 | rb2.toRequest should have('method(GET), 'url(new URI("http://localhost/")), 'body(Some("everybody"))) 43 | } 44 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RestTest 2 | 3 | A lightweight Scala DSL for system testing REST web services 4 | 5 | ## Example 6 | 7 | ```scala 8 | val Jason: Person = ??? 9 | val personJson = Json.stringify(Jason) 10 | val EmptyList = List[Person]() 11 | 12 | using(_ url "http://api.rest.org/person") { implicit rb => 13 | GET asserting (statusCode is Status.OK, jsonBodyAsList[Person] is EmptyList) 14 | val id = POST body personJson asserting (statusCode is Status.Created) returning (header("X-Person-Id")) 15 | GET / id asserting (statusCode is Status.OK, jsonBodyAs[Person] is Jason) 16 | GET asserting (statusCode is Status.OK, jsonBodyAsList[Person] is Seq(Jason)) 17 | DELETE / id asserting (statusCode is Status.OK) 18 | GET / id asserting (statusCode is Status.NotFound) 19 | GET asserting (statusCode is Status.OK, jsonBodyAsList[Person] is EmptyList) 20 | } 21 | ``` 22 | 23 | ## The Plan 24 | 25 | I plan for this to be a useful resource for writing REST web service system tests. However my initial focus is learning and documenting the creation of a Scala DSL. The progress on the implementation is slow because I am documenting my understanding of DSLs as I go. 26 | 27 | You can follow the [progress on my blog](http://iainhull.github.io/tags.html#resttest-ref): 28 | 29 | * [The Builder Pattern](http://iainhull.github.io/2013/07/01/a-simple-rest-dsl-part-1/) 30 | * [The Builder as the basis for a DSL](http://iainhull.github.io/2013/07/02/a-simple-rest-dsl-part-2/) 31 | * [Extracting and asserting on response values](http://iainhull.github.io/2013/07/14/a-simple-rest-dsl-part-3/) 32 | * [Grouping common request configuration with the `using` method](http://iainhull.github.io/2013/07/14/a-simple-rest-dsl-part-4/) 33 | * [How to structure DLS projects](http://iainhull.github.io/2014/05/18/a-simple-rest-dsl-part-5/) 34 | * [Improvements to Extractors](http://iainhull.github.io/2014/06/19/a-simple-rest-dsl-part-6/) 35 | * Integrating RestTest with ScalaTest (planned) 36 | * How to document a DLS (planned) 37 | * Summary of Scala techniques and resources for creating DSLs (planned) 38 | 39 | ## How to build 40 | 41 | This project has been migrated to Scala 2.11 and the build ported to [SBT](http://www.scala-sbt.org/). 42 | 43 | To download and build RestTest just: 44 | 45 | ``` 46 | git clone git@github.com:IainHull/resttest.git 47 | cd resttest 48 | sbt test 49 | ``` 50 | 51 | To create a fully configured eclipse project just: 52 | 53 | ``` 54 | sbt eclipse with-source=true 55 | ``` 56 | 57 | 58 | ### Old build using Gradle ### 59 | This project used to be built with [gradle](http://www.gradle.org/). It still includes the gradle files and the gradle wrapper which will download gradle and build the project for you (the only prereq is Java). 60 | 61 | To download and build RestTest just: 62 | 63 | ``` 64 | git clone git@github.com:IainHull/resttest.git 65 | cd resttest 66 | ./gradlew build 67 | ``` 68 | 69 | To create a fully configured eclipse project just: 70 | 71 | ``` 72 | ./gradlew eclipse 73 | ``` 74 | 75 | ## License 76 | 77 | RestTest is licensed under the permissive [Apache 2 Open Source License](http://www.apache.org/licenses/LICENSE-2.0.txt). 78 | -------------------------------------------------------------------------------- /src/main/scala/org/iainhull/resttest/driver/Jersey.scala: -------------------------------------------------------------------------------- 1 | package org.iainhull.resttest.driver 2 | 3 | import com.sun.jersey.api.client.Client 4 | import com.sun.jersey.api.client.ClientResponse 5 | import com.sun.jersey.api.client.WebResource 6 | import org.iainhull.resttest.Api 7 | import org.iainhull.resttest.TestDriver 8 | import scala.collection.JavaConverters 9 | 10 | /** 11 | * Provides the Jersey httpClient implementation (as a trait to support 12 | * mixing in). 13 | */ 14 | trait Jersey extends Api { 15 | import Jersey.Impl 16 | 17 | implicit val httpClient: HttpClient = { request => 18 | val response = Impl.createClientResponse(request) 19 | Response(response.getStatus, Impl.headers(response), Some(response.getEntity(classOf[String]))) 20 | } 21 | } 22 | 23 | /** 24 | * Provides the Jersey httpClient implementation (as an object to support 25 | * straight import). 26 | */ 27 | object Jersey extends Jersey { 28 | private object Impl { 29 | 30 | val jersey = Client.create() 31 | 32 | def createClientResponse(request: Request): ClientResponse = { 33 | val builder = addRequestHeaders(request.headers, jersey.resource(request.url).getRequestBuilder) 34 | 35 | for (b <- request.body) { 36 | builder.entity(b) 37 | } 38 | 39 | request.method match { 40 | case GET => builder.get(classOf[ClientResponse]) 41 | case POST => builder.post(classOf[ClientResponse]) 42 | case PUT => builder.put(classOf[ClientResponse]) 43 | case DELETE => builder.delete(classOf[ClientResponse]) 44 | case HEAD => builder.method("HEAD", classOf[ClientResponse]) 45 | case PATCH => builder.method("PATCH", classOf[ClientResponse]) 46 | } 47 | } 48 | 49 | def addRequestHeaders(headers: Map[String, List[String]], builder: WebResource#Builder): WebResource#Builder = { 50 | def forAllNames(names: List[String], b: WebResource#Builder): WebResource#Builder = { 51 | names match { 52 | case h :: t => forAllNames(t, forAllValues(h, headers(h), b)) 53 | case Nil => b 54 | } 55 | } 56 | def forAllValues(name: String, values: List[String], b: WebResource#Builder): WebResource#Builder = { 57 | values match { 58 | case h :: t => forAllValues(name, t, b.header(name, h)) 59 | case Nil => b 60 | } 61 | } 62 | forAllNames(headers.keys.toList, builder) 63 | } 64 | 65 | def headers(response: ClientResponse): Map[String, List[String]] = { 66 | import JavaConverters._ 67 | 68 | response.getHeaders.asScala.toMap.map { 69 | case (k, v) => 70 | (k, v.asScala.toList) 71 | } 72 | } 73 | } 74 | } 75 | 76 | /** 77 | * The JerseySystemTestDriver can be mixed into Rest Test Suites to execute 78 | * then with Jersey. Suites must provide the baseUrl from which all test 79 | * paths are relative. 80 | */ 81 | trait JerseySystemTestDriver extends TestDriver with Jersey { 82 | override implicit def defBuilder = RequestBuilder.emptyBuilder withUrl baseUrl 83 | 84 | /** 85 | * Implements must specify the baseUrl from which all test paths are relative. 86 | */ 87 | def baseUrl: String 88 | } -------------------------------------------------------------------------------- /src/test/scala/org/iainhull/resttest/RestMatchersSpec.scala: -------------------------------------------------------------------------------- 1 | package org.iainhull.resttest 2 | 3 | import org.scalatest.FlatSpec 4 | import org.scalatest.Matchers 5 | import org.scalatest.matchers.HavePropertyMatcher 6 | import org.scalatest.matchers.HavePropertyMatchResult 7 | 8 | class RestMatcherSpec extends FlatSpec with Matchers { 9 | import language.implicitConversions 10 | 11 | import TestData._ 12 | import Dsl._ 13 | import RestMatchers._ 14 | 15 | implicit val driver = TestClient 16 | 17 | val response = Response(Status.OK, toHeaders("header1" -> "", "header2" -> "value", "header3" -> "value1", "header3" -> "value2"), None) 18 | 19 | "RestMatchers" should "support 'have property' equals check" in { 20 | response should have('statusCode(Status.OK)) 21 | response should have(StatusCode(Status.OK)) 22 | 23 | response should have(Header("header2")("value")) 24 | } 25 | 26 | it should "support 'have property' check for Extractor[Option[_]]" in { 27 | response should have(Header("header1").asOption) 28 | response should not(have(Body)) 29 | } 30 | 31 | "Sample use-case" should "support asserting on values from the response with have matchers" in { 32 | import JsonExtractors._ 33 | val EmptyList = Seq() 34 | val BodyAsListPerson = jsonBodyAsList[Person] 35 | val BodyAsPerson = jsonBodyAs[Person] 36 | 37 | driver.responses = Response(Status.OK, Map(), Some("[]")) :: 38 | Response(Status.Created, toHeaders("X-Person-Id" -> "99"), None) :: 39 | Response(Status.OK, Map(), Some(personJson)) :: 40 | Response(Status.OK, Map(), Some("[" + personJson + "]")) :: 41 | Response(Status.OK, Map(), None) :: 42 | Response(Status.NotFound, Map(), None) :: 43 | Response(Status.OK, Map(), Some("[]")) :: 44 | Nil 45 | 46 | using(_ url "http://api.rest.org/person") { implicit rb => 47 | GET should have(StatusCode(Status.OK), BodyAsListPerson(EmptyList)) 48 | 49 | val (status, id) = POST body personJson returning (StatusCode, Header("X-Person-Id")) 50 | status should be(Status.Created) 51 | 52 | val foo = GET / id should have(StatusCode(Status.OK), BodyAsPerson(Jason)) 53 | 54 | GET should have(StatusCode(Status.OK), BodyAsListPerson(Seq(Jason))) 55 | 56 | DELETE / id should have(StatusCode(Status.OK)) 57 | 58 | GET / id should have(StatusCode(Status.NotFound)) 59 | 60 | GET should have(StatusCode(Status.OK), BodyAsListPerson( EmptyList)) 61 | } 62 | } 63 | 64 | it should "support asserting on extractor as values" in { 65 | import JsonExtractors._ 66 | val EmptyList = Seq() 67 | 68 | driver.responses = Response(Status.OK, Map(), Some("[]")) :: 69 | Response(Status.Created, toHeaders("X-Person-Id" -> "99"), None) :: 70 | Response(Status.OK, Map(), Some(personJson)) :: 71 | Response(Status.OK, Map(), Some("[" + personJson + "]")) :: 72 | Response(Status.OK, Map(), None) :: 73 | Response(Status.NotFound, Map(), None) :: 74 | Response(Status.OK, Map(), Some("[]")) :: 75 | Nil 76 | 77 | using(_ url "http://api.rest.org/person") { implicit rb => 78 | GET expecting { implicit res => 79 | StatusCode should be(Status.OK) 80 | jsonBodyAsList[Person] should be(EmptyList) 81 | } 82 | 83 | val id = POST body personJson expecting { implicit res => 84 | StatusCode should be(Status.Created) 85 | Header("X-Person-Id").value 86 | } 87 | 88 | GET / id expecting { implicit res => 89 | StatusCode should be(Status.OK) 90 | jsonBodyAs[Person] should be(Jason) 91 | } 92 | 93 | GET expecting { implicit res => 94 | StatusCode should be(Status.OK) 95 | jsonBodyAsList[Person] should be(Seq(Jason)) 96 | } 97 | 98 | DELETE / id expecting { implicit res => 99 | StatusCode should be(Status.OK) 100 | } 101 | 102 | GET / id expecting { implicit res => 103 | StatusCode should be(Status.NotFound) 104 | } 105 | 106 | GET expecting { implicit res => 107 | StatusCode should be(Status.OK) 108 | jsonBodyAsList[Person] should be(EmptyList) 109 | } 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/main/scala/org/iainhull/resttest/RestMatchers.scala: -------------------------------------------------------------------------------- 1 | package org.iainhull.resttest 2 | 3 | import org.scalatest.Matchers.AnyShouldWrapper 4 | import org.scalatest.matchers.HavePropertyMatcher 5 | import org.scalatest.matchers.HavePropertyMatchResult 6 | import org.scalatest.Assertions 7 | import scala.util.Success 8 | 9 | /** 10 | * Adds [[http://www.scalatest.org/ ScalaTest]] support to the RestTest [[Dsl]]. 11 | * 12 | * The `should` keyword is added to [[Api.RequestBuilder]] and [[Api.Response]] expressions, the 13 | * `RequestBuilder` is executed first and `should` applied to the `Response`. 14 | * 15 | * The `have` keyword supports [[Extractors.ExtractorLike]]s. See [[ExtractorToHavePropertyMatcher]] for more details. 16 | * 17 | * === Example === 18 | * 19 | * {{{ 20 | * using (_ url "http://api.rest.org/person") { implicit rb => 21 | * GET should have (statusCode(Status.OK), jsonBodyAsList[Person] === EmptyList) 22 | * 23 | * val (status, id) = POST body personJson returning (statusCode, headerText("X-Person-Id")) 24 | * status should be(Status.Created) 25 | * 26 | * val foo = GET / id should have (statusCode(Status.OK), jsonBodyAs[Person] === Jason) 27 | * 28 | * GET should have (statusCode === Status.OK, jsonBodyAsList[Person] === Seq(Jason)) 29 | * 30 | * DELETE / id should have (statusCode === Status.OK) 31 | * 32 | * GET / id should have (statusCode === Status.NotFound) 33 | * 34 | * GET should have (statusCode(Status.OK), jsonBodyAsList[Person] === EmptyList) 35 | * } 36 | * }}} 37 | */ 38 | trait RestMatchers { 39 | import language.implicitConversions 40 | import Api._ 41 | import Dsl._ 42 | 43 | /** 44 | * Implicitly execute a [[Api.RequestBuilder]] and convert the [[Api.Response]] into a `AnyRefShouldWrapper` 45 | * 46 | * This adds support for ScalaTest's `ShouldMatchers` to `RequestBuilder` 47 | */ 48 | implicit def requestBuilderToShouldWrapper(builder: RequestBuilder)(implicit client: HttpClient): AnyShouldWrapper[Response] = { 49 | responseToShouldWrapper(builder execute ()) 50 | } 51 | 52 | /** 53 | * Implicitly convert a [[Api.Response]] into a `AnyRefShouldWrapper` 54 | * 55 | * This adds support for ScalaTest's `ShouldMatchers` to `Response` 56 | */ 57 | implicit def responseToShouldWrapper(response: Response): AnyShouldWrapper[Response] = { 58 | new AnyShouldWrapper(response) 59 | } 60 | 61 | implicit def methodToShouldWrapper(method: Method)(implicit builder: RequestBuilder, client: HttpClient): AnyShouldWrapper[Response] = { 62 | requestBuilderToShouldWrapper(builder.withMethod(method)) 63 | } 64 | 65 | implicit def extractorToShouldWrapper[T](extractor: ExtractorLike[T])(implicit response: Response): AnyShouldWrapper[T] = { 66 | Assertions.withClue(extractor.name) { 67 | val v: T = extractor.value.get 68 | new AnyShouldWrapper[T](v) 69 | } 70 | } 71 | 72 | /** 73 | * Implicitly add operations to [[Extractors.Extractor]] that create `HavePropertyMatcher`s. 74 | * 75 | * This adds support for reusing `Extractor`s in `should have(...)` expressions, for example 76 | * 77 | * {{{ 78 | * response should have(statusCode(Status.OK)) 79 | * }}} 80 | */ 81 | implicit class ExtractorToHavePropertyMatcher[T](extractor: ExtractorLike[T]) { 82 | def apply(expected: T): HavePropertyMatcher[Response, String] = { 83 | new HavePropertyMatcher[Response, String] { 84 | def apply(response: Response) = { 85 | val actual = extractor.value(response) 86 | new HavePropertyMatchResult( 87 | actual == Success(expected), 88 | extractor.name, 89 | expected.toString, 90 | actual.toString) 91 | } 92 | } 93 | } 94 | } 95 | 96 | /** 97 | * Implicitly convert an [[Extractors.Extractor]] that returns any type of `Option` into a `HavePropertyMatcher`. 98 | * 99 | * This adds support for reusing `Extractor[Option[_]]`s in `should have(...)` expressions, for example 100 | * 101 | * {{{ 102 | * response should have(header("header2")) 103 | * response should have(body) 104 | * }}} 105 | */ 106 | implicit class OptionExtractorToHavePropertyMatcher(extractor: ExtractorLike[Option[_]]) extends HavePropertyMatcher[Response, String] { 107 | def apply(response: Response) = { 108 | val actual = extractor.value(response) 109 | val isDefined = actual.map(a => a.isDefined).getOrElse(false) 110 | new HavePropertyMatchResult( 111 | isDefined, 112 | extractor.name, 113 | "defined", 114 | (if (isDefined) "" else "not") + " defined") 115 | } 116 | } 117 | } 118 | 119 | object RestMatchers extends RestMatchers -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=`basename "$0"` 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn ( ) { 19 | echo "$*" 20 | } 21 | 22 | die ( ) { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "`uname`" in 34 | CYGWIN* ) 35 | cygwin=true 36 | ;; 37 | Darwin* ) 38 | darwin=true 39 | ;; 40 | MINGW* ) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # For Cygwin, ensure paths are in UNIX format before anything is touched. 46 | if $cygwin ; then 47 | [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 48 | fi 49 | 50 | # Attempt to set APP_HOME 51 | # Resolve links: $0 may be a link 52 | PRG="$0" 53 | # Need this for relative symlinks. 54 | while [ -h "$PRG" ] ; do 55 | ls=`ls -ld "$PRG"` 56 | link=`expr "$ls" : '.*-> \(.*\)$'` 57 | if expr "$link" : '/.*' > /dev/null; then 58 | PRG="$link" 59 | else 60 | PRG=`dirname "$PRG"`"/$link" 61 | fi 62 | done 63 | SAVED="`pwd`" 64 | cd "`dirname \"$PRG\"`/" >&- 65 | APP_HOME="`pwd -P`" 66 | cd "$SAVED" >&- 67 | 68 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 69 | 70 | # Determine the Java command to use to start the JVM. 71 | if [ -n "$JAVA_HOME" ] ; then 72 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 73 | # IBM's JDK on AIX uses strange locations for the executables 74 | JAVACMD="$JAVA_HOME/jre/sh/java" 75 | else 76 | JAVACMD="$JAVA_HOME/bin/java" 77 | fi 78 | if [ ! -x "$JAVACMD" ] ; then 79 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 80 | 81 | Please set the JAVA_HOME variable in your environment to match the 82 | location of your Java installation." 83 | fi 84 | else 85 | JAVACMD="java" 86 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 87 | 88 | Please set the JAVA_HOME variable in your environment to match the 89 | location of your Java installation." 90 | fi 91 | 92 | # Increase the maximum file descriptors if we can. 93 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then 94 | MAX_FD_LIMIT=`ulimit -H -n` 95 | if [ $? -eq 0 ] ; then 96 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 97 | MAX_FD="$MAX_FD_LIMIT" 98 | fi 99 | ulimit -n $MAX_FD 100 | if [ $? -ne 0 ] ; then 101 | warn "Could not set maximum file descriptor limit: $MAX_FD" 102 | fi 103 | else 104 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 105 | fi 106 | fi 107 | 108 | # For Darwin, add options to specify how the application appears in the dock 109 | if $darwin; then 110 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 111 | fi 112 | 113 | # For Cygwin, switch paths to Windows format before running java 114 | if $cygwin ; then 115 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 116 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 158 | function splitJvmOpts() { 159 | JVM_OPTS=("$@") 160 | } 161 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 162 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 163 | 164 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 165 | -------------------------------------------------------------------------------- /src/main/scala/org/iainhull/resttest/Extractors.scala: -------------------------------------------------------------------------------- 1 | package org.iainhull.resttest 2 | 3 | import Api._ 4 | import scala.util.Try 5 | import scala.util.Failure 6 | 7 | trait Extractors { 8 | import language.implicitConversions 9 | 10 | type ExtractorLike[+A] = Extractors.ExtractorLike[A] 11 | type ExtractorFailedException = Extractors.ExtractorFailedException 12 | 13 | type Extractor[+A] = Extractors.Extractor[A] 14 | val Extractor = Extractors.Extractor 15 | 16 | type Header = Extractors.Header 17 | val Header = Extractors.Header 18 | 19 | val StatusCode = Extractors.StatusCode 20 | 21 | val Body = Extractors.Body 22 | 23 | val BodyText = Extractors.BodyText 24 | 25 | val & = Extractors.& 26 | } 27 | 28 | object Extractors { 29 | /** 30 | * Basic trait for all extractors chanes are you want [[Extractor]]. 31 | */ 32 | trait ExtractorLike[+A] { 33 | def name: String 34 | def unapply(res: Response): Option[A] = value(res).toOption 35 | def value(implicit res: Response): Try[A] 36 | } 37 | 38 | /** 39 | * Primary implementation of ExtractorLike. The name and extraction 40 | * 41 | * @param name 42 | * The name of the extractor 43 | * @param op 44 | * The operation to extract the value of type `A` from the `Response` 45 | */ 46 | case class Extractor[+A](name: String, op: Response => A) extends ExtractorLike[A] { 47 | override def value(implicit res: Response): Try[A] = { 48 | Try { op(res) } recoverWith { 49 | case e => 50 | Failure[A](new ExtractorFailedException( 51 | s"Cannot extract $name from Response: ${e.getMessage}", 52 | e)) 53 | } 54 | } 55 | 56 | /** 57 | * Create a new `Extractor` by executing a new function to modify the result. 58 | * Normally followed by `as`. 59 | * 60 | * {{{ 61 | * val JsonBody = BodyText andThen Json.parse as "JsonBody" 62 | * }}} 63 | */ 64 | def andThen[B](nextOp: A => B): Extractor[B] = copy(name = name + ".andThen ?", op = op andThen nextOp) 65 | 66 | /** 67 | * Rename the extractor 68 | */ 69 | def as(newName: String) = copy(name = newName) 70 | } 71 | 72 | class ExtractorFailedException(message: String, cause: Throwable) extends Exception(message, cause) { 73 | def this(message: String) = this(message, null) 74 | 75 | } 76 | 77 | val StatusCode = Extractor[Int]("StatusCode", r => r.statusCode) 78 | 79 | val Body = Extractor[Option[String]]("Body", r => r.body) 80 | 81 | val BodyText = Extractor[String]("BodyText", r => r.body.get) 82 | 83 | /** 84 | * Enable Extractors to be chained together in case clauses. 85 | * 86 | * For example: 87 | * {{{ 88 | * GET / id expecting { 89 | * case StatusCode(Status.OK) & Header.ContentType(ct) & BodyAsPerson(person) => 90 | * ct should be("application/json") 91 | * person should be(Jason) 92 | * } 93 | * }}} 94 | */ 95 | object & { 96 | def unapply(res: Response): Option[(Response, Response)] = { 97 | Some((res, res)) 98 | } 99 | } 100 | 101 | /** 102 | * Defines `Extractor`s for the specified header, specific extractors provided by the `asText`, `asList`, `asOption` members. 103 | * Instances behave like their `asText` member. 104 | */ 105 | case class Header(header: String) extends ExtractorLike[String] { 106 | val asText = Extractor[String]("Header(" + header + ")", _.headers(header).mkString(",")) 107 | val asList = Extractor[List[String]]("Header(" + header + ").asList", _.headers(header)) 108 | val asOption = Extractor[Option[List[String]]]("Header(" + header + ").asOption", _.headers.get(header)) 109 | val isDefined = new Object { 110 | def unapply(res: Response): Boolean = { 111 | res.headers.contains(header) 112 | } 113 | } 114 | 115 | def ->(value: String): (String, String) = { 116 | (header, value) 117 | } 118 | 119 | override def name: String = asText.name 120 | override def unapply(res: Response): Option[String] = asText.unapply(res) 121 | override def value(implicit res: Response): Try[String] = asText.value 122 | } 123 | 124 | /** 125 | * Provides constants for standard headers. 126 | */ 127 | object Header { 128 | val AccessControlAllowOrigin = Header("Access-Control-Allow-Origin") 129 | val AcceptRanges = Header("Accept-Ranges") 130 | val Age = Header("Age") 131 | val Allow = Header("Allow") 132 | val CacheControl = Header("Cache-Control") 133 | val Connection = Header("Connection") 134 | val ContentEncoding = Header("Content-Encoding") 135 | val ContentLanguage = Header("Content-Language") 136 | val ContentLength = Header("Content-Length") 137 | val ContentLocation = Header("Content-Location") 138 | val ContentMd5 = Header("Content-MD5") 139 | val ContentDisposition = Header("Content-Disposition") 140 | val ContentRange = Header("Content-Range") 141 | val ContentType = Header("Content-Type") 142 | val Date = Header("Date") 143 | val ETag = Header("ETag") 144 | val Expires = Header("Expires") 145 | val LastModified = Header("Last-Modified") 146 | val Link = Header("Link") 147 | val Location = Header("Location") 148 | val P3P = Header("P3P") 149 | val Pragma = Header("Pragma") 150 | val ProxyAuthenticate = Header("Proxy-Authenticate") 151 | val Refresh = Header("Refresh") 152 | val RetryAfter = Header("Retry-After") 153 | val Server = Header("Server") 154 | val SetCookie = Header("Set-Cookie") 155 | val StrictTransportSecurity = Header("Strict-Transport-Security") 156 | val Trailer = Header("Trailer") 157 | val TransferEncoding = Header("Transfer-Encoding") 158 | val Vary = Header("Vary") 159 | val Via = Header("Via") 160 | val Warning = Header("Warning") 161 | val WwwAuthenticate = Header("WWW-Authenticate") 162 | } 163 | } -------------------------------------------------------------------------------- /src/main/scala/org/iainhull/resttest/Api.scala: -------------------------------------------------------------------------------- 1 | package org.iainhull.resttest 2 | 3 | import java.net.URI 4 | import java.net.URLEncoder 5 | 6 | /** 7 | * Provides the main api for creating and sending REST Web service requests 8 | * (as an object for importing). 9 | * 10 | * {{{ 11 | * val request = Request(GET, new URI("http://api.rest.org/person", Map(), None)) 12 | * val response = driver.execute(request) 13 | * response.statusCode should be(Status.OK) 14 | * response.body match { 15 | * Some(body) => objectMapper.readValue[List[Person]](body) should have length(0) 16 | * None => fail("Expected a body")) 17 | * } 18 | * }}} 19 | * 20 | * or using the [[Api.RequestBuilder]] 21 | * {{{ 22 | * val request = driver.execute(RequestBuilder().withUrl("http://api.rest.org/person/").withMethod(GET)) 23 | * }}} 24 | * 25 | * This provides the basic interface used to implement the [[Dsl]], users 26 | * are expected to use the Dsl. 27 | */ 28 | object Api { 29 | 30 | /** HTTP Method */ 31 | sealed abstract class Method(name: String) 32 | /** HTTP delete method */ 33 | case object DELETE extends Method("DELETE") 34 | /** HTTP get method */ 35 | case object GET extends Method("GET") 36 | /** HTTP head method */ 37 | case object HEAD extends Method("HEAD") 38 | /** HTTP patch method */ 39 | case object PATCH extends Method("PATCH") 40 | /** HTTP post method */ 41 | case object POST extends Method("POST") 42 | /** HTTP put method */ 43 | case object PUT extends Method("PUT") 44 | 45 | /** The HTTP Request */ 46 | case class Request(method: Method, url: URI, headers: Map[String, List[String]] = Map(), body: Option[String] = None) 47 | 48 | /** The HTTP Response */ 49 | case class Response(statusCode: Int, headers: Map[String, List[String]], body: Option[String]) 50 | 51 | /** 52 | * Builder to make creating [[Request]] objects nicer - normally this should 53 | * be driven through the [[Dsl]]. 54 | */ 55 | case class RequestBuilder( 56 | method: Option[Method], 57 | url: Option[URI], 58 | query: Seq[(String, String)], 59 | headers: Seq[(String, String)], 60 | queryParams: Seq[(String, String)], 61 | body: Option[String]) { 62 | 63 | /** specify the method of the {{Request}} */ 64 | def withMethod(method: Method): RequestBuilder = copy(method = Some(method)) 65 | 66 | /** specify the url of the {{Request}} */ 67 | def withUrl(url: String): RequestBuilder = copy(url = Some(new URI(url))) 68 | 69 | /** specify the body of the {{Request}} */ 70 | def withBody(body: String): RequestBuilder = copy(body = Some(body)) 71 | 72 | /** Append the specified path to the url of the {{Request}}, fails if url not set yet */ 73 | def addPath(path: String): RequestBuilder = { 74 | val s = url.get.toString 75 | val slash = if (s.endsWith("/")) "" else "/" 76 | copy(url = Some(new URI(s + slash + path))) 77 | } 78 | 79 | /** Add headers to the {{Request}} */ 80 | def addHeaders(hs: (String, String)*) = copy(headers = headers ++ hs) 81 | 82 | /** Add query parameters to the {{Request}} */ 83 | def addQuery(qs: (String, String)*) = copy(queryParams = queryParams ++ qs) 84 | 85 | /** Build the {{Request}} */ 86 | def toRequest: Request = { 87 | val fullUrl = URI.create(url.get + toQueryString(queryParams: _*)) 88 | Request(method.get, fullUrl, toHeaders(headers: _*), body) 89 | } 90 | } 91 | 92 | /** 93 | * Helper object for RequestBuilder class, supplies the emptyBuilder 94 | * instance and access to the current implicit builder. 95 | */ 96 | object RequestBuilder { 97 | /** 98 | * The initial empty RequestBuilder instance. This is the default implicit 99 | * instance. 100 | */ 101 | implicit val emptyBuilder = RequestBuilder(None, None, Seq(), Seq(), Seq(), None) 102 | 103 | /** 104 | * Returns the current RequestBuilder from the implicit context. 105 | */ 106 | def apply()(implicit builder: RequestBuilder): RequestBuilder = { 107 | builder 108 | } 109 | } 110 | 111 | /** 112 | * Abstract interface for submitting REST `Requests` and receiving `Responses` 113 | */ 114 | type HttpClient = Request => Response 115 | 116 | /** 117 | * Constants for HTTP Status Codes 118 | */ 119 | object Status { 120 | val Continue = 100 121 | val SwitchingProtocols = 101 122 | val OK = 200 123 | val Created = 201 124 | val Accepted = 202 125 | val NonAuthoritativeInformation = 203 126 | val NoContent = 204 127 | val ResetContent = 205 128 | val PartialContent = 206 129 | val MultipleChoices = 300 130 | val MovedPermanently = 301 131 | val Found = 302 132 | val SeeOther = 303 133 | val NotModified = 304 134 | val UseProxy = 305 135 | val SwitchProxy = 306 136 | val TemporaryRedirect = 307 137 | val PermanentRedirect = 308 138 | val BadRequest = 400 139 | val Unauthorized = 401 140 | val PaymentRequired = 402 141 | val Forbidden = 403 142 | val NotFound = 404 143 | val MethodNotAllowed = 405 144 | val NotAcceptable = 406 145 | val ProxyAuthenticationRequired = 407 146 | val RequestTimeout = 408 147 | val Conflict = 409 148 | val Gone = 410 149 | val LengthRequired = 411 150 | val PreconditionFailed = 412 151 | val RequestEntityTooLarge = 413 152 | val RequestUriTooLong = 414 153 | val UnsupportedMediaType = 415 154 | val RequestedRangeNotSatisfiable = 416 155 | val ExpectationFailed = 417 156 | val InternalServerError = 500 157 | val NotImplemented = 501 158 | val BadGateway = 502 159 | val ServiceUnavailable = 503 160 | val GatewayTimeout = 504 161 | val HttpVersionNotSupported = 505 162 | } 163 | 164 | /** 165 | * Convert a sequence of `(name, value)` tuples into a map of headers. 166 | * Each tuple creates an entry in the map, duplicate `name`s add the 167 | * `value` to the list. 168 | */ 169 | def toHeaders(hs: (String, String)*): Map[String, List[String]] = { 170 | hs.foldRight(Map[String, List[String]]()) { 171 | case ((name, value), hm) => 172 | val listValue = if (value == "") List() else List(value) 173 | val list = hm.get(name).map(listValue ++ _) getOrElse (listValue) 174 | hm + (name -> list) 175 | } 176 | } 177 | 178 | def toQueryString(qs: (String, String)*): String = { 179 | def urlEncodeUTF8(s: String): String = URLEncoder.encode(s, "UTF-8") 180 | 181 | if (!qs.isEmpty) { 182 | qs map { 183 | case (name, value) => 184 | urlEncodeUTF8(name) + "=" + urlEncodeUTF8(value) 185 | } mkString ("?", "&", "") 186 | } else { 187 | "" 188 | } 189 | } 190 | } 191 | 192 | /** 193 | * Provides the main api for creating and sending REST Web service requests 194 | * (as a trait for mixing in). 195 | */ 196 | trait Api { 197 | /** The HTTP Methods used to make a request */ 198 | type Method = Api.Method 199 | val GET = Api.GET 200 | val POST = Api.POST 201 | val PUT = Api.PUT 202 | val DELETE = Api.DELETE 203 | val HEAD = Api.HEAD 204 | val PATCH = Api.PATCH 205 | 206 | /** The HTTP Request */ 207 | type Request = Api.Request 208 | val Request = Api.Request 209 | 210 | /** The HTTP Response */ 211 | type Response = Api.Response 212 | val Response = Api.Response 213 | 214 | /** The HTTP RequestBuilder */ 215 | type RequestBuilder = Api.RequestBuilder 216 | val RequestBuilder = Api.RequestBuilder 217 | 218 | /** 219 | * HttpClient 220 | */ 221 | type HttpClient = Api.HttpClient 222 | 223 | val Status = Api.Status 224 | 225 | def toHeaders(hs: (String, String)*): Map[String, List[String]] = Api.toHeaders(hs: _*) 226 | def toQueryString(qs: (String, String)*): String = Api.toQueryString(qs: _*) 227 | } 228 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /src/main/scala/org/iainhull/resttest/Dsl.scala: -------------------------------------------------------------------------------- 1 | package org.iainhull.resttest 2 | 3 | import java.net.URI 4 | import scala.util.Success 5 | import scala.util.Failure 6 | 7 | /** 8 | * Provides a DSL for simplifying REST system tests. This is meant to be used with ScalaTest or similar testing framework. 9 | * 10 | * For example to post a json document to a REST endpoint and check the statusCode: 11 | * {{{ 12 | * val personJson = """{ "name": "fred" }""" 13 | * POST url "http://api.rest.org/person" body personJson asserting (statusCode is Status.Created) 14 | * }}} 15 | * 16 | * Or to get a json document from a REST endpoint and convert the json array to a List of Person objects: 17 | * {{{ 18 | * val people = GET url "http://api.rest.org/person" returning (jsonBodyAsList[Person]) 19 | * }}} 20 | * 21 | * Finally a more complete example that using a ScalaTest Spec to verify a simple REST API. 22 | * {{{ 23 | * class DslSpec extends FlatSpec with Dsl { 24 | * "An empty api" should "support adding and deleting a single object" { 25 | * using (_ url "http://api.rest.org/person") { implicit rb => 26 | * GET asserting (statusCode is Status.OK, jsonBodyAsList[Person] is EmptyList) 27 | * val id = POST body personJson asserting (statusCode is Status.Created) returning (header("X-Person-Id")) 28 | * GET / id asserting (statusCode is Status.OK, jsonBodyAs[Person] is Jason) 29 | * GET asserting (statusCode is Status.OK, jsonBodyAsList[Person] is Seq(Jason)) 30 | * DELETE / id asserting (statusCode is Status.OK) 31 | * GET / id asserting (statusCode is Status.NotFound) 32 | * GET asserting (statusCode is Status.OK, jsonBodyAsList[Person] is EmptyList) 33 | * } 34 | * } 35 | * } 36 | * }}} 37 | * 38 | * == Configuring a Request == 39 | * 40 | * The DSL centers around the [[Api.RequestBuilder]], which specifies the properties 41 | * of the request. Most expressions begin with the HTTP [[Api.Method]] followed by a 42 | * call to [[RichRequestBuilder]], this converts the `Method` to a [[Api.RequestBuilder]]. 43 | * The resulting `RequestBuilder` contains both the `Method` and secondary property. 44 | * For example: 45 | * {{{ 46 | * GET url "http://api.rest.org/person" 47 | * }}} 48 | * is the same as 49 | * {{{ 50 | * RequestBuilder().withMethod(GET).withUrl("http://api.rest.org/person") 51 | * }}} 52 | * 53 | * The `RequestBuilder` DSL also supports default values passed implicitly into expressions, 54 | * for example: 55 | * {{{ 56 | * implicit val defaults = RequestBuilder() addHeader ("Accept", "application/json") 57 | * GET url "http://api.rest.org/person" 58 | * }}} 59 | * creates a `RequestBuilder` with a method, url and accept header set. The default values 60 | * are normal expressed the with the [[using]] expression. 61 | * 62 | * == Executing a Request == 63 | * 64 | * There are three ways to execute a request: [[RichRequestBuilder]]`.execute`, [[RichResponse]]`.returning`, 65 | * [[RichRequestBuilder]]`.asserting`, these can all be applied to `RequestBuilder` instances. 66 | * 67 | * The `execute` method executes the request with the implicit [[Api.HttpClient]] and returns the `Response`. 68 | * {{{ 69 | * val response: Response = GET url "http://api.rest.org/person" execute () 70 | * }}} 71 | * 72 | * The `returning` method executes the request like the `execute` method, except it applies one or more 73 | * [[Extractor]]s to the `Response` to return only the extracted information. 74 | * {{{ 75 | * val code1 = GET url "http://api.rest.org/person" returning (StatusCode) 76 | * val (code2, people) = GET url "http://api.rest.org/person" returning (StatusCode, jsonBodyAsList[Person]) 77 | * }}} 78 | * 79 | * The `asserting` method executes the request like the `execute` method, except it verifies the specified 80 | * value of one or more `Response` values. `asserting` is normally used with extractors, see [RichExtractor] 81 | * for more information. `asserting` and `returning` methods can be used in the same expression. 82 | * {{{ 83 | * GET url "http://api.rest.org/person" asserting (statusCode is Status.OK) 84 | * val people = GET url "http://api.rest.org/person" asserting (statusCode is Status.OK) returning (jsonBodyAsList[Person]) 85 | * }}} 86 | * 87 | * 88 | * == Working with Extractors == 89 | * 90 | * Extractors are simply functions that take a [[Api.Response]] are extract or convert part of its contents. 91 | * Extracts are written to assume that the data they require is in the response, if it is not they throw an 92 | * Exception (failing the test). See [[Extractors]] for more information on the available default `Extractor`s 93 | * And how to implement your own. 94 | */ 95 | trait Dsl extends Api with Extractors { 96 | import language.implicitConversions 97 | 98 | implicit def toRequest(builder: RequestBuilder): Request = builder.toRequest 99 | implicit def methodToRequestBuilder(method: Method)(implicit builder: RequestBuilder): RequestBuilder = builder.withMethod(method) 100 | implicit def methodToRichRequestBuilder(method: Method)(implicit builder: RequestBuilder): RichRequestBuilder = new RichRequestBuilder(methodToRequestBuilder(method)(builder)) 101 | 102 | type Assertion = Function1[Response, Option[String]] 103 | 104 | def assertionFailed(assertionResults: Seq[String]): Throwable = { 105 | new AssertionError(assertionResults.mkString(",")) 106 | } 107 | 108 | implicit class RichRequestBuilder(builder: RequestBuilder) { 109 | def url(u: String) = builder.withUrl(u) 110 | def body(b: String) = builder.withBody(b) 111 | def /(p: Symbol) = builder.addPath(p.name) 112 | def /(p: Any) = builder.addPath(p.toString) 113 | def :?(params: (Symbol, Any)*) = builder.addQuery(params map (p => (p._1.name, p._2.toString)): _*) 114 | 115 | def execute()(implicit client: HttpClient): Response = { 116 | client(builder) 117 | } 118 | 119 | def apply[T](proc: RequestBuilder => T): T = { 120 | proc(builder) 121 | } 122 | 123 | def asserting(assertions: Assertion*)(implicit client: HttpClient): Response = { 124 | val res = execute() 125 | val assertionFailures = for { 126 | a <- assertions 127 | r <- a(res) 128 | } yield r 129 | if (assertionFailures.nonEmpty) { 130 | throw assertionFailed(assertionFailures) 131 | } 132 | res 133 | } 134 | 135 | def expecting[T](func: Response => T)(implicit client: HttpClient): T = { 136 | val res = execute() 137 | func(res) 138 | } 139 | } 140 | 141 | /** 142 | * Extend the default request's configuration so that partially configured requests to be reused. Foe example: 143 | * 144 | * {{{ 145 | * using(_ url "http://api.rest.org/person") { implicit rb => 146 | * GET asserting (StatusCode === Status.OK, jsonBodyAsList[Person] === EmptyList) 147 | * val id = POST body personJson asserting (StatusCode === Status.Created) returning (Header("X-Person-Id")) 148 | * GET / id asserting (StatusCode === Status.OK, jsonBodyAs[Person] === Jason) 149 | * } 150 | * }}} 151 | * 152 | * @param config 153 | * a function to configure the default request 154 | * @param block 155 | * the block of code where the the newly configured request is applied 156 | * @param builder 157 | * the current default request, implicitly resolved, defaults to the empty request 158 | */ 159 | def using(config: RequestBuilder => RequestBuilder)(process: RequestBuilder => Unit)(implicit builder: RequestBuilder): Unit = { 160 | process(config(builder)) 161 | } 162 | 163 | implicit class RichResponse(response: Response) { 164 | def returning[T1](ext1: ExtractorLike[T1])(implicit client: HttpClient): T1 = { 165 | ext1.value(response).get 166 | } 167 | 168 | def returning[T1, T2](ext1: ExtractorLike[T1], ext2: ExtractorLike[T2]): (T1, T2) = { 169 | val tryValue = for { 170 | r1 <- ext1.value(response) 171 | r2 <- ext2.value(response) 172 | } yield (r1, r2) 173 | tryValue.get 174 | } 175 | 176 | def returning[T1, T2, T3](ext1: ExtractorLike[T1], ext2: ExtractorLike[T2], ext3: ExtractorLike[T3]): (T1, T2, T3) = { 177 | val tryValue = for { 178 | r1 <- ext1.value(response) 179 | r2 <- ext2.value(response) 180 | r3 <- ext3.value(response) 181 | } yield (r1, r2, r3) 182 | tryValue.get 183 | } 184 | 185 | def returning[T1, T2, T3, T4](ext1: ExtractorLike[T1], ext2: ExtractorLike[T2], ext3: ExtractorLike[T3], ext4: ExtractorLike[T4]): (T1, T2, T3, T4) = { 186 | val tryValue = for { 187 | r1 <- ext1.value(response) 188 | r2 <- ext2.value(response) 189 | r3 <- ext3.value(response) 190 | r4 <- ext4.value(response) 191 | } yield (r1, r2, r3, r4) 192 | tryValue.get 193 | } 194 | } 195 | 196 | implicit def requestBuilderToRichResponse(builder: RequestBuilder)(implicit client: HttpClient): RichResponse = new RichResponse(builder.execute()) 197 | implicit def methodToRichResponse(method: Method)(implicit builder: RequestBuilder, client: HttpClient): RichResponse = new RichResponse(builder.withMethod(method).execute()) 198 | 199 | /** 200 | * Add operator support to `Extractor`s these are used to generate an `Assertion` using the extracted value. 201 | * 202 | * {{{ 203 | * GET url "http://api.rest.org/person" assert (StatusCode === Status.Ok) 204 | * }}} 205 | * 206 | * == Operations == 207 | * 208 | * The following operations are added to all `Extractors` 209 | * 210 | * $ - `extractor === expected` - the extracted value is equal to the `expected` value. 211 | * $ - `extractor !== expected` - the extracted value is not equal to the `expected` value. 212 | * $ - `extractor in (expected1, expected2, ...)` - the extracted value is in the list of expected values. 213 | * $ - `extractor notIn (expected1, expected2, ...)` - the extracted value is in the list of expected values. 214 | * 215 | * The following operations are added to `Extractor`s that support `scala.math.Ordering`. 216 | * More precisely these operations are added to `Extractor[T]` if there exists an implicit 217 | * `Ordering[T]` for any type `T`. 218 | * 219 | * $ - `extractor < expected` - the extracted value is less than the `expected` value. 220 | * $ - `extractor <= expected` - the extracted value is less than or equal to the `expected` value. 221 | * $ - `extractor > expected` - the extracted value is greater than the `expected` value. 222 | * $ - `extractor <= expected` - the extracted value is greater than or equal to the `expected` value. 223 | */ 224 | implicit class RichExtractor[A](ext: ExtractorLike[A]) { 225 | def ===[B >: A](expected: B): Assertion = makeAssertion(_ == expected, expected, "did not equal") 226 | def !==[B >: A](expected: B): Assertion = makeAssertion(_ != expected, expected, "did equal") 227 | 228 | def <[B >: A](expected: B)(implicit ord: math.Ordering[B]): Assertion = makeAssertion(ord.lt(_, expected), expected, "was not less than") 229 | def <=[B >: A](expected: B)(implicit ord: math.Ordering[B]): Assertion = makeAssertion(ord.lteq(_, expected), expected, "was not less than or equal") 230 | def >[B >: A](expected: B)(implicit ord: math.Ordering[B]): Assertion = makeAssertion(ord.gt(_, expected), expected, "was not greater than") 231 | def >=[B >: A](expected: B)(implicit ord: math.Ordering[B]): Assertion = makeAssertion(ord.gteq(_, expected), expected, "was not greater than or equal") 232 | 233 | def in[B >: A](expectedVals: B*): Assertion = makeAssertion(expectedVals.contains(_), expectedVals.mkString("(", ", ", ")"), "was not in") 234 | def notIn[B >: A](expectedVals: B*): Assertion = makeAssertion(!expectedVals.contains(_), expectedVals.mkString("(", ", ", ")"), "was in") 235 | 236 | private def makeAssertion[B](pred: A => Boolean, expected: B, text: String): Assertion = { res => 237 | val actual = ext.value(res) 238 | actual match { 239 | case Success(a) if (!pred(a)) => 240 | Some(s"${ext.name}: $a $text $expected") 241 | case Success(_) => 242 | None 243 | case Failure(e) => 244 | Some(e.getMessage) 245 | } 246 | } 247 | } 248 | } 249 | 250 | object Dsl extends Dsl 251 | -------------------------------------------------------------------------------- /src/test/scala/org/iainhull/resttest/DslSpec.scala: -------------------------------------------------------------------------------- 1 | package org.iainhull.resttest 2 | 3 | import org.scalatest.Suite 4 | import java.net.URI 5 | import org.scalatest.FlatSpec 6 | import org.scalatest.Matchers 7 | import play.api.libs.json._ 8 | 9 | class DslSpec extends FlatSpec with Matchers { 10 | import Dsl._ 11 | import TestData._ 12 | 13 | implicit val driver = TestClient 14 | 15 | "The DSL" should "support a basic rest use case with a RequestBuilder" in { 16 | RequestBuilder().withMethod(GET).withUrl("http://api.rest.org/person/").toRequest should 17 | have('method(GET), 'url(new URI("http://api.rest.org/person/"))) 18 | 19 | RequestBuilder().withMethod(POST).withUrl("http://api.rest.org/person/").withBody(personJson).toRequest should 20 | have('method(POST), 'url(new URI("http://api.rest.org/person/")), 'body(Some(personJson))) 21 | 22 | val id = "myid" 23 | RequestBuilder().withMethod(GET).withUrl("http://api.rest.org/person/").addPath(id).toRequest should 24 | have('method(GET), 'url(new URI("http://api.rest.org/person/myid"))) 25 | 26 | RequestBuilder().withMethod(DELETE).withUrl("http://api.rest.org/person/").addPath(id).toRequest should 27 | have('method(DELETE), 'url(new URI("http://api.rest.org/person/myid"))) 28 | } 29 | 30 | it should "support a basic rest use case, reusing a RequestBuilder" in { 31 | val rb = RequestBuilder().withUrl("http://api.rest.org/person/") 32 | 33 | rb.withMethod(GET).toRequest should 34 | have('method(GET), 'url(new URI("http://api.rest.org/person/"))) 35 | 36 | rb.withMethod(POST).withBody(personJson).toRequest should 37 | have('method(POST), 'url(new URI("http://api.rest.org/person/")), 'body(Some(personJson))) 38 | 39 | val id = "myid" 40 | rb.withMethod(GET).addPath(id).toRequest should 41 | have('method(GET), 'url(new URI("http://api.rest.org/person/myid"))) 42 | 43 | rb.withMethod(DELETE).addPath(id).toRequest should 44 | have('method(DELETE), 'url(new URI("http://api.rest.org/person/myid"))) 45 | } 46 | 47 | it should "support a basic rest use case, with Method boostrapping the DSL and infix notation" in { 48 | (GET withUrl "http://api.rest.org/person/").toRequest should 49 | have('method(GET), 'url(new URI("http://api.rest.org/person/"))) 50 | 51 | (POST withUrl "http://api.rest.org/person/" withBody personJson).toRequest should 52 | have('method(POST), 'url(new URI("http://api.rest.org/person/")), 'body(Some(personJson))) 53 | 54 | val id = "myid" 55 | (GET withUrl "http://api.rest.org/person/" addPath id).toRequest should 56 | have('method(GET), 'url(new URI("http://api.rest.org/person/myid"))) 57 | 58 | (DELETE withUrl "http://api.rest.org/person/" addPath id).toRequest should 59 | have('method(DELETE), 'url(new URI("http://api.rest.org/person/myid"))) 60 | } 61 | 62 | it should "support a basic rest use case, with Method boostrapping the DSL and execute method" in { 63 | GET withUrl "http://api.rest.org/person/" execute () should be(driver.nextResponse) 64 | driver.lastRequest should have('method(GET), 'url(new URI("http://api.rest.org/person/"))) 65 | 66 | POST withUrl "http://api.rest.org/person/" withBody personJson execute () 67 | driver.lastRequest should have('method(POST), 'url(new URI("http://api.rest.org/person/")), 'body(Some(personJson))) 68 | } 69 | 70 | it should "support abstracting common values with codeblocks" in { 71 | RequestBuilder() withUrl "http://api.rest.org/person/" apply { implicit rb => 72 | GET execute () 73 | driver.lastRequest should have('method(GET), 'url(new URI("http://api.rest.org/person/"))) 74 | 75 | POST withBody personJson execute () 76 | driver.lastRequest should have('method(POST), 'url(new URI("http://api.rest.org/person/")), 'body(Some(personJson))) 77 | 78 | val id = "myid" 79 | GET addPath id execute () 80 | driver.lastRequest should have('method(GET), 'url(new URI("http://api.rest.org/person/myid"))) 81 | } 82 | } 83 | 84 | it should "support abstracting common values with nested codeblocks" in { 85 | RequestBuilder() withUrl "http://api.rest.org/person/" apply { implicit rb => 86 | RequestBuilder() addHeaders ("X-Custom-Header" -> "foo") apply { implicit rb => 87 | GET execute () 88 | driver.lastRequest should have('method(GET), 'url(new URI("http://api.rest.org/person/")), 'headers(toHeaders("X-Custom-Header" -> "foo"))) 89 | } 90 | } 91 | } 92 | 93 | it should "support abstracting common values with codeblocks and method aliases" in { 94 | RequestBuilder() url "http://api.rest.org/" apply { implicit rb => 95 | GET / 'person :? ('page -> 2, 'per_page -> 100) execute () 96 | driver.lastRequest should have('method(GET), 'url(new URI("http://api.rest.org/person?page=2&per_page=100"))) 97 | } 98 | } 99 | 100 | it should "support abstracting common values with using method" in { 101 | using(_ url "http://api.rest.org/") { implicit rb => 102 | GET / 'person :? ('page -> 2, 'per_page -> 100) execute () 103 | driver.lastRequest should have('method(GET), 'url(new URI("http://api.rest.org/person?page=2&per_page=100"))) 104 | } 105 | } 106 | 107 | it should "support returning values from the response" in { 108 | using(_ url "http://api.rest.org/person/") { implicit rb => 109 | val (c1, b1) = GET returning (StatusCode, BodyText) 110 | driver.lastRequest should have('method(GET), 'url(new URI("http://api.rest.org/person/"))) 111 | c1 should be(Status.OK) 112 | b1 should be("body") 113 | } 114 | } 115 | 116 | it should "support asserting values equals check" in { 117 | using(_ url "http://api.rest.org/person/") { implicit rb => 118 | GET asserting (StatusCode === Status.OK) 119 | driver.lastRequest should have('method(GET), 'url(new URI("http://api.rest.org/person/"))) 120 | 121 | GET asserting (Header("X-Person-Id") === "1234") 122 | 123 | val e = the[AssertionError] thrownBy { GET asserting (StatusCode === Status.Created) } 124 | e should have('message("StatusCode: 200 did not equal 201")) 125 | driver.lastRequest should have('method(GET), 'url(new URI("http://api.rest.org/person/"))) 126 | } 127 | } 128 | 129 | it should "support asserting values not-equals check" in { 130 | using(_ url "http://api.rest.org/person/") { implicit rb => 131 | GET asserting (StatusCode !== Status.Created) 132 | driver.lastRequest should have('method(GET), 'url(new URI("http://api.rest.org/person/"))) 133 | 134 | GET asserting (Header("X-Person-Id") !== "999") 135 | 136 | val e = the[AssertionError] thrownBy { GET asserting (StatusCode !== Status.OK) } 137 | e should have('message("StatusCode: 200 did equal 200")) 138 | } 139 | } 140 | 141 | it should "support asserting values in check" in { 142 | using(_ url "http://api.rest.org/person/") { implicit rb => 143 | GET asserting (StatusCode in (Status.OK, Status.Created)) 144 | driver.lastRequest should have('method(GET), 'url(new URI("http://api.rest.org/person/"))) 145 | 146 | val e = the[AssertionError] thrownBy { GET asserting (StatusCode in (Status.Created, Status.Accepted)) } 147 | e should have('message("StatusCode: 200 was not in (201, 202)")) 148 | } 149 | } 150 | 151 | it should "support asserting values Ordered comparison operator checks" in { 152 | using(_ url "http://api.rest.org/person/") { implicit rb => 153 | GET asserting (StatusCode > 1) 154 | GET asserting (StatusCode >= 1) 155 | GET asserting (StatusCode < 299) 156 | GET asserting (StatusCode <= 299) 157 | 158 | val e = the[AssertionError] thrownBy { GET asserting (StatusCode > 999) } 159 | e should have('message("StatusCode: 200 was not greater than 999")) 160 | driver.lastRequest should have('method(GET), 'url(new URI("http://api.rest.org/person/"))) 161 | } 162 | } 163 | 164 | it should "support 'using' function to abstract common parameters in a readable way" in { 165 | import JsonExtractors._ 166 | using(_ url "http://api.rest.org/person/") { implicit rb => 167 | GET asserting (StatusCode === Status.OK) 168 | driver.lastRequest should have('method(GET), 'url(new URI("http://api.rest.org/person/"))) 169 | 170 | val e = the[AssertionError] thrownBy { GET asserting (StatusCode === Status.Created) } 171 | e should have('message("StatusCode: 200 did not equal 201")) 172 | driver.lastRequest should have('method(GET), 'url(new URI("http://api.rest.org/person/"))) 173 | } 174 | } 175 | 176 | /** 177 | * These use-cases do not contain any asserts they are simply use to show 178 | * the DSL supports various forms of syntax. If they compile they work. 179 | * The workings of the DSL are checked above, those tests verify that the 180 | * functionality of the DSL works as expected, but are not as easy to read 181 | * Each test starts with a use-case to verify the syntax which is then ported 182 | * to a test above to verify the functionality. 183 | */ 184 | "Sample use-case" should "support a basic rest use case with a RequestBuilder" in { 185 | val r1 = TestClient(RequestBuilder().withMethod(GET).withUrl("http://api.rest.org/person/")) 186 | val r2 = TestClient(RequestBuilder().withMethod(POST).withUrl("http://api.rest.org/person/").withBody(personJson)) 187 | val id = r2.headers.get("X-Person-Id").get.head 188 | val r3 = TestClient(RequestBuilder().withMethod(GET).withUrl("http://api.rest.org/person/").addPath(id)) 189 | val r4 = TestClient(RequestBuilder().withMethod(GET).withUrl("http://api.rest.org/person/")) 190 | val r5 = TestClient(RequestBuilder().withMethod(DELETE).withUrl("http://api.rest.org/person/").addPath(id)) 191 | val r6 = TestClient(RequestBuilder().withMethod(GET).withUrl("http://api.rest.org/person/")) 192 | } 193 | 194 | it should "support a basic rest use case, reusing a RequestBuilder" in { 195 | val rb = RequestBuilder().withUrl("http://api.rest.org/person/") 196 | val r1 = TestClient(rb.withMethod(GET)) 197 | val r2 = TestClient(rb.withMethod(POST).withBody(personJson)) 198 | val id = r2.headers.get("X-Person-Id").get.head 199 | val r3 = TestClient(rb.withMethod(GET).addPath(id)) 200 | val r4 = TestClient(rb.withMethod(GET)) 201 | val r5 = TestClient(rb.withMethod(DELETE).addPath(id)) 202 | val r6 = TestClient(rb.withMethod(GET)) 203 | } 204 | 205 | it should "support a basic rest use case, with Method boostrapping the DSL and infix notation" in { 206 | val r1 = TestClient(GET withUrl "http://api.rest.org/person/") 207 | val r2 = TestClient(POST withUrl "http://api.rest.org/person/" withBody personJson) 208 | val id = r2.headers.get("X-Person-Id").get.head 209 | val r3 = TestClient(GET withUrl "http://api.rest.org/person/" addPath id) 210 | val r4 = TestClient(GET withUrl "http://api.rest.org/person/") 211 | val r5 = TestClient(DELETE withUrl "http://api.rest.org/person/" addPath id) 212 | val r6 = TestClient(GET withUrl "http://api.rest.org/person/") 213 | } 214 | 215 | it should "support a basic rest use case, with Method boostrapping the DSL and execute method" in { 216 | val r1 = GET withUrl "http://api.rest.org/person/" execute () 217 | val r2 = POST withUrl "http://api.rest.org/person/" withBody personJson execute () 218 | val id = r2.headers.get("X-Person-Id").get.head 219 | val r3 = GET withUrl "http://api.rest.org/person/" addPath id execute () 220 | val r4 = GET withUrl "http://api.rest.org/person/" execute () 221 | val r5 = DELETE withUrl "http://api.rest.org/person/" addPath id execute () 222 | val r6 = GET withUrl "http://api.rest.org/person/" execute () 223 | } 224 | 225 | it should "support abstracting common values with codeblocks" in { 226 | RequestBuilder() withUrl "http://api.rest.org/person/" addHeaders 227 | ("Content-Type" -> "application/json") apply { implicit rb => 228 | val r1 = GET execute () 229 | val r2 = POST withBody personJson execute () 230 | val id = r2.headers("X-Person-Id").head 231 | val r3 = GET addPath id execute () 232 | val r4 = GET execute () 233 | val r5 = DELETE addPath id execute () 234 | val r6 = GET execute () 235 | } 236 | } 237 | 238 | it should "support abstracting common values with codeblocks and method aliases" in { 239 | RequestBuilder() url "http://api.rest.org/" apply { implicit rb => 240 | val r1 = GET / 'person execute () 241 | val r2 = POST / 'person body personJson execute () 242 | val id = r2.headers("X-Person-Id").head 243 | val r3 = GET / 'person / id execute () 244 | val r4 = GET / 'person execute () 245 | val r5 = DELETE / 'person / id execute () 246 | val r6 = GET / 'person :? ('page -> 2, 'per_page -> 100) execute () 247 | } 248 | } 249 | 250 | it should "support shorter names for common builder methods" in { 251 | RequestBuilder() url "http://api.rest.org/" apply { implicit rb => 252 | val r1 = GET / 'person execute () 253 | val r2 = POST / 'person body personJson execute () 254 | val id = r2.headers("X-Person-Id").head 255 | val r3 = GET / 'person / id execute () 256 | val r4 = GET / 'person execute () 257 | val r5 = DELETE / 'person / id execute () 258 | val r6 = GET / 'person :? ('page -> 2, 'per_page -> 100) execute () 259 | } 260 | } 261 | 262 | it should "support returning values from the response" in { 263 | RequestBuilder() url "http://api.rest.org/person/" apply { implicit rb => 264 | val (c1, b1) = GET returning (StatusCode, Body) 265 | val (c2, id) = POST body personJson returning (StatusCode, Header("X-Person-Id")) 266 | val (c3, b3) = GET / id returning (StatusCode, Body) 267 | val (c4, b4) = GET returning (StatusCode, Body) 268 | val c5 = DELETE / id returning StatusCode 269 | val (c6, b6) = GET returning (StatusCode, Body) 270 | } 271 | } 272 | 273 | it should "support asserting on values from the response" in { 274 | import JsonExtractors._ 275 | val EmptyList = Seq() 276 | 277 | driver.responses = Response(Status.OK, Map(), Some("[]")) :: 278 | Response(Status.Created, toHeaders("X-Person-Id" -> "99"), None) :: 279 | Response(Status.OK, Map(), Some(personJson)) :: 280 | Response(Status.OK, Map(), Some("[" + personJson + "]")) :: 281 | Response(Status.OK, Map(), None) :: 282 | Response(Status.NotFound, Map(), None) :: 283 | Response(Status.OK, Map(), Some("[]")) :: 284 | Nil 285 | 286 | RequestBuilder() url "http://api.rest.org/person" apply { implicit rb => 287 | GET asserting (StatusCode === Status.OK, jsonBodyAsList[Person] === EmptyList) 288 | val id = POST body personJson asserting (StatusCode === Status.Created) returning (Header("X-Person-Id")) 289 | GET / id asserting (StatusCode === Status.OK, jsonBodyAs[Person] === Jason) 290 | GET asserting (StatusCode === Status.OK, jsonBodyAsList[Person] === Seq(Jason)) 291 | DELETE / id asserting (StatusCode === Status.OK) 292 | GET / id asserting (StatusCode === Status.NotFound) 293 | GET asserting (StatusCode === Status.OK, jsonBodyAsList[Person] === EmptyList) 294 | } 295 | } 296 | 297 | it should "support expecting on values from the response" in { 298 | import JsonExtractors._ 299 | val EmptyList = Seq() 300 | 301 | driver.responses = Response(Status.OK, Map(), Some("[]")) :: 302 | Response(Status.Created, toHeaders("X-Person-Id" -> "99"), None) :: 303 | Response(Status.OK, Map(), Some(personJson)) :: 304 | Response(Status.OK, Map(), Some("[" + personJson + "]")) :: 305 | Response(Status.OK, Map(), None) :: 306 | Response(Status.NotFound, Map(), None) :: 307 | Response(Status.OK, Map(), Some("[]")) :: 308 | Nil 309 | 310 | val BodyAsPersonList = jsonBodyAsList[Person] 311 | val BodyAsPerson = jsonBodyAs[Person] 312 | val PersonIdHeader = Header("X-Person-Id") 313 | 314 | using(_ url "http://api.rest.org/person") { implicit rb => 315 | GET expecting { 316 | case StatusCode(Status.OK) & BodyAsPersonList(EmptyList) => 317 | } 318 | val id = POST body personJson expecting { 319 | case StatusCode(Status.Created) & PersonIdHeader(id) => id 320 | } 321 | GET / id expecting { 322 | case StatusCode(Status.OK) & BodyAsPerson(p) => p should be(Jason) 323 | } 324 | GET expecting { 325 | case StatusCode(Status.OK) & BodyAsPersonList(xp) => xp should be(Seq(Jason)) 326 | } 327 | DELETE / id expecting { 328 | case StatusCode(Status.OK) => 329 | } 330 | GET / id expecting { 331 | case StatusCode(Status.NotFound) => 332 | } 333 | GET expecting { 334 | case StatusCode(Status.OK) & BodyAsPersonList(EmptyList) => 335 | } 336 | } 337 | } 338 | 339 | it should "support 'using' function to abstract common parameters in a readable way" in { 340 | import JsonExtractors._ 341 | val EmptyList = Seq() 342 | 343 | driver.responses = Response(Status.OK, Map(), Some("[]")) :: 344 | Response(Status.Created, toHeaders("X-Person-Id" -> "99"), None) :: 345 | Response(Status.OK, Map(), Some(personJson)) :: 346 | Response(Status.OK, Map(), Some("[" + personJson + "]")) :: 347 | Response(Status.OK, Map(), None) :: 348 | Response(Status.NotFound, Map(), None) :: 349 | Response(Status.OK, Map(), Some("[]")) :: 350 | Nil 351 | 352 | using(_ url "http://api.rest.org/person") { implicit rb => 353 | GET asserting (StatusCode === Status.OK, jsonBodyAsList[Person] === EmptyList) 354 | val id = POST body personJson asserting (StatusCode === Status.Created) returning (Header("X-Person-Id")) 355 | GET / id asserting (StatusCode === Status.OK, jsonBodyAs[Person] === Jason) 356 | GET asserting (StatusCode === Status.OK, jsonBodyAsList[Person] === Seq(Jason)) 357 | DELETE / id asserting (StatusCode === Status.OK) 358 | GET / id asserting (StatusCode === Status.NotFound) 359 | GET asserting (StatusCode === Status.OK, jsonBodyAsList[Person] === EmptyList) 360 | } 361 | } 362 | } --------------------------------------------------------------------------------