mirror of
https://github.com/nextcloud/server.git
synced 2026-06-09 08:44:07 -04:00
feat: Add event filtering to webhooks
Signed-off-by: Côme Chilliet <come.chilliet@nextcloud.com>
This commit is contained in:
parent
5dd9c2f8e8
commit
144bdd73f9
11 changed files with 390 additions and 8 deletions
|
|
@ -16,5 +16,6 @@ return array(
|
|||
'OCA\\Webhooks\\Listener\\WebhooksEventListener' => $baseDir . '/../lib/Listener/WebhooksEventListener.php',
|
||||
'OCA\\Webhooks\\Migration\\Version1000Date20240527153425' => $baseDir . '/../lib/Migration/Version1000Date20240527153425.php',
|
||||
'OCA\\Webhooks\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
|
||||
'OCA\\Webhooks\\Service\\PHPMongoQuery' => $baseDir . '/../lib/Service/PHPMongoQuery.php',
|
||||
'OCA\\Webhooks\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ class ComposerStaticInitWebhooks
|
|||
'OCA\\Webhooks\\Listener\\WebhooksEventListener' => __DIR__ . '/..' . '/../lib/Listener/WebhooksEventListener.php',
|
||||
'OCA\\Webhooks\\Migration\\Version1000Date20240527153425' => __DIR__ . '/..' . '/../lib/Migration/Version1000Date20240527153425.php',
|
||||
'OCA\\Webhooks\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
|
||||
'OCA\\Webhooks\\Service\\PHPMongoQuery' => __DIR__ . '/..' . '/../lib/Service/PHPMongoQuery.php',
|
||||
'OCA\\Webhooks\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php',
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -26,14 +26,11 @@ class WebhookCall extends QueuedJob {
|
|||
}
|
||||
|
||||
protected function run($argument): void {
|
||||
[$event, $userId, $webhookId] = $argument;
|
||||
[$data, $webhookId] = $argument;
|
||||
$webhookListener = $this->mapper->getById($webhookId);
|
||||
$client = $this->clientService->newClient();
|
||||
$options = [];
|
||||
$options['body'] = json_encode([
|
||||
'event' => $event,
|
||||
'userid' => $userId,
|
||||
]);
|
||||
$options['body'] = json_encode($data);
|
||||
try {
|
||||
$response = $client->request($webhookListener->getHttpMethod(), $webhookListener->getUri(), $options);
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
|
|
|||
|
|
@ -31,7 +31,11 @@ class Index extends Base {
|
|||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$webhookListeners = array_map(
|
||||
fn (WebhookListener $listener) => $listener->jsonSerialize(),
|
||||
function (WebhookListener $listener): array {
|
||||
$data = $listener->jsonSerialize();
|
||||
$data['eventFilter'] = json_encode($data['eventFilter']);
|
||||
return $data;
|
||||
},
|
||||
$this->mapper->getAll()
|
||||
);
|
||||
$this->writeTableInOutputFormat($input, $output, $webhookListeners);
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ class WebhooksController extends OCSController {
|
|||
string $httpMethod,
|
||||
string $uri,
|
||||
string $event,
|
||||
?array $eventFilter,
|
||||
?array $headers,
|
||||
?string $authMethod,
|
||||
?array $authData,
|
||||
|
|
@ -101,6 +102,7 @@ class WebhooksController extends OCSController {
|
|||
$httpMethod,
|
||||
$uri,
|
||||
$event,
|
||||
$eventFilter,
|
||||
$headers,
|
||||
$authMethod,
|
||||
$authData,
|
||||
|
|
@ -142,6 +144,7 @@ class WebhooksController extends OCSController {
|
|||
string $httpMethod,
|
||||
string $uri,
|
||||
string $event,
|
||||
?array $eventFilter,
|
||||
?array $headers,
|
||||
?string $authMethod,
|
||||
?array $authData,
|
||||
|
|
@ -153,6 +156,7 @@ class WebhooksController extends OCSController {
|
|||
$httpMethod,
|
||||
$uri,
|
||||
$event,
|
||||
$eventFilter,
|
||||
$headers,
|
||||
$authMethod,
|
||||
$authData,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ class WebhookListener extends Entity implements \JsonSerializable {
|
|||
$this->addType('httpMethod', 'string');
|
||||
$this->addType('uri', 'string');
|
||||
$this->addType('event', 'string');
|
||||
$this->addType('eventFilter', 'json');
|
||||
$this->addType('headers', 'json');
|
||||
$this->addType('authMethod', 'string');
|
||||
$this->addType('authData', 'json');
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ class WebhookListenerMapper extends QBMapper {
|
|||
string $httpMethod,
|
||||
string $uri,
|
||||
string $event,
|
||||
?array $eventFilter,
|
||||
?array $headers,
|
||||
?string $authMethod,
|
||||
?array $authData,
|
||||
|
|
@ -71,6 +72,7 @@ class WebhookListenerMapper extends QBMapper {
|
|||
'httpMethod' => $httpMethod,
|
||||
'uri' => $uri,
|
||||
'event' => $event,
|
||||
'eventFilter' => $eventFilter ?? [],
|
||||
'headers' => $headers,
|
||||
'authMethod' => $authMethod ?? 'none',
|
||||
'authData' => $authData,
|
||||
|
|
@ -85,6 +87,7 @@ class WebhookListenerMapper extends QBMapper {
|
|||
string $httpMethod,
|
||||
string $uri,
|
||||
string $event,
|
||||
?array $eventFilter,
|
||||
?array $headers,
|
||||
?string $authMethod,
|
||||
?array $authData,
|
||||
|
|
@ -96,6 +99,7 @@ class WebhookListenerMapper extends QBMapper {
|
|||
'httpMethod' => $httpMethod,
|
||||
'uri' => $uri,
|
||||
'event' => $event,
|
||||
'eventFilter' => $eventFilter ?? [],
|
||||
'headers' => $headers,
|
||||
'authMethod' => $authMethod,
|
||||
'authData' => $authData,
|
||||
|
|
|
|||
|
|
@ -11,9 +11,12 @@ namespace OCA\Webhooks\Listener;
|
|||
|
||||
use OCA\Webhooks\BackgroundJobs\WebhookCall;
|
||||
use OCA\Webhooks\Db\WebhookListenerMapper;
|
||||
use OCA\Webhooks\Service\PHPMongoQuery;
|
||||
use OCP\BackgroundJob\IJobList;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\EventDispatcher\JsonSerializer;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
|
|
@ -25,15 +28,31 @@ class WebhooksEventListener implements IEventListener {
|
|||
private WebhookListenerMapper $mapper,
|
||||
private IJobList $jobList,
|
||||
private LoggerInterface $logger,
|
||||
private ?string $userId,
|
||||
private IUserSession $userSession,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(Event $event): void {
|
||||
$webhookListeners = $this->mapper->getByEvent($event::class);
|
||||
/** @var IUser */
|
||||
$user = $this->userSession->getUser();
|
||||
|
||||
foreach ($webhookListeners as $webhookListener) {
|
||||
$this->jobList->add(WebhookCall::class, [$this->serializeEvent($event), $this->userId, $webhookListener->getId(), time()]);
|
||||
// TODO add group membership to be able to filter on it
|
||||
$data = [
|
||||
'event' => $this->serializeEvent($event),
|
||||
'user' => JsonSerializer::serializeUser($user),
|
||||
'time' => time(),
|
||||
];
|
||||
if ($this->filterMatch($webhookListener->getEventFilter(), $data)) {
|
||||
$this->jobList->add(
|
||||
WebhookCall::class,
|
||||
[
|
||||
$data,
|
||||
$webhookListener->getId(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -61,4 +80,11 @@ class WebhooksEventListener implements IEventListener {
|
|||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
private function filterMatch(array $filter, array $data): bool {
|
||||
if ($filter === []) {
|
||||
return true;
|
||||
}
|
||||
return PHPMongoQuery::executeQuery($filter, $data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ class Version1000Date20240527153425 extends SimpleMigrationStep {
|
|||
$table->addColumn('event', Types::TEXT, [
|
||||
'notnull' => true,
|
||||
]);
|
||||
$table->addColumn('event_filter', Types::TEXT, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
$table->addColumn('headers', Types::TEXT, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
|
|
|
|||
340
apps/webhooks/lib/Service/PHPMongoQuery.php
Normal file
340
apps/webhooks/lib/Service/PHPMongoQuery.php
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2013 Akkroo Solutions Ltd
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Webhooks\Service;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* PHPMongoQuery implements MongoDB queries in PHP, allowing developers to query
|
||||
* a 'document' (an array containing data) against a Mongo query object,
|
||||
* returning a boolean value for pass or fail
|
||||
*/
|
||||
abstract class PHPMongoQuery {
|
||||
/**
|
||||
* Execute a mongo query on a set of documents and return the documents that pass the query
|
||||
*
|
||||
* @param array $query A boolean value or an array defining a query
|
||||
* @param array $documents The document to query
|
||||
* @param array $options Any options:
|
||||
* 'debug' - boolean - debug mode, verbose logging
|
||||
* 'logger' - \Psr\LoggerInterface - A logger instance that implements {@link https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md#3-psrlogloggerinterface PSR-3}
|
||||
* 'unknownOperatorCallback' - a callback to be called if an operator can't be found. The function definition is function($operator, $operatorValue, $field, $document). return true or false.
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function find(array $query, array $documents, array $options = []): array {
|
||||
if(empty($documents) || empty($query)) {
|
||||
return [];
|
||||
}
|
||||
$ret = [];
|
||||
$options['_shouldLog'] = !empty($options['logger']) && $options['logger'] instanceof \Psr\Log\LoggerInterface;
|
||||
$options['_debug'] = !empty($options['debug']);
|
||||
foreach ($documents as $doc) {
|
||||
if(static::_executeQuery($query, $doc, $options)) {
|
||||
$ret[] = $doc;
|
||||
}
|
||||
}
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Mongo query on a document
|
||||
*
|
||||
* @param mixed $query A boolean value or an array defining a query
|
||||
* @param array $document The document to query
|
||||
* @param array $options Any options:
|
||||
* 'debug' - boolean - debug mode, verbose logging
|
||||
* 'logger' - \Psr\LoggerInterface - A logger instance that implements {@link https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md#3-psrlogloggerinterface PSR-3}
|
||||
* 'unknownOperatorCallback' - a callback to be called if an operator can't be found. The function definition is function($operator, $operatorValue, $field, $document). return true or false.
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function executeQuery($query, array &$document, array $options = []): bool {
|
||||
$options['_shouldLog'] = !empty($options['logger']) && $options['logger'] instanceof \Psr\Log\LoggerInterface;
|
||||
$options['_debug'] = !empty($options['debug']);
|
||||
if($options['_debug'] && $options['_shouldLog']) {
|
||||
$options['logger']->debug('executeQuery called', ['query' => $query, 'document' => $document, 'options' => $options]);
|
||||
}
|
||||
|
||||
if(!is_array($query)) {
|
||||
return (bool)$query;
|
||||
}
|
||||
|
||||
return self::_executeQuery($query, $document, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal execute query
|
||||
*
|
||||
* This expects an array from the query and has an additional logical operator (for the root query object the logical operator is always $and so this is not required)
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private static function _executeQuery(array $query, array &$document, array $options = [], string $logicalOperator = '$and'): bool {
|
||||
if($logicalOperator !== '$and' && (!count($query) || !isset($query[0]))) {
|
||||
throw new Exception($logicalOperator.' requires nonempty array');
|
||||
}
|
||||
if($options['_debug'] && $options['_shouldLog']) {
|
||||
$options['logger']->debug('_executeQuery called', ['query' => $query, 'document' => $document, 'logicalOperator' => $logicalOperator]);
|
||||
}
|
||||
|
||||
// for the purpose of querying documents, we are going to specify that an indexed array is an array which
|
||||
// only contains numeric keys, is sequential, the first key is zero, and not empty. This will allow us
|
||||
// to detect an array of key->vals that have numeric IDs vs an array of queries (where keys were not specified)
|
||||
$queryIsIndexedArray = !empty($query) && array_is_list($query);
|
||||
|
||||
foreach($query as $k => $q) {
|
||||
$pass = true;
|
||||
if(is_string($k) && substr($k, 0, 1) === '$') {
|
||||
// key is an operator at this level, except $not, which can be at any level
|
||||
if($k === '$not') {
|
||||
$pass = !self::_executeQuery($q, $document, $options);
|
||||
} else {
|
||||
$pass = self::_executeQuery($q, $document, $options, $k);
|
||||
}
|
||||
} elseif($logicalOperator === '$and') { // special case for $and
|
||||
if($queryIsIndexedArray) { // $q is an array of query objects
|
||||
$pass = self::_executeQuery($q, $document, $options);
|
||||
} elseif(is_array($q)) { // query is array, run all queries on field. All queries must match. e.g { 'age': { $gt: 24, $lt: 52 } }
|
||||
$pass = self::_executeQueryOnElement($q, $k, $document, $options);
|
||||
} else {
|
||||
// key value means equality
|
||||
$pass = self::_executeOperatorOnElement('$e', $q, $k, $document, $options);
|
||||
}
|
||||
} else { // $q is array of query objects e.g '$or' => [{'fullName' => 'Nick'}]
|
||||
$pass = self::_executeQuery($q, $document, $options, '$and');
|
||||
}
|
||||
switch($logicalOperator) {
|
||||
case '$and': // if any fail, query fails
|
||||
if(!$pass) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case '$or': // if one succeeds, query succeeds
|
||||
if($pass) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case '$nor': // if one succeeds, query fails
|
||||
if($pass) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if($options['_shouldLog']) {
|
||||
$options['logger']->warning('_executeQuery could not find logical operator', ['query' => $query, 'document' => $document, 'logicalOperator' => $logicalOperator]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
switch($logicalOperator) {
|
||||
case '$and': // all succeeded, query succeeds
|
||||
return true;
|
||||
case '$or': // all failed, query fails
|
||||
return false;
|
||||
case '$nor': // all failed, query succeeded
|
||||
return true;
|
||||
default:
|
||||
if($options['_shouldLog']) {
|
||||
$options['logger']->warning('_executeQuery could not find logical operator', ['query' => $query, 'document' => $document, 'logicalOperator' => $logicalOperator]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query object on an element
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private static function _executeQueryOnElement(array $query, string $element, array &$document, array $options = []): bool {
|
||||
if($options['_debug'] && $options['_shouldLog']) {
|
||||
$options['logger']->debug('_executeQueryOnElement called', ['query' => $query, 'element' => $element, 'document' => $document]);
|
||||
}
|
||||
// iterate through query operators
|
||||
foreach($query as $op => $opVal) {
|
||||
if(!self::_executeOperatorOnElement($op, $opVal, $element, $document, $options)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an operator is equal to a value
|
||||
*
|
||||
* Equality includes direct equality, regular expression match, and checking if the operator value is one of the values in an array value
|
||||
*
|
||||
* @param mixed $v
|
||||
* @param mixed $operatorValue
|
||||
*/
|
||||
private static function _isEqual($v, $operatorValue): bool {
|
||||
if (is_array($v) && is_array($operatorValue)) {
|
||||
return $v == $operatorValue;
|
||||
}
|
||||
if(is_array($v)) {
|
||||
return in_array($operatorValue, $v);
|
||||
}
|
||||
if(is_string($operatorValue) && preg_match('/^\/(.*?)\/([a-z]*)$/i', $operatorValue, $matches)) {
|
||||
return (bool)preg_match('/'.$matches[1].'/'.$matches[2], $v);
|
||||
}
|
||||
return $operatorValue === $v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Mongo Operator on an element
|
||||
*
|
||||
* @param string $operator The operator to perform
|
||||
* @param mixed $operatorValue The value to provide the operator
|
||||
* @param string $element The target element. Can be an object path eg price.shoes
|
||||
* @param array $document The document in which to find the element
|
||||
* @param array $options Options
|
||||
* @throws Exception Exceptions on invalid operators, invalid unknown operator callback, and invalid operator values
|
||||
*/
|
||||
private static function _executeOperatorOnElement(string $operator, $operatorValue, string $element, array &$document, array $options = []): bool {
|
||||
if($options['_debug'] && $options['_shouldLog']) {
|
||||
$options['logger']->debug('_executeOperatorOnElement called', ['operator' => $operator, 'operatorValue' => $operatorValue, 'element' => $element, 'document' => $document]);
|
||||
}
|
||||
|
||||
if($operator === '$not') {
|
||||
return !self::_executeQueryOnElement($operatorValue, $element, $document, $options);
|
||||
}
|
||||
|
||||
$elementSpecifier = explode('.', $element);
|
||||
$v = & $document;
|
||||
$exists = true;
|
||||
foreach($elementSpecifier as $index => $es) {
|
||||
if(empty($v)) {
|
||||
$exists = false;
|
||||
break;
|
||||
}
|
||||
if(isset($v[0])) {
|
||||
// value from document is an array, so we need to iterate through array and test the query on all elements of the array
|
||||
// if any elements match, then return true
|
||||
$newSpecifier = implode('.', array_slice($elementSpecifier, $index));
|
||||
foreach($v as $item) {
|
||||
if(self::_executeOperatorOnElement($operator, $operatorValue, $newSpecifier, $item, $options)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if(isset($v[$es])) {
|
||||
$v = & $v[$es];
|
||||
} else {
|
||||
$exists = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
switch($operator) {
|
||||
case '$all':
|
||||
if(!$exists) {
|
||||
return false;
|
||||
}
|
||||
if(!is_array($operatorValue)) {
|
||||
throw new Exception('$all requires array');
|
||||
}
|
||||
if(count($operatorValue) === 0) {
|
||||
return false;
|
||||
}
|
||||
if(!is_array($v)) {
|
||||
if(count($operatorValue) === 1) {
|
||||
return $v === $operatorValue[0];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return count(array_intersect($v, $operatorValue)) === count($operatorValue);
|
||||
case '$e':
|
||||
if(!$exists) {
|
||||
return false;
|
||||
}
|
||||
return self::_isEqual($v, $operatorValue);
|
||||
case '$in':
|
||||
if(!$exists) {
|
||||
return false;
|
||||
}
|
||||
if(!is_array($operatorValue)) {
|
||||
throw new Exception('$in requires array');
|
||||
}
|
||||
if(count($operatorValue) === 0) {
|
||||
return false;
|
||||
}
|
||||
if(is_array($v)) {
|
||||
return count(array_intersect($v, $operatorValue)) > 0;
|
||||
}
|
||||
return in_array($v, $operatorValue);
|
||||
case '$lt': return $exists && $v < $operatorValue;
|
||||
case '$lte': return $exists && $v <= $operatorValue;
|
||||
case '$gt': return $exists && $v > $operatorValue;
|
||||
case '$gte': return $exists && $v >= $operatorValue;
|
||||
case '$ne': return (!$exists && $operatorValue !== null) || ($exists && !self::_isEqual($v, $operatorValue));
|
||||
case '$nin':
|
||||
if(!$exists) {
|
||||
return true;
|
||||
}
|
||||
if(!is_array($operatorValue)) {
|
||||
throw new Exception('$nin requires array');
|
||||
}
|
||||
if(count($operatorValue) === 0) {
|
||||
return true;
|
||||
}
|
||||
if(is_array($v)) {
|
||||
return count(array_intersect($v, $operatorValue)) === 0;
|
||||
}
|
||||
return !in_array($v, $operatorValue);
|
||||
|
||||
case '$exists': return ($operatorValue && $exists) || (!$operatorValue && !$exists);
|
||||
case '$mod':
|
||||
if(!$exists) {
|
||||
return false;
|
||||
}
|
||||
if(!is_array($operatorValue)) {
|
||||
throw new Exception('$mod requires array');
|
||||
}
|
||||
if(count($operatorValue) !== 2) {
|
||||
throw new Exception('$mod requires two parameters in array: divisor and remainder');
|
||||
}
|
||||
return $v % $operatorValue[0] === $operatorValue[1];
|
||||
|
||||
default:
|
||||
if(empty($options['unknownOperatorCallback']) || !is_callable($options['unknownOperatorCallback'])) {
|
||||
throw new Exception('Operator '.$operator.' is unknown');
|
||||
}
|
||||
|
||||
$res = call_user_func($options['unknownOperatorCallback'], $operator, $operatorValue, $element, $document);
|
||||
if($res === null) {
|
||||
throw new Exception('Operator '.$operator.' is unknown');
|
||||
}
|
||||
if(!is_bool($res)) {
|
||||
throw new Exception('Return value of unknownOperatorCallback must be boolean, actual value '.$res);
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
throw new Exception('Didn\'t return in switch');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fields this query depends on
|
||||
*
|
||||
* @param array query The query to analyse
|
||||
* @return array An array of fields this query depends on
|
||||
*/
|
||||
public static function getDependentFields(array $query) {
|
||||
$fields = [];
|
||||
foreach($query as $k => $v) {
|
||||
if(is_array($v)) {
|
||||
$fields = array_merge($fields, static::getDependentFields($v));
|
||||
}
|
||||
if(is_int($k) || $k[0] === '$') {
|
||||
continue;
|
||||
}
|
||||
$fields[] = $k;
|
||||
}
|
||||
return array_unique($fields);
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ class WebhookListenerMapperTest extends TestCase {
|
|||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
$listener2 = $this->mapper->getById($listener1->getId());
|
||||
|
|
|
|||
Loading…
Reference in a new issue