From 4bf92107ce114979357dbda9edeede077638dfb8 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 27 Apr 2021 14:38:38 -0700 Subject: [PATCH] APIv4 - When running validateValues(), fire an event --- Civi/Api4/Event/ValidateValuesEvent.php | 141 ++++++++++++++++++ Civi/Api4/Generic/AbstractCreateAction.php | 8 + Civi/Api4/Generic/AbstractSaveAction.php | 8 + Civi/Api4/Generic/AbstractUpdateAction.php | 9 +- Civi/Core/Container.php | 7 + .../phpunit/api/v4/Entity/ConformanceTest.php | 12 ++ 6 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 Civi/Api4/Event/ValidateValuesEvent.php diff --git a/Civi/Api4/Event/ValidateValuesEvent.php b/Civi/Api4/Event/ValidateValuesEvent.php new file mode 100644 index 0000000000..146f29f2cb --- /dev/null +++ b/Civi/Api4/Event/ValidateValuesEvent.php @@ -0,0 +1,141 @@ +entity !== 'Foozball') return; + * foreach ($e->records as $r => $record) { + * if (strtotime($record['start_time']) < CRM_Utils_Time::time()) { + * $e->addError($r, 'start_time', 'past', ts('Start time has already passed.')); + * } + * if ($record['length'] * $record['width'] * $record['height'] > VOLUME_LIMIT) { + * $e->addError($r, ['length', 'width', 'height'], 'excessive_volume', ts('The record is too big.')); + * } + * } + * } + * + * Example #2: Prohibit recording `Contribution` records on `Student` contacts. + * + * function(ValidateValuesEvent $e) { + * if ($e->entity !== 'Contribution') return; + * $contactSubTypes = CRM_Utils_SQL_Select::from('civicrm_contact') + * ->where('id IN (#ids)', ['ids' => array_column($e->records, 'contact_id')]) + * ->select('id, contact_sub_type') + * ->execute()->fetchMap('id', 'contact_sub_type'); + * foreach ($e->records as $r => $record) { + * if ($contactSubTypes[$record['contact_id']] === 'Student') { + * $e->addError($r, 'contact_id', 'student_prohibited', ts('Donations from student records are strictly prohibited.')); + * } + * } + * } + */ +class ValidateValuesEvent extends GenericHookEvent { + + use RequestTrait; + + /** + * List of updated records. + * + * The list of `$records` reflects only the list of new values assigned + * by this action. It may or may not correspond to an existing row in the database. + * It is similar to the `$records` list used by `save()`. + * + * @var array|\CRM_Utils_LazyArray + * @see \Civi\Api4\Generic\AbstractSaveAction::$records + */ + public $records; + + /** + * List of error messages. + * + * @var array + * Array(string $errorName => string $errorMessage) + * Note: + */ + public $errors = []; + + /** + * ValidateValuesEvent constructor. + * + * @param \Civi\Api4\Generic\AbstractAction $apiRequest + * @param array|\CRM_Utils_LazyArray $records + * List of updates (akin to SaveAction::$records). + */ + public function __construct($apiRequest, $records) { + $this->setApiRequest($apiRequest); + $this->records = $records; + $this->errors = []; + } + + /** + * @inheritDoc + */ + public function getHookValues() { + return [$this->getApiRequest(), $this->records, &$this->errors]; + } + + /** + * Add an error. + * + * @param string|int $recordKey + * The validator may work with multiple records. This should identify the specific record. + * Each record is identified by its offset (`$records[$recordKey] === [...the record...]`). + * @param string|array $field + * The name of the field which has an error. + * If the error is multi-field (e.g. mismatched password-confirmation), then use an array. + * If the error is independent of any field, then use []. + * @param string $name + * @param string|NULL $message + * @return $this + */ + public function addError($recordKey, $field, string $name, string $message = NULL): self { + $this->errors[] = [ + 'record' => $recordKey, + 'fields' => (array) $field, + 'name' => $name, + 'message' => $message ?: ts('Error code (%1)', [1 => $name]), + ]; + return $this; + } + + /** + * Convert the list of errors an exception. + * + * @return \API_Exception + */ + public function toException() { + // We should probably have a better way to report the errors in a structured/list format. + return new \API_Exception(ts('Found %1 error(s) in submitted %2 record(s) of type "%3": %4', [ + 1 => count($this->errors), + 2 => count(array_unique(array_column($this->errors, 'record'))), + 3 => $this->getEntityName(), + 4 => implode(', ', array_column($this->errors, 'message')), + ])); + } + +} diff --git a/Civi/Api4/Generic/AbstractCreateAction.php b/Civi/Api4/Generic/AbstractCreateAction.php index c77236e979..5299741a8f 100644 --- a/Civi/Api4/Generic/AbstractCreateAction.php +++ b/Civi/Api4/Generic/AbstractCreateAction.php @@ -19,6 +19,8 @@ namespace Civi\Api4\Generic; +use Civi\Api4\Event\ValidateValuesEvent; + /** * Base class for all `Create` api actions. * @@ -59,10 +61,16 @@ abstract class AbstractCreateAction extends AbstractAction { * @throws \API_Exception */ protected function validateValues() { + // FIXME: There should be a protocol to report a full list of errors... Perhaps a subclass of API_Exception? $unmatched = $this->checkRequiredFields($this->getValues()); if ($unmatched) { throw new \API_Exception("Mandatory values missing from Api4 {$this->getEntityName()}::{$this->getActionName()}: " . implode(", ", $unmatched), "mandatory_missing", ["fields" => $unmatched]); } + $e = new ValidateValuesEvent($this, [$this->getValues()]); + \Civi::dispatcher()->dispatch('civi.api4.validate', $e); + if (!empty($e->errors)) { + throw $e->toException(); + } } } diff --git a/Civi/Api4/Generic/AbstractSaveAction.php b/Civi/Api4/Generic/AbstractSaveAction.php index b12dc33204..d67fa3c2c5 100644 --- a/Civi/Api4/Generic/AbstractSaveAction.php +++ b/Civi/Api4/Generic/AbstractSaveAction.php @@ -19,6 +19,8 @@ namespace Civi\Api4\Generic; +use Civi\Api4\Event\ValidateValuesEvent; + /** * Create or update one or more $ENTITIES. * @@ -93,6 +95,7 @@ abstract class AbstractSaveAction extends AbstractAction { * @throws \API_Exception */ protected function validateValues() { + // FIXME: There should be a protocol to report a full list of errors... Perhaps a subclass of API_Exception? $unmatched = []; foreach ($this->records as $record) { if (empty($record[$this->idField])) { @@ -102,6 +105,11 @@ abstract class AbstractSaveAction extends AbstractAction { if ($unmatched) { throw new \API_Exception("Mandatory values missing from Api4 {$this->getEntityName()}::{$this->getActionName()}: " . implode(", ", $unmatched), "mandatory_missing", ["fields" => $unmatched]); } + $e = new ValidateValuesEvent($this, $this->records); + \Civi::dispatcher()->dispatch('civi.api4.validate', $e); + if (!empty($e->errors)) { + throw $e->toException(); + } } /** diff --git a/Civi/Api4/Generic/AbstractUpdateAction.php b/Civi/Api4/Generic/AbstractUpdateAction.php index 0260fb2a89..f4fb2ec8f8 100644 --- a/Civi/Api4/Generic/AbstractUpdateAction.php +++ b/Civi/Api4/Generic/AbstractUpdateAction.php @@ -19,6 +19,8 @@ namespace Civi\Api4\Generic; +use Civi\Api4\Event\ValidateValuesEvent; + /** * Base class for all `Update` api actions * @@ -74,7 +76,12 @@ abstract class AbstractUpdateAction extends AbstractBatchAction { * @throws \API_Exception */ protected function validateValues() { - // Placeholder + // FIXME: There should be a protocol to report a full list of errors... Perhaps a subclass of API_Exception? + $e = new ValidateValuesEvent($this, [$this->values]); + \Civi::dispatcher()->dispatch('civi.api4.validate', $e); + if (!empty($e->errors)) { + throw $e->toException(); + } } } diff --git a/Civi/Core/Container.php b/Civi/Core/Container.php index 2785d04da8..8e2e926d1b 100644 --- a/Civi/Core/Container.php +++ b/Civi/Core/Container.php @@ -377,6 +377,13 @@ class Container { \Civi::dispatcher()->dispatch($eventName . "::" . $e->{$fieldName}, $e); }; }; + $aliasMethodEvent = function($eventName, $methodName) { + return function($e) use ($eventName, $methodName) { + \Civi::dispatcher()->dispatch($eventName . "::" . $e->{$methodName}(), $e); + }; + }; + + $dispatcher->addListener('civi.api4.validate', $aliasMethodEvent('civi.api4.validate', 'getEntityName'), 100); $dispatcher->addListener('civi.core.install', ['\Civi\Core\InstallationCanary', 'check']); $dispatcher->addListener('civi.core.install', ['\Civi\Core\DatabaseInitializer', 'initialize']); diff --git a/tests/phpunit/api/v4/Entity/ConformanceTest.php b/tests/phpunit/api/v4/Entity/ConformanceTest.php index 5ad31a132f..1ad972ab13 100644 --- a/tests/phpunit/api/v4/Entity/ConformanceTest.php +++ b/tests/phpunit/api/v4/Entity/ConformanceTest.php @@ -21,6 +21,7 @@ namespace api\v4\Entity; use Civi\Api4\Entity; use api\v4\UnitTestCase; +use Civi\Api4\Event\ValidateValuesEvent; use Civi\Api4\Utils\CoreUtil; /** @@ -205,6 +206,13 @@ class ConformanceTest extends UnitTestCase { * @return mixed */ protected function checkCreation($entity, $entityClass) { + $hookLog = []; + $onValidate = function(ValidateValuesEvent $e) use (&$hookLog) { + $hookLog[$e->entity][$e->action] = 1 + ($hookLog[$e->entity][$e->action] ?? 0); + }; + \Civi::dispatcher()->addListener('civi.api4.validate', $onValidate); + \Civi::dispatcher()->addListener('civi.api4.validate::' . $entity, $onValidate); + $requiredParams = $this->creationParamProvider->getRequired($entity); $createResult = $entityClass::create() ->setValues($requiredParams) @@ -217,6 +225,10 @@ class ConformanceTest extends UnitTestCase { $this->assertGreaterThanOrEqual(1, $id, "$entity ID not positive"); + $this->assertEquals(2, $hookLog[$entity]['create']); + \Civi::dispatcher()->removeListener('civi.api4.validate', $onValidate); + \Civi::dispatcher()->removeListener('civi.api4.validate::' . $entity, $onValidate); + return $id; } -- 2.25.1