diff --git a/.github/settings.yml b/.github/settings.yml
new file mode 100644
index 0000000..9267e7d
--- /dev/null
+++ b/.github/settings.yml
@@ -0,0 +1,2 @@
+# These settings are synced to GitHub by https://probot.github.io/apps/settings/
+_extends: .github
diff --git a/.github/workflows/basic-linters.yml b/.github/workflows/basic-linters.yml
new file mode 100644
index 0000000..6114f14
--- /dev/null
+++ b/.github/workflows/basic-linters.yml
@@ -0,0 +1,10 @@
+name: Vality basic linters
+
+on:
+ pull_request:
+ branches:
+ - "*"
+
+jobs:
+ lint:
+ uses: valitydev/base-workflows/.github/workflows/basic-linters.yml@v1
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..67a681d
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,10 @@
+name: Build Maven Artifact
+
+on:
+ pull_request:
+ branches:
+ - '*'
+
+jobs:
+ build:
+ uses: valitydev/java-workflow/.github/workflows/maven-service-build.yml@v1
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..71e13b8
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,13 @@
+name: Maven Deploy Artifact
+
+on:
+ push:
+ branches:
+ - 'master'
+
+jobs:
+ build-and-deploy:
+ uses: valitydev/java-workflow/.github/workflows/maven-service-deploy.yml@v1
+ secrets:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ mm-webhook-url: ${{ secrets.MATTERMOST_WEBHOOK_URL }}
diff --git a/.github/workflows/settings.yml b/.github/workflows/settings.yml
new file mode 100644
index 0000000..9267e7d
--- /dev/null
+++ b/.github/workflows/settings.yml
@@ -0,0 +1,2 @@
+# These settings are synced to GitHub by https://probot.github.io/apps/settings/
+_extends: .github
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0499eef
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,79 @@
+# Created by .ignore support plugin (hsz.mobi)
+.eunit
+deps
+*.o
+*.beam
+*.plt
+erl_crash.dump
+ebin/*.beam
+rel/example_project
+.concrete/DEV_MODE
+.rebar
+target/
+pom.xml.tag
+pom.xml.releaseBackup
+pom.xml.versionsBackup
+pom.xml.next
+release.properties
+dependency-reduced-pom.xml
+buildNumber.properties
+.mvn/timing.properties
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff:
+.DS_Store
+.idea/
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/dictionaries
+.idea/vcs.xml
+.idea/jsLibraryMappings.xml
+
+# Sensitive or high-churn files:
+.idea/dataSources.ids
+.idea/dataSources.xml
+.idea/dataSources.local.xml
+.idea/sqlDataSources.xml
+.idea/dynamic.xml
+.idea/uiDesigner.xml
+
+# Gradle:
+.idea/gradle.xml
+.idea/libraries
+
+# Mongo Explorer plugin:
+.idea/mongoSettings.xml
+
+*.iws
+*.ipr
+*.iml
+
+
+# IntelliJ
+/out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+*.class
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.jar
+*.war
+*.ear
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+env.list
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d9a10c0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,176 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
diff --git a/README.md b/README.md
index 6e76e46..5252d07 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,71 @@
# beholder
+
+Сервис собирает метрики о производительности загрузки платёжной формы из разных регионов.
+
+## Особенности имплементации
+
+Точка входа в приложение: ```dev.vality.beholder.service.BeholderService.behold```. Этот метод вызывается по расписанию, указанном в свойстве ```schedule.cron```.
+
+### Подготовка данных для загрузки платёжной формы
+
+Для загрузки платёжной формы необходимы ```InvoiceId``` и ```InvoiceAccessToken```.
+
+Схема взаимодействия с [swag-payments](https://github.com/valitydev/swag-payments):
+
+![PaymentsImage](img/payments.drawio.svg)
+
+Алгоритм взаимодействия реализован здесь: ```dev.vality.beholder.service.PaymentsService.prepareFormData```
+
+### Загрузка платёжной формы
+
+Beholder умеет работать c простым selenium-hub и с [lambdatest](https://www.lambdatest.com/).
+В реальности обе интеграции работают через selenium API и являются совместимыми.
+
+Алгоритм загрузки и сбора метрик формы реализован здесь: ```dev.vality.beholder.service.SeleniumService.executePaymentRequest```
+
+Его можно разбить на следующие шаги:
+
+1. Установить подключение с selenium-hub/lambdatest
+2. Отправить запрос на загрузку формы
+3. Собрать метрики загрузки формы посредством javascript'а (```dev.vality.beholder.util.SeleniumUtil.PERFORMANCE_SCRIPT```)
+4. Заполнить форму и отправить запрос на проведение платежа
+5. Собрать логи производительности браузера
+
+### Обновление метрик
+
+Собранная на предыдущем шаге информация о производительности формы записывается в соответствующие метрики prometheus'а.
+Этот функционал реализован в классе ```dev.vality.beholder.service.MetricsService```
+
+#### Метрики
+
+| Название | Лейблы | Описание |
+|--------------------------------------------------|-----------------|------------------------------------------------------------------------------|
+| beholder_form_loading_requests_total | browser, region | счетчик запросов на загрузку формы |
+| beholder_form_loading_failed_total | browser, region | счетчик неудачных загрузок формы |
+| beholder_form_dom_complete_duration_millis | browser, region | время от момента отправки запроса до полной загрузки формы в миллисекундах |
+| beholder_form_waiting_response_duration_millis | browser, region | время от момента отправки запроса до начала получения ответа в миллисекундах |
+| beholder_form_receiving_response_duration_millis | browser, region | время между получением первым и последним байтом информации в миллисекундах |
+| beholder_form_resource_loading_duration_millis | browser, region | время, затраченное на загрузку ресурса (включая блокировки, ожидание и т.д) |
+
+## Тестирование
+
+Поскольку загружать во время юнит-тестирования реальную платежную форму не представляется возможным,
+реализован интеграционный тест, который отключен по умолчанию, однако может использоваться для локальной отладки сервиса.
+
+Тест: ```dev.vality.beholder.IntegrationTest```
+Подготовка к запуску теста:
+
+1. Прописать валидные значения в следующих свойствах:
+ 1. payments.api-url - адрес для обращения к api
+ 2. payments.form-url - адрес для загрузки платёжной формы
+ 3. payments.request.shop-id - идентификатор магазина, который нужно использовать
+2. Прописать в свойстве ```dev.vality.beholder.IntegrationTest.TEST_USER_TOKEN``` валидный токен
+3. Готово, можно запускать тест.
+
+## Полезные ссылки
+
+[Описание метрик производительности](https://developer.mozilla.org/en-US/docs/Web/Performance/Navigation_and_resource_timings), которые можно получить через JS.
+
+[Описание метрик сети](https://chromedevtools.github.io/devtools-protocol/tot/Network/), которые можно получить от chromium'а.
+
+[Регионы](https://www.lambdatest.com/capabilities-generator/), доступные для тестирования. На их основе заполнен справочник ```regions.json```
diff --git a/img/payments.drawio.svg b/img/payments.drawio.svg
new file mode 100644
index 0000000..292b76c
--- /dev/null
+++ b/img/payments.drawio.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..a08e719
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,204 @@
+
+
+ 4.0.0
+
+
+ dev.vality
+ service-parent-pom
+ 1.0.16
+
+
+ beholder
+ 0.0.1-SNAPSHOT
+ jar
+
+ beholder
+
+
+ UTF-8
+ UTF-8
+ 15
+ 8022
+ 8023
+ ${server.port} ${management.port}
+
+
+
+
+
+
+
+ dev.vality.woody
+ woody-thrift
+
+
+ dev.vality
+ shared-resources
+
+
+ dev.vality
+ swag-payments
+ 1.627-0089567-client
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.hibernate
+ hibernate-validator
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+
+ javax.servlet
+ javax.servlet-api
+
+
+ org.projectlombok
+ lombok
+ provided
+
+
+ io.micrometer
+ micrometer-core
+
+
+ io.micrometer
+ micrometer-registry-prometheus
+
+
+ org.seleniumhq.selenium
+ selenium-java
+
+
+ org.keycloak
+ keycloak-admin-client
+ 15.1.1
+
+
+ org.jboss.resteasy
+ resteasy-client
+
+
+ org.jboss.resteasy
+ resteasy-multipart-provider
+
+
+ org.jboss.resteasy
+ resteasy-jackson2-provider
+
+
+ org.jboss.resteasy
+ resteasy-jaxb-provider
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.testcontainers
+ testcontainers
+ 1.17.2
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ 1.17.2
+ test
+
+
+ com.github.oshi
+ oshi-core-java11
+ 6.1.6
+ test
+
+
+
+
+
+ ${project.basedir}/src/main/java
+ ${project.basedir}/src/test/java
+
+
+ ${project.build.directory}/maven-shared-archive-resources
+ ${project.build.directory}
+
+ Dockerfile
+
+ true
+
+
+ ${project.build.directory}/maven-shared-archive-resources
+ true
+
+ Dockerfile
+
+
+
+ src/main/resources
+ true
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ org.apache.maven.plugins
+ maven-remote-resources-plugin
+ 1.7.0
+
+
+ org.apache.maven.shared
+ maven-filtering
+ 3.2.0
+
+
+
+
+ dev.vality:shared-resources:${shared-resources.version}
+
+ false
+ false
+
+
+
+
+ process
+
+
+
+
+
+
+
diff --git a/renovate.json b/renovate.json
new file mode 100644
index 0000000..a20bfd6
--- /dev/null
+++ b/renovate.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "extends": ["local>valitydev/.github:renovate-config"]
+}
diff --git a/src/main/java/dev/vality/beholder/BeholderApplication.java b/src/main/java/dev/vality/beholder/BeholderApplication.java
new file mode 100644
index 0000000..cfa0258
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/BeholderApplication.java
@@ -0,0 +1,17 @@
+package dev.vality.beholder;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.web.servlet.ServletComponentScan;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@ServletComponentScan
+@SpringBootApplication
+@EnableScheduling
+public class BeholderApplication extends SpringApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(BeholderApplication.class, args);
+ }
+
+}
diff --git a/src/main/java/dev/vality/beholder/config/PaymentsConfig.java b/src/main/java/dev/vality/beholder/config/PaymentsConfig.java
new file mode 100644
index 0000000..49822c6
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/config/PaymentsConfig.java
@@ -0,0 +1,73 @@
+package dev.vality.beholder.config;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import dev.vality.beholder.config.properties.PaymentsProperties;
+import dev.vality.swag.payments.ApiClient;
+import dev.vality.swag.payments.api.ClaimsApi;
+import dev.vality.swag.payments.api.InvoicesApi;
+import dev.vality.swag.payments.api.PartiesApi;
+import dev.vality.swag.payments.api.ShopsApi;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.ClientHttpRequestFactory;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.web.client.RestTemplate;
+
+import java.text.SimpleDateFormat;
+
+@Configuration
+public class PaymentsConfig {
+
+ private static final String BEARER_TYPE = "Bearer";
+ private static final String DATE_TIME_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSZ";
+
+ @Bean
+ public ClientHttpRequestFactory clientHttpRequestFactory(PaymentsProperties paymentsProperties) {
+ var requestFactory = new SimpleClientHttpRequestFactory();
+ requestFactory.setConnectTimeout(paymentsProperties.getApiTimeoutSec().intValue() * 1000);
+ return requestFactory;
+ }
+
+ @Bean
+ public RestTemplate restTemplate(ClientHttpRequestFactory clientHttpRequestFactory) {
+ var restTemplate = new RestTemplate(clientHttpRequestFactory);
+ restTemplate.getMessageConverters()
+ .removeIf(m -> m.getClass().getName().equals(MappingJackson2HttpMessageConverter.class.getName()));
+ Jackson2ObjectMapperBuilder builder =
+ new Jackson2ObjectMapperBuilder()
+ .serializationInclusion(JsonInclude.Include.NON_NULL)
+ .dateFormat(new SimpleDateFormat(DATE_TIME_PATTERN));
+ restTemplate.getMessageConverters().add(new MappingJackson2HttpMessageConverter(builder.build()));
+ return restTemplate;
+ }
+
+ @Bean
+ public ApiClient apiClient(RestTemplate restTemplate, PaymentsProperties paymentsProperties) {
+ ApiClient apiClient = new ApiClient(restTemplate);
+ apiClient.setBasePath(paymentsProperties.getApiUrl());
+ apiClient.setApiKeyPrefix(BEARER_TYPE);
+ return apiClient;
+ }
+
+ @Bean
+ public PartiesApi partiesApi(ApiClient apiClient) {
+ return new PartiesApi(apiClient);
+ }
+
+ @Bean
+ public ClaimsApi claimsApi(ApiClient apiClient) {
+ return new ClaimsApi(apiClient);
+ }
+
+ @Bean
+ public ShopsApi shopsApi(ApiClient apiClient) {
+ return new ShopsApi(apiClient);
+ }
+
+ @Bean
+ public InvoicesApi invoicesApi(ApiClient apiClient) {
+ return new InvoicesApi(apiClient);
+ }
+}
diff --git a/src/main/java/dev/vality/beholder/config/SeleniumConfig.java b/src/main/java/dev/vality/beholder/config/SeleniumConfig.java
new file mode 100644
index 0000000..6932bb0
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/config/SeleniumConfig.java
@@ -0,0 +1,68 @@
+package dev.vality.beholder.config;
+
+import dev.vality.beholder.config.properties.PaymentsProperties;
+import dev.vality.beholder.config.properties.SeleniumProperties;
+import dev.vality.beholder.converter.LogEntriesToNetworkLogsConverter;
+import dev.vality.beholder.model.Region;
+import dev.vality.beholder.service.SeleniumService;
+import dev.vality.beholder.util.FileUtil;
+import dev.vality.beholder.util.SeleniumUtil;
+import org.openqa.selenium.remote.DesiredCapabilities;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.io.Resource;
+
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Configuration
+public class SeleniumConfig {
+
+ @Value("${dictionary.regions}")
+ private Resource dictionaryRegions;
+
+ @Bean(name = "remoteFormService")
+ @ConditionalOnProperty(name = "selenium.use-external-provider", havingValue = "true")
+ public SeleniumService lambdaTestRemoteFormService(SeleniumProperties seleniumProperties,
+ PaymentsProperties paymentsProperties,
+ LogEntriesToNetworkLogsConverter converter)
+ throws MalformedURLException {
+ URL lambdaTestUrl = buildLambdaTestUrl(seleniumProperties.getLambdaTest());
+ String formUrl = paymentsProperties.getFormUrl();
+ Long formTimeoutSec = paymentsProperties.getFormTimeoutSec();
+ DesiredCapabilities desiredCapabilities = SeleniumUtil.getLambdaTestCapabilities();
+ return new SeleniumService(lambdaTestUrl, formUrl, formTimeoutSec, desiredCapabilities, converter);
+ }
+
+ @Bean(name = "remoteFormService")
+ @ConditionalOnProperty(name = "selenium.use-external-provider", havingValue = "false")
+ public SeleniumService seleniumRemoteFormService(SeleniumProperties seleniumProperties,
+ PaymentsProperties paymentsProperties,
+ LogEntriesToNetworkLogsConverter converter)
+ throws MalformedURLException {
+ URL seleniumUrl = new URL(seleniumProperties.getUrl() + ":" + seleniumProperties.getPort());
+ String formUrl = paymentsProperties.getFormUrl();
+ Long formTimeoutSec = paymentsProperties.getFormTimeoutSec();
+ DesiredCapabilities desiredCapabilities = SeleniumUtil.getCommonCapabilities();
+ return new SeleniumService(seleniumUrl, formUrl, formTimeoutSec, desiredCapabilities, converter);
+ }
+
+ @Bean
+ public List regions(SeleniumProperties seleniumProperties) throws IOException {
+ return FileUtil.readRegions(dictionaryRegions)
+ .stream().filter(region -> seleniumProperties.getRegions().contains(region.getCode()))
+ .collect(Collectors.toList());
+ }
+
+ private URL buildLambdaTestUrl(SeleniumProperties.LambdaTestProperties lambdaTestProperties)
+ throws MalformedURLException {
+ String url = "https://" + lambdaTestProperties.getUser() + ":" + lambdaTestProperties.getToken() +
+ "@hub.lambdatest.com/wd/hub";
+ return new URL(url);
+ }
+}
diff --git a/src/main/java/dev/vality/beholder/config/properties/KeycloakProperties.java b/src/main/java/dev/vality/beholder/config/properties/KeycloakProperties.java
new file mode 100644
index 0000000..5b0b872
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/config/properties/KeycloakProperties.java
@@ -0,0 +1,30 @@
+package dev.vality.beholder.config.properties;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.annotation.Validated;
+
+import javax.validation.constraints.NotEmpty;
+
+@Getter
+@Setter
+@Component
+@Validated
+@ConfigurationProperties(prefix = "keycloak")
+public class KeycloakProperties {
+
+ @NotEmpty
+ private String user;
+
+ @NotEmpty
+ private String password;
+
+ @NotEmpty
+ private String url;
+
+ @NotEmpty
+ private String resource;
+
+}
diff --git a/src/main/java/dev/vality/beholder/config/properties/PaymentsProperties.java b/src/main/java/dev/vality/beholder/config/properties/PaymentsProperties.java
new file mode 100644
index 0000000..515e551
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/config/properties/PaymentsProperties.java
@@ -0,0 +1,49 @@
+package dev.vality.beholder.config.properties;
+
+import lombok.Data;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.annotation.Validated;
+
+import javax.validation.constraints.AssertTrue;
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+
+@Getter
+@Setter
+@Component
+@Validated
+@ConfigurationProperties(prefix = "payments")
+public class PaymentsProperties {
+
+ @NotEmpty
+ private String apiUrl;
+
+ private Long apiTimeoutSec = 10L;
+
+ @NotEmpty
+ private String formUrl;
+
+ private Long formTimeoutSec = 30L;
+
+ @NotNull
+ private Request request;
+
+ @Data
+ public static class Request {
+ @NotNull
+ private String shopId;
+ private Boolean createShopIfNotFound = false;
+ private Integer paymentInstitutionId;
+ private Integer categoryId;
+ }
+
+ @AssertTrue(message = "Check 'create-shop-if-not-found' option and related parameters")
+ private boolean isRequestConfigurationValid() {
+ return !request.createShopIfNotFound
+ || (request.getPaymentInstitutionId() != null && request.getCategoryId() != null);
+ }
+
+}
diff --git a/src/main/java/dev/vality/beholder/config/properties/SeleniumProperties.java b/src/main/java/dev/vality/beholder/config/properties/SeleniumProperties.java
new file mode 100644
index 0000000..e8af7f3
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/config/properties/SeleniumProperties.java
@@ -0,0 +1,49 @@
+package dev.vality.beholder.config.properties;
+
+import lombok.Data;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import org.springframework.util.ObjectUtils;
+import org.springframework.validation.annotation.Validated;
+
+import javax.validation.constraints.AssertTrue;
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import java.util.List;
+
+@Getter
+@Setter
+@Component
+@Validated
+@ConfigurationProperties(prefix = "selenium")
+public class SeleniumProperties {
+
+ private String url;
+
+ private Integer port;
+
+ @NotNull
+ private Boolean useExternalProvider;
+
+ @NotEmpty
+ private List regions;
+
+ private LambdaTestProperties lambdaTest;
+
+ @Data
+ public static class LambdaTestProperties {
+
+ private String user;
+
+ private String token;
+ }
+
+ @AssertTrue(message = "Check 'use-external-provider' option and related parameters")
+ private boolean isSeleniumConfigurationValid() {
+ return useExternalProvider && lambdaTest != null && !ObjectUtils.isEmpty(lambdaTest.token)
+ && !ObjectUtils.isEmpty(lambdaTest.user)
+ || !useExternalProvider && !ObjectUtils.isEmpty(url);
+ }
+}
diff --git a/src/main/java/dev/vality/beholder/converter/LogEntriesToNetworkLogsConverter.java b/src/main/java/dev/vality/beholder/converter/LogEntriesToNetworkLogsConverter.java
new file mode 100644
index 0000000..ab2e3be
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/converter/LogEntriesToNetworkLogsConverter.java
@@ -0,0 +1,46 @@
+package dev.vality.beholder.converter;
+
+import dev.vality.beholder.exception.BadFormatException;
+import dev.vality.beholder.model.NetworkLog;
+import dev.vality.beholder.model.NetworkMethod;
+import org.openqa.selenium.logging.LogEntry;
+import org.springframework.boot.configurationprocessor.json.JSONException;
+import org.springframework.boot.configurationprocessor.json.JSONObject;
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.stereotype.Component;
+
+import java.util.*;
+
+@Component
+public class LogEntriesToNetworkLogsConverter implements Converter, List> {
+
+ @Override
+ public List convert(List source) {
+ Map networkLogs = new HashMap<>();
+ for (LogEntry logEntry : source) {
+ try {
+ //Message format doc: https://chromedevtools.github.io/devtools-protocol/tot/Network/
+ JSONObject log = new JSONObject(logEntry.getMessage());
+ JSONObject message = log.getJSONObject("message");
+ JSONObject params = message.getJSONObject("params");
+ if (params.has("requestId")) {
+ String requestId = params.getString("requestId");
+ String method = message.getString("method");
+ if (NetworkMethod.REQUEST_WILL_BE_SENT.getValue().equals(method)) {
+ double time = params.getDouble("timestamp") * 1000;
+ String resource = params.getJSONObject("request").getString("url");
+ networkLogs.put(requestId, new NetworkLog(resource, time, null));
+ } else if (NetworkMethod.LOADING_FINISHED.getValue().equals(method)) {
+ double time = params.getDouble("timestamp") * 1000;
+ NetworkLog networkLog =
+ networkLogs.getOrDefault(requestId, new NetworkLog(requestId, null, null));
+ networkLog.setEnd(time);
+ }
+ }
+ } catch (JSONException e) {
+ throw new BadFormatException("Error during parsing network logs:", e);
+ }
+ }
+ return new ArrayList<>(networkLogs.values());
+ }
+}
diff --git a/src/main/java/dev/vality/beholder/exception/BadFormatException.java b/src/main/java/dev/vality/beholder/exception/BadFormatException.java
new file mode 100644
index 0000000..a008bc6
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/exception/BadFormatException.java
@@ -0,0 +1,16 @@
+package dev.vality.beholder.exception;
+
+public class BadFormatException extends RuntimeException {
+
+ public BadFormatException(String message) {
+ super(message);
+ }
+
+ public BadFormatException(Throwable throwable) {
+ super(throwable);
+ }
+
+ public BadFormatException(String message, Throwable throwable) {
+ super(message, throwable);
+ }
+}
diff --git a/src/main/java/dev/vality/beholder/model/Browser.java b/src/main/java/dev/vality/beholder/model/Browser.java
new file mode 100644
index 0000000..4d6708c
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/model/Browser.java
@@ -0,0 +1,13 @@
+package dev.vality.beholder.model;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
+public enum Browser {
+ CHROME("chrome");
+
+ @Getter
+ private final String label;
+}
diff --git a/src/main/java/dev/vality/beholder/model/FormDataRequest.java b/src/main/java/dev/vality/beholder/model/FormDataRequest.java
new file mode 100644
index 0000000..41e211a
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/model/FormDataRequest.java
@@ -0,0 +1,26 @@
+package dev.vality.beholder.model;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.ToString;
+
+@Data
+public class FormDataRequest {
+
+ private String invoiceId;
+
+ private String invoiceAccessToken;
+
+ private Card cardInfo;
+
+ @Data
+ @Builder
+ public static class Card {
+ @ToString.Exclude
+ private String pan;
+ private String expiration;
+ @ToString.Exclude
+ private String cvv;
+ }
+
+}
diff --git a/src/main/java/dev/vality/beholder/model/FormDataResponse.java b/src/main/java/dev/vality/beholder/model/FormDataResponse.java
new file mode 100644
index 0000000..7b998aa
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/model/FormDataResponse.java
@@ -0,0 +1,38 @@
+package dev.vality.beholder.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@Builder
+public class FormDataResponse {
+
+ private FormDataRequest request;
+
+ private FormPerformance formPerformance;
+
+ private Browser browser;
+
+ private Region region;
+
+ private List networkLogs;
+
+ private boolean failed;
+
+ @Data
+ @Builder
+ public static class FormPerformance {
+
+ private Double requestStartAt;
+
+ private Double responseStartAt;
+
+ private Double responseEndAt;
+
+ private Double domCompletedAt;
+
+ }
+
+}
diff --git a/src/main/java/dev/vality/beholder/model/NetworkLog.java b/src/main/java/dev/vality/beholder/model/NetworkLog.java
new file mode 100644
index 0000000..dbb825f
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/model/NetworkLog.java
@@ -0,0 +1,16 @@
+package dev.vality.beholder.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+@Data
+@AllArgsConstructor
+public class NetworkLog {
+
+ private String resource;
+
+ private Double start;
+
+ private Double end;
+
+}
diff --git a/src/main/java/dev/vality/beholder/model/NetworkMethod.java b/src/main/java/dev/vality/beholder/model/NetworkMethod.java
new file mode 100644
index 0000000..85f6357
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/model/NetworkMethod.java
@@ -0,0 +1,17 @@
+package dev.vality.beholder.model;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public enum NetworkMethod {
+
+ REQUEST_WILL_BE_SENT("Network.requestWillBeSent"),
+
+ RESPONSE_RECEIVED("Network.responseReceived"),
+ LOADING_FINISHED("Network.loadingFinished");
+
+ @Getter
+ private final String value;
+
+}
diff --git a/src/main/java/dev/vality/beholder/model/Region.java b/src/main/java/dev/vality/beholder/model/Region.java
new file mode 100644
index 0000000..1cdf027
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/model/Region.java
@@ -0,0 +1,16 @@
+package dev.vality.beholder.model;
+
+import lombok.Data;
+
+@Data
+public class Region {
+
+ private String code;
+
+ private String country;
+
+ @Override
+ public String toString() {
+ return country + "[" + code + "]";
+ }
+}
diff --git a/src/main/java/dev/vality/beholder/security/KeycloakService.java b/src/main/java/dev/vality/beholder/security/KeycloakService.java
new file mode 100644
index 0000000..9c4c751
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/security/KeycloakService.java
@@ -0,0 +1,30 @@
+package dev.vality.beholder.security;
+
+import dev.vality.beholder.config.properties.KeycloakProperties;
+import lombok.RequiredArgsConstructor;
+import org.keycloak.OAuth2Constants;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+
+@Service
+@RequiredArgsConstructor
+public class KeycloakService {
+
+ private final RestTemplate restTemplate;
+ private final KeycloakProperties keycloakProperties;
+
+ public String getUserToken() {
+ HttpEntity> request =
+ new TokenRequest.Builder(keycloakProperties.getResource(), OAuth2Constants.PASSWORD)
+ .add("username", keycloakProperties.getUser())
+ .add("password", keycloakProperties.getPassword())
+ .build();
+ ResponseEntity response =
+ restTemplate.postForEntity(keycloakProperties.getUrl(), request, String.class);
+ return response.getBody();
+ }
+
+}
diff --git a/src/main/java/dev/vality/beholder/security/TokenRequest.java b/src/main/java/dev/vality/beholder/security/TokenRequest.java
new file mode 100644
index 0000000..60123e4
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/security/TokenRequest.java
@@ -0,0 +1,36 @@
+package dev.vality.beholder.security;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+import java.util.Collections;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class TokenRequest {
+ public static class Builder {
+ MultiValueMap data;
+
+ public Builder(String clientId, String grantType) {
+ data = new LinkedMultiValueMap<>();
+ data.put("client_id", Collections.singletonList(clientId));
+ data.put("grant_type", Collections.singletonList(grantType));
+ }
+
+ public Builder add(String key, String value) {
+ data.put(key, Collections.singletonList(value));
+ return this;
+ }
+
+ public HttpEntity> build() {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+ return new HttpEntity<>(data, headers);
+ }
+ }
+
+}
diff --git a/src/main/java/dev/vality/beholder/service/BeholderService.java b/src/main/java/dev/vality/beholder/service/BeholderService.java
new file mode 100644
index 0000000..697996a
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/service/BeholderService.java
@@ -0,0 +1,47 @@
+package dev.vality.beholder.service;
+
+import dev.vality.beholder.model.FormDataRequest;
+import dev.vality.beholder.model.FormDataResponse;
+import dev.vality.beholder.model.Region;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class BeholderService {
+
+ private final PaymentsService paymentsService;
+ private final SeleniumService seleniumService;
+ private final MetricsService metricsService;
+
+ private final List regions;
+
+ @Scheduled(cron = "${schedule.cron:-}")
+ public void behold() {
+ log.info("Start sending requests from {} regions", regions);
+ List responses = new ArrayList<>();
+ for (Region region : regions) {
+ FormDataRequest request;
+ try {
+ log.debug("Preparing request for {} region", region.getCountry());
+ request = paymentsService.prepareFormData();
+ log.debug("Request for {} region successfully prepared", region.getCountry());
+ } catch (Exception e) {
+ log.error("Unable to prepare request for {}:", region.getCountry(), e);
+ continue;
+ }
+ FormDataResponse response = seleniumService.executePaymentRequest(request, region);
+ responses.add(response);
+ log.debug("Metrics for {} region saved", region.getCountry());
+ }
+ metricsService.updateMetrics(responses);
+ log.info("Finished processing requests from {} regions", regions);
+
+ }
+}
diff --git a/src/main/java/dev/vality/beholder/service/MetricsService.java b/src/main/java/dev/vality/beholder/service/MetricsService.java
new file mode 100644
index 0000000..01a3f1a
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/service/MetricsService.java
@@ -0,0 +1,158 @@
+package dev.vality.beholder.service;
+
+import dev.vality.beholder.model.FormDataResponse;
+import dev.vality.beholder.util.MetricUtil;
+import io.micrometer.core.instrument.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Predicate;
+
+import static java.util.stream.Collectors.toList;
+
+@Service
+@Slf4j
+public class MetricsService {
+
+ private final MeterRegistry meterRegistry;
+ private final MultiGauge resourcesLoadingTimings;
+ private final MultiGauge formDataWaitingDurationGauges;
+ private final MultiGauge formDataReceivingDuration;
+ private final MultiGauge formDomCompleteDuration;
+ private final Map formLoadingCounters;
+ private final Map formLoadingFailedCounters;
+
+
+ public MetricsService(MeterRegistry meterRegistry) {
+
+ this.meterRegistry = meterRegistry;
+
+ this.resourcesLoadingTimings = MultiGauge.builder("beholder_form_resource_loading_duration")
+ .description("Resources uploading time")
+ .baseUnit("millis")
+ .register(meterRegistry);
+
+ this.formDataWaitingDurationGauges = MultiGauge.builder("beholder_form_waiting_response_duration")
+ .description("Time between sending request and first received byte of data")
+ .baseUnit("millis")
+ .register(meterRegistry);
+
+ this.formDataReceivingDuration = MultiGauge.builder("beholder_form_receiving_response_duration")
+ .description("Time between receiving first and last byte of data")
+ .baseUnit("millis")
+ .register(meterRegistry);
+
+ this.formDomCompleteDuration = MultiGauge.builder("beholder_form_dom_complete_duration")
+ .description("Time between sending request and fully rendered DOM")
+ .baseUnit("millis")
+ .register(meterRegistry);
+
+ this.formLoadingCounters = new HashMap<>();
+
+ this.formLoadingFailedCounters = new HashMap<>();
+
+ }
+
+ public void updateMetrics(List formDataResponses) {
+ log.debug("Updating beholder metrics started");
+ updateWaitingResponseDuration(formDataResponses);
+ updateFormDataReceivingDuration(formDataResponses);
+ updateFormDomCompleteDuration(formDataResponses);
+ updateResourceLoadingDuration(formDataResponses);
+ updateFormLoadingRequestsTotal(formDataResponses);
+ updateFormLoadingFailedRequestsTotal(formDataResponses);
+ log.debug("Updating beholder metrics finished");
+ }
+
+ private void updateWaitingResponseDuration(List formDataResponses) {
+ formDataWaitingDurationGauges.register(
+ formDataResponses.stream()
+ .filter(Predicate.not(FormDataResponse::isFailed))
+ .map(formDataResponse -> MultiGauge.Row.of(
+ MetricUtil.createCommonTags(formDataResponse),
+ MetricUtil.calculateWaitingResponseDuration(formDataResponse.getFormPerformance())))
+ .collect(toList()),
+ true
+ );
+ }
+
+ private void updateFormDataReceivingDuration(List formDataResponses) {
+ formDataReceivingDuration.register(
+ formDataResponses.stream()
+ .filter(Predicate.not(FormDataResponse::isFailed))
+ .map(formDataResponse -> MultiGauge.Row.of(
+ MetricUtil.createCommonTags(formDataResponse),
+ MetricUtil.calculateDataReceivingDuration(formDataResponse.getFormPerformance())))
+ .collect(toList()),
+ true
+ );
+ }
+
+ private void updateFormDomCompleteDuration(List formDataResponses) {
+ formDomCompleteDuration.register(
+ formDataResponses.stream()
+ .filter(Predicate.not(FormDataResponse::isFailed))
+ .map(formDataResponse -> MultiGauge.Row.of(
+ MetricUtil.createCommonTags(formDataResponse),
+ MetricUtil.calculateDomCompleteDuration(formDataResponse.getFormPerformance())))
+ .collect(toList()),
+ true
+ );
+ }
+
+ private void updateFormLoadingRequestsTotal(List formDataResponses) {
+ for (FormDataResponse response : formDataResponses) {
+ String id = MetricUtil.getCounterId(response);
+ Counter counter = formLoadingCounters.getOrDefault(id,
+ Counter.builder("beholder_form_loading_requests")
+ .description("Total requests for form upload")
+ .tags(MetricUtil.createCommonTags(response))
+ .baseUnit("total")
+ .register(meterRegistry));
+ counter.increment();
+ formLoadingCounters.put(id, counter);
+ }
+ }
+
+ private void updateFormLoadingFailedRequestsTotal(List formDataResponses) {
+ for (FormDataResponse response : formDataResponses) {
+ if (response.isFailed()) {
+ String id = MetricUtil.getCounterId(response);
+ Counter counter = formLoadingFailedCounters.getOrDefault(id,
+ Counter.builder("beholder_form_loading_failed")
+ .description("Total failed requests for form upload")
+ .tags(MetricUtil.createCommonTags(response))
+ .baseUnit("total")
+ .register(meterRegistry));
+ counter.increment();
+ formLoadingFailedCounters.put(id, counter);
+ }
+ }
+ }
+
+ private void updateResourceLoadingDuration(List formDataResponses) {
+ formDataResponses.stream()
+ .filter(Predicate.not(FormDataResponse::isFailed))
+ .forEach(
+ formDataResponse ->
+ resourcesLoadingTimings.register(
+ formDataResponse.getNetworkLogs().stream()
+ .map(networkLog -> MultiGauge.Row.of(
+ MetricUtil.createCommonTags(formDataResponse)
+ .and("resource",
+ MetricUtil.getNormalisedPath(networkLog,
+ formDataResponse.getRequest()
+ .getInvoiceId(),
+ formDataResponse.getRequest()
+ .getInvoiceAccessToken())),
+ MetricUtil.calculateRequestDuration(networkLog)))
+ .collect(toList()),
+ true
+ )
+ );
+ }
+
+}
diff --git a/src/main/java/dev/vality/beholder/service/PaymentsService.java b/src/main/java/dev/vality/beholder/service/PaymentsService.java
new file mode 100644
index 0000000..927782e
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/service/PaymentsService.java
@@ -0,0 +1,77 @@
+package dev.vality.beholder.service;
+
+import dev.vality.beholder.config.properties.PaymentsProperties;
+import dev.vality.beholder.model.FormDataRequest;
+import dev.vality.beholder.security.KeycloakService;
+import dev.vality.beholder.util.PaymentsUtil;
+import dev.vality.swag.payments.ApiClient;
+import dev.vality.swag.payments.api.ClaimsApi;
+import dev.vality.swag.payments.api.InvoicesApi;
+import dev.vality.swag.payments.api.PartiesApi;
+import dev.vality.swag.payments.api.ShopsApi;
+import dev.vality.swag.payments.model.*;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.HttpStatusCodeException;
+
+@Service
+@RequiredArgsConstructor
+public class PaymentsService {
+
+ private final ApiClient apiClient;
+ private final PartiesApi partiesApi;
+ private final ShopsApi shopsApi;
+ private final InvoicesApi invoicesApi;
+ private final ClaimsApi claimsApi;
+ private final KeycloakService keycloakService;
+ private final PaymentsProperties paymentsProperties;
+
+ public FormDataRequest prepareFormData() {
+ apiClient.setApiKey(keycloakService.getUserToken());
+ String shopId = getShopId();
+ InvoiceParams invoiceParams = PaymentsUtil.createInvoiceParams(shopId);
+ InvoiceAndToken invoiceAndToken = invoicesApi.createInvoice(PaymentsUtil.getRequestId(), invoiceParams,
+ PaymentsUtil.getRequestDeadline(paymentsProperties.getApiTimeoutSec()));
+ return createFormDataRequest(invoiceAndToken);
+ }
+
+ private String getShopId() {
+ Party party = partiesApi.getMyParty(PaymentsUtil.getRequestId(),
+ PaymentsUtil.getRequestDeadline(paymentsProperties.getApiTimeoutSec()));
+ var request = paymentsProperties.getRequest();
+ String shopId = request.getShopId();
+ try {
+ shopsApi.getShopByIDForParty(PaymentsUtil.getRequestId(), shopId, party.getId(),
+ PaymentsUtil.getRequestDeadline(paymentsProperties.getApiTimeoutSec()));
+ } catch (HttpStatusCodeException httpStatusCodeException) {
+ if (!isNotFoundError(httpStatusCodeException) || !request.getCreateShopIfNotFound()) {
+ throw httpStatusCodeException;
+ }
+ sendShopCreationClaim(request, shopId);
+ }
+ return shopId;
+ }
+
+ private void sendShopCreationClaim(PaymentsProperties.Request request, String shopId) {
+ ClaimChangeset changeset = PaymentsUtil.buildCreateShopClaim(request.getPaymentInstitutionId(),
+ shopId, request.getCategoryId());
+ claimsApi.createClaim(PaymentsUtil.getRequestId(), changeset,
+ PaymentsUtil.getRequestDeadline(paymentsProperties.getApiTimeoutSec()));
+ }
+
+ private FormDataRequest createFormDataRequest(InvoiceAndToken invoiceAndToken) {
+ FormDataRequest formDataRequest = new FormDataRequest();
+ formDataRequest.setCardInfo(FormDataRequest.Card.builder()
+ .pan(PaymentsUtil.TEST_CARD_PAN)
+ .cvv(PaymentsUtil.TEST_CARD_CVV)
+ .expiration(PaymentsUtil.TEST_CARD_EXPIRATION).build());
+ formDataRequest.setInvoiceId(invoiceAndToken.getInvoice().getId());
+ formDataRequest.setInvoiceAccessToken(invoiceAndToken.getInvoiceAccessToken().getPayload());
+ return formDataRequest;
+ }
+
+ private boolean isNotFoundError(HttpStatusCodeException httpStatusCodeException) {
+ return HttpStatus.NOT_FOUND.equals(httpStatusCodeException.getStatusCode());
+ }
+}
diff --git a/src/main/java/dev/vality/beholder/service/SeleniumService.java b/src/main/java/dev/vality/beholder/service/SeleniumService.java
new file mode 100644
index 0000000..0e5cc37
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/service/SeleniumService.java
@@ -0,0 +1,118 @@
+package dev.vality.beholder.service;
+
+import dev.vality.beholder.converter.LogEntriesToNetworkLogsConverter;
+import dev.vality.beholder.model.*;
+import dev.vality.beholder.util.SeleniumUtil;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.openqa.selenium.By;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.logging.LogEntries;
+import org.openqa.selenium.logging.LogType;
+import org.openqa.selenium.remote.DesiredCapabilities;
+import org.openqa.selenium.remote.RemoteWebDriver;
+import org.openqa.selenium.support.ui.ExpectedConditions;
+import org.openqa.selenium.support.ui.WebDriverWait;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+@Slf4j
+@RequiredArgsConstructor
+public class SeleniumService {
+
+ private final URL seleniumUrl;
+ private final String formUrl;
+ private final Long formTimeoutSec;
+ private final DesiredCapabilities desiredCapabilities;
+ private final LogEntriesToNetworkLogsConverter logEntriesToNetworkLogsConverter;
+
+ @Getter
+ private final Browser browser = Browser.CHROME;
+
+ public FormDataResponse executePaymentRequest(FormDataRequest formDataRequest, Region region) {
+ DesiredCapabilities capabilities = new DesiredCapabilities(desiredCapabilities);
+ updateCapabilities(capabilities, region);
+
+ RemoteWebDriver driver = null;
+ try {
+ driver = new RemoteWebDriver(seleniumUrl, capabilities);
+ driver.get(prepareParams(formDataRequest));
+
+ //noinspection rawtypes
+ ArrayList performanceMetrics = (ArrayList) driver.executeScript(SeleniumUtil.PERFORMANCE_SCRIPT);
+ fillAndSendPaymentRequest(driver, formDataRequest.getCardInfo());
+
+ LogEntries les = driver.manage().logs().get(LogType.PERFORMANCE);
+ List networkLogs = logEntriesToNetworkLogsConverter.convert(les.getAll());
+
+ return FormDataResponse.builder()
+ .networkLogs(networkLogs)
+ .request(formDataRequest)
+ .formPerformance(
+ FormDataResponse.FormPerformance.builder()
+ .requestStartAt(castToDouble(performanceMetrics.get(0)))
+ .responseStartAt(castToDouble(performanceMetrics.get(1)))
+ .responseEndAt(castToDouble(performanceMetrics.get(2)))
+ .domCompletedAt(castToDouble(performanceMetrics.get(3)))
+ .build()
+ )
+ .region(region)
+ .browser(browser)
+ .build();
+ } catch (Exception e) {
+ log.error("Error during sending request from {}:", region.getCountry(), e);
+ } finally {
+ if (driver != null) {
+ driver.quit();
+ }
+ }
+ return FormDataResponse.builder()
+ .region(region)
+ .browser(browser)
+ .failed(true)
+ .build();
+ }
+
+ private void updateCapabilities(DesiredCapabilities capabilities, Region region) {
+ capabilities.setCapability("browser", browser.getLabel());
+ capabilities.setCapability("geoLocation", region.getCode());
+ capabilities.setCapability("name", String.format("Payment from %s", region.getCountry()));
+ }
+
+ private void fillAndSendPaymentRequest(RemoteWebDriver driver, FormDataRequest.Card card) {
+ WebElement cardNumInput = new WebDriverWait(driver, formTimeoutSec).until(
+ ExpectedConditions.visibilityOfElementLocated(By.ById.id("card-number-input")));
+ cardNumInput.sendKeys(card.getPan());
+ driver.findElementById("expire-date-input").sendKeys(card.getExpiration());
+ driver.findElementById("secure-code-input").sendKeys(card.getCvv());
+ driver.findElementById("card-holder-input").sendKeys("Ivan Ivanov");
+ driver.findElementById("email-input").sendKeys("test@test.com");
+ driver.findElementById("pay-btn").click();
+ new WebDriverWait(driver, formTimeoutSec).until(
+ ExpectedConditions.visibilityOfElementLocated(By.ById.id("success-icon")));
+ }
+
+ private Double castToDouble(Object object) {
+ if (object instanceof Double) {
+ return (Double) object;
+ } else if (object instanceof Long) {
+ return ((Long) object).doubleValue();
+ } else {
+ log.warn("Unable to cast {} to double", object);
+ }
+
+ return Double.NaN;
+ }
+
+ private String prepareParams(FormDataRequest formDataRequest) {
+ return UriComponentsBuilder.fromHttpUrl(formUrl)
+ .queryParam("invoiceAccessToken", formDataRequest.getInvoiceAccessToken())
+ .queryParam("invoiceID", formDataRequest.getInvoiceId())
+ .build()
+ .toString();
+ }
+}
diff --git a/src/main/java/dev/vality/beholder/util/FileUtil.java b/src/main/java/dev/vality/beholder/util/FileUtil.java
new file mode 100644
index 0000000..84b9067
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/util/FileUtil.java
@@ -0,0 +1,22 @@
+package dev.vality.beholder.util;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.vality.beholder.model.Region;
+import lombok.experimental.UtilityClass;
+import org.springframework.core.io.Resource;
+
+import java.io.*;
+import java.util.List;
+
+@UtilityClass
+public class FileUtil {
+
+ public static List readRegions(Resource resource) throws IOException {
+ final ObjectMapper objectMapper = new ObjectMapper();
+ return objectMapper.readValue(
+ resource.getFile(),
+ new TypeReference<>() {
+ });
+ }
+}
diff --git a/src/main/java/dev/vality/beholder/util/MetricUtil.java b/src/main/java/dev/vality/beholder/util/MetricUtil.java
new file mode 100644
index 0000000..527ea11
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/util/MetricUtil.java
@@ -0,0 +1,58 @@
+package dev.vality.beholder.util;
+
+import dev.vality.beholder.model.FormDataResponse;
+import dev.vality.beholder.model.NetworkLog;
+import io.micrometer.core.instrument.Tags;
+import lombok.experimental.UtilityClass;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@UtilityClass
+public class MetricUtil {
+
+ public static String getNormalisedPath(NetworkLog networkLog, String invoiceId, String invoiceToken) {
+ String path = networkLog.getResource();
+ path = path.replaceAll(invoiceId, "{invoice_id}")
+ .replaceAll(invoiceToken, "{invoice_token}");
+ if (path.contains("nocache=")) {
+ path = path.substring(0, path.length() - 13);
+ }
+ return path;
+ }
+
+ public static double calculateRequestDuration(NetworkLog networkLog) {
+ return calculateDiff(networkLog.getStart(), networkLog.getEnd());
+ }
+
+ public static double calculateWaitingResponseDuration(FormDataResponse.FormPerformance performance) {
+ return calculateDiff(performance.getRequestStartAt(), performance.getResponseStartAt());
+ }
+
+ public static double calculateDataReceivingDuration(FormDataResponse.FormPerformance performance) {
+ return calculateDiff(performance.getResponseStartAt(), performance.getResponseEndAt());
+ }
+
+ public static double calculateDomCompleteDuration(FormDataResponse.FormPerformance performance) {
+ return calculateDiff(performance.getRequestStartAt(), performance.getDomCompletedAt());
+ }
+
+ private static double calculateDiff(Double from, Double to) {
+ if (isNumber(from) && isNumber(to)) {
+ return Math.round(to - from);
+ }
+ return 0.0;
+ }
+
+ private static boolean isNumber(Double value) {
+ return value != null && !value.isNaN();
+ }
+
+ public Tags createCommonTags(FormDataResponse formDataResponse) {
+ return Tags.of("browser", formDataResponse.getBrowser().getLabel(),
+ "region", formDataResponse.getRegion().getCode());
+ }
+
+ public String getCounterId(FormDataResponse formDataResponse) {
+ return String.join("_", formDataResponse.getBrowser().name(), formDataResponse.getRegion().getCountry());
+ }
+}
diff --git a/src/main/java/dev/vality/beholder/util/PaymentsUtil.java b/src/main/java/dev/vality/beholder/util/PaymentsUtil.java
new file mode 100644
index 0000000..1c19ab1
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/util/PaymentsUtil.java
@@ -0,0 +1,136 @@
+package dev.vality.beholder.util;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.vality.swag.payments.model.*;
+import lombok.experimental.UtilityClass;
+
+import java.time.OffsetDateTime;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+@UtilityClass
+public class PaymentsUtil {
+
+ public static final String TEST_CARD_PAN = "4242424242424242";
+ public static final String TEST_CARD_EXPIRATION = "12/24";
+ public static final String TEST_CARD_CVV = "123";
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ public static final String CURRENCY_CODE = "RUB";
+
+ public static PartyModification createShopModification(String shopId, String contractId, String payoutToolId) {
+ return new ShopCreation()
+ .details(
+ new ShopDetails()
+ .name("OOOBlackMaster")
+ .description("Goods for education"))
+ .location(
+ new ShopLocationUrl()
+ .url("http://all-time-favourite-spinners.com/"))
+ .contractID(contractId)
+ .payoutToolID(payoutToolId)
+ .shopID(shopId)
+ .shopModificationType(ShopModification.ShopModificationTypeEnum.SHOPCREATION)
+ .partyModificationType(PartyModification.PartyModificationTypeEnum.SHOPMODIFICATION);
+ }
+
+ public static PartyModification createShopAccountCreationModification(String shopId) {
+ return new ShopAccountCreation()
+ .currency(CURRENCY_CODE)
+ .shopID(shopId)
+ .shopModificationType(ShopModification.ShopModificationTypeEnum.SHOPACCOUNTCREATION)
+ .partyModificationType(PartyModification.PartyModificationTypeEnum.SHOPMODIFICATION);
+ }
+
+ public static PartyModification createShopCategoryChangeModification(String shopId, Integer categoryId) {
+ return new ShopCategoryChange()
+ .categoryID(categoryId)
+ .shopID(shopId)
+ .shopModificationType(ShopModification.ShopModificationTypeEnum.SHOPCATEGORYCHANGE)
+ .partyModificationType(PartyModification.PartyModificationTypeEnum.SHOPMODIFICATION);
+ }
+
+ public static ClaimChangeset buildCreateShopClaim(Integer paymentInstitutionId, String shopId, Integer categoryId) {
+ PartyModification contractCreation = createContractModification(shopId, paymentInstitutionId);
+ PartyModification contractModification = createPayoutToolModification(shopId);
+ PartyModification shopModification = createShopModification(shopId, shopId, shopId);
+ PartyModification categoryChange = createShopCategoryChangeModification(shopId, categoryId);
+ PartyModification accountChange = createShopAccountCreationModification(shopId);
+
+ ClaimChangeset changeset = new ClaimChangeset();
+ changeset.add(contractCreation);
+ changeset.add(contractModification);
+ changeset.add(shopModification);
+ changeset.add(categoryChange);
+ changeset.add(accountChange);
+ return changeset;
+ }
+
+ public static PartyModification createContractModification(String contractId, Integer paymentInstitutionId) {
+ return new ContractCreation()
+ .contractor(createContractor())
+ .paymentInstitutionID(paymentInstitutionId)
+ .contractID(contractId)
+ .contractModificationType(ContractModification.ContractModificationTypeEnum.CONTRACTCREATION)
+ .partyModificationType(PartyModification.PartyModificationTypeEnum.CONTRACTMODIFICATION);
+ }
+
+ public static Contractor createContractor() {
+ return new RussianLegalEntity()
+ .registeredName("testRegisteredName")
+ .registeredNumber("1234567890123")
+ .inn("1234567890")
+ .actualAddress("testActualAddress")
+ .postAddress("testPostAddress")
+ .representativePosition("testRepresentativePosition")
+ .representativeFullName("testRepresentativeFullName")
+ .representativeDocument("testRepresentativeDocument")
+ .bankAccount(createBankAccount())
+ .entityType(LegalEntity.EntityTypeEnum.RUSSIANLEGALENTITY)
+ .contractorType(Contractor.ContractorTypeEnum.LEGALENTITY);
+ }
+
+ public static PartyModification createPayoutToolModification(String contractId) {
+ return new ContractPayoutToolCreation()
+ .payoutToolID(contractId)
+ .currency(CURRENCY_CODE)
+ .details(new PayoutToolDetailsBankAccount()
+ .account("12345678901234567890")
+ .bankName("testBankName")
+ .bankPostAccount("12345678901234567890")
+ .bankBik("123456789")
+ )
+ .contractID(contractId)
+ .contractModificationType(ContractModification.ContractModificationTypeEnum.CONTRACTPAYOUTTOOLCREATION)
+ .partyModificationType(PartyModification.PartyModificationTypeEnum.CONTRACTMODIFICATION);
+ }
+
+ public static BankAccount createBankAccount() {
+ return new BankAccount()
+ .account("12345678901234567890")
+ .bankName("testBankName")
+ .bankPostAccount("12345678901234567890")
+ .bankBik("123456789");
+ }
+
+ public static InvoiceParams createInvoiceParams(String shopId) {
+ return new InvoiceParams()
+ .shopID(shopId)
+ .dueDate(OffsetDateTime.now().plusDays(1))
+ .currency(CURRENCY_CODE)
+ .product("Order num 12345")
+ .amount(1000L)
+ .metadata(MAPPER.createObjectNode());
+ }
+
+ public static String getRequestId() {
+ return RandomUtil.generateString(8);
+ }
+
+ public static String getRequestDeadline(long seconds) {
+ return ZonedDateTime.now()
+ .plusSeconds(seconds)
+ .format(DateTimeFormatter.ISO_INSTANT);
+ }
+
+}
diff --git a/src/main/java/dev/vality/beholder/util/RandomUtil.java b/src/main/java/dev/vality/beholder/util/RandomUtil.java
new file mode 100644
index 0000000..29be612
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/util/RandomUtil.java
@@ -0,0 +1,22 @@
+package dev.vality.beholder.util;
+
+import lombok.experimental.UtilityClass;
+
+import java.util.Random;
+
+@UtilityClass
+public class RandomUtil {
+
+ private static final String ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ private static final Random STRING_RANDOM = new Random();
+
+ public String generateString(int length) {
+ StringBuilder builder = new StringBuilder(length);
+
+ for (int i = 0; i < length; i++) {
+ builder.append(ALPHABET.charAt(STRING_RANDOM.nextInt(ALPHABET.length())));
+ }
+
+ return builder.toString();
+ }
+}
diff --git a/src/main/java/dev/vality/beholder/util/SeleniumUtil.java b/src/main/java/dev/vality/beholder/util/SeleniumUtil.java
new file mode 100644
index 0000000..b23abc8
--- /dev/null
+++ b/src/main/java/dev/vality/beholder/util/SeleniumUtil.java
@@ -0,0 +1,40 @@
+package dev.vality.beholder.util;
+
+import lombok.experimental.UtilityClass;
+import org.openqa.selenium.logging.LogType;
+import org.openqa.selenium.logging.LoggingPreferences;
+import org.openqa.selenium.remote.CapabilityType;
+import org.openqa.selenium.remote.DesiredCapabilities;
+
+import java.util.logging.Level;
+
+@UtilityClass
+public class SeleniumUtil {
+
+ public static final String PERFORMANCE_SCRIPT = """
+ var data = new Array();
+ var navigation = window.performance.getEntriesByType("navigation")[0];
+ data[0] = navigation.requestStart;
+ data[1] = navigation.responseStart;
+ data[2] = navigation.responseEnd;
+ data[3] = navigation.domComplete;
+ return data;""";
+
+ public static DesiredCapabilities getCommonCapabilities() {
+ var capabilities = new DesiredCapabilities();
+ capabilities.setCapability("build", "Simple payment test");
+ var logPrefs = new LoggingPreferences();
+ logPrefs.enable(LogType.PERFORMANCE, Level.ALL);
+ capabilities.setCapability(CapabilityType.LOGGING_PREFS, logPrefs);
+ return capabilities;
+ }
+
+ public static DesiredCapabilities getLambdaTestCapabilities() {
+ var capabilities = getCommonCapabilities();
+ capabilities.setCapability("network", false);
+ capabilities.setCapability("visual", false);
+ capabilities.setCapability("video", false);
+ capabilities.setCapability("console", false);
+ return capabilities;
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 0000000..69ba07a
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,62 @@
+server:
+ port: '@server.port@'
+
+management:
+ server:
+ port: '@management.port@'
+ metrics:
+ tags:
+ application: '@project.name@'
+ export:
+ prometheus:
+ enabled: true
+ endpoint:
+ health:
+ show-details: always
+ metrics:
+ enabled: true
+ prometheus:
+ enabled: true
+ endpoints:
+ web:
+ exposure:
+ include: health,info,prometheus
+
+spring:
+ application:
+ name: '@project.name@'
+ output:
+ ansi:
+ enabled: always
+info:
+ version: '@project.version@'
+ stage: dev
+logging:
+ level:
+ root: INFO
+
+schedule:
+ cron: '0 */5 * * * *' #every 5 minutes
+keycloak:
+ url: http://127.0.0.1
+ user: user
+ password: admin
+ resource: external
+payments:
+ api-url: http://127.0.0.1
+ api-timeout-sec: 30
+ form-url: http://127.0.0.1
+ form-timeout-sec: 30
+ request:
+ shop-id: 1
+ create-shop-if-not-found: true
+ payment-institution-id: 1
+ category-id: 1
+selenium:
+ url: http://127.0.0.1
+ port: 4444
+ use-external-provider: false
+ regions:
+ - BR
+dictionary:
+ regions: classpath:regions.json
diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt
new file mode 100644
index 0000000..51188b4
--- /dev/null
+++ b/src/main/resources/banner.txt
@@ -0,0 +1,140 @@
+
+ ` `
+ `` ` ` ` `
+ ` ` ,***,```
+ :*; `;*:````
+ :i` ` .i*```
+ `.*` ``..` :*``
+ `*`` `:#zz, `*,``
+ ,; ```nx#i#` .+.`
+ :: ``nxz#+` `z,``
+ :;` `:#nz: ` +:.`
+ ,*`````.,,` #;.`
+ .+,` `` `` `n;.`
+ .i;```` ```` i+:.`
+ `.z:. ` ` ``i#;:``
+ `.+#:,````:#+*:,`
+ ``:*z+z#+Mn+i;,``
+ `.+;x@+n**;:,.`
+ `i;nzi#*::.``
+ ``:;*z**;,.`
+ :iiiz;:.`
+ `;:,:#;,`
+ `*i;*+;.`
+ ` ` ::;,i+:,` ` ` ` `` `
+ `````` `;:..:*:.` ` ` .`` `
+ `:*;;+i` ` ` `*,`.*+:.` ```i*;i*,` ``
+ `,;+;;+#i;*i.`` *,,`i**,. i*` ```ii. ` `.``
+ `:ii;:::*nM#;:i+; .i,``.ni.` ;:``+##. i;``:*;;;i*,``
+ ` ```` ,++:```+nx; ` :i.````:i:`.i+:.` .i``ixxzz.`+i+;:;,,,`;*:`.::;i+z++i+i*+**i.`` `
+ `````ii:`.:;M#,` `` `i:`` `*i` .#i:.` i: `*xx#z,`,+;,.` ` ` `*#i:ii:++##zi.````:*.``
+ `` ` ` `:;:``#++M# ``..``,i.``,*;``i#;,` #, .+zx*.` z``` .````i;``,`.,;;nn`````` .*.```
+ ` ` ``````:*. .**nM;```:z#i ,*,``*;, ,;#:.` `#. ``::````* ``++n+`*:````.i;+#x: ```````*,.``
+ `,:::::.```+;.`,:n#x: `:zzn#.:+,.,;+. `**:.` `+, ` ,;,:;:::;n, ``.:i;i;:i` ``.:,``.+.`
+ ,*::,.,:i,``*;` ;*z+z, `inz*#.*+,.:ii, `+;,. ` ``#. ```` `*.i:####z:` .,+#####: ` ;#zz: `z:``
+ ` ` `:i```` ```*,.*i` .,zi+:``:nxz;;zi,.+*i.``+;,` `,z:` ` `:+...x**i+:, `.*ii#ii;;#. `,zM*i#``z:.`
+ ,i ``,**.`,#,i:i ``n;;+ ``:;:;z*;,;;*::``+;,` ,*;*:` .:iz. .i+i:;+:. `*. :#;,,,*, :zWz##``n:.`
+ i.` `;xn#n` i;ii#. :#;;zi;;i##*;,.+:*+*``+i,```.,;;i..+@@#i*i+```:#i;,+,, `;n.`.*;.`.i;` `*zxz:`*+:.`
+ ` `.* ``.zn+,#:`;*:#:+ `:#:;*+##*i;..`++M#ii;++**ii;::. .,iz#:z*+: ,zi:;i:```x:z.`+:.``,+`` `:::`izi,.`
+ ;.` .znWzn,`:*:;*ii` .*i:;;::,.`,i*,`` ` ` ` `,iz;in#:i ```+:;*,, +#ii. +:.```,z: ``.:+#i:.``
+ i` ` :#nn+` :*::*:*,` `,**,.,,.`i;...``` `` ` `,:*;;z#*#.i`` `#;+:: ``#++;, ;i.```.;*#####+i;,`
+ `i` ` ``````;+:.,#:;,```;**,.`.*i.`` ` ` ` ``,,:*;+z*i;*,z; z+.` ;#iz;i`.z,.` `.,;iiii;:,.`
+ ` ,i ` ``````zi,..:#;+:`.`#;#..+*i:` `..,..,`,:+z+#+i;:,;:i. ` :+,` ` **;z:#..z:``` ..,::::,.``
+ ``*: `` ,n#i,`..:#;*i.:,Mi##;+..``` `,:**ii++nx#**i;;:,...+,+.;+:`` .ni:#;n+:i+i,` ``.``.``
+ `:#, `.i*#z*:,``..:+;#x*,**z*,.` ..*i;:;;##*i;i:::,.````*in::` `` `+*;,*iWx;:;:i:`` ````
+ `.;*zznW+#n+i;,.` `.;z;#x++W:#..` `;:zi,.```,+*;,,...``.::i*, ` ` *#;:.*#;` ``` +,```
+ ``:i+xxi+#*i;,.`` `*+ii**xxni+#.````,,x:;:.` `..+:,````:*;:,``` ` ` i#*:,`#,` `````;i```
+ `,;;;i;#*;:,```:ii.,,:;:izn:#:... `:inii:.:. `:i:.``:*: ..:` ` ` `,##i;,.`i` `:i;` :*.``
+ ``+``.;#;:`.`;i:,+:,,:,,n*++:`..` .,++#,n,.` `..+,`*:`.:,.` ` ,,;+n*i:.`.i``;nnn*`:*,.`
+ `*:` .,#*ii+, `..,,`:.,,z:,#;.` ;:ni+:nz;.``i;z;i.:*i;.` `.;:#*;*,`#i:. `* `nMi*+`;+,.`
+ `:i,:` ``` ` ``;:,.,:,x:**i.. *:zi*;i+i:. ::+#.,+#;` `;,;;#i:` ;+,.` `# `+Mnn*`#i,.`` ` `
+ ` `.i+ii,` ` ` `.;i#*n+zz*+,*i*.. `+:*ii;+ni.,,:i*,i#+::, `::;*n*zi` `,*,` `,*..*+*.*+i,` ` ` ` ```:;**;,`` ``
+ ```.i#;;i;i,:i*z**:;ii;;#n:+i,:, .++;+;i+M#i;:ini,xi*;..,;:##++;z*`` ..#,.` `.*+;.,*z#i:.````` ``i:. ,;+.` `
+ ` `.,;#+;;:ii;ii*znz++zz,#**i;+:,;*n;+;i+zi**#+#`inn;+*:;,n#*;*,xi. ` .*i,` ``,:*z#*ii:.`` ```.;*,```` `:*``
+ ` `i++++#znn+++###+***i;;;i+*z:#+z,:#;++:ni#nnz+zx*`+nMnx:ii+z*###:x;.```,,+,.:i++#zz*;i:.`` .:*i;i:*.```,,```z..`
+ ,i*.` `.:++#*;;;::,,,.,:i*W:M+,i:i*iz#;+M@Mz@#M:+WznWnz#:x@@z#;zx#,.,,;.n+i::::,.,;+;.``:*i.`:i;**``.#xz#` :*.`
+ ` ;+,. ` `````i:*+:,,....```i;@+;ii:++:+Wn#;n@@xzzx#i;,.,;+M+zWxW#;M*i;;:;+.*;*;#****;;,;i,i;i` ``:ni``;xn:z,`.+,``
+ .;i,` `..` ` ``;,,*i,` ` ` `;i@@,zxWii#z+WWW#,. ``,` `` `:i+x*:zn*i:i;*#:##;:,::,```,`:#i+.```.,+@; `:nxnz, .z,``
+ `,i;` ..,;;,..`. `` ,;;i*;.`` `;z##+,xznzzMW@z:` ` . `.``.;M#zx+i:i*#.n+::,,:` ``++,``.*:;i#;``.:zz;``:#:``
+ i;,`;:izn#zzzi,:.`` ``:,;****izn@@W*;MxMMn:,` ` .,;:,i*:;;,:,` ` ``#WxMnWzz#.ni;:.`` ```` `,i.,;:;iiii;``````` `zi,.
+ .**`*.+z*ii;ii+#*:#i`` ``:.`,**+*#MMzi:+xi``. ```:;+nnMnx#M+#**:`` ``:xWx@nM:iz:::,:.`` `*i;:##+++#n````````;z;,``
+ `` ``**z#;+#i;:,,,::;*#:+#i``` `;:+#+#Wx#*: ` `.:,*zMMxxnnzznnxMWz*;,`` .MMWMz,nW++nn#ii;: ` *;;n*i;:;;i+. .ini:.`
+ `` `*+MMx;ni:,.```..,:i+#i#xi*:` .;;*Wzi.`.. ` ,**zzi;i*++**##*:;nWx#i,` `iWxn;*MzzzMi,,;,,, :+z*;:,..,,*z,,,+z#i;,.`
+ `` `.*zi;+xW*#;,.` ```,;in*i#+*:,..;;*##*``` . .`;;ixn+i+i` ``` ``;+;#+zWz:` `:W#,WMMzz,:x@nz#,.` :x*;:.````.,:+z#+ii;,.`
+ `` :*:` ```**+;.` ` .i+***+*#:i#zxi+nxz@` ``` `;z*MM,zi:` ` ``*``` ,i**@xz;;.,,#+MMM#.;W@@x*#;. `i+i:.`` `.,:;i;::.``
+ ``,* `` ` ,+:` ,ii,;i*+#MxW#i;*zxxn@# ` .ii;#z#x*x: `` .zM+` ` *+:z@M**. :MWx+inM#W#n@M+: ` zi;.` ```...,.``
+ ``*` ``` ``z:` i;. ``:**+zWxW@Wxz*:;n+ ;;M,nWz#W:` ``` #Mnx,` ` `;M*;#+#;.`:;#W@+i+###WWz*```#;,`` ` ````````
+ :; ``,,.` +;.` `:*` ```ii#;#xn####*x@@Mz `.iiMn.*MW;,` ` ` ;Mxznn. ` `.;Wz.M@i:,,`.x##*@zzW#@x, .,#;,` ``` `
+ ::``+#z#, ;*.` +i` `**:*+M+x##@#x@MM#n``i+#@i+M@*:,. ```znnznxi`` `,;@z:x@+,,.`z+MzxWn*##ni..+:+,.:**+; ```
+ ;,`;xx.+#` ,*.. ,*:` ``;#n#*++;zMnzWxz+i;:,+zW#+znW:,,, ``;nnnxnnn`` ,ixzM:W#zi: *W*i##Wx#n#,,`:i*+*:` .+:``
+ ;:`;nx,+z. ,*,.`,++ ```z+***+zzi,;**i::*;;iixx*z+@#`;:.` ``+xnnMnzM``` ` .:#+n#;+i*``,n*iz#;;*nz;:`;M#+, `#.``
+ .:;``+MMn* ` i+:.``++;.`.*+;::;*iz#:.``,..;.i+i,*zMx:;.:.` ``znnx@xnx.`````.;,Mixi#@*,.,i#:`,i#*n+#,:;*#` ` ``` `+.`
+ `.#` .:ii`` `zi,``.iin+;;;*+,,,;+;zWxz*#:,`,#xn;xi@#+,``.` `MnnM@nnx` ````,:;xin+;*+:`:+` :+MiMWin:+#+;` ``.:. `i:``
+ .,z;``` ``:n*;.```,+innnM+,*+*i*x*;zxx*::`*+W:#ix@+,.:..`` `xxnMx;nx` `` .,i*M+M+:xW+;n;+xx:#M@z+#.#M` `+nz#``;*.`
+ `,;*+:.,*z+*i:.```.:+i@#,` `,#MWz#+*:i.,,;;;i#xM,,,;..` ``zxnM,:nn `` ;#i:Wzz*,#z+:,+#z:++#@#z++`z. ,xM*#:`;*,`
+ ````,:i+##*ii;,.`` `.;++` ``` :@Wn#*i;:`,:i*,;i*#*;*,.,:.` *Mxxi#x+`````.i,,iMzii`:;;*;*z#z:*ix#z*i#,,` ;nMin:`;+,.
+ ```.::;;;;:,.. ` ` `:#.` ````` WWzM++;ii;i+:.:zi#n*+##n+i:.;xWM@MMz:,,;iiz+nMnz;i*xW@n#;::i**#MM##i*z,. ,xnnz``;+,.
+ ...,..``` `;:`` :nxx* xWn;*+@#xnn;.,#z+n+ii*+*;inzzi,..`.,:;:..:+x@z:;i,*nznznzznzz##nWW*zzz+.``.;i.` +i,`
+ ``````` `;,` ,zn#ix. xW+;:#n*+W*,:;,+:;i;::,,:.`,.`.`,i;i##ii;*,.*i,.,::++#i+Mxz#zW;*#WMx###;```````:z;,`
+ `i,``:nx+*n.`xi+;#i*##. .`i, ``.:;;,,` ` `,`,;::,:..`` , ` ,``,*ii.,#z;+***n@Mz*+++,````*zi:``
+ `;:``:znMx#`,i+zi;i:;:````:. ```....,,:` ,;i,` ` `` ` ;.```iii.;:::z#+n@x*+;++zz#z##*:,``
+ `:i `*#z+,`#+n**#.**.,*`,, `` ` `` .,:*+nn,.` ` `:z` `i..ii*;;;*+zn*#n#*:;iii*ii;,```
+ .*: `..``+++;i+`+zi,*,`:` ` ` ``` `..,,*+*;:.` .```,zzxi :# .n;ii;i,+##x+i#;,::;::,,.`
+ ..++::,,*M##.ii.`#M::i.., `i;i:;:. `` ` ` ` ````` ` ``` `i*` ;:.n:+,:*`;nx++n+i,.....```
+ `.:i++zzWn;;.i:..n#:.:`.` ::;.```` `..` ```. .`` ` ,` ``+ ` .`.:#,+::,i;nn+*;+;.````
+ ``.,:;#xWx;* ::`.+x;,.` `` ` ., .,`. .. ` .. `+.` `:,`*@,z.;;z`nx+;:,..`
+ ``,,+xxW#;.:+: *nz,*.,`,`. ` ,` ;ni .:,`:i;.`` :````#` ``,:`xn,+:;**`nni;,.``
+ ```*zzxnMM:;`iM..+@:.,: ,;..;, ,.,#* iMx;zMxnzn+;:...: ::i;`; ``,nMi*`i.#*.n@;+,``
+ `;nz#zM*Mi;,.n#`+@,` :`.i:;`.;i+:+ni ix, .xMMi``x#z+nz;.`iii*.,``ni:,i,,*: z#n*;;;`
+ `;*;i++n,z+:: ;x:*W:` :#:;:.;xzx+ :#;`*M, `#@#; W@ xzzM#*:nz*i,+z;#x:,*:*`,Mx#M+:``
+ ..,:;++,+ii+ .z+,M,;:`;i:inz#:M+`,#i iW, :@##; #x `@#@x`:+;;#zxz*nx:`#.+.`.:#nzi,.`
+ ``.,*+:+i#;in;:x +,,*z+:+x:.i`nz i@n ;@; ###@; .#z :##@n `Mn*zn*niW::z:i#.,;.,xi;,`
+ ` ``:;,x:.:n#ix,#,,#*nn*;*#:`# zz`+#@ ,@i n##@; :@z ##@#z `n*#xx#z#z.n,`z#,*iz`++:,`
+ ` :;i*,:` :nx:*.ni**z#;+i`z`+`#W n##, @+`z###; i#*.x#### ii+*MM#:+:`i, in:i;*i,#:.`
+ ` `i.;+nnn*`:M**,*#*W+Mz,+`*;i,*#`n### Mz`n###i z#;;@@##i`z:;xWni:#.:,:,i.``..*:i;.``
+ ` `;::*iii+z#,*M::.n;xz@+z;i.#:ii#;+#@W;+M`n###*.xM,n###M::i;iMMz,**`i;i,`:+#+:.;:*;``
+ `***+n#M+n;z,*:*`**+z@+Wi#.+:z:@#i####i#`n###*:Wn.@##@n`z+:MMM+:+,:+*:`;Mx+;;::,,+.``
+ ` `,:i;#x.zni+:+,,.#.zM+xW*n.n#:xx:####:x:z###+i#*+##@#;*+;n#xx,:z`#;# ,M+,n#+;:,`*:.`
+ `.,,;;+,n#z,.*.; *`*xzx#WWi*z#n@;##@##niz###++#:M##@W:Mn#@#n#.;z.*,#.in`+#n+z+++:i.`
+ `````*iz@MW;.,;,::,i##x###@;x@@@zn##@Wnnz###+Mzz###@*WW+@##n,;;z,+.z.#,z#x;n*i**n;,`
+ ````+#*nW#x,`z`:;,;;#M###@x+##@@####@@x####z@n#@##@W@x@##@n`;iW;#;+;:#Wx;x+#;::n:.`
+ `.,+nx*M#;x.:#i.*;@####@xM@########@n###W####@#########W+,*Wi#x,,#+i,*#i:,..,..`
+ .,i;MW+M:x.+;n;i;####@#@@@##@###@##W#######@#####@#####+*#M#;x:#,:i;z*;:.``.```
+ ``,**##n;W:;+zi;;####@#####@#@###@##########@###@##@###*ziMM*+x;.#z#*;,.`` `
+ `.:*MWx;x#;z+i*#######################@###@##@#####@@#iMizni*zi.+i#;:.`
+ ``.+nMM:xx:#++#MW@######@#######################@@W##@izi*#nzn, ;n*:..`
+ ``*nMM,zM:@*z#W+@##@##M@################@###@###W*##@n*i;+#M*,.,,#:. `
+ `**Mxi*x+n##zxi#@#@zW##########W@##########z@###*+##xzii#*@::Mz+;*.`
+ `nx+zn;+z+z,xn,####M########@W##W####@#WW##*@@###,W#@++#Mzni;+z+*;.``
+ `,;+xin:iWM,n+,W#@:#iz#######W#;W######x###ixxx#M`n#@z:znxn,#x#*;,.`
+ .,#+z*:;x#;+;,#@x,W:n######i#W:W######x;##innn#@ #iz#`W@nz:W*+;:.``
+ `.ii#*,n+#M,#i;@n.x.n#####W:#z,M######M.##;#x*##`i*n.+#zxiM##i:.``
+ ``,+#:.+;@@#,*#nn`n`z#####n`@#.x######M,W#iix,##`iin.xWxMxni#;,`
+ ``.:+,:::W#@+.+@# n`*#####n #*`z######M,n#i,x.@n i#z;MWWz++#i:.`
+ ` `.+.;`+@###,iMi`n`i##@##z #i z####@#M,z#:`M,M+ i#.;WMMxxi#;,`
+ ` `;iiinM##@M+,#zin`,x@###* @: *######M.+@``x.n+;M`.+@#M#W;+:,
+ `.;*#zzW##@#ii,@Mn` +@###:`x: :######n`#z `x`#iM+`i##WWW;+i:.
+ ;i*+#x+M#W@z*i:#n, ;xx#@. #: .#####@# #i` Wn*#`,,i#@W@z**;.`
+ .,:;*+zz#W+zi#.`:zn+..;x +; `######,`++ ,+#i`;;#@@@@Mi#i:.`
+ ``.,,:;++#W@#**+,` *n@#i#,`+i .xnnn,#.`*n*+*+ :*M@M@#@zn*;,``
+ ``...,:*#+#@*nzi; .,;ix+i+zx#+.:,:i,##*+nn*i`i+#x@###*#i:.
+ ` ```.,;z#MWi@#+zi,.``iz*Mn;;M@#@#@;:+x*..:i:W###xi+,;i,`
+ `.,;##zx+@x+++,;.,;,;+W@#M#*i*#zx: :;*ix+zW+*zz*z;.`
+ ``.:iz*xn@#zii+i+:,.`::.``.````:`:+#nW#nniz#**i;,.`
+ `.,*ziM@@#xz:::ix,i**;i;zz++i;;#+n#Mn###*i;::,.`
+ `.,;xi#xxW#n,;,,*#+*+*i+;i,.i#z@#M**z*i;:,.```
+ ``:#++##+#z;xz:zM##Mz#n+*, x#@@M*##ii:,.``
+ `,:;;*+zM;+#zz##++z@##+,;@@x++z*;:,.``
+ ``,,,:;;zizz**#n@#@####.#z#++z*;:.``
+ ```...,#;#+zznnzzz#++z:z#+#i*;,.``
+ ``,*;#**iiiiiiii;+;+ii*+;,.
+ ``*+;::::,,,,,,,i;*;::,,.`
+ `.,,,..........i:i,,...`
+ ```...`` `+,*,.`````
+ ``` `;,+,.``
+ ` .ii+;.`
+ `**z:.`
+ ``:::.`
+ ..,.``
+ ``````
+ ` ```
\ No newline at end of file
diff --git a/src/main/resources/regions.json b/src/main/resources/regions.json
new file mode 100644
index 0000000..b28c296
--- /dev/null
+++ b/src/main/resources/regions.json
@@ -0,0 +1,414 @@
+[
+ {
+ "code": "AL",
+ "country": "Albania"
+ },
+ {
+ "code": "AD",
+ "country": "Andorra"
+ },
+ {
+ "code": "AR",
+ "country": "Argentina"
+ },
+ {
+ "code": "AM",
+ "country": "Armenia"
+ },
+ {
+ "code": "AU",
+ "country": "Australia"
+ },
+ {
+ "code": "AT",
+ "country": "Austria"
+ },
+ {
+ "code": "AZ",
+ "country": "Azerbaijan"
+ },
+ {
+ "code": "BH",
+ "country": "Bahrain"
+ },
+ {
+ "code": "BD",
+ "country": "Bangladesh"
+ },
+ {
+ "code": "BY",
+ "country": "Belarus"
+ },
+ {
+ "code": "BE",
+ "country": "Belgium"
+ },
+ {
+ "code": "BA",
+ "country": "Bosnia and Herzegovina"
+ },
+ {
+ "code": "BR",
+ "country": "Brazil"
+ },
+ {
+ "code": "BG",
+ "country": "Bulgaria"
+ },
+ {
+ "code": "KH",
+ "country": "Cambodia"
+ },
+ {
+ "code": "CA",
+ "country": "Canada"
+ },
+ {
+ "code": "CL",
+ "country": "Chile"
+ },
+ {
+ "code": "CN",
+ "country": "China"
+ },
+ {
+ "code": "CO",
+ "country": "Colombia"
+ },
+ {
+ "code": "CR",
+ "country": "Costa Rica"
+ },
+ {
+ "code": "HR",
+ "country": "Croatia"
+ },
+ {
+ "code": "CW",
+ "country": "Curacao"
+ },
+ {
+ "code": "CY",
+ "country": "Cyprus"
+ },
+ {
+ "code": "CZ",
+ "country": "Czech Republic"
+ },
+ {
+ "code": "DK",
+ "country": "Denmark"
+ },
+ {
+ "code": "DO",
+ "country": "Dominican Republic"
+ },
+ {
+ "code": "EC",
+ "country": "Ecuador"
+ },
+ {
+ "code": "EG",
+ "country": "Egypt"
+ },
+ {
+ "code": "SV",
+ "country": "El Salvador"
+ },
+ {
+ "code": "EE",
+ "country": "Estonia"
+ },
+ {
+ "code": "FI",
+ "country": "Finland"
+ },
+ {
+ "code": "FR",
+ "country": "France"
+ },
+ {
+ "code": "GE",
+ "country": "Georgia"
+ },
+ {
+ "code": "DE",
+ "country": "Germany"
+ },
+ {
+ "code": "GR",
+ "country": "Greece"
+ },
+ {
+ "code": "GT",
+ "country": "Guatemala"
+ },
+ {
+ "code": "HN",
+ "country": "Honduras"
+ },
+ {
+ "code": "HK",
+ "country": "Hong Kong"
+ },
+ {
+ "code": "HU",
+ "country": "Hungary"
+ },
+ {
+ "code": "IS",
+ "country": "Iceland"
+ },
+ {
+ "code": "IN",
+ "country": "India"
+ },
+ {
+ "code": "ID",
+ "country": "Indonesia"
+ },
+ {
+ "code": "IE",
+ "country": "Ireland"
+ },
+ {
+ "code": "IM",
+ "country": "Isle of Man"
+ },
+ {
+ "code": "IL",
+ "country": "Israel"
+ },
+ {
+ "code": "IT",
+ "country": "Italy"
+ },
+ {
+ "code": "JM",
+ "country": "Jamaica"
+ },
+ {
+ "code": "JP",
+ "country": "Japan"
+ },
+ {
+ "code": "JO",
+ "country": "Jordan"
+ },
+ {
+ "code": "KZ",
+ "country": "Kazakhstan"
+ },
+ {
+ "code": "KE",
+ "country": "Kenya"
+ },
+ {
+ "code": "KR",
+ "country": "Korea"
+ },
+ {
+ "code": "KW",
+ "country": "Kuwait"
+ },
+ {
+ "code": "KG",
+ "country": "Kyrgyzstan"
+ },
+ {
+ "code": "LV",
+ "country": "Latvia"
+ },
+ {
+ "code": "LB",
+ "country": "Lebanon"
+ },
+ {
+ "code": "LT",
+ "country": "Lithuania"
+ },
+ {
+ "code": "LU",
+ "country": "Luxembourg"
+ },
+ {
+ "code": "MY",
+ "country": "Malaysia"
+ },
+ {
+ "code": "MT",
+ "country": "Malta"
+ },
+ {
+ "code": "MX",
+ "country": "Mexico"
+ },
+ {
+ "code": "MD",
+ "country": "Moldova"
+ },
+ {
+ "code": "MN",
+ "country": "Mongolia"
+ },
+ {
+ "code": "MA",
+ "country": "Morocco"
+ },
+ {
+ "code": "NL",
+ "country": "Netherlands"
+ },
+ {
+ "code": "NZ",
+ "country": "New Zealand"
+ },
+ {
+ "code": "NI",
+ "country": "Nicaragua"
+ },
+ {
+ "code": "NG",
+ "country": "Nigeria"
+ },
+ {
+ "code": "NO",
+ "country": "Norway"
+ },
+ {
+ "code": "OM",
+ "country": "Oman"
+ },
+ {
+ "code": "PK",
+ "country": "Pakistan"
+ },
+ {
+ "code": "PA",
+ "country": "Panama"
+ },
+ {
+ "code": "PY",
+ "country": "Paraguay"
+ },
+ {
+ "code": "PE",
+ "country": "Peru"
+ },
+ {
+ "code": "PH",
+ "country": "Philippines"
+ },
+ {
+ "code": "PL",
+ "country": "Poland"
+ },
+ {
+ "code": "PT",
+ "country": "Portugal"
+ },
+ {
+ "code": "PR",
+ "country": "Puerto Rico"
+ },
+ {
+ "code": "QA",
+ "country": "Qatar"
+ },
+ {
+ "code": "RO",
+ "country": "Romania"
+ },
+ {
+ "code": "RU",
+ "country": "Russian Federation"
+ },
+ {
+ "code": "SA",
+ "country": "Saudi Arabia"
+ },
+ {
+ "code": "RS",
+ "country": "Serbia"
+ },
+ {
+ "code": "SG",
+ "country": "Singapore"
+ },
+ {
+ "code": "SK",
+ "country": "Slovakia"
+ },
+ {
+ "code": "SI",
+ "country": "Slovenia"
+ },
+ {
+ "code": "ZA",
+ "country": "South Africa"
+ },
+ {
+ "code": "ES",
+ "country": "Spain"
+ },
+ {
+ "code": "SE",
+ "country": "Sweden"
+ },
+ {
+ "code": "CH",
+ "country": "Switzerland"
+ },
+ {
+ "code": "TW",
+ "country": "Taiwan"
+ },
+ {
+ "code": "TZ",
+ "country": "Tanzania"
+ },
+ {
+ "code": "TH",
+ "country": "Thailand"
+ },
+ {
+ "code": "TN",
+ "country": "Tunisia"
+ },
+ {
+ "code": "TR",
+ "country": "Turkey"
+ },
+ {
+ "code": "UA",
+ "country": "Ukraine"
+ },
+ {
+ "code": "AE",
+ "country": "United Arab Emirates"
+ },
+ {
+ "code": "GB",
+ "country": "United Kingdom"
+ },
+ {
+ "code": "US",
+ "country": "United States"
+ },
+ {
+ "code": "UY",
+ "country": "Uruguay"
+ },
+ {
+ "code": "UZ",
+ "country": "Uzbekistan"
+ },
+ {
+ "code": "VE",
+ "country": "Venezuela"
+ },
+ {
+ "code": "VN",
+ "country": "Vietnam"
+ }
+]
\ No newline at end of file
diff --git a/src/test/java/dev/vality/beholder/BeholderFullTest.java b/src/test/java/dev/vality/beholder/BeholderFullTest.java
new file mode 100644
index 0000000..13a50af
--- /dev/null
+++ b/src/test/java/dev/vality/beholder/BeholderFullTest.java
@@ -0,0 +1,193 @@
+package dev.vality.beholder;
+
+import dev.vality.beholder.config.properties.PaymentsProperties;
+import dev.vality.beholder.config.properties.SeleniumProperties;
+import dev.vality.beholder.security.KeycloakService;
+import dev.vality.beholder.service.BeholderService;
+import dev.vality.beholder.service.SeleniumService;
+import dev.vality.beholder.testutil.ResponseUtil;
+import dev.vality.swag.payments.ApiClient;
+import dev.vality.swag.payments.api.ClaimsApi;
+import dev.vality.swag.payments.api.InvoicesApi;
+import dev.vality.swag.payments.api.PartiesApi;
+import dev.vality.swag.payments.api.ShopsApi;
+import dev.vality.swag.payments.model.Party;
+import org.junit.jupiter.api.*;
+import org.mockito.MockitoAnnotations;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.actuate.metrics.AutoConfigureMetrics;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.HttpStatus;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.web.client.HttpClientErrorException;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+
+@AutoConfigureMetrics
+@AutoConfigureMockMvc
+@SpringBootTest(
+ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
+ properties = {"beholder.cron=-", //disables scheduled execution
+ "payments.request.shop-id=test",
+ "payments.request.create-shop-if-not-found=true",
+ "payments.request.payment-institution-id=1",
+ "payments.request.category-id=1",
+ "management.server.port="})
+public class BeholderFullTest {
+
+ public static final String TEST_USER_TOKEN = "test_token";
+
+ @MockBean
+ private KeycloakService keycloakService;
+ @MockBean
+ private ApiClient apiClient;
+ @MockBean
+ private PartiesApi partiesApi;
+ @MockBean
+ private ShopsApi shopsApi;
+ @MockBean
+ private InvoicesApi invoicesApi;
+ @MockBean
+ private ClaimsApi claimsApi;
+ @MockBean
+ private SeleniumService seleniumService;
+
+ @Autowired
+ public BeholderService beholderService;
+
+ @Autowired
+ public SeleniumProperties seleniumProperties;
+
+ @Autowired
+ public PaymentsProperties paymentsProperties;
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ private AutoCloseable mocks;
+
+ private Object[] preparedMocks;
+
+ @BeforeEach
+ public void init() {
+ mocks = MockitoAnnotations.openMocks(this);
+ preparedMocks = new Object[] {keycloakService, apiClient, partiesApi, shopsApi, invoicesApi, claimsApi,
+ seleniumService};
+ }
+
+ @AfterEach
+ public void clean() throws Exception {
+ verifyNoMoreInteractions(preparedMocks);
+ mocks.close();
+ }
+
+ @Test
+ public void beholdWithExistingShop() throws Exception {
+ Party party = ResponseUtil.getMyPartyResponse();
+ String shopId = paymentsProperties.getRequest().getShopId();
+
+ when(keycloakService.getUserToken()).thenReturn(TEST_USER_TOKEN);
+ when(partiesApi.getMyParty(anyString(), anyString())).thenReturn(party);
+ when(shopsApi.getShopByIDForParty(anyString(), eq(shopId),
+ eq(party.getId()), anyString()))
+ .thenReturn(ResponseUtil.getShopResponse(shopId));
+ when(invoicesApi.createInvoice(anyString(), any(), anyString()))
+ .thenReturn(ResponseUtil.getInvoiceAndToken());
+ when(seleniumService.executePaymentRequest(any(), any()))
+ .thenReturn(ResponseUtil.getFormDataResponse());
+
+ beholderService.behold();
+
+
+ verify(keycloakService, times(1)).getUserToken();
+ verify(apiClient, times(1)).setApiKey(TEST_USER_TOKEN);
+ verify(partiesApi, times(1)).getMyParty(anyString(), anyString());
+ verify(shopsApi, times(1)).getShopByIDForParty(anyString(), eq(shopId),
+ eq(party.getId()), anyString());
+ verify(invoicesApi, times(1)).createInvoice(anyString(), any(), anyString());
+ verify(seleniumService, times(1)).executePaymentRequest(any(), any());
+
+
+ var mvcResult = mockMvc.perform(get("/actuator/prometheus"))
+ .andReturn();
+ String prometheusResponse = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8);
+ List metrics = Arrays.stream(prometheusResponse.split("\n"))
+ .filter(row -> row.startsWith("beholder_")).collect(Collectors.toList());
+ Assertions.assertFalse(metrics.isEmpty());
+ }
+
+
+ @Test
+ public void beholdWithShopCreation() throws Exception {
+ when(keycloakService.getUserToken()).thenReturn(TEST_USER_TOKEN);
+ Party party = ResponseUtil.getMyPartyResponse();
+ when(partiesApi.getMyParty(anyString(), anyString())).thenReturn(party);
+ String shopId = paymentsProperties.getRequest().getShopId();
+ when(shopsApi.getShopByIDForParty(anyString(), eq(shopId),
+ eq(party.getId()), anyString()))
+ .thenThrow(new HttpClientErrorException(HttpStatus.NOT_FOUND));
+ when(claimsApi.createClaim(anyString(), any(), anyString()))
+ .thenReturn(ResponseUtil.getClaim());
+ when(invoicesApi.createInvoice(anyString(), any(), anyString()))
+ .thenReturn(ResponseUtil.getInvoiceAndToken());
+ when(seleniumService.executePaymentRequest(any(), any()))
+ .thenReturn(ResponseUtil.getFormDataResponse());
+
+ beholderService.behold();
+
+
+ verify(keycloakService, times(1)).getUserToken();
+ verify(apiClient, times(1)).setApiKey(TEST_USER_TOKEN);
+ verify(partiesApi, times(1)).getMyParty(anyString(), anyString());
+ verify(shopsApi, times(1)).getShopByIDForParty(anyString(), eq(shopId),
+ eq(party.getId()), anyString());
+ verify(claimsApi, times(1)).createClaim(anyString(), any(), anyString());
+ verify(invoicesApi, times(1)).createInvoice(anyString(), any(), anyString());
+ verify(seleniumService, times(1)).executePaymentRequest(any(), any());
+
+
+ var mvcResult = mockMvc.perform(get("/actuator/prometheus"))
+ .andReturn();
+ String prometheusResponse = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8);
+ List metrics = Arrays.stream(prometheusResponse.split("\n"))
+ .filter(row -> row.startsWith("beholder_")).collect(Collectors.toList());
+ Assertions.assertFalse(metrics.isEmpty());
+ }
+
+ @Test
+ public void beholdWithGetShopError() throws Exception {
+ when(keycloakService.getUserToken()).thenReturn(TEST_USER_TOKEN);
+ Party party = ResponseUtil.getMyPartyResponse();
+ when(partiesApi.getMyParty(anyString(), anyString())).thenReturn(party);
+ String shopId = paymentsProperties.getRequest().getShopId();
+ when(shopsApi.getShopByIDForParty(anyString(), eq(shopId),
+ eq(party.getId()), anyString()))
+ .thenThrow(new HttpClientErrorException(HttpStatus.I_AM_A_TEAPOT));
+
+ beholderService.behold();
+
+
+ verify(keycloakService, times(1)).getUserToken();
+ verify(apiClient, times(1)).setApiKey(TEST_USER_TOKEN);
+ verify(partiesApi, times(1)).getMyParty(anyString(), anyString());
+ verify(shopsApi, times(1)).getShopByIDForParty(anyString(), eq(shopId),
+ eq(party.getId()), anyString());
+
+ var mvcResult = mockMvc.perform(get("/actuator/prometheus"))
+ .andReturn();
+ String prometheusResponse = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8);
+ List metrics = Arrays.stream(prometheusResponse.split("\n"))
+ .filter(row -> row.startsWith("beholder_")).collect(Collectors.toList());
+ Assertions.assertFalse(metrics.isEmpty());
+ }
+
+
+}
diff --git a/src/test/java/dev/vality/beholder/IntegrationTest.java b/src/test/java/dev/vality/beholder/IntegrationTest.java
new file mode 100644
index 0000000..c7ff9f2
--- /dev/null
+++ b/src/test/java/dev/vality/beholder/IntegrationTest.java
@@ -0,0 +1,111 @@
+package dev.vality.beholder;
+
+import dev.vality.beholder.config.properties.SeleniumProperties;
+import dev.vality.beholder.security.KeycloakService;
+import dev.vality.beholder.service.BeholderService;
+import org.junit.jupiter.api.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.actuate.metrics.AutoConfigureMetrics;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.utility.DockerImageName;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+import static dev.vality.beholder.testutil.SystemUtil.isArmArchitecture;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+
+@Disabled("Used only for local testing")
+@AutoConfigureMetrics
+@AutoConfigureMockMvc
+@SpringBootTest(
+ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
+ properties = {"beholder.cron=-", //disables scheduled execution
+ "payments.api-url=https://api.test.com",
+ "payments.form-url=https://checkout.test.com/checkout.html",
+ "payments.request.shop-id=test", "management.server.port="})
+public class IntegrationTest {
+
+ public static final String TEST_USER_TOKEN = "test_token";
+
+ public static final String SELENIUM_IMAGE_NAME =
+ (isArmArchitecture() ? "seleniarm" : "selenium") + "/standalone-chromium";
+
+ public static final String SELENIUM_IMAGE_TAG = "101.0";
+
+ @Container
+ public static final GenericContainer SELENIUM_CONTAINER = new GenericContainer(DockerImageName
+ .parse(SELENIUM_IMAGE_NAME)
+ .withTag(SELENIUM_IMAGE_TAG))
+ .withExposedPorts(4444)
+ .waitingFor(new HostPortWaitStrategy());
+
+ @DynamicPropertySource
+ static void redisProperties(DynamicPropertyRegistry registry) {
+ Supplier