From c9da0675d9c8b6e94a680fcc21a15c2f6fa9e38f Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Fri, 3 Oct 2025 21:31:10 +0100 Subject: [PATCH] added accept header negotiations, OPTIONS method Allow header and, better method handling. --- flight/Engine.php | 26 +++++++++++++++++++++++++- flight/Flight.php | 1 + flight/net/Request.php | 21 +++++++++++++++++++++ flight/net/Router.php | 5 +++++ tests/EngineTest.php | 20 ++++++++++++++++++-- tests/RequestTest.php | 23 +++++++++++++++++++++++ tests/RouterTest.php | 12 +++++++++++- tests/commands/RouteCommandTest.php | 28 ++++++++++++++-------------- 8 files changed, 118 insertions(+), 18 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index 858ab42..0cfaf68 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -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. * diff --git a/flight/Flight.php b/flight/Flight.php index 2b69e16..c5692eb 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -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) diff --git a/flight/net/Request.php b/flight/net/Request.php index 8368c28..c56f1f4 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -449,6 +449,27 @@ class Request return 'http'; } + /** + * Negotiates the best content type from the Accept header. + * + * @param array $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. * diff --git a/flight/net/Router.php b/flight/net/Router.php index 8f5555e..8c2c6e1 100644 --- a/flight/net/Router.php +++ b/flight/net/Router.php @@ -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. diff --git a/tests/EngineTest.php b/tests/EngineTest.php index 4b3dbc9..91e9f3b 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -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 diff --git a/tests/RequestTest.php b/tests/RequestTest.php index a84841b..214b26e 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -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'])); + } } diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 58adb8a..c0ed3c1 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -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']); diff --git a/tests/commands/RouteCommandTest.php b/tests/commands/RouteCommandTest.php index 7bcc123..8683871 100644 --- a/tests/commands/RouteCommandTest.php +++ b/tests/commands/RouteCommandTest.php @@ -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 | - +---------+-----------+-------+----------+----------------+ + +---------+--------------------+-------+----------+----------------+ + | Pattern | Methods | Alias | Streamed | 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 | - +---------+---------+-------+----------+------------+ + +---------+---------------+-------+----------+------------+ + | Pattern | Methods | Alias | Streamed | Middleware | + +---------+---------------+-------+----------+------------+ + | /post | POST, OPTIONS | | No | Closure | + +---------+---------------+-------+----------+------------+ output; // phpcs:ignore $this->assertStringContainsString(