From bfa27b7c222a54c4b9aa037b957b572717243463 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 23 Sep 2026 10:25:40 +0100 Subject: [PATCH 1/5] fix(http): treat an explicit null optional param as omitted A null skipped validation and bypassed the declared default, so typed actions threw a TypeError and every endpoint had to restore its defaults by hand. A Nullable validator still receives null, since that is how a route declares null as a value. --- packages/http/src/Http/Http.php | 31 ++++++++++++--- packages/http/tests/HttpTest.php | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/packages/http/src/Http/Http.php b/packages/http/src/Http/Http.php index 67f1bd539..b2c195bc7 100755 --- a/packages/http/src/Http/Http.php +++ b/packages/http/src/Http/Http.php @@ -9,6 +9,7 @@ use Utopia\Telemetry\Histogram; use Utopia\Telemetry\UpDownCounter; use Utopia\Validator; +use Utopia\Validator\Nullable; class Http { @@ -730,6 +731,12 @@ protected function getArguments(Hook $hook, array $values, array $requestParams, } $existsInRequest = \array_key_exists($requestKey, $requestParams); + + // An explicit null is treated as omitted unless the validator declares null as a value + if ($existsInRequest && $requestParams[$requestKey] === null && $param['optional'] && $param['default'] !== null && !$this->resolveValidator($param) instanceof Nullable) { + $existsInRequest = false; + } + $existsInValues = \array_key_exists($valuesKey, $values); $paramExists = $existsInRequest || $existsInValues; @@ -884,12 +891,7 @@ protected function validate(string $key, array $param, mixed $value): void return; } - $validator = $param['validator']; // checking whether the class exists - - if (\is_callable($validator)) { - $context = $this->adapter->context(); - $validator = \call_user_func_array($validator, array_map($context->get(...), $param['injections'])); - } + $validator = $this->resolveValidator($param); if (!$validator instanceof Validator) { // is the validator object an instance of the Validator class throw new Exception('Validator object is not an instance of the Validator class', 500); @@ -900,6 +902,23 @@ protected function validate(string $key, array $param, mixed $value): void } } + /** + * Build the param's validator, resolving a factory closure with its injections. + * + * @param array $param + */ + private function resolveValidator(array $param): mixed + { + $validator = $param['validator']; + + if (\is_callable($validator)) { + $context = $this->adapter->context(); + $validator = \call_user_func_array($validator, array_map($context->get(...), $param['injections'])); + } + + return $validator; + } + /** * Reset all the static variables */ diff --git a/packages/http/tests/HttpTest.php b/packages/http/tests/HttpTest.php index e3268d92c..a5035c914 100755 --- a/packages/http/tests/HttpTest.php +++ b/packages/http/tests/HttpTest.php @@ -11,7 +11,10 @@ use Utopia\Http\Adapter\FPM\Response; use Utopia\Http\Adapter\FPM\Server; use Utopia\Http\Tests\UtopiaFPMRequestTest; +use Utopia\Validator\Integer; +use Utopia\Validator\Nullable; use Utopia\Validator\Text; +use Utopia\Validator\WhiteList; final class HttpTest extends TestCase { @@ -23,6 +26,16 @@ final class HttpTest extends TestCase protected ?string $uri; + /** + * @var array + */ + protected array $get = []; + + /** + * @var array + */ + protected array $post = []; + public function setUp(): void { Http::reset(); @@ -42,12 +55,16 @@ protected function saveRequest(): void { $this->method = $_SERVER['REQUEST_METHOD'] ?? null; $this->uri = $_SERVER['REQUEST_URI'] ?? null; + $this->get = $_GET; + $this->post = $_POST; } protected function restoreRequest(): void { $_SERVER['REQUEST_METHOD'] = $this->method; $_SERVER['REQUEST_URI'] = $this->uri; + $_GET = $this->get; + $_POST = $this->post; } public function testCanGetDifferentModes(): void @@ -931,6 +948,57 @@ public function testCallableStringParametersNotExecuted(): void $this->assertSame('generated: generated-value', $result); } + public function testExplicitNullOptionalParamFallsBackToDefault(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + $run = function (string $path, array $params): string { + $_SERVER['REQUEST_URI'] = $path; + + $request = new UtopiaFPMRequestTest(); + $request::_setParams($params); + + ob_start(); + $this->http->execute($request, new Response()); + $result = ob_get_contents(); + ob_end_clean(); + + $request::_setParams(null); + + return (string) $result; + }; + + // Typed action with a non-null default: null reaches the action as the default, which is not validated + Http::get('/typed') + ->param('image', '', new WhiteList(['a', 'b']), 'image param', true) + ->param('badge', -1, new Integer(), 'badge param', true) + ->action(function (string $image, int $badge) { + echo var_export([$image, $badge], true); + }); + + $this->assertSame($run('/typed', []), $run('/typed', ['image' => null, 'badge' => null])); + $this->assertSame(var_export(['', -1], true), $run('/typed', ['image' => null, 'badge' => null])); + + // Nullable validators declare null as a value, so it is passed through + Http::get('/nullable') + ->param('x', 'x-def', new Nullable(new Text(200)), 'x param', true) + ->param('y', 'y-def', fn() => new Nullable(new Text(200)), 'y param', true) + ->action(function (?string $x, ?string $y) { + echo var_export([$x, $y], true); + }); + + $this->assertSame(var_export([null, null], true), $run('/nullable', ['x' => null, 'y' => null])); + + // A null default already matches the explicit null + Http::get('/null-default') + ->param('x', null, new Text(200), 'x param', true) + ->action(function (?string $x) { + echo var_export($x, true); + }); + + $this->assertSame('NULL', $run('/null-default', ['x' => null])); + } + public function testCanInjectResourceAndParamWithSameName(): void { $_SERVER['REQUEST_METHOD'] = 'GET'; From 8969c1ef02fdb74629feeb9f4668238f873bfb61 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 23 Sep 2026 10:36:38 +0100 Subject: [PATCH 2/5] fix(http): keep null for any validator that accepts it A top-level Nullable check missed compositions such as AnyOf containing a Nullable, replacing a meaningful null with the default. Asking the validator whether null is valid covers every composition. --- packages/http/src/Http/Http.php | 10 ++++++---- packages/http/tests/HttpTest.php | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/http/src/Http/Http.php b/packages/http/src/Http/Http.php index b2c195bc7..1e3c6a2fe 100755 --- a/packages/http/src/Http/Http.php +++ b/packages/http/src/Http/Http.php @@ -9,7 +9,6 @@ use Utopia\Telemetry\Histogram; use Utopia\Telemetry\UpDownCounter; use Utopia\Validator; -use Utopia\Validator\Nullable; class Http { @@ -732,9 +731,12 @@ protected function getArguments(Hook $hook, array $values, array $requestParams, $existsInRequest = \array_key_exists($requestKey, $requestParams); - // An explicit null is treated as omitted unless the validator declares null as a value - if ($existsInRequest && $requestParams[$requestKey] === null && $param['optional'] && $param['default'] !== null && !$this->resolveValidator($param) instanceof Nullable) { - $existsInRequest = false; + // An explicit null is treated as omitted unless the validator accepts null as a value + if ($existsInRequest && $requestParams[$requestKey] === null && $param['optional'] && $param['default'] !== null) { + $validator = $this->resolveValidator($param); + if ($validator instanceof Validator && !$validator->isValid(null)) { + $existsInRequest = false; + } } $existsInValues = \array_key_exists($valuesKey, $values); diff --git a/packages/http/tests/HttpTest.php b/packages/http/tests/HttpTest.php index a5035c914..0e77bf6b4 100755 --- a/packages/http/tests/HttpTest.php +++ b/packages/http/tests/HttpTest.php @@ -11,6 +11,7 @@ use Utopia\Http\Adapter\FPM\Response; use Utopia\Http\Adapter\FPM\Server; use Utopia\Http\Tests\UtopiaFPMRequestTest; +use Utopia\Validator\AnyOf; use Utopia\Validator\Integer; use Utopia\Validator\Nullable; use Utopia\Validator\Text; @@ -979,15 +980,16 @@ public function testExplicitNullOptionalParamFallsBackToDefault(): void $this->assertSame($run('/typed', []), $run('/typed', ['image' => null, 'badge' => null])); $this->assertSame(var_export(['', -1], true), $run('/typed', ['image' => null, 'badge' => null])); - // Nullable validators declare null as a value, so it is passed through + // Validators that accept null declare it as a value, so it is passed through Http::get('/nullable') ->param('x', 'x-def', new Nullable(new Text(200)), 'x param', true) ->param('y', 'y-def', fn() => new Nullable(new Text(200)), 'y param', true) - ->action(function (?string $x, ?string $y) { - echo var_export([$x, $y], true); + ->param('z', 'z-def', new AnyOf([new Nullable(new Text(200)), new Integer()]), 'z param', true) + ->action(function (?string $x, ?string $y, mixed $z) { + echo var_export([$x, $y, $z], true); }); - $this->assertSame(var_export([null, null], true), $run('/nullable', ['x' => null, 'y' => null])); + $this->assertSame(var_export([null, null, null], true), $run('/nullable', ['x' => null, 'y' => null, 'z' => null])); // A null default already matches the explicit null Http::get('/null-default') From 0ab0116d8cf27bb37db8b7baca966113e66f4147 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 23 Sep 2026 11:45:56 +0100 Subject: [PATCH 3/5] fix(http): leave params that skip validation untouched on explicit null The explicit-null check built and ran the validator before looking at skipValidation, so a skipped validator factory ran, or threw, on requests that used to succeed. Such params now receive null exactly as before. --- packages/http/src/Http/Http.php | 2 +- packages/http/tests/HttpTest.php | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/http/src/Http/Http.php b/packages/http/src/Http/Http.php index 1e3c6a2fe..6255d9862 100755 --- a/packages/http/src/Http/Http.php +++ b/packages/http/src/Http/Http.php @@ -732,7 +732,7 @@ protected function getArguments(Hook $hook, array $values, array $requestParams, $existsInRequest = \array_key_exists($requestKey, $requestParams); // An explicit null is treated as omitted unless the validator accepts null as a value - if ($existsInRequest && $requestParams[$requestKey] === null && $param['optional'] && $param['default'] !== null) { + if ($existsInRequest && $requestParams[$requestKey] === null && $param['optional'] && $param['default'] !== null && !$param['skipValidation']) { $validator = $this->resolveValidator($param); if ($validator instanceof Validator && !$validator->isValid(null)) { $existsInRequest = false; diff --git a/packages/http/tests/HttpTest.php b/packages/http/tests/HttpTest.php index 0e77bf6b4..e7de6d58e 100755 --- a/packages/http/tests/HttpTest.php +++ b/packages/http/tests/HttpTest.php @@ -999,6 +999,17 @@ public function testExplicitNullOptionalParamFallsBackToDefault(): void }); $this->assertSame('NULL', $run('/null-default', ['x' => null])); + + // A param that skips validation never has its validator built, and its null passes through as before + Http::get('/skip-validation') + ->param('x', 'x-def', function () { + throw new \RuntimeException('validator must not be built'); + }, 'x param', true, skipValidation: true) + ->action(function (?string $x) { + echo var_export($x, true); + }); + + $this->assertSame('NULL', $run('/skip-validation', ['x' => null])); } public function testCanInjectResourceAndParamWithSameName(): void From 0139910eea0307ba2bc555406637bf3fb65907ab Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 23 Sep 2026 13:29:28 +0100 Subject: [PATCH 4/5] test(http): assert the skipped-validation null on the action's input The regression test pinned whether the validator factory runs. What callers observe is the value the action receives, so assert that instead. --- packages/http/tests/HttpTest.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/http/tests/HttpTest.php b/packages/http/tests/HttpTest.php index e7de6d58e..d78064c44 100755 --- a/packages/http/tests/HttpTest.php +++ b/packages/http/tests/HttpTest.php @@ -1000,11 +1000,9 @@ public function testExplicitNullOptionalParamFallsBackToDefault(): void $this->assertSame('NULL', $run('/null-default', ['x' => null])); - // A param that skips validation never has its validator built, and its null passes through as before + // A param that skips validation receives an explicit null as before, even when its validator rejects null Http::get('/skip-validation') - ->param('x', 'x-def', function () { - throw new \RuntimeException('validator must not be built'); - }, 'x param', true, skipValidation: true) + ->param('x', 'x-def', new Text(200), 'x param', true, skipValidation: true) ->action(function (?string $x) { echo var_export($x, true); }); From 963b3045aee001e8aac796d9de65f6c87174f504 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 23 Sep 2026 13:37:53 +0100 Subject: [PATCH 5/5] fix(http): skip the null probe when a route value takes precedence A path value wins over a request null for the same key, so building the validator to test null there was wasted work and ran stateful factories an extra time. --- packages/http/src/Http/Http.php | 4 ++-- packages/http/tests/HttpTest.php | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/http/src/Http/Http.php b/packages/http/src/Http/Http.php index 6255d9862..267e055f0 100755 --- a/packages/http/src/Http/Http.php +++ b/packages/http/src/Http/Http.php @@ -730,16 +730,16 @@ protected function getArguments(Hook $hook, array $values, array $requestParams, } $existsInRequest = \array_key_exists($requestKey, $requestParams); + $existsInValues = \array_key_exists($valuesKey, $values); // An explicit null is treated as omitted unless the validator accepts null as a value - if ($existsInRequest && $requestParams[$requestKey] === null && $param['optional'] && $param['default'] !== null && !$param['skipValidation']) { + if ($existsInRequest && !$existsInValues && $requestParams[$requestKey] === null && $param['optional'] && $param['default'] !== null && !$param['skipValidation']) { $validator = $this->resolveValidator($param); if ($validator instanceof Validator && !$validator->isValid(null)) { $existsInRequest = false; } } - $existsInValues = \array_key_exists($valuesKey, $values); $paramExists = $existsInRequest || $existsInValues; $arg = $existsInRequest ? $requestParams[$requestKey] : $param['default']; diff --git a/packages/http/tests/HttpTest.php b/packages/http/tests/HttpTest.php index d78064c44..61ae6487d 100755 --- a/packages/http/tests/HttpTest.php +++ b/packages/http/tests/HttpTest.php @@ -1008,6 +1008,15 @@ public function testExplicitNullOptionalParamFallsBackToDefault(): void }); $this->assertSame('NULL', $run('/skip-validation', ['x' => null])); + + // A path value takes precedence over an explicit null in the request for the same key + Http::get('/items/:x') + ->param('x', 'x-def', new Text(200), 'x param', true) + ->action(function (string $x) { + echo var_export($x, true); + }); + + $this->assertSame(var_export('abc', true), $run('/items/abc', ['x' => null])); } public function testCanInjectResourceAndParamWithSameName(): void