diff --git a/docs/1-essentials/01-routing.md b/docs/1-essentials/01-routing.md index 696c6328c6..37e28d35bc 100644 --- a/docs/1-essentials/01-routing.md +++ b/docs/1-essentials/01-routing.md @@ -24,7 +24,7 @@ final readonly class HomeController } ``` -Out of the box, an attribute for every HTTP verb is available: {b`Tempest\Router\Get`}, {b`Tempest\Router\Post`}, {b`Tempest\Router\Delete`}, {b`Tempest\Router\Put`}, {b`Tempest\Router\Patch`}, {b`Tempest\Router\Options`}, {b`Tempest\Router\Connect`}, {b`Tempest\Router\Trace`} and {b`Tempest\Router\Head`}. +Out of the box, an attribute for every HTTP verb is available: {b`Tempest\Router\Get`}, {b`Tempest\Router\Post`}, {b`Tempest\Router\Query`}, {b`Tempest\Router\Delete`}, {b`Tempest\Router\Put`}, {b`Tempest\Router\Patch`}, {b`Tempest\Router\Options`}, {b`Tempest\Router\Connect`}, {b`Tempest\Router\Trace`} and {b`Tempest\Router\Head`}. ## Route parameters diff --git a/packages/http-client/src/GenericHttpClient.php b/packages/http-client/src/GenericHttpClient.php index 46ef3de2a1..3e5847242f 100644 --- a/packages/http-client/src/GenericHttpClient.php +++ b/packages/http-client/src/GenericHttpClient.php @@ -39,6 +39,16 @@ public function head(string $uri, array $headers = []): Response ); } + public function query(string $uri, array $headers = [], ?string $body = null): Response + { + return $this->send( + method: Method::QUERY, + uri: $uri, + headers: $headers, + body: $body, + ); + } + public function post(string $uri, array $headers = [], ?string $body = null): Response { return $this->send( diff --git a/packages/http-client/src/HttpClient.php b/packages/http-client/src/HttpClient.php index 0dbc2fed61..7c4345a5f6 100644 --- a/packages/http-client/src/HttpClient.php +++ b/packages/http-client/src/HttpClient.php @@ -15,6 +15,8 @@ public function get(string $uri, array $headers = []): Response; public function head(string $uri, array $headers = []): Response; + public function query(string $uri, array $headers = [], ?string $body = null): Response; + public function trace(string $uri, array $headers = []): Response; public function post(string $uri, array $headers = [], ?string $body = null): Response; diff --git a/packages/http-client/tests/GenericHttpClientTest.php b/packages/http-client/tests/GenericHttpClientTest.php index 633c01360f..d936ed0262 100644 --- a/packages/http-client/tests/GenericHttpClientTest.php +++ b/packages/http-client/tests/GenericHttpClientTest.php @@ -74,6 +74,21 @@ public function test_post_proxies_to_http_client(): void ->assertBodyIs('{"test":"value"}'); } + public function test_query_proxies_to_http_client(): void + { + $this->client->query( + uri: '/test-query', + headers: ['Content-Type' => 'application/json'], + body: '{"filter":"active"}', + ); + + $this->mock + ->assertMethod('QUERY') + ->assertUri('/test-query') + ->assertHeaderEquals('Content-Type', 'application/json') + ->assertBodyIs('{"filter":"active"}'); + } + public function test_trace_proxies_to_http_client(): void { $this->client->trace('/test-trace'); diff --git a/packages/http/src/Method.php b/packages/http/src/Method.php index c015964d6d..5663381850 100644 --- a/packages/http/src/Method.php +++ b/packages/http/src/Method.php @@ -8,6 +8,7 @@ enum Method: string { case GET = 'GET'; case HEAD = 'HEAD'; + case QUERY = 'QUERY'; case POST = 'POST'; case PUT = 'PUT'; case DELETE = 'DELETE'; diff --git a/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php b/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php index 45982e0a73..10e7dd34c0 100644 --- a/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php +++ b/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php @@ -108,6 +108,7 @@ public static function nonPostMethodsProvider(): array ['HEAD'], ['OPTIONS'], ['TRACE'], + ['QUERY'], ['CONNECT'], ]; } diff --git a/packages/router/src/PreventCrossSiteRequestsMiddleware.php b/packages/router/src/PreventCrossSiteRequestsMiddleware.php index 7435a2587b..2c1daff8db 100644 --- a/packages/router/src/PreventCrossSiteRequestsMiddleware.php +++ b/packages/router/src/PreventCrossSiteRequestsMiddleware.php @@ -27,6 +27,7 @@ private const array SAFE_METHODS = [ Method::GET, Method::HEAD, + Method::QUERY, Method::OPTIONS, Method::TRACE, ]; diff --git a/packages/router/src/Query.php b/packages/router/src/Query.php new file mode 100644 index 0000000000..7c242a25d7 --- /dev/null +++ b/packages/router/src/Query.php @@ -0,0 +1,24 @@ +[] $middleware Middleware specific to this route. + * @param class-string[] $without Middleware to remove from this route. + */ + public function __construct( + public string $uri, + public array $middleware = [], + public array $without = [], + ) {} +} diff --git a/packages/router/src/RouteConfig.php b/packages/router/src/RouteConfig.php index ed70c9b6b2..8a42c4fad4 100644 --- a/packages/router/src/RouteConfig.php +++ b/packages/router/src/RouteConfig.php @@ -31,6 +31,7 @@ public function __construct( public Middleware $middleware = new Middleware( HandleRouteExceptionMiddleware::class, MatchRouteMiddleware::class, + ValidateQueryRequestMiddleware::class, ResolveRouteRequestMiddleware::class, SetCookieHeadersMiddleware::class, HandleRouteSpecificMiddleware::class, diff --git a/packages/router/src/ValidateQueryRequestMiddleware.php b/packages/router/src/ValidateQueryRequestMiddleware.php new file mode 100644 index 0000000000..cfc0ed22b8 --- /dev/null +++ b/packages/router/src/ValidateQueryRequestMiddleware.php @@ -0,0 +1,39 @@ +method !== Method::QUERY) { + return $next($request); + } + + $contentType = trim($request->headers->get(ContentType::HEADER, '')); + + if ($contentType === '') { + throw new HttpRequestFailed(Status::BAD_REQUEST); + } + + $mediaType = strtolower(trim(explode(';', $contentType, 2)[0])); + $isJson = $mediaType === ContentType::JSON->value || str_ends_with($mediaType, '+json'); + + $hasInvalidJson = $request->raw !== null && ($request->raw === '' ? $request->body === [] : ! json_validate($request->raw)); + + if ($isJson && $hasInvalidJson) { + throw new HttpRequestFailed(Status::BAD_REQUEST); + } + + return $next($request); + } +} diff --git a/src/Tempest/Framework/Testing/Http/HttpRouterTester.php b/src/Tempest/Framework/Testing/Http/HttpRouterTester.php index 477d19b19f..1df72b6c04 100644 --- a/src/Tempest/Framework/Testing/Http/HttpRouterTester.php +++ b/src/Tempest/Framework/Testing/Http/HttpRouterTester.php @@ -154,6 +154,17 @@ public function head(string $uri, array $query = [], array $headers = []): TestR )); } + public function query(string $uri, array|string $body = [], array $query = [], array $headers = []): TestResponseHelper + { + return $this->sendRequest(new GenericRequest( + method: Method::QUERY, + uri: Uri\merge_query($uri, ...$query), + body: is_string($body) ? [] : $body, + headers: $this->createHeaders($headers), + raw: is_string($body) ? $body : null, + )); + } + public function post(string $uri, array|string $body = [], array $query = [], array $headers = []): TestResponseHelper { return $this->sendRequest(new GenericRequest( diff --git a/tests/Fixtures/Controllers/TestController.php b/tests/Fixtures/Controllers/TestController.php index 35a843ec9b..aa122720f1 100644 --- a/tests/Fixtures/Controllers/TestController.php +++ b/tests/Fixtures/Controllers/TestController.php @@ -18,6 +18,7 @@ use Tempest\Router\Patch; use Tempest\Router\Post; use Tempest\Router\Put; +use Tempest\Router\Query; use Tempest\Router\Trace; use Tempest\View\View; use Tests\Tempest\Fixtures\Views\ViewModel; @@ -52,6 +53,7 @@ public function withComplexCustomRegexParams(string $id): Response #[Options(uri: '/test')] #[Patch(uri: '/test')] #[Post(uri: '/test')] + #[Query(uri: '/test')] #[Put(uri: '/test')] #[Trace(uri: '/test')] public function __invoke(): Response diff --git a/tests/Integration/Router/PreventCrossSiteRequestsMiddlewareTest.php b/tests/Integration/Router/PreventCrossSiteRequestsMiddlewareTest.php index e7ae7bb146..f18e4927fc 100644 --- a/tests/Integration/Router/PreventCrossSiteRequestsMiddlewareTest.php +++ b/tests/Integration/Router/PreventCrossSiteRequestsMiddlewareTest.php @@ -31,6 +31,17 @@ public function safe_methods_are_always_allowed(): void $this->http->get('/test')->assertOk(); $this->http->head('/test')->assertOk(); $this->http->options('/test')->assertOk(); + $this->http + ->query( + '/test', + body: ['filter' => 'active'], + headers: [ + 'content-type' => 'application/json', + 'sec-fetch-site' => SecFetchSite::CROSS_SITE, + 'sec-fetch-mode' => SecFetchMode::CORS, + ], + ) + ->assertOk(); } #[Test] diff --git a/tests/Integration/Testing/Http/HttpRouterTesterIntegrationTest.php b/tests/Integration/Testing/Http/HttpRouterTesterIntegrationTest.php index 41fc5eb169..3b165aa56e 100644 --- a/tests/Integration/Testing/Http/HttpRouterTesterIntegrationTest.php +++ b/tests/Integration/Testing/Http/HttpRouterTesterIntegrationTest.php @@ -7,8 +7,11 @@ use Exception; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\Attributes\Test; +use Tempest\Http\ContentType; +use Tempest\Http\Method; use Tempest\Http\Request; use Tempest\Http\Responses\Ok; +use Tempest\Http\Status; use Tempest\Router\Post; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use Tests\Tempest\Integration\Route\Fixtures\Http500Controller; @@ -144,6 +147,53 @@ public function options_requests_failure(): void ->assertOk(); } + #[Test] + public function query_requests(): void + { + $response = $this->http + ->query( + '/test', + body: ['filter' => 'active'], + headers: [ + 'Content-Type' => ContentType::JSON, + ], + ) + ->assertOk(); + + $this->assertSame(Method::QUERY, $response->request->method); + $this->assertSame(['filter' => 'active'], $response->request->body); + } + + #[Test] + public function query_requests_require_a_content_type(): void + { + $this->http + ->query('/test') + ->assertStatus(Status::BAD_REQUEST); + } + + #[Test] + public function query_requests_reject_invalid_json(): void + { + $this->http + ->query('/test', body: 'invalid json', headers: [ + 'Content-Type' => ContentType::JSON, + ]) + ->assertStatus(Status::BAD_REQUEST); + } + + #[Test] + public function query_requests_failure(): void + { + $this->expectException(AssertionFailedError::class); + + $this->http + ->query('/this-route-does-not-exist', headers: [ + 'Content-Type' => ContentType::JSON, + ]) + ->assertOk(); + } + #[Test] public function trace_requests(): void { @@ -193,7 +243,7 @@ public function has_exception(): void } #[Test] - public function query(): void + public function query_parameters(): void { $this->assertSame($this->http->get('/test?foo=baz', query: ['foo' => 'bar'])->request->uri, '/test?foo=bar'); $this->assertSame($this->http->get('/test?jon=doe', query: ['foo' => 'bar'])->request->uri, '/test?jon=doe&foo=bar'); @@ -204,6 +254,7 @@ public function query(): void $this->assertSame($this->http->put('/test', query: ['foo' => 'bar'])->request->uri, '/test?foo=bar'); $this->assertSame($this->http->delete('/test', query: ['foo' => 'bar'])->request->uri, '/test?foo=bar'); $this->assertSame($this->http->patch('/test', query: ['foo' => 'bar'])->request->uri, '/test?foo=bar'); + $this->assertSame($this->http->query('/test', query: ['foo' => 'bar'], headers: ['Content-Type' => ContentType::JSON])->request->uri, '/test?foo=bar'); $this->assertSame($this->http->head('/test', query: ['foo' => 'bar'])->request->uri, '/test?foo=bar'); }