Merge pull request #547 from flightphp/stream-response

added streaming responses. Fixed JSONP.
pull/551/head v3.5.3
fadrian06 11 months ago committed by GitHub
commit 4f37a4854e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

1
.gitattributes vendored

@ -5,4 +5,5 @@
/.gitignore export-ignore
/phpcs.xml export-ignore
/phpstan.neon export-ignore
/phpstan-baseline.neon export-ignore
/phpunit.xml export-ignore

2
.gitignore vendored

@ -4,7 +4,5 @@ composer.phar
composer.lock
.phpunit.result.cache
coverage/
.vscode/settings.json
*.sublime*
.vscode/
clover.xml

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

@ -364,34 +364,36 @@ class Engine
/**
* Processes each routes middleware.
*
* @param array<int, callable> $middleware Middleware attached to the route.
* @param array<mixed> $params `$route->params`.
* @param Route $route The route to process the middleware for.
* @param string $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 === 'before') {
if ($event_name === Dispatcher::FILTER_BEFORE) {
// can be a callable or a class
$middleware_object = (is_callable($middleware) === true
? $middleware
: (method_exists($middleware, 'before') === true
? [$middleware, 'before']
: (method_exists($middleware, Dispatcher::FILTER_BEFORE) === true
? [$middleware, Dispatcher::FILTER_BEFORE]
: false
)
);
} elseif ($event_name === '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, 'after') === true
&& method_exists($middleware, Dispatcher::FILTER_AFTER) === true
) {
$middleware_object = [$middleware, '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,16 +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();
}
@ -481,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;
@ -774,8 +796,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();
}
}
/**

@ -386,6 +386,14 @@ class Response
return $this->sent;
}
/**
* Marks the response as sent.
*/
public function markAsSent(): void
{
$this->sent = true;
}
/**
* Sends a HTTP response.
*/

@ -63,10 +63,20 @@ class Route
/**
* The middleware to be applied to the route
*
* @var array<int,callable|object>
* @var array<int, callable|object>
*/
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<string, mixed>
*/
public array $streamed_headers = [];
/**
* Constructor.
*
@ -179,7 +189,7 @@ class Route
/**
* Hydrates the route url with the given parameters
*
* @param array<string,mixed> $params the parameters to pass to the route
* @param array<string, mixed> $params the parameters to pass to the route
*/
public function hydrateUrl(array $params = []): string
{
@ -212,9 +222,7 @@ class Route
/**
* Sets the route middleware
*
* @param array<int,callable>|callable $middleware
*
* @return self
* @param array<int, callable>|callable $middleware
*/
public function addMiddleware($middleware): self
{
@ -225,4 +233,19 @@ class Route
}
return $this;
}
/**
* This will allow the response for this route to be streamed.
*
* @param array<string, mixed> $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;
}
}

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

@ -1,5 +1,6 @@
includes:
- vendor/phpstan/phpstan/conf/bleedingEdge.neon
- phpstan-baseline.neon
parameters:
level: 6

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

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

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

@ -20,9 +20,9 @@ Flight::set('flight.v2.output_buffering', true);
// Test 1: Root route
Flight::route('/', function () {
echo '<span id="infotext">Route text:</span> Root route works!';
if (Flight::request()->query->redirected) {
echo '<br>Redirected from /redirect route successfully!';
}
if (Flight::request()->query->redirected) {
echo '<br>Redirected from /redirect route successfully!';
}
});
Flight::route('/querytestpath', function () {
echo '<span id="infotext">Route text:</span> This ir query route<br>';
@ -99,24 +99,31 @@ Flight::route('/template/@name', function ($name) {
// Test 8: Throw an error
Flight::route('/error', function () {
trigger_error('This is a successful error');
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";
echo "\n\n\n\n\n";
Flight::json(['message' => 'JSON renders successfully!']);
echo "\n\n\n\n\n";
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');
Flight::halt(400, 'Halt worked successfully');
});
// Test 11: Redirect
Flight::route('/redirect', function () {
Flight::redirect('/?redirected=1');
Flight::redirect('/?redirected=1');
});
Flight::set('flight.views.path', './');
@ -192,6 +199,7 @@ echo '
<li><a href="' . Flight::getUrl('final_group') . '">Mega group</a></li>
<li><a href="/error">Error</a></li>
<li><a href="/json">JSON</a></li>
<li><a href="/json?jsonp=myjson">JSONP</a></li>
<li><a href="/halt">Halt</a></li>
<li><a href="/redirect">Redirect</a></li>
</ul>';

@ -73,8 +73,10 @@ class LayoutMiddleware
<li><a href="{$final_route}">Mega group</a></li>
<li><a href="/error">Error</a></li>
<li><a href="/json">JSON</a></li>
<li><a href="/json?jsonp=myjson">JSONP</a></li>
<li><a href="/halt">Halt</a></li>
<li><a href="/redirect">Redirect</a></li>
<li><a href="/streamResponse">Stream</a></li>
</ul>
HTML;
echo '<div id="container">';

@ -104,10 +104,21 @@ Flight::group('', function () {
Flight::halt(400, 'Halt worked successfully');
});
// Test 11: Redirect
Flight::route('/redirect', function () {
Flight::redirect('/?redirected=1');
});
// 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)
@ -115,6 +126,11 @@ 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(
'<h1>500 Internal Server Error</h1>' .

Loading…
Cancel
Save