From 0e51e12359457a7f38fd061965d5ad5a0f6deaa4 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 3 Jul 2026 08:31:44 +0530 Subject: [PATCH] (feat): implement the HTTP QUERY method (RFC 10008) Adds first-class support for the QUERY method: routing via Http::query() and Router buckets, request body parsing in the FPM and Swoole adapters, the RFC-mandated 400 on missing Content-Type, and a Response::setAcceptQuery() helper emitting the Accept-Query structured field (RFC 9651). --- README.md | 21 +++++++++++++ src/Http/Adapter/FPM/Request.php | 3 +- src/Http/Adapter/Swoole/Request.php | 3 +- src/Http/Http.php | 20 +++++++++++++ src/Http/Request.php | 2 ++ src/Http/Response.php | 19 ++++++++++++ src/Http/Router.php | 2 ++ tests/HttpTest.php | 46 +++++++++++++++++++++++++++++ tests/RequestTest.php | 11 +++++++ tests/ResponseTest.php | 10 +++++++ tests/RouterTest.php | 10 +++++++ 11 files changed, 145 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 06d9d55..ad2ce0f 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,27 @@ Http::routes([Http::REQUEST_METHOD_GET, Http::REQUEST_METHOD_POST], '/oauth/user Path aliases and multiple methods combine: a route with both responds on every method under every path. Use `getMethods()` to inspect the methods a route was registered with, and use the request resource to tell how a request arrived. +### The QUERY Method + +The `QUERY` method ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)) is a safe, idempotent request method that carries the query as request content — useful when query parameters are too large or too sensitive for the URL. Register QUERY routes with `Http::query()`; the request body is parsed the same way as for POST, so JSON payloads map to params: + +```php +Http::query('/documents/search') + ->param('filter', '', new Text(2048), 'Filter expression') + ->inject('response') + ->action(function(string $filter, Response $response) { + $response->json(['results' => []]); + }); +``` + +Per the RFC, a QUERY request without a `Content-Type` header is rejected with a 400 error before the route action runs. To advertise the query formats a resource accepts, use `Response::setAcceptQuery()`, which serializes the `Accept-Query` header as an RFC 9651 structured field: + +```php +$response->setAcceptQuery(['application/json', 'application/sql']); +``` + +Note: serving QUERY requires the underlying server to accept the method — PHP-FPM passes any method through, while Swoole support depends on the Swoole version's HTTP parser recognizing `QUERY`. + ### Hooks There are three types of hooks: diff --git a/src/Http/Adapter/FPM/Request.php b/src/Http/Adapter/FPM/Request.php index 2f3db6b..9143b03 100644 --- a/src/Http/Adapter/FPM/Request.php +++ b/src/Http/Adapter/FPM/Request.php @@ -260,7 +260,8 @@ protected function generateInput(): array self::METHOD_POST, self::METHOD_PUT, self::METHOD_PATCH, - self::METHOD_DELETE => $this->payload, + self::METHOD_DELETE, + self::METHOD_QUERY => $this->payload, default => $this->queryString, }; } diff --git a/src/Http/Adapter/Swoole/Request.php b/src/Http/Adapter/Swoole/Request.php index 3c4e19e..2c7d82b 100644 --- a/src/Http/Adapter/Swoole/Request.php +++ b/src/Http/Adapter/Swoole/Request.php @@ -280,7 +280,8 @@ protected function generateInput(): array self::METHOD_POST, self::METHOD_PUT, self::METHOD_PATCH, - self::METHOD_DELETE => $this->payload, + self::METHOD_DELETE, + self::METHOD_QUERY => $this->payload, default => $this->queryString, }; } diff --git a/src/Http/Http.php b/src/Http/Http.php index e952fd8..d27f3fd 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -29,6 +29,8 @@ class Http public const string REQUEST_METHOD_DELETE = 'DELETE'; + public const string REQUEST_METHOD_QUERY = 'QUERY'; + public const string REQUEST_METHOD_OPTIONS = 'OPTIONS'; public const string REQUEST_METHOD_HEAD = 'HEAD'; @@ -226,6 +228,18 @@ public static function delete(string $url): Route return self::routes(self::REQUEST_METHOD_DELETE, $url); } + /** + * QUERY + * + * Add QUERY request route (RFC 10008). QUERY is a safe, idempotent + * method whose request content describes the query to evaluate against + * the target resource. + */ + public static function query(string $url): Route + { + return self::routes(self::REQUEST_METHOD_QUERY, $url); + } + /** * ROUTES * @@ -626,6 +640,12 @@ public function execute(Request $request, Response $response): static $groups = $route->getGroups(); try { + // RFC 10008 Section 2: servers MUST fail a QUERY request whose + // Content-Type field is missing. + if (self::REQUEST_METHOD_QUERY === $method && $request->getHeaderLine('content-type') === '') { + throw new Exception('Content-Type header is required for QUERY requests', 400); + } + if ($route->getHook()) { foreach (self::$init as $hook) { // Global init hooks if (\in_array('*', $hook->getGroups())) { diff --git a/src/Http/Request.php b/src/Http/Request.php index d286213..9f17c72 100755 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -21,6 +21,8 @@ abstract class Request public const string METHOD_DELETE = 'DELETE'; + public const string METHOD_QUERY = 'QUERY'; + public const string METHOD_TRACE = 'TRACE'; public const string METHOD_CONNECT = 'CONNECT'; diff --git a/src/Http/Response.php b/src/Http/Response.php index 6660e49..c7948de 100755 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -465,6 +465,25 @@ public function addHeader(string $key, string $value): static return $this; } + /** + * Set Accept-Query header + * + * Advertise the media types accepted as QUERY request content (RFC 10008 + * Section 3). Media types are serialized as an RFC 9651 Structured Field + * list of strings, e.g. `Accept-Query: "application/sql", "application/jsonpath"`. + * + * @param array $mediaTypes + */ + public function setAcceptQuery(array $mediaTypes): static + { + $items = array_map( + fn(string $type) => '"' . addcslashes($type, '"\\') . '"', + $mediaTypes, + ); + + return $this->setHeader('Accept-Query', implode(', ', $items)); + } + /** * Remove header * diff --git a/src/Http/Router.php b/src/Http/Router.php index 09744ba..51947ed 100644 --- a/src/Http/Router.php +++ b/src/Http/Router.php @@ -28,6 +28,7 @@ class Router Http::REQUEST_METHOD_PUT => [], Http::REQUEST_METHOD_PATCH => [], Http::REQUEST_METHOD_DELETE => [], + Http::REQUEST_METHOD_QUERY => [], ]; /** @@ -269,6 +270,7 @@ public static function reset(): void Http::REQUEST_METHOD_PUT => [], Http::REQUEST_METHOD_PATCH => [], Http::REQUEST_METHOD_DELETE => [], + Http::REQUEST_METHOD_QUERY => [], ]; } } diff --git a/tests/HttpTest.php b/tests/HttpTest.php index 079f430..0ce2985 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -250,6 +250,52 @@ public function testCanExecuteRoute(): void $this->assertSame('init-' . $resource . '-(init-homepage)-param-x*param-y-(shutdown-homepage)-shutdown', $result); } + public function testCanExecuteQueryRoute(): void + { + $_SERVER['REQUEST_METHOD'] = 'QUERY'; + $_SERVER['REQUEST_URI'] = '/search'; + + Http::query('/search') + ->param('q', '', new Text(200), 'query expression', true) + ->action(function ($q) { + echo 'results:' . $q; + }); + + $request = new Request(); + $request->setHeader('content-type', 'application/json'); + $request->setPayload(['q' => 'find-me']); + + ob_start(); + $this->http->execute($request, new Response()); + $result = ob_get_clean(); + + $this->assertSame('results:find-me', $result); + } + + public function testQueryRouteRequiresContentType(): void + { + $_SERVER['REQUEST_METHOD'] = 'QUERY'; + $_SERVER['REQUEST_URI'] = '/search'; + unset($_SERVER['HTTP_CONTENT_TYPE'], $_SERVER['CONTENT_TYPE']); + + $this->http + ->error() + ->inject('error') + ->action(function ($error) { + echo 'error-' . $error->getCode() . ':' . $error->getMessage(); + }); + + Http::query('/search')->action(function () { + echo 'should-not-run'; + }); + + ob_start(); + $this->http->execute(new Request(), new Response()); + $result = ob_get_clean(); + + $this->assertSame('error-400:Content-Type header is required for QUERY requests', $result); + } + public function testCanExecuteRouteWithMultipleMethods(): void { Http::routes([Http::REQUEST_METHOD_GET, Http::REQUEST_METHOD_POST], '/v1/oauth/userinfo') diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 3142163..6c4cbd6 100755 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -120,6 +120,17 @@ public function testCanSetPayload(): void $this->assertSame('test', $this->request->getPayload('unknown', 'test')); } + public function testQueryMethodParamsComeFromPayload(): void + { + $_SERVER['REQUEST_METHOD'] = 'QUERY'; + $_GET['ignored'] = 'query-string-value'; + + $this->request->setPayload(['q' => 'find-me']); + + $this->assertSame(['q' => 'find-me'], $this->request->getParams()); + $this->assertSame('find-me', $this->request->getParam('q')); + } + public function testCanGetRawPayload(): void { $this->assertSame('', $this->request->getRawPayload()); diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 160c0d3..ebac637 100755 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -29,6 +29,16 @@ public function testCanSetContentType(): void $this->assertInstanceOf('Utopia\Http\Response', $contentType); } + public function testCanSetAcceptQuery(): void + { + $this->response->setAcceptQuery(['application/sql', 'application/jsonpath']); + + $this->assertSame( + '"application/sql", "application/jsonpath"', + $this->response->getHeaderLine('Accept-Query'), + ); + } + public function testCanSetStatus(): void { $status = $this->response->setStatusCode(Response::STATUS_CODE_OK); diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 42d5ec5..447806e 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -33,6 +33,16 @@ public function testCanMatchUrl(): void $this->assertEquals($routeAboutMe, Router::match(Http::REQUEST_METHOD_GET, '/about/me')?->route); } + public function testCanMatchQueryMethod(): void + { + $routeSearch = new Route(Http::REQUEST_METHOD_QUERY, '/documents/search'); + + Router::addRoute($routeSearch); + + $this->assertEquals($routeSearch, Router::match(Http::REQUEST_METHOD_QUERY, '/documents/search')?->route); + $this->assertNull(Router::match(Http::REQUEST_METHOD_GET, '/documents/search')); + } + public function testCanMatchUrlWithPlaceholder(): void { $routeBlog = new Route(Http::REQUEST_METHOD_GET, '/blog');