merge master

pull/548/head
fadrian06 2 weeks ago
parent d6854cb078
commit 5068995d17

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

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

@ -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": [

@ -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<int, callable|object> $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<string, string|array<string>> $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<string,mixed> $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<string> List of methods that can be extended in the Engine class.
*/
private const MAPPABLE_METHODS = [
'start', 'stop', 'route', 'halt', 'error', 'notFound',
'render', 'redirect', 'etag', 'lastModified', 'json', '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<string, mixed> 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(
<<<HTML
<<<'HTML'
<h1>500 Internal Server Error</h1>
<h3>%s (%s)</h3>
<pre>%s</pre>
@ -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<string, string|array<string>> $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);
}
}

@ -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<int, mixed> $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<string, string|array<string>> $methods = [])
* Adds standardized RESTful routes for a controller.
* @method static Router router() Returns Router instance.
* @method static string getUrl(string $alias, array<string, mixed> $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<int, mixed> &$params, string &$output): (void|false) $callback)
* Adds a filter before a framework method.
* @method static void after(string $name, Closure(array<int, mixed> &$params, string &$output): (void|false) $callback)
* Adds a filter after a framework method.
*
* # Variables
* @method static void set(string|iterable<string, mixed> $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();
}
/**

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace flight\core;
class EventDispatcher
{
/** @var self|null Singleton instance of the EventDispatcher */
private static ?self $instance = null;
/** @var array<string, array<int, callable>> */
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<int, callable> 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<int, string> 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;
}
}

@ -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<int,array<string,mixed>> $queryMetrics Metrics related to the database connection. */
protected array $queryMetrics = [];
/** @var array<string,string> $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<string, mixed>|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<string,string> 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
*

@ -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<string, array<string,UploadedFile>|array<string,array<string,UploadedFile>>> 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<string, array<int, array<string, mixed>>> 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;
}
}

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

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

@ -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<int, string>
*/
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<int,Route> Array of routes
* @return array<int, Route> 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<int, callable|object> $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<string,mixed> $params the parameters to pass to the route
* @param array<string, mixed> $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<string, string|array<string>> $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.
*/

@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace flight\net;
use Exception;
class UploadedFile
{
/**
* @var string $name The name of the uploaded file.
*/
private string $name;
/**
* @var string $mimeType The MIME type of the uploaded file.
*/
private string $mimeType;
/**
* @var int $size The size of the uploaded file in bytes.
*/
private int $size;
/**
* @var string $tmpName The temporary name of the uploaded file.
*/
private string $tmpName;
/**
* @var int $error The error code associated with the uploaded file.
*/
private int $error;
/**
* Constructs a new UploadedFile object.
*
* @param string $name The name of the uploaded file.
* @param string $mimeType The MIME type of the uploaded file.
* @param int $size The size of the uploaded file in bytes.
* @param string $tmpName The temporary name of the uploaded file.
* @param int $error The error code associated with the uploaded file.
*/
public function __construct(string $name, string $mimeType, int $size, string $tmpName, int $error)
{
$this->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;
}
}
}

@ -31,6 +31,7 @@
<exclude name="Generic.Formatting.MultipleStatementAlignment.NotSameWarning" />
<exclude name="Generic.Functions.OpeningFunctionBraceBsdAllman.BraceOnSameLine" />
<exclude name="Generic.PHP.DisallowRequestSuperglobal.Found" />
<exclude name="Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsedHeredocCloser" />
</rule>
<rule ref="Generic.Files.LineLength">
<properties>

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

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

@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
namespace flight\tests;
use Flight;
use PHPUnit\Framework\TestCase;
use flight\Engine;
use TypeError;
class EventSystemTest extends TestCase
{
protected function setUp(): void
{
// Reset the Flight engine before each test to ensure a clean state
Flight::setEngine(new Engine());
Flight::app()->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');
}
}

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace tests;
use Flight;
use flight\Engine;
use PHPUnit\Framework\TestCase;
class FlightAsyncTest extends TestCase
{
public static function setUpBeforeClass(): void
{
Flight::setEngine(new Engine());
}
protected function setUp(): void
{
$_SERVER = [];
$_REQUEST = [];
}
protected function tearDown(): void
{
unset($_REQUEST);
unset($_SERVER);
}
// Checks that default components are loaded
public function testSingleRoute()
{
Flight::route('GET /', function () {
echo 'hello world';
});
$this->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();
}
}

@ -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(<<<html
$html = <<<'html'
<div>Hi</div>
<div>Hi</div>
@ -387,7 +386,12 @@ class FlightTest extends TestCase
<input type="number" />
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');

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

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

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

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

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace tests;
use Exception;
use flight\net\UploadedFile;
use PHPUnit\Framework\TestCase;
class UploadedFileTest extends TestCase
{
public function tearDown(): void
{
if (file_exists('file.txt')) {
unlink('file.txt');
}
if (file_exists('tmp_name')) {
unlink('tmp_name');
}
}
public function testMoveToSuccess()
{
file_put_contents('tmp_name', 'test');
$uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK);
$uploadedFile->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');
}
}

