themichaelhall/bluemvc-core

View on GitHub
src/Base/AbstractApplication.php

Summary

Maintainability
B
5 hrs
Test Coverage
<?php

/**
 * This file is a part of the bluemvc-core package.
 *
 * Read more at https://bluemvc.com/
 */

declare(strict_types=1);

namespace BlueMvc\Core\Base;

use BlueMvc\Core\Collections\HeaderCollection;
use BlueMvc\Core\Exceptions\InvalidControllerClassException;
use BlueMvc\Core\Exceptions\InvalidFilePathException;
use BlueMvc\Core\Http\StatusCode;
use BlueMvc\Core\Interfaces\ApplicationInterface;
use BlueMvc\Core\Interfaces\ControllerInterface;
use BlueMvc\Core\Interfaces\ErrorControllerInterface;
use BlueMvc\Core\Interfaces\PluginInterface;
use BlueMvc\Core\Interfaces\RequestInterface;
use BlueMvc\Core\Interfaces\ResponseInterface;
use BlueMvc\Core\Interfaces\RouteInterface;
use BlueMvc\Core\Interfaces\ViewRendererInterface;
use BlueMvc\Core\Traits\CustomItemsTrait;
use DataTypes\System\Exceptions\FilePathLogicException;
use DataTypes\System\FilePath;
use DataTypes\System\FilePathInterface;
use Throwable;

/**
 * Abstract class representing a BlueMvc main application.
 *
 * @since 1.0.0
 */
abstract class AbstractApplication implements ApplicationInterface
{
    use CustomItemsTrait;

    /**
     * Constructs the application.
     *
     * @since 1.0.0
     *
     * @param FilePathInterface $documentRoot The document root.
     *
     * @throws InvalidFilePathException If the $documentRoot parameter is invalid.
     */
    public function __construct(FilePathInterface $documentRoot)
    {
        $this->setDocumentRoot($documentRoot);
        $this->routes = [];
        $this->tempPath = null;
        $this->viewRenderers = [];
        $this->viewPaths = [];
        $this->isDebug = false;
        $this->errorControllerClass = null;
        $this->plugins = [];
    }

    /**
     * Adds a plugin.
     *
     * @since 1.0.0
     *
     * @param PluginInterface $plugin The plugin.
     */
    public function addPlugin(PluginInterface $plugin): void
    {
        $this->plugins[] = $plugin;
    }

    /**
     * Adds a route.
     *
     * @since 1.0.0
     *
     * @param RouteInterface $route The route.
     */
    public function addRoute(RouteInterface $route): void
    {
        $this->routes[] = $route;
    }

    /**
     * Adds a view renderer.
     *
     * @since 1.0.0
     *
     * @param ViewRendererInterface $viewRenderer The view renderer.
     */
    public function addViewRenderer(ViewRendererInterface $viewRenderer): void
    {
        $this->viewRenderers[] = $viewRenderer;
    }

    /**
     * Returns the document root.
     *
     * @since 1.0.0
     *
     * @return FilePathInterface The document root.
     */
    public function getDocumentRoot(): FilePathInterface
    {
        return $this->documentRoot;
    }

    /**
     * Returns the error controller class name or null if not specified.
     *
     * @since 1.0.0
     *
     * @return string|null The error controller class name or null if not specified.
     */
    public function getErrorControllerClass(): ?string
    {
        return $this->errorControllerClass;
    }

    /**
     * Returns the plugins.
     *
     * @since 1.0.0
     *
     * @return PluginInterface[] The plugins.
     */
    public function getPlugins(): array
    {
        return $this->plugins;
    }

    /**
     * Returns the routes.
     *
     * @since 1.0.0
     *
     * @return RouteInterface[] The routes.
     */
    public function getRoutes(): array
    {
        return $this->routes;
    }

    /**
     * Returns the path to the application-specific temporary directory.
     *
     * @since 1.0.0
     *
     * @return FilePathInterface The path to the application-specific temporary directory.
     */
    public function getTempPath(): FilePathInterface
    {
        if ($this->tempPath === null) {
            // Generate a default temporary directory by document root.
            $this->tempPath = FilePath::parseAsDirectory(sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'bluemvc' . DIRECTORY_SEPARATOR . sha1($this->documentRoot->__toString()));
            self::ensureDirectoryExists($this->tempPath);
        }

        return $this->tempPath;
    }

    /**
     * Returns The view files path.
     *
     * @since      1.0.0
     * @deprecated Use getViewPaths instead.
     *
     * @return FilePathInterface The view files path.
     */
    public function getViewPath(): FilePathInterface
    {
        if (count($this->viewPaths) === 0) {
            return $this->documentRoot;
        }

        return $this->viewPaths[0];
    }

    /**
     * Returns the view files paths.
     *
     * @since 3.0.0
     *
     * @return FilePathInterface[] The view files paths.
     */
    public function getViewPaths(): array
    {
        if (count($this->viewPaths) === 0) {
            return [$this->documentRoot];
        }

        return $this->viewPaths;
    }

