From ac2b35004d8a55165ba6d90fa7f748762b1d9e2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 17 Mar 2026 13:03:26 +0100 Subject: [PATCH] Add more public webhook endpoints --- app/config/errors.php | 5 + src/Appwrite/Extend/Exception.php | 1 + .../Modules/Webhooks/Http/Webhooks/Create.php | 8 +- .../Modules/Webhooks/Http/Webhooks/Delete.php | 92 +++++++++++ .../Http/Webhooks/Signature/Update.php | 91 ++++++++++ .../Modules/Webhooks/Http/Webhooks/Update.php | 122 ++++++++++++++ .../Modules/Webhooks/Services/Http.php | 6 + tests/e2e/Services/Webhooks/WebhooksBase.php | 155 +++++++++++++++++- 8 files changed, 467 insertions(+), 13 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php create mode 100644 src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php diff --git a/app/config/errors.php b/app/config/errors.php index e8519fd797..278dbb3458 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1144,6 +1144,11 @@ return [ 'description' => 'Webhook with the requested ID could not be found.', 'code' => 404, ], + Exception::WEBHOOK_ALREADY_EXISTS => [ + 'name' => Exception::WEBHOOK_ALREADY_EXISTS, + 'description' => 'Webhook with the same ID already exists. Try again with a different ID.', + 'code' => 409, + ], Exception::KEY_NOT_FOUND => [ 'name' => Exception::KEY_NOT_FOUND, 'description' => 'Key with the requested ID could not be found.', diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 95a9c6ddac..a54edf7074 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -307,6 +307,7 @@ class Exception extends \Exception /** Webhooks */ public const string WEBHOOK_NOT_FOUND = 'webhook_not_found'; + public const string WEBHOOK_ALREADY_EXISTS = 'webhook_already_exists'; /** Router */ public const string ROUTER_HOST_NOT_FOUND = 'router_host_not_found'; diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php index c3d2260be4..d2bbd39ee8 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php @@ -61,10 +61,10 @@ class Create extends Base ], )) ->param('webhokId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['$dbForPlatform']) + ->param('url', '', fn () => new Multiple([new URL(['http', 'https']), new PublicDomain()], Multiple::TYPE_STRING), 'Webhook URL.') ->param('name', null, new Text(128), 'Webhook name. Max length: 128 chars.') ->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') ->param('enabled', true, new Boolean(), 'Enable or disable a webhook.', true) - ->param('url', '', fn () => new Multiple([new URL(['http', 'https']), new PublicDomain()], Multiple::TYPE_STRING), 'Webhook URL.', false) ->param('security', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) ->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) ->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) @@ -82,9 +82,9 @@ class Create extends Base public function action( string $webhookId, string $name, + string $url, array $events, bool $enabled, - string $url, bool $security, string $httpUser, string $httpPass, @@ -94,8 +94,6 @@ class Create extends Base Database $dbForPlatform, Authorization $authorization ) { - $security = (bool) filter_var($security, FILTER_VALIDATE_BOOLEAN); - $webhookId = ($webhookId == 'unique()') ? ID::unique() : $webhookId; $webhook = new Document([ @@ -116,7 +114,7 @@ class Create extends Base try { $webhook = $authorization->skip(fn () => $dbForPlatform->createDocument('webhooks', $webhook)); } catch (DuplicateException) { - throw new Exception(Exception::SITE_ALREADY_EXISTS); + throw new Exception(Exception::WEBHOOK_ALREADY_EXISTS); } $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php new file mode 100644 index 0000000000..5562d080de --- /dev/null +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Delete.php @@ -0,0 +1,92 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/webhooks/:webhookId') + ->desc('Delete webhook') + ->groups(['api', 'webhooks']) + ->label('scope', 'webhooks.write') + ->label('event', 'webhooks.[webhookId].delete') + ->label('audits.event', 'webhook.delete') + ->label('audits.resource', 'webhook/{request.webhookId}') + ->label('sdk', new Method( + namespace: 'webhooks', + group: null, + name: 'delete', + description: <<param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook ID.', false, ['$dbForPlatform']) + ->inject('project') + ->inject('response') + ->inject('dbForPlatform') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $webhookId, + Document $project, + Response $response, + Database $dbForPlatform, + Event $queueForEvents, + Authorization $authorization + ) { + $webhook = $authorization->skip(fn () => $dbForPlatform->getDocument('webhooks', $webhookId, [ + Query::equal('projectInternalId', [$project->getSequence()]), + ])); + + if ($webhook->isEmpty()) { + throw new Exception(Exception::WEBHOOK_NOT_FOUND); + } + + if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('webhooks', $webhook->getId()))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove document from DB'); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('webhookId', $webhook->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php new file mode 100644 index 0000000000..9d02404720 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php @@ -0,0 +1,91 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/webhooks/:webhookId/signature') + ->desc('Update webhook signature key') + ->groups(['api', 'webhooks']) + ->label('scope', 'webhooks.write') + ->label('event', 'webhooks.[webhookId].update') + ->label('audits.event', 'webhooks.update') + ->label('audits.resource', 'webhook/{response.$id}') + ->label('sdk', new Method( + namespace: 'webhooks', + group: null, + name: 'updateSignature', + description: <<param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('project') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $webhookId, + Response $response, + Document $project, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization + ) { + $webhook = $authorization->skip(fn () => $dbForPlatform->getDocument('webhooks', $webhookId, [ + Query::equal('projectInternalId', [$project->getSequence()]), + ])); + + if ($webhook->isEmpty()) { + throw new Exception(Exception::WEBHOOK_NOT_FOUND); + } + + $updates = new Document([ + 'signatureKey' => \bin2hex(\random_bytes(64)), + ]); + + $authorization->skip(fn () => $dbForPlatform->updateDocument('webhooks', $webhook->getId(), $updates)); + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('webhookId', $webhook->getId()); + + $response->dynamic($webhook, Response::MODEL_WEBHOOK); + } +} diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php new file mode 100644 index 0000000000..3f873f5174 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php @@ -0,0 +1,122 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/webhooks/:webhookId') + ->desc('Update webhook') + ->groups(['api', 'webhooks']) + ->label('scope', 'webhooks.write') + ->label('event', 'webhooks.[webhookId].update') + ->label('audits.event', 'webhooks.update') + ->label('audits.resource', 'webhook/{response.$id}') + ->label('sdk', new Method( + namespace: 'webhooks', + group: null, + name: 'update', + description: <<param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook ID.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Webhook name. Max length: 128 chars.') + ->param('url', '', fn () => new Multiple([new URL(['http', 'https']), new PublicDomain()], Multiple::TYPE_STRING), 'Webhook URL.') + ->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') + ->param('enabled', true, new Boolean(), 'Enable or disable a webhook.', true) + ->param('security', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) + ->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) + ->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) + ->inject('response') + ->inject('project') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $webhookId, + string $name, + string $url, + array $events, + bool $enabled, + bool $security, + string $httpUser, + string $httpPass, + Response $response, + Document $project, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization + ) { + $webhook = $authorization->skip(fn () => $dbForPlatform->getDocument('webhooks', $webhookId, [ + Query::equal('projectInternalId', [$project->getSequence()]), + ])); + + if ($webhook->isEmpty()) { + throw new Exception(Exception::WEBHOOK_NOT_FOUND); + } + + $updates = new Document([ + 'name' => $name, + 'events' => $events, + 'url' => $url, + 'security' => $security, + 'httpUser' => $httpUser, + 'httpPass' => $httpPass, + 'enabled' => $enabled, + ]); + + if ($enabled) { + $updates->setAttribute('attempts', 0); + } + + $authorization->skip(fn () => $dbForPlatform->updateDocument('webhooks', $webhook->getId(), $updates)); + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('webhookId', $webhook->getId()); + + $response->dynamic($webhook, Response::MODEL_WEBHOOK); + } +} diff --git a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php index ab74104c1f..d33d345bae 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php @@ -3,7 +3,10 @@ namespace Appwrite\Platform\Modules\Webhooks\Services; use Appwrite\Platform\Modules\Webhooks\Http\Init; +use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Delete as DeleteWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Get as GetWebhook; +use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Signature\Update as UpdateWebhookSignature; +use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Update as UpdateWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\XList as ListWebhooks; use Utopia\Platform\Service; @@ -19,5 +22,8 @@ class Http extends Service // Webhooks $this->addAction(ListWebhooks::getName(), new ListWebhooks()); $this->addAction(GetWebhook::getName(), new GetWebhook()); + $this->addAction(DeleteWebhook::getName(), new DeleteWebhook()); + $this->addAction(UpdateWebhook::getName(), new UpdateWebhook()); + $this->addAction(UpdateWebhookSignature::getName(), new UpdateWebhookSignature()); } } diff --git a/tests/e2e/Services/Webhooks/WebhooksBase.php b/tests/e2e/Services/Webhooks/WebhooksBase.php index 4d78af004c..8b22cff41b 100644 --- a/tests/e2e/Services/Webhooks/WebhooksBase.php +++ b/tests/e2e/Services/Webhooks/WebhooksBase.php @@ -11,22 +11,104 @@ trait WebhooksBase // Tests for all auth scenarios - public function testListWebhooks(): void + public function testCreateWebhook(): void { - $webhooks = $this->listWebhooks(); + } + + public function testCreateWebhookWithSecurity(): void + { + } + + public function testCreateWebhookWithHttpAuth(): void + { + } + + public function testCreateWebhookEnabled(): void + { + } + + public function testCreateWebhookWithoutAuthentication(): void + { + } + + public function testCreateWebhookInvalidId(): void + { + } - $this->assertSame(200, $webhooks['headers']['status-code']); - $this->assertSame(1, $webhooks['body']['total']); // One created during project setup - $this->assertIsArray($webhooks['body']['webhooks']); - $this->assertCount(1, $webhooks['body']['webhooks']); + public function testCreateWebhookMissingName(): void + { + } + + public function testCreateWebhookMissingUrl(): void + { + } + + public function testCreateWebhookMissingEvents(): void + { + } + + public function testCreateWebhookDuplicateId(): void + { + } + + public function testCreateWebhookAudit(): void + { + } + + public function testUpdateWebhook(): void + { + } + + public function testUpdateWebhookWithSecurity(): void + { + } + + public function testUpdateWebhookWithHttpAuth(): void + { + } + + public function testUpdateWebhookEnabled(): void + { + } + + public function testUpdateWebhookWithoutAuthentication(): void + { + } + + public function testUpdateWebhookInvalidId(): void + { + } + + public function testUpdateWebhookMissingName(): void + { + } + + public function testUpdateWebhookMissingUrl(): void + { + } + + public function testUpdateWebhookMissingEvents(): void + { + } + + public function testUpdateWebhookDuplicateId(): void + { + } + + public function testUpdateWebhookAudit(): void + { + } + + public function testUpdateWebhookSignature(): void + { } // Helpers /** - * @param array $queries + * @param array|null $queries */ - protected function listWebhooks(array $queries = [], bool $total = true): mixed + protected function listWebhooks(?array $queries, ?bool $total): mixed { $webhooks = $this->client->call(Client::METHOD_GET, '/webhooks', array_merge([ 'content-type' => 'application/json', @@ -38,4 +120,61 @@ trait WebhooksBase return $webhooks; } + + protected function getWebhook(string $webhookId): mixed + { + $webhook = $this->client->call(Client::METHOD_GET, '/webhooks/' . $webhookId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + return $webhook; + } + + protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $security, ?string $httpUser, ?string $httpPass): mixed + { + $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'webhookId' => $webhookId, + 'name' => $name, + 'events' => $events, + 'enabled' => $enabled, + 'url' => $url, + 'security' => $security, + 'httpUser' => $httpUser, + 'httpPass' => $httpPass, + ]); + + return $webhook; + } + + protected function updateWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $security, ?string $httpUser, ?string $httpPass): mixed + { + $webhook = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => $name, + 'events' => $events, + 'enabled' => $enabled, + 'url' => $url, + 'security' => $security, + 'httpUser' => $httpUser, + 'httpPass' => $httpPass, + ]); + + return $webhook; + } + + protected function updateWebhookSignature(string $webhookId): mixed + { + $webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/signature', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + return $webhook; + } }