Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/1-essentials/01-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions packages/http-client/src/GenericHttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions packages/http-client/src/HttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions packages/http-client/tests/GenericHttpClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
1 change: 1 addition & 0 deletions packages/http/src/Method.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ public static function nonPostMethodsProvider(): array
['HEAD'],
['OPTIONS'],
['TRACE'],
['QUERY'],
['CONNECT'],
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
private const array SAFE_METHODS = [
Method::GET,
Method::HEAD,
Method::QUERY,
Method::OPTIONS,
Method::TRACE,
];
Expand Down
24 changes: 24 additions & 0 deletions packages/router/src/Query.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Tempest\Router;

use Attribute;
use Tempest\Http\Method;

#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)]
final class Query implements Route
{
public Method $method = Method::QUERY;

/**
* @param class-string<HttpMiddleware>[] $middleware Middleware specific to this route.
* @param class-string<HttpMiddleware>[] $without Middleware to remove from this route.
*/
public function __construct(
public string $uri,
public array $middleware = [],
public array $without = [],
) {}
}
1 change: 1 addition & 0 deletions packages/router/src/RouteConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public function __construct(
public Middleware $middleware = new Middleware(
HandleRouteExceptionMiddleware::class,
MatchRouteMiddleware::class,
ValidateQueryRequestMiddleware::class,
ResolveRouteRequestMiddleware::class,
SetCookieHeadersMiddleware::class,
HandleRouteSpecificMiddleware::class,
Expand Down
39 changes: 39 additions & 0 deletions packages/router/src/ValidateQueryRequestMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Tempest\Router;

use Tempest\Http\ContentType;
use Tempest\Http\HttpRequestFailed;
use Tempest\Http\Method;
use Tempest\Http\Request;
use Tempest\Http\Response;
use Tempest\Http\Status;

final readonly class ValidateQueryRequestMiddleware implements HttpMiddleware
{
public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
{
if ($request->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);
}
}
11 changes: 11 additions & 0 deletions src/Tempest/Framework/Testing/Http/HttpRouterTester.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions tests/Fixtures/Controllers/TestController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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');
Expand All @@ -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');
}

Expand Down
Loading