From 8087f54f1fa77e7150178e89fd346ac076b04cf4 Mon Sep 17 00:00:00 2001 From: Aleksei Shashev Date: Wed, 19 Jul 2023 23:52:21 +0300 Subject: [PATCH] Start of DAL implementation, independent of the database Add dal2.HttpStubDAO and impl for MongoDB that hides logic of building queries to Database. Added a DSL that lets describe an action sequence and then generates scalatest, executing it and markdown files containing curl commands for these actions. --- .github/workflows/ci.yml | 33 +- backend/.sbtopts | 2 +- backend/build.sbt | 80 ++- .../tcb/criteria/UntypedCriteriaSpec.scala | 2 +- .../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 + .../mockingbird/examples/BasicHttpStub.scala | 341 ++++++++++ .../tcb/mockingbird/examples/Main.scala | 68 ++ .../interpreter/AsyncScalaTestSuiteTest.scala | 152 +++++ .../AsyncScalaTestSuiteWholeTest.scala | 98 +++ .../interpreter/MarkdownGeneratorSuite.scala | 175 +++++ .../tcb/mockingbird/examples/CatsFacts.scala | 37 ++ .../src/test/resources/logback-test.xml | 18 + .../integration/src/test/resources/test.conf | 19 + .../dal2/HttpStubDAOSpecBehaviors.scala | 621 ++++++++++++++++++ .../dal2/mongo/HttpStubDAOSpec.scala | 76 +++ .../examples/mongo/BasicHttpStubSuite.scala | 131 ++++ .../tinkoff/tcb/mockingbird/Mockingbird.scala | 35 +- .../tcb/mockingbird/api/AdminApiHandler.scala | 77 +-- .../mockingbird/api/PublicApiHandler.scala | 7 +- .../tcb/mockingbird/api/StubResolver.scala | 25 +- .../mockingbird/config/Configuration.scala | 6 + .../tcb/mockingbird/dal/HttpStubDAO.scala | 10 - .../tcb/mockingbird/dal2/HttpStubDAO.scala | 194 ++++++ .../dal2/mongo/HttpStubDAOImpl.scala | 116 ++++ .../mockingbird/stream/EphemeralCleaner.scala | 17 +- backend/project/Dependencies.scala | 12 +- backend/project/Settings.scala | 2 +- .../impl/ZUniversalContextLogging.scala | 0 .../tofu/logging/impl/ZUniversalLogging.scala | 0 docker-compose.yml | 2 +- examples/basic_http_stub.md | 350 ++++++++++ frontend/build.sh | 22 + 40 files changed, 3357 insertions(+), 114 deletions(-) create mode 100644 backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/ExampleSet.scala create mode 100644 backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuite.scala create mode 100644 backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/MarkdownGenerator.scala create mode 100644 backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/package.scala create mode 100644 backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/Check.scala create mode 100644 backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/ExampleDescription.scala create mode 100644 backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/Step.scala create mode 100644 backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/http.scala create mode 100644 backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/package.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/Main.scala create mode 100644 backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuiteTest.scala create mode 100644 backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuiteWholeTest.scala create mode 100644 backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/MarkdownGeneratorSuite.scala create mode 100644 backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/CatsFacts.scala create mode 100644 backend/integration/src/test/resources/logback-test.xml create mode 100644 backend/integration/src/test/resources/test.conf create mode 100644 backend/integration/src/test/scala/ru/tinkoff/tcb/mockingbird/dal2/HttpStubDAOSpecBehaviors.scala create mode 100644 backend/integration/src/test/scala/ru/tinkoff/tcb/mockingbird/dal2/mongo/HttpStubDAOSpec.scala create mode 100644 backend/integration/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/mongo/BasicHttpStubSuite.scala create mode 100644 backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/dal2/HttpStubDAO.scala create mode 100644 backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/dal2/mongo/HttpStubDAOImpl.scala rename backend/{mockingbird => utils}/src/main/scala/tofu/logging/impl/ZUniversalContextLogging.scala (100%) rename backend/{mockingbird => utils}/src/main/scala/tofu/logging/impl/ZUniversalLogging.scala (100%) create mode 100644 examples/basic_http_stub.md create mode 100755 frontend/build.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6416241b..63e6f8f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,35 @@ jobs: name: front-static path: frontend/dist/out + heavy-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + check-latest: true + + - name: Install Protoc + uses: arduino/setup-protoc@v1 + with: + version: '3.20.0' + + - name: Get the Ref + id: get-ref + uses: ankitvgupta/ref-to-tag-action@v1.0.1 + with: + ref: ${{ github.ref }} + head_ref: ${{ github.head_ref }} + + - name: Test + run: | + cd backend + sbt "project integration;clean;fixCheck;test;" + back-build: needs: [front-build] @@ -142,7 +171,7 @@ jobs: uses: arduino/setup-protoc@v1 with: version: '3.20.0' - + - name: Get the Ref id: get-ref uses: ankitvgupta/ref-to-tag-action@v1.0.1 @@ -169,4 +198,4 @@ jobs: context: ./backend/mockingbird-native/target/docker/stage push: true tags: ghcr.io/tinkoff/mockingbird:${{ steps.get-ref.outputs.tag }}-native - if: ${{ github.ref_type == 'tag' || startsWith(github.event.head_commit.message, '[docker]') }} \ No newline at end of file + if: ${{ github.ref_type == 'tag' || startsWith(github.event.head_commit.message, '[docker]') }} diff --git a/backend/.sbtopts b/backend/.sbtopts index e5e59b89..13499294 100644 --- a/backend/.sbtopts +++ b/backend/.sbtopts @@ -2,7 +2,7 @@ -J-Xmx1536m -J-XX:+AlwaysPreTouch -J-XX:ReservedCodeCacheSize=128M --J-XX:MaxMetaspaceSize=512M +-J-XX:MaxMetaspaceSize=1024M -J-Xss2m -J-XX:+TieredCompilation -J-XX:+UseParallelGC diff --git a/backend/build.sbt b/backend/build.sbt index f180f9a9..6c6c084d 100644 --- a/backend/build.sbt +++ b/backend/build.sbt @@ -11,7 +11,7 @@ ThisBuild / evictionErrorLevel := Level.Debug val utils = (project in file("utils")) .settings(Settings.common) .settings( - libraryDependencies ++= Dependencies.zio ++ Dependencies.scalatest ++ Dependencies.metrics + libraryDependencies ++= Dependencies.zio ++ Dependencies.scalatest ++ Dependencies.metrics ++ Dependencies.tofu ) val circeUtils = (project in file("circe-utils")) @@ -20,6 +20,48 @@ val circeUtils = (project in file("circe-utils")) libraryDependencies ++= Dependencies.json ++ Dependencies.zio ++ Dependencies.scalatest ) +val examples = (project in file("examples")) + .enablePlugins( + JavaAppPackaging + ) + .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", + "dev.zio" %% "zio-cli" % "0.5.0", + ), + ) + .settings( + Compile / doc / sources := (file("examples/src") ** "*.scala").get, + Compile / doc / scalacOptions ++= Seq("-groups", "-skip-packages", "sttp") + ) + .settings( + addCommandAlias( + "fixCheck", + "scalafixAll --check; scalafmtCheck" + ), + addCommandAlias( + "lintAll", + "scalafixAll; scalafmtAll" + ), + addCommandAlias( + "simulacrum", + "scalafixEnable;scalafix AddSerializable;scalafix AddImplicitNotFound;scalafix TypeClassSupport;" + ) + ) + val dataAccess = (project in file("dataAccess")) .settings(Settings.common) .settings( @@ -165,6 +207,41 @@ lazy val `mockingbird-native` = (project in file("mockingbird-native")) ) ) +lazy val integration = (project in file("integration")) + .dependsOn(examples) + .dependsOn(mockingbird) + .dependsOn(`mockingbird-api`) + .enablePlugins( + JavaAppPackaging + ) + .settings(Settings.common) + .settings( + publish / skip := true, + Test / fork := true, + libraryDependencies ++= Seq( + Dependencies.scalatest, + Dependencies.scalacheck, + ).flatten, + libraryDependencies ++= Seq( + "com.dimafeng" %% "testcontainers-scala" % "0.40.17" % Test, + ), + Test / javaOptions += "-Dconfig.resource=test.conf", + ) + .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 +250,7 @@ val root = (project in file(".")) mockingbird, `mockingbird-api`, `mockingbird-native`, + examples ) .settings( run / aggregate := false, diff --git a/backend/dataAccess/src/test/scala/ru/tinkoff/tcb/criteria/UntypedCriteriaSpec.scala b/backend/dataAccess/src/test/scala/ru/tinkoff/tcb/criteria/UntypedCriteriaSpec.scala index 446553f6..e2988965 100644 --- a/backend/dataAccess/src/test/scala/ru/tinkoff/tcb/criteria/UntypedCriteriaSpec.scala +++ b/backend/dataAccess/src/test/scala/ru/tinkoff/tcb/criteria/UntypedCriteriaSpec.scala @@ -285,7 +285,7 @@ class UntypedCriteriaSpec extends AnyFlatSpec with Matchers { it should "correctly negate and" in { ( !(criteria.name === "peka" && criteria.id === 42) && - !(criteria.list in ("a", "b", "c")) + !(criteria.list.in("a", "b", "c")) ).toJson shouldBe BsonDocument( "$and" -> BsonArray( BsonDocument( diff --git a/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/ExampleSet.scala b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/ExampleSet.scala new file mode 100644 index 00000000..6309080b --- /dev/null +++ b/backend/examples/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.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/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuite.scala b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuite.scala new file mode 100644 index 00000000..34c3ea52 --- /dev/null +++ b/backend/examples/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/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/MarkdownGenerator.scala b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/MarkdownGenerator.scala new file mode 100644 index 00000000..e1a8fb53 --- /dev/null +++ b/backend/examples/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/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/package.scala b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/package.scala new file mode 100644 index 00000000..82a64132 --- /dev/null +++ b/backend/examples/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/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/Check.scala b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/Check.scala new file mode 100644 index 00000000..faf1b9d3 --- /dev/null +++ b/backend/examples/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/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/ExampleDescription.scala b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/ExampleDescription.scala new file mode 100644 index 00000000..36f960ab --- /dev/null +++ b/backend/examples/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/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/Step.scala b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/Step.scala new file mode 100644 index 00000000..7b449814 --- /dev/null +++ b/backend/examples/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/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/http.scala b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/http.scala new file mode 100644 index 00000000..3b995c44 --- /dev/null +++ b/backend/examples/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/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/package.scala b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/edsl/model/package.scala new file mode 100644 index 00000000..4807f6ae --- /dev/null +++ b/backend/examples/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/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..0f2a8798 --- /dev/null +++ b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/examples/BasicHttpStub.scala @@ -0,0 +1,341 @@ +package ru.tinkoff.tcb.mockingbird.examples + +import cats.syntax.all.* +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/Main.scala b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/examples/Main.scala new file mode 100644 index 00000000..5b798593 --- /dev/null +++ b/backend/examples/src/main/scala/ru/tinkoff/tcb/mockingbird/examples/Main.scala @@ -0,0 +1,68 @@ +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 tofu.logging.Logging +import tofu.logging.impl.ZUniversalLogging +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 zioLog: Logging[UIO] = new ZUniversalLogging(this.getClass.getName) + + private val mdg = MarkdownGenerator(uri"http://localhost:8228") + + private val exampleSets = List( + "basic_http_stub.md" -> new BasicHttpStub[MarkdownGenerator.HttpResponseR]() + ) + + def program(dir: Path) = + for { + _ <- zioLog.info("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.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 { + _ <- zioLog.info("write example {}", file.toString()) + f <- wopen(file) + data = mdg.generate(set) + _ <- ZIO.attempt(f.write(data)) + } yield () + +} diff --git a/backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuiteTest.scala b/backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuiteTest.scala new file mode 100644 index 00000000..510a86b5 --- /dev/null +++ b/backend/examples/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/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuiteWholeTest.scala b/backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/AsyncScalaTestSuiteWholeTest.scala new file mode 100644 index 00000000..c72463e8 --- /dev/null +++ b/backend/examples/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/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/MarkdownGeneratorSuite.scala b/backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/edsl/interpreter/MarkdownGeneratorSuite.scala new file mode 100644 index 00000000..dcdb11c9 --- /dev/null +++ b/backend/examples/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/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/CatsFacts.scala b/backend/examples/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/CatsFacts.scala new file mode 100644 index 00000000..6a4a1e09 --- /dev/null +++ b/backend/examples/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/integration/src/test/resources/logback-test.xml b/backend/integration/src/test/resources/logback-test.xml new file mode 100644 index 00000000..c11e0a5b --- /dev/null +++ b/backend/integration/src/test/resources/logback-test.xml @@ -0,0 +1,18 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n + + + + + + + + + + + + + + diff --git a/backend/integration/src/test/resources/test.conf b/backend/integration/src/test/resources/test.conf new file mode 100644 index 00000000..588b851b --- /dev/null +++ b/backend/integration/src/test/resources/test.conf @@ -0,0 +1,19 @@ +include classpath("application.conf") + +"secrets": { + "security": { + "secret": "secret" + }, + "mongodb": { + "uri": "mongodb://localhost/mockingbird" + } +} + +ru.tinkoff.tcb { + db.mongo = ${?secrets.mongodb} + server = ${?secrets.server} + security = ${secrets.security} + proxy = ${?secrets.proxy} + event = ${?secrets.event} +} + diff --git a/backend/integration/src/test/scala/ru/tinkoff/tcb/mockingbird/dal2/HttpStubDAOSpecBehaviors.scala b/backend/integration/src/test/scala/ru/tinkoff/tcb/mockingbird/dal2/HttpStubDAOSpecBehaviors.scala new file mode 100644 index 00000000..1035d99c --- /dev/null +++ b/backend/integration/src/test/scala/ru/tinkoff/tcb/mockingbird/dal2/HttpStubDAOSpecBehaviors.scala @@ -0,0 +1,621 @@ +package ru.tinkoff.tcb.mockingbird.dal2 + +import java.time.Instant +import java.time.temporal.ChronoUnit +import scala.concurrent.Await +import scala.concurrent.ExecutionContext +import scala.concurrent.Future +import scala.concurrent.duration.Duration +import scala.util.Random +import scala.util.matching.Regex + +import cats.Monad +import eu.timepit.refined.api.Refined +import eu.timepit.refined.auto.* +import eu.timepit.refined.collection.NonEmpty +import eu.timepit.refined.numeric.Positive +import eu.timepit.refined.refineV +import io.circe.Json +import io.circe.syntax.* +import io.scalaland.chimney.dsl.* +import org.scalacheck.Arbitrary +import org.scalacheck.Gen +import org.scalacheck.rng.Seed +import org.scalatest.EitherValues.* +import org.scalatest.FutureOutcome +import org.scalatest.OptionValues.* +import org.scalatest.funsuite.AsyncFunSuiteLike +import org.scalatest.matchers.should.Matchers +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks + +import ru.tinkoff.tcb.dataaccess.UpdateResult +import ru.tinkoff.tcb.mockingbird.api.request.StubPatch +import ru.tinkoff.tcb.mockingbird.model.* +import ru.tinkoff.tcb.predicatedsl.Keyword +import ru.tinkoff.tcb.utils.circe.optics.JsonOptic +import ru.tinkoff.tcb.utils.id.SID + +@SuppressWarnings( + Array( + "scalafix:DisableSyntax.mapAs" + ) +) +trait HttpStubDAOSpecBehaviors[F[_]] extends AsyncFunSuiteLike with Matchers with ScalaCheckDrivenPropertyChecks { + + // В некоторых случаях, можно встретить, что заглушки сравниваются как Json, + // после вызова asJson. Это связано с тем, что поле pathPattern имеет тип Option[Regex], + // а для Regex метод equals не сравнивает инстансы по значению, а только по ссылке. + // Можно было бы переопределеить org.scalatic.Equiality и использовать shouldEqual + // вместо shouldBe, но вывод Equality не является рекурсивным, поэтому переопределенный + // имплисит не будет использоваться ни для вложенных полей, ни для элементов коллеций. + // см. https://github.com/scalatest/scalatest/issues/917#issuecomment-227376714 + + implicit def M: Monad[F] + implicit def fToFuture[T](fwh: F[T]): Future[T] + + type TestName = String Refined NonEmpty + type CancelTestReason = String Refined NonEmpty + + /** + * canceledTests возвращает словарь содержащий имена тестов, которые необходимо отменить и причину отмены. Это может + * быть связано с тем, что конкретная реализация не поддерживает требуемую функциональность или это работает иначе, но + * без ущерба для конечного пользователя mockingbird. + */ + def canceledTests: Map[TestName, CancelTestReason] = Map.empty + + override def withFixture(test: NoArgAsyncTest): FutureOutcome = + canceledTests.get(refineV[NonEmpty](test.name).value).map(FutureOutcome.canceled(_)).getOrElse(test()) + + def dao: HttpStubDAO[F] + + import HttpStubDAOSpecBehaviors.* + + implicit override def executionContext: ExecutionContext = ExecutionContext.Implicits.global + + test("Сохранить, прочитать, обновить, удалить заглушку") { + val stub = genHttpStub() + val patch = genHttpStubPatch(stub.id, stub.serviceSuffix) + + val updStub = patch + .into[HttpStub] + .withFieldConst(_.created, stub.created) + .withFieldConst(_.serviceSuffix, stub.serviceSuffix) + .transform + + for { + ir <- dao.insert(stub) + _ = ir shouldBe 1 + + obtained <- dao.get(stub.id) + _ = obtained.map(_.asJson) shouldBe Some(stub.asJson) + + ur <- dao.update(patch) + _ = ur.successful shouldBe true + + obtained <- dao.get(stub.id) + _ = obtained.map(_.asJson) shouldBe Some(updStub.asJson) + + dr <- dao.delete(stub.id) + _ = dr shouldBe 1 + + obtained3 <- dao.get(stub.id) + } yield obtained3 shouldBe empty + } + + test("Сохранить, прочитать, обновить, удалить заглушку v2") { + val stubAndUpd: Gen[(HttpStub, StubPatch)] = + for { + stub <- gen.httpStub + patch <- gen.stubPatch(stub.id, stub.serviceSuffix) + } yield (stub, patch) + + implicit val arbStubAndUpd: Arbitrary[(HttpStub, StubPatch)] = Arbitrary(stubAndUpd) + + forAll { (sp: (HttpStub, StubPatch)) => + val (stub, patch) = sp + val updStub = patch + .into[HttpStub] + .withFieldConst(_.created, stub.created) + .withFieldConst(_.serviceSuffix, stub.serviceSuffix) + .transform + + val result = for { + ir <- dao.insert(stub) + _ = ir shouldBe 1 + + obtained <- dao.get(stub.id) + _ = obtained.map(_.asJson) shouldBe Some(stub.asJson) + + ur <- dao.update(patch) + _ = ur.successful shouldBe true + + obtained <- dao.get(stub.id) + _ = obtained.map(_.asJson) shouldBe Some(updStub.asJson) + + dr <- dao.delete(stub.id) + _ = dr shouldBe 1 + + obtained3 <- dao.get(stub.id) + } yield obtained3 shouldBe empty + + Await.result(result, Duration.Inf) + } + } + + test("Получение списка всех заглушек (fetch): без фильтров") { + val now = Instant.now().truncatedTo(ChronoUnit.MILLIS) // scalafix:ok + val stubs = (1 to 13).map(i => HttpStubDAOSpecBehaviors.genHttpStub(_.copy(created = now.minusMillis(i)))).toVector + + // Заглушки возвращаются отсортированными по дате создания в обратном + // порядке, т.е. те, что были созданы позже - в конце + val expected = stubs.sortBy(_.created)(Ordering[Instant].reverse) + + for { + _ <- Traverse[Vector].traverse(Random.shuffle(stubs))(dao.insert) + p1 <- dao.fetch(StubFetchParams(page = 0, query = None, service = None, labels = Seq.empty, count = 5)) + _ = p1 should have size 5 + p2 <- dao.fetch(StubFetchParams(page = 1, query = None, service = None, labels = Seq.empty, count = 5)) + _ = p2 should have size 5 + p3 <- dao.fetch(StubFetchParams(page = 2, query = None, service = None, labels = Seq.empty, count = 5)) + _ = p3 should have size 3 + } yield (p1 ++ p2 ++ p3).map(_.asJson) shouldBe expected.map(_.asJson) + } + + test("Получение списка всех заглушек (fetch): фильтр query == ID") { + val stubs = (1 to 5).toVector.map(_ => HttpStubDAOSpecBehaviors.genHttpStub()) + + for { + _ <- Traverse[Vector].traverse(stubs)(dao.insert) + expected = stubs(2) + obtained <- dao.fetch( + StubFetchParams(page = 0, query = Some(expected.id), service = None, labels = Seq.empty, count = 5) + ) + } yield obtained.map(_.asJson) shouldBe Vector(expected.asJson) + } + + test("Получение списка всех заглушек (fetch): фильтр query по name") { + val partName = "desired" + val desiredStubs = + (1 to 3).toVector.map(_ => HttpStubDAOSpecBehaviors.genHttpStub(s => s.copy(name = s.name + partName))) + + val otherStubs = (1 to 10).toVector.map(_ => genHttpStub()) + val stubs = Random.shuffle(desiredStubs ++ otherStubs) + val expected = desiredStubs.sortBy(_.id) + + for { + _ <- Traverse[Vector].traverse(stubs)(dao.insert) + obtained <- dao.fetch( + StubFetchParams(page = 0, query = Some(partName), service = None, labels = Seq.empty, count = 5) + ) + } yield obtained.sortBy(_.id).map(_.asJson) shouldBe expected.map(_.asJson) + } + + test("Получение списка всех заглушек (fetch): фильтр query по path") { + val partPath = "desired" + val desiredStubs = + (1 to 3).toVector.map(_ => + HttpStubDAOSpecBehaviors.genHttpStub(s => + s.copy( + path = Some(s"/${s.serviceSuffix}/api/$partPath/${genAlphanum(10)}"), + pathPattern = None + ) + ) + ) + val otherStubs = (1 to 10).toVector.map(_ => genHttpStub()) + val stubs = Random.shuffle(desiredStubs ++ otherStubs) + val expected = desiredStubs.sortBy(_.id) + + for { + _ <- Traverse[Vector].traverse(stubs)(dao.insert) + obtained <- dao.fetch( + StubFetchParams(page = 0, query = Some(partPath), service = None, labels = Seq.empty, count = 5) + ) + } yield obtained.sortBy(_.id).map(_.asJson) shouldBe expected.map(_.asJson) + } + + test("Получение списка всех заглушек (fetch): фильтр query по pathPattern") { + val partPath = "desired" + val desiredStubs = + (1 to 3).toVector.map(_ => + HttpStubDAOSpecBehaviors.genHttpStub(s => + s.copy( + path = None, + pathPattern = Some(s"/${s.serviceSuffix}/api/$partPath/${genAlphanum(10)}/[0-9]+".r) + ) + ) + ) + val otherStubs = (1 to 10).toVector.map(_ => genHttpStub()) + val stubs = Random.shuffle(desiredStubs ++ otherStubs) + val expected = desiredStubs.sortBy(_.id) + + for { + _ <- Traverse[Vector].traverse(stubs)(dao.insert) + obtained <- dao.fetch( + StubFetchParams(page = 0, query = Some(partPath), service = None, labels = Seq.empty, count = 5) + ) + } yield obtained.sortBy(_.id).map(_.asJson) shouldBe expected.map(_.asJson) + } + + test("Получение списка всех заглушек (fetch): фильтр сервис") { + val serviceName = "svc1" + val desiredStubs = + (1 to 7).toVector.map(_ => genHttpStub(_.copy(serviceSuffix = serviceName))) + val otherStubs = (1 to 10).toVector.map(_ => genHttpStub()) + val stubs = Random.shuffle(desiredStubs ++ otherStubs) + val expected = desiredStubs.sortBy(_.id) + + for { + _ <- Traverse[Vector].traverse(stubs)(dao.insert) + + p1 <- dao.fetch( + StubFetchParams(page = 0, query = None, service = Some(serviceName), labels = Seq.empty, count = 5) + ) + _ = p1 should have size 5 + + p2 <- dao.fetch( + StubFetchParams(page = 1, query = None, service = Some(serviceName), labels = Seq.empty, count = 5) + ) + _ = p2 should have size 2 + } yield (p1 ++ p2).sortBy(_.id).map(_.asJson) shouldBe expected.map(_.asJson) + } + + test("Получение списка всех заглушек (fetch): фильтр лейблы") { + val desiredStubs = Vector( + genHttpStub(_.copy(labels = Seq("l1", "l2"))), + genHttpStub(_.copy(labels = Seq("l2", "l1"))), + genHttpStub(_.copy(labels = Seq("l1", "l3", "l2"))), + ) + + val otherStubs = Vector( + genHttpStub(_.copy(labels = Seq("l1"))), + genHttpStub(_.copy(labels = Seq("l2"))), + genHttpStub(), + genHttpStub(), + ) + val stubs = Random.shuffle(desiredStubs ++ otherStubs) + val expected = desiredStubs.sortBy(_.id) + + for { + _ <- Traverse[Vector].traverse(stubs)(dao.insert) + + obtained <- dao.fetch( + StubFetchParams(page = 0, query = None, service = None, labels = Seq("l1", "l2"), count = 5) + ) + } yield obtained.sortBy(_.id).map(_.asJson) shouldBe expected.map(_.asJson) + } + + test("Поиск заглушек (find): указан path") { + val serviceName = "svc1" + val scope = genScope() + val path = refineV[NonEmpty](s"/$serviceName/api/obj").value + val method = HttpMethod.Get + + val desired = + (1 to 2).toVector.map(_ => + genHttpStub( + _.copy( + scope = scope, + serviceSuffix = serviceName, + path = Some(path), + pathPattern = None, + method = method, + times = Some(1), + ) + ) + ) + + val likeDesired = Vector( + // Метод find возвращает только заглушки с times > 0 + genHttpStub( + _.copy( + scope = scope, + serviceSuffix = serviceName, + path = Some(path), + pathPattern = None, + method = method, + times = Some(0), + ) + ), + // Сейчас поиск идет для path, поэтому с таким же pathPattern должен пропустить + genHttpStub( + _.copy( + scope = scope, + serviceSuffix = serviceName, + path = None, + pathPattern = Some(new Regex(path)), + method = method, + times = Some(1), + ) + ) + ) + + val other = (1 to 3).toVector.map(_ => genHttpStub()) + val stubs = Random.shuffle(desired ++ likeDesired ++ other) + val expected = desired.sortBy(_.id) + + for { + _ <- Traverse[Vector].traverse(stubs)(dao.insert) + obtained <- dao.find( + StubFindParams(scope = scope, path = StubExactlyPath(path), method = method) + ) + } yield obtained.sortBy(_.id).map(_.asJson) shouldBe expected.map(_.asJson) + } + + test("Поиск заглушек (find): указан pathPattern") { + val serviceName = "svc2" + val scope = genScope() + val pathPattern = s"/$serviceName/api/obj/(?[A-z][A-z0-9]+)/[0-9]+".r + val method = HttpMethod.Get + + val desired = + (1 to 2).toVector.map(_ => + genHttpStub( + _.copy( + scope = scope, + serviceSuffix = serviceName, + path = None, + pathPattern = Some(pathPattern), + method = method, + times = Some(1), + ) + ) + ) + + val likeDesired = Vector( + // Метод find возвращает только заглушки с times > 0 + genHttpStub( + _.copy( + scope = scope, + serviceSuffix = serviceName, + path = None, + pathPattern = Some(pathPattern), + method = method, + times = Some(0), + ) + ), + // Сейчас поиск идет для pathPattern, поэтому с таким же path пропускаем + genHttpStub( + _.copy( + scope = scope, + serviceSuffix = serviceName, + path = Some(pathPattern.toString), + pathPattern = None, + method = method, + times = Some(1), + ) + ) + ) + + val other = (1 to 3).toVector.map(_ => genHttpStub()) + val stubs = Random.shuffle(desired ++ likeDesired ++ other) + val expected = desired.sortBy(_.id) + + for { + _ <- Traverse[Vector].traverse(stubs)(dao.insert) + obtained <- dao.find( + StubFindParams(scope = scope, path = StubPathPattern(pathPattern), method = method) + ) + } yield obtained.sortBy(_.id).map(_.asJson) shouldBe expected.map(_.asJson) + } + + test("Поисх подходящих заглушек (findMatch)") { + val desiredStubs = Vector( + genHttpStub(_.copy(scope = Scope.Countdown, method = HttpMethod.Get, path = Some("/api/obj/123"))), + genHttpStub(_.copy(scope = Scope.Countdown, method = HttpMethod.Get, path = Some("/api/obj/123"))), + genHttpStub(_.copy(scope = Scope.Countdown, method = HttpMethod.Get, pathPattern = Some("/api/obj/[0-9]+".r))), + genHttpStub( + _.copy( + scope = Scope.Countdown, + method = HttpMethod.Get, + pathPattern = Some("/api/(?[a-z]+)/(?[0-9]+)".r) + ) + ), + ) + + val otherStubs = + desiredStubs.map(_.copy(scope = Scope.Persistent, id = SID.random[HttpStub])) ++ + desiredStubs.map(_.copy(method = HttpMethod.Post, id = SID.random[HttpStub])) ++ + desiredStubs.map(_.copy(times = Some(0), id = SID.random[HttpStub])) ++ + (1 to 10).toVector.map(_ => genHttpStub()) + + val stubs = Random.shuffle(desiredStubs ++ otherStubs) + val expected = desiredStubs.sortBy(_.id) + + for { + _ <- Traverse[Vector].traverse(stubs)(dao.insert) + + obtained <- dao.findMatch( + StubMatchParams(scope = Scope.Countdown, path = "/api/obj/123", method = HttpMethod.Get) + ) + } yield obtained.sortBy(_.id).map(_.asJson) shouldBe expected.map(_.asJson) + } + + test("Удаление заглушек с истекшей датой (deleteExpired)") { + val threshold = Instant.now().truncatedTo(ChronoUnit.MILLIS) // scalafix:ok + val created = threshold.minusMillis(1) + + val expired = + (1 to 3).map(_ => genHttpStub(_.copy(scope = Scope.Countdown, created = created))).toVector ++ + (1 to 3).map(_ => genHttpStub(_.copy(scope = Scope.Ephemeral, created = created))).toVector + + val mustRest = + (1 to 3).map(_ => genHttpStub(_.copy(scope = Scope.Persistent, created = created))).toVector ++ + (1 to 3).map(_ => genHttpStub(_.copy(scope = Scope.Ephemeral, created = threshold))).toVector + (1 to 3).map(_ => genHttpStub(_.copy(scope = Scope.Countdown, created = threshold))).toVector + + val stubs = Random.shuffle(expired ++ mustRest) + val expected = mustRest.sortBy(_.id) + + for { + _ <- Traverse[Vector].traverse(stubs)(dao.insert) + + wasDeleted <- dao.deleteExpired(threshold) + _ = wasDeleted shouldBe expired.size + + obtained <- dao.fetch( + StubFetchParams( + page = 0, + query = None, + service = None, + labels = Seq.empty, + count = refineV[Positive](stubs.size).value + ) + ) + } yield obtained.sortBy(_.id).map(_.asJson) shouldBe expected.map(_.asJson) + } + + test("Удаление отработанных countdown заглушек (deleteDepleted)") { + val depleted = + (1 to 5).map(_ => genHttpStub(_.copy(scope = Scope.Countdown, times = Some(0)))).toVector ++ + (1 to 5).map(_ => genHttpStub(_.copy(scope = Scope.Countdown, times = Some(-1)))).toVector + + val mustRest = + (1 to 5).map(_ => genHttpStub(_.copy(scope = Scope.Persistent, times = Some(0)))).toVector ++ + (1 to 5).map(_ => genHttpStub(_.copy(scope = Scope.Ephemeral, times = Some(0)))).toVector + + val stubs = Random.shuffle(depleted ++ mustRest) + val expected = mustRest.sortBy(_.id) + + for { + _ <- Traverse[Vector].traverse(stubs)(dao.insert) + + wasDeleted <- dao.deleteDepleted() + _ = wasDeleted shouldBe depleted.size + + obtained <- dao.fetch( + StubFetchParams( + page = 0, + query = None, + service = None, + labels = Seq.empty, + count = refineV[Positive](stubs.size).value + ) + ) + } yield obtained.sortBy(_.id).map(_.asJson) shouldBe expected.map(_.asJson) + } + + test("Инкремент поля times заглушки (incTimesById)") { + val stub = genHttpStub(_.copy(times = Some(2))) + + for { + _ <- dao.insert(stub) + + res <- dao.incTimesById(stub.id, -1) + _ = res shouldBe UpdateResult(1, 1) + + obtained <- dao.get(stub.id) + } yield obtained.value.times shouldBe Some(1) + } +} + +object HttpStubDAOSpecBehaviors { + def genAlphanum(length: Int): String = + Random.alphanumeric.take(length).mkString + + def genScope(): Scope = + gen.scope.pureApply(Gen.Parameters.default, Seed.random()) + + def genHttpStub(m: HttpStub => HttpStub = identity): HttpStub = + gen.httpStub.map(m).pureApply(Gen.Parameters.default, Seed.random()) + + def genHttpStubPatch(id: SID[HttpStub], service: String, m: StubPatch => StubPatch = identity): StubPatch = + gen.stubPatch(id, service).map(m).pureApply(Gen.Parameters.default, Seed.random()) + + object gen { + val scope: Gen[Scope] = Gen.oneOf(Scope.values) + + def sid[T]: Gen[SID[T]] = Gen.uuid.map(u => SID[T](u.toString)) + + def serviceSuffix: Gen[String] = Gen.alphaNumStr.map(s => "svc-" ++ s.take(5).toLowerCase()) + + def method: Gen[HttpMethod] = Gen.oneOf(HttpMethod.values) + + def path(service: String): Gen[String] = { + val partGen = Gen.alphaNumStr.map(_.take(7).toLowerCase()) + Gen.containerOfN[Seq, String](2, partGen).map { ss => + val parts = ss.filter(_.nonEmpty) + parts.mkString(s"/$service/", "/", "/") + } + } + + def pathPattern(service: String): Gen[Regex] = + path(service).map(p => new Regex(p ++ "[A-z0-9]+")) + + def times(scope: Scope): Gen[Option[Int]] = + if (scope == Scope.Countdown) Gen.choose[Int](1, 10).map(Some(_)) + else Gen.const(Some(1)) + + /** + * A generator that generates an Instant in range [now - 7 days, now + 2 days] + */ + val instant: Gen[Instant] = { + val up = 2.days.toMillis() + val down = -7.days.toMillis() + Gen + .choose(down, up) + .map(d => Instant.now().truncatedTo(ChronoUnit.MILLIS).plusMillis(d)) // scalafix:ok + } + + val httpStub: Gen[HttpStub] = for { + id <- sid[HttpStub] + created <- instant + scope <- scope + times <- times(scope) + service <- serviceSuffix + name <- Gen.asciiPrintableStr + method <- method + pathOrPattern <- Gen.either(path(service), pathPattern(service)) + labelsCount <- Gen.choose(0, 5) + labels <- Gen.containerOfN[Seq, String](labelsCount, Gen.alphaNumStr.map(_.take(5).toLowerCase())) + } yield HttpStub( + id = id, + created = created, + scope = scope, + times = times, + serviceSuffix = service, + name = name, + method = method, + path = pathOrPattern.left.toOption, + pathPattern = pathOrPattern.toOption, + seed = None, + state = None, + request = RequestWithoutBody( + headers = Map.empty, + query = Map(JsonOptic.forPath(genAlphanum(3)) -> Map(Keyword.Equals -> Json.fromString(genAlphanum(4)))) + ), + persist = None, + response = RawResponse(code = 200, headers = Map.empty, body = "OK", delay = None), + callback = None, + labels = labels, + ) + + def stubPatch(id: SID[HttpStub], service: String): Gen[StubPatch] = for { + scope <- scope + times <- times(scope) + name <- Gen.asciiPrintableStr + method <- method + pathOrPattern <- Gen.either(path(service), pathPattern(service)) + labelsCount <- Gen.choose(0, 5) + labels <- Gen.containerOfN[Seq, String](labelsCount, Gen.alphaNumStr.map(_.take(5).toLowerCase())) + } yield StubPatch( + id = id, + scope = scope, + times = times, + name = name, + method = method, + path = pathOrPattern.left.toOption, + pathPattern = pathOrPattern.toOption, + seed = None, + state = None, + request = RequestWithoutBody( + headers = Map.empty, + query = Map(JsonOptic.forPath(genAlphanum(3)) -> Map(Keyword.Equals -> Json.fromString(genAlphanum(4)))) + ), + persist = None, + response = RawResponse(code = 200, headers = Map.empty, body = "OK", delay = None), + callback = None, + labels = labels, + ) + } + +} diff --git a/backend/integration/src/test/scala/ru/tinkoff/tcb/mockingbird/dal2/mongo/HttpStubDAOSpec.scala b/backend/integration/src/test/scala/ru/tinkoff/tcb/mockingbird/dal2/mongo/HttpStubDAOSpec.scala new file mode 100644 index 00000000..bd564962 --- /dev/null +++ b/backend/integration/src/test/scala/ru/tinkoff/tcb/mockingbird/dal2/mongo/HttpStubDAOSpec.scala @@ -0,0 +1,76 @@ +package ru.tinkoff.tcb.mockingbird.dal2.mongo + +import java.util.UUID +import scala.concurrent.Await +import scala.concurrent.Future +import scala.concurrent.duration.Duration + +import com.dimafeng.testcontainers.ContainerDef +import com.dimafeng.testcontainers.GenericContainer +import com.dimafeng.testcontainers.scalatest.TestContainerForAll +import eu.timepit.refined.auto.* +import eu.timepit.refined.collection.NonEmpty +import eu.timepit.refined.refineMV +import org.mongodb.scala.MongoClient +import org.mongodb.scala.MongoCollection +import org.mongodb.scala.MongoDatabase +import org.mongodb.scala.bson.BsonDocument +import org.scalatest.BeforeAndAfterEach +import org.scalatest.funsuite.AsyncFunSuite +import org.scalatest.matchers.should.Matchers + +import ru.tinkoff.tcb.mockingbird.dal2 + +class HttpStubDAOSpec + extends AsyncFunSuite + with TestContainerForAll + with BeforeAndAfterEach + with Matchers + with dal2.HttpStubDAOSpecBehaviors[Task] { + + val mongoExposedPort = 27017 + + private var mongoClient: MongoClient = _ + private var mongoDb: MongoDatabase = _ + private var dao_ : dal2.HttpStubDAO[Task] = _ + + val M: Monad[Task] = zio.interop.catz.taskConcurrentInstance + def fToFuture[T](fwh: Task[T]): Future[T] = Unsafe.unsafe { implicit unsafe => + Runtime.default.unsafe.runToFuture(fwh) + } + + def dao: dal2.HttpStubDAO[Task] = dao_ + + override val containerDef: ContainerDef = GenericContainer.Def( + dockerImage = "mongo", + exposedPorts = Seq(mongoExposedPort), + ) + + override def afterContainersStart(containers: Containers): Unit = { + super.afterContainersStart(containers) + val c = containers.asInstanceOf[GenericContainer] + mongoClient = MongoClient(s"mongodb://${c.containerIpAddress}:${c.mappedPort(mongoExposedPort)}") + mongoDb = mongoClient.getDatabase(UUID.randomUUID().toString()) + } + + override def beforeContainersStop(containers: Containers): Unit = { + mongoClient.close() + super.beforeContainersStop(containers) + } + + override protected def beforeEach(): Unit = { + val collection: MongoCollection[BsonDocument] = mongoDb.getCollection(UUID.randomUUID().toString()) + dao_ = Await.result(fToFuture(HttpStubDAOImpl.create(collection)), Duration.Inf) + super.beforeEach() + } + + override protected def afterEach(): Unit = + super.afterEach() + + override val canceledTests: Map[TestName, CancelTestReason] = Map( + refineMV[NonEmpty]("Получение списка всех заглушек (fetch): фильтр query по pathPattern") -> + """MongoDB не позволяет искать по вхождению подстроки в регулярное выражение, нет +возможности преобразовать регулярное выражение в обычную строку над которой +возможна операция regexMatch.""" + ) +} diff --git a/backend/integration/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/mongo/BasicHttpStubSuite.scala b/backend/integration/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/mongo/BasicHttpStubSuite.scala new file mode 100644 index 00000000..d6c4f60d --- /dev/null +++ b/backend/integration/src/test/scala/ru/tinkoff/tcb/mockingbird/examples/mongo/BasicHttpStubSuite.scala @@ -0,0 +1,131 @@ +package ru.tinkoff.tcb.mockingbird.examples.mongo + +import java.net.ServerSocket +import scala.concurrent.Await +import scala.util.Using + +import com.dimafeng.testcontainers.ContainerDef +import com.dimafeng.testcontainers.GenericContainer +import com.dimafeng.testcontainers.scalatest.TestContainerForAll +import sttp.capabilities.WebSockets +import sttp.capabilities.zio.ZioStreams +import sttp.client3.* +import sttp.client3.httpclient.zio.HttpClientZioBackend +import sttp.model.Uri +import zio.Exit.Failure +import zio.Exit.Success + +import ru.tinkoff.tcb.mockingbird.Mockingbird +import ru.tinkoff.tcb.mockingbird.config.MockingbirdConfiguration +import ru.tinkoff.tcb.mockingbird.config.MongoConfig +import ru.tinkoff.tcb.mockingbird.config.SecurityConfig +import ru.tinkoff.tcb.mockingbird.config.ServerConfig +import ru.tinkoff.tcb.mockingbird.edsl.interpreter.AsyncScalaTestSuite +import ru.tinkoff.tcb.mockingbird.examples + +class BasicHttpStubSuite extends AsyncScalaTestSuite with TestContainerForAll { + import HttpStubSuite.* + + private val set = new examples.BasicHttpStub[HttpResponseR]() + + var httpPort = 0 + val mongoExposedPort = 27017 + var mockingbird: Option[CancelableFuture[Nothing]] = None + + override def baseUri: Uri = uri"http://localhost:$httpPort" + + override val containerDef: ContainerDef = GenericContainer.Def( + dockerImage = "mongo", + exposedPorts = Seq(mongoExposedPort), + ) + + override def afterContainersStart(containers: Containers): Unit = { + super.afterContainersStart(containers) + val c = containers.asInstanceOf[GenericContainer] + val connectionString = s"mongodb://${c.containerIpAddress}:${c.mappedPort(mongoExposedPort)}/mockingbird-db" + + Using.Manager { use => + val ss1 = use(new ServerSocket(0)) + + httpPort = ss1.getLocalPort() + }.get + + val server = Unsafe.unsafe { implicit us => + val cfg = MockingbirdConfiguration.live + .update[MongoConfig](_.copy(uri = connectionString)) + .update[ServerConfig](_.copy(port = httpPort, healthCheckRoute = "/ready".some, allowedOrigins = Seq("*"))) + .update[SecurityConfig](_.copy(secret = "secret")) + Runtime.default.unsafe.runToFuture(Mockingbird.app(cfg)) + } + + mockingbird = server.some + + val e = Unsafe.unsafe { implicit unsafe => + Runtime.default.unsafe.run( + (for { + _ <- waitForReadiness(baseUri, server) + r <- initTestPrerequisites(baseUri) + } yield r).provide(HttpClientZioBackend.layer()) + ) + } + + e match { + case Success(Response(Right(_), _, _, _, _, _)) => info("Mockingbird server started") + case Success(Response(Left(body), code, _, _, _, _)) => fail(s"service creating failed, code $code: $body") + case Failure(cause) => + if (server.isCompleted) { + val res = scala.util.Try(Await.result(server, scala.concurrent.duration.Duration.Inf)).failed.get + fail(s"server failed: ${toCauses(res).map(_.getMessage()).mkString("\n - ", "\n - ", "\n")}") + } else fail(s"$cause") + } + } + + override def beforeContainersStop(containers: Containers): Unit = { + mockingbird.foreach(s => Await.ready(s.cancel(), scala.concurrent.duration.Duration.Inf)) + super.beforeContainersStop(containers) + } + + generateTests(set) +} + +object HttpStubSuite { + def waitForReadiness( + host: Uri, + server: CancelableFuture[Nothing] + ): RIO[SttpBackend[Task, ZioStreams with WebSockets], Unit] = { + @annotation.nowarn("msg=a type was inferred to be `Any`") + val policy = Schedule.fixed(3.second) && Schedule.recurs(10) && Schedule.recurWhile((_: Any) => !server.isCompleted) + + val probe = for { + sb <- ZIO.service[SttpBackend[Task, ZioStreams with WebSockets]] + _ <- zio.Console.printLine("waiting for server ready...") + r <- quickRequest.readTimeout(5.seconds.asScala).get(host.withPath("ready")).send(sb) + _ <- + if (r.code.code / 100 != 2) ZIO.fail(new Exception(s"waiting for 2xx, but got $r")) + else ZIO.succeed(()) + } yield () + + probe.retry(policy) + } + + def initTestPrerequisites( + host: Uri + ): RIO[SttpBackend[Task, ZioStreams with WebSockets], Response[Either[String, String]]] = + for { + sb <- ZIO.service[SttpBackend[Task, ZioStreams with WebSockets]] + r <- basicRequest + .body("""{ "suffix": "alpha", "name": "Test Service" }""") + .post(host.withPath("api/internal/mockingbird/v2/service".split("/"))) + .send(sb) + } yield r + + def toCauses(src: Throwable): List[Throwable] = { + val b = List.newBuilder[Throwable] + var e = src + while (e != null) { + b += e + e = e.getCause() + } + b.result() + } +} diff --git a/backend/mockingbird-api/src/main/scala/ru/tinkoff/tcb/mockingbird/Mockingbird.scala b/backend/mockingbird-api/src/main/scala/ru/tinkoff/tcb/mockingbird/Mockingbird.scala index 3466ec55..fd8af5a1 100644 --- a/backend/mockingbird-api/src/main/scala/ru/tinkoff/tcb/mockingbird/Mockingbird.scala +++ b/backend/mockingbird-api/src/main/scala/ru/tinkoff/tcb/mockingbird/Mockingbird.scala @@ -39,6 +39,7 @@ import ru.tinkoff.tcb.mockingbird.config.ProxyConfig import ru.tinkoff.tcb.mockingbird.config.ProxyServerType import ru.tinkoff.tcb.mockingbird.config.ServerConfig import ru.tinkoff.tcb.mockingbird.dal.* +import ru.tinkoff.tcb.mockingbird.dal2 import ru.tinkoff.tcb.mockingbird.grpc.GrpcRequestHandler import ru.tinkoff.tcb.mockingbird.grpc.GrpcRequestHandlerImpl import ru.tinkoff.tcb.mockingbird.grpc.GrpcStubResolverImpl @@ -52,7 +53,7 @@ import ru.tinkoff.tcb.mockingbird.stream.EventSpawner import ru.tinkoff.tcb.mockingbird.stream.SDFetcher import ru.tinkoff.tcb.utils.metrics.makeRegistry -object Mockingbird extends scala.App { +object Mockingbird { type FL = WLD & ServerConfig & PublicHttp & EventSpawner & ResourceManager & EphemeralCleaner & GrpcRequestHandler private val zioLog: Logging[UIO] = new ZUniversalLogging(this.getClass.getName) @@ -91,16 +92,12 @@ object Mockingbird extends scala.App { .use(_ => ZIO.never) .exitCode - private val server = ZLayer.fromZIO { + private def server(config: MockingbirdConfiguration.Layer) = ZLayer.fromZIO { program .provide( Tracing.live, ZLayer.succeed(makeRegistry("mockingbird")), - MockingbirdConfiguration.server, - MockingbirdConfiguration.security, - MockingbirdConfiguration.mongo, - MockingbirdConfiguration.proxy, - MockingbirdConfiguration.event, + config, ZLayer.scoped { for { pc <- ZIO.service[ProxyConfig] @@ -144,7 +141,7 @@ object Mockingbird extends scala.App { }, mongoLayer, aesEncoder, - collection(_.stub) >>> HttpStubDAOImpl.live, + collection(_.stub) >>> dal2.mongo.HttpStubDAOImpl.live, collection(_.state) >>> PersistentStateDAOImpl.live, collection(_.scenario) >>> ScenarioDAOImpl.live, collection(_.service) >>> ServiceDAOImpl.live, @@ -201,14 +198,20 @@ object Mockingbird extends scala.App { _ = builder.fallbackHandlerRegistry(mutableRegistry) } yield () - Unsafe.unsafe { implicit us => - wldRuntime.unsafe.run { - (registry *> zioLog.info(s"GRPC server started at port: $port") *> ZIO.scoped[Any]( - server.build *> ZIO.never - )) - .catchAll(ex => zioLog.errorCause(ex.getMessage, ex)) - .catchAllDefect(ex => zioLog.errorCause(ex.getMessage, ex)) - .exitCode + def app(config: MockingbirdConfiguration.Layer) = + registry *> zioLog.info(s"GRPC server started at port: $port") *> ZIO.scoped[Any]( + server(config).build *> ZIO.never + ) + + def main(args: Array[String]): Unit = { + val result = Unsafe.unsafe { implicit us => + wldRuntime.unsafe.run { + app(MockingbirdConfiguration.live) + .catchAll(ex => zioLog.errorCause(ex.getMessage, ex)) + .catchAllDefect(ex => zioLog.errorCause(ex.getMessage, ex)) + .exitCode + } } + } } diff --git a/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/api/AdminApiHandler.scala b/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/api/AdminApiHandler.scala index d6a98efb..6607bf25 100644 --- a/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/api/AdminApiHandler.scala +++ b/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/api/AdminApiHandler.scala @@ -2,6 +2,9 @@ package ru.tinkoff.tcb.mockingbird.api import scala.util.control.NonFatal +import eu.timepit.refined.auto.* +import eu.timepit.refined.numeric.NonNegative +import eu.timepit.refined.refineV import io.circe.Json import io.circe.parser.parse import io.scalaland.chimney.dsl.* @@ -19,12 +22,16 @@ import ru.tinkoff.tcb.mockingbird.api.response.OperationResult import ru.tinkoff.tcb.mockingbird.api.response.SourceDTO import ru.tinkoff.tcb.mockingbird.dal.DestinationConfigurationDAO import ru.tinkoff.tcb.mockingbird.dal.GrpcStubDAO -import ru.tinkoff.tcb.mockingbird.dal.HttpStubDAO import ru.tinkoff.tcb.mockingbird.dal.LabelDAO import ru.tinkoff.tcb.mockingbird.dal.PersistentStateDAO import ru.tinkoff.tcb.mockingbird.dal.ScenarioDAO import ru.tinkoff.tcb.mockingbird.dal.ServiceDAO import ru.tinkoff.tcb.mockingbird.dal.SourceConfigurationDAO +import ru.tinkoff.tcb.mockingbird.dal2.HttpStubDAO +import ru.tinkoff.tcb.mockingbird.dal2.StubExactlyPath +import ru.tinkoff.tcb.mockingbird.dal2.StubFetchParams +import ru.tinkoff.tcb.mockingbird.dal2.StubFindParams +import ru.tinkoff.tcb.mockingbird.dal2.StubPathPattern import ru.tinkoff.tcb.mockingbird.error.* import ru.tinkoff.tcb.mockingbird.error.DuplicationError import ru.tinkoff.tcb.mockingbird.error.ValidationError @@ -78,14 +85,12 @@ final class AdminApiHandler( ) ) service = service1.orElse(service2).get - candidates0 <- stubDAO.findChunk( - prop[HttpStub](_.method) === body.method && - (if (body.path.isDefined) prop[HttpStub](_.path) === body.path.map(_.value) - else prop[HttpStub](_.pathPattern) === body.pathPattern) && - prop[HttpStub](_.scope) === body.scope && - prop[HttpStub](_.times) > Option(0), - 0, - Int.MaxValue + candidates0 <- stubDAO.find( + StubFindParams( + body.scope, + body.path.map(StubExactlyPath).getOrElse(body.pathPattern.map(StubPathPattern).get), + body.method + ) ) candidates1 = candidates0.filter(_.request == body.request) candidates2 = candidates1.filter(_.state == body.state) @@ -224,25 +229,16 @@ final class AdminApiHandler( query: Option[String], service: Option[String], labels: List[String] - ): RIO[WLD, Vector[HttpStub]] = { - var queryDoc = - prop[HttpStub](_.scope) =/= Scope.Countdown.asInstanceOf[Scope] || prop[HttpStub](_.times) > Option(0) - if (query.isDefined) { - val qs = query.get - val q = prop[HttpStub](_.id) === SID[HttpStub](qs).asInstanceOf[SID[HttpStub]] || - prop[HttpStub](_.name).regex(qs, "i") || - prop[HttpStub](_.path).regex(qs, "i") || - prop[HttpStub](_.pathPattern).regex(qs, "i") - queryDoc = queryDoc && q - } - if (service.isDefined) { - queryDoc = queryDoc && (prop[HttpStub](_.serviceSuffix) === service.get) - } - if (labels.nonEmpty) { - queryDoc = queryDoc && (prop[HttpStub](_.labels).containsAll(labels)) - } - stubDAO.findChunk(queryDoc, page.getOrElse(0) * 20, 20, prop[HttpStub](_.created).sort(Desc)) - } + ): RIO[WLD, Vector[HttpStub]] = + stubDAO.fetch( + StubFetchParams( + page.flatMap(refineV[NonNegative](_).toOption).getOrElse(0), + query, + service, + labels, + 20 + ) + ) def fetchScenarios( page: Option[Int], @@ -270,13 +266,13 @@ final class AdminApiHandler( } def getStub(id: SID[HttpStub]): RIO[WLD, Option[HttpStub]] = - stubDAO.findById(id) + stubDAO.get(id) def getScenario(id: SID[Scenario]): RIO[WLD, Option[Scenario]] = scenarioDAO.findById(id) def deleteStub2(id: SID[HttpStub]): RIO[WLD, OperationResult[String]] = - ZIO.ifZIO(stubDAO.deleteById(id).map(_ > 0))( + ZIO.ifZIO(stubDAO.delete(id).map(_ > 0))( ZIO.succeed(OperationResult("success")), ZIO.succeed(OperationResult("nothing deleted")) ) @@ -299,16 +295,15 @@ final class AdminApiHandler( ) ) service = service1.orElse(service2).get - candidates0 <- stubDAO.findChunk( - where(_._id =/= id) && - prop[HttpStub](_.method) === body.method && - (if (body.path.isDefined) prop[HttpStub](_.path) === body.path.map(_.value) - else prop[HttpStub](_.pathPattern) === body.pathPattern) && - prop[HttpStub](_.scope) === body.scope && - prop[HttpStub](_.times) > Option(0), - 0, - Int.MaxValue - ) + candidates0 <- stubDAO + .find( + StubFindParams( + body.scope, + body.path.map(StubExactlyPath).getOrElse(body.pathPattern.map(StubPathPattern).get), + body.method + ) + ) + .map(_.filter(_.id != id)) candidates1 = candidates0.filter(_.request == body.request) candidates2 = candidates1.filter(_.state == body.state) _ <- ZIO.when(candidates2.nonEmpty)( @@ -333,7 +328,7 @@ final class AdminApiHandler( destNames = destinations.map(_.name).toSet vr = HttpStub.validationRules(destNames)(stub) _ <- ZIO.when(vr.nonEmpty)(ZIO.fail(ValidationError(vr))) - res <- stubDAO.patch(stubPatch) + res <- stubDAO.update(stubPatch) _ <- labelDAO.ensureLabels(service.suffix, stubPatch.labels.to(Vector)) } yield if (res.successful) OperationResult("success", stub.id) else OperationResult("nothing updated") diff --git a/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/api/PublicApiHandler.scala b/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/api/PublicApiHandler.scala index edb904d9..daf6ee76 100644 --- a/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/api/PublicApiHandler.scala +++ b/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/api/PublicApiHandler.scala @@ -14,19 +14,16 @@ import sttp.client3.circe.* import sttp.model.Method import zio.interop.catz.core.* -import ru.tinkoff.tcb.criteria.* -import ru.tinkoff.tcb.criteria.Typed.* import ru.tinkoff.tcb.logging.MDCLogging import ru.tinkoff.tcb.mockingbird.config.ProxyConfig -import ru.tinkoff.tcb.mockingbird.dal.HttpStubDAO import ru.tinkoff.tcb.mockingbird.dal.PersistentStateDAO +import ru.tinkoff.tcb.mockingbird.dal2.HttpStubDAO import ru.tinkoff.tcb.mockingbird.error.* import ru.tinkoff.tcb.mockingbird.misc.Renderable.ops.* import ru.tinkoff.tcb.mockingbird.model.AbsentRequestBody import ru.tinkoff.tcb.mockingbird.model.BinaryResponse import ru.tinkoff.tcb.mockingbird.model.ByteArray import ru.tinkoff.tcb.mockingbird.model.HttpMethod -import ru.tinkoff.tcb.mockingbird.model.HttpStub import ru.tinkoff.tcb.mockingbird.model.HttpStubResponse import ru.tinkoff.tcb.mockingbird.model.JsonProxyResponse import ru.tinkoff.tcb.mockingbird.model.MultipartRequestBody @@ -129,7 +126,7 @@ final class PublicApiHandler( } else stub.response ) } - _ <- ZIO.when(stub.scope == Scope.Countdown)(stubDAO.updateById(stub.id, prop[HttpStub](_.times).inc(-1))) + _ <- ZIO.when(stub.scope == Scope.Countdown)(stubDAO.incTimesById(stub.id, -1)) _ <- ZIO.when(stub.callback.isDefined)( engine .recurseCallback(state, stub.callback.get, data, xdata) diff --git a/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/api/StubResolver.scala b/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/api/StubResolver.scala index 351a20bc..0d4db349 100644 --- a/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/api/StubResolver.scala +++ b/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/api/StubResolver.scala @@ -1,18 +1,14 @@ package ru.tinkoff.tcb.mockingbird.api -import com.github.dwickern.macros.NameOf.* import io.circe.Json import kantan.xpath.Node -import mouse.boolean.* import mouse.option.* -import org.mongodb.scala.bson.* import zio.interop.catz.core.* -import ru.tinkoff.tcb.criteria.* -import ru.tinkoff.tcb.criteria.Typed.* import ru.tinkoff.tcb.logging.MDCLogging -import ru.tinkoff.tcb.mockingbird.dal.HttpStubDAO import ru.tinkoff.tcb.mockingbird.dal.PersistentStateDAO +import ru.tinkoff.tcb.mockingbird.dal2.HttpStubDAO +import ru.tinkoff.tcb.mockingbird.dal2.StubMatchParams import ru.tinkoff.tcb.mockingbird.error.* import ru.tinkoff.tcb.mockingbird.misc.Renderable.ops.* import ru.tinkoff.tcb.mockingbird.model.HttpMethod @@ -41,21 +37,8 @@ final class StubResolver(stubDAO: HttpStubDAO[Task], stateDAO: PersistentStateDA ): RIO[WLD, Option[(HttpStub, Option[PersistentState])]] = ( for { - _ <- log.info("Поиск заглушек для запроса {} типа {}", path, scope) - pathPatternExpr = Expression[HttpStub]( - None, - "$expr" -> BsonDocument( - "$regexMatch" -> BsonDocument( - "input" -> path, - "regex" -> s"$$${nameOf[HttpStub](_.pathPattern)}" - ) - ) - ) - condition0 = prop[HttpStub](_.method) === method && - (prop[HttpStub](_.path) ==@ path || pathPatternExpr) && - prop[HttpStub](_.scope) === scope - condition = (scope == Scope.Countdown).fold(condition0 && prop[HttpStub](_.times) > Option(0), condition0) - candidates0 <- stubDAO.findChunk(condition, 0, Int.MaxValue) + _ <- log.info("Поиск заглушек для запроса {} типа {}", path, scope) + candidates0 <- stubDAO.findMatch(StubMatchParams(scope, path, method)) _ <- ZIO.when(candidates0.isEmpty)( log.info("Не найдены обработчики для запроса {} типа {}", path, scope) *> ZIO.fail(EarlyReturn) ) diff --git a/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/config/Configuration.scala b/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/config/Configuration.scala index 470351da..2d9aa663 100644 --- a/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/config/Configuration.scala +++ b/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/config/Configuration.scala @@ -59,6 +59,8 @@ case class MockingbirdConfiguration( ) object MockingbirdConfiguration { + type Layer = ULayer[ServerConfig & SecurityConfig & MongoConfig & ProxyConfig & EventConfig] + def load(): MockingbirdConfiguration = load(ConfigFactory.load().getConfig("ru.tinkoff.tcb")) @@ -78,4 +80,8 @@ object MockingbirdConfiguration { val mongo: ULayer[MongoConfig] = ZLayer.succeed(conf.mongo) val proxy: ULayer[ProxyConfig] = ZLayer.succeed(conf.proxy) val event: ULayer[EventConfig] = ZLayer.succeed(conf.event) + + val live: Layer = + server ++ security ++ mongo ++ proxy ++ event + } diff --git a/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/dal/HttpStubDAO.scala b/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/dal/HttpStubDAO.scala index 6a305fec..efc0e122 100644 --- a/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/dal/HttpStubDAO.scala +++ b/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/dal/HttpStubDAO.scala @@ -79,13 +79,3 @@ class HttpStubDAOImpl(collection: MongoCollection[BsonDocument]) ascending(nameOf[HttpStub](_.labels)) ) } - -object HttpStubDAOImpl { - val live = ZLayer { - for { - mc <- ZIO.service[MongoCollection[BsonDocument]] - sd = new HttpStubDAOImpl(mc) - _ <- sd.createIndexes - } yield sd.asInstanceOf[HttpStubDAO[Task]] - } -} diff --git a/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/dal2/HttpStubDAO.scala b/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/dal2/HttpStubDAO.scala new file mode 100644 index 00000000..073779ed --- /dev/null +++ b/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/dal2/HttpStubDAO.scala @@ -0,0 +1,194 @@ +package ru.tinkoff.tcb.mockingbird.dal2 + +import java.time.Instant +import scala.annotation.implicitNotFound +import scala.util.matching.Regex + +import cats.tagless.autoFunctorK +import eu.timepit.refined.api.Refined +import eu.timepit.refined.collection.NonEmpty +import eu.timepit.refined.numeric.NonNegative +import eu.timepit.refined.numeric.Positive +import simulacrum.typeclass + +import ru.tinkoff.tcb.dataaccess.UpdateResult +import ru.tinkoff.tcb.mockingbird.api.request.StubPatch +import ru.tinkoff.tcb.mockingbird.model.HttpMethod +import ru.tinkoff.tcb.mockingbird.model.HttpStub +import ru.tinkoff.tcb.mockingbird.model.Scope +import ru.tinkoff.tcb.utils.id.SID + +/** + * Потенциально заглушка может сопоставляться с мокируемым путям или как точное соответствие или регулярное выражения, + * которому удовлетворяет путь на который пришел запрос. + */ +sealed trait StubPath extends Serializable with Product +final case class StubExactlyPath(value: String Refined NonEmpty) extends StubPath +final case class StubPathPattern(value: Regex) extends StubPath + +/** + * Параметры для поиска заглушек. + * + * @param scope + * @param pathPattern + * представляет собой или строку, которая соответствует точному пути, или регулярное выражение. Заглушка может + * содержать или одно, или другое. + * @param method + */ +final case class StubFindParams(scope: Scope, path: StubPath, method: HttpMethod) + +/** + * Параметры для подбора заглушек подходящих под указанный путь. + * + * @param scope + * @param path + * Путь который был передан в mockingbird при вызове заглушки. Подходящая у подходящей заглушки поле path будет в + * точности равно этому пути или переданный путь будет соотвествовать регулярному выражению хранимому в поле + * pathPattern. + * @param method + */ +final case class StubMatchParams(scope: Scope, path: String, method: HttpMethod) + +/** + * Параметры для отбора заглушек для отображения их списка в UI. + * + * @param page + * номер страницы для которой формируется список заглушек + * @param query + * строка запроса, рассматривается как точный ID заглушки или используется как регулярное выражения и сопоставляется с + * полями name, path, pathPattern + * @param service + * имя сервиса к которому относится заглушка (поле serviceSuffix) + * @param labels + * список лейблов, которыми должна быть отмечена заглушка, все перечисленные лейблы должны содержаться в поле labels + * заглушки, хранящейся в хранилище + * @param count + * количество заглушек, отображаемых на странице + */ +final case class StubFetchParams( + page: Int Refined NonNegative, + query: Option[String], + service: Option[String], + labels: Seq[String], + count: Int Refined Positive +) + +@implicitNotFound("Could not find an instance of HttpStubDAO for ${F}") +@typeclass @autoFunctorK +trait HttpStubDAO[F[_]] extends Serializable { + + /** + * Возвращает заглушку по ID + */ + def get(id: SID[HttpStub]): F[Option[HttpStub]] + + /** + * Сохраняет заглушку в хранилище + */ + def insert(stub: HttpStub): F[Long] + + /** + * Обновляет заглушку, `patch` не содержит полей `created` и `serviceSuffix`. + */ + def update(patch: StubPatch): F[UpdateResult] + + /** + * Удаляет заглушку из хранилища + */ + def delete(id: SID[HttpStub]): F[Long] + + /** + * Удаляет Ephemeral и Countdown заглушки у которых `created` < `threshold`. + * + * @param threshold + * Максимальная дата создания заглушки которая должна остаться + * @return + * количество удаленных записей + */ + def deleteExpired(threshold: Instant): F[Long] + + /** + * Удаляет Countdown заглушки у которых `times` <= 0 + * + * @return + * количество удаленных записей + */ + def deleteDepleted(): F[Long] + + /** + * Возвращает список сохраненных заглушек, соответствующих преданным параметрам. Возвращает только заглушки для + * который `times` > 0. + * + * P.S. При модификации заглушки запрос содержит условние на неравенство ID, но можно это потом проверить и на стороне + * приложения, отфильтровать в полученных заглуках заглушку с определенным ID. + */ + def find(params: StubFindParams): F[Vector[HttpStub]] + + /** + * Возвращает заглушки для переданного `scope`, которые могут быть сопоставлены с указанным `path` и `method`. Под + * сопоставлением понимается или совпадение с хранимым значением `path`, или, если переданный `path` удовлетворяет + * регулярному выражению, хранимом в `pathPattern`. Так же у заглушки должен быть `times` > 0. + */ + def findMatch(params: StubMatchParams): F[Vector[HttpStub]] + + /** + * Возвращает заглушки для отображения в UI. Возвращает указанное в `params.count` количество, пропуская первые + * `params.page * params.count` заглушек. Результат отсортирован по времени создания в обратном порядке - первыми идут + * те заглушки, которые были созданы последними. + */ + def fetch(params: StubFetchParams): F[Vector[HttpStub]] + + /** + * Изменяет значения поля times у указанной заглушки на величину value + * + * @param id + * идентификатор заглушки + * @param value + * значение на которое нужно изменить значение поля times + * @return + */ + def incTimesById(id: SID[HttpStub], value: Int): F[UpdateResult] +} + +object HttpStubDAO { + + /* ======================================================================== */ + /* THE FOLLOWING CODE IS MANAGED BY SIMULACRUM; PLEASE DO NOT EDIT!!!! */ + /* ======================================================================== */ + + /** + * Summon an instance of [[HttpStubDAO]] for `F`. + */ + @inline def apply[F[_]](implicit instance: HttpStubDAO[F]): HttpStubDAO[F] = instance + + object ops { + implicit def toAllHttpStubDAOOps[F[_], A](target: F[A])(implicit tc: HttpStubDAO[F]): AllOps[F, A] { + type TypeClassType = HttpStubDAO[F] + } = new AllOps[F, A] { + type TypeClassType = HttpStubDAO[F] + val self: F[A] = target + val typeClassInstance: TypeClassType = tc + } + } + trait Ops[F[_], A] extends Serializable { + type TypeClassType <: HttpStubDAO[F] + def self: F[A] + val typeClassInstance: TypeClassType + } + trait AllOps[F[_], A] extends Ops[F, A] + trait ToHttpStubDAOOps extends Serializable { + implicit def toHttpStubDAOOps[F[_], A](target: F[A])(implicit tc: HttpStubDAO[F]): Ops[F, A] { + type TypeClassType = HttpStubDAO[F] + } = new Ops[F, A] { + type TypeClassType = HttpStubDAO[F] + val self: F[A] = target + val typeClassInstance: TypeClassType = tc + } + } + object nonInheritedOps extends ToHttpStubDAOOps + + /* ======================================================================== */ + /* END OF SIMULACRUM-MANAGED CODE */ + /* ======================================================================== */ + +} diff --git a/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/dal2/mongo/HttpStubDAOImpl.scala b/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/dal2/mongo/HttpStubDAOImpl.scala new file mode 100644 index 00000000..eb0f6ca3 --- /dev/null +++ b/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/dal2/mongo/HttpStubDAOImpl.scala @@ -0,0 +1,116 @@ +package ru.tinkoff.tcb.mockingbird.dal2.mongo + +import java.time.Instant + +import com.github.dwickern.macros.NameOf.* +import eu.timepit.refined.auto.* +import mouse.boolean.* +import org.mongodb.scala.MongoCollection +import org.mongodb.scala.bson.BsonDocument +import zio.ZIO + +import ru.tinkoff.tcb.criteria.* +import ru.tinkoff.tcb.criteria.Typed.* +import ru.tinkoff.tcb.criteria.Untyped.* +import ru.tinkoff.tcb.dataaccess.UpdateResult +import ru.tinkoff.tcb.mockingbird.api.request.StubPatch +import ru.tinkoff.tcb.mockingbird.dal +import ru.tinkoff.tcb.mockingbird.dal2 +import ru.tinkoff.tcb.mockingbird.model.HttpStub +import ru.tinkoff.tcb.mockingbird.model.Scope +import ru.tinkoff.tcb.protocol.rof.* +import ru.tinkoff.tcb.utils.id.SID + +class HttpStubDAOImpl(collection: MongoCollection[BsonDocument]) extends dal2.HttpStubDAO[Task] { + private val impl = new dal.HttpStubDAOImpl(collection) + + override def get(id: SID[HttpStub]): Task[Option[HttpStub]] = impl.findById(id) + + override def insert(stub: HttpStub): Task[Long] = impl.insert(stub).map(x => x) + + override def update(patch: StubPatch): Task[UpdateResult] = impl.patch(patch) + + override def delete(id: SID[HttpStub]): Task[Long] = impl.deleteById(id) + + override def deleteExpired(threshold: Instant): Task[Long] = + impl.delete( + prop[HttpStub](_.scope).in[Scope](Scope.Ephemeral, Scope.Countdown) && prop[HttpStub](_.created) < threshold + ) + + override def deleteDepleted(): Task[Long] = + impl.delete(prop[HttpStub](_.scope) === Scope.Countdown.asInstanceOf[Scope] && prop[HttpStub](_.times) <= Option(0)) + + override def find(params: dal2.StubFindParams): Task[Vector[HttpStub]] = + impl.findChunk( + prop[HttpStub](_.method) === params.method && + (params.path match { + case dal2.StubExactlyPath(p) => prop[HttpStub](_.path) === Option(p.value) + case dal2.StubPathPattern(pp) => prop[HttpStub](_.pathPattern) === Option(pp) + }) && + prop[HttpStub](_.scope) === params.scope && + prop[HttpStub](_.times) > Option(0), + 0, + Int.MaxValue + ) + + override def findMatch(params: dal2.StubMatchParams): Task[Vector[HttpStub]] = { + val pathPatternExpr = Expression[HttpStub]( + None, + "$expr" -> BsonDocument( + "$regexMatch" -> BsonDocument( + "input" -> params.path, + "regex" -> s"$$${nameOf[HttpStub](_.pathPattern)}" + ) + ) + ) + val condition0 = prop[HttpStub](_.method) === params.method && + (prop[HttpStub](_.path) ==@ params.path || pathPatternExpr) && + prop[HttpStub](_.scope) === params.scope + val condition = (params.scope == Scope.Countdown).fold(condition0 && prop[HttpStub](_.times) > Option(0), condition0) + impl.findChunk(condition, 0, Int.MaxValue) + } + + override def fetch(params: dal2.StubFetchParams): Task[Vector[HttpStub]] = { + var queryDoc: Expression[Any] = + prop[HttpStub](_.scope) =/= Scope.Countdown.asInstanceOf[Scope] || prop[HttpStub](_.times) > Option(0) + + queryDoc = params.query.fold(queryDoc) { qs => + val q = where(_._id === qs) || + prop[HttpStub](_.name).regex(qs, "i") || + prop[HttpStub](_.path).regex(qs, "i") || + prop[HttpStub](_.pathPattern).regex(qs, "i") + queryDoc && q + } + + queryDoc = params.service.fold(queryDoc) { service => + queryDoc && (prop[HttpStub](_.serviceSuffix) === service) + } + + if (params.labels.nonEmpty) { + queryDoc = queryDoc && (prop[HttpStub](_.labels).containsAll(params.labels)) + } + + impl.findChunk(queryDoc, params.page * params.count, params.count, prop[HttpStub](_.created).sort(Desc)) + } + + override def incTimesById(id: SID[HttpStub], value: Int): Task[UpdateResult] = + impl.updateById(id, prop[HttpStub](_.times).inc(value)) + + def createIndexes(): Task[Unit] = impl.createIndexes +} + +object HttpStubDAOImpl { + def create(collection: MongoCollection[BsonDocument]): Task[dal2.HttpStubDAO[Task]] = + for { + _ <- ZIO.unit + sd = new HttpStubDAOImpl(collection) + _ <- sd.createIndexes() + } yield sd + + val live: RLayer[MongoCollection[BsonDocument], dal2.HttpStubDAO[Task]] = ZLayer { + for { + mc <- ZIO.service[MongoCollection[BsonDocument]] + sd <- create(mc) + } yield sd + } +} diff --git a/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/stream/EphemeralCleaner.scala b/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/stream/EphemeralCleaner.scala index 7d071d58..6f9f25a8 100644 --- a/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/stream/EphemeralCleaner.scala +++ b/backend/mockingbird/src/main/scala/ru/tinkoff/tcb/mockingbird/stream/EphemeralCleaner.scala @@ -9,9 +9,8 @@ import zio.interop.catz.implicits.* import ru.tinkoff.tcb.criteria.* import ru.tinkoff.tcb.criteria.Typed.* -import ru.tinkoff.tcb.mockingbird.dal.HttpStubDAO import ru.tinkoff.tcb.mockingbird.dal.ScenarioDAO -import ru.tinkoff.tcb.mockingbird.model.HttpStub +import ru.tinkoff.tcb.mockingbird.dal2.HttpStubDAO import ru.tinkoff.tcb.mockingbird.model.Scenario import ru.tinkoff.tcb.mockingbird.model.Scope @@ -26,18 +25,14 @@ final class EphemeralCleaner(stubDAO: HttpStubDAO[Task], scenarioDAO: ScenarioDA for { current <- ZIO.clock.flatMap(_.instant) threshold = current.minusSeconds(secondsInDay) - deleted <- stubDAO.delete( - prop[HttpStub](_.scope).in[Scope](Scope.Ephemeral, Scope.Countdown) && prop[HttpStub](_.created) < threshold - ) - _ <- log.info("Purging expired stubs: {} deleted", deleted) + deleted <- stubDAO.deleteExpired(threshold) + _ <- log.info("Purging expired stubs: {} deleted", deleted) deleted2 <- scenarioDAO.delete( prop[Scenario](_.scope).in[Scope](Scope.Ephemeral, Scope.Countdown) && prop[Scenario](_.created) < threshold ) - _ <- log.info("Purging expired scenarios: {} deleted", deleted2) - deleted3 <- stubDAO.delete( - prop[HttpStub](_.scope) === Scope.Countdown.asInstanceOf[Scope] && prop[HttpStub](_.times) <= Option(0) - ) - _ <- log.info("Purging countdown stubs: {} deleted", deleted3) + _ <- log.info("Purging expired scenarios: {} deleted", deleted2) + deleted3 <- stubDAO.deleteDepleted() + _ <- log.info("Purging countdown stubs: {} deleted", deleted3) deleted4 <- scenarioDAO.delete( prop[Scenario](_.scope) === Scope.Countdown.asInstanceOf[Scope] && prop[Scenario](_.times) <= Option(0) ) diff --git a/backend/project/Dependencies.scala b/backend/project/Dependencies.scala index 48f189b8..b71d77cb 100644 --- a/backend/project/Dependencies.scala +++ b/backend/project/Dependencies.scala @@ -57,16 +57,22 @@ 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 refined = Seq( "eu.timepit" %% "refined" % "0.9.28" ) diff --git a/backend/project/Settings.scala b/backend/project/Settings.scala index d336cc0a..ffaede7a 100644 --- a/backend/project/Settings.scala +++ b/backend/project/Settings.scala @@ -26,7 +26,7 @@ object Settings { val common = Seq( organization := "ru.tinkoff", - version := "3.11.0", + version := "3.12.0", scalaVersion := "2.13.11", Compile / packageDoc / publishArtifact := false, Compile / packageSrc / publishArtifact := false, diff --git a/backend/mockingbird/src/main/scala/tofu/logging/impl/ZUniversalContextLogging.scala b/backend/utils/src/main/scala/tofu/logging/impl/ZUniversalContextLogging.scala similarity index 100% rename from backend/mockingbird/src/main/scala/tofu/logging/impl/ZUniversalContextLogging.scala rename to backend/utils/src/main/scala/tofu/logging/impl/ZUniversalContextLogging.scala diff --git a/backend/mockingbird/src/main/scala/tofu/logging/impl/ZUniversalLogging.scala b/backend/utils/src/main/scala/tofu/logging/impl/ZUniversalLogging.scala similarity index 100% rename from backend/mockingbird/src/main/scala/tofu/logging/impl/ZUniversalLogging.scala rename to backend/utils/src/main/scala/tofu/logging/impl/ZUniversalLogging.scala diff --git a/docker-compose.yml b/docker-compose.yml index 43e9f539..0628e69b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: networks: - app-tier mock: - image: "ghcr.io/tinkoff/mockingbird:3.10.0-native" + image: "ghcr.io/tinkoff/mockingbird:3.12.0-native" ports: - "8228:8228" - "9000:9000" 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/frontend/build.sh b/frontend/build.sh new file mode 100755 index 00000000..4f082d3c --- /dev/null +++ b/frontend/build.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +set -ex + +export PORT=3000 +export APP_ID=mockingbird +export NODE_ENV=production +export RELATIVE_PATH=/mockingbird +export ASSETS_FOLDER_NAME=/assets +export ASSETS_PREFIX=/mockingbird/assets/ +export MOCKINGBIRD_API=/api/internal/mockingbird +export MOCKINGBIRD_EXEC_API=/api/mockingbird/exec +export DEBUG_PLAIN=true + +rm -rf ./env.development.js +rm -rf ./dist/out${ASSETS_FOLDER_NAME} +mkdir -p ./dist/out${ASSETS_FOLDER_NAME} +npm install +npm run build + +cp -r ./dist/static${RELATIVE_PATH}/. ./dist/out +cp -a ./dist/client/. ./dist/out${ASSETS_FOLDER_NAME}