diff --git a/src/Body/PsrStreamBody.php b/src/Body/PsrStreamBody.php new file mode 100644 index 00000000..a4b1f372 --- /dev/null +++ b/src/Body/PsrStreamBody.php @@ -0,0 +1,41 @@ +stream = $stream; + } + + public function getBodyLength(): Promise + { + return new Success($this->stream->getSize() ?? -1); + } + + public function getHeaders(): Promise + { + return new Success([]); + } + + public function createBodyStream(): InputStream + { + return new PsrInputStream($this->stream); + } +} diff --git a/src/Internal/PsrInputStream.php b/src/Internal/PsrInputStream.php new file mode 100644 index 00000000..f1214460 --- /dev/null +++ b/src/Internal/PsrInputStream.php @@ -0,0 +1,63 @@ +stream = $stream; + $this->chunkSize = $chunkSize; + } + + public function read(): Promise + { + if (!$this->stream->isReadable()) { + return new Success(); + } + if ($this->tryRewind) { + $this->tryRewind = false; + if ($this->stream->isSeekable()) { + $this->stream->rewind(); + } + } + if ($this->stream->eof()) { + return new Success(); + } + + $data = $this->stream->read($this->chunkSize); + + return new Success($data); + } +} diff --git a/src/Internal/PsrRequestStream.php b/src/Internal/PsrRequestStream.php new file mode 100644 index 00000000..3e078a28 --- /dev/null +++ b/src/Internal/PsrRequestStream.php @@ -0,0 +1,193 @@ +stream = $stream; + $this->timeout = $timeout; + } + + public function __toString() + { + try { + return $this->getContents(); + } catch (\Throwable $e) { + return ''; + } + } + + public function close(): void + { + unset($this->stream); + } + + public function eof(): bool + { + return $this->isEof; + } + + public function tell() + { + throw new \RuntimeException("Source stream is not seekable"); + } + + /** + * @return null + */ + public function getSize() + { + return null; + } + + /** + * @return bool + */ + public function isSeekable() + { + return false; + } + + /** + * @param int $offset + * @param int $whence + * @return void + */ + public function seek($offset, $whence = SEEK_SET) + { + throw new \RuntimeException("Source stream is not seekable"); + } + + /** + * @return void + */ + public function rewind() + { + throw new \RuntimeException("Source stream is not seekable"); + } + + /** + * @return bool + */ + public function isWritable() + { + return false; + } + + public function write($string) + { + throw new \RuntimeException("Source stream is not writable"); + } + + /** + * @param string|null $key + * @return array|null + */ + public function getMetadata($key = null) + { + return isset($key) ? null : []; + } + + /** + * @return null + */ + public function detach() + { + unset($this->stream); + + return null; + } + + /** + * @return bool + */ + public function isReadable() + { + return isset($this->stream); + } + + /** + * @param int $length + * @return string + */ + public function read($length) + { + while (!$this->isEof && \strlen($this->buffer) < $length) { + $this->buffer .= $this->readFromStream(); + } + + $data = \substr($this->buffer, 0, $length); + $this->buffer = \substr($this->buffer, \strlen($data)); + + return $data; + } + + private function readFromStream(): string + { + $data = wait(timeout($this->getOpenStream()->read(), $this->timeout)); + if (!isset($data)) { + $this->isEof = true; + + return ''; + } + if (\is_string($data)) { + return $data; + } + + throw new \RuntimeException("Invalid data received from stream"); + } + + private function getOpenStream(): InputStream + { + if (isset($this->stream)) { + return $this->stream; + } + + throw new \RuntimeException("Stream is closed"); + } + + public function getContents() + { + while (!$this->isEof) { + $this->buffer .= $this->readFromStream(); + } + + return $this->buffer; + } +} diff --git a/src/PsrAdapter.php b/src/PsrAdapter.php new file mode 100644 index 00000000..17e0408c --- /dev/null +++ b/src/PsrAdapter.php @@ -0,0 +1,56 @@ +getUri(), $source->getMethod()); + $target->setHeaders($source->getHeaders()); + $target->setProtocolVersions([$source->getProtocolVersion()]); + $target->setBody(new PsrStreamBody($source->getBody())); + + return $target; + } + + public function toPsrRequest( + RequestFactoryInterface $requestFactory, + Request $source, + ?string $protocolVersion = null + ): RequestInterface { + $target = $requestFactory + ->createRequest($source->getMethod(), $source->getUri()) + ->withBody(new PsrRequestStream($source->getBody()->createBodyStream())); + foreach ($source->getHeaders() as $headerName => $headerValues) { + $target = $target->withHeader($headerName, $headerValues); + } + $protocolVersions = $source->getProtocolVersions(); + if (isset($protocolVersion)) { + if (!\in_array($protocolVersion, $protocolVersions)) { + throw new \RuntimeException( + "Source request doesn't support provided HTTP protocol version: {$protocolVersion}" + ); + } + + return $target->withProtocolVersion($protocolVersion); + } + if (\count($protocolVersions) == 1) { + return $target->withProtocolVersion($protocolVersions[0]); + } + + if (!\in_array($target->getProtocolVersion(), $protocolVersions)) { + throw new \RuntimeException("Can't choose HTTP protocol version automatically"); + } + + return $target; + } +} diff --git a/test/Body/PsrStreamBodyTest.php b/test/Body/PsrStreamBodyTest.php new file mode 100644 index 00000000..0bf2b5f3 --- /dev/null +++ b/test/Body/PsrStreamBodyTest.php @@ -0,0 +1,76 @@ +createMock(StreamInterface::class); + $stream->method('getSize')->willReturn($size); + $body = new PsrStreamBody($stream); + self::assertSame($expectedSize, yield $body->getBodyLength()); + } + + public function providerBodyLength(): array + { + return [ + 'Stream provides zero size' => [0, 0], + 'Stream provides positive size' => [1, 1], + 'Stream doesn\'t provide its size' => [null, -1], + ]; + } + + public function testGetHeadersReturnsEmptyList(): \Generator + { + $stream = $this->createMock(StreamInterface::class); + $body = new PsrStreamBody($stream); + self::assertSame([], yield $body->getHeaders()); + } + + public function testCreateBodyStreamResultReadsFromOriginalStream(): \Generator + { + $stream = (new StreamFactory())->createStream('body_content'); + $body = new PsrStreamBody($stream); + self::assertSame('body_content', yield $this->readStream($body->createBodyStream())); + } + + private function readStream(InputStream $stream): Promise + { + return call( + function () use ($stream): \Generator { + $buffer = []; + do { + $chunk = yield $stream->read(); + if (isset($chunk)) { + $buffer[] = $chunk; + } else { + break; + } + } while (true); + + return \implode('', $buffer); + } + ); + } +} diff --git a/test/Internal/PsrInputStreamTest.php b/test/Internal/PsrInputStreamTest.php new file mode 100644 index 00000000..a3de1d51 --- /dev/null +++ b/test/Internal/PsrInputStreamTest.php @@ -0,0 +1,105 @@ +createMock(StreamInterface::class); + $inputStream = new PsrInputStream($stream); + $stream->method('isReadable')->willReturn(false); + + self::assertNull(yield $inputStream->read()); + } + + public function testReadFromReadableStreamAtEofReturnsNull(): \Generator + { + $stream = $this->createMock(StreamInterface::class); + $inputStream = new PsrInputStream($stream); + $stream->method('isReadable')->willReturn(true); + $stream->method('eof')->willReturn(true); + + self::assertNull(yield $inputStream->read()); + } + + public function testReadFromReadableStreamRewindsSeekableStream(): \Generator + { + $stream = $this->createMock(StreamInterface::class); + $inputStream = new PsrInputStream($stream); + $stream->method('isReadable')->willReturn(true); + $stream->method('isSeekable')->willReturn(true); + $stream + ->expects(self::once()) + ->method('rewind'); + + yield $inputStream->read(); + } + + public function testReadFromReadableStreamNeverRewindsNonSeekableStream(): \Generator + { + $stream = $this->createMock(StreamInterface::class); + $inputStream = new PsrInputStream($stream); + $stream->method('isReadable')->willReturn(true); + $stream->method('isSeekable')->willReturn(false); + $stream + ->expects(self::never()) + ->method('rewind'); + + yield $inputStream->read(); + } + + public function testReadFromReadableStreamReturnsDataProvidedByStream(): \Generator + { + $stream = $this->createMock(StreamInterface::class); + $inputStream = new PsrInputStream($stream, 5); + $stream->method('isReadable')->willReturn(true); + $stream->method('eof')->willReturn(false); + $stream + ->method('read') + ->with(self::identicalTo(5)) + ->willReturn('abcde'); + + self::assertSame('abcde', yield $inputStream->read()); + } + + /** + * @param string $sourceData + * @param int $chunkSize + * @param string $firstChunk + * @param string $secondChunk + * @return \Generator + * @dataProvider providerStreamData + */ + public function testReadReturnsMatchingDataFromStream( + string $sourceData, + int $chunkSize, + string $firstChunk, + string $secondChunk + ): \Generator { + $stream = (new StreamFactory())->createStream($sourceData); + $inputStream = new PsrInputStream($stream, $chunkSize); + self::assertSame($firstChunk, (yield $inputStream->read()) ?? ''); + self::assertSame($secondChunk, (yield $inputStream->read()) ?? ''); + } + + public function providerStreamData(): array + { + return [ + 'Empty stream' => ['', 1, '', ''], + 'Data size lesser than chunk size' => ['a', 2, 'a', ''], + 'Data size equal to chunk size' => ['a', 1, 'a', ''], + 'Data size greater than chunk size' => ['ab', 1, 'a', 'b'], + ]; + } +} diff --git a/test/Internal/PsrRequestStreamTest.php b/test/Internal/PsrRequestStreamTest.php new file mode 100644 index 00000000..abb43b41 --- /dev/null +++ b/test/Internal/PsrRequestStreamTest.php @@ -0,0 +1,221 @@ +createMock(InputStream::class); + $inputStream + ->method('read') + ->willThrowException(new \Exception()); + $requestStream = new PsrRequestStream($inputStream); + self::assertSame('', (string) $requestStream); + } + + public function testReadAfterCloseThrowsException(): void + { + $inputStream = new InMemoryStream('a'); + $requestStream = new PsrRequestStream($inputStream); + $requestStream->close(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Stream is closed'); + $requestStream->read(1); + } + + public function testReadAfterDetachThrowsException(): void + { + $inputStream = new InMemoryStream('a'); + $requestStream = new PsrRequestStream($inputStream); + $requestStream->detach(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Stream is closed'); + $requestStream->read(1); + } + + public function testEofBeforeReadReturnsFalse(): void + { + $inputStream = new InMemoryStream(''); + $requestStream = new PsrRequestStream($inputStream); + self::assertFalse($requestStream->eof()); + } + + public function testEofAfterPartialReadReturnsFalse(): void + { + $inputStream = new InMemoryStream('ab'); + $requestStream = new PsrRequestStream($inputStream); + $requestStream->read(1); + self::assertFalse($requestStream->eof()); + } + + public function testEofAfterFullReadReturnsTrue(): void + { + $inputStream = new InMemoryStream('a'); + $requestStream = new PsrRequestStream($inputStream); + $requestStream->read(2); + self::assertTrue($requestStream->eof()); + } + + public function testTellThrowsException(): void + { + $inputStream = new InMemoryStream('a'); + $requestStream = new PsrRequestStream($inputStream); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Source stream is not seekable'); + $requestStream->tell(); + } + + public function testRewindThrowsException(): void + { + $inputStream = new InMemoryStream('a'); + $requestStream = new PsrRequestStream($inputStream); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Source stream is not seekable'); + $requestStream->rewind(); + } + + public function testSeekThrowsException(): void + { + $inputStream = new InMemoryStream('a'); + $requestStream = new PsrRequestStream($inputStream); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Source stream is not seekable'); + $requestStream->seek(0); + } + + public function testGetSizeReturnsNull(): void + { + $inputStream = new InMemoryStream('a'); + $requestStream = new PsrRequestStream($inputStream); + self::assertNull($requestStream->getSize()); + } + + public function testIsSeekableReturnsFalse(): void + { + $inputStream = new InMemoryStream('a'); + $requestStream = new PsrRequestStream($inputStream); + self::assertFalse($requestStream->isSeekable()); + } + + public function testIsWritableReturnsFalse(): void + { + $inputStream = new InMemoryStream('a'); + $requestStream = new PsrRequestStream($inputStream); + self::assertFalse($requestStream->isWritable()); + } + + public function testWriteThrowsException(): void + { + $inputStream = new InMemoryStream('a'); + $requestStream = new PsrRequestStream($inputStream); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Source stream is not writable'); + $requestStream->write('a'); + } + + public function testIsReadableAfterConstructionReturnsTrue(): void + { + $inputStream = new InMemoryStream('a'); + $requestStream = new PsrRequestStream($inputStream); + self::assertTrue($requestStream->isReadable()); + } + + public function testIsReadableAfterCloseReturnsFalse(): void + { + $inputStream = new InMemoryStream('a'); + $requestStream = new PsrRequestStream($inputStream); + $requestStream->close(); + self::assertFalse($requestStream->isReadable()); + } + + public function testIsReadableAfterDetachReturnsFalse(): void + { + $inputStream = new InMemoryStream('a'); + $requestStream = new PsrRequestStream($inputStream); + $requestStream->detach(); + self::assertFalse($requestStream->isReadable()); + } + + public function testGetContentsReadsAllDataFromStream(): void + { + $inputStream = new InMemoryStream('abcd'); + $requestStream = new PsrRequestStream($inputStream); + self::assertSame('abcd', $requestStream->getContents()); + } + + public function testGetMetadataReturnsNullWithKey(): void + { + $inputStream = new InMemoryStream('a'); + $requestStream = new PsrRequestStream($inputStream); + self::assertNull($requestStream->getMetadata('b')); + } + + public function testGetMetadataReturnsEmptyArrayWithoutKey(): void + { + $inputStream = new InMemoryStream('a'); + $requestStream = new PsrRequestStream($inputStream); + self::assertSame([], $requestStream->getMetadata()); + } + + public function testReadThrowsExceptionOnInvalidDataFromStream(): void + { + $inputStream = $this->createMock(InputStream::class); + $requestStream = new PsrRequestStream($inputStream); + $inputStream->method('read')->willReturn(new Success(1)); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid data received from stream'); + $requestStream->read(1); + } + + /** + * @param string|null $firstChunk + * @param string|null $secondChunk + * @param int $firstChunkSize + * @param int $secondChunkSize + * @param string $expectedFirstResult + * @param string $expectedSecondResult + * @dataProvider providerReadChunks + */ + public function testReadReturnsCorrectDataFromStreamReadingTwice( + ?string $firstChunk, + ?string $secondChunk, + int $firstChunkSize, + int $secondChunkSize, + string $expectedFirstResult, + string $expectedSecondResult + ) { + $inputStream = $this->createMock(InputStream::class); + $inputStream->method('read')->willReturn(new Success($firstChunk), new Success($secondChunk)); + $requestStream = new PsrRequestStream($inputStream); + self::assertSame($expectedFirstResult, $requestStream->read($firstChunkSize)); + self::assertSame($expectedSecondResult, $requestStream->read($secondChunkSize)); + } + + public function providerReadChunks(): array + { + return [ + 'Source chunks match target chunks' => ['a', 'b', 1, 1, 'a', 'b'], + 'Source chunks border within first target chunk' => ['ab', 'c', 1, 2, 'a', 'bc'], + 'Source chunks border within second target chunk' => ['a', 'bc', 2, 1, 'ab', 'c'], + ]; + } +} diff --git a/test/PsrAdapterTest.php b/test/PsrAdapterTest.php new file mode 100644 index 00000000..5d3bdb43 --- /dev/null +++ b/test/PsrAdapterTest.php @@ -0,0 +1,155 @@ +fromPsrRequest($source); + self::assertSame('https://user:password@localhost/foo?a=b#c', (string) $target->getUri()); + } + + public function testFromPsrRequestReturnsRequestWithEqualMethod(): void + { + $adapter = new PsrAdapter(); + $source = new PsrRequest(null, 'POST'); + $target = $adapter->fromPsrRequest($source); + self::assertSame('POST', $target->getMethod()); + } + + public function testFromPsrRequestReturnsRequestWithAllAddedHeaders(): void + { + $adapter = new PsrAdapter(); + $source = new PsrRequest(null, null, 'php://memory', ['a' => 'b', 'c' => ['d', 'e']]); + $target = $adapter->fromPsrRequest($source); + $actualHeaders = \array_map([$target, 'getHeaderArray'], ['a', 'c']); + self::assertSame([['b'], ['d', 'e']], $actualHeaders); + } + + public function testFromPsrRequestReturnsRequestWithSameProtocolVersion(): void + { + $adapter = new PsrAdapter(); + $source = (new PsrRequest())->withProtocolVersion('2'); + $target = $adapter->fromPsrRequest($source); + self::assertSame(['2'], $target->getProtocolVersions()); + } + + public function testFromPsrRequestReturnsRequestWithMatchingBody(): \Generator + { + $adapter = new PsrAdapter(); + $source = new PsrRequest(); + $source->getBody()->write('body_content'); + $target = $adapter->fromPsrRequest($source); + + self::assertSame('body_content', yield $this->readBody($target->getBody())); + } + + private function readBody(RequestBody $body): Promise + { + return call( + function () use ($body): \Generator { + $stream = $body->createBodyStream(); + $buffer = []; + do { + $chunk = yield $stream->read(); + if (isset($chunk)) { + $buffer[] = $chunk; + } else { + break; + } + } while (true); + + return \implode('', $buffer); + } + ); + } + + public function testToPsrRequestReturnsRequestWithEqualUri(): void + { + $adapter = new PsrAdapter(); + $source = new Request('https://user:password@localhost/foo?a=b#c'); + $target = $adapter->toPsrRequest(new RequestFactory(), $source); + self::assertSame('https://user:password@localhost/foo?a=b#c', (string) $target->getUri()); + } + + public function testToPsrRequestReturnsRequestWithEqualMethod(): void + { + $adapter = new PsrAdapter(); + $source = new Request('', 'POST'); + $target = $adapter->toPsrRequest(new RequestFactory(), $source); + self::assertSame('POST', $target->getMethod()); + } + + public function testToPsrRequestReturnsRequestWithAllAddedHeaders(): void + { + $adapter = new PsrAdapter(); + $source = new Request(''); + $source->setHeaders(['a' => 'b', 'c' => ['d', 'e']]); + $target = $adapter->toPsrRequest(new RequestFactory(), $source); + $actualHeaders = \array_map([$target, 'getHeader'], ['a', 'c']); + self::assertSame([['b'], ['d', 'e']], $actualHeaders); + } + + /** + * @param array $sourceVersions + * @param string|null $selectedVersion + * @param string $targetVersion + * @dataProvider providerSuccessfulProtocolVersions + */ + public function testToPsrRequestReturnsRequestWithMatchingProtocolVersion( + array $sourceVersions, + ?string $selectedVersion, + string $targetVersion + ): void { + $adapter = new PsrAdapter(); + $source = new Request(''); + $source->setProtocolVersions($sourceVersions); + $target = $adapter->toPsrRequest(new RequestFactory(), $source, $selectedVersion); + self::assertSame($targetVersion, $target->getProtocolVersion()); + } + + public function providerSuccessfulProtocolVersions(): array + { + return [ + 'Default version is set when available in list and not explicitly provided' => [['1.1', '2'], null, '1.1'], + 'The only available version is picked from list if not explicitly provided' => [['2'], null, '2'], + 'Explicitly provided version is set when available in list' => [['1.1', '2'], '2', '2'], + ]; + } + + public function testToPsrRequestThrowsExceptionIfProvidedVersionNotInSource(): void + { + $adapter = new PsrAdapter(); + $source = new Request(''); + $source->setProtocolVersions(['2']); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Source request doesn\'t support provided HTTP protocol version: 1.1'); + $adapter->toPsrRequest(new RequestFactory(), $source, '1.1'); + } + + public function testToPsrRequestThrowsExceptionIfDefaultVersionNotInSource(): void + { + $adapter = new PsrAdapter(); + $source = new Request(''); + $source->setProtocolVersions(['1.0', '2']); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Can\'t choose HTTP protocol version automatically'); + $adapter->toPsrRequest(new RequestFactory(), $source); + } +}