[FEATURE] dispatching and controllers

This commit is contained in:
Marco Stoll 2019-06-24 11:43:49 +02:00
parent b1183f11d2
commit 7421585697
14 changed files with 1330 additions and 1 deletions

View File

@ -0,0 +1,74 @@
<?php
/**
* Definition of AbstractController
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Controllers;
use FF\Factories\SF;
use FF\Services\Dispatching\Dispatcher;
use FF\Services\Templating\TemplateRendererInterface;
use Symfony\Component\HttpFoundation\Response;
/**
* Class AbstractController
*
* Concrete sub classes should define one or more action methods.
*
* Any action method must meet the following requirements:
* - must be public
* - must not be static
* - must return an instance of Symfony\Component\HttpFoundation\Response
*
* Action methods may define any number of arguments
*
* @package FF\Dispatching
*/
abstract class AbstractController
{
/**
* For use with the BaseNamespaceClassLocator of the ControllersFactory
*/
const COMMON_NS_SUFFIX = 'Controllers';
/**
* Forwards to another controller action
*
* This method can be invoked with an arbitrary amount of arguments.
* Any $args will be passed to the designated forwarded action in the given order.
*
* @param AbstractController|string $controller A controller instance or the class identifier of a controller class
* @param string $action
* @param array $args
* @return Response
*/
protected function forward($controller, string $action, ...$args)
{
/** @var Dispatcher $dispatcher */
$dispatcher = SF::i()->get('dispatching/dispatcher');
return $dispatcher->forward($controller, $action, ...$args);
}
/**
* Renders a template
*
* @param string $template
* @param array $data
* @return string
*/
protected function render(string $template, array $data = []): string
{
return $this->getTemplateRenderer()->render($template, $data);
}
/**
* @return TemplateRendererInterface
*/
protected abstract function getTemplateRenderer(): TemplateRendererInterface;
}

View File

@ -0,0 +1,89 @@
<?php
/**
* Definition of PostDispatch
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Events\Dispatching;
use FF\Controllers\AbstractController;
use FF\Events\AbstractEvent;
use Symfony\Component\HttpFoundation\Response;
/**
* Class PostDispatch
*
* @package FF\Events\Dispatching
*/
class PostDispatch extends AbstractEvent
{
/**
* @var Response
*/
protected $response;
/**
* @var AbstractController
*/
protected $controller;
/**
* @var string
*/
protected $action;
/**
* @var array
*/
protected $args;
/**
* @param Response $response
* @param AbstractController $controller
* @param string $action
* @param array $args
*/
public function __construct(Response $response, AbstractController $controller, $action, array $args = [])
{
$this->response = $response;
$this->controller = $controller;
$this->action = $action;
$this->args = $args;
}
/**
* @return Response
*/
public function getResponse(): Response
{
return $this->response;
}
/**
* @return AbstractController
*/
public function getController(): AbstractController
{
return $this->controller;
}
/**
* @return string
*/
public function getAction(): string
{
return $this->action;
}
/**
* @return array
*/
public function getArgs(): array
{
return $this->args;
}
}

View File

@ -0,0 +1,91 @@
<?php
/**
* Definition of PostRoute
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Events\Dispatching;
use FF\Controllers\AbstractController;
use FF\Events\AbstractEvent;
use Symfony\Component\HttpFoundation\Request;
/**
* Class PostRoute
*
* @package FF\Events\Dispatching
*/
class PostRoute extends AbstractEvent
{
/**
* @var Request
*/
protected $request;
/**
* @var AbstractController
*/
protected $controller;
/**
* @var string
*/
protected $action;
/**
* @var array
*/
protected $args = [];
/**
* @param Request $request
* @param AbstractController $controller
* @param string $action
* @param array $args
*/
public function __construct(Request $request, AbstractController $controller, $action, array $args = [])
{
$this->request = $request;
$this->controller = $controller;
$this->action = $action;
$this->args = $args;
}
/**
* @return Request
*/
public function getRequest(): Request
{
return $this->request;
}
/**
* @return AbstractController
*/
public function getController(): AbstractController
{
return $this->controller;
}
/**
* @return string
*/
public function getAction(): string
{
return $this->action;
}
/**
* Retrieves the action arguments
*
* @return array
*/
public function getArgs(): array
{
return $this->args;
}
}

