Add more public webhook endpoints

This commit is contained in:
Matej Bačo
2026-03-17 13:03:26 +01:00
parent 6b6092c9ec
commit ac2b35004d
8 changed files with 467 additions and 13 deletions
+5
View File
@@ -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.',
+1
View File
@@ -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';
@@ -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()));
@@ -0,0 +1,92 @@
<?php
namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Delete extends Base
{
use HTTP;
public static function getName()
{
return 'deleteWebhook';
}
public function __construct()
{
$this
->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: <<<EOT
Delete a webhook by its unique ID. Once deleted, the webhook will no longer receive project events.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->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();
}
}
@@ -0,0 +1,91 @@
<?php
namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Signature;
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Update extends Base
{
use HTTP;
public static function getName()
{
return 'updateWebhookSignature';
}
public function __construct()
{
$this->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: <<<EOT
Update the webhook signature key. This endpoint can be used to regenerate the signature key used to sign and validate payload deliveries for a specific webhook.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_WEBHOOK,
)
]
))
->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);
}
}
@@ -0,0 +1,122 @@
<?php
namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks;
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Event\Validator\Event;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Domains\Validator\PublicDomain;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Multiple;
use Utopia\Validator\Text;
use Utopia\Validator\URL;
class Update extends Base
{
use HTTP;
public static function getName()
{
return 'updateWebhook';
}
public function __construct()
{
$this->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: <<<EOT
Update a webhook by its unique ID. Use this endpoint to update the URL, events, or status of an existing webhook.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_WEBHOOK,
)
]
))
->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);
}
}
@@ -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());
}
}
+147 -8
View File
@@ -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<string> $queries
* @param array<string>|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;
}
}