From c929626fc6de968902aeada12534e398669fc045 Mon Sep 17 00:00:00 2001 From: Sascha Nos Date: Tue, 5 Sep 2023 12:17:27 +0200 Subject: [PATCH 001/331] fix wrong type return --- flight/Engine.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flight/Engine.php b/flight/Engine.php index f8c996bf..bd6921da 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -427,7 +427,8 @@ public function _stop(?int $code = null): void $response->status($code); } - $response->write(ob_get_clean()); + $content = ob_get_clean(); + $response->write($content ?: ''); $response->send(); } From cce78f18a3e70399852909bc735a63fcc7cf56d7 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Mon, 4 Dec 2023 16:59:19 -0400 Subject: [PATCH 002/331] PHPStan installation --- README.md | 3 +++ composer.json | 26 +++++++++++++++++++++++--- phpstan.neon | 10 ++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 phpstan.neon diff --git a/README.md b/README.md index 61083e9b..e078d003 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # What is Flight? +![](https://user-images.githubusercontent.com/104888/50957476-9c4acb80-14be-11e9-88ce-6447364dc1bb.png) +![](https://img.shields.io/badge/PHPStan-level%206-brightgreen.svg?style=flat) + Flight is a fast, simple, extensible framework for PHP. Flight enables you to quickly and easily build RESTful web applications. diff --git a/composer.json b/composer.json index 8e2611b1..325ba7f2 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "mikecao/flight", + "name": "faslatam/flight", "description": "Flight is a fast, simple, extensible framework for PHP. Flight enables you to quickly and easily build RESTful web applications.", "homepage": "http://flightphp.com", "license": "MIT", @@ -9,16 +9,36 @@ "email": "mike@mikecao.com", "homepage": "http://www.mikecao.com/", "role": "Original Developer" + }, + { + "name": "Franyer Sánchez", + "email": "franyeradriansanchez@gmail.com", + "homepage": "https://faslatam.000webhostapp.com", + "role": "Maintainer" } ], "require": { - "php": "^7.4|^8.0|^8.1", + "php": "^7.4|^8.0|^8.1|^8.2", "ext-json": "*" }, "autoload": { "files": [ "flight/autoload.php", "flight/Flight.php" ] }, "require-dev": { - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^9.5", + "phpstan/phpstan": "^1.10", + "phpstan/extension-installer": "^1.3" + }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true + } + }, + "scripts": { + "test": "phpunit tests", + "lint": "phpstan --no-progress -cphpstan.neon" + }, + "suggest": { + "phpstan/phpstan": "PHP Static Analysis Tool" } } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..56c06c39 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,10 @@ +includes: + - vendor/phpstan/phpstan/conf/bleedingEdge.neon + +parameters: + level: 6 + excludePaths: + - vendor + paths: + - flight + - index.php From fbcc9108c202970ae2484dffdf354b39a07fdf1d Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Mon, 4 Dec 2023 16:59:49 -0400 Subject: [PATCH 003/331] DocBlocks improved --- flight/Engine.php | 39 ++++++++++++--------- flight/Flight.php | 40 +++++++++++---------- flight/core/Dispatcher.php | 26 +++++++------- flight/core/Loader.php | 26 ++++++++------ flight/net/Request.php | 48 ++++++++++++++------------ flight/net/Response.php | 15 ++++---- flight/net/Route.php | 8 ++--- flight/net/Router.php | 7 ++-- flight/template/View.php | 18 +++++++--- flight/util/Collection.php | 32 ++++++++--------- flight/util/LegacyJsonSerializable.php | 4 +++ 11 files changed, 146 insertions(+), 117 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index f8c996bf..34060813 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -40,7 +40,7 @@ * Request-response * @method Request request() Gets current request * @method Response response() Gets current response - * @method void error(Exception $e) Sends an HTTP 500 response for any errors. + * @method void error(Throwable $e) Sends an HTTP 500 response for any errors. * @method void notFound() Sends an HTTP 404 response when a URL is not found. * @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. @@ -54,6 +54,7 @@ class Engine { /** * Stored variables. + * @var array */ protected array $vars; @@ -84,7 +85,7 @@ public function __construct() * Handles calls to class methods. * * @param string $name Method name - * @param array $params Method parameters + * @param array $params Method parameters * * @throws Exception * @@ -155,8 +156,8 @@ public function init(): void $this->before('start', function () use ($self) { // Enable error handling if ($self->get('flight.handle_errors')) { - set_error_handler([$self, 'handleError']); - set_exception_handler([$self, 'handleException']); + set_error_handler(array($self, 'handleError')); + set_exception_handler(array($self, 'handleException')); } // Set case-sensitivity @@ -177,18 +178,21 @@ public function init(): void * @param int $errline Error file line number * * @throws ErrorException + * @return bool */ public function handleError(int $errno, string $errstr, string $errfile, int $errline) { if ($errno & error_reporting()) { throw new ErrorException($errstr, $errno, 0, $errfile, $errline); } + + return false; } /** * Custom exception handler. Logs exceptions. * - * @param Exception $e Thrown exception + * @param Throwable $e Thrown exception */ public function handleException($e): void { @@ -203,7 +207,7 @@ public function handleException($e): void * Maps a callback to a framework method. * * @param string $name Method name - * @param callback $callback Callback function + * @param callable $callback Callback function * * @throws Exception If trying to map over a framework method */ @@ -218,11 +222,12 @@ public function map(string $name, callable $callback): void /** * Registers a class to a framework method. + * @template T of object * * @param string $name Method name - * @param string $class Class name - * @param array $params Class initialization parameters - * @param callable|null $callback $callback Function to call after object instantiation + * @param class-string $class Class name + * @param array $params Class initialization parameters + * @param ?callable(T $instance): void $callback Function to call after object instantiation * * @throws Exception If trying to map over a framework method */ @@ -239,7 +244,7 @@ public function register(string $name, string $class, array $params = [], ?calla * Adds a pre-filter to a method. * * @param string $name Method name - * @param callback $callback Callback function + * @param callable $callback Callback function */ public function before(string $name, callable $callback): void { @@ -250,7 +255,7 @@ public function before(string $name, callable $callback): void * Adds a post-filter to a method. * * @param string $name Method name - * @param callback $callback Callback function + * @param callable $callback Callback function */ public function after(string $name, callable $callback): void { @@ -437,7 +442,7 @@ public function _stop(?int $code = null): void * Routes a URL to a callback function. * * @param string $pattern URL pattern to match - * @param callback $callback Callback function + * @param callable $callback Callback function * @param bool $pass_route Pass the matching route object to the callback */ public function _route(string $pattern, callable $callback, bool $pass_route = false): void @@ -449,7 +454,7 @@ public function _route(string $pattern, callable $callback, bool $pass_route = f * Routes a URL to a callback function. * * @param string $pattern URL pattern to match - * @param callback $callback Callback function + * @param callable $callback Callback function * @param bool $pass_route Pass the matching route object to the callback */ public function _post(string $pattern, callable $callback, bool $pass_route = false): void @@ -461,7 +466,7 @@ public function _post(string $pattern, callable $callback, bool $pass_route = fa * Routes a URL to a callback function. * * @param string $pattern URL pattern to match - * @param callback $callback Callback function + * @param callable $callback Callback function * @param bool $pass_route Pass the matching route object to the callback */ public function _put(string $pattern, callable $callback, bool $pass_route = false): void @@ -473,7 +478,7 @@ public function _put(string $pattern, callable $callback, bool $pass_route = fal * Routes a URL to a callback function. * * @param string $pattern URL pattern to match - * @param callback $callback Callback function + * @param callable $callback Callback function * @param bool $pass_route Pass the matching route object to the callback */ public function _patch(string $pattern, callable $callback, bool $pass_route = false): void @@ -485,7 +490,7 @@ public function _patch(string $pattern, callable $callback, bool $pass_route = f * Routes a URL to a callback function. * * @param string $pattern URL pattern to match - * @param callback $callback Callback function + * @param callable $callback Callback function * @param bool $pass_route Pass the matching route object to the callback */ public function _delete(string $pattern, callable $callback, bool $pass_route = false): void @@ -555,7 +560,7 @@ public function _redirect(string $url, int $code = 303): void * Renders a template. * * @param string $file Template file - * @param array|null $data Template data + * @param ?array $data Template data * @param string|null $key View variable name * * @throws Exception diff --git a/flight/Flight.php b/flight/Flight.php index 101c455c..2bca787d 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -18,36 +18,27 @@ /** * The Flight class is a static representation of the framework. * - * Core. - * * @method static void start() Starts the framework. - * @method static void path($path) Adds a path for autoloading classes. + * @method static void path(string $path) Adds a path for autoloading classes. * @method static void stop() Stops the framework and sends a response. - * @method static void halt($code = 200, $message = '') Stop the framework with an optional status code and message. + * @method static void halt(int $code = 200, string $message = '') Stop the framework with an optional status code and message. * - * Routing. - * @method static void route($pattern, $callback) Maps a URL pattern to a callback. + * @method static void route(string $pattern, callable $callback) Maps a URL pattern to a callback. * @method static Router router() Returns Router instance. * - * Extending & Overriding. - * @method static void map($name, $callback) Creates a custom framework method. - * @method static void register($name, $class, array $params = array(), $callback = null) Registers a class to a framework method. + * @method static void map(string $name, callable $callback) Creates a custom framework method. * - * Filtering. * @method static void before($name, $callback) Adds a filter before a framework method. * @method static void after($name, $callback) Adds a filter after a framework method. * - * Variables. * @method static void set($key, $value) Sets a variable. * @method static mixed get($key) Gets a variable. * @method static bool has($key) Checks if a variable is set. * @method static void clear($key = null) Clears a variable. * - * Views. * @method static void render($file, array $data = null, $key = null) Renders a template file. * @method static View view() Returns View instance. * - * Request & Response. * @method static Request request() Returns Request instance. * @method static Response response() Returns Response instance. * @method static void redirect($url, $code = 303) Redirects to another URL. @@ -56,7 +47,6 @@ * @method static void error($exception) Sends an HTTP 500 response. * @method static void notFound() Sends an HTTP 404 response. * - * HTTP Caching. * @method static void etag($id, $type = 'strong') Performs ETag HTTP caching. * @method static void lastModified($time) Performs last modified HTTP caching. */ @@ -72,19 +62,33 @@ private function __construct() { } - private function __destruct() + private function __clone() { } - private function __clone() - { + /** + * Registers a class to a framework method. + * @template T of object + * @param string $name Static method name + * ``` + * Flight::register('user', User::class); + * + * Flight::user(); # <- Return a User instance + * ``` + * @param class-string $class Fully Qualified Class Name + * @param array $params Class constructor params + * @param ?Closure(T $instance): void $callback Perform actions with the instance + * @return void + */ + static function register($name, $class, $params = array(), $callback = null) { + static::__callStatic('register', func_get_args()); } /** * Handles calls to static methods. * * @param string $name Method name - * @param array $params Method parameters + * @param array $params Method parameters * * @throws Exception * diff --git a/flight/core/Dispatcher.php b/flight/core/Dispatcher.php index 50b73f00..0f810bdb 100644 --- a/flight/core/Dispatcher.php +++ b/flight/core/Dispatcher.php @@ -23,11 +23,13 @@ class Dispatcher { /** * Mapped events. + * @var array */ protected array $events = []; /** * Method filters. + * @var array>> */ protected array $filters = []; @@ -35,9 +37,9 @@ class Dispatcher * Dispatches an event. * * @param string $name Event name - * @param array $params Callback parameters + * @param array $params Callback parameters * - *@throws Exception + * @throws Exception * * @return mixed|null Output of callback */ @@ -65,7 +67,7 @@ final public function run(string $name, array $params = []) * Assigns a callback to an event. * * @param string $name Event name - * @param callback $callback Callback function + * @param callable $callback Callback function */ final public function set(string $name, callable $callback): void { @@ -77,7 +79,7 @@ final public function set(string $name, callable $callback): void * * @param string $name Event name * - * @return callback $callback Callback function + * @return callable $callback Callback function */ final public function get(string $name): ?callable { @@ -118,7 +120,7 @@ final public function clear(?string $name = null): void * * @param string $name Event name * @param string $type Filter type - * @param callback $callback Callback function + * @param callable $callback Callback function */ final public function hook(string $name, string $type, callable $callback): void { @@ -128,8 +130,8 @@ final public function hook(string $name, string $type, callable $callback): void /** * Executes a chain of method filters. * - * @param array $filters Chain of filters - * @param array $params Method parameters + * @param array $filters Chain of filters + * @param array $params Method parameters * @param mixed $output Method output * * @throws Exception @@ -148,10 +150,10 @@ final public function filter(array $filters, array &$params, &$output): void /** * Executes a callback function. * - * @param array|callback $callback Callback function - * @param array $params Function parameters + * @param callable|array $callback Callback function + * @param array $params Function parameters * - *@throws Exception + * @throws Exception * * @return mixed Function results */ @@ -170,7 +172,7 @@ public static function execute($callback, array &$params = []) * Calls a function. * * @param callable|string $func Name of function to call - * @param array $params Function parameters + * @param array $params Function parameters * * @return mixed Function results */ @@ -203,7 +205,7 @@ public static function callFunction($func, array &$params = []) * Invokes a method. * * @param mixed $func Class method - * @param array $params Class method parameters + * @param array $params Class method parameters * * @return mixed Function results */ diff --git a/flight/core/Loader.php b/flight/core/Loader.php index 95874a6d..5ff66fe3 100644 --- a/flight/core/Loader.php +++ b/flight/core/Loader.php @@ -10,6 +10,7 @@ namespace flight\core; +use Closure; use Exception; use ReflectionClass; use ReflectionException; @@ -24,26 +25,30 @@ class Loader { /** * Registered classes. + * @var array, ?callable}> $classes */ protected array $classes = []; /** * Class instances. + * @var array */ protected array $instances = []; /** * Autoload directories. + * @var array */ protected static array $dirs = []; /** * Registers a class. + * @template T of object * * @param string $name Registry name - * @param callable|string $class Class name or function to instantiate class - * @param array $params Class initialization parameters - * @param callable|null $callback $callback Function to call after object instantiation + * @param class-string $class Class name or function to instantiate class + * @param array $params Class initialization parameters + * @param ?callable(T $instance): void $callback $callback Function to call after object instantiation */ public function register(string $name, $class, array $params = [], ?callable $callback = null): void { @@ -77,7 +82,7 @@ public function load(string $name, bool $shared = true): ?object $obj = null; if (isset($this->classes[$name])) { - [$class, $params, $callback] = $this->classes[$name]; + [0 => $class, 1 => $params, 2 => $callback] = $this->classes[$name]; $exists = isset($this->instances[$name]); @@ -116,15 +121,16 @@ public function getInstance(string $name): ?object /** * Gets a new instance of a class. + * @template T of object * - * @param callable|string $class Class name or callback function to instantiate class - * @param array $params Class initialization parameters + * @param class-string|Closure(): class-string $class Class name or callback function to instantiate class + * @param array $params Class initialization parameters * * @throws Exception * - * @return object Class instance + * @return T Class instance */ - public function newInstance($class, array $params = []): object + public function newInstance($class, array $params = []) { if (\is_callable($class)) { return \call_user_func_array($class, $params); @@ -179,7 +185,7 @@ public function reset(): void * Starts/stops autoloader. * * @param bool $enabled Enable/disable autoloading - * @param mixed $dirs Autoload directories + * @param string|iterable $dirs Autoload directories */ public static function autoload(bool $enabled = true, $dirs = []): void { @@ -216,7 +222,7 @@ public static function loadClass(string $class): void /** * Adds a directory for autoloading classes. * - * @param mixed $dir Directory path + * @param string|iterable $dir Directory path */ public static function addDirectory($dir): void { diff --git a/flight/net/Request.php b/flight/net/Request.php index 75d4665f..8f8ecf8f 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -18,23 +18,24 @@ * are stored and accessible via the Request object. * * The default request properties are: - * url - The URL being requested - * base - The parent subdirectory of the URL - * method - The request method (GET, POST, PUT, DELETE) - * referrer - The referrer URL - * ip - IP address of the client - * ajax - Whether the request is an AJAX request - * scheme - The server protocol (http, https) - * user_agent - Browser information - * type - The content type - * length - The content length - * query - Query string parameters - * data - Post parameters - * cookies - Cookie parameters - * files - Uploaded files - * secure - Connection is secure - * accept - HTTP accept parameters - * proxy_ip - Proxy IP address of the client + * + * - **url** - The URL being requested + * - **base** - The parent subdirectory of the URL + * - **method** - The request method (GET, POST, PUT, DELETE) + * - **referrer** - The referrer URL + * - **ip** - IP address of the client + * - **ajax** - Whether the request is an AJAX request + * - **scheme** - The server protocol (http, https) + * - **user_agent** - Browser information + * - **type** - The content type + * - **length** - The content length + * - **query** - Query string parameters + * - **data** - Post parameters + * - **cookies** - Cookie parameters + * - **files** - Uploaded files + * - **secure** - Connection is secure + * - **accept** - HTTP accept parameters + * - **proxy_ip** - Proxy IP address of the client */ final class Request { @@ -131,9 +132,9 @@ final class Request /** * Constructor. * - * @param array $config Request configuration + * @param array $config Request configuration */ - public function __construct(array $config = []) + public function __construct($config = array()) { // Default properties if (empty($config)) { @@ -165,7 +166,8 @@ public function __construct(array $config = []) /** * Initialize request properties. * - * @param array $properties Array of request properties + * @param array $properties Array of request properties + * @return static */ public function init(array $properties = []) { @@ -199,6 +201,8 @@ public function init(array $properties = []) } } } + + return $this; } /** @@ -287,11 +291,11 @@ public static function getVar(string $var, $default = '') * * @param string $url URL string * - * @return array Query parameters + * @return array> */ public static function parseQuery(string $url): array { - $params = []; + $params = array(); $args = parse_url($url); if (isset($args['query'])) { diff --git a/flight/net/Response.php b/flight/net/Response.php index e21fc9a2..5873ec7c 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -25,7 +25,7 @@ class Response public bool $content_length = true; /** - * @var array HTTP status codes + * @var array HTTP status codes */ public static array $codes = [ 100 => 'Continue', @@ -103,7 +103,7 @@ class Response protected int $status = 200; /** - * @var array HTTP headers + * @var array> HTTP headers */ protected array $headers = []; @@ -124,7 +124,7 @@ class Response * * @throws Exception If invalid status code * - * @return int|object Self reference + * @return int|static Self reference */ public function status(?int $code = null) { @@ -144,10 +144,10 @@ public function status(?int $code = null) /** * Adds a header to the response. * - * @param array|string $name Header name or array of names and values + * @param array|string $name Header name or array of names and values * @param string|null $value Header value * - * @return object Self reference + * @return static Self reference */ public function header($name, ?string $value = null) { @@ -164,8 +164,7 @@ public function header($name, ?string $value = null) /** * Returns the headers from the response. - * - * @return array + * @return array> */ public function headers() { @@ -203,7 +202,7 @@ public function clear(): self /** * Sets caching headers for the response. * - * @param int|string $expires Expiration time + * @param int|string|false $expires Expiration time * * @return Response Self reference */ diff --git a/flight/net/Route.php b/flight/net/Route.php index 91a6aff9..fbfc20b2 100644 --- a/flight/net/Route.php +++ b/flight/net/Route.php @@ -28,12 +28,12 @@ final class Route public $callback; /** - * @var array HTTP methods + * @var array HTTP methods */ public array $methods = []; /** - * @var array Route parameters + * @var array Route parameters */ public array $params = []; @@ -56,8 +56,8 @@ final class Route * Constructor. * * @param string $pattern URL pattern - * @param mixed $callback Callback function - * @param array $methods HTTP methods + * @param callable $callback Callback function + * @param array $methods HTTP methods * @param bool $pass Pass self in callback parameters */ public function __construct(string $pattern, $callback, array $methods, bool $pass) diff --git a/flight/net/Router.php b/flight/net/Router.php index dcf15918..f1f8842d 100644 --- a/flight/net/Router.php +++ b/flight/net/Router.php @@ -23,6 +23,7 @@ class Router public bool $case_sensitive = false; /** * Mapped routes. + * @var array */ protected array $routes = []; @@ -34,7 +35,7 @@ class Router /** * Gets mapped routes. * - * @return array Array of routes + * @return array Array of routes */ public function getRoutes(): array { @@ -53,7 +54,7 @@ public function clear(): void * Maps a URL pattern to a callback function. * * @param string $pattern URL pattern to match - * @param callback $callback Callback function + * @param callable $callback Callback function * @param bool $pass_route Pass the matching route object to the callback */ public function map(string $pattern, callable $callback, bool $pass_route = false): void @@ -81,7 +82,7 @@ public function route(Request $request) { $url_decoded = urldecode($request->url); while ($route = $this->current()) { - if (false !== $route && $route->matchMethod($request->method) && $route->matchUrl($url_decoded, $this->case_sensitive)) { + if ($route->matchMethod($request->method) && $route->matchUrl($url_decoded, $this->case_sensitive)) { return $route; } $this->next(); diff --git a/flight/template/View.php b/flight/template/View.php index 90dbb819..f4d866cc 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -34,7 +34,7 @@ class View /** * View variables. * - * @var array + * @var array */ protected $vars = []; @@ -70,8 +70,9 @@ public function get($key) /** * Sets a template variable. * - * @param mixed $key Key - * @param string $value Value + * @param string|iterable $key Key + * @param mixed $value Value + * @return static */ public function set($key, $value = null) { @@ -82,6 +83,8 @@ public function set($key, $value = null) } else { $this->vars[$key] = $value; } + + return $this; } /** @@ -100,6 +103,7 @@ public function has($key) * Unsets a template variable. If no key is passed in, clear all variables. * * @param string $key Key + * @return static */ public function clear($key = null) { @@ -108,15 +112,18 @@ public function clear($key = null) } else { unset($this->vars[$key]); } + + return $this; } /** * Renders a template. * * @param string $file Template file - * @param array $data Template data + * @param array $data Template data * * @throws \Exception If template not found + * @return void */ public function render($file, $data = null) { @@ -139,7 +146,7 @@ public function render($file, $data = null) * Gets the output of a template. * * @param string $file Template file - * @param array $data Template data + * @param array $data Template data * * @return string Output of template */ @@ -196,5 +203,6 @@ public function getTemplate($file) public function e($str) { echo htmlentities($str); + return htmlentities($str); } } diff --git a/flight/util/Collection.php b/flight/util/Collection.php index 97658ec6..5998b252 100644 --- a/flight/util/Collection.php +++ b/flight/util/Collection.php @@ -11,7 +11,6 @@ namespace flight\util; use ArrayAccess; -use function count; use Countable; use Iterator; use JsonSerializable; @@ -23,18 +22,21 @@ /** * The Collection class allows you to access a set of data * using both array and object notation. + * @implements ArrayAccess + * @implements Iterator */ final class Collection implements ArrayAccess, Iterator, Countable, JsonSerializable { /** * Collection data. + * @var array */ private array $data; /** * Constructor. * - * @param array $data Initial data + * @param array $data Initial data */ public function __construct(array $data = []) { @@ -102,11 +104,11 @@ public function offsetGet($offset) /** * Sets an item at the offset. * - * @param string $offset Offset + * @param ?string $offset Offset * @param mixed $value Value */ #[\ReturnTypeWillChange] - public function offsetSet($offset, $value) + public function offsetSet($offset, $value): void { if (null === $offset) { $this->data[] = $value; @@ -169,13 +171,11 @@ public function key() /** * Gets the next collection value. - * - * @return mixed Value */ #[\ReturnTypeWillChange] - public function next() + public function next(): void { - return next($this->data); + next($this->data); } /** @@ -187,7 +187,7 @@ public function valid(): bool { $key = key($this->data); - return null !== $key && false !== $key; + return null !== $key; } /** @@ -203,7 +203,7 @@ public function count(): int /** * Gets the item keys. * - * @return array Collection keys + * @return array Collection keys */ public function keys(): array { @@ -213,7 +213,7 @@ public function keys(): array /** * Gets the collection data. * - * @return array Collection data + * @return array Collection data */ public function getData(): array { @@ -223,19 +223,15 @@ public function getData(): array /** * Sets the collection data. * - * @param array $data New collection data + * @param array $data New collection data */ public function setData(array $data): void { $this->data = $data; } - /** - * Gets the collection data which can be serialized to JSON. - * - * @return array Collection data which can be serialized by json_encode - */ - public function jsonSerialize(): array + #[\ReturnTypeWillChange] + public function jsonSerialize() { return $this->data; } diff --git a/flight/util/LegacyJsonSerializable.php b/flight/util/LegacyJsonSerializable.php index 0c973aa2..39e1721a 100644 --- a/flight/util/LegacyJsonSerializable.php +++ b/flight/util/LegacyJsonSerializable.php @@ -9,5 +9,9 @@ */ interface LegacyJsonSerializable { + /** + * Gets the collection data which can be serialized to JSON. + * @return mixed Collection data which can be serialized by json_encode + */ public function jsonSerialize(); } From ba0c6bda3310436139b9fd6fd5137b0c6489c2ca Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Mon, 4 Dec 2023 17:28:50 -0400 Subject: [PATCH 004/331] Improved documentation --- README.md | 366 +++++++++++++++++++++++++++--------------------------- 1 file changed, 186 insertions(+), 180 deletions(-) diff --git a/README.md b/README.md index e078d003..469c5f70 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,14 @@ ![](https://user-images.githubusercontent.com/104888/50957476-9c4acb80-14be-11e9-88ce-6447364dc1bb.png) ![](https://img.shields.io/badge/PHPStan-level%206-brightgreen.svg?style=flat) -Flight is a fast, simple, extensible framework for PHP. Flight enables you to +Flight is a fast, simple, extensible framework for PHP. Flight enables you to quickly and easily build RESTful web applications. ```php require 'flight/Flight.php'; -Flight::route('/', function(){ - echo 'hello world!'; +Flight::route('/', function() { + echo 'hello world!'; }); Flight::start(); @@ -30,14 +30,15 @@ Flight is released under the [MIT](http://flightphp.com/license) license. 1\. Download the files. -If you're using [Composer](https://getcomposer.org/), you can run the following command: +If you're using [Composer](https://getcomposer.org/), you can run the following +command: -``` +```bash composer require mikecao/flight ``` -OR you can [download](https://github.com/mikecao/flight/archive/master.zip) them directly -and extract them to your web directory. +OR you can [download](https://github.com/mikecao/flight/archive/master.zip) +them directly and extract them to your web directory. 2\. Configure your webserver. @@ -50,15 +51,16 @@ RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^(.*)$ index.php [QSA,L] ``` -**Note**: If you need to use flight in a subdirectory add the line `RewriteBase /subdir/` just after `RewriteEngine On`. +**Note**: If you need to use flight in a subdirectory add the line +`RewriteBase /subdir/` just after `RewriteEngine On`. For *Nginx*, add the following to your server declaration: ``` server { - location / { - try_files $uri $uri/ /index.php; - } + location / { + try_files $uri $uri/ /index.php; + } } ``` 3\. Create your `index.php` file. @@ -78,8 +80,8 @@ require 'vendor/autoload.php'; Then define a route and assign a function to handle the request. ```php -Flight::route('/', function(){ - echo 'hello world!'; +Flight::route('/', function () { + echo 'hello world!'; }); ``` @@ -94,16 +96,16 @@ Flight::start(); Routing in Flight is done by matching a URL pattern with a callback function. ```php -Flight::route('/', function(){ - echo 'hello world!'; +Flight::route('/', function () { + echo 'hello world!'; }); ``` The callback can be any object that is callable. So you can use a regular function: ```php -function hello(){ - echo 'hello world!'; +function hello() { + echo 'hello world!'; } Flight::route('/', 'hello'); @@ -113,9 +115,9 @@ Or a class method: ```php class Greeting { - public static function hello() { - echo 'hello world!'; - } + public static function hello() { + echo 'hello world!'; + } } Flight::route('/', array('Greeting', 'hello')); @@ -126,18 +128,18 @@ Or an object method: ```php class Greeting { - public function __construct() { - $this->name = 'John Doe'; - } + public function __construct() { + $this->name = 'John Doe'; + } - public function hello() { - echo "Hello, {$this->name}!"; - } + public function hello() { + echo "Hello, {$this->name}!"; + } } $greeting = new Greeting(); -Flight::route('/', array($greeting, 'hello')); +Flight::route('/', array($greeting, 'hello')); ``` Routes are matched in the order they are defined. The first route to match a @@ -149,20 +151,20 @@ By default, route patterns are matched against all request methods. You can resp to specific methods by placing an identifier before the URL. ```php -Flight::route('GET /', function(){ - echo 'I received a GET request.'; +Flight::route('GET /', function () { + echo 'I received a GET request.'; }); -Flight::route('POST /', function(){ - echo 'I received a POST request.'; +Flight::route('POST /', function () { + echo 'I received a POST request.'; }); ``` You can also map multiple methods to a single callback by using a `|` delimiter: ```php -Flight::route('GET|POST /', function(){ - echo 'I received either a GET or a POST request.'; +Flight::route('GET|POST /', function () { + echo 'I received either a GET or a POST request.'; }); ``` @@ -171,8 +173,8 @@ Flight::route('GET|POST /', function(){ You can use regular expressions in your routes: ```php -Flight::route('/user/[0-9]+', function(){ - // This will match /user/1234 +Flight::route('/user/[0-9]+', function () { + // This will match /user/1234 }); ``` @@ -182,8 +184,8 @@ You can specify named parameters in your routes which will be passed along to your callback function. ```php -Flight::route('/@name/@id', function($name, $id){ - echo "hello, $name ($id)!"; +Flight::route('/@name/@id', function(string $name, string $id) { + echo "hello, $name ($id)!"; }); ``` @@ -191,9 +193,9 @@ You can also include regular expressions with your named parameters by using the `:` delimiter: ```php -Flight::route('/@name/@id:[0-9]{3}', function($name, $id){ - // This will match /bob/123 - // But will not match /bob/12345 +Flight::route('/@name/@id:[0-9]{3}', function(string $name, string $id) { + // This will match /bob/123 + // But will not match /bob/12345 }); ``` @@ -205,13 +207,16 @@ You can specify named parameters that are optional for matching by wrapping segments in parentheses. ```php -Flight::route('/blog(/@year(/@month(/@day)))', function($year, $month, $day){ +Flight::route( + '/blog(/@year(/@month(/@day)))', + function(?string $year, ?string $month, ?string $day) { // This will match the following URLS: // /blog/2012/12/10 // /blog/2012/12 // /blog/2012 // /blog -}); + } +); ``` Any optional parameters that are not matched will be passed in as NULL. @@ -222,16 +227,16 @@ Matching is only done on individual URL segments. If you want to match multiple segments you can use the `*` wildcard. ```php -Flight::route('/blog/*', function(){ - // This will match /blog/2000/02/01 +Flight::route('/blog/*', function () { + // This will match /blog/2000/02/01 }); ``` To route all requests to a single callback, you can do: ```php -Flight::route('*', function(){ - // Do something +Flight::route('*', function () { + // Do something }); ``` @@ -241,16 +246,16 @@ You can pass execution on to the next matching route by returning `true` from your callback function. ```php -Flight::route('/user/@name', function($name){ - // Check some condition - if ($name != "Bob") { - // Continue to next route - return true; - } +Flight::route('/user/@name', function (string $name) { + // Check some condition + if ($name != "Bob") { + // Continue to next route + return true; + } }); -Flight::route('/user/*', function(){ - // This will get called +Flight::route('/user/*', function () { + // This will get called }); ``` @@ -262,18 +267,18 @@ the route method. The route object will always be the last parameter passed to y callback function. ```php -Flight::route('/', function($route){ - // Array of HTTP methods matched against - $route->methods; +Flight::route('/', function(\flight\net\Route $route) { + // Array of HTTP methods matched against + $route->methods; - // Array of named parameters - $route->params; + // Array of named parameters + $route->params; - // Matching regular expression - $route->regex; + // Matching regular expression + $route->regex; - // Contains the contents of any '*' used in the URL pattern - $route->splat; + // Contains the contents of any '*' used in the URL pattern + $route->splat; }, true); ``` @@ -289,8 +294,8 @@ To map your own custom method, you use the `map` function: ```php // Map your method -Flight::map('hello', function($name){ - echo "hello $name!"; +Flight::map('hello', function ($name) { + echo "hello $name!"; }); // Call your custom method @@ -321,7 +326,7 @@ Flight::register('db', 'PDO', array('mysql:host=localhost;dbname=test','user','p // Get an instance of your class // This will create an object with the defined parameters // -// new PDO('mysql:host=localhost;dbname=test','user','pass'); +// new PDO('mysql:host=localhost;dbname=test','user','pass'); // $db = Flight::db(); ``` @@ -332,8 +337,8 @@ new object. The callback function takes one parameter, an instance of the new ob ```php // The callback will be passed the object that was constructed -Flight::register('db', 'PDO', array('mysql:host=localhost;dbname=test','user','pass'), function($db){ - $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +Flight::register('db', 'PDO', array('mysql:host=localhost;dbname=test','user','pass'), function (PDO $db): void { + $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); }); ``` @@ -362,8 +367,8 @@ by using the `map` method: ```php Flight::map('notFound', function(){ - // Display custom 404 page - include 'errors/404.html'; + // Display custom 404 page + include 'errors/404.html'; }); ``` @@ -390,8 +395,8 @@ methods as well as any custom methods that you've mapped. A filter function looks like this: ```php -function(&$params, &$output) { - // Filter code +function (array &$params, string &$output) { + // Filter code } ``` @@ -400,16 +405,16 @@ Using the passed in variables you can manipulate the input parameters and/or the You can have a filter run before a method by doing: ```php -Flight::before('start', function(&$params, &$output){ - // Do something +Flight::before('start', function (array &$params, string &$output) { + // Do something }); ``` You can have a filter run after a method by doing: ```php -Flight::after('start', function(&$params, &$output){ - // Do something +Flight::after('start', function (array &$params, string &$output) { + // Do something }); ``` @@ -420,20 +425,20 @@ Here's an example of the filtering process: ```php // Map a custom method -Flight::map('hello', function($name){ - return "Hello, $name!"; +Flight::map('hello', function ($name) { + return "Hello, $name!"; }); // Add a before filter -Flight::before('hello', function(&$params, &$output){ - // Manipulate the parameter - $params[0] = 'Fred'; +Flight::before('hello', function (array &$params, string &$output) { + // Manipulate the parameter + $params[0] = 'Fred'; }); // Add an after filter -Flight::after('hello', function(&$params, &$output){ - // Manipulate the output - $output .= " Have a nice day!"; +Flight::after('hello', function (array &$params, string &$output) { + // Manipulate the output + $output .= " Have a nice day!"; }); // Invoke the custom method @@ -442,26 +447,28 @@ echo Flight::hello('Bob'); This should display: - Hello Fred! Have a nice day! +``` +Hello Fred! Have a nice day! +``` If you have defined multiple filters, you can break the chain by returning `false` in any of your filter functions: ```php -Flight::before('start', function(&$params, &$output){ - echo 'one'; +Flight::before('start', function (array &$params, string &$output){ + echo 'one'; }); -Flight::before('start', function(&$params, &$output){ - echo 'two'; +Flight::before('start', function (array &$params, string &$output): bool { + echo 'two'; - // This will end the chain - return false; + // This will end the chain + return false; }); // This will not get called -Flight::before('start', function(&$params, &$output){ - echo 'three'; +Flight::before('start', function (array &$params, string &$output){ + echo 'three'; }); ``` @@ -483,7 +490,7 @@ To see if a variable has been set you can do: ```php if (Flight::has('id')) { - // Do something + // Do something } ``` @@ -518,12 +525,14 @@ be reference like a local variable. Template files are simply PHP files. If the content of the `hello.php` template file is: ```php -Hello, ''! +Hello, ! ``` The output would be: - Hello, Bob! +``` +Hello, Bob! +``` You can also manually set view variables by using the set method: @@ -583,26 +592,26 @@ If the template files looks like this: ```php - -<?php echo $title; ?> - - - - - + + <?php echo $title; ?> + + + + + ``` The output would be: ```html - -Home Page - - -

Hello

-
World
- + + Home Page + + +

Hello

