diff --git a/lib/private/AppFramework/Http/Dispatcher.php b/lib/private/AppFramework/Http/Dispatcher.php index a6b44d9fb96..be80db28e8e 100644 --- a/lib/private/AppFramework/Http/Dispatcher.php +++ b/lib/private/AppFramework/Http/Dispatcher.php @@ -16,6 +16,7 @@ use OC\DB\ConnectionAdapter; 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; @@ -163,6 +164,8 @@ class Dispatcher { $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); } @@ -237,4 +240,29 @@ class Dispatcher { 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()->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/public/AppFramework/Http/InvalidEnumParameterException.php b/lib/public/AppFramework/Http/InvalidEnumParameterException.php new file mode 100644 index 00000000000..92d00aab319 --- /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/tests/lib/AppFramework/Http/DispatcherTest.php b/tests/lib/AppFramework/Http/DispatcherTest.php index af261131543..4d2e4778f39 100644 --- a/tests/lib/AppFramework/Http/DispatcherTest.php +++ b/tests/lib/AppFramework/Http/DispatcherTest.php @@ -16,6 +16,7 @@ use OC\AppFramework\Utility\ControllerMethodReflector; 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; @@ -30,6 +31,16 @@ use PHPUnit\Framework\MockObject\MockObject; 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 @@ -70,6 +81,18 @@ class TestController extends Controller { 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]; + } } /** @@ -316,6 +339,88 @@ class DispatcherTest extends \Test\TestCase { $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( [ @@ -628,4 +733,62 @@ class DispatcherTest extends \Test\TestCase { $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]); + } }