Skip to content

Commit e9eb9ab

Browse files
committed
Add an HTTP client dedicated to functional API testing
1 parent ebc98da commit e9eb9ab

File tree

6 files changed

+439
-1
lines changed

6 files changed

+439
-1
lines changed

composer.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"symfony/finder": "^3.4 || ^4.0",
6767
"symfony/form": "^3.4 || ^4.0",
6868
"symfony/framework-bundle": "^4.2",
69+
"symfony/http-client": "^4.3",
6970
"symfony/mercure-bundle": "*",
7071
"symfony/messenger": "^4.2",
7172
"symfony/phpunit-bridge": "^3.4.5 || ^4.0.5",
@@ -118,5 +119,7 @@
118119
"url": "https://github.com/alanpoulain/FOSUserBundle",
119120
"no-api": true
120121
}
121-
]
122+
],
123+
"minimum-stability": "dev",
124+
"prefer-stable": true
122125
}

src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
use Elasticsearch\Client;
3434
use phpDocumentor\Reflection\DocBlockFactoryInterface;
3535
use Ramsey\Uuid\Uuid;
36+
use Symfony\Component\BrowserKit\Client as BrowserKitClient;
3637
use Symfony\Component\Cache\Adapter\ArrayAdapter;
3738
use Symfony\Component\Config\FileLocator;
3839
use Symfony\Component\Config\Resource\DirectoryResource;
@@ -147,6 +148,14 @@ public function load(array $configs, ContainerBuilder $container)
147148
$this->registerMessengerConfiguration($config, $loader);
148149
$this->registerElasticsearchConfiguration($container, $config, $loader);
149150
$this->registerDataTransformerConfiguration($container);
151+
152+
if ('test' === $container->getParameter('kernel.environment')) {
153+
$loader->load('test.xml');
154+
155+
if (!class_exists(BrowserKitClient::class)) {
156+
$container->removeDefinition('test.api_platform.client');
157+
}
158+
}
150159
}
151160

