Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion src/Http/Adapter/FPM/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
3 changes: 2 additions & 1 deletion src/Http/Adapter/Swoole/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
20 changes: 20 additions & 0 deletions src/Http/Http.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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())) {
Expand Down
2 changes: 2 additions & 0 deletions src/Http/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
19 changes: 19 additions & 0 deletions src/Http/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, string> $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
*
Expand Down
2 changes: 2 additions & 0 deletions src/Http/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class Router
Http::REQUEST_METHOD_PUT => [],
Http::REQUEST_METHOD_PATCH => [],
Http::REQUEST_METHOD_DELETE => [],
Http::REQUEST_METHOD_QUERY => [],
];

/**
Expand Down Expand Up @@ -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 => [],
];
}
}
46 changes: 46 additions & 0 deletions tests/HttpTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
11 changes: 11 additions & 0 deletions tests/RequestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
10 changes: 10 additions & 0 deletions tests/ResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions tests/RouterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down