From 65f926b4c58c75c4b0704901462b19646e74d09e Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 1 May 2026 18:55:13 +0530 Subject: [PATCH 01/15] Update appwrite runtimes to 0.20 --- composer.json | 2 +- composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 683da6f21b..735955d980 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,7 @@ "ext-openssl": "*", "ext-zlib": "*", "ext-sockets": "*", - "appwrite/php-runtimes": "0.19.*", + "appwrite/php-runtimes": "0.20.*", "appwrite/php-clamav": "2.0.*", "utopia-php/abuse": "1.2.*", "utopia-php/agents": "1.2.*", diff --git a/composer.lock b/composer.lock index 3edbc39614..2889069e67 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": "4bee36b21a57e754d2b3417e72dc9599", + "content-hash": "bd45829c252971301370d62300be106d", "packages": [ { "name": "adhocore/jwt", @@ -161,16 +161,16 @@ }, { "name": "appwrite/php-runtimes", - "version": "0.19.5", + "version": "0.20.0", "source": { "type": "git", "url": "https://github.com/appwrite/runtimes.git", - "reference": "aa2f7760cd0493c0880209b92df812c9386b3546" + "reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/runtimes/zipball/aa2f7760cd0493c0880209b92df812c9386b3546", - "reference": "aa2f7760cd0493c0880209b92df812c9386b3546", + "url": "https://api.github.com/repos/appwrite/runtimes/zipball/7d9b7f4eef5c0a142a60907b06de2219d025c5c3", + "reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3", "shasum": "" }, "require": { @@ -210,9 +210,9 @@ ], "support": { "issues": "https://github.com/appwrite/runtimes/issues", - "source": "https://github.com/appwrite/runtimes/tree/0.19.5" + "source": "https://github.com/appwrite/runtimes/tree/0.20.0" }, - "time": "2026-04-01T01:39:23+00:00" + "time": "2026-05-01T07:47:07+00:00" }, { "name": "brick/math", From 413930a15edd9f630ee29483964073a585bc148a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 2 May 2026 12:10:03 +0530 Subject: [PATCH 02/15] Add Rust starter function template runtime --- app/config/templates/function.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/config/templates/function.php b/app/config/templates/function.php index df3a569705..c6ac446509 100644 --- a/app/config/templates/function.php +++ b/app/config/templates/function.php @@ -79,12 +79,13 @@ return [ ...getRuntimes($templateRuntimes['DENO'], 'deno cache src/main.ts', 'src/main.ts', 'deno/starter', $allowList), ...getRuntimes($templateRuntimes['BUN'], 'bun install', 'src/main.ts', 'bun/starter', $allowList), ...getRuntimes($templateRuntimes['RUBY'], 'bundle install', 'lib/main.rb', 'ruby/starter', $allowList), + ...getRuntimes($templateRuntimes['RUST'], '', 'main.rs', 'rust/starter', $allowList), ], - 'instructions' => 'For documentation and instructions check out file.', + 'instructions' => 'For documentation and instructions check out the templates repository.', 'vcsProvider' => 'github', 'providerRepositoryId' => 'templates', 'providerOwner' => 'appwrite', - 'providerVersion' => '0.2.*', + 'providerVersion' => '0.3.*', 'variables' => [], 'scopes' => ['users.read'] ], From 7f27851dab09b52f5abba84af1bdff009d936215 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sun, 3 May 2026 15:15:27 +0100 Subject: [PATCH 03/15] chore: bump base image to 1.3.1 Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1922a0d2b9..5f43b1527f 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist \ `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi` -FROM appwrite/base:1.2.1 AS base +FROM appwrite/base:1.3.1 AS base LABEL maintainer="team@appwrite.io" From 4e20e382d21647c44f6d41445467d7768477c67c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 3 May 2026 19:49:13 +0200 Subject: [PATCH 04/15] Add deprecated function scopes --- app/config/scopes/project.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 63b946f74f..3aaf5469de 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -210,10 +210,20 @@ return [ 'executions.read' => [ 'description' => 'Access to read function executions', 'category' => 'Functions', + 'deprecated' => true, ], 'executions.write' => [ 'description' => 'Access to create function executions', 'category' => 'Functions', + 'deprecated' => true, + ], + 'execution.read' => [ + 'description' => 'Access to read function executions. This scope is deprecated for consistency purposes, and replaced by `executions.read`.', + 'category' => 'Functions', + ], + 'execution.write' => [ + 'description' => 'Access to create function executions. This scope is deprecated for consistency purposes, and replaced by `executions.write`.', + 'category' => 'Functions', ], // Sites From 6051b8150c5bf671db43ab93b6f6e7bec0f18e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 3 May 2026 19:51:10 +0200 Subject: [PATCH 05/15] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Matej Bačo --- app/config/scopes/project.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 3aaf5469de..716caa3b8e 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -210,7 +210,6 @@ return [ 'executions.read' => [ 'description' => 'Access to read function executions', 'category' => 'Functions', - 'deprecated' => true, ], 'executions.write' => [ 'description' => 'Access to create function executions', From 8f68a59a797e9297b9229bc19c318502b6ef7893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 3 May 2026 19:51:56 +0200 Subject: [PATCH 06/15] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Matej Bačo --- app/config/scopes/project.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 716caa3b8e..a048920de9 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -214,15 +214,16 @@ return [ 'executions.write' => [ 'description' => 'Access to create function executions', 'category' => 'Functions', - 'deprecated' => true, ], 'execution.read' => [ 'description' => 'Access to read function executions. This scope is deprecated for consistency purposes, and replaced by `executions.read`.', 'category' => 'Functions', + 'deprecated' => true, ], 'execution.write' => [ 'description' => 'Access to create function executions. This scope is deprecated for consistency purposes, and replaced by `executions.write`.', 'category' => 'Functions', + 'deprecated' => true, ], // Sites From 92eceba2181134d19263ec90fdd6bddd5237c8f2 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sun, 3 May 2026 19:21:00 +0100 Subject: [PATCH 07/15] chore: bump base image to 1.4.1 Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5f43b1527f..9a61635415 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist \ `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi` -FROM appwrite/base:1.3.1 AS base +FROM appwrite/base:1.4.1 AS base LABEL maintainer="team@appwrite.io" From f5a7cfd2ea0135d1b01a01fd60d378bd81acba8c Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 4 May 2026 12:22:48 +0530 Subject: [PATCH 08/15] fix: resolve query syntax errors and improve error handling in Request class --- src/Appwrite/Utopia/Request.php | 65 ++++++----- .../e2e/Services/Databases/DatabasesBase.php | 45 ++++++++ .../Utopia/Request/Filters/ThrowingFilter.php | 24 +++++ tests/unit/Utopia/RequestTest.php | 101 ++++++++++++++++++ 4 files changed, 207 insertions(+), 28 deletions(-) create mode 100644 tests/unit/Utopia/Request/Filters/ThrowingFilter.php diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 3004392f76..945a3d2bf6 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -51,38 +51,47 @@ class Request extends UtopiaRequest if (!\is_array($methods)) { $id = $methods->getNamespace() . '.' . $methods->getMethodName(); + } else { + $matched = null; + foreach ($methods as $method) { + /** @var Method|null $method */ + if ($method === null) { + continue; + } + + // Find the method that matches the parameters passed + $methodParamNames = \array_map(fn ($param) => $param->getName(), $method->getParameters()); + $invalidParams = \array_diff(\array_keys($parameters), $methodParamNames); + + // No params defined, or all params are valid + if (empty($methodParamNames) || empty($invalidParams)) { + $matched = $method; + break; + } + } + + $id = $matched !== null + ? $matched->getNamespace() . '.' . $matched->getMethodName() + : 'unknown.unknown'; + } + + try { foreach ($this->getFilters() as $filter) { $parameters = $filter->parse($parameters, $id); } - $this->filteredParams = $parameters; - return $parameters; - } - - $matched = null; - foreach ($methods as $method) { - /** @var Method|null $method */ - if ($method === null) { - continue; + } catch (\Throwable $e) { + // 4xx filter throws are user-input errors that the action layer + // revalidates and reports. Cache the raw, pre-filter parameters + // so a subsequent getParams() — e.g. when the framework builds + // arguments for an error hook — returns without re-running + // filters. Otherwise the second throw gets wrapped as + // "Error handler had an error: ..." (HTTP 500), masking the + // intended 400. + $code = $e->getCode(); + if (\is_int($code) && $code >= 400 && $code < 500) { + $this->filteredParams = parent::getParams(); } - - // Find the method that matches the parameters passed - $methodParamNames = \array_map(fn ($param) => $param->getName(), $method->getParameters()); - $invalidParams = \array_diff(\array_keys($parameters), $methodParamNames); - - // No params defined, or all params are valid - if (empty($methodParamNames) || empty($invalidParams)) { - $matched = $method; - break; - } - } - - $id = $matched !== null - ? $matched->getNamespace() . '.' . $matched->getMethodName() - : 'unknown.unknown'; - - // Apply filters - foreach ($this->getFilters() as $filter) { - $parameters = $filter->parse($parameters, $id); + throw $e; } $this->filteredParams = $parameters; diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index e3efe3bbd9..315f40475a 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -4339,6 +4339,51 @@ trait DatabasesBase // $this->assertEquals('Invalid query: Cannot query search on attribute "actors" because it is an array.', $documents['body']['message']); } + public function testDocumentsListInvalidQuerySyntax(): void + { + $data = $this->setupDocuments(); + $databaseId = $data['databaseId']; + + // Each entry in `queries` must be a JSON-encoded object (e.g. + // `{"method":"limit","values":[5]}`). Anything else — including the + // legacy SDK shorthand `limit(5)`, a hand-rolled non-JSON string, or a + // JSON value that isn't an object — must be rejected by Query::parse + // with the same `Invalid query: Syntax error` message so callers get + // a consistent 400. + $invalidQueries = [ + 'legacy shorthand' => 'limit(5)', + 'plain string' => 'not-json', + 'json non-object' => '"limit"', + 'malformed json' => '{"method":"limit","values":[5}', + 'unquoted attribute' => '{method:"limit",values:[5]}', + ]; + + foreach ($invalidQueries as $label => $rawQuery) { + $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [$rawQuery], + ]); + + $this->assertEquals(400, $documents['headers']['status-code'], "Expected 400 for [$label]: $rawQuery"); + $this->assertEquals('Invalid query: Syntax error', $documents['body']['message'], "Wrong error for [$label]: $rawQuery"); + } + + // Sanity check: the JSON form the SDK actually emits still works. + $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::limit(5)->toString(), + ], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertLessThanOrEqual(5, count($documents['body'][$this->getRecordResource()])); + } + public function testUpdateDocument(): void { $data = $this->setupDocuments(); diff --git a/tests/unit/Utopia/Request/Filters/ThrowingFilter.php b/tests/unit/Utopia/Request/Filters/ThrowingFilter.php new file mode 100644 index 0000000000..8e02b92e39 --- /dev/null +++ b/tests/unit/Utopia/Request/Filters/ThrowingFilter.php @@ -0,0 +1,24 @@ +calls++; + throw new \Exception($this->reason, $this->code); + } +} diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php index 57ebae6d1e..d6f911da8d 100644 --- a/tests/unit/Utopia/RequestTest.php +++ b/tests/unit/Utopia/RequestTest.php @@ -5,10 +5,12 @@ namespace Tests\Unit\Utopia; use Appwrite\SDK\Method; use Appwrite\SDK\Parameter; use Appwrite\Utopia\Request; +use Appwrite\Utopia\Request\Filter; use PHPUnit\Framework\TestCase; use Swoole\Http\Request as SwooleRequest; use Tests\Unit\Utopia\Request\Filters\First; use Tests\Unit\Utopia\Request\Filters\Second; +use Tests\Unit\Utopia\Request\Filters\ThrowingFilter; use Utopia\Http\Route; class RequestTest extends TestCase @@ -192,6 +194,105 @@ class RequestTest extends TestCase $this->assertSame('fallback', $request->getHeader('referer', 'fallback')); } + public function testGetParamsCachesRawParamsWhenFilterThrows4xx(): void + { + // Regression: when a request filter throws a 4xx exception during + // Request::getParams() (e.g. RequestV20 rejecting an unparseable + // queries[]), the framework's error path calls getParams() again to + // build error-hook arguments. Without caching, that second call + // re-runs the filter and re-throws, which the framework wraps as + // "Error handler had an error: ..." (HTTP 500), masking the intended + // 400. This test pins that behavior: the first call throws (so the + // action's argument resolution aborts), but the second call returns + // the raw, pre-filter params without re-invoking filters. + $filter = new ThrowingFilter(400, 'invalid input'); + + $this->setupSingleMethodRoute($filter); + $this->request->setQueryString(['foo' => 'bar']); + + $threw = false; + try { + $this->request->getParams(); + } catch (\Throwable $e) { + $threw = true; + $this->assertSame(400, $e->getCode()); + $this->assertSame('invalid input', $e->getMessage()); + } + $this->assertTrue($threw, 'First getParams() call must rethrow the filter exception.'); + $this->assertSame(1, $filter->calls, 'Filter ran once on the first call.'); + + // Second call: framework's error hook arg resolution. Must return raw + // params without re-invoking the filter. + $params = $this->request->getParams(); + $this->assertSame(['foo' => 'bar'], $params); + $this->assertSame(1, $filter->calls, 'Filter must not run again after a cached 4xx failure.'); + } + + public function testGetParamsDoesNotCacheRawParamsForServerError(): void + { + // 5xx filter throws indicate genuine server-side problems, not + // user-input mistakes. They must keep rethrowing on every call so + // the framework's normal error handling sees the failure each time + // — caching raw params would silently swallow real bugs. + $filter = new ThrowingFilter(500, 'boom'); + + $this->setupSingleMethodRoute($filter); + $this->request->setQueryString(['foo' => 'bar']); + + for ($attempt = 1; $attempt <= 2; $attempt++) { + $threw = false; + try { + $this->request->getParams(); + } catch (\Throwable $e) { + $threw = true; + $this->assertSame(500, $e->getCode()); + } + $this->assertTrue($threw, "Call #$attempt must rethrow."); + $this->assertSame($attempt, $filter->calls, "Filter must run on call #$attempt."); + } + } + + public function testGetParamsDoesNotCacheRawParamsForUncodedException(): void + { + // \Exception with the default code of 0 is treated as "unknown" and + // must propagate every call — same reasoning as 5xx. + $filter = new ThrowingFilter(0, 'unknown'); + + $this->setupSingleMethodRoute($filter); + $this->request->setQueryString(['foo' => 'bar']); + + for ($attempt = 1; $attempt <= 2; $attempt++) { + $threw = false; + try { + $this->request->getParams(); + } catch (\Throwable) { + $threw = true; + } + $this->assertTrue($threw, "Call #$attempt must rethrow."); + $this->assertSame($attempt, $filter->calls, "Filter must run on call #$attempt."); + } + } + + /** + * Helper to attach a route with a single SDK method and one filter. + */ + private function setupSingleMethodRoute(Filter $filter): void + { + $route = new Route(Request::METHOD_GET, '/single'); + $route->label('sdk', new Method( + namespace: 'namespace', + group: 'group', + name: 'method', + description: 'description', + auth: [], + responses: [], + )); + + $this->request->addHeader('EXAMPLE', 'VALUE'); + $this->request->setRoute($route); + $this->request->addFilter($filter); + } + /** * Helper to attach a route with multiple SDK methods to the request. */ From f27172018ff73c74928834a7361cde9e9d506b60 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 4 May 2026 06:56:56 +0000 Subject: [PATCH 09/15] chore: bump utopia-php/audit to 2.2.2 --- composer.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.lock b/composer.lock index 2889069e67..9f34d2dbe1 100644 --- a/composer.lock +++ b/composer.lock @@ -3502,16 +3502,16 @@ }, { "name": "utopia-php/audit", - "version": "2.2.1", + "version": "2.2.2", "source": { "type": "git", "url": "https://github.com/utopia-php/audit.git", - "reference": "e3e2d6ad5c7f6377d9237df296a12eb7943892fd" + "reference": "90886c202e7983999e6b6a8201004d5ab61d4b57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/audit/zipball/e3e2d6ad5c7f6377d9237df296a12eb7943892fd", - "reference": "e3e2d6ad5c7f6377d9237df296a12eb7943892fd", + "url": "https://api.github.com/repos/utopia-php/audit/zipball/90886c202e7983999e6b6a8201004d5ab61d4b57", + "reference": "90886c202e7983999e6b6a8201004d5ab61d4b57", "shasum": "" }, "require": { @@ -3545,9 +3545,9 @@ ], "support": { "issues": "https://github.com/utopia-php/audit/issues", - "source": "https://github.com/utopia-php/audit/tree/2.2.1" + "source": "https://github.com/utopia-php/audit/tree/2.2.2" }, - "time": "2026-02-02T10:39:25+00:00" + "time": "2026-05-04T06:48:58+00:00" }, { "name": "utopia-php/auth", @@ -8444,7 +8444,7 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -8465,5 +8465,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } From c32294743ac428769fff11948f4a3c510ba3fa1d Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 4 May 2026 12:31:50 +0530 Subject: [PATCH 10/15] reverted e2e --- src/Appwrite/Utopia/Request.php | 2 +- .../e2e/Services/Databases/DatabasesBase.php | 45 ------------------- 2 files changed, 1 insertion(+), 46 deletions(-) diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 945a3d2bf6..123b13cc8b 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -89,7 +89,7 @@ class Request extends UtopiaRequest // intended 400. $code = $e->getCode(); if (\is_int($code) && $code >= 400 && $code < 500) { - $this->filteredParams = parent::getParams(); + $this->filteredParams = $parameters; } throw $e; } diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 315f40475a..e3efe3bbd9 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -4339,51 +4339,6 @@ trait DatabasesBase // $this->assertEquals('Invalid query: Cannot query search on attribute "actors" because it is an array.', $documents['body']['message']); } - public function testDocumentsListInvalidQuerySyntax(): void - { - $data = $this->setupDocuments(); - $databaseId = $data['databaseId']; - - // Each entry in `queries` must be a JSON-encoded object (e.g. - // `{"method":"limit","values":[5]}`). Anything else — including the - // legacy SDK shorthand `limit(5)`, a hand-rolled non-JSON string, or a - // JSON value that isn't an object — must be rejected by Query::parse - // with the same `Invalid query: Syntax error` message so callers get - // a consistent 400. - $invalidQueries = [ - 'legacy shorthand' => 'limit(5)', - 'plain string' => 'not-json', - 'json non-object' => '"limit"', - 'malformed json' => '{"method":"limit","values":[5}', - 'unquoted attribute' => '{method:"limit",values:[5]}', - ]; - - foreach ($invalidQueries as $label => $rawQuery) { - $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'queries' => [$rawQuery], - ]); - - $this->assertEquals(400, $documents['headers']['status-code'], "Expected 400 for [$label]: $rawQuery"); - $this->assertEquals('Invalid query: Syntax error', $documents['body']['message'], "Wrong error for [$label]: $rawQuery"); - } - - // Sanity check: the JSON form the SDK actually emits still works. - $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'queries' => [ - Query::limit(5)->toString(), - ], - ]); - - $this->assertEquals(200, $documents['headers']['status-code']); - $this->assertLessThanOrEqual(5, count($documents['body'][$this->getRecordResource()])); - } - public function testUpdateDocument(): void { $data = $this->setupDocuments(); From 1fc3a8803ca1e393ac7cc89cad6280c4107fe9c4 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 4 May 2026 12:45:32 +0530 Subject: [PATCH 11/15] multiline comment --- src/Appwrite/Utopia/Request.php | 16 +++++++++------- tests/unit/Utopia/RequestTest.php | 20 +++++++++++--------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 123b13cc8b..24803eeaa7 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -80,13 +80,15 @@ class Request extends UtopiaRequest $parameters = $filter->parse($parameters, $id); } } catch (\Throwable $e) { - // 4xx filter throws are user-input errors that the action layer - // revalidates and reports. Cache the raw, pre-filter parameters - // so a subsequent getParams() — e.g. when the framework builds - // arguments for an error hook — returns without re-running - // filters. Otherwise the second throw gets wrapped as - // "Error handler had an error: ..." (HTTP 500), masking the - // intended 400. + /* + * 4xx filter throws are user-input errors that the action layer + * revalidates and reports. Cache the raw, pre-filter parameters + * so a subsequent getParams() — e.g. when the framework builds + * arguments for an error hook — returns without re-running + * filters. Otherwise the second throw gets wrapped as + * "Error handler had an error: ..." (HTTP 500), masking the + * intended 400. + */ $code = $e->getCode(); if (\is_int($code) && $code >= 400 && $code < 500) { $this->filteredParams = $parameters; diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php index d6f911da8d..a242d74f46 100644 --- a/tests/unit/Utopia/RequestTest.php +++ b/tests/unit/Utopia/RequestTest.php @@ -196,15 +196,17 @@ class RequestTest extends TestCase public function testGetParamsCachesRawParamsWhenFilterThrows4xx(): void { - // Regression: when a request filter throws a 4xx exception during - // Request::getParams() (e.g. RequestV20 rejecting an unparseable - // queries[]), the framework's error path calls getParams() again to - // build error-hook arguments. Without caching, that second call - // re-runs the filter and re-throws, which the framework wraps as - // "Error handler had an error: ..." (HTTP 500), masking the intended - // 400. This test pins that behavior: the first call throws (so the - // action's argument resolution aborts), but the second call returns - // the raw, pre-filter params without re-invoking filters. + /* + * Regression: when a request filter throws a 4xx exception during + * Request::getParams() (e.g. RequestV20 rejecting an unparseable + * queries[]), the framework's error path calls getParams() again to + * build error-hook arguments. Without caching, that second call + * re-runs the filter and re-throws, which the framework wraps as + * "Error handler had an error: ..." (HTTP 500), masking the intended + * 400. This test pins that behavior: the first call throws (so the + * action's argument resolution aborts), but the second call returns + * the raw, pre-filter params without re-invoking filters. + */ $filter = new ThrowingFilter(400, 'invalid input'); $this->setupSingleMethodRoute($filter); From 737c85822d6b5ad95cb5885d73fc1c00172d3a9d Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 4 May 2026 12:46:44 +0530 Subject: [PATCH 12/15] multi line comment --- tests/unit/Utopia/RequestTest.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php index a242d74f46..2247ff71f1 100644 --- a/tests/unit/Utopia/RequestTest.php +++ b/tests/unit/Utopia/RequestTest.php @@ -232,10 +232,12 @@ class RequestTest extends TestCase public function testGetParamsDoesNotCacheRawParamsForServerError(): void { - // 5xx filter throws indicate genuine server-side problems, not - // user-input mistakes. They must keep rethrowing on every call so - // the framework's normal error handling sees the failure each time - // — caching raw params would silently swallow real bugs. + /* + * 5xx filter throws indicate genuine server-side problems, not + * user-input mistakes. They must keep rethrowing on every call so + * the framework's normal error handling sees the failure each time + * — caching raw params would silently swallow real bugs. + */ $filter = new ThrowingFilter(500, 'boom'); $this->setupSingleMethodRoute($filter); From 1a410c43d79ec837e54c48d4d34753c61e16fbef Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 5 May 2026 10:54:01 +0530 Subject: [PATCH 13/15] fix: update graphql php dependency --- composer.json | 2 +- composer.lock | 23 ++++++++++++----------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/composer.json b/composer.json index 735955d980..236e3aa83d 100644 --- a/composer.json +++ b/composer.json @@ -93,7 +93,7 @@ "chillerlan/php-qrcode": "4.3.*", "adhocore/jwt": "1.1.*", "spomky-labs/otphp": "11.*", - "webonyx/graphql-php": "15.31.*", + "webonyx/graphql-php": "15.32.*", "league/csv": "9.14.*", "enshrined/svg-sanitize": "0.22.*" }, diff --git a/composer.lock b/composer.lock index 9f34d2dbe1..a155f3f68b 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": "bd45829c252971301370d62300be106d", + "content-hash": "2af4d953af2a624be8bf2f89ea27336c", "packages": [ { "name": "adhocore/jwt", @@ -5384,16 +5384,16 @@ }, { "name": "webonyx/graphql-php", - "version": "v15.31.5", + "version": "v15.32.3", "source": { "type": "git", "url": "https://github.com/webonyx/graphql-php.git", - "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9" + "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/089c4ef7e112df85788cfe06596278a8f99f4aa9", - "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/993bf0bea17f870412ad8a90f60c41cb8d5f1145", + "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145", "shasum": "" }, "require": { @@ -5402,16 +5402,16 @@ "php": "^7.4 || ^8" }, "require-dev": { - "amphp/amp": "^2.6", - "amphp/http-server": "^2.1", + "amphp/amp": "^2.6 || ^3", + "amphp/http-server": "^2.1 || ^3", "dms/phpunit-arraysubset-asserts": "dev-master", "ergebnis/composer-normalize": "^2.28", - "friendsofphp/php-cs-fixer": "3.94.2", + "friendsofphp/php-cs-fixer": "3.95.1", "mll-lab/php-cs-fixer-config": "5.13.0", "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "2.1.46", + "phpstan/phpstan": "2.1.51", "phpstan/phpstan-phpunit": "2.0.16", "phpstan/phpstan-strict-rules": "2.0.10", "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", @@ -5425,6 +5425,7 @@ "ticketswap/phpstan-error-formatter": "1.3.0" }, "suggest": { + "amphp/amp": "To leverage async resolving on AMPHP platform (v3 with AmpFutureAdapter, v2 with AmpPromiseAdapter)", "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", "psr/http-message": "To use standard GraphQL server", "react/promise": "To leverage async resolving on React PHP platform" @@ -5447,7 +5448,7 @@ ], "support": { "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v15.31.5" + "source": "https://github.com/webonyx/graphql-php/tree/v15.32.3" }, "funding": [ { @@ -5459,7 +5460,7 @@ "type": "open_collective" } ], - "time": "2026-04-11T18:06:15+00:00" + "time": "2026-04-24T13:49:35+00:00" } ], "packages-dev": [ From b0a2ef271a0440f6b0398a7cfa93eebb5074b7eb Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 5 May 2026 11:35:49 +0530 Subject: [PATCH 14/15] updated the queue for event propagation --- app/realtime.php | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 17edb7bbbc..87efec02b0 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -322,14 +322,8 @@ if (!function_exists('triggerPresenceUsage')) { if (!function_exists('getQueueForEventsForProject')) { function getQueueForEventsForProject(Document $project, User $user): QueueEvent { - global $register; - - /** @var Group $pools */ - $pools = $register->get('pools'); - - $queueForEvents = new QueueEvent(new BrokerPool( - publisher: $pools->get('publisher') - )); + global $container; + $queueForEvents = $container->get('queueForEvents'); $queueForEvents->setProject($project); $queueForEvents->setUser($user); @@ -340,8 +334,6 @@ if (!function_exists('getQueueForEventsForProject')) { if (!function_exists('triggerPresenceEvent')) { function triggerPresenceEvent( - Server $server, - Realtime $realtime, Document $project, User $user, string $eventName, @@ -407,9 +399,17 @@ if (!function_exists('setPermission')) { } global $container; + $container->set('pools', function ($register) { return $register->get('pools'); }, ['register']); + +$container->set('queueForEvents', function ($pools) { + return new QueueEvent(new BrokerPool( + publisher: $pools->get('publisher') + )); +}, ['pools']); + $container->set('queueForRealtime', function () { return new QueueRealtime(); }, []); @@ -1559,7 +1559,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } } - triggerPresenceEvent($server, $realtime, $project, $user, 'presences.[presenceId].upsert', $presence); + triggerPresenceEvent($project, $user, 'presences.[presenceId].upsert', $presence); break; @@ -1613,7 +1613,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } }); -$server->onClose(function (int $connection) use ($server, $realtime, $stats, $register) { +$server->onClose(function (int $connection) use ($realtime, $stats, $register) { $projectId = null; $userId = null; $subscriptionsBeforeClose = 0; @@ -1670,7 +1670,7 @@ $server->onClose(function (int $connection) use ($server, $realtime, $stats, $re foreach ($presences as $presence) { try { - triggerPresenceEvent($server, $realtime, $project, new User([]), 'presences.[presenceId].delete', $presence); + triggerPresenceEvent($project, new User([]), 'presences.[presenceId].delete', $presence); } catch (Throwable) { // Swallow errors to avoid breaking disconnect cleanup } From f455bb7a58c67cc669d94d4a57e517e16ea96dc9 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 5 May 2026 17:11:49 +0530 Subject: [PATCH 15/15] refactor: add checks for queueForEvents and queueForRealtime in container setup; enhance presence resolution in tests --- app/realtime.php | 21 ++++--- tests/e2e/Services/Presence/PresenceBase.php | 58 +++++++++++++++----- 2 files changed, 57 insertions(+), 22 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 87efec02b0..3221f83e95 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -404,15 +404,20 @@ $container->set('pools', function ($register) { return $register->get('pools'); }, ['register']); -$container->set('queueForEvents', function ($pools) { - return new QueueEvent(new BrokerPool( - publisher: $pools->get('publisher') - )); -}, ['pools']); +if (!$container->has('queueForEvents')) { + $container->set('queueForEvents', function ($pools) { + var_dump("ce"); + return new QueueEvent(new BrokerPool( + publisher: $pools->get('publisher') + )); + }, ['pools']); +} -$container->set('queueForRealtime', function () { - return new QueueRealtime(); -}, []); +if (!$container->has('queueForRealtime')) { + $container->set('queueForRealtime', function () { + return new QueueRealtime(); + }, []); +} $realtime = getRealtime(); $presenceState = new PresenceState(); diff --git a/tests/e2e/Services/Presence/PresenceBase.php b/tests/e2e/Services/Presence/PresenceBase.php index 9131af18dd..8f05b4144a 100644 --- a/tests/e2e/Services/Presence/PresenceBase.php +++ b/tests/e2e/Services/Presence/PresenceBase.php @@ -105,6 +105,26 @@ trait PresenceBase return $presence; } + protected function resolvePresenceForUser(string $userId, array $headers): array + { + $presence = $this->client->call( + Client::METHOD_GET, + '/presences', + $headers, + [ + 'queries' => [ + Query::equal('userId', [$userId])->toString(), + ], + ] + ); + + $this->assertEquals(200, $presence['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $presence['body']['total'] ?? 0); + $this->assertNotEmpty($presence['body']['presences'][0] ?? []); + + return $presence['body']['presences'][0]; + } + public function testUpsertAndGetPresence(): void { if ($this->getSide() === 'client') { @@ -270,16 +290,12 @@ trait PresenceBase } $projectId = $this->getProject()['$id']; - $user1 = $this->getUser(); - $headersUser1 = $this->getHeaders(false); - + $user1 = $this->getUser(true); $user2 = $this->getUser(true); - - // Avoid overwriting the cached user for the rest of the test run. - self::$user[$projectId] = $user1; - - $headersUser2 = $this->getHeaders(false); - $headersUser2['cookie'] = 'a_session_' . $projectId . '=' . $user2['session']; + $headersUser2 = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $user2['session'], + ]; $permissionsForUser2 = [ Permission::read(Role::user($user2['$id'])), @@ -430,7 +446,14 @@ trait PresenceBase ); $this->assertEquals(200, $upsert['headers']['status-code']); - $presenceId = $upsert['body']['$id']; + $presence = $this->resolvePresenceForUser( + $upsert['body']['userId'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)) + ); + $presenceId = $presence['$id']; $update = $this->client->call( Client::METHOD_PATCH, @@ -498,7 +521,14 @@ trait PresenceBase ); $this->assertEquals(200, $upsert['headers']['status-code']); - $presenceId = $upsert['body']['$id']; + $presence = $this->resolvePresenceForUser( + $upsert['body']['userId'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)) + ); + $presenceId = $presence['$id']; $delete = $this->client->call( Client::METHOD_DELETE, @@ -547,11 +577,11 @@ trait PresenceBase ] ); $this->assertEquals(200, $upsert['headers']['status-code']); - $presence = $upsert['body']; $headers = \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders(false)); + $presence = $this->resolvePresenceForUser($upsert['body']['userId'], $headers); } else { $presence = $this->setupPresence([ 'status' => 'cache-update-setup', @@ -619,11 +649,11 @@ trait PresenceBase ] ); $this->assertEquals(200, $upsert['headers']['status-code']); - $presence = $upsert['body']; $headers = \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders(false)); + $presence = $this->resolvePresenceForUser($upsert['body']['userId'], $headers); } else { $presence = $this->setupPresence([ 'status' => 'cache-purge-only-setup', @@ -690,11 +720,11 @@ trait PresenceBase ] ); $this->assertEquals(200, $upsert['headers']['status-code']); - $presence = $upsert['body']; $headers = \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders(false)); + $presence = $this->resolvePresenceForUser($upsert['body']['userId'], $headers); } else { $presence = $this->setupPresence([ 'status' => 'cache-delete-setup',