Skip to content
This repository has been archived by the owner on Jan 29, 2020. It is now read-only.

Throw EmptyPipelineException if passed in continuation is invoked more than once. #186

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,51 @@ details.

### Changed

- Nothing.
- [#186](https://github.com/zendframework/zend-stratigility/pull/186) adds a safeguard to middleware pipes to prevent them from being called
multiple times within the same middleware. As an example, consider the
following middleware:

```php
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
) : Response Interface {
$session = $request->getAttribute('session');
if (! $session) {
$response = $handler->handle($request);
}

// Inject another attribute before handling
$response = $handler->handle($request->withAttribute(
'sessionWasEnabled',
true
);
return $response;
}
```

When using Stratigility, the `$handler` is an instance of
`Zend\Stratigility\Next`, which encapsulates the middleware pipeline and
advances through it on each call to `handle()`.

The example demonstrates a subtle error: the response from the first
conditional should have been returned, but wasn't, which has led to invoking
the handler a second time. This scenario can have unexpected behaviors,
including always returning a "not found" response, or returning a response
from a handler that was not supposed to execute (as an earlier middleware
already returned early in the original call).

These bugs are hard to locate, as calling `handle()` is a normal part of any
middleware, and multiple conditional calls to it are a standard workflow.

With this new version, `Next` will pass a **clone** of itself to the next
middleware in the pipeline, and unset its own internal pipeline queue. Any
subsequent requests to `handle()` within the same scope will therefore result
in the exception `Zend\Stratigility\Exception\MiddlewarePipeNextHandlerAlreadyCalledException`.

If you depended on calling `$handler->handle()` multiple times in succession
within middleware, we recommend that you compose the specific pipeline(s)
and/or handler(s) you wish to call as class dependencies.

### Deprecated

Expand Down
21 changes: 21 additions & 0 deletions src/Exception/MiddlewarePipeNextHandlerAlreadyCalledException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php
/**
* @see https://github.com/zendframework/zend-stratigility for the canonical source repository
* @copyright Copyright (c) 2019 Zend Technologies USA Inc. (https://www.zend.com)
* @license https://github.com/zendframework/zend-stratigility/blob/master/LICENSE.md New BSD License
*/

declare(strict_types=1);

namespace Zend\Stratigility\Exception;

use DomainException;

class MiddlewarePipeNextHandlerAlreadyCalledException extends DomainException implements ExceptionInterface
{

public static function create(): self
{
return new self('Cannot invoke pipeline handler $handler->handle() more than once');
}
}
14 changes: 11 additions & 3 deletions src/Next.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php
/**
* @see https://github.com/zendframework/zend-stratigility for the canonical source repository
* @copyright Copyright (c) 2015-2018 Zend Technologies USA Inc. (https://www.zend.com)
* @copyright Copyright (c) 2015-2019 Zend Technologies USA Inc. (https://www.zend.com)
* @license https://github.com/zendframework/zend-stratigility/blob/master/LICENSE.md New BSD License
*/

Expand All @@ -13,6 +13,7 @@
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use SplQueue;
use Zend\Stratigility\Exception\MiddlewarePipeNextHandlerAlreadyCalledException;

/**
* Iterate a queue of middlewares and execute them.
Expand All @@ -25,7 +26,7 @@ final class Next implements RequestHandlerInterface
private $fallbackHandler;

/**
* @var SplQueue
* @var null|SplQueue
*/
private $queue;

Expand All @@ -43,12 +44,19 @@ public function __construct(SplQueue $queue, RequestHandlerInterface $fallbackHa

public function handle(ServerRequestInterface $request) : ResponseInterface
{
if ($this->queue === null) {
throw MiddlewarePipeNextHandlerAlreadyCalledException::create();
}

if ($this->queue->isEmpty()) {
Xerkus marked this conversation as resolved.
Show resolved Hide resolved
$this->queue = null;
return $this->fallbackHandler->handle($request);
}

$middleware = $this->queue->dequeue();
Xerkus marked this conversation as resolved.
Show resolved Hide resolved
$next = clone $this; // deep clone is not used intentionally
$this->queue = null; // mark queue as processed at this nesting level

return $middleware->process($request, $this);
return $middleware->process($request, $next);
}
}
102 changes: 101 additions & 1 deletion test/NextTest.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php
/**
* @see https://github.com/zendframework/zend-stratigility for the canonical source repository
* @copyright Copyright (c) 2015-2018 Zend Technologies USA Inc. (https://www.zend.com)
* @copyright Copyright (c) 2015-2019 Zend Technologies USA Inc. (https://www.zend.com)
* @license https://github.com/zendframework/zend-stratigility/blob/master/LICENSE.md New BSD License
*/

Expand All @@ -18,9 +18,12 @@
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use SplQueue;
use ZendTest\Stratigility\TestAsset\DelegatingMiddleware;
use ZendTest\Stratigility\TestAsset\ShortCircuitingMiddleware;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequest as Request;
use Zend\Diactoros\Uri;
use Zend\Stratigility\Exception\MiddlewarePipeNextHandlerAlreadyCalledException;
use Zend\Stratigility\Next;

class NextTest extends TestCase
Expand Down Expand Up @@ -214,4 +217,101 @@ public function testMiddlewareReturningResponseShortCircuitsProcess()

$this->assertSame($response, $next->handle($this->request));
}