@ -175,7 +175,7 @@ class ViewTest extends TestCase
public function testKeepThePreviousStateOfOneViewComponentByDefault(): void
{
$this->expectOutputString(<<<html
$html = <<<'html'
<div>Hi</div>
<div>Hi</div>
@ -183,7 +183,12 @@ class ViewTest extends TestCase
<input type="number" />
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(<<<html
$html = <<<'html'
<div>qux</div>
<div>bar</div>
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'
<div>Hi</div>
<div></div>
html;
$html2 = <<<'html'
<input type="number" />
<input type="text" />
html;
$html1 = str_replace(["\n", "\r\n"], PHP_EOL, $html1);
$html2 = str_replace(["\n", "\r\n"], PHP_EOL, $html2);
return [
[
<<<html
<div>Hi</div>
<div></div>
html,
$html1,
['myComponent', ['prop' => 'Hi']],
'/^Undefined variable:? \$?prop$/'
],
[
<<<html
<input type="number" />
<input type="text" />
html,
$html2,
['input', ['type' => 'number']],
'/^.*$/'
],

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace tests\classes;
class ClassWithExceptionInConstruct
{
public function __construct()
{
throw new \Exception('This is an exception in the constructor');
}
}

@ -11,11 +11,14 @@ use PHPUnit\Framework\TestCase;
class ControllerCommandTest extends TestCase
{
protected static $in = __DIR__ . '/input.test';
protected static $ou = __DIR__ . '/output.test';
protected static $in = '';
protected static $ou = '';
public function setUp(): void
{
// Need dynamic filenames to avoid unlink() issues with windows.
static::$in = __DIR__ . DIRECTORY_SEPARATOR . 'input.test' . uniqid('', true) . '.txt';
static::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt';
file_put_contents(static::$in, '', LOCK_EX);
file_put_contents(static::$ou, '', LOCK_EX);
}
@ -37,6 +40,10 @@ class ControllerCommandTest extends TestCase
if (file_exists(__DIR__ . '/controllers/')) {
rmdir(__DIR__ . '/controllers/');
}
// Thanks Windows
clearstatcache();
gc_collect_cycles();
}
protected function newApp(string $name, string $version = '')

@ -13,13 +13,16 @@ use PHPUnit\Framework\TestCase;
class RouteCommandTest extends TestCase
{
protected static $in = __DIR__ . '/input.test';
protected static $ou = __DIR__ . '/output.test';
protected static $in = __DIR__ . DIRECTORY_SEPARATOR . 'input.test';
protected static $ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test';
public function setUp(): void
{
file_put_contents(static::$in, '', LOCK_EX);
file_put_contents(static::$ou, '', LOCK_EX);
// Need dynamic filenames to avoid unlink() issues with windows.
static::$in = __DIR__ . DIRECTORY_SEPARATOR . 'input.test' . uniqid('', true) . '.txt';
static::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt';
file_put_contents(static::$in, '');
file_put_contents(static::$ou, '');
$_SERVER = [];
$_REQUEST = [];
Flight::init();
@ -43,18 +46,22 @@ class RouteCommandTest extends TestCase
unset($_REQUEST);
unset($_SERVER);
Flight::clear();
// Thanks Windows
clearstatcache();
gc_collect_cycles();
}
protected function newApp(string $name, string $version = '')
{
$app = new Application($name, $version ?: '0.0.1', fn () => false);
$app = new Application($name, $version ?: '0.0.1', fn() => false);
return $app->io(new Interactor(static::$in, static::$ou));
}
protected function createIndexFile()
{
$index = <<<PHP
$index = <<<'PHP'
<?php
require __DIR__ . '/../../vendor/autoload.php';
@ -64,7 +71,7 @@ Flight::post('/post', function () {})->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()

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use tests\groupcompactsyntax\PostsController;
use tests\groupcompactsyntax\TodosController;
use tests\groupcompactsyntax\UsersController;
require_once __DIR__ . '/UsersController.php';
require_once __DIR__ . '/PostsController.php';
final class FlightRouteCompactSyntaxTest extends TestCase
{
public function setUp(): void
{
Flight::router()->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);
}
}

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace tests\groupcompactsyntax;
final class PostsController
{
public function index(): void
{
}
public function show(string $id): void
{
}
public function create(): void
{
}
public function store(): void
{
}
public function edit(string $id): void
{
}
public function update(string $id): void
{
}
public function destroy(string $id): void
{
}
}

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace tests\groupcompactsyntax;
final class TodosController
{
public function index(): void
{
}
public function show(string $id): void
{
}
}

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace tests\groupcompactsyntax;
final class UsersController
{
public function index(): void
{
echo __METHOD__;
}
public function destroy(): void
{
echo __METHOD__;
}
}

@ -69,7 +69,8 @@ class LayoutMiddleware
<li><a href="/protected">Protected path</a></li>
<li><a href="/template/templatevariable">Template path</a></li>
<li><a href="/querytestpath?test=1&variable2=uuid&variable3=tester">Query path</a></li>
<li><a href="/postpage">404 Not Found</a></li>
<li><a href="/badpagename">404 Not Found</a></li>
<li><a href="/postpage">405 Method Not Found</a></li>
<li><a href="{$final_route}">Mega group</a></li>
<li><a href="/error">Error</a></li>
<li><a href="/json">JSON</a></li>
@ -86,6 +87,7 @@ class LayoutMiddleware
<li><a href="/dice">Dice Container</a></li>
<li><a href="/no-container">No Container Registered</a></li>
<li><a href="/Pascal_Snake_Case">Pascal_Snake_Case</a></li>
<li><a href="/download">Download File</a></li>
</ul>
HTML;
echo '<div id="container">';

@ -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(
<<<HTML
<<<'HTML'
<h1>500 Internal Server Error</h1>
<h3>%s (%s)</h3>
<pre style="border: 2px solid red; padding: 21px; background: lightgray; font-weight: bold;">%s</pre>

@ -0,0 +1 @@
This file downloaded successfully!
Loading…
Cancel
Save