View File

@ -0,0 +1,45 @@
<?php
/**
* Definition of PreDispatch
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Events\Dispatching;
use FF\Events\AbstractEvent;
use Symfony\Component\HttpFoundation\Request;
/**
* Class PreDispatch
*
* @package FF\Events\Dispatching
*/
class PreDispatch extends AbstractEvent
{
/**
* @var Request
*/
protected $request;
/**
* @param Request $request
*/
public function __construct(Request $request)
{
$this->request = $request;
}
/**
* Retrieves the request
*
* @return Request
*/
public function getRequest(): Request
{
return $this->request;
}
}

View File

@ -0,0 +1,73 @@
<?php
/**
* Definition of PreForward
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Events\Dispatching;
use FF\Controllers\AbstractController;
use FF\Events\AbstractEvent;
/**
* Class PreForward
*
* @package FF\Events\Dispatching
*/
class PreForward extends AbstractEvent
{
/**
* @var AbstractController
*/
protected $controller;
/**
* @var string
*/
protected $action;
/**
* @var array
*/
protected $args = [];
/**
* @param AbstractController $controller
* @param string $action
* @param array $args
*/
public function __construct(AbstractController $controller, string $action, array $args = [])
{
$this->controller = $controller;
$this->action = $action;
$this->args = $args;
}
/**
* @return AbstractController
*/
public function getController(): AbstractController
{
return $this->controller;
}
/**
* @return string
*/
public function getAction(): string
{
return $this->action;
}
/**
* @return array
*/
public function getArgs(): array
{
return $this->args;
}
}

View File

@ -0,0 +1,81 @@
<?php
/**
* Definition of ControllersFactory
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Factories;
use FF\Controllers\AbstractController;
use FF\Factories\ClassLocators\BaseNamespaceClassLocator;
use FF\Factories\ClassLocators\ClassLocatorInterface;
/**
* Class ControllersFactory
*
* @package FF\Factories
*/
class ControllersFactory extends AbstractSingletonFactory
{
/**
* @var ControllersFactory
*/
protected static $instance;
/**
* Declared protected to prevent external usage.
* Uses a BaseNamespaceClassLocator pre-configured with the 'Events' as common suffix and the FF namespace.
* @see \FF\Factories\ClassLocators\BaseNamespaceClassLocator
*/
protected function __construct()
{
parent::__construct(new BaseNamespaceClassLocator(AbstractController::COMMON_NS_SUFFIX, 'FF'));
}
/**
* Declared protected to prevent external usage
*/
protected function __clone()
{
}
/**
* {@inheritDoc}
* @return BaseNamespaceClassLocator
*/
public function getClassLocator(): ClassLocatorInterface
{
return parent::getClassLocator();
}
/**
* Retrieves the singleton instance of this class
*
* @return ControllersFactory
*/
public static function getInstance(): ControllersFactory
{
if (is_null(self::$instance)) {
self::$instance = new ControllersFactory();
}
return self::$instance;
}
/**
* {@inheritdoc}
* @return AbstractController
*/
public function create(string $classIdentifier, ...$args)
{
/** @var AbstractController $controller */
$controller = parent::create($classIdentifier, ...$args);
return $controller;
}
}

View File

