Merge branch 'master' into php8-named-arguments-support

php8-named-arguments-support
fadrian06 8 months ago
commit d6854cb078

@ -0,0 +1,36 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: Bug
assignees: ''
---
**Before you submit the bug**
If you're having issues with your templates or output showing up out of order, please make sure to check the [updates to output buffering](https://docs.flightphp.com/learn/migrating-to-v3#output-buffering-behavior-3-5-0) for workarounds and corrections.
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Here's some sample code ...
2. Here's the URL I hit ...
3. etc
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- OS: [e.g. Linux, Mac, Windows]
- Browser [e.g. chrome, safari]
- PHP Version [e.g. 7.4]
- Flight Version [e.g. 3.7.2]
**Additional context**
Add any other context about the problem here.

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

5
.gitignore vendored

@ -1,4 +1,5 @@
.idea
.idea/
.vscode/
vendor/
composer.phar
composer.lock
@ -6,3 +7,5 @@ composer.lock
coverage/
*.sublime*
clover.xml
phpcs.xml
.runway-config.json

@ -1,5 +0,0 @@
{
"php.suggest.basic": false,
"editor.detectIndentation": false,
"editor.insertSpaces": true
}

@ -0,0 +1,58 @@
## Contributing to the Flight Framework
Thanks for being willing to contribute to the Flight! The goal of Flight is to keep the implementation of things simple and free of outside dependencies.
You should only bring in the depedencies you want in your project right? Right.
### Overarching Guidelines
Flight aims to be simple and fast. Anything that compromises either of those two things will be heavily scrutinized and/or rejected. Other things to consider when making a contribution:
* **Dependencies** - We strive to be dependency free in Flight. Yes even polyfills, yes even `Interface` only repos like `psr/container`. The fewer dependencies, the fewer your exposed attack vectors.
* **Coding Standards** - We use PSR1 coding standards enforced by PHPCS. Some standards that either need additional configuration or need to be manually done are:
* PHPStan is at level 6.
* `===` instead of truthy or falsey statements like `==` or `!is_array()`.
* **PHP 7.4 Focused** - We do not make PHP 8+ focused enhancements on the framework as the focus is maintaining PHP 7.4.
* **Core functionality vs Plugin** - Have a conversation with us in the [chatroom](https://matrix.to/#/!cTfwPXhpkTXPXwVmxY:matrix.org?via=matrix.org&via=leitstelle511.net&via=integrations.ems.host) to know if your idea is worth makes sense in the framework or in a plugin.
* **Testing** - Until automated testing is put into place, any PRs must pass unit testing in PHP 7.4 and PHP 8.2+. Additionally you need to run `composer test-server` and `composer test-server-v2` and ensure all the header links work correctly.
#### **Did you find a bug?**
* **Do not open up a GitHub issue if the bug is a security vulnerability**. Instead contact maintainers directly via email to safely pass in the information related to the security vuln.
* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/flightphp/core/issues).
* If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/flightphp/core/issues/new). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring.
#### **Did you write a patch that fixes a bug?**
* Open a new GitHub pull request with the patch.
* Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable.
#### **Did you fix whitespace, format code, or make a purely cosmetic patch?**
Changes that are cosmetic in nature and do not add anything substantial to the stability, functionality, or testability of Flight will generally not be accepted.
#### **Do you intend to add a new feature or change an existing one?**
* Hop into the [chatroom](https://matrix.to/#/!cTfwPXhpkTXPXwVmxY:matrix.org?via=matrix.org&via=leitstelle511.net&via=integrations.ems.host) for Flight and let's have a conversation about the feature you want to add. It could be amazing, or it might make more sense as an extension/plugin. If you create a PR without having a conversation with maintainers, it likely will be closed without review.
* Do not open an issue on GitHub until you have collected positive feedback about the change. GitHub issues are primarily intended for bug reports and fixes.
#### **Do you have questions about the source code?**
* Ask any question about how to use Flight in the in the [Flight Matrix chat room](https://matrix.to/#/!cTfwPXhpkTXPXwVmxY:matrix.org?via=matrix.org&via=leitstelle511.net&via=integrations.ems.host).
#### **Do you want to contribute to the Flight documentation?**
* Please see the [Flight Documentation repo on GitHub](https://github.com/flightphp/docs).
Flight is a volunteer effort. We encourage you to pitch in and join!
Thanks! :heart: :heart: :heart:
Flight Team

@ -1,5 +1,8 @@
![PHPStan: enabled](https://user-images.githubusercontent.com/104888/50957476-9c4acb80-14be-11e9-88ce-6447364dc1bb.png)
![PHPStan: level 6](https://img.shields.io/badge/PHPStan-level%206-brightgreen.svg?style=flat)
[![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)
![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)
![Matrix](https://img.shields.io/matrix/flight-php-framework%3Amatrix.org?server_fqdn=matrix.org&style=social&logo=matrix)
# What is Flight?

@ -23,7 +23,7 @@
}
],
"require": {
"php": "^7.4|^8.0|^8.1|^8.2|^8.3",
"php": ">=7.4",
"ext-json": "*"
},
"autoload": {
@ -41,6 +41,7 @@
},
"require-dev": {
"ext-pdo_sqlite": "*",
"flightphp/runway": "^0.2.0",
"league/container": "^4.2",
"level-2/dice": "^4.0",
"phpstan/extension-installer": "^1.3",
@ -64,7 +65,10 @@
"test-coverage:win": "del clover.xml && phpunit --coverage-html=coverage --coverage-clover=clover.xml && coverage-check clover.xml 100",
"lint": "phpstan --no-progress -cphpstan.neon",
"beautify": "phpcbf --standard=phpcs.xml",
"phpcs": "phpcs --standard=phpcs.xml -n"
"phpcs": "phpcs --standard=phpcs.xml -n",
"post-install-cmd": [
"php -r \"if (!file_exists('phpcs.xml')) copy('phpcs.xml.dist', 'phpcs.xml');\""
]
},
"suggest": {
"latte/latte": "Latte template engine",

@ -57,6 +57,8 @@ use flight\net\Route;
* @method void redirect(string $url, int $code = 303) Redirects the current request to another URL.
* @method void json(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0)
* Sends a JSON response.
* @method 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 void jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0)
* Sends a JSONP response.
*
@ -73,7 +75,7 @@ class Engine
*/
private const MAPPABLE_METHODS = [
'start', 'stop', 'route', 'halt', 'error', 'notFound',
'render', 'redirect', 'etag', 'lastModified', 'json', 'jsonp',
'render', 'redirect', 'etag', 'lastModified', 'json', 'jsonHalt', 'jsonp',
'post', 'put', 'patch', 'delete', 'group', 'getUrl'
];
@ -314,7 +316,7 @@ class Engine
*/
public function get(?string $key = null)
{
if (null === $key) {
if ($key === null) {
return $this->vars;
}
@ -360,7 +362,7 @@ class Engine
*/
public function clear(?string $key = null): void
{
if (null === $key) {
if ($key === null) {
$this->vars = [];
return;
}
@ -382,64 +384,81 @@ class Engine
* Processes each routes middleware.
*
* @param Route $route The route to process the middleware for.
* @param string $event_name If this is the before or after method.
* @param string $eventName If this is the before or after method.
*/
protected function processMiddleware(Route $route, string $event_name): bool
protected function processMiddleware(Route $route, string $eventName): bool
{
$at_least_one_middleware_failed = false;
$atLeastOneMiddlewareFailed = false;
$middlewares = $event_name === Dispatcher::FILTER_BEFORE ? $route->middleware : array_reverse($route->middleware);
// Process things normally for before, and then in reverse order for after.
$middlewares = $eventName === Dispatcher::FILTER_BEFORE
? $route->middleware
: array_reverse($route->middleware);
$params = $route->params;
foreach ($middlewares as $middleware) {
$middleware_object = false;
if ($event_name === Dispatcher::FILTER_BEFORE) {
// can be a callable or a class
$middleware_object = (is_callable($middleware) === true
? $middleware
: (method_exists($middleware, Dispatcher::FILTER_BEFORE) === true
? [$middleware, Dispatcher::FILTER_BEFORE]
: false
)
);
} elseif ($event_name === Dispatcher::FILTER_AFTER) {
// must be an object. No functions allowed here
if (
is_object($middleware) === true
&& !($middleware instanceof Closure)
&& method_exists($middleware, Dispatcher::FILTER_AFTER) === true
) {
$middleware_object = [$middleware, Dispatcher::FILTER_AFTER];
// Assume that nothing is going to be executed for the middleware.
$middlewareObject = false;
// Closure functions can only run on the before event
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.
} elseif (is_object($middleware) === true) {
$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.
} 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()
} 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 ];
}
}
if ($middleware_object === false) {
// If nothing was resolved, go to the next thing
if ($middlewareObject === false) {
continue;
}
$use_v3_output_buffering =
// This is the way that v3 handles output buffering (which captures output correctly)
$useV3OutputBuffering =
$this->response()->v2_output_buffering === false &&
$route->is_streamed === false;
if ($use_v3_output_buffering === true) {
if ($useV3OutputBuffering === true) {
ob_start();
}
// It's assumed if you don't declare before, that it will be assumed as the before method
$middleware_result = $middleware_object($params);
// 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)
$middlewareResult = $middlewareObject($params);
if ($use_v3_output_buffering === true) {
if ($useV3OutputBuffering === true) {
$this->response()->write(ob_get_clean());
}
if ($middleware_result === false) {
$at_least_one_middleware_failed = true;
// If you return false in your middleware, it will halt the request
// and throw a 403 forbidden error by default.
if ($middlewareResult === false) {
$atLeastOneMiddlewareFailed = true;
break;
}
}
return $at_least_one_middleware_failed;
return $atLeastOneMiddlewareFailed;
}
////////////////////////
@ -475,7 +494,7 @@ class Engine
}
// Route the request
$failed_middleware_check = false;
$failedMiddlewareCheck = false;
while ($route = $router->route($request)) {
$params = array_values($route->params);
@ -487,13 +506,16 @@ class Engine
// If this route is to be streamed, we need to output the headers now
if ($route->is_streamed === true) {
$response->status($route->streamed_headers['status']);
unset($route->streamed_headers['status']);
if (count($route->streamed_headers) > 0) {
$response->status($route->streamed_headers['status'] ?? 200);
unset($route->streamed_headers['status']);
foreach ($route->streamed_headers as $header => $value) {
$response->header($header, $value);
}
}
$response->header('X-Accel-Buffering', 'no');
$response->header('Connection', 'close');
foreach ($route->streamed_headers as $header => $value) {
$response->header($header, $value);
}
// We obviously don't know the content length right now. This must be false.
$response->content_length = false;
@ -503,18 +525,18 @@ class Engine
// Run any before middlewares
if (count($route->middleware) > 0) {
$at_least_one_middleware_failed = $this->processMiddleware($route, 'before');
if ($at_least_one_middleware_failed === true) {
$failed_middleware_check = true;
$atLeastOneMiddlewareFailed = $this->processMiddleware($route, 'before');
if ($atLeastOneMiddlewareFailed === true) {
$failedMiddlewareCheck = true;
break;
}
}
$use_v3_output_buffering =
$useV3OutputBuffering =
$this->response()->v2_output_buffering === false &&
$route->is_streamed === false;
if ($use_v3_output_buffering === true) {
if ($useV3OutputBuffering === true) {
ob_start();
}
@ -524,17 +546,17 @@ class Engine
$params
);
if ($use_v3_output_buffering === true) {
if ($useV3OutputBuffering === true) {
$response->write(ob_get_clean());
}
// Run any before middlewares
if (count($route->middleware) > 0) {
// process the middleware in reverse order now
$at_least_one_middleware_failed = $this->processMiddleware($route, 'after');
$atLeastOneMiddlewareFailed = $this->processMiddleware($route, 'after');
if ($at_least_one_middleware_failed === true) {
$failed_middleware_check = true;
if ($atLeastOneMiddlewareFailed === true) {
$failedMiddlewareCheck = true;
break;
}
}
@ -555,10 +577,16 @@ class Engine
$response->clearBody();
}
if ($failed_middleware_check === true) {
if ($failedMiddlewareCheck === true) {
$this->halt(403, 'Forbidden', empty(getenv('PHPUNIT_TEST')));
} elseif ($dispatched === false) {
$this->notFound();
// Get the previous route and check if the method failed, but the URL was good.
$lastRouteExecuted = $router->executedRoute;
if ($lastRouteExecuted !== null && $lastRouteExecuted->matchUrl($request->url) === true && $lastRouteExecuted->matchMethod($request->method) === false) {
$this->halt(405, 'Method Not Allowed', empty(getenv('PHPUNIT_TEST')));
} else {
$this->notFound();
}
}
}
@ -570,9 +598,11 @@ class Engine
public function _error(Throwable $e): void
{
$msg = sprintf(
'<h1>500 Internal Server Error</h1>' .
'<h3>%s (%s)</h3>' .
'<pre>%s</pre>',
<<<HTML
<h1>500 Internal Server Error</h1>
<h3>%s (%s)</h3>
<pre>%s</pre>
HTML,
$e->getMessage(),
$e->getCode(),
$e->getTraceAsString()
@ -580,7 +610,7 @@ class Engine
try {
$this->response()
->clear()
->clearBody()
->status(500)
->write($msg)
->send();
@ -603,8 +633,8 @@ class Engine
{
$response = $this->response();
if (!$response->sent()) {
if (null !== $code) {
if ($response->sent() === false) {
if ($code !== null) {
$response->status($code);
}
@ -647,10 +677,12 @@ class Engine
* @param string $pattern URL pattern to match
* @param callable|string $callback Callback function or string class->method
* @param bool $pass_route Pass the matching route object to the callback
*
* @return Route
*/
public function _post(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): void
public function _post(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): Route
{
$this->router()->map('POST ' . $pattern, $callback, $pass_route, $route_alias);
return $this->router()->map('POST ' . $pattern, $callback, $pass_route, $route_alias);
}
/**
@ -659,10 +691,12 @@ class Engine
* @param string $pattern URL pattern to match
* @param callable|string $callback Callback function or string class->method
* @param bool $pass_route Pass the matching route object to the callback
*
* @return Route
*/
public function _put(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): void
public function _put(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): Route
{
$this->router()->map('PUT ' . $pattern, $callback, $pass_route, $route_alias);
return $this->router()->map('PUT ' . $pattern, $callback, $pass_route, $route_alias);
}
/**
@ -671,10 +705,12 @@ class Engine
* @param string $pattern URL pattern to match
* @param callable|string $callback Callback function or string class->method
* @param bool $pass_route Pass the matching route object to the callback
*
* @return Route
*/
public function _patch(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): void
public function _patch(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): Route
{
$this->router()->map('PATCH ' . $pattern, $callback, $pass_route, $route_alias);
return $this->router()->map('PATCH ' . $pattern, $callback, $pass_route, $route_alias);
}
/**
@ -683,10 +719,12 @@ class Engine
* @param string $pattern URL pattern to match
* @param callable|string $callback Callback function or string class->method
* @param bool $pass_route Pass the matching route object to the callback
*
* @return Route
*/
public function _delete(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): void
public function _delete(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): Route
{
$this->router()->map('DELETE ' . $pattern, $callback, $pass_route, $route_alias);
return $this->router()->map('DELETE ' . $pattern, $callback, $pass_route, $route_alias);
}
/**
@ -699,7 +737,7 @@ class Engine
public function _halt(int $code = 200, string $message = '', bool $actuallyExit = true): void
{
$this->response()
->clear()
->clearBody()
->status($code)
->write($message)
->send();
@ -714,7 +752,7 @@ class Engine
$output = '<h1>404 Not Found</h1><h3>The page you have requested could not be found.</h3>';
$this->response()
->clear()
->clearBody()
->status(404)
->write($output)
->send();
@ -729,17 +767,17 @@ class Engine
{
$base = $this->get('flight.base_url');
if (null === $base) {
if ($base === null) {
$base = $this->request()->base;
}
// Append base url to redirect url
if ('/' !== $base && false === strpos($url, '://')) {
if ($base !== '/' && strpos($url, '://') === false) {
$url = $base . preg_replace('#/+#', '/', '/' . $url);
}
$this->response()
->clear()
->clearBody()
->status($code)
->header('Location', $url)
->send();
@ -756,7 +794,7 @@ class Engine
*/
public function _render(string $file, ?array $data = null, ?string $key = null): void
{
if (null !== $key) {
if ($key !== null) {
$this->view()->set($key, $this->view()->fetch($file, $data));
return;
}
@ -793,6 +831,33 @@ class Engine
}
}
/**
* Sends a JSON response and halts execution immediately.
*
* @param mixed $data JSON data
* @param int $code HTTP status code
* @param bool $encode Whether to perform JSON encoding
* @param string $charset Charset
* @param int $option Bitmask Json constant such as JSON_HEX_QUOT
*
* @throws Exception
*/
public function _jsonHalt(
$data,
int $code = 200,
bool $encode = true,
string $charset = 'utf-8',
int $option = 0
): void {
$this->json($data, $code, $encode, $charset, $option);
$jsonBody = $this->response()->getBody();
if ($this->response()->v2_output_buffering === false) {
$this->response()->clearBody();
$this->response()->send();
}
$this->halt($code, $jsonBody, empty(getenv('PHPUNIT_TEST')));
}
/**
* Sends a JSONP response.
*
@ -833,7 +898,7 @@ class Engine
*/
public function _etag(string $id, string $type = 'strong'): void
{
$id = (('weak' === $type) ? 'W/' : '') . $id;
$id = (($type === 'weak') ? 'W/' : '') . $id;
$this->response()->header('ETag', '"' . str_replace('"', '\"', $id) . '"');
@ -841,6 +906,7 @@ class Engine
isset($_SERVER['HTTP_IF_NONE_MATCH']) &&
$_SERVER['HTTP_IF_NONE_MATCH'] === $id
) {
$this->response()->clear();
$this->halt(304, '', empty(getenv('PHPUNIT_TEST')));
}
}
@ -858,6 +924,7 @@ class Engine
isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) &&
strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) === $time
) {
$this->response()->clear();
$this->halt(304, '', empty(getenv('PHPUNIT_TEST')));
}
}

@ -2,7 +2,6 @@
declare(strict_types=1);
use flight\core\Dispatcher;
use flight\Engine;
use flight\net\Request;
use flight\net\Response;
@ -24,6 +23,10 @@ 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)
* Registers a class to a framework method.
* @method static void unregister(string $methodName)
* Unregisters a class to a framework method.
* @method static void registerContainerHandler(callable|object $containerHandler) Registers a container handler.
*
* # Routing
@ -65,6 +68,8 @@ require_once __DIR__ . '/autoload.php';
* @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)
* 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.

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace flight\commands;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\PhpFile;
use Nette\PhpGenerator\PhpNamespace;
class ControllerCommand extends AbstractBaseCommand
{
/**
* Construct
*
* @param array<string,mixed> $config JSON config from .runway-config.json
*/
public function __construct(array $config)
{
parent::__construct('make:controller', 'Create a controller', $config);
$this->argument('<controller>', 'The name of the controller to create (with or without the Controller suffix)');
}
/**
* Executes the function
*
* @return void
*/
public function execute(string $controller)
{
$io = $this->app()->io();
if (isset($this->config['app_root']) === false) {
$io->error('app_root not set in .runway-config.json', true);
return;
}
if (!preg_match('/Controller$/', $controller)) {
$controller .= 'Controller';
}
$controllerPath = getcwd() . DIRECTORY_SEPARATOR . $this->config['app_root'] . 'controllers' . DIRECTORY_SEPARATOR . $controller . '.php';
if (file_exists($controllerPath) === true) {
$io->error($controller . ' already exists.', true);
return;
}
if (is_dir(dirname($controllerPath)) === false) {
$io->info('Creating directory ' . dirname($controllerPath), true);
mkdir(dirname($controllerPath), 0755, true);
}
$file = new PhpFile();
$file->setStrictTypes();
$namespace = new PhpNamespace('app\\controllers');
$namespace->addUse('flight\\Engine');
$class = new ClassType($controller);
$class->addProperty('app')
->setVisibility('protected')
->setType('flight\\Engine')
->addComment('@var Engine');
$method = $class->addMethod('__construct')
->addComment('Constructor')
->setVisibility('public')
->setBody('$this->app = $app;');
$method->addParameter('app')
->setType('flight\\Engine');
$namespace->add($class);
$file->addNamespace($namespace);
$this->persistClass($controller, $file);
$io->ok('Controller successfully created at ' . $controllerPath, true);
}
/**
* Saves the class name to a file
*
* @param string $controllerName Name of the Controller
* @param PhpFile $file Class Object from Nette\PhpGenerator
*
* @return void
*/
protected function persistClass(string $controllerName, PhpFile $file)
{
$printer = new \Nette\PhpGenerator\PsrPrinter();
file_put_contents(getcwd() . DIRECTORY_SEPARATOR . $this->config['app_root'] . 'controllers' . DIRECTORY_SEPARATOR . $controllerName . '.php', $printer->printFile($file));
}
}

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace flight\commands;
use Flight;
use flight\net\Route;
/**
* @property-read ?bool $get
* @property-read ?bool $post
* @property-read ?bool $delete
* @property-read ?bool $put
* @property-read ?bool $patch
*/
class RouteCommand extends AbstractBaseCommand
{
/**
* Construct
*
* @param array<string,mixed> $config JSON config from .runway-config.json
*/
public function __construct(array $config)
{
parent::__construct('routes', 'Gets all routes for an application', $config);
$this->option('--get', 'Only return GET requests');
$this->option('--post', 'Only return POST requests');
$this->option('--delete', 'Only return DELETE requests');
$this->option('--put', 'Only return PUT requests');
$this->option('--patch', 'Only return PATCH requests');
}
/**
* Executes the function
*
* @return void
*/
public function execute()
{
$io = $this->app()->io();
if (isset($this->config['index_root']) === false) {
$io->error('index_root not set in .runway-config.json', true);
return;
}
$io->bold('Routes', true);
$cwd = getcwd();
$index_root = $cwd . '/' . $this->config['index_root'];
// This makes it so the framework doesn't actually execute
Flight::map('start', function () {
return;
});
include($index_root);
$routes = Flight::router()->getRoutes();
$arrayOfRoutes = [];
foreach ($routes as $route) {
if ($this->shouldAddRoute($route) === true) {
$middlewares = [];
if (!empty($route->middleware)) {
try {
$middlewares = array_map(function ($middleware) {
$middleware_class_name = explode("\\", get_class($middleware));
return preg_match("/^class@anonymous/", end($middleware_class_name)) ? 'Anonymous' : end($middleware_class_name);
}, $route->middleware);
} catch (\TypeError $e) {
$middlewares[] = 'Bad Middleware';
} finally {
if (is_string($route->middleware) === true) {
$middlewares[] = $route->middleware;
}
}
}
$arrayOfRoutes[] = [
'Pattern' => $route->pattern,
'Methods' => implode(', ', $route->methods),
'Alias' => $route->alias ?? '',
'Streamed' => $route->is_streamed ? 'Yes' : 'No',
'Middleware' => !empty($middlewares) ? implode(",", $middlewares) : '-'
];
}
}
$io->table($arrayOfRoutes, [
'head' => 'boldGreen'
]);
}
/**
* Whether or not to add the route based on the request
*
* @param Route $route Flight Route object
*
* @return boolean
*/
public function shouldAddRoute(Route $route)
{
$boolval = false;
$showAll = !$this->get && !$this->post && !$this->put && !$this->delete && !$this->patch;
if ($showAll === true) {
$boolval = true;
} else {
$methods = [ 'GET', 'POST', 'PUT', 'DELETE', 'PATCH' ];
foreach ($methods as $method) {
$lowercaseMethod = strtolower($method);
if (
$this->{$lowercaseMethod} === true &&
(
$route->methods[0] === '*' ||
in_array($method, $route->methods, true) === true
)
) {
$boolval = true;
break;
}
}
}
return $boolval;
}
}

@ -4,10 +4,12 @@ declare(strict_types=1);
namespace flight\core;
use Closure;
use Exception;
use flight\Engine;
use InvalidArgumentException;
use Psr\Container\ContainerInterface;
use ReflectionFunction;
use Throwable;
use TypeError;
/**
@ -23,41 +25,54 @@ class Dispatcher
{
public const FILTER_BEFORE = 'before';
public const FILTER_AFTER = 'after';
private const FILTER_TYPES = [self::FILTER_BEFORE, self::FILTER_AFTER];
/** @var mixed $containerException Exception message if thrown by setting the container as a callable method */
protected $containerException = null;
/** Exception message if thrown by setting the container as a callable method. */
protected ?Throwable $containerException = null;
/** @var ?Engine $engine Engine instance */
/** @var ?Engine $engine Engine instance. */
protected ?Engine $engine = null;
/** @var array<string, Closure(): (void|mixed)> Mapped events. */
/** @var array<string, callable(): (void|mixed)> Mapped events. */
protected array $events = [];
/**
* Method filters.
*
* @var array<string, array<'before'|'after', array<int, Closure(array<int, mixed> &$params, mixed &$output): (void|false)>>>
* @var array<string, array<'before'|'after', array<int, callable(array<int, mixed> &$params, mixed &$output): (void|false)>>>
*/
protected array $filters = [];
/**
* This is a container for the dependency injection.
*
* @var callable|object|null
* @var null|ContainerInterface|(callable(string $classString, array<int, mixed> $params): (null|object))
*/
protected $containerHandler = null;
/**
* Sets the dependency injection container handler.
*
* @param callable|object $containerHandler Dependency injection container
* @param ContainerInterface|(callable(string $classString, array<int, mixed> $params): (null|object)) $containerHandler
* Dependency injection container.
*
* @return void
* @throws InvalidArgumentException If $containerHandler is not a `callable` or instance of `Psr\Container\ContainerInterface`.
*/
public function setContainerHandler($containerHandler): void
{
$this->containerHandler = $containerHandler;
$containerInterfaceNS = '\Psr\Container\ContainerInterface';
if (
is_a($containerHandler, $containerInterfaceNS)
|| is_callable($containerHandler)
) {
$this->containerHandler = $containerHandler;
return;
}
throw new InvalidArgumentException(
"\$containerHandler must be of type callable or instance $containerInterfaceNS"
);
}
public function setEngine(Engine $engine): void
@ -68,11 +83,11 @@ class Dispatcher
/**
* Dispatches an event.
*
* @param string $name Event name
* @param string $name Event name.
* @param array<int, mixed> $params Callback parameters.
*
* @return mixed Output of callback
* @throws Exception If event name isn't found or if event throws an `Exception`
* @throws Exception If event name isn't found or if event throws an `Exception`.
*/
public function run(string $name, array $params = [])
{
@ -110,7 +125,7 @@ class Dispatcher
$requestedMethod = $this->get($eventName);
if ($requestedMethod === null) {
throw new Exception("Event '{$eventName}' isn't found.");
throw new Exception("Event '$eventName' isn't found.");
}
return $this->execute($requestedMethod, $params);
@ -138,8 +153,8 @@ class Dispatcher
/**
* Assigns a callback to an event.
*
* @param string $name Event name
* @param Closure(): (void|mixed) $callback Callback function
* @param string $name Event name.
* @param callable(): (void|mixed) $callback Callback function.
*
* @return $this
*/
@ -153,9 +168,9 @@ class Dispatcher
/**
* Gets an assigned callback.
*
* @param string $name Event name
* @param string $name Event name.
*
* @return null|(Closure(): (void|mixed)) $callback Callback function
* @return null|(callable(): (void|mixed)) $callback Callback function.
*/
public function get(string $name): ?callable
{
@ -165,9 +180,9 @@ class Dispatcher
/**
* Checks if an event has been set.
*
* @param string $name Event name
* @param string $name Event name.
*
* @return bool Event status
* @return bool If event exists or doesn't exists.
*/
public function has(string $name): bool
{
@ -177,7 +192,7 @@ class Dispatcher
/**
* Clears an event. If no name is given, all events will be removed.
*
* @param ?string $name Event name
* @param ?string $name Event name.
*/
public function clear(?string $name = null): void
{
@ -188,27 +203,38 @@ class Dispatcher
return;
}
$this->events = [];
$this->filters = [];
$this->reset();
}
/**
* Hooks a callback to an event.
*
* @param string $name Event name
* @param 'before'|'after' $type Filter type
* @param Closure(array<int, mixed> &$params, string &$output): (void|false) $callback
* @param 'before'|'after' $type Filter type.
* @param callable(array<int, mixed> &$params, mixed &$output): (void|false)|callable(mixed &$output): (void|false) $callback
*
* @return $this
*/
public function hook(string $name, string $type, callable $callback): self
{
if (!in_array($type, self::FILTER_TYPES, true)) {
$noticeMessage = "Invalid filter type '$type', use " . join('|', self::FILTER_TYPES);
static $filterTypes = [self::FILTER_BEFORE, self::FILTER_AFTER];
if (!in_array($type, $filterTypes, true)) {
$noticeMessage = "Invalid filter type '$type', use " . join('|', $filterTypes);
trigger_error($noticeMessage, E_USER_NOTICE);
}
if ($type === self::FILTER_AFTER) {
$callbackInfo = new ReflectionFunction($callback);
$parametersNumber = $callbackInfo->getNumberOfParameters();
if ($parametersNumber === 1) {
/** @disregard &$params in after filters are deprecated. */
$callback = fn (array &$params, &$output) => $callback($output);
}
}
$this->filters[$name][$type][] = $callback;
return $this;
@ -217,10 +243,10 @@ class Dispatcher
/**
* Executes a chain of method filters.
*
* @param array<int, Closure(array<int, mixed> &$params, mixed &$output): (void|false)> $filters
* Chain of filters-
* @param array<int, mixed> $params Method parameters
* @param mixed $output Method output
* @param array<int, callable(array<int, mixed> &$params, mixed &$output): (void|false)> $filters
* Chain of filters.
* @param array<int, mixed> $params Method parameters.
* @param mixed $output Method output.
*
* @throws Exception If an event throws an `Exception` or if `$filters` contains an invalid filter.
*/
@ -242,16 +268,19 @@ class Dispatcher
/**
* Executes a callback function.
*
* @param callable-string|(Closure(): mixed)|array{class-string|object, string} $callback
* Callback function
* @param array<int, mixed> $params Function parameters
* @param callable-string|(callable(): mixed)|array{class-string|object, string} $callback
* Callback function.
* @param array<int, mixed> $params Function parameters.
*
* @return mixed Function results
* @return mixed Function results.
* @throws Exception If `$callback` also throws an `Exception`.
*/
public function execute($callback, array &$params = [])
{
if (is_string($callback) === true && (strpos($callback, '->') !== false || strpos($callback, '::') !== false)) {
if (
is_string($callback) === true
&& (strpos($callback, '->') !== false || strpos($callback, '::') !== false)
) {
$callback = $this->parseStringClassAndMethod($callback);
}
@ -263,28 +292,26 @@ class Dispatcher
*
* @param string $classAndMethod Class and method
*
* @return array{class-string|object, string} Class and method
* @return array{0: class-string|object, 1: string} Class and method
*/
public function parseStringClassAndMethod(string $classAndMethod): array
{
$class_parts = explode('->', $classAndMethod);
if (count($class_parts) === 1) {
$class_parts = explode('::', $class_parts[0]);
}
$classParts = explode('->', $classAndMethod);
$class = $class_parts[0];
$method = $class_parts[1];
if (count($classParts) === 1) {
$classParts = explode('::', $classParts[0]);
}
return [ $class, $method ];
return $classParts;
}
/**
* Calls a function.
*
* @param callable $func Name of function to call
* @param array<int, mixed> &$params Function parameters
* @param callable $func Name of function to call.
* @param array<int, mixed> &$params Function parameters.
*
* @return mixed Function results
* @return mixed Function results.
* @deprecated 3.7.0 Use invokeCallable instead
*/
public function callFunction(callable $func, array &$params = [])
@ -295,12 +322,12 @@ class Dispatcher
/**
* Invokes a method.
*
* @param array{class-string|object, string} $func Class method
* @param array<int, mixed> &$params Class method parameters
* @param array{0: class-string|object, 1: string} $func Class method.
* @param array<int, mixed> &$params Class method parameters.
*
* @return mixed Function results
* @return mixed Function results.
* @throws TypeError For nonexistent class name.
* @deprecated 3.7.0 Use invokeCallable instead
* @deprecated 3.7.0 Use invokeCallable instead.
*/
public function invokeMethod(array $func, array &$params = [])
{
@ -310,12 +337,12 @@ class Dispatcher
/**
* Invokes a callable (anonymous function or Class->method).
*
* @param array{class-string|object, string}|Callable $func Class method
* @param array<int, mixed> &$params Class method parameters
* @param array{0: class-string|object, 1: string}|callable $func Class method.
* @param array<int, mixed> &$params Class method parameters.
*
* @return mixed Function results
* @return mixed Function results.
* @throws TypeError For nonexistent class name.
* @throws InvalidArgumentException If the constructor requires parameters
* @throws InvalidArgumentException If the constructor requires parameters.
* @version 3.7.0
*/
public function invokeCallable($func, array &$params = [])
@ -323,56 +350,43 @@ class Dispatcher
// If this is a directly callable function, call it
if (is_array($func) === false) {
$this->verifyValidFunction($func);
return call_user_func_array($func, $params);
}
[$class, $method] = $func;
$resolvedClass = null;
// Only execute the container handler if it's not a Flight class
if (
$this->containerHandler !== null &&
(
(
is_object($class) === true &&
strpos(get_class($class), 'flight\\') === false
) ||
is_string($class) === true
)
) {
$containerHandler = $this->containerHandler;
$resolvedClass = $this->resolveContainerClass($containerHandler, $class, $params);
if ($resolvedClass !== null) {
$mustUseTheContainer = $this->mustUseContainer($class);
if ($mustUseTheContainer === true) {
$resolvedClass = $this->resolveContainerClass($class, $params);
if ($resolvedClass) {
$class = $resolvedClass;
}
}
$this->verifyValidClassCallable($class, $method, $resolvedClass);
$this->verifyValidClassCallable($class, $method, $resolvedClass ?? null);
// Class is a string, and method exists, create the object by hand and inject only the Engine
if (is_string($class) === true) {
if (is_string($class)) {
$class = new $class($this->engine);
}
return call_user_func_array([ $class, $method ], $params);
return call_user_func_array([$class, $method], $params);
}
/**
* Handles invalid callback types.
*
* @param callable-string|(Closure(): mixed)|array{class-string|object, string} $callback
* Callback function
* @param callable-string|(callable(): mixed)|array{0: class-string|object, 1: string} $callback
* Callback function.
*
* @throws InvalidArgumentException If `$callback` is an invalid type
* @throws InvalidArgumentException If `$callback` is an invalid type.
*/
protected function verifyValidFunction($callback): void
{
$isInvalidFunctionName = (
is_string($callback)
&& !function_exists($callback)
);
if ($isInvalidFunctionName) {
if (is_string($callback) && !function_exists($callback)) {
throw new InvalidArgumentException('Invalid callback specified.');
}
}
@ -381,84 +395,92 @@ class Dispatcher
/**
* Verifies if the provided class and method are valid callable.
*
* @param string|object $class The class name.
* @param class-string|object $class The class name.
* @param string $method The method name.
* @param object|null $resolvedClass The resolved class.
*
* @throws Exception If the class or method is not found.
*
* @return void
*/
protected function verifyValidClassCallable($class, $method, $resolvedClass): void
{
$final_exception = null;
$exception = null;
// Final check to make sure it's actually a class and a method, or throw an error
if (is_object($class) === false && class_exists($class) === false) {
$final_exception = new Exception("Class '$class' not found. Is it being correctly autoloaded with Flight::path()?");
$exception = new Exception("Class '$class' not found. Is it being correctly autoloaded with Flight::path()?");
// If this tried to resolve a class in a container and failed somehow, throw the exception
} elseif (isset($resolvedClass) === false && $this->containerException !== null) {
$final_exception = $this->containerException;
// If this tried to resolve a class in a container and failed somehow, throw the exception
} elseif (!$resolvedClass && $this->containerException !== null) {
$exception = $this->containerException;
// Class is there, but no method
// Class is there, but no method
} elseif (is_object($class) === true && method_exists($class, $method) === false) {
$final_exception = new Exception("Class found, but method '" . get_class($class) . "::$method' not found.");
$classNamespace = get_class($class);
$exception = new Exception("Class found, but method '$classNamespace::$method' not found.");
}
if ($final_exception !== null) {
if ($exception !== null) {
$this->fixOutputBuffering();
throw $final_exception;
throw $exception;
}
}
/**
* Resolves the container class.
*
* @param callable|object $container_handler Dependency injection container
* @param class-string $class Class name
* @param array<int, mixed> &$params Class constructor parameters
* @param class-string $class Class name.
* @param array<int, mixed> &$params Class constructor parameters.
*
* @return object Class object
* @return ?object Class object.
*/
protected function resolveContainerClass($container_handler, $class, array &$params)
public function resolveContainerClass(string $class, array &$params)
{
$class_object = null;
// PSR-11
if (
is_object($container_handler) === true &&
method_exists($container_handler, 'has') === true &&
$container_handler->has($class)
is_a($this->containerHandler, '\Psr\Container\ContainerInterface')
&& $this->containerHandler->has($class)
) {
$class_object = call_user_func([$container_handler, 'get'], $class);
return $this->containerHandler->get($class);
}
// Just a callable where you configure the behavior (Dice, PHP-DI, etc.)
} elseif (is_callable($container_handler) === true) {
// This is to catch all the error that could be thrown by whatever container you are using
if (is_callable($this->containerHandler)) {
/* This is to catch all the error that could be thrown by whatever
container you are using */
try {
$class_object = call_user_func($container_handler, $class, $params);
} catch (Exception $e) {
// could not resolve a class for some reason
$class_object = null;
return ($this->containerHandler)($class, $params);
// could not resolve a class for some reason
} catch (Exception $exception) {
// If the container throws an exception, we need to catch it
// and store it somewhere. If we just let it throw itself, it
// doesn't properly close the output buffers and can cause other
// issues.
// This is thrown in the verifyValidClassCallable method
$this->containerException = $e;
// This is thrown in the verifyValidClassCallable method.
$this->containerException = $exception;
}
}
return $class_object;
return null;
}
/**
* Because this could throw an exception in the middle of an output buffer,
* Checks to see if a container should be used or not.
*
* @return void
* @param string|object $class the class to verify
*
* @return boolean
*/
public function mustUseContainer($class): bool
{
return $this->containerHandler !== null && (
(is_object($class) === true && strpos(get_class($class), 'flight\\') === false)
|| is_string($class)
);
}
/** Because this could throw an exception in the middle of an output buffer, */
protected function fixOutputBuffering(): void
{
// Cause PHPUnit has 1 level of output buffering by default

@ -25,6 +25,11 @@ class Loader
*/
protected array $classes = [];
/**
* If this is disabled, classes can load with underscores
*/
protected static bool $v2ClassLoading = true;
/**
* Class instances.
*
@ -190,14 +195,14 @@ class Loader
*/
public static function loadClass(string $class): void
{
$classFile = str_replace(['\\', '_'], '/', $class) . '.php';
$replace_chars = self::$v2ClassLoading === true ? ['\\', '_'] : ['\\'];
$classFile = str_replace($replace_chars, '/', $class) . '.php';
foreach (self::$dirs as $dir) {
$filePath = "$dir/$classFile";
if (file_exists($filePath)) {
require_once $filePath;
return;
}
}
@ -220,4 +225,17 @@ class Loader
}
}
}
/**
* Sets the value for V2 class loading.
*
* @param bool $value The value to set for V2 class loading.
*
* @return void
*/
public static function setV2ClassLoading(bool $value): void
{
self::$v2ClassLoading = $value;
}
}

@ -151,7 +151,7 @@ class Request
'method' => self::getMethod(),
'referrer' => self::getVar('HTTP_REFERER'),
'ip' => self::getVar('REMOTE_ADDR'),
'ajax' => 'XMLHttpRequest' === self::getVar('HTTP_X_REQUESTED_WITH'),
'ajax' => self::getVar('HTTP_X_REQUESTED_WITH') === 'XMLHttpRequest',
'scheme' => self::getScheme(),
'user_agent' => self::getVar('HTTP_USER_AGENT'),
'type' => self::getVar('CONTENT_TYPE'),
@ -160,7 +160,7 @@ class Request
'data' => new Collection($_POST),
'cookies' => new Collection($_COOKIE),
'files' => new Collection($_FILES),
'secure' => 'https' === self::getScheme(),
'secure' => self::getScheme() === 'https',
'accept' => self::getVar('HTTP_ACCEPT'),
'proxy_ip' => self::getProxyIpAddress(),
'host' => self::getVar('HTTP_HOST'),
@ -188,12 +188,12 @@ class Request
// This rewrites the url in case the public url and base directories match
// (such as installing on a subdirectory in a web server)
// @see testInitUrlSameAsBaseDirectory
if ('/' !== $this->base && '' !== $this->base && 0 === strpos($this->url, $this->base)) {
if ($this->base !== '/' && $this->base !== '' && strpos($this->url, $this->base) === 0) {
$this->url = substr($this->url, \strlen($this->base));
}
// Default url
if (empty($this->url)) {
if (empty($this->url) === true) {
$this->url = '/';
} else {
// Merge URL query parameters with $_GET
@ -203,11 +203,11 @@ class Request
}
// Check for JSON input
if (0 === strpos($this->type, 'application/json')) {
if (strpos($this->type, 'application/json') === 0) {
$body = $this->getBody();
if ('' !== $body) {
if ($body !== '') {
$data = json_decode($body, true);
if (is_array($data)) {
if (is_array($data) === true) {
$this->data->setData($data);
}
}
@ -225,13 +225,13 @@ class Request
{
$body = $this->body;
if ('' !== $body) {
if ($body !== '') {
return $body;
}
$method = $this->method ?? self::getMethod();
if ('POST' === $method || 'PUT' === $method || 'DELETE' === $method || 'PATCH' === $method) {
if ($method === 'POST' || $method === 'PUT' || $method === 'DELETE' || $method === 'PATCH') {
$body = file_get_contents($this->stream_path);
}
@ -247,9 +247,9 @@ class Request
{
$method = self::getVar('REQUEST_METHOD', 'GET');
if (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
if (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']) === true) {
$method = $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'];
} elseif (isset($_REQUEST['_method'])) {
} elseif (isset($_REQUEST['_method']) === true) {
$method = $_REQUEST['_method'];
}
@ -275,9 +275,9 @@ class Request
$flags = \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE;
foreach ($forwarded as $key) {
if (\array_key_exists($key, $_SERVER)) {
if (\array_key_exists($key, $_SERVER) === true) {
sscanf($_SERVER[$key], '%[^,]', $ip);
if (false !== filter_var($ip, \FILTER_VALIDATE_IP, $flags)) {
if (filter_var($ip, \FILTER_VALIDATE_IP, $flags) !== false) {
return $ip;
}
}
@ -322,7 +322,7 @@ class Request
{
$headers = [];
foreach ($_SERVER as $key => $value) {
if (0 === strpos($key, 'HTTP_')) {
if (strpos($key, 'HTTP_') === 0) {
// converts headers like HTTP_CUSTOM_HEADER to Custom-Header
$key = str_replace(' ', '-', ucwords(str_replace('_', ' ', strtolower(substr($key, 5)))));
$headers[$key] = $value;
@ -386,7 +386,7 @@ class Request
$params = [];
$args = parse_url($url);
if (isset($args['query'])) {
if (isset($args['query']) === true) {
parse_str($args['query'], $params);
}
@ -401,13 +401,13 @@ class Request
public static function getScheme(): string
{
if (
(isset($_SERVER['HTTPS']) && 'on' === strtolower($_SERVER['HTTPS']))
(isset($_SERVER['HTTPS']) === true && strtolower($_SERVER['HTTPS']) === 'on')
||
(isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO'])
(isset($_SERVER['HTTP_X_FORWARDED_PROTO']) === true && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https')
||
(isset($_SERVER['HTTP_FRONT_END_HTTPS']) && 'on' === $_SERVER['HTTP_FRONT_END_HTTPS'])
(isset($_SERVER['HTTP_FRONT_END_HTTPS']) === true && $_SERVER['HTTP_FRONT_END_HTTPS'] === 'on')
||
(isset($_SERVER['REQUEST_SCHEME']) && 'https' === $_SERVER['REQUEST_SCHEME'])
(isset($_SERVER['REQUEST_SCHEME']) === true && $_SERVER['REQUEST_SCHEME'] === 'https')
) {
return 'https';
}

@ -128,6 +128,13 @@ class Response
*/
protected bool $sent = false;
/**
* These are callbacks that can process the response body before it's sent
*
* @var array<int, callable> $responseBodyCallbacks
*/
protected array $responseBodyCallbacks = [];
/**
* Sets the HTTP status of the response.
*
@ -139,7 +146,7 @@ class Response
*/
public function status(?int $code = null)
{
if (null === $code) {
if ($code === null) {
return $this->status;
}
@ -279,19 +286,22 @@ class Response
*/
public function cache($expires): self
{
if (false === $expires) {
if ($expires === false) {
$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['Pragma'] = 'no-cache';
} else {
$expires = \is_int($expires) ? $expires : strtotime($expires);
$this->headers['Expires'] = gmdate('D, d M Y H:i:s', $expires) . ' GMT';
$this->headers['Cache-Control'] = 'max-age=' . ($expires - time());
if (isset($this->headers['Pragma']) && 'no-cache' == $this->headers['Pragma']) {
if (isset($this->headers['Pragma']) && $this->headers['Pragma'] === 'no-cache') {
unset($this->headers['Pragma']);
}
}
@ -307,7 +317,7 @@ class Response
public function sendHeaders(): self
{
// Send status code header
if (false !== strpos(\PHP_SAPI, 'cgi')) {
if (strpos(\PHP_SAPI, 'cgi') !== false) {
// @codeCoverageIgnoreStart
$this->setRealHeader(
sprintf(
@ -331,6 +341,15 @@ class Response
);
}
if ($this->content_length === true) {
// Send content length
$length = $this->getContentLength();
if ($length > 0) {
$this->setHeader('Content-Length', (string) $length);
}
}
// Send other headers
foreach ($this->headers as $field => $value) {
if (\is_array($value)) {
@ -342,15 +361,6 @@ class Response
}
}
if ($this->content_length) {
// Send content length
$length = $this->getContentLength();
if ($length > 0) {
$this->setRealHeader('Content-Length: ' . $length);
}
}
return $this;
}
@ -422,7 +432,12 @@ class Response
}
}
if (!headers_sent()) {
// Only for the v3 output buffering.
if ($this->v2_output_buffering === false) {
$this->processResponseCallbacks();
}
if (headers_sent() === false) {
$this->sendHeaders(); // @codeCoverageIgnore
}
@ -430,4 +445,29 @@ class Response
$this->sent = true;
}
/**
* Adds a callback to process the response body before it's sent. These are processed in the order
* they are added
*
* @param callable $callback The callback to process the response body
*
* @return void
*/
public function addResponseBodyCallback(callable $callback): void
{
$this->responseBodyCallbacks[] = $callback;
}
/**
* Cycles through the response body callbacks and processes them in order
*
* @return void
*/
protected function processResponseCallbacks(): void
{
foreach ($this->responseBodyCallbacks as $callback) {
$this->body = $callback($this->body);
}
}
}

@ -63,7 +63,7 @@ class Route
/**
* The middleware to be applied to the route
*
* @var array<int, callable|object>
* @var array<int, callable|object|string>
*/
public array $middleware = [];
@ -105,7 +105,7 @@ class Route
public function matchUrl(string $url, bool $case_sensitive = false): bool
{
// Wildcard or exact match
if ('*' === $this->pattern || $this->pattern === $url) {
if ($this->pattern === '*' || $this->pattern === $url) {
return true;
}
@ -120,8 +120,9 @@ class Route
for ($i = 0; $i < $len; $i++) {
if ($url[$i] === '/') {
$n++;
++$n;
}
if ($n === $count) {
break;
}
@ -153,24 +154,20 @@ class Route
$regex
);
if ('/' === $last_char) { // Fix trailing slash
$regex .= '?';
} else { // Allow trailing slash
$regex .= '/?';
}
$regex .= $last_char === '/' ? '?' : '/?';
// Attempt to match route and named parameters
if (preg_match('#^' . $regex . '(?:\?[\s\S]*)?$#' . (($case_sensitive) ? '' : 'i'), $url, $matches)) {
foreach ($ids as $k => $v) {
$this->params[$k] = (\array_key_exists($k, $matches)) ? urldecode($matches[$k]) : null;
}
$this->regex = $regex;
if (!preg_match('#^' . $regex . '(?:\?[\s\S]*)?$#' . (($case_sensitive) ? '' : 'i'), $url, $matches)) {
return false;
}
return true;
foreach (array_keys($ids) as $k) {
$this->params[$k] = (\array_key_exists($k, $matches)) ? urldecode($matches[$k]) : null;
}
return false;
$this->regex = $regex;
return true;
}
/**
@ -229,7 +226,7 @@ class Route
/**
* Sets the route middleware
*
* @param array<int, callable>|callable $middleware
* @param array<int, callable|string>|callable|string $middleware
*/
public function addMiddleware($middleware): self
{
@ -241,6 +238,17 @@ class Route
return $this;
}
/**
* If the response should be streamed
*
* @return self
*/
public function stream(): self
{
$this->is_streamed = true;
return $this;
}
/**
* This will allow the response for this route to be streamed.
*

@ -32,7 +32,7 @@ class Router
/**
* The current route that is has been found and executed.
*/
protected ?Route $executedRoute = null;
public ?Route $executedRoute = null;
/**
* Pointer to current route.
@ -42,21 +42,21 @@ class Router
/**
* When groups are used, this is mapped against all the routes
*/
protected string $group_prefix = '';
protected string $groupPrefix = '';
/**
* Group Middleware
*
* @var array<int,mixed>
*/
protected array $group_middlewares = [];
protected array $groupMiddlewares = [];
/**
* Allowed HTTP methods
*
* @var array<int, string>
*/
protected array $allowed_methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
protected array $allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
/**
* Gets mapped routes.
@ -93,7 +93,7 @@ class Router
// Flight::route('', function() {});
// }
// Keep the space so that it can execute the below code normally
if ($this->group_prefix !== '') {
if ($this->groupPrefix !== '') {
$url = ltrim($pattern);
} else {
$url = trim($pattern);
@ -101,7 +101,7 @@ class Router
$methods = ['*'];
if (false !== strpos($url, ' ')) {
if (strpos($url, ' ') !== false) {
[$method, $url] = explode(' ', $url, 2);
$url = trim($url);
$methods = explode('|', $method);
@ -113,14 +113,14 @@ class Router
}
// And this finishes it off.
if ($this->group_prefix !== '') {
$url = rtrim($this->group_prefix . $url);
if ($this->groupPrefix !== '') {
$url = rtrim($this->groupPrefix . $url);
}
$route = new Route($url, $callback, $methods, $pass_route, $route_alias);
// to handle group middleware
foreach ($this->group_middlewares as $gm) {
foreach ($this->groupMiddlewares as $gm) {
$route->addMiddleware($gm);
}
@ -197,20 +197,20 @@ class Router
/**
* Group together a set of routes
*
* @param string $group_prefix group URL prefix (such as /api/v1)
* @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> $group_middlewares
* @param array<int, callable|object> $groupMiddlewares
* The middlewares to be applied to the group. Example: `[$middleware1, $middleware2]`
*/
public function group(string $group_prefix, callable $callback, array $group_middlewares = []): void
public function group(string $groupPrefix, callable $callback, array $groupMiddlewares = []): void
{
$old_group_prefix = $this->group_prefix;
$old_group_middlewares = $this->group_middlewares;
$this->group_prefix .= $group_prefix;
$this->group_middlewares = array_merge($this->group_middlewares, $group_middlewares);
$oldGroupPrefix = $this->groupPrefix;
$oldGroupMiddlewares = $this->groupMiddlewares;
$this->groupPrefix .= $groupPrefix;
$this->groupMiddlewares = array_merge($this->groupMiddlewares, $groupMiddlewares);
$callback($this);
$this->group_prefix = $old_group_prefix;
$this->group_middlewares = $old_group_middlewares;
$this->groupPrefix = $oldGroupPrefix;
$this->groupMiddlewares = $oldGroupMiddlewares;
}
/**
@ -221,9 +221,14 @@ class Router
public function route(Request $request)
{
while ($route = $this->current()) {
if ($route->matchMethod($request->method) && $route->matchUrl($request->url, $this->case_sensitive)) {
$urlMatches = $route->matchUrl($request->url, $this->case_sensitive);
$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
} elseif ($urlMatches === true && $methodMatches === false) {
$this->executedRoute = $route;
}
$this->next();
}
@ -299,12 +304,20 @@ class Router
return $this->routes[$this->index] ?? false;
}
/**
* Gets the previous route.
*/
public function previous(): void
{
--$this->index;
}
/**
* Gets the next route.
*/
public function next(): void
{
$this->index++;
++$this->index;
}
/**
@ -312,6 +325,6 @@ class Router
*/
public function reset(): void
{
$this->index = 0;
$this->rewind();
}
}

@ -20,6 +20,8 @@ class View
/** File extension. */
public string $extension = '.php';
public bool $preserveVars = true;
/**
* View variables.
*
@ -88,7 +90,7 @@ class View
*/
public function clear(?string $key = null): self
{
if (null === $key) {
if ($key === null) {
$this->vars = [];
} else {
unset($this->vars[$key]);
@ -114,12 +116,16 @@ class View
throw new \Exception("Template file not found: {$normalized_path}.");
}
if (\is_array($data)) {
$this->vars = \array_merge($this->vars, $data);
}
\extract($this->vars);
if (\is_array($data) === true) {
\extract($data);
if ($this->preserveVars === true) {
$this->vars = \array_merge($this->vars, $data);
}
}
include $this->template;
}
@ -169,7 +175,7 @@ class View
$is_windows = \strtoupper(\substr(PHP_OS, 0, 3)) === 'WIN';
if (('/' == \substr($file, 0, 1)) || ($is_windows === true && ':' == \substr($file, 1, 1))) {
if ((\substr($file, 0, 1) === '/') || ($is_windows && \substr($file, 1, 1) === ':')) {
return $file;
}

@ -95,7 +95,7 @@ class Collection implements ArrayAccess, Iterator, Countable, JsonSerializable
#[\ReturnTypeWillChange]
public function offsetSet($offset, $value): void
{
if (null === $offset) {
if ($offset === null) {
$this->data[] = $value;
} else {
$this->data[$offset] = $value;
@ -166,9 +166,7 @@ class Collection implements ArrayAccess, Iterator, Countable, JsonSerializable
*/
public function valid(): bool
{
$key = key($this->data);
return null !== $key;
return key($this->data) !== null;
}
/**

@ -3,7 +3,6 @@
declare(strict_types=1);
// This file is only here so that the PHP8 attribute for doesn't throw an error in files
// phpcs:ignoreFile PSR1.Classes.ClassDeclaration.MissingNamespace
class ReturnTypeWillChange
{
}

@ -1,7 +1,9 @@
<?php
// require 'flight/Flight.php';
require 'flight/autoload.php';
declare(strict_types=1);
require_once 'flight/Flight.php';
// require 'flight/autoload.php';
Flight::route('/', function () {
echo 'hello world!';

@ -9,35 +9,43 @@
</description>
<arg name="colors" />
<arg name="tab-width" value="4" />
<rule ref="PSR12" />
<rule ref="PSR1">
<exclude name="PSR1.Classes.ClassDeclaration.MissingNamespace" />
</rule>
<rule ref="PSR12" />
<rule ref="Generic">
<exclude name="Generic.Files.LineEndings.InvalidEOLChar" />
<exclude name="Generic.PHP.ClosingPHPTag.NotFound" />
<exclude name="Generic.PHP.UpperCaseConstant.Found" />
<exclude name="Generic.Arrays.DisallowShortArraySyntax.Found" />
<exclude name="Generic.Files.EndFileNoNewline.Found" />
<exclude name="Generic.Files.LowercasedFilename.NotFound" />
<exclude name="Generic.Commenting.DocComment.TagValueIndent" />
<exclude name="Generic.Classes.OpeningBraceSameLine.BraceOnNewLine" />
<exclude name="Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed" />
<exclude name="Generic.Files.LowercasedFilename.NotFound" />
<exclude name="Generic.Commenting.DocComment.ContentAfterOpen" />
<exclude name="Generic.Commenting.DocComment.ContentBeforeClose" />
<exclude name="Generic.Commenting.DocComment.MissingShort" />
<exclude name="Generic.Commenting.DocComment.SpacingBeforeShort" />
<exclude name="Generic.Formatting.NoSpaceAfterCast.SpaceFound" />
<exclude name="Generic.Functions.OpeningFunctionBraceKernighanRitchie.BraceOnNewLine" />
<exclude name="Generic.Formatting.MultipleStatementAlignment.NotSameWarning" />
<exclude name="Generic.Formatting.SpaceAfterNot.Incorrect" />
<exclude name="Generic.Commenting.DocComment.SpacingBeforeShort" />
<exclude name="Generic.Commenting.DocComment.ContentAfterOpen" />
<exclude name="Generic.Functions.OpeningFunctionBraceBsdAllman.BraceOnSameLine" />
<exclude name="Generic.PHP.DisallowRequestSuperglobal.Found" />
<exclude name="Generic.Commenting.DocComment.ContentBeforeClose" />
<exclude name="Generic.ControlStructures.DisallowYodaConditions.Found" />
<exclude name="Generic.Strings.UnnecessaryStringConcat.Found" />
<exclude name="Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition" />
<exclude name="Generic.Commenting.DocComment.MissingShort" />
<exclude name="Generic.Commenting.DocComment.SpacingBeforeTags" />
<exclude name="Generic.WhiteSpace.ArbitraryParenthesesSpacing.SpaceAfterOpen" />
<exclude name="Generic.WhiteSpace.ArbitraryParenthesesSpacing.SpaceBeforeClose" />
</rule>
<rule ref="Generic.Files.LineLength">
<properties>
<property name="ignoreComments" value="true" />
</properties>
</rule>
<rule ref="Generic.Formatting.SpaceAfterNot">
<properties>
<property name="spacing" value="0" />
</properties>
</rule>
<rule ref="Generic.WhiteSpace.ArbitraryParenthesesSpacing">
<properties>
<property name="ignoreNewlines" value="true" />
</properties>
</rule>
<file>flight/</file>
<file>tests/</file>

@ -1,6 +0,0 @@
parameters:
ignoreErrors:
-
message: "#^Parameter \\#2 \\$callback of method flight\\\\core\\\\Dispatcher\\:\\:set\\(\\) expects Closure\\(\\)\\: mixed, array\\{\\$this\\(flight\\\\Engine\\), literal\\-string&non\\-falsy\\-string\\} given\\.$#"
count: 1
path: flight/Engine.php

@ -6,6 +6,7 @@ parameters:
level: 6
excludePaths:
- vendor
- flight/util/ReturnTypeWillChange.php
paths:
- flight
- index.php

@ -11,10 +11,13 @@
stopOnFailure="true"
verbose="true"
colors="true">
<coverage processUncoveredFiles="true">
<coverage processUncoveredFiles="false">
<include>
<directory suffix=".php">flight/</directory>
</include>
<exclude>
<file>flight/autoload.php</file>
</exclude>
</coverage>
<testsuites>
<testsuite name="default">

@ -37,9 +37,7 @@ class DispatcherTest extends TestCase
public function testFunctionMapping(): void
{
$this->dispatcher->set('map2', function (): string {
return 'hello';
});
$this->dispatcher->set('map2', fn (): string => 'hello');
$this->assertSame('hello', $this->dispatcher->run('map2'));
}
@ -61,6 +59,9 @@ class DispatcherTest extends TestCase
->set('map-event', $customFunction)
->set('map-event-2', $anotherFunction);
$this->assertTrue($this->dispatcher->has('map-event'));
$this->assertTrue($this->dispatcher->has('map-event-2'));
$this->dispatcher->clear();
$this->assertFalse($this->dispatcher->has('map-event'));
@ -76,6 +77,9 @@ class DispatcherTest extends TestCase
->set('map-event', $customFunction)
->set('map-event-2', $anotherFunction);
$this->assertTrue($this->dispatcher->has('map-event'));
$this->assertTrue($this->dispatcher->has('map-event-2'));
$this->dispatcher->clear('map-event');
$this->assertFalse($this->dispatcher->has('map-event'));
@ -105,9 +109,7 @@ class DispatcherTest extends TestCase
public function testBeforeAndAfter(): void
{
$this->dispatcher->set('hello', function (string $name): string {
return "Hello, $name!";
});
$this->dispatcher->set('hello', fn (string $name): string => "Hello, $name!");
$this->dispatcher
->hook('hello', Dispatcher::FILTER_BEFORE, function (array &$params): void {
@ -124,6 +126,25 @@ class DispatcherTest extends TestCase
$this->assertSame('Hello, Fred! Have a nice day!', $result);
}
public function testBeforeAndAfterWithShortAfterFilterSyntax(): void
{
$this->dispatcher->set('hello', fn (string $name): string => "Hello, $name!");
$this->dispatcher
->hook('hello', Dispatcher::FILTER_BEFORE, function (array &$params): void {
// Manipulate the parameter
$params[0] = 'Fred';
})
->hook('hello', Dispatcher::FILTER_AFTER, function (string &$output): void {
// Manipulate the output
$output .= ' Have a nice day!';
});
$result = $this->dispatcher->run('hello', ['Bob']);
$this->assertSame('Hello, Fred! Have a nice day!', $result);
}
public function testInvalidCallback(): void
{
$this->expectException(Exception::class);
@ -245,7 +266,7 @@ class DispatcherTest extends TestCase
public function testInvokeMethod(): void
{
$class = new TesterClass('param1', 'param2', 'param3', 'param4', 'param5', 'param6');
$result = $this->dispatcher->invokeMethod([ $class, 'instanceMethod' ]);
$result = $this->dispatcher->invokeMethod([$class, 'instanceMethod']);
$this->assertSame('param1', $class->param2);
}
@ -271,7 +292,7 @@ class DispatcherTest extends TestCase
public function testExecuteStringClassNoConstructArraySyntax(): void
{
$result = $this->dispatcher->execute([ Hello::class, 'sayHi' ]);
$result = $this->dispatcher->execute([Hello::class, 'sayHi']);
$this->assertSame('hello', $result);
}
@ -298,7 +319,7 @@ class DispatcherTest extends TestCase
$engine = new Engine();
$engine->set('test_me_out', 'You got it boss!');
$this->dispatcher->setEngine($engine);
$result = $this->dispatcher->execute([ ContainerDefault::class, 'testTheContainer' ]);
$result = $this->dispatcher->execute([ContainerDefault::class, 'testTheContainer']);
$this->assertSame('You got it boss!', $result);
}
@ -306,6 +327,6 @@ class DispatcherTest extends TestCase
{
$this->expectException(TypeError::class);
$this->expectExceptionMessageMatches('#tests\\\\classes\\\\ContainerDefault::__construct\(\).+flight\\\\Engine, null given#');
$result = $this->dispatcher->execute([ ContainerDefault::class, 'testTheContainer' ]);
$result = $this->dispatcher->execute([ContainerDefault::class, 'testTheContainer']);
}
}

@ -74,4 +74,22 @@ class DocExamplesTest extends TestCase
Flight::app()->handleException(new Exception('Error'));
$this->expectOutputString('Custom: Error');
}
public function testGetRouterStatically()
{
$router = Flight::router();
Flight::request()->method = 'GET';
Flight::request()->url = '/';
$router->get(
'/',
function () {
Flight::response()->write('from resp ');
}
);
Flight::start();
$this->expectOutputString('from resp ');
}
}

@ -11,6 +11,7 @@ use flight\Engine;
use flight\net\Request;
use flight\net\Response;
use flight\util\Collection;
use InvalidArgumentException;
use PDOException;
use PHPUnit\Framework\TestCase;
use tests\classes\Container;
@ -86,8 +87,7 @@ class EngineTest extends TestCase
public function testHandleException()
{
$engine = new Engine();
$regex_message = preg_quote('<h1>500 Internal Server Error</h1><h3>thrown exception message (20)</h3>');
$this->expectOutputRegex('~' . $regex_message . '~');
$this->expectOutputRegex('~\<h1\>500 Internal Server Error\</h1\>[\s\S]*\<h3\>thrown exception message \(20\)\</h3\>~');
$engine->handleException(new Exception('thrown exception message', 20));
}
@ -370,6 +370,16 @@ class EngineTest extends TestCase
$this->assertEquals(200, $engine->response()->status());
}
public function testJsonHalt()
{
$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(200, $engine->response()->status());
$this->assertEquals('{"key1":"value1","key2":"value2"}', $engine->response()->getBody());
}
public function testJsonP()
{
$engine = new Engine();
@ -572,6 +582,49 @@ class EngineTest extends TestCase
$this->expectOutputString('OK123after123');
}
public function testMiddlewareClassStringNoContainer()
{
$middleware = new class {
public function after($params)
{
echo 'after' . $params['id'];
}
};
$engine = new Engine();
$engine->route('/path1/@id', function ($id) {
echo 'OK' . $id;
})
->addMiddleware(get_class($middleware));
$engine->request()->url = '/path1/123';
$engine->start();
$this->expectOutputString('OK123after123');
}
public function testMiddlewareClassStringWithContainer()
{
$engine = new Engine();
$dice = new \Dice\Dice();
$dice = $dice->addRule('*', [
'substitutions' => [
Engine::class => $engine
]
]);
$engine->registerContainerHandler(function ($class, $params) use ($dice) {
return $dice->create($class, $params);
});
$engine->route('/path1/@id', function ($id) {
echo 'OK' . $id;
})
->addMiddleware(ContainerDefault::class);
$engine->request()->url = '/path1/123';
$engine->start();
$this->expectOutputString('I returned before the route was called with the following parameters: {"id":"123"}OK123');
}
public function testMiddlewareClassAfterFailedCheck()
{
$middleware = new class {
@ -681,6 +734,14 @@ class EngineTest extends TestCase
$this->expectOutputString('before456before123OKafter123456after123');
}
public function testContainerBadClass() {
$engine = new Engine();
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage("\$containerHandler must be of type callable or instance \\Psr\\Container\\ContainerInterface");
$engine->registerContainerHandler('BadClass');
}
public function testContainerDice() {
$engine = new Engine();
$dice = new \Dice\Dice();
@ -751,8 +812,14 @@ class EngineTest extends TestCase
$engine->route('/container', Container::class.'->testThePdoWrapper');
$engine->request()->url = '/container';
$this->expectException(ErrorException::class);
$this->expectExceptionMessageMatches("/Passing null to parameter/");
// 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();
}
@ -842,4 +909,47 @@ class EngineTest extends TestCase
$engine->start();
}
public function testRouteFoundButBadMethod() {
$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;
}
};
});
$engine->route('POST /path1/@id', function ($id) {
echo 'OK' . $id;
});
$engine->route('GET /path2/@id', function ($id) {
echo 'OK' . $id;
});
$engine->route('PATCH /path3/@id', function ($id) {
echo 'OK' . $id;
});
$engine->request()->url = '/path1/123';
$engine->request()->method = 'GET';
$engine->start();
$this->expectOutputString('Method Not Allowed');
$this->assertEquals(405, $engine->response()->status());
$this->assertEquals('Method Not Allowed', $engine->response()->getBody());
}
}

@ -22,6 +22,7 @@ class FlightTest extends TestCase
$_REQUEST = [];
Flight::init();
Flight::setEngine(new Engine());
Flight::set('flight.views.path', __DIR__ . '/views');
}
protected function tearDown(): void
@ -280,6 +281,30 @@ class FlightTest extends TestCase
}
public function testStreamRoute()
{
$response_mock = new class extends Response {
public function setRealHeader(string $header_string, bool $replace = true, int $response_code = 0): Response
{
return $this;
}
};
$mock_response_class_name = get_class($response_mock);
Flight::register('response', $mock_response_class_name);
Flight::route('/stream', function () {
echo 'stream';
})->stream();
Flight::request()->url = '/stream';
$this->expectOutputString('stream');
Flight::start();
$this->assertEquals('', Flight::response()->getBody());
$this->assertEquals([
'X-Accel-Buffering' => 'no',
'Connection' => 'close'
], Flight::response()->getHeaders());
$this->assertEquals(200, Flight::response()->status());
}
public function testStreamRouteWithHeaders()
{
$response_mock = new class extends Response {
public function setRealHeader(string $header_string, bool $replace = true, int $response_code = 0): Response
@ -330,4 +355,43 @@ class FlightTest extends TestCase
$this->expectOutputString('Thisisaroutewithhtml');
}
/** @dataProvider \tests\ViewTest::renderDataProvider */
public function testDoesNotPreserveVarsWhenFlagIsDisabled(
string $output,
array $renderParams,
string $regexp
): void
{
Flight::view()->preserveVars = false;
$this->expectOutputString($output);
Flight::render(...$renderParams);
set_error_handler(function (int $code, string $message) use ($regexp): void {
$this->assertMatchesRegularExpression($regexp, $message);
});
Flight::render($renderParams[0]);
restore_error_handler();
}
public function testKeepThePreviousStateOfOneViewComponentByDefault(): void
{
$this->expectOutputString(<<<html
<div>Hi</div>
<div>Hi</div>
<input type="number" />
<input type="number" />
html);
Flight::render('myComponent', ['prop' => 'Hi']);
Flight::render('myComponent');
Flight::render('input', ['type' => 'number']);
Flight::render('input');
}
}

@ -152,4 +152,17 @@ class LoaderTest extends TestCase
__DIR__ . '/classes'
], $loader->getDirectories());
}
public function testV2ClassLoading()
{
$loader = new class extends Loader {
public static function getV2ClassLoading()
{
return self::$v2ClassLoading;
}
};
$this->assertTrue($loader::getV2ClassLoading());
$loader::setV2ClassLoading(false);
$this->assertFalse($loader::getV2ClassLoading());
}
}

@ -21,7 +21,7 @@ class RedirectTest extends TestCase
public function getBaseUrl($base, $url)
{
if ('/' !== $base && false === strpos($url, '://')) {
if ($base !== '/' && strpos($url, '://') === false) {
$url = preg_replace('#/+#', '/', $base . '/' . $url);
}
@ -67,11 +67,7 @@ class RedirectTest extends TestCase
public function testBaseOverride()
{
$url = 'login';
if (null !== $this->app->get('flight.base_url')) {
$base = $this->app->get('flight.base_url');
} else {
$base = $this->app->request()->base;
}
$base = $this->app->get('flight.base_url') ?? $this->app->request()->base;
self::assertEquals('/testdir/login', $this->getBaseUrl($base, $url));
}

@ -255,4 +255,54 @@ class ResponseTest extends TestCase
$response->write('new', true);
$this->assertEquals('new', $response->getBody());
}
public function testResponseBodyCallback()
{
$response = new Response();
$response->write('test');
$str_rot13 = function ($body) {
return str_rot13($body);
};
$response->addResponseBodyCallback($str_rot13);
ob_start();
$response->send();
$rot13_body = ob_get_clean();
$this->assertEquals('grfg', $rot13_body);
}
public function testResponseBodyCallbackGzip()
{
$response = new Response();
$response->content_length = true;
$response->write('test');
$gzip = function ($body) {
return gzencode($body);
};
$response->addResponseBodyCallback($gzip);
ob_start();
$response->send();
$gzip_body = ob_get_clean();
$expected = PHP_OS === 'WINNT' ? 'H4sIAAAAAAAACitJLS4BAAx+f9gEAAAA' : 'H4sIAAAAAAAAAytJLS4BAAx+f9gEAAAA';
$this->assertEquals($expected, base64_encode($gzip_body));
$this->assertEquals(strlen(gzencode('test')), strlen($gzip_body));
}
public function testResponseBodyCallbackMultiple()
{
$response = new Response();
$response->write('test');
$str_rot13 = function ($body) {
return str_rot13($body);
};
$str_replace = function ($body) {
return str_replace('g', 'G', $body);
};
$response->addResponseBodyCallback($str_rot13);
$response->addResponseBodyCallback($str_replace);
$response->addResponseBodyCallback($str_rot13);
ob_start();
$response->send();
$rot13_body = ob_get_clean();
$this->assertEquals('TesT', $rot13_body);
}
}

@ -336,12 +336,14 @@ class RouterTest extends TestCase
{
$this->router->map('GET /api/intune/hey', [$this, 'ok']);
$error_description = 'error_description=AADSTS65004%3a+User+declined+to+consent+to+access+the';
$error_description .= '+app.%0d%0aTrace+ID%3a+747c0cc1-ccbd-4e53-8e2f-48812eb24100%0d%0a';
$error_description .= 'Correlation+ID%3a+362e3cb3-20ef-400b-904e-9983bd989184%0d%0a';
$error_description .= 'Timestamp%3a+2022-09-08+09%3a58%3a12Z';
$query_params = [
'error=access_denied',
'error_description=AADSTS65004%3a+User+declined+to+consent+to+access+the'
. '+app.%0d%0aTrace+ID%3a+747c0cc1-ccbd-4e53-8e2f-48812eb24100%0d%0a'
. 'Correlation+ID%3a+362e3cb3-20ef-400b-904e-9983bd989184%0d%0a'
. 'Timestamp%3a+2022-09-08+09%3a58%3a12Z',
$error_description,
'error_uri=https%3a%2f%2flogin.microsoftonline.com%2ferror%3fcode%3d65004',
'admin_consent=True',
'state=x2EUE0fcSj#'
@ -629,6 +631,10 @@ class RouterTest extends TestCase
$this->router->rewind();
$result = $this->router->valid();
$this->assertTrue($result);
$this->router->previous();
$result = $this->router->valid();
$this->assertFalse($result);
}
public function testGetRootUrlByAlias()

@ -152,4 +152,84 @@ class ViewTest extends TestCase
$viewMock::normalizePath('C:/xampp/htdocs/libs/Flight\core\index.php', '°')
);
}
/** @dataProvider renderDataProvider */
public function testDoesNotPreserveVarsWhenFlagIsDisabled(
string $output,
array $renderParams,
string $regexp
): void {
$this->view->preserveVars = false;
$this->expectOutputString($output);
$this->view->render(...$renderParams);
set_error_handler(function (int $code, string $message) use ($regexp): void {
$this->assertMatchesRegularExpression($regexp, $message);
});
$this->view->render($renderParams[0]);
restore_error_handler();
}
public function testKeepThePreviousStateOfOneViewComponentByDefault(): void
{
$this->expectOutputString(<<<html
<div>Hi</div>
<div>Hi</div>
<input type="number" />
<input type="number" />
html);
$this->view->render('myComponent', ['prop' => 'Hi']);
$this->view->render('myComponent');
$this->view->render('input', ['type' => 'number']);
$this->view->render('input');
}
public function testKeepThePreviousStateOfDataSettedBySetMethod(): void
{
$this->view->preserveVars = false;
$this->view->set('prop', 'bar');
$this->expectOutputString(<<<html
<div>qux</div>
<div>bar</div>
html);
$this->view->render('myComponent', ['prop' => 'qux']);
$this->view->render('myComponent');
}
public static function renderDataProvider(): array
{
return [
[
<<<html
<div>Hi</div>
<div></div>
html,
['myComponent', ['prop' => 'Hi']],
'/^Undefined variable:? \$?prop$/'
],
[
<<<html
<input type="number" />
<input type="text" />
html,
['input', ['type' => 'number']],
'/^.*$/'
],
];
}
}

@ -15,6 +15,11 @@ class ContainerDefault
$this->app = $engine;
}
public function before(array $params)
{
echo 'I returned before the route was called with the following parameters: ' . json_encode($params);
}
public function testTheContainer()
{
return $this->app->get('test_me_out');

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace tests\commands;
use Ahc\Cli\Application;
use Ahc\Cli\IO\Interactor;
use flight\commands\ControllerCommand;
use PHPUnit\Framework\TestCase;
class ControllerCommandTest extends TestCase
{
protected static $in = __DIR__ . '/input.test';
protected static $ou = __DIR__ . '/output.test';
public function setUp(): void
{
file_put_contents(static::$in, '', LOCK_EX);
file_put_contents(static::$ou, '', LOCK_EX);
}
public function tearDown(): void
{
// Make sure we clean up after ourselves:
if (file_exists(static::$in)) {
unlink(static::$in);
}
if (file_exists(static::$ou)) {
unlink(static::$ou);
}
if (file_exists(__DIR__ . '/controllers/TestController.php')) {
unlink(__DIR__ . '/controllers/TestController.php');
}
if (file_exists(__DIR__ . '/controllers/')) {
rmdir(__DIR__ . '/controllers/');
}
}
protected function newApp(string $name, string $version = '')
{
$app = new Application($name, $version ?: '0.0.1', fn () => false);
return $app->io(new Interactor(static::$in, static::$ou));
}
public function testConfigAppRootNotSet()
{
$app = $this->newApp('test', '0.0.1');
$app->add(new ControllerCommand([]));
$app->handle(['runway', 'make:controller', 'Test']);
$this->assertStringContainsString('app_root not set in .runway-config.json', file_get_contents(static::$ou));
}
public function testControllerAlreadyExists()
{
$app = $this->newApp('test', '0.0.1');
mkdir(__DIR__ . '/controllers/');
file_put_contents(__DIR__ . '/controllers/TestController.php', '<?php class TestController {}');
$app->add(new ControllerCommand(['app_root' => 'tests/commands/']));
$app->handle(['runway', 'make:controller', 'Test']);
$this->assertStringContainsString('TestController already exists.', file_get_contents(static::$ou));
}
public function testCreateController()
{
$app = $this->newApp('test', '0.0.1');
$app->add(new ControllerCommand(['app_root' => 'tests/commands/']));
$app->handle(['runway', 'make:controller', 'Test']);
$this->assertFileExists(__DIR__ . '/controllers/TestController.php');
}
}

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace tests\commands;
use Ahc\Cli\Application;
use Ahc\Cli\IO\Interactor;
use Flight;
use flight\commands\RouteCommand;
use flight\Engine;
use PHPUnit\Framework\TestCase;
class RouteCommandTest extends TestCase
{
protected static $in = __DIR__ . '/input.test';
protected static $ou = __DIR__ . '/output.test';
public function setUp(): void
{
file_put_contents(static::$in, '', LOCK_EX);
file_put_contents(static::$ou, '', LOCK_EX);
$_SERVER = [];
$_REQUEST = [];
Flight::init();
Flight::setEngine(new Engine());
}
public function tearDown(): void
{
// Make sure we clean up after ourselves:
if (file_exists(static::$in)) {
unlink(static::$in);
}
if (file_exists(static::$ou)) {
unlink(static::$ou);
}
if (file_exists(__DIR__ . '/index.php')) {
unlink(__DIR__ . '/index.php');
}
unset($_REQUEST);
unset($_SERVER);
Flight::clear();
}
protected function newApp(string $name, string $version = '')
{
$app = new Application($name, $version ?: '0.0.1', fn () => false);
return $app->io(new Interactor(static::$in, static::$ou));
}
protected function createIndexFile()
{
$index = <<<PHP
<?php
require __DIR__ . '/../../vendor/autoload.php';
Flight::route('GET /', function () {});
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::start();
PHP;
file_put_contents(__DIR__ . '/index.php', $index);
}
protected function removeColors(string $str): string
{
return preg_replace('/\e\[[\d;]*m/', '', $str);
}
public function testConfigIndexRootNotSet()
{
$app = $this->newApp('test', '0.0.1');
$app->add(new RouteCommand([]));
$app->handle(['runway', 'routes']);
$this->assertStringContainsString('index_root not set in .runway-config.json', file_get_contents(static::$ou));
}
public function testGetRoutes()
{
$app = $this->newApp('test', '0.0.1');
$this->createIndexFile();
$app->add(new RouteCommand(['index_root' => 'tests/commands/index.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)));
}
public function testGetPostRoute()
{
$app = $this->newApp('test', '0.0.1');
$this->createIndexFile();
$app->add(new RouteCommand(['index_root' => 'tests/commands/index.php']));
$app->handle(['runway', 'routes', '--post']);
$this->assertStringContainsString('Routes', file_get_contents(static::$ou));
$this->assertStringContainsString('+---------+---------+-------+----------+------------+
| Pattern | Methods | Alias | Streamed | Middleware |
+---------+---------+-------+----------+------------+
| /post | POST | | No | Closure |
+---------+---------+-------+----------+------------+', $this->removeColors(file_get_contents(static::$ou)));
}
}

@ -1,9 +1,26 @@
#!/bin/bash
# Run all tests
composer lint
composer beautify
composer phpcs
composer test-coverage
xdg-open http://localhost:8000
composer test-server
php_versions=("php7.4" "php8.0" "php8.1" "php8.2" "php8.3")
count=${#php_versions[@]}
echo "Prettifying code first"
vendor/bin/phpcbf --standard=phpcs.xml
set -e
for ((i = 0; i < count; i++)); do
if type "${php_versions[$i]}" &> /dev/null; then
echo "Running tests for ${php_versions[$i]}"
echo " ${php_versions[$i]} vendor/bin/phpunit"
${php_versions[$i]} vendor/bin/phpunit
echo "Running PHPStan"
echo " ${php_versions[$i]} vendor/bin/phpstan"
${php_versions[$i]} vendor/bin/phpstan
echo "Running PHPCS"
echo " ${php_versions[$i]} vendor/bin/phpcs --standard=phpcs.xml -n"
${php_versions[$i]} vendor/bin/phpcs --standard=phpcs.xml -n
fi
done

@ -116,6 +116,10 @@ Flight::route('/jsonp', function () {
echo "\n\n\n\n\n";
});
Flight::route('/json-halt', function () {
Flight::jsonHalt(['message' => 'JSON rendered and halted successfully with no other body content!']);
});
// Test 10: Halt
Flight::route('/halt', function () {
Flight::halt(400, 'Halt worked successfully');
@ -200,6 +204,7 @@ echo '
<li><a href="/error">Error</a></li>
<li><a href="/json">JSON</a></li>
<li><a href="/jsonp?jsonp=myjson">JSONP</a></li>
<li><a href="/json-halt">JSON Halt</a></li>
<li><a href="/halt">Halt</a></li>
<li><a href="/redirect">Redirect</a></li>
</ul>';

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

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

@ -2,6 +2,7 @@
declare(strict_types=1);
use flight\core\Loader;
use flight\database\PdoWrapper;
use tests\classes\Container;
use tests\classes\ContainerDefault;
@ -18,10 +19,8 @@ use tests\classes\ContainerDefault;
Flight::set('flight.content_length', false);
Flight::set('flight.views.path', './');
Flight::set('flight.views.extension', '.phtml');
//Flight::set('flight.v2.output_buffering', true);
require_once 'LayoutMiddleware.php';
require_once 'OverwriteBodyMiddleware.php';
Loader::setV2ClassLoading(false);
Flight::path(__DIR__);
Flight::group('', function () {
@ -123,7 +122,19 @@ Flight::group('', function () {
ob_flush();
}
echo "is successful!!";
})->stream();
// Test 12: Redirect with status code
Flight::route('/streamWithHeaders', function () {
echo "Streaming a response";
for ($i = 1; $i <= 50; $i++) {
echo ".";
usleep(50000);
ob_flush();
}
echo "is successful!!";
})->streamWithHeaders(['Content-Type' => 'text/html', 'status' => 200 ]);
// Test 14: Overwrite the body with a middleware
Flight::route('/overwrite', function () {
echo '<span id="infotext">Route text:</span> This route status is that it <span style="color:red; font-weight: bold;">failed</span>';
@ -147,6 +158,7 @@ Flight::group('', function () {
Flight::set('test_me_out', 'You got it boss!'); // used in /no-container route
Flight::route('/no-container', ContainerDefault::class . '->testUi');
Flight::route('/dice', Container::class . '->testThePdoWrapper');
Flight::route('/Pascal_Snake_Case', Pascal_Snake_Case::class . '->doILoad');
}, [ new LayoutMiddleware() ]);
// Test 9: JSON output (should not output any other html)
@ -159,11 +171,17 @@ Flight::route('/jsonp', function () {
Flight::jsonp(['message' => 'JSONP renders successfully!'], 'jsonp');
});
Flight::route('/json-halt', function () {
Flight::jsonHalt(['message' => 'JSON rendered and halted successfully with no other body content!']);
});
Flight::map('error', function (Throwable $e) {
echo sprintf(
'<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>',
<<<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>
HTML,
$e->getMessage(),
$e->getCode(),
str_replace(getenv('PWD'), '***CONFIDENTIAL***', $e->getTraceAsString())

@ -0,0 +1,7 @@
<?php
$type ??= 'text';
?>
<input type="<?= $type ?>" />

@ -0,0 +1 @@
<div><?= $prop ?></div>
Loading…
Cancel
Save