Skip to content

Commit 73ce8e5

Browse files
committed
feature symfony#61173 [HttpFoundation][HttpKernel][WebProfilerBundle] Add support for the QUERY HTTP method (alexandre-daubois)
This PR was merged into the 7.4 branch. Discussion ---------- [HttpFoundation][HttpKernel][WebProfilerBundle] Add support for the `QUERY` HTTP method | Q | A | ------------- | --- | Branch? | 7.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | Fix symfony#60521 | License | MIT Not much difference from `GET`, appart that the request body is used to generate the cache key in `HttpCache`. Webprofiler integration: <img width="2072" height="778" alt="image" src="https://github.com/user-attachments/assets/4a5c8c3a-e15b-4b96-9a7f-6edafecb67d8" /> Commits ------- 466c5d7 [HttpFoundation][HttpKernel][WebProfilerBundle] Add support for the `QUERY` HTTP method
2 parents 32db679 + 466c5d7 commit 73ce8e5

File tree

10 files changed

+198
-8
lines changed

10 files changed

+198
-8
lines changed

src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.4
5+
---
6+
7+
* Add support for the `QUERY` HTTP method in the profiler
8+
49
7.3
510
---
611

src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/search.html.twig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
{% if 'command' == profile_type %}
2626
{% set methods = ['BATCH', 'INTERACTIVE'] %}
2727
{% else %}
28-
{% set methods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'] %}
28+
{% set methods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'QUERY'] %}
2929
{% endif %}
3030
{% for m in methods %}
3131
<option {{ m == method ? 'selected="selected"' }}>{{ m }}</option>

src/Symfony/Component/HttpFoundation/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ CHANGELOG
55
---
66

77
* Deprecate using `Request::sendHeaders()` after headers have already been sent; use a `StreamedResponse` instead
8+
* Add support for the `QUERY` HTTP method
89

910
7.3
1011
---

src/Symfony/Component/HttpFoundation/Request.php

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class Request
6262
public const METHOD_OPTIONS = 'OPTIONS';
6363
public const METHOD_TRACE = 'TRACE';
6464
public const METHOD_CONNECT = 'CONNECT';
65+
public const METHOD_QUERY = 'QUERY';
6566

6667
/**
6768
* @var string[]
@@ -254,7 +255,7 @@ public static function createFromGlobals(): static
254255
$request = self::createRequestFromFactory($_GET, $_POST, [], $_COOKIE, $_FILES, $_SERVER);
255256

256257
if (str_starts_with($request->headers->get('CONTENT_TYPE', ''), 'application/x-www-form-urlencoded')
257-
&& \in_array(strtoupper($request->server->get('REQUEST_METHOD', 'GET')), ['PUT', 'DELETE', 'PATCH'], true)
258+
&& \in_array(strtoupper($request->server->get('REQUEST_METHOD', 'GET')), ['PUT', 'DELETE', 'PATCH', 'QUERY'], true)
258259
) {
259260
parse_str($request->getContent(), $data);
260261
$request->request = new InputBag($data);
@@ -350,6 +351,7 @@ public static function create(string $uri, string $method = 'GET', array $parame
350351
case 'POST':
351352
case 'PUT':
352353
case 'DELETE':
354+
case 'QUERY':
353355
if (!isset($server['CONTENT_TYPE'])) {
354356
$server['CONTENT_TYPE'] = 'application/x-www-form-urlencoded';
355357
}
@@ -1175,7 +1177,7 @@ public function getMethod(): string
11751177

11761178
$method = strtoupper($method);
11771179

1178-
if (\in_array($method, ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'PATCH', 'PURGE', 'TRACE'], true)) {
1180+
if (\in_array($method, ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'PATCH', 'PURGE', 'TRACE', 'QUERY'], true)) {
11791181
return $this->method = $method;
11801182
}
11811183

@@ -1351,15 +1353,15 @@ public function isMethod(string $method): bool
13511353
*/
13521354
public function isMethodSafe(): bool
13531355
{
1354-
return \in_array($this->getMethod(), ['GET', 'HEAD', 'OPTIONS', 'TRACE'], true);
1356+
return \in_array($this->getMethod(), ['GET', 'HEAD', 'OPTIONS', 'TRACE', 'QUERY'], true);
13551357
}
13561358