public function testNextHandlerCannotBeInvokedTwice()
{
$fallbackHandler = $this->prophesize(RequestHandlerInterface::class);
$fallbackHandler
->handle(Argument::any())
->willReturn(new Response());

$this->queue->push(new DelegatingMiddleware());

$next = new Next($this->queue, $fallbackHandler->reveal());
$next->handle($this->request);
Xerkus marked this conversation as resolved.
Show resolved Hide resolved

$this->expectException(MiddlewarePipeNextHandlerAlreadyCalledException::class);
$next->handle($this->request);
}

public function testSecondInvocationAttemptDoesNotInvokeFinalHandler()
{
$fallBackHandler = $this->prophesize(RequestHandlerInterface::class);
$fallBackHandler
->handle(Argument::any())
->willReturn(new Response())
->shouldBeCalledTimes(1);
Xerkus marked this conversation as resolved.
Show resolved Hide resolved

$this->queue->push(new DelegatingMiddleware());

$next = new Next($this->queue, $fallBackHandler->reveal());
$next->handle($this->request);

$this->expectException(MiddlewarePipeNextHandlerAlreadyCalledException::class);
$next->handle($this->request);
}

public function testSecondInvocationAttemptDoesNotInvokeMiddleware()
{
$fallBackHandler = $this->prophesize(RequestHandlerInterface::class);
$fallBackHandler
->handle(Argument::any())
->willReturn(new Response());

$middleware = $this->prophesize(MiddlewareInterface::class);
$middleware
->process(
Argument::type(ServerRequestInterface::class),
Argument::type(RequestHandlerInterface::class)
)
->will(function (array $args): ResponseInterface {
return $args[1]->handle($args[0]);
})
->shouldBeCalledTimes(1);
Xerkus marked this conversation as resolved.
Show resolved Hide resolved

$this->queue->push($middleware->reveal());

$next = new Next($this->queue, $fallBackHandler->reveal());
$next->handle($this->request);

$this->expectException(MiddlewarePipeNextHandlerAlreadyCalledException::class);
$next->handle($this->request);
}

public function testShortCircuitingMiddlewareDoesNotEnableSecondInvocation()
{
$fallBackHandler = $this->prophesize(RequestHandlerInterface::class);
$fallBackHandler
->handle(Argument::any())
->willReturn(new Response())
->shouldNotBeCalled();
Xerkus marked this conversation as resolved.
Show resolved Hide resolved

$this->queue->push(new ShortCircuitingMiddleware());

// The middleware above shorcircuits (when handler is invoked first)
// The middleware below still exists in the queue (when handler is invoked again)
$this->queue->push(new DelegatingMiddleware());

$next = new Next($this->queue, $fallBackHandler->reveal());
$next->handle($this->request);

$this->expectException(MiddlewarePipeNextHandlerAlreadyCalledException::class);
$next->handle($this->request);
}

public function testSecondInvocationAttemptWithEmptyQueueDoesNotInvokeFinalHandler()
{
$fallBackHandler = $this->prophesize(RequestHandlerInterface::class);
$fallBackHandler
->handle(Argument::any())
->willReturn(new Response())
->shouldBeCalledTimes(1);
Xerkus marked this conversation as resolved.
Show resolved Hide resolved

$next = new Next($this->queue, $fallBackHandler->reveal());

$next->handle($this->request);

$this->expectException(MiddlewarePipeNextHandlerAlreadyCalledException::class);
$next->handle($this->request);
}
}
23 changes: 23 additions & 0 deletions test/TestAsset/DelegatingMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php
/**
* @see https://github.com/zendframework/zend-stratigility for the canonical source repository
* @copyright Copyright (c) 2019 Zend Technologies USA Inc. (https://www.zend.com)
* @license https://github.com/zendframework/zend-stratigility/blob/master/LICENSE.md New BSD License
*/

declare(strict_types=1);

namespace ZendTest\Stratigility\TestAsset;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class DelegatingMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $req, RequestHandlerInterface $handler): ResponseInterface
{
return $handler->handle($req);
}
}
24 changes: 24 additions & 0 deletions test/TestAsset/ShortCircuitingMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php
/**
* @see https://github.com/zendframework/zend-stratigility for the canonical source repository
* @copyright Copyright (c) 2019 Zend Technologies USA Inc. (https://www.zend.com)
* @license https://github.com/zendframework/zend-stratigility/blob/master/LICENSE.md New BSD License
*/

declare(strict_types=1);

namespace ZendTest\Stratigility\TestAsset;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Zend\Diactoros\Response;

class ShortCircuitingMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $req, RequestHandlerInterface $handler): ResponseInterface
{
return new Response();
}
}