Router.php

<?php

namespace Lia\Http;

/**
 * HTTP router. 
 * You configure it, add routes to it, then get routes from it, for a given request url & request method.
 */
class Router {

    /**
     * A string of characters that can be used as delimiters for dynamic url portions. 
     * Affects all routes on this router.
     * This string will be passed through `preg_quote($varDelim, '/')` before being used in a regex pattern.
     */
    public string $varDelim = '/';
     //public $varDelim = './:';

    /**
     * raw, html_page, or a theme_name for your default theme.
     * @see Lia\Http\Response
     */
    public string $theme_name = 'raw';

    /**
     * @structure `['GET'=>['/test/?/pattern/'=>$decoded_pattern_array] ...]` 
     */
    public array $routeMap = [];

    /**
     * Array of extensions to hide when generating routes from files. Defaults to php only.
     *
     * array<int index, string extension> - extension should be lowercase text only (no period!)
     */
    public array $hidden_extensions = ['php'];

    /**
     * Array of index file names to hide when generating routes from files. Defaults to 'index.php' only.
     * array<int index, string index_file_name> 
     */
    public array $index_names = ['index.php'];

    /**
     * true to add a trailing slash to route patterns that do NOT have an extension. false to disable.
     */
    public bool $add_trailing_slash = true;

    /**
     * array<string arg_name, mixed value> Arguments to pass to every route. Each arg will be a defined `$arg_name`
     */
    public array $route_args = [];


    /**
     * Set true to allow executable files to handle routes.
     */
    public bool $allowExecutableFile = false;

    /**
     * Standardize URL Path, replacing: 
     * - space with '+'
     * - quote with '%27'
     * - multiple slashes with a single slash
     *
     * @param $dirty_url string url like `/some url///'with quotes'//`
     * @return string standardized url like `/some_url/%27with+quotes%27/`
     *
     * @example `/some url///'with quotes'//` returns `/some+url/%27with+quotes%27/`
     */
    public function clean_url(string $dirty_url): string {
        $dirty_url = str_replace(' ', '+', $dirty_url);
        $dirty_url = str_replace('\'', '%27', $dirty_url);
        $dirty_url = str_replace(['///','//'],'/',$dirty_url);
        return $dirty_url;
    }

    /**
     * Check if static route exists. Does not check dynamic routes.
     *
     * @param $path string url path
     * @param $method string HTTP method like 'GET' or 'POST'
     * @return bool true if static route exists, false otherwise
     */
    public function has_static_route(string $path, string $method="GET"):bool {
        return isset($this->routeMap[$method][$path]);
    }

    /**
     * Add a route to each file in $dir. Handles static and php files. 
     *
     * NOTICE: This Http Router returns a list of routes & does not control how the calling code includes the file, or how it passes the $with_args.
     *
     * @param $dir string directory to search within
     * @param $base_url string url prefix
     * @param $with_args array of args to pass to each php file route. 
     *
     * @return void
     */
    public function addDirectoryRoutes(string $dir, string $base_url, array $with_args=[]): void {

        $patterns = $this->patterns_from_dir($dir);
        foreach ($patterns as $file=>$pattern){
            $full_pattern = str_replace('//','/',$base_url.$pattern);

            $this->addRoute($full_pattern,$dir.'/'.$file,['GET'], $with_args);
        }

    }


    /**
     * Add a route. 
     *
     * NOTICE: This Http Router returns a list of routes & does not control how the calling code calls the target, or how it passes the $args.
     * 
     * @param $pattern string like `/blog/{category}/{post}/`
     * @param $target mixed callable or string file path
     * @param $methods array of HTTP methods this route allows.
     * @param $args array of args to pass to the callable or file.
     *
     * @return void
     */
    public function addRoute(string $pattern, mixed $target, array $methods=["GET"],array $args = []): void {
        $initialParsed = $this->decode_pattern($pattern);
        $list = $this->separate_optional_from_decoded_pattern($initialParsed);

        $base_route = [
            'target'=>$target,
            'args'=>$args,
        ];
        
        foreach ($list as $decoded_route){
            $route = array_merge($decoded_route, $base_route);
            $testPattern = $route['parsedPattern'];
            $matches = [];
            foreach ($route['methods'] as $m){
                $this->routeMap[$m][$testPattern][] = $route;
            }            
        }
    }