+
World
+ ``` @@ -618,11 +627,11 @@ require './Smarty/libs/Smarty.class.php'; // Register Smarty as the view class // Also pass a callback function to configure Smarty on load -Flight::register('view', 'Smarty', array(), function($smarty){ - $smarty->template_dir = './templates/'; - $smarty->compile_dir = './templates_c/'; - $smarty->config_dir = './config/'; - $smarty->cache_dir = './cache/'; +Flight::register('view', 'Smarty', array(), function (Smarty $smarty) { + $smarty->setTemplateDir() = './templates/'; + $smarty->setCompileDir() = './templates_c/'; + $smarty->setConfigDir() = './config/'; + $smarty->setCacheDir() = './cache/'; }); // Assign template data @@ -636,8 +645,8 @@ For completeness, you should also override Flight's default render method: ```php Flight::map('render', function($template, $data){ - Flight::view()->assign($data); - Flight::view()->display($template); + Flight::view()->assign($data); + Flight::view()->display($template); }); ``` # Error Handling @@ -651,9 +660,9 @@ response with some error information. You can override this behavior for your own needs: ```php -Flight::map('error', function(Exception $ex){ - // Handle error - echo $ex->getTraceAsString(); +Flight::map('error', function(Throwable $ex){ + // Handle error + echo $ex->getTraceAsString(); }); ``` @@ -672,8 +681,8 @@ behavior is to send an `HTTP 404 Not Found` response with a simple message. You can override this behavior for your own needs: ```php -Flight::map('notFound', function(){ - // Handle not found +Flight::map('notFound', function () { + // Handle not found }); ``` @@ -704,26 +713,24 @@ $request = Flight::request(); The request object provides the following properties: -``` -url - The URL being requested -base - The parent subdirectory of the URL -method - The request method (GET, POST, PUT, DELETE) -referrer - The referrer URL -ip - IP address of the client -ajax - Whether the request is an AJAX request -scheme - The server protocol (http, https) -user_agent - Browser information -type - The content type -length - The content length -query - Query string parameters -data - Post data or JSON data -cookies - Cookie data -files - Uploaded files -secure - Whether the connection is secure -accept - HTTP accept parameters -proxy_ip - Proxy IP address of the client -host - The request host name -``` +- **url** - The URL being requested +- **base** - The parent subdirectory of the URL +- **method** - The request method (GET, POST, PUT, DELETE) +- **referrer** - The referrer URL +- **ip** - IP address of the client +- **ajax** - Whether the request is an AJAX request +- **scheme** - The server protocol (http, https) +- **user_agent** - Browser information +- **type** - The content type +- **length** - The content length +- **query** - Query string parameters +- **data** - Post data or JSON data +- **cookies** - Cookie data +- **files** - Uploaded files +- **secure** - Whether the connection is secure +- **accept** - HTTP accept parameters +- **proxy_ip** - Proxy IP address of the client +- **host** - The request host name You can access the `query`, `data`, `cookies`, and `files` properties as arrays or objects. @@ -742,7 +749,8 @@ $id = Flight::request()->query->id; ## RAW Request Body -To get the raw HTTP request body, for example when dealing with PUT requests, you can do: +To get the raw HTTP request body, for example when dealing with PUT requests, +you can do: ```php $body = Flight::request()->getBody(); @@ -750,8 +758,8 @@ $body = Flight::request()->getBody(); ## JSON Input -If you send a request with the type `application/json` and the data `{"id": 123}` it will be available -from the `data` property: +If you send a request with the type `application/json` and the data `{"id": 123}` +it will be available from the `data` property: ```php $id = Flight::request()->data->id; @@ -771,9 +779,9 @@ and time a page was last modified. The client will continue to use their cache u the last modified value is changed. ```php -Flight::route('/news', function(){ - Flight::lastModified(1234567890); - echo 'This content will be cached.'; +Flight::route('/news', function () { + Flight::lastModified(1234567890); + echo 'This content will be cached.'; }); ``` @@ -783,9 +791,9 @@ Flight::route('/news', function(){ want for the resource: ```php -Flight::route('/news', function(){ - Flight::etag('my-unique-id'); - echo 'This content will be cached.'; +Flight::route('/news', function () { + Flight::etag('my-unique-id'); + echo 'This content will be cached.'; }); ``` @@ -850,12 +858,12 @@ Flight::set('flight.log_errors', true); The following is a list of all the available configuration settings: - flight.base_url - Override the base url of the request. (default: null) - flight.case_sensitive - Case sensitive matching for URLs. (default: false) - flight.handle_errors - Allow Flight to handle all errors internally. (default: true) - flight.log_errors - Log errors to the web server's error log file. (default: false) - flight.views.path - Directory containing view template files. (default: ./views) - flight.views.extension - View template file extension. (default: .php) +- **flight.base_url** - Override the base url of the request. (default: null) +- **flight.case_sensitive** - Case sensitive matching for URLs. (default: false) +- **flight.handle_errors** - Allow Flight to handle all errors internally. (default: true) +- **flight.log_errors** - Log errors to the web server's error log file. (default: false) +- **flight.views.path** - Directory containing view template files. (default: ./views) +- **flight.views.extension** - View template file extension. (default: .php) # Framework Methods @@ -867,15 +875,15 @@ or overridden. ## Core Methods ```php -Flight::map($name, $callback) // Creates a custom framework method. -Flight::register($name, $class, [$params], [$callback]) // Registers a class to a framework method. -Flight::before($name, $callback) // Adds a filter before a framework method. -Flight::after($name, $callback) // Adds a filter after a framework method. -Flight::path($path) // Adds a path for autoloading classes. -Flight::get($key) // Gets a variable. -Flight::set($key, $value) // Sets a variable. -Flight::has($key) // Checks if a variable is set. -Flight::clear([$key]) // Clears a variable. +Flight::map(string $name, callable $callback, bool $pass_route = false) // Creates a custom framework method. +Flight::register(string $name, string $class, array $params = [], ?callable $callback = null) // Registers a class to a framework method. +Flight::before(string $name, callable $callback) // Adds a filter before a framework method. +Flight::after(string $name, callable $callback) // Adds a filter after a framework method. +Flight::path(string $path) // Adds a path for autoloading classes. +Flight::get(string $key) // Gets a variable. +Flight::set(string $key, mixed $value) // Sets a variable. +Flight::has(string $key) // Checks if a variable is set. +Flight::clear(array|string $key = []) // Clears a variable. Flight::init() // Initializes the framework to its default settings. Flight::app() // Gets the application object instance ``` @@ -885,16 +893,16 @@ Flight::app() // Gets the application object instance ```php Flight::start() // Starts the framework. Flight::stop() // Stops the framework and sends a response. -Flight::halt([$code], [$message]) // Stop the framework with an optional status code and message. -Flight::route($pattern, $callback) // Maps a URL pattern to a callback. -Flight::redirect($url, [$code]) // Redirects to another URL. -Flight::render($file, [$data], [$key]) // Renders a template file. -Flight::error($exception) // Sends an HTTP 500 response. +Flight::halt(int $code = 200, string $message = '') // Stop the framework with an optional status code and message. +Flight::route(string $pattern, callable $callback, bool $pass_route = false) // Maps a URL pattern to a callback. +Flight::redirect(string $url, int $code) // Redirects to another URL. +Flight::render(string $file, array $data, ?string $key = null) // Renders a template file. +Flight::error(Throwable $exception) // Sends an HTTP 500 response. Flight::notFound() // Sends an HTTP 404 response. -Flight::etag($id, [$type]) // Performs ETag HTTP caching. -Flight::lastModified($time) // Performs last modified HTTP caching. -Flight::json($data, [$code], [$encode], [$charset], [$option]) // Sends a JSON response. -Flight::jsonp($data, [$param], [$code], [$encode], [$charset], [$option]) // Sends a JSONP response. +Flight::etag(string $id, string $type = 'string') // Performs ETag HTTP caching. +Flight::lastModified(int $time) // Performs last modified HTTP caching. +Flight::json(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf8', int $option) // Sends a JSON response. +Flight::jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = 'utf8', int $option) // Sends a JSONP response. ``` Any custom methods added with `map` and `register` can also be filtered. @@ -908,12 +916,10 @@ as an object instance. ```php require 'flight/autoload.php'; -use flight\Engine; +$app = new flight\Engine(); -$app = new Engine(); - -$app->route('/', function(){ - echo 'hello world!'; +$app->route('/', function () { + echo 'hello world!'; }); $app->start(); From 42ec161d8a1f3da5dc26767e24d74677d8d898cd Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Mon, 4 Dec 2023 17:38:10 -0400 Subject: [PATCH 005/331] Added code format --- .editorconfig | 12 ++++++++++++ .gitignore | 1 + composer.json | 5 ++++- flight.sublime-project | 21 +++++++++++++++++++++ flight/Engine.php | 23 ++++++++++++++--------- flight/Flight.php | 7 ++++--- flight/net/Response.php | 3 ++- flight/util/ReturnTypeWillChange.php | 3 +++ phpstan.neon | 14 +++++++------- 9 files changed, 68 insertions(+), 21 deletions(-) create mode 100644 .editorconfig create mode 100644 flight.sublime-project create mode 100644 flight/util/ReturnTypeWillChange.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..e8d15f4f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +indent_size = 2 diff --git a/.gitignore b/.gitignore index a5ec20f9..c92307cf 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ vendor/ composer.phar composer.lock +*.sublime-workspace diff --git a/composer.json b/composer.json index 325ba7f2..2becdbcb 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,10 @@ "ext-json": "*" }, "autoload": { - "files": [ "flight/autoload.php", "flight/Flight.php" ] + "files": [ + "flight/autoload.php", + "flight/Flight.php" + ] }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/flight.sublime-project b/flight.sublime-project new file mode 100644 index 00000000..26552b2a --- /dev/null +++ b/flight.sublime-project @@ -0,0 +1,21 @@ +{ + "folders": [ + { + "path": ".", + } + ], + "settings": { + "LSP": { + "LSP-intelephense": { + "settings": { + "intelephense.environment.phpVersion": "7.4.0", + "intelephense.format.braces": "psr12", + }, + }, + "formatters": + { + "embedding.php": "LSP-intelephense" + }, + }, + }, +} diff --git a/flight/Engine.php b/flight/Engine.php index 34060813..66442fc8 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -397,9 +397,10 @@ public function _start(): void */ public function _error($e): void { - $msg = sprintf('

500 Internal Server Error

' . - '

%s (%s)

' . - '
%s
', + $msg = sprintf( + '

500 Internal Server Error

' . + '

%s (%s)

' . + '
%s
', $e->getMessage(), $e->getCode(), $e->getTraceAsString() @@ -524,8 +525,8 @@ public function _notFound(): void ->status(404) ->write( '

404 Not Found

' . - '

The page you have requested could not be found.

' . - str_repeat(' ', 512) + '

The page you have requested could not be found.

' . + str_repeat(' ', 512) ) ->send(); } @@ -644,8 +645,10 @@ public function _etag(string $id, string $type = 'strong'): void $this->response()->header('ETag', $id); - if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && - $_SERVER['HTTP_IF_NONE_MATCH'] === $id) { + if ( + isset($_SERVER['HTTP_IF_NONE_MATCH']) && + $_SERVER['HTTP_IF_NONE_MATCH'] === $id + ) { $this->halt(304); } } @@ -659,8 +662,10 @@ public function _lastModified(int $time): void { $this->response()->header('Last-Modified', gmdate('D, d M Y H:i:s \G\M\T', $time)); - if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && - strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) === $time) { + if ( + isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && + strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) === $time + ) { $this->halt(304); } } diff --git a/flight/Flight.php b/flight/Flight.php index 2bca787d..e6cdcac1 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -23,7 +23,7 @@ * @method static void stop() Stops the framework and sends a response. * @method static void halt(int $code = 200, string $message = '') Stop the framework with an optional status code and message. * - * @method static void route(string $pattern, callable $callback) Maps a URL pattern to a callback. + * @method static void route(string $pattern, callable $callback, bool $pass_route = false) Maps a URL pattern to a callback. * @method static Router router() Returns Router instance. * * @method static void map(string $name, callable $callback) Creates a custom framework method. @@ -80,8 +80,9 @@ private function __clone() * @param ?Closure(T $instance): void $callback Perform actions with the instance * @return void */ - static function register($name, $class, $params = array(), $callback = null) { - static::__callStatic('register', func_get_args()); + static function register($name, $class, $params = array(), $callback = null) + { + static::__callStatic('register', func_get_args()); } /** diff --git a/flight/net/Response.php b/flight/net/Response.php index 5873ec7c..6b69c670 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -251,7 +251,8 @@ public function sendHeaders(): self '%s %d %s', $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1', $this->status, - self::$codes[$this->status]), + self::$codes[$this->status] + ), true, $this->status ); diff --git a/flight/util/ReturnTypeWillChange.php b/flight/util/ReturnTypeWillChange.php new file mode 100644 index 00000000..3ed841b0 --- /dev/null +++ b/flight/util/ReturnTypeWillChange.php @@ -0,0 +1,3 @@ + Date: Mon, 4 Dec 2023 17:38:40 -0400 Subject: [PATCH 006/331] Bump version v2.0.2 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 38f77a65..e9307ca5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.1 +2.0.2 From 3d58a5bee4efc3453f68d1a613975dfaf6100d81 Mon Sep 17 00:00:00 2001 From: Austin Collier Date: Wed, 27 Dec 2023 16:56:02 -0700 Subject: [PATCH 007/331] added phpunit config for testing coverage --- .gitignore | 3 +++ phpunit.xml | 22 ++++++++++++++++++++++ tests/FlightTest.php | 7 +++++++ tests/RequestTest.php | 7 +++++++ tests/RouterTest.php | 7 +++++++ 5 files changed, 46 insertions(+) create mode 100644 phpunit.xml diff --git a/.gitignore b/.gitignore index a5ec20f9..2161d402 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ vendor/ composer.phar composer.lock +.phpunit.result.cache +coverage/ +.vscode/settings.json diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 00000000..76d14634 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,22 @@ + + + + + flight/ + + + + + tests/ + + + + diff --git a/tests/FlightTest.php b/tests/FlightTest.php index 19dfdd33..d002eb6b 100644 --- a/tests/FlightTest.php +++ b/tests/FlightTest.php @@ -18,9 +18,16 @@ class FlightTest extends PHPUnit\Framework\TestCase { protected function setUp(): void { + $_SERVER = []; + $_REQUEST = []; Flight::init(); } + protected function tearDown(): void { + unset($_REQUEST); + unset($_SERVER); + } + // Checks that default components are loaded public function testDefaultComponents() { diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 842c1bc8..05c8c493 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -17,6 +17,8 @@ class RequestTest extends PHPUnit\Framework\TestCase protected function setUp(): void { + $_SERVER = []; + $_REQUEST = []; $_SERVER['REQUEST_URI'] = '/'; $_SERVER['SCRIPT_NAME'] = '/index.php'; $_SERVER['REQUEST_METHOD'] = 'GET'; @@ -34,6 +36,11 @@ protected function setUp(): void $this->request = new Request(); } + protected function tearDown(): void { + unset($_REQUEST); + unset($_SERVER); + } + public function testDefaults() { self::assertEquals('/', $this->request->url); diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 686d91f3..5b4069f0 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -23,11 +23,18 @@ class RouterTest extends PHPUnit\Framework\TestCase protected function setUp(): void { + $_SERVER = []; + $_REQUEST = []; $this->router = new Router(); $this->request = new Request(); $this->dispatcher = new Dispatcher(); } + protected function tearDown(): void { + unset($_REQUEST); + unset($_SERVER); + } + // Simple output public function ok() { From 57d4d20bba400537f39c850350633cf7eefccffd Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sat, 30 Dec 2023 13:05:46 -0700 Subject: [PATCH 008/331] Update composer.json --- composer.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 2becdbcb..760e656e 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "faslatam/flight", + "name": "n0nag0n/flight", "description": "Flight is a fast, simple, extensible framework for PHP. Flight enables you to quickly and easily build RESTful web applications.", "homepage": "http://flightphp.com", "license": "MIT", @@ -15,6 +15,11 @@ "email": "franyeradriansanchez@gmail.com", "homepage": "https://faslatam.000webhostapp.com", "role": "Maintainer" + }, + { + "name": "n0nag0n", + "email": "n0nag0n@sky-9.com", + "role": "Maintainer" } ], "require": { From 8b60a8a72f2fd70cf435919c13910bff87ebfb2a Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sat, 30 Dec 2023 13:17:01 -0700 Subject: [PATCH 009/331] Update README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 469c5f70..26c444d8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +# What the fork? +This is a fork of the original project [https://github.com/mikecao/flight](https://github.com/mikecao/flight). That project hasn't seen updates in quite some time, so this fork is to help maintain the project going forward. + # What is Flight? ![](https://user-images.githubusercontent.com/104888/50957476-9c4acb80-14be-11e9-88ce-6447364dc1bb.png) From 7bc2f738c4f53b3415b826b94ee3a2f33faa3c8f Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sat, 30 Dec 2023 13:30:05 -0700 Subject: [PATCH 010/331] Versioned to 2.0.3 --- .gitignore | 1 + VERSION | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index efa50c0d..6014b459 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ composer.lock coverage/ .vscode/settings.json *.sublime-workspace +.vscode/ \ No newline at end of file diff --git a/VERSION b/VERSION index e9307ca5..50ffc5aa 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.2 +2.0.3 From 3676602454bb42d227c0704925e8527137b5b520 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sat, 30 Dec 2023 13:45:56 -0700 Subject: [PATCH 011/331] fixed references to old repo --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 26c444d8..24c1d073 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,10 @@ If you're using [Composer](https://getcomposer.org/), you can run the following command: ```bash -composer require mikecao/flight +composer require n0nag0n/flight ``` -OR you can [download](https://github.com/mikecao/flight/archive/master.zip) +OR you can [download](https://github.com/n0nag0n/flight/archive/master.zip) them directly and extract them to your web directory. 2\. Configure your webserver. From 991a1705b91b05925e5503db12b9b6817ef46338 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sat, 30 Dec 2023 22:40:52 -0700 Subject: [PATCH 012/331] added matrix link and readme updates --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 24c1d073..5376e704 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,15 @@ This is a fork of the original project [https://github.com/mikecao/flight](https Flight is a fast, simple, extensible framework for PHP. Flight enables you to quickly and easily build RESTful web applications. +Chat with us on Matrix IRC [#flight-php-framework:matrix.org](https://matrix.to/#/#flight-php-framework:matrix.org) + +# Basic Usage ```php -require 'flight/Flight.php'; + +// if installed with composer +require 'vendor/autoload.php'; +// or if installed manually by zip file +//require 'flight/Flight.php'; Flight::route('/', function() { echo 'hello world!'; From ccdca7e180d731e9013996209a4d38b8d1e8a8c1 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sat, 30 Dec 2023 22:51:09 -0700 Subject: [PATCH 013/331] added chat badge --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5376e704..21c116d5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ +![](https://user-images.githubusercontent.com/104888/50957476-9c4acb80-14be-11e9-88ce-6447364dc1bb.png) +![](https://img.shields.io/badge/PHPStan-level%206-brightgreen.svg?style=flat) +![](https://img.shields.io/matrix/flight-php-framework%3Amatrix.org?server_fqdn=matrix.org&style=social&logo=matrix) + # What the fork? This is a fork of the original project [https://github.com/mikecao/flight](https://github.com/mikecao/flight). That project hasn't seen updates in quite some time, so this fork is to help maintain the project going forward. # What is Flight? -![](https://user-images.githubusercontent.com/104888/50957476-9c4acb80-14be-11e9-88ce-6447364dc1bb.png) -![](https://img.shields.io/badge/PHPStan-level%206-brightgreen.svg?style=flat) - Flight is a fast, simple, extensible framework for PHP. Flight enables you to quickly and easily build RESTful web applications. From e395c7826274e6e9f970a7961cf0ead3a9dcdba9 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sun, 31 Dec 2023 15:12:50 -0700 Subject: [PATCH 014/331] Update composer.json --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 760e656e..6dfce678 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "n0nag0n/flight", - "description": "Flight is a fast, simple, extensible framework for PHP. Flight enables you to quickly and easily build RESTful web applications.", + "name": "flightphp/core", + "description": "Flight is a fast, simple, extensible framework for PHP. Flight enables you to quickly and easily build RESTful web applications. This is the maintained fork of mikecao/flight", "homepage": "http://flightphp.com", "license": "MIT", "authors": [ From 9faf27262c707368f3ec94af9778b05ed388716c Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sun, 31 Dec 2023 16:09:16 -0700 Subject: [PATCH 015/331] cleaned up some unit test requires --- composer.json | 14 +++++++++++--- phpunit.xml | 1 + tests/AutoloadTest.php | 3 --- tests/DispatcherTest.php | 3 --- tests/FilterTest.php | 3 --- tests/FlightTest.php | 3 --- tests/LoaderTest.php | 1 - tests/MapTest.php | 2 -- tests/RedirectTest.php | 3 --- tests/RegisterTest.php | 2 -- tests/RenderTest.php | 3 --- tests/RequestTest.php | 3 --- tests/RouterTest.php | 3 --- tests/VariableTest.php | 3 --- tests/ViewTest.php | 3 --- tests/phpunit_autoload.php | 6 ++++++ 16 files changed, 18 insertions(+), 38 deletions(-) create mode 100644 tests/phpunit_autoload.php diff --git a/composer.json b/composer.json index 760e656e..f5a61d23 100644 --- a/composer.json +++ b/composer.json @@ -43,10 +43,18 @@ } }, "scripts": { - "test": "phpunit tests", + "test": "phpunit", + "test-coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage", "lint": "phpstan --no-progress -cphpstan.neon" }, "suggest": { - "phpstan/phpstan": "PHP Static Analysis Tool" - } + "phpstan/phpstan": "PHP Static Analysis Tool", + "latte/latte": "Latte template engine" + }, + "suggest-dev": { + "tracy/tracy": "Tracy debugger" + }, + "replace": { + "mikecao/flight": "2.0.2" + } } diff --git a/phpunit.xml b/phpunit.xml index 76d14634..1577ac23 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,6 @@ * @license MIT, http://flightphp.com/license */ -require_once 'vendor/autoload.php'; -require_once __DIR__ . '/../flight/Flight.php'; - class FlightTest extends PHPUnit\Framework\TestCase { protected function setUp(): void diff --git a/tests/LoaderTest.php b/tests/LoaderTest.php index 2e2de8f6..0fa96839 100644 --- a/tests/LoaderTest.php +++ b/tests/LoaderTest.php @@ -8,7 +8,6 @@ use flight\core\Loader; -require_once 'vendor/autoload.php'; require_once __DIR__ . '/classes/User.php'; require_once __DIR__ . '/classes/Factory.php'; diff --git a/tests/MapTest.php b/tests/MapTest.php index 5b0d3df7..4f4a0eb3 100644 --- a/tests/MapTest.php +++ b/tests/MapTest.php @@ -8,8 +8,6 @@ use flight\Engine; -require_once 'vendor/autoload.php'; -require_once __DIR__ . '/../flight/autoload.php'; require_once __DIR__ . '/classes/Hello.php'; class MapTest extends PHPUnit\Framework\TestCase diff --git a/tests/RedirectTest.php b/tests/RedirectTest.php index 0f8079ac..9099fe42 100644 --- a/tests/RedirectTest.php +++ b/tests/RedirectTest.php @@ -8,9 +8,6 @@ use flight\Engine; -require_once 'vendor/autoload.php'; -require_once __DIR__ . '/../flight/autoload.php'; - class RedirectTest extends PHPUnit\Framework\TestCase { private Engine $app; diff --git a/tests/RegisterTest.php b/tests/RegisterTest.php index dc383cd7..bd671065 100644 --- a/tests/RegisterTest.php +++ b/tests/RegisterTest.php @@ -8,8 +8,6 @@ use flight\Engine; -require_once 'vendor/autoload.php'; -require_once __DIR__ . '/../flight/autoload.php'; require_once __DIR__ . '/classes/User.php'; class RegisterTest extends PHPUnit\Framework\TestCase diff --git a/tests/RenderTest.php b/tests/RenderTest.php index 260caa6a..dccdc690 100644 --- a/tests/RenderTest.php +++ b/tests/RenderTest.php @@ -8,9 +8,6 @@ use flight\Engine; -require_once 'vendor/autoload.php'; -require_once __DIR__ . '/../flight/Flight.php'; - class RenderTest extends PHPUnit\Framework\TestCase { private Engine $app; diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 05c8c493..dc7c5cf2 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -8,9 +8,6 @@ use flight\net\Request; -require_once 'vendor/autoload.php'; -require_once __DIR__ . '/../flight/autoload.php'; - class RequestTest extends PHPUnit\Framework\TestCase { private Request $request; diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 5b4069f0..db02757c 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -10,9 +10,6 @@ use flight\net\Request; use flight\net\Router; -require_once 'vendor/autoload.php'; -require_once __DIR__ . '/../flight/autoload.php'; - class RouterTest extends PHPUnit\Framework\TestCase { private Router $router; diff --git a/tests/VariableTest.php b/tests/VariableTest.php index e7a610d0..03f29008 100644 --- a/tests/VariableTest.php +++ b/tests/VariableTest.php @@ -5,9 +5,6 @@ * @copyright Copyright (c) 2012, Mike Cao * @license MIT, http://flightphp.com/license */ -require_once 'vendor/autoload.php'; -require_once __DIR__ . '/../flight/autoload.php'; - class VariableTest extends PHPUnit\Framework\TestCase { /** diff --git a/tests/ViewTest.php b/tests/ViewTest.php index bdfcfebc..485a5bd1 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -5,9 +5,6 @@ * @copyright Copyright (c) 2012, Mike Cao * @license MIT, http://flightphp.com/license */ -require_once 'vendor/autoload.php'; -require_once __DIR__ . '/../flight/autoload.php'; - class ViewTest extends PHPUnit\Framework\TestCase { /** diff --git a/tests/phpunit_autoload.php b/tests/phpunit_autoload.php new file mode 100644 index 00000000..7b9b7620 --- /dev/null +++ b/tests/phpunit_autoload.php @@ -0,0 +1,6 @@ + Date: Sun, 31 Dec 2023 18:21:42 -0700 Subject: [PATCH 016/331] added test for unique subdir setup in request --- flight/net/Request.php | 3 +++ tests/RequestTest.php | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/flight/net/Request.php b/flight/net/Request.php index 8f8ecf8f..aceda0b4 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -177,6 +177,9 @@ public function init(array $properties = []) } // Get the requested URL without the base directory + // This rewrites the url in case the public url and base directories match + // (such as installing on a subdirectory in a web server) + // @see testInitUrlSameAsBaseDirectory if ('/' !== $this->base && '' !== $this->base && 0 === strpos($this->url, $this->base)) { $this->url = substr($this->url, \strlen($this->base)); } diff --git a/tests/RequestTest.php b/tests/RequestTest.php index dc7c5cf2..a2e4eec0 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -7,6 +7,7 @@ */ use flight\net\Request; +use flight\util\Collection; class RequestTest extends PHPUnit\Framework\TestCase { @@ -154,4 +155,14 @@ public function testHttps() $request = new Request(); self::assertEquals('http', $request->scheme); } + + public function testInitUrlSameAsBaseDirectory() { + $request = new Request([ + 'url' => '/vagrant/public/flightphp', + 'base' => '/vagrant/public', + 'query' => new Collection(), + 'type' => '' + ]); + $this->assertEquals('/flightphp', $request->url); + } } From a416bfe19b3b903f5036dbbd299f1b9b59b46b5f Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Mon, 1 Jan 2024 10:22:54 -0700 Subject: [PATCH 017/331] 100% coverage for Request and Route --- flight/net/Request.php | 25 ++++++++++++++++++++----- tests/RequestTest.php | 28 ++++++++++++++++++++++++++++ tests/RouterTest.php | 10 ++++++++++ 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/flight/net/Request.php b/flight/net/Request.php index aceda0b4..8a40a504 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -129,10 +129,23 @@ final class Request */ public string $host; + /** + * Stream path for where to pull the request body from + * + * @var string + */ + private string $stream_path = 'php://input'; + + /** + * @var string Raw HTTP request body + */ + public string $body = ''; + /** * Constructor. * * @param array $config Request configuration + * @param string */ public function __construct($config = array()) { @@ -196,7 +209,7 @@ public function init(array $properties = []) // Check for JSON input if (0 === strpos($this->type, 'application/json')) { - $body = self::getBody(); + $body = $this->getBody(); if ('' !== $body && null !== $body) { $data = json_decode($body, true); if (is_array($data)) { @@ -213,20 +226,22 @@ public function init(array $properties = []) * * @return string Raw HTTP request body */ - public static function getBody(): ?string + public function getBody(): ?string { - static $body; + $body = $this->body; - if (null !== $body) { + if ('' !== $body) { return $body; } $method = self::getMethod(); if ('POST' === $method || 'PUT' === $method || 'DELETE' === $method || 'PATCH' === $method) { - $body = file_get_contents('php://input'); + $body = file_get_contents($this->stream_path); } + $this->body = $body; + return $body; } diff --git a/tests/RequestTest.php b/tests/RequestTest.php index a2e4eec0..aea65eed 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -165,4 +165,32 @@ public function testInitUrlSameAsBaseDirectory() { ]); $this->assertEquals('/flightphp', $request->url); } + + public function testInitNoUrl() { + $request = new Request([ + 'url' => '', + 'base' => '/vagrant/public', + 'type' => '' + ]); + $this->assertEquals('/', $request->url); + } + + public function testInitWithJsonBody() { + // 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"}'); + $_SERVER['REQUEST_METHOD'] = 'POST'; + $request = new Request([ + 'url' => '/something/fancy', + 'base' => '/vagrant/public', + 'type' => 'application/json', + 'length' => 13, + 'data' => new Collection(), + 'query' => new Collection(), + 'stream_path' => $stream_path + ]); + $this->assertEquals([ 'foo' => 'bar' ], $request->data->getData()); + $this->assertEquals('{"foo":"bar"}', $request->getBody()); + } } diff --git a/tests/RouterTest.php b/tests/RouterTest.php index db02757c..b6b8a6a4 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -105,6 +105,16 @@ public function testPathRoute() $this->check('OK'); } + // Simple path with trailing slash + // Simple path + public function testPathRouteTrailingSlash() + { + $this->router->map('/path/', [$this, 'ok']); + $this->request->url = '/path'; + + $this->check('OK'); + } + // POST route public function testPostRoute() { From 39f61b3d7474b8fffcfc71a8be5c128b9bcab513 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Mon, 1 Jan 2024 10:29:11 -0700 Subject: [PATCH 018/331] Router with 100% coverage --- tests/RouterTest.php | 45 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/RouterTest.php b/tests/RouterTest.php index b6b8a6a4..9adc7fc5 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -307,4 +307,49 @@ public function testRegExParametersCyrillic() $this->check('цветя'); } + + public function testGetAndClearRoutes() { + $this->router->map('/path1', [$this, 'ok']); + $this->router->map('/path2', [$this, 'ok']); + $this->router->map('/path3', [$this, 'ok']); + $this->router->map('/path4', [$this, 'ok']); + $this->router->map('/path5', [$this, 'ok']); + $this->router->map('/path6', [$this, 'ok']); + $this->router->map('/path7', [$this, 'ok']); + $this->router->map('/path8', [$this, 'ok']); + $this->router->map('/path9', [$this, 'ok']); + + $routes = $this->router->getRoutes(); + $this->assertEquals(9, count($routes)); + + $this->router->clear(); + + $this->assertEquals(0, count($this->router->getRoutes())); + } + + public function testResetRoutes() { + $router = new class extends Router { + public function getIndex() { + return $this->index; + } + }; + + $router->map('/path1', [$this, 'ok']); + $router->map('/path2', [$this, 'ok']); + $router->map('/path3', [$this, 'ok']); + $router->map('/path4', [$this, 'ok']); + $router->map('/path5', [$this, 'ok']); + $router->map('/path6', [$this, 'ok']); + $router->map('/path7', [$this, 'ok']); + $router->map('/path8', [$this, 'ok']); + $router->map('/path9', [$this, 'ok']); + + $router->next(); + $router->next(); + $router->next(); + + $this->assertEquals(3, $router->getIndex()); + $router->reset(); + $this->assertEquals(0, $router->getIndex()); + } } From 7b15d2cfca65c0e35b8f2387b5169031285791fe Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Mon, 1 Jan 2024 16:52:23 -0700 Subject: [PATCH 019/331] 100% coverage for net classes --- flight/net/Response.php | 34 ++++-- tests/ResponseTest.php | 238 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 9 deletions(-) create mode 100644 tests/ResponseTest.php diff --git a/flight/net/Response.php b/flight/net/Response.php index 6b69c670..38d06eca 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -202,7 +202,7 @@ public function clear(): self /** * Sets caching headers for the response. * - * @param int|string|false $expires Expiration time + * @param int|string|false $expires Expiration time as time() or as strtotime() string value * * @return Response Self reference */ @@ -237,7 +237,8 @@ public function sendHeaders(): self { // Send status code header if (false !== strpos(\PHP_SAPI, 'cgi')) { - header( + // @codeCoverageIgnoreStart + $this->setRealHeader( sprintf( 'Status: %d %s', $this->status, @@ -245,8 +246,9 @@ public function sendHeaders(): self ), true ); + // @codeCoverageIgnoreEnd } else { - header( + $this->setRealHeader( sprintf( '%s %d %s', $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1', @@ -262,10 +264,10 @@ public function sendHeaders(): self foreach ($this->headers as $field => $value) { if (\is_array($value)) { foreach ($value as $v) { - header($field . ': ' . $v, false); + $this->setRealHeader($field . ': ' . $v, false); } } else { - header($field . ': ' . $value); + $this->setRealHeader($field . ': ' . $value); } } @@ -274,13 +276,27 @@ public function sendHeaders(): self $length = $this->getContentLength(); if ($length > 0) { - header('Content-Length: ' . $length); + $this->setRealHeader('Content-Length: ' . $length); } } return $this; } + /** + * Sets a real header. Mostly used for test mocking. + * + * @param string $header_string The header string you would pass to header() + * @param bool $replace The optional replace parameter indicates whether the header should replace a previous similar header, or add a second header of the same type. By default it will replace, but if you pass in false as the second argument you can force multiple headers of the same type. + * @param int $response_code The response code to send + * @return self + * @codeCoverageIgnore + */ + public function setRealHeader(string $header_string, bool $replace = true, int $response_code = 0): self { + header($header_string, $replace, $response_code); + return $this; + } + /** * Gets the content length. * @@ -294,7 +310,7 @@ public function getContentLength(): int } /** - * Gets whether response was sent. + * Gets whether response body was sent. */ public function sent(): bool { @@ -307,11 +323,11 @@ public function sent(): bool public function send(): void { if (ob_get_length() > 0) { - ob_end_clean(); + ob_end_clean(); // @codeCoverageIgnore } if (!headers_sent()) { - $this->sendHeaders(); + $this->sendHeaders(); // @codeCoverageIgnore } echo $this->body; diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php new file mode 100644 index 00000000..cd3e582f --- /dev/null +++ b/tests/ResponseTest.php @@ -0,0 +1,238 @@ + + * @license MIT, http://flightphp.com/license + */ + +use flight\net\Request; +use flight\net\Response; +use flight\util\Collection; + +class ResponseTest extends PHPUnit\Framework\TestCase +{ + + protected function setUp(): void + { + $_SERVER = []; + $_REQUEST = []; + // $_SERVER['REQUEST_URI'] = '/'; + // $_SERVER['SCRIPT_NAME'] = '/index.php'; + // $_SERVER['REQUEST_METHOD'] = 'GET'; + // $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; + // $_SERVER['REMOTE_ADDR'] = '8.8.8.8'; + // $_SERVER['HTTP_X_FORWARDED_FOR'] = '32.32.32.32'; + // $_SERVER['HTTP_HOST'] = 'example.com'; + // $_SERVER['CONTENT_TYPE'] = ''; + + $_GET = []; + $_POST = []; + $_COOKIE = []; + $_FILES = []; + + // $this->request = new Request(); + } + + protected function tearDown(): void { + unset($_REQUEST); + unset($_SERVER); + } + + public function testStatusDefault() { + $response = new Response(); + $this->assertSame(200, $response->status()); + } + + public function testStatusValidCode() { + $response = new Response(); + $response->status(200); + $this->assertEquals(200, $response->status()); + } + + public function testStatusInvalidCode() { + $response = new Response(); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid status code.'); + $response->status(999); + } + + public function testStatusReturnObject() { + $response = new Response(); + $this->assertEquals($response, $response->status(200)); + } + + public function testHeaderSingle() { + $response = new Response(); + $response->header('Content-Type', 'text/html'); + $this->assertEquals(['Content-Type' => 'text/html'], $response->headers()); + } + + public function testHeaderSingleKeepCaseSensitive() { + $response = new Response(); + $response->header('content-type', 'text/html'); + $response->header('x-test', 'test'); + $this->assertEquals(['content-type' => 'text/html', 'x-test' => 'test'], $response->headers()); + } + + public function testHeaderArray() { + $response = new Response(); + $response->header(['Content-Type' => 'text/html', 'X-Test' => 'test']); + $this->assertEquals(['Content-Type' => 'text/html', 'X-Test' => 'test'], $response->headers()); + } + + public function testHeaderReturnObject() { + $response = new Response(); + $this->assertEquals($response, $response->header('Content-Type', 'text/html')); + } + + public function testWrite() { + $response = new class extends Response { + public function getBody() { + return $this->body; + } + }; + $response->write('test'); + $this->assertEquals('test', $response->getBody()); + } + + public function testWriteEmptyString() { + $response = new class extends Response { + public function getBody() { + return $this->body; + } + }; + $response->write(''); + $this->assertEquals('', $response->getBody()); + } + + public function testWriteReturnObject() { + $response = new Response(); + $this->assertEquals($response, $response->write('test')); + } + + public function testClear() { + $response = new class extends Response { + public function getBody() { + return $this->body; + } + }; + $response->write('test'); + $response->status(404); + $response->header('Content-Type', 'text/html'); + $response->clear(); + $this->assertEquals('', $response->getBody()); + $this->assertEquals(200, $response->status()); + $this->assertEquals([], $response->headers()); + } + + public function testCacheSimple() { + $response = new Response(); + $cache_time = time() + 60; + $response->cache($cache_time); + $this->assertEquals([ + 'Expires' => gmdate('D, d M Y H:i:s', $cache_time) . ' GMT', + 'Cache-Control' => 'max-age=60' + ], $response->headers()); + } + + public function testCacheSimpleWithString() { + $response = new Response(); + $cache_time = time() + 60; + $response->cache('now +60 seconds'); + $this->assertEquals([ + 'Expires' => gmdate('D, d M Y H:i:s', $cache_time) . ' GMT', + 'Cache-Control' => 'max-age=60' + ], $response->headers()); + } + + public function testCacheSimpleWithPragma() { + $response = new Response(); + $cache_time = time() + 60; + $response->header('Pragma', 'no-cache'); + $response->cache($cache_time); + $this->assertEquals([ + 'Expires' => gmdate('D, d M Y H:i:s', $cache_time) . ' GMT', + 'Cache-Control' => 'max-age=60' + ], $response->headers()); + } + + public function testCacheFalseExpiresValue() { + $response = new Response(); + $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', + ], + 'Pragma' => 'no-cache' + ], $response->headers()); + } + + public function testSendHeadersRegular() { + $response = new class extends Response { + protected $test_sent_headers = []; + + protected array $headers = [ + 'Cache-Control' => [ + 'no-store, no-cache, must-revalidate', + 'post-check=0, pre-check=0', + 'max-age=0', + ] + ]; + public function setRealHeader(string $header_string, bool $replace = true, int $response_code = 0): Response + { + $this->test_sent_headers[] = $header_string; + return $this; + } + + public function getSentHeaders(): array + { + return $this->test_sent_headers; + } + }; + $response->header('Content-Type', 'text/html'); + $response->header('X-Test', 'test'); + $response->write('Something'); + + $response->sendHeaders(); + $sent_headers = $response->getSentHeaders(); + $this->assertEquals([ + 'HTTP/1.1 200 OK', + 'Cache-Control: no-store, no-cache, must-revalidate', + 'Cache-Control: post-check=0, pre-check=0', + 'Cache-Control: max-age=0', + 'Content-Type: text/html', + 'X-Test: test', + 'Content-Length: 9' + ], $sent_headers); + } + + public function testSentDefault() { + $response = new Response(); + $this->assertFalse($response->sent()); + } + + public function testSentTrue() { + $response = new class extends Response { + protected $test_sent_headers = []; + + public function setRealHeader(string $header_string, bool $replace = true, int $response_code = 0): Response + { + $this->test_sent_headers[] = $header_string; + return $this; + } + }; + $response->header('Content-Type', 'text/html'); + $response->header('X-Test', 'test'); + $response->write('Something'); + + $this->expectOutputString('Something'); + $response->send(); + $this->assertTrue($response->sent()); + } + + +} From 978a05d765bce0d27052ccfb58c22fdc7d7b7c57 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Mon, 1 Jan 2024 16:54:20 -0700 Subject: [PATCH 020/331] Flight class 100% coverage (too easy!) --- flight/Flight.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/flight/Flight.php b/flight/Flight.php index e6cdcac1..69a8b652 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -57,11 +57,22 @@ class Flight */ private static Engine $engine; - // Don't allow object instantiation + /** + * Don't allow object instantiation + * + * @codeCoverageIgnore + * @return void + */ private function __construct() { } + /** + * Forbid cloning the class + * + * @codeCoverageIgnore + * @return void + */ private function __clone() { } From e36e9024c69b07bda9e927f5756aaa30eaadb442 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Mon, 1 Jan 2024 17:08:32 -0700 Subject: [PATCH 021/331] 100% Coverage Dispatcher Class --- flight/core/Dispatcher.php | 3 ++ tests/DispatcherTest.php | 82 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/flight/core/Dispatcher.php b/flight/core/Dispatcher.php index 0f810bdb..4ce68540 100644 --- a/flight/core/Dispatcher.php +++ b/flight/core/Dispatcher.php @@ -232,6 +232,8 @@ public static function invokeMethod($func, array &$params = []) return ($instance) ? $class->$method($params[0], $params[1], $params[2]) : $class::$method($params[0], $params[1], $params[2]); + // This will be refactored soon enough + // @codeCoverageIgnoreStart case 4: return ($instance) ? $class->$method($params[0], $params[1], $params[2], $params[3]) : @@ -242,6 +244,7 @@ public static function invokeMethod($func, array &$params = []) $class::$method($params[0], $params[1], $params[2], $params[3], $params[4]); default: return \call_user_func_array($func, $params); + // @codeCoverageIgnoreEnd } } diff --git a/tests/DispatcherTest.php b/tests/DispatcherTest.php index 97fb3918..0fc5d470 100644 --- a/tests/DispatcherTest.php +++ b/tests/DispatcherTest.php @@ -44,6 +44,61 @@ public function testFunctionMapping() self::assertEquals('hello', $result); } + public function testHasEvent() + { + $this->dispatcher->set('map-event', function () { + return 'hello'; + }); + + $result = $this->dispatcher->has('map-event'); + + $this->assertTrue($result); + } + + public function testClearAllRegisteredEvents() { + $this->dispatcher->set('map-event', function () { + return 'hello'; + }); + + $this->dispatcher->set('map-event-2', function () { + return 'there'; + }); + + $this->dispatcher->clear(); + + $result = $this->dispatcher->has('map-event'); + $this->assertFalse($result); + $result = $this->dispatcher->has('map-event-2'); + $this->assertFalse($result); + } + + public function testClearDeclaredRegisteredEvent() { + $this->dispatcher->set('map-event', function () { + return 'hello'; + }); + + $this->dispatcher->set('map-event-2', function () { + return 'there'; + }); + + $this->dispatcher->clear('map-event'); + + $result = $this->dispatcher->has('map-event'); + $this->assertFalse($result); + $result = $this->dispatcher->has('map-event-2'); + $this->assertTrue($result); + } + + // Map a static function + public function testStaticFunctionMapping() + { + $this->dispatcher->set('map2', 'Hello::sayHi'); + + $result = $this->dispatcher->run('map2'); + + self::assertEquals('hello', $result); + } + // Map a class method public function testClassMethodMapping() { @@ -95,4 +150,31 @@ public function testInvalidCallback() $this->dispatcher->execute(['NonExistentClass', 'nonExistentMethod']); } + + public function testCallFunction4Params() { + $closure = function($param1, $params2, $params3, $param4) { + return 'hello'.$param1.$params2.$params3.$param4; + }; + $params = ['param1', 'param2', 'param3', 'param4']; + $result = $this->dispatcher->callFunction($closure, $params); + $this->assertEquals('helloparam1param2param3param4', $result); + } + + public function testCallFunction5Params() { + $closure = function($param1, $params2, $params3, $param4, $param5) { + return 'hello'.$param1.$params2.$params3.$param4.$param5; + }; + $params = ['param1', 'param2', 'param3', 'param4', 'param5']; + $result = $this->dispatcher->callFunction($closure, $params); + $this->assertEquals('helloparam1param2param3param4param5', $result); + } + + public function testCallFunction6Params() { + $closure = function($param1, $params2, $params3, $param4, $param5, $param6) { + return 'hello'.$param1.$params2.$params3.$param4.$param5.$param6; + }; + $params = ['param1', 'param2', 'param3', 'param4', 'param5', 'param6']; + $result = $this->dispatcher->callFunction($closure, $params); + $this->assertEquals('helloparam1param2param3param4param5param6', $result); + } } From 9a007c521618387ed5203c0aac04f368ac6b774a Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Mon, 1 Jan 2024 17:54:47 -0700 Subject: [PATCH 022/331] 100% Coverage for core Classes --- flight/core/Loader.php | 4 +++- tests/LoaderTest.php | 39 +++++++++++++++++++++++++++++++++++ tests/classes/TesterClass.php | 17 +++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 tests/classes/TesterClass.php diff --git a/flight/core/Loader.php b/flight/core/Loader.php index 5ff66fe3..c4384453 100644 --- a/flight/core/Loader.php +++ b/flight/core/Loader.php @@ -141,6 +141,7 @@ public function newInstance($class, array $params = []) return new $class(); case 1: return new $class($params[0]); + // @codeCoverageIgnoreStart case 2: return new $class($params[0], $params[1]); case 3: @@ -149,6 +150,7 @@ public function newInstance($class, array $params = []) return new $class($params[0], $params[1], $params[2], $params[3]); case 5: return new $class($params[0], $params[1], $params[2], $params[3], $params[4]); + // @codeCoverageIgnoreEnd default: try { $refClass = new ReflectionClass($class); @@ -192,7 +194,7 @@ public static function autoload(bool $enabled = true, $dirs = []): void if ($enabled) { spl_autoload_register([__CLASS__, 'loadClass']); } else { - spl_autoload_unregister([__CLASS__, 'loadClass']); + spl_autoload_unregister([__CLASS__, 'loadClass']); // @codeCoverageIgnore } if (!empty($dirs)) { diff --git a/tests/LoaderTest.php b/tests/LoaderTest.php index 0fa96839..ecadd0a5 100644 --- a/tests/LoaderTest.php +++ b/tests/LoaderTest.php @@ -10,6 +10,7 @@ require_once __DIR__ . '/classes/User.php'; require_once __DIR__ . '/classes/Factory.php'; +require_once __DIR__ . '/classes/TesterClass.php'; class LoaderTest extends PHPUnit\Framework\TestCase { @@ -117,4 +118,42 @@ public function testRegisterUsingCallback() self::assertIsObject($obj); self::assertInstanceOf(Factory::class, $obj); } + + public function testUnregisterClass() { + $this->loader->register('g', 'User'); + $current_class = $this->loader->get('g'); + $this->assertEquals([ 'User', [], null ], $current_class); + $this->loader->unregister('g'); + $unregistered_class_result = $this->loader->get('g'); + $this->assertNull($unregistered_class_result); + } + + public function testNewInstance6Params() { + $TesterClass = $this->loader->newInstance('TesterClass', ['Bob','Fred', 'Joe', 'Jane', 'Sally', 'Suzie']); + $this->assertEquals('Bob', $TesterClass->param1); + $this->assertEquals('Fred', $TesterClass->param2); + $this->assertEquals('Joe', $TesterClass->param3); + $this->assertEquals('Jane', $TesterClass->param4); + $this->assertEquals('Sally', $TesterClass->param5); + $this->assertEquals('Suzie', $TesterClass->param6); + } + + public function testNewInstance6ParamsBadClass() { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Cannot instantiate BadClass'); + $TesterClass = $this->loader->newInstance('BadClass', ['Bob','Fred', 'Joe', 'Jane', 'Sally', 'Suzie']); + } + + public function testAddDirectoryAsArray() { + $loader = new class extends Loader { + public function getDirectories() { + return self::$dirs; + } + }; + $loader->addDirectory([__DIR__ . '/classes']); + self::assertEquals([ + dirname(__DIR__), + __DIR__ . '/classes' + ], $loader->getDirectories()); + } } diff --git a/tests/classes/TesterClass.php b/tests/classes/TesterClass.php new file mode 100644 index 00000000..1b063fda --- /dev/null +++ b/tests/classes/TesterClass.php @@ -0,0 +1,17 @@ +param1 = $param1; + $this->param2 = $param2; + $this->param3 = $param3; + $this->param4 = $param4; + $this->param5 = $param5; + $this->param6 = $param6; + } +}; \ No newline at end of file From d075fc8be661373f118830e2172c764c307c01b7 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Mon, 1 Jan 2024 18:05:34 -0700 Subject: [PATCH 023/331] 100% View class coverage --- flight/template/View.php | 5 +++-- tests/ViewTest.php | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/flight/template/View.php b/flight/template/View.php index f4d866cc..6796577b 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -202,7 +202,8 @@ public function getTemplate($file) */ public function e($str) { - echo htmlentities($str); - return htmlentities($str); + $value = htmlentities($str); + echo $value; + return $value; } } diff --git a/tests/ViewTest.php b/tests/ViewTest.php index 485a5bd1..c952b506 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -33,6 +33,21 @@ public function testVariables() $this->assertNull($this->view->get('test')); } + public function testMultipleVariables() { + $this->view->set([ + 'test' => 123, + 'foo' => 'bar' + ]); + + $this->assertEquals(123, $this->view->get('test')); + $this->assertEquals('bar', $this->view->get('foo')); + + $this->view->clear(); + + $this->assertNull($this->view->get('test')); + $this->assertNull($this->view->get('foo')); + } + // Check if template files exist public function testTemplateExists() { @@ -48,6 +63,13 @@ public function testRender() $this->expectOutputString('Hello, Bob!'); } + public function testRenderBadFilePath() { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Template file not found: '.__DIR__ . '/views/badfile.php'); + + $this->view->render('badfile'); + } + // Fetch template output public function testFetch() { @@ -76,4 +98,22 @@ public function testTemplateWithCustomExtension() $this->expectOutputString('Hello world, Bob!'); } + + public function testGetTemplateAbsolutePath() { + $tmpfile = tmpfile(); + $file_path = stream_get_meta_data($tmpfile)['uri'].'.php'; + $this->assertEquals($file_path, $this->view->getTemplate($file_path)); + } + + public function testE() { + $this->expectOutputString('<script>'); + $result = $this->view->e('']; + $result = Json::prettyPrint($data, JSON_HEX_TAG); + $this->assertStringContainsString('\u003C', $result); // Should escape < character + } + + // Test getLastError when no error + public function testGetLastErrorNoError(): void + { + // Perform a valid JSON operation first + Json::encode(['valid' => 'data']); + $this->assertEquals('', Json::getLastError()); + } + + // Test getLastError when there is an error + public function testGetLastErrorWithError(): void + { + // Trigger a JSON error by using json_decode directly with invalid JSON + // This bypasses our Json class exception handling to test getLastError() + json_decode('{"invalid": json}'); + + $errorMessage = Json::getLastError(); + $this->assertNotEmpty($errorMessage); + $this->assertIsString($errorMessage); + } + + // Test encoding arrays + public function testEncodeArray(): void + { + $data = [1, 2, 3, 'four']; + $result = Json::encode($data); + $this->assertEquals('[1,2,3,"four"]', $result); + } + + // Test encoding null + public function testEncodeNull(): void + { + $result = Json::encode(null); + $this->assertEquals('null', $result); + } + + // Test encoding boolean values + public function testEncodeBoolean(): void + { + $this->assertEquals('true', Json::encode(true)); + $this->assertEquals('false', Json::encode(false)); + } + + // Test encoding strings + public function testEncodeString(): void + { + $result = Json::encode('Hello World'); + $this->assertEquals('"Hello World"', $result); + } + + // Test encoding numbers + public function testEncodeNumbers(): void + { + $this->assertEquals('42', Json::encode(42)); + $this->assertEquals('3.14', Json::encode(3.14)); + } + + // Test decoding arrays + public function testDecodeArray(): void + { + $json = '[1,2,3,"four"]'; + $result = Json::decode($json, true); + $this->assertEquals([1, 2, 3, 'four'], $result); + } + + // Test decoding nested objects + public function testDecodeNestedObjects(): void + { + $json = '{"user":{"name":"John","profile":{"age":30}}}'; + $result = Json::decode($json, true); + $this->assertEquals('John', $result['user']['name']); + $this->assertEquals(30, $result['user']['profile']['age']); + } + + // Test default encoding options are applied + public function testDefaultEncodingOptions(): void + { + $data = ['url' => 'https://example.com/path']; + $result = Json::encode($data); + // Should not escape slashes due to JSON_UNESCAPED_SLASHES + $this->assertStringContainsString('https://example.com/path', $result); + } + + // Test round trip encoding/decoding + public function testRoundTrip(): void + { + $original = [ + 'string' => 'test', + 'number' => 42, + 'boolean' => true, + 'null' => null, + 'array' => [1, 2, 3], + 'object' => ['nested' => 'value'] + ]; + + $encoded = Json::encode($original); + $decoded = Json::decode($encoded, true); + + $this->assertEquals($original, $decoded); + } +} diff --git a/tests/ViewTest.php b/tests/ViewTest.php index 736d0b43..ddbf32b9 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -180,7 +180,7 @@ public function testKeepThePreviousStateOfOneViewComponentByDefault(): void
Hi
- html; + html; // phpcs:ignore // if windows replace \n with \r\n $html = str_replace(["\n", "\r"], '', $html); @@ -202,7 +202,7 @@ public function testKeepThePreviousStateOfDataSettedBySetMethod(): void $html = <<<'html'
qux
bar
- html; + html; // phpcs:ignore $html = str_replace(["\n", "\r"], '', $html); @@ -217,12 +217,12 @@ public static function renderDataProvider(): array $html1 = <<<'html'
Hi
- html; + html; // phpcs:ignore $html2 = <<<'html' - html; + html; // phpcs:ignore $html1 = str_replace(["\n", "\r"], '', $html1); $html2 = str_replace(["\n", "\r"], '', $html2); diff --git a/tests/commands/RouteCommandTest.php b/tests/commands/RouteCommandTest.php index b4f22919..7bcc1231 100644 --- a/tests/commands/RouteCommandTest.php +++ b/tests/commands/RouteCommandTest.php @@ -115,7 +115,7 @@ public function testGetRoutes(): void | /put | PUT | | No | - | | /patch | PATCH | | No | Bad Middleware | +---------+-----------+-------+----------+----------------+ - output; + output; // phpcs:ignore $this->assertStringContainsString( $expected, @@ -138,7 +138,7 @@ public function testGetPostRoute(): void +---------+---------+-------+----------+------------+ | /post | POST | | No | Closure | +---------+---------+-------+----------+------------+ - output; + output; // phpcs:ignore $this->assertStringContainsString( $expected, diff --git a/tests/server/index.php b/tests/server/index.php index a4cd2895..bca95f7d 100644 --- a/tests/server/index.php +++ b/tests/server/index.php @@ -190,7 +190,7 @@

500 Internal Server Error

%s (%s)

%s
- HTML, + HTML, // phpcs:ignore $e->getMessage(), $e->getCode(), str_replace(getenv('PWD'), '***CONFIDENTIAL***', $e->getTraceAsString()) From eecbdc389558624b69a7f813d22d5936c1983b7b Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sun, 20 Jul 2025 09:49:00 -0600 Subject: [PATCH 291/331] a few fixes from Copilot review --- flight/util/Json.php | 6 +++--- tests/EngineTest.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flight/util/Json.php b/flight/util/Json.php index 2d5e8b44..532fc2b2 100644 --- a/flight/util/Json.php +++ b/flight/util/Json.php @@ -41,7 +41,7 @@ public static function encode($data, int $options = 0, int $depth = 512): string try { return json_encode($data, $options, $depth); } catch (JsonException $e) { - throw new JsonException('JSON encoding failed: ' . $e->getMessage(), $e->getCode(), $e); + throw new Exception('JSON encoding failed: ' . $e->getMessage(), $e->getCode(), $e); } } @@ -56,13 +56,13 @@ public static function encode($data, int $options = 0, int $depth = 512): string * @return mixed Decoded data * @throws Exception If decoding fails */ - public static function decode(string $json, bool $associative = false, int $depth = 512, int $options = self::DEFAULT_DECODE_OPTIONS) + public static function decode(string $json, bool $associative = false, int $depth = 512, int $options = 0) { $options = $options | self::DEFAULT_DECODE_OPTIONS; // Ensure default options are applied try { return json_decode($json, $associative, $depth, $options); } catch (JsonException $e) { - throw new JsonException('JSON decoding failed: ' . $e->getMessage(), $e->getCode(), $e); + throw new Exception('JSON decoding failed: ' . $e->getMessage(), $e->getCode(), $e); } } diff --git a/tests/EngineTest.php b/tests/EngineTest.php index 1b553ae4..3e81c4fd 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -398,7 +398,7 @@ public function testJsonWithDuplicateDefaultFlags() public function testJsonThrowOnErrorByDefault() { $engine = new Engine(); - $this->expectException(JsonException::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('Malformed UTF-8 characters, possibly incorrectly encoded'); $engine->json(['key1' => 'value1', 'key2' => 'value2', 'utf8_emoji' => "\xB1\x31"]); } From c8f39b7a11e86f842f4455a67754b0f73863014d Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Tue, 22 Jul 2025 08:09:20 -0600 Subject: [PATCH 292/331] improvements with the request class --- flight/Engine.php | 4 +- flight/net/Request.php | 87 +++++++++++++++++++++++------------------ flight/net/Response.php | 3 +- tests/RequestTest.php | 21 ++++++++++ 4 files changed, 76 insertions(+), 39 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index c3e769e1..59741584 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -1020,8 +1020,10 @@ public function _etag(string $id, string $type = 'strong'): void public function _lastModified(int $time): void { $this->response()->header('Last-Modified', gmdate('D, d M Y H:i:s \G\M\T', $time)); + $request = $this->request(); + $ifModifiedSince = $request->header('If-Modified-Since'); - $hit = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) === $time; + $hit = isset($ifModifiedSince) && strtotime($ifModifiedSince) === $time; $this->triggerEvent('flight.cache.checked', 'lastModified', $hit, 0.0); if ($hit === true) { diff --git a/flight/net/Request.php b/flight/net/Request.php index 6ca6b72c..2d0c1099 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -155,27 +155,37 @@ class Request public function __construct(array $config = []) { // Default properties - if (empty($config)) { + if (empty($config) === true) { + $scheme = $this->getScheme(); + $url = $this->getVar('REQUEST_URI', '/'); + if (strpos($url, '@') !== false) { + $url = str_replace('@', '%40', $url); + } + $base = $this->getVar('SCRIPT_NAME', ''); + if (strpos($base, ' ') !== false || strpos($base, '\\') !== false) { + $base = str_replace(['\\', ' '], ['/', '%20'], $base); + } + $base = dirname($base); $config = [ - 'url' => str_replace('@', '%40', self::getVar('REQUEST_URI', '/')), - 'base' => str_replace(['\\', ' '], ['/', '%20'], \dirname(self::getVar('SCRIPT_NAME'))), - 'method' => self::getMethod(), - 'referrer' => self::getVar('HTTP_REFERER'), - 'ip' => self::getVar('REMOTE_ADDR'), - 'ajax' => self::getVar('HTTP_X_REQUESTED_WITH') === 'XMLHttpRequest', - 'scheme' => self::getScheme(), - 'user_agent' => self::getVar('HTTP_USER_AGENT'), - 'type' => self::getVar('CONTENT_TYPE'), - 'length' => intval(self::getVar('CONTENT_LENGTH', 0)), + 'url' => $url, + 'base' => $base, + 'method' => $this->getMethod(), + 'referrer' => $this->getVar('HTTP_REFERER'), + 'ip' => $this->getVar('REMOTE_ADDR'), + 'ajax' => $this->getVar('HTTP_X_REQUESTED_WITH') === 'XMLHttpRequest', + 'scheme' => $scheme, + 'user_agent' => $this->getVar('HTTP_USER_AGENT'), + 'type' => $this->getVar('CONTENT_TYPE'), + 'length' => intval($this->getVar('CONTENT_LENGTH', 0)), 'query' => new Collection($_GET), 'data' => new Collection($_POST), 'cookies' => new Collection($_COOKIE), 'files' => new Collection($_FILES), - 'secure' => self::getScheme() === 'https', - 'accept' => self::getVar('HTTP_ACCEPT'), - 'proxy_ip' => self::getProxyIpAddress(), - 'host' => self::getVar('HTTP_HOST'), - 'servername' => self::getVar('SERVER_NAME', ''), + 'secure' => $scheme === 'https', + 'accept' => $this->getVar('HTTP_ACCEPT'), + 'proxy_ip' => $this->getProxyIpAddress(), + 'host' => $this->getVar('HTTP_HOST'), + 'servername' => $this->getVar('SERVER_NAME', ''), ]; } @@ -201,7 +211,7 @@ public function init(array $properties = []): self // (such as installing on a subdirectory in a web server) // @see testInitUrlSameAsBaseDirectory if ($this->base !== '/' && $this->base !== '' && strpos($this->url, $this->base) === 0) { - $this->url = substr($this->url, \strlen($this->base)); + $this->url = substr($this->url, strlen($this->base)); } // Default url @@ -249,11 +259,11 @@ public function getBody(): string return $body; } - $method = $this->method ?? self::getMethod(); + $method = $this->method ?? $this->getMethod(); - if ($method === 'POST' || $method === 'PUT' || $method === 'DELETE' || $method === 'PATCH') { - $body = file_get_contents($this->stream_path); - } + if (in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true) === true) { + $body = file_get_contents($this->stream_path); + } $this->body = $body; @@ -267,8 +277,8 @@ public static function getMethod(): string { $method = self::getVar('REQUEST_METHOD', 'GET'); - if (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']) === true) { - $method = $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']; + if (self::getVar('HTTP_X_HTTP_METHOD_OVERRIDE') !== '') { + $method = self::getVar('HTTP_X_HTTP_METHOD_OVERRIDE'); } elseif (isset($_REQUEST['_method']) === true) { $method = $_REQUEST['_method']; } @@ -295,8 +305,9 @@ public static function getProxyIpAddress(): string $flags = \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE; foreach ($forwarded as $key) { - if (\array_key_exists($key, $_SERVER) === true) { - sscanf($_SERVER[$key], '%[^,]', $ip); + $serverVar = self::getVar($key); + if ($serverVar !== '') { + sscanf($serverVar, '%[^,]', $ip); if (filter_var($ip, \FILTER_VALIDATE_IP, $flags) !== false) { return $ip; } @@ -403,14 +414,16 @@ public function getBaseUrl(): string */ public static function parseQuery(string $url): array { - $params = []; - - $args = parse_url($url); - if (isset($args['query']) === true) { - parse_str($args['query'], $params); - } - - return $params; + $queryPos = strpos($url, '?'); + if ($queryPos === false) { + return []; + } + $query = substr($url, $queryPos + 1); + if ($query === '') { + return []; + } + parse_str($query, $params); + return $params; } /** @@ -421,13 +434,13 @@ public static function parseQuery(string $url): array public static function getScheme(): string { if ( - (isset($_SERVER['HTTPS']) === true && strtolower($_SERVER['HTTPS']) === 'on') + (strtolower(self::getVar('HTTPS')) === 'on') || - (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) === true && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') + (self::getVar('HTTP_X_FORWARDED_PROTO') === 'https') || - (isset($_SERVER['HTTP_FRONT_END_HTTPS']) === true && $_SERVER['HTTP_FRONT_END_HTTPS'] === 'on') + (self::getVar('HTTP_FRONT_END_HTTPS') === 'on') || - (isset($_SERVER['REQUEST_SCHEME']) === true && $_SERVER['REQUEST_SCHEME'] === 'https') + (self::getVar('REQUEST_SCHEME') === 'https') ) { return 'https'; } diff --git a/flight/net/Response.php b/flight/net/Response.php index f9ee9a28..b364b13f 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -324,10 +324,11 @@ public function sendHeaders(): self ); // @codeCoverageIgnoreEnd } else { + $serverProtocol = Request::getVar('SERVER_PROTOCOL') ?: 'HTTP/1.1'; $this->setRealHeader( sprintf( '%s %d %s', - $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1', + $serverProtocol, $this->status, self::$codes[$this->status] ), diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 1b7b4847..a84841be 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -356,4 +356,25 @@ public function testGetMultiFileUpload(): void $this->assertEquals('/tmp/php456', $files[1]->getTempName()); $this->assertEquals(0, $files[1]->getError()); } + + public function testUrlWithAtSymbol(): void + { + $_SERVER['REQUEST_URI'] = '/user@domain'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $request = new Request(); + $this->assertEquals('/user%40domain', $request->url); + } + + public function testBaseWithSpaceAndBackslash(): void + { + $_SERVER['SCRIPT_NAME'] = '\\dir name\\base folder\\index.php'; + $request = new Request(); + $this->assertEquals('/dir%20name/base%20folder', $request->base); + } + + public function testParseQueryWithEmptyQueryString(): void + { + $result = Request::parseQuery('/foo?'); + $this->assertEquals([], $result); + } } From 8ecb93f2dff01e82b1f408d310aaed35b76b1914 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sat, 16 Aug 2025 18:54:31 -0600 Subject: [PATCH 293/331] router performance enhancements --- flight/net/Router.php | 67 +++++++++++--- tests/EngineTest.php | 102 +++++++++++++++++++++ tests/performance/index.php | 120 +++++++++++++++++++++++++ tests/performance/performance_tests.sh | 66 ++++++++++++++ 4 files changed, 345 insertions(+), 10 deletions(-) create mode 100644 tests/performance/index.php create mode 100644 tests/performance/performance_tests.sh diff --git a/flight/net/Router.php b/flight/net/Router.php index 455fdb4d..8f5555e2 100644 --- a/flight/net/Router.php +++ b/flight/net/Router.php @@ -29,6 +29,13 @@ class Router */ protected array $routes = []; + /** + * Routes grouped by HTTP method for faster lookups + * + * @var array> + */ + protected array $routesByMethod = []; + /** * The current route that is has been found and executed. */ @@ -82,6 +89,7 @@ public function getRoutes(): array public function clear(): void { $this->routes = []; + $this->routesByMethod = []; } /** @@ -134,6 +142,14 @@ public function map(string $pattern, $callback, bool $pass_route = false, string $this->routes[] = $route; + // Group routes by HTTP method for faster lookups + foreach ($methods as $method) { + if (!isset($this->routesByMethod[$method])) { + $this->routesByMethod[$method] = []; + } + $this->routesByMethod[$method][] = $route; + } + return $route; } @@ -228,17 +244,48 @@ public function group(string $groupPrefix, callable $callback, array $groupMiddl */ public function route(Request $request) { - while ($route = $this->current()) { - $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 - } elseif ($urlMatches === true && $methodMatches === false) { - $this->executedRoute = $route; + $requestMethod = $request->method; + $requestUrl = $request->url; + + // If we're in the middle of iterating (index > 0), continue with the original iterator logic + // This handles cases where the Engine calls next() and continues routing (e.g., when routes return true) + if ($this->index > 0) { + while ($route = $this->current()) { + $urlMatches = $route->matchUrl($requestUrl, $this->caseSensitive); + $methodMatches = $route->matchMethod($requestMethod); + if ($urlMatches === true && $methodMatches === true) { + $this->executedRoute = $route; + return $route; + } elseif ($urlMatches === true && $methodMatches === false) { + $this->executedRoute = $route; + } + $this->next(); + } + return false; + } + + // Fast path: check method-specific routes first, then wildcard routes (only on first routing attempt) + $methodsToCheck = [$requestMethod, '*']; + foreach ($methodsToCheck as $method) { + if (isset($this->routesByMethod[$method])) { + foreach ($this->routesByMethod[$method] as $route) { + if ($route->matchUrl($requestUrl, $this->caseSensitive)) { + $this->executedRoute = $route; + // Set iterator position to this route for potential next() calls + $this->index = array_search($route, $this->routes, true); + return $route; + } + } + } + } + + // If no exact match found, check all routes for 405 (method not allowed) cases + // This maintains the original behavior where we capture routes that match URL but not method + foreach ($this->routes as $route) { + if ($route->matchUrl($requestUrl, $this->caseSensitive) && !$route->matchMethod($requestMethod)) { + $this->executedRoute = $route; // Capture for 405 error in Engine + // Don't return false yet, continue checking for other potential matches } - $this->next(); } return false; diff --git a/tests/EngineTest.php b/tests/EngineTest.php index 3e81c4fd..256efb57 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -174,6 +174,108 @@ public function getInitializedVar() $engine->start(); } + public function testDoubleReturnTrueRoutesContinueIteration(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/someRoute'; + + $engine = new class extends Engine { + public function getInitializedVar() + { + return $this->initialized; + } + }; + + // First route that returns true (should continue routing) + $engine->route('/someRoute', function () { + echo 'first route ran, '; + return true; + }, true); + + // Second route that should be found and executed + $engine->route('/someRoute', function () { + echo 'second route executed!'; + }, true); + + $this->expectOutputString('first route ran, second route executed!'); + $engine->start(); + } + + public function testDoubleReturnTrueWithMethodMismatchDuringIteration(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/someRoute'; + + $engine = new class extends Engine { + public function getInitializedVar() + { + return $this->initialized; + } + + public function getLoader() + { + return $this->loader; + } + }; + + // Mock response to prevent actual headers + $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; + } + }; + }); + + // First route that returns true and matches POST + $engine->route('POST /someRoute', function () { + echo 'first POST route ran, '; + return true; + }, true); + + // Second route that matches URL but wrong method (GET) - should be captured for 405 + $engine->route('GET /someRoute', function () { + echo 'should not execute'; + }, true); + + // Third route that matches POST and should execute + $engine->route('POST /someRoute', function () { + echo 'second POST route executed!'; + }, true); + + $this->expectOutputString('first POST route ran, second POST route executed!'); + $engine->start(); + } + + public function testIteratorReachesEndWithoutMatch(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/someRoute'; + + $engine = new class extends Engine { + public function getInitializedVar() + { + return $this->initialized; + } + }; + + // Route that returns true (continues iteration) + $engine->route('/someRoute', function () { + echo 'first route ran, '; + return true; + }, true); + + // Route with different URL that won't match + $engine->route('/differentRoute', function () { + echo 'should not execute'; + }, true); + + // No more matching routes - should reach end of iterator and return 404 + $this->expectOutputString('

404 Not Found

The page you have requested could not be found.

'); + $engine->start(); + } + public function testDoubleStart() { $engine = new Engine(); diff --git a/tests/performance/index.php b/tests/performance/index.php new file mode 100644 index 00000000..2d4d7cb5 --- /dev/null +++ b/tests/performance/index.php @@ -0,0 +1,120 @@ +Available Test Routes:"; + echo "

Performance Test URLs:

"; + echo "

Static routes: /route0, /route8, /route16, /route24, /route32, /route40, /route48

"; + echo "

Param routes: /user1/123, /user9/456, /user17/789

"; + echo "

Complex routes: /post2/tech/article, /api3/123, /file6/test.txt

"; +}); + + +for ($i = 0; $i < 50; $i++) { + $route_type = $i % 8; + + switch ($route_type) { + case 0: + // Simple static routes + Flight::route("GET /route{$i}", function () use ($i) { + echo "This is static route {$i}"; + }); + break; + + case 1: + // Routes with single parameter + Flight::route("GET /user{$i}/@id", function ($id) use ($i) { + echo "User route {$i} with ID: {$id}"; + }); + break; + + case 2: + // Routes with multiple parameters + Flight::route("GET /post{$i}/@category/@slug", function ($category, $slug) use ($i) { + echo "Post route {$i}: {$category}/{$slug}"; + }); + break; + + case 3: + // Routes with regex constraints + Flight::route("GET /api{$i}/@id:[0-9]+", function ($id) use ($i) { + echo "API route {$i} with numeric ID: {$id}"; + }); + break; + + case 4: + // POST routes with parameters + Flight::route("POST /submit{$i}/@type", function ($type) use ($i) { + echo "POST route {$i} with type: {$type}"; + }); + break; + + case 5: + // Grouped routes + Flight::group("/admin{$i}", function () use ($i) { + Flight::route("GET /dashboard", function () use ($i) { + echo "Admin dashboard {$i}"; + }); + Flight::route("GET /users/@id:[0-9]+", function ($id) use ($i) { + echo "Admin user {$i}: {$id}"; + }); + }); + break; + + case 6: + // Complex regex patterns + Flight::route("GET /file{$i}/@path:.*", function ($path) use ($i) { + echo "File route {$i} with path: {$path}"; + }); + break; + + case 7: + // Multiple HTTP methods + Flight::route("GET|POST|PUT /resource{$i}/@id", function ($id) use ($i) { + echo "Multi-method route {$i} for resource: {$id}"; + }); + break; + } +} +// Add some predictable routes for easy performance testing +Flight::route('GET /test-static', function () { + $memory_start = memory_get_usage(); + $memory_peak = memory_get_peak_usage(); + echo "Static test route"; + if (isset($_GET['memory'])) { + echo "\nMemory: " . round($memory_peak / 1024, 2) . " KB"; + } +}); + +Flight::route('GET /test-param/@id', function ($id) { + $memory_start = memory_get_usage(); + $memory_peak = memory_get_peak_usage(); + echo "Param test route: {$id}"; + if (isset($_GET['memory'])) { + echo "\nMemory: " . round($memory_peak / 1024, 2) . " KB"; + } +}); + +Flight::route('GET /test-complex/@category/@slug', function ($category, $slug) { + $memory_start = memory_get_usage(); + $memory_peak = memory_get_peak_usage(); + echo "Complex test route: {$category}/{$slug}"; + if (isset($_GET['memory'])) { + echo "\nMemory: " . round($memory_peak / 1024, 2) . " KB"; + } +}); +Flight::start(); diff --git a/tests/performance/performance_tests.sh b/tests/performance/performance_tests.sh new file mode 100644 index 00000000..4b277d91 --- /dev/null +++ b/tests/performance/performance_tests.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +URL="http://firefly74.com/flight-test/test-static" +REQUESTS=1000 +CONCURRENCY=10 +ITERATIONS=10 + +declare -a times=() +total=0 + +echo "Benchmarking: $URL" +echo "Requests per test: $REQUESTS" +echo "Concurrency: $CONCURRENCY" +echo "Iterations: $ITERATIONS" +echo "========================================" + +# First, get a baseline memory reading +echo "Getting memory baseline..." +memory_response=$(curl -s "${URL}?memory=1") +baseline_memory=$(echo "$memory_response" | grep "Memory:" | awk '{print $2}') +echo "Baseline memory usage: ${baseline_memory} KB" +echo "----------------------------------------" + +for i in $(seq 1 $ITERATIONS); do + printf "Run %2d/%d: " $i $ITERATIONS + + # Run ab and extract time per request + result=$(ab -n $REQUESTS -c $CONCURRENCY $URL 2>/dev/null) + time_per_request=$(echo "$result" | grep "Time per request:" | head -1 | awk '{print $4}') + requests_per_sec=$(echo "$result" | grep "Requests per second:" | awk '{print $4}') + + times+=($time_per_request) + total=$(echo "$total + $time_per_request" | bc -l) + + printf "%.3f ms (%.2f req/s)\n" $time_per_request $requests_per_sec +done + +# Calculate statistics +average=$(echo "scale=3; $total / $ITERATIONS" | bc -l) + +# Find min and max +min=${times[0]} +max=${times[0]} +for time in "${times[@]}"; do + if (( $(echo "$time < $min" | bc -l) )); then + min=$time + fi + if (( $(echo "$time > $max" | bc -l) )); then + max=$time + fi +done + +echo "========================================" +echo "Results:" +echo "Average Time per Request: $average ms" +echo "Min Time per Request: $min ms" +echo "Max Time per Request: $max ms" +echo "Range: $(echo "scale=3; $max - $min" | bc -l) ms" +echo "Baseline Memory Usage: ${baseline_memory} KB" + +# Get final memory reading after stress test +echo "----------------------------------------" +echo "Getting post-test memory reading..." +final_memory_response=$(curl -s "${URL}?memory=1") +final_memory=$(echo "$final_memory_response" | grep "Memory:" | awk '{print $2}') +echo "Final memory usage: ${final_memory} KB" \ No newline at end of file From 204ffbb024ca82f07a8375b156859cc74c962886 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sun, 17 Aug 2025 11:22:00 -0600 Subject: [PATCH 294/331] Update tests/performance/performance_tests.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/performance/performance_tests.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/performance/performance_tests.sh b/tests/performance/performance_tests.sh index 4b277d91..12978f57 100644 --- a/tests/performance/performance_tests.sh +++ b/tests/performance/performance_tests.sh @@ -1,6 +1,7 @@ #!/bin/bash -URL="http://firefly74.com/flight-test/test-static" +# Allow URL to be set via environment variable or first command-line argument, default to localhost for safety +URL="${URL:-${1:-http://localhost:8080/test-static}}" REQUESTS=1000 CONCURRENCY=10 ITERATIONS=10 From a85790a20cd8bae06da5b6b7b9b3d71100aec1be Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sun, 17 Aug 2025 11:24:15 -0600 Subject: [PATCH 295/331] Update flight/net/Request.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- flight/net/Request.php | 1 + 1 file changed, 1 insertion(+) diff --git a/flight/net/Request.php b/flight/net/Request.php index 2d0c1099..5d0c9b5f 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -422,6 +422,7 @@ public static function parseQuery(string $url): array if ($query === '') { return []; } + $params = []; parse_str($query, $params); return $params; } From 3884da4bdcd93d31b215599194f8de33cc146410 Mon Sep 17 00:00:00 2001 From: Arshid Date: Sun, 28 Sep 2025 09:01:09 +0530 Subject: [PATCH 296/331] php85 --- tests/PdoWrapperTest.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/PdoWrapperTest.php b/tests/PdoWrapperTest.php index 39d2b603..d2eeb458 100644 --- a/tests/PdoWrapperTest.php +++ b/tests/PdoWrapperTest.php @@ -128,7 +128,9 @@ public function testPullDataFromDsn(): void // Testing protected method using reflection $reflection = new ReflectionClass($this->pdo_wrapper); $method = $reflection->getMethod('pullDataFromDsn'); - $method->setAccessible(true); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } // Test SQLite DSN $sqliteDsn = 'sqlite::memory:'; @@ -205,7 +207,9 @@ public function testLogQueries(): void // Verify metrics are reset after logging $reflection = new ReflectionClass($trackingPdo); $property = $reflection->getProperty('queryMetrics'); - $property->setAccessible(true); + if (PHP_VERSION_ID < 80100) { + $property->setAccessible(true); + } $this->assertCount(0, $property->getValue($trackingPdo)); } } From 053227b3b0ecd5ae223daf64e71a54cecbabf304 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Tue, 30 Sep 2025 17:33:24 +0100 Subject: [PATCH 297/331] adjusted phpstan linting to be friendlier and changed download() to also specify file name --- flight/Engine.php | 113 +++++++++++++------------- flight/Flight.php | 142 +++++++++++++++------------------ flight/core/Dispatcher.php | 16 +++- flight/database/PdoWrapper.php | 6 +- flight/net/Request.php | 50 ++++++------ flight/net/Response.php | 13 ++- 6 files changed, 171 insertions(+), 169 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index 59741584..858ab420 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -24,60 +24,58 @@ * It is responsible for loading an HTTP request, running the assigned services, * and generating an HTTP response. * - * @license MIT, http://flightphp.com/license - * @copyright Copyright (c) 2011, Mike Cao + * @license MIT, https://docs.flightphp.com/license + * @copyright Copyright (c) 2011-2025, Mike Cao , n0nag0n * - * # Core methods - * @method void start() Starts engine - * @method void stop() Stops framework and outputs current response - * @method void halt(int $code = 200, string $message = '', bool $actuallyExit = true) Stops processing and returns a given response. + * @method void start() + * @method void stop() + * @method void halt(int $code = 200, string $message = '', bool $actuallyExit = true) + * @method EventDispatcher eventDispatcher() + * @method Route route(string $pattern, callable|string|array $callback, bool $pass_route = false, string $alias = '') + * @method void group(string $pattern, callable $callback, array $group_middlewares = []) + * @method Route post(string $pattern, callable|string|array $callback, bool $pass_route = false, string $alias = '') + * @method Route put(string $pattern, callable|string|array $callback, bool $pass_route = false, string $alias = '') + * @method Route patch(string $pattern, callable|string|array $callback, bool $pass_route = false, string $alias = '') + * @method Route delete(string $pattern, callable|string|array $callback, bool $pass_route = false, string $alias = '') + * @method void resource(string $pattern, string $controllerClass, array $methods = []) + * @method Router router() + * @method string getUrl(string $alias) + * @method void render(string $file, array $data = null, string $key = null) + * @method View view() + * @method void onEvent(string $event, callable $callback) + * @method void triggerEvent(string $event, ...$args) + * @method Request request() + * @method Response response() + * @method void error(Throwable $e) + * @method void notFound() + * @method void redirect(string $url, int $code = 303) + * @method void json($data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * @method void jsonHalt($data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * @method void jsonp($data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * @method void etag(string $id, string $type = 'strong') + * @method void lastModified(int $time) + * @method void download(string $filePath) * - * # Class registration - * @method EventDispatcher eventDispatcher() Gets event dispatcher + * @phpstan-template EngineTemplate of object + * @phpstan-method void registerContainerHandler(ContainerInterface|callable(class-string $id, array $params): ?EngineTemplate $containerHandler) + * @phpstan-method Route route(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method void group(string $pattern, callable $callback, (class-string|callable|array{0: class-string, 1: string})[] $group_middlewares = []) + * @phpstan-method Route post(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method Route put(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method Route patch(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method Route delete(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method void resource(string $pattern, class-string $controllerClass, array> $methods = []) + * @phpstan-method string getUrl(string $alias, array $params = []) + * @phpstan-method void before(string $name, Closure(array &$params, string &$output): (void|false) $callback) + * @phpstan-method void after(string $name, Closure(array &$params, string &$output): (void|false) $callback) + * @phpstan-method void set(string|iterable $key, ?mixed $value = null) + * @phpstan-method mixed get(?string $key) + * @phpstan-method void render(string $file, ?array $data = null, ?string $key = null) + * @phpstan-method void json(mixed $data, int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) + * @phpstan-method void jsonHalt(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * @phpstan-method void jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) * - * # Routing - * @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, (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|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|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|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|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') - * Routes a DELETE URL to a callback function. - * @method void resource(string $pattern, class-string $controllerClass, array> $methods = []) - * Adds standardized RESTful routes for a controller. - * @method Router router() Gets router - * @method string getUrl(string $alias) Gets a url from an alias - * - * # Views - * @method void render(string $file, ?array $data = null, ?string $key = null) Renders template - * @method 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 - * @method void error(Throwable $e) Sends an HTTP 500 response for any errors. - * @method void notFound() Sends an HTTP 404 response when a URL is not found. - * @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. - * - * # 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 + * Note: IDEs will use standard @method tags for autocompletion, while PHPStan will use @phpstan-* tags for advanced type checking. * * phpcs:disable PSR2.Methods.MethodDeclaration.Underscore */ @@ -118,7 +116,7 @@ class Engine /** Class loader. */ protected Loader $loader; - /** Method and class dispatcher. */ + /** @var Dispatcher Method and class dispatcher. */ protected Dispatcher $dispatcher; /** Event dispatcher. */ @@ -370,7 +368,7 @@ public function get(?string $key = null) * * @param string|iterable $key * Variable name as `string` or an iterable of `'varName' => $varValue` - * @param mixed $value Ignored if `$key` is an `iterable` + * @param ?mixed $value Ignored if `$key` is an `iterable` */ public function set($key, $value = null): void { @@ -981,14 +979,15 @@ public function _jsonp( * Downloads a file * * @param string $filePath The path to the file to download + * @param string $fileName The name the file should be downloaded as * * @throws Exception If the file cannot be found * * @return void */ - public function _download(string $filePath): void + public function _download(string $filePath, string $fileName = ''): void { - $this->response()->downloadFile($filePath); + $this->response()->downloadFile($filePath, $fileName); } /** @@ -1020,8 +1019,8 @@ public function _etag(string $id, string $type = 'strong'): void public function _lastModified(int $time): void { $this->response()->header('Last-Modified', gmdate('D, d M Y H:i:s \G\M\T', $time)); - $request = $this->request(); - $ifModifiedSince = $request->header('If-Modified-Since'); + $request = $this->request(); + $ifModifiedSince = $request->header('If-Modified-Since'); $hit = isset($ifModifiedSince) && strtotime($ifModifiedSince) === $time; $this->triggerEvent('flight.cache.checked', 'lastModified', $hit, 0.0); diff --git a/flight/Flight.php b/flight/Flight.php index 1c648266..2b69e167 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -16,90 +16,76 @@ /** * The Flight class is a static representation of the framework. * - * @license MIT, http://flightphp.com/license - * @copyright Copyright (c) 2011, Mike Cao + * @license MIT, https://docs.flightphp.com/license + * @copyright Copyright (c) 2011-2025, Mike Cao , n0nag0n * - * @template T of object - * - * # Core methods - * @method static void start() Starts the framework. - * @method static void path(string $dir) Adds a path for autoloading classes. - * @method static void stop(?int $code = null) Stops the framework and sends a response. + * @method static void start() + * @method static void path(string $dir) + * @method static void stop(int $code = null) * @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 register(string $name, string $class, array $params = [], callable $callback = null) * @method static void unregister(string $methodName) - * Unregisters a class to a framework method. - * @method static void registerContainerHandler(ContainerInterface|callable(class-string $id, array $params): ?T $containerHandler) Registers a container handler. - * - * # Class registration - * @method EventDispatcher eventDispatcher() Gets event dispatcher - * - * # Class registration - * @method EventDispatcher eventDispatcher() Gets event dispatcher - * - * # Routing - * @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, (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|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|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|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|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') - * Routes a DELETE URL to a callback function. - * @method static void resource(string $pattern, class-string $controllerClass, array> $methods = []) - * Adds standardized RESTful routes for a controller. - * @method static Router router() Returns Router instance. - * @method static string getUrl(string $alias, array $params = []) Gets a url from an alias - * @method static void map(string $name, callable $callback) Creates a custom framework method. - * - * # Filters - * @method static void before(string $name, Closure(array &$params, string &$output): (void|false) $callback) - * Adds a filter before a framework method. - * @method static void after(string $name, Closure(array &$params, string &$output): (void|false) $callback) - * Adds a filter after a framework method. + * @method static void registerContainerHandler($containerHandler) + * @method static EventDispatcher eventDispatcher() + * @method static Route route(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method static void group(string $pattern, callable $callback, array $group_middlewares = []) + * @method static Route post(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method static Route put(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method static Route patch(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method static Route delete(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method static void resource(string $pattern, string $controllerClass, array $methods = []) + * @method static Router router() + * @method static string getUrl(string $alias, array $params = []) + * @method static void map(string $name, callable $callback) + * @method static void before(string $name, Closure $callback) + * @method static void after(string $name, Closure $callback) + * @method static void set($key, $value) + * @method static mixed get($key = null) + * @method static bool has(string $key) + * @method static void clear($key = null) + * @method static void render(string $file, array $data = null, string $key = null) + * @method static View view() + * @method void onEvent(string $event, callable $callback) + * @method void triggerEvent(string $event, ...$args) + * @method static Request request() + * @method static Response response() + * @method static void redirect(string $url, int $code = 303) + * @method static void json($data, int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) + * @method static void jsonHalt($data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * @method static void jsonp($data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) + * @method static void error(Throwable $exception) + * @method static void notFound() + * @method static void etag(string $id, string $type = 'strong') + * @method static void lastModified(int $time) + * @method static void download(string $filePath) * - * # Variables - * @method static void set(string|iterable $key, mixed $value) Sets a variable. - * @method static mixed get(?string $key) Gets a variable. - * @method static bool has(string $key) Checks if a variable is set. - * @method static void clear(?string $key = null) Clears a variable. + * @phpstan-template FlightTemplate of object + * @phpstan-method static void register(string $name, class-string $class, array $params = [], (callable(class-string $class, array $params): void)|null $callback = null) + * @phpstan-method static void registerContainerHandler(ContainerInterface|callable(class-string $id, array $params): ?FlightTemplate $containerHandler) + * @phpstan-method static Route route(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method static void group(string $pattern, callable $callback, (class-string|callable|array{0: class-string, 1: string})[] $group_middlewares = []) + * @phpstan-method static Route post(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method static Route put(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method static Route patch(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method static Route delete(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method static void resource(string $pattern, class-string $controllerClass, array> $methods = []) + * @phpstan-method static string getUrl(string $alias, array $params = []) + * @phpstan-method static void before(string $name, Closure(array &$params, string &$output): (void|false) $callback) + * @phpstan-method static void after(string $name, Closure(array &$params, string &$output): (void|false) $callback) + * @phpstan-method static void set(string|iterable $key, mixed $value) + * @phpstan-method static mixed get(?string $key) + * @phpstan-method static void render(string $file, ?array $data = null, ?string $key = null) + * @phpstan-method static void json(mixed $data, int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) + * @phpstan-method static void jsonHalt(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * @phpstan-method static void jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) * - * # Views - * @method static void render(string $file, ?array $data = null, ?string $key = null) - * 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 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 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 + * Note: IDEs will use standard @method tags for autocompletion, while PHPStan will use @phpstan-* tags for advanced type checking. */ class Flight { - /** Framework engine. */ + /** + * @var Engine + */ private static Engine $engine; /** @@ -138,7 +124,7 @@ public static function __callStatic(string $name, array $params) return self::app()->{$name}(...$params); } - /** @return Engine Application instance */ + /** @return Engine Application instance */ public static function app(): Engine { return self::$engine ?? self::$engine = new Engine(); @@ -147,7 +133,7 @@ public static function app(): Engine /** * Set the engine instance * - * @param Engine $engine Vroom vroom! + * @param Engine $engine Vroom vroom! */ public static function setEngine(Engine $engine): void { diff --git a/flight/core/Dispatcher.php b/flight/core/Dispatcher.php index 9d1873d6..2d45a49c 100644 --- a/flight/core/Dispatcher.php +++ b/flight/core/Dispatcher.php @@ -20,6 +20,7 @@ * * @license MIT, http://flightphp.com/license * @copyright Copyright (c) 2011, Mike Cao + * @phpstan-template EngineTemplate of object */ class Dispatcher { @@ -29,7 +30,7 @@ class Dispatcher /** 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 Mapped events. */ @@ -77,6 +78,13 @@ public function setContainerHandler($containerHandler): void ); } + /** + * Sets the engine instance + * + * @param Engine $engine Flight instance + * + * @return void + */ public function setEngine(Engine $engine): void { $this->engine = $engine; @@ -88,8 +96,9 @@ public function setEngine(Engine $engine): void * @param string $name Event name. * @param array $params Callback parameters. * - * @return mixed Output of callback * @throws Exception If event name isn't found or if event throws an `Exception`. + * + * @return mixed Output of callback */ public function run(string $name, array $params = []) { @@ -102,8 +111,9 @@ public function run(string $name, array $params = []) /** * @param array &$params * - * @return $this * @throws Exception + * + * @return $this */ protected function runPreFilters(string $eventName, array &$params): self { diff --git a/flight/database/PdoWrapper.php b/flight/database/PdoWrapper.php index 7cf74cf9..ff13a4c3 100644 --- a/flight/database/PdoWrapper.php +++ b/flight/database/PdoWrapper.php @@ -100,9 +100,9 @@ public function fetchField(string $sql, array $params = []) * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" * @param array $params - Ex: [ $something ] * - * @return Collection + * @return Collection|array */ - public function fetchRow(string $sql, array $params = []): Collection + public function fetchRow(string $sql, array $params = []) { $sql .= stripos($sql, 'LIMIT') === false ? ' LIMIT 1' : ''; $result = $this->fetchAll($sql, $params); @@ -120,7 +120,7 @@ public function fetchRow(string $sql, array $params = []): Collection * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" * @param array $params - Ex: [ $something ] * - * @return array + * @return array> */ public function fetchAll(string $sql, array $params = []) { diff --git a/flight/net/Request.php b/flight/net/Request.php index 5d0c9b5f..8368c28e 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -156,16 +156,16 @@ public function __construct(array $config = []) { // Default properties if (empty($config) === true) { - $scheme = $this->getScheme(); - $url = $this->getVar('REQUEST_URI', '/'); - if (strpos($url, '@') !== false) { - $url = str_replace('@', '%40', $url); - } - $base = $this->getVar('SCRIPT_NAME', ''); - if (strpos($base, ' ') !== false || strpos($base, '\\') !== false) { - $base = str_replace(['\\', ' '], ['/', '%20'], $base); - } - $base = dirname($base); + $scheme = $this->getScheme(); + $url = $this->getVar('REQUEST_URI', '/'); + if (strpos($url, '@') !== false) { + $url = str_replace('@', '%40', $url); + } + $base = $this->getVar('SCRIPT_NAME', ''); + if (strpos($base, ' ') !== false || strpos($base, '\\') !== false) { + $base = str_replace(['\\', ' '], ['/', '%20'], $base); + } + $base = dirname($base); $config = [ 'url' => $url, 'base' => $base, @@ -261,9 +261,9 @@ public function getBody(): string $method = $this->method ?? $this->getMethod(); - if (in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true) === true) { - $body = file_get_contents($this->stream_path); - } + if (in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true) === true) { + $body = file_get_contents($this->stream_path); + } $this->body = $body; @@ -305,7 +305,7 @@ public static function getProxyIpAddress(): string $flags = \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE; foreach ($forwarded as $key) { - $serverVar = self::getVar($key); + $serverVar = self::getVar($key); if ($serverVar !== '') { sscanf($serverVar, '%[^,]', $ip); if (filter_var($ip, \FILTER_VALIDATE_IP, $flags) !== false) { @@ -414,17 +414,17 @@ public function getBaseUrl(): string */ public static function parseQuery(string $url): array { - $queryPos = strpos($url, '?'); - if ($queryPos === false) { - return []; - } - $query = substr($url, $queryPos + 1); - if ($query === '') { - return []; - } - $params = []; - parse_str($query, $params); - return $params; + $queryPos = strpos($url, '?'); + if ($queryPos === false) { + return []; + } + $query = substr($url, $queryPos + 1); + if ($query === '') { + return []; + } + $params = []; + parse_str($query, $params); + return $params; } /** diff --git a/flight/net/Response.php b/flight/net/Response.php index b364b13f..e3b3cc6f 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -324,7 +324,7 @@ public function sendHeaders(): self ); // @codeCoverageIgnoreEnd } else { - $serverProtocol = Request::getVar('SERVER_PROTOCOL') ?: 'HTTP/1.1'; + $serverProtocol = Request::getVar('SERVER_PROTOCOL') ?: 'HTTP/1.1'; $this->setRealHeader( sprintf( '%s %d %s', @@ -484,10 +484,13 @@ protected function processResponseCallbacks(): void * Downloads a file. * * @param string $filePath The path to the file to be downloaded. + * @param string $fileName The name the downloaded file should have. If not provided, the name of the file on disk will be used. + * + * @throws Exception If the file cannot be found. * * @return void */ - public function downloadFile(string $filePath): void + public function downloadFile(string $filePath, string $fileName = ''): void { if (file_exists($filePath) === false) { throw new Exception("$filePath cannot be found."); @@ -498,10 +501,14 @@ public function downloadFile(string $filePath): void $mimeType = mime_content_type($filePath); $mimeType = $mimeType !== false ? $mimeType : 'application/octet-stream'; + if ($fileName === '') { + $fileName = basename($filePath); + } + $this->send(); $this->setRealHeader('Content-Description: File Transfer'); $this->setRealHeader('Content-Type: ' . $mimeType); - $this->setRealHeader('Content-Disposition: attachment; filename="' . basename($filePath) . '"'); + $this->setRealHeader('Content-Disposition: attachment; filename="' . $fileName . '"'); $this->setRealHeader('Expires: 0'); $this->setRealHeader('Cache-Control: must-revalidate'); $this->setRealHeader('Pragma: public'); From 4d9f0f42bdfb2929994d7ee8a7620dbfd7d48478 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Tue, 30 Sep 2025 18:15:10 +0100 Subject: [PATCH 298/331] Added response test and did some sanitizing --- flight/net/Response.php | 4 +++- tests/EngineTest.php | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/flight/net/Response.php b/flight/net/Response.php index e3b3cc6f..9587ddf8 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -484,7 +484,7 @@ protected function processResponseCallbacks(): void * Downloads a file. * * @param string $filePath The path to the file to be downloaded. - * @param string $fileName The name the downloaded file should have. If not provided, the name of the file on disk will be used. + * @param string $fileName The name the downloaded file should have. If not provided or is an empty string, the name of the file on disk will be used. * * @throws Exception If the file cannot be found. * @@ -501,6 +501,8 @@ public function downloadFile(string $filePath, string $fileName = ''): void $mimeType = mime_content_type($filePath); $mimeType = $mimeType !== false ? $mimeType : 'application/octet-stream'; + // Sanitize filename to prevent header injection + $fileName = str_replace(["\r", "\n", '"'], '', $fileName); if ($fileName === '') { $fileName = basename($filePath); } diff --git a/tests/EngineTest.php b/tests/EngineTest.php index 256efb57..4b3dbc9e 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -1086,11 +1086,13 @@ public function getLoader() // doing this so we can overwrite some parts of the response $engine->getLoader()->register('response', function () { return new class extends Response { + public $headersSent = []; public function setRealHeader( string $header_string, bool $replace = true, int $response_code = 0 ): self { + $this->headersSent[] = $header_string; return $this; } }; @@ -1100,6 +1102,37 @@ public function setRealHeader( $streamPath = stream_get_meta_data($tmpfile)['uri']; $this->expectOutputString('I am a teapot'); $engine->download($streamPath); + $this->assertContains('Content-Disposition: attachment; filename="'.basename($streamPath).'"', $engine->response()->headersSent); + } + + public function testDownloadWithDefaultFileName(): void + { + $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 $headersSent = []; + public function setRealHeader( + string $header_string, + bool $replace = true, + int $response_code = 0 + ): self { + $this->headersSent[] = $header_string; + 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, 'something.txt'); + $this->assertContains('Content-Disposition: attachment; filename="something.txt"', $engine->response()->headersSent); } public function testDownloadBadPath() { From c9da0675d9c8b6e94a680fcc21a15c2f6fa9e38f Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Fri, 3 Oct 2025 21:31:10 +0100 Subject: [PATCH 299/331] added accept header negotiations, OPTIONS method Allow header and, better method handling. --- flight/Engine.php | 26 +++++++++++++++++++++++++- flight/Flight.php | 1 + flight/net/Request.php | 21 +++++++++++++++++++++ flight/net/Router.php | 5 +++++ tests/EngineTest.php | 20 ++++++++++++++++++-- tests/RequestTest.php | 23 +++++++++++++++++++++++ tests/RouterTest.php | 12 +++++++++++- tests/commands/RouteCommandTest.php | 28 ++++++++++++++-------------- 8 files changed, 118 insertions(+), 18 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index 858ab420..0cfaf683 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -48,6 +48,7 @@ * @method Response response() * @method void error(Throwable $e) * @method void notFound() + * @method void methodNotFound(Route $route) * @method void redirect(string $url, int $code = 303) * @method void json($data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) * @method void jsonHalt($data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) @@ -91,6 +92,7 @@ class Engine 'halt', 'error', 'notFound', + 'methodNotFound', 'render', 'redirect', 'etag', @@ -562,6 +564,15 @@ public function _start(): void $params[] = $route; } + // OPTIONS request handling + if ($request->method === 'OPTIONS') { + $allowedMethods = $route->methods; + $response->status(204) + ->header('Allow', implode(', ', $allowedMethods)) + ->send(); + return; + } + // If this route is to be streamed, we need to output the headers now if ($route->is_streamed === true) { if (count($route->streamed_headers) > 0) { @@ -644,7 +655,7 @@ public function _start(): void // 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'))); + $this->methodNotFound($lastRouteExecuted); } else { $this->notFound(); } @@ -839,6 +850,19 @@ public function _notFound(): void ->send(); } + /** + * Function to run if the route has been found but not the method. + * + * @param Route $route - The executed route + * + * @return void + */ + public function _methodNotFound(Route $route): void + { + $this->response()->setHeader('Allow', implode(', ', $route->methods)); + $this->halt(405, 'Method Not Allowed. Allowed Methods are: ' . implode(', ', $route->methods), empty(getenv('PHPUNIT_TEST'))); + } + /** * Redirects the current request to another URL. * diff --git a/flight/Flight.php b/flight/Flight.php index 2b69e167..c5692ebe 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -55,6 +55,7 @@ * @method static void jsonp($data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) * @method static void error(Throwable $exception) * @method static void notFound() + * @method static void methodNotFound(Route $route) * @method static void etag(string $id, string $type = 'strong') * @method static void lastModified(int $time) * @method static void download(string $filePath) diff --git a/flight/net/Request.php b/flight/net/Request.php index 8368c28e..c56f1f47 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -449,6 +449,27 @@ public static function getScheme(): string return 'http'; } + /** + * Negotiates the best content type from the Accept header. + * + * @param array $supported List of supported content types. + * + * @return ?string The negotiated content type. + */ + public function negotiateContentType(array $supported): ?string + { + $accept = $this->header('Accept') ?? ''; + if ($accept === '') { + return $supported[0]; + } + foreach ($supported as $type) { + if (stripos($accept, $type) !== false) { + return $type; + } + } + return null; + } + /** * Retrieves the array of uploaded files. * diff --git a/flight/net/Router.php b/flight/net/Router.php index 8f5555e2..8c2c6e1e 100644 --- a/flight/net/Router.php +++ b/flight/net/Router.php @@ -126,6 +126,11 @@ public function map(string $pattern, $callback, bool $pass_route = false, string if (in_array('GET', $methods, true) === true && in_array('HEAD', $methods, true) === false) { $methods[] = 'HEAD'; } + + // Always allow an OPTIONS request + if (in_array('OPTIONS', $methods, true) === false) { + $methods[] = 'OPTIONS'; + } } // And this finishes it off. diff --git a/tests/EngineTest.php b/tests/EngineTest.php index 4b3dbc9e..91e9f3bd 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -409,6 +409,21 @@ public function testHeadRoute(): void $this->expectOutputString(''); } + public function testOptionsRoute(): void + { + $engine = new Engine(); + $engine->route('GET /someRoute', function () { + echo 'i ran'; + }, true); + $engine->request()->method = 'OPTIONS'; + $engine->request()->url = '/someRoute'; + $engine->start(); + + // No body should be sent + $this->expectOutputString(''); + $this->assertEquals('GET, HEAD, OPTIONS', $engine->response()->headers()['Allow']); + } + public function testHalt(): void { $engine = new class extends Engine { @@ -1070,9 +1085,10 @@ public function setRealHeader( $engine->start(); - $this->expectOutputString('Method Not Allowed'); + $this->expectOutputString('Method Not Allowed. Allowed Methods are: POST, OPTIONS'); $this->assertEquals(405, $engine->response()->status()); - $this->assertEquals('Method Not Allowed', $engine->response()->getBody()); + $this->assertEquals('Method Not Allowed. Allowed Methods are: POST, OPTIONS', $engine->response()->getBody()); + $this->assertEquals('POST, OPTIONS', $engine->response()->headers()['Allow']); } public function testDownload(): void diff --git a/tests/RequestTest.php b/tests/RequestTest.php index a84841be..214b26e4 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -377,4 +377,27 @@ public function testParseQueryWithEmptyQueryString(): void $result = Request::parseQuery('/foo?'); $this->assertEquals([], $result); } + + public function testNegotiateContentType(): void + { + // Find best match first + $_SERVER['HTTP_ACCEPT'] = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'; + $request = new Request(); + $this->assertEquals('application/xml', $request->negotiateContentType(['application/xml', 'application/json', 'text/html'])); + + // Find the first match + $_SERVER['HTTP_ACCEPT'] = 'application/json,text/html'; + $request = new Request(); + $this->assertEquals('application/json', $request->negotiateContentType(['application/json', 'text/html'])); + + // No match found + $_SERVER['HTTP_ACCEPT'] = 'application/xml'; + $request = new Request(); + $this->assertNull($request->negotiateContentType(['application/json', 'text/html'])); + + // No header present, return first supported type + $_SERVER['HTTP_ACCEPT'] = ''; + $request = new Request(); + $this->assertEquals('application/json', $request->negotiateContentType(['application/json', 'text/html'])); + } } diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 58adb8a9..c0ed3c19 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -137,7 +137,7 @@ public function testGetRouteShortcut(): void public function testHeadRouteShortcut(): void { $route = $this->router->get('/path', [$this, 'ok']); - $this->assertEquals(['GET', 'HEAD'], $route->methods); + $this->assertEquals(['GET', 'HEAD', 'OPTIONS'], $route->methods); $this->request->url = '/path'; $this->request->method = 'HEAD'; $this->check(''); @@ -172,6 +172,16 @@ public function testGetPostRoute(): void $this->check('OK'); } + public function testOptionsRouteShortcut(): void + { + $route = $this->router->map('GET|POST /path', [$this, 'ok']); + $this->assertEquals(['GET', 'POST', 'HEAD', 'OPTIONS'], $route->methods); + $this->request->url = '/path'; + $this->request->method = 'OPTIONS'; + + $this->check('OK'); + } + public function testPutRouteShortcut(): void { $this->router->put('/path', [$this, 'ok']); diff --git a/tests/commands/RouteCommandTest.php b/tests/commands/RouteCommandTest.php index 7bcc1231..8683871f 100644 --- a/tests/commands/RouteCommandTest.php +++ b/tests/commands/RouteCommandTest.php @@ -106,15 +106,15 @@ public function testGetRoutes(): void $this->assertStringContainsString('Routes', 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 | - +---------+-----------+-------+----------+----------------+ + +---------+--------------------+-------+----------+----------------+ + | Pattern | Methods | Alias | Streamed | Middleware | + +---------+--------------------+-------+----------+----------------+ + | / | GET, HEAD, OPTIONS | | No | - | + | /post | POST, OPTIONS | | No | Closure | + | /delete | DELETE, OPTIONS | | No | - | + | /put | PUT, OPTIONS | | No | - | + | /patch | PATCH, OPTIONS | | No | Bad Middleware | + +---------+--------------------+-------+----------+----------------+ output; // phpcs:ignore $this->assertStringContainsString( @@ -133,11 +133,11 @@ public function testGetPostRoute(): void $this->assertStringContainsString('Routes', file_get_contents(static::$ou)); $expected = <<<'output' - +---------+---------+-------+----------+------------+ - | Pattern | Methods | Alias | Streamed | Middleware | - +---------+---------+-------+----------+------------+ - | /post | POST | | No | Closure | - +---------+---------+-------+----------+------------+ + +---------+---------------+-------+----------+------------+ + | Pattern | Methods | Alias | Streamed | Middleware | + +---------+---------------+-------+----------+------------+ + | /post | POST, OPTIONS | | No | Closure | + +---------+---------------+-------+----------+------------+ output; // phpcs:ignore $this->assertStringContainsString( From 095a46663f7090c549f5c3459f0dada893b3b5d9 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Fri, 3 Oct 2025 22:05:48 +0100 Subject: [PATCH 300/331] configured performance testing --- composer.json | 9 +++++++++ tests/performance/index.php | 2 +- tests/performance/performance_tests.sh | 13 +++++++++---- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 4be65242..96d45dbe 100644 --- a/composer.json +++ b/composer.json @@ -68,6 +68,15 @@ "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", + "test-performance": [ + "echo \"Running Performance Tests...\"", + "php -S localhost:8077 -t tests/performance/ > /dev/null 2>&1 & echo $! > server.pid", + "sleep 2", + "bash tests/performance/performance_tests.sh", + "kill `cat server.pid`", + "rm server.pid", + "echo \"Performance Tests Completed.\"" + ], "lint": "phpstan --no-progress --memory-limit=256M -cphpstan.neon", "beautify": "phpcbf --standard=phpcs.xml", "phpcs": "phpcs --standard=phpcs.xml -n", diff --git a/tests/performance/index.php b/tests/performance/index.php index 2d4d7cb5..a42ddf82 100644 --- a/tests/performance/index.php +++ b/tests/performance/index.php @@ -2,7 +2,7 @@ declare(strict_types=1); -require __DIR__ . '/vendor/autoload.php'; +require __DIR__ . '/../../vendor/autoload.php'; // Route to list all available test routes Flight::route('GET /', function () { diff --git a/tests/performance/performance_tests.sh b/tests/performance/performance_tests.sh index 12978f57..605e242f 100644 --- a/tests/performance/performance_tests.sh +++ b/tests/performance/performance_tests.sh @@ -1,7 +1,7 @@ #!/bin/bash # Allow URL to be set via environment variable or first command-line argument, default to localhost for safety -URL="${URL:-${1:-http://localhost:8080/test-static}}" +URL="${URL:-${1:-http://localhost:8077/test-static}}" REQUESTS=1000 CONCURRENCY=10 ITERATIONS=10 @@ -24,15 +24,20 @@ echo "----------------------------------------" for i in $(seq 1 $ITERATIONS); do printf "Run %2d/%d: " $i $ITERATIONS - + # Run ab and extract time per request result=$(ab -n $REQUESTS -c $CONCURRENCY $URL 2>/dev/null) time_per_request=$(echo "$result" | grep "Time per request:" | head -1 | awk '{print $4}') requests_per_sec=$(echo "$result" | grep "Requests per second:" | awk '{print $4}') - + + if [[ -z "$time_per_request" || ! "$time_per_request" =~ ^[0-9.]+$ ]]; then + echo "Warning: Could not parse time per request (ab output may be malformed)" + continue + fi + times+=($time_per_request) total=$(echo "$total + $time_per_request" | bc -l) - + printf "%.3f ms (%.2f req/s)\n" $time_per_request $requests_per_sec done From f0bd5dda8136e660919fb73184ef0d914024d9ab Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Thu, 16 Oct 2025 15:03:54 +0900 Subject: [PATCH 301/331] fixed multiple file upload errors --- flight/net/Request.php | 8 +++---- tests/RequestTest.php | 52 +++++++++++++++++++++++++++++++----------- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/flight/net/Request.php b/flight/net/Request.php index c56f1f47..13978429 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -477,7 +477,7 @@ public function negotiateContentType(array $supported): ?string */ public function getUploadedFiles(): array { - $files = []; + $uploadedFiles = []; $correctedFilesArray = $this->reArrayFiles($this->files); foreach ($correctedFilesArray as $keyName => $files) { foreach ($files as $file) { @@ -489,14 +489,14 @@ public function getUploadedFiles(): array $file['error'] ); if (count($files) > 1) { - $files[$keyName][] = $UploadedFile; + $uploadedFiles[$keyName][] = $UploadedFile; } else { - $files[$keyName] = $UploadedFile; + $uploadedFiles[$keyName] = $UploadedFile; } } } - return $files; + return $uploadedFiles; } /** diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 214b26e4..5dc1f1a1 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -330,31 +330,57 @@ public function testGetSingleFileUpload(): void public function testGetMultiFileUpload(): void { - $_FILES['files'] = [ + // Arrange: Setup multiple file upload arrays + $_FILES['files_1'] = [ 'name' => ['file1.txt', 'file2.txt'], 'type' => ['text/plain', 'text/plain'], 'size' => [123, 456], 'tmp_name' => ['/tmp/php123', '/tmp/php456'], 'error' => [0, 0] ]; + $_FILES['files_2'] = [ + 'name' => ['file3.txt', 'file4.txt'], + 'type' => ['text/html', 'application/json'], + 'size' => [789, 321], + 'tmp_name' => ['/tmp/php789', '/tmp/php321'], + 'error' => [0, 0] + ]; + // Act $request = new Request(); + $uploadedFiles = $request->getUploadedFiles(); - $files = $request->getUploadedFiles()['files']; + // Assert: Verify first file group + $firstGroup = $uploadedFiles['files_1'] ?? []; + $this->assertCount(2, $firstGroup, 'First file group should contain 2 files'); - $this->assertCount(2, $files); + $this->assertUploadedFile($firstGroup[0], 'file1.txt', 'text/plain', 123, '/tmp/php123', 0); + $this->assertUploadedFile($firstGroup[1], 'file2.txt', 'text/plain', 456, '/tmp/php456', 0); - $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()); + // Assert: Verify second file group + $secondGroup = $uploadedFiles['files_2'] ?? []; + $this->assertCount(2, $secondGroup, 'Second file group should contain 2 files'); + + $this->assertUploadedFile($secondGroup[0], 'file3.txt', 'text/html', 789, '/tmp/php789', 0); + $this->assertUploadedFile($secondGroup[1], 'file4.txt', 'application/json', 321, '/tmp/php321', 0); + } - $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()); + /** + * Helper method to assert uploaded file properties + */ + private function assertUploadedFile( + $file, + string $expectedName, + string $expectedType, + int $expectedSize, + string $expectedTmpName, + int $expectedError + ): void { + $this->assertEquals($expectedName, $file->getClientFilename()); + $this->assertEquals($expectedType, $file->getClientMediaType()); + $this->assertEquals($expectedSize, $file->getSize()); + $this->assertEquals($expectedTmpName, $file->getTempName()); + $this->assertEquals($expectedError, $file->getError()); } public function testUrlWithAtSymbol(): void From ca46fd041d1a61bd72640e5a263a080b692713a3 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Thu, 16 Oct 2025 17:38:21 +0900 Subject: [PATCH 302/331] fix file upload handling to preserve array format for single files --- flight/net/Request.php | 11 ++++++--- tests/RequestTest.php | 51 +++++++++++++++++++++++++++++------------- 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/flight/net/Request.php b/flight/net/Request.php index 13978429..829356f6 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -480,6 +480,10 @@ public function getUploadedFiles(): array $uploadedFiles = []; $correctedFilesArray = $this->reArrayFiles($this->files); foreach ($correctedFilesArray as $keyName => $files) { + // Check if original data was array format (files_name[] style) + $originalFile = $this->files->getData()[$keyName] ?? null; + $isArrayFormat = $originalFile && is_array($originalFile['name']); + foreach ($files as $file) { $UploadedFile = new UploadedFile( $file['name'], @@ -488,7 +492,9 @@ public function getUploadedFiles(): array $file['tmp_name'], $file['error'] ); - if (count($files) > 1) { + + // Always use array format if original data was array, regardless of count + if ($isArrayFormat) { $uploadedFiles[$keyName][] = $UploadedFile; } else { $uploadedFiles[$keyName] = $UploadedFile; @@ -508,10 +514,9 @@ public function getUploadedFiles(): array */ protected function reArrayFiles(Collection $filesCollection): array { - $fileArray = []; foreach ($filesCollection as $fileKeyName => $file) { - $isMulti = is_array($file['name']) === true && count($file['name']) > 1; + $isMulti = is_array($file['name']) === true ; $fileCount = $isMulti === true ? count($file['name']) : 1; $fileKeys = array_keys($file); diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 5dc1f1a1..38321ea5 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -332,13 +332,20 @@ public function testGetMultiFileUpload(): void { // Arrange: Setup multiple file upload arrays $_FILES['files_1'] = [ - 'name' => ['file1.txt', 'file2.txt'], - 'type' => ['text/plain', 'text/plain'], - 'size' => [123, 456], - 'tmp_name' => ['/tmp/php123', '/tmp/php456'], - 'error' => [0, 0] + 'name' => 'file1.txt', + 'type' => 'text/plain', + 'size' => 123, + 'tmp_name' => '/tmp/php123', + 'error' => 0 ]; $_FILES['files_2'] = [ + 'name' => ['file2.txt'], + 'type' => ['text/plain'], + 'size' => [456], + 'tmp_name' => ['/tmp/php456'], + 'error' => [0] + ]; + $_FILES['files_3'] = [ 'name' => ['file3.txt', 'file4.txt'], 'type' => ['text/html', 'application/json'], 'size' => [789, 321], @@ -350,19 +357,33 @@ public function testGetMultiFileUpload(): void $request = new Request(); $uploadedFiles = $request->getUploadedFiles(); - // Assert: Verify first file group - $firstGroup = $uploadedFiles['files_1'] ?? []; - $this->assertCount(2, $firstGroup, 'First file group should contain 2 files'); + // Assert: Verify first file group (single file) + /* + + */ + $firstFile = $uploadedFiles['files_1'] ?? null; + $this->assertNotNull($firstFile, 'First file should exist'); + $this->assertUploadedFile($firstFile, 'file1.txt', 'text/plain', 123, '/tmp/php123', 0); + + // Assert: Verify second file group (array format with single file) + /* + + */ + $secondGroup = $uploadedFiles['files_2'] ?? []; + $this->assertCount(1, $secondGroup, 'Second file group should contain 1 file in array format'); - $this->assertUploadedFile($firstGroup[0], 'file1.txt', 'text/plain', 123, '/tmp/php123', 0); - $this->assertUploadedFile($firstGroup[1], 'file2.txt', 'text/plain', 456, '/tmp/php456', 0); + $this->assertUploadedFile($secondGroup[0], 'file2.txt', 'text/plain', 456, '/tmp/php456', 0); - // Assert: Verify second file group - $secondGroup = $uploadedFiles['files_2'] ?? []; - $this->assertCount(2, $secondGroup, 'Second file group should contain 2 files'); + // Assert: Verify third file group (multiple files) + /* + + + */ + $thirdGroup = $uploadedFiles['files_3'] ?? []; + $this->assertCount(2, $thirdGroup, 'Third file group should contain 2 files'); - $this->assertUploadedFile($secondGroup[0], 'file3.txt', 'text/html', 789, '/tmp/php789', 0); - $this->assertUploadedFile($secondGroup[1], 'file4.txt', 'application/json', 321, '/tmp/php321', 0); + $this->assertUploadedFile($thirdGroup[0], 'file3.txt', 'text/html', 789, '/tmp/php789', 0); + $this->assertUploadedFile($thirdGroup[1], 'file4.txt', 'application/json', 321, '/tmp/php321', 0); } /** From 106955a82e3e6f7ca78a67ee5307acf494357c05 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Thu, 16 Oct 2025 18:31:26 +0900 Subject: [PATCH 303/331] add multipart parsing for PUT/PATCH/DELETE methods and comprehensive tests --- flight/net/Request.php | 211 ++++++++++++++++++++++- tests/RequestBodyParserTest.php | 286 ++++++++++++++++++++++++++++++++ 2 files changed, 489 insertions(+), 8 deletions(-) create mode 100644 tests/RequestBodyParserTest.php diff --git a/flight/net/Request.php b/flight/net/Request.php index 829356f6..5d2b2838 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -233,14 +233,9 @@ public function init(array $properties = []): self $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); - } + // Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data or multipart/form-data + } elseif (in_array($this->method, [ 'PUT', 'DELETE', 'PATCH' ], true) === true) { + $this->parseRequestBodyForHttpMethods(); } return $this; @@ -533,4 +528,204 @@ protected function reArrayFiles(Collection $filesCollection): array return $fileArray; } + + /** + * Parse request body data for HTTP methods that don't natively support form data (PUT, DELETE, PATCH) + * @return void + */ + protected function parseRequestBodyForHttpMethods(): void { + $body = $this->getBody(); + + // Empty body + if ($body === '') { + return; + } + + // Check Content-Type for multipart/form-data + $contentType = strtolower(trim($this->type)); + $isMultipart = strpos($contentType, 'multipart/form-data') === 0; + $boundary = null; + + if ($isMultipart) { + // Extract boundary more safely + if (preg_match('/boundary=(["\']?)([^"\';,\s]+)\1/i', $this->type, $matches)) { + $boundary = $matches[2]; + } + + // If no boundary found, it's not valid multipart + if (empty($boundary)) { + $isMultipart = false; + } + } + + $data = []; + $file = []; + + // Parse application/x-www-form-urlencoded + if ($isMultipart === false) { + parse_str($body, $data); + $this->data->setData($data); + + return; + } + + // Parse multipart/form-data + $bodyParts = preg_split('/\\R?-+' . preg_quote($boundary, '/') . '/s', $body); + array_pop($bodyParts); // Remove last element (empty) + + foreach ($bodyParts as $bodyPart) { + if (empty($bodyPart)) { + continue; + } + + // Get the headers and value + [$header, $value] = preg_split('/\\R\\R/', $bodyPart, 2); + + // Check if the header is normal + if (strpos(strtolower($header), 'content-disposition') === false) { + continue; + } + + $value = ltrim($value, "\r\n"); + + /** + * Process Header + */ + $headers = []; + // split the headers + $headerParts = preg_split('/\\R/', $header); + foreach ($headerParts as $headerPart) { + if (strpos($headerPart, ':') === false) { + continue; + } + + // Process the header + [$headerKey, $headerValue] = explode(':', $headerPart, 2); + + $headerKey = strtolower(trim($headerKey)); + $headerValue = trim($headerValue); + + if (strpos($headerValue, ';') !== false) { + $headers[$headerKey] = []; + foreach (explode(';', $headerValue) as $headerValuePart) { + preg_match_all('/(\w+)=\"?([^";]+)\"?/', $headerValuePart, $headerMatches, PREG_SET_ORDER); + + foreach ($headerMatches as $headerMatch) { + $headerSubKey = strtolower($headerMatch[1]); + $headerSubValue = $headerMatch[2]; + + $headers[$headerKey][$headerSubKey] = $headerSubValue; + } + } + } else { + $headers[$headerKey] = $headerValue; + } + } + + /** + * Process Value + */ + if (!isset($headers['content-disposition']) || !isset($headers['content-disposition']['name'])) { + continue; + } + + $keyName = str_replace("[]", "", $headers['content-disposition']['name']); + + // if is not file + if (!isset($headers['content-disposition']['filename'])) { + if (isset($data[$keyName])) { + if (!is_array($data[$keyName])) { + $data[$keyName] = [$data[$keyName]]; + } + $data[$keyName][] = $value; + } + else { + $data[$keyName] = $value; + } + continue; + } + + $tmpFile = [ + 'name' => $headers['content-disposition']['filename'], + 'type' => $headers['content-type'] ?? 'application/octet-stream', + 'size' => mb_strlen($value, '8bit'), + 'tmp_name' => null, + 'error' => UPLOAD_ERR_OK, + ]; + + if ($tmpFile['size'] > $this->getUploadMaxFileSize()) { + $tmpFile['error'] = UPLOAD_ERR_INI_SIZE; + } else { + // Create a temporary file + $tmpName = tempnam(sys_get_temp_dir(), 'flight_tmp_'); + if ($tmpName === false) { + $tmpFile['error'] = UPLOAD_ERR_CANT_WRITE; + } else { + // Write the value to a temporary file + $bytes = file_put_contents($tmpName, $value); + + if ($bytes === false) { + $tmpFile['error'] = UPLOAD_ERR_CANT_WRITE; + } + else { + // delete the temporary file before ended script + register_shutdown_function(function () use ($tmpName): void { + if (file_exists($tmpName)) { + unlink($tmpName); + } + }); + + $tmpFile['tmp_name'] = $tmpName; + } + } + } + + foreach ($tmpFile as $key => $value) { + if (isset($file[$keyName][$key])) { + if (!is_array($file[$keyName][$key])) { + $file[$keyName][$key] = [$file[$keyName][$key]]; + } + $file[$keyName][$key][] = $value; + } else { + $file[$keyName][$key] = $value; + } + } + } + + $this->data->setData($data); + $this->files->setData($file); + } + + /** + * Get the maximum file size that can be uploaded. + * @return int The maximum file size in bytes. + */ + protected function getUploadMaxFileSize() { + $value = ini_get('upload_max_filesize'); + + $unit = strtolower(preg_replace('/[^a-zA-Z]/', '', $value)); + $value = preg_replace('/[^\d.]/', '', $value); + + switch ($unit) { + case 'p': // PentaByte + case 'pb': + $value *= 1024; + case 't': // Terabyte + case 'tb': + $value *= 1024; + case 'g': // Gigabyte + case 'gb': + $value *= 1024; + case 'm': // Megabyte + case 'mb': + $value *= 1024; + case 'k': // Kilobyte + case 'kb': + $value *= 1024; + case 'b': // Byte + return $value *= 1; + default: + return 0; + } + } } diff --git a/tests/RequestBodyParserTest.php b/tests/RequestBodyParserTest.php new file mode 100644 index 00000000..6b7ede3e --- /dev/null +++ b/tests/RequestBodyParserTest.php @@ -0,0 +1,286 @@ + '/', + 'base' => '/', + 'method' => $method, + 'referrer' => '', + 'ip' => '127.0.0.1', + 'ajax' => false, + 'scheme' => 'http', + 'user_agent' => 'Test', + 'type' => $contentType, + 'length' => strlen($body), + 'secure' => false, + 'accept' => '', + 'proxy_ip' => '', + 'host' => 'localhost', + 'servername' => 'localhost', + 'stream_path' => $stream_path, + 'data' => new Collection(), + 'query' => new Collection(), + 'cookies' => new Collection(), + 'files' => new Collection() + ]; + } + + private function assertUrlEncodedParsing(string $method): void + { + $body = 'foo=bar&baz=qux&key=value'; + $tmpfile = tmpfile(); + $stream_path = stream_get_meta_data($tmpfile)['uri']; + file_put_contents($stream_path, $body); + + $config = [ + 'url' => '/', + 'base' => '/', + 'method' => $method, + 'referrer' => '', + 'ip' => '127.0.0.1', + 'ajax' => false, + 'scheme' => 'http', + 'user_agent' => 'Test', + 'type' => 'application/x-www-form-urlencoded', + 'length' => strlen($body), + 'secure' => false, + 'accept' => '', + 'proxy_ip' => '', + 'host' => 'localhost', + 'servername' => 'localhost', + 'stream_path' => $stream_path, + 'data' => new Collection(), + 'query' => new Collection(), + 'cookies' => new Collection(), + 'files' => new Collection() + ]; + + $request = new Request($config); + + $expectedData = [ + 'foo' => 'bar', + 'baz' => 'qux', + 'key' => 'value' + ]; + $this->assertEquals($expectedData, $request->data->getData()); + + fclose($tmpfile); + } + + private function createMultipartBody(string $boundary, array $fields, array $files = []): string + { + $body = ''; + + // Add form fields + foreach ($fields as $name => $value) { + if (is_array($value)) { + foreach ($value as $item) { + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"{$name}\"\r\n"; + $body .= "\r\n"; + $body .= "{$item}\r\n"; + } + } else { + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"{$name}\"\r\n"; + $body .= "\r\n"; + $body .= "{$value}\r\n"; + } + } + + // Add files + foreach ($files as $name => $file) { + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"{$name}\"; filename=\"{$file['filename']}\"\r\n"; + $body .= "Content-Type: {$file['type']}\r\n"; + $body .= "\r\n"; + $body .= "{$file['content']}\r\n"; + } + + $body .= "--{$boundary}--\r\n"; + + return $body; + } + + public function testParseUrlEncodedBodyForPutMethod(): void + { + $this->assertUrlEncodedParsing('PUT'); + } + + public function testParseUrlEncodedBodyForPatchMethod(): void + { + $this->assertUrlEncodedParsing('PATCH'); + } + + public function testParseUrlEncodedBodyForDeleteMethod(): void + { + $this->assertUrlEncodedParsing('DELETE'); + } + + public function testParseMultipartFormDataWithFiles(): void + { + $boundary = 'boundary123456789'; + $fields = ['title' => 'Test Document']; + $files = [ + 'file' => [ + 'filename' => 'file.txt', + 'type' => 'text/plain', + 'content' => 'This is test file content' + ] + ]; + + $body = $this->createMultipartBody($boundary, $fields, $files); + $config = $this->createRequestConfig('PUT', "multipart/form-data; boundary={$boundary}", $body, $tmpfile); + $request = new Request($config); + + $this->assertEquals(['title' => 'Test Document'], $request->data->getData()); + + $file = $request->getUploadedFiles()['file']; + $this->assertEquals('file.txt', $file->getClientFilename()); + $this->assertEquals('text/plain', $file->getClientMediaType()); + $this->assertEquals(strlen('This is test file content'), $file->getSize()); + $this->assertEquals(UPLOAD_ERR_OK, $file->getError()); + $this->assertNotNull($file->getTempName()); + + fclose($tmpfile); + } + + public function testParseMultipartFormDataWithQuotedBoundary(): void + { + $boundary = 'boundary123456789'; + $fields = ['foo' => 'bar']; + + $body = $this->createMultipartBody($boundary, $fields); + $config = $this->createRequestConfig('PATCH', "multipart/form-data; boundary=\"{$boundary}\"", $body, $tmpfile); + $request = new Request($config); + + $this->assertEquals($fields, $request->data->getData()); + + fclose($tmpfile); + } + + public function testParseMultipartFormDataWithArrayFields(): void + { + $boundary = 'boundary123456789'; + $fields = ['name[]' => ['foo', 'bar']]; + $expectedData = ['name' => ['foo', 'bar']]; + + $body = $this->createMultipartBody($boundary, $fields); + $config = $this->createRequestConfig('PUT', "multipart/form-data; boundary={$boundary}", $body, $tmpfile); + $request = new Request($config); + + $this->assertEquals($expectedData, $request->data->getData()); + + fclose($tmpfile); + } + + public function testParseEmptyBody(): void + { + $config = $this->createRequestConfig('PUT', 'application/x-www-form-urlencoded', '', $tmpfile); + $request = new Request($config); + + $this->assertEquals([], $request->data->getData()); + + fclose($tmpfile); + } + + public function testParseInvalidMultipartWithoutBoundary(): void + { + $originalData = ['foo foo' => 'bar bar', 'baz baz' => 'qux']; + $body = http_build_query($originalData); + $expectedData = ['foo_foo' => 'bar bar', 'baz_baz' => 'qux']; + + $config = $this->createRequestConfig('PUT', 'multipart/form-data', $body, $tmpfile); // no boundary + $request = new Request($config); + + // should fall back to URL encoding and parse correctly + $this->assertEquals($expectedData, $request->data->getData()); + + fclose($tmpfile); + } + + public function testParseMultipartWithLargeFile(): void + { + $boundary = 'boundary123456789'; + $largeContent = str_repeat('A', 10000); // 10KB content + $files = [ + 'file' => [ + 'filename' => 'large.txt', + 'type' => 'text/plain', + 'content' => $largeContent + ] + ]; + + $body = $this->createMultipartBody($boundary, [], $files); + $config = $this->createRequestConfig('PUT', "multipart/form-data; boundary={$boundary}", $body, $tmpfile); + $request = new Request($config); + + $file = $request->getUploadedFiles()['file']; + $this->assertArrayHasKey('file', $request->getUploadedFiles()); + $this->assertEquals('large.txt', $file->getClientFilename()); + $this->assertEquals(10000, $file->getSize()); + $this->assertEquals(UPLOAD_ERR_OK, $file->getError()); + $this->assertNotNull($file->getTempName()); + + fclose($tmpfile); + } + + public function testGetMethodDoesNotTriggerParsing(): void + { + $body = 'foo=bar&baz=qux&key=value'; + $config = $this->createRequestConfig('GET', 'application/x-www-form-urlencoded', $body, $tmpfile); + $request = new Request($config); + + // GET method should not trigger parsing + $this->assertEquals([], $request->data->getData()); + + fclose($tmpfile); + } + + public function testPostMethodDoesNotTriggerParsing(): void + { + $body = 'foo=bar&baz=qux&key=value'; + $config = $this->createRequestConfig('POST', 'application/x-www-form-urlencoded', $body, $tmpfile); + $request = new Request($config); + + // POST method should not trigger this parsing (uses $_POST instead) + $this->assertEquals([], $request->data->getData()); + + fclose($tmpfile); + } +} \ No newline at end of file From 68b9306d308bdce96056ba542a9ea76a5e208513 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 17 Oct 2025 10:39:26 +0900 Subject: [PATCH 304/331] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- flight/net/Request.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/flight/net/Request.php b/flight/net/Request.php index 5d2b2838..08090cb3 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -511,7 +511,7 @@ protected function reArrayFiles(Collection $filesCollection): array { $fileArray = []; foreach ($filesCollection as $fileKeyName => $file) { - $isMulti = is_array($file['name']) === true ; + $isMulti = is_array($file['name']) === true; $fileCount = $isMulti === true ? count($file['name']) : 1; $fileKeys = array_keys($file); @@ -533,7 +533,8 @@ protected function reArrayFiles(Collection $filesCollection): array * Parse request body data for HTTP methods that don't natively support form data (PUT, DELETE, PATCH) * @return void */ - protected function parseRequestBodyForHttpMethods(): void { + protected function parseRequestBodyForHttpMethods(): void + { $body = $this->getBody(); // Empty body @@ -571,7 +572,7 @@ protected function parseRequestBodyForHttpMethods(): void { // Parse multipart/form-data $bodyParts = preg_split('/\\R?-+' . preg_quote($boundary, '/') . '/s', $body); - array_pop($bodyParts); // Remove last element (empty) + array_pop($bodyParts); // Remove last element (empty) foreach ($bodyParts as $bodyPart) { if (empty($bodyPart)) { @@ -700,7 +701,7 @@ protected function parseRequestBodyForHttpMethods(): void { * Get the maximum file size that can be uploaded. * @return int The maximum file size in bytes. */ - protected function getUploadMaxFileSize() { + protected function getUploadMaxFileSize(): int { $value = ini_get('upload_max_filesize'); $unit = strtolower(preg_replace('/[^a-zA-Z]/', '', $value)); @@ -723,7 +724,7 @@ protected function getUploadMaxFileSize() { case 'kb': $value *= 1024; case 'b': // Byte - return $value *= 1; + return (int)$value; default: return 0; } From 847a0d51f0f0c35ce71525607cc3642a29fbb5a7 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 17 Oct 2025 10:49:56 +0900 Subject: [PATCH 305/331] fix: add null coalescing for undefined superglobals --- flight/net/Request.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flight/net/Request.php b/flight/net/Request.php index 08090cb3..4341b08f 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -177,10 +177,10 @@ public function __construct(array $config = []) 'user_agent' => $this->getVar('HTTP_USER_AGENT'), 'type' => $this->getVar('CONTENT_TYPE'), 'length' => intval($this->getVar('CONTENT_LENGTH', 0)), - 'query' => new Collection($_GET), - 'data' => new Collection($_POST), - 'cookies' => new Collection($_COOKIE), - 'files' => new Collection($_FILES), + 'query' => new Collection($_GET ?? []), + 'data' => new Collection($_POST ?? []), + 'cookies' => new Collection($_COOKIE ?? []), + 'files' => new Collection($_FILES ?? []), 'secure' => $scheme === 'https', 'accept' => $this->getVar('HTTP_ACCEPT'), 'proxy_ip' => $this->getProxyIpAddress(), @@ -219,7 +219,7 @@ public function init(array $properties = []): self $this->url = '/'; } else { // Merge URL query parameters with $_GET - $_GET = array_merge($_GET, self::parseQuery($this->url)); + $_GET = array_merge($_GET ?? [], self::parseQuery($this->url)); $this->query->setData($_GET); } @@ -546,7 +546,7 @@ protected function parseRequestBodyForHttpMethods(): void $contentType = strtolower(trim($this->type)); $isMultipart = strpos($contentType, 'multipart/form-data') === 0; $boundary = null; - + if ($isMultipart) { // Extract boundary more safely if (preg_match('/boundary=(["\']?)([^"\';,\s]+)\1/i', $this->type, $matches)) { From 41dc0e36789f9fc3d977fcd879449ea7b8e8233f Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 17 Oct 2025 11:13:01 +0900 Subject: [PATCH 306/331] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- flight/net/Request.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/flight/net/Request.php b/flight/net/Request.php index 4341b08f..134a83db 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -711,18 +711,19 @@ protected function getUploadMaxFileSize(): int { case 'p': // PentaByte case 'pb': $value *= 1024; + return (int)$value; case 't': // Terabyte - case 'tb': $value *= 1024; + return (int)$value; case 'g': // Gigabyte - case 'gb': $value *= 1024; + return (int)$value; case 'm': // Megabyte - case 'mb': $value *= 1024; + return (int)$value; case 'k': // Kilobyte - case 'kb': $value *= 1024; + return (int)$value; case 'b': // Byte return (int)$value; default: From bbf5204da675491e6ad5a3f117add405342d1469 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 17 Oct 2025 11:16:52 +0900 Subject: [PATCH 307/331] fix: initialize tmp_name as empty string instead of null --- flight/net/Request.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flight/net/Request.php b/flight/net/Request.php index 134a83db..4dd81e77 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -650,7 +650,7 @@ protected function parseRequestBodyForHttpMethods(): void 'name' => $headers['content-disposition']['filename'], 'type' => $headers['content-type'] ?? 'application/octet-stream', 'size' => mb_strlen($value, '8bit'), - 'tmp_name' => null, + 'tmp_name' => '', 'error' => UPLOAD_ERR_OK, ]; From 2c980eb7edd2e8a7e2dcf3b3d582864aee89f5b3 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 17 Oct 2025 11:27:17 +0900 Subject: [PATCH 308/331] Revert to previous code --- flight/net/Request.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flight/net/Request.php b/flight/net/Request.php index 4341b08f..9df43e73 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -728,5 +728,7 @@ protected function getUploadMaxFileSize(): int { default: return 0; } + + return (int)$value; } } From 66a91e042ce8bc917e4a400b4af0abe0148276a2 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 17 Oct 2025 11:28:08 +0900 Subject: [PATCH 309/331] Revert to previous code --- flight/net/Request.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flight/net/Request.php b/flight/net/Request.php index 9df43e73..f3764fdb 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -724,7 +724,7 @@ protected function getUploadMaxFileSize(): int { case 'kb': $value *= 1024; case 'b': // Byte - return (int)$value; + break; default: return 0; } From 144327da78ccd098331e999981376165806b4812 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 17 Oct 2025 11:31:20 +0900 Subject: [PATCH 310/331] Revert "fix: add null coalescing for undefined superglobals" This reverts commit 847a0d51f0f0c35ce71525607cc3642a29fbb5a7. --- flight/net/Request.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flight/net/Request.php b/flight/net/Request.php index aebe6659..ac6f013f 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -177,10 +177,10 @@ public function __construct(array $config = []) 'user_agent' => $this->getVar('HTTP_USER_AGENT'), 'type' => $this->getVar('CONTENT_TYPE'), 'length' => intval($this->getVar('CONTENT_LENGTH', 0)), - 'query' => new Collection($_GET ?? []), - 'data' => new Collection($_POST ?? []), - 'cookies' => new Collection($_COOKIE ?? []), - 'files' => new Collection($_FILES ?? []), + 'query' => new Collection($_GET), + 'data' => new Collection($_POST), + 'cookies' => new Collection($_COOKIE), + 'files' => new Collection($_FILES), 'secure' => $scheme === 'https', 'accept' => $this->getVar('HTTP_ACCEPT'), 'proxy_ip' => $this->getProxyIpAddress(), @@ -219,7 +219,7 @@ public function init(array $properties = []): self $this->url = '/'; } else { // Merge URL query parameters with $_GET - $_GET = array_merge($_GET ?? [], self::parseQuery($this->url)); + $_GET = array_merge($_GET, self::parseQuery($this->url)); $this->query->setData($_GET); } @@ -546,7 +546,7 @@ protected function parseRequestBodyForHttpMethods(): void $contentType = strtolower(trim($this->type)); $isMultipart = strpos($contentType, 'multipart/form-data') === 0; $boundary = null; - + if ($isMultipart) { // Extract boundary more safely if (preg_match('/boundary=(["\']?)([^"\';,\s]+)\1/i', $this->type, $matches)) { From 42ad8ae23fedfbbfae8a06f9faf9e5a7e0de1d4c Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 17 Oct 2025 11:35:19 +0900 Subject: [PATCH 311/331] refactor: simplify upload_max_filesize unit conversion with fallthrough switch --- flight/net/Request.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/flight/net/Request.php b/flight/net/Request.php index ac6f013f..d6756c8e 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -711,19 +711,14 @@ protected function getUploadMaxFileSize(): int { case 'p': // PentaByte case 'pb': $value *= 1024; - return (int)$value; case 't': // Terabyte $value *= 1024; - return (int)$value; case 'g': // Gigabyte $value *= 1024; - return (int)$value; case 'm': // Megabyte $value *= 1024; - return (int)$value; case 'k': // Kilobyte $value *= 1024; - return (int)$value; case 'b': // Byte break; default: From 9ab2695d48940be11196834eaae2f427d6ace25c Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 17 Oct 2025 11:36:50 +0900 Subject: [PATCH 312/331] fix: add null coalescing for undefined superglobals --- flight/net/Request.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/flight/net/Request.php b/flight/net/Request.php index d6756c8e..67e416d0 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -177,10 +177,10 @@ public function __construct(array $config = []) 'user_agent' => $this->getVar('HTTP_USER_AGENT'), 'type' => $this->getVar('CONTENT_TYPE'), 'length' => intval($this->getVar('CONTENT_LENGTH', 0)), - 'query' => new Collection($_GET), - 'data' => new Collection($_POST), - 'cookies' => new Collection($_COOKIE), - 'files' => new Collection($_FILES), + 'query' => new Collection($_GET ?? []), + 'data' => new Collection($_POST ?? []), + 'cookies' => new Collection($_COOKIE ?? []), + 'files' => new Collection($_FILES ?? []), 'secure' => $scheme === 'https', 'accept' => $this->getVar('HTTP_ACCEPT'), 'proxy_ip' => $this->getProxyIpAddress(), @@ -219,7 +219,7 @@ public function init(array $properties = []): self $this->url = '/'; } else { // Merge URL query parameters with $_GET - $_GET = array_merge($_GET, self::parseQuery($this->url)); + $_GET = array_merge($_GET ?? [], self::parseQuery($this->url)); $this->query->setData($_GET); } From 82daf71d0a65df5e9a18119cfe2f7bf000691b48 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 17 Oct 2025 11:43:11 +0900 Subject: [PATCH 313/331] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- flight/net/Request.php | 6 ++---- tests/RequestBodyParserTest.php | 28 ++-------------------------- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/flight/net/Request.php b/flight/net/Request.php index 67e416d0..7233758e 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -639,8 +639,7 @@ protected function parseRequestBodyForHttpMethods(): void $data[$keyName] = [$data[$keyName]]; } $data[$keyName][] = $value; - } - else { + } else { $data[$keyName] = $value; } continue; @@ -667,8 +666,7 @@ protected function parseRequestBodyForHttpMethods(): void if ($bytes === false) { $tmpFile['error'] = UPLOAD_ERR_CANT_WRITE; - } - else { + } else { // delete the temporary file before ended script register_shutdown_function(function () use ($tmpName): void { if (file_exists($tmpName)) { diff --git a/tests/RequestBodyParserTest.php b/tests/RequestBodyParserTest.php index 6b7ede3e..55e3b5a8 100644 --- a/tests/RequestBodyParserTest.php +++ b/tests/RequestBodyParserTest.php @@ -63,32 +63,8 @@ private function createRequestConfig(string $method, string $contentType, string private function assertUrlEncodedParsing(string $method): void { $body = 'foo=bar&baz=qux&key=value'; - $tmpfile = tmpfile(); - $stream_path = stream_get_meta_data($tmpfile)['uri']; - file_put_contents($stream_path, $body); - - $config = [ - 'url' => '/', - 'base' => '/', - 'method' => $method, - 'referrer' => '', - 'ip' => '127.0.0.1', - 'ajax' => false, - 'scheme' => 'http', - 'user_agent' => 'Test', - 'type' => 'application/x-www-form-urlencoded', - 'length' => strlen($body), - 'secure' => false, - 'accept' => '', - 'proxy_ip' => '', - 'host' => 'localhost', - 'servername' => 'localhost', - 'stream_path' => $stream_path, - 'data' => new Collection(), - 'query' => new Collection(), - 'cookies' => new Collection(), - 'files' => new Collection() - ]; + $tmpfile = null; + $config = $this->createRequestConfig($method, 'application/x-www-form-urlencoded', $body, $tmpfile); $request = new Request($config); From dc56ca2c4ad378b3a29217e22e2904b411189c3e Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Tue, 21 Oct 2025 16:01:45 +0900 Subject: [PATCH 314/331] fix: allow non-string types in Response::write() to prevent TypeError --- flight/net/Response.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flight/net/Response.php b/flight/net/Response.php index 9587ddf8..6d3da1c2 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -237,7 +237,7 @@ public function getHeaders(): array * * @return $this Self reference */ - public function write(string $str, bool $overwrite = false): self + public function write($str, bool $overwrite = false): self { if ($overwrite === true) { $this->clearBody(); From 671fea075c8e207d1aee73144c2adda49603195c Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Tue, 21 Oct 2025 08:32:10 -0600 Subject: [PATCH 315/331] adjustments for security, 100% coverage and phpcs --- flight/net/Request.php | 354 +++++++++++++++++++++----------- tests/RequestBodyParserTest.php | 205 +++++++++++++++++- 2 files changed, 427 insertions(+), 132 deletions(-) diff --git a/flight/net/Request.php b/flight/net/Request.php index 7233758e..4aa51f46 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -147,6 +147,13 @@ class Request */ public string $body = ''; + /** + * Hold tmp file handles created via tmpfile() so they persist for request lifetime + * + * @var array + */ + private array $tmpFileHandles = []; + /** * Constructor. * @@ -177,10 +184,10 @@ public function __construct(array $config = []) 'user_agent' => $this->getVar('HTTP_USER_AGENT'), 'type' => $this->getVar('CONTENT_TYPE'), 'length' => intval($this->getVar('CONTENT_LENGTH', 0)), - 'query' => new Collection($_GET ?? []), - 'data' => new Collection($_POST ?? []), - 'cookies' => new Collection($_COOKIE ?? []), - 'files' => new Collection($_FILES ?? []), + 'query' => new Collection($_GET), + 'data' => new Collection($_POST), + 'cookies' => new Collection($_COOKIE), + 'files' => new Collection($_FILES), 'secure' => $scheme === 'https', 'accept' => $this->getVar('HTTP_ACCEPT'), 'proxy_ip' => $this->getProxyIpAddress(), @@ -219,7 +226,7 @@ public function init(array $properties = []): self $this->url = '/'; } else { // Merge URL query parameters with $_GET - $_GET = array_merge($_GET ?? [], self::parseQuery($this->url)); + $_GET = array_merge($_GET, self::parseQuery($this->url)); $this->query->setData($_GET); } @@ -233,7 +240,7 @@ public function init(array $properties = []): self $this->data->setData($data); } } - // Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data or multipart/form-data + // Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data or multipart/form-data } elseif (in_array($this->method, [ 'PUT', 'DELETE', 'PATCH' ], true) === true) { $this->parseRequestBodyForHttpMethods(); } @@ -468,7 +475,7 @@ public function negotiateContentType(array $supported): ?string /** * Retrieves the array of uploaded files. * - * @return array|array>> The array of uploaded files. + * @return array> Key is field name; value is either a single UploadedFile or an array of UploadedFile when multiple were uploaded. */ public function getUploadedFiles(): array { @@ -478,7 +485,7 @@ public function getUploadedFiles(): array // Check if original data was array format (files_name[] style) $originalFile = $this->files->getData()[$keyName] ?? null; $isArrayFormat = $originalFile && is_array($originalFile['name']); - + foreach ($files as $file) { $UploadedFile = new UploadedFile( $file['name'], @@ -487,9 +494,9 @@ public function getUploadedFiles(): array $file['tmp_name'], $file['error'] ); - + // Always use array format if original data was array, regardless of count - if ($isArrayFormat) { + if ($isArrayFormat === true) { $uploadedFiles[$keyName][] = $UploadedFile; } else { $uploadedFiles[$keyName] = $UploadedFile; @@ -531,198 +538,299 @@ protected function reArrayFiles(Collection $filesCollection): array /** * Parse request body data for HTTP methods that don't natively support form data (PUT, DELETE, PATCH) + * * @return void */ protected function parseRequestBodyForHttpMethods(): void { $body = $this->getBody(); - + // Empty body if ($body === '') { return; } - + // Check Content-Type for multipart/form-data $contentType = strtolower(trim($this->type)); $isMultipart = strpos($contentType, 'multipart/form-data') === 0; $boundary = null; - - if ($isMultipart) { + + if ($isMultipart === true) { // Extract boundary more safely if (preg_match('/boundary=(["\']?)([^"\';,\s]+)\1/i', $this->type, $matches)) { $boundary = $matches[2]; } - + // If no boundary found, it's not valid multipart if (empty($boundary)) { $isMultipart = false; } - } - $data = []; - $file = []; + $firstLine = strtok($body, "\r\n"); + if ($firstLine === false || strpos($firstLine, '--' . $boundary) !== 0) { + // Does not start with the boundary marker; fall back + $isMultipart = false; + } + } // Parse application/x-www-form-urlencoded if ($isMultipart === false) { parse_str($body, $data); $this->data->setData($data); - return; } - + + $this->setParsedRequestBodyMultipartFormData($body, $boundary); + } + + /** + * Sets the parsed request body for multipart form data requests + * + * This method processes and stores multipart form data from the request body, + * parsing it according to the specified boundary delimiter. It handles the + * complex parsing of multipart data including file uploads and form fields. + * + * @param string $body The raw multipart request body content + * @param string $boundary The boundary string used to separate multipart sections + * + * @return void + */ + protected function setParsedRequestBodyMultipartFormData(string $body, string $boundary): void + { + + $data = []; + $file = []; + // Parse multipart/form-data - $bodyParts = preg_split('/\\R?-+' . preg_quote($boundary, '/') . '/s', $body); - array_pop($bodyParts); // Remove last element (empty) - + $bodyParts = preg_split('/\R?-+' . preg_quote($boundary, '/') . '/s', $body); + array_pop($bodyParts); // Remove last element (closing boundary or empty) + + $partsProcessed = 0; + $filesTotalBytes = 0; + // Use ini values directly + $maxParts = (int) ini_get('max_file_uploads'); + if ($maxParts <= 0) { + // unlimited parts if not specified + $maxParts = PHP_INT_MAX; // @codeCoverageIgnore + } + $maxTotalBytes = self::derivePostMaxSizeBytes(); + foreach ($bodyParts as $bodyPart) { - if (empty($bodyPart)) { + if ($partsProcessed >= $maxParts) { + // reached part limit from ini + break; // @codeCoverageIgnore + } + if ($bodyPart === '' || $bodyPart === null) { + continue; // skip empty segments + } + $partsProcessed++; + + // Split headers and value; if format invalid, skip early + $split = preg_split('/\R\R/', $bodyPart, 2); + if ($split === false || count($split) < 2) { continue; } + [$header, $value] = $split; - // Get the headers and value - [$header, $value] = preg_split('/\\R\\R/', $bodyPart, 2); - - // Check if the header is normal - if (strpos(strtolower($header), 'content-disposition') === false) { + // Fast header sanity checks + if (stripos($header, 'content-disposition') === false) { + continue; + } + if (strlen($header) > 16384) { // 16KB header block guard continue; } $value = ltrim($value, "\r\n"); - /** - * Process Header - */ - $headers = []; - // split the headers - $headerParts = preg_split('/\\R/', $header); - foreach ($headerParts as $headerPart) { - if (strpos($headerPart, ':') === false) { - continue; - } + // Parse headers (simple approach, fail-fast on anomalies) + $headers = $this->parseRequestBodyHeadersFromMultipartFormData($header); - // Process the header - [$headerKey, $headerValue] = explode(':', $headerPart, 2); - - $headerKey = strtolower(trim($headerKey)); - $headerValue = trim($headerValue); - - if (strpos($headerValue, ';') !== false) { - $headers[$headerKey] = []; - foreach (explode(';', $headerValue) as $headerValuePart) { - preg_match_all('/(\w+)=\"?([^";]+)\"?/', $headerValuePart, $headerMatches, PREG_SET_ORDER); - - foreach ($headerMatches as $headerMatch) { - $headerSubKey = strtolower($headerMatch[1]); - $headerSubValue = $headerMatch[2]; - - $headers[$headerKey][$headerSubKey] = $headerSubValue; - } - } - } else { - $headers[$headerKey] = $headerValue; - } - } - - /** - * Process Value - */ - if (!isset($headers['content-disposition']) || !isset($headers['content-disposition']['name'])) { + // Required disposition/name + if (isset($headers['content-disposition']['name']) === false) { continue; } + $keyName = str_replace('[]', '', (string) $headers['content-disposition']['name']); + if ($keyName === '') { + continue; // avoid empty keys + } - $keyName = str_replace("[]", "", $headers['content-disposition']['name']); - - // if is not file - if (!isset($headers['content-disposition']['filename'])) { - if (isset($data[$keyName])) { - if (!is_array($data[$keyName])) { + // Non-file field + if (isset($headers['content-disposition']['filename']) === false) { + if (isset($data[$keyName]) === false) { + $data[$keyName] = $value; + } else { + if (is_array($data[$keyName]) === false) { $data[$keyName] = [$data[$keyName]]; } $data[$keyName][] = $value; - } else { - $data[$keyName] = $value; } - continue; + continue; // done with this part + } + + // Sanitize filename early + $rawFilename = (string) $headers['content-disposition']['filename']; + $rawFilename = str_replace(["\0", "\r", "\n"], '', $rawFilename); + $sanitizedFilename = basename($rawFilename); + $matchCriteria = preg_match('/^[A-Za-z0-9._-]{1,255}$/', $sanitizedFilename); + if ($sanitizedFilename === '' || $matchCriteria !== 1) { + $sanitizedFilename = 'upload_' . uniqid('', true); } + $size = mb_strlen($value, '8bit'); + $filesTotalBytes += $size; $tmpFile = [ - 'name' => $headers['content-disposition']['filename'], + 'name' => $sanitizedFilename, 'type' => $headers['content-type'] ?? 'application/octet-stream', - 'size' => mb_strlen($value, '8bit'), + 'size' => $size, 'tmp_name' => '', 'error' => UPLOAD_ERR_OK, ]; - if ($tmpFile['size'] > $this->getUploadMaxFileSize()) { - $tmpFile['error'] = UPLOAD_ERR_INI_SIZE; + // Fail fast on size constraints + if ($size > $this->getUploadMaxFileSize() || $filesTotalBytes > $maxTotalBytes) { + // individual file or total size exceeded + $tmpFile['error'] = UPLOAD_ERR_INI_SIZE; // @codeCoverageIgnore } else { - // Create a temporary file - $tmpName = tempnam(sys_get_temp_dir(), 'flight_tmp_'); - if ($tmpName === false) { - $tmpFile['error'] = UPLOAD_ERR_CANT_WRITE; - } else { - // Write the value to a temporary file - $bytes = file_put_contents($tmpName, $value); + $tempResult = $this->createTempFile($value); + $tmpFile['tmp_name'] = $tempResult['tmp_name']; + $tmpFile['error'] = $tempResult['error']; + } - if ($bytes === false) { - $tmpFile['error'] = UPLOAD_ERR_CANT_WRITE; - } else { - // delete the temporary file before ended script - register_shutdown_function(function () use ($tmpName): void { - if (file_exists($tmpName)) { - unlink($tmpName); - } - }); - - $tmpFile['tmp_name'] = $tmpName; - } + // Aggregate into synthetic files array + foreach ($tmpFile as $metaKey => $metaVal) { + if (!isset($file[$keyName][$metaKey])) { + $file[$keyName][$metaKey] = $metaVal; + continue; + } + if (!is_array($file[$keyName][$metaKey])) { + $file[$keyName][$metaKey] = [$file[$keyName][$metaKey]]; } + $file[$keyName][$metaKey][] = $metaVal; } + } - foreach ($tmpFile as $key => $value) { - if (isset($file[$keyName][$key])) { - if (!is_array($file[$keyName][$key])) { - $file[$keyName][$key] = [$file[$keyName][$key]]; + $this->data->setData($data); + $this->files->setData($file); + } + + /** + * Parses request body headers from multipart form data + * + * This method extracts and processes headers from a multipart form data section, + * typically used for file uploads or complex form submissions. It parses the + * header string and returns an associative array of header name-value pairs. + * + * @param string $header The raw header string from a multipart form data section + * + * @return array An associative array containing parsed header name-value pairs + */ + protected function parseRequestBodyHeadersFromMultipartFormData(string $header): array + { + $headers = []; + foreach (preg_split('/\R/', $header) as $headerLine) { + if (strpos($headerLine, ':') === false) { + continue; + } + [$headerKey, $headerValue] = explode(':', $headerLine, 2); + $headerKey = strtolower(trim($headerKey)); + $headerValue = trim($headerValue); + if (strpos($headerValue, ';') !== false) { + $headers[$headerKey] = []; + foreach (explode(';', $headerValue) as $hvPart) { + preg_match_all('/(\w+)="?([^";]+)"?/', $hvPart, $matches, PREG_SET_ORDER); + foreach ($matches as $m) { + $subKey = strtolower($m[1]); + $headers[$headerKey][$subKey] = $m[2]; } - $file[$keyName][$key][] = $value; - } else { - $file[$keyName][$key] = $value; } + } else { + $headers[$headerKey] = $headerValue; } } - - $this->data->setData($data); - $this->files->setData($file); + return $headers; } /** * Get the maximum file size that can be uploaded. + * * @return int The maximum file size in bytes. */ - protected function getUploadMaxFileSize(): int { + public function getUploadMaxFileSize(): int + { $value = ini_get('upload_max_filesize'); + return self::parsePhpSize($value); + } + + /** + * Parse a PHP shorthand size string (like "1K", "1.5M") into bytes. + * Returns 0 on unknown or unsupported unit (keeps existing behavior). + * + * @param string $size + * + * @return int + */ + public static function parsePhpSize(string $size): int + { + $unit = strtolower(preg_replace('/[^a-zA-Z]/', '', $size)); + $value = (int) preg_replace('/[^\d.]/', '', $size); - $unit = strtolower(preg_replace('/[^a-zA-Z]/', '', $value)); - $value = preg_replace('/[^\d.]/', '', $value); + // No unit => follow existing behavior and return value directly if > 1024 (1K) + if ($unit === '' && $value >= 1024) { + return $value; + } switch ($unit) { - case 'p': // PentaByte - case 'pb': - $value *= 1024; - case 't': // Terabyte - $value *= 1024; - case 'g': // Gigabyte + case 't': + case 'tb': + $value *= 1024; // Fall through + case 'g': + case 'gb': + $value *= 1024; // Fall through + case 'm': + case 'mb': + $value *= 1024; // Fall through + case 'k': $value *= 1024; - case 'm': // Megabyte - $value *= 1024; - case 'k': // Kilobyte - $value *= 1024; - case 'b': // Byte break; default: return 0; } - return (int)$value; + return $value; + } + + /** + * Derive post_max_size in bytes. Returns 0 when unlimited or unparsable. + */ + private static function derivePostMaxSizeBytes(): int + { + $postMax = (string) ini_get('post_max_size'); + $bytes = self::parsePhpSize($postMax); + return $bytes; // 0 means unlimited + } + + /** + * Create a temporary file for uploaded content using tmpfile(). + * Returns array with tmp_name and error code. + * + * @param string $content + * + * @return array + */ + private function createTempFile(string $content): array + { + $fp = tmpfile(); + if ($fp === false) { + return ['tmp_name' => '', 'error' => UPLOAD_ERR_CANT_WRITE]; // @codeCoverageIgnore + } + $bytes = fwrite($fp, $content); + if ($bytes === false) { + fclose($fp); // @codeCoverageIgnore + return ['tmp_name' => '', 'error' => UPLOAD_ERR_CANT_WRITE]; // @codeCoverageIgnore + } + $meta = stream_get_meta_data($fp); + $tmpName = isset($meta['uri']) ? $meta['uri'] : ''; + $this->tmpFileHandles[] = $fp; // retain handle for lifecycle + return ['tmp_name' => $tmpName, 'error' => UPLOAD_ERR_OK]; } } diff --git a/tests/RequestBodyParserTest.php b/tests/RequestBodyParserTest.php index 55e3b5a8..1cc5bdf7 100644 --- a/tests/RequestBodyParserTest.php +++ b/tests/RequestBodyParserTest.php @@ -24,10 +24,6 @@ protected function tearDown(): void { unset($_REQUEST); unset($_SERVER); - unset($_GET); - unset($_POST); - unset($_COOKIE); - unset($_FILES); } private function createRequestConfig(string $method, string $contentType, string $body, &$tmpfile = null): array @@ -65,7 +61,7 @@ private function assertUrlEncodedParsing(string $method): void $body = 'foo=bar&baz=qux&key=value'; $tmpfile = null; $config = $this->createRequestConfig($method, 'application/x-www-form-urlencoded', $body, $tmpfile); - + $request = new Request($config); $expectedData = [ @@ -81,7 +77,7 @@ private function assertUrlEncodedParsing(string $method): void private function createMultipartBody(string $boundary, array $fields, array $files = []): string { $body = ''; - + // Add form fields foreach ($fields as $name => $value) { if (is_array($value)) { @@ -109,7 +105,7 @@ private function createMultipartBody(string $boundary, array $fields, array $fil } $body .= "--{$boundary}--\r\n"; - + return $body; } @@ -145,7 +141,7 @@ public function testParseMultipartFormDataWithFiles(): void $request = new Request($config); $this->assertEquals(['title' => 'Test Document'], $request->data->getData()); - + $file = $request->getUploadedFiles()['file']; $this->assertEquals('file.txt', $file->getClientFilename()); $this->assertEquals('text/plain', $file->getClientMediaType()); @@ -259,4 +255,195 @@ public function testPostMethodDoesNotTriggerParsing(): void fclose($tmpfile); } -} \ No newline at end of file + + /** + * Tests getUploadMaxFileSize parsing for various php.ini unit suffixes. + * We'll call the method in-process after setting ini values via ini_set + * and also simulate a value with unknown unit to hit the default branch. + */ + public function testGetUploadMaxFileSizeUnits(): void + { + // Use PHP CLI with -d to set upload_max_filesize (ini_set can't change this setting in many SAPIs) + $cases = [ + // No unit yields default branch which returns 0 in current implementation + ['1' , 0], // no unit and number too small + ['1K' , 1024], + ['2M' , 2 * 1024 * 1024], + ['1G' , 1024 * 1024 * 1024], + ['1T' , 1024 * 1024 * 1024 * 1024], + ['1Z' , 0 ], // Unknown unit and number too small + [ '1024', 1024 ] + ]; + + foreach ($cases as [$iniVal, $expected]) { + $actual = Request::parsePhpSize($iniVal); + $this->assertEquals($expected, $actual, "upload_max_filesize={$iniVal}"); + } + } + + /** + * Helper: run PHP CLI with -d upload_max_filesize and return the Request::getUploadMaxFileSize() result. + */ + // removed CLI helper; parsePhpSize covers unit parsing and is pure + + public function testMultipartBoundaryInvalidFallsBackToUrlEncoded(): void + { + // Body doesn't start with boundary marker => fallback to urlencoded branch + $body = 'field1=value1&field2=value2'; + $tmp = tmpfile(); + $path = stream_get_meta_data($tmp)['uri']; + file_put_contents($path, $body); + + $request = new Request([ + 'url' => '/upload', + 'base' => '/', + 'method' => 'PATCH', + 'type' => 'multipart/form-data; boundary=BOUNDARYXYZ', // claims multipart + 'stream_path' => $path, + 'data' => new Collection(), + 'query' => new Collection(), + 'files' => new Collection(), + ]); + + $this->assertEquals(['field1' => 'value1', 'field2' => 'value2'], $request->data->getData()); + $this->assertSame([], $request->files->getData()); + } + + public function testMultipartParsingEdgeCases(): void + { + $boundary = 'MBOUND123'; + $parts = []; + + // A: invalid split (no blank line) => skipped + $parts[] = "Content-Disposition: form-data; name=\"skipnosplit\""; // no value portion + + // B: missing content-disposition entirely => skipped + $parts[] = "Content-Type: text/plain\r\n\r\nignoredvalue"; + + // C: header too long (>16384) => skipped + $longHeader = 'Content-Disposition: form-data; name="toolong"; filename="toolong.txt"; ' . str_repeat('x', 16500); + $parts[] = $longHeader . "\r\n\r\nlongvalue"; + + // D: header line without colon gets skipped but rest processed; becomes non-file field + $parts[] = "BadHeaderLine\r\nContent-Disposition: form-data; name=\"fieldX\"\r\n\r\nvalueX"; + + // E: disposition without name => skipped + $parts[] = "Content-Disposition: form-data; filename=\"nofname.txt\"\r\n\r\nnoNameValue"; + + // F: empty name => skipped + $parts[] = "Content-Disposition: form-data; name=\"\"; filename=\"empty.txt\"\r\n\r\nemptyNameValue"; + + // G: invalid filename triggers sanitized fallback + $parts[] = "Content-Disposition: form-data; name=\"filebad\"; filename=\"a*b?.txt\"\r\nContent-Type: text/plain\r\n\r\nFILEBAD"; + + // H1 & H2: two files same key for aggregation logic (arrays) + $parts[] = "Content-Disposition: form-data; name=\"filemulti\"; filename=\"one.txt\"\r\nContent-Type: text/plain\r\n\r\nONE"; + $parts[] = "Content-Disposition: form-data; name=\"filemulti\"; filename=\"two.txt\"\r\nContent-Type: text/plain\r\n\r\nTWO"; + + // I: file exceeding total bytes triggers UPLOAD_ERR_INI_SIZE + $parts[] = "Content-Disposition: form-data; name=\"filebig\"; filename=\"big.txt\"\r\nContent-Type: text/plain\r\n\r\n" . str_repeat('A', 10); + + // Build full body + $body = ''; + foreach ($parts as $p) { + $body .= '--' . $boundary . "\r\n" . $p . "\r\n"; + } + $body .= '--' . $boundary . "--\r\n"; + + $tmp = tmpfile(); + $path = stream_get_meta_data($tmp)['uri']; + file_put_contents($path, $body); + + $request = new Request([ + 'url' => '/upload', + 'base' => '/', + 'method' => 'PATCH', + 'type' => 'multipart/form-data; boundary=' . $boundary, + 'stream_path' => $path, + 'data' => new Collection(), + 'query' => new Collection(), + 'files' => new Collection(), + ]); + + $data = $request->data->getData(); + $this->assertArrayHasKey('fieldX', $data); // only processed non-file field + $this->assertEquals('valueX', $data['fieldX']); + $files = $request->files->getData(); + + // filebad fallback name + $this->assertArrayHasKey('filebad', $files); + $this->assertMatchesRegularExpression('/^upload_/', $files['filebad']['name']); + + // filemulti aggregated arrays + $this->assertArrayHasKey('filemulti', $files); + $this->assertEquals(['one.txt', 'two.txt'], $files['filemulti']['name']); + $this->assertEquals(['text/plain', 'text/plain'], $files['filemulti']['type']); + + // filebig error path + $this->assertArrayHasKey('filebig', $files); + $uploadMax = Request::parsePhpSize(ini_get('upload_max_filesize')); + $postMax = Request::parsePhpSize(ini_get('post_max_size')); + $shouldError = ($uploadMax > 0 && $uploadMax < 10) || ($postMax > 0 && $postMax < 10); + if ($shouldError) { + $this->assertEquals(UPLOAD_ERR_INI_SIZE, $files['filebig']['error']); + } else { + $this->assertEquals(UPLOAD_ERR_OK, $files['filebig']['error']); + } + } + + + public function testMultipartEmptyArrayNameStripped(): void + { + // Covers line where keyName becomes empty after removing [] (name="[]") and header param extraction (preg_match_all) + $boundary = 'BOUNDARYEMPTY'; + $validFilePart = "Content-Disposition: form-data; name=\"fileok\"; filename=\"ok.txt\"\r\nContent-Type: text/plain\r\n\r\nOK"; + $emptyNameFilePart = "Content-Disposition: form-data; name=\"[]\"; filename=\"empty.txt\"\r\nContent-Type: text/plain\r\n\r\nSHOULD_SKIP"; + $body = '--' . $boundary . "\r\n" . $validFilePart . "\r\n" . '--' . $boundary . "\r\n" . $emptyNameFilePart . "\r\n" . '--' . $boundary . "--\r\n"; + + $tmp = tmpfile(); + $path = stream_get_meta_data($tmp)['uri']; + file_put_contents($path, $body); + + $request = new Request([ + 'url' => '/upload', + 'base' => '/', + 'method' => 'PATCH', + 'type' => 'multipart/form-data; boundary=' . $boundary, + 'stream_path' => $path, + 'data' => new Collection(), + 'query' => new Collection(), + 'files' => new Collection(), + ]); + + $files = $request->files->getData(); + // fileok processed + $this->assertArrayHasKey('fileok', $files); + // name="[]" stripped => keyName becomes empty -> skipped + $this->assertArrayNotHasKey('empty', $files); // just to show not mistakenly created + $this->assertCount(5, $files['fileok']); // meta keys name,type,size,tmp_name,error + } + + public function testMultipartMalformedBoundaryFallsBackToUrlEncoded(): void + { + // boundary has invalid characters (spaces) so regex validation fails -> line 589 path + $invalidBoundary = 'BAD BOUNDARY WITH SPACE'; + $body = 'alpha=1&beta=2'; // should parse as urlencoded after fallback + $tmp = tmpfile(); + $path = stream_get_meta_data($tmp)['uri']; + file_put_contents($path, $body); + + $request = new Request([ + 'url' => '/upload', + 'base' => '/', + 'method' => 'PATCH', + 'type' => 'multipart/form-data; boundary=' . $invalidBoundary, + 'stream_path' => $path, + 'data' => new Collection(), + 'query' => new Collection(), + 'files' => new Collection(), + ]); + + $this->assertEquals(['alpha' => '1', 'beta' => '2'], $request->data->getData()); + $this->assertSame([], $request->files->getData()); + } +} From 049d445c58cbaccff9965dff926fb3d8a3bbbdc2 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Wed, 22 Oct 2025 01:26:54 +0900 Subject: [PATCH 316/331] fix: Support file uploads for non-POST HTTP methods (PATCH, PUT, DELETE) --- flight/net/UploadedFile.php | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/flight/net/UploadedFile.php b/flight/net/UploadedFile.php index 2b3947be..07e0af27 100644 --- a/flight/net/UploadedFile.php +++ b/flight/net/UploadedFile.php @@ -114,15 +114,32 @@ public function moveTo(string $targetPath): void throw new Exception($this->getUploadErrorMessage($this->error)); } + // Check if this is a legitimate uploaded file (POST method uploads) $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')) { + + if ($isUploadedFile === true) { + // Standard POST upload - use move_uploaded_file for security + if (move_uploaded_file($this->tmpName, $targetPath) === false) { + throw new Exception('Cannot move uploaded file'); // @codeCoverageIgnore + } + } elseif (getenv('PHPUNIT_TEST')) { rename($this->tmpName, $targetPath); + } elseif (file_exists($this->tmpName) === true && is_readable($this->tmpName) === true) { + // Handle non-POST uploads (PATCH, PUT, DELETE) or other valid temp files + // Verify the file is in a valid temp directory for security + $tempDir = sys_get_temp_dir(); + $uploadTmpDir = ini_get('upload_tmp_dir') ?: $tempDir; + + if (strpos(realpath($this->tmpName), realpath($uploadTmpDir)) === 0 || + strpos(realpath($this->tmpName), realpath($tempDir)) === 0) { + if (rename($this->tmpName, $targetPath) === false) { + throw new Exception('Cannot move uploaded file'); + } + } else { + throw new Exception('Invalid temporary file location'); + } + } else { + throw new Exception('Temporary file does not exist or is not readable'); } } From b174bdbc6f4312b0bebb0f6172f00517249a8e26 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Mon, 27 Oct 2025 08:01:01 -0600 Subject: [PATCH 317/331] more changes with upload. Security enhancements. Also got rid of php-watcher --- composer.json | 3 +- flight/net/UploadedFile.php | 71 ++++++++++++++++++++++------------- tests/UploadedFileTest.php | 74 +++++++++++++++++++++++++++++++++++-- 3 files changed, 118 insertions(+), 30 deletions(-) diff --git a/composer.json b/composer.json index 96d45dbe..2e6ca74b 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,6 @@ "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^9.6", "rregeer/phpunit-coverage-check": "^0.3.1", - "spatie/phpunit-watcher": "^1.23 || ^1.24", "squizlabs/php_codesniffer": "^3.11" }, "config": { @@ -62,7 +61,7 @@ "sort-packages": true }, "scripts": { - "test": "vendor/bin/phpunit-watcher watch", + "test": "phpunit", "test-ci": "phpunit", "test-coverage": "rm -f clover.xml && XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage --coverage-clover=clover.xml && vendor/bin/coverage-check clover.xml 100", "test-server": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server/", diff --git a/flight/net/UploadedFile.php b/flight/net/UploadedFile.php index 07e0af27..c2f3ad32 100644 --- a/flight/net/UploadedFile.php +++ b/flight/net/UploadedFile.php @@ -33,6 +33,11 @@ class UploadedFile */ private int $error; + /** + * @var bool $isPostUploadedFile Indicates if the file was uploaded via POST method. + */ + private bool $isPostUploadedFile = false; + /** * Constructs a new UploadedFile object. * @@ -41,14 +46,20 @@ class UploadedFile * @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. + * @param bool|null $isPostUploadedFile Indicates if the file was uploaded via POST method. */ - public function __construct(string $name, string $mimeType, int $size, string $tmpName, int $error) + public function __construct(string $name, string $mimeType, int $size, string $tmpName, int $error, ?bool $isPostUploadedFile = null) { $this->name = $name; $this->mimeType = $mimeType; $this->size = $size; $this->tmpName = $tmpName; $this->error = $error; + if (is_uploaded_file($tmpName) === true) { + $this->isPostUploadedFile = true; // @codeCoverageIgnore + } else { + $this->isPostUploadedFile = $isPostUploadedFile ?? false; + } } /** @@ -114,32 +125,42 @@ public function moveTo(string $targetPath): void throw new Exception($this->getUploadErrorMessage($this->error)); } + if (is_writeable(dirname($targetPath)) === false) { + throw new Exception('Target directory is not writable'); + } + + // Prevent path traversal attacks + if (strpos($targetPath, '..') !== false) { + throw new Exception('Invalid target path: contains directory traversal'); + } + // Prevent absolute paths (basic check for Unix/Windows) + if ($targetPath[0] === '/' || (strlen($targetPath) > 1 && $targetPath[1] === ':')) { + throw new Exception('Invalid target path: absolute paths not allowed'); + } + + // Prevent overwriting existing files + if (file_exists($targetPath)) { + throw new Exception('Target file already exists'); + } + // Check if this is a legitimate uploaded file (POST method uploads) - $isUploadedFile = is_uploaded_file($this->tmpName) === true; - - if ($isUploadedFile === true) { + $isUploadedFile = $this->isPostUploadedFile; + + // Prevent symlink attacks for non-POST uploads + if (!$isUploadedFile && is_link($this->tmpName)) { + throw new Exception('Invalid temp file: symlink detected'); + } + + $uploadFunctionToCall = $isUploadedFile === true ? // Standard POST upload - use move_uploaded_file for security - if (move_uploaded_file($this->tmpName, $targetPath) === false) { - throw new Exception('Cannot move uploaded file'); // @codeCoverageIgnore - } - } elseif (getenv('PHPUNIT_TEST')) { - rename($this->tmpName, $targetPath); - } elseif (file_exists($this->tmpName) === true && is_readable($this->tmpName) === true) { - // Handle non-POST uploads (PATCH, PUT, DELETE) or other valid temp files - // Verify the file is in a valid temp directory for security - $tempDir = sys_get_temp_dir(); - $uploadTmpDir = ini_get('upload_tmp_dir') ?: $tempDir; - - if (strpos(realpath($this->tmpName), realpath($uploadTmpDir)) === 0 || - strpos(realpath($this->tmpName), realpath($tempDir)) === 0) { - if (rename($this->tmpName, $targetPath) === false) { - throw new Exception('Cannot move uploaded file'); - } - } else { - throw new Exception('Invalid temporary file location'); - } - } else { - throw new Exception('Temporary file does not exist or is not readable'); + 'move_uploaded_file' : + // Handle non-POST uploads (PATCH, PUT, DELETE) or other valid temp files + 'rename'; + + $result = $uploadFunctionToCall($this->tmpName, $targetPath); + + if ($result === false) { + throw new Exception('Cannot move uploaded file'); } } diff --git a/tests/UploadedFileTest.php b/tests/UploadedFileTest.php index 05cdc956..f64e155a 100644 --- a/tests/UploadedFileTest.php +++ b/tests/UploadedFileTest.php @@ -18,14 +18,24 @@ public function tearDown(): void if (file_exists('tmp_name')) { unlink('tmp_name'); } + if (file_exists('existing.txt')) { + unlink('existing.txt'); + } + if (file_exists('real_file')) { + unlink('real_file'); + } + + // not found with file_exists...just delete it brute force + @unlink('tmp_symlink'); } - public function testMoveToSuccess(): void + public function testMoveToFalseSuccess(): void { + // This test would have passed in the real world but we can't actually force a post request in unit tests file_put_contents('tmp_name', 'test'); - $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, true); + $this->expectExceptionMessage('Cannot move uploaded file'); $uploadedFile->moveTo('file.txt'); - $this->assertFileExists('file.txt'); } public function getFileErrorMessageTests(): array @@ -53,4 +63,62 @@ public function testMoveToFailureMessages($error, $message) $this->expectExceptionMessage($message); $uploadedFile->moveTo('file.txt'); } + + public function testMoveToBadLocation(): void + { + file_put_contents('tmp_name', 'test'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, true); + $this->expectExceptionMessage('Target directory is not writable'); + $uploadedFile->moveTo('/root/file.txt'); + } + + public function testMoveToSuccessNonPost(): void + { + file_put_contents('tmp_name', 'test'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, false); + $uploadedFile->moveTo('file.txt'); + $this->assertFileExists('file.txt'); + $this->assertEquals('test', file_get_contents('file.txt')); + } + + public function testMoveToPathTraversal(): void + { + file_put_contents('tmp_name', 'test'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, false); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid target path: contains directory traversal'); + $uploadedFile->moveTo('../file.txt'); + } + + public function testMoveToAbsolutePath(): void + { + file_put_contents('tmp_name', 'test'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, false); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid target path: absolute paths not allowed'); + $uploadedFile->moveTo('/tmp/file.txt'); + } + + public function testMoveToOverwrite(): void + { + file_put_contents('tmp_name', 'test'); + file_put_contents('existing.txt', 'existing'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK, false); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Target file already exists'); + $uploadedFile->moveTo('existing.txt'); + } + + public function testMoveToSymlinkNonPost(): void + { + file_put_contents('real_file', 'test'); + if (file_exists('tmp_symlink')) { + unlink('tmp_symlink'); + } + symlink('real_file', 'tmp_symlink'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_symlink', UPLOAD_ERR_OK, false); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid temp file: symlink detected'); + $uploadedFile->moveTo('file.txt'); + } } From 09cb95c6e88b724bf8910c5fba749d07fb3ba501 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Tue, 28 Oct 2025 10:45:04 +0900 Subject: [PATCH 318/331] revert: Re-add string type hint to Response::write() parameter --- flight/net/Response.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flight/net/Response.php b/flight/net/Response.php index 6d3da1c2..9587ddf8 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -237,7 +237,7 @@ public function getHeaders(): array * * @return $this Self reference */ - public function write($str, bool $overwrite = false): self + public function write(string $str, bool $overwrite = false): self { if ($overwrite === true) { $this->clearBody(); From 40f176fa3ab4012710af8c397dc9d1bf16489111 Mon Sep 17 00:00:00 2001 From: riku Date: Wed, 19 Nov 2025 06:55:13 +0900 Subject: [PATCH 319/331] Fixed an issue where `Flight::request()->base` would display only a yen symbol in Windows environments without a parent directory. --- flight/net/Request.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flight/net/Request.php b/flight/net/Request.php index 4aa51f46..18d1692a 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -173,6 +173,9 @@ public function __construct(array $config = []) $base = str_replace(['\\', ' '], ['/', '%20'], $base); } $base = dirname($base); + if ($base === '\\') { + $base = '/'; + } $config = [ 'url' => $url, 'base' => $base, From 28ae1fa03835acbb5f8fdf8a507b6250fb814886 Mon Sep 17 00:00:00 2001 From: Joan Miquel Date: Mon, 24 Nov 2025 12:55:21 +0100 Subject: [PATCH 320/331] Add PHP 8.5 to testing matrix --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2a6ec381..4e5513e3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - php: [7.4, 8.0, 8.1, 8.2, 8.3, 8.4] + php: [7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5] runs-on: ubuntu-latest steps: - name: Checkout repository @@ -20,4 +20,4 @@ jobs: extensions: curl, mbstring tools: composer:v2 - run: composer install - - run: composer test-ci \ No newline at end of file + - run: composer test-ci From 70379a94132f4a21fb5f4430aa757bc7f33b51e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A3=9E=E6=89=AC?= <3280204+ycrao@users.noreply.github.com> Date: Thu, 25 Dec 2025 10:03:43 +0800 Subject: [PATCH 321/331] Update RouteCommand.php fix return Bad Middleware when using class string name --- flight/commands/RouteCommand.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flight/commands/RouteCommand.php b/flight/commands/RouteCommand.php index 8b5408e9..bbcf52e3 100644 --- a/flight/commands/RouteCommand.php +++ b/flight/commands/RouteCommand.php @@ -65,7 +65,11 @@ public function execute() if (!empty($route->middleware)) { try { $middlewares = array_map(function ($middleware) { - $middleware_class_name = explode("\\", get_class($middleware)); + if (is_string($middleware)) { + $middleware_class_name = explode("\\", $middleware); + } else { + $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) { From 8b047021fef52df2444c4674f49afbc643646c97 Mon Sep 17 00:00:00 2001 From: yangzhi-rao Date: Fri, 26 Dec 2025 12:13:36 +0800 Subject: [PATCH 322/331] update route tests --- tests/commands/RouteCommandTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/commands/RouteCommandTest.php b/tests/commands/RouteCommandTest.php index 8683871f..0d2aee79 100644 --- a/tests/commands/RouteCommandTest.php +++ b/tests/commands/RouteCommandTest.php @@ -113,7 +113,7 @@ public function testGetRoutes(): void | /post | POST, OPTIONS | | No | Closure | | /delete | DELETE, OPTIONS | | No | - | | /put | PUT, OPTIONS | | No | - | - | /patch | PATCH, OPTIONS | | No | Bad Middleware | + | /patch | PATCH, OPTIONS | | No | SomeMiddleware | +---------+--------------------+-------+----------+----------------+ output; // phpcs:ignore From 009f2f9bad212c78234a355ae29d8c64f1373f7c Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Fri, 26 Dec 2025 23:10:19 -0700 Subject: [PATCH 323/331] Fix AI commands to use new runway config syntax --- composer.json | 25 +- flight/Engine.php | 2 +- .../AiGenerateInstructionsCommand.php | 66 +++-- flight/commands/AiInitCommand.php | 70 +---- tests/classes/NoExitInteractor.php | 21 ++ .../AiGenerateInstructionsCommandTest.php | 209 +++++++++++---- tests/commands/AiInitCommandTest.php | 248 +++++------------- 7 files changed, 310 insertions(+), 331 deletions(-) create mode 100644 tests/classes/NoExitInteractor.php diff --git a/composer.json b/composer.json index 96d45dbe..b2d77bf8 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,7 @@ "require-dev": { "ext-pdo_sqlite": "*", "flightphp/container": "^1.0", - "flightphp/runway": "^0.2.3 || ^1.0", + "flightphp/runway": "^1.2", "league/container": "^4.2", "level-2/dice": "^4.0", "phpstan/extension-installer": "^1.4", @@ -62,21 +62,22 @@ "sort-packages": true }, "scripts": { - "test": "vendor/bin/phpunit-watcher watch", + "test": "phpunit", + "test-watcher": "phpunit-watcher watch", "test-ci": "phpunit", "test-coverage": "rm -f clover.xml && XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage --coverage-clover=clover.xml && vendor/bin/coverage-check clover.xml 100", "test-server": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server/", "test-server-v2": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server-v2/", "test-coverage:win": "del clover.xml && phpunit --coverage-html=coverage --coverage-clover=clover.xml && coverage-check clover.xml 100", - "test-performance": [ - "echo \"Running Performance Tests...\"", - "php -S localhost:8077 -t tests/performance/ > /dev/null 2>&1 & echo $! > server.pid", - "sleep 2", - "bash tests/performance/performance_tests.sh", - "kill `cat server.pid`", - "rm server.pid", - "echo \"Performance Tests Completed.\"" - ], + "test-performance": [ + "echo \"Running Performance Tests...\"", + "php -S localhost:8077 -t tests/performance/ > /dev/null 2>&1 & echo $! > server.pid", + "sleep 2", + "bash tests/performance/performance_tests.sh", + "kill `cat server.pid`", + "rm server.pid", + "echo \"Performance Tests Completed.\"" + ], "lint": "phpstan --no-progress --memory-limit=256M -cphpstan.neon", "beautify": "phpcbf --standard=phpcs.xml", "phpcs": "phpcs --standard=phpcs.xml -n", @@ -92,4 +93,4 @@ "replace": { "mikecao/flight": "2.0.2" } -} +} \ No newline at end of file diff --git a/flight/Engine.php b/flight/Engine.php index 0cfaf683..fe88301e 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -621,7 +621,7 @@ public function _start(): void $response->write(ob_get_clean()); } - // Run any before middlewares + // Run any after middlewares if (count($route->middleware) > 0) { // process the middleware in reverse order now $atLeastOneMiddlewareFailed = $this->processMiddleware($route, 'after'); diff --git a/flight/commands/AiGenerateInstructionsCommand.php b/flight/commands/AiGenerateInstructionsCommand.php index 90dd7cb0..cb78e9a7 100644 --- a/flight/commands/AiGenerateInstructionsCommand.php +++ b/flight/commands/AiGenerateInstructionsCommand.php @@ -4,24 +4,23 @@ namespace flight\commands; -use Ahc\Cli\Input\Command; - /** - * @property-read ?string $credsFile + * @property-read ?string $configFile * @property-read ?string $baseDir */ -class AiGenerateInstructionsCommand extends Command +class AiGenerateInstructionsCommand extends AbstractBaseCommand { /** * Constructor for the AiGenerateInstructionsCommand class. * * Initializes a new instance of the command. + * + * @param array $config Config from config.php */ - public function __construct() + public function __construct(array $config) { - parent::__construct('ai:generate-instructions', 'Generate project-specific AI coding instructions'); - $this->option('--creds-file', 'Path to .runway-creds.json file', null, ''); - $this->option('--base-dir', 'Project base directory (for testing or custom use)', null, ''); + parent::__construct('ai:generate-instructions', 'Generate project-specific AI coding instructions', $config); + $this->option('--config-file', 'Path to .runway-config.json file (deprecated, use config.php instead)', null, ''); } /** @@ -37,12 +36,19 @@ public function __construct() public function execute() { $io = $this->app()->io(); - $baseDir = $this->baseDir ? rtrim($this->baseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR : getcwd() . DIRECTORY_SEPARATOR; - $runwayCredsFile = $this->credsFile ?: $baseDir . '.runway-creds.json'; - // Check for runway creds - if (!file_exists($runwayCredsFile)) { - $io->error('Missing .runway-creds.json. Please run the \'ai:init\' command first.', true); + if (empty($this->config['runway'])) { + $configFile = $this->configFile; + $io = $this->app()->io(); + $io->warn('The --config-file option is deprecated. Move your config values to the \'runway\' key in the config.php file for configuration.', true); + $runwayConfig = json_decode(file_get_contents($configFile), true) ?? []; + } else { + $runwayConfig = $this->config['runway']; + } + + // Check for runway creds ai + if (empty($runwayConfig['ai'])) { + $io->error('Missing AI configuration. Please run the \'ai:init\' command first.', true); return 1; } @@ -61,8 +67,8 @@ public function execute() $other = $io->prompt('Any other important requirements or context? (optional)', 'no'); // Prepare prompt for LLM - $contextFile = $baseDir . '.github/copilot-instructions.md'; - $context = file_exists($contextFile) ? file_get_contents($contextFile) : ''; + $contextFile = $this->projectRoot . '.github/copilot-instructions.md'; + $context = file_exists($contextFile) === true ? file_get_contents($contextFile) : ''; $userDetails = [ 'Project Description' => $projectDesc, 'Database' => $database, @@ -80,7 +86,7 @@ public function execute() $detailsText .= "$k: $v\n"; } $prompt = <<info('Updating .github/copilot-instructions.md, .cursor/rules/project-overview.mdc, and .windsurfrules...', true); - if (!is_dir($baseDir . '.github')) { - mkdir($baseDir . '.github', 0755, true); + $io->info('Updating .github/copilot-instructions.md, .cursor/rules/project-overview.mdc, .gemini/GEMINI.md and .windsurfrules...', true); + if (!is_dir($this->projectRoot . '.github')) { + mkdir($this->projectRoot . '.github', 0755, true); + } + if (!is_dir($this->projectRoot . '.cursor/rules')) { + mkdir($this->projectRoot . '.cursor/rules', 0755, true); } - if (!is_dir($baseDir . '.cursor/rules')) { - mkdir($baseDir . '.cursor/rules', 0755, true); + if (!is_dir($this->projectRoot . '.gemini')) { + mkdir($this->projectRoot . '.gemini', 0755, true); } - file_put_contents($baseDir . '.github/copilot-instructions.md', $instructions); - file_put_contents($baseDir . '.cursor/rules/project-overview.mdc', $instructions); - file_put_contents($baseDir . '.windsurfrules', $instructions); + file_put_contents($this->projectRoot . '.github/copilot-instructions.md', $instructions); + file_put_contents($this->projectRoot . '.cursor/rules/project-overview.mdc', $instructions); + file_put_contents($this->projectRoot . '.gemini/GEMINI.md', $instructions); + file_put_contents($this->projectRoot . '.windsurfrules', $instructions); $io->ok('AI instructions updated successfully.', true); return 0; } diff --git a/flight/commands/AiInitCommand.php b/flight/commands/AiInitCommand.php index f60ce95f..f24332d1 100644 --- a/flight/commands/AiInitCommand.php +++ b/flight/commands/AiInitCommand.php @@ -4,25 +4,22 @@ namespace flight\commands; -use Ahc\Cli\Input\Command; - /** - * @property-read ?string $gitignoreFile - * @property-read ?string $credsFile + * @property-read ?string $credsFile Deprecated, use config.php instead */ -class AiInitCommand extends Command +class AiInitCommand extends AbstractBaseCommand { /** * Constructor for the AiInitCommand class. * * Initializes the command instance and sets up any required dependencies. + * + * @param array $config Config from config.php */ - public function __construct() + public function __construct(array $config) { - parent::__construct('ai:init', 'Initialize LLM API credentials and settings'); - $this - ->option('--gitignore-file', 'Path to .gitignore file', null, '') - ->option('--creds-file', 'Path to .runway-creds.json file', null, ''); + parent::__construct('ai:init', 'Initialize LLM API credentials and settings', $config); + $this->option('--creds-file', 'Path to .runway-creds.json file (deprecated, use config.php instead)', null, ''); } /** @@ -36,21 +33,6 @@ public function execute() $io->info('Welcome to AI Init!', true); - $baseDir = getcwd() . DIRECTORY_SEPARATOR; - $runwayCredsFile = $this->credsFile ?: $baseDir . '.runway-creds.json'; - $gitignoreFile = $this->gitignoreFile ?: $baseDir . '.gitignore'; - - // make sure the .runway-creds.json file is not already present - if (file_exists($runwayCredsFile)) { - $io->error('.runway-creds.json file already exists. Please remove it before running this command.', true); - // prompt to overwrite - $overwrite = $io->confirm('Do you want to overwrite the existing .runway-creds.json file?', 'n'); - if ($overwrite === false) { - $io->info('Exiting without changes.', true); - return 0; - } - } - // Prompt for API provider with validation $allowedApis = [ '1' => 'openai', @@ -88,50 +70,26 @@ public function execute() // Validate model input switch ($api) { case 'openai': - $defaultModel = 'gpt-4o'; + $defaultModel = 'gpt-5'; break; case 'grok': - $defaultModel = 'grok-3-beta'; + $defaultModel = 'grok-4.1-fast-non-reasoning'; break; case 'claude': - $defaultModel = 'claude-3-opus'; + $defaultModel = 'claude-sonnet-4-5'; break; } - $model = trim($io->prompt('Enter the model name you want to use (e.g. gpt-4, claude-3-opus, etc)', $defaultModel)); + $model = trim($io->prompt('Enter the model name you want to use (e.g. gpt-5, claude-sonnet-4-5, etc)', $defaultModel)); - $creds = [ + $runwayAiConfig = [ 'provider' => $api, 'api_key' => $apiKey, 'model' => $model, 'base_url' => $baseUrl, ]; + $this->setRunwayConfigValue('ai', $runwayAiConfig); - $json = json_encode($creds, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $file = $runwayCredsFile; - file_put_contents($file, $json); - - // change permissions to 600 - chmod($file, 0600); - - $io->ok('Credentials saved to ' . $file, true); - - // run a check to make sure that the creds file is in the .gitignore file - // use $gitignoreFile instead of hardcoded path - if (!file_exists($gitignoreFile)) { - // create the .gitignore file if it doesn't exist - file_put_contents($gitignoreFile, basename($runwayCredsFile) . "\n"); - $io->info(basename($gitignoreFile) . ' file created and ' . basename($runwayCredsFile) . ' added to it.', true); - } else { - // check if the creds file is already in the .gitignore file - $gitignoreContents = file_get_contents($gitignoreFile); - if (strpos($gitignoreContents, basename($runwayCredsFile)) === false) { - // add the creds file to the .gitignore file - file_put_contents($gitignoreFile, "\n" . basename($runwayCredsFile) . "\n", FILE_APPEND); - $io->info(basename($runwayCredsFile) . ' added to ' . basename($gitignoreFile) . ' file.', true); - } else { - $io->info(basename($runwayCredsFile) . ' is already in the ' . basename($gitignoreFile) . ' file.', true); - } - } + $io->ok('Credentials saved to app/config/config.php', true); return 0; } diff --git a/tests/classes/NoExitInteractor.php b/tests/classes/NoExitInteractor.php new file mode 100644 index 00000000..737f49c8 --- /dev/null +++ b/tests/classes/NoExitInteractor.php @@ -0,0 +1,21 @@ +writer()->error($text, 0); + return $this; + } + public function warn(string $text, bool $exit = false): self + { + $this->writer()->warn($text, 0); + return $this; + } +} diff --git a/tests/commands/AiGenerateInstructionsCommandTest.php b/tests/commands/AiGenerateInstructionsCommandTest.php index 6e094840..ea842edf 100644 --- a/tests/commands/AiGenerateInstructionsCommandTest.php +++ b/tests/commands/AiGenerateInstructionsCommandTest.php @@ -5,16 +5,15 @@ namespace tests\commands; use Ahc\Cli\Application; -use Ahc\Cli\IO\Interactor; use flight\commands\AiGenerateInstructionsCommand; use PHPUnit\Framework\TestCase; +use tests\classes\NoExitInteractor; class AiGenerateInstructionsCommandTest extends TestCase { protected static $in; protected static $ou; protected $baseDir; - protected $runwayCredsFile; public function setUp(): void { @@ -26,16 +25,6 @@ public function setUp(): void if (!is_dir($this->baseDir)) { mkdir($this->baseDir, 0777, true); } - $this->runwayCredsFile = $this->baseDir . 'dummy-creds.json'; - if (file_exists($this->runwayCredsFile)) { - unlink($this->runwayCredsFile); - } - @unlink($this->baseDir . '.github/copilot-instructions.md'); - @unlink($this->baseDir . '.cursor/rules/project-overview.mdc'); - @unlink($this->baseDir . '.windsurfrules'); - @rmdir($this->baseDir . '.github'); - @rmdir($this->baseDir . '.cursor/rules'); - @rmdir($this->baseDir . '.cursor'); } public function tearDown(): void @@ -46,27 +35,19 @@ public function tearDown(): void if (file_exists(self::$ou)) { unlink(self::$ou); } - if (file_exists($this->runwayCredsFile)) { - unlink($this->runwayCredsFile); - } - @unlink($this->baseDir . '.github/copilot-instructions.md'); - @unlink($this->baseDir . '.cursor/rules/project-overview.mdc'); - @unlink($this->baseDir . '.windsurfrules'); - @rmdir($this->baseDir . '.github'); - @rmdir($this->baseDir . '.cursor/rules'); - @rmdir($this->baseDir . '.cursor'); - if (is_dir($this->baseDir . '.cursor/rules')) { - @rmdir($this->baseDir . '.cursor/rules'); - } - if (is_dir($this->baseDir . '.cursor')) { - @rmdir($this->baseDir . '.cursor'); - } - if (is_dir($this->baseDir . '.github')) { - @rmdir($this->baseDir . '.github'); + $this->recursiveRmdir($this->baseDir); + } + + protected function recursiveRmdir($dir) + { + if (!is_dir($dir)) { + return; } - if (is_dir($this->baseDir)) { - @rmdir($this->baseDir); + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? $this->recursiveRmdir("$dir/$file") : unlink("$dir/$file"); } + return rmdir($dir); } protected function newApp($command): Application @@ -74,7 +55,7 @@ protected function newApp($command): Application $app = new Application('test', '0.0.1', function ($exitCode) { return $exitCode; }); - $app->io(new Interactor(self::$in, self::$ou)); + $app->io(new NoExitInteractor(self::$in, self::$ou)); $app->add($command); return $app; } @@ -84,22 +65,51 @@ protected function setInput(array $lines): void file_put_contents(self::$in, implode("\n", $lines) . "\n"); } - public function testFailsIfCredsFileMissing() + protected function setProjectRoot($command, $path) + { + $reflection = new \ReflectionClass(get_class($command)); + $property = null; + $currentClass = $reflection; + while ($currentClass && !$property) { + try { + $property = $currentClass->getProperty('projectRoot'); + } catch (\ReflectionException $e) { + $currentClass = $currentClass->getParentClass(); + } + } + if ($property) { + $property->setAccessible(true); + $property->setValue($command, $path); + } + } + + public function testFailsIfAiConfigMissing() { $this->setInput([ - 'desc', 'none', 'latte', 'y', 'y', 'none', 'Docker', '1', 'n', 'no' + 'desc', + 'none', + 'latte', + 'y', + 'y', + 'none', + 'Docker', + '1', + 'n', + 'no' ]); + // Provide 'runway' with dummy data to avoid deprecated configFile logic $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([['runway' => ['dummy' => true]]]) ->onlyMethods(['callLlmApi']) ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); $app = $this->newApp($cmd); $result = $app->handle([ - 'runway', 'ai:generate-instructions', - '--creds-file=' . $this->runwayCredsFile, - '--base-dir=' . $this->baseDir + 'runway', + 'ai:generate-instructions', ]); $this->assertSame(1, $result); - $this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md'); + $this->assertStringContainsString('Missing AI configuration', file_get_contents(self::$ou)); } public function testWritesInstructionsToFiles() @@ -109,14 +119,28 @@ public function testWritesInstructionsToFiles() 'model' => 'gpt-4o', 'base_url' => 'https://api.openai.com', ]; - file_put_contents($this->runwayCredsFile, json_encode($creds)); $this->setInput([ - 'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info' + 'desc', + 'mysql', + 'latte', + 'y', + 'y', + 'flight/lib', + 'Docker', + '2', + 'y', + 'context info' ]); $mockInstructions = "# Project Instructions\n\nUse MySQL, Latte, Docker."; $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([ + [ + 'runway' => ['ai' => $creds] + ] + ]) ->onlyMethods(['callLlmApi']) ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); $cmd->expects($this->once()) ->method('callLlmApi') ->willReturn(json_encode([ @@ -126,17 +150,14 @@ public function testWritesInstructionsToFiles() ])); $app = $this->newApp($cmd); $result = $app->handle([ - 'runway', 'ai:generate-instructions', - '--creds-file=' . $this->runwayCredsFile, - '--base-dir=' . $this->baseDir + 'runway', + 'ai:generate-instructions', ]); $this->assertSame(0, $result); $this->assertFileExists($this->baseDir . '.github/copilot-instructions.md'); $this->assertFileExists($this->baseDir . '.cursor/rules/project-overview.mdc'); + $this->assertFileExists($this->baseDir . '.gemini/GEMINI.md'); $this->assertFileExists($this->baseDir . '.windsurfrules'); - $this->assertStringContainsString('MySQL', file_get_contents($this->baseDir . '.github/copilot-instructions.md')); - $this->assertStringContainsString('MySQL', file_get_contents($this->baseDir . '.cursor/rules/project-overview.mdc')); - $this->assertStringContainsString('MySQL', file_get_contents($this->baseDir . '.windsurfrules')); } public function testNoInstructionsReturnedFromLlm() @@ -146,13 +167,27 @@ public function testNoInstructionsReturnedFromLlm() 'model' => 'gpt-4o', 'base_url' => 'https://api.openai.com', ]; - file_put_contents($this->runwayCredsFile, json_encode($creds)); $this->setInput([ - 'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info' + 'desc', + 'mysql', + 'latte', + 'y', + 'y', + 'flight/lib', + 'Docker', + '2', + 'y', + 'context info' ]); $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([ + [ + 'runway' => ['ai' => $creds] + ] + ]) ->onlyMethods(['callLlmApi']) ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); $cmd->expects($this->once()) ->method('callLlmApi') ->willReturn(json_encode([ @@ -162,12 +197,10 @@ public function testNoInstructionsReturnedFromLlm() ])); $app = $this->newApp($cmd); $result = $app->handle([ - 'runway', 'ai:generate-instructions', - '--creds-file=' . $this->runwayCredsFile, - '--base-dir=' . $this->baseDir + 'runway', + 'ai:generate-instructions', ]); $this->assertSame(1, $result); - $this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md'); } public function testLlmApiCallFails() @@ -177,23 +210,83 @@ public function testLlmApiCallFails() 'model' => 'gpt-4o', 'base_url' => 'https://api.openai.com', ]; - file_put_contents($this->runwayCredsFile, json_encode($creds)); $this->setInput([ - 'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info' + 'desc', + 'mysql', + 'latte', + 'y', + 'y', + 'flight/lib', + 'Docker', + '2', + 'y', + 'context info' ]); $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([ + [ + 'runway' => ['ai' => $creds] + ] + ]) ->onlyMethods(['callLlmApi']) ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); $cmd->expects($this->once()) ->method('callLlmApi') ->willReturn(false); $app = $this->newApp($cmd); $result = $app->handle([ - 'runway', 'ai:generate-instructions', - '--creds-file=' . $this->runwayCredsFile, - '--base-dir=' . $this->baseDir + 'runway', + 'ai:generate-instructions', ]); $this->assertSame(1, $result); - $this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md'); + } + + public function testUsesDeprecatedConfigFile() + { + $creds = [ + 'ai' => [ + 'api_key' => 'key', + 'model' => 'gpt-4o', + 'base_url' => 'https://api.openai.com', + ] + ]; + $configFile = $this->baseDir . 'old-config.json'; + file_put_contents($configFile, json_encode($creds)); + $this->setInput([ + 'desc', + 'mysql', + 'latte', + 'y', + 'y', + 'flight/lib', + 'Docker', + '2', + 'y', + 'context info' + ]); + $mockInstructions = "# Project Instructions\n\nUse MySQL, Latte, Docker."; + // runway key is MISSING from config to trigger deprecated logic + $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['callLlmApi']) + ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); + $cmd->expects($this->once()) + ->method('callLlmApi') + ->willReturn(json_encode([ + 'choices' => [ + ['message' => ['content' => $mockInstructions]] + ] + ])); + $app = $this->newApp($cmd); + $result = $app->handle([ + 'runway', + 'ai:generate-instructions', + '--config-file=' . $configFile + ]); + $this->assertSame(0, $result); + $this->assertStringContainsString('The --config-file option is deprecated', file_get_contents(self::$ou)); + $this->assertFileExists($this->baseDir . '.github/copilot-instructions.md'); } } diff --git a/tests/commands/AiInitCommandTest.php b/tests/commands/AiInitCommandTest.php index 1e7dd517..cb46c55e 100644 --- a/tests/commands/AiInitCommandTest.php +++ b/tests/commands/AiInitCommandTest.php @@ -13,9 +13,6 @@ class AiInitCommandTest extends TestCase { protected static $in; protected static $ou; - protected $baseDir; - protected $runwayCredsFile; - protected $gitignoreFile; public function setUp(): void { @@ -23,15 +20,6 @@ public function setUp(): void self::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt'; file_put_contents(self::$in, ''); file_put_contents(self::$ou, ''); - $this->baseDir = getcwd() . DIRECTORY_SEPARATOR; - $this->runwayCredsFile = __DIR__ . DIRECTORY_SEPARATOR . 'dummy-creds-' . uniqid('', true) . '.json'; - $this->gitignoreFile = __DIR__ . DIRECTORY_SEPARATOR . 'dummy-gitignore-' . uniqid('', true); - if (file_exists($this->runwayCredsFile)) { - unlink($this->runwayCredsFile); - } - if (file_exists($this->gitignoreFile)) { - unlink($this->gitignoreFile); - } } public function tearDown(): void @@ -42,24 +30,15 @@ public function tearDown(): void if (file_exists(self::$ou)) { unlink(self::$ou); } - if (file_exists($this->runwayCredsFile)) { - if (is_dir($this->runwayCredsFile)) { - rmdir($this->runwayCredsFile); - } else { - unlink($this->runwayCredsFile); - } - } - if (file_exists($this->gitignoreFile)) { - unlink($this->gitignoreFile); - } } - protected function newApp(): Application + protected function newApp($command): Application { $app = new Application('test', '0.0.1', function ($exitCode) { return $exitCode; }); $app->io(new Interactor(self::$in, self::$ou)); + $app->add($command); return $app; } @@ -68,135 +47,55 @@ protected function setInput(array $lines): void file_put_contents(self::$in, implode("\n", $lines) . "\n"); } - public function testInitCreatesCredsAndGitignore() + public function testInitSavesCreds() { $this->setInput([ - '1', // provider + '1', // provider (openai) '', // accept default base url 'test-key', // api key '', // accept default model ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); - $this->assertSame(0, $result); - $this->assertFileExists($this->runwayCredsFile); - $creds = json_decode(file_get_contents($this->runwayCredsFile), true); - $this->assertSame('openai', $creds['provider']); - $this->assertSame('test-key', $creds['api_key']); - $this->assertSame('gpt-4o', $creds['model']); - $this->assertSame('https://api.openai.com', $creds['base_url']); - $this->assertFileExists($this->gitignoreFile); - $this->assertStringContainsString(basename($this->runwayCredsFile), file_get_contents($this->gitignoreFile)); - } - - public function testInitWithExistingCredsNoOverwrite() - { - file_put_contents($this->runwayCredsFile, '{}'); - $this->setInput([ - 'n', // do not overwrite - ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); + $cmd = $this->getMockBuilder(AiInitCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['setRunwayConfigValue']) + ->getMock(); + $cmd->expects($this->once()) + ->method('setRunwayConfigValue') + ->with('ai', [ + 'provider' => 'openai', + 'api_key' => 'test-key', + 'model' => 'gpt-5', + 'base_url' => 'https://api.openai.com', + ]); + $app = $this->newApp($cmd); + $result = $app->handle(['runway', 'ai:init']); $this->assertSame(0, $result); - $this->assertSame('{}', file_get_contents($this->runwayCredsFile)); + $this->assertStringContainsString('Credentials saved', file_get_contents(self::$ou)); } - public function testInitWithExistingCredsOverwrite() + public function testInitWithGrokProvider() { - file_put_contents($this->runwayCredsFile, '{}'); $this->setInput([ - 'y', // overwrite - '2', // provider + '2', // provider (grok) '', // accept default base url 'grok-key', // api key '', // accept default model ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); + $cmd = $this->getMockBuilder(AiInitCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['setRunwayConfigValue']) + ->getMock(); + $cmd->expects($this->once()) + ->method('setRunwayConfigValue') + ->with('ai', [ + 'provider' => 'grok', + 'api_key' => 'grok-key', + 'model' => 'grok-4.1-fast-non-reasoning', + 'base_url' => 'https://api.x.ai', + ]); + $app = $this->newApp($cmd); + $result = $app->handle(['runway', 'ai:init']); $this->assertSame(0, $result); - $creds = json_decode(file_get_contents($this->runwayCredsFile), true); - $this->assertSame('grok', $creds['provider']); - $this->assertSame('grok-key', $creds['api_key']); - $this->assertSame('grok-3-beta', $creds['model']); - $this->assertSame('https://api.x.ai', $creds['base_url']); - } - - public function testEmptyApiKeyPromptsAgain() - { - $this->setInput([ - '1', - '', // accept default base url - '', // empty api key, should error and exit - ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); - $this->assertSame(1, $result); - $this->assertFileDoesNotExist($this->runwayCredsFile); - } - - public function testEmptyModelPrompts() - { - $this->setInput([ - '1', - '', - 'key', - '', // accept default model (should use default) - ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); - $this->assertSame(0, $result); - $creds = json_decode(file_get_contents($this->runwayCredsFile), true); - $this->assertSame('gpt-4o', $creds['model']); - } - - public function testGitignoreAlreadyHasCreds() - { - file_put_contents($this->gitignoreFile, basename($this->runwayCredsFile) . "\n"); - $this->setInput([ - '1', - '', - 'key', - '', - ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); - $this->assertSame(0, $result); - $this->assertFileExists($this->gitignoreFile); - $lines = file($this->gitignoreFile, FILE_IGNORE_NEW_LINES); - $this->assertContains(basename($this->runwayCredsFile), $lines); - $this->assertCount(1, array_filter($lines, function ($l) { - return trim($l) === basename($this->runwayCredsFile); - })); } public function testInitWithClaudeProvider() @@ -207,44 +106,41 @@ public function testInitWithClaudeProvider() 'claude-key', // api key '', // accept default model ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); + $cmd = $this->getMockBuilder(AiInitCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['setRunwayConfigValue']) + ->getMock(); + $cmd->expects($this->once()) + ->method('setRunwayConfigValue') + ->with('ai', [ + 'provider' => 'claude', + 'api_key' => 'claude-key', + 'model' => 'claude-sonnet-4-5', + 'base_url' => 'https://api.anthropic.com', + ]); + $app = $this->newApp($cmd); + $result = $app->handle(['runway', 'ai:init']); $this->assertSame(0, $result); - $creds = json_decode(file_get_contents($this->runwayCredsFile), true); - $this->assertSame('claude', $creds['provider']); - $this->assertSame('claude-key', $creds['api_key']); - $this->assertSame('claude-3-opus', $creds['model']); - $this->assertSame('https://api.anthropic.com', $creds['base_url']); } - public function testAddsCredsFileToExistingGitignoreIfMissing() + public function testEmptyApiKeyFails() { - // .gitignore exists but does not contain creds file - file_put_contents($this->gitignoreFile, "vendor\nnode_modules\n.DS_Store\n"); $this->setInput([ - '1', // provider + '1', '', // accept default base url - 'test-key', // api key - '', // accept default model - ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); - $this->assertSame(0, $result); - $lines = file($this->gitignoreFile, FILE_IGNORE_NEW_LINES); - $this->assertContains(basename($this->runwayCredsFile), $lines); - $this->assertCount(1, array_filter($lines, function ($l) { - return trim($l) === basename($this->runwayCredsFile); - })); + '', // empty api key + ]); + $cmd = $this->getMockBuilder(AiInitCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['setRunwayConfigValue']) + ->getMock(); + $cmd->expects($this->never()) + ->method('setRunwayConfigValue'); + $app = $this->newApp($cmd); + $result = $app->handle(['runway', 'ai:init']); + // Since $io->error(..., true) exits, Ahc\Cli will return the exit code. + // If it exits with 1, it should be 1. + $this->assertSame(1, $result); } public function testInvalidBaseUrlFails() @@ -253,14 +149,14 @@ public function testInvalidBaseUrlFails() '1', // provider 'not-a-valid-url', // invalid base url ]); - $app = $this->newApp(); - $app->add(new AiInitCommand()); - $result = $app->handle([ - 'runway', 'ai:init', - '--creds-file=' . $this->runwayCredsFile, - '--gitignore-file=' . $this->gitignoreFile - ]); + $cmd = $this->getMockBuilder(AiInitCommand::class) + ->setConstructorArgs([[]]) + ->onlyMethods(['setRunwayConfigValue']) + ->getMock(); + $cmd->expects($this->never()) + ->method('setRunwayConfigValue'); + $app = $this->newApp($cmd); + $result = $app->handle(['runway', 'ai:init']); $this->assertSame(1, $result); - $this->assertFileDoesNotExist($this->runwayCredsFile); } } From e73b4e3f92a0b7c779ba8c8ee2a2bbab804b2ec2 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Fri, 26 Dec 2025 23:13:53 -0700 Subject: [PATCH 324/331] Removed useless method --- .../AiGenerateInstructionsCommandTest.php | 37 ++++++------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/tests/commands/AiGenerateInstructionsCommandTest.php b/tests/commands/AiGenerateInstructionsCommandTest.php index ea842edf..b905f9db 100644 --- a/tests/commands/AiGenerateInstructionsCommandTest.php +++ b/tests/commands/AiGenerateInstructionsCommandTest.php @@ -9,14 +9,12 @@ use PHPUnit\Framework\TestCase; use tests\classes\NoExitInteractor; -class AiGenerateInstructionsCommandTest extends TestCase -{ +class AiGenerateInstructionsCommandTest extends TestCase { protected static $in; protected static $ou; protected $baseDir; - public function setUp(): void - { + public function setUp(): void { self::$in = __DIR__ . DIRECTORY_SEPARATOR . 'input.test' . uniqid('', true) . '.txt'; self::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt'; file_put_contents(self::$in, ''); @@ -27,8 +25,7 @@ public function setUp(): void } } - public function tearDown(): void - { + public function tearDown(): void { if (file_exists(self::$in)) { unlink(self::$in); } @@ -38,8 +35,7 @@ public function tearDown(): void $this->recursiveRmdir($this->baseDir); } - protected function recursiveRmdir($dir) - { + protected function recursiveRmdir($dir) { if (!is_dir($dir)) { return; } @@ -50,8 +46,7 @@ protected function recursiveRmdir($dir) return rmdir($dir); } - protected function newApp($command): Application - { + protected function newApp($command): Application { $app = new Application('test', '0.0.1', function ($exitCode) { return $exitCode; }); @@ -60,13 +55,11 @@ protected function newApp($command): Application return $app; } - protected function setInput(array $lines): void - { + protected function setInput(array $lines): void { file_put_contents(self::$in, implode("\n", $lines) . "\n"); } - protected function setProjectRoot($command, $path) - { + protected function setProjectRoot($command, $path) { $reflection = new \ReflectionClass(get_class($command)); $property = null; $currentClass = $reflection; @@ -78,13 +71,11 @@ protected function setProjectRoot($command, $path) } } if ($property) { - $property->setAccessible(true); $property->setValue($command, $path); } } - public function testFailsIfAiConfigMissing() - { + public function testFailsIfAiConfigMissing() { $this->setInput([ 'desc', 'none', @@ -112,8 +103,7 @@ public function testFailsIfAiConfigMissing() $this->assertStringContainsString('Missing AI configuration', file_get_contents(self::$ou)); } - public function testWritesInstructionsToFiles() - { + public function testWritesInstructionsToFiles() { $creds = [ 'api_key' => 'key', 'model' => 'gpt-4o', @@ -160,8 +150,7 @@ public function testWritesInstructionsToFiles() $this->assertFileExists($this->baseDir . '.windsurfrules'); } - public function testNoInstructionsReturnedFromLlm() - { + public function testNoInstructionsReturnedFromLlm() { $creds = [ 'api_key' => 'key', 'model' => 'gpt-4o', @@ -203,8 +192,7 @@ public function testNoInstructionsReturnedFromLlm() $this->assertSame(1, $result); } - public function testLlmApiCallFails() - { + public function testLlmApiCallFails() { $creds = [ 'api_key' => 'key', 'model' => 'gpt-4o', @@ -242,8 +230,7 @@ public function testLlmApiCallFails() $this->assertSame(1, $result); } - public function testUsesDeprecatedConfigFile() - { + public function testUsesDeprecatedConfigFile() { $creds = [ 'ai' => [ 'api_key' => 'key', From b9818ea27b5f0702cd47eccac138e76bb709f9b2 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Fri, 26 Dec 2025 23:18:02 -0700 Subject: [PATCH 325/331] ok, so only for php 7.4 and 8... --- tests/commands/AiGenerateInstructionsCommandTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/commands/AiGenerateInstructionsCommandTest.php b/tests/commands/AiGenerateInstructionsCommandTest.php index b905f9db..8e534448 100644 --- a/tests/commands/AiGenerateInstructionsCommandTest.php +++ b/tests/commands/AiGenerateInstructionsCommandTest.php @@ -71,6 +71,10 @@ protected function setProjectRoot($command, $path) { } } if ($property) { + // only setAccessible if php 8 or php 7.4 + if (PHP_VERSION_ID < 80100) { + $property->setAccessible(true); + } $property->setValue($command, $path); } } From 888ff46d2683c05fb4a854f691c7cdfc2671fc12 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sat, 27 Dec 2025 08:58:50 -0700 Subject: [PATCH 326/331] Fixed route and controller commands to accept old configs --- flight/commands/ControllerCommand.php | 21 +++++++---- flight/commands/RouteCommand.php | 19 ++++++---- flight/net/Request.php | 8 ++--- tests/UploadedFileTest.php | 4 +-- .../AiGenerateInstructionsCommandTest.php | 36 ++++++++++++------- tests/commands/ControllerCommandTest.php | 10 +++--- tests/commands/RouteCommandTest.php | 13 ++++--- 7 files changed, 71 insertions(+), 40 deletions(-) diff --git a/flight/commands/ControllerCommand.php b/flight/commands/ControllerCommand.php index 9706d97d..a7c10bcd 100644 --- a/flight/commands/ControllerCommand.php +++ b/flight/commands/ControllerCommand.php @@ -29,8 +29,16 @@ public function __construct(array $config) 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); + + if (empty($this->config['runway'])) { + $io->warn('Using a .runway-config.json file is deprecated. Move your config values to app/config/config.php with `php runway config:migrate`.', true); // @codeCoverageIgnore + $runwayConfig = json_decode(file_get_contents($this->projectRoot . '/.runway-config.json'), true); // @codeCoverageIgnore + } else { + $runwayConfig = $this->config['runway']; + } + + if (isset($runwayConfig['app_root']) === false) { + $io->error('app_root not set in app/config/config.php', true); return; } @@ -38,7 +46,7 @@ public function execute(string $controller) $controller .= 'Controller'; } - $controllerPath = getcwd() . DIRECTORY_SEPARATOR . $this->config['app_root'] . 'controllers' . DIRECTORY_SEPARATOR . $controller . '.php'; + $controllerPath = $this->projectRoot . '/' . $runwayConfig['app_root'] . 'controllers/' . $controller . '.php'; if (file_exists($controllerPath) === true) { $io->error($controller . ' already exists.', true); return; @@ -70,7 +78,7 @@ public function execute(string $controller) $namespace->add($class); $file->addNamespace($namespace); - $this->persistClass($controller, $file); + $this->persistClass($controller, $file, $runwayConfig['app_root']); $io->ok('Controller successfully created at ' . $controllerPath, true); } @@ -80,12 +88,13 @@ public function execute(string $controller) * * @param string $controllerName Name of the Controller * @param PhpFile $file Class Object from Nette\PhpGenerator + * @param string $appRoot App Root from runway config * * @return void */ - protected function persistClass(string $controllerName, PhpFile $file) + protected function persistClass(string $controllerName, PhpFile $file, string $appRoot) { $printer = new \Nette\PhpGenerator\PsrPrinter(); - file_put_contents(getcwd() . DIRECTORY_SEPARATOR . $this->config['app_root'] . 'controllers' . DIRECTORY_SEPARATOR . $controllerName . '.php', $printer->printFile($file)); + file_put_contents($this->projectRoot . '/' . $appRoot . 'controllers/' . $controllerName . '.php', $printer->printFile($file)); } } diff --git a/flight/commands/RouteCommand.php b/flight/commands/RouteCommand.php index bbcf52e3..d7427085 100644 --- a/flight/commands/RouteCommand.php +++ b/flight/commands/RouteCommand.php @@ -41,16 +41,21 @@ 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); + if (empty($this->config['runway'])) { + $io->warn('Using a .runway-config.json file is deprecated. Move your config values to app/config/config.php with `php runway config:migrate`.', true); // @codeCoverageIgnore + $runwayConfig = json_decode(file_get_contents($this->projectRoot . '/.runway-config.json'), true); // @codeCoverageIgnore + } else { + $runwayConfig = $this->config['runway']; + } + + if (isset($runwayConfig['index_root']) === false) { + $io->error('index_root not set in app/config/config.php', true); return; } $io->bold('Routes', true); - $cwd = getcwd(); - - $index_root = $cwd . '/' . $this->config['index_root']; + $index_root = $this->projectRoot . '/' . $runwayConfig['index_root']; // This makes it so the framework doesn't actually execute Flight::map('start', function () { @@ -72,8 +77,8 @@ public function execute() } return preg_match("/^class@anonymous/", end($middleware_class_name)) ? 'Anonymous' : end($middleware_class_name); }, $route->middleware); - } catch (\TypeError $e) { - $middlewares[] = 'Bad Middleware'; + } catch (\TypeError $e) { // @codeCoverageIgnore + $middlewares[] = 'Bad Middleware'; // @codeCoverageIgnore } finally { if (is_string($route->middleware) === true) { $middlewares[] = $route->middleware; diff --git a/flight/net/Request.php b/flight/net/Request.php index 18d1692a..6e9aa934 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -174,7 +174,7 @@ public function __construct(array $config = []) } $base = dirname($base); if ($base === '\\') { - $base = '/'; + $base = '/'; // @codeCoverageIgnore } $config = [ 'url' => $url, @@ -243,8 +243,8 @@ public function init(array $properties = []): self $this->data->setData($data); } } - // Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data or multipart/form-data - } elseif (in_array($this->method, [ 'PUT', 'DELETE', 'PATCH' ], true) === true) { + // Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data or multipart/form-data + } elseif (in_array($this->method, ['PUT', 'DELETE', 'PATCH'], true) === true) { $this->parseRequestBodyForHttpMethods(); } @@ -478,7 +478,7 @@ public function negotiateContentType(array $supported): ?string /** * Retrieves the array of uploaded files. * - * @return array> Key is field name; value is either a single UploadedFile or an array of UploadedFile when multiple were uploaded. + * @return array> Key is field name; value is either a single UploadedFile or an array of UploadedFile when multiple were uploaded. */ public function getUploadedFiles(): array { diff --git a/tests/UploadedFileTest.php b/tests/UploadedFileTest.php index f64e155a..9a531f5c 100644 --- a/tests/UploadedFileTest.php +++ b/tests/UploadedFileTest.php @@ -25,8 +25,8 @@ public function tearDown(): void unlink('real_file'); } - // not found with file_exists...just delete it brute force - @unlink('tmp_symlink'); + // not found with file_exists...just delete it brute force + @unlink('tmp_symlink'); } public function testMoveToFalseSuccess(): void diff --git a/tests/commands/AiGenerateInstructionsCommandTest.php b/tests/commands/AiGenerateInstructionsCommandTest.php index 8e534448..c874a4b5 100644 --- a/tests/commands/AiGenerateInstructionsCommandTest.php +++ b/tests/commands/AiGenerateInstructionsCommandTest.php @@ -9,12 +9,14 @@ use PHPUnit\Framework\TestCase; use tests\classes\NoExitInteractor; -class AiGenerateInstructionsCommandTest extends TestCase { +class AiGenerateInstructionsCommandTest extends TestCase +{ protected static $in; protected static $ou; protected $baseDir; - public function setUp(): void { + public function setUp(): void + { self::$in = __DIR__ . DIRECTORY_SEPARATOR . 'input.test' . uniqid('', true) . '.txt'; self::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt'; file_put_contents(self::$in, ''); @@ -25,7 +27,8 @@ public function setUp(): void { } } - public function tearDown(): void { + public function tearDown(): void + { if (file_exists(self::$in)) { unlink(self::$in); } @@ -35,7 +38,8 @@ public function tearDown(): void { $this->recursiveRmdir($this->baseDir); } - protected function recursiveRmdir($dir) { + protected function recursiveRmdir($dir) + { if (!is_dir($dir)) { return; } @@ -46,7 +50,8 @@ protected function recursiveRmdir($dir) { return rmdir($dir); } - protected function newApp($command): Application { + protected function newApp($command): Application + { $app = new Application('test', '0.0.1', function ($exitCode) { return $exitCode; }); @@ -55,11 +60,13 @@ protected function newApp($command): Application { return $app; } - protected function setInput(array $lines): void { + protected function setInput(array $lines): void + { file_put_contents(self::$in, implode("\n", $lines) . "\n"); } - protected function setProjectRoot($command, $path) { + protected function setProjectRoot($command, $path) + { $reflection = new \ReflectionClass(get_class($command)); $property = null; $currentClass = $reflection; @@ -79,7 +86,8 @@ protected function setProjectRoot($command, $path) { } } - public function testFailsIfAiConfigMissing() { + public function testFailsIfAiConfigMissing() + { $this->setInput([ 'desc', 'none', @@ -107,7 +115,8 @@ public function testFailsIfAiConfigMissing() { $this->assertStringContainsString('Missing AI configuration', file_get_contents(self::$ou)); } - public function testWritesInstructionsToFiles() { + public function testWritesInstructionsToFiles() + { $creds = [ 'api_key' => 'key', 'model' => 'gpt-4o', @@ -154,7 +163,8 @@ public function testWritesInstructionsToFiles() { $this->assertFileExists($this->baseDir . '.windsurfrules'); } - public function testNoInstructionsReturnedFromLlm() { + public function testNoInstructionsReturnedFromLlm() + { $creds = [ 'api_key' => 'key', 'model' => 'gpt-4o', @@ -196,7 +206,8 @@ public function testNoInstructionsReturnedFromLlm() { $this->assertSame(1, $result); } - public function testLlmApiCallFails() { + public function testLlmApiCallFails() + { $creds = [ 'api_key' => 'key', 'model' => 'gpt-4o', @@ -234,7 +245,8 @@ public function testLlmApiCallFails() { $this->assertSame(1, $result); } - public function testUsesDeprecatedConfigFile() { + public function testUsesDeprecatedConfigFile() + { $creds = [ 'ai' => [ 'api_key' => 'key', diff --git a/tests/commands/ControllerCommandTest.php b/tests/commands/ControllerCommandTest.php index 510aaa24..c19b3120 100644 --- a/tests/commands/ControllerCommandTest.php +++ b/tests/commands/ControllerCommandTest.php @@ -48,7 +48,7 @@ public function tearDown(): void 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)); } @@ -56,10 +56,10 @@ protected function newApp(string $name, string $version = '') public function testConfigAppRootNotSet(): void { $app = $this->newApp('test', '0.0.1'); - $app->add(new ControllerCommand([])); + $app->add(new ControllerCommand(['runway' => ['something' => '']])); @$app->handle(['runway', 'make:controller', 'Test']); - $this->assertStringContainsString('app_root not set in .runway-config.json', file_get_contents(static::$ou)); + $this->assertStringContainsString('app_root not set in app/config/config.php', file_get_contents(static::$ou)); } public function testControllerAlreadyExists(): void @@ -67,7 +67,7 @@ public function testControllerAlreadyExists(): void $app = $this->newApp('test', '0.0.1'); mkdir(__DIR__ . '/controllers/'); file_put_contents(__DIR__ . '/controllers/TestController.php', 'add(new ControllerCommand(['app_root' => 'tests/commands/'])); + $app->add(new ControllerCommand(['runway' => ['app_root' => 'tests/commands/']])); $app->handle(['runway', 'make:controller', 'Test']); $this->assertStringContainsString('TestController already exists.', file_get_contents(static::$ou)); @@ -76,7 +76,7 @@ public function testControllerAlreadyExists(): void public function testCreateController(): void { $app = $this->newApp('test', '0.0.1'); - $app->add(new ControllerCommand(['app_root' => 'tests/commands/'])); + $app->add(new ControllerCommand(['runway' => ['app_root' => 'tests/commands/']])); $app->handle(['runway', 'make:controller', 'Test']); $this->assertFileExists(__DIR__ . '/controllers/TestController.php'); diff --git a/tests/commands/RouteCommandTest.php b/tests/commands/RouteCommandTest.php index 0d2aee79..11e39ce0 100644 --- a/tests/commands/RouteCommandTest.php +++ b/tests/commands/RouteCommandTest.php @@ -23,6 +23,7 @@ public function setUp(): void static::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt'; file_put_contents(static::$in, ''); file_put_contents(static::$ou, ''); + $_SERVER = []; $_REQUEST = []; Flight::init(); @@ -91,21 +92,22 @@ protected function removeColors(string $str): string public function testConfigIndexRootNotSet(): void { $app = @$this->newApp('test', '0.0.1'); - $app->add(new RouteCommand([])); + $app->add(new RouteCommand(['runway' => ['something' => 'else']])); @$app->handle(['runway', 'routes']); - $this->assertStringContainsString('index_root not set in .runway-config.json', file_get_contents(static::$ou)); + $this->assertStringContainsString('index_root not set in app/config/config.php', file_get_contents(static::$ou)); } public function testGetRoutes(): void { $app = @$this->newApp('test', '0.0.1'); $this->createIndexFile(); - $app->add(new RouteCommand(['index_root' => 'tests/commands/index.php'])); + $app->add(new RouteCommand(['runway' => ['index_root' => 'tests/commands/index.php']])); @$app->handle(['runway', 'routes']); $this->assertStringContainsString('Routes', file_get_contents(static::$ou)); $expected = <<<'output' + Routes +---------+--------------------+-------+----------+----------------+ | Pattern | Methods | Alias | Streamed | Middleware | +---------+--------------------+-------+----------+----------------+ @@ -127,12 +129,13 @@ public function testGetPostRoute(): void { $app = @$this->newApp('test', '0.0.1'); $this->createIndexFile(); - $app->add(new RouteCommand(['index_root' => 'tests/commands/index.php'])); + $app->add(new RouteCommand(['runway' => ['index_root' => 'tests/commands/index.php']])); @$app->handle(['runway', 'routes', '--post']); $this->assertStringContainsString('Routes', file_get_contents(static::$ou)); $expected = <<<'output' + Routes +---------+---------------+-------+----------+------------+ | Pattern | Methods | Alias | Streamed | Middleware | +---------+---------------+-------+----------+------------+ @@ -140,6 +143,8 @@ public function testGetPostRoute(): void +---------+---------------+-------+----------+------------+ output; // phpcs:ignore + $expected = str_replace(["\r\n", "\\n", "\n"], ["\n", "", "\n"], $expected); + $this->assertStringContainsString( $expected, $this->removeColors(file_get_contents(static::$ou)) From e644461129c221df0f0365b5f9f88a2799820497 Mon Sep 17 00:00:00 2001 From: Arshid Date: Thu, 1 Jan 2026 12:55:14 +0530 Subject: [PATCH 327/331] Add return type --- flight/commands/AiGenerateInstructionsCommand.php | 2 +- flight/commands/AiInitCommand.php | 2 +- flight/commands/ControllerCommand.php | 2 +- flight/commands/RouteCommand.php | 2 +- flight/net/Request.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flight/commands/AiGenerateInstructionsCommand.php b/flight/commands/AiGenerateInstructionsCommand.php index cb78e9a7..5190c3c4 100644 --- a/flight/commands/AiGenerateInstructionsCommand.php +++ b/flight/commands/AiGenerateInstructionsCommand.php @@ -33,7 +33,7 @@ public function __construct(array $config) * * @return int */ - public function execute() + public function execute(): int { $io = $this->app()->io(); diff --git a/flight/commands/AiInitCommand.php b/flight/commands/AiInitCommand.php index f24332d1..92b468d1 100644 --- a/flight/commands/AiInitCommand.php +++ b/flight/commands/AiInitCommand.php @@ -27,7 +27,7 @@ public function __construct(array $config) * * @return int */ - public function execute() + public function execute(): int { $io = $this->app()->io(); diff --git a/flight/commands/ControllerCommand.php b/flight/commands/ControllerCommand.php index a7c10bcd..e61dadae 100644 --- a/flight/commands/ControllerCommand.php +++ b/flight/commands/ControllerCommand.php @@ -26,7 +26,7 @@ public function __construct(array $config) * * @return void */ - public function execute(string $controller) + public function execute(string $controller): void { $io = $this->app()->io(); diff --git a/flight/commands/RouteCommand.php b/flight/commands/RouteCommand.php index d7427085..16679342 100644 --- a/flight/commands/RouteCommand.php +++ b/flight/commands/RouteCommand.php @@ -37,7 +37,7 @@ public function __construct(array $config) * * @return void */ - public function execute() + public function execute(): void { $io = $this->app()->io(); diff --git a/flight/net/Request.php b/flight/net/Request.php index 6e9aa934..31ff1b9b 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -375,7 +375,7 @@ public static function getHeaders(): array * * @return string */ - public static function header(string $header, $default = '') + public static function header(string $header, $default = ''): string { return self::getHeader($header, $default); } From a59829d8fdbcf4efb1d9b9608c743bf5ed460306 Mon Sep 17 00:00:00 2001 From: Arshid Date: Thu, 1 Jan 2026 13:04:11 +0530 Subject: [PATCH 328/331] Add return type - tests --- tests/EngineTest.php | 24 ++++++++++++------------ tests/RouterTest.php | 4 ++-- tests/UploadedFileTest.php | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/EngineTest.php b/tests/EngineTest.php index 91e9f3bd..bdb53023 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -52,7 +52,7 @@ public function getInitializedVar() public function testInitBeforeStartV2OutputBuffering(): void { $engine = new class extends Engine { - public function getInitializedVar() + public function getInitializedVar(): bool { return $this->initialized; } @@ -122,7 +122,7 @@ public function testStartWithRoute(): void $_SERVER['REQUEST_URI'] = '/someRoute'; $engine = new class extends Engine { - public function getInitializedVar() + public function getInitializedVar(): bool { return $this->initialized; } @@ -141,7 +141,7 @@ public function testStartWithRouteButReturnedValueThrows404(): void $_SERVER['REQUEST_URI'] = '/someRoute'; $engine = new class extends Engine { - public function getInitializedVar() + public function getInitializedVar(): bool { return $this->initialized; } @@ -160,7 +160,7 @@ public function testStartWithRouteButReturnedValueThrows404V2OutputBuffering(): $_SERVER['REQUEST_URI'] = '/someRoute'; $engine = new class extends Engine { - public function getInitializedVar() + public function getInitializedVar(): bool { return $this->initialized; } @@ -180,7 +180,7 @@ public function testDoubleReturnTrueRoutesContinueIteration(): void $_SERVER['REQUEST_URI'] = '/someRoute'; $engine = new class extends Engine { - public function getInitializedVar() + public function getInitializedVar(): bool { return $this->initialized; } @@ -207,7 +207,7 @@ public function testDoubleReturnTrueWithMethodMismatchDuringIteration(): void $_SERVER['REQUEST_URI'] = '/someRoute'; $engine = new class extends Engine { - public function getInitializedVar() + public function getInitializedVar(): bool { return $this->initialized; } @@ -254,7 +254,7 @@ public function testIteratorReachesEndWithoutMatch(): void $_SERVER['REQUEST_URI'] = '/someRoute'; $engine = new class extends Engine { - public function getInitializedVar() + public function getInitializedVar(): bool { return $this->initialized; } @@ -276,7 +276,7 @@ public function getInitializedVar() $engine->start(); } - public function testDoubleStart() + public function testDoubleStart(): void { $engine = new Engine(); $engine->route('/someRoute', function () { @@ -512,7 +512,7 @@ public function testJsonWithDuplicateDefaultFlags() $this->assertEquals('{"key1":"value1","key2":"value2","utf8_emoji":"😀"}', $engine->response()->getBody()); } - public function testJsonThrowOnErrorByDefault() + public function testJsonThrowOnErrorByDefault(): void { $engine = new Engine(); $this->expectException(Exception::class); @@ -1000,7 +1000,7 @@ public function testContainerDiceBadMethod() { $engine->start(); } - public function testContainerPsr11() { + public function testContainerPsr11(): void { $engine = new Engine(); $container = new \League\Container\Container(); $container->add(Container::class)->addArgument(Collection::class)->addArgument(PdoWrapper::class); @@ -1031,7 +1031,7 @@ public function testContainerPsr11ClassNotFound() { $engine->start(); } - public function testContainerPsr11MethodNotFound() { + public function testContainerPsr11MethodNotFound(): void { $engine = new Engine(); $container = new \League\Container\Container(); $container->add(Container::class)->addArgument(Collection::class)->addArgument(PdoWrapper::class); @@ -1048,7 +1048,7 @@ public function testContainerPsr11MethodNotFound() { $engine->start(); } - public function testRouteFoundButBadMethod() { + public function testRouteFoundButBadMethod(): void { $engine = new class extends Engine { public function getLoader() { diff --git a/tests/RouterTest.php b/tests/RouterTest.php index c0ed3c19..620ecc34 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -39,7 +39,7 @@ public function ok(): void } // Checks if a route was matched with a given output - public function check($str = '') + public function check($str = ''): void { /*$route = $this->router->route($this->request); @@ -502,7 +502,7 @@ public function testResetRoutes(): void { $router = new class extends Router { - public function getIndex() + public function getIndex(): int { return $this->index; } diff --git a/tests/UploadedFileTest.php b/tests/UploadedFileTest.php index 9a531f5c..f8817f35 100644 --- a/tests/UploadedFileTest.php +++ b/tests/UploadedFileTest.php @@ -55,7 +55,7 @@ public function getFileErrorMessageTests(): array /** * @dataProvider getFileErrorMessageTests */ - public function testMoveToFailureMessages($error, $message) + public function testMoveToFailureMessages(int $error, string $message): void { file_put_contents('tmp_name', 'test'); $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', $error); From ff17dd591d528758bad67f934b77fa4274cbae46 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sun, 4 Jan 2026 00:23:48 -0700 Subject: [PATCH 329/331] Added SimplePdo class to replace PdoWrapper --- .gemini/GEMINI.md | 27 ++ composer.json | 19 +- flight/database/PdoWrapper.php | 3 + flight/database/SimplePdo.php | 427 +++++++++++++++++++++++++++++++ tests/SimplePdoTest.php | 448 +++++++++++++++++++++++++++++++++ 5 files changed, 917 insertions(+), 7 deletions(-) create mode 100644 .gemini/GEMINI.md create mode 100644 flight/database/SimplePdo.php create mode 100644 tests/SimplePdoTest.php diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md new file mode 100644 index 00000000..59a33e44 --- /dev/null +++ b/.gemini/GEMINI.md @@ -0,0 +1,27 @@ +# FlightPHP/Core Project Instructions + +## Overview +This is the main FlightPHP core library for building fast, simple, and extensible PHP web applications. It is dependency-free for core usage and supports PHP 7.4+. + +## Project Guidelines +- PHP 7.4 must be supported. PHP 8 or greater also supported, but avoid PHP 8+ only features. +- Keep the core library dependency-free (no polyfills or interface-only repositories). +- All Flight projects are meant to be kept simple and fast. Performance is a priority. +- Flight is extensible and when implementing new features, consider how they can be added as plugins or extensions rather than bloating the core library. +- Any new features built into the core should be well-documented and tested. +- Any new features should be added with a focus on simplicity and performance, avoiding unnecessary complexity. +- This is not a Laravel, Yii, Code Igniter or Symfony clone. It is a simple, fast, and extensible framework that allows you to build applications quickly without the overhead of large frameworks. + +## Development & Testing +- Run tests: `composer test` (uses phpunit/phpunit and spatie/phpunit-watcher) +- Run test server: `composer test-server` or `composer test-server-v2` +- Lint code: `composer lint` (uses phpstan/phpstan, level 6) +- Beautify code: `composer beautify` (uses squizlabs/php_codesniffer, PSR1) +- Check code style: `composer phpcs` +- Test coverage: `composer test-coverage` + +## Coding Standards +- Follow PSR1 coding standards (enforced by PHPCS) +- Use strict comparisons (`===`, `!==`) +- PHPStan level 6 compliance +- Focus on PHP 7.4 compatibility (avoid PHP 8+ only features) diff --git a/composer.json b/composer.json index 1b2defbc..ae0925ea 100644 --- a/composer.json +++ b/composer.json @@ -61,16 +61,21 @@ "sort-packages": true }, "scripts": { - "test": "phpunit", - "test-watcher": "phpunit-watcher watch", - "test-ci": "phpunit", - "test-coverage": "rm -f clover.xml && XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage --coverage-clover=clover.xml && vendor/bin/coverage-check clover.xml 100", - "test-server": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server/", - "test-server-v2": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server-v2/", + "test": "@php vendor/bin/phpunit", + "test-watcher": "@php vendor/bin/phpunit-watcher watch", + "test-ci": "@php vendor/bin/phpunit", + "test-coverage": [ + "rm -f clover.xml", + "@putenv XDEBUG_MODE=coverage", + "@php vendor/bin/phpunit --coverage-html=coverage --coverage-clover=clover.xml", + "@php vendor/bin/coverage-check clover.xml 100" + ], + "test-server": "echo \"Running Test Server\" && @php -S localhost:8000 -t tests/server/", + "test-server-v2": "echo \"Running Test Server\" && @php -S localhost:8000 -t tests/server-v2/", "test-coverage:win": "del clover.xml && phpunit --coverage-html=coverage --coverage-clover=clover.xml && coverage-check clover.xml 100", "test-performance": [ "echo \"Running Performance Tests...\"", - "php -S localhost:8077 -t tests/performance/ > /dev/null 2>&1 & echo $! > server.pid", + "@php -S localhost:8077 -t tests/performance/ > /dev/null 2>&1 & echo $! > server.pid", "sleep 2", "bash tests/performance/performance_tests.sh", "kill `cat server.pid`", diff --git a/flight/database/PdoWrapper.php b/flight/database/PdoWrapper.php index ff13a4c3..164946cf 100644 --- a/flight/database/PdoWrapper.php +++ b/flight/database/PdoWrapper.php @@ -9,6 +9,9 @@ use PDO; use PDOStatement; +/** + * @deprecated version 3.18.0 - Use SimplePdo instead + */ class PdoWrapper extends PDO { /** @var bool $trackApmQueries Whether to track application performance metrics (APM) for queries. */ diff --git a/flight/database/SimplePdo.php b/flight/database/SimplePdo.php new file mode 100644 index 00000000..fe1bb0e1 --- /dev/null +++ b/flight/database/SimplePdo.php @@ -0,0 +1,427 @@ +|null $pdoOptions An array of options for the PDO connection. + * @param array $options An array of options for the SimplePdo class + */ + public function __construct( + ?string $dsn = null, + ?string $username = null, + ?string $password = null, + ?array $pdoOptions = null, + array $options = [] + ) { + // Set default fetch mode if not provided in pdoOptions + if (isset($pdoOptions[PDO::ATTR_DEFAULT_FETCH_MODE]) === false) { + $pdoOptions = $pdoOptions ?? []; + $pdoOptions[PDO::ATTR_DEFAULT_FETCH_MODE] = PDO::FETCH_ASSOC; + } + + // Pass to parent (PdoWrapper) constructor + parent::__construct($dsn, $username, $password, $pdoOptions, false); // APM off by default here + + // Modern defaults – override parent's behavior where needed + $defaults = [ + 'trackApmQueries' => false, // still optional + 'maxQueryMetrics' => 1000, + ]; + + $options = array_merge($defaults, $options); + + $this->trackApmQueries = (bool) $options['trackApmQueries']; + $this->maxQueryMetrics = (int) $options['maxQueryMetrics']; + + // If APM is enabled, pull connection metrics (same as parent) + if ($this->trackApmQueries && $dsn !== null) { + $this->connectionMetrics = $this->pullDataFromDsn($dsn); + } + } + + /** + * Pulls one row from the query + * + * Ex: $row = $db->fetchRow("SELECT * FROM table WHERE something = ?", [ $something ]); + * + * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" + * @param array $params - Ex: [ $something ] + * + * @return ?Collection + */ + public function fetchRow(string $sql, array $params = []): ?Collection + { + // Smart LIMIT 1 addition (avoid if already present at end or complex query) + if (!preg_match('/\sLIMIT\s+\d+(?:\s+OFFSET\s+\d+)?\s*$/i', trim($sql))) { + $sql .= ' LIMIT 1'; + } + + $results = $this->fetchAll($sql, $params); + + return $results ? $results[0] : null; + } + + /** + * Don't worry about this guy. Converts stuff for IN statements + * + * Ex: $row = $db->fetchAll("SELECT * FROM table WHERE id = ? AND something IN(?), [ $id, [1,2,3] ]); + * Converts this to "SELECT * FROM table WHERE id = ? AND something IN(?,?,?)" + * + * @param string $sql the sql statement + * @param array $params the params for the sql statement + * + * @return array> + */ + protected function processInStatementSql(string $sql, array $params = []): array + { + // First, find all placeholders (?) in the original SQL and their positions + // We need to track which are IN(?) patterns vs regular ? + $originalSql = $sql; + $newParams = []; + $paramIndex = 0; + + // Find all ? positions and whether they're part of IN(?) + $pattern = '/IN\s*\(\s*\?\s*\)/i'; + $inPositions = []; + if (preg_match_all($pattern, $originalSql, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches[0] as $match) { + $inPositions[] = $match[1]; + } + } + + // Process from right to left so string positions don't shift + $inPositions = array_reverse($inPositions); + + // First, figure out which param indices correspond to IN(?) patterns + $questionMarkPositions = []; + $pos = 0; + while (($pos = strpos($originalSql, '?', $pos)) !== false) { + $questionMarkPositions[] = $pos; + $pos++; + } + + // Map each ? position to whether it's inside an IN() + $inParamIndices = []; + foreach ($inPositions as $inPos) { + // Find which ? is inside this IN() + foreach ($questionMarkPositions as $idx => $qPos) { + if ($qPos > $inPos && $qPos < $inPos + 20) { // IN(?) is typically under 20 chars + $inParamIndices[$idx] = true; + break; + } + } + } + + // Now build the new SQL and params + $newSql = $originalSql; + $offset = 0; + + // Process each param + for ($i = 0; $i < count($params); $i++) { + if (isset($inParamIndices[$i])) { + $value = $params[$i]; + + // Find the next IN(?) in the remaining SQL + if (preg_match($pattern, $newSql, $match, PREG_OFFSET_CAPTURE, $offset)) { + $matchPos = $match[0][1]; + $matchLen = strlen($match[0][0]); + + if (!is_array($value)) { + // Single value, keep as-is + $newParams[] = $value; + $newSql = substr_replace($newSql, 'IN(?)', $matchPos, $matchLen); + $offset = $matchPos + 5; + } elseif (count($value) === 0) { + // Empty array + $newSql = substr_replace($newSql, 'IN(NULL)', $matchPos, $matchLen); + $offset = $matchPos + 8; + } else { + // Expand array + $placeholders = implode(',', array_fill(0, count($value), '?')); + $replacement = "IN($placeholders)"; + $newSql = substr_replace($newSql, $replacement, $matchPos, $matchLen); + $newParams = array_merge($newParams, $value); + $offset = $matchPos + strlen($replacement); + } + } + } else { + $newParams[] = $params[$i]; + } + } + + return ['sql' => $newSql, 'params' => $newParams]; + } + + /** + * Use this for INSERTS, UPDATES, or if you plan on using a SELECT in a while loop + * + * Ex: $statement = $db->runQuery("SELECT * FROM table WHERE something = ?", [ $something ]); + * while($row = $statement->fetch()) { + * // ... + * } + * + * $db->runQuery("INSERT INTO table (name) VALUES (?)", [ $name ]); + * $db->runQuery("UPDATE table SET name = ? WHERE id = ?", [ $name, $id ]); + * + * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" + * @param array $params - Ex: [ $something ] + * + * @return PDOStatement + */ + public function runQuery(string $sql, array $params = []): PDOStatement + { + $processed = $this->processInStatementSql($sql, $params); + $sql = $processed['sql']; + $params = $processed['params']; + + $start = $this->trackApmQueries ? microtime(true) : 0; + $memoryStart = $this->trackApmQueries ? memory_get_usage() : 0; + + $stmt = $this->prepare($sql); + if ($stmt === false) { + throw new PDOException( + "Prepare failed: " . ($this->errorInfo()[2] ?? 'Unknown error') + ); + } + + $stmt->execute($params); + + if ($this->trackApmQueries) { + $this->queryMetrics[] = [ + 'sql' => $sql, + 'params' => $params, + 'execution_time' => microtime(true) - $start, + 'row_count' => $stmt->rowCount(), + 'memory_usage' => memory_get_usage() - $memoryStart + ]; + + // Cap to prevent memory leak in long-running processes + if (count($this->queryMetrics) > $this->maxQueryMetrics) { + array_shift($this->queryMetrics); + } + } + + return $stmt; + } + + /** + * Pulls all rows from the query + * + * Ex: $rows = $db->fetchAll("SELECT * FROM table WHERE something = ?", [ $something ]); + * foreach($rows as $row) { + * // ... + * } + * + * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" + * @param array $params - Ex: [ $something ] + * + * @return array> + */ + public function fetchAll(string $sql, array $params = []): array + { + $stmt = $this->runQuery($sql, $params); // Already processes IN statements and tracks metrics + $results = $stmt->fetchAll(PDO::FETCH_ASSOC); + return array_map(fn($row) => new Collection($row), $results); + } + + /** + * Fetch a single column as an array + * + * Ex: $ids = $db->fetchColumn("SELECT id FROM users WHERE active = ?", [1]); + * + * @param string $sql + * @param array $params + * @return array + */ + public function fetchColumn(string $sql, array $params = []): array + { + $stmt = $this->runQuery($sql, $params); + return $stmt->fetchAll(PDO::FETCH_COLUMN, 0); + } + + /** + * Fetch results as key-value pairs (first column as key, second as value) + * + * Ex: $userNames = $db->fetchPairs("SELECT id, name FROM users"); + * + * @param string $sql + * @param array $params + * @return array + */ + public function fetchPairs(string $sql, array $params = []): array + { + $stmt = $this->runQuery($sql, $params); + return $stmt->fetchAll(PDO::FETCH_KEY_PAIR); + } + + /** + * Execute a callback within a transaction + * + * Ex: $db->transaction(function($db) { + * $db->runQuery("INSERT INTO users (name) VALUES (?)", ['John']); + * $db->runQuery("INSERT INTO logs (action) VALUES (?)", ['user_created']); + * return $db->lastInsertId(); + * }); + * + * @param callable $callback + * @return mixed The return value of the callback + * @throws \Throwable + */ + public function transaction(callable $callback) + { + $this->beginTransaction(); + try { + $result = $callback($this); + $this->commit(); + return $result; + } catch (\Throwable $e) { + $this->rollBack(); + throw $e; + } + } + + /** + * Insert one or more rows and return the last insert ID + * + * Single insert: + * $id = $db->insert('users', ['name' => 'John', 'email' => 'john@example.com']); + * + * Bulk insert: + * $id = $db->insert('users', [ + * ['name' => 'John', 'email' => 'john@example.com'], + * ['name' => 'Jane', 'email' => 'jane@example.com'], + * ]); + * + * @param string $table + * @param array|array> $data Single row or array of rows + * @return string Last insert ID (for single insert or last row of bulk insert) + */ + public function insert(string $table, array $data): string + { + // Detect if this is a bulk insert (array of arrays) + $isBulk = isset($data[0]) && is_array($data[0]); + + if ($isBulk) { + // Bulk insert + if (empty($data[0])) { + throw new PDOException("Cannot perform bulk insert with empty data array"); + } + + // Use first row to determine columns + $firstRow = $data[0]; + $columns = array_keys($firstRow); + $columnCount = count($columns); + + // Validate all rows have same columns + foreach ($data as $index => $row) { + if (count($row) !== $columnCount) { + throw new PDOException( + "Row $index has " . count($row) . " columns, expected $columnCount" + ); + } + } + + // Build placeholders for multiple rows: (?,?), (?,?), (?,?) + $rowPlaceholder = '(' . implode(',', array_fill(0, $columnCount, '?')) . ')'; + $allPlaceholders = implode(', ', array_fill(0, count($data), $rowPlaceholder)); + + $sql = sprintf( + "INSERT INTO %s (%s) VALUES %s", + $table, + implode(', ', $columns), + $allPlaceholders + ); + + // Flatten all row values into a single params array + $params = []; + foreach ($data as $row) { + $params = array_merge($params, array_values($row)); + } + + $this->runQuery($sql, $params); + } else { + // Single insert + $columns = array_keys($data); + $placeholders = array_fill(0, count($data), '?'); + + $sql = sprintf( + "INSERT INTO %s (%s) VALUES (%s)", + $table, + implode(', ', $columns), + implode(', ', $placeholders) + ); + + $this->runQuery($sql, array_values($data)); + } + + return $this->lastInsertId(); + } + + /** + * Update rows and return the number of affected rows + * + * Ex: $affected = $db->update('users', ['name' => 'Jane'], 'id = ?', [1]); + * + * Note: SQLite's rowCount() returns the number of rows where data actually changed. + * If you UPDATE a row with the same values it already has, rowCount() will return 0. + * This differs from MySQL's behavior when using PDO::MYSQL_ATTR_FOUND_ROWS. + * + * @param string $table + * @param array $data + * @param string $where - e.g., "id = ?" + * @param array $whereParams + * @return int Number of affected rows (rows where data actually changed) + */ + public function update(string $table, array $data, string $where, array $whereParams = []): int + { + $sets = []; + foreach (array_keys($data) as $column) { + $sets[] = "$column = ?"; + } + + $sql = sprintf( + "UPDATE %s SET %s WHERE %s", + $table, + implode(', ', $sets), + $where + ); + + $params = array_merge(array_values($data), $whereParams); + $stmt = $this->runQuery($sql, $params); + return $stmt->rowCount(); + } + + /** + * Delete rows and return the number of deleted rows + * + * Ex: $deleted = $db->delete('users', 'id = ?', [1]); + * + * @param string $table + * @param string $where - e.g., "id = ?" + * @param array $whereParams + * @return int Number of deleted rows + */ + public function delete(string $table, string $where, array $whereParams = []): int + { + $sql = "DELETE FROM $table WHERE $where"; + $stmt = $this->runQuery($sql, $whereParams); + return $stmt->rowCount(); + } + +} \ No newline at end of file diff --git a/tests/SimplePdoTest.php b/tests/SimplePdoTest.php new file mode 100644 index 00000000..8d678d7d --- /dev/null +++ b/tests/SimplePdoTest.php @@ -0,0 +1,448 @@ +db = new SimplePdo('sqlite::memory:'); + // Create a test table and insert 3 rows of data + $this->db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); + $this->db->exec('INSERT INTO users (name, email) VALUES ("John", "john@example.com")'); + $this->db->exec('INSERT INTO users (name, email) VALUES ("Jane", "jane@example.com")'); + $this->db->exec('INSERT INTO users (name, email) VALUES ("Bob", "bob@example.com")'); + } + + protected function tearDown(): void + { + $this->db->exec('DROP TABLE users'); + } + + // ========================================================================= + // Constructor Tests + // ========================================================================= + + public function testDefaultFetchModeIsAssoc(): void + { + $fetchMode = $this->db->getAttribute(PDO::ATTR_DEFAULT_FETCH_MODE); + $this->assertEquals(PDO::FETCH_ASSOC, $fetchMode); + } + + public function testCustomFetchModeCanBeSet(): void + { + $db = new SimplePdo('sqlite::memory:', null, null, [ + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ + ]); + $fetchMode = $db->getAttribute(PDO::ATTR_DEFAULT_FETCH_MODE); + $this->assertEquals(PDO::FETCH_OBJ, $fetchMode); + } + + public function testApmTrackingOffByDefault(): void + { + // APM is off by default, so logQueries should not trigger events + // We test this indirectly by calling logQueries and ensuring no error + $this->db->logQueries(); + $this->assertTrue(true); // If we get here, no exception was thrown + } + + public function testApmTrackingCanBeEnabled(): void + { + $db = new SimplePdo('sqlite::memory:', null, null, null, [ + 'trackApmQueries' => true + ]); + $db->exec('CREATE TABLE test (id INTEGER PRIMARY KEY)'); + $db->runQuery('SELECT * FROM test'); + $db->logQueries(); // Should work without error + $this->assertTrue(true); + } + + // ========================================================================= + // runQuery Tests + // ========================================================================= + + public function testRunQueryReturnsStatement(): void + { + $stmt = $this->db->runQuery('SELECT * FROM users'); + $this->assertInstanceOf(PDOStatement::class, $stmt); + } + + public function testRunQueryWithParams(): void + { + $stmt = $this->db->runQuery('SELECT * FROM users WHERE name = ?', ['John']); + $rows = $stmt->fetchAll(); + $this->assertCount(1, $rows); + $this->assertEquals('John', $rows[0]['name']); + } + + public function testRunQueryWithoutParamsWithMaxQueryMetrics(): void + { + $db = new class ('sqlite::memory:', null, null, null, ['maxQueryMetrics' => 2, 'trackApmQueries' => true]) extends SimplePdo { + public function getQueryMetrics(): array + { + return $this->queryMetrics; + } + }; + $db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); + $db->exec('INSERT INTO users (name, email) VALUES ("John", "john@example.com")'); + $db->exec('INSERT INTO users (name, email) VALUES ("Jane", "jane@example.com")'); + $db->exec('INSERT INTO users (name, email) VALUES ("Bob", "bob@example.com")'); + + $db->runQuery('SELECT * FROM users WHERE 1 = 1'); + $db->runQuery('SELECT * FROM users WHERE 1 = 2'); + $this->assertEquals(2, count($db->getQueryMetrics())); + $db->runQuery('SELECT * FROM users WHERE 1 = 3'); + $dbMetrics = $db->getQueryMetrics(); + $this->assertEquals(2, count($dbMetrics)); + $this->assertEquals('SELECT * FROM users WHERE 1 = 2', $dbMetrics[0]['sql']); + $this->assertEquals('SELECT * FROM users WHERE 1 = 3', $dbMetrics[1]['sql']); + } + + public function testRunQueryInsert(): void + { + $stmt = $this->db->runQuery('INSERT INTO users (name, email) VALUES (?, ?)', ['Alice', 'alice@example.com']); + $this->assertEquals(1, $stmt->rowCount()); + } + + public function testRunQueryThrowsOnPrepareFailure(): void + { + $this->expectException(PDOException::class); + $this->db->runQuery('SELECT * FROM nonexistent_table'); + } + + // ========================================================================= + // fetchRow Tests + // ========================================================================= + + public function testFetchRowReturnsCollection(): void + { + $row = $this->db->fetchRow('SELECT * FROM users WHERE id = ?', [1]); + $this->assertInstanceOf(Collection::class, $row); + $this->assertEquals('John', $row['name']); + } + + public function testFetchRowReturnsNullWhenNoResults(): void + { + $row = $this->db->fetchRow('SELECT * FROM users WHERE id = ?', [999]); + $this->assertNull($row); + } + + public function testFetchRowAddsLimitAutomatically(): void + { + // Even though there are 3 rows, fetchRow should only return 1 + $row = $this->db->fetchRow('SELECT * FROM users'); + $this->assertInstanceOf(Collection::class, $row); + $this->assertEquals(1, $row['id']); + } + + public function testFetchRowDoesNotDuplicateLimitClause(): void + { + // Query already has LIMIT - should not add another + $row = $this->db->fetchRow('SELECT * FROM users ORDER BY id DESC LIMIT 1'); + $this->assertInstanceOf(Collection::class, $row); + $this->assertEquals(3, $row['id']); // Should be Bob (id=3) + } + + // ========================================================================= + // fetchAll Tests + // ========================================================================= + + public function testFetchAllReturnsArrayOfCollections(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users'); + $this->assertIsArray($rows); + $this->assertCount(3, $rows); + $this->assertInstanceOf(Collection::class, $rows[0]); + } + + public function testFetchAllReturnsEmptyArrayWhenNoResults(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE 1 = 0'); + $this->assertIsArray($rows); + $this->assertCount(0, $rows); + } + + public function testFetchAllWithParams(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE name LIKE ?', ['J%']); + $this->assertCount(2, $rows); // John and Jane + } + + // ========================================================================= + // fetchColumn Tests + // ========================================================================= + + public function testFetchColumnReturnsFlatArray(): void + { + $names = $this->db->fetchColumn('SELECT name FROM users ORDER BY id'); + $this->assertIsArray($names); + $this->assertEquals(['John', 'Jane', 'Bob'], $names); + } + + public function testFetchColumnWithParams(): void + { + $ids = $this->db->fetchColumn('SELECT id FROM users WHERE name LIKE ?', ['J%']); + $this->assertEquals([1, 2], $ids); + } + + public function testFetchColumnReturnsEmptyArrayWhenNoResults(): void + { + $result = $this->db->fetchColumn('SELECT id FROM users WHERE 1 = 0'); + $this->assertEquals([], $result); + } + + // ========================================================================= + // fetchPairs Tests + // ========================================================================= + + public function testFetchPairsReturnsKeyValueArray(): void + { + $pairs = $this->db->fetchPairs('SELECT id, name FROM users ORDER BY id'); + $this->assertEquals([1 => 'John', 2 => 'Jane', 3 => 'Bob'], $pairs); + } + + public function testFetchPairsWithParams(): void + { + $pairs = $this->db->fetchPairs('SELECT id, email FROM users WHERE name = ?', ['John']); + $this->assertEquals([1 => 'john@example.com'], $pairs); + } + + public function testFetchPairsReturnsEmptyArrayWhenNoResults(): void + { + $pairs = $this->db->fetchPairs('SELECT id, name FROM users WHERE 1 = 0'); + $this->assertEquals([], $pairs); + } + + // ========================================================================= + // IN Statement Processing Tests + // ========================================================================= + + public function testInStatementWithArrayOfIntegers(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE id IN(?)', [[1, 2]]); + $this->assertCount(2, $rows); + } + + public function testInStatementWithArrayOfStrings(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE name IN(?)', [['John', 'Jane']]); + $this->assertCount(2, $rows); + } + + public function testInStatementWithEmptyArray(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE id IN(?)', [[]]); + $this->assertCount(0, $rows); // IN(NULL) matches nothing + } + + public function testInStatementWithSingleValue(): void + { + $rows = $this->db->fetchAll('SELECT * FROM users WHERE id IN(?)', [1]); + $this->assertCount(1, $rows); + } + + public function testMultipleInStatements(): void + { + $rows = $this->db->fetchAll( + 'SELECT * FROM users WHERE id IN(?) AND name IN(?)', + [[1, 2, 3], ['John', 'Bob']] + ); + $this->assertCount(2, $rows); // John (id=1) and Bob (id=3) + } + + public function testInStatementWithOtherParams(): void + { + $rows = $this->db->fetchAll( + 'SELECT * FROM users WHERE id > ? AND name IN(?)', + [0, ['John', 'Jane']] + ); + $this->assertCount(2, $rows); + } + + // ========================================================================= + // insert() Tests + // ========================================================================= + + public function testInsertSingleRow(): void + { + $id = $this->db->insert('users', ['name' => 'Alice', 'email' => 'alice@example.com']); + $this->assertEquals('4', $id); + + $row = $this->db->fetchRow('SELECT * FROM users WHERE id = ?', [$id]); + $this->assertEquals('Alice', $row['name']); + $this->assertEquals('alice@example.com', $row['email']); + } + + public function testInsertBulkRows(): void + { + $id = $this->db->insert('users', [ + ['name' => 'Alice', 'email' => 'alice@example.com'], + ['name' => 'Charlie', 'email' => 'charlie@example.com'], + ]); + + // Last insert ID should be 5 (Charlie) + $this->assertEquals('5', $id); + + // Verify both rows were inserted + $rows = $this->db->fetchAll('SELECT * FROM users WHERE id > 3 ORDER BY id'); + $this->assertCount(2, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals('Charlie', $rows[1]['name']); + } + + public function testInsertBulkWithEmptyArrayThrows(): void + { + $this->expectException(PDOException::class); + $this->expectExceptionMessage('Cannot perform bulk insert with empty data array'); + + $this->db->insert('users', [[]]); + } + + public function testInsertBulkWithMismatchedColumnCountThrows(): void + { + $this->expectException(PDOException::class); + $this->expectExceptionMessage('columns'); + + $this->db->insert('users', [ + ['name' => 'Alice', 'email' => 'alice@example.com'], + ['name' => 'Charlie'], // Missing email column + ]); + } + + // ========================================================================= + // update() Tests + // ========================================================================= + + public function testUpdateReturnsAffectedRowCount(): void + { + $count = $this->db->update('users', ['name' => 'Updated'], 'name LIKE ?', ['J%']); + $this->assertEquals(2, $count); // John and Jane + + $rows = $this->db->fetchAll('SELECT * FROM users WHERE name = ?', ['Updated']); + $this->assertCount(2, $rows); + } + + public function testUpdateSingleRow(): void + { + $count = $this->db->update('users', ['email' => 'newemail@example.com'], 'id = ?', [1]); + $this->assertEquals(1, $count); + + $row = $this->db->fetchRow('SELECT * FROM users WHERE id = ?', [1]); + $this->assertEquals('newemail@example.com', $row['email']); + } + + public function testUpdateNoMatchingRows(): void + { + $count = $this->db->update('users', ['name' => 'Nobody'], 'id = ?', [999]); + $this->assertEquals(0, $count); + } + + // ========================================================================= + // delete() Tests + // ========================================================================= + + public function testDeleteReturnsDeletedRowCount(): void + { + $count = $this->db->delete('users', 'name LIKE ?', ['J%']); + $this->assertEquals(2, $count); // John and Jane + + $rows = $this->db->fetchAll('SELECT * FROM users'); + $this->assertCount(1, $rows); + $this->assertEquals('Bob', $rows[0]['name']); + } + + public function testDeleteSingleRow(): void + { + $count = $this->db->delete('users', 'id = ?', [1]); + $this->assertEquals(1, $count); + + $rows = $this->db->fetchAll('SELECT * FROM users'); + $this->assertCount(2, $rows); + } + + public function testDeleteNoMatchingRows(): void + { + $count = $this->db->delete('users', 'id = ?', [999]); + $this->assertEquals(0, $count); + } + + // ========================================================================= + // transaction() Tests + // ========================================================================= + + public function testTransactionCommitsOnSuccess(): void + { + $result = $this->db->transaction(function ($db) { + $db->runQuery('INSERT INTO users (name, email) VALUES (?, ?)', ['Alice', 'alice@example.com']); + return $db->lastInsertId(); + }); + + $this->assertEquals('4', $result); + + $row = $this->db->fetchRow('SELECT * FROM users WHERE id = ?', [4]); + $this->assertEquals('Alice', $row['name']); + } + + public function testTransactionRollsBackOnException(): void + { + try { + $this->db->transaction(function ($db) { + $db->runQuery('INSERT INTO users (name, email) VALUES (?, ?)', ['Alice', 'alice@example.com']); + throw new \RuntimeException('Something went wrong'); + }); + } catch (\RuntimeException $e) { + $this->assertEquals('Something went wrong', $e->getMessage()); + } + + // Verify the insert was rolled back + $rows = $this->db->fetchAll('SELECT * FROM users'); + $this->assertCount(3, $rows); // Still only the original 3 rows + } + + public function testTransactionReturnsCallbackValue(): void + { + $result = $this->db->transaction(function () { + return 'hello world'; + }); + + $this->assertEquals('hello world', $result); + } + + public function testTransactionRethrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Test exception'); + + $this->db->transaction(function () { + throw new \InvalidArgumentException('Test exception'); + }); + } + + // ========================================================================= + // fetchField (inherited from PdoWrapper) Tests + // ========================================================================= + + public function testFetchFieldReturnsValue(): void + { + $name = $this->db->fetchField('SELECT name FROM users WHERE id = ?', [1]); + $this->assertEquals('John', $name); + } + + public function testFetchFieldReturnsFirstColumn(): void + { + $id = $this->db->fetchField('SELECT id, name FROM users WHERE id = ?', [1]); + $this->assertEquals(1, $id); + } + +} From dd39d4adad4b576a37d2ae5032015f6a8dff2df1 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sun, 4 Jan 2026 00:31:14 -0700 Subject: [PATCH 330/331] fixed spacing --- flight/database/SimplePdo.php | 10 ++++++++-- flight/net/Request.php | 36 +++++++++++++++++------------------ tests/SimplePdoTest.php | 1 - 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/flight/database/SimplePdo.php b/flight/database/SimplePdo.php index fe1bb0e1..6e5cb57c 100644 --- a/flight/database/SimplePdo.php +++ b/flight/database/SimplePdo.php @@ -247,6 +247,7 @@ public function fetchAll(string $sql, array $params = []): array * * @param string $sql * @param array $params + * * @return array */ public function fetchColumn(string $sql, array $params = []): array @@ -262,6 +263,7 @@ public function fetchColumn(string $sql, array $params = []): array * * @param string $sql * @param array $params + * * @return array */ public function fetchPairs(string $sql, array $params = []): array @@ -280,7 +282,9 @@ public function fetchPairs(string $sql, array $params = []): array * }); * * @param callable $callback + * * @return mixed The return value of the callback + * * @throws \Throwable */ public function transaction(callable $callback) @@ -310,6 +314,7 @@ public function transaction(callable $callback) * * @param string $table * @param array|array> $data Single row or array of rows + * * @return string Last insert ID (for single insert or last row of bulk insert) */ public function insert(string $table, array $data): string @@ -386,6 +391,7 @@ public function insert(string $table, array $data): string * @param array $data * @param string $where - e.g., "id = ?" * @param array $whereParams + * * @return int Number of affected rows (rows where data actually changed) */ public function update(string $table, array $data, string $where, array $whereParams = []): int @@ -415,6 +421,7 @@ public function update(string $table, array $data, string $where, array $wherePa * @param string $table * @param string $where - e.g., "id = ?" * @param array $whereParams + * * @return int Number of deleted rows */ public function delete(string $table, string $where, array $whereParams = []): int @@ -423,5 +430,4 @@ public function delete(string $table, string $where, array $whereParams = []): i $stmt = $this->runQuery($sql, $whereParams); return $stmt->rowCount(); } - -} \ No newline at end of file +} diff --git a/flight/net/Request.php b/flight/net/Request.php index 31ff1b9b..845b55ce 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -177,24 +177,24 @@ public function __construct(array $config = []) $base = '/'; // @codeCoverageIgnore } $config = [ - 'url' => $url, - 'base' => $base, - 'method' => $this->getMethod(), - 'referrer' => $this->getVar('HTTP_REFERER'), - 'ip' => $this->getVar('REMOTE_ADDR'), - 'ajax' => $this->getVar('HTTP_X_REQUESTED_WITH') === 'XMLHttpRequest', - 'scheme' => $scheme, + 'url' => $url, + 'base' => $base, + 'method' => $this->getMethod(), + 'referrer' => $this->getVar('HTTP_REFERER'), + 'ip' => $this->getVar('REMOTE_ADDR'), + 'ajax' => $this->getVar('HTTP_X_REQUESTED_WITH') === 'XMLHttpRequest', + 'scheme' => $scheme, 'user_agent' => $this->getVar('HTTP_USER_AGENT'), - 'type' => $this->getVar('CONTENT_TYPE'), - 'length' => intval($this->getVar('CONTENT_LENGTH', 0)), - 'query' => new Collection($_GET), - 'data' => new Collection($_POST), - 'cookies' => new Collection($_COOKIE), - 'files' => new Collection($_FILES), - 'secure' => $scheme === 'https', - 'accept' => $this->getVar('HTTP_ACCEPT'), - 'proxy_ip' => $this->getProxyIpAddress(), - 'host' => $this->getVar('HTTP_HOST'), + 'type' => $this->getVar('CONTENT_TYPE'), + 'length' => intval($this->getVar('CONTENT_LENGTH', 0)), + 'query' => new Collection($_GET), + 'data' => new Collection($_POST), + 'cookies' => new Collection($_COOKIE), + 'files' => new Collection($_FILES), + 'secure' => $scheme === 'https', + 'accept' => $this->getVar('HTTP_ACCEPT'), + 'proxy_ip' => $this->getProxyIpAddress(), + 'host' => $this->getVar('HTTP_HOST'), 'servername' => $this->getVar('SERVER_NAME', ''), ]; } @@ -463,7 +463,7 @@ public static function getScheme(): string */ public function negotiateContentType(array $supported): ?string { - $accept = $this->header('Accept') ?? ''; + $accept = $this->header('Accept') ?: ''; if ($accept === '') { return $supported[0]; } diff --git a/tests/SimplePdoTest.php b/tests/SimplePdoTest.php index 8d678d7d..de103786 100644 --- a/tests/SimplePdoTest.php +++ b/tests/SimplePdoTest.php @@ -444,5 +444,4 @@ public function testFetchFieldReturnsFirstColumn(): void $id = $this->db->fetchField('SELECT id, name FROM users WHERE id = ?', [1]); $this->assertEquals(1, $id); } - } From a3f47bc9020d0ea71cd0ddfe90ff013d083f6329 Mon Sep 17 00:00:00 2001 From: Mr Percival <49914839+Lawrence72@users.noreply.github.com> Date: Thu, 15 Jan 2026 20:28:34 -0600 Subject: [PATCH 331/331] Ignore LiMIT when using RETURNING --- .gitignore | 1 + flight/database/SimplePdo.php | 2 +- tests/SimplePdoTest.php | 11 +++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 90cd18f9..49ff5839 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ clover.xml phpcs.xml .runway-config.json .runway-creds.json +.DS_Store diff --git a/flight/database/SimplePdo.php b/flight/database/SimplePdo.php index 6e5cb57c..c1bc0b7a 100644 --- a/flight/database/SimplePdo.php +++ b/flight/database/SimplePdo.php @@ -68,7 +68,7 @@ public function __construct( public function fetchRow(string $sql, array $params = []): ?Collection { // Smart LIMIT 1 addition (avoid if already present at end or complex query) - if (!preg_match('/\sLIMIT\s+\d+(?:\s+OFFSET\s+\d+)?\s*$/i', trim($sql))) { + if (!preg_match('/\s(LIMIT\s+\d+(?:\s+OFFSET\s+\d+)?|RETURNING\s+.+)\s*$/i', trim($sql))) { $sql .= ' LIMIT 1'; } diff --git a/tests/SimplePdoTest.php b/tests/SimplePdoTest.php index de103786..a8e85b21 100644 --- a/tests/SimplePdoTest.php +++ b/tests/SimplePdoTest.php @@ -153,6 +153,17 @@ public function testFetchRowDoesNotDuplicateLimitClause(): void $this->assertInstanceOf(Collection::class, $row); $this->assertEquals(3, $row['id']); // Should be Bob (id=3) } + + public function testFetchRowDoesNotAddLimitAfterReturningClause(): void + { + $row = $this->db->fetchRow( + 'INSERT INTO users (name, email) VALUES (?, ?) RETURNING id, name', + ['Alice', 'alice@example.com'] + ); + + $this->assertInstanceOf(Collection::class, $row); + $this->assertSame('Alice', $row['name']); + } // ========================================================================= // fetchAll Tests