@ -0,0 +1,344 @@
<?php
/**
* Definition of Dispatcher
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Services\Dispatching;
use FF\Controllers\AbstractController;
use FF\Factories\ControllersFactory;
use FF\Factories\Exceptions\ClassNotFoundException;
use FF\Services\AbstractService;
use FF\Services\Dispatching\Exceptions\ControllerInspectionException;
use FF\Services\Dispatching\Exceptions\IncompleteRouteException;
use FF\Services\Exceptions\ResourceNotFoundException;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Exception\ResourceNotFoundException as SymfonyResourceNotFoundException;
use Symfony\Component\Routing\Loader\YamlFileLoader;
use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\RouteCollection;
/**
* Class Dispatcher
*
* Options:
*
* - routing-yaml : string - path to routing yaml
* - fire-events : bool (default: false) - whether to fire rendering events
*
* @package FF\Services\Dispatching
*/
class Dispatcher extends AbstractService
{
const RESERVED_ROUTE_PARAMS = ['controller', 'action', '_route'];
/**
* @var RouteCollection
*/
protected $routes;
/**
* @var bool
*/
protected $fireEvents;
/**
* @return RouteCollection
*/
public function getRoutes(): RouteCollection
{
return $this->routes;
}
/**
* @param RouteCollection $routes
* @return $this
*/
public function setRoutes(RouteCollection $routes)
{
$this->routes = $routes;
return $this;
}
/**
* @return bool
*/
public function getFireEvents(): bool
{
return $this->fireEvents;
}
/**
* @param bool $fireEvents
* @return $this
*/
public function setFireEvents(bool $fireEvents)
{
$this->fireEvents = $fireEvents;
return $this;
}
/**
* Retrieves a route's path by name
*
* @param string $name
* @return string
*/
public function getRoutePath(string $name): string
{
$route = $this->routes->get($name);
if (is_null($route)) return '';
$path = $route->getPath();
return !empty($path) ? $path : '/';
}
/**
* Builds a relative path
*
* Strips non-filled path tokens from the end of the path.
* Returns an empty string if $routeName is not found.
*
* @param string $routeName
* @param array $namedArgs
* @return string
*/
public function buildPath(string $routeName, array $namedArgs = []): string
{
$route = $this->routes->get($routeName);
if (is_null($route)) return '';
// add omitted args having defaults in route's definition
foreach ($route->getDefaults() as $name => $default) {
if ($name == 'controller' || $name == 'action') continue;
if (array_key_exists($name, $namedArgs)) continue;
$namedArgs[$name] = $default;
}
// fill-in args in route's path
$path = $route->getPath();
foreach ($namedArgs as $key => $value) {
$path = str_replace('{' . $key . '}', $value, $path);
}
// strip unfilled args from end of path
// e.g. /something/foo/{bar}
$path = preg_replace('~(/{[^}]+})+$~', '', $path);
if (empty($path)) $path = '/';
return $path;
}
/**
* Retrieves parameters of a matching route for the request given
*
* @param Request $request
* @return array|null
* @throws IncompleteRouteException
*/
public function match(Request $request): ?array
{
$context = new RequestContext();
$context->fromRequest($request);
try {
$matcher = new UrlMatcher($this->routes, $context);
$pathInfo = $context->getPathInfo();
$parameters = $matcher->match($pathInfo);
if (!isset($parameters['controller'])) {
throw new IncompleteRouteException(
'controller param missing from route [' . $parameters['_route'] . ']'
);
}
if (!isset($parameters['action'])) {
throw new IncompleteRouteException('action param missing from route [' . $parameters['_route'] . ']');
}
return $parameters;
} catch (SymfonyResourceNotFoundException $e) {
return null;
}
}
/**
* Dispatches a request
*
* @param Request $request
* @return Response
* @throws ResourceNotFoundException no route found for request
* @fires Dispatching\PreDispatch
* @fires Dispatching\PostRoute
* @fires Dispatching\PostDispatch
*/
public function dispatch(Request $request): Response
{
if ($this->fireEvents) {
$this->fire('Dispatching\PreDispatch', $request);
}
$parameters = $this->match($request);
if (is_null($parameters)) {
throw new ResourceNotFoundException('no route found for request [' . $request->getPathInfo() . ']');
}
try {
$controller = ControllersFactory::getInstance()->create($parameters['controller']);
$action = $parameters['action'];
$args = $this->extractArgs($parameters);
$actionArgs = $this->buildActionArgs($controller, $action, $args);
} catch (ClassNotFoundException $e) {
throw new ResourceNotFoundException('controller [' . $parameters['controller'] . '] not found', 0, $e);
} catch(ControllerInspectionException $e) {
throw new ResourceNotFoundException(
'action [' . $parameters['action'] . '] not found in controller [' . $parameters['controller'] . ']',
0,
$e
);
}
if ($this->fireEvents) {
$this->fire('Dispatching\PostRoute', $request, $controller, $action, $args);
}
/** @var Response $response */
$response = call_user_func_array([$controller, $action], $actionArgs);
if ($this->fireEvents) {
$this->fire('Dispatching\PostDispatch', $response, $controller, $action, $actionArgs);
}
return $response;
}
/**
* Forwards to another controller action
*
* This method can be invoked with an arbitrary amount of arguments.
* Any $args will be passed to the designated forwarded action in the given order.
*
* @param AbstractController|string $controller A controller instance or the class identifier of a controller class
* @param string $action
* @param array $args
* @return Response
* @throws \InvalidArgumentException action not callable
* @fires Dispatching\PreForward
*/
public function forward($controller, string $action, ...$args)
{
if (is_string($controller)) {
$controller = ControllersFactory::getInstance()->create($controller);
}
if ($this->fireEvents) {
$this->fire('Dispatching\PreForward', $controller, $action, $args);
}
if (!method_exists($controller, $action) || !is_callable([$controller, $action])) {
throw new \InvalidArgumentException(
'controller [' . get_class($controller) . '] does not define a callable action [' . $action . ']'
);
}
// invoke forwarded action
$methodArgs = $this->buildActionArgs($controller, $action, $args);
/** @var Response $response */
$response = call_user_func_array([$controller, $action], $methodArgs);
return $response;
}
/**
* Retrieves the action arguments
*
* @param array $parameters
* @return array
*/
protected function extractArgs(array $parameters): array
{
$args = [];
foreach ($parameters as $key => $value) {
if (in_array($key, self::RESERVED_ROUTE_PARAMS)) continue;
$args[$key] = $value;
}
return $args;
}
/**
* Builds the arguments list for invoking the desired action
*
* @param AbstractController $controller
* @param string $action
* @param array $args
* @return array
* @throws ResourceNotFoundException
* @throws ControllerInspectionException
*/
protected function buildActionArgs(AbstractController $controller, string $action, array $args): array
{
$methodArgs = [];
try {
$reflection = new \ReflectionMethod($controller, $action);
foreach ($reflection->getParameters() as $param) {
$name = $param->getName();
if (!isset($args[$name])) {
if (!$param->isOptional()) {
throw new ResourceNotFoundException(
'missing required argument [' . $name . '] for action [' . $action . '] '
. 'of controller [' . get_class($controller) . ']'
);
}
$methodArgs[] = $param->getDefaultValue();
} else {
$methodArgs[] = $args[$name];
}
}
} catch (\ReflectionException $e) {
throw new ControllerInspectionException(
'error while inspecting action [' . $action . '] of controller [' . get_class($controller) . ']',
0,
$e
);
}
return $methodArgs;
}
/**
* {@inheritDoc}
*/
protected function initialize(array $options)
{
parent::initialize($options);
$this->fireEvents = $this->getOption('fire-events', false);
$locator = new FileLocator(dirname($this->getOption('routing-yaml')));
$loader = new YamlFileLoader($locator);
$this->routes = $loader->load(basename($this->getOption('routing-yaml')));
}
/**
* {@inheritDoc}
*/
protected function validateOptions(array $options, array &$errors): bool
{
if (!isset($options['routing-yaml']) || empty($options['routing-yaml'])) {
$errors[] = 'missing or empty mandatory option [routing-yaml]';
return false;
}
return true;
}
}

