diff --git a/.gitattributes b/.gitattributes index d90daf1..0c5892c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,4 +5,5 @@ /.gitignore export-ignore /phpcs.xml export-ignore /phpstan.neon export-ignore +/phpstan-baseline.neon export-ignore /phpunit.xml export-ignore diff --git a/.gitignore b/.gitignore index 6ca7489..59a1f8b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,6 @@ composer.phar composer.lock .phpunit.result.cache coverage/ -.vscode/settings.json *.sublime* -.vscode/ clover.xml phpcs.xml diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fcf56e7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "php.suggest.basic": false, + "editor.detectIndentation": false, + "editor.insertSpaces": true +} diff --git a/README.md b/README.md index 6fae650..7fb6ab7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ ![PHPStan: enabled](https://user-images.githubusercontent.com/104888/50957476-9c4acb80-14be-11e9-88ce-6447364dc1bb.png) ![PHPStan: level 6](https://img.shields.io/badge/PHPStan-level%206-brightgreen.svg?style=flat) ![Matrix](https://img.shields.io/matrix/flight-php-framework%3Amatrix.org?server_fqdn=matrix.org&style=social&logo=matrix) -[![Hit Count](https://hits.dwyl.com/flightphp/core.svg?style=flat-square&show=unique)](http://hits.dwyl.com/flightphp/core) # What is Flight? diff --git a/flight/Engine.php b/flight/Engine.php index e3af4a1..2a4e10c 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -364,34 +364,36 @@ class Engine /** * Processes each routes middleware. * - * @param array $middleware Middleware attached to the route. - * @param array $params `$route->params`. + * @param Route $route The route to process the middleware for. * @param 'before'|'after' $event_name If this is the before or after method. */ - protected function processMiddleware(array $middleware, array $params, string $event_name): bool + protected function processMiddleware(Route $route, string $event_name): bool { $at_least_one_middleware_failed = false; - foreach ($middleware as $middleware) { + $middlewares = $event_name === Dispatcher::FILTER_BEFORE ? $route->middleware : array_reverse($route->middleware); + $params = $route->params; + + foreach ($middlewares as $middleware) { $middleware_object = false; - if ($event_name === $this->dispatcher::FILTER_BEFORE) { + if ($event_name === Dispatcher::FILTER_BEFORE) { // can be a callable or a class $middleware_object = (is_callable($middleware) === true ? $middleware - : (method_exists($middleware, $this->dispatcher::FILTER_BEFORE) === true - ? [$middleware, $this->dispatcher::FILTER_BEFORE] + : (method_exists($middleware, Dispatcher::FILTER_BEFORE) === true + ? [$middleware, Dispatcher::FILTER_BEFORE] : false ) ); - } elseif ($event_name === $this->dispatcher::FILTER_AFTER) { + } elseif ($event_name === Dispatcher::FILTER_AFTER) { // must be an object. No functions allowed here if ( is_object($middleware) === true && !($middleware instanceof Closure) - && method_exists($middleware, $this->dispatcher::FILTER_AFTER) === true + && method_exists($middleware, Dispatcher::FILTER_AFTER) === true ) { - $middleware_object = [$middleware, $this->dispatcher::FILTER_AFTER]; + $middleware_object = [$middleware, Dispatcher::FILTER_AFTER]; } } @@ -399,14 +401,18 @@ class Engine continue; } - if ($this->response()->v2_output_buffering === false) { + $use_v3_output_buffering = + $this->response()->v2_output_buffering === false && + $route->is_streamed === false; + + if ($use_v3_output_buffering === true) { ob_start(); } // It's assumed if you don't declare before, that it will be assumed as the before method $middleware_result = $middleware_object($params); - if ($this->response()->v2_output_buffering === false) { + if ($use_v3_output_buffering === true) { $this->response()->write(ob_get_clean()); } @@ -462,21 +468,36 @@ class Engine $params[] = $route; } + // If this route is to be streamed, we need to output the headers now + if ($route->is_streamed === true) { + $response->status($route->streamed_headers['status']); + unset($route->streamed_headers['status']); + $response->header('X-Accel-Buffering', 'no'); + $response->header('Connection', 'close'); + foreach ($route->streamed_headers as $header => $value) { + $response->header($header, $value); + } + + // We obviously don't know the content length right now. This must be false. + $response->content_length = false; + $response->sendHeaders(); + $response->markAsSent(); + } + // Run any before middlewares if (count($route->middleware) > 0) { - $at_least_one_middleware_failed = $this->processMiddleware( - $route->middleware, - $route->params, - 'before' - ); - + $at_least_one_middleware_failed = $this->processMiddleware($route, 'before'); if ($at_least_one_middleware_failed === true) { $failed_middleware_check = true; break; } } - if ($response->v2_output_buffering === false) { + $use_v3_output_buffering = + $this->response()->v2_output_buffering === false && + $route->is_streamed === false; + + if ($use_v3_output_buffering === true) { ob_start(); } @@ -486,18 +507,14 @@ class Engine $params ); - if ($response->v2_output_buffering === false) { + if ($use_v3_output_buffering === true) { $response->write(ob_get_clean()); } // Run any before middlewares if (count($route->middleware) > 0) { // process the middleware in reverse order now - $at_least_one_middleware_failed = $this->processMiddleware( - array_reverse($route->middleware), - $route->params, - 'after' - ); + $at_least_one_middleware_failed = $this->processMiddleware($route, 'after'); if ($at_least_one_middleware_failed === true) { $failed_middleware_check = true; @@ -750,8 +767,10 @@ class Engine $this->response() ->status($code) ->header('Content-Type', 'application/json; charset=' . $charset) - ->write($json) - ->send(); + ->write($json); + if ($this->response()->v2_output_buffering === true) { + $this->response()->send(); + } } /** @@ -780,8 +799,10 @@ class Engine $this->response() ->status($code) ->header('Content-Type', 'application/javascript; charset=' . $charset) - ->write($callback . '(' . $json . ');') - ->send(); + ->write($callback . '(' . $json . ');'); + if ($this->response()->v2_output_buffering === true) { + $this->response()->send(); + } } /** diff --git a/flight/net/Response.php b/flight/net/Response.php index 291e84e..a98a416 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -389,6 +389,14 @@ class Response return $this->sent; } + /** + * Marks the response as sent. + */ + public function markAsSent(): void + { + $this->sent = true; + } + /** * Sends a HTTP response. */ diff --git a/flight/net/Route.php b/flight/net/Route.php index e73906b..a080a0a 100644 --- a/flight/net/Route.php +++ b/flight/net/Route.php @@ -63,10 +63,20 @@ class Route /** * The middleware to be applied to the route * - * @var array + * @var array */ public array $middleware = []; + /** Whether the response for this route should be streamed. */ + public bool $is_streamed = false; + + /** + * If this route is streamed, the headers to be sent before the response. + * + * @var array + */ + public array $streamed_headers = []; + /** * Constructor. * @@ -176,7 +186,7 @@ class Route /** * Hydrates the route url with the given parameters * - * @param array $params the parameters to pass to the route + * @param array $params the parameters to pass to the route */ public function hydrateUrl(array $params = []): string { @@ -209,9 +219,7 @@ class Route /** * Sets the route middleware * - * @param array|callable $middleware - * - * @return self + * @param array|callable $middleware */ public function addMiddleware($middleware): self { @@ -222,4 +230,19 @@ class Route } return $this; } + + /** + * This will allow the response for this route to be streamed. + * + * @param array $headers a key value of headers to set before the stream starts. + * + * @return $this + */ + public function streamWithHeaders(array $headers): self + { + $this->is_streamed = true; + $this->streamed_headers = $headers; + + return $this; + } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..9fa597c --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,6 @@ +parameters: + ignoreErrors: + - + message: "#^Parameter \\#2 \\$callback of method flight\\\\core\\\\Dispatcher\\:\\:set\\(\\) expects Closure\\(\\)\\: mixed, array\\{\\$this\\(flight\\\\Engine\\), literal\\-string&non\\-falsy\\-string\\} given\\.$#" + count: 1 + path: flight/Engine.php diff --git a/phpstan.neon b/phpstan.neon index 95afff0..97e16eb 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,6 @@ includes: - vendor/phpstan/phpstan/conf/bleedingEdge.neon + - phpstan-baseline.neon parameters: level: 6 diff --git a/tests/EngineTest.php b/tests/EngineTest.php index 1f9d456..c7e21d2 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace tests; use Exception; +use Flight; use flight\Engine; use flight\net\Response; use PHPUnit\Framework\TestCase; @@ -307,6 +308,16 @@ 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(200, $engine->response()->status()); + $this->assertEquals('{"key1":"value1","key2":"value2"}', $engine->response()->getBody()); + } + + 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(200, $engine->response()->status()); @@ -317,6 +328,17 @@ class EngineTest extends TestCase $engine = new Engine(); $engine->request()->query['jsonp'] = 'whatever'; $engine->jsonp(['key1' => 'value1', 'key2' => 'value2']); + $this->assertEquals('application/javascript; charset=utf-8', $engine->response()->headers()['Content-Type']); + $this->assertEquals(200, $engine->response()->status()); + $this->assertEquals('whatever({"key1":"value1","key2":"value2"});', $engine->response()->getBody()); + } + + public function testJsonPV2OutputBuffering() + { + $engine = new Engine(); + $engine->response()->v2_output_buffering = true; + $engine->request()->query['jsonp'] = 'whatever'; + $engine->jsonp(['key1' => 'value1', 'key2' => 'value2']); $this->expectOutputString('whatever({"key1":"value1","key2":"value2"});'); $this->assertEquals('application/javascript; charset=utf-8', $engine->response()->headers()['Content-Type']); $this->assertEquals(200, $engine->response()->status()); @@ -326,6 +348,16 @@ class EngineTest extends TestCase { $engine = new Engine(); $engine->jsonp(['key1' => 'value1', 'key2' => 'value2']); + $this->assertEquals('({"key1":"value1","key2":"value2"});', $engine->response()->getBody()); + $this->assertEquals('application/javascript; charset=utf-8', $engine->response()->headers()['Content-Type']); + $this->assertEquals(200, $engine->response()->status()); + } + + public function testJsonpBadParamV2OutputBuffering() + { + $engine = new Engine(); + $engine->response()->v2_output_buffering = true; + $engine->jsonp(['key1' => 'value1', 'key2' => 'value2']); $this->expectOutputString('({"key1":"value1","key2":"value2"});'); $this->assertEquals('application/javascript; charset=utf-8', $engine->response()->headers()['Content-Type']); $this->assertEquals(200, $engine->response()->status()); diff --git a/tests/FlightTest.php b/tests/FlightTest.php index c068dbe..a6ffa16 100644 --- a/tests/FlightTest.php +++ b/tests/FlightTest.php @@ -278,4 +278,30 @@ class FlightTest extends TestCase Flight::start(); $this->assertEquals('hooked before starttest', Flight::response()->getBody()); } + + public function testStreamRoute() + { + $response_mock = new class extends Response { + public function setRealHeader(string $header_string, bool $replace = true, int $response_code = 0): Response + { + return $this; + } + }; + $mock_response_class_name = get_class($response_mock); + Flight::register('response', $mock_response_class_name); + Flight::route('/stream', function () { + echo 'stream'; + })->streamWithHeaders(['Content-Type' => 'text/plain', 'X-Test' => 'test', 'status' => 200 ]); + Flight::request()->url = '/stream'; + $this->expectOutputString('stream'); + Flight::start(); + $this->assertEquals('', Flight::response()->getBody()); + $this->assertEquals([ + 'Content-Type' => 'text/plain', + 'X-Test' => 'test', + 'X-Accel-Buffering' => 'no', + 'Connection' => 'close' + ], Flight::response()->getHeaders()); + $this->assertEquals(200, Flight::response()->status()); + } } diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh new file mode 100644 index 0000000..d38bf0d --- /dev/null +++ b/tests/run_all_tests.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# Run all tests +composer lint +composer beautify +composer phpcs +composer test-coverage +xdg-open http://localhost:8000 +composer test-server \ No newline at end of file diff --git a/tests/server-v2/index.php b/tests/server-v2/index.php index 1ebdd13..d4cc385 100644 --- a/tests/server-v2/index.php +++ b/tests/server-v2/index.php @@ -20,6 +20,9 @@ Flight::set('flight.v2.output_buffering', true); // Test 1: Root route Flight::route('/', function () { echo 'Route text: Root route works!'; + if (Flight::request()->query->redirected) { + echo '
Redirected from /redirect route successfully!'; + } }); Flight::route('/querytestpath', function () { echo 'Route text: This ir query route
'; @@ -93,9 +96,39 @@ Flight::route('/protected', function () { Flight::route('/template/@name', function ($name) { Flight::render('template.phtml', ['name' => $name]); }); + +// Test 8: Throw an error +Flight::route('/error', function () { + trigger_error('This is a successful error'); +}); + +// Test 9: JSON output (should not output any other html) +Flight::route('/json', function () { + echo "\n\n\n\n\n"; + Flight::json(['message' => 'JSON renders successfully!']); + echo "\n\n\n\n\n"; +}); + +// Test 13: JSONP output (should not output any other html) +Flight::route('/jsonp', function () { + echo "\n\n\n\n\n"; + Flight::jsonp(['message' => 'JSONP renders successfully!'], 'jsonp'); + echo "\n\n\n\n\n"; +}); + +// Test 10: Halt +Flight::route('/halt', function () { + Flight::halt(400, 'Halt worked successfully'); +}); + +// Test 11: Redirect +Flight::route('/redirect', function () { + Flight::redirect('/?redirected=1'); +}); + Flight::set('flight.views.path', './'); Flight::map('error', function (Throwable $error) { - echo "

An error occurred, mapped error method worked, error bellow

"; + echo "

An error occurred, mapped error method worked, error below

"; echo '
';
     echo str_replace(getenv('PWD'), "***CLASSIFIED*****", $error->getTraceAsString());
     echo "
"; @@ -164,6 +197,11 @@ echo '
  • Query path
  • Post method test page - should be 404
  • Mega group
  • +
  • Error
  • +
  • JSON
  • +
  • JSONP
  • +
  • Halt
  • +
  • Redirect
  • '; Flight::before('start', function ($params) { echo '
    '; diff --git a/tests/server/LayoutMiddleware.php b/tests/server/LayoutMiddleware.php index bb30bda..d5dfcde 100644 --- a/tests/server/LayoutMiddleware.php +++ b/tests/server/LayoutMiddleware.php @@ -72,6 +72,11 @@ class LayoutMiddleware
  • 404 Not Found
  • Mega group
  • Error
  • +
  • JSON
  • +
  • JSONP
  • +
  • Halt
  • +
  • Redirect
  • +
  • Stream
  • HTML; echo '
    '; diff --git a/tests/server/index.php b/tests/server/index.php index c13e359..e3ea703 100644 --- a/tests/server/index.php +++ b/tests/server/index.php @@ -9,9 +9,7 @@ declare(strict_types=1); * @author Kristaps Muižnieks https://github.com/krmu */ -require_once file_exists(__DIR__ . '/../../vendor/autoload.php') - ? __DIR__ . '/../../vendor/autoload.php' - : __DIR__ . '/../../flight/autoload.php'; + require file_exists(__DIR__ . '/../../vendor/autoload.php') ? __DIR__ . '/../../vendor/autoload.php' : __DIR__ . '/../../flight/autoload.php'; Flight::set('flight.content_length', false); Flight::set('flight.views.path', './'); @@ -25,10 +23,13 @@ Flight::group('', function () { // Test 1: Root route Flight::route('/', function () { echo 'Route text: Root route works!'; + if (Flight::request()->query->redirected) { + echo '
    Redirected from /redirect route successfully!'; + } }); Flight::route('/querytestpath', function () { - echo 'Route text: This ir query route
    '; - echo "I got such query parameters:
    ";
    +        echo 'Route text: This is query route
    '; + echo "Query parameters:
    ";
             print_r(Flight::request()->query);
             echo "
    "; }, false, "querytestpath"); @@ -97,23 +98,48 @@ Flight::group('', function () { Flight::route('/error', function () { trigger_error('This is a successful error'); }); -}, [new LayoutMiddleware()]); -Flight::map('error', function (Throwable $e) { - $styles = join(';', [ - 'border: 2px solid red', - 'padding: 21px', - 'background: lightgray', - 'font-weight: bold' - ]); + // Test 10: Halt + Flight::route('/halt', function () { + Flight::halt(400, 'Halt worked successfully'); + }); + + // Test 11: Redirect + Flight::route('/redirect', function () { + Flight::redirect('/?redirected=1'); + }); + + // Test 12: Redirect with status code + Flight::route('/streamResponse', function () { + echo "Streaming a response"; + for ($i = 1; $i <= 50; $i++) { + echo "."; + usleep(50000); + ob_flush(); + } + echo "is successful!!"; + })->streamWithHeaders(['Content-Type' => 'text/html', 'status' => 200 ]); +}, [ new LayoutMiddleware() ]); + +// Test 9: JSON output (should not output any other html) +Flight::route('/json', function () { + Flight::json(['message' => 'JSON renders successfully!']); +}); + +// Test 13: JSONP output (should not output any other html) +Flight::route('/jsonp', function () { + Flight::jsonp(['message' => 'JSONP renders successfully!'], 'jsonp'); +}); +Flight::map('error', function (Throwable $e) { echo sprintf( - "

    500 Internal Server Error

    %s (%s)

    %s
    ", + '

    500 Internal Server Error

    ' . + '

    %s (%s)

    ' . + '
    %s
    ', $e->getMessage(), $e->getCode(), str_replace(getenv('PWD'), '***CONFIDENTIAL***', $e->getTraceAsString()) ); - echo "
    Go back"; }); Flight::map('notFound', function () {