Skip to content

Commit 4f37a48

Browse files
authored
Merge pull request #547 from flightphp/stream-response
added streaming responses. Fixed JSONP.
2 parents 6861388 + 65c4509 commit 4f37a48

File tree

14 files changed

+200
-41
lines changed

14 files changed

+200
-41
lines changed

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55
/.gitignore export-ignore
66
/phpcs.xml export-ignore
77
/phpstan.neon export-ignore
8+
/phpstan-baseline.neon export-ignore
89
/phpunit.xml export-ignore

.gitignore

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,5 @@ composer.phar
44
composer.lock
55
.phpunit.result.cache
66
coverage/
7-
.vscode/settings.json
87
*.sublime*
9-
.vscode/
108
clover.xml

.vscode/settings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"php.suggest.basic": false,
3+
"editor.detectIndentation": false,
4+
"editor.insertSpaces": true
5+
}

flight/Engine.php

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -364,49 +364,55 @@ public function path(string $dir): void
364364
/**
365365
* Processes each routes middleware.
366366
*
367-
* @param array<int, callable> $middleware Middleware attached to the route.
368-
* @param array<mixed> $params `$route->params`.
367+
* @param Route $route The route to process the middleware for.
369368
* @param string $event_name If this is the before or after method.
370369
*/
371-
protected function processMiddleware(array $middleware, array $params, string $event_name): bool
370+
protected function processMiddleware(Route $route, string $event_name): bool
372371
{
373372
$at_least_one_middleware_failed = false;
374373

375-
foreach ($middleware as $middleware) {
374+
$middlewares = $event_name === Dispatcher::FILTER_BEFORE ? $route->middleware : array_reverse($route->middleware);
375+
$params = $route->params;
376+
377+
foreach ($middlewares as $middleware) {
376378
$middleware_object = false;
377379

378-
if ($event_name === 'before') {
380+
if ($event_name === Dispatcher::FILTER_BEFORE) {
379381
// can be a callable or a class
380382
$middleware_object = (is_callable($middleware) === true
381383
? $middleware
382-
: (method_exists($middleware, 'before') === true
383-
? [$middleware, 'before']
384+
: (method_exists($middleware, Dispatcher::FILTER_BEFORE) === true
385+
? [$middleware, Dispatcher::FILTER_BEFORE]
384386
: false
385387
)
386388
);
387-
} elseif ($event_name === 'after') {
389+
} elseif ($event_name === Dispatcher::FILTER_AFTER) {
388390
// must be an object. No functions allowed here
389391
if (
390392
is_object($middleware) === true
391393
&& !($middleware instanceof Closure)
392-
&& method_exists($middleware, 'after') === true
394+
&& method_exists($middleware, Dispatcher::FILTER_AFTER) === true
393395
) {
394-
$middleware_object = [$middleware, 'after'];
396+
$middleware_object = [$middleware, Dispatcher::FILTER_AFTER];
395397
}
396398
}
397399

398400
if ($middleware_object === false) {
399401
continue;
400402
}
401403

402-
if ($this->response()->v2_output_buffering === false) {
404+
$use_v3_output_buffering =
405+
$this->response()->v2_output_buffering === false &&
406+
$route->is_streamed === false;
407+
408+
if ($use_v3_output_buffering === true) {
403409
ob_start();
404410
}
405411

406412
// It's assumed if you don't declare before, that it will be assumed as the before method
407413
$middleware_result = $middleware_object($params);
408414

409-
if ($this->response()->v2_output_buffering === false) {
415+
if ($use_v3_output_buffering === true) {
410416
$this->response()->write(ob_get_clean());
411417
}
412418

@@ -462,16 +468,36 @@ public function _start(): void
462468
$params[] = $route;
463469
}
464470

471+
// If this route is to be streamed, we need to output the headers now
472+
if ($route->is_streamed === true) {
473+
$response->status($route->streamed_headers['status']);
474+
unset($route->streamed_headers['status']);
475+
$response->header('X-Accel-Buffering', 'no');
476+
$response->header('Connection', 'close');
477+
foreach ($route->streamed_headers as $header => $value) {
478+
$response->header($header, $value);
479+
}
480+
481+
// We obviously don't know the content length right now. This must be false.
482+
$response->content_length = false;
483+
$response->sendHeaders();
484+
$response->markAsSent();
485+
}
486+
465487
// Run any before middlewares
466488
if (count($route->middleware) > 0) {
467-
$at_least_one_middleware_failed = $this->processMiddleware($route->middleware, $route->params, 'before');
489+
$at_least_one_middleware_failed = $this->processMiddleware($route, 'before');
468490
if ($at_least_one_middleware_failed === true) {
469491
$failed_middleware_check = true;
470492
break;
471493
}
472494
}
473495

474-
if ($response->v2_output_buffering === false) {
496+
$use_v3_output_buffering =
497+
$this->response()->v2_output_buffering === false &&
498+
$route->is_streamed === false;
499+
500+
if ($use_v3_output_buffering === true) {
475501
ob_start();
476502
}
477503

@@ -481,18 +507,14 @@ public function _start(): void
481507
$params
482508
);
483509

484-
if ($response->v2_output_buffering === false) {
510+
if ($use_v3_output_buffering === true) {
485511
$response->write(ob_get_clean());
486512
}
487513

488514
// Run any before middlewares
489515
if (count($route->middleware) > 0) {
490516
// process the middleware in reverse order now
491-
$at_least_one_middleware_failed = $this->processMiddleware(
492-
array_reverse($route->middleware),
493-
$route->params,
494-
'after'
495-
);
517+
$at_least_one_middleware_failed = $this->processMiddleware($route, 'after');
496518

497519
if ($at_least_one_middleware_failed === true) {
498520
$failed_middleware_check = true;
@@ -774,8 +796,10 @@ public function _jsonp(
774796
$this->response()
775797
->status($code)
776798
->header('Content-Type', 'application/javascript; charset=' . $charset)
777-
->write($callback . '(' . $json . ');')
778-
->send();
799+
->write($callback . '(' . $json . ');');
800+
if ($this->response()->v2_output_buffering === true) {
801+
$this->response()->send();
802+
}
779803
}
780804

781805
/**

flight/net/Response.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,14 @@ public function sent(): bool
386386
return $this->sent;
387387
}
388388

389+
/**
390+
* Marks the response as sent.
391+
*/
392+
public function markAsSent(): void
393+
{
394+
$this->sent = true;
395+
}
396+
389397
/**
390398
* Sends a HTTP response.
391399
*/

flight/net/Route.php

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,20 @@ class Route
6363
/**
6464
* The middleware to be applied to the route
6565
*
66-
* @var array<int,callable|object>
66+
* @var array<int, callable|object>
6767
*/
6868
public array $middleware = [];
6969

70+
/** Whether the response for this route should be streamed. */
71+
public bool $is_streamed = false;
72+
73+
/**
74+
* If this route is streamed, the headers to be sent before the response.
75+
*
76+
* @var array<string, mixed>
77+
*/
78+
public array $streamed_headers = [];
79+
7080
/**
7181
* Constructor.
7282
*
@@ -179,7 +189,7 @@ public function matchAlias(string $alias): bool
179189
/**
180190
* Hydrates the route url with the given parameters
181191
*
182-
* @param array<string,mixed> $params the parameters to pass to the route
192+
* @param array<string, mixed> $params the parameters to pass to the route
183193
*/
184194
public function hydrateUrl(array $params = []): string
185195
{
@@ -212,9 +222,7 @@ public function setAlias(string $alias): self
212222
/**
213223
* Sets the route middleware
214224
*
215-
* @param array<int,callable>|callable $middleware
216-
*
217-
* @return self
225+
* @param array<int, callable>|callable $middleware
218226
*/
219227
public function addMiddleware($middleware): self
220228
{
@@ -225,4 +233,19 @@ public function addMiddleware($middleware): self
225233
}
226234
return $this;
227235
}
236+
237+
/**
238+
* This will allow the response for this route to be streamed.
239+
*
240+
* @param array<string, mixed> $headers a key value of headers to set before the stream starts.
241+
*
242+
* @return $this
243+
*/
244+
public function streamWithHeaders(array $headers): self
245+
{
246+
$this->is_streamed = true;
247+
$this->streamed_headers = $headers;
248+
249+
return $this;
250+
}
228251
}

phpstan-baseline.neon

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
parameters:
2+
ignoreErrors:
3+
-
4+
message: "#^Parameter \\#2 \\$callback of method flight\\\\core\\\\Dispatcher\\:\\:set\\(\\) expects Closure\\(\\)\\: mixed, array\\{\\$this\\(flight\\\\Engine\\), literal\\-string&non\\-falsy\\-string\\} given\\.$#"
5+
count: 1
6+
path: flight/Engine.php

phpstan.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
includes:
22
- vendor/phpstan/phpstan/conf/bleedingEdge.neon
3+
- phpstan-baseline.neon
34

45
parameters:
56
level: 6

tests/EngineTest.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace tests;
66

77
use Exception;
8+
use Flight;
89
use flight\Engine;
910
use flight\net\Response;
1011
use PHPUnit\Framework\TestCase;
@@ -307,6 +308,16 @@ public function testJson()
307308
{
308309
$engine = new Engine();
309310
$engine->json(['key1' => 'value1', 'key2' => 'value2']);
311+
$this->assertEquals('application/json; charset=utf-8', $engine->response()->headers()['Content-Type']);
312+
$this->assertEquals(200, $engine->response()->status());
313+
$this->assertEquals('{"key1":"value1","key2":"value2"}', $engine->response()->getBody());
314+
}
315+
316+
public function testJsonV2OutputBuffering()
317+
{
318+
$engine = new Engine();
319+
$engine->response()->v2_output_buffering = true;
320+
$engine->json(['key1' => 'value1', 'key2' => 'value2']);
310321
$this->expectOutputString('{"key1":"value1","key2":"value2"}');
311322
$this->assertEquals('application/json; charset=utf-8', $engine->response()->headers()['Content-Type']);
312323
$this->assertEquals(200, $engine->response()->status());
@@ -317,6 +328,17 @@ public function testJsonP()
317328
$engine = new Engine();
318329
$engine->request()->query['jsonp'] = 'whatever';
319330
$engine->jsonp(['key1' => 'value1', 'key2' => 'value2']);
331+
$this->assertEquals('application/javascript; charset=utf-8', $engine->response()->headers()['Content-Type']);
332+
$this->assertEquals(200, $engine->response()->status());
333+
$this->assertEquals('whatever({"key1":"value1","key2":"value2"});', $engine->response()->getBody());
334+
}
335+
336+
public function testJsonPV2OutputBuffering()
337+
{
338+
$engine = new Engine();
339+
$engine->response()->v2_output_buffering = true;
340+
$engine->request()->query['jsonp'] = 'whatever';
341+
$engine->jsonp(['key1' => 'value1', 'key2' => 'value2']);
320342
$this->expectOutputString('whatever({"key1":"value1","key2":"value2"});');
321343
$this->assertEquals('application/javascript; charset=utf-8', $engine->response()->headers()['Content-Type']);
322344
$this->assertEquals(200, $engine->response()->status());
@@ -326,6 +348,16 @@ public function testJsonpBadParam()
326348
{
327349
$engine = new Engine();
328350
$engine->jsonp(['key1' => 'value1', 'key2' => 'value2']);
351+
$this->assertEquals('({"key1":"value1","key2":"value2"});', $engine->response()->getBody());
352+
$this->assertEquals('application/javascript; charset=utf-8', $engine->response()->headers()['Content-Type']);
353+
$this->assertEquals(200, $engine->response()->status());
354+
}
355+
356+
public function testJsonpBadParamV2OutputBuffering()
357+
{
358+
$engine = new Engine();
359+
$engine->response()->v2_output_buffering = true;
360+
$engine->jsonp(['key1' => 'value1', 'key2' => 'value2']);
329361
$this->expectOutputString('({"key1":"value1","key2":"value2"});');
330362
$this->assertEquals('application/javascript; charset=utf-8', $engine->response()->headers()['Content-Type']);
331363
$this->assertEquals(200, $engine->response()->status());

tests/FlightTest.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,4 +278,30 @@ public function testHookOutputBufferingV2OutputBuffering()
278278
Flight::start();
279279
$this->assertEquals('hooked before starttest', Flight::response()->getBody());
280280
}
281+
282+
public function testStreamRoute()
283+
{
284+
$response_mock = new class extends Response {
285+
public function setRealHeader(string $header_string, bool $replace = true, int $response_code = 0): Response
286+
{
287+
return $this;
288+
}
289+
};
290+
$mock_response_class_name = get_class($response_mock);
291+
Flight::register('response', $mock_response_class_name);
292+
Flight::route('/stream', function () {
293+
echo 'stream';
294+
})->streamWithHeaders(['Content-Type' => 'text/plain', 'X-Test' => 'test', 'status' => 200 ]);
295+
Flight::request()->url = '/stream';
296+
$this->expectOutputString('stream');
297+
Flight::start();
298+
$this->assertEquals('', Flight::response()->getBody());
299+
$this->assertEquals([
300+
'Content-Type' => 'text/plain',
301+
'X-Test' => 'test',
302+
'X-Accel-Buffering' => 'no',
303+
'Connection' => 'close'
304+
], Flight::response()->getHeaders());
305+
$this->assertEquals(200, Flight::response()->status());
306+
}
281307
}

0 commit comments

Comments
 (0)