Fix #17722: Add action injection support

This commit is contained in:
Sam
2020-06-12 09:06:18 +02:00
committed by GitHub
parent c365f472cd
commit 4ea484ca68
10 changed files with 391 additions and 9 deletions

View File

@ -375,6 +375,24 @@ cannot be instantiated. This is because you need to tell the DI container how to
Now if you access the controller again, an instance of `app\components\BookingService` will be
created and injected as the 3rd parameter to the controller's constructor.
Since Yii 2.0.36 when using PHP 7 action injection is available for both web and console controllers:
```php
namespace app\controllers;
use yii\web\Controller;
use app\components\BookingInterface;
class HotelController extends Controller
{
public function actionBook($id, BookingInterface $bookingService)
{
$result = $bookingService->book($id);
// ...
}
}
```
Advanced Practical Usage <span id="advanced-practical-usage"></span>
---------------

View File

@ -9,6 +9,7 @@ Yii Framework 2 Change Log
- Bug #18026: Fix `ArrayHelper::getValue()` did not work with `ArrayAccess` objects (mikk150)
- Enh #18048: Use `Instance::ensure()` to set `User::$accessChecker` (lav45)
- Bug #18051: Fix missing support for custom validation method in EachValidator (bizley)
- Enh #17722: Add action injection support (SamMousa, samdark)
- Bug #18041: Fix RBAC migration for MSSQL (darkdef)
- Bug #18081: Fix for PDO_DBLIB/MSSQL. Set flag ANSI_NULL_DFLT_ON to ON for current connect to DB (darkdef)
- Bug #13828: Fix retrieving inserted data for a primary key of type uniqueidentifier for SQL Server 2005 or later (darkdef)

View File

@ -523,4 +523,34 @@ class Controller extends Component implements ViewContextInterface
return $path;
}
/**
* Fills parameters based on types and names in action method signature.
* @param \ReflectionType $type The reflected type of the action parameter.
* @param string $name The name of the parameter.
* @param array &$args The array of arguments for the action, this function may append items to it.
* @param array &$requestedParams The array with requested params, this function may write specific keys to it.
* @throws ErrorException when we cannot load a required service.
* @throws \yii\base\InvalidConfigException Thrown when there is an error in the DI configuration.
* @throws \yii\di\NotInstantiableException Thrown when a definition cannot be resolved to a concrete class
* (for example an interface type hint) without a proper definition in the container.
* @since 2.0.36
*/
final protected function bindInjectedParams(\ReflectionType $type, $name, &$args, &$requestedParams)
{
// Since it is not a builtin type it must be DI injection.
$typeName = $type->getName();
if (($component = $this->module->get($name, false)) instanceof $typeName) {
$args[] = $component;
$requestedParams[$name] = "Component: " . get_class($component) . " \$$name";
} elseif (\Yii::$container->has($typeName) && ($service = \Yii::$container->get($typeName)) instanceof $typeName) {
$args[] = $service;
$requestedParams[$name] = "DI: $typeName \$$name";
} elseif ($type->allowsNull()) {
$args[] = null;
$requestedParams[$name] = "Unavailable service: $name";
} else {
throw new Exception('Could not load required service: ' . $name);
}
}
}

View File

