diff --git a/src/Controllers/AbstractController.php b/src/Controllers/AbstractController.php new file mode 100644 index 0000000..acd358b --- /dev/null +++ b/src/Controllers/AbstractController.php @@ -0,0 +1,74 @@ + + * @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; +} diff --git a/src/Events/Dispatching/PostDispatch.php b/src/Events/Dispatching/PostDispatch.php new file mode 100644 index 0000000..1fb58b3 --- /dev/null +++ b/src/Events/Dispatching/PostDispatch.php @@ -0,0 +1,89 @@ + + * @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; + } +} diff --git a/src/Events/Dispatching/PostRoute.php b/src/Events/Dispatching/PostRoute.php new file mode 100644 index 0000000..6aa5306 --- /dev/null +++ b/src/Events/Dispatching/PostRoute.php @@ -0,0 +1,91 @@ + + * @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; + } +} diff --git a/src/Events/Dispatching/PreDispatch.php b/src/Events/Dispatching/PreDispatch.php new file mode 100644 index 0000000..fcfca89 --- /dev/null +++ b/src/Events/Dispatching/PreDispatch.php @@ -0,0 +1,45 @@ + + * @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; + } +} diff --git a/src/Events/Dispatching/PreForward.php b/src/Events/Dispatching/PreForward.php new file mode 100644 index 0000000..d4f0577 --- /dev/null +++ b/src/Events/Dispatching/PreForward.php @@ -0,0 +1,73 @@ + + * @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; + } +} \ No newline at end of file diff --git a/src/Factories/ControllersFactory.php b/src/Factories/ControllersFactory.php new file mode 100644 index 0000000..3bb6468 --- /dev/null +++ b/src/Factories/ControllersFactory.php @@ -0,0 +1,81 @@ + + * @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; + } +} \ No newline at end of file diff --git a/src/Services/Dispatching/Dispatcher.php b/src/Services/Dispatching/Dispatcher.php new file mode 100644 index 0000000..73fad55 --- /dev/null +++ b/src/Services/Dispatching/Dispatcher.php @@ -0,0 +1,344 @@ + + * @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; + } +} diff --git a/src/Services/Dispatching/Exceptions/ControllerInspectionException.php b/src/Services/Dispatching/Exceptions/ControllerInspectionException.php new file mode 100644 index 0000000..809c56f --- /dev/null +++ b/src/Services/Dispatching/Exceptions/ControllerInspectionException.php @@ -0,0 +1,21 @@ + + * @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 +{ + +} \ No newline at end of file diff --git a/src/Services/Dispatching/Exceptions/IncompleteRouteException.php b/src/Services/Dispatching/Exceptions/IncompleteRouteException.php new file mode 100644 index 0000000..9f4b227 --- /dev/null +++ b/src/Services/Dispatching/Exceptions/IncompleteRouteException.php @@ -0,0 +1,21 @@ + + * @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 +{ + +} \ No newline at end of file diff --git a/src/Services/Exceptions/ResourceNotFoundException.php b/src/Services/Exceptions/ResourceNotFoundException.php new file mode 100644 index 0000000..1ee176d --- /dev/null +++ b/src/Services/Exceptions/ResourceNotFoundException.php @@ -0,0 +1,21 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Services\Exceptions; + +/** + * Class ResourceNotFoundException + * + * @package FF\Services\Exceptions + */ +class ResourceNotFoundException extends \RuntimeException +{ + +} \ No newline at end of file diff --git a/tests/Factories/ControllersFactoryTest.php b/tests/Factories/ControllersFactoryTest.php new file mode 100644 index 0000000..412f4de --- /dev/null +++ b/tests/Factories/ControllersFactoryTest.php @@ -0,0 +1,41 @@ + + * @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()); + } +} \ No newline at end of file diff --git a/tests/Services/Dispatching/DispatcherTest.php b/tests/Services/Dispatching/DispatcherTest.php new file mode 100644 index 0000000..2e72586 --- /dev/null +++ b/tests/Services/Dispatching/DispatcherTest.php @@ -0,0 +1,396 @@ + + * @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']); + } + } +} \ No newline at end of file diff --git a/tests/Services/Dispatching/routing/test-routing.yml b/tests/Services/Dispatching/routing/test-routing.yml new file mode 100644 index 0000000..4c85000 --- /dev/null +++ b/tests/Services/Dispatching/routing/test-routing.yml @@ -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' } \ No newline at end of file diff --git a/tests/Services/Runtime/ExceptionHandlerTest.php b/tests/Services/Runtime/ExceptionHandlerTest.php index 3e0a500..0a5e978 100644 --- a/tests/Services/Runtime/ExceptionHandlerTest.php +++ b/tests/Services/Runtime/ExceptionHandlerTest.php @@ -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;