Merge branch 'master' into dispatcher-refactor

pull/567/head
fadrian06 10 months ago
commit 62f15baf55

@ -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.

@ -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",

@ -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<int, callable|object> $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<string> List of methods that can be extended in the Engine class. */
/**
* @var array<string> 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(
'<h1>500 Internal Server Error</h1><h3>%s (%s)</h3><pre>%s</pre>',
'<h1>500 Internal Server Error</h1>' .
'<h3>%s (%s)</h3>' .
'<pre>%s</pre>',
$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) . '"');

@ -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 <mike@mikecao.com>
*
* # 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<string, mixed> $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<T> $class Fully Qualified Class Name
* @param array<int, mixed> $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;
}

@ -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<string, Closure(): (void|mixed)> 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<int, mixed> &$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<int, mixed> &$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<int, mixed> &$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<int, mixed> &$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();
}
}
/**

@ -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;
}
}

@ -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<int|string,mixed> $params - Ex: [ $something ]
*
* @return array<string,mixed>
* @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<int|string,mixed> $params - Ex: [ $something ]
*
* @return array<int,array<string,mixed>>
* @return array<int,Collection>
*/
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;
}
/**

@ -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';
}

@ -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) {

@ -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<int, string> $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]];
}

@ -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);
}
}

@ -23,6 +23,7 @@
</testsuites>
<logging />
<php>
<ini name="error_reporting" value="-1"/>
<env name="PHPUNIT_TEST" value="true" force="true" />
</php>
</phpunit>

@ -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' ]);
}
}

@ -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 ');
}
}

@ -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();
}
}

@ -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 '<p>This is a route with html</p>';
})->addMiddleware($middleware);
Flight::request()->url = '/route-with-html';
Flight::start();
$this->expectOutputString('Thisisaroutewithhtml');
}
}

@ -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());
}
}

@ -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']);

@ -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());
}
}

@ -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']);

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace tests\classes;
use flight\database\PdoWrapper;
use flight\util\Collection;
class Container
{
protected Collection $collection;
protected PdoWrapper $pdoWrapper;
public function __construct(Collection $collection, PdoWrapper $pdoWrapper)
{
$this->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!';
}
}

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace tests\classes;
use flight\Engine;
class ContainerDefault
{
protected Engine $app;
public function __construct(Engine $engine)
{
$this->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 '<span id="infotext">Route text:</span> The container successfully injected a value into the engine! Engine class: <b>' . get_class($this->app) . '</b> test_me_out Value: <b>' . $this->app->get('test_me_out') . '</b>';
}
}

@ -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
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

@ -199,7 +199,7 @@ echo '
<li><a href="' . Flight::getUrl('final_group') . '">Mega group</a></li>
<li><a href="/error">Error</a></li>
<li><a href="/json">JSON</a></li>
<li><a href="/json?jsonp=myjson">JSONP</a></li>
<li><a href="/jsonp?jsonp=myjson">JSONP</a></li>
<li><a href="/halt">Halt</a></li>
<li><a href="/redirect">Redirect</a></li>
</ul>';

@ -73,10 +73,17 @@ class LayoutMiddleware
<li><a href="{$final_route}">Mega group</a></li>
<li><a href="/error">Error</a></li>
<li><a href="/json">JSON</a></li>
<li><a href="/json?jsonp=myjson">JSONP</a></li>
<li><a href="/jsonp?jsonp=myjson">JSONP</a></li>
<li><a href="/halt">Halt</a></li>
<li><a href="/redirect">Redirect</a></li>
<li><a href="/streamResponse">Stream</a></li>
<li><a href="/overwrite">Overwrite Body</a></li>
<li><a href="/redirect/before%2Fafter">Slash in Param</a></li>
<li><a href="/わたしはひとです">UTF8 URL</a></li>
<li><a href="/わたしはひとです/ええ">UTF8 URL w/ Param</a></li>
<li><a href="/dice">Dice Container</a></li>
<li><a href="/no-container">No Container Registered</a></li>
<li><a href="/Pascal_Snake_Case">Pascal_Snake_Case</a></li>
</ul>
HTML;
echo '<div id="container">';

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
class OverwriteBodyMiddleware
{
public function after()
{
$response = Flight::response();
$response->write(str_replace('<span style="color:red; font-weight: bold;">failed</span>', '<span style="color:green; font-weight: bold;">successfully works!</span>', $response->getBody()), true);
}
}

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
class Pascal_Snake_Case // phpcs:ignore
{
public function doILoad() // phpcs:ignore
{
echo 'Yes, I load!!!';
}
}

@ -2,6 +2,11 @@
declare(strict_types=1);
use flight\core\Loader;
use flight\database\PdoWrapper;
use tests\classes\Container;
use tests\classes\ContainerDefault;
/*
* This is the test file where we can open up a quick test server and make
* sure that the UI is really working the way we would expect it to.
@ -14,9 +19,8 @@ declare(strict_types=1);
Flight::set('flight.content_length', false);
Flight::set('flight.views.path', './');
Flight::set('flight.views.extension', '.phtml');
//Flight::set('flight.v2.output_buffering', true);
require_once 'LayoutMiddleware.php';
Loader::setV2ClassLoading(false);
Flight::path(__DIR__);
Flight::group('', function () {
@ -119,6 +123,30 @@ Flight::group('', function () {
}
echo "is successful!!";
})->streamWithHeaders(['Content-Type' => 'text/html', 'status' => 200 ]);
// Test 14: Overwrite the body with a middleware
Flight::route('/overwrite', function () {
echo '<span id="infotext">Route text:</span> This route status is that it <span style="color:red; font-weight: bold;">failed</span>';
})->addMiddleware([new OverwriteBodyMiddleware()]);
// Test 15: UTF8 Chars in url
Flight::route('/わたしはひとです', function () {
echo '<span id="infotext">Route text:</span> This route status is that it <span style="color:green; font-weight: bold;">succeeded はい!!!</span>';
});
// Test 16: UTF8 Chars in url with utf8 params
Flight::route('/わたしはひとです/@name', function ($name) {
echo '<span id="infotext">Route text:</span> This route status is that it <span style="color:' . ($name === 'ええ' ? 'green' : 'red') . '; font-weight: bold;">' . ($name === 'ええ' ? 'succeeded' : 'failed') . ' URL Param: ' . $name . '</span>';
});
// Test 17: Slash in param
Flight::route('/redirect/@id', function ($id) {
echo '<span id="infotext">Route text:</span> This route status is that it <span style="color:' . ($id === 'before/after' ? 'green' : 'red') . '; font-weight: bold;">' . ($id === 'before/after' ? 'succeeded' : 'failed') . ' URL Param: ' . $id . '</span>';
});
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 "<a href='/'>Go back</a>";
});
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();

Loading…
Cancel
Save