added accept header negotiations, OPTIONS method Allow header and, better method handling.

api-security
n0nag0n 1 week ago
parent 6a2494c698
commit c9da0675d9

@ -48,6 +48,7 @@ use Psr\Container\ContainerInterface;
* @method Response response()
* @method void error(Throwable $e)
* @method void notFound()
* @method void methodNotFound(Route $route)
* @method void redirect(string $url, int $code = 303)
* @method void json($data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0)
* @method void jsonHalt($data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0)
@ -91,6 +92,7 @@ class Engine
'halt',
'error',
'notFound',
'methodNotFound',
'render',
'redirect',
'etag',
@ -562,6 +564,15 @@ class Engine
$params[] = $route;
}
// OPTIONS request handling
if ($request->method === 'OPTIONS') {
$allowedMethods = $route->methods;
$response->status(204)
->header('Allow', implode(', ', $allowedMethods))
->send();
return;
}
// If this route is to be streamed, we need to output the headers now
if ($route->is_streamed === true) {
if (count($route->streamed_headers) > 0) {
@ -644,7 +655,7 @@ class Engine
// Get the previous route and check if the method failed, but the URL was good.
$lastRouteExecuted = $router->executedRoute;
if ($lastRouteExecuted !== null && $lastRouteExecuted->matchUrl($request->url) === true && $lastRouteExecuted->matchMethod($request->method) === false) {
$this->halt(405, 'Method Not Allowed', empty(getenv('PHPUNIT_TEST')));
$this->methodNotFound($lastRouteExecuted);
} else {
$this->notFound();
}
@ -839,6 +850,19 @@ class Engine
->send();
}
/**
* Function to run if the route has been found but not the method.
*
* @param Route $route - The executed route
*
* @return void
*/
public function _methodNotFound(Route $route): void
{
$this->response()->setHeader('Allow', implode(', ', $route->methods));
$this->halt(405, 'Method Not Allowed. Allowed Methods are: ' . implode(', ', $route->methods), empty(getenv('PHPUNIT_TEST')));
}
/**
* Redirects the current request to another URL.
*

@ -55,6 +55,7 @@ require_once __DIR__ . '/autoload.php';
* @method static void jsonp($data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512)
* @method static void error(Throwable $exception)
* @method static void notFound()
* @method static void methodNotFound(Route $route)
* @method static void etag(string $id, string $type = 'strong')
* @method static void lastModified(int $time)
* @method static void download(string $filePath)

@ -449,6 +449,27 @@ class Request
return 'http';
}
/**
* Negotiates the best content type from the Accept header.
*
* @param array<int, string> $supported List of supported content types.
*
* @return ?string The negotiated content type.
*/
public function negotiateContentType(array $supported): ?string
{
$accept = $this->header('Accept') ?? '';
if ($accept === '') {
return $supported[0];
}
foreach ($supported as $type) {
if (stripos($accept, $type) !== false) {
return $type;
}
}
return null;
}
/**
* Retrieves the array of uploaded files.
*

@ -126,6 +126,11 @@ class Router
if (in_array('GET', $methods, true) === true && in_array('HEAD', $methods, true) === false) {
$methods[] = 'HEAD';
}
// Always allow an OPTIONS request
if (in_array('OPTIONS', $methods, true) === false) {
$methods[] = 'OPTIONS';
}
}
// And this finishes it off.

@ -409,6 +409,21 @@ class EngineTest extends TestCase
$this->expectOutputString('');
}
public function testOptionsRoute(): void
{
$engine = new Engine();
$engine->route('GET /someRoute', function () {
echo 'i ran';
}, true);
$engine->request()->method = 'OPTIONS';
$engine->request()->url = '/someRoute';
$engine->start();
// No body should be sent
$this->expectOutputString('');
$this->assertEquals('GET, HEAD, OPTIONS', $engine->response()->headers()['Allow']);
}
public function testHalt(): void
{
$engine = new class extends Engine {
@ -1070,9 +1085,10 @@ class EngineTest extends TestCase
$engine->start();
$this->expectOutputString('Method Not Allowed');
$this->expectOutputString('Method Not Allowed. Allowed Methods are: POST, OPTIONS');
$this->assertEquals(405, $engine->response()->status());
$this->assertEquals('Method Not Allowed', $engine->response()->getBody());
$this->assertEquals('Method Not Allowed. Allowed Methods are: POST, OPTIONS', $engine->response()->getBody());
$this->assertEquals('POST, OPTIONS', $engine->response()->headers()['Allow']);
}
public function testDownload(): void

@ -377,4 +377,27 @@ class RequestTest extends TestCase
$result = Request::parseQuery('/foo?');
$this->assertEquals([], $result);
}
public function testNegotiateContentType(): void
{
// Find best match first
$_SERVER['HTTP_ACCEPT'] = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8';
$request = new Request();
$this->assertEquals('application/xml', $request->negotiateContentType(['application/xml', 'application/json', 'text/html']));
// Find the first match
$_SERVER['HTTP_ACCEPT'] = 'application/json,text/html';
$request = new Request();
$this->assertEquals('application/json', $request->negotiateContentType(['application/json', 'text/html']));
// No match found
$_SERVER['HTTP_ACCEPT'] = 'application/xml';
$request = new Request();
$this->assertNull($request->negotiateContentType(['application/json', 'text/html']));
// No header present, return first supported type
$_SERVER['HTTP_ACCEPT'] = '';
$request = new Request();
$this->assertEquals('application/json', $request->negotiateContentType(['application/json', 'text/html']));
}
}

@ -137,7 +137,7 @@ class RouterTest extends TestCase
public function testHeadRouteShortcut(): void
{
$route = $this->router->get('/path', [$this, 'ok']);
$this->assertEquals(['GET', 'HEAD'], $route->methods);
$this->assertEquals(['GET', 'HEAD', 'OPTIONS'], $route->methods);
$this->request->url = '/path';
$this->request->method = 'HEAD';
$this->check('');
@ -172,6 +172,16 @@ class RouterTest extends TestCase
$this->check('OK');
}
public function testOptionsRouteShortcut(): void
{
$route = $this->router->map('GET|POST /path', [$this, 'ok']);
$this->assertEquals(['GET', 'POST', 'HEAD', 'OPTIONS'], $route->methods);
$this->request->url = '/path';
$this->request->method = 'OPTIONS';
$this->check('OK');
}
public function testPutRouteShortcut(): void
{
$this->router->put('/path', [$this, 'ok']);

@ -106,15 +106,15 @@ PHP;
$this->assertStringContainsString('Routes', file_get_contents(static::$ou));
$expected = <<<'output'
+---------+-----------+-------+----------+----------------+
+---------+--------------------+-------+----------+----------------+
| Pattern | Methods | Alias | Streamed | Middleware |
+---------+-----------+-------+----------+----------------+
| / | GET, HEAD | | No | - |
| /post | POST | | No | Closure |
| /delete | DELETE | | No | - |
| /put | PUT | | No | - |
| /patch | PATCH | | No | Bad Middleware |
+---------+-----------+-------+----------+----------------+
+---------+--------------------+-------+----------+----------------+
| / | GET, HEAD, OPTIONS | | No | - |
| /post | POST, OPTIONS | | No | Closure |
| /delete | DELETE, OPTIONS | | No | - |
| /put | PUT, OPTIONS | | No | - |
| /patch | PATCH, OPTIONS | | No | Bad Middleware |
+---------+--------------------+-------+----------+----------------+
output; // phpcs:ignore
$this->assertStringContainsString(
@ -133,11 +133,11 @@ PHP;
$this->assertStringContainsString('Routes', file_get_contents(static::$ou));
$expected = <<<'output'
+---------+---------+-------+----------+------------+
+---------+---------------+-------+----------+------------+
| Pattern | Methods | Alias | Streamed | Middleware |
+---------+---------+-------+----------+------------+
| /post | POST | | No | Closure |
+---------+---------+-------+----------+------------+
+---------+---------------+-------+----------+------------+
| /post | POST, OPTIONS | | No | Closure |
+---------+---------------+-------+----------+------------+
output; // phpcs:ignore
$this->assertStringContainsString(

Loading…
Cancel
Save