Skip to content

Commit 45db684

Browse files
authored
Merge pull request #222 from php-http/fix/remove-request-body-on-redirect
remove body on redirection if needed
2 parents 92b7425 + a48935c commit 45db684

File tree

5 files changed

+144
-5
lines changed

5 files changed

+144
-5
lines changed

CHANGELOG.md

+7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Change Log
22

3+
## 2.6.0 - 2022-09-29
4+
5+
- [RedirectPlugin] Redirection of non GET/HEAD requests with a body now removes the body on follow-up requests, if the
6+
HTTP method changes. To do this, the plugin needs to find a PSR-7 stream implementation. If none is found, you can
7+
explicitly pass a PSR-17 StreamFactoryInterface in the `stream_factory` option.
8+
To keep sending the body in all cases, set the `stream_factory` option to null explicitly.
9+
310
## 2.5.1 - 2022-09-29
411

512
### Fixed

phpstan.neon.dist

+12
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ parameters:
3636
count: 1
3737
path: src/Plugin/RedirectPlugin.php
3838

39+
# phpstan is confused by the optional dependencies. we check for existence first
40+
-
41+
message: "#^Method Http\\\\Client\\\\Common\\\\Plugin\\\\RedirectPlugin::guessStreamFactory\\(\\) should return Psr\\\\Http\\\\Message\\\\StreamFactoryInterface\\|null but returns Nyholm\\\\Psr7\\\\Factory\\\\Psr17Factory\\.$#"
42+
count: 1
43+
path: src/Plugin/RedirectPlugin.php
44+
45+
# phpstan is confused by the optional dependencies. we check for existence first
46+
-
47+
message: "#^Call to static method streamFor\\(\\) on an unknown class GuzzleHttp\\\\Psr7\\\\Utils\\.$#"
48+
count: 1
49+
path: src/Plugin/RedirectPlugin.php
50+
3951
-
4052
message: "#^Method Http\\\\Client\\\\Common\\\\Plugin\\\\RetryPlugin\\:\\:retry\\(\\) should return Psr\\\\Http\\\\Message\\\\ResponseInterface but returns mixed\\.$#"
4153
count: 1

spec/Plugin/RedirectPluginSpec.php