View File

@ -0,0 +1,21 @@
<?php
/**
* Definition of ControllerInspectionException
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Services\Dispatching\Exceptions;
/**
* Class ControllerInspectionException
*
* @package FF\Services\Dispatching\Exceptions
*/
class ControllerInspectionException extends \RuntimeException
{
}

View File

@ -0,0 +1,21 @@
<?php
/**
* Definition of IncompleteRouteException
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Services\Dispatching\Exceptions;
/**
* Class IncompleteRouteException
*
* @package FF\Services\Dispatching\Exceptions
*/
class IncompleteRouteException extends \RuntimeException
{
}

View File

@ -0,0 +1,21 @@
<?php
/**
* Definition of ResourceNotFoundException
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Services\Exceptions;
/**
* Class ResourceNotFoundException
*
* @package FF\Services\Exceptions
*/
class ResourceNotFoundException extends \RuntimeException
{
}

View File

@ -0,0 +1,41 @@
<?php
/**
* Definition of ControllersFactoryTest
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Tests\Factories;
use FF\Factories\ClassLocators\BaseNamespaceClassLocator;
use FF\Factories\ControllersFactory;
use PHPUnit\Framework\TestCase;
/**
* Test ControllersFactoryTest
*
* @package FF\Tests
*/
class ControllersFactoryTest extends TestCase
{
/**
* Tests the namesake method/feature
*/
public function testGetInstance()
{
$instance = ControllersFactory::getInstance();
$this->assertInstanceOf(ControllersFactory::class, $instance);
$this->assertSame($instance, ControllersFactory::getInstance());
}
/**
* Tests the namesake method/feature
*/
public function testGetClassLocator()
{
$this->assertInstanceOf(BaseNamespaceClassLocator::class, ControllersFactory::getInstance()->getClassLocator());
}
}

