diff --git a/README.md b/README.md index 7fb6ab7..4961468 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,10 @@ We have our own documentation website that is built with Flight (naturally). Lea Chat with us on Matrix IRC [#flight-php-framework:matrix.org](https://matrix.to/#/#flight-php-framework:matrix.org) +# Upgrading From v2 + +If you have a current project on v2, you should be able to upgrade to v2 with no issues depending on how your project was built. If there are any issues with upgrade, they are documented in the [migrating to v3](https://docs.flightphp.com/learn/migrating-to-v3) documentation page. It is the intention of Flight to maintain longterm stability of the project and to not add rewrites with major version changes. + # Requirements > [!IMPORTANT] @@ -43,6 +47,10 @@ Chat with us on Matrix IRC [#flight-php-framework:matrix.org](https://matrix.to/ The framework also supports PHP >8. +# Roadmap + +To see the current and future roadmap for the Flight Framework, visit the [project roadmap](https://github.com/orgs/flightphp/projects/1/views/1) + # License Flight is released under the [MIT](http://docs.flightphp.com/license) license. diff --git a/composer.json b/composer.json index 6c61fde..2be3e4b 100644 --- a/composer.json +++ b/composer.json @@ -41,11 +41,13 @@ }, "require-dev": { "ext-pdo_sqlite": "*", + "league/container": "^4.2", + "level-2/dice": "^4.0", "phpstan/extension-installer": "^1.3", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^9.5", "rregeer/phpunit-coverage-check": "^0.3.1", - "squizlabs/php_codesniffer": "^3.9" + "squizlabs/php_codesniffer": "^3.8" }, "config": { "allow-plugins": { @@ -56,14 +58,13 @@ }, "scripts": { "test": "phpunit", - "test-coverage": "rm clover.xml && XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage --coverage-clover=clover.xml && vendor/bin/coverage-check clover.xml 100", + "test-coverage": "rm -f clover.xml && XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage --coverage-clover=clover.xml && vendor/bin/coverage-check clover.xml 100", "test-server": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server/", "test-server-v2": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server-v2/", "test-coverage:win": "del clover.xml && phpunit --coverage-html=coverage --coverage-clover=clover.xml && coverage-check clover.xml 100", "lint": "phpstan --no-progress -cphpstan.neon", "beautify": "phpcbf --standard=phpcs.xml", - "phpcs": "phpcs --standard=phpcs.xml -n", - "post-install-cmd": ["php -r \"if (!file_exists('phpcs.xml')) copy('phpcs.xml.dist', 'phpcs.xml');\""] + "phpcs": "phpcs --standard=phpcs.xml -n" }, "suggest": { "latte/latte": "Latte template engine", diff --git a/flight/Engine.php b/flight/Engine.php index 2a4e10c..62342b6 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -27,21 +27,20 @@ use flight\net\Route; * # Core methods * @method void start() Starts engine * @method void stop() Stops framework and outputs current response - * @method void halt(int $code = 200, string $message = '', bool $actuallyExit = true) - * Stops processing and returns a given response. + * @method void halt(int $code = 200, string $message = '', bool $actuallyExit = true) Stops processing and returns a given response. * * # Routing - * @method Route route(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method Route route(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a URL to a callback function with all applicable methods * @method void group(string $pattern, callable $callback, array $group_middlewares = []) * Groups a set of routes together under a common prefix. - * @method Route post(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method Route post(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a POST URL to a callback function. - * @method Route put(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method Route put(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a PUT URL to a callback function. - * @method Route patch(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method Route patch(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a PATCH URL to a callback function. - * @method Route delete(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method Route delete(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a DELETE URL to a callback function. * @method Router router() Gets router * @method string getUrl(string $alias) Gets a url from an alias @@ -64,10 +63,14 @@ use flight\net\Route; * # HTTP caching * @method void etag(string $id, ('strong'|'weak') $type = 'strong') Handles ETag HTTP caching. * @method void lastModified(int $time) Handles last modified HTTP caching. + * + * phpcs:disable PSR2.Methods.MethodDeclaration.Underscore */ class Engine { - /** @var array List of methods that can be extended in the Engine class. */ + /** + * @var array List of methods that can be extended in the Engine class. + */ private const MAPPABLE_METHODS = [ 'start', 'stop', 'route', 'halt', 'error', 'notFound', 'render', 'redirect', 'etag', 'lastModified', 'json', 'jsonp', @@ -135,6 +138,9 @@ class Engine $this->dispatcher->reset(); } + // Add this class to Dispatcher + $this->dispatcher->setEngine($this); + // Register default components $this->loader->register('request', Request::class); $this->loader->register('response', Response::class); @@ -214,6 +220,18 @@ class Engine $this->error($e); } + /** + * Registers the container handler + * + * @param callable|object $containerHandler Callback function or PSR-11 Container object that sets the container and how it will inject classes + * + * @return void + */ + public function registerContainerHandler($containerHandler): void + { + $this->dispatcher->setContainerHandler($containerHandler); + } + /** * Maps a callback to a framework method. * @@ -296,7 +314,7 @@ class Engine */ public function get(?string $key = null) { - if ($key === null) { + if (null === $key) { return $this->vars; } @@ -342,9 +360,8 @@ class Engine */ public function clear(?string $key = null): void { - if ($key === null) { + if (null === $key) { $this->vars = []; - return; } @@ -365,7 +382,7 @@ class Engine * Processes each routes middleware. * * @param Route $route The route to process the middleware for. - * @param 'before'|'after' $event_name If this is the before or after method. + * @param string $event_name If this is the before or after method. */ protected function processMiddleware(Route $route, string $event_name): bool { @@ -533,6 +550,11 @@ class Engine $dispatched = false; } + // HEAD requests should be identical to GET requests but have no body + if ($request->method === 'HEAD') { + $response->clearBody(); + } + if ($failed_middleware_check === true) { $this->halt(403, 'Forbidden', empty(getenv('PHPUNIT_TEST'))); } elseif ($dispatched === false) { @@ -548,7 +570,9 @@ class Engine public function _error(Throwable $e): void { $msg = sprintf( - '

500 Internal Server Error

%s (%s)

%s
', + '

500 Internal Server Error

' . + '

%s (%s)

' . + '
%s
', $e->getMessage(), $e->getCode(), $e->getTraceAsString() @@ -573,13 +597,14 @@ class Engine * @param ?int $code HTTP status code * * @throws Exception + * @deprecated 3.5.3 This method will be removed in v4 */ public function _stop(?int $code = null): void { $response = $this->response(); if (!$response->sent()) { - if ($code !== null) { + if (null !== $code) { $response->status($code); } @@ -595,11 +620,11 @@ class Engine * Routes a URL to a callback function. * * @param string $pattern URL pattern to match - * @param callable $callback Callback function + * @param callable|string $callback Callback function * @param bool $pass_route Pass the matching route object to the callback * @param string $alias The alias for the route */ - public function _route(string $pattern, callable $callback, bool $pass_route = false, string $alias = ''): Route + public function _route(string $pattern, $callback, bool $pass_route = false, string $alias = ''): Route { return $this->router()->map($pattern, $callback, $pass_route, $alias); } @@ -620,10 +645,10 @@ class Engine * Routes a URL to a callback function. * * @param string $pattern URL pattern to match - * @param callable $callback Callback function + * @param callable|string $callback Callback function or string class->method * @param bool $pass_route Pass the matching route object to the callback */ - public function _post(string $pattern, callable $callback, bool $pass_route = false, string $route_alias = ''): void + public function _post(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): void { $this->router()->map('POST ' . $pattern, $callback, $pass_route, $route_alias); } @@ -632,10 +657,10 @@ class Engine * Routes a URL to a callback function. * * @param string $pattern URL pattern to match - * @param callable $callback Callback function + * @param callable|string $callback Callback function or string class->method * @param bool $pass_route Pass the matching route object to the callback */ - public function _put(string $pattern, callable $callback, bool $pass_route = false, string $route_alias = ''): void + public function _put(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): void { $this->router()->map('PUT ' . $pattern, $callback, $pass_route, $route_alias); } @@ -644,15 +669,11 @@ class Engine * Routes a URL to a callback function. * * @param string $pattern URL pattern to match - * @param callable $callback Callback function + * @param callable|string $callback Callback function or string class->method * @param bool $pass_route Pass the matching route object to the callback */ - public function _patch( - string $pattern, - callable $callback, - bool $pass_route = false, - string $route_alias = '' - ): void { + public function _patch(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): void + { $this->router()->map('PATCH ' . $pattern, $callback, $pass_route, $route_alias); } @@ -660,15 +681,11 @@ class Engine * Routes a URL to a callback function. * * @param string $pattern URL pattern to match - * @param callable $callback Callback function + * @param callable|string $callback Callback function or string class->method * @param bool $pass_route Pass the matching route object to the callback */ - public function _delete( - string $pattern, - callable $callback, - bool $pass_route = false, - string $route_alias = '' - ): void { + public function _delete(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): void + { $this->router()->map('DELETE ' . $pattern, $callback, $pass_route, $route_alias); } @@ -710,10 +727,14 @@ class Engine */ public function _redirect(string $url, int $code = 303): void { - $base = $this->get('flight.base_url') ?? $this->request()->base; + $base = $this->get('flight.base_url'); + + if (null === $base) { + $base = $this->request()->base; + } // Append base url to redirect url - if ($base !== '/' && strpos($url, '://') === false) { + if ('/' !== $base && false === strpos($url, '://')) { $url = $base . preg_replace('#/+#', '/', '/' . $url); } @@ -735,9 +756,8 @@ class Engine */ public function _render(string $file, ?array $data = null, ?string $key = null): void { - if ($key !== null) { + if (null !== $key) { $this->view()->set($key, $this->view()->fetch($file, $data)); - return; } @@ -813,7 +833,7 @@ class Engine */ public function _etag(string $id, string $type = 'strong'): void { - $id = (($type === 'weak') ? 'W/' : '') . $id; + $id = (('weak' === $type) ? 'W/' : '') . $id; $this->response()->header('ETag', '"' . str_replace('"', '\"', $id) . '"'); diff --git a/flight/Flight.php b/flight/Flight.php index 4609c60..a04ad80 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use flight\core\Dispatcher; use flight\Engine; use flight\net\Request; use flight\net\Response; @@ -10,6 +9,8 @@ use flight\net\Router; use flight\template\View; use flight\net\Route; +require_once __DIR__ . '/autoload.php'; + /** * The Flight class is a static representation of the framework. * @@ -17,24 +18,29 @@ use flight\net\Route; * @copyright Copyright (c) 2011, Mike Cao * * # Core methods - * @method static void start() Starts the framework. - * @method static void path(string $path) Adds a path for autoloading classes. - * @method static void stop(?int $code = null) Stops the framework and sends a response. - * @method static void halt(int $code = 200, string $message = '', bool $actuallyExit = true) + * @method static void start() Starts the framework. + * @method static void path(string $path) Adds a path for autoloading classes. + * @method static void stop(?int $code = null) Stops the framework and sends a response. + * @method static void halt(int $code = 200, string $message = '', bool $actuallyExit = true) * Stop the framework with an optional status code and message. + * @method static void register(string $name, string $class, array $params = [], ?callable $callback = null) + * Registers a class to a framework method. + * @method static void unregister(string $methodName) + * Unregisters a class to a framework method. + * @method static void registerContainerHandler(callable|object $containerHandler) Registers a container handler. * * # Routing - * @method static Route route(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method static Route route(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Maps a URL pattern to a callback with all applicable methods. * @method static void group(string $pattern, callable $callback, callable[] $group_middlewares = []) * Groups a set of routes together under a common prefix. - * @method static Route post(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method static Route post(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a POST URL to a callback function. - * @method static Route put(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method static Route put(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a PUT URL to a callback function. - * @method static Route patch(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method static Route patch(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a PATCH URL to a callback function. - * @method static Route delete(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method static Route delete(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a DELETE URL to a callback function. * @method static Router router() Returns Router instance. * @method static string getUrl(string $alias, array $params = []) Gets a url from an alias @@ -99,34 +105,6 @@ class Flight { } - /** - * Registers a class to a framework method. - * - * # Usage example: - * ``` - * Flight::register('user', User::class); - * - * Flight::user(); # <- Return a User instance - * ``` - * - * @param string $name Static method name - * @param class-string $class Fully Qualified Class Name - * @param array $params Class constructor params - * @param ?Closure(T $instance): void $callback Perform actions with the instance - * - * @template T of object - */ - public static function register($name, $class, $params = [], $callback = null): void - { - static::__callStatic('register', [$name, $class, $params, $callback]); - } - - /** Unregisters a class. */ - public static function unregister(string $methodName): void - { - static::__callStatic('unregister', [$methodName]); - } - /** * Handles calls to static methods. * @@ -138,15 +116,15 @@ class Flight */ public static function __callStatic(string $name, array $params) { - require_once __DIR__ . '/autoload.php'; - - return Dispatcher::invokeMethod([self::app(), $name], $params); + return self::app()->{$name}(...$params); } /** @return Engine Application instance */ public static function app(): Engine { if (!self::$initialized) { + require_once __DIR__ . '/autoload.php'; + self::setEngine(new Engine()); self::$initialized = true; } diff --git a/flight/core/Dispatcher.php b/flight/core/Dispatcher.php index 6af0d66..6a167a8 100644 --- a/flight/core/Dispatcher.php +++ b/flight/core/Dispatcher.php @@ -6,8 +6,8 @@ namespace flight\core; use Closure; use Exception; +use flight\Engine; use InvalidArgumentException; -use ReflectionClass; use TypeError; /** @@ -25,6 +25,12 @@ class Dispatcher public const FILTER_AFTER = 'after'; private const FILTER_TYPES = [self::FILTER_BEFORE, self::FILTER_AFTER]; + /** @var mixed $containerException Exception message if thrown by setting the container as a callable method */ + protected $containerException = null; + + /** @var ?Engine $engine Engine instance */ + protected ?Engine $engine = null; + /** @var array Mapped events. */ protected array $events = []; @@ -35,6 +41,30 @@ class Dispatcher */ protected array $filters = []; + /** + * This is a container for the dependency injection. + * + * @var callable|object|null + */ + protected $containerHandler = null; + + /** + * Sets the dependency injection container handler. + * + * @param callable|object $containerHandler Dependency injection container + * + * @return void + */ + public function setContainerHandler($containerHandler): void + { + $this->containerHandler = $containerHandler; + } + + public function setEngine(Engine $engine): void + { + $this->engine = $engine; + } + /** * Dispatches an event. * @@ -80,10 +110,10 @@ class Dispatcher $requestedMethod = $this->get($eventName); if ($requestedMethod === null) { - throw new Exception("Event '$eventName' isn't found."); + throw new Exception("Event '{$eventName}' isn't found."); } - return $requestedMethod(...$params); + return $this->execute($requestedMethod, $params); } /** @@ -194,7 +224,7 @@ class Dispatcher * * @throws Exception If an event throws an `Exception` or if `$filters` contains an invalid filter. */ - public static function filter(array $filters, array &$params, &$output): void + public function filter(array $filters, array &$params, &$output): void { foreach ($filters as $key => $callback) { if (!is_callable($callback)) { @@ -219,22 +249,33 @@ class Dispatcher * @return mixed Function results * @throws Exception If `$callback` also throws an `Exception`. */ - public static function execute($callback, array &$params = []) + public function execute($callback, array &$params = []) { - $isInvalidFunctionName = ( - is_string($callback) - && !function_exists($callback) - ); - - if ($isInvalidFunctionName) { - throw new InvalidArgumentException('Invalid callback specified.'); + if (is_string($callback) === true && (strpos($callback, '->') !== false || strpos($callback, '::') !== false)) { + $callback = $this->parseStringClassAndMethod($callback); } - if (is_array($callback)) { - return self::invokeMethod($callback, $params); + return $this->invokeCallable($callback, $params); + } + + /** + * Parses a string into a class and method. + * + * @param string $classAndMethod Class and method + * + * @return array{class-string|object, string} Class and method + */ + public function parseStringClassAndMethod(string $classAndMethod): array + { + $class_parts = explode('->', $classAndMethod); + if (count($class_parts) === 1) { + $class_parts = explode('::', $class_parts[0]); } - return self::callFunction($callback, $params); + $class = $class_parts[0]; + $method = $class_parts[1]; + + return [ $class, $method ]; } /** @@ -244,10 +285,11 @@ class Dispatcher * @param array &$params Function parameters * * @return mixed Function results + * @deprecated 3.7.0 Use invokeCallable instead */ - public static function callFunction(callable $func, array &$params = []) + public function callFunction(callable $func, array &$params = []) { - return call_user_func_array($func, $params); + return $this->invokeCallable($func, $params); } /** @@ -257,34 +299,172 @@ class Dispatcher * @param array &$params Class method parameters * * @return mixed Function results - * @throws TypeError For unexistent class name. + * @throws TypeError For nonexistent class name. + * @deprecated 3.7.0 Use invokeCallable instead */ - public static function invokeMethod(array $func, array &$params = []) + public function invokeMethod(array $func, array &$params = []) { - [$class, $method] = $func; + return $this->invokeCallable($func, $params); + } - if (is_string($class) && class_exists($class)) { - $constructor = (new ReflectionClass($class))->getConstructor(); - $constructorParamsNumber = 0; + /** + * Invokes a callable (anonymous function or Class->method). + * + * @param array{class-string|object, string}|Callable $func Class method + * @param array &$params Class method parameters + * + * @return mixed Function results + * @throws TypeError For nonexistent class name. + * @throws InvalidArgumentException If the constructor requires parameters + * @version 3.7.0 + */ + public function invokeCallable($func, array &$params = []) + { + // If this is a directly callable function, call it + if (is_array($func) === false) { + $this->verifyValidFunction($func); + return call_user_func_array($func, $params); + } - if ($constructor !== null) { - $constructorParamsNumber = count($constructor->getParameters()); + [$class, $method] = $func; + $resolvedClass = null; + + // Only execute the container handler if it's not a Flight class + if ( + $this->containerHandler !== null && + ( + ( + is_object($class) === true && + strpos(get_class($class), 'flight\\') === false + ) || + is_string($class) === true + ) + ) { + $containerHandler = $this->containerHandler; + $resolvedClass = $this->resolveContainerClass($containerHandler, $class, $params); + if ($resolvedClass !== null) { + $class = $resolvedClass; } + } - if ($constructorParamsNumber > 0) { - $exceptionMessage = "Method '$class::$method' cannot be called statically. "; - $exceptionMessage .= sprintf( - "$class::__construct require $constructorParamsNumber parameter%s", - $constructorParamsNumber > 1 ? 's' : '' - ); + $this->verifyValidClassCallable($class, $method, $resolvedClass); - throw new InvalidArgumentException($exceptionMessage, E_ERROR); - } + // Class is a string, and method exists, create the object by hand and inject only the Engine + if (is_string($class) === true) { + $class = new $class($this->engine); + } + + return call_user_func_array([ $class, $method ], $params); + } + + /** + * Handles invalid callback types. + * + * @param callable-string|(Closure(): mixed)|array{class-string|object, string} $callback + * Callback function + * + * @throws InvalidArgumentException If `$callback` is an invalid type + */ + protected function verifyValidFunction($callback): void + { + $isInvalidFunctionName = ( + is_string($callback) + && !function_exists($callback) + ); + + if ($isInvalidFunctionName) { + throw new InvalidArgumentException('Invalid callback specified.'); + } + } + + + /** + * Verifies if the provided class and method are valid callable. + * + * @param string|object $class The class name. + * @param string $method The method name. + * @param object|null $resolvedClass The resolved class. + * + * @throws Exception If the class or method is not found. + * + * @return void + */ + protected function verifyValidClassCallable($class, $method, $resolvedClass): void + { + $final_exception = null; + + // Final check to make sure it's actually a class and a method, or throw an error + if (is_object($class) === false && class_exists($class) === false) { + $final_exception = new Exception("Class '$class' not found. Is it being correctly autoloaded with Flight::path()?"); + + // If this tried to resolve a class in a container and failed somehow, throw the exception + } elseif (isset($resolvedClass) === false && $this->containerException !== null) { + $final_exception = $this->containerException; + + // Class is there, but no method + } elseif (is_object($class) === true && method_exists($class, $method) === false) { + $final_exception = new Exception("Class found, but method '" . get_class($class) . "::$method' not found."); + } - $class = new $class(); + if ($final_exception !== null) { + $this->fixOutputBuffering(); + throw $final_exception; } + } - return call_user_func_array([$class, $method], $params); + /** + * Resolves the container class. + * + * @param callable|object $container_handler Dependency injection container + * @param class-string $class Class name + * @param array &$params Class constructor parameters + * + * @return object Class object + */ + protected function resolveContainerClass($container_handler, $class, array &$params) + { + $class_object = null; + + // PSR-11 + if ( + is_object($container_handler) === true && + method_exists($container_handler, 'has') === true && + $container_handler->has($class) + ) { + $class_object = call_user_func([$container_handler, 'get'], $class); + + // Just a callable where you configure the behavior (Dice, PHP-DI, etc.) + } elseif (is_callable($container_handler) === true) { + // This is to catch all the error that could be thrown by whatever container you are using + try { + $class_object = call_user_func($container_handler, $class, $params); + } catch (Exception $e) { + // could not resolve a class for some reason + $class_object = null; + + // If the container throws an exception, we need to catch it + // and store it somewhere. If we just let it throw itself, it + // doesn't properly close the output buffers and can cause other + // issues. + // This is thrown in the verifyValidClassCallable method + $this->containerException = $e; + } + } + + return $class_object; + } + + /** + * Because this could throw an exception in the middle of an output buffer, + * + * @return void + */ + protected function fixOutputBuffering(): void + { + // Cause PHPUnit has 1 level of output buffering by default + if (ob_get_level() > (getenv('PHPUNIT_TEST') ? 1 : 0)) { + ob_end_clean(); + } } /** diff --git a/flight/core/Loader.php b/flight/core/Loader.php index 9792949..1824b9c 100644 --- a/flight/core/Loader.php +++ b/flight/core/Loader.php @@ -25,6 +25,11 @@ class Loader */ protected array $classes = []; + /** + * If this is disabled, classes can load with underscores + */ + protected static bool $v2ClassLoading = true; + /** * Class instances. * @@ -190,14 +195,14 @@ class Loader */ public static function loadClass(string $class): void { - $classFile = str_replace(['\\', '_'], '/', $class) . '.php'; + $replace_chars = self::$v2ClassLoading === true ? ['\\', '_'] : ['\\']; + $classFile = str_replace($replace_chars, '/', $class) . '.php'; foreach (self::$dirs as $dir) { $filePath = "$dir/$classFile"; if (file_exists($filePath)) { require_once $filePath; - return; } } @@ -220,4 +225,17 @@ class Loader } } } + + + /** + * Sets the value for V2 class loading. + * + * @param bool $value The value to set for V2 class loading. + * + * @return void + */ + public static function setV2ClassLoading(bool $value): void + { + self::$v2ClassLoading = $value; + } } diff --git a/flight/database/PdoWrapper.php b/flight/database/PdoWrapper.php index 842038c..297121a 100644 --- a/flight/database/PdoWrapper.php +++ b/flight/database/PdoWrapper.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace flight\database; +use flight\util\Collection; use PDO; use PDOStatement; @@ -47,7 +48,8 @@ class PdoWrapper extends PDO */ public function fetchField(string $sql, array $params = []) { - $data = $this->fetchRow($sql, $params); + $result = $this->fetchRow($sql, $params); + $data = $result->getData(); return reset($data); } @@ -59,13 +61,13 @@ class PdoWrapper extends PDO * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" * @param array $params - Ex: [ $something ] * - * @return array + * @return Collection */ - public function fetchRow(string $sql, array $params = []): array + public function fetchRow(string $sql, array $params = []): Collection { $sql .= stripos($sql, 'LIMIT') === false ? ' LIMIT 1' : ''; $result = $this->fetchAll($sql, $params); - return count($result) > 0 ? $result[0] : []; + return count($result) > 0 ? $result[0] : new Collection(); } /** @@ -79,17 +81,24 @@ class PdoWrapper extends PDO * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" * @param array $params - Ex: [ $something ] * - * @return array> + * @return array */ - public function fetchAll(string $sql, array $params = []): array + public function fetchAll(string $sql, array $params = []) { $processed_sql_data = $this->processInStatementSql($sql, $params); $sql = $processed_sql_data['sql']; $params = $processed_sql_data['params']; $statement = $this->prepare($sql); $statement->execute($params); - $result = $statement->fetchAll(); - return is_array($result) ? $result : []; + $results = $statement->fetchAll(); + if (is_array($results) === true && count($results) > 0) { + foreach ($results as &$result) { + $result = new Collection($result); + } + } else { + $results = []; + } + return $results; } /** diff --git a/flight/net/Request.php b/flight/net/Request.php index b322ae3..569994e 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -146,24 +146,24 @@ class Request // Default properties if (empty($config)) { $config = [ - 'url' => str_replace('@', '%40', self::getVar('REQUEST_URI', '/')), - 'base' => str_replace(['\\', ' '], ['/', '%20'], \dirname(self::getVar('SCRIPT_NAME'))), - 'method' => self::getMethod(), - 'referrer' => self::getVar('HTTP_REFERER'), - 'ip' => self::getVar('REMOTE_ADDR'), - 'ajax' => self::getVar('HTTP_X_REQUESTED_WITH') === 'XMLHttpRequest', - 'scheme' => self::getScheme(), + 'url' => str_replace('@', '%40', self::getVar('REQUEST_URI', '/')), + 'base' => str_replace(['\\', ' '], ['/', '%20'], \dirname(self::getVar('SCRIPT_NAME'))), + 'method' => self::getMethod(), + 'referrer' => self::getVar('HTTP_REFERER'), + 'ip' => self::getVar('REMOTE_ADDR'), + 'ajax' => 'XMLHttpRequest' === self::getVar('HTTP_X_REQUESTED_WITH'), + 'scheme' => self::getScheme(), 'user_agent' => self::getVar('HTTP_USER_AGENT'), - 'type' => self::getVar('CONTENT_TYPE'), - 'length' => intval(self::getVar('CONTENT_LENGTH', 0)), - 'query' => new Collection($_GET), - 'data' => new Collection($_POST), - 'cookies' => new Collection($_COOKIE), - 'files' => new Collection($_FILES), - 'secure' => self::getScheme() === 'https', - 'accept' => self::getVar('HTTP_ACCEPT'), - 'proxy_ip' => self::getProxyIpAddress(), - 'host' => self::getVar('HTTP_HOST'), + 'type' => self::getVar('CONTENT_TYPE'), + 'length' => intval(self::getVar('CONTENT_LENGTH', 0)), + 'query' => new Collection($_GET), + 'data' => new Collection($_POST), + 'cookies' => new Collection($_COOKIE), + 'files' => new Collection($_FILES), + 'secure' => 'https' === self::getScheme(), + 'accept' => self::getVar('HTTP_ACCEPT'), + 'proxy_ip' => self::getProxyIpAddress(), + 'host' => self::getVar('HTTP_HOST'), ]; } @@ -181,14 +181,14 @@ class Request { // Set all the defined properties foreach ($properties as $name => $value) { - $this->$name = $value; + $this->{$name} = $value; } // Get the requested URL without the base directory // This rewrites the url in case the public url and base directories match // (such as installing on a subdirectory in a web server) // @see testInitUrlSameAsBaseDirectory - if ($this->base !== '/' && $this->base !== '' && strpos($this->url, $this->base) === 0) { + if ('/' !== $this->base && '' !== $this->base && 0 === strpos($this->url, $this->base)) { $this->url = substr($this->url, \strlen($this->base)); } @@ -203,10 +203,9 @@ class Request } // Check for JSON input - if (strpos($this->type, 'application/json') === 0) { + if (0 === strpos($this->type, 'application/json')) { $body = $this->getBody(); - - if ($body !== '') { + if ('' !== $body) { $data = json_decode($body, true); if (is_array($data)) { $this->data->setData($data); @@ -226,17 +225,14 @@ class Request { $body = $this->body; - if ($body !== '') { + if ('' !== $body) { return $body; } - switch (self::getMethod()) { - case 'POST': - case 'PUT': - case 'DELETE': - case 'PATCH': - $body = file_get_contents($this->stream_path); - break; + $method = $this->method ?? self::getMethod(); + + if ('POST' === $method || 'PUT' === $method || 'DELETE' === $method || 'PATCH' === $method) { + $body = file_get_contents($this->stream_path); } $this->body = $body; @@ -281,8 +277,7 @@ class Request foreach ($forwarded as $key) { if (\array_key_exists($key, $_SERVER)) { sscanf($_SERVER[$key], '%[^,]', $ip); - - if (filter_var($ip, \FILTER_VALIDATE_IP, $flags) !== false) { + if (false !== filter_var($ip, \FILTER_VALIDATE_IP, $flags)) { return $ip; } } @@ -326,15 +321,13 @@ class Request public static function getHeaders(): array { $headers = []; - foreach ($_SERVER as $key => $value) { - if (strpos($key, 'HTTP_') === 0) { + if (0 === strpos($key, 'HTTP_')) { // converts headers like HTTP_CUSTOM_HEADER to Custom-Header $key = str_replace(' ', '-', ucwords(str_replace('_', ' ', strtolower(substr($key, 5))))); $headers[$key] = $value; } } - return $headers; } @@ -343,8 +336,10 @@ class Request * * @param string $header Header name. Can be caps, lowercase, or mixed. * @param string $default Default value if the header does not exist + * + * @return string */ - public static function header(string $header, $default = ''): string + public static function header(string $header, $default = '') { return self::getHeader($header, $default); } @@ -359,13 +354,21 @@ class Request return self::getHeaders(); } - /** Gets the full request URL. */ + /** + * Gets the full request URL. + * + * @return string URL + */ public function getFullUrl(): string { return $this->scheme . '://' . $this->host . $this->url; } - /** Grabs the scheme and host. Does not end with a / */ + /** + * Grabs the scheme and host. Does not end with a / + * + * @return string + */ public function getBaseUrl(): string { return $this->scheme . '://' . $this->host; @@ -393,18 +396,18 @@ class Request /** * Gets the URL Scheme * - * @return 'http'|'https' + * @return string 'http'|'https' */ public static function getScheme(): string { if ( - (isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) === 'on') + (isset($_SERVER['HTTPS']) && 'on' === strtolower($_SERVER['HTTPS'])) || - (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') + (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO']) || - (isset($_SERVER['HTTP_FRONT_END_HTTPS']) && $_SERVER['HTTP_FRONT_END_HTTPS'] === 'on') + (isset($_SERVER['HTTP_FRONT_END_HTTPS']) && 'on' === $_SERVER['HTTP_FRONT_END_HTTPS']) || - (isset($_SERVER['REQUEST_SCHEME']) && $_SERVER['REQUEST_SCHEME'] === 'https') + (isset($_SERVER['REQUEST_SCHEME']) && 'https' === $_SERVER['REQUEST_SCHEME']) ) { return 'https'; } diff --git a/flight/net/Response.php b/flight/net/Response.php index a98a416..2971bd9 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -225,16 +225,32 @@ class Response * Writes content to the response body. * * @param string $str Response content + * @param bool $overwrite Overwrite the response body * * @return $this Self reference */ - public function write(string $str): self + public function write(string $str, bool $overwrite = false): self { + if ($overwrite === true) { + $this->clearBody(); + } + $this->body .= $str; return $this; } + /** + * Clears the response body. + * + * @return $this Self reference + */ + public function clearBody(): self + { + $this->body = ''; + return $this; + } + /** * Clears the response. * @@ -244,7 +260,7 @@ class Response { $this->status = 200; $this->headers = []; - $this->body = ''; + $this->clearBody(); // This needs to clear the output buffer if it's on if ($this->v2_output_buffering === false && ob_get_length() > 0) { diff --git a/flight/net/Route.php b/flight/net/Route.php index a080a0a..57ba2fc 100644 --- a/flight/net/Route.php +++ b/flight/net/Route.php @@ -81,11 +81,11 @@ class Route * Constructor. * * @param string $pattern URL pattern - * @param callable $callback Callback function + * @param callable|string $callback Callback function * @param array $methods HTTP methods * @param bool $pass Pass self in callback parameters */ - public function __construct(string $pattern, callable $callback, array $methods, bool $pass, string $alias = '') + public function __construct(string $pattern, $callback, array $methods, bool $pass, string $alias = '') { $this->pattern = $pattern; $this->callback = $callback; @@ -97,7 +97,7 @@ class Route /** * Checks if a URL matches the route pattern. Also parses named parameters in the URL. * - * @param string $url Requested URL + * @param string $url Requested URL (original format, not URL decoded) * @param bool $case_sensitive Case sensitive matching * * @return bool Match status @@ -128,11 +128,18 @@ class Route } } - $this->splat = strval(substr($url, $i + 1)); + $this->splat = urldecode(strval(substr($url, $i + 1))); } // Build the regex for matching - $regex = str_replace([')', '/*'], [')?', '(/?|/.*?)'], $this->pattern); + $pattern_utf_chars_encoded = preg_replace_callback( + '#(\\p{L}+)#u', + static function ($matches) { + return urlencode($matches[0]); + }, + $this->pattern + ); + $regex = str_replace([')', '/*'], [')?', '(/?|/.*?)'], $pattern_utf_chars_encoded); $regex = preg_replace_callback( '#@([\w]+)(:([^/\(\)]*))?#', @@ -190,7 +197,7 @@ class Route */ public function hydrateUrl(array $params = []): string { - $url = preg_replace_callback("/(?:@([a-zA-Z0-9]+)(?:\:([^\/]+))?\)*)/i", function ($match) use ($params) { + $url = preg_replace_callback("/(?:@([\w]+)(?:\:([^\/]+))?\)*)/i", function ($match) use ($params) { if (isset($match[1]) && isset($params[$match[1]])) { return $params[$match[1]]; } diff --git a/flight/net/Router.php b/flight/net/Router.php index 2387baa..d494dbb 100644 --- a/flight/net/Router.php +++ b/flight/net/Router.php @@ -29,6 +29,11 @@ class Router */ protected array $routes = []; + /** + * The current route that is has been found and executed. + */ + protected ?Route $executedRoute = null; + /** * Pointer to current route. */ @@ -75,11 +80,11 @@ class Router * Maps a URL pattern to a callback function. * * @param string $pattern URL pattern to match. - * @param callable $callback Callback function. + * @param callable|string $callback Callback function or string class->method * @param bool $pass_route Pass the matching route object to the callback. * @param string $route_alias Alias for the route. */ - public function map(string $pattern, callable $callback, bool $pass_route = false, string $route_alias = ''): Route + public function map(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): Route { // This means that the route ies defined in a group, but the defined route is the base @@ -96,10 +101,15 @@ class Router $methods = ['*']; - if (strpos($url, ' ') !== false) { + if (false !== strpos($url, ' ')) { [$method, $url] = explode(' ', $url, 2); $url = trim($url); $methods = explode('|', $method); + + // Add head requests to get methods, should they come in as a get request + if (in_array('GET', $methods, true) === true && in_array('HEAD', $methods, true) === false) { + $methods[] = 'HEAD'; + } } // And this finishes it off. @@ -123,11 +133,11 @@ class Router * Creates a GET based route * * @param string $pattern URL pattern to match - * @param callable $callback Callback function + * @param callable|string $callback Callback function or string class->method * @param bool $pass_route Pass the matching route object to the callback * @param string $alias Alias for the route */ - public function get(string $pattern, callable $callback, bool $pass_route = false, string $alias = ''): Route + public function get(string $pattern, $callback, bool $pass_route = false, string $alias = ''): Route { return $this->map('GET ' . $pattern, $callback, $pass_route, $alias); } @@ -136,11 +146,11 @@ class Router * Creates a POST based route * * @param string $pattern URL pattern to match - * @param callable $callback Callback function + * @param callable|string $callback Callback function or string class->method * @param bool $pass_route Pass the matching route object to the callback * @param string $alias Alias for the route */ - public function post(string $pattern, callable $callback, bool $pass_route = false, string $alias = ''): Route + public function post(string $pattern, $callback, bool $pass_route = false, string $alias = ''): Route { return $this->map('POST ' . $pattern, $callback, $pass_route, $alias); } @@ -149,11 +159,11 @@ class Router * Creates a PUT based route * * @param string $pattern URL pattern to match - * @param callable $callback Callback function + * @param callable|string $callback Callback function or string class->method * @param bool $pass_route Pass the matching route object to the callback * @param string $alias Alias for the route */ - public function put(string $pattern, callable $callback, bool $pass_route = false, string $alias = ''): Route + public function put(string $pattern, $callback, bool $pass_route = false, string $alias = ''): Route { return $this->map('PUT ' . $pattern, $callback, $pass_route, $alias); } @@ -162,11 +172,11 @@ class Router * Creates a PATCH based route * * @param string $pattern URL pattern to match - * @param callable $callback Callback function + * @param callable|string $callback Callback function or string class->method * @param bool $pass_route Pass the matching route object to the callback * @param string $alias Alias for the route */ - public function patch(string $pattern, callable $callback, bool $pass_route = false, string $alias = ''): Route + public function patch(string $pattern, $callback, bool $pass_route = false, string $alias = ''): Route { return $this->map('PATCH ' . $pattern, $callback, $pass_route, $alias); } @@ -175,11 +185,11 @@ class Router * Creates a DELETE based route * * @param string $pattern URL pattern to match - * @param callable $callback Callback function + * @param callable|string $callback Callback function or string class->method * @param bool $pass_route Pass the matching route object to the callback * @param string $alias Alias for the route */ - public function delete(string $pattern, callable $callback, bool $pass_route = false, string $alias = ''): Route + public function delete(string $pattern, $callback, bool $pass_route = false, string $alias = ''): Route { return $this->map('DELETE ' . $pattern, $callback, $pass_route, $alias); } @@ -210,13 +220,11 @@ class Router */ public function route(Request $request) { - $url_decoded = urldecode($request->url); - while ($route = $this->current()) { - if ($route->matchMethod($request->method) && $route->matchUrl($url_decoded, $this->case_sensitive)) { + if ($route->matchMethod($request->method) && $route->matchUrl($request->url, $this->case_sensitive)) { + $this->executedRoute = $route; return $route; } - $this->next(); } @@ -235,6 +243,11 @@ class Router foreach ($this->routes as $route) { $potential_aliases[] = $route->alias; if ($route->matchAlias($alias)) { + // This will make it so the params that already + // exist in the url will be passed in. + if (!empty($this->executedRoute->params)) { + $params = $params + $this->executedRoute->params; + } return $route->hydrateUrl($params); } } diff --git a/phpunit.xml b/phpunit.xml index c97f669..18134c0 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,6 +23,7 @@ + diff --git a/tests/DispatcherTest.php b/tests/DispatcherTest.php index 826c190..a755666 100644 --- a/tests/DispatcherTest.php +++ b/tests/DispatcherTest.php @@ -4,13 +4,16 @@ declare(strict_types=1); namespace tests; +use ArgumentCountError; use Closure; use Exception; use flight\core\Dispatcher; +use flight\Engine; use InvalidArgumentException; use PharIo\Manifest\InvalidEmailException; use tests\classes\Hello; use PHPUnit\Framework\TestCase; +use tests\classes\ContainerDefault; use tests\classes\TesterClass; use TypeError; @@ -107,11 +110,11 @@ class DispatcherTest extends TestCase }); $this->dispatcher - ->hook('hello', $this->dispatcher::FILTER_BEFORE, function (array &$params): void { + ->hook('hello', Dispatcher::FILTER_BEFORE, function (array &$params): void { // Manipulate the parameter $params[0] = 'Fred'; }) - ->hook('hello', $this->dispatcher::FILTER_AFTER, function (array &$params, string &$output): void { + ->hook('hello', Dispatcher::FILTER_AFTER, function (array &$params, string &$output): void { // Manipulate the output $output .= ' Have a nice day!'; }); @@ -123,9 +126,10 @@ class DispatcherTest extends TestCase public function testInvalidCallback(): void { - $this->expectException(TypeError::class); + $this->expectException(Exception::class); + $this->expectExceptionMessage("Class 'NonExistentClass' not found. Is it being correctly autoloaded with Flight::path()?"); - Dispatcher::execute(['NonExistentClass', 'nonExistentMethod']); + $this->dispatcher->execute(['NonExistentClass', 'nonExistentMethod']); } public function testInvalidCallableString(): void @@ -133,27 +137,13 @@ class DispatcherTest extends TestCase $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid callback specified.'); - Dispatcher::execute('inexistentGlobalFunction'); - } - - public function testInvalidCallbackBecauseConstructorParameters(): void - { - $class = TesterClass::class; - $method = 'instanceMethod'; - $exceptionMessage = "Method '$class::$method' cannot be called statically. "; - $exceptionMessage .= "$class::__construct require 6 parameters"; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage($exceptionMessage); - - static $params = []; - Dispatcher::invokeMethod([$class, $method], $params); + $this->dispatcher->execute('nonexistentGlobalFunction'); } // It will be useful for executing instance Controller methods statically public function testCanExecuteAnNonStaticMethodStatically(): void { - $this->assertSame('hello', Dispatcher::execute([Hello::class, 'sayHi'])); + $this->assertSame('hello', $this->dispatcher->execute([Hello::class, 'sayHi'])); } public function testItThrowsAnExceptionWhenRunAnUnregistedEventName(): void @@ -191,7 +181,7 @@ class DispatcherTest extends TestCase { set_error_handler(function (int $errno, string $errstr): void { $this->assertSame(E_USER_NOTICE, $errno); - $this->assertSame("Event 'myMethod' has been overriden!", $errstr); + $this->assertSame("Event 'myMethod' has been overridden!", $errstr); }); $this->dispatcher->set('myMethod', function (): string { @@ -199,10 +189,10 @@ class DispatcherTest extends TestCase }); $this->dispatcher->set('myMethod', function (): string { - return 'Overriden'; + return 'Overridden'; }); - $this->assertSame('Overriden', $this->dispatcher->run('myMethod')); + $this->assertSame('Overridden', $this->dispatcher->run('myMethod')); restore_error_handler(); } @@ -218,7 +208,7 @@ class DispatcherTest extends TestCase return 'Original'; }) ->hook('myMethod', 'invalid', function (array &$params, &$output): void { - $output = 'Overriden'; + $output = 'Overridden'; }); $this->assertSame('Original', $this->dispatcher->run('myMethod')); @@ -237,7 +227,7 @@ class DispatcherTest extends TestCase $validCallable = function (): void { }; - Dispatcher::filter([$validCallable, $invalidCallable], $params, $output); + $this->dispatcher->filter([$validCallable, $invalidCallable], $params, $output); } public function testCallFunction6Params(): void @@ -247,8 +237,75 @@ class DispatcherTest extends TestCase }; $params = ['param1', 'param2', 'param3', 'param4', 'param5', 'param6']; - $result = Dispatcher::callFunction($func, $params); + $result = $this->dispatcher->callFunction($func, $params); $this->assertSame('helloparam1param2param3param4param5param6', $result); } + + public function testInvokeMethod(): void + { + $class = new TesterClass('param1', 'param2', 'param3', 'param4', 'param5', 'param6'); + $result = $this->dispatcher->invokeMethod([ $class, 'instanceMethod' ]); + + $this->assertSame('param1', $class->param2); + } + + public function testExecuteStringClassBadConstructParams(): void + { + $this->expectException(ArgumentCountError::class); + $this->expectExceptionMessageMatches('#Too few arguments to function tests\\\\classes\\\\TesterClass::__construct\(\), 1 passed .+ and exactly 6 expected#'); + $this->dispatcher->execute(TesterClass::class . '->instanceMethod'); + } + + public function testExecuteStringClassNoConstruct(): void + { + $result = $this->dispatcher->execute(Hello::class . '->sayHi'); + $this->assertSame('hello', $result); + } + + public function testExecuteStringClassNoConstructDoubleColon(): void + { + $result = $this->dispatcher->execute(Hello::class . '::sayHi'); + $this->assertSame('hello', $result); + } + + public function testExecuteStringClassNoConstructArraySyntax(): void + { + $result = $this->dispatcher->execute([ Hello::class, 'sayHi' ]); + $this->assertSame('hello', $result); + } + + public function testExecuteStringClassDefaultContainer(): void + { + $engine = new Engine(); + $engine->set('test_me_out', 'You got it boss!'); + $this->dispatcher->setEngine($engine); + $result = $this->dispatcher->execute(ContainerDefault::class . '->testTheContainer'); + $this->assertSame('You got it boss!', $result); + } + + public function testExecuteStringClassDefaultContainerDoubleColon(): void + { + $engine = new Engine(); + $engine->set('test_me_out', 'You got it boss!'); + $this->dispatcher->setEngine($engine); + $result = $this->dispatcher->execute(ContainerDefault::class . '::testTheContainer'); + $this->assertSame('You got it boss!', $result); + } + + public function testExecuteStringClassDefaultContainerArraySyntax(): void + { + $engine = new Engine(); + $engine->set('test_me_out', 'You got it boss!'); + $this->dispatcher->setEngine($engine); + $result = $this->dispatcher->execute([ ContainerDefault::class, 'testTheContainer' ]); + $this->assertSame('You got it boss!', $result); + } + + public function testExecuteStringClassDefaultContainerButForgotInjectingEngine(): void + { + $this->expectException(TypeError::class); + $this->expectExceptionMessageMatches('#tests\\\\classes\\\\ContainerDefault::__construct\(\).+flight\\\\Engine, null given#'); + $result = $this->dispatcher->execute([ ContainerDefault::class, 'testTheContainer' ]); + } } diff --git a/tests/DocExamplesTest.php b/tests/DocExamplesTest.php index 1518363..2bba482 100644 --- a/tests/DocExamplesTest.php +++ b/tests/DocExamplesTest.php @@ -74,4 +74,22 @@ class DocExamplesTest extends TestCase Flight::app()->handleException(new Exception('Error')); $this->expectOutputString('Custom: Error'); } + + public function testGetRouterStatically() + { + $router = Flight::router(); + Flight::request()->method = 'GET'; + Flight::request()->url = '/'; + + $router->get( + '/', + function () { + Flight::response()->write('from resp '); + } + ); + + Flight::start(); + + $this->expectOutputString('from resp '); + } } diff --git a/tests/EngineTest.php b/tests/EngineTest.php index c7e21d2..5c32968 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -4,11 +4,17 @@ declare(strict_types=1); namespace tests; +use ErrorException; use Exception; -use Flight; +use flight\database\PdoWrapper; use flight\Engine; +use flight\net\Request; use flight\net\Response; +use flight\util\Collection; +use PDOException; use PHPUnit\Framework\TestCase; +use tests\classes\Container; +use tests\classes\ContainerDefault; // phpcs:ignoreFile PSR2.Methods.MethodDeclaration.Underscore class EngineTest extends TestCase @@ -263,6 +269,20 @@ class EngineTest extends TestCase $this->assertEquals('/someRoute', $routes[0]->pattern); } + public function testHeadRoute() + { + $engine = new Engine(); + $engine->route('GET /someRoute', function () { + echo 'i ran'; + }, true); + $engine->request()->method = 'HEAD'; + $engine->request()->url = '/someRoute'; + $engine->start(); + + // No body should be sent + $this->expectOutputString(''); + } + public function testHalt() { $engine = new class extends Engine { @@ -304,6 +324,33 @@ class EngineTest extends TestCase $this->assertEquals(301, $engine->response()->status()); } + public function testJsonRequestBody() + { + $engine = new Engine(); + $tmpfile = tmpfile(); + $stream_path = stream_get_meta_data($tmpfile)['uri']; + file_put_contents($stream_path, '{"key1":"value1","key2":"value2"}'); + + $engine->register('request', Request::class, [ + [ + 'method' => 'POST', + 'url' => '/something/fancy', + 'base' => '/vagrant/public', + 'type' => 'application/json', + 'length' => 13, + 'data' => new Collection(), + 'query' => new Collection(), + 'stream_path' => $stream_path + ] + ]); + $engine->post('/something/fancy', function () use ($engine) { + echo $engine->request()->data->key1; + echo $engine->request()->data->key2; + }); + $engine->start(); + $this->expectOutputString('value1value2'); + } + public function testJson() { $engine = new Engine(); @@ -405,6 +452,34 @@ class EngineTest extends TestCase $this->assertEquals('/path1/123', $url); } + public function testGetUrlComplex() + { + $engine = new Engine(); + $engine->route('/item/@item_param:[a-z0-9]{16}/by-status/@token:[a-z0-9]{16}', function () { + echo 'I win'; + }, false, 'path_item_1'); + $url = $engine->getUrl('path_item_1', [ 'item_param' => 1234567890123456, 'token' => 6543210987654321 ]); + $this->assertEquals('/item/1234567890123456/by-status/6543210987654321', $url); + } + + public function testGetUrlInsideRoute() + { + $engine = new Engine(); + $engine->route('/path1/@param:[0-9]{3}', function () { + echo 'I win'; + }, false, 'path1'); + $found_url = ''; + $engine->route('/path1/@param:[0-9]{3}/path2', function () use ($engine, &$found_url) { + + // this should pull the param from the first route + // since the param names are the same. + $found_url = $engine->getUrl('path1'); + }); + $engine->request()->url = '/path1/123/path2'; + $engine->start(); + $this->assertEquals('/path1/123', $found_url); + } + public function testMiddlewareCallableFunction() { $engine = new Engine(); @@ -605,4 +680,172 @@ class EngineTest extends TestCase $engine->start(); $this->expectOutputString('before456before123OKafter123456after123'); } + + public function testContainerDice() { + $engine = new Engine(); + $dice = new \Dice\Dice(); + $dice = $dice->addRules([ + PdoWrapper::class => [ + 'shared' => true, + 'constructParams' => [ 'sqlite::memory:' ] + ] + ]); + $engine->registerContainerHandler(function ($class, $params) use ($dice) { + return $dice->create($class, $params); + }); + + $engine->route('/container', Container::class.'->testTheContainer'); + $engine->request()->url = '/container'; + $engine->start(); + + $this->expectOutputString('yay! I injected a collection, and it has 1 items'); + } + + public function testContainerDicePdoWrapperTest() { + $engine = new Engine(); + $dice = new \Dice\Dice(); + $dice = $dice->addRules([ + PdoWrapper::class => [ + 'shared' => true, + 'constructParams' => [ 'sqlite::memory:' ] + ] + ]); + $engine->registerContainerHandler(function ($class, $params) use ($dice) { + return $dice->create($class, $params); + }); + + $engine->route('/container', Container::class.'->testThePdoWrapper'); + $engine->request()->url = '/container'; + $engine->start(); + + $this->expectOutputString('Yay! I injected a PdoWrapper, and it returned the number 5 from the database!'); + } + + public function testContainerDiceFlightEngine() { + $engine = new Engine(); + $engine->set('test_me_out', 'You got it boss!'); + $dice = new \Dice\Dice(); + $dice = $dice->addRule('*', [ + 'substitutions' => [ + Engine::class => $engine + ] + ]); + $engine->registerContainerHandler(function ($class, $params) use ($dice) { + return $dice->create($class, $params); + }); + + $engine->route('/container', ContainerDefault::class.'->echoTheContainer'); + $engine->request()->url = '/container'; + $engine->start(); + + $this->expectOutputString('You got it boss!'); + } + + public function testContainerDicePdoWrapperTestBadParams() { + $engine = new Engine(); + $dice = new \Dice\Dice(); + $engine->registerContainerHandler(function ($class, $params) use ($dice) { + return $dice->create($class, $params); + }); + + $engine->route('/container', Container::class.'->testThePdoWrapper'); + $engine->request()->url = '/container'; + + // php 7.4 will throw a PDO exception, but php 8 will throw an ErrorException + if(version_compare(PHP_VERSION, '8.0.0', '<')) { + $this->expectException(PDOException::class); + $this->expectExceptionMessageMatches("/invalid data source name/"); + } else { + $this->expectException(ErrorException::class); + $this->expectExceptionMessageMatches("/Passing null to parameter/"); + } + + $engine->start(); + } + + public function testContainerDiceBadClass() { + $engine = new Engine(); + $dice = new \Dice\Dice(); + $engine->registerContainerHandler(function ($class, $params) use ($dice) { + return $dice->create($class, $params); + }); + + $engine->route('/container', 'BadClass->testTheContainer'); + $engine->request()->url = '/container'; + + $this->expectException(Exception::class); + $this->expectExceptionMessage("Class 'BadClass' not found. Is it being correctly autoloaded with Flight::path()?"); + + $engine->start(); + } + + public function testContainerDiceBadMethod() { + $engine = new Engine(); + $dice = new \Dice\Dice(); + $dice = $dice->addRules([ + PdoWrapper::class => [ + 'shared' => true, + 'constructParams' => [ 'sqlite::memory:' ] + ] + ]); + $engine->registerContainerHandler(function ($class, $params) use ($dice) { + return $dice->create($class, $params); + }); + + $engine->route('/container', Container::class.'->badMethod'); + $engine->request()->url = '/container'; + + $this->expectException(Exception::class); + $this->expectExceptionMessage("Class found, but method 'tests\classes\Container::badMethod' not found."); + + $engine->start(); + } + + public function testContainerPsr11() { + $engine = new Engine(); + $container = new \League\Container\Container(); + $container->add(Container::class)->addArgument(Collection::class)->addArgument(PdoWrapper::class); + $container->add(Collection::class); + $container->add(PdoWrapper::class)->addArgument('sqlite::memory:'); + $engine->registerContainerHandler($container); + + $engine->route('/container', Container::class.'->testTheContainer'); + $engine->request()->url = '/container'; + $engine->start(); + + $this->expectOutputString('yay! I injected a collection, and it has 1 items'); + } + + public function testContainerPsr11ClassNotFound() { + $engine = new Engine(); + $container = new \League\Container\Container(); + $container->add(Container::class)->addArgument(Collection::class); + $container->add(Collection::class); + $engine->registerContainerHandler($container); + + $engine->route('/container', 'BadClass->testTheContainer'); + $engine->request()->url = '/container'; + + $this->expectException(Exception::class); + $this->expectExceptionMessage("Class 'BadClass' not found. Is it being correctly autoloaded with Flight::path()?"); + + $engine->start(); + } + + public function testContainerPsr11MethodNotFound() { + $engine = new Engine(); + $container = new \League\Container\Container(); + $container->add(Container::class)->addArgument(Collection::class)->addArgument(PdoWrapper::class); + $container->add(Collection::class); + $container->add(PdoWrapper::class)->addArgument('sqlite::memory:'); + $engine->registerContainerHandler($container); + + $engine->route('/container', Container::class.'->badMethod'); + $engine->request()->url = '/container'; + + $this->expectException(Exception::class); + $this->expectExceptionMessage("Class found, but method 'tests\classes\Container::badMethod' not found."); + + $engine->start(); + } } diff --git a/tests/FlightTest.php b/tests/FlightTest.php index a6ffa16..042b6bb 100644 --- a/tests/FlightTest.php +++ b/tests/FlightTest.php @@ -304,4 +304,30 @@ class FlightTest extends TestCase ], Flight::response()->getHeaders()); $this->assertEquals(200, Flight::response()->status()); } + + public function testOverwriteBodyWithMiddleware() + { + $middleware = new class { + public function after() + { + $response = Flight::response(); + $body = $response->getBody(); + $body = strip_tags($body); + // remove spaces for fun + $body = str_replace(' ', '', $body); + $response->write($body, true); + return $response; + } + }; + + Flight::route('/route-with-html', function () { + echo '

This is a route with html

'; + })->addMiddleware($middleware); + + Flight::request()->url = '/route-with-html'; + + Flight::start(); + + $this->expectOutputString('Thisisaroutewithhtml'); + } } diff --git a/tests/LoaderTest.php b/tests/LoaderTest.php index 44a89d0..9b6047c 100644 --- a/tests/LoaderTest.php +++ b/tests/LoaderTest.php @@ -152,4 +152,17 @@ class LoaderTest extends TestCase __DIR__ . '/classes' ], $loader->getDirectories()); } + + public function testV2ClassLoading() + { + $loader = new class extends Loader { + public static function getV2ClassLoading() + { + return self::$v2ClassLoading; + } + }; + $this->assertTrue($loader::getV2ClassLoading()); + $loader::setV2ClassLoading(false); + $this->assertFalse($loader::getV2ClassLoading()); + } } diff --git a/tests/PdoWrapperTest.php b/tests/PdoWrapperTest.php index 324c47b..0f41a92 100644 --- a/tests/PdoWrapperTest.php +++ b/tests/PdoWrapperTest.php @@ -88,6 +88,13 @@ class PdoWrapperTest extends TestCase $this->assertEquals('three', $rows[2]['name']); } + public function testFetchAllNoRows() + { + $rows = $this->pdo_wrapper->fetchAll('SELECT * FROM test WHERE 1 = 2'); + $this->assertCount(0, $rows); + $this->assertSame([], $rows); + } + public function testFetchAllWithNamedParams() { $rows = $this->pdo_wrapper->fetchAll('SELECT * FROM test WHERE name = :name', [ 'name' => 'two']); diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index d7155fc..42701d4 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -238,4 +238,21 @@ class ResponseTest extends TestCase $response->send(); $this->assertTrue($response->sent()); } + + public function testClearBody() + { + $response = new Response(); + $response->write('test'); + $response->clearBody(); + $this->assertEquals('', $response->getBody()); + } + + public function testOverwriteBody() + { + $response = new Response(); + $response->write('test'); + $response->write('lots more test'); + $response->write('new', true); + $this->assertEquals('new', $response->getBody()); + } } diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 84322c2..8906168 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -81,6 +81,10 @@ class RouterTest extends TestCase $dispatched = false; } + if ($this->request->method === 'HEAD') { + ob_clean(); + } + if (!$dispatched) { echo '404'; } @@ -122,6 +126,15 @@ class RouterTest extends TestCase $this->check('OK'); } + public function testHeadRouteShortcut() + { + $route = $this->router->get('/path', [$this, 'ok']); + $this->assertEquals(['GET', 'HEAD'], $route->methods); + $this->request->url = '/path'; + $this->request->method = 'HEAD'; + $this->check(''); + } + // POST route public function testPostRoute() { @@ -198,6 +211,67 @@ class RouterTest extends TestCase $this->check('123'); } + public function testUrlParametersWithEncodedSlash() + { + $this->router->map('/redirect/@id', function ($id) { + echo $id; + }); + $this->request->url = '/redirect/before%2Fafter'; + + $this->check('before/after'); + } + + public function testUrlParametersWithRealSlash() + { + $this->router->map('/redirect/@id', function ($id) { + echo $id; + }); + $this->request->url = '/redirect/before/after'; + + $this->check('404'); + } + + public function testUrlParametersWithJapanese() + { + $this->router->map('/わたしはひとです', function () { + echo 'はい'; + }); + $this->request->url = '/わたしはひとです'; + + $this->check('はい'); + } + + public function testUrlParametersWithJapaneseAndParam() + { + $this->router->map('/わたしはひとです/@name', function ($name) { + echo $name; + }); + $this->request->url = '/' . urlencode('わたしはひとです') . '/' . urlencode('ええ'); + + $this->check('ええ'); + } + + // Passing URL parameters matched with regular expression for a URL containing Cyrillic letters: + public function testRegExParametersCyrillic() + { + $this->router->map('/категория/@name:[абвгдеёжзийклмнопрстуфхцчшщъыьэюя]+', function ($name) { + echo $name; + }); + $this->request->url = '/' . urlencode('категория') . '/' . urlencode('цветя'); + + $this->check('цветя'); + } + + public function testRegExOnlyCyrillicUrl() + { + $this->router->map('/категория/цветя', function () { + echo 'цветя'; + }); + $this->request->url = '/категория/цветя'; + + $this->check('цветя'); + } + // Passing URL parameters matched with regular expression public function testRegExParameters() { @@ -386,17 +460,6 @@ class RouterTest extends TestCase $this->check('404'); } - // Passing URL parameters matched with regular expression for a URL containing Cyrillic letters: - public function testRegExParametersCyrillic() - { - $this->router->map('/категория/@name:[абвгдеёжзийклмнопрстуфхцчшщъыьэюя]+', function ($name) { - echo $name; - }); - $this->request->url = urlencode('/категория/цветя'); - - $this->check('цветя'); - } - public function testGetAndClearRoutes() { $this->router->map('/path1', [$this, 'ok']); diff --git a/tests/classes/Container.php b/tests/classes/Container.php new file mode 100644 index 0000000..5b9d216 --- /dev/null +++ b/tests/classes/Container.php @@ -0,0 +1,32 @@ +collection = $collection; + $this->pdoWrapper = $pdoWrapper; + } + + public function testTheContainer() + { + $this->collection->whatever = 'yay!'; + echo 'yay! I injected a collection, and it has ' . $this->collection->count() . ' items'; + } + + public function testThePdoWrapper() + { + $value = intval($this->pdoWrapper->fetchField('SELECT 5')); + echo 'Yay! I injected a PdoWrapper, and it returned the number ' . $value . ' from the database!'; + } +} diff --git a/tests/classes/ContainerDefault.php b/tests/classes/ContainerDefault.php new file mode 100644 index 0000000..a71ff1f --- /dev/null +++ b/tests/classes/ContainerDefault.php @@ -0,0 +1,32 @@ +app = $engine; + } + + public function testTheContainer() + { + return $this->app->get('test_me_out'); + } + + public function echoTheContainer() + { + echo $this->app->get('test_me_out'); + } + + public function testUi() + { + echo 'Route text: The container successfully injected a value into the engine! Engine class: ' . get_class($this->app) . ' test_me_out Value: ' . $this->app->get('test_me_out') . ''; + } +} diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index d38bf0d..72ec8ba 100644 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -1,9 +1,26 @@ #!/bin/bash -# Run all tests -composer lint -composer beautify -composer phpcs -composer test-coverage -xdg-open http://localhost:8000 -composer test-server \ No newline at end of file +php_versions=("php7.4" "php8.0" "php8.1" "php8.2" "php8.3") + +count=${#php_versions[@]} + + +echo "Prettifying code first" +vendor/bin/phpcbf --standard=phpcs.xml + +set -e +for ((i = 0; i < count; i++)); do + if type "${php_versions[$i]}" &> /dev/null; then + echo "Running tests for ${php_versions[$i]}" + echo " ${php_versions[$i]} vendor/bin/phpunit" + ${php_versions[$i]} vendor/bin/phpunit + + echo "Running PHPStan" + echo " ${php_versions[$i]} vendor/bin/phpstan" + ${php_versions[$i]} vendor/bin/phpstan + + echo "Running PHPCS" + echo " ${php_versions[$i]} vendor/bin/phpcs --standard=phpcs.xml -n" + ${php_versions[$i]} vendor/bin/phpcs --standard=phpcs.xml -n + fi +done \ No newline at end of file diff --git a/tests/server-v2/index.php b/tests/server-v2/index.php index d4cc385..3c1951c 100644 --- a/tests/server-v2/index.php +++ b/tests/server-v2/index.php @@ -199,7 +199,7 @@ echo '
  • Mega group
  • Error
  • JSON
  • -
  • JSONP
  • +
  • JSONP
  • Halt
  • Redirect
  • '; diff --git a/tests/server/LayoutMiddleware.php b/tests/server/LayoutMiddleware.php index d5dfcde..b89c4e0 100644 --- a/tests/server/LayoutMiddleware.php +++ b/tests/server/LayoutMiddleware.php @@ -73,10 +73,17 @@ class LayoutMiddleware
  • Mega group
  • Error
  • JSON
  • -
  • JSONP
  • +
  • JSONP
  • Halt
  • Redirect
  • Stream
  • +
  • Overwrite Body
  • +
  • Slash in Param
  • +
  • UTF8 URL
  • +
  • UTF8 URL w/ Param
  • +
  • Dice Container
  • +
  • No Container Registered
  • +
  • Pascal_Snake_Case
  • HTML; echo '
    '; diff --git a/tests/server/OverwriteBodyMiddleware.php b/tests/server/OverwriteBodyMiddleware.php new file mode 100644 index 0000000..79e1194 --- /dev/null +++ b/tests/server/OverwriteBodyMiddleware.php @@ -0,0 +1,12 @@ +write(str_replace('failed', 'successfully works!', $response->getBody()), true); + } +} diff --git a/tests/server/Pascal_Snake_Case.php b/tests/server/Pascal_Snake_Case.php new file mode 100644 index 0000000..bbba0d2 --- /dev/null +++ b/tests/server/Pascal_Snake_Case.php @@ -0,0 +1,11 @@ +streamWithHeaders(['Content-Type' => 'text/html', 'status' => 200 ]); + // Test 14: Overwrite the body with a middleware + Flight::route('/overwrite', function () { + echo 'Route text: This route status is that it failed'; + })->addMiddleware([new OverwriteBodyMiddleware()]); + + // Test 15: UTF8 Chars in url + Flight::route('/わたしはひとです', function () { + echo 'Route text: This route status is that it succeeded はい!!!'; + }); + + // Test 16: UTF8 Chars in url with utf8 params + Flight::route('/わたしはひとです/@name', function ($name) { + echo 'Route text: This route status is that it ' . ($name === 'ええ' ? 'succeeded' : 'failed') . ' URL Param: ' . $name . ''; + }); + + // Test 17: Slash in param + Flight::route('/redirect/@id', function ($id) { + echo 'Route text: This route status is that it ' . ($id === 'before/after' ? 'succeeded' : 'failed') . ' URL Param: ' . $id . ''; + }); + + Flight::set('test_me_out', 'You got it boss!'); // used in /no-container route + Flight::route('/no-container', ContainerDefault::class . '->testUi'); + Flight::route('/dice', Container::class . '->testThePdoWrapper'); + Flight::route('/Pascal_Snake_Case', Pascal_Snake_Case::class . '->doILoad'); }, [ new LayoutMiddleware() ]); // Test 9: JSON output (should not output any other html) @@ -147,4 +175,23 @@ Flight::map('notFound', function () { echo "Go back"; }); +Flight::map('start', function () { + + if (Flight::request()->url === '/dice') { + $dice = new \Dice\Dice(); + $dice = $dice->addRules([ + PdoWrapper::class => [ + 'shared' => true, + 'constructParams' => [ 'sqlite::memory:' ] + ] + ]); + Flight::registerContainerHandler(function ($class, $params) use ($dice) { + return $dice->create($class, $params); + }); + } + + // Default start behavior now + Flight::_start(); +}); + Flight::start();