From a714120d4216b81d5a1226d538865b4c07dcecdf Mon Sep 17 00:00:00 2001 From: Aleksei Shashev Date: Thu, 21 Sep 2023 00:53:45 +0300 Subject: [PATCH] Add examples --- .gitignore | 1 + backend/build.sbt | 57 ++ backend/compose-test.yml.jvm.tmpl | 30 + backend/compose-test.yml.native.tmpl | 28 + .../tcb/mockingbird/edsl/ExampleSet.scala | 220 ++++++ .../interpreter/AsyncScalaTestSuite.scala | 185 +++++ .../edsl/interpreter/MarkdownGenerator.scala | 148 ++++ .../edsl/interpreter/package.scala | 26 + .../tcb/mockingbird/edsl/model/Check.scala | 105 +++ .../edsl/model/ExampleDescription.scala | 5 + .../tcb/mockingbird/edsl/model/Step.scala | 18 + .../tcb/mockingbird/edsl/model/http.scala | 29 + .../tcb/mockingbird/edsl/model/package.scala | 7 + .../interpreter/AsyncScalaTestSuiteTest.scala | 152 +++++ .../AsyncScalaTestSuiteWholeTest.scala | 98 +++ .../interpreter/MarkdownGeneratorSuite.scala | 175 +++++ .../tcb/mockingbird/examples/CatsFacts.scala | 37 + .../mockingbird/examples/BasicHttpStub.scala | 340 ++++++++++ .../examples/HttpStubWithState.scala | 594 +++++++++++++++++ .../tcb/mockingbird/examples/Main.scala | 65 ++ .../examples/src/test/resources/logback.xml | 24 + .../tcb/mockingbird/examples/BaseSuite.scala | 51 ++ .../examples/BasicHttpStubSuite.scala | 6 + .../examples/HttpStubWithStateSuite.scala | 6 + backend/project/Dependencies.scala | 17 +- backend/secrets-for-test.conf | 17 + examples/basic_http_stub.md | 350 ++++++++++ examples/http_stub_with_state.md | 631 ++++++++++++++++++ 28 files changed, 3419 insertions(+), 3 deletions(-) create mode 100644 backend/compose-test.yml.jvm.tmpl create mode 100644 backend/compose-test.yml.native.tmpl create mode 100644 backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/ExampleSet.scala create mode 100644 backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuite.scala create mode 100644 backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/MarkdownGenerator.scala create mode 100644 backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/package.scala create mode 100644 backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/Check.scala create mode 100644 backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/ExampleDescription.scala create mode 100644 backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/Step.scala create mode 100644 backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/http.scala create mode 100644 backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/package.scala create mode 100644 backend/edsl/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuiteTest.scala create mode 100644 backend/edsl/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuiteWholeTest.scala create mode 100644 backend/edsl/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/MarkdownGeneratorSuite.scala create mode 100644 backend/edsl/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/CatsFacts.scala create mode 100644 backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/examples/BasicHttpStub.scala create mode 100644 backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/examples/HttpStubWithState.scala create mode 100644 backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/examples/Main.scala create mode 100644 backend/examples/src/test/resources/logback.xml create mode 100644 backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/BaseSuite.scala create mode 100644 backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/BasicHttpStubSuite.scala create mode 100644 backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/HttpStubWithStateSuite.scala create mode 100644 backend/secrets-for-test.conf create mode 100644 examples/basic_http_stub.md create mode 100644 examples/http_stub_with_state.md diff --git a/.gitignore b/.gitignore index 5187abd3..3cfa2860 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ node_modules dist .fleet secrets.conf +/backend/compose-test.yml diff --git a/backend/build.sbt b/backend/build.sbt index f180f9a9..f9f2b3d7 100644 --- a/backend/build.sbt +++ b/backend/build.sbt @@ -165,6 +165,62 @@ lazy val `mockingbird-native` = (project in file("mockingbird-native")) ) ) +val edsl = (project in file("edsl")) + .dependsOn(utils, circeUtils) + .settings(Settings.common) + .settings( + libraryDependencies ++= Seq( + Dependencies.cats, + Dependencies.tofu, + Dependencies.mouse, + Dependencies.enumeratum, + Dependencies.scalatestMain, + Dependencies.scalamock, + Dependencies.refined, + ).flatten, + libraryDependencies ++= Seq( + "com.softwaremill.sttp.client3" %% "armeria-backend-zio" % Versions.sttp, + "com.softwaremill.sttp.client3" %% "circe" % Versions.sttp, + "pl.muninn" %% "scala-md-tag" % "0.2.3", + ), + ) + .settings( + Compile / doc / sources := (file("edsl/src/main") ** "*.scala").get, + Compile / doc / scalacOptions ++= Seq("-groups", "-skip-packages", "sttp") + ) + +val examples = (project in file("examples")) + .enablePlugins( + JavaAppPackaging + ) + .dependsOn(edsl) + .settings(Settings.common) + .settings( + libraryDependencies ++= Seq( + Dependencies.scalatest, + Dependencies.scalamock, + Dependencies.testContainers, + ).flatten, + libraryDependencies ++= Seq( + "dev.zio" %% "zio-cli" % "0.5.0", + ), + Test / parallelExecution := false, + ) + .settings( + addCommandAlias( + "fixCheck", + "scalafixAll --check; scalafmtCheck" + ), + addCommandAlias( + "lintAll", + "scalafixAll; scalafmtAll" + ), + addCommandAlias( + "simulacrum", + "scalafixEnable;scalafix AddSerializable;scalafix AddImplicitNotFound;scalafix TypeClassSupport;" + ) + ) + val root = (project in file(".")) .aggregate( utils, @@ -173,6 +229,7 @@ val root = (project in file(".")) mockingbird, `mockingbird-api`, `mockingbird-native`, + `edsl` ) .settings( run / aggregate := false, diff --git a/backend/compose-test.yml.jvm.tmpl b/backend/compose-test.yml.jvm.tmpl new file mode 100644 index 00000000..1657a567 --- /dev/null +++ b/backend/compose-test.yml.jvm.tmpl @@ -0,0 +1,30 @@ +services: + mongo: + image: mongo + environment: + - MONGO_INITDB_DATABASE=mockingbird + healthcheck: + test: echo 'db.runCommand("ping").ok' | mongosh mongo:27017/test --quiet + interval: 10s + timeout: 10s + retries: 5 + start_period: 10s + networks: + - app-tier + + mockingbird: + image: "${MOCKINGBIRD_IMAGE}" + depends_on: + mongo: + condition: service_healthy + volumes: + # Read the docs about secrets + - ./secrets-for-test.conf:/opt/mockingbird/conf/secrets.conf + environment: + - JAVA_OPTS=-server -Xms256m -Xmx256m -XX:MaxDirectMemorySize=128m -Dconfig.resource=qa.conf -Dlog.level=DEBUG -Dlog4j.formatMsgNoLookups=true + networks: + - app-tier + +networks: + app-tier: + driver: bridge diff --git a/backend/compose-test.yml.native.tmpl b/backend/compose-test.yml.native.tmpl new file mode 100644 index 00000000..ea25401c --- /dev/null +++ b/backend/compose-test.yml.native.tmpl @@ -0,0 +1,28 @@ +services: + mongo: + image: mongo + environment: + - MONGO_INITDB_DATABASE=mockingbird + healthcheck: + test: echo 'db.runCommand("ping").ok' | mongosh mongo:27017/test --quiet + interval: 10s + timeout: 10s + retries: 5 + start_period: 10s + networks: + - app-tier + + mockingbird: + image: "${MOCKINGBIRD_IMAGE}" + depends_on: + mongo: + condition: service_healthy + volumes: + # Read the docs about secrets + - ./secrets-for-test.conf:/opt/mockingbird-native/conf/secrets.conf + command: -server -Xms256m -Xmx256m -XX:MaxDirectMemorySize=128m -Dconfig.file=/opt/mockingbird-native/qa.conf -Dlog.level=DEBUG -Dlog4j.formatMsgNoLookups=true + networks: + - app-tier +networks: + app-tier: + driver: bridge diff --git a/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/ExampleSet.scala b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/ExampleSet.scala new file mode 100644 index 00000000..e0fcdbcd --- /dev/null +++ b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/ExampleSet.scala @@ -0,0 +1,220 @@ +package ru.tinkoff.tcb.mockingbird.edsl + +import cats.free.Free.liftF +import org.scalactic.source + +import ru.tinkoff.tcb.mockingbird.edsl.model.* + +/** + * ==Описание набора примеров== + * + * `ExampleSet` предоставляет DSL для описания примеров взаимодействия с Mockingbird со стороны внешнего + * приложения/пользователя через его API. Описанные примеры потом можно в Markdown описание последовательности действий + * с примерами HTTP запросов и ответов на них или сгенерировать тесты для scalatest. За это отвечают интерпретаторы DSL + * [[ru.tinkoff.tcb.mockingbird.edsl.interpreter.MarkdownGenerator MarkdownGenerator]] и + * [[ru.tinkoff.tcb.mockingbird.edsl.interpreter.AsyncScalaTestSuite AsyncScalaTestSuite]] соответственно. + * + * Описание набора примеров может выглядеть так: + * + * {{{ + * package ru.tinkoff.tcb.mockingbird.examples + * + * import ru.tinkoff.tcb.mockingbird.edsl.ExampleSet + * import ru.tinkoff.tcb.mockingbird.edsl.model.* + * import ru.tinkoff.tcb.mockingbird.edsl.model.Check.* + * import ru.tinkoff.tcb.mockingbird.edsl.model.HttpMethod.* + * import ru.tinkoff.tcb.mockingbird.edsl.model.ValueMatcher.syntax.* + * + * class CatsFacts[HttpResponseR] extends ExampleSet[HttpResponseR] { + * + * override val name = "Примеры использования ExampleSet" + * + * example("Получение случайного факта о котиках")( + * for { + * _ <- describe("Отправить GET запрос") + * resp <- sendHttp( + * method = Get, + * path = "/fact", + * headers = Seq("X-CSRF-TOKEN" -> "unEENxJqSLS02rji2GjcKzNLc0C0ySlWih9hSxwn") + * ) + * _ <- describe("Ответ содержит случайный факт полученный с сервера") + * _ <- checkHttp( + * resp, + * HttpResponseExpected( + * code = Some(CheckInteger(200)), + * body = Some( + * CheckJsonObject( + * "fact" -> CheckJsonString("There are approximately 100 breeds of cat.".sample), + * "length" -> CheckJsonNumber(42.sample) + * ) + * ), + * headers = Seq("Content-Type" -> CheckString("application/json")) + * ) + * ) + * } yield () + * ) + * } + * }}} + * + * Дженерик параметр `HttpResponseR` нужен так результат выполнения HTTP запроса зависит от интерпретатора DSL. + * + * Переменная `name` - общий заголовок для примеров внутри набора, при генерации Markdown файла будет добавлен в самое + * начало как заголовок первого уровня. + * + * Метод `example` позволяет добавить пример к набору. Вначале указывается название примера, как первый набор + * аргументов. При генерации тестов это будет именем теста, а при генерации Markdown будет добавлено как заголовок + * второго уровня, затем описывается сам пример. Последовательность действий описывается при помощи монады + * [[ru.tinkoff.tcb.mockingbird.edsl.model.Example Example]]. + * + * `ExampleSet` предоставляет следующие действия: + * - [[describe]] - добавить текстовое описание. + * - [[sendHttp]] - исполнить HTTP запрос с указанными параметрами, возвращает результат запроса. + * - [[checkHttp]] - проверить, что результат запроса отвечает указанным ожиданиям, возвращает извлеченные из ответа + * данные на основании проверок. ''Если предполагается использовать какие-то части ответа по ходу описания примера, + * то необходимо для них задать ожидания, иначе они будут отсутствовать в возвращаемом объекте.'' + * + * Для описания ожиданий используются проверки [[model.Check$]]. Некоторые проверки принимают как параметр + * [[model.ValueMatcher ValueMatcher]]. Данный трейт тип представлен двумя реализациями + * [[model.ValueMatcher.AnyValue AnyValue]] и [[model.ValueMatcher.FixedValue FixedValue]]. Первая описывает + * произвольное значение определенного типа, т.е. проверки значения не производится. Вторая задает конкретное ожидаемое + * значение. + * + * Для упрощения создания значений типа [[model.ValueMatcher ValueMatcher]] добавлены имплиситы в объекте + * [[model.ValueMatcher.syntax ValueMatcher.syntax]]. Они добавляют неявную конвертацию значений в тип + * [[model.ValueMatcher.FixedValue FixedValue]], а так же методы `sample` и `fixed` для создания + * [[model.ValueMatcher.AnyValue AnyValue]] и [[model.ValueMatcher.FixedValue FixedValue]] соответственно. Благодаря + * этому можно писать: + * {{{ + * CheckString("some sample".sample) // вместо CheckString(AnyValue("some sample")) + * CheckString("some fixed string") // вместо CheckString(FixedValue("some fixed string")) + * }}} + * + * ==Генерации markdown документа из набора примеров== + * + * {{{ + * package ru.tinkoff.tcb.mockingbird.examples + * + * import sttp.client3.* + * + * import ru.tinkoff.tcb.mockingbird.edsl.interpreter.MarkdownGenerator + * + * object CatsFactsMd { + * def main(args: Array[String]): Unit = { + * val mdg = MarkdownGenerator(baseUri = uri"https://catfact.ninja") + * val set = new CatsFacts[MarkdownGenerator.HttpResponseR]() + * println(mdg.generate(set)) + * } + * } + * }}} + * + * Здесь создается интерпретатор [[ru.tinkoff.tcb.mockingbird.edsl.interpreter.MarkdownGenerator MarkdownGenerator]] для + * генерации markdown документа из инстанса `ExampleSet`. Как параметр, конструктору передается хост со схемой который + * будет подставлен в качестве примера в документ. + * + * Как упоминалось ранее, тип ответа от HTTP сервера зависит от интерпретатора DSL, поэтому при создании `CatsFacts` + * параметром передается тип `MarkdownGenerator.HttpResponseR`. + * + * ==Генерация тестов из набора примеров== + * {{{ + * package ru.tinkoff.tcb.mockingbird.examples + * + * import sttp.client3.* + * + * import ru.tinkoff.tcb.mockingbird.edsl.interpreter.AsyncScalaTestSuite + * + * class CatsFactsSuite extends AsyncScalaTestSuite { + * override val baseUri = uri"https://catfact.ninja" + * val set = new CatsFacts[HttpResponseR]() + * generateTests(set) + * } + * }}} + * + * Для генерации тестов нужно создать класс и унаследовать его от + * [[ru.tinkoff.tcb.mockingbird.edsl.interpreter.AsyncScalaTestSuite AsyncScalaTestSuite]]. После чего в переопределить + * значение `baseUri` и в конструкторе вызвать метод `generateTests` передав в него набор примеров. В качестве дженерик + * параметра для типа HTTP ответа, в создаваемый инстанс набора примеров надо передать тип + * [[ru.tinkoff.tcb.mockingbird.edsl.interpreter.AsyncScalaTestSuite.HttpResponseR AsyncScalaTestSuite.HttpResponseR]] + * + * Пример запуска тестов: + * {{{ + * [info] CatsFactsSuite: + * [info] - Получение случайного факта о котиках + * [info] + Отправить GET запрос + * [info] + Ответ содержит случайный факт полученный с сервера + * [info] Run completed in 563 milliseconds. + * [info] Total number of tests run: 1 + * [info] Suites: completed 1, aborted 0 + * [info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0 + * [info] All tests passed. + * }}} + */ +trait ExampleSet[HttpResponseR] { + private var examples_ : Vector[ExampleDescription] = Vector.empty + + final private[edsl] def examples: Vector[ExampleDescription] = examples_ + + /** + * Заглавие набора примеров. + */ + def name: String + + final protected def example(name: String)(body: Example[Any])(implicit pos: source.Position): Unit = + examples_ = examples_ :+ ExampleDescription(name, body, pos) + + /** + * Выводит сообщение при помощи `info` при генерации тестов или добавляет текстовый блок при генерации Markdown. + * @param text + * текст сообщения + */ + final def describe(text: String)(implicit pos: source.Position): Example[Unit] = + liftF[Step, Unit](Describe(text, pos)) + + /** + * В тестах, выполняет HTTP запрос с указанными параметрами или добавляет в Markdown пример запроса, который можно + * исполнить командой `curl`. + * + * @param method + * используемый HTTP метод. + * @param path + * путь до ресурса без схемы и хоста. + * @param body + * тело запроса как текст. + * @param headers + * заголовки, который будут переданы вместе с запросом. + * @param query + * URL параметры запроса + * @return + * возвращает объект представляющий собой результат исполнения запроса, конкретный тип зависит от интерпретатора + * DSL. Использовать возвращаемое значение можно только передав в метод [[checkHttp]]. + */ + final def sendHttp( + method: HttpMethod, + path: String, + body: Option[String] = None, + headers: Seq[(String, String)] = Seq.empty, + query: Seq[(String, String)] = Seq.empty, + )(implicit + pos: source.Position + ): Example[HttpResponseR] = + liftF[Step, HttpResponseR](SendHttp[HttpResponseR](HttpRequest(method, path, body, headers, query), pos)) + + /** + * В тестах, проверяет, что полученный HTTP ответ соответствует ожиданиям. При генерации Markdown вставляет ожидаемый + * ответ опираясь на указанные ожидания. Если никакие ожидания не указана, то ничего добавлено не будет. + * + * @param response + * результат исполнения [[sendHttp]], тип зависит от интерпретатора DSL. + * @param expects + * ожидания предъявляемые к результату HTTP запроса. Ожидания касаются кода ответа, тела запроса и заголовков + * полеченных от сервера. + * @return + * возвращает разобранный ответ от сервера. При генерации Markdown, так как реального ответа от сервера нет, то + * формирует ответ на основании переданных ожиданий от ответа. В Markdown добавляется информация только от том, для + * чего была указана проверка. + */ + final def checkHttp(response: HttpResponseR, expects: HttpResponseExpected)(implicit + pos: source.Position + ): Example[HttpResponse] = + liftF[Step, HttpResponse](CheckHttp(response, expects, pos)) + +} diff --git a/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuite.scala b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuite.scala new file mode 100644 index 00000000..34c3ea52 --- /dev/null +++ b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuite.scala @@ -0,0 +1,185 @@ +package ru.tinkoff.tcb.mockingbird.edsl.interpreter + +import scala.concurrent.Future + +import cats.arrow.FunctionK +import cats.data.* +import cats.data.Validated.Invalid +import cats.data.Validated.Valid +import io.circe.Json +import mouse.boolean.* +import org.scalactic.source +import org.scalatest.Assertion +import org.scalatest.funsuite.AsyncFunSuiteLike +import sttp.capabilities.WebSockets +import sttp.client3.* +import sttp.model.Uri + +import ru.tinkoff.tcb.mockingbird.edsl.ExampleSet +import ru.tinkoff.tcb.mockingbird.edsl.model.* +import ru.tinkoff.tcb.mockingbird.edsl.model.Check.* +import ru.tinkoff.tcb.mockingbird.edsl.model.ValueMatcher.* + +/** + * Базовый трейт для генерации набора тестов по набору примеров + * [[ru.tinkoff.tcb.mockingbird.edsl.ExampleSet ExampleSet]]. + * + * Трейт наследуется от `AsyncFunSuiteLike` из фреймоврка [[https://www.scalatest.org/ ScalaTest]], поэтому внутри можно + * как дописать дополнительные тесты, так и использовать + * [[https://www.scalatest.org/user_guide/sharing_fixtures#beforeAndAfter BeforeAndAfter]] и/или + * [[https://www.scalatest.org/user_guide/sharing_fixtures#composingFixtures BeforeAndAfterEach]] для управления + * поднятием необходимого для исполнения тестов окружения, в том числе используя + * [[https://github.com/testcontainers/testcontainers-scala testcontainers-scala]]. + */ +trait AsyncScalaTestSuite extends AsyncFunSuiteLike { + + type HttpResponseR = sttp.client3.Response[String] + + private val sttpbackend_ = HttpClientFutureBackend() + + private[interpreter] def sttpbackend: SttpBackend[Future, WebSockets] = sttpbackend_ + + /** + * URI относительно которого будут разрешаться пути используемые в примерах + */ + def baseUri: Uri + + /** + * Сгенерировать тесты из набора примеров. + */ + protected def generateTests(es: ExampleSet[HttpResponseR]): Unit = + es.examples.foreach { desc => + test(desc.name)(desc.steps.foldMap(stepsBuilder).as(succeed))(desc.pos) + } + + private[interpreter] def stepsBuilder: FunctionK[Step, Future] = new (Step ~> Future) { + override def apply[A](fa: Step[A]): Future[A] = + fa match { + case Describe(text, pos) => Future(info(text)(pos)) + case SendHttp(request, pos) => + buildRequest(baseUri, request).send(sttpbackend).map(_.asInstanceOf[A]) + case CheckHttp(response, expects, pos) => + Future { + val resp = response.asInstanceOf[HttpResponseR] + + expects.code.foreach(c => + check(resp.code.code, Checker(c), "response HTTP code", "Response body:", resp.body)(pos) + ) + expects.body.map(Checker(_)).foreach(c => check(resp.body, c, "response body")(pos)) + check( + resp.headers.map(h => h.name.toLowerCase() -> h.value).toMap, + Checker(expects.headers.map(kv => kv._1.toLowerCase() -> kv._2).toMap), + "response headers", + )(pos) + + val res = HttpResponse( + resp.code.code, + resp.body.nonEmpty.option(resp.body), + resp.headers.map(h => h.name -> h.value) + ) + res.asInstanceOf[A] + } + } + } + + private def check[T]( + value: T, + validation: T => ValidatedNel[String, Unit], + what: String, + clue: String* + )(pos: source.Position): Assertion = + validation(value) match { + case Invalid(errs) => + fail(s"""Checking $what failed with errors: + |${errs.toList.mkString(" - ", "\n - ", "")} + |Value: + |${value} + |${clue.mkString("\n")} + |""".stripMargin)(pos) + case Valid(_) => succeed + } + + object Checker { + def apply(checks: Map[String, Check])(vs: Map[String, Any]): ValidatedNel[String, Unit] = + checks.toSeq + .traverse { case (k, c) => + vs.get(k).map(v => Checker(c)(v)).getOrElse(s"key '$k' wasn't found".invalidNel) + } + .as(()) + + def apply(check: Check)(value: Any): ValidatedNel[String, Unit] = + check match { + case CheckAny(_) => ().validNel + case c: CheckJson => checkJson(value, c) + case CheckString(matcher) => + value match { + case s: String => checkValue(matcher, s).leftMap(NonEmptyList.one) + case _ => s"expect string type, but got ${value.getClass().getTypeName()}".invalidNel + } + case CheckInteger(matcher) => + value match { + case v: Int => checkValue(matcher, v.toLong).leftMap(NonEmptyList.one) + case v: Long => checkValue(matcher, v).leftMap(NonEmptyList.one) + case _ => s"expect integer type, but got ${value.getClass().getTypeName()}".invalidNel + } + } + + private def checkJson(value: Any, check: CheckJson): ValidatedNel[String, Unit] = + value match { + case j: Json => + checkJson(j, check, Seq.empty) + case s: String => + io.circe.parser.parse(s) match { + case Left(err) => s"JSON parsing failed: $err".invalidNel + case Right(v) => + checkJson(v, check, Seq.empty) + } + case _ => fail(s"CheckJson: got ${value.getClass().getTypeName()}:\nWhat:\n${value}") + } + + private def checkJson(value: Json, check: CheckJson, path: Seq[String]): ValidatedNel[String, Unit] = + check match { + case CheckJsonAny(_) => ().validNel + case CheckJsonNull if value.isNull => ().validNel + case CheckJsonNull => s"field ${path.mkString(".")} should be Null".invalidNel + case CheckJsonArray(cs*) => + value.asArray match { + case Some(arr) if arr.isEmpty && cs.nonEmpty => + s"field ${path.mkString(".")} should be non empty".invalidNel + case Some(arr) => + (arr zip cs).zipWithIndex.traverse { case ((j, c), n) => checkJson(j, c, path :+ s"[$n]") }.as(()) + case None => s"field ${path.mkString(".")} should be an array".invalidNel + } + case CheckJsonString(matcher) => + value.asString + .map(checkValue(matcher, _)) + .getOrElse(s"field ${path.mkString(".")} should be a string".invalid) + .leftMap(NonEmptyList.one) + case CheckJsonNumber(matcher) => + value.asNumber + .map(_.toDouble) + .map(checkValue(matcher, _)) + .getOrElse(s"field ${path.mkString(".")} should be a number".invalid) + .leftMap(NonEmptyList.one) + case CheckJsonObject(fields*) => + value.asObject + .map { o => + fields + .traverse { case (n, c) => + o(n) + .map(j => checkJson(j, c, path :+ n)) + .getOrElse(s"field ${(path :+ n).mkString(".")} doesn't exist".invalidNel) + } + .as(()) + } + .getOrElse(s"field ${path.mkString(".")} should be an object".invalidNel) + } + + private def checkValue[T](matcher: ValueMatcher[T], value: T): Validated[String, Unit] = + matcher match { + case AnyValue(_) => ().valid + case FixedValue(`value`) => ().valid + case FixedValue(expected) => s"'$value' didn't equal '$expected'".invalid + } + } +} diff --git a/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/MarkdownGenerator.scala b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/MarkdownGenerator.scala new file mode 100644 index 00000000..e1a8fb53 --- /dev/null +++ b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/MarkdownGenerator.scala @@ -0,0 +1,148 @@ +package ru.tinkoff.tcb.mockingbird.edsl.interpreter + +import cats.arrow.FunctionK +import cats.data.Writer +import io.circe.Json +import mouse.boolean.* +import pl.muninn.scalamdtag.* +import pl.muninn.scalamdtag.tags.Markdown +import sttp.model.Uri + +import ru.tinkoff.tcb.mockingbird.edsl.ExampleSet +import ru.tinkoff.tcb.mockingbird.edsl.interpreter.buildRequest +import ru.tinkoff.tcb.mockingbird.edsl.model.* +import ru.tinkoff.tcb.mockingbird.edsl.model.Check.* +import ru.tinkoff.tcb.mockingbird.edsl.model.HttpMethod.Delete +import ru.tinkoff.tcb.mockingbird.edsl.model.HttpMethod.Get +import ru.tinkoff.tcb.mockingbird.edsl.model.HttpMethod.Post + +object MarkdownGenerator { + type HttpResponseR = {} + private[interpreter] val httpResponseR: HttpResponseR = new {} + + def apply(baseUri: Uri): MarkdownGenerator = + new MarkdownGenerator(baseUri) + + private object implicits { + implicit val httpMethodShow: Show[HttpMethod] = new Show[HttpMethod] { + override def show(m: HttpMethod): String = + m match { + case Delete => "DELETE" + case Get => "GET" + case Post => "POST" + } + } + + implicit def valueMatcherShow[T: Show]: Show[ValueMatcher[T]] = + (vm: ValueMatcher[T]) => + vm match { + case ValueMatcher.AnyValue(example) => example.show + case ValueMatcher.FixedValue(value) => value.show + } + + implicit class ValueMatcherOps[T](private val vm: ValueMatcher[T]) extends AnyVal { + def value: T = vm match { + case ValueMatcher.AnyValue(example) => example + case ValueMatcher.FixedValue(value) => value + } + } + + implicit val checkShow: Show[Check] = (check: Check) => + check match { + case CheckAny(example) => example + case CheckInteger(matcher) => matcher.show + case CheckString(matcher) => matcher.show + case cj: CheckJson => buildJson(cj).spaces2 + } + + def buildJson(cj: CheckJson): Json = + cj match { + case CheckJsonAny(example) => example + case CheckJsonArray(items*) => Json.arr(items.map(buildJson): _*) + case CheckJsonNull => Json.Null + case CheckJsonNumber(matcher) => Json.fromDoubleOrNull(matcher.value) + case CheckJsonObject(fields*) => Json.obj(fields.map { case (n, v) => n -> buildJson(v) }: _*) + case CheckJsonString(matcher) => Json.fromString(matcher.value) + } + } +} + +/** + * Интерпретатор DSL создающий markdown документ с описанием примера. + * + * @param baseUri + * URI относительно которого будут разрешаться пути используемые в примерах + */ +final class MarkdownGenerator(baseUri: Uri) { + import MarkdownGenerator.HttpResponseR + import MarkdownGenerator.httpResponseR + import MarkdownGenerator.implicits.* + import cats.syntax.writer.* + + private[interpreter] type W[A] = Writer[Vector[Markdown], A] + + /** + * Сгенерировать markdown документ из переданного набора примеров. + * + * @param set + * набор примеров + * @return + * строка содержащая markdown документ. + */ + def generate(set: ExampleSet[HttpResponseR]): String = { + val tags = for { + _ <- Vector(h1(set.name)).tell + _ <- set.examples.traverse(generate) + } yield () + + markdown(tags.written).md + } + + private[interpreter] def generate(desc: ExampleDescription): W[Unit] = + for { + _ <- Vector[Markdown](h2(desc.name)).tell + _ <- desc.steps.foldMap(stepsPrinterW) + } yield () + + private[interpreter] def stepsPrinterW: FunctionK[Step, W] = new (Step ~> W) { + def apply[A](fa: Step[A]): W[A] = + fa match { + case Describe(text, pos) => Vector(p(text)).tell + + case SendHttp(request, pos) => + val skipCurlStrings = Seq("Content-Length") + val sreq = buildRequest(baseUri, request) + .followRedirects(false) + .toCurl + .split("\n") + .filterNot(s => skipCurlStrings.exists(r => s.contains(r))) + .mkString("", "\n", "\n") + Writer(Vector(codeBlock(sreq)), httpResponseR.asInstanceOf[A]) + + case CheckHttp(_, HttpResponseExpected(None, None, Seq()), _) => + Writer value HttpResponse(0, None, Seq.empty) + + case CheckHttp(_, HttpResponseExpected(code, body, headers), _) => + val bodyStr = body.map(_.show) + val cb = Vector( + code.map(c => s"Код ответа: ${c.matcher.show}\n"), + headers.nonEmpty.option { + headers.map { case (k, v) => s"$k: '${v.matcher.show}'" }.mkString("Заголовки ответа:\n", "\n", "\n") + }, + bodyStr.map("Тело ответа:\n" ++ _ ++ "\n"), + ).flatten.mkString("\n") + + Writer( + Vector( + p("Ответ:"), + codeBlock(cb) + ), + HttpResponse( + code.fold(0L)(_.matcher.value).toInt, + bodyStr, + headers.map { case (k, c) => k -> c.matcher.value }, + ) + ) + } + } +} diff --git a/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/package.scala b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/package.scala new file mode 100644 index 00000000..82a64132 --- /dev/null +++ b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/package.scala @@ -0,0 +1,26 @@ +package ru.tinkoff.tcb.mockingbird.edsl + +import sttp.client3.* +import sttp.model.Uri +import sttp.model.Uri.QuerySegment + +import ru.tinkoff.tcb.mockingbird.edsl.model.HttpMethod.* +import ru.tinkoff.tcb.mockingbird.edsl.model.HttpRequest + +package object interpreter { + def makeUri(host: Uri, req: HttpRequest): Uri = + host + .addPath(req.path.split("/").filter(_.nonEmpty)) + .addQuerySegments(req.query.map { case (k, v) => QuerySegment.KeyValue(k, v) }) + + def buildRequest(host: Uri, m: HttpRequest): Request[String, Any] = { + var req = m.body.fold(quickRequest)(quickRequest.body) + req = m.headers.foldLeft(req) { case (r, (k, v)) => r.header(k, v, replaceExisting = true) } + val url = makeUri(host, m) + m.method match { + case Delete => req.delete(url) + case Get => req.get(url) + case Post => req.post(url) + } + } +} diff --git a/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/Check.scala b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/Check.scala new file mode 100644 index 00000000..faf1b9d3 --- /dev/null +++ b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/Check.scala @@ -0,0 +1,105 @@ +package ru.tinkoff.tcb.mockingbird.edsl.model +import io.circe.Json + +sealed trait ValueMatcher[T] extends Product with Serializable +object ValueMatcher { + + /** + * Показывает, что ожидается конкретное значение типа `T`, в случае несовпадения сгенерированный тест упадет с + * ошибкой. + * + * @param value + * значение используемое для сравнения и отображения при генерации примера ответа от сервера в markdown. + */ + final case class FixedValue[T](value: T) extends ValueMatcher[T] + + /** + * Показывает, что ожидается любое значение типа `T`. + * + * @param example + * Это значение будет отображено в markdown документе при генерации в описании примера ответа от сервера. + */ + final case class AnyValue[T](example: T) extends ValueMatcher[T] + + object syntax { + implicit class ValueMatcherBuilder[T](private val v: T) extends AnyVal { + def fixed: ValueMatcher[T] = FixedValue(v) + def sample: ValueMatcher[T] = AnyValue(v) + } + + implicit def buildFixed[T](v: T): ValueMatcher[T] = ValueMatcher.FixedValue(v) + + implicit def convertion[A, B](vm: ValueMatcher[A])(implicit f: A => B): ValueMatcher[B] = + vm match { + case FixedValue(a) => FixedValue(f(a)) + case AnyValue(a) => AnyValue(f(a)) + } + } +} + +sealed trait Check extends Product with Serializable +object Check { + + /** + * Соответствует любому значению. + * + * @param example + * значение, которое будет использоваться как пример при генерации Markdown. + * @group CheckCommon + */ + final case class CheckAny(example: String) extends Check + + /** + * @group CheckCommon + */ + final case class CheckString(matcher: ValueMatcher[String]) extends Check + + /** + * @group CheckCommon + */ + final case class CheckInteger(matcher: ValueMatcher[Long]) extends Check + + /** + * Показывает, что ожидается JSON, реализации этого трейта позволяют детальнее описать ожидания. + * @group CheckJson + */ + sealed trait CheckJson extends Check + + /** + * Значение null + * @group CheckJson + */ + final case object CheckJsonNull extends CheckJson + + /** + * Любой валидный JSON. + * + * @constructor + * @param example + * значение, которое будет использоваться как пример при генерации Markdown. + * @group CheckJson + */ + final case class CheckJsonAny(example: Json) extends CheckJson + + /** + * JSON объект с указанными полями, объект с которым производится сравнение может содержать дополнительные поля. + * @group CheckJson + */ + final case class CheckJsonObject(fields: (String, CheckJson)*) extends CheckJson + + /** + * Массив с указанными элементами, важен порядок. Проверяемы массив может содержать в конце дополнительные элементы. + * @group CheckJson + */ + final case class CheckJsonArray(items: CheckJson*) extends CheckJson + + /** + * @group CheckJson + */ + final case class CheckJsonString(matcher: ValueMatcher[String]) extends CheckJson + + /** + * @group CheckJson + */ + final case class CheckJsonNumber(matcher: ValueMatcher[Double]) extends CheckJson +} diff --git a/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/ExampleDescription.scala b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/ExampleDescription.scala new file mode 100644 index 00000000..36f960ab --- /dev/null +++ b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/ExampleDescription.scala @@ -0,0 +1,5 @@ +package ru.tinkoff.tcb.mockingbird.edsl.model + +import org.scalactic.source + +final case class ExampleDescription(name: String, steps: Example[Any], pos: source.Position) diff --git a/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/Step.scala b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/Step.scala new file mode 100644 index 00000000..7b449814 --- /dev/null +++ b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/Step.scala @@ -0,0 +1,18 @@ +package ru.tinkoff.tcb.mockingbird.edsl.model + +import org.scalactic.source + +sealed trait Step[T] + +final case class Describe(text: String, pos: source.Position) extends Step[Unit] + +final case class SendHttp[R]( + request: HttpRequest, + pos: source.Position, +) extends Step[R] + +final case class CheckHttp[R]( + response: R, + expects: HttpResponseExpected, + pos: source.Position, +) extends Step[HttpResponse] diff --git a/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/http.scala b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/http.scala new file mode 100644 index 00000000..3b995c44 --- /dev/null +++ b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/http.scala @@ -0,0 +1,29 @@ +package ru.tinkoff.tcb.mockingbird.edsl.model + +import enumeratum.* + +import ru.tinkoff.tcb.mockingbird.edsl.model.Check.* + +sealed trait HttpMethod extends EnumEntry +object HttpMethod extends Enum[HttpMethod] { + val values = findValues + case object Get extends HttpMethod + case object Post extends HttpMethod + case object Delete extends HttpMethod +} + +final case class HttpResponse(code: Int, body: Option[String], headers: Seq[(String, String)]) + +final case class HttpRequest( + method: HttpMethod, + path: String, + body: Option[String] = None, + headers: Seq[(String, String)] = Seq.empty, + query: Seq[(String, String)] = Seq.empty, +) + +final case class HttpResponseExpected( + code: Option[CheckInteger] = None, + body: Option[Check] = None, + headers: Seq[(String, CheckString)] = Seq.empty, +) diff --git a/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/package.scala b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/package.scala new file mode 100644 index 00000000..4807f6ae --- /dev/null +++ b/backend/edsl/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/package.scala @@ -0,0 +1,7 @@ +package ru.tinkoff.tcb.mockingbird.edsl + +import cats.free.Free + +package object model { + type Example[T] = Free[Step, T] +} diff --git a/backend/edsl/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuiteTest.scala b/backend/edsl/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuiteTest.scala new file mode 100644 index 00000000..510a86b5 --- /dev/null +++ b/backend/edsl/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuiteTest.scala @@ -0,0 +1,152 @@ +package ru.tinkoff.tcb.mockingbird.edsl.interpreter + +import scala.concurrent.Future + +import io.circe.Json +import org.scalactic.source +import org.scalamock.scalatest.AsyncMockFactory +import org.scalatest.BeforeAndAfterEach +import org.scalatest.Informer +import org.scalatest.matchers.should.Matchers +import sttp.capabilities.WebSockets +import sttp.client3.* +import sttp.client3.testing.SttpBackendStub +import sttp.model.Header +import sttp.model.MediaType +import sttp.model.Method.* +import sttp.model.StatusCode +import sttp.model.Uri + +import ru.tinkoff.tcb.mockingbird.edsl.ExampleSet +import ru.tinkoff.tcb.mockingbird.edsl.model.* +import ru.tinkoff.tcb.mockingbird.edsl.model.Check.* +import ru.tinkoff.tcb.mockingbird.edsl.model.ValueMatcher.syntax.* + +class AsyncScalaTestSuiteTest extends AsyncScalaTestSuite with Matchers with AsyncMockFactory with BeforeAndAfterEach { + val eset = new ExampleSet[HttpResponseR] { + override def name: String = "" + } + + var sttpbackend_ : SttpBackendStub[Future, WebSockets] = + HttpClientFutureBackend.stub() + + override private[interpreter] def sttpbackend: SttpBackend[Future, WebSockets] = sttpbackend_ + + override def baseUri: Uri = uri"http://some.domain.com:8090" + + var mockInformer: Option[Informer] = none + override protected def info: Informer = mockInformer.getOrElse(super.info) + + override protected def beforeEach(): Unit = { + super.beforeEach() + mockInformer = None + } + + test("describe calls info") { + val mockI = mock[Informer] + mockInformer = mockI.some + (mockI.apply(_: String, _: Option[Any])(_: source.Position)).expects("some info", *, *).once() + + val example = eset.describe("some info") + + example.foldMap(stepsBuilder).as(succeed) + } + + test("sendHttp produces to send HTTP requst") { + val method = HttpMethod.Post + val path = "/api/handler" + val body = """{ + | "foo": [], + | "bar": 42 + |}""".stripMargin + val headers = Seq("x-token" -> "asd5453qwe", "Content-Type" -> "application/json") + val query = Seq("service" -> "world") + + sttpbackend_ = HttpClientFutureBackend.stub().whenRequestMatchesPartial { + case RequestT(POST, uri, StringBody(`body`, _, _), hs, _, _, _) + if uri == uri"http://some.domain.com:8090/api/handler?service=world" + && hs.exists(h => h.name == "x-token" && h.value == "asd5453qwe") + && hs.exists(h => h.name == "Content-Type" && h.value == "application/json") => + Response(body = "got request", code = StatusCode.Ok) + } + + val example = eset.sendHttp(method, path, body.some, headers, query) + + example.foldMap(stepsBuilder).map { resp => + resp.code shouldBe StatusCode.Ok + resp.body shouldBe "got request" + } + } + + test("checkHttp checks code of response") { + sttpbackend_ = HttpClientFutureBackend.stub().whenRequestMatches(_ => true).thenRespondOk() + + val sttpResp = Response( + body = "got request", + code = StatusCode.InternalServerError, + statusText = "", + headers = Seq.empty, + ) + + val example = eset.checkHttp( + sttpResp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = None, + headers = Seq.empty + ) + ) + + example.foldMap(stepsBuilder).failed.map { e => + e.getMessage() should include("Checking response HTTP code failed with errors") + } + } + + test("checkHttp checks body of response") { + sttpbackend_ = HttpClientFutureBackend.stub().whenRequestMatches(_ => true).thenRespondOk() + + val sttpResp = Response( + body = "got request", + code = StatusCode.Ok, + statusText = "", + headers = Seq.empty, + ) + + val example = eset.checkHttp( + sttpResp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckString("some wrong string").some, + headers = Seq.empty + ) + ) + + example.foldMap(stepsBuilder).failed.map { e => + e.getMessage() should include("Checking response body failed with errors") + } + } + + test("checkHttp checks headers of response") { + sttpbackend_ = HttpClientFutureBackend.stub().whenRequestMatches(_ => true).thenRespondOk() + + val sttpResp = Response( + body = "{}", + code = StatusCode.Ok, + statusText = "", + headers = Seq(Header.contentType(MediaType.TextPlain)), + ) + + val example = eset.checkHttp( + sttpResp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonAny(Json.obj()).some, + headers = Seq("Content-Type" -> CheckString("application/json")) + ) + ) + + example.foldMap(stepsBuilder).failed.map { e => + e.getMessage() should include("Checking response headers failed with errors") + } + } +} diff --git a/backend/edsl/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuiteWholeTest.scala b/backend/edsl/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuiteWholeTest.scala new file mode 100644 index 00000000..c72463e8 --- /dev/null +++ b/backend/edsl/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuiteWholeTest.scala @@ -0,0 +1,98 @@ +package ru.tinkoff.tcb.mockingbird.edsl.interpreter + +import scala.concurrent.Future + +import org.scalactic.source +import org.scalamock.scalatest.AsyncMockFactory +import org.scalatest.BeforeAndAfterAll +import org.scalatest.FutureOutcome +import org.scalatest.Informer +import org.scalatest.matchers.should.Matchers +import sttp.capabilities.WebSockets +import sttp.client3.* +import sttp.client3.testing.SttpBackendStub +import sttp.model.Header +import sttp.model.MediaType +import sttp.model.Method.* +import sttp.model.StatusCode +import sttp.model.Uri + +import ru.tinkoff.tcb.mockingbird.examples.CatsFacts + +class AsyncScalaTestSuiteWholeTest + extends AsyncScalaTestSuite + with Matchers + with AsyncMockFactory + with BeforeAndAfterAll { + + val eset = new CatsFacts[HttpResponseR]() + + var sttpbackend_ : SttpBackendStub[Future, WebSockets] = + HttpClientFutureBackend.stub() + + override private[interpreter] def sttpbackend: SttpBackend[Future, WebSockets] = sttpbackend_ + + override def baseUri: Uri = uri"https://localhost.example:9977" + + var mockInformer: Option[Informer] = none + override protected def info: Informer = mockInformer.getOrElse(super.info) + + override protected def beforeAll(): Unit = { + super.beforeAll() + + val mockI = mock[Informer] + + (mockI + .apply(_: String, _: Option[Any])(_: source.Position)) + .expects("Отправить GET запрос", *, *) + .once() + + (mockI + .apply(_: String, _: Option[Any])(_: source.Position)) + .expects("Ответ содержит случайный факт полученный с сервера", *, *) + .twice() + + mockInformer = mockI.some + + sttpbackend_ = HttpClientFutureBackend + .stub() + .whenRequestMatches { req => + req.method == GET && req.uri.toString() == s"https://localhost.example:9977/fact" && + req.headers.exists(h => h.name == "X-CSRF-TOKEN" && h.value == "unEENxJqSLS02rji2GjcKzNLc0C0ySlWih9hSxwn") + } + .thenRespond( + Response( + body = """{ + | "fact" : "There are approximately 100 breeds of cat.", + | "length" : 42.0 + |}""".stripMargin, + code = StatusCode.Ok, + statusText = "", + headers = Seq(Header.contentType(MediaType.ApplicationJson)) + ) + ) + } + + override protected def afterAll(): Unit = { + calledTests shouldBe Vector("fake", "Получение случайного факта о котиках") + + super.afterAll() + } + + private var calledTests: Vector[String] = Vector.empty + + override def withFixture(test: NoArgAsyncTest): FutureOutcome = { + calledTests = calledTests :+ test.name + test() + } + + test("fake") { + // The afterAll isn't called if the test suite doesn't contain any test. + // It happens If the generateTest doesn't add any test. It's reason + // why this test added. Its existence prevents this case. + Future(succeed) + } + + generateTests(eset) + +} diff --git a/backend/edsl/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/MarkdownGeneratorSuite.scala b/backend/edsl/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/MarkdownGeneratorSuite.scala new file mode 100644 index 00000000..dcdb11c9 --- /dev/null +++ b/backend/edsl/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/MarkdownGeneratorSuite.scala @@ -0,0 +1,175 @@ +package ru.tinkoff.tcb.mockingbird.edsl.interpreter + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.muninn.scalamdtag.* +import sttp.client3.* + +import ru.tinkoff.tcb.mockingbird.edsl.ExampleSet +import ru.tinkoff.tcb.mockingbird.edsl.model.* +import ru.tinkoff.tcb.mockingbird.edsl.model.Check.* +import ru.tinkoff.tcb.mockingbird.edsl.model.ValueMatcher.syntax.* +import ru.tinkoff.tcb.mockingbird.examples.CatsFacts + +class MarkdownGeneratorSuite extends AnyFunSuite with Matchers { + val eset = new ExampleSet[MarkdownGenerator.HttpResponseR] { + override def name: String = "" + } + + test("describe produces text as markdown paragraph") { + + val mdg = MarkdownGenerator(uri"http://localhost") + val text = """ + | + | + |Sea at omnes semper causae. Eleifend `inimicus` ea mea, ut zril nemore qui. Ne + |odio enim has. Probo ignota phaedrum no pri, ei eam tale luptatum moderatius. + |Et elit postea sensibus sea, et his malis luptatum. + | + | + |Vis prima vituperata ad. No sed debitis `gloriatur` intellegat. Et per volumus + |dissentiet, ei audiam diceret vim. Sed wisi falli ex. Vis noster eirmod ex, eos + |euismod ponderum eu. + | + | + |""".stripMargin + + val mds = eset.describe(text).foldMap(mdg.stepsPrinterW).written + mds should have length 1 + mds.head.md shouldBe ("\n" ++ text ++ "\n") + } + + test("sendHttp produces curl command") { + val host = "http://localhost:8080" + val mdg = MarkdownGenerator(uri"$host") + val method = HttpMethod.Post + val path = "/api/handler" + val body = """{ + | "foo": [], + | "bar": 42 + |}""".stripMargin + val headers = Seq("x-token" -> "asd5453qwe", "Content-Type" -> "application/json") + val query = Seq("page" -> "3", "limit" -> "10", "service" -> "world") + + val example = eset.sendHttp(method, path, body.some, headers, query) + + val mds = example + .foldMap(mdg.stepsPrinterW) + .written + mds should have length 1 + + val obtains = mds.head.md + val expected = + raw"""``` + |curl \ + | --request POST \ + | --url '$host$path?${query.map { case (n, v) => s"$n=$v" }.mkString("&")}' \ + | --header 'x-token: asd5453qwe' \ + | --header 'Content-Type: application/json' \ + | --data-raw '$body' + | + |```""".stripMargin + obtains shouldBe expected + } + + test("checkHttp without expectation produces nothing") { + val mdg = MarkdownGenerator(uri"http://localhost:8080") + + val example = eset.checkHttp(MarkdownGenerator.httpResponseR, HttpResponseExpected(None, None, Seq.empty)) + + val mds = example + .foldMap(mdg.stepsPrinterW) + .written + + mds shouldBe empty + } + + test("checkHttp with expectation produces code block") { + val mdg = MarkdownGenerator(uri"http://localhost:8080") + + val example = eset.checkHttp( + MarkdownGenerator.httpResponseR, + HttpResponseExpected( + code = CheckInteger(418).some, + body = CheckJsonObject( + "foo" -> CheckJsonArray(), + "bar" -> CheckJsonNull, + "inner" -> CheckJsonObject( + "i1" -> CheckJsonString("some string"), + "xx" -> CheckJsonNumber(23.0.sample), + ), + ).some, + headers = Seq( + "Content-Type" -> CheckString("application/json"), + "token" -> CheckString("token-example".sample), + ), + ) + ) + + val mds = example + .foldMap(mdg.stepsPrinterW) + .written + mds should have length 2 + + val obtains = markdown(mds).md + val expected = + raw""" + |Ответ: + |``` + |Код ответа: 418 + | + |Заголовки ответа: + |Content-Type: 'application/json' + |token: 'token-example' + | + |Тело ответа: + |{ + | "foo" : [ + | ], + | "bar" : null, + | "inner" : { + | "i1" : "some string", + | "xx" : 23.0 + | } + |} + | + |``` + |""".stripMargin + obtains shouldBe expected + } + + test("whole HTTP example") { + val mdg = MarkdownGenerator(uri"https://catfact.ninja") + val set = new CatsFacts[MarkdownGenerator.HttpResponseR]() + mdg.generate(set) shouldBe + """# Примеры использования ExampleSet + |## Получение случайного факта о котиках + | + |Отправить GET запрос + |``` + |curl \ + | --request GET \ + | --url 'https://catfact.ninja/fact' \ + | --header 'X-CSRF-TOKEN: unEENxJqSLS02rji2GjcKzNLc0C0ySlWih9hSxwn' + | + |``` + | + |Ответ содержит случайный факт полученный с сервера + | + |Ответ: + |``` + |Код ответа: 200 + | + |Заголовки ответа: + |Content-Type: 'application/json' + | + |Тело ответа: + |{ + | "fact" : "There are approximately 100 breeds of cat.", + | "length" : 42.0 + |} + | + |``` + |""".stripMargin + } +} diff --git a/backend/edsl/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/CatsFacts.scala b/backend/edsl/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/CatsFacts.scala new file mode 100644 index 00000000..6a4a1e09 --- /dev/null +++ b/backend/edsl/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/CatsFacts.scala @@ -0,0 +1,37 @@ +package ru.tinkoff.tcb.mockingbird.examples + +import ru.tinkoff.tcb.mockingbird.edsl.ExampleSet +import ru.tinkoff.tcb.mockingbird.edsl.model.* +import ru.tinkoff.tcb.mockingbird.edsl.model.Check.* +import ru.tinkoff.tcb.mockingbird.edsl.model.HttpMethod.* +import ru.tinkoff.tcb.mockingbird.edsl.model.ValueMatcher.syntax.* + +class CatsFacts[HttpResponseR] extends ExampleSet[HttpResponseR] { + + override val name = "Примеры использования ExampleSet" + + example("Получение случайного факта о котиках")( + for { + _ <- describe("Отправить GET запрос") + resp <- sendHttp( + method = Get, + path = "/fact", + headers = Seq("X-CSRF-TOKEN" -> "unEENxJqSLS02rji2GjcKzNLc0C0ySlWih9hSxwn") + ) + _ <- describe("Ответ содержит случайный факт полученный с сервера") + _ <- checkHttp( + resp, + HttpResponseExpected( + code = Some(CheckInteger(200)), + body = Some( + CheckJsonObject( + "fact" -> CheckJsonString("There are approximately 100 breeds of cat.".sample), + "length" -> CheckJsonNumber(42.sample) + ) + ), + headers = Seq("Content-Type" -> CheckString("application/json")) + ) + ) + } yield () + ) +} diff --git a/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/examples/BasicHttpStub.scala b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/examples/BasicHttpStub.scala new file mode 100644 index 00000000..1a4d935f --- /dev/null +++ b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/examples/BasicHttpStub.scala @@ -0,0 +1,340 @@ +package ru.tinkoff.tcb.mockingbird.examples + +import io.circe.parser + +import ru.tinkoff.tcb.mockingbird.edsl.ExampleSet +import ru.tinkoff.tcb.mockingbird.edsl.model.* +import ru.tinkoff.tcb.mockingbird.edsl.model.Check.* +import ru.tinkoff.tcb.utils.circe.optics.* + +class BasicHttpStub[HttpResponseR] extends ExampleSet[HttpResponseR] { + import ValueMatcher.syntax.* + + val name = "Базовые примеры работы с HTTP заглушками" + + example("Persistent, ephemeral и countdown HTTP заглушки") { + for { + // TODO: подумать можно ли описать какие-то пререквизиты, чтобы в тестах + // автоматически их проверять и возможно исполнять. Например, проверить + // и создать сервис, если его нет, запустить сервис до которого будет + // mockingbird проксировать запрос и т.п. + _ <- describe("Предполагается, что в mockingbird есть сервис `alpha`.") + + _ <- describe("Создаем заглушку в скоупе `persistent`.") + resp <- sendHttp( + method = HttpMethod.Post, + path = "/api/internal/mockingbird/v2/stub", + body = """{ + | "path": "/alpha/handler1", + | "name": "Persistent HTTP Stub", + | "method": "GET", + | "scope": "persistent", + | "request": { + | "mode": "no_body", + | "headers": {} + | }, + | "response": { + | "mode": "raw", + | "body": "persistent scope", + | "headers": { + | "Content-Type": "text/plain" + | }, + | "code": "451" + | } + |}""".stripMargin.some, + headers = Seq( + "Content-Type" -> "application/json", + ) + ) + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonObject( + "status" -> CheckJsonString("success"), + "id" -> CheckJsonString("29dfd29e-d684-462e-8676-94dbdd747e30".sample) + ).some, + ) + ) + + _ <- describe("Проверяем созданную заглушку.") + resp <- sendHttp( + method = HttpMethod.Get, + path = "/api/mockingbird/exec/alpha/handler1", + ) + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(451).some, + body = CheckString("persistent scope").some, + headers = Seq( + "Content-Type" -> CheckString("text/plain"), + ), + ) + ) + + _ <- describe("Для этого же пути, создаем заглушку в скоупе `ephemeral`.") + resp <- sendHttp( + method = HttpMethod.Post, + path = "/api/internal/mockingbird/v2/stub", + body = """{ + | "path": "/alpha/handler1", + | "name": "Ephemeral HTTP Stub", + | "method": "GET", + | "scope": "ephemeral", + | "request": { + | "mode": "no_body", + | "headers": {} + | }, + | "response": { + | "mode": "raw", + | "body": "ephemeral scope", + | "headers": { + | "Content-Type": "text/plain" + | }, + | "code": "200" + | } + |}""".stripMargin.some, + headers = Seq( + "Content-Type" -> "application/json", + ) + ) + r <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonObject( + "status" -> CheckJsonString("success"), + "id" -> CheckJsonString("13da7ef2-650e-4a54-9dca-377a1b1ca8b9".sample) + ).some, + ) + ) + idEphemeral = parser.parse(r.body.get).toOption.flatMap((JLens \ "id").getOpt).flatMap(_.asString).get + + _ <- describe("И создаем заглушку в скоупе `countdown` с `times` равным 2.") + resp <- sendHttp( + method = HttpMethod.Post, + path = "/api/internal/mockingbird/v2/stub", + body = """{ + | "path": "/alpha/handler1", + | "times": 2, + | "name": "Countdown Stub", + | "method": "GET", + | "scope": "countdown", + | "request": { + | "mode": "no_body", + | "headers": {} + | }, + | "response": { + | "mode": "raw", + | "body": "countdown scope", + | "headers": { + | "Content-Type": "text/plain" + | }, + | "code": "429" + | } + |}""".stripMargin.some, + headers = Seq( + "Content-Type" -> "application/json", + ) + ) + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonObject( + "status" -> CheckJsonString("success"), + "id" -> CheckJsonString("09ec1cb9-4ca0-4142-b796-b94a24d9df29".sample) + ).some, + ) + ) + + _ <- describe( + """Заданные заглушки отличаются возвращаемыми ответами, а именно содержимым `body` и `code`, + | в целом они могут быть как и полностью одинаковыми так и иметь больше различий. + | Скоупы заглушек в порядке убывания приоритета: Countdown, Ephemeral, Persistent""".stripMargin + ) + + _ <- describe( + """Так как заглушка `countdown` была создана с `times` равным двум, то следующие два + |запроса вернут указанное в ней содержимое.""".stripMargin + ) + // TODO: при генерации Markdown будет дважды добавлен запрос и ожидаемый ответ, + // может стоит добавить действие Repeat, чтобы при генерации Markdown в документе + // указывалось бы, что данное действие повторяется N раз. + _ <- Seq + .fill(2)( + for { + resp <- sendHttp( + method = HttpMethod.Get, + path = "/api/mockingbird/exec/alpha/handler1", + ) + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(429).some, + body = CheckString("countdown scope").some, + headers = Seq( + "Content-Type" -> CheckString("text/plain"), + ), + ) + ) + } yield () + ) + .sequence + + _ <- describe( + """Последующие запросы будут возвращать содержимое заглушки `ephemeral`. Если бы её не было, + |то вернулся бы ответ от заглушки `persistent`.""".stripMargin + ) + resp <- sendHttp( + method = HttpMethod.Get, + path = "/api/mockingbird/exec/alpha/handler1", + ) + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckString("ephemeral scope").some, + headers = Seq( + "Content-Type" -> CheckString("text/plain"), + ), + ) + ) + + _ <- describe("""Чтобы получить теперь ответ от `persistent` заглушки нужно или дождаться, когда истекут + |сутки с момента её создания или просто удалить `ephemeral` заглушку.""".stripMargin) + resp <- sendHttp( + method = HttpMethod.Delete, + path = s"/api/internal/mockingbird/v2/stub/$idEphemeral", + headers = Seq( + "Content-Type" -> "application/json", + ) + ) + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonObject( + "status" -> CheckJsonString("success"), + "id" -> CheckJsonNull, + ).some, + ) + ) + + _ <- describe("После удаления `ephemeral` заглушки, при запросе вернется результат заглушки `persistent`") + resp <- sendHttp( + method = HttpMethod.Get, + path = "/api/mockingbird/exec/alpha/handler1", + ) + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(451).some, + body = CheckString("persistent scope").some, + headers = Seq( + "Content-Type" -> CheckString("text/plain"), + ), + ) + ) + } yield () + } + + example("Использование параметров пути в HTTP заглушках") { + for { + _ <- describe( + """Заглушка может выбираться в том числе и на основании регулярного выражения + |в пути, это может быть не очень эффективно с точки зрения поиска такой заглушки. + |Поэтому без необходимости, лучше не использовать этот механизм.""".stripMargin + ) + + _ <- describe("Предполагается, что в mockingbird есть сервис `alpha`.") + + _ <- describe( + """Скоуп в котором создаются заглушки не важен. В целом скоуп влияет только + |на приоритет заглушек. В данном случае заглушка создается в скоупе `countdown`. + |В отличие от предыдущих примеров, здесь для указания пути для срабатывания + |заглушки используется поле `pathPattern`, вместо `path`. Так же, ответ который + |формирует заглушка не статичный, а зависит от параметров пути.""".stripMargin + ) + + resp <- sendHttp( + method = HttpMethod.Post, + path = "/api/internal/mockingbird/v2/stub", + body = """{ + | "pathPattern": "/alpha/handler2/(?[-_A-z0-9]+)/(?[0-9]+)", + | "times": 2, + | "name": "Simple HTTP Stub with path pattern", + | "method": "GET", + | "scope": "countdown", + | "request": { + | "mode": "no_body", + | "headers": {} + | }, + | "response": { + | "mode": "json", + | "body": { + | "static_field": "Fixed part of reponse", + | "obj": "${pathParts.obj}", + | "id": "${pathParts.id}" + | }, + | "headers": { + | "Content-Type": "application/json" + | }, + | "code": "200" + | } + |}""".stripMargin.some, + headers = Seq( + "Content-Type" -> "application/json", + ) + ) + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonObject( + "status" -> CheckJsonString("success"), + "id" -> CheckJsonString("c8c9d92f-192e-4fe3-8a09-4c9b69802603".sample) + ).some, + ) + ) + + _ <- describe( + """Теперь сделаем несколько запросов, который приведут к срабатыванию этой заглшки, + |чтобы увидеть, что результат действительно зависит от пути.""".stripMargin + ) + resp <- sendHttp( + method = HttpMethod.Get, + path = "/api/mockingbird/exec/alpha/handler2/alpha/123", + ) + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonObject( + "static_field" -> CheckJsonString("Fixed part of reponse"), + "obj" -> CheckJsonString("alpha"), + "id" -> CheckJsonString("123") + ).some, + headers = Seq("Content-Type" -> CheckString("application/json")), + ) + ) + resp <- sendHttp( + method = HttpMethod.Get, + path = "/api/mockingbird/exec/alpha/handler2/beta/876", + ) + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonObject( + "static_field" -> CheckJsonString("Fixed part of reponse"), + "obj" -> CheckJsonString("beta"), + "id" -> CheckJsonString("876") + ).some, + headers = Seq("Content-Type" -> CheckString("application/json")), + ) + ) + } yield () + } +} diff --git a/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/examples/HttpStubWithState.scala b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/examples/HttpStubWithState.scala new file mode 100644 index 00000000..4aa720a9 --- /dev/null +++ b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/examples/HttpStubWithState.scala @@ -0,0 +1,594 @@ +package ru.tinkoff.tcb.mockingbird.examples + +import io.circe.parser + +import ru.tinkoff.tcb.mockingbird.edsl.ExampleSet +import ru.tinkoff.tcb.mockingbird.edsl.model.* +import ru.tinkoff.tcb.mockingbird.edsl.model.Check.* +import ru.tinkoff.tcb.mockingbird.edsl.model.HttpMethod.* +import ru.tinkoff.tcb.mockingbird.edsl.model.ValueMatcher.syntax.* +import ru.tinkoff.tcb.utils.circe.optics.* + +class HttpStubWithState[HttpResponseR] extends ExampleSet[HttpResponseR] { + + val name = "Использование хранимого состояние в HTTP заглушках" + + example("Создать, получить и обновить хранимое состояние") { + for { + _ <- describe("Предполагается, что в mockingbird есть сервис `alpha`.") + _ <- describe("""Для работы с состоянием у HTTP заглушки есть две секции: `persist` и `state`. + |Секция `persist` отвечает за сохранение состояния для последующего доступа к + |нему. А секция `state` содержит предикаты для поиска состояния. Если указана + |только секция `persist`, то каждый раз при срабатывании заглушки в БД будет + |записываться новое состояние. А если указаны обе секции, то найденное состояние + |будет перезаписано. Состояние - это JSON объект.""".stripMargin) + _ <- describe("""В качестве примера, будем хранить как состояние JSON объект вида: + |```json + |{ + | "id": "o1", + | "name": "Object #1", + | "version": 1 + |} + |``` + |И дополнительно сохранять время создания и модификации.""".stripMargin) + _ <- describe("Для первоначального создания состояния создадим следующую заглушку.") + resp <- sendHttp( + method = Post, + path = "/api/internal/mockingbird/v2/stub", + body = """{ + | "path": "/alpha/state1", + | "name": "Insert new state", + | "labels": [], + | "method": "POST", + | "scope": "persistent", + | "request": { + | "mode": "jlens", + | "body": { + | "id": {"exists": true} + | }, + | "headers": {} + | }, + | "response": { + | "mode": "json", + | "body": { + | "new": "${req}", + | "meta": { + | "created": "${seed.timestamp}" + | } + | }, + | "headers": { + | "Content-Type": "application/json" + | }, + | "code": "200" + | }, + | "persist": { + | "_data": "${req}", + | "meta": { + | "created": "${seed.timestamp}" + | } + | }, + | "seed": { + | "timestamp": "%{now(yyyy-MM-dd'T'HH:mm:ss.nn'Z')}" + | } + |}""".stripMargin.some, + headers = Seq("Content-Type" -> "application/json"), + ) + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonObject( + "status" -> CheckJsonString("success"), + "id" -> CheckJsonString("98f393d3-07f0-403e-9043-150bf5c5b4bc".sample) + ).some + ) + ) + _ <- describe( + """Данная заглушка делает следующее: + | * Проверяет, что тело запроса - это JSON объект содержащий как минимум одно + | поле `id`. + | * В секции `seed` создается переменная `timestamp` в которую записывается + | текущее время. + | * Секция `persist` описывает объект, который будет сохранен как состояние. + | Данные, которые пришли в теле запроса записываются в поле `_data`, в добавок, + | в поле `created` записывает текущее время. + | * В ответе возвращаются полученные данные и временная метка.""".stripMargin + ) + _ <- describe( + """В итоге в Mockingbird состояние будет записано как: + |```json + |{ + | "_data": { + | "id": "o1", + | "name": "Object #1", + | "version": 1 + | }, + | "created": "2023-08-09T11:30:00.261287000Z" + |} + |``` + |""".stripMargin + ) + _ <- describe( + """Добавим заглушку для модификации состояния, она будет похожей на предыдущую, + |но будет иметь секцию `state` для поиска уже существующего состояния, а в секции + |`persist` будет поле `modified` вместо `created`.""".stripMargin + ) + resp <- sendHttp( + method = Post, + path = "/api/internal/mockingbird/v2/stub", + body = """{ + | "path": "/alpha/state1", + | "name": "Update existed state", + | "labels": [], + | "method": "POST", + | "scope": "persistent", + | "request": { + | "mode": "jlens", + | "body": { + | "id": {"exists": true} + | }, + | "headers": {} + | }, + | "response": { + | "mode": "json", + | "body": { + | "old": "${state._data}", + | "new": "${req}", + | "meta": { + | "created": "${state.meta.created}", + | "modified": "${seed.timestamp}" + | } + | }, + | "headers": { + | "Content-Type": "application/json" + | }, + | "code": "200" + | }, + | "persist": { + | "_data": "${req}", + | "meta.modified": "${seed.timestamp}" + | }, + | "state": { + | "_data.id": {"==": "${id}"} + | }, + | "seed": { + | "timestamp": "%{now(yyyy-MM-dd'T'HH:mm:ss.nn'Z')}" + | } + |}""".stripMargin.some, + headers = Seq("Content-Type" -> "application/json"), + ) + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonObject( + "status" -> CheckJsonString("success"), + "id" -> CheckJsonString("11b57bcc-ecee-445d-ad56-106b6ba706c7".sample) + ).some + ) + ) + _ <- describe( + """Для обновления состояния принимаем такие же данные, как и для создания нового. + |В секции `state` поля из тела запроса доступны сразу, без дополнительных, + |поэтому просто пишем, имя поля `${id}`, в отличии от секций `response` + |и `persist`, где доступ к данным запроса осуществляется через переменную `req`. + |В случае, если используются именованные параметры пути в `pathPattern`, + |то доступ к ним из секции `state` осуществляется через переменную `__segments`.""".stripMargin + ) + _ <- describe( + """При обновлении состояния, поля перечисленные в секции `persist` дописываются + |к тем, что уже есть в найденном состоянии. В случае если поле уже существует, то + |оно будет перезаписано. Стоит обратить внимание каким образом дописывается + |временная метка `modified`. Она указана как `meta.modified`, такой синтаксис + |позволяет перезаписывать не весь объект, а только его часть или добавлять + |в него новые поля.""".stripMargin + ) + _ <- describe( + """При выборе между двух заглушек, заглушка для которой выполнилось условие поиска + |хранимого состояние, т.е. существует состояние удовлетворяющее критериям + |указанным в секции `state`, имеет больший приоритет, чем заглушка без условий + |выбора состояний. Поэтому первая заглушка будет срабатывать когда в БД ещё нет + |хранимого состояния с указанным `id`, а вторая когда такое состояние уже есть. """.stripMargin + ) + _ <- describe("""Теперь создадим заглушку для получения хранимого состояния. Получать состояние + |будем отправляя POST запрос с JSON содержащим поле `id`: + |```json + |{ + | "id": "o1" + |} + |``` + |""".stripMargin) + resp <- sendHttp( + method = Post, + path = "/api/internal/mockingbird/v2/stub", + body = """{ + | "path": "/alpha/state1/get", + | "name": "Get existed state", + | "labels": [], + | "method": "POST", + | "scope": "persistent", + | "request": { + | "mode": "jlens", + | "body": { + | "id": {"exists": true} + | }, + | "headers": {} + | }, + | "response": { + | "mode": "json", + | "body": { + | "data": "${state._data}", + | "meta": "${state.meta}" + | }, + | "headers": { + | "Content-Type": "application/json" + | }, + | "code": "200" + | }, + | "state": { + | "_data.id": {"==": "${id}"} + | } + |}""".stripMargin.some, + headers = Seq("Content-Type" -> "application/json"), + ) + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonObject( + "status" -> CheckJsonString("success"), + "id" -> CheckJsonString("da6b4458-596b-4db9-8943-0cea96bbba33".sample) + ).some + ) + ) + _ <- describe("Теперь попробуем вызвать заглушку, записывающую новое состояние.") + resp <- sendHttp( + method = Post, + path = "/api/mockingbird/exec/alpha/state1", + body = """{ + | "id": "o1", + | "name": "Object #1", + | "version": 1 + |}""".stripMargin.some, + headers = Seq("Content-Type" -> "application/json"), + ) + checked <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonObject( + "new" -> CheckJsonObject( + "id" -> CheckJsonString("o1"), + "name" -> CheckJsonString("Object #1"), + "version" -> CheckJsonNumber(1), + ), + "meta" -> CheckJsonObject( + "created" -> CheckJsonString("2023-08-09T16:51:56.386854000Z".sample), + ) + ).some + ) + ) + o1v1 = checked.body.flatMap(b => parser.parse(b).toOption).get + o1v1created = (JLens \ "meta" \ "created").getOpt(o1v1).flatMap(_.asString).get + _ <- describe("А теперь получить состояние") + resp <- sendHttp( + method = Post, + path = "/api/mockingbird/exec/alpha/state1/get", + body = """{"id": "o1"}""".some + ) + checked <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonObject( + "data" -> CheckJsonObject( + "id" -> CheckJsonString("o1"), + "name" -> CheckJsonString("Object #1"), + "version" -> CheckJsonNumber(1), + ), + "meta" -> CheckJsonObject( + "created" -> CheckJsonString(o1v1created), + ) + ).some + ) + ) + _ <- describe( + """Теперь модифицируем состояние, изменив значение поля `version` и добавив новое + |поле `description`. Поле `name` опустим.""".stripMargin + ) + resp <- sendHttp( + method = Post, + path = "/api/mockingbird/exec/alpha/state1", + body = """{ + | "id": "o1", + | "description": "some value", + | "version": 2 + |}""".stripMargin.some, + headers = Seq("Content-Type" -> "application/json"), + ) + checked <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonObject( + "old" -> CheckJsonObject( + "id" -> CheckJsonString("o1"), + "name" -> CheckJsonString("Object #1"), + "version" -> CheckJsonNumber(1), + ), + "new" -> CheckJsonObject( + "id" -> CheckJsonString("o1"), + "description" -> CheckJsonString("some value"), + "version" -> CheckJsonNumber(2), + ), + "meta" -> CheckJsonObject( + "created" -> CheckJsonString(o1v1created), + "modified" -> CheckJsonString("2023-08-09T16:59:56.241827000Z".sample), + ) + ).some + ) + ) + o1v2 = checked.body.flatMap(b => parser.parse(b).toOption).get + o1v2modified = (JLens \ "meta" \ "modified").getOpt(o1v2).flatMap(_.asString).get + _ <- describe("И снова запросим состояние объекта `o1`") + resp <- sendHttp( + method = Post, + path = "/api/mockingbird/exec/alpha/state1/get", + body = """{"id": "o1"}""".some + ) + checked <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonObject( + "data" -> CheckJsonObject( + "id" -> CheckJsonString("o1"), + "description" -> CheckJsonString("some value"), + "version" -> CheckJsonNumber(2), + ), + "meta" -> CheckJsonObject( + "created" -> CheckJsonString(o1v1created), + "modified" -> CheckJsonString(o1v2modified), + ) + ).some + ) + ) + _ <- describe( + """Ответ изменился, мы видим новые поля. Так как поле `data` перезаписывалось + |целиком, то поле `name` исчезло, в то время как в объекте `meta` + |модифицировалось только поле `modified`, поэтому, хотя поле `created` не указано + |в секции `persist` заглушки обновляющей сосотояние, оно отсталось.""".stripMargin + ) + _ <- describe( + """Если попробовать вызвать заглушку читающую состояние объекта которого нет, + |то Mockingbird вернет ошибку, в котрой будет сказано, что не найдено подходящие + |заглушки.""".stripMargin + ) + resp <- sendHttp( + method = Post, + path = "/api/mockingbird/exec/alpha/state1/get", + body = """{"id": "o2"}""".some + ) + checked <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(400).some, + body = CheckString( + "ru.tinkoff.tcb.mockingbird.error.StubSearchError: Не удалось подобрать заглушку для [Post] /alpha/state1/get" + ).some + ) + ) + _ <- describe( + """Для решения подобной проблемы, надо создать вторую заглушку с таким же `path`, + |но с незаполненным `state`. Тогда, в случае отсутствия искомого состояния, будет + |отрабатывать она. Это аналогично тому как мы создали заглушку для записи нового + |состояния и заглушку для его обновления.""".stripMargin + ) + } yield () + } + + example("Несколько состояний подходящих под условие поиска") { + for { + _ <- describe( + """В предыдущем примере было рассмотрено создание и модификация состояния, + |для этого было создано две соответствующие заглушки. Важно помнить, что если + |секция `state` не указана, а указана только секция `persist`, то в БД **всегда** + |создается новый объект состояния. При это заглушка с заполненным полем `state` + |будет выбрана только в том случае, если в результате поиска по заданным + |параметрам из БД вернулся строго один объект с состоянием.""".stripMargin + ) + _ <- describe( + """**ВНИМАНИЕ!** Функции удаления состояний в Mockingbird нет. Неосторожная работа + |с состояниями может привести к неработоспособности заглушек и придется удалять + |данные напрямую из БД.""".stripMargin + ) + _ <- describe("Для демонстрации этого создадим новые заглушки для записи и чтения состояния.") + resp <- sendHttp( + method = Post, + path = "/api/internal/mockingbird/v2/stub", + body = """{ + | "path": "/alpha/state2", + | "name": "Insert new state #2", + | "labels": [], + | "method": "POST", + | "scope": "persistent", + | "request": { + | "mode": "jlens", + | "body": { + | "bad_id": {"exists": true} + | }, + | "headers": {} + | }, + | "response": { + | "mode": "raw", + | "body": "OK", + | "headers": { + | "Content-Type": "text/plain" + | }, + | "code": "200" + | }, + | "persist": { + | "req": "${req}" + | } + |}""".stripMargin.some, + headers = Seq("Content-Type" -> "application/json"), + ) + _ <- checkHttp( + resp, + HttpResponseExpected(code = CheckInteger(200).some) + ) + resp <- sendHttp( + method = Post, + path = "/api/internal/mockingbird/v2/stub", + body = """{ + | "path": "/alpha/state2/get", + | "name": "Get state #2", + | "labels": [], + | "method": "POST", + | "scope": "persistent", + | "request": { + | "mode": "jlens", + | "body": { + | "bad_id": {"exists": true} + | }, + | "headers": {} + | }, + | "response": { + | "mode": "json", + | "body": "${state.req}", + | "headers": { + | "Content-Type": "application/json" + | }, + | "code": "200" + | }, + | "state": { + | "req.bad_id": {"==": "${bad_id}"} + | } + |}""".stripMargin.some, + headers = Seq("Content-Type" -> "application/json"), + ) + _ <- checkHttp( + resp, + HttpResponseExpected(code = CheckInteger(200).some) + ) + _ <- describe("Вызовем заглушку для записи состояния") + resp <- sendHttp( + method = Post, + path = "/api/mockingbird/exec/alpha/state2", + body = """{ + | "bad_id": "bad1", + | "version": 1 + |}""".stripMargin.some, + headers = Seq("Content-Type" -> "application/json"), + ) + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckString("OK").some, + ) + ) + _ <- describe("Теперь попробуем его получить.") + resp <- sendHttp( + method = Post, + path = "/api/mockingbird/exec/alpha/state2/get", + body = """{ + | "bad_id": "bad1" + |}""".stripMargin.some, + headers = Seq("Content-Type" -> "application/json"), + ) + _ <- describe("Тут всё хорошо и мы получили то, что записали.") + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonObject( + "bad_id" -> CheckJsonString("bad1"), + "version" -> CheckJsonNumber(1), + ).some, + ) + ) + _ <- describe("Теперь еще раз отправим объект с таким же `bad_id`") + resp <- sendHttp( + method = Post, + path = "/api/mockingbird/exec/alpha/state2", + body = """{ + | "bad_id": "bad1", + | "version": 2 + |}""".stripMargin.some, + headers = Seq("Content-Type" -> "application/json"), + ) + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckString("OK").some, + ) + ) + _ <- describe("И снова попробуем его получить.") + resp <- sendHttp( + method = Post, + path = "/api/mockingbird/exec/alpha/state2/get", + body = """{ + | "bad_id": "bad1" + |}""".stripMargin.some, + headers = Seq("Content-Type" -> "application/json"), + ) + _ <- describe("А вот тут уже ошибка") + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(400).some, + body = CheckString( + "ru.tinkoff.tcb.mockingbird.error.StubSearchError: Для одной или нескольких заглушек найдено более одного подходящего состояния" + ).some, + ) + ) + _ <- describe("Для проверки состояний подходящих для под заданное условие, можно выполнить следующий запрос.") + resp <- sendHttp( + method = Post, + path = "/api/internal/mockingbird/fetchStates", + body = """{ + | "query": { + | "req.bad_id": {"==": "bad1"} + | } + |}""".stripMargin.some, + headers = Seq("Content-Type" -> "application/json"), + ) + _ <- describe("В результате будет два объекта") + _ <- checkHttp( + resp, + HttpResponseExpected( + code = CheckInteger(200).some, + body = CheckJsonArray( + CheckJsonObject( + "id" -> CheckJsonString("7d81f74b-968b-4737-8ebe-b0592d4fb89b".sample), + "data" -> CheckJsonObject( + "req" -> CheckJsonObject( + "bad_id" -> CheckJsonString("bad1"), + "version" -> CheckJsonNumber(1), + ) + ), + "created" -> CheckJsonString("2023-08-09T17:35:56.389+00:00".sample), + ), + CheckJsonObject( + "id" -> CheckJsonString("dade36fb-0048-40f6-b534-96eda9426728".sample), + "data" -> CheckJsonObject( + "req" -> CheckJsonObject( + "bad_id" -> CheckJsonString("bad1"), + "version" -> CheckJsonNumber(2), + ) + ), + "created" -> CheckJsonString("2023-08-09T17:38:23.472+00:00".sample), + ), + ).some, + ) + ) + _ <- describe( + """Ручка `/api/internal/mockingbird/fetchStates` возвращает состояния в том виде + |как они хранятся в БД, присутствуют поля `id`, `created`, а записанное состояние + |хранится в поле `data`.""".stripMargin + ) + } yield () + } +} diff --git a/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/examples/Main.scala b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/examples/Main.scala new file mode 100644 index 00000000..305cfe23 --- /dev/null +++ b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/examples/Main.scala @@ -0,0 +1,65 @@ +package ru.tinkoff.tcb.mockingbird.examples + +import java.io.BufferedWriter +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardOpenOption.CREATE +import java.nio.file.StandardOpenOption.TRUNCATE_EXISTING +import java.nio.file.StandardOpenOption.WRITE + +import sttp.client3.* +import zio.cli.* +import zio.interop.catz.* + +import ru.tinkoff.tcb.mockingbird.edsl.ExampleSet +import ru.tinkoff.tcb.mockingbird.edsl.interpreter.MarkdownGenerator + +object Main extends ZIOCliDefault { + private val mdg = MarkdownGenerator(uri"http://localhost:8228") + + private val exampleSets = List( + "basic_http_stub.md" -> new BasicHttpStub[MarkdownGenerator.HttpResponseR](), + "http_stub_with_state.md" -> new HttpStubWithState[MarkdownGenerator.HttpResponseR](), + ) + + def program(dir: Path) = + for { + _ <- ZIO.logInfo(s"examples generator started, output dir is ${dir.toString()}") + _ <- exampleSets.map(ns => ns.copy(_1 = Paths.get(dir.toString(), ns._1))).traverse(write.tupled) + } yield () + + private val cliOps = + Options.directory("o", Exists.Yes) ?? "It sets output directory where generated examples will be placed." + + private val cliCmd = Command.Single( + "", + HelpDoc.p("It generates markdown files with examples from examples sets that written in Scala."), + cliOps, + Args.none + ) + + override val cliApp: CliApp[Environment & ZIOAppArgs & Scope, Any, (java.nio.file.Path, Unit)] = CliApp.make( + name = "Examples Generator", + version = getClass().getPackage().getImplementationVersion(), + summary = HelpDoc.Span.empty, + command = cliCmd + ) { case (dir, _) => program(dir) } + + private def wopen(file: Path): RIO[Scope, BufferedWriter] = + ZIO.acquireRelease( + ZIO.attempt(Files.newBufferedWriter(file, CREATE, WRITE, TRUNCATE_EXISTING)) + )(f => ZIO.succeed(f.close())) + + private def write( + file: Path, + set: ExampleSet[MarkdownGenerator.HttpResponseR] + ): RIO[Scope, Unit] = + for { + _ <- ZIO.logInfo(s"write example ${file.toString()}") + f <- wopen(file) + data = mdg.generate(set) + _ <- ZIO.attempt(f.write(data)) + } yield () + +} diff --git a/backend/examples/src/test/resources/logback.xml b/backend/examples/src/test/resources/logback.xml new file mode 100644 index 00000000..62ead022 --- /dev/null +++ b/backend/examples/src/test/resources/logback.xml @@ -0,0 +1,24 @@ + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} %msg%n + + + + + + + + + + + + + + + + diff --git a/backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/BaseSuite.scala b/backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/BaseSuite.scala new file mode 100644 index 00000000..9adcef03 --- /dev/null +++ b/backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/BaseSuite.scala @@ -0,0 +1,51 @@ +package ru.tinkoff.tcb.mockingbird.examples + +import java.io.File + +import com.dimafeng.testcontainers.DockerComposeContainer +import com.dimafeng.testcontainers.ExposedService +import com.dimafeng.testcontainers.scalatest.TestContainerForAll +import org.testcontainers.containers.wait.strategy.Wait +import sttp.client3.* +import sttp.model.StatusCode +import sttp.model.Uri + +import ru.tinkoff.tcb.mockingbird.edsl.interpreter.AsyncScalaTestSuite + +trait BaseSuite extends AsyncScalaTestSuite with TestContainerForAll { + private var httpHost: Uri = _ + @annotation.nowarn("msg=is never used") + private var grpcHost: String = _ + + override def baseUri = httpHost + + override val containerDef: DockerComposeContainer.Def = + DockerComposeContainer.Def( + new File("./compose-test.yml"), + exposedServices = Seq( + ExposedService("mockingbird", 9000, 1), + ExposedService("mockingbird", 8228, 1, Wait.forLogMessage(".*\"App started\".*", 1)), + ) + ) + + override def afterContainersStart(containers: DockerComposeContainer): Unit = { + super.afterContainersStart(containers) + + val host = containers.getServiceHost("mockingbird", 8228) + val httpPort = containers.getServicePort("mockingbird", 8228) + val grpcPort = containers.getServicePort("mockingbird", 9000) + + httpHost = uri"http://$host:$httpPort" + grpcHost = s"$host:$grpcPort" + + val sb = HttpClientSyncBackend() + val resp = quickRequest + .body("""{ "suffix": "alpha", "name": "Test Service" }""") + .post(baseUri.withPath("api/internal/mockingbird/v2/service".split("/"))) + .send(sb) + + assert(resp.code === StatusCode.Ok, resp.body) + + () + } +} diff --git a/backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/BasicHttpStubSuite.scala b/backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/BasicHttpStubSuite.scala new file mode 100644 index 00000000..e42a7550 --- /dev/null +++ b/backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/BasicHttpStubSuite.scala @@ -0,0 +1,6 @@ +package ru.tinkoff.tcb.mockingbird.examples + +class BasicHttpStubSuite extends BaseSuite { + private val set = new BasicHttpStub[HttpResponseR] + generateTests(set) +} diff --git a/backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/HttpStubWithStateSuite.scala b/backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/HttpStubWithStateSuite.scala new file mode 100644 index 00000000..5778fde6 --- /dev/null +++ b/backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/HttpStubWithStateSuite.scala @@ -0,0 +1,6 @@ +package ru.tinkoff.tcb.mockingbird.examples + +class HttpStubWithStateSuite extends BaseSuite { + private val set = new HttpStubWithState[HttpResponseR] + generateTests(set) +} diff --git a/backend/project/Dependencies.scala b/backend/project/Dependencies.scala index 48f189b8..0fca27a8 100644 --- a/backend/project/Dependencies.scala +++ b/backend/project/Dependencies.scala @@ -57,16 +57,27 @@ object Dependencies { "com.beachape" %% "enumeratum-circe" % "1.7.0" ) - val scalatest = Seq( - "org.scalatest" %% "scalatest" % "3.2.2" % Test, - "com.ironcorelabs" %% "cats-scalatest" % "3.1.1" % Test + val scalatestMain = Seq( + "org.scalatest" %% "scalatest" % "3.2.2", + "com.ironcorelabs" %% "cats-scalatest" % "3.1.1", ) + val scalatest = scalatestMain.map(_ % Test) + val scalacheck = Seq( "org.scalatestplus" %% "scalacheck-1-15" % "3.2.2.0" % Test, "org.scalacheck" %% "scalacheck" % "1.15.2" % Test ) + val scalamock = Seq( + "org.scalamock" %% "scalamock" % "5.1.0" % Test + ) + + lazy val testContainers = Seq( + "testcontainers-scala-scalatest", + "testcontainers-scala-mongodb", + ).map("com.dimafeng" %% _ % "0.41.0" % Test) + lazy val refined = Seq( "eu.timepit" %% "refined" % "0.9.28" ) diff --git a/backend/secrets-for-test.conf b/backend/secrets-for-test.conf new file mode 100644 index 00000000..03e80783 --- /dev/null +++ b/backend/secrets-for-test.conf @@ -0,0 +1,17 @@ +{ + "secrets": { + "server": { + "allowedOrigins": [ + "*" + ], + "healthCheckRoute": "/ready", + }, + "security": { + "secret": "secret" + }, + "mongodb": { + "uri": "mongodb://mongo/mockingbird" + } + } +} + diff --git a/examples/basic_http_stub.md b/examples/basic_http_stub.md new file mode 100644 index 00000000..7233a396 --- /dev/null +++ b/examples/basic_http_stub.md @@ -0,0 +1,350 @@ +# Базовые примеры работы с HTTP заглушками +## Persistent, ephemeral и countdown HTTP заглушки + +Предполагается, что в mockingbird есть сервис `alpha`. + +Создаем заглушку в скоупе `persistent`. +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/internal/mockingbird/v2/stub' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "path": "/alpha/handler1", + "name": "Persistent HTTP Stub", + "method": "GET", + "scope": "persistent", + "request": { + "mode": "no_body", + "headers": {} + }, + "response": { + "mode": "raw", + "body": "persistent scope", + "headers": { + "Content-Type": "text/plain" + }, + "code": "451" + } +}' + +``` + +Ответ: +``` +Код ответа: 200 + +Тело ответа: +{ + "status" : "success", + "id" : "29dfd29e-d684-462e-8676-94dbdd747e30" +} + +``` + +Проверяем созданную заглушку. +``` +curl \ + --request GET \ + --url 'http://localhost:8228/api/mockingbird/exec/alpha/handler1' + +``` + +Ответ: +``` +Код ответа: 451 + +Заголовки ответа: +Content-Type: 'text/plain' + +Тело ответа: +persistent scope + +``` + +Для этого же пути, создаем заглушку в скоупе `ephemeral`. +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/internal/mockingbird/v2/stub' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "path": "/alpha/handler1", + "name": "Ephemeral HTTP Stub", + "method": "GET", + "scope": "ephemeral", + "request": { + "mode": "no_body", + "headers": {} + }, + "response": { + "mode": "raw", + "body": "ephemeral scope", + "headers": { + "Content-Type": "text/plain" + }, + "code": "200" + } +}' + +``` + +Ответ: +``` +Код ответа: 200 + +Тело ответа: +{ + "status" : "success", + "id" : "13da7ef2-650e-4a54-9dca-377a1b1ca8b9" +} + +``` + +И создаем заглушку в скоупе `countdown` с `times` равным 2. +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/internal/mockingbird/v2/stub' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "path": "/alpha/handler1", + "times": 2, + "name": "Countdown Stub", + "method": "GET", + "scope": "countdown", + "request": { + "mode": "no_body", + "headers": {} + }, + "response": { + "mode": "raw", + "body": "countdown scope", + "headers": { + "Content-Type": "text/plain" + }, + "code": "429" + } +}' + +``` + +Ответ: +``` +Код ответа: 200 + +Тело ответа: +{ + "status" : "success", + "id" : "09ec1cb9-4ca0-4142-b796-b94a24d9df29" +} + +``` + +Заданные заглушки отличаются возвращаемыми ответами, а именно содержимым `body` и `code`, + в целом они могут быть как и полностью одинаковыми так и иметь больше различий. + Скоупы заглушек в порядке убывания приоритета: Countdown, Ephemeral, Persistent + +Так как заглушка `countdown` была создана с `times` равным двум, то следующие два +запроса вернут указанное в ней содержимое. +``` +curl \ + --request GET \ + --url 'http://localhost:8228/api/mockingbird/exec/alpha/handler1' + +``` + +Ответ: +``` +Код ответа: 429 + +Заголовки ответа: +Content-Type: 'text/plain' + +Тело ответа: +countdown scope + +``` +``` +curl \ + --request GET \ + --url 'http://localhost:8228/api/mockingbird/exec/alpha/handler1' + +``` + +Ответ: +``` +Код ответа: 429 + +Заголовки ответа: +Content-Type: 'text/plain' + +Тело ответа: +countdown scope + +``` + +Последующие запросы будут возвращать содержимое заглушки `ephemeral`. Если бы её не было, +то вернулся бы ответ от заглушки `persistent`. +``` +curl \ + --request GET \ + --url 'http://localhost:8228/api/mockingbird/exec/alpha/handler1' + +``` + +Ответ: +``` +Код ответа: 200 + +Заголовки ответа: +Content-Type: 'text/plain' + +Тело ответа: +ephemeral scope + +``` + +Чтобы получить теперь ответ от `persistent` заглушки нужно или дождаться, когда истекут +сутки с момента её создания или просто удалить `ephemeral` заглушку. +``` +curl \ + --request DELETE \ + --url 'http://localhost:8228/api/internal/mockingbird/v2/stub/13da7ef2-650e-4a54-9dca-377a1b1ca8b9' \ + --header 'Content-Type: application/json' + +``` + +Ответ: +``` +Код ответа: 200 + +Тело ответа: +{ + "status" : "success", + "id" : null +} + +``` + +После удаления `ephemeral` заглушки, при запросе вернется результат заглушки `persistent` +``` +curl \ + --request GET \ + --url 'http://localhost:8228/api/mockingbird/exec/alpha/handler1' + +``` + +Ответ: +``` +Код ответа: 451 + +Заголовки ответа: +Content-Type: 'text/plain' + +Тело ответа: +persistent scope + +``` +## Использование параметров пути в HTTP заглушках + +Заглушка может выбираться в том числе и на основании регулярного выражения +в пути, это может быть не очень эффективно с точки зрения поиска такой заглушки. +Поэтому без необходимости, лучше не использовать этот механизм. + +Предполагается, что в mockingbird есть сервис `alpha`. + +Скоуп в котором создаются заглушки не важен. В целом скоуп влияет только +на приоритет заглушек. В данном случае заглушка создается в скоупе `countdown`. +В отличие от предыдущих примеров, здесь для указания пути для срабатывания +заглушки используется поле `pathPattern`, вместо `path`. Так же, ответ который +формирует заглушка не статичный, а зависит от параметров пути. +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/internal/mockingbird/v2/stub' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "pathPattern": "/alpha/handler2/(?[-_A-z0-9]+)/(?[0-9]+)", + "times": 2, + "name": "Simple HTTP Stub with path pattern", + "method": "GET", + "scope": "countdown", + "request": { + "mode": "no_body", + "headers": {} + }, + "response": { + "mode": "json", + "body": { + "static_field": "Fixed part of reponse", + "obj": "${pathParts.obj}", + "id": "${pathParts.id}" + }, + "headers": { + "Content-Type": "application/json" + }, + "code": "200" + } +}' + +``` + +Ответ: +``` +Код ответа: 200 + +Тело ответа: +{ + "status" : "success", + "id" : "c8c9d92f-192e-4fe3-8a09-4c9b69802603" +} + +``` + +Теперь сделаем несколько запросов, который приведут к срабатыванию этой заглшки, +чтобы увидеть, что результат действительно зависит от пути. +``` +curl \ + --request GET \ + --url 'http://localhost:8228/api/mockingbird/exec/alpha/handler2/alpha/123' + +``` + +Ответ: +``` +Код ответа: 200 + +Заголовки ответа: +Content-Type: 'application/json' + +Тело ответа: +{ + "static_field" : "Fixed part of reponse", + "obj" : "alpha", + "id" : "123" +} + +``` +``` +curl \ + --request GET \ + --url 'http://localhost:8228/api/mockingbird/exec/alpha/handler2/beta/876' + +``` + +Ответ: +``` +Код ответа: 200 + +Заголовки ответа: +Content-Type: 'application/json' + +Тело ответа: +{ + "static_field" : "Fixed part of reponse", + "obj" : "beta", + "id" : "876" +} + +``` diff --git a/examples/http_stub_with_state.md b/examples/http_stub_with_state.md new file mode 100644 index 00000000..d47a16fc --- /dev/null +++ b/examples/http_stub_with_state.md @@ -0,0 +1,631 @@ +# Использование хранимого состояние в HTTP заглушках +## Создать, получить и обновить хранимое состояние + +Предполагается, что в mockingbird есть сервис `alpha`. + +Для работы с состоянием у HTTP заглушки есть две секции: `persist` и `state`. +Секция `persist` отвечает за сохранение состояния для последующего доступа к +нему. А секция `state` содержит предикаты для поиска состояния. Если указана +только секция `persist`, то каждый раз при срабатывании заглушки в БД будет +записываться новое состояние. А если указаны обе секции, то найденное состояние +будет перезаписано. Состояние - это JSON объект. + +В качестве примера, будем хранить как состояние JSON объект вида: +```json +{ + "id": "o1", + "name": "Object #1", + "version": 1 +} +``` +И дополнительно сохранять время создания и модификации. + +Для первоначального создания состояния создадим следующую заглушку. +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/internal/mockingbird/v2/stub' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "path": "/alpha/state1", + "name": "Insert new state", + "labels": [], + "method": "POST", + "scope": "persistent", + "request": { + "mode": "jlens", + "body": { + "id": {"exists": true} + }, + "headers": {} + }, + "response": { + "mode": "json", + "body": { + "new": "${req}", + "meta": { + "created": "${seed.timestamp}" + } + }, + "headers": { + "Content-Type": "application/json" + }, + "code": "200" + }, + "persist": { + "_data": "${req}", + "meta": { + "created": "${seed.timestamp}" + } + }, + "seed": { + "timestamp": "%{now(yyyy-MM-dd\'T\'HH:mm:ss.nn\'Z\')}" + } +}' + +``` + +Ответ: +``` +Код ответа: 200 + +Тело ответа: +{ + "status" : "success", + "id" : "98f393d3-07f0-403e-9043-150bf5c5b4bc" +} + +``` + +Данная заглушка делает следующее: + * Проверяет, что тело запроса - это JSON объект содержащий как минимум одно + поле `id`. + * В секции `seed` создается переменная `timestamp` в которую записывается + текущее время. + * Секция `persist` описывает объект, который будет сохранен как состояние. + Данные, которые пришли в теле запроса записываются в поле `_data`, в добавок, + в поле `created` записывает текущее время. + * В ответе возвращаются полученные данные и временная метка. + +В итоге в Mockingbird состояние будет записано как: +```json +{ + "_data": { + "id": "o1", + "name": "Object #1", + "version": 1 + }, + "created": "2023-08-09T11:30:00.261287000Z" +} +``` + + +Добавим заглушку для модификации состояния, она будет похожей на предыдущую, +но будет иметь секцию `state` для поиска уже существующего состояния, а в секции +`persist` будет поле `modified` вместо `created`. +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/internal/mockingbird/v2/stub' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "path": "/alpha/state1", + "name": "Update existed state", + "labels": [], + "method": "POST", + "scope": "persistent", + "request": { + "mode": "jlens", + "body": { + "id": {"exists": true} + }, + "headers": {} + }, + "response": { + "mode": "json", + "body": { + "old": "${state._data}", + "new": "${req}", + "meta": { + "created": "${state.meta.created}", + "modified": "${seed.timestamp}" + } + }, + "headers": { + "Content-Type": "application/json" + }, + "code": "200" + }, + "persist": { + "_data": "${req}", + "meta.modified": "${seed.timestamp}" + }, + "state": { + "_data.id": {"==": "${id}"} + }, + "seed": { + "timestamp": "%{now(yyyy-MM-dd\'T\'HH:mm:ss.nn\'Z\')}" + } +}' + +``` + +Ответ: +``` +Код ответа: 200 + +Тело ответа: +{ + "status" : "success", + "id" : "11b57bcc-ecee-445d-ad56-106b6ba706c7" +} + +``` + +Для обновления состояния принимаем такие же данные, как и для создания нового. +В секции `state` поля из тела запроса доступны сразу, без дополнительных, +поэтому просто пишем, имя поля `${id}`, в отличии от секций `response` +и `persist`, где доступ к данным запроса осуществляется через переменную `req`. +В случае, если используются именованные параметры пути в `pathPattern`, +то доступ к ним из секции `state` осуществляется через переменную `__segments`. + +При обновлении состояния, поля перечисленные в секции `persist` дописываются +к тем, что уже есть в найденном состоянии. В случае если поле уже существует, то +оно будет перезаписано. Стоит обратить внимание каким образом дописывается +временная метка `modified`. Она указана как `meta.modified`, такой синтаксис +позволяет перезаписывать не весь объект, а только его часть или добавлять +в него новые поля. + +При выборе между двух заглушек, заглушка для которой выполнилось условие поиска +хранимого состояние, т.е. существует состояние удовлетворяющее критериям +указанным в секции `state`, имеет больший приоритет, чем заглушка без условий +выбора состояний. Поэтому первая заглушка будет срабатывать когда в БД ещё нет +хранимого состояния с указанным `id`, а вторая когда такое состояние уже есть. + +Теперь создадим заглушку для получения хранимого состояния. Получать состояние +будем отправляя POST запрос с JSON содержащим поле `id`: +```json +{ + "id": "o1" +} +``` + +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/internal/mockingbird/v2/stub' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "path": "/alpha/state1/get", + "name": "Get existed state", + "labels": [], + "method": "POST", + "scope": "persistent", + "request": { + "mode": "jlens", + "body": { + "id": {"exists": true} + }, + "headers": {} + }, + "response": { + "mode": "json", + "body": { + "data": "${state._data}", + "meta": "${state.meta}" + }, + "headers": { + "Content-Type": "application/json" + }, + "code": "200" + }, + "state": { + "_data.id": {"==": "${id}"} + } +}' + +``` + +Ответ: +``` +Код ответа: 200 + +Тело ответа: +{ + "status" : "success", + "id" : "da6b4458-596b-4db9-8943-0cea96bbba33" +} + +``` + +Теперь попробуем вызвать заглушку, записывающую новое состояние. +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/mockingbird/exec/alpha/state1' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "id": "o1", + "name": "Object #1", + "version": 1 +}' + +``` + +Ответ: +``` +Код ответа: 200 + +Тело ответа: +{ + "new" : { + "id" : "o1", + "name" : "Object #1", + "version" : 1.0 + }, + "meta" : { + "created" : "2023-08-09T16:51:56.386854000Z" + } +} + +``` + +А теперь получить состояние +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/mockingbird/exec/alpha/state1/get' \ + --header 'Content-Type: text/plain; charset=utf-8' \ + --data-raw '{"id": "o1"}' + +``` + +Ответ: +``` +Код ответа: 200 + +Тело ответа: +{ + "data" : { + "id" : "o1", + "name" : "Object #1", + "version" : 1.0 + }, + "meta" : { + "created" : "2023-08-09T16:51:56.386854000Z" + } +} + +``` + +Теперь модифицируем состояние, изменив значение поля `version` и добавив новое +поле `description`. Поле `name` опустим. +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/mockingbird/exec/alpha/state1' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "id": "o1", + "description": "some value", + "version": 2 +}' + +``` + +Ответ: +``` +Код ответа: 200 + +Тело ответа: +{ + "old" : { + "id" : "o1", + "name" : "Object #1", + "version" : 1.0 + }, + "new" : { + "id" : "o1", + "description" : "some value", + "version" : 2.0 + }, + "meta" : { + "created" : "2023-08-09T16:51:56.386854000Z", + "modified" : "2023-08-09T16:59:56.241827000Z" + } +} + +``` + +И снова запросим состояние объекта `o1` +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/mockingbird/exec/alpha/state1/get' \ + --header 'Content-Type: text/plain; charset=utf-8' \ + --data-raw '{"id": "o1"}' + +``` + +Ответ: +``` +Код ответа: 200 + +Тело ответа: +{ + "data" : { + "id" : "o1", + "description" : "some value", + "version" : 2.0 + }, + "meta" : { + "created" : "2023-08-09T16:51:56.386854000Z", + "modified" : "2023-08-09T16:59:56.241827000Z" + } +} + +``` + +Ответ изменился, мы видим новые поля. Так как поле `data` перезаписывалось +целиком, то поле `name` исчезло, в то время как в объекте `meta` +модифицировалось только поле `modified`, поэтому, хотя поле `created` не указано +в секции `persist` заглушки обновляющей сосотояние, оно отсталось. + +Если попробовать вызвать заглушку читающую состояние объекта которого нет, +то Mockingbird вернет ошибку, в котрой будет сказано, что не найдено подходящие +заглушки. +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/mockingbird/exec/alpha/state1/get' \ + --header 'Content-Type: text/plain; charset=utf-8' \ + --data-raw '{"id": "o2"}' + +``` + +Ответ: +``` +Код ответа: 400 + +Тело ответа: +ru.tinkoff.tcb.mockingbird.error.StubSearchError: Не удалось подобрать заглушку для [Post] /alpha/state1/get + +``` + +Для решения подобной проблемы, надо создать вторую заглушку с таким же `path`, +но с незаполненным `state`. Тогда, в случае отсутствия искомого состояния, будет +отрабатывать она. Это аналогично тому как мы создали заглушку для записи нового +состояния и заглушку для его обновления. +## Несколько состояний подходящих под условие поиска + +В предыдущем примере было рассмотрено создание и модификация состояния, +для этого было создано две соответствующие заглушки. Важно помнить, что если +секция `state` не указана, а указана только секция `persist`, то в БД **всегда** +создается новый объект состояния. При это заглушка с заполненным полем `state` +будет выбрана только в том случае, если в результате поиска по заданным +параметрам из БД вернулся строго один объект с состоянием. + +**ВНИМАНИЕ!** Функции удаления состояний в Mockingbird нет. Неосторожная работа +с состояниями может привести к неработоспособности заглушек и придется удалять +данные напрямую из БД. + +Для демонстрации этого создадим новые заглушки для записи и чтения состояния. +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/internal/mockingbird/v2/stub' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "path": "/alpha/state2", + "name": "Insert new state #2", + "labels": [], + "method": "POST", + "scope": "persistent", + "request": { + "mode": "jlens", + "body": { + "bad_id": {"exists": true} + }, + "headers": {} + }, + "response": { + "mode": "raw", + "body": "OK", + "headers": { + "Content-Type": "text/plain" + }, + "code": "200" + }, + "persist": { + "req": "${req}" + } +}' + +``` + +Ответ: +``` +Код ответа: 200 + +``` +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/internal/mockingbird/v2/stub' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "path": "/alpha/state2/get", + "name": "Get state #2", + "labels": [], + "method": "POST", + "scope": "persistent", + "request": { + "mode": "jlens", + "body": { + "bad_id": {"exists": true} + }, + "headers": {} + }, + "response": { + "mode": "json", + "body": "${state.req}", + "headers": { + "Content-Type": "application/json" + }, + "code": "200" + }, + "state": { + "req.bad_id": {"==": "${bad_id}"} + } +}' + +``` + +Ответ: +``` +Код ответа: 200 + +``` + +Вызовем заглушку для записи состояния +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/mockingbird/exec/alpha/state2' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "bad_id": "bad1", + "version": 1 +}' + +``` + +Ответ: +``` +Код ответа: 200 + +Тело ответа: +OK + +``` + +Теперь попробуем его получить. +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/mockingbird/exec/alpha/state2/get' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "bad_id": "bad1" +}' + +``` + +Тут всё хорошо и мы получили то, что записали. + +Ответ: +``` +Код ответа: 200 + +Тело ответа: +{ + "bad_id" : "bad1", + "version" : 1.0 +} + +``` + +Теперь еще раз отправим объект с таким же `bad_id` +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/mockingbird/exec/alpha/state2' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "bad_id": "bad1", + "version": 2 +}' + +``` + +Ответ: +``` +Код ответа: 200 + +Тело ответа: +OK + +``` + +И снова попробуем его получить. +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/mockingbird/exec/alpha/state2/get' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "bad_id": "bad1" +}' + +``` + +А вот тут уже ошибка + +Ответ: +``` +Код ответа: 400 + +Тело ответа: +ru.tinkoff.tcb.mockingbird.error.StubSearchError: Для одной или нескольких заглушек найдено более одного подходящего состояния + +``` + +Для проверки состояний подходящих для под заданное условие, можно выполнить следующий запрос. +``` +curl \ + --request POST \ + --url 'http://localhost:8228/api/internal/mockingbird/fetchStates' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "query": { + "req.bad_id": {"==": "bad1"} + } +}' + +``` + +В результате будет два объекта + +Ответ: +``` +Код ответа: 200 + +Тело ответа: +[ + { + "id" : "7d81f74b-968b-4737-8ebe-b0592d4fb89b", + "data" : { + "req" : { + "bad_id" : "bad1", + "version" : 1.0 + } + }, + "created" : "2023-08-09T17:35:56.389+00:00" + }, + { + "id" : "dade36fb-0048-40f6-b534-96eda9426728", + "data" : { + "req" : { + "bad_id" : "bad1", + "version" : 2.0 + } + }, + "created" : "2023-08-09T17:38:23.472+00:00" + } +] + +``` + +Ручка `/api/internal/mockingbird/fetchStates` возвращает состояния в там виде +как они хранятся в БД, присутствуют поля `id`, `created`, а записанное состояние +хранится в поле `data`.