View File

@ -0,0 +1,396 @@
<?php
/**
* Definition of DispatcherTest
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Tests\Services\Dispatching {
use FF\Events\AbstractEvent;
use FF\Events\Dispatching\PostDispatch;
use FF\Events\Dispatching\PostRoute;
use FF\Events\Dispatching\PreDispatch;
use FF\Events\Dispatching\PreForward;
use FF\Factories\ControllersFactory;
use FF\Factories\Exceptions\ClassNotFoundException;
use FF\Factories\ServicesFactory;
use FF\Factories\SF;
use FF\Services\Dispatching\Dispatcher;
use FF\Services\Dispatching\Exceptions\IncompleteRouteException;
use FF\Services\Events\EventBroker;
use FF\Services\Exceptions\ConfigurationException;
use FF\Services\Exceptions\ResourceNotFoundException;
use FF\Tests\Services\Dispatching\Controllers\HelloWorldController;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouteCollection;
/**
* Test DispatcherTest
*
* @package FF\Tests
*/
class DispatcherTest extends TestCase
{
const DEFAULT_OPTIONS = [
'routing-yaml' => __DIR__ . '/routing/test-routing.yml'
];
/**
* @var AbstractEvent[]
*/
protected static $lastEvents;
/**
* @var Dispatcher
*/
protected $uut;
/**
* {@inheritdoc}
*/
public static function setUpBeforeClass(): void
{
SF::setInstance(new ServicesFactory());
ControllersFactory::getInstance()
->getClassLocator()
->prependNamespaces(__NAMESPACE__);
/** @var EventBroker $eventBroker */
$eventBroker = SF::i()->get('Events\EventBroker');
// register test listener
$eventBroker
->subscribe([__CLASS__, 'listener'], 'Dispatching\PreDispatch')
->subscribe([__CLASS__, 'listener'], 'Dispatching\PostRoute')
->subscribe([__CLASS__, 'listener'], 'Dispatching\PostDispatch')
->subscribe([__CLASS__, 'listener'], 'Dispatching\PreForward');
}
/**
* {@inheritdoc}
*/
protected function setUp(): void
{
$this->uut = new Dispatcher(self::DEFAULT_OPTIONS);
self::$lastEvents = [];
}
/**
* Dummy event listener
*
* @param AbstractEvent $event
*/
public static function listener(AbstractEvent $event)
{
self::$lastEvents[get_class($event)] = $event;
}
/**
* Retrieves a request instance
*
* @param string $requestUri
* @return Request
*/
protected function buildRequest($requestUri)
{
return new Request([], [], [], [], [], ['REQUEST_URI' => $requestUri]);
}
/**
* Tests the namesake method/feature
*/
public function testInitializeErrorMandatoryOptions()
{
$this->expectException(ConfigurationException::class);
new Dispatcher();
}
/**
* Tests the namesake method/feature
*/
public function testSetGetRoutes()
{
$value = new RouteCollection();
$same = $this->uut->setRoutes($value);
$this->assertSame($this->uut, $same);
$this->assertSame($value, $this->uut->getRoutes());
}
/**
* Tests the namesake method/feature
*/
public function testGetRoutePath()
{
$this->assertEquals('/default', $this->uut->getRoutePath('default'));
}
/**
* Tests the namesake method/feature
*/
public function testGetRoutePathMissing()
{
$this->assertEquals('', $this->uut->getRoutePath('unknown'));
}
/**
* Tests the namesake method/feature
*/
public function testBuildUrl()
{
$path = $this->uut->buildPath('default');
$this->assertEquals('/default', $path);
}
/**
* Tests the namesake method/feature
*/
public function testBuildUrlExtraArgs()
{
$path = $this->uut->buildPath('default', ['foo' => 'bar']);
$this->assertEquals('/default', $path);
}
/**
* Tests the namesake method/feature
*/
public function testBuildUrlWithArgs()
{
$path = $this->uut->buildPath('with-args', ['foo' => 'foo', 'bar' => 'bar']);
$this->assertEquals('/with-args/foo/bar', $path);
}
/**
* Tests the namesake method/feature
*/
public function testBuildUrlWithoutArgs()
{
$path = $this->uut->buildPath('with-args');
$this->assertEquals('/with-args', $path);
}
/**
* Tests the namesake method/feature
*/
public function testBuildUrlDefaultArgs()
{
$path = $this->uut->buildPath('omitted-args', ['foo' => 'foo']);
$this->assertEquals('/omitted-args/foo/bar', $path);
}
/**
* Tests the namesake method/feature
*/
public function testBuildUrlMissingArgs()
{
$path = $this->uut->buildPath('omitted-args');
$this->assertEquals('/omitted-args/{foo}/bar', $path);
}
/**
* Tests the namesake method/feature
*/
public function testDispatchDefault()
{
$response = $this->uut->dispatch($this->buildRequest('/default'));
$this->assertInstanceOf(Response::class, $response);
$this->assertEquals('default', $response->getContent());
}
/**
* Tests the namesake method/feature
*/
public function testDispatchWithArgs()
{
$response = $this->uut->dispatch($this->buildRequest('/with-args/foo/bar'));
$this->assertInstanceOf(Response::class, $response);
$this->assertEquals('foo-bar', $response->getContent());
}
/**
* Tests the namesake method/feature
*/
public function testDispatchOmittedArgs()
{
$response = $this->uut->dispatch($this->buildRequest('/omitted-args/foo'));
$this->assertInstanceOf(Response::class, $response);
$this->assertEquals('foo-bar', $response->getContent());
}
/**
* Tests the namesake method/feature
*/
public function testDispatchEvents()
{
$this->uut->setFireEvents(true)
->dispatch($this->buildRequest('/default'));
$this->assertArrayHasKey(PreDispatch::class, self::$lastEvents);
$this->assertArrayHasKey(PostRoute::class, self::$lastEvents);
$this->assertArrayHasKey(PostDispatch::class, self::$lastEvents);
}
/**
* Tests the namesake method/feature
*/
public function testDispatchErrorRouteController()
{
$this->expectException(IncompleteRouteException::class);
$this->uut->dispatch($this->buildRequest('/missing-controller'));
}
/**
* Tests the namesake method/feature
*/
public function testDispatchErrorRouteAction()
{
$this->expectException(IncompleteRouteException::class);
$this->uut->dispatch($this->buildRequest('/missing-action'));
}
/**
* Tests the namesake method/feature
*/
public function testDispatchErrorNoRoute()
{
$this->expectException(ResourceNotFoundException::class);
$this->uut->dispatch($this->buildRequest('/unknown-path'));
}
/**
* Tests the namesake method/feature
*/
public function testDispatchErrorNoController()
{
$this->expectException(ResourceNotFoundException::class);
$this->uut->dispatch($this->buildRequest('/unknown-controller'));
}
/**
* Tests the namesake method/feature
*/
public function testDispatchErrorNoAction()
{
$this->expectException(ResourceNotFoundException::class);
$this->uut->dispatch($this->buildRequest('/unknown-action'));
}
/**
* Tests the namesake method/feature
*/
public function testDispatchErrorMissingArg()
{
$this->expectException(ResourceNotFoundException::class);
$this->uut->dispatch($this->buildRequest('/missing-arg'));
}
/**
* Tests the namesake method/feature
*/
public function testForwardByObject()
{
$response = $this->uut->forward(new HelloWorldController(), 'default');
$this->assertInstanceOf(Response::class, $response);
$this->assertEquals($response->getContent(), 'default');
}
/**
* Tests the namesake method/feature
*/
public function testForwardByClassName()
{
$response = $this->uut->forward('HelloWorldController', 'default');
$this->assertInstanceOf(Response::class, $response);
$this->assertEquals($response->getContent(), 'default');
}
/**
* Tests the namesake method/feature
*/
public function testForwardEvents()
{
$this->uut->setFireEvents(true)
->forward(new HelloWorldController(), 'default');
$this->assertArrayHasKey(PreForward::class, self::$lastEvents);
}
/**
* Tests the namesake method/feature
*/
public function testForwardErrorController()
{
$this->expectException(ClassNotFoundException::class);
$this->uut->forward('UnknownController', 'foo');
}
/**
* Tests the namesake method/feature
*/
public function testForwardErrorAction()
{
$this->expectException(\InvalidArgumentException::class);
$this->uut->forward('HelloWorldController', 'unknown');
}
/**
* Tests the namesake method/feature
*/
public function testForwardErrorRequiredArg()
{
$this->expectException(ResourceNotFoundException::class);
$this->uut->forward('HelloWorldController', 'helloWorld');
}
}
}
namespace FF\Tests\Services\Dispatching\Controllers {
use FF\Controllers\AbstractController;
use FF\Services\Templating\TemplateRendererInterface;
use FF\Services\Templating\TwigRenderer;
use Symfony\Component\HttpFoundation\Response;
class HelloWorldController extends AbstractController
{
public function default(): Response
{
return new Response('default');
}
public function helloWorld(string $foo, string $bar = 'baz'): Response
{
return new Response($foo . '-' . $bar);
}
/**
* @return TemplateRendererInterface
*/
protected function getTemplateRenderer(): TemplateRendererInterface
{
return new TwigRenderer(['template-dir' => 'foo']);
}
}
}

View File

@ -0,0 +1,32 @@
default:
path: /default
defaults: { controller: 'HelloWorldController', action: 'default' }
with-args:
path: /with-args/{foo}/{bar}
defaults: { controller: 'HelloWorldController', action: 'helloWorld'}
omitted-args:
path: /omitted-args/{foo}/{bar}
defaults: { controller: 'HelloWorldController', action: 'helloWorld', bar: bar }
# errors
missing-controller:
path: /missing-controller
defaults: { action: 'index' }
missing-action:
path: /missing-action
defaults: { controller: 'HelloWorldController' }
unknown-controller:
path: /unknown-controller
defaults: { controller: 'UnknownController', action: 'index' }
unknown-action:
path: /unknown-action
defaults: { controller: 'HelloWorldController', action: 'unknown' }
missing-arg:
path: /missing-arg
defaults: { controller: 'HelloWorldController', action: 'helloWorld' }

View File

@ -8,7 +8,7 @@
*/
declare(strict_types=1);
namespace FF\Tests\Runtime;
namespace FF\Tests\Services\Runtime;
use FF\Events\Runtime\Exception;
use FF\Factories\ServicesFactory;