diff --git a/.github/workflows/installation.yml b/.github/workflows/installation.yml index 25a563a..a9e49ee 100644 --- a/.github/workflows/installation.yml +++ b/.github/workflows/installation.yml @@ -92,3 +92,10 @@ jobs: - name: Check Install run: | tests/install.sh ${{ matrix.expect }} "${{ matrix.method }}" "${{ matrix.requirements }}" + + - name: Run Tests + run: | + composer remove --dev --no-update php-http/httplug php-http/message-factory + composer require --dev ${{ matrix.requirements }} + vendor/bin/simple-phpunit + if: "matrix.expect == 'will-find' && matrix.method != 'Http\\Discovery\\HttpClientDiscovery::find();' && matrix.method != 'Http\\Discovery\\HttpAsyncClientDiscovery::find();' && matrix.pecl != 'psr-1.0.0, phalcon-4.0.6'" diff --git a/CHANGELOG.md b/CHANGELOG.md index a4f3840..ce3c592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## 1.15.0 - 2023-01-XX + +- [#209](https://github.com/php-http/discovery/pull/209) - Add generic `Psr17Factory` class + ## 1.14.3 - 2022-07-11 - [#207](https://github.com/php-http/discovery/pull/207) - Updates Exception to extend Throwable solving static analysis errors for consumers diff --git a/src/Psr17Factory.php b/src/Psr17Factory.php new file mode 100644 index 0000000..d61a4cc --- /dev/null +++ b/src/Psr17Factory.php @@ -0,0 +1,282 @@ + + * Copyright (c) 2015 Michael Dowling + * Copyright (c) 2015 Márk Sági-Kazár + * Copyright (c) 2015 Graham Campbell + * Copyright (c) 2016 Tobias Schultze + * Copyright (c) 2016 George Mponos + * Copyright (c) 2016-2018 Tobias Nyholm + * + * @author Nicolas Grekas + */ +class Psr17Factory implements RequestFactoryInterface, ResponseFactoryInterface, ServerRequestFactoryInterface, StreamFactoryInterface, UploadedFileFactoryInterface, UriFactoryInterface +{ + private $requestFactory; + private $responseFactory; + private $serverRequestFactory; + private $streamFactory; + private $uploadedFileFactory; + private $uriFactory; + + public function __construct( + RequestFactoryInterface $requestFactory = null, + ResponseFactoryInterface $responseFactory = null, + ServerRequestFactoryInterface $serverRequestFactory = null, + StreamFactoryInterface $streamFactory = null, + UploadedFileFactoryInterface $uploadedFileFactory = null, + UriFactoryInterface $uriFactory = null + ) { + $this->requestFactory = $requestFactory; + $this->responseFactory = $responseFactory; + $this->serverRequestFactory = $serverRequestFactory; + $this->streamFactory = $streamFactory; + $this->uploadedFileFactory = $uploadedFileFactory; + $this->uriFactory = $uriFactory; + + $this->setFactory($requestFactory); + $this->setFactory($responseFactory); + $this->setFactory($serverRequestFactory); + $this->setFactory($streamFactory); + $this->setFactory($uploadedFileFactory); + $this->setFactory($uriFactory); + } + + /** + * @param UriInterface|string $uri + */ + public function createRequest(string $method, $uri): RequestInterface + { + $factory = $this->requestFactory ?? $this->setFactory(Psr17FactoryDiscovery::findRequestFactory()); + + return $factory->createRequest(...\func_get_args()); + } + + public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface + { + $factory = $this->responseFactory ?? $this->setFactory(Psr17FactoryDiscovery::findResponseFactory()); + + return $factory->createResponse(...\func_get_args()); + } + + /** + * @param UriInterface|string $uri + */ + public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface + { + $factory = $this->serverRequestFactory ?? $this->setFactory(Psr17FactoryDiscovery::findServerRequestFactory()); + + return $factory->createServerRequest(...\func_get_args()); + } + + public function createServerRequestFromGlobals(array $server = null, array $get = null, array $post = null, array $cookie = null, array $files = null, StreamInterface $body = null): ServerRequestInterface + { + $server = $server ?? $_SERVER; + $request = $this->createServerRequest($server['REQUEST_METHOD'] ?? 'GET', $this->createUriFromGlobals($server), $server); + + return $this->buildServerRequestFromGlobals($request, $server, $files ?? $_FILES) + ->withQueryParams($get ?? $_GET) + ->withParsedBody($post ?? $_POST) + ->withCookieParams($cookie ?? $_COOKIE) + ->withBody($body ?? $this->createStreamFromFile('php://input', 'r+')); + } + + public function createStream(string $content = ''): StreamInterface + { + $factory = $this->streamFactory ?? $this->setFactory(Psr17FactoryDiscovery::findStreamFactory()); + + return $factory->createStream($content); + } + + public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface + { + $factory = $this->streamFactory ?? $this->setFactory(Psr17FactoryDiscovery::findStreamFactory()); + + return $factory->createStreamFromFile($filename, $mode); + } + + /** + * @param resource $resource + */ + public function createStreamFromResource($resource): StreamInterface + { + $factory = $this->streamFactory ?? $this->setFactory(Psr17FactoryDiscovery::findStreamFactory()); + + return $factory->createStreamFromResource($resource); + } + + public function createUploadedFile(StreamInterface $stream, int $size = null, int $error = \UPLOAD_ERR_OK, string $clientFilename = null, string $clientMediaType = null): UploadedFileInterface + { + $factory = $this->uploadedFileFactory ?? $this->setFactory(Psr17FactoryDiscovery::findUploadedFileFactory()); + + return $factory->createUploadedFile(...\func_get_args()); + } + + public function createUri(string $uri = ''): UriInterface + { + $factory = $this->uriFactory ?? $this->setFactory(Psr17FactoryDiscovery::findUriFactory()); + + return $factory->createUri(...\func_get_args()); + } + + public function createUriFromGlobals(array $server = null): UriInterface + { + return $this->buildUriFromGlobals($this->createUri(''), $server ?? $_SERVER); + } + + private function setFactory($factory) + { + if (!$this->requestFactory && $factory instanceof RequestFactoryInterface) { + $this->requestFactory = $factory; + } + if (!$this->responseFactory && $factory instanceof ResponseFactoryInterface) { + $this->responseFactory = $factory; + } + if (!$this->serverRequestFactory && $factory instanceof ServerRequestFactoryInterface) { + $this->serverRequestFactory = $factory; + } + if (!$this->streamFactory && $factory instanceof StreamFactoryInterface) { + $this->streamFactory = $factory; + } + if (!$this->uploadedFileFactory && $factory instanceof UploadedFileFactoryInterface) { + $this->uploadedFileFactory = $factory; + } + if (!$this->uriFactory && $factory instanceof UriFactoryInterface) { + $this->uriFactory = $factory; + } + + return $factory; + } + + private function buildServerRequestFromGlobals(ServerRequestInterface $request, array $server, array $files): ServerRequestInterface + { + $request = $request + ->withProtocolVersion(isset($server['SERVER_PROTOCOL']) ? str_replace('HTTP/', '', $server['SERVER_PROTOCOL']) : '1.1') + ->withUploadedFiles($this->normalizeFiles($files)); + + $headers = []; + foreach ($server as $key => $value) { + if (0 === strpos($key, 'HTTP_')) { + $key = substr($key, 5); + } elseif (!\in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'], true)) { + continue; + } + $key = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', $key)))); + + $headers[$key] = $value; + } + + if (!isset($headers['Authorization'])) { + if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) { + $headers['Authorization'] = $_SERVER['REDIRECT_HTTP_AUTHORIZATION']; + } elseif (isset($_SERVER['PHP_AUTH_USER'])) { + $headers['Authorization'] = 'Basic '.base64_encode($_SERVER['PHP_AUTH_USER'].':'.($_SERVER['PHP_AUTH_PW'] ?? '')); + } elseif (isset($_SERVER['PHP_AUTH_DIGEST'])) { + $headers['Authorization'] = $_SERVER['PHP_AUTH_DIGEST']; + } + } + + foreach ($headers as $key => $value) { + try { + $request = $request->withHeader($key, $value); + } catch (\InvalidArgumentException $e) { + // ignore invalid headers + } + } + + return $request; + } + + private function buildUriFromGlobals(UriInterface $uri, array $server): UriInterface + { + $uri = $uri->withScheme(!empty($server['HTTPS']) && 'off' !== strtolower($server['HTTPS']) ? 'https' : 'http'); + + $hasPort = false; + if (isset($server['HTTP_HOST'])) { + $parts = parse_url('http://'.$server['HTTP_HOST']); + + $uri = $uri->withHost($parts['host'] ?? 'localhost'); + + if ($parts['port'] ?? false) { + $hasPort = true; + $uri = $uri->withPort($parts['port']); + } + } else { + $uri = $uri->withHost($server['SERVER_NAME'] ?? $server['SERVER_ADDR'] ?? 'localhost'); + } + + if (!$hasPort && isset($server['SERVER_PORT'])) { + $uri = $uri->withPort($server['SERVER_PORT']); + } + + $hasQuery = false; + if (isset($server['REQUEST_URI'])) { + $requestUriParts = explode('?', $server['REQUEST_URI'], 2); + $uri = $uri->withPath($requestUriParts[0]); + if (isset($requestUriParts[1])) { + $hasQuery = true; + $uri = $uri->withQuery($requestUriParts[1]); + } + } + + if (!$hasQuery && isset($server['QUERY_STRING'])) { + $uri = $uri->withQuery($server['QUERY_STRING']); + } + + return $uri; + } + + private function normalizeFiles(array $files): array + { + $normalized = []; + + foreach ($files as $key => $value) { + if ($value instanceof UploadedFileInterface) { + $normalized[$key] = $value; + } elseif (!\is_array($value)) { + continue; + } elseif (!isset($value['tmp_name'])) { + $normalized[$key] = $this->normalizeFiles($value); + } elseif (\is_array($value['tmp_name'])) { + foreach ($value['tmp_name'] as $k => $v) { + $file = $this->createStreamFromFile($value['tmp_name'][$k], 'r'); + $normalized[$key][$k] = $this->createUploadedFile($file, $value['size'][$k], $value['error'][$k], $value['name'][$k], $value['type'][$k]); + } + } else { + $file = $this->createStreamFromFile($value['tmp_name'], 'r'); + $normalized[$key] = $this->createUploadedFile($file, $value['size'], $value['error'], $value['name'], $value['type']); + } + } + + return $normalized; + } +} diff --git a/tests/HttpClientDiscoveryTest.php b/tests/HttpClientDiscoveryTest.php index 03e22c5..ca73356 100644 --- a/tests/HttpClientDiscoveryTest.php +++ b/tests/HttpClientDiscoveryTest.php @@ -10,6 +10,10 @@ class HttpClientDiscoveryTest extends TestCase { public function testFind() { + if (!interface_exists(HttpClient::class)) { + $this->markTestSkipped(HttpClient::class.' required.'); + } + $client = HttpClientDiscovery::find(); $this->assertInstanceOf(HttpClient::class, $client); } diff --git a/tests/Psr17FactoryDiscoveryTest.php b/tests/Psr17FactoryDiscoveryTest.php index aaa9917..cc5c9ca 100644 --- a/tests/Psr17FactoryDiscoveryTest.php +++ b/tests/Psr17FactoryDiscoveryTest.php @@ -19,6 +19,10 @@ class Psr17FactoryDiscoveryTest extends TestCase */ public function testFind($method, $interface) { + if (!interface_exists(RequestFactoryInterface::class)) { + $this->markTestSkipped(RequestFactoryInterface::class.' required.'); + } + $callable = [Psr17FactoryDiscovery::class, $method]; $client = $callable(); $this->assertInstanceOf($interface, $client); diff --git a/tests/Psr17FactoryTest.php b/tests/Psr17FactoryTest.php new file mode 100644 index 0000000..a987faa --- /dev/null +++ b/tests/Psr17FactoryTest.php @@ -0,0 +1,325 @@ +markTestSkipped(RequestFactoryInterface::class.' required.'); + } + } + + public function testRequest() + { + $request = (new Psr17Factory())->createRequest('GET', '/foo'); + + $this->assertInstanceOf(RequestInterface::class, $request); + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('/foo', (string) $request->getUri()); + } + + public function testRequestUri() + { + $factory = new Psr17Factory(); + $request = $factory->createRequest('GET', $factory->createUri('/foo')); + + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('/foo', (string) $request->getUri()); + } + + public function testResponse() + { + $response = (new Psr17Factory())->createResponse(202); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(202, $response->getStatusCode()); + } + + public function testReasonPhrase() + { + $response = (new Psr17Factory())->createResponse(202, 'Hello'); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('Hello', $response->getReasonPhrase()); + } + + public function testServerRequest() + { + $request = (new Psr17Factory())->createServerRequest('GET', '/foo'); + + $this->assertInstanceOf(ServerRequestInterface::class, $request); + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('/foo', (string) $request->getUri()); + } + + public function testServerRequestUri() + { + $factory = new Psr17Factory(); + $request = $factory->createServerRequest('GET', $factory->createUri('/foo')); + + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('/foo', (string) $request->getUri()); + } + + public function testServerParam() + { + $request = (new Psr17Factory())->createServerRequest('POST', '/foo', ['FOO' => 'bar']); + + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('/foo', (string) $request->getUri()); + $this->assertSame(['FOO' => 'bar'], $request->getServerParams()); + } + + public function testStreamString() + { + $stream = (new Psr17Factory())->createStream('Hello'); + + $this->assertInstanceOf(StreamInterface::class, $stream); + $this->assertSame('Hello', (string) $stream); + } + + public function testStreamFile() + { + $stream = (new Psr17Factory())->createStreamFromFile(__FILE__, 'r'); + + $this->assertStringEqualsFile(__FILE__, (string) $stream); + } + + public function testStreamResource() + { + $stream = (new Psr17Factory())->createStreamFromResource(fopen(__FILE__, 'r')); + + $this->assertStringEqualsFile(__FILE__, (string) $stream); + } + + public function testUploadedFile() + { + $factory = new Psr17Factory(); + $file = $factory->createUploadedFile($factory->createStream('Hello'), null, \UPLOAD_ERR_PARTIAL, 'client.name', 'client/type'); + + $this->assertInstanceOf(UploadedFileInterface::class, $file); + $this->assertSame(5, $file->getSize()); + $this->assertSame(\UPLOAD_ERR_PARTIAL, $file->getError()); + $this->assertSame('client.name', $file->getClientFilename()); + $this->assertSame('client/type', $file->getClientMediaType()); + } + + public function testUri() + { + $uri = (new Psr17Factory())->createUri('/hello'); + + $this->assertInstanceOf(UriInterface::class, $uri); + $this->assertSame('/hello', (string) $uri); + } + + public function testUriEmpty() + { + $uri = (new Psr17Factory())->createUri(); + + $this->assertSame('', $uri->getPath()); + } + + // The methods below come from the guzzlehttp/psr7 package and are subject to the following notice: + // + // Copyright (c) 2015 Michael Dowling, https://github.com/mtdowling + // + // Permission is hereby granted, free of charge, to any person obtaining a copy + // of this software and associated documentation files (the "Software"), to deal + // in the Software without restriction, including without limitation the rights + // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + // copies of the Software, and to permit persons to whom the Software is + // furnished to do so, subject to the following conditions: + // + // The above copyright notice and this permission notice shall be included in + // all copies or substantial portions of the Software. + // + // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + // THE SOFTWARE. + + public static function dataGetUriFromGlobals(): iterable + { + $server = [ + 'REQUEST_URI' => '/blog/article.php?id=10&user=foo', + 'SERVER_PORT' => '443', + 'SERVER_ADDR' => '217.112.82.20', + 'SERVER_NAME' => 'www.example.org', + 'SERVER_PROTOCOL' => 'HTTP/1.1', + 'REQUEST_METHOD' => 'POST', + 'QUERY_STRING' => 'id=10&user=foo', + 'DOCUMENT_ROOT' => '/path/to/your/server/root/', + 'HTTP_HOST' => 'www.example.org', + 'HTTPS' => 'on', + 'REMOTE_ADDR' => '193.60.168.69', + 'REMOTE_PORT' => '5390', + 'SCRIPT_NAME' => '/blog/article.php', + 'SCRIPT_FILENAME' => '/path/to/your/server/root/blog/article.php', + 'PHP_SELF' => '/blog/article.php', + ]; + + return [ + 'HTTPS request' => [ + 'https://www.example.org/blog/article.php?id=10&user=foo', + $server, + ], + 'HTTPS request with different on value' => [ + 'https://www.example.org/blog/article.php?id=10&user=foo', + array_merge($server, ['HTTPS' => '1']), + ], + 'HTTP request' => [ + 'http://www.example.org/blog/article.php?id=10&user=foo', + array_merge($server, ['HTTPS' => 'off', 'SERVER_PORT' => '80']), + ], + 'HTTP_HOST missing -> fallback to SERVER_NAME' => [ + 'https://www.example.org/blog/article.php?id=10&user=foo', + array_merge($server, ['HTTP_HOST' => null]), + ], + 'HTTP_HOST and SERVER_NAME missing -> fallback to SERVER_ADDR' => [ + 'https://217.112.82.20/blog/article.php?id=10&user=foo', + array_merge($server, ['HTTP_HOST' => null, 'SERVER_NAME' => null]), + ], + 'Query string with ?' => [ + 'https://www.example.org/path?continue=https://example.com/path?param=1', + array_merge($server, ['REQUEST_URI' => '/path?continue=https://example.com/path?param=1', 'QUERY_STRING' => '']), + ], + 'No query String' => [ + 'https://www.example.org/blog/article.php', + array_merge($server, ['REQUEST_URI' => '/blog/article.php', 'QUERY_STRING' => '']), + ], + 'Host header with port' => [ + 'https://www.example.org:8324/blog/article.php?id=10&user=foo', + array_merge($server, ['HTTP_HOST' => 'www.example.org:8324']), + ], + 'IPv6 local loopback address' => [ + 'https://[::1]:8000/blog/article.php?id=10&user=foo', + array_merge($server, ['HTTP_HOST' => '[::1]:8000']), + ], + 'Invalid host' => [ + 'https://localhost/blog/article.php?id=10&user=foo', + array_merge($server, ['HTTP_HOST' => 'a:b']), + ], + 'Different port with SERVER_PORT' => [ + 'https://www.example.org:8324/blog/article.php?id=10&user=foo', + array_merge($server, ['SERVER_PORT' => '8324']), + ], + 'REQUEST_URI missing query string' => [ + 'https://www.example.org/blog/article.php?id=10&user=foo', + array_merge($server, ['REQUEST_URI' => '/blog/article.php']), + ], + 'Empty server variable' => [ + 'http://localhost', + [], + ], + ]; + } + + /** + * @dataProvider dataGetUriFromGlobals + */ + public function testGetUriFromGlobals($expected, $serverParams) + { + $factory = new Psr17Factory(); + + self::assertEquals($factory->createUri($expected), $factory->createUriFromGlobals($serverParams)); + } + + public function testFromGlobals() + { + $server = [ + 'REQUEST_URI' => '/blog/article.php?id=10&user=foo', + 'SERVER_PORT' => '443', + 'SERVER_ADDR' => '217.112.82.20', + 'SERVER_NAME' => 'www.example.org', + 'SERVER_PROTOCOL' => 'HTTP/1.1', + 'REQUEST_METHOD' => 'POST', + 'QUERY_STRING' => 'id=10&user=foo', + 'DOCUMENT_ROOT' => '/path/to/your/server/root/', + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_HOST' => 'www.example.org', + 'HTTP_ACCEPT' => 'text/html', + 'HTTP_REFERRER' => 'https://example.com', + 'HTTP_USER_AGENT' => 'My User Agent', + 'HTTPS' => 'on', + 'REMOTE_ADDR' => '193.60.168.69', + 'REMOTE_PORT' => '5390', + 'SCRIPT_NAME' => '/blog/article.php', + 'SCRIPT_FILENAME' => '/path/to/your/server/root/blog/article.php', + 'PHP_SELF' => '/blog/article.php', + ]; + + $cookie = [ + 'logged-in' => 'yes!' + ]; + + $post = [ + 'name' => 'Pesho', + 'email' => 'pesho@example.com', + ]; + + $get = [ + 'id' => 10, + 'user' => 'foo', + ]; + + $files = [ + 'file' => [ + 'name' => 'MyFile.txt', + 'type' => 'text/plain', + 'tmp_name' => 'php://memory', + 'error' => UPLOAD_ERR_OK, + 'size' => 123, + ] + ]; + + $factory = new Psr17Factory(); + $server = $factory->createServerRequestFromGlobals($server, $get, $post, $cookie, $files); + + self::assertSame('POST', $server->getMethod()); + self::assertEquals([ + 'Host' => ['www.example.org'], + 'Content-Type' => ['text/plain'], + 'Accept' => ['text/html'], + 'Referrer' => ['https://example.com'], + 'User-Agent' => ['My User Agent'], + ], $server->getHeaders()); + self::assertSame('', (string) $server->getBody()); + self::assertSame('1.1', $server->getProtocolVersion()); + self::assertSame($cookie, $server->getCookieParams()); + self::assertSame($post, $server->getParsedBody()); + self::assertSame($get, $server->getQueryParams()); + + self::assertEquals( + $factory->createUri('https://www.example.org/blog/article.php?id=10&user=foo'), + $server->getUri() + ); + + $expectedFiles = [ + 'file' => $factory->createUploadedFile( + $server->getUploadedFiles()['file']->getStream(), + 123, + UPLOAD_ERR_OK, + 'MyFile.txt', + 'text/plain' + ), + ]; + + self::assertEquals($expectedFiles, $server->getUploadedFiles()); + } +} diff --git a/tests/Psr18ClientDiscoveryTest.php b/tests/Psr18ClientDiscoveryTest.php index 30657b8..aa17922 100644 --- a/tests/Psr18ClientDiscoveryTest.php +++ b/tests/Psr18ClientDiscoveryTest.php @@ -11,6 +11,10 @@ class Psr18ClientDiscoveryTest extends TestCase { public function testFind() { + if (!interface_exists(ClientInterface::class)) { + $this->markTestSkipped(ClientInterface::class.' required.'); + } + $client = Psr18ClientDiscovery::find(); $this->assertInstanceOf(ClientInterface::class, $client); }