From 4f5cb2bc9a5797f6f357586da5af9d3a641dbec1 Mon Sep 17 00:00:00 2001 From: Alannah Kearney Date: Sat, 14 Nov 2020 22:43:53 +1000 Subject: [PATCH] feat: GET requests can reach controllers. Removed Controller event. Headers set correctly on GET requests. Fixes #26 and closes #25 --- events/event.controller.php | 147 ---------------------- src/Api_Framework/JsonFrontend.php | 97 ++++++++++++-- src/Api_Framework/JsonFrontendPage.php | 167 +++++++++++++++---------- src/Api_Framework/Models/PageCache.php | 2 - src/Includes/Functions.php | 66 +++++++--- 5 files changed, 240 insertions(+), 239 deletions(-) delete mode 100755 events/event.controller.php diff --git a/events/event.controller.php b/events/event.controller.php deleted file mode 100755 index 047360b..0000000 --- a/events/event.controller.php +++ /dev/null @@ -1,147 +0,0 @@ - 'API Framework: Controller', - 'author' => [ - 'name' => 'Alannah Kearney', - 'website' => 'http://alannahkearney.com', - 'email' => 'hi@alannahkearney.com', - ], - 'release-date' => '2019-06-12', - 'trigger-condition' => 'POST|PUT|PATCH|DELETE', - ]; - } - - public function load(): void - { - // This ensures the composer autoloader for the framework is included - Extension_API_Framework::init(); - - try { - $request = Api_Framework\JsonRequest::createFromGlobals(); - - // We want to allow non-JSON requests in certain situations. - } catch (Api_Framework\Exceptions\RequestJsonInvalidException $ex) { - $request = HttpFoundation\Request::createFromGlobals(); - - // The input is discarded, but we need to emulate the json - // ParameterBag object. - $request->json = new HttpFoundation\ParameterBag(); - } - - // Event controller only responds to certain methods. GET is handled - // by the data sources - if ('GET' == $request->getMethod()) { - return; - } - - if (0 === strpos($request->headers->get('Content-Type'), 'application/json')) { - $data = json_decode($request->getContent(), true, 512, JSON_BIGINT_AS_STRING); - $request->request->replace(is_array($data) ? $data : []); - } - - // #5 - Use the full page path to generate the controller class name - // #7 - Use a PSR-4 folder structure and build the namespace accordingly - // #14 - Each page has a parent-path (somtimes this is / when at root). - // In order to find the correct controller path, we need to combine - // current-page with parent-path - - // Grab out the "current-page". Our controller will always be named - // using this - $controllerName = 'Controller'.ucfirst(trim( - Api_Framework\JsonFrontend::instance() - ->Page() - ->Params()['current-page'] - )); - - // Next, do some processing over the "parent-path" (if there is one) to - // determine the folder path. - $currentPagePath = trim( - Api_Framework\JsonFrontend::instance() - ->Page() - ->Params()['parent-path'], - '/' - ); - $parts = array_map('ucfirst', preg_split("@\/@", $currentPagePath)); - $controllerPath = implode($parts, '\\').'\\'; - - $controllerPath = sprintf( - "pointybeard\Symphony\Extensions\Api_Framework\Controllers\\%s%s", - ltrim($controllerPath, '\\'), - $controllerName - ); - - // #6 - Check if the controller exists before trying to include it. - // Throw an exception if it cannot be located. - if (!class_exists($controllerPath)) { - throw new Api_Framework\Exceptions\ControllerNotFoundException($controllerPath); - } - - $controller = new $controllerPath(); - - // Make sure the controller extends the AbstractController class - if (!($controller instanceof Api_Framework\AbstractController)) { - throw new Api_Framework\Exceptions\ControllerNotValidException( - sprintf( - "'%s' is not a valid controller. Check implementation conforms to AbstractController and ControllerInterface", - $controllerPath - ) - ); - } - - $method = strtolower($request->getMethod()); - - if (!method_exists($controller, $method)) { - throw new Api_Framework\Exceptions\MethodNotAllowedException($request->getMethod()); - } - - $canValidate = ($controller instanceof Api_Framework\Interfaces\JsonSchemaValidationInterface); - - // Run any controller pre-flight code - $controller->execute($request); - - // Prepare the response. - $response = new JsonResponse(); - $response->headers->set('Content-Type', 'application/json'); - $response->setEncodingOptions( - Api_Framework\JsonFrontend::instance()->getEncodingOptions() - ); - - // Find any request or response schemas to apply - if (true == $canValidate) { - $schemas = $controller->schemas($request->getMethod()); - - // Validate the request. We dont care about the returned data - $controller->validate( - $request->request->all(), - $schemas->request - ); - } - - // Run the controller's method that corresponds to the request method - $response = $controller->$method($request, $response); - - // Validate the response. We dont care about the returned data - if (true == $canValidate) { - $controller->validate($response->getContent(), $schemas->response); - } - - $response->send(); - exit; - } - - public static function documentation(): string - { - return '

