Skip to content

Commit

Permalink
Fix #17722: Add action injection support
Browse files Browse the repository at this point in the history
  • Loading branch information
SamMousa authored Jun 12, 2020
1 parent c365f47 commit 4ea484c
Show file tree
Hide file tree
Showing 10 changed files with 391 additions and 9 deletions.
18 changes: 18 additions & 0 deletions docs/guide/concept-di-container.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>
---------------

Expand Down
1 change: 1 addition & 0 deletions framework/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions framework/base/Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -523,4 +523,34 @@ public function findLayoutFile($view)

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);
}
}
}
39 changes: 30 additions & 9 deletions framework/console/Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,26 +182,47 @@ public function bindActionParams($action, $params)
$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;
}
}

if (!empty($missing)) {
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;
}

Expand Down
14 changes: 14 additions & 0 deletions framework/web/Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -125,6 +127,7 @@ public function bindActionParams($action, $params)
$args = [];
$missing = [];
$actionParams = [];
$requestedParams = [];
foreach ($method->getParameters() as $param) {
$name = $param->getName();
if (array_key_exists($name, $params)) {
Expand Down Expand Up @@ -162,6 +165,12 @@ public function bindActionParams($action, $params)
}
$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 {
Expand All @@ -177,6 +186,11 @@ public function bindActionParams($action, $params)

$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;
}

Expand Down
100 changes: 100 additions & 0 deletions tests/framework/console/ControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,6 +23,9 @@
*/
class ControllerTest extends TestCase
{
/** @var FakeController */
private $controller;

protected function setUp()
{
parent::setUp();
Expand Down Expand Up @@ -76,6 +84,98 @@ public function testBindActionParams()
$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);
Expand Down
24 changes: 24 additions & 0 deletions tests/framework/console/FakePhp71Controller.php
Original file line number Diff line number Diff line change
@@ -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)
{
}
}
16 changes: 16 additions & 0 deletions tests/framework/console/stubs/DummyService.php
Original file line number Diff line number Diff line change
@@ -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
{

}
Loading

0 comments on commit 4ea484c

Please sign in to comment.