From 6aa0f64805902b60d2a0a8c25435dfaf3d4cab57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Glawaty?= Date: Mon, 20 Nov 2023 05:53:00 +0100 Subject: [PATCH] Init --- .gitattributes | 9 + .github/workflows/coding-style.yml | 50 +++++ .github/workflows/tests.yml | 40 ++++ .gitignore | 10 + .php-cs-fixer.dist.php | 107 +++++++++ Dockerfile | 51 +++++ Makefile | 63 ++++++ README.md | 9 + composer.json | 40 ++++ docker-compose.yml | 38 ++++ phpstan.neon | 14 ++ src/AmpClient.php | 206 +++++++++++++++++ src/AmpClientInterface.php | 26 +++ src/ClientConfig.php | 207 ++++++++++++++++++ src/Exception/AbstractHttpException.php | 39 ++++ src/Exception/AmpExceptionInterface.php | 11 + src/Exception/AmpHttpExceptionInterface.php | 15 ++ src/Exception/BadRequestException.php | 9 + src/Exception/NotFoundException.php | 9 + src/Exception/ResponseHydrationException.php | 35 +++ src/Exception/ServerErrorException.php | 9 + src/Exception/UnexpectedErrorException.php | 16 ++ src/Http/Cache/CacheControl.php | 34 +++ src/Http/Cache/CacheControlHeader.php | 67 ++++++ src/Http/Cache/CacheKey.php | 55 +++++ src/Http/Cache/CacheStorageInterface.php | 16 ++ src/Http/Cache/CachedResponse.php | 48 ++++ src/Http/Cache/Etag.php | 29 +++ src/Http/Cache/Expiration.php | 53 +++++ src/Http/Cache/InMemoryCacheStorage.php | 45 ++++ src/Http/Cache/NoCacheStorage.php | 25 +++ src/Http/HttpClient.php | 141 ++++++++++++ src/Http/HttpClientFactory.php | 58 +++++ src/Http/HttpClientFactoryInterface.php | 18 ++ src/Http/HttpClientInterface.php | 20 ++ src/Http/HttpRequest.php | 71 ++++++ src/Http/Middleware/MiddlewareInterface.php | 23 ++ .../ResponseExceptionMiddleware.php | 75 +++++++ .../Middleware/UnexpectedErrorMiddleware.php | 36 +++ .../Middleware/XAmpOriginHeaderMiddleware.php | 38 ++++ src/Http/Middlewares.php | 50 +++++ src/Request/BannersRequest.php | 72 ++++++ src/Request/ValueObject/BannerResource.php | 57 +++++ src/Request/ValueObject/Position.php | 76 +++++++ src/Response/BannersResponse.php | 34 +++ .../BannersResponseHydratorHandler.php | 162 ++++++++++++++ src/Response/Hydrator/ResponseHydrator.php | 37 ++++ .../ResponseHydratorHandlerInterface.php | 18 ++ .../Hydrator/ResponseHydratorInterface.php | 21 ++ src/Response/ValueObject/Banner.php | 87 ++++++++ src/Response/ValueObject/ContentInterface.php | 13 ++ src/Response/ValueObject/HtmlContent.php | 30 +++ src/Response/ValueObject/ImageContent.php | 100 +++++++++ src/Response/ValueObject/Position.php | 89 ++++++++ src/Response/ValueObject/Source.php | 30 +++ tests/.coveralls.yml | 3 + tests/.gitignore | 2 + tests/ClientConfigTest.php | 141 ++++++++++++ tests/Exception/AmpExceptionFixture.php | 12 + tests/Http/Cache/CacheControlHeaderTest.php | 69 ++++++ tests/Http/Cache/CacheControlTest.php | 30 +++ tests/Http/Cache/CacheKeyTest.php | 31 +++ tests/Http/Cache/EtagTest.php | 35 +++ tests/Http/Cache/ExpirationTest.php | 61 ++++++ .../ResponseExceptionMiddlewareTest.php | 96 ++++++++ .../UnexpectedErrorMiddlewareTest.php | 75 +++++++ .../XAmpOriginHeaderMiddlewareTest.php | 45 ++++ tests/Request/BannersRequestTest.php | 77 +++++++ .../ValueObject/BannerResourceTest.php | 47 ++++ tests/Request/ValueObject/PositionTest.php | 63 ++++++ .../BannerResponseHydratorHandlerTest.php | 38 ++++ .../ResponseHydratorHandlerFixture.php | 33 +++ .../Hydrator/ResponseHydratorTest.php | 54 +++++ tests/bootstrap.php | 16 ++ .../response-body/fetch-banners.full.json | 118 ++++++++++ .../response-body/fetch-banners.full.php | 128 +++++++++++ 76 files changed, 3915 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/coding-style.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 composer.json create mode 100644 docker-compose.yml create mode 100644 phpstan.neon create mode 100644 src/AmpClient.php create mode 100644 src/AmpClientInterface.php create mode 100644 src/ClientConfig.php create mode 100644 src/Exception/AbstractHttpException.php create mode 100644 src/Exception/AmpExceptionInterface.php create mode 100644 src/Exception/AmpHttpExceptionInterface.php create mode 100644 src/Exception/BadRequestException.php create mode 100644 src/Exception/NotFoundException.php create mode 100644 src/Exception/ResponseHydrationException.php create mode 100644 src/Exception/ServerErrorException.php create mode 100644 src/Exception/UnexpectedErrorException.php create mode 100644 src/Http/Cache/CacheControl.php create mode 100644 src/Http/Cache/CacheControlHeader.php create mode 100644 src/Http/Cache/CacheKey.php create mode 100644 src/Http/Cache/CacheStorageInterface.php create mode 100644 src/Http/Cache/CachedResponse.php create mode 100644 src/Http/Cache/Etag.php create mode 100644 src/Http/Cache/Expiration.php create mode 100644 src/Http/Cache/InMemoryCacheStorage.php create mode 100644 src/Http/Cache/NoCacheStorage.php create mode 100644 src/Http/HttpClient.php create mode 100644 src/Http/HttpClientFactory.php create mode 100644 src/Http/HttpClientFactoryInterface.php create mode 100644 src/Http/HttpClientInterface.php create mode 100644 src/Http/HttpRequest.php create mode 100644 src/Http/Middleware/MiddlewareInterface.php create mode 100644 src/Http/Middleware/ResponseExceptionMiddleware.php create mode 100644 src/Http/Middleware/UnexpectedErrorMiddleware.php create mode 100644 src/Http/Middleware/XAmpOriginHeaderMiddleware.php create mode 100644 src/Http/Middlewares.php create mode 100644 src/Request/BannersRequest.php create mode 100644 src/Request/ValueObject/BannerResource.php create mode 100644 src/Request/ValueObject/Position.php create mode 100644 src/Response/BannersResponse.php create mode 100644 src/Response/Hydrator/BannersResponseHydratorHandler.php create mode 100644 src/Response/Hydrator/ResponseHydrator.php create mode 100644 src/Response/Hydrator/ResponseHydratorHandlerInterface.php create mode 100644 src/Response/Hydrator/ResponseHydratorInterface.php create mode 100644 src/Response/ValueObject/Banner.php create mode 100644 src/Response/ValueObject/ContentInterface.php create mode 100644 src/Response/ValueObject/HtmlContent.php create mode 100644 src/Response/ValueObject/ImageContent.php create mode 100644 src/Response/ValueObject/Position.php create mode 100644 src/Response/ValueObject/Source.php create mode 100644 tests/.coveralls.yml create mode 100644 tests/.gitignore create mode 100644 tests/ClientConfigTest.php create mode 100644 tests/Exception/AmpExceptionFixture.php create mode 100644 tests/Http/Cache/CacheControlHeaderTest.php create mode 100644 tests/Http/Cache/CacheControlTest.php create mode 100644 tests/Http/Cache/CacheKeyTest.php create mode 100644 tests/Http/Cache/EtagTest.php create mode 100644 tests/Http/Cache/ExpirationTest.php create mode 100644 tests/Http/Middleware/ResponseExceptionMiddlewareTest.php create mode 100644 tests/Http/Middleware/UnexpectedErrorMiddlewareTest.php create mode 100644 tests/Http/Middleware/XAmpOriginHeaderMiddlewareTest.php create mode 100644 tests/Request/BannersRequestTest.php create mode 100644 tests/Request/ValueObject/BannerResourceTest.php create mode 100644 tests/Request/ValueObject/PositionTest.php create mode 100644 tests/Response/Hydrator/BannerResponseHydratorHandlerTest.php create mode 100644 tests/Response/Hydrator/ResponseHydratorHandlerFixture.php create mode 100644 tests/Response/Hydrator/ResponseHydratorTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/resources/response-body/fetch-banners.full.json create mode 100644 tests/resources/response-body/fetch-banners.full.php 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
', + ), + ], + ), + ], + ), +]);