diff --git a/docs/apis/subsystems/routing/index.md b/docs/apis/subsystems/routing/index.md new file mode 100644 index 0000000000..60a4a02635 --- /dev/null +++ b/docs/apis/subsystems/routing/index.md @@ -0,0 +1,73 @@ +--- +title: Routing +--- + + + +Moodle includes a powerful routing system based upon the [Slim Framework](https://www.slimframework.com/), and [FastRoute](https://github.com/nikic/FastRoute). + +Routes are defined by creating classes within the `route` L2 namespace of a component, and associating methods with the `\core\router\route` attribute. Routes have a Route _group_ identified by the L3 namespace, for example `api`. Unknown route groups are ignored. + +The currently supported list of route groups are: + +| Route Group | Namespace | URI Prefix | Purpose | +| --- | --- | --- | --- | +| API | `api` | `/api/rest/v2` | REST Web Services | + +Routes may optionally describe additional metadata including: + +- path parameters +- optional path parameters +- header parameters +- HTTP method types (GET, POST, and so on) +- Responses +- Examples + +When invoked, any parameter to the route method will be resolved using a combination of [Dependency Injection](../../core/di/index.md) and resolution of path, query, and header parameters. + +## Using the `route` attribute + +When applied to a method, the `\core\router\route` attribute will create a route. Class-level attributes can also be defined and are used to define a route prefix, and some defaults, but cannot handle a route directly. + +The path will be combined with the URI prefix described by the route _group_ (for example `api` has a prefix of `/api/rest/v2`), and the component (for example `mod_example`) to create a fully-qualified path. + +Route groups are pre-defined by the Routing Subsystem and will provide a URI prefix, relevant [Middleware](https://www.php-fig.org/psr/psr-15/meta/), and some rules -- as an example the `api` route group has a route prefix of `/api/rest/v2`, and will apply a range of Route Middleware to configure CORS, perform Input and Output Sanitisation, normalise paths, and more. + +:::note + +Any unknown Route Group will be ignored. + +::: + +In the following example, the namespace of the class has: + +- A Level 2 namespace of `route`; and +- A Level 3 namespace of `api`. + +This relates to the `api` route group giving it a path prefix of `/api/rest/v2`. + +```php title="A simple route" +// mod/example/classes/route/api/example.php +namespace mod_example\route\api; + +use core\router\schema\response\payload_response; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +class example { + #[\core\router\route( + // Resolves to https://example.com/moodle/api/rest/v2/mod_example/example + path: '/example', + )] + public function example_route( + ServerRequestInterface $request, + ResponseInterface $response, + ): ResponseInterface { + return new payload_response( + request: $request, + response: $response, + payload: [], + ); + } +} +``` diff --git a/docs/apis/subsystems/routing/parameters.md b/docs/apis/subsystems/routing/parameters.md new file mode 100644 index 0000000000..e8c0253f0b --- /dev/null +++ b/docs/apis/subsystems/routing/parameters.md @@ -0,0 +1,608 @@ +--- +title: Parameters +tags: + - PSR-15 + - ServerRequestInterface + - RequestInterface + - Request +--- + +The Routing subsystem supports three types of parameter to a request: + +- Path parameters +- Query parameters +- Header key/value pairs + +Parameters are specified with a `name`, and a parameter `type` which is used to validate the input before passing it to the routed method. All types are cases of the `\core\param` enum, for example `\core\param::TEXT`. + +Parameters also support optional features including: + +- a description specified in the `description` property; +- allowing a field to be marked as required via the boolean `required` property; +- allowing an optional field to provide a default value using the `default` property; +- allowing a field to be deprecated via the boolean `deprected` property; and +- the specification of examples by specifying an array in the `examples` property. + +## Path parameters + +When defining the path in a route, it is possible to specify parts of the URI as variables using placeholders. These are known as path parameters. Each path parameter must be appropriately described in the `pathtypes` array property of the route attribute using instances of `\core\router\schema\parameters\path_parameter`. + +In the following example a `username` parameter is described in the path, and described further in the `pathtypes` array: + +```php title="Defining a basic path parameter" +namespace core\route\api; + +use core\param; +use core\router\route; +use core\router\schema\parameters\path_parameter; + +class example { + #[route( + path: '/users/{username}', + pathtypes: [ + new path_parameter( + name: 'username', + type: param::ALPHANUM, + ), + ], + )] + public function get_user_information( + string $username, + ): ResponseInterface { + // ... + } +} +``` + +When a user accesses the URI the name will be validated using the `\core\param::ALPHANUM` type specified in the `path_parameter` constructor, and provided to the method. + +### Retrieving parameter values + +Path parameters can be retrieved from the request in two ways: + +- by specifying an `array $args` parameter on the method; or +- by naming the individual parameters you wish to access. + +```php title="Fetching parameters" +namespace core\route\api; + +use core\param; +use core\router\route; +use core\router\schema\parameters\path_parameter; + +class example { + #[route( + path: '/users/{username}', + pathtypes: [ + new path_parameter( + name: 'username', + type: param::ALPHANUM, + ), + ], + )] + public function get_user_information( + string $username, + array $args, + ): ResponseInterface { + // ... + $args['username'] === $username; + } +} +``` + +### Optional path parameters + +Path parameters can be made optional by wrapping them in `[]` braces in the path definition, for example: + +```php title="An optional parameter in the path" +path: '/users[/{username}]', +``` + +The full usage of this is therefore: + +```php title="Defining an optional path parameter" +#[route( + path: '/users[/{username}]', + pathtypes: [ + new path_parameter( + name: 'username', + type: param::TEXT, + ), + ], +)] +``` + +This creates _two_ routes: + +- `/path/to/route/users` +- `/path/to/route/users/{username}` + +If the username is not specified then its value will default to `null` or the supplied default. + +:::note + +If fetching the value using a named method parameter, and the default is `null`, then the optional parameter must be nullable, for example: + +```php title= +public function get_user_information( + ?string $name, +): ResponseInterface { + // ... +} +``` + +::: + +#### Multiple optional parameters + +In some cases it is necessary to capture multiple optional parameters. This can be achieved by adding them within the optional path braces, for example: + +```php title="Defining multiple optional path parameters" +#[route( + path: '/users[/{name}/[{pet}]]', + pathtypes: [ + new path_parameter( + name: 'name', + type: param::TEXT, + ), + new path_parameter( + name: 'pet', + type: param::TEXT, + ), + ], +)] +public function get_user_information( + ?string $name, + ?string $pet, +): ResponseInterface { + // ... +} +``` + +:::warning + +All parameters after the first optional parameter are considered to be optional. The following is an example of an invalid path: + + + +```php +#[route( + path: '/users[/{name}]/example' +)] +``` + + + +::: + +#### Default values + +The default value for an unspecified optional parameter is `null`, but an alternative default can be provided by defining the `default` property in the `path_parameter` constructor, for example: + +```php title="Specifying a default value for an optional path parameter" +#[route( + path: '/users[/{name}/]', + pathtypes: [ + new path_parameter( + name: 'name', + type: param::TEXT, + default: 'dave', + ), + ], +)] +``` + +:::note + +Optional parameters cannot be set on a required parameter. + +::: + +## Query parameters + +Query parameters are similar to path parameters in that they allow data to be passed in the URI, but instead of forming part of the _path_, they are specified after the path in the query section. + +Consider the following example URI: + +``` +https://example.com/api/rest/v2/mod_example/users/colin?pet=james&type=bird +``` + +In this example: + +- The path is: `/api/rest/v2/mode_example/users/colin` +- The query parameters are: + - `pet=james` + - `type=bird` + +To define a query parameter it must be specified in the `queryparams` array, for example: + +```php title="Defining query parameters" +#[route( + path: '/users/{name}', + queryparams: [ + new query_parameter( + name: 'pet', + type: param::TEXT, + ), + new query_parameter( + name: 'type', + type: param::TEXT, + ), + ], + pathtypes: [ + // ... + ], +)] +``` + +The value of the query parameter can be fetched from the request using the `ServerRequestInterface::getQueryParams()` method, for example: + +```php title="Fetching the value of a query parameter" +public function get_user_information( + ServerRequestInterface $request, + ?string $name, +): ResponseInterface { + $params = $request->getQueryParams(); + + // The pet name: + $params['pet']; + + // The type of pet: + $params['type']; +} +``` + +### Optional query parameters + +Query parameters are optional by default, but can be made required by specifying the `required` flag. + +```php title="Making a query parameter required" +new query_parameter( + name: 'pet', + type: param::TEXT, + required: true, +), +``` + +## Header values + +In addition to path and query parameters, it is sometimes necessary to specify options as Request Headers. This is particularly common in web service requests. An example of such a request might be to determine whether Moodle Filters should be applied to text content. + +Header parameters are defined in the route attribute in a similar way as to path and query parameters, using the `headerparams` route property and specifying instances of the `\core\router\schema\parameters\header_object` class. + +```php title="Creating a header parameter" +#[route( + path: '/users', + headerparams: [ + new \core\router\schema\parameters\header_object( + name: 'Filters', + description: 'Whether Moodle Filters should be applied to text in the response', + type: param::BOOL, + ), + ], +)] +``` + +### Retrieving parameter values + +Header values may be fetched using the `getHeaders()` and `getHeaderLine()` methods in the `ServerRequest` object, for example: + +```php title="Retrieving header params" +public function example( + ServerRequestInterface $request, +): ResponseInterface { + // Get the header value as an Array. + $values = $request->getHeaders('Filters'); + + // Get the header value as a string. + $values = $request->getHeaderLine('Filters'); +} +``` + +### Required and Optional header parameters + +Headers can be made required or optional by setting the `required` flag as required, for example: + +```php title="Setting the required flag" +#[route( + path: '/users', + headerparams: [ + new \core\router\schema\parameters\header_object( + name: 'X-Optional', + description: 'An example optional parameter', + type: param::BOOL, + required: false, + default: false, + ), + new \core\router\schema\parameters\header_object( + name: 'X-Required', + description: 'An example required parameter', + type: param::BOOL, + required: true, + ), + ], +)] +``` + +### Multiple headers + +In some cases it is necessary to accept multiple header values. This can be configured using the `multiple` flag to the header object, for example: + +```php title="Accepting multiple header values" +#[route( + path: '/users', + headerparams: [ + new \core\router\schema\parameters\header_object( + name: 'X-Users', + description: 'A list of usernames', + type: param::ALPHANUM, + multiple: true, + ), + ], +] +public function example( + ServerRequestInterface $request, +): payload_response { + // Returns an array with all of the header values. + $values = $request->getHeader('X-users'); + + // Returns a comma-separated string with all of the header values. + $values = $request->getHeaderLine('X-users'); +} +``` + +## Other features + +### Reusable parameters + +When writing endpoints it is common to reuse the same patterns frequently. This may be the same query, path, or header. + +Since all parameter types are instances of the relevant class it is very easy to create reusable parameters by creating a new class which extends the relevant parameter type and just using that type instead. + +In the following example a reusable path parameter is created for the theme name: + +```php title="lib/classes/router/parameters/path_themename.php" +namespace core\router\parameters; + +use core\param; +use core\router\schema\referenced_object; + +class path_themename + extends \core\router\schema\parameters\path_parameter + implements referenced_object +{ + public function __construct( + string $name = 'themename', + ...$args, + ) { + $args['name'] = $name; + + $args['type'] = param::ALPHANUMEXT; + $args['description'] = 'The name of a Moodle theme.'; + + parent::__construct(...$args); + } +} +``` + +To use this theme name parameter, it can be provided as a path parameter instead of `path_parameter`, for example: + +```php title="Using the path_themename parameter" +#[route( + path: '/users/{themename}', + pathtypes: [ + new \core\router\parameters\path_themename( + name: 'username', + type: param::ALPHANUM, + ), + ], +)] +``` + +:::tip Use of the `referenced_object` interface for reusable components + +When creating any kind of reusable component it is strongly advisable to have it implement the `\core\router\schema\referenced_object` interface. + +This empty interface informs the OpenAPI specification generator that this parameter is a reusable component and that it should create an entry for it in the OpenAPI specification. Internally any use of this reusable parameter will use an OpenAPI reference. + +The primary benefit of usign this interface is to reduce the size of the generated OpenAPI Schema. + +::: + +### Mapped parameters + +One very powerful feature of the Moodle Routing API is provision for 'mapped' parameters which allow a standard parameter of any type to be mapped to another value of some kind. + +This has several main benefits, including: + +- the ability to map an identifier into an instance of a specific object; and +- allowing for multiple dynamic mappings into a single value. + +Mapped parameters must implement the `\core\router\schema\parameters\mapped_property_parameter` interface which declares a single method, `add_attributes_for_parameter_value`. It takes the `ServerRequestInterface` and the value supplied by the user. + +The method must add the value as an attribute to the request using the same name. + +```php title="Creating a mapped path parameter for a course" +class path_course extends \core\router\schema\parameters\path_parameter implements + referenced_object, + mapped_property_parameter +{ + #[\Override] + public function add_attributes_for_parameter_value( + ServerRequestInterface $request, + string $value, + ): ServerRequestInterface { + $course = $this->get_course_for_value($value); + + return $request + ->withAttribute($this->name, $course) + ->withAttribute( + "{$this->name}_context", + \core\context\course::instance($course->id), + ); + } +} +``` + +Where this becomes particularly powerful is the ability to use a prefix to map multiple possible options into a single value. + +In the above example, the `get_course_for_value` method can accept any of the following: + +- `([\d+])` - A numeric course id +- `^idnumber:(.*)$` - A course specified by its idnumber using the prefix `idnumber:` +- `^shortname:(.*)$` - A course specified by its shortname using the prefix `shortname:` + +All of these values will return the database record for the specified course. + +:::note Uniqueness of mapped property + +Mapped parameters work best with unique values. In the above example it is not advisable to use the course full name as this is not a unique value. + +::: + +
+ A complete example of a mapped property + +This example includes a name, type, description, and examples of correct usage. The `get_schema_from_type` method is specified and allows a more detailed description of accepted patterns using a Perl-Compatible Regular Expression. + +```php title="A full example" +namespace core\router\parameters; + +use core\exception\not_found_exception; +use core\param; +use core\router\schema\example; +use core\router\schema\parameters\mapped_property_parameter; +use core\router\schema\referenced_object; +use Psr\Http\Message\ServerRequestInterface; + +/** + * A Moodle parameter referenced in the path. + * + * @package core + * @copyright 2023 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class path_course extends \core\router\schema\parameters\path_parameter implements + referenced_object, + mapped_property_parameter +{ + /** + * Create a new path_course parameter. + * + * @param string $name The name of the parameter to use for the course identifier + * @param mixed ...$extra Additional arguments + */ + public function __construct( + string $name = 'course', + ...$extra, + ) { + $extra['name'] = $name; + $extra['type'] = param::RAW; + $extra['description'] = <<get_record('course', [ + 'id' => $value, + ]); + } else if (str_starts_with($value, 'idnumber:')) { + $data = $DB->get_record('course', [ + 'idnumber' => substr($value, strlen('idnumber:')), + ]); + } else if (str_starts_with($value, 'name:')) { + $data = $DB->get_record('course', [ + 'shortname' => substr($value, strlen('name:')), + ]); + } + + if ($data) { + return $data; + } + + throw new not_found_exception('course', $value); + } + + #[\Override] + public function add_attributes_for_parameter_value( + ServerRequestInterface $request, + string $value, + ): ServerRequestInterface { + $course = $this->get_course_for_value($value); + + return $request + ->withAttribute($this->name, $course) + ->withAttribute("{$this->name}_context", \core\context\course::instance($course->id)); + } + + #[\Override] + public function get_schema_from_type(param $type): \stdClass { + $schema = parent::get_schema_from_type($type); + + $schema->pattern = "^("; + $schema->pattern .= implode("|", [ + '\d+', + 'idnumber:.+', + 'name:.+', + ]); + $schema->pattern .= ")$"; + + return $schema; + } +} +``` + +
+ +### Examples + +When writing web services it is often desirable to give an example of the correct usage of that parameter. This can be achieved by specifying instances of the `\core\router\schema\example` to the `examples` property, for example: + +```php title="Specifying an example for a parameter" +new path_parameter( + name: 'themename', + type: param::ALPHANUMEXT, + examples: [ + new \core\router\schema\example( + name: 'The Boost theme', + value: 'boost', + ), + ], +) +``` diff --git a/docs/apis/subsystems/routing/responses.md b/docs/apis/subsystems/routing/responses.md new file mode 100644 index 0000000000..1161cc419a --- /dev/null +++ b/docs/apis/subsystems/routing/responses.md @@ -0,0 +1,112 @@ +--- +title: Responses +tags: + - PSR-7 + - ResponseInterface + - payload +--- + +When creating a Route you will need to return some data. This can be achieved in a number of ways, depending on the purpose of the route and data type. + +Routes must return one of the following types: + +- A PSR `ResponseInterface` - `\Psr\Http\Message\ResponseInterface` +- A Data Object represented by a `payload_response` - `\core\router\schema\response\payload_response` + +Other types may be added in the future, for example to support rendering a template. + +:::danger Web Service Response types + +Web Service responses will almost always return a `payload_reponse` to allow validation of their data. + +::: + +## Data as a Payload Response + +The Moodle `\core\router\schema\response\payload_response` class represents a set of data for return to the user. By default it is serialized into JSON as part of the Response handling and is automatically checked against the schema for valid response types. + +You can create a payload response with: + +```php +return new payload_response( + payload: $result, + request: $request, + response: $response, +); +``` + +The request should be the `ServerRequestInterface` that was passed into the route method, and the payload can be an Array containing any JSON-encodable data. + +Where possible the `ResponseInterface` instance passed into the route method should be provided. + +In the following example a JSON structure containing three users will be created. + +```php title="Returning data with a payload response" +// mod/example/classes/route/api/example.php +namespace mod_example\route\api; + +use core\router\schema\response\payload_response; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +class example { + #[\core\router\route( + // Resolves to https://example.com/moodle/api/rest/v2/mod_example/example + path: '/example', + )] + public function example_route( + ServerRequestInterface $request, + ResponseInterface $response, + ): ResponseInterface { + return new payload_respoinse( + request: $request, + response $response, + payload: [ + 'users' => [ + 'charlie', + 'john', + 'david', + ], + ], + ); + } +} +``` + +:::tip Why a `payload_response` object is used instead of raw JSON + +Whilst you are _able_ to return JSON directly this is strongly discouraged because: + +- additional processing would be required to JSON-decode the data in order to validate the response; and +- by using a generic payload response it is theoretically possible to return data in other formats such as XML and YAML. + +::: + +### Adding Headers to the payload + +In some cases you may wish to provide additional data using the HTTP Response Header. This can be achieved by modifying the Response object before passing it into the `payload_response`, for example: + +```php title="Adding the X-Example header to the payload header" +$response = $response->withHeader( + 'X-Example', + 'This header is added to the Response', +); + +return new payload_response( + payload: [ + 'some' => [ + 'data' => [ + 'children' => $children, + ], + ], + ], + request: $request, + response: $response, +); +``` + +:::tip + +Other modifications to the Response are also possible, but the Response Body will always be replaced with the payload data. + +::: diff --git a/docs/apis/subsystems/routing/testing.md b/docs/apis/subsystems/routing/testing.md new file mode 100644 index 0000000000..b8ec1d5ca6 --- /dev/null +++ b/docs/apis/subsystems/routing/testing.md @@ -0,0 +1,129 @@ +--- +title: Unit Testing +tags: + - testing + - routing +--- + +One of the benefits of using routes is how easy they are to unit test. It is also possible to test [parameters](./parameters.md) for validation, which is especially useful for [mapped parameters](./parameters.md#mapped-parameters). + +## The `\route_testcase` testcase class + +When testing routes your test should extend the `\route_testcase` base testcase, which is loaded as part of the PHPUnit Bootstrap. + +This testcase provides a number of helper methods to create environments to test your routes. + +When unit testing code it is usually desirable to test only your code without all of the additional unrelated code typically included in the setup of routes. To make this easier the testcase includes methods to create a simplified router, to add one or more routes from a class, to create requests, and to route requests through the router to the relevant route. + +## Creating a simplified app + +You can quickly and easily create a copy of the Moodle Router using the `route_testcase::get_router()` method. + +This is a fully-configured copy of the Moodle Router, and allows to handle requests directly. + +```php +final class my_test extends \route_testcase { + public function test_example(): void { + $router = $this->get_router(); + } +} +``` + +:::note + +This router contains _all_ possible routes in Moodle. When writing unit tests it is typically advisable to only include the routes that you wish to test. + +::: + +## Specifying routes to include + +When calling `get_router()` the Router will use [Dependency Injection](../../core/di/index.md) to inject all dependencies of the router. Amongst these dependencies is an implementation of the `\core\router\route_loader_interface`. The default implementation of this is the `\core\route\route_loader`. + +Moodle also provides a `mocking_route_loader` which can be used in unit tests to either create completely new mocked routes, or to load existing routes from disk. + +When using the mocking route loader only those routes that you explicitly add will be included. + +:::caution Ordering + +Because the router uses DI to inject routes during its initialisation, all routes must be mocked before calling `get_router()` or related methods. + +::: + +### Adding existing routes + +You can easily add existing routes to mocking route loader either individually, or by including all routes in a class. + +To add an individual route, you can use the `\route_testcase::add_route_to_route_loader()` method, for example: + +```php title="Adding a single route to the route loader" +final class my_test extends \route_testcase { + public function test_example(): void { + $this->add_route_to_route_loader( + my_route::class, + 'my_route_method', + ); + + $router = $this->get_router(); + } +} +``` + +You can also add all routes in a class to the route loader using the `\route_testcase::add_class_routes_to_route_loader()` method, for example: + +```php title="Adding all routes in a class to the route loader" +final class my_test extends \route_testcase { + public function test_example(): void { + $this->add_class_routes_to_route_loader( + my_route::class, + ); + + $router = $this->get_router(); + } +} + +``` + +### Using the app to handle a request + +The `\route_testcase` also includes several methods to simplify generating a Request object, and having routing it within the Router. + +You can create a request and manually pass it to Router using the `\route_testcase::create_request()` method, for example: + +```php title="Creating an example request and processing it" +public function test_example(): void { + $this->add_class_routes_to_route_loader( + my_route::class, + ); + + $router = $this->get_router(); + + // Create the ServerRequest. + $request = $this->create_request('GET', '/path/to/route', route_loader_interface::ROUTE_GROUP_API); + + // Pass the request into the App and process it through all Middleware. + $response = $router->get_app()->handle($request); +} +``` + +:::note Request Groups + +When creating a request the default is to use the Route Group for the REST API, but any valid `ROUTE_GROUP` is supported. + +::: + +The `\route_testcase::process_request()` and `\route_testcsae::process_api_request()` methods act as a shortcut for creating the request, fetching the router, and the app, and handling the request to return a response. The above example can therefore be simplified to: + +```php title="Creating and processing an example request" +public function test_example(): void { + $this->add_class_routes_to_route_loader( + my_route::class, + ); + + $response = $this->process_api_request('GET', '/path/to/route'); +} +``` + +All of these methods also accept: + +- any headers to provide with your request +- any query parameters diff --git a/versioned_docs/version-4.5/apis/subsystems/routing/index.md b/versioned_docs/version-4.5/apis/subsystems/routing/index.md new file mode 100644 index 0000000000..60a4a02635 --- /dev/null +++ b/versioned_docs/version-4.5/apis/subsystems/routing/index.md @@ -0,0 +1,73 @@ +--- +title: Routing +--- + + + +Moodle includes a powerful routing system based upon the [Slim Framework](https://www.slimframework.com/), and [FastRoute](https://github.com/nikic/FastRoute). + +Routes are defined by creating classes within the `route` L2 namespace of a component, and associating methods with the `\core\router\route` attribute. Routes have a Route _group_ identified by the L3 namespace, for example `api`. Unknown route groups are ignored. + +The currently supported list of route groups are: + +| Route Group | Namespace | URI Prefix | Purpose | +| --- | --- | --- | --- | +| API | `api` | `/api/rest/v2` | REST Web Services | + +Routes may optionally describe additional metadata including: + +- path parameters +- optional path parameters +- header parameters +- HTTP method types (GET, POST, and so on) +- Responses +- Examples + +When invoked, any parameter to the route method will be resolved using a combination of [Dependency Injection](../../core/di/index.md) and resolution of path, query, and header parameters. + +## Using the `route` attribute + +When applied to a method, the `\core\router\route` attribute will create a route. Class-level attributes can also be defined and are used to define a route prefix, and some defaults, but cannot handle a route directly. + +The path will be combined with the URI prefix described by the route _group_ (for example `api` has a prefix of `/api/rest/v2`), and the component (for example `mod_example`) to create a fully-qualified path. + +Route groups are pre-defined by the Routing Subsystem and will provide a URI prefix, relevant [Middleware](https://www.php-fig.org/psr/psr-15/meta/), and some rules -- as an example the `api` route group has a route prefix of `/api/rest/v2`, and will apply a range of Route Middleware to configure CORS, perform Input and Output Sanitisation, normalise paths, and more. + +:::note + +Any unknown Route Group will be ignored. + +::: + +In the following example, the namespace of the class has: + +- A Level 2 namespace of `route`; and +- A Level 3 namespace of `api`. + +This relates to the `api` route group giving it a path prefix of `/api/rest/v2`. + +```php title="A simple route" +// mod/example/classes/route/api/example.php +namespace mod_example\route\api; + +use core\router\schema\response\payload_response; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +class example { + #[\core\router\route( + // Resolves to https://example.com/moodle/api/rest/v2/mod_example/example + path: '/example', + )] + public function example_route( + ServerRequestInterface $request, + ResponseInterface $response, + ): ResponseInterface { + return new payload_response( + request: $request, + response: $response, + payload: [], + ); + } +} +``` diff --git a/versioned_docs/version-4.5/apis/subsystems/routing/parameters.md b/versioned_docs/version-4.5/apis/subsystems/routing/parameters.md new file mode 100644 index 0000000000..e8c0253f0b --- /dev/null +++ b/versioned_docs/version-4.5/apis/subsystems/routing/parameters.md @@ -0,0 +1,608 @@ +--- +title: Parameters +tags: + - PSR-15 + - ServerRequestInterface + - RequestInterface + - Request +--- + +The Routing subsystem supports three types of parameter to a request: + +- Path parameters +- Query parameters +- Header key/value pairs + +Parameters are specified with a `name`, and a parameter `type` which is used to validate the input before passing it to the routed method. All types are cases of the `\core\param` enum, for example `\core\param::TEXT`. + +Parameters also support optional features including: + +- a description specified in the `description` property; +- allowing a field to be marked as required via the boolean `required` property; +- allowing an optional field to provide a default value using the `default` property; +- allowing a field to be deprecated via the boolean `deprected` property; and +- the specification of examples by specifying an array in the `examples` property. + +## Path parameters + +When defining the path in a route, it is possible to specify parts of the URI as variables using placeholders. These are known as path parameters. Each path parameter must be appropriately described in the `pathtypes` array property of the route attribute using instances of `\core\router\schema\parameters\path_parameter`. + +In the following example a `username` parameter is described in the path, and described further in the `pathtypes` array: + +```php title="Defining a basic path parameter" +namespace core\route\api; + +use core\param; +use core\router\route; +use core\router\schema\parameters\path_parameter; + +class example { + #[route( + path: '/users/{username}', + pathtypes: [ + new path_parameter( + name: 'username', + type: param::ALPHANUM, + ), + ], + )] + public function get_user_information( + string $username, + ): ResponseInterface { + // ... + } +} +``` + +When a user accesses the URI the name will be validated using the `\core\param::ALPHANUM` type specified in the `path_parameter` constructor, and provided to the method. + +### Retrieving parameter values + +Path parameters can be retrieved from the request in two ways: + +- by specifying an `array $args` parameter on the method; or +- by naming the individual parameters you wish to access. + +```php title="Fetching parameters" +namespace core\route\api; + +use core\param; +use core\router\route; +use core\router\schema\parameters\path_parameter; + +class example { + #[route( + path: '/users/{username}', + pathtypes: [ + new path_parameter( + name: 'username', + type: param::ALPHANUM, + ), + ], + )] + public function get_user_information( + string $username, + array $args, + ): ResponseInterface { + // ... + $args['username'] === $username; + } +} +``` + +### Optional path parameters + +Path parameters can be made optional by wrapping them in `[]` braces in the path definition, for example: + +```php title="An optional parameter in the path" +path: '/users[/{username}]', +``` + +The full usage of this is therefore: + +```php title="Defining an optional path parameter" +#[route( + path: '/users[/{username}]', + pathtypes: [ + new path_parameter( + name: 'username', + type: param::TEXT, + ), + ], +)] +``` + +This creates _two_ routes: + +- `/path/to/route/users` +- `/path/to/route/users/{username}` + +If the username is not specified then its value will default to `null` or the supplied default. + +:::note + +If fetching the value using a named method parameter, and the default is `null`, then the optional parameter must be nullable, for example: + +```php title= +public function get_user_information( + ?string $name, +): ResponseInterface { + // ... +} +``` + +::: + +#### Multiple optional parameters + +In some cases it is necessary to capture multiple optional parameters. This can be achieved by adding them within the optional path braces, for example: + +```php title="Defining multiple optional path parameters" +#[route( + path: '/users[/{name}/[{pet}]]', + pathtypes: [ + new path_parameter( + name: 'name', + type: param::TEXT, + ), + new path_parameter( + name: 'pet', + type: param::TEXT, + ), + ], +)] +public function get_user_information( + ?string $name, + ?string $pet, +): ResponseInterface { + // ... +} +``` + +:::warning + +All parameters after the first optional parameter are considered to be optional. The following is an example of an invalid path: + + + +```php +#[route( + path: '/users[/{name}]/example' +)] +``` + + + +::: + +#### Default values + +The default value for an unspecified optional parameter is `null`, but an alternative default can be provided by defining the `default` property in the `path_parameter` constructor, for example: + +```php title="Specifying a default value for an optional path parameter" +#[route( + path: '/users[/{name}/]', + pathtypes: [ + new path_parameter( + name: 'name', + type: param::TEXT, + default: 'dave', + ), + ], +)] +``` + +:::note + +Optional parameters cannot be set on a required parameter. + +::: + +## Query parameters + +Query parameters are similar to path parameters in that they allow data to be passed in the URI, but instead of forming part of the _path_, they are specified after the path in the query section. + +Consider the following example URI: + +``` +https://example.com/api/rest/v2/mod_example/users/colin?pet=james&type=bird +``` + +In this example: + +- The path is: `/api/rest/v2/mode_example/users/colin` +- The query parameters are: + - `pet=james` + - `type=bird` + +To define a query parameter it must be specified in the `queryparams` array, for example: + +```php title="Defining query parameters" +#[route( + path: '/users/{name}', + queryparams: [ + new query_parameter( + name: 'pet', + type: param::TEXT, + ), + new query_parameter( + name: 'type', + type: param::TEXT, + ), + ], + pathtypes: [ + // ... + ], +)] +``` + +The value of the query parameter can be fetched from the request using the `ServerRequestInterface::getQueryParams()` method, for example: + +```php title="Fetching the value of a query parameter" +public function get_user_information( + ServerRequestInterface $request, + ?string $name, +): ResponseInterface { + $params = $request->getQueryParams(); + + // The pet name: + $params['pet']; + + // The type of pet: + $params['type']; +} +``` + +### Optional query parameters + +Query parameters are optional by default, but can be made required by specifying the `required` flag. + +```php title="Making a query parameter required" +new query_parameter( + name: 'pet', + type: param::TEXT, + required: true, +), +``` + +## Header values + +In addition to path and query parameters, it is sometimes necessary to specify options as Request Headers. This is particularly common in web service requests. An example of such a request might be to determine whether Moodle Filters should be applied to text content. + +Header parameters are defined in the route attribute in a similar way as to path and query parameters, using the `headerparams` route property and specifying instances of the `\core\router\schema\parameters\header_object` class. + +```php title="Creating a header parameter" +#[route( + path: '/users', + headerparams: [ + new \core\router\schema\parameters\header_object( + name: 'Filters', + description: 'Whether Moodle Filters should be applied to text in the response', + type: param::BOOL, + ), + ], +)] +``` + +### Retrieving parameter values + +Header values may be fetched using the `getHeaders()` and `getHeaderLine()` methods in the `ServerRequest` object, for example: + +```php title="Retrieving header params" +public function example( + ServerRequestInterface $request, +): ResponseInterface { + // Get the header value as an Array. + $values = $request->getHeaders('Filters'); + + // Get the header value as a string. + $values = $request->getHeaderLine('Filters'); +} +``` + +### Required and Optional header parameters + +Headers can be made required or optional by setting the `required` flag as required, for example: + +```php title="Setting the required flag" +#[route( + path: '/users', + headerparams: [ + new \core\router\schema\parameters\header_object( + name: 'X-Optional', + description: 'An example optional parameter', + type: param::BOOL, + required: false, + default: false, + ), + new \core\router\schema\parameters\header_object( + name: 'X-Required', + description: 'An example required parameter', + type: param::BOOL, + required: true, + ), + ], +)] +``` + +### Multiple headers + +In some cases it is necessary to accept multiple header values. This can be configured using the `multiple` flag to the header object, for example: + +```php title="Accepting multiple header values" +#[route( + path: '/users', + headerparams: [ + new \core\router\schema\parameters\header_object( + name: 'X-Users', + description: 'A list of usernames', + type: param::ALPHANUM, + multiple: true, + ), + ], +] +public function example( + ServerRequestInterface $request, +): payload_response { + // Returns an array with all of the header values. + $values = $request->getHeader('X-users'); + + // Returns a comma-separated string with all of the header values. + $values = $request->getHeaderLine('X-users'); +} +``` + +## Other features + +### Reusable parameters + +When writing endpoints it is common to reuse the same patterns frequently. This may be the same query, path, or header. + +Since all parameter types are instances of the relevant class it is very easy to create reusable parameters by creating a new class which extends the relevant parameter type and just using that type instead. + +In the following example a reusable path parameter is created for the theme name: + +```php title="lib/classes/router/parameters/path_themename.php" +namespace core\router\parameters; + +use core\param; +use core\router\schema\referenced_object; + +class path_themename + extends \core\router\schema\parameters\path_parameter + implements referenced_object +{ + public function __construct( + string $name = 'themename', + ...$args, + ) { + $args['name'] = $name; + + $args['type'] = param::ALPHANUMEXT; + $args['description'] = 'The name of a Moodle theme.'; + + parent::__construct(...$args); + } +} +``` + +To use this theme name parameter, it can be provided as a path parameter instead of `path_parameter`, for example: + +```php title="Using the path_themename parameter" +#[route( + path: '/users/{themename}', + pathtypes: [ + new \core\router\parameters\path_themename( + name: 'username', + type: param::ALPHANUM, + ), + ], +)] +``` + +:::tip Use of the `referenced_object` interface for reusable components + +When creating any kind of reusable component it is strongly advisable to have it implement the `\core\router\schema\referenced_object` interface. + +This empty interface informs the OpenAPI specification generator that this parameter is a reusable component and that it should create an entry for it in the OpenAPI specification. Internally any use of this reusable parameter will use an OpenAPI reference. + +The primary benefit of usign this interface is to reduce the size of the generated OpenAPI Schema. + +::: + +### Mapped parameters + +One very powerful feature of the Moodle Routing API is provision for 'mapped' parameters which allow a standard parameter of any type to be mapped to another value of some kind. + +This has several main benefits, including: + +- the ability to map an identifier into an instance of a specific object; and +- allowing for multiple dynamic mappings into a single value. + +Mapped parameters must implement the `\core\router\schema\parameters\mapped_property_parameter` interface which declares a single method, `add_attributes_for_parameter_value`. It takes the `ServerRequestInterface` and the value supplied by the user. + +The method must add the value as an attribute to the request using the same name. + +```php title="Creating a mapped path parameter for a course" +class path_course extends \core\router\schema\parameters\path_parameter implements + referenced_object, + mapped_property_parameter +{ + #[\Override] + public function add_attributes_for_parameter_value( + ServerRequestInterface $request, + string $value, + ): ServerRequestInterface { + $course = $this->get_course_for_value($value); + + return $request + ->withAttribute($this->name, $course) + ->withAttribute( + "{$this->name}_context", + \core\context\course::instance($course->id), + ); + } +} +``` + +Where this becomes particularly powerful is the ability to use a prefix to map multiple possible options into a single value. + +In the above example, the `get_course_for_value` method can accept any of the following: + +- `([\d+])` - A numeric course id +- `^idnumber:(.*)$` - A course specified by its idnumber using the prefix `idnumber:` +- `^shortname:(.*)$` - A course specified by its shortname using the prefix `shortname:` + +All of these values will return the database record for the specified course. + +:::note Uniqueness of mapped property + +Mapped parameters work best with unique values. In the above example it is not advisable to use the course full name as this is not a unique value. + +::: + +
+ A complete example of a mapped property + +This example includes a name, type, description, and examples of correct usage. The `get_schema_from_type` method is specified and allows a more detailed description of accepted patterns using a Perl-Compatible Regular Expression. + +```php title="A full example" +namespace core\router\parameters; + +use core\exception\not_found_exception; +use core\param; +use core\router\schema\example; +use core\router\schema\parameters\mapped_property_parameter; +use core\router\schema\referenced_object; +use Psr\Http\Message\ServerRequestInterface; + +/** + * A Moodle parameter referenced in the path. + * + * @package core + * @copyright 2023 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class path_course extends \core\router\schema\parameters\path_parameter implements + referenced_object, + mapped_property_parameter +{ + /** + * Create a new path_course parameter. + * + * @param string $name The name of the parameter to use for the course identifier + * @param mixed ...$extra Additional arguments + */ + public function __construct( + string $name = 'course', + ...$extra, + ) { + $extra['name'] = $name; + $extra['type'] = param::RAW; + $extra['description'] = <<get_record('course', [ + 'id' => $value, + ]); + } else if (str_starts_with($value, 'idnumber:')) { + $data = $DB->get_record('course', [ + 'idnumber' => substr($value, strlen('idnumber:')), + ]); + } else if (str_starts_with($value, 'name:')) { + $data = $DB->get_record('course', [ + 'shortname' => substr($value, strlen('name:')), + ]); + } + + if ($data) { + return $data; + } + + throw new not_found_exception('course', $value); + } + + #[\Override] + public function add_attributes_for_parameter_value( + ServerRequestInterface $request, + string $value, + ): ServerRequestInterface { + $course = $this->get_course_for_value($value); + + return $request + ->withAttribute($this->name, $course) + ->withAttribute("{$this->name}_context", \core\context\course::instance($course->id)); + } + + #[\Override] + public function get_schema_from_type(param $type): \stdClass { + $schema = parent::get_schema_from_type($type); + + $schema->pattern = "^("; + $schema->pattern .= implode("|", [ + '\d+', + 'idnumber:.+', + 'name:.+', + ]); + $schema->pattern .= ")$"; + + return $schema; + } +} +``` + +
+ +### Examples + +When writing web services it is often desirable to give an example of the correct usage of that parameter. This can be achieved by specifying instances of the `\core\router\schema\example` to the `examples` property, for example: + +```php title="Specifying an example for a parameter" +new path_parameter( + name: 'themename', + type: param::ALPHANUMEXT, + examples: [ + new \core\router\schema\example( + name: 'The Boost theme', + value: 'boost', + ), + ], +) +``` diff --git a/versioned_docs/version-4.5/apis/subsystems/routing/responses.md b/versioned_docs/version-4.5/apis/subsystems/routing/responses.md new file mode 100644 index 0000000000..1161cc419a --- /dev/null +++ b/versioned_docs/version-4.5/apis/subsystems/routing/responses.md @@ -0,0 +1,112 @@ +--- +title: Responses +tags: + - PSR-7 + - ResponseInterface + - payload +--- + +When creating a Route you will need to return some data. This can be achieved in a number of ways, depending on the purpose of the route and data type. + +Routes must return one of the following types: + +- A PSR `ResponseInterface` - `\Psr\Http\Message\ResponseInterface` +- A Data Object represented by a `payload_response` - `\core\router\schema\response\payload_response` + +Other types may be added in the future, for example to support rendering a template. + +:::danger Web Service Response types + +Web Service responses will almost always return a `payload_reponse` to allow validation of their data. + +::: + +## Data as a Payload Response + +The Moodle `\core\router\schema\response\payload_response` class represents a set of data for return to the user. By default it is serialized into JSON as part of the Response handling and is automatically checked against the schema for valid response types. + +You can create a payload response with: + +```php +return new payload_response( + payload: $result, + request: $request, + response: $response, +); +``` + +The request should be the `ServerRequestInterface` that was passed into the route method, and the payload can be an Array containing any JSON-encodable data. + +Where possible the `ResponseInterface` instance passed into the route method should be provided. + +In the following example a JSON structure containing three users will be created. + +```php title="Returning data with a payload response" +// mod/example/classes/route/api/example.php +namespace mod_example\route\api; + +use core\router\schema\response\payload_response; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +class example { + #[\core\router\route( + // Resolves to https://example.com/moodle/api/rest/v2/mod_example/example + path: '/example', + )] + public function example_route( + ServerRequestInterface $request, + ResponseInterface $response, + ): ResponseInterface { + return new payload_respoinse( + request: $request, + response $response, + payload: [ + 'users' => [ + 'charlie', + 'john', + 'david', + ], + ], + ); + } +} +``` + +:::tip Why a `payload_response` object is used instead of raw JSON + +Whilst you are _able_ to return JSON directly this is strongly discouraged because: + +- additional processing would be required to JSON-decode the data in order to validate the response; and +- by using a generic payload response it is theoretically possible to return data in other formats such as XML and YAML. + +::: + +### Adding Headers to the payload + +In some cases you may wish to provide additional data using the HTTP Response Header. This can be achieved by modifying the Response object before passing it into the `payload_response`, for example: + +```php title="Adding the X-Example header to the payload header" +$response = $response->withHeader( + 'X-Example', + 'This header is added to the Response', +); + +return new payload_response( + payload: [ + 'some' => [ + 'data' => [ + 'children' => $children, + ], + ], + ], + request: $request, + response: $response, +); +``` + +:::tip + +Other modifications to the Response are also possible, but the Response Body will always be replaced with the payload data. + +::: diff --git a/versioned_docs/version-4.5/apis/subsystems/routing/testing.md b/versioned_docs/version-4.5/apis/subsystems/routing/testing.md new file mode 100644 index 0000000000..b8ec1d5ca6 --- /dev/null +++ b/versioned_docs/version-4.5/apis/subsystems/routing/testing.md @@ -0,0 +1,129 @@ +--- +title: Unit Testing +tags: + - testing + - routing +--- + +One of the benefits of using routes is how easy they are to unit test. It is also possible to test [parameters](./parameters.md) for validation, which is especially useful for [mapped parameters](./parameters.md#mapped-parameters). + +## The `\route_testcase` testcase class + +When testing routes your test should extend the `\route_testcase` base testcase, which is loaded as part of the PHPUnit Bootstrap. + +This testcase provides a number of helper methods to create environments to test your routes. + +When unit testing code it is usually desirable to test only your code without all of the additional unrelated code typically included in the setup of routes. To make this easier the testcase includes methods to create a simplified router, to add one or more routes from a class, to create requests, and to route requests through the router to the relevant route. + +## Creating a simplified app + +You can quickly and easily create a copy of the Moodle Router using the `route_testcase::get_router()` method. + +This is a fully-configured copy of the Moodle Router, and allows to handle requests directly. + +```php +final class my_test extends \route_testcase { + public function test_example(): void { + $router = $this->get_router(); + } +} +``` + +:::note + +This router contains _all_ possible routes in Moodle. When writing unit tests it is typically advisable to only include the routes that you wish to test. + +::: + +## Specifying routes to include + +When calling `get_router()` the Router will use [Dependency Injection](../../core/di/index.md) to inject all dependencies of the router. Amongst these dependencies is an implementation of the `\core\router\route_loader_interface`. The default implementation of this is the `\core\route\route_loader`. + +Moodle also provides a `mocking_route_loader` which can be used in unit tests to either create completely new mocked routes, or to load existing routes from disk. + +When using the mocking route loader only those routes that you explicitly add will be included. + +:::caution Ordering + +Because the router uses DI to inject routes during its initialisation, all routes must be mocked before calling `get_router()` or related methods. + +::: + +### Adding existing routes + +You can easily add existing routes to mocking route loader either individually, or by including all routes in a class. + +To add an individual route, you can use the `\route_testcase::add_route_to_route_loader()` method, for example: + +```php title="Adding a single route to the route loader" +final class my_test extends \route_testcase { + public function test_example(): void { + $this->add_route_to_route_loader( + my_route::class, + 'my_route_method', + ); + + $router = $this->get_router(); + } +} +``` + +You can also add all routes in a class to the route loader using the `\route_testcase::add_class_routes_to_route_loader()` method, for example: + +```php title="Adding all routes in a class to the route loader" +final class my_test extends \route_testcase { + public function test_example(): void { + $this->add_class_routes_to_route_loader( + my_route::class, + ); + + $router = $this->get_router(); + } +} + +``` + +### Using the app to handle a request + +The `\route_testcase` also includes several methods to simplify generating a Request object, and having routing it within the Router. + +You can create a request and manually pass it to Router using the `\route_testcase::create_request()` method, for example: + +```php title="Creating an example request and processing it" +public function test_example(): void { + $this->add_class_routes_to_route_loader( + my_route::class, + ); + + $router = $this->get_router(); + + // Create the ServerRequest. + $request = $this->create_request('GET', '/path/to/route', route_loader_interface::ROUTE_GROUP_API); + + // Pass the request into the App and process it through all Middleware. + $response = $router->get_app()->handle($request); +} +``` + +:::note Request Groups + +When creating a request the default is to use the Route Group for the REST API, but any valid `ROUTE_GROUP` is supported. + +::: + +The `\route_testcase::process_request()` and `\route_testcsae::process_api_request()` methods act as a shortcut for creating the request, fetching the router, and the app, and handling the request to return a response. The above example can therefore be simplified to: + +```php title="Creating and processing an example request" +public function test_example(): void { + $this->add_class_routes_to_route_loader( + my_route::class, + ); + + $response = $this->process_api_request('GET', '/path/to/route'); +} +``` + +All of these methods also accept: + +- any headers to provide with your request +- any query parameters