152161
/**
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?xml version="1.0" ?>
2+
3+
<container xmlns="http://symfony.com/schema/dic/services"
4+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5+
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
6+
7+
<services>
8+
<service id="test.api_platform.client" class="ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Client" public="true">
9+
<argument type="service" id="test.client" />
10+
</service>
11+
</services>
12+
13+
</container>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Core\Bridge\Symfony\Bundle\Test;
15+
16+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
17+
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
18+
19+
/**
20+
* Base class for functional API tests.
21+
*
22+
* @experimental
23+
*
24+
* @author Kévin Dunglas <[email protected]>
25+
*/
26+
abstract class ApiTestCase extends KernelTestCase
27+
{
28+
/**
29+
* Creates a Client.
30+
*
31+
* @param array $options An array of options to pass to the createKernel method
32+
*
33+
* @return Client A Client instance
34+
*/
35+
protected static function createClient(array $options = [])
36+
{
37+
$kernel = static::bootKernel($options);
38+
39+
try {
40+
$client = $kernel->getContainer()->get('test.api_platform.client');
41+
} catch (ServiceNotFoundException $e) {
42+
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".');
43+
}
44+
45+
return $client;
46+
}
47+
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Core\Bridge\Symfony\Bundle\Test;
15+
16+
use Symfony\Bundle\FrameworkBundle\Client as FrameworkBundleClient;
17+
use Symfony\Component\DependencyInjection\ContainerInterface;
18+
use Symfony\Component\HttpClient\HttpClientTrait;
19+
use Symfony\Component\HttpKernel\KernelInterface;
20+
use Symfony\Component\HttpKernel\Profiler\Profile as HttpProfile;
21+
use Symfony\Contracts\HttpClient\HttpClientInterface;
22+
use Symfony\Contracts\HttpClient\ResponseInterface;
23+
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
24+
25+
/**
26+
* Convenient test client that makes requests to a Kernel object.
27+
*
28+
* @experimental
29+
*
30+
* @author Kévin Dunglas <[email protected]>
31+
*/
32+
final class Client implements HttpClientInterface
33+
{
34+
public const OPTIONS_DEFAULT = [
35+
'query' => [], // string[] - associative array of query string values to merge with the request's URL
36+
'headers' => ['accept' => ['application/ld+json']], // iterable|string[]|string[][] - headers names provided as keys or as part of values
37+
'body' => '', // array|string|resource|\Traversable|\Closure - the callback SHOULD yield a string
38+
'json' => null, // array|\JsonSerializable - when set, implementations MUST set the "body" option to
39+
// the JSON-encoded value and set the "content-type" headers to a JSON-compatible
40+
'base_uri' => 'http://example.com', // string - the URI to resolve relative URLs, following rules in RFC 3986, section 2
41+
'bearer' => null, // string - sets a corresponding Authorization token of the Bearer type
42+
];
43+
44+
use HttpClientTrait;
45+
46+
private $fwbClient;
47+
48+
public function __construct(FrameworkBundleClient $fwbClient)
49+
{
50+
$this->fwbClient = $fwbClient;
51+
$fwbClient->followRedirects(false);
52+
}
53+
54+
/**
55+
* @see Client::OPTIONS_DEFAULTS for available options
56+
*
57+
* {@inheritdoc}
58+
*/
59+
public function request(string $method, string $url, array $options = []): ResponseInterface
60+
{
61+
if (isset($options['body'])) {
62+
if (isset($options['headers'])) {
63+
$options['headers'] = self::normalizeHeaders($options['headers']);
64+
}
65+
66+
$json = false;
67+
if (!isset($options['headers']['content-type'][0])) {
68+
// Content-Type default to JSON-LD if a body is set, but no Content-Type is defined
69+
$options['headers']['content-type'] = $options['headers']['content-type'] ?? ['application/ld+json'];
70+
$json = true;
71+
}
72+
73+
if (
74+
(\is_array($options['body']) || $options['body'] instanceof \JsonSerializable) &&
75+
(
76+
$json ||
77+
false !== preg_match('#^application/(?:.+\+)?json$#', $options['headers']['content-type'][0])
78+
)
79+
) {
80+
// Encode the JSON
81+
$options['json'] = $options['body'];
82+
}
83+
}
84+
85+
if (isset($options['bearer']) && \is_string($options['bearer'])) {
86+
$options['headers']['authorization'] = ['Bearer '.$options['bearer']];
87+
}
88+
89+
[$url, $options] = $this->prepareRequest($method, $url, $options, self::OPTIONS_DEFAULT);
90+
91+
$server = [];
92+
// Convert headers to a $_SERVER-like array
93+
foreach ($options['headers'] as $key => $value) {
94+
if ('content-type' === strtolower($key)) {
95+
$server['CONTENT_TYPE'] = $value[0] ?? '';
96+
97+
continue;
98+
}
99+
100+
// BrowserKit doesn't support setting several headers with the same name
101+
$server['HTTP_'.strtoupper(str_replace('-', '_', $key))] = $value[0] ?? '';
102+
}
103+
104+
$info = [
105+
'redirect_count' => 0,
106+
'redirect_url' => null,
107+
'http_method' => $method,
108+
'start_time' => microtime(true),
109+
'data' => $options['data'] ?? null,
110+
'url' => $url,
111+
];
112+
$this->fwbClient->request($method, implode('', $url), [], [], $server, $options['body'] ?? null);
113+
114+
return new Response($this->fwbClient->getResponse(), $this->fwbClient->getInternalResponse(), $info);
115+
}
116+
117+
/**
118+
* {@inheritdoc}
119+
*/
120+
public function stream($responses, float $timeout = null): ResponseStreamInterface
121+
{
122+
throw new \LogicException('Not implemented yet');
123+
}
124+
125+
/**
126+
* Gets the underlying test client.
127+
*/
128+
public function getFrameworkBundleClient(): FrameworkBundleClient
129+
{
130+
return $this->fwbClient;
131+
}
132+
133+
// The following methods are proxy methods for FrameworkBundleClient's ones
134+
135+
/**
136+
* Returns the container.
137+
*
138+
* @return ContainerInterface|null Returns null when the Kernel has been shutdown or not started yet
139+
*/
140+
public function getContainer()
141+
{
142+
return $this->fwbClient->getContainer();
143+
}
144+
145+
/**
146+
* Returns the kernel.
147+
*
148+
* @return KernelInterface
149+
*/
150+
public function getKernel()
151+
{
152+
return $this->fwbClient->getKernel();
153+
}
154+
155+
/**
156+
* Gets the profile associated with the current Response.
157+
*
158+
* @return HttpProfile|false A Profile instance
159+
*/
160+
public function getProfile()
161+
{
162+
return $this->fwbClient->getProfile();
163+
}
164+
165+
/**
166+
* Enables the profiler for the very next request.
167+
*
168+
* If the profiler is not enabled, the call to this method does nothing.
169+
*/
170+
public function enableProfiler()
171+
{
172+
$this->fwbClient->enableProfiler();
173+
}
174+
175+
/**
176+
* Disables kernel reboot between requests.
177+
*
178+
* By default, the Client reboots the Kernel for each request. This method
179+
* allows to keep the same kernel across requests.
180+
*/
181+
public function disableReboot()
182+
{
183+
$this->fwbClient->disableReboot();
184+
}
185+
186+
/**
187+
* Enables kernel reboot between requests.
188+
*/
189+
public function enableReboot()
190+
{
191+
$this->fwbClient->enableReboot();
192+
}
193+
}

0 commit comments

Comments
 (0)