diff --git a/composer.json b/composer.json index 46fdef1121..56c224e216 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "~1.0", "symfony/polyfill-php72": "~1.5", - "symfony/polyfill-php73": "^1.8" + "symfony/polyfill-php73": "^1.11" }, "replace": { "symfony/asset": "self.version", diff --git a/src/Symfony/Component/HttpClient/Exception/JsonException.php b/src/Symfony/Component/HttpClient/Exception/JsonException.php new file mode 100644 index 0000000000..5dd6345580 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Exception/JsonException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Exception; + +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +/** + * Thrown by responses' toArray() method when their content cannot be JSON-decoded. + * + * @author Nicolas Grekas + * + * @internal + */ +final class JsonException extends \JsonException implements TransportExceptionInterface +{ +} diff --git a/src/Symfony/Component/HttpClient/Response/ResponseTrait.php b/src/Symfony/Component/HttpClient/Response/ResponseTrait.php index a83a4de516..3d890b911d 100644 --- a/src/Symfony/Component/HttpClient/Response/ResponseTrait.php +++ b/src/Symfony/Component/HttpClient/Response/ResponseTrait.php @@ -15,6 +15,7 @@ use Symfony\Component\HttpClient\Chunk\DataChunk; use Symfony\Component\HttpClient\Chunk\ErrorChunk; use Symfony\Component\HttpClient\Chunk\LastChunk; use Symfony\Component\HttpClient\Exception\ClientException; +use Symfony\Component\HttpClient\Exception\JsonException; use Symfony\Component\HttpClient\Exception\RedirectionException; use Symfony\Component\HttpClient\Exception\ServerException; use Symfony\Component\HttpClient\Exception\TransportException; @@ -52,6 +53,7 @@ trait ResponseTrait private $timeout; private $finalInfo; private $offset = 0; + private $jsonData; /** * {@inheritdoc} @@ -121,6 +123,47 @@ trait ResponseTrait return stream_get_contents($this->content); } + /** + * {@inheritdoc} + */ + public function toArray(bool $throw = true): array + { + if ('' === $content = $this->getContent($throw)) { + throw new TransportException('Response body is empty.'); + } + + if (null !== $this->jsonData) { + return $this->jsonData; + } + + $contentType = $this->headers['content-type'][0] ?? 'application/json'; + + if (!preg_match('/\bjson\b/i', $contentType)) { + throw new JsonException(sprintf('Response content-type is "%s" while a JSON-compatible one was expected.', $contentType)); + } + + try { + $content = json_decode($content, true, 512, JSON_BIGINT_AS_STRING | (\PHP_VERSION_ID >= 70300 ? JSON_THROW_ON_ERROR : 0)); + } catch (\JsonException $e) { + throw new JsonException($e->getMessage(), $e->getCode()); + } + + if (\PHP_VERSION_ID < 70300 && JSON_ERROR_NONE !== json_last_error()) { + throw new JsonException(json_last_error_msg(), json_last_error()); + } + + if (!\is_array($content)) { + throw new JsonException(sprintf('JSON content was expected to decode to an array, %s returned.', \gettype($content))); + } + + if (null !== $this->content) { + // Option "buffer" is true + return $this->jsonData = $content; + } + + return $content; + } + /** * Closes the response and all its network handles. */ diff --git a/src/Symfony/Component/HttpClient/composer.json b/src/Symfony/Component/HttpClient/composer.json index 854c2c64fe..979385626d 100644 --- a/src/Symfony/Component/HttpClient/composer.json +++ b/src/Symfony/Component/HttpClient/composer.json @@ -20,7 +20,8 @@ }, "require": { "php": "^7.1.3", - "symfony/contracts": "^1.1" + "symfony/contracts": "^1.1", + "symfony/polyfill-php73": "^1.11" }, "require-dev": { "nyholm/psr7": "^1.0", diff --git a/src/Symfony/Contracts/HttpClient/ResponseInterface.php b/src/Symfony/Contracts/HttpClient/ResponseInterface.php index 244accc094..549bfcda3f 100644 --- a/src/Symfony/Contracts/HttpClient/ResponseInterface.php +++ b/src/Symfony/Contracts/HttpClient/ResponseInterface.php @@ -59,6 +59,18 @@ interface ResponseInterface */ public function getContent(bool $throw = true): string; + /** + * Gets the response body decoded as array, typically from a JSON payload. + * + * @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes + * + * @throws TransportExceptionInterface When the body cannot be decoded or when a network error occurs + * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached + * @throws ClientExceptionInterface On a 4xx when $throw is true + * @throws ServerExceptionInterface On a 5xx when $throw is true + */ + public function toArray(bool $throw = true): array; + /** * Returns info coming from the transport layer. * diff --git a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php index af6e0dc375..fb63a9f07d 100644 --- a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php +++ b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php @@ -58,6 +58,7 @@ abstract class HttpClientTestCase extends TestCase $this->assertSame(['application/json'], $headers['content-type']); $body = json_decode($response->getContent(), true); + $this->assertSame($body, $response->toArray()); $this->assertSame('HTTP/1.1', $body['SERVER_PROTOCOL']); $this->assertSame('/', $body['REQUEST_URI']); @@ -79,7 +80,7 @@ abstract class HttpClientTestCase extends TestCase 'headers' => ['Foo' => 'baR'], ]); - $body = json_decode($response->getContent(), true); + $body = $response->toArray(); $this->assertSame('baR', $body['HTTP_FOO']); $this->expectException(TransportExceptionInterface::class); @@ -106,7 +107,7 @@ abstract class HttpClientTestCase extends TestCase $this->assertSame(200, $response->getStatusCode()); $this->assertSame('HTTP/1.0 200 OK', $response->getInfo('raw_headers')[0]); - $body = json_decode($response->getContent(), true); + $body = $response->toArray(); $this->assertSame('HTTP/1.0', $body['SERVER_PROTOCOL']); $this->assertSame('GET', $body['REQUEST_METHOD']); @@ -203,7 +204,7 @@ abstract class HttpClientTestCase extends TestCase $client = $this->getHttpClient(); $response = $client->request('GET', 'http://foo:bar%3Dbar@localhost:8057'); - $body = json_decode($response->getContent(), true); + $body = $response->toArray(); $this->assertSame('foo', $body['PHP_AUTH_USER']); $this->assertSame('bar=bar', $body['PHP_AUTH_PW']); @@ -219,7 +220,7 @@ abstract class HttpClientTestCase extends TestCase }, ]); - $body = json_decode($response->getContent(), true); + $body = $response->toArray(); $this->assertSame('GET', $body['REQUEST_METHOD']); $this->assertSame('Basic Zm9vOmJhcg==', $body['HTTP_AUTHORIZATION']); $this->assertSame('http://localhost:8057/', $response->getInfo('url')); @@ -250,7 +251,8 @@ abstract class HttpClientTestCase extends TestCase $client = $this->getHttpClient(); $response = $client->request('GET', 'http://localhost:8057/302/relative'); - $body = json_decode($response->getContent(), true); + $body = $response->toArray(); + $this->assertSame('/', $body['REQUEST_URI']); $this->assertNull($response->getInfo('redirect_url')); @@ -279,7 +281,7 @@ abstract class HttpClientTestCase extends TestCase 'body' => 'foo=bar', ]); - $body = json_decode($response->getContent(), true); + $body = $response->toArray(); $this->assertSame(['foo' => 'bar', 'REQUEST_METHOD' => 'POST'], $body); } @@ -388,7 +390,7 @@ abstract class HttpClientTestCase extends TestCase 'on_progress' => function (...$state) use (&$steps) { $steps[] = $state; }, ]); - $body = json_decode($response->getContent(), true); + $body = $response->toArray(); $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], $body); $this->assertSame([0, 0], \array_slice($steps[0], 0, 2)); @@ -405,7 +407,7 @@ abstract class HttpClientTestCase extends TestCase 'body' => ['foo' => 'bar'], ]); - $this->assertSame(['foo' => 'bar', 'REQUEST_METHOD' => 'POST'], json_decode($response->getContent(), true)); + $this->assertSame(['foo' => 'bar', 'REQUEST_METHOD' => 'POST'], $response->toArray()); } public function testPostResource() @@ -420,7 +422,7 @@ abstract class HttpClientTestCase extends TestCase 'body' => $h, ]); - $body = json_decode($response->getContent(), true); + $body = $response->toArray(); $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], $body); } @@ -438,7 +440,7 @@ abstract class HttpClientTestCase extends TestCase }, ]); - $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], json_decode($response->getContent(), true)); + $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], $response->toArray()); } public function testOnProgressCancel() @@ -581,7 +583,7 @@ abstract class HttpClientTestCase extends TestCase 'proxy' => 'http://localhost:8057', ]); - $body = json_decode($response->getContent(), true); + $body = $response->toArray(); $this->assertSame('localhost:8057', $body['HTTP_HOST']); $this->assertRegexp('#^http://(localhost|127\.0\.0\.1):8057/$#', $body['REQUEST_URI']); @@ -589,7 +591,7 @@ abstract class HttpClientTestCase extends TestCase 'proxy' => 'http://foo:b%3Dar@localhost:8057', ]); - $body = json_decode($response->getContent(), true); + $body = $response->toArray(); $this->assertSame('Basic Zm9vOmI9YXI=', $body['HTTP_PROXY_AUTHORIZATION']); } @@ -603,7 +605,7 @@ abstract class HttpClientTestCase extends TestCase 'proxy' => 'http://localhost:8057', ]); - $body = json_decode($response->getContent(), true); + $body = $response->toArray(); $this->assertSame('HTTP/1.1', $body['SERVER_PROTOCOL']); $this->assertSame('/', $body['REQUEST_URI']); @@ -629,7 +631,7 @@ abstract class HttpClientTestCase extends TestCase $this->assertSame(['Accept-Encoding'], $headers['vary']); $this->assertContains('gzip', $headers['content-encoding'][0]); - $body = json_decode($response->getContent(), true); + $body = $response->toArray(); $this->assertContains('gzip', $body['HTTP_ACCEPT_ENCODING']); } @@ -652,7 +654,7 @@ abstract class HttpClientTestCase extends TestCase 'query' => ['b' => 'b'], ]); - $body = json_decode($response->getContent(), true); + $body = $response->toArray(); $this->assertSame('GET', $body['REQUEST_METHOD']); $this->assertSame('/?a=a&b=b', $body['REQUEST_URI']); } @@ -673,10 +675,9 @@ abstract class HttpClientTestCase extends TestCase $this->assertContains('gzip', $headers['content-encoding'][0]); $body = $response->getContent(); - $this->assertSame("\x1F", $body[0]); - $body = json_decode(gzdecode($body), true); + $body = json_decode(gzdecode($body), true); $this->assertSame('gzip', $body['HTTP_ACCEPT_ENCODING']); }