From bfefdc0d651c5e68f7401a5bd1522f5be73d655d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 22 Sep 2025 21:43:13 +1200 Subject: [PATCH 1/5] Add request model support --- composer.json | 10 +- src/App.php | 43 ++++- src/Hook.php | 22 ++- src/Model.php | 8 + tests/ModelTest.php | 434 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 495 insertions(+), 22 deletions(-) create mode 100644 src/Model.php create mode 100644 tests/ModelTest.php diff --git a/composer.json b/composer.json index cf4f01e..72c7f8d 100644 --- a/composer.json +++ b/composer.json @@ -22,15 +22,15 @@ "bench": "vendor/bin/phpbench run --report=benchmark" }, "require": { - "php": ">=8.1", + "php": ">=8.3", "utopia-php/compression": "0.1.*", "utopia-php/telemetry": "0.1.*" }, "require-dev": { - "phpunit/phpunit": "^9.5.25", - "laravel/pint": "^1.2", - "phpstan/phpstan": "^1.10", - "phpbench/phpbench": "^1.2" + "phpunit/phpunit": "9.*", + "laravel/pint": "1.*", + "phpstan/phpstan": "1.*", + "phpbench/phpbench": "1.*" }, "config": { "allow-plugins": { diff --git a/src/App.php b/src/App.php index d041bc3..df53eb9 100755 --- a/src/App.php +++ b/src/App.php @@ -693,21 +693,50 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) $value = $existsInValues ? $values[$key] : $arg; - if (!$param['skipValidation']) { - if (!$paramExists && !$param['optional']) { - throw new Exception('Param "' . $key . '" is not optional.', 400); - } + if (!$param['skipValidation']&& !$paramExists && !$param['optional']) { + throw new Exception('Param "' . $key . '" is not optional.', 400); + } - if ($paramExists) { - $this->validate($key, $param, $value); + if ($param['model'] && $paramExists) { + $model = $param['model']; + + if (!\is_string($model) || !\class_exists($model)) { + throw new Exception('Model class does not exist: ' . $model, 500); + } + if (!\is_a($model, Model::class, true)) { + throw new Exception('Model class is not an instance of Utopia\\Model', 500); + } + if (($value === null || $value === '') && $param['optional']) { + $value = null; + } + if (\is_string($value) && $value !== '') { + try { + $value = \json_decode($value, true, flags: JSON_THROW_ON_ERROR); + } catch (\Throwable $e) { + throw new Exception('Failed to parse JSON for model param "' . $key . '": ' . $e->getMessage(), 400); + } + } + if (!\is_array($value) && $value !== null && $value !== '') { + throw new Exception('Model param "' . $key . '" must be a JSON string, or an array', 400); + } + if (\is_array($value)) { + try { + $value = $model::fromArray($value); + } catch (\Throwable $e) { + throw new Exception('Failed to create model instance for param "' . $key . '": ' . $e->getMessage(), 400); + } } } + if (!$param['skipValidation'] && $paramExists && $value !== null && $value !== '') { + $this->validate($key, $param, $value); + } + $hook->setParamValue($key, $value); $arguments[$param['order']] = $value; } - foreach ($hook->getInjections() as $key => $injection) { + foreach ($hook->getInjections() as $injection) { $arguments[$injection['order']] = $this->getResource($injection['name']); } diff --git a/src/Hook.php b/src/Hook.php index 166852f..b679b78 100644 --- a/src/Hook.php +++ b/src/Hook.php @@ -190,18 +190,19 @@ public function inject(string $injection): static /** * Add Param * - * @param string $key - * @param mixed $default - * @param Validator|callable $validator - * @param string $description - * @param bool $optional - * @param array $injections - * @param bool $skipValidation - * @param bool $deprecated - * @param string $example + * @param string $key + * @param mixed $default + * @param Validator|callable $validator + * @param string $description + * @param bool $optional + * @param array $injections + * @param bool $skipValidation + * @param bool $deprecated + * @param string $example + * @param string|null $model * @return static */ - public function param(string $key, mixed $default, Validator|callable $validator, string $description = '', bool $optional = false, array $injections = [], bool $skipValidation = false, bool $deprecated = false, string $example = ''): static + public function param(string $key, mixed $default, Validator|callable $validator, string $description = '', bool $optional = false, array $injections = [], bool $skipValidation = false, bool $deprecated = false, string $example = '', ?string $model = null): static { $this->params[$key] = [ 'default' => $default, @@ -212,6 +213,7 @@ public function param(string $key, mixed $default, Validator|callable $validator 'skipValidation' => $skipValidation, 'deprecated' => $deprecated, 'example' => $example, + 'model' => $model, 'value' => null, 'order' => count($this->params) + count($this->injections), ]; diff --git a/src/Model.php b/src/Model.php new file mode 100644 index 0000000..58bce7e --- /dev/null +++ b/src/Model.php @@ -0,0 +1,8 @@ +app = new App('UTC'); + $this->saveRequest(); + } + + public function tearDown(): void + { + $this->app = null; + $this->restoreRequest(); + } + + protected function saveRequest(): void + { + $this->method = $_SERVER['REQUEST_METHOD'] ?? null; + $this->uri = $_SERVER['REQUEST_URI'] ?? null; + } + + protected function restoreRequest(): void + { + $_SERVER['REQUEST_METHOD'] = $this->method; + $_SERVER['REQUEST_URI'] = $this->uri; + $_GET = []; + $_POST = []; + } + + public function testModelParamWithJsonString(): void + { + $result = null; + + $this->app + ->get('/users') + ->param('user', null, new UserValidator(), 'User data', false, [], false, false, '', UserModel::class) + ->action(function (UserModel $user) use (&$result) { + $result = $user; + }); + + // Test with JSON string input + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/users'; + $_GET = ['user' => '{"name":"John Doe","age":30,"email":"john@example.com"}']; + + $this->app->run(new Request(), new Response()); + + $this->assertInstanceOf(UserModel::class, $result); + $this->assertEquals('John Doe', $result->name); + $this->assertEquals(30, $result->age); + $this->assertEquals('john@example.com', $result->email); + } + + public function testModelParamWithArray(): void + { + $result = null; + + $this->app + ->post('/users') + ->param('user', null, new UserValidator(), 'User data', false, [], false, false, '', UserModel::class) + ->action(function (UserModel $user) use (&$result) { + $result = $user; + }); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/users'; + $_POST = ['user' => ['name' => 'Jane Smith', 'age' => 25]]; + + $this->app->run(new Request(), new Response()); + + $this->assertInstanceOf(UserModel::class, $result); + $this->assertEquals('Jane Smith', $result->name); + $this->assertEquals(25, $result->age); + $this->assertNull($result->email); + } + + public function testModelParamWithInvalidJson(): void + { + $errorCaught = false; + $errorMessage = ''; + + $this->app + ->get('/users') + ->param('user', null, new UserValidator(), 'User data', false, [], false, false, '', UserModel::class) + ->action(function (UserModel $user) { + // Should not reach here + }); + + $this->app->error()->inject('error')->action(function (\Throwable $error) use (&$errorCaught, &$errorMessage) { + $errorCaught = true; + $errorMessage = $error->getMessage(); + }); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/users'; + $_GET = ['user' => '{invalid json}']; + + $this->app->run(new Request(), new Response()); + + $this->assertTrue($errorCaught); + $this->assertStringContainsString('Failed to parse JSON', $errorMessage); + } + + public function testModelParamWithMissingRequiredFields(): void + { + $errorCaught = false; + $errorMessage = ''; + + $this->app + ->post('/users') + ->param('user', null, new UserValidator(), 'User data', false, [], false, false, '', UserModel::class) + ->action(function (UserModel $user) { + // Should not reach here + }); + + $this->app->error()->inject('error')->action(function (\Throwable $error) use (&$errorCaught, &$errorMessage) { + $errorCaught = true; + $errorMessage = $error->getMessage(); + }); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/users'; + $_POST = ['user' => '{"name":"John"}']; // Missing 'age' field + + $this->app->run(new Request(), new Response()); + + $this->assertTrue($errorCaught); + $this->assertStringContainsString('Failed to create model instance', $errorMessage); + } + + public function testModelParamWithInvalidType(): void + { + $errorCaught = false; + $errorMessage = ''; + + $this->app + ->get('/users-invalid-type') + ->param('user', null, new UserValidator(), 'User data', false, [], false, false, '', UserModel::class) + ->action(function (UserModel $user) { + // Should not reach here + }); + + $this->app->error()->inject('error')->action(function (\Throwable $error) use (&$errorCaught, &$errorMessage) { + $errorCaught = true; + $errorMessage = $error->getMessage(); + }); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/users-invalid-type'; + $_GET = ['user' => 12345]; // Invalid type (number instead of JSON string/array) + + $this->app->run(new Request(), new Response()); + + $this->assertTrue($errorCaught); + $this->assertStringContainsString('must be a JSON string, or an array', $errorMessage); + } + + public function testModelParamOptional(): void + { + $result = null; + $actionCalled = false; + + $this->app + ->get('/users-optional') + ->param('user', null, new UserValidator(), 'User data', true, [], false, false, '', UserModel::class) + ->action(function (?UserModel $user = null) use (&$result, &$actionCalled) { + $actionCalled = true; + $result = $user; + }); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/users-optional'; + $_GET = []; + // Not providing 'user' param + + $this->app->run(new Request(), new Response()); + + $this->assertTrue($actionCalled); + $this->assertNull($result); + } + + public function testModelParamWithDefault(): void + { + $result = null; + $defaultUser = new UserModel('Default User', 0); + + $this->app + ->get('/users-default') + ->param('user', $defaultUser, new UserValidator(), 'User data', true, [], false, false, '', UserModel::class) + ->action(function (UserModel $user) use (&$result) { + $result = $user; + }); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/users-default'; + $_GET = []; + // Not providing 'user' param, should use default + + $this->app->run(new Request(), new Response()); + + $this->assertInstanceOf(UserModel::class, $result); + $this->assertEquals('Default User', $result->name); + $this->assertEquals(0, $result->age); + } + + public function testMultipleModelParams(): void + { + $userResult = null; + $addressResult = null; + + $this->app + ->post('/profile') + ->param('user', null, new UserValidator(), 'User data', false, [], false, false, '', UserModel::class) + ->param('address', null, new AddressValidator(), 'Address data', false, [], false, false, '', AddressModel::class) + ->action(function (UserModel $user, AddressModel $address) use (&$userResult, &$addressResult) { + $userResult = $user; + $addressResult = $address; + }); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/profile'; + $_POST = [ + 'user' => '{"name":"Alice","age":28}', + 'address' => '{"street":"123 Main St","city":"New York","zipCode":"10001"}' + ]; + + $this->app->run(new Request(), new Response()); + + $this->assertInstanceOf(UserModel::class, $userResult); + $this->assertEquals('Alice', $userResult->name); + $this->assertEquals(28, $userResult->age); + + $this->assertInstanceOf(AddressModel::class, $addressResult); + $this->assertEquals('123 Main St', $addressResult->street); + $this->assertEquals('New York', $addressResult->city); + $this->assertEquals('10001', $addressResult->zipCode); + $this->assertEquals('USA', $addressResult->country); + } + + public function testInvalidModelClass(): void + { + $errorCaught = false; + $errorMessage = ''; + + $this->app + ->get('/test') + ->param('data', null, new Text(100), 'Test data', false, [], false, false, '', 'NonExistentClass') + ->action(function ($data) { + // Should not reach here + }); + + $this->app->error()->inject('error')->action(function (\Throwable $error) use (&$errorCaught, &$errorMessage) { + $errorCaught = true; + $errorMessage = $error->getMessage(); + }); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/test'; + $_GET = ['data' => '{"test":"value"}']; + + $this->app->run(new Request(), new Response()); + + $this->assertTrue($errorCaught); + $this->assertStringContainsString('Model class does not exist', $errorMessage); + } + + public function testNonModelClass(): void + { + $errorCaught = false; + $errorMessage = ''; + + $this->app + ->get('/test-non-model') + ->param('data', null, new Text(100), 'Test data', false, [], false, false, '', \stdClass::class) + ->action(function ($data) { + // Should not reach here + }); + + $this->app->error()->inject('error')->action(function (\Throwable $error) use (&$errorCaught, &$errorMessage) { + $errorCaught = true; + $errorMessage = $error->getMessage(); + }); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/test-non-model'; + $_GET = ['data' => '{"test":"value"}']; + + $this->app->run(new Request(), new Response()); + + $this->assertTrue($errorCaught); + $this->assertStringContainsString('not an instance of Utopia\\Model', $errorMessage); + } + + public function testModelWithEmptyString(): void + { + $result = null; + $actionCalled = false; + + $this->app + ->get('/users-empty') + ->param('user', null, new UserValidator(), 'User data', true, [], false, false, '', UserModel::class) + ->action(function (?UserModel $user = null) use (&$result, &$actionCalled) { + $actionCalled = true; + $result = $user; + }); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/users-empty'; + $_GET = ['user' => '']; // Empty string + + $this->app->run(new Request(), new Response()); + + $this->assertTrue($actionCalled); + $this->assertNull($result); + } +} \ No newline at end of file From 7e3f52377e3194b3f8293e6aa63f05fe27d4ce90 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 22 Sep 2025 21:55:28 +1200 Subject: [PATCH 2/5] Fix stan --- composer.lock | 136 +++++++++++++++++++++++--------------------- src/App.php | 2 +- src/Model.php | 2 +- tests/ModelTest.php | 16 +++--- 4 files changed, 80 insertions(+), 76 deletions(-) diff --git a/composer.lock b/composer.lock index 1282e5d..c8030c4 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": "703d47ec02ec2d09dda755ab4924cae6", + "content-hash": "0b765d89cd9951f9c042d4c82867ed26", "packages": [ { "name": "brick/math", @@ -145,24 +145,21 @@ }, { "name": "google/protobuf", - "version": "v4.32.0", + "version": "v4.32.1", "source": { "type": "git", "url": "https://github.com/protocolbuffers/protobuf-php.git", - "reference": "9a9a92ecbe9c671dc1863f6d4a91ea3ea12c8646" + "reference": "c4ed1c1f9bbc1e91766e2cd6c0af749324fe87cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/9a9a92ecbe9c671dc1863f6d4a91ea3ea12c8646", - "reference": "9a9a92ecbe9c671dc1863f6d4a91ea3ea12c8646", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/c4ed1c1f9bbc1e91766e2cd6c0af749324fe87cb", + "reference": "c4ed1c1f9bbc1e91766e2cd6c0af749324fe87cb", "shasum": "" }, "require": { "php": ">=8.1.0" }, - "provide": { - "ext-protobuf": "*" - }, "require-dev": { "phpunit/phpunit": ">=5.0.0 <8.5.27" }, @@ -186,9 +183,9 @@ "proto" ], "support": { - "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.32.0" + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.32.1" }, - "time": "2025-08-14T20:00:33+00:00" + "time": "2025-09-14T05:14:52+00:00" }, { "name": "nyholm/psr7", @@ -336,20 +333,20 @@ }, { "name": "open-telemetry/api", - "version": "1.5.0", + "version": "1.6.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "7692075f486c14d8cfd37fba98a08a5667f089e5" + "reference": "ee17d937652eca06c2341b6fadc0f74c1c1a5af2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/7692075f486c14d8cfd37fba98a08a5667f089e5", - "reference": "7692075f486c14d8cfd37fba98a08a5667f089e5", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/ee17d937652eca06c2341b6fadc0f74c1c1a5af2", + "reference": "ee17d937652eca06c2341b6fadc0f74c1c1a5af2", "shasum": "" }, "require": { - "open-telemetry/context": "^1.0", + "open-telemetry/context": "^1.4", "php": "^8.1", "psr/log": "^1.1|^2.0|^3.0", "symfony/polyfill-php82": "^1.26" @@ -402,20 +399,20 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-08-07T23:07:38+00:00" + "time": "2025-09-19T00:05:49+00:00" }, { "name": "open-telemetry/context", - "version": "1.3.1", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/context.git", - "reference": "438f71812242db3f196fb4c717c6f92cbc819be6" + "reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/438f71812242db3f196fb4c717c6f92cbc819be6", - "reference": "438f71812242db3f196fb4c717c6f92cbc819be6", + "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/d4c4470b541ce72000d18c339cfee633e4c8e0cf", + "reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf", "shasum": "" }, "require": { @@ -461,7 +458,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-08-13T01:12:00+00:00" + "time": "2025-09-19T00:05:49+00:00" }, { "name": "open-telemetry/exporter-otlp", @@ -529,16 +526,16 @@ }, { "name": "open-telemetry/gen-otlp-protobuf", - "version": "1.5.0", + "version": "1.8.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git", - "reference": "585bafddd4ae6565de154610b10a787a455c9ba0" + "reference": "673af5b06545b513466081884b47ef15a536edde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/585bafddd4ae6565de154610b10a787a455c9ba0", - "reference": "585bafddd4ae6565de154610b10a787a455c9ba0", + "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/673af5b06545b513466081884b47ef15a536edde", + "reference": "673af5b06545b513466081884b47ef15a536edde", "shasum": "" }, "require": { @@ -588,27 +585,27 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-01-15T23:07:07+00:00" + "time": "2025-09-17T23:10:12+00:00" }, { "name": "open-telemetry/sdk", - "version": "1.7.1", + "version": "1.8.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "52690d4b37ae4f091af773eef3c238ed2bc0aa06" + "reference": "105c6e81e3d86150bd5704b00c7e4e165e957b89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/52690d4b37ae4f091af773eef3c238ed2bc0aa06", - "reference": "52690d4b37ae4f091af773eef3c238ed2bc0aa06", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/105c6e81e3d86150bd5704b00c7e4e165e957b89", + "reference": "105c6e81e3d86150bd5704b00c7e4e165e957b89", "shasum": "" }, "require": { "ext-json": "*", "nyholm/psr7-server": "^1.1", - "open-telemetry/api": "^1.4", - "open-telemetry/context": "^1.0", + "open-telemetry/api": "^1.6", + "open-telemetry/context": "^1.4", "open-telemetry/sem-conv": "^1.0", "php": "^8.1", "php-http/discovery": "^1.14", @@ -685,7 +682,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-09-05T07:17:06+00:00" + "time": "2025-09-19T00:05:49+00:00" }, { "name": "open-telemetry/sem-conv", @@ -2188,16 +2185,16 @@ }, { "name": "laravel/pint", - "version": "v1.24.0", + "version": "v1.25.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a" + "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/0345f3b05f136801af8c339f9d16ef29e6b4df8a", - "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a", + "url": "https://api.github.com/repos/laravel/pint/zipball/5016e263f95d97670d71b9a987bd8996ade6d8d9", + "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9", "shasum": "" }, "require": { @@ -2208,9 +2205,9 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.82.2", - "illuminate/view": "^11.45.1", - "larastan/larastan": "^3.5.0", + "friendsofphp/php-cs-fixer": "^3.87.2", + "illuminate/view": "^11.46.0", + "larastan/larastan": "^3.7.1", "laravel-zero/framework": "^11.45.0", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3.1", @@ -2221,9 +2218,6 @@ ], "type": "project", "autoload": { - "files": [ - "overrides/Runner/Parallel/ProcessFactory.php" - ], "psr-4": { "App\\": "app/", "Database\\Seeders\\": "database/seeders/", @@ -2253,7 +2247,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-07-10T18:09:32+00:00" + "time": "2025-09-19T02:57:12+00:00" }, { "name": "myclabs/deep-copy", @@ -2642,16 +2636,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.28", + "version": "1.12.29", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "fcf8b71aeab4e1a1131d1783cef97b23a51b87a9" + "reference": "0835c625a38ac6484f050077116b6668bc3ab57d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/fcf8b71aeab4e1a1131d1783cef97b23a51b87a9", - "reference": "fcf8b71aeab4e1a1131d1783cef97b23a51b87a9", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0835c625a38ac6484f050077116b6668bc3ab57d", + "reference": "0835c625a38ac6484f050077116b6668bc3ab57d", "shasum": "" }, "require": { @@ -2696,7 +2690,7 @@ "type": "github" } ], - "time": "2025-07-17T17:15:39+00:00" + "time": "2025-09-16T08:46:57+00:00" }, { "name": "phpunit/php-code-coverage", @@ -3019,16 +3013,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.25", + "version": "9.6.27", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "049c011e01be805202d8eebedef49f769a8ec7b7" + "reference": "0a9aa4440b6a9528cf360071502628d717af3e0a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/049c011e01be805202d8eebedef49f769a8ec7b7", - "reference": "049c011e01be805202d8eebedef49f769a8ec7b7", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0a9aa4440b6a9528cf360071502628d717af3e0a", + "reference": "0a9aa4440b6a9528cf360071502628d717af3e0a", "shasum": "" }, "require": { @@ -3102,7 +3096,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.25" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.27" }, "funding": [ { @@ -3126,7 +3120,7 @@ "type": "tidelift" } ], - "time": "2025-08-20T14:38:31+00:00" + "time": "2025-09-14T06:18:03+00:00" }, { "name": "psr/cache", @@ -3618,16 +3612,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.6", + "version": "4.0.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "eb49b981ef0817890129cb70f774506bebe57740" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/eb49b981ef0817890129cb70f774506bebe57740", + "reference": "eb49b981ef0817890129cb70f774506bebe57740", "shasum": "" }, "require": { @@ -3683,15 +3677,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.7" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2025-09-22T05:18:21+00:00" }, { "name": "sebastian/global-state", @@ -5055,12 +5061,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.1" + "php": ">=8.3" }, - "platform-dev": [], - "plugin-api-version": "2.3.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/src/App.php b/src/App.php index df53eb9..bfcce7b 100755 --- a/src/App.php +++ b/src/App.php @@ -693,7 +693,7 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) $value = $existsInValues ? $values[$key] : $arg; - if (!$param['skipValidation']&& !$paramExists && !$param['optional']) { + if (!$param['skipValidation'] && !$paramExists && !$param['optional']) { throw new Exception('Param "' . $key . '" is not optional.', 400); } diff --git a/src/Model.php b/src/Model.php index 58bce7e..0b6da55 100644 --- a/src/Model.php +++ b/src/Model.php @@ -5,4 +5,4 @@ interface Model { public static function fromArray(array $value): static; -} \ No newline at end of file +} diff --git a/tests/ModelTest.php b/tests/ModelTest.php index 6d79693..7f2c2f8 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -13,12 +13,11 @@ // Test Model implementation class UserModel implements Model { - public function __construct( + final public function __construct( public string $name, public int $age, public ?string $email = null - ) - { + ) { } public static function fromArray(array $value): static @@ -26,7 +25,7 @@ public static function fromArray(array $value): static if (!isset($value['name']) || !isset($value['age'])) { throw new \InvalidArgumentException('Missing required fields: name and age'); } - return new self( + return new static( $value['name'], $value['age'], $value['email'] ?? null @@ -37,18 +36,17 @@ public static function fromArray(array $value): static // Another test model with nested data class AddressModel implements Model { - public function __construct( + final public function __construct( public string $street, public string $city, public string $zipCode, public ?string $country = 'USA' - ) - { + ) { } public static function fromArray(array $value): static { - return new self( + return new static( $value['street'] ?? '', $value['city'] ?? '', $value['zipCode'] ?? '', @@ -431,4 +429,4 @@ public function testModelWithEmptyString(): void $this->assertTrue($actionCalled); $this->assertNull($result); } -} \ No newline at end of file +} From 98d710fecf7234fda9739832acf1a78e14df83bc Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 22 Sep 2025 22:00:48 +1200 Subject: [PATCH 3/5] Fix empty check --- src/App.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/App.php b/src/App.php index bfcce7b..ba8f0f0 100755 --- a/src/App.php +++ b/src/App.php @@ -706,9 +706,6 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) if (!\is_a($model, Model::class, true)) { throw new Exception('Model class is not an instance of Utopia\\Model', 500); } - if (($value === null || $value === '') && $param['optional']) { - $value = null; - } if (\is_string($value) && $value !== '') { try { $value = \json_decode($value, true, flags: JSON_THROW_ON_ERROR); @@ -726,6 +723,9 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) throw new Exception('Failed to create model instance for param "' . $key . '": ' . $e->getMessage(), 400); } } + if ($param['optional'] && $value === '') { + $value = null; + } } if (!$param['skipValidation'] && $paramExists && $value !== null && $value !== '') { From 569d8080454fe87045a1c9d284e0c791cf260de7 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 22 Sep 2025 22:06:43 +1200 Subject: [PATCH 4/5] Fix validation check --- src/App.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/App.php b/src/App.php index ba8f0f0..ec5ce92 100755 --- a/src/App.php +++ b/src/App.php @@ -728,7 +728,11 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) } } - if (!$param['skipValidation'] && $paramExists && $value !== null && $value !== '') { + if ( + !$param['skipValidation'] && + !($param['optional'] && $value === null) && + $paramExists + ) { $this->validate($key, $param, $value); } From b79aafd494a3526f8d5c2f357dac0ec59ff7c28c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 8 Dec 2025 20:39:57 +1300 Subject: [PATCH 5/5] Handle arraylist map models --- src/App.php | 22 +++++-- tests/ModelTest.php | 153 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 4 deletions(-) diff --git a/src/App.php b/src/App.php index f66e6d4..aebfe23 100755 --- a/src/App.php +++ b/src/App.php @@ -717,10 +717,24 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) throw new Exception('Model param "' . $key . '" must be a JSON string, or an array', 400); } if (\is_array($value)) { - try { - $value = $model::fromArray($value); - } catch (\Throwable $e) { - throw new Exception('Failed to create model instance for param "' . $key . '": ' . $e->getMessage(), 400); + $validator = $param['validator']; + $isArrayList = $validator instanceof \Utopia\Validator\ArrayList; + + if ($isArrayList) { + try { + $value = \array_map( + fn ($item) => \is_array($item) ? $model::fromArray($item) : $item, + $value + ); + } catch (\Throwable $e) { + throw new Exception('Failed to create model instance for param "' . $key . '": ' . $e->getMessage(), 400); + } + } else { + try { + $value = $model::fromArray($value); + } catch (\Throwable $e) { + throw new Exception('Failed to create model instance for param "' . $key . '": ' . $e->getMessage(), 400); + } } } if ($param['optional'] && $value === '') { diff --git a/tests/ModelTest.php b/tests/ModelTest.php index 7f2c2f8..5fe32fd 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -8,6 +8,7 @@ use Utopia\Request; use Utopia\Response; use Utopia\Validator; +use Utopia\Validator\ArrayList; use Utopia\Validator\Text; // Test Model implementation @@ -429,4 +430,156 @@ public function testModelWithEmptyString(): void $this->assertTrue($actionCalled); $this->assertNull($result); } + + public function testArrayListModelParamWithJsonString(): void + { + $result = null; + + $this->app + ->post('/users-array') + ->param('users', [], new ArrayList(new UserValidator()), 'Array of users', false, [], false, false, '', UserModel::class) + ->action(function (array $users) use (&$result) { + $result = $users; + }); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/users-array'; + $_POST = ['users' => '[{"name":"John Doe","age":30},{"name":"Jane Smith","age":25}]']; + + $this->app->run(new Request(), new Response()); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertInstanceOf(UserModel::class, $result[0]); + $this->assertInstanceOf(UserModel::class, $result[1]); + $this->assertEquals('John Doe', $result[0]->name); + $this->assertEquals(30, $result[0]->age); + $this->assertEquals('Jane Smith', $result[1]->name); + $this->assertEquals(25, $result[1]->age); + } + + public function testArrayListModelParamWithArray(): void + { + $result = null; + + $this->app + ->post('/users-array-native') + ->param('users', [], new ArrayList(new UserValidator()), 'Array of users', false, [], false, false, '', UserModel::class) + ->action(function (array $users) use (&$result) { + $result = $users; + }); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/users-array-native'; + $_POST = ['users' => [ + ['name' => 'Alice', 'age' => 28, 'email' => 'alice@example.com'], + ['name' => 'Bob', 'age' => 35] + ]]; + + $this->app->run(new Request(), new Response()); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertInstanceOf(UserModel::class, $result[0]); + $this->assertInstanceOf(UserModel::class, $result[1]); + $this->assertEquals('Alice', $result[0]->name); + $this->assertEquals('alice@example.com', $result[0]->email); + $this->assertEquals('Bob', $result[1]->name); + $this->assertNull($result[1]->email); + } + + public function testArrayListModelParamEmpty(): void + { + $result = null; + + $this->app + ->post('/users-array-empty') + ->param('users', [], new ArrayList(new UserValidator()), 'Array of users', false, [], false, false, '', UserModel::class) + ->action(function (array $users) use (&$result) { + $result = $users; + }); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/users-array-empty'; + $_POST = ['users' => '[]']; + + $this->app->run(new Request(), new Response()); + + $this->assertIsArray($result); + $this->assertCount(0, $result); + } + + public function testArrayListModelParamWithInvalidElement(): void + { + $errorCaught = false; + $errorMessage = ''; + + $this->app + ->post('/users-array-invalid') + ->param('users', [], new ArrayList(new UserValidator()), 'Array of users', false, [], false, false, '', UserModel::class) + ->action(function (array $users) { + // Should not reach here + }); + + $this->app->error()->inject('error')->action(function (\Throwable $error) use (&$errorCaught, &$errorMessage) { + $errorCaught = true; + $errorMessage = $error->getMessage(); + }); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/users-array-invalid'; + $_POST = ['users' => '[{"name":"John"}]']; // Missing 'age' field + + $this->app->run(new Request(), new Response()); + + $this->assertTrue($errorCaught); + $this->assertStringContainsString('Failed to create model instance', $errorMessage); + } + + public function testArrayListModelParamOptional(): void + { + $result = null; + $actionCalled = false; + + $this->app + ->post('/users-array-optional') + ->param('users', null, new ArrayList(new UserValidator()), 'Array of users', true, [], false, false, '', UserModel::class) + ->action(function (?array $users = null) use (&$result, &$actionCalled) { + $actionCalled = true; + $result = $users; + }); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/users-array-optional'; + $_POST = []; + + $this->app->run(new Request(), new Response()); + + $this->assertTrue($actionCalled); + $this->assertNull($result); + } + + public function testArrayListModelParamWithDefault(): void + { + $result = null; + $defaultUsers = [new UserModel('Default User', 0)]; + + $this->app + ->post('/users-array-default') + ->param('users', $defaultUsers, new ArrayList(new UserValidator()), 'Array of users', true, [], false, false, '', UserModel::class) + ->action(function (array $users) use (&$result) { + $result = $users; + }); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/users-array-default'; + $_POST = []; + + $this->app->run(new Request(), new Response()); + + $this->assertIsArray($result); + $this->assertCount(1, $result); + $this->assertInstanceOf(UserModel::class, $result[0]); + $this->assertEquals('Default User', $result[0]->name); + } }