Event Controller

Handles passing off work to controllers depending on what has been requested.

'; - } -} diff --git a/src/Api_Framework/JsonFrontend.php b/src/Api_Framework/JsonFrontend.php index 780dfda..33fc0ae 100644 --- a/src/Api_Framework/JsonFrontend.php +++ b/src/Api_Framework/JsonFrontend.php @@ -5,6 +5,7 @@ namespace pointybeard\Symphony\Extensions\Api_Framework; use Symphony; +use Symfony\Component\HttpFoundation; /** * This extends the core Symphony class to give us a vector to @@ -45,17 +46,18 @@ protected function __construct() * Code duplication from core Frontend class, however it returns an * instance of JsonFrontendPage rather than FrontendPage. */ - public function display(string $page): string + public function display(string $page) { + $resolvedPage = (new \FrontendPage())->resolvePage($page); // GET Requests on pages that are of type 'cacheable' can be cached. $isCacheable = ( - \Extension_API_Framework::isCacheEnabled() + true == \Extension_API_Framework::isCacheEnabled() && 'GET' == $_SERVER['REQUEST_METHOD'] - && is_array($resolvedPage) - && in_array('cacheable', $resolvedPage['type']) + && true == is_array($resolvedPage) + && true == in_array(JsonFrontendPage::PAGE_TYPE_CACHEABLE, $resolvedPage['type']) ); self::$_page = $isCacheable @@ -63,15 +65,94 @@ public function display(string $page): string : new JsonFrontendPage() ; - self::$_page->addHeaderToPage( + $this->Page()->addHeaderToPage( 'X-API-Framework-Page-Renderer', array_pop(explode('\\', get_class(self::$_page))) ); - Symphony::ExtensionManager()->notifyMembers('FrontendInitialised', '/frontend/'); - $output = self::$_page->generate($page); + \Symphony::ExtensionManager()->notifyMembers('FrontendInitialised', '/frontend/'); + + // Get the controller + try { + $controller = $this->Page()->getController(); + } catch(Exceptions\ControllerNotFoundException $ex) { + // It's okay if controller ends up as nothing + } + + $this->Page()->addHeaderToPage( + 'X-API-Framework-Controller', + true == ($controller instanceof AbstractController) + ? get_class($controller) + : "none" + ); + + // Built a HTTP request object + try { + $request = JsonRequest::createFromGlobals(); + + // We want to allow non-JSON requests in certain situations. + } catch (Exceptions\RequestJsonInvalidException $ex) { + $request = HttpFoundation\Request::createFromGlobals(); + + // The input is discarded, but we need to emulate the json + // ParameterBag object. + $request->json = new HttpFoundation\ParameterBag(); + } + + // There are a couple of pathways here: + // 1. There is no controller so it should just + // pass on the normal page rendering process + if(false == ($controller instanceof AbstractController)) { + return self::$_page->generate($page); + + // 2a. There is a page controller + // but it does not respond to GET requests AND it indicates a 403 + // should not be thrown, or + } elseif (HttpFoundation\Request::METHOD_GET == $request->getMethod() + && false == $controller->respondsToRequestMethod($request->getMethod()) + && false == $controller->throwMethodNotAllowedExceptionOnGet() + ) { + return self::$_page->generate($page); + } + + // 2b. There is a page controller for this page and it takes over + // generating the output, or + if (false == $controller->respondsToRequestMethod($request->getMethod())) { + throw new Exceptions\MethodNotAllowedException($request->getMethod()); + } + + // Run any controller pre-flight code + $controller->execute($request); + + // Prepare the response. + $response = new HttpFoundation\JsonResponse(); + $response->headers->set('Content-Type', 'application/json'); + $response->setEncodingOptions( + JsonFrontend::instance()->getEncodingOptions() + ); + + // Find any request or response schemas to apply + if (true == $canValidate) { + $schemas = $controller->schemas($request->getMethod()); + + // Validate the request. We dont care about the returned data + $controller->validate( + $request->request->all(), + $schemas->request + ); + } + + // Run the controller's method that corresponds to the request method + $response = call_user_func([$controller, strtolower($request->getMethod())], $request, $response); + //$response = $controller->$method($request, $response); + + // Validate the response. We dont care about the returned data + if (true == $canValidate) { + $controller->validate($response->getContent(), $schemas->response); + } + + return $response; - return $output; } /** diff --git a/src/Api_Framework/JsonFrontendPage.php b/src/Api_Framework/JsonFrontendPage.php index a0ef021..4bc0cea 100644 --- a/src/Api_Framework/JsonFrontendPage.php +++ b/src/Api_Framework/JsonFrontendPage.php @@ -10,6 +10,9 @@ */ class JsonFrontendPage extends \FrontendPage { + const PAGE_TYPE_JSON = 'JSON'; + const PAGE_TYPE_CACHEABLE = 'cacheable'; + /** * Constructor function sets the `$is_logged_in` variable, calls * XSLTPage constructor (bypassing FrontendPage::__construct()) and @@ -25,86 +28,118 @@ public function __construct() $this->is_logged_in = JsonFrontend::instance()->isLoggedIn(); } + public function getController(): AbstractController + { + // #5 - Use the full page path to generate the controller class name + // #7 - Use a PSR-4 folder structure and build the namespace accordingly + // #14 - Each page has a parent-path (somtimes this is / when at root). + + // In order to find the correct controller path, we need to combine + // current-page with parent-path + + // Determine the current page path + $page = (new PageResolver((string)getCurrentPage()))->resolve(); + + $controllerName = 'Controller'.ucfirst(trim($page->handle)); + + // Next, do some processing over the parent path (if there is one) to + // determine the folder path. + $parts = array_map('ucfirst', preg_split("@\/@", trim((string)$page->path, '/'))); + $controllerPath = implode($parts, '\\').'\\'; + + $controllerPath = sprintf( + __NAMESPACE__ . "\\Controllers\\%s%s", + ltrim($controllerPath, '\\'), + $controllerName + ); + + // #6 - Check if the controller exists before trying to include it. + // Throw an exception if it cannot be located. + if (false == class_exists($controllerPath)) { + throw new Exceptions\ControllerNotFoundException($controllerPath); + } + + $controller = new $controllerPath(); + + // Make sure the controller extends the AbstractController class + if (false == ($controller instanceof AbstractController)) { + throw new Exceptions\ControllerNotValidException("'{$controllerPath}' is not a valid controller."); + } + + return $controller; + } + // Accessor method for rendering the page headers. public function renderHeaders(): void { \Page::__renderHeaders(); } - public function addRenderTimeToHeaders(): void + public function generateParent($page = null): string { - \Profiler::instance()->sample('API JSON Rendering complete.'); - - $profile = (object) array_combine( - ['message', 'elapsed', 'created', 'type', 'queries', 'memory'], - \Symphony::Profiler()->retrieveLast() - ); - - JsonFrontend::Page()->addHeaderToPage( - 'X-API-Framework-Render-Time', - number_format($profile->elapsed, 4) - ); + return parent::generate($page); } public function generate($page = null): string { - $output = parent::generate($page); + + $output = $this->generateParent($page); + cleanup_session_cookies(); - if (in_array('JSON', $this->pageData()['type'])) { - // Load the output into a SimpleXML Container and convert to JSON - try { - $xml = new \SimpleXMLElement($output, LIBXML_NOCDATA); - - // Convert the XML to a plain array. This step is necessary as we cannot - // use JSON_PRETTY_PRINT directly on a SimpleXMLElement object - $outputArray = json_decode(json_encode($xml), true); - - // Get the transforer object ready. Other extensions will - // add their transormations to this. - $transformer = new Transformer(); - - /* - * Allow other extensions to add their own transformers - */ - \Symphony::ExtensionManager()->notifyMembers( - 'APIFrameworkJSONRendererAppendTransformations', - '/frontend/', - ['transformer' => &$transformer] - ); - - // Apply transformations - $outputArray = $transformer->run($outputArray); - - // Now put the array through a json_encode - $output = json_encode( - $outputArray, - JsonFrontend::instance()->getEncodingOptions() - ); - - $this->addRenderTimeToHeaders(); - } catch (\Exception $e) { - // This happened because the input was not valid XML. This could - // occur for a few reasons, but there are two scenarios - // we are interested in. - - // 1) This is a devkit page (profile, debug etc). We want the data - // to be passed through and displayed rather than converted into - // JSON. There is no easy way in Symphony to tell if a devkit has - // control over the page, so instead lets inspect the output for - // any signs a devkit is rendering the page. - - // 2) It is actually bad XML. In that case we need to let the error - // bubble through. - - // Currently the easiest method is to check for the devkit.min.css - // in the output. This may fail in the furture if this file is - // renamed or moved. - if (!preg_match("@\/symphony\/assets\/css\/devkit.min.css@", $output)) { - throw $e; - } + + // Load the output into a SimpleXML Container and convert to JSON + try { + $xml = new \SimpleXMLElement($output, LIBXML_NOCDATA); + + // Convert the XML to a plain array. This step is necessary as we cannot + // use JSON_PRETTY_PRINT directly on a SimpleXMLElement object + $outputArray = json_decode(json_encode($xml), true); + + // Get the transforer object ready. Other extensions will + // add their transormations to this. + $transformer = new Transformer(); + + /* + * Allow other extensions to add their own transformers + */ + \Symphony::ExtensionManager()->notifyMembers( + 'APIFrameworkJSONRendererAppendTransformations', + '/frontend/', + ['transformer' => &$transformer] + ); + + // Apply transformations + $outputArray = $transformer->run($outputArray); + + // Now put the array through a json_encode + $output = json_encode( + $outputArray, + JsonFrontend::instance()->getEncodingOptions() + ); + + \Profiler::instance()->sample('API JSON Rendering complete.'); + + } catch (\Exception $e) { + // This happened because the input was not valid XML. This could + // occur for a few reasons, but there are two scenarios + // we are interested in. + + // 1) This is a devkit page (profile, debug etc). We want the data + // to be passed through and displayed rather than converted into + // JSON. There is no easy way in Symphony to tell if a devkit has + // control over the page, so instead lets inspect the output for + // any signs a devkit is rendering the page. + + // 2) It is actually bad XML. In that case we need to let the error + // bubble through. + + // Currently the easiest method is to check for the devkit.min.css + // in the output. This may fail in the furture if this file is + // renamed or moved. + if (false == preg_match("@\/symphony\/assets\/css\/devkit.min.css@", $output)) { + throw $e; } } - return $output; } } diff --git a/src/Api_Framework/Models/PageCache.php b/src/Api_Framework/Models/PageCache.php index b2ee151..c3bdc00 100644 --- a/src/Api_Framework/Models/PageCache.php +++ b/src/Api_Framework/Models/PageCache.php @@ -165,8 +165,6 @@ public function render() ); } - Api_Framework\JsonFrontend::Page()->addRenderTimeToHeaders(); - return $this->contents; } diff --git a/src/Includes/Functions.php b/src/Includes/Functions.php index 75ffba3..7eaea7f 100644 --- a/src/Includes/Functions.php +++ b/src/Includes/Functions.php @@ -4,6 +4,8 @@ namespace pointybeard\Symphony\Extensions\Api_Framework; +use Symfony\Component\HttpFoundation; + if (!function_exists(__NAMESPACE__.'\renderer_json')) { function renderer_json(?string $mode): void { @@ -18,6 +20,9 @@ function renderer_json(?string $mode): void ExceptionHandler::initialise(); ErrorHandler::initialise(); + // This ensures the composer autoloader for the framework is included + \Extension_API_Framework::init(); + // #1808 if (isset($_SERVER['HTTP_MOD_REWRITE'])) { throw new Exception('mod_rewrite is required, however is not enabled.'); @@ -25,21 +30,50 @@ function renderer_json(?string $mode): void $output = JsonFrontend::instance()->display((string)getCurrentPage()); - /* - * This is just prior to the page headers being re-rendered - * @delegate JsonFrontendPreRenderHeaders - * @param string $context - * '/json_frontend/' - */ - \Symphony::ExtensionManager()->notifyMembers( - 'JsonFrontendPreRenderHeaders', - '/json_frontend/', - [] - ); - - // This will render new headers. - JsonFrontend::Page()->renderHeaders(); - - echo $output; + if(true == (JsonFrontend::Page() instanceof JsonFrontendPage)) { + + /* + * This is just prior to the page headers being re-rendered + * @delegate JsonFrontendPreRenderHeaders + * @param string $context + * '/json_frontend/' + */ + \Symphony::ExtensionManager()->notifyMembers( + 'JsonFrontendPreRenderHeaders', + '/json_frontend/', + [] + ); + + $profile = (object) array_combine( + ['message', 'elapsed', 'created', 'type', 'queries', 'memory'], + \Symphony::Profiler()->retrieveLast() + ); + + JsonFrontend::Page()->addHeaderToPage( + 'X-API-Framework-Render-Time', + number_format($profile->elapsed, 4) + ); + + if(true == ($output instanceof HttpFoundation\Response)) { + + // Transfer all the page headers over to the Response object + foreach(JsonFrontend::Page()->headers() as $h) { + $output->headers->set(...explode(":", $h['header'], 2)); + } + $output->send(); + + } else { + // This will render new headers. + JsonFrontend::Page()->renderHeaders(); + echo $output; + } + + } else { + echo $output; + } + + // Make sure nothing happens after calling this method. It shouldn't + // but just in case. + exit; } }