diff --git a/.travis.yml b/.travis.yml index 2a34ac2..33db299 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,8 +18,6 @@ env: - TEST_COMMAND="composer test" matrix: - allow_failures: - - php: 7.0 fast_finish: true include: - php: 5.4 diff --git a/composer.json b/composer.json index 53e906d..38b3567 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,8 @@ "php-http/authentication": "^0.1@dev", "php-http/cookie": "^0.1@dev", "symfony/stopwatch": "^2.3", - "psr/log": "^1.0" + "psr/log": "^1.0", + "php-http/encoding": "^0.1@dev" }, "autoload": { "psr-4": { @@ -32,7 +33,8 @@ "php-http/authentication": "Allow to use the AuthenticationPlugin", "php-http/cookie": "Allow to use CookiePlugin", "symfony/stopwatch": "Allow to use the StopwatchPlugin", - "psr/log-implementation": "Allow to use the LoggerPlugin" + "psr/log-implementation": "Allow to use the LoggerPlugin", + "php-http/encoding": "Allow to use the Decoder and Encoder plugin" }, "scripts": { "test": "vendor/bin/phpspec run", diff --git a/spec/ContentLengthPluginSpec.php b/spec/ContentLengthPluginSpec.php new file mode 100644 index 0000000..b0a683f --- /dev/null +++ b/spec/ContentLengthPluginSpec.php @@ -0,0 +1,47 @@ +shouldHaveType('Http\Client\Plugin\ContentLengthPlugin'); + $this->shouldImplement('Http\Client\Plugin\Plugin'); + } + + function it_adds_content_length_header(RequestInterface $request, StreamInterface $stream) + { + $request->hasHeader('Content-Length')->shouldBeCalled()->willReturn(false); + $request->getBody()->shouldBeCalled()->willReturn($stream); + $stream->getSize()->shouldBeCalled()->willReturn(100); + $request->withHeader('Content-Length', 100)->shouldBeCalled()->willReturn($request); + + $this->handleRequest($request, function () {}, function () {}); + } + + function it_streams_chunked_if_no_size(RequestInterface $request, StreamInterface $stream) + { + if(defined('HHVM_VERSION')) { + throw new SkippingException('Skipping test on hhvm, as there is no chunk encoding on hhvm'); + } + + $request->hasHeader('Content-Length')->shouldBeCalled()->willReturn(false); + $request->getBody()->shouldBeCalled()->willReturn($stream); + + $stream->getSize()->shouldBeCalled()->willReturn(null); + $stream->isReadable()->shouldBeCalled()->willReturn(true); + $stream->isWritable()->shouldBeCalled()->willReturn(false); + $stream->eof()->shouldBeCalled()->willReturn(false); + + $request->withBody(Argument::type('Http\Encoding\ChunkStream'))->shouldBeCalled()->willReturn($request); + + $this->handleRequest($request, function () {}, function () {}); + } +} diff --git a/spec/DecoderPluginSpec.php b/spec/DecoderPluginSpec.php new file mode 100644 index 0000000..43376db --- /dev/null +++ b/spec/DecoderPluginSpec.php @@ -0,0 +1,132 @@ +shouldHaveType('Http\Client\Plugin\DecoderPlugin'); + } + + function it_is_a_plugin() + { + $this->shouldImplement('Http\Client\Plugin\Plugin'); + } + + function it_decodes(RequestInterface $request, ResponseInterface $response, StreamInterface $stream) + { + if(defined('HHVM_VERSION')) { + throw new SkippingException('Skipping test on hhvm, as there is no chunk encoding on hhvm'); + } + + $request->withHeader('TE', ['gzip', 'deflate', 'compress', 'chunked'])->shouldBeCalled()->willReturn($request); + $request->withHeader('Accept-Encoding', ['gzip', 'deflate', 'compress'])->shouldBeCalled()->willReturn($request); + $next = function () use($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $response->hasHeader('Transfer-Encoding')->willReturn(true); + $response->getHeader('Transfer-Encoding')->willReturn(['chunked']); + $response->getBody()->willReturn($stream); + $response->withBody(Argument::type('Http\Encoding\DechunkStream'))->willReturn($response); + $response->withHeader('Transfer-Encoding', [])->willReturn($response); + $response->hasHeader('Content-Encoding')->willReturn(false); + + $stream->isReadable()->willReturn(true); + $stream->isWritable()->willReturn(false); + $stream->eof()->willReturn(false); + + $this->handleRequest($request, $next, function () {}); + } + + function it_decodes_gzip(RequestInterface $request, ResponseInterface $response, StreamInterface $stream) + { + $request->withHeader('TE', ['gzip', 'deflate', 'compress', 'chunked'])->shouldBeCalled()->willReturn($request); + $request->withHeader('Accept-Encoding', ['gzip', 'deflate', 'compress'])->shouldBeCalled()->willReturn($request); + $next = function () use($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $response->hasHeader('Transfer-Encoding')->willReturn(false); + $response->hasHeader('Content-Encoding')->willReturn(true); + $response->getHeader('Content-Encoding')->willReturn(['gzip']); + $response->getBody()->willReturn($stream); + $response->withBody(Argument::type('Http\Encoding\GzipDecodeStream'))->willReturn($response); + $response->withHeader('Content-Encoding', [])->willReturn($response); + + $stream->isReadable()->willReturn(true); + $stream->isWritable()->willReturn(false); + $stream->eof()->willReturn(false); + + $this->handleRequest($request, $next, function () {}); + } + + function it_decodes_deflate(RequestInterface $request, ResponseInterface $response, StreamInterface $stream) + { + $request->withHeader('TE', ['gzip', 'deflate', 'compress', 'chunked'])->shouldBeCalled()->willReturn($request); + $request->withHeader('Accept-Encoding', ['gzip', 'deflate', 'compress'])->shouldBeCalled()->willReturn($request); + $next = function () use($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $response->hasHeader('Transfer-Encoding')->willReturn(false); + $response->hasHeader('Content-Encoding')->willReturn(true); + $response->getHeader('Content-Encoding')->willReturn(['deflate']); + $response->getBody()->willReturn($stream); + $response->withBody(Argument::type('Http\Encoding\InflateStream'))->willReturn($response); + $response->withHeader('Content-Encoding', [])->willReturn($response); + + $stream->isReadable()->willReturn(true); + $stream->isWritable()->willReturn(false); + $stream->eof()->willReturn(false); + + $this->handleRequest($request, $next, function () {}); + } + + function it_decodes_inflate(RequestInterface $request, ResponseInterface $response, StreamInterface $stream) + { + $request->withHeader('TE', ['gzip', 'deflate', 'compress', 'chunked'])->shouldBeCalled()->willReturn($request); + $request->withHeader('Accept-Encoding', ['gzip', 'deflate', 'compress'])->shouldBeCalled()->willReturn($request); + $next = function () use($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $response->hasHeader('Transfer-Encoding')->willReturn(false); + $response->hasHeader('Content-Encoding')->willReturn(true); + $response->getHeader('Content-Encoding')->willReturn(['compress']); + $response->getBody()->willReturn($stream); + $response->withBody(Argument::type('Http\Encoding\DecompressStream'))->willReturn($response); + $response->withHeader('Content-Encoding', [])->willReturn($response); + + $stream->isReadable()->willReturn(true); + $stream->isWritable()->willReturn(false); + $stream->eof()->willReturn(false); + + $this->handleRequest($request, $next, function () {}); + } + + function it_does_not_decode_with_content_encoding(RequestInterface $request, ResponseInterface $response) + { + $this->beConstructedWith(false); + + $request->withHeader('TE', ['gzip', 'deflate', 'compress', 'chunked'])->shouldBeCalled()->willReturn($request); + $request->withHeader('Accept-Encoding', ['gzip', 'deflate', 'compress'])->shouldNotBeCalled(); + $next = function () use($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $response->hasHeader('Transfer-Encoding')->willReturn(false); + $response->hasHeader('Content-Encoding')->shouldNotBeCalled(); + + $this->handleRequest($request, $next, function () {}); + } +} diff --git a/src/ContentLengthPlugin.php b/src/ContentLengthPlugin.php new file mode 100644 index 0000000..afab963 --- /dev/null +++ b/src/ContentLengthPlugin.php @@ -0,0 +1,34 @@ + + */ +class ContentLengthPlugin implements Plugin +{ + /** + * {@inheritDoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + if (!$request->hasHeader('Content-Length')) { + $stream = $request->getBody(); + + // Cannot determine the size so we use a chunk stream + if (null === $stream->getSize()) { + $stream = new ChunkStream($stream); + $request = $request->withBody($stream); + } else { + $request = $request->withHeader('Content-Length', $stream->getSize()); + } + } + + return $next($request); + } +} diff --git a/src/DecoderPlugin.php b/src/DecoderPlugin.php new file mode 100644 index 0000000..bc844b2 --- /dev/null +++ b/src/DecoderPlugin.php @@ -0,0 +1,132 @@ + + */ +class DecoderPlugin implements Plugin +{ + /** + * @var bool Whether this plugin decode stream with value in the Content-Encoding header (default to true). + * + * If set to false only the Transfer-Encoding header will be used. + */ + private $useContentEncoding; + + /** + * @param bool $useContentEncoding Whether this plugin decode stream with value in the Content-Encoding header (default to true). + * + * If set to false only the Transfer-Encoding header will be used. + */ + public function __construct($useContentEncoding = true) + { + $this->useContentEncoding = $useContentEncoding; + } + + /** + * {@inheritDoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + $request = $request->withHeader('TE', ['gzip', 'deflate', 'compress', 'chunked']); + + if ($this->useContentEncoding) { + $request = $request->withHeader('Accept-Encoding', ['gzip', 'deflate', 'compress']); + } + + return $next($request)->then(function (ResponseInterface $response) { + return $this->decodeResponse($response); + }); + } + + /** + * Decode a response body given its Transfer-Encoding or Content-Encoding value + * + * @param ResponseInterface $response Response to decode + * + * @return ResponseInterface New response decoded + */ + private function decodeResponse(ResponseInterface $response) + { + $response = $this->decodeOnEncodingHeader('Transfer-Encoding', $response); + + if ($this->useContentEncoding) { + $response = $this->decodeOnEncodingHeader('Content-Encoding', $response); + } + + return $response; + } + + /** + * Decode a response on a specific header (content encoding or transfer encoding mainly) + * + * @param string $headerName Name of the header + * @param ResponseInterface $response Response + * + * @return ResponseInterface A new instance of the response decoded + */ + private function decodeOnEncodingHeader($headerName, ResponseInterface $response) + { + if ($response->hasHeader($headerName)) { + $encodings = $response->getHeader($headerName); + $newEncodings = []; + + while ($encoding = array_pop($encodings)) { + $stream = $this->decorateStream($encoding, $response->getBody()); + + if (false === $stream) { + array_unshift($newEncodings, $encoding); + + continue; + } + + $response = $response->withBody($stream); + } + + $response = $response->withHeader($headerName, $newEncodings); + } + + return $response; + } + + /** + * Decorate a stream given an encoding + * + * @param string $encoding + * @param StreamInterface $stream + * + * @return StreamInterface|false A new stream interface or false if encoding is not supported + */ + private function decorateStream($encoding, StreamInterface $stream) + { + if (strtolower($encoding) == 'chunked') { + return new DechunkStream($stream); + } + + if (strtolower($encoding) == 'compress') { + return new DecompressStream($stream); + } + + if (strtolower($encoding) == 'deflate') { + return new InflateStream($stream); + } + + if (strtolower($encoding) == 'gzip') { + return new GzipDecodeStream($stream); + } + + return false; + } +}