Skip to content

Commit

Permalink
APIv4 - When running validateValues(), fire an event
Browse files Browse the repository at this point in the history
  • Loading branch information
totten committed Apr 28, 2021
1 parent e7524a9 commit 4d54b8c
Show file tree
Hide file tree
Showing 4 changed files with 204 additions and 0 deletions.
165 changes: 165 additions & 0 deletions Civi/Api4/Event/ValidateValuesEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<?php

/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/

/**
*
* @package CRM
* @copyright CiviCRM LLC https://civicrm.org/licensing
*/

namespace Civi\Api4\Event;

use Symfony\Component\EventDispatcher\Event;

/**
* The ValidateValuesEvent ('civi.api4.validate') is emitted when creating or saving an entire record via APIv4.
* It is emitted once for every record is updated.
*
* Example #1: Walk each record and validate some fields
*
* function(ValidateValuesEvent $e) {
* if ($e->getEntity() !== 'Foozball') return;
* foreach ($e->getRecords() 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->getEntity() !== 'Contribution') return;
* $contactSubTypes = CRM_Utils_SQL_Select::from('civicrm_contact')
* ->where('id IN (#ids)', ['ids' => array_column($e->getRecords(), 'contact_id')])
* ->select('id, contact_sub_type')
* ->execute()->fetchMap('id', 'contact_sub_type');
* foreach ($e->getRecords() 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 Event {

/**
* Type of entity being validated.
*
* @var string
* Ex: 'Membership'
*/
protected $entity;

/**
* Key-value properties for a specific record.
*
* @var array
*/
protected $records;

/**
* List of error messages.
*
* @var array
* Array(string $errorName => string $errorMessage)
* Note:
*/
protected $errors = [];

/**
* ValidateValuesEvent constructor.
*
* @param string $entity
* @param array $records
* List of records to validate.
*/
public function __construct(string $entity, array $records) {
$this->entity = $entity;
$this->records = $records;
}

/**
* @return string
* Ex: 'Contact', 'Contribution', 'Mailing'
*/
public function getEntity(): string {
return $this->entity;
}

/**
* Get a list of records (with their new values).
*
* @return array
*/
public function getRecords(): array {
return $this->records;
}

// Tempting to add a 'getOldRecords()' and/or 'getEffectiveRecords()`. For updates, these would provide
// lazy-loading (ie call '{$entity}.get' in the same security-context and cache the result).
// Thus, most validations wouldn't require any extra I/O -- but if some did need I/O, then it could be minimized.

/**
* 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 (`getRecords()[$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;
}

/**
* @return array
* Each error has the format:
* - record: string|int
* - fields: string[]
* - name: string, symbolic name
* - message: string, printable message
*/
public function getErrors(): array {
return $this->errors;
}

/**
* Replace the list of errors.
*
* This is useful if you need to filter or rewrite the list.
*
* @param array $errors
* @return self
*/
public function setErrors(array $errors): self {
$this->errors = $errors;
return $this;
}

}
13 changes: 13 additions & 0 deletions Civi/Api4/Generic/AbstractCreateAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

namespace Civi\Api4\Generic;

use Civi\Api4\Event\ValidateValuesEvent;

/**
* Base class for all `Create` api actions.
*
Expand Down Expand Up @@ -59,10 +61,21 @@ public function addValue(string $fieldName, $value) {
* @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->getEntityName(), [$this->getValues()]);
\Civi::dispatcher()->dispatch('civi.api4.validate', $e);
\Civi::dispatcher()->dispatch('civi.api4.validate::' . $this->getEntityName(), $e);
if (!empty($e->getErrors())) {
throw new \API_Exception(ts('Found %1 error(s) in submitted record (%2): %3', [
1 => count($e->getErrors()),
2 => $this->getEntityName(),
3 => implode(', ', array_column($e->getErrors(), 'message')),
]));
}
}

}
14 changes: 14 additions & 0 deletions Civi/Api4/Generic/AbstractSaveAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

namespace Civi\Api4\Generic;

use Civi\Api4\Event\ValidateValuesEvent;

/**
* Create or update one or more $ENTITIES.
*
Expand Down Expand Up @@ -93,6 +95,7 @@ public function __construct($entityName, $actionName, $idField = 'id') {
* @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])) {
Expand All @@ -102,6 +105,17 @@ protected function validateValues() {
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->getEntityName(), $this->records);
\Civi::dispatcher()->dispatch('civi.api4.validate', $e);
\Civi::dispatcher()->dispatch('civi.api4.validate::' . $this->getEntityName(), $e);
if (!empty($e->getErrors())) {
throw new \API_Exception(ts('Found %1 error(s) in submitted %2 record(s) of type "%3": %4', [
1 => count($e->getErrors()),
2 => count(array_unique(array_column($e->getErrors(), 'record'))),
3 => $this->getEntityName(),
4 => implode(', ', array_column($e->getErrors(), 'message')),
]));
}
}

/**
Expand Down
12 changes: 12 additions & 0 deletions tests/phpunit/api/v4/Entity/ConformanceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

use Civi\Api4\Entity;
use api\v4\UnitTestCase;
use Civi\Api4\Event\ValidateValuesEvent;
use Civi\Api4\Utils\CoreUtil;

/**
Expand Down Expand Up @@ -198,6 +199,13 @@ protected function checkActions($entityClass): array {
* @return mixed
*/
protected function checkCreation($entity, $entityClass) {
$hookLog = [];
$onValidate = function(ValidateValuesEvent $e) use (&$hookLog) {
$hookLog[$e->getEntity()] = 1 + ($hookLog[$e->getEntity()] ?? 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)
Expand All @@ -210,6 +218,10 @@ protected function checkCreation($entity, $entityClass) {

$this->assertGreaterThanOrEqual(1, $id, "$entity ID not positive");

$this->assertEquals(2, $hookLog[$entity]);
\Civi::dispatcher()->removeListener('civi.api4.validate', $onValidate);
\Civi::dispatcher()->removeListener('civi.api4.validate::' . $entity, $onValidate);

return $id;
}

Expand Down

0 comments on commit 4d54b8c

Please sign in to comment.