diff --git a/.docker/compose.yaml b/.docker/compose.yaml new file mode 100644 index 0000000..66cc06e --- /dev/null +++ b/.docker/compose.yaml @@ -0,0 +1,14 @@ +x-build-args: &build-args + UID: "${UID:-1000}" + GID: "${GID:-1000}" + +name: cleverage-rest-process-bundle + +services: + php: + build: + context: php + args: + <<: *build-args + volumes: + - ../:/var/www diff --git a/.docker/php/Dockerfile b/.docker/php/Dockerfile new file mode 100644 index 0000000..f98c3ba --- /dev/null +++ b/.docker/php/Dockerfile @@ -0,0 +1,29 @@ +FROM php:8.2-fpm-alpine + +ARG UID +ARG GID + +RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" +COPY /conf.d/ "$PHP_INI_DIR/conf.d/" + +RUN apk update && apk add \ + tzdata \ + shadow \ + nano \ + bash \ + icu-dev \ + && docker-php-ext-configure intl \ + && docker-php-ext-install intl opcache \ + && docker-php-ext-enable opcache + +RUN ln -s /usr/share/zoneinfo/Europe/Paris /etc/localtime \ + && sed -i "s/^;date.timezone =.*/date.timezone = Europe\/Paris/" $PHP_INI_DIR/php.ini + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +RUN usermod -u $UID www-data \ + && groupmod -g $GID www-data + +USER www-data:www-data + +WORKDIR /var/www diff --git a/.docker/php/conf.d/dev.ini b/.docker/php/conf.d/dev.ini new file mode 100644 index 0000000..2a141be --- /dev/null +++ b/.docker/php/conf.d/dev.ini @@ -0,0 +1,5 @@ +display_errors = 1 +error_reporting = E_ALL + +opcache.validate_timestamps = 1 +opcache.revalidate_freq = 0 diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..7711713 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,14 @@ +## Description + + + +## Requirements + +* Documentation updates + - [ ] Reference + - [ ] Changelog +* [ ] Unit tests + +## Breaking changes + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..58db37d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +## Description + + + +## Requirements + +* Documentation updates + - [ ] Reference + - [ ] Changelog +* [ ] Unit tests + +## Breaking changes + + diff --git a/.github/workflows/notifications.yml b/.github/workflows/notifications.yml new file mode 100644 index 0000000..e7974ab --- /dev/null +++ b/.github/workflows/notifications.yml @@ -0,0 +1,23 @@ +name: Rocket chat notifications + +# Controls when the action will run. +on: + push: + tags: + - '*' + +jobs: + notification: + runs-on: ubuntu-latest + + steps: + - name: Get the tag short reference + id: get_tag + run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT + + - name: Rocket.Chat Notification + uses: madalozzo/Rocket.Chat.GitHub.Action.Notification@master + with: + type: success + job_name: "[cleverage/rest-process-bundle](https://github.com/cleverage/rest-process-bundle) : ${{ steps.get_tag.outputs.TAG }} has been released" + url: ${{ secrets.CLEVER_AGE_ROCKET_CHAT_WEBOOK_URL }} diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..9f1580f --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,62 @@ +name: Quality + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install PHP with extensions + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + tools: composer:v2 + - name: Install Composer dependencies (locked) + uses: ramsey/composer-install@v3 + - name: PHPStan + run: vendor/bin/phpstan --no-progress --memory-limit=1G analyse --error-format=github + + php-cs-fixer: + name: PHP-CS-Fixer + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install PHP with extensions + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + tools: composer:v2 + - name: Install Composer dependencies (locked) + uses: ramsey/composer-install@v3 + - name: PHP-CS-Fixer + run: vendor/bin/php-cs-fixer fix --diff --dry-run --show-progress=none + + rector: + name: Rector + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install PHP with extensions + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + tools: composer:v2 + - name: Install Composer dependencies (locked) + uses: ramsey/composer-install@v3 + - name: Rector + run: vendor/bin/rector --no-progress-bar --dry-run diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2d7e7a4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,74 @@ +name: Test + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + test: + name: PHP ${{ matrix.php-version }} + ${{ matrix.dependencies }} + ${{ matrix.variant }} + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.allowed-to-fail }} + env: + SYMFONY_REQUIRE: ${{matrix.symfony-require}} + + strategy: + matrix: + php-version: + - '8.2' + - '8.3' + dependencies: [highest] + allowed-to-fail: [false] + symfony-require: [''] + variant: [normal] + include: + - php-version: '8.2' + dependencies: highest + allowed-to-fail: false + symfony-require: 6.4.* + variant: symfony/symfony:"6.4.*" + - php-version: '8.2' + dependencies: highest + allowed-to-fail: false + symfony-require: 7.1.* + variant: symfony/symfony:"7.1.*" + - php-version: '8.3' + dependencies: highest + allowed-to-fail: false + symfony-require: 6.4.* + variant: symfony/symfony:"6.4.*" + - php-version: '8.3' + dependencies: highest + allowed-to-fail: false + symfony-require: 7.1.* + variant: symfony/symfony:"7.1.*" + + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install PHP with extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: pcov + tools: composer:v2, flex + - name: Add PHPUnit matcher + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + - name: Install variant + if: matrix.variant != 'normal' && !startsWith(matrix.variant, 'symfony/symfony') + run: composer require ${{ matrix.variant }} --no-update + - name: Install Composer dependencies (${{ matrix.dependencies }}) + uses: ramsey/composer-install@v3 + with: + dependency-versions: ${{ matrix.dependencies }} + - name: Run Tests with coverage + run: vendor/bin/phpunit -c phpunit.xml.dist --coverage-clover build/logs/clover.xml + #- name: Send coverage to Codecov + # uses: codecov/codecov-action@v4 + # with: + # files: build/logs/clover.xml diff --git a/.gitignore b/.gitignore index ff72e2d..ca08796 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ /composer.lock /vendor +.env +.idea +/phpunit.xml +.phpunit.result.cache +.phpunit.cache +.php-cs-fixer.cache +coverage-report diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..7e90154 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,46 @@ +setRules([ + '@PHP71Migration' => true, + '@PHP82Migration' => true, + '@PHPUnit75Migration:risky' => true, + '@Symfony' => true, + '@Symfony:risky' => true, + 'protected_to_private' => false, + 'native_constant_invocation' => ['strict' => false], + 'header_comment' => ['header' => $fileHeaderComment], + 'modernize_strpos' => true, + 'get_class_to_class_keyword' => true, + ]) + ->setRiskyAllowed(true) + ->setFinder( + (new PhpCsFixer\Finder()) + ->in(__DIR__.'/src') + ->in(__DIR__.'/tests') + ->append([__FILE__]) + ) + ->setCacheFile('.php-cs-fixer.cache') +; diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0afe273 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +v2.0 +------ + +## BC breaks + +* [#3](https://github.com/cleverage/rest-process-bundle/issues/3) Replace `nategood/httpful` dependency by `symfony/http-client` +* [#3](https://github.com/cleverage/rest-process-bundle/issues/5) Update Tasks for "symfony/http-client": "^6.4|^7.1" +* [#4](https://github.com/cleverage/rest-process-bundle/issues/4) Update services according to Symfony best practices. +Services should not use autowiring or autoconfiguration. Instead, all services should be defined explicitly. +Services must be prefixed with the bundle alias instead of using fully qualified class names => `cleverage_rest_process` +* RequestTask : `query_parameters` option is deprecated, use `data` instead +* Remove RequestTransformer, use RequestTask instead. + +### Changes + +* [#1](https://github.com/cleverage/rest-process-bundle/issues/1) Add Makefile & .docker for local standalone usage +* [#1](https://github.com/cleverage/rest-process-bundle/issues/1) Add rector, phpstan & php-cs-fixer configurations & apply it +* [#2](https://github.com/cleverage/rest-process-bundle/issues/2) Remove `sidus/base-bundle` dependency + +### Fixes + +v1.0.4 +------ + +### Changes + +* Fixed dependencies after removing sidus/base-bundle from the base process bundle + +v1.0.3 +------ + +### Changes + +* Minor refactoring in RequestTask to allow override of options more easily + +v1.0.2 +------ + +### Fixes + +* Fixing trailing '?'/'&' in request uri + +v1.0.1 +------ + +### Changes + +* Adding debug information in RequestTask + +v1.0.0 +------ + +* Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9a8dfba --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +Contributing +============ + +First of all, **thank you** for contributing, **you are awesome**! + +Here are a few rules to follow in order to ease code reviews, and discussions before +maintainers accept and merge your work. + +You MUST run the quality & test suites. + +You SHOULD write (or update) unit tests. + +You SHOULD write documentation. + +Please, write [commit messages that make sense](https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html), +and [rebase your branch](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) before submitting your Pull Request. + +One may ask you to [squash your commits](https://gitready.com/advanced/2009/02/10/squashing-commits-with-rebase.html) +too. This is used to "clean" your Pull Request before merging it (we don't want +commits such as `fix tests`, `fix 2`, `fix 3`, etc.). + +Thank you! + +## Running the quality & test suites + +Tests suite uses Docker environments in order to be idempotent to OS's. More than this +PHP version is written inside the Dockerfile; this assures to test the bundle with +the same resources. No need to have PHP installed. + +You only need Docker set it up. + +To allow testing environments more smooth we implemented **Makefile**. +You have two commands available: + +```bash +make quality +``` + +```bash +make tests +``` + +## Deprecations notices + +When a feature should be deprecated, or when you have a breaking change for a future version, please : +* [Fill an issue](https://github.com/cleverage/rest-process-bundle/issues/new) +* Add TODO comments with the following format: `@TODO deprecated v2.0` +* Trigger a deprecation error: `@trigger_error('This feature will be deprecated in v2.0', E_USER_DEPRECATED);` + +You can check which deprecation notice is triggered in tests +* `make bash` +* `SYMFONY_DEPRECATIONS_HELPER=0 ./vendor/bin/phpunit` diff --git a/Client/Client.php b/Client/Client.php deleted file mode 100644 index 759f45c..0000000 --- a/Client/Client.php +++ /dev/null @@ -1,283 +0,0 @@ - - */ -class Client implements ClientInterface -{ - /** @var LoggerInterface */ - private $logger; - - /** @var string */ - private $code; - - /** @var string */ - private $uri; - - /** - * Shopify constructor. - * - * @param LoggerInterface $logger - * @param string $code - * @param string $uri - */ - public function __construct(LoggerInterface $logger, string $code, string $uri) - { - $this->logger = $logger; - $this->code = $code; - $this->uri = $uri; - } - - /** - * @return LoggerInterface - */ - public function getLogger(): LoggerInterface - { - return $this->logger; - } - - /** - * @return string - */ - public function getCode(): string - { - return $this->code; - } - - /** - * @return string - */ - public function geUri(): string - { - return $this->uri; - } - - /** - * @param string $uri - */ - public function setUri(string $uri): void - { - $this->uri = $uri; - } - - /** - * @param array $options - * - * @throws \Exception - * - * @return Response - */ - public function call(array $options = []): Response - { - $options = $this->getOptions($options); - - $request = $this->initializeRequest($options); - $this->setRequestQueryParameters($request, $options); - $this->setRequestHeader($request, $options); - - return $this->sendRequest($request, $options); - } - - /** - * @param OptionsResolver $resolver - */ - protected function configureOptions(OptionsResolver $resolver): void - { - $resolver->setRequired( - [ - 'url', - ] - ); - - $resolver->setDefaults( - [ - 'method' => 'GET', - 'url_parameters' => [], - 'query_parameters' => [], - 'headers' => [], - 'sends' => 'json', - 'expects' => 'json', - 'body' => null, - ] - ); - - $resolver->setAllowedTypes('url', ['string']); - $resolver->setAllowedTypes('method', ['string']); - $resolver->setAllowedTypes('sends', ['string']); - $resolver->setAllowedTypes('expects', ['string']); - $resolver->setAllowedTypes('url_parameters', ['array']); - $resolver->setAllowedTypes('query_parameters', ['array']); - $resolver->setAllowedTypes('headers', ['array']); - } - - /** - * @param array $options - * - * @return array - */ - protected function getOptions(array $options = []): array - { - $resolver = new OptionsResolver(); - $this->configureOptions($resolver); - - return $resolver->resolve($options); - } - - /** - * @param array $options - * - * @throws RestRequestException - * - * @return Request - * - */ - protected function initializeRequest(array $options = []): Request - { - if (!in_array( - $options['method'], - [Http::HEAD, Http::GET, Http::POST, Http::PUT, Http::DELETE, Http::OPTIONS, Http::TRACE, Http::PATCH], - true - )) { - throw new RestRequestException(sprintf('%s is not an HTTP method', $options['method'])); - } - $request = Request::init($options['method']); - $request->sends($options['sends']); - $request->expects($options['expects']); - $request->body($options['body']); - - return $request; - } - - /** - * @param Request $request - * @param array $options - * - * - * @throws \Exception - */ - protected function setRequestQueryParameters(Request $request, array $options = []): void - { - $uri = $this->constructUri($options); - if (Http::GET === $options['method']) { - if (is_array($options['query_parameters'])) { - $parametersString = http_build_query($options['query_parameters']); - } else { - $parametersString = (string) $options['query_parameters']; - } - if ($parametersString) { - $uri .= strpos($uri, '?') ? '&' : '?'; - $uri .= $parametersString; - } - } elseif ($options['query_parameters']) { - $request->body($options['query_parameters']); - } - - $uri = $this->replaceParametersInUri($uri, $options); - $request->uri($uri); - } - - /** - * @param Request $request - * @param array $options - * - * - */ - protected function setRequestHeader(Request $request, array $options = []): void - { - if ($options['headers']) { - $request->addHeaders($options['headers']); - } - } - - /** - * @param Request $request - * @param array $options - * - * @throws RestRequestException - * - * @return Response|null - */ - protected function sendRequest(Request $request, array $options = []): ?Response - { - try { - return $request->send(); - } catch (\Exception $e) { - $this->logger->error( - 'Rest request failed', - [ - 'url' => $request->uri, - 'error' => $e->getMessage(), - ] - ); - throw new RestRequestException('Rest request failed', 0, $e); - } - } - - /** - * @return string - */ - protected function getApiUrl(): string - { - return sprintf('%s', $this->geUri()); - } - - /** - * @param array $options - * - * @return string - */ - protected function constructUri(array $options): string - { - $uri = ltrim($options['url'], '/'); - - return sprintf('%s/%s', $this->getApiUrl(), $uri); - } - - /** - * @param string $uri - * @param array $options - * - * @return string - * - */ - protected function replaceParametersInUri(string $uri, array $options = []): string - { - if (array_key_exists('url_parameters', $options) && $options['url_parameters']) { - $search = array_keys($options['url_parameters']); - array_walk( - $search, - static function (&$item) { - $item = '{'.$item.'}'; - } - ); - $replace = array_values($options['url_parameters']); - array_walk( - $replace, - 'rawurlencode' - ); - - $uri = str_replace($search, $replace, $uri); - } - - return $uri; - } -} diff --git a/Client/ClientInterface.php b/Client/ClientInterface.php deleted file mode 100644 index 437f593..0000000 --- a/Client/ClientInterface.php +++ /dev/null @@ -1,51 +0,0 @@ - - */ -interface ClientInterface -{ - /** - * Return the code of the client used in client registry. - * - * @return string - */ - public function getCode(): string; - - /** - * Return the URI - * - * @return string - */ - public function geUri(): string; - - /** - * Set the URI - * - * @param string $uri - * - * @return void - */ - public function setUri(string $uri): void; - - /** - * @param array $options - * - * @return \Httpful\Response - */ - public function call(array $options = []): Response; -} diff --git a/DependencyInjection/CleverAgeRestProcessExtension.php b/DependencyInjection/CleverAgeRestProcessExtension.php deleted file mode 100644 index 5342cba..0000000 --- a/DependencyInjection/CleverAgeRestProcessExtension.php +++ /dev/null @@ -1,26 +0,0 @@ - - * @author Vincent Chalnot - * @author Madeline Veyrenc - */ -class CleverAgeRestProcessExtension extends SidusBaseExtension -{ -} diff --git a/LICENSE b/LICENSE index fdc6131..045d824 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2015-2019 Clever-Age +Copyright (c) Clever-Age Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0a58e32 --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +.ONESHELL: +SHELL := /bin/bash + +DOCKER_RUN_PHP = docker compose -f .docker/compose.yaml run --rm php "bash" "-c" +DOCKER_COMPOSE = docker compose -f .docker/compose.yaml + +start: upd #[Global] Start application + +src/vendor: #[Composer] install dependencies + $(DOCKER_RUN_PHP) "composer install --no-interaction" + +upd: #[Docker] Start containers detached + touch .docker/.env + make src/vendor + $(DOCKER_COMPOSE) up --remove-orphans --detach + +up: #[Docker] Start containers + touch .docker/.env + make src/vendor + $(DOCKER_COMPOSE) up --remove-orphans + +stop: #[Docker] Down containers + $(DOCKER_COMPOSE) stop + +down: #[Docker] Down containers + $(DOCKER_COMPOSE) down + +build: #[Docker] Build containers + $(DOCKER_COMPOSE) build + +ps: # [Docker] Show running containers + $(DOCKER_COMPOSE) ps + +bash: #[Docker] Connect to php container with current host user + $(DOCKER_COMPOSE) exec php bash + +logs: #[Docker] Show logs + $(DOCKER_COMPOSE) logs -f + +quality: phpstan php-cs-fixer rector #[Quality] Run all quality checks + +phpstan: #[Quality] Run PHPStan + $(DOCKER_RUN_PHP) "vendor/bin/phpstan --no-progress --memory-limit=1G analyse" + +php-cs-fixer: #[Quality] Run PHP-CS-Fixer + $(DOCKER_RUN_PHP) "vendor/bin/php-cs-fixer fix --diff --verbose" + +rector: #[Quality] Run Rector + $(DOCKER_RUN_PHP) "vendor/bin/rector" + +tests: phpunit #[Tests] Run all tests + +phpunit: #[Tests] Run PHPUnit + $(DOCKER_RUN_PHP) "vendor/bin/phpunit" diff --git a/README.md b/README.md index 9c3b0fe..7c88fe9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,22 @@ CleverAge/RestProcessBundle ======================= -See process bundle documentation +This bundle is a part of the [CleverAge/ProcessBundle](https://github.com/cleverage/process-bundle) project. +It provides [Rest](https://fr.wikipedia.org/wiki/Representational_state_transfer) integration on Process bundle. + +Compatible with [Symfony stable version and latest Long-Term Support (LTS) release](https://symfony.com/releases). + +## Documentation + +For usage documentation, see: +[docs/index.md](docs/index.md) + +## Support & Contribution + +For general support and questions, please use [Github](https://github.com/cleverage/rest-process-bundle/issues). +If you think you found a bug or you have a feature idea to propose, feel free to open an issue after looking at the [contributing](CONTRIBUTING.md) guide. + +## License + +This bundle is under the MIT license. +For the whole copyright, see the [LICENSE](LICENSE) file distributed with this source code. diff --git a/Resources/config/services/registry.yml b/Resources/config/services/registry.yml deleted file mode 100644 index 323b1aa..0000000 --- a/Resources/config/services/registry.yml +++ /dev/null @@ -1,3 +0,0 @@ -services: - CleverAge\RestProcessBundle\Registry\ClientRegistry: - public: false diff --git a/Resources/config/services/task.yml b/Resources/config/services/task.yml deleted file mode 100644 index f0591f4..0000000 --- a/Resources/config/services/task.yml +++ /dev/null @@ -1,8 +0,0 @@ -services: - CleverAge\RestProcessBundle\Task\: - resource: '../../../Task/*' - autowire: true - public: true - shared: false - tags: - - { name: monolog.logger, channel: cleverage_process_task } diff --git a/Resources/config/services/transformer.yml b/Resources/config/services/transformer.yml deleted file mode 100644 index 20242ec..0000000 --- a/Resources/config/services/transformer.yml +++ /dev/null @@ -1,8 +0,0 @@ -services: - CleverAge\RestProcessBundle\Transformer\: - resource: '../../../Transformer/*' - autowire: true - public: false - tags: - - { name: cleverage.transformer } - - { name: monolog.logger, channel: cleverage_process_transformer } diff --git a/Transformer/RequestTransformer.php b/Transformer/RequestTransformer.php deleted file mode 100644 index d021586..0000000 --- a/Transformer/RequestTransformer.php +++ /dev/null @@ -1,129 +0,0 @@ - - */ -class RequestTransformer implements ConfigurableTransformerInterface -{ - - /** @var LoggerInterface */ - protected $logger; - - /** @var ClientRegistry */ - protected $registry; - - /** - * @param LoggerInterface $logger - * @param ClientRegistry $registry - */ - public function __construct(LoggerInterface $logger, ClientRegistry $registry) - { - $this->logger = $logger; - $this->registry = $registry; - } - - /** - * {@inheritdoc} - * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException - * @throws \Symfony\Component\OptionsResolver\Exception\OptionDefinitionException - * @throws \Symfony\Component\OptionsResolver\Exception\NoSuchOptionException - * @throws \Symfony\Component\OptionsResolver\Exception\MissingOptionsException - * @throws \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException - * @throws \Symfony\Component\OptionsResolver\Exception\AccessException - * @throws \RuntimeException - * @throws \Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException - * @throws \Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException - * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface - * @throws \CleverAge\RestProcessBundle\Exception\MissingClientException - */ - public function transform($value, array $options = []) - { - $resolver = new OptionsResolver(); - $this->configureOptions($resolver); - $options = $resolver->resolve($options); - - $client = $this->registry->getClient($options['client']); - - $requestOptions = [ - 'url' => $options['url'], - 'headers' => $options['headers'], - 'url_parameters' => $options['url_parameters'], - 'query_parameters' => $options['query_parameters'], - 'sends' => $options['sends'], - 'expects' => $options['expects'], - ]; - - $input = $value ?: []; - $requestOptions = array_merge($requestOptions, $input); - $result = $client->call($requestOptions); - - // Handle empty results - if (!\in_array($result->code, $options['valid_response_code'], false)) { - $this->logger->error( - 'REST request failed', - [ - 'client' => $options['client'], - 'options' => $options, - 'raw_headers' => $result->raw_headers, - 'raw_body' => $result->raw_body, - ] - ); - - throw new TransformerException('REST request failed'); - } - - return $result->body; - } - - /** - * Returns the unique code to identify the transformer - * - * @return string - */ - public function getCode() - { - return 'rest_request'; - } - - /** - * {@inheritdoc} - */ - public function configureOptions(OptionsResolver $resolver) - { - $resolver->setRequired( - [ - 'client', - 'url', - 'method', - ] - ); - $resolver->setDefault('headers', []); - $resolver->setDefault('url_parameters', []); - $resolver->setDefault('query_parameters', []); - $resolver->setDefault('sends', 'json'); - $resolver->setDefault('expects', 'json'); - $resolver->setDefault('valid_response_code', [200]); - $resolver->setAllowedTypes('client', ['string']); - $resolver->setAllowedTypes('url', ['string']); - $resolver->setAllowedTypes('method', ['string']); - $resolver->setAllowedTypes('valid_response_code', ['array']); - } -} diff --git a/composer.json b/composer.json index fb0c16f..afa9628 100644 --- a/composer.json +++ b/composer.json @@ -34,15 +34,35 @@ ], "autoload": { "psr-4": { - "CleverAge\\RestProcessBundle\\": "" + "CleverAge\\RestProcessBundle\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "CleverAge\\RestProcessBundle\\Tests\\": "tests/" } }, "require": { - "cleverage/process-bundle": "3.*|dev-v3.0-dev", - "nategood/httpful": ">0.2.0, <1.0.0", - "sidus/base-bundle": "~1.0" + "php": ">=8.1", + "cleverage/process-bundle": "^4.0", + "symfony/http-client": "^6.4|^7.1" }, "require-dev": { - "phpunit/phpunit": "~6.4" + "friendsofphp/php-cs-fixer": "*", + "phpstan/extension-installer": "*", + "phpstan/phpstan": "*", + "phpstan/phpstan-symfony": "*", + "phpunit/phpunit": "<10.0", + "rector/rector": "*", + "roave/security-advisories": "dev-latest", + "symfony/test-pack": "^1.1" + }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true, + "symfony/flex": true, + "symfony/runtime": true + }, + "sort-packages": true } } diff --git a/config/services/registry.yaml b/config/services/registry.yaml new file mode 100644 index 0000000..f2f59d9 --- /dev/null +++ b/config/services/registry.yaml @@ -0,0 +1,4 @@ +services: + cleverage_rest_process.registry.client: + class: CleverAge\RestProcessBundle\Registry\ClientRegistry + public: false diff --git a/config/services/task.yaml b/config/services/task.yaml new file mode 100644 index 0000000..bb53f13 --- /dev/null +++ b/config/services/task.yaml @@ -0,0 +1,12 @@ +services: + cleverage_rest_process.task.request: + class: CleverAge\RestProcessBundle\Task\RequestTask + public: false + arguments: + - '@logger' + - '@cleverage_rest_process.registry.client' + tags: + - { name: monolog.logger, channel: cleverage_process_task } + CleverAge\RestProcessBundle\Task\RequestTask: + alias: cleverage_rest_process.task.request + public: true diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..1b7effa --- /dev/null +++ b/docs/index.md @@ -0,0 +1,25 @@ +## Prerequisite + +CleverAge/ProcessBundle must be [installed](https://github.com/cleverage/process-bundle/blob/main/docs/01-quick_start.md#installation. + +## Installation + +Make sure Composer is installed globally, as explained in the [installation chapter](https://getcomposer.org/doc/00-intro.md) +of the Composer documentation. + +Open a command console, enter your project directory and install it using composer: + +```bash +composer require cleverage/rest-process-bundle +``` + +Remember to add the following line to config/bundles.php (not required if Symfony Flex is used) + +```php +CleverAge\RestProcessBundle\CleverAgeRestProcessBundle::class => ['all' => true], +``` + +## Reference + +- Tasks + - [RequestTask](reference/tasks/request_task.md) diff --git a/docs/reference/tasks/_template.md b/docs/reference/tasks/_template.md new file mode 100644 index 0000000..ed1d4a5 --- /dev/null +++ b/docs/reference/tasks/_template.md @@ -0,0 +1,44 @@ +TaskName +======== + +_Describe main goal an use cases of the task_ + +Task reference +-------------- + +* **Service**: `ClassName` + +Accepted inputs +--------------- + +_Description of allowed types_ + +Possible outputs +---------------- + +_Description of possible types_ + +Options +------- + +| Code | Type | Required | Default | Description | +| ---- | ---- | :------: | ------- | ----------- | +| `code` | `type` | **X** _or nothing_ | `default value` _if available_ | _description_ | + +Examples +-------- + +_YAML samples and explanations_ + +* Example 1 + - details + - details + +```yaml +# Task configuration level +code: + service: '@service_ref' + options: + a: 1 + b: 2 +``` diff --git a/docs/reference/tasks/request_task.md b/docs/reference/tasks/request_task.md new file mode 100644 index 0000000..88315cb --- /dev/null +++ b/docs/reference/tasks/request_task.md @@ -0,0 +1,90 @@ +RequestTask +=============== + +Call a Rest Request and get result. + +Task reference +-------------- + +* **Client Service Interface**: `CleverAge\RestProcessBundle\Client\ClientInterface` +* **Task Service**: `CleverAge\RestProcessBundle\Task\RequestTask` + +Accepted inputs +--------------- + +`array`: inputs are merged with task defined options. + +Possible outputs +---------------- + +`string`: the result content of the rest call. + +Options +------- + +### For Client + +| Code | Type | Required | Default | Description | +|--------|----------|:--------:|---------|------------------------------------------------| +| `code` | `string` | **X** | | Service identifier, used by Task client option | +| `uri` | `string` | **X** | | Base uri, concatenated with Task `url` | + +### For Task + +| Code | Type | Required | Default | Description | +|-----------------------|-----------------------------|:--------:|--------------------|------------------------------------------------------------------------------------------| +| `client` | `string` | **X** | | `ClientInterface` service identifier | +| `url` | `string` | **X** | | Relative url to call | +| `method` | `string` | **X** | | HTTP method from `['HEAD', 'GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'PATCH']` | +| `headers` | `array` | | `[]` | | +| `url_parameters` | `array` | | `[]` | Search/Replace data on `url` | +| `data` | `array`, `string` or `null` | | `null` | Treated as `body`, `query` or `json` on HttpClient, depending on `method` and `sends` | +| `sends` | `string` | | `application/json` | `Content-Type` header, if value is not empty | +| `expects` | `string` | | `application/json` | `Accept` header, if value is not empty | +| `valid_response_code` | `array` | | `[200]` | One or more [HTTP status code](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes) | +| `log_response` | `bool` | | `false` | | + +Examples +-------- + +### Client + +```yaml +services: + app.cleverage_rest_process.client.apicarto_ign: + class: CleverAge\RestProcessBundle\Client\Client + bind: + $code: 'domain_sample' + $uri: 'https://domain/api' + tags: + - { name: cleverage.rest.client } +``` + +### Task + +```yaml +# Task configuration level +code: + service: '@CleverAge\RestProcessBundle\Task\RequestTask' + error_strategy: 'stop' + options: + client: domain_sample + url: '/sample/{parameter}' + method: 'GET' + url_parameters: { parameter: '{{ parameter }}' } +``` + +```yaml +# Task configuration level +code: + service: '@CleverAge\RestProcessBundle\Task\RequestTask' + error_strategy: 'stop' + options: + client: domain_sample + url: '/sample' + method: 'POST' + data: # May be a json string or an array + parameter_1: + parameter_11: "eleven" + array: [-1, 666] +``` diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..9cae086 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: 10 + paths: + - src + - tests + ignoreErrors: + - identifier: parameter.defaultValue diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..766495c --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,27 @@ + + + + + tests + + + + + + src + + + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..72a2408 --- /dev/null +++ b/rector.php @@ -0,0 +1,30 @@ +withPhpVersion(PhpVersion::PHP_82) + ->withPaths([ + __DIR__.'/src', + __DIR__.'/tests', + ]) + ->withPhpSets(php82: true) + // here we can define, what prepared sets of rules will be applied + ->withPreparedSets( + deadCode: true, + codeQuality: true + ) + ->withSets([ + LevelSetList::UP_TO_PHP_82, + SymfonySetList::SYMFONY_64, + SymfonySetList::SYMFONY_71, + SymfonySetList::SYMFONY_CODE_QUALITY, + SymfonySetList::SYMFONY_CONSTRUCTOR_INJECTION, + SymfonySetList::ANNOTATIONS_TO_ATTRIBUTES, + ]) +; diff --git a/CleverAgeRestProcessBundle.php b/src/CleverAgeRestProcessBundle.php similarity index 60% rename from CleverAgeRestProcessBundle.php rename to src/CleverAgeRestProcessBundle.php index 6f3146e..b78e4c5 100644 --- a/CleverAgeRestProcessBundle.php +++ b/src/CleverAgeRestProcessBundle.php @@ -1,8 +1,11 @@ - - * @author Vincent Chalnot - * @author Madeline Veyrenc - */ class CleverAgeRestProcessBundle extends Bundle { /** - * Adding compiler passes to inject services into registry - * - * @param ContainerBuilder $container + * Adding compiler passes to inject services into registry. */ public function build(ContainerBuilder $container): void { $container->addCompilerPass( new RegistryCompilerPass( - ClientRegistry::class, + 'cleverage_rest_process.registry.client', 'cleverage.rest.client', 'addClient' ) ); } + + public function getPath(): string + { + return \dirname(__DIR__); + } } diff --git a/src/Client/Client.php b/src/Client/Client.php new file mode 100644 index 0000000..94d586f --- /dev/null +++ b/src/Client/Client.php @@ -0,0 +1,216 @@ +logger; + } + + public function getCode(): string + { + return $this->code; + } + + public function geUri(): string + { + return $this->uri; + } + + public function setUri(string $uri): void + { + $this->uri = $uri; + } + + /** + * @param RequestOptions $options + * + * @throws RestRequestException + */ + public function call(array $options = []): ResponseInterface + { + $options = $this->getOptions($options); + + if (!\in_array( + $options['method'], + ['HEAD', 'GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'PATCH'], + true + )) { + throw new RestRequestException(\sprintf('%s is not an HTTP method', $options['method'])); + } + + try { + return $this->httpClient->request( + $options['method'], + $this->getRequestUri($options), + $this->getRequestOptions($options), + ); + } catch (\Throwable $e) { + $this->logger->error( + 'Rest request failed', + [ + 'url' => $this->getRequestUri($options), + 'error' => $e->getMessage(), + ] + ); + throw new RestRequestException('Rest request failed', 0, $e); + } + } + + protected function configureOptions(OptionsResolver $resolver): void + { + $resolver->setRequired( + [ + 'url', + ] + ); + + $resolver->setDefaults( + [ + 'method' => 'GET', + 'sends' => 'application/json', + 'expects' => 'application/json', + 'url_parameters' => [], + 'headers' => [], + 'data' => null, + ] + ); + + $resolver->setAllowedTypes('url', ['string']); + $resolver->setAllowedTypes('method', ['string']); + $resolver->setAllowedTypes('sends', ['string']); + $resolver->setAllowedTypes('expects', ['string']); + $resolver->setAllowedTypes('url_parameters', ['array']); + $resolver->setAllowedTypes('headers', ['array']); + $resolver->setAllowedTypes('data', ['array', 'string', 'null']); + } + + /** + * @param RequestOptions $options + * + * @return RequestOptions + */ + protected function getOptions(array $options = []): array + { + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + + /** @var RequestOptions $resolved */ + $resolved = $resolver->resolve($options); + + return $resolved; + } + + /** + * @param RequestOptions $options + */ + protected function getRequestUri(array $options = []): string + { + return $this->replaceParametersInUri($this->constructUri($options), $options); + } + + /** + * @param RequestOptions $options + * + * @return array{ + * 'headers': array, + * 'json'?: array|string|null, + * 'query'?: array|string|null, + * 'body'?: array|string|null + * } + */ + protected function getRequestOptions(array $options = []): array + { + $requestOptions = []; + $requestOptions['headers'] = empty($options['headers']) ? [] : $options['headers']; + if (!empty($options['sends'])) { + $requestOptions['headers']['Content-Type'] = $options['sends']; + } + if (!empty($options['expects'])) { + $requestOptions['headers']['Accept'] = $options['expects']; + } + if ('POST' === $options['method'] && 'application/json' === $options['sends']) { + $requestOptions['json'] = $options['data']; + } elseif ('GET' === $options['method']) { + $requestOptions['query'] = $options['data']; + } else { + $requestOptions['body'] = $options['data']; + } + + return $requestOptions; + } + + /** + * @param RequestOptions $options + */ + protected function constructUri(array $options): string + { + $uri = ltrim((string) $options['url'], '/'); + + return \sprintf('%s/%s', $this->getApiUrl(), $uri); + } + + protected function getApiUrl(): string + { + return $this->geUri(); + } + + /** + * @param RequestOptions $options + */ + protected function replaceParametersInUri(string $uri, array $options = []): string + { + if ($options['url_parameters']) { + /** @var array $search */ + $search = array_keys($options['url_parameters']); + array_walk( + $search, + static function (&$item, $key) { + $item = '{'.$item.'}'; + } + ); + /** @var array $replace */ + $replace = array_values($options['url_parameters']); + array_walk( + $replace, + static function (&$item, $key) { + $item = rawurlencode($item); + } + ); + + $uri = str_replace($search, $replace, $uri); + } + + return $uri; + } +} diff --git a/src/Client/ClientInterface.php b/src/Client/ClientInterface.php new file mode 100644 index 0000000..b08bf2a --- /dev/null +++ b/src/Client/ClientInterface.php @@ -0,0 +1,36 @@ +findServices($container, __DIR__.'/../../config/services'); + } + + /** + * Recursively import config files into container. + */ + protected function findServices(ContainerBuilder $container, string $path, string $extension = 'yaml'): void + { + $finder = new Finder(); + $finder->in($path) + ->name('*.'.$extension)->files(); + $loader = new YamlFileLoader($container, new FileLocator($path)); + foreach ($finder as $file) { + $loader->load($file->getFilename()); + } + } +} diff --git a/Exception/MissingClientException.php b/src/Exception/MissingClientException.php similarity index 73% rename from Exception/MissingClientException.php rename to src/Exception/MissingClientException.php index 1e9c141..f6836e1 100644 --- a/Exception/MissingClientException.php +++ b/src/Exception/MissingClientException.php @@ -1,8 +1,11 @@ - */ diff --git a/Exception/RestException.php b/src/Exception/RestException.php similarity index 65% rename from Exception/RestException.php rename to src/Exception/RestException.php index c09f5a0..786a041 100644 --- a/Exception/RestException.php +++ b/src/Exception/RestException.php @@ -1,8 +1,11 @@ - */ class RestException extends \Exception { - } diff --git a/Exception/RestRequestException.php b/src/Exception/RestRequestException.php similarity index 65% rename from Exception/RestRequestException.php rename to src/Exception/RestRequestException.php index 7bc2287..574a97d 100644 --- a/Exception/RestRequestException.php +++ b/src/Exception/RestRequestException.php @@ -1,8 +1,11 @@ - */ class RestRequestException extends RestException { - } diff --git a/Registry/ClientRegistry.php b/src/Registry/ClientRegistry.php similarity index 58% rename from Registry/ClientRegistry.php rename to src/Registry/ClientRegistry.php index 17cf58f..828852c 100644 --- a/Registry/ClientRegistry.php +++ b/src/Registry/ClientRegistry.php @@ -1,8 +1,11 @@ - + * Holds all tagged rest client services. */ class ClientRegistry { /** @var ClientInterface[] */ - private $clients = []; + private array $clients = []; - /** - * @param ClientInterface $client - */ public function addClient(ClientInterface $client): void { - if (array_key_exists($client->getCode(), $this->getClients())) { + if (\array_key_exists($client->getCode(), $this->getClients())) { throw new \UnexpectedValueException("Client {$client->getCode()} is already defined"); } $this->clients[$client->getCode()] = $client; @@ -43,13 +41,9 @@ public function getClients(): array } /** - * @param string $code - * * @throws MissingClientException - * - * @return ClientInterface */ - public function getClient($code): ClientInterface + public function getClient(string $code): ClientInterface { if (!$this->hasClient($code)) { throw MissingClientException::create($code); @@ -58,13 +52,8 @@ public function getClient($code): ClientInterface return $this->getClients()[$code]; } - /** - * @param string $code - * - * @return bool - */ - public function hasClient($code): bool + public function hasClient(string $code): bool { - return array_key_exists($code, $this->getClients()); + return \array_key_exists($code, $this->getClients()); } } diff --git a/Task/RequestTask.php b/src/Task/RequestTask.php similarity index 52% rename from Task/RequestTask.php rename to src/Task/RequestTask.php index 0e6502f..d8b4b9b 100644 --- a/Task/RequestTask.php +++ b/src/Task/RequestTask.php @@ -1,8 +1,11 @@ - + * @phpstan-type Options array{ + * 'client': string, + * 'url': string, + * 'method': string, + * 'headers': array, + * 'url_parameters': array, + * 'data': array|string|null, + * 'sends': string, + * 'expects': string, + * 'valid_response_code': array, + * 'log_response': bool, + * } + * @phpstan-type RequestOptions array{ + * 'url': string, + * 'method': string, + * 'headers': array, + * 'url_parameters': array, + * 'sends': string, + * 'expects': string, + * 'data': array|string|null + * } */ class RequestTask extends AbstractConfigurableTask { - /** @var LoggerInterface */ - protected $logger; - - /** @var ClientRegistry */ - protected $registry; - - /** - * @param LoggerInterface $logger - * @param ClientRegistry $registry - */ - public function __construct(LoggerInterface $logger, ClientRegistry $registry) + public function __construct(protected LoggerInterface $logger, protected ClientRegistry $registry) { - $this->logger = $logger; - $this->registry = $registry; } /** - * {@inheritdoc} - * @param ProcessState $state - * * @throws MissingClientException - * @throws ExceptionInterface + * @throws ClientExceptionInterface + * @throws RedirectionExceptionInterface + * @throws ServerExceptionInterface + * @throws TransportExceptionInterface + * @throws \Throwable */ public function execute(ProcessState $state): void { + /** @var Options $options */ $options = $this->getOptions($state); $requestOptions = $this->getRequestOptions($state); @@ -61,45 +75,49 @@ public function execute(ProcessState $state): void "Sending request {$requestOptions['method']} to '{$requestOptions['url']}'", ['requestOptions' => $requestOptions] ); - $result = $this->registry->getClient($options['client'])->call($requestOptions); + $response = $this->registry->getClient($options['client'])->call($requestOptions); if ($options['log_response']) { $this->logger->debug( "Response received from '{$options['url']}'", [ 'requestOptions' => $requestOptions, - 'result' => $result, + 'result' => $response, ] ); } // Handle empty results - if (!\in_array($result->code, $options['valid_response_code'], false)) { + try { + if (!\in_array($response->getStatusCode(), $options['valid_response_code'], false)) { + $state->setErrorOutput($response->getContent()); + + if (TaskConfiguration::STRATEGY_SKIP === $state->getTaskConfiguration()->getErrorStrategy()) { + $state->setSkipped(true); + } elseif (TaskConfiguration::STRATEGY_STOP === $state->getTaskConfiguration()->getErrorStrategy()) { + $state->setStopped(true); + } + + throw new \Exception('Invalid response code'); + } + + $state->setOutput($response->getContent()); + } catch (\Throwable $e) { $this->logger->error( 'REST request failed', [ 'client' => $options['client'], 'options' => $options, - 'raw_headers' => $result->raw_headers, - 'raw_body' => $result->raw_body, + 'message' => $e->getMessage(), + 'raw_headers' => $response->getHeaders(false), + 'raw_body' => $response->getContent(false), ] ); - $state->setErrorOutput($result->body); - if ($state->getTaskConfiguration()->getErrorStrategy() === TaskConfiguration::STRATEGY_SKIP) { - $state->setSkipped(true); - } elseif ($state->getTaskConfiguration()->getErrorStrategy() === TaskConfiguration::STRATEGY_STOP) { - $state->setStopped(true); - } - - return; + throw $e; } - - $state->setOutput($result->body); } /** - * @param OptionsResolver $resolver - * * @throws UndefinedOptionsException * @throws AccessException */ @@ -116,9 +134,9 @@ protected function configureOptions(OptionsResolver $resolver): void [ 'headers' => [], 'url_parameters' => [], - 'query_parameters' => [], - 'sends' => 'json', - 'expects' => 'json', + 'data' => null, + 'sends' => 'application/json', + 'expects' => 'application/json', 'valid_response_code' => [200], 'log_response' => false, ] @@ -132,28 +150,29 @@ protected function configureOptions(OptionsResolver $resolver): void } /** - * @param ProcessState $state - * - * @throws ExceptionInterface - * - * @return array + * @return RequestOptions */ protected function getRequestOptions(ProcessState $state): array { + /** @var Options $options */ $options = $this->getOptions($state); $requestOptions = [ - 'method' => $options['method'], 'url' => $options['url'], + 'method' => $options['method'], 'headers' => $options['headers'], 'url_parameters' => $options['url_parameters'], - 'query_parameters' => $options['query_parameters'], 'sends' => $options['sends'], 'expects' => $options['expects'], + 'data' => $options['data'], ]; + /** @var array $input */ $input = $state->getInput() ?: []; - return array_merge($requestOptions, $input); + /** @var RequestOptions $mergedOptions */ + $mergedOptions = array_merge($requestOptions, $input); + + return $mergedOptions; } } diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..e69de29