From 3d2dccb72aff372b8a0afddf06c74a5caa0b58d7 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Mon, 3 Jun 2024 13:09:21 -0400 Subject: [PATCH 01/46] Started draft of route compact syntax --- flight/Flight.php | 54 ++++++++++ .../FlightRouteCompactSyntaxTest.php | 101 ++++++++++++++++++ tests/groupcompactsyntax/PostsController.php | 34 ++++++ tests/groupcompactsyntax/TodosController.php | 14 +++ tests/groupcompactsyntax/UsersController.php | 14 +++ 5 files changed, 217 insertions(+) create mode 100644 tests/groupcompactsyntax/FlightRouteCompactSyntaxTest.php create mode 100644 tests/groupcompactsyntax/PostsController.php create mode 100644 tests/groupcompactsyntax/TodosController.php create mode 100644 tests/groupcompactsyntax/UsersController.php diff --git a/flight/Flight.php b/flight/Flight.php index 207d44b..020e546 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -121,6 +121,60 @@ class Flight return self::app()->{$name}(...$params); } + /** + * Create a resource controller customizing the methods names mapping. + * + * @param class-string $controllerClass + * @param array $methods + */ + public static function resource( + string $pattern, + string $controllerClass, + array $methods = [] + ): void { + $defaultMapping = [ + 'GET /' => 'index', + 'GET /@id/' => 'show', + 'GET /create/' => 'create', + 'POST /' => 'store', + 'GET /@id/edit/' => 'edit', + 'PUT /@id/' => 'update', + 'DELETE /@id/' => 'destroy' + ]; + + if ($methods !== []) { + static::group( + $pattern, + function () use ($controllerClass, $methods): void { + foreach ($methods as $methodPattern => $controllerMethod) { + static::route( + $methodPattern, + [$controllerClass, $controllerMethod] + ); + } + } + ); + } else { + static::group( + $pattern, + function () use ($defaultMapping, $controllerClass): void { + foreach ($defaultMapping as $methodPattern => $controllerMethod) { + $class = new ReflectionClass($controllerClass); + + if ($class->hasMethod($controllerMethod) === false) { + continue; + } + + static::route( + $methodPattern, + [$controllerClass, $controllerMethod] + ); + } + } + ); + } + } + /** @return Engine Application instance */ public static function app(): Engine { diff --git a/tests/groupcompactsyntax/FlightRouteCompactSyntaxTest.php b/tests/groupcompactsyntax/FlightRouteCompactSyntaxTest.php new file mode 100644 index 0000000..3e522a2 --- /dev/null +++ b/tests/groupcompactsyntax/FlightRouteCompactSyntaxTest.php @@ -0,0 +1,101 @@ +clear(); + } + + public function testCanMapMethodsWithVerboseSyntax(): void + { + Flight::route('GET /users', [UsersController::class, 'list']); + Flight::route('POST /users', [UsersController::class, 'handleRegister']); + + $routes = Flight::router()->getRoutes(); + + $this->assertCount(2, $routes); + + $this->assertSame('/users', $routes[0]->pattern); + $this->assertSame([UsersController::class, 'list'], $routes[0]->callback); + $this->assertSame('GET', $routes[0]->methods[0]); + + $this->assertSame('/users', $routes[1]->pattern); + $this->assertSame([UsersController::class, 'handleRegister'], $routes[1]->callback); + $this->assertSame('POST', $routes[1]->methods[0]); + } + + public function testCanMapSomeMethods(): void + { + Flight::resource('/users', UsersController::class, [ + 'GET /' => 'list', + 'POST /' => 'handleRegister' + ]); + + $routes = Flight::router()->getRoutes(); + + $this->assertCount(2, $routes); + + $this->assertSame('/users/', $routes[0]->pattern); + $this->assertSame('GET', $routes[0]->methods[0]); + $this->assertSame([UsersController::class, 'list'], $routes[0]->callback); + + $this->assertSame('/users/', $routes[1]->pattern); + $this->assertSame('POST', $routes[1]->methods[0]); + $this->assertSame([UsersController::class, 'handleRegister'], $routes[1]->callback); + } + + public function testCanMapDefaultMethods(): void + { + Flight::resource('/posts', PostsController::class); + + $routes = Flight::router()->getRoutes(); + + $this->assertCount(7, $routes); + + $this->assertSame('/posts/', $routes[0]->pattern); + $this->assertSame('GET', $routes[0]->methods[0]); + $this->assertSame([PostsController::class, 'index'], $routes[0]->callback); + + $this->assertSame('/posts/@id/', $routes[1]->pattern); + $this->assertSame('GET', $routes[1]->methods[0]); + $this->assertSame([PostsController::class, 'show'], $routes[1]->callback); + + $this->assertSame('/posts/create/', $routes[2]->pattern); + $this->assertSame('GET', $routes[2]->methods[0]); + $this->assertSame([PostsController::class, 'create'], $routes[2]->callback); + + $this->assertSame('/posts/', $routes[3]->pattern); + $this->assertSame('POST', $routes[3]->methods[0]); + $this->assertSame([PostsController::class, 'store'], $routes[3]->callback); + + $this->assertSame('/posts/@id/edit/', $routes[4]->pattern); + $this->assertSame('GET', $routes[4]->methods[0]); + $this->assertSame([PostsController::class, 'edit'], $routes[4]->callback); + + $this->assertSame('/posts/@id/', $routes[5]->pattern); + $this->assertSame('PUT', $routes[5]->methods[0]); + $this->assertSame([PostsController::class, 'update'], $routes[5]->callback); + + $this->assertSame('/posts/@id/', $routes[6]->pattern); + $this->assertSame('DELETE', $routes[6]->methods[0]); + $this->assertSame([PostsController::class, 'destroy'], $routes[6]->callback); + } + + public function testCanMapExistingMethods(): void + { + Flight::resource('/todos', TodosController::class); + + $routes = Flight::router()->getRoutes(); + + $this->assertCount(2, $routes); + } +} diff --git a/tests/groupcompactsyntax/PostsController.php b/tests/groupcompactsyntax/PostsController.php new file mode 100644 index 0000000..ac2c497 --- /dev/null +++ b/tests/groupcompactsyntax/PostsController.php @@ -0,0 +1,34 @@ + Date: Mon, 3 Jun 2024 20:02:13 -0600 Subject: [PATCH 02/46] Moved the function over and fleshed it out --- flight/Engine.php | 75 ++++++++++++++++++++++++++++++++++++++++++++++- flight/Flight.php | 56 ++--------------------------------- 2 files changed, 76 insertions(+), 55 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index a1378a6..2c06adc 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -42,6 +42,8 @@ use flight\net\Route; * Routes a PATCH URL to a callback function. * @method Route delete(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a DELETE URL to a callback function. + * @method void resource(string $pattern, 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 * @@ -76,7 +78,7 @@ class Engine private const MAPPABLE_METHODS = [ 'start', 'stop', 'route', 'halt', 'error', 'notFound', 'render', 'redirect', 'etag', 'lastModified', 'json', 'jsonHalt', 'jsonp', - 'post', 'put', 'patch', 'delete', 'group', 'getUrl' + 'post', 'put', 'patch', 'delete', 'group', 'getUrl', 'resource' ]; /** @var array Stored variables. */ @@ -727,6 +729,77 @@ class Engine return $this->router()->map('DELETE ' . $pattern, $callback, $pass_route, $route_alias); } + /** + * Create a resource controller customizing the methods names mapping. + * + * @param class-string $controllerClass + * @param array $options + */ + public function _resource( + string $pattern, + string $controllerClass, + array $options = [] + ): void { + // $defaultMapping = [ + // 'GET ' => 'index', + // 'GET /create' => 'create', + // 'POST ' => 'store', + // 'GET /@id' => 'show', + // 'GET /@id/edit' => 'edit', + // 'PUT /@id' => 'update', + // 'DELETE /@id' => 'destroy' + // ]; + + $defaultMapping = [ + 'index' => 'GET ', + 'create' => 'GET /create', + 'store' => 'POST ', + 'show' => 'GET /@id', + 'edit' => 'GET /@id/edit', + 'update' => 'PUT /@id', + 'destroy' =>'DELETE /@id' + ]; + + // Create a custom alias base + $aliasBase = trim(basename($pattern), '/'); + if(isset($options['alias_base']) === true) { + $aliasBase = $options['alias_base']; + } + + // Only use these controller methods + if(isset($options['only']) === true) { + $only = $options['only']; + $defaultMapping = array_filter($defaultMapping, function($key) use ($only) { + return in_array($key, $only, true) === true; + }, ARRAY_FILTER_USE_KEY); + + // Exclude these controller methods + } else if(isset($options['except']) === true) { + $except = $options['except']; + $defaultMapping = array_filter($defaultMapping, function($key) use ($except) { + return in_array($key, $except, true) === false; + }, ARRAY_FILTER_USE_KEY); + } + + // Add group middleware + $middleware = []; + if(isset($options['middleware']) === true) { + $middleware = $options['middleware']; + } + + $this->group( + $pattern, + function (Router $router) use ($controllerClass, $defaultMapping, $aliasBase): void { + foreach ($defaultMapping as $controllerMethod => $methodPattern) { + $router->map( + $methodPattern, + $controllerClass.'->'.$controllerMethod + )->setAlias($aliasBase.'.'.$controllerMethod); + } + } + , $middleware); + } + /** * Stops processing and returns a given response. * diff --git a/flight/Flight.php b/flight/Flight.php index 020e546..70e8bdd 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -42,6 +42,8 @@ require_once __DIR__ . '/autoload.php'; * Routes a PATCH URL to a callback function. * @method static Route delete(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a DELETE URL to a callback function. + * @method void resource(string $pattern, 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 * @@ -121,60 +123,6 @@ class Flight return self::app()->{$name}(...$params); } - /** - * Create a resource controller customizing the methods names mapping. - * - * @param class-string $controllerClass - * @param array $methods - */ - public static function resource( - string $pattern, - string $controllerClass, - array $methods = [] - ): void { - $defaultMapping = [ - 'GET /' => 'index', - 'GET /@id/' => 'show', - 'GET /create/' => 'create', - 'POST /' => 'store', - 'GET /@id/edit/' => 'edit', - 'PUT /@id/' => 'update', - 'DELETE /@id/' => 'destroy' - ]; - - if ($methods !== []) { - static::group( - $pattern, - function () use ($controllerClass, $methods): void { - foreach ($methods as $methodPattern => $controllerMethod) { - static::route( - $methodPattern, - [$controllerClass, $controllerMethod] - ); - } - } - ); - } else { - static::group( - $pattern, - function () use ($defaultMapping, $controllerClass): void { - foreach ($defaultMapping as $methodPattern => $controllerMethod) { - $class = new ReflectionClass($controllerClass); - - if ($class->hasMethod($controllerMethod) === false) { - continue; - } - - static::route( - $methodPattern, - [$controllerClass, $controllerMethod] - ); - } - } - ); - } - } - /** @return Engine Application instance */ public static function app(): Engine { From a12d4740065a36a1b14afebf762e5489f1fe1c5d Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Mon, 3 Jun 2024 20:03:02 -0600 Subject: [PATCH 03/46] beautify --- flight/Engine.php | 91 ++++++++++---------- flight/Flight.php | 2 +- tests/FlightTest.php | 3 +- tests/groupcompactsyntax/UsersController.php | 6 +- 4 files changed, 52 insertions(+), 50 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index 2c06adc..8572b69 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -42,7 +42,7 @@ use flight\net\Route; * Routes a PATCH URL to a callback function. * @method Route delete(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a DELETE URL to a callback function. - * @method void resource(string $pattern, string $controllerClass, array $methods = []) + * @method void resource(string $pattern, 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 @@ -729,7 +729,7 @@ class Engine return $this->router()->map('DELETE ' . $pattern, $callback, $pass_route, $route_alias); } - /** + /** * Create a resource controller customizing the methods names mapping. * * @param class-string $controllerClass @@ -748,56 +748,57 @@ class Engine // 'GET /@id/edit' => 'edit', // 'PUT /@id' => 'update', // 'DELETE /@id' => 'destroy' - // ]; + // ]; - $defaultMapping = [ + $defaultMapping = [ 'index' => 'GET ', 'create' => 'GET /create', 'store' => 'POST ', 'show' => 'GET /@id', 'edit' => 'GET /@id/edit', 'update' => 'PUT /@id', - 'destroy' =>'DELETE /@id' - ]; - - // Create a custom alias base - $aliasBase = trim(basename($pattern), '/'); - if(isset($options['alias_base']) === true) { - $aliasBase = $options['alias_base']; - } - - // Only use these controller methods - if(isset($options['only']) === true) { - $only = $options['only']; - $defaultMapping = array_filter($defaultMapping, function($key) use ($only) { - return in_array($key, $only, true) === true; - }, ARRAY_FILTER_USE_KEY); - - // Exclude these controller methods - } else if(isset($options['except']) === true) { - $except = $options['except']; - $defaultMapping = array_filter($defaultMapping, function($key) use ($except) { - return in_array($key, $except, true) === false; - }, ARRAY_FILTER_USE_KEY); - } - - // Add group middleware - $middleware = []; - if(isset($options['middleware']) === true) { - $middleware = $options['middleware']; - } - - $this->group( - $pattern, - function (Router $router) use ($controllerClass, $defaultMapping, $aliasBase): void { - foreach ($defaultMapping as $controllerMethod => $methodPattern) { - $router->map( - $methodPattern, - $controllerClass.'->'.$controllerMethod - )->setAlias($aliasBase.'.'.$controllerMethod); - } - } - , $middleware); + 'destroy' => 'DELETE /@id' + ]; + + // Create a custom alias base + $aliasBase = trim(basename($pattern), '/'); + if (isset($options['alias_base']) === true) { + $aliasBase = $options['alias_base']; + } + + // Only use these controller methods + if (isset($options['only']) === true) { + $only = $options['only']; + $defaultMapping = array_filter($defaultMapping, function ($key) use ($only) { + return in_array($key, $only, true) === true; + }, ARRAY_FILTER_USE_KEY); + + // Exclude these controller methods + } elseif (isset($options['except']) === true) { + $except = $options['except']; + $defaultMapping = array_filter($defaultMapping, function ($key) use ($except) { + return in_array($key, $except, true) === false; + }, ARRAY_FILTER_USE_KEY); + } + + // Add group middleware + $middleware = []; + if (isset($options['middleware']) === true) { + $middleware = $options['middleware']; + } + + $this->group( + $pattern, + function (Router $router) use ($controllerClass, $defaultMapping, $aliasBase): void { + foreach ($defaultMapping as $controllerMethod => $methodPattern) { + $router->map( + $methodPattern, + $controllerClass . '->' . $controllerMethod + )->setAlias($aliasBase . '.' . $controllerMethod); + } + }, + $middleware + ); } /** diff --git a/flight/Flight.php b/flight/Flight.php index 70e8bdd..4942b25 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -42,7 +42,7 @@ require_once __DIR__ . '/autoload.php'; * Routes a PATCH URL to a callback function. * @method static Route delete(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a DELETE URL to a callback function. - * @method void resource(string $pattern, string $controllerClass, array $methods = []) + * @method void resource(string $pattern, 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 diff --git a/tests/FlightTest.php b/tests/FlightTest.php index 5d196d6..cf0c9f0 100644 --- a/tests/FlightTest.php +++ b/tests/FlightTest.php @@ -361,8 +361,7 @@ class FlightTest extends TestCase string $output, array $renderParams, string $regexp - ): void - { + ): void { Flight::view()->preserveVars = false; $this->expectOutputString($output); diff --git a/tests/groupcompactsyntax/UsersController.php b/tests/groupcompactsyntax/UsersController.php index 184c102..90e1b68 100644 --- a/tests/groupcompactsyntax/UsersController.php +++ b/tests/groupcompactsyntax/UsersController.php @@ -2,13 +2,15 @@ namespace tests\groupcompactsyntax; -final class UsersController { +final class UsersController +{ public function list(): void { echo __METHOD__; } - public function handleRegister(): void { + public function handleRegister(): void + { echo __METHOD__; } } From af2891f568a8ad6b03a74cb1025ee8daa39ef68d Mon Sep 17 00:00:00 2001 From: Billtec Date: Fri, 7 Jun 2024 22:46:14 +0800 Subject: [PATCH 04/46] fix: make the jsonHalt a static function as the user guide indicates --- flight/Flight.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flight/Flight.php b/flight/Flight.php index 207d44b..ecba040 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -68,7 +68,7 @@ require_once __DIR__ . '/autoload.php'; * @method static void redirect(string $url, int $code = 303) Redirects to another URL. * @method static void json(mixed $data, int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) * Sends a JSON response. - * @method void jsonHalt(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * @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. From f06febdb2d12ce25a06639443090bcb545b9356d Mon Sep 17 00:00:00 2001 From: Austin Collier Date: Sat, 29 Jun 2024 16:11:07 -0600 Subject: [PATCH 05/46] changed default cache behavior --- flight/net/Response.php | 29 ++++++++++++++++++-------- tests/ResponseTest.php | 46 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/flight/net/Response.php b/flight/net/Response.php index 1798de5..b02535f 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -288,13 +288,7 @@ class Response { if ($expires === false) { $this->headers['Expires'] = 'Mon, 26 Jul 1997 05:00:00 GMT'; - - $this->headers['Cache-Control'] = [ - 'no-store, no-cache, must-revalidate', - 'post-check=0, pre-check=0', - 'max-age=0', - ]; - + $this->headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'; $this->headers['Pragma'] = 'no-cache'; } else { $expires = \is_int($expires) ? $expires : strtotime($expires); @@ -437,8 +431,14 @@ class Response $this->processResponseCallbacks(); } - if (headers_sent() === false) { - $this->sendHeaders(); // @codeCoverageIgnore + if ($this->headersSent() === false) { + + // If you haven't set a Cache-Control header, we'll assume you don't want caching + if($this->getHeader('Cache-Control') === null) { + $this->cache(false); + } + + $this->sendHeaders(); } echo $this->body; @@ -446,6 +446,17 @@ class Response $this->sent = true; } + /** + * Headers have been sent + * + * @return bool + * @codeCoverageIgnore + */ + public function headersSent(): bool + { + return headers_sent(); + } + /** * Adds a callback to process the response body before it's sent. These are processed in the order * they are added diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index a163e7e..de461eb 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -164,11 +164,7 @@ class ResponseTest extends TestCase $response->cache(false); $this->assertEquals([ 'Expires' => 'Mon, 26 Jul 1997 05:00:00 GMT', - 'Cache-Control' => [ - 'no-store, no-cache, must-revalidate', - 'post-check=0, pre-check=0', - 'max-age=0', - ], + 'Cache-Control' => 'no-store, no-cache, must-revalidate', 'Pragma' => 'no-cache' ], $response->headers()); } @@ -239,6 +235,46 @@ class ResponseTest extends TestCase $this->assertTrue($response->sent()); } + public function testSendWithNoHeadersSent() + { + $response = new class extends Response { + protected $test_sent_headers = []; + + public function setRealHeader(string $header_string, bool $replace = true, int $response_code = 0): self + { + $this->test_sent_headers[] = $header_string; + return $this; + } + + public function getSentHeaders(): array + { + return $this->test_sent_headers; + } + + public function headersSent(): bool + { + return false; + } + }; + $response->header('Content-Type', 'text/html'); + $response->header('X-Test', 'test'); + $response->write('Something'); + + $this->expectOutputString('Something'); + + $response->send(); + $sent_headers = $response->getSentHeaders(); + $this->assertEquals([ + 'HTTP/1.1 200 OK', + 'Content-Type: text/html', + 'X-Test: test', + 'Expires: Mon, 26 Jul 1997 05:00:00 GMT', + 'Cache-Control: no-store, no-cache, must-revalidate', + 'Pragma: no-cache', + 'Content-Length: 9' + ], $sent_headers); + } + public function testClearBody() { $response = new Response(); From ba80e047b1bd7459cdd9029a6b104b46324be600 Mon Sep 17 00:00:00 2001 From: Austin Collier Date: Sat, 29 Jun 2024 16:14:08 -0600 Subject: [PATCH 06/46] prettified and added max-age=0 --- flight/net/Response.php | 31 +++++++++++++++---------------- tests/FlightTest.php | 3 +-- tests/ResponseTest.php | 22 +++++++++++----------- 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/flight/net/Response.php b/flight/net/Response.php index b02535f..362fe5d 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -288,7 +288,7 @@ class Response { if ($expires === false) { $this->headers['Expires'] = 'Mon, 26 Jul 1997 05:00:00 GMT'; - $this->headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'; + $this->headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'; $this->headers['Pragma'] = 'no-cache'; } else { $expires = \is_int($expires) ? $expires : strtotime($expires); @@ -432,11 +432,10 @@ class Response } if ($this->headersSent() === false) { - - // If you haven't set a Cache-Control header, we'll assume you don't want caching - if($this->getHeader('Cache-Control') === null) { - $this->cache(false); - } + // If you haven't set a Cache-Control header, we'll assume you don't want caching + if ($this->getHeader('Cache-Control') === null) { + $this->cache(false); + } $this->sendHeaders(); } @@ -446,16 +445,16 @@ class Response $this->sent = true; } - /** - * Headers have been sent - * - * @return bool - * @codeCoverageIgnore - */ - public function headersSent(): bool - { - return headers_sent(); - } + /** + * Headers have been sent + * + * @return bool + * @codeCoverageIgnore + */ + public function headersSent(): bool + { + return headers_sent(); + } /** * Adds a callback to process the response body before it's sent. These are processed in the order diff --git a/tests/FlightTest.php b/tests/FlightTest.php index 5d196d6..cf0c9f0 100644 --- a/tests/FlightTest.php +++ b/tests/FlightTest.php @@ -361,8 +361,7 @@ class FlightTest extends TestCase string $output, array $renderParams, string $regexp - ): void - { + ): void { Flight::view()->preserveVars = false; $this->expectOutputString($output); diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index de461eb..b646294 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -164,7 +164,7 @@ class ResponseTest extends TestCase $response->cache(false); $this->assertEquals([ 'Expires' => 'Mon, 26 Jul 1997 05:00:00 GMT', - 'Cache-Control' => 'no-store, no-cache, must-revalidate', + 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma' => 'no-cache' ], $response->headers()); } @@ -235,8 +235,8 @@ class ResponseTest extends TestCase $this->assertTrue($response->sent()); } - public function testSendWithNoHeadersSent() - { + public function testSendWithNoHeadersSent() + { $response = new class extends Response { protected $test_sent_headers = []; @@ -251,16 +251,16 @@ class ResponseTest extends TestCase return $this->test_sent_headers; } - public function headersSent(): bool - { - return false; - } + public function headersSent(): bool + { + return false; + } }; $response->header('Content-Type', 'text/html'); $response->header('X-Test', 'test'); $response->write('Something'); - $this->expectOutputString('Something'); + $this->expectOutputString('Something'); $response->send(); $sent_headers = $response->getSentHeaders(); @@ -268,9 +268,9 @@ class ResponseTest extends TestCase 'HTTP/1.1 200 OK', 'Content-Type: text/html', 'X-Test: test', - 'Expires: Mon, 26 Jul 1997 05:00:00 GMT', - 'Cache-Control: no-store, no-cache, must-revalidate', - 'Pragma: no-cache', + 'Expires: Mon, 26 Jul 1997 05:00:00 GMT', + 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0', + 'Pragma: no-cache', 'Content-Length: 9' ], $sent_headers); } From 4a93c661a852f4241ad90eaf8ddde8fab19ca313 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Fri, 12 Jul 2024 21:40:58 -0600 Subject: [PATCH 07/46] corrected the cache behavior in some areas --- flight/Engine.php | 5 +++++ flight/net/Response.php | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/flight/Engine.php b/flight/Engine.php index a1378a6..4dd595f 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -610,6 +610,7 @@ class Engine try { $this->response() + ->cache(0) ->clearBody() ->status(500) ->write($msg) @@ -736,6 +737,10 @@ class Engine */ public function _halt(int $code = 200, string $message = '', bool $actuallyExit = true): void { + if ($this->response()->getHeader('Cache-Control') === null) { + $this->response()->cache(0); + } + $this->response() ->clearBody() ->status($code) diff --git a/flight/net/Response.php b/flight/net/Response.php index 362fe5d..73be770 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -286,7 +286,7 @@ class Response */ public function cache($expires): self { - if ($expires === false) { + if ($expires === false || $expires === 0) { $this->headers['Expires'] = 'Mon, 26 Jul 1997 05:00:00 GMT'; $this->headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'; $this->headers['Pragma'] = 'no-cache'; From f08b9bcbfbd94645c2050255f579951232fff468 Mon Sep 17 00:00:00 2001 From: Pierre Clavequin Date: Sat, 27 Jul 2024 00:02:36 +0800 Subject: [PATCH 08/46] feat: method to download files easily --- flight/Engine.php | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/flight/Engine.php b/flight/Engine.php index 4dd595f..e84a742 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -76,7 +76,7 @@ class Engine private const MAPPABLE_METHODS = [ 'start', 'stop', 'route', 'halt', 'error', 'notFound', 'render', 'redirect', 'etag', 'lastModified', 'json', 'jsonHalt', 'jsonp', - 'post', 'put', 'patch', 'delete', 'group', 'getUrl' + 'post', 'put', 'patch', 'delete', 'group', 'getUrl', 'download' ]; /** @var array Stored variables. */ @@ -895,6 +895,31 @@ class Engine } } + public function _download(string $file): void { + if (!file_exists($file)) { + throw new Exception("$file cannot be found."); + } + + $fileSize = filesize($file); + + $mimeType = mime_content_type($file); + + header('Content-Description: File Transfer'); + header('Content-Type: ' . $mimeType); + header('Content-Disposition: attachment; filename="' . basename($file) . '"'); + header('Expires: 0'); + header('Cache-Control: must-revalidate'); + header('Pragma: public'); + header('Content-Length: ' . $fileSize); + + // Clear the output buffer + ob_clean(); + flush(); + + // Read the file and send it to the output buffer + readfile($file); + } + /** * Handles ETag HTTP caching. * From f697e30afa89dfaf9b1a2c796d080e7bf123a1e3 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Fri, 26 Jul 2024 21:07:27 -0600 Subject: [PATCH 09/46] added unit and integration tests --- flight/Engine.php | 47 ++++++++++++++++++++----------- flight/Flight.php | 3 +- tests/EngineTest.php | 34 ++++++++++++++++++++++ tests/server/LayoutMiddleware.php | 1 + tests/server/index.php | 5 ++++ tests/server/test_file.txt | 1 + 6 files changed, 74 insertions(+), 17 deletions(-) create mode 100644 tests/server/test_file.txt diff --git a/flight/Engine.php b/flight/Engine.php index e84a742..3f08e65 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -62,9 +62,10 @@ use flight\net\Route; * @method void jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) * Sends a JSONP response. * - * # HTTP caching + * # HTTP methods * @method void etag(string $id, ('strong'|'weak') $type = 'strong') Handles ETag HTTP caching. * @method void lastModified(int $time) Handles last modified HTTP caching. + * @method void download(string $filePath) Downloads a file * * phpcs:disable PSR2.Methods.MethodDeclaration.Underscore */ @@ -895,29 +896,43 @@ class Engine } } - public function _download(string $file): void { - if (!file_exists($file)) { - throw new Exception("$file cannot be found."); + /** + * Downloads a file + * + * @param string $filePath The path to the file to download + * @throws Exception If the file cannot be found + * + * @return void + */ + public function _download(string $filePath): void { + if (file_exists($filePath) === false) { + throw new Exception("$filePath cannot be found."); } - $fileSize = filesize($file); + $fileSize = filesize($filePath); - $mimeType = mime_content_type($file); + $mimeType = mime_content_type($filePath); + $mimeType = $mimeType !== false ? $mimeType : 'application/octet-stream'; - header('Content-Description: File Transfer'); - header('Content-Type: ' . $mimeType); - header('Content-Disposition: attachment; filename="' . basename($file) . '"'); - header('Expires: 0'); - header('Cache-Control: must-revalidate'); - header('Pragma: public'); - header('Content-Length: ' . $fileSize); + $response = $this->response(); + $response->send(); + $response->setRealHeader('Content-Description: File Transfer'); + $response->setRealHeader('Content-Type: ' . $mimeType); + $response->setRealHeader('Content-Disposition: attachment; filename="' . basename($filePath) . '"'); + $response->setRealHeader('Expires: 0'); + $response->setRealHeader('Cache-Control: must-revalidate'); + $response->setRealHeader('Pragma: public'); + $response->setRealHeader('Content-Length: ' . $fileSize); - // Clear the output buffer + // // Clear the output buffer ob_clean(); flush(); - // Read the file and send it to the output buffer - readfile($file); + // // Read the file and send it to the output buffer + readfile($filePath); + if(empty(getenv('PHPUNIT_TEST'))) { + exit; // @codeCoverageIgnore + } } /** diff --git a/flight/Flight.php b/flight/Flight.php index ecba040..7002e66 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -75,9 +75,10 @@ require_once __DIR__ . '/autoload.php'; * @method static void error(Throwable $exception) Sends an HTTP 500 response. * @method static void notFound() Sends an HTTP 404 response. * - * # HTTP caching + * # HTTP methods * @method static void etag(string $id, ('strong'|'weak') $type = 'strong') Performs ETag HTTP caching. * @method static void lastModified(int $time) Performs last modified HTTP caching. + * @method static void download(string $filePath) Downloads a file */ class Flight { diff --git a/tests/EngineTest.php b/tests/EngineTest.php index 93f3ff7..d4bf243 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -952,4 +952,38 @@ class EngineTest extends TestCase $this->assertEquals('Method Not Allowed', $engine->response()->getBody()); } + public function testDownload() + { + $engine = new class extends Engine { + public function getLoader() + { + return $this->loader; + } + }; + // doing this so we can overwrite some parts of the response + $engine->getLoader()->register('response', function () { + return new class extends Response { + public function setRealHeader( + string $header_string, + bool $replace = true, + int $response_code = 0 + ): self { + return $this; + } + }; + }); + $tmpfile = tmpfile(); + fwrite($tmpfile, 'I am a teapot'); + $streamPath = stream_get_meta_data($tmpfile)['uri']; + $this->expectOutputString('I am a teapot'); + $engine->download($streamPath); + } + + public function testDownloadBadPath() { + $engine = new Engine(); + $this->expectException(Exception::class); + $this->expectExceptionMessage("/path/to/nowhere cannot be found."); + $engine->download('/path/to/nowhere'); + } + } diff --git a/tests/server/LayoutMiddleware.php b/tests/server/LayoutMiddleware.php index 2d55f24..719d8cc 100644 --- a/tests/server/LayoutMiddleware.php +++ b/tests/server/LayoutMiddleware.php @@ -86,6 +86,7 @@ class LayoutMiddleware
  • Dice Container
  • No Container Registered
  • Pascal_Snake_Case
  • +
  • Download File
  • HTML; echo '
    '; diff --git a/tests/server/index.php b/tests/server/index.php index 5c86d11..8bb0498 100644 --- a/tests/server/index.php +++ b/tests/server/index.php @@ -175,6 +175,11 @@ Flight::route('/json-halt', function () { Flight::jsonHalt(['message' => 'JSON rendered and halted successfully with no other body content!']); }); +// Download a file +Flight::route('/download', function () { + Flight::download('test_file.txt'); +}); + Flight::map('error', function (Throwable $e) { echo sprintf( << Date: Wed, 21 Aug 2024 09:00:30 -0600 Subject: [PATCH 10/46] Added ability to handle file uploads in a simple way --- flight/Engine.php | 49 +++-------- flight/net/Request.php | 59 ++++++++++++++ flight/net/Response.php | 38 +++++++++ flight/net/UploadedFile.php | 157 ++++++++++++++++++++++++++++++++++++ tests/RequestTest.php | 116 ++++++++++++++++++-------- tests/UploadedFileTest.php | 56 +++++++++++++ tests/server/index.php | 2 +- 7 files changed, 406 insertions(+), 71 deletions(-) create mode 100644 flight/net/UploadedFile.php create mode 100644 tests/UploadedFileTest.php diff --git a/flight/Engine.php b/flight/Engine.php index 3f08e65..ebc2319 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -896,43 +896,18 @@ class Engine } } - /** - * Downloads a file - * - * @param string $filePath The path to the file to download - * @throws Exception If the file cannot be found - * - * @return void - */ - public function _download(string $filePath): void { - if (file_exists($filePath) === false) { - throw new Exception("$filePath cannot be found."); - } - - $fileSize = filesize($filePath); - - $mimeType = mime_content_type($filePath); - $mimeType = $mimeType !== false ? $mimeType : 'application/octet-stream'; - - $response = $this->response(); - $response->send(); - $response->setRealHeader('Content-Description: File Transfer'); - $response->setRealHeader('Content-Type: ' . $mimeType); - $response->setRealHeader('Content-Disposition: attachment; filename="' . basename($filePath) . '"'); - $response->setRealHeader('Expires: 0'); - $response->setRealHeader('Cache-Control: must-revalidate'); - $response->setRealHeader('Pragma: public'); - $response->setRealHeader('Content-Length: ' . $fileSize); - - // // Clear the output buffer - ob_clean(); - flush(); - - // // Read the file and send it to the output buffer - readfile($filePath); - if(empty(getenv('PHPUNIT_TEST'))) { - exit; // @codeCoverageIgnore - } + /** + * Downloads a file + * + * @param string $filePath The path to the file to download + * + * @throws Exception If the file cannot be found + * + * @return void + */ + public function _download(string $filePath): void + { + $this->response()->downloadFile($filePath); } /** diff --git a/flight/net/Request.php b/flight/net/Request.php index fd9194b..9cdc64b 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -414,4 +414,63 @@ class Request return 'http'; } + + /** + * Retrieves the array of uploaded files. + * + * @return array|array>> The array of uploaded files. + */ + public function getUploadedFiles(): array + { + $files = []; + $correctedFilesArray = $this->reArrayFiles($this->files); + foreach ($correctedFilesArray as $keyName => $files) { + foreach ($files as $file) { + $UploadedFile = new UploadedFile( + $file['name'], + $file['type'], + $file['size'], + $file['tmp_name'], + $file['error'] + ); + if (count($files) > 1) { + $files[$keyName][] = $UploadedFile; + } else { + $files[$keyName] = $UploadedFile; + } + } + } + + return $files; + } + + /** + * Re-arranges the files in the given files collection. + * + * @param Collection $filesCollection The collection of files to be re-arranged. + * + * @return array>> The re-arranged files collection. + */ + protected function reArrayFiles(Collection $filesCollection): array + { + + $fileArray = []; + foreach ($filesCollection as $fileKeyName => $file) { + $isMulti = is_array($file['name']) === true && count($file['name']) > 1; + $fileCount = $isMulti === true ? count($file['name']) : 1; + $fileKeys = array_keys($file); + + for ($i = 0; $i < $fileCount; $i++) { + foreach ($fileKeys as $key) { + if ($isMulti === true) { + $fileArray[$fileKeyName][$i][$key] = $file[$key][$i]; + } else { + $fileArray[$fileKeyName][$i][$key] = $file[$key]; + } + } + } + } + + return $fileArray; + } } diff --git a/flight/net/Response.php b/flight/net/Response.php index 73be770..264174e 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -480,4 +480,42 @@ class Response $this->body = $callback($this->body); } } + + /** + * Downloads a file. + * + * @param string $filePath The path to the file to be downloaded. + * + * @return void + */ + public function downloadFile(string $filePath): void + { + if (file_exists($filePath) === false) { + throw new Exception("$filePath cannot be found."); + } + + $fileSize = filesize($filePath); + + $mimeType = mime_content_type($filePath); + $mimeType = $mimeType !== false ? $mimeType : 'application/octet-stream'; + + $this->send(); + $this->setRealHeader('Content-Description: File Transfer'); + $this->setRealHeader('Content-Type: ' . $mimeType); + $this->setRealHeader('Content-Disposition: attachment; filename="' . basename($filePath) . '"'); + $this->setRealHeader('Expires: 0'); + $this->setRealHeader('Cache-Control: must-revalidate'); + $this->setRealHeader('Pragma: public'); + $this->setRealHeader('Content-Length: ' . $fileSize); + + // // Clear the output buffer + ob_clean(); + flush(); + + // // Read the file and send it to the output buffer + readfile($filePath); + if (empty(getenv('PHPUNIT_TEST'))) { + exit; // @codeCoverageIgnore + } + } } diff --git a/flight/net/UploadedFile.php b/flight/net/UploadedFile.php new file mode 100644 index 0000000..2b3947b --- /dev/null +++ b/flight/net/UploadedFile.php @@ -0,0 +1,157 @@ +name = $name; + $this->mimeType = $mimeType; + $this->size = $size; + $this->tmpName = $tmpName; + $this->error = $error; + } + + /** + * Retrieves the client-side filename of the uploaded file. + * + * @return string The client-side filename. + */ + public function getClientFilename(): string + { + return $this->name; + } + + /** + * Retrieves the media type of the uploaded file as provided by the client. + * + * @return string The media type of the uploaded file. + */ + public function getClientMediaType(): string + { + return $this->mimeType; + } + + /** + * Returns the size of the uploaded file. + * + * @return int The size of the uploaded file. + */ + public function getSize(): int + { + return $this->size; + } + + /** + * Retrieves the temporary name of the uploaded file. + * + * @return string The temporary name of the uploaded file. + */ + public function getTempName(): string + { + return $this->tmpName; + } + + /** + * Get the error code associated with the uploaded file. + * + * @return int The error code. + */ + public function getError(): int + { + return $this->error; + } + + /** + * Moves the uploaded file to the specified target path. + * + * @param string $targetPath The path to move the file to. + * + * @return void + */ + public function moveTo(string $targetPath): void + { + if ($this->error !== UPLOAD_ERR_OK) { + throw new Exception($this->getUploadErrorMessage($this->error)); + } + + $isUploadedFile = is_uploaded_file($this->tmpName) === true; + if ( + $isUploadedFile === true + && + move_uploaded_file($this->tmpName, $targetPath) === false + ) { + throw new Exception('Cannot move uploaded file'); // @codeCoverageIgnore + } elseif ($isUploadedFile === false && getenv('PHPUNIT_TEST')) { + rename($this->tmpName, $targetPath); + } + } + + /** + * Retrieves the error message for a given upload error code. + * + * @param int $error The upload error code. + * + * @return string The error message. + */ + protected function getUploadErrorMessage(int $error): string + { + switch ($error) { + case UPLOAD_ERR_INI_SIZE: + return 'The uploaded file exceeds the upload_max_filesize directive in php.ini.'; + case UPLOAD_ERR_FORM_SIZE: + return 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.'; + case UPLOAD_ERR_PARTIAL: + return 'The uploaded file was only partially uploaded.'; + case UPLOAD_ERR_NO_FILE: + return 'No file was uploaded.'; + case UPLOAD_ERR_NO_TMP_DIR: + return 'Missing a temporary folder.'; + case UPLOAD_ERR_CANT_WRITE: + return 'Failed to write file to disk.'; + case UPLOAD_ERR_EXTENSION: + return 'A PHP extension stopped the file upload.'; + default: + return 'An unknown error occurred. Error code: ' . $error; + } + } +} diff --git a/tests/RequestTest.php b/tests/RequestTest.php index a8b4310..9b4c234 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -41,23 +41,23 @@ class RequestTest extends TestCase public function testDefaults() { - self::assertEquals('/', $this->request->url); - self::assertEquals('/', $this->request->base); - self::assertEquals('GET', $this->request->method); - self::assertEquals('', $this->request->referrer); - self::assertTrue($this->request->ajax); - self::assertEquals('http', $this->request->scheme); - self::assertEquals('', $this->request->type); - self::assertEquals(0, $this->request->length); - self::assertFalse($this->request->secure); - self::assertEquals('', $this->request->accept); - self::assertEquals('example.com', $this->request->host); + $this->assertEquals('/', $this->request->url); + $this->assertEquals('/', $this->request->base); + $this->assertEquals('GET', $this->request->method); + $this->assertEquals('', $this->request->referrer); + $this->assertTrue($this->request->ajax); + $this->assertEquals('http', $this->request->scheme); + $this->assertEquals('', $this->request->type); + $this->assertEquals(0, $this->request->length); + $this->assertFalse($this->request->secure); + $this->assertEquals('', $this->request->accept); + $this->assertEquals('example.com', $this->request->host); } public function testIpAddress() { - self::assertEquals('8.8.8.8', $this->request->ip); - self::assertEquals('32.32.32.32', $this->request->proxy_ip); + $this->assertEquals('8.8.8.8', $this->request->ip); + $this->assertEquals('32.32.32.32', $this->request->proxy_ip); } public function testSubdirectory() @@ -66,7 +66,7 @@ class RequestTest extends TestCase $request = new Request(); - self::assertEquals('/subdir', $request->base); + $this->assertEquals('/subdir', $request->base); } public function testQueryParameters() @@ -75,9 +75,9 @@ class RequestTest extends TestCase $request = new Request(); - self::assertEquals('/page?id=1&name=bob', $request->url); - self::assertEquals(1, $request->query->id); - self::assertEquals('bob', $request->query->name); + $this->assertEquals('/page?id=1&name=bob', $request->url); + $this->assertEquals(1, $request->query->id); + $this->assertEquals('bob', $request->query->name); } public function testCollections() @@ -91,11 +91,11 @@ class RequestTest extends TestCase $request = new Request(); - self::assertEquals(1, $request->query->q); - self::assertEquals(1, $request->query->id); - self::assertEquals(1, $request->data->q); - self::assertEquals(1, $request->cookies->q); - self::assertEquals(1, $request->files->q); + $this->assertEquals(1, $request->query->q); + $this->assertEquals(1, $request->query->id); + $this->assertEquals(1, $request->data->q); + $this->assertEquals(1, $request->cookies->q); + $this->assertEquals(1, $request->files->q); } public function testJsonWithEmptyBody() @@ -104,7 +104,7 @@ class RequestTest extends TestCase $request = new Request(); - self::assertSame([], $request->data->getData()); + $this->assertSame([], $request->data->getData()); } public function testMethodOverrideWithHeader() @@ -113,7 +113,7 @@ class RequestTest extends TestCase $request = new Request(); - self::assertEquals('PUT', $request->method); + $this->assertEquals('PUT', $request->method); } public function testMethodOverrideWithPost() @@ -122,38 +122,38 @@ class RequestTest extends TestCase $request = new Request(); - self::assertEquals('PUT', $request->method); + $this->assertEquals('PUT', $request->method); } public function testHttps() { $_SERVER['HTTPS'] = 'on'; $request = new Request(); - self::assertEquals('https', $request->scheme); + $this->assertEquals('https', $request->scheme); $_SERVER['HTTPS'] = 'off'; $request = new Request(); - self::assertEquals('http', $request->scheme); + $this->assertEquals('http', $request->scheme); $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; $request = new Request(); - self::assertEquals('https', $request->scheme); + $this->assertEquals('https', $request->scheme); $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'http'; $request = new Request(); - self::assertEquals('http', $request->scheme); + $this->assertEquals('http', $request->scheme); $_SERVER['HTTP_FRONT_END_HTTPS'] = 'on'; $request = new Request(); - self::assertEquals('https', $request->scheme); + $this->assertEquals('https', $request->scheme); $_SERVER['HTTP_FRONT_END_HTTPS'] = 'off'; $request = new Request(); - self::assertEquals('http', $request->scheme); + $this->assertEquals('http', $request->scheme); $_SERVER['REQUEST_SCHEME'] = 'https'; $request = new Request(); - self::assertEquals('https', $request->scheme); + $this->assertEquals('https', $request->scheme); $_SERVER['REQUEST_SCHEME'] = 'http'; $request = new Request(); - self::assertEquals('http', $request->scheme); + $this->assertEquals('http', $request->scheme); } public function testInitUrlSameAsBaseDirectory() @@ -279,4 +279,54 @@ class RequestTest extends TestCase $request = new Request(); $this->assertEquals('https://localhost:8000', $request->getBaseUrl()); } + + public function testGetSingleFileUpload() + { + $_FILES['file'] = [ + 'name' => 'file.txt', + 'type' => 'text/plain', + 'size' => 123, + 'tmp_name' => '/tmp/php123', + 'error' => 0 + ]; + + $request = new Request(); + + $file = $request->getUploadedFiles()['file']; + + $this->assertEquals('file.txt', $file->getClientFilename()); + $this->assertEquals('text/plain', $file->getClientMediaType()); + $this->assertEquals(123, $file->getSize()); + $this->assertEquals('/tmp/php123', $file->getTempName()); + $this->assertEquals(0, $file->getError()); + } + + public function testGetMultiFileUpload() + { + $_FILES['files'] = [ + 'name' => ['file1.txt', 'file2.txt'], + 'type' => ['text/plain', 'text/plain'], + 'size' => [123, 456], + 'tmp_name' => ['/tmp/php123', '/tmp/php456'], + 'error' => [0, 0] + ]; + + $request = new Request(); + + $files = $request->getUploadedFiles()['files']; + + $this->assertCount(2, $files); + + $this->assertEquals('file1.txt', $files[0]->getClientFilename()); + $this->assertEquals('text/plain', $files[0]->getClientMediaType()); + $this->assertEquals(123, $files[0]->getSize()); + $this->assertEquals('/tmp/php123', $files[0]->getTempName()); + $this->assertEquals(0, $files[0]->getError()); + + $this->assertEquals('file2.txt', $files[1]->getClientFilename()); + $this->assertEquals('text/plain', $files[1]->getClientMediaType()); + $this->assertEquals(456, $files[1]->getSize()); + $this->assertEquals('/tmp/php456', $files[1]->getTempName()); + $this->assertEquals(0, $files[1]->getError()); + } } diff --git a/tests/UploadedFileTest.php b/tests/UploadedFileTest.php new file mode 100644 index 0000000..94d9f75 --- /dev/null +++ b/tests/UploadedFileTest.php @@ -0,0 +1,56 @@ +moveTo('file.txt'); + $this->assertFileExists('file.txt'); + } + + public function getFileErrorMessageTests(): array + { + return [ + [ UPLOAD_ERR_INI_SIZE, 'The uploaded file exceeds the upload_max_filesize directive in php.ini.', ], + [ UPLOAD_ERR_FORM_SIZE, 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.', ], + [ UPLOAD_ERR_PARTIAL, 'The uploaded file was only partially uploaded.', ], + [ UPLOAD_ERR_NO_FILE, 'No file was uploaded.', ], + [ UPLOAD_ERR_NO_TMP_DIR, 'Missing a temporary folder.', ], + [ UPLOAD_ERR_CANT_WRITE, 'Failed to write file to disk.', ], + [ UPLOAD_ERR_EXTENSION, 'A PHP extension stopped the file upload.', ], + [ -1, 'An unknown error occurred. Error code: -1' ] + ]; + } + + /** + * @dataProvider getFileErrorMessageTests + */ + public function testMoveToFailureMessages($error, $message) + { + file_put_contents('tmp_name', 'test'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', $error); + $this->expectException(Exception::class); + $this->expectExceptionMessage($message); + $uploadedFile->moveTo('file.txt'); + } +} diff --git a/tests/server/index.php b/tests/server/index.php index 8bb0498..a572ed8 100644 --- a/tests/server/index.php +++ b/tests/server/index.php @@ -177,7 +177,7 @@ Flight::route('/json-halt', function () { // Download a file Flight::route('/download', function () { - Flight::download('test_file.txt'); + Flight::download('test_file.txt'); }); Flight::map('error', function (Throwable $e) { From c5caa0d7e233a6e1beb62c2f3c798c04cfcf21ce Mon Sep 17 00:00:00 2001 From: lubiana Date: Tue, 27 Aug 2024 18:41:41 +0200 Subject: [PATCH 11/46] add case for Macos in ResponseTest::testResponseBodyCallbackGzip --- tests/ResponseTest.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index b646294..fade322 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -318,7 +318,16 @@ class ResponseTest extends TestCase ob_start(); $response->send(); $gzip_body = ob_get_clean(); - $expected = PHP_OS === 'WINNT' ? 'H4sIAAAAAAAACitJLS4BAAx+f9gEAAAA' : 'H4sIAAAAAAAAAytJLS4BAAx+f9gEAAAA'; + switch (PHP_OS) { + case 'WINNT': + $expected = 'H4sIAAAAAAAACitJLS4BAAx+f9gEAAAA'; + break; + case 'Darwin': + $expected = 'H4sIAAAAAAAAEytJLS4BAAx+f9gEAAAA'; + break; + default: + $expected = 'H4sIAAAAAAAAAytJLS4BAAx+f9gEAAAA'; + } $this->assertEquals($expected, base64_encode($gzip_body)); $this->assertEquals(strlen(gzencode('test')), strlen($gzip_body)); } From 720deac36ee15868bd187eba1fba87853d34aefd Mon Sep 17 00:00:00 2001 From: lubiana Date: Tue, 27 Aug 2024 20:25:55 +0200 Subject: [PATCH 12/46] add unit-test workflow --- .github/workflows/test.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..75f03e1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: Pull Request Check +on: [pull_request] + +jobs: + unit-test: + name: Unit testing + strategy: + fail-fast: false + matrix: + php: [7.4, 8.0, 8.1, 8.2, 8.3, 8.4] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, mbstring + tools: composer:v2 + - run: composer install + - run: composer test \ No newline at end of file From 2d36b79f3215323a376f153a4d7a43c32e7a314e Mon Sep 17 00:00:00 2001 From: lubiana Date: Tue, 27 Aug 2024 21:31:11 +0200 Subject: [PATCH 13/46] fix tests on different php versions --- tests/EngineTest.php | 11 +++++++---- tests/commands/ControllerCommandTest.php | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/EngineTest.php b/tests/EngineTest.php index d4bf243..82b7294 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -813,12 +813,15 @@ class EngineTest extends TestCase $engine->request()->url = '/container'; // php 7.4 will throw a PDO exception, but php 8 will throw an ErrorException - if(version_compare(PHP_VERSION, '8.0.0', '<')) { - $this->expectException(PDOException::class); - $this->expectExceptionMessageMatches("/invalid data source name/"); - } else { + if(version_compare(PHP_VERSION, '8.1.0') >= 0) { $this->expectException(ErrorException::class); $this->expectExceptionMessageMatches("/Passing null to parameter/"); + } elseif(version_compare(PHP_VERSION, '8.0.0') >= 0) { + $this->expectException(PDOException::class); + $this->expectExceptionMessageMatches("/must be a valid data source name/"); + } else { + $this->expectException(PDOException::class); + $this->expectExceptionMessageMatches("/invalid data source name/"); } $engine->start(); diff --git a/tests/commands/ControllerCommandTest.php b/tests/commands/ControllerCommandTest.php index 82fb0c1..c333b75 100644 --- a/tests/commands/ControllerCommandTest.php +++ b/tests/commands/ControllerCommandTest.php @@ -68,6 +68,8 @@ class ControllerCommandTest extends TestCase public function testCreateController() { + + $this->markTestIncomplete('does not work on php > 8.0'); $app = $this->newApp('test', '0.0.1'); $app->add(new ControllerCommand(['app_root' => 'tests/commands/'])); $app->handle(['runway', 'make:controller', 'Test']); From 6c365a00e9e0b365d880cca90021215431fd39dd Mon Sep 17 00:00:00 2001 From: lubiana Date: Tue, 27 Aug 2024 21:37:49 +0200 Subject: [PATCH 14/46] remove unit tests on 8.4 (for now) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75f03e1..30ff6e3 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] runs-on: ubuntu-latest steps: - name: Checkout repository From 0fdc300f334ae828c22695f7825aa022541cbdd8 Mon Sep 17 00:00:00 2001 From: Austin Collier Date: Thu, 29 Aug 2024 07:32:35 -0600 Subject: [PATCH 15/46] updated runway dependency version --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index bf59bbf..6fec7c5 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ }, "require-dev": { "ext-pdo_sqlite": "*", - "flightphp/runway": "^0.2.0", + "flightphp/runway": "^0.2.3", "league/container": "^4.2", "level-2/dice": "^4.0", "phpstan/extension-installer": "^1.3", From 4276c2baeb182b08cd7b8cc31ad1f9cba135507a Mon Sep 17 00:00:00 2001 From: lubiana Date: Thu, 29 Aug 2024 15:39:01 +0200 Subject: [PATCH 16/46] reenable comtroller command test --- tests/commands/ControllerCommandTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/commands/ControllerCommandTest.php b/tests/commands/ControllerCommandTest.php index c333b75..82fb0c1 100644 --- a/tests/commands/ControllerCommandTest.php +++ b/tests/commands/ControllerCommandTest.php @@ -68,8 +68,6 @@ class ControllerCommandTest extends TestCase public function testCreateController() { - - $this->markTestIncomplete('does not work on php > 8.0'); $app = $this->newApp('test', '0.0.1'); $app->add(new ControllerCommand(['app_root' => 'tests/commands/'])); $app->handle(['runway', 'make:controller', 'Test']); From 06fa9e5e515fdc16aa80c45693e63f220aa39617 Mon Sep 17 00:00:00 2001 From: lubiana Date: Thu, 29 Aug 2024 15:56:30 +0200 Subject: [PATCH 17/46] allow newer runway version in composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 6fec7c5..23b6b9e 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ }, "require-dev": { "ext-pdo_sqlite": "*", - "flightphp/runway": "^0.2.3", + "flightphp/runway": "^0.2.3 || ^1.0", "league/container": "^4.2", "level-2/dice": "^4.0", "phpstan/extension-installer": "^1.3", From edcd1ad092dd0511222dc3cadc5fa8b031d3d9c7 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Fri, 27 Sep 2024 23:26:49 -0600 Subject: [PATCH 18/46] got unit tests up and running --- flight/Engine.php | 60 +--------- flight/Flight.php | 2 +- flight/net/Router.php | 65 ++++++++++- .../FlightRouteCompactSyntaxTest.php | 107 ++++++++++++------ tests/groupcompactsyntax/UsersController.php | 4 +- 5 files changed, 141 insertions(+), 97 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index 8572b69..83f0c1b 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -740,65 +740,7 @@ class Engine string $controllerClass, array $options = [] ): void { - // $defaultMapping = [ - // 'GET ' => 'index', - // 'GET /create' => 'create', - // 'POST ' => 'store', - // 'GET /@id' => 'show', - // 'GET /@id/edit' => 'edit', - // 'PUT /@id' => 'update', - // 'DELETE /@id' => 'destroy' - // ]; - - $defaultMapping = [ - 'index' => 'GET ', - 'create' => 'GET /create', - 'store' => 'POST ', - 'show' => 'GET /@id', - 'edit' => 'GET /@id/edit', - 'update' => 'PUT /@id', - 'destroy' => 'DELETE /@id' - ]; - - // Create a custom alias base - $aliasBase = trim(basename($pattern), '/'); - if (isset($options['alias_base']) === true) { - $aliasBase = $options['alias_base']; - } - - // Only use these controller methods - if (isset($options['only']) === true) { - $only = $options['only']; - $defaultMapping = array_filter($defaultMapping, function ($key) use ($only) { - return in_array($key, $only, true) === true; - }, ARRAY_FILTER_USE_KEY); - - // Exclude these controller methods - } elseif (isset($options['except']) === true) { - $except = $options['except']; - $defaultMapping = array_filter($defaultMapping, function ($key) use ($except) { - return in_array($key, $except, true) === false; - }, ARRAY_FILTER_USE_KEY); - } - - // Add group middleware - $middleware = []; - if (isset($options['middleware']) === true) { - $middleware = $options['middleware']; - } - - $this->group( - $pattern, - function (Router $router) use ($controllerClass, $defaultMapping, $aliasBase): void { - foreach ($defaultMapping as $controllerMethod => $methodPattern) { - $router->map( - $methodPattern, - $controllerClass . '->' . $controllerMethod - )->setAlias($aliasBase . '.' . $controllerMethod); - } - }, - $middleware - ); + $this->router()->mapResource($pattern, $controllerClass, $options); } /** diff --git a/flight/Flight.php b/flight/Flight.php index 4942b25..287d43a 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -42,7 +42,7 @@ require_once __DIR__ . '/autoload.php'; * Routes a PATCH URL to a callback function. * @method static Route delete(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a DELETE URL to a callback function. - * @method void resource(string $pattern, string $controllerClass, array $methods = []) + * @method static void resource(string $pattern, 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 diff --git a/flight/net/Router.php b/flight/net/Router.php index a43b5ba..f80fab6 100644 --- a/flight/net/Router.php +++ b/flight/net/Router.php @@ -87,7 +87,7 @@ class Router public function map(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): Route { - // This means that the route ies defined in a group, but the defined route is the base + // This means that the route is defined in a group, but the defined route is the base // url path. Note the '' in route() // Ex: Flight::group('/api', function() { // Flight::route('', function() {}); @@ -276,6 +276,69 @@ class Router throw new Exception($exception_message); } + /** + * Create a resource controller customizing the methods names mapping. + * + * @param class-string $controllerClass + * @param array $options + */ + public function mapResource( + string $pattern, + string $controllerClass, + array $options = [] + ): void { + + $defaultMapping = [ + 'index' => 'GET ', + 'create' => 'GET /create', + 'store' => 'POST ', + 'show' => 'GET /@id', + 'edit' => 'GET /@id/edit', + 'update' => 'PUT /@id', + 'destroy' => 'DELETE /@id' + ]; + + // Create a custom alias base + $aliasBase = trim(basename($pattern), '/'); + if (isset($options['alias_base']) === true) { + $aliasBase = $options['alias_base']; + } + + // Only use these controller methods + if (isset($options['only']) === true) { + $only = $options['only']; + $defaultMapping = array_filter($defaultMapping, function ($key) use ($only) { + return in_array($key, $only, true) === true; + }, ARRAY_FILTER_USE_KEY); + + // Exclude these controller methods + } elseif (isset($options['except']) === true) { + $except = $options['except']; + $defaultMapping = array_filter($defaultMapping, function ($key) use ($except) { + return in_array($key, $except, true) === false; + }, ARRAY_FILTER_USE_KEY); + } + + // Add group middleware + $middleware = []; + if (isset($options['middleware']) === true) { + $middleware = $options['middleware']; + } + + $this->group( + $pattern, + function (Router $router) use ($controllerClass, $defaultMapping, $aliasBase): void { + foreach ($defaultMapping as $controllerMethod => $methodPattern) { + $router->map( + $methodPattern, + [ $controllerClass, $controllerMethod ] + )->setAlias($aliasBase . '.' . $controllerMethod); + } + }, + $middleware + ); + } + /** * Rewinds the current route index. */ diff --git a/tests/groupcompactsyntax/FlightRouteCompactSyntaxTest.php b/tests/groupcompactsyntax/FlightRouteCompactSyntaxTest.php index 3e522a2..719946d 100644 --- a/tests/groupcompactsyntax/FlightRouteCompactSyntaxTest.php +++ b/tests/groupcompactsyntax/FlightRouteCompactSyntaxTest.php @@ -17,85 +17,124 @@ final class FlightRouteCompactSyntaxTest extends TestCase public function testCanMapMethodsWithVerboseSyntax(): void { - Flight::route('GET /users', [UsersController::class, 'list']); - Flight::route('POST /users', [UsersController::class, 'handleRegister']); + Flight::route('GET /users', [UsersController::class, 'index']); + Flight::route('DELETE /users/@id', [UsersController::class, 'destroy']); $routes = Flight::router()->getRoutes(); $this->assertCount(2, $routes); $this->assertSame('/users', $routes[0]->pattern); - $this->assertSame([UsersController::class, 'list'], $routes[0]->callback); + $this->assertSame([UsersController::class, 'index'], $routes[0]->callback); $this->assertSame('GET', $routes[0]->methods[0]); - $this->assertSame('/users', $routes[1]->pattern); - $this->assertSame([UsersController::class, 'handleRegister'], $routes[1]->callback); - $this->assertSame('POST', $routes[1]->methods[0]); + $this->assertSame('/users/@id', $routes[1]->pattern); + $this->assertSame([UsersController::class, 'destroy'], $routes[1]->callback); + $this->assertSame('DELETE', $routes[1]->methods[0]); } - public function testCanMapSomeMethods(): void + public function testOptionsOnly(): void { Flight::resource('/users', UsersController::class, [ - 'GET /' => 'list', - 'POST /' => 'handleRegister' + 'only' => [ 'index', 'destroy' ] ]); $routes = Flight::router()->getRoutes(); $this->assertCount(2, $routes); - $this->assertSame('/users/', $routes[0]->pattern); + $this->assertSame('/users', $routes[0]->pattern); $this->assertSame('GET', $routes[0]->methods[0]); - $this->assertSame([UsersController::class, 'list'], $routes[0]->callback); + $this->assertSame([UsersController::class, 'index'], $routes[0]->callback); - $this->assertSame('/users/', $routes[1]->pattern); - $this->assertSame('POST', $routes[1]->methods[0]); - $this->assertSame([UsersController::class, 'handleRegister'], $routes[1]->callback); + $this->assertSame('/users/@id', $routes[1]->pattern); + $this->assertSame('DELETE', $routes[1]->methods[0]); + $this->assertSame([UsersController::class, 'destroy'], $routes[1]->callback); } - public function testCanMapDefaultMethods(): void + public function testDefaultMethods(): void { Flight::resource('/posts', PostsController::class); $routes = Flight::router()->getRoutes(); - $this->assertCount(7, $routes); - $this->assertSame('/posts/', $routes[0]->pattern); + $this->assertSame('/posts', $routes[0]->pattern); $this->assertSame('GET', $routes[0]->methods[0]); $this->assertSame([PostsController::class, 'index'], $routes[0]->callback); - - $this->assertSame('/posts/@id/', $routes[1]->pattern); + $this->assertSame('posts.index', $routes[0]->alias); + + $this->assertSame('/posts/create', $routes[1]->pattern); $this->assertSame('GET', $routes[1]->methods[0]); - $this->assertSame([PostsController::class, 'show'], $routes[1]->callback); - - $this->assertSame('/posts/create/', $routes[2]->pattern); - $this->assertSame('GET', $routes[2]->methods[0]); - $this->assertSame([PostsController::class, 'create'], $routes[2]->callback); - - $this->assertSame('/posts/', $routes[3]->pattern); - $this->assertSame('POST', $routes[3]->methods[0]); - $this->assertSame([PostsController::class, 'store'], $routes[3]->callback); - - $this->assertSame('/posts/@id/edit/', $routes[4]->pattern); + $this->assertSame([PostsController::class, 'create'], $routes[1]->callback); + $this->assertSame('posts.create', $routes[1]->alias); + + $this->assertSame('/posts', $routes[2]->pattern); + $this->assertSame('POST', $routes[2]->methods[0]); + $this->assertSame([PostsController::class, 'store'], $routes[2]->callback); + $this->assertSame('posts.store', $routes[2]->alias); + + $this->assertSame('/posts/@id', $routes[3]->pattern); + $this->assertSame('GET', $routes[3]->methods[0]); + $this->assertSame([PostsController::class, 'show'], $routes[3]->callback); + $this->assertSame('posts.show', $routes[3]->alias); + + $this->assertSame('/posts/@id/edit', $routes[4]->pattern); $this->assertSame('GET', $routes[4]->methods[0]); $this->assertSame([PostsController::class, 'edit'], $routes[4]->callback); + $this->assertSame('posts.edit', $routes[4]->alias); - $this->assertSame('/posts/@id/', $routes[5]->pattern); + $this->assertSame('/posts/@id', $routes[5]->pattern); $this->assertSame('PUT', $routes[5]->methods[0]); $this->assertSame([PostsController::class, 'update'], $routes[5]->callback); + $this->assertSame('posts.update', $routes[5]->alias); - $this->assertSame('/posts/@id/', $routes[6]->pattern); + $this->assertSame('/posts/@id', $routes[6]->pattern); $this->assertSame('DELETE', $routes[6]->methods[0]); $this->assertSame([PostsController::class, 'destroy'], $routes[6]->callback); + $this->assertSame('posts.destroy', $routes[6]->alias); } - public function testCanMapExistingMethods(): void + public function testOptionsExcept(): void { - Flight::resource('/todos', TodosController::class); + Flight::resource('/todos', TodosController::class, [ + 'except' => [ 'create', 'store', 'update', 'destroy', 'edit' ] + ]); $routes = Flight::router()->getRoutes(); $this->assertCount(2, $routes); + + $this->assertSame('/todos', $routes[0]->pattern); + $this->assertSame('GET', $routes[0]->methods[0]); + $this->assertSame([TodosController::class, 'index'], $routes[0]->callback); + + $this->assertSame('/todos/@id', $routes[1]->pattern); + $this->assertSame('GET', $routes[1]->methods[0]); + $this->assertSame([TodosController::class, 'show'], $routes[1]->callback); } + + public function testOptionsMiddlewareAndAliasBase(): void + { + Flight::resource('/todos', TodosController::class, [ + 'middleware' => [ 'auth' ], + 'alias_base' => 'nothanks' + ]); + + $routes = Flight::router()->getRoutes(); + + $this->assertCount(7, $routes); + + $this->assertSame('/todos', $routes[0]->pattern); + $this->assertSame('GET', $routes[0]->methods[0]); + $this->assertSame([TodosController::class, 'index'], $routes[0]->callback); + $this->assertSame('auth', $routes[0]->middleware[0]); + $this->assertSame('nothanks.index', $routes[0]->alias); + + $this->assertSame('/todos/create', $routes[1]->pattern); + $this->assertSame('GET', $routes[1]->methods[0]); + $this->assertSame([TodosController::class, 'create'], $routes[1]->callback); + $this->assertSame('auth', $routes[1]->middleware[0]); + $this->assertSame('nothanks.create', $routes[1]->alias); + } } diff --git a/tests/groupcompactsyntax/UsersController.php b/tests/groupcompactsyntax/UsersController.php index 90e1b68..2727ac6 100644 --- a/tests/groupcompactsyntax/UsersController.php +++ b/tests/groupcompactsyntax/UsersController.php @@ -4,12 +4,12 @@ namespace tests\groupcompactsyntax; final class UsersController { - public function list(): void + public function index(): void { echo __METHOD__; } - public function handleRegister(): void + public function destroy(): void { echo __METHOD__; } From a788c8a6081b07ff1299fc52160dfc0d68c9ad78 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Fri, 27 Sep 2024 23:35:20 -0600 Subject: [PATCH 19/46] prettify --- flight/Engine.php | 4 +- flight/net/Router.php | 6 +- .../FlightRouteCompactSyntaxTest.php | 78 ++++++++++--------- tests/groupcompactsyntax/PostsController.php | 2 + tests/groupcompactsyntax/TodosController.php | 2 + tests/groupcompactsyntax/UsersController.php | 2 + 6 files changed, 51 insertions(+), 43 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index 83f0c1b..1235229 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -733,14 +733,14 @@ class Engine * Create a resource controller customizing the methods names mapping. * * @param class-string $controllerClass - * @param array $options + * @param array> $options */ public function _resource( string $pattern, string $controllerClass, array $options = [] ): void { - $this->router()->mapResource($pattern, $controllerClass, $options); + $this->router()->mapResource($pattern, $controllerClass, $options); } /** diff --git a/flight/net/Router.php b/flight/net/Router.php index f80fab6..50275ac 100644 --- a/flight/net/Router.php +++ b/flight/net/Router.php @@ -276,13 +276,13 @@ class Router throw new Exception($exception_message); } - /** + /** * Create a resource controller customizing the methods names mapping. * * @param class-string $controllerClass - * @param array $options + * @param array> $options */ - public function mapResource( + public function mapResource( string $pattern, string $controllerClass, array $options = [] diff --git a/tests/groupcompactsyntax/FlightRouteCompactSyntaxTest.php b/tests/groupcompactsyntax/FlightRouteCompactSyntaxTest.php index 719946d..1f438bb 100644 --- a/tests/groupcompactsyntax/FlightRouteCompactSyntaxTest.php +++ b/tests/groupcompactsyntax/FlightRouteCompactSyntaxTest.php @@ -1,5 +1,7 @@ [ 'index', 'destroy' ] + 'only' => [ 'index', 'destroy' ] ]); $routes = Flight::router()->getRoutes(); @@ -62,79 +64,79 @@ final class FlightRouteCompactSyntaxTest extends TestCase $this->assertSame('/posts', $routes[0]->pattern); $this->assertSame('GET', $routes[0]->methods[0]); $this->assertSame([PostsController::class, 'index'], $routes[0]->callback); - $this->assertSame('posts.index', $routes[0]->alias); - + $this->assertSame('posts.index', $routes[0]->alias); + $this->assertSame('/posts/create', $routes[1]->pattern); $this->assertSame('GET', $routes[1]->methods[0]); $this->assertSame([PostsController::class, 'create'], $routes[1]->callback); - $this->assertSame('posts.create', $routes[1]->alias); - + $this->assertSame('posts.create', $routes[1]->alias); + $this->assertSame('/posts', $routes[2]->pattern); $this->assertSame('POST', $routes[2]->methods[0]); $this->assertSame([PostsController::class, 'store'], $routes[2]->callback); - $this->assertSame('posts.store', $routes[2]->alias); - + $this->assertSame('posts.store', $routes[2]->alias); + $this->assertSame('/posts/@id', $routes[3]->pattern); $this->assertSame('GET', $routes[3]->methods[0]); $this->assertSame([PostsController::class, 'show'], $routes[3]->callback); - $this->assertSame('posts.show', $routes[3]->alias); + $this->assertSame('posts.show', $routes[3]->alias); $this->assertSame('/posts/@id/edit', $routes[4]->pattern); $this->assertSame('GET', $routes[4]->methods[0]); $this->assertSame([PostsController::class, 'edit'], $routes[4]->callback); - $this->assertSame('posts.edit', $routes[4]->alias); + $this->assertSame('posts.edit', $routes[4]->alias); $this->assertSame('/posts/@id', $routes[5]->pattern); $this->assertSame('PUT', $routes[5]->methods[0]); $this->assertSame([PostsController::class, 'update'], $routes[5]->callback); - $this->assertSame('posts.update', $routes[5]->alias); + $this->assertSame('posts.update', $routes[5]->alias); $this->assertSame('/posts/@id', $routes[6]->pattern); $this->assertSame('DELETE', $routes[6]->methods[0]); $this->assertSame([PostsController::class, 'destroy'], $routes[6]->callback); - $this->assertSame('posts.destroy', $routes[6]->alias); + $this->assertSame('posts.destroy', $routes[6]->alias); } public function testOptionsExcept(): void { Flight::resource('/todos', TodosController::class, [ - 'except' => [ 'create', 'store', 'update', 'destroy', 'edit' ] - ]); + 'except' => [ 'create', 'store', 'update', 'destroy', 'edit' ] + ]); $routes = Flight::router()->getRoutes(); $this->assertCount(2, $routes); - $this->assertSame('/todos', $routes[0]->pattern); + $this->assertSame('/todos', $routes[0]->pattern); $this->assertSame('GET', $routes[0]->methods[0]); $this->assertSame([TodosController::class, 'index'], $routes[0]->callback); - $this->assertSame('/todos/@id', $routes[1]->pattern); + $this->assertSame('/todos/@id', $routes[1]->pattern); $this->assertSame('GET', $routes[1]->methods[0]); $this->assertSame([TodosController::class, 'show'], $routes[1]->callback); } - public function testOptionsMiddlewareAndAliasBase(): void - { - Flight::resource('/todos', TodosController::class, [ - 'middleware' => [ 'auth' ], - 'alias_base' => 'nothanks' - ]); - - $routes = Flight::router()->getRoutes(); - - $this->assertCount(7, $routes); - - $this->assertSame('/todos', $routes[0]->pattern); - $this->assertSame('GET', $routes[0]->methods[0]); - $this->assertSame([TodosController::class, 'index'], $routes[0]->callback); - $this->assertSame('auth', $routes[0]->middleware[0]); - $this->assertSame('nothanks.index', $routes[0]->alias); - - $this->assertSame('/todos/create', $routes[1]->pattern); - $this->assertSame('GET', $routes[1]->methods[0]); - $this->assertSame([TodosController::class, 'create'], $routes[1]->callback); - $this->assertSame('auth', $routes[1]->middleware[0]); - $this->assertSame('nothanks.create', $routes[1]->alias); - } + public function testOptionsMiddlewareAndAliasBase(): void + { + Flight::resource('/todos', TodosController::class, [ + 'middleware' => [ 'auth' ], + 'alias_base' => 'nothanks' + ]); + + $routes = Flight::router()->getRoutes(); + + $this->assertCount(7, $routes); + + $this->assertSame('/todos', $routes[0]->pattern); + $this->assertSame('GET', $routes[0]->methods[0]); + $this->assertSame([TodosController::class, 'index'], $routes[0]->callback); + $this->assertSame('auth', $routes[0]->middleware[0]); + $this->assertSame('nothanks.index', $routes[0]->alias); + + $this->assertSame('/todos/create', $routes[1]->pattern); + $this->assertSame('GET', $routes[1]->methods[0]); + $this->assertSame([TodosController::class, 'create'], $routes[1]->callback); + $this->assertSame('auth', $routes[1]->middleware[0]); + $this->assertSame('nothanks.create', $routes[1]->alias); + } } diff --git a/tests/groupcompactsyntax/PostsController.php b/tests/groupcompactsyntax/PostsController.php index ac2c497..bae8242 100644 --- a/tests/groupcompactsyntax/PostsController.php +++ b/tests/groupcompactsyntax/PostsController.php @@ -1,5 +1,7 @@ Date: Fri, 27 Sep 2024 23:41:45 -0600 Subject: [PATCH 20/46] fix bad resolve --- flight/Engine.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index df06a8c..d52f498 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -600,11 +600,7 @@ class Engine */ public function _error(Throwable $e): void { - $msg = sprintf( - - - - HTML + $msg = sprintf(<<500 Internal Server Error

    %s (%s)

    %s
    From ab18073e287bef8ba2b6404d5df574ecd3e8058d Mon Sep 17 00:00:00 2001 From: lubiana Date: Thu, 21 Nov 2024 17:24:47 +0100 Subject: [PATCH 21/46] add php 8.4 to workflow matrix --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30ff6e3..75f03e1 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] + php: [7.4, 8.0, 8.1, 8.2, 8.3, 8.4] runs-on: ubuntu-latest steps: - name: Checkout repository From c003dd094f823e1cadaaa66859de255d39dcb1f2 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Sun, 22 Dec 2024 00:03:35 -0400 Subject: [PATCH 22/46] Fixed README badges --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a749fdb..aa0b04b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -[![Version](http://poser.pugx.org/flightphp/core/version)](https://packagist.org/packages/flightphp/core) -[![Monthly Downloads](http://poser.pugx.org/flightphp/core/d/monthly)](https://packagist.org/packages/flightphp/core) +[![Version](https://poser.pugx.org/flightphp/core/version)](https://packagist.org/packages/flightphp/core) +[![Monthly Downloads](https://poser.pugx.org/flightphp/core/d/monthly)](https://packagist.org/packages/flightphp/core) ![PHPStan: Level 6](https://img.shields.io/badge/PHPStan-level%206-brightgreen.svg?style=flat) -[![License](http://poser.pugx.org/flightphp/core/license)](https://packagist.org/packages/flightphp/core) -[![PHP Version Require](http://poser.pugx.org/flightphp/core/require/php)](https://packagist.org/packages/flightphp/core) +[![License](https://poser.pugx.org/flightphp/core/license)](https://packagist.org/packages/flightphp/core) +[![PHP Version Require](https://poser.pugx.org/flightphp/core/require/php)](https://packagist.org/packages/flightphp/core) ![Matrix](https://img.shields.io/matrix/flight-php-framework%3Amatrix.org?server_fqdn=matrix.org&style=social&logo=matrix) # What is Flight? From 882a956bd0f16bbfd82bb3afd656c8b3aea5abec Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Wed, 1 Jan 2025 13:55:41 -0700 Subject: [PATCH 23/46] added some fixes to get this working in windows locally --- flight/Engine.php | 7 ++++--- flight/Flight.php | 4 ++-- flight/net/Route.php | 2 +- tests/FlightTest.php | 2 +- tests/ViewTest.php | 8 ++++---- tests/commands/ControllerCommandTest.php | 11 +++++++++-- tests/commands/RouteCommandTest.php | 17 ++++++++++++----- tests/server/index.php | 2 +- 8 files changed, 34 insertions(+), 19 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index d52f498..27b44b4 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -42,13 +42,13 @@ use flight\net\Route; * Routes a PATCH URL to a callback function. * @method Route delete(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a DELETE URL to a callback function. - * @method void resource(string $pattern, string $controllerClass, array $methods = []) + * @method void resource(string $pattern, string $controllerClass, array> $methods = []) * Adds standardized RESTful routes for a controller. * @method Router router() Gets router * @method string getUrl(string $alias) Gets a url from an alias * * # Views - * @method void render(string $file, ?array $data = null, ?string $key = null) Renders template + * @method void render(string $file, ?array $data = null, ?string $key = null) Renders template * @method View view() Gets current view * * # Request-Response @@ -600,7 +600,8 @@ class Engine */ public function _error(Throwable $e): void { - $msg = sprintf(<<500 Internal Server Error

    %s (%s)

    %s
    diff --git a/flight/Flight.php b/flight/Flight.php index 61de281..8887269 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -23,7 +23,7 @@ require_once __DIR__ . '/autoload.php'; * @method static void stop(?int $code = null) Stops the framework and sends a response. * @method static void halt(int $code = 200, string $message = '', bool $actuallyExit = true) * Stop the framework with an optional status code and message. - * @method static void register(string $name, string $class, array $params = [], ?callable $callback = null) + * @method static void register(string $name, string $class, array $params = [], ?callable $callback = null) * Registers a class to a framework method. * @method static void unregister(string $methodName) * Unregisters a class to a framework method. @@ -42,7 +42,7 @@ require_once __DIR__ . '/autoload.php'; * Routes a PATCH URL to a callback function. * @method static Route delete(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a DELETE URL to a callback function. - * @method static void resource(string $pattern, string $controllerClass, array $methods = []) + * @method static void resource(string $pattern, 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 diff --git a/flight/net/Route.php b/flight/net/Route.php index 4e6e83c..05811ec 100644 --- a/flight/net/Route.php +++ b/flight/net/Route.php @@ -198,7 +198,7 @@ class Route public function hydrateUrl(array $params = []): string { $url = preg_replace_callback("/(?:@([\w]+)(?:\:([^\/]+))?\)*)/i", function ($match) use ($params) { - if (isset($match[1]) && isset($params[$match[1]])) { + if (isset($params[$match[1]]) === true) { return $params[$match[1]]; } }, $this->pattern); diff --git a/tests/FlightTest.php b/tests/FlightTest.php index cf0c9f0..54acf53 100644 --- a/tests/FlightTest.php +++ b/tests/FlightTest.php @@ -378,7 +378,7 @@ class FlightTest extends TestCase public function testKeepThePreviousStateOfOneViewComponentByDefault(): void { - $this->expectOutputString(<<expectOutputString(<<<'html'
    Hi
    Hi
    diff --git a/tests/ViewTest.php b/tests/ViewTest.php index d6754c9..e95224d 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -175,7 +175,7 @@ class ViewTest extends TestCase public function testKeepThePreviousStateOfOneViewComponentByDefault(): void { - $this->expectOutputString(<<expectOutputString(<<<'html'
    Hi
    Hi
    @@ -197,7 +197,7 @@ class ViewTest extends TestCase $this->view->set('prop', 'bar'); - $this->expectOutputString(<<expectOutputString(<<<'html'
    qux
    bar
    @@ -211,7 +211,7 @@ class ViewTest extends TestCase { return [ [ - <<Hi
    @@ -220,7 +220,7 @@ class ViewTest extends TestCase '/^Undefined variable:? \$?prop$/' ], [ - << diff --git a/tests/commands/ControllerCommandTest.php b/tests/commands/ControllerCommandTest.php index 82fb0c1..b16f6bc 100644 --- a/tests/commands/ControllerCommandTest.php +++ b/tests/commands/ControllerCommandTest.php @@ -11,11 +11,14 @@ use PHPUnit\Framework\TestCase; class ControllerCommandTest extends TestCase { - protected static $in = __DIR__ . '/input.test'; - protected static $ou = __DIR__ . '/output.test'; + protected static $in = ''; + protected static $ou = ''; public function setUp(): void { + // Need dynamic filenames to avoid unlink() issues with windows. + static::$in = __DIR__ . DIRECTORY_SEPARATOR . 'input.test' . uniqid('', true) . '.txt'; + static::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt'; file_put_contents(static::$in, '', LOCK_EX); file_put_contents(static::$ou, '', LOCK_EX); } @@ -37,6 +40,10 @@ class ControllerCommandTest extends TestCase if (file_exists(__DIR__ . '/controllers/')) { rmdir(__DIR__ . '/controllers/'); } + + // Thanks Windows + clearstatcache(); + gc_collect_cycles(); } protected function newApp(string $name, string $version = '') diff --git a/tests/commands/RouteCommandTest.php b/tests/commands/RouteCommandTest.php index eae0b81..d58562d 100644 --- a/tests/commands/RouteCommandTest.php +++ b/tests/commands/RouteCommandTest.php @@ -13,13 +13,16 @@ use PHPUnit\Framework\TestCase; class RouteCommandTest extends TestCase { - protected static $in = __DIR__ . '/input.test'; - protected static $ou = __DIR__ . '/output.test'; + protected static $in = __DIR__ . DIRECTORY_SEPARATOR . 'input.test'; + protected static $ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test'; public function setUp(): void { - file_put_contents(static::$in, '', LOCK_EX); - file_put_contents(static::$ou, '', LOCK_EX); + // Need dynamic filenames to avoid unlink() issues with windows. + static::$in = __DIR__ . DIRECTORY_SEPARATOR . 'input.test' . uniqid('', true) . '.txt'; + static::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt'; + file_put_contents(static::$in, ''); + file_put_contents(static::$ou, ''); $_SERVER = []; $_REQUEST = []; Flight::init(); @@ -43,6 +46,10 @@ class RouteCommandTest extends TestCase unset($_REQUEST); unset($_SERVER); Flight::clear(); + + // Thanks Windows + clearstatcache(); + gc_collect_cycles(); } protected function newApp(string $name, string $version = '') @@ -54,7 +61,7 @@ class RouteCommandTest extends TestCase protected function createIndexFile() { - $index = <<500 Internal Server Error

    %s (%s)

    %s
    From f4f3c7a342c5571d71190d4c018362a80ecdb71d Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Mon, 13 Jan 2025 22:57:04 -0700 Subject: [PATCH 24/46] Fixed multiple trailing slashes issues. Also fixed server test for 405 and 404 --- flight/Engine.php | 2 +- flight/net/Route.php | 13 ++++++++++--- flight/net/Router.php | 4 ++-- tests/EngineTest.php | 4 ++-- tests/RouterTest.php | 18 +++++++++++++++++- tests/commands/RouteCommandTest.php | 2 +- tests/server/LayoutMiddleware.php | 3 ++- 7 files changed, 35 insertions(+), 11 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index 27b44b4..0a77031 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -179,7 +179,7 @@ class Engine } // Set case-sensitivity - $self->router()->case_sensitive = $self->get('flight.case_sensitive'); + $self->router()->caseSensitive = $self->get('flight.case_sensitive'); // Set Content-Length $self->response()->content_length = $self->get('flight.content_length'); // This is to maintain legacy handling of output buffering diff --git a/flight/net/Route.php b/flight/net/Route.php index 05811ec..8294a19 100644 --- a/flight/net/Route.php +++ b/flight/net/Route.php @@ -98,17 +98,24 @@ class Route * Checks if a URL matches the route pattern. Also parses named parameters in the URL. * * @param string $url Requested URL (original format, not URL decoded) - * @param bool $case_sensitive Case sensitive matching + * @param bool $caseSensitive Case sensitive matching * * @return bool Match status */ - public function matchUrl(string $url, bool $case_sensitive = false): bool + public function matchUrl(string $url, bool $caseSensitive = false): bool { // Wildcard or exact match if ($this->pattern === '*' || $this->pattern === $url) { return true; } + // if the last character of the incoming url is a slash, only allow one trailing slash, not multiple + if (substr($url, -2) === '//') { + // remove all trailing slashes, and then add one back. + $url = rtrim($url, '/') . '/'; + } + + $ids = []; $last_char = substr($this->pattern, -1); @@ -157,7 +164,7 @@ class Route $regex .= $last_char === '/' ? '?' : '/?'; // Attempt to match route and named parameters - if (!preg_match('#^' . $regex . '(?:\?[\s\S]*)?$#' . (($case_sensitive) ? '' : 'i'), $url, $matches)) { + if (!preg_match('#^' . $regex . '(?:\?[\s\S]*)?$#' . (($caseSensitive) ? '' : 'i'), $url, $matches)) { return false; } diff --git a/flight/net/Router.php b/flight/net/Router.php index 50275ac..8025024 100644 --- a/flight/net/Router.php +++ b/flight/net/Router.php @@ -20,7 +20,7 @@ class Router /** * Case sensitive matching. */ - public bool $case_sensitive = false; + public bool $caseSensitive = false; /** * Mapped routes. @@ -221,7 +221,7 @@ class Router public function route(Request $request) { while ($route = $this->current()) { - $urlMatches = $route->matchUrl($request->url, $this->case_sensitive); + $urlMatches = $route->matchUrl($request->url, $this->caseSensitive); $methodMatches = $route->matchMethod($request->method); if ($urlMatches === true && $methodMatches === true) { $this->executedRoute = $route; diff --git a/tests/EngineTest.php b/tests/EngineTest.php index 82b7294..e2289c6 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -45,7 +45,7 @@ class EngineTest extends TestCase $engine->request()->url = '/someRoute'; $engine->start(); - $this->assertFalse($engine->router()->case_sensitive); + $this->assertFalse($engine->router()->caseSensitive); $this->assertTrue($engine->response()->content_length); } @@ -64,7 +64,7 @@ class EngineTest extends TestCase // This is a necessary evil because of how the v2 output buffer works. ob_end_clean(); - $this->assertFalse($engine->router()->case_sensitive); + $this->assertFalse($engine->router()->caseSensitive); $this->assertTrue($engine->response()->content_length); } diff --git a/tests/RouterTest.php b/tests/RouterTest.php index f0ac765..ebb3fa7 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -117,6 +117,14 @@ class RouterTest extends TestCase $this->check('OK'); } + public function testPathRouteWithUrlTrailingSlash() + { + $this->router->map('/path', [$this, 'ok']); + $this->request->url = '/path/'; + + $this->check('OK'); + } + public function testGetRouteShortcut() { $this->router->get('/path', [$this, 'ok']); @@ -455,7 +463,7 @@ class RouterTest extends TestCase { $this->router->map('/hello', [$this, 'ok']); $this->request->url = '/HELLO'; - $this->router->case_sensitive = true; + $this->router->caseSensitive = true; $this->check('404'); } @@ -752,4 +760,12 @@ class RouterTest extends TestCase $this->assertEquals('/path1/123/abc', $url); } + + public function testStripMultipleSlashesFromUrlAndStillMatch() + { + $this->router->get('/', [ $this, 'ok' ]); + $this->request->url = '///'; + $this->request->method = 'GET'; + $this->check('OK'); + } } diff --git a/tests/commands/RouteCommandTest.php b/tests/commands/RouteCommandTest.php index d58562d..fd4cc2b 100644 --- a/tests/commands/RouteCommandTest.php +++ b/tests/commands/RouteCommandTest.php @@ -71,7 +71,7 @@ Flight::post('/post', function () {})->addMiddleware(function() {}); Flight::delete('/delete', function () {}); Flight::put('/put', function () {}); Flight::patch('/patch', function () {})->addMiddleware('SomeMiddleware'); -Flight::router()->case_sensitive = true; +Flight::router()->caseSensitive = true; Flight::start(); PHP; diff --git a/tests/server/LayoutMiddleware.php b/tests/server/LayoutMiddleware.php index 719d8cc..8ee6800 100644 --- a/tests/server/LayoutMiddleware.php +++ b/tests/server/LayoutMiddleware.php @@ -69,7 +69,8 @@ class LayoutMiddleware
  • Protected path
  • Template path
  • Query path
  • -
  • 404 Not Found
  • +
  • 404 Not Found
  • +
  • 405 Method Not Found
  • Mega group
  • Error
  • JSON
  • From 1b57f9eb2b32589b23c409e8b8e63df3574ffbe6 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Mon, 3 Feb 2025 23:08:24 +0900 Subject: [PATCH 25/46] Added PUT, PATCH, DELETE methods for data --- flight/net/Request.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/flight/net/Request.php b/flight/net/Request.php index 9cdc64b..02e012f 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -213,6 +213,16 @@ class Request } } + // Check PUT, PATCH, DELETE for data + if ($this->method === 'PUT' || $this->method === 'DELETE' || $this->method === 'PATCH') { + $body = $this->getBody(); + if ($body !== '') { + $data = []; + parse_str($body, $data); + $this->data->setData($data); + } + } + return $this; } From ce088c86dce8ec76bd37747f0e2dd25e41f53ff8 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Wed, 5 Feb 2025 22:58:17 -0700 Subject: [PATCH 26/46] Fix Unit test errors, correct logic --- flight/net/Request.php | 6 ++---- tests/RequestTest.php | 33 +++++++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/flight/net/Request.php b/flight/net/Request.php index 02e012f..f88c770 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -211,10 +211,8 @@ class Request $this->data->setData($data); } } - } - - // Check PUT, PATCH, DELETE for data - if ($this->method === 'PUT' || $this->method === 'DELETE' || $this->method === 'PATCH') { + // Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data + } else if (in_array($this->method, [ 'PUT', 'DELETE', 'PATCH' ], true) === true) { $body = $this->getBody(); if ($body !== '') { $data = []; diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 9b4c234..03acd5a 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -162,7 +162,8 @@ class RequestTest extends TestCase 'url' => '/vagrant/public/flightphp', 'base' => '/vagrant/public', 'query' => new Collection(), - 'type' => '' + 'type' => '', + 'method' => 'GET' ]); $this->assertEquals('/flightphp', $request->url); } @@ -172,7 +173,8 @@ class RequestTest extends TestCase $request = new Request([ 'url' => '', 'base' => '/vagrant/public', - 'type' => '' + 'type' => '', + 'method' => 'GET' ]); $this->assertEquals('/', $request->url); } @@ -183,7 +185,6 @@ class RequestTest extends TestCase $tmpfile = tmpfile(); $stream_path = stream_get_meta_data($tmpfile)['uri']; file_put_contents($stream_path, '{"foo":"bar"}'); - $_SERVER['REQUEST_METHOD'] = 'POST'; $request = new Request([ 'url' => '/something/fancy', 'base' => '/vagrant/public', @@ -191,12 +192,36 @@ class RequestTest extends TestCase 'length' => 13, 'data' => new Collection(), 'query' => new Collection(), - 'stream_path' => $stream_path + 'stream_path' => $stream_path, + 'method' => 'POST' ]); $this->assertEquals([ 'foo' => 'bar' ], $request->data->getData()); $this->assertEquals('{"foo":"bar"}', $request->getBody()); } + public function testInitWithFormBody() + { + // create dummy file to pull request body from + $tmpfile = tmpfile(); + $stream_path = stream_get_meta_data($tmpfile)['uri']; + file_put_contents($stream_path, 'foo=bar&baz=qux'); + $request = new Request([ + 'url' => '/something/fancy', + 'base' => '/vagrant/public', + 'type' => 'application/x-www-form-urlencoded', + 'length' => 15, + 'data' => new Collection(), + 'query' => new Collection(), + 'stream_path' => $stream_path, + 'method' => 'PATCH' + ]); + $this->assertEquals([ + 'foo' => 'bar', + 'baz' => 'qux' + ], $request->data->getData()); + $this->assertEquals('foo=bar&baz=qux', $request->getBody()); + } + public function testGetHeader() { $_SERVER['HTTP_X_CUSTOM_HEADER'] = 'custom header value'; From 25250bf44ec800a8c6bebc245741f305469ff684 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Thu, 13 Feb 2025 22:53:46 -0700 Subject: [PATCH 27/46] Added ability to generate new requests/responses on duplicate start() --- flight/Engine.php | 15 +++++++++++++++ flight/net/Request.php | 4 ++-- tests/EngineTest.php | 24 +++++++++++++++++++++++ tests/FlightTest.php | 9 +++++++-- tests/RequestTest.php | 18 +++++++++--------- tests/ViewTest.php | 43 ++++++++++++++++++++++++++++-------------- 6 files changed, 86 insertions(+), 27 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index 0a77031..e2c1dc3 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -94,6 +94,9 @@ class Engine /** If the framework has been initialized or not. */ protected bool $initialized = false; + /** If the request has been handled or not. */ + protected bool $requestHandled = false; + public function __construct() { $this->loader = new Loader(); @@ -476,6 +479,18 @@ class Engine { $dispatched = false; $self = $this; + + // This behavior is specifically for test suites, and for async platforms like swoole, workerman, etc. + if ($this->requestHandled === false) { + // not doing much here, just setting the requestHandled flag to true + $this->requestHandled = true; + } else { + // deregister the request and response objects and re-register them with new instances + $this->unregister('request'); + $this->unregister('response'); + $this->register('request', Request::class); + $this->register('response', Response::class); + } $request = $this->request(); $response = $this->response(); $router = $this->router(); diff --git a/flight/net/Request.php b/flight/net/Request.php index f88c770..5164b11 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -211,8 +211,8 @@ class Request $this->data->setData($data); } } - // Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data - } else if (in_array($this->method, [ 'PUT', 'DELETE', 'PATCH' ], true) === true) { + // 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 = []; diff --git a/tests/EngineTest.php b/tests/EngineTest.php index e2289c6..857dfb8 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -174,6 +174,30 @@ class EngineTest extends TestCase $engine->start(); } + public function testDoubleStart() + { + $engine = new Engine(); + $engine->route('/someRoute', function () { + echo 'i ran'; + }, true); + $engine->request()->url = '/someRoute'; + $engine->start(); + + $request = $engine->request(); + $response = $engine->response(); + + // This is pretending like this is embodied in a platform like swoole where + // another request comes in while still holding all the same state. + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/someRoute'; + $engine->start(); + + $this->assertFalse($request === $engine->request()); + $this->assertFalse($response === $engine->response()); + + $this->expectOutputString('i rani ran'); + } + public function testStopWithCode() { $engine = new class extends Engine { diff --git a/tests/FlightTest.php b/tests/FlightTest.php index 54acf53..fbc8eb6 100644 --- a/tests/FlightTest.php +++ b/tests/FlightTest.php @@ -378,7 +378,7 @@ class FlightTest extends TestCase public function testKeepThePreviousStateOfOneViewComponentByDefault(): void { - $this->expectOutputString(<<<'html' + $html = <<<'html'
    Hi
    Hi
    @@ -386,7 +386,12 @@ class FlightTest extends TestCase - html); + html; + + // if windows replace \n with \r\n + $html = str_replace("\n", PHP_EOL, $html); + + $this->expectOutputString($html); Flight::render('myComponent', ['prop' => 'Hi']); Flight::render('myComponent'); diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 03acd5a..4044d06 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -163,7 +163,7 @@ class RequestTest extends TestCase 'base' => '/vagrant/public', 'query' => new Collection(), 'type' => '', - 'method' => 'GET' + 'method' => 'GET' ]); $this->assertEquals('/flightphp', $request->url); } @@ -174,7 +174,7 @@ class RequestTest extends TestCase 'url' => '', 'base' => '/vagrant/public', 'type' => '', - 'method' => 'GET' + 'method' => 'GET' ]); $this->assertEquals('/', $request->url); } @@ -193,13 +193,13 @@ class RequestTest extends TestCase 'data' => new Collection(), 'query' => new Collection(), 'stream_path' => $stream_path, - 'method' => 'POST' + 'method' => 'POST' ]); $this->assertEquals([ 'foo' => 'bar' ], $request->data->getData()); $this->assertEquals('{"foo":"bar"}', $request->getBody()); } - public function testInitWithFormBody() + public function testInitWithFormBody() { // create dummy file to pull request body from $tmpfile = tmpfile(); @@ -213,12 +213,12 @@ class RequestTest extends TestCase 'data' => new Collection(), 'query' => new Collection(), 'stream_path' => $stream_path, - 'method' => 'PATCH' + 'method' => 'PATCH' ]); - $this->assertEquals([ - 'foo' => 'bar', - 'baz' => 'qux' - ], $request->data->getData()); + $this->assertEquals([ + 'foo' => 'bar', + 'baz' => 'qux' + ], $request->data->getData()); $this->assertEquals('foo=bar&baz=qux', $request->getBody()); } diff --git a/tests/ViewTest.php b/tests/ViewTest.php index e95224d..e6d9d02 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -175,7 +175,7 @@ class ViewTest extends TestCase public function testKeepThePreviousStateOfOneViewComponentByDefault(): void { - $this->expectOutputString(<<<'html' + $html = <<<'html'
    Hi
    Hi
    @@ -183,7 +183,12 @@ class ViewTest extends TestCase - html); + html; + + // if windows replace \n with \r\n + $html = str_replace("\n", PHP_EOL, $html); + + $this->expectOutputString($html); $this->view->render('myComponent', ['prop' => 'Hi']); $this->view->render('myComponent'); @@ -197,11 +202,16 @@ class ViewTest extends TestCase $this->view->set('prop', 'bar'); - $this->expectOutputString(<<<'html' + $html = <<<'html'
    qux
    bar
    - html); + html; + + // if windows replace \n with \r\n + $html = str_replace("\n", PHP_EOL, $html); + + $this->expectOutputString($html); $this->view->render('myComponent', ['prop' => 'qux']); $this->view->render('myComponent'); @@ -209,24 +219,29 @@ class ViewTest extends TestCase public static function renderDataProvider(): array { - return [ - [ - <<<'html' + $html1 = <<<'html'
    Hi
    - html, - ['myComponent', ['prop' => 'Hi']], - '/^Undefined variable:? \$?prop$/' - ], - [ - <<<'html' + html; + $html2 = <<<'html' - html, + html; + + $html1 = str_replace("\n", PHP_EOL, $html1); + $html2 = str_replace("\n", PHP_EOL, $html2); + return [ + [ + $html1, + ['myComponent', ['prop' => 'Hi']], + '/^Undefined variable:? \$?prop$/' + ], + [ + $html2, ['input', ['type' => 'number']], '/^.*$/' ], From ca7b5b47b9f18a550a95aac91a28027a0f1fd530 Mon Sep 17 00:00:00 2001 From: Joan Miquel Date: Fri, 14 Feb 2025 15:42:39 +0100 Subject: [PATCH 28/46] Simplify Flight and faster performance Null coalescing operator work from PHP 7.0 --- flight/Flight.php | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/flight/Flight.php b/flight/Flight.php index 8887269..1d38677 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -87,9 +87,6 @@ class Flight /** Framework engine. */ private static Engine $engine; - /** Whether or not the app has been initialized. */ - private static bool $initialized = false; - /** * Don't allow object instantiation * @@ -127,14 +124,7 @@ class Flight /** @return Engine Application instance */ public static function app(): Engine { - if (!self::$initialized) { - require_once __DIR__ . '/autoload.php'; - - self::setEngine(new Engine()); - self::$initialized = true; - } - - return self::$engine; + return self::$engine ?? self::$engine = new Engine(); } /** @@ -142,8 +132,8 @@ class Flight * * @param Engine $engine Vroom vroom! */ - public static function setEngine(Engine $engine): void + public static function setEngine(Engine $engine): Engine { - self::$engine = $engine; + return self::$engine = $engine; } } From 78e3a5ed6aa4b0f1f4d1d5a29ee9dba8f652e69d Mon Sep 17 00:00:00 2001 From: Joan Miquel Date: Fri, 14 Feb 2025 15:59:26 +0100 Subject: [PATCH 29/46] Revert setEngine() --- flight/Flight.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flight/Flight.php b/flight/Flight.php index 1d38677..59a9945 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -132,8 +132,8 @@ class Flight * * @param Engine $engine Vroom vroom! */ - public static function setEngine(Engine $engine): Engine + public static function setEngine(Engine $engine): void { - return self::$engine = $engine; + self::$engine = $engine; } } From 27e6fe652f7d7e672f409a15414f5300ceaea782 Mon Sep 17 00:00:00 2001 From: Kenneth Gathuru Date: Sun, 16 Feb 2025 13:19:00 +0300 Subject: [PATCH 30/46] Update README.md with correct version upgrading from v2 to v3 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index aa0b04b..025f2f4 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Chat with us on Matrix IRC [#flight-php-framework:matrix.org](https://matrix.to/ # Upgrading From v2 -If you have a current project on v2, you should be able to upgrade to v2 with no issues depending on how your project was built. If there are any issues with upgrade, they are documented in the [migrating to v3](https://docs.flightphp.com/learn/migrating-to-v3) documentation page. It is the intention of Flight to maintain longterm stability of the project and to not add rewrites with major version changes. +If you have a current project on v2, you should be able to upgrade to v3 with no issues depending on how your project was built. If there are any issues with upgrade, they are documented in the [migrating to v3](https://docs.flightphp.com/learn/migrating-to-v3) documentation page. It is the intention of Flight to maintain longterm stability of the project and to not add rewrites with major version changes. # Requirements From 3a84a9aaf2d085df231b6828fe0295d629f74905 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sun, 16 Feb 2025 08:21:52 -0700 Subject: [PATCH 31/46] removed utf8 from json function, add default flags, and more unit tests --- flight/Engine.php | 8 +++++--- tests/EngineTest.php | 25 ++++++++++++++++++++++--- tests/commands/RouteCommandTest.php | 4 ++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index 0a77031..aa4e202 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -830,7 +830,7 @@ class Engine * @param mixed $data JSON data * @param int $code HTTP status code * @param bool $encode Whether to perform JSON encoding - * @param string $charset Charset + * @param ?string $charset Charset * @param int $option Bitmask Json constant such as JSON_HEX_QUOT * * @throws Exception @@ -839,14 +839,16 @@ class Engine $data, int $code = 200, bool $encode = true, - string $charset = 'utf-8', + ?string $charset = 'utf-8', int $option = 0 ): void { + // add some default flags + $option |= JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR; $json = $encode ? json_encode($data, $option) : $data; $this->response() ->status($code) - ->header('Content-Type', 'application/json; charset=' . $charset) + ->header('Content-Type', 'application/json') ->write($json); if ($this->response()->v2_output_buffering === true) { $this->response()->send(); diff --git a/tests/EngineTest.php b/tests/EngineTest.php index e2289c6..45445ed 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -12,6 +12,7 @@ use flight\net\Request; use flight\net\Response; use flight\util\Collection; use InvalidArgumentException; +use JsonException; use PDOException; use PHPUnit\Framework\TestCase; use tests\classes\Container; @@ -355,18 +356,36 @@ class EngineTest extends TestCase { $engine = new Engine(); $engine->json(['key1' => 'value1', 'key2' => 'value2']); - $this->assertEquals('application/json; charset=utf-8', $engine->response()->headers()['Content-Type']); + $this->assertEquals('application/json', $engine->response()->headers()['Content-Type']); $this->assertEquals(200, $engine->response()->status()); $this->assertEquals('{"key1":"value1","key2":"value2"}', $engine->response()->getBody()); } + public function testJsonWithDuplicateDefaultFlags() + { + $engine = new Engine(); + // utf8 emoji + $engine->json(['key1' => 'value1', 'key2' => 'value2', 'utf8_emoji' => '😀'], 201, true, '', JSON_HEX_TAG | JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $this->assertEquals('application/json', $engine->response()->headers()['Content-Type']); + $this->assertEquals(201, $engine->response()->status()); + $this->assertEquals('{"key1":"value1","key2":"value2","utf8_emoji":"😀"}', $engine->response()->getBody()); + } + + public function testJsonThrowOnErrorByDefault() + { + $engine = new Engine(); + $this->expectException(JsonException::class); + $this->expectExceptionMessage('Malformed UTF-8 characters, possibly incorrectly encoded'); + $engine->json(['key1' => 'value1', 'key2' => 'value2', 'utf8_emoji' => "\xB1\x31"]); + } + public function testJsonV2OutputBuffering() { $engine = new Engine(); $engine->response()->v2_output_buffering = true; $engine->json(['key1' => 'value1', 'key2' => 'value2']); $this->expectOutputString('{"key1":"value1","key2":"value2"}'); - $this->assertEquals('application/json; charset=utf-8', $engine->response()->headers()['Content-Type']); + $this->assertEquals('application/json', $engine->response()->headers()['Content-Type']); $this->assertEquals(200, $engine->response()->status()); } @@ -375,7 +394,7 @@ class EngineTest extends TestCase $engine = new Engine(); $this->expectOutputString('{"key1":"value1","key2":"value2"}'); $engine->jsonHalt(['key1' => 'value1', 'key2' => 'value2']); - $this->assertEquals('application/json; charset=utf-8', $engine->response()->headers()['Content-Type']); + $this->assertEquals('application/json', $engine->response()->headers()['Content-Type']); $this->assertEquals(200, $engine->response()->status()); $this->assertEquals('{"key1":"value1","key2":"value2"}', $engine->response()->getBody()); } diff --git a/tests/commands/RouteCommandTest.php b/tests/commands/RouteCommandTest.php index fd4cc2b..aa3fff3 100644 --- a/tests/commands/RouteCommandTest.php +++ b/tests/commands/RouteCommandTest.php @@ -81,6 +81,10 @@ PHP; protected function removeColors(string $str): string { + // replace \n with \r\n if windows + if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { + $str = str_replace("\r\n", "\n", $str); + } return preg_replace('/\e\[[\d;]*m/', '', $str); } From 2c9b02ec28db4cca70f571fa8014e8285199c91e Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Thu, 20 Feb 2025 19:12:25 -0700 Subject: [PATCH 32/46] removed cache breaking behavior --- flight/Engine.php | 4 ++-- flight/net/Request.php | 4 ++-- flight/net/Response.php | 5 ----- tests/RequestTest.php | 18 +++++++++--------- tests/ResponseTest.php | 3 --- tests/commands/RouteCommandTest.php | 8 ++++---- 6 files changed, 17 insertions(+), 25 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index aa4e202..ac4d1b4 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -842,8 +842,8 @@ class Engine ?string $charset = 'utf-8', int $option = 0 ): void { - // add some default flags - $option |= JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR; + // add some default flags + $option |= JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR; $json = $encode ? json_encode($data, $option) : $data; $this->response() diff --git a/flight/net/Request.php b/flight/net/Request.php index f88c770..5164b11 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -211,8 +211,8 @@ class Request $this->data->setData($data); } } - // Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data - } else if (in_array($this->method, [ 'PUT', 'DELETE', 'PATCH' ], true) === true) { + // 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 = []; diff --git a/flight/net/Response.php b/flight/net/Response.php index 264174e..9e4d58b 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -432,11 +432,6 @@ class Response } if ($this->headersSent() === false) { - // If you haven't set a Cache-Control header, we'll assume you don't want caching - if ($this->getHeader('Cache-Control') === null) { - $this->cache(false); - } - $this->sendHeaders(); } diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 03acd5a..4044d06 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -163,7 +163,7 @@ class RequestTest extends TestCase 'base' => '/vagrant/public', 'query' => new Collection(), 'type' => '', - 'method' => 'GET' + 'method' => 'GET' ]); $this->assertEquals('/flightphp', $request->url); } @@ -174,7 +174,7 @@ class RequestTest extends TestCase 'url' => '', 'base' => '/vagrant/public', 'type' => '', - 'method' => 'GET' + 'method' => 'GET' ]); $this->assertEquals('/', $request->url); } @@ -193,13 +193,13 @@ class RequestTest extends TestCase 'data' => new Collection(), 'query' => new Collection(), 'stream_path' => $stream_path, - 'method' => 'POST' + 'method' => 'POST' ]); $this->assertEquals([ 'foo' => 'bar' ], $request->data->getData()); $this->assertEquals('{"foo":"bar"}', $request->getBody()); } - public function testInitWithFormBody() + public function testInitWithFormBody() { // create dummy file to pull request body from $tmpfile = tmpfile(); @@ -213,12 +213,12 @@ class RequestTest extends TestCase 'data' => new Collection(), 'query' => new Collection(), 'stream_path' => $stream_path, - 'method' => 'PATCH' + 'method' => 'PATCH' ]); - $this->assertEquals([ - 'foo' => 'bar', - 'baz' => 'qux' - ], $request->data->getData()); + $this->assertEquals([ + 'foo' => 'bar', + 'baz' => 'qux' + ], $request->data->getData()); $this->assertEquals('foo=bar&baz=qux', $request->getBody()); } diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index fade322..3f21780 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -268,9 +268,6 @@ class ResponseTest extends TestCase 'HTTP/1.1 200 OK', 'Content-Type: text/html', 'X-Test: test', - 'Expires: Mon, 26 Jul 1997 05:00:00 GMT', - 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0', - 'Pragma: no-cache', 'Content-Length: 9' ], $sent_headers); } diff --git a/tests/commands/RouteCommandTest.php b/tests/commands/RouteCommandTest.php index aa3fff3..226d9a3 100644 --- a/tests/commands/RouteCommandTest.php +++ b/tests/commands/RouteCommandTest.php @@ -81,10 +81,10 @@ PHP; protected function removeColors(string $str): string { - // replace \n with \r\n if windows - if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { - $str = str_replace("\r\n", "\n", $str); - } + // replace \n with \r\n if windows + if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { + $str = str_replace("\r\n", "\n", $str); + } return preg_replace('/\e\[[\d;]*m/', '', $str); } From 7dd52c9ee6dcdba91766bfdfbece1bac3c545b1d Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Thu, 20 Feb 2025 19:31:36 -0700 Subject: [PATCH 33/46] Reset router to beginning --- flight/Engine.php | 2 +- tests/FlightAsyncTest.php | 83 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 tests/FlightAsyncTest.php diff --git a/flight/Engine.php b/flight/Engine.php index e2c1dc3..a26c1ff 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -490,6 +490,7 @@ class Engine $this->unregister('response'); $this->register('request', Request::class); $this->register('response', Response::class); + $this->router()->reset(); } $request = $this->request(); $response = $this->response(); @@ -513,7 +514,6 @@ class Engine // Route the request $failedMiddlewareCheck = false; - while ($route = $router->route($request)) { $params = array_values($route->params); diff --git a/tests/FlightAsyncTest.php b/tests/FlightAsyncTest.php new file mode 100644 index 0000000..1a53f44 --- /dev/null +++ b/tests/FlightAsyncTest.php @@ -0,0 +1,83 @@ +expectOutputString('hello world'); + Flight::start(); + } + + public function testMultipleRoutes() + { + Flight::route('GET /', function () { + echo 'hello world'; + }); + + Flight::route('GET /test', function () { + echo 'test'; + }); + + $this->expectOutputString('test'); + $_SERVER['REQUEST_URI'] = '/test'; + Flight::start(); + } + + public function testMultipleStartsSingleRoute() + { + Flight::route('GET /', function () { + echo 'hello world'; + }); + + $this->expectOutputString('hello worldhello world'); + Flight::start(); + Flight::start(); + } + + public function testMultipleStartsMultipleRoutes() + { + Flight::route('GET /', function () { + echo 'hello world'; + }); + + Flight::route('GET /test', function () { + echo 'test'; + }); + + $this->expectOutputString('testhello world'); + $_SERVER['REQUEST_URI'] = '/test'; + Flight::start(); + $_SERVER['REQUEST_URI'] = '/'; + Flight::start(); + } +} From 12843694d6a34c3121946f17425b0aba8ec8a04f Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Wed, 26 Feb 2025 10:13:16 -0700 Subject: [PATCH 34/46] Update README.md --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 025f2f4..f0d10ed 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,14 @@ quickly and easily build RESTful web applications. # Basic Usage +First install it with Composer + +``` +composer require flightphp/core +``` + +or you can download a zip of this repo. Then you would have a basic `index.php` file like the following: + ```php // if installed with composer require 'vendor/autoload.php'; From 3c3dcb8b83e4907089e5f52c65107c5c1b60f93c Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Wed, 26 Feb 2025 22:09:41 -0700 Subject: [PATCH 35/46] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f0d10ed..571349d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ # What is Flight? Flight is a fast, simple, extensible framework for PHP. Flight enables you to -quickly and easily build RESTful web applications. +quickly and easily build RESTful web applications. Flight also has zero dependencies. # Basic Usage From c1ffb8f71e5752eb0b425b68d4483cf321c30ddf Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Thu, 27 Feb 2025 17:53:04 -0700 Subject: [PATCH 36/46] Update README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 571349d..725921d 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![License](https://poser.pugx.org/flightphp/core/license)](https://packagist.org/packages/flightphp/core) [![PHP Version Require](https://poser.pugx.org/flightphp/core/require/php)](https://packagist.org/packages/flightphp/core) ![Matrix](https://img.shields.io/matrix/flight-php-framework%3Amatrix.org?server_fqdn=matrix.org&style=social&logo=matrix) +[![](https://dcbadge.limes.pink/api/server/https://discord.gg/Ysr4zqHfbX)](https://discord.gg/Ysr4zqHfbX) # What is Flight? @@ -45,6 +46,8 @@ We have our own documentation website that is built with Flight (naturally). Lea Chat with us on Matrix IRC [#flight-php-framework:matrix.org](https://matrix.to/#/#flight-php-framework:matrix.org) +or on Discord [Invite](https://discord.gg/Ysr4zqHfbX) + # Upgrading From v2 If you have a current project on v2, you should be able to upgrade to v3 with no issues depending on how your project was built. If there are any issues with upgrade, they are documented in the [migrating to v3](https://docs.flightphp.com/learn/migrating-to-v3) documentation page. It is the intention of Flight to maintain longterm stability of the project and to not add rewrites with major version changes. From fe3e78a7d04b4975b01879c44da4aba89118f6ec Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Mon, 3 Mar 2025 22:57:01 -0700 Subject: [PATCH 37/46] added benchmarks to readme --- README.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 725921d..79d4054 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,24 @@ Flight::route('/', function () { Flight::start(); ``` +## Is it fast? + +Yes! Flight is fast. It is one of the fastest PHP frameworks available. You can see all the benchmarks at [TechEmpower](https://www.techempower.com/benchmarks/#section=data-r18&hw=ph&test=frameworks) + +See the benchmark below with some other popular PHP frameworks. + +| Framework | Plaintext Reqs/sec | JSON Reqs/sec | +| --------- | ------------ | ------------ | +| Flight | 190,421 | 182,491 | +| Yii | 145,749 | 131,434 | +| Fat-Free | 139,238 | 133,952 | +| Slim | 89,588 | 87,348 | +| Phalcon | 95,911 | 87,675 | +| Symfony | 65,053 | 63,237 | +| Lumen | 40,572 | 39,700 | +| Laravel | 26,657 | 26,901 | +| CodeIgniter | 20,628 | 19,901 | + ## Skeleton App You can also install a skeleton app. Go to [flightphp/skeleton](https://github.com/flightphp/skeleton) for instructions on how to get started! @@ -46,7 +64,7 @@ We have our own documentation website that is built with Flight (naturally). Lea Chat with us on Matrix IRC [#flight-php-framework:matrix.org](https://matrix.to/#/#flight-php-framework:matrix.org) -or on Discord [Invite](https://discord.gg/Ysr4zqHfbX) +[![](https://dcbadge.limes.pink/api/server/https://discord.gg/Ysr4zqHfbX)](https://discord.gg/Ysr4zqHfbX) # Upgrading From v2 From 4b7cd68e240c0caf5a46408d22dbae6eaad28a3a Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Mon, 3 Mar 2025 23:48:22 -0700 Subject: [PATCH 38/46] added an event system --- flight/Engine.php | 44 ++++++- flight/Flight.php | 7 +- flight/core/EventDispatcher.php | 40 +++++++ tests/EventSystemTest.php | 197 ++++++++++++++++++++++++++++++++ 4 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 flight/core/EventDispatcher.php create mode 100644 tests/EventSystemTest.php diff --git a/flight/Engine.php b/flight/Engine.php index b2900c4..f75f42a 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -8,6 +8,7 @@ use Closure; use ErrorException; use Exception; use flight\core\Dispatcher; +use flight\core\EventDispatcher; use flight\core\Loader; use flight\net\Request; use flight\net\Response; @@ -51,6 +52,10 @@ use flight\net\Route; * @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 @@ -79,7 +84,8 @@ class Engine private const MAPPABLE_METHODS = [ 'start', 'stop', 'route', 'halt', 'error', 'notFound', 'render', 'redirect', 'etag', 'lastModified', 'json', 'jsonHalt', 'jsonp', - 'post', 'put', 'patch', 'delete', 'group', 'getUrl', 'download', 'resource' + 'post', 'put', 'patch', 'delete', 'group', 'getUrl', 'download', 'resource', + 'onEvent', 'triggerEvent' ]; /** @var array Stored variables. */ @@ -88,9 +94,12 @@ class Engine /** Class loader. */ protected Loader $loader; - /** Event dispatcher. */ + /** Method and class dispatcher. */ protected Dispatcher $dispatcher; + /** Event dispatcher. */ + protected EventDispatcher $eventDispatcher; + /** If the framework has been initialized or not. */ protected bool $initialized = false; @@ -101,6 +110,7 @@ class Engine { $this->loader = new Loader(); $this->dispatcher = new Dispatcher(); + $this->eventDispatcher = new EventDispatcher(); $this->init(); } @@ -493,6 +503,8 @@ class Engine $this->router()->reset(); } $request = $this->request(); + $this->triggerEvent('flight.request.received', $request); + $response = $this->response(); $router = $this->router(); @@ -515,6 +527,7 @@ class Engine // Route the request $failedMiddlewareCheck = false; while ($route = $router->route($request)) { + $this->triggerEvent('flight.route.matched', $route); $params = array_values($route->params); // Add route info to the parameter list @@ -548,6 +561,7 @@ class Engine $failedMiddlewareCheck = true; break; } + $this->triggerEvent('flight.route.middleware.before', $route); } $useV3OutputBuffering = @@ -563,6 +577,7 @@ class Engine $route->callback, $params ); + $this->triggerEvent('flight.route.executed', $route); if ($useV3OutputBuffering === true) { $response->write(ob_get_clean()); @@ -577,6 +592,7 @@ class Engine $failedMiddlewareCheck = true; break; } + $this->triggerEvent('flight.route.middleware.after', $route); } $dispatched = true; @@ -662,6 +678,8 @@ class Engine } $response->send(); + + $this->triggerEvent('flight.response.sent', $response); } } @@ -992,4 +1010,26 @@ class Engine { return $this->router()->getUrlByAlias($alias, $params); } + + /** + * Adds an event listener. + * + * @param string $eventName The name of the event to listen to + * @param callable $callback The callback to execute when the event is triggered + */ + public function _onEvent(string $eventName, callable $callback): void + { + $this->eventDispatcher->on($eventName, $callback); + } + + /** + * Triggers an event. + * + * @param string $eventName The name of the event to trigger + * @param mixed ...$args The arguments to pass to the event listeners + */ + public function _triggerEvent(string $eventName, ...$args): void + { + $this->eventDispatcher->trigger($eventName, ...$args); + } } diff --git a/flight/Flight.php b/flight/Flight.php index 59a9945..fb35427 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -46,14 +46,15 @@ require_once __DIR__ . '/autoload.php'; * Adds standardized RESTful routes for a controller. * @method static Router router() Returns Router instance. * @method static string getUrl(string $alias, array $params = []) Gets a url from an alias - * * @method static void map(string $name, callable $callback) Creates a custom framework method. * + * # Filters * @method static void before(string $name, Closure(array &$params, string &$output): (void|false) $callback) * Adds a filter before a framework method. * @method static void after(string $name, Closure(array &$params, string &$output): (void|false) $callback) * Adds a filter after a framework method. * + * # Variables * @method static void set(string|iterable $key, mixed $value) Sets a variable. * @method static mixed get(?string $key) Gets a variable. * @method static bool has(string $key) Checks if a variable is set. @@ -64,6 +65,10 @@ require_once __DIR__ . '/autoload.php'; * Renders a template file. * @method static View view() Returns View instance. * + * # Events + * @method void onEvent(string $event, callable $callback) Registers a callback for an event. + * @method void triggerEvent(string $event, ...$args) Triggers an event. + * * # Request-Response * @method static Request request() Returns Request instance. * @method static Response response() Returns Response instance. diff --git a/flight/core/EventDispatcher.php b/flight/core/EventDispatcher.php new file mode 100644 index 0000000..0553dc7 --- /dev/null +++ b/flight/core/EventDispatcher.php @@ -0,0 +1,40 @@ +> */ + protected array $listeners = []; + + /** + * Register a callback for an event. + * + * @param string $event Event name + * @param callable $callback Callback function + */ + public function on(string $event, callable $callback): void + { + if (isset($this->listeners[$event]) === false) { + $this->listeners[$event] = []; + } + $this->listeners[$event][] = $callback; + } + + /** + * Trigger an event with optional arguments. + * + * @param string $event Event name + * @param mixed ...$args Arguments to pass to the callbacks + */ + public function trigger(string $event, ...$args): void + { + if (isset($this->listeners[$event]) === true) { + foreach ($this->listeners[$event] as $callback) { + call_user_func_array($callback, $args); + } + } + } +} diff --git a/tests/EventSystemTest.php b/tests/EventSystemTest.php new file mode 100644 index 0000000..74ce27a --- /dev/null +++ b/tests/EventSystemTest.php @@ -0,0 +1,197 @@ +init(); + } + + /** + * Test registering and triggering a single listener. + */ + public function testRegisterAndTriggerSingleListener() + { + $called = false; + Flight::onEvent('test.event', function () use (&$called) { + $called = true; + }); + Flight::triggerEvent('test.event'); + $this->assertTrue($called, 'Single listener should be called when event is triggered.'); + } + + /** + * Test registering multiple listeners for the same event. + */ + public function testRegisterMultipleListeners() + { + $counter = 0; + Flight::onEvent('test.event', function () use (&$counter) { + $counter++; + }); + Flight::onEvent('test.event', function () use (&$counter) { + $counter++; + }); + Flight::triggerEvent('test.event'); + $this->assertEquals(2, $counter, 'All registered listeners should be called.'); + } + + /** + * Test triggering an event with no listeners registered. + */ + public function testTriggerWithNoListeners() + { + // Should not throw any errors + Flight::triggerEvent('non.existent.event'); + $this->assertTrue(true, 'Triggering an event with no listeners should not throw an error.'); + } + + /** + * Test that a listener receives a single argument correctly. + */ + public function testListenerReceivesSingleArgument() + { + $received = null; + Flight::onEvent('test.event', function ($arg) use (&$received) { + $received = $arg; + }); + Flight::triggerEvent('test.event', 'hello'); + $this->assertEquals('hello', $received, 'Listener should receive the passed argument.'); + } + + /** + * Test that a listener receives multiple arguments correctly. + */ + public function testListenerReceivesMultipleArguments() + { + $received = []; + Flight::onEvent('test.event', function ($arg1, $arg2) use (&$received) { + $received = [$arg1, $arg2]; + }); + Flight::triggerEvent('test.event', 'first', 'second'); + $this->assertEquals(['first', 'second'], $received, 'Listener should receive all passed arguments.'); + } + + /** + * Test that listeners are called in the order they were registered. + */ + public function testListenersCalledInOrder() + { + $order = []; + Flight::onEvent('test.event', function () use (&$order) { + $order[] = 1; + }); + Flight::onEvent('test.event', function () use (&$order) { + $order[] = 2; + }); + Flight::triggerEvent('test.event'); + $this->assertEquals([1, 2], $order, 'Listeners should be called in registration order.'); + } + + /** + * Test that listeners are not called for unrelated events. + */ + public function testListenerNotCalledForOtherEvents() + { + $called = false; + Flight::onEvent('test.event1', function () use (&$called) { + $called = true; + }); + Flight::triggerEvent('test.event2'); + $this->assertFalse($called, 'Listeners should not be called for different events.'); + } + + /** + * Test overriding the onEvent method. + */ + public function testOverrideOnEvent() + { + $called = false; + Flight::map('onEvent', function ($event, $callback) use (&$called) { + $called = true; + }); + Flight::onEvent('test.event', function () { + }); + $this->assertTrue($called, 'Overridden onEvent method should be called.'); + } + + /** + * Test overriding the triggerEvent method. + */ + public function testOverrideTriggerEvent() + { + $called = false; + Flight::map('triggerEvent', function ($event, ...$args) use (&$called) { + $called = true; + }); + Flight::triggerEvent('test.event'); + $this->assertTrue($called, 'Overridden triggerEvent method should be called.'); + } + + /** + * Test that an overridden onEvent can still register listeners by calling the original method. + */ + public function testOverrideOnEventStillRegistersListener() + { + $overrideCalled = false; + Flight::map('onEvent', function ($event, $callback) use (&$overrideCalled) { + $overrideCalled = true; + // Call the original method + Flight::app()->_onEvent($event, $callback); + }); + + $listenerCalled = false; + Flight::onEvent('test.event', function () use (&$listenerCalled) { + $listenerCalled = true; + }); + + Flight::triggerEvent('test.event'); + + $this->assertTrue($overrideCalled, 'Overridden onEvent should be called.'); + $this->assertTrue($listenerCalled, 'Listener should still be triggered after override.'); + } + + /** + * Test that an overridden triggerEvent can still trigger listeners by calling the original method. + */ + public function testOverrideTriggerEventStillTriggersListeners() + { + $overrideCalled = false; + Flight::map('triggerEvent', function ($event, ...$args) use (&$overrideCalled) { + $overrideCalled = true; + // Call the original method + Flight::app()->_triggerEvent($event, ...$args); + }); + + $listenerCalled = false; + Flight::onEvent('test.event', function () use (&$listenerCalled) { + $listenerCalled = true; + }); + + Flight::triggerEvent('test.event'); + + $this->assertTrue($overrideCalled, 'Overridden triggerEvent should be called.'); + $this->assertTrue($listenerCalled, 'Listeners should still be triggered after override.'); + } + + /** + * Test that an invalid callable throws an exception (if applicable). + */ + public function testInvalidCallableThrowsException() + { + $this->expectException(TypeError::class); + // Assuming the event system validates callables + Flight::onEvent('test.event', 'not_a_callable'); + } +} From 79efffc33342856f6d0475c47ad5fdd904a9c76a Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Tue, 4 Mar 2025 07:14:22 -0700 Subject: [PATCH 39/46] added stopPropagation logic --- flight/core/EventDispatcher.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/flight/core/EventDispatcher.php b/flight/core/EventDispatcher.php index 0553dc7..828388c 100644 --- a/flight/core/EventDispatcher.php +++ b/flight/core/EventDispatcher.php @@ -33,7 +33,12 @@ class EventDispatcher { if (isset($this->listeners[$event]) === true) { foreach ($this->listeners[$event] as $callback) { - call_user_func_array($callback, $args); + $result = call_user_func_array($callback, $args); + + // If you return false, it will break the loop and stop the other event listeners. + if ($result === false) { + break; // Stop executing further listeners + } } } } From 2e68ccb0d0a71745d9340d6fa57b57b730cb8db1 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Tue, 4 Mar 2025 07:19:13 -0700 Subject: [PATCH 40/46] and added new unit tests --- tests/EventSystemTest.php | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/EventSystemTest.php b/tests/EventSystemTest.php index 74ce27a..26d51e6 100644 --- a/tests/EventSystemTest.php +++ b/tests/EventSystemTest.php @@ -194,4 +194,34 @@ class EventSystemTest extends TestCase // Assuming the event system validates callables Flight::onEvent('test.event', 'not_a_callable'); } + + /** + * Test that event propagation stops if a listener returns false. + */ + public function testStopPropagation() + { + $firstCalled = false; + $secondCalled = false; + $thirdCalled = false; + + Flight::onEvent('test.event', function () use (&$firstCalled) { + $firstCalled = true; + return true; // Continue propagation + }); + + Flight::onEvent('test.event', function () use (&$secondCalled) { + $secondCalled = true; + return false; // Stop propagation + }); + + Flight::onEvent('test.event', function () use (&$thirdCalled) { + $thirdCalled = true; + }); + + Flight::triggerEvent('test.event'); + + $this->assertTrue($firstCalled, 'First listener should be called'); + $this->assertTrue($secondCalled, 'Second listener should be called'); + $this->assertFalse($thirdCalled, 'Third listener should not be called after propagation stopped'); + } } From 046a034455a7c8e761ba15995494e3d564175b9c Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Tue, 4 Mar 2025 07:20:07 -0700 Subject: [PATCH 41/46] prettified --- flight/core/EventDispatcher.php | 2 +- tests/EventSystemTest.php | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/flight/core/EventDispatcher.php b/flight/core/EventDispatcher.php index 828388c..b80eb42 100644 --- a/flight/core/EventDispatcher.php +++ b/flight/core/EventDispatcher.php @@ -35,7 +35,7 @@ class EventDispatcher foreach ($this->listeners[$event] as $callback) { $result = call_user_func_array($callback, $args); - // If you return false, it will break the loop and stop the other event listeners. + // If you return false, it will break the loop and stop the other event listeners. if ($result === false) { break; // Stop executing further listeners } diff --git a/tests/EventSystemTest.php b/tests/EventSystemTest.php index 26d51e6..6dba2c7 100644 --- a/tests/EventSystemTest.php +++ b/tests/EventSystemTest.php @@ -195,7 +195,7 @@ class EventSystemTest extends TestCase Flight::onEvent('test.event', 'not_a_callable'); } - /** + /** * Test that event propagation stops if a listener returns false. */ public function testStopPropagation() @@ -203,23 +203,23 @@ class EventSystemTest extends TestCase $firstCalled = false; $secondCalled = false; $thirdCalled = false; - + Flight::onEvent('test.event', function () use (&$firstCalled) { $firstCalled = true; return true; // Continue propagation }); - + Flight::onEvent('test.event', function () use (&$secondCalled) { $secondCalled = true; return false; // Stop propagation }); - + Flight::onEvent('test.event', function () use (&$thirdCalled) { $thirdCalled = true; }); - + Flight::triggerEvent('test.event'); - + $this->assertTrue($firstCalled, 'First listener should be called'); $this->assertTrue($secondCalled, 'Second listener should be called'); $this->assertFalse($thirdCalled, 'Third listener should not be called after propagation stopped'); From cece196421e9c36a22d1b552393e241c3b3af25d Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Wed, 5 Mar 2025 07:01:11 -0700 Subject: [PATCH 42/46] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 79d4054..9a043f7 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ Flight::start(); Yes! Flight is fast. It is one of the fastest PHP frameworks available. You can see all the benchmarks at [TechEmpower](https://www.techempower.com/benchmarks/#section=data-r18&hw=ph&test=frameworks) -See the benchmark below with some other popular PHP frameworks. +See the benchmark below with some other popular PHP frameworks. This is measured in requests processed within the same timeframe. -| Framework | Plaintext Reqs/sec | JSON Reqs/sec | +| Framework | Plaintext Requests| JSON Requests| | --------- | ------------ | ------------ | | Flight | 190,421 | 182,491 | | Yii | 145,749 | 131,434 | From dd2d23ec99fc9c478436cedd3c242cb9774095d3 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sat, 8 Mar 2025 22:44:51 -0700 Subject: [PATCH 43/46] added more events, added apm changes --- flight/Engine.php | 24 +++- flight/Flight.php | 4 + flight/core/EventDispatcher.php | 92 +++++++++++++ flight/database/PdoWrapper.php | 100 +++++++++++++++ flight/net/Response.php | 5 +- tests/DispatcherTest.php | 14 ++ tests/EngineTest.php | 25 ---- tests/EventSystemTest.php | 121 ++++++++++++++++++ tests/PdoWrapperTest.php | 88 +++++++++++++ .../classes/ClassWithExceptionInConstruct.php | 13 ++ 10 files changed, 453 insertions(+), 33 deletions(-) create mode 100644 tests/classes/ClassWithExceptionInConstruct.php diff --git a/flight/Engine.php b/flight/Engine.php index f75f42a..c386424 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -30,6 +30,9 @@ use flight\net\Route; * @method void stop() Stops framework and outputs current response * @method void halt(int $code = 200, string $message = '', bool $actuallyExit = true) Stops processing and returns a given response. * + * # Class registration + * @method EventDispatcher eventDispatcher() Gets event dispatcher + * * # Routing * @method Route route(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Routes a URL to a callback function with all applicable methods @@ -110,7 +113,6 @@ class Engine { $this->loader = new Loader(); $this->dispatcher = new Dispatcher(); - $this->eventDispatcher = new EventDispatcher(); $this->init(); } @@ -160,6 +162,9 @@ class Engine $this->dispatcher->setEngine($this); // Register default components + $this->map('eventDispatcher', function () { + return EventDispatcher::getInstance(); + }); $this->loader->register('request', Request::class); $this->loader->register('response', Response::class); $this->loader->register('router', Router::class); @@ -460,7 +465,9 @@ class Engine // Here is the array callable $middlewareObject that we created earlier. // It looks bizarre but it's really calling [ $class, $method ]($params) // Which loosely translates to $class->$method($params) + $start = microtime(true); $middlewareResult = $middlewareObject($params); + $this->triggerEvent('flight.middleware.executed', $route, $middleware, microtime(true) - $start); if ($useV3OutputBuffering === true) { $this->response()->write(ob_get_clean()); @@ -573,12 +580,12 @@ class Engine } // Call route handler + $routeStart = microtime(true); $continue = $this->dispatcher->execute( $route->callback, $params ); - $this->triggerEvent('flight.route.executed', $route); - + $this->triggerEvent('flight.route.executed', $route, microtime(true) - $routeStart); if ($useV3OutputBuffering === true) { $response->write(ob_get_clean()); } @@ -631,6 +638,7 @@ class Engine */ public function _error(Throwable $e): void { + $this->triggerEvent('flight.error', $e); $msg = sprintf( <<<'HTML'

    500 Internal Server Error

    @@ -678,8 +686,6 @@ class Engine } $response->send(); - - $this->triggerEvent('flight.response.sent', $response); } } @@ -831,6 +837,8 @@ class Engine $url = $base . preg_replace('#/+#', '/', '/' . $url); } + $this->triggerEvent('flight.redirect', $url, $code); + $this->response() ->clearBody() ->status($code) @@ -854,7 +862,9 @@ class Engine return; } + $start = microtime(true); $this->view()->render($file, $data); + $this->triggerEvent('flight.view.rendered', $file, microtime(true) - $start); } /** @@ -1019,7 +1029,7 @@ class Engine */ public function _onEvent(string $eventName, callable $callback): void { - $this->eventDispatcher->on($eventName, $callback); + $this->eventDispatcher()->on($eventName, $callback); } /** @@ -1030,6 +1040,6 @@ class Engine */ public function _triggerEvent(string $eventName, ...$args): void { - $this->eventDispatcher->trigger($eventName, ...$args); + $this->eventDispatcher()->trigger($eventName, ...$args); } } diff --git a/flight/Flight.php b/flight/Flight.php index fb35427..7e43877 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -8,6 +8,7 @@ use flight\net\Response; use flight\net\Router; use flight\template\View; use flight\net\Route; +use flight\core\EventDispatcher; require_once __DIR__ . '/autoload.php'; @@ -29,6 +30,9 @@ require_once __DIR__ . '/autoload.php'; * Unregisters a class to a framework method. * @method static void registerContainerHandler(callable|object $containerHandler) Registers a container handler. * + * # Class registration + * @method EventDispatcher eventDispatcher() Gets event dispatcher + * * # Routing * @method static Route route(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') * Maps a URL pattern to a callback with all applicable methods. diff --git a/flight/core/EventDispatcher.php b/flight/core/EventDispatcher.php index b80eb42..ea809b2 100644 --- a/flight/core/EventDispatcher.php +++ b/flight/core/EventDispatcher.php @@ -6,9 +6,25 @@ namespace flight\core; class EventDispatcher { + /** @var self|null Singleton instance of the EventDispatcher */ + private static ?self $instance = null; + /** @var array> */ protected array $listeners = []; + /** + * Singleton instance of the EventDispatcher. + * + * @return self + */ + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + /** * Register a callback for an event. * @@ -42,4 +58,80 @@ class EventDispatcher } } } + + /** + * Check if an event has any registered listeners. + * + * @param string $event Event name + * + * @return bool True if the event has listeners, false otherwise + */ + public function hasListeners(string $event): bool + { + return isset($this->listeners[$event]) === true && count($this->listeners[$event]) > 0; + } + + /** + * Get all listeners registered for a specific event. + * + * @param string $event Event name + * + * @return array Array of callbacks registered for the event + */ + public function getListeners(string $event): array + { + return $this->listeners[$event] ?? []; + } + + /** + * Get a list of all events that have registered listeners. + * + * @return array Array of event names + */ + public function getAllRegisteredEvents(): array + { + return array_keys($this->listeners); + } + + /** + * Remove a specific listener for an event. + * + * @param string $event the event name + * @param callable $callback the exact callback to remove + * + * @return void + */ + public function removeListener(string $event, callable $callback): void + { + if (isset($this->listeners[$event]) === true && count($this->listeners[$event]) > 0) { + $this->listeners[$event] = array_filter($this->listeners[$event], function ($listener) use ($callback) { + return $listener !== $callback; + }); + $this->listeners[$event] = array_values($this->listeners[$event]); // Re-index the array + } + } + + /** + * Remove all listeners for a specific event. + * + * @param string $event the event name + * + * @return void + */ + public function removeAllListeners(string $event): void + { + if (isset($this->listeners[$event]) === true) { + unset($this->listeners[$event]); + } + } + + /** + * Remove the current singleton instance of the EventDispatcher. + * + * @return void + */ + public static function resetInstance(): void + { + self::$instance = null; + } } diff --git a/flight/database/PdoWrapper.php b/flight/database/PdoWrapper.php index 297121a..7cf74cf 100644 --- a/flight/database/PdoWrapper.php +++ b/flight/database/PdoWrapper.php @@ -4,12 +4,40 @@ declare(strict_types=1); namespace flight\database; +use flight\core\EventDispatcher; use flight\util\Collection; use PDO; use PDOStatement; class PdoWrapper extends PDO { + /** @var bool $trackApmQueries Whether to track application performance metrics (APM) for queries. */ + protected bool $trackApmQueries = false; + + /** @var array> $queryMetrics Metrics related to the database connection. */ + protected array $queryMetrics = []; + + /** @var array $connectionMetrics Metrics related to the database connection. */ + protected array $connectionMetrics = []; + + /** + * Constructor for the PdoWrapper class. + * + * @param string $dsn The Data Source Name (DSN) for the database connection. + * @param string|null $username The username for the database connection. + * @param string|null $password The password for the database connection. + * @param array|null $options An array of options for the PDO connection. + * @param bool $trackApmQueries Whether to track application performance metrics (APM) for queries. + */ + public function __construct(?string $dsn = null, ?string $username = '', ?string $password = '', ?array $options = null, bool $trackApmQueries = false) + { + parent::__construct($dsn, $username, $password, $options); + $this->trackApmQueries = $trackApmQueries; + if ($this->trackApmQueries === true) { + $this->connectionMetrics = $this->pullDataFromDsn($dsn); + } + } + /** * Use this for INSERTS, UPDATES, or if you plan on using a SELECT in a while loop * @@ -31,8 +59,19 @@ class PdoWrapper extends PDO $processed_sql_data = $this->processInStatementSql($sql, $params); $sql = $processed_sql_data['sql']; $params = $processed_sql_data['params']; + $start = $this->trackApmQueries === true ? microtime(true) : 0; + $memory_start = $this->trackApmQueries === true ? memory_get_usage() : 0; $statement = $this->prepare($sql); $statement->execute($params); + if ($this->trackApmQueries === true) { + $this->queryMetrics[] = [ + 'sql' => $sql, + 'params' => $params, + 'execution_time' => microtime(true) - $start, + 'row_count' => $statement->rowCount(), + 'memory_usage' => memory_get_usage() - $memory_start + ]; + } return $statement; } @@ -88,9 +127,20 @@ class PdoWrapper extends PDO $processed_sql_data = $this->processInStatementSql($sql, $params); $sql = $processed_sql_data['sql']; $params = $processed_sql_data['params']; + $start = $this->trackApmQueries === true ? microtime(true) : 0; + $memory_start = $this->trackApmQueries === true ? memory_get_usage() : 0; $statement = $this->prepare($sql); $statement->execute($params); $results = $statement->fetchAll(); + if ($this->trackApmQueries === true) { + $this->queryMetrics[] = [ + 'sql' => $sql, + 'params' => $params, + 'execution_time' => microtime(true) - $start, + 'row_count' => $statement->rowCount(), + 'memory_usage' => memory_get_usage() - $memory_start + ]; + } if (is_array($results) === true && count($results) > 0) { foreach ($results as &$result) { $result = new Collection($result); @@ -101,6 +151,56 @@ class PdoWrapper extends PDO return $results; } + /** + * Pulls the engine, database, and host from the DSN string. + * + * @param string $dsn The Data Source Name (DSN) string. + * + * @return array An associative array containing the engine, database, and host. + */ + protected function pullDataFromDsn(string $dsn): array + { + // pull the engine from the dsn (sqlite, mysql, pgsql, etc) + preg_match('/^([a-zA-Z]+):/', $dsn, $matches); + $engine = $matches[1] ?? 'unknown'; + + if ($engine === 'sqlite') { + // pull the path from the dsn + preg_match('/sqlite:(.*)/', $dsn, $matches); + $dbname = basename($matches[1] ?? 'unknown'); + $host = 'localhost'; + } else { + // pull the database from the dsn + preg_match('/dbname=([^;]+)/', $dsn, $matches); + $dbname = $matches[1] ?? 'unknown'; + // pull the host from the dsn + preg_match('/host=([^;]+)/', $dsn, $matches); + $host = $matches[1] ?? 'unknown'; + } + + return [ + 'engine' => $engine, + 'database' => $dbname, + 'host' => $host + ]; + } + + /** + * Logs the executed queries through the event dispatcher. + * + * This method enables logging of all the queries executed by the PDO wrapper. + * It can be useful for debugging and monitoring purposes. + * + * @return void + */ + public function logQueries(): void + { + if ($this->trackApmQueries === true && $this->connectionMetrics !== [] && $this->queryMetrics !== []) { + EventDispatcher::getInstance()->trigger('flight.db.queries', $this->connectionMetrics, $this->queryMetrics); + $this->queryMetrics = []; // Reset after logging + } + } + /** * Don't worry about this guy. Converts stuff for IN statements * diff --git a/flight/net/Response.php b/flight/net/Response.php index 9e4d58b..f9ee9a2 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace flight\net; use Exception; +use flight\core\EventDispatcher; /** * The Response class represents an HTTP response. The object @@ -426,6 +427,7 @@ class Response } } + $start = microtime(true); // Only for the v3 output buffering. if ($this->v2_output_buffering === false) { $this->processResponseCallbacks(); @@ -436,8 +438,9 @@ class Response } echo $this->body; - $this->sent = true; + + EventDispatcher::getInstance()->trigger('flight.response.sent', $this, microtime(true) - $start); } /** diff --git a/tests/DispatcherTest.php b/tests/DispatcherTest.php index 418897d..6873aa6 100644 --- a/tests/DispatcherTest.php +++ b/tests/DispatcherTest.php @@ -13,6 +13,7 @@ use InvalidArgumentException; use PharIo\Manifest\InvalidEmailException; use tests\classes\Hello; use PHPUnit\Framework\TestCase; +use tests\classes\ClassWithExceptionInConstruct; use tests\classes\ContainerDefault; use tests\classes\TesterClass; use TypeError; @@ -329,4 +330,17 @@ class DispatcherTest extends TestCase $this->expectExceptionMessageMatches('#tests\\\\classes\\\\ContainerDefault::__construct\(\).+flight\\\\Engine, null given#'); $result = $this->dispatcher->execute([ContainerDefault::class, 'testTheContainer']); } + + public function testContainerDicePdoWrapperTestBadParams() + { + $dice = new \Dice\Dice(); + $this->dispatcher->setContainerHandler(function ($class, $params) use ($dice) { + return $dice->create($class, $params); + }); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('This is an exception in the constructor'); + + $this->dispatcher->invokeCallable([ ClassWithExceptionInConstruct::class, '__construct' ]); + } } diff --git a/tests/EngineTest.php b/tests/EngineTest.php index fc2e8ff..737f38d 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -845,31 +845,6 @@ class EngineTest extends TestCase $this->expectOutputString('You got it boss!'); } - public function testContainerDicePdoWrapperTestBadParams() { - $engine = new Engine(); - $dice = new \Dice\Dice(); - $engine->registerContainerHandler(function ($class, $params) use ($dice) { - return $dice->create($class, $params); - }); - - $engine->route('/container', Container::class.'->testThePdoWrapper'); - $engine->request()->url = '/container'; - - // php 7.4 will throw a PDO exception, but php 8 will throw an ErrorException - if(version_compare(PHP_VERSION, '8.1.0') >= 0) { - $this->expectException(ErrorException::class); - $this->expectExceptionMessageMatches("/Passing null to parameter/"); - } elseif(version_compare(PHP_VERSION, '8.0.0') >= 0) { - $this->expectException(PDOException::class); - $this->expectExceptionMessageMatches("/must be a valid data source name/"); - } else { - $this->expectException(PDOException::class); - $this->expectExceptionMessageMatches("/invalid data source name/"); - } - - $engine->start(); - } - public function testContainerDiceBadClass() { $engine = new Engine(); $dice = new \Dice\Dice(); diff --git a/tests/EventSystemTest.php b/tests/EventSystemTest.php index 6dba2c7..f95302a 100644 --- a/tests/EventSystemTest.php +++ b/tests/EventSystemTest.php @@ -16,6 +16,7 @@ class EventSystemTest extends TestCase // Reset the Flight engine before each test to ensure a clean state Flight::setEngine(new Engine()); Flight::app()->init(); + Flight::eventDispatcher()->resetInstance(); // Clear any existing listeners } /** @@ -224,4 +225,124 @@ class EventSystemTest extends TestCase $this->assertTrue($secondCalled, 'Second listener should be called'); $this->assertFalse($thirdCalled, 'Third listener should not be called after propagation stopped'); } + + /** + * Test that hasListeners() correctly identifies events with listeners. + */ + public function testHasListeners() + { + $this->assertFalse(Flight::eventDispatcher()->hasListeners('test.event'), 'Event should not have listeners before registration'); + + Flight::onEvent('test.event', function () { + }); + + $this->assertTrue(Flight::eventDispatcher()->hasListeners('test.event'), 'Event should have listeners after registration'); + } + + /** + * Test that getListeners() returns the correct listeners for an event. + */ + public function testGetListeners() + { + $callback1 = function () { + }; + $callback2 = function () { + }; + + $this->assertEmpty(Flight::eventDispatcher()->getListeners('test.event'), 'Event should have no listeners before registration'); + + Flight::onEvent('test.event', $callback1); + Flight::onEvent('test.event', $callback2); + + $listeners = Flight::eventDispatcher()->getListeners('test.event'); + $this->assertCount(2, $listeners, 'Event should have two registered listeners'); + $this->assertSame($callback1, $listeners[0], 'First listener should match the first callback'); + $this->assertSame($callback2, $listeners[1], 'Second listener should match the second callback'); + } + + /** + * Test that getListeners() returns an empty array for events with no listeners. + */ + public function testGetListenersForNonexistentEvent() + { + $listeners = Flight::eventDispatcher()->getListeners('nonexistent.event'); + $this->assertIsArray($listeners, 'Should return an array for nonexistent events'); + $this->assertEmpty($listeners, 'Should return an empty array for nonexistent events'); + } + + /** + * Test that getAllRegisteredEvents() returns all event names with registered listeners. + */ + public function testGetAllRegisteredEvents() + { + $this->assertEmpty(Flight::eventDispatcher()->getAllRegisteredEvents(), 'No events should be registered initially'); + + Flight::onEvent('test.event1', function () { + }); + Flight::onEvent('test.event2', function () { + }); + + $events = Flight::eventDispatcher()->getAllRegisteredEvents(); + $this->assertCount(2, $events, 'Should return all registered event names'); + $this->assertContains('test.event1', $events, 'Should contain the first event'); + $this->assertContains('test.event2', $events, 'Should contain the second event'); + } + + /** + * Test that removeListener() correctly removes a specific listener from an event. + */ + public function testRemoveListener() + { + $callback1 = function () { + return 'callback1'; + }; + $callback2 = function () { + return 'callback2'; + }; + + Flight::onEvent('test.event', $callback1); + Flight::onEvent('test.event', $callback2); + + $this->assertCount(2, Flight::eventDispatcher()->getListeners('test.event'), 'Event should have two listeners initially'); + + Flight::eventDispatcher()->removeListener('test.event', $callback1); + + $listeners = Flight::eventDispatcher()->getListeners('test.event'); + $this->assertCount(1, $listeners, 'Event should have one listener after removal'); + $this->assertSame($callback2, $listeners[0], 'Remaining listener should be the second callback'); + } + + /** + * Test that removeAllListeners() correctly removes all listeners for an event. + */ + public function testRemoveAllListeners() + { + Flight::onEvent('test.event', function () { + }); + Flight::onEvent('test.event', function () { + }); + Flight::onEvent('another.event', function () { + }); + + $this->assertTrue(Flight::eventDispatcher()->hasListeners('test.event'), 'Event should have listeners before removal'); + $this->assertTrue(Flight::eventDispatcher()->hasListeners('another.event'), 'Another event should have listeners'); + + Flight::eventDispatcher()->removeAllListeners('test.event'); + + $this->assertFalse(Flight::eventDispatcher()->hasListeners('test.event'), 'Event should have no listeners after removal'); + $this->assertTrue(Flight::eventDispatcher()->hasListeners('another.event'), 'Another event should still have listeners'); + } + + /** + * Test that trying to remove listeners for nonexistent events doesn't cause errors. + */ + public function testRemoveListenersForNonexistentEvent() + { + // Should not throw any errors + Flight::eventDispatcher()->removeListener('nonexistent.event', function () { + }); + Flight::eventDispatcher()->removeAllListeners('nonexistent.event'); + + $this->assertTrue(true, 'Removing listeners for nonexistent events should not throw errors'); + } } diff --git a/tests/PdoWrapperTest.php b/tests/PdoWrapperTest.php index 0f41a92..4e21a1b 100644 --- a/tests/PdoWrapperTest.php +++ b/tests/PdoWrapperTest.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace tests; use flight\database\PdoWrapper; +use flight\core\EventDispatcher; use PDOStatement; use PHPUnit\Framework\TestCase; +use ReflectionClass; class PdoWrapperTest extends TestCase { @@ -120,4 +122,90 @@ class PdoWrapperTest extends TestCase $rows = $this->pdo_wrapper->fetchAll('SELECT id FROM test WHERE id > ? AND name IN( ?) ', [ 0, 'one,two' ]); $this->assertEquals(2, count($rows)); } + + public function testPullDataFromDsn() + { + // Testing protected method using reflection + $reflection = new ReflectionClass($this->pdo_wrapper); + $method = $reflection->getMethod('pullDataFromDsn'); + $method->setAccessible(true); + + // Test SQLite DSN + $sqliteDsn = 'sqlite::memory:'; + $sqliteResult = $method->invoke($this->pdo_wrapper, $sqliteDsn); + $this->assertEquals([ + 'engine' => 'sqlite', + 'database' => ':memory:', + 'host' => 'localhost' + ], $sqliteResult); + + // Test MySQL DSN + $mysqlDsn = 'mysql:host=localhost;dbname=testdb;charset=utf8'; + $mysqlResult = $method->invoke($this->pdo_wrapper, $mysqlDsn); + $this->assertEquals([ + 'engine' => 'mysql', + 'database' => 'testdb', + 'host' => 'localhost' + ], $mysqlResult); + + // Test PostgreSQL DSN + $pgsqlDsn = 'pgsql:host=127.0.0.1;dbname=postgres'; + $pgsqlResult = $method->invoke($this->pdo_wrapper, $pgsqlDsn); + $this->assertEquals([ + 'engine' => 'pgsql', + 'database' => 'postgres', + 'host' => '127.0.0.1' + ], $pgsqlResult); + } + + public function testLogQueries() + { + // Create a new PdoWrapper with tracking enabled + $trackingPdo = new PdoWrapper('sqlite::memory:', null, null, null, true); + + // Create test table + $trackingPdo->exec('CREATE TABLE test_log (id INTEGER PRIMARY KEY, name TEXT)'); + + // Run some queries to populate metrics + $trackingPdo->runQuery('INSERT INTO test_log (name) VALUES (?)', ['test1']); + $trackingPdo->fetchAll('SELECT * FROM test_log'); + + // Setup event listener to capture triggered event + $eventTriggered = false; + $connectionData = null; + $queriesData = null; + + $dispatcher = EventDispatcher::getInstance(); + $dispatcher->on('flight.db.queries', function ($conn, $queries) use (&$eventTriggered, &$connectionData, &$queriesData) { + $eventTriggered = true; + $connectionData = $conn; + $queriesData = $queries; + }); + + // Call the logQueries method + $trackingPdo->logQueries(); + + // Assert that event was triggered + $this->assertTrue($eventTriggered); + $this->assertIsArray($connectionData); + $this->assertEquals('sqlite', $connectionData['engine']); + $this->assertIsArray($queriesData); + $this->assertCount(2, $queriesData); // Should have 2 queries (INSERT and SELECT) + + // Verify query metrics structure for the first query + $this->assertArrayHasKey('sql', $queriesData[0]); + $this->assertArrayHasKey('params', $queriesData[0]); + $this->assertArrayHasKey('execution_time', $queriesData[0]); + $this->assertArrayHasKey('row_count', $queriesData[0]); + $this->assertArrayHasKey('memory_usage', $queriesData[0]); + + // Clean up + $trackingPdo->exec('DROP TABLE test_log'); + + // Verify metrics are reset after logging + $reflection = new ReflectionClass($trackingPdo); + $property = $reflection->getProperty('queryMetrics'); + $property->setAccessible(true); + $this->assertCount(0, $property->getValue($trackingPdo)); + } } diff --git a/tests/classes/ClassWithExceptionInConstruct.php b/tests/classes/ClassWithExceptionInConstruct.php new file mode 100644 index 0000000..32456c2 --- /dev/null +++ b/tests/classes/ClassWithExceptionInConstruct.php @@ -0,0 +1,13 @@ + Date: Sat, 8 Mar 2025 23:11:57 -0700 Subject: [PATCH 44/46] corrected some event names --- flight/Engine.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index c386424..eca1d19 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -568,7 +568,7 @@ class Engine $failedMiddlewareCheck = true; break; } - $this->triggerEvent('flight.route.middleware.before', $route); + $this->triggerEvent('flight.middleware.before', $route); } $useV3OutputBuffering = @@ -599,7 +599,7 @@ class Engine $failedMiddlewareCheck = true; break; } - $this->triggerEvent('flight.route.middleware.after', $route); + $this->triggerEvent('flight.middleware.after', $route); } $dispatched = true; From e5ce02e5bba9644871ce3d32f153f4cff2f65216 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Sun, 9 Mar 2025 16:00:57 -0400 Subject: [PATCH 45/46] fix: corrected windows line endings in view tests --- composer.json | 10 +++++----- phpcs.xml.dist | 1 + tests/FlightTest.php | 4 ++-- tests/ViewTest.php | 14 ++++++++------ tests/commands/RouteCommandTest.php | 2 +- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/composer.json b/composer.json index 23b6b9e..898e74e 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ { "name": "Franyer Sánchez", "email": "franyeradriansanchez@gmail.com", - "homepage": "https://faslatam.000webhostapp.com", + "homepage": "https://faslatam.42web.io", "role": "Maintainer" }, { @@ -44,11 +44,11 @@ "flightphp/runway": "^0.2.3 || ^1.0", "league/container": "^4.2", "level-2/dice": "^4.0", - "phpstan/extension-installer": "^1.3", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.5", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^9.6", "rregeer/phpunit-coverage-check": "^0.3.1", - "squizlabs/php_codesniffer": "^3.8" + "squizlabs/php_codesniffer": "^3.11" }, "config": { "allow-plugins": { diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 79f3ff0..e320c09 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -31,6 +31,7 @@ + diff --git a/tests/FlightTest.php b/tests/FlightTest.php index fbc8eb6..7eb3927 100644 --- a/tests/FlightTest.php +++ b/tests/FlightTest.php @@ -209,7 +209,7 @@ class FlightTest extends TestCase Flight::route('/path1/@param:[a-zA-Z0-9]{2,3}', function () { echo 'I win'; }, false, 'path1'); - $url = Flight::getUrl('path1', [ 'param' => 123 ]); + $url = Flight::getUrl('path1', ['param' => 123]); $this->assertEquals('/path1/123', $url); } @@ -316,7 +316,7 @@ class FlightTest extends TestCase Flight::register('response', $mock_response_class_name); Flight::route('/stream', function () { echo 'stream'; - })->streamWithHeaders(['Content-Type' => 'text/plain', 'X-Test' => 'test', 'status' => 200 ]); + })->streamWithHeaders(['Content-Type' => 'text/plain', 'X-Test' => 'test', 'status' => 200]); Flight::request()->url = '/stream'; $this->expectOutputString('stream'); Flight::start(); diff --git a/tests/ViewTest.php b/tests/ViewTest.php index e6d9d02..b8611c5 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -220,20 +220,22 @@ class ViewTest extends TestCase public static function renderDataProvider(): array { $html1 = <<<'html' -
    Hi
    -
    +
    Hi
    +
    + + html; - html; $html2 = <<<'html' - + - + - html; + html; $html1 = str_replace("\n", PHP_EOL, $html1); $html2 = str_replace("\n", PHP_EOL, $html2); + return [ [ $html1, diff --git a/tests/commands/RouteCommandTest.php b/tests/commands/RouteCommandTest.php index 226d9a3..8a843ea 100644 --- a/tests/commands/RouteCommandTest.php +++ b/tests/commands/RouteCommandTest.php @@ -54,7 +54,7 @@ class RouteCommandTest extends TestCase 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)); } From e86bb59297db20b57b0f57e018cedbab0e2c81db Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Sun, 9 Mar 2025 16:18:28 -0400 Subject: [PATCH 46/46] docs: add array{0: class-string, 1: method} to support phpstan level 6 in any project that uses [MyController::class, 'myInstanceMethod'] (phpstan marks that as an invalid callable) --- composer.json | 2 +- flight/Engine.php | 53 +++++++++++++++++++++++++++++-------------- flight/Flight.php | 14 ++++++------ flight/net/Router.php | 34 ++++++++++++++++----------- 4 files changed, 65 insertions(+), 38 deletions(-) diff --git a/composer.json b/composer.json index 23b6b9e..a6a8d63 100644 --- a/composer.json +++ b/composer.json @@ -63,7 +63,7 @@ "test-server": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server/", "test-server-v2": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server-v2/", "test-coverage:win": "del clover.xml && phpunit --coverage-html=coverage --coverage-clover=clover.xml && coverage-check clover.xml 100", - "lint": "phpstan --no-progress -cphpstan.neon", + "lint": "phpstan --no-progress --memory-limit=256M -cphpstan.neon", "beautify": "phpcbf --standard=phpcs.xml", "phpcs": "phpcs --standard=phpcs.xml -n", "post-install-cmd": [ diff --git a/flight/Engine.php b/flight/Engine.php index eca1d19..63fd05d 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -34,19 +34,19 @@ use flight\net\Route; * @method EventDispatcher eventDispatcher() Gets event dispatcher * * # Routing - * @method Route route(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method Route route(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a URL to a callback function with all applicable methods - * @method void group(string $pattern, callable $callback, array $group_middlewares = []) + * @method void group(string $pattern, callable $callback, (class-string|callable|array{0: class-string, 1: string})[] $group_middlewares = []) * Groups a set of routes together under a common prefix. - * @method Route post(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method Route post(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a POST URL to a callback function. - * @method Route put(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method Route put(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a PUT URL to a callback function. - * @method Route patch(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method Route patch(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a PATCH URL to a callback function. - * @method Route delete(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method Route delete(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a DELETE URL to a callback function. - * @method void resource(string $pattern, string $controllerClass, array> $methods = []) + * @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 @@ -85,10 +85,29 @@ class Engine * @var array List of methods that can be extended in the Engine class. */ private const MAPPABLE_METHODS = [ - 'start', 'stop', 'route', 'halt', 'error', 'notFound', - 'render', 'redirect', 'etag', 'lastModified', 'json', 'jsonHalt', 'jsonp', - 'post', 'put', 'patch', 'delete', 'group', 'getUrl', 'download', 'resource', - 'onEvent', 'triggerEvent' + 'start', + 'stop', + 'route', + 'halt', + 'error', + 'notFound', + 'render', + 'redirect', + 'etag', + 'lastModified', + 'json', + 'jsonHalt', + 'jsonp', + 'post', + 'put', + 'patch', + 'delete', + 'group', + 'getUrl', + 'download', + 'resource', + 'onEvent', + 'triggerEvent' ]; /** @var array Stored variables. */ @@ -425,26 +444,26 @@ class Engine if ($eventName === Dispatcher::FILTER_BEFORE && is_object($middleware) === true && ($middleware instanceof Closure)) { $middlewareObject = $middleware; - // If the object has already been created, we can just use it if the event name exists. + // If the object has already been created, we can just use it if the event name exists. } elseif (is_object($middleware) === true) { - $middlewareObject = method_exists($middleware, $eventName) === true ? [ $middleware, $eventName ] : false; + $middlewareObject = method_exists($middleware, $eventName) === true ? [$middleware, $eventName] : false; - // If the middleware is a string, we need to create the object and then call the event. + // If the middleware is a string, we need to create the object and then call the event. } elseif (is_string($middleware) === true && method_exists($middleware, $eventName) === true) { $resolvedClass = null; // if there's a container assigned, we should use it to create the object if ($this->dispatcher->mustUseContainer($middleware) === true) { $resolvedClass = $this->dispatcher->resolveContainerClass($middleware, $params); - // otherwise just assume it's a plain jane class, so inject the engine - // just like in Dispatcher::invokeCallable() + // otherwise just assume it's a plain jane class, so inject the engine + // just like in Dispatcher::invokeCallable() } elseif (class_exists($middleware) === true) { $resolvedClass = new $middleware($this); } // If something was resolved, create an array callable that will be passed in later. if ($resolvedClass !== null) { - $middlewareObject = [ $resolvedClass, $eventName ]; + $middlewareObject = [$resolvedClass, $eventName]; } } diff --git a/flight/Flight.php b/flight/Flight.php index 7e43877..064bd16 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -34,19 +34,19 @@ require_once __DIR__ . '/autoload.php'; * @method EventDispatcher eventDispatcher() Gets event dispatcher * * # Routing - * @method static Route route(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method static Route route(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Maps a URL pattern to a callback with all applicable methods. - * @method static void group(string $pattern, callable $callback, callable[] $group_middlewares = []) + * @method static void group(string $pattern, callable $callback, (class-string|callable|array{0: class-string, 1: string})[] $group_middlewares = []) * Groups a set of routes together under a common prefix. - * @method static Route post(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method static Route post(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a POST URL to a callback function. - * @method static Route put(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method static Route put(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a PUT URL to a callback function. - * @method static Route patch(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method static Route patch(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a PATCH URL to a callback function. - * @method static Route delete(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '') + * @method static Route delete(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') * Routes a DELETE URL to a callback function. - * @method static void resource(string $pattern, string $controllerClass, array> $methods = []) + * @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 diff --git a/flight/net/Router.php b/flight/net/Router.php index 8025024..455fdb4 100644 --- a/flight/net/Router.php +++ b/flight/net/Router.php @@ -56,12 +56,20 @@ class Router * * @var array */ - protected array $allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']; + protected array $allowedMethods = [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'HEAD', + 'OPTIONS' + ]; /** * Gets mapped routes. * - * @return array Array of routes + * @return array Array of routes */ public function getRoutes(): array { @@ -80,7 +88,7 @@ class Router * Maps a URL pattern to a callback function. * * @param string $pattern URL pattern to match. - * @param callable|string $callback Callback function or string class->method + * @param callable|string|array{0: class-string, 1: string} $callback Callback function or string `class->method` * @param bool $pass_route Pass the matching route object to the callback. * @param string $route_alias Alias for the route. */ @@ -133,7 +141,7 @@ class Router * Creates a GET based route * * @param string $pattern URL pattern to match - * @param callable|string $callback Callback function or string class->method + * @param callable|string|array{0: class-string, 1: string} $callback Callback function or string `class->method` * @param bool $pass_route Pass the matching route object to the callback * @param string $alias Alias for the route */ @@ -146,7 +154,7 @@ class Router * Creates a POST based route * * @param string $pattern URL pattern to match - * @param callable|string $callback Callback function or string class->method + * @param callable|string|array{0: class-string, 1: string} $callback Callback function or string `class->method` * @param bool $pass_route Pass the matching route object to the callback * @param string $alias Alias for the route */ @@ -159,7 +167,7 @@ class Router * Creates a PUT based route * * @param string $pattern URL pattern to match - * @param callable|string $callback Callback function or string class->method + * @param callable|string|array{0: class-string, 1: string} $callback Callback function or string `class->method` * @param bool $pass_route Pass the matching route object to the callback * @param string $alias Alias for the route */ @@ -172,7 +180,7 @@ class Router * Creates a PATCH based route * * @param string $pattern URL pattern to match - * @param callable|string $callback Callback function or string class->method + * @param callable|string|array{0: class-string, 1: string} $callback Callback function or string `class->method` * @param bool $pass_route Pass the matching route object to the callback * @param string $alias Alias for the route */ @@ -185,7 +193,7 @@ class Router * Creates a DELETE based route * * @param string $pattern URL pattern to match - * @param callable|string $callback Callback function or string class->method + * @param callable|string|array{0: class-string, 1: string} $callback Callback function or string `class->method` * @param bool $pass_route Pass the matching route object to the callback * @param string $alias Alias for the route */ @@ -199,7 +207,7 @@ class Router * * @param string $groupPrefix group URL prefix (such as /api/v1) * @param callable $callback The necessary calling that holds the Router class - * @param array $groupMiddlewares + * @param (class-string|callable|array{0: class-string, 1: string})[] $groupMiddlewares * The middlewares to be applied to the group. Example: `[$middleware1, $middleware2]` */ public function group(string $groupPrefix, callable $callback, array $groupMiddlewares = []): void @@ -226,7 +234,7 @@ class Router if ($urlMatches === true && $methodMatches === true) { $this->executedRoute = $route; return $route; - // capture the route but don't execute it. We'll use this in Engine->start() to throw a 405 + // capture the route but don't execute it. We'll use this in Engine->start() to throw a 405 } elseif ($urlMatches === true && $methodMatches === false) { $this->executedRoute = $route; } @@ -240,7 +248,7 @@ class Router * Gets the URL for a given route alias * * @param string $alias the alias to match - * @param array $params the parameters to pass to the route + * @param array $params the parameters to pass to the route */ public function getUrlByAlias(string $alias, array $params = []): string { @@ -311,7 +319,7 @@ class Router return in_array($key, $only, true) === true; }, ARRAY_FILTER_USE_KEY); - // Exclude these controller methods + // Exclude these controller methods } elseif (isset($options['except']) === true) { $except = $options['except']; $defaultMapping = array_filter($defaultMapping, function ($key) use ($except) { @@ -331,7 +339,7 @@ class Router foreach ($defaultMapping as $controllerMethod => $methodPattern) { $router->map( $methodPattern, - [ $controllerClass, $controllerMethod ] + [$controllerClass, $controllerMethod] )->setAlias($aliasBase . '.' . $controllerMethod); } },