Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add PSR-17 StreamFactoryInterface Support in ImplicitHeadMiddleware #48

Merged
merged 6 commits into from
Apr 24, 2023
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
19 changes: 15 additions & 4 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="5.6.0@e784128902dfe01d489c4123d69918a9f3c1eac5">
<files psalm-version="5.8.0@9cf4f60a333f779ad3bc704a555920e81d4fdcda">
<file src="src/Middleware/ImplicitHeadMiddleware.php">
<DeprecatedClass>
<code>new CallableStreamFactoryDecorator($streamFactory)</code>
</DeprecatedClass>
</file>
<file src="src/Middleware/ImplicitHeadMiddlewareFactory.php">
<DeprecatedClass>
<code><![CDATA[new CallableStreamFactoryDecorator($container->get(StreamInterface::class))]]></code>
</DeprecatedClass>
<DeprecatedMethod>
<code>detectStreamFactory</code>
</DeprecatedMethod>
<InvalidArgument>
<code>$container-&gt;get(StreamInterface::class)</code>
<code><![CDATA[$container->get(StreamInterface::class)]]></code>
</InvalidArgument>
</file>
<file src="test/InMemoryContainer.php">
<MixedReturnStatement>
<code>$this-&gt;services[$id]</code>
<code><![CDATA[$this->services[$id]]]></code>
</MixedReturnStatement>
</file>
<file src="test/Response/CallableResponseFactoryDecoratorTest.php">
<InternalMethod>
<code>new CallableResponseFactoryDecorator(fn (): ResponseInterface =&gt; $this-&gt;response)</code>
<code><![CDATA[new CallableResponseFactoryDecorator(fn (): ResponseInterface => $this->response)]]></code>
</InternalMethod>
</file>
</files>
26 changes: 15 additions & 11 deletions src/Middleware/ImplicitHeadMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@
use Fig\Http\Message\RequestMethodInterface as RequestMethod;
use Mezzio\Router\RouteResult;
use Mezzio\Router\RouterInterface;
use Mezzio\Router\Stream\CallableStreamFactoryDecorator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

use function is_callable;

/**
* Handle implicit HEAD requests.
*
Expand Down Expand Up @@ -44,19 +48,20 @@ class ImplicitHeadMiddleware implements MiddlewareInterface
{
public const FORWARDED_HTTP_METHOD_ATTRIBUTE = 'forwarded_http_method';

/** @var callable(): StreamInterface */
private $streamFactory;
private StreamFactoryInterface $streamFactory;

/**
* @param callable(): StreamInterface $streamFactory A factory capable of returning an empty
* StreamInterface instance to inject in a response.
* @param (callable(): StreamInterface)|StreamFactoryInterface $streamFactory A factory capable of returning
* an empty StreamInterface instance to
* inject in a response.
*/
public function __construct(private RouterInterface $router, callable $streamFactory)
public function __construct(private RouterInterface $router, callable|StreamFactoryInterface $streamFactory)
boesing marked this conversation as resolved.
Show resolved Hide resolved
{
// Factory is wrapped in closure in order to enforce return type safety.
$this->streamFactory = function () use ($streamFactory): StreamInterface {
return $streamFactory();
};
if (is_callable($streamFactory)) {
$streamFactory = new CallableStreamFactoryDecorator($streamFactory);
}

$this->streamFactory = $streamFactory;
}

/**
Expand Down Expand Up @@ -99,7 +104,6 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
->withMethod(RequestMethod::METHOD_GET)
);

$body = ($this->streamFactory)();
return $response->withBody($body);
return $response->withBody($this->streamFactory->createStream());
}
}
40 changes: 31 additions & 9 deletions src/Middleware/ImplicitHeadMiddlewareFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,19 @@

use Mezzio\Router\Exception\MissingDependencyException;
use Mezzio\Router\RouterInterface;
use Mezzio\Router\Stream\CallableStreamFactoryDecorator;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;

/**
* Create and return an ImplicitHeadMiddleware instance.
*
* This factory depends on two other services:
*
* - Mezzio\Router\RouterInterface, which should resolve to an
* instance of that interface.
* - Psr\Http\Message\StreamInterface, which should resolve to a callable
* that will produce an empty Psr\Http\Message\StreamInterface instance.
* - Mezzio\Router\RouterInterface, which should resolve to an instance of that interface.
* - Either Psr\Http\Message\StreamFactoryInterface or Psr\Http\Message\StreamInterface, which should resolve to a
* callable that will produce an empty Psr\Http\Message\StreamInterface instance.
*
* @final
*/
Expand All @@ -36,16 +37,37 @@ public function __invoke(ContainerInterface $container): ImplicitHeadMiddleware
);
}

if (! $container->has(StreamInterface::class)) {
return new ImplicitHeadMiddleware(
$container->get(RouterInterface::class),
$this->detectStreamFactory($container),
);
}

/**
* BC Preserving StreamFactoryInterface Retrieval
*
* Preserves existing behaviour in the 3.x series by fetching a `StreamInterface` callable and wrapping it in a
* decorator that implements StreamFactoryInterface. If `StreamInterface` callable is unavailable, attempt to
* fetch a `StreamFactoryInterface`, throwing a MissingDependencyException if neither are found.
*
* @deprecated Will be removed in version 4.0.0
*/
private function detectStreamFactory(ContainerInterface $container): StreamFactoryInterface
{
$hasStreamFactory = $container->has(StreamFactoryInterface::class);
$hasDeprecatedCallable = $container->has(StreamInterface::class);

if (! $hasStreamFactory && ! $hasDeprecatedCallable) {
throw MissingDependencyException::dependencyForService(
StreamInterface::class,
ImplicitHeadMiddleware::class
);
}

return new ImplicitHeadMiddleware(
$container->get(RouterInterface::class),
$container->get(StreamInterface::class)
);
if ($hasDeprecatedCallable) {
return new CallableStreamFactoryDecorator($container->get(StreamInterface::class));
}

return $container->get(StreamFactoryInterface::class);
}
}
5 changes: 2 additions & 3 deletions src/Middleware/ImplicitOptionsMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,14 @@
*/
class ImplicitOptionsMiddleware implements MiddlewareInterface
{
/** @var ResponseFactoryInterface */
private $responseFactory;
private ResponseFactoryInterface $responseFactory;

/**
* @param (callable():ResponseInterface)|ResponseFactoryInterface $responseFactory A factory capable of returning an
* empty ResponseInterface instance to return for implicit OPTIONS
* requests.
*/
public function __construct($responseFactory)
public function __construct(callable|ResponseFactoryInterface $responseFactory)
boesing marked this conversation as resolved.
Show resolved Hide resolved
{
if (is_callable($responseFactory)) {
// Factories are wrapped in a closure in order to enforce return type safety.
Expand Down
5 changes: 2 additions & 3 deletions src/Middleware/MethodNotAllowedMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,12 @@
*/
class MethodNotAllowedMiddleware implements MiddlewareInterface
{
/** @var ResponseFactoryInterface */
private $responseFactory;
private ResponseFactoryInterface $responseFactory;

/**
* @param (callable():ResponseInterface)|ResponseFactoryInterface $responseFactory
*/
public function __construct($responseFactory)
public function __construct(callable|ResponseFactoryInterface $responseFactory)
boesing marked this conversation as resolved.
Show resolved Hide resolved
{
if (is_callable($responseFactory)) {
// Factories are wrapped in a closure in order to enforce return type safety.
Expand Down
43 changes: 43 additions & 0 deletions src/Stream/CallableStreamFactoryDecorator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Mezzio\Router\Stream;

use Mezzio\Router\Exception\RuntimeException;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;

/**
* @internal
* @deprecated Will be removed in version 4.0.0
*/
final class CallableStreamFactoryDecorator implements StreamFactoryInterface
{
/** @var callable(): StreamInterface */
private $streamFactory;

/** @param callable(): StreamInterface $streamFactory */
public function __construct(callable $streamFactory)
{
$this->streamFactory = $streamFactory;
}

/** @inheritDoc */
public function createStream(string $content = ''): StreamInterface
{
return ($this->streamFactory)();
}

/** @inheritDoc */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably document @returns never?

public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface
{
throw new RuntimeException('This method will not be implemented');
}

/** @inheritDoc */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably document @returns never?

public function createStreamFromResource($resource): StreamInterface
{
throw new RuntimeException('This method will not be implemented');
}
}
54 changes: 47 additions & 7 deletions test/Middleware/ImplicitHeadMiddlewareFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

namespace MezzioTest\Router\Middleware;

use Laminas\Diactoros\StreamFactory;
use Mezzio\Router\Exception\MissingDependencyException;
use Mezzio\Router\Middleware\ImplicitHeadMiddlewareFactory;
use Mezzio\Router\RouterInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;

use function in_array;
Expand Down Expand Up @@ -47,33 +49,41 @@ public function testFactoryRaisesExceptionIfRouterInterfaceServiceIsMissing(): v
public function testFactoryRaisesExceptionIfStreamFactoryServiceIsMissing(): void
{
$this->container
->expects(self::exactly(2))
->expects(self::exactly(3))
->method('has')
->with(self::callback(function (string $arg): bool {
self::assertTrue(in_array($arg, [RouterInterface::class, StreamInterface::class]));
self::assertTrue(in_array($arg, [
RouterInterface::class,
StreamFactoryInterface::class,
StreamInterface::class,
]));
return true;
}))
->willReturnOnConsecutiveCalls(true, false);
->willReturnOnConsecutiveCalls(true, false, false);

$this->expectException(MissingDependencyException::class);

($this->factory)($this->container);
}

public function testFactoryProducesImplicitHeadMiddlewareWhenAllDependenciesPresent(): void
public function testFactoryProducesImplicitHeadMiddlewareWithCallableStreamFactory(): void
{
$router = $this->createMock(RouterInterface::class);
$streamFactory = static function (): void {
};

$this->container
->expects(self::exactly(2))
->expects(self::exactly(3))
->method('has')
->with(self::callback(function (string $arg): bool {
self::assertTrue(in_array($arg, [RouterInterface::class, StreamInterface::class]));
self::assertTrue(in_array($arg, [
RouterInterface::class,
StreamFactoryInterface::class,
StreamInterface::class,
]));
return true;
}))
->willReturn(true);
->willReturnOnConsecutiveCalls(true, false, true);

$this->container
->expects(self::exactly(2))
Expand All @@ -86,4 +96,34 @@ public function testFactoryProducesImplicitHeadMiddlewareWhenAllDependenciesPres

($this->factory)($this->container);
}

public function testFactoryProducesImplicitHeadMiddlewareWithStreamFactoryInterface(): void
{
$router = $this->createMock(RouterInterface::class);
$streamFactory = new StreamFactory();

$this->container
->expects(self::exactly(3))
->method('has')
->with(self::callback(function (string $arg): bool {
self::assertTrue(in_array($arg, [
RouterInterface::class,
StreamFactoryInterface::class,
StreamInterface::class,
]));
return true;
}))
->willReturn(true, true, false);

$this->container
->expects(self::exactly(2))
->method('get')
->with(self::callback(function (string $arg): bool {
self::assertTrue(in_array($arg, [RouterInterface::class, StreamFactoryInterface::class]));
return true;
}))
->willReturnOnConsecutiveCalls($router, $streamFactory);

($this->factory)($this->container);
}
}
61 changes: 61 additions & 0 deletions test/Stream/CallableStreamFactoryDecoratorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace MezzioTest\Router\Stream;

use Laminas\Diactoros\StreamFactory;
use Mezzio\Router\Exception\RuntimeException;
use Mezzio\Router\Stream\CallableStreamFactoryDecorator;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\StreamInterface;

use function fopen;

/**
* @psalm-suppress InternalClass, InternalMethod, DeprecatedClass
*/
#[CoversClass(CallableStreamFactoryDecorator::class)]
class CallableStreamFactoryDecoratorTest extends TestCase
{
private StreamInterface $stream;
private CallableStreamFactoryDecorator $decorator;

protected function setUp(): void
{
parent::setUp();

$this->stream = (new StreamFactory())->createStream();

$this->decorator = new CallableStreamFactoryDecorator(fn (): StreamInterface => $this->stream);
}

public function testThatCreateStreamWillProduceStream(): void
{
self::assertSame($this->stream, $this->decorator->createStream());
}

public function testThatTheStreamDoesNotReceiveContentArgument(): void
{
$result = $this->decorator->createStream('some content');

self::assertSame('', $result->getContents());
}

public function testCreateStreamFromFileIsNotImplemented(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('This method will not be implemented');

$this->decorator->createStreamFromFile('/foo');
}

public function testCreateStreamFromResourceIsNotImplemented(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('This method will not be implemented');

$this->decorator->createStreamFromResource(fopen(__FILE__, 'r'));
}
}