From 7e44e96c487ca60301d2d6f56bc9774c54781ab9 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Fri, 29 Apr 2016 19:59:00 +0200 Subject: [PATCH 01/39] experiment with circe only diff method is missing for complete migration --- build.sbt | 13 +- .../agourlay/cornichon/core/Errors.scala | 10 +- .../agourlay/cornichon/core/Resolver.scala | 6 +- .../agourlay/cornichon/core/Session.scala | 3 +- .../agourlay/cornichon/dsl/DataTable.scala | 18 +- .../cornichon/http/HttpAssertions.scala | 103 +++--- .../agourlay/cornichon/http/HttpDsl.scala | 5 +- .../cornichon/http/HttpDslErrors.scala | 6 +- .../agourlay/cornichon/http/HttpEffects.scala | 9 +- .../agourlay/cornichon/http/HttpService.scala | 6 +- .../http/client/AkkaHttpClient.scala | 30 +- .../cornichon/http/client/HttpClient.scala | 8 +- .../cornichon/json/CornichonJson.scala | 111 +++--- .../agourlay/cornichon/json/JsonErrors.scala | 4 +- .../agourlay/cornichon/json/JsonPath.scala | 48 ++- .../cornichon/dsl/DataTableSpec.scala | 24 +- .../superHeroes/SuperHeroesScenario.scala | 7 +- .../cornichon/json/CornichonJsonSpec.scala | 328 +++++++++++++----- 18 files changed, 488 insertions(+), 251 deletions(-) diff --git a/build.sbt b/build.sbt index 98ed91a04..3cbffb580 100644 --- a/build.sbt +++ b/build.sbt @@ -20,7 +20,7 @@ scalacOptions ++= Seq( "-Ywarn-unused-import" ) -fork in Test := true +fork in Test := false SbtScalariform.scalariformSettings @@ -34,19 +34,18 @@ libraryDependencies ++= { val scalaTestV = "2.2.6" val akkaHttpV = "2.4.4" val catsV = "0.4.1" - val sprayJsonV = "1.3.2" val json4sV = "3.3.0" val logbackV = "1.1.7" val parboiledV = "2.1.2" val akkaSseV = "1.7.3" val scalacheckV = "1.12.5" val sangriaV = "0.6.2" - val sangriaJsonV = "0.3.0" + val sangriaCirceV = "0.4.3" + val circeVersion = "0.4.1" Seq( "com.typesafe.akka" %% "akka-http-core" % akkaHttpV ,"de.heikoseeberger" %% "akka-sse" % akkaSseV ,"org.json4s" %% "json4s-jackson" % json4sV - ,"io.spray" %% "spray-json" % sprayJsonV ,"org.typelevel" %% "cats-macros" % catsV ,"org.typelevel" %% "cats-core" % catsV ,"org.scalatest" %% "scalatest" % scalaTestV @@ -54,7 +53,11 @@ libraryDependencies ++= { ,"org.parboiled" %% "parboiled" % parboiledV ,"org.scalacheck" %% "scalacheck" % scalacheckV ,"org.sangria-graphql" %% "sangria" % sangriaV - ,"org.sangria-graphql" %% "sangria-json4s-jackson" % sangriaJsonV + ,"org.sangria-graphql" %% "sangria-circe" % sangriaCirceV + ,"io.circe" %% "circe-core" % circeVersion + ,"io.circe" %% "circe-generic" % circeVersion + ,"io.circe" %% "circe-parser" % circeVersion + ,"io.circe" %% "circe-optics" % circeVersion ,"com.typesafe.akka" %% "akka-http-spray-json-experimental" % akkaHttpV % "test" ,"com.typesafe.akka" %% "akka-http-experimental" % akkaHttpV % "test" ) diff --git a/src/main/scala/com/github/agourlay/cornichon/core/Errors.scala b/src/main/scala/com/github/agourlay/cornichon/core/Errors.scala index e7153e35e..341b675d7 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/Errors.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/Errors.scala @@ -2,8 +2,8 @@ package com.github.agourlay.cornichon.core import java.io.{ PrintWriter, StringWriter } -import org.json4s._ import com.github.agourlay.cornichon.json.CornichonJson._ +import io.circe.Json import scala.util.control.NoStackTrace @@ -40,13 +40,13 @@ case class StepAssertionError[A](expected: A, actual: A, negate: Boolean) extend // TODO Introduce Show typeclass to display objects nicely val msg = actual match { case s: String ⇒ baseMsg - case j: JValue ⇒ + case j: Json ⇒ s"""|expected result was${if (negate) " different than:" else ":"} - |'${prettyPrint(expected.asInstanceOf[JValue])}' + |'${prettyPrint(expected.asInstanceOf[Json])}' |but actual result is: - |'${prettyPrint(actual.asInstanceOf[JValue])}' + |'${prettyPrint(actual.asInstanceOf[Json])}' |diff: - |${prettyDiff(j, expected.asInstanceOf[JValue])} + |${prettyDiff(j, expected.asInstanceOf[Json])} """.stripMargin.trim case j: Seq[A] ⇒ s"$baseMsg \n Seq diff is '${j.diff(expected.asInstanceOf[Seq[A]]).mkString(", ")}'" case j: Set[A] ⇒ s"$baseMsg \n Set diff is '${j.diff(expected.asInstanceOf[Set[A]]).mkString(", ")}'" diff --git a/src/main/scala/com/github/agourlay/cornichon/core/Resolver.scala b/src/main/scala/com/github/agourlay/cornichon/core/Resolver.scala index b6c1ff989..36013603f 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/Resolver.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/Resolver.scala @@ -38,10 +38,12 @@ class Resolver(extractors: Map[String, Mapper]) { case TextMapper(key, transform) ⇒ session.getXor(key, ph.index).map(transform) case JsonMapper(key, jsonPath, transform) ⇒ - session.getXor(key, ph.index).map { sessionValue ⇒ + session.getXor(key, ph.index).flatMap { sessionValue ⇒ // No placeholders in JsonMapper for now to avoid people running into infinite recursion // Could be enabled if there is a use case for it. - transform(JsonPath.run(jsonPath, sessionValue).values.toString) + JsonPath.run(jsonPath, sessionValue) + .map(_.noSpaces) + .map(transform) } } } diff --git a/src/main/scala/com/github/agourlay/cornichon/core/Session.scala b/src/main/scala/com/github/agourlay/cornichon/core/Session.scala index ff1c630f9..3062a6976 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/Session.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/Session.scala @@ -25,7 +25,8 @@ case class Session(content: Map[String, Vector[String]]) extends CornichonJson { def getXor(key: String, stackingIndice: Option[Int] = None) = Xor.fromOption(getOpt(key, stackingIndice), KeyNotFoundInSession(key, this)) - def getJson(key: String, stackingIndice: Option[Int] = None) = parseJson(get(key, stackingIndice)) + def getJson(key: String, stackingIndice: Option[Int] = None) = + parseJson(get(key, stackingIndice)).fold(e ⇒ throw e, identity) def getJsonOpt(key: String, stackingIndice: Option[Int] = None) = getOpt(key, stackingIndice).map(parseJson) diff --git a/src/main/scala/com/github/agourlay/cornichon/dsl/DataTable.scala b/src/main/scala/com/github/agourlay/cornichon/dsl/DataTable.scala index 1062aa750..428855140 100644 --- a/src/main/scala/com/github/agourlay/cornichon/dsl/DataTable.scala +++ b/src/main/scala/com/github/agourlay/cornichon/dsl/DataTable.scala @@ -1,7 +1,7 @@ package com.github.agourlay.cornichon.dsl +import io.circe.{ Json, JsonObject } import org.parboiled2._ -import spray.json.{ JsArray, JsObject, JsValue } import scala.util.{ Failure, Success } @@ -26,11 +26,10 @@ class DataTableParser(val input: ParserInput) extends Parser { optional(NL) ~ HeaderRule ~ NL ~ oneOrMore(RowRule).separatedBy(NL) ~ optional(NL) ~ EOI ~> DataTable } - import spray.json._ - def HeaderRule = rule { Separator ~ oneOrMore(HeaderTXT).separatedBy(Separator) ~ Separator ~> Headers } - def RowRule = rule { Separator ~ oneOrMore(TXT).separatedBy(Separator) ~ Separator ~> (x ⇒ Row(x.map(_.parseJson))) } + //fix me - should not swallow error + def RowRule = rule { Separator ~ oneOrMore(TXT).separatedBy(Separator) ~ Separator ~> (x ⇒ Row(x.map(io.circe.parser.parse(_).getOrElse(Json.Null)))) } val delims = s"$delimeter\r\n" @@ -49,18 +48,17 @@ class DataTableParser(val input: ParserInput) extends Parser { case class DataTable(headers: Headers, rows: Seq[Row]) { require(rows.forall(_.fields.size == headers.fields.size), "Datatable is malformed, all rows must have the same number of elements") - def asSprayMap: Map[String, Seq[JsValue]] = + def asMap: Map[String, Seq[Json]] = headers.fields.zipWithIndex.map { case (header, index) ⇒ header → rows.map(r ⇒ r.fields(index)) }.groupBy(_._1).map { case (k, v) ⇒ (k, v.flatMap(_._2)) } - def asSprayJson: JsArray = { - val map = asSprayMap - val tmp = for (i ← rows.indices) yield map.map { case (k, v) ⇒ k → v(i) } - JsArray(tmp.map(JsObject(_)).toVector) + def objectList: List[JsonObject] = { + val tmp = for (i ← rows.indices) yield asMap.map { case (k, v) ⇒ k → v(i) } + tmp.map(JsonObject.fromMap).toList } } case class Headers(fields: Seq[String]) -case class Row(fields: Seq[JsValue]) \ No newline at end of file +case class Row(fields: Seq[Json]) \ No newline at end of file diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala index 045a24278..835143455 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala @@ -8,7 +8,7 @@ import com.github.agourlay.cornichon.http.HttpService._ import com.github.agourlay.cornichon.json.CornichonJson._ import com.github.agourlay.cornichon.json.{ JsonPath, NotAnArrayError, WhiteListError } import com.github.agourlay.cornichon.steps.regular.AssertStep -import org.json4s._ +import io.circe.Json object HttpAssertions { @@ -69,7 +69,7 @@ object HttpAssertions { override def inOrder: HeadersAssertion = copy(ordered = true) } - case class BodyAssertion[A](private val jsonPath: String, private val ignoredKeys: Seq[String], whiteList: Boolean = false, resolver: Resolver) extends AssertionStep[A, JValue] { + case class BodyAssertion[A](private val jsonPath: String, private val ignoredKeys: Seq[String], whiteList: Boolean = false, resolver: Resolver) extends AssertionStep[A, Json] { def path(path: String): BodyAssertion[A] = copy(jsonPath = path) @@ -77,7 +77,7 @@ object HttpAssertions { def whiteListing: BodyAssertion[A] = copy(whiteList = true) - override def is(expected: A): AssertStep[JValue] = { + override def is(expected: A): AssertStep[Json] = { if (whiteList && ignoredKeys.nonEmpty) throw InvalidIgnoringConfigError else { @@ -91,9 +91,9 @@ object HttpAssertions { if (whiteList) { val expectedJson = resolveParseJson(expected, session, resolver) val sessionValueJson = resolveRunJsonPath(jsonPath, sessionValue, resolver)(session) - val Diff(changed, _, deleted) = expectedJson.diff(sessionValueJson) - if (deleted != JNothing) throw new WhiteListError(s"White list error - '${prettyPrint(deleted)}' is not defined in object '${prettyPrint(sessionValueJson)}") - if (changed != JNothing) changed else expectedJson + val Diff(changed, _, deleted) = diff(expectedJson, sessionValueJson) + if (deleted != Json.Null) throw new WhiteListError(s"White list error - '${prettyPrint(deleted)}' is not defined in object '${prettyPrint(sessionValueJson)}") + if (changed != Json.Null) changed else expectedJson } else { val subJson = resolveRunJsonPath(jsonPath, sessionValue, resolver)(session) if (ignoredKeys.isEmpty) subJson @@ -117,8 +117,8 @@ object HttpAssertions { (session, sessionValue) ⇒ { val subJson = resolveRunJsonPath(jsonPath, sessionValue, resolver)(session) val predicate = subJson match { - case JNothing | JNull ⇒ true - case _ ⇒ false + case Json.Null ⇒ true + case _ ⇒ false } (predicate, keyIsPresentError(jsonPath, prettyPrint(subJson))) } @@ -135,24 +135,28 @@ object HttpAssertions { (session, sessionValue) ⇒ { val subJson = resolveRunJsonPath(jsonPath, sessionValue, resolver)(session) val predicate = subJson match { - case JNothing | JNull ⇒ false - case _ ⇒ true + case Json.Null ⇒ false + case _ ⇒ true } - (predicate, keyIsAbsentError(jsonPath, prettyPrint(parseJson(sessionValue)))) + (predicate, keyIsAbsentError(jsonPath, prettyPrint(parseJsonUnsafe(sessionValue)))) } ) } - def asArray = BodyArrayAssertion[A](jsonPath, ordered = false, ignoredKeys, resolver) + def asArray = + if (ignoredKeys.nonEmpty) + throw UseIgnoringEach + else + BodyArrayAssertion[A](jsonPath, ordered = false, ignoredKeys, resolver) } - case class BodyArrayAssertion[A](private val jsonPath: String, ordered: Boolean, private val ignoredKeys: Seq[String], resolver: Resolver) extends AssertionStep[A, Iterable[JValue]] { + case class BodyArrayAssertion[A](private val jsonPath: String, ordered: Boolean, private val ignoredKeys: Seq[String], resolver: Resolver) extends AssertionStep[A, Iterable[Json]] { def path(path: String): BodyArrayAssertion[A] = copy(jsonPath = path) def inOrder: BodyArrayAssertion[A] = copy(ordered = true) - def ignoring(ignoring: String*): BodyArrayAssertion[A] = copy(ignoredKeys = ignoring) + def ignoringEach(ignoring: String*): BodyArrayAssertion[A] = copy(ignoredKeys = ignoring) def hasSize(size: Int): AssertStep[Int] = { val title = if (jsonPath == JsonPath.root) s"response body array size is '$size'" else s"response body's array '$jsonPath' size is '$size'" @@ -161,17 +165,23 @@ object HttpAssertions { key = LastResponseBodyKey, expected = s ⇒ size, mapValue = (s, sessionValue) ⇒ { - val jArray = if (jsonPath == JsonPath.root) parseArray(sessionValue) - else { - val parsedPath = resolveParseJsonPath(jsonPath, resolver)(s) - selectArrayJsonPath(parsedPath, sessionValue) + val jArray = { + if (jsonPath == JsonPath.root) + parseArray(sessionValue) + else { + val parsedPath = resolveParseJsonPath(jsonPath, resolver)(s) + selectArrayJsonPath(parsedPath, sessionValue) + } } - (jArray.arr.size, arraySizeError(size, prettyPrint(jArray))) + jArray.fold( + e ⇒ throw e, + l ⇒ (l.size, arraySizeError(size, prettyPrint(Json.fromValues(l)))) + ) } ) } - override def is(expected: A): AssertStep[Iterable[JValue]] = { + override def is(expected: A): AssertStep[Iterable[Json]] = { val assertionTitle = { val expectedSentence = if (ordered) s"in order is '$expected'" else s"is '$expected'" val titleString = if (jsonPath == JsonPath.root) @@ -181,12 +191,12 @@ object HttpAssertions { titleBuilder(titleString, ignoredKeys) } - def removeIgnoredPathFromElements(s: Session, jArray: JArray) = { + def removeIgnoredPathFromElements(s: Session, jArray: List[Json]) = { val ignoredPaths = ignoredKeys.map(resolveParseJsonPath(_, resolver)(s)) - jArray.arr.map(removeFieldsByPath(_, ignoredPaths)) + jArray.map(removeFieldsByPath(_, ignoredPaths)) } - def removeIgnoredPathFromElementsSet(s: Session, jArray: JArray) = removeIgnoredPathFromElements(s, jArray).toSet + def removeIgnoredPathFromElementsSet(s: Session, jArray: List[Json]) = removeIgnoredPathFromElements(s, jArray).toSet //TODO remove duplication between Array and Set base comparation if (ordered) @@ -195,10 +205,11 @@ object HttpAssertions { mapFct = removeIgnoredPathFromElements, title = assertionTitle, expected = s ⇒ { - resolveParseJson(expected, s, resolver) match { - case expectedArray: JArray ⇒ expectedArray.arr - case _ ⇒ throw new NotAnArrayError(expected) - } + resolveParseJson(expected, s, resolver).arrayOrObject( + throw new NotAnArrayError(expected), + values ⇒ values, + obj ⇒ throw new NotAnArrayError(expected) + ) } ) else @@ -207,10 +218,11 @@ object HttpAssertions { mapFct = removeIgnoredPathFromElementsSet, title = assertionTitle, expected = s ⇒ { - resolveParseJson(expected, s, resolver) match { - case expectedArray: JArray ⇒ expectedArray.arr.toSet - case _ ⇒ throw new NotAnArrayError(expected) - } + resolveParseJson(expected, s, resolver).arrayOrObject( + throw new NotAnArrayError(expected), + values ⇒ values.toSet, + obj ⇒ throw new NotAnArrayError(expected) + ) } ) } @@ -225,23 +237,23 @@ object HttpAssertions { mapValue = (s, sessionValue) ⇒ { val jArr = applyPathAndFindArray(jsonPath, resolver)(s, sessionValue) val resolvedJson = elements.map(resolveParseJson(_, s, resolver)) - val containsAll = resolvedJson.forall(jArr.arr.contains) - (containsAll, arrayDoesNotContainError(resolvedJson.map(prettyPrint), prettyPrint(jArr))) + val containsAll = resolvedJson.forall(jArr.contains) + (containsAll, arrayDoesNotContainError(resolvedJson.map(prettyPrint), prettyPrint(Json.fromValues(jArr)))) } ) } } - private def applyPathAndFindArray(path: String, resolver: Resolver)(s: Session, sessionValue: String): JArray = { + private def applyPathAndFindArray(path: String, resolver: Resolver)(s: Session, sessionValue: String): List[Json] = { val jArr = if (path == JsonPath.root) parseArray(sessionValue) else { val parsedPath = resolveParseJsonPath(path, resolver)(s) selectArrayJsonPath(parsedPath, sessionValue) } - jArr + jArr.fold(e ⇒ throw e, identity) } - private def body_array_transform[A](arrayExtractor: (Session, String) ⇒ JArray, mapFct: (Session, JArray) ⇒ A, title: String, expected: Session ⇒ A): AssertStep[A] = + private def body_array_transform[A](arrayExtractor: (Session, String) ⇒ List[Json], mapFct: (Session, List[Json]) ⇒ A, title: String, expected: Session ⇒ A): AssertStep[A] = from_session_step[A]( title = title, key = LastResponseBodyKey, @@ -259,21 +271,18 @@ object HttpAssertions { else s"$baseWithWhite ignoring keys ${ignoring.mkString(", ")}" } - private def resolveParseJson[A](input: A, session: Session, resolver: Resolver): JValue = - parseJsonUnsafe { + private def resolveParseJson[A](input: A, session: Session, resolver: Resolver): Json = + parseJson { input match { case string: String ⇒ resolver.fillPlaceholdersUnsafe(string)(session).asInstanceOf[A] case _ ⇒ input } - } + }.fold(e ⇒ throw e, identity) - private def resolveParseJsonPath(path: String, resolver: Resolver)(s: Session): JsonPath = { - val resolvedPath = resolver.fillPlaceholdersUnsafe(path)(s) - JsonPath.parse(resolvedPath) - } + private def resolveParseJsonPath(path: String, resolver: Resolver)(s: Session) = + resolver.fillPlaceholders(path)(s).map(JsonPath.parse).fold(e ⇒ throw e, identity) + + private def resolveRunJsonPath(path: String, source: String, resolver: Resolver)(s: Session): Json = + resolver.fillPlaceholders(path)(s).flatMap(JsonPath.run(_, source)).fold(e ⇒ throw e, identity) - private def resolveRunJsonPath(path: String, source: String, resolver: Resolver)(s: Session): JValue = { - val resolvedPath = resolver.fillPlaceholdersUnsafe(path)(s) - JsonPath.run(resolvedPath, source) - } } diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpDsl.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpDsl.scala index 855b4b64a..94322021d 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpDsl.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpDsl.scala @@ -63,7 +63,8 @@ trait HttpDsl extends Dsl { val inputs = args.map { case (path, target) ⇒ FromSessionSetter(LastResponseBodyKey, (session, s) ⇒ { val resolvedPath = resolver.fillPlaceholdersUnsafe(path)(session) - JsonPath.parse(resolvedPath).run(s).values.toString + // fixme cleanup of quotes + JsonPath.parse(resolvedPath).run(s).fold(e ⇒ throw e, json ⇒ prettyPrint(json).tail.init.toString) }, target) } save_from_session(inputs) @@ -77,7 +78,7 @@ trait HttpDsl extends Dsl { def show_last_response_headers = show_session(LastResponseHeadersKey) - def show_key_as_json(key: String) = show_session(key, v ⇒ prettyPrint(parseJson(v))) + def show_key_as_json(key: String) = show_session(key, v ⇒ parseJson(v).fold(e ⇒ throw e, prettyPrint(_))) def WithBasicAuth(userName: String, password: String) = WithHeaders(("Authorization", "Basic " + Base64.getEncoder.encodeToString(s"$userName:$password".getBytes(StandardCharsets.UTF_8)))) diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpDslErrors.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpDslErrors.scala index 84648b19e..56ecace31 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpDslErrors.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpDslErrors.scala @@ -7,7 +7,7 @@ object HttpDslErrors { def statusError(expected: Int, body: String): Int ⇒ String = actual ⇒ { s"""expected '$expected' but actual is '$actual' with response body: - |${prettyPrint(parseJson(body))}""".stripMargin + |${prettyPrint(parseJsonUnsafe(body))}""".stripMargin } def arraySizeError(expected: Int, sourceArray: String): Int ⇒ String = actual ⇒ { @@ -41,4 +41,8 @@ object HttpDslErrors { val msg = "usage of 'ignoring' and 'whiteListing' is mutually exclusive" } + case object UseIgnoringEach extends CornichonError { + val msg = "use 'ignoringEach' when asserting on a body as an array" + } + } diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala index 00ef2e70c..2d2ca47a0 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala @@ -98,15 +98,12 @@ object HttpEffects { def withVariables(newVariables: (String, String)*) = copy(variables = variables.fold(Some(newVariables.toMap))(v ⇒ Some(v ++ newVariables))).buildBody() def buildBody() = { - - import org.json4s.Extraction - import org.json4s.FieldSerializer - - implicit val formats = org.json4s.DefaultFormats + FieldSerializer[GqlPayload]() + import io.circe.generic.auto._ + import io.circe.syntax._ val queryDoc = query.source.getOrElse(QueryRenderer.render(query, QueryRenderer.Pretty)) val newPayload = GqlPayload(queryDoc, operationName, variables) - copy(payload = prettyPrint(Extraction.decompose(newPayload))) + copy(payload = prettyPrint(newPayload.asJson)) } } diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala index 475faacf7..f435e2fac 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala @@ -8,7 +8,7 @@ import cats.data.Xor.{ left, right } import com.github.agourlay.cornichon.core._ import com.github.agourlay.cornichon.http.client.HttpClient import com.github.agourlay.cornichon.json.CornichonJson -import org.json4s._ +import io.circe.Json import scala.concurrent.duration._ @@ -16,14 +16,14 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC import com.github.agourlay.cornichon.http.HttpService._ - private type WithPayloadCall = (JValue, String, Seq[(String, String)], Seq[HttpHeader], FiniteDuration) ⇒ Xor[HttpError, CornichonHttpResponse] + private type WithPayloadCall = (Json, String, Seq[(String, String)], Seq[HttpHeader], FiniteDuration) ⇒ Xor[HttpError, CornichonHttpResponse] private type WithoutPayloadCall = (String, Seq[(String, String)], Seq[HttpHeader], FiniteDuration) ⇒ Xor[HttpError, CornichonHttpResponse] private def withPayload(call: WithPayloadCall, payload: String, url: String, params: Seq[(String, String)], headers: Seq[(String, String)], extractor: Option[String], requestTimeout: FiniteDuration, expect: Option[Int])(s: Session) = for { payloadResolved ← resolver.fillPlaceholders(payload)(s) - json ← parseJsonXor(payloadResolved) + json ← parseJson(payloadResolved) r ← resolveCommonRequestParts(url, params, headers)(s) resp ← call(json, r._1, r._2, r._3, requestTimeout) newSession ← handleResponse(resp, expect, extractor)(s) diff --git a/src/main/scala/com/github/agourlay/cornichon/http/client/AkkaHttpClient.scala b/src/main/scala/com/github/agourlay/cornichon/http/client/AkkaHttpClient.scala index 9d9add2b7..993db60a9 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/client/AkkaHttpClient.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/client/AkkaHttpClient.scala @@ -8,24 +8,30 @@ import akka.http.scaladsl.coding.Gzip import akka.http.scaladsl.marshalling._ import akka.http.scaladsl.model.Uri.Query import akka.http.scaladsl.model._ -import akka.http.scaladsl.model.ws.{ TextMessage, Message, WebSocketRequest } +import akka.http.scaladsl.model.ws.{ Message, TextMessage, WebSocketRequest } import akka.http.scaladsl.unmarshalling.Unmarshal import akka.http.scaladsl.ConnectionContext import akka.stream.Materializer -import akka.stream.scaladsl.{ Flow, Sink, Keep, Source } +import akka.stream.scaladsl.{ Flow, Keep, Sink, Source } + import cats.data.Xor import cats.data.Xor.{ left, right } + import com.github.agourlay.cornichon.http._ +import com.github.agourlay.cornichon.json.CornichonJson._ + import de.heikoseeberger.akkasse.EventStreamUnmarshalling._ import de.heikoseeberger.akkasse.ServerSentEvent -import org.json4s._ -import org.json4s.jackson.JsonMethods._ import java.security.SecureRandom import java.security.cert.X509Certificate import java.util.concurrent.TimeoutException import javax.net.ssl._ +import io.circe.Json +import io.circe.generic.auto._ +import io.circe.syntax._ + import scala.collection.mutable.ListBuffer import scala.concurrent.duration.FiniteDuration import scala.concurrent.{ Await, ExecutionContext, Future } @@ -49,8 +55,6 @@ class AkkaHttpClient(implicit system: ActorSystem, mat: Materializer) extends Ht ConnectionContext.https(ssl) } - implicit private val formats = DefaultFormats - private def requestRunner(req: HttpRequest, headers: Seq[HttpHeader], timeout: FiniteDuration): Xor[HttpError, CornichonHttpResponse] = { val request = req.withHeaders(collection.immutable.Seq(headers: _*)) val f = Http().singleRequest(request, sslContext).flatMap(toCornichonResponse) @@ -66,18 +70,18 @@ class AkkaHttpClient(implicit system: ActorSystem, mat: Materializer) extends Ht } } - implicit def JValueMarshaller: ToEntityMarshaller[JValue] = - Marshaller.StringMarshaller.wrap(MediaTypes.`application/json`)(j ⇒ compact(render(j))) + implicit def JsonMarshaller: ToEntityMarshaller[Json] = + Marshaller.StringMarshaller.wrap(MediaTypes.`application/json`)(j ⇒ j.noSpaces) private def uriBuilder(url: String, params: Seq[(String, String)]): Uri = Uri(url).withQuery(Query(params: _*)) - def postJson(payload: JValue, url: String, params: Seq[(String, String)], headers: Seq[HttpHeader], timeout: FiniteDuration) = + def postJson(payload: Json, url: String, params: Seq[(String, String)], headers: Seq[HttpHeader], timeout: FiniteDuration) = requestRunner(Post(uriBuilder(url, params), payload), headers, timeout) - def putJson(payload: JValue, url: String, params: Seq[(String, String)], headers: Seq[HttpHeader], timeout: FiniteDuration) = + def putJson(payload: Json, url: String, params: Seq[(String, String)], headers: Seq[HttpHeader], timeout: FiniteDuration) = requestRunner(Put(uriBuilder(url, params), payload), headers, timeout) - def patchJson(payload: JValue, url: String, params: Seq[(String, String)], headers: Seq[HttpHeader], timeout: FiniteDuration) = + def patchJson(payload: Json, url: String, params: Seq[(String, String)], headers: Seq[HttpHeader], timeout: FiniteDuration) = requestRunner(Patch(uriBuilder(url, params), payload), headers, timeout) def deleteJson(url: String, params: Seq[(String, String)], headers: Seq[HttpHeader], timeout: FiniteDuration) = @@ -106,7 +110,7 @@ class AkkaHttpClient(implicit system: ActorSystem, mat: Materializer) extends Ht CornichonHttpResponse( status = StatusCodes.OK, headers = collection.immutable.Seq.empty[HttpHeader], - body = compact(render(JArray(events.map(Extraction.decompose(_)).toList))) + body = prettyPrint(Json.fromValues(events.map(_.asJson).toList)) ) } Await.result(r, takeWithin) @@ -145,7 +149,7 @@ class AkkaHttpClient(implicit system: ActorSystem, mat: Materializer) extends Ht CornichonHttpResponse( status = StatusCodes.OK, headers = collection.immutable.Seq.empty[HttpHeader], - body = compact(render(JArray(received.map(Extraction.decompose(_)).toList))) + body = prettyPrint(Json.fromValues(received.map(_.asJson).toList)) ) } } diff --git a/src/main/scala/com/github/agourlay/cornichon/http/client/HttpClient.scala b/src/main/scala/com/github/agourlay/cornichon/http/client/HttpClient.scala index bb84754a4..33a22513e 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/client/HttpClient.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/client/HttpClient.scala @@ -3,18 +3,18 @@ package com.github.agourlay.cornichon.http.client import akka.http.scaladsl.model.HttpHeader import cats.data.Xor import com.github.agourlay.cornichon.http.{ CornichonHttpResponse, HttpError } -import org.json4s._ +import io.circe.Json import scala.concurrent.Future import scala.concurrent.duration.FiniteDuration trait HttpClient { - def postJson(payload: JValue, url: String, params: Seq[(String, String)], headers: Seq[HttpHeader], timeout: FiniteDuration): Xor[HttpError, CornichonHttpResponse] + def postJson(payload: Json, url: String, params: Seq[(String, String)], headers: Seq[HttpHeader], timeout: FiniteDuration): Xor[HttpError, CornichonHttpResponse] - def putJson(payload: JValue, url: String, params: Seq[(String, String)], headers: Seq[HttpHeader], timeout: FiniteDuration): Xor[HttpError, CornichonHttpResponse] + def putJson(payload: Json, url: String, params: Seq[(String, String)], headers: Seq[HttpHeader], timeout: FiniteDuration): Xor[HttpError, CornichonHttpResponse] - def patchJson(payload: JValue, url: String, params: Seq[(String, String)], headers: Seq[HttpHeader], timeout: FiniteDuration): Xor[HttpError, CornichonHttpResponse] + def patchJson(payload: Json, url: String, params: Seq[(String, String)], headers: Seq[HttpHeader], timeout: FiniteDuration): Xor[HttpError, CornichonHttpResponse] def deleteJson(url: String, params: Seq[(String, String)], headers: Seq[HttpHeader], timeout: FiniteDuration): Xor[HttpError, CornichonHttpResponse] diff --git a/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala b/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala index e83cf7f08..9885c43e9 100644 --- a/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala +++ b/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala @@ -5,84 +5,95 @@ import cats.data.Xor.{ left, right } import com.github.agourlay.cornichon.core.CornichonError import com.github.agourlay.cornichon.dsl.DataTableParser import com.github.agourlay.cornichon.json.CornichonJson.GqlString - -import org.json4s._ -import org.json4s.jackson.JsonMethods._ +import io.circe.{ Json, JsonObject } import sangria.marshalling.MarshallingUtil._ import sangria.parser.QueryParser import sangria.marshalling.queryAst._ -import sangria.marshalling.json4s.jackson._ +import sangria.marshalling.circe._ -import scala.util.{ Failure, Success, Try } +import scala.util.{ Failure, Success } trait CornichonJson { - def parseJson[A](input: A): JValue = input match { - case s: String if s.trim.headOption.contains('|') ⇒ parseDataTable(s) - case s: String if s.trim.headOption.contains('{') ⇒ parse(s) - case s: String if s.trim.headOption.contains('[') ⇒ parse(s) - case s: String ⇒ JString(s) - case d: Double ⇒ JDouble(d) - case b: BigDecimal ⇒ JDecimal(b) - case i: Int ⇒ JInt(i) - case l: Long ⇒ JLong(l) - case b: Boolean ⇒ JBool(b) + def parseJson[A](input: A): Xor[CornichonError, Json] = input match { + case s: String if s.trim.headOption.contains('|') ⇒ right(Json.fromValues(parseDataTable(s).map(Json.fromJsonObject))) + case s: String if s.trim.headOption.contains('{') ⇒ parseString(s) + case s: String if s.trim.headOption.contains('[') ⇒ parseString(s) + case s: String ⇒ right(Json.fromString(s)) + case d: Double ⇒ right(Json.fromDoubleOrNull(d)) + case b: BigDecimal ⇒ right(Json.fromBigDecimal(b)) + case i: Int ⇒ right(Json.fromInt(i)) + case l: Long ⇒ right(Json.fromLong(l)) + case b: Boolean ⇒ right(Json.fromBoolean(b)) case GqlString(g) ⇒ parseGraphQLJson(g) } - def parseDataTable(table: String): JArray = { - val sprayArray = DataTableParser.parseDataTable(table).asSprayJson - JArray(sprayArray.elements.map(v ⇒ parse(v.toString())).toList) - } + def parseJsonUnsafe[A](input: A): Json = + parseJson(input).fold(e ⇒ throw e, identity) - def parseJsonUnsafe[A](input: A): JValue = - Try { parseJson(input) } match { - case Success(json) ⇒ json - case Failure(e) ⇒ throw new MalformedJsonError(input, e) - } + def parseString(s: String) = + io.circe.parser.parse(s).leftMap(f ⇒ new MalformedJsonError(s, f.message)) - def parseJsonXor[A](input: A): Xor[CornichonError, JValue] = - Try { parseJson(input) } match { - case Success(json) ⇒ right(json) - case Failure(e) ⇒ left(new MalformedJsonError(input, e)) - } + def parseDataTable(table: String): List[JsonObject] = + DataTableParser.parseDataTable(table).objectList def parseGraphQLJson(input: String) = QueryParser.parseInput(input) match { - case Success(value) ⇒ value.convertMarshaled[JValue] - case Failure(e) ⇒ throw new MalformedGraphQLJsonError(input, e) + case Success(value) ⇒ right(value.convertMarshaled[Json]) + case Failure(e) ⇒ left(new MalformedGraphQLJsonError(input, e)) } - def parseArray(input: String): JArray = parseJson(input) match { - case arr: JArray ⇒ arr - case _ ⇒ throw new NotAnArrayError(input) - } + def parseArray(input: String): Xor[CornichonError, List[Json]] = + parseJson(input).flatMap { json ⇒ + json.arrayOrObject( + left(new NotAnArrayError(input)), + values ⇒ right(values), + obj ⇒ left(new NotAnArrayError(input)) + ) + } - def selectArrayJsonPath(path: JsonPath, sessionValue: String): JArray = { - val extracted = path.run(sessionValue) - extracted match { - case jarr: JArray ⇒ jarr - case _ ⇒ throw new NotAnArrayError(extracted) + def selectArrayJsonPath(path: JsonPath, sessionValue: String): Xor[CornichonError, List[Json]] = { + path.run(sessionValue).flatMap { json ⇒ + json.arrayOrObject( + left(new NotAnArrayError(json)), + values ⇒ right(values), + obj ⇒ left(new NotAnArrayError(json)) + ) } } - // FIXME can break if JSON contains duplicate field => make bulletproof using lenses - def removeFieldsByPath(input: JValue, paths: Seq[JsonPath]) = { + def removeFieldsByPath(input: Json, paths: Seq[JsonPath]) = { paths.foldLeft(input) { (json, path) ⇒ - val jsonToRemove = path.run(json) - json.removeField { f ⇒ f._1 == path.operations.last.field && f._2 == jsonToRemove } + path.removeFromJson(json) } } - def prettyPrint(json: JValue) = pretty(render(json)) + def prettyPrint(json: Json) = json.spaces2 - def prettyDiff(first: JValue, second: JValue) = { - val Diff(changed, added, deleted) = first diff second + def prettyDiff(first: Json, second: Json) = { + val Diff(changed, added, deleted) = diff(first, second) s""" - |${if (changed == JNothing) "" else "changed = " + prettyPrint(changed)} - |${if (added == JNothing) "" else "added = " + prettyPrint(added)} - |${if (deleted == JNothing) "" else "deleted = " + prettyPrint(deleted)} + |${if (changed == Json.Null) "" else "changed = " + prettyPrint(changed)} + |${if (added == Json.Null) "" else "added = " + prettyPrint(added)} + |${if (deleted == Json.Null) "" else "deleted = " + prettyPrint(deleted)} """.stripMargin } + + def diff(v1: Json, v2: Json): Diff = { + import org.json4s.JValue + import org.json4s.jackson.JsonMethods._ + + def circeToJson4s(c: Json): JValue = + parse(c.noSpaces) + + def json4sToCirce(j: JValue): Json = + parseJsonUnsafe(compact(render(j))) + + val diff = circeToJson4s(v1).diff(circeToJson4s(v2)) + + Diff(changed = json4sToCirce(diff.changed), added = json4sToCirce(diff.added), deleted = json4sToCirce(diff.deleted)) + } + + case class Diff(changed: Json, added: Json, deleted: Json) } object CornichonJson extends CornichonJson { diff --git a/src/main/scala/com/github/agourlay/cornichon/json/JsonErrors.scala b/src/main/scala/com/github/agourlay/cornichon/json/JsonErrors.scala index 30131f3bd..b81f64ca1 100644 --- a/src/main/scala/com/github/agourlay/cornichon/json/JsonErrors.scala +++ b/src/main/scala/com/github/agourlay/cornichon/json/JsonErrors.scala @@ -8,8 +8,8 @@ case class NotAnArrayError[A](badPayload: A) extends JsonError { val msg = s"expected JSON Array but got $badPayload" } -case class MalformedJsonError[A](input: A, exception: Throwable) extends JsonError { - val msg = s"malformed JSON input $input with ${exception.getMessage}" +case class MalformedJsonError[A](input: A, message: String) extends JsonError { + val msg = s"malformed JSON input $input: $message" } case class MalformedGraphQLJsonError[A](input: A, exception: Throwable) extends JsonError { diff --git a/src/main/scala/com/github/agourlay/cornichon/json/JsonPath.scala b/src/main/scala/com/github/agourlay/cornichon/json/JsonPath.scala index 6caa64f54..8249d1956 100644 --- a/src/main/scala/com/github/agourlay/cornichon/json/JsonPath.scala +++ b/src/main/scala/com/github/agourlay/cornichon/json/JsonPath.scala @@ -1,7 +1,9 @@ package com.github.agourlay.cornichon.json -import org.json4s.JsonAST.JValue +import com.github.agourlay.cornichon.core.CornichonError import com.github.agourlay.cornichon.json.CornichonJson._ +import io.circe.Json +import cats.data.Xor case class JsonPath(operations: List[JsonPathOperation] = List.empty) { @@ -9,9 +11,42 @@ case class JsonPath(operations: List[JsonPathOperation] = List.empty) { val isRoot = operations.isEmpty - def run(json: JValue): JValue = operations.foldLeft(json) { (j, op) ⇒ op.run(j) } + //TODO test with key starting with numbers + private val lense = operations.foldLeft(io.circe.optics.JsonPath.root) { (l, op) ⇒ + op match { + case FieldSelection(field) ⇒ + l.field + case ArrayFieldSelection(field, indice) ⇒ + l.field.at(indice) + } + } + + def run(superSet: Json): Json = lense.json.getOption(superSet).getOrElse(Json.Null) + def run(json: String): Xor[CornichonError, Json] = parseJson(json).map(run) - def run(json: String): JValue = run(parseJson(json)) + def cursor(input: Json) = operations.foldLeft(Option(input.cursor)) { (oc, op) ⇒ + op match { + case FieldSelection(field) ⇒ + for { + c ← oc + downC ← c.downField(field) + } yield downC + + case ArrayFieldSelection(field, indice) ⇒ + for { + c ← oc + downC ← c.downField(field) + arrayC ← downC.downArray + indexC ← arrayC.rightN(indice) + } yield indexC + } + } + + def removeFromJson(input: Json): Json = { + cursor(input).fold(input) { c ⇒ + c.delete.fold(input)(_.top) + } + } } object JsonPath { @@ -25,9 +60,9 @@ object JsonPath { } } - def run(path: String, json: JValue) = JsonPath.parse(path).run(json) + def run(path: String, json: Json) = JsonPath.parse(path).run(json) - def run(path: String, json: String) = JsonPath.parse(path).run(parseJson(json)) + def run(path: String, json: String) = parseJson(json).map(JsonPath.parse(path).run) def fromSegments(segments: List[JsonSegment]) = { val operations = segments.map { @@ -42,15 +77,12 @@ object JsonPath { sealed trait JsonPathOperation { def field: String def pretty: String - def run(json: JValue): JValue } case class FieldSelection(field: String) extends JsonPathOperation { - def run(json: JValue) = json \ field val pretty = field } case class ArrayFieldSelection(field: String, indice: Int) extends JsonPathOperation { - def run(json: JValue) = (json \ field)(indice) val pretty = s"$field[$indice]" } \ No newline at end of file diff --git a/src/test/scala/com/github/agourlay/cornichon/dsl/DataTableSpec.scala b/src/test/scala/com/github/agourlay/cornichon/dsl/DataTableSpec.scala index 79546e478..9b83a3ef0 100644 --- a/src/test/scala/com/github/agourlay/cornichon/dsl/DataTableSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/dsl/DataTableSpec.scala @@ -1,10 +1,13 @@ package com.github.agourlay.cornichon.dsl +import io.circe.Json import org.scalatest.{ Matchers, TryValues, WordSpec } -import spray.json._ class DataTableSpec extends WordSpec with Matchers with TryValues { + def referenceParser(input: String) = + io.circe.parser.parse(input).fold(e ⇒ throw e, identity) + "DataTable parser" must { "process a single line with 1 value without new line on first" in { @@ -15,7 +18,7 @@ class DataTableSpec extends WordSpec with Matchers with TryValues { val expected = DataTable( headers = Headers(Seq("Name")), rows = Seq( - Row(Seq(JsString("John"))) + Row(Seq(Json.fromString("John"))) ) ) val p = new DataTableParser(input) @@ -31,7 +34,7 @@ class DataTableSpec extends WordSpec with Matchers with TryValues { val expected = DataTable( headers = Headers(Seq("Name")), rows = Seq( - Row(Seq(JsString("John"))) + Row(Seq(Json.fromString("John"))) ) ) val p = new DataTableParser(input) @@ -47,7 +50,7 @@ class DataTableSpec extends WordSpec with Matchers with TryValues { val expected = DataTable( headers = Headers(Seq("Name", "Age")), rows = Seq( - Row(Seq(JsString("John"), JsNumber(50))) + Row(Seq(Json.fromString("John"), Json.fromInt(50))) ) ) val p = new DataTableParser(input) @@ -64,8 +67,8 @@ class DataTableSpec extends WordSpec with Matchers with TryValues { val expected = DataTable( headers = Headers(Seq("Name", "Age")), rows = Seq( - Row(Seq(JsString("John"), JsNumber(50))), - Row(Seq(JsString("Bob"), JsNumber(11))) + Row(Seq(Json.fromString("John"), Json.fromInt(50))), + Row(Seq(Json.fromString("Bob"), Json.fromInt(11))) ) ) val p = new DataTableParser(input) @@ -90,8 +93,7 @@ class DataTableSpec extends WordSpec with Matchers with TryValues { | "Bob" | 11 | true | """ - val p = new DataTableParser(input) - p.dataTableRule.run().success.value.asSprayJson should be(""" + val expected = """ [{ "Name":"John", "Age":50, @@ -102,7 +104,11 @@ class DataTableSpec extends WordSpec with Matchers with TryValues { "Age":11, "2LettersName": true }] - """.parseJson) + """ + + val p = new DataTableParser(input) + val objects = p.dataTableRule.run().success.value.objectList + Json.fromValues(objects.map(Json.fromJsonObject)) should be(referenceParser(expected)) } } } diff --git a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala index 5a9e9856b..360a6430a 100644 --- a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala +++ b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala @@ -245,7 +245,7 @@ class SuperHeroesScenario extends CornichonFeature { When I get("/superheroes").withParams("sessionId" → "") - Then assert body.asArray.ignoring("publisher").is( + Then assert body.asArray.ignoringEach("publisher").is( """ [{ "name": "Batman", @@ -279,7 +279,7 @@ class SuperHeroesScenario extends CornichonFeature { }]""" ) - Then assert body.asArray.ignoring("publisher").is( + Then assert body.asArray.ignoringEach("publisher").is( """ | name | realName | city | hasSuperpowers | | "Batman" | "Bruce Wayne" | "Gotham city" | false | @@ -290,7 +290,7 @@ class SuperHeroesScenario extends CornichonFeature { """ ) - Then assert body.asArray.ignoring("hasSuperpowers", "publisher").is( + Then assert body.asArray.ignoringEach("hasSuperpowers", "publisher").is( """ [{ "name": "Superman", @@ -598,6 +598,7 @@ class SuperHeroesScenario extends CornichonFeature { def superhero_exists(name: String) = Attach { When I get("/superheroes/Batman").withParams("sessionId" → "") + Then I show_session Then assert status.is(200) } diff --git a/src/test/scala/com/github/agourlay/cornichon/json/CornichonJsonSpec.scala b/src/test/scala/com/github/agourlay/cornichon/json/CornichonJsonSpec.scala index bfe74c51b..022ad6add 100644 --- a/src/test/scala/com/github/agourlay/cornichon/json/CornichonJsonSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/json/CornichonJsonSpec.scala @@ -1,54 +1,65 @@ package com.github.agourlay.cornichon.json -import org.json4s._ -import org.json4s.JsonDSL._ +import io.circe.{ Json, JsonObject } import org.scalatest.prop.PropertyChecks - import org.scalatest.{ Matchers, WordSpec } +import cats.data.Xor._ class CornichonJsonSpec extends WordSpec with Matchers with PropertyChecks with CornichonJson { + def refParser(input: String) = + io.circe.parser.parse(input).fold(e ⇒ throw e, identity) + + def mapToJsonObject(m: Map[String, Json]) = + Json.fromJsonObject(JsonObject.fromMap(m)) + "CornichonJson" when { "parseJson" must { "parse Boolean" in { forAll { bool: Boolean ⇒ - parseJson(bool) should be(JBool(bool)) + parseJson(bool) should be(right(Json.fromBoolean(bool))) } } "parse Int" in { forAll { int: Int ⇒ - parseJson(int) should be(JInt(int)) + parseJson(int) should be(right(Json.fromInt(int))) } } "parse Long" in { forAll { long: Long ⇒ - parseJson(long) should be(JLong(long)) + parseJson(long) should be(right(Json.fromLong(long))) } } "parse Double" in { forAll { double: Double ⇒ - parseJson(double) should be(JDouble(double)) + parseJson(double) should be(right(Json.fromDoubleOrNull(double))) } } "parse BigDecimal" in { forAll { bigDec: BigDecimal ⇒ - parseJson(bigDec) should be(JDecimal(bigDec)) + parseJson(bigDec) should be(right(Json.fromBigDecimal(bigDec))) } } "parse flat string" in { - parseJson("cornichon") should be(JString("cornichon")) + parseJson("cornichon") should be(right(Json.fromString("cornichon"))) } "parse JSON object string" in { - parseJson("""{"name":"cornichon"}""") should be(JObject(JField("name", JString("cornichon")))) + val expected = mapToJsonObject(Map("name" → Json.fromString("cornichon"))) + parseJson("""{"name":"cornichon"}""") should be(right(expected)) } "parse JSON Array string" in { + val expected = Json.fromValues(Seq( + mapToJsonObject(Map("name" → Json.fromString("cornichon"))), + mapToJsonObject(Map("name" → Json.fromString("scala"))) + )) + parseJson( """ [ @@ -56,89 +67,251 @@ class CornichonJsonSpec extends WordSpec with Matchers with PropertyChecks with {"name":"scala"} ] """ - ) should be(JArray(List( - JObject(List(("name", JString("cornichon")))), - JObject(List(("name", JString("scala")))) - ))) + ) should be(right(expected)) } "parse data table" in { + + val expected = + """ + |[ + |{ + |"2LettersName" : false, + | "Age": 50, + | "Name": "John" + |}, + |{ + |"2LettersName" : true, + | "Age": 11, + | "Name": "Bob" + |} + |] + """.stripMargin + parseJson(""" | Name | Age | 2LettersName | | "John" | 50 | false | | "Bob" | 11 | true | - """) should be(JArray(List( - JObject(List(("2LettersName", JBool.False), ("Age", JInt(50)), ("Name", JString("John")))), - JObject(List(("2LettersName", JBool.True), ("Age", JInt(11)), ("Name", JString("Bob")))) - ))) + """) should be(right(refParser(expected))) } } "removeFieldsByPath" must { - "remove root key" in { - val input = JObject( - List( - ("TwoLettersName", JBool(false)), - ("Age", JInt(50)), - ("Name", JString("John")) - ) - ) - val paths = Seq("TwoLettersName", "Name").map(JsonPath.parse) - removeFieldsByPath(input, paths) should be(JObject(List(("Age", JInt(50))))) + "remove root keys" in { + val input = + """ + |{ + |"2LettersName" : false, + | "Age": 50, + | "Name": "John" + |} + """.stripMargin + + val expected = + """ + |{ + | "Age": 50 + |} + """.stripMargin + val paths = Seq("2LettersName", "Name").map(JsonPath.parse) + removeFieldsByPath(refParser(input), paths) should be(refParser(expected)) } "remove only root keys" in { - val input = ("name" → "bob") ~ ("age", 50) ~ ("brother" → (("name" → "john") ~ ("age", 40))) + val input = + """ + |{ + |"name" : "bob", + |"age": 50, + |"brothers":[ + | { + | "name" : "john", + | "age": 40 + | } + |] + |} """.stripMargin - val expected = ("age", 50) ~ ("brother" → (("name" → "john") ~ ("age", 40))) + val expected = """ + |{ + |"age": 50, + |"brothers":[ + | { + | "name" : "john", + | "age": 40 + | } + |] + |} """.stripMargin val paths = Seq("name").map(JsonPath.parse) - removeFieldsByPath(input, paths) should be(expected) + removeFieldsByPath(refParser(input), paths) should be(refParser(expected)) } - "remove nested keys" in { - val input: JValue = - ("name" → "bob") ~ - ("age", 50) ~ - ("brother" → - (("name" → "john") ~ ("age", 40))) + "remove keys inside specific indexed element" in { + val input = + """ + |{ + |"name" : "bob", + |"age": 50, + |"brothers":[ + | { + | "name" : "john", + | "age": 40 + | }, + | { + | "name" : "jim", + | "age": 30 + | } + |] + |} + """.stripMargin - val expected = ("name" → "bob") ~ ("age", 50) ~ ("brother" → ("age", 40)) + val expected = """ + |{ + |"name" : "bob", + |"age": 50, + |"brothers":[ + | { + | "age": 40 + | }, + | { + | "name" : "jim", + | "age": 30 + | } + |] + |} """.stripMargin - val paths = Seq("brother.name").map(JsonPath.parse) - removeFieldsByPath(input, paths) should be(expected) + val paths = Seq("brothers[0].name").map(JsonPath.parse) + removeFieldsByPath(refParser(input), paths) should be(refParser(expected)) } - //FIXME - "remove field in each element of an array" ignore { - val p1 = ("name" → "bob") ~ ("age", 50) - val p2 = ("name" → "jim") ~ ("age", 40) - val p3 = ("name" → "john") ~ ("age", 30) + //FIXME - done manually in BodyArrayAssertion for now + "remove field in each element of a root array" ignore { - val input = JArray(List(p1, p2, p3)) - val expected = JArray(List(JObject(JField("name", "bob"), JField("name", "jim"), JField("name", "john")))) + val input = + """ + |[ + |{ + | "name" : "bob", + | "age": 50 + |}, + |{ + | "name" : "jim", + | "age": 40 + |}, + |{ + | "name" : "john", + | "age": 30 + |} + |] + """.stripMargin + + val expected = + """ + |[ + |{ + | "name" : "bob" + |}, + |{ + | "name" : "jim" + |}, + |{ + | "name" : "john" + |} + |] + """.stripMargin val paths = Seq("age").map(JsonPath.parse) - removeFieldsByPath(input, paths) should be(expected) + removeFieldsByPath(refParser(input), paths) should be(right(refParser(expected))) } - //FIXME - "do not trip on duplicate" ignore { - val input: JValue = - ("name" → "bob") ~ - ("age", 50) ~ - ("brother" → - (("name" → "john") ~ ("age", 40))) ~ - ("friend" → - (("name" → "john") ~ ("age", 30))) + //FIXME - done manually in BodyArrayAssertion for now + "remove field in each element of a nested array" ignore { - val expected = ("name" → "bob") ~ ("age", 50) ~ ("brother" → ("age", 40)) ~ ("friend" → (("name" → "john") ~ ("age", 30))) + val input = + """ + |{ + |"people":[ + |{ + | "name" : "bob", + | "age": 50 + |}, + |{ + | "name" : "jim", + | "age": 40 + |}, + |{ + | "name" : "john", + | "age": 30 + |} + |] + |} + """.stripMargin - val paths = Seq("brother.name").map(JsonPath.parse) + val expected = + """ + |{ + |"people":[ + |{ + | "name" : "bob" + |}, + |{ + | "name" : "jim" + |}, + |{ + | "name" : "john" + |} + |] + |} + """.stripMargin - // debug - println(prettyPrint(removeFieldsByPath(input, paths))) - removeFieldsByPath(input, paths) should be(expected) + val paths = Seq("people[*].age").map(JsonPath.parse) + removeFieldsByPath(refParser(input), paths) should be(right(refParser(expected))) + } + + "be correct even with duplicate Fields" in { + + val input = + """ + |{ + |"name" : "bob", + |"age": 50, + |"brother":[ + | { + | "name" : "john", + | "age": 40 + | } + |], + |"friend":[ + | { + | "name" : "john", + | "age": 30 + | } + |] + |} + """.stripMargin + + val expected = + """ + |{ + |"name" : "bob", + |"age": 50, + |"brother":[ + | { + | "age": 40 + | } + |], + |"friend":[ + | { + | "name" : "john", + | "age": 30 + | } + |] + |} + """.stripMargin + + val paths = Seq("brother[0].name").map(JsonPath.parse) + + removeFieldsByPath(refParser(input), paths) should be(refParser(expected)) } } @@ -157,26 +330,21 @@ class CornichonJsonSpec extends WordSpec with Matchers with PropertyChecks with } """ + val expected = """ + { + "id": 1, + "name": "door", + "items": [ + {"state": "Open", "durability": 0.1465645654675762354763254763343243242}, + null, + {"state": "Open", "durability": 0.5, "foo": null} + ] + } + """ + val out = parseGraphQLJson(in) - out should be( - JObject(List( - "id" → JInt(1), - "name" → JString("door"), - "items" → JArray(List( - JObject(List( - "state" → JString("Open"), - "durability" → JDecimal(BigDecimal("0.1465645654675762354763254763343243242")) - )), - JNull, - JObject(List( - "state" → JString("Open"), - "durability" → JDecimal(BigDecimal("0.5")), - "foo" → JNull - )) - )) - )) - ) + out should be(right(refParser(expected))) } } From 468c2f51d8623f3089ef2c8aa0db206c731f2dd6 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Wed, 1 Jun 2016 10:58:25 +0200 Subject: [PATCH 02/39] make it compile by updating deps --- build.sbt | 4 ++-- .../com/github/agourlay/cornichon/http/HttpEffects.scala | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/build.sbt b/build.sbt index c6091937f..d886dd029 100644 --- a/build.sbt +++ b/build.sbt @@ -39,8 +39,8 @@ libraryDependencies ++= { val parboiledV = "2.1.3" val akkaSseV = "1.8.0" val scalacheckV = "1.12.5" - val sangriaCirceV = "0.4.3" - val circeVersion = "0.4.1" + val sangriaCirceV = "0.4.4" + val circeVersion = "0.5.0-M1" val sangriaV = "0.6.3" val sangriaSprayJsonV = "0.3.1" val fansiV = "0.1.1" diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala index 2e1d894af..51d6a6e34 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala @@ -3,7 +3,6 @@ package com.github.agourlay.cornichon.http import com.github.agourlay.cornichon.dsl.Dsl._ import com.github.agourlay.cornichon.json.CornichonJson._ import io.circe.Json -import org.json4s.JValue import sangria.ast.Document import sangria.renderer.QueryRenderer From 83da8e399cd9b0565675541e91e5621fb792cc16 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Wed, 1 Jun 2016 21:11:54 +0200 Subject: [PATCH 03/39] add missing bits to fix remaining tests --- .../agourlay/cornichon/core/Resolver.scala | 6 +-- .../agourlay/cornichon/http/HttpDsl.scala | 3 +- .../cornichon/json/CornichonJson.scala | 29 ++++++++++---- .../agourlay/cornichon/json/JsonPath.scala | 4 +- .../superHeroes/SuperHeroesScenario.scala | 18 +++------ .../cornichon/json/JsonPathSpec.scala | 38 +++++++++++++++++++ .../steps/wrapped/RepeatDuringStepSpec.scala | 17 +++++---- 7 files changed, 83 insertions(+), 32 deletions(-) create mode 100644 src/test/scala/com/github/agourlay/cornichon/json/JsonPathSpec.scala diff --git a/src/main/scala/com/github/agourlay/cornichon/core/Resolver.scala b/src/main/scala/com/github/agourlay/cornichon/core/Resolver.scala index 24d45e85a..f43459c74 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/Resolver.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/Resolver.scala @@ -3,8 +3,8 @@ package com.github.agourlay.cornichon.core import java.util.UUID import cats.data.Xor -import cats.data.Xor.{ right, left } -import com.github.agourlay.cornichon.json.JsonPath +import cats.data.Xor.{ left, right } +import com.github.agourlay.cornichon.json.{ CornichonJson, JsonPath } import org.parboiled2._ import org.scalacheck.Gen import org.scalacheck.Gen.Parameters @@ -53,7 +53,7 @@ class Resolver(extractors: Map[String, Mapper]) { // No placeholders in JsonMapper for now to avoid people running into infinite recursion // Could be enabled if there is a use case for it. JsonPath.run(jsonPath, sessionValue) - .map(_.noSpaces) + .map(CornichonJson.jsonStringValue) .map(transform) } } diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpDsl.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpDsl.scala index 94322021d..0b6a3d7d2 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpDsl.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpDsl.scala @@ -63,8 +63,7 @@ trait HttpDsl extends Dsl { val inputs = args.map { case (path, target) ⇒ FromSessionSetter(LastResponseBodyKey, (session, s) ⇒ { val resolvedPath = resolver.fillPlaceholdersUnsafe(path)(session) - // fixme cleanup of quotes - JsonPath.parse(resolvedPath).run(s).fold(e ⇒ throw e, json ⇒ prettyPrint(json).tail.init.toString) + JsonPath.parse(resolvedPath).run(s).fold(e ⇒ throw e, json ⇒ jsonStringValue(json)) }, target) } save_from_session(inputs) diff --git a/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala b/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala index 6c19ca08f..834071ba6 100644 --- a/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala +++ b/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala @@ -6,6 +6,7 @@ import com.github.agourlay.cornichon.core.CornichonError import com.github.agourlay.cornichon.dsl.DataTableParser import com.github.agourlay.cornichon.json.CornichonJson.GqlString import io.circe.{ Json, JsonObject } +import org.json4s.JsonAST.JNothing import sangria.marshalling.MarshallingUtil._ import sangria.parser.QueryParser import sangria.marshalling.queryAst._ @@ -67,15 +68,25 @@ trait CornichonJson { } } + def jsonStringValue(j: Json): String = + j.fold( + jsonNull = "", + jsonBoolean = b ⇒ prettyPrint(j), + jsonNumber = b ⇒ prettyPrint(j), + jsonString = s ⇒ s, + jsonArray = b ⇒ prettyPrint(j), + jsonObject = b ⇒ prettyPrint(j) + ) + def prettyPrint(json: Json) = json.spaces2 def prettyDiff(first: Json, second: Json) = { val Diff(changed, added, deleted) = diff(first, second) s""" - |${if (changed == Json.Null) "" else "changed = " + prettyPrint(changed)} - |${if (added == Json.Null) "" else "added = " + prettyPrint(added)} - |${if (deleted == Json.Null) "" else "deleted = " + prettyPrint(deleted)} + |${if (changed == Json.Null) "" else "changed = " + jsonStringValue(changed)} + |${if (added == Json.Null) "" else "added = " + jsonStringValue(added)} + |${if (deleted == Json.Null) "" else "deleted = " + jsonStringValue(deleted)} """.stripMargin } @@ -83,11 +94,15 @@ trait CornichonJson { import org.json4s.JValue import org.json4s.jackson.JsonMethods._ - def circeToJson4s(c: Json): JValue = - parse(c.noSpaces) + def circeToJson4s(c: Json): JValue = c match { + case Json.Null ⇒ JNothing + case _ ⇒ parse(c.noSpaces) + } - def json4sToCirce(j: JValue): Json = - parseJsonUnsafe(compact(render(j))) + def json4sToCirce(j: JValue): Json = j match { + case JNothing ⇒ Json.Null + case _ ⇒ parseJsonUnsafe(compact(render(j))) + } val diff = circeToJson4s(v1).diff(circeToJson4s(v2)) diff --git a/src/main/scala/com/github/agourlay/cornichon/json/JsonPath.scala b/src/main/scala/com/github/agourlay/cornichon/json/JsonPath.scala index 8249d1956..7c3860a03 100644 --- a/src/main/scala/com/github/agourlay/cornichon/json/JsonPath.scala +++ b/src/main/scala/com/github/agourlay/cornichon/json/JsonPath.scala @@ -11,6 +11,8 @@ case class JsonPath(operations: List[JsonPathOperation] = List.empty) { val isRoot = operations.isEmpty + //FIXME should we use Lens or Cursor to implement JsonPath? + //FIXME current implement does not work //TODO test with key starting with numbers private val lense = operations.foldLeft(io.circe.optics.JsonPath.root) { (l, op) ⇒ op match { @@ -21,7 +23,7 @@ case class JsonPath(operations: List[JsonPathOperation] = List.empty) { } } - def run(superSet: Json): Json = lense.json.getOption(superSet).getOrElse(Json.Null) + def run(superSet: Json): Json = cursor(superSet).fold(Json.Null)(c ⇒ c.focus) def run(json: String): Xor[CornichonError, Json] = parseJson(json).map(run) def cursor(input: Json) = operations.foldLeft(Option(input.cursor)) { (oc, op) ⇒ diff --git a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala index 72955f622..b389bf6cb 100644 --- a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala +++ b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala @@ -401,12 +401,6 @@ class SuperHeroesScenario extends CornichonFeature { ) // Extract value from response into session for reuse - And I save_body_path( - "city" → "batman-city", - "realName" → "batman-real-name" - ) - - // Or with extractor And I save_body_path("city" → "batman-city") Then assert session_contains("batman-city" → "Gotham city") @@ -639,12 +633,12 @@ class SuperHeroesScenario extends CornichonFeature { Then assert body.asArray.is( """ - | eventType | data | - | "superhero name" | "Batman" | - | "superhero name" | "Superman" | - | "superhero name" | "GreenLantern" | - | "superhero name" | "Spiderman" | - | "superhero name" | "IronMan" | + | eventType | data | id | retry | + | "superhero name" | "Batman" | null | null | + | "superhero name" | "Superman" | null | null | + | "superhero name" | "GreenLantern" | null | null | + | "superhero name" | "Spiderman" | null | null | + | "superhero name" | "IronMan" | null | null | """ ) } diff --git a/src/test/scala/com/github/agourlay/cornichon/json/JsonPathSpec.scala b/src/test/scala/com/github/agourlay/cornichon/json/JsonPathSpec.scala new file mode 100644 index 000000000..d265c9273 --- /dev/null +++ b/src/test/scala/com/github/agourlay/cornichon/json/JsonPathSpec.scala @@ -0,0 +1,38 @@ +package com.github.agourlay.cornichon.json + +import org.scalatest.prop.PropertyChecks +import org.scalatest.{ Matchers, WordSpec } +import cats.data.Xor._ +import io.circe.Json + +class JsonPathSpec extends WordSpec with Matchers with PropertyChecks { + + "JsonPath" must { + + "select properly String based on single field" in { + val input = + """ + |{ + |"2LettersName" : false, + | "Age": 50, + | "Name": "John" + |} + """.stripMargin + + JsonPath.parse("Name").run(input) should be(right(Json.fromString("John"))) + } + + "select properly Int based on single field" in { + val input = + """ + |{ + |"2LettersName" : false, + | "Age": 50, + | "Name": "John" + |} + """.stripMargin + + JsonPath.parse("Age").run(input) should be(right(Json.fromInt(50))) + } + } +} diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStepSpec.scala index 83efaa87b..e92fa3932 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStepSpec.scala @@ -44,9 +44,11 @@ class RepeatDuringStepSpec extends WordSpec with Matchers { val now = System.nanoTime engine.runScenario(Session.newSession)(s).stepsRunReport.isSuccess should be(true) val executionTime = Duration.fromNanos(System.nanoTime - now) - executionTime.gt(50.millis) should be(true) - // empiric values for the upper bound here - executionTime.lt(55.millis) should be(true) + withClue(executionTime.toMillis) { + executionTime.gt(50.millis) should be(true) + // empiric values for the upper bound here + executionTime.lt(60.millis) should be(true) + } } "repeat steps inside 'repeatDuring' at least once if they take more time than the duration param" in { @@ -66,10 +68,11 @@ class RepeatDuringStepSpec extends WordSpec with Matchers { val now = System.nanoTime engine.runScenario(Session.newSession)(s).stepsRunReport.isSuccess should be(true) val executionTime = Duration.fromNanos(System.nanoTime - now) - //println(executionTime) - executionTime.gt(50.millis) should be(true) - // empiric values for the upper bound here - executionTime.lt(550.millis) should be(true) + withClue(executionTime.toMillis) { + executionTime.gt(50.millis) should be(true) + // empiric values for the upper bound here + executionTime.lt(550.millis) should be(true) + } } } From 31419d823e7b3ca901892e2cfa86a7266f9ad193 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Wed, 1 Jun 2016 21:35:34 +0200 Subject: [PATCH 04/39] re-enable fork in tests --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index d886dd029..d41af1b23 100644 --- a/build.sbt +++ b/build.sbt @@ -20,7 +20,7 @@ scalacOptions ++= Seq( "-Ywarn-unused-import" ) -fork in Test := false +fork in Test := true SbtScalariform.scalariformSettings From d13ed4715bc193a54879187ef4459c8fdd9a4f6c Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Thu, 2 Jun 2016 09:39:57 +0200 Subject: [PATCH 05/39] migrate test API to Circe --- build.sbt | 17 ++--- .../examples/superHeroes/server/RestAPI.scala | 73 +++++++++---------- .../examples/superHeroes/server/models.scala | 14 ++-- 3 files changed, 49 insertions(+), 55 deletions(-) diff --git a/build.sbt b/build.sbt index d41af1b23..dcf621f17 100644 --- a/build.sbt +++ b/build.sbt @@ -38,34 +38,31 @@ libraryDependencies ++= { val logbackV = "1.1.7" val parboiledV = "2.1.3" val akkaSseV = "1.8.0" - val scalacheckV = "1.12.5" + val scalaCheckV = "1.12.5" val sangriaCirceV = "0.4.4" val circeVersion = "0.5.0-M1" val sangriaV = "0.6.3" - val sangriaSprayJsonV = "0.3.1" val fansiV = "0.1.1" + val akkaHttpCirce = "1.6.0" Seq( "com.typesafe.akka" %% "akka-http-core" % akkaHttpV ,"de.heikoseeberger" %% "akka-sse" % akkaSseV - ,"org.json4s" %% "json4s-jackson" % json4sV + ,"org.json4s" %% "json4s-jackson" % json4sV // Only use for Json diff - remove asap. ,"org.typelevel" %% "cats-macros" % catsV ,"org.typelevel" %% "cats-core" % catsV ,"org.scalatest" %% "scalatest" % scalaTestV ,"ch.qos.logback" % "logback-classic" % logbackV ,"org.parboiled" %% "parboiled" % parboiledV - ,"org.scalacheck" %% "scalacheck" % scalacheckV + ,"org.scalacheck" %% "scalacheck" % scalaCheckV ,"com.lihaoyi" %% "fansi" % fansiV ,"org.sangria-graphql" %% "sangria" % sangriaV ,"org.sangria-graphql" %% "sangria-circe" % sangriaCirceV ,"io.circe" %% "circe-core" % circeVersion ,"io.circe" %% "circe-generic" % circeVersion ,"io.circe" %% "circe-parser" % circeVersion - ,"io.circe" %% "circe-optics" % circeVersion - ,"com.typesafe.akka" %% "akka-http-spray-json-experimental" % akkaHttpV % "test" - ,"com.typesafe.akka" %% "akka-http-experimental" % akkaHttpV % "test" - ,"org.sangria-graphql" %% "sangria-spray-json" % sangriaSprayJsonV % "test" - ,"com.typesafe.akka" %% "akka-http-spray-json-experimental" % akkaHttpV % "test" - ,"com.typesafe.akka" %% "akka-http-experimental" % akkaHttpV % "test" + ,"io.circe" %% "circe-optics" % circeVersion // Remove if cursors are used instead or lenses for JsonPath. + ,"de.heikoseeberger" %% "akka-http-circe" % akkaHttpCirce % "test" + ,"com.typesafe.akka" %% "akka-http-experimental" % akkaHttpV % "test" ) } diff --git a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/RestAPI.scala b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/RestAPI.scala index 0db55f353..f7dd88b33 100644 --- a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/RestAPI.scala +++ b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/RestAPI.scala @@ -3,6 +3,7 @@ package com.github.agourlay.cornichon.examples.superHeroes.server import akka.actor.ActorSystem import akka.http.scaladsl.Http import akka.http.scaladsl.marshalling.ToResponseMarshallable +import akka.http.scaladsl.model.{ ContentTypes, HttpEntity } import akka.http.scaladsl.model.StatusCodes._ import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.PathMatchers.Remaining @@ -11,10 +12,12 @@ import akka.http.scaladsl.server.directives.Credentials import akka.stream.ActorMaterializer import akka.stream.scaladsl.Source import de.heikoseeberger.akkasse.{ EventStreamMarshalling, ServerSentEvent } +import de.heikoseeberger.akkahttpcirce.CirceSupport._ import sangria.execution._ import sangria.parser.QueryParser -import sangria.marshalling.sprayJson._ -import spray.json._ +import sangria.marshalling.circe._ +import io.circe.{ Json, JsonObject } +import io.circe.generic.auto._ import scala.concurrent.ExecutionContext import scala.util.{ Failure, Success } @@ -67,7 +70,7 @@ class RestAPI() extends EventStreamMarshalling { path("session") { post { onSuccess(testData.createSession()) { session: String ⇒ - complete(ToResponseMarshallable(Created → session)) + complete(Created → HttpEntity(ContentTypes.`text/html(UTF-8)`, session)) } } } ~ @@ -166,43 +169,39 @@ class RestAPI() extends EventStreamMarshalling { } ~ path("graphql") { post { - entity(as[JsValue]) { requestJson ⇒ - val JsObject(fields) = requestJson - - val JsString(query) = fields("query") - - val operation = fields.get("operationName") collect { - case JsString(op) ⇒ op - } + entity(as[Json]) { requestJson ⇒ + val obj = requestJson.asObject + val query = obj.flatMap(_("query")).flatMap(_.asString) + val operation = obj.flatMap(_("operationName")).flatMap(_.asString) + val vars = obj.flatMap(_("variables")).getOrElse(Json.fromJsonObject(JsonObject.empty)) + + query.fold(complete(BadRequest, Json.obj("error" → Json.fromString("Query is required")))) { q ⇒ + + QueryParser.parse(q) match { + + // query parsed successfully, time to execute it! + case Success(queryAst) ⇒ + complete( + Executor.execute( + schema = GraphQlSchema.SuperHeroesSchema, + queryAst = queryAst, + root = testData, + variables = vars, + operationName = operation + ).map(OK → _) + .recover { + case error: QueryAnalysisError ⇒ BadRequest → error.resolveError + case error: ErrorWithResolver ⇒ InternalServerError → error.resolveError + } + ) + + // can't parse GraphQL query, return error + case Failure(error) ⇒ + complete(BadRequest, Json.obj("error" → Json.fromString(error.getMessage))) + } - val vars = fields.get("variables") match { - case Some(obj: JsObject) ⇒ obj - case Some(JsString(s)) if s.trim.nonEmpty ⇒ s.parseJson - case _ ⇒ JsObject.empty } - QueryParser.parse(query) match { - - // query parsed successfully, time to execute it! - case Success(queryAst) ⇒ - complete( - Executor.execute( - schema = GraphQlSchema.SuperHeroesSchema, - queryAst = queryAst, - root = testData, - variables = vars, - operationName = operation - ).map(OK → _) - .recover { - case error: QueryAnalysisError ⇒ BadRequest → error.resolveError - case error: ErrorWithResolver ⇒ InternalServerError → error.resolveError - } - ) - - // can't parse GraphQL query, return error - case Failure(error) ⇒ - complete(BadRequest, JsObject("error" → JsString(error.getMessage))) - } } } } diff --git a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/models.scala b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/models.scala index 6266fd1bd..547471718 100644 --- a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/models.scala +++ b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/models.scala @@ -1,17 +1,18 @@ package com.github.agourlay.cornichon.examples.superHeroes.server -import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport import de.heikoseeberger.akkasse.ServerSentEvent -import spray.json.DefaultJsonProtocol import sangria.macros.derive._ import sangria.schema.Schema +import io.circe.generic.auto._ +import io.circe.syntax._ + case class Publisher(name: String, foundationYear: Int, location: String) case class SuperHero(name: String, realName: String, city: String, hasSuperpowers: Boolean, publisher: Publisher) import sangria.schema._ -import sangria.marshalling.sprayJson._ +import sangria.marshalling.circe._ object GraphQlSchema { import JsonSupport._ @@ -63,11 +64,8 @@ case class SuperHeroAlreadyExists(id: String) extends ResourceNotFound case class HttpError(error: String) -object JsonSupport extends DefaultJsonProtocol with SprayJsonSupport { - implicit val formatCP = jsonFormat3(Publisher) - implicit val formatSH = jsonFormat5(SuperHero) - implicit val formatHE = jsonFormat1(HttpError) +object JsonSupport { implicit def toServerSentEvent(sh: SuperHero): ServerSentEvent = { - ServerSentEvent(eventType = "superhero", data = formatSH.write(sh).compactPrint) + ServerSentEvent(eventType = "superhero", data = sh.asJson.noSpaces) } } \ No newline at end of file From 1d2b53e47f48623e0686de451ff20e6305e51c25 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Thu, 2 Jun 2016 20:09:50 +0200 Subject: [PATCH 06/39] fail datatable parsing if invalid json --- .../com/github/agourlay/cornichon/dsl/DataTable.scala | 5 +++-- .../github/agourlay/cornichon/dsl/DataTableSpec.scala | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/scala/com/github/agourlay/cornichon/dsl/DataTable.scala b/src/main/scala/com/github/agourlay/cornichon/dsl/DataTable.scala index 428855140..7ff3fa3f9 100644 --- a/src/main/scala/com/github/agourlay/cornichon/dsl/DataTable.scala +++ b/src/main/scala/com/github/agourlay/cornichon/dsl/DataTable.scala @@ -3,6 +3,8 @@ package com.github.agourlay.cornichon.dsl import io.circe.{ Json, JsonObject } import org.parboiled2._ +import com.github.agourlay.cornichon.json.CornichonJson._ + import scala.util.{ Failure, Success } object DataTableParser { @@ -28,8 +30,7 @@ class DataTableParser(val input: ParserInput) extends Parser { def HeaderRule = rule { Separator ~ oneOrMore(HeaderTXT).separatedBy(Separator) ~ Separator ~> Headers } - //fix me - should not swallow error - def RowRule = rule { Separator ~ oneOrMore(TXT).separatedBy(Separator) ~ Separator ~> (x ⇒ Row(x.map(io.circe.parser.parse(_).getOrElse(Json.Null)))) } + def RowRule = rule { Separator ~ oneOrMore(TXT).separatedBy(Separator) ~ Separator ~> (x ⇒ Row(x.map(parseString(_).fold(e ⇒ throw e, identity)))) } val delims = s"$delimeter\r\n" diff --git a/src/test/scala/com/github/agourlay/cornichon/dsl/DataTableSpec.scala b/src/test/scala/com/github/agourlay/cornichon/dsl/DataTableSpec.scala index 9b83a3ef0..847adb4f2 100644 --- a/src/test/scala/com/github/agourlay/cornichon/dsl/DataTableSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/dsl/DataTableSpec.scala @@ -10,6 +10,17 @@ class DataTableSpec extends WordSpec with Matchers with TryValues { "DataTable parser" must { + "report malformed JSON" in { + val input = """ + | Name | Age | + | "John" | 5a | + """ + + val p = new DataTableParser(input) + println(p.dataTableRule.run()) + p.dataTableRule.run().isFailure should be(true) + } + "process a single line with 1 value without new line on first" in { val input = """ | Name | | "John" | From 6515f41ac95bc0f6f8b482648eee5d9a3950840c Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Thu, 2 Jun 2016 20:12:19 +0200 Subject: [PATCH 07/39] fansi 0.1.2 --- build.sbt | 2 +- .../scala/com/github/agourlay/cornichon/dsl/DataTableSpec.scala | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/build.sbt b/build.sbt index dcf621f17..7021951fe 100644 --- a/build.sbt +++ b/build.sbt @@ -42,7 +42,7 @@ libraryDependencies ++= { val sangriaCirceV = "0.4.4" val circeVersion = "0.5.0-M1" val sangriaV = "0.6.3" - val fansiV = "0.1.1" + val fansiV = "0.1.2" val akkaHttpCirce = "1.6.0" Seq( "com.typesafe.akka" %% "akka-http-core" % akkaHttpV diff --git a/src/test/scala/com/github/agourlay/cornichon/dsl/DataTableSpec.scala b/src/test/scala/com/github/agourlay/cornichon/dsl/DataTableSpec.scala index 847adb4f2..a0213692d 100644 --- a/src/test/scala/com/github/agourlay/cornichon/dsl/DataTableSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/dsl/DataTableSpec.scala @@ -17,7 +17,6 @@ class DataTableSpec extends WordSpec with Matchers with TryValues { """ val p = new DataTableParser(input) - println(p.dataTableRule.run()) p.dataTableRule.run().isFailure should be(true) } From e4212034c54ecc3b9c63d19c4e39e091fc943513 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Thu, 2 Jun 2016 21:54:49 +0200 Subject: [PATCH 08/39] cosmetic changes --- .../cornichon/core/ScalatestIntegration.scala | 1 + .../agourlay/cornichon/http/HttpAssertions.scala | 6 +++--- .../cornichon/examples/math/MathSteps.scala | 12 ++++++------ .../superHeroes/SuperHeroesScenario.scala | 16 ++++++++++------ 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/main/scala/com/github/agourlay/cornichon/core/ScalatestIntegration.scala b/src/main/scala/com/github/agourlay/cornichon/core/ScalatestIntegration.scala index c3ad54e9e..95dbfeb77 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/ScalatestIntegration.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/ScalatestIntegration.scala @@ -55,6 +55,7 @@ trait ScalatestIntegration extends WordSpecLike with BeforeAndAfterAll with Para |${scenarioReport.msg} |replay only this scenario with: |${replayCmd(feat.name, s.name)} + | |""".stripMargin ) } diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala index 245827236..47d1eb747 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala @@ -81,7 +81,7 @@ object HttpAssertions { if (whiteList && ignoredKeys.nonEmpty) throw InvalidIgnoringConfigError else { - val baseTitle = if (jsonPath == JsonPath.root) s"response body is '$expected'" else s"response body's field '$jsonPath' is '$expected'" + val baseTitle = if (jsonPath == JsonPath.root) s"response body is $expected" else s"response body's field '$jsonPath' is $expected" from_session_step( key = LastResponseBodyKey, title = titleBuilder(baseTitle, ignoredKeys, whiteList), @@ -181,7 +181,7 @@ object HttpAssertions { override def is(expected: A): AssertStep[Iterable[Json]] = { val assertionTitle = { - val expectedSentence = if (ordered) s"in order is '$expected'" else s"is '$expected'" + val expectedSentence = if (ordered) s"in order is $expected" else s"is $expected" val titleString = if (jsonPath == JsonPath.root) s"response body array $expectedSentence" else @@ -227,7 +227,7 @@ object HttpAssertions { def contains(elements: A*) = { val prettyElements = elements.mkString(" and ") - val title = if (jsonPath == JsonPath.root) s"response body array contains '$prettyElements'" else s"response body's array '$jsonPath.pretty' contains '$prettyElements'" + val title = if (jsonPath == JsonPath.root) s"response body array contains $prettyElements" else s"response body's array '$jsonPath' contains $prettyElements" from_session_detail_step( title = title, key = LastResponseBodyKey, diff --git a/src/test/scala/com/github/agourlay/cornichon/examples/math/MathSteps.scala b/src/test/scala/com/github/agourlay/cornichon/examples/math/MathSteps.scala index 42f2c67cb..91d5d1cd0 100644 --- a/src/test/scala/com/github/agourlay/cornichon/examples/math/MathSteps.scala +++ b/src/test/scala/com/github/agourlay/cornichon/examples/math/MathSteps.scala @@ -9,7 +9,7 @@ trait MathSteps { case class adding_values(arg1: String, arg2: String) { def equals(res: Int) = AssertStep( - title = s"Value of $arg1 + $arg2 should be $res", + title = s"value of $arg1 + $arg2 should be $res", action = s ⇒ { val v1 = s.get(arg1).toInt val v2 = s.get(arg2).toInt @@ -20,7 +20,7 @@ trait MathSteps { def generate_random_int(target: String, max: Int = 100) = EffectStep( - title = s"Generate random Int into '$target' (max=$max)", + title = s"generate random Int into '$target' (max=$max)", effect = s ⇒ { s.addValue(target, Random.nextInt(max).toString) } @@ -28,7 +28,7 @@ trait MathSteps { def generate_random_double(target: String) = EffectStep( - title = s"Generate random Double into '$target'", + title = s"generate random Double into '$target'", effect = s ⇒ { s.addValue(target, Random.nextDouble().toString) } @@ -37,7 +37,7 @@ trait MathSteps { case class double_value(source: String) { def isBetween(low: Double, high: Double) = AssertStep( - title = s"Double value of '$source' is between '$low' and '$high'", + title = s"double value of '$source' is between '$low' and '$high'", action = s ⇒ { val v = s.get(source).toDouble DetailedStepAssertion(true, v > low && v < high, ratioError(v, low, high)) @@ -48,7 +48,7 @@ trait MathSteps { private def ratioError(v: Double, low: Double, high: Double): Boolean ⇒ String = b ⇒ s"$v is not between $low and $high" def calculate_point_in_circle(target: String) = EffectStep( - title = s"Calculate points inside circle", + title = s"calculate points inside circle", effect = s ⇒ { val x = s.get("x").toDouble val y = s.get("y").toDouble @@ -59,7 +59,7 @@ trait MathSteps { def estimate_pi_from_ratio(inside: String, target: String) = EffectStep( - title = s"Estimate PI from ration into key '$target'", + title = s"estimate PI from ratio into key '$target'", effect = s ⇒ { val insides = s.getHistory(inside) val trial = insides.size diff --git a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala index b389bf6cb..b49140c0c 100644 --- a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala +++ b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala @@ -57,8 +57,8 @@ class SuperHeroesScenario extends CornichonFeature { And assert body.ignoring("city", "publisher").is( gql""" { - name: "Batman", - realName: "Bruce Wayne", + name: "Batman" + realName: "Bruce Wayne" hasSuperpowers: false } """ @@ -103,7 +103,8 @@ class SuperHeroesScenario extends CornichonFeature { "name":"DC", "foundationYear":1934, "location":"Burbank, California" - } """ + } + """ ) Then assert body.path("publisher").ignoring("location").is( @@ -111,7 +112,8 @@ class SuperHeroesScenario extends CornichonFeature { { "name":"DC", "foundationYear":1934 - } """ + } + """ ) Then assert body.path("publisher.name").is("DC") @@ -278,7 +280,8 @@ class SuperHeroesScenario extends CornichonFeature { "realName": "Tony Stark", "hasSuperpowers": false, "city": "New York" - }]""" + }] + """ ) Then assert body.asArray.ignoringEach("publisher").is( @@ -318,7 +321,8 @@ class SuperHeroesScenario extends CornichonFeature { "name": "GreenLantern", "realName": "Hal Jordan", "city": "Coast City" - }]""" + }] + """ ) Then assert body.asArray.hasSize(5) From 096ecace1b74f4ed5b0d61492422c0207ab532c7 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Fri, 3 Jun 2016 20:06:11 +0200 Subject: [PATCH 09/39] fansi 0.1.3 --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 7021951fe..5a0f9b637 100644 --- a/build.sbt +++ b/build.sbt @@ -42,7 +42,7 @@ libraryDependencies ++= { val sangriaCirceV = "0.4.4" val circeVersion = "0.5.0-M1" val sangriaV = "0.6.3" - val fansiV = "0.1.2" + val fansiV = "0.1.3" val akkaHttpCirce = "1.6.0" Seq( "com.typesafe.akka" %% "akka-http-core" % akkaHttpV From a81b575419740155d6f23c73ef1afc8467da384d Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Fri, 3 Jun 2016 20:09:53 +0200 Subject: [PATCH 10/39] akka 2.4.7 --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 5a0f9b637..58bc32a5d 100644 --- a/build.sbt +++ b/build.sbt @@ -32,7 +32,7 @@ ScalariformKeys.preferences := ScalariformKeys.preferences.value libraryDependencies ++= { val scalaTestV = "2.2.6" - val akkaHttpV = "2.4.6" + val akkaHttpV = "2.4.7" val catsV = "0.6.0" val json4sV = "3.3.0" val logbackV = "1.1.7" From 5c48c6601dc1a8937afaa12fd19e06097a9cfc50 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Fri, 3 Jun 2016 20:39:21 +0200 Subject: [PATCH 11/39] improve error reporting of 'Headers contain' assertion --- .../github/agourlay/cornichon/dsl/Dsl.scala | 5 +---- .../cornichon/http/HttpAssertions.scala | 3 ++- .../cornichon/http/HttpDslErrors.scala | 21 +++++++++++-------- .../agourlay/cornichon/http/HttpEffects.scala | 2 +- .../agourlay/cornichon/http/HttpService.scala | 21 ++++++++++++++----- .../agourlay/cornichon/util/Formats.scala | 9 ++++++++ .../superHeroes/SuperHeroesScenario.scala | 2 +- 7 files changed, 42 insertions(+), 21 deletions(-) create mode 100644 src/main/scala/com/github/agourlay/cornichon/util/Formats.scala diff --git a/src/main/scala/com/github/agourlay/cornichon/dsl/Dsl.scala b/src/main/scala/com/github/agourlay/cornichon/dsl/Dsl.scala index 4096b780e..4eba3eda0 100644 --- a/src/main/scala/com/github/agourlay/cornichon/dsl/Dsl.scala +++ b/src/main/scala/com/github/agourlay/cornichon/dsl/Dsl.scala @@ -5,6 +5,7 @@ import com.github.agourlay.cornichon.core.{ Scenario ⇒ ScenarioDef } import com.github.agourlay.cornichon.dsl.CoreAssertion.{ SessionAssertion, SessionValuesAssertion } import com.github.agourlay.cornichon.steps.regular._ import com.github.agourlay.cornichon.steps.wrapped._ +import com.github.agourlay.cornichon.util.Formats._ import scala.language.experimental.{ macros ⇒ `scalac, please just let me do it!` } import scala.concurrent.duration.{ Duration, FiniteDuration } @@ -151,10 +152,6 @@ object Dsl { ) } - def displayTuples(params: Seq[(String, String)]): String = { - params.map { case (name, value) ⇒ s"'$name' -> '$value'" }.mkString(", ") - } - def from_session_step[A](key: String, expected: Session ⇒ A, mapValue: (Session, String) ⇒ A, title: String) = AssertStep( title, diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala index 47d1eb747..70df0d48e 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala @@ -8,6 +8,7 @@ import com.github.agourlay.cornichon.http.HttpService._ import com.github.agourlay.cornichon.json.CornichonJson._ import com.github.agourlay.cornichon.json.{ JsonPath, NotAnArrayError, WhiteListError } import com.github.agourlay.cornichon.steps.regular.AssertStep +import com.github.agourlay.cornichon.util.Formats._ import io.circe.Json object HttpAssertions { @@ -59,7 +60,7 @@ object HttpAssertions { key = LastResponseHeadersKey, expected = s ⇒ true, mapValue = (session, sessionHeaders) ⇒ { - val sessionHeadersValue = sessionHeaders.split(",") + val sessionHeadersValue = sessionHeaders.split(InterHeadersValueDelim) val predicate = elements.forall { case (name, value) ⇒ sessionHeadersValue.contains(s"$name$HeadersKeyValueDelim$value") } (predicate, headersDoesNotContainError(displayTuples(elements), sessionHeaders)) } diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpDslErrors.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpDslErrors.scala index 057ec1e95..05bc35246 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpDslErrors.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpDslErrors.scala @@ -2,39 +2,42 @@ package com.github.agourlay.cornichon.http import com.github.agourlay.cornichon.core.CornichonError import com.github.agourlay.cornichon.json.CornichonJson._ +import com.github.agourlay.cornichon.http.HttpService._ +import com.github.agourlay.cornichon.util.Formats._ object HttpDslErrors { def statusError(expected: Int, body: String): Int ⇒ String = actual ⇒ { s"""expected '$expected' but actual is '$actual' with response body: - |${prettyPrint(parseJsonUnsafe(body))}""".stripMargin + |${prettyPrint(parseJsonUnsafe(body))}""".stripMargin } def arraySizeError(expected: Int, sourceArray: String): Int ⇒ String = actual ⇒ { s"""expected array size '$expected' but actual is '$actual' with array: - |$sourceArray""".stripMargin + |$sourceArray""".stripMargin } def arrayDoesNotContainError(expected: Seq[String], sourceArray: String): Boolean ⇒ String = resFalse ⇒ { s"""expected array to contain - |'${expected.mkString(" and ")}' - |but it is not the case with array: - |$sourceArray""".stripMargin + |'${expected.mkString(" and ")}' + |but it is not the case with array: + |$sourceArray""".stripMargin } def headersDoesNotContainError(expected: String, sourceArray: String): Boolean ⇒ String = resFalse ⇒ { - s"""expected headers to contain '$expected' but it is not the case with headers: - |$sourceArray""".stripMargin + val prettyHeaders = displayTuples(decodeSessionHeaders(sourceArray)) + s"""expected headers to contain $expected but it is not the case with headers: + |$prettyHeaders""".stripMargin } def keyIsPresentError(keyName: String, source: String): Boolean ⇒ String = resFalse ⇒ { s"""expected key '$keyName' to be absent but it was found with value : - |$source""".stripMargin + |$source""".stripMargin } def keyIsAbsentError(keyName: String, source: String): Boolean ⇒ String = resFalse ⇒ { s"""expected key '$keyName' to be present but it was not in the source : - |$source""".stripMargin + |$source""".stripMargin } case object InvalidIgnoringConfigError extends CornichonError { diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala index 51d6a6e34..0152daa0e 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala @@ -1,6 +1,6 @@ package com.github.agourlay.cornichon.http -import com.github.agourlay.cornichon.dsl.Dsl._ +import com.github.agourlay.cornichon.util.Formats._ import com.github.agourlay.cornichon.json.CornichonJson._ import io.circe.Json import sangria.ast.Document diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala index f435e2fac..484cd00a2 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala @@ -107,7 +107,7 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC }.addValues(Seq( LastResponseStatusKey → response.status.intValue().toString, LastResponseBodyKey → response.body, - LastResponseHeadersKey → response.headers.map(h ⇒ s"${h.name()}$HeadersKeyValueDelim${h.value()}").mkString(",") + LastResponseHeadersKey → encodeSessionHeaders(response) )) def parseHttpHeaders(headers: Seq[(String, String)]): Xor[MalformedHeadersError, Seq[HttpHeader]] = { @@ -127,10 +127,7 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC def extractWithHeadersSession(session: Session): Xor[MalformedHeadersError, Seq[HttpHeader]] = session.getOpt(WithHeadersKey).fold[Xor[MalformedHeadersError, Seq[HttpHeader]]](right(Seq.empty[HttpHeader])) { headers ⇒ - val tuples = headers.split(',').toSeq.map { header ⇒ - val elms = header.split(HeadersKeyValueDelim) - (elms.head, elms.tail.head) - } + val tuples: Seq[(String, String)] = decodeSessionHeaders(headers) parseHttpHeaders(tuples) } @@ -143,4 +140,18 @@ object HttpService { val LastResponseHeadersKey = "last-response-headers" val WithHeadersKey = "with-headers" val HeadersKeyValueDelim = '|' + val InterHeadersValueDelim = ";" + + def encodeSessionHeaders(response: CornichonHttpResponse): String = + response.headers.map { h ⇒ + s"${h.name()}$HeadersKeyValueDelim${h.value()}" + }.mkString(InterHeadersValueDelim) + + def decodeSessionHeaders(headers: String): Seq[(String, String)] = { + val tuples = headers.split(InterHeadersValueDelim).toSeq.map { header ⇒ + val elms = header.split(HeadersKeyValueDelim) + (elms.head, elms.tail.head) + } + tuples + } } \ No newline at end of file diff --git a/src/main/scala/com/github/agourlay/cornichon/util/Formats.scala b/src/main/scala/com/github/agourlay/cornichon/util/Formats.scala new file mode 100644 index 000000000..ff37da447 --- /dev/null +++ b/src/main/scala/com/github/agourlay/cornichon/util/Formats.scala @@ -0,0 +1,9 @@ +package com.github.agourlay.cornichon.util + +object Formats { + + def displayTuples(params: Seq[(String, String)]): String = { + params.map { case (name, value) ⇒ s"'$name' -> '$value'" }.mkString(", ") + } + +} diff --git a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala index b49140c0c..e43d5045e 100644 --- a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala +++ b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala @@ -423,7 +423,7 @@ class SuperHeroesScenario extends CornichonFeature { """ ) - Then assert headers.contain("Server" → "akka-http/2.4.6") + Then assert headers.contain("Server" → "akka-http/2.4.7") // To make debugging easier, here are some debug steps printing into console And I show_session From 93922354c8797d6bc1f04d13d769105ea9ce62a1 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Sat, 4 Jun 2016 16:36:53 +0200 Subject: [PATCH 12/39] prepare version number --- version.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.sbt b/version.sbt index 9cf65c91a..48bb0f19e 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -version in ThisBuild := "0.7.5-SNAPSHOT" \ No newline at end of file +version in ThisBuild := "0.8.0-SNAPSHOT" \ No newline at end of file From 7f80d6cf49502fd4747b5259b017ca0522e49b42 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Sun, 5 Jun 2016 15:36:31 +0200 Subject: [PATCH 13/39] wild refactoring --- build.sbt | 36 +++--- .../agourlay/cornichon/core/Engine.scala | 18 +-- .../cornichon/core/LogInstruction.scala | 44 +++++++ .../agourlay/cornichon/core/Models.scala | 2 +- .../cornichon/core/ScalatestIntegration.scala | 6 +- .../cornichon/core/ScenarioReport.scala | 112 ++++++------------ .../cornichon/steps/regular/DebugStep.scala | 5 +- .../steps/wrapped/ConcurrentlyStep.scala | 15 ++- .../steps/wrapped/EventuallyStep.scala | 13 +- .../steps/wrapped/LogDurationStep.scala | 4 +- .../steps/wrapped/RepeatDuringStep.scala | 15 +-- .../cornichon/steps/wrapped/RepeatStep.scala | 15 +-- .../steps/wrapped/RetryMaxStep.scala | 15 +-- .../cornichon/steps/wrapped/WithinStep.scala | 7 +- .../agourlay/cornichon/core/EngineSpec.scala | 10 +- .../steps/regular/AssertStepSpec.scala | 4 +- .../steps/regular/DebugStepSpec.scala | 2 +- .../steps/regular/EffectStepSpec.scala | 2 +- .../steps/wrapped/ConcurrentlyStepSpec.scala | 4 +- .../steps/wrapped/EventuallyStepSpec.scala | 4 +- .../steps/wrapped/RepeatDuringStepSpec.scala | 6 +- .../steps/wrapped/RepeatStepSpec.scala | 4 +- .../steps/wrapped/RetryMaxStepSpec.scala | 4 +- .../steps/wrapped/WithinStepSpec.scala | 4 +- 24 files changed, 183 insertions(+), 168 deletions(-) create mode 100644 src/main/scala/com/github/agourlay/cornichon/core/LogInstruction.scala diff --git a/build.sbt b/build.sbt index 58bc32a5d..1188a0908 100644 --- a/build.sbt +++ b/build.sbt @@ -45,24 +45,24 @@ libraryDependencies ++= { val fansiV = "0.1.3" val akkaHttpCirce = "1.6.0" Seq( - "com.typesafe.akka" %% "akka-http-core" % akkaHttpV - ,"de.heikoseeberger" %% "akka-sse" % akkaSseV - ,"org.json4s" %% "json4s-jackson" % json4sV // Only use for Json diff - remove asap. - ,"org.typelevel" %% "cats-macros" % catsV - ,"org.typelevel" %% "cats-core" % catsV - ,"org.scalatest" %% "scalatest" % scalaTestV - ,"ch.qos.logback" % "logback-classic" % logbackV - ,"org.parboiled" %% "parboiled" % parboiledV - ,"org.scalacheck" %% "scalacheck" % scalaCheckV - ,"com.lihaoyi" %% "fansi" % fansiV - ,"org.sangria-graphql" %% "sangria" % sangriaV - ,"org.sangria-graphql" %% "sangria-circe" % sangriaCirceV - ,"io.circe" %% "circe-core" % circeVersion - ,"io.circe" %% "circe-generic" % circeVersion - ,"io.circe" %% "circe-parser" % circeVersion - ,"io.circe" %% "circe-optics" % circeVersion // Remove if cursors are used instead or lenses for JsonPath. - ,"de.heikoseeberger" %% "akka-http-circe" % akkaHttpCirce % "test" - ,"com.typesafe.akka" %% "akka-http-experimental" % akkaHttpV % "test" + "com.typesafe.akka" %% "akka-http-core" % akkaHttpV + ,"de.heikoseeberger" %% "akka-sse" % akkaSseV + ,"org.json4s" %% "json4s-jackson" % json4sV // Only used for Json diff - remove asap. + ,"org.typelevel" %% "cats-macros" % catsV + ,"org.typelevel" %% "cats-core" % catsV + ,"org.scalatest" %% "scalatest" % scalaTestV + ,"ch.qos.logback" % "logback-classic" % logbackV + ,"org.parboiled" %% "parboiled" % parboiledV + ,"org.scalacheck" %% "scalacheck" % scalaCheckV + ,"com.lihaoyi" %% "fansi" % fansiV + ,"org.sangria-graphql" %% "sangria" % sangriaV + ,"org.sangria-graphql" %% "sangria-circe" % sangriaCirceV + ,"io.circe" %% "circe-core" % circeVersion + ,"io.circe" %% "circe-generic" % circeVersion + ,"io.circe" %% "circe-parser" % circeVersion + ,"io.circe" %% "circe-optics" % circeVersion // Remove if cursors are used instead or lenses for JsonPath. + ,"de.heikoseeberger" %% "akka-http-circe" % akkaHttpCirce % "test" + ,"com.typesafe.akka" %% "akka-http-experimental" % akkaHttpV % "test" ) } diff --git a/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala b/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala index fb9ff18af..1cd89fec6 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala @@ -2,6 +2,7 @@ package com.github.agourlay.cornichon.core import cats.data.Xor +import scala.annotation.tailrec import scala.concurrent.ExecutionContext import scala.concurrent.duration.Duration @@ -23,14 +24,16 @@ class Engine(executionContext: ExecutionContext) { } } - def runSteps(steps: Vector[Step], session: Session, accLogs: Vector[LogInstruction], depth: Int): StepsReport = - steps.headOption.fold[StepsReport](SuccessRunSteps(session, accLogs)) { step ⇒ - step.run(this, session, depth) match { - case SuccessRunSteps(newSession, updatedLogs) ⇒ + @tailrec + final def runSteps(steps: Vector[Step], session: Session, accLogs: Vector[LogInstruction], depth: Int): StepsResult = + if (steps.isEmpty) SuccessStepsResult(session, accLogs) + else { + steps(0).run(this, session, depth) match { + case SuccessStepsResult(newSession, updatedLogs) ⇒ val nextSteps = steps.drop(1) runSteps(nextSteps, newSession, accLogs ++ updatedLogs, depth) - case f: FailedRunSteps ⇒ + case f: FailureStepsResult ⇒ f.copy(logs = accLogs ++ f.logs) } } @@ -39,11 +42,12 @@ class Engine(executionContext: ExecutionContext) { res match { case Xor.Left(e) ⇒ val runLogs = errorLogs(title, e, depth) - FailedRunSteps(currentStep, e, runLogs, session) + val failedStep = FailedStep(currentStep, e) + FailureStepsResult(failedStep, runLogs, session) case Xor.Right(newSession) ⇒ val runLogs = if (show) Vector(SuccessLogInstruction(title, depth, duration)) else Vector.empty - SuccessRunSteps(newSession, runLogs) + SuccessStepsResult(newSession, runLogs) } def errorLogs(title: String, e: Throwable, depth: Int) = { diff --git a/src/main/scala/com/github/agourlay/cornichon/core/LogInstruction.scala b/src/main/scala/com/github/agourlay/cornichon/core/LogInstruction.scala new file mode 100644 index 000000000..cd3d28ad9 --- /dev/null +++ b/src/main/scala/com/github/agourlay/cornichon/core/LogInstruction.scala @@ -0,0 +1,44 @@ +package com.github.agourlay.cornichon.core + +import scala.concurrent.duration.Duration + +sealed trait LogInstruction { + def message: String + def marginNb: Int + def duration: Option[Duration] + def colorized: String + val physicalMargin = " " + val completeMessage = { + + def withDuration(line: String) = physicalMargin * marginNb + line + duration.fold("")(d ⇒ s" (${d.toMillis} millis)") + + // Inject duration at the end of the first line + message.split('\n').toList match { + case head :: Nil ⇒ + withDuration(head) + case head :: tail ⇒ + (withDuration(head) :: tail.map(l ⇒ physicalMargin * marginNb + l)).mkString("\n") + case _ ⇒ withDuration("") + } + } +} + +case class ScenarioTitleLogInstruction(message: String, marginNb: Int, duration: Option[Duration] = None) extends LogInstruction { + val colorized = '\n' + fansi.Color.White(completeMessage).overlay(attrs = fansi.Underlined.On, start = (physicalMargin * marginNb).length).render +} + +case class InfoLogInstruction(message: String, marginNb: Int, duration: Option[Duration] = None) extends LogInstruction { + val colorized = fansi.Color.White(completeMessage).render +} + +case class SuccessLogInstruction(message: String, marginNb: Int, duration: Option[Duration] = None) extends LogInstruction { + val colorized = fansi.Color.Green(completeMessage).render +} + +case class FailureLogInstruction(message: String, marginNb: Int, duration: Option[Duration] = None) extends LogInstruction { + val colorized = fansi.Color.Red(completeMessage).render +} + +case class DebugLogInstruction(message: String, marginNb: Int, duration: Option[Duration] = None) extends LogInstruction { + val colorized = fansi.Color.Cyan(completeMessage).render +} \ No newline at end of file diff --git a/src/main/scala/com/github/agourlay/cornichon/core/Models.scala b/src/main/scala/com/github/agourlay/cornichon/core/Models.scala index 97b089bb3..bd8ea15bd 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/Models.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/Models.scala @@ -25,7 +25,7 @@ case class DetailedStepAssertion[A](expected: A, result: A, details: A ⇒ Strin trait Step { def title: String - def run(engine: Engine, session: Session, depth: Int)(implicit ec: ExecutionContext): StepsReport + def run(engine: Engine, session: Session, depth: Int)(implicit ec: ExecutionContext): StepsResult } trait WrapperStep extends Step { diff --git a/src/main/scala/com/github/agourlay/cornichon/core/ScalatestIntegration.scala b/src/main/scala/com/github/agourlay/cornichon/core/ScalatestIntegration.scala index 95dbfeb77..ef2d45b58 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/ScalatestIntegration.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/ScalatestIntegration.scala @@ -43,12 +43,12 @@ trait ScalatestIntegration extends WordSpecLike with BeforeAndAfterAll with Para else s.name in { val scenarioReport = runScenario(s) - scenarioReport.stepsRunReport match { - case SuccessRunSteps(newSession, logs) ⇒ + scenarioReport.stepsExecutionResult match { + case SuccessStepsResult(newSession, logs) ⇒ // In case of success, logs are only shown if the scenario contains DebugLogInstruction if (logs.collect { case d: DebugLogInstruction ⇒ d }.nonEmpty) printLogs(logs) assert(true) - case FailedRunSteps(_, _, logs, _) ⇒ + case FailureStepsResult(_, logs, _) ⇒ printLogs(logs) fail( s""" diff --git a/src/main/scala/com/github/agourlay/cornichon/core/ScenarioReport.scala b/src/main/scala/com/github/agourlay/cornichon/core/ScenarioReport.scala index 881d1d132..a4c9641ff 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/ScenarioReport.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/ScenarioReport.scala @@ -1,101 +1,59 @@ package com.github.agourlay.cornichon.core -import scala.concurrent.duration.Duration -sealed trait StepsReport { - def logs: Vector[LogInstruction] - def session: Session - def isSuccess: Boolean - def merge(otherStepsReport: StepsReport): StepsReport -} - -case class SuccessRunSteps(session: Session, logs: Vector[LogInstruction]) extends StepsReport { - val isSuccess = true - - def merge(otherStepRunReport: StepsReport) = otherStepRunReport match { - case s: SuccessRunSteps ⇒ - // Success + Sucess = Success - SuccessRunSteps(session.merge(otherStepRunReport.session), logs ++ otherStepRunReport.logs) - case f: FailedRunSteps ⇒ - // Success + Error = Error - f.copy(session = session.merge(otherStepRunReport.session), logs = logs ++ otherStepRunReport.logs) - } - -} -case class FailedRunSteps(step: Step, error: CornichonError, logs: Vector[LogInstruction], session: Session) extends StepsReport { - val isSuccess = false - - def merge(otherStepRunReport: StepsReport) = otherStepRunReport match { - case s: SuccessRunSteps ⇒ - // Error + Success = Error - this.copy(session = session.merge(otherStepRunReport.session), logs = logs ++ otherStepRunReport.logs) - case f: FailedRunSteps ⇒ - // Error + Error = Error - f.copy(session = session.merge(otherStepRunReport.session), logs = logs ++ otherStepRunReport.logs) - } -} +case class ScenarioReport(scenarioName: String, stepsExecutionResult: StepsResult) { -object FailedRunSteps { - def apply(step: Step, error: Throwable, logs: Vector[LogInstruction], session: Session): FailedRunSteps = { - val e = CornichonError.fromThrowable(error) - FailedRunSteps(step, e, logs, session) - } -} - -case class ScenarioReport(scenarioName: String, stepsRunReport: StepsReport) { - - val msg = stepsRunReport match { - case s: SuccessRunSteps ⇒ + val msg = stepsExecutionResult match { + case s: SuccessStepsResult ⇒ s"Scenario '$scenarioName' succeeded" - case FailedRunSteps(failedStep, error, _, _) ⇒ + case FailureStepsResult(failedStep, _, _) ⇒ s""" | |Scenario '$scenarioName' failed at step: - |${failedStep.title} + |${failedStep.step.title} |with error: - |${error.msg} + |${failedStep.error.msg} | """.trim.stripMargin } } -sealed trait LogInstruction { - def message: String - def marginNb: Int - def duration: Option[Duration] - def colorized: String - val physicalMargin = " " - val completeMessage = { +sealed trait StepsResult { + def logs: Vector[LogInstruction] + def session: Session + def isSuccess: Boolean + def merge(otherStepsReport: StepsResult): StepsResult +} - def withDuration(line: String) = physicalMargin * marginNb + line + duration.fold("")(d ⇒ s" (${d.toMillis} millis)") +case class SuccessStepsResult(session: Session, logs: Vector[LogInstruction]) extends StepsResult { + val isSuccess = true - // Inject duration at the end of the first line - message.split('\n').toList match { - case head :: Nil ⇒ - withDuration(head) - case head :: tail ⇒ - (withDuration(head) :: tail.map(l ⇒ physicalMargin * marginNb + l)).mkString("\n") - case _ ⇒ withDuration("") - } + def merge(otherStepsResult: StepsResult) = otherStepsResult match { + case s: SuccessStepsResult ⇒ + // Success + Sucess = Success + SuccessStepsResult(session.merge(otherStepsResult.session), logs ++ otherStepsResult.logs) + case f: FailureStepsResult ⇒ + // Success + Error = Error + f.copy(session = session.merge(otherStepsResult.session), logs = logs ++ otherStepsResult.logs) } } -case class ScenarioTitleLogInstruction(message: String, marginNb: Int, duration: Option[Duration] = None) extends LogInstruction { - val colorized = '\n' + fansi.Color.White(completeMessage).overlay(attrs = fansi.Underlined.On, start = (physicalMargin * marginNb).length).render -} - -case class InfoLogInstruction(message: String, marginNb: Int, duration: Option[Duration] = None) extends LogInstruction { - val colorized = fansi.Color.White(completeMessage).render -} +case class FailedStep(step: Step, error: CornichonError) -case class SuccessLogInstruction(message: String, marginNb: Int, duration: Option[Duration] = None) extends LogInstruction { - val colorized = fansi.Color.Green(completeMessage).render +object FailedStep { + def fromThrowable(step: Step, error: Throwable) = + FailedStep(step, CornichonError.fromThrowable(error)) } -case class FailureLogInstruction(message: String, marginNb: Int, duration: Option[Duration] = None) extends LogInstruction { - val colorized = fansi.Color.Red(completeMessage).render -} +case class FailureStepsResult(failedStep: FailedStep, logs: Vector[LogInstruction], session: Session) extends StepsResult { + val isSuccess = false -case class DebugLogInstruction(message: String, marginNb: Int, duration: Option[Duration] = None) extends LogInstruction { - val colorized = fansi.Color.Cyan(completeMessage).render + def merge(otherStepRunReport: StepsResult) = otherStepRunReport match { + case s: SuccessStepsResult ⇒ + // Error + Success = Error + this.copy(session = session.merge(otherStepRunReport.session), logs = logs ++ otherStepRunReport.logs) + case f: FailureStepsResult ⇒ + // Error + Error = Error + f.copy(session = session.merge(otherStepRunReport.session), logs = logs ++ otherStepRunReport.logs) + } } \ No newline at end of file diff --git a/src/main/scala/com/github/agourlay/cornichon/steps/regular/DebugStep.scala b/src/main/scala/com/github/agourlay/cornichon/steps/regular/DebugStep.scala index 9701614ed..dcb8af2dc 100644 --- a/src/main/scala/com/github/agourlay/cornichon/steps/regular/DebugStep.scala +++ b/src/main/scala/com/github/agourlay/cornichon/steps/regular/DebugStep.scala @@ -12,10 +12,11 @@ case class DebugStep(message: Session ⇒ String) extends Step { Try { message(session) } match { case Success(debugMessage) ⇒ val runLogs = Vector(DebugLogInstruction(message(session), depth)) - SuccessRunSteps(session, runLogs) + SuccessStepsResult(session, runLogs) case Failure(e) ⇒ val runLogs = engine.errorLogs(title, e, depth) - FailedRunSteps(this, e, runLogs, session) + val failedStep = FailedStep.fromThrowable(this, e) + FailureStepsResult(failedStep, runLogs, session) } } } \ No newline at end of file diff --git a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/ConcurrentlyStep.scala b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/ConcurrentlyStep.scala index 5c0ce2efb..0900101ce 100644 --- a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/ConcurrentlyStep.scala +++ b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/ConcurrentlyStep.scala @@ -19,20 +19,23 @@ case class ConcurrentlyStep(nested: Vector[Step], factor: Int, maxTime: Duration } val results = Try { Await.result(f, maxTime) } match { - case Success(s) ⇒ s - case Failure(e) ⇒ List(FailedRunSteps(this, ConcurrentlyTimeout, Vector(failedTitleLog(depth)), session)) + case Success(s) ⇒ + s + case Failure(e) ⇒ + val failedStep = FailedStep(this, ConcurrentlyTimeout) + List(FailureStepsResult(failedStep, Vector(failedTitleLog(depth)), session)) } // Only the first error report found is used in the logs. - val failedStepRun = results.collectFirst { case f @ FailedRunSteps(_, _, _, _) ⇒ f } - failedStepRun.fold[StepsReport] { + val failedStepRun = results.collectFirst { case f @ FailureStepsResult(_, _, _) ⇒ f } + failedStepRun.fold[StepsResult] { val executionTime = Duration.fromNanos(System.nanoTime - start) - val successStepsRun = results.collect { case s @ SuccessRunSteps(_, _) ⇒ s } + val successStepsRun = results.collect { case s @ SuccessStepsResult(_, _) ⇒ s } //TODO all sessions should be merged? val updatedSession = successStepsRun.head.session //TODO all logs should be merged? val updatedLogs = successTitleLog(depth) +: successStepsRun.head.logs :+ SuccessLogInstruction(s"Concurrently block with factor '$factor' succeeded", depth, Some(executionTime)) - SuccessRunSteps(updatedSession, updatedLogs) + SuccessStepsResult(updatedSession, updatedLogs) } { f ⇒ f.copy(logs = failedTitleLog(depth) +: f.logs :+ FailureLogInstruction(s"Concurrently block failed", depth)) } diff --git a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/EventuallyStep.scala b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/EventuallyStep.scala index 91c6bc45a..fe79a051d 100644 --- a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/EventuallyStep.scala +++ b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/EventuallyStep.scala @@ -24,23 +24,24 @@ case class EventuallyStep(nested: Vector[Step], conf: EventuallyConf) extends Wr def run(engine: Engine, session: Session, depth: Int)(implicit ec: ExecutionContext) = { @tailrec - def retryEventuallySteps(stepsToRetry: Vector[Step], session: Session, conf: EventuallyConf, accLogs: Vector[LogInstruction], retriesNumber: Long, depth: Int): (Long, StepsReport) = { + def retryEventuallySteps(stepsToRetry: Vector[Step], session: Session, conf: EventuallyConf, accLogs: Vector[LogInstruction], retriesNumber: Long, depth: Int): (Long, StepsResult) = { val (res, executionTime) = engine.withDuration { engine.runSteps(stepsToRetry, session, Vector.empty, depth) } val remainingTime = conf.maxTime - executionTime res match { - case s @ SuccessRunSteps(successSession, sLogs) ⇒ + case s @ SuccessStepsResult(successSession, sLogs) ⇒ val runLogs = accLogs ++ sLogs if (remainingTime.gt(Duration.Zero)) { // In case of success all logs are returned but they are not printed by default. (retriesNumber, s.copy(logs = runLogs)) } else { // Run was a success but the time is up. - (retriesNumber, FailedRunSteps(stepsToRetry.last, EventuallyBlockSucceedAfterMaxDuration, runLogs, successSession)) + val failedStep = FailedStep(stepsToRetry.last, EventuallyBlockSucceedAfterMaxDuration) + (retriesNumber, FailureStepsResult(failedStep, runLogs, successSession)) } - case f @ FailedRunSteps(_, _, fLogs, fSession) ⇒ + case f @ FailureStepsResult(_, fLogs, fSession) ⇒ if ((remainingTime - conf.interval).gt(Duration.Zero)) { Thread.sleep(conf.interval.toMillis) retryEventuallySteps(stepsToRetry, session, conf.consume(executionTime + conf.interval), accLogs ++ fLogs, retriesNumber + 1, depth) @@ -58,10 +59,10 @@ case class EventuallyStep(nested: Vector[Step], conf: EventuallyConf) extends Wr val (retries, report) = res report match { - case s @ SuccessRunSteps(sSession, sLogs) ⇒ + case s @ SuccessStepsResult(sSession, sLogs) ⇒ val fullLogs = successTitleLog(depth) +: sLogs :+ SuccessLogInstruction(s"Eventually block succeeded after '$retries' retries", depth, Some(executionTime)) s.copy(logs = fullLogs) - case f @ FailedRunSteps(_, _, eLogs, fSession) ⇒ + case f @ FailureStepsResult(_, eLogs, fSession) ⇒ val fullLogs = failedTitleLog(depth) +: eLogs :+ FailureLogInstruction(s"Eventually block did not complete in time after being retried '$retries' times", depth, Some(executionTime)) f.copy(logs = fullLogs, session = fSession) } diff --git a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/LogDurationStep.scala b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/LogDurationStep.scala index 2469a5999..14a9a5633 100644 --- a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/LogDurationStep.scala +++ b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/LogDurationStep.scala @@ -15,8 +15,8 @@ case class LogDurationStep(nested: Vector[Step], label: String) extends WrapperS } val fullLogs = titleLog +: repeatRes.logs :+ DebugLogInstruction(s"Log duration block with label '$label' ended", depth, Some(executionTime)) repeatRes match { - case s: SuccessRunSteps ⇒ s.copy(logs = fullLogs) - case f: FailedRunSteps ⇒ f.copy(logs = fullLogs) + case s: SuccessStepsResult ⇒ s.copy(logs = fullLogs) + case f: FailureStepsResult ⇒ f.copy(logs = fullLogs) } } diff --git a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStep.scala b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStep.scala index a13404fb8..6ac280bfb 100644 --- a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStep.scala +++ b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStep.scala @@ -12,19 +12,19 @@ case class RepeatDuringStep(nested: Vector[Step], duration: Duration) extends Wr def run(engine: Engine, session: Session, depth: Int)(implicit ec: ExecutionContext) = { @tailrec - def repeatStepsDuring(steps: Vector[Step], session: Session, duration: Duration, accLogs: Vector[LogInstruction], retriesNumber: Long, depth: Int): (Long, StepsReport) = { + def repeatStepsDuring(steps: Vector[Step], session: Session, duration: Duration, accLogs: Vector[LogInstruction], retriesNumber: Long, depth: Int): (Long, StepsResult) = { val (res, executionTime) = engine.withDuration { engine.runSteps(steps, session, Vector.empty, depth) } val remainingTime = duration - executionTime res match { - case s @ SuccessRunSteps(sSession, sLogs) ⇒ + case s @ SuccessStepsResult(sSession, sLogs) ⇒ if (remainingTime.gt(Duration.Zero)) repeatStepsDuring(steps, sSession, remainingTime, accLogs ++ res.logs, retriesNumber + 1, depth) else // In case of success all logs are returned but they are not printed by default. (retriesNumber, s.copy(logs = accLogs ++ sLogs)) - case f @ FailedRunSteps(_, _, eLogs, _) ⇒ + case f @ FailureStepsResult(_, eLogs, _) ⇒ // In case of failure only the logs of the last run are shown to avoid giant traces. (retriesNumber, f.copy(logs = eLogs)) } @@ -37,12 +37,13 @@ case class RepeatDuringStep(nested: Vector[Step], duration: Duration) extends Wr val (retries, report) = repeatRes report match { - case s: SuccessRunSteps ⇒ + case s: SuccessStepsResult ⇒ val fullLogs = successTitleLog(depth) +: report.logs :+ SuccessLogInstruction(s"Repeat block during '$duration' succeeded after '$retries' retries", depth, Some(executionTime)) - SuccessRunSteps(report.session, fullLogs) - case f: FailedRunSteps ⇒ + SuccessStepsResult(report.session, fullLogs) + case f: FailureStepsResult ⇒ val fullLogs = failedTitleLog(depth) +: report.logs :+ FailureLogInstruction(s"Repeat block during '$duration' failed after being retried '$retries' times", depth, Some(executionTime)) - FailedRunSteps(f.step, RepeatDuringBlockContainFailedSteps, fullLogs, report.session) + val failedStep = FailedStep(f.failedStep.step, RepeatDuringBlockContainFailedSteps) + FailureStepsResult(failedStep, fullLogs, report.session) } } } diff --git a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatStep.scala b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatStep.scala index 6907bb796..092460920 100644 --- a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatStep.scala +++ b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatStep.scala @@ -14,12 +14,12 @@ case class RepeatStep(nested: Vector[Step], occurence: Int) extends WrapperStep def run(engine: Engine, session: Session, depth: Int)(implicit ec: ExecutionContext) = { @tailrec - def repeatSuccessSteps(session: Session, retriesNumber: Long = 0): (Long, StepsReport) = { + def repeatSuccessSteps(session: Session, retriesNumber: Long = 0): (Long, StepsResult) = { engine.runSteps(nested, session, Vector.empty, depth + 1) match { - case s: SuccessRunSteps ⇒ + case s: SuccessStepsResult ⇒ if (retriesNumber == occurence - 1) (retriesNumber, s) else repeatSuccessSteps(s.session, retriesNumber + 1) - case f: FailedRunSteps ⇒ + case f: FailureStepsResult ⇒ // In case of failure only the logs of the last run are shown to avoid giant traces. (retriesNumber, f) } @@ -32,12 +32,13 @@ case class RepeatStep(nested: Vector[Step], occurence: Int) extends WrapperStep val (retries, report) = repeatRes report match { - case s: SuccessRunSteps ⇒ + case s: SuccessStepsResult ⇒ val fullLogs = successTitleLog(depth) +: report.logs :+ SuccessLogInstruction(s"Repeat block with occurence '$occurence' succeeded", depth, Some(executionTime)) - SuccessRunSteps(report.session, fullLogs) - case f: FailedRunSteps ⇒ + SuccessStepsResult(report.session, fullLogs) + case f: FailureStepsResult ⇒ val fullLogs = failedTitleLog(depth) +: report.logs :+ FailureLogInstruction(s"Repeat block with occurence '$occurence' failed after '$retries' occurence", depth, Some(executionTime)) - FailedRunSteps(f.step, RepeatBlockContainFailedSteps, fullLogs, report.session) + val failedStep = FailedStep(f.failedStep.step, RepeatBlockContainFailedSteps) + FailureStepsResult(failedStep, fullLogs, report.session) } } } \ No newline at end of file diff --git a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RetryMaxStep.scala b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RetryMaxStep.scala index f01f3e8e4..d4a0e3e16 100644 --- a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RetryMaxStep.scala +++ b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RetryMaxStep.scala @@ -14,11 +14,11 @@ case class RetryMaxStep(nested: Vector[Step], limit: Int) extends WrapperStep { def run(engine: Engine, session: Session, depth: Int)(implicit ec: ExecutionContext) = { @tailrec - def retryMaxSteps(steps: Vector[Step], session: Session, limit: Int, accLogs: Vector[LogInstruction], retriesNumber: Long, depth: Int): (Long, StepsReport) = { + def retryMaxSteps(steps: Vector[Step], session: Session, limit: Int, accLogs: Vector[LogInstruction], retriesNumber: Long, depth: Int): (Long, StepsResult) = { engine.runSteps(steps, session, Vector.empty, depth) match { - case s @ SuccessRunSteps(_, sLogs) ⇒ + case s @ SuccessStepsResult(_, sLogs) ⇒ (retriesNumber, s.copy(logs = accLogs ++ sLogs)) - case f @ FailedRunSteps(_, _, eLogs, fSession) ⇒ + case f @ FailureStepsResult(_, eLogs, fSession) ⇒ if (limit > 0) // In case of success all logs are returned but they are not printed by default. retryMaxSteps(steps, session, limit - 1, accLogs ++ eLogs, retriesNumber + 1, depth) @@ -35,12 +35,13 @@ case class RetryMaxStep(nested: Vector[Step], limit: Int) extends WrapperStep { val (retries, report) = repeatRes report match { - case s: SuccessRunSteps ⇒ + case s: SuccessStepsResult ⇒ val fullLogs = successTitleLog(depth) +: report.logs :+ SuccessLogInstruction(s"RetryMax block with limit '$limit' succeeded after '$retries' retries", depth, Some(executionTime)) - SuccessRunSteps(report.session, fullLogs) - case f: FailedRunSteps ⇒ + SuccessStepsResult(report.session, fullLogs) + case f: FailureStepsResult ⇒ val fullLogs = failedTitleLog(depth) +: report.logs :+ FailureLogInstruction(s"RetryMax block with limit '$limit' failed", depth, Some(executionTime)) - FailedRunSteps(f.step, RetryMaxBlockReachedLimit, fullLogs, report.session) + val failedStep = FailedStep(f.failedStep.step, RetryMaxBlockReachedLimit) + FailureStepsResult(failedStep, fullLogs, report.session) } } } diff --git a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/WithinStep.scala b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/WithinStep.scala index cc5730d39..39d9b6add 100644 --- a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/WithinStep.scala +++ b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/WithinStep.scala @@ -15,17 +15,18 @@ case class WithinStep(nested: Vector[Step], maxDuration: Duration) extends Wrapp } res match { - case s @ SuccessRunSteps(sSession, sLogs) ⇒ + case s @ SuccessStepsResult(sSession, sLogs) ⇒ val successLogs = successTitleLog(depth) +: sLogs if (executionTime.gt(maxDuration)) { val fullLogs = successLogs :+ FailureLogInstruction(s"Within block did not complete in time", depth, Some(executionTime)) // The nested steps were successfull but the did not finish in time, the last step is picked as failed step - FailedRunSteps(nested.last, WithinBlockSucceedAfterMaxDuration, fullLogs, sSession) + val failedStep = FailedStep(nested.last, WithinBlockSucceedAfterMaxDuration) + FailureStepsResult(failedStep, fullLogs, sSession) } else { val fullLogs = successLogs :+ SuccessLogInstruction(s"Within block succeeded", depth, Some(executionTime)) s.copy(logs = fullLogs) } - case f @ FailedRunSteps(_, _, eLogs, fSession) ⇒ + case f @ FailureStepsResult(_, eLogs, fSession) ⇒ // Failure of the nested steps have a higher priority val fullLogs = failedTitleLog(depth) +: eLogs f.copy(logs = fullLogs, session = fSession) diff --git a/src/test/scala/com/github/agourlay/cornichon/core/EngineSpec.scala b/src/test/scala/com/github/agourlay/cornichon/core/EngineSpec.scala index 30f0244f7..961a2766f 100644 --- a/src/test/scala/com/github/agourlay/cornichon/core/EngineSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/core/EngineSpec.scala @@ -15,7 +15,7 @@ class EngineSpec extends WordSpec with Matchers { val session = Session.newSession val steps = Vector(AssertStep[Int]("first step", s ⇒ SimpleStepAssertion(2 + 1, 3))) val s = Scenario("test", steps) - engine.runScenario(session)(s).stepsRunReport.isSuccess should be(true) + engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(true) } "stop at first failed step" in { @@ -27,12 +27,12 @@ class EngineSpec extends WordSpec with Matchers { step1, step2, step3 ) val s = Scenario("test", steps) - val res = engine.runScenario(session)(s).stepsRunReport + val res = engine.runScenario(session)(s).stepsExecutionResult withClue(s"logs were ${res.logs}") { res match { - case s: SuccessRunSteps ⇒ fail("Should be a FailedScenarioReport") - case f: FailedRunSteps ⇒ - f.error.msg.replaceAll("\r", "") should be(""" + case s: SuccessStepsResult ⇒ fail("Should be a FailedScenarioReport") + case f: FailureStepsResult ⇒ + f.failedStep.error.msg.replaceAll("\r", "") should be(""" |expected result was: |'4' |but actual result is: diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/regular/AssertStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/regular/AssertStepSpec.scala index 6a4dee761..826594c0d 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/regular/AssertStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/regular/AssertStepSpec.scala @@ -20,7 +20,7 @@ class AssertStepSpec extends WordSpec with Matchers { }) ) val s = Scenario("scenario with stupid test", steps) - engine.runScenario(session)(s).stepsRunReport.isSuccess should be(false) + engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(false) } "success if non equality was expected" in { @@ -31,7 +31,7 @@ class AssertStepSpec extends WordSpec with Matchers { ) ) val s = Scenario("scenario with unresolved", steps) - engine.runScenario(session)(s).stepsRunReport.isSuccess should be(true) + engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(true) } } diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/regular/DebugStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/regular/DebugStepSpec.scala index 7364dd60b..6fe9f3fed 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/regular/DebugStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/regular/DebugStepSpec.scala @@ -17,7 +17,7 @@ class DebugStepSpec extends WordSpec with Matchers { "Never gonna read this" }) val s = Scenario("scenario with faulty debug step", Vector(step)) - engine.runScenario(session)(s).stepsRunReport.isSuccess should be(false) + engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(false) } } } diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/regular/EffectStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/regular/EffectStepSpec.scala index 0a3e789e8..95742af9e 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/regular/EffectStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/regular/EffectStepSpec.scala @@ -17,7 +17,7 @@ class EffectStepSpec extends WordSpec with Matchers { s }) val s = Scenario("scenario with broken effect step", Vector(step)) - engine.runScenario(session)(s).stepsRunReport.isSuccess should be(false) + engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(false) } } } diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/ConcurrentlyStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/ConcurrentlyStepSpec.scala index c5c2ed805..98e0c4c9a 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/ConcurrentlyStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/ConcurrentlyStepSpec.scala @@ -25,7 +25,7 @@ class ConcurrentlyStepSpec extends WordSpec with Matchers { ConcurrentlyStep(nested, 3, 200.millis) ) val s = Scenario("scenario with Concurrently", steps) - engine.runScenario(Session.newSession)(s).stepsRunReport.isSuccess should be(false) + engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(false) } "run nested block 'n' times" in { @@ -44,7 +44,7 @@ class ConcurrentlyStepSpec extends WordSpec with Matchers { ConcurrentlyStep(nested, loop, 300.millis) ) val s = Scenario("scenario with Concurrently", steps) - engine.runScenario(Session.newSession)(s).stepsRunReport.isSuccess should be(true) + engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(true) uglyCounter.intValue() should be(loop) } } diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/EventuallyStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/EventuallyStepSpec.scala index 022edbde2..9972593b9 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/EventuallyStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/EventuallyStepSpec.scala @@ -24,7 +24,7 @@ class EventuallyStepSpec extends WordSpec with Matchers { val steps = Vector(EventuallyStep(nested, eventuallyConf)) val s = Scenario("scenario with eventually", steps) - engine.runScenario(session)(s).stepsRunReport.isSuccess should be(true) + engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(true) } "replay eventually wrapped steps until limit" in { @@ -39,7 +39,7 @@ class EventuallyStepSpec extends WordSpec with Matchers { EventuallyStep(nested, eventuallyConf) ) val s = Scenario("scenario with eventually that fails", steps) - engine.runScenario(session)(s).stepsRunReport.isSuccess should be(false) + engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(false) } } diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStepSpec.scala index e92fa3932..ffca8850c 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStepSpec.scala @@ -24,7 +24,7 @@ class RepeatDuringStepSpec extends WordSpec with Matchers { RepeatDuringStep(nested, 5.millis) ) val s = Scenario("scenario with RepeatDuring", steps) - engine.runScenario(Session.newSession)(s).stepsRunReport.isSuccess should be(false) + engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(false) } "repeat steps inside 'repeatDuring' for at least the duration param" in { @@ -42,7 +42,7 @@ class RepeatDuringStepSpec extends WordSpec with Matchers { ) val s = Scenario("scenario with RepeatDuring", steps) val now = System.nanoTime - engine.runScenario(Session.newSession)(s).stepsRunReport.isSuccess should be(true) + engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(true) val executionTime = Duration.fromNanos(System.nanoTime - now) withClue(executionTime.toMillis) { executionTime.gt(50.millis) should be(true) @@ -66,7 +66,7 @@ class RepeatDuringStepSpec extends WordSpec with Matchers { ) val s = Scenario("scenario with RepeatDuring", steps) val now = System.nanoTime - engine.runScenario(Session.newSession)(s).stepsRunReport.isSuccess should be(true) + engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(true) val executionTime = Duration.fromNanos(System.nanoTime - now) withClue(executionTime.toMillis) { executionTime.gt(50.millis) should be(true) diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatStepSpec.scala index 0bdd63acb..edaf5bedf 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatStepSpec.scala @@ -22,7 +22,7 @@ class RepeatStepSpec extends WordSpec with Matchers { RepeatStep(nested, 5) ) val s = Scenario("scenario with Repeat", steps) - engine.runScenario(Session.newSession)(s).stepsRunReport.isSuccess should be(false) + engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(false) } "repeat steps inside a 'repeat' block" in { @@ -41,7 +41,7 @@ class RepeatStepSpec extends WordSpec with Matchers { RepeatStep(nested, loop) ) val s = Scenario("scenario with Repeat", steps) - engine.runScenario(Session.newSession)(s).stepsRunReport.isSuccess should be(true) + engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(true) uglyCounter should be(loop) } } diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RetryMaxStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RetryMaxStepSpec.scala index 557297f58..8bc24e876 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RetryMaxStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RetryMaxStepSpec.scala @@ -27,7 +27,7 @@ class RetryMaxStepSpec extends WordSpec with Matchers { RetryMaxStep(nested, loop) ) val s = Scenario("scenario with RetryMax", steps) - engine.runScenario(Session.newSession)(s).stepsRunReport.isSuccess should be(false) + engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(false) // Initial run + 'loop' retries uglyCounter should be(loop + 1) } @@ -48,7 +48,7 @@ class RetryMaxStepSpec extends WordSpec with Matchers { RetryMaxStep(nested, max) ) val s = Scenario("scenario with RetryMax", steps) - engine.runScenario(Session.newSession)(s).stepsRunReport.isSuccess should be(true) + engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(true) uglyCounter should be(max - 2) } } diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/WithinStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/WithinStepSpec.scala index eae28422e..e5ec6e6bf 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/WithinStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/WithinStepSpec.scala @@ -28,7 +28,7 @@ class WithinStepSpec extends WordSpec with Matchers { WithinStep(nested, d) ) val s = Scenario("scenario with Within", steps) - engine.runScenario(session)(s).stepsRunReport.isSuccess should be(true) + engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(true) } "fail if duration of 'within' is exceeded" in { @@ -47,7 +47,7 @@ class WithinStepSpec extends WordSpec with Matchers { WithinStep(nested, d) ) val s = Scenario("scenario with Within", steps) - engine.runScenario(session)(s).stepsRunReport.isSuccess should be(false) + engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(false) } } From 372db7c0ad2e92ce26f4c9d704c892ca7d99a7e3 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Sun, 5 Jun 2016 19:59:34 +0200 Subject: [PATCH 14/39] refactor ScenarioReport --- .../agourlay/cornichon/core/Engine.scala | 7 +- .../cornichon/core/LogInstruction.scala | 7 ++ .../cornichon/core/ScalatestIntegration.scala | 21 ++---- .../cornichon/core/ScenarioReport.scala | 67 +++++++++++-------- .../cornichon/steps/regular/DebugStep.scala | 2 +- .../steps/wrapped/ConcurrentlyStep.scala | 2 +- .../steps/wrapped/EventuallyStep.scala | 10 +-- .../steps/wrapped/RepeatDuringStep.scala | 4 +- .../cornichon/steps/wrapped/RepeatStep.scala | 2 +- .../steps/wrapped/RetryMaxStep.scala | 4 +- .../cornichon/steps/wrapped/WithinStep.scala | 6 +- .../agourlay/cornichon/core/EngineSpec.scala | 8 +-- .../steps/regular/AssertStepSpec.scala | 4 +- .../steps/regular/DebugStepSpec.scala | 2 +- .../steps/regular/EffectStepSpec.scala | 2 +- .../steps/wrapped/ConcurrentlyStepSpec.scala | 4 +- .../steps/wrapped/EventuallyStepSpec.scala | 4 +- .../steps/wrapped/RepeatDuringStepSpec.scala | 6 +- .../steps/wrapped/RepeatStepSpec.scala | 4 +- .../steps/wrapped/RetryMaxStepSpec.scala | 4 +- .../steps/wrapped/WithinStepSpec.scala | 4 +- 21 files changed, 93 insertions(+), 81 deletions(-) diff --git a/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala b/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala index 1cd89fec6..9d232f037 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala @@ -15,12 +15,11 @@ class Engine(executionContext: ExecutionContext) { val titleLog = ScenarioTitleLogInstruction(s"Scenario : ${scenario.name}", initMargin) val mainRunReport = runSteps(scenario.steps, session, Vector(titleLog), initMargin + 1) if (finallySteps.isEmpty) - ScenarioReport(scenario.name, mainRunReport) + ScenarioReport.build(scenario.name, mainRunReport) else { // Reuse mainline session val finallyReport = runSteps(finallySteps.toVector, mainRunReport.session, Vector.empty, initMargin + 1) - val mergedReport = mainRunReport.merge(finallyReport) - ScenarioReport(scenario.name, mergedReport) + ScenarioReport.build(scenario.name, mainRunReport, finallyReport) } } @@ -43,7 +42,7 @@ class Engine(executionContext: ExecutionContext) { case Xor.Left(e) ⇒ val runLogs = errorLogs(title, e, depth) val failedStep = FailedStep(currentStep, e) - FailureStepsResult(failedStep, runLogs, session) + FailureStepsResult(failedStep, session, runLogs) case Xor.Right(newSession) ⇒ val runLogs = if (show) Vector(SuccessLogInstruction(title, depth, duration)) else Vector.empty diff --git a/src/main/scala/com/github/agourlay/cornichon/core/LogInstruction.scala b/src/main/scala/com/github/agourlay/cornichon/core/LogInstruction.scala index cd3d28ad9..f3079948a 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/LogInstruction.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/LogInstruction.scala @@ -23,6 +23,13 @@ sealed trait LogInstruction { } } +object LogInstruction { + def printLogs(logs: Seq[LogInstruction]): Unit = { + logs.foreach(l ⇒ println(l.colorized)) + print('\n') + } +} + case class ScenarioTitleLogInstruction(message: String, marginNb: Int, duration: Option[Duration] = None) extends LogInstruction { val colorized = '\n' + fansi.Color.White(completeMessage).overlay(attrs = fansi.Underlined.On, start = (physicalMargin * marginNb).length).render } diff --git a/src/main/scala/com/github/agourlay/cornichon/core/ScalatestIntegration.scala b/src/main/scala/com/github/agourlay/cornichon/core/ScalatestIntegration.scala index ef2d45b58..6cd589d05 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/ScalatestIntegration.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/ScalatestIntegration.scala @@ -1,9 +1,9 @@ package com.github.agourlay.cornichon.core import com.github.agourlay.cornichon.CornichonFeature +import com.github.agourlay.cornichon.core.LogInstruction._ import org.scalatest.{ BeforeAndAfterAll, ParallelTestExecution, WordSpecLike } -import scala.Console._ import scala.util.{ Failure, Success, Try } trait ScalatestIntegration extends WordSpecLike with BeforeAndAfterAll with ParallelTestExecution { @@ -42,19 +42,18 @@ trait ScalatestIntegration extends WordSpecLike with BeforeAndAfterAll with Para s.name ignore {} else s.name in { - val scenarioReport = runScenario(s) - scenarioReport.stepsExecutionResult match { - case SuccessStepsResult(newSession, logs) ⇒ + runScenario(s) match { + case SuccessScenarioReport(_, _, logs) ⇒ // In case of success, logs are only shown if the scenario contains DebugLogInstruction if (logs.collect { case d: DebugLogInstruction ⇒ d }.nonEmpty) printLogs(logs) assert(true) - case FailureStepsResult(_, logs, _) ⇒ + case f @ FailureScenarioReport(_, _, _, logs) ⇒ printLogs(logs) fail( s""" - |${scenarioReport.msg} + |${f.msg} |replay only this scenario with: - |${replayCmd(feat.name, s.name)} + |${scalaTestReplayCmd(feat.name, s.name)} | |""".stripMargin ) @@ -62,15 +61,9 @@ trait ScalatestIntegration extends WordSpecLike with BeforeAndAfterAll with Para } } } - } - private def replayCmd(featureName: String, scenarioName: String) = + private def scalaTestReplayCmd(featureName: String, scenarioName: String) = s"""testOnly *${this.getClass.getSimpleName} -- -t "$featureName should $scenarioName" """ - private def printLogs(logs: Seq[LogInstruction]): Unit = { - logs.foreach(l ⇒ println(l.colorized)) - print('\n') - } - } diff --git a/src/main/scala/com/github/agourlay/cornichon/core/ScenarioReport.scala b/src/main/scala/com/github/agourlay/cornichon/core/ScenarioReport.scala index a4c9641ff..d32f174b0 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/ScenarioReport.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/ScenarioReport.scala @@ -1,41 +1,63 @@ package com.github.agourlay.cornichon.core +trait ScenarioReport { + def scenarioName: String + def isSuccess: Boolean + def session: Session + def logs: Vector[LogInstruction] +} + +object ScenarioReport { + def build(scenarioName: String, stepsResult: StepsResult*): ScenarioReport = mergeReport(stepsResult) match { + case SuccessStepsResult(s, l) ⇒ SuccessScenarioReport(scenarioName, s, l) + case FailureStepsResult(f, s, l) ⇒ FailureScenarioReport(scenarioName, f, s, l) + } + + def mergeReport(stepsResults: Seq[StepsResult]) = { + stepsResults.reduceLeft { (acc, nextResult) ⇒ + (acc, nextResult) match { + // Success + Sucess = Success + case (SuccessStepsResult(leftSession, leftLogs), SuccessStepsResult(rightSession, rightLogs)) ⇒ + SuccessStepsResult(leftSession.merge(rightSession), leftLogs ++ rightLogs) + // Success + Error = Error + case (SuccessStepsResult(leftSession, leftLogs), FailureStepsResult(failedStep, rightSession, rightLogs)) ⇒ + FailureStepsResult(failedStep, leftSession.merge(rightSession), leftLogs ++ rightLogs) + // Error + Success = Error + case (FailureStepsResult(failedStep, leftSession, leftLogs), SuccessStepsResult(rightSession, rightLogs)) ⇒ + FailureStepsResult(failedStep, leftSession.merge(rightSession), leftLogs ++ rightLogs) + // Error + Error = Error + case (FailureStepsResult(leftFailedStep, leftSession, leftLogs), FailureStepsResult(rightFailedStep, rightSession, rightLogs)) ⇒ + FailureStepsResult(leftFailedStep, leftSession.merge(rightSession), leftLogs ++ rightLogs) + } + } + } +} -case class ScenarioReport(scenarioName: String, stepsExecutionResult: StepsResult) { +case class SuccessScenarioReport(scenarioName: String, session: Session, logs: Vector[LogInstruction]) extends ScenarioReport { + val isSuccess = true +} - val msg = stepsExecutionResult match { - case s: SuccessStepsResult ⇒ - s"Scenario '$scenarioName' succeeded" +case class FailureScenarioReport(scenarioName: String, failedStep: FailedStep, session: Session, logs: Vector[LogInstruction]) extends ScenarioReport { - case FailureStepsResult(failedStep, _, _) ⇒ - s""" + val isSuccess = false + + val msg = s""" | |Scenario '$scenarioName' failed at step: |${failedStep.step.title} |with error: |${failedStep.error.msg} | """.trim.stripMargin - } } sealed trait StepsResult { def logs: Vector[LogInstruction] def session: Session def isSuccess: Boolean - def merge(otherStepsReport: StepsResult): StepsResult } case class SuccessStepsResult(session: Session, logs: Vector[LogInstruction]) extends StepsResult { val isSuccess = true - - def merge(otherStepsResult: StepsResult) = otherStepsResult match { - case s: SuccessStepsResult ⇒ - // Success + Sucess = Success - SuccessStepsResult(session.merge(otherStepsResult.session), logs ++ otherStepsResult.logs) - case f: FailureStepsResult ⇒ - // Success + Error = Error - f.copy(session = session.merge(otherStepsResult.session), logs = logs ++ otherStepsResult.logs) - } } case class FailedStep(step: Step, error: CornichonError) @@ -45,15 +67,6 @@ object FailedStep { FailedStep(step, CornichonError.fromThrowable(error)) } -case class FailureStepsResult(failedStep: FailedStep, logs: Vector[LogInstruction], session: Session) extends StepsResult { +case class FailureStepsResult(failedStep: FailedStep, session: Session, logs: Vector[LogInstruction]) extends StepsResult { val isSuccess = false - - def merge(otherStepRunReport: StepsResult) = otherStepRunReport match { - case s: SuccessStepsResult ⇒ - // Error + Success = Error - this.copy(session = session.merge(otherStepRunReport.session), logs = logs ++ otherStepRunReport.logs) - case f: FailureStepsResult ⇒ - // Error + Error = Error - f.copy(session = session.merge(otherStepRunReport.session), logs = logs ++ otherStepRunReport.logs) - } } \ No newline at end of file diff --git a/src/main/scala/com/github/agourlay/cornichon/steps/regular/DebugStep.scala b/src/main/scala/com/github/agourlay/cornichon/steps/regular/DebugStep.scala index dcb8af2dc..dc305760c 100644 --- a/src/main/scala/com/github/agourlay/cornichon/steps/regular/DebugStep.scala +++ b/src/main/scala/com/github/agourlay/cornichon/steps/regular/DebugStep.scala @@ -16,7 +16,7 @@ case class DebugStep(message: Session ⇒ String) extends Step { case Failure(e) ⇒ val runLogs = engine.errorLogs(title, e, depth) val failedStep = FailedStep.fromThrowable(this, e) - FailureStepsResult(failedStep, runLogs, session) + FailureStepsResult(failedStep, session, runLogs) } } } \ No newline at end of file diff --git a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/ConcurrentlyStep.scala b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/ConcurrentlyStep.scala index 0900101ce..7b7c7b2cb 100644 --- a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/ConcurrentlyStep.scala +++ b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/ConcurrentlyStep.scala @@ -23,7 +23,7 @@ case class ConcurrentlyStep(nested: Vector[Step], factor: Int, maxTime: Duration s case Failure(e) ⇒ val failedStep = FailedStep(this, ConcurrentlyTimeout) - List(FailureStepsResult(failedStep, Vector(failedTitleLog(depth)), session)) + List(FailureStepsResult(failedStep, session, Vector(failedTitleLog(depth)))) } // Only the first error report found is used in the logs. diff --git a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/EventuallyStep.scala b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/EventuallyStep.scala index fe79a051d..3b54133b4 100644 --- a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/EventuallyStep.scala +++ b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/EventuallyStep.scala @@ -38,16 +38,16 @@ case class EventuallyStep(nested: Vector[Step], conf: EventuallyConf) extends Wr } else { // Run was a success but the time is up. val failedStep = FailedStep(stepsToRetry.last, EventuallyBlockSucceedAfterMaxDuration) - (retriesNumber, FailureStepsResult(failedStep, runLogs, successSession)) + (retriesNumber, FailureStepsResult(failedStep, successSession, runLogs)) } - case f @ FailureStepsResult(_, fLogs, fSession) ⇒ + case f @ FailureStepsResult(_, fSession, fLogs) ⇒ if ((remainingTime - conf.interval).gt(Duration.Zero)) { Thread.sleep(conf.interval.toMillis) retryEventuallySteps(stepsToRetry, session, conf.consume(executionTime + conf.interval), accLogs ++ fLogs, retriesNumber + 1, depth) } else { // In case of failure only the logs of the last run are shown to avoid giant traces. - (retriesNumber, f.copy(logs = fLogs, session = fSession)) + (retriesNumber, f.copy(session = fSession, logs = fLogs)) } } } @@ -62,9 +62,9 @@ case class EventuallyStep(nested: Vector[Step], conf: EventuallyConf) extends Wr case s @ SuccessStepsResult(sSession, sLogs) ⇒ val fullLogs = successTitleLog(depth) +: sLogs :+ SuccessLogInstruction(s"Eventually block succeeded after '$retries' retries", depth, Some(executionTime)) s.copy(logs = fullLogs) - case f @ FailureStepsResult(_, eLogs, fSession) ⇒ + case f @ FailureStepsResult(_, fSession, eLogs) ⇒ val fullLogs = failedTitleLog(depth) +: eLogs :+ FailureLogInstruction(s"Eventually block did not complete in time after being retried '$retries' times", depth, Some(executionTime)) - f.copy(logs = fullLogs, session = fSession) + f.copy(session = fSession, logs = fullLogs) } } } diff --git a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStep.scala b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStep.scala index 6ac280bfb..1d0835600 100644 --- a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStep.scala +++ b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStep.scala @@ -24,7 +24,7 @@ case class RepeatDuringStep(nested: Vector[Step], duration: Duration) extends Wr else // In case of success all logs are returned but they are not printed by default. (retriesNumber, s.copy(logs = accLogs ++ sLogs)) - case f @ FailureStepsResult(_, eLogs, _) ⇒ + case f @ FailureStepsResult(_, _, eLogs) ⇒ // In case of failure only the logs of the last run are shown to avoid giant traces. (retriesNumber, f.copy(logs = eLogs)) } @@ -43,7 +43,7 @@ case class RepeatDuringStep(nested: Vector[Step], duration: Duration) extends Wr case f: FailureStepsResult ⇒ val fullLogs = failedTitleLog(depth) +: report.logs :+ FailureLogInstruction(s"Repeat block during '$duration' failed after being retried '$retries' times", depth, Some(executionTime)) val failedStep = FailedStep(f.failedStep.step, RepeatDuringBlockContainFailedSteps) - FailureStepsResult(failedStep, fullLogs, report.session) + FailureStepsResult(failedStep, report.session, fullLogs) } } } diff --git a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatStep.scala b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatStep.scala index 092460920..7f24df27e 100644 --- a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatStep.scala +++ b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatStep.scala @@ -38,7 +38,7 @@ case class RepeatStep(nested: Vector[Step], occurence: Int) extends WrapperStep case f: FailureStepsResult ⇒ val fullLogs = failedTitleLog(depth) +: report.logs :+ FailureLogInstruction(s"Repeat block with occurence '$occurence' failed after '$retries' occurence", depth, Some(executionTime)) val failedStep = FailedStep(f.failedStep.step, RepeatBlockContainFailedSteps) - FailureStepsResult(failedStep, fullLogs, report.session) + FailureStepsResult(failedStep, report.session, fullLogs) } } } \ No newline at end of file diff --git a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RetryMaxStep.scala b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RetryMaxStep.scala index d4a0e3e16..e6e85e25d 100644 --- a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RetryMaxStep.scala +++ b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/RetryMaxStep.scala @@ -18,7 +18,7 @@ case class RetryMaxStep(nested: Vector[Step], limit: Int) extends WrapperStep { engine.runSteps(steps, session, Vector.empty, depth) match { case s @ SuccessStepsResult(_, sLogs) ⇒ (retriesNumber, s.copy(logs = accLogs ++ sLogs)) - case f @ FailureStepsResult(_, eLogs, fSession) ⇒ + case f @ FailureStepsResult(_, fSession, eLogs) ⇒ if (limit > 0) // In case of success all logs are returned but they are not printed by default. retryMaxSteps(steps, session, limit - 1, accLogs ++ eLogs, retriesNumber + 1, depth) @@ -41,7 +41,7 @@ case class RetryMaxStep(nested: Vector[Step], limit: Int) extends WrapperStep { case f: FailureStepsResult ⇒ val fullLogs = failedTitleLog(depth) +: report.logs :+ FailureLogInstruction(s"RetryMax block with limit '$limit' failed", depth, Some(executionTime)) val failedStep = FailedStep(f.failedStep.step, RetryMaxBlockReachedLimit) - FailureStepsResult(failedStep, fullLogs, report.session) + FailureStepsResult(failedStep, report.session, fullLogs) } } } diff --git a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/WithinStep.scala b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/WithinStep.scala index 39d9b6add..b5568e6c5 100644 --- a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/WithinStep.scala +++ b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/WithinStep.scala @@ -21,15 +21,15 @@ case class WithinStep(nested: Vector[Step], maxDuration: Duration) extends Wrapp val fullLogs = successLogs :+ FailureLogInstruction(s"Within block did not complete in time", depth, Some(executionTime)) // The nested steps were successfull but the did not finish in time, the last step is picked as failed step val failedStep = FailedStep(nested.last, WithinBlockSucceedAfterMaxDuration) - FailureStepsResult(failedStep, fullLogs, sSession) + FailureStepsResult(failedStep, sSession, fullLogs) } else { val fullLogs = successLogs :+ SuccessLogInstruction(s"Within block succeeded", depth, Some(executionTime)) s.copy(logs = fullLogs) } - case f @ FailureStepsResult(_, eLogs, fSession) ⇒ + case f @ FailureStepsResult(_, fSession, eLogs) ⇒ // Failure of the nested steps have a higher priority val fullLogs = failedTitleLog(depth) +: eLogs - f.copy(logs = fullLogs, session = fSession) + f.copy(session = fSession, logs = fullLogs) } } } diff --git a/src/test/scala/com/github/agourlay/cornichon/core/EngineSpec.scala b/src/test/scala/com/github/agourlay/cornichon/core/EngineSpec.scala index 961a2766f..2d99c98a0 100644 --- a/src/test/scala/com/github/agourlay/cornichon/core/EngineSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/core/EngineSpec.scala @@ -15,7 +15,7 @@ class EngineSpec extends WordSpec with Matchers { val session = Session.newSession val steps = Vector(AssertStep[Int]("first step", s ⇒ SimpleStepAssertion(2 + 1, 3))) val s = Scenario("test", steps) - engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(true) + engine.runScenario(session)(s).isSuccess should be(true) } "stop at first failed step" in { @@ -27,11 +27,11 @@ class EngineSpec extends WordSpec with Matchers { step1, step2, step3 ) val s = Scenario("test", steps) - val res = engine.runScenario(session)(s).stepsExecutionResult + val res = engine.runScenario(session)(s) withClue(s"logs were ${res.logs}") { res match { - case s: SuccessStepsResult ⇒ fail("Should be a FailedScenarioReport") - case f: FailureStepsResult ⇒ + case s: SuccessScenarioReport ⇒ fail("Should be a FailedScenarioReport") + case f: FailureScenarioReport ⇒ f.failedStep.error.msg.replaceAll("\r", "") should be(""" |expected result was: |'4' diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/regular/AssertStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/regular/AssertStepSpec.scala index 826594c0d..a0c656446 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/regular/AssertStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/regular/AssertStepSpec.scala @@ -20,7 +20,7 @@ class AssertStepSpec extends WordSpec with Matchers { }) ) val s = Scenario("scenario with stupid test", steps) - engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(false) + engine.runScenario(session)(s).isSuccess should be(false) } "success if non equality was expected" in { @@ -31,7 +31,7 @@ class AssertStepSpec extends WordSpec with Matchers { ) ) val s = Scenario("scenario with unresolved", steps) - engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(true) + engine.runScenario(session)(s).isSuccess should be(true) } } diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/regular/DebugStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/regular/DebugStepSpec.scala index 6fe9f3fed..4f21da318 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/regular/DebugStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/regular/DebugStepSpec.scala @@ -17,7 +17,7 @@ class DebugStepSpec extends WordSpec with Matchers { "Never gonna read this" }) val s = Scenario("scenario with faulty debug step", Vector(step)) - engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(false) + engine.runScenario(session)(s).isSuccess should be(false) } } } diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/regular/EffectStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/regular/EffectStepSpec.scala index 95742af9e..a1cff58d9 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/regular/EffectStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/regular/EffectStepSpec.scala @@ -17,7 +17,7 @@ class EffectStepSpec extends WordSpec with Matchers { s }) val s = Scenario("scenario with broken effect step", Vector(step)) - engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(false) + engine.runScenario(session)(s).isSuccess should be(false) } } } diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/ConcurrentlyStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/ConcurrentlyStepSpec.scala index 98e0c4c9a..961efdebe 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/ConcurrentlyStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/ConcurrentlyStepSpec.scala @@ -25,7 +25,7 @@ class ConcurrentlyStepSpec extends WordSpec with Matchers { ConcurrentlyStep(nested, 3, 200.millis) ) val s = Scenario("scenario with Concurrently", steps) - engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(false) + engine.runScenario(Session.newSession)(s).isSuccess should be(false) } "run nested block 'n' times" in { @@ -44,7 +44,7 @@ class ConcurrentlyStepSpec extends WordSpec with Matchers { ConcurrentlyStep(nested, loop, 300.millis) ) val s = Scenario("scenario with Concurrently", steps) - engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(true) + engine.runScenario(Session.newSession)(s).isSuccess should be(true) uglyCounter.intValue() should be(loop) } } diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/EventuallyStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/EventuallyStepSpec.scala index 9972593b9..9a1254942 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/EventuallyStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/EventuallyStepSpec.scala @@ -24,7 +24,7 @@ class EventuallyStepSpec extends WordSpec with Matchers { val steps = Vector(EventuallyStep(nested, eventuallyConf)) val s = Scenario("scenario with eventually", steps) - engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(true) + engine.runScenario(session)(s).isSuccess should be(true) } "replay eventually wrapped steps until limit" in { @@ -39,7 +39,7 @@ class EventuallyStepSpec extends WordSpec with Matchers { EventuallyStep(nested, eventuallyConf) ) val s = Scenario("scenario with eventually that fails", steps) - engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(false) + engine.runScenario(session)(s).isSuccess should be(false) } } diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStepSpec.scala index ffca8850c..e5cff9037 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatDuringStepSpec.scala @@ -24,7 +24,7 @@ class RepeatDuringStepSpec extends WordSpec with Matchers { RepeatDuringStep(nested, 5.millis) ) val s = Scenario("scenario with RepeatDuring", steps) - engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(false) + engine.runScenario(Session.newSession)(s).isSuccess should be(false) } "repeat steps inside 'repeatDuring' for at least the duration param" in { @@ -42,7 +42,7 @@ class RepeatDuringStepSpec extends WordSpec with Matchers { ) val s = Scenario("scenario with RepeatDuring", steps) val now = System.nanoTime - engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(true) + engine.runScenario(Session.newSession)(s).isSuccess should be(true) val executionTime = Duration.fromNanos(System.nanoTime - now) withClue(executionTime.toMillis) { executionTime.gt(50.millis) should be(true) @@ -66,7 +66,7 @@ class RepeatDuringStepSpec extends WordSpec with Matchers { ) val s = Scenario("scenario with RepeatDuring", steps) val now = System.nanoTime - engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(true) + engine.runScenario(Session.newSession)(s).isSuccess should be(true) val executionTime = Duration.fromNanos(System.nanoTime - now) withClue(executionTime.toMillis) { executionTime.gt(50.millis) should be(true) diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatStepSpec.scala index edaf5bedf..3bda171bc 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RepeatStepSpec.scala @@ -22,7 +22,7 @@ class RepeatStepSpec extends WordSpec with Matchers { RepeatStep(nested, 5) ) val s = Scenario("scenario with Repeat", steps) - engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(false) + engine.runScenario(Session.newSession)(s).isSuccess should be(false) } "repeat steps inside a 'repeat' block" in { @@ -41,7 +41,7 @@ class RepeatStepSpec extends WordSpec with Matchers { RepeatStep(nested, loop) ) val s = Scenario("scenario with Repeat", steps) - engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(true) + engine.runScenario(Session.newSession)(s).isSuccess should be(true) uglyCounter should be(loop) } } diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RetryMaxStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RetryMaxStepSpec.scala index 8bc24e876..beb3e154b 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RetryMaxStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/RetryMaxStepSpec.scala @@ -27,7 +27,7 @@ class RetryMaxStepSpec extends WordSpec with Matchers { RetryMaxStep(nested, loop) ) val s = Scenario("scenario with RetryMax", steps) - engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(false) + engine.runScenario(Session.newSession)(s).isSuccess should be(false) // Initial run + 'loop' retries uglyCounter should be(loop + 1) } @@ -48,7 +48,7 @@ class RetryMaxStepSpec extends WordSpec with Matchers { RetryMaxStep(nested, max) ) val s = Scenario("scenario with RetryMax", steps) - engine.runScenario(Session.newSession)(s).stepsExecutionResult.isSuccess should be(true) + engine.runScenario(Session.newSession)(s).isSuccess should be(true) uglyCounter should be(max - 2) } } diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/WithinStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/WithinStepSpec.scala index e5ec6e6bf..46d31626d 100644 --- a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/WithinStepSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/WithinStepSpec.scala @@ -28,7 +28,7 @@ class WithinStepSpec extends WordSpec with Matchers { WithinStep(nested, d) ) val s = Scenario("scenario with Within", steps) - engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(true) + engine.runScenario(session)(s).isSuccess should be(true) } "fail if duration of 'within' is exceeded" in { @@ -47,7 +47,7 @@ class WithinStepSpec extends WordSpec with Matchers { WithinStep(nested, d) ) val s = Scenario("scenario with Within", steps) - engine.runScenario(session)(s).stepsExecutionResult.isSuccess should be(false) + engine.runScenario(session)(s).isSuccess should be(false) } } From b56b74a552190870d3127288dbcf26419bc12fdd Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Sun, 5 Jun 2016 20:03:45 +0200 Subject: [PATCH 15/39] akka-http-circe 1.7.0 --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 1188a0908..fbff5a10e 100644 --- a/build.sbt +++ b/build.sbt @@ -43,7 +43,7 @@ libraryDependencies ++= { val circeVersion = "0.5.0-M1" val sangriaV = "0.6.3" val fansiV = "0.1.3" - val akkaHttpCirce = "1.6.0" + val akkaHttpCirce = "1.7.0" Seq( "com.typesafe.akka" %% "akka-http-core" % akkaHttpV ,"de.heikoseeberger" %% "akka-sse" % akkaSseV From b7858bb89e75294e647e065f3544300cb52b10f8 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Sun, 5 Jun 2016 20:06:04 +0200 Subject: [PATCH 16/39] akka-sse 1.8.1 --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index fbff5a10e..fba400d00 100644 --- a/build.sbt +++ b/build.sbt @@ -37,7 +37,7 @@ libraryDependencies ++= { val json4sV = "3.3.0" val logbackV = "1.1.7" val parboiledV = "2.1.3" - val akkaSseV = "1.8.0" + val akkaSseV = "1.8.1" val scalaCheckV = "1.12.5" val sangriaCirceV = "0.4.4" val circeVersion = "0.5.0-M1" From 78416acda8878b9d3e1a333c3766b2145dfcb4d4 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Sun, 5 Jun 2016 20:08:22 +0200 Subject: [PATCH 17/39] better naming --- .../cornichon/examples/superHeroes/SuperHeroesScenario.scala | 4 ++-- .../superHeroes/server/{RestAPI.scala => HttpAPI.scala} | 2 +- .../cornichon/examples/superHeroes/server/models.scala | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) rename src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/{RestAPI.scala => HttpAPI.scala} (99%) diff --git a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala index e43d5045e..708b36fd5 100644 --- a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala +++ b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala @@ -6,7 +6,7 @@ import java.util.Base64 import akka.http.scaladsl.Http.ServerBinding import com.github.agourlay.cornichon.CornichonFeature import com.github.agourlay.cornichon.core.JsonMapper -import com.github.agourlay.cornichon.examples.superHeroes.server.RestAPI +import com.github.agourlay.cornichon.examples.superHeroes.server.HttpAPI import com.github.agourlay.cornichon.http.HttpService import com.github.agourlay.cornichon.json.CornichonJson._ @@ -679,7 +679,7 @@ class SuperHeroesScenario extends CornichonFeature { // Starts up test server beforeFeature { - server = Await.result(new RestAPI().start(port), 5 second) + server = Await.result(new HttpAPI().start(port), 5 second) } // Stops test server diff --git a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/RestAPI.scala b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/HttpAPI.scala similarity index 99% rename from src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/RestAPI.scala rename to src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/HttpAPI.scala index f7dd88b33..b77f3d5bd 100644 --- a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/RestAPI.scala +++ b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/HttpAPI.scala @@ -22,7 +22,7 @@ import io.circe.generic.auto._ import scala.concurrent.ExecutionContext import scala.util.{ Failure, Success } -class RestAPI() extends EventStreamMarshalling { +class HttpAPI() extends EventStreamMarshalling { import JsonSupport._ diff --git a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/models.scala b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/models.scala index 547471718..ff52c7265 100644 --- a/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/models.scala +++ b/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/server/models.scala @@ -15,7 +15,6 @@ import sangria.schema._ import sangria.marshalling.circe._ object GraphQlSchema { - import JsonSupport._ implicit val PublisherType = deriveObjectType[Unit, Publisher]( ObjectTypeDescription("A comics publisher.") From 001ed334e6c588a2dc0b45b0b903bf7d222a06f4 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Mon, 6 Jun 2016 12:49:29 +0200 Subject: [PATCH 18/39] accumulates error in scenario and afterEach - fixes #75 --- .../agourlay/cornichon/core/Engine.scala | 2 +- .../cornichon/core/ScenarioReport.scala | 53 +++++++++++-------- .../agourlay/cornichon/core/EngineSpec.scala | 34 +++++++++++- 3 files changed, 65 insertions(+), 24 deletions(-) diff --git a/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala b/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala index 9d232f037..58e49170e 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala @@ -19,7 +19,7 @@ class Engine(executionContext: ExecutionContext) { else { // Reuse mainline session val finallyReport = runSteps(finallySteps.toVector, mainRunReport.session, Vector.empty, initMargin + 1) - ScenarioReport.build(scenario.name, mainRunReport, finallyReport) + ScenarioReport.build(scenario.name, mainRunReport, Some(finallyReport)) } } diff --git a/src/main/scala/com/github/agourlay/cornichon/core/ScenarioReport.scala b/src/main/scala/com/github/agourlay/cornichon/core/ScenarioReport.scala index d32f174b0..170c6e71d 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/ScenarioReport.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/ScenarioReport.scala @@ -1,6 +1,6 @@ package com.github.agourlay.cornichon.core -trait ScenarioReport { +sealed trait ScenarioReport { def scenarioName: String def isSuccess: Boolean def session: Session @@ -8,45 +8,54 @@ trait ScenarioReport { } object ScenarioReport { - def build(scenarioName: String, stepsResult: StepsResult*): ScenarioReport = mergeReport(stepsResult) match { - case SuccessStepsResult(s, l) ⇒ SuccessScenarioReport(scenarioName, s, l) - case FailureStepsResult(f, s, l) ⇒ FailureScenarioReport(scenarioName, f, s, l) - } - - def mergeReport(stepsResults: Seq[StepsResult]) = { - stepsResults.reduceLeft { (acc, nextResult) ⇒ - (acc, nextResult) match { + def build(scenarioName: String, mainResult: StepsResult, finallyResult: Option[StepsResult] = None): ScenarioReport = + finallyResult.fold { + mainResult match { + case SuccessStepsResult(s, l) ⇒ SuccessScenarioReport(scenarioName, s, l) + case FailureStepsResult(f, s, l) ⇒ FailureScenarioReport(scenarioName, Vector(f), s, l) + } + } { finallyRes ⇒ + + (mainResult, finallyRes) match { // Success + Sucess = Success case (SuccessStepsResult(leftSession, leftLogs), SuccessStepsResult(rightSession, rightLogs)) ⇒ - SuccessStepsResult(leftSession.merge(rightSession), leftLogs ++ rightLogs) + SuccessScenarioReport(scenarioName, leftSession.merge(rightSession), leftLogs ++ rightLogs) + // Success + Error = Error case (SuccessStepsResult(leftSession, leftLogs), FailureStepsResult(failedStep, rightSession, rightLogs)) ⇒ - FailureStepsResult(failedStep, leftSession.merge(rightSession), leftLogs ++ rightLogs) + FailureScenarioReport(scenarioName, Vector(failedStep), leftSession.merge(rightSession), leftLogs ++ rightLogs) + // Error + Success = Error case (FailureStepsResult(failedStep, leftSession, leftLogs), SuccessStepsResult(rightSession, rightLogs)) ⇒ - FailureStepsResult(failedStep, leftSession.merge(rightSession), leftLogs ++ rightLogs) - // Error + Error = Error + FailureScenarioReport(scenarioName, Vector(failedStep), leftSession.merge(rightSession), leftLogs ++ rightLogs) + + // Error + Error = Errors accumulated case (FailureStepsResult(leftFailedStep, leftSession, leftLogs), FailureStepsResult(rightFailedStep, rightSession, rightLogs)) ⇒ - FailureStepsResult(leftFailedStep, leftSession.merge(rightSession), leftLogs ++ rightLogs) + FailureScenarioReport(scenarioName, Vector(leftFailedStep, rightFailedStep), leftSession.merge(rightSession), leftLogs ++ rightLogs) } } - } } case class SuccessScenarioReport(scenarioName: String, session: Session, logs: Vector[LogInstruction]) extends ScenarioReport { val isSuccess = true } -case class FailureScenarioReport(scenarioName: String, failedStep: FailedStep, session: Session, logs: Vector[LogInstruction]) extends ScenarioReport { +case class FailureScenarioReport(scenarioName: String, failedSteps: Vector[FailedStep], session: Session, logs: Vector[LogInstruction]) extends ScenarioReport { val isSuccess = false - val msg = s""" - | - |Scenario '$scenarioName' failed at step: - |${failedStep.step.title} - |with error: - |${failedStep.error.msg} + private def messageForFailedStep(failedStep: FailedStep) = + s""" + |${failedStep.step.title} + |with error: + |${failedStep.error.msg} + | + |""".stripMargin + + val msg = + s""" + |Scenario '$scenarioName' failed at step(s): + |${failedSteps.map(messageForFailedStep).mkString("and\n")} | """.trim.stripMargin } diff --git a/src/test/scala/com/github/agourlay/cornichon/core/EngineSpec.scala b/src/test/scala/com/github/agourlay/cornichon/core/EngineSpec.scala index 2d99c98a0..91de0572d 100644 --- a/src/test/scala/com/github/agourlay/cornichon/core/EngineSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/core/EngineSpec.scala @@ -32,7 +32,7 @@ class EngineSpec extends WordSpec with Matchers { res match { case s: SuccessScenarioReport ⇒ fail("Should be a FailedScenarioReport") case f: FailureScenarioReport ⇒ - f.failedStep.error.msg.replaceAll("\r", "") should be(""" + f.failedSteps.head.error.msg should be(""" |expected result was: |'4' |but actual result is: @@ -40,6 +40,38 @@ class EngineSpec extends WordSpec with Matchers { } } } + + "accumulated errors if 'main' and 'finally' fail" in { + val session = Session.newSession + val mainStep = AssertStep[Boolean]("main step", s ⇒ SimpleStepAssertion(true, false)) + val finallyStep = AssertStep[Boolean]("finally step", s ⇒ SimpleStepAssertion(true, false)) + val s = Scenario("test", Vector(mainStep)) + val res = engine.runScenario(session, Vector(finallyStep))(s) + res match { + case s: SuccessScenarioReport ⇒ fail("Should be a FailedScenarioReport") + case f: FailureScenarioReport ⇒ + f.msg should be("""Scenario 'test' failed at step(s): + | + |main step + |with error: + |expected result was: + |'true' + |but actual result is: + |'false' + | + |and + | + |finally step + |with error: + |expected result was: + |'true' + |but actual result is: + |'false' + | + | + |""".stripMargin) + } + } } } } From 764b8c05131306809d7ddd44cb4613f7b5fc0227 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Tue, 7 Jun 2016 08:39:04 +0200 Subject: [PATCH 19/39] build GQL payload only once --- .../agourlay/cornichon/http/HttpDsl.scala | 22 +++++++++---------- .../agourlay/cornichon/http/HttpEffects.scala | 15 ++++++------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpDsl.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpDsl.scala index 0b6a3d7d2..81ef16ad3 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpDsl.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpDsl.scala @@ -25,16 +25,16 @@ trait HttpDsl extends Dsl { title = request.description, effect = s ⇒ request match { - case Get(url, params, headers) ⇒ http.Get(url, params, headers)(s) - case Head(url, params, headers) ⇒ http.Head(url, params, headers)(s) - case Options(url, params, headers) ⇒ http.Options(url, params, headers)(s) - case Delete(url, params, headers) ⇒ http.Delete(url, params, headers)(s) - case Post(url, payload, params, headers) ⇒ http.Post(url, payload, params, headers)(s) - case Put(url, payload, params, headers) ⇒ http.Put(url, payload, params, headers)(s) - case Patch(url, payload, params, headers) ⇒ http.Patch(url, payload, params, headers)(s) - case OpenSSE(url, takeWithin, params, headers) ⇒ http.OpenSSE(url, takeWithin, params, headers)(s) - case OpenWS(url, takeWithin, params, headers) ⇒ http.OpenWS(url, takeWithin, params, headers)(s) - case QueryGQL(url, payload, params, headers, _, _, _) ⇒ http.Post(url, payload, params, headers)(s) + case Get(url, params, headers) ⇒ http.Get(url, params, headers)(s) + case Head(url, params, headers) ⇒ http.Head(url, params, headers)(s) + case Options(url, params, headers) ⇒ http.Options(url, params, headers)(s) + case Delete(url, params, headers) ⇒ http.Delete(url, params, headers)(s) + case Post(url, payload, params, headers) ⇒ http.Post(url, payload, params, headers)(s) + case Put(url, payload, params, headers) ⇒ http.Put(url, payload, params, headers)(s) + case Patch(url, payload, params, headers) ⇒ http.Patch(url, payload, params, headers)(s) + case OpenSSE(url, takeWithin, params, headers) ⇒ http.OpenSSE(url, takeWithin, params, headers)(s) + case OpenWS(url, takeWithin, params, headers) ⇒ http.OpenWS(url, takeWithin, params, headers)(s) + case q @ QueryGQL(url, params, headers, _, _, _) ⇒ http.Post(url, q.payload, params, headers)(s) } ) @@ -47,7 +47,7 @@ trait HttpDsl extends Dsl { def open_sse(url: String, takeWithin: FiniteDuration) = OpenSSE(url, takeWithin, Seq.empty, Seq.empty) def open_ws(url: String, takeWithin: FiniteDuration) = OpenWS(url, takeWithin, Seq.empty, Seq.empty) - def query_gql(url: String) = QueryGQL(url, "", Seq.empty, Seq.empty, Document(List.empty)) + def query_gql(url: String) = QueryGQL(url, Seq.empty, Seq.empty, Document(List.empty)) val root = JsonPath.root diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala index 0152daa0e..6fae06008 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala @@ -86,28 +86,27 @@ object HttpEffects { def withHeaders(headers: (String, String)*) = copy(headers = headers) } - case class QueryGQL(url: String, payload: String, params: Seq[(String, String)], headers: Seq[(String, String)], + case class QueryGQL(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], query: Document, operationName: Option[String] = None, variables: Option[Map[String, Json]] = None) extends HttpRequestWithPayload { - val name = "Query GQL" + val name = "Query GraphQL" def withParams(params: (String, String)*) = copy(params = params) def withHeaders(headers: (String, String)*) = copy(headers = headers) - //GQL builder - def withQuery(query: Document) = copy(query = query).buildBody() - def withOperationName(operationName: String) = copy(operationName = Some(operationName)).buildBody() + def withQuery(query: Document) = copy(query = query) + def withOperationName(operationName: String) = copy(operationName = Some(operationName)) def withVariables(newVariables: (String, Any)*) = { val toJsonTuples = newVariables.map { case (k, v) ⇒ k → parseJsonUnsafe(v) } - copy(variables = variables.fold(Some(toJsonTuples.toMap))(v ⇒ Some(v ++ toJsonTuples))).buildBody() + copy(variables = variables.fold(Some(toJsonTuples.toMap))(v ⇒ Some(v ++ toJsonTuples))) } - def buildBody() = { + lazy val payload = { import io.circe.generic.auto._ import io.circe.syntax._ val queryDoc = query.source.getOrElse(QueryRenderer.render(query, QueryRenderer.Pretty)) val newPayload = GqlPayload(queryDoc, operationName, variables) - copy(payload = prettyPrint(newPayload.asJson)) + prettyPrint(newPayload.asJson) } } From 2f841d7663b09d83f2fb809261c3d6fbf3579cc4 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Tue, 7 Jun 2016 10:40:31 +0200 Subject: [PATCH 20/39] add println statement for earlier feedback that something is going on --- src/main/scala/com/github/agourlay/cornichon/core/Engine.scala | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala b/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala index 58e49170e..f96a70d78 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala @@ -11,6 +11,7 @@ class Engine(executionContext: ExecutionContext) { private implicit val ec = executionContext def runScenario(session: Session, finallySteps: Seq[Step] = Seq.empty)(scenario: Scenario): ScenarioReport = { + println(s"Starting scenario '${scenario.name}'") val initMargin = 1 val titleLog = ScenarioTitleLogInstruction(s"Scenario : ${scenario.name}", initMargin) val mainRunReport = runSteps(scenario.steps, session, Vector(titleLog), initMargin + 1) From b93ce4b79465fa8eeebeb4bde929ddf35cf73cda Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Tue, 7 Jun 2016 10:58:58 +0200 Subject: [PATCH 21/39] improve GQL display --- .../com/github/agourlay/cornichon/http/HttpDsl.scala | 2 +- .../github/agourlay/cornichon/http/HttpEffects.scala | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpDsl.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpDsl.scala index 81ef16ad3..00ddea3bf 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpDsl.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpDsl.scala @@ -34,7 +34,7 @@ trait HttpDsl extends Dsl { case Patch(url, payload, params, headers) ⇒ http.Patch(url, payload, params, headers)(s) case OpenSSE(url, takeWithin, params, headers) ⇒ http.OpenSSE(url, takeWithin, params, headers)(s) case OpenWS(url, takeWithin, params, headers) ⇒ http.OpenWS(url, takeWithin, params, headers)(s) - case q @ QueryGQL(url, params, headers, _, _, _) ⇒ http.Post(url, q.payload, params, headers)(s) + case q @ QueryGQL(url, params, headers, _, _, _) ⇒ http.Post(url, q.fullPayload, params, headers)(s) } ) diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala index 6fae06008..86ea486ce 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpEffects.scala @@ -88,7 +88,7 @@ object HttpEffects { case class QueryGQL(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], query: Document, operationName: Option[String] = None, variables: Option[Map[String, Json]] = None) extends HttpRequestWithPayload { - val name = "Query GraphQL" + val name = "POST GraphQL query" def withParams(params: (String, String)*) = copy(params = params) def withHeaders(headers: (String, String)*) = copy(headers = headers) @@ -100,13 +100,15 @@ object HttpEffects { copy(variables = variables.fold(Some(toJsonTuples.toMap))(v ⇒ Some(v ++ toJsonTuples))) } - lazy val payload = { + // Used only for display - problem being that the query is a String and looks ugly inside the full JSON object. + lazy val payload = query.source.getOrElse(QueryRenderer.render(query, QueryRenderer.Pretty)) + + lazy val fullPayload = { import io.circe.generic.auto._ import io.circe.syntax._ - val queryDoc = query.source.getOrElse(QueryRenderer.render(query, QueryRenderer.Pretty)) - val newPayload = GqlPayload(queryDoc, operationName, variables) - prettyPrint(newPayload.asJson) + val gqlPayload = GqlPayload(payload, operationName, variables) + prettyPrint(gqlPayload.asJson) } } From 942af1a52e4a4fba6054d0303b25eed2d887fb94 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Wed, 8 Jun 2016 08:38:42 +0200 Subject: [PATCH 22/39] geJson from session now take an additional json path arg --- .../github/agourlay/cornichon/CornichonFeature.scala | 4 +++- .../com/github/agourlay/cornichon/core/Engine.scala | 1 - .../github/agourlay/cornichon/core/Resolver.scala | 3 +-- .../com/github/agourlay/cornichon/core/Session.scala | 12 +++++++++--- .../agourlay/cornichon/json/CornichonJson.scala | 1 + 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/main/scala/com/github/agourlay/cornichon/CornichonFeature.scala b/src/main/scala/com/github/agourlay/cornichon/CornichonFeature.scala index 97f454db7..7599ecb9a 100644 --- a/src/main/scala/com/github/agourlay/cornichon/CornichonFeature.scala +++ b/src/main/scala/com/github/agourlay/cornichon/CornichonFeature.scala @@ -26,10 +26,12 @@ trait CornichonFeature extends HttpDsl with ScalatestIntegration { protected def unregisterFeature() = releaseGlobalRuntime() - protected def runScenario(s: Scenario) = + protected def runScenario(s: Scenario) = { + println(s"Starting scenario '${s.name}'") engine.runScenario(Session.newSession, afterEachScenario) { s.copy(steps = beforeEachScenario.toVector ++ s.steps) } + } def httpServiceByURL(baseUrl: String, timeout: FiniteDuration = requestTimeout) = new HttpService(baseUrl, timeout, globalClient, resolver) diff --git a/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala b/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala index f96a70d78..58e49170e 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala @@ -11,7 +11,6 @@ class Engine(executionContext: ExecutionContext) { private implicit val ec = executionContext def runScenario(session: Session, finallySteps: Seq[Step] = Seq.empty)(scenario: Scenario): ScenarioReport = { - println(s"Starting scenario '${scenario.name}'") val initMargin = 1 val titleLog = ScenarioTitleLogInstruction(s"Scenario : ${scenario.name}", initMargin) val mainRunReport = runSteps(scenario.steps, session, Vector(titleLog), initMargin + 1) diff --git a/src/main/scala/com/github/agourlay/cornichon/core/Resolver.scala b/src/main/scala/com/github/agourlay/cornichon/core/Resolver.scala index f43459c74..f8c78afbb 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/Resolver.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/Resolver.scala @@ -50,8 +50,7 @@ class Resolver(extractors: Map[String, Mapper]) { session.getXor(key, ph.index).map(transform) case JsonMapper(key, jsonPath, transform) ⇒ session.getXor(key, ph.index).flatMap { sessionValue ⇒ - // No placeholders in JsonMapper for now to avoid people running into infinite recursion - // Could be enabled if there is a use case for it. + // No placeholders in JsonMapper to avoid accidental infinite recursions. JsonPath.run(jsonPath, sessionValue) .map(CornichonJson.jsonStringValue) .map(transform) diff --git a/src/main/scala/com/github/agourlay/cornichon/core/Session.scala b/src/main/scala/com/github/agourlay/cornichon/core/Session.scala index 3062a6976..d08fdcdf1 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/Session.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/Session.scala @@ -1,7 +1,7 @@ package com.github.agourlay.cornichon.core import cats.data.Xor -import com.github.agourlay.cornichon.json.CornichonJson +import com.github.agourlay.cornichon.json.{ CornichonJson, JsonPath } import scala.collection.immutable.HashMap @@ -25,8 +25,14 @@ case class Session(content: Map[String, Vector[String]]) extends CornichonJson { def getXor(key: String, stackingIndice: Option[Int] = None) = Xor.fromOption(getOpt(key, stackingIndice), KeyNotFoundInSession(key, this)) - def getJson(key: String, stackingIndice: Option[Int] = None) = - parseJson(get(key, stackingIndice)).fold(e ⇒ throw e, identity) + def getJson(key: String, stackingIndice: Option[Int] = None, path: String = JsonPath.root) = { + val res = for { + sessionValue ← getXor(key, stackingIndice) + jsonValue ← parseJson(sessionValue) + extracted ← Xor.catchNonFatal(JsonPath.run(path, jsonValue)) + } yield extracted + res.fold(e ⇒ throw e, identity) + } def getJsonOpt(key: String, stackingIndice: Option[Int] = None) = getOpt(key, stackingIndice).map(parseJson) diff --git a/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala b/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala index 834071ba6..9ab90593d 100644 --- a/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala +++ b/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala @@ -90,6 +90,7 @@ trait CornichonJson { """.stripMargin } + //FIXME implement JSON diff independently from JSON4s def diff(v1: Json, v2: Json): Diff = { import org.json4s.JValue import org.json4s.jackson.JsonMethods._ From b8286fec753d4d2810250486f76fad9ecbb48aeb Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Wed, 8 Jun 2016 14:13:33 +0200 Subject: [PATCH 23/39] add helper methods to work with JSON on Session --- .../agourlay/cornichon/core/Session.scala | 20 ++++++++++++++----- .../cornichon/json/CornichonJson.scala | 3 +++ .../agourlay/cornichon/json/JsonErrors.scala | 6 ++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/main/scala/com/github/agourlay/cornichon/core/Session.scala b/src/main/scala/com/github/agourlay/cornichon/core/Session.scala index d08fdcdf1..286b5f072 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/Session.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/Session.scala @@ -1,7 +1,8 @@ package com.github.agourlay.cornichon.core import cats.data.Xor -import com.github.agourlay.cornichon.json.{ CornichonJson, JsonPath } +import com.github.agourlay.cornichon.json.{ CornichonJson, JsonPath, NotStringFieldError } +import io.circe.Json import scala.collection.immutable.HashMap @@ -25,16 +26,25 @@ case class Session(content: Map[String, Vector[String]]) extends CornichonJson { def getXor(key: String, stackingIndice: Option[Int] = None) = Xor.fromOption(getOpt(key, stackingIndice), KeyNotFoundInSession(key, this)) - def getJson(key: String, stackingIndice: Option[Int] = None, path: String = JsonPath.root) = { - val res = for { + def getJsonXor(key: String, stackingIndice: Option[Int] = None, path: String = JsonPath.root): Xor[CornichonError, Json] = + for { sessionValue ← getXor(key, stackingIndice) jsonValue ← parseJson(sessionValue) - extracted ← Xor.catchNonFatal(JsonPath.run(path, jsonValue)) + extracted ← Xor.catchNonFatal(JsonPath.run(path, jsonValue)).leftMap(CornichonError.fromThrowable) } yield extracted + + def getJson(key: String, stackingIndice: Option[Int] = None, path: String = JsonPath.root) = + getJsonXor(key, stackingIndice, path).fold(e ⇒ throw e, identity) + + def getJsonStringField(key: String, stackingIndice: Option[Int] = None, path: String = JsonPath.root) = { + val res = for { + json ← getJsonXor(key, stackingIndice, path) + field ← Xor.fromOption(json.asString, new NotStringFieldError(json, path)) + } yield field res.fold(e ⇒ throw e, identity) } - def getJsonOpt(key: String, stackingIndice: Option[Int] = None) = getOpt(key, stackingIndice).map(parseJson) + def getJsonOpt(key: String, stackingIndice: Option[Int] = None): Option[Json] = getOpt(key, stackingIndice).flatMap(s ⇒ parseJson(s).toOption) def getList(keys: Seq[String]) = keys.map(v ⇒ get(v)) diff --git a/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala b/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala index 9ab90593d..6e15797ad 100644 --- a/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala +++ b/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala @@ -78,6 +78,9 @@ trait CornichonJson { jsonObject = b ⇒ prettyPrint(j) ) + def extract(json: Json, path: String) = + JsonPath.run(path, json) + def prettyPrint(json: Json) = json.spaces2 def prettyDiff(first: Json, second: Json) = { diff --git a/src/main/scala/com/github/agourlay/cornichon/json/JsonErrors.scala b/src/main/scala/com/github/agourlay/cornichon/json/JsonErrors.scala index b81f64ca1..fa18ac85b 100644 --- a/src/main/scala/com/github/agourlay/cornichon/json/JsonErrors.scala +++ b/src/main/scala/com/github/agourlay/cornichon/json/JsonErrors.scala @@ -1,6 +1,7 @@ package com.github.agourlay.cornichon.json import com.github.agourlay.cornichon.core.CornichonError +import io.circe.Json sealed trait JsonError extends CornichonError @@ -26,3 +27,8 @@ case class JsonPathError(input: String, error: Throwable) extends JsonError { case class WhiteListError(msg: String) extends CornichonError +case class NotStringFieldError(input: Json, field: String) extends JsonError { + val msg = + s"""field '$field' is not of type String in JSON + |${CornichonJson.prettyPrint(input)}""".stripMargin +} From ceeed3d7c4d7a3c3192a700161ad4de4771dc309 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Wed, 8 Jun 2016 22:15:17 +0200 Subject: [PATCH 24/39] try to port JSON4s json diff implementation --- build.sbt | 4 +- .../cornichon/http/HttpAssertions.scala | 1 + .../cornichon/json/CornichonJson.scala | 26 +------ .../agourlay/cornichon/json/JsonDiff.scala | 67 +++++++++++++++++++ 4 files changed, 71 insertions(+), 27 deletions(-) create mode 100644 src/main/scala/com/github/agourlay/cornichon/json/JsonDiff.scala diff --git a/build.sbt b/build.sbt index fba400d00..b5dca13d0 100644 --- a/build.sbt +++ b/build.sbt @@ -34,7 +34,6 @@ libraryDependencies ++= { val scalaTestV = "2.2.6" val akkaHttpV = "2.4.7" val catsV = "0.6.0" - val json4sV = "3.3.0" val logbackV = "1.1.7" val parboiledV = "2.1.3" val akkaSseV = "1.8.1" @@ -47,7 +46,6 @@ libraryDependencies ++= { Seq( "com.typesafe.akka" %% "akka-http-core" % akkaHttpV ,"de.heikoseeberger" %% "akka-sse" % akkaSseV - ,"org.json4s" %% "json4s-jackson" % json4sV // Only used for Json diff - remove asap. ,"org.typelevel" %% "cats-macros" % catsV ,"org.typelevel" %% "cats-core" % catsV ,"org.scalatest" %% "scalatest" % scalaTestV @@ -68,7 +66,7 @@ libraryDependencies ++= { // Wartremover wartremoverErrors in (Compile, compile) ++= Seq( - Wart.Any2StringAdd, Wart.Option2Iterable, Wart.OptionPartial, + Wart.Any2StringAdd, Wart.Option2Iterable, Wart.Return, Wart.TryPartial) // Publishing diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala index 70df0d48e..8a17bb593 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala @@ -6,6 +6,7 @@ import com.github.agourlay.cornichon.dsl.Dsl._ import com.github.agourlay.cornichon.http.HttpDslErrors._ import com.github.agourlay.cornichon.http.HttpService._ import com.github.agourlay.cornichon.json.CornichonJson._ +import com.github.agourlay.cornichon.json.JsonDiff._ import com.github.agourlay.cornichon.json.{ JsonPath, NotAnArrayError, WhiteListError } import com.github.agourlay.cornichon.steps.regular.AssertStep import com.github.agourlay.cornichon.util.Formats._ diff --git a/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala b/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala index 6e15797ad..86fb6ca26 100644 --- a/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala +++ b/src/main/scala/com/github/agourlay/cornichon/json/CornichonJson.scala @@ -5,8 +5,8 @@ import cats.data.Xor.{ left, right } import com.github.agourlay.cornichon.core.CornichonError import com.github.agourlay.cornichon.dsl.DataTableParser import com.github.agourlay.cornichon.json.CornichonJson.GqlString +import com.github.agourlay.cornichon.json.JsonDiff.Diff import io.circe.{ Json, JsonObject } -import org.json4s.JsonAST.JNothing import sangria.marshalling.MarshallingUtil._ import sangria.parser.QueryParser import sangria.marshalling.queryAst._ @@ -84,7 +84,7 @@ trait CornichonJson { def prettyPrint(json: Json) = json.spaces2 def prettyDiff(first: Json, second: Json) = { - val Diff(changed, added, deleted) = diff(first, second) + val Diff(changed, added, deleted) = JsonDiff.diff(first, second) s""" |${if (changed == Json.Null) "" else "changed = " + jsonStringValue(changed)} @@ -92,28 +92,6 @@ trait CornichonJson { |${if (deleted == Json.Null) "" else "deleted = " + jsonStringValue(deleted)} """.stripMargin } - - //FIXME implement JSON diff independently from JSON4s - def diff(v1: Json, v2: Json): Diff = { - import org.json4s.JValue - import org.json4s.jackson.JsonMethods._ - - def circeToJson4s(c: Json): JValue = c match { - case Json.Null ⇒ JNothing - case _ ⇒ parse(c.noSpaces) - } - - def json4sToCirce(j: JValue): Json = j match { - case JNothing ⇒ Json.Null - case _ ⇒ parseJsonUnsafe(compact(render(j))) - } - - val diff = circeToJson4s(v1).diff(circeToJson4s(v2)) - - Diff(changed = json4sToCirce(diff.changed), added = json4sToCirce(diff.added), deleted = json4sToCirce(diff.deleted)) - } - - case class Diff(changed: Json, added: Json, deleted: Json) } object CornichonJson extends CornichonJson { diff --git a/src/main/scala/com/github/agourlay/cornichon/json/JsonDiff.scala b/src/main/scala/com/github/agourlay/cornichon/json/JsonDiff.scala new file mode 100644 index 000000000..d1e0183d9 --- /dev/null +++ b/src/main/scala/com/github/agourlay/cornichon/json/JsonDiff.scala @@ -0,0 +1,67 @@ +package com.github.agourlay.cornichon.json + +import io.circe.{ Json, JsonObject } + +// Mostly a port from JSON4s +// https://github.com/json4s/json4s/blob/3.4/ast/src/main/scala/org/json4s/Diff.scala +object JsonDiff { + + case class Diff(changed: Json, added: Json, deleted: Json) { + def toField(name: String): Diff = { + def applyTo(x: Json): Json = if (x == Json.Null) Json.Null else Json.fromJsonObject(JsonObject.singleton(name, x)) + + Diff(applyTo(changed), applyTo(added), applyTo(deleted)) + } + } + + def append(v1: Json, v2: Json): Json = + if (v1.isNull) v2 + else if (v2.isNull) v1 + else if (v1.isArray && v2.isArray) Json.fromValues(v1.asArray.get ::: v2.asArray.get) + else if (v1.isArray) Json.fromValues(v1.asArray.get :+ v2) + else if (v2.isArray) Json.fromValues(v1 +: v2.asArray.get) + else Json.fromValues(v1 :: v2 :: Nil) + + type JField = (String, Json) + + def diffJsonObject(v1: JsonObject, v2: JsonObject): Diff = { + def diffRec(xleft: List[JField], yleft: List[JField]): Diff = xleft match { + case Nil ⇒ Diff(Json.Null, if (yleft.isEmpty) Json.Null else Json.fromJsonObject(JsonObject.fromIterable(yleft)), Json.Null) + case x :: xs ⇒ yleft find (_._1 == x._1) match { + case Some(y) ⇒ + val Diff(c1, a1, d1) = diff(x._2, y._2).toField(y._1) + val Diff(c2, a2, d2) = diffRec(xs, yleft filterNot (_ == y)) + Diff(c1.deepMerge(c2), a1.deepMerge(a2), d1.deepMerge(d2)) + case None ⇒ + val Diff(c, a, d) = diffRec(xs, yleft) + Diff(c, a, Json.fromJsonObject(JsonObject.fromIterable(x :: Nil)).deepMerge(d)) + } + } + + diffRec(v1.toList, v2.toList) + } + + def diffJsonArray(v1: List[Json], v2: List[Json]): Diff = { + def diffRec(xleft: List[Json], yleft: List[Json]): Diff = (xleft, yleft) match { + case (xs, Nil) ⇒ Diff(Json.Null, Json.Null, if (xs.isEmpty) Json.Null else Json.fromValues(xs)) + case (Nil, ys) ⇒ Diff(Json.Null, if (ys.isEmpty) Json.Null else Json.fromValues(ys), Json.Null) + case (x :: xs, y :: ys) ⇒ + val Diff(c1, a1, d1) = diff(x, y) + val Diff(c2, a2, d2) = diffRec(xs, ys) + Diff(append(c1, c2), append(a1, a2), append(d1, d2)) + } + diffRec(v1, v2) + } + + def diff(v1: Json, v2: Json): Diff = + if (v1 == v2) Diff(Json.Null, Json.Null, Json.Null) + else if (v1.isObject && v2.isObject) diffJsonObject(v1.asObject.get, v2.asObject.get) + else if (v1.isArray && v2.isArray) diffJsonArray(v1.asArray.get, v2.asArray.get) + else if (v1.isNumber && v2.isNumber) Diff(v2, Json.Null, Json.Null) + else if (v1.isString && v2.isString) Diff(v2, Json.Null, Json.Null) + else if (v1.isBoolean && v2.isBoolean) Diff(v2, Json.Null, Json.Null) + else if (v1.isNull) Diff(Json.Null, v2, Json.Null) + else if (v2.isNull) Diff(Json.Null, Json.Null, v1) + else Diff(v2, Json.Null, Json.Null) + +} From c566f02f40d6e7b81f634d73f2dd7ec6c7473112 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Thu, 9 Jun 2016 15:31:48 +0200 Subject: [PATCH 25/39] fix implementation and add some tests --- .../agourlay/cornichon/json/JsonDiff.scala | 30 ++-- .../cornichon/json/JsonDiffSpec.scala | 143 ++++++++++++++++++ 2 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 src/test/scala/com/github/agourlay/cornichon/json/JsonDiffSpec.scala diff --git a/src/main/scala/com/github/agourlay/cornichon/json/JsonDiff.scala b/src/main/scala/com/github/agourlay/cornichon/json/JsonDiff.scala index d1e0183d9..893cf28f2 100644 --- a/src/main/scala/com/github/agourlay/cornichon/json/JsonDiff.scala +++ b/src/main/scala/com/github/agourlay/cornichon/json/JsonDiff.scala @@ -14,7 +14,7 @@ object JsonDiff { } } - def append(v1: Json, v2: Json): Json = + private def append(v1: Json, v2: Json): Json = if (v1.isNull) v2 else if (v2.isNull) v1 else if (v1.isArray && v2.isArray) Json.fromValues(v1.asArray.get ::: v2.asArray.get) @@ -24,24 +24,30 @@ object JsonDiff { type JField = (String, Json) - def diffJsonObject(v1: JsonObject, v2: JsonObject): Diff = { - def diffRec(xleft: List[JField], yleft: List[JField]): Diff = xleft match { - case Nil ⇒ Diff(Json.Null, if (yleft.isEmpty) Json.Null else Json.fromJsonObject(JsonObject.fromIterable(yleft)), Json.Null) - case x :: xs ⇒ yleft find (_._1 == x._1) match { - case Some(y) ⇒ - val Diff(c1, a1, d1) = diff(x._2, y._2).toField(y._1) - val Diff(c2, a2, d2) = diffRec(xs, yleft filterNot (_ == y)) - Diff(c1.deepMerge(c2), a1.deepMerge(a2), d1.deepMerge(d2)) + private def merge(v1: Json, v2: Json): Json = { + if (v2.isNull) v1 + else v1.deepMerge(v2) + } + + private def diffJsonObject(v1: JsonObject, v2: JsonObject): Diff = { + def diffRec(leftFields: List[JField], rightFields: List[JField]): Diff = leftFields match { + case Nil ⇒ Diff(Json.Null, if (rightFields.isEmpty) Json.Null else Json.fromJsonObject(JsonObject.fromIterable(rightFields)), Json.Null) + case x :: xs ⇒ rightFields find (_._1 == x._1) match { + case Some(fieldInBoth) ⇒ + val Diff(c1, a1, d1) = diff(x._2, fieldInBoth._2).toField(fieldInBoth._1) + val fieldsAdded = rightFields filterNot (_ == fieldInBoth) + val Diff(c2, a2, d2) = diffRec(xs, fieldsAdded) + Diff(merge(c1, c2), merge(a1, a2), merge(d1, d2)) case None ⇒ - val Diff(c, a, d) = diffRec(xs, yleft) - Diff(c, a, Json.fromJsonObject(JsonObject.fromIterable(x :: Nil)).deepMerge(d)) + val Diff(c, a, d) = diffRec(xs, rightFields) + Diff(c, a, merge(Json.fromJsonObject(JsonObject.fromIterable(x :: Nil)), d)) } } diffRec(v1.toList, v2.toList) } - def diffJsonArray(v1: List[Json], v2: List[Json]): Diff = { + private def diffJsonArray(v1: List[Json], v2: List[Json]): Diff = { def diffRec(xleft: List[Json], yleft: List[Json]): Diff = (xleft, yleft) match { case (xs, Nil) ⇒ Diff(Json.Null, Json.Null, if (xs.isEmpty) Json.Null else Json.fromValues(xs)) case (Nil, ys) ⇒ Diff(Json.Null, if (ys.isEmpty) Json.Null else Json.fromValues(ys), Json.Null) diff --git a/src/test/scala/com/github/agourlay/cornichon/json/JsonDiffSpec.scala b/src/test/scala/com/github/agourlay/cornichon/json/JsonDiffSpec.scala new file mode 100644 index 000000000..9a7900ca5 --- /dev/null +++ b/src/test/scala/com/github/agourlay/cornichon/json/JsonDiffSpec.scala @@ -0,0 +1,143 @@ +package com.github.agourlay.cornichon.json + +import com.github.agourlay.cornichon.json.JsonDiff.Diff +import io.circe.Json +import org.scalatest.prop.PropertyChecks +import org.scalatest.{ Matchers, WordSpec } + +class JsonDiffSpec extends WordSpec with Matchers with PropertyChecks { + + "JsonDiff" must { + + "diff null values" in { + JsonDiff.diff(Json.Null, Json.Null) should be(Diff(Json.Null, Json.Null, Json.Null)) + } + + "diff null value and something else" in { + val right = Json.fromString("right") + JsonDiff.diff(right, Json.Null) should be(Diff(Json.Null, Json.Null, right)) + } + + "diff something and null" in { + val right = Json.fromString("left") + JsonDiff.diff(Json.Null, right) should be(Diff(Json.Null, right, Json.Null)) + } + + "diff of identical non nested type" in { + val left = Json.fromString("left") + val right = Json.fromString("right") + JsonDiff.diff(left, right) should be(Diff(right, Json.Null, Json.Null)) + } + + "diff of identical object type" in { + val input = + """ + |{ + |"2LettersName" : false, + | "Age": 50, + | "Name": "John" + |} + """.stripMargin + val json = CornichonJson.parseJsonUnsafe(input) + JsonDiff.diff(json, json) should be(Diff(Json.Null, Json.Null, Json.Null)) + } + + "changed object field" in { + val left = + """ + |{ + | "2LettersName" : false, + | "Age": 50, + | "Name": "John" + |} + """.stripMargin + + val right = + """ + |{ + | "2LettersName" : false, + | "Age": 50, + | "Name": "Johnny" + |} + """.stripMargin + + val jsonLeft = CornichonJson.parseJsonUnsafe(left) + val jsonRight = CornichonJson.parseJsonUnsafe(right) + + val expected = + """ + |{ "Name" : "Johnny" } + """.stripMargin + + val expectedJson = CornichonJson.parseJsonUnsafe(expected) + + JsonDiff.diff(jsonLeft, jsonRight) should be(Diff(expectedJson, Json.Null, Json.Null)) + } + + "deleted object field" in { + val left = + """ + |{ + | "2LettersName" : false, + | "Age": 50, + | "Name": "John" + |} + """.stripMargin + + val right = + """ + |{ + | "2LettersName" : false, + | "Age": 50 + |} + """.stripMargin + + val jsonLeft = CornichonJson.parseJsonUnsafe(left) + val jsonRight = CornichonJson.parseJsonUnsafe(right) + + val expected = + """ + |{ "Name" : "John" } + """.stripMargin + + val expectedJson = CornichonJson.parseJsonUnsafe(expected) + + JsonDiff.diff(jsonLeft, jsonRight) should be(Diff(Json.Null, Json.Null, expectedJson)) + } + + "added object field" in { + val left = + """ + |{ + | "2LettersName" : false, + | "Age": 50, + | "Name": "John" + |} + """.stripMargin + + val right = + """ + |{ + | "2LettersName" : false, + | "Age": 50, + | "Name": "John", + | "NickName": "Johnny" + |} + """.stripMargin + + val jsonLeft = CornichonJson.parseJsonUnsafe(left) + val jsonRight = CornichonJson.parseJsonUnsafe(right) + + val expected = + """ + |{ "NickName" : "Johnny" } + """.stripMargin + + val expectedJson = CornichonJson.parseJsonUnsafe(expected) + + JsonDiff.diff(jsonLeft, jsonRight) should be(Diff(Json.Null, expectedJson, Json.Null)) + } + + } + +} From c2e0974ac2a27dc973e0a3ffdc8520ea55b6effe Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Thu, 9 Jun 2016 19:43:57 +0200 Subject: [PATCH 26/39] refactor JsonDiff and add more tests --- .../agourlay/cornichon/json/JsonDiff.scala | 18 +- .../cornichon/json/JsonDiffSpec.scala | 185 +++++++++++++++++- 2 files changed, 190 insertions(+), 13 deletions(-) diff --git a/src/main/scala/com/github/agourlay/cornichon/json/JsonDiff.scala b/src/main/scala/com/github/agourlay/cornichon/json/JsonDiff.scala index 893cf28f2..a68961d34 100644 --- a/src/main/scala/com/github/agourlay/cornichon/json/JsonDiff.scala +++ b/src/main/scala/com/github/agourlay/cornichon/json/JsonDiff.scala @@ -7,10 +7,9 @@ import io.circe.{ Json, JsonObject } object JsonDiff { case class Diff(changed: Json, added: Json, deleted: Json) { - def toField(name: String): Diff = { - def applyTo(x: Json): Json = if (x == Json.Null) Json.Null else Json.fromJsonObject(JsonObject.singleton(name, x)) - - Diff(applyTo(changed), applyTo(added), applyTo(deleted)) + def asField(name: String): Diff = { + def bindTo(x: Json): Json = if (x == Json.Null) Json.Null else Json.fromJsonObject(JsonObject.singleton(name, x)) + Diff(bindTo(changed), bindTo(added), bindTo(deleted)) } } @@ -22,19 +21,14 @@ object JsonDiff { else if (v2.isArray) Json.fromValues(v1 +: v2.asArray.get) else Json.fromValues(v1 :: v2 :: Nil) - type JField = (String, Json) - - private def merge(v1: Json, v2: Json): Json = { - if (v2.isNull) v1 - else v1.deepMerge(v2) - } + private def merge(v1: Json, v2: Json) = if (v2.isNull) v1 else v1.deepMerge(v2) private def diffJsonObject(v1: JsonObject, v2: JsonObject): Diff = { - def diffRec(leftFields: List[JField], rightFields: List[JField]): Diff = leftFields match { + def diffRec(leftFields: List[(String, Json)], rightFields: List[(String, Json)]): Diff = leftFields match { case Nil ⇒ Diff(Json.Null, if (rightFields.isEmpty) Json.Null else Json.fromJsonObject(JsonObject.fromIterable(rightFields)), Json.Null) case x :: xs ⇒ rightFields find (_._1 == x._1) match { case Some(fieldInBoth) ⇒ - val Diff(c1, a1, d1) = diff(x._2, fieldInBoth._2).toField(fieldInBoth._1) + val Diff(c1, a1, d1) = diff(x._2, fieldInBoth._2).asField(fieldInBoth._1) val fieldsAdded = rightFields filterNot (_ == fieldInBoth) val Diff(c2, a2, d2) = diffRec(xs, fieldsAdded) Diff(merge(c1, c2), merge(a1, a2), merge(d1, d2)) diff --git a/src/test/scala/com/github/agourlay/cornichon/json/JsonDiffSpec.scala b/src/test/scala/com/github/agourlay/cornichon/json/JsonDiffSpec.scala index 9a7900ca5..c8030e34c 100644 --- a/src/test/scala/com/github/agourlay/cornichon/json/JsonDiffSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/json/JsonDiffSpec.scala @@ -138,6 +138,189 @@ class JsonDiffSpec extends WordSpec with Matchers with PropertyChecks { JsonDiff.diff(jsonLeft, jsonRight) should be(Diff(Json.Null, expectedJson, Json.Null)) } - } + "changed object nested field" in { + val left = + """ + |{ + | "2LettersName" : false, + | "Age": 50, + | "Name": "John", + | "brother": { + | "Name" : "Paul", + | "Age": 50 + | } + |} + """.stripMargin + + val right = + """ + |{ + | "2LettersName" : false, + | "Age": 50, + | "Name": "John", + | "brother": { + | "Name" : "Paul", + | "Age": 51 + | } + |} + """.stripMargin + + val jsonLeft = CornichonJson.parseJsonUnsafe(left) + val jsonRight = CornichonJson.parseJsonUnsafe(right) + + val expected = + """ + |{ + |"brother" : { + | "Age" : 51 + | } + |} + """.stripMargin + + val expectedJson = CornichonJson.parseJsonUnsafe(expected) + + JsonDiff.diff(jsonLeft, jsonRight) should be(Diff(expectedJson, Json.Null, Json.Null)) + } + + "added object nested field" in { + val left = + """ + |{ + | "2LettersName" : false, + | "Age": 50, + | "Name": "John", + | "brother": { + | "Name" : "Paul", + | "Age": 50 + | } + |} + """.stripMargin + + val right = + """ + |{ + | "2LettersName" : false, + | "Age": 50, + | "Name": "John", + | "brother": { + | "Name" : "Paul", + | "Age": 50, + | "Job": "Farmer" + | } + |} + """.stripMargin + + val jsonLeft = CornichonJson.parseJsonUnsafe(left) + val jsonRight = CornichonJson.parseJsonUnsafe(right) + + val expected = + """ + |{ + |"brother" : { + | "Job" : "Farmer" + | } + |} + """.stripMargin + + val expectedJson = CornichonJson.parseJsonUnsafe(expected) + + JsonDiff.diff(jsonLeft, jsonRight) should be(Diff(Json.Null, expectedJson, Json.Null)) + } + + "deleted object nested field" in { + val left = + """ + |{ + | "2LettersName" : false, + | "Age": 50, + | "Name": "John", + | "brother": { + | "Name" : "Paul", + | "Age": 50 + | } + |} + """.stripMargin + + val right = + """ + |{ + | "2LettersName" : false, + | "Age": 50, + | "Name": "John", + | "brother": { + | "Name" : "Paul" + | } + |} + """.stripMargin + + val jsonLeft = CornichonJson.parseJsonUnsafe(left) + val jsonRight = CornichonJson.parseJsonUnsafe(right) + + val expected = + """ + |{ + |"brother" : { + | "Age" : 50 + | } + |} + """.stripMargin + + val expectedJson = CornichonJson.parseJsonUnsafe(expected) + + JsonDiff.diff(jsonLeft, jsonRight) should be(Diff(Json.Null, Json.Null, expectedJson)) + } + + "diff identical arrays" in { + val one = Json.fromString("one") + val two = Json.fromString("two") + + val rightArray = Json.fromValues(one :: two :: Nil) + val leftArray = Json.fromValues(one :: two :: Nil) + + JsonDiff.diff(leftArray, rightArray) should be(Diff(Json.Null, Json.Null, Json.Null)) + } + + "removed element in arrays" in { + val one = Json.fromString("one") + val two = Json.fromString("two") + val three = Json.fromString("three") + + val rightArray = Json.fromValues(one :: two :: Nil) + val leftArray = Json.fromValues(one :: two :: three :: Nil) + JsonDiff.diff(leftArray, rightArray) should be(Diff(Json.Null, Json.Null, Json.fromValues(three :: Nil))) + } + + "added element in arrays" in { + val one = Json.fromString("one") + val two = Json.fromString("two") + val three = Json.fromString("three") + + val leftArray = Json.fromValues(one :: two :: Nil) + val rightArray = Json.fromValues(one :: two :: three :: Nil) + + JsonDiff.diff(leftArray, rightArray) should be(Diff(Json.Null, Json.fromValues(three :: Nil), Json.Null)) + } + + "changed element in arrays" in { + val one = Json.fromString("one") + val two = Json.fromString("two") + val three = Json.fromString("three") + + val leftArray = Json.fromValues(one :: two :: Nil) + val rightArray = Json.fromValues(one :: three :: Nil) + + JsonDiff.diff(leftArray, rightArray) should be(Diff(three, Json.Null, Json.Null)) + } + + "changed order in arrays" in { + val one = Json.fromString("one") + val two = Json.fromString("two") + + val rightArray = Json.fromValues(one :: two :: Nil) + val leftArray = Json.fromValues(two :: one :: Nil) + + JsonDiff.diff(leftArray, rightArray) should be(Diff(Json.fromValues(one :: two :: Nil), Json.Null, Json.Null)) + } + } } From 22f7356c3e09215f396d7c1024360dc98d2ab7bf Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Fri, 10 Jun 2016 08:12:35 +0200 Subject: [PATCH 27/39] comment json path lens and remove dependency for now to not ship it --- build.sbt | 2 +- .../scala/com/github/agourlay/cornichon/json/JsonPath.scala | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.sbt b/build.sbt index b5dca13d0..62c35768c 100644 --- a/build.sbt +++ b/build.sbt @@ -58,7 +58,7 @@ libraryDependencies ++= { ,"io.circe" %% "circe-core" % circeVersion ,"io.circe" %% "circe-generic" % circeVersion ,"io.circe" %% "circe-parser" % circeVersion - ,"io.circe" %% "circe-optics" % circeVersion // Remove if cursors are used instead or lenses for JsonPath. + //,"io.circe" %% "circe-optics" % circeVersion Remove if cursors are used instead or lenses for JsonPath. ,"de.heikoseeberger" %% "akka-http-circe" % akkaHttpCirce % "test" ,"com.typesafe.akka" %% "akka-http-experimental" % akkaHttpV % "test" ) diff --git a/src/main/scala/com/github/agourlay/cornichon/json/JsonPath.scala b/src/main/scala/com/github/agourlay/cornichon/json/JsonPath.scala index 7c3860a03..07fe67dad 100644 --- a/src/main/scala/com/github/agourlay/cornichon/json/JsonPath.scala +++ b/src/main/scala/com/github/agourlay/cornichon/json/JsonPath.scala @@ -14,14 +14,14 @@ case class JsonPath(operations: List[JsonPathOperation] = List.empty) { //FIXME should we use Lens or Cursor to implement JsonPath? //FIXME current implement does not work //TODO test with key starting with numbers - private val lense = operations.foldLeft(io.circe.optics.JsonPath.root) { (l, op) ⇒ + /*private val lense = operations.foldLeft(io.circe.optics.JsonPath.root) { (l, op) ⇒ op match { case FieldSelection(field) ⇒ l.field case ArrayFieldSelection(field, indice) ⇒ l.field.at(indice) } - } + }*/ def run(superSet: Json): Json = cursor(superSet).fold(Json.Null)(c ⇒ c.focus) def run(json: String): Xor[CornichonError, Json] = parseJson(json).map(run) From a0077be5d92610118fe3374eada2d401e836b9d8 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Fri, 10 Jun 2016 11:44:45 +0200 Subject: [PATCH 28/39] extra test for Json path --- .../cornichon/json/JsonPathSpec.scala | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/test/scala/com/github/agourlay/cornichon/json/JsonPathSpec.scala b/src/test/scala/com/github/agourlay/cornichon/json/JsonPathSpec.scala index d265c9273..31ab09b34 100644 --- a/src/test/scala/com/github/agourlay/cornichon/json/JsonPathSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/json/JsonPathSpec.scala @@ -34,5 +34,45 @@ class JsonPathSpec extends WordSpec with Matchers with PropertyChecks { JsonPath.parse("Age").run(input) should be(right(Json.fromInt(50))) } + + "select properly nested field in Object" in { + val input = + """ + |{ + | "2LettersName" : false, + | "Age": 50, + | "Name": "John", + | "brother": { + | "Name" : "Paul", + | "Age": 50 + | } + |} + """.stripMargin + + JsonPath.parse("brother.Age").run(input) should be(right(Json.fromInt(50))) + } + + "select properly nested field in Array" in { + val input = + """ + |{ + | "2LettersName" : false, + | "Age": 50, + | "Name": "John", + | "brothers": [ + | { + | "Name" : "Paul", + | "Age": 50 + | }, + | { + | "Name": "Bob", + | "Age" : 30 + | } + | ] + |} + """.stripMargin + + JsonPath.parse("brothers[1].Age").run(input) should be(right(Json.fromInt(30))) + } } } From a4487a54e611571cfc00121a25336ba3d0fbb1ee Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Fri, 10 Jun 2016 12:37:20 +0200 Subject: [PATCH 29/39] Design better HttpServiceExtractor to provide also the usage of JsonPath --- .../agourlay/cornichon/http/HttpService.scala | 60 +++++++++++++------ .../cornichon/http/HttpServiceSpec.scala | 36 ++++++++++- 2 files changed, 76 insertions(+), 20 deletions(-) diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala index 484cd00a2..f4640c8ab 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala @@ -7,7 +7,7 @@ import cats.data.Xor import cats.data.Xor.{ left, right } import com.github.agourlay.cornichon.core._ import com.github.agourlay.cornichon.http.client.HttpClient -import com.github.agourlay.cornichon.json.CornichonJson +import com.github.agourlay.cornichon.json.{ CornichonJson, JsonPath } import io.circe.Json import scala.concurrent.duration._ @@ -20,7 +20,7 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC private type WithoutPayloadCall = (String, Seq[(String, String)], Seq[HttpHeader], FiniteDuration) ⇒ Xor[HttpError, CornichonHttpResponse] private def withPayload(call: WithPayloadCall, payload: String, url: String, params: Seq[(String, String)], - headers: Seq[(String, String)], extractor: Option[String], requestTimeout: FiniteDuration, expect: Option[Int])(s: Session) = + headers: Seq[(String, String)], extractor: ResponseExtractor, requestTimeout: FiniteDuration, expect: Option[Int])(s: Session) = for { payloadResolved ← resolver.fillPlaceholders(payload)(s) json ← parseJson(payloadResolved) @@ -32,7 +32,7 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC } private def withoutPayload(call: WithoutPayloadCall, url: String, params: Seq[(String, String)], - headers: Seq[(String, String)], extractor: Option[String], requestTimeout: FiniteDuration, expect: Option[Int])(s: Session) = + headers: Seq[(String, String)], extractor: ResponseExtractor, requestTimeout: FiniteDuration, expect: Option[Int])(s: Session) = for { r ← resolveCommonRequestParts(url, params, headers)(s) resp ← call(r._1, r._2, r._3, requestTimeout) @@ -41,7 +41,7 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC (resp, newSession) } - private def handleResponse(resp: CornichonHttpResponse, expect: Option[Int], extractor: Option[String])(session: Session) = + private def handleResponse(resp: CornichonHttpResponse, expect: Option[Int], extractor: ResponseExtractor)(session: Session) = for { resExpected ← expectStatusCode(resp, expect) newSession = fillInSessionWithResponse(session, resp, extractor) @@ -74,42 +74,63 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC resolver.tuplesResolver(urlParams.toSeq ++ params, session) } - def Post(url: String, payload: String, params: Seq[(String, String)], headers: Seq[(String, String)], extractor: Option[String] = None, expected: Option[Int] = None)(s: Session) = + def Post(url: String, payload: String, params: Seq[(String, String)], headers: Seq[(String, String)], + extractor: ResponseExtractor = NoOpExtraction, expected: Option[Int] = None)(s: Session) = withPayload(client.postJson, payload, url, params, headers, extractor, requestTimeout, expected)(s).fold(e ⇒ throw e, _._2) - def Put(url: String, payload: String, params: Seq[(String, String)], headers: Seq[(String, String)], extractor: Option[String] = None, expected: Option[Int] = None)(s: Session): Session = + def Put(url: String, payload: String, params: Seq[(String, String)], headers: Seq[(String, String)], + extractor: ResponseExtractor = NoOpExtraction, expected: Option[Int] = None)(s: Session): Session = withPayload(client.putJson, payload, url, params, headers, extractor, requestTimeout, expected)(s).fold(e ⇒ throw e, _._2) - def Patch(url: String, payload: String, params: Seq[(String, String)], headers: Seq[(String, String)], extractor: Option[String] = None, expected: Option[Int] = None)(s: Session): Session = + def Patch(url: String, payload: String, params: Seq[(String, String)], headers: Seq[(String, String)], + extractor: ResponseExtractor = NoOpExtraction, expected: Option[Int] = None)(s: Session): Session = withPayload(client.patchJson, payload, url, params, headers, extractor, requestTimeout, expected)(s).fold(e ⇒ throw e, _._2) - def Get(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], extractor: Option[String] = None, expected: Option[Int] = None)(s: Session): Session = + def Get(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], + extractor: ResponseExtractor = NoOpExtraction, expected: Option[Int] = None)(s: Session): Session = withoutPayload(client.getJson, url, params, headers, extractor, requestTimeout, expected)(s).fold(e ⇒ throw e, _._2) - def Head(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], extractor: Option[String] = None, expected: Option[Int] = None)(s: Session): Session = + def Head(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], + extractor: ResponseExtractor = NoOpExtraction, expected: Option[Int] = None)(s: Session): Session = withoutPayload(client.headJson, url, params, headers, extractor, requestTimeout, expected)(s).fold(e ⇒ throw e, _._2) - def Options(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], extractor: Option[String] = None, expected: Option[Int] = None)(s: Session): Session = + def Options(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], + extractor: ResponseExtractor = NoOpExtraction, expected: Option[Int] = None)(s: Session): Session = withoutPayload(client.optionsJson, url, params, headers, extractor, requestTimeout, expected)(s).fold(e ⇒ throw e, _._2) - def Delete(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], extractor: Option[String] = None, expected: Option[Int] = None)(s: Session): Session = + def Delete(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], + extractor: ResponseExtractor = NoOpExtraction, expected: Option[Int] = None)(s: Session): Session = withoutPayload(client.deleteJson, url, params, headers, extractor, requestTimeout, expected)(s).fold(e ⇒ throw e, _._2) - def OpenSSE(url: String, takeWithin: FiniteDuration, params: Seq[(String, String)], headers: Seq[(String, String)], extractor: Option[String] = None)(s: Session) = + def OpenSSE(url: String, takeWithin: FiniteDuration, params: Seq[(String, String)], headers: Seq[(String, String)], + extractor: ResponseExtractor = NoOpExtraction)(s: Session) = withoutPayload(client.openSSE, url, params, headers, extractor, takeWithin, None)(s).fold(e ⇒ throw e, _._2) - def OpenWS(url: String, takeWithin: FiniteDuration, params: Seq[(String, String)], headers: Seq[(String, String)], extractor: Option[String] = None)(s: Session) = + def OpenWS(url: String, takeWithin: FiniteDuration, params: Seq[(String, String)], headers: Seq[(String, String)], + extractor: ResponseExtractor = NoOpExtraction)(s: Session) = withoutPayload(client.openWS, url, params, headers, extractor, takeWithin, None)(s).fold(e ⇒ throw e, _._2) - def fillInSessionWithResponse(session: Session, response: CornichonHttpResponse, extractor: Option[String]): Session = - extractor.fold(session) { e ⇒ - session.addValue(e, response.body) - }.addValues(Seq( + def commonSessionExtraction(session: Session, response: CornichonHttpResponse): Session = + session.addValues(Seq( LastResponseStatusKey → response.status.intValue().toString, LastResponseBodyKey → response.body, LastResponseHeadersKey → encodeSessionHeaders(response) )) + def fillInSessionWithResponse(session: Session, response: CornichonHttpResponse, extractor: ResponseExtractor): Session = { + extractor match { + case NoOpExtraction ⇒ + commonSessionExtraction(session, response) + + case RootResponseExtractor(targetKey) ⇒ + commonSessionExtraction(session, response).addValue(targetKey, response.body) + + case PathResponseExtractor(path, targetKey) ⇒ + val extractedJson = JsonPath.run(path, response.body).fold(e ⇒ throw e, identity) + commonSessionExtraction(session, response).addValue(targetKey, CornichonJson.jsonStringValue(extractedJson)) + } + } + def parseHttpHeaders(headers: Seq[(String, String)]): Xor[MalformedHeadersError, Seq[HttpHeader]] = { @scala.annotation.tailrec def loop(headers: Seq[(String, String)], acc: Seq[HttpHeader]): Xor[MalformedHeadersError, Seq[HttpHeader]] = { @@ -134,6 +155,11 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC private def withBaseUrl(input: String) = if (baseUrl.isEmpty) input else baseUrl + input } +sealed trait ResponseExtractor +case class RootResponseExtractor(targetKey: String) extends ResponseExtractor +case class PathResponseExtractor(path: String, targetKey: String) extends ResponseExtractor +object NoOpExtraction extends ResponseExtractor + object HttpService { val LastResponseBodyKey = "last-response-body" val LastResponseStatusKey = "last-response-status" diff --git a/src/test/scala/com/github/agourlay/cornichon/http/HttpServiceSpec.scala b/src/test/scala/com/github/agourlay/cornichon/http/HttpServiceSpec.scala index 21e267830..2a9ee47fd 100644 --- a/src/test/scala/com/github/agourlay/cornichon/http/HttpServiceSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/http/HttpServiceSpec.scala @@ -25,11 +25,41 @@ class HttpServiceSpec extends WordSpec with Matchers with BeforeAndAfterAll { "HttpService" when { "fillInSessionWithResponse" must { - "extract content" in { + "extract content with NoOpExtraction" in { val s = Session.newSession val resp = CornichonHttpResponse(StatusCodes.OK, Nil, "hello world") - service.fillInSessionWithResponse(s, resp, None).get("last-response-status") should be("200") - service.fillInSessionWithResponse(s, resp, None).get("last-response-body") should be("hello world") + val filledSession = service.fillInSessionWithResponse(s, resp, NoOpExtraction) + filledSession.get("last-response-status") should be("200") + filledSession.get("last-response-body") should be("hello world") + } + + "extract content with RootResponseExtraction" in { + val s = Session.newSession + val resp = CornichonHttpResponse(StatusCodes.OK, Nil, "hello world") + val filledSession = service.fillInSessionWithResponse(s, resp, RootResponseExtractor("copy-body")) + filledSession.get("last-response-status") should be("200") + filledSession.get("last-response-body") should be("hello world") + filledSession.get("copy-body") should be("hello world") + } + + "extract content with PathResponseExtraction" in { + val s = Session.newSession + val resp = CornichonHttpResponse(StatusCodes.OK, Nil, + """ + { + "name" : "batman" + } + """) + val filledSession = service.fillInSessionWithResponse(s, resp, PathResponseExtractor("name", "part-of-body")) + filledSession.get("last-response-status") should be("200") + filledSession.get("last-response-body") should be( + """ + { + "name" : "batman" + } + """ + ) + filledSession.get("part-of-body") should be("batman") } } From be707dd8095525dcfcf13e2de7994a60d0ecfd8e Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Fri, 10 Jun 2016 12:41:28 +0200 Subject: [PATCH 30/39] improve naming of expectedStatus param --- .../agourlay/cornichon/http/HttpService.scala | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala index f4640c8ab..94f89f4c2 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala @@ -20,30 +20,30 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC private type WithoutPayloadCall = (String, Seq[(String, String)], Seq[HttpHeader], FiniteDuration) ⇒ Xor[HttpError, CornichonHttpResponse] private def withPayload(call: WithPayloadCall, payload: String, url: String, params: Seq[(String, String)], - headers: Seq[(String, String)], extractor: ResponseExtractor, requestTimeout: FiniteDuration, expect: Option[Int])(s: Session) = + headers: Seq[(String, String)], extractor: ResponseExtractor, requestTimeout: FiniteDuration, expectedStatus: Option[Int])(s: Session) = for { payloadResolved ← resolver.fillPlaceholders(payload)(s) json ← parseJson(payloadResolved) r ← resolveCommonRequestParts(url, params, headers)(s) resp ← call(json, r._1, r._2, r._3, requestTimeout) - newSession ← handleResponse(resp, expect, extractor)(s) + newSession ← handleResponse(resp, expectedStatus, extractor)(s) } yield { (resp, newSession) } private def withoutPayload(call: WithoutPayloadCall, url: String, params: Seq[(String, String)], - headers: Seq[(String, String)], extractor: ResponseExtractor, requestTimeout: FiniteDuration, expect: Option[Int])(s: Session) = + headers: Seq[(String, String)], extractor: ResponseExtractor, requestTimeout: FiniteDuration, expectedStatus: Option[Int])(s: Session) = for { r ← resolveCommonRequestParts(url, params, headers)(s) resp ← call(r._1, r._2, r._3, requestTimeout) - newSession ← handleResponse(resp, expect, extractor)(s) + newSession ← handleResponse(resp, expectedStatus, extractor)(s) } yield { (resp, newSession) } - private def handleResponse(resp: CornichonHttpResponse, expect: Option[Int], extractor: ResponseExtractor)(session: Session) = + private def handleResponse(resp: CornichonHttpResponse, expectedStatus: Option[Int], extractor: ResponseExtractor)(session: Session) = for { - resExpected ← expectStatusCode(resp, expect) + resExpected ← expectStatusCode(resp, expectedStatus) newSession = fillInSessionWithResponse(session, resp, extractor) } yield { newSession @@ -75,32 +75,32 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC } def Post(url: String, payload: String, params: Seq[(String, String)], headers: Seq[(String, String)], - extractor: ResponseExtractor = NoOpExtraction, expected: Option[Int] = None)(s: Session) = - withPayload(client.postJson, payload, url, params, headers, extractor, requestTimeout, expected)(s).fold(e ⇒ throw e, _._2) + extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session) = + withPayload(client.postJson, payload, url, params, headers, extractor, requestTimeout, expectedStatus)(s).fold(e ⇒ throw e, _._2) def Put(url: String, payload: String, params: Seq[(String, String)], headers: Seq[(String, String)], - extractor: ResponseExtractor = NoOpExtraction, expected: Option[Int] = None)(s: Session): Session = - withPayload(client.putJson, payload, url, params, headers, extractor, requestTimeout, expected)(s).fold(e ⇒ throw e, _._2) + extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = + withPayload(client.putJson, payload, url, params, headers, extractor, requestTimeout, expectedStatus)(s).fold(e ⇒ throw e, _._2) def Patch(url: String, payload: String, params: Seq[(String, String)], headers: Seq[(String, String)], - extractor: ResponseExtractor = NoOpExtraction, expected: Option[Int] = None)(s: Session): Session = - withPayload(client.patchJson, payload, url, params, headers, extractor, requestTimeout, expected)(s).fold(e ⇒ throw e, _._2) + extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = + withPayload(client.patchJson, payload, url, params, headers, extractor, requestTimeout, expectedStatus)(s).fold(e ⇒ throw e, _._2) def Get(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], - extractor: ResponseExtractor = NoOpExtraction, expected: Option[Int] = None)(s: Session): Session = - withoutPayload(client.getJson, url, params, headers, extractor, requestTimeout, expected)(s).fold(e ⇒ throw e, _._2) + extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = + withoutPayload(client.getJson, url, params, headers, extractor, requestTimeout, expectedStatus)(s).fold(e ⇒ throw e, _._2) def Head(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], - extractor: ResponseExtractor = NoOpExtraction, expected: Option[Int] = None)(s: Session): Session = - withoutPayload(client.headJson, url, params, headers, extractor, requestTimeout, expected)(s).fold(e ⇒ throw e, _._2) + extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = + withoutPayload(client.headJson, url, params, headers, extractor, requestTimeout, expectedStatus)(s).fold(e ⇒ throw e, _._2) def Options(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], - extractor: ResponseExtractor = NoOpExtraction, expected: Option[Int] = None)(s: Session): Session = - withoutPayload(client.optionsJson, url, params, headers, extractor, requestTimeout, expected)(s).fold(e ⇒ throw e, _._2) + extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = + withoutPayload(client.optionsJson, url, params, headers, extractor, requestTimeout, expectedStatus)(s).fold(e ⇒ throw e, _._2) def Delete(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], - extractor: ResponseExtractor = NoOpExtraction, expected: Option[Int] = None)(s: Session): Session = - withoutPayload(client.deleteJson, url, params, headers, extractor, requestTimeout, expected)(s).fold(e ⇒ throw e, _._2) + extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = + withoutPayload(client.deleteJson, url, params, headers, extractor, requestTimeout, expectedStatus)(s).fold(e ⇒ throw e, _._2) def OpenSSE(url: String, takeWithin: FiniteDuration, params: Seq[(String, String)], headers: Seq[(String, String)], extractor: ResponseExtractor = NoOpExtraction)(s: Session) = From a221de06adf49e86a5f3d397ce9add60e5948bd7 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Fri, 10 Jun 2016 12:46:53 +0200 Subject: [PATCH 31/39] lighter names --- .../com/github/agourlay/cornichon/http/HttpService.scala | 8 ++++---- .../github/agourlay/cornichon/http/HttpServiceSpec.scala | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala index 94f89f4c2..96ac96ce8 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala @@ -122,10 +122,10 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC case NoOpExtraction ⇒ commonSessionExtraction(session, response) - case RootResponseExtractor(targetKey) ⇒ + case RootExtractor(targetKey) ⇒ commonSessionExtraction(session, response).addValue(targetKey, response.body) - case PathResponseExtractor(path, targetKey) ⇒ + case PathExtractor(path, targetKey) ⇒ val extractedJson = JsonPath.run(path, response.body).fold(e ⇒ throw e, identity) commonSessionExtraction(session, response).addValue(targetKey, CornichonJson.jsonStringValue(extractedJson)) } @@ -156,8 +156,8 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC } sealed trait ResponseExtractor -case class RootResponseExtractor(targetKey: String) extends ResponseExtractor -case class PathResponseExtractor(path: String, targetKey: String) extends ResponseExtractor +case class RootExtractor(targetKey: String) extends ResponseExtractor +case class PathExtractor(path: String, targetKey: String) extends ResponseExtractor object NoOpExtraction extends ResponseExtractor object HttpService { diff --git a/src/test/scala/com/github/agourlay/cornichon/http/HttpServiceSpec.scala b/src/test/scala/com/github/agourlay/cornichon/http/HttpServiceSpec.scala index 2a9ee47fd..18664d448 100644 --- a/src/test/scala/com/github/agourlay/cornichon/http/HttpServiceSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/http/HttpServiceSpec.scala @@ -36,7 +36,7 @@ class HttpServiceSpec extends WordSpec with Matchers with BeforeAndAfterAll { "extract content with RootResponseExtraction" in { val s = Session.newSession val resp = CornichonHttpResponse(StatusCodes.OK, Nil, "hello world") - val filledSession = service.fillInSessionWithResponse(s, resp, RootResponseExtractor("copy-body")) + val filledSession = service.fillInSessionWithResponse(s, resp, RootExtractor("copy-body")) filledSession.get("last-response-status") should be("200") filledSession.get("last-response-body") should be("hello world") filledSession.get("copy-body") should be("hello world") @@ -50,7 +50,7 @@ class HttpServiceSpec extends WordSpec with Matchers with BeforeAndAfterAll { "name" : "batman" } """) - val filledSession = service.fillInSessionWithResponse(s, resp, PathResponseExtractor("name", "part-of-body")) + val filledSession = service.fillInSessionWithResponse(s, resp, PathExtractor("name", "part-of-body")) filledSession.get("last-response-status") should be("200") filledSession.get("last-response-body") should be( """ From c296be459eb2db062d8d1acfaaaf86cef2aa6ba0 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Fri, 10 Jun 2016 14:35:43 +0200 Subject: [PATCH 32/39] format --- .../agourlay/cornichon/http/HttpService.scala | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala index 96ac96ce8..896ecfee2 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala @@ -75,31 +75,31 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC } def Post(url: String, payload: String, params: Seq[(String, String)], headers: Seq[(String, String)], - extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session) = + extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session) = withPayload(client.postJson, payload, url, params, headers, extractor, requestTimeout, expectedStatus)(s).fold(e ⇒ throw e, _._2) def Put(url: String, payload: String, params: Seq[(String, String)], headers: Seq[(String, String)], - extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = + extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = withPayload(client.putJson, payload, url, params, headers, extractor, requestTimeout, expectedStatus)(s).fold(e ⇒ throw e, _._2) def Patch(url: String, payload: String, params: Seq[(String, String)], headers: Seq[(String, String)], - extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = + extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = withPayload(client.patchJson, payload, url, params, headers, extractor, requestTimeout, expectedStatus)(s).fold(e ⇒ throw e, _._2) def Get(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], - extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = + extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = withoutPayload(client.getJson, url, params, headers, extractor, requestTimeout, expectedStatus)(s).fold(e ⇒ throw e, _._2) def Head(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], - extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = + extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = withoutPayload(client.headJson, url, params, headers, extractor, requestTimeout, expectedStatus)(s).fold(e ⇒ throw e, _._2) def Options(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], - extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = + extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = withoutPayload(client.optionsJson, url, params, headers, extractor, requestTimeout, expectedStatus)(s).fold(e ⇒ throw e, _._2) def Delete(url: String, params: Seq[(String, String)], headers: Seq[(String, String)], - extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = + extractor: ResponseExtractor = NoOpExtraction, expectedStatus: Option[Int] = None)(s: Session): Session = withoutPayload(client.deleteJson, url, params, headers, extractor, requestTimeout, expectedStatus)(s).fold(e ⇒ throw e, _._2) def OpenSSE(url: String, takeWithin: FiniteDuration, params: Seq[(String, String)], headers: Seq[(String, String)], From 910941fae5534c2149daeaa0cdb67a408abd5161 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Fri, 10 Jun 2016 14:36:03 +0200 Subject: [PATCH 33/39] add helper bodyAssertion isEmpty == hasSize(0) --- .../com/github/agourlay/cornichon/http/HttpAssertions.scala | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala index 8a17bb593..f3881a3eb 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpAssertions.scala @@ -158,6 +158,8 @@ object HttpAssertions { def ignoringEach(ignoringEach: String*): BodyArrayAssertion[A] = copy(ignoredEachKeys = ignoringEach) + def isEmpty = hasSize(0) + def hasSize(size: Int): AssertStep[Int] = { val title = if (jsonPath == JsonPath.root) s"response body array size is '$size'" else s"response body's array '$jsonPath' size is '$size'" from_session_detail_step( From 21b4583fe3e33f42421c4e41a518480a0d87dc50 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Sat, 11 Jun 2016 16:08:56 +0200 Subject: [PATCH 34/39] add docs about http service for custom steps --- README.md | 177 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 107 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index e27cb639e..e7d150c76 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,10 @@ An extensible Scala DSL for testing JSON HTTP APIs. 5. [Wrapper steps](#wrapper-steps) 6. [Debug steps](#debug-steps) 5. [DSL composition](#dsl-composition) -6. [Custom steps](#custom-steps) -7. [Placeholders](#placeholders) +6. [Placeholders](#placeholders) +7. [Custom steps](#custom-steps) + 1. [Effects and Assertions](#effects-and-assertions) + 2. [HTTP service](#http-service) 8. [Feature options](#feature-options) 1. [Before and after hooks](#before-and-after-hooks) 2. [Base URL](#base-url) @@ -585,74 +587,6 @@ class CornichonExamplesSpec extends CornichonFeature { ``` -## Custom steps - -There are two kind of ```step``` : -- EffectStep ```Session => Session``` : It runs a side effect and populates the ```Session``` with values. -- AssertStep ```Sesssion => StepAssertion[A]``` : Describes the expectation of the test. - - -A ```session``` is a Map-like object used to propagate state throughout a ```scenario```. It is used to resolve [placeholders](#placeholders) - -A ```StepAssertion``` is simply a container for 2 values, the expected value and the actual result. The test engine is responsible to test the equality of the ```StepAssertion``` values. - -The engine will try its best to provide a meaningful error message, if a specific error message is required tt is also possible to provide a custom error message using a ```DetailedStepAssertion```. - -```scala - DetailedStepAssertion[A](expected: A, result: A, details: A ⇒ String) -``` - -The engine will feed the actual result to the ```details``` function. - -In practice the simplest runnable statement in the DSL is - -```scala -When I AssertStep("do nothing", s => StepAssertion(true, true)) -``` - -Let's try to assert the result of a computation - -```scala -When I AssertStep("calculate", s => StepAssertion(2 + 2, 4)) -``` - -The ```session``` is used to store the result of a computation in order to reuse it or to apply more advanced assertions on it later. - - -```scala -When I EffectStep( - title = "run crazy computation", - action = s => { - val pi = piComputation() - s.add("result", res) - }) - -Then assert AssertStep( - title = "check computation infos", - action = s => { - val pi = s.get("result") - StepAssertion(pi, 3.14) - }) -``` - -This is rather low level and you not should write your steps like that directly inside the DSL. - -Fortunately a bunch of built-in steps and primitive building blocs are already available. - -Most of the time you will create your own trait containing your custom steps : - -```scala -trait MySteps { - this: CornichonFeature ⇒ - - // here access all the goodies from the DSLs and the HttpService. -} - -``` - -Note for advance users: it is also possible to write custom wrapper steps. - - ## Placeholders Most built-in steps can use placeholders in their arguments, those will be automatically resolved from the ```session```: @@ -722,6 +656,109 @@ It becomes then possible to retrieve past values : - `````` uses the first value taken by the key - `````` uses the second element taken by the key +## Custom steps + +### Effects and Assertions + +There are two kind of ```step``` : +- EffectStep ```Session => Session``` : It runs a side effect and populates the ```Session``` with values. +- AssertStep ```Sesssion => StepAssertion[A]``` : Describes the expectation of the test. + + +A ```session``` is a Map-like object used to propagate state throughout a ```scenario```. It is used to resolve [placeholders](#placeholders) + +A ```StepAssertion``` is simply a container for 2 values, the expected value and the actual result. The test engine is responsible to test the equality of the ```StepAssertion``` values. + +The engine will try its best to provide a meaningful error message, if a specific error message is required it is also possible to provide a custom error message using a ```DetailedStepAssertion```. + +```scala + DetailedStepAssertion[A](expected: A, result: A, details: A ⇒ String) +``` + +The engine will feed the actual result to the ```details``` function. + +In practice the simplest runnable statement in the DSL is + +```scala +When I AssertStep("do nothing", s => StepAssertion(true, true)) +``` + +Let's try to assert the result of a computation + +```scala +When I AssertStep("calculate", s => StepAssertion(2 + 2, 4)) +``` + +The ```session``` is used to store the result of a computation in order to reuse it or to apply more advanced assertions on it later. + + +```scala +When I EffectStep( + title = "run crazy computation", + action = s => + val pi = piComputation() + s.add("result", res) + ) + +Then assert AssertStep( + title = "check computation infos", + action = s => + val pi = s.get("result") + StepAssertion(pi, 3.14) + ) +``` + +This is rather low level and you not should write your steps like that directly inside the DSL. + +Fortunately a bunch of built-in steps and primitive building blocs are already available for you. + +Note for advance users: it is also possible to write custom wrapper steps by implementing ```WrapperStep```. + + +### HTTP service + +Sometimes you still want to perform HTTP calls inside of custom effect steps, this is where the ```http``` service comes in handy. + +In order to illustrate its usage let's take the following example, you would like to write a custom step like: + +```scala +def feature = Feature("Customer endpoint"){ + + Scenario("create customer"){ + + When I create_customer + + Then assert status.is(201) + + } +``` + +Most of the time you will create your own trait containing your custom steps and declare a self-type on ```CornichonFeature``` to be able to access the ```http``` service. + +```scala +trait MySteps { + this: CornichonFeature ⇒ + + def create_customer = EffectStep( + title = "create new customer", + effect = s ⇒ + http.Post( + url = "/customer", + payload = some_json_payload_to_define, + params = Seq.empty, + headers = Seq.empty, + extractor = RootExtractor("customer") + )(s) + ) +} + +``` + +The built-in HTTP steps available on the DSL are actually built on top of the ```http``` service which means that you benefit from all the existing placeholder resolution features. + +The ```http``` service call returns effect steps that you can reuse and can be parametrized with different response extractors to fill the session automatically. + + ## Feature options To implement a ```CornichonFeature``` it is only required to implement the ```feature``` function. However a number of useful options are available using override. From 49dffb3f34fd0e4c2e045732bbe53391d427abda Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Sat, 11 Jun 2016 20:53:57 +0200 Subject: [PATCH 35/39] fix integration tests --- README.md | 4 ++-- .../OpenMovieDatabase.scala | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e7d150c76..4b2766177 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,10 @@ An extensible Scala DSL for testing JSON HTTP APIs. Add the library dependency ``` scala -libraryDependencies += "com.github.agourlay" %% "cornichon" % "0.7.4" % "test" +libraryDependencies += "com.github.agourlay" %% "cornichon" % "0.8.0" % "test" ``` -Cornichon is currently integrated with [ScalaTest](http://www.scalatest.org/), just place your ```Feature``` inside ```src/test/scala``` and run them using ```sbt test```. +Cornichon is currently integrated with [ScalaTest](http://www.scalatest.org/), place your ```Feature``` files inside ```src/test/scala``` and run them using ```sbt test```. A ```Feature``` is a class extending ```CornichonFeature``` and implementing the required ```feature``` function. diff --git a/src/it/scala/com.github.agourlay.cornichon.examples/OpenMovieDatabase.scala b/src/it/scala/com.github.agourlay.cornichon.examples/OpenMovieDatabase.scala index e55f85c48..a2a3d6c68 100644 --- a/src/it/scala/com.github.agourlay.cornichon.examples/OpenMovieDatabase.scala +++ b/src/it/scala/com.github.agourlay.cornichon.examples/OpenMovieDatabase.scala @@ -65,7 +65,8 @@ class OpenMovieDatabase extends CornichonFeature { And assert body.ignoring("Episodes", "Response").is(""" { "Title": "Game of Thrones", - "Season": "1" + "Season": "1", + "totalSeasons" : "7" } """) @@ -74,6 +75,7 @@ class OpenMovieDatabase extends CornichonFeature { { "Title": "Game of Thrones", "Season": "1", + "totalSeasons" : "7", "Episodes": [ { "Title": "Winter Is Coming", From fae225c2e9a455df173fda3e3fe7ae22189e3bd4 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Sun, 12 Jun 2016 09:45:22 +0200 Subject: [PATCH 36/39] sangria 0.7.0 --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 62c35768c..069cbc55e 100644 --- a/build.sbt +++ b/build.sbt @@ -40,7 +40,7 @@ libraryDependencies ++= { val scalaCheckV = "1.12.5" val sangriaCirceV = "0.4.4" val circeVersion = "0.5.0-M1" - val sangriaV = "0.6.3" + val sangriaV = "0.7.0" val fansiV = "0.1.3" val akkaHttpCirce = "1.7.0" Seq( From ea51d43e3a85c985e52d9a389aff41c95d398727 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Sun, 12 Jun 2016 11:55:28 +0200 Subject: [PATCH 37/39] add WithDataInput bloc --- README.md | 19 +++ .../agourlay/cornichon/core/Engine.scala | 10 +- .../github/agourlay/cornichon/dsl/Dsl.scala | 5 + .../steps/wrapped/WithDataInputStep.scala | 59 +++++++++ .../examples/math/MathScenario.scala | 16 +++ .../steps/wrapped/WithDataInputStepSpec.scala | 115 ++++++++++++++++++ 6 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 src/main/scala/com/github/agourlay/cornichon/steps/wrapped/WithDataInputStep.scala create mode 100644 src/test/scala/com/github/agourlay/cornichon/steps/wrapped/WithDataInputStepSpec.scala diff --git a/README.md b/README.md index 4b2766177..f8343efb3 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ For more examples see the following files which are part of the test pipeline: - [Embedded Superheroes API](https://github.com/agourlay/cornichon/blob/master/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala). +- [Math Operations](https://github.com/agourlay/cornichon/blob/master/src/test/scala/com/github/agourlay/cornichon/examples/math/MathScenario.scala). ## Structure @@ -498,6 +499,24 @@ Within(maxDuration = 10 seconds) { ``` +- repeat a series of steps with different inputs specified via a datatable + +```scala +WithDataInputs( + """ + | a | b | c | + | 1 | 3 | 4 | + | 7 | 4 | 11 | + | 1 | -1 | 0 | + """ +) { + Then assert a_plus_b_equals_c +} + +def a_plus_b_equals_c = + AssertStep("sum of 'a' + 'b' = 'c'", s ⇒ SimpleStepAssertion(s.get("a").toInt + s.get("b").toInt, s.get("c").toInt)) +``` + - WithHeaders automatically sets headers for several steps useful for authenticated scenario. ```scala diff --git a/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala b/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala index 58e49170e..e67621cf5 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/Engine.scala @@ -40,15 +40,19 @@ class Engine(executionContext: ExecutionContext) { def XorToStepReport(currentStep: Step, session: Session, res: Xor[CornichonError, Session], title: String, depth: Int, show: Boolean, duration: Option[Duration] = None) = res match { case Xor.Left(e) ⇒ - val runLogs = errorLogs(title, e, depth) - val failedStep = FailedStep(currentStep, e) - FailureStepsResult(failedStep, session, runLogs) + exceptionToFailureStep(currentStep, session, title, depth, e) case Xor.Right(newSession) ⇒ val runLogs = if (show) Vector(SuccessLogInstruction(title, depth, duration)) else Vector.empty SuccessStepsResult(newSession, runLogs) } + def exceptionToFailureStep(currentStep: Step, session: Session, title: String, depth: Int, e: CornichonError): FailureStepsResult = { + val runLogs = errorLogs(title, e, depth) + val failedStep = FailedStep(currentStep, e) + FailureStepsResult(failedStep, session, runLogs) + } + def errorLogs(title: String, e: Throwable, depth: Int) = { val failureLog = FailureLogInstruction(s"$title *** FAILED ***", depth) val error = CornichonError.fromThrowable(e) diff --git a/src/main/scala/com/github/agourlay/cornichon/dsl/Dsl.scala b/src/main/scala/com/github/agourlay/cornichon/dsl/Dsl.scala index 4eba3eda0..738c836de 100644 --- a/src/main/scala/com/github/agourlay/cornichon/dsl/Dsl.scala +++ b/src/main/scala/com/github/agourlay/cornichon/dsl/Dsl.scala @@ -95,6 +95,11 @@ trait Dsl { LogDurationStep(steps, label) } + def WithDataInputs(where: String) = + BodyElementCollector[Step, Step] { steps ⇒ + WithDataInputStep(steps, where) + } + def wait(duration: FiniteDuration) = EffectStep( title = s"wait for ${duration.toMillis} millis", effect = s ⇒ { diff --git a/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/WithDataInputStep.scala b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/WithDataInputStep.scala new file mode 100644 index 000000000..cd6aea75f --- /dev/null +++ b/src/main/scala/com/github/agourlay/cornichon/steps/wrapped/WithDataInputStep.scala @@ -0,0 +1,59 @@ +package com.github.agourlay.cornichon.steps.wrapped + +import cats.data.Xor +import com.github.agourlay.cornichon.core._ +import com.github.agourlay.cornichon.json.CornichonJson +import com.github.agourlay.cornichon.util.Formats + +import scala.annotation.tailrec +import scala.concurrent.ExecutionContext + +case class WithDataInputStep(nested: Vector[Step], where: String) extends WrapperStep { + + val title = s"With data input block $where" + + def run(engine: Engine, session: Session, depth: Int)(implicit ec: ExecutionContext) = { + + @tailrec + def runInputs(inputs: List[List[(String, String)]], accLogs: Vector[LogInstruction], depth: Int): StepsResult = { + if (inputs.isEmpty) SuccessStepsResult(session, accLogs) + else { + val currentInputs = inputs.head + val filledSession = session.addValues(currentInputs) + val runInfo = InfoLogInstruction(s"Run with inputs ${Formats.displayTuples(currentInputs)}", depth) + engine.runSteps(nested, filledSession, Vector(runInfo), depth + 1) match { + case SuccessStepsResult(_, logs) ⇒ + // Logs are propogated but not the session + runInputs(inputs.tail, accLogs ++ logs, depth) + case f: FailureStepsResult ⇒ + // Prepend previous logs + f.copy(logs = accLogs ++ f.logs) + } + } + } + + Xor.catchNonFatal(CornichonJson.parseDataTable(where)) + .fold( + t ⇒ engine.exceptionToFailureStep(this, session, title, depth, CornichonError.fromThrowable(t)), + parsedTable ⇒ { + val inputs = parsedTable.map { line ⇒ + line.toList.map { case (key, json) ⇒ (key, CornichonJson.jsonStringValue(json)) } + } + + val (inputsRes, executionTime) = engine.withDuration { + runInputs(inputs, Vector.empty, depth + 1) + } + + inputsRes match { + case s: SuccessStepsResult ⇒ + val fullLogs = successTitleLog(depth) +: inputsRes.logs :+ SuccessLogInstruction(s"With data input succeeded for all inputs", depth, Some(executionTime)) + SuccessStepsResult(session, fullLogs) + case f: FailureStepsResult ⇒ + val fullLogs = failedTitleLog(depth) +: inputsRes.logs :+ FailureLogInstruction(s"With data input failed for one input", depth, Some(executionTime)) + val failedStep = FailedStep(f.failedStep.step, RetryMaxBlockReachedLimit) + FailureStepsResult(failedStep, session, fullLogs) + } + } + ) + } +} diff --git a/src/test/scala/com/github/agourlay/cornichon/examples/math/MathScenario.scala b/src/test/scala/com/github/agourlay/cornichon/examples/math/MathScenario.scala index 4f3ed13c4..2adf89db0 100644 --- a/src/test/scala/com/github/agourlay/cornichon/examples/math/MathScenario.scala +++ b/src/test/scala/com/github/agourlay/cornichon/examples/math/MathScenario.scala @@ -1,6 +1,8 @@ package com.github.agourlay.cornichon.examples.math import com.github.agourlay.cornichon.CornichonFeature +import com.github.agourlay.cornichon.core.SimpleStepAssertion +import com.github.agourlay.cornichon.steps.regular.AssertStep import scala.concurrent.duration._ @@ -50,5 +52,19 @@ class MathScenario extends CornichonFeature with MathSteps { And I show_session("pi") } + + Scenario("Addition table") { + + WithDataInputs( + """ + | a | b | c | + | 1 | 3 | 4 | + | 7 | 4 | 11 | + | 1 | -1 | 0 | + """ + ) { + Then assert AssertStep("sum of 'a' + 'b' = 'c'", s ⇒ SimpleStepAssertion(s.get("a").toInt + s.get("b").toInt, s.get("c").toInt)) + } + } } } diff --git a/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/WithDataInputStepSpec.scala b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/WithDataInputStepSpec.scala new file mode 100644 index 000000000..d9b9af087 --- /dev/null +++ b/src/test/scala/com/github/agourlay/cornichon/steps/wrapped/WithDataInputStepSpec.scala @@ -0,0 +1,115 @@ +package com.github.agourlay.cornichon.steps.wrapped + +import com.github.agourlay.cornichon.core._ +import com.github.agourlay.cornichon.steps.regular.AssertStep +import org.scalatest.{ Matchers, WordSpec } + +import scala.concurrent.ExecutionContext + +class WithDataInputStepSpec extends WordSpec with Matchers { + + val engine = new Engine(ExecutionContext.global) + + "WithDataInputStep" must { + + "fail if table is malformed" in { + val nested: Vector[Step] = Vector( + AssertStep( + "always ok", + s ⇒ SimpleStepAssertion(true, true) + ) + ) + val inputs = + """ + | a | b | c | + | 1 | 3 3 | + | 7 | 4 | 4 | + | 0 0 | 0 | + """ + + val steps = Vector( + WithDataInputStep(nested, inputs) + ) + val s = Scenario("scenario with WithDataInput", steps) + val res = engine.runScenario(Session.newSession)(s) + res.isSuccess should be(false) + } + + "fail at first failed input" in { + val nested: Vector[Step] = Vector( + AssertStep( + "always fails", + s ⇒ SimpleStepAssertion(true, false) + ) + ) + val inputs = + """ + | a | b | c | + | 1 | 3 | 3 | + | 7 | 4 | 4 | + | 0 | 0 | 0 | + """ + + val steps = Vector( + WithDataInputStep(nested, inputs) + ) + val s = Scenario("scenario with WithDataInput", steps) + engine.runScenario(Session.newSession)(s).isSuccess should be(false) + } + + "execute all steps if successful" in { + var uglyCounter = 0 + val nested: Vector[Step] = Vector( + AssertStep( + "always ok", + s ⇒ { + uglyCounter = uglyCounter + 1 + SimpleStepAssertion(true, true) + } + ) + ) + val inputs = + """ + | a | b | c | + | 1 | 3 | 3 | + | 7 | 4 | 4 | + | 0 | 0 | 0 | + """ + + val steps = Vector( + WithDataInputStep(nested, inputs) + ) + val s = Scenario("scenario with WithDataInput", steps) + val res = engine.runScenario(Session.newSession)(s) + res.isSuccess should be(true) + uglyCounter should be(3) + } + + "inject values in session" in { + val nested: Vector[Step] = Vector( + AssertStep( + "sum of 'a' + 'b' = 'c'", + s ⇒ { + val sum = s.get("a").toInt + s.get("b").toInt + SimpleStepAssertion(sum, s.get("c").toInt) + } + ) + ) + val inputs = + """ + | a | b | c | + | 1 | 3 | 4 | + | 7 | 4 | 11 | + | 1 | -1 | 0 | + """ + + val steps = Vector( + WithDataInputStep(nested, inputs) + ) + val s = Scenario("scenario with WithDataInput", steps) + val res = engine.runScenario(Session.newSession)(s) + res.isSuccess should be(true) + } + } + +} From e3ba6b67b203cd5840e282dcb4228bf69214c775 Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Sun, 12 Jun 2016 23:44:35 +0200 Subject: [PATCH 38/39] more integration examples --- .../DeckOfCard.scala | 37 +++++++ .../StarWars.scala | 104 ++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 src/it/scala/com.github.agourlay.cornichon.examples/DeckOfCard.scala create mode 100644 src/it/scala/com.github.agourlay.cornichon.examples/StarWars.scala diff --git a/src/it/scala/com.github.agourlay.cornichon.examples/DeckOfCard.scala b/src/it/scala/com.github.agourlay.cornichon.examples/DeckOfCard.scala new file mode 100644 index 000000000..dd29bd632 --- /dev/null +++ b/src/it/scala/com.github.agourlay.cornichon.examples/DeckOfCard.scala @@ -0,0 +1,37 @@ +package com.github.agourlay.cornichon.examples + +import com.github.agourlay.cornichon.CornichonFeature +import scala.concurrent.duration._ + +//see http://deckofcardsapi.com/ +class DeckOfCard extends CornichonFeature { + + override lazy val baseUrl = "http://deckofcardsapi.com/api" + + def feature = + Feature("Deck of Card API") { + + Scenario("draw any king") { + + Given I get("/deck/new/shuffle/?deck_count=1").withParams( + "deck_count" -> "1" + ) + + Then assert status.is(200) + + And I save_body_path("deck_id" -> "deck-id") + + Eventually(maxDuration = 10.seconds, interval = 10.millis) { + + When I get("/deck//draw/") + + And assert status.is(200) + + Then assert body.path("cards[0].value").is("KING") + + } + } + + // Idea: implement blackjack game :) + } +} diff --git a/src/it/scala/com.github.agourlay.cornichon.examples/StarWars.scala b/src/it/scala/com.github.agourlay.cornichon.examples/StarWars.scala new file mode 100644 index 000000000..bb0a91b51 --- /dev/null +++ b/src/it/scala/com.github.agourlay.cornichon.examples/StarWars.scala @@ -0,0 +1,104 @@ +package com.github.agourlay.cornichon.examples + +import com.github.agourlay.cornichon.CornichonFeature + +// see https://swapi.co/ +class StarWars extends CornichonFeature { + + def feature = + Feature("Star Wars API") { + + Scenario("check out Luke Skywalker") { + + When I get("http://swapi.co/api/people/1/") + + Then assert status.is(200) + + Then assert body.is( + """ + { + "name": "Luke Skywalker", + "height": "172", + "mass": "77", + "hair_color": "blond", + "skin_color": "fair", + "eye_color": "blue", + "birth_year": "19BBY", + "gender": "male", + "homeworld": "http://swapi.co/api/planets/1/", + "films": [ + "http://swapi.co/api/films/6/", + "http://swapi.co/api/films/3/", + "http://swapi.co/api/films/2/", + "http://swapi.co/api/films/1/", + "http://swapi.co/api/films/7/" + ], + "species": [ + "http://swapi.co/api/species/1/" + ], + "vehicles": [ + "http://swapi.co/api/vehicles/14/", + "http://swapi.co/api/vehicles/30/" + ], + "starships": [ + "http://swapi.co/api/starships/12/", + "http://swapi.co/api/starships/22/" + ], + "created": "2014-12-09T13:50:51.644000Z", + "edited": "2014-12-20T21:17:56.891000Z", + "url": "http://swapi.co/api/people/1/" + } + """ + ) + + And I save_body_path("homeworld" -> "homeworld-url") + + When I get("") + + Then assert body.is( + """ + { + "name" : "Tatooine", + "rotation_period" : "23", + "orbital_period" : "304", + "diameter" : "10465", + "climate" : "arid", + "gravity" : "1 standard", + "terrain" : "desert", + "surface_water" : "1", + "population" : "200000", + "residents" : [ + "http://swapi.co/api/people/1/", + "http://swapi.co/api/people/2/", + "http://swapi.co/api/people/4/", + "http://swapi.co/api/people/6/", + "http://swapi.co/api/people/7/", + "http://swapi.co/api/people/8/", + "http://swapi.co/api/people/9/", + "http://swapi.co/api/people/11/", + "http://swapi.co/api/people/43/", + "http://swapi.co/api/people/62/" + ], + "films" : [ + "http://swapi.co/api/films/5/", + "http://swapi.co/api/films/4/", + "http://swapi.co/api/films/6/", + "http://swapi.co/api/films/3/", + "http://swapi.co/api/films/1/" + ], + "created" : "2014-12-09T13:50:49.641000Z", + "edited" : "2014-12-21T20:48:04.175778Z", + "url" : "http://swapi.co/api/planets/1/" + } + """ + ) + + And I save_body_path("residents[0]" -> "first-resident") + + When I get("") + + Then assert body.path("name").is("Luke Skywalker") + } + } + +} From 7e76c6319374764d0c091201609c8d0f4102314b Mon Sep 17 00:00:00 2001 From: Arnaud Gourlay Date: Sun, 12 Jun 2016 23:46:53 +0200 Subject: [PATCH 39/39] various cleanup --- README.md | 6 ++- build.sbt | 2 + .../agourlay/cornichon/core/Resolver.scala | 10 ++-- .../agourlay/cornichon/http/HttpService.scala | 47 +++++++++---------- .../cornichon/core/ResolverSpec.scala | 44 ++++++++--------- .../cornichon/http/HttpServiceSpec.scala | 19 ++++---- 6 files changed, 65 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index f8343efb3..e8e24a987 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,13 @@ class ReadmeExample extends CornichonFeature { For more examples see the following files which are part of the test pipeline: +- [Embedded Superheroes API](https://github.com/agourlay/cornichon/blob/master/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala). + - [OpenMovieDatabase API](https://github.com/agourlay/cornichon/blob/master/src/it/scala/com.github.agourlay.cornichon.examples/OpenMovieDatabase.scala). -- [Embedded Superheroes API](https://github.com/agourlay/cornichon/blob/master/src/test/scala/com/github/agourlay/cornichon/examples/superHeroes/SuperHeroesScenario.scala). +- [DeckOfCard API](https://github.com/agourlay/cornichon/blob/master/src/it/scala/com.github.agourlay.cornichon.examples/DeckOfCard.scala). + +- [Star Wars API](https://github.com/agourlay/cornichon/blob/master/src/it/scala/com.github.agourlay.cornichon.examples/StarWars.scala). - [Math Operations](https://github.com/agourlay/cornichon/blob/master/src/test/scala/com/github/agourlay/cornichon/examples/math/MathScenario.scala). diff --git a/build.sbt b/build.sbt index 069cbc55e..9d84c2c66 100644 --- a/build.sbt +++ b/build.sbt @@ -43,6 +43,7 @@ libraryDependencies ++= { val sangriaV = "0.7.0" val fansiV = "0.1.3" val akkaHttpCirce = "1.7.0" + val catsScalaTest = "1.3.0" Seq( "com.typesafe.akka" %% "akka-http-core" % akkaHttpV ,"de.heikoseeberger" %% "akka-sse" % akkaSseV @@ -61,6 +62,7 @@ libraryDependencies ++= { //,"io.circe" %% "circe-optics" % circeVersion Remove if cursors are used instead or lenses for JsonPath. ,"de.heikoseeberger" %% "akka-http-circe" % akkaHttpCirce % "test" ,"com.typesafe.akka" %% "akka-http-experimental" % akkaHttpV % "test" + ,"com.ironcorelabs" %% "cats-scalatest" % catsScalaTest % "test" ) } diff --git a/src/main/scala/com/github/agourlay/cornichon/core/Resolver.scala b/src/main/scala/com/github/agourlay/cornichon/core/Resolver.scala index f8c78afbb..96c944b43 100644 --- a/src/main/scala/com/github/agourlay/cornichon/core/Resolver.scala +++ b/src/main/scala/com/github/agourlay/cornichon/core/Resolver.scala @@ -15,11 +15,11 @@ class Resolver(extractors: Map[String, Mapper]) { val r = new scala.util.Random() - def findPlaceholders(input: String): List[Placeholder] = + def findPlaceholders(input: String): Xor[CornichonError, List[Placeholder]] = new PlaceholderParser(input).placeholdersRule.run() match { - case Failure(e: ParseError) ⇒ List.empty - case Failure(e: Throwable) ⇒ throw new ResolverParsingError(e) - case Success(dt) ⇒ dt.toList + case Failure(e: ParseError) ⇒ right(List.empty) + case Failure(e: Throwable) ⇒ left(new ResolverParsingError(e)) + case Success(dt) ⇒ right(dt.toList) } def resolvePlaceholder(ph: Placeholder)(session: Session): Xor[CornichonError, String] = @@ -66,7 +66,7 @@ class Resolver(extractors: Map[String, Mapper]) { } yield res } - loop(findPlaceholders(input), input) + findPlaceholders(input).flatMap(loop(_, input)) } def fillPlaceholdersUnsafe(input: String)(session: Session): String = diff --git a/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala b/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala index 896ecfee2..5c0ee9606 100644 --- a/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala +++ b/src/main/scala/com/github/agourlay/cornichon/http/HttpService.scala @@ -7,12 +7,14 @@ import cats.data.Xor import cats.data.Xor.{ left, right } import com.github.agourlay.cornichon.core._ import com.github.agourlay.cornichon.http.client.HttpClient -import com.github.agourlay.cornichon.json.{ CornichonJson, JsonPath } +import com.github.agourlay.cornichon.json.JsonPath +import com.github.agourlay.cornichon.json.CornichonJson._ import io.circe.Json +import scala.annotation.tailrec import scala.concurrent.duration._ -class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpClient, resolver: Resolver) extends CornichonJson { +class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpClient, resolver: Resolver) { import com.github.agourlay.cornichon.http.HttpService._ @@ -27,9 +29,7 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC r ← resolveCommonRequestParts(url, params, headers)(s) resp ← call(json, r._1, r._2, r._3, requestTimeout) newSession ← handleResponse(resp, expectedStatus, extractor)(s) - } yield { - (resp, newSession) - } + } yield (resp, newSession) private def withoutPayload(call: WithoutPayloadCall, url: String, params: Seq[(String, String)], headers: Seq[(String, String)], extractor: ResponseExtractor, requestTimeout: FiniteDuration, expectedStatus: Option[Int])(s: Session) = @@ -37,17 +37,13 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC r ← resolveCommonRequestParts(url, params, headers)(s) resp ← call(r._1, r._2, r._3, requestTimeout) newSession ← handleResponse(resp, expectedStatus, extractor)(s) - } yield { - (resp, newSession) - } + } yield (resp, newSession) private def handleResponse(resp: CornichonHttpResponse, expectedStatus: Option[Int], extractor: ResponseExtractor)(session: Session) = for { resExpected ← expectStatusCode(resp, expectedStatus) - newSession = fillInSessionWithResponse(session, resp, extractor) - } yield { - newSession - } + newSession ← fillInSessionWithResponse(session, resp, extractor) + } yield newSession def resolveCommonRequestParts(url: String, params: Seq[(String, String)], headers: Seq[(String, String)])(s: Session) = for { @@ -56,17 +52,15 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC headersResolved ← resolver.tuplesResolver(headers, s) parsedHeaders ← parseHttpHeaders(headersResolved) extractedHeaders ← extractWithHeadersSession(s) - } yield { - (urlResolved, paramsResolved, parsedHeaders ++ extractedHeaders) - } + } yield (urlResolved, paramsResolved, parsedHeaders ++ extractedHeaders) private def expectStatusCode(httpResponse: CornichonHttpResponse, expected: Option[Int]): Xor[CornichonError, CornichonHttpResponse] = - expected.fold[Xor[CornichonError, CornichonHttpResponse]](right(httpResponse)) { e ⇒ - if (httpResponse.status == StatusCode.int2StatusCode(e)) + expected.map { expectedStatus ⇒ + if (httpResponse.status == StatusCode.int2StatusCode(expectedStatus)) right(httpResponse) else - left(StatusNonExpected(e, httpResponse)) - } + left(StatusNonExpected(expectedStatus, httpResponse)) + }.getOrElse(right(httpResponse)) def resolveParams(url: String, params: Seq[(String, String)])(session: Session): Xor[CornichonError, Seq[(String, String)]] = { val urlsParamsPart = url.dropWhile(_ != '?').drop(1) @@ -117,22 +111,23 @@ class HttpService(baseUrl: String, requestTimeout: FiniteDuration, client: HttpC LastResponseHeadersKey → encodeSessionHeaders(response) )) - def fillInSessionWithResponse(session: Session, response: CornichonHttpResponse, extractor: ResponseExtractor): Session = { + def fillInSessionWithResponse(session: Session, response: CornichonHttpResponse, extractor: ResponseExtractor): Xor[CornichonError, Session] = extractor match { case NoOpExtraction ⇒ - commonSessionExtraction(session, response) + right(commonSessionExtraction(session, response)) case RootExtractor(targetKey) ⇒ - commonSessionExtraction(session, response).addValue(targetKey, response.body) + right(commonSessionExtraction(session, response).addValue(targetKey, response.body)) case PathExtractor(path, targetKey) ⇒ - val extractedJson = JsonPath.run(path, response.body).fold(e ⇒ throw e, identity) - commonSessionExtraction(session, response).addValue(targetKey, CornichonJson.jsonStringValue(extractedJson)) + JsonPath.run(path, response.body) + .map { extractedJson ⇒ + commonSessionExtraction(session, response).addValue(targetKey, jsonStringValue(extractedJson)) + } } - } def parseHttpHeaders(headers: Seq[(String, String)]): Xor[MalformedHeadersError, Seq[HttpHeader]] = { - @scala.annotation.tailrec + @tailrec def loop(headers: Seq[(String, String)], acc: Seq[HttpHeader]): Xor[MalformedHeadersError, Seq[HttpHeader]] = { if (headers.isEmpty) right(acc) else { diff --git a/src/test/scala/com/github/agourlay/cornichon/core/ResolverSpec.scala b/src/test/scala/com/github/agourlay/cornichon/core/ResolverSpec.scala index 7598274e7..6a52a1b1d 100644 --- a/src/test/scala/com/github/agourlay/cornichon/core/ResolverSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/core/ResolverSpec.scala @@ -2,13 +2,13 @@ package com.github.agourlay.cornichon.core import java.util.UUID -import cats.data.Xor._ +import cats.scalatest.XorValues import org.scalatest.prop.PropertyChecks -import org.scalatest.{ OptionValues, Matchers, WordSpec } +import org.scalatest.{ Matchers, OptionValues, WordSpec } import org.scalacheck.Gen import com.github.agourlay.cornichon.core.SessionSpec._ -class ResolverSpec extends WordSpec with Matchers with OptionValues with PropertyChecks { +class ResolverSpec extends WordSpec with Matchers with OptionValues with PropertyChecks with XorValues { val resolver = Resolver.withoutExtractor() @@ -16,44 +16,44 @@ class ResolverSpec extends WordSpec with Matchers with OptionValues with Propert "findPlaceholders" must { "find placeholder in content solely containing a placeholder without index" in { forAll(keyGen) { key ⇒ - resolver.findPlaceholders(s"<$key>") should be(List(Placeholder(key, None))) + resolver.findPlaceholders(s"<$key>").value should be(List(Placeholder(key, None))) } } "find placeholder in content solely containing a placeholder with index" in { forAll(keyGen, indiceGen) { (key, indice) ⇒ - resolver.findPlaceholders(s"<$key[$indice]>") should be(List(Placeholder(key, Some(indice)))) + resolver.findPlaceholders(s"<$key[$indice]>").value should be(List(Placeholder(key, Some(indice)))) } } "find placeholder in content starting with whitespace and containing a placeholder" in { forAll(keyGen) { key ⇒ - resolver.findPlaceholders(s" <$key>") should be(List(Placeholder(key, None))) + resolver.findPlaceholders(s" <$key>").value should be(List(Placeholder(key, None))) } } "find placeholder in content starting with 2 whitespaces and containing a placeholder" in { forAll(keyGen) { key ⇒ - resolver.findPlaceholders(s" <$key>") should be(List(Placeholder(key, None))) + resolver.findPlaceholders(s" <$key>").value should be(List(Placeholder(key, None))) } } "find placeholder in content finishing with whitespace and containing a placeholder" in { forAll(keyGen) { key ⇒ - resolver.findPlaceholders(s"<$key> ") should be(List(Placeholder(key, None))) + resolver.findPlaceholders(s"<$key> ").value should be(List(Placeholder(key, None))) } } "find placeholder in content finishing with 2 whitespaces and containing a placeholder" in { forAll(keyGen) { key ⇒ - resolver.findPlaceholders(s"<$key> ") should be(List(Placeholder(key, None))) + resolver.findPlaceholders(s"<$key> ").value should be(List(Placeholder(key, None))) } } "find placeholder in random content containing a placeholder with index" in { forAll(keyGen, indiceGen) { (key, indice) ⇒ val content1 = Gen.alphaStr - resolver.findPlaceholders(s"$content1<$key[$indice]>$content1") should be(List(Placeholder(key, Some(indice)))) + resolver.findPlaceholders(s"$content1<$key[$indice]>$content1").value should be(List(Placeholder(key, Some(indice)))) } } } @@ -63,7 +63,7 @@ class ResolverSpec extends WordSpec with Matchers with OptionValues with Propert forAll(keyGen, valueGen) { (ph, value) ⇒ val session = Session.newSession.addValue(ph, value) val content = s"This project is <$ph>" - resolver.fillPlaceholders(content)(session) should be(right(s"This project is $value")) + resolver.fillPlaceholders(content)(session).value should be(s"This project is $value") } } @@ -71,38 +71,38 @@ class ResolverSpec extends WordSpec with Matchers with OptionValues with Propert forAll(keyGen, valueGen) { (ph, value) ⇒ val session = Session.newSession.addValue(ph, value) val content = s"This project is >$ph<" - resolver.fillPlaceholders(content)(session) should be(right(s"This project is >$ph<")) + resolver.fillPlaceholders(content)(session).value should be(s"This project is >$ph<") } } "not be confused if key contains empty string" in { val session = Session.newSession.addValue("project-name", "cornichon") val content = "This project is named " - resolver.fillPlaceholders(content)(session) should be(right("This project is named ")) + resolver.fillPlaceholders(content)(session).value should be("This project is named ") } "not be confused by unclosed markup used in a math context" in { val session = Session.newSession.addValue("pi", "3.14") val content = "3.15 > " - resolver.fillPlaceholders(content)(session) should be(right("3.15 > 3.14")) + resolver.fillPlaceholders(content)(session).value should be("3.15 > 3.14") } "return ResolverError if placeholder not found" in { val session = Session.newSession.addValue("project-name", "cornichon") val content = "This project is named " - resolver.fillPlaceholders(content)(session) should be(left(KeyNotFoundInSession("project-new-name", session))) + resolver.fillPlaceholders(content)(session).leftValue should be(KeyNotFoundInSession("project-new-name", session)) } "resolve two placeholders" in { val session = Session.newSession.addValues(Seq("project-name" → "cornichon", "taste" → "tasty")) val content = "This project is named and is super " - resolver.fillPlaceholders(content)(session) should be(right("This project is named cornichon and is super tasty")) + resolver.fillPlaceholders(content)(session).value should be("This project is named cornichon and is super tasty") } "return ResolverError for the first placeholder not found" in { val session = Session.newSession.addValues(Seq("project-name" → "cornichon", "taste" → "tasty")) val content = "This project is named and is super " - resolver.fillPlaceholders(content)(session) should be(left(KeyNotFoundInSession("new-taste", session))) + resolver.fillPlaceholders(content)(session).leftValue should be(KeyNotFoundInSession("new-taste", session)) } "generate random uuid if " in { @@ -116,7 +116,7 @@ class ResolverSpec extends WordSpec with Matchers with OptionValues with Propert forAll(keyGen, valueGen, valueGen) { (key, firstValue, secondValue) ⇒ val s = Session.newSession.addValue(key, firstValue).addValue(key, secondValue) val content = s"<$key[0]>" - resolver.fillPlaceholders(content)(s) should be(right(firstValue)) + resolver.fillPlaceholders(content)(s).value should be(firstValue) } } @@ -124,7 +124,7 @@ class ResolverSpec extends WordSpec with Matchers with OptionValues with Propert forAll(keyGen, valueGen, valueGen) { (key, firstValue, secondValue) ⇒ val s = Session.newSession.addValue(key, firstValue).addValue(key, secondValue) val content = s"<$key[1]>" - resolver.fillPlaceholders(content)(s) should be(right(secondValue)) + resolver.fillPlaceholders(content)(s).value should be(secondValue) } } @@ -132,21 +132,21 @@ class ResolverSpec extends WordSpec with Matchers with OptionValues with Propert val mapper = Map("letter-from-gen" → GenMapper(Gen.oneOf(List("a")))) val res = new Resolver(mapper) val content = s"" - res.fillPlaceholders(content)(Session.newSession) should be(right("a")) + res.fillPlaceholders(content)(Session.newSession).value should be("a") } "fail if GenMapper does not return a value" in { val mapper = Map("letter-from-gen" → GenMapper(Gen.oneOf(List()))) val res = new Resolver(mapper) val content = s"" - res.fillPlaceholders(content)(Session.newSession) should be(left(GeneratorError(""))) + res.fillPlaceholders(content)(Session.newSession).leftValue should be(GeneratorError("")) } "fail with clear error message if key is defined in both Session and Extractors" in { val extractor = JsonMapper("customer", "id") val resolverWithExt = new Resolver(Map("customer-id" → extractor)) val s = Session.newSession.addValue("customer-id", "12345") - resolverWithExt.fillPlaceholders("")(s) should be(left(AmbiguousKeyDefinition("customer-id"))) + resolverWithExt.fillPlaceholders("")(s).leftValue should be(AmbiguousKeyDefinition("customer-id")) } } } diff --git a/src/test/scala/com/github/agourlay/cornichon/http/HttpServiceSpec.scala b/src/test/scala/com/github/agourlay/cornichon/http/HttpServiceSpec.scala index 18664d448..e2f4cac5d 100644 --- a/src/test/scala/com/github/agourlay/cornichon/http/HttpServiceSpec.scala +++ b/src/test/scala/com/github/agourlay/cornichon/http/HttpServiceSpec.scala @@ -4,6 +4,7 @@ import akka.actor.ActorSystem import akka.http.scaladsl.model.StatusCodes import akka.stream.ActorMaterializer import cats.data.Xor.right +import cats.scalatest.XorValues import com.github.agourlay.cornichon.core.{ Resolver, Session } import com.github.agourlay.cornichon.http.client.AkkaHttpClient import org.scalatest.{ BeforeAndAfterAll, Matchers, WordSpec } @@ -11,7 +12,7 @@ import org.scalatest.{ BeforeAndAfterAll, Matchers, WordSpec } import scala.concurrent.duration._ import scala.concurrent.ExecutionContext.Implicits.global -class HttpServiceSpec extends WordSpec with Matchers with BeforeAndAfterAll { +class HttpServiceSpec extends WordSpec with Matchers with BeforeAndAfterAll with XorValues { implicit val system = ActorSystem("akka-http-client") implicit val mat = ActorMaterializer() @@ -29,17 +30,17 @@ class HttpServiceSpec extends WordSpec with Matchers with BeforeAndAfterAll { val s = Session.newSession val resp = CornichonHttpResponse(StatusCodes.OK, Nil, "hello world") val filledSession = service.fillInSessionWithResponse(s, resp, NoOpExtraction) - filledSession.get("last-response-status") should be("200") - filledSession.get("last-response-body") should be("hello world") + filledSession.value.get("last-response-status") should be("200") + filledSession.value.get("last-response-body") should be("hello world") } "extract content with RootResponseExtraction" in { val s = Session.newSession val resp = CornichonHttpResponse(StatusCodes.OK, Nil, "hello world") val filledSession = service.fillInSessionWithResponse(s, resp, RootExtractor("copy-body")) - filledSession.get("last-response-status") should be("200") - filledSession.get("last-response-body") should be("hello world") - filledSession.get("copy-body") should be("hello world") + filledSession.value.get("last-response-status") should be("200") + filledSession.value.get("last-response-body") should be("hello world") + filledSession.value.get("copy-body") should be("hello world") } "extract content with PathResponseExtraction" in { @@ -51,15 +52,15 @@ class HttpServiceSpec extends WordSpec with Matchers with BeforeAndAfterAll { } """) val filledSession = service.fillInSessionWithResponse(s, resp, PathExtractor("name", "part-of-body")) - filledSession.get("last-response-status") should be("200") - filledSession.get("last-response-body") should be( + filledSession.value.get("last-response-status") should be("200") + filledSession.value.get("last-response-body") should be( """ { "name" : "batman" } """ ) - filledSession.get("part-of-body") should be("batman") + filledSession.value.get("part-of-body") should be("batman") } }