13571359
/**
13581360
* Checks whether or not the method is idempotent.
13591361
*/
13601362
public function isMethodIdempotent(): bool
13611363
{
1362-
return \in_array($this->getMethod(), ['HEAD', 'GET', 'PUT', 'DELETE', 'TRACE', 'OPTIONS', 'PURGE'], true);
1364+
return \in_array($this->getMethod(), ['HEAD', 'GET', 'PUT', 'DELETE', 'TRACE', 'OPTIONS', 'PURGE', 'QUERY'], true);
13631365
}
13641366

13651367
/**
@@ -1369,7 +1371,7 @@ public function isMethodIdempotent(): bool
13691371
*/
13701372
public function isMethodCacheable(): bool
13711373
{
1372-
return \in_array($this->getMethod(), ['GET', 'HEAD'], true);
1374+
return \in_array($this->getMethod(), ['GET', 'HEAD', 'QUERY'], true);
13731375
}
13741376

13751377
/**

src/Symfony/Component/HttpFoundation/Tests/RequestTest.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2285,6 +2285,7 @@ public static function methodIdempotentProvider()
22852285
['OPTIONS', true],
22862286
['TRACE', true],
22872287
['CONNECT', false],
2288+
['QUERY', true],
22882289
];
22892290
}
22902291

@@ -2309,6 +2310,7 @@ public static function methodSafeProvider()
23092310
['OPTIONS', true],
23102311
['TRACE', true],
23112312
['CONNECT', false],
2313+
['QUERY', true],
23122314
];
23132315
}
23142316

@@ -2333,6 +2335,7 @@ public static function methodCacheableProvider()
23332335
['OPTIONS', false],
23342336
['TRACE', false],
23352337
['CONNECT', false],
2338+
['QUERY', true],
23362339
];
23372340
}
23382341

src/Symfony/Component/HttpKernel/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
7.4
55
---
66

7+
* Add support for the `QUERY` HTTP method
78
* Deprecate implementing `__sleep/wakeup()` on kernels; use `__(un)serialize()` instead
89
* Deprecate implementing `__sleep/wakeup()` on data collectors; use `__(un)serialize()` instead
910
* Make `Profile` final and `Profiler::__sleep()` internal

src/Symfony/Component/HttpKernel/HttpCache/Store.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,15 @@ public function getPath(string $key): string
427427
*/
428428
protected function generateCacheKey(Request $request): string
429429
{
430-
return 'md'.hash('sha256', $request->getUri());
430+
$key = $request->getUri();
431+
432+
if ('QUERY' === $request->getMethod()) {
433+
// add null byte to separate the URI from the body and avoid boundary collisions
434+
// which could lead to cache poisoning
435+
$key .= "\0".$request->getContent();
436+
}
437+
438+
return 'md'.hash('sha256', $key);
431439
}
432440

