From 7fca4226ae33a60ec334d0dbdc1353c2464c4306 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 14 May 2026 20:27:19 +0100 Subject: [PATCH 1/4] Add webhook migration support (in GROUP_SETTINGS) - Resource::TYPE_WEBHOOK joins project-variable under the Settings group. - Source uses Appwrite SDK Webhooks::list() (separate service from Project) with cursor pagination. - Destination writes to dbForPlatform.webhooks matching upstream createWebhook payload. Signing secret regenerates on the destination because the SDK strips it from list responses (same caveat as api keys). --- src/Migration/Destinations/Appwrite.php | 50 ++++++++ src/Migration/Resource.php | 2 + src/Migration/Resources/Settings/Webhook.php | 116 +++++++++++++++++++ src/Migration/Sources/Appwrite.php | 84 ++++++++++++++ src/Migration/Transfer.php | 2 + 5 files changed, 254 insertions(+) create mode 100644 src/Migration/Resources/Settings/Webhook.php diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index f3b7d335..0fa76be4 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -53,6 +53,7 @@ use Utopia\Migration\Resources\Integrations\ApiKey; use Utopia\Migration\Resources\Integrations\Platform; use Utopia\Migration\Resources\Settings\ProjectVariable; +use Utopia\Migration\Resources\Settings\Webhook; use Utopia\Migration\Resources\Messaging\Message; use Utopia\Migration\Resources\Messaging\Provider; use Utopia\Migration\Resources\Messaging\Subscriber; @@ -281,6 +282,7 @@ public static function getSupportedResources(): array // Settings Resource::TYPE_PROJECT_VARIABLE, + Resource::TYPE_WEBHOOK, // Backups Resource::TYPE_BACKUP_POLICY, @@ -3101,6 +3103,10 @@ public function importSettingsResource(Resource $resource): Resource /** @var ProjectVariable $resource */ $this->createProjectVariable($resource); break; + case Resource::TYPE_WEBHOOK: + /** @var Webhook $resource */ + $this->createWebhook($resource); + break; } if ($resource->getStatus() !== Resource::STATUS_SKIPPED) { @@ -3151,6 +3157,50 @@ protected function createProjectVariable(ProjectVariable $resource): bool return true; } + protected function createWebhook(Webhook $resource): bool + { + $existing = $this->dbForPlatform->findOne('webhooks', [ + Query::equal('projectInternalId', [$this->projectInternalId]), + Query::equal('name', [$resource->getWebhookName()]), + ]); + + if ($existing !== false && !$existing->isEmpty()) { + $resource->setStatus(Resource::STATUS_SKIPPED, 'Webhook already exists'); + return false; + } + + $createdAt = $this->normalizeDateTime($resource->getCreatedAt()); + $updatedAt = $this->normalizeDateTime($resource->getUpdatedAt(), $createdAt); + + try { + $this->dbForPlatform->createDocument('webhooks', new UtopiaDocument([ + '$id' => ID::unique(), + '$permissions' => $resource->getPermissions(), + 'projectInternalId' => $this->projectInternalId, + 'projectId' => $this->project, + 'name' => $resource->getWebhookName(), + 'events' => $resource->getEvents(), + 'url' => $resource->getUrl(), + 'security' => $resource->getSecurity(), + 'httpUser' => $resource->getHttpUser(), + 'httpPass' => $resource->getHttpPass(), + // SDK only returns the signing secret on creation, never on list — regenerate + // a fresh one on the destination to match upstream createWebhook behavior. + 'signatureKey' => \bin2hex(\random_bytes(64)), + 'enabled' => $resource->isEnabled(), + '$createdAt' => $createdAt, + '$updatedAt' => $updatedAt, + ])); + } catch (DuplicateException) { + $resource->setStatus(Resource::STATUS_SKIPPED, 'Webhook already exists'); + return false; + } + + $this->dbForPlatform->purgeCachedDocument('projects', $this->project); + + return true; + } + /** * @throws \Throwable */ diff --git a/src/Migration/Resource.php b/src/Migration/Resource.php index 6dd1df78..9dc936ac 100644 --- a/src/Migration/Resource.php +++ b/src/Migration/Resource.php @@ -77,6 +77,7 @@ abstract class Resource implements \JsonSerializable // Settings public const TYPE_PROJECT_VARIABLE = 'project-variable'; + public const TYPE_WEBHOOK = 'webhook'; // Messaging public const TYPE_SUBSCRIBER = 'subscriber'; @@ -119,6 +120,7 @@ abstract class Resource implements \JsonSerializable self::TYPE_PLATFORM, self::TYPE_API_KEY, self::TYPE_PROJECT_VARIABLE, + self::TYPE_WEBHOOK, self::TYPE_PROVIDER, self::TYPE_TOPIC, self::TYPE_SUBSCRIBER, diff --git a/src/Migration/Resources/Settings/Webhook.php b/src/Migration/Resources/Settings/Webhook.php new file mode 100644 index 00000000..20ba47f6 --- /dev/null +++ b/src/Migration/Resources/Settings/Webhook.php @@ -0,0 +1,116 @@ + $events + */ + public function __construct( + string $id, + private readonly string $name, + private readonly string $url, + private readonly array $events = [], + private readonly bool $security = false, + private readonly string $httpUser = '', + private readonly string $httpPass = '', + private readonly bool $enabled = true, + string $createdAt = '', + string $updatedAt = '', + ) { + $this->id = $id; + $this->createdAt = $createdAt; + $this->updatedAt = $updatedAt; + } + + /** + * @param array $array + * @return self + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + $array['name'], + $array['url'], + $array['events'] ?? [], + (bool) ($array['security'] ?? false), + $array['httpUser'] ?? '', + $array['httpPass'] ?? '', + (bool) ($array['enabled'] ?? true), + createdAt: $array['createdAt'] ?? '', + updatedAt: $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'url' => $this->url, + 'events' => $this->events, + 'security' => $this->security, + 'httpUser' => $this->httpUser, + 'httpPass' => $this->httpPass, + 'enabled' => $this->enabled, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getName(): string + { + return Resource::TYPE_WEBHOOK; + } + + public function getGroup(): string + { + return Transfer::GROUP_SETTINGS; + } + + public function getWebhookName(): string + { + return $this->name; + } + + public function getUrl(): string + { + return $this->url; + } + + /** + * @return array + */ + public function getEvents(): array + { + return $this->events; + } + + public function getSecurity(): bool + { + return $this->security; + } + + public function getHttpUser(): string + { + return $this->httpUser; + } + + public function getHttpPass(): string + { + return $this->httpPass; + } + + public function isEnabled(): bool + { + return $this->enabled; + } +} diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 28e63416..7d699143 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -13,6 +13,7 @@ use Appwrite\Services\TablesDB; use Appwrite\Services\Teams; use Appwrite\Services\Users; +use Appwrite\Services\Webhooks; use Utopia\Database\Database as UtopiaDatabase; use Utopia\Database\DateTime as UtopiaDateTime; use Utopia\Database\Document as UtopiaDocument; @@ -57,6 +58,7 @@ use Utopia\Migration\Resources\Functions\Func; use Utopia\Migration\Resources\Integrations\ApiKey; use Utopia\Migration\Resources\Settings\ProjectVariable; +use Utopia\Migration\Resources\Settings\Webhook; use Utopia\Migration\Resources\Integrations\Platform; use Utopia\Migration\Resources\Messaging\Message; use Utopia\Migration\Resources\Messaging\Provider; @@ -98,6 +100,8 @@ class Appwrite extends Source private Project $project; + private Webhooks $webhooks; + /** * @var callable(UtopiaDocument $database|null): UtopiaDatabase */ @@ -127,6 +131,7 @@ public function __construct( $this->messaging = new Messaging($this->client); $this->sites = new Sites($this->client); $this->project = new Project($this->client); + $this->webhooks = new Webhooks($this->client); $this->headers['x-appwrite-project'] = $this->projectId; $this->headers['x-appwrite-key'] = $this->key; @@ -216,6 +221,7 @@ public static function getSupportedResources(): array // Settings Resource::TYPE_PROJECT_VARIABLE, + Resource::TYPE_WEBHOOK, ]; } @@ -1466,6 +1472,19 @@ private function reportSettings(array $resources, array &$report, array $resourc $report[Resource::TYPE_PROJECT_VARIABLE] = 0; } } + + if (\in_array(Resource::TYPE_WEBHOOK, $resources)) { + $webhookQueries = $this->buildQueries( + resourceType: Resource::TYPE_WEBHOOK, + resourceIds: $resourceIds, + limit: 1 + ); + try { + $report[Resource::TYPE_WEBHOOK] = $this->webhooks->list($webhookQueries)->total; + } catch (\Throwable) { + $report[Resource::TYPE_WEBHOOK] = 0; + } + } } /** @@ -1487,6 +1506,20 @@ protected function exportGroupSettings(int $batchSize, array $resources): void )); } } + + if (\in_array(Resource::TYPE_WEBHOOK, $resources)) { + try { + $this->exportWebhooks($batchSize); + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_WEBHOOK, + Transfer::GROUP_SETTINGS, + message: $e->getMessage(), + code: $e->getCode(), + previous: $e + )); + } + } } /** @@ -1536,6 +1569,57 @@ private function exportProjectVariables(int $batchSize): void } } + /** + * @throws AppwriteException + */ + private function exportWebhooks(int $batchSize): void + { + $lastId = null; + + while (true) { + $queries = [Query::limit($batchSize)]; + + if ($this->rootResourceId !== '' && $this->rootResourceType === Resource::TYPE_WEBHOOK) { + $queries[] = Query::equal('$id', $this->rootResourceId); + $queries[] = Query::limit(1); + } + + if ($lastId !== null) { + $queries[] = Query::cursorAfter($lastId); + } + + $response = $this->webhooks->list($queries); + if ($response->total === 0) { + break; + } + + $webhooks = []; + + foreach ($response->webhooks as $webhook) { + $webhooks[] = new Webhook( + $webhook->id, + $webhook->name, + $webhook->url, + $webhook->events, + $webhook->tls, + $webhook->authUsername, + $webhook->authPassword, + $webhook->enabled, + createdAt: $webhook->createdAt, + updatedAt: $webhook->updatedAt, + ); + + $lastId = $webhook->id; + } + + $this->callback($webhooks); + + if (\count($response->webhooks) < $batchSize) { + break; + } + } + } + /** * @throws AppwriteException */ diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index 069a780b..05c61f40 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -96,6 +96,7 @@ class Transfer public const GROUP_SETTINGS_RESOURCES = [ Resource::TYPE_PROJECT_VARIABLE, + Resource::TYPE_WEBHOOK, ]; public const GROUP_BACKUPS_RESOURCES = [ @@ -138,6 +139,7 @@ class Transfer // Settings Resource::TYPE_PROJECT_VARIABLE, + Resource::TYPE_WEBHOOK, // legacy Resource::TYPE_DOCUMENT, From 45f1ed706b58a93174e6a287f4f0f4dd23996c91 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 28 May 2026 08:46:28 +0100 Subject: [PATCH 2/4] Move webhook into integrations group --- src/Migration/Destinations/Appwrite.php | 10 ++--- src/Migration/Sources/Appwrite.php | 56 ++++++++++++------------- src/Migration/Transfer.php | 4 +- 3 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 90f596ee..c8330e52 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -279,10 +279,10 @@ public static function getSupportedResources(): array // Integrations Resource::TYPE_PLATFORM, Resource::TYPE_API_KEY, + Resource::TYPE_WEBHOOK, // Settings Resource::TYPE_PROJECT_VARIABLE, - Resource::TYPE_WEBHOOK, // Backups Resource::TYPE_BACKUP_POLICY, @@ -3087,6 +3087,10 @@ public function importIntegrationsResource(Resource $resource): Resource /** @var ApiKey $resource */ $this->createApiKey($resource); break; + case Resource::TYPE_WEBHOOK: + /** @var Webhook $resource */ + $this->createWebhook($resource); + break; } if ($resource->getStatus() !== Resource::STATUS_SKIPPED) { @@ -3103,10 +3107,6 @@ public function importSettingsResource(Resource $resource): Resource /** @var ProjectVariable $resource */ $this->createProjectVariable($resource); break; - case Resource::TYPE_WEBHOOK: - /** @var Webhook $resource */ - $this->createWebhook($resource); - break; } if ($resource->getStatus() !== Resource::STATUS_SKIPPED) { diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index fd03b7d0..4114bb2e 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -215,13 +215,13 @@ public static function getSupportedResources(): array // Integrations Resource::TYPE_PLATFORM, Resource::TYPE_API_KEY, + Resource::TYPE_WEBHOOK, // Backups Resource::TYPE_BACKUP_POLICY, // Settings Resource::TYPE_PROJECT_VARIABLE, - Resource::TYPE_WEBHOOK, ]; } @@ -1472,19 +1472,6 @@ private function reportSettings(array $resources, array &$report, array $resourc $report[Resource::TYPE_PROJECT_VARIABLE] = 0; } } - - if (\in_array(Resource::TYPE_WEBHOOK, $resources)) { - $webhookQueries = $this->buildQueries( - resourceType: Resource::TYPE_WEBHOOK, - resourceIds: $resourceIds, - limit: 1 - ); - try { - $report[Resource::TYPE_WEBHOOK] = $this->webhooks->list($webhookQueries)->total; - } catch (\Throwable) { - $report[Resource::TYPE_WEBHOOK] = 0; - } - } } /** @@ -1506,20 +1493,6 @@ protected function exportGroupSettings(int $batchSize, array $resources): void )); } } - - if (\in_array(Resource::TYPE_WEBHOOK, $resources)) { - try { - $this->exportWebhooks($batchSize); - } catch (\Throwable $e) { - $this->addError(new Exception( - Resource::TYPE_WEBHOOK, - Transfer::GROUP_SETTINGS, - message: $e->getMessage(), - code: $e->getCode(), - previous: $e - )); - } - } } /** @@ -2425,6 +2398,19 @@ private function reportIntegrations(array $resources, array &$report, array $res $report[Resource::TYPE_API_KEY] = 0; } } + + if (\in_array(Resource::TYPE_WEBHOOK, $resources)) { + $webhookQueries = $this->buildQueries( + resourceType: Resource::TYPE_WEBHOOK, + resourceIds: $resourceIds, + limit: 1 + ); + try { + $report[Resource::TYPE_WEBHOOK] = $this->webhooks->list($webhookQueries)->total; + } catch (\Throwable) { + $report[Resource::TYPE_WEBHOOK] = 0; + } + } } /** @@ -2484,6 +2470,20 @@ protected function exportGroupIntegrations(int $batchSize, array $resources): vo )); } } + + if (\in_array(Resource::TYPE_WEBHOOK, $resources)) { + try { + $this->exportWebhooks($batchSize); + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_WEBHOOK, + Transfer::GROUP_INTEGRATIONS, + message: $e->getMessage(), + code: $e->getCode(), + previous: $e + )); + } + } } /** diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index 319e57fe..f7a4a1b2 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -65,6 +65,7 @@ class Transfer public const GROUP_INTEGRATIONS_RESOURCES = [ Resource::TYPE_PLATFORM, Resource::TYPE_API_KEY, + Resource::TYPE_WEBHOOK, ]; public const GROUP_DOCUMENTSDB_RESOURCES = [ Resource::TYPE_DATABASE_DOCUMENTSDB, @@ -96,7 +97,6 @@ class Transfer public const GROUP_SETTINGS_RESOURCES = [ Resource::TYPE_PROJECT_VARIABLE, - Resource::TYPE_WEBHOOK, ]; public const GROUP_BACKUPS_RESOURCES = [ @@ -136,10 +136,10 @@ class Transfer // Integrations Resource::TYPE_PLATFORM, Resource::TYPE_API_KEY, + Resource::TYPE_WEBHOOK, // Settings Resource::TYPE_PROJECT_VARIABLE, - Resource::TYPE_WEBHOOK, // legacy Resource::TYPE_DOCUMENT, From dd823f1ea9f48fe6928115e4a6588cc8f101c930 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 28 May 2026 09:26:41 +0100 Subject: [PATCH 3/4] Webhook resource reports integrations group --- src/Migration/Resources/Settings/Webhook.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Migration/Resources/Settings/Webhook.php b/src/Migration/Resources/Settings/Webhook.php index 20ba47f6..184d2482 100644 --- a/src/Migration/Resources/Settings/Webhook.php +++ b/src/Migration/Resources/Settings/Webhook.php @@ -73,7 +73,7 @@ public static function getName(): string public function getGroup(): string { - return Transfer::GROUP_SETTINGS; + return Transfer::GROUP_INTEGRATIONS; } public function getWebhookName(): string From 278bf591aebc2f0eb4465d43393d8987850b8a43 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 2 Jun 2026 07:29:44 +0100 Subject: [PATCH 4/4] Register TYPE_PROJECT_VARIABLE and TYPE_WEBHOOK in mock source/destination --- tests/Migration/Unit/Adapters/MockDestination.php | 2 ++ tests/Migration/Unit/Adapters/MockSource.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/tests/Migration/Unit/Adapters/MockDestination.php b/tests/Migration/Unit/Adapters/MockDestination.php index 68f0eec9..640aba08 100644 --- a/tests/Migration/Unit/Adapters/MockDestination.php +++ b/tests/Migration/Unit/Adapters/MockDestination.php @@ -53,6 +53,8 @@ public static function getSupportedResources(): array Resource::TYPE_MEMBERSHIP, Resource::TYPE_PLATFORM, Resource::TYPE_API_KEY, + Resource::TYPE_PROJECT_VARIABLE, + Resource::TYPE_WEBHOOK, Resource::TYPE_PROVIDER, Resource::TYPE_TOPIC, Resource::TYPE_SUBSCRIBER, diff --git a/tests/Migration/Unit/Adapters/MockSource.php b/tests/Migration/Unit/Adapters/MockSource.php index 28002376..4b0118c1 100644 --- a/tests/Migration/Unit/Adapters/MockSource.php +++ b/tests/Migration/Unit/Adapters/MockSource.php @@ -82,6 +82,8 @@ public static function getSupportedResources(): array Resource::TYPE_MEMBERSHIP, Resource::TYPE_PLATFORM, Resource::TYPE_API_KEY, + Resource::TYPE_PROJECT_VARIABLE, + Resource::TYPE_WEBHOOK, Resource::TYPE_PROVIDER, Resource::TYPE_TOPIC, Resource::TYPE_SUBSCRIBER,