    /**
     * Returns the view renderers.
     *
     * @since 1.0.0
     *
     * @return ViewRendererInterface[] The view renderers.
     */
    public function getViewRenderers(): array
    {
        return $this->viewRenderers;
    }

    /**
     * Returns true if in debug mode, false otherwise.
     *
     * @since 1.0.0
     *
     * @return bool True if in debug mode, false otherwise.
     */
    public function isDebug(): bool
    {
        return $this->isDebug;
    }

    /**
     * Runs a request.
     *
     * @since 1.0.0
     *
     * @param RequestInterface  $request  The request.
     * @param ResponseInterface $response The response.
     */
    public function run(RequestInterface $request, ResponseInterface $response): void
    {
        $this->doRun($request, $response);
        $response->output();
    }

    /**
     * Sets the error controller class name.
     *
     * @since 1.0.0
     *
     * @param string $errorControllerClass The error controller class name.
     *
     * @throws InvalidControllerClassException If the class name is not a valid controller class.
     */
    public function setErrorControllerClass(string $errorControllerClass): void
    {
        if (!is_a($errorControllerClass, ErrorControllerInterface::class, true)) {
            throw new InvalidControllerClassException('"' . $errorControllerClass . '" is not a valid error controller class.');
        }

        $this->errorControllerClass = $errorControllerClass;
    }

    /**
     * Sets the path to the application-specific temporary directory.
     *
     * @since 1.0.0
     *
     * @param FilePathInterface $tempPath The path to the application-specific temporary directory.
     *
     * @throws InvalidFilePathException If the $tempPath parameter is invalid.
     */
    public function setTempPath(FilePathInterface $tempPath): void
    {
        if (!$tempPath->isDirectory()) {
            throw new InvalidFilePathException('Temp path "' . $tempPath . '" is not a directory.');
        }

        try {
            $this->tempPath = $this->documentRoot->withFilePath($tempPath);
        } catch (FilePathLogicException $e) {
            throw new InvalidFilePathException($e->getMessage());
        }
    }

    /**
     * Sets the view files path.
     *
     * @since 1.0.0
     *
     * @param FilePathInterface $viewPath The view files path.
     *
     * @throws InvalidFilePathException If the $viewPath parameter is invalid.
     */
    public function setViewPath(FilePathInterface $viewPath): void
    {
        $this->viewPaths = [];
        $this->addViewPath($viewPath);
    }

    /**
     * Sets the view files paths.
     *
     * @since 3.0.0
     *
     * @param FilePathInterface[] $viewPaths The view files paths.
     *
     * @throws InvalidFilePathException If the $viewPaths parameter is invalid.
     */
    public function setViewPaths(array $viewPaths): void
    {
        $this->viewPaths = [];
        foreach ($viewPaths as $viewPath) {
            $this->addViewPath($viewPath);
        }
    }

    /**
     * Sets the debug mode.
     *
     * @since 1.0.0
     *
     * @param bool $isDebug The debug mode.
     */
    protected function setDebug(bool $isDebug): void
    {
        $this->isDebug = $isDebug;
    }

    /**
     * Sets the document root.
     *
     * @since 1.0.0
     *
     * @param FilePathInterface $documentRoot The document root.
     *
     * @throws InvalidFilePathException If the $documentRoot parameter is invalid.
     */
    protected function setDocumentRoot(FilePathInterface $documentRoot): void
    {
        if (!$documentRoot->isDirectory()) {
            throw new InvalidFilePathException('Document root "' . $documentRoot . '" is not a directory.');
        }

        if (!$documentRoot->isAbsolute()) {
            throw new InvalidFilePathException('Document root "' . $documentRoot . '" is not an absolute path.');
        }

        $this->documentRoot = $documentRoot;
    }

    /**
     * Adds a view file path to tha list of view file paths.
     *
     * @param FilePathInterface $viewPath The view file path.
     *
     * @throws InvalidFilePathException If the $viewPath parameter is invalid.
     */
    private function addViewPath(FilePathInterface $viewPath): void
    {
        if (!$viewPath->isDirectory()) {
            throw new InvalidFilePathException('View path "' . $viewPath . '" is not a directory.');
        }

        try {
            $this->viewPaths[] = $this->documentRoot->withFilePath($viewPath);
        } catch (FilePathLogicException $e) {
            throw new InvalidFilePathException($e->getMessage());
        }
    }

    /**
     * Runs a request.
     *
     * @param RequestInterface  $request  The request.
     * @param ResponseInterface $response The response.
     */
    private function doRun(RequestInterface $request, ResponseInterface $response): void
    {
        $throwable = null;

        foreach ($this->plugins as $plugin) {
            if ($plugin->onPreRequest($this, $request, $response)) {
                return;
            }
        }

        try {
            $this->handleRequest($request, $response);
        } catch (Throwable $throwable) {
            $this->throwableToResponse($throwable, $response);
        }

        if ($response->getStatusCode()->isError()) {
            $this->handleError($request, $response, $throwable);
        }

        foreach ($this->plugins as $plugin) {
            if ($plugin->onPostRequest($this, $request, $response)) {
                return;
            }
        }
    }

