array union in controllers

! fix yii\web\Controller::bindActionParams behaviour for union types
o some refactoring in the process
This commit is contained in:
Chris Reichel
2025-04-10 10:34:06 +02:00
parent 2fad37b597
commit a67bfc1274
3 changed files with 178 additions and 34 deletions

View File

@ -133,41 +133,15 @@ class Controller extends \yii\base\Controller
$name = $param->getName();
if (array_key_exists($name, $params)) {
$isValid = true;
$isArray = ($type = $param->getType()) instanceof \ReflectionNamedType && $type->getName() === 'array';
if ($isArray) {
$params[$name] = (array)$params[$name];
} elseif (is_array($params[$name])) {
$isValid = false;
} elseif (
PHP_VERSION_ID >= 70000
&& ($type = $param->getType()) !== null
&& method_exists($type, 'isBuiltin')
&& $type->isBuiltin()
&& ($params[$name] !== null || !$type->allowsNull())
) {
$typeName = PHP_VERSION_ID >= 70100 ? $type->getName() : (string)$type;
if ($params[$name] === '' && $type->allowsNull()) {
if ($typeName !== 'string') { // for old string behavior compatibility
$params[$name] = null;
}
} else {
switch ($typeName) {
case 'int':
$params[$name] = filter_var($params[$name], FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE);
break;
case 'float':
$params[$name] = filter_var($params[$name], FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE);
break;
case 'bool':
$params[$name] = filter_var($params[$name], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
break;
}
if ($params[$name] === null) {
$isValid = false;
}
}
$type = $param->getType();
if ($type instanceof \ReflectionNamedType) {
[$result, $isValid] = $this->filterSingleTypeActionParam($params[$name], $type);
$params[$name] = $result;
} elseif (class_exists('\ReflectionUnionType') && $type instanceof \ReflectionUnionType) {
[$result, $isValid] = $this->filterUnionTypeActionParam($params[$name], $type);
$params[$name] = $result;
}
if (!$isValid) {
throw new BadRequestHttpException(
Yii::t('yii', 'Invalid data received for parameter "{param}".', ['param' => $name])
@ -211,6 +185,123 @@ class Controller extends \yii\base\Controller
return $args;
}
/**
* The logic for [[bindActionParam]] to validate whether a given parameter matches the action's typing
* if the function parameter has a single named type.
* @param mixed $param The parameter value.
* @param \ReflectionNamedType $type
* @return array{0: mixed, 1: bool} The resulting parameter value and a boolean indicating whether the value is valid.
*/
private function filterSingleTypeActionParam($param, $type)
{
$isArray = $type->getName() === 'array';
if ($isArray) {
return [(array)$param, true];
}
if (is_array($param)) {
return [$param, false];
}
if (PHP_VERSION_ID >= 70000
&& method_exists($type, 'isBuiltin')
&& $type->isBuiltin()
&& ($param !== null || !$type->allowsNull())) {
$typeName = PHP_VERSION_ID >= 70100 ? $type->getName() : (string)$type;
if ($param === '' && $type->allowsNull()) {
if ($typeName !== 'string') { // for old string behavior compatibility
return [null, true];
}
return ['', true];
}
$filterResult = $this->filterParamByType($param, $typeName);
return [$filterResult, $filterResult !== null];
}
return [$param, true];
}
/**
* The logic for [[bindActionParam]] to validate whether a given parameter matches the action's typing
* if the function parameter has a union type.
* @param mixed $param The parameter value.
* @param \ReflectionUnionType $type
* @return array{0: mixed, 1: bool} The resulting parameter value and a boolean indicating whether the value is valid.
*/
private function filterUnionTypeActionParam($param, $type)
{
$types = $type->getTypes();
if ($param === '' && $type->allowsNull()) {
// check if type can be string for old string behavior compatibility
foreach ($types as $partialType) {
if ($partialType === null
|| !method_exists($partialType, 'isBuiltin')
|| !$partialType->isBuiltin()
) {
continue;
}
$typeName = PHP_VERSION_ID >= 70100 ? $partialType->getName() : (string)$partialType;
if ($typeName === 'string') {
return ['', true];
}
}
return [null, true];
}
// if we found a built-in type but didn't return out, its validation failed
$foundBuiltinType = false;
// we save returning out an array or string for later because other types should take precedence
$canBeArray = false;
$canBeString = false;
foreach ($types as $partialType) {
if ($partialType === null
|| !method_exists($partialType, 'isBuiltin')
|| !$partialType->isBuiltin()
) {
continue;
}
$foundBuiltinType = true;
$typeName = PHP_VERSION_ID >= 70100 ? $partialType->getName() : (string)$partialType;
$canBeArray |= $typeName === 'array';
$canBeString |= $typeName === 'string';
if (is_array($param)) {
if ($canBeArray) {
break;
}
continue;
}
$filterResult = $this->filterParamByType($param, $typeName);
if ($filterResult !== null) {
return [$filterResult, true];
}
}
if (!is_array($param) && $canBeString) {
return [$param, true];
}
if ($canBeArray) {
return [(array)$param, true];
}
return [$param, $canBeString || !$foundBuiltinType];
}
/**
* Run the according filter_var logic for teh given type.
* @param string $param The value to filter.
* @param string $typeName The type name.
* @return mixed|null The resulting value, or null if validation failed or the type can't be validated.
*/
private function filterParamByType(string $param, string $typeName) {
switch ($typeName) {
case 'int':
return filter_var($param, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE);
case 'float':
return filter_var($param, FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE);
case 'bool':
return filter_var($param, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
}
return null;
}
/**
* {@inheritdoc}
*/

View File

@ -10,6 +10,7 @@ namespace yiiunit\framework\web;
use RuntimeException;
use Yii;
use yii\base\InlineAction;
use yii\web\BadRequestHttpException;
use yii\web\NotFoundHttpException;
use yii\web\Response;
use yii\web\ServerErrorHttpException;
@ -329,5 +330,52 @@ class ControllerTest extends TestCase
$args = $this->controller->bindActionParams($injectionAction, $params);
$this->assertSame('test', $args[0]);
$this->assertSame(1, $args[1]);
// test that a value PHP parsed to a string but that should be an int becomes one
$params = ['arg' => 'test', 'second' => '1'];
$args = $this->controller->bindActionParams($injectionAction, $params);
$this->assertSame('test', $args[0]);
$this->assertSame(1, $args[1]);
}
public function testUnionBindingActionParamsWithArray()
{
if (PHP_VERSION_ID < 80000) {
$this->markTestSkipped('Can not be tested on PHP < 8.0');
return;
}
// Use the PHP80 controller for this test
$this->controller = new FakePhp80Controller('fake', new \yii\web\Application([
'id' => 'app',
'basePath' => __DIR__,
'components' => [
'request' => [
'cookieValidationKey' => 'wefJDF8sfdsfSDefwqdxj9oq',
'scriptFile' => __DIR__ . '/index.php',
'scriptUrl' => '/index.php',
],
],
]));
$this->mockWebApplication(['controller' => $this->controller]);
$injectionAction = new InlineAction('array-or-int', $this->controller, 'actionArrayOrInt');
$params = ['foo' => 1];
try {
$args = $this->controller->bindActionParams($injectionAction, $params);
$this->assertSame(1, $args[0]);
} catch (BadRequestHttpException $e) {
$this->fail('Failed to bind int param for array|int union type!');
}
try {
$paramsArray = ['foo' => [1, 2, 3, 4]];
$args = $this->controller->bindActionParams($injectionAction, $paramsArray);
$this->assertSame([1, 2, 3, 4], $args[0]);
} catch (BadRequestHttpException $e) {
$this->fail('Failed to bind array param for array|int union type!');
}
}
}

View File

@ -17,4 +17,9 @@ class FakePhp80Controller extends Controller
{
}
public function actionArrayOrInt(array|int $foo)
{
}
}