diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..75f03e1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: Pull Request Check +on: [pull_request] + +jobs: + unit-test: + name: Unit testing + strategy: + fail-fast: false + matrix: + php: [7.4, 8.0, 8.1, 8.2, 8.3, 8.4] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, mbstring + tools: composer:v2 + - run: composer install + - run: composer test \ No newline at end of file diff --git a/README.md b/README.md index a749fdb..9a043f7 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,26 @@ -[![Version](http://poser.pugx.org/flightphp/core/version)](https://packagist.org/packages/flightphp/core) -[![Monthly Downloads](http://poser.pugx.org/flightphp/core/d/monthly)](https://packagist.org/packages/flightphp/core) +[![Version](https://poser.pugx.org/flightphp/core/version)](https://packagist.org/packages/flightphp/core) +[![Monthly Downloads](https://poser.pugx.org/flightphp/core/d/monthly)](https://packagist.org/packages/flightphp/core) ![PHPStan: Level 6](https://img.shields.io/badge/PHPStan-level%206-brightgreen.svg?style=flat) -[![License](http://poser.pugx.org/flightphp/core/license)](https://packagist.org/packages/flightphp/core) -[![PHP Version Require](http://poser.pugx.org/flightphp/core/require/php)](https://packagist.org/packages/flightphp/core) +[![License](https://poser.pugx.org/flightphp/core/license)](https://packagist.org/packages/flightphp/core) +[![PHP Version Require](https://poser.pugx.org/flightphp/core/require/php)](https://packagist.org/packages/flightphp/core) ![Matrix](https://img.shields.io/matrix/flight-php-framework%3Amatrix.org?server_fqdn=matrix.org&style=social&logo=matrix) +[![](https://dcbadge.limes.pink/api/server/https://discord.gg/Ysr4zqHfbX)](https://discord.gg/Ysr4zqHfbX) # What is Flight? Flight is a fast, simple, extensible framework for PHP. Flight enables you to -quickly and easily build RESTful web applications. +quickly and easily build RESTful web applications. Flight also has zero dependencies. # Basic Usage +First install it with Composer + +``` +composer require flightphp/core +``` + +or you can download a zip of this repo. Then you would have a basic `index.php` file like the following: + ```php // if installed with composer require 'vendor/autoload.php'; @@ -25,6 +34,24 @@ Flight::route('/', function () { Flight::start(); ``` +## Is it fast? + +Yes! Flight is fast. It is one of the fastest PHP frameworks available. You can see all the benchmarks at [TechEmpower](https://www.techempower.com/benchmarks/#section=data-r18&hw=ph&test=frameworks) + +See the benchmark below with some other popular PHP frameworks. This is measured in requests processed within the same timeframe. + +| Framework | Plaintext Requests| JSON Requests| +| --------- | ------------ | ------------ | +| Flight | 190,421 | 182,491 | +| Yii | 145,749 | 131,434 | +| Fat-Free | 139,238 | 133,952 | +| Slim | 89,588 | 87,348 | +| Phalcon | 95,911 | 87,675 | +| Symfony | 65,053 | 63,237 | +| Lumen | 40,572 | 39,700 | +| Laravel | 26,657 | 26,901 | +| CodeIgniter | 20,628 | 19,901 | + ## Skeleton App You can also install a skeleton app. Go to [flightphp/skeleton](https://github.com/flightphp/skeleton) for instructions on how to get started! @@ -37,9 +64,11 @@ 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) +[![](https://dcbadge.limes.pink/api/server/https://discord.gg/Ysr4zqHfbX)](https://discord.gg/Ysr4zqHfbX) + # 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. +If you have a current project on v2, you should be able to upgrade to v3 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 diff --git a/composer.json b/composer.json index bf9a243..1fd7e82 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ { "name": "Franyer Sánchez", "email": "franyeradriansanchez@gmail.com", - "homepage": "https://faslatam.000webhostapp.com", + "homepage": "https://faslatam.42web.io", "role": "Maintainer" }, { @@ -41,14 +41,14 @@ }, "require-dev": { "ext-pdo_sqlite": "*", - "flightphp/runway": "^0.2.0", + "flightphp/runway": "^0.2.3 || ^1.0", "league/container": "^4.2", "level-2/dice": "^4.0", - "phpstan/extension-installer": "^1.3", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.5", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^9.6", "rregeer/phpunit-coverage-check": "^0.3.1", - "squizlabs/php_codesniffer": "^3.8" + "squizlabs/php_codesniffer": "^3.11" }, "config": { "allow-plugins": { @@ -63,7 +63,7 @@ "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", + "lint": "phpstan --no-progress --memory-limit=256M -cphpstan.neon", "beautify": "phpcbf --standard=phpcs.xml", "phpcs": "phpcs --standard=phpcs.xml -n", "post-install-cmd": [ diff --git a/flight/Engine.php b/flight/Engine.php index a1378a6..63fd05d 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -8,6 +8,7 @@ use Closure; use ErrorException; use Exception; use flight\core\Dispatcher; +use flight\core\EventDispatcher; use flight\core\Loader; use flight\net\Request; use flight\net\Response; @@ -29,26 +30,35 @@ use flight\net\Route; * @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. * + * # Class registration + * @method EventDispatcher eventDispatcher() Gets event dispatcher + * * # Routing - * @method Route route(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method Route route(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a URL to a callback function with all applicable methods - * @method void group(string $pattern, callable $callback, array $group_middlewares = []) + * @method void group(string $pattern, callable $callback, (class-string|callable|array{0: class-string, 1: string})[] $group_middlewares = []) * Groups a set of routes together under a common prefix. - * @method Route post(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method Route post(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a POST URL to a callback function. - * @method Route put(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method Route put(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a PUT URL to a callback function. - * @method Route patch(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method Route patch(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a PATCH URL to a callback function. - * @method Route delete(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method Route delete(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a DELETE URL to a callback function. + * @method void resource(string $pattern, class-string $controllerClass, array> $methods = []) + * Adds standardized RESTful routes for a controller. * @method Router router() Gets router * @method string getUrl(string $alias) Gets a url from an alias * * # Views - * @method void render(string $file, ?array $data = null, ?string $key = null) Renders template + * @method void render(string $file, ?array $data = null, ?string $key = null) Renders template * @method View view() Gets current view * + * # Events + * @method void onEvent(string $event, callable $callback) Registers a callback for an event. + * @method void triggerEvent(string $event, ...$args) Triggers an event. + * * # Request-Response * @method Request request() Gets current request * @method Response response() Gets current response @@ -62,9 +72,10 @@ use flight\net\Route; * @method void jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) * Sends a JSONP response. * - * # HTTP caching + * # HTTP methods * @method void etag(string $id, ('strong'|'weak') $type = 'strong') Handles ETag HTTP caching. * @method void lastModified(int $time) Handles last modified HTTP caching. + * @method void download(string $filePath) Downloads a file * * phpcs:disable PSR2.Methods.MethodDeclaration.Underscore */ @@ -74,9 +85,29 @@ class Engine * @var array List of methods that can be extended in the Engine class. */ private const MAPPABLE_METHODS = [ - 'start', 'stop', 'route', 'halt', 'error', 'notFound', - 'render', 'redirect', 'etag', 'lastModified', 'json', 'jsonHalt', 'jsonp', - 'post', 'put', 'patch', 'delete', 'group', 'getUrl' + 'start', + 'stop', + 'route', + 'halt', + 'error', + 'notFound', + 'render', + 'redirect', + 'etag', + 'lastModified', + 'json', + 'jsonHalt', + 'jsonp', + 'post', + 'put', + 'patch', + 'delete', + 'group', + 'getUrl', + 'download', + 'resource', + 'onEvent', + 'triggerEvent' ]; /** @var array Stored variables. */ @@ -85,12 +116,18 @@ class Engine /** Class loader. */ protected Loader $loader; - /** Event dispatcher. */ + /** Method and class dispatcher. */ protected Dispatcher $dispatcher; + /** Event dispatcher. */ + protected EventDispatcher $eventDispatcher; + /** If the framework has been initialized or not. */ protected bool $initialized = false; + /** If the request has been handled or not. */ + protected bool $requestHandled = false; + public function __construct() { $this->loader = new Loader(); @@ -144,6 +181,9 @@ class Engine $this->dispatcher->setEngine($this); // Register default components + $this->map('eventDispatcher', function () { + return EventDispatcher::getInstance(); + }); $this->loader->register('request', Request::class); $this->loader->register('response', Response::class); $this->loader->register('router', Router::class); @@ -176,7 +216,7 @@ class Engine } // Set case-sensitivity - $self->router()->case_sensitive = $self->get('flight.case_sensitive'); + $self->router()->caseSensitive = $self->get('flight.case_sensitive'); // Set Content-Length $self->response()->content_length = $self->get('flight.content_length'); // This is to maintain legacy handling of output buffering @@ -404,26 +444,26 @@ class Engine if ($eventName === Dispatcher::FILTER_BEFORE && is_object($middleware) === true && ($middleware instanceof Closure)) { $middlewareObject = $middleware; - // If the object has already been created, we can just use it if the event name exists. + // If the object has already been created, we can just use it if the event name exists. } elseif (is_object($middleware) === true) { - $middlewareObject = method_exists($middleware, $eventName) === true ? [ $middleware, $eventName ] : false; + $middlewareObject = method_exists($middleware, $eventName) === true ? [$middleware, $eventName] : false; - // If the middleware is a string, we need to create the object and then call the event. + // If the middleware is a string, we need to create the object and then call the event. } elseif (is_string($middleware) === true && method_exists($middleware, $eventName) === true) { $resolvedClass = null; // if there's a container assigned, we should use it to create the object if ($this->dispatcher->mustUseContainer($middleware) === true) { $resolvedClass = $this->dispatcher->resolveContainerClass($middleware, $params); - // otherwise just assume it's a plain jane class, so inject the engine - // just like in Dispatcher::invokeCallable() + // otherwise just assume it's a plain jane class, so inject the engine + // just like in Dispatcher::invokeCallable() } elseif (class_exists($middleware) === true) { $resolvedClass = new $middleware($this); } // If something was resolved, create an array callable that will be passed in later. if ($resolvedClass !== null) { - $middlewareObject = [ $resolvedClass, $eventName ]; + $middlewareObject = [$resolvedClass, $eventName]; } } @@ -444,7 +484,9 @@ class Engine // Here is the array callable $middlewareObject that we created earlier. // It looks bizarre but it's really calling [ $class, $method ]($params) // Which loosely translates to $class->$method($params) + $start = microtime(true); $middlewareResult = $middlewareObject($params); + $this->triggerEvent('flight.middleware.executed', $route, $middleware, microtime(true) - $start); if ($useV3OutputBuffering === true) { $this->response()->write(ob_get_clean()); @@ -473,7 +515,22 @@ class Engine { $dispatched = false; $self = $this; + + // This behavior is specifically for test suites, and for async platforms like swoole, workerman, etc. + if ($this->requestHandled === false) { + // not doing much here, just setting the requestHandled flag to true + $this->requestHandled = true; + } else { + // deregister the request and response objects and re-register them with new instances + $this->unregister('request'); + $this->unregister('response'); + $this->register('request', Request::class); + $this->register('response', Response::class); + $this->router()->reset(); + } $request = $this->request(); + $this->triggerEvent('flight.request.received', $request); + $response = $this->response(); $router = $this->router(); @@ -495,8 +552,8 @@ class Engine // Route the request $failedMiddlewareCheck = false; - while ($route = $router->route($request)) { + $this->triggerEvent('flight.route.matched', $route); $params = array_values($route->params); // Add route info to the parameter list @@ -530,6 +587,7 @@ class Engine $failedMiddlewareCheck = true; break; } + $this->triggerEvent('flight.middleware.before', $route); } $useV3OutputBuffering = @@ -541,11 +599,12 @@ class Engine } // Call route handler + $routeStart = microtime(true); $continue = $this->dispatcher->execute( $route->callback, $params ); - + $this->triggerEvent('flight.route.executed', $route, microtime(true) - $routeStart); if ($useV3OutputBuffering === true) { $response->write(ob_get_clean()); } @@ -559,6 +618,7 @@ class Engine $failedMiddlewareCheck = true; break; } + $this->triggerEvent('flight.middleware.after', $route); } $dispatched = true; @@ -597,8 +657,9 @@ class Engine */ public function _error(Throwable $e): void { + $this->triggerEvent('flight.error', $e); $msg = sprintf( - <<500 Internal Server Error

%s (%s)

%s
@@ -610,6 +671,7 @@ class Engine try { $this->response() + ->cache(0) ->clearBody() ->status(500) ->write($msg) @@ -727,6 +789,20 @@ class Engine return $this->router()->map('DELETE ' . $pattern, $callback, $pass_route, $route_alias); } + /** + * Create a resource controller customizing the methods names mapping. + * + * @param class-string $controllerClass + * @param array> $options + */ + public function _resource( + string $pattern, + string $controllerClass, + array $options = [] + ): void { + $this->router()->mapResource($pattern, $controllerClass, $options); + } + /** * Stops processing and returns a given response. * @@ -736,6 +812,10 @@ class Engine */ public function _halt(int $code = 200, string $message = '', bool $actuallyExit = true): void { + if ($this->response()->getHeader('Cache-Control') === null) { + $this->response()->cache(0); + } + $this->response() ->clearBody() ->status($code) @@ -776,6 +856,8 @@ class Engine $url = $base . preg_replace('#/+#', '/', '/' . $url); } + $this->triggerEvent('flight.redirect', $url, $code); + $this->response() ->clearBody() ->status($code) @@ -799,7 +881,9 @@ class Engine return; } + $start = microtime(true); $this->view()->render($file, $data); + $this->triggerEvent('flight.view.rendered', $file, microtime(true) - $start); } /** @@ -808,7 +892,7 @@ class Engine * @param mixed $data JSON data * @param int $code HTTP status code * @param bool $encode Whether to perform JSON encoding - * @param string $charset Charset + * @param ?string $charset Charset * @param int $option Bitmask Json constant such as JSON_HEX_QUOT * * @throws Exception @@ -817,14 +901,16 @@ class Engine $data, int $code = 200, bool $encode = true, - string $charset = 'utf-8', + ?string $charset = 'utf-8', int $option = 0 ): void { + // add some default flags + $option |= JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR; $json = $encode ? json_encode($data, $option) : $data; $this->response() ->status($code) - ->header('Content-Type', 'application/json; charset=' . $charset) + ->header('Content-Type', 'application/json') ->write($json); if ($this->response()->v2_output_buffering === true) { $this->response()->send(); @@ -890,6 +976,20 @@ class Engine } } + /** + * Downloads a file + * + * @param string $filePath The path to the file to download + * + * @throws Exception If the file cannot be found + * + * @return void + */ + public function _download(string $filePath): void + { + $this->response()->downloadFile($filePath); + } + /** * Handles ETag HTTP caching. * @@ -939,4 +1039,26 @@ class Engine { return $this->router()->getUrlByAlias($alias, $params); } + + /** + * Adds an event listener. + * + * @param string $eventName The name of the event to listen to + * @param callable $callback The callback to execute when the event is triggered + */ + public function _onEvent(string $eventName, callable $callback): void + { + $this->eventDispatcher()->on($eventName, $callback); + } + + /** + * Triggers an event. + * + * @param string $eventName The name of the event to trigger + * @param mixed ...$args The arguments to pass to the event listeners + */ + public function _triggerEvent(string $eventName, ...$args): void + { + $this->eventDispatcher()->trigger($eventName, ...$args); + } } diff --git a/flight/Flight.php b/flight/Flight.php index 207d44b..064bd16 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -8,6 +8,7 @@ use flight\net\Response; use flight\net\Router; use flight\template\View; use flight\net\Route; +use flight\core\EventDispatcher; require_once __DIR__ . '/autoload.php'; @@ -23,35 +24,41 @@ require_once __DIR__ . '/autoload.php'; * @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) + * @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. * + * # Class registration + * @method EventDispatcher eventDispatcher() Gets event dispatcher + * * # Routing - * @method static Route route(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method static Route route(string $pattern, callable|string|array{0: class-string, 1: 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 = []) + * @method static void group(string $pattern, callable $callback, (class-string|callable|array{0: class-string, 1: string})[] $group_middlewares = []) * Groups a set of routes together under a common prefix. - * @method static Route post(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method static Route post(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a POST URL to a callback function. - * @method static Route put(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method static Route put(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a PUT URL to a callback function. - * @method static Route patch(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method static Route patch(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a PATCH URL to a callback function. - * @method static Route delete(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method static Route delete(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a DELETE URL to a callback function. + * @method static void resource(string $pattern, class-string $controllerClass, array> $methods = []) + * Adds standardized RESTful routes for a controller. * @method static Router router() Returns Router instance. * @method static string getUrl(string $alias, array $params = []) Gets a url from an alias - * * @method static void map(string $name, callable $callback) Creates a custom framework method. * + * # Filters * @method static void before(string $name, Closure(array &$params, string &$output): (void|false) $callback) * Adds a filter before a framework method. * @method static void after(string $name, Closure(array &$params, string &$output): (void|false) $callback) * Adds a filter after a framework method. * + * # Variables * @method static void set(string|iterable $key, mixed $value) Sets a variable. * @method static mixed get(?string $key) Gets a variable. * @method static bool has(string $key) Checks if a variable is set. @@ -62,31 +69,33 @@ require_once __DIR__ . '/autoload.php'; * Renders a template file. * @method static View view() Returns View instance. * + * # Events + * @method void onEvent(string $event, callable $callback) Registers a callback for an event. + * @method void triggerEvent(string $event, ...$args) Triggers an event. + * * # Request-Response * @method static Request request() Returns Request instance. * @method static Response response() Returns Response instance. * @method static void redirect(string $url, int $code = 303) Redirects to another URL. * @method static void json(mixed $data, int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) * Sends a JSON response. - * @method void jsonHalt(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * @method static void jsonHalt(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) * Sends a JSON response and immediately halts the request. * @method static void jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) * Sends a JSONP response. * @method static void error(Throwable $exception) Sends an HTTP 500 response. * @method static void notFound() Sends an HTTP 404 response. * - * # HTTP caching + * # HTTP methods * @method static void etag(string $id, ('strong'|'weak') $type = 'strong') Performs ETag HTTP caching. * @method static void lastModified(int $time) Performs last modified HTTP caching. + * @method static void download(string $filePath) Downloads a file */ class Flight { /** Framework engine. */ private static Engine $engine; - /** Whether or not the app has been initialized. */ - private static bool $initialized = false; - /** * Don't allow object instantiation * @@ -124,14 +133,7 @@ class Flight /** @return Engine Application instance */ public static function app(): Engine { - if (!self::$initialized) { - require_once __DIR__ . '/autoload.php'; - - self::setEngine(new Engine()); - self::$initialized = true; - } - - return self::$engine; + return self::$engine ?? self::$engine = new Engine(); } /** diff --git a/flight/core/EventDispatcher.php b/flight/core/EventDispatcher.php new file mode 100644 index 0000000..ea809b2 --- /dev/null +++ b/flight/core/EventDispatcher.php @@ -0,0 +1,137 @@ +> */ + protected array $listeners = []; + + /** + * Singleton instance of the EventDispatcher. + * + * @return self + */ + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Register a callback for an event. + * + * @param string $event Event name + * @param callable $callback Callback function + */ + public function on(string $event, callable $callback): void + { + if (isset($this->listeners[$event]) === false) { + $this->listeners[$event] = []; + } + $this->listeners[$event][] = $callback; + } + + /** + * Trigger an event with optional arguments. + * + * @param string $event Event name + * @param mixed ...$args Arguments to pass to the callbacks + */ + public function trigger(string $event, ...$args): void + { + if (isset($this->listeners[$event]) === true) { + foreach ($this->listeners[$event] as $callback) { + $result = call_user_func_array($callback, $args); + + // If you return false, it will break the loop and stop the other event listeners. + if ($result === false) { + break; // Stop executing further listeners + } + } + } + } + + /** + * Check if an event has any registered listeners. + * + * @param string $event Event name + * + * @return bool True if the event has listeners, false otherwise + */ + public function hasListeners(string $event): bool + { + return isset($this->listeners[$event]) === true && count($this->listeners[$event]) > 0; + } + + /** + * Get all listeners registered for a specific event. + * + * @param string $event Event name + * + * @return array Array of callbacks registered for the event + */ + public function getListeners(string $event): array + { + return $this->listeners[$event] ?? []; + } + + /** + * Get a list of all events that have registered listeners. + * + * @return array Array of event names + */ + public function getAllRegisteredEvents(): array + { + return array_keys($this->listeners); + } + + /** + * Remove a specific listener for an event. + * + * @param string $event the event name + * @param callable $callback the exact callback to remove + * + * @return void + */ + public function removeListener(string $event, callable $callback): void + { + if (isset($this->listeners[$event]) === true && count($this->listeners[$event]) > 0) { + $this->listeners[$event] = array_filter($this->listeners[$event], function ($listener) use ($callback) { + return $listener !== $callback; + }); + $this->listeners[$event] = array_values($this->listeners[$event]); // Re-index the array + } + } + + /** + * Remove all listeners for a specific event. + * + * @param string $event the event name + * + * @return void + */ + public function removeAllListeners(string $event): void + { + if (isset($this->listeners[$event]) === true) { + unset($this->listeners[$event]); + } + } + + /** + * Remove the current singleton instance of the EventDispatcher. + * + * @return void + */ + public static function resetInstance(): void + { + self::$instance = null; + } +} diff --git a/flight/database/PdoWrapper.php b/flight/database/PdoWrapper.php index 297121a..7cf74cf 100644 --- a/flight/database/PdoWrapper.php +++ b/flight/database/PdoWrapper.php @@ -4,12 +4,40 @@ declare(strict_types=1); namespace flight\database; +use flight\core\EventDispatcher; use flight\util\Collection; use PDO; use PDOStatement; class PdoWrapper extends PDO { + /** @var bool $trackApmQueries Whether to track application performance metrics (APM) for queries. */ + protected bool $trackApmQueries = false; + + /** @var array> $queryMetrics Metrics related to the database connection. */ + protected array $queryMetrics = []; + + /** @var array $connectionMetrics Metrics related to the database connection. */ + protected array $connectionMetrics = []; + + /** + * Constructor for the PdoWrapper class. + * + * @param string $dsn The Data Source Name (DSN) for the database connection. + * @param string|null $username The username for the database connection. + * @param string|null $password The password for the database connection. + * @param array|null $options An array of options for the PDO connection. + * @param bool $trackApmQueries Whether to track application performance metrics (APM) for queries. + */ + public function __construct(?string $dsn = null, ?string $username = '', ?string $password = '', ?array $options = null, bool $trackApmQueries = false) + { + parent::__construct($dsn, $username, $password, $options); + $this->trackApmQueries = $trackApmQueries; + if ($this->trackApmQueries === true) { + $this->connectionMetrics = $this->pullDataFromDsn($dsn); + } + } + /** * Use this for INSERTS, UPDATES, or if you plan on using a SELECT in a while loop * @@ -31,8 +59,19 @@ class PdoWrapper extends PDO $processed_sql_data = $this->processInStatementSql($sql, $params); $sql = $processed_sql_data['sql']; $params = $processed_sql_data['params']; + $start = $this->trackApmQueries === true ? microtime(true) : 0; + $memory_start = $this->trackApmQueries === true ? memory_get_usage() : 0; $statement = $this->prepare($sql); $statement->execute($params); + if ($this->trackApmQueries === true) { + $this->queryMetrics[] = [ + 'sql' => $sql, + 'params' => $params, + 'execution_time' => microtime(true) - $start, + 'row_count' => $statement->rowCount(), + 'memory_usage' => memory_get_usage() - $memory_start + ]; + } return $statement; } @@ -88,9 +127,20 @@ class PdoWrapper extends PDO $processed_sql_data = $this->processInStatementSql($sql, $params); $sql = $processed_sql_data['sql']; $params = $processed_sql_data['params']; + $start = $this->trackApmQueries === true ? microtime(true) : 0; + $memory_start = $this->trackApmQueries === true ? memory_get_usage() : 0; $statement = $this->prepare($sql); $statement->execute($params); $results = $statement->fetchAll(); + if ($this->trackApmQueries === true) { + $this->queryMetrics[] = [ + 'sql' => $sql, + 'params' => $params, + 'execution_time' => microtime(true) - $start, + 'row_count' => $statement->rowCount(), + 'memory_usage' => memory_get_usage() - $memory_start + ]; + } if (is_array($results) === true && count($results) > 0) { foreach ($results as &$result) { $result = new Collection($result); @@ -101,6 +151,56 @@ class PdoWrapper extends PDO return $results; } + /** + * Pulls the engine, database, and host from the DSN string. + * + * @param string $dsn The Data Source Name (DSN) string. + * + * @return array An associative array containing the engine, database, and host. + */ + protected function pullDataFromDsn(string $dsn): array + { + // pull the engine from the dsn (sqlite, mysql, pgsql, etc) + preg_match('/^([a-zA-Z]+):/', $dsn, $matches); + $engine = $matches[1] ?? 'unknown'; + + if ($engine === 'sqlite') { + // pull the path from the dsn + preg_match('/sqlite:(.*)/', $dsn, $matches); + $dbname = basename($matches[1] ?? 'unknown'); + $host = 'localhost'; + } else { + // pull the database from the dsn + preg_match('/dbname=([^;]+)/', $dsn, $matches); + $dbname = $matches[1] ?? 'unknown'; + // pull the host from the dsn + preg_match('/host=([^;]+)/', $dsn, $matches); + $host = $matches[1] ?? 'unknown'; + } + + return [ + 'engine' => $engine, + 'database' => $dbname, + 'host' => $host + ]; + } + + /** + * Logs the executed queries through the event dispatcher. + * + * This method enables logging of all the queries executed by the PDO wrapper. + * It can be useful for debugging and monitoring purposes. + * + * @return void + */ + public function logQueries(): void + { + if ($this->trackApmQueries === true && $this->connectionMetrics !== [] && $this->queryMetrics !== []) { + EventDispatcher::getInstance()->trigger('flight.db.queries', $this->connectionMetrics, $this->queryMetrics); + $this->queryMetrics = []; // Reset after logging + } + } + /** * Don't worry about this guy. Converts stuff for IN statements * diff --git a/flight/net/Request.php b/flight/net/Request.php index fd9194b..5164b11 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -211,6 +211,14 @@ class Request $this->data->setData($data); } } + // Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data + } elseif (in_array($this->method, [ 'PUT', 'DELETE', 'PATCH' ], true) === true) { + $body = $this->getBody(); + if ($body !== '') { + $data = []; + parse_str($body, $data); + $this->data->setData($data); + } } return $this; @@ -414,4 +422,63 @@ class Request return 'http'; } + + /** + * Retrieves the array of uploaded files. + * + * @return array|array>> The array of uploaded files. + */ + public function getUploadedFiles(): array + { + $files = []; + $correctedFilesArray = $this->reArrayFiles($this->files); + foreach ($correctedFilesArray as $keyName => $files) { + foreach ($files as $file) { + $UploadedFile = new UploadedFile( + $file['name'], + $file['type'], + $file['size'], + $file['tmp_name'], + $file['error'] + ); + if (count($files) > 1) { + $files[$keyName][] = $UploadedFile; + } else { + $files[$keyName] = $UploadedFile; + } + } + } + + return $files; + } + + /** + * Re-arranges the files in the given files collection. + * + * @param Collection $filesCollection The collection of files to be re-arranged. + * + * @return array>> The re-arranged files collection. + */ + protected function reArrayFiles(Collection $filesCollection): array + { + + $fileArray = []; + foreach ($filesCollection as $fileKeyName => $file) { + $isMulti = is_array($file['name']) === true && count($file['name']) > 1; + $fileCount = $isMulti === true ? count($file['name']) : 1; + $fileKeys = array_keys($file); + + for ($i = 0; $i < $fileCount; $i++) { + foreach ($fileKeys as $key) { + if ($isMulti === true) { + $fileArray[$fileKeyName][$i][$key] = $file[$key][$i]; + } else { + $fileArray[$fileKeyName][$i][$key] = $file[$key]; + } + } + } + } + + return $fileArray; + } } diff --git a/flight/net/Response.php b/flight/net/Response.php index 1798de5..f9ee9a2 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace flight\net; use Exception; +use flight\core\EventDispatcher; /** * The Response class represents an HTTP response. The object @@ -286,15 +287,9 @@ class Response */ public function cache($expires): self { - if ($expires === false) { + if ($expires === false || $expires === 0) { $this->headers['Expires'] = 'Mon, 26 Jul 1997 05:00:00 GMT'; - - $this->headers['Cache-Control'] = [ - 'no-store, no-cache, must-revalidate', - 'post-check=0, pre-check=0', - 'max-age=0', - ]; - + $this->headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'; $this->headers['Pragma'] = 'no-cache'; } else { $expires = \is_int($expires) ? $expires : strtotime($expires); @@ -432,18 +427,31 @@ class Response } } + $start = microtime(true); // Only for the v3 output buffering. if ($this->v2_output_buffering === false) { $this->processResponseCallbacks(); } - if (headers_sent() === false) { - $this->sendHeaders(); // @codeCoverageIgnore + if ($this->headersSent() === false) { + $this->sendHeaders(); } echo $this->body; - $this->sent = true; + + EventDispatcher::getInstance()->trigger('flight.response.sent', $this, microtime(true) - $start); + } + + /** + * Headers have been sent + * + * @return bool + * @codeCoverageIgnore + */ + public function headersSent(): bool + { + return headers_sent(); } /** @@ -470,4 +478,42 @@ class Response $this->body = $callback($this->body); } } + + /** + * Downloads a file. + * + * @param string $filePath The path to the file to be downloaded. + * + * @return void + */ + public function downloadFile(string $filePath): void + { + if (file_exists($filePath) === false) { + throw new Exception("$filePath cannot be found."); + } + + $fileSize = filesize($filePath); + + $mimeType = mime_content_type($filePath); + $mimeType = $mimeType !== false ? $mimeType : 'application/octet-stream'; + + $this->send(); + $this->setRealHeader('Content-Description: File Transfer'); + $this->setRealHeader('Content-Type: ' . $mimeType); + $this->setRealHeader('Content-Disposition: attachment; filename="' . basename($filePath) . '"'); + $this->setRealHeader('Expires: 0'); + $this->setRealHeader('Cache-Control: must-revalidate'); + $this->setRealHeader('Pragma: public'); + $this->setRealHeader('Content-Length: ' . $fileSize); + + // // Clear the output buffer + ob_clean(); + flush(); + + // // Read the file and send it to the output buffer + readfile($filePath); + if (empty(getenv('PHPUNIT_TEST'))) { + exit; // @codeCoverageIgnore + } + } } diff --git a/flight/net/Route.php b/flight/net/Route.php index 4e6e83c..8294a19 100644 --- a/flight/net/Route.php +++ b/flight/net/Route.php @@ -98,17 +98,24 @@ class Route * Checks if a URL matches the route pattern. Also parses named parameters in the URL. * * @param string $url Requested URL (original format, not URL decoded) - * @param bool $case_sensitive Case sensitive matching + * @param bool $caseSensitive Case sensitive matching * * @return bool Match status */ - public function matchUrl(string $url, bool $case_sensitive = false): bool + public function matchUrl(string $url, bool $caseSensitive = false): bool { // Wildcard or exact match if ($this->pattern === '*' || $this->pattern === $url) { return true; } + // if the last character of the incoming url is a slash, only allow one trailing slash, not multiple + if (substr($url, -2) === '//') { + // remove all trailing slashes, and then add one back. + $url = rtrim($url, '/') . '/'; + } + + $ids = []; $last_char = substr($this->pattern, -1); @@ -157,7 +164,7 @@ class Route $regex .= $last_char === '/' ? '?' : '/?'; // Attempt to match route and named parameters - if (!preg_match('#^' . $regex . '(?:\?[\s\S]*)?$#' . (($case_sensitive) ? '' : 'i'), $url, $matches)) { + if (!preg_match('#^' . $regex . '(?:\?[\s\S]*)?$#' . (($caseSensitive) ? '' : 'i'), $url, $matches)) { return false; } @@ -198,7 +205,7 @@ class Route public function hydrateUrl(array $params = []): string { $url = preg_replace_callback("/(?:@([\w]+)(?:\:([^\/]+))?\)*)/i", function ($match) use ($params) { - if (isset($match[1]) && isset($params[$match[1]])) { + if (isset($params[$match[1]]) === true) { return $params[$match[1]]; } }, $this->pattern); diff --git a/flight/net/Router.php b/flight/net/Router.php index a43b5ba..455fdb4 100644 --- a/flight/net/Router.php +++ b/flight/net/Router.php @@ -20,7 +20,7 @@ class Router /** * Case sensitive matching. */ - public bool $case_sensitive = false; + public bool $caseSensitive = false; /** * Mapped routes. @@ -56,12 +56,20 @@ class Router * * @var array */ - protected array $allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']; + protected array $allowedMethods = [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'HEAD', + 'OPTIONS' + ]; /** * Gets mapped routes. * - * @return array Array of routes + * @return array Array of routes */ public function getRoutes(): array { @@ -80,14 +88,14 @@ class Router * Maps a URL pattern to a callback function. * * @param string $pattern URL pattern to match. - * @param callable|string $callback Callback function or string class->method + * @param callable|string|array{0: class-string, 1: 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, $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 + // This means that the route is defined in a group, but the defined route is the base // url path. Note the '' in route() // Ex: Flight::group('/api', function() { // Flight::route('', function() {}); @@ -133,7 +141,7 @@ class Router * Creates a GET based route * * @param string $pattern URL pattern to match - * @param callable|string $callback Callback function or string class->method + * @param callable|string|array{0: class-string, 1: 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 */ @@ -146,7 +154,7 @@ class Router * Creates a POST based route * * @param string $pattern URL pattern to match - * @param callable|string $callback Callback function or string class->method + * @param callable|string|array{0: class-string, 1: 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 */ @@ -159,7 +167,7 @@ class Router * Creates a PUT based route * * @param string $pattern URL pattern to match - * @param callable|string $callback Callback function or string class->method + * @param callable|string|array{0: class-string, 1: 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 */ @@ -172,7 +180,7 @@ class Router * Creates a PATCH based route * * @param string $pattern URL pattern to match - * @param callable|string $callback Callback function or string class->method + * @param callable|string|array{0: class-string, 1: 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 */ @@ -185,7 +193,7 @@ class Router * Creates a DELETE based route * * @param string $pattern URL pattern to match - * @param callable|string $callback Callback function or string class->method + * @param callable|string|array{0: class-string, 1: 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 */ @@ -199,7 +207,7 @@ class Router * * @param string $groupPrefix group URL prefix (such as /api/v1) * @param callable $callback The necessary calling that holds the Router class - * @param array $groupMiddlewares + * @param (class-string|callable|array{0: class-string, 1: string})[] $groupMiddlewares * The middlewares to be applied to the group. Example: `[$middleware1, $middleware2]` */ public function group(string $groupPrefix, callable $callback, array $groupMiddlewares = []): void @@ -221,12 +229,12 @@ class Router public function route(Request $request) { while ($route = $this->current()) { - $urlMatches = $route->matchUrl($request->url, $this->case_sensitive); + $urlMatches = $route->matchUrl($request->url, $this->caseSensitive); $methodMatches = $route->matchMethod($request->method); if ($urlMatches === true && $methodMatches === true) { $this->executedRoute = $route; return $route; - // capture the route but don't execute it. We'll use this in Engine->start() to throw a 405 + // capture the route but don't execute it. We'll use this in Engine->start() to throw a 405 } elseif ($urlMatches === true && $methodMatches === false) { $this->executedRoute = $route; } @@ -240,7 +248,7 @@ class Router * Gets the URL for a given route alias * * @param string $alias the alias to match - * @param array $params the parameters to pass to the route + * @param array $params the parameters to pass to the route */ public function getUrlByAlias(string $alias, array $params = []): string { @@ -276,6 +284,69 @@ class Router throw new Exception($exception_message); } + /** + * Create a resource controller customizing the methods names mapping. + * + * @param class-string $controllerClass + * @param array> $options + */ + public function mapResource( + string $pattern, + string $controllerClass, + array $options = [] + ): void { + + $defaultMapping = [ + 'index' => 'GET ', + 'create' => 'GET /create', + 'store' => 'POST ', + 'show' => 'GET /@id', + 'edit' => 'GET /@id/edit', + 'update' => 'PUT /@id', + 'destroy' => 'DELETE /@id' + ]; + + // Create a custom alias base + $aliasBase = trim(basename($pattern), '/'); + if (isset($options['alias_base']) === true) { + $aliasBase = $options['alias_base']; + } + + // Only use these controller methods + if (isset($options['only']) === true) { + $only = $options['only']; + $defaultMapping = array_filter($defaultMapping, function ($key) use ($only) { + return in_array($key, $only, true) === true; + }, ARRAY_FILTER_USE_KEY); + + // Exclude these controller methods + } elseif (isset($options['except']) === true) { + $except = $options['except']; + $defaultMapping = array_filter($defaultMapping, function ($key) use ($except) { + return in_array($key, $except, true) === false; + }, ARRAY_FILTER_USE_KEY); + } + + // Add group middleware + $middleware = []; + if (isset($options['middleware']) === true) { + $middleware = $options['middleware']; + } + + $this->group( + $pattern, + function (Router $router) use ($controllerClass, $defaultMapping, $aliasBase): void { + foreach ($defaultMapping as $controllerMethod => $methodPattern) { + $router->map( + $methodPattern, + [$controllerClass, $controllerMethod] + )->setAlias($aliasBase . '.' . $controllerMethod); + } + }, + $middleware + ); + } + /** * Rewinds the current route index. */ diff --git a/flight/net/UploadedFile.php b/flight/net/UploadedFile.php new file mode 100644 index 0000000..2b3947b --- /dev/null +++ b/flight/net/UploadedFile.php @@ -0,0 +1,157 @@ +name = $name; + $this->mimeType = $mimeType; + $this->size = $size; + $this->tmpName = $tmpName; + $this->error = $error; + } + + /** + * Retrieves the client-side filename of the uploaded file. + * + * @return string The client-side filename. + */ + public function getClientFilename(): string + { + return $this->name; + } + + /** + * Retrieves the media type of the uploaded file as provided by the client. + * + * @return string The media type of the uploaded file. + */ + public function getClientMediaType(): string + { + return $this->mimeType; + } + + /** + * Returns the size of the uploaded file. + * + * @return int The size of the uploaded file. + */ + public function getSize(): int + { + return $this->size; + } + + /** + * Retrieves the temporary name of the uploaded file. + * + * @return string The temporary name of the uploaded file. + */ + public function getTempName(): string + { + return $this->tmpName; + } + + /** + * Get the error code associated with the uploaded file. + * + * @return int The error code. + */ + public function getError(): int + { + return $this->error; + } + + /** + * Moves the uploaded file to the specified target path. + * + * @param string $targetPath The path to move the file to. + * + * @return void + */ + public function moveTo(string $targetPath): void + { + if ($this->error !== UPLOAD_ERR_OK) { + throw new Exception($this->getUploadErrorMessage($this->error)); + } + + $isUploadedFile = is_uploaded_file($this->tmpName) === true; + if ( + $isUploadedFile === true + && + move_uploaded_file($this->tmpName, $targetPath) === false + ) { + throw new Exception('Cannot move uploaded file'); // @codeCoverageIgnore + } elseif ($isUploadedFile === false && getenv('PHPUNIT_TEST')) { + rename($this->tmpName, $targetPath); + } + } + + /** + * Retrieves the error message for a given upload error code. + * + * @param int $error The upload error code. + * + * @return string The error message. + */ + protected function getUploadErrorMessage(int $error): string + { + switch ($error) { + case UPLOAD_ERR_INI_SIZE: + return 'The uploaded file exceeds the upload_max_filesize directive in php.ini.'; + case UPLOAD_ERR_FORM_SIZE: + return 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.'; + case UPLOAD_ERR_PARTIAL: + return 'The uploaded file was only partially uploaded.'; + case UPLOAD_ERR_NO_FILE: + return 'No file was uploaded.'; + case UPLOAD_ERR_NO_TMP_DIR: + return 'Missing a temporary folder.'; + case UPLOAD_ERR_CANT_WRITE: + return 'Failed to write file to disk.'; + case UPLOAD_ERR_EXTENSION: + return 'A PHP extension stopped the file upload.'; + default: + return 'An unknown error occurred. Error code: ' . $error; + } + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 79f3ff0..e320c09 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -31,6 +31,7 @@ + diff --git a/tests/DispatcherTest.php b/tests/DispatcherTest.php index 418897d..6873aa6 100644 --- a/tests/DispatcherTest.php +++ b/tests/DispatcherTest.php @@ -13,6 +13,7 @@ use InvalidArgumentException; use PharIo\Manifest\InvalidEmailException; use tests\classes\Hello; use PHPUnit\Framework\TestCase; +use tests\classes\ClassWithExceptionInConstruct; use tests\classes\ContainerDefault; use tests\classes\TesterClass; use TypeError; @@ -329,4 +330,17 @@ class DispatcherTest extends TestCase $this->expectExceptionMessageMatches('#tests\\\\classes\\\\ContainerDefault::__construct\(\).+flight\\\\Engine, null given#'); $result = $this->dispatcher->execute([ContainerDefault::class, 'testTheContainer']); } + + public function testContainerDicePdoWrapperTestBadParams() + { + $dice = new \Dice\Dice(); + $this->dispatcher->setContainerHandler(function ($class, $params) use ($dice) { + return $dice->create($class, $params); + }); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('This is an exception in the constructor'); + + $this->dispatcher->invokeCallable([ ClassWithExceptionInConstruct::class, '__construct' ]); + } } diff --git a/tests/EngineTest.php b/tests/EngineTest.php index 93f3ff7..737f38d 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -12,6 +12,7 @@ use flight\net\Request; use flight\net\Response; use flight\util\Collection; use InvalidArgumentException; +use JsonException; use PDOException; use PHPUnit\Framework\TestCase; use tests\classes\Container; @@ -45,7 +46,7 @@ class EngineTest extends TestCase $engine->request()->url = '/someRoute'; $engine->start(); - $this->assertFalse($engine->router()->case_sensitive); + $this->assertFalse($engine->router()->caseSensitive); $this->assertTrue($engine->response()->content_length); } @@ -64,7 +65,7 @@ class EngineTest extends TestCase // This is a necessary evil because of how the v2 output buffer works. ob_end_clean(); - $this->assertFalse($engine->router()->case_sensitive); + $this->assertFalse($engine->router()->caseSensitive); $this->assertTrue($engine->response()->content_length); } @@ -174,6 +175,30 @@ class EngineTest extends TestCase $engine->start(); } + public function testDoubleStart() + { + $engine = new Engine(); + $engine->route('/someRoute', function () { + echo 'i ran'; + }, true); + $engine->request()->url = '/someRoute'; + $engine->start(); + + $request = $engine->request(); + $response = $engine->response(); + + // This is pretending like this is embodied in a platform like swoole where + // another request comes in while still holding all the same state. + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/someRoute'; + $engine->start(); + + $this->assertFalse($request === $engine->request()); + $this->assertFalse($response === $engine->response()); + + $this->expectOutputString('i rani ran'); + } + public function testStopWithCode() { $engine = new class extends Engine { @@ -355,18 +380,36 @@ class EngineTest extends TestCase { $engine = new Engine(); $engine->json(['key1' => 'value1', 'key2' => 'value2']); - $this->assertEquals('application/json; charset=utf-8', $engine->response()->headers()['Content-Type']); + $this->assertEquals('application/json', $engine->response()->headers()['Content-Type']); $this->assertEquals(200, $engine->response()->status()); $this->assertEquals('{"key1":"value1","key2":"value2"}', $engine->response()->getBody()); } + public function testJsonWithDuplicateDefaultFlags() + { + $engine = new Engine(); + // utf8 emoji + $engine->json(['key1' => 'value1', 'key2' => 'value2', 'utf8_emoji' => '😀'], 201, true, '', JSON_HEX_TAG | JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $this->assertEquals('application/json', $engine->response()->headers()['Content-Type']); + $this->assertEquals(201, $engine->response()->status()); + $this->assertEquals('{"key1":"value1","key2":"value2","utf8_emoji":"😀"}', $engine->response()->getBody()); + } + + public function testJsonThrowOnErrorByDefault() + { + $engine = new Engine(); + $this->expectException(JsonException::class); + $this->expectExceptionMessage('Malformed UTF-8 characters, possibly incorrectly encoded'); + $engine->json(['key1' => 'value1', 'key2' => 'value2', 'utf8_emoji' => "\xB1\x31"]); + } + public function testJsonV2OutputBuffering() { $engine = new Engine(); $engine->response()->v2_output_buffering = true; $engine->json(['key1' => 'value1', 'key2' => 'value2']); $this->expectOutputString('{"key1":"value1","key2":"value2"}'); - $this->assertEquals('application/json; charset=utf-8', $engine->response()->headers()['Content-Type']); + $this->assertEquals('application/json', $engine->response()->headers()['Content-Type']); $this->assertEquals(200, $engine->response()->status()); } @@ -375,7 +418,7 @@ class EngineTest extends TestCase $engine = new Engine(); $this->expectOutputString('{"key1":"value1","key2":"value2"}'); $engine->jsonHalt(['key1' => 'value1', 'key2' => 'value2']); - $this->assertEquals('application/json; charset=utf-8', $engine->response()->headers()['Content-Type']); + $this->assertEquals('application/json', $engine->response()->headers()['Content-Type']); $this->assertEquals(200, $engine->response()->status()); $this->assertEquals('{"key1":"value1","key2":"value2"}', $engine->response()->getBody()); } @@ -802,28 +845,6 @@ class EngineTest extends TestCase $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(); @@ -952,4 +973,38 @@ class EngineTest extends TestCase $this->assertEquals('Method Not Allowed', $engine->response()->getBody()); } + public function testDownload() + { + $engine = new class extends Engine { + public function getLoader() + { + return $this->loader; + } + }; + // doing this so we can overwrite some parts of the response + $engine->getLoader()->register('response', function () { + return new class extends Response { + public function setRealHeader( + string $header_string, + bool $replace = true, + int $response_code = 0 + ): self { + return $this; + } + }; + }); + $tmpfile = tmpfile(); + fwrite($tmpfile, 'I am a teapot'); + $streamPath = stream_get_meta_data($tmpfile)['uri']; + $this->expectOutputString('I am a teapot'); + $engine->download($streamPath); + } + + public function testDownloadBadPath() { + $engine = new Engine(); + $this->expectException(Exception::class); + $this->expectExceptionMessage("/path/to/nowhere cannot be found."); + $engine->download('/path/to/nowhere'); + } + } diff --git a/tests/EventSystemTest.php b/tests/EventSystemTest.php new file mode 100644 index 0000000..f95302a --- /dev/null +++ b/tests/EventSystemTest.php @@ -0,0 +1,348 @@ +init(); + Flight::eventDispatcher()->resetInstance(); // Clear any existing listeners + } + + /** + * Test registering and triggering a single listener. + */ + public function testRegisterAndTriggerSingleListener() + { + $called = false; + Flight::onEvent('test.event', function () use (&$called) { + $called = true; + }); + Flight::triggerEvent('test.event'); + $this->assertTrue($called, 'Single listener should be called when event is triggered.'); + } + + /** + * Test registering multiple listeners for the same event. + */ + public function testRegisterMultipleListeners() + { + $counter = 0; + Flight::onEvent('test.event', function () use (&$counter) { + $counter++; + }); + Flight::onEvent('test.event', function () use (&$counter) { + $counter++; + }); + Flight::triggerEvent('test.event'); + $this->assertEquals(2, $counter, 'All registered listeners should be called.'); + } + + /** + * Test triggering an event with no listeners registered. + */ + public function testTriggerWithNoListeners() + { + // Should not throw any errors + Flight::triggerEvent('non.existent.event'); + $this->assertTrue(true, 'Triggering an event with no listeners should not throw an error.'); + } + + /** + * Test that a listener receives a single argument correctly. + */ + public function testListenerReceivesSingleArgument() + { + $received = null; + Flight::onEvent('test.event', function ($arg) use (&$received) { + $received = $arg; + }); + Flight::triggerEvent('test.event', 'hello'); + $this->assertEquals('hello', $received, 'Listener should receive the passed argument.'); + } + + /** + * Test that a listener receives multiple arguments correctly. + */ + public function testListenerReceivesMultipleArguments() + { + $received = []; + Flight::onEvent('test.event', function ($arg1, $arg2) use (&$received) { + $received = [$arg1, $arg2]; + }); + Flight::triggerEvent('test.event', 'first', 'second'); + $this->assertEquals(['first', 'second'], $received, 'Listener should receive all passed arguments.'); + } + + /** + * Test that listeners are called in the order they were registered. + */ + public function testListenersCalledInOrder() + { + $order = []; + Flight::onEvent('test.event', function () use (&$order) { + $order[] = 1; + }); + Flight::onEvent('test.event', function () use (&$order) { + $order[] = 2; + }); + Flight::triggerEvent('test.event'); + $this->assertEquals([1, 2], $order, 'Listeners should be called in registration order.'); + } + + /** + * Test that listeners are not called for unrelated events. + */ + public function testListenerNotCalledForOtherEvents() + { + $called = false; + Flight::onEvent('test.event1', function () use (&$called) { + $called = true; + }); + Flight::triggerEvent('test.event2'); + $this->assertFalse($called, 'Listeners should not be called for different events.'); + } + + /** + * Test overriding the onEvent method. + */ + public function testOverrideOnEvent() + { + $called = false; + Flight::map('onEvent', function ($event, $callback) use (&$called) { + $called = true; + }); + Flight::onEvent('test.event', function () { + }); + $this->assertTrue($called, 'Overridden onEvent method should be called.'); + } + + /** + * Test overriding the triggerEvent method. + */ + public function testOverrideTriggerEvent() + { + $called = false; + Flight::map('triggerEvent', function ($event, ...$args) use (&$called) { + $called = true; + }); + Flight::triggerEvent('test.event'); + $this->assertTrue($called, 'Overridden triggerEvent method should be called.'); + } + + /** + * Test that an overridden onEvent can still register listeners by calling the original method. + */ + public function testOverrideOnEventStillRegistersListener() + { + $overrideCalled = false; + Flight::map('onEvent', function ($event, $callback) use (&$overrideCalled) { + $overrideCalled = true; + // Call the original method + Flight::app()->_onEvent($event, $callback); + }); + + $listenerCalled = false; + Flight::onEvent('test.event', function () use (&$listenerCalled) { + $listenerCalled = true; + }); + + Flight::triggerEvent('test.event'); + + $this->assertTrue($overrideCalled, 'Overridden onEvent should be called.'); + $this->assertTrue($listenerCalled, 'Listener should still be triggered after override.'); + } + + /** + * Test that an overridden triggerEvent can still trigger listeners by calling the original method. + */ + public function testOverrideTriggerEventStillTriggersListeners() + { + $overrideCalled = false; + Flight::map('triggerEvent', function ($event, ...$args) use (&$overrideCalled) { + $overrideCalled = true; + // Call the original method + Flight::app()->_triggerEvent($event, ...$args); + }); + + $listenerCalled = false; + Flight::onEvent('test.event', function () use (&$listenerCalled) { + $listenerCalled = true; + }); + + Flight::triggerEvent('test.event'); + + $this->assertTrue($overrideCalled, 'Overridden triggerEvent should be called.'); + $this->assertTrue($listenerCalled, 'Listeners should still be triggered after override.'); + } + + /** + * Test that an invalid callable throws an exception (if applicable). + */ + public function testInvalidCallableThrowsException() + { + $this->expectException(TypeError::class); + // Assuming the event system validates callables + Flight::onEvent('test.event', 'not_a_callable'); + } + + /** + * Test that event propagation stops if a listener returns false. + */ + public function testStopPropagation() + { + $firstCalled = false; + $secondCalled = false; + $thirdCalled = false; + + Flight::onEvent('test.event', function () use (&$firstCalled) { + $firstCalled = true; + return true; // Continue propagation + }); + + Flight::onEvent('test.event', function () use (&$secondCalled) { + $secondCalled = true; + return false; // Stop propagation + }); + + Flight::onEvent('test.event', function () use (&$thirdCalled) { + $thirdCalled = true; + }); + + Flight::triggerEvent('test.event'); + + $this->assertTrue($firstCalled, 'First listener should be called'); + $this->assertTrue($secondCalled, 'Second listener should be called'); + $this->assertFalse($thirdCalled, 'Third listener should not be called after propagation stopped'); + } + + /** + * Test that hasListeners() correctly identifies events with listeners. + */ + public function testHasListeners() + { + $this->assertFalse(Flight::eventDispatcher()->hasListeners('test.event'), 'Event should not have listeners before registration'); + + Flight::onEvent('test.event', function () { + }); + + $this->assertTrue(Flight::eventDispatcher()->hasListeners('test.event'), 'Event should have listeners after registration'); + } + + /** + * Test that getListeners() returns the correct listeners for an event. + */ + public function testGetListeners() + { + $callback1 = function () { + }; + $callback2 = function () { + }; + + $this->assertEmpty(Flight::eventDispatcher()->getListeners('test.event'), 'Event should have no listeners before registration'); + + Flight::onEvent('test.event', $callback1); + Flight::onEvent('test.event', $callback2); + + $listeners = Flight::eventDispatcher()->getListeners('test.event'); + $this->assertCount(2, $listeners, 'Event should have two registered listeners'); + $this->assertSame($callback1, $listeners[0], 'First listener should match the first callback'); + $this->assertSame($callback2, $listeners[1], 'Second listener should match the second callback'); + } + + /** + * Test that getListeners() returns an empty array for events with no listeners. + */ + public function testGetListenersForNonexistentEvent() + { + $listeners = Flight::eventDispatcher()->getListeners('nonexistent.event'); + $this->assertIsArray($listeners, 'Should return an array for nonexistent events'); + $this->assertEmpty($listeners, 'Should return an empty array for nonexistent events'); + } + + /** + * Test that getAllRegisteredEvents() returns all event names with registered listeners. + */ + public function testGetAllRegisteredEvents() + { + $this->assertEmpty(Flight::eventDispatcher()->getAllRegisteredEvents(), 'No events should be registered initially'); + + Flight::onEvent('test.event1', function () { + }); + Flight::onEvent('test.event2', function () { + }); + + $events = Flight::eventDispatcher()->getAllRegisteredEvents(); + $this->assertCount(2, $events, 'Should return all registered event names'); + $this->assertContains('test.event1', $events, 'Should contain the first event'); + $this->assertContains('test.event2', $events, 'Should contain the second event'); + } + + /** + * Test that removeListener() correctly removes a specific listener from an event. + */ + public function testRemoveListener() + { + $callback1 = function () { + return 'callback1'; + }; + $callback2 = function () { + return 'callback2'; + }; + + Flight::onEvent('test.event', $callback1); + Flight::onEvent('test.event', $callback2); + + $this->assertCount(2, Flight::eventDispatcher()->getListeners('test.event'), 'Event should have two listeners initially'); + + Flight::eventDispatcher()->removeListener('test.event', $callback1); + + $listeners = Flight::eventDispatcher()->getListeners('test.event'); + $this->assertCount(1, $listeners, 'Event should have one listener after removal'); + $this->assertSame($callback2, $listeners[0], 'Remaining listener should be the second callback'); + } + + /** + * Test that removeAllListeners() correctly removes all listeners for an event. + */ + public function testRemoveAllListeners() + { + Flight::onEvent('test.event', function () { + }); + Flight::onEvent('test.event', function () { + }); + Flight::onEvent('another.event', function () { + }); + + $this->assertTrue(Flight::eventDispatcher()->hasListeners('test.event'), 'Event should have listeners before removal'); + $this->assertTrue(Flight::eventDispatcher()->hasListeners('another.event'), 'Another event should have listeners'); + + Flight::eventDispatcher()->removeAllListeners('test.event'); + + $this->assertFalse(Flight::eventDispatcher()->hasListeners('test.event'), 'Event should have no listeners after removal'); + $this->assertTrue(Flight::eventDispatcher()->hasListeners('another.event'), 'Another event should still have listeners'); + } + + /** + * Test that trying to remove listeners for nonexistent events doesn't cause errors. + */ + public function testRemoveListenersForNonexistentEvent() + { + // Should not throw any errors + Flight::eventDispatcher()->removeListener('nonexistent.event', function () { + }); + Flight::eventDispatcher()->removeAllListeners('nonexistent.event'); + + $this->assertTrue(true, 'Removing listeners for nonexistent events should not throw errors'); + } +} diff --git a/tests/FlightAsyncTest.php b/tests/FlightAsyncTest.php new file mode 100644 index 0000000..1a53f44 --- /dev/null +++ b/tests/FlightAsyncTest.php @@ -0,0 +1,83 @@ +expectOutputString('hello world'); + Flight::start(); + } + + public function testMultipleRoutes() + { + Flight::route('GET /', function () { + echo 'hello world'; + }); + + Flight::route('GET /test', function () { + echo 'test'; + }); + + $this->expectOutputString('test'); + $_SERVER['REQUEST_URI'] = '/test'; + Flight::start(); + } + + public function testMultipleStartsSingleRoute() + { + Flight::route('GET /', function () { + echo 'hello world'; + }); + + $this->expectOutputString('hello worldhello world'); + Flight::start(); + Flight::start(); + } + + public function testMultipleStartsMultipleRoutes() + { + Flight::route('GET /', function () { + echo 'hello world'; + }); + + Flight::route('GET /test', function () { + echo 'test'; + }); + + $this->expectOutputString('testhello world'); + $_SERVER['REQUEST_URI'] = '/test'; + Flight::start(); + $_SERVER['REQUEST_URI'] = '/'; + Flight::start(); + } +} diff --git a/tests/FlightTest.php b/tests/FlightTest.php index 5d196d6..990bf93 100644 --- a/tests/FlightTest.php +++ b/tests/FlightTest.php @@ -209,7 +209,7 @@ class FlightTest extends TestCase Flight::route('/path1/@param:[a-zA-Z0-9]{2,3}', function () { echo 'I win'; }, false, 'path1'); - $url = Flight::getUrl('path1', [ 'param' => 123 ]); + $url = Flight::getUrl('path1', ['param' => 123]); $this->assertEquals('/path1/123', $url); } @@ -316,7 +316,7 @@ class FlightTest extends TestCase Flight::register('response', $mock_response_class_name); Flight::route('/stream', function () { echo 'stream'; - })->streamWithHeaders(['Content-Type' => 'text/plain', 'X-Test' => 'test', 'status' => 200 ]); + })->streamWithHeaders(['Content-Type' => 'text/plain', 'X-Test' => 'test', 'status' => 200]); Flight::request()->url = '/stream'; $this->expectOutputString('stream'); Flight::start(); @@ -361,8 +361,7 @@ class FlightTest extends TestCase string $output, array $renderParams, string $regexp - ): void - { + ): void { Flight::view()->preserveVars = false; $this->expectOutputString($output); @@ -379,7 +378,7 @@ class FlightTest extends TestCase public function testKeepThePreviousStateOfOneViewComponentByDefault(): void { - $this->expectOutputString(<<Hi
Hi
@@ -387,7 +386,12 @@ class FlightTest extends TestCase - html); + html; + + // if windows replace \n with \r\n + $html = str_replace(["\n", "\r\n"], PHP_EOL, $html); + + $this->expectOutputString($html); Flight::render('myComponent', ['prop' => 'Hi']); Flight::render('myComponent'); diff --git a/tests/PdoWrapperTest.php b/tests/PdoWrapperTest.php index 0f41a92..4e21a1b 100644 --- a/tests/PdoWrapperTest.php +++ b/tests/PdoWrapperTest.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace tests; use flight\database\PdoWrapper; +use flight\core\EventDispatcher; use PDOStatement; use PHPUnit\Framework\TestCase; +use ReflectionClass; class PdoWrapperTest extends TestCase { @@ -120,4 +122,90 @@ class PdoWrapperTest extends TestCase $rows = $this->pdo_wrapper->fetchAll('SELECT id FROM test WHERE id > ? AND name IN( ?) ', [ 0, 'one,two' ]); $this->assertEquals(2, count($rows)); } + + public function testPullDataFromDsn() + { + // Testing protected method using reflection + $reflection = new ReflectionClass($this->pdo_wrapper); + $method = $reflection->getMethod('pullDataFromDsn'); + $method->setAccessible(true); + + // Test SQLite DSN + $sqliteDsn = 'sqlite::memory:'; + $sqliteResult = $method->invoke($this->pdo_wrapper, $sqliteDsn); + $this->assertEquals([ + 'engine' => 'sqlite', + 'database' => ':memory:', + 'host' => 'localhost' + ], $sqliteResult); + + // Test MySQL DSN + $mysqlDsn = 'mysql:host=localhost;dbname=testdb;charset=utf8'; + $mysqlResult = $method->invoke($this->pdo_wrapper, $mysqlDsn); + $this->assertEquals([ + 'engine' => 'mysql', + 'database' => 'testdb', + 'host' => 'localhost' + ], $mysqlResult); + + // Test PostgreSQL DSN + $pgsqlDsn = 'pgsql:host=127.0.0.1;dbname=postgres'; + $pgsqlResult = $method->invoke($this->pdo_wrapper, $pgsqlDsn); + $this->assertEquals([ + 'engine' => 'pgsql', + 'database' => 'postgres', + 'host' => '127.0.0.1' + ], $pgsqlResult); + } + + public function testLogQueries() + { + // Create a new PdoWrapper with tracking enabled + $trackingPdo = new PdoWrapper('sqlite::memory:', null, null, null, true); + + // Create test table + $trackingPdo->exec('CREATE TABLE test_log (id INTEGER PRIMARY KEY, name TEXT)'); + + // Run some queries to populate metrics + $trackingPdo->runQuery('INSERT INTO test_log (name) VALUES (?)', ['test1']); + $trackingPdo->fetchAll('SELECT * FROM test_log'); + + // Setup event listener to capture triggered event + $eventTriggered = false; + $connectionData = null; + $queriesData = null; + + $dispatcher = EventDispatcher::getInstance(); + $dispatcher->on('flight.db.queries', function ($conn, $queries) use (&$eventTriggered, &$connectionData, &$queriesData) { + $eventTriggered = true; + $connectionData = $conn; + $queriesData = $queries; + }); + + // Call the logQueries method + $trackingPdo->logQueries(); + + // Assert that event was triggered + $this->assertTrue($eventTriggered); + $this->assertIsArray($connectionData); + $this->assertEquals('sqlite', $connectionData['engine']); + $this->assertIsArray($queriesData); + $this->assertCount(2, $queriesData); // Should have 2 queries (INSERT and SELECT) + + // Verify query metrics structure for the first query + $this->assertArrayHasKey('sql', $queriesData[0]); + $this->assertArrayHasKey('params', $queriesData[0]); + $this->assertArrayHasKey('execution_time', $queriesData[0]); + $this->assertArrayHasKey('row_count', $queriesData[0]); + $this->assertArrayHasKey('memory_usage', $queriesData[0]); + + // Clean up + $trackingPdo->exec('DROP TABLE test_log'); + + // Verify metrics are reset after logging + $reflection = new ReflectionClass($trackingPdo); + $property = $reflection->getProperty('queryMetrics'); + $property->setAccessible(true); + $this->assertCount(0, $property->getValue($trackingPdo)); + } } diff --git a/tests/RequestTest.php b/tests/RequestTest.php index a8b4310..4044d06 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -41,23 +41,23 @@ class RequestTest extends TestCase public function testDefaults() { - self::assertEquals('/', $this->request->url); - self::assertEquals('/', $this->request->base); - self::assertEquals('GET', $this->request->method); - self::assertEquals('', $this->request->referrer); - self::assertTrue($this->request->ajax); - self::assertEquals('http', $this->request->scheme); - self::assertEquals('', $this->request->type); - self::assertEquals(0, $this->request->length); - self::assertFalse($this->request->secure); - self::assertEquals('', $this->request->accept); - self::assertEquals('example.com', $this->request->host); + $this->assertEquals('/', $this->request->url); + $this->assertEquals('/', $this->request->base); + $this->assertEquals('GET', $this->request->method); + $this->assertEquals('', $this->request->referrer); + $this->assertTrue($this->request->ajax); + $this->assertEquals('http', $this->request->scheme); + $this->assertEquals('', $this->request->type); + $this->assertEquals(0, $this->request->length); + $this->assertFalse($this->request->secure); + $this->assertEquals('', $this->request->accept); + $this->assertEquals('example.com', $this->request->host); } public function testIpAddress() { - self::assertEquals('8.8.8.8', $this->request->ip); - self::assertEquals('32.32.32.32', $this->request->proxy_ip); + $this->assertEquals('8.8.8.8', $this->request->ip); + $this->assertEquals('32.32.32.32', $this->request->proxy_ip); } public function testSubdirectory() @@ -66,7 +66,7 @@ class RequestTest extends TestCase $request = new Request(); - self::assertEquals('/subdir', $request->base); + $this->assertEquals('/subdir', $request->base); } public function testQueryParameters() @@ -75,9 +75,9 @@ class RequestTest extends TestCase $request = new Request(); - self::assertEquals('/page?id=1&name=bob', $request->url); - self::assertEquals(1, $request->query->id); - self::assertEquals('bob', $request->query->name); + $this->assertEquals('/page?id=1&name=bob', $request->url); + $this->assertEquals(1, $request->query->id); + $this->assertEquals('bob', $request->query->name); } public function testCollections() @@ -91,11 +91,11 @@ class RequestTest extends TestCase $request = new Request(); - self::assertEquals(1, $request->query->q); - self::assertEquals(1, $request->query->id); - self::assertEquals(1, $request->data->q); - self::assertEquals(1, $request->cookies->q); - self::assertEquals(1, $request->files->q); + $this->assertEquals(1, $request->query->q); + $this->assertEquals(1, $request->query->id); + $this->assertEquals(1, $request->data->q); + $this->assertEquals(1, $request->cookies->q); + $this->assertEquals(1, $request->files->q); } public function testJsonWithEmptyBody() @@ -104,7 +104,7 @@ class RequestTest extends TestCase $request = new Request(); - self::assertSame([], $request->data->getData()); + $this->assertSame([], $request->data->getData()); } public function testMethodOverrideWithHeader() @@ -113,7 +113,7 @@ class RequestTest extends TestCase $request = new Request(); - self::assertEquals('PUT', $request->method); + $this->assertEquals('PUT', $request->method); } public function testMethodOverrideWithPost() @@ -122,38 +122,38 @@ class RequestTest extends TestCase $request = new Request(); - self::assertEquals('PUT', $request->method); + $this->assertEquals('PUT', $request->method); } public function testHttps() { $_SERVER['HTTPS'] = 'on'; $request = new Request(); - self::assertEquals('https', $request->scheme); + $this->assertEquals('https', $request->scheme); $_SERVER['HTTPS'] = 'off'; $request = new Request(); - self::assertEquals('http', $request->scheme); + $this->assertEquals('http', $request->scheme); $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; $request = new Request(); - self::assertEquals('https', $request->scheme); + $this->assertEquals('https', $request->scheme); $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'http'; $request = new Request(); - self::assertEquals('http', $request->scheme); + $this->assertEquals('http', $request->scheme); $_SERVER['HTTP_FRONT_END_HTTPS'] = 'on'; $request = new Request(); - self::assertEquals('https', $request->scheme); + $this->assertEquals('https', $request->scheme); $_SERVER['HTTP_FRONT_END_HTTPS'] = 'off'; $request = new Request(); - self::assertEquals('http', $request->scheme); + $this->assertEquals('http', $request->scheme); $_SERVER['REQUEST_SCHEME'] = 'https'; $request = new Request(); - self::assertEquals('https', $request->scheme); + $this->assertEquals('https', $request->scheme); $_SERVER['REQUEST_SCHEME'] = 'http'; $request = new Request(); - self::assertEquals('http', $request->scheme); + $this->assertEquals('http', $request->scheme); } public function testInitUrlSameAsBaseDirectory() @@ -162,7 +162,8 @@ class RequestTest extends TestCase 'url' => '/vagrant/public/flightphp', 'base' => '/vagrant/public', 'query' => new Collection(), - 'type' => '' + 'type' => '', + 'method' => 'GET' ]); $this->assertEquals('/flightphp', $request->url); } @@ -172,7 +173,8 @@ class RequestTest extends TestCase $request = new Request([ 'url' => '', 'base' => '/vagrant/public', - 'type' => '' + 'type' => '', + 'method' => 'GET' ]); $this->assertEquals('/', $request->url); } @@ -183,7 +185,6 @@ class RequestTest extends TestCase $tmpfile = tmpfile(); $stream_path = stream_get_meta_data($tmpfile)['uri']; file_put_contents($stream_path, '{"foo":"bar"}'); - $_SERVER['REQUEST_METHOD'] = 'POST'; $request = new Request([ 'url' => '/something/fancy', 'base' => '/vagrant/public', @@ -191,12 +192,36 @@ class RequestTest extends TestCase 'length' => 13, 'data' => new Collection(), 'query' => new Collection(), - 'stream_path' => $stream_path + 'stream_path' => $stream_path, + 'method' => 'POST' ]); $this->assertEquals([ 'foo' => 'bar' ], $request->data->getData()); $this->assertEquals('{"foo":"bar"}', $request->getBody()); } + public function testInitWithFormBody() + { + // create dummy file to pull request body from + $tmpfile = tmpfile(); + $stream_path = stream_get_meta_data($tmpfile)['uri']; + file_put_contents($stream_path, 'foo=bar&baz=qux'); + $request = new Request([ + 'url' => '/something/fancy', + 'base' => '/vagrant/public', + 'type' => 'application/x-www-form-urlencoded', + 'length' => 15, + 'data' => new Collection(), + 'query' => new Collection(), + 'stream_path' => $stream_path, + 'method' => 'PATCH' + ]); + $this->assertEquals([ + 'foo' => 'bar', + 'baz' => 'qux' + ], $request->data->getData()); + $this->assertEquals('foo=bar&baz=qux', $request->getBody()); + } + public function testGetHeader() { $_SERVER['HTTP_X_CUSTOM_HEADER'] = 'custom header value'; @@ -279,4 +304,54 @@ class RequestTest extends TestCase $request = new Request(); $this->assertEquals('https://localhost:8000', $request->getBaseUrl()); } + + public function testGetSingleFileUpload() + { + $_FILES['file'] = [ + 'name' => 'file.txt', + 'type' => 'text/plain', + 'size' => 123, + 'tmp_name' => '/tmp/php123', + 'error' => 0 + ]; + + $request = new Request(); + + $file = $request->getUploadedFiles()['file']; + + $this->assertEquals('file.txt', $file->getClientFilename()); + $this->assertEquals('text/plain', $file->getClientMediaType()); + $this->assertEquals(123, $file->getSize()); + $this->assertEquals('/tmp/php123', $file->getTempName()); + $this->assertEquals(0, $file->getError()); + } + + public function testGetMultiFileUpload() + { + $_FILES['files'] = [ + 'name' => ['file1.txt', 'file2.txt'], + 'type' => ['text/plain', 'text/plain'], + 'size' => [123, 456], + 'tmp_name' => ['/tmp/php123', '/tmp/php456'], + 'error' => [0, 0] + ]; + + $request = new Request(); + + $files = $request->getUploadedFiles()['files']; + + $this->assertCount(2, $files); + + $this->assertEquals('file1.txt', $files[0]->getClientFilename()); + $this->assertEquals('text/plain', $files[0]->getClientMediaType()); + $this->assertEquals(123, $files[0]->getSize()); + $this->assertEquals('/tmp/php123', $files[0]->getTempName()); + $this->assertEquals(0, $files[0]->getError()); + + $this->assertEquals('file2.txt', $files[1]->getClientFilename()); + $this->assertEquals('text/plain', $files[1]->getClientMediaType()); + $this->assertEquals(456, $files[1]->getSize()); + $this->assertEquals('/tmp/php456', $files[1]->getTempName()); + $this->assertEquals(0, $files[1]->getError()); + } } diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index a163e7e..3f21780 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -164,11 +164,7 @@ class ResponseTest extends TestCase $response->cache(false); $this->assertEquals([ 'Expires' => 'Mon, 26 Jul 1997 05:00:00 GMT', - 'Cache-Control' => [ - 'no-store, no-cache, must-revalidate', - 'post-check=0, pre-check=0', - 'max-age=0', - ], + 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma' => 'no-cache' ], $response->headers()); } @@ -239,6 +235,43 @@ class ResponseTest extends TestCase $this->assertTrue($response->sent()); } + public function testSendWithNoHeadersSent() + { + $response = new class extends Response { + protected $test_sent_headers = []; + + public function setRealHeader(string $header_string, bool $replace = true, int $response_code = 0): self + { + $this->test_sent_headers[] = $header_string; + return $this; + } + + public function getSentHeaders(): array + { + return $this->test_sent_headers; + } + + public function headersSent(): bool + { + return false; + } + }; + $response->header('Content-Type', 'text/html'); + $response->header('X-Test', 'test'); + $response->write('Something'); + + $this->expectOutputString('Something'); + + $response->send(); + $sent_headers = $response->getSentHeaders(); + $this->assertEquals([ + 'HTTP/1.1 200 OK', + 'Content-Type: text/html', + 'X-Test: test', + 'Content-Length: 9' + ], $sent_headers); + } + public function testClearBody() { $response = new Response(); @@ -282,7 +315,16 @@ class ResponseTest extends TestCase ob_start(); $response->send(); $gzip_body = ob_get_clean(); - $expected = PHP_OS === 'WINNT' ? 'H4sIAAAAAAAACitJLS4BAAx+f9gEAAAA' : 'H4sIAAAAAAAAAytJLS4BAAx+f9gEAAAA'; + switch (PHP_OS) { + case 'WINNT': + $expected = 'H4sIAAAAAAAACitJLS4BAAx+f9gEAAAA'; + break; + case 'Darwin': + $expected = 'H4sIAAAAAAAAEytJLS4BAAx+f9gEAAAA'; + break; + default: + $expected = 'H4sIAAAAAAAAAytJLS4BAAx+f9gEAAAA'; + } $this->assertEquals($expected, base64_encode($gzip_body)); $this->assertEquals(strlen(gzencode('test')), strlen($gzip_body)); } diff --git a/tests/RouterTest.php b/tests/RouterTest.php index f0ac765..ebb3fa7 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -117,6 +117,14 @@ class RouterTest extends TestCase $this->check('OK'); } + public function testPathRouteWithUrlTrailingSlash() + { + $this->router->map('/path', [$this, 'ok']); + $this->request->url = '/path/'; + + $this->check('OK'); + } + public function testGetRouteShortcut() { $this->router->get('/path', [$this, 'ok']); @@ -455,7 +463,7 @@ class RouterTest extends TestCase { $this->router->map('/hello', [$this, 'ok']); $this->request->url = '/HELLO'; - $this->router->case_sensitive = true; + $this->router->caseSensitive = true; $this->check('404'); } @@ -752,4 +760,12 @@ class RouterTest extends TestCase $this->assertEquals('/path1/123/abc', $url); } + + public function testStripMultipleSlashesFromUrlAndStillMatch() + { + $this->router->get('/', [ $this, 'ok' ]); + $this->request->url = '///'; + $this->request->method = 'GET'; + $this->check('OK'); + } } diff --git a/tests/UploadedFileTest.php b/tests/UploadedFileTest.php new file mode 100644 index 0000000..94d9f75 --- /dev/null +++ b/tests/UploadedFileTest.php @@ -0,0 +1,56 @@ +moveTo('file.txt'); + $this->assertFileExists('file.txt'); + } + + public function getFileErrorMessageTests(): array + { + return [ + [ UPLOAD_ERR_INI_SIZE, 'The uploaded file exceeds the upload_max_filesize directive in php.ini.', ], + [ UPLOAD_ERR_FORM_SIZE, 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.', ], + [ UPLOAD_ERR_PARTIAL, 'The uploaded file was only partially uploaded.', ], + [ UPLOAD_ERR_NO_FILE, 'No file was uploaded.', ], + [ UPLOAD_ERR_NO_TMP_DIR, 'Missing a temporary folder.', ], + [ UPLOAD_ERR_CANT_WRITE, 'Failed to write file to disk.', ], + [ UPLOAD_ERR_EXTENSION, 'A PHP extension stopped the file upload.', ], + [ -1, 'An unknown error occurred. Error code: -1' ] + ]; + } + + /** + * @dataProvider getFileErrorMessageTests + */ + public function testMoveToFailureMessages($error, $message) + { + file_put_contents('tmp_name', 'test'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', $error); + $this->expectException(Exception::class); + $this->expectExceptionMessage($message); + $uploadedFile->moveTo('file.txt'); + } +} diff --git a/tests/ViewTest.php b/tests/ViewTest.php index d6754c9..cf300c9 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -175,7 +175,7 @@ class ViewTest extends TestCase public function testKeepThePreviousStateOfOneViewComponentByDefault(): void { - $this->expectOutputString(<<Hi
Hi
@@ -183,7 +183,12 @@ class ViewTest extends TestCase - html); + html; + + // if windows replace \n with \r\n + $html = str_replace("\n", PHP_EOL, $html); + + $this->expectOutputString($html); $this->view->render('myComponent', ['prop' => 'Hi']); $this->view->render('myComponent'); @@ -197,11 +202,16 @@ class ViewTest extends TestCase $this->view->set('prop', 'bar'); - $this->expectOutputString(<<qux
bar
- html); + html; + + // if windows replace \n with \r\n + $html = str_replace("\n", PHP_EOL, $html); + + $this->expectOutputString($html); $this->view->render('myComponent', ['prop' => 'qux']); $this->view->render('myComponent'); @@ -209,24 +219,31 @@ class ViewTest extends TestCase public static function renderDataProvider(): array { + $html1 = <<<'html' +
Hi
+
+ + html; + + $html2 = <<<'html' + + + + + + html; + + $html1 = str_replace(["\n", "\r\n"], PHP_EOL, $html1); + $html2 = str_replace(["\n", "\r\n"], PHP_EOL, $html2); + return [ [ - <<Hi -
- - html, + $html1, ['myComponent', ['prop' => 'Hi']], '/^Undefined variable:? \$?prop$/' ], [ - << - - - - html, + $html2, ['input', ['type' => 'number']], '/^.*$/' ], diff --git a/tests/classes/ClassWithExceptionInConstruct.php b/tests/classes/ClassWithExceptionInConstruct.php new file mode 100644 index 0000000..32456c2 --- /dev/null +++ b/tests/classes/ClassWithExceptionInConstruct.php @@ -0,0 +1,13 @@ + false); + $app = new Application($name, $version ?: '0.0.1', fn() => false); return $app->io(new Interactor(static::$in, static::$ou)); } protected function createIndexFile() { - $index = <<addMiddleware(function() {}); Flight::delete('/delete', function () {}); Flight::put('/put', function () {}); Flight::patch('/patch', function () {})->addMiddleware('SomeMiddleware'); -Flight::router()->case_sensitive = true; +Flight::router()->caseSensitive = true; Flight::start(); PHP; @@ -74,6 +81,10 @@ PHP; protected function removeColors(string $str): string { + // replace \n with \r\n if windows + if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { + $str = str_replace("\r\n", "\n", $str); + } return preg_replace('/\e\[[\d;]*m/', '', $str); } @@ -94,15 +105,22 @@ PHP; $app->handle(['runway', 'routes']); $this->assertStringContainsString('Routes', file_get_contents(static::$ou)); - $this->assertStringContainsString('+---------+-----------+-------+----------+----------------+ -| Pattern | Methods | Alias | Streamed | Middleware | -+---------+-----------+-------+----------+----------------+ -| / | GET, HEAD | | No | - | -| /post | POST | | No | Closure | -| /delete | DELETE | | No | - | -| /put | PUT | | No | - | -| /patch | PATCH | | No | Bad Middleware | -+---------+-----------+-------+----------+----------------+', $this->removeColors(file_get_contents(static::$ou))); + $expected = <<<'output' + +---------+-----------+-------+----------+----------------+ + | Pattern | Methods | Alias | Streamed | Middleware | + +---------+-----------+-------+----------+----------------+ + | / | GET, HEAD | | No | - | + | /post | POST | | No | Closure | + | /delete | DELETE | | No | - | + | /put | PUT | | No | - | + | /patch | PATCH | | No | Bad Middleware | + +---------+-----------+-------+----------+----------------+ + output; + + $this->assertStringContainsString( + $expected, + $this->removeColors(file_get_contents(static::$ou)) + ); } public function testGetPostRoute() diff --git a/tests/groupcompactsyntax/FlightRouteCompactSyntaxTest.php b/tests/groupcompactsyntax/FlightRouteCompactSyntaxTest.php new file mode 100644 index 0000000..1f438bb --- /dev/null +++ b/tests/groupcompactsyntax/FlightRouteCompactSyntaxTest.php @@ -0,0 +1,142 @@ +clear(); + } + + public function testCanMapMethodsWithVerboseSyntax(): void + { + Flight::route('GET /users', [UsersController::class, 'index']); + Flight::route('DELETE /users/@id', [UsersController::class, 'destroy']); + + $routes = Flight::router()->getRoutes(); + + $this->assertCount(2, $routes); + + $this->assertSame('/users', $routes[0]->pattern); + $this->assertSame([UsersController::class, 'index'], $routes[0]->callback); + $this->assertSame('GET', $routes[0]->methods[0]); + + $this->assertSame('/users/@id', $routes[1]->pattern); + $this->assertSame([UsersController::class, 'destroy'], $routes[1]->callback); + $this->assertSame('DELETE', $routes[1]->methods[0]); + } + + public function testOptionsOnly(): void + { + Flight::resource('/users', UsersController::class, [ + 'only' => [ 'index', 'destroy' ] + ]); + + $routes = Flight::router()->getRoutes(); + + $this->assertCount(2, $routes); + + $this->assertSame('/users', $routes[0]->pattern); + $this->assertSame('GET', $routes[0]->methods[0]); + $this->assertSame([UsersController::class, 'index'], $routes[0]->callback); + + $this->assertSame('/users/@id', $routes[1]->pattern); + $this->assertSame('DELETE', $routes[1]->methods[0]); + $this->assertSame([UsersController::class, 'destroy'], $routes[1]->callback); + } + + public function testDefaultMethods(): void + { + Flight::resource('/posts', PostsController::class); + + $routes = Flight::router()->getRoutes(); + $this->assertCount(7, $routes); + + $this->assertSame('/posts', $routes[0]->pattern); + $this->assertSame('GET', $routes[0]->methods[0]); + $this->assertSame([PostsController::class, 'index'], $routes[0]->callback); + $this->assertSame('posts.index', $routes[0]->alias); + + $this->assertSame('/posts/create', $routes[1]->pattern); + $this->assertSame('GET', $routes[1]->methods[0]); + $this->assertSame([PostsController::class, 'create'], $routes[1]->callback); + $this->assertSame('posts.create', $routes[1]->alias); + + $this->assertSame('/posts', $routes[2]->pattern); + $this->assertSame('POST', $routes[2]->methods[0]); + $this->assertSame([PostsController::class, 'store'], $routes[2]->callback); + $this->assertSame('posts.store', $routes[2]->alias); + + $this->assertSame('/posts/@id', $routes[3]->pattern); + $this->assertSame('GET', $routes[3]->methods[0]); + $this->assertSame([PostsController::class, 'show'], $routes[3]->callback); + $this->assertSame('posts.show', $routes[3]->alias); + + $this->assertSame('/posts/@id/edit', $routes[4]->pattern); + $this->assertSame('GET', $routes[4]->methods[0]); + $this->assertSame([PostsController::class, 'edit'], $routes[4]->callback); + $this->assertSame('posts.edit', $routes[4]->alias); + + $this->assertSame('/posts/@id', $routes[5]->pattern); + $this->assertSame('PUT', $routes[5]->methods[0]); + $this->assertSame([PostsController::class, 'update'], $routes[5]->callback); + $this->assertSame('posts.update', $routes[5]->alias); + + $this->assertSame('/posts/@id', $routes[6]->pattern); + $this->assertSame('DELETE', $routes[6]->methods[0]); + $this->assertSame([PostsController::class, 'destroy'], $routes[6]->callback); + $this->assertSame('posts.destroy', $routes[6]->alias); + } + + public function testOptionsExcept(): void + { + Flight::resource('/todos', TodosController::class, [ + 'except' => [ 'create', 'store', 'update', 'destroy', 'edit' ] + ]); + + $routes = Flight::router()->getRoutes(); + + $this->assertCount(2, $routes); + + $this->assertSame('/todos', $routes[0]->pattern); + $this->assertSame('GET', $routes[0]->methods[0]); + $this->assertSame([TodosController::class, 'index'], $routes[0]->callback); + + $this->assertSame('/todos/@id', $routes[1]->pattern); + $this->assertSame('GET', $routes[1]->methods[0]); + $this->assertSame([TodosController::class, 'show'], $routes[1]->callback); + } + + public function testOptionsMiddlewareAndAliasBase(): void + { + Flight::resource('/todos', TodosController::class, [ + 'middleware' => [ 'auth' ], + 'alias_base' => 'nothanks' + ]); + + $routes = Flight::router()->getRoutes(); + + $this->assertCount(7, $routes); + + $this->assertSame('/todos', $routes[0]->pattern); + $this->assertSame('GET', $routes[0]->methods[0]); + $this->assertSame([TodosController::class, 'index'], $routes[0]->callback); + $this->assertSame('auth', $routes[0]->middleware[0]); + $this->assertSame('nothanks.index', $routes[0]->alias); + + $this->assertSame('/todos/create', $routes[1]->pattern); + $this->assertSame('GET', $routes[1]->methods[0]); + $this->assertSame([TodosController::class, 'create'], $routes[1]->callback); + $this->assertSame('auth', $routes[1]->middleware[0]); + $this->assertSame('nothanks.create', $routes[1]->alias); + } +} diff --git a/tests/groupcompactsyntax/PostsController.php b/tests/groupcompactsyntax/PostsController.php new file mode 100644 index 0000000..bae8242 --- /dev/null +++ b/tests/groupcompactsyntax/PostsController.php @@ -0,0 +1,36 @@ +Protected path
  • Template path
  • Query path
  • -
  • 404 Not Found
  • +
  • 404 Not Found
  • +
  • 405 Method Not Found
  • Mega group
  • Error
  • JSON
  • @@ -86,6 +87,7 @@ class LayoutMiddleware
  • Dice Container
  • No Container Registered
  • Pascal_Snake_Case
  • +
  • Download File
  • HTML; echo '
    '; diff --git a/tests/server/index.php b/tests/server/index.php index 5c86d11..a26dfd8 100644 --- a/tests/server/index.php +++ b/tests/server/index.php @@ -175,9 +175,14 @@ Flight::route('/json-halt', function () { Flight::jsonHalt(['message' => 'JSON rendered and halted successfully with no other body content!']); }); +// Download a file +Flight::route('/download', function () { + Flight::download('test_file.txt'); +}); + Flight::map('error', function (Throwable $e) { echo sprintf( - <<500 Internal Server Error

    %s (%s)

    %s
    diff --git a/tests/server/test_file.txt b/tests/server/test_file.txt new file mode 100644 index 0000000..1a47dab --- /dev/null +++ b/tests/server/test_file.txt @@ -0,0 +1 @@ +This file downloaded successfully! \ No newline at end of file