<?php
namespace Lia\Addon;
/**
*
* description
*
* @see Router tests for good examples of output
*
* @note `$varDelim` is a string of characters used to separate dynamic portions of a url.
* @note `$varDelim` is a global setting, but you can change it, add routes, change it, add routes, etc.
* @note `pattern` refers to an unparsed url pattern like `/{category}/{blog_name}/`
* @note added routers take a `\Lia\Obj\Request` object
* @note route derivers accept `false` and return array of patterns or a `\Lia\Obj\Request` object
* @note test patterns (or testReg) are simplified patterns with `?` in place of dynamic paramater names & are used internally
* @note `$routeMap` contains all the info needed for selecting which route to use
* @note optional paramaters (`/blog/{?optional}/`) only allow for one optional paramater
*
* @todo handleBlog(){} to respond to a request, routeBlog(){} to get an array of routes (which auto-point to handleBlog(){}))
*/
class Router extends \Lia\Addon {
public string $fqn = 'lia:server.router';
/**
* A string of characters that can be used as delimiters for dynamic url portions
* @note this setting is global across all routes. You can circumvent this by using a second router addon
*/
public $varDelim = '\\.\\/\\-\\:';
// public $varDelim = '\\.\\/\\:';
/**
* @structure `['GET'=>['/test/?/pattern/'=>$decoded_pattern_array] ...]`
*/
public $routeMap = [];
/**
* alternate routers to use. Should only contain callables. The callables should accept `\Lia\Obj\Request` objects
*/
public array $routers = [];
/**
* Callables that can route a request & return false if they don't route
*/
// protected $routers = [];
/** set true to load routes from cache & disable the addRoute() liaison method */
public bool $cache_routes = false;
public function init_lia(){
$lia = $this->lia;
// @NOTE Router provides the prefix 'route' which i may remove in the future
$lia->prefixes['route'] = [$this, 'handle_route_prefix_method'];
$lia->methods['addRoute'] = [$this, 'addRoute'];
$lia->methods['route'] = [$this, 'route'];
$lia->methods['clean_url'] = [$this, 'clean_url'];
//
// $lia->addApi('lia:route.deriveFromCallable',[$this,'deriveRouteUrls']);
// $lia->addApiPrefix('lia:route.deriveFromCallable', 'route');
// @NOTE Router addon uses the 'on' prefix for hooks.
$lia->scan('on', $this);
}
/**
* Write the routes cache (if $this->cache_routes is true)
*/
public function onRoutesFound(){
if ($this->cache_routes){
$this->write_cache();
}
}
/**
* Enable cache (if $this->cache_routes is true)
*/
public function onLiaReady($lia){
if ($this->cache_routes){
$this->enable_cache();
}
}
/**
* Enable the cache. You should first set `$lia->router->cache_routes = true;`, so the hook for writing the cache works too
* Disables addRoute() if routes are already cached
*/
public function enable_cache(){
echo "\n\n\n-----------\n\n";
echo "ENABLE CACHE";
echo "\n\n\n-----------\n\n";
$cached_routes = $this->lia->cache_get_file('lia:server.router.cached_routes_map');
echo "\n\nCached Routes:";
var_dump($cached_routes);
if ($cached_routes==false)return;
echo "\n\n\n-----------\n\n";
echo "CACHED YES";
echo "\n\n\n-----------\n\n";
$this->routeMap = $cached_routes;
$this->lia->methods['addRoute'] = function(){};
// logic:
// A: cache is disabled
// 1: do not load routes from cache
// 2: do not disable addRoute()
// B: cache is enabled but not written (or is expired)
// 1: do not disable addRoute()
// 2: let lia run as normal
// 3: hook on server to write routes to the cache
// C: cache is enabled and written to disk
// 1: disable addRoute()
// 2: load routes from cache
// 3: in server hook, do NOT write routes to cache file
}
public function write_cache(){
// echo "\n\n\n-----------\n\n";
// echo 'write cache';
// echo "\n\n\n-----------\n\n";
// exit;
$did_write = $this->lia->cache_file('lia:server.router.cached_routes_map', json_encode($this->routeMap));
// $this->lia->dump($this->routeMap);
// exit;
//
// echo "cached file:\n";
// var_dump($did_write);
// exit;
// $file = $this->lia->cache->cache_file_path('lia:server.router.cached_routes_map');
// var_dump($file);
// var_dump($this->lia->cache->enabled);
// exit;
// exit;
}
/**
* Get an array of patterns from files in a directory.
*
* @return key=>value array, like `[rel_file_path=>pattern]`
*/
public function dir_to_patterns($dir, $prefix=''){
$files = \Lia\Utility\Files::all($dir,$dir);
$patterns = [];
foreach ($files as $f){
$patterns[$f] = $prefix.$this->fileToPattern($f);
}
return $patterns;
}
/**
* Convert a relative file path into a pattern
*
* @todo move file path => pattern conversion into router
* @tag utility, routing
*/
public function fileToPattern($relFile){
$pattern = $relFile;
$ext = pathinfo($relFile,PATHINFO_EXTENSION);
// $hidden = $this->props['route']['hidden_extensions'];
// @NOTE '.php' extension is removed when converting a file path to a url pattern
$hidden = ['php'];
if (in_array($ext,$hidden)){
$pattern = substr($pattern,0,-(strlen($ext)+1));
// @NOTE if `.php` extension is removed, then a trailing slash is added. This is a bug and may be fixed in the future.
$ext = '';
}
// $indexNames = $this->props['route']['index_names'];
// @NOTE 'index' filename is removed when converting a file path to a url pattern
$indexNames = ['index'];
$base = basename($pattern);
if (in_array($base,$indexNames)){
$pattern = substr($pattern,0,-strlen($base));
}
// if ($ext==''
// &&$this->config['route']['force_trail_slash'])$pattern .= '/';
// @NOTE if there is no file extension in the final pattern, then a trailing slash is added (if .php extension is removed, trailing slash is also added, even if there is another extension. This is a bug and may be fixed in the future.)
if ($ext==''
&&true)$pattern .= '/';
// @NOTE `///` and `//` are replaced with a single `/`
$pattern = str_replace(['///','//'], '/', $pattern);
return $pattern;
}
/**
* Clean a url (without domain): replace space with '+', single quote with '%27', and replace multiple slashes with a single slash
*
* @param $dirty_url a url like `/some url///'with quotes'//`
* @return a cleaned up url
*
* @example `/some url///'with quotes'//` returns `/some+url/%27with+quotes%27/`
*/
public function clean_url($dirty_url){
$dirty_url = str_replace(' ', '+', $dirty_url);
$dirty_url = str_replace('\'', '%27', $dirty_url);
$dirty_url = str_replace(['///','//'],'/',$dirty_url);
return $dirty_url;
}
//
// APIs
//
/**
* Get routes by calling the object's method & use the method as the router.
*
* @param $object an object
* @param $m a method on that object. When passed `false`, the method should return an array of url patterns. If passed a string, it should route the url.
* @param $dot_name not used here, but is intended to be an all-lowercase version of the method name without the prefix with dots in place of underscores
*
*
* @deprecated I don't like this design & I'm gonna get rid of it.
*/
public function handle_route_prefix_method($object, $m, $dot_name){
$patterns = $object->$m(false);
foreach ($patterns as $pattern){
$this->addRoute($pattern, [$object, $m]);
}
}
/**
* Add a route
*
* @param $pattern A pattern. See decode_pattern() documentation
* @param $callbackOrFile a callback or a file path
* @param $package (optional) A liaison package
*
*/
public function addRoute($pattern, $callbackOrFile,$package=null){
$initialParsed = $this->decode_pattern($pattern);
$list = $this->separate_optional_from_decoded_pattern($initialParsed);
foreach ($list as $decoded){
$decoded['target'] = $callbackOrFile;
$decoded['package'] = $package;
$testPattern = $decoded['parsedPattern'];
$matches = [];
foreach ($decoded['methods'] as $m){
$this->routeMap[$m][$testPattern][] = $decoded;
}
}
}
/**
* This method is not tested at all. does not check dynamic routes.
* @return true/false if the path has a route.
*/
public function has_static_route(string $path,string $method="GET"):bool{
return isset($this->routeMap[$method][$path]);
}
/**
* Facilitates optional paramaters
*
* Processes a parsed pattern into an array of valid parsed patterns where the original pattern may contain details for optional paramaters
*
* @return array of valid patterns (including the original)
*/
public function separate_optional_from_decoded_pattern($original_parsed){
$clean_original = $original_parsed;
unset($clean_original['extraParsedPattern']);
unset($clean_original['optionalParams']);
$list = [$clean_original];
if (isset($original_parsed['extraParsedPattern'])){
$next_parsed = $clean_original;
$next_parsed['parsedPattern'] = $original_parsed['extraParsedPattern'];
$params = $clean_original['params'];
foreach ($original_parsed['optionalParams'] as $p){
$index = array_search($p, $params);
unset($params[$index]);
}
$params = array_values($params);
$next_parsed['params'] = $params;
$list[] = $next_parsed;
}
return $list;
}
/**
* add a callable as a router. It will be called with a `\Lia\Obj\Request` object as the only paramater
*/
public function addRouter(callable $router){
$this->routers[] = $router;
}
/**
* get a route for the given request
*
* @todo write test for routing via added routers
*/
public function route(\Lia\Obj\Request $request){
$url = $request->url();
$method = $request->method();
foreach ($this->routers as $r){
if ($routeList = $r($request)){
return $routeList;
}
}
$testReg = $this->url_to_regex($url);
$all = array_filter($this->routeMap[$method]??[],
function($routeList,$decoded_pattern) use ($testReg) {
if (preg_match('/'.$testReg.'/',$decoded_pattern))return true;
return false;
}
,ARRAY_FILTER_USE_BOTH);
$routeList = [];
sort($all);
$all = array_merge(...$all);
foreach ($all as $routeInfo){
$active = [
'url' => $url,
'method'=>$method,
'urlRegex'=>$testReg
];
$paramaters = null;
$paramaters = $this->extract_url_paramaters($routeInfo, $url);
$optionalParamaters = $routeInfo['optionalParams']??[];
$shared = [
'paramaters'=>$paramaters,
'optionalParamaters'=> $optionalParamaters,
];
$static = [
'allowedMethods'=>$routeInfo['methods'],
'paramaterizedPattern'=>$routeInfo['pattern'],
'placeholderPattern'=>$routeInfo['parsedPattern'],
'target'=>$routeInfo['target'],
'package'=>$routeInfo['package'],
];
$route = new \Lia\Obj\Route(array_merge($active,$shared,$static));
$routeList[] = $route;
}
return $routeList;
}
/**
*
* @NOTE docblock not done for addDirectoryRoutes()
* @param $package package to attach to the routes. Package is passed to public files and is a paramater on the Route object
* @param
*/
public function addDirectoryRoutes(?\Lia\Package $package, string $directory, string $base_url = '/', array $exts_to_remove=[".php"]){
$patterns = $this->dir_to_patterns($directory);
//$exts_to_remove = ['php', 'md', 'php4', 'a'];
usort($exts_to_remove,
function ($a, $b){
if (strlen($a) < strlen($b))return 1;
else if (strlen($a) > strlen($b))return -1;
else return 0;
}
);
foreach ($patterns as $file=>$pattern){
// Double-slashes are removed from routes. I.e. it's okay if you accidentally leave a double slash
//echo "\nHandle '$pattern'";
$full_pattern = $base_url.$pattern;
foreach ($exts_to_remove as $ext){
$test_str = substr($full_pattern,$len=-strlen($ext));
//echo "\n Test Str: $test_str";
if ($test_str==$ext){
$full_pattern = substr($full_pattern, 0, $len).'/';
if (substr($full_pattern,-7)=='/index/'){
$full_pattern = substr($full_pattern,0,-6);
}
//echo "\n Final Pattern: ".$full_pattern;
break;
}
}
//echo "\nAdd Pattern: $full_pattern";
//echo "\n File: $file";
$this->addRoute($full_pattern,$directory.$file, $package);
}
}
//
// utility Functions
//
/**
*
* Convert a pattern into a decoded array of information about that pattern
*
* The patterns apply both for the `public` dir and by adding routes via `$lia->addRoute()`. The file extension (for .php) is removed prior to calling decode_pattern()
* The delimiters can be changed globally by setting $router->varDelims
*
* ## Examples:
* - /blog/{category}/{post} is valid for url /blog/black-lives/matter
* - /blog/{category}.{post}/ is valid for url /blog/environment.zero-waste/
* - /blog/{category}{post}/ is valid for url /blog/{category}{post}/ and has NO dynamic paramaters
* - /blog/{category}/@GET.{post}/ is valid for GET /blog/kindness/is-awesome/ but not for POST request
* - /@POST.dir/sub/@GET.file/ is valid for both POST /dir/sub/file/ and GET /dir/sub/file/
*
* ## Methods: @POST, @GET, @PUT, @DELETE, @OPTIONS, @TRACE, @HEAD, @CONNECT
* - We do not currently check the name of the method, just @ABCDEF for length 3-7
* - These must appear after a `/` or after another '@METHOD.' or they will be taken literally
* - lower case is not valid
* - Each method MUST be followed by a period (.)
*
* ## Paramaters:
* - NEW: Dynamic portions may be separated by by (-) and/or (:)
* - {under_scoreCamel} specifies a named, dynamic paramater
* - {param} must be surrounded by path delimiters (/) OR periods (.) which will be literal characters in the url
* - {param} MAY be at the end of a pattern with no trailing delimiter
*
* ## TODO
* - {paramName:regex} would specify a dynamic portion of a url that MUST match the given regex.
* - Not currently implemented
* - {?param} would specify a paramater that is optional
* - Not currently implemented
*
* @export(Router.PatternRules)
*/
public function decode_pattern($pattern){
$dlm = $this->varDelim;
$params = [];
$optionalParams = [];
$replace =
function($matches) use (&$params, &$optionalParams){
if ($matches[1]=='?'){
$params[] = $matches[2];
$optionalParams[] = $matches[2];
return '#';
}
$params[] = $matches[2];
return '?';
};
$pieces = explode('/',$pattern);
$methods = [];
$testUrl = '';
$extraTestUrl = '';
foreach ($pieces as $piece){
$startPiece = $piece;
// extract @METHODS.
while (preg_match('/^\@([A-Z]{3,7})\./',$piece,$methodMatches)){
$method = $methodMatches[1];
$len = strlen($method);
$piece = substr($piece,2+$len);
$methods[$methodMatches[1]] = $methodMatches[1];
}
while ($piece!=($piece = preg_replace_callback('/(?<=^|['.$dlm.'])\{(\??)([a-zA-Z\_]+)\}(?=['.$dlm.']|$)/',$replace,$piece))){
}
if ($piece=='#'&&$startPiece!=$piece){
$extraTestUrl .= '';// don't add anything.
$piece = '?';
} else {
$extraTestUrl .= '/'.$piece;
}
$testUrl .= '/'.$piece;
}
$testUrl = str_replace(['///','//'],'/',$testUrl);
$extraTestUrl = str_replace(['///','//'],'/',$extraTestUrl);
$decoded = [
'pattern'=>$pattern,
'parsedPattern'=>$testUrl,
'params'=>$params,
'methods'=>count($methods)>0 ? $methods : ['GET'=>'GET'],
];
if ($testUrl!=$extraTestUrl){
$decoded['extraParsedPattern']=$extraTestUrl;
$decoded['optionalParams'] = $optionalParams;
}
return $decoded;
}
/**
* Given a url and array of paramaters,
*
* @param $decoded_pattern expects the array generated by decode_pattern(/url/pattern/)
* @param $url
*
* @return an array of paramaters expected by $decoded_pattern and found in $url
*/
public function extract_url_paramaters($decoded_pattern, $url){
$phPattern = $decoded_pattern['parsedPattern'];
$staticPieces = explode('?',$phPattern);
$staticPieces = array_map(function($piece){return preg_quote($piece,'/');}, $staticPieces);
$dlm = $this->varDelim;
$reg = "([^{$dlm}].*)";
$asReg = '/'.implode($reg, $staticPieces).'/';
preg_match($asReg,$url,$matches);
$params = [];
$i=1;
foreach ($decoded_pattern['params'] as $name){
$params[$name] = $matches[$i++] ?? null;
if ($params[$name]==null){
echo "\n\nInternal Error. Please report a bug on https://github.com/Taeluf/Liaison/issues with the following:\n\n";
echo "url: {$url}\nParsed Pattern:\n";
print_r($decoded_pattern);
echo "\n\n";
throw new \Lia\Exception("Internal error. We were unable to perform routing for '{$url}'. ");
}
}
return $params;
}
/**
* convert an actual url into regex that can be used to match the test regs.
*
* @example `/one/two/` becomes `^\/(?:one|\?)\/(?:two|\?)\/$`
*/
public function url_to_regex($url){
$url = str_replace('+', ' ',$url);
$dlm = $this->varDelim;
$pieces = preg_split('/['.$dlm.']/',$url);
array_shift($pieces);
$last = array_pop($pieces);
if ($last!='')$pieces[] = $last;
$reg = '';
$pos = 0;
$test = '';
foreach ($pieces as $p){
$len = strlen($p)+1;
$startDelim = substr($url,$pos,1);
if ($p==''){
$test .= '\\'.$startDelim;
$pos += $len;
continue;
}
$pEsc = preg_quote($p,'/');
$pReg = '\\'.$startDelim.'(?:'.$pEsc.'|\?)';
$pos += $len;
$test .= $pReg;
}
$finalDelim = substr($url,$pos);
$test .= $finalDelim ? '\\'.$finalDelim : '';
$test = '^'.$test.'$';
return $test;
}
/**
* Get a url from a parsed pattern & array of values to fill
*
* @param $decoded see decode_pattern()
* @param $withValues a `key=>value` array
* @return the url with the paramaters inserted
*/
public function decoded_pattern_to_url(array $decoded, array $withValues): string{
$sorted = [];
foreach ($decoded['params'] as $index=>$param){
$sorted[$index] = $withValues[$param];
}
$filledPattern = $decoded['parsedPattern'];
$val = reset($sorted);
while($pos = strpos($filledPattern,'?')){
$filledPattern = substr($filledPattern,0,$pos)
.$val
.substr($filledPattern,$pos+1);
$val = next($sorted);
}
return $filledPattern;
}
}