Router.php

<?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;
    }



}