    /**
     *  Get array of routes for the request.
     *
     *  @param $url string url path. 
     *  @param $http_method string (optional) 'GET' or 'POST'. Default: GET
     *  @return array<int index, \Lia\Http\Route $route> Array of routes matching the request
     */
    public function getRoutes(string $url, string $http_method = "GET"): array {
        $testReg = $this->url_to_regex($url);
        $all = array_filter($this->routeMap[$http_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){
            $route = new \Lia\Http\Route();
            $route->url = $url;
            $route->method = $http_method;

            $url_paramaters = $this->extract_url_paramaters($routeInfo, $url);
            foreach ($routeInfo['optionalParams']??[] as $param_name){
                if (!isset($url_paramaters[$param_name]))$url_paramaters[$param_name] = null;
            }
            $route->paramaters = $url_paramaters;

            $route->allowedMethods = $routeInfo['methods'];
            $route->originalPattern = $routeInfo['pattern'];
            $route->testPattern = $routeInfo['parsedPattern'];
            $route->regexPattern = $testReg;
            $route->target = $routeInfo['target'];
            $route->args = array_merge($this->route_args,$routeInfo['args']);
            $routeList[] = $route;
        }
        return $routeList;
    }


    /**
     * Get an array of route patterns from files in a directory.
     *
     * @param string $dir the directory to scan.
     * @param string $prefix to add to all the route patterns. 
     *
     * @return array<string $rel_file_name, string $url_pattern> file=>pattern array.
     */
    public function patterns_from_dir(string $dir, string $prefix=''): array {
        $files = \Lia\Utility\Files::all($dir,$dir);
        $patterns = [];
        foreach ($files as $f){
            $patterns[$f] = $prefix.$this->pattern_from_file($f);
        }

        return $patterns;
    }

    /**
     * Get a route pattern from a relative file path.
     *
     * 'php' extension is removed. Other extensions are unchanged.
     *
     * @param $relFile string relative file path
     * @return string route pattern
     */
    public function pattern_from_file(string $relFile): string {
        $pattern = $relFile;

        if (in_array($base=basename($relFile),$this->index_names)){
            $pattern = substr($pattern,0,-strlen($base));
            $ext = '';
        }

        $ext = strtolower(pathinfo($pattern,PATHINFO_EXTENSION));
        if (in_array($ext,$this->hidden_extensions)){
            $pattern = substr($pattern,0,-(strlen($ext)+1));
            $ext = '';
        }


        if ($ext=='' && $this->add_trailing_slash
            &&substr($pattern,-1)!='/'
        ){
            $pattern .= '/';
        }
        
        return $pattern;
    }


    /**
     * Facilitates optional paramaters
     *
     * Processes a parsed pattern into an array of valid parsed patterns where the original pattern may contain details for optional paramaters
     *
     * @param $original_parsed array (idk how this works, sorry)
     * @return array of valid patterns (including the original)
     */
    protected function separate_optional_from_decoded_pattern(array $original_parsed): array {
        $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;
    }

    /**
     *
     * Convert a pattern into a decoded array of information about that pattern
     *
     * ## Examples: 
     * - /blog/{category}/{post}/ is valid for urls like `/blog/animals/cute-cats/`
     * - /blog/{category}+{post}/ has no dynamic paramaters because the default delimiter is a `/`.
     * - /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 as characters in the pattern.
     * - lower case is not valid
     * - Each method MUST be followed by a period (.)
     * 
     * ## Paramaters:
     * - Set `varDelim` on the router to change how named paramaters are separated.
     * - Default `varDelim` is `/`. It is a string list of characters.
     * - {Param_Names} can include a-z, A-Z, and underscores (_).
     * - {param} MAY be at the end of a pattern with no trailing delimiter, like `/blog/{slug}`
     * - {?optional_paramaters} are supported, but shouldn't be used. They're confusing to use & may be removed in a future update.
     *
     * ## TODO
     * - TODO {paramName:regex} to specify a named paramater that must match a specific regex.
     *
     * @export(Router.PatternRules)
     * @param $pattern string route pattern.
     * @return array information about the pattern ... idk how it works. Sorry.
     */
    protected function decode_pattern(string $pattern): array {

        //$dlm = $this->varDelim;
        $dlm = preg_quote($this->varDelim,'/');
        //$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 = '';
        //echo "\n\n";
        //echo "Decode patter '$pattern' with delim '$dlm' in class ".get_class($this).'#'.spl_object_id($this);
        //echo "\n\n";
        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];
            } 
            //echo "\n\n\n".'/(?<=^|['.$dlm.'])\{(\??)([a-zA-Z\_]+)\}(?=['.$dlm.']|$)/'."\n\n";
            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;
    }

    /**
     * Get an array of named paramaters from a url.
     *
     * @param $decoded_pattern array as generated by decode_pattern(/url/pattern/)
     * @param $url string actual request url.
     *
     * @return array of paramaters expected by $decoded_pattern and found in $url
     */
    protected function extract_url_paramaters(array $decoded_pattern, string $url): array {

        $phPattern = $decoded_pattern['parsedPattern'];
        $staticPieces = explode('?',$phPattern);
        $staticPieces = array_map(function($piece){return preg_quote($piece,'/');}, $staticPieces);
        //$dlm = $this->varDelim;
        $dlm = preg_quote($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 \Exception("Internal error. We were unable to perform routing for '{$url}'. ");
            }
        }

        return $params;
    }

    /**
     * Convert an actual url into regex to match against testPatterns.
     *
     * @example `/one/two/` becomes `^\/(?:one|\?)\/(?:two|\?)\/$`
     * @param $url string url path
     * @return string regex to match against testPatterns.
     */
    protected function url_to_regex(string $url): string {
        $url = str_replace('+', ' ',$url);
        $dlm = preg_quote($this->varDelim,'/');
        //$dlm = $this->varDelim;
        //var_dump($dlm);
        $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;
    }

    /** 
     * Generate a url from a parsed pattern & array of values to fill it.
     *
     * @param $decoded array see decode_pattern()
     * @param $withValues array a `key=>value` array
     * @return the url with the paramaters inserted
     */ 
    protected 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;
    }
}