diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 0656706dab91e..cfdb2b034d814 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -156,6 +156,8 @@ 'OCP\\AppFramework\\Http\\FileDisplayResponse' => $baseDir . '/lib/public/AppFramework/Http/FileDisplayResponse.php', 'OCP\\AppFramework\\Http\\ICallbackResponse' => $baseDir . '/lib/public/AppFramework/Http/ICallbackResponse.php', 'OCP\\AppFramework\\Http\\IOutput' => $baseDir . '/lib/public/AppFramework/Http/IOutput.php', + 'OCP\\AppFramework\\Http\\InvalidEnumParameterException' => $baseDir . '/lib/public/AppFramework/Http/InvalidEnumParameterException.php', + 'OCP\\AppFramework\\Http\\InvalidStringParameterException' => $baseDir . '/lib/public/AppFramework/Http/InvalidStringParameterException.php', 'OCP\\AppFramework\\Http\\JSONResponse' => $baseDir . '/lib/public/AppFramework/Http/JSONResponse.php', 'OCP\\AppFramework\\Http\\NotFoundResponse' => $baseDir . '/lib/public/AppFramework/Http/NotFoundResponse.php', 'OCP\\AppFramework\\Http\\ParameterOutOfRangeException' => $baseDir . '/lib/public/AppFramework/Http/ParameterOutOfRangeException.php', @@ -1207,6 +1209,7 @@ 'OC\\AppFramework\\Http\\RequestId' => $baseDir . '/lib/private/AppFramework/Http/RequestId.php', 'OC\\AppFramework\\Middleware\\AdditionalScriptsMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/AdditionalScriptsMiddleware.php', 'OC\\AppFramework\\Middleware\\CompressionMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/CompressionMiddleware.php', + 'OC\\AppFramework\\Middleware\\InvalidParameterMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/InvalidParameterMiddleware.php', 'OC\\AppFramework\\Middleware\\MiddlewareDispatcher' => $baseDir . '/lib/private/AppFramework/Middleware/MiddlewareDispatcher.php', 'OC\\AppFramework\\Middleware\\NotModifiedMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/NotModifiedMiddleware.php', 'OC\\AppFramework\\Middleware\\OCSMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/OCSMiddleware.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index ef4a9af838583..f62d3e0303a0c 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -197,6 +197,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\AppFramework\\Http\\FileDisplayResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/FileDisplayResponse.php', 'OCP\\AppFramework\\Http\\ICallbackResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/ICallbackResponse.php', 'OCP\\AppFramework\\Http\\IOutput' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/IOutput.php', + 'OCP\\AppFramework\\Http\\InvalidEnumParameterException' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/InvalidEnumParameterException.php', + 'OCP\\AppFramework\\Http\\InvalidStringParameterException' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/InvalidStringParameterException.php', 'OCP\\AppFramework\\Http\\JSONResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/JSONResponse.php', 'OCP\\AppFramework\\Http\\NotFoundResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/NotFoundResponse.php', 'OCP\\AppFramework\\Http\\ParameterOutOfRangeException' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/ParameterOutOfRangeException.php', @@ -1248,6 +1250,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\AppFramework\\Http\\RequestId' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/RequestId.php', 'OC\\AppFramework\\Middleware\\AdditionalScriptsMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/AdditionalScriptsMiddleware.php', 'OC\\AppFramework\\Middleware\\CompressionMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/CompressionMiddleware.php', + 'OC\\AppFramework\\Middleware\\InvalidParameterMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/InvalidParameterMiddleware.php', 'OC\\AppFramework\\Middleware\\MiddlewareDispatcher' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/MiddlewareDispatcher.php', 'OC\\AppFramework\\Middleware\\NotModifiedMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/NotModifiedMiddleware.php', 'OC\\AppFramework\\Middleware\\OCSMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/OCSMiddleware.php', diff --git a/lib/private/AppFramework/DependencyInjection/DIContainer.php b/lib/private/AppFramework/DependencyInjection/DIContainer.php index 28580065dfdde..bef47120af9c2 100644 --- a/lib/private/AppFramework/DependencyInjection/DIContainer.php +++ b/lib/private/AppFramework/DependencyInjection/DIContainer.php @@ -16,6 +16,7 @@ use OC\AppFramework\Http\Output; use OC\AppFramework\Middleware\AdditionalScriptsMiddleware; use OC\AppFramework\Middleware\CompressionMiddleware; +use OC\AppFramework\Middleware\InvalidParameterMiddleware; use OC\AppFramework\Middleware\MiddlewareDispatcher; use OC\AppFramework\Middleware\NotModifiedMiddleware; use OC\AppFramework\Middleware\OCSMiddleware; @@ -201,6 +202,7 @@ public function __construct( $dispatcher->registerMiddleware($c->get(SameSiteCookieMiddleware::class)); $dispatcher->registerMiddleware($c->get(CORSMiddleware::class)); $dispatcher->registerMiddleware($c->get(OCSMiddleware::class)); + $dispatcher->registerMiddleware($c->get(InvalidParameterMiddleware::class)); $securityMiddleware = new SecurityMiddleware( $c->get(IRequest::class), diff --git a/lib/private/AppFramework/Http/Dispatcher.php b/lib/private/AppFramework/Http/Dispatcher.php index 2fa6bd8f1cbea..ecdb348429cd3 100644 --- a/lib/private/AppFramework/Http/Dispatcher.php +++ b/lib/private/AppFramework/Http/Dispatcher.php @@ -16,6 +16,8 @@ use OCP\App\IAppManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\InvalidEnumParameterException; +use OCP\AppFramework\Http\InvalidStringParameterException; use OCP\AppFramework\Http\ParameterOutOfRangeException; use OCP\AppFramework\Http\Response; use OCP\Diagnostics\IEventLogger; @@ -160,6 +162,10 @@ private function executeController(Controller $controller, string $methodName): } elseif ($value !== null && \in_array($type, $types, true)) { settype($value, $type); $this->ensureParameterValueSatisfiesRange($param, $value, $default); + } elseif ($value !== null && $type === 'string' && \is_string($value)) { + $this->ensureParameterValueSatisfiesStringConstraint($param, $value); + } elseif ($value !== null && $type !== null && !($value instanceof $type) && enum_exists($type) && is_a($type, \BackedEnum::class, true)) { + $value = $this->resolveBackedEnumValue($param, $type, $value); } elseif ($value === null && $type !== null && $this->appContainer->has($type)) { $value = $this->appContainer->get($type); } @@ -225,4 +231,40 @@ private function ensureParameterValueSatisfiesRange(string $param, $value, $defa } } } + + /** + * @throws InvalidStringParameterException + */ + private function ensureParameterValueSatisfiesStringConstraint(string $param, string $value): void { + if (!$this->reflector->satisfiesStringConstraint($param, $value)) { + throw new InvalidStringParameterException($param, $this->reflector->getStringConstraint($param)); + } + } + + /** + * @template T of \BackedEnum + * @psalm-param class-string $enumClass + * @psalm-param mixed $value + * @psalm-return T + * @throws InvalidEnumParameterException + */ + private function resolveBackedEnumValue(string $param, string $enumClass, $value): \BackedEnum { + if (!is_scalar($value)) { + throw new InvalidEnumParameterException($param, get_debug_type($value), $enumClass); + } + + $backingType = (new \ReflectionEnum($enumClass))->getBackingType(); + assert($backingType instanceof \ReflectionNamedType); + $backingType = $backingType->getName(); + if ($backingType === 'int') { + if (!is_numeric($value)) { + throw new InvalidEnumParameterException($param, (string)$value, $enumClass); + } + $value = (int)$value; + } else { + $value = (string)$value; + } + + return $enumClass::tryFrom($value) ?? throw new InvalidEnumParameterException($param, (string)$value, $enumClass); + } } diff --git a/lib/private/AppFramework/Middleware/InvalidParameterMiddleware.php b/lib/private/AppFramework/Middleware/InvalidParameterMiddleware.php new file mode 100644 index 0000000000000..e0aa306a569d8 --- /dev/null +++ b/lib/private/AppFramework/Middleware/InvalidParameterMiddleware.php @@ -0,0 +1,39 @@ + $exception->getMessage()], Http::STATUS_BAD_REQUEST); + } + + throw $exception; + } +} diff --git a/lib/private/AppFramework/Utility/ControllerMethodReflector.php b/lib/private/AppFramework/Utility/ControllerMethodReflector.php index e09756a957bda..3c2a7d01f618e 100644 --- a/lib/private/AppFramework/Utility/ControllerMethodReflector.php +++ b/lib/private/AppFramework/Utility/ControllerMethodReflector.php @@ -20,6 +20,7 @@ class ControllerMethodReflector implements IControllerMethodReflector { private array $types = []; private array $parameters = []; private array $ranges = []; + private array $stringConstraints = []; private int $startLine = 0; private string $file = ''; private ?\ReflectionMethod $reflectionMethod = null; @@ -38,6 +39,7 @@ public function reflect($object, string $method) { $this->types = []; $this->parameters = []; $this->ranges = []; + $this->stringConstraints = []; $this->reflectionMethod = new \ReflectionMethod($object, $method); $this->startLine = $this->reflectionMethod->getStartLine(); $this->file = $this->reflectionMethod->getFileName(); @@ -79,6 +81,23 @@ public function reflect($object, string $method) { 'max' => $matches['rangeMax'][$index] === 'max' ? PHP_INT_MAX : (int)$matches['rangeMax'][$index], ]; } + + // extract psalm int aliases that imply a fixed range + preg_match_all('/@(?:psalm-)?param\h+(\?)?(?Ppositive-int|non-negative-int|negative-int|non-positive-int)(\|null)?\h+\$(?P\w+)/', $docs, $matches); + foreach ($matches['var'] as $index => $varName) { + $this->ranges[$varName] = match ($matches['type'][$index]) { + 'positive-int' => ['min' => 1, 'max' => PHP_INT_MAX], + 'non-negative-int' => ['min' => 0, 'max' => PHP_INT_MAX], + 'negative-int' => ['min' => PHP_INT_MIN, 'max' => -1], + 'non-positive-int' => ['min' => PHP_INT_MIN, 'max' => 0], + }; + } + + // extract psalm scalar string types + preg_match_all('/@(?:psalm-)?param\h+(\?)?(?Pnon-empty-lowercase-string|non-falsy-string|non-empty-string|lowercase-string|numeric-string)(\|null)?\h+\$(?P\w+)/', $docs, $matches); + foreach ($matches['var'] as $index => $varName) { + $this->stringConstraints[$varName] = $matches['type'][$index]; + } } foreach ($this->reflectionMethod->getParameters() as $param) { @@ -120,6 +139,25 @@ public function getRange(string $parameter): ?array { return null; } + public function getStringConstraint(string $parameter): ?string { + return $this->stringConstraints[$parameter] ?? null; + } + + /** + * Whether $value satisfies the psalm string type annotated for $parameter, + * or true if none was annotated + */ + public function satisfiesStringConstraint(string $parameter, string $value): bool { + return match ($this->getStringConstraint($parameter)) { + 'non-empty-string' => $value !== '', + 'non-empty-lowercase-string' => $value !== '' && $value === strtolower($value), + 'lowercase-string' => $value === strtolower($value), + 'non-falsy-string' => $value !== '' && $value !== '0', + 'numeric-string' => is_numeric($value), + default => true, + }; + } + /** * @return array the arguments of the method with key => default value */ diff --git a/lib/public/AppFramework/Http/InvalidEnumParameterException.php b/lib/public/AppFramework/Http/InvalidEnumParameterException.php new file mode 100644 index 0000000000000..92d00aab31966 --- /dev/null +++ b/lib/public/AppFramework/Http/InvalidEnumParameterException.php @@ -0,0 +1,49 @@ +parameterName, $this->value, $this->enumClass) + ); + } + + /** + * @since 35.0.0 + */ + public function getParameterName(): string { + return $this->parameterName; + } + + /** + * @since 35.0.0 + */ + public function getValue(): string { + return $this->value; + } + + /** + * @since 35.0.0 + */ + public function getEnumClass(): string { + return $this->enumClass; + } +} diff --git a/lib/public/AppFramework/Http/InvalidStringParameterException.php b/lib/public/AppFramework/Http/InvalidStringParameterException.php new file mode 100644 index 0000000000000..16d92eec469e1 --- /dev/null +++ b/lib/public/AppFramework/Http/InvalidStringParameterException.php @@ -0,0 +1,41 @@ +parameterName, $this->constraint) + ); + } + + /** + * @since 35.0.0 + */ + public function getParameterName(): string { + return $this->parameterName; + } + + /** + * @since 35.0.0 + */ + public function getConstraint(): string { + return $this->constraint; + } +} diff --git a/tests/lib/AppFramework/Http/DispatcherTest.php b/tests/lib/AppFramework/Http/DispatcherTest.php index 3e90108c60e2a..4d2e4778f3924 100644 --- a/tests/lib/AppFramework/Http/DispatcherTest.php +++ b/tests/lib/AppFramework/Http/DispatcherTest.php @@ -16,6 +16,8 @@ use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\InvalidEnumParameterException; +use OCP\AppFramework\Http\InvalidStringParameterException; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\ParameterOutOfRangeException; use OCP\AppFramework\Http\Response; @@ -29,6 +31,16 @@ use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; +enum TestStringBackedEnum: string { + case Foo = 'foo'; + case Bar = 'bar'; +} + +enum TestIntBackedEnum: int { + case One = 1; + case Two = 2; +} + class TestController extends Controller { /** * @param string $appName @@ -69,6 +81,18 @@ public function execDataResponse($int, $bool, $test = 4, $test2 = 1) { public function test(): Response { return new DataResponse(); } + + public function execStringBackedEnum(TestStringBackedEnum $enum) { + return [$enum]; + } + + public function execIntBackedEnum(TestIntBackedEnum $enum) { + return [$enum]; + } + + public function execNullableBackedEnum(?TestStringBackedEnum $enum = null) { + return [$enum]; + } } /** @@ -315,6 +339,88 @@ public function testControllerParametersInjected(): void { $this->assertEquals('[3,false,4,1]', $response[3]); } + public function testControllerParametersInjectedStringBackedEnum(): void { + $this->request = new Request( + [ + 'post' => [ + 'enum' => 'foo', + ], + 'method' => 'POST', + ], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $this->dispatcher = new Dispatcher( + $this->http, $this->middlewareDispatcher, $this->reflector, + $this->request, + $this->config, + Server::get(IDBConnection::class), + $this->logger, + $this->eventLogger, + $this->container + ); + $controller = new TestController('app', $this->request); + + $this->dispatcherPassthrough(); + $response = $this->dispatcher->dispatch($controller, 'execStringBackedEnum'); + + $this->assertEquals('["foo"]', $response[3]); + } + + public function testControllerParametersInjectedIntBackedEnum(): void { + $this->request = new Request( + [ + 'post' => [ + 'enum' => '2', + ], + 'method' => 'POST', + ], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $this->dispatcher = new Dispatcher( + $this->http, $this->middlewareDispatcher, $this->reflector, + $this->request, + $this->config, + Server::get(IDBConnection::class), + $this->logger, + $this->eventLogger, + $this->container + ); + $controller = new TestController('app', $this->request); + + $this->dispatcherPassthrough(); + $response = $this->dispatcher->dispatch($controller, 'execIntBackedEnum'); + + $this->assertEquals('[2]', $response[3]); + } + + public function testControllerParametersInjectedNullableBackedEnumDefault(): void { + $this->request = new Request( + [ + 'post' => [], + 'method' => 'POST', + ], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $this->dispatcher = new Dispatcher( + $this->http, $this->middlewareDispatcher, $this->reflector, + $this->request, + $this->config, + Server::get(IDBConnection::class), + $this->logger, + $this->eventLogger, + $this->container + ); + $controller = new TestController('app', $this->request); + + $this->dispatcherPassthrough(); + $response = $this->dispatcher->dispatch($controller, 'execNullableBackedEnum'); + + $this->assertEquals('[null]', $response[3]); + } + public function testControllerParametersInjectedDefaultOverwritten(): void { $this->request = new Request( [ @@ -586,4 +692,103 @@ public function testEnsureParameterValueSatisfiesRange(?int $min, ?int $max, int $this->assertTrue(true); } } + + public static function stringConstraintDataProvider(): array { + return [ + [true, null, false], + [true, 'non-empty-string', false], + [false, 'non-empty-string', true], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('stringConstraintDataProvider')] + public function testEnsureParameterValueSatisfiesStringConstraint(bool $satisfies, ?string $constraint, bool $throw): void { + $this->reflector = $this->createMock(ControllerMethodReflector::class); + $this->reflector->expects($this->any()) + ->method('satisfiesStringConstraint') + ->willReturn($satisfies); + $this->reflector->expects($this->any()) + ->method('getStringConstraint') + ->willReturn($constraint); + + $this->dispatcher = new Dispatcher( + $this->http, + $this->middlewareDispatcher, + $this->reflector, + $this->request, + $this->config, + Server::get(IDBConnection::class), + $this->logger, + $this->eventLogger, + $this->container, + ); + + if ($throw) { + $this->expectException(InvalidStringParameterException::class); + } + + self::invokePrivate($this->dispatcher, 'ensureParameterValueSatisfiesStringConstraint', ['myArgument', '']); + if (!$throw) { + // do not mark this test risky + $this->assertTrue(true); + } + } + + public static function backedEnumDataProvider(): array { + return [ + [TestStringBackedEnum::class, 'foo', TestStringBackedEnum::Foo], + [TestStringBackedEnum::class, 'bar', TestStringBackedEnum::Bar], + [TestIntBackedEnum::class, '1', TestIntBackedEnum::One], + [TestIntBackedEnum::class, 1, TestIntBackedEnum::One], + [TestIntBackedEnum::class, 2, TestIntBackedEnum::Two], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('backedEnumDataProvider')] + public function testResolveBackedEnumValue(string $enumClass, string|int $input, \BackedEnum $expected): void { + $this->reflector = $this->createMock(ControllerMethodReflector::class); + $this->dispatcher = new Dispatcher( + $this->http, + $this->middlewareDispatcher, + $this->reflector, + $this->request, + $this->config, + Server::get(IDBConnection::class), + $this->logger, + $this->eventLogger, + $this->container, + ); + + $result = self::invokePrivate($this->dispatcher, 'resolveBackedEnumValue', ['myArgument', $enumClass, $input]); + $this->assertSame($expected, $result); + } + + public static function invalidBackedEnumDataProvider(): array { + return [ + [TestStringBackedEnum::class, 'invalid'], + [TestIntBackedEnum::class, 'not-a-number'], + [TestIntBackedEnum::class, 99], + [TestStringBackedEnum::class, ['array']], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('invalidBackedEnumDataProvider')] + public function testResolveBackedEnumValueThrowsOnInvalidValue(string $enumClass, mixed $input): void { + $this->reflector = $this->createMock(ControllerMethodReflector::class); + $this->dispatcher = new Dispatcher( + $this->http, + $this->middlewareDispatcher, + $this->reflector, + $this->request, + $this->config, + Server::get(IDBConnection::class), + $this->logger, + $this->eventLogger, + $this->container, + ); + + $this->expectException(InvalidEnumParameterException::class); + + self::invokePrivate($this->dispatcher, 'resolveBackedEnumValue', ['myArgument', $enumClass, $input]); + } } diff --git a/tests/lib/AppFramework/Utility/ControllerMethodReflectorTest.php b/tests/lib/AppFramework/Utility/ControllerMethodReflectorTest.php index 1fd890398073e..618f3feef9c3c 100644 --- a/tests/lib/AppFramework/Utility/ControllerMethodReflectorTest.php +++ b/tests/lib/AppFramework/Utility/ControllerMethodReflectorTest.php @@ -63,6 +63,41 @@ public function test4(int $rangedOne, int $rangedTwo, ?int $rangedThree, ?int $r */ public function test5(int $rangedOne, int $rangedTwo, ?int $rangedThree, ?int $rangedFour) { } + + /** + * @psalm-param positive-int $positive + * @psalm-param non-negative-int $nonNegative + * @psalm-param negative-int $negative + * @psalm-param non-positive-int $nonPositive + * @psalm-param positive-int|null $positiveOrNull + * @psalm-param ?non-negative-int $nonNegativeOrNull + * @return void + */ + public function test6(int $positive, int $nonNegative, int $negative, int $nonPositive, ?int $positiveOrNull, ?int $nonNegativeOrNull) { + } + + /** + * @psalm-param non-empty-string $nonEmpty + * @psalm-param non-empty-lowercase-string $nonEmptyLowercase + * @psalm-param lowercase-string $lowercase + * @psalm-param non-falsy-string $nonFalsy + * @psalm-param numeric-string $numeric + * @psalm-param non-empty-string|null $nonEmptyOrNull + * @psalm-param ?non-empty-string $nonEmptyOrNullPrefix + * @psalm-param string $plain + * @return void + */ + public function test7( + string $nonEmpty, + string $nonEmptyLowercase, + string $lowercase, + string $nonFalsy, + string $numeric, + ?string $nonEmptyOrNull, + ?string $nonEmptyOrNullPrefix, + string $plain, + ) { + } } class EndController extends MiddleController { @@ -275,4 +310,86 @@ public function testRangeDetectionNative(): void { $this->assertSame(-70, $rangeInfo3['min']); $this->assertSame(-30, $rangeInfo3['max']); } + + public function testRangeDetectionIntAliases(): void { + $reader = new ControllerMethodReflector(Server::get(LoggerInterface::class)); + $reader->reflect('Test\AppFramework\Utility\EndController', 'test6'); + + $positive = $reader->getRange('positive'); + $this->assertSame(1, $positive['min']); + $this->assertSame(PHP_INT_MAX, $positive['max']); + + $nonNegative = $reader->getRange('nonNegative'); + $this->assertSame(0, $nonNegative['min']); + $this->assertSame(PHP_INT_MAX, $nonNegative['max']); + + $negative = $reader->getRange('negative'); + $this->assertSame(PHP_INT_MIN, $negative['min']); + $this->assertSame(-1, $negative['max']); + + $nonPositive = $reader->getRange('nonPositive'); + $this->assertSame(PHP_INT_MIN, $nonPositive['min']); + $this->assertSame(0, $nonPositive['max']); + + $positiveOrNull = $reader->getRange('positiveOrNull'); + $this->assertSame(1, $positiveOrNull['min']); + $this->assertSame(PHP_INT_MAX, $positiveOrNull['max']); + + $nonNegativeOrNull = $reader->getRange('nonNegativeOrNull'); + $this->assertSame(0, $nonNegativeOrNull['min']); + $this->assertSame(PHP_INT_MAX, $nonNegativeOrNull['max']); + } + + public function testStringConstraintDetection(): void { + $reader = new ControllerMethodReflector(Server::get(LoggerInterface::class)); + $reader->reflect('Test\AppFramework\Utility\EndController', 'test7'); + + $this->assertSame('non-empty-string', $reader->getStringConstraint('nonEmpty')); + $this->assertSame('non-empty-lowercase-string', $reader->getStringConstraint('nonEmptyLowercase')); + $this->assertSame('lowercase-string', $reader->getStringConstraint('lowercase')); + $this->assertSame('non-falsy-string', $reader->getStringConstraint('nonFalsy')); + $this->assertSame('numeric-string', $reader->getStringConstraint('numeric')); + $this->assertSame('non-empty-string', $reader->getStringConstraint('nonEmptyOrNull')); + $this->assertSame('non-empty-string', $reader->getStringConstraint('nonEmptyOrNullPrefix')); + $this->assertNull($reader->getStringConstraint('plain')); + } + + public static function stringConstraintDataProvider(): array { + return [ + ['non-empty-string', '', false], + ['non-empty-string', 'a', true], + ['non-empty-lowercase-string', '', false], + ['non-empty-lowercase-string', 'ABC', false], + ['non-empty-lowercase-string', 'abc', true], + ['lowercase-string', '', true], + ['lowercase-string', 'ABC', false], + ['lowercase-string', 'abc', true], + ['non-falsy-string', '', false], + ['non-falsy-string', '0', false], + ['non-falsy-string', '0.0', true], + ['non-falsy-string', 'a', true], + ['numeric-string', 'abc', false], + ['numeric-string', '42', true], + ['numeric-string', '4.2', true], + [null, '', true], + [null, 'anything', true], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('stringConstraintDataProvider')] + public function testSatisfiesStringConstraint(?string $constraint, string $value, bool $expected): void { + $reader = new ControllerMethodReflector(Server::get(LoggerInterface::class)); + $reader->reflect('Test\AppFramework\Utility\EndController', 'test7'); + + $parameter = match ($constraint) { + 'non-empty-string' => 'nonEmpty', + 'non-empty-lowercase-string' => 'nonEmptyLowercase', + 'lowercase-string' => 'lowercase', + 'non-falsy-string' => 'nonFalsy', + 'numeric-string' => 'numeric', + default => 'plain', + }; + + $this->assertSame($expected, $reader->satisfiesStringConstraint($parameter, $value)); + } }