diff --git a/.claude/skills/patch-release-checklist/SKILL.md b/.claude/skills/patch-release-checklist/SKILL.md new file mode 100644 index 0000000000..000fcd065a --- /dev/null +++ b/.claude/skills/patch-release-checklist/SKILL.md @@ -0,0 +1,29 @@ +# Patch Release Checklist for Appwrite + +When bumping a patch version (e.g., `1.9.0` -> `1.9.1`), follow this checklist. + +## Checklist + +### Bump console image + +Update the console Docker image tag in both files: +- [ ] `docker-compose.yml` -- update `image: appwrite/console:X.Y.Z` +- [ ] `app/views/install/compose.phtml` -- update `image: /console:X.Y.Z` + +### Bump Appwrite version + +- [ ] **`app/init/constants.php`** -- update `APP_VERSION_STABLE` to the new version (e.g., `'1.9.1'`). In same file, increment `APP_CACHE_BUSTER` by 1. +- [ ] **`README.md`** -- update the Docker image tag `appwrite/appwrite:X.Y.Z` in all 3 install code blocks (Unix, Windows CMD, PowerShell). +- [ ] **`README-CN.md`** -- same Docker image tag update in all 3 install code blocks. +- [ ] **`src/Appwrite/Migration/Migration.php`** -- add the new version to the `$versions` array, mapping it to a migration class. If new class exists, use that, otherwise use sle same class as previous version + +### Update CHANGES.md + +- [ ] Add a new `# Version X.Y.Z` section at the top of `CHANGES.md` with subsections: `### Notable changes`, `### Fixes`, `### Miscellaneous` + +## Final review + +- [ ] Ask user to review changes before commiting +- [ ] Ask user to update `CHANGES.md` with PRs +- [ ] Ask user to generate specs, if needed +- [ ] Ask user to add request and response filters, if needed diff --git a/AGENTS.md b/AGENTS.md index 4d11ff0ee3..4c5db871d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,10 @@ Common injections: `$response`, `$request`, `$dbForProject`, `$dbForPlatform`, ` - Never hardcode credentials -- use environment variables. - Code changes may require container restart. No central log location -- check relevant containers. +## Patch release process + +For bumping patch versions (e.g., `1.9.0` -> `1.9.1`), follow the checklist in `.claude/skills/patch-release-checklist/SKILL.md`. It covers the 4 files that must be updated, console image bumps, CHANGES.md updates, and common pitfalls to avoid. + ## Cross-repo context Appwrite is the base server for `appwrite/cloud`. Changes to the Action pattern, module structure, DI system, or response models affect cloud. The `feat-dedicated-db` feature spans cloud, edge, and console. diff --git a/README-CN.md b/README-CN.md index 212b5bb08d..2c7402f1ef 100644 --- a/README-CN.md +++ b/README-CN.md @@ -72,7 +72,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` ### Windows @@ -84,7 +84,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` #### PowerShell @@ -94,7 +94,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index 88d527f060..31076ffa31 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` ### Windows @@ -88,7 +88,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` #### PowerShell @@ -99,7 +99,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation. diff --git a/app/cli.php b/app/cli.php index 458df2d642..73908510d9 100644 --- a/app/cli.php +++ b/app/cli.php @@ -6,8 +6,8 @@ use Appwrite\Event\Certificate; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; +use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; -use Appwrite\Event\StatsResources; use Appwrite\Platform\Appwrite; use Appwrite\Runtimes\Runtimes; use Appwrite\Usage\Context as UsageContext; @@ -253,9 +253,10 @@ $container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePubli $publisher, new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME)) ), ['publisher']); -$container->set('queueForStatsResources', function (Publisher $publisher) { - return new StatsResources($publisher); -}, ['publisher']); +$container->set('publisherForStatsResources', fn (Publisher $publisher) => new StatsResourcesPublisher( + $publisher, + new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME)) +), ['publisher']); $container->set('queueForFunctions', function (Publisher $publisher) { return new Func($publisher); }, ['publisher']); diff --git a/app/config/apis.php b/app/config/protocols.php similarity index 69% rename from app/config/apis.php rename to app/config/protocols.php index a625999682..bb12453712 100644 --- a/app/config/apis.php +++ b/app/config/protocols.php @@ -9,8 +9,8 @@ return [ 'key' => 'graphql', 'name' => 'GraphQL', ], - 'realtime' => [ - 'key' => 'realtime', - 'name' => 'Realtime', + 'websocket' => [ + 'key' => 'websocket', + 'name' => 'Websocket', ], ]; diff --git a/app/config/services.php b/app/config/services.php index a99501c530..548f659a81 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -137,7 +137,7 @@ return [ 'docs' => true, 'docsUrl' => '', 'tests' => false, - 'optional' => false, + 'optional' => true, 'icon' => '', 'platforms' => ['client', 'server', 'console'], ], @@ -193,7 +193,7 @@ return [ 'docs' => false, 'docsUrl' => '', 'tests' => false, - 'optional' => false, + 'optional' => true, 'icon' => '', 'platforms' => ['client', 'server', 'console'], ], @@ -235,7 +235,7 @@ return [ 'docs' => true, 'docsUrl' => 'https://appwrite.io/docs/proxy', 'tests' => false, - 'optional' => false, + 'optional' => true, 'icon' => '/images/services/proxy.png', 'platforms' => ['client', 'server', 'console'], ], @@ -291,7 +291,7 @@ return [ 'docs' => true, 'docsUrl' => 'https://appwrite.io/docs/migrations', 'tests' => true, - 'optional' => false, + 'optional' => true, 'icon' => '/images/services/migrations.png', 'platforms' => ['client', 'server', 'console'], ], diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php index 45a663fb56..4c541d2817 100644 --- a/app/controllers/api/migrations.php +++ b/app/controllers/api/migrations.php @@ -1,7 +1,8 @@ inject('dbForProject') ->inject('project') ->inject('platform') - ->inject('user') ->inject('queueForEvents') - ->inject('queueForMigrations') - ->action(function (array $resources, string $endpoint, string $projectId, string $apiKey, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Event $queueForEvents, Migration $queueForMigrations) { + ->inject('publisherForMigrations') + ->action(function (array $resources, string $endpoint, string $projectId, string $apiKey, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { $migration = $dbForProject->createDocument('migrations', new Document([ '$id' => ID::unique(), 'status' => 'pending', @@ -114,12 +114,11 @@ Http::post('/v1/migrations/appwrite') $queueForEvents->setParam('migrationId', $migration->getId()); // Trigger Transfer - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setPlatform($platform) - ->setUser($user) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) @@ -151,10 +150,9 @@ Http::post('/v1/migrations/firebase') ->inject('dbForProject') ->inject('project') ->inject('platform') - ->inject('user') ->inject('queueForEvents') - ->inject('queueForMigrations') - ->action(function (array $resources, string $serviceAccount, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Event $queueForEvents, Migration $queueForMigrations) { + ->inject('publisherForMigrations') + ->action(function (array $resources, string $serviceAccount, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { $serviceAccountData = json_decode($serviceAccount, true); if (empty($serviceAccountData)) { @@ -183,12 +181,11 @@ Http::post('/v1/migrations/firebase') $queueForEvents->setParam('migrationId', $migration->getId()); // Trigger Transfer - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setPlatform($platform) - ->setUser($user) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) @@ -225,10 +222,9 @@ Http::post('/v1/migrations/supabase') ->inject('dbForProject') ->inject('project') ->inject('platform') - ->inject('user') ->inject('queueForEvents') - ->inject('queueForMigrations') - ->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Event $queueForEvents, Migration $queueForMigrations) { + ->inject('publisherForMigrations') + ->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { $migration = $dbForProject->createDocument('migrations', new Document([ '$id' => ID::unique(), 'status' => 'pending', @@ -252,12 +248,11 @@ Http::post('/v1/migrations/supabase') $queueForEvents->setParam('migrationId', $migration->getId()); // Trigger Transfer - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setPlatform($platform) - ->setUser($user) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) @@ -295,10 +290,9 @@ Http::post('/v1/migrations/nhost') ->inject('dbForProject') ->inject('project') ->inject('platform') - ->inject('user') ->inject('queueForEvents') - ->inject('queueForMigrations') - ->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Event $queueForEvents, Migration $queueForMigrations) { + ->inject('publisherForMigrations') + ->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { $migration = $dbForProject->createDocument('migrations', new Document([ '$id' => ID::unique(), 'status' => 'pending', @@ -323,12 +317,11 @@ Http::post('/v1/migrations/nhost') $queueForEvents->setParam('migrationId', $migration->getId()); // Trigger Transfer - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setPlatform($platform) - ->setUser($user) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) @@ -368,7 +361,7 @@ Http::post('/v1/migrations/csv/imports') ->inject('deviceForFiles') ->inject('deviceForMigrations') ->inject('queueForEvents') - ->inject('queueForMigrations') + ->inject('publisherForMigrations') ->action(function ( string $bucketId, string $fileId, @@ -383,7 +376,7 @@ Http::post('/v1/migrations/csv/imports') Device $deviceForFiles, Device $deviceForMigrations, Event $queueForEvents, - Migration $queueForMigrations + MigrationPublisher $publisherForMigrations ) { $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { if ($internalFile) { @@ -479,11 +472,10 @@ Http::post('/v1/migrations/csv/imports') $queueForEvents->setParam('migrationId', $migration->getId()); - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setProject($project) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + )); $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) @@ -526,7 +518,7 @@ Http::post('/v1/migrations/csv/exports') ->inject('project') ->inject('platform') ->inject('queueForEvents') - ->inject('queueForMigrations') + ->inject('publisherForMigrations') ->action(function ( string $resourceId, string $filename, @@ -545,7 +537,7 @@ Http::post('/v1/migrations/csv/exports') Document $project, array $platform, Event $queueForEvents, - Migration $queueForMigrations + MigrationPublisher $publisherForMigrations ) { try { $parsedQueries = Query::parseQueries($queries); @@ -630,11 +622,11 @@ Http::post('/v1/migrations/csv/exports') $queueForEvents->setParam('migrationId', $migration->getId()); - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setPlatform($platform) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) @@ -673,7 +665,7 @@ Http::post('/v1/migrations/json/imports') ->inject('deviceForFiles') ->inject('deviceForMigrations') ->inject('queueForEvents') - ->inject('queueForMigrations') + ->inject('publisherForMigrations') ->action(function ( string $bucketId, string $fileId, @@ -688,7 +680,7 @@ Http::post('/v1/migrations/json/imports') Device $deviceForFiles, Device $deviceForMigrations, Event $queueForEvents, - Migration $queueForMigrations + MigrationPublisher $publisherForMigrations ) { $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { if ($internalFile) { @@ -783,11 +775,11 @@ Http::post('/v1/migrations/json/imports') $queueForEvents->setParam('migrationId', $migration->getId()); - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setPlatform($platform) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) @@ -826,7 +818,7 @@ Http::post('/v1/migrations/json/exports') ->inject('project') ->inject('platform') ->inject('queueForEvents') - ->inject('queueForMigrations') + ->inject('publisherForMigrations') ->action(function ( string $resourceId, string $filename, @@ -841,7 +833,7 @@ Http::post('/v1/migrations/json/exports') Document $project, array $platform, Event $queueForEvents, - Migration $queueForMigrations + MigrationPublisher $publisherForMigrations ) { try { $parsedQueries = Query::parseQueries($queries); @@ -915,11 +907,11 @@ Http::post('/v1/migrations/json/exports') $queueForEvents->setParam('migrationId', $migration->getId()); - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setPlatform($platform) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) @@ -1216,9 +1208,8 @@ Http::patch('/v1/migrations/:migrationId') ->inject('dbForProject') ->inject('project') ->inject('platform') - ->inject('user') - ->inject('queueForMigrations') - ->action(function (string $migrationId, Response $response, Database $dbForProject, Document $project, array $platform, Document $user, Migration $queueForMigrations) { + ->inject('publisherForMigrations') + ->action(function (string $migrationId, Response $response, Database $dbForProject, Document $project, array $platform, MigrationPublisher $publisherForMigrations) { $migration = $dbForProject->getDocument('migrations', $migrationId); if ($migration->isEmpty()) { @@ -1234,12 +1225,11 @@ Http::patch('/v1/migrations/:migrationId') ->setAttribute('dateUpdated', \time()); // Trigger Migration - $queueForMigrations - ->setMigration($migration) - ->setProject($project) - ->setPlatform($platform) - ->setUser($user) - ->trigger(); + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); $response->noContent(); }); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index dac6ed456a..5b82e6c1a3 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -71,202 +71,20 @@ Http::get('/v1/projects/:projectId') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/service') - ->desc('Update service status') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'updateServiceStatus', - description: '/docs/references/projects/update-service-status.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('service', '', new WhiteList(array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])), true), 'Service name.') - ->param('status', null, new Boolean(), 'Service status.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $service, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $services = $project->getAttribute('services', []); - $services[$service] = $status; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('services', $services)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::patch('/v1/projects/:projectId/service/all') ->desc('Update all service status') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'updateServiceStatusAll', - description: '/docs/references/projects/update-service-status-all.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('status', null, new Boolean(), 'Service status.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $allServices = array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])); - - $services = []; - foreach ($allServices as $service) { - $services[$service] = $status; - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('services', $services)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/api') - ->desc('Update API status') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'projects', - name: 'updateApiStatus', - description: '/docs/references/projects/update-api-status.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateAPIStatus', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'projects', - name: 'updateAPIStatus', - description: '/docs/references/projects/update-api-status.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('api', '', new WhiteList(array_keys(Config::getParam('apis')), true), 'API name.') - ->param('status', null, new Boolean(), 'API status.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $api, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $apis = $project->getAttribute('apis', []); - $apis[$api] = $status; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('apis', $apis)); - - $response->dynamic($project, Response::MODEL_PROJECT); + ->action(function () { + throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.'); }); Http::patch('/v1/projects/:projectId/api/all') ->desc('Update all API status') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'projects', - name: 'updateApiStatusAll', - description: '/docs/references/projects/update-api-status-all.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateAPIStatusAll', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'projects', - name: 'updateAPIStatusAll', - description: '/docs/references/projects/update-api-status-all.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('status', null, new Boolean(), 'API status.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $allApis = array_keys(Config::getParam('apis')); - - $apis = []; - foreach ($allApis as $api) { - $apis[$api] = $status; - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('apis', $apis)); - - $response->dynamic($project, Response::MODEL_PROJECT); + ->action(function () { + throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.'); }); Http::patch('/v1/projects/:projectId/oauth2') diff --git a/app/controllers/general.php b/app/controllers/general.php index c6e2eacb33..542effc091 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -25,6 +25,7 @@ use Appwrite\Utopia\Request\Filters\V18 as RequestV18; use Appwrite\Utopia\Request\Filters\V19 as RequestV19; use Appwrite\Utopia\Request\Filters\V20 as RequestV20; use Appwrite\Utopia\Request\Filters\V21 as RequestV21; +use Appwrite\Utopia\Request\Filters\V22 as RequestV22; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filters\V16 as ResponseV16; use Appwrite\Utopia\Response\Filters\V17 as ResponseV17; @@ -32,6 +33,7 @@ use Appwrite\Utopia\Response\Filters\V18 as ResponseV18; use Appwrite\Utopia\Response\Filters\V19 as ResponseV19; use Appwrite\Utopia\Response\Filters\V20 as ResponseV20; use Appwrite\Utopia\Response\Filters\V21 as ResponseV21; +use Appwrite\Utopia\Response\Filters\V22 as ResponseV22; use Appwrite\Utopia\View; use Executor\Executor; use MaxMind\Db\Reader; @@ -892,6 +894,9 @@ Http::init() if (version_compare($requestFormat, '1.9.0', '<')) { $request->addFilter(new RequestV21()); } + if (version_compare($requestFormat, '1.9.1', '<')) { + $request->addFilter(new RequestV22()); + } } $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); @@ -916,6 +921,9 @@ Http::init() */ $responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); if ($responseFormat) { + if (version_compare($responseFormat, '1.9.1', '<')) { + $response->addFilter(new ResponseV22()); + } if (version_compare($responseFormat, '1.9.0', '<')) { $response->addFilter(new ResponseV21()); } @@ -1168,15 +1176,6 @@ Http::error() ->inject('devKey') ->inject('authorization') ->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization) { - $trace = $error->getTrace(); - - foreach (array_slice($trace, 0, 100) as $index => $traceEntry) { - $file = isset($traceEntry['file']) ? $traceEntry['file'] : '[internal function]'; - $line = isset($traceEntry['line']) ? $traceEntry['line'] : ''; - $function = isset($traceEntry['function']) ? $traceEntry['function'] : ''; - Console::error("[$index] $file : $line -> $function()"); - } - $version = System::getEnv('_APP_VERSION', 'UNKNOWN'); $route = $utopia->getRoute(); $class = \get_class($error); @@ -1186,9 +1185,7 @@ Http::error() $line = $error->getLine(); $trace = $error->getTrace(); - if (php_sapi_name() === 'cli') { - Span::error($error); - } + Span::error($error); switch ($class) { case Utopia\Http\Exception::class: @@ -1430,6 +1427,7 @@ Http::error() case 402: // Error allowed publicly case 403: // Error allowed publicly case 404: // Error allowed publicly + case 405: // Error allowed publicly case 408: // Error allowed publicly case 409: // Error allowed publicly case 412: // Error allowed publicly diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 24744c501f..bd54a8300b 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -37,6 +37,7 @@ use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Authorization\Input; use Utopia\Database\Validator\Roles; use Utopia\Http\Http; +use Utopia\Span\Span; use Utopia\System\System; use Utopia\Telemetry\Adapter as Telemetry; use Utopia\Validator\WhiteList; @@ -424,7 +425,7 @@ Http::init() } if (! empty($method)) { - $namespace = $method->getNamespace(); + $namespace = \strtolower($method->getNamespace()); if ( array_key_exists($namespace, $project->getAttribute('services', [])) @@ -435,6 +436,15 @@ Http::init() } } + // Step 8b: Check REST protocol status + if ( + array_key_exists('rest', $project->getAttribute('apis', [])) + && ! $project->getAttribute('apis', [])['rest'] + && ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) + ) { + throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); + } + // Step 9: Validate scope permissions $allowed = (array) $route->getLabel('scope', 'none'); if (empty(\array_intersect($allowed, $scopes))) { @@ -510,14 +520,6 @@ Http::init() default => '', }; - if ( - array_key_exists('rest', $project->getAttribute('apis', [])) - && ! $project->getAttribute('apis', [])['rest'] - && ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) - ) { - throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); - } - /* * Abuse Check */ @@ -633,6 +635,7 @@ Http::init() $isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! $user->isPrivileged($authorization->getRoles()); $key = $request->cacheIdentifier(); + Span::add('storage.cache.key', $key); $cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key)); $cache = new Cache( new Filesystem(APP_STORAGE_CACHE . DIRECTORY_SEPARATOR . 'app-' . $project->getId()) @@ -681,6 +684,8 @@ Http::init() if ($file->isEmpty()) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } + Span::add('storage.bucket.id', $bucketId); + Span::add('storage.file.id', $fileId); // Do not update transformedAt if it's a console user if (! $user->isPrivileged($authorization->getRoles())) { $transformedAt = $file->getAttribute('transformedAt', ''); @@ -708,10 +713,12 @@ Http::init() ->setContentType($cacheLog->getAttribute('mimeType')); $storageCacheOperationsCounter->add(1, ['result' => 'hit']); if (! $isImageTransformation || ! $isDisabled) { + Span::add('storage.cache.hit', true); $response->send($data); } } else { $storageCacheOperationsCounter->add(1, ['result' => 'miss']); + Span::add('storage.cache.hit', false); $response ->addHeader('Cache-Control', 'no-cache, no-store, must-revalidate') ->addHeader('Pragma', 'no-cache') diff --git a/app/init/configs.php b/app/init/configs.php index 35c8e3899d..360a7abc34 100644 --- a/app/init/configs.php +++ b/app/init/configs.php @@ -12,7 +12,7 @@ Config::load('runtimes-v2', __DIR__ . '/../config/runtimes-v2.php', $configAdapt Config::load('template-runtimes', __DIR__ . '/../config/template-runtimes.php', $configAdapter); Config::load('events', __DIR__ . '/../config/events.php', $configAdapter); Config::load('auth', __DIR__ . '/../config/auth.php', $configAdapter); -Config::load('apis', __DIR__ . '/../config/apis.php', $configAdapter); // List of APIs +Config::load('protocols', __DIR__ . '/../config/protocols.php', $configAdapter); Config::load('errors', __DIR__ . '/../config/errors.php', $configAdapter); Config::load('oAuthProviders', __DIR__ . '/../config/oAuthProviders.php', $configAdapter); Config::load('sdks', __DIR__ . '/../config/sdks.php', $configAdapter); diff --git a/app/init/constants.php b/app/init/constants.php index 3b907572ab..f2127cd666 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -46,8 +46,8 @@ const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours -const APP_CACHE_BUSTER = 4321; -const APP_VERSION_STABLE = '1.9.0'; +const APP_CACHE_BUSTER = 4322; +const APP_VERSION_STABLE = '1.9.1'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; const APP_DATABASE_ATTRIBUTE_IP = 'ip'; diff --git a/app/init/resources.php b/app/init/resources.php index fdca88c30e..32d6e0a45f 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -1,6 +1,9 @@ set('publisherForUsage', fn (Publisher $publisher) => new UsagePubli $publisher, new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME)) ), ['publisher']); +$container->set('publisherForExecutions', fn (Publisher $publisher) => new ExecutionPublisher( + $publisher, + new Queue(System::getEnv('_APP_EXECUTIONS_QUEUE_NAME', Event::EXECUTIONS_QUEUE_NAME)) +), ['publisher']); +$container->set('publisherForMigrations', fn (Publisher $publisher) => new MigrationPublisher( + $publisher, + new Queue(System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME)) +), ['publisher']); +$container->set('publisherForStatsResources', fn (Publisher $publisher) => new StatsResourcesPublisher( + $publisher, + new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME)) +), ['publisher']); /** * Platform configuration diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 156e151501..63e58e92f7 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -13,10 +13,8 @@ use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; -use Appwrite\Event\Migration; use Appwrite\Event\Realtime; use Appwrite\Event\Screenshot; -use Appwrite\Event\StatsResources; use Appwrite\Event\Webhook; use Appwrite\Extend\Exception; use Appwrite\Functions\EventProcessor; @@ -163,13 +161,6 @@ return function (Container $container): void { $container->set('queueForCertificates', function (Publisher $publisher) { return new Certificate($publisher); }, ['publisher']); - $container->set('queueForMigrations', function (Publisher $publisher) { - return new Migration($publisher); - }, ['publisher']); - $container->set('queueForStatsResources', function (Publisher $publisher) { - return new StatsResources($publisher); - }, ['publisher']); - $container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) { $adapter = new DatabasePool($pools->get('console')); $database = new Database($adapter, $cache); diff --git a/app/init/worker/message.php b/app/init/worker/message.php index 95477088ce..f893c84858 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -9,7 +9,6 @@ use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; -use Appwrite\Event\Migration; use Appwrite\Event\Realtime; use Appwrite\Event\Screenshot; use Appwrite\Event\Webhook; @@ -344,10 +343,6 @@ return function (Container $container): void { return new Certificate($publisher); }, ['publisher']); - $container->set('queueForMigrations', function (Publisher $publisher) { - return new Migration($publisher); - }, ['publisher']); - $container->set('deviceForSites', function (Document $project, Telemetry $telemetry) { return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId())); }, ['project', 'telemetry']); diff --git a/app/realtime.php b/app/realtime.php index e1a930d4ab..955832e93a 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -653,9 +653,11 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $user = $connectionContainer->get('user'); /** @var User $user */ $logUser = $user; + $apis = $project->getAttribute('apis', []); + // Websocket is what to check, but realtime is checked too for backwards compatibility + $websocketEnabled = $apis['websocket'] ?? $apis['realtime'] ?? true; if ( - array_key_exists('realtime', $project->getAttribute('apis', [])) - && !$project->getAttribute('apis', [])['realtime'] + !$websocketEnabled && !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) ) { throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 8d0ae55394..ef4d4a1fe4 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -993,7 +993,7 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); <<: *x-logging restart: unless-stopped stop_signal: SIGINT - image: openruntimes/executor:0.7.22 + image: openruntimes/executor:0.11.4 networks: - appwrite - runtimes diff --git a/composer.json b/composer.json index b502fa191e..4ad1ae6120 100644 --- a/composer.json +++ b/composer.json @@ -61,7 +61,7 @@ "utopia-php/compression": "0.1.*", "utopia-php/config": "1.*", "utopia-php/console": "0.1.*", - "utopia-php/database": "dev-datetime-exception as 5.21.0", + "utopia-php/database": "5.*", "utopia-php/detector": "0.2.*", "utopia-php/domains": "1.*", "utopia-php/emails": "0.6.*", diff --git a/composer.lock b/composer.lock index 777c02076b..7fd088dbf6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cf3f6bf217746bbfb9d5a5a8c3295eef", + "content-hash": "4fb974e9843f6104e40396e7cad4a833", "packages": [ { "name": "adhocore/jwt", @@ -1996,16 +1996,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.50", + "version": "3.0.51", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b" + "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", - "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748", + "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748", "shasum": "" }, "require": { @@ -2086,7 +2086,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.50" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.51" }, "funding": [ { @@ -2102,7 +2102,7 @@ "type": "tidelift" } ], - "time": "2026-03-19T02:57:58+00:00" + "time": "2026-04-10T01:33:53+00:00" }, { "name": "psr/clock", @@ -2887,16 +2887,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -2948,7 +2948,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.34.0" }, "funding": [ { @@ -2968,20 +2968,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php82", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", - "reference": "5d2ed36f7734637dacc025f179698031951b1692" + "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/5d2ed36f7734637dacc025f179698031951b1692", - "reference": "5d2ed36f7734637dacc025f179698031951b1692", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/34808efe3e68f69685796f7c253a2f1d8ea9df59", + "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59", "shasum": "" }, "require": { @@ -3028,7 +3028,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.34.0" }, "funding": [ { @@ -3048,20 +3048,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", "shasum": "" }, "require": { @@ -3108,7 +3108,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.34.0" }, "funding": [ { @@ -3128,20 +3128,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + "reference": "2c408a6bb0313e6001a83628dc5506100474254e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/2c408a6bb0313e6001a83628dc5506100474254e", + "reference": "2c408a6bb0313e6001a83628dc5506100474254e", "shasum": "" }, "require": { @@ -3188,7 +3188,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.34.0" }, "funding": [ { @@ -3208,7 +3208,7 @@ "type": "tidelift" } ], - "time": "2025-06-23T16:12:55+00:00" + "time": "2026-04-10T16:50:15+00:00" }, { "name": "symfony/service-contracts", @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "dev-datetime-exception", + "version": "5.3.21", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "615a530e6434e74742b6b12dabee5993ba8575fd" + "reference": "ee2d7d4c87b3a3fae954089ad7494ceb454f619d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/615a530e6434e74742b6b12dabee5993ba8575fd", - "reference": "615a530e6434e74742b6b12dabee5993ba8575fd", + "url": "https://api.github.com/repos/utopia-php/database/zipball/ee2d7d4c87b3a3fae954089ad7494ceb454f619d", + "reference": "ee2d7d4c87b3a3fae954089ad7494ceb454f619d", "shasum": "" }, "require": { @@ -3903,9 +3903,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/datetime-exception" + "source": "https://github.com/utopia-php/database/tree/5.3.21" }, - "time": "2026-04-10T11:10:59+00:00" + "time": "2026-04-10T12:38:57+00:00" }, { "name": "utopia-php/detector", @@ -4271,16 +4271,16 @@ }, { "name": "utopia-php/http", - "version": "0.34.19", + "version": "0.34.20", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "995c119f31866cacd42d63b1f922bf86eabb396c" + "reference": "d6b360d555022d16c16d40be51f86180364819f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/995c119f31866cacd42d63b1f922bf86eabb396c", - "reference": "995c119f31866cacd42d63b1f922bf86eabb396c", + "url": "https://api.github.com/repos/utopia-php/http/zipball/d6b360d555022d16c16d40be51f86180364819f8", + "reference": "d6b360d555022d16c16d40be51f86180364819f8", "shasum": "" }, "require": { @@ -4319,9 +4319,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.34.19" + "source": "https://github.com/utopia-php/http/tree/0.34.20" }, - "time": "2026-04-08T10:23:17+00:00" + "time": "2026-04-12T14:25:22+00:00" }, { "name": "utopia-php/image", @@ -5225,16 +5225,16 @@ }, { "name": "utopia-php/vcs", - "version": "3.1.0", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "03b76ad5fd01bc50f809915bca6ff0745ea913af" + "reference": "44a84ab52b42fc12f812b4d7331286b519d39db3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/03b76ad5fd01bc50f809915bca6ff0745ea913af", - "reference": "03b76ad5fd01bc50f809915bca6ff0745ea913af", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/44a84ab52b42fc12f812b4d7331286b519d39db3", + "reference": "44a84ab52b42fc12f812b4d7331286b519d39db3", "shasum": "" }, "require": { @@ -5268,9 +5268,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/3.1.0" + "source": "https://github.com/utopia-php/vcs/tree/3.2.0" }, - "time": "2026-03-24T08:49:14+00:00" + "time": "2026-04-08T16:00:31+00:00" }, { "name": "utopia-php/websocket", @@ -5448,16 +5448,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.17.7", + "version": "1.17.11", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "291471d04c3f0e7b9fcc46668a6255a4c0f2947e" + "reference": "c714ee52659ef5968b3372ff4da0e407140a6250" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/291471d04c3f0e7b9fcc46668a6255a4c0f2947e", - "reference": "291471d04c3f0e7b9fcc46668a6255a4c0f2947e", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/c714ee52659ef5968b3372ff4da0e407140a6250", + "reference": "c714ee52659ef5968b3372ff4da0e407140a6250", "shasum": "" }, "require": { @@ -5493,9 +5493,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.17.7" + "source": "https://github.com/appwrite/sdk-generator/tree/1.17.11" }, - "time": "2026-04-08T08:51:05+00:00" + "time": "2026-04-11T02:42:32+00:00" }, { "name": "brianium/paratest", @@ -7764,16 +7764,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -7823,7 +7823,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.34.0" }, "funding": [ { @@ -7843,20 +7843,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df", + "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df", "shasum": "" }, "require": { @@ -7905,7 +7905,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.34.0" }, "funding": [ { @@ -7925,11 +7925,11 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -7990,7 +7990,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.34.0" }, "funding": [ { @@ -8014,7 +8014,7 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -8070,7 +8070,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.34.0" }, "funding": [ { @@ -8426,18 +8426,9 @@ "time": "2024-11-07T12:36:22+00:00" } ], - "aliases": [ - { - "package": "utopia-php/database", - "version": "dev-datetime-exception", - "alias": "5.21.0", - "alias_normalized": "5.21.0.0" - } - ], + "aliases": [], "minimum-stability": "dev", - "stability-flags": { - "utopia-php/database": 20 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/src/Appwrite/Bus/Listeners/Log.php b/src/Appwrite/Bus/Listeners/Log.php index 9bd539d5fe..076ed5c02d 100644 --- a/src/Appwrite/Bus/Listeners/Log.php +++ b/src/Appwrite/Bus/Listeners/Log.php @@ -3,10 +3,10 @@ namespace Appwrite\Bus\Listeners; use Appwrite\Bus\Events\ExecutionCompleted; -use Appwrite\Event\Execution; +use Appwrite\Event\Message\Execution as ExecutionMessage; +use Appwrite\Event\Publisher\Execution as ExecutionPublisher; use Utopia\Bus\Listener; use Utopia\Database\Document; -use Utopia\Queue\Publisher; class Log extends Listener { @@ -24,16 +24,15 @@ class Log extends Listener { $this ->desc('Persists execution logs to database via queue') - ->inject('publisher') + ->inject('publisherForExecutions') ->callback($this->handle(...)); } - public function handle(ExecutionCompleted $event, Publisher $publisher): void + public function handle(ExecutionCompleted $event, ExecutionPublisher $publisherForExecutions): void { - $queueForExecutions = new Execution($publisher); - $queueForExecutions - ->setExecution(new Document($event->execution)) - ->setProject(new Document($event->project)) - ->trigger(); + $publisherForExecutions->enqueue(new ExecutionMessage( + project: new Document($event->project), + execution: new Document($event->execution), + )); } } diff --git a/src/Appwrite/Event/Execution.php b/src/Appwrite/Event/Execution.php index 398025565c..9e735991ba 100644 --- a/src/Appwrite/Event/Execution.php +++ b/src/Appwrite/Event/Execution.php @@ -53,4 +53,23 @@ class Execution extends Event 'execution' => $this->execution, ]; } + + /** + * Trim payload for the execution event. + * Only the project ID is needed — the worker DI fetches the full project from the platform database. + * + * @return array + */ + protected function trimPayload(): array + { + $trimmed = []; + + if ($this->project) { + $trimmed['project'] = new Document([ + '$id' => $this->project->getId(), + ]); + } + + return $trimmed; + } } diff --git a/src/Appwrite/Event/Message/Execution.php b/src/Appwrite/Event/Message/Execution.php new file mode 100644 index 0000000000..0943c82e4a --- /dev/null +++ b/src/Appwrite/Event/Message/Execution.php @@ -0,0 +1,30 @@ + $this->project->getArrayCopy(), + 'execution' => $this->execution->getArrayCopy(), + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + execution: new Document($data['execution'] ?? []), + ); + } +} diff --git a/src/Appwrite/Event/Message/Migration.php b/src/Appwrite/Event/Message/Migration.php new file mode 100644 index 0000000000..ceeec45461 --- /dev/null +++ b/src/Appwrite/Event/Message/Migration.php @@ -0,0 +1,33 @@ + $this->project->getArrayCopy(), + 'migration' => $this->migration->getArrayCopy(), + 'platform' => $this->platform, + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + migration: new Document($data['migration'] ?? []), + platform: $data['platform'] ?? [], + ); + } +} diff --git a/src/Appwrite/Event/Message/StatsResources.php b/src/Appwrite/Event/Message/StatsResources.php new file mode 100644 index 0000000000..584cbc137a --- /dev/null +++ b/src/Appwrite/Event/Message/StatsResources.php @@ -0,0 +1,27 @@ + $this->project->getArrayCopy(), + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + ); + } +} diff --git a/src/Appwrite/Event/Publisher/Execution.php b/src/Appwrite/Event/Publisher/Execution.php new file mode 100644 index 0000000000..05ea28d540 --- /dev/null +++ b/src/Appwrite/Event/Publisher/Execution.php @@ -0,0 +1,27 @@ +publish($this->queue, $message); + } + + public function getSize(bool $failed = false): int + { + return $this->getQueueSize($this->queue, $failed); + } +} diff --git a/src/Appwrite/Event/Publisher/Migration.php b/src/Appwrite/Event/Publisher/Migration.php new file mode 100644 index 0000000000..fc455a7e95 --- /dev/null +++ b/src/Appwrite/Event/Publisher/Migration.php @@ -0,0 +1,27 @@ +publish($this->queue, $message); + } + + public function getSize(bool $failed = false): int + { + return $this->getQueueSize($this->queue, $failed); + } +} diff --git a/src/Appwrite/Event/Publisher/StatsResources.php b/src/Appwrite/Event/Publisher/StatsResources.php new file mode 100644 index 0000000000..4c04583b15 --- /dev/null +++ b/src/Appwrite/Event/Publisher/StatsResources.php @@ -0,0 +1,34 @@ +publish($this->queue, $message); + } catch (\Throwable $th) { + Console::error('[StatsResources] Failed to publish stats resources message: ' . $th->getMessage()); + return false; + } + } + + public function getSize(bool $failed = false): int + { + return $this->getQueueSize($this->queue, $failed); + } +} diff --git a/src/Appwrite/Event/Realtime.php b/src/Appwrite/Event/Realtime.php index 747fd786f9..f040d91468 100644 --- a/src/Appwrite/Event/Realtime.php +++ b/src/Appwrite/Event/Realtime.php @@ -61,6 +61,26 @@ class Realtime extends Event return $this->subscribers; } + /** + * Reset the event state for long-lived worker processes. + * + * `Event::reset()` clears params/sensitive/event/payload only. Realtime routing also + * depends on `context`, `subscribers`, and `project`/`user` fields, so we clear them too + * to prevent stale state from affecting subsequent triggers. + */ + public function reset(): self + { + parent::reset(); + + $this->subscribers = []; + $this->context = []; + $this->project = null; + $this->user = null; + $this->userId = null; + + return $this; + } + /** * Execute Event. * diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 689724d9f1..65f8a64d68 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -262,11 +262,12 @@ class Resolvers $request = clone $request; $utopia->setResource('request', static fn () => $request); $response->setContentType(Response::CONTENT_TYPE_NULL); + $response->clearSent(); try { $route = $utopia->match($request, fresh: true); - $utopia->execute($route, $request); + $utopia->execute($route, $request, $response); } catch (\Throwable $e) { if ($beforeReject) { $e = $beforeReject($e); diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index e481eebf6e..a01031de9b 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -93,6 +93,7 @@ abstract class Migration '1.8.0' => 'V23', '1.8.1' => 'V23', '1.9.0' => 'V24', + '1.9.1' => 'V24', ]; /** diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index c35eebaea2..aeee280615 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -145,7 +145,11 @@ class XList extends Action $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS); $documentsCacheHit = false; - $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); + try { + $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); + } catch (\Throwable) { + $cachedDocuments = null; + } if ($cachedDocuments !== null && $cachedDocuments !== false && @@ -157,21 +161,30 @@ class XList extends Action } else { $documents = $find(); - // Convert Document objects to arrays for caching $documentsArray = \array_map(function ($doc) { return $doc->getArrayCopy(); }, $documents); - $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); + try { + $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); + } catch (\Throwable) { + } } if ($includeTotal) { $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL); - $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); + try { + $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); + } catch (\Throwable) { + $cachedTotal = null; + } if ($cachedTotal !== null && $cachedTotal !== false) { $total = $cachedTotal; } else { $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); - $dbForProject->getCache()->save($cacheKey, $total, $totalField); + try { + $dbForProject->getCache()->save($cacheKey, $total, $totalField); + } catch (\Throwable) { + } } } else { $total = 0; diff --git a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php index 66ed3e0eab..a50e8f8bdf 100644 --- a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php +++ b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php @@ -650,26 +650,30 @@ class Databases extends Action Document|null $attribute = null, Document|null $index = null, ): void { - $queueForRealtime - ->setProject($project) - ->setSubscribers(['console']) - ->setEvent($event) - ->setParam('databaseId', $database->getId()) - ->setParam('tableId', $collection->getId()) - ->setParam('collectionId', $collection->getId()); + try { + $queueForRealtime + ->setProject($project) + ->setSubscribers(['console']) + ->setEvent($event) + ->setParam('databaseId', $database->getId()) + ->setParam('tableId', $collection->getId()) + ->setParam('collectionId', $collection->getId()); - if (! empty($attribute)) { - $queueForRealtime - ->setParam('columnId', $attribute->getId()) - ->setParam('attributeId', $attribute->getId()) - ->setPayload($attribute->getArrayCopy()); - } - if (! empty($index)) { - $queueForRealtime - ->setParam('indexId', $index->getId()) - ->setPayload($index->getArrayCopy()); + if (! empty($attribute)) { + $queueForRealtime + ->setParam('columnId', $attribute->getId()) + ->setParam('attributeId', $attribute->getId()) + ->setPayload($attribute->getArrayCopy()); + } + if (! empty($index)) { + $queueForRealtime + ->setParam('indexId', $index->getId()) + ->setPayload($index->getArrayCopy()); + } + $queueForRealtime->trigger(); + } finally { + $queueForRealtime->reset(); } - $queueForRealtime->trigger(); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 37292ce984..72474b03f9 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -305,9 +305,7 @@ class Create extends Base if ($async) { if (is_null($scheduledAt)) { - if ($project->getId() != '6862e6a6000cce69f9da') { - $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); - } + $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); $queueForFunctions ->setType('http') ->setExecution($execution) @@ -348,9 +346,7 @@ class Create extends Base ->setAttribute('scheduleInternalId', $schedule->getSequence()) ->setAttribute('scheduledAt', $scheduledAt); - if ($project->getId() != '6862e6a6000cce69f9da') { - $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); - } + $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); } if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { @@ -516,9 +512,7 @@ class Create extends Base ->addMetric(str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_FUNCTIONS, $function->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS), (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) ; - if ($project->getId() != '6862e6a6000cce69f9da') { - $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); - } + $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); } $executionResponse['headers']['x-appwrite-execution-id'] = $execution->getId(); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php index cb3640746f..1f7cc0bf33 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php @@ -11,10 +11,10 @@ use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; -use Appwrite\Event\Migration; +use Appwrite\Event\Publisher\Migration as MigrationPublisher; +use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Screenshot; -use Appwrite\Event\StatsResources; use Appwrite\Event\Webhook; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; @@ -78,13 +78,13 @@ class Get extends Base ->inject('queueForAudits') ->inject('queueForMails') ->inject('queueForFunctions') - ->inject('queueForStatsResources') + ->inject('publisherForStatsResources') ->inject('publisherForUsage') ->inject('queueForWebhooks') ->inject('queueForCertificates') ->inject('queueForBuilds') ->inject('queueForMessaging') - ->inject('queueForMigrations') + ->inject('publisherForMigrations') ->inject('queueForScreenshots') ->callback($this->action(...)); } @@ -98,13 +98,13 @@ class Get extends Base Audit $queueForAudits, Mail $queueForMails, Func $queueForFunctions, - StatsResources $queueForStatsResources, + StatsResourcesPublisher $publisherForStatsResources, UsagePublisher $publisherForUsage, Webhook $queueForWebhooks, Certificate $queueForCertificates, Build $queueForBuilds, Messaging $queueForMessaging, - Migration $queueForMigrations, + MigrationPublisher $publisherForMigrations, Screenshot $queueForScreenshots, ): void { $threshold = (int) $threshold; @@ -115,14 +115,14 @@ class Get extends Base System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME) => $queueForAudits, System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME) => $queueForMails, System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME) => $queueForFunctions, - System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME) => $queueForStatsResources, + System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME) => $publisherForStatsResources, System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME) => $publisherForUsage, System::getEnv('_APP_WEBHOOK_QUEUE_NAME', Event::WEBHOOK_QUEUE_NAME) => $queueForWebhooks, System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $queueForCertificates, System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $queueForBuilds, System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $queueForScreenshots, System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging, - System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $queueForMigrations, + System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $publisherForMigrations, }; $failed = $queue->getSize(failed: true); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Migrations/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Migrations/Get.php index 4faca7d8a4..70bef3562b 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Migrations/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Migrations/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Migrations; -use Appwrite\Event\Migration; +use Appwrite\Event\Publisher\Migration as MigrationPublisher; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -42,16 +42,16 @@ class Get extends Base contentType: ContentType::JSON )) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForMigrations') + ->inject('publisherForMigrations') ->inject('response') ->callback($this->action(...)); } - public function action(int|string $threshold, Migration $queueForMigrations, Response $response): void + public function action(int|string $threshold, MigrationPublisher $publisherForMigrations, Response $response): void { $threshold = (int) $threshold; - $size = $queueForMigrations->getSize(); + $size = $publisherForMigrations->getSize(); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsResources/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsResources/Get.php index 57605298fd..5ab0aa2532 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsResources/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsResources/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\StatsResources; -use Appwrite\Event\StatsResources; +use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -42,16 +42,16 @@ class Get extends Base contentType: ContentType::JSON )) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForStatsResources') + ->inject('publisherForStatsResources') ->inject('response') ->callback($this->action(...)); } - public function action(int|string $threshold, StatsResources $queueForStatsResources, Response $response): void + public function action(int|string $threshold, StatsResourcesPublisher $publisherForStatsResources, Response $response): void { $threshold = (int) $threshold; - $size = $queueForStatsResources->getSize(); + $size = $publisherForStatsResources->getSize(); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php index 59d2c1db49..236c091c31 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -62,7 +62,7 @@ class Create extends Base )) ->param('keyId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key 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('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false) ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('queueForEvents') @@ -72,13 +72,10 @@ class Create extends Base ->callback($this->action(...)); } - /** - * @param array|null $scopes - */ public function action( string $keyId, string $name, - ?array $scopes, + array $scopes, ?string $expire, Response $response, QueueEvent $queueForEvents, @@ -95,7 +92,7 @@ class Create extends Base 'resourceId' => $project->getId(), 'resourceType' => 'projects', 'name' => $name, - 'scopes' => $scopes ?? [], + 'scopes' => $scopes, 'expire' => $expire, 'sdks' => [], 'accessedAt' => null, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php index 8759faacc1..9193bdbfdf 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php @@ -60,7 +60,7 @@ class Update extends Base )) ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false) ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('queueForEvents') @@ -70,13 +70,10 @@ class Update extends Base ->callback($this->action(...)); } - /** - * @param array|null $scopes - */ public function action( string $keyId, string $name, - ?array $scopes, + array $scopes, ?string $expire, Response $response, QueueEvent $queueForEvents, @@ -92,7 +89,7 @@ class Update extends Base $updates = new Document([ 'name' => $name, - 'scopes' => $scopes ?? [], + 'scopes' => $scopes, 'expire' => $expire, ]); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php index 6794901c47..2fca0ace6c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php @@ -64,7 +64,7 @@ class Create extends Action )) ->param('platformId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform 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('name', null, new Text(128), 'Platform name. Max length: 128 chars.') - ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true) // Optional for backwards compatibility + ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true, example: 'app.example.com') // Optional for backwards compatibility ->param('key', '', new Text(256), 'Deprecated: Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility ->param('type', '', new Text(256), 'Deprecated: Platform type. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility ->inject('request') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php index 1e1f1b5ac1..62d209ea25 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php @@ -56,7 +56,7 @@ class Update extends Action )) ->param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform']) ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') - ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true) // Optional for backwards compatibility + ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true, example: 'app.example.com') // Optional for backwards compatibility ->param('key', '', new Text(256), 'Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility ->inject('response') ->inject('queueForEvents') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php new file mode 100644 index 0000000000..1fa2df3566 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php @@ -0,0 +1,80 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/protocols/:protocolId/status') + ->httpAlias('/v1/projects/:projectId/api') + ->desc('Update project protocol status') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'protocols.[protocol].update') + ->label('audits.event', 'project.protocols.[protocol].update') + ->label('audits.resource', 'project.protocols/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: null, + name: 'updateProtocolStatus', + description: <<param('protocolId', '', new WhiteList(array_keys(Config::getParam('protocols')), true), 'Protocol name. Can be one of: ' . \implode(', ', array_keys(Config::getParam('protocols')))) + ->param('enabled', null, new Boolean(), 'Protocol status.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $protocolId, + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $protocols = $project->getAttribute('apis', []); + $protocols[$protocolId] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'apis' => $protocols, + ]))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php new file mode 100644 index 0000000000..35be32a604 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php @@ -0,0 +1,80 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/services/:serviceId/status') + ->httpAlias('/v1/projects/:projectId/service') + ->desc('Update project service status') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'services.[service].update') + ->label('audits.event', 'project.services.[service].update') + ->label('audits.resource', 'project.services/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: null, + name: 'updateServiceStatus', + description: <<param('serviceId', '', new WhiteList(array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])), true), 'Service name. Can be one of: '.\implode(', ', array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])))) + ->param('enabled', null, new Boolean(), 'Service status.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $serviceId, + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $services = $project->getAttribute('services', []); + $services[$serviceId] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'services' => $services, + ]))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 9fd8366097..a2c94928e2 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -22,6 +22,8 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as Updat use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as CreateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; +use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; +use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -40,6 +42,8 @@ class Http extends Service // Project $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); + $this->addAction(UpdateProjectProtocolStatus::getName(), new UpdateProjectProtocolStatus()); + $this->addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index 22e55e672e..f0ee045214 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -15,7 +15,6 @@ use Utopia\Compression\Algorithms\GZIP; use Utopia\Compression\Algorithms\Zstd; use Utopia\Compression\Compression; use Utopia\Config\Config; -use Utopia\Console; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; @@ -26,6 +25,7 @@ use Utopia\Http\Adapter\Swoole\Request; use Utopia\Image\Image; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Utopia\Span\Span; use Utopia\Storage\Device; use Utopia\System\System; use Utopia\Validator\HexColor; @@ -269,7 +269,17 @@ class Get extends Action $totalTime = \microtime(true) - $startTime; - Console::info("File preview rendered,project=" . $project->getId() . ",bucket=" . $bucketId . ",file=" . $file->getId() . ",uri=" . $request->getURI() . ",total=" . $totalTime . ",rendering=" . $renderingTime . ",decryption=" . $decryptionTime . ",decompression=" . $decompressionTime . ",download=" . $downloadTime); + Span::add('storage.file.id', $file->getId()); + Span::add('storage.bucket.id', $bucketId); + Span::add('storage.file.size_bytes', $file->getAttribute('sizeActual')); + if (!empty($type)) { + Span::add('storage.file.extension', $type); + } + Span::add('storage.timing.download_seconds', $downloadTime); + Span::add('storage.timing.decryption_seconds', $decryptionTime); + Span::add('storage.timing.decompression_seconds', $decompressionTime); + Span::add('storage.timing.rendering_seconds', $renderingTime); + Span::add('storage.timing.total_seconds', $totalTime); $contentType = (\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['jpg']; diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php index 3c716202af..e192dff2a0 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php @@ -21,6 +21,7 @@ use Utopia\Platform\Scope\HTTP; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Multiple; +use Utopia\Validator\Nullable; use Utopia\Validator\Text; use Utopia\Validator\URL; @@ -65,9 +66,10 @@ class Create extends Action ->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('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) + ->param('tls', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) + ->param('authUsername', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) + ->param('authPassword', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) + ->param('secret', null, new Nullable(new Text(256, 8)), 'Webhook secret key. If not provided, a new key will be generated automatically. Key must be at least 8 characters long, and at max 256 characters.', optional: true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -85,9 +87,10 @@ class Create extends Action string $name, array $events, bool $enabled, - bool $security, - string $httpUser, - string $httpPass, + bool $tls, + string $authUsername, + string $authPassword, + ?string $secret, Response $response, Document $project, QueueEvent $queueForEvents, @@ -104,10 +107,10 @@ class Create extends Action 'name' => $name, 'events' => $events, 'url' => $url, - 'security' => $security, - 'httpUser' => $httpUser, - 'httpPass' => $httpPass, - 'signatureKey' => \bin2hex(\random_bytes(64)), + 'security' => $tls, + 'httpUser' => $authUsername, + 'httpPass' => $authPassword, + 'signatureKey' => $secret ?? \bin2hex(\random_bytes(64)), 'enabled' => $enabled, ]); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php index ebe6fa7bcb..a42500ca46 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php @@ -72,6 +72,8 @@ class Get extends Action throw new Exception(Exception::WEBHOOK_NOT_FOUND); } + $webhook->removeAttribute('signatureKey'); + $response->dynamic($webhook, Response::MODEL_WEBHOOK); } } diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php similarity index 77% rename from src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php rename to src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php index 51c5bfbaf9..fbff94735e 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/webhooks/:webhookId/signature') + ->setHttpPath('/v1/webhooks/:webhookId/secret') ->httpAlias('/v1/projects/:projectId/webhooks/:webhookId/signature') - ->desc('Update webhook signature key') + ->desc('Update webhook secret key') ->groups(['api', 'webhooks']) ->label('scope', 'webhooks.write') ->label('event', 'webhooks.[webhookId].update') @@ -39,9 +41,9 @@ class Update extends Action ->label('sdk', new Method( namespace: 'webhooks', group: null, - name: 'updateSignature', + name: 'updateSecret', description: <<param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook ID.', false, ['dbForPlatform']) + ->param('secret', null, new Nullable(new Text(256, 8)), 'Webhook secret key. If not provided, a new key will be generated automatically. Key must be at least 8 characters long, and at max 256 characters.', optional: true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -62,6 +65,7 @@ class Update extends Action public function action( string $webhookId, + ?string $secret, Response $response, Document $project, QueueEvent $queueForEvents, @@ -78,7 +82,7 @@ class Update extends Action } $updates = new Document([ - 'signatureKey' => \bin2hex(\random_bytes(64)), + 'signatureKey' => $secret ?? \bin2hex(\random_bytes(64)), ]); $webhook = $authorization->skip(fn () => $dbForPlatform->updateDocument('webhooks', $webhook->getId(), $updates)); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php index 968c15dae2..e7b516449e 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php @@ -63,9 +63,9 @@ class Update extends Action ->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) + ->param('tls', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) + ->param('authUsername', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) + ->param('authPassword', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -80,9 +80,9 @@ class Update extends Action string $url, array $events, bool $enabled, - bool $security, - string $httpUser, - string $httpPass, + bool $tls, + string $authUsername, + string $authPassword, Response $response, Document $project, QueueEvent $queueForEvents, @@ -102,9 +102,9 @@ class Update extends Action 'name' => $name, 'events' => $events, 'url' => $url, - 'security' => $security, - 'httpUser' => $httpUser, - 'httpPass' => $httpPass, + 'security' => $tls, + 'httpUser' => $authUsername, + 'httpPass' => $authPassword, 'enabled' => $enabled, ]); @@ -118,6 +118,8 @@ class Update extends Action $queueForEvents->setParam('webhookId', $webhook->getId()); + $webhook->removeAttribute('signatureKey'); + $response->dynamic($webhook, Response::MODEL_WEBHOOK); } } diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php index 2a4c4f9e59..f0961b541c 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php @@ -78,6 +78,15 @@ class XList extends Action throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } + foreach ($queries as $query) { + $attribute = $query->getAttribute(); + if ($attribute === 'authUsername') { + $query->setAttribute('httpUser'); + } elseif ($attribute === 'tls') { + $query->setAttribute('security'); + } + } + $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); $cursor = Query::getCursorQueries($queries, false); @@ -111,6 +120,10 @@ class XList extends Action throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } + foreach ($webhooks as $webhook) { + $webhook->removeAttribute('signatureKey'); + } + $response->dynamic(new Document([ 'webhooks' => $webhooks, 'total' => $total, diff --git a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php index 4805de6ebc..0e9c39a762 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php @@ -6,7 +6,7 @@ use Appwrite\Platform\Modules\Webhooks\Http\Init; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Create as CreateWebhook; 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\Secret\Update as UpdateWebhookSecret; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Update as UpdateWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\XList as ListWebhooks; use Utopia\Platform\Service; @@ -26,6 +26,6 @@ class Http extends Service $this->addAction(GetWebhook::getName(), new GetWebhook()); $this->addAction(DeleteWebhook::getName(), new DeleteWebhook()); $this->addAction(UpdateWebhook::getName(), new UpdateWebhook()); - $this->addAction(UpdateWebhookSignature::getName(), new UpdateWebhookSignature()); + $this->addAction(UpdateWebhookSecret::getName(), new UpdateWebhookSecret()); } } diff --git a/src/Appwrite/Platform/Tasks/StatsResources.php b/src/Appwrite/Platform/Tasks/StatsResources.php index 220e377619..8699d73bbb 100644 --- a/src/Appwrite/Platform/Tasks/StatsResources.php +++ b/src/Appwrite/Platform/Tasks/StatsResources.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Tasks; -use Appwrite\Event\StatsResources as EventStatsResources; +use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Platform\Action; use Utopia\Console; use Utopia\Database\Database; @@ -43,11 +43,11 @@ class StatsResources extends Action ->desc('Schedules projects for usage count') ->inject('dbForPlatform') ->inject('logError') - ->inject('queueForStatsResources') + ->inject('publisherForStatsResources') ->callback($this->action(...)); } - public function action(Database $dbForPlatform, callable $logError, EventStatsResources $queueForStatsResources): void + public function action(Database $dbForPlatform, callable $logError, StatsResourcesPublisher $publisherForStatsResources): void { $this->logError = $logError; $this->dbForPlatform = $dbForPlatform; @@ -60,7 +60,7 @@ class StatsResources extends Action $interval = (int) System::getEnv('_APP_STATS_RESOURCES_INTERVAL', '3600'); - Console::loop(function () use ($queueForStatsResources) { + Console::loop(function () use ($publisherForStatsResources) { $last24Hours = (new \DateTime())->sub(\DateInterval::createFromDateString('24 hours')); /** @@ -69,10 +69,10 @@ class StatsResources extends Action $this->foreachDocument($this->dbForPlatform, 'projects', [ Query::greaterThanEqual('accessedAt', DateTime::format($last24Hours)), Query::equal('region', [System::getEnv('_APP_REGION', 'default')]) - ], function ($project) use ($queueForStatsResources) { - $queueForStatsResources - ->setProject($project) - ->trigger(); + ], function ($project) use ($publisherForStatsResources) { + $publisherForStatsResources->enqueue(new \Appwrite\Event\Message\StatsResources( + project: $project, + )); Console::success('project: ' . $project->getId() . '(' . $project->getSequence() . ')' . ' queued'); }); }, $interval); diff --git a/src/Appwrite/Platform/Workers/Executions.php b/src/Appwrite/Platform/Workers/Executions.php index d874e26267..673e9de791 100644 --- a/src/Appwrite/Platform/Workers/Executions.php +++ b/src/Appwrite/Platform/Workers/Executions.php @@ -2,9 +2,9 @@ namespace Appwrite\Platform\Workers; +use Appwrite\Event\Message\Execution; use Exception; use Utopia\Database\Database; -use Utopia\Database\Document; use Utopia\Platform\Action; use Utopia\Queue\Message; @@ -32,21 +32,13 @@ class Executions extends Action Message $message, Database $dbForProject, ): void { - $payload = $message->getPayload() ?? []; - - if (empty($payload)) { - throw new Exception('Missing payload'); - } - - $execution = new Document($payload['execution'] ?? []); + $executionMessage = Execution::fromArray($message->getPayload() ?? []); + $execution = $executionMessage->execution; if ($execution->isEmpty()) { throw new Exception('Missing execution'); } - $project = new Document($payload['project'] ?? []); - if ($project->getId() != '6862e6a6000cce69f9da') { - $dbForProject->upsertDocument('executions', $execution); - } + $dbForProject->upsertDocument('executions', $execution); } } diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 43f5c97ba6..118ff7acf9 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -4,6 +4,7 @@ namespace Appwrite\Platform\Workers; use Ahc\Jwt\JWT; use Appwrite\Event\Mail; +use Appwrite\Event\Message\Migration; use Appwrite\Event\Message\Usage as UsageMessage; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; @@ -129,7 +130,7 @@ class Migrations extends Action array $plan, Authorization $authorization, ): void { - $payload = $message->getPayload() ?? []; + $migrationMessage = Migration::fromArray($message->getPayload() ?? []); $this->getDatabasesDB = $getDatabasesDB; $this->getProjectDB = $getProjectDB; @@ -137,12 +138,7 @@ class Migrations extends Action $this->deviceForFiles = $deviceForFiles; $this->plan = $plan; - if (empty($payload)) { - throw new Exception('Missing payload'); - } - - $events = $payload['events'] ?? []; - $migration = new Document($payload['migration'] ?? []); + $migration = $migrationMessage->migration; if ($migration->isEmpty()) { throw new \Exception('Migration not found'); @@ -161,11 +157,7 @@ class Migrations extends Action $this->project = $project; $this->logError = $logError; - $platform = $payload['platform'] ?? Config::getParam('platform', []); - - if (!empty($events)) { - return; - } + $platform = $migrationMessage->platform ?: Config::getParam('platform', []); try { $this->processMigration( diff --git a/src/Appwrite/Platform/Workers/StatsResources.php b/src/Appwrite/Platform/Workers/StatsResources.php index b2823d3722..db214f5d32 100644 --- a/src/Appwrite/Platform/Workers/StatsResources.php +++ b/src/Appwrite/Platform/Workers/StatsResources.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Workers; +use Appwrite\Event\Message\StatsResources as StatsResourcesMessage; use Appwrite\Platform\Action; use Exception; use Throwable; @@ -67,8 +68,8 @@ class StatsResources extends Action { $this->logError = $logError; - $payload = $message->getPayload() ?? []; - if (empty($payload)) { + $statsResources = StatsResourcesMessage::fromArray($message->getPayload() ?? []); + if ($statsResources->project->isEmpty()) { throw new Exception('Missing payload'); } diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 02fac12a7a..91b090a9f6 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -78,6 +78,18 @@ abstract class Format ], ]; + private const array REQUEST_PARAMETER_OVERRIDES = [ + [ + 'namespace' => 'project', + 'methods' => [ + 'createWebPlatform', + 'updateWebPlatform', + ], + 'parameter' => 'hostname', + 'required' => true, + ], + ]; + protected array $enumBlacklist = []; public function __construct(Container $container, array $services, array $routes, array $models, array $keys, int $authCount, string $platform) @@ -774,8 +786,38 @@ abstract class Format return $values; } + protected function getRequestParameterConfig(string $service, string $method, string $param, bool $optional, bool $nullable, mixed $default): array + { + $config = [ + 'required' => !$optional, + 'nullable' => $nullable, + ]; + + foreach (self::REQUEST_PARAMETER_OVERRIDES as $override) { + if ( + $override['namespace'] !== $service + || !\in_array($method, $override['methods'], true) + || $override['parameter'] !== $param + ) { + continue; + } + + $config['required'] = $override['required'] ?? $config['required']; + $config['nullable'] = $override['nullable'] ?? $config['nullable']; + break; + } + + $config['emitDefault'] = !$config['required'] && !\is_null($default); + + return $config; + } + public function getResponseEnumName(string $model, string $param): ?string { + if ($param === 'type' && \str_starts_with($model, 'platform') && $model !== 'platformList') { + return 'PlatformType'; + } + if ($param !== 'status') { return null; } diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 7da48fc2ca..b611558826 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -381,14 +381,23 @@ class OpenAPI3 extends Format */ $validator = $this->getValidator($param); + $isNullable = $validator instanceof Nullable; + + $parameter = $this->getRequestParameterConfig( + $sdk->getNamespace() ?? '', + $methodName, + $name, + $param['optional'], + $isNullable, + $param['default'], + ); + $node = [ 'name' => $name, 'description' => $param['description'], - 'required' => !$param['optional'], + 'required' => $parameter['required'], ]; - $isNullable = $validator instanceof Nullable; - if ($isNullable) { /** @var Nullable $validator */ $validator = $validator->getValidator(); @@ -735,7 +744,7 @@ class OpenAPI3 extends Format break; } - if ($param['optional'] && !\is_null($param['default'])) { // Param has default value + if ($parameter['emitDefault']) { // Param has default value $node['schema']['default'] = $param['default']; } @@ -746,7 +755,7 @@ class OpenAPI3 extends Format $node['in'] = 'query'; $temp['parameters'][] = $node; } else { // Param is in payload - if (!$param['optional']) { + if ($node['required']) { $bodyRequired[] = $name; } @@ -783,7 +792,7 @@ class OpenAPI3 extends Format $body['content'][$consumes[0]]['schema']['properties'][$name]['x-global'] = true; } - if ($isNullable) { + if ($parameter['nullable']) { $body['content'][$consumes[0]]['schema']['properties'][$name]['x-nullable'] = true; } } diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index d95f99bb70..413239f000 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -383,14 +383,23 @@ class Swagger2 extends Format /** @var Validator $validator */ $validator = $this->getValidator($param); + $isNullable = $validator instanceof Nullable; + + $parameter = $this->getRequestParameterConfig( + $sdk->getNamespace() ?? '', + $methodName, + $name, + $param['optional'], + $isNullable, + $param['default'], + ); + $node = [ 'name' => $name, 'description' => $param['description'], - 'required' => !$param['optional'], + 'required' => $parameter['required'], ]; - $isNullable = $validator instanceof Nullable; - if ($isNullable) { /** @var Nullable $validator */ $validator = $validator->getValidator(); @@ -711,7 +720,7 @@ class Swagger2 extends Format break; } - if ($param['optional'] && !\is_null($param['default'])) { // Param has default value + if ($parameter['emitDefault']) { // Param has default value $node['default'] = $param['default']; } @@ -729,7 +738,7 @@ class Swagger2 extends Format continue; } - if (!$param['optional']) { + if ($node['required']) { $bodyRequired[] = $name; } @@ -755,7 +764,7 @@ class Swagger2 extends Format $body['schema']['properties'][$name]['x-global'] = true; } - if ($isNullable) { + if ($parameter['nullable']) { $body['schema']['properties'][$name]['x-nullable'] = true; } diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php index fa20bf34ef..07e27f06cb 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php @@ -5,6 +5,28 @@ namespace Appwrite\Utopia\Database\Validator\Queries; class Webhooks extends Base { public const ALLOWED_ATTRIBUTES = [ + 'name', + 'url', + 'authUsername', + 'tls', + 'events', + 'enabled', + 'logs', + 'attempts', + ]; + + /** + * Map API attribute names to DB column names. + */ + private const ATTRIBUTE_ALIASES = [ + 'tls' => 'security', + 'authUsername' => 'httpUser', + ]; + + /** + * DB column names used for schema validation. + */ + private const DB_ATTRIBUTES = [ 'name', 'url', 'httpUser', @@ -21,6 +43,26 @@ class Webhooks extends Base */ public function __construct() { - parent::__construct('webhooks', self::ALLOWED_ATTRIBUTES); + parent::__construct('webhooks', self::DB_ATTRIBUTES); + } + + /** + * Convert API attribute names to DB column names in query strings before validation. + */ + public function isValid($value): bool + { + if (\is_array($value)) { + foreach ($value as &$queryString) { + if (!\is_string($queryString)) { + continue; + } + foreach (self::ATTRIBUTE_ALIASES as $alias => $dbName) { + $queryString = \str_replace('"' . $alias . '"', '"' . $dbName . '"', $queryString); + } + } + unset($queryString); + } + + return parent::isValid($value); } } diff --git a/src/Appwrite/Utopia/Request/Filters/V22.php b/src/Appwrite/Utopia/Request/Filters/V22.php new file mode 100644 index 0000000000..4f1e746775 --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V22.php @@ -0,0 +1,93 @@ +parseUpdateServiceStatus($content); + break; + case 'project.updateProtocolStatus': + $content = $this->parseUpdateProtocolStatus($content); + break; + case 'project.createKey': + case 'project.updateKey': + $content = $this->parseKeyScopes($content); + break; + case 'webhooks.create': + case 'webhooks.update': + $content = $this->parseWebhook($content); + break; + } + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 295348c665..5cd0e8366a 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -626,6 +626,17 @@ class Response extends SwooleResponse return $this->payload; } + /** + * Reset the sent flag so the response can be reused for another + * action execution (e.g. batched GraphQL queries that share one + * Response instance). + */ + public function clearSent(): static + { + $this->sent = false; + return $this; + } + /** * Function to add a response filter, the order of filters are first in - first out. * diff --git a/src/Appwrite/Utopia/Response/Filters/V22.php b/src/Appwrite/Utopia/Response/Filters/V22.php new file mode 100644 index 0000000000..4e295e43dd --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V22.php @@ -0,0 +1,45 @@ + $this->parseProject($content), + Response::MODEL_WEBHOOK => $this->parseWebhook($content), + Response::MODEL_WEBHOOK_LIST => $this->handleList($content, 'webhooks', fn ($item) => $this->parseWebhook($item)), + default => $content, + }; + } + + private function parseProject(array $content): array + { + foreach (['protocolStatusForRest', 'protocolStatusForGraphql', 'protocolStatusForWebsocket'] as $field) { + unset($content[$field]); + } + return $content; + } + + private function parseWebhook(array $content): array + { + $content['security'] = $content['tls'] ?? true; + unset($content['tls']); + + $content['httpUser'] = $content['authUsername'] ?? ''; + unset($content['authUsername']); + + $content['httpPass'] = $content['authPassword'] ?? ''; + unset($content['authPassword']); + + $content['signatureKey'] = $content['secret'] ?? ''; + unset($content['secret']); + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 1ef73aa769..4cb038fc37 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -344,6 +344,22 @@ class Project extends Model ]) ; } + + $apis = Config::getParam('protocols', []); + + foreach ($apis as $api) { + $name = $api['name'] ?? ''; + $key = $api['key'] ?? ''; + + $this + ->addRule('protocolStatusFor' . ucfirst($key), [ + 'type' => self::TYPE_BOOLEAN, + 'description' => $name . ' protocol status', + 'example' => true, + 'default' => true, + ]) + ; + } } /** @@ -375,6 +391,7 @@ class Project extends Model { $this->expandSmtpFields($document); $this->expandServiceFields($document); + $this->expandApiFields($document); $this->expandAuthFields($document); $this->expandOAuthProviders($document); @@ -419,6 +436,22 @@ class Project extends Model } } + private function expandApiFields(Document $document): void + { + if (!$document->isSet('apis')) { + return; + } + + $values = $document->getAttribute('apis', []); + $apis = Config::getParam('protocols', []); + + foreach ($apis as $api) { + $key = $api['key'] ?? ''; + $value = $values[$key] ?? true; + $document->setAttribute('protocolStatusFor' . ucfirst($key), $value); + } + } + private function expandAuthFields(Document $document): void { if (!$document->isSet('auths')) { diff --git a/src/Appwrite/Utopia/Response/Model/Rule.php b/src/Appwrite/Utopia/Response/Model/Rule.php index 86ac6f470e..1ff854e7ce 100644 --- a/src/Appwrite/Utopia/Response/Model/Rule.php +++ b/src/Appwrite/Utopia/Response/Model/Rule.php @@ -66,8 +66,9 @@ class Rule extends Model ]) ->addRule('deploymentResourceType', [ 'type' => self::TYPE_ENUM, + 'required' => false, 'description' => 'Type of deployment. Possible values are "function", "site". Used if rule\'s type is "deployment".', - 'default' => '', + 'default' => null, 'example' => 'function', 'enum' => ['function', 'site'], ]) diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index 1ae8d5cb7b..6a0197e4a1 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -4,6 +4,7 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; +use Utopia\Database\Document; class Webhook extends Model { @@ -50,27 +51,27 @@ class Webhook extends Model ], 'array' => true, ]) - ->addRule('security', [ + ->addRule('tls', [ 'type' => self::TYPE_BOOLEAN, - 'description' => 'Indicated if SSL / TLS Certificate verification is enabled.', + 'description' => 'Indicates if SSL / TLS certificate verification is enabled.', 'default' => true, 'example' => true, ]) - ->addRule('httpUser', [ + ->addRule('authUsername', [ 'type' => self::TYPE_STRING, 'description' => 'HTTP basic authentication username.', 'default' => '', 'example' => 'username', ]) - ->addRule('httpPass', [ + ->addRule('authPassword', [ 'type' => self::TYPE_STRING, 'description' => 'HTTP basic authentication password.', 'default' => '', 'example' => 'password', ]) - ->addRule('signatureKey', [ + ->addRule('secret', [ 'type' => self::TYPE_STRING, - 'description' => 'Signature key which can be used to validated incoming', + 'description' => 'Signature key which can be used to validate incoming webhook payloads. Only returned on creation and secret rotation.', 'default' => '', 'example' => 'ad3d581ca230e2b7059c545e5a', ]) @@ -94,6 +95,23 @@ class Webhook extends Model ]); } + public function filter(Document $document): Document + { + $document->setAttribute('tls', $document->getAttribute('security')); + $document->removeAttribute('security'); + + $document->setAttribute('authUsername', $document->getAttribute('httpUser')); + $document->removeAttribute('httpUser'); + + $document->setAttribute('authPassword', $document->getAttribute('httpPass')); + $document->removeAttribute('httpPass'); + + $document->setAttribute('secret', $document->getAttribute('signatureKey')); + $document->removeAttribute('signatureKey'); + + return $document; + } + /** * Get Name * diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 10641019f0..a62a1e8ba3 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -216,7 +216,7 @@ trait ProjectCustom 'users.*' ], 'url' => 'http://request-catcher-webhook:5000/', - 'security' => false, + 'tls' => false, ]); $this->assertEquals(201, $webhook['headers']['status-code']); @@ -243,7 +243,7 @@ trait ProjectCustom 'apiKey' => $key['body']['secret'], 'devKey' => $devKey['body']['secret'], 'webhookId' => $webhook['body']['$id'], - 'signatureKey' => $webhook['body']['signatureKey'], + 'signatureKey' => $webhook['body']['secret'], ]; } diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php index fda5ef377f..505c7f6539 100644 --- a/tests/e2e/Services/Project/KeysBase.php +++ b/tests/e2e/Services/Project/KeysBase.php @@ -78,12 +78,12 @@ trait KeysBase $this->deleteKey($key['body']['$id']); } - public function testCreateKeyWithNullScopes(): void + public function testCreateKeyWithEmptyScopes(): void { $key = $this->createKey( ID::unique(), - 'Null Scopes Key', - null, + 'Empty Scopes Key', + [], ); $this->assertSame(201, $key['headers']['status-code']); @@ -93,6 +93,58 @@ trait KeysBase $this->deleteKey($key['body']['$id']); } + public function testCreateKeyWithNullScopesV22BackwardCompat(): void + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ]; + $headers = array_merge($headers, $this->getHeaders()); + + $key = $this->client->call(Client::METHOD_POST, '/project/keys', $headers, [ + 'keyId' => ID::unique(), + 'name' => 'V22 Compat Key', + 'scopes' => null, + ]); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame([], $key['body']['scopes']); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testUpdateKeyWithNullScopesV22BackwardCompat(): void + { + $key = $this->createKey( + ID::unique(), + 'V22 Update Compat Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ]; + $headers = array_merge($headers, $this->getHeaders()); + + $updated = $this->client->call(Client::METHOD_PUT, '/project/keys/' . $keyId, $headers, [ + 'name' => 'V22 Update Compat Key', + 'scopes' => null, + ]); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame([], $updated['body']['scopes']); + + // Cleanup + $this->deleteKey($keyId); + } + public function testCreateKeyWithoutAuthentication(): void { $response = $this->createKey( diff --git a/tests/e2e/Services/Project/ProtocolsBase.php b/tests/e2e/Services/Project/ProtocolsBase.php new file mode 100644 index 0000000000..0187fc8463 --- /dev/null +++ b/tests/e2e/Services/Project/ProtocolsBase.php @@ -0,0 +1,261 @@ +updateProtocolStatus($protocol, false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['protocolStatusFor' . ucfirst($protocol)]); + } + + // Cleanup + foreach (self::$protocols as $protocol) { + $this->updateProtocolStatus($protocol, true); + } + } + + public function testEnableProtocol(): void + { + // Disable first + foreach (self::$protocols as $protocol) { + $this->updateProtocolStatus($protocol, false); + } + + // Re-enable + foreach (self::$protocols as $protocol) { + $response = $this->updateProtocolStatus($protocol, true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['protocolStatusFor' . ucfirst($protocol)]); + } + } + + public function testDisableProtocolIdempotent(): void + { + $first = $this->updateProtocolStatus('rest', false); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['protocolStatusForRest']); + + $second = $this->updateProtocolStatus('rest', false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['protocolStatusForRest']); + + // Cleanup + $this->updateProtocolStatus('rest', true); + } + + public function testEnableProtocolIdempotent(): void + { + $first = $this->updateProtocolStatus('rest', true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['protocolStatusForRest']); + + $second = $this->updateProtocolStatus('rest', true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['protocolStatusForRest']); + } + + public function testDisabledRestBlocksClientRequest(): void + { + $this->updateProtocolStatus('rest', false); + + $response = $this->client->call(Client::METHOD_GET, '/locale/countries', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(403, $response['headers']['status-code']); + $this->assertSame('general_api_disabled', $response['body']['type']); + + // Cleanup + $this->updateProtocolStatus('rest', true); + } + + public function testEnabledRestAllowsClientRequest(): void + { + $this->updateProtocolStatus('rest', false); + $this->updateProtocolStatus('rest', true); + + $response = $this->client->call(Client::METHOD_GET, '/locale/countries', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(200, $response['headers']['status-code']); + } + + public function testDisabledGraphqlBlocksClientRequest(): void + { + $this->updateProtocolStatus('graphql', false); + + $response = $this->client->call(Client::METHOD_POST, '/graphql', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'query' => '{ localeListCountries { total } }', + ]); + + $this->assertSame(403, $response['headers']['status-code']); + $this->assertSame('general_api_disabled', $response['body']['type']); + + // Cleanup + $this->updateProtocolStatus('graphql', true); + } + + public function testDisableOneProtocolDoesNotAffectOther(): void + { + $this->updateProtocolStatus('graphql', false); + + // REST should still work + $response = $this->client->call(Client::METHOD_GET, '/locale/countries', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(200, $response['headers']['status-code']); + + // Cleanup + $this->updateProtocolStatus('graphql', true); + } + + public function testDisabledRestBlocksAllServiceEndpoints(): void + { + $endpoints = [ + 'account' => '/account', + 'teams' => '/teams', + 'databases' => '/databases', + 'storage' => '/storage/buckets', + 'functions' => '/functions', + 'sites' => '/sites', + 'locale' => '/locale', + 'health' => '/health', + 'users' => '/users', + 'messaging' => '/messaging/providers', + 'migrations' => '/migrations', + ]; + + $this->updateProtocolStatus('rest', false); + + foreach ($endpoints as $service => $path) { + $response = $this->client->call(Client::METHOD_GET, $path, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(403, $response['headers']['status-code'], 'Disabled REST protocol should block ' . $service . ' endpoint (got ' . $response['headers']['status-code'] . ')'); + $this->assertSame('general_api_disabled', $response['body']['type'], 'Disabled REST protocol should return general_api_disabled for ' . $service); + } + + // Cleanup + $this->updateProtocolStatus('rest', true); + } + + public function testReenabledRestAllowsAllServiceEndpoints(): void + { + $endpoints = [ + 'teams' => '/teams', + 'databases' => '/databases', + 'functions' => '/functions', + 'locale' => '/locale', + ]; + + $this->updateProtocolStatus('rest', false); + $this->updateProtocolStatus('rest', true); + + foreach ($endpoints as $service => $path) { + $response = $this->client->call(Client::METHOD_GET, $path, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertNotEquals(403, $response['headers']['status-code'], 'Re-enabled REST protocol should not block ' . $service . ' endpoint'); + } + } + + public function testDisabledGraphqlBlocksMutationRequest(): void + { + $this->updateProtocolStatus('graphql', false); + + $response = $this->client->call(Client::METHOD_POST, '/graphql', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'query' => 'mutation { teamsCreate(teamId: "unique()", name: "Test") { _id } }', + ]); + + $this->assertSame(403, $response['headers']['status-code']); + $this->assertSame('general_api_disabled', $response['body']['type']); + + // Cleanup + $this->updateProtocolStatus('graphql', true); + } + + public function testResponseModel(): void + { + $response = $this->updateProtocolStatus('rest', false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('protocolStatusForRest', $response['body']); + $this->assertArrayHasKey('protocolStatusForGraphql', $response['body']); + $this->assertArrayHasKey('protocolStatusForWebsocket', $response['body']); + + // Cleanup + $this->updateProtocolStatus('rest', true); + } + + // Failure flow + + public function testUpdateProtocolWithoutAuthentication(): void + { + $response = $this->updateProtocolStatus('rest', false, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateProtocolInvalidProtocolId(): void + { + $response = $this->updateProtocolStatus('invalid', false); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateProtocolEmptyProtocolId(): void + { + $response = $this->updateProtocolStatus('', false); + + $this->assertSame(404, $response['headers']['status-code']); + } + + // Helpers + + protected function updateProtocolStatus(string $protocolId, bool $enabled, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PATCH, '/project/protocols/' . $protocolId . '/status', $headers, [ + 'enabled' => $enabled, + ]); + } +} diff --git a/tests/e2e/Services/Project/ProtocolsConsoleClientTest.php b/tests/e2e/Services/Project/ProtocolsConsoleClientTest.php new file mode 100644 index 0000000000..b2cec9a438 --- /dev/null +++ b/tests/e2e/Services/Project/ProtocolsConsoleClientTest.php @@ -0,0 +1,14 @@ +updateServiceStatus($service, false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['serviceStatusFor' . ucfirst($service)]); + } + + // Cleanup + foreach (self::$optionalServices as $service) { + $this->updateServiceStatus($service, true); + } + } + + public function testEnableService(): void + { + // Disable first + foreach (self::$optionalServices as $service) { + $this->updateServiceStatus($service, false); + } + + // Re-enable + foreach (self::$optionalServices as $service) { + $response = $this->updateServiceStatus($service, true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['serviceStatusFor' . ucfirst($service)]); + } + } + + public function testDisableServiceIdempotent(): void + { + $first = $this->updateServiceStatus('teams', false); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['serviceStatusForTeams']); + + $second = $this->updateServiceStatus('teams', false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['serviceStatusForTeams']); + + // Cleanup + $this->updateServiceStatus('teams', true); + } + + public function testEnableServiceIdempotent(): void + { + $first = $this->updateServiceStatus('teams', true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['serviceStatusForTeams']); + + $second = $this->updateServiceStatus('teams', true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['serviceStatusForTeams']); + } + + public function testDisabledServiceBlocksClientRequest(): void + { + $this->updateServiceStatus('teams', false); + + $response = $this->client->call(Client::METHOD_GET, '/teams', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(403, $response['headers']['status-code']); + $this->assertSame('general_service_disabled', $response['body']['type']); + + // Cleanup + $this->updateServiceStatus('teams', true); + } + + public function testEnabledServiceAllowsClientRequest(): void + { + $this->updateServiceStatus('teams', false); + $this->updateServiceStatus('teams', true); + + $response = $this->client->call(Client::METHOD_GET, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertSame(200, $response['headers']['status-code']); + } + + public function testDisableOneServiceDoesNotAffectOther(): void + { + $this->updateServiceStatus('teams', false); + + $response = $this->client->call(Client::METHOD_GET, '/functions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertSame(200, $response['headers']['status-code']); + + // Cleanup + $this->updateServiceStatus('teams', true); + } + + public function testEachDisabledServiceBlocksItsEndpoint(): void + { + $serviceEndpoints = [ + 'account' => ['method' => Client::METHOD_GET, 'path' => '/account'], + 'avatars' => ['method' => Client::METHOD_GET, 'path' => '/avatars/initials'], + 'databases' => ['method' => Client::METHOD_GET, 'path' => '/databases'], + 'tablesdb' => ['method' => Client::METHOD_GET, 'path' => '/tablesdb'], + 'locale' => ['method' => Client::METHOD_GET, 'path' => '/locale'], + 'health' => ['method' => Client::METHOD_GET, 'path' => '/health'], + 'project' => ['method' => Client::METHOD_GET, 'path' => '/project/platforms'], + 'storage' => ['method' => Client::METHOD_GET, 'path' => '/storage/buckets'], + 'teams' => ['method' => Client::METHOD_GET, 'path' => '/teams'], + 'users' => ['method' => Client::METHOD_GET, 'path' => '/users'], + 'vcs' => ['method' => Client::METHOD_GET, 'path' => '/vcs/installations'], + 'sites' => ['method' => Client::METHOD_GET, 'path' => '/sites'], + 'functions' => ['method' => Client::METHOD_GET, 'path' => '/functions'], + 'proxy' => ['method' => Client::METHOD_GET, 'path' => '/proxy/rules'], + 'migrations' => ['method' => Client::METHOD_GET, 'path' => '/migrations'], + 'messaging' => ['method' => Client::METHOD_GET, 'path' => '/messaging/providers'], + ]; + + foreach ($serviceEndpoints as $service => $endpoint) { + $this->updateServiceStatus($service, false); + + $response = $this->client->call($endpoint['method'], $endpoint['path'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(403, $response['headers']['status-code'], 'Service ' . $service . ' should block requests when disabled (got ' . $response['headers']['status-code'] . ')'); + $this->assertSame('general_service_disabled', $response['body']['type'], 'Service ' . $service . ' should return general_service_disabled error type'); + + // Cleanup + $this->updateServiceStatus($service, true); + } + } + + public function testEachReenabledServiceAllowsRequest(): void + { + $serviceEndpoints = [ + 'account' => ['method' => Client::METHOD_GET, 'path' => '/account'], + 'avatars' => ['method' => Client::METHOD_GET, 'path' => '/avatars/initials'], + 'databases' => ['method' => Client::METHOD_GET, 'path' => '/databases'], + 'tablesdb' => ['method' => Client::METHOD_GET, 'path' => '/tablesdb'], + 'locale' => ['method' => Client::METHOD_GET, 'path' => '/locale'], + 'health' => ['method' => Client::METHOD_GET, 'path' => '/health'], + 'project' => ['method' => Client::METHOD_GET, 'path' => '/project/platforms'], + 'storage' => ['method' => Client::METHOD_GET, 'path' => '/storage/buckets'], + 'teams' => ['method' => Client::METHOD_GET, 'path' => '/teams'], + 'users' => ['method' => Client::METHOD_GET, 'path' => '/users'], + 'vcs' => ['method' => Client::METHOD_GET, 'path' => '/vcs/installations'], + 'sites' => ['method' => Client::METHOD_GET, 'path' => '/sites'], + 'functions' => ['method' => Client::METHOD_GET, 'path' => '/functions'], + 'proxy' => ['method' => Client::METHOD_GET, 'path' => '/proxy/rules'], + 'migrations' => ['method' => Client::METHOD_GET, 'path' => '/migrations'], + 'messaging' => ['method' => Client::METHOD_GET, 'path' => '/messaging/providers'], + ]; + + foreach ($serviceEndpoints as $service => $endpoint) { + $this->updateServiceStatus($service, false); + $this->updateServiceStatus($service, true); + + $response = $this->client->call($endpoint['method'], $endpoint['path'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertNotEquals(403, $response['headers']['status-code'], 'Service ' . $service . ' should allow requests after re-enabling'); + } + } + + public function testResponseModel(): void + { + $response = $this->updateServiceStatus('teams', false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('serviceStatusForTeams', $response['body']); + + // Cleanup + $this->updateServiceStatus('teams', true); + } + + // Failure flow + + public function testUpdateServiceWithoutAuthentication(): void + { + $response = $this->updateServiceStatus('teams', false, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateServiceInvalidServiceId(): void + { + $response = $this->updateServiceStatus('invalid', false); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateServiceEmptyServiceId(): void + { + $response = $this->updateServiceStatus('', false); + + $this->assertSame(404, $response['headers']['status-code']); + } + + // Helpers + + protected function updateServiceStatus(string $serviceId, bool $enabled, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PATCH, '/project/services/' . $serviceId . '/status', $headers, [ + 'enabled' => $enabled, + ]); + } +} diff --git a/tests/e2e/Services/Project/ServicesConsoleClientTest.php b/tests/e2e/Services/Project/ServicesConsoleClientTest.php new file mode 100644 index 0000000000..b5660607f4 --- /dev/null +++ b/tests/e2e/Services/Project/ServicesConsoleClientTest.php @@ -0,0 +1,14 @@ + 'Webhook Test', 'events' => ['users.*.create', 'users.*.update.email'], 'url' => 'https://appwrite.io', - 'security' => true, - 'httpUser' => 'username', - 'httpPass' => 'password', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', ]); $this->assertEquals(201, $response['headers']['status-code']); self::$cachedProjectWithWebhook = array_merge($projectData, [ 'webhookId' => $response['body']['$id'], - 'signatureKey' => $response['body']['signatureKey'] + 'signatureKey' => $response['body']['secret'] ]); return self::$cachedProjectWithWebhook; @@ -388,6 +388,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'service' => $key, diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e0f94b64cc..7b9848e38f 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -2497,49 +2497,222 @@ class ProjectsConsoleClientTest extends Scope $id = $project['body']['$id']; + // Bulk disable should no longer work $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'status' => false, ]); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['$id']); - - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ])); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['$id']); - - $matches = []; - $pattern = '/serviceStatusFor.*/'; - - foreach ($response['body'] as $key => $value) { - if (\preg_match($pattern, $key)) { - $matches[$key] = $value; - } - } - - foreach ($matches as $value) { - $this->assertFalse($value); - } + $this->assertEquals(405, $response['headers']['status-code']); + $this->assertEquals('general_not_implemented', $response['body']['type']); + // Bulk enable should no longer work $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'status' => true, ]); + $this->assertEquals(405, $response['headers']['status-code']); + $this->assertEquals('general_not_implemented', $response['body']['type']); + } + + public function testUpdateProjectApisAll(): void + { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'teamId' => ID::unique(), + 'name' => 'Project Test', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + $this->assertNotEmpty($team['body']['$id']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'projectId' => ID::unique(), + 'name' => 'Project Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $this->assertNotEmpty($project['body']['$id']); + + $id = $project['body']['$id']; + + // Bulk disable should no longer work + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'status' => false, + ]); + + $this->assertEquals(405, $response['headers']['status-code']); + $this->assertEquals('general_not_implemented', $response['body']['type']); + + // Bulk enable should no longer work + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'status' => true, + ]); + + $this->assertEquals(405, $response['headers']['status-code']); + $this->assertEquals('general_not_implemented', $response['body']['type']); + } + + public function testUpdateProjectApiStatus(): void + { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'teamId' => ID::unique(), + 'name' => 'Project Test', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + $this->assertNotEmpty($team['body']['$id']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'projectId' => ID::unique(), + 'name' => 'Project Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $this->assertNotEmpty($project['body']['$id']); + + $id = $project['body']['$id']; + $protocols = ['rest', 'graphql', 'websocket']; + + /** + * Test for Disabled using old format (api + status) + */ + foreach ($protocols as $key) { + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'api' => $key, + 'status' => false, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals(false, $response['body']['protocolStatusFor' . ucfirst($key)]); + } + + /** + * Test for Enabled using old format (api + status) + */ + foreach ($protocols as $key) { + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'api' => $key, + 'status' => true, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals(true, $response['body']['protocolStatusFor' . ucfirst($key)]); + } + } + + public function testUpdateProjectApiStatusRealtimeBackwardsCompat(): void + { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'teamId' => ID::unique(), + 'name' => 'Project Test', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'projectId' => ID::unique(), + 'name' => 'Project Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + + $id = $project['body']['$id']; + + /** + * Test that "realtime" gets renamed to "websocket" via request filter + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'api' => 'realtime', + 'status' => false, + ]); + $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['$id']); $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ 'content-type' => 'application/json', @@ -2548,17 +2721,29 @@ class ProjectsConsoleClientTest extends Scope ])); $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(false, $response['body']['protocolStatusForWebsocket']); - $matches = []; - foreach ($response['body'] as $key => $value) { - if (\preg_match($pattern, $key)) { - $matches[$key] = $value; - } - } + // Re-enable via old "realtime" name + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'api' => 'realtime', + 'status' => true, + ]); - foreach ($matches as $value) { - $this->assertTrue($value); - } + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(true, $response['body']['protocolStatusForWebsocket']); } public function testUpdateProjectServiceStatusAdmin(): array @@ -2604,6 +2789,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'service' => $key, @@ -2649,6 +2835,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', ], $this->getHeaders()), [ 'service' => $key, 'status' => true, @@ -2678,6 +2865,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'service' => $key, @@ -2725,6 +2913,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', ], $this->getHeaders()), [ 'service' => $service, 'status' => true, @@ -2752,6 +2941,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'service' => $key, @@ -2841,6 +3031,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', ], $this->getHeaders()), [ 'service' => $service, 'status' => true, @@ -2862,9 +3053,9 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test', 'events' => ['users.*.create', 'users.*.update.email'], 'url' => 'https://appwrite.io', - 'security' => true, - 'httpUser' => 'username', - 'httpPass' => 'password', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', ]); $this->assertEquals(201, $response['headers']['status-code']); @@ -2873,9 +3064,9 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('users.*.update.email', $response['body']['events']); $this->assertCount(2, $response['body']['events']); $this->assertEquals('https://appwrite.io', $response['body']['url']); - $this->assertIsBool($response['body']['security']); - $this->assertEquals(true, $response['body']['security']); - $this->assertEquals('username', $response['body']['httpUser']); + $this->assertIsBool($response['body']['tls']); + $this->assertEquals(true, $response['body']['tls']); + $this->assertEquals('username', $response['body']['authUsername']); /** * Test for FAILURE @@ -2889,9 +3080,9 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test', 'events' => ['account.unknown', 'users.*.update.email'], 'url' => 'https://appwrite.io', - 'security' => true, - 'httpUser' => 'username', - 'httpPass' => 'password', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', ]); $this->assertEquals(400, $response['headers']['status-code']); @@ -2949,8 +3140,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('users.*.update.email', $response['body']['events']); $this->assertCount(2, $response['body']['events']); $this->assertEquals('https://appwrite.io', $response['body']['url']); - $this->assertEquals('username', $response['body']['httpUser']); - $this->assertEquals('password', $response['body']['httpPass']); + $this->assertEquals('username', $response['body']['authUsername']); + $this->assertEquals('password', $response['body']['authPassword']); /** * Test for FAILURE @@ -2978,7 +3169,7 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test Update', 'events' => ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.create'], 'url' => 'https://appwrite.io/new', - 'security' => false, + 'tls' => false, ]); $this->assertEquals(200, $response['headers']['status-code']); @@ -2990,10 +3181,10 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('buckets.*.files.*.create', $response['body']['events']); $this->assertCount(3, $response['body']['events']); $this->assertEquals('https://appwrite.io/new', $response['body']['url']); - $this->assertIsBool($response['body']['security']); - $this->assertEquals(false, $response['body']['security']); - $this->assertEquals('', $response['body']['httpUser']); - $this->assertEquals('', $response['body']['httpPass']); + $this->assertIsBool($response['body']['tls']); + $this->assertEquals(false, $response['body']['tls']); + $this->assertEquals('', $response['body']['authUsername']); + $this->assertEquals('', $response['body']['authPassword']); $response = $this->client->call(Client::METHOD_GET, '/webhooks/' . $webhookId, array_merge([ 'content-type' => 'application/json', @@ -3010,10 +3201,10 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('buckets.*.files.*.create', $response['body']['events']); $this->assertCount(3, $response['body']['events']); $this->assertEquals('https://appwrite.io/new', $response['body']['url']); - $this->assertIsBool($response['body']['security']); - $this->assertEquals(false, $response['body']['security']); - $this->assertEquals('', $response['body']['httpUser']); - $this->assertEquals('', $response['body']['httpPass']); + $this->assertIsBool($response['body']['tls']); + $this->assertEquals(false, $response['body']['tls']); + $this->assertEquals('', $response['body']['authUsername']); + $this->assertEquals('', $response['body']['authPassword']); /** * Test for FAILURE @@ -3026,7 +3217,7 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test Update', 'events' => ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.unknown'], 'url' => 'https://appwrite.io/new', - 'security' => false, + 'tls' => false, ]); $this->assertEquals(400, $response['headers']['status-code']); @@ -3039,7 +3230,7 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test Update', 'events' => ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.create'], 'url' => 'appwrite.io/new', - 'security' => false, + 'tls' => false, ]); $this->assertEquals(400, $response['headers']['status-code']); @@ -3064,15 +3255,15 @@ class ProjectsConsoleClientTest extends Scope $webhookId = $data['webhookId']; $signatureKey = $data['signatureKey']; - $response = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/signature', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $id, 'x-appwrite-mode' => 'admin' ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['signatureKey']); - $this->assertNotEquals($signatureKey, $response['body']['signatureKey']); + $this->assertNotEmpty($response['body']['secret']); + $this->assertNotEquals($signatureKey, $response['body']['secret']); } public function testDeleteProjectWebhook(): void @@ -3091,9 +3282,9 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook To Delete', 'events' => ['users.*.create'], 'url' => 'https://appwrite.io', - 'security' => true, - 'httpUser' => 'username', - 'httpPass' => 'password', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', ]); $this->assertEquals(201, $response['headers']['status-code']); diff --git a/tests/e2e/Services/Webhooks/WebhooksBase.php b/tests/e2e/Services/Webhooks/WebhooksBase.php index 7ad701b564..f926a97c81 100644 --- a/tests/e2e/Services/Webhooks/WebhooksBase.php +++ b/tests/e2e/Services/Webhooks/WebhooksBase.php @@ -35,11 +35,11 @@ trait WebhooksBase $this->assertContains('users.*.create', $webhook['body']['events']); $this->assertCount(1, $webhook['body']['events']); $this->assertEquals(true, $webhook['body']['enabled']); - $this->assertEquals(false, $webhook['body']['security']); - $this->assertEquals('', $webhook['body']['httpUser']); - $this->assertEquals('', $webhook['body']['httpPass']); - $this->assertNotEmpty($webhook['body']['signatureKey']); - $this->assertEquals(128, \strlen($webhook['body']['signatureKey'])); + $this->assertEquals(false, $webhook['body']['tls']); + $this->assertEquals('', $webhook['body']['authUsername']); + $this->assertEquals('', $webhook['body']['authPassword']); + $this->assertNotEmpty($webhook['body']['secret']); + $this->assertEquals(128, \strlen($webhook['body']['secret'])); $this->assertEquals(0, $webhook['body']['attempts']); $this->assertEquals('', $webhook['body']['logs']); @@ -63,11 +63,11 @@ trait WebhooksBase $this->deleteWebhook($webhook['body']['$id']); } - public function testCreateWebhookWithSecurity(): void + public function testCreateWebhookWithTls(): void { $webhook = $this->createWebhook( ID::unique(), - 'Webhook With Security', + 'Webhook With TLS', ['users.*.create'], null, 'https://appwrite.io', @@ -78,8 +78,8 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $this->assertNotEmpty($webhook['body']['$id']); - $this->assertEquals(true, $webhook['body']['security']); - $this->assertIsBool($webhook['body']['security']); + $this->assertEquals(true, $webhook['body']['tls']); + $this->assertIsBool($webhook['body']['tls']); // Cleanup $this->deleteWebhook($webhook['body']['$id']); @@ -100,14 +100,14 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $this->assertNotEmpty($webhook['body']['$id']); - $this->assertEquals('username', $webhook['body']['httpUser']); - $this->assertEquals('password', $webhook['body']['httpPass']); - $this->assertEquals(true, $webhook['body']['security']); + $this->assertEquals('username', $webhook['body']['authUsername']); + $this->assertEquals('password', $webhook['body']['authPassword']); + $this->assertEquals(true, $webhook['body']['tls']); // Verify via GET $get = $this->getWebhook($webhook['body']['$id']); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertEquals('username', $get['body']['httpUser']); + $this->assertEquals('username', $get['body']['authUsername']); // Cleanup $this->deleteWebhook($webhook['body']['$id']); @@ -331,11 +331,11 @@ trait WebhooksBase $this->deleteWebhook($webhookId); } - public function testUpdateWebhookWithSecurity(): void + public function testUpdateWebhookWithTls(): void { $webhook = $this->createWebhook( ID::unique(), - 'Security Webhook', + 'TLS Webhook', ['users.*.create'], null, 'https://appwrite.io', @@ -345,7 +345,7 @@ trait WebhooksBase ); $this->assertEquals(201, $webhook['headers']['status-code']); - $this->assertEquals(false, $webhook['body']['security']); + $this->assertEquals(false, $webhook['body']['tls']); $webhookId = $webhook['body']['$id']; // Update to enable security @@ -361,8 +361,8 @@ trait WebhooksBase ); $this->assertEquals(200, $updated['headers']['status-code']); - $this->assertEquals(true, $updated['body']['security']); - $this->assertIsBool($updated['body']['security']); + $this->assertEquals(true, $updated['body']['tls']); + $this->assertIsBool($updated['body']['tls']); // Cleanup $this->deleteWebhook($webhookId); @@ -382,8 +382,8 @@ trait WebhooksBase ); $this->assertEquals(201, $webhook['headers']['status-code']); - $this->assertEquals('', $webhook['body']['httpUser']); - $this->assertEquals('', $webhook['body']['httpPass']); + $this->assertEquals('', $webhook['body']['authUsername']); + $this->assertEquals('', $webhook['body']['authPassword']); $webhookId = $webhook['body']['$id']; // Update with HTTP auth credentials @@ -399,13 +399,13 @@ trait WebhooksBase ); $this->assertEquals(200, $updated['headers']['status-code']); - $this->assertEquals('newuser', $updated['body']['httpUser']); - $this->assertEquals('newpass', $updated['body']['httpPass']); + $this->assertEquals('newuser', $updated['body']['authUsername']); + $this->assertEquals('newpass', $updated['body']['authPassword']); // Verify via GET $get = $this->getWebhook($webhookId); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertEquals('newuser', $get['body']['httpUser']); + $this->assertEquals('newuser', $get['body']['authUsername']); // Cleanup $this->deleteWebhook($webhookId); @@ -657,19 +657,19 @@ trait WebhooksBase $this->assertContains('buckets.*.files.*.create', $updated['body']['events']); $this->assertCount(3, $updated['body']['events']); $this->assertEquals('https://appwrite.io/updated', $updated['body']['url']); - $this->assertEquals(true, $updated['body']['security']); - $this->assertEquals('user', $updated['body']['httpUser']); - $this->assertEquals('pass', $updated['body']['httpPass']); + $this->assertEquals(true, $updated['body']['tls']); + $this->assertEquals('user', $updated['body']['authUsername']); + $this->assertEquals('pass', $updated['body']['authPassword']); // Cleanup $this->deleteWebhook($webhookId); } - public function testUpdateWebhookSignature(): void + public function testUpdateWebhookSecret(): void { $webhook = $this->createWebhook( ID::unique(), - 'Signature Webhook', + 'Secret Webhook', ['users.*.create'], null, 'https://appwrite.io', @@ -680,27 +680,27 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $webhookId = $webhook['body']['$id']; - $originalSignatureKey = $webhook['body']['signatureKey']; + $originalSecret = $webhook['body']['secret']; - $this->assertNotEmpty($originalSignatureKey); - $this->assertEquals(128, \strlen($originalSignatureKey)); + $this->assertNotEmpty($originalSecret); + $this->assertEquals(128, \strlen($originalSecret)); - // Update signature - $updated = $this->updateWebhookSignature($webhookId); + // Update secret + $updated = $this->updateWebhookSecret($webhookId); $this->assertEquals(200, $updated['headers']['status-code']); $this->assertEquals($webhookId, $updated['body']['$id']); - $this->assertNotEmpty($updated['body']['signatureKey']); - $this->assertEquals(128, \strlen($updated['body']['signatureKey'])); - $this->assertNotEquals($originalSignatureKey, $updated['body']['signatureKey']); + $this->assertNotEmpty($updated['body']['secret']); + $this->assertEquals(128, \strlen($updated['body']['secret'])); + $this->assertNotEquals($originalSecret, $updated['body']['secret']); - // Verify new signature persisted via GET + // Verify secret is not exposed via GET $get = $this->getWebhook($webhookId); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertNotEquals($originalSignatureKey, $get['body']['signatureKey']); + $this->assertEmpty($get['body']['secret']); - // Test signature update on non-existent webhook - $notFound = $this->updateWebhookSignature('non-existent-id'); + // Test secret update on non-existent webhook + $notFound = $this->updateWebhookSecret('non-existent-id'); $this->assertEquals(404, $notFound['headers']['status-code']); $this->assertEquals('webhook_not_found', $notFound['body']['type']); @@ -708,6 +708,351 @@ trait WebhooksBase $this->deleteWebhook($webhookId); } + public function testSecretRotationZeroDowntime(): void + { + // Create webhook pointing to request-catcher so deliveries are captured + $webhook = $this->createWebhook( + ID::unique(), + 'Rotation Test Webhook', + ['users.*.create'], + null, + 'http://request-catcher-webhook:5000/', + false, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $originalSecret = $webhook['body']['secret']; + $this->assertNotEmpty($originalSecret); + $this->assertEquals(128, \strlen($originalSecret)); + + // Step 1: Trigger user creation with the original auto-generated secret + $email1 = uniqid() . 'rotation1@localhost.test'; + $user1 = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email1, + 'password' => 'password', + 'name' => 'Rotation User 1', + ]); + + $this->assertEquals(201, $user1['headers']['status-code']); + $userId1 = $user1['body']['$id']; + + // Verify webhook delivery is signed with the original secret + $this->assertEventually(function () use ($userId1, $originalSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId1) { + $this->assertStringContainsString( + "users.{$userId1}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $originalSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Step 2: Rotate the secret to a known custom value + $newSecret = 'new-key-after-rotation'; + $updated = $this->updateWebhookSecret($webhookId, $newSecret); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($newSecret, $updated['body']['secret']); + $this->assertNotEquals($originalSecret, $updated['body']['secret']); + + // Step 3: Trigger another user creation — should be signed with the new secret + $email2 = uniqid() . 'rotation2@localhost.test'; + $user2 = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email2, + 'password' => 'password', + 'name' => 'Rotation User 2', + ]); + + $this->assertEquals(201, $user2['headers']['status-code']); + $userId2 = $user2['body']['$id']; + + // Verify webhook delivery is signed with the new rotated secret + $this->assertEventually(function () use ($userId2, $newSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId2) { + $this->assertStringContainsString( + "users.{$userId2}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $newSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testCreateWebhookWithCustomSecret(): void + { + $customSecret = 'custom-secret-key'; + + // Create webhook with a custom secret pointing to request-catcher + $webhook = $this->createWebhook( + ID::unique(), + 'Custom Secret Webhook', + ['users.*.create'], + null, + 'http://request-catcher-webhook:5000/', + false, + null, + null, + $customSecret + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $this->assertEquals($customSecret, $webhook['body']['secret']); + + // Trigger user creation to generate a webhook delivery + $email = uniqid() . 'customsecret@localhost.test'; + $user = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => 'password', + 'name' => 'Custom Secret User', + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + + // Verify webhook delivery is signed with the custom secret + $this->assertEventually(function () use ($userId, $customSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId) { + $this->assertStringContainsString( + "users.{$userId}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $customSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testCreateWebhookSecretMinLength(): void + { + // 7 chars — below minimum of 8 + $webhook = $this->createWebhook( + ID::unique(), + 'Short Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'short12' + ); + + $this->assertEquals(400, $webhook['headers']['status-code']); + + // 8 chars — exactly at minimum + $webhook = $this->createWebhook( + ID::unique(), + 'Min Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'exact8ch' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals('exact8ch', $webhook['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + } + + public function testCreateWebhookSecretMaxLength(): void + { + // 256 chars — exactly at maximum + $maxSecret = str_repeat('a', 256); + $webhook = $this->createWebhook( + ID::unique(), + 'Max Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + $maxSecret + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals($maxSecret, $webhook['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + + // 257 chars — above maximum + $tooLongSecret = str_repeat('a', 257); + $webhook = $this->createWebhook( + ID::unique(), + 'Too Long Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + $tooLongSecret + ); + + $this->assertEquals(400, $webhook['headers']['status-code']); + } + + public function testUpdateWebhookSecretMinLength(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Min Update Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // 7 chars — below minimum of 8 + $updated = $this->updateWebhookSecret($webhookId, 'short12'); + $this->assertEquals(400, $updated['headers']['status-code']); + + // 8 chars — exactly at minimum + $updated = $this->updateWebhookSecret($webhookId, 'exact8ch'); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals('exact8ch', $updated['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testUpdateWebhookSecretMaxLength(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Max Update Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // 256 chars — exactly at maximum + $maxSecret = str_repeat('a', 256); + $updated = $this->updateWebhookSecret($webhookId, $maxSecret); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($maxSecret, $updated['body']['secret']); + + // 257 chars — above maximum + $tooLongSecret = str_repeat('a', 257); + $updated = $this->updateWebhookSecret($webhookId, $tooLongSecret); + $this->assertEquals(400, $updated['headers']['status-code']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testWebhookSecretNotExposedInResponses(): void + { + // Create webhook — secret IS returned on creation + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Exposure Test', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'my-custom-secret' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $this->assertEquals('my-custom-secret', $webhook['body']['secret']); + $this->assertArrayNotHasKey('signatureKey', $webhook['body']); + + // Get webhook — secret must not be exposed + $get = $this->getWebhook($webhookId); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEmpty($get['body']['secret']); + $this->assertArrayNotHasKey('signatureKey', $get['body']); + + // List webhooks — secret must not be exposed + $list = $this->listWebhooks(null, true); + $this->assertEquals(200, $list['headers']['status-code']); + foreach ($list['body']['webhooks'] as $item) { + $this->assertEmpty($item['secret']); + $this->assertArrayNotHasKey('signatureKey', $item); + } + + // Update webhook — secret must not be exposed + $updated = $this->updateWebhook( + $webhookId, + 'Secret Exposure Test Updated', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEmpty($updated['body']['secret']); + $this->assertArrayNotHasKey('signatureKey', $updated['body']); + + // Update webhook secret — secret IS returned on rotation + $rotated = $this->updateWebhookSecret($webhookId, 'rotated-secret-key'); + $this->assertEquals(200, $rotated['headers']['status-code']); + $this->assertEquals('rotated-secret-key', $rotated['body']['secret']); + $this->assertArrayNotHasKey('signatureKey', $rotated['body']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + // URL validation tests public function testCreateWebhookWithPrivateDomain(): void @@ -883,6 +1228,12 @@ trait WebhooksBase { $customId = 'my-custom-webhook-id'; + // Clean up stale webhook from a previous run if it exists + $existing = $this->getWebhook($customId); + if ($existing['headers']['status-code'] === 200) { + $this->deleteWebhook($customId); + } + $webhook = $this->createWebhook( $customId, 'Custom ID Webhook', @@ -902,6 +1253,19 @@ trait WebhooksBase $this->assertEquals(200, $get['headers']['status-code']); $this->assertEquals($customId, $get['body']['$id']); + // Ensure duplicate creation fails + $duplicate = $this->createWebhook( + $customId, + 'Duplicate Custom ID Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + $this->assertEquals(409, $duplicate['headers']['status-code']); + // Cleanup $this->deleteWebhook($customId); } @@ -934,11 +1298,10 @@ trait WebhooksBase $this->assertContains('users.*.update.email', $get['body']['events']); $this->assertCount(2, $get['body']['events']); $this->assertEquals(true, $get['body']['enabled']); - $this->assertEquals(true, $get['body']['security']); - $this->assertEquals('myuser', $get['body']['httpUser']); - $this->assertEquals('mypass', $get['body']['httpPass']); - $this->assertNotEmpty($get['body']['signatureKey']); - $this->assertEquals(128, \strlen($get['body']['signatureKey'])); + $this->assertEquals(true, $get['body']['tls']); + $this->assertEquals('myuser', $get['body']['authUsername']); + $this->assertEquals('mypass', $get['body']['authPassword']); + $this->assertEmpty($get['body']['secret']); $this->assertEquals(0, $get['body']['attempts']); $this->assertEquals('', $get['body']['logs']); @@ -1043,9 +1406,9 @@ trait WebhooksBase $this->assertArrayHasKey('name', $webhook); $this->assertArrayHasKey('url', $webhook); $this->assertArrayHasKey('events', $webhook); - $this->assertArrayHasKey('security', $webhook); + $this->assertArrayHasKey('tls', $webhook); $this->assertArrayHasKey('enabled', $webhook); - $this->assertArrayHasKey('signatureKey', $webhook); + $this->assertArrayHasKey('secret', $webhook); $this->assertArrayHasKey('attempts', $webhook); $this->assertArrayHasKey('logs', $webhook); } @@ -1247,11 +1610,11 @@ trait WebhooksBase $this->deleteWebhook($webhook['body']['$id']); } - public function testListWebhooksFilterBySecurity(): void + public function testListWebhooksFilterByTls(): void { $webhook = $this->createWebhook( ID::unique(), - 'Security Filter Webhook', + 'TLS Filter Webhook', ['users.*.create'], null, 'https://appwrite.io/sec', @@ -1262,13 +1625,13 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $list = $this->listWebhooks([ - Query::equal('security', [true])->toString(), + Query::equal('tls', [true])->toString(), ], true); $this->assertEquals(200, $list['headers']['status-code']); $this->assertGreaterThanOrEqual(1, $list['body']['total']); foreach ($list['body']['webhooks'] as $w) { - $this->assertEquals(true, $w['security']); + $this->assertEquals(true, $w['tls']); } // Cleanup @@ -1503,6 +1866,254 @@ trait WebhooksBase $this->assertEquals('webhook_not_found', $delete['body']['type']); } + // ========================================================================= + // Backward compatibility tests (1.9.0 response format) + // ========================================================================= + + public function testCreateWebhookV22BackwardCompatRequest(): void + { + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + // Send old param names with 1.9.0 header + $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', $headers, [ + 'webhookId' => ID::unique(), + 'name' => 'V22 Compat Create', + 'events' => ['users.*.create'], + 'url' => 'https://appwrite.io', + 'security' => true, + 'httpUser' => 'olduser', + 'httpPass' => 'oldpass', + ]); + + $this->assertEquals(201, $webhook['headers']['status-code']); + + // Response should use OLD field names + $this->assertArrayHasKey('security', $webhook['body']); + $this->assertArrayHasKey('httpUser', $webhook['body']); + $this->assertArrayHasKey('httpPass', $webhook['body']); + $this->assertArrayHasKey('signatureKey', $webhook['body']); + + // New field names should NOT be present + $this->assertArrayNotHasKey('tls', $webhook['body']); + $this->assertArrayNotHasKey('authUsername', $webhook['body']); + $this->assertArrayNotHasKey('authPassword', $webhook['body']); + $this->assertArrayNotHasKey('secret', $webhook['body']); + + // Values should be correct + $this->assertEquals(true, $webhook['body']['security']); + $this->assertEquals('olduser', $webhook['body']['httpUser']); + $this->assertEquals('oldpass', $webhook['body']['httpPass']); + $this->assertNotEmpty($webhook['body']['signatureKey']); + $this->assertEquals(128, \strlen($webhook['body']['signatureKey'])); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + } + + public function testUpdateWebhookV22BackwardCompatRequest(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'V22 Compat Update', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + // Update using old param names + $updated = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, $headers, [ + 'name' => 'V22 Compat Updated', + 'events' => ['users.*.create'], + 'url' => 'https://appwrite.io', + 'security' => true, + 'httpUser' => 'updateduser', + 'httpPass' => 'updatedpass', + ]); + + $this->assertEquals(200, $updated['headers']['status-code']); + + // Response should use OLD field names + $this->assertArrayHasKey('security', $updated['body']); + $this->assertArrayHasKey('httpUser', $updated['body']); + $this->assertArrayHasKey('httpPass', $updated['body']); + $this->assertArrayHasKey('signatureKey', $updated['body']); + + $this->assertArrayNotHasKey('tls', $updated['body']); + $this->assertArrayNotHasKey('authUsername', $updated['body']); + $this->assertArrayNotHasKey('authPassword', $updated['body']); + $this->assertArrayNotHasKey('secret', $updated['body']); + + $this->assertEquals(true, $updated['body']['security']); + $this->assertEquals('updateduser', $updated['body']['httpUser']); + $this->assertEquals('updatedpass', $updated['body']['httpPass']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testGetWebhookV22BackwardCompatResponse(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'V22 Compat Get', + ['users.*.create'], + null, + 'https://appwrite.io', + true, + 'getuser', + 'getpass' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // GET with 1.9.0 header + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + $get = $this->client->call(Client::METHOD_GET, '/webhooks/' . $webhookId, $headers); + + $this->assertEquals(200, $get['headers']['status-code']); + + // Should have old field names + $this->assertArrayHasKey('security', $get['body']); + $this->assertArrayHasKey('httpUser', $get['body']); + $this->assertArrayHasKey('httpPass', $get['body']); + $this->assertArrayHasKey('signatureKey', $get['body']); + + $this->assertArrayNotHasKey('tls', $get['body']); + $this->assertArrayNotHasKey('authUsername', $get['body']); + $this->assertArrayNotHasKey('authPassword', $get['body']); + $this->assertArrayNotHasKey('secret', $get['body']); + + $this->assertEquals(true, $get['body']['security']); + $this->assertEquals('getuser', $get['body']['httpUser']); + $this->assertEquals('getpass', $get['body']['httpPass']); + $this->assertEmpty($get['body']['signatureKey']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testListWebhooksV22BackwardCompatResponse(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'V22 Compat List', + ['users.*.create'], + null, + 'https://appwrite.io', + true, + 'listuser', + 'listpass' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // LIST with 1.9.0 header + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + $list = $this->client->call(Client::METHOD_GET, '/webhooks', $headers, [ + 'queries' => [ + Query::equal('name', ['V22 Compat List'])->toString(), + ], + 'total' => true, + ]); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertEquals(1, $list['body']['total']); + $this->assertCount(1, $list['body']['webhooks']); + + $item = $list['body']['webhooks'][0]; + + // Each item should have old field names + $this->assertArrayHasKey('security', $item); + $this->assertArrayHasKey('httpUser', $item); + $this->assertArrayHasKey('httpPass', $item); + $this->assertArrayHasKey('signatureKey', $item); + + $this->assertArrayNotHasKey('tls', $item); + $this->assertArrayNotHasKey('authUsername', $item); + $this->assertArrayNotHasKey('authPassword', $item); + $this->assertArrayNotHasKey('secret', $item); + + $this->assertEquals(true, $item['security']); + $this->assertEquals('listuser', $item['httpUser']); + $this->assertEquals('listpass', $item['httpPass']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testUpdateWebhookSecretV22BackwardCompatResponse(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'V22 Compat Secret', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // Update secret with 1.9.0 header + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + $updated = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', $headers); + + $this->assertEquals(200, $updated['headers']['status-code']); + + // Response should use old field names + $this->assertArrayHasKey('signatureKey', $updated['body']); + $this->assertArrayHasKey('security', $updated['body']); + $this->assertArrayHasKey('httpUser', $updated['body']); + $this->assertArrayHasKey('httpPass', $updated['body']); + + $this->assertArrayNotHasKey('secret', $updated['body']); + $this->assertArrayNotHasKey('tls', $updated['body']); + $this->assertArrayNotHasKey('authUsername', $updated['body']); + $this->assertArrayNotHasKey('authPassword', $updated['body']); + + $this->assertNotEmpty($updated['body']['signatureKey']); + $this->assertEquals(128, \strlen($updated['body']['signatureKey'])); + + // Cleanup + $this->deleteWebhook($webhookId); + } + // Helpers /** @@ -1531,7 +2142,7 @@ trait WebhooksBase return $webhook; } - protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $security, ?string $httpUser, ?string $httpPass): mixed + protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword, ?string $secret = null): mixed { $params = [ 'webhookId' => $webhookId, @@ -1543,14 +2154,17 @@ trait WebhooksBase if ($enabled !== null) { $params['enabled'] = $enabled; } - if ($security !== null) { - $params['security'] = $security; + if ($tls !== null) { + $params['tls'] = $tls; } - if ($httpUser !== null) { - $params['httpUser'] = $httpUser; + if ($authUsername !== null) { + $params['authUsername'] = $authUsername; } - if ($httpPass !== null) { - $params['httpPass'] = $httpPass; + if ($authPassword !== null) { + $params['authPassword'] = $authPassword; + } + if ($secret !== null) { + $params['secret'] = $secret; } $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ @@ -1561,7 +2175,7 @@ trait WebhooksBase return $webhook; } - protected function updateWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $security, ?string $httpUser, ?string $httpPass): mixed + protected function updateWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword): mixed { $params = [ 'name' => $name, @@ -1572,14 +2186,14 @@ trait WebhooksBase if ($enabled !== null) { $params['enabled'] = $enabled; } - if ($security !== null) { - $params['security'] = $security; + if ($tls !== null) { + $params['tls'] = $tls; } - if ($httpUser !== null) { - $params['httpUser'] = $httpUser; + if ($authUsername !== null) { + $params['authUsername'] = $authUsername; } - if ($httpPass !== null) { - $params['httpPass'] = $httpPass; + if ($authPassword !== null) { + $params['authPassword'] = $authPassword; } $webhook = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, array_merge([ @@ -1590,12 +2204,17 @@ trait WebhooksBase return $webhook; } - protected function updateWebhookSignature(string $webhookId): mixed + protected function updateWebhookSecret(string $webhookId, ?string $secret = null): mixed { - $webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/signature', array_merge([ + $params = []; + if ($secret !== null) { + $params['secret'] = $secret; + } + + $webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); + ], $this->getHeaders()), $params); return $webhook; } diff --git a/tests/unit/SDK/Specification/FormatTest.php b/tests/unit/SDK/Specification/FormatTest.php new file mode 100644 index 0000000000..f99b29bfe2 --- /dev/null +++ b/tests/unit/SDK/Specification/FormatTest.php @@ -0,0 +1,66 @@ +getRequestParameterConfig($service, $method, $param, $optional, $nullable, $default); + } +} + +class FormatTest extends TestCase +{ + private TestFormat $format; + + protected function setUp(): void + { + parent::setUp(); + + $this->format = new TestFormat(new Container(), [], [], [], [], 0, 'console'); + } + + public function testProjectRequestParameterOverrides(): void + { + $createWebPlatform = $this->format->requestParameterConfig('project', 'createWebPlatform', 'hostname', true, false, ''); + $updateWebPlatform = $this->format->requestParameterConfig('project', 'updateWebPlatform', 'hostname', true, false, ''); + $listPlatforms = $this->format->requestParameterConfig('project', 'listPlatforms', 'queries', true, false, []); + + $this->assertTrue($createWebPlatform['required']); + $this->assertFalse($createWebPlatform['emitDefault']); + $this->assertTrue($updateWebPlatform['required']); + $this->assertFalse($updateWebPlatform['emitDefault']); + $this->assertTrue($listPlatforms['emitDefault']); + } + + public function testProjectPlatformResponseTypeUsesSharedEnumName(): void + { + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformAndroid', 'type')); + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformWeb', 'type')); + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformApple', 'type')); + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformWindows', 'type')); + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformLinux', 'type')); + $this->assertNull($this->format->getResponseEnumName('platformList', 'type')); + } + + public function testExistingResponseEnumMappingsRemainUnchanged(): void + { + $this->assertSame('HealthCheckStatus', $this->format->getResponseEnumName('healthStatus', 'status')); + $this->assertNull($this->format->getResponseEnumName('key', 'name')); + } +}