From ec059885c2675c72061205983a51e11a63adf038 Mon Sep 17 00:00:00 2001 From: Dominic Tubach Date: Thu, 26 Oct 2023 15:29:41 +0200 Subject: [PATCH 1/2] Add generic CiviCRM entity implementation --- .github/workflows/phpcs_entity.yml | 43 +++++ .github/workflows/phpstan_entity.yml | 49 ++++++ .gitignore | 6 + composer.json | 1 + modules/civiremote_entity/ci/README.md | 2 + modules/civiremote_entity/ci/composer.json | 17 ++ .../civiremote_entity.info.yml | 11 ++ .../civiremote_entity.services.yml | 31 ++++ modules/civiremote_entity/composer.json | 82 ++++++++++ modules/civiremote_entity/phpcs.xml.dist | 83 ++++++++++ modules/civiremote_entity/phpstan.ci.neon | 8 + modules/civiremote_entity/phpstan.neon.dist | 30 ++++ modules/civiremote_entity/phpunit.xml.dist | 62 ++++++++ .../src/Access/RemoteContactIdProvider.php | 46 ++++++ .../RemoteContactIdProviderInterface.php | 27 ++++ .../src/Api/AbstractEntityApi.php | 125 +++++++++++++++ .../src/Api/CiviCRMApiClient.php | 73 +++++++++ .../src/Api/CiviCRMApiClientInterface.php | 54 +++++++ .../Api/Exception/ApiCallFailedException.php | 45 ++++++ .../src/Api/Exception/ExceptionInterface.php | 24 +++ .../src/Api/Form/EntityForm.php | 58 +++++++ .../src/Api/Form/FormSubmitResponse.php | 42 +++++ .../src/Api/Form/FormValidationResponse.php | 61 +++++++ .../src/Form/AbstractEntityForm.php | 149 ++++++++++++++++++ .../EntityCreateFormRequestHandler.php | 63 ++++++++ .../EntityUpdateFormRequestHandler.php | 72 +++++++++ .../FormRequestHandlerInterface.php | 57 +++++++ .../FormResponseHandlerChain.php | 46 ++++++ .../FormResponseHandlerInterface.php | 30 ++++ .../FormResponseHandlerMessage.php | 42 +++++ .../tools/phpstan/composer.json | 18 +++ 31 files changed, 1457 insertions(+) create mode 100644 .github/workflows/phpcs_entity.yml create mode 100644 .github/workflows/phpstan_entity.yml create mode 100644 modules/civiremote_entity/ci/README.md create mode 100644 modules/civiremote_entity/ci/composer.json create mode 100644 modules/civiremote_entity/civiremote_entity.info.yml create mode 100644 modules/civiremote_entity/civiremote_entity.services.yml create mode 100644 modules/civiremote_entity/composer.json create mode 100644 modules/civiremote_entity/phpcs.xml.dist create mode 100644 modules/civiremote_entity/phpstan.ci.neon create mode 100644 modules/civiremote_entity/phpstan.neon.dist create mode 100644 modules/civiremote_entity/phpunit.xml.dist create mode 100644 modules/civiremote_entity/src/Access/RemoteContactIdProvider.php create mode 100644 modules/civiremote_entity/src/Access/RemoteContactIdProviderInterface.php create mode 100644 modules/civiremote_entity/src/Api/AbstractEntityApi.php create mode 100644 modules/civiremote_entity/src/Api/CiviCRMApiClient.php create mode 100644 modules/civiremote_entity/src/Api/CiviCRMApiClientInterface.php create mode 100644 modules/civiremote_entity/src/Api/Exception/ApiCallFailedException.php create mode 100644 modules/civiremote_entity/src/Api/Exception/ExceptionInterface.php create mode 100644 modules/civiremote_entity/src/Api/Form/EntityForm.php create mode 100644 modules/civiremote_entity/src/Api/Form/FormSubmitResponse.php create mode 100644 modules/civiremote_entity/src/Api/Form/FormValidationResponse.php create mode 100644 modules/civiremote_entity/src/Form/AbstractEntityForm.php create mode 100644 modules/civiremote_entity/src/Form/RequestHandler/EntityCreateFormRequestHandler.php create mode 100644 modules/civiremote_entity/src/Form/RequestHandler/EntityUpdateFormRequestHandler.php create mode 100644 modules/civiremote_entity/src/Form/RequestHandler/FormRequestHandlerInterface.php create mode 100644 modules/civiremote_entity/src/Form/ResponseHandler/FormResponseHandlerChain.php create mode 100644 modules/civiremote_entity/src/Form/ResponseHandler/FormResponseHandlerInterface.php create mode 100644 modules/civiremote_entity/src/Form/ResponseHandler/FormResponseHandlerMessage.php create mode 100644 modules/civiremote_entity/tools/phpstan/composer.json diff --git a/.github/workflows/phpcs_entity.yml b/.github/workflows/phpcs_entity.yml new file mode 100644 index 0000000..273de8d --- /dev/null +++ b/.github/workflows/phpcs_entity.yml @@ -0,0 +1,43 @@ +name: PHP_CodeSniffer - civiremote_entity + +on: + push: ~ + pull_request: + branches: [ main ] + +jobs: + phpcs: + runs-on: ubuntu-latest + name: PHP_CodeSniffer + defaults: + run: + working-directory: modules/civiremote_entity + + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.2 + coverage: none + tools: cs2pr + env: + fail-fast: true + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('modules/civiremote_entity/tools/phpcs/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer update --no-progress --prefer-dist + + - name: Run PHP_CodeSniffer + run: composer phpcs -- -q --report=checkstyle | cs2pr diff --git a/.github/workflows/phpstan_entity.yml b/.github/workflows/phpstan_entity.yml new file mode 100644 index 0000000..7679006 --- /dev/null +++ b/.github/workflows/phpstan_entity.yml @@ -0,0 +1,49 @@ +name: PHPStan - civiremote_entity + +on: + push: ~ + pull_request: + branches: [ main ] + +jobs: + phpstan: + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: ['7.4', '8.0', '8.2'] + prefer: ['prefer-stable', 'prefer-lowest'] + name: PHPStan with PHP ${{ matrix.php-versions }} ${{ matrix.prefer }} + defaults: + run: + working-directory: modules/civiremote_entity + + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + coverage: none + env: + fail-fast: true + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ matrix.prefer }}-${{ hashFiles('modules/civiremote_entity/**/composer.json') }} + restore-keys: ${{ runner.os }}-composer-${{ matrix.prefer }}- + + - name: Install dependencies + run: | + composer update --no-progress --prefer-dist --${{ matrix.prefer }} --optimize-autoloader && + composer composer-phpstan -- update --no-progress --prefer-dist --optimize-autoloader && + composer --working-dir=ci update --no-progress --prefer-dist --${{ matrix.prefer }} --optimize-autoloader + + - name: Run PHPStan + run: composer phpstan -- analyse -c phpstan.ci.neon diff --git a/.gitignore b/.gitignore index 509d989..aac83a4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ +composer.lock +vendor/ +.phpcs.cache +.phpstan/ +.phpunit.result.cache +phpstan.neon translations/*.mo diff --git a/composer.json b/composer.json index 97bc365..368f503 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ ], "homepage": "https://github.com/systopia/civiremote", "require": { + "beberlei/assert": "*", "drupal/cmrf_core": "^1.0@beta | ^2.0@beta" } } diff --git a/modules/civiremote_entity/ci/README.md b/modules/civiremote_entity/ci/README.md new file mode 100644 index 0000000..0bfc584 --- /dev/null +++ b/modules/civiremote_entity/ci/README.md @@ -0,0 +1,2 @@ +The dependencies specified in composer.json of this directory are required to +run phpstan in CI. diff --git a/modules/civiremote_entity/ci/composer.json b/modules/civiremote_entity/ci/composer.json new file mode 100644 index 0000000..c4687b3 --- /dev/null +++ b/modules/civiremote_entity/ci/composer.json @@ -0,0 +1,17 @@ +{ + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "sort-packages": true + }, + "repositories": [ + { + "type": "composer", + "url": "https://packages.drupal.org/8" + } + ], + "require": { + "drupal/cmrf_core": "^2.0", + "drupal/core": "^9.5 || ^10" + } +} diff --git a/modules/civiremote_entity/civiremote_entity.info.yml b/modules/civiremote_entity/civiremote_entity.info.yml new file mode 100644 index 0000000..1516b08 --- /dev/null +++ b/modules/civiremote_entity/civiremote_entity.info.yml @@ -0,0 +1,11 @@ +name: CiviRemote Entity +type: module +description: 'CiviRemote Entity' +package: CiviCRM +core_version_requirement: ^9.5 || ^10 +dependencies: + - civiremote + - json_forms + +project: civiremote +version: 0.1-dev diff --git a/modules/civiremote_entity/civiremote_entity.services.yml b/modules/civiremote_entity/civiremote_entity.services.yml new file mode 100644 index 0000000..8028da1 --- /dev/null +++ b/modules/civiremote_entity/civiremote_entity.services.yml @@ -0,0 +1,31 @@ +parameters: + civiremote.cmrf_connector_config_key: cmrf_connector + +services: + _defaults: + autowire: true + public: false # Controller classes and services directly fetched from container need to be public + + civiremote.civiremote.settings: + class: Drupal\Core\Config\ImmutableConfig + factory: [ 'Drupal', 'config' ] + arguments: [ 'civiremote.settings' ] + + Drupal\civiremote_entity\Api\CiviCRMApiClientInterface: + class: Drupal\civiremote_entity\Api\CiviCRMApiClient + factory: [ 'Drupal\civiremote_entity\Api\CiviCRMApiClient', 'create' ] + arguments: + $cmrfCore: '@cmrf_core.core' + $config: '@civiremote.civiremote.settings' + $connectorConfigKey: '%civiremote.cmrf_connector_config_key%' + + Drupal\civiremote_entity\Access\RemoteContactIdProviderInterface: + class: Drupal\civiremote_entity\Access\RemoteContactIdProvider + arguments: + - '@current_user' + + Drupal\civiremote_entity\Form\ResponseHandler\FormResponseHandlerInterface: + class: Drupal\civiremote_entity\Form\ResponseHandler\FormResponseHandlerMessage + arguments: + $messenger: '@messenger' + public: true diff --git a/modules/civiremote_entity/composer.json b/modules/civiremote_entity/composer.json new file mode 100644 index 0000000..d358370 --- /dev/null +++ b/modules/civiremote_entity/composer.json @@ -0,0 +1,82 @@ +{ + "name": "custom/civiremote_entity", + "description": "Base module to access CiviCRM Remote Entities.", + "type": "drupal-custom-module", + "license": "AGPL-3.0-only", + "authors": [ + { + "name": "SYSTOPIA GmbH", + "email": "info@systopia.de" + } + ], + "autoload": { + "psr-4": { + "Drupal\\civiremote_entity\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Drupal\\Tests\\civiremote_entity\\": "tests/src/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "phpstan/extension-installer": true + } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/systopia/drupal-json_forms.git" + }, + { + "type": "vcs", + "url": "https://github.com/systopia/opis-json-schema-ext.git" + }, + { + "type": "vcs", + "url": "https://github.com/systopia/expression-language-ext.git" + }, + { + "type": "composer", + "url": "https://packages.drupal.org/8" + } + ], + "require": { + "php": "^7.4 || ^8", + "beberlei/assert": "*", + "drupal/json_forms": "~0.1" + }, + "require-dev": { + "drupal/core-dev": "^9.5 || ^10" + }, + "scripts": { + "composer-phpstan": [ + "@composer --working-dir=tools/phpstan" + ], + "composer-tools": [ + "@composer-phpstan" + ], + "phpcs": [ + "@php vendor/bin/phpcs" + ], + "phpcbf": [ + "@php vendor/bin/phpcbf" + ], + "phpstan": [ + "@php tools/phpstan/vendor/bin/phpstan" + ], + "phpunit": [ + "@php vendor/bin/phpunit --coverage-text" + ], + "test": [ + "@phpcs", + "@phpstan", + "@phpunit" + ] + } +} diff --git a/modules/civiremote_entity/phpcs.xml.dist b/modules/civiremote_entity/phpcs.xml.dist new file mode 100644 index 0000000..40ce349 --- /dev/null +++ b/modules/civiremote_entity/phpcs.xml.dist @@ -0,0 +1,83 @@ + + + + src + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/civiremote_entity/phpstan.ci.neon b/modules/civiremote_entity/phpstan.ci.neon new file mode 100644 index 0000000..d051f46 --- /dev/null +++ b/modules/civiremote_entity/phpstan.ci.neon @@ -0,0 +1,8 @@ +includes: + - phpstan.neon.dist + +parameters: + bootstrapFiles: + - ci/vendor/autoload.php + scanFiles: + - ci/vendor/drupal/core/tests/Drupal/Tests/UnitTestCase.php diff --git a/modules/civiremote_entity/phpstan.neon.dist b/modules/civiremote_entity/phpstan.neon.dist new file mode 100644 index 0000000..84c7638 --- /dev/null +++ b/modules/civiremote_entity/phpstan.neon.dist @@ -0,0 +1,30 @@ +parameters: + paths: + - src + #- tests + #- civiremote_entity.module + bootstrapFiles: + - vendor/autoload.php + scanDirectories: + - ../../src + level: 9 + checkTooWideReturnTypesInProtectedAndPublicMethods: true + checkUninitializedProperties: true + checkMissingCallableSignature: true + treatPhpDocTypesAsCertain: false + exceptions: + check: + missingCheckedExceptionInThrows: true + tooWideThrowType: true + checkedExceptionClasses: + - \Assert\AssertionFailedException + implicitThrows: false + ignoreErrors: + # Note paths are prefixed with ""*/" to work with inspections in PHPStorm because of: + # https://youtrack.jetbrains.com/issue/WI-63891/PHPStan-ignoreErrors-configuration-isnt-working-with-inspections + - '/^Parameter #1 \$form \(array\) of method [^\s]+::(build|validate|submit)Form\(\) should be contravariant with parameter \$form \(array\) of method Drupal\\Core\\Form\\(FormInterface|FormBase)::(build|validate|submit)Form\(\)$/' + - + message: '/^Parameter #1 \$value of static method Drupal\\civiremote_entity\\Api\\[^:]+::fromApiResultValue\(\) expects array\{.+\}, array given.$/' + path: */Api/AbstractEntityApi.php + + tmpDir: .phpstan diff --git a/modules/civiremote_entity/phpunit.xml.dist b/modules/civiremote_entity/phpunit.xml.dist new file mode 100644 index 0000000..6545ac2 --- /dev/null +++ b/modules/civiremote_entity/phpunit.xml.dist @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tests/src/Unit + + + + + + + + + + + + src + + + + diff --git a/modules/civiremote_entity/src/Access/RemoteContactIdProvider.php b/modules/civiremote_entity/src/Access/RemoteContactIdProvider.php new file mode 100644 index 0000000..bf734d7 --- /dev/null +++ b/modules/civiremote_entity/src/Access/RemoteContactIdProvider.php @@ -0,0 +1,46 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Access; + +use Assert\Assertion; +use Drupal\Core\Session\AccountProxyInterface; + +final class RemoteContactIdProvider implements RemoteContactIdProviderInterface { + + private AccountProxyInterface $currentUser; + + public function __construct(AccountProxyInterface $currentUser) { + $this->currentUser = $currentUser; + } + + public function getRemoteContactId(): string { + $account = $this->currentUser->getAccount(); + Assertion::propertyExists($account, 'civiremote_id'); + // @phpstan-ignore-next-line + $remoteContactId = $account->civiremote_id; + + Assertion::string($remoteContactId); + Assertion::notEmpty($remoteContactId); + + return $remoteContactId; + } + +} diff --git a/modules/civiremote_entity/src/Access/RemoteContactIdProviderInterface.php b/modules/civiremote_entity/src/Access/RemoteContactIdProviderInterface.php new file mode 100644 index 0000000..787c421 --- /dev/null +++ b/modules/civiremote_entity/src/Access/RemoteContactIdProviderInterface.php @@ -0,0 +1,27 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Access; + +interface RemoteContactIdProviderInterface { + + public function getRemoteContactId(): string; + +} diff --git a/modules/civiremote_entity/src/Api/AbstractEntityApi.php b/modules/civiremote_entity/src/Api/AbstractEntityApi.php new file mode 100644 index 0000000..d320761 --- /dev/null +++ b/modules/civiremote_entity/src/Api/AbstractEntityApi.php @@ -0,0 +1,125 @@ +. + */ + +declare(strict_types = 1); + +namespace Drupal\civiremote_entity\Api; + +use Drupal\civiremote_entity\Access\RemoteContactIdProviderInterface; +use Drupal\civiremote_entity\Api\Form\EntityForm; +use Drupal\civiremote_entity\Api\Form\FormSubmitResponse; +use Drupal\civiremote_entity\Api\Form\FormValidationResponse; + +abstract class AbstractEntityApi { + + protected CiviCRMApiClientInterface $client; + + protected RemoteContactIdProviderInterface $remoteContactIdProvider; + + public function __construct( + CiviCRMApiClientInterface $client, + RemoteContactIdProviderInterface $remoteContactIdProvider + ) { + $this->client = $client; + $this->remoteContactIdProvider = $remoteContactIdProvider; + } + + /** + * @phpstan-param array $arguments JSON serializable. + */ + public function getCreateForm(string $profile, array $arguments = []): EntityForm { + $result = $this->client->executeV4($this->getRemoteEntityName(), 'getUpdateForm', [ + 'profile' => $profile, + 'arguments' => $arguments, + 'remoteContactId' => $this->remoteContactIdProvider->getRemoteContactId(), + ]); + + return EntityForm::fromApiResultValue($result['values']); + } + + /** + * @phpstan-param array $data JSON serializable. + * @phpstan-param array $arguments JSON serializable. + */ + public function submitCreateForm(string $profile, array $data, array $arguments = []): FormSubmitResponse { + $result = $this->client->executeV4($this->getRemoteEntityName(), 'submitUpdateForm', [ + 'profile' => $profile, + 'data' => $data, + 'arguments' => $arguments, + 'remoteContactId' => $this->remoteContactIdProvider->getRemoteContactId(), + ]); + + return FormSubmitResponse::fromApiResultValue($result['values']); + } + + /** + * @phpstan-param array $data JSON serializable. + * @phpstan-param array $arguments JSON serializable. + */ + public function validateCreateForm(string $profile, array $data, array $arguments = []): FormValidationResponse { + $result = $this->client->executeV4($this->getRemoteEntityName(), 'validateUpdateForm', [ + 'profile' => $profile, + 'data' => $data, + 'arguments' => $arguments, + 'remoteContactId' => $this->remoteContactIdProvider->getRemoteContactId(), + ]); + + return FormValidationResponse::fromApiResultValue($result['values']); + } + + public function getUpdateForm(string $profile, int $id): EntityForm { + $result = $this->client->executeV4($this->getRemoteEntityName(), 'getUpdateForm', [ + 'profile' => $profile, + 'id' => $id, + 'remoteContactId' => $this->remoteContactIdProvider->getRemoteContactId(), + ]); + + return EntityForm::fromApiResultValue($result['values']); + } + + /** + * @phpstan-param array $data JSON serializable. + */ + public function submitUpdateForm(string $profile, int $id, array $data): FormSubmitResponse { + $result = $this->client->executeV4($this->getRemoteEntityName(), 'submitUpdateForm', [ + 'profile' => $profile, + 'id' => $id, + 'data' => $data, + 'remoteContactId' => $this->remoteContactIdProvider->getRemoteContactId(), + ]); + + return FormSubmitResponse::fromApiResultValue($result['values']); + } + + /** + * @phpstan-param array $data JSON serializable. + */ + public function validateUpdateForm(string $profile, int $id, array $data): FormValidationResponse { + $result = $this->client->executeV4($this->getRemoteEntityName(), 'validateUpdateForm', [ + 'profile' => $profile, + 'id' => $id, + 'data' => $data, + 'remoteContactId' => $this->remoteContactIdProvider->getRemoteContactId(), + ]); + + return FormValidationResponse::fromApiResultValue($result['values']); + } + + abstract protected function getRemoteEntityName(): string; + +} diff --git a/modules/civiremote_entity/src/Api/CiviCRMApiClient.php b/modules/civiremote_entity/src/Api/CiviCRMApiClient.php new file mode 100644 index 0000000..c3e10d3 --- /dev/null +++ b/modules/civiremote_entity/src/Api/CiviCRMApiClient.php @@ -0,0 +1,73 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Api; + +use Assert\Assertion; +use CMRF\Core\Call; +use CMRF\Core\Core; +use Drupal\civiremote_entity\Api\Exception\ApiCallFailedException; +use Drupal\Core\Config\ImmutableConfig; + +final class CiviCRMApiClient implements CiviCRMApiClientInterface { + + private Core $cmrfCore; + + private string $connectorId; + + public static function create( + Core $cmrfCore, + ImmutableConfig $config, + string $connectorConfigKey + ): self { + $connectorId = $config->get($connectorConfigKey); + Assertion::string($connectorId); + + return new static($cmrfCore, $connectorId); + } + + public function __construct(Core $cmrfCore, string $connectorId) { + $this->cmrfCore = $cmrfCore; + $this->connectorId = $connectorId; + } + + public function executeV3(string $entity, string $action, array $parameters = [], array $options = []): array { + $call = $this->cmrfCore->createCallV3($this->connectorId, $entity, $action, $parameters, $options); + + $result = $this->cmrfCore->executeCall($call); + if (NULL === $result || Call::STATUS_FAILED === $call->getStatus()) { + throw ApiCallFailedException::fromCall($call); + } + + return $result; + } + + public function executeV4(string $entity, string $action, array $parameters = []): array { + $call = $this->cmrfCore->createCallV4($this->connectorId, $entity, $action, $parameters); + + $result = $this->cmrfCore->executeCall($call); + if (NULL === $result || Call::STATUS_FAILED === $call->getStatus()) { + throw ApiCallFailedException::fromCall($call); + } + + return $result; + } + +} diff --git a/modules/civiremote_entity/src/Api/CiviCRMApiClientInterface.php b/modules/civiremote_entity/src/Api/CiviCRMApiClientInterface.php new file mode 100644 index 0000000..514e698 --- /dev/null +++ b/modules/civiremote_entity/src/Api/CiviCRMApiClientInterface.php @@ -0,0 +1,54 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Api; + +interface CiviCRMApiClientInterface { + + /** + * Execute a CiviCRM APIv3 call. + * + * @param string $entity + * @param string $action + * @param array $parameters + * JSON encodable array. + * @param array $options + * + * @return array JSON encodable array. + * + * @throws \Drupal\civiremote_entity\Api\Exception\ApiCallFailedException + */ + public function executeV3(string $entity, string $action, array $parameters = [], array $options = []): array; + + /** + * Execute a CiviCRM APIv4 call. + * + * @param string $entity + * @param string $action + * @param array $parameters + * JSON encodable array. + * + * @return array&array{values: array} JSON encodable array. + * + * @throws \Drupal\civiremote_entity\Api\Exception\ApiCallFailedException + */ + public function executeV4(string $entity, string $action, array $parameters = []): array; + +} diff --git a/modules/civiremote_entity/src/Api/Exception/ApiCallFailedException.php b/modules/civiremote_entity/src/Api/Exception/ApiCallFailedException.php new file mode 100644 index 0000000..13737a2 --- /dev/null +++ b/modules/civiremote_entity/src/Api/Exception/ApiCallFailedException.php @@ -0,0 +1,45 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Api\Exception; + +use CMRF\Core\Call; + +final class ApiCallFailedException extends \RuntimeException implements ExceptionInterface { + + private Call $call; + + public static function fromCall(Call $call): self { + /** @phpstan-var array{error_message: string, error_code: int|string} $reply */ + $reply = $call->getReply(); + + return new self($call, $reply['error_message'], (int) $reply['error_code']); + } + + public function __construct(Call $call, string $message = '', int $code = 0, \Throwable $previous = NULL) { + parent::__construct($message, $code, $previous); + $this->call = $call; + } + + public function getCall(): Call { + return $this->call; + } + +} diff --git a/modules/civiremote_entity/src/Api/Exception/ExceptionInterface.php b/modules/civiremote_entity/src/Api/Exception/ExceptionInterface.php new file mode 100644 index 0000000..9956106 --- /dev/null +++ b/modules/civiremote_entity/src/Api/Exception/ExceptionInterface.php @@ -0,0 +1,24 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Api\Exception; + +interface ExceptionInterface extends \Throwable { +} diff --git a/modules/civiremote_entity/src/Api/Form/EntityForm.php b/modules/civiremote_entity/src/Api/Form/EntityForm.php new file mode 100644 index 0000000..b998ad9 --- /dev/null +++ b/modules/civiremote_entity/src/Api/Form/EntityForm.php @@ -0,0 +1,58 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Api\Form; + +use Drupal\json_forms\Form\Util\JsonConverter; + +class EntityForm { + + private \stdClass $jsonSchema; + + private \stdClass $uiSchema; + + /** + * @phpstan-param array{jsonSchema: array, uiSchema: array} $value + */ + public static function fromApiResultValue(array $value): self { + $jsonSchema = JsonConverter::toStdClass($value['jsonSchema']); + $uiSchema = JsonConverter::toStdClass($value['uiSchema']); + + return new self($jsonSchema, $uiSchema); + } + + /** + * @param \stdClass $jsonSchema + * @param \stdClass $uiSchema + */ + public function __construct(\stdClass $jsonSchema, \stdClass $uiSchema) { + $this->jsonSchema = $jsonSchema; + $this->uiSchema = $uiSchema; + } + + public function getJsonSchema(): \stdClass { + return $this->jsonSchema; + } + + public function getUiSchema(): \stdClass { + return $this->uiSchema; + } + +} diff --git a/modules/civiremote_entity/src/Api/Form/FormSubmitResponse.php b/modules/civiremote_entity/src/Api/Form/FormSubmitResponse.php new file mode 100644 index 0000000..5841464 --- /dev/null +++ b/modules/civiremote_entity/src/Api/Form/FormSubmitResponse.php @@ -0,0 +1,42 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Api\Form; + +class FormSubmitResponse { + + private string $message; + + /** + * @phpstan-param array{message: string} $value + */ + public static function fromApiResultValue(array $value): self { + return new self($value['message']); + } + + public function __construct(string $message) { + $this->message = $message; + } + + public function getMessage(): string { + return $this->message; + } + +} diff --git a/modules/civiremote_entity/src/Api/Form/FormValidationResponse.php b/modules/civiremote_entity/src/Api/Form/FormValidationResponse.php new file mode 100644 index 0000000..26b0365 --- /dev/null +++ b/modules/civiremote_entity/src/Api/Form/FormValidationResponse.php @@ -0,0 +1,61 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Api\Form; + +class FormValidationResponse { + + /** + * @phpstan-var array> + */ + private array $errors; + + private bool $valid; + + /** + * @param bool $valid + * @param array> $errors + */ + public function __construct(bool $valid, array $errors) { + $this->errors = $errors; + $this->valid = $valid; + } + + /** + * @phpstan-param array{valid: bool, errors?: array>} $value + * + * @return self + */ + public static function fromApiResultValue(array $value): self { + return new self($value['valid'], $value['errors'] ?? []); + } + + /** + * @return array> + */ + public function getErrors(): array { + return $this->errors; + } + + public function isValid(): bool { + return $this->valid; + } + +} diff --git a/modules/civiremote_entity/src/Form/AbstractEntityForm.php b/modules/civiremote_entity/src/Form/AbstractEntityForm.php new file mode 100644 index 0000000..85eaeb5 --- /dev/null +++ b/modules/civiremote_entity/src/Form/AbstractEntityForm.php @@ -0,0 +1,149 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Form; + +use Assert\Assertion; +use Drupal\civiremote_entity\Api\Exception\ApiCallFailedException; +use Drupal\civiremote_entity\Api\Form\EntityForm; +use Drupal\civiremote_entity\Form\RequestHandler\FormRequestHandlerInterface; +use Drupal\civiremote_entity\Form\ResponseHandler\FormResponseHandlerInterface; +use Drupal\Core\Form\FormStateInterface; +use Drupal\json_forms\Form\AbstractJsonFormsForm; +use Drupal\json_forms\Form\FormArrayFactoryInterface; +use Drupal\json_forms\Form\Util\FieldNameUtil; +use Drupal\json_forms\Form\Validation\FormValidationMapperInterface; +use Drupal\json_forms\Form\Validation\FormValidatorInterface; +use Opis\JsonSchema\JsonPointer; + +abstract class AbstractEntityForm extends AbstractJsonFormsForm { + + protected FormRequestHandlerInterface $formRequestHandler; + + protected FormResponseHandlerInterface $formResponseHandler; + + public function __construct(FormArrayFactoryInterface $formArrayFactory, + FormValidatorInterface $formValidator, + FormValidationMapperInterface $formValidationMapper, + FormRequestHandlerInterface $formRequestHandler, + FormResponseHandlerInterface $formResponseHandler + ) { + parent::__construct($formArrayFactory, $formValidator, $formValidationMapper); + $this->formRequestHandler = $formRequestHandler; + $this->formResponseHandler = $formResponseHandler; + } + + /** + * @phpstan-param array $form + * @param \Drupal\Core\Form\FormStateInterface $form_state + * Note: Using underscore case is enforced by Drupal's argument resolver. + * + * @phpstan-return array + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + if (!$form_state->isCached()) { + try { + $entityForm = $this->formRequestHandler->getForm($this->getRequest()); + } + catch (ApiCallFailedException $e) { + $this->messenger()->addError($this->t('Loading form failed: @error', ['@error' => $e->getMessage()])); + $entityForm = new EntityForm(new \stdClass(), new \stdClass()); + } + $form_state->set('jsonSchema', $entityForm->getJsonSchema()); + $form_state->set('uiSchema', $entityForm->getUiSchema()); + } + + return $this->buildJsonFormsForm( + $form, + $form_state, + // @phpstan-ignore-next-line + $form_state->get('jsonSchema'), + // @phpstan-ignore-next-line + $form_state->get('uiSchema'), + ); + } + + public function validateForm(array &$form, FormStateInterface $formState): void { + parent::validateForm($form, $formState); + if (!$formState->isSubmitted() && !$formState->isValidationEnforced()) { + return; + } + + if ([] === $formState->getErrors()) { + $data = $this->getSubmittedData($formState); + try { + $validationResponse = $this->formRequestHandler->validateForm($this->getRequest(), $data); + } + catch (ApiCallFailedException $e) { + $formState->setErrorByName( + '', + $this->t('Error validating form: @error', ['@error' => $e->getMessage()])->render() + ); + + return; + } + + if (!$validationResponse->isValid()) { + $this->mapResponseErrors($validationResponse->getErrors(), $formState); + } + } + } + + /** + * {@inheritDoc} + * + * @phpstan-param array $form + */ + public function submitForm(array &$form, FormStateInterface $formState): void { + $data = $this->getSubmittedData($formState); + try { + $submitResponse = $this->formRequestHandler->submitForm($this->getRequest(), $data); + } + catch (ApiCallFailedException $e) { + $this->messenger()->addError($this->t('Submitting form failed: @error', ['@error' => $e->getMessage()])); + + return; + } + + $this->formResponseHandler->handleSubmitResponse($submitResponse, $formState); + } + + /** + * @phpstan-param array> $errors + */ + private function mapResponseErrors(array $errors, FormStateInterface $formState): void { + foreach ($errors as $field => $messages) { + $pointer = JsonPointer::parse('/' . $field); + Assertion::notNull($pointer); + $absolutePath = $pointer->absolutePath(); + Assertion::notNull($absolutePath); + $element = ['#parents' => FieldNameUtil::toFormParents($absolutePath)]; + $formState->setError($element, implode("\n", $messages)); + } + } + + protected function doGetSubmittedData(FormStateInterface $formState): array { + $data = parent::doGetSubmittedData($formState); + unset($data['_submit']); + + return $data; + } + +} diff --git a/modules/civiremote_entity/src/Form/RequestHandler/EntityCreateFormRequestHandler.php b/modules/civiremote_entity/src/Form/RequestHandler/EntityCreateFormRequestHandler.php new file mode 100644 index 0000000..e7f55c8 --- /dev/null +++ b/modules/civiremote_entity/src/Form/RequestHandler/EntityCreateFormRequestHandler.php @@ -0,0 +1,63 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Form\RequestHandler; + +use Assert\Assertion; +use Drupal\civiremote_entity\Api\AbstractEntityApi; +use Drupal\civiremote_entity\Api\Form\EntityForm; +use Drupal\civiremote_entity\Api\Form\FormSubmitResponse; +use Drupal\civiremote_entity\Api\Form\FormValidationResponse; +use Drupal\Core\Routing\RouteMatch; +use Symfony\Component\HttpFoundation\Request; + +class EntityCreateFormRequestHandler implements FormRequestHandlerInterface { + + protected AbstractEntityApi $entityApi; + + public function __construct(AbstractEntityApi $entityApi) { + $this->entityApi = $entityApi; + } + + public function getForm(Request $request): EntityForm { + $routeMatch = RouteMatch::createFromRequest($request); + $profile = $routeMatch->getParameter('profile'); + Assertion::string($profile); + + return $this->entityApi->getCreateForm($profile); + } + + public function validateForm(Request $request, array $data): FormValidationResponse { + $routeMatch = RouteMatch::createFromRequest($request); + $profile = $routeMatch->getParameter('profile'); + Assertion::string($profile); + + return $this->entityApi->validateCreateForm($profile, $data); + } + + public function submitForm(Request $request, array $data): FormSubmitResponse { + $routeMatch = RouteMatch::createFromRequest($request); + $profile = $routeMatch->getParameter('profile'); + Assertion::string($profile); + + return $this->entityApi->submitCreateForm($profile, $data); + } + +} diff --git a/modules/civiremote_entity/src/Form/RequestHandler/EntityUpdateFormRequestHandler.php b/modules/civiremote_entity/src/Form/RequestHandler/EntityUpdateFormRequestHandler.php new file mode 100644 index 0000000..62ad2a4 --- /dev/null +++ b/modules/civiremote_entity/src/Form/RequestHandler/EntityUpdateFormRequestHandler.php @@ -0,0 +1,72 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Form\RequestHandler; + +use Assert\Assertion; +use Drupal\civiremote_entity\Api\AbstractEntityApi; +use Drupal\civiremote_entity\Api\Form\EntityForm; +use Drupal\civiremote_entity\Api\Form\FormSubmitResponse; +use Drupal\civiremote_entity\Api\Form\FormValidationResponse; +use Drupal\Core\Routing\RouteMatch; +use Symfony\Component\HttpFoundation\Request; + +class EntityUpdateFormRequestHandler implements FormRequestHandlerInterface { + + protected AbstractEntityApi $activityApi; + + public function __construct(AbstractEntityApi $activityApi) { + $this->activityApi = $activityApi; + } + + public function getForm(Request $request): EntityForm { + $routeMatch = RouteMatch::createFromRequest($request); + $profile = $routeMatch->getParameter('profile'); + Assertion::string($profile); + $id = $routeMatch->getParameter('id'); + Assertion::integerish($id); + $id = (int) $id; + + return $this->activityApi->getUpdateForm($profile, $id); + } + + public function validateForm(Request $request, array $data): FormValidationResponse { + $routeMatch = RouteMatch::createFromRequest($request); + $profile = $routeMatch->getParameter('profile'); + Assertion::string($profile); + $id = $routeMatch->getParameter('id'); + Assertion::integerish($id); + $id = (int) $id; + + return $this->activityApi->validateUpdateForm($profile, $id, $data); + } + + public function submitForm(Request $request, array $data): FormSubmitResponse { + $routeMatch = RouteMatch::createFromRequest($request); + $profile = $routeMatch->getParameter('profile'); + Assertion::string($profile); + $id = $routeMatch->getParameter('id'); + Assertion::integerish($id); + $id = (int) $id; + + return $this->activityApi->submitUpdateForm($profile, $id, $data); + } + +} diff --git a/modules/civiremote_entity/src/Form/RequestHandler/FormRequestHandlerInterface.php b/modules/civiremote_entity/src/Form/RequestHandler/FormRequestHandlerInterface.php new file mode 100644 index 0000000..2e9260c --- /dev/null +++ b/modules/civiremote_entity/src/Form/RequestHandler/FormRequestHandlerInterface.php @@ -0,0 +1,57 @@ +. + */ + +declare(strict_types = 1); + +namespace Drupal\civiremote_entity\Form\RequestHandler; + +use Drupal\civiremote_entity\Api\Form\EntityForm; +use Drupal\civiremote_entity\Api\Form\FormSubmitResponse; +use Drupal\civiremote_entity\Api\Form\FormValidationResponse; +use Symfony\Component\HttpFoundation\Request; + +interface FormRequestHandlerInterface { + + /** + * @throws \Drupal\civiremote_entity\Api\Exception\ApiCallFailedException + */ + public function getForm(Request $request): EntityForm; + + /** + * @param \Symfony\Component\HttpFoundation\Request $request + * @param array $data + * JSON serializable array. + * + * @return \Drupal\civiremote_entity\Api\Form\FormValidationResponse + * + * @throws \Drupal\civiremote_entity\Api\Exception\ApiCallFailedException + */ + public function validateForm(Request $request, array $data): FormValidationResponse; + + /** + * @param \Symfony\Component\HttpFoundation\Request $request + * @param array $data + * JSON serializable array. + * + * @return \Drupal\civiremote_entity\Api\Form\FormSubmitResponse + * + * @throws \Drupal\civiremote_entity\Api\Exception\ApiCallFailedException + */ + public function submitForm(Request $request, array $data): FormSubmitResponse; + +} diff --git a/modules/civiremote_entity/src/Form/ResponseHandler/FormResponseHandlerChain.php b/modules/civiremote_entity/src/Form/ResponseHandler/FormResponseHandlerChain.php new file mode 100644 index 0000000..cd368c6 --- /dev/null +++ b/modules/civiremote_entity/src/Form/ResponseHandler/FormResponseHandlerChain.php @@ -0,0 +1,46 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Form\ResponseHandler; + +use Drupal\civiremote_entity\Api\Form\FormSubmitResponse; +use Drupal\Core\Form\FormStateInterface; + +/** + * @codeCoverageIgnore + */ +final class FormResponseHandlerChain implements FormResponseHandlerInterface { + + /** + * @var array + */ + private array $formResponseHandlers; + + public function __construct(FormResponseHandlerInterface ...$formResponseHandlers) { + $this->formResponseHandlers = $formResponseHandlers; + } + + public function handleSubmitResponse(FormSubmitResponse $submitResponse, FormStateInterface $formState): void { + foreach ($this->formResponseHandlers as $formResponseHandler) { + $formResponseHandler->handleSubmitResponse($submitResponse, $formState); + } + } + +} diff --git a/modules/civiremote_entity/src/Form/ResponseHandler/FormResponseHandlerInterface.php b/modules/civiremote_entity/src/Form/ResponseHandler/FormResponseHandlerInterface.php new file mode 100644 index 0000000..b4f4cbc --- /dev/null +++ b/modules/civiremote_entity/src/Form/ResponseHandler/FormResponseHandlerInterface.php @@ -0,0 +1,30 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Form\ResponseHandler; + +use Drupal\civiremote_entity\Api\Form\FormSubmitResponse; +use Drupal\Core\Form\FormStateInterface; + +interface FormResponseHandlerInterface { + + public function handleSubmitResponse(FormSubmitResponse $submitResponse, FormStateInterface $formState): void; + +} diff --git a/modules/civiremote_entity/src/Form/ResponseHandler/FormResponseHandlerMessage.php b/modules/civiremote_entity/src/Form/ResponseHandler/FormResponseHandlerMessage.php new file mode 100644 index 0000000..c66c405 --- /dev/null +++ b/modules/civiremote_entity/src/Form/ResponseHandler/FormResponseHandlerMessage.php @@ -0,0 +1,42 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Form\ResponseHandler; + +use Drupal\civiremote_entity\Api\Form\FormSubmitResponse; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Messenger\MessengerInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; + +class FormResponseHandlerMessage implements FormResponseHandlerInterface { + + use StringTranslationTrait; + + protected MessengerInterface $messenger; + + public function __construct(MessengerInterface $messenger) { + $this->messenger = $messenger; + } + + public function handleSubmitResponse(FormSubmitResponse $submitResponse, FormStateInterface $formState): void { + $this->messenger->addMessage($submitResponse->getMessage()); + } + +} diff --git a/modules/civiremote_entity/tools/phpstan/composer.json b/modules/civiremote_entity/tools/phpstan/composer.json new file mode 100644 index 0000000..f1a0233 --- /dev/null +++ b/modules/civiremote_entity/tools/phpstan/composer.json @@ -0,0 +1,18 @@ +{ + "require": { + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.7", + "phpstan/phpstan-beberlei-assert": "^1.0", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.1", + "phpstan/phpstan-strict-rules": "^1.2", + "thecodingmachine/phpstan-strict-rules": "^1.0", + "voku/phpstan-rules": "^3.0" + }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true + }, + "sort-packages": true + } +} From b21a4aeb7d41b1154c89b7ba13bc65fcd940d526 Mon Sep 17 00:00:00 2001 From: Dominic Tubach Date: Fri, 27 Oct 2023 10:27:07 +0200 Subject: [PATCH 2/2] EntityUpdateFormRequestHandler: Fix property name --- .../EntityUpdateFormRequestHandler.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/civiremote_entity/src/Form/RequestHandler/EntityUpdateFormRequestHandler.php b/modules/civiremote_entity/src/Form/RequestHandler/EntityUpdateFormRequestHandler.php index 62ad2a4..74a8e43 100644 --- a/modules/civiremote_entity/src/Form/RequestHandler/EntityUpdateFormRequestHandler.php +++ b/modules/civiremote_entity/src/Form/RequestHandler/EntityUpdateFormRequestHandler.php @@ -30,10 +30,10 @@ class EntityUpdateFormRequestHandler implements FormRequestHandlerInterface { - protected AbstractEntityApi $activityApi; + protected AbstractEntityApi $entityApi; - public function __construct(AbstractEntityApi $activityApi) { - $this->activityApi = $activityApi; + public function __construct(AbstractEntityApi $entityApi) { + $this->entityApi = $entityApi; } public function getForm(Request $request): EntityForm { @@ -44,7 +44,7 @@ public function getForm(Request $request): EntityForm { Assertion::integerish($id); $id = (int) $id; - return $this->activityApi->getUpdateForm($profile, $id); + return $this->entityApi->getUpdateForm($profile, $id); } public function validateForm(Request $request, array $data): FormValidationResponse { @@ -55,7 +55,7 @@ public function validateForm(Request $request, array $data): FormValidationRespo Assertion::integerish($id); $id = (int) $id; - return $this->activityApi->validateUpdateForm($profile, $id, $data); + return $this->entityApi->validateUpdateForm($profile, $id, $data); } public function submitForm(Request $request, array $data): FormSubmitResponse { @@ -66,7 +66,7 @@ public function submitForm(Request $request, array $data): FormSubmitResponse { Assertion::integerish($id); $id = (int) $id; - return $this->activityApi->submitUpdateForm($profile, $id, $data); + return $this->entityApi->submitUpdateForm($profile, $id, $data); } }