aboutsummaryrefslogtreecommitdiffstats
path: root/includes/Rest/Module
diff options
context:
space:
mode:
Diffstat (limited to 'includes/Rest/Module')
-rw-r--r--includes/Rest/Module/Module.php384
-rw-r--r--includes/Rest/Module/ModuleConfigurationException.php12
-rw-r--r--includes/Rest/Module/RouteFileModule.php359
3 files changed, 755 insertions, 0 deletions
diff --git a/includes/Rest/Module/Module.php b/includes/Rest/Module/Module.php
new file mode 100644
index 000000000000..b370b3e4d34a
--- /dev/null
+++ b/includes/Rest/Module/Module.php
@@ -0,0 +1,384 @@
+<?php
+
+namespace MediaWiki\Rest\Module;
+
+use LogicException;
+use MediaWiki\Profiler\ProfilingContext;
+use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface;
+use MediaWiki\Rest\CorsUtils;
+use MediaWiki\Rest\Handler;
+use MediaWiki\Rest\HttpException;
+use MediaWiki\Rest\LocalizedHttpException;
+use MediaWiki\Rest\PathTemplateMatcher\ModuleConfigurationException;
+use MediaWiki\Rest\Reporter\ErrorReporter;
+use MediaWiki\Rest\RequestInterface;
+use MediaWiki\Rest\ResponseException;
+use MediaWiki\Rest\ResponseFactory;
+use MediaWiki\Rest\ResponseInterface;
+use MediaWiki\Rest\Router;
+use MediaWiki\Rest\Validator\Validator;
+use Throwable;
+use Wikimedia\Message\MessageValue;
+use Wikimedia\ObjectFactory\ObjectFactory;
+
+/**
+ * A REST module represents a collection of endpoints.
+ * The module object is responsible for generating a response for a given
+ * request. This is typically done by routing requests to the appropriate
+ * request handler.
+ *
+ * @since 1.43
+ */
+abstract class Module {
+
+ /**
+ * @internal for use in cached module data
+ */
+ public const CACHE_CONFIG_HASH_KEY = 'CONFIG-HASH';
+
+ protected string $pathPrefix;
+ protected ResponseFactory $responseFactory;
+ private BasicAuthorizerInterface $basicAuth;
+ private ObjectFactory $objectFactory;
+ private Validator $restValidator;
+ private ErrorReporter $errorReporter;
+
+ private Router $router;
+ private ?CorsUtils $cors = null;
+
+ /**
+ * @param Router $router
+ * @param string $pathPrefix
+ * @param ResponseFactory $responseFactory
+ * @param BasicAuthorizerInterface $basicAuth
+ * @param ObjectFactory $objectFactory
+ * @param Validator $restValidator
+ * @param ErrorReporter $errorReporter
+ */
+ public function __construct(
+ Router $router,
+ string $pathPrefix,
+ ResponseFactory $responseFactory,
+ BasicAuthorizerInterface $basicAuth,
+ ObjectFactory $objectFactory,
+ Validator $restValidator,
+ ErrorReporter $errorReporter
+ ) {
+ $this->router = $router;
+ $this->pathPrefix = $pathPrefix;
+ $this->responseFactory = $responseFactory;
+ $this->basicAuth = $basicAuth;
+ $this->objectFactory = $objectFactory;
+ $this->restValidator = $restValidator;
+ $this->errorReporter = $errorReporter;
+ }
+
+ public function getPathPrefix(): string {
+ return $this->pathPrefix;
+ }
+
+ /**
+ * Return data that can later be used to initialize a new instance of
+ * this module in a fast and efficient way.
+ *
+ * @see initFromCacheData()
+ *
+ * @return array An associative array suitable to be processed by
+ * initFromCacheData. Implementations are free to choose the format.
+ */
+ abstract public function getCacheData(): array;
+
+ /**
+ * Initialize from the given cache data if possible.
+ * This allows fast initialization based on data that was cached during
+ * a previous invocation of the module.
+ *
+ * Implementations are responsible for verifying that the cache data
+ * matches the information provided to the constructor, to protect against
+ * a situation where configuration was updated in a way that affects the
+ * operation of the module.
+ *
+ * @param array $cacheData Data generated by getCacheData(), implementations
+ * are free to choose the format.
+ *
+ * @return bool true if the cache data could be used,
+ * false if it was discarded.
+ * @see getCacheData()
+ */
+ abstract public function initFromCacheData( array $cacheData ): bool;
+
+ /**
+ * Create a Handler for the given path, taking into account the request
+ * method.
+ *
+ * If $prepExecution is true, the handler's prepareForExecute() method will
+ * be called, which will call postInitSetup(). The $request object will be
+ * updated with any path parameters and parsed body data.
+ *
+ * @unstable
+ *
+ * @param string $path
+ * @param RequestInterface $request The request to handle. If $forExecution
+ * is true, this will be updated with the path parameters and parsed
+ * body data as appropriate.
+ * @param bool $initForExecute Whether the handler and the request should be
+ * prepared for execution. Callers that only need the Handler object
+ * for access to meta-data should set this to false.
+ *
+ * @return Handler
+ * @throws HttpException If no handler was found
+ */
+ public function getHandlerForPath(
+ string $path,
+ RequestInterface $request,
+ bool $initForExecute = false
+ ): Handler {
+ $requestMethod = strtoupper( $request->getMethod() );
+
+ $match = $this->findHandlerMatch( $path, $requestMethod );
+
+ if ( !$match['found'] && $requestMethod === 'HEAD' ) {
+ // For a HEAD request, execute the GET handler instead if one exists.
+ $match = $this->findHandlerMatch( $path, 'GET' );
+ }
+
+ if ( !$match['found'] ) {
+ $this->throwNoMatch(
+ $path,
+ $request->getMethod(),
+ $match['methods'] ?? []
+ );
+ }
+
+ if ( isset( $match['handler'] ) ) {
+ $handler = $match['handler'];
+ } elseif ( isset( $match['spec'] ) ) {
+ $handler = $this->instantiateHandlerObject( $match['spec'] );
+ } else {
+ throw new LogicException(
+ 'Match does not specify a handler instance or object spec.'
+ );
+ }
+
+ // Provide context about the module
+ $handler->initContext( $this, $match['config'] ?? [] );
+
+ // Inject services and state from the router
+ $this->getRouter()->prepareHandler( $handler );
+
+ if ( $initForExecute ) {
+ // Use rawurldecode so a "+" in path params is not interpreted as a space character.
+ $pathParams = array_map( 'rawurldecode', $match['params'] ?? [] );
+ $request->setPathParams( $pathParams );
+
+ $handler->initForExecute( $request );
+ }
+
+ return $handler;
+ }
+
+ public function getRouter(): Router {
+ return $this->router;
+ }
+
+ /**
+ * Determines which handler to use for the given path and returns an array
+ * describing the handler and initialization context.
+ *
+ * @param string $path
+ * @param string $requestMethod
+ *
+ * @return array<string,mixed>
+ * - bool found: Whether a match was found. If true, the `handler`
+ * or `spec` field must be set.
+ * - Handler handler: the Handler object to use. Either handler or
+ * spec must be given.
+ * - array spec: an object spec for use with instantiateHandlerObject()
+ * - array config: the route config, to be passed to Handler::initContext()
+ * - string path: the path the handler is responsible for,
+ * including placeholders for path parameters.
+ * - string[] params: path parameters, to be passed the
+ * Request::setPathPrams()
+ * - string[] methods: supported methods, if the path is known but
+ * the method did not match. Only meaningful if `found` is false.
+ * To be returned in the Allow header of a 405 response and included
+ * in CORS pre-flight.
+ */
+ abstract protected function findHandlerMatch(
+ string $path,
+ string $requestMethod
+ ): array;
+
+ /**
+ * Implementations of getHandlerForPath() should call this method when they
+ * cannot handle the requested path.
+ *
+ * @param string $path The requested path
+ * @param string $method The HTTP method of the current request
+ * @param string[] $allowed The allowed HTTP methods allowed by the path
+ *
+ * @return never
+ * @throws HttpException
+ */
+ protected function throwNoMatch( string $path, string $method, array $allowed ): void {
+ // Check for CORS Preflight. This response will *not* allow the request unless
+ // an Access-Control-Allow-Origin header is added to this response.
+ if ( $this->cors && $method === 'OPTIONS' && $allowed ) {
+ // IDEA: Create a CorsHandler, which getHandlerForPath can return in this case.
+ $response = $this->cors->createPreflightResponse( $allowed );
+ throw new ResponseException( $response );
+ }
+
+ if ( $allowed ) {
+ // There are allowed methods for this patch, so reply with Method Not Allowed.
+ $response = $this->responseFactory->createLocalizedHttpError( 405,
+ ( new MessageValue( 'rest-wrong-method' ) )
+ ->textParams( $method )
+ ->commaListParams( $allowed )
+ ->numParams( count( $allowed ) )
+ );
+ $response->setHeader( 'Allow', $allowed );
+ throw new ResponseException( $response );
+ } else {
+ // There are no allowed methods for this path, so the path was not found at all.
+ $msg = ( new MessageValue( 'rest-no-match' ) )
+ ->plaintextParams( $path );
+ throw new LocalizedHttpException( $msg, 404 );
+ }
+ }
+
+ /**
+ * Find the handler for a request and execute it
+ */
+ public function execute( string $path, RequestInterface $request ): ResponseInterface {
+ $handler = null;
+
+ try {
+ $handler = $this->getHandlerForPath( $path, $request, true );
+
+ $response = $this->executeHandler( $handler );
+ } catch ( HttpException $e ) {
+ $response = $this->responseFactory->createFromException( $e );
+ } catch ( Throwable $e ) {
+ // Note that $handler is allowed to be null here.
+ $this->errorReporter->reportError( $e, $handler, $request );
+ $response = $this->responseFactory->createFromException( $e );
+ }
+
+ return $response;
+ }
+
+ /**
+ * @internal for testing
+ *
+ * @return array[] An associative array, mapping path patterns to
+ * a list of request methods supported for the path.
+ */
+ abstract public function getDefinedPaths(): array;
+
+ /**
+ * Get the allowed methods for a path.
+ * Useful to check for 405 wrong method and for generating OpenAPI specs.
+ *
+ * @param string $relPath A concrete request path.
+ * @return string[] A list of allowed HTTP request methods for the path.
+ * If the path is not supported, the list will be empty.
+ */
+ abstract public function getAllowedMethods( string $relPath ): array;
+
+ /**
+ * Creates a handler from the given spec, but does not initialize it.
+ */
+ protected function instantiateHandlerObject( array $spec ): Handler {
+ /** @var $handler Handler (annotation for PHPStorm) */
+ $handler = $this->objectFactory->createObject(
+ $spec,
+ [ 'assertClass' => Handler::class ]
+ );
+
+ return $handler;
+ }
+
+ /**
+ * Execute a fully-constructed handler
+ * @throws HttpException
+ */
+ protected function executeHandler( Handler $handler ): ResponseInterface {
+ ProfilingContext::singleton()->init( MW_ENTRY_POINT, $handler->getPath() );
+ // Check for basic authorization, to avoid leaking data from private wikis
+ $authResult = $this->basicAuth->authorize( $handler->getRequest(), $handler );
+ if ( $authResult ) {
+ return $this->responseFactory->createHttpError( 403, [ 'error' => $authResult ] );
+ }
+
+ // Check session (and session provider)
+ $handler->checkSession();
+
+ // Validate the parameters
+ $handler->validate( $this->restValidator );
+
+ // Check conditional request headers
+ $earlyResponse = $handler->checkPreconditions();
+ if ( $earlyResponse ) {
+ return $earlyResponse;
+ }
+
+ // Run the main part of the handler
+ $response = $handler->execute();
+ if ( !( $response instanceof ResponseInterface ) ) {
+ $response = $this->responseFactory->createFromReturnValue( $response );
+ }
+
+ // Set Last-Modified and ETag headers in the response if available
+ $handler->applyConditionalResponseHeaders( $response );
+
+ $handler->applyCacheControl( $response );
+
+ return $response;
+ }
+
+ /**
+ * @param CorsUtils $cors
+ * @return self
+ */
+ public function setCors( CorsUtils $cors ): self {
+ $this->cors = $cors;
+
+ return $this;
+ }
+
+ /**
+ * Loads a module specification from a file.
+ *
+ * This method does not know or care about the structure of the file
+ * other than that it must be JSON and contain a list or map
+ * (that is, a JSON array or object).
+ *
+ * @param string $fileName
+ *
+ * @internal
+ *
+ * @return array An associative or indexed array describing the module
+ * @throws ModuleConfigurationException
+ */
+ public static function loadJsonFile( string $fileName ): array {
+ $json = file_get_contents( $fileName );
+ if ( $json === false ) {
+ throw new ModuleConfigurationException(
+ "Failed to route file `$fileName`"
+ );
+ }
+
+ $spec = json_decode(
+ $json,
+ true
+ );
+ if ( !is_array( $spec ) ) {
+ throw new ModuleConfigurationException(
+ "Failed to parse `$fileName` as a JSON object"
+ );
+ }
+
+ return $spec;
+ }
+}
diff --git a/includes/Rest/Module/ModuleConfigurationException.php b/includes/Rest/Module/ModuleConfigurationException.php
new file mode 100644
index 000000000000..2d64d97ee69d
--- /dev/null
+++ b/includes/Rest/Module/ModuleConfigurationException.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace MediaWiki\Rest\PathTemplateMatcher;
+
+use Exception;
+
+/**
+ * Exception indicating incorrect REST module configuration.
+ * @since 1.43
+ */
+class ModuleConfigurationException extends Exception {
+}
diff --git a/includes/Rest/Module/RouteFileModule.php b/includes/Rest/Module/RouteFileModule.php
new file mode 100644
index 000000000000..d367764f882c
--- /dev/null
+++ b/includes/Rest/Module/RouteFileModule.php
@@ -0,0 +1,359 @@
+<?php
+
+namespace MediaWiki\Rest\Module;
+
+use AppendIterator;
+use ArrayIterator;
+use Iterator;
+use LogicException;
+use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface;
+use MediaWiki\Rest\Handler\RedirectHandler;
+use MediaWiki\Rest\PathTemplateMatcher\ModuleConfigurationException;
+use MediaWiki\Rest\PathTemplateMatcher\PathMatcher;
+use MediaWiki\Rest\Reporter\ErrorReporter;
+use MediaWiki\Rest\ResponseFactory;
+use MediaWiki\Rest\RouteDefinitionException;
+use MediaWiki\Rest\Router;
+use MediaWiki\Rest\Validator\Validator;
+use Wikimedia\ObjectFactory\ObjectFactory;
+
+/**
+ * A Module that is based on route definition files. This module responds to
+ * requests by matching the requested path against a list of known routes to
+ * identify the appropriate handler. The routes are loaded for route definition
+ * files.
+ *
+ * Two versions of route declaration files are currently supported, "flat"
+ * route files and "annotated" route files. Both use JSON syntax. Flat route
+ * files are supported for backwards compatibility, and should be avoided.
+ *
+ * Flat files just contain a list (a JSON array) or route definitions (see below).
+ * Annotated route definition files contain a map (a JSON object) with the
+ * following fields:
+ * - "module": the module name (string). The router uses this name to find the
+ * correct module for handling a request by matching it against the prefix
+ * of the request path. The module name must be unique.
+ * - "routes": a list (JSON array) or route definitions (see below).
+ *
+ * Each route definition maps a path pattern to a handler class. It is given as
+ * a map (JSON object) with the following fields:
+ * - "path": the path pattern (string) relative to the module prefix. Required.
+ * The path may contain placeholders for path parameters.
+ * - "method": the HTTP method(s) or "verbs" supported by the route. If not given,
+ * it is assumed that the route supports the "GET" method. The "OPTIONS" method
+ * for CORS is supported implicitly.
+ * - "class" or "factory": The handler class (string) or factory function
+ * (callable) of an "object spec" for use with ObjectFactory::createObject.
+ * See there for the usage of additional fields like "services". If a shorthand
+ * is used (see below), no object spec is needed.
+ *
+ * The following fields are supported as a shorthand notation:
+ * - "redirect": the route represents a redirect and will be handled by
+ * the RedirectHandler class. The redirect is specified as a JSON object
+ * that specifies the target "path", and optional the redirect "code".
+ *
+ * More shorthands may be added in the future.
+ *
+ * Route definitions can contain additional fields to configure the handler.
+ * The handler can access the route definition by calling getConfig().
+ *
+ * @internal
+ * @since 1.43
+ */
+class RouteFileModule extends Module {
+
+ /** @var string[] */
+ private array $routeFiles;
+
+ /**
+ * @var array<int,array> A list of route definitions
+ */
+ private array $extraRoutes;
+
+ /**
+ * @var array<int,array>|null A list of route definitions loaded from
+ * the files specified by $routeFiles
+ */
+ private ?array $routesFromFiles = null;
+
+ /** @var int[]|null */
+ private ?array $routeFileTimestamps = null;
+
+ /** @var string|null */
+ private ?string $configHash = null;
+
+ /** @var PathMatcher[]|null Path matchers by method */
+ private ?array $matchers = null;
+
+ /**
+ * @param string[] $routeFiles List of names of JSON files containing routes
+ * See the documentation of this class for a description of the file
+ * format.
+ * @param array<int,array> $extraRoutes Extension route array. The content of
+ * this array must be a list of route definitions. See the documentation
+ * of this class for a description of the expected structure.
+ */
+ public function __construct(
+ array $routeFiles,
+ array $extraRoutes,
+ Router $router,
+ string $pathPrefix,
+ ResponseFactory $responseFactory,
+ BasicAuthorizerInterface $basicAuth,
+ ObjectFactory $objectFactory,
+ Validator $restValidator,
+ ErrorReporter $errorReporter
+ ) {
+ parent::__construct(
+ $router,
+ $pathPrefix,
+ $responseFactory,
+ $basicAuth,
+ $objectFactory,
+ $restValidator,
+ $errorReporter
+ );
+ $this->routeFiles = $routeFiles;
+ $this->extraRoutes = $extraRoutes;
+ }
+
+ public function getCacheData(): array {
+ $cacheData = [];
+
+ foreach ( $this->getMatchers() as $method => $matcher ) {
+ $cacheData[$method] = $matcher->getCacheData();
+ }
+
+ $cacheData[self::CACHE_CONFIG_HASH_KEY] = $this->getConfigHash();
+ return $cacheData;
+ }
+
+ public function initFromCacheData( array $cacheData ): bool {
+ if ( $cacheData[self::CACHE_CONFIG_HASH_KEY] !== $this->getConfigHash() ) {
+ return false;
+ }
+
+ unset( $cacheData[self::CACHE_CONFIG_HASH_KEY] );
+ $this->matchers = [];
+
+ foreach ( $cacheData as $method => $data ) {
+ $this->matchers[$method] = PathMatcher::newFromCache( $data );
+ }
+
+ return true;
+ }
+
+ /**
+ * Get a config version hash for cache invalidation
+ *
+ * @return string
+ */
+ private function getConfigHash(): string {
+ if ( $this->configHash === null ) {
+ $this->configHash = md5( json_encode( [
+ 'version' => 5,
+ 'extraRoutes' => $this->extraRoutes,
+ 'fileTimestamps' => $this->getRouteFileTimestamps()
+ ] ) );
+ }
+ return $this->configHash;
+ }
+
+ /**
+ * Load the defined JSON files and return the merged routes.
+ *
+ * @return array<int,array> A list of route definitions. See this class's
+ * documentation for a description of the format of route definitions.
+ * @throws ModuleConfigurationException If a route file cannot be loaded or processed.
+ */
+ private function getRoutesFromFiles(): array {
+ if ( $this->routesFromFiles !== null ) {
+ return $this->routesFromFiles;
+ }
+
+ $this->routesFromFiles = [];
+ $this->routeFileTimestamps = [];
+ foreach ( $this->routeFiles as $fileName ) {
+ $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
+
+ $routes = $this->loadRoutes( $fileName );
+
+ $this->routesFromFiles = array_merge( $this->routesFromFiles, $routes );
+ }
+
+ return $this->routesFromFiles;
+ }
+
+ /**
+ * Loads route definitions from the given file
+ *
+ * @param string $fileName
+ *
+ * @return array<int,array> A list of route definitions. See this class's
+ * documentation for a description of the format of route definitions.
+ * @throws ModuleConfigurationException
+ */
+ private function loadRoutes( string $fileName ) {
+ $spec = $this->loadJsonFile( $fileName );
+
+ if ( isset( $spec['routes'] ) ) {
+ if ( !isset( $spec['module'] ) ) {
+ throw new ModuleConfigurationException(
+ "Missing module name in $fileName"
+ );
+ }
+
+ if ( $spec['module'] !== $this->pathPrefix ) {
+ // The Router gave us a route file that doesn't match the module name.
+ // This is a programming error, the Router should get this right.
+ throw new LogicException(
+ "Module name mismatch in $fileName: " .
+ "expected {$this->pathPrefix} but got {$spec['module']}."
+ );
+ }
+
+ // intermediate format with meta-data
+ $routes = $spec['routes'];
+ } else {
+ // old, flat format
+ $routes = $spec;
+ }
+
+ return $routes;
+ }
+
+ /**
+ * Get an array of last modification times of the defined route files.
+ *
+ * @return int[] Last modification times
+ */
+ private function getRouteFileTimestamps(): array {
+ if ( $this->routeFileTimestamps === null ) {
+ $this->routeFileTimestamps = [];
+ foreach ( $this->routeFiles as $fileName ) {
+ $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
+ }
+ }
+ return $this->routeFileTimestamps;
+ }
+
+ /**
+ * @internal for testing and for generating OpenAPI specs
+ *
+ * @return array[]
+ */
+ public function getDefinedPaths(): array {
+ $paths = [];
+ foreach ( $this->getAllRoutes() as $spec ) {
+ $key = $spec['path'];
+
+ $methods = isset( $spec['method'] ) ? (array)$spec['method'] : [ 'GET' ];
+
+ $paths[$key] = array_merge( $paths[$key] ?? [], $methods );
+ }
+
+ return $paths;
+ }
+
+ /**
+ * @return Iterator<array>
+ */
+ private function getAllRoutes() {
+ $iterator = new AppendIterator;
+ $iterator->append( new ArrayIterator( $this->getRoutesFromFiles() ) );
+ $iterator->append( new ArrayIterator( $this->extraRoutes ) );
+ return $iterator;
+ }
+
+ /**
+ * Get an array of PathMatcher objects indexed by HTTP method
+ *
+ * @return PathMatcher[]
+ */
+ private function getMatchers() {
+ if ( $this->matchers === null ) {
+ $routeDefs = $this->getAllRoutes();
+
+ $matchers = [];
+
+ foreach ( $routeDefs as $spec ) {
+ $methods = $spec['method'] ?? [ 'GET' ];
+ if ( !is_array( $methods ) ) {
+ $methods = [ $methods ];
+ }
+ foreach ( $methods as $method ) {
+ if ( !isset( $matchers[$method] ) ) {
+ $matchers[$method] = new PathMatcher;
+ }
+ $matchers[$method]->add( $spec['path'], $spec );
+ }
+ }
+ $this->matchers = $matchers;
+ }
+
+ return $this->matchers;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function findHandlerMatch(
+ string $path,
+ string $requestMethod
+ ): array {
+ $matchers = $this->getMatchers();
+ $matcher = $matchers[$requestMethod] ?? null;
+ $match = $matcher ? $matcher->match( $path ) : null;
+
+ if ( !$match ) {
+ // Return allowed methods, to support CORS and 405 responses.
+ return [
+ 'found' => false,
+ 'methods' => $this->getAllowedMethods( $path ),
+ ];
+ } else {
+ $spec = $match['userData'];
+
+ if ( !isset( $spec['class'] ) && !isset( $spec['factory'] ) ) {
+ // Inject well known handler class for shorthand definition
+ if ( isset( $spec['redirect'] ) ) {
+ $spec['class'] = RedirectHandler::class;
+ } else {
+ throw new RouteDefinitionException(
+ 'Route handler definition must specify "class" or ' .
+ '"factory" or "redirect"'
+ );
+ }
+ }
+
+ return [
+ 'found' => true,
+ 'spec' => $spec,
+ 'params' => $match['params'] ?? [],
+ 'config' => $spec,
+ 'path' => $spec['path'],
+ ];
+ }
+ }
+
+ /**
+ * Get the allowed methods for a path.
+ * Useful to check for 405 wrong method.
+ *
+ * @param string $relPath A concrete request path.
+ * @return string[]
+ */
+ public function getAllowedMethods( string $relPath ): array {
+ $allowed = [];
+ foreach ( $this->getMatchers() as $allowedMethod => $allowedMatcher ) {
+ if ( $allowedMatcher->match( $relPath ) ) {
+ $allowed[] = $allowedMethod;
+ }
+ }
+
+ return array_unique(
+ in_array( 'GET', $allowed ) ? array_merge( [ 'HEAD' ], $allowed ) : $allowed
+ );
+ }
+
+}