diff --git a/.github/workflows/phpstan-7.yaml b/.github/workflows/phpstan-7.yaml deleted file mode 100644 index e7d8d6e..0000000 --- a/.github/workflows/phpstan-7.yaml +++ /dev/null @@ -1,23 +0,0 @@ -name: PHPStan level 7 -on: push -jobs: - phpstan: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/cache@v3 - with: - path: '**/vendor' - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-composer- - - uses: php-actions/composer@v6 - with: - args: --prefer-dist - php_version: '8.2' - - - name: PHPStan - uses: php-actions/phpstan@v3 - with: - path: src/ - level: 7 diff --git a/.github/workflows/phpstan-6.yaml b/.github/workflows/phpstan-9.yaml similarity index 90% rename from .github/workflows/phpstan-6.yaml rename to .github/workflows/phpstan-9.yaml index 8fe418d..68dbcae 100644 --- a/.github/workflows/phpstan-6.yaml +++ b/.github/workflows/phpstan-9.yaml @@ -1,7 +1,7 @@ -name: PHPStan level 6 +name: PHPStan level 9 on: push jobs: - phpstan: + phpstan-9: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -20,4 +20,4 @@ jobs: uses: php-actions/phpstan@v3 with: path: src/ - level: 6 + level: 9 diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml index 8e70ec6..7e391e1 100644 --- a/.github/workflows/quality.yaml +++ b/.github/workflows/quality.yaml @@ -1,4 +1,4 @@ -name: Quality (PHPStan lvl 4) +name: Quality (PHPStan lvl 7) on: push jobs: cs-fixer: @@ -12,25 +12,24 @@ jobs: run: | wget -q https://cs.symfony.com/download/php-cs-fixer-v3.phar -O php-cs-fixer chmod a+x php-cs-fixer - PHP_CS_FIXER_IGNORE_ENV=true ./php-cs-fixer fix src --dry-run + ./php-cs-fixer fix src --dry-run phpstan: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/cache@v3 - with: - path: '**/vendor' - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-composer- - - uses: php-actions/composer@v6 - with: - args: --prefer-dist - php_version: '8.2' - - - name: PHPStan - uses: php-actions/phpstan@v3 - with: - path: src/ - level: 4 + - uses: actions/checkout@v3 + - uses: actions/cache@v3 + with: + path: '**/vendor' + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + - uses: php-actions/composer@v6 + with: + args: --prefer-dist + php_version: '8.2' + - name: PHPStan + uses: php-actions/phpstan@v3 + with: + path: src/ + level: 7 diff --git a/composer.json b/composer.json index b3580f3..aceceed 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "psr/log": "^3.0", "nyholm/psr7": "^1.5", "php-etl/bucket": "*", - "php-etl/magento2-api-client": "^0.1.0", + "php-etl/magento2-api-client": "2.4.x-dev", "psr/simple-cache": "^3.0", "php-etl/mapping-contracts": "0.4.*" }, diff --git a/composer.lock b/composer.lock index d5e0fd3..114226d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "032582e526fc85372024e11317f4a7be", + "content-hash": "971b575cae686edbfb95693562fcdba5", "packages": [ { "name": "clue/stream-filter", @@ -614,27 +614,28 @@ }, { "name": "php-etl/magento2-api-client", - "version": "dev-main", + "version": "2.4.x-dev", "source": { "type": "git", "url": "https://github.com/php-etl/magento2-api-client.git", - "reference": "8997167f00c6ea2f939aac95b9f4810ef43fb4a3" + "reference": "8cd739e2b1da61abcf22ba150d53874854c8cc11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-etl/magento2-api-client/zipball/8997167f00c6ea2f939aac95b9f4810ef43fb4a3", - "reference": "8997167f00c6ea2f939aac95b9f4810ef43fb4a3", + "url": "https://api.github.com/repos/php-etl/magento2-api-client/zipball/8cd739e2b1da61abcf22ba150d53874854c8cc11", + "reference": "8cd739e2b1da61abcf22ba150d53874854c8cc11", "shasum": "" }, "require": { "jane-php/open-api-runtime": "^7.3", - "php": "^8.0" + "php": "^8.0", + "symfony/serializer": "^6.0" }, "require-dev": { - "jane-php/open-api-3": "^7.3", - "phpunit/phpunit": "^9.5" + "jane-php/open-api-2": "^7.5", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.15" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -643,10 +644,10 @@ }, "autoload": { "psr-4": { - "Kiboko\\Magento\\V2_1\\": "src/v2_1", - "Kiboko\\Magento\\V2_2\\": "src/v2_2", - "Kiboko\\Magento\\V2_3\\": "src/v2_3", - "Kiboko\\Magento\\V2_4\\": "src/v2_4" + "Kiboko\\Magento\\": [ + "src/", + "generated/" + ] } }, "notification-url": "https://packagist.org/downloads/", @@ -662,9 +663,9 @@ "description": "This package provides Jane-PHP generated API models and client based on the OpenAPI specification for Magento 2.3.x", "support": { "issues": "https://github.com/php-etl/magento2-api-client/issues", - "source": "https://github.com/php-etl/magento2-api-client/tree/main" + "source": "https://github.com/php-etl/magento2-api-client/tree/2.4" }, - "time": "2022-09-23T08:47:45+00:00" + "time": "2024-02-09T10:10:21+00:00" }, { "name": "php-etl/mapping-contracts", @@ -777,21 +778,22 @@ }, { "name": "php-etl/pipeline-contracts", - "version": "v0.4.0", + "version": "v0.4.2", "source": { "type": "git", "url": "https://github.com/php-etl/pipeline-contracts.git", - "reference": "499da7f0d7340cf90dd47f76426e952eb23bf6c2" + "reference": "5a37ebe518d4d85aa964c74b9da068d29e2e9b92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-etl/pipeline-contracts/zipball/499da7f0d7340cf90dd47f76426e952eb23bf6c2", - "reference": "499da7f0d7340cf90dd47f76426e952eb23bf6c2", + "url": "https://api.github.com/repos/php-etl/pipeline-contracts/zipball/5a37ebe518d4d85aa964c74b9da068d29e2e9b92", + "reference": "5a37ebe518d4d85aa964c74b9da068d29e2e9b92", "shasum": "" }, "require": { "php": "^8.2", - "php-etl/bucket-contracts": "0.2.*" + "php-etl/bucket-contracts": "0.2.*", + "php-etl/satellite-contracts": "0.1.*" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", @@ -826,9 +828,61 @@ "description": "This library describes contracts for the Extract-Transform-Load pattern.", "support": { "issues": "https://github.com/php-etl/pipeline-contracts/issues", - "source": "https://github.com/php-etl/pipeline-contracts/tree/v0.4.0" + "source": "https://github.com/php-etl/pipeline-contracts/tree/v0.4.2" }, - "time": "2023-04-17T13:08:18+00:00" + "time": "2023-06-12T09:38:03+00:00" + }, + { + "name": "php-etl/satellite-contracts", + "version": "v0.1.1", + "source": { + "type": "git", + "url": "https://github.com/php-etl/satellite-contracts.git", + "reference": "d2be591800f42e460a59d864888aedf00f111dd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-etl/satellite-contracts/zipball/d2be591800f42e460a59d864888aedf00f111dd7", + "reference": "d2be591800f42e460a59d864888aedf00f111dd7", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "rector/rector": "^0.15" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kiboko\\Contract\\Satellite\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kiboko SAS", + "homepage": "http://kiboko.fr" + }, + { + "name": "Grégory Planchat", + "email": "gregory@kiboko.fr" + } + ], + "description": "This library describes contracts for defining satellite formats", + "support": { + "issues": "https://github.com/php-etl/satellite-contracts/issues", + "source": "https://github.com/php-etl/satellite-contracts/tree/v0.1.1" + }, + "time": "2023-11-20T10:48:56+00:00" }, { "name": "php-http/client-common", @@ -4641,7 +4695,9 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": [], + "stability-flags": { + "php-etl/magento2-api-client": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..53b8e89 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,3 @@ +parameters: + level: 7 + treatPhpDocTypesAsCertain: false diff --git a/src/CategoryLookup.php b/src/CategoryLookup.php index e331a75..e76cc88 100644 --- a/src/CategoryLookup.php +++ b/src/CategoryLookup.php @@ -5,56 +5,161 @@ namespace Kiboko\Component\Flow\Magento2; use Kiboko\Component\Bucket\AcceptanceResultBucket; +use Kiboko\Component\Bucket\EmptyResultBucket; use Kiboko\Component\Bucket\RejectionResultBucket; +use Kiboko\Contract\Bucket\RejectionResultBucketInterface; use Kiboko\Contract\Mapping\CompiledMapperInterface; use Kiboko\Contract\Pipeline\TransformerInterface; -use Psr\SimpleCache\CacheInterface; +use Kiboko\Magento\Client; +use Kiboko\Magento\Exception\GetV1CategoriesCategoryIdBadRequestException; +use Kiboko\Magento\Exception\UnexpectedStatusCodeException; +use Kiboko\Magento\Model\CatalogDataCategoryInterface; +use Kiboko\Magento\Model\ErrorResponse; +use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Log\LoggerInterface; +/** + * @template InputType of array + * @template OutputType of InputType|array + * + * @implements TransformerInterface + */ final readonly class CategoryLookup implements TransformerInterface { + /** + * @param CompiledMapperInterface $mapper + */ public function __construct( - private \Psr\Log\LoggerInterface $logger, - private \Kiboko\Magento\V2_1\Client|\Kiboko\Magento\V2_2\Client|\Kiboko\Magento\V2_3\Client|\Kiboko\Magento\V2_4\Client $client, - private CacheInterface $cache, - private string $cacheKey, + private LoggerInterface $logger, + private Client $client, private CompiledMapperInterface $mapper, private string $mappingField, ) { } + /** + * @return RejectionResultBucketInterface + */ + private function rejectErrorResponse(ErrorResponse $response): RejectionResultBucketInterface + { + $this->logger->error( + $response->getMessage(), + [ + 'resource' => 'getV1CategoriesCategoryId', + 'method' => 'get', + ], + ); + + return new RejectionResultBucket($response->getMessage(), null); + } + + /** + * @return RejectionResultBucketInterface + */ + private function rejectInvalidResponse(): RejectionResultBucketInterface + { + $this->logger->error( + $message = 'The result provided by the API client does not match the expected type. The connector compilation may have fetched incompatible versions.', + [ + 'resource' => 'getV1CategoriesCategoryId', + 'method' => 'get', + ], + ); + + return new RejectionResultBucket($message, null); + } + + /** + * @param array $line + * + * @return OutputType + */ + public function passThrough(array $line): array + { + /* @var OutputType $line */ + return $line; + } + public function transform(): \Generator { - $line = yield; + $line = yield new EmptyResultBucket(); while (true) { + if (null === $line) { + $line = yield new EmptyResultBucket(); + continue; + } + if (null === $line[$this->mappingField]) { - $line = yield new AcceptanceResultBucket($line); + $line = yield new AcceptanceResultBucket($this->passThrough($line)); + continue; } try { - $lookup = $this->cache->get(sprintf($this->cacheKey, $line[$this->mappingField])); - - if (null === $lookup) { - $lookup = $this->client->catalogCategoryRepositoryV1GetGet( - categoryId: (int) $line[$this->mappingField], - ); - - if (!$lookup instanceof \Kiboko\Magento\V2_1\Model\CatalogDataCategoryInterface - && !$lookup instanceof \Kiboko\Magento\V2_2\Model\CatalogDataCategoryInterface - && !$lookup instanceof \Kiboko\Magento\V2_3\Model\CatalogDataCategoryInterface - && !$lookup instanceof \Kiboko\Magento\V2_4\Model\CatalogDataCategoryInterface - ) { - return; - } - - $this->cache->set( - sprintf($this->cacheKey, $line[$this->mappingField]), - $lookup, - ); + $lookup = $this->client->getV1CategoriesCategoryId( + categoryId: (int) $line[$this->mappingField], + ); + + if ($lookup instanceof ErrorResponse) { + $line = yield $this->rejectErrorResponse($lookup); + continue; + } + + if (!$lookup instanceof CatalogDataCategoryInterface) { + $line = yield $this->rejectInvalidResponse(); + continue; } - } catch (\RuntimeException $exception) { - $this->logger->warning($exception->getMessage(), ['exception' => $exception, 'item' => $line]); - $line = yield new RejectionResultBucket($line); + } catch (NetworkExceptionInterface $exception) { + $this->logger->critical( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1CategoriesCategoryId', + 'method' => 'get', + 'categoryId' => (int) $line[$this->mappingField], + 'mappingField' => $this->mappingField, + ], + ); + $line = yield new RejectionResultBucket( + 'There are some network difficulties. We could not properly connect to the Magento API. There is nothing we could no to fix this currently. Please contact the Magento administrator.', + $exception, + $this->passThrough($line), + ); continue; + } catch (GetV1CategoriesCategoryIdBadRequestException $exception) { + $this->logger->error( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1CategoriesCategoryId', + 'method' => 'get', + 'categoryId' => (int) $line[$this->mappingField], + 'mappingField' => $this->mappingField, + ], + ); + $line = yield new RejectionResultBucket( + 'The source API rejected our request. Ignoring line. Maybe you are requesting on incompatible versions.', + $exception, + $this->passThrough($line), + ); + continue; + } catch (UnexpectedStatusCodeException $exception) { + $this->logger->critical( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1CategoriesCategoryId', + 'method' => 'get', + 'categoryId' => (int) $line[$this->mappingField], + 'mappingField' => $this->mappingField, + ], + ); + $line = yield new RejectionResultBucket( + 'The source API responded with a status we did not expect. Aborting. Please check the availability of the source API and if there are no rate limiting or redirections active.', + $exception, + $this->passThrough($line), + ); + + return; } $output = ($this->mapper)($lookup, $line); diff --git a/src/CustomerExtractor.php b/src/CustomerExtractor.php index c7e4324..7c82dc1 100644 --- a/src/CustomerExtractor.php +++ b/src/CustomerExtractor.php @@ -6,77 +6,177 @@ use Kiboko\Component\Bucket\AcceptanceResultBucket; use Kiboko\Component\Bucket\RejectionResultBucket; -use Kiboko\Contract\Bucket\ResultBucketInterface; +use Kiboko\Contract\Bucket\RejectionResultBucketInterface; use Kiboko\Contract\Pipeline\ExtractorInterface; +use Kiboko\Magento\Client; +use Kiboko\Magento\Exception\GetV1CustomersSearchInternalServerErrorException; +use Kiboko\Magento\Exception\GetV1CustomersSearchUnauthorizedException; +use Kiboko\Magento\Exception\UnexpectedStatusCodeException; +use Kiboko\Magento\Model\CustomerDataCustomerInterface; +use Kiboko\Magento\Model\CustomerDataCustomerSearchResultsInterface; +use Kiboko\Magento\Model\ErrorResponse; +use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Log\LoggerInterface; -final class CustomerExtractor implements ExtractorInterface +/** + * @implements ExtractorInterface + */ +final readonly class CustomerExtractor implements ExtractorInterface { - private array $queryParameters = [ - 'searchCriteria[currentPage]' => 1, - 'searchCriteria[pageSize]' => 100, - ]; - public function __construct( - private readonly \Psr\Log\LoggerInterface $logger, - private readonly \Kiboko\Magento\V2_1\Client|\Kiboko\Magento\V2_2\Client|\Kiboko\Magento\V2_3\Client|\Kiboko\Magento\V2_4\Client $client, - private readonly int $pageSize = 100, - /** @var FilterGroup[] $filters */ - private readonly array $filters = [], + private LoggerInterface $logger, + private Client $client, + private QueryParameters $queryParameters, + private int $pageSize = 100, ) { } - private function compileQueryParameters(int $currentPage = 1): array + /** + * @param array $parameters + * + * @return array + */ + private function applyPagination(array $parameters, int $currentPage, int $pageSize): array + { + return [ + ...$parameters, + 'searchCriteria[currentPage]' => (string) $currentPage, + 'searchCriteria[pageSize]' => (string) $pageSize, + ]; + } + + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectErrorResponse(ErrorResponse $response, array $parameters, int $currentPage): RejectionResultBucketInterface { - $parameters = $this->queryParameters; - $parameters['searchCriteria[currentPage]'] = $currentPage; - $parameters['searchCriteria[pageSize]'] = $this->pageSize; + $this->logger->error( + $response->getMessage(), + [ + 'resource' => 'getV1CustomersSearch', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); - $filters = array_map(fn (FilterGroup $item, int $key) => $item->compileFilters($key), $this->filters, array_keys($this->filters)); + return new RejectionResultBucket($response->getMessage(), null); + } + + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectInvalidResponse(array $parameters, int $currentPage): RejectionResultBucketInterface + { + $this->logger->error( + $message = 'The result provided by the API client does not match the expected type. The connector compilation may have fetched incompatible versions.', + [ + 'resource' => 'getV1CustomersSearch', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); - return array_merge($parameters, ...$filters); + return new RejectionResultBucket($message, null); } public function extract(): iterable { - try { - $response = $this->client->customerCustomerRepositoryV1GetListGet( - queryParameters: $this->compileQueryParameters(), - ); - - if (!$response instanceof \Kiboko\Magento\V2_1\Model\CustomerDataCustomerSearchResultsInterface - && !$response instanceof \Kiboko\Magento\V2_2\Model\CustomerDataCustomerSearchResultsInterface - && !$response instanceof \Kiboko\Magento\V2_3\Model\CustomerDataCustomerSearchResultsInterface - && !$response instanceof \Kiboko\Magento\V2_4\Model\CustomerDataCustomerSearchResultsInterface - ) { + foreach ($this->queryParameters->walkVariants() as $parameters) { + try { + $currentPage = 1; + $response = $this->client->getV1CustomersSearch( + queryParameters: $this->applyPagination($parameters, $currentPage, $this->pageSize), + ); + if ($response instanceof ErrorResponse) { + yield $this->rejectErrorResponse($response, $parameters, $currentPage); + + return; + } + if (!$response instanceof CustomerDataCustomerSearchResultsInterface) { + yield $this->rejectInvalidResponse($parameters, $currentPage); + + return; + } + $pageCount = (int) ceil($response->getTotalCount() / $this->pageSize); + + yield new AcceptanceResultBucket(...$response->getItems()); + + while ($currentPage++ < $pageCount) { + $response = $this->client->getV1CustomersSearch( + queryParameters: $this->applyPagination($parameters, $currentPage, $this->pageSize), + ); + if ($response instanceof ErrorResponse) { + yield $this->rejectErrorResponse($response, $parameters, $currentPage); + + return; + } + if (!$response instanceof CustomerDataCustomerSearchResultsInterface) { + yield $this->rejectInvalidResponse($parameters, $currentPage); + + return; + } + + yield new AcceptanceResultBucket(...$response->getItems()); + } + } catch (NetworkExceptionInterface $exception) { + $this->logger->critical( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1CustomersSearch', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); + yield new RejectionResultBucket( + 'There are some network difficulties. We could not properly connect to the Magento API. There is nothing we could no to fix this currently. Please contact the Magento administrator.', + $exception, + ); + return; - } + } catch (GetV1CustomersSearchUnauthorizedException $exception) { + $this->logger->warning($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The source API responded we are not authorized to access this resource. Aborting. Please check the credentials you provided.', + $exception, + ); - yield $this->processResponse($response); + return; + } catch (GetV1CustomersSearchInternalServerErrorException $exception) { + $this->logger->error($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The source API responded it is currently unavailable due to an internal error. Aborting. Please check the availability of the source API.', + $exception, + ); - $currentPage = 1; - $pageCount = ceil($response->getTotalCount() / $this->pageSize); - while ($currentPage++ < $pageCount) { - $response = $this->client->customerCustomerRepositoryV1GetListGet( - queryParameters: $this->compileQueryParameters($currentPage), + return; + } catch (UnexpectedStatusCodeException $exception) { + $this->logger->critical($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The source API responded with a status we did not expect. Aborting. Please check the availability of the source API and if there are no rate limiting or redirections active.', + $exception, ); - yield $this->processResponse($response); - } - } catch (\Exception $exception) { - $this->logger->alert($exception->getMessage(), ['exception' => $exception]); - } - } + return; + } catch (\Throwable $exception) { + $this->logger->emergency($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The client failed critically. Aborting. Please contact customer support or your system administrator.', + $exception, + ); - private function processResponse($response): ResultBucketInterface - { - if ($response instanceof \Kiboko\Magento\V2_1\Model\ErrorResponse - || $response instanceof \Kiboko\Magento\V2_2\Model\ErrorResponse - || $response instanceof \Kiboko\Magento\V2_3\Model\ErrorResponse - || $response instanceof \Kiboko\Magento\V2_4\Model\ErrorResponse - ) { - return new RejectionResultBucket($response); + return; + } } - - return new AcceptanceResultBucket(...$response->getItems()); } } diff --git a/src/Filter.php b/src/Filter.php deleted file mode 100644 index 6312fac..0000000 --- a/src/Filter.php +++ /dev/null @@ -1,15 +0,0 @@ - + */ +final class ArrayFilter implements FilterInterface, \IteratorAggregate +{ + /** + * @param list $values + */ + public function __construct( + public string $field, + public string $conditionType, + public array $values, + private readonly int $threshold = 200 + ) { + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + $length = \count($this->values); + for ($offset = 0; $offset < $length; $offset += $this->threshold) { + yield [ + 'field' => $this->field, + 'value' => implode(',', array_map( + fn (bool|\DateTimeInterface|float|int|string $value) => $value instanceof \DateTimeInterface ? $value->format(\DateTimeInterface::ATOM) : (string) $value, + \array_slice($this->values, $offset, $this->threshold, false) + )), + 'conditionType' => $this->conditionType, + ]; + } + } +} diff --git a/src/Filter/FilterInterface.php b/src/Filter/FilterInterface.php new file mode 100644 index 0000000..e753cfb --- /dev/null +++ b/src/Filter/FilterInterface.php @@ -0,0 +1,12 @@ + + */ +interface FilterInterface extends \Traversable +{ +} diff --git a/src/Filter/ScalarFilter.php b/src/Filter/ScalarFilter.php new file mode 100644 index 0000000..7e8bc1d --- /dev/null +++ b/src/Filter/ScalarFilter.php @@ -0,0 +1,30 @@ + + */ +final class ScalarFilter implements FilterInterface, \IteratorAggregate +{ + public function __construct( + public string $field, + public string $conditionType, + public bool|\DateTimeInterface|float|int|string $value, + ) { + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + yield [ + 'field' => $this->field, + 'value' => $this->value instanceof \DateTimeInterface ? $this->value->format(\DateTimeInterface::ATOM) : (string) $this->value, + 'conditionType' => $this->conditionType, + ]; + } +} diff --git a/src/FilterGroup.php b/src/FilterGroup.php index 1634f83..71a3aa1 100644 --- a/src/FilterGroup.php +++ b/src/FilterGroup.php @@ -4,48 +4,84 @@ namespace Kiboko\Component\Flow\Magento2; +use Kiboko\Component\Flow\Magento2\Filter\FilterInterface; +use Kiboko\Component\Flow\Magento2\Filter\ScalarFilter; + class FilterGroup { + /** @var array */ private array $filters = []; - public function withFilter(Filter $filter): self + public function withFilter(FilterInterface $filter): self { - $this->filters[] = [ - 'field' => $filter->field, - 'value' => $filter->value, - 'condition_type' => $filter->conditionType, - ]; + $this->filters[] = $filter; return $this; } - public function withFilters(Filter ...$filters): self + public function withFilters(FilterInterface ...$filters): self { - array_walk($filters, fn (Filter $filter) => $this->filters[] = [ - 'field' => $filter->field, - 'value' => $filter->value, - 'condition_type' => $filter->conditionType, - ]); + array_push($this->filters, ...$filters); return $this; } - public function compileFilters(int $groupIndex = 0): array + /** + * @param array $parameters + * + * @return \Traversable> + */ + public function walkFilters(array $parameters, int $groupIndex = 0): \Traversable + { + if (\count($this->filters) < 1) { + return; + } + + yield from $this->buildFilters($parameters, $groupIndex, 1, ...$this->filters); + } + + /** + * @param array $parameters + * + * @return \Traversable> + */ + private function buildFilters(array $parameters, int $groupIndex, int $filterIndex, FilterInterface $first, FilterInterface ...$next): \Traversable + { + foreach ($first as $current) { + $childParameters = [ + ...$parameters, + ...[ + sprintf('searchCriteria[filterGroups][%s][filters][%s][field]', $groupIndex, $filterIndex) => $current['field'], + sprintf('searchCriteria[filterGroups][%s][filters][%s][value]', $groupIndex, $filterIndex) => $current['value'], + sprintf('searchCriteria[filterGroups][%s][filters][%s][conditionType]', $groupIndex, $filterIndex) => $current['conditionType'], + ], + ]; + + if (\count($next) >= 1) { + yield from $this->buildFilters($childParameters, $groupIndex, $filterIndex + 1, ...$next); + } else { + yield $childParameters; + } + } + } + + public function greaterThan(string $field, \DateTimeInterface|float|int|string $value): self + { + return $this->withFilter(new ScalarFilter($field, 'gt', $value)); + } + + public function lowerThan(string $field, \DateTimeInterface|float|int|string $value): self { - return array_merge(...array_map(fn (array $item, int $key) => [ - sprintf('searchCriteria[filterGroups][%s][filters][%s][field]', $groupIndex, $key) => $item['field'], - sprintf('searchCriteria[filterGroups][%s][filters][%s][value]', $groupIndex, $key) => $item['value'], - sprintf('searchCriteria[filterGroups][%s][filters][%s][conditionType]', $groupIndex, $key) => $item['condition_type'], - ], $this->filters, array_keys($this->filters))); + return $this->withFilter(new ScalarFilter($field, 'lt', $value)); } - public function greaterThan(string $field, mixed $value): self + public function greaterThanOrEqual(string $field, \DateTimeInterface|float|int|string $value): self { - return $this->withFilter(new Filter($field, 'gt', $value)); + return $this->withFilter(new ScalarFilter($field, 'gteq', $value)); } - public function greaterThanEqual(string $field, mixed $value): self + public function lowerThanOrEqual(string $field, \DateTimeInterface|float|int|string $value): self { - return $this->withFilter(new Filter($field, 'gteq', $value)); + return $this->withFilter(new ScalarFilter($field, 'lteq', $value)); } } diff --git a/src/InvoiceExtractor.php b/src/InvoiceExtractor.php index bbbe364..bcda827 100644 --- a/src/InvoiceExtractor.php +++ b/src/InvoiceExtractor.php @@ -6,77 +6,168 @@ use Kiboko\Component\Bucket\AcceptanceResultBucket; use Kiboko\Component\Bucket\RejectionResultBucket; -use Kiboko\Contract\Bucket\ResultBucketInterface; +use Kiboko\Contract\Bucket\RejectionResultBucketInterface; use Kiboko\Contract\Pipeline\ExtractorInterface; +use Kiboko\Magento\Client; +use Kiboko\Magento\Exception\GetV1InvoicesUnauthorizedException; +use Kiboko\Magento\Exception\UnexpectedStatusCodeException; +use Kiboko\Magento\Model\ErrorResponse; +use Kiboko\Magento\Model\SalesDataInvoiceInterface; +use Kiboko\Magento\Model\SalesDataInvoiceSearchResultInterface; +use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Log\LoggerInterface; -final class InvoiceExtractor implements ExtractorInterface +/** + * @implements ExtractorInterface + */ +final readonly class InvoiceExtractor implements ExtractorInterface { - private array $queryParameters = [ - 'searchCriteria[currentPage]' => 1, - 'searchCriteria[pageSize]' => 100, - ]; - public function __construct( - private readonly \Psr\Log\LoggerInterface $logger, - private readonly \Kiboko\Magento\V2_1\Client|\Kiboko\Magento\V2_2\Client|\Kiboko\Magento\V2_3\Client|\Kiboko\Magento\V2_4\Client $client, - private readonly int $pageSize = 100, - /** @var FilterGroup[] $filters */ - private readonly array $filters = [], + private LoggerInterface $logger, + private Client $client, + private QueryParameters $queryParameters, + private int $pageSize = 100, ) { } - private function compileQueryParameters(int $currentPage = 1): array + /** + * @param array $parameters + * + * @return array + */ + private function applyPagination(array $parameters, int $currentPage, int $pageSize): array + { + return [ + ...$parameters, + 'searchCriteria[currentPage]' => (string) $currentPage, + 'searchCriteria[pageSize]' => (string) $pageSize, + ]; + } + + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectErrorResponse(ErrorResponse $response, array $parameters, int $currentPage): RejectionResultBucketInterface { - $parameters = $this->queryParameters; - $parameters['searchCriteria[currentPage]'] = $currentPage; - $parameters['searchCriteria[pageSize]'] = $this->pageSize; + $this->logger->error( + $response->getMessage(), + [ + 'resource' => 'getV1Invoices', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); - $filters = array_map(fn (FilterGroup $item, int $key) => $item->compileFilters($key), $this->filters, array_keys($this->filters)); + return new RejectionResultBucket($response->getMessage(), null); + } + + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectInvalidResponse(array $parameters, int $currentPage): RejectionResultBucketInterface + { + $this->logger->error( + $message = 'The result provided by the API client does not match the expected type. The connector compilation may have fetched incompatible versions.', + [ + 'resource' => 'getV1Invoices', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); - return array_merge($parameters, ...$filters); + return new RejectionResultBucket($message, null); } public function extract(): iterable { - try { - $response = $this->client->salesInvoiceRepositoryV1GetListGet( - queryParameters: $this->compileQueryParameters(), - ); - - if (!$response instanceof \Kiboko\Magento\V2_1\Model\SalesDataInvoiceSearchResultInterface - && !$response instanceof \Kiboko\Magento\V2_2\Model\SalesDataInvoiceSearchResultInterface - && !$response instanceof \Kiboko\Magento\V2_3\Model\SalesDataInvoiceSearchResultInterface - && !$response instanceof \Kiboko\Magento\V2_4\Model\SalesDataInvoiceSearchResultInterface - ) { + foreach ($this->queryParameters->walkVariants() as $parameters) { + try { + $currentPage = 1; + $response = $this->client->getV1Invoices( + queryParameters: $this->applyPagination($parameters, $currentPage, $this->pageSize), + ); + if ($response instanceof ErrorResponse) { + yield $this->rejectErrorResponse($response, $parameters, $currentPage); + + return; + } + if (!$response instanceof SalesDataInvoiceSearchResultInterface) { + yield $this->rejectInvalidResponse($parameters, $currentPage); + + return; + } + $pageCount = (int) ceil($response->getTotalCount() / $this->pageSize); + + yield new AcceptanceResultBucket(...$response->getItems()); + + while ($currentPage++ < $pageCount) { + $response = $this->client->getV1Invoices( + queryParameters: $this->applyPagination($parameters, $currentPage, $this->pageSize), + ); + if ($response instanceof ErrorResponse) { + yield $this->rejectErrorResponse($response, $parameters, $currentPage); + + return; + } + if (!$response instanceof SalesDataInvoiceSearchResultInterface) { + yield $this->rejectInvalidResponse($parameters, $currentPage); + + return; + } + + yield new AcceptanceResultBucket(...$response->getItems()); + } + } catch (NetworkExceptionInterface $exception) { + $this->logger->critical( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1Invoices', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); + yield new RejectionResultBucket( + 'There are some network difficulties. We could not properly connect to the Magento API. There is nothing we could no to fix this currently. Please contact the Magento administrator.', + $exception, + ); + return; - } + } catch (GetV1InvoicesUnauthorizedException $exception) { + $this->logger->warning($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The source API responded we are not authorized to access this resource. Aborting. Please check the credentials you provided.', + $exception, + ); - yield $this->processResponse($response); + return; + } catch (UnexpectedStatusCodeException $exception) { + $this->logger->critical($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The source API responded with a status we did not expect. Aborting. Please check the availability of the source API and if there are no rate limiting or redirections active.', + $exception, + ); - $currentPage = 1; - $pageCount = ceil($response->getTotalCount() / $this->pageSize); - while ($currentPage++ < $pageCount) { - $response = $this->client->salesInvoiceRepositoryV1GetListGet( - queryParameters: $this->compileQueryParameters($currentPage), + return; + } catch (\Throwable $exception) { + $this->logger->emergency($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The client failed critically. Aborting. Please contact customer support or your system administrator.', + $exception, ); - yield $this->processResponse($response); + return; } - } catch (\Exception $exception) { - $this->logger->alert($exception->getMessage(), ['exception' => $exception]); } } - - private function processResponse($response): ResultBucketInterface - { - if ($response instanceof \Kiboko\Magento\V2_1\Model\ErrorResponse - || $response instanceof \Kiboko\Magento\V2_2\Model\ErrorResponse - || $response instanceof \Kiboko\Magento\V2_3\Model\ErrorResponse - || $response instanceof \Kiboko\Magento\V2_4\Model\ErrorResponse - ) { - return new RejectionResultBucket($response); - } - - return new AcceptanceResultBucket(...$response->getItems()); - } } diff --git a/src/Lookup.php b/src/Lookup.php deleted file mode 100644 index 8b1347e..0000000 --- a/src/Lookup.php +++ /dev/null @@ -1,68 +0,0 @@ -mappingField]) { - $line = yield new AcceptanceResultBucket($line); - } - - try { - $lookup = $this->cache->get(sprintf($this->cacheKey, $line[$this->mappingField])); - - if (null === $lookup) { - $results = $this->client->catalogProductAttributeOptionManagementV1GetItemsGet( - attributeCode: $this->attributeCode, - ); - - $lookup = array_values(array_filter($results, fn (object $item) => $item->getValue() === $line[$this->mappingField]))[0]; - - if (!$lookup instanceof \Kiboko\Magento\V2_1\Model\EavDataAttributeOptionInterface - && !$lookup instanceof \Kiboko\Magento\V2_2\Model\EavDataAttributeOptionInterface - && !$lookup instanceof \Kiboko\Magento\V2_3\Model\EavDataAttributeOptionInterface - && !$lookup instanceof \Kiboko\Magento\V2_4\Model\EavDataAttributeOptionInterface - ) { - return; - } - - $this->cache->set( - sprintf($this->cacheKey, $line[$this->mappingField]), - $lookup, - ); - } - } catch (\RuntimeException $exception) { - $this->logger->warning($exception->getMessage(), ['exception' => $exception, 'item' => $line]); - $line = yield new RejectionResultBucket($line); - continue; - } - - $output = ($this->mapper)($lookup, $line); - - $line = yield new AcceptanceResultBucket($output); - } - } -} diff --git a/src/OrderExtractor.php b/src/OrderExtractor.php index e5cc5d6..80d95c0 100644 --- a/src/OrderExtractor.php +++ b/src/OrderExtractor.php @@ -6,77 +6,200 @@ use Kiboko\Component\Bucket\AcceptanceResultBucket; use Kiboko\Component\Bucket\RejectionResultBucket; -use Kiboko\Contract\Bucket\ResultBucketInterface; +use Kiboko\Contract\Bucket\RejectionResultBucketInterface; use Kiboko\Contract\Pipeline\ExtractorInterface; +use Kiboko\Magento\Client; +use Kiboko\Magento\Exception\GetV1OrdersUnauthorizedException; +use Kiboko\Magento\Exception\UnexpectedStatusCodeException; +use Kiboko\Magento\Model\ErrorResponse; +use Kiboko\Magento\Model\SalesDataOrderInterface; +use Kiboko\Magento\Model\SalesDataOrderSearchResultInterface; +use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException; -final class OrderExtractor implements ExtractorInterface +/** + * @implements ExtractorInterface + */ +final readonly class OrderExtractor implements ExtractorInterface { - private array $queryParameters = [ - 'searchCriteria[currentPage]' => 1, - 'searchCriteria[pageSize]' => 100, - ]; - public function __construct( - private readonly \Psr\Log\LoggerInterface $logger, - private readonly \Kiboko\Magento\V2_1\Client|\Kiboko\Magento\V2_2\Client|\Kiboko\Magento\V2_3\Client|\Kiboko\Magento\V2_4\Client $client, - private readonly int $pageSize = 100, - /** @var FilterGroup[] $filters */ - private readonly array $filters = [], + private LoggerInterface $logger, + private Client $client, + private QueryParameters $queryParameters, + private int $pageSize = 100, ) { } - private function compileQueryParameters(int $currentPage = 1): array + /** + * @param array $parameters + * + * @return array + */ + private function applyPagination(array $parameters, int $currentPage, int $pageSize): array { - $parameters = $this->queryParameters; - $parameters['searchCriteria[currentPage]'] = $currentPage; - $parameters['searchCriteria[pageSize]'] = $this->pageSize; + return [ + ...$parameters, + 'searchCriteria[currentPage]' => $currentPage, + 'searchCriteria[pageSize]' => $pageSize, + ]; + } + + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectErrorResponse(ErrorResponse $response, array $parameters, int $currentPage): RejectionResultBucketInterface + { + $this->logger->error( + $response->getMessage(), + [ + 'resource' => 'getV1Orders', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); + + return new RejectionResultBucket($response->getMessage(), null); + } + + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectInvalidResponse(array $parameters, int $currentPage): RejectionResultBucketInterface + { + $this->logger->error( + $message = 'The result provided by the API client does not match the expected type. The connector compilation may have fetched incompatible versions.', + [ + 'resource' => 'getV1Orders', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); + + return new RejectionResultBucket($message, null); + } - $filters = array_map(fn (FilterGroup $item, int $key) => $item->compileFilters($key), $this->filters, array_keys($this->filters)); + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectUndefinedOptionsResponse(UndefinedOptionsException $response, array $parameters, int $currentPage): RejectionResultBucketInterface + { + $this->logger->error( + $message = 'The result provided by the API client does not match the expected query parameters. The connector compilation may have fetched incompatible versions.', + [ + 'resource' => 'getV1Orders', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); - return array_merge($parameters, ...$filters); + return new RejectionResultBucket($message, null); } public function extract(): iterable { - try { - $response = $this->client->salesOrderRepositoryV1GetListGet( - queryParameters: $this->compileQueryParameters(), - ); - - if (!$response instanceof \Kiboko\Magento\V2_1\Model\SalesDataOrderSearchResultInterface - && !$response instanceof \Kiboko\Magento\V2_2\Model\SalesDataOrderSearchResultInterface - && !$response instanceof \Kiboko\Magento\V2_3\Model\SalesDataOrderSearchResultInterface - && !$response instanceof \Kiboko\Magento\V2_4\Model\SalesDataOrderSearchResultInterface - ) { + foreach ($this->queryParameters->walkVariants() as $parameters) { + try { + $currentPage = 1; + $response = $this->client->getV1Orders( + queryParameters: $this->applyPagination($parameters, $currentPage, $this->pageSize), + ); + if ($response instanceof ErrorResponse) { + yield $this->rejectErrorResponse($response, $parameters, $currentPage); + + return; + } + if ($response instanceof UndefinedOptionsException) { + yield $this->rejectUndefinedOptionsResponse($response, $parameters, $currentPage); + + return; + } + if (!$response instanceof SalesDataOrderSearchResultInterface) { + yield $this->rejectInvalidResponse($parameters, $currentPage); + + return; + } + $pageCount = (int) ceil($response->getTotalCount() / $this->pageSize); + + yield new AcceptanceResultBucket(...$response->getItems()); + + while ($currentPage++ < $pageCount) { + $response = $this->client->getV1Orders( + queryParameters: $this->applyPagination($parameters, $currentPage, $this->pageSize), + ); + if ($response instanceof ErrorResponse) { + yield $this->rejectErrorResponse($response, $parameters, $currentPage); + + return; + } + if ($response instanceof UndefinedOptionsException) { + yield $this->rejectUndefinedOptionsResponse($response, $parameters, $currentPage); + + return; + } + if (!$response instanceof SalesDataOrderSearchResultInterface) { + yield $this->rejectInvalidResponse($parameters, $currentPage); + + return; + } + + yield new AcceptanceResultBucket(...$response->getItems()); + } + } catch (NetworkExceptionInterface $exception) { + $this->logger->critical( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1Orders', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); + yield new RejectionResultBucket( + 'There are some network difficulties. We could not properly connect to the Magento API. There is nothing we could no to fix this currently. Please contact the Magento administrator.', + $exception, + ); + return; - } + } catch (GetV1OrdersUnauthorizedException $exception) { + $this->logger->warning($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The source API responded we are not authorized to access this resource. Aborting. Please check the credentials you provided.', + $exception, + ); - yield $this->processResponse($response); + return; + } catch (UnexpectedStatusCodeException $exception) { + $this->logger->critical($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The source API responded with a status we did not expect. Aborting. Please check the availability of the source API and if there are no rate limiting or redirections active.', + $exception, + ); - $currentPage = 1; - $pageCount = ceil($response->getTotalCount() / $this->pageSize); - while ($currentPage++ < $pageCount) { - $response = $this->client->salesOrderRepositoryV1GetListGet( - queryParameters: $this->compileQueryParameters($currentPage), + return; + } catch (\Throwable $exception) { + $this->logger->emergency($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The client failed critically. Aborting. Please contact customer support or your system administrator.', + $exception, ); - yield $this->processResponse($response); + return; } - } catch (\Exception $exception) { - $this->logger->alert($exception->getMessage(), ['exception' => $exception]); - } - } - - private function processResponse($response): ResultBucketInterface - { - if ($response instanceof \Kiboko\Magento\V2_1\Model\ErrorResponse - || $response instanceof \Kiboko\Magento\V2_2\Model\ErrorResponse - || $response instanceof \Kiboko\Magento\V2_3\Model\ErrorResponse - || $response instanceof \Kiboko\Magento\V2_4\Model\ErrorResponse - ) { - return new RejectionResultBucket($response); } - - return new AcceptanceResultBucket(...$response->getItems()); } } diff --git a/src/ProductExtractor.php b/src/ProductExtractor.php index e0b0665..f6e7054 100644 --- a/src/ProductExtractor.php +++ b/src/ProductExtractor.php @@ -6,77 +6,159 @@ use Kiboko\Component\Bucket\AcceptanceResultBucket; use Kiboko\Component\Bucket\RejectionResultBucket; -use Kiboko\Contract\Bucket\ResultBucketInterface; +use Kiboko\Contract\Bucket\RejectionResultBucketInterface; use Kiboko\Contract\Pipeline\ExtractorInterface; +use Kiboko\Magento\Client; +use Kiboko\Magento\Exception\UnexpectedStatusCodeException; +use Kiboko\Magento\Model\CatalogDataProductInterface; +use Kiboko\Magento\Model\CatalogDataProductSearchResultsInterface; +use Kiboko\Magento\Model\ErrorResponse; +use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Log\LoggerInterface; -final class ProductExtractor implements ExtractorInterface +/** + * @implements ExtractorInterface + */ +final readonly class ProductExtractor implements ExtractorInterface { - private array $queryParameters = [ - 'searchCriteria[currentPage]' => 1, - 'searchCriteria[pageSize]' => 100, - ]; - public function __construct( - private readonly \Psr\Log\LoggerInterface $logger, - private readonly \Kiboko\Magento\V2_1\Client|\Kiboko\Magento\V2_2\Client|\Kiboko\Magento\V2_3\Client|\Kiboko\Magento\V2_4\Client $client, - private readonly int $pageSize = 100, - /** @var FilterGroup[] $filters */ - private readonly array $filters = [], + private LoggerInterface $logger, + private Client $client, + private QueryParameters $queryParameters, + private int $pageSize = 100, ) { } - private function compileQueryParameters(int $currentPage = 1): array + /** + * @param array $parameters + * + * @return array + */ + private function applyPagination(array $parameters, int $currentPage, int $pageSize): array + { + return [ + ...$parameters, + 'searchCriteria[currentPage]' => (string) $currentPage, + 'searchCriteria[pageSize]' => (string) $pageSize, + ]; + } + + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectErrorResponse(ErrorResponse $response, array $parameters, int $currentPage): RejectionResultBucketInterface { - $parameters = $this->queryParameters; - $parameters['searchCriteria[currentPage]'] = $currentPage; - $parameters['searchCriteria[pageSize]'] = $this->pageSize; + $this->logger->error( + $response->getMessage(), + [ + 'resource' => 'getV1Products', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); - $filters = array_map(fn (FilterGroup $item, int $key) => $item->compileFilters($key), $this->filters, array_keys($this->filters)); + return new RejectionResultBucket($response->getMessage(), null); + } + + /** + * @param array $parameters + * + * @return RejectionResultBucketInterface + */ + private function rejectInvalidResponse(array $parameters, int $currentPage): RejectionResultBucketInterface + { + $this->logger->error( + $message = 'The result provided by the API client does not match the expected type. The connector compilation may have fetched incompatible versions.', + [ + 'resource' => 'getV1Products', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); - return array_merge($parameters, ...$filters); + return new RejectionResultBucket($message, null); } public function extract(): iterable { - try { - $response = $this->client->catalogProductRepositoryV1GetListGet( - queryParameters: $this->compileQueryParameters(), - ); - - if (!$response instanceof \Kiboko\Magento\V2_1\Model\CatalogDataProductSearchResultsInterface - && !$response instanceof \Kiboko\Magento\V2_2\Model\CatalogDataProductSearchResultsInterface - && !$response instanceof \Kiboko\Magento\V2_3\Model\CatalogDataProductSearchResultsInterface - && !$response instanceof \Kiboko\Magento\V2_4\Model\CatalogDataProductSearchResultsInterface - ) { - return; - } + foreach ($this->queryParameters->walkVariants() as $parameters) { + try { + $currentPage = 1; + $response = $this->client->getV1Products( + queryParameters: $this->applyPagination($parameters, $currentPage, $this->pageSize), + ); + if ($response instanceof ErrorResponse) { + yield $this->rejectErrorResponse($response, $parameters, $currentPage); + + return; + } + if (!$response instanceof CatalogDataProductSearchResultsInterface) { + yield $this->rejectInvalidResponse($parameters, $currentPage); + + return; + } + $pageCount = (int) ceil($response->getTotalCount() / $this->pageSize); + + yield new AcceptanceResultBucket(...$response->getItems()); + + while ($currentPage++ < $pageCount) { + $response = $this->client->getV1Products( + queryParameters: $this->applyPagination($parameters, $currentPage, $this->pageSize), + ); + if ($response instanceof ErrorResponse) { + yield $this->rejectErrorResponse($response, $parameters, $currentPage); - yield $this->processResponse($response); + return; + } + if (!$response instanceof CatalogDataProductSearchResultsInterface) { + yield $this->rejectInvalidResponse($parameters, $currentPage); - $currentPage = 1; - $pageCount = ceil($response->getTotalCount() / $this->pageSize); - while ($currentPage++ < $pageCount) { - $response = $this->client->catalogProductRepositoryV1GetListGet( - queryParameters: $this->compileQueryParameters($currentPage), + return; + } + + yield new AcceptanceResultBucket(...$response->getItems()); + } + } catch (NetworkExceptionInterface $exception) { + $this->logger->critical( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1Products', + 'method' => 'get', + 'queryParameters' => $parameters, + 'currentPage' => $currentPage, + 'pageSize' => $this->pageSize, + ], + ); + yield new RejectionResultBucket( + 'There are some network difficulties. We could not properly connect to the Magento API. There is nothing we could no to fix this currently. Please contact the Magento administrator.', + $exception, ); - yield $this->processResponse($response); - } - } catch (\Exception $exception) { - $this->logger->alert($exception->getMessage(), ['exception' => $exception]); - } - } + return; + } catch (UnexpectedStatusCodeException $exception) { + $this->logger->critical($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The source API responded with a status we did not expect. Aborting. Please check the availability of the source API and if there are no rate limiting or redirections active.', + $exception, + ); - private function processResponse($response): ResultBucketInterface - { - if ($response instanceof \Kiboko\Magento\V2_1\Model\ErrorResponse - || $response instanceof \Kiboko\Magento\V2_2\Model\ErrorResponse - || $response instanceof \Kiboko\Magento\V2_3\Model\ErrorResponse - || $response instanceof \Kiboko\Magento\V2_4\Model\ErrorResponse - ) { - return new RejectionResultBucket($response); - } + return; + } catch (\Throwable $exception) { + $this->logger->emergency($exception->getMessage(), ['exception' => $exception]); + yield new RejectionResultBucket( + 'The client failed critically. Aborting. Please contact customer support or your system administrator.', + $exception, + ); - return new AcceptanceResultBucket(...$response->getItems()); + return; + } + } } } diff --git a/src/ProductOptionsLookup.php b/src/ProductOptionsLookup.php new file mode 100644 index 0000000..ff893fe --- /dev/null +++ b/src/ProductOptionsLookup.php @@ -0,0 +1,191 @@ + + */ +final readonly class ProductOptionsLookup implements TransformerInterface +{ + /** + * @param CompiledMapperInterface $mapper + */ + public function __construct( + private LoggerInterface $logger, + private Client $client, + private CompiledMapperInterface $mapper, + private string $mappingField, + private string $attributeCode, + ) { + } + + /** + * @return RejectionResultBucketInterface + */ + private function rejectErrorResponse(ErrorResponse $response): RejectionResultBucketInterface + { + $this->logger->error( + $response->getMessage(), + [ + 'resource' => 'getV1ProductsAttributesAttributeCodeOptions', + 'method' => 'get', + ], + ); + + return new RejectionResultBucket($response->getMessage(), null); + } + + /** + * @return RejectionResultBucketInterface + */ + private function rejectInvalidResponse(): RejectionResultBucketInterface + { + $this->logger->error( + $message = 'The result provided by the API client does not match the expected type. The connector compilation may have fetched incompatible versions.', + [ + 'resource' => 'getV1ProductsAttributesAttributeCodeOptions', + 'method' => 'get', + ], + ); + + return new RejectionResultBucket($message, null); + } + + /** + * @param InputType $line + * + * @return OutputType + */ + public function passThrough(array $line): array + { + /* @var OutputType $line */ + return $line; + } + + public function transform(): \Generator + { + $line = yield new EmptyResultBucket(); + while (true) { + if (null === $line) { + $line = yield new EmptyResultBucket(); + continue; + } + + if (null === $line[$this->mappingField]) { + $line = yield new AcceptanceResultBucket($this->passThrough($line)); + continue; + } + + try { + $lookup = $this->client->getV1ProductsAttributesAttributeCodeOptions( + attributeCode: $this->attributeCode, + ); + + if ($lookup instanceof ErrorResponse) { + $line = yield $this->rejectErrorResponse($lookup); + continue; + } + + if (!\is_array($lookup) || !array_is_list($lookup)) { + $line = yield $this->rejectInvalidResponse(); + continue; + } + + $lookup = array_filter( + $lookup, + fn (object $item) => $item->getValue() === $line[$this->mappingField], + ); + } catch (NetworkExceptionInterface $exception) { + $this->logger->critical( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1ProductsAttributesAttributeCodeOptions', + 'method' => 'get', + 'categoryId' => (int) $line[$this->mappingField], + 'mappingField' => $this->mappingField, + ], + ); + $line = yield new RejectionResultBucket( + 'There are some network difficulties. We could not properly connect to the Magento API. There is nothing we could no to fix this currently. Please contact the Magento administrator.', + $exception, + $this->passThrough($line), + ); + continue; + } catch (GetV1ProductsAttributesAttributeCodeOptionsBadRequestException $exception) { + $this->logger->error( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1ProductsAttributesAttributeCodeOptions', + 'method' => 'get', + 'categoryId' => (int) $line[$this->mappingField], + 'mappingField' => $this->mappingField, + ], + ); + $line = yield new RejectionResultBucket( + 'The source API rejected our request. Ignoring line. Maybe you are requesting on incompatible versions.', + $exception, + $this->passThrough($line), + ); + continue; + } catch (UnexpectedStatusCodeException $exception) { + $this->logger->critical( + $exception->getMessage(), + [ + 'exception' => $exception, + 'resource' => 'getV1ProductsAttributesAttributeCodeOptions', + 'method' => 'get', + 'categoryId' => (int) $line[$this->mappingField], + 'mappingField' => $this->mappingField, + ], + ); + $line = yield new RejectionResultBucket( + 'The source API responded with a status we did not expect. Aborting. Please check the availability of the source API and if there are no rate limiting or redirections active.', + $exception, + $this->passThrough($line), + ); + + return; + } + + reset($lookup); + $current = current($lookup); + if (\count($lookup) <= 0 || false === $current) { + $this->logger->critical( + 'The lookup did not find any related resource. The lookup operation had no effect.', + [ + 'resource' => 'getV1ProductsAttributesAttributeCodeOptions', + 'method' => 'get', + 'categoryId' => (int) $line[$this->mappingField], + 'mappingField' => $this->mappingField, + ], + ); + $line = yield new AcceptanceResultBucket($this->passThrough($line)); + continue; + } + $output = ($this->mapper)($current, $line); + + $line = yield new AcceptanceResultBucket($output); + } + } +} diff --git a/src/QueryParameters.php b/src/QueryParameters.php new file mode 100644 index 0000000..bf5ef79 --- /dev/null +++ b/src/QueryParameters.php @@ -0,0 +1,55 @@ + */ + private array $groups = []; + + public function withGroup(FilterGroup $group): self + { + $this->groups[] = $group; + + return $this; + } + + public function withGroups(FilterGroup ...$groups): self + { + array_push($this->groups, ...$groups); + + return $this; + } + + /** + * @param array $parameters + * + * @return \Traversable> + */ + public function walkVariants(array $parameters = []): \Traversable + { + if (\count($this->groups) < 1) { + return; + } + + yield from $this->buildFilters($parameters, 0, ...$this->groups); + } + + /** + * @param array $parameters + * + * @return \Traversable> + */ + private function buildFilters(array $parameters, int $groupIndex, FilterGroup $first, FilterGroup ...$next): \Traversable + { + foreach ($first->walkFilters($parameters, $groupIndex) as $current) { + if (\count($next) >= 1) { + yield from $this->buildFilters($current, $groupIndex + 1, ...$next); + } else { + yield $current; + } + } + } +} diff --git a/tests/CustomerExtractorTest.php b/tests/CustomerExtractorTest.php index 8bc25c0..8189cbe 100644 --- a/tests/CustomerExtractorTest.php +++ b/tests/CustomerExtractorTest.php @@ -5,14 +5,15 @@ namespace Tests\Kiboko\Magento\V2\Extractor; use Kiboko\Component\Flow\Magento2\CustomerExtractor; -use Kiboko\Component\Flow\Magento2\Filter; +use Kiboko\Component\Flow\Magento2\Filter\ScalarFilter; use Kiboko\Component\Flow\Magento2\FilterGroup; +use Kiboko\Component\Flow\Magento2\QueryParameters; use Kiboko\Component\PHPUnitExtension\Assert\ExtractorAssertTrait; use Kiboko\Component\PHPUnitExtension\PipelineRunner; use Kiboko\Contract\Pipeline\PipelineRunnerInterface; -use Kiboko\Magento\V2_3\Client; -use Kiboko\Magento\V2_3\Model\CustomerDataCustomerInterface; -use Kiboko\Magento\V2_3\Model\CustomerDataCustomerSearchResultsInterface; +use Kiboko\Magento\Client; +use Kiboko\Magento\Model\CustomerDataCustomerInterface; +use Kiboko\Magento\Model\CustomerDataCustomerSearchResultsInterface; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; @@ -35,7 +36,7 @@ public function testIsSuccessful(): void $client = $this->createMock(Client::class); $client ->expects($this->once()) - ->method('customerCustomerRepositoryV1GetListGet') + ->method('getV1CustomersSearch') ->willReturn( (new CustomerDataCustomerSearchResultsInterface()) ->setItems([ @@ -48,11 +49,15 @@ public function testIsSuccessful(): void $extractor = new CustomerExtractor( new NullLogger(), $client, - 1, - [ - (new FilterGroup())->withFilter(new Filter('updated_at', 'eq', '2022-09-05')), - (new FilterGroup())->withFilter(new Filter('active', 'eq', true)), - ] + (new QueryParameters()) + ->withGroup( + (new FilterGroup()) + ->withFilter(new ScalarFilter('updated_at', 'eq', '2022-09-05')), + ) + ->withGroup( + (new FilterGroup()) + ->withFilter(new ScalarFilter('active', 'eq', true)), + ) ); $this->assertExtractorExtractsExactly( diff --git a/tests/Filter/ArrayFilterTest.php b/tests/Filter/ArrayFilterTest.php new file mode 100644 index 0000000..40f8a04 --- /dev/null +++ b/tests/Filter/ArrayFilterTest.php @@ -0,0 +1,46 @@ +assertCount(1, iterator_to_array($filter->getIterator(), false)); + $this->assertContains([ + 'field' => 'foo', + 'value' => '1,2,3,4', + 'conditionType' => 'in', + ], $filter); + } + + #[Test] + public function shouldProduceSeveralVariants(): void + { + $filter = new ArrayFilter('foo', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], 4); + $this->assertCount(3, iterator_to_array($filter->getIterator(), false)); + $this->assertContains([ + 'field' => 'foo', + 'value' => '1,2,3,4', + 'conditionType' => 'in', + ], $filter); + $this->assertContains([ + 'field' => 'foo', + 'value' => '5,6,7,8', + 'conditionType' => 'in', + ], $filter); + $this->assertContains([ + 'field' => 'foo', + 'value' => '9,10,11', + 'conditionType' => 'in', + ], $filter); + } +} diff --git a/tests/Filter/ScalarFilterTest.php b/tests/Filter/ScalarFilterTest.php new file mode 100644 index 0000000..960c4ad --- /dev/null +++ b/tests/Filter/ScalarFilterTest.php @@ -0,0 +1,24 @@ +assertCount(1, iterator_to_array($filter->getIterator(), false)); + $this->assertContains([ + 'field' => 'foo', + 'value' => '4', + 'conditionType' => 'eq', + ], $filter); + } +} diff --git a/tests/FilterGroupTest.php b/tests/FilterGroupTest.php new file mode 100644 index 0000000..b6c9c24 --- /dev/null +++ b/tests/FilterGroupTest.php @@ -0,0 +1,162 @@ +withFilter( + new ArrayFilter('foo', 'in', [1, 2, 3, 4], 4), + ); + $this->assertCount(1, iterator_to_array($group->walkFilters([]), false)); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + ], $group->walkFilters([])); + } + + #[Test] + public function shouldProduceSeveralVariants(): void + { + $group = new FilterGroup(); + $group->withFilter( + new ArrayFilter('foo', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], 4), + ); + $this->assertCount(3, iterator_to_array($group->walkFilters([]), false)); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + ], $group->walkFilters([])); + } + + #[Test] + public function shouldProduceDemultipliedVariants(): void + { + $group = new FilterGroup(); + $group->withFilter( + new ArrayFilter('foo', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], 4), + ); + $group->withFilter( + new ArrayFilter('bar', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], 4), + ); + $this->assertCount(12, iterator_to_array($group->walkFilters([]), false)); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '9,10,11,12', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '9,10,11,12', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '9,10,11,12', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '13,14,15', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '13,14,15', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][0][filters][2][field]' => 'bar', + 'searchCriteria[filterGroups][0][filters][2][value]' => '13,14,15', + 'searchCriteria[filterGroups][0][filters][2][conditionType]' => 'in', + ], $group->walkFilters([])); + } +} diff --git a/tests/InvoiceExtractorTest.php b/tests/InvoiceExtractorTest.php new file mode 100644 index 0000000..2f600aa --- /dev/null +++ b/tests/InvoiceExtractorTest.php @@ -0,0 +1,69 @@ +setBaseCurrencyCode('EUR') + ->setTotalQty(1) + ->setBaseGrandTotal(59.90); + + $client = $this->createMock(Client::class); + $client + ->expects($this->once()) + ->method('getV1Invoices') + ->willReturn( + (new SalesDataInvoiceSearchResultInterface()) + ->setItems([ + $invoice, + ]) + ->setTotalCount(1) + ); + + $extractor = new InvoiceExtractor( + new NullLogger(), + $client, + (new QueryParameters()) + ->withGroup( + (new FilterGroup()) + ->withFilter(new ScalarFilter('updated_at', 'eq', '2022-09-05')), + ) + ->withGroup( + (new FilterGroup()) + ->withFilter(new ScalarFilter('active', 'eq', true)), + ) + ); + + $this->assertExtractorExtractsExactly( + [ + $invoice, + ], + $extractor + ); + } + + public function pipelineRunner(): PipelineRunnerInterface + { + return new PipelineRunner(); + } +} diff --git a/tests/OrderExtractorTest.php b/tests/OrderExtractorTest.php index bfc853f..eba97d5 100644 --- a/tests/OrderExtractorTest.php +++ b/tests/OrderExtractorTest.php @@ -4,12 +4,15 @@ namespace Tests\Kiboko\Magento\V2\Extractor; +use Kiboko\Component\Flow\Magento2\Filter\ScalarFilter; +use Kiboko\Component\Flow\Magento2\FilterGroup; use Kiboko\Component\Flow\Magento2\OrderExtractor; +use Kiboko\Component\Flow\Magento2\QueryParameters; use Kiboko\Component\PHPUnitExtension\Assert\ExtractorAssertTrait; use Kiboko\Component\PHPUnitExtension\PipelineRunner; use Kiboko\Contract\Pipeline\PipelineRunnerInterface; -use Kiboko\Magento\V2_3\Model\SalesDataOrderInterface; -use Kiboko\Magento\V2_3\Model\SalesDataOrderSearchResultInterface; +use Kiboko\Magento\Model\SalesDataOrderInterface; +use Kiboko\Magento\Model\SalesDataOrderSearchResultInterface; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; @@ -24,10 +27,10 @@ public function testIsSuccessful(): void ->setCustomerId(10) ->setTotalQtyOrdered(3); - $client = $this->createMock(\Kiboko\Magento\V2_3\Client::class); + $client = $this->createMock(\Kiboko\Magento\Client::class); $client ->expects($this->once()) - ->method('salesOrderRepositoryV1GetListGet') + ->method('getV1Orders') ->willReturn( (new SalesDataOrderSearchResultInterface) ->setItems([ @@ -39,6 +42,15 @@ public function testIsSuccessful(): void $extractor = new OrderExtractor( new NullLogger(), $client, + (new QueryParameters()) + ->withGroup( + (new FilterGroup()) + ->withFilter(new ScalarFilter('updated_at', 'eq', '2022-09-05')), + ) + ->withGroup( + (new FilterGroup()) + ->withFilter(new ScalarFilter('status', 'eq', 'complete')), + ) ); $this->assertExtractorExtractsExactly( diff --git a/tests/ProductExtractorTest.php b/tests/ProductExtractorTest.php index 2b8cd25..7e4b3a7 100644 --- a/tests/ProductExtractorTest.php +++ b/tests/ProductExtractorTest.php @@ -4,13 +4,16 @@ namespace Tests\Kiboko\Magento\V2\Extractor; +use Kiboko\Component\Flow\Magento2\Filter\ScalarFilter; +use Kiboko\Component\Flow\Magento2\FilterGroup; use Kiboko\Component\Flow\Magento2\ProductExtractor; +use Kiboko\Component\Flow\Magento2\QueryParameters; use Kiboko\Component\PHPUnitExtension\Assert\ExtractorAssertTrait; use Kiboko\Component\PHPUnitExtension\PipelineRunner; use Kiboko\Contract\Pipeline\PipelineRunnerInterface; -use Kiboko\Magento\V2_3\Client; -use Kiboko\Magento\V2_3\Model\CatalogDataProductInterface; -use Kiboko\Magento\V2_3\Model\CatalogDataProductSearchResultsInterface; +use Kiboko\Magento\Client; +use Kiboko\Magento\Model\CatalogDataProductInterface; +use Kiboko\Magento\Model\CatalogDataProductSearchResultsInterface; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; @@ -28,7 +31,7 @@ public function testIsSuccessful(): void $client = $this->createMock(Client::class); $client ->expects($this->once()) - ->method('catalogProductRepositoryV1GetListGet') + ->method('getV1Products') ->willReturn( (new CatalogDataProductSearchResultsInterface()) ->setItems([ @@ -40,6 +43,18 @@ public function testIsSuccessful(): void $extractor = new ProductExtractor( new NullLogger(), $client, + (new QueryParameters()) + ->withGroup( + (new FilterGroup()) + ->withFilter(new ScalarFilter('updated_at', 'eq', '2022-09-05')), + ) + ->withGroup( + (new FilterGroup()) + ->withFilter(new ScalarFilter('status', 'eq', 'complete')) + ->withFilter(new ScalarFilter('status', 'eq', 'canceled')) + ->withFilter(new ScalarFilter('status', 'eq', 'canceled')) + ->withFilter(new ScalarFilter('status', 'eq', 'in_preparation')) + ) ); $this->assertExtractorExtractsExactly( diff --git a/tests/QueryParametersTest.php b/tests/QueryParametersTest.php new file mode 100644 index 0000000..4e71e37 --- /dev/null +++ b/tests/QueryParametersTest.php @@ -0,0 +1,172 @@ +withGroup((new FilterGroup()) + ->withFilter( + new ArrayFilter('foo', 'in', [1, 2, 3, 4], 4), + ) + ); + + $this->assertCount(1, iterator_to_array($queryParameters->walkVariants(), false)); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + } + + #[Test] + public function shouldProduceSeveralVariants(): void + { + $queryParameters = (new QueryParameters()) + ->withGroup((new FilterGroup()) + ->withFilter( + new ArrayFilter('foo', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], 4), + ) + ); + $this->assertCount(3, iterator_to_array($queryParameters->walkVariants(), false)); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + } + + #[Test] + public function shouldProduceDemultipliedVariants(): void + { + $queryParameters = (new QueryParameters()) + ->withGroup((new FilterGroup()) + ->withFilter( + new ArrayFilter('foo', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], 4), + ) + ) + ->withGroup((new FilterGroup()) + ->withFilter( + new ArrayFilter('bar', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], 4), + ) + ); + $this->assertCount(12, iterator_to_array($queryParameters->walkVariants(), false)); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '9,10,11,12', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '9,10,11,12', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '9,10,11,12', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '1,2,3,4', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '13,14,15', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '5,6,7,8', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '13,14,15', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + $this->assertContains([ + 'searchCriteria[filterGroups][0][filters][1][field]' => 'foo', + 'searchCriteria[filterGroups][0][filters][1][value]' => '9,10,11', + 'searchCriteria[filterGroups][0][filters][1][conditionType]' => 'in', + 'searchCriteria[filterGroups][1][filters][1][field]' => 'bar', + 'searchCriteria[filterGroups][1][filters][1][value]' => '13,14,15', + 'searchCriteria[filterGroups][1][filters][1][conditionType]' => 'in', + ], $queryParameters->walkVariants()); + } +}