@ -182,19 +182,35 @@ class Controller extends \yii\base\Controller
$method = new \ReflectionMethod($action, 'run');
}
$args = array_values($params);
$args = [];
$missing = [];
$actionParams = [];
$requestedParams = [];
foreach ($method->getParameters() as $i => $param) {
if ($param->isArray() && isset($args[$i])) {
$args[$i] = $args[$i] === '' ? [] : preg_split('/\s*,\s*/', $args[$i]);
$name = $param->getName();
$key = null;
if (array_key_exists($i, $params)) {
$key = $i;
} elseif (array_key_exists($name, $params)) {
$key = $name;
}
if (!isset($args[$i])) {
if ($param->isDefaultValueAvailable()) {
$args[$i] = $param->getDefaultValue();
} else {
$missing[] = $param->getName();
if ($key !== null) {
if ($param->isArray()) {
$params[$key] = $params[$key] === '' ? [] : preg_split('/\s*,\s*/', $params[$key]);
}
$args[] = $actionParams[$key] = $params[$key];
unset($params[$key]);
} elseif (PHP_VERSION_ID >= 70100 && ($type = $param->getType()) !== null && !$type->isBuiltin()) {
try {
$this->bindInjectedParams($type, $name, $args, $requestedParams);
} catch (\yii\base\Exception $e) {
throw new Exception($e->getMessage());
}
} elseif ($param->isDefaultValueAvailable()) {
$args[] = $actionParams[$i] = $param->getDefaultValue();
} else {
$missing[] = $name;
}
}
@ -202,6 +218,11 @@ class Controller extends \yii\base\Controller
throw new Exception(Yii::t('yii', 'Missing required arguments: {params}', ['params' => implode(', ', $missing)]));
}
// We use a different array here, specifically one that doesn't contain service instances but descriptions instead.
if (\Yii::$app->requestedParams === null) {
\Yii::$app->requestedParams = array_merge($actionParams, $requestedParams);
}
return $args;
}

View File

@ -8,6 +8,8 @@
namespace yii\web;
use Yii;
use yii\base\ErrorException;
use yii\base\Exception;
use yii\base\InlineAction;
use yii\helpers\Url;
@ -125,6 +127,7 @@ class Controller extends \yii\base\Controller
$args = [];
$missing = [];
$actionParams = [];
$requestedParams = [];
foreach ($method->getParameters() as $param) {
$name = $param->getName();
if (array_key_exists($name, $params)) {
@ -162,6 +165,12 @@ class Controller extends \yii\base\Controller
}
$args[] = $actionParams[$name] = $params[$name];
unset($params[$name]);
} elseif (PHP_VERSION_ID >= 70100 && ($type = $param->getType()) !== null && !$type->isBuiltin()) {
try {
$this->bindInjectedParams($type, $name, $args, $requestedParams);
} catch (Exception $e) {
throw new ServerErrorHttpException($e->getMessage(), 0, $e);
}
} elseif ($param->isDefaultValueAvailable()) {
$args[] = $actionParams[$name] = $param->getDefaultValue();
} else {
@ -177,6 +186,11 @@ class Controller extends \yii\base\Controller
$this->actionParams = $actionParams;
// We use a different array here, specifically one that doesn't contain service instances but descriptions instead.
if (\Yii::$app->requestedParams === null) {
\Yii::$app->requestedParams = array_merge($actionParams, $requestedParams);
}
return $args;
}

View File

@ -7,8 +7,13 @@
namespace yiiunit\framework\console;
use RuntimeException;
use yii\console\Exception;
use yiiunit\framework\console\stubs\DummyService;
use Yii;
use yii\base\InlineAction;
use yii\base\Module;
use yii\console\Application;
use yii\console\Request;
use yii\helpers\Console;
use yiiunit\TestCase;
@ -18,6 +23,9 @@ use yiiunit\TestCase;
*/
class ControllerTest extends TestCase
{
/** @var FakeController */
private $controller;
protected function setUp()
{
parent::setUp();
@ -76,6 +84,98 @@ class ControllerTest extends TestCase
$result = $controller->runAction('aksi3', $params);
}
public function testNullableInjectedActionParams()
{
if (PHP_VERSION_ID < 70100) {
$this->markTestSkipped('Can not be tested on PHP < 7.1');
return;
}
// Use the PHP71 controller for this test
$this->controller = new FakePhp71Controller('fake', new Application([
'id' => 'app',
'basePath' => __DIR__,
]));
$this->mockApplication(['controller' => $this->controller]);
$injectionAction = new InlineAction('injection', $this->controller, 'actionNullableInjection');
$params = [];
$args = $this->controller->bindActionParams($injectionAction, $params);
$this->assertEquals(\Yii::$app->request, $args[0]);
$this->assertNull($args[1]);
}
public function testInjectionContainerException()
{
if (PHP_VERSION_ID < 70100) {
$this->markTestSkipped('Can not be tested on PHP < 7.1');
return;
}
// Use the PHP71 controller for this test
$this->controller = new FakePhp71Controller('fake', new Application([
'id' => 'app',
'basePath' => __DIR__,
]));
$this->mockApplication(['controller' => $this->controller]);
$injectionAction = new InlineAction('injection', $this->controller, 'actionInjection');
$params = ['between' => 'test', 'after' => 'another', 'before' => 'test'];
\Yii::$container->set(DummyService::className(), function() { throw new \RuntimeException('uh oh'); });
$this->expectException(get_class(new RuntimeException()));
$this->expectExceptionMessage('uh oh');
$this->controller->bindActionParams($injectionAction, $params);
}
public function testUnknownInjection()
{
if (PHP_VERSION_ID < 70100) {
$this->markTestSkipped('Can not be tested on PHP < 7.1');
return;
}
// Use the PHP71 controller for this test
$this->controller = new FakePhp71Controller('fake', new Application([
'id' => 'app',
'basePath' => __DIR__,
]));
$this->mockApplication(['controller' => $this->controller]);
$injectionAction = new InlineAction('injection', $this->controller, 'actionInjection');
$params = ['between' => 'test', 'after' => 'another', 'before' => 'test'];
\Yii::$container->clear(DummyService::className());
$this->expectException(get_class(new Exception()));
$this->expectExceptionMessage('Could not load required service: dummyService');
$this->controller->bindActionParams($injectionAction, $params);
}
public function testInjectedActionParams()
{
if (PHP_VERSION_ID < 70100) {
$this->markTestSkipped('Can not be tested on PHP < 7.1');
return;
}
// Use the PHP71 controller for this test
$this->controller = new FakePhp71Controller('fake', new Application([
'id' => 'app',
'basePath' => __DIR__,
]));
$this->mockApplication(['controller' => $this->controller]);
$injectionAction = new InlineAction('injection', $this->controller, 'actionInjection');
$params = ['between' => 'test', 'after' => 'another', 'before' => 'test'];
\Yii::$container->set(DummyService::className(), DummyService::className());
$args = $this->controller->bindActionParams($injectionAction, $params);
$this->assertEquals($params['before'], $args[0]);
$this->assertEquals(\Yii::$app->request, $args[1]);
$this->assertEquals('Component: yii\console\Request $request', \Yii::$app->requestedParams['request']);
$this->assertEquals($params['between'], $args[2]);
$this->assertInstanceOf(DummyService::className(), $args[3]);
$this->assertEquals('DI: yiiunit\framework\console\stubs\DummyService $dummyService', \Yii::$app->requestedParams['dummyService']);
$this->assertNull($args[4]);
$this->assertEquals('Unavailable service: post', \Yii::$app->requestedParams['post']);
$this->assertEquals($params['after'], $args[5]);
}
public function assertResponseStatus($status, $response)
{
$this->assertInstanceOf('yii\console\Response', $response);

View File

@ -0,0 +1,24 @@
<?php
/**
* @link http://www.yiiframework.com/
* @copyright Copyright (c) 2008 Yii Software LLC
* @license http://www.yiiframework.com/license/
*/
namespace yiiunit\framework\console;
use yiiunit\framework\console\stubs\DummyService;
use yii\console\Controller;
use yii\console\Request;
class FakePhp71Controller extends Controller
{
public function actionInjection($before, Request $request, $between, DummyService $dummyService, Post $post = null, $after)
{
}
public function actionNullableInjection(?Request $request, ?Post $post)
{
}
}

View File

@ -0,0 +1,16 @@
<?php
/**
* @link http://www.yiiframework.com/
* @copyright Copyright (c) 2008 Yii Software LLC
* @license http://www.yiiframework.com/license/
*/
namespace yiiunit\framework\console\stubs;
use yii\base\BaseObject;
class DummyService extends BaseObject
{
}

View File

@ -7,9 +7,12 @@
namespace yiiunit\framework\web;
use RuntimeException;
use Yii;
use yii\base\InlineAction;
use yii\web\Response;
use yii\web\ServerErrorHttpException;
use yiiunit\framework\web\stubs\VendorImage;
use yiiunit\TestCase;
/**
@ -17,6 +20,8 @@ use yiiunit\TestCase;
*/
class ControllerTest extends TestCase
{
/** @var FakeController */
private $controller;
public function testBindActionParams()
{
$aksi1 = new InlineAction('aksi1', $this->controller, 'actionAksi1');
@ -32,6 +37,129 @@ class ControllerTest extends TestCase
$this->assertEquals('avaliable', $other);
}
public function testNullableInjectedActionParams()
{
if (PHP_VERSION_ID < 70100) {
$this->markTestSkipped('Can not be tested on PHP < 7.1');
return;
}
// Use the PHP71 controller for this test
$this->controller = new FakePhp71Controller('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('injection', $this->controller, 'actionNullableInjection');
$params = [];
$args = $this->controller->bindActionParams($injectionAction, $params);
$this->assertEquals(\Yii::$app->request, $args[0]);
$this->assertNull($args[1]);
}
public function testInjectionContainerException()
{
if (PHP_VERSION_ID < 70100) {
$this->markTestSkipped('Can not be tested on PHP < 7.1');
return;
}
// Use the PHP71 controller for this test
$this->controller = new FakePhp71Controller('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('injection', $this->controller, 'actionInjection');
$params = ['between' => 'test', 'after' => 'another', 'before' => 'test'];
\Yii::$container->set(VendorImage::className(), function() { throw new \RuntimeException('uh oh'); });
$this->expectException(get_class(new RuntimeException()));
$this->expectExceptionMessage('uh oh');
$this->controller->bindActionParams($injectionAction, $params);
}
public function testUnknownInjection()
{
if (PHP_VERSION_ID < 70100) {
$this->markTestSkipped('Can not be tested on PHP < 7.1');
return;
}
// Use the PHP71 controller for this test
$this->controller = new FakePhp71Controller('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('injection', $this->controller, 'actionInjection');
$params = ['between' => 'test', 'after' => 'another', 'before' => 'test'];
\Yii::$container->clear(VendorImage::className());
$this->expectException(get_class(new ServerErrorHttpException()));
$this->expectExceptionMessage('Could not load required service: vendorImage');
$this->controller->bindActionParams($injectionAction, $params);
}
public function testInjectedActionParams()
{
if (PHP_VERSION_ID < 70100) {
$this->markTestSkipped('Can not be tested on PHP < 7.1');
return;
}
// Use the PHP71 controller for this test
$this->controller = new FakePhp71Controller('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('injection', $this->controller, 'actionInjection');
$params = ['between' => 'test', 'after' => 'another', 'before' => 'test'];
\Yii::$container->set(VendorImage::className(), VendorImage::className());
$args = $this->controller->bindActionParams($injectionAction, $params);
$this->assertEquals($params['before'], $args[0]);
$this->assertEquals(\Yii::$app->request, $args[1]);
$this->assertEquals('Component: yii\web\Request $request', \Yii::$app->requestedParams['request']);
$this->assertEquals($params['between'], $args[2]);
$this->assertInstanceOf(VendorImage::className(), $args[3]);
$this->assertEquals('DI: yiiunit\framework\web\stubs\VendorImage $vendorImage', \Yii::$app->requestedParams['vendorImage']);
$this->assertNull($args[4]);
$this->assertEquals('Unavailable service: post', \Yii::$app->requestedParams['post']);
$this->assertEquals($params['after'], $args[5]);
}
/**
* @see https://github.com/yiisoft/yii2/issues/17701
*/

View File

@ -0,0 +1,30 @@
<?php
/**
* @link http://www.yiiframework.com/
* @copyright Copyright (c) 2008 Yii Software LLC
* @license http://www.yiiframework.com/license/
*/
namespace yiiunit\framework\web;
use yii\web\Controller;
use yii\web\Request;
use yiiunit\framework\web\stubs\VendorImage;
/**
* @author Sam Mousa<sam@mousa.nl>
* @since 2.0.36
*/
class FakePhp71Controller extends Controller
{
public $enableCsrfValidation = false;
public function actionInjection($before, Request $request, $between, VendorImage $vendorImage, Post $post = null, $after)
{
}
public function actionNullableInjection(?Request $request, ?Post $post)
{
}
}