diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..2600fb4
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,9 @@
+.github export-ignore
+.gitattributes export-ignore
+.gitignore export-ignore
+.php-cs-fixer.dist.php export-ignore
+docker-compose.yml export-ignore
+Dockerfile export-ignore
+Makefile export-ignore
+phpstan.neon export-ignore
+tests export-ignore
diff --git a/.github/workflows/coding-style.yml b/.github/workflows/coding-style.yml
new file mode 100644
index 0000000..30dfd38
--- /dev/null
+++ b/.github/workflows/coding-style.yml
@@ -0,0 +1,50 @@
+name: Coding style
+
+on:
+ push:
+ branches:
+ - main
+ tags:
+ - v*
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ php-cs-fixer:
+ name: Php-Cs-Fixer
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: 7.4
+ tools: composer:v2
+
+ - name: Install dependencies
+ run: composer update --no-progress --prefer-dist --prefer-stable --optimize-autoloader --quiet
+
+ - name: Php-Cs-Fixer
+ run: vendor/bin/php-cs-fixer fix -v --dry-run
+
+ php-stan:
+ name: PHPStan
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: 7.4
+ tools: composer:v2
+
+ - name: Install dependencies
+ run: composer update --no-progress --prefer-dist --prefer-stable --optimize-autoloader --quiet
+
+ - name: PhpStan
+ run: vendor/bin/phpstan analyse
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..f9223f5
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,40 @@
+name: Tests
+
+on:
+ push:
+ branches:
+ - main
+ tags:
+ - v*
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ tests:
+ name: Unit Tests [PHP ${{ matrix.php-versions }}]
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-versions: ['7.4', '8.0', '8.1', '8.2']
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-versions }}
+ tools: composer:v2
+
+ - name: Install dependencies
+ run: composer update --no-progress --prefer-dist --prefer-stable --optimize-autoloader
+
+ - name: Run tests
+ run: vendor/bin/tester -C -s ./tests
+
+ - name: Install dependencies (lowest)
+ run: composer update --no-progress --prefer-dist --prefer-lowest --prefer-stable --optimize-autoloader
+
+ - name: Run tests
+ run: vendor/bin/tester -C -s ./tests
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c946d7c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+# Application
+composer.lock
+vendor/
+coverage.xml
+
+# Project files etc.
+.idea
+
+# MacOS
+.DS_Store
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 0000000..0882086
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,107 @@
+in(__DIR__ . '/src')
+ ->in(__DIR__ . '/tests');
+
+return (new Config())
+ ->registerCustomFixers(new CustomFixers())
+ ->setUsingCache(false)
+ ->setIndent(" ")
+ ->setRules([
+ '@PSR2' => true,
+ 'array_syntax' => [
+ 'syntax' => 'short'
+ ],
+ 'trailing_comma_in_multiline' => [
+ 'elements' => [
+ 'arguments',
+ 'arrays',
+ ],
+ ],
+ 'constant_case' => [
+ 'case' => 'lower',
+ ],
+ 'declare_strict_types' => true,
+ 'phpdoc_align' => true,
+ 'blank_line_after_opening_tag' => true,
+ 'blank_line_before_statement' => [
+ 'statements' => [
+ 'break',
+ 'continue',
+ 'declare',
+ 'return'
+ ],
+ ],
+ 'blank_line_after_namespace' => true,
+ 'blank_lines_before_namespace' => [
+ 'max_line_breaks' => 2,
+ 'min_line_breaks' => 2,
+ ],
+ 'return_type_declaration' => [
+ 'space_before' => 'none',
+ ],
+ 'ordered_imports' => [
+ 'sort_algorithm' => 'alpha',
+ 'imports_order' => [
+ 'class',
+ 'function',
+ 'const'
+ ],
+ ],
+ 'no_unused_imports' => true,
+ 'single_line_after_imports' => true,
+ 'no_leading_import_slash' => true,
+ 'global_namespace_import' => [
+ 'import_constants' => true,
+ 'import_functions' => true,
+ 'import_classes' => true,
+ ],
+ 'fully_qualified_strict_types' => true,
+ 'concat_space' => [
+ 'spacing' => 'one',
+ ],
+ 'no_superfluous_phpdoc_tags' => [
+ 'allow_mixed' => true,
+ 'remove_inheritdoc' => true,
+ 'allow_unused_params' => false,
+ ],
+ 'no_empty_phpdoc' => true,
+ 'no_blank_lines_after_phpdoc' => true,
+ 'phpdoc_trim_consecutive_blank_line_separation' => true,
+ 'phpdoc_trim' => true,
+ 'no_extra_blank_lines' => [
+ 'tokens' => [
+ 'curly_brace_block',
+ 'extra',
+ 'parenthesis_brace_block',
+ 'return',
+ 'square_brace_block',
+ 'throw',
+ 'use',
+ ],
+ ],
+ 'single_trait_insert_per_statement' => true,
+ 'single_class_element_per_statement' => [
+ 'elements' => [
+ 'const',
+ 'property',
+ ]
+ ],
+ 'type_declaration_spaces' => [
+ 'elements' => [
+ 'function',
+ 'property',
+ ],
+ ],
+ ConstructorEmptyBracesFixer::name() => true,
+ ])
+ ->setRiskyAllowed(true)
+ ->setFinder($finder);
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..a9a2061
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,51 @@
+FROM php:7.4.33-cli-alpine3.16 AS php74
+
+CMD ["/bin/sh"]
+WORKDIR /var/www/html
+
+RUN apk add --no-cache --update git
+RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
+RUN apk add --no-cache ${PHPIZE_DEPS} \
+ && pecl install pcov \
+ && docker-php-ext-enable pcov
+
+CMD tail -f /dev/null
+
+FROM php:8.0.28-cli-alpine3.16 AS php80
+
+CMD ["/bin/sh"]
+WORKDIR /var/www/html
+
+RUN apk add --no-cache --update git
+RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
+RUN apk add --no-cache ${PHPIZE_DEPS} \
+ && pecl install pcov \
+ && docker-php-ext-enable pcov
+
+CMD tail -f /dev/null
+
+FROM php:8.1.25-cli-alpine3.18 AS php81
+
+CMD ["/bin/sh"]
+WORKDIR /var/www/html
+
+RUN apk add --no-cache --update git
+RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
+RUN apk add --no-cache ${PHPIZE_DEPS} \
+ && pecl install pcov \
+ && docker-php-ext-enable pcov
+
+CMD tail -f /dev/null
+
+FROM php:8.2.13RC1-cli-alpine3.18 AS php82
+
+CMD ["/bin/sh"]
+WORKDIR /var/www/html
+
+RUN apk add --no-cache --update git
+RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
+RUN apk add --no-cache ${PHPIZE_DEPS} \
+ && pecl install pcov \
+ && docker-php-ext-enable pcov
+
+CMD tail -f /dev/null
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..f314f53
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,63 @@
+init:
+ make stop
+ make start
+
+stop:
+ docker compose stop
+
+start:
+ docker compose up -d
+
+down:
+ docker compose down
+
+restart:
+ make stop
+ make start
+
+tests.all:
+ PHP=74 make tests.run
+ PHP=80 make tests.run
+ PHP=81 make tests.run
+ PHP=82 make tests.run
+
+cs.fix:
+ PHP=74 make composer.update
+ docker exec 68publishers.amp-client-php.74 vendor/bin/php-cs-fixer fix -v
+
+cs.check:
+ PHP=74 make composer.update
+ docker exec 68publishers.amp-client-php.74 vendor/bin/php-cs-fixer fix -v --dry-run
+
+stan:
+ PHP=74 make composer.update
+ docker exec 68publishers.amp-client-php.74 vendor/bin/phpstan analyse
+
+coverage:
+ PHP=74 make composer.update
+ docker exec 68publishers.amp-client-php.74 vendor/bin/tester -C -s --coverage ./coverage.xml --coverage-src ./src ./tests
+
+composer.update:
+ifndef PHP
+ $(error "PHP argument not set.")
+endif
+ @echo "========== Installing dependencies with PHP $(PHP) ==========" >&2
+ docker exec 68publishers.amp-client-php.$(PHP) composer update --no-progress --prefer-dist --prefer-stable --optimize-autoloader --quiet
+
+composer.update-lowest:
+ifndef PHP
+ $(error "PHP argument not set.")
+endif
+ @echo "========== Installing dependencies with PHP $(PHP) (prefer lowest dependencies) ==========" >&2
+ docker exec 68publishers.amp-client-php.$(PHP) composer update --no-progress --prefer-dist --prefer-lowest --prefer-stable --optimize-autoloader --quiet
+
+tests.run:
+ifndef PHP
+ $(error "PHP argument not set.")
+endif
+ PHP=$(PHP) make composer.update
+ @echo "========== Running tests with PHP $(PHP) ==========" >&2
+ docker exec 68publishers.amp-client-php.$(PHP) vendor/bin/tester -C -s ./tests
+ PHP=$(PHP) make composer.update-lowest
+ @echo "========== Running tests with PHP $(PHP) (prefer lowest dependencies) ==========" >&2
+ docker exec 68publishers.amp-client-php.$(PHP) vendor/bin/tester -C -s ./tests
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..68d1f57
--- /dev/null
+++ b/README.md
@@ -0,0 +1,9 @@
+
+
AMP Client
+
+
+## Installation
+
+```sh
+$ composer require 68publishers/amp-client
+```
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..d3f292b
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,40 @@
+{
+ "name": "68publishers/amp-client",
+ "description": "",
+ "keywords": ["68publishers", "amp", "amp-client"],
+ "license": "proprietary",
+ "authors": [
+ {
+ "name": "Tomáš Glawaty",
+ "email": "tomasglawaty@icloud.com"
+ }
+ ],
+ "require": {
+ "php": "^7.4 || ^8.0",
+ "ext-json": "*",
+ "guzzlehttp/guzzle": "^7.7"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.17",
+ "kubawerlos/php-cs-fixer-custom-fixers": "^3.14",
+ "mockery/mockery": "^1.6",
+ "nette/tester": "^2.4",
+ "phpstan/phpstan": "^1.10",
+ "phpstan/phpstan-nette": "^1.2",
+ "roave/security-advisories": "dev-latest",
+ "symplify/phpstan-rules": "12.0.2.72"
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "autoload": {
+ "psr-4": {
+ "SixtyEightPublishers\\AmpClient\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "SixtyEightPublishers\\AmpClient\\Tests\\": "tests/"
+ }
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..274c03e
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,38 @@
+version: "3.7"
+
+services:
+ php74:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: php74
+ container_name: 68publishers.amp-client-php.74
+ volumes:
+ - .:/var/www/html
+
+ php80:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: php80
+ container_name: 68publishers.amp-client-php.80
+ volumes:
+ - .:/var/www/html
+
+ php81:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: php81
+ container_name: 68publishers.amp-client-php.81
+ volumes:
+ - .:/var/www/html
+
+ php82:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: php82
+ container_name: 68publishers.amp-client-php.82
+ volumes:
+ - .:/var/www/html
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..ef7e8ff
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,14 @@
+includes:
+ - vendor/phpstan/phpstan-nette/extension.neon
+ - vendor/phpstan/phpstan-nette/rules.neon
+
+rules:
+ - Symplify\PHPStanRules\Rules\NoArrayAccessOnObjectRule
+
+services:
+ - Symplify\PHPStanRules\Matcher\ArrayStringAndFnMatcher
+
+parameters:
+ level: 8
+ paths:
+ - src
diff --git a/src/AmpClient.php b/src/AmpClient.php
new file mode 100644
index 0000000..d8c34ce
--- /dev/null
+++ b/src/AmpClient.php
@@ -0,0 +1,206 @@
+config = $config;
+ $this->httpClientFactory = $httpClientFactory;
+ $this->cacheStorage = $cacheStorage;
+ }
+
+ public static function create(
+ ClientConfig $config,
+ ?HttpClientFactoryInterface $httpClientFactory = null,
+ ?CacheStorageInterface $cacheStorage = null
+ ): self {
+ $httpClientFactory = $httpClientFactory ?? new HttpClientFactory(
+ new ResponseHydrator([
+ new BannersResponseHydratorHandler(),
+ ]),
+ );
+
+ $cacheStorage = $cacheStorage ?? new NoCacheStorage();
+
+ return new self(
+ $config,
+ $httpClientFactory,
+ $cacheStorage,
+ );
+ }
+
+ public function getConfig(): ClientConfig
+ {
+ return $this->config;
+ }
+
+ public function withConfig(ClientConfig $config): AmpClientInterface
+ {
+ return new self(
+ $config,
+ $this->httpClientFactory,
+ $this->cacheStorage,
+ );
+ }
+
+ public function getCacheStorage(): CacheStorageInterface
+ {
+ return $this->cacheStorage;
+ }
+
+ public function withCacheStorage(CacheStorageInterface $cacheStorage): AmpClientInterface
+ {
+ return new self(
+ $this->config,
+ $this->httpClientFactory,
+ $cacheStorage,
+ );
+ }
+
+ public function fetchBanners(BannersRequest $request): BannersResponse
+ {
+ $positions = $request->getPositions();
+
+ if (0 >= count($positions)) {
+ return new BannersResponse([]);
+ }
+
+ $client = $this->getHttpClient();
+ $method = $this->config->getMethod();
+ $defaultResources = $this->config->getDefaultResources();
+ $locale = $request->getLocale() ?? $this->config->getLocale();
+ $queryParam = [];
+
+ foreach ($positions as $position) {
+ $position = $position->withResources($defaultResources);
+ $queryParam[$position->getCode()] = array_map(
+ static fn (BannerResource $resource): array => $resource->getValues(),
+ $position->getResources(),
+ );
+ }
+
+ $options = [
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ 'Accept' => 'application/json',
+ ],
+ ];
+
+ if (ClientConfig::MethodPost === $method) {
+ $body = [
+ 'query' => $queryParam,
+ ];
+
+ if (null !== $locale) {
+ $body['locale'] = $locale;
+ }
+
+ $options['body'] = $this->jsonEncode($body);
+ } else {
+ $options['query'] = [
+ 'query' => $this->jsonEncode($queryParam),
+ ];
+
+ if (null !== $locale) {
+ $options['query']['locale'] = $locale;
+ }
+ }
+
+ $request = new HttpRequest(
+ $method,
+ 'content/' . $this->config->getChannel(),
+ $options,
+ [
+ 'query' => $queryParam,
+ 'locale' => $locale,
+ ],
+ );
+
+ return $client->request($request, BannersResponse::class);
+ }
+
+ private function getHttpClient(): HttpClientInterface
+ {
+ if (null !== $this->httpClient) {
+ return $this->httpClient;
+ }
+
+ $baseUrl = sprintf(
+ '%s/api/v%d',
+ $this->config->getUrl(),
+ $this->config->getVersion(),
+ );
+
+ $middlewares = new Middlewares([
+ new UnexpectedErrorMiddleware(),
+ new ResponseExceptionMiddleware(),
+ ]);
+
+ if (null !== $this->config->getOrigin()) {
+ $middlewares = $middlewares->with(new XAmpOriginHeaderMiddleware($this->config->getOrigin()));
+ }
+
+ $cacheControl = new CacheControl(
+ $this->config->getCacheExpiration(),
+ $this->config->getCacheControlHeaderOverride(),
+ );
+
+ return $this->httpClient = $this->httpClientFactory->create(
+ $baseUrl,
+ $middlewares,
+ $this->cacheStorage,
+ $cacheControl,
+ );
+ }
+
+ /**
+ * @param mixed $value
+ *
+ * @throws UnexpectedErrorException
+ */
+ private function jsonEncode($value): string
+ {
+ try {
+ return (string) json_encode($value, JSON_THROW_ON_ERROR);
+ } catch (JsonException $e) {
+ throw new UnexpectedErrorException($e);
+ }
+ }
+}
diff --git a/src/AmpClientInterface.php b/src/AmpClientInterface.php
new file mode 100644
index 0000000..016fdcd
--- /dev/null
+++ b/src/AmpClientInterface.php
@@ -0,0 +1,26 @@
+,
+ * origin: string|null,
+ * cache_expiration: string|int,
+ * cache_control_header_override: string|null
+ * }
+ */
+final class ClientConfig
+{
+ public const MethodGet = 'GET';
+ public const MethodPost = 'POST';
+
+ public const Version1 = 1;
+
+ private const Methods = [
+ self::MethodGet,
+ self::MethodPost,
+ ];
+
+ private const Versions = [
+ self::Version1,
+ ];
+
+ private const OptMethod = 'method';
+ private const OptUrl = 'url';
+ private const OptVersion = 'version';
+ private const OptChannel = 'channel';
+ private const OptLocale = 'locale';
+ private const OptDefaultResources = 'default_resources';
+ private const OptOrigin = 'origin';
+ private const OptCacheExpiration = 'cache_expiration';
+ private const OptCacheControlHeaderOverride = 'cache_control_header_override';
+
+ /** @var OptionsStructure */
+ private array $options;
+
+ /**
+ * @param OptionsStructure $options
+ */
+ private function __construct(array $options)
+ {
+ $this->options = $options;
+ }
+
+ public static function create(string $url, string $channel): self
+ {
+ return new self([
+ self::OptMethod => self::MethodGet,
+ self::OptUrl => rtrim($url, '/'),
+ self::OptVersion => self::Version1,
+ self::OptChannel => $channel,
+ self::OptLocale => null,
+ self::OptDefaultResources => [],
+ self::OptOrigin => null,
+ self::OptCacheExpiration => 0,
+ self::OptCacheControlHeaderOverride => null,
+ ]);
+ }
+
+ public function getMethod(): string
+ {
+ return $this->options[self::OptMethod];
+ }
+
+ public function withMethod(string $method): self
+ {
+ $method = strtoupper($method);
+
+ if (!in_array($method, self::Methods, true)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Invalid method "%s" passed.',
+ $method,
+ ));
+ }
+
+ return $this->withOption(self::OptMethod, $method);
+ }
+
+ public function getUrl(): ?string
+ {
+ return $this->options[self::OptUrl];
+ }
+
+ public function withUrl(string $url): self
+ {
+ return $this->withOption(self::OptUrl, rtrim($url, '/'));
+ }
+
+ public function getVersion(): int
+ {
+ return $this->options[self::OptVersion];
+ }
+
+ public function withVersion(int $version): self
+ {
+ if (!in_array($version, self::Versions, true)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Invalid version %d passed.',
+ $version,
+ ));
+ }
+
+ return $this->withOption(self::OptVersion, $version);
+ }
+
+ public function getChannel(): string
+ {
+ return $this->options[self::OptChannel];
+ }
+
+ public function withChannel(string $channel): self
+ {
+ return $this->withOption(self::OptChannel, $channel);
+ }
+
+ public function getLocale(): ?string
+ {
+ return $this->options[self::OptLocale];
+ }
+
+ public function withLocale(?string $locale): self
+ {
+ return $this->withOption(self::OptLocale, $locale);
+ }
+
+ /**
+ * @return array
+ */
+ public function getDefaultResources(): array
+ {
+ return $this->options[self::OptDefaultResources];
+ }
+
+ /**
+ * @param array $resources
+ */
+ public function withDefaultResources(array $resources): self
+ {
+ return $this->withOption(self::OptDefaultResources, $resources);
+ }
+
+ public function getOrigin(): ?string
+ {
+ return $this->options[self::OptOrigin];
+ }
+
+ public function withOrigin(?string $origin): self
+ {
+ return $this->withOption(self::OptOrigin, $origin);
+ }
+
+ /**
+ * @return string|int
+ */
+ public function getCacheExpiration()
+ {
+ return $this->options[self::OptCacheExpiration];
+ }
+
+ /**
+ * @param string|int $cacheExpiration
+ */
+ public function withCacheExpiration($cacheExpiration): self
+ {
+ return $this->withOption(self::OptCacheExpiration, $cacheExpiration);
+ }
+
+ public function getCacheControlHeaderOverride(): ?string
+ {
+ return $this->options[self::OptCacheControlHeaderOverride];
+ }
+
+ public function withCacheControlHeaderOverride(?string $cacheControlHeaderOverride): self
+ {
+ return $this->withOption(self::OptCacheControlHeaderOverride, $cacheControlHeaderOverride);
+ }
+
+ /**
+ * @param mixed $value
+ */
+ private function withOption(string $key, $value): self
+ {
+ $options = $this->options;
+ $options[$key] = $value;
+
+ return new self($options); // @phpstan-ignore-line
+ }
+}
diff --git a/src/Exception/AbstractHttpException.php b/src/Exception/AbstractHttpException.php
new file mode 100644
index 0000000..7ac38a4
--- /dev/null
+++ b/src/Exception/AbstractHttpException.php
@@ -0,0 +1,39 @@
+getStatusCode(), $previous);
+
+ $this->request = $request;
+ $this->response = $response;
+ }
+
+ public function getRequest(): RequestInterface
+ {
+ return $this->request;
+ }
+
+ public function getResponse(): ResponseInterface
+ {
+ return $this->response;
+ }
+}
diff --git a/src/Exception/AmpExceptionInterface.php b/src/Exception/AmpExceptionInterface.php
new file mode 100644
index 0000000..b2d6cf4
--- /dev/null
+++ b/src/Exception/AmpExceptionInterface.php
@@ -0,0 +1,11 @@
+getMessage() : '',
+ ),
+ 0,
+ $previous,
+ );
+ }
+}
diff --git a/src/Exception/ServerErrorException.php b/src/Exception/ServerErrorException.php
new file mode 100644
index 0000000..f558945
--- /dev/null
+++ b/src/Exception/ServerErrorException.php
@@ -0,0 +1,9 @@
+getMessage(), $previous->getCode(), $previous);
+ }
+}
diff --git a/src/Http/Cache/CacheControl.php b/src/Http/Cache/CacheControl.php
new file mode 100644
index 0000000..9359e0e
--- /dev/null
+++ b/src/Http/Cache/CacheControl.php
@@ -0,0 +1,34 @@
+expiration = $expiration;
+ $this->cacheControlHeaderOverride = null !== $cacheControlHeaderOverride ? new CacheControlHeader([$cacheControlHeaderOverride]) : null;
+ }
+
+ public function createExpiration(): Expiration
+ {
+ return Expiration::create($this->expiration);
+ }
+
+ public function getCacheControlHeaderOverride(): ?CacheControlHeader
+ {
+ return $this->cacheControlHeaderOverride;
+ }
+}
diff --git a/src/Http/Cache/CacheControlHeader.php b/src/Http/Cache/CacheControlHeader.php
new file mode 100644
index 0000000..4e98c00
--- /dev/null
+++ b/src/Http/Cache/CacheControlHeader.php
@@ -0,0 +1,67 @@
+@\,;\:\\\\"\/\[\]\?\=\{\}\x7F]+)(?:\=(?:([^\x00-\x20\(\)<>@\,;\:\\\\"\/\[\]\?\=\{\}\x7F]+)|(?:\"((?:[^"\\\\]|\\\\.)*)\")))?/';
+
+ /** @var array */
+ private array $values = [];
+
+ /**
+ * @param array $values
+ */
+ public function __construct(array $values)
+ {
+ foreach ($values as $value) {
+ $matches = [];
+ if (preg_match_all(self::Regex, $value, $matches, PREG_SET_ORDER)) {
+ foreach ($matches as $match) {
+ $val = '';
+ if (count($match) == 3) {
+ $val = $match[2];
+ } elseif (count($match) > 3) {
+ $val = $match[3];
+ }
+
+ $this->values[$match[1]] = $val;
+ }
+ }
+ }
+ }
+
+ public static function fromResponse(ResponseInterface $response): self
+ {
+ return new self($response->getHeader('cache-control'));
+ }
+
+ public function has(string $key): bool
+ {
+ return isset($this->values[$key]) || array_key_exists($key, $this->values);
+ }
+
+ public function get(string $key, string $default = ''): string
+ {
+ if ($this->has($key)) {
+ return $this->values[$key];
+ }
+
+ return $default;
+ }
+
+ /**
+ * @return array
+ */
+ public function all(): array
+ {
+ return $this->values;
+ }
+}
diff --git a/src/Http/Cache/CacheKey.php b/src/Http/Cache/CacheKey.php
new file mode 100644
index 0000000..96d5dbf
--- /dev/null
+++ b/src/Http/Cache/CacheKey.php
@@ -0,0 +1,55 @@
+value = $value;
+ }
+
+ /**
+ * @param array $components
+ *
+ * @throws UnexpectedErrorException
+ */
+ public static function compute(array $components): self
+ {
+ try {
+ $payload = json_encode($components, JSON_THROW_ON_ERROR);
+ } catch (JsonException $e) {
+ throw new UnexpectedErrorException($e);
+ }
+
+ $hash = mb_substr(
+ base64_encode(
+ hash('sha1', $payload, true),
+ ),
+ 0,
+ 27,
+ );
+
+ return new self(
+ dechex(mb_strlen($payload, 'UTF-8')) . '-' . $hash,
+ );
+ }
+
+ public function getValue(): string
+ {
+ return $this->value;
+ }
+}
diff --git a/src/Http/Cache/CacheStorageInterface.php b/src/Http/Cache/CacheStorageInterface.php
new file mode 100644
index 0000000..f6f700c
--- /dev/null
+++ b/src/Http/Cache/CacheStorageInterface.php
@@ -0,0 +1,16 @@
+key = $key;
+ $this->response = $response;
+ $this->maxAge = $maxAge;
+ $this->etag = $etag;
+ }
+
+ public function getKey(): CacheKey
+ {
+ return $this->key;
+ }
+
+ public function getResponse(): object
+ {
+ return $this->response;
+ }
+
+ public function getMaxAge(): Expiration
+ {
+ return $this->maxAge;
+ }
+
+ public function getEtag(): ?Etag
+ {
+ return $this->etag;
+ }
+}
diff --git a/src/Http/Cache/Etag.php b/src/Http/Cache/Etag.php
new file mode 100644
index 0000000..b992667
--- /dev/null
+++ b/src/Http/Cache/Etag.php
@@ -0,0 +1,29 @@
+value = $value;
+ }
+
+ public static function fromResponse(ResponseInterface $response): ?self
+ {
+ $headerValue = $response->getHeader('ETag')[0] ?? null;
+
+ return null !== $headerValue && '' !== $headerValue ? new self($headerValue) : null;
+ }
+
+ public function getValue(): string
+ {
+ return $this->value;
+ }
+}
diff --git a/src/Http/Cache/Expiration.php b/src/Http/Cache/Expiration.php
new file mode 100644
index 0000000..4eb491a
--- /dev/null
+++ b/src/Http/Cache/Expiration.php
@@ -0,0 +1,53 @@
+value = $value;
+ }
+
+ /**
+ * @param string|int $expiration
+ */
+ public static function create($expiration): self
+ {
+ $time = time();
+ $expiration = is_string($expiration)
+ ? (int) (new DateTimeImmutable('now'))->modify($expiration)->format('U')
+ : $time + $expiration;
+
+ if ($time === $expiration) {
+ $expiration -= 1;
+ }
+
+ return new self(
+ $expiration,
+ );
+ }
+
+ public function getValue(): int
+ {
+ return $this->value;
+ }
+
+ public function isFresh(): bool
+ {
+ return !$this->isExpired();
+ }
+
+ public function isExpired(): bool
+ {
+ return time() > $this->value;
+ }
+}
diff --git a/src/Http/Cache/InMemoryCacheStorage.php b/src/Http/Cache/InMemoryCacheStorage.php
new file mode 100644
index 0000000..26716d4
--- /dev/null
+++ b/src/Http/Cache/InMemoryCacheStorage.php
@@ -0,0 +1,45 @@
+ */
+ private array $cache = [];
+
+ public function get(CacheKey $key): ?CachedResponse
+ {
+ if (!isset($this->cache[$key->getValue()])) {
+ return null;
+ }
+
+ [$response, $expiration] = $this->cache[$key->getValue()];
+
+ if ($expiration->isFresh()) {
+ return $response;
+ }
+
+ $this->delete($key);
+
+ return null;
+ }
+
+ public function save(CachedResponse $response, Expiration $expiration): void
+ {
+ $this->cache[$response->getKey()->getValue()] = [$response, $expiration];
+ }
+
+ public function delete(CacheKey $key): void
+ {
+ if (isset($this->cache[$key->getValue()])) {
+ unset($this->cache[$key->getValue()]);
+ }
+ }
+
+ public function clear(): void
+ {
+ $this->cache = [];
+ }
+}
diff --git a/src/Http/Cache/NoCacheStorage.php b/src/Http/Cache/NoCacheStorage.php
new file mode 100644
index 0000000..355c8bb
--- /dev/null
+++ b/src/Http/Cache/NoCacheStorage.php
@@ -0,0 +1,25 @@
+baseUrl = rtrim($baseUrl, '/');
+ $this->guzzleClient = $guzzleClient;
+ $this->responseHydrator = $responseHydrator;
+ $this->cacheStorage = $cacheStorage;
+ $this->cacheControl = $cacheControl;
+ }
+
+ /**
+ * @throws GuzzleException
+ * @throws UnexpectedErrorException
+ * @throws ResponseHydrationException
+ */
+ public function request(HttpRequest $request, string $responseClassname): object
+ {
+ $url = $this->baseUrl . '/' . ltrim($request->getUrl());
+ $cacheComponents = $request->getCacheComponents();
+
+ if (null !== $cacheComponents) {
+ $cacheComponents['__url'] = $url;
+ $cacheComponents['__method'] = $request->getMethod();
+ }
+
+ $cacheKey = null !== $cacheComponents ? CacheKey::compute($cacheComponents) : null;
+ $cachedResponse = null !== $cacheKey ? $this->cacheStorage->get($cacheKey) : null;
+
+ if (null !== $cachedResponse) {
+ if ($cachedResponse->getMaxAge()->isFresh()) {
+ assert($cachedResponse->getResponse() instanceof $responseClassname);
+
+ return $cachedResponse->getResponse();
+ }
+
+ if (null !== $cachedResponse->getEtag()) {
+ $currentOptions = $request->getOptions();
+ $request = $request->withOptions(array_merge(
+ $currentOptions,
+ [
+ 'headers' => array_merge(
+ $currentOptions['headers'] ?? [],
+ [
+ 'if-none-match' => $cachedResponse->getEtag()->getValue(),
+ ],
+ ),
+ ],
+ ));
+ }
+ }
+
+ $response = $this->guzzleClient->request($request->getMethod(), $url, $request->getOptions());
+ $cacheControlHeader = $this->cacheControl->getCacheControlHeaderOverride() ?? CacheControlHeader::fromResponse($response);
+ $etag = Etag::fromResponse($response);
+
+ $canBeStored = !$cacheControlHeader->has('no-store');
+ $maxAge = 0;
+
+ if ($canBeStored && !$cacheControlHeader->has('no-cache')) {
+ if ('' !== $cacheControlHeader->get('s-maxage')) {
+ $maxAge = (int) $cacheControlHeader->get('s-maxage');
+ } elseif ('' !== $cacheControlHeader->get('max-age')) {
+ $maxAge = (int) $cacheControlHeader->get('max-age');
+ }
+ }
+
+ $mappedResponse = 304 === $response->getStatusCode() && null !== $cachedResponse
+ ? $cachedResponse->getResponse()
+ : $this->responseHydrator->hydrate($responseClassname, $this->getJsonFromResponseBody($response));
+
+ assert($mappedResponse instanceof $responseClassname);
+
+ if (null !== $cacheKey && $canBeStored) {
+ $cachedResponse = new CachedResponse(
+ $cacheKey,
+ $mappedResponse,
+ Expiration::create($maxAge),
+ $etag,
+ );
+
+ $this->cacheStorage->save($cachedResponse, $this->cacheControl->createExpiration());
+ }
+
+ return $mappedResponse;
+ }
+
+ /**
+ * @return mixed
+ * @throws ResponseHydrationException
+ */
+ protected function getJsonFromResponseBody(ResponseInterface $response)
+ {
+ try {
+ return json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
+ } catch (Throwable $e) {
+ throw ResponseHydrationException::malformedResponseBody($e);
+ }
+ }
+}
diff --git a/src/Http/HttpClientFactory.php b/src/Http/HttpClientFactory.php
new file mode 100644
index 0000000..c8c61d0
--- /dev/null
+++ b/src/Http/HttpClientFactory.php
@@ -0,0 +1,58 @@
+ */
+ private array $guzzleClientConfig;
+
+ /**
+ * @param array $guzzleClientConfig
+ */
+ public function __construct(
+ ResponseHydratorInterface $responseHydrator,
+ array $guzzleClientConfig = []
+ ) {
+ $this->responseHydrator = $responseHydrator;
+ $this->guzzleClientConfig = $guzzleClientConfig;
+ }
+
+ public function create(
+ string $baseUrl,
+ Middlewares $middlewares,
+ CacheStorageInterface $cacheStorage,
+ CacheControl $cacheControl
+ ): HttpClientInterface {
+ $guzzleClientConfig = $this->guzzleClientConfig;
+ $handlerStack = $guzzleClientConfig['handler'] ?? null;
+ $handlerStack = $handlerStack instanceof HandlerStack ? clone $handlerStack : HandlerStack::create();
+
+ $guzzleClientConfig['handler'] = $handlerStack;
+ $guzzleClientConfig['http_errors'] = false;
+
+ foreach ($middlewares as $middleware) {
+ $handlerStack->push($middleware, $middleware->getName()); // @phpstan-ignore-line
+ }
+
+ $guzzleClient = new Client($guzzleClientConfig);
+
+ return new HttpClient(
+ $baseUrl,
+ $guzzleClient,
+ $this->responseHydrator,
+ $cacheStorage,
+ $cacheControl,
+ );
+ }
+}
diff --git a/src/Http/HttpClientFactoryInterface.php b/src/Http/HttpClientFactoryInterface.php
new file mode 100644
index 0000000..407e719
--- /dev/null
+++ b/src/Http/HttpClientFactoryInterface.php
@@ -0,0 +1,18 @@
+ $responseClassname
+ *
+ * @return T
+ * @throws AmpExceptionInterface
+ */
+ public function request(HttpRequest $request, string $responseClassname): object;
+}
diff --git a/src/Http/HttpRequest.php b/src/Http/HttpRequest.php
new file mode 100644
index 0000000..0ac51f7
--- /dev/null
+++ b/src/Http/HttpRequest.php
@@ -0,0 +1,71 @@
+ */
+ private array $options;
+
+ /** @var array|null */
+ private ?array $cacheComponents;
+
+ /**
+ * @param array $options
+ * @param array|null $cacheComponents
+ */
+ public function __construct(string $method, string $url, array $options = [], ?array $cacheComponents = null)
+ {
+ $this->method = strtoupper($method);
+ $this->url = $url;
+ $this->options = $options;
+ $this->cacheComponents = $cacheComponents;
+ }
+
+ public function getMethod(): string
+ {
+ return $this->method;
+ }
+
+ public function getUrl(): string
+ {
+ return $this->url;
+ }
+
+ /**
+ * @return array
+ */
+ public function getOptions(): array
+ {
+ return $this->options;
+ }
+
+ /**
+ * @param array $options
+ */
+ public function withOptions(array $options): self
+ {
+ return new self(
+ $this->method,
+ $this->url,
+ $options,
+ $this->cacheComponents,
+ );
+ }
+
+ /**
+ * @return array|null
+ */
+ public function getCacheComponents(): ?array
+ {
+ return $this->cacheComponents;
+ }
+}
diff --git a/src/Http/Middleware/MiddlewareInterface.php b/src/Http/Middleware/MiddlewareInterface.php
new file mode 100644
index 0000000..b9d4d36
--- /dev/null
+++ b/src/Http/Middleware/MiddlewareInterface.php
@@ -0,0 +1,23 @@
+ $options): PromiseInterface $next
+ *
+ * @return Closure(RequestInterface $request, array $options): PromiseInterface
+ */
+ public function __invoke(Closure $next): Closure;
+}
diff --git a/src/Http/Middleware/ResponseExceptionMiddleware.php b/src/Http/Middleware/ResponseExceptionMiddleware.php
new file mode 100644
index 0000000..24987ed
--- /dev/null
+++ b/src/Http/Middleware/ResponseExceptionMiddleware.php
@@ -0,0 +1,75 @@
+then(
+ function (ResponseInterface $response) use ($request) {
+ $statusCode = $response->getStatusCode();
+
+ if (400 > $statusCode) {
+ return $response;
+ }
+
+ $errorMessage = false !== strpos($response->getHeaderLine('content-type'), 'application/json')
+ ? $this->getErrorMessage($response)
+ : (string) $response->getBody();
+
+ if (404 === $statusCode) {
+ throw new NotFoundException($request, $response, $errorMessage);
+ }
+
+ if (500 > $statusCode) {
+ throw new BadRequestException($request, $response, $errorMessage);
+ }
+
+ throw new ServerErrorException($request, $response, $errorMessage);
+ },
+ );
+ };
+ }
+
+ private function getErrorMessage(ResponseInterface $response): string
+ {
+ $responseBodyString = (string) $response->getBody();
+
+ try {
+ $responseBodyJson = json_decode($responseBodyString, false, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException $e) {
+ return $responseBodyString;
+ }
+
+ if (500 <= $response->getStatusCode()) {
+ return (string) ($responseBodyJson->message ?? $responseBodyString);
+ }
+
+ return (string) ($responseBodyJson->data->error ?? $responseBodyString);
+ }
+}
diff --git a/src/Http/Middleware/UnexpectedErrorMiddleware.php b/src/Http/Middleware/UnexpectedErrorMiddleware.php
new file mode 100644
index 0000000..7298dde
--- /dev/null
+++ b/src/Http/Middleware/UnexpectedErrorMiddleware.php
@@ -0,0 +1,36 @@
+origin = $origin;
+ }
+
+ public function getName(): string
+ {
+ return 'x_amp_origin_header';
+ }
+
+ public function getPriority(): int
+ {
+ return 80;
+ }
+
+ public function __invoke(Closure $next): Closure
+ {
+ return function (RequestInterface $request, array $options) use ($next): PromiseInterface {
+ $request = $request->withHeader('X-Amp-Origin', $this->origin);
+
+ return $next($request, $options);
+ };
+ }
+}
diff --git a/src/Http/Middlewares.php b/src/Http/Middlewares.php
new file mode 100644
index 0000000..4b2ff06
--- /dev/null
+++ b/src/Http/Middlewares.php
@@ -0,0 +1,50 @@
+
+ */
+final class Middlewares implements IteratorAggregate
+{
+ /** @var array */
+ private array $middlewares;
+
+ /**
+ * @param array $middlewares
+ */
+ public function __construct(array $middlewares)
+ {
+ $this->middlewares = $middlewares;
+ }
+
+ public function with(MiddlewareInterface $middleware): self
+ {
+ $middlewares = $this->middlewares;
+ $middlewares[] = $middleware;
+
+ return new self($middlewares);
+ }
+
+ /**
+ * @return ArrayIterator
+ */
+ public function getIterator(): ArrayIterator
+ {
+ $middlewares = $this->middlewares;
+
+ usort(
+ $middlewares,
+ static fn (MiddlewareInterface $left, MiddlewareInterface $right): int => $right->getPriority() <=> $left->getPriority(),
+ );
+
+ return new ArrayIterator($middlewares);
+ }
+}
diff --git a/src/Request/BannersRequest.php b/src/Request/BannersRequest.php
new file mode 100644
index 0000000..af2ffc5
--- /dev/null
+++ b/src/Request/BannersRequest.php
@@ -0,0 +1,72 @@
+ */
+ private array $positions = [];
+
+ private ?string $locale;
+
+ /**
+ * @param array $positions
+ */
+ public function __construct(array $positions = [], ?string $locale = null)
+ {
+ foreach ($positions as $position) {
+ $this->assertPositionCodeDoesNotExists($position->getCode());
+
+ $this->positions[$position->getCode()] = $position;
+ }
+
+ $this->locale = $locale;
+ }
+
+ public function withPosition(Position $position): self
+ {
+ $this->assertPositionCodeDoesNotExists($position->getCode());
+
+ $request = clone $this;
+ $request->positions[$position->getCode()] = $position;
+
+ return $request;
+ }
+
+ public function withLocale(string $locale): self
+ {
+ $request = clone $this;
+ $request->locale = $locale;
+
+ return $request;
+ }
+
+ /**
+ * @return array
+ */
+ public function getPositions(): array
+ {
+ return $this->positions;
+ }
+
+ public function getLocale(): ?string
+ {
+ return $this->locale;
+ }
+
+ public function assertPositionCodeDoesNotExists(string $code): void
+ {
+ if (isset($this->positions[$code])) {
+ throw new InvalidArgumentException(sprintf(
+ 'Position "%s" has been already defined.',
+ $code,
+ ));
+ }
+ }
+}
diff --git a/src/Request/ValueObject/BannerResource.php b/src/Request/ValueObject/BannerResource.php
new file mode 100644
index 0000000..d02059d
--- /dev/null
+++ b/src/Request/ValueObject/BannerResource.php
@@ -0,0 +1,57 @@
+ */
+ private array $values;
+
+ /**
+ * @param string|array $value
+ */
+ public function __construct(string $code, $value)
+ {
+ $this->code = $code;
+ $values = is_array($value) ? array_unique($value) : [$value];
+
+ sort($values);
+
+ $this->values = $values;
+ }
+
+ public function getCode(): string
+ {
+ return $this->code;
+ }
+
+ /**
+ * @return array
+ */
+ public function getValues(): array
+ {
+ return $this->values;
+ }
+
+ public function merge(self $resource): self
+ {
+ $values = $resource->getValues();
+ $values = array_unique(array_merge($this->values, $values));
+
+ sort($values);
+
+ return new self(
+ $this->code,
+ $values,
+ );
+ }
+}
diff --git a/src/Request/ValueObject/Position.php b/src/Request/ValueObject/Position.php
new file mode 100644
index 0000000..21fe664
--- /dev/null
+++ b/src/Request/ValueObject/Position.php
@@ -0,0 +1,76 @@
+ */
+ private array $resources;
+
+ /**
+ * @param array $resources
+ */
+ public function __construct(string $code, array $resources = [])
+ {
+ $this->code = $code;
+ $this->resources = $this->mergeResources([], $resources);
+ }
+
+ public function getCode(): string
+ {
+ return $this->code;
+ }
+
+ /**
+ * @return array
+ */
+ public function getResources(): array
+ {
+ return $this->resources;
+ }
+
+ /**
+ * @param array $resources
+ */
+ public function withResources(array $resources): self
+ {
+ if (0 >= count($resources)) {
+ return $this;
+ }
+
+ $position = clone $this;
+ $position->resources = $this->mergeResources($this->resources, $resources);
+
+ return $position;
+ }
+
+ /**
+ * @param array $currentResources
+ * @param array $newResources
+ *
+ * @return array
+ */
+ private function mergeResources(array $currentResources, array $newResources): array
+ {
+ foreach ($newResources as $resource) {
+ $resourceCode = $resource->getCode();
+
+ if (isset($currentResources[$resourceCode])) {
+ $currentResources[$resourceCode] = $currentResources[$resourceCode]->merge($resource);
+ } else {
+ $currentResources[$resourceCode] = $resource;
+ }
+ }
+
+ ksort($currentResources);
+
+ return $currentResources;
+ }
+}
diff --git a/src/Response/BannersResponse.php b/src/Response/BannersResponse.php
new file mode 100644
index 0000000..661008a
--- /dev/null
+++ b/src/Response/BannersResponse.php
@@ -0,0 +1,34 @@
+ */
+ private array $positions;
+
+ /**
+ * @param array $positions
+ */
+ public function __construct(array $positions)
+ {
+ $this->positions = $positions;
+ }
+
+ /**
+ * @return array
+ */
+ public function getPositions(): array
+ {
+ return $this->positions;
+ }
+
+ public function getPosition(string $code): ?Position
+ {
+ return $this->positions[$code] ?? null;
+ }
+}
diff --git a/src/Response/Hydrator/BannersResponseHydratorHandler.php b/src/Response/Hydrator/BannersResponseHydratorHandler.php
new file mode 100644
index 0000000..5ccf145
--- /dev/null
+++ b/src/Response/Hydrator/BannersResponseHydratorHandler.php
@@ -0,0 +1,162 @@
+,
+ * sizes: string,
+ * }
+ *
+ * @phpstan-type BannerData = array{
+ * id: string,
+ * name: string,
+ * score: int|float,
+ * campaign_id: string|null,
+ * campaign_code: string|null,
+ * campaign_name: string|null,
+ * contents: array,
+ * }
+ *
+ * @phpstan-type PositionData = array{
+ * position_id?: string|null,
+ * position_name?: string|null,
+ * rotation_seconds: int,
+ * display_type: string|null,
+ * breakpoint_type: string,
+ * banners: array,
+ * }
+ *
+ * @phpstan-type BannersResponseBody = array{
+ * status: string,
+ * data: array,
+ * }
+ */
+final class BannersResponseHydratorHandler implements ResponseHydratorHandlerInterface
+{
+ public function canHydrateResponse(string $responseClassname): bool
+ {
+ return $responseClassname === BannersResponse::class;
+ }
+
+ /**
+ * @param BannersResponseBody $responseBody
+ */
+ public function hydrate($responseBody): BannersResponse
+ {
+ $data = $responseBody['data'];
+ $positions = [];
+
+ foreach ($data as $positionCode => $positionData) {
+ $positions[$positionCode] = new Position(
+ $positionData['position_id'] ?? null,
+ $positionCode,
+ $positionData['position_name'] ?? null,
+ $positionData['rotation_seconds'],
+ $positionData['display_type'] ?? null,
+ $positionData['breakpoint_type'],
+ $this->hydrateBanners($positionData['banners']),
+ );
+ }
+
+ return new BannersResponse($positions);
+ }
+
+ /**
+ * @param array $bannersData
+ *
+ * @return array
+ */
+ private function hydrateBanners(array $bannersData): array
+ {
+ $banners = [];
+
+ foreach ($bannersData as $bannerData) {
+ $banners[] = new Banner(
+ $bannerData['id'],
+ $bannerData['name'],
+ $bannerData['score'],
+ $bannerData['campaign_id'],
+ $bannerData['campaign_code'],
+ $bannerData['campaign_name'],
+ $this->hydrateContents($bannerData['contents']),
+ );
+ }
+
+ return $banners;
+ }
+
+ /**
+ * @param array $contentsData
+ *
+ * @return array
+ */
+ private function hydrateContents(array $contentsData): array
+ {
+ $contents = [];
+
+ foreach ($contentsData as $contentData) {
+ switch ($contentData['type']) {
+ case ContentInterface::TypeImage:
+ /** @var ImageContentData $contentData */
+ $contents[] = new ImageContent(
+ $contentData['breakpoint'],
+ $contentData['href'],
+ $contentData['target'],
+ $contentData['alt'],
+ $contentData['title'],
+ $contentData['src'],
+ $contentData['srcset'],
+ $contentData['sizes'],
+ array_map(
+ static fn (array $sourceData): Source => new Source(
+ $sourceData['type'],
+ $sourceData['srcset'],
+ ),
+ $contentData['sources'],
+ ),
+ );
+
+ break;
+ case ContentInterface::TypeHtml:
+ /** @var HtmlContentData $contentData */
+ $contents[] = new HtmlContent(
+ $contentData['breakpoint'],
+ $contentData['html'],
+ );
+
+ break;
+ }
+ }
+
+ return $contents;
+ }
+}
diff --git a/src/Response/Hydrator/ResponseHydrator.php b/src/Response/Hydrator/ResponseHydrator.php
new file mode 100644
index 0000000..81ca42e
--- /dev/null
+++ b/src/Response/Hydrator/ResponseHydrator.php
@@ -0,0 +1,37 @@
+ */
+ private array $handlers;
+
+ /**
+ * @param array $handlers
+ */
+ public function __construct(array $handlers)
+ {
+ $this->handlers = $handlers;
+ }
+
+ public function hydrate(string $responseClassname, $responseBody): object
+ {
+ foreach ($this->handlers as $hydrator) {
+ if ($hydrator->canHydrateResponse($responseClassname)) {
+ $response = $hydrator->hydrate($responseBody);
+
+ assert($response instanceof $responseClassname);
+
+ return $response;
+ }
+ }
+
+ throw ResponseHydrationException::unableToHandleResponseClassname($responseClassname);
+ }
+}
diff --git a/src/Response/Hydrator/ResponseHydratorHandlerInterface.php b/src/Response/Hydrator/ResponseHydratorHandlerInterface.php
new file mode 100644
index 0000000..24a0e97
--- /dev/null
+++ b/src/Response/Hydrator/ResponseHydratorHandlerInterface.php
@@ -0,0 +1,18 @@
+ $responseClassname
+ * @param mixed $responseBody
+ *
+ * @return T
+ * @throws ResponseHydrationException
+ */
+ public function hydrate(string $responseClassname, $responseBody): object;
+}
diff --git a/src/Response/ValueObject/Banner.php b/src/Response/ValueObject/Banner.php
new file mode 100644
index 0000000..22a110e
--- /dev/null
+++ b/src/Response/ValueObject/Banner.php
@@ -0,0 +1,87 @@
+ */
+ private array $contents;
+
+ /**
+ * @param int|float $score
+ * @param array $contents
+ */
+ public function __construct(
+ string $id,
+ string $name,
+ $score,
+ ?string $campaignId,
+ ?string $campaignCode,
+ ?string $campaignName,
+ array $contents
+ ) {
+ $this->id = $id;
+ $this->name = $name;
+ $this->score = $score;
+ $this->campaignId = $campaignId;
+ $this->campaignCode = $campaignCode;
+ $this->campaignName = $campaignName;
+ $this->contents = $contents;
+ }
+
+ public function getId(): string
+ {
+ return $this->id;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ /**
+ * @return float|int
+ */
+ public function getScore()
+ {
+ return $this->score;
+ }
+
+ public function getCampaignId(): ?string
+ {
+ return $this->campaignId;
+ }
+
+ public function getCampaignCode(): ?string
+ {
+ return $this->campaignCode;
+ }
+
+ public function getCampaignName(): ?string
+ {
+ return $this->campaignName;
+ }
+
+ /**
+ * @return array
+ */
+ public function getContents(): array
+ {
+ return $this->contents;
+ }
+}
diff --git a/src/Response/ValueObject/ContentInterface.php b/src/Response/ValueObject/ContentInterface.php
new file mode 100644
index 0000000..313113d
--- /dev/null
+++ b/src/Response/ValueObject/ContentInterface.php
@@ -0,0 +1,13 @@
+breakpoint = $breakpoint;
+ $this->html = $html;
+ }
+
+ public function getBreakpoint(): ?int
+ {
+ return $this->breakpoint;
+ }
+
+ public function getHtml(): string
+ {
+ return $this->html;
+ }
+}
diff --git a/src/Response/ValueObject/ImageContent.php b/src/Response/ValueObject/ImageContent.php
new file mode 100644
index 0000000..a1aebbe
--- /dev/null
+++ b/src/Response/ValueObject/ImageContent.php
@@ -0,0 +1,100 @@
+ */
+ private array $sources;
+
+ /**
+ * @param array $sources
+ */
+ public function __construct(
+ ?int $breakpoint,
+ string $href,
+ ?string $target,
+ string $alt,
+ string $title,
+ string $src,
+ string $srcset,
+ string $sizes,
+ array $sources
+ ) {
+ $this->breakpoint = $breakpoint;
+ $this->href = $href;
+ $this->target = $target;
+ $this->alt = $alt;
+ $this->title = $title;
+ $this->src = $src;
+ $this->srcset = $srcset;
+ $this->sizes = $sizes;
+ $this->sources = $sources;
+ }
+
+ public function getBreakpoint(): ?int
+ {
+ return $this->breakpoint;
+ }
+
+ public function getHref(): string
+ {
+ return $this->href;
+ }
+
+ public function getTarget(): ?string
+ {
+ return $this->target;
+ }
+
+ public function getAlt(): string
+ {
+ return $this->alt;
+ }
+
+ public function getTitle(): string
+ {
+ return $this->title;
+ }
+
+ public function getSrc(): string
+ {
+ return $this->src;
+ }
+
+ public function getSrcset(): string
+ {
+ return $this->srcset;
+ }
+
+ public function getSizes(): string
+ {
+ return $this->sizes;
+ }
+
+ /**
+ * @return array
+ */
+ public function getSources(): array
+ {
+ return $this->sources;
+ }
+}
diff --git a/src/Response/ValueObject/Position.php b/src/Response/ValueObject/Position.php
new file mode 100644
index 0000000..d5d53d4
--- /dev/null
+++ b/src/Response/ValueObject/Position.php
@@ -0,0 +1,89 @@
+ */
+ private array $banners;
+
+ /**
+ * @param array $banners
+ */
+ public function __construct(
+ ?string $id,
+ string $code,
+ ?string $name,
+ int $rotationSeconds,
+ ?string $displayType,
+ string $breakpointType,
+ array $banners
+ ) {
+ $this->id = $id;
+ $this->code = $code;
+ $this->name = $name;
+ $this->rotationSeconds = $rotationSeconds;
+ $this->displayType = $displayType;
+ $this->breakpointType = $breakpointType;
+ $this->banners = $banners;
+ }
+
+ public function getId(): ?string
+ {
+ return $this->id;
+ }
+
+ public function getCode(): string
+ {
+ return $this->code;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ public function getRotationSeconds(): int
+ {
+ return $this->rotationSeconds;
+ }
+
+ public function getDisplayType(): ?string
+ {
+ return $this->displayType;
+ }
+
+ public function getBreakpointType(): string
+ {
+ return $this->breakpointType;
+ }
+
+ /**
+ * @return array
+ */
+ public function getBanners(): array
+ {
+ return $this->banners;
+ }
+}
diff --git a/src/Response/ValueObject/Source.php b/src/Response/ValueObject/Source.php
new file mode 100644
index 0000000..7ba3ae6
--- /dev/null
+++ b/src/Response/ValueObject/Source.php
@@ -0,0 +1,30 @@
+type = $type;
+ $this->srcset = $srcset;
+ }
+
+ public function getType(): string
+ {
+ return $this->type;
+ }
+
+ public function getSrcset(): string
+ {
+ return $this->srcset;
+ }
+}
diff --git a/tests/.coveralls.yml b/tests/.coveralls.yml
new file mode 100644
index 0000000..80c1e54
--- /dev/null
+++ b/tests/.coveralls.yml
@@ -0,0 +1,3 @@
+service_name: github-actions
+coverage_clover: coverage.xml
+json_path: coverage.json
diff --git a/tests/.gitignore b/tests/.gitignore
new file mode 100644
index 0000000..1a18321
--- /dev/null
+++ b/tests/.gitignore
@@ -0,0 +1,2 @@
+**.actual
+**.expected
diff --git a/tests/ClientConfigTest.php b/tests/ClientConfigTest.php
new file mode 100644
index 0000000..a66bd4f
--- /dev/null
+++ b/tests/ClientConfigTest.php
@@ -0,0 +1,141 @@
+getUrl());
+ Assert::same('test', $config->getChannel());
+ Assert::same('GET', $config->getMethod());
+ Assert::same(1, $config->getVersion());
+ Assert::null($config->getLocale());
+ Assert::same([], $config->getDefaultResources());
+ Assert::null($config->getOrigin());
+ Assert::same(0, $config->getCacheExpiration());
+ Assert::null($config->getCacheControlHeaderOverride());
+ }
+
+ public function testUrlShouldBeChanged(): void
+ {
+ $config = ClientConfig::create('https://www.example.com', 'test');
+ $modified = $config->withUrl('https://www.example.io');
+
+ Assert::notSame($config, $modified);
+ Assert::same('https://www.example.com', $config->getUrl());
+ Assert::same('https://www.example.io', $modified->getUrl());
+ }
+
+ public function testChannelShouldBeChanged(): void
+ {
+ $config = ClientConfig::create('https://www.example.com', 'test');
+ $modified = $config->withChannel('demo');
+
+ Assert::notSame($config, $modified);
+ Assert::same('test', $config->getChannel());
+ Assert::same('demo', $modified->getChannel());
+ }
+
+ public function testMethodShouldBeChanged(): void
+ {
+ $config = ClientConfig::create('https://www.example.com', 'test');
+ $modified = $config->withMethod('POST');
+
+ Assert::notSame($config, $modified);
+ Assert::same('GET', $config->getMethod());
+ Assert::same('POST', $modified->getMethod());
+ }
+
+ public function testExceptionShouldBeThrownWhenInvalidMethodIsPassed(): void
+ {
+ $config = ClientConfig::create('https://www.example.com', 'test');
+
+ Assert::exception(
+ static fn () => $config->withMethod('PUT'),
+ InvalidArgumentException::class,
+ 'Invalid method "PUT" passed.',
+ );
+ }
+
+ public function testExceptionShouldBeThrownWhenInvalidVersionIsPassed(): void
+ {
+ $config = ClientConfig::create('https://www.example.com', 'test');
+
+ Assert::exception(
+ static fn () => $config->withVersion(1000),
+ InvalidArgumentException::class,
+ 'Invalid version 1000 passed.',
+ );
+ }
+
+ public function testLocaleShouldBeChanged(): void
+ {
+ $config = ClientConfig::create('https://www.example.com', 'test');
+ $modified = $config->withLocale('cs');
+
+ Assert::notSame($config, $modified);
+ Assert::null($config->getLocale());
+ Assert::same('cs', $modified->getLocale());
+ }
+
+ public function testDefaultResourceShouldBeChanged(): void
+ {
+ $config = ClientConfig::create('https://www.example.com', 'test');
+ $resources = [
+ new BannerResource('test', ['dummy']),
+ ];
+ $modified = $config->withDefaultResources($resources);
+
+ Assert::notSame($config, $modified);
+ Assert::same([], $config->getDefaultResources());
+ Assert::same($resources, $modified->getDefaultResources());
+ }
+
+ public function testOriginShouldBeChanged(): void
+ {
+ $config = ClientConfig::create('https://www.example.com', 'test');
+ $modified = $config->withOrigin('https://www.example.io');
+
+ Assert::notSame($config, $modified);
+ Assert::null($config->getOrigin());
+ Assert::same('https://www.example.io', $modified->getOrigin());
+ }
+
+ public function testCacheExpirationShouldBeChanged(): void
+ {
+ $config = ClientConfig::create('https://www.example.com', 'test');
+ $modified = $config->withCacheExpiration(3600);
+ $modified2 = $config->withCacheExpiration('+1 hour');
+
+ Assert::notSame($config, $modified);
+ Assert::notSame($modified2, $modified);
+ Assert::same(0, $config->getCacheExpiration());
+ Assert::same(3600, $modified->getCacheExpiration());
+ Assert::same('+1 hour', $modified2->getCacheExpiration());
+ }
+
+ public function testCacheControlHeaderOverrideShouldBeChanged(): void
+ {
+ $config = ClientConfig::create('https://www.example.com', 'test');
+ $modified = $config->withCacheControlHeaderOverride('no-store');
+
+ Assert::notSame($config, $modified);
+ Assert::null($config->getCacheControlHeaderOverride());
+ Assert::same('no-store', $modified->getCacheControlHeaderOverride());
+ }
+}
+
+(new ClientConfigTest())->run();
diff --git a/tests/Exception/AmpExceptionFixture.php b/tests/Exception/AmpExceptionFixture.php
new file mode 100644
index 0000000..2bf1cfb
--- /dev/null
+++ b/tests/Exception/AmpExceptionFixture.php
@@ -0,0 +1,12 @@
+doAsserts($header);
+ }
+
+ public function testHeaderParsingFromResponse(): void
+ {
+ $response = new Response(200, [
+ 'Cache-Control' => [
+ 'max-age=300, must-revalidate',
+ 'no-cache, no-store, must-revalidate',
+ 's-maxage=100',
+ ],
+ ]);
+
+ $header = CacheControlHeader::fromResponse($response);
+
+ $this->doAsserts($header);
+ }
+
+ private function doAsserts(CacheControlHeader $header): void
+ {
+ Assert::true($header->has('max-age'));
+ Assert::true($header->has('must-revalidate'));
+ Assert::true($header->has('no-cache'));
+ Assert::true($header->has('no-store'));
+ Assert::true($header->has('must-revalidate'));
+ Assert::true($header->has('s-maxage'));
+ Assert::false($header->has('foo'));
+
+ Assert::same('300', $header->get('max-age'));
+ Assert::same('', $header->get('must-revalidate'));
+ Assert::same('', $header->get('no-cache'));
+ Assert::same('', $header->get('no-store'));
+ Assert::same('', $header->get('must-revalidate'));
+ Assert::same('100', $header->get('s-maxage'));
+
+ Assert::same([
+ 'max-age' => '300',
+ 'must-revalidate' => '',
+ 'no-cache' => '',
+ 'no-store' => '',
+ 's-maxage' => '100',
+ ], $header->all());
+ }
+}
+
+(new CacheControlHeaderTest())->run();
diff --git a/tests/Http/Cache/CacheControlTest.php b/tests/Http/Cache/CacheControlTest.php
new file mode 100644
index 0000000..5ae4830
--- /dev/null
+++ b/tests/Http/Cache/CacheControlTest.php
@@ -0,0 +1,30 @@
+createExpiration());
+ Assert::null($a->getCacheControlHeaderOverride());
+
+ Assert::equal(Expiration::create(60), $b->createExpiration());
+ Assert::equal(new CacheControlHeader(['max-age=300, must-revalidate']), $b->getCacheControlHeaderOverride());
+ }
+}
+
+(new CacheControlTest())->run();
diff --git a/tests/Http/Cache/CacheKeyTest.php b/tests/Http/Cache/CacheKeyTest.php
new file mode 100644
index 0000000..45a2eba
--- /dev/null
+++ b/tests/Http/Cache/CacheKeyTest.php
@@ -0,0 +1,31 @@
+ 'https://www.example.com/api/test',
+ '__version' => 1,
+ 'query' => [
+ 'foo' => 'bar',
+ 'test' => '1',
+ ],
+ ]);
+
+ Assert::same('5f-tX8P4AcvzMTW8RAeB/IfbPkhmtA', $a->getValue());
+ }
+}
+
+(new CacheKeyTest())->run();
diff --git a/tests/Http/Cache/EtagTest.php b/tests/Http/Cache/EtagTest.php
new file mode 100644
index 0000000..727d055
--- /dev/null
+++ b/tests/Http/Cache/EtagTest.php
@@ -0,0 +1,35 @@
+getValue());
+ }
+
+ public function testCreatingEtagFromResponse(): void
+ {
+ $response = new Response(200, [
+ 'ETag' => 'test',
+ ]);
+
+ $etag = Etag::fromResponse($response);
+
+ Assert::same('test', $etag->getValue());
+ }
+}
+
+(new EtagTest())->run();
diff --git a/tests/Http/Cache/ExpirationTest.php b/tests/Http/Cache/ExpirationTest.php
new file mode 100644
index 0000000..a391514
--- /dev/null
+++ b/tests/Http/Cache/ExpirationTest.php
@@ -0,0 +1,61 @@
+getValue());
+ Assert::true($expiration->isExpired());
+ Assert::false($expiration->isFresh());
+ }
+
+ public function testZeroExpirationFromIntegerShouldBeDirectlyExpired(): void
+ {
+ $expiration = Expiration::create(0);
+
+ Assert::same(time() - 1, $expiration->getValue());
+ Assert::true($expiration->isExpired());
+ Assert::false($expiration->isFresh());
+ }
+
+ public function testFutureExpirationFromStringShouldBeFresh(): void
+ {
+ $expiration = Expiration::create('+60 seconds');
+
+ Assert::same(time() + 60, $expiration->getValue());
+ Assert::false($expiration->isExpired());
+ Assert::true($expiration->isFresh());
+ }
+
+ public function testFutureExpirationFromIntegerShouldBeFresh(): void
+ {
+ $expiration = Expiration::create(60);
+
+ Assert::same(time() + 60, $expiration->getValue());
+ Assert::false($expiration->isExpired());
+ Assert::true($expiration->isFresh());
+ }
+
+ public function testPastExpirationShouldBeExpired(): void
+ {
+ $expiration = new Expiration(time() - 1);
+
+ Assert::true($expiration->isExpired());
+ Assert::false($expiration->isFresh());
+ }
+}
+
+(new ExpirationTest())->run();
diff --git a/tests/Http/Middleware/ResponseExceptionMiddlewareTest.php b/tests/Http/Middleware/ResponseExceptionMiddlewareTest.php
new file mode 100644
index 0000000..ac368ff
--- /dev/null
+++ b/tests/Http/Middleware/ResponseExceptionMiddlewareTest.php
@@ -0,0 +1,96 @@
+getName());
+ }
+
+ public function testPriorityShouldBeReturned(): void
+ {
+ $middleware = new ResponseExceptionMiddleware();
+
+ Assert::same(90, $middleware->getPriority());
+ }
+
+ public function testResponseShouldBeReturnedOnNonErrorStatusCode(): void
+ {
+ $request = new Request('GET', 'https://example.com', [], '');
+ $response = new Response(200, [], 'OK');
+ $options = [];
+ $next = static fn (): FulfilledPromise => new FulfilledPromise($response);
+
+ $middleware = new ResponseExceptionMiddleware();
+ $middlewareFunction = $middleware($next);
+
+ $returnedResponse = $middlewareFunction($request, $options)->wait();
+
+ Assert::same($response, $returnedResponse);
+ }
+
+ /**
+ * @dataProvider dataProviderHttpResponseExceptionShouldBeThrown
+ */
+ public function testHttpResponseExceptionShouldBeThrown(
+ int $statusCode,
+ string $responseBody,
+ string $exceptionClassname,
+ string $exceptionMessage,
+ bool $isJson
+ ): void {
+ $request = new Request('GET', 'https://example.com', [], '');
+ $response = new Response($statusCode, $isJson ? ['Content-Type' => 'application/json'] : [], $responseBody);
+ $next = static fn (): FulfilledPromise => new FulfilledPromise($response);
+
+ $middleware = new ResponseExceptionMiddleware();
+ $middlewareFunction = $middleware($next);
+
+ $thrownException = Assert::exception(
+ static fn () => $middlewareFunction($request, [])->wait(),
+ $exceptionClassname,
+ );
+
+ assert($thrownException instanceof AmpHttpExceptionInterface);
+
+ Assert::same($request, $thrownException->getRequest());
+ Assert::same($response, $thrownException->getResponse());
+ Assert::equal($exceptionMessage, $thrownException->getMessage());
+ }
+
+ public function dataProviderHttpResponseExceptionShouldBeThrown(): array
+ {
+ return [
+ [400, '{"status":"error","data":{"code":400,"error":"Bad request 400!"}}', BadRequestException::class, 'Bad request 400!', true],
+ [403, '{"status":"error","data":{"code":403,"error":"Bad request 403!"}}', BadRequestException::class, 'Bad request 403!', true],
+ [404, '{"status":"error","data":{"code":404,"error":"Not found!"}}', NotFoundException::class, 'Not found!', true],
+ [408, 'Timeout!', BadRequestException::class, 'Timeout!', false], // text response
+ [500, '{"status":"error","message":"Server error 500!"}', ServerErrorException::class, 'Server error 500!', true],
+ [503, '{"status":"error","message":"Server error 503!"}', ServerErrorException::class, 'Server error 503!', true],
+ [504, 'Gateway timeout!', ServerErrorException::class, 'Gateway timeout!', false], // text response
+ [504, '{"error":"Gateway timeout!', ServerErrorException::class, '{"error":"Gateway timeout!', true], // invalid json
+ ];
+ }
+}
+
+(new ResponseExceptionMiddlewareTest())->run();
diff --git a/tests/Http/Middleware/UnexpectedErrorMiddlewareTest.php b/tests/Http/Middleware/UnexpectedErrorMiddlewareTest.php
new file mode 100644
index 0000000..3a5d733
--- /dev/null
+++ b/tests/Http/Middleware/UnexpectedErrorMiddlewareTest.php
@@ -0,0 +1,75 @@
+getName());
+ }
+
+ public function testPriorityShouldBeReturned(): void
+ {
+ $middleware = new UnexpectedErrorMiddleware();
+
+ Assert::same(100, $middleware->getPriority());
+ }
+
+ public function testAmpExceptionShouldBeThrown(): void
+ {
+ $request = new Request('GET', 'https://example.com', [], '');
+ $options = [];
+ $next = static function (): void {
+ throw new AmpExceptionFixture('Test amp exception.');
+ };
+
+ $middleware = new UnexpectedErrorMiddleware();
+ $middlewareFunction = $middleware($next);
+
+ $thrownException = Assert::exception(
+ static fn () => $middlewareFunction($request, $options),
+ AmpExceptionFixture::class,
+ 'Test amp exception.',
+ );
+
+ Assert::null($thrownException->getPrevious());
+ }
+
+ public function testUnexpectedErrorExceptionShouldBeThrown(): void
+ {
+ $originalException = new RuntimeException('Test runtime exception.');
+ $request = new Request('GET', 'https://example.com', [], '');
+ $options = [];
+ $next = static function () use ($originalException): void {
+ throw $originalException;
+ };
+
+ $middleware = new UnexpectedErrorMiddleware();
+ $middlewareFunction = $middleware($next);
+
+ $thrownException = Assert::exception(
+ static fn () => $middlewareFunction($request, $options),
+ UnexpectedErrorException::class,
+ 'Client thrown an unexpected exception: Test runtime exception.',
+ );
+
+ Assert::same($originalException, $thrownException->getPrevious());
+ }
+}
+
+(new UnexpectedErrorMiddlewareTest())->run();
diff --git a/tests/Http/Middleware/XAmpOriginHeaderMiddlewareTest.php b/tests/Http/Middleware/XAmpOriginHeaderMiddlewareTest.php
new file mode 100644
index 0000000..a89ad67
--- /dev/null
+++ b/tests/Http/Middleware/XAmpOriginHeaderMiddlewareTest.php
@@ -0,0 +1,45 @@
+getName());
+ }
+
+ public function testPriorityShouldBeReturned(): void
+ {
+ $middleware = new XAmpOriginHeaderMiddleware('https://test.example.com');
+
+ Assert::same(80, $middleware->getPriority());
+ }
+
+ public function testXAmpOriginHeaderShouldBeAdded(): void
+ {
+ $request = new Request('GET', 'https://example.com', [], '');
+ $next = static fn (RequestInterface $request): FulfilledPromise => new FulfilledPromise($request);
+
+ $middleware = new XAmpOriginHeaderMiddleware('https://test.example.com');
+ $middlewareFunction = $middleware($next);
+ $returnedRequest = $middlewareFunction($request, [])->wait();
+
+ Assert::same(['https://test.example.com'], $returnedRequest->getHeader('x-amp-origin'));
+ }
+}
+
+(new XAmpOriginHeaderMiddlewareTest())->run();
diff --git a/tests/Request/BannersRequestTest.php b/tests/Request/BannersRequestTest.php
new file mode 100644
index 0000000..e922484
--- /dev/null
+++ b/tests/Request/BannersRequestTest.php
@@ -0,0 +1,77 @@
+getPositions());
+ Assert::null($request->getLocale());
+ }
+
+ public function testRequestImmutability(): void
+ {
+ $positionA = new Position('a');
+ $positionB = new Position('b');
+ $positionC = new Position('c');
+ $positionD = new Position('d');
+
+ $request = new BannersRequest([$positionA, $positionB]);
+
+ $request2 = $request->withPosition($positionC);
+ $request3 = $request2->withLocale('cs');
+ $request4 = $request3->withPosition($positionD);
+
+ Assert::same(['a' => $positionA, 'b' => $positionB], $request->getPositions());
+ Assert::same(['a' => $positionA, 'b' => $positionB, 'c' => $positionC], $request2->getPositions());
+ Assert::same(['a' => $positionA, 'b' => $positionB, 'c' => $positionC], $request3->getPositions());
+ Assert::same(['a' => $positionA, 'b' => $positionB, 'c' => $positionC, 'd' => $positionD], $request4->getPositions());
+
+ Assert::null($request->getLocale());
+ Assert::null($request2->getLocale());
+ Assert::same('cs', $request3->getLocale());
+ Assert::same('cs', $request4->getLocale());
+ }
+
+ public function testExceptionShouldBeThrownWhenRequestWithDuplicatePositionsIsCreated(): void
+ {
+ Assert::exception(
+ static fn () => new BannersRequest([
+ new Position('a'),
+ new Position('b'),
+ new Position('a'),
+ ]),
+ InvalidArgumentException::class,
+ 'Position "a" has been already defined.',
+ );
+ }
+
+ public function testExceptionShouldBeThrownWhenDuplicatedPositionIsAdded(): void
+ {
+ $request = new BannersRequest([
+ new Position('a'),
+ new Position('b'),
+ ]);
+
+ Assert::exception(
+ static fn () => $request->withPosition(new Position('a')),
+ InvalidArgumentException::class,
+ 'Position "a" has been already defined.',
+ );
+ }
+}
+
+(new BannersRequestTest())->run();
diff --git a/tests/Request/ValueObject/BannerResourceTest.php b/tests/Request/ValueObject/BannerResourceTest.php
new file mode 100644
index 0000000..fea5126
--- /dev/null
+++ b/tests/Request/ValueObject/BannerResourceTest.php
@@ -0,0 +1,47 @@
+getCode());
+ Assert::same(['a'], $resource->getValues());
+ }
+
+ public function testCreatingResourceWithMultipleValues(): void
+ {
+ $resource = new BannerResource('test', ['b', 'a', 'c', 'a']);
+
+ Assert::same('test', $resource->getCode());
+ Assert::same(['a', 'b', 'c'], $resource->getValues());
+ }
+
+ public function testResourcesMerging(): void
+ {
+ $resource1 = new BannerResource('test', ['a', 'x']);
+ $resource2 = new BannerResource('test2', ['b', 'a', 'c', 'c']);
+
+ $merged1 = $resource1->merge($resource2);
+ $merged2 = $resource2->merge($resource1);
+
+ Assert::same('test', $merged1->getCode());
+ Assert::same('test2', $merged2->getCode());
+
+ Assert::same(['a', 'b', 'c', 'x'], $merged1->getValues());
+ Assert::same(['a', 'b', 'c', 'x'], $merged2->getValues());
+ }
+}
+
+(new BannerResourceTest())->run();
diff --git a/tests/Request/ValueObject/PositionTest.php b/tests/Request/ValueObject/PositionTest.php
new file mode 100644
index 0000000..aaff5f5
--- /dev/null
+++ b/tests/Request/ValueObject/PositionTest.php
@@ -0,0 +1,63 @@
+getCode());
+ Assert::same([], $position->getResources());
+ }
+
+ public function testCreatingPositionWithResources(): void
+ {
+ $position = new Position('test', [
+ new BannerResource('test1', 'a'),
+ new BannerResource('test2', ['a', 'c', 'b']),
+ new BannerResource('test1', ['b']),
+ ]);
+
+ Assert::same('test', $position->getCode());
+ Assert::equal([
+ 'test1' => new BannerResource('test1', ['a', 'b']),
+ 'test2' => new BannerResource('test2', ['a', 'b', 'c']),
+ ], $position->getResources());
+ }
+
+ public function testAddingPositionResources(): void
+ {
+ $position = new Position('test', [
+ new BannerResource('test1', 'a'),
+ ]);
+
+ $position2 = $position->withResources([
+ new BannerResource('test2', ['a', 'b']),
+ new BannerResource('test1', ['a', 'b', 'c']),
+ ]);
+
+ Assert::notSame($position, $position2);
+ Assert::same('test', $position->getCode());
+ Assert::same('test', $position->getCode());
+ Assert::equal([
+ 'test1' => new BannerResource('test1', ['a']),
+ ], $position->getResources());
+ Assert::equal([
+ 'test1' => new BannerResource('test1', ['a', 'b', 'c']),
+ 'test2' => new BannerResource('test2', ['a', 'b']),
+ ], $position2->getResources());
+ }
+}
+
+(new PositionTest())->run();
diff --git a/tests/Response/Hydrator/BannerResponseHydratorHandlerTest.php b/tests/Response/Hydrator/BannerResponseHydratorHandlerTest.php
new file mode 100644
index 0000000..55074af
--- /dev/null
+++ b/tests/Response/Hydrator/BannerResponseHydratorHandlerTest.php
@@ -0,0 +1,38 @@
+canHydrateResponse(BannersResponse::class));
+ Assert::false($handler->canHydrateResponse(stdClass::class));
+ }
+
+ public function testResponseShouldBeHydrated(): void
+ {
+ $response = json_decode(file_get_contents(__DIR__ . '/../../resources/response-body/fetch-banners.full.json'), true);
+ $expected = require __DIR__ . '/../../resources/response-body/fetch-banners.full.php';
+
+ $handler = new BannersResponseHydratorHandler();
+
+ Assert::equal($expected, $handler->hydrate($response));
+ }
+}
+
+(new BannerResponseHydratorHandlerTest())->run();
diff --git a/tests/Response/Hydrator/ResponseHydratorHandlerFixture.php b/tests/Response/Hydrator/ResponseHydratorHandlerFixture.php
new file mode 100644
index 0000000..9544875
--- /dev/null
+++ b/tests/Response/Hydrator/ResponseHydratorHandlerFixture.php
@@ -0,0 +1,33 @@
+responseClassname = $responseClassname;
+ $this->result = $result ?? new stdClass();
+ }
+
+ public function canHydrateResponse(string $responseClassname): bool
+ {
+ return $this->responseClassname === $responseClassname;
+ }
+
+ public function hydrate($responseBody): object
+ {
+ return $this->result;
+ }
+}
diff --git a/tests/Response/Hydrator/ResponseHydratorTest.php b/tests/Response/Hydrator/ResponseHydratorTest.php
new file mode 100644
index 0000000..77dd928
--- /dev/null
+++ b/tests/Response/Hydrator/ResponseHydratorTest.php
@@ -0,0 +1,54 @@
+ $hydrator->hydrate('stdClass', []),
+ ResponseHydrationException::class,
+ 'Unable to handle response of type stdClass.',
+ );
+ }
+
+ public function testExceptionShouldBeThrownWhenNoHandlerCanHydrateClassname(): void
+ {
+ $hydrator = new ResponseHydrator([
+ new ResponseHydratorHandlerFixture('ArrayObject'),
+ ]);
+
+ Assert::exception(
+ static fn () => $hydrator->hydrate('stdClass', []),
+ ResponseHydrationException::class,
+ 'Unable to handle response of type stdClass.',
+ );
+ }
+
+ public function testResponseShouldBeHydrated(): void
+ {
+ $result = (object) [
+ 'a' => 13,
+ ];
+
+ $hydrator = new ResponseHydrator([
+ new ResponseHydratorHandlerFixture('stdClass', $result),
+ ]);
+
+ Assert::same($result, $hydrator->hydrate('stdClass', $result));
+ }
+}
+
+(new ResponseHydratorTest())->run();
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..c489dda
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,16 @@
+Homepage bottom"
+ }
+ ]
+ }
+ ]
+ }
+ }
+}
diff --git a/tests/resources/response-body/fetch-banners.full.php b/tests/resources/response-body/fetch-banners.full.php
new file mode 100644
index 0000000..54246c6
--- /dev/null
+++ b/tests/resources/response-body/fetch-banners.full.php
@@ -0,0 +1,128 @@
+ new Position(
+ '0360fbe9-e742-4dee-8f3d-b63c8b601d66',
+ 'homepage.top',
+ 'Homepage top',
+ 3,
+ 'multiple',
+ 'min',
+ [
+ new Banner(
+ 'd7275445-c287-47d2-b71a-3baff5b4d23c',
+ 'Homepage top - first',
+ 2,
+ null,
+ null,
+ null,
+ [
+ new ImageContent(
+ null,
+ 'https://www.example.com/top-first',
+ '_blank',
+ 'Homepage top - first',
+ 'Homepage top - first',
+ 'https://amp.example.com/data/images/d7275445-c287-47d2-b71a-3baff5b4d23c/w=1320/cee005030cd85b434a3b361d284011d8.jpg',
+ 'https://amp.example.com/data/images/d7275445-c287-47d2-b71a-3baff5b4d23c/w=320/cee005030cd85b434a3b361d284011d8.jpg 320w, https://amp.example.com/data/images/d7275445-c287-47d2-b71a-3baff5b4d23c/w=520/cee005030cd85b434a3b361d284011d8.jpg 520w, https://amp.example.com/data/images/d7275445-c287-47d2-b71a-3baff5b4d23c/w=720/cee005030cd85b434a3b361d284011d8.jpg 720w, https://amp.example.com/data/images/d7275445-c287-47d2-b71a-3baff5b4d23c/w=920/cee005030cd85b434a3b361d284011d8.jpg 920w, https://amp.example.com/data/images/d7275445-c287-47d2-b71a-3baff5b4d23c/w=1120/cee005030cd85b434a3b361d284011d8.jpg 1120w, https://amp.example.com/data/images/d7275445-c287-47d2-b71a-3baff5b4d23c/w=1320/cee005030cd85b434a3b361d284011d8.jpg 1320w',
+ '(min-width: 1200px) calc(1200px - 2 * 16px), (min-width: 576px) calc(100vw - 2 * 16px), 100vw',
+ [
+ new Source(
+ 'image/webp',
+ 'https://amp.example.com/data/images/d7275445-c287-47d2-b71a-3baff5b4d23c/w=320/cee005030cd85b434a3b361d284011d8.webp 320w, https://amp.example.com/data/images/d7275445-c287-47d2-b71a-3baff5b4d23c/w=520/cee005030cd85b434a3b361d284011d8.webp 520w, https://amp.example.com/data/images/d7275445-c287-47d2-b71a-3baff5b4d23c/w=720/cee005030cd85b434a3b361d284011d8.webp 720w, https://amp.example.com/data/images/d7275445-c287-47d2-b71a-3baff5b4d23c/w=920/cee005030cd85b434a3b361d284011d8.webp 920w, https://amp.example.com/data/images/d7275445-c287-47d2-b71a-3baff5b4d23c/w=1120/cee005030cd85b434a3b361d284011d8.webp 1120w, https://amp.example.com/data/images/d7275445-c287-47d2-b71a-3baff5b4d23c/w=1320/cee005030cd85b434a3b361d284011d8.webp 1320w',
+ ),
+ ],
+ ),
+ ],
+ ),
+ new Banner(
+ '1b3f5f5a-f67d-4e0f-9d5e-b607f99fb217',
+ 'Homepage top - second',
+ 0,
+ 'a6c98208-b707-46c2-80c3-8f3753a522b8',
+ 'test-campaign',
+ 'Test campaign',
+ [
+ new ImageContent(
+ null,
+ 'https://www.example.com/top-second',
+ null,
+ 'Homepage top - second',
+ 'Homepage top - second',
+ 'https://amp.example.com/data/images/1b3f5f5a-f67d-4e0f-9d5e-b607f99fb217/w=1320/d551946507aad54e40de5ba48fd8ed38.jpg',
+ 'https://amp.example.com/data/images/1b3f5f5a-f67d-4e0f-9d5e-b607f99fb217/w=320/d551946507aad54e40de5ba48fd8ed38.jpg 320w, https://amp.example.com/data/images/1b3f5f5a-f67d-4e0f-9d5e-b607f99fb217/w=520/d551946507aad54e40de5ba48fd8ed38.jpg 520w, https://amp.example.com/data/images/1b3f5f5a-f67d-4e0f-9d5e-b607f99fb217/w=720/d551946507aad54e40de5ba48fd8ed38.jpg 720w, https://amp.example.com/data/images/1b3f5f5a-f67d-4e0f-9d5e-b607f99fb217/w=920/d551946507aad54e40de5ba48fd8ed38.jpg 920w, https://amp.example.com/data/images/1b3f5f5a-f67d-4e0f-9d5e-b607f99fb217/w=1120/d551946507aad54e40de5ba48fd8ed38.jpg 1120w, https://amp.example.com/data/images/1b3f5f5a-f67d-4e0f-9d5e-b607f99fb217/w=1320/d551946507aad54e40de5ba48fd8ed38.jpg 1320w',
+ '(min-width: 1200px) calc(1200px - 2 * 16px), (min-width: 576px) calc(100vw - 2 * 16px), 100vw',
+ [
+ new Source(
+ 'image/webp',
+ 'https://amp.example.com/data/images/1b3f5f5a-f67d-4e0f-9d5e-b607f99fb217/w=320/d551946507aad54e40de5ba48fd8ed38.webp 320w, https://amp.example.com/data/images/1b3f5f5a-f67d-4e0f-9d5e-b607f99fb217/w=520/d551946507aad54e40de5ba48fd8ed38.webp 520w, https://amp.example.com/data/images/1b3f5f5a-f67d-4e0f-9d5e-b607f99fb217/w=720/d551946507aad54e40de5ba48fd8ed38.webp 720w, https://amp.example.com/data/images/1b3f5f5a-f67d-4e0f-9d5e-b607f99fb217/w=920/d551946507aad54e40de5ba48fd8ed38.webp 920w, https://amp.example.com/data/images/1b3f5f5a-f67d-4e0f-9d5e-b607f99fb217/w=1120/d551946507aad54e40de5ba48fd8ed38.webp 1120w, https://amp.example.com/data/images/1b3f5f5a-f67d-4e0f-9d5e-b607f99fb217/w=1320/d551946507aad54e40de5ba48fd8ed38.webp 1320w',
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ 'homepage.middle' => new Position(
+ '82fde136-1ba5-4556-b7f1-5d7e870f08d1',
+ 'homepage.middle',
+ 'Homepage - middle',
+ 5,
+ 'random',
+ 'min',
+ [],
+ ),
+ 'homepage.missing' => new Position(
+ null,
+ 'homepage.missing',
+ null,
+ 0,
+ null,
+ 'min',
+ [],
+ ),
+ 'homepage.bottom' => new Position(
+ 'ac13dfd6-547f-4f5e-801f-5799ce24ee09',
+ 'homepage.bottom',
+ 'Homepage - bottom',
+ 5,
+ 'single',
+ 'min',
+ [
+ new Banner(
+ '54c72f46-2b6c-4d80-a75e-28fed4d79f5c',
+ 'Homepage bottom',
+ 0,
+ 'a6c98208-b707-46c2-80c3-8f3753a522b8',
+ 'test-campaign',
+ 'Test campaign',
+ [
+ new ImageContent(
+ null,
+ 'https://www.example.com/bottom',
+ '_blank',
+ 'Homepage bottom',
+ 'Homepage bottom',
+ 'https://amp.example.com/data/images/54c72f46-2b6c-4d80-a75e-28fed4d79f5c/w=500/462316f2-30f2-4eb5-ad0d-dbc06ef0e06b.jpg',
+ 'https://amp.example.com/data/images/54c72f46-2b6c-4d80-a75e-28fed4d79f5c/w=320/462316f2-30f2-4eb5-ad0d-dbc06ef0e06b.jpg 320w, https://amp.example.com/data/images/54c72f46-2b6c-4d80-a75e-28fed4d79f5c/w=520/462316f2-30f2-4eb5-ad0d-dbc06ef0e06b.jpg 520w',
+ '100vw',
+ [],
+ ),
+ new HtmlContent(
+ 500,
+ 'Homepage bottom
',
+ ),
+ ],
+ ),
+ ],
+ ),
+]);