├── tests ├── PresenterTest │ └── test.php ├── fixtures │ └── image.jpg ├── ActionTest │ └── Action.php ├── ActionFactoryTest.php ├── ExceptionTest.php ├── EventHandlers │ └── DispatchErrorHandlerTest.php ├── ActionTest.php ├── PresenterTest.php └── DispatchTest.php ├── src ├── EventDispatcher.php ├── EventHandlers │ ├── EventHandlerInterface.php │ └── DispatchErrorHandler.php ├── Exceptions │ ├── DispatchException.php │ ├── ExitDispatchException.php │ ├── PageNotFoundException.php │ ├── NotAuthorizedException.php │ └── ActionNotFoundException.php ├── ActionFactoryInterface.php ├── DispatchEvents.php ├── PresenterInterface.php ├── ActionFactory.php ├── ActionInterface.php ├── Events │ ├── PreDispatchEvent.php │ ├── PostDispatchEvent.php │ ├── DispatchErrorEvent.php │ ├── PrePresentEvent.php │ └── PostPresentEvent.php ├── Action.php ├── Config │ └── Common.php ├── Presenter.php ├── Exception.php └── Dispatch.php ├── .travis.yml ├── composer.json ├── phpunit.xml ├── LICENSE ├── .gitignore └── README.md /tests/PresenterTest/test.php: -------------------------------------------------------------------------------- 1 | Sup, ? 2 | -------------------------------------------------------------------------------- /tests/fixtures/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aolarchive/atc/HEAD/tests/fixtures/image.jpg -------------------------------------------------------------------------------- /src/EventDispatcher.php: -------------------------------------------------------------------------------- 1 | 'error', 'message' => 'internal error']; 10 | protected $http_code = 500; 11 | protected $view = 'errors/500'; 12 | } 13 | -------------------------------------------------------------------------------- /src/Exceptions/ExitDispatchException.php: -------------------------------------------------------------------------------- 1 | 'error', 'message' => 'page not found']; 10 | protected $http_code = 404; 11 | protected $view = 'errors/404'; 12 | } 13 | -------------------------------------------------------------------------------- /src/Exceptions/NotAuthorizedException.php: -------------------------------------------------------------------------------- 1 | 'error', 'message' => 'not authorized']; 10 | protected $http_code = 401; 11 | protected $view = 'errors/401'; 12 | } 13 | -------------------------------------------------------------------------------- /src/ActionFactoryInterface.php: -------------------------------------------------------------------------------- 1 | params; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Exceptions/ActionNotFoundException.php: -------------------------------------------------------------------------------- 1 | 'error', 'message' => 'internal error - action not found']; 11 | protected $http_code = 500; 12 | protected $view = 'errors/500'; 13 | } 14 | -------------------------------------------------------------------------------- /src/DispatchEvents.php: -------------------------------------------------------------------------------- 1 | 'bar']; 13 | $factory = new ActionFactory('Aol\\Atc\\Tests\\ActionTest\\'); 14 | 15 | /** @var Action $action */ 16 | $action = $factory->newInstance('Action', $params); 17 | 18 | $this->assertInstanceOf('Aol\Atc\ActionInterface', $action); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/ActionFactory.php: -------------------------------------------------------------------------------- 1 | namespace = $namespace; 12 | } 13 | 14 | /** 15 | * @inheritdoc 16 | */ 17 | public function newInstance($action, $params) 18 | { 19 | $class = $this->parseAction($action); 20 | if (!is_null($class)) { 21 | $class = new $class($params); 22 | } 23 | 24 | return $class; 25 | } 26 | 27 | /** 28 | * @param string $action 29 | * @return string 30 | */ 31 | protected function parseAction($action) 32 | { 33 | $class = $this->namespace . str_replace('.', '\\', $action); 34 | $class = class_exists($class) ? $class : null; 35 | 36 | return $class; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aol/atc", 3 | "description": "ATC is a small dispatching library for PHP built on Aura.Router and Symfony's HTTP Foundation", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Jake A. Smith", 8 | "email": "theman@jakeasmith.com" 9 | }, 10 | { 11 | "name": "Samantha Quiñones", 12 | "email": "samantha@tembies.com" 13 | } 14 | ], 15 | "minimum-stability": "stable", 16 | "require": { 17 | "aura/accept": "~2.0", 18 | "aura/router": "~2.0", 19 | "psr/log": "~1.0", 20 | "symfony/http-foundation": "~2.5", 21 | "symfony/event-dispatcher": "~2.6" 22 | }, 23 | "require-dev": { 24 | "phpunit/phpunit": "~4.3", 25 | "mockery/mockery": "~0.9", 26 | "aura/di": "~2.0" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Aol\\Atc\\": "src/", 31 | "Aol\\Atc\\Tests\\": "tests/" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/ActionInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | 9 | 10 | ./src 11 | ./src/Config 12 | ./src/Events 13 | 14 | 15 | 16 | 19 | 20 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/EventHandlers/DispatchErrorHandler.php: -------------------------------------------------------------------------------- 1 | Oops! Something went wrong.Oops! Looks like something went wrong.'; 17 | } 18 | 19 | /** 20 | * @param Event $event 21 | * @return mixed|Response 22 | */ 23 | public function __invoke(Event $event) 24 | { 25 | /** @var DispatchErrorEvent $event */ 26 | $exc = $event->getException(); 27 | return new Response( 28 | $event->isDebug() ? $exc->getMessage() : $this->getErrorHtml(), 29 | Response::HTTP_INTERNAL_SERVER_ERROR, 30 | ['content-type' => 'text/html'] 31 | ); 32 | } 33 | 34 | } -------------------------------------------------------------------------------- /src/Events/PreDispatchEvent.php: -------------------------------------------------------------------------------- 1 | request; 27 | } 28 | 29 | /** 30 | * @return ActionInterface 31 | */ 32 | public function getAction() 33 | { 34 | return $this->action; 35 | } 36 | 37 | /** 38 | * @param Request $request 39 | * @param ActionInterface $action 40 | */ 41 | public function __construct(Request $request, ActionInterface $action) 42 | { 43 | $this->request = $request; 44 | $this->action = $action; 45 | } 46 | } -------------------------------------------------------------------------------- /src/Events/PostDispatchEvent.php: -------------------------------------------------------------------------------- 1 | request; 28 | } 29 | 30 | /** 31 | * @param Request $request 32 | * @param ActionInterface $action 33 | * @param null $response 34 | */ 35 | public function __construct(Request $request, ActionInterface $action, $response = null) 36 | { 37 | $this->request = $request; 38 | $this->action = $action; 39 | $this->response = $response; 40 | } 41 | } -------------------------------------------------------------------------------- /tests/ExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($message, $data['message']); 20 | } 21 | 22 | public function testGetAllowedFormatsReturnsArrayOfStrings() 23 | { 24 | $exc = new Exception('foo'); 25 | $this->assertContainsOnly('string', $exc->getAllowedFormats()); 26 | } 27 | 28 | public function testGetView() 29 | { 30 | $this->assertEquals('errors/500', $this->exc->getView()); 31 | } 32 | 33 | public function testGetHttpCode() 34 | { 35 | $this->assertEquals(500, $this->exc->getHttpCode()); 36 | } 37 | 38 | public function setUp() 39 | { 40 | $this->exc = new Exception('foo'); 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 AOL Inc 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/Events/DispatchErrorEvent.php: -------------------------------------------------------------------------------- 1 | exception; 36 | } 37 | 38 | /** 39 | * @return Request 40 | */ 41 | public function getRequest() 42 | { 43 | return $this->request; 44 | } 45 | 46 | /** 47 | * @return boolean 48 | */ 49 | public function isDebug() 50 | { 51 | return $this->debug; 52 | } 53 | 54 | /** 55 | * @param \Exception $exception 56 | * @param Request $request 57 | * @param bool $debug 58 | */ 59 | function __construct(\Exception $exception, Request $request, $debug = false) 60 | { 61 | $this->exception = $exception; 62 | $this->request = $request; 63 | $this->debug = $debug; 64 | } 65 | } -------------------------------------------------------------------------------- /tests/EventHandlers/DispatchErrorHandlerTest.php: -------------------------------------------------------------------------------- 1 | Oops! Something went wrong.Oops! Looks like something went wrong.'; 16 | $event = new DispatchErrorEvent(new \Exception(), new Request([])); 17 | $handler = new DispatchErrorHandler(); 18 | $response = $handler($event); 19 | $this->assertEquals(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); 20 | $this->assertEquals($html, $response->getContent()); 21 | } 22 | 23 | public function testDebugInvocation() 24 | { 25 | $event = new DispatchErrorEvent(new \Exception('foo'), new Request([]), true); 26 | $handler = new DispatchErrorHandler(); 27 | $response = $handler($event); 28 | $this->assertEquals(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); 29 | $this->assertEquals('foo', $response->getContent()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Events/PrePresentEvent.php: -------------------------------------------------------------------------------- 1 | request = $request; 31 | $this->action = $action; 32 | $this->data = $data; 33 | } 34 | 35 | /** 36 | * @return ActionInterface 37 | */ 38 | public function getAction() 39 | { 40 | return $this->action; 41 | } 42 | 43 | public function getData() 44 | { 45 | return $this->data; 46 | } 47 | 48 | /** 49 | * @return Request 50 | */ 51 | public function getRequest() 52 | { 53 | return $this->request; 54 | } 55 | 56 | public function setData($data) 57 | { 58 | $this->data = $data; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Events/PostPresentEvent.php: -------------------------------------------------------------------------------- 1 | request = $request; 33 | $this->response = $response; 34 | $this->action = $action; 35 | } 36 | 37 | /** 38 | * @return ActionInterface 39 | */ 40 | public function getAction() 41 | { 42 | return $this->action; 43 | } 44 | 45 | /** 46 | * @return Request 47 | */ 48 | public function getRequest() 49 | { 50 | return $this->request; 51 | } 52 | 53 | /** 54 | * @return Response 55 | */ 56 | public function getResponse() 57 | { 58 | return $this->response; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Action.php: -------------------------------------------------------------------------------- 1 | params = $params; 29 | } 30 | 31 | /** 32 | * Takes a response object and returns an array of data. Formatting will be 33 | * handled by a Presenter. 34 | * 35 | * @param Request $request 36 | * @return Response|void 37 | */ 38 | abstract public function __invoke(Request $request); 39 | 40 | /** 41 | * @return int 42 | */ 43 | public function getHttpCode() 44 | { 45 | return $this->http_code; 46 | } 47 | 48 | /** 49 | * @inheritdoc 50 | */ 51 | public function getView() 52 | { 53 | return $this->view; 54 | } 55 | 56 | /** 57 | * @inheritdoc 58 | */ 59 | public function getAllowedFormats() 60 | { 61 | return $this->allowed_formats; 62 | } 63 | } -------------------------------------------------------------------------------- /tests/ActionTest.php: -------------------------------------------------------------------------------- 1 | data['content'] = $request->getContent(); 22 | } 23 | } 24 | 25 | class ActionTest extends \PHPUnit_Framework_TestCase 26 | { 27 | /** @var Request */ 28 | public $request; 29 | 30 | /** @var Action */ 31 | public $action; 32 | 33 | public function testGetView() 34 | { 35 | $this->assertEquals('', $this->action->getView()); 36 | } 37 | 38 | public function testGetHttpCode() 39 | { 40 | $this->assertEquals(200, $this->action->getHttpCode()); 41 | } 42 | 43 | public function testGetAllowedFormatsReturnsArrayOfStrings() 44 | { 45 | $this->assertContainsOnly('string', $this->action->getAllowedFormats()); 46 | } 47 | 48 | public function setUp() 49 | { 50 | $this->request = new Request(['message' => "I'm a chunky monkey from funky town"]); 51 | $this->action = new TestAction([]); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Config/Common.php: -------------------------------------------------------------------------------- 1 | set('Aol\\Atc\\Dispatch', $di->lazyNew('Aol\\Atc\\Dispatch')); 12 | 13 | $di->set('Aol\\Atc\\ActionFactory', $di->lazyNew('Aol\\Atc\\ActionFactory')); 14 | $di->set('Aol\\Atc\\ActionFactoryInterface', $di->lazyGet('Aol\\Atc\\ActionFactory')); 15 | 16 | $di->set('Aol\\Atc\\Presenter', $di->lazyNew('Aol\\Atc\\Presenter')); 17 | $di->set('Aol\\Atc\\PresenterInterface', $di->lazyGet('Aol\\Atc\\Presenter')); 18 | 19 | $di->set('Aol\\Atc\\EventDispatcher', $di->lazyNew('Aol\\Atc\\EventDispatcher')); 20 | $di->set('Aol\\Atc\\EventHandlers\\DispatchErrorHandler', $di->lazyNew('Aol\\Atc\\EventHandlers\\DispatchErrorHandler')); 21 | 22 | $di->params['Aol\\Atc\\Dispatch'] = [ 23 | 'router' => $di->lazyGet('Aura\\Router\\Router'), 24 | 'web_factory' => $di->lazyGet('Aura\\Web\\WebFactory'), 25 | 'action_factory' => $di->lazyGet('Aol\\Atc\\ActionFactoryInterface'), 26 | 'presenter' => $di->lazyGet('Aol\\Atc\\PresenterInterface'), 27 | 'event_dispatcher' => $di->lazyGet('Aol\\Atc\\EventDispatcher'), 28 | 'exception_handler' => $di->lazyGet('Aol\\Atc\\EventHandlers\\DispatchErrorHandler') 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Presenter.php: -------------------------------------------------------------------------------- 1 | view_dir = $view_dir; 23 | } 24 | 25 | /** 26 | * @inheritdoc 27 | */ 28 | public function getAvailableFormats() 29 | { 30 | return ['text/html', 'application/json', 'image/png']; 31 | } 32 | 33 | /** 34 | * @inheritdoc 35 | */ 36 | public function run($data, $format, $view = null) 37 | { 38 | switch ($format) { 39 | case 'application/json': 40 | $response = new JsonResponse($data); 41 | break; 42 | 43 | case 'image/gif': 44 | case 'image/jpeg': 45 | case 'image/png': 46 | $response = new BinaryFileResponse($data); 47 | break; 48 | 49 | case 'text/html': 50 | default: 51 | $file = $this->view_dir . $view . $this->view_ext; 52 | if (!file_exists($file)) { 53 | throw new Exception('View does not exist: ' . $view); 54 | } 55 | 56 | ob_start(); 57 | require $file; 58 | $response = new Response(ob_get_clean()); 59 | } 60 | 61 | return $response; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Exception.php: -------------------------------------------------------------------------------- 1 | 'error', 'message' => 'Internal Service Error']; 11 | protected $http_code = 500; 12 | protected $view = 'errors/500'; 13 | 14 | /** 15 | * Takes a response, modifies it, and returns it back for processing. All 16 | * data is expected to be assigned to the response content as an array. 17 | * Formatting will be handled by a separate process. 18 | * 19 | * @param Request $request 20 | * @return Response 21 | */ 22 | public function __invoke(Request $request) 23 | { 24 | $message = $this->getMessage(); 25 | if (!empty($message)) { 26 | $this->data['message'] = $message; 27 | } 28 | 29 | return $this->data; 30 | } 31 | 32 | /** 33 | * Returns the view name. 34 | * 35 | * @return string 36 | */ 37 | public function getView() 38 | { 39 | return $this->view; 40 | } 41 | 42 | /** 43 | * Returns the allowed response formats. Will be used by the 44 | * dispatcher to determine the correct response format. 45 | * 46 | * @see https://github.com/auraphp/Aura.Web/blob/develop-2/README-REQUEST.md#accept 47 | * 48 | * @return array 49 | */ 50 | public function getAllowedFormats() 51 | { 52 | return ['text/html', 'application/json', 'image/x-icon']; 53 | } 54 | 55 | /** 56 | * @return int 57 | */ 58 | public function getHttpCode() 59 | { 60 | return $this->http_code; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by http://www.gitignore.io 2 | 3 | ### PhpStorm ### 4 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm 5 | 6 | ## Directory-based project format 7 | .idea/ 8 | # if you remove the above rule, at least ignore user-specific stuff: 9 | # .idea/workspace.xml 10 | # .idea/tasks.xml 11 | # and these sensitive or high-churn files: 12 | # .idea/dataSources.ids 13 | # .idea/dataSources.xml 14 | # .idea/sqlDataSources.xml 15 | # .idea/dynamic.xml 16 | 17 | ## File-based project format 18 | *.ipr 19 | *.iws 20 | *.iml 21 | 22 | ## Additional for IntelliJ 23 | out/ 24 | 25 | # generated by mpeltonen/sbt-idea plugin 26 | .idea_modules/ 27 | 28 | # generated by JIRA plugin 29 | atlassian-ide-plugin.xml 30 | 31 | # generated by Crashlytics plugin (for Android Studio and Intellij) 32 | com_crashlytics_export_strings.xml 33 | 34 | 35 | ### Composer ### 36 | composer.phar 37 | vendor/ 38 | 39 | # Commit your application's lock file http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file 40 | # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file 41 | composer.lock 42 | 43 | 44 | ### OSX ### 45 | .DS_Store 46 | .AppleDouble 47 | .LSOverride 48 | 49 | # Icon must end with two \r 50 | Icon 51 | 52 | 53 | # Thumbnails 54 | ._* 55 | 56 | # Files that might appear on external disk 57 | .Spotlight-V100 58 | .Trashes 59 | 60 | # Directories potentially created on remote AFP share 61 | .AppleDB 62 | .AppleDesktop 63 | Network Trash Folder 64 | Temporary Items 65 | .apdisk 66 | 67 | ### Build & Output ### 68 | build/ 69 | output/ -------------------------------------------------------------------------------- /tests/PresenterTest.php: -------------------------------------------------------------------------------- 1 | presenter->run(['foo' => 'bar'], 'application/json', 'test'); 15 | 16 | $this->assertInstanceOf('Symfony\\Component\\HttpFoundation\\JsonResponse', $response); 17 | $this->assertEquals($response->getContent(), '{"foo":"bar"}'); 18 | } 19 | 20 | public function testHtmlResponse() 21 | { 22 | $response = $this->presenter->run(['name' => 'Tester'], 'text/html', 'test'); 23 | 24 | $this->assertInstanceOf('Symfony\\Component\\HttpFoundation\\Response', $response); 25 | $this->assertEquals($response->getContent(), 'Sup, Tester?' . PHP_EOL); 26 | } 27 | 28 | public function testBinaryFileResponse() 29 | { 30 | $file = __DIR__ . '/fixtures/image.jpg'; 31 | $response = $this->presenter->run($file, 'image/jpeg'); 32 | $this->assertInstanceOf('Symfony\\Component\\HttpFoundation\\BinaryFileResponse', $response); 33 | $this->assertFileEquals($file, $response->getFile()->getFileInfo()->getRealPath()); 34 | } 35 | 36 | /** 37 | * @expectedException \Exception 38 | * @expectedExceptionMessage View does not exist: file-that-doesnt-exist-so-forget-about-it 39 | */ 40 | public function testPresenterThrowsExceptionOnInvalidView() 41 | { 42 | $this->presenter->run(['foo' => 'bar'], 'text/html', 'file-that-doesnt-exist-so-forget-about-it'); 43 | } 44 | 45 | public function testGetAvailableFormatsReturnsArrayOfStrings() 46 | { 47 | $this->assertContainsOnly('string', $this->presenter->getAvailableFormats()); 48 | } 49 | 50 | protected function setUp() 51 | { 52 | $this->presenter = new Presenter(__DIR__ . '/PresenterTest/'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ATC, An Action-Based PHP Dispatching Library 2 | [![Build Status](https://travis-ci.org/aol/atc.png)](https://travis-ci.org/aol/atc) 3 | [![Latest Stable Version](https://poser.pugx.org/aol/atc/v/stable.png)](https://packagist.org/packages/aol/atc) 4 | [![Latest Unstable Version](https://poser.pugx.org/aol/atc/v/unstable.png)](https://packagist.org/packages/aol/atc) 5 | [![Total Downloads](https://poser.pugx.org/aol/atc/downloads.png)](https://packagist.org/packages/aol/atc) 6 | [![Code Climate](https://codeclimate.com/github/aol/atc/badges/gpa.svg)](https://codeclimate.com/github/aol/atc) 7 | 8 | ATC is a small dispatching library for PHP built on [Aura.Router](https://github.com/auraphp/Aura.Router) package and Symfony's [HttpFoundation](https://github.com/symfony/HttpFoundation) and [EventDispatcher](https://github.com/symfony/EventDispatcher). There are two things you should know about this library: 9 | 10 | 1. Every single route matches to a single Action class. 11 | 2. Exceptions thrown from the Action can implement the ActionInterface and become the new Action. 12 | 13 | **"What's an action?"** You can think of an Action as a Controller with a single method. Rather than being responsible for many different routes/pages it is only responsible for a single route. 14 | 15 | ## Usage 16 | The simplest possible way to use ATC is by just setting up a route and a corresponding action. Lets do a simple hello world for our home page with an Action called Index. 17 | 18 | ```php 19 | $router->addGet('Index', '/'); 20 | ``` 21 | 22 | ```php 23 | namespace Your\Namespace\Prefix; 24 | 25 | class Index extends \Aol\Atc\Action 26 | { 27 | public function __invoke(Request $request) 28 | { 29 | return Response::create('Hello world'); 30 | } 31 | } 32 | ``` 33 | 34 | Now any requests for the home page `/` will match the `Index` Action and send a "Hello world" back to the browser. Remember, this is just using Aura.Router so its pretty easy to build complex paths with named parameters. 35 | 36 | ```php 37 | $router->addGet('Index', '/{name}/'); 38 | ``` 39 | 40 | ```php 41 | class Index extends \Aol\Atc\Action 42 | { 43 | public function __invoke(Request $request) 44 | { 45 | return Response::create('Hello ' . $this->params['name']); 46 | } 47 | } 48 | ``` 49 | 50 | ### Leveraging the presenter 51 | While the method above would be great for simple API responses often you need to be able to do more complex things with templating libraries. ATC will always evaluate the return value of your Action and if it is a Symfony response object it will just send it straight to the browser. If it is not a response object it will take that data and hand it off to the Presenter to handle formatting. 52 | 53 | There is an interface for the Presenter class - which makes it very easy to drop in your templating package of choice - but out of the box ATC will handle JSON responses and basic PHP templates. By default it will render the HTML template, but if the request header for the content type is set to `application/json` it will send the json encoded version of your action response. You can always lock an Action down to a single response type by setting the `$allowed_formats` property. 54 | 55 | ```php 56 | class Index extends \Aol\Atc\Action 57 | { 58 | protected $allowed_formats = ['text/html']; 59 | protected $view = 'index'; 60 | 61 | public function __invoke(Request $request) 62 | { 63 | return ['name' => $this->params['name']]; 64 | } 65 | } 66 | ``` 67 | 68 | ```php 69 | 70 | Hello 71 | ``` 72 | 73 | ### Exceptions can be Actions too 74 | 75 | Any exception thrown from an ATC Action can immediately replace the current Action as long as implements the `ActionInterface`. Yep, you read that correctly, exceptions can be actions too. For example, if you throw the `NotAuthorizedException` from your Action the dispatcher will verify the exception implements the `ActionInterface` and then redispatch the request using the exception action. In this case it will respond with a `401` HTTP response code and look for a `errors/401` template to use in the presenter. 76 | 77 | ```php 78 | class Index extends \Aol\Atc\Action 79 | { 80 | public function __invoke(Request $request) 81 | { 82 | throw new \Aol\Atc\Exceptions\NotAuthorizedException; 83 | } 84 | } 85 | ``` 86 | 87 | You could also create custom exceptions within your own application. For example you could have a `NotSignedInException` that returns a `RedirectResponse` to the signin page: 88 | 89 | 90 | ```php 91 | class NotSignedInException extends \Aol\Atc\Exception 92 | { 93 | public function __invoke(Request $request) 94 | { 95 | return new RedirectResponse('/signin/'); 96 | } 97 | } 98 | ``` 99 | 100 | The flexibility is unparalleled and the possibilities are endless. 101 | 102 | ## Setup 103 | The dispatch class itself can be instantiated with just a few dependencies. 104 | 105 | ```php 106 | $router = (new \Aura\Router\RouterFactory())->newInstance(); 107 | $request = \Symfony\Component\HttpFoundation\Request::createFromGlobals(); 108 | $action_factory = new \Aol\Atc\ActionFactory('Your\\Namespace\\Prefix\\'); 109 | $presenter = new \Aol\Atc\Presenter(__DIR__ . '/your/view/dir/'); 110 | $event_dispatcher = new \Aol\Atc\EventDispatcher; 111 | $exception_handler = new \Aol\Atc\EventHandlers\DispatchErrorHandler; 112 | 113 | $dispatch = new \Aol\Atc\Dispatch( 114 | $router, 115 | $request, 116 | $action_factory, 117 | $presenter, 118 | $event_dispatcher, 119 | $exception_handler 120 | ); 121 | 122 | $response = $dispatch->run(); // Returns a symfony response object 123 | $response->send(); 124 | ``` 125 | 126 | ## Installing via Composer 127 | ATC supports PHP 5.4 or above. The recommended way to install ATC is through 128 | [Composer](http://getcomposer.org). 129 | 130 | ```bash 131 | # Install Composer 132 | curl -sS https://getcomposer.org/installer | php 133 | ``` 134 | 135 | Next, run the Composer command to install the latest stable version of ATC: 136 | 137 | ```bash 138 | composer require aol/atc 139 | ``` 140 | 141 | After installing, you need to require Composer's autoloader: 142 | 143 | ```php 144 | require 'vendor/autoload.php'; 145 | ``` 146 | 147 | ## FAQ 148 | **WTF is "ATC"?** It originally stood for "Air Traffic Control" but that's a lot of typing and doesn't roll off the tongue very well. 149 | 150 | **How do I inject dependencies into my Action classes?** The included ActionFactory is just the bare bones, but you can inject your own factory into the Dispatcher as long as it implements the `ActionFactoryInterface`. 151 | 152 | **What about Twig/Smarty/Blade/etc?** Like the ActionFactory you can inject your own presenter class into the dispatcher as well as long as it implements the `PresenterInterface`. There are a lot of possibilities here so if you do something cool let us know! 153 | 154 | **I think I found a bug, now what?** Please, open up an issue! Make sure you tell us what you expected, what happened instead, and include just enough code so that we can reproduce the issue. 155 | 156 | **Will you add feature X?** Maybe, but you'll never know until you ask. Open up an issue and we'll discuss it and if you're interested in submitting a pull request check out the Contributing section below. 157 | 158 | ## Contributing 159 | ATC is an open source project and pull requests are welcome if you'd like to contribute. Please include full unit test coverage and any relevant documentation updates with your PR. 160 | 161 | ## License 162 | ATC is licensed under the MIT License - see the LICENSE file for details 163 | -------------------------------------------------------------------------------- /src/Dispatch.php: -------------------------------------------------------------------------------- 1 | router = $router; 72 | $this->request = $request; 73 | $this->action_factory = $action_factory; 74 | $this->presenter = $presenter; 75 | $this->events = $event_dispatcher; 76 | 77 | $this->events->addListener(DispatchEvents::DISPATCH_ERROR, $exception_handler, DispatchEvents::LATE_EVENT); 78 | } 79 | 80 | /** 81 | * Dispatch to an action and handle errors 82 | * 83 | * @throws ExitDispatchException 84 | */ 85 | public function run() 86 | { 87 | $this->action = $this->getAction($this->request); 88 | return $this->process(); 89 | } 90 | 91 | protected function process() { 92 | // --------------- Dispatch 93 | $response = $this->dispatch($this->request); 94 | 95 | // --------------- Present 96 | if (!($response instanceof Response)) { 97 | $response = $this->present($response); 98 | } 99 | 100 | // --------------- Return 101 | return $response; 102 | } 103 | 104 | /** 105 | * @param Request $request 106 | * @param bool $events 107 | * @throws ExitDispatchException 108 | * @throws \Exception 109 | * @return mixed 110 | */ 111 | protected function dispatch(Request $request, $events = true) 112 | { 113 | $response = null; 114 | $action = $this->action; 115 | try { 116 | $events && $this->events->dispatch(DispatchEvents::PRE_DISPATCH, new PreDispatchEvent($this->request, $action)); 117 | $response = $action($request); 118 | $events && $this->events->dispatch(DispatchEvents::POST_DISPATCH, new PostDispatchEvent($this->request, $action, $response)); 119 | } catch(ExitDispatchException $e) { // Break out of ATC 120 | throw $e; 121 | } catch (ActionInterface $e) { // Re-dispatch if the exception implements ActionInterface (http://i.imgur.com/QKIfg.gif) 122 | $this->action = $e; 123 | $response = $this->dispatch($request, false); // Re-Dispatch without events 124 | } catch (\Exception $e) { // Nope. No chance of recovery here. Dispatch a default Action. 125 | $events && $this->events->dispatch(DispatchEvents::DISPATCH_ERROR, new DispatchErrorEvent($e, $request)); 126 | $this->action = new DispatchException(); 127 | $response = $this->dispatch($request, false); 128 | } 129 | return $response; 130 | } 131 | 132 | /** 133 | * @param $data 134 | * @throws Exception 135 | * @throws \Exception 136 | * @return Response 137 | */ 138 | protected function present($data) 139 | { 140 | $media_type = $this->getMedia($this->action)->getValue(); 141 | 142 | try { 143 | $pre_present_event = new PrePresentEvent($this->request, $this->action, $data); 144 | $this->events->dispatch(DispatchEvents::PRE_PRESENT, $pre_present_event); 145 | $data = $pre_present_event->getData(); 146 | 147 | $response = $this->presenter->run($data, $media_type, $this->action->getView()); 148 | $response->setStatusCode($this->action->getHttpCode()); 149 | 150 | $this->events->dispatch(DispatchEvents::POST_PRESENT, new PostPresentEvent($this->request, $response, $this->action)); 151 | } catch(Exception $exc) { 152 | $this->events->dispatch(DispatchEvents::DISPATCH_ERROR, new DispatchErrorEvent($exc, $this->request, $this->debug_enabled)); 153 | $this->action = $exc; 154 | $response = $this->process(); 155 | } catch (\Exception $exc) { 156 | $this->events->dispatch(DispatchEvents::DISPATCH_ERROR, new DispatchErrorEvent($exc, $this->request, $this->debug_enabled)); 157 | $this->action = new Exception('Unknown presentation error'); 158 | $response = $this->process(); 159 | } 160 | 161 | return $response; 162 | } 163 | 164 | /** 165 | * Return true or false if the target route is defined in the router 166 | * 167 | * @return bool 168 | */ 169 | public function routeExists() 170 | { 171 | if (!isset($this->matched_route)) { 172 | $this->matched_route = $this->matchRoute(); 173 | } 174 | 175 | return (bool)$this->matched_route; 176 | } 177 | 178 | /** 179 | * Toggle debugging on 180 | */ 181 | public function enableDebug() 182 | { 183 | $this->debug_enabled = true; 184 | } 185 | 186 | /** 187 | * Toggle debugging off 188 | */ 189 | public function disableDebug() 190 | { 191 | $this->debug_enabled = false; 192 | } 193 | 194 | /** 195 | * Searches for the target route on the router, returning it or false if it does not exist. 196 | * 197 | * @return \Aura\Router\Route|false 198 | */ 199 | protected function matchRoute() 200 | { 201 | $this->matched_route = $this->router->match( 202 | $this->request->getPathInfo(), 203 | $this->request->server->all() 204 | ); 205 | 206 | return $this->matched_route; 207 | } 208 | 209 | /** 210 | * Get the media type for the request 211 | * 212 | * @param ActionInterface $action 213 | * @return \Aura\Accept\Media\MediaValue|false 214 | * @throws Exception 215 | */ 216 | protected function getMedia(ActionInterface $action) 217 | { 218 | $available = array_intersect($action->getAllowedFormats(), $this->presenter->getAvailableFormats()); 219 | 220 | //@todo don't mix Di and randomly calling factories 221 | $accept_factory = new AcceptFactory($_SERVER); 222 | $accept = $accept_factory->newInstance(); 223 | $media = $accept->negotiateMedia($available); 224 | if (empty($media)) { 225 | throw new Exception('Could not find a compatible content type for response'); 226 | } 227 | 228 | return $media; 229 | } 230 | 231 | /** 232 | * Attempt to match a route and instantiate an Action, bailing out with an exception on failure 233 | * 234 | * @param Request $request 235 | * @return ActionInterface 236 | * @throws ActionNotFoundException 237 | * @throws PageNotFoundException 238 | */ 239 | protected function getAction(Request $request) 240 | { 241 | // Get the matched route. 242 | $route = $this->matched_route ?: $this->matchRoute(); 243 | if (!$route) { 244 | $exc = new PageNotFoundException(); 245 | $this->events->dispatch(DispatchEvents::DISPATCH_ERROR, new DispatchErrorEvent($exc, $request)); 246 | return $exc; 247 | } 248 | 249 | $params = $route->params; 250 | 251 | // Get the appropriate action. 252 | $action = $this->action_factory->newInstance($params['action'], $params); 253 | if (!$action) { 254 | $exc = new ActionNotFoundException('Action not found: ' . $params['action']); 255 | $this->events->dispatch(DispatchEvents::DISPATCH_ERROR, new DispatchErrorEvent($exc, $request)); 256 | return $exc; 257 | } 258 | 259 | return $action; 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /tests/DispatchTest.php: -------------------------------------------------------------------------------- 1 | getProperty('debug_enabled'); 67 | $p->setAccessible(true); 68 | $value = $p->getValue($dispatch); 69 | return $value; 70 | }; 71 | 72 | $this->dispatch->enableDebug(); 73 | $this->assertEquals(true, $read_debug_state($this->dispatch)); 74 | 75 | $this->dispatch->disableDebug(); 76 | $this->assertEquals(false, $read_debug_state($this->dispatch)); 77 | } 78 | 79 | /** 80 | * @param $expected 81 | * @param $match 82 | * @dataProvider matchRouteProvider 83 | */ 84 | public function testRouting($expected, $match) 85 | { 86 | $this->router->shouldReceive('match')->once()->withAnyArgs()->andReturn($match); 87 | $this->request->shouldIgnoreMissing(); 88 | $this->request->server = $this->getBag('ServerBag'); 89 | $this->request->server->shouldIgnoreMissing([]); 90 | $this->assertEquals($expected, $this->dispatch->routeExists()); 91 | } 92 | 93 | private function setUpTestRun($case) 94 | { 95 | // Matcher 96 | $this->router->shouldReceive('match')->once()->withAnyArgs()->andReturn($case['route']); 97 | 98 | // Request 99 | $this->request->shouldIgnoreMissing(); 100 | $this->request->server = $this->getBag('ServerBag'); 101 | $this->request->server->shouldIgnoreMissing([]); 102 | 103 | $this->presenter->shouldReceive('getAvailableFormats')->once()->withNoArgs()->andReturn($case['formats']); 104 | 105 | $this->action_factory->shouldReceive('newInstance')->once()->withAnyArgs()->andReturn($case['action']); 106 | 107 | $this->event_dispatcher->shouldReceive('dispatch')->once()->with(DispatchEvents::PRE_DISPATCH, \Mockery::any())->andReturnNull(); 108 | $this->event_dispatcher->shouldReceive('dispatch')->with(DispatchEvents::POST_DISPATCH, \Mockery::any())->andReturnNull(); 109 | $this->event_dispatcher->shouldReceive('dispatch')->with(DispatchEvents::PRE_PRESENT, \Mockery::any())->andReturnNull(); 110 | $this->event_dispatcher->shouldReceive('dispatch')->with(DispatchEvents::POST_PRESENT, \Mockery::any())->andReturnNull(); 111 | 112 | } 113 | 114 | public function testDispatchWhenPageNotFound() 115 | { 116 | $case = [ 117 | 'action' => new Action([]), 118 | 'status_code' => Response::HTTP_NOT_FOUND, 119 | 'route' =>false, 120 | 'response_obj' => new Response(), 121 | 'formats' => ['text/html'], 122 | ]; 123 | $this->setUpTestRun($case); 124 | $this->presenter->shouldReceive('run')->once()->withAnyArgs()->andReturn($case['response_obj']); 125 | $this->event_dispatcher->shouldReceive('dispatch')->once()->with(DispatchEvents::DISPATCH_ERROR, \Mockery::type('Aol\\Atc\\Events\\DispatchErrorEvent')); 126 | 127 | $response = $this->dispatch->run(); 128 | $this->assertInstanceOf('Symfony\\Component\\HttpFoundation\\Response', $response); 129 | $this->assertEquals($case['status_code'], $response->getStatusCode()); 130 | } 131 | 132 | public function testDispatchWhenActionThrowsException() 133 | { 134 | $case = [ 135 | 'action' => new ExceptionThrowingDummyAction([]), 136 | 'status_code' => Response::HTTP_INTERNAL_SERVER_ERROR, 137 | 'route' => $this->getMatchedRoute(), 138 | 'response_obj' => new Response(), 139 | 'formats' => ['text/html'], 140 | ]; 141 | $this->setUpTestRun($case); 142 | $case['route'] = $this->setUpRoute($case['route']); 143 | 144 | $this->presenter->shouldReceive('run')->once()->withAnyArgs()->andReturn($case['response_obj']); 145 | 146 | $this->event_dispatcher->shouldReceive('dispatch')->once()->with(DispatchEvents::DISPATCH_ERROR, \Mockery::type('Aol\\Atc\\Events\\DispatchErrorEvent')); 147 | $response = $this->dispatch->run(); 148 | $this->assertInstanceOf('Symfony\\Component\\HttpFoundation\\Response', $response); 149 | } 150 | 151 | public function testDispatchWhenActionThrowsDispatchableException() 152 | { 153 | $case = [ 154 | 'action' => new DispatchableExceptionThrowingDummyAction([]), 155 | 'status_code' => Response::HTTP_INTERNAL_SERVER_ERROR, 156 | 'route' => $this->getMatchedRoute(), 157 | 'response_obj' => new Response(), 158 | 'formats' => ['text/html'], 159 | ]; 160 | $this->setUpTestRun($case); 161 | $case['route'] = $this->setUpRoute($case['route']); 162 | 163 | $this->presenter->shouldReceive('run')->once()->withAnyArgs()->andReturn($case['response_obj']); 164 | 165 | $this->event_dispatcher->shouldReceive('dispatch')->once()->with(DispatchEvents::DISPATCH_ERROR, \Mockery::type('Aol\\Atc\\Events\\DispatchErrorEvent')); 166 | $response = $this->dispatch->run(); 167 | $this->assertInstanceOf('Symfony\\Component\\HttpFoundation\\Response', $response); 168 | } 169 | 170 | public function testDispatchWhenPresenterThrowsException() 171 | { 172 | $case = [ 173 | 'action' => new Action([]), 174 | 'status_code' => Response::HTTP_INTERNAL_SERVER_ERROR, 175 | 'route' => $this->getMatchedRoute(), 176 | 'response_obj' => new \Exception(), 177 | 'formats' => ['text/html'] 178 | ]; 179 | $this->setUpTestRun($case); 180 | $case['route'] = $this->setUpRoute($case['route']); 181 | $this->presenter->shouldReceive('run')->once()->withAnyArgs()->andThrow($case['response_obj']); 182 | $this->presenter->shouldReceive('run')->once()->withAnyArgs()->andReturn(new Response('', 500)); 183 | 184 | $this->event_dispatcher->shouldReceive('dispatch')->once()->with(DispatchEvents::DISPATCH_ERROR, \Mockery::type('Aol\\Atc\\Events\\DispatchErrorEvent')); 185 | $response = $this->dispatch->run(); 186 | $this->assertEquals($case['status_code'], $response->getStatusCode()); 187 | } 188 | 189 | public function testDispatchWhenMediaNegotiationsFail() 190 | { 191 | $case = [ 192 | 'action' => new Action([]), 193 | 'status_code' => Response::HTTP_INTERNAL_SERVER_ERROR, 194 | 'route' => $this->getMatchedRoute(), 195 | 'response_obj' => new Response(), 196 | 'formats' => [] 197 | ]; 198 | $this->setUpTestRun($case); 199 | $case['route'] = $this->setUpRoute($case['route']); 200 | $this->presenter->shouldReceive('run')->once()->withAnyArgs()->andReturn($case['response_obj']); 201 | 202 | 203 | $this->setExpectedException('Exception'); 204 | $this->event_dispatcher->shouldReceive('dispatch')->once()->with(DispatchEvents::DISPATCH_ERROR, \Mockery::type('Aol\\Atc\\Events\\DispatchErrorEvent')); 205 | $response = $this->dispatch->run(); 206 | $this->assertEquals($case['status_code'], $response->getStatusCode()); 207 | } 208 | 209 | public function testDispatchWhenActionNotFound() 210 | { 211 | $case = [ 212 | 'action' => null, 213 | 'status_code' => Response::HTTP_INTERNAL_SERVER_ERROR, 214 | 'route' => $this->getMatchedRoute(), 215 | 'response_obj' => new Response(), 216 | 'formats' => [] 217 | ]; 218 | $this->setUpTestRun($case); 219 | $case['route'] = $this->setUpRoute($case['route']); 220 | $this->presenter->shouldReceive('run')->once()->withAnyArgs()->andReturn($case['response_obj']); 221 | 222 | 223 | $this->setExpectedException('Exception'); 224 | $this->event_dispatcher->shouldReceive('dispatch')->once()->with(DispatchEvents::DISPATCH_ERROR, \Mockery::type('Aol\\Atc\\Events\\DispatchErrorEvent')); 225 | $response = $this->dispatch->run(); 226 | $this->assertEquals($case['status_code'], $response->getStatusCode()); 227 | } 228 | 229 | private function setUpRoute($route) 230 | { 231 | $c = new \ReflectionClass($route); 232 | $p = $c->getProperty('params'); 233 | $p->setAccessible(true); 234 | $p->setValue($route, ['action' => 'foo']); 235 | return $route; 236 | } 237 | 238 | private function getMatchedRoute() 239 | { 240 | return new Route(new Regex(), '/', 'test-route'); 241 | } 242 | 243 | public function matchRouteProvider() 244 | { 245 | return [ 246 | [true, new Route(new Regex(), '/', 'test-route')], 247 | [false, false] 248 | ]; 249 | } 250 | 251 | public function setUp() 252 | { 253 | $this->router = \Mockery::mock('Aura\\Router\\Router'); 254 | $this->request = $this->setUpRequest(); 255 | $this->action_factory = $this->setUpActionFactory(); 256 | $this->presenter = $this->setUpPresenter(); 257 | $this->event_dispatcher = $this->setUpEventDispatcher(); 258 | $this->exception_handler = $this->setUpExceptionHandler(); 259 | $this->event_dispatcher->shouldReceive('addListener')->andReturnNull(); 260 | $this->dispatch = new Dispatch($this->router, $this->request, $this->action_factory, $this->presenter, $this->event_dispatcher, $this->exception_handler); 261 | } 262 | 263 | private function getBag($bag) 264 | { 265 | return \Mockery::mock('Symfony\\Component\\HttpFoundation\\' . $bag); 266 | } 267 | 268 | private function setUpExceptionHandler() 269 | { 270 | return new DispatchErrorHandler(); 271 | } 272 | 273 | private function setUpEventDispatcher() 274 | { 275 | $dispatcher = \Mockery::Mock('Aol\\Atc\\EventDispatcher'); 276 | return $dispatcher; 277 | } 278 | private function setUpRequest() 279 | { 280 | $request = \Mockery::mock('Symfony\\Component\\HttpFoundation\\Request'); 281 | return $request; 282 | } 283 | 284 | private function setUpPresenter() 285 | { 286 | $presenter = \Mockery::mock('Aol\\Atc\\PresenterInterface'); 287 | return $presenter; 288 | } 289 | 290 | private function setUpActionFactory() 291 | { 292 | $factory = \Mockery::mock('Aol\\Atc\\ActionFactoryInterface'); 293 | return $factory; 294 | } 295 | } 296 | --------------------------------------------------------------------------------