wiki.techinc.nl/includes/Rest/PathTemplateMatcher/PathMatcher.php
Tim Starling 3f0056a252 REST API initial commit
Add some of the basic REST API class hierarchies:

* EntryPoint
* Router
* Request
* Response
* Handler

The actual entry point file rest.php has been moved to a separate
commit, so this is just an unused library and service.

Bug: T221177
Change-Id: Ifca6bcb8a304e8e8b7f52b79c607bdcebf805cd1
2019-06-12 10:22:28 +10:00

221 lines
6.5 KiB
PHP

<?php
namespace MediaWiki\Rest\PathTemplateMatcher;
/**
* A tree-based path routing algorithm.
*
* This container builds defined routing templates into a tree, allowing
* paths to be efficiently matched against all templates. The match time is
* independent of the number of registered path templates.
*
* Efficient matching comes at the cost of a potentially significant setup time.
* We measured ~10ms for 1000 templates. Using getCacheData() and
* newFromCache(), this setup time may be amortized over multiple requests.
*/
class PathMatcher {
/**
* An array of trees indexed by the number of path components in the input.
*
* A tree node consists of an associative array in which the key is a match
* specifier string, and the value is another node. A leaf node, which is
* identifiable by its fixed depth in the tree, consists of an associative
* array with the following keys:
* - template: The path template string
* - paramNames: A list of parameter names extracted from the template
* - userData: The user data supplied to add()
*
* A match specifier string may be either "*", which matches any path
* component, or a literal string prefixed with "=", which matches the
* specified deprefixed string literal.
*
* @var array
*/
private $treesByLength = [];
/**
* Create a PathMatcher from cache data
*
* @param array $data The data array previously returned by getCacheData()
* @return PathMatcher
*/
public static function newFromCache( $data ) {
$matcher = new self;
$matcher->treesByLength = $data;
return $matcher;
}
/**
* Get a data array for later use by newFromCache().
*
* The internal format is private to PathMatcher, but note that it includes
* any data passed as $userData to add(). The array returned will be
* serializable as long as all $userData values are serializable.
*
* @return array
*/
public function getCacheData() {
return $this->treesByLength;
}
/**
* Determine whether a path template component is a parameter
*
* @param string $part
* @return bool
*/
private function isParam( $part ) {
$partLength = strlen( $part );
return $partLength > 2 && $part[0] === '{' && $part[$partLength - 1] === '}';
}
/**
* If a path template component is a parameter, return the parameter name.
* Otherwise, return false.
*
* @param string $part
* @return string|false
*/
private function getParamName( $part ) {
if ( $this->isParam( $part ) ) {
return substr( $part, 1, -1 );
} else {
return false;
}
}
/**
* Recursively search the match tree, checking whether the proposed path
* template, passed as an array of component parts, can be added to the
* matcher without ambiguity.
*
* Ambiguity means that a path exists which matches multiple templates.
*
* The function calls itself recursively, incrementing $index so as to
* ignore a prefix of the input, in order to check deeper parts of the
* match tree.
*
* If a conflict is discovered, the conflicting leaf node is returned.
* Otherwise, false is returned.
*
* @param array $node The tree node to check against
* @param string[] $parts The array of path template parts
* @param int $index The current index into $parts
* @return array|false
*/
private function findConflict( $node, $parts, $index = 0 ) {
if ( $index >= count( $parts ) ) {
// If we reached the leaf node then a conflict is detected
return $node;
}
$part = $parts[$index];
$result = false;
if ( $this->isParam( $part ) ) {
foreach ( $node as $key => $childNode ) {
$result = $this->findConflict( $childNode, $parts, $index + 1 );
if ( $result !== false ) {
break;
}
}
} else {
if ( isset( $node["=$part"] ) ) {
$result = $this->findConflict( $node["=$part"], $parts, $index + 1 );
}
if ( $result === false && isset( $node['*'] ) ) {
$result = $this->findConflict( $node['*'], $parts, $index + 1 );
}
}
return $result;
}
/**
* Add a template to the matcher.
*
* The path template consists of components separated by "/". Each component
* may be either a parameter of the form {paramName}, or a literal string.
* A parameter matches any input path component, whereas a literal string
* matches itself.
*
* Path templates must not conflict with each other, that is, any input
* path must match at most one path template. If a path template conflicts
* with another already registered, this function throws a PathConflict
* exception.
*
* @param string $template The path template
* @param mixed $userData User data used to identify the matched route to
* the caller of match()
* @throws PathConflict
*/
public function add( $template, $userData ) {
$parts = explode( '/', $template );
$length = count( $parts );
if ( !isset( $this->treesByLength[$length] ) ) {
$this->treesByLength[$length] = [];
}
$tree =& $this->treesByLength[$length];
$conflict = $this->findConflict( $tree, $parts );
if ( $conflict !== false ) {
throw new PathConflict( $template, $userData, $conflict );
}
$params = [];
foreach ( $parts as $index => $part ) {
$paramName = $this->getParamName( $part );
if ( $paramName !== false ) {
$params[] = $paramName;
$key = '*';
} else {
$key = "=$part";
}
if ( $index === $length - 1 ) {
$tree[$key] = [
'template' => $template,
'paramNames' => $params,
'userData' => $userData
];
} elseif ( !isset( $tree[$key] ) ) {
$tree[$key] = [];
}
$tree =& $tree[$key];
}
}
/**
* Match a path against the current match trees.
*
* If the path matches a previously added path template, an array will be
* returned with the following keys:
* - params: An array mapping parameter names to their detected values
* - userData: The user data passed to add(), which identifies the route
*
* If the path does not match any template, false is returned.
*
* @param string $path
* @return array|false
*/
public function match( $path ) {
$parts = explode( '/', $path );
$length = count( $parts );
if ( !isset( $this->treesByLength[$length] ) ) {
return false;
}
$node = $this->treesByLength[$length];
$paramValues = [];
foreach ( $parts as $part ) {
if ( isset( $node["=$part"] ) ) {
$node = $node["=$part"];
} elseif ( isset( $node['*'] ) ) {
$node = $node['*'];
$paramValues[] = $part;
} else {
return false;
}
}
return [
'params' => array_combine( $node['paramNames'], $paramValues ),
'userData' => $node['userData']
];
}
}