diff --git a/composer.json b/composer.json index f4c080f..1e95f27 100644 --- a/composer.json +++ b/composer.json @@ -22,16 +22,16 @@ "bench": "vendor/bin/phpbench run --report=benchmark" }, "require": { - "php": ">=8.1", + "php": ">=8.3", "utopia-php/compression": "0.1.*", "utopia-php/telemetry": "0.1.*", "utopia-php/validators": "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/composer.lock b/composer.lock index 737cede..aad6434 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e07ec1b1f2bc1ee69c29e3d9e8b00ba4", + "content-hash": "d1b510aa3074ac8f58a17767cccd43eb", "packages": [ { "name": "brick/math", - "version": "0.14.0", + "version": "0.14.1", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2" + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", - "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", + "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", "shasum": "" }, "require": { @@ -56,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.0" + "source": "https://github.com/brick/math/tree/0.14.1" }, "funding": [ { @@ -64,7 +64,7 @@ "type": "github" } ], - "time": "2025-08-29T12:40:03+00:00" + "time": "2025-11-24T14:40:29+00:00" }, { "name": "composer/semver", @@ -333,16 +333,16 @@ }, { "name": "open-telemetry/api", - "version": "1.7.0", + "version": "1.7.1", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "610b79ad9d6d97e8368bcb6c4d42394fbb87b522" + "reference": "45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/610b79ad9d6d97e8368bcb6c4d42394fbb87b522", - "reference": "610b79ad9d6d97e8368bcb6c4d42394fbb87b522", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4", + "reference": "45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4", "shasum": "" }, "require": { @@ -362,7 +362,7 @@ ] }, "branch-alias": { - "dev-main": "1.7.x-dev" + "dev-main": "1.8.x-dev" } }, "autoload": { @@ -395,11 +395,11 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-10-02T23:44:28+00:00" + "time": "2025-10-19T10:49:48+00:00" }, { "name": "open-telemetry/context", @@ -462,16 +462,16 @@ }, { "name": "open-telemetry/exporter-otlp", - "version": "1.3.2", + "version": "1.3.3", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/exporter-otlp.git", - "reference": "196f3a1dbce3b2c0f8110d164232c11ac00ddbb2" + "reference": "07b02bc71838463f6edcc78d3485c04b48fb263d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/196f3a1dbce3b2c0f8110d164232c11ac00ddbb2", - "reference": "196f3a1dbce3b2c0f8110d164232c11ac00ddbb2", + "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/07b02bc71838463f6edcc78d3485c04b48fb263d", + "reference": "07b02bc71838463f6edcc78d3485c04b48fb263d", "shasum": "" }, "require": { @@ -518,11 +518,11 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-06-16T00:24:51+00:00" + "time": "2025-11-13T08:04:37+00:00" }, { "name": "open-telemetry/gen-otlp-protobuf", @@ -589,16 +589,16 @@ }, { "name": "open-telemetry/sdk", - "version": "1.9.0", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e" + "reference": "3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e", - "reference": "8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99", + "reference": "3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99", "shasum": "" }, "require": { @@ -678,11 +678,11 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-10-02T23:44:28+00:00" + "time": "2025-11-25T10:59:15+00:00" }, { "name": "open-telemetry/sem-conv", @@ -2235,16 +2235,16 @@ }, { "name": "laravel/pint", - "version": "v1.25.1", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9" + "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/5016e263f95d97670d71b9a987bd8996ade6d8d9", - "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9", + "url": "https://api.github.com/repos/laravel/pint/zipball/69dcca060ecb15e4b564af63d1f642c81a241d6f", + "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f", "shasum": "" }, "require": { @@ -2255,13 +2255,13 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.87.2", - "illuminate/view": "^11.46.0", - "larastan/larastan": "^3.7.1", - "laravel-zero/framework": "^11.45.0", + "friendsofphp/php-cs-fixer": "^3.90.0", + "illuminate/view": "^12.40.1", + "larastan/larastan": "^3.8.0", + "laravel-zero/framework": "^12.0.4", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.1", - "pestphp/pest": "^2.36.0" + "nunomaduro/termwind": "^2.3.3", + "pestphp/pest": "^3.8.4" }, "bin": [ "builds/pint" @@ -2287,6 +2287,7 @@ "description": "An opinionated code formatter for PHP.", "homepage": "https://laravel.com", "keywords": [ + "dev", "format", "formatter", "lint", @@ -2297,7 +2298,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-09-19T02:57:12+00:00" + "time": "2025-11-25T21:15:52+00:00" }, { "name": "myclabs/deep-copy", @@ -4293,47 +4294,39 @@ }, { "name": "symfony/console", - "version": "v7.3.6", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a" + "reference": "307d3cf852f5ead3618ac60ecbedbdd512c348b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/c28ad91448f86c5f6d9d2c70f0cf68bf135f252a", - "reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a", + "url": "https://api.github.com/repos/symfony/console/zipball/307d3cf852f5ead3618ac60ecbedbdd512c348b1", + "reference": "307d3cf852f5ead3618ac60ecbedbdd512c348b1", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2" - }, - "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" + "symfony/string": "^7.4|^8.0" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4367,7 +4360,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.6" + "source": "https://github.com/symfony/console/tree/v8.0.0" }, "funding": [ { @@ -4387,29 +4380,29 @@ "type": "tidelift" } ], - "time": "2025-11-04T01:21:42+00:00" + "time": "2025-11-21T13:19:49+00:00" }, { "name": "symfony/filesystem", - "version": "v7.3.6", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "e9bcfd7837928ab656276fe00464092cc9e1826a" + "reference": "7fc96ae83372620eaba3826874f46e26295768ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/e9bcfd7837928ab656276fe00464092cc9e1826a", - "reference": "e9bcfd7837928ab656276fe00464092cc9e1826a", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7fc96ae83372620eaba3826874f46e26295768ca", + "reference": "7fc96ae83372620eaba3826874f46e26295768ca", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^6.4|^7.0" + "symfony/process": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4437,7 +4430,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.3.6" + "source": "https://github.com/symfony/filesystem/tree/v8.0.0" }, "funding": [ { @@ -4457,27 +4450,27 @@ "type": "tidelift" } ], - "time": "2025-11-05T09:52:27+00:00" + "time": "2025-11-05T14:36:47+00:00" }, { "name": "symfony/finder", - "version": "v7.3.5", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "9f696d2f1e340484b4683f7853b273abff94421f" + "reference": "7598dd5770580fa3517ec83e8da0c9b9e01f4291" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/9f696d2f1e340484b4683f7853b273abff94421f", - "reference": "9f696d2f1e340484b4683f7853b273abff94421f", + "url": "https://api.github.com/repos/symfony/finder/zipball/7598dd5770580fa3517ec83e8da0c9b9e01f4291", + "reference": "7598dd5770580fa3517ec83e8da0c9b9e01f4291", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0" + "symfony/filesystem": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4505,7 +4498,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.5" + "source": "https://github.com/symfony/finder/tree/v8.0.0" }, "funding": [ { @@ -4525,24 +4518,24 @@ "type": "tidelift" } ], - "time": "2025-10-15T18:45:57+00:00" + "time": "2025-11-05T14:36:47+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.3.3", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d" + "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/0ff2f5c3df08a395232bbc3c2eb7e84912df911d", - "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7", + "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", @@ -4576,7 +4569,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.3.3" + "source": "https://github.com/symfony/options-resolver/tree/v8.0.0" }, "funding": [ { @@ -4596,7 +4589,7 @@ "type": "tidelift" } ], - "time": "2025-08-05T10:16:07+00:00" + "time": "2025-11-12T15:55:31+00:00" }, { "name": "symfony/polyfill-ctype", @@ -4850,20 +4843,20 @@ }, { "name": "symfony/process", - "version": "v7.3.4", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b" + "reference": "a0a750500c4ce900d69ba4e9faf16f82c10ee149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b", - "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b", + "url": "https://api.github.com/repos/symfony/process/zipball/a0a750500c4ce900d69ba4e9faf16f82c10ee149", + "reference": "a0a750500c4ce900d69ba4e9faf16f82c10ee149", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "type": "library", "autoload": { @@ -4891,7 +4884,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.3.4" + "source": "https://github.com/symfony/process/tree/v8.0.0" }, "funding": [ { @@ -4911,38 +4904,38 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2025-10-16T16:25:44+00:00" }, { "name": "symfony/string", - "version": "v7.3.4", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f96476035142921000338bad71e5247fbc138872" + "reference": "f929eccf09531078c243df72398560e32fa4cf4f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872", - "reference": "f96476035142921000338bad71e5247fbc138872", + "url": "https://api.github.com/repos/symfony/string/zipball/f929eccf09531078c243df72398560e32fa4cf4f", + "reference": "f929eccf09531078c243df72398560e32fa4cf4f", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1", - "symfony/http-client": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4981,7 +4974,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.4" + "source": "https://github.com/symfony/string/tree/v8.0.0" }, "funding": [ { @@ -5001,7 +4994,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T14:36:48+00:00" + "time": "2025-09-11T14:37:55+00:00" }, { "name": "theseer/tokenizer", @@ -5109,8 +5102,8 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.1" + "php": ">=8.3" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/src/App.php b/src/App.php index 9af02c8..aebfe23 100755 --- a/src/App.php +++ b/src/App.php @@ -693,21 +693,68 @@ 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 ($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 (\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)) { + $validator = $param['validator']; + $isArrayList = $validator instanceof \Utopia\Validator\ArrayList; - if ($paramExists) { - $this->validate($key, $param, $value); + 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 === '') { + $value = null; } } + if ( + !$param['skipValidation'] && + !($param['optional'] && $value === null) && + $paramExists + ) { + $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..0b6da55 --- /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); + } + + 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); + } +}