MDL-86866 router: Use response_aware handler for status code

This commit is contained in:
Andrew Nicols
2026-02-25 18:31:13 +08:00
parent 9263ae528b
commit 1be333a5d0
6 changed files with 214 additions and 32 deletions
+11 -1
View File
@@ -16,6 +16,9 @@
namespace core;
use core\exception\not_found_exception;
use core\exception\response_aware_exception;
use core\router\error_renderer;
use core\router\middleware\api_validation_middleware;
use core\router\middleware\cors_middleware;
use core\router\middleware\error_handling_middleware;
@@ -213,12 +216,19 @@ class router {
// Set a custom error handler for the HttpNotFoundException and HttpForbiddenException.
// We route these to a custom error handler to ensure that the error is displayed with a feedback form.
$errorhandler = new router\error_handler(
$this->app->getCallableResolver(),
$this->app->getResponseFactory(),
);
$errorhandler->registerErrorRenderer('text/html', error_renderer::class);
$errormiddleware->setErrorHandler(
[
response_aware_exception::class,
HttpNotFoundException::class,
HttpForbiddenException::class,
],
new router\error_handler($this->app),
$errorhandler,
true,
);
$errormiddleware->getDefaultErrorHandler()->registerErrorRenderer('text/html', router\error_renderer::class);
+17 -18
View File
@@ -16,34 +16,19 @@
namespace core\router;
use core\exception\response_aware_exception;
use core\router\response\exception_response;
use Psr\Http\Message\ServerRequestInterface;
use Slim\App;
use Slim\Handlers\ErrorHandler;
/**
* An Eerror Handler implementation for Moodle which is aware of the REST API.
* An Error Handler implementation for Moodle which is aware of the REST API.
*
* @package core
* @copyright Andrew Lyons <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class error_handler extends ErrorHandler {
/**
* Construct a new Error Handler.
*
* @param \Slim\App $app
*/
public function __construct(
App $app,
) {
parent::__construct(
$app->getCallableResolver(),
$app->getResponseFactory(),
);
$this->registerErrorRenderer('text/html', error_renderer::class);
}
#[\Override]
protected function determineContentType(ServerRequestInterface $request): ?string {
// For anything hitting /rest/api/v2 we will default to JSON.
@@ -55,4 +40,18 @@ class error_handler extends ErrorHandler {
// Fall back to the default behaviour of using the Accept header.
return parent::determineContentType($request);
}
#[\Override]
protected function determineStatusCode(): int {
$exception = $this->exception;
if ($exception instanceof response_aware_exception) {
$responseclassname = $exception->get_response_classname();
if (is_subclass_of($responseclassname, exception_response::class)) {
return $responseclassname::get_exception_status_code();
}
}
return parent::determineStatusCode();
}
}
+4 -2
View File
@@ -100,8 +100,10 @@ class util {
/**
* Throw a specialized HTTP exception based on the response status code.
*
* @param \Psr\Http\Message\ServerRequestInterface $request
* @param \Psr\Http\Message\ResponseInterface $response
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @param string|null $message
* @param \Throwable|null $previous
*/
public static function throw_specialized_exception(
ServerRequestInterface $request,
@@ -0,0 +1,117 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core\router\middleware;
use core\di;
use core\router\request_validator;
use core\router\response_handler;
use core\router\response_validator;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\ServerRequest;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Tests for the API validation middleware.
*
* @package core
* @category test
* @copyright 2026 Laurent David <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[\PHPunit\Framework\Attributes\CoversClass(api_validation_middleware::class)]
final class api_validation_middleware_test extends \advanced_testcase {
/**
* If request validation fails, API middleware returns an error response.
*/
public function test_process_fails_request_validation_returns_error_response(): void {
$request = new ServerRequest('GET', '/test');
$errorresponse = (new Response())->withStatus(400);
// Mock the request validator to throw an exception.
$requestvalidator = $this->getMockBuilder(request_validator::class)->getMock();
$requestvalidator->expects($this->once())
->method('validate_request')
->with($request)
->willThrowException(new \Exception('Invalid request'));
// If the request fails validation, it will not be passed to next Middleware.
$handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock();
$handler->expects($this->never())->method('handle');
// It will return an error response.
$responsehandler = $this->getMockBuilder(response_handler::class)
->disableOriginalConstructor()
->getMock();
$responsehandler->expects($this->once())
->method('get_response_from_exception')
->with($request, $this->isInstanceOf(\Exception::class))
->willReturn($errorresponse);
di::set(request_validator::class, $requestvalidator);
di::set(response_handler::class, $responsehandler);
$middleware = di::get(api_validation_middleware::class);
$this->assertSame($errorresponse, $middleware->process($request, $handler));
}
/**
* If response validation fails, API middleware returns an error response.
*/
public function test_process_fails_response_validation_returns_error_response(): void {
$request = new ServerRequest('GET', '/test');
$response = new Response();
$errorresponse = (new Response())->withStatus(500);
// Mock the request validator to pass validation.
$requestvalidator = $this->getMockBuilder(request_validator::class)->getMock();
$requestvalidator->expects($this->once())
->method('validate_request')
->with($request)
->willReturnArgument(0);
// The request will be passed to next Middleware.
$handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock();
$handler->expects($this->once())
->method('handle')
->with($request)
->willReturn($response);
// Mock the response validator to throw an exception.
$responsevalidator = $this->getMockBuilder(response_validator::class)->getMock();
$responsevalidator->expects($this->once())
->method('validate_response')
->with($request, $response)
->willThrowException(new \Exception('Invalid response'));
// It will return an error response.
$responsehandler = $this->getMockBuilder(response_handler::class)
->disableOriginalConstructor()
->getMock();
$responsehandler->expects($this->once())
->method('get_response_from_exception')
->with($request, $this->isInstanceOf(\Exception::class))
->willReturn($errorresponse);
di::set(request_validator::class, $requestvalidator);
di::set(response_validator::class, $responsevalidator);
di::set(response_handler::class, $responsehandler);
// Execute the middleware.
$middleware = di::get(api_validation_middleware::class);
$this->assertSame($errorresponse, $middleware->process($request, $handler));
}
}
@@ -55,7 +55,7 @@ final class error_handling_middleware_test extends route_testcase {
}
/**
* When no errors, the error handle is not called.
* When errors occur, the error handler is called.
*/
public function test_error_handling(): void {
$responsehandler = $this->getMockBuilder(response_handler::class)
+64 -10
View File
@@ -17,6 +17,8 @@
namespace core;
use core\tests\router\route_testcase;
use GuzzleHttp\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Slim\App;
/**
@@ -25,9 +27,9 @@ use Slim\App;
* @package core
* @copyright Andrew Lyons <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core\router
* @covers \core\router\response_handler
*/
#[\PHPUnit\Framework\Attributes\CoversClass(\core\router::class)]
#[\PHPUnit\Framework\Attributes\CoversClass(\core\router\response_handler::class)]
final class router_test extends route_testcase {
public function test_get_app(): void {
$router = $this->get_router('/example');
@@ -88,9 +90,8 @@ final class router_test extends route_testcase {
$this->assertEquals('/example', $router->basepath);
}
/**
* @dataProvider basepath_provider
*/
#[\PHPUnit\Framework\Attributes\DataProvider('basepath_provider')]
public function test_basepath(
string $wwwroot,
bool $configured,
@@ -107,13 +108,21 @@ final class router_test extends route_testcase {
$this->assertEquals($expected, $router->basepath);
}
public static function basepath_provider(): \Iterator {
/**
* Data provider for test_basepath.
*
* @return \Generator
*/
public static function basepath_provider(): \Generator {
yield 'Domain Router not configured' => ['http://example.com', false, '/r.php'];
yield 'Domain Router configured' => ['http://example.com', true, ''];
yield 'Subdirectory Router not configured' => ['http://example.com/moodle', false, '/moodle/r.php'];
yield 'Subdirectory Router configured' => ['http://example.com/moodle', true, '/moodle'];
}
/**
* Test that the basepath is correctly guessed when accessed via r.php.
*/
public function test_basepath_guessed_rphp(): void {
$wwwroot = new \moodle_url('/r.php');
$_SERVER['SCRIPT_FILENAME'] = 'r.php';
@@ -155,11 +164,9 @@ final class router_test extends route_testcase {
/**
* Data provider for test_basepath_guessed_rphp_configuration_provided.
*
* @return \Generator<string, array<bool|string|null>, mixed, void>
* @return \Generator
*/
public static function router_configured_basepath_provider(): \Iterator {
global $CFG;
public static function router_configured_basepath_provider(): \Generator {
yield 'Root domain, Not configured, accessed via r.php' => [
'http://example.com',
null,
@@ -233,4 +240,51 @@ final class router_test extends route_testcase {
'/moodle/r.php',
];
}
/**
* Test the expected error codes of various exception types.
*
* @param \Throwable $exception The exception to throw.
* @param int $expectedstatus The expected HTTP status code.
*/
#[\PHPUnit\Framework\Attributes\DataProvider('exception_provider')]
public function test_error_codes_correct(
\Throwable $exception,
int $expectedstatus,
): void {
$app = $this->get_app();
$app->map(['GET'], '/test', fn ($request, $response) => throw $exception);
// Handle the request.
$request = new ServerRequest('GET', '/test');
$returns = $app->handle($request);
$this->assertInstanceOf(ResponseInterface::class, $returns);
$this->assertEquals($expectedstatus, $returns->getStatusCode());
}
/**
* Data provider for testing error handling.
*
* @return \Generator
*/
public static function exception_provider(): \Generator {
yield 'Generic Exception' => [new \Exception('Test'), 500];
yield 'Moodle not_found_exception' => [
new \core\exception\not_found_exception('test', 'thing'),
404,
];
yield 'Not Found Exception' => [new \Slim\Exception\HttpNotFoundException(new ServerRequest('GET', '/test')), 404];
yield 'Method Not Allowed Exception' => [
new \Slim\Exception\HttpMethodNotAllowedException(
new ServerRequest('POST', '/test'),
'GET',
),
405,
];
yield 'Moodle exception not implementing response_aware_exception_interface' => [
new \core\exception\moodle_exception('test', 'thing'),
500,
];
}
}