433441
/**

src/Symfony/Component/HttpKernel/Tests/HttpCache/HttpCacheTest.php

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2062,6 +2062,93 @@ public function testTraceLevelShort()
20622062
$this->assertTrue($this->response->headers->has('X-Symfony-Cache'));
20632063
$this->assertEquals('miss', $this->response->headers->get('X-Symfony-Cache'));
20642064
}
2065+
2066+
public function testQueryMethodIsCacheable()
2067+
{
2068+
$this->setNextResponse(200, ['Cache-Control' => 'public, max-age=10000'], 'Query result', function (Request $request) {
2069+
$this->assertSame('QUERY', $request->getMethod());
2070+
2071+
return '{"query": "users"}' === $request->getContent();
2072+
});
2073+
2074+
$this->kernel->reset();
2075+
$this->store = $this->createStore();
2076+
$this->cacheConfig['debug'] = true;
2077+
$this->cache = new HttpCache($this->kernel, $this->store, null, $this->cacheConfig);
2078+
2079+
$request1 = Request::create('/', 'QUERY', [], [], [], [], '{"query": "users"}');
2080+
$this->response = $this->cache->handle($request1, HttpKernelInterface::MAIN_REQUEST, $this->catch);
2081+
2082+
$this->assertSame(200, $this->response->getStatusCode());
2083+
$this->assertTraceContains('miss');
2084+
$this->assertSame('Query result', $this->response->getContent());
2085+
2086+
$request2 = Request::create('/', 'QUERY', [], [], [], [], '{"query": "users"}');
2087+
$this->response = $this->cache->handle($request2, HttpKernelInterface::MAIN_REQUEST, $this->catch);
2088+
2089+
$this->assertSame(200, $this->response->getStatusCode());
2090+
$this->assertTrue($this->response->headers->has('Age'));
2091+
$this->assertSame('Query result', $this->response->getContent());
2092+
}
2093+
2094+
public function testQueryMethodDifferentBodiesCreateDifferentCacheEntries()
2095+
{
2096+
$this->setNextResponses([
2097+
[
2098+
'status' => 200,
2099+
'body' => 'Users result',
2100+
'headers' => ['Cache-Control' => 'public, max-age=10000'],
2101+
],
2102+
[
2103+
'status' => 200,
2104+
'body' => 'Posts result',
2105+
'headers' => ['Cache-Control' => 'public, max-age=10000'],
2106+
],
2107+
]);
2108+
2109+
$this->store = $this->createStore();
2110+
$this->cacheConfig['debug'] = true;
2111+
$this->cache = new HttpCache($this->kernel, $this->store, null, $this->cacheConfig);
2112+
2113+
$request1 = Request::create('/', 'QUERY', [], [], [], [], '{"query": "users"}');
2114+
$this->response = $this->cache->handle($request1, HttpKernelInterface::MAIN_REQUEST, $this->catch);
2115+
2116+
$this->assertSame('Users result', $this->response->getContent());
2117+
$this->assertTraceContains('miss');
2118+
2119+
$request2 = Request::create('/', 'QUERY', [], [], [], [], '{"query": "posts"}');
2120+
$this->response = $this->cache->handle($request2, HttpKernelInterface::MAIN_REQUEST, $this->catch);
2121+
2122+
$this->assertSame('Posts result', $this->response->getContent());
2123+
$this->assertTraceContains('miss');
2124+
2125+
$request3 = Request::create('/', 'QUERY', [], [], [], [], '{"query": "users"}');
2126+
$this->response = $this->cache->handle($request3, HttpKernelInterface::MAIN_REQUEST, $this->catch);
2127+
2128+
$this->assertSame('Users result', $this->response->getContent());
2129+
$this->assertTrue($this->response->headers->has('Age'));
2130+
}
2131+
2132+
public function testQueryMethodWithEmptyBodyIsCacheable()
2133+
{
2134+
$this->setNextResponse(200, ['Cache-Control' => 'public, max-age=10000'], 'Empty query result');
2135+
$this->kernel->reset();
2136+
$this->store = $this->createStore();
2137+
$this->cacheConfig['debug'] = true;
2138+
$this->cache = new HttpCache($this->kernel, $this->store, null, $this->cacheConfig);
2139+
2140+
$request1 = Request::create('/', 'QUERY', [], [], [], [], '');
2141+
$this->response = $this->cache->handle($request1, HttpKernelInterface::MAIN_REQUEST, $this->catch);
2142+
2143+
$this->assertSame(200, $this->response->getStatusCode());
2144+
$this->assertTraceContains('miss');
2145+
2146+
$request2 = Request::create('/', 'QUERY', [], [], [], [], '');
2147+
$this->response = $this->cache->handle($request2, HttpKernelInterface::MAIN_REQUEST, $this->catch);
2148+
2149+
$this->assertSame(200, $this->response->getStatusCode());
2150+
$this->assertTrue($this->response->headers->has('Age'));
2151+
}
20652152
}
20662153

20672154
class TestKernel implements HttpKernelInterface

src/Symfony/Component/HttpKernel/Tests/HttpCache/StoreTest.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,4 +378,87 @@ protected function getStorePath($key)
378378

379379
return $m->invoke($this->store, $key);
380380
}
381+
382+
public function testQueryMethodCacheKeyIncludesBody()
383+
{
384+
$response = new Response('test', 200, ['Cache-Control' => 'max-age=420']);
385+
386+
$request1 = Request::create('/', 'QUERY', [], [], [], [], '{"query": "users"}');
387+
$request2 = Request::create('/', 'QUERY', [], [], [], [], '{"query": "posts"}');
388+
$request3 = Request::create('/', 'QUERY', [], [], [], [], '{"query": "users"}');
389+
390+
$key1 = $this->store->write($request1, $response);
391+
$key2 = $this->store->write($request2, $response);
392+
$key3 = $this->store->write($request3, $response);
393+
394+
$this->assertNotSame($key1, $key2);
395+
$this->assertSame($key1, $key3);
396+
397+
$this->assertNotEmpty($this->getStoreMetadata($key1));
398+
$this->assertNotEmpty($this->getStoreMetadata($key2));
399+
400+
$this->assertNotNull($this->store->lookup($request1));
401+
$this->assertNotNull($this->store->lookup($request2));
402+
$this->assertNotNull($this->store->lookup($request3));
403+
}
404+
405+
public function testQueryMethodCacheKeyDiffersFromGet()
406+
{
407+
$response = new Response('test', 200, ['Cache-Control' => 'max-age=420']);
408+
409+
$getRequest = Request::create('/');
410+
$queryRequest = Request::create('/', 'QUERY', [], [], [], [], '{"query": "test"}');
411+
412+
$getKey = $this->store->write($getRequest, $response);
413+
$queryKey = $this->store->write($queryRequest, $response);
414+
415+
$this->assertNotSame($getKey, $queryKey);
416+
417+
$this->assertNotEmpty($this->getStoreMetadata($getKey));
418+
$this->assertNotEmpty($this->getStoreMetadata($queryKey));
419+
420+
$this->assertNotNull($this->store->lookup($getRequest));
421+
$this->assertNotNull($this->store->lookup($queryRequest));
422+
}
423+
424+
public function testOtherMethodsCacheKeyIgnoresBody()
425+
{
426+
$response1 = new Response('test 1', 200, ['Cache-Control' => 'max-age=420']);
427+
$response2 = new Response('test 2', 200, ['Cache-Control' => 'max-age=420']);
428+
429+
$getRequest1 = Request::create('/', 'GET', [], [], [], [], '{"data": "test"}');
430+
$getRequest2 = Request::create('/', 'GET', [], [], [], [], '{"data": "different"}');
431+
432+
$key1 = $this->store->write($getRequest1, $response1);
433+
$key2 = $this->store->write($getRequest2, $response2);
434+
435+
$this->assertSame($key1, $key2);
436+
437+
$lookup1 = $this->store->lookup($getRequest1);
438+
$lookup2 = $this->store->lookup($getRequest2);
439+
$this->assertNotNull($lookup1);
440+
$this->assertNotNull($lookup2);
441+
442+
$this->assertCount(1, $this->getStoreMetadata($key1));
443+
$this->assertSame($lookup1->getContent(), $lookup2->getContent());
444+
}
445+
446+
public function testQueryMethodCacheKeyAvoidsBoundaryCollisions()
447+
{
448+
$response = new Response('test', 200, ['Cache-Control' => 'max-age=420']);
449+
450+
$request1 = Request::create('/api/query', 'QUERY', [], [], [], [], 'test');
451+
$request2 = Request::create('/api/que', 'QUERY', [], [], [], [], 'rytest');
452+
453+
$key1 = $this->store->write($request1, $response);
454+
$key2 = $this->store->write($request2, $response);
455+
456+
$this->assertNotSame($key1, $key2);
457+
458+
$this->assertNotEmpty($this->getStoreMetadata($key1));
459+
$this->assertNotEmpty($this->getStoreMetadata($key2));
460+
461+
$this->assertNotNull($this->store->lookup($request1));
462+
$this->assertNotNull($this->store->lookup($request2));
463+
}
381464
}

src/Symfony/Component/HttpKernel/composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"symfony/deprecation-contracts": "^2.5|^3",
2121
"symfony/error-handler": "^6.4|^7.0|^8.0",
2222
"symfony/event-dispatcher": "^7.3|^8.0",
23-
"symfony/http-foundation": "^7.3|^8.0",
23+
"symfony/http-foundation": "^7.4|^8.0",
2424
"symfony/polyfill-ctype": "^1.8",
2525
"psr/log": "^1|^2|^3"
2626
},

0 commit comments

Comments
 (0)