. namespace core\tests; use core\router; use core\router\bridge; use core\router\mocking_route_loader; use core\router\route_loader_interface; use core\router\schema\openapi_base; use core\router\schema\referenced_object; use core\router\schema\specification; use stdClass; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\ServerRequest; use GuzzleHttp\Psr7\Uri; use PHPUnit\Framework\ExpectationFailedException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamInterface; use Slim\App; use Slim\Middleware\RoutingMiddleware; use Slim\Routing\Route; use Slim\Routing\RouteContext; /** * Tests for user preference API handler. * * @package core * @copyright 2023 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class route_testcase extends \advanced_testcase { /** * Update the test route loader using the supplied callback. * * @param callable $modifier */ protected function update_test_route_loader( callable $modifier, ): void { self::load_fixture('core', 'router/mocking_route_loader.php'); $routeloader = \core\di::get(mocking_route_loader::class); $modifier($routeloader); \core\di::set(route_loader_interface::class, $routeloader); } /** * Add a route from a class method. * * @param string $classname The class to add the route from * @param string $methodname The method name to add * @param null|string $grouppath The path to the route group */ protected function add_route_to_route_loader( string $classname, string $methodname, ?string $grouppath = null, ) { $grouppath = $grouppath ?? $this->guess_group_path_from_classname($classname); $this->update_test_route_loader(fn (mocking_route_loader $routeloader) => $routeloader->mock_route_from_class_method( $grouppath, new \ReflectionMethod($classname, $methodname), )); } /** * Add all routes from the specified class to the test loader. * * Only methods within the class with a #[route] attribute will be added. * * @param string $classname The class to add routes from * @param null|string $grouppath The path of the route group */ protected function add_class_routes_to_route_loader( string $classname, ?string $grouppath = null, ): void { $this->update_test_route_loader( fn (mocking_route_loader $routeloader) => $routeloader->add_all_routes_in_class( grouppath: $grouppath ?? $this->guess_group_path_from_classname($classname), class: $classname, ), ); } /** * Guess the group path from a class name. * * @param string $classname * @return string */ protected function guess_group_path_from_classname( string $classname, ): string { [, , $l3] = explode('\\', $classname, 4); if ($l3 === 'api') { return route_loader_interface::ROUTE_GROUP_API; } throw new \coding_exception("Unable to determine route path for '{$classname}'"); } /** * Mock a route from a route attribute. * * @param string $grouppath * @param \core\router\route $route * @param string $name * @param callable|null $callable */ protected function mock_route_from_route_attribute( string $grouppath, \core\router\route $route, string $name = 'route', ?callable $callable = null, ): void { if ($callable === null) { $callable = fn ($request, $response) => $response->withStatus(200); } $this->update_test_route_loader(fn (mocking_route_loader $routeloader) => $routeloader->mock_route_from_callable( grouppath: $grouppath, methods: $route->get_methods(['GET']), pattern: $route->get_path(), callable: $callable, name: $name, )); } /** * Get a fully-configured instance of the Moodle Routing Application. * * @return App */ protected function get_app(): App { $router = $this->get_router(); return $router->get_app(); } /** * Get a fully-configured instance of the Moodle Routing Application. * * @param string $basepath The basepath for the router * @return router */ protected function get_router(string $basepath = ''): router { \core\di::set( router::class, \DI\autowire(router::class)->constructorParameter('basepath', $basepath), ); return \core\di::get(router::class); } /** * Get an unconfigured instance of the Slim Application. * * @return App */ protected function get_simple_app(): App { global $CFG; require_once("{$CFG->libdir}/nikic/fast-route/src/functions.php"); $app = bridge::create( container: \core\di::get_container(), ); return $app; } /** * Get the request for a route which is known to the router. * * @param \core\router\route $route * @param string $path * @return ServerRequestInterface */ protected function get_request_for_routed_route( \core\router\route $route, string $path, ): ServerRequestInterface { $this->mock_route_from_route_attribute('', $route); // Grab just one method. $methods = $route->get_methods(); $method = $methods ? reset($methods) : 'GET'; $request = $this->create_request( method: $method, path: $path, prefix: '', route: $route, ); $request = $this->route_request( $this->get_app(), $request, ); return $request; } /** * Create a Request object. * * @param string $method * @param string $path * @param string $prefix * @param array $headers * @param array $cookies * @param array $serverparams * @param null|\core\router\route $route * @return ServerRequestInterface */ protected function create_request( string $method, string $path, string $prefix = route_loader_interface::ROUTE_GROUP_API, array $headers = ['Content-Type' => 'application/json'], array $cookies = [], array $serverparams = [], ?\core\router\route $route = null, ): ServerRequestInterface { $uri = new Uri($prefix . $path); $request = new ServerRequest( method: $method, headers: $headers, uri: $uri, serverParams: $serverparams, ); // Sadly Guzzle's Uri only deals with query strings, not query params. $query = $uri->getQuery(); if ($query) { $queryparams = []; foreach (explode('&', $query) as $queryparam) { [$key, $value] = explode('=', $queryparam, 2); $queryparams[$key] = $value; } $request = $request->withQueryParams($queryparams); } if ($route) { $request = $request->withAttribute(\core\router\route::class, $route); } return $request ->withCookieParams($cookies); } /** * Process a request with the app. * * @param string $method * @param string $path * @param string $prefix * @param array $headers * @param null|StreamInterface $body * @param null|string $contenttype * @param array $cookies * @param array $serverparams * @return ResponseInterface */ protected function process_request( string $method, string $path, string $prefix = '', array $headers = ['HTTP_ACCEPT' => 'application/json'], ?StreamInterface $body = null, ?string $contenttype = 'application/json', array $cookies = [], array $serverparams = [], ): ResponseInterface { $app = $this->get_app(); if ($contenttype !== null) { $headers['Content-Type'] = $contenttype; } $request = $this->create_request( $method, $path, $prefix, $headers, $cookies, $serverparams, ); if ($body) { $request = $request->withBody($body); } return $app->handle($request); } /** * Process a request with the app. * * @param string $method * @param string $path * @param array $headers * @param null|StreamInterface $body * @param array $cookies * @param array $serverparams * @return ResponseInterface */ protected function process_api_request( string $method, string $path, array $headers = ['HTTP_ACCEPT' => 'application/json'], ?StreamInterface $body = null, array $cookies = [], array $serverparams = [], ): ResponseInterface { return $this->process_request( method: $method, path: $path, prefix: route_loader_interface::ROUTE_GROUP_API, headers: $headers, body: $body, cookies: $cookies, serverparams: $serverparams, ); } /** * Route a request within the app. * * @param App $app * @param ServerRequestInterface $request * @return ServerRequestInterface */ protected function route_request( App $app, ServerRequestInterface $request, ): ServerRequestInterface { $routingmiddleware = new RoutingMiddleware( $app->getRouteResolver(), $app->getRouteCollector()->getRouteParser(), ); return $routingmiddleware->performRouting($request); } /** * Create a route and route it to create a request. * * @param string $routepath * @param string $requestpath * @return ServerRequestInterface */ protected function create_route( string $routepath, string $requestpath, ): ServerRequestInterface { $app = $this->get_simple_app(); $app->get($routepath, fn () => new Response()); $request = $this->route_request($app, new ServerRequest('GET', $requestpath)); return $request; } /** * Get the Slim Route object from a Request object. * * @param ServerRequestInterface $request * @return Route */ protected function get_slim_route_from_request( ServerRequestInterface $request, ): Route { return $request->getAttribute(RouteContext::ROUTE); } /** * Assert that a Response object was valid. * * @param ResponseInterface $response * @param null|int $statuscode The expected status code * @throws ExpectationFailedException */ protected function assert_valid_response( ResponseInterface $response, ?int $statuscode = 200, ): void { $this->assertInstanceOf(Response::class, $response); $this->assertEquals( $statuscode, $response->getStatusCode(), "Response status code is not $statuscode", ); } /** * Assert that the supplied response related to an exception. * * @param ResponseInterface $response * @param null|int $responsecode The expected response code */ protected function assert_exception_response( ResponseInterface $response, ?int $responsecode = null, ): void { $this->assertInstanceOf(Response::class, $response); $this->assertNotEquals( 200, $response->getStatusCode(), ); if ($responsecode !== null) { $this->assertEquals( $responsecode, $response->getStatusCode(), ); } $payload = $this->decode_response($response); $this->assertObjectHasProperty('message', $payload); $this->assertObjectHasProperty('stacktrace', $payload); } /** * Assert that the supplied response was an invalid_parameter_exception response. * * @param ResponseInterface $response */ protected function assert_invalid_parameter_response( ResponseInterface $response, ): void { $this->assert_exception_response($response, 400); $payload = $this->decode_response($response); $this->assertObjectHasProperty('errorcode', $payload); $this->assertEquals('invalidparameter', $payload->errorcode); } /** * Assert that the supplied response was an access_denied exception response. * * @param ResponseInterface $response */ protected function assert_access_denied_response( ResponseInterface $response, ): void { $this->assert_exception_response($response, 403); $payload = $this->decode_response($response); $this->assertObjectHasProperty('errorcode', $payload); } /** * Assert that the supplied response was a not_found exception response. * * @param \Psr\Http\Message\ResponseInterface $response */ protected function assert_not_found_response( ResponseInterface $response, ): void { $this->assert_exception_response($response, 404); $payload = $this->decode_response($response); $this->assertObjectHasProperty('errorcode', $payload); } /** * Decode the JSON response for a Response object. * * @param ResponseInterface $response * @param bool $forcearray Force the contents to Array instead of Object * @return stdClass|array */ protected function decode_response( ResponseInterface $response, bool $forcearray = false, ): stdClass|array { if ($forcearray) { return json_decode( json: (string) $response->getBody(), associative: true, ); } else { return (object) json_decode( json: (string) $response->getBody(), associative: false, flags: JSON_FORCE_OBJECT, ); } } /** * Get the schema for an OpenAPI Component. * * Components include headers, parameters, responses, examples, requestBodies, and schemas. * * All components are subclasses of the openapi_base class and may be referenced. * * Any component which implements the referenced_object interface will return a reference * to the stored internal object. * * @param specification $api * @param openapi_base $component * @return stdClass|null */ protected function get_api_component_schema( specification $api, openapi_base $component, ): ?stdClass { $this->assertInstanceOf(referenced_object::class, $component); if (is_a($component, \core\router\schema\header_object::class)) { $type = 'headers'; } else if (is_a($component, \core\router\schema\parameter::class)) { $type = 'parameters'; } else if (is_a($component, \core\router\schema\response\response::class)) { $type = 'responses'; } else if (is_a($component, \core\router\schema\example::class)) { $type = 'examples'; } else if (is_a($component, \core\router\schema\request_body::class)) { $type = 'requestBodies'; } else if (is_a($component, \core\router\schema\objects\type_base::class)) { $type = 'schemas'; } else { $this->fail('Component is not a recognised type'); } $ref = $component->get_reference(false); $schema = $api->get_schema(); $components = $schema->components; $component = $components->{$type}->{$ref} ?? null; return $component; } }