feat: Add support for BackedEnums in controller methods

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Carl Schwan <carl@carlschwan.eu>
pull/63326/head
Carl Schwan 3 days ago
parent feb731a69a
commit 4e8e774918
No known key found for this signature in database
GPG Key ID: 02325448204E452A
  1. 28
      lib/private/AppFramework/Http/Dispatcher.php
  2. 49
      lib/public/AppFramework/Http/InvalidEnumParameterException.php
  3. 163
      tests/lib/AppFramework/Http/DispatcherTest.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<T> $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);
}
}

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\AppFramework\Http;
/**
* @since 35.0.0
*/
class InvalidEnumParameterException extends \InvalidArgumentException {
/**
* @since 35.0.0
*/
public function __construct(
protected string $parameterName,
protected string $value,
protected string $enumClass,
) {
parent::__construct(
sprintf('Parameter %s with value "%s" is not a valid case of %s', $this->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;
}
}

@ -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]);
}
}

Loading…
Cancel
Save