+8-1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public function it_redirects_on_302(
3737
ResponseInterface $finalResponse,
3838
Promise $promise
3939
) {
40+
$this->beConstructedWith(['stream_factory' => null]);
4041
$responseRedirect->getStatusCode()->willReturn(302);
4142
$responseRedirect->hasHeader('Location')->willReturn(true);
4243
$responseRedirect->getHeaderLine('Location')->willReturn('/redirect');
@@ -81,6 +82,7 @@ public function it_use_storage_on_301(
8182
ResponseInterface $finalResponse,
8283
ResponseInterface $redirectResponse
8384
) {
85+
$this->beConstructedWith(['stream_factory' => null]);
8486
$request->getUri()->willReturn($uri);
8587
$uri->__toString()->willReturn('/original');
8688
$uri->withPath('/redirect')->willReturn($uriRedirect);
@@ -153,6 +155,7 @@ public function it_replace_full_url(
153155
ResponseInterface $finalResponse,
154156
Promise $promise
155157
) {
158+
$this->beConstructedWith(['stream_factory' => null]);
156159
$request->getUri()->willReturn($uri);
157160
$uri->__toString()->willReturn('/original');
158161

@@ -275,6 +278,7 @@ public function it_switch_method_for_302(
275278
ResponseInterface $finalResponse,
276279
Promise $promise
277280
) {
281+
$this->beConstructedWith(['stream_factory' => null]);
278282
$request->getUri()->willReturn($uri);
279283
$uri->__toString()->willReturn('/original');
280284

@@ -367,7 +371,10 @@ public function it_clears_headers(
367371
ResponseInterface $finalResponse,
368372
Promise $promise
369373
) {
370-
$this->beConstructedWith(['preserve_header' => ['Accept']]);
374+
$this->beConstructedWith([
375+
'preserve_header' => ['Accept'],
376+
'stream_factory' => null,
377+
]);
371378

372379
$request->getUri()->willReturn($uri);
373380
$uri->__toString()->willReturn('/original');

src/Plugin/RedirectPlugin.php

+65-3
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@
44

55
namespace Http\Client\Common\Plugin;
66

7+
use GuzzleHttp\Psr7\Utils;
78
use Http\Client\Common\Exception\CircularRedirectionException;
89
use Http\Client\Common\Exception\MultipleRedirectionException;
910
use Http\Client\Common\Plugin;
1011
use Http\Client\Exception\HttpException;
12+
use Http\Discovery\Psr17FactoryDiscovery;
1113
use Http\Promise\Promise;
14+
use Nyholm\Psr7\Factory\Psr17Factory;
1215
use Psr\Http\Message\RequestInterface;
1316
use Psr\Http\Message\ResponseInterface;
17+
use Psr\Http\Message\StreamFactoryInterface;
18+
use Psr\Http\Message\StreamInterface;
1419
use Psr\Http\Message\UriInterface;
20+
use Symfony\Component\OptionsResolver\Options;
1521
use Symfony\Component\OptionsResolver\OptionsResolver;
1622

1723
/**
@@ -101,13 +107,19 @@ final class RedirectPlugin implements Plugin
101107
*/
102108
private $circularDetection = [];
103109

110+
/**
111+
* @var StreamFactoryInterface|null
112+
*/
113+
private $streamFactory;
114+
104115
/**
105116
* @param array{'preserve_header'?: bool|string[], 'use_default_for_multiple'?: bool, 'strict'?: bool} $config
106117
*
107118
* Configuration options:
108119
* - preserve_header: True keeps all headers, false remove all of them, an array is interpreted as a list of header names to keep
109120
* - use_default_for_multiple: Whether the location header must be directly used for a multiple redirection status code (300)
110121
* - strict: When true, redirect codes 300, 301, 302 will not modify request method and body
122+
* - stream_factory: If set, must be a PSR-17 StreamFactoryInterface - if not set, we try to discover one
111123
*/
112124
public function __construct(array $config = [])
113125
{
@@ -116,17 +128,22 @@ public function __construct(array $config = [])
116128
'preserve_header' => true,
117129
'use_default_for_multiple' => true,
118130
'strict' => false,
131+
'stream_factory' => null,
119132
]);
120133
$resolver->setAllowedTypes('preserve_header', ['bool', 'array']);
121134
$resolver->setAllowedTypes('use_default_for_multiple', 'bool');
122135
$resolver->setAllowedTypes('strict', 'bool');
136+
$resolver->setAllowedTypes('stream_factory', [StreamFactoryInterface::class, 'null']);
123137
$resolver->setNormalizer('preserve_header', function (OptionsResolver $resolver, $value) {
124138
if (is_bool($value) && false === $value) {
125139
return [];
126140
}
127141

128142
return $value;
129143
});
144+
$resolver->setDefault('stream_factory', function (Options $options): ?StreamFactoryInterface {
145+
return $this->guessStreamFactory();
146+
});
130147
$options = $resolver->resolve($config);
131148

132149
$this->preserveHeader = $options['preserve_header'];
@@ -137,6 +154,8 @@ public function __construct(array $config = [])
137154
$this->redirectCodes[301]['switch'] = false;
138155
$this->redirectCodes[302]['switch'] = false;
139156
}
157+
158+
$this->streamFactory = $options['stream_factory'];
140159
}
141160

142161
/**
@@ -170,7 +189,7 @@ public function handleRequest(RequestInterface $request, callable $next, callabl
170189

171190
$this->circularDetection[$chainIdentifier][] = (string) $request->getUri();
172191

173-
if (in_array((string) $redirectRequest->getUri(), $this->circularDetection[$chainIdentifier])) {
192+
if (in_array((string) $redirectRequest->getUri(), $this->circularDetection[$chainIdentifier], true)) {
174193
throw new CircularRedirectionException('Circular redirection detected', $request, $response);
175194
}
176195

@@ -186,19 +205,62 @@ public function handleRequest(RequestInterface $request, callable $next, callabl
186205
});
187206
}
188207

208+
/**
209+
* The default only needs to be determined if no value is provided.
210+
*/
211+
public function guessStreamFactory(): ?StreamFactoryInterface
212+
{
213+
if (class_exists(Psr17FactoryDiscovery::class)) {
214+
try {
215+
return Psr17FactoryDiscovery::findStreamFactory();
216+
} catch (\Throwable $t) {
217+
// ignore and try other options
218+
}
219+
}
220+
if (class_exists(Psr17Factory::class)) {
221+
return new Psr17Factory();
222+
}
223+
if (class_exists(Utils::class)) {
224+
return new class() implements StreamFactoryInterface {
225+
public function createStream(string $content = ''): StreamInterface
226+
{
227+
return Utils::streamFor($content);
228+
}
229+
230+
public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface
231+
{
232+
throw new \RuntimeException('Internal error: this method should not be needed');
233+
}
234+
235+
public function createStreamFromResource($resource): StreamInterface
236+
{
237+
throw new \RuntimeException('Internal error: this method should not be needed');
238+
}
239+
};
240+
}
241+
242+
return null;
243+
}
244+
189245
private function buildRedirectRequest(RequestInterface $originalRequest, UriInterface $targetUri, int $statusCode): RequestInterface
190246
{
191247
$originalRequest = $originalRequest->withUri($targetUri);
192248

193-
if (false !== $this->redirectCodes[$statusCode]['switch'] && !in_array($originalRequest->getMethod(), $this->redirectCodes[$statusCode]['switch']['unless'])) {
249+
if (false !== $this->redirectCodes[$statusCode]['switch'] && !in_array($originalRequest->getMethod(), $this->redirectCodes[$statusCode]['switch']['unless'], true)) {
194250
$originalRequest = $originalRequest->withMethod($this->redirectCodes[$statusCode]['switch']['to']);
251+
if ('GET' === $this->redirectCodes[$statusCode]['switch']['to'] && $this->streamFactory) {
252+
// if we found a stream factory, remove the request body. otherwise leave the body there.
253+
$originalRequest = $originalRequest->withoutHeader('content-type');
254+
$originalRequest = $originalRequest->withoutHeader('content-length');
255+
$originalRequest = $originalRequest->withBody($this->streamFactory->createStream());
256+
}
195257
}
196258

197259
if (is_array($this->preserveHeader)) {
198260
$headers = array_keys($originalRequest->getHeaders());
199261

200262
foreach ($headers as $name) {
201-
if (!in_array($name, $this->preserveHeader)) {
263+
if (!in_array($name, $this->preserveHeader, true)) {
202264
$originalRequest = $originalRequest->withoutHeader($name);
203265
}
204266
}

tests/Plugin/RedirectPluginTest.php

+52-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Http\Client\Common\Exception\CircularRedirectionException;
88
use Http\Client\Common\Plugin\RedirectPlugin;
99
use Http\Promise\FulfilledPromise;
10+
use Nyholm\Psr7\Factory\Psr17Factory;
1011
use Nyholm\Psr7\Request;
1112
use Nyholm\Psr7\Response;
1213
use PHPUnit\Framework\TestCase;
@@ -27,6 +28,56 @@ function () {}
2728
)->wait();
2829
}
2930

31+
public function testPostGetDropRequestBody(): void
32+
{
33+
$response = (new RedirectPlugin())->handleRequest(
34+
new Request('POST', 'https://example.com/path', ['Content-Type' => 'text/plain', 'Content-Length' => '10'], (new Psr17Factory())->createStream('hello test')),
35+
function (RequestInterface $request) {
36+
$this->assertSame(10, $request->getBody()->getSize());
37+
$this->assertTrue($request->hasHeader('Content-Type'));
38+
$this->assertTrue($request->hasHeader('Content-Length'));
39+
40+
return new FulfilledPromise(new Response(302, ['Location' => 'https://example.com/other']));
41+
},
42+
function (RequestInterface $request) {
43+
$this->assertSame('GET', $request->getMethod());
44+
$this->assertSame(0, $request->getBody()->getSize());
45+
$this->assertFalse($request->hasHeader('Content-Type'));
46+
$this->assertFalse($request->hasHeader('Content-Length'));
47+
48+
return new FulfilledPromise(new Response(200, ['uri' => $request->getUri()->__toString()]));
49+
}
50+
)->wait();
51+
52+
$this->assertSame('https://example.com/other', $response->getHeaderLine('uri'));
53+
}
54+
55+
public function testPostGetNoFactory(): void
56+
{
57+
// We explicitly set the stream factory to null. Same happens if no factory can be found.
58+
// In this case, the redirect will leave the body alone.
59+
$response = (new RedirectPlugin(['stream_factory' => null]))->handleRequest(
60+
new Request('POST', 'https://example.com/path', ['Content-Type' => 'text/plain', 'Content-Length' => '10'], (new Psr17Factory())->createStream('hello test')),
61+
function (RequestInterface $request) {
62+
$this->assertSame(10, $request->getBody()->getSize());
63+
$this->assertTrue($request->hasHeader('Content-Type'));
64+
$this->assertTrue($request->hasHeader('Content-Length'));
65+
66+
return new FulfilledPromise(new Response(302, ['Location' => 'https://example.com/other']));
67+
},
68+
function (RequestInterface $request) {
69+
$this->assertSame('GET', $request->getMethod());
70+
$this->assertSame(10, $request->getBody()->getSize());
71+
$this->assertTrue($request->hasHeader('Content-Type'));
72+
$this->assertTrue($request->hasHeader('Content-Length'));
73+
74+
return new FulfilledPromise(new Response(200, ['uri' => $request->getUri()->__toString()]));
75+
}
76+
)->wait();
77+
78+
$this->assertSame('https://example.com/other', $response->getHeaderLine('uri'));
79+
}
80+
3081
public function provideRedirections(): array
3182
{
3283
return [
@@ -60,6 +111,6 @@ function (RequestInterface $request) {
60111
}
61112
)->wait();
62113
$this->assertInstanceOf(ResponseInterface::class, $response);
63-
$this->assertEquals($targetUri, $response->getHeaderLine('uri'));
114+
$this->assertSame($targetUri, $response->getHeaderLine('uri'));
64115
}
65116
}

0 commit comments

Comments
 (0)