diff --git a/flight/Engine.php b/flight/Engine.php index 880decff..36666335 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -57,8 +57,7 @@ * @method void lastModified(int $time) * @method void download(string $filePath) * - * @phpstan-template EngineTemplate of object - * @phpstan-method void registerContainerHandler(ContainerInterface|callable(class-string $id, array $params): ?EngineTemplate $containerHandler) + * @phpstan-method void registerContainerHandler(ContainerInterface|callable(class-string $id, array $params): ?object $containerHandler) * @phpstan-method Route route(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * @phpstan-method void group(string $pattern, callable $callback, (class-string|callable|array{0: class-string, 1: string})[] $group_middlewares = []) * @phpstan-method Route post(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') @@ -118,7 +117,7 @@ class Engine /** Class loader. */ protected Loader $loader; - /** @var Dispatcher Method and class dispatcher. */ + /** Method and class dispatcher. */ protected Dispatcher $dispatcher; /** Event dispatcher. */ diff --git a/flight/core/Dispatcher.php b/flight/core/Dispatcher.php index 62ff7f95..53f193fc 100644 --- a/flight/core/Dispatcher.php +++ b/flight/core/Dispatcher.php @@ -4,114 +4,112 @@ namespace flight\core; -use Exception; use flight\Engine; use InvalidArgumentException; -use Psr\Container\ContainerInterface; +use OutOfBoundsException; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface as Container; use ReflectionFunction; use Throwable; -use TypeError; /** - * The Dispatcher class is responsible for dispatching events. Events - * are simply aliases for class methods or functions. The Dispatcher - * allows you to hook other functions to an event that can modify the - * input parameters and/or the output. + * Responsible for dispatching named callables. + * + * The Dispatcher allows you to add filters to a named callable that can modify + * the named callable `input` and/or `output`. + * + * - The `input` is the arguments passed to the named callable. + * - The `output` is the return value of the named callable. * * @license MIT, http://flightphp.com/license * @copyright Copyright (c) 2011, Mike Cao - * @phpstan-template EngineTemplate of object */ class Dispatcher { public const FILTER_BEFORE = 'before'; public const FILTER_AFTER = 'after'; + private const CALLABLE_STRING_OPERATORS = ['->', '::']; - /** Exception message if thrown by setting the container as a callable method. */ protected ?Throwable $containerException = null; - - /** @var ?Engine $engine Engine instance. */ protected ?Engine $engine = null; - /** @var array Mapped events. */ + /** + * @deprecated Don't use this property directly, use `set()`, `get()` and `has()` instead. + * @var array + */ protected array $events = []; + /** @var array */ + private array $namedCallables = []; + /** - * Method filters. - * - * @var array &$params, mixed &$output): (void|false)>>> + * @deprecated Don't use this property, use `hook()` instead. + * @var array */ protected array $filters = []; - /** - * This is a container for the dependency injection. - * - * @var null|ContainerInterface|(callable(string $classString, array $params): (null|object)) - */ + /** @var null|Container|(callable(class-string $classString, mixed[] $params): ?object) */ protected $containerHandler = null; /** - * Sets the dependency injection container handler. - * - * @param ContainerInterface|(callable(class-string $classString, array $params): ?T) $containerHandler - * Dependency injection container. - * - * @template T of object - * + * @param Container|(callable(class-string $classString, mixed[] $params): ?object) $containerHandler * @throws InvalidArgumentException - * If $containerHandler is not a `callable` or instance of `Psr\Container\ContainerInterface`. + * If $containerHandler is not a `callable` or instance of `\Psr\Container\ContainerInterface`. */ public function setContainerHandler($containerHandler): void { - $containerInterfaceNS = '\Psr\Container\ContainerInterface'; - - if (is_a($containerHandler, $containerInterfaceNS) || is_callable($containerHandler)) { - $this->containerHandler = $containerHandler; + if (!$containerHandler instanceof Container && !is_callable($containerHandler)) { + $message = "\$containerHandler must be of type callable or instance \\" . Container::class; - return; + throw new InvalidArgumentException($message); } - throw new InvalidArgumentException( - "\$containerHandler must be of type callable or instance $containerInterfaceNS" - ); + $this->containerHandler = $containerHandler; } - /** - * Sets the engine instance - * - * @param Engine $engine Flight instance - * - * @return void - */ public function setEngine(Engine $engine): void { $this->engine = $engine; } /** - * Dispatches an event. - * - * @param string $name Event name. - * @param array $params Callback parameters. - * - * @throws Exception If event name isn't found or if event throws an `Exception`. + * Runs a named callable and its filters. * - * @return mixed Output of callback + * @param string $name Callable name. + * @param mixed[] $params Callable input. + * @return void|never|mixed Callable output. + * @throws Throwable If the callable or its filters throw an `Throwable`. + * @throws OutOfBoundsException If callable name is not found. */ public function run(string $name, array $params = []) { - $this->runPreFilters($name, $params); - $output = $this->runEvent($name, $params); + if (get_called_class() !== self::class) { + /* If dispatcher was extended, use the possibly overridden methods + for pre/post filters and event execution. */ + $this->runPreFilters($name, $params); + $output = $this->runEvent($name, $params); - return $this->runPostFilters($name, $output); + return $this->runPostFilters($name, $output); + } + + // Executes the FilteredCallable, responsible of running its filters. + $filteredCallable = $this->get($name); + + if (!$filteredCallable) { + throw new OutOfBoundsException("Event '$name' isn't found."); + } + + return $filteredCallable(...$params); } /** - * @param array &$params - * - * @throws Exception - * - * @return $this + * @deprecated Don't override this method. + * @param string $eventName Callable name. + * @param mixed[] &$params Callable input. + * @throws Throwable If any of the callable filters throw an `Throwable`. */ protected function runPreFilters(string $eventName, array &$params): self { @@ -125,27 +123,30 @@ protected function runPreFilters(string $eventName, array &$params): self } /** - * @param array &$params - * - * @return void|mixed - * @throws Exception + * @deprecated Don't override this method. + * @param string $eventName Callable name. + * @param mixed[] $params Callable input. + * @return void|never|mixed + * @throws Throwable If the callable or its filters throw an `Throwable`. + * @throws OutOfBoundsException If callable name is not found. */ - protected function runEvent(string $eventName, array &$params) + protected function runEvent(string $eventName, array $params) { $requestedMethod = $this->get($eventName); if ($requestedMethod === null) { - throw new Exception("Event '$eventName' isn't found."); + throw new OutOfBoundsException("Event '$eventName' isn't found."); } return $this->execute($requestedMethod, $params); } /** - * @param mixed &$output - * - * @return mixed - * @throws Exception + * @deprecated Don't override this method. + * @template Output of mixed + * @param Output &$output Callable output. + * @return Output Callable output. + * @throws Throwable If any of the callable filters throw an `Throwable`. */ protected function runPostFilters(string $eventName, &$output) { @@ -161,54 +162,53 @@ protected function runPostFilters(string $eventName, &$output) } /** - * Assigns a callback to an event. + * Assigns a name to a callable. * - * @param string $name Event name. - * @param callable(): (void|mixed) $callback Callback function. - * - * @return $this + * @param string $name Callable name. + * @param callable $callback Callable. */ public function set(string $name, callable $callback): self { $this->events[$name] = $callback; + $this->namedCallables[$name] = new FilteredCallable($callback); return $this; } /** - * Gets an assigned callback. - * - * @param string $name Event name. + * Returns a callable by its name. * - * @return null|(callable(): (void|mixed)) $callback Callback function. + * @param string $name Callable name. + * @return ?callable */ public function get(string $name): ?callable { - return $this->events[$name] ?? null; + return $this->namedCallables[$name] ?? $this->events[$name] ?? null; } /** - * Checks if an event has been set. + * Checks if a callable exists by its name. * - * @param string $name Event name. - * - * @return bool If event exists or doesn't exists. + * @param string $name Callable name. */ public function has(string $name): bool { - return isset($this->events[$name]); + return $this->get($name) !== null; } /** - * Clears an event. If no name is given, all events will be removed. + * Clears a callable and its filters by its name. + * + * If no name is provided, clears all callables names and theirs filters. * - * @param ?string $name Event name. + * @param ?string $name Callable name. */ public function clear(?string $name = null): void { if ($name !== null) { unset($this->events[$name]); unset($this->filters[$name]); + unset($this->namedCallables[$name]); return; } @@ -217,13 +217,11 @@ public function clear(?string $name = null): void } /** - * Hooks a callback to an event. + * Adds a filter to a callable. * - * @param string $name Event name + * @param string $name Callable name. * @param 'before'|'after' $type Filter type. - * @param callable(array &$params, mixed &$output): (void|false)|callable(mixed &$output): (void|false) $callback - * - * @return $this + * @param callable(mixed[] &$params): (void|never|false)|callable(mixed &$output): (void|never|false) $callback */ public function hook(string $name, string $type, callable $callback): self { @@ -247,27 +245,39 @@ public function hook(string $name, string $type, callable $callback): self $this->filters[$name][$type][] = $callback; + $filteredCallable = $this->get($name); + + if ($filteredCallable instanceof FilteredCallable) { + if ($type === self::FILTER_BEFORE) { + $filteredCallable->pushBeforeFilter($callback); + } + + if ($type === self::FILTER_AFTER) { + $filteredCallable->pushAfterFilter($callback); + } + } + return $this; } /** - * Executes a chain of method filters. - * - * @param array &$params, mixed &$output): (void|false)> $filters - * Chain of filters. - * @param array $params Method parameters. - * @param mixed $output Method output. - * - * @throws Exception If an event throws an `Exception` or if `$filters` contains an invalid filter. + * Executes a list of callable filters. + * + * @deprecated This method will be removed. + * @param (callable(mixed[] &$params, mixed &$output): (void|never|false))[] $filters Callable filters. + * @param mixed[] &$params Callable input. + * @param mixed &$output Callable output. + * @throws Throwable If any of the callable filters throw an `Throwable`. + * @throws InvalidArgumentException If any of the callable filters is not a `callable`. */ public function filter(array $filters, array &$params, &$output): void { - foreach ($filters as $key => $callback) { - if (!is_callable($callback)) { + foreach ($filters as $key => $filter) { + if (!is_callable($filter)) { throw new InvalidArgumentException("Invalid callable \$filters[$key]."); } - $continue = $callback($params, $output); + $continue = $filter($params, $output); if ($continue === false) { break; @@ -276,159 +286,202 @@ public function filter(array $filters, array &$params, &$output): void } /** - * Executes a callback function. + * Executes a callable. * - * @param callable-string|(callable(): mixed)|array{class-string|object, string} $callback - * Callback function. - * @param array $params Function parameters. - * - * @return mixed Function results. - * @throws Exception If `$callback` also throws an `Exception`. + * @template T of object + * @param callable|array{class-string|T, string}|string $callback Callable. + * @param mixed[] $params Callable input. + * @return mixed Callable output. + * @throws Throwable If the callable throws an `Throwable`. */ - public function execute($callback, array &$params = []) + public function execute($callback, array $params = []) { - if (is_string($callback) === true && (strpos($callback, '->') !== false || strpos($callback, '::') !== false)) { + $this->verifyValidFunction($callback); + + if (is_string($callback)) { $callback = $this->parseStringClassAndMethod($callback); } - return $this->invokeCallable($callback, $params); + if (is_callable($callback) && !is_array($callback)) { + return $callback(...$params); + } + + [$class, $method] = $callback; + $object = null; + + if (is_object($class)) { + return $class->$method(...$params); + } + + if ($this->mustUseContainer($class)) { + $object = $this->resolveContainerClass($class, $params); + + if (is_object($object)) { + $class = $object; + } + } + + $this->verifyValidClassCallable($class, $method, $object); + + if (is_string($class)) { + $class = new $class($this->engine); + } + + return $class->$method(...$params); } /** - * Parses a string into a class and method. - * - * @param string $classAndMethod Class and method + * Parses a string with an unloaded class and method into an array. * - * @return array{0: class-string|object, 1: string} Class and method + * @deprecated Use `execute()` instead. + * @param string $classAndMethod An string with an unloaded class and method, + * like `ClassName::method` or `ClassName->method`. + * @return array{class-string, string} + * @throws InvalidArgumentException If the string is not in a valid format. */ public function parseStringClassAndMethod(string $classAndMethod): array { - $classParts = explode('->', $classAndMethod); + foreach (self::CALLABLE_STRING_OPERATORS as $operator) { + $classAndMethod = explode($operator, $classAndMethod); - if (count($classParts) === 1) { - $classParts = explode('::', $classParts[0]); + if (count($classAndMethod) === 2) { + return [$classAndMethod[0], $classAndMethod[1]]; + } + + [$classAndMethod] = $classAndMethod; } - return $classParts; + $message = "Invalid string format '$classAndMethod', use 'ClassName::method' or 'ClassName->method'."; + + throw new InvalidArgumentException($message); } /** - * Calls a function. - * - * @param callable $func Name of function to call. - * @param array &$params Function parameters. + * Executes a callable. * - * @return mixed Function results. - * @deprecated 3.7.0 Use invokeCallable instead + * @deprecated Use execute instead. + * @param callable $func Callable. + * @param mixed[] $params Callable input. + * @return mixed Callable output. + * @throws Throwable If the callable throws an `Throwable`. */ - public function callFunction(callable $func, array &$params = []) + public function callFunction(callable $func, array $params = []) { - return $this->invokeCallable($func, $params); + return $this->execute($func, $params); } /** - * Invokes a method. + * Executes a callable. * - * @param array{0: class-string|object, 1: string} $func Class method. - * @param array &$params Class method parameters. - * - * @return mixed Function results. - * @throws TypeError For nonexistent class name. - * @deprecated 3.7.0 Use invokeCallable instead. + * @deprecated Use execute instead. + * @template T of object + * @param array{class-string|T, string} $func Callable. + * @param mixed[] $params Callable input. + * @return mixed Callable output. + * @throws Throwable If the callable throws an `Throwable`. */ - public function invokeMethod(array $func, array &$params = []) + public function invokeMethod(array $func, array $params = []) { - return $this->invokeCallable($func, $params); + return $this->execute($func, $params); } /** - * Invokes a callable (anonymous function or Class->method). + * Executes a callable. * - * @param array{0: class-string|object, 1: 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 + * @deprecated Use execute instead. + * @template T of object + * @param callable|array{class-string|T, string}|string $func Callable. + * @param mixed[] $params Callable input. + * @return mixed Callable output. + * @throws Throwable If the callable throws an `Throwable`. */ - public function invokeCallable($func, array &$params = []) + public function invokeCallable($func, array $params = []) { - // If this is a directly callable function, call it - if (is_array($func) === false) { - $this->verifyValidFunction($func); + return $this->execute($func, $params); + } - return call_user_func_array($func, $params); + /** + * Verifies if the provided function is valid callable. + * + * @deprecated This method will be removed. + * @template T of object + * @param callable|array{class-string|T, string}|string $callback Callable. + * @throws InvalidArgumentException If the function is not valid callable. + */ + protected function verifyValidFunction($callback): void + { + /* + ✔️ function () {} + ✔️ Closure + ✔️ Object that implements __invoke + ✔️ 'existingFunction' + ✔️ 'ExistingClass::existingAccessibleStaticMethod' + ✔️ ['ExistingClass', 'existingAccessibleStaticMethod'] + ✔️ [$object, 'existingAccessibleMethod'] + ✔️ [$object, 'existingAccessibleStaticMethod'] + */ + if (is_callable($callback)) { + return; } - [$class, $method] = $func; + /* + ✔️ ['UnloadedClass', 'method'] + ✔️ ['UnloadedClass', 'staticMethod'] + */ + if ( + is_array($callback) + && count($callback) === 2 + && is_string($callback[0]) + && is_string($callback[1]) + ) { + return; + } - $mustUseTheContainer = $this->mustUseContainer($class); + /* + ✔️ 'UnloadedClass::method' + ✔️ 'UnloadedClass->method' + */ + if (is_string($callback)) { + foreach (self::CALLABLE_STRING_OPERATORS as $operator) { + $callback = explode($operator, $callback); - if ($mustUseTheContainer === true) { - $resolvedClass = $this->resolveContainerClass($class, $params); + if (count($callback) === 2) { + return; + } - if ($resolvedClass) { - $class = $resolvedClass; + [$callback] = $callback; } } - $this->verifyValidClassCallable($class, $method, $resolvedClass ?? null); - - // Class is a string, and method exists, create the object by hand and inject only the Engine - if (is_string($class)) { - $class = new $class($this->engine); - } - - return call_user_func_array([$class, $method], $params); - } - - /** - * Handles invalid callback types. - * - * @param callable-string|(callable(): mixed)|array{0: class-string|object, 1: string} $callback - * Callback function. - * - * @throws InvalidArgumentException If `$callback` is an invalid type. - */ - protected function verifyValidFunction($callback): void - { - if (is_string($callback) && !function_exists($callback)) { - throw new InvalidArgumentException('Invalid callback specified.'); - } + throw new InvalidArgumentException('Invalid callback specified.'); } /** - * Verifies if the provided class and method are valid callable. - * - * @param class-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. + * @deprecated This method will be removed. + * @template T of object + * @param class-string|T $class The class name or object. + * @param ?T $resolvedClass A class instance. + * @return void|never + * @throws InvalidArgumentException If the class or method is not found. + * @throws Throwable If the container throws an exception. */ - protected function verifyValidClassCallable($class, $method, $resolvedClass): void + protected function verifyValidClassCallable($class, string $method, ?object $resolvedClass): void { $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) { - $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 (!$resolvedClass && $this->containerException !== null) { + if (!is_object($class) && !class_exists($class)) { + $message = "Class '$class' not found. Is it being correctly autoloaded with Flight::path()?"; + $exception = new InvalidArgumentException($message); + } elseif ($this->containerException) { $exception = $this->containerException; - - // Class is there, but no method - } elseif (is_object($class) === true && method_exists($class, $method) === false) { - $classNamespace = get_class($class); - $exception = new Exception("Class found, but method '$classNamespace::$method' not found."); + } elseif (is_object($class) && !method_exists($class, $method)) { + $fqcn = get_class($class); + $exception = new InvalidArgumentException("Class found, but method '$fqcn::$method' not found."); } - if ($exception !== null) { + if ($exception) { $this->fixOutputBuffering(); throw $exception; @@ -436,39 +489,38 @@ protected function verifyValidClassCallable($class, $method, $resolvedClass): vo } /** - * Resolves the container class. + * Resolves a class from the container. * - * @param class-string $class Class name. - * @param array &$params Class constructor parameters. - * - * @return ?object Class object. + * @deprecated This method will be removed. + * @template T of object + * @param class-string $class The class name. + * @param mixed[] $params Class constructor arguments. + * @return ?T The resolved class instance, or null if not found. */ - public function resolveContainerClass(string $class, array &$params) + public function resolveContainerClass(string $class, array $params): ?object { - // PSR-11 - if (is_a($this->containerHandler, '\Psr\Container\ContainerInterface')) { + $container = $this->containerHandler; + + if ($container instanceof Container) { try { - return $this->containerHandler->get($class); - } catch (Throwable $exception) { + return $container->get($class); + } catch (ContainerExceptionInterface $exception) { + $this->containerException = $exception; + return null; } } - // Just a callable where you configure the behavior (Dice, PHP-DI, etc.) - if (is_callable($this->containerHandler)) { - /* This is to catch all the error that could be thrown by whatever - container you are using */ + if (is_callable($container)) { try { - return ($this->containerHandler)($class, $params); - - // could not resolve a class for some reason - } catch (Exception $exception) { + return $container($class, $params); + } catch (Throwable $throwable) { // 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 = $exception; + $this->containerException = $throwable; } } @@ -476,21 +528,28 @@ public function resolveContainerClass(string $class, array &$params) } /** - * Checks to see if a container should be used or not. + * Checks if the class must be resolved by the container. * - * @param string|object $class the class to verify - * - * @return boolean + * @deprecated This method will be removed. + * @template T of object + * @param class-string|T $class Class name or object. */ public function mustUseContainer($class): bool { - return $this->containerHandler !== null && ( - (is_object($class) === true && strpos(get_class($class), 'flight\\') === false) - || is_string($class) - ); + $container = $this->containerHandler; + + if (is_object($class)) { + $class = get_class($class); + } + + if ($container instanceof Container && $container->has($class)) { + return true; + } + + return is_callable($container); } - /** Because this could throw an exception in the middle of an output buffer, */ + /** Fixes output buffering issues when an exception is thrown. */ protected function fixOutputBuffering(): void { // Cause PHPUnit has 1 level of output buffering by default @@ -499,15 +558,12 @@ protected function fixOutputBuffering(): void } } - /** - * Resets the object to the initial state. - * - * @return $this - */ + /** Resets the dispatcher state by clearing all events, filters, and named filtered callables. */ public function reset(): self { $this->events = []; $this->filters = []; + $this->namedCallables = []; return $this; } diff --git a/flight/core/FilteredCallable.php b/flight/core/FilteredCallable.php new file mode 100644 index 00000000..9e686cdc --- /dev/null +++ b/flight/core/FilteredCallable.php @@ -0,0 +1,90 @@ +closure = Closure::fromCallable($callable); + } + + /** + * @param mixed ...$input + * @return Output + * @throws Throwable + */ + public function __invoke(...$input) + { + foreach ($this->beforeFilters as $filter) { + $filterReturnValue = $filter($input); + + if ($filterReturnValue === false) { + break; + } + } + + $closure = $this->closure; + + $output = $closure(...$input); + + foreach ($this->afterFilters as $filter) { + $filterReturnValue = $filter($output); + + if ($filterReturnValue === false) { + break; + } + } + + return $output; + } + + /** @param callable(mixed[] &$input): (void|never|false) $filter */ + public function pushBeforeFilter(callable $filter): void + { + if (!in_array($filter, $this->beforeFilters)) { + $this->beforeFilters[] = $filter; + } + } + + /** @param callable(Output &$output): (void|never|false) $filter */ + public function pushAfterFilter(callable $filter): void + { + if (!in_array($filter, $this->afterFilters)) { + $filterReflectionFunction = new ReflectionFunction($filter); + + if ($filterReflectionFunction->getNumberOfParameters() === 2) { + $filter = static function (&$output) use ($filter) { + static $input = []; + + return $filter($input, $output); + }; + } + + $this->afterFilters[] = $filter; + } + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 6694874d..e61a6556 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -6,6 +6,10 @@ flight tests - + + + + +