    /**
     * Handles a request.
     *
     * @param RequestInterface  $request  The request.
     * @param ResponseInterface $response The response.
     */
    private function handleRequest(RequestInterface $request, ResponseInterface $response): void
    {
        foreach ($this->routes as $route) {
            $routeMatch = $route->matches($request);

            if ($routeMatch === null) {
                continue;
            }

            $controllerClass = $routeMatch->getControllerClassName();
            $controller = new $controllerClass();

            /** @var ControllerInterface $controller */
            $controller->processRequest($this, $request, $response, $routeMatch->getAction(), $routeMatch->getParameters());

            return;
        }

        $response->setStatusCode(new StatusCode(StatusCode::NOT_FOUND));
    }

    /**
     * Handles an error.
     *
     * @param RequestInterface  $request   The request.
     * @param ResponseInterface $response  The response.
     * @param Throwable|null    $throwable The throwable or null if no throwable.
     */
    private function handleError(RequestInterface $request, ResponseInterface $response, Throwable $throwable = null): void
    {
        $errorControllerClass = $this->getErrorControllerClass();

        if ($errorControllerClass !== null) {
            /** @var ErrorControllerInterface $errorController */
            $errorController = new $errorControllerClass();
            if ($throwable !== null) {
                $errorController->setThrowable($throwable);
            }

            try {
                $errorController->processRequest($this, $request, $response, strval($response->getStatusCode()->getCode()), []);
            } catch (Throwable $throwable) {
                $this->throwableToResponse($throwable, $response);
            }
        }
    }

    /**
     * Outputs a throwable to a response.
     *
     * @param Throwable         $throwable The throwable.
     * @param ResponseInterface $response  The response.
     */
    private function throwableToResponse(Throwable $throwable, ResponseInterface $response): void
    {
        $response->setStatusCode(new StatusCode(StatusCode::INTERNAL_SERVER_ERROR));
        $response->setHeaders(new HeaderCollection());
        $response->setContent($this->throwableToHtml($throwable));
    }

    /**
     * Converts a throwable to html.
     *
     * @param Throwable $throwable The throwable.
     *
     * @return string The html.
     */
    private function throwableToHtml(Throwable $throwable): string
    {
        if (!$this->isDebug) {
            return '';
        }

        return
            "<!DOCTYPE html>\n" .
            "<html lang=\"en\">\n" .
            "   <head>\n" .
            "      <meta charset=\"utf-8\">\n" .
            '      <title>' . htmlentities($throwable->getMessage()) . "</title>\n" .
            "      <style>\n" .
            "         html, body, h1, p, code, pre {margin:0; padding:0; font-size:16px; font-family:Arial, Helvetica, sans-serif; color:#555;}\n" .
            "         h1 {font-size:2em; margin:.5em; color:#338;}\n" .
            "         p, pre {font-size:1em; margin:1em;}\n" .
            "         pre, code {font-family:monospace; color:#000;}\n" .
            "      </style>\n" .
            "   </head>\n" .
            "   <body>\n" .
            '      <h1>' . htmlentities($throwable->getMessage()) . "</h1>\n" .
            "      <p>\n" .
            '         <code>' . htmlentities(get_class($throwable)) . '</code> was thrown from <code>' . htmlentities($throwable->getFile()) . ':' . htmlentities(strval($throwable->getLine())) . '</code> with message <code>' . htmlentities($throwable->getMessage()) . ' (' . htmlentities(strval($throwable->getCode())) . ")</code>\n" .
            "      </p>\n" .
            '      <pre>' . htmlentities($throwable->getTraceAsString()) . "</pre>\n" .
            "   </body>\n" .
            "</html>\n";
    }

    /**
     * Ensures that a specified directory exists.
     *
     * @param FilePathInterface $directory The directory.
     */
    private static function ensureDirectoryExists(FilePathInterface $directory): void
    {
        if (!is_dir($directory->__toString())) {
            mkdir($directory->__toString(), 0777, true);
        }
    }

    /**
     * @var FilePathInterface The document root.
     */
    private FilePathInterface $documentRoot;

    /**
     * @var bool True if in debug mode, false otherwise.
     */
    private bool $isDebug;

    /**
     * @var RouteInterface[] The routes.
     */
    private array $routes;

    /**
     * @var FilePathInterface|null The path to the application-specific temporary directory or null if not yet initialized.
     */
    private ?FilePathInterface $tempPath;

    /**
     * @var FilePathInterface[] The view files paths.
     */
    private array $viewPaths;

    /**
     * @var ViewRendererInterface[] The view renderers.
     */
    private array $viewRenderers;

    /**
     * @var string|null The error controller class name.
     */
    private ?string $errorControllerClass;

    /**
     * @var PluginInterface[] The plugins.
     */
    private array $plugins;
}