diff --git a/composer.json b/composer.json
index 7d87a1e2d37..f9d313b33b4 100644
--- a/composer.json
+++ b/composer.json
@@ -72,6 +72,7 @@
"symfony/finder": "^3.4 || ^4.0",
"symfony/form": "^3.4 || ^4.0",
"symfony/framework-bundle": "^4.3",
+ "symfony/http-client": "^4.3",
"symfony/mercure-bundle": "*",
"symfony/messenger": "^4.3",
"symfony/phpunit-bridge": "^4.3.1",
diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php
index 13404f59a92..7aeda770021 100644
--- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php
+++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php
@@ -35,6 +35,7 @@
use Doctrine\Common\Annotations\Annotation;
use phpDocumentor\Reflection\DocBlockFactoryInterface;
use Ramsey\Uuid\Uuid;
+use Symfony\Component\BrowserKit\AbstractBrowser;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\Config\Resource\DirectoryResource;
@@ -120,6 +121,10 @@ public function load(array $configs, ContainerBuilder $container): void
->addTag('api_platform.subresource_data_provider');
$container->registerForAutoconfiguration(FilterInterface::class)
->addTag('api_platform.filter');
+
+ if ($container->hasParameter('test.client.parameters') && class_exists(AbstractBrowser::class)) {
+ $loader->load('test.xml');
+ }
}
private function registerCommonConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader, array $formats, array $errorFormats): void
diff --git a/src/Bridge/Symfony/Bundle/Resources/config/test.xml b/src/Bridge/Symfony/Bundle/Resources/config/test.xml
new file mode 100644
index 00000000000..c3c28980219
--- /dev/null
+++ b/src/Bridge/Symfony/Bundle/Resources/config/test.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Bridge/Symfony/Bundle/Test/ApiTestCase.php b/src/Bridge/Symfony/Bundle/Test/ApiTestCase.php
new file mode 100644
index 00000000000..04f50c91623
--- /dev/null
+++ b/src/Bridge/Symfony/Bundle/Test/ApiTestCase.php
@@ -0,0 +1,48 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\Core\Bridge\Symfony\Bundle\Test;
+
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
+
+/**
+ * Base class for functional API tests.
+ *
+ * @experimental
+ *
+ * @author Kévin Dunglas
+ */
+abstract class ApiTestCase extends KernelTestCase
+{
+ /**
+ * Creates a Client.
+ *
+ * @param array $options An array of options to pass to the createKernel method
+ */
+ protected static function createClient(array $options = []): Client
+ {
+ $kernel = static::bootKernel($options);
+
+ try {
+ /**
+ * @var Client
+ */
+ $client = $kernel->getContainer()->get('test.api_platform.client');
+ } catch (ServiceNotFoundException $e) {
+ throw new \LogicException('You cannot create the client used in functional tests if the BrowserKit component is not available. Try running "composer require symfony/browser-kit".');
+ }
+
+ return $client;
+ }
+}
diff --git a/src/Bridge/Symfony/Bundle/Test/Client.php b/src/Bridge/Symfony/Bundle/Test/Client.php
new file mode 100644
index 00000000000..a879f1f0f2e
--- /dev/null
+++ b/src/Bridge/Symfony/Bundle/Test/Client.php
@@ -0,0 +1,180 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\Core\Bridge\Symfony\Bundle\Test;
+
+use Symfony\Bundle\FrameworkBundle\KernelBrowser;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpClient\HttpClientTrait;
+use Symfony\Component\HttpKernel\KernelInterface;
+use Symfony\Component\HttpKernel\Profiler\Profile;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+use Symfony\Contracts\HttpClient\ResponseStreamInterface;
+
+/**
+ * Convenient test client that makes requests to a Kernel object.
+ *
+ * @experimental
+ *
+ * @author Kévin Dunglas
+ */
+final class Client implements HttpClientInterface
+{
+ /**
+ * @see HttpClientInterface::OPTIONS_DEFAULTS
+ */
+ public const OPTIONS_DEFAULT = [
+ 'auth_basic' => null,
+ 'auth_bearer' => null,
+ 'query' => [],
+ 'headers' => ['accept' => ['application/ld+json']],
+ 'body' => '',
+ 'json' => null,
+ 'base_uri' => 'http://example.com',
+ ];
+
+ use HttpClientTrait;
+
+ private $kernelBrowser;
+
+ public function __construct(KernelBrowser $kernelBrowser)
+ {
+ $this->kernelBrowser = $kernelBrowser;
+ $kernelBrowser->followRedirects(false);
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @see Client::OPTIONS_DEFAULTS for available options
+ *
+ * @return Response
+ */
+ public function request(string $method, string $url, array $options = []): ResponseInterface
+ {
+ $basic = $options['auth_basic'] ?? null;
+ [$url, $options] = self::prepareRequest($method, $url, $options, self::OPTIONS_DEFAULT);
+ $resolvedUrl = implode('', $url);
+
+ $server = [];
+ // Convert headers to a $_SERVER-like array
+ foreach ($options['headers'] as $key => $value) {
+ if ('content-type' === $key) {
+ $server['CONTENT_TYPE'] = $value[0] ?? '';
+
+ continue;
+ }
+
+ // BrowserKit doesn't support setting several headers with the same name
+ $server['HTTP_'.strtoupper(str_replace('-', '_', $key))] = $value[0] ?? '';
+ }
+
+ if ($basic) {
+ $credentials = \is_array($basic) ? $basic : explode(':', $basic, 2);
+ $server['PHP_AUTH_USER'] = $credentials[0];
+ $server['PHP_AUTH_PW'] = $credentials[1] ?? '';
+ }
+
+ $info = [
+ 'response_headers' => [],
+ 'redirect_count' => 0,
+ 'redirect_url' => null,
+ 'start_time' => 0.0,
+ 'http_method' => $method,
+ 'http_code' => 0,
+ 'error' => null,
+ 'user_data' => $options['user_data'] ?? null,
+ 'url' => $resolvedUrl,
+ 'primary_port' => 'http:' === $url['scheme'] ? 80 : 443,
+ ];
+ $this->kernelBrowser->request($method, $resolvedUrl, [], [], $server, $options['body'] ?? null);
+
+ return new Response($this->kernelBrowser->getResponse(), $this->kernelBrowser->getInternalResponse(), $info);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function stream($responses, float $timeout = null): ResponseStreamInterface
+ {
+ throw new \LogicException('Not implemented yet');
+ }
+
+ /**
+ * Gets the underlying test client.
+ */
+ public function getKernelBrowser(): KernelBrowser
+ {
+ return $this->kernelBrowser;
+ }
+
+ // The following methods are proxy methods for KernelBrowser's ones
+
+ /**
+ * Returns the container.
+ *
+ * @return ContainerInterface|null Returns null when the Kernel has been shutdown or not started yet
+ */
+ public function getContainer(): ?ContainerInterface
+ {
+ return $this->kernelBrowser->getContainer();
+ }
+
+ /**
+ * Returns the kernel.
+ */
+ public function getKernel(): KernelInterface
+ {
+ return $this->kernelBrowser->getKernel();
+ }
+
+ /**
+ * Gets the profile associated with the current Response.
+ *
+ * @return Profile|false A Profile instance
+ */
+ public function getProfile()
+ {
+ return $this->kernelBrowser->getProfile();
+ }
+
+ /**
+ * Enables the profiler for the very next request.
+ *
+ * If the profiler is not enabled, the call to this method does nothing.
+ */
+ public function enableProfiler(): void
+ {
+ $this->kernelBrowser->enableProfiler();
+ }
+
+ /**
+ * Disables kernel reboot between requests.
+ *
+ * By default, the Client reboots the Kernel for each request. This method
+ * allows to keep the same kernel across requests.
+ */
+ public function disableReboot(): void
+ {
+ $this->kernelBrowser->disableReboot();
+ }
+
+ /**
+ * Enables kernel reboot between requests.
+ */
+ public function enableReboot(): void
+ {
+ $this->kernelBrowser->enableReboot();
+ }
+}
diff --git a/src/Bridge/Symfony/Bundle/Test/Response.php b/src/Bridge/Symfony/Bundle/Test/Response.php
new file mode 100644
index 00000000000..c4e015fa64e
--- /dev/null
+++ b/src/Bridge/Symfony/Bundle/Test/Response.php
@@ -0,0 +1,187 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\Core\Bridge\Symfony\Bundle\Test;
+
+use Symfony\Component\BrowserKit\Response as BrowserKitResponse;
+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;
+use Symfony\Component\HttpFoundation\Response as HttpFoundationResponse;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+/**
+ * HTTP Response.
+ *
+ * @internal
+ *
+ * Partially copied from \Symfony\Component\HttpClient\Response\ResponseTrait
+ *
+ * @author Kévin Dunglas
+ */
+final class Response implements ResponseInterface
+{
+ private $httpFoundationResponse;
+ private $browserKitResponse;
+ private $headers;
+ private $info;
+ private $content;
+ private $jsonData;
+
+ public function __construct(HttpFoundationResponse $httpFoundationResponse, BrowserKitResponse $browserKitResponse, array $info)
+ {
+ $this->httpFoundationResponse = $httpFoundationResponse;
+ $this->browserKitResponse = $browserKitResponse;
+
+ $this->headers = $httpFoundationResponse->headers->all();
+
+ // Compute raw headers
+ $responseHeaders = [];
+ foreach ($this->headers as $key => $values) {
+ foreach ($values as $value) {
+ $responseHeaders[] = sprintf('%s: %s', $key, $value);
+ }
+ }
+
+ $this->content = $httpFoundationResponse->getContent();
+ $this->info = [
+ 'http_code' => $httpFoundationResponse->getStatusCode(),
+ 'error' => null,
+ 'response_headers' => $responseHeaders,
+ ] + $info;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getInfo(string $type = null)
+ {
+ if ($type) {
+ return $this->info[$type] ?? null;
+ }
+
+ return $this->info;
+ }
+
+ /**
+ * Checks the status, and try to extract message if appropriate.
+ */
+ private function checkStatusCode(): void
+ {
+ if (500 <= $this->info['http_code']) {
+ throw new ServerException($this);
+ }
+
+ if (400 <= $this->info['http_code']) {
+ throw new ClientException($this);
+ }
+
+ if (300 <= $this->info['http_code']) {
+ throw new RedirectionException($this);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getContent(bool $throw = true): string
+ {
+ if ($throw) {
+ $this->checkStatusCode();
+ }
+
+ return $this->content;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getStatusCode(): int
+ {
+ return $this->info['http_code'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getHeaders(bool $throw = true): array
+ {
+ if ($throw) {
+ $this->checkStatusCode();
+ }
+
+ return $this->headers;
+ }
+
+ /**
+ * {@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)));
+ }
+
+ return $this->jsonData = $content;
+ }
+
+ /**
+ * Returns the internal HttpKernel response.
+ */
+ public function getKernelResponse(): HttpFoundationResponse
+ {
+ return $this->httpFoundationResponse;
+ }
+
+ /**
+ * Returns the internal BrowserKit reponse.
+ */
+ public function getBrowserKitResponse(): BrowserKitResponse
+ {
+ return $this->browserKitResponse;
+ }
+
+ /**
+ * {@inheritdoc}.
+ */
+ public function cancel(): void
+ {
+ $this->info['error'] = 'Response has been canceled.';
+ }
+}
diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php
index c6a401fae48..4f45856f0cd 100644
--- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php
+++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php
@@ -1057,6 +1057,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo
foreach ($parameters as $key => $value) {
$containerBuilderProphecy->setParameter($key, $value)->shouldBeCalled();
}
+ $containerBuilderProphecy->hasParameter('test.client.parameters')->wilLReturn(true);
foreach (['yaml', 'xml'] as $format) {
$definitionProphecy = $this->prophesize(Definition::class);
@@ -1158,6 +1159,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo
'api_platform.swagger.normalizer.api_gateway',
'api_platform.swagger.normalizer.documentation',
'api_platform.validator',
+ 'test.api_platform.client',
];
if (\in_array('odm', $doctrineIntegrationsToLoad, true)) {
diff --git a/tests/Bridge/Symfony/Bundle/Test/ClientTest.php b/tests/Bridge/Symfony/Bundle/Test/ClientTest.php
new file mode 100644
index 00000000000..1f1225d5837
--- /dev/null
+++ b/tests/Bridge/Symfony/Bundle/Test/ClientTest.php
@@ -0,0 +1,92 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\Core\Tests\Bridge\Symfony\Bundle\Test;
+
+use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\ApiTestCase;
+use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Response;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\Tools\SchemaTool;
+use Symfony\Component\HttpKernel\Profiler\Profile;
+
+class ClientTest extends ApiTestCase
+{
+ protected function setUp(): void
+ {
+ self::bootKernel();
+ /**
+ * @var EntityManagerInterface
+ */
+ $manager = self::$container->get('doctrine')->getManager();
+ $classes = $manager->getMetadataFactory()->getAllMetadata();
+ $schemaTool = new SchemaTool($manager);
+
+ $schemaTool->dropSchema($classes);
+ $schemaTool->createSchema($classes);
+ }
+
+ public function testRequest(): void
+ {
+ $client = self::createClient();
+ $client->getKernelBrowser();
+ $this->assertSame(self::$kernel->getContainer(), $client->getContainer());
+ $this->assertSame(self::$kernel, $client->getKernel());
+
+ $client->enableProfiler();
+ $response = $client->request('GET', '/');
+
+ $this->assertSame('/contexts/Entrypoint', $response->toArray()['@context']);
+ $this->assertInstanceOf(Profile::class, $client->getProfile());
+ $this->assertInstanceOf(Response::class, $response);
+ }
+
+ public function testCustomHeader(): void
+ {
+ $client = self::createClient();
+ $client->disableReboot();
+ $response = $client->request('POST', '/dummies', [
+ 'headers' => [
+ 'content-type' => 'application/json',
+ 'accept' => 'text/xml',
+ ],
+ 'body' => '{"name": "Kevin"}',
+ ]);
+ $this->assertSame('application/xml; charset=utf-8', $response->getHeaders()['content-type'][0]);
+ $this->assertContains('Kevin', $response->getContent());
+ }
+
+ /**
+ * @dataProvider authBasicProvider
+ */
+ public function testAuthBasic($basic): void
+ {
+ $client = self::createClient();
+ $client->enableReboot();
+ $response = $client->request('GET', '/secured_dummies', ['auth_basic' => $basic]);
+ $this->assertSame(200, $response->getStatusCode());
+ }
+
+ public function authBasicProvider(): iterable
+ {
+ yield ['dunglas:kevin'];
+ yield [['dunglas', 'kevin']];
+ }
+
+ public function testStream(): void
+ {
+ $this->expectException(\LogicException::class);
+
+ $client = self::createClient();
+ $client->stream([]);
+ }
+}
diff --git a/tests/Bridge/Symfony/Bundle/Test/ResponseTest.php b/tests/Bridge/Symfony/Bundle/Test/ResponseTest.php
new file mode 100644
index 00000000000..ac5ba4bfa0a
--- /dev/null
+++ b/tests/Bridge/Symfony/Bundle/Test/ResponseTest.php
@@ -0,0 +1,126 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\Core\Tests\Bridge\Symfony\Bundle\Test;
+
+use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Response;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\BrowserKit\Response as BrowserKitResponse;
+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;
+use Symfony\Component\HttpFoundation\Response as HttpFoundationResponse;
+
+class ResponseTest extends TestCase
+{
+ public function testCreate(): void
+ {
+ $browserKitResponse = new BrowserKitResponse('', 200, ['content-type' => 'application/json']);
+ $httpFoundationResponse = new HttpFoundationResponse('', 200, ['content-type' => 'application/json']);
+
+ $response = new Response($httpFoundationResponse, $browserKitResponse, []);
+
+ $this->assertSame($httpFoundationResponse, $response->getKernelResponse());
+ $this->assertSame($browserKitResponse, $response->getBrowserKitResponse());
+
+ $this->assertSame(200, $response->getInfo('http_code'));
+ $this->assertSame(200, $response->getInfo()['http_code']);
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertSame('application/json', $response->getHeaders()['content-type'][0]);
+ $this->assertSame('', $response->getContent());
+ }
+
+ /**
+ * @dataProvider errorProvider
+ */
+ public function testCheckStatus(string $expectedException, int $status): void
+ {
+ $this->expectException($expectedException);
+ $browserKitResponse = new BrowserKitResponse('', $status);
+ $httpFoundationResponse = new HttpFoundationResponse('', $status);
+
+ $response = new Response($httpFoundationResponse, $browserKitResponse, []);
+ $response->getContent();
+ }
+
+ public function errorProvider(): iterable
+ {
+ yield [ServerException::class, 500];
+ yield [ClientException::class, 400];
+ yield [RedirectionException::class, 300];
+ }
+
+ public function testToArray(): void
+ {
+ $browserKitResponse = new BrowserKitResponse('{"foo": "bar"}', 200, ['content-type' => 'application/ld+json']);
+ $httpFoundationResponse = new HttpFoundationResponse('{"foo": "bar"}', 200, ['content-type' => 'application/ld+json']);
+
+ $response = new Response($httpFoundationResponse, $browserKitResponse, []);
+ $this->assertSame(['foo' => 'bar'], $response->toArray());
+ $this->assertSame(['foo' => 'bar'], $response->toArray()); // Trigger the cache
+ }
+
+ public function testToArrayTransportException(): void
+ {
+ $this->expectException(TransportException::class);
+
+ $response = new Response(new HttpFoundationResponse(), new BrowserKitResponse(), []);
+ $response->toArray();
+ }
+
+ public function testInvalidContentType(): void
+ {
+ $this->expectException(JsonException::class);
+ $this->expectExceptionMessage('Response content-type is "application/invalid" while a JSON-compatible one was expected.');
+
+ $browserKitResponse = new BrowserKitResponse('{"foo": "bar"}', 200, ['content-type' => 'application/invalid']);
+ $httpFoundationResponse = new HttpFoundationResponse('{"foo": "bar"}', 200, ['content-type' => 'application/invalid']);
+
+ $response = new Response($httpFoundationResponse, $browserKitResponse, []);
+ $response->toArray();
+ }
+
+ public function testInvalidJson(): void
+ {
+ $this->expectException(JsonException::class);
+ $this->expectExceptionMessage('Control character error, possibly incorrectly encoded');
+
+ $browserKitResponse = new BrowserKitResponse('{"foo}', 200, ['content-type' => 'application/json']);
+ $httpFoundationResponse = new HttpFoundationResponse('{"foo}', 200, ['content-type' => 'application/json']);
+
+ $response = new Response($httpFoundationResponse, $browserKitResponse, []);
+ $response->toArray();
+ }
+
+ public function testNonArrayJson(): void
+ {
+ $this->expectException(JsonException::class);
+ $this->expectExceptionMessage('JSON content was expected to decode to an array, string returned.');
+
+ $browserKitResponse = new BrowserKitResponse('"foo"', 200, ['content-type' => 'application/json']);
+ $httpFoundationResponse = new HttpFoundationResponse('"foo"', 200, ['content-type' => 'application/json']);
+
+ $response = new Response($httpFoundationResponse, $browserKitResponse, []);
+ $response->toArray();
+ }
+
+ public function testCancel(): void
+ {
+ $response = new Response(new HttpFoundationResponse(), new BrowserKitResponse(), []);
+ $response->cancel();
+
+ $this->assertSame('Response has been canceled.', $response->getInfo('error'));
+ }
+}