Router.php

<?php

namespace Lia\Compo;

class Router extends \Lia\Compo {

    
    protected $varDelim = '\\.\\/';
    protected $routeMap = [];
/**
 *
 * Documentation
 * - Document the paramaters that are passed to a public file
 * - document examples of routing
 * - improve 'Pattern Rules' documentation
 * 
 * Essentials
 * - figure out sitemaps... Though that may not be part of router??
 * - add... something to help convert pattern-based routes to sitemap representations
 *
 * Performance
 * - caching of urls & routes
 *
 * Other
 * - auto-add `public` dir for each package
 *    - Right now, the package handles this. Should the Router be able to process a directory? (probably)
 * - add a function to convert a file path to a pattern (aka, remove extensions like '.md' and '.php', per a configuration)
 * - add a normalizeUrl function (add trailing slash, remove .php / .html / .md, all lowercase?)
 * - Option to return both perfect-match routes AND routes that match with the normalized url, aka: /some/url && /some/url/ would return the same route(s)
 * - add a mediator function to handle multiple global routers, possibly??
 * 
 * @export(TODO.Router)
 */


//
// Things i need to DO
//
    // protected function normalizeUrl($url){
    //     $ext = pathinfo($url,PATHINFO_EXTENSION);
    //     $hiddenExts = ['php'];

    //     if ($removeDoubleSlash=true){
    //         while (strpos($url,'//')!==FALSE)
    //             $url = str_replace(['////','///','//'],'/',$url);
    //     }
    //     if ($toLowerCase=true){
    //         $url = strtolower($url);
    //     }
    //     foreach ($hiddenExts as $hiddenExt){
    //         $hiddenExt = '.'.$hiddenExt;
    //         $hideIt = true;
    //         if ($hideIt
    //         &&strtolower(substr($url,-(strlen($hiddenExt))))==strtolower($hiddenExt)){
    //             $pos = strrpos($url,$hiddenExt);
    //             $remainder = substr($url,$pos+strlen($hiddenExt));
    //             $url = substr($url,0,$pos).$remainder;
    //             $ext = '';
    //         }
    //     }
    //     if ($forceTrailingSlash=true
    //         &&substr($url,-1)!=='/'){
    //         $url .= '/';
    //     }
    //     if ($removeTrailingSlashIfExt=true
    //     &&$ext!=''){
    //         while (substr($url,-1)==='/')
    //             $url = substr($url,0,-1);
                
    //     }

    //     return $url;
    // }

//
// APIs
//
    public function apiPrefix_lia_route_routePattern_deriveRouteUrls($key, $callable){
        $patterns = $callable(false);
        // print_r($patterns);
        foreach ($patterns as $pattern){
            $this->lia->api('lia.route', 'add', $pattern, $callable);
        }
    }
    /**
     * Call `$lia->addRoute($pattern, $callbackOrFile,$package=null)` to add a route to the built-in router
     * 
     * ```
     * @param $pattern A pattern. See [the pattern matching rules](@link(Rules.Router.pattern))
     * @param $callbackOrFile a callback or a file path
     * @param $package (optional) A liaison package
     * ```
     * @export(Usage.Router.addRoute)
     * @TODO make addcallback & addfile separate functions, probably
     */
    public function apiAdd_lia_route_addRoute($pattern, $callbackOrFile,$package=null){
        $initialParsed = $this->parsePattern($pattern);
        $list = [$initialParsed];
        if (($initialParsed['extraParsedPattern']??false)){
            $extraParsed = $initialParsed;
            $extraParsed['parsedPattern'] = $extraParsed['extraParsedPattern'];
            $params = $extraParsed['params'];
            foreach ($extraParsed['optionalParams'] as $p){
                $index = array_search($p, $params);
                unset($params[$index]);
            }
            
            $params = array_values($params);
            $extraParsed['params'] = $params;
            
            $list[] = $extraParsed;
        }
        foreach ($list as $parsed){
            $parsed['target'] = $callbackOrFile;
            $parsed['package'] = $package;
            $testPattern = $parsed['parsedPattern'];
            $matches = [];
            foreach ($parsed['methods'] as $m){
                $this->routeMap[$m][$testPattern][] = $parsed;
            }            
        }
    }
    /**
     * 
     * @export(Usage.Router.getRoute)
     */
    public function apiGet_lia_route_route(\Lia\Obj\Request $request){
        $url = $request->url();
        $method = $request->method();
        
        $testReg = $this->urlToTestReg($url);
        $all = array_filter($this->routeMap[$method]??[],
            function($routeList,$parsedPattern) use ($testReg) {
                if (preg_match('/'.$testReg.'/',$parsedPattern))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->mapParamaters($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;
        
    }


//
// utility Functions
//
    /**
    * ```
    * rules:
    *     .php will generally be removed & replaced with a trailing slash, but that is NOT part of parsePattern()
    *     That will be a pattern-normalization step that happens prior to parsePattern() and is extensible/configurable
    * 
    *     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 (.)
    *         - example: /@POST.dir/sub/@GET.file/ is valid for both POST /dir/sub/file/ and GET /dir/sub/file 
    * 
    *   Paramaters:
    *       - {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
    *       - {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
    *     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
    * ```
    * @export(Rules.Router.pattern)
    */
    public function parsePattern($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);
        
        $parsed = [
            'pattern'=>$pattern,
            'parsedPattern'=>$testUrl,
            'params'=>$params,
            'methods'=>count($methods)>0 ? $methods : ['GET'=>'GET'],
        ];
        
        if ($testUrl!=$extraTestUrl){
            $parsed['extraParsedPattern']=$extraTestUrl;
            $parsed['optionalParams'] = $optionalParams;
        }
        return $parsed;
    }

    /**
     * @param $parsedPattern expects the array generated by parsePattern(/url/pattern/)
     */
    public function mapParamaters($parsedPattern, $url){
        $phPattern = $parsedPattern['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 ($parsedPattern['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($parsedPattern);
                echo "\n\n";
                throw new \Lia\Exception\Base("Internal error. We were unable to perform routing for '{$url}'. ");
            }
        }

        return $params;
    }

    public function urlToTestReg($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;
    }
    





}