[FEATURE] Provide implementation for PSR-17 HTTP Message Factories 58/61558/17
authorBenjamin Franzke <bfr@qbus.de>
Mon, 26 Aug 2019 12:11:18 +0000 (14:11 +0200)
committerFrank Nägler <frank.naegler@typo3.org>
Fri, 20 Sep 2019 08:48:56 +0000 (10:48 +0200)
Support for PSR-17 HTTP Message Factories has been added.

PSR-17 HTTP Factories are intended to be used by PSR-15 request handlers
in order to create PSR-7 compatible message objects.

Classes may use dependency injection to use any of the available PSR-17
HTTP Factory interfaces.

The Request/Response base class (Message) is adapted to be able to lazily
initialize a stream when getBody() is called.
This is done as the PSR (Stream)RequestFactoryInterface does not allow
to control Stream properties. Therefore it is a performance
optimization to defer initialization. It is likely, that a new
Stream will be added to a Request with withStream() anyway.
(Which would mean resources for the intermediate stream would have
been wasted)

Furthermore some DocBlocks are adapted to reflect the variadic
UriInterface/StreamInterface parameters that are already handled in
code but were not documented. These cases are needed/required
by the PSR-17 factory implementation now.

composer require psr/http-factory:^1.0

Releases: master
Resolves: #89018
Change-Id: Ie6b9d865679bbf6f5d3d030b0ed1a3f277c47a3d
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/61558
Tested-by: TYPO3com <noreply@typo3.com>
Tested-by: Benni Mack <benni@typo3.org>
Tested-by: Frank Nägler <frank.naegler@typo3.org>
Reviewed-by: Benni Mack <benni@typo3.org>
Reviewed-by: Frank Nägler <frank.naegler@typo3.org>
23 files changed:
composer.json
composer.lock
typo3/sysext/core/Classes/Http/Message.php
typo3/sysext/core/Classes/Http/NullResponse.php
typo3/sysext/core/Classes/Http/Request.php
typo3/sysext/core/Classes/Http/RequestFactory.php
typo3/sysext/core/Classes/Http/Response.php
typo3/sysext/core/Classes/Http/ResponseFactory.php [new file with mode: 0644]
typo3/sysext/core/Classes/Http/ServerRequest.php
typo3/sysext/core/Classes/Http/ServerRequestFactory.php
typo3/sysext/core/Classes/Http/StreamFactory.php [new file with mode: 0644]
typo3/sysext/core/Classes/Http/UploadedFile.php
typo3/sysext/core/Classes/Http/UploadedFileFactory.php [new file with mode: 0644]
typo3/sysext/core/Classes/Http/UriFactory.php [new file with mode: 0644]
typo3/sysext/core/Configuration/Services.yaml
typo3/sysext/core/Documentation/Changelog/master/Feature-89018-ProvideImplementationForPSR-17HTTPMessageFactories.rst [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/Http/RequestFactoryTest.php [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/Http/ResponseFactoryTest.php [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/Http/ServerRequestFactoryTest.php
typo3/sysext/core/Tests/Unit/Http/StreamFactoryTest.php [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/Http/UploadedFileFactoryTest.php [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/Http/UriFactoryTest.php [new file with mode: 0644]
typo3/sysext/core/composer.json

index 151b12c..f214857 100644 (file)
@@ -47,6 +47,7 @@
                "phpdocumentor/reflection-docblock": "^4.3",
                "psr/container": "^1.0",
                "psr/event-dispatcher": "^1.0",
+               "psr/http-factory": "^1.0",
                "psr/http-message": "~1.0",
                "psr/http-server-middleware": "^1.0",
                "psr/log": "~1.0.0",
                "symfony/routing": "4.2.7",
                "phpdocumentor/reflection-docblock": ">= 4.3.2"
        },
+       "provide": {
+               "psr/http-factory-implementation": "1.0",
+               "psr/http-message-implementation": "1.0"
+       },
        "extra": {
                "typo3/class-alias-loader": {
                        "class-alias-maps": [
index 65119b7..ba1291a 100644 (file)
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "6309d4f7acafe489cc5ed464bd5b6373",
+    "content-hash": "504d732795cef3c4078aa9f9587ff3dd",
     "packages": [
         {
             "name": "cogpowered/finediff",
             "time": "2019-01-08T18:20:26+00:00"
         },
         {
+            "name": "psr/http-factory",
+            "version": "1.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-factory.git",
+                "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be",
+                "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.0.0",
+                "psr/http-message": "^1.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Http\\Message\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "http://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interfaces for PSR-7 HTTP message factories",
+            "keywords": [
+                "factory",
+                "http",
+                "message",
+                "psr",
+                "psr-17",
+                "psr-7",
+                "request",
+                "response"
+            ],
+            "time": "2019-04-30T12:38:16+00:00"
+        },
+        {
             "name": "psr/http-message",
             "version": "1.0.1",
             "source": {
             "authors": [
                 {
                     "name": "Arne Blankerts",
-                    "email": "arne@blankerts.de",
-                    "role": "Developer"
+                    "role": "Developer",
+                    "email": "arne@blankerts.de"
                 }
             ],
             "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
index 3e76881..6aa428b 100644 (file)
@@ -285,6 +285,9 @@ class Message implements MessageInterface
      */
     public function getBody()
     {
+        if ($this->body === null) {
+            $this->body = new Stream('php://temp', 'r+');
+        }
         return $this->body;
     }
 
index 4550302..4bba7e4 100644 (file)
@@ -22,4 +22,8 @@ namespace TYPO3\CMS\Core\Http;
  */
 class NullResponse extends Response
 {
+    public function __construct()
+    {
+        parent::__construct(null);
+    }
 }
index 26a6379..22b404c 100644 (file)
@@ -77,9 +77,9 @@ class Request extends Message implements RequestInterface
     /**
      * Constructor, the only place to set all parameters of this Request
      *
-     * @param string|null $uri URI for the request, if any.
+     * @param string|UriInterface|null $uri URI for the request, if any.
      * @param string|null $method HTTP method for the request, if any.
-     * @param string|resource|StreamInterface $body Message body, if any.
+     * @param string|resource|StreamInterface|null $body Message body, if any.
      * @param array $headers Headers for the message, if any.
      * @throws \InvalidArgumentException for any invalid value.
      */
@@ -87,11 +87,11 @@ class Request extends Message implements RequestInterface
     {
 
         // Build a streamable object for the body
-        if (!is_string($body) && !is_resource($body) && !$body instanceof StreamInterface) {
+        if ($body !== null && !is_string($body) && !is_resource($body) && !$body instanceof StreamInterface) {
             throw new \InvalidArgumentException('Body must be a string stream resource identifier, a stream resource, or a StreamInterface instance', 1436717271);
         }
 
-        if (!$body instanceof StreamInterface) {
+        if ($body !== null && !$body instanceof StreamInterface) {
             $body = new Stream($body);
         }
 
index d421d1d..93bbf5d 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+declare(strict_types = 1);
 namespace TYPO3\CMS\Core\Http;
 
 /*
@@ -17,17 +18,32 @@ namespace TYPO3\CMS\Core\Http;
 use GuzzleHttp\Client;
 use GuzzleHttp\ClientInterface;
 use GuzzleHttp\HandlerStack;
+use Psr\Http\Message\RequestFactoryInterface;
+use Psr\Http\Message\RequestInterface;
 use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\UriInterface;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 
 /**
  * Class RequestFactory to create Request objects
- * Returns PSR-7 Request objects (currently the Guzzle implementation).
+ * Returns PSR-7 Request objects
  */
-class RequestFactory
+class RequestFactory implements RequestFactoryInterface
 {
     /**
-     * Create a request object with our custom implementation
+     * Create a new request.
+     *
+     * @param string $method The HTTP method associated with the request.
+     * @param UriInterface|string $uri The URI associated with the request.
+     * @return RequestInterface
+     */
+    public function createRequest(string $method, $uri): RequestInterface
+    {
+        return new Request($uri, $method, null);
+    }
+
+    /**
+     * Create a guzzle request object with our custom implementation
      *
      * @param string $uri the URI to request
      * @param string $method the HTTP method (defaults to GET)
index 3a4a0dd..aa19143 100644 (file)
@@ -120,16 +120,17 @@ class Response extends Message implements ResponseInterface
      * @param StreamInterface|string $body
      * @param int $statusCode
      * @param array $headers
+     * @param string $reasonPhrase
      * @throws \InvalidArgumentException if any of the given arguments are given
      */
-    public function __construct($body = 'php://temp', $statusCode = 200, $headers = [])
+    public function __construct($body = 'php://temp', $statusCode = 200, $headers = [], string $reasonPhrase = '')
     {
         // Build a streamable object for the body
-        if (!is_string($body) && !is_resource($body) && !$body instanceof StreamInterface) {
+        if ($body !== null && !is_string($body) && !is_resource($body) && !$body instanceof StreamInterface) {
             throw new \InvalidArgumentException('Body must be a string stream resource identifier, a stream resource, or a StreamInterface instance', 1436717277);
         }
 
-        if (!$body instanceof StreamInterface) {
+        if ($body !== null && !$body instanceof StreamInterface) {
             $body = new Stream($body, 'rw');
         }
         $this->body = $body;
@@ -139,7 +140,7 @@ class Response extends Message implements ResponseInterface
         }
         $this->statusCode = (int)$statusCode;
 
-        $this->reasonPhrase = $this->availableStatusCodes[$this->statusCode];
+        $this->reasonPhrase = $reasonPhrase === '' ? $this->availableStatusCodes[$this->statusCode] : $reasonPhrase;
         list($this->lowercasedHeaderNames, $headers) = $this->filterHeaders($headers);
         $this->assertHeaders($headers);
         $this->headers = $headers;
diff --git a/typo3/sysext/core/Classes/Http/ResponseFactory.php b/typo3/sysext/core/Classes/Http/ResponseFactory.php
new file mode 100644 (file)
index 0000000..d44dff0
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\Http;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * @internal Note that this is not public API, use PSR-17 interfaces instead.
+ */
+class ResponseFactory implements ResponseFactoryInterface
+{
+    /**
+     * Create a new response.
+     *
+     * @param int $code HTTP status code; defaults to 200
+     * @param string $reasonPhrase Reason phrase to associate with status code
+     * @return ResponseInterface
+     */
+    public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface
+    {
+        return new Response(null, $code, [], $reasonPhrase);
+    }
+}
index daa16b4..c8b2c7c 100644 (file)
@@ -66,9 +66,9 @@ class ServerRequest extends Request implements ServerRequestInterface
     /**
      * Constructor, the only place to set all parameters of this Message/Request
      *
-     * @param string|null $uri URI for the request, if any.
+     * @param string|UriInterface|null $uri URI for the request, if any.
      * @param string|null $method HTTP method for the request, if any.
-     * @param string|resource|StreamInterface $body Message body, if any.
+     * @param string|resource|StreamInterface|null $body Message body, if any.
      * @param array $headers Headers for the message, if any.
      * @param array $serverParams Server parameters, typically from $_SERVER
      * @param array $uploadedFiles Upload file information, a tree of UploadedFiles
index f8049bf..964a3a0 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+declare(strict_types = 1);
 namespace TYPO3\CMS\Core\Http;
 
 /*
@@ -14,6 +15,8 @@ namespace TYPO3\CMS\Core\Http;
  * The TYPO3 project - inspiring people to share!
  */
 
+use Psr\Http\Message\ServerRequestFactoryInterface;
+use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Message\UploadedFileInterface;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 
@@ -24,9 +27,26 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
  *
  * @internal Note that this is not public API yet.
  */
-class ServerRequestFactory
+class ServerRequestFactory implements ServerRequestFactoryInterface
 {
     /**
+     * Create a new server request.
+     *
+     * Note that server-params are taken precisely as given - no parsing/processing
+     * of the given values is performed, and, in particular, no attempt is made to
+     * determine the HTTP method or URI, which must be provided explicitly.
+     *
+     * @param string $method The HTTP method associated with the request.
+     * @param UriInterface|string $uri The URI associated with the request.
+     * @param array $serverParams Array of SAPI parameters with which to seed the generated request instance.
+     * @return ServerRequestInterface
+     */
+    public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface
+    {
+        return new ServerRequest($uri, $method, null, [], $serverParams);
+    }
+
+    /**
      * Create a request from the original superglobal variables.
      *
      * @return ServerRequest
diff --git a/typo3/sysext/core/Classes/Http/StreamFactory.php b/typo3/sysext/core/Classes/Http/StreamFactory.php
new file mode 100644 (file)
index 0000000..6528801
--- /dev/null
@@ -0,0 +1,82 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\Http;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\StreamFactoryInterface;
+use Psr\Http\Message\StreamInterface;
+
+/**
+ * @internal Note that this is not public API, use PSR-17 interfaces instead.
+ */
+class StreamFactory implements StreamFactoryInterface
+{
+    /**
+     * Create a new stream from a string.
+     *
+     * @param string $content String content with which to populate the stream.
+     * @return StreamInterface
+     */
+    public function createStream(string $content = ''): StreamInterface
+    {
+        $stream = new Stream('php://temp', 'r+');
+        if ($content !== '') {
+            $stream->write($content);
+        }
+        return $stream;
+    }
+
+    /**
+     * Create a stream from an existing file.
+     *
+     * The `$filename` MAY be any string supported by `fopen()`.
+     *
+     * @param string $filename Filename or stream URI to use as basis of stream.
+     * @param string $mode Mode with which to open the underlying filename/stream.
+     * @return StreamInterface
+     * @throws \RuntimeException If the file cannot be opened.
+     * @throws \InvalidArgumentException If the mode is invalid.
+     */
+    public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface
+    {
+        $resource = @fopen($filename, $mode);
+        if ($resource === false) {
+            if ($mode === '' || in_array($mode[0], ['r', 'w', 'a', 'x', 'c'], true) === false) {
+                throw new \InvalidArgumentException('The mode ' . $mode . ' is invalid.', 1566823434);
+            }
+
+            throw new \RuntimeException('The file ' . $filename . ' cannot be opened.', 1566823435);
+        }
+
+        return new Stream($resource);
+    }
+
+    /**
+     * Create a new stream from an existing resource.
+     *
+     * The stream MUST be readable and may be writable.
+     *
+     * @param resource $resource PHP resource to use as basis of stream.
+     * @return StreamInterface
+     * @throws \InvalidArgumentException
+     */
+    public function createStreamFromResource($resource): StreamInterface
+    {
+        if (!is_resource($resource) || get_resource_type($resource) !== 'stream') {
+            throw new \InvalidArgumentException('Invalid stream provided; must be a stream resource', 1566853697);
+        }
+        return new Stream($resource);
+    }
+}
index cda9153..2e666a2 100644 (file)
@@ -67,7 +67,7 @@ class UploadedFile implements UploadedFileInterface
     /**
      * Constructor method
      *
-     * @param string|resource $input is either a stream or a filename
+     * @param string|resource|StreamInterface $input is either a stream or a filename
      * @param int $size see $_FILES['size'] from PHP
      * @param int $errorStatus see $_FILES['error']
      * @param string $clientFilename the original filename handed over from the client
diff --git a/typo3/sysext/core/Classes/Http/UploadedFileFactory.php b/typo3/sysext/core/Classes/Http/UploadedFileFactory.php
new file mode 100644 (file)
index 0000000..5cf3ee9
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\Http;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\StreamInterface;
+use Psr\Http\Message\UploadedFileFactoryInterface;
+use Psr\Http\Message\UploadedFileInterface;
+
+/**
+ * @internal Note that this is not public API, use PSR-17 interfaces instead.
+ */
+class UploadedFileFactory implements UploadedFileFactoryInterface
+{
+    /**
+     * Create a new uploaded file.
+     *
+     * If a size is not provided it will be determined by checking the size of
+     * the file.
+     *
+     * @see http://php.net/manual/features.file-upload.post-method.php
+     * @see http://php.net/manual/features.file-upload.errors.php
+     *
+     * @param StreamInterface $stream Underlying stream representing the uploaded file content.
+     * @param int $size in bytes
+     * @param int $error PHP file upload error
+     * @param string $clientFilename Filename as provided by the client, if any.
+     * @param string $clientMediaType Media type as provided by the client, if any.
+     * @return UploadedFileInterface
+     * @throws \InvalidArgumentException If the file resource is not readable.
+     */
+    public function createUploadedFile(
+        StreamInterface $stream,
+        int $size = null,
+        int $error = \UPLOAD_ERR_OK,
+        string $clientFilename = null,
+        string $clientMediaType = null
+    ): UploadedFileInterface {
+        if ($size === null) {
+            $size = $stream->getSize();
+            if ($size === null) {
+                throw new \InvalidArgumentException('Stream size could not be determined.', 1566823423);
+            }
+        }
+
+        return new UploadedFile($stream, $size, $error, $clientFilename, $clientMediaType);
+    }
+}
diff --git a/typo3/sysext/core/Classes/Http/UriFactory.php b/typo3/sysext/core/Classes/Http/UriFactory.php
new file mode 100644 (file)
index 0000000..b327f62
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\Http;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\UriFactoryInterface;
+use Psr\Http\Message\UriInterface;
+
+/**
+ * @internal Note that this is not public API, use PSR-17 interfaces instead.
+ */
+class UriFactory implements UriFactoryInterface
+{
+    /**
+     * Create a new URI.
+     *
+     * @param string $uri
+     * @return UriInterface
+     * @throws \InvalidArgumentException If the given URI cannot be parsed.
+     */
+    public function createUri(string $uri = ''): UriInterface
+    {
+        return new Uri($uri);
+    }
+}
index e4dffc2..30baf16 100644 (file)
@@ -7,10 +7,6 @@ services:
   TYPO3\CMS\Core\:
     resource: '../Classes/*'
 
-  Psr\EventDispatcher\EventDispatcherInterface:
-    alias: TYPO3\CMS\Core\EventDispatcher\EventDispatcher
-    public: true
-
   TYPO3\CMS\Core\DependencyInjection\EnvVarProcessor:
     tags: ['container.env_var_processor']
 
@@ -76,3 +72,26 @@ services:
     class: TYPO3\CMS\Core\Cache\Frontend\FrontendInterface
     factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache']
     arguments: ['l10n']
+
+  # Interface implementations
+  Psr\EventDispatcher\EventDispatcherInterface:
+    alias: TYPO3\CMS\Core\EventDispatcher\EventDispatcher
+    public: true
+  Psr\Http\Message\RequestFactoryInterface:
+    alias: TYPO3\CMS\Core\Http\RequestFactory
+    public: true
+  Psr\Http\Message\ResponseFactoryInterface:
+    alias: TYPO3\CMS\Core\Http\ResponseFactory
+    public: true
+  Psr\Http\Message\ServerRequestFactoryInterface:
+    alias: TYPO3\CMS\Core\Http\ServerRequestFactory
+    public: true
+  Psr\Http\Message\StreamFactoryInterface:
+    alias: TYPO3\CMS\Core\Http\StreamFactory
+    public: true
+  Psr\Http\Message\UploadedFileFactoryInterface:
+    alias: TYPO3\CMS\Core\Http\UploadedFileFactory
+    public: true
+  Psr\Http\Message\UriFactoryInterface:
+    alias: TYPO3\CMS\Core\Http\UriFactory
+    public: true
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-89018-ProvideImplementationForPSR-17HTTPMessageFactories.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-89018-ProvideImplementationForPSR-17HTTPMessageFactories.rst
new file mode 100644 (file)
index 0000000..81047bb
--- /dev/null
@@ -0,0 +1,81 @@
+.. include:: ../../Includes.txt
+
+==========================================================================
+Feature: #89018 - Provide implementation for PSR-17 HTTP Message Factories
+==========================================================================
+
+See :issue:`89018`
+
+Description
+===========
+
+Support for PSR-17_ HTTP Message Factories has been added.
+
+PSR-17 HTTP Factories are intended to be used by PSR-15_ request handlers in order to create PSR-7_
+compatible message objects.
+
+PSR-17 consists of six factory interfaces:
+
+- :php:`Psr\Http\Message\RequestFactoryInterface:`
+- :php:`Psr\Http\Message\ResponseFactoryInterface:`
+- :php:`Psr\Http\Message\ServerRequestFactoryInterface:`
+- :php:`Psr\Http\Message\StreamFactoryInterface:`
+- :php:`Psr\Http\Message\UploadedFileFactoryInterface:`
+- :php:`Psr\Http\Message\UriFactoryInterface:`
+
+Request handlers shall use dependency injection to use any of the available PSR-17 HTTP Factory interfaces.
+
+
+Impact
+======
+
+PSR-17 HTTP Fatory interfaces are provided by `psr/http-factory` and should be used as
+dependencies for PSR-15 request handlers or services that need to create PSR-7 message objects.
+
+It is discouraged to explicitly create PSR-7 instances of classes from the :php:`TYPO3\CMS\Core\Http`
+namespace (they are not public API). Use type declarations against PSR-17 HTTP Message Factory interfaces
+and dependency injection instead.
+
+Example usage
+-------------
+
+A middleware that needs to send a JSON response when a certain condition is met, uses the
+PSR-17 response factory interface (the concrete TYPO3 implementation is injected as constructor
+dependency) to create a new PSR-7 response object:
+
+.. code-block:: php
+
+    use Psr\Http\Message\ResponseFactoryInterface;
+    use Psr\Http\Message\ResponseInterface;
+    use Psr\Http\Message\ServerRequestInterface;
+    use Psr\Http\Server\MiddlewareInterface;
+    use Psr\Http\Server\RequestHandlerInterface;
+
+    class StatusCheckMiddleware implements MiddlewareInterface
+    {
+        /** @var ResponseFactoryInterface */
+        private $responseFactory;
+
+        public function __construct(ResponseFactoryInterface $responseFactory)
+        {
+            $this->responseFactory = $responseFactory;
+        }
+
+        public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+        {
+            if ($request->getRequestTarget() === '/check') {
+                $data = ['status' => 'ok'];
+                $response = $this->responseFactory->createResponse()
+                    ->withHeader('Content-Type', 'application/json; charset=utf-8');
+                $response->getBody()->write(json_encode($data));
+                return $response;
+            }
+            return $handler->handle($request);
+        }
+    }
+
+.. _PSR-17: https://www.php-fig.org/psr/psr-17/
+.. _PSR-15: https://www.php-fig.org/psr/psr-15/
+.. _PSR-7: https://www.php-fig.org/psr/psr-7/
+
+.. index:: PHP-API, ext:core
diff --git a/typo3/sysext/core/Tests/Unit/Http/RequestFactoryTest.php b/typo3/sysext/core/Tests/Unit/Http/RequestFactoryTest.php
new file mode 100644 (file)
index 0000000..ead236c
--- /dev/null
@@ -0,0 +1,104 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\Tests\Unit\Http;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\RequestFactoryInterface;
+use Psr\Http\Message\RequestInterface;
+use TYPO3\CMS\Core\Http\RequestFactory;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+/**
+ * Testcase for \TYPO3\CMS\Core\Http\RequestFactory
+ */
+class RequestFactoryTest extends UnitTestCase
+{
+    /**
+     * @test
+     */
+    public function implementsPsr17FactoryInterface()
+    {
+        $factory = new RequestFactory();
+        $this->assertInstanceOf(RequestFactoryInterface::class, $factory);
+    }
+
+    /**
+     * @test
+     */
+    public function testRequestHasMethodSet()
+    {
+        $factory = new RequestFactory();
+        $request = $factory->createRequest('POST', '/');
+        $this->assertSame('POST', $request->getMethod());
+    }
+
+    /**
+     * @test
+     */
+    public function testRequestFactoryHasAWritableEmptyBody()
+    {
+        $factory = new RequestFactory();
+        $request = $factory->createRequest('GET', '/');
+        $body = $request->getBody();
+
+        $this->assertInstanceOf(RequestInterface::class, $request);
+
+        $this->assertSame('', $body->__toString());
+        $this->assertSame(0, $body->getSize());
+        $this->assertTrue($body->isSeekable());
+
+        $body->write('Foo');
+        $this->assertSame(3, $body->getSize());
+        $this->assertSame('Foo', $body->__toString());
+    }
+
+    /**
+     * @return array
+     */
+    public function invalidRequestUriDataProvider()
+    {
+        return [
+            'true'     => [true],
+            'false'    => [false],
+            'int'      => [1],
+            'float'    => [1.1],
+            'array'    => [['http://example.com']],
+            'stdClass' => [(object)['href' => 'http://example.com']],
+        ];
+    }
+
+    /**
+     * @dataProvider invalidRequestUriDataProvider
+     * @test
+     */
+    public function constructorRaisesExceptionForInvalidUri($uri)
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1436717272);
+        $factory = new RequestFactory();
+        $factory->createRequest('GET', $uri);
+    }
+
+    /**
+     * @test
+     */
+    public function raisesExceptionForInvalidMethod()
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1436717275);
+        $factory = new RequestFactory();
+        $factory->createRequest('BOGUS-BODY', '/');
+    }
+}
diff --git a/typo3/sysext/core/Tests/Unit/Http/ResponseFactoryTest.php b/typo3/sysext/core/Tests/Unit/Http/ResponseFactoryTest.php
new file mode 100644 (file)
index 0000000..159eb90
--- /dev/null
@@ -0,0 +1,75 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\Tests\Unit\Http;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\ResponseFactoryInterface;
+use TYPO3\CMS\Core\Http\ResponseFactory;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+/**
+ * Testcase for \TYPO3\CMS\Core\Http\ResponseFactory
+ */
+class ResponseFactoryTest extends UnitTestCase
+{
+    /**
+     * @test
+     */
+    public function implementsPsr17FactoryInterface()
+    {
+        $factory = new ResponseFactory();
+        $this->assertInstanceOf(ResponseFactoryInterface::class, $factory);
+    }
+
+    /**
+     * @test
+     */
+    public function testResponseHasStatusCode200ByDefault()
+    {
+        $factory = new ResponseFactory();
+        $response = $factory->createResponse();
+        $this->assertSame(200, $response->getStatusCode());
+    }
+
+    /**
+     * @test
+     */
+    public function testResponseHasStatusCodeSet()
+    {
+        $factory = new ResponseFactory();
+        $response = $factory->createResponse(201);
+        $this->assertSame(201, $response->getStatusCode());
+    }
+
+    /**
+     * @test
+     */
+    public function testResponseHasDefaultReasonPhrase()
+    {
+        $factory = new ResponseFactory();
+        $response = $factory->createResponse(301);
+        $this->assertSame('Moved Permanently', $response->getReasonPhrase());
+    }
+
+    /**
+     * @test
+     */
+    public function testResponseHasCustomReasonPhrase()
+    {
+        $factory = new ResponseFactory();
+        $response = $factory->createResponse(201, 'custom message');
+        $this->assertSame('custom message', $response->getReasonPhrase());
+    }
+}
index 75e6a3d..35fce0a 100644 (file)
@@ -15,6 +15,8 @@ namespace TYPO3\CMS\Core\Tests\Unit\Http;
  * The TYPO3 project - inspiring people to share!
  */
 
+use Psr\Http\Message\ServerRequestFactoryInterface;
+use Psr\Http\Message\ServerRequestInterface;
 use TYPO3\CMS\Core\Http\ServerRequestFactory;
 use TYPO3\CMS\Core\Http\UploadedFile;
 use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
@@ -27,6 +29,83 @@ class ServerRequestFactoryTest extends UnitTestCase
     /**
      * @test
      */
+    public function implementsPsr17FactoryInterface()
+    {
+        $factory = new ServerRequestFactory();
+        $this->assertInstanceOf(ServerRequestFactoryInterface::class, $factory);
+    }
+
+    /**
+     * @test
+     */
+    public function testServerRequestHasMethodSet()
+    {
+        $factory = new ServerRequestFactory();
+        $request = $factory->createServerRequest('POST', '/');
+        $this->assertSame('POST', $request->getMethod());
+    }
+
+    /**
+     * @test
+     */
+    public function testServerRequestFactoryHasAWritableEmptyBody()
+    {
+        $factory = new ServerRequestFactory();
+        $request = $factory->createServerRequest('GET', '/');
+        $body = $request->getBody();
+
+        $this->assertInstanceOf(ServerRequestInterface::class, $request);
+
+        $this->assertSame('', $body->__toString());
+        $this->assertSame(0, $body->getSize());
+        $this->assertTrue($body->isSeekable());
+
+        $body->write('Foo');
+        $this->assertSame(3, $body->getSize());
+        $this->assertSame('Foo', $body->__toString());
+    }
+
+    /**
+     * @return array
+     */
+    public function invalidRequestUriDataProvider()
+    {
+        return [
+            'true'     => [true],
+            'false'    => [false],
+            'int'      => [1],
+            'float'    => [1.1],
+            'array'    => [['http://example.com']],
+            'stdClass' => [(object)['href' => 'http://example.com']],
+        ];
+    }
+
+    /**
+     * @dataProvider invalidRequestUriDataProvider
+     * @test
+     */
+    public function constructorRaisesExceptionForInvalidUri($uri)
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1436717272);
+        $factory = new ServerRequestFactory();
+        $factory->createServerRequest('GET', $uri);
+    }
+
+    /**
+     * @test
+     */
+    public function raisesExceptionForInvalidMethod()
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1436717275);
+        $factory = new ServerRequestFactory();
+        $factory->createServerRequest('BOGUS-BODY', '/');
+    }
+
+    /**
+     * @test
+     */
     public function uploadedFilesAreNormalizedFromFilesSuperGlobal()
     {
         $_SERVER['HTTP_HOST'] = 'localhost';
diff --git a/typo3/sysext/core/Tests/Unit/Http/StreamFactoryTest.php b/typo3/sysext/core/Tests/Unit/Http/StreamFactoryTest.php
new file mode 100644 (file)
index 0000000..d324cfd
--- /dev/null
@@ -0,0 +1,171 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\Tests\Unit\Http;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\StreamFactoryInterface;
+use TYPO3\CMS\Core\Core\Environment;
+use TYPO3\CMS\Core\Http\StreamFactory;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+/**
+ * Testcase for \TYPO3\CMS\Core\Http\StreamFactory
+ */
+class StreamFactoryTest extends UnitTestCase
+{
+    /**
+     * @test
+     */
+    public function implementsPsr17FactoryInterface()
+    {
+        $factory = new StreamFactory();
+        $this->assertInstanceOf(StreamFactoryInterface::class, $factory);
+    }
+
+    /**
+     * @test
+     */
+    public function testCreateStreamReturnsEmptyStreamByDefault()
+    {
+        $factory = new StreamFactory();
+        $stream = $factory->createStream();
+        $this->assertSame('', $stream->__toString());
+    }
+
+    /**
+     * @test
+     */
+    public function testCreateStreamFromEmptyString()
+    {
+        $factory = new StreamFactory();
+        $stream = $factory->createStream('');
+        $this->assertSame('', $stream->__toString());
+    }
+
+    /**
+     * @test
+     */
+    public function testCreateStreamFromNonEmptyString()
+    {
+        $factory = new StreamFactory();
+        $stream = $factory->createStream('Foo');
+        $this->assertSame('Foo', $stream->__toString());
+    }
+
+    /**
+     * @test
+     */
+    public function testCreateStreamReturnsWritableStream()
+    {
+        $factory = new StreamFactory();
+        $stream = $factory->createStream();
+        $stream->write('Foo');
+        $this->assertSame('Foo', $stream->__toString());
+    }
+
+    /**
+     * @test
+     */
+    public function testCreateStreamReturnsAppendableStream()
+    {
+        $factory = new StreamFactory();
+        $stream = $factory->createStream('Foo');
+        $stream->write('Bar');
+        $this->assertSame('FooBar', $stream->__toString());
+    }
+
+    /**
+     * @test
+     */
+    public function testCreateStreamFromFile()
+    {
+        $fileName = Environment::getVarPath() . '/tests/' . $this->getUniqueId('test_');
+        file_put_contents($fileName, 'Foo');
+
+        $factory = new StreamFactory();
+        $stream = $factory->createStreamFromFile($fileName);
+        $this->assertSame('Foo', $stream->__toString());
+    }
+
+    /**
+     * @test
+     */
+    public function testCreateStreamFromFileWithMode()
+    {
+        $fileName = Environment::getVarPath() . '/tests/' . $this->getUniqueId('test_');
+
+        $factory = new StreamFactory();
+        $stream = $factory->createStreamFromFile($fileName, 'w');
+        $stream->write('Foo');
+
+        $contents = file_get_contents($fileName);
+        $this->assertSame('Foo', $contents);
+    }
+
+    /**
+     * @test
+     */
+    public function testCreateStreamFromFileWithInvalidMode()
+    {
+        $fileName = Environment::getVarPath() . '/tests/' . $this->getUniqueId('test_');
+        touch($fileName);
+
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1566823434);
+        $factory = new StreamFactory();
+        $factory->createStreamFromFile($fileName, 'z');
+    }
+
+    /**
+     * @test
+     */
+    public function testCreateStreamFromFileWithMissingFile()
+    {
+        $unavailableFileName = Environment::getVarPath() . '/tests/' . $this->getUniqueId('test_');
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionCode(1566823435);
+        $factory = new StreamFactory();
+        $factory->createStreamFromFile($unavailableFileName, 'r');
+    }
+
+    /**
+     * @test
+     */
+    public function testCreateStreamFromResource()
+    {
+        $fileName = Environment::getVarPath() . '/tests/' . $this->getUniqueId('test_');
+        touch($fileName);
+        file_put_contents($fileName, 'Foo');
+
+        $resource = fopen($fileName, 'r');
+
+        $factory = new StreamFactory();
+        $stream = $factory->createStreamFromResource($resource);
+        $this->assertSame('Foo', $stream->__toString());
+    }
+
+    /**
+     * @test
+     */
+    public function testCreateStreamResourceFromInvalidResource()
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1566853697);
+        $resource = xml_parser_create();
+
+        $factory = new StreamFactory();
+        $factory->createStreamFromResource($resource);
+    }
+}
diff --git a/typo3/sysext/core/Tests/Unit/Http/UploadedFileFactoryTest.php b/typo3/sysext/core/Tests/Unit/Http/UploadedFileFactoryTest.php
new file mode 100644 (file)
index 0000000..8f929a4
--- /dev/null
@@ -0,0 +1,98 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\Tests\Unit\Http;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\StreamInterface;
+use Psr\Http\Message\UploadedFileFactoryInterface;
+use Psr\Http\Message\UploadedFileInterface;
+use TYPO3\CMS\Core\Http\UploadedFileFactory;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+/**
+ * Testcase for \TYPO3\CMS\Core\Http\UploadedFileFactory
+ */
+class UploadedFileFactoryTest extends UnitTestCase
+{
+    /**
+     * @test
+     */
+    public function implementsPsr17FactoryInterface()
+    {
+        $factory = new UploadedFileFactory();
+        $this->assertInstanceOf(UploadedFileFactoryInterface::class, $factory);
+    }
+
+    /**
+     * @test
+     */
+    public function testCreateUploadedFile()
+    {
+        $streamProphecy = $this->prophesize(StreamInterface::class);
+        $factory = new UploadedFileFactory();
+        $uploadedFile = $factory->createUploadedFile($streamProphecy->reveal(), 0);
+
+        $this->assertInstanceOf(UploadedFileInterface::class, $uploadedFile);
+        $this->assertSame(UPLOAD_ERR_OK, $uploadedFile->getError());
+        $this->assertNull($uploadedFile->getClientFileName());
+        $this->assertNull($uploadedFile->getClientMediaType());
+    }
+
+    /**
+     * @test
+     */
+    public function testCreateUploadedFileWithParams()
+    {
+        $streamProphecy = $this->prophesize(StreamInterface::class);
+        $factory = new UploadedFileFactory();
+        $uploadedFile = $factory->createUploadedFile($streamProphecy->reveal(), 0, UPLOAD_ERR_NO_FILE, 'filename.html', 'text/html');
+
+        $this->assertInstanceOf(UploadedFileInterface::class, $uploadedFile);
+        $this->assertSame(UPLOAD_ERR_NO_FILE, $uploadedFile->getError());
+        $this->assertSame('filename.html', $uploadedFile->getClientFileName());
+        $this->assertSame('text/html', $uploadedFile->getClientMediaType());
+    }
+
+    /**
+     * @test
+     */
+    public function testCreateUploadedFileCreateSizeFromStreamSize()
+    {
+        $streamProphecy = $this->prophesize(StreamInterface::class);
+        $streamProphecy->getSize()->willReturn(5);
+
+        $factory = new UploadedFileFactory();
+        $uploadedFile = $factory->createUploadedFile($streamProphecy->reveal());
+
+        $this->assertSame(5, $uploadedFile->getSize());
+    }
+
+    /**
+     * @test
+     */
+    public function testCreateUploadedFileThrowsExceptionWhenStreamSizeCanNotBeDetermined()
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1566823423);
+
+        $streamProphecy = $this->prophesize(StreamInterface::class);
+        $streamProphecy->getSize()->willReturn(null);
+
+        $factory = new UploadedFileFactory();
+        $uploadedFile = $factory->createUploadedFile($streamProphecy->reveal());
+
+        $this->assertSame(3, $uploadedFile->getSize());
+    }
+}
diff --git a/typo3/sysext/core/Tests/Unit/Http/UriFactoryTest.php b/typo3/sysext/core/Tests/Unit/Http/UriFactoryTest.php
new file mode 100644 (file)
index 0000000..2cccf45
--- /dev/null
@@ -0,0 +1,52 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\Tests\Unit\Http;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\UriFactoryInterface;
+use Psr\Http\Message\UriInterface;
+use TYPO3\CMS\Core\Http\UriFactory;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+/**
+ * Testcase for \TYPO3\CMS\Core\Http\UriFactory
+ */
+class UriFactoryTest extends UnitTestCase
+{
+    /**
+     * @test
+     */
+    public function implementsPsr17FactoryInterface()
+    {
+        $factory = new UriFactory();
+        $this->assertInstanceOf(UriFactoryInterface::class, $factory);
+    }
+
+    /**
+     * @test
+     */
+    public function testUriIsCreated()
+    {
+        $factory = new UriFactory();
+        $uri = $factory->createUri('https://user:pass@domain.localhost:3000/path?query');
+
+        $this->assertInstanceOf(UriInterface::class, $uri);
+        $this->assertSame('user:pass', $uri->getUserInfo());
+        $this->assertSame('domain.localhost', $uri->getHost());
+        $this->assertSame(3000, $uri->getPort());
+        $this->assertSame('/path', $uri->getPath());
+        $this->assertSame('query', $uri->getQuery());
+    }
+}
index f1d2737..2149bc8 100644 (file)
@@ -28,6 +28,7 @@
                "nikic/php-parser": "^4.2",
                "psr/container": "^1.0",
                "psr/event-dispatcher": "^1.0",
+               "psr/http-factory": "^1.0",
                "psr/http-message": "~1.0",
                "psr/http-server-handler": "^1.0",
                "psr/http-server-middleware": "^1.0",
                "typo3/cms-saltedpasswords": "*",
                "typo3/cms-sv": "*"
        },
+       "provide": {
+               "psr/http-factory-implementation": "1.0",
+               "psr/http-message-implementation": "1.0"
+       },
        "extra": {
                "branch-alias": {
                        "dev-master": "10.1.x-dev"