diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 314b8a4..8571406 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,13 +10,28 @@ on: workflow_dispatch: jobs: - tests: - runs-on: ubuntu-20.04 + cs-fix: + name: CS Fixer + runs-on: ubuntu-22.04 - strategy: - fail-fast: true - matrix: - php-versions: ['7.3', '7.4'] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + + - name: Install dependencies + run: composer install + + - name: Run CS Fixer + run: composer run-script cs + + phpstan: + name: PHPStan + runs-on: ubuntu-22.04 steps: - name: Checkout @@ -25,16 +40,17 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.php-versions }} + php-version: '8.1' - name: Install dependencies - run: composer install --optimize-autoloader --prefer-dist + run: composer install - - name: Run tests - run: composer run-script test + - name: Run CS Fixer + run: composer run-script phpstan - tests-php8: - runs-on: ubuntu-20.04 + tests: + name: PHPUnit on PHP ${{ matrix.php-versions }} + runs-on: ubuntu-22.04 strategy: fail-fast: true @@ -51,7 +67,7 @@ jobs: php-version: ${{ matrix.php-versions }} - name: Install dependencies - run: composer require -W phpunit/phpunit + run: composer install - name: Run tests run: composer run-script test diff --git a/.gitignore b/.gitignore index e5294f4..0943256 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/.idea vendor composer.lock .DS_Store diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..cb2af38 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,19 @@ +setParallelConfig(ParallelConfigFactory::detect()) + ->setRiskyAllowed(true) + ->setUsingCache(false) + ->setRules([ + '@PER-CS2.0' => true, + ]) + ->setFinder( + (new Finder()) + ->in([__DIR__ . '/src', __DIR__ . '/tests']) + ); \ No newline at end of file diff --git a/composer.json b/composer.json index cec985a..0dbfd2e 100644 --- a/composer.json +++ b/composer.json @@ -11,10 +11,12 @@ } ], "require": { - "php": ">=5.3.0" + "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "~4.8.36" + "phpunit/phpunit": "~10.0", + "phpstan/phpstan": "^1.12", + "friendsofphp/php-cs-fixer": "^3.63" }, "autoload": { "psr-4": { @@ -22,6 +24,9 @@ } }, "scripts": { - "test": "vendor/bin/phpunit test/" + "test": "vendor/bin/phpunit", + "cs": "vendor/bin/php-cs-fixer fix --dry-run", + "cs-fix": "vendor/bin/php-cs-fixer fix", + "phpstan": "vendor/bin/phpstan" } } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..a76a832 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 5 + paths: + - src + - tests \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..530f84f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,22 @@ + + + + + tests + + + + + + src + + + diff --git a/src/Rize/UriTemplate.php b/src/Rize/UriTemplate.php index 330f39b..51a841d 100644 --- a/src/Rize/UriTemplate.php +++ b/src/Rize/UriTemplate.php @@ -5,47 +5,38 @@ use Rize\UriTemplate\Parser; /** - * URI Template + * URI Template. */ class UriTemplate { - /** - * @var Parser - */ - protected $parser, - $parsed = array(), - $base_uri, - $params = array(); - - public function __construct($base_uri = '', $params = array(), Parser $parser = null) + protected Parser $parser; + protected array $parsed = []; + + public function __construct(protected string $base_uri = '', protected array $params = [], ?Parser $parser = null) { - $this->base_uri = $base_uri; - $this->params = $params; - $this->parser = $parser ?: $this->createNodeParser(); + $this->parser = $parser ?: $this->createNodeParser(); } /** - * Expands URI Template + * Expands URI Template. * - * @param string $uri URI Template - * @param array $params URI Template's parameters - * @return string + * @param mixed $params */ - public function expand($uri, $params = array()) + public function expand(string $uri, $params = []): string { $params += $this->params; - $uri = $this->base_uri.$uri; - $result = array(); + $uri = $this->base_uri . $uri; + $result = []; // quick check - if (($start = strpos($uri, '{')) === false) { + if (!str_contains($uri, '{')) { return $uri; } $parser = $this->parser; $nodes = $parser->parse($uri); - foreach($nodes as $node) { + foreach ($nodes as $node) { $result[] = $node->expand($parser, $params); } @@ -53,48 +44,48 @@ public function expand($uri, $params = array()) } /** - * Extracts variables from URI + * Extracts variables from URI. * - * @param string $template - * @param string $uri - * @param bool $strict This will perform a full match * @return null|array params or null if not match and $strict is true */ - public function extract($template, $uri, $strict = false) + public function extract(string $template, string $uri, bool $strict = false): ?array { - $params = array(); + $params = []; $nodes = $this->parser->parse($template); - # PHP 8.1.0RC4-dev still throws deprecation warning for `strlen`. - # $uri = (string) $uri; - - foreach($nodes as $node) { + // PHP 8.1.0RC4-dev still throws deprecation warning for `strlen`. + // $uri = (string) $uri; + foreach ($nodes as $node) { // if strict is given, and there's no remaining uri just return null - if ($strict && !strlen((string) $uri)) { + if ($strict && (string) $uri === '') { return null; } - // uri'll be truncated from the start when a match is found + // URI will be truncated from the start when a match is found $match = $node->match($this->parser, $uri, $params, $strict); - list($uri, $params) = $match; + if ($match === null) { + return null; + } + + [$uri, $params] = $match; } // if there's remaining $uri, matching is failed - if ($strict && strlen((string) $uri)) { + if ($strict && (string) $uri !== '') { return null; } return $params; } - public function getParser() + public function getParser(): Parser { return $this->parser; } - protected function createNodeParser() + protected function createNodeParser(): Parser { static $parser; @@ -102,6 +93,6 @@ protected function createNodeParser() return $parser; } - return $parser = new Parser; + return $parser = new Parser(); } } diff --git a/src/Rize/UriTemplate/Node/Abstraction.php b/src/Rize/UriTemplate/Node/Abstraction.php index 3f2e350..7332df5 100644 --- a/src/Rize/UriTemplate/Node/Abstraction.php +++ b/src/Rize/UriTemplate/Node/Abstraction.php @@ -5,61 +5,45 @@ use Rize\UriTemplate\Parser; /** - * Base class for all Nodes + * Base class for all Nodes. */ abstract class Abstraction { - /** - * @var string - */ - private $token; - - public function __construct($token) - { - $this->token = $token; - } + public function __construct(private readonly string $token) {} /** * Expands URI template * - * @param Parser $parser - * @param array $params - * @return null|string + * @param array $params */ - public function expand(Parser $parser, array $params = array()) + public function expand(Parser $parser, array $params = []): ?string { return $this->token; } /** - * Matches given URI against current node + * Matches given URI against current node. + * + * @param array $params * - * @param Parser $parser - * @param string $uri - * @param array $params - * @param bool $strict - * @return null|array `uri and params` or `null` if not match and $strict is true + * @return null|array{0: string, 1: array} `uri and params` or `null` if not match and $strict is true */ - public function match(Parser $parser, $uri, $params = array(), $strict = false) + public function match(Parser $parser, string $uri, array $params = [], bool $strict = false): ?array { // match literal string from start to end - $length = strlen($this->token); - if (substr($uri, 0, $length) === $this->token) { - $uri = substr($uri, $length); + if (str_starts_with($uri, $this->token)) { + $uri = substr($uri, strlen($this->token)); } // when there's no match, just return null if strict mode is given - else if ($strict) { + elseif ($strict) { return null; } - return array($uri, $params); + return [$uri, $params]; } - /** - * @return string - */ - public function getToken() + public function getToken(): string { return $this->token; } diff --git a/src/Rize/UriTemplate/Node/Expression.php b/src/Rize/UriTemplate/Node/Expression.php index 892ccc6..a31fa24 100644 --- a/src/Rize/UriTemplate/Node/Expression.php +++ b/src/Rize/UriTemplate/Node/Expression.php @@ -2,83 +2,53 @@ namespace Rize\UriTemplate\Node; -use Rize\UriTemplate\Parser; use Rize\UriTemplate\Operator; +use Rize\UriTemplate\Parser; -/** - * Description - */ class Expression extends Abstraction { /** - * @var Operator\Abstraction - */ - private $operator; - - /** - * @var array + * @param string $forwardLookupSeparator */ - private $variables = array(); - - /** - * Whether to do a forward lookup for a given separator - * @var string + public function __construct(string $token, private readonly Operator\Abstraction $operator, private readonly ?array $variables = null, /** + * Whether to do a forward lookup for a given separator. */ - private $forwardLookupSeparator; - - public function __construct($token, Operator\Abstraction $operator, array $variables = null, $forwardLookupSeparator = null) + private $forwardLookupSeparator = null) { parent::__construct($token); - $this->operator = $operator; - $this->variables = $variables; - $this->forwardLookupSeparator = $forwardLookupSeparator; } - /** - * @return Operator\Abstraction - */ - public function getOperator() + public function getOperator(): Operator\Abstraction { return $this->operator; } - /** - * @return array - */ - public function getVariables() + public function getVariables(): ?array { return $this->variables; } - /** - * @return string - */ - public function getForwardLookupSeparator() + public function getForwardLookupSeparator(): string { return $this->forwardLookupSeparator; } - /** - * @param string $forwardLookupSeparator - */ - public function setForwardLookupSeparator($forwardLookupSeparator) + public function setForwardLookupSeparator(string $forwardLookupSeparator): void { $this->forwardLookupSeparator = $forwardLookupSeparator; } - /** - * @param Parser $parser - * @param array $params - * @return null|string - */ - public function expand(Parser $parser, array $params = array()) + public function expand(Parser $parser, array $params = []): ?string { - $data = array(); - $op = $this->operator; + $data = []; + $op = $this->operator; - // check for variable modifiers - foreach($this->variables as $var) { + if ($this->variables === null) { + return $op->first; + } + // check for variable modifiers + foreach ($this->variables as $var) { $val = $op->expand($parser, $var, $params); // skip null value @@ -87,25 +57,21 @@ public function expand(Parser $parser, array $params = array()) } } - return $data ? $op->first.implode($op->sep, $data) : null; + return $data ? $op->first . implode($op->sep, $data) : null; } /** - * Matches given URI against current node + * Matches given URI against current node. * - * @param Parser $parser - * @param string $uri - * @param array $params - * @param bool $strict * @return null|array `uri and params` or `null` if not match and $strict is true */ - public function match(Parser $parser, $uri, $params = array(), $strict = false) + public function match(Parser $parser, string $uri, array $params = [], bool $strict = false): ?array { $op = $this->operator; // check expression operator first if ($op->id && isset($uri[0]) && $uri[0] !== $op->id) { - return array($uri, $params); + return [$uri, $params]; } // remove operator from input @@ -113,9 +79,8 @@ public function match(Parser $parser, $uri, $params = array(), $strict = false) $uri = substr($uri, 1); } - foreach($this->sortVariables($this->variables) as $var) { - /** @var \Rize\UriTemplate\Node\Variable $regex */ - $regex = '#'.$op->toRegex($parser, $var).'#'; + foreach ($this->sortVariables($this->variables) as $var) { + $regex = '#' . $op->toRegex($parser, $var) . '#'; $val = null; // do a forward lookup and get just the relevant part @@ -128,14 +93,13 @@ public function match(Parser $parser, $uri, $params = array(), $strict = false) } if (preg_match($regex, $preparedUri, $match)) { - // remove matched part from input - $preparedUri = preg_replace($regex, '', $preparedUri, $limit = 1); + $preparedUri = preg_replace($regex, '', $preparedUri, 1); $val = $op->extract($parser, $var, $match[0]); } // if strict is given, we quit immediately when there's no match - else if ($strict) { + elseif ($strict) { return null; } @@ -144,21 +108,16 @@ public function match(Parser $parser, $uri, $params = array(), $strict = false) $params[$var->getToken()] = $val; } - return array($uri, $params); + return [$uri, $params]; } /** * Sort variables before extracting data from uri. * We have to sort vars by non-explode to explode. - * - * @param array $vars - * @return array */ - protected function sortVariables(array $vars) + protected function sortVariables(array $vars): array { - usort($vars, function($a, $b) { - return $a->options['modifier'] >= $b->options['modifier'] ? 1 : -1; - }); + usort($vars, static fn($a, $b) => $a->options['modifier'] <=> $b->options['modifier']); return $vars; } diff --git a/src/Rize/UriTemplate/Node/Literal.php b/src/Rize/UriTemplate/Node/Literal.php index 1c54da1..bc939b6 100644 --- a/src/Rize/UriTemplate/Node/Literal.php +++ b/src/Rize/UriTemplate/Node/Literal.php @@ -1,11 +1,5 @@ - null, - 'value' => null, - ); + /** + * Variable name without modifier + * e.g. 'term:1' becomes 'term'. + */ + public string $name; + public array $options = ['modifier' => null, 'value' => null]; - public function __construct($token, array $options = array()) + public function __construct(string $token, array $options = []) { parent::__construct($token); $this->options = $options + $this->options; @@ -25,9 +19,9 @@ public function __construct($token, array $options = array()) // normalize var name e.g. from 'term:1' becomes 'term' $name = $token; if ($options['modifier'] === ':') { - $name = substr($name, 0, strpos($name, $options['modifier'])); + $name = strstr($name, $options['modifier'], true); } $this->name = $name; } -} \ No newline at end of file +} diff --git a/src/Rize/UriTemplate/Operator/Abstraction.php b/src/Rize/UriTemplate/Operator/Abstraction.php index 1b76337..856bd1c 100644 --- a/src/Rize/UriTemplate/Operator/Abstraction.php +++ b/src/Rize/UriTemplate/Operator/Abstraction.php @@ -14,7 +14,7 @@ * | named | false false false false true true true false | * | ifemp | "" "" "" "" "" "=" "=" "" | * | allow | U U+R U U U U U U+R | - * `------------------------------------------------------------------' + * `------------------------------------------------------------------'. * * named = false * | 1 | {/list} /red,green,blue | {$value}*(?:,{$value}+)* @@ -35,7 +35,7 @@ * * RESERVED * -------- - * RFC 1738 ":" | "/" | "?" | | "@" | "!" | "$" | "&" | "'" | "(" | ")" | "*" | "+" | "," | ";" | "=" | "-" | "_" | "." | + * RFC 1738 ":" | "/" | "?" | | "@" | "!" | "$" | "&" | "'" | "(" | ")" | "*" | "+" | "," | ";" | "=" | "-" | "_" | "." | * RFC 3986 ":" | "/" | "?" | "#" | "[" | "]" | "@" | "!" | "$" | "&" | "'" | "(" | ")" | "*" | "+" | "," | ";" | "=" * RFC 6570 ":" | "/" | "?" | "#" | "[" | "]" | "@" | "!" | "$" | "&" | "'" | "(" | ")" | "*" | "+" | "," | ";" | "=" * @@ -47,108 +47,109 @@ abstract class Abstraction * start - Variable offset position, level-2 operators start at 1 * (exclude operator itself, e.g. {?query}) * first - If variables found, prepend this value to it - * named - Whether or not the expansion includes the variable or key name - * reserved - union of (unreserved / reserved / pct-encoded) + * named - Whether the expansion includes the variable or key name + * reserved - union of (unreserved / reserved / pct-encoded). */ - public $id, - $named, - $sep, - $empty, - $reserved, - $start, - $first; - - protected static $types = array( - '' => array( - 'sep' => ',', - 'named' => false, - 'empty' => '', - 'reserved' => false, - 'start' => 0, - 'first' => null, - ), - '+' => array( - 'sep' => ',', - 'named' => false, - 'empty' => '', - 'reserved' => true, - 'start' => 1, - 'first' => null, - ), - '.' => array( - 'sep' => '.', - 'named' => false, - 'empty' => '', - 'reserved' => false, - 'start' => 1, - 'first' => '.', - ), - '/' => array( - 'sep' => '/', - 'named' => false, - 'empty' => '', - 'reserved' => false, - 'start' => 1, - 'first' => '/', - ), - ';' => array( - 'sep' => ';', - 'named' => true, - 'empty' => '', - 'reserved' => false, - 'start' => 1, - 'first' => ';', - ), - '?' => array( - 'sep' => '&', - 'named' => true, - 'empty' => '=', - 'reserved' => false, - 'start' => 1, - 'first' => '?', - ), - '&' => array( - 'sep' => '&', - 'named' => true, - 'empty' => '=', - 'reserved' => false, - 'start' => 1, - 'first' => '&', - ), - '#' => array( - 'sep' => ',', - 'named' => false, - 'empty' => '', - 'reserved' => true, - 'start' => 1, - 'first' => '#', - ), - ), - $loaded = array(); - - /** - * gen-delims | sub-delims - */ - public static $reserved_chars = array( - '%3A' => ':', - '%2F' => '/', - '%3F' => '?', - '%23' => '#', - '%5B' => '[', - '%5D' => ']', - '%40' => '@', - '%21' => '!', - '%24' => '$', - '%26' => '&', - '%27' => "'", - '%28' => '(', - '%29' => ')', - '%2A' => '*', - '%2B' => '+', - '%2C' => ',', - '%3B' => ';', - '%3D' => '=', - ); + public $id; + public $named; + public $sep; + public $empty; + public $reserved; + public $start; + public $first; + + /** + * gen-delims | sub-delims. + */ + public static $reserved_chars = [ + '%3A' => ':', + '%2F' => '/', + '%3F' => '?', + '%23' => '#', + '%5B' => '[', + '%5D' => ']', + '%40' => '@', + '%21' => '!', + '%24' => '$', + '%26' => '&', + '%27' => "'", + '%28' => '(', + '%29' => ')', + '%2A' => '*', + '%2B' => '+', + '%2C' => ',', + '%3B' => ';', + '%3D' => '=', + ]; + + protected static $types = [ + '' => [ + 'sep' => ',', + 'named' => false, + 'empty' => '', + 'reserved' => false, + 'start' => 0, + 'first' => null, + ], + '+' => [ + 'sep' => ',', + 'named' => false, + 'empty' => '', + 'reserved' => true, + 'start' => 1, + 'first' => null, + ], + '.' => [ + 'sep' => '.', + 'named' => false, + 'empty' => '', + 'reserved' => false, + 'start' => 1, + 'first' => '.', + ], + '/' => [ + 'sep' => '/', + 'named' => false, + 'empty' => '', + 'reserved' => false, + 'start' => 1, + 'first' => '/', + ], + ';' => [ + 'sep' => ';', + 'named' => true, + 'empty' => '', + 'reserved' => false, + 'start' => 1, + 'first' => ';', + ], + '?' => [ + 'sep' => '&', + 'named' => true, + 'empty' => '=', + 'reserved' => false, + 'start' => 1, + 'first' => '?', + ], + '&' => [ + 'sep' => '&', + 'named' => true, + 'empty' => '=', + 'reserved' => false, + 'start' => 1, + 'first' => '&', + ], + '#' => [ + 'sep' => ',', + 'named' => false, + 'empty' => '', + 'reserved' => true, + 'start' => 1, + 'first' => '#', + ], + ]; + + protected static $loaded = []; /** * RFC 3986 Allowed path characters regex except the path delimiter '/'. @@ -166,29 +167,29 @@ abstract class Abstraction public function __construct($id, $named, $sep, $empty, $reserved, $start, $first) { - $this->id = $id; + $this->id = $id; $this->named = $named; - $this->sep = $sep; + $this->sep = $sep; $this->empty = $empty; $this->start = $start; $this->first = $first; $this->reserved = $reserved; } - abstract public function toRegex(Parser $parser, Node\Variable $var); + abstract public function toRegex(Parser $parser, Node\Variable $var): string; - public function expand(Parser $parser, Node\Variable $var, array $params = array()) + public function expand(Parser $parser, Node\Variable $var, array $params = []) { $options = $var->options; $name = $var->name; - $is_explode = in_array($options['modifier'], array('*', '%')); + $is_explode = in_array($options['modifier'], ['*', '%']); // skip null if (!isset($params[$name])) { return null; } - $val = $params[$name]; + $val = $params[$name]; // This algorithm is based on RFC6570 http://tools.ietf.org/html/rfc6570 // non-array, e.g. string @@ -197,38 +198,32 @@ public function expand(Parser $parser, Node\Variable $var, array $params = array } // non-explode ':' - else if (!$is_explode) { + if (!$is_explode) { return $this->expandNonExplode($parser, $var, $val); } // explode '*', '%' - else { - return $this->expandExplode($parser, $var, $val); - } + + return $this->expandExplode($parser, $var, $val); } public function expandString(Parser $parser, Node\Variable $var, $val) { - $val = (string)$val; + $val = (string) $val; $options = $var->options; $result = null; if ($options['modifier'] === ':') { - $val = substr($val, 0, (int)$options['value']); + $val = substr($val, 0, (int) $options['value']); } - return $result.$this->encode($parser, $var, $val); + return $result . $this->encode($parser, $var, $val); } /** - * Non explode modifier ':' - * - * @param Parser $parser - * @param Node\Variable $var - * @param array $val - * @return null|string + * Non explode modifier ':'. */ - public function expandNonExplode(Parser $parser, Node\Variable $var, array $val) + public function expandNonExplode(Parser $parser, Node\Variable $var, array $val): ?string { if (empty($val)) { return null; @@ -238,14 +233,9 @@ public function expandNonExplode(Parser $parser, Node\Variable $var, array $val) } /** - * Explode modifier '*', '%' - * - * @param Parser $parser - * @param Node\Variable $var - * @param array $val - * @return null|string + * Explode modifier '*', '%'. */ - public function expandExplode(Parser $parser, Node\Variable $var, array $val) + public function expandExplode(Parser $parser, Node\Variable $var, array $val): ?string { if (empty($val)) { return null; @@ -255,17 +245,13 @@ public function expandExplode(Parser $parser, Node\Variable $var, array $val) } /** - * Encodes variable according to spec (reserved or unreserved) - * - * @param Parser $parser - * @param Node\Variable $var - * @param mixed $values + * Encodes variable according to spec (reserved or unreserved). * * @return string encoded string */ - public function encode(Parser $parser, Node\Variable $var, $values) + public function encode(Parser $parser, Node\Variable $var, mixed $values) { - $values = (array)$values; + $values = (array) $values; $list = isset($values[0]); $reserved = $this->reserved; $maps = static::$reserved_chars; @@ -277,13 +263,12 @@ public function encode(Parser $parser, Node\Variable $var, $values) $assoc_sep = $sep = ','; } - array_walk($values, function(&$v, $k) use ($assoc_sep, $reserved, $list, $maps) { - + array_walk($values, function (&$v, $k) use ($assoc_sep, $reserved, $list, $maps): void { $encoded = rawurlencode($v); // assoc? encode key too if (!$list) { - $encoded = rawurlencode($k).$assoc_sep.$encoded; + $encoded = rawurlencode($k) . $assoc_sep . $encoded; } // rawurlencode is compliant with 'unreserved' set @@ -293,11 +278,10 @@ public function encode(Parser $parser, Node\Variable $var, $values) // decode chars in reserved set else { - $v = str_replace( array_keys($maps), $maps, - $encoded + $encoded, ); } }); @@ -306,71 +290,59 @@ public function encode(Parser $parser, Node\Variable $var, $values) } /** - * Decodes variable - * - * @param Parser $parser - * @param Node\Variable $var - * @param mixed $values + * Decodes variable. * * @return string decoded string */ - public function decode(Parser $parser, Node\Variable $var, $values) + public function decode(Parser $parser, Node\Variable $var, mixed $values) { $single = !is_array($values); - $values = (array)$values; + $values = (array) $values; - array_walk($values, function(&$v, $k) { + array_walk($values, function (&$v, $k): void { $v = rawurldecode($v); }); return $single ? reset($values) : $values; } - + /** - * Extracts value from variable - * - * @param Parser $parser - * @param Node\Variable $var - * @param string $data - * @return string + * Extracts value from variable. */ - public function extract(Parser $parser, Node\Variable $var, $data) + public function extract(Parser $parser, Node\Variable $var, string $data): array|string { - $value = $data; - $vals = array_filter(explode($this->sep, $data)); + $value = $data; + $vals = array_filter(explode($this->sep, $data)); $options = $var->options; switch ($options['modifier']) { - case '*': - $data = array(); - foreach($vals as $val) { - - if (strpos($val, '=') !== false) { - list($k, $v) = explode('=', $val); - $data[$k] = $v; - } - - else { - $data[] = $val; + $value = []; + foreach ($vals as $val) { + if (str_contains($val, '=')) { + [$k, $v] = explode('=', $val); + $value[$k] = $v; + } else { + $value[] = $val; } } break; + case ':': break; - default: - $data = strpos($data, $this->sep) !== false ? $vals : $value; + default: + $value = str_contains($value, (string) $this->sep) ? $vals : $value; } - return $this->decode($parser, $var, $data); + return $this->decode($parser, $var, $value); } public static function createById($id) { if (!isset(static::$types[$id])) { - throw new \Exception("Invalid operator [$id]"); + throw new \InvalidArgumentException("Invalid operator [{$id}]"); } if (isset(static::$loaded[$id])) { @@ -378,31 +350,24 @@ public static function createById($id) } $op = static::$types[$id]; - $class = __NAMESPACE__.'\\'.($op['named'] ? 'Named' : 'UnNamed'); + $class = __NAMESPACE__ . '\\' . ($op['named'] ? 'Named' : 'UnNamed'); return static::$loaded[$id] = new $class($id, $op['named'], $op['sep'], $op['empty'], $op['reserved'], $op['start'], $op['first']); } - public static function isValid($id) + public static function isValid($id): bool { return isset(static::$types[$id]); } /** - * Returns the correct regex given the variable location in the URI - * - * @return string + * Returns the correct regex given the variable location in the URI. */ - protected function getRegex() + protected function getRegex(): string { - switch ($this->id) { - case '?': - case '&': - case '#': - return self::$queryRegex; - case ';': - default: - return self::$pathRegex; - } + return match ($this->id) { + '?', '&', '#' => self::$queryRegex, + default => self::$pathRegex, + }; } } diff --git a/src/Rize/UriTemplate/Operator/Named.php b/src/Rize/UriTemplate/Operator/Named.php index 708710c..2c0a505 100644 --- a/src/Rize/UriTemplate/Operator/Named.php +++ b/src/Rize/UriTemplate/Operator/Named.php @@ -10,49 +10,51 @@ * | 2 | {?list*} ?list=red&list=green&list=blue | {name}+=(?:{$value}+(?:{sep}{name}+={$value}*))* * | 3 | {?keys} ?keys=semi,%3B,dot,.,comma,%2C | (same as 1) * | 4 | {?keys*} ?semi=%3B&dot=.&comma=%2C | (same as 2) - * | 5 | {?list*} ?list[]=red&list[]=green&list[]=blue | {name[]}+=(?:{$value}+(?:{sep}{name[]}+={$value}*))* + * | 5 | {?list*} ?list[]=red&list[]=green&list[]=blue | {name[]}+=(?:{$value}+(?:{sep}{name[]}+={$value}*))*. */ class Named extends Abstraction { - public function toRegex(Parser $parser, Node\Variable $var) + public function toRegex(Parser $parser, Node\Variable $var): string { - $regex = null; $name = $var->name; $value = $this->getRegex(); $options = $var->options; if ($options['modifier']) { - switch($options['modifier']) { + switch ($options['modifier']) { case '*': // 2 | 4 $regex = "{$name}+=(?:{$value}+(?:{$this->sep}{$name}+={$value}*)*)" . "|{$value}+=(?:{$value}+(?:{$this->sep}{$value}+={$value}*)*)"; + break; + case ':': - $regex = "{$value}\{0,{$options['value']}\}"; + $regex = "{$value}\\{0,{$options['value']}\\}"; + break; case '%': // 5 - $name = $name.'+(?:%5B|\[)[^=]*='; + $name .= '+(?:%5B|\[)[^=]*='; $regex = "{$name}(?:{$value}+(?:{$this->sep}{$name}{$value}*)*)"; + break; + default: - throw new \Exception("Unknown modifier `{$options['modifier']}`"); + throw new \InvalidArgumentException("Unknown modifier `{$options['modifier']}`"); } - } - - else { + } else { // 1, 3 $regex = "{$name}=(?:{$value}+(?:,{$value}+)*)*"; } - return '(?:&)?'.$regex; + return '(?:&)?' . $regex; } - public function expandString(Parser $parser, Node\Variable $var, $val) + public function expandString(Parser $parser, Node\Variable $var, $val): string { - $val = (string)$val; + $val = (string) $val; $options = $var->options; $result = $this->encode($parser, $var, $var->name); @@ -61,61 +63,42 @@ public function expandString(Parser $parser, Node\Variable $var, $val) return $result . $this->empty; } - else { - $result .= '='; - } + $result .= '='; if ($options['modifier'] === ':') { - $val = mb_substr($val, 0, (int)$options['value']); + $val = mb_substr($val, 0, (int) $options['value']); } - return $result.$this->encode($parser, $var, $val); + return $result . $this->encode($parser, $var, $val); } - public function expandNonExplode(Parser $parser, Node\Variable $var, array $val) + public function expandNonExplode(Parser $parser, Node\Variable $var, array $val): ?string { if (empty($val)) { return null; } - $result = $this->encode($parser, $var, $var->name); - - if (empty($val)) { - return $result . $this->empty; - } + $result = $this->encode($parser, $var, $var->name); - else { - $result .= '='; - } + $result .= '='; - return $result.$this->encode($parser, $var, $val); + return $result . $this->encode($parser, $var, $val); } - public function expandExplode(Parser $parser, Node\Variable $var, array $val) + public function expandExplode(Parser $parser, Node\Variable $var, array $val): ?string { if (empty($val)) { return null; } - $result = $this->encode($parser, $var, $var->name); - - // RFC6570 doesn't specify how to handle empty list/assoc array - // for explode modifier - if (empty($val)) { - return $result . $this->empty; - } - - $list = isset($val[0]); - $data = array(); - foreach($val as $k => $v) { - + $list = isset($val[0]); + $data = []; + foreach ($val as $k => $v) { // if value is a list, use `varname` as keyname, otherwise use `key` name $key = $list ? $var->name : $k; if ($list) { $data[$key][] = $v; - } - - else { + } else { $data[$key] = $v; } } @@ -123,14 +106,14 @@ public function expandExplode(Parser $parser, Node\Variable $var, array $val) // if it's array modifier, we have to use variable name as index // e.g. if variable name is 'query' and value is ['limit' => 1] // then we convert it to ['query' => ['limit' => 1]] - if (!$list and $var->options['modifier'] === '%') { - $data = array($var->name => $data); + if (!$list && $var->options['modifier'] === '%') { + $data = [$var->name => $data]; } - return $this->encodeExplodeVars($parser, $var, $data); + return $this->encodeExplodeVars($var, $data); } - public function extract(Parser $parser, Node\Variable $var, $data) + public function extract(Parser $parser, Node\Variable $var, $data): array|string { // get rid of optional `&` at the beginning if ($data[0] === '&') { @@ -143,45 +126,47 @@ public function extract(Parser $parser, Node\Variable $var, $data) switch ($options['modifier']) { case '%': - parse_str($data, $query); + parse_str($value, $query); return $query[$var->name]; case '*': - $data = array(); + $value = []; - foreach($vals as $val) { - list($k, $v) = explode('=', $val); + foreach ($vals as $val) { + [$k, $v] = explode('=', $val); // 2 if ($k === $var->getToken()) { - $data[] = $v; + $value[] = $v; } // 4 else { - $data[$k] = $v; + $value[$k] = $v; } } break; + case ':': break; + default: // 1, 3 // remove key from value e.g. 'lang=en,th' becomes 'en,th' - $value = str_replace($var->getToken().'=', '', $value); - $data = explode(',', $value); + $value = str_replace($var->getToken() . '=', '', $value); + $value = explode(',', $value); - if (sizeof($data) === 1) { - $data = current($data); + if (count($value) === 1) { + $value = current($value); } } - return $this->decode($parser, $var, $data); + return $this->decode($parser, $var, $value); } - public function encodeExplodeVars(Parser $parser, Node\Variable $var, $data) + public function encodeExplodeVars(Node\Variable $var, $data): null|array|string { // http_build_query uses PHP_QUERY_RFC1738 encoding by default // i.e. spaces are encoded as '+' (plus signs) we need to convert @@ -198,7 +183,6 @@ public function encodeExplodeVars(Parser $parser, Node\Variable $var, $data) // `:`, `*` modifiers else { - // by default, http_build_query will convert array values to `a[]=1&a[]=2` // which is different from the spec. It should be `a=1&a=2` $query = preg_replace('#%5B\d+%5D#', '', $query); @@ -206,11 +190,10 @@ public function encodeExplodeVars(Parser $parser, Node\Variable $var, $data) // handle reserved charset if ($this->reserved) { - $query = str_replace( array_keys(static::$reserved_chars), static::$reserved_chars, - $query + $query, ); } diff --git a/src/Rize/UriTemplate/Operator/UnNamed.php b/src/Rize/UriTemplate/Operator/UnNamed.php index 14a49b2..d1e53dd 100644 --- a/src/Rize/UriTemplate/Operator/UnNamed.php +++ b/src/Rize/UriTemplate/Operator/UnNamed.php @@ -9,37 +9,39 @@ * | 1 | {/list} /red,green,blue | {$value}*(?:,{$value}+)* * | 2 | {/list*} /red/green/blue | {$value}+(?:{$sep}{$value}+)* * | 3 | {/keys} /semi,%3B,dot,.,comma,%2C | /(\w+,?)+ - * | 4 | {/keys*} /semi=%3B/dot=./comma=%2C | /(?:\w+=\w+/?)* + * | 4 | {/keys*} /semi=%3B/dot=./comma=%2C | /(?:\w+=\w+/?)*. */ class UnNamed extends Abstraction { - public function toRegex(Parser $parser, Node\Variable $var) + public function toRegex(Parser $parser, Node\Variable $var): string { - $regex = null; $value = $this->getRegex(); $options = $var->options; if ($options['modifier']) { - switch($options['modifier']) { + switch ($options['modifier']) { case '*': // 2 | 4 $regex = "{$value}+(?:{$this->sep}{$value}+)*"; + break; + case ':': - $regex = $value.'{0,'.$options['value'].'}'; + $regex = $value . '{0,' . $options['value'] . '}'; + break; + case '%': - throw new \Exception("% (array) modifier only works with Named type operators e.g. ;,?,&"); + throw new \InvalidArgumentException('% (array) modifier only works with Named type operators e.g. ;,?,&'); + default: - throw new \Exception("Unknown modifier `{$options['modifier']}`"); + throw new \InvalidArgumentException("Unknown modifier `{$options['modifier']}`"); } - } - - else { + } else { // 1, 3 $regex = "{$value}*(?:,{$value}+)*"; } return $regex; } -} \ No newline at end of file +} diff --git a/src/Rize/UriTemplate/Parser.php b/src/Rize/UriTemplate/Parser.php index 10ca382..48e8f17 100644 --- a/src/Rize/UriTemplate/Parser.php +++ b/src/Rize/UriTemplate/Parser.php @@ -2,34 +2,33 @@ namespace Rize\UriTemplate; -use Rize\UriTemplate\Node; +use Rize\UriTemplate\Node\Abstraction; use Rize\UriTemplate\Node\Expression; -use Rize\UriTemplate\Operator; +use Rize\UriTemplate\Node\Variable; use Rize\UriTemplate\Operator\UnNamed; class Parser { - const REGEX_VARNAME = '(?:[A-z0-9_\.]|%[0-9a-fA-F]{2})'; + private const REGEX_VARNAME = '[A-z0-9.]|%[0-9a-fA-F]{2}'; /** - * Parses URI Template and returns nodes + * Parses URI Template and returns nodes. * - * @param string $template * @return Node\Abstraction[] */ - public function parse($template) + public function parse(string $template): array { - $parts = preg_split('#(\{[^\}]+\})#', $template, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); - $nodes = array(); + $parts = preg_split('#(\{[^}]+})#', $template, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + $nodes = []; - foreach($parts as $part) { + foreach ($parts as $part) { $node = $this->createNode($part); // if current node has dot separator that requires a forward lookup // for the previous node iff previous node's operator is UnNamed if ($node instanceof Expression && $node->getOperator()->id === '.') { - if (sizeof($nodes) > 0) { - $previousNode = $nodes[sizeof($nodes) - 1]; + if (count($nodes) > 0) { + $previousNode = $nodes[count($nodes) - 1]; if ($previousNode instanceof Expression && $previousNode->getOperator() instanceof UnNamed) { $previousNode->setForwardLookupSeparator($node->getOperator()->id); } @@ -42,11 +41,7 @@ public function parse($template) return $nodes; } - /** - * @param string $token - * @return Node\Abstraction - */ - protected function createNode($token) + protected function createNode(string $token): Abstraction { // literal string if ($token[0] !== '{') { @@ -59,17 +54,16 @@ protected function createNode($token) return $node; } - protected function parseExpression($expression) + protected function parseExpression(string $expression): Expression { $token = $expression; $prefix = $token[0]; // not a valid operator? if (!Operator\Abstraction::isValid($prefix)) { - // not valid chars? - if (!preg_match('#'.self::REGEX_VARNAME.'#', $token)) { - throw new \Exception("Invalid operator [$prefix] found at {$token}"); + if (!preg_match('#' . self::REGEX_VARNAME . '#', $token)) { + throw new \InvalidArgumentException("Invalid operator [{$prefix}] found at {$token}"); } // default operator @@ -82,74 +76,71 @@ protected function parseExpression($expression) } // parse variables - $vars = array(); - foreach(explode(',', $token) as $var) { + $vars = []; + foreach (explode(',', $token) as $var) { $vars[] = $this->parseVariable($var); } return $this->createExpressionNode( $token, $this->createOperatorNode($prefix), - $vars + $vars, ); } - protected function parseVariable($var) + protected function parseVariable(string $var): Variable { $var = trim($var); $val = null; $modifier = null; // check for prefix (:) / explode (*) / array (%) modifier - if (strpos($var, ':') !== false) { + if (str_contains($var, ':')) { $modifier = ':'; - list($varname, $val) = explode(':', $var); + [$varname, $val] = explode(':', $var); // error checking if (!is_numeric($val)) { - throw new \Exception("Value for `:` modifier must be numeric value [$varname:$val]"); + throw new \InvalidArgumentException("Value for `:` modifier must be numeric value [{$varname}:{$val}]"); } } - switch($last = substr($var, -1)) { + switch ($last = substr($var, -1)) { case '*': case '%': - // there can be only 1 modifier per var if ($modifier) { - throw new \Exception("Multiple modifiers per variable are not allowed [$var]"); + throw new \InvalidArgumentException("Multiple modifiers per variable are not allowed [{$var}]"); } $modifier = $last; - $var = substr($var, 0, -1); + $var = substr($var, 0, -1); + break; } return $this->createVariableNode( $var, - array( - 'modifier' => $modifier, - 'value' => $val, - ) + ['modifier' => $modifier, 'value' => $val], ); } - protected function createVariableNode($token, $options = array()) + protected function createVariableNode($token, $options = []): Variable { - return new Node\Variable($token, $options); + return new Variable($token, $options); } - protected function createExpressionNode($token, Operator\Abstraction $operator = null, array $vars = array()) + protected function createExpressionNode($token, ?Operator\Abstraction $operator = null, array $vars = []): Expression { - return new Node\Expression($token, $operator, $vars); + return new Expression($token, $operator, $vars); } - protected function createLiteralNode($token) + protected function createLiteralNode(string $token): Node\Literal { return new Node\Literal($token); } - protected function createOperatorNode($token) + protected function createOperatorNode($token): Operator\Abstraction { return Operator\Abstraction::createById($token); } diff --git a/src/Rize/UriTemplate/UriTemplate.php b/src/Rize/UriTemplate/UriTemplate.php index 983d5b5..2170bfc 100644 --- a/src/Rize/UriTemplate/UriTemplate.php +++ b/src/Rize/UriTemplate/UriTemplate.php @@ -5,8 +5,6 @@ use Rize\UriTemplate as Template; /** - * Future compatibility + * Future compatibility. */ -class UriTemplate extends Template -{ -} +class UriTemplate extends Template {} diff --git a/test/Rize/Uri/Node/ParserTest.php b/test/Rize/Uri/Node/ParserTest.php deleted file mode 100644 index e6d1f1a..0000000 --- a/test/Rize/Uri/Node/ParserTest.php +++ /dev/null @@ -1,131 +0,0 @@ - ':', - 'value' => 1, - ) - ), - ) - ), - new Node\Literal('/'), - new Node\Expression( - 'term', - Operator\Abstraction::createById(''), - array( - new Node\Variable( - 'term', - array( - 'modifier' => null, - 'value' => null, - ) - ), - ) - ), - new Node\Literal('/'), - new Node\Expression( - 'test*', - Operator\Abstraction::createById(''), - array( - new Node\Variable( - 'test', - array( - 'modifier' => '*', - 'value' => null, - ) - ), - ) - ), - new Node\Literal('/foo'), - new Node\Expression( - 'query,number', - Operator\Abstraction::createById('?'), - array( - new Node\Variable( - 'query', - array( - 'modifier' => null, - 'value' => null, - ) - ), - new Node\Variable( - 'number', - array( - 'modifier' => null, - 'value' => null, - ) - ), - ) - ), - ); - - $service = $this->service(); - $actual = $service->parse($input); - - $this->assertEquals($expected, $actual); - } - - public function testParseTemplateWithLiteral() - { - // will pass - $uri = new UriTemplate('http://www.example.com/v1/company/', array()); - $params = $uri->extract('/{countryCode}/{registrationNumber}/test{.format}', '/gb/0123456/test.json'); - static::assertEquals(array('countryCode' => 'gb', 'registrationNumber' => '0123456', 'format' => 'json'), $params); - } - - /** - * @depends testParseTemplateWithLiteral - */ - public function testParseTemplateWithTwoVariablesAndDotBetween() - { - // will fail - $uri = new UriTemplate('http://www.example.com/v1/company/', array()); - $params = $uri->extract('/{countryCode}/{registrationNumber}{.format}', '/gb/0123456.json'); - static::assertEquals(array('countryCode' => 'gb', 'registrationNumber' => '0123456', 'format' => 'json'), $params); - } - - /** - * @depends testParseTemplateWithLiteral - */ - public function testParseTemplateWithTwoVariablesAndDotBetweenStrict() - { - // will fail - $uri = new UriTemplate('http://www.example.com/v1/company/', array()); - $params = $uri->extract('/{countryCode}/{registrationNumber}{.format}', '/gb/0123456.json', true); - static::assertEquals(array('countryCode' => 'gb', 'registrationNumber' => '0123456', 'format' => 'json'), $params); - } - - /** - * @depends testParseTemplateWithLiteral - */ - public function testParseTemplateWithThreeVariablesAndDotBetweenStrict() - { - // will fail - $uri = new UriTemplate('http://www.example.com/v1/company/', array()); - $params = $uri->extract('/{countryCode}/{registrationNumber}{.namespace}{.format}', '/gb/0123456.company.json'); - static::assertEquals(array('countryCode' => 'gb', 'registrationNumber' => '0123456', 'namespace' => 'company', 'format' => 'json'), $params); - } -} diff --git a/test/Rize/UriTemplateTest.php b/test/Rize/UriTemplateTest.php deleted file mode 100644 index d57a226..0000000 --- a/test/Rize/UriTemplateTest.php +++ /dev/null @@ -1,688 +0,0 @@ - array("one", "two", "three"), - 'dom' => array("example", "com"), - 'dub' => "me/too", - 'hello' => "Hello World!", - 'half' => "50%", - 'var' => "value", - 'who' => "fred", - 'base' => "http://example.com/home/", - 'path' => "/foo/bar", - 'list' => array("red", "green", "blue"), - 'keys' => array( - "semi" => ";", - "dot" => ".", - "comma" => ",", - ), - 'list_with_empty' => array(''), - 'keys_with_empty' => array('john' => ''), - 'v' => "6", - 'x' => "1024", - 'y' => "768", - 'empty' => "", - 'empty_keys' => array(), - 'undef' => null, - ); - - return array( - - array( - 'http://example.com/~john', - array( - 'uri' => 'http://example.com/~{username}', - 'params' => array( - 'username' => 'john', - ), - ), - ), - - array( - 'http://example.com/dictionary/d/dog', - array( - 'uri' => 'http://example.com/dictionary/{term:1}/{term}', - 'params' => array( - 'term' => 'dog', - ), - 'extract' => array( - 'term:1' => 'd', - 'term' => 'dog', - ), - ), - ), - - # Form-style parameters expression - array( - 'http://example.com/j/john/search?q=mycelium&q=3&lang=th,jp,en', - array( - 'uri' => 'http://example.com/{term:1}/{term}/search{?q*,lang}', - 'params' => array( - 'q' => array('mycelium', 3), - 'lang' => array('th', 'jp', 'en'), - 'term' => 'john', - ), - ), - ), - - array( - 'http://www.example.com/john', - array( - 'uri' => 'http://www.example.com/{username}', - 'params' => array( - 'username' => 'john', - ), - ), - ), - - array( - 'http://www.example.com/foo?query=mycelium&number=100', - array( - 'uri' => 'http://www.example.com/foo{?query,number}', - 'params' => array( - 'query' => 'mycelium', - 'number' => 100, - ), - ), - ), - - # 'query' is undefined - array( - 'http://www.example.com/foo?number=100', - array( - 'uri' => 'http://www.example.com/foo{?query,number}', - 'params' => array( - 'number' => 100, - ), - # we can't extract undefined values - 'extract' => false, - ), - ), - - # undefined variables - array( - 'http://www.example.com/foo', - array( - 'uri' => 'http://www.example.com/foo{?query,number}', - 'params' => array(), - 'extract' => array('query' => null, 'number' => null), - ), - ), - - array( - 'http://www.example.com/foo', - array( - 'uri' => 'http://www.example.com/foo{?number}', - 'params' => array(), - 'extract' => array('number' => null), - ), - ), - - array( - 'one,two,three|one,two,three|/one,two,three|/one/two/three|;count=one,two,three|;count=one;count=two;count=three|?count=one,two,three|?count=one&count=two&count=three|&count=one&count=two&count=three', - array( - 'uri' => '{count}|{count*}|{/count}|{/count*}|{;count}|{;count*}|{?count}|{?count*}|{&count*}', - 'params' => array( - 'count' => array('one', 'two', 'three'), - ), - ), - ), - - array( - 'http://www.host.com/path/to/a/file.x.y', - array( - 'uri' => 'http://{host}{/segments*}/{file}{.extensions*}', - 'params' => array( - 'host' => 'www.host.com', - 'segments' => array('path', 'to', 'a'), - 'file' => 'file', - 'extensions' => array('x', 'y'), - ), - 'extract' => array( - 'host' => 'www.host.com', - 'segments' => array('path', 'to', 'a'), - 'file' => 'file.x.y', - 'extensions' => null, - ), - ), - ), - - # level 1 - Simple String Expansion: {var} - array( - 'value|Hello%20World%21|50%25|OX|OX|1024,768|1024,Hello%20World%21,768|?1024,|?1024|?768|val|value|red,green,blue|semi,%3B,dot,.,comma,%2C|semi=%3B,dot=.,comma=%2C', - array( - 'uri' => '{var}|{hello}|{half}|O{empty}X|O{undef}X|{x,y}|{x,hello,y}|?{x,empty}|?{x,undef}|?{undef,y}|{var:3}|{var:30}|{list}|{keys}|{keys*}', - 'params' => $params, - ), - ), - - # level 2 - Reserved Expansion: {+var} - array( - 'value|Hello%20World!|50%25|http%3A%2F%2Fexample.com%2Fhome%2Findex|http://example.com/home/index|OX|OX|/foo/bar/here|here?ref=/foo/bar|up/foo/barvalue/here|1024,Hello%20World!,768|/foo/bar,1024/here|/foo/b/here|red,green,blue|red,green,blue|semi,;,dot,.,comma,,|semi=;,dot=.,comma=,', - array( - 'uri' => '{+var}|{+hello}|{+half}|{base}index|{+base}index|O{+empty}X|O{+undef}X|{+path}/here|here?ref={+path}|up{+path}{var}/here|{+x,hello,y}|{+path,x}/here|{+path:6}/here|{+list}|{+list*}|{+keys}|{+keys*}', - 'params' => $params, - ), - ), - - # level 2 - Fragment Expansion: {#var} - array( - '#value|#Hello%20World!|#50%25|foo#|foo|#1024,Hello%20World!,768|#/foo/bar,1024/here|#/foo/b/here|#red,green,blue|#red,green,blue|#semi,;,dot,.,comma,,|#semi=;,dot=.,comma=,', - array( - 'uri' => '{#var}|{#hello}|{#half}|foo{#empty}|foo{#undef}|{#x,hello,y}|{#path,x}/here|{#path:6}/here|{#list}|{#list*}|{#keys}|{#keys*}', - 'params' => $params, - ), - ), - - # Label Expansion with Dot-Prefix: {.var} - array( - '.fred|.fred.fred|.50%25.fred|www.example.com|X.value|X.|X|X.val|X.red,green,blue|X.red.green.blue|X.semi,%3B,dot,.,comma,%2C|X.semi=%3B.dot=..comma=%2C|X|X', - array( - 'uri' => '{.who}|{.who,who}|{.half,who}|www{.dom*}|X{.var}|X{.empty}|X{.undef}|X{.var:3}|X{.list}|X{.list*}|X{.keys}|X{.keys*}|X{.empty_keys}|X{.empty_keys*}', - 'params' => $params, - ), - ), - - # Path Segment Expansion: {/var} - array( - '/fred|/fred/fred|/50%25/fred|/fred/me%2Ftoo|/value|/value/|/value|/value/1024/here|/v/value|/red,green,blue|/red/green/blue|/red/green/blue/%2Ffoo|/semi,%3B,dot,.,comma,%2C|/semi=%3B/dot=./comma=%2C', - array( - 'uri' => '{/who}|{/who,who}|{/half,who}|{/who,dub}|{/var}|{/var,empty}|{/var,undef}|{/var,x}/here|{/var:1,var}|{/list}|{/list*}|{/list*,path:4}|{/keys}|{/keys*}', - 'params' => $params, - ), - ), - - # Path-Style Parameter Expansion: {;var} - array( - ';who=fred|;half=50%25|;empty|;v=6;empty;who=fred|;v=6;who=fred|;x=1024;y=768|;x=1024;y=768;empty|;x=1024;y=768|;hello=Hello|;list=red,green,blue|;list=red;list=green;list=blue|;keys=semi,%3B,dot,.,comma,%2C|;semi=%3B;dot=.;comma=%2C', - array( - 'uri' => '{;who}|{;half}|{;empty}|{;v,empty,who}|{;v,bar,who}|{;x,y}|{;x,y,empty}|{;x,y,undef}|{;hello:5}|{;list}|{;list*}|{;keys}|{;keys*}', - 'params' => $params, - ), - ), - - # Form-Style Query Expansion: {?var} - array( - '?who=fred|?half=50%25|?x=1024&y=768|?x=1024&y=768&empty=|?x=1024&y=768|?var=val|?list=red,green,blue|?list=red&list=green&list=blue|?keys=semi,%3B,dot,.,comma,%2C|?semi=%3B&dot=.&comma=%2C|?list_with_empty=|?john=', - array( - 'uri' => '{?who}|{?half}|{?x,y}|{?x,y,empty}|{?x,y,undef}|{?var:3}|{?list}|{?list*}|{?keys}|{?keys*}|{?list_with_empty*}|{?keys_with_empty*}', - 'params' => $params, - ), - ), - - # Form-Style Query Continuation: {&var} - array( - '&who=fred|&half=50%25|?fixed=yes&x=1024|&x=1024&y=768&empty=|&x=1024&y=768|&var=val|&list=red,green,blue|&list=red&list=green&list=blue|&keys=semi,%3B,dot,.,comma,%2C|&semi=%3B&dot=.&comma=%2C', - array( - 'uri' => '{&who}|{&half}|?fixed=yes{&x}|{&x,y,empty}|{&x,y,undef}|{&var:3}|{&list}|{&list*}|{&keys}|{&keys*}', - 'params' => $params, - ), - ), - - # Test empty values - array( - '|||', - array( - 'uri' => '{empty}|{empty*}|{?empty}|{?empty*}', - 'params' => array( - 'empty' => array(), - ), - ), - ), - ); - } - - public static function dataExpandWithArrayModifier() - { - return array( - # List - array( - # '?choices[]=a&choices[]=b&choices[]=c', - '?choices%5B%5D=a&choices%5B%5D=b&choices%5B%5D=c', - array( - 'uri' => '{?choices%}', - 'params' => array( - 'choices' => array('a', 'b', 'c'), - ), - ), - ), - - # Keys - array( - # '?choices[a]=1&choices[b]=2&choices[c][test]=3', - '?choices%5Ba%5D=1&choices%5Bb%5D=2&choices%5Bc%5D%5Btest%5D=3', - array( - 'uri' => '{?choices%}', - 'params' => array( - 'choices' => array( - 'a' => 1, - 'b' => 2, - 'c' => array( - 'test' => 3, - ), - ), - ), - ), - ), - - # Mixed - array( - # '?list[]=a&list[]=b&keys[a]=1&keys[b]=2', - '?list%5B%5D=a&list%5B%5D=b&keys%5Ba%5D=1&keys%5Bb%5D=2', - array( - 'uri' => '{?list%,keys%}', - 'params' => array( - 'list' => array( - 'a', 'b', - ), - 'keys' => array( - 'a' => 1, - 'b' => 2, - ), - ), - ), - ), - ); - } - - public static function dataBaseTemplate() - { - return array( - array( - 'http://google.com/api/1/users/1', - # base uri - array( - 'uri' => '{+host}/api/{v}', - 'params' => array( - 'host' => 'http://google.com', - 'v' => 1, - ), - ), - # other uri - array( - 'uri' => '/{resource}/{id}', - 'params' => array( - 'resource' => 'users', - 'id' => 1, - ), - ), - ), - - # test override base params - array( - 'http://github.com/api/1/users/1', - # base uri - array( - 'uri' => '{+host}/api/{v}', - 'params' => array( - 'host' => 'http://google.com', - 'v' => 1, - ), - ), - # other uri - array( - 'uri' => '/{resource}/{id}', - 'params' => array( - 'host' => 'http://github.com', - 'resource' => 'users', - 'id' => 1, - ), - ), - ), - ); - } - - public static function dataExtraction() - { - return array( - array( - '/no/{term:1}/random/foo{?query,list%,keys%}', - '/no/j/random/foo?query=1,2,3&list%5B%5D=a&list%5B%5D=b&keys%5Ba%5D=1&keys%5Bb%5D=2&keys%5Bc%5D%5Btest%5D%5Btest%5D=1', - array( - 'term:1' => 'j', - 'query' => array(1, 2, 3), - 'list' => array( - 'a', 'b', - ), - 'keys' => array( - 'a' => 1, - 'b' => 2, - 'c' => array( - 'test' => array( - 'test' => 1, - ), - ), - ), - ), - ), - array( - '/no/{term:1}/random/{term}/{test*}/foo{?query,number}', - '/no/j/random/john/a,b,c/foo?query=1,2,3&number=10', - array( - 'term:1' => 'j', - 'term' => 'john', - 'test' => array('a', 'b', 'c'), - 'query' => array(1, 2, 3), - 'number' => 10, - ), - ), - array( - '/search/{term:1}/{term}/{?q*,limit}', - '/search/j/john/?a=1&b=2&limit=10', - array( - 'term:1' => 'j', - 'term' => 'john', - 'q' => array('a' => 1, 'b' => 2), - 'limit' => 10, - ), - ), - array( - 'http://www.example.com/foo{?query,number}', - 'http://www.example.com/foo?query=5', - array( - 'query' => 5, - 'number' => null, - ), - ), - array( - '{count}|{count*}|{/count}|{/count*}|{;count}|{;count*}|{?count}|{?count*}|{&count*}', - 'one,two,three|one,two,three|/one,two,three|/one/two/three|;count=one,two,three|;count=one;count=two;count=three|?count=one,two,three|?count=one&count=two&count=three|&count=one&count=two&count=three', - array( - 'count' => array('one', 'two', 'three'), - ), - ), - array( - 'http://example.com/{term:1}/{term}/search{?q*,lang}', - 'http://example.com/j/john/search?q=Hello%20World%21&q=3&lang=th,jp,en', - array( - 'q' => array('Hello World!', 3), - 'lang' => array('th', 'jp', 'en'), - 'term' => 'john', - 'term:1' => 'j', - ), - ), - array( - '/foo/bar/{number}', - '/foo/bar/0', - array( - 'number' => 0, - ), - ), - array( - '/some/{path}{?ref}', - '/some/foo', - array( - 'path' => 'foo', - 'ref' => null, - ), - ), - ); - } - - /** - * @dataProvider dataExpansion - */ - public function testExpansion($expected, $input) - { - $service = $this->service(); - $result = $service->expand($input['uri'], $input['params']); - - $this->assertEquals($expected, $result); - } - - /** - * @dataProvider dataExpandWithArrayModifier - */ - public function testExpandWithArrayModifier($expected, $input) - { - $service = $this->service(); - $result = $service->expand($input['uri'], $input['params']); - - $this->assertEquals($expected, $result); - } - - /** - * @dataProvider dataBaseTemplate - */ - public function testBaseTemplate($expected, $base, $other) - { - $service = $this->service($base['uri'], $base['params']); - $result = $service->expand($other['uri'], $other['params']); - - $this->assertEquals($expected, $result); - } - - /** - * @dataProvider dataExtraction - */ - public function testExtract($template, $uri, $expected) - { - $service = $this->service(); - $actual = $service->extract($template, $uri); - - $this->assertEquals($expected, $actual); - } - - public function testExpandFromFixture() - { - $dir = dirname(__DIR__).DIRECTORY_SEPARATOR.'fixtures'.DIRECTORY_SEPARATOR; - $files = array('spec-examples.json', 'spec-examples-by-section.json', 'extended-tests.json'); - $service = $this->service(); - - foreach($files as $file) { - $content = json_decode(file_get_contents($dir.$file), $array = true); - - # iterate through each fixture - foreach($content as $fixture) { - $vars = $fixture['variables']; - - # assert each test cases - foreach($fixture['testcases'] as $case) { - list($uri, $expected) = $case; - - $actual = $service->expand($uri, $vars); - - if (is_array($expected)) { - $expected = current(array_filter($expected, function($input) use ($actual) { - return $actual === $input; - })); - } - - $this->assertEquals($expected, $actual); - } - } - } - } - - public static function dataExtractStrictMode() - { - $dataTest = array( - - array( - '/search/{term:1}/{term}/{?q*,limit}', - '/search/j/john/?a=1&b=2&limit=10', - array( - 'term:1' => 'j', - 'term' => 'john', - 'limit' => '10', - 'q' => array( - 'a' => '1', - 'b' => '2', - ), - ), - ), - array( - 'http://example.com/{term:1}/{term}/search{?q*,lang}', - 'http://example.com/j/john/search?q=Hello%20World%21&q=3&lang=th,jp,en', - array( - 'term:1' => 'j', - 'term' => 'john', - 'lang' => array( - 'th', - 'jp', - 'en', - ), - 'q' => array( - 'Hello World!', - '3', - ), - ), - ), - array( - '/foo/bar/{number}', - '/foo/bar/0', - array( - 'number' => 0, - ), - ), - array( - '/', - '/', - array(), - ), - ); - - $rfc3986AllowedPathCharacters = array( - '-', '.', '_', '~', '!', '$', '&', "'", '(', ')', '*', '+', ',', ';', '=', ':', '@', - ); - - foreach ($rfc3986AllowedPathCharacters as $char) { - $title = "RFC3986 path character ($char)"; - $title = str_replace("'", 'single quote', $title); // PhpStorm workaround - if ($char === ',') { // , means array on RFC6570 - $params = array( - 'term' => array( - 'foo', - 'baz', - ), - ); - } else { - $params = array( - 'term' => "foo{$char}baz", - ); - } - - $data = array( - '/search/{term}', - "/search/foo{$char}baz", - $params, - ); - - $dataTest[$title] = $data; - $data = array( - '/search/{;term}', - "/search/;term=foo{$char}baz", - $params, - ); - $dataTest['Named ' . $title] = $data; - } - - $rfc3986AllowedQueryCharacters = $rfc3986AllowedPathCharacters; - $rfc3986AllowedQueryCharacters[] = '/'; - $rfc3986AllowedQueryCharacters[] = '?'; - unset($rfc3986AllowedQueryCharacters[array_search('&', $rfc3986AllowedQueryCharacters, true)]); - - foreach ($rfc3986AllowedQueryCharacters as $char) { - $title = "RFC3986 query character ($char)"; - $title = str_replace("'", 'single quote', $title); // PhpStorm workaround - if ($char === ',') { // , means array on RFC6570 - $params = array( - 'term' => array( - 'foo', - 'baz', - ), - ); - } else { - $params = array( - 'term' => "foo{$char}baz", - ); - } - - $data = array( - '/search/{?term}', - "/search/?term=foo{$char}baz", - $params, - ); - $dataTest['Named ' . $title] = $data; - } - - return $dataTest; - } - - public static function extractStrictModeNotMatchProvider() - { - return array( - array( - '/', - '/a', - ), - array( - '/{test}', - '/a/', - ), - array( - '/search/{term:1}/{term}/{?q*,limit}', - '/search/j/?a=1&b=2&limit=10', - ), - array( - 'http://www.example.com/foo{?query,number}', - 'http://www.example.com/foo?query=5', - ), - array( - 'http://www.example.com/foo{?query,number}', - 'http://www.example.com/foo', - ), - array( - 'http://example.com/{term:1}/{term}/search{?q*,lang}', - 'http://example.com/j/john/search?q=', - ), - ); - } - - /** - * @dataProvider dataExtractStrictMode - * - * @param string $template - * @param string $uri - * @param array $expectedParams - */ - public function testExtractStrictMode($template, $uri, array $expectedParams) - { - $service = $this->service(); - $params = $service->extract($template, $uri, true); - - $this->assertTrue(isset($params)); - $this->assertEquals($expectedParams, $params); - } - - /** - * @dataProvider extractStrictModeNotMatchProvider - * - * @param string $template - * @param string $uri - */ - public function testExtractStrictModeNotMatch($template, $uri) - { - $service = $this->service(); - $actual = $service->extract($template, $uri, true); - - $this->assertFalse(isset($actual)); - } -} diff --git a/tests/Rize/Uri/Node/ParserTest.php b/tests/Rize/Uri/Node/ParserTest.php new file mode 100644 index 0000000..203d238 --- /dev/null +++ b/tests/Rize/Uri/Node/ParserTest.php @@ -0,0 +1,93 @@ + ':', 'value' => 1], + )], + ), new Node\Literal('/'), new Node\Expression( + 'term', + Operator\Abstraction::createById(''), + [new Node\Variable( + 'term', + ['modifier' => null, 'value' => null], + )], + ), new Node\Literal('/'), new Node\Expression( + 'test*', + Operator\Abstraction::createById(''), + [new Node\Variable( + 'test', + ['modifier' => '*', 'value' => null], + )], + ), new Node\Literal('/foo'), new Node\Expression( + 'query,number', + Operator\Abstraction::createById('?'), + [new Node\Variable( + 'query', + ['modifier' => null, 'value' => null], + ), new Node\Variable( + 'number', + ['modifier' => null, 'value' => null], + )], + )]; + + $service = $this->service(); + $actual = $service->parse($input); + + $this->assertEquals($expected, $actual); + } + + public function testParseTemplateWithLiteral() + { + // will pass + $uri = new UriTemplate('http://www.example.com/v1/company/', []); + $params = $uri->extract('/{countryCode}/{registrationNumber}/test{.format}', '/gb/0123456/test.json'); + static::assertEquals(['countryCode' => 'gb', 'registrationNumber' => '0123456', 'format' => 'json'], $params); + } + + #[Depends('testParseTemplateWithLiteral')] + public function testParseTemplateWithTwoVariablesAndDotBetween() + { + // will fail + $uri = new UriTemplate('http://www.example.com/v1/company/', []); + $params = $uri->extract('/{countryCode}/{registrationNumber}{.format}', '/gb/0123456.json'); + static::assertEquals(['countryCode' => 'gb', 'registrationNumber' => '0123456', 'format' => 'json'], $params); + } + + #[Depends('testParseTemplateWithLiteral')] + public function testParseTemplateWithTwoVariablesAndDotBetweenStrict() + { + // will fail + $uri = new UriTemplate('http://www.example.com/v1/company/', []); + $params = $uri->extract('/{countryCode}/{registrationNumber}{.format}', '/gb/0123456.json', true); + static::assertEquals(['countryCode' => 'gb', 'registrationNumber' => '0123456', 'format' => 'json'], $params); + } + + #[Depends('testParseTemplateWithLiteral')] + public function testParseTemplateWithThreeVariablesAndDotBetweenStrict() + { + // will fail + $uri = new UriTemplate('http://www.example.com/v1/company/', []); + $params = $uri->extract('/{countryCode}/{registrationNumber}{.namespace}{.format}', '/gb/0123456.company.json'); + static::assertEquals(['countryCode' => 'gb', 'registrationNumber' => '0123456', 'namespace' => 'company', 'format' => 'json'], $params); + } +} diff --git a/tests/Rize/UriTemplateTest.php b/tests/Rize/UriTemplateTest.php new file mode 100644 index 0000000..427f93f --- /dev/null +++ b/tests/Rize/UriTemplateTest.php @@ -0,0 +1,253 @@ + ["one", "two", "three"], 'dom' => ["example", "com"], 'dub' => "me/too", 'hello' => "Hello World!", 'half' => "50%", 'var' => "value", 'who' => "fred", 'base' => "http://example.com/home/", 'path' => "/foo/bar", 'list' => ["red", "green", "blue"], 'keys' => ["semi" => ";", "dot" => ".", "comma" => ","], 'list_with_empty' => [''], 'keys_with_empty' => ['john' => ''], 'v' => "6", 'x' => "1024", 'y' => "768", 'empty' => "", 'empty_keys' => [], 'undef' => null]; + + return [ + ['http://example.com/~john', ['uri' => 'http://example.com/~{username}', 'params' => ['username' => 'john']]], + ['http://example.com/dictionary/d/dog', ['uri' => 'http://example.com/dictionary/{term:1}/{term}', 'params' => ['term' => 'dog'], 'extract' => ['term:1' => 'd', 'term' => 'dog']]], + # Form-style parameters expression + ['http://example.com/j/john/search?q=mycelium&q=3&lang=th,jp,en', ['uri' => 'http://example.com/{term:1}/{term}/search{?q*,lang}', 'params' => ['q' => ['mycelium', 3], 'lang' => ['th', 'jp', 'en'], 'term' => 'john']]], + ['http://www.example.com/john', ['uri' => 'http://www.example.com/{username}', 'params' => ['username' => 'john']]], + ['http://www.example.com/foo?query=mycelium&number=100', ['uri' => 'http://www.example.com/foo{?query,number}', 'params' => ['query' => 'mycelium', 'number' => 100]]], + # 'query' is undefined + ['http://www.example.com/foo?number=100', [ + 'uri' => 'http://www.example.com/foo{?query,number}', + 'params' => ['number' => 100], + # we can't extract undefined values + 'extract' => false, + ]], + # undefined variables + ['http://www.example.com/foo', ['uri' => 'http://www.example.com/foo{?query,number}', 'params' => [], 'extract' => ['query' => null, 'number' => null]]], + ['http://www.example.com/foo', ['uri' => 'http://www.example.com/foo{?number}', 'params' => [], 'extract' => ['number' => null]]], + ['one,two,three|one,two,three|/one,two,three|/one/two/three|;count=one,two,three|;count=one;count=two;count=three|?count=one,two,three|?count=one&count=two&count=three|&count=one&count=two&count=three', ['uri' => '{count}|{count*}|{/count}|{/count*}|{;count}|{;count*}|{?count}|{?count*}|{&count*}', 'params' => ['count' => ['one', 'two', 'three']]]], + ['http://www.host.com/path/to/a/file.x.y', ['uri' => 'http://{host}{/segments*}/{file}{.extensions*}', 'params' => ['host' => 'www.host.com', 'segments' => ['path', 'to', 'a'], 'file' => 'file', 'extensions' => ['x', 'y']], 'extract' => ['host' => 'www.host.com', 'segments' => ['path', 'to', 'a'], 'file' => 'file.x.y', 'extensions' => null]]], + # level 1 - Simple String Expansion: {var} + ['value|Hello%20World%21|50%25|OX|OX|1024,768|1024,Hello%20World%21,768|?1024,|?1024|?768|val|value|red,green,blue|semi,%3B,dot,.,comma,%2C|semi=%3B,dot=.,comma=%2C', ['uri' => '{var}|{hello}|{half}|O{empty}X|O{undef}X|{x,y}|{x,hello,y}|?{x,empty}|?{x,undef}|?{undef,y}|{var:3}|{var:30}|{list}|{keys}|{keys*}', 'params' => $params]], + # level 2 - Reserved Expansion: {+var} + ['value|Hello%20World!|50%25|http%3A%2F%2Fexample.com%2Fhome%2Findex|http://example.com/home/index|OX|OX|/foo/bar/here|here?ref=/foo/bar|up/foo/barvalue/here|1024,Hello%20World!,768|/foo/bar,1024/here|/foo/b/here|red,green,blue|red,green,blue|semi,;,dot,.,comma,,|semi=;,dot=.,comma=,', ['uri' => '{+var}|{+hello}|{+half}|{base}index|{+base}index|O{+empty}X|O{+undef}X|{+path}/here|here?ref={+path}|up{+path}{var}/here|{+x,hello,y}|{+path,x}/here|{+path:6}/here|{+list}|{+list*}|{+keys}|{+keys*}', 'params' => $params]], + # level 2 - Fragment Expansion: {#var} + ['#value|#Hello%20World!|#50%25|foo#|foo|#1024,Hello%20World!,768|#/foo/bar,1024/here|#/foo/b/here|#red,green,blue|#red,green,blue|#semi,;,dot,.,comma,,|#semi=;,dot=.,comma=,', ['uri' => '{#var}|{#hello}|{#half}|foo{#empty}|foo{#undef}|{#x,hello,y}|{#path,x}/here|{#path:6}/here|{#list}|{#list*}|{#keys}|{#keys*}', 'params' => $params]], + # Label Expansion with Dot-Prefix: {.var} + ['.fred|.fred.fred|.50%25.fred|www.example.com|X.value|X.|X|X.val|X.red,green,blue|X.red.green.blue|X.semi,%3B,dot,.,comma,%2C|X.semi=%3B.dot=..comma=%2C|X|X', ['uri' => '{.who}|{.who,who}|{.half,who}|www{.dom*}|X{.var}|X{.empty}|X{.undef}|X{.var:3}|X{.list}|X{.list*}|X{.keys}|X{.keys*}|X{.empty_keys}|X{.empty_keys*}', 'params' => $params]], + # Path Segment Expansion: {/var} + ['/fred|/fred/fred|/50%25/fred|/fred/me%2Ftoo|/value|/value/|/value|/value/1024/here|/v/value|/red,green,blue|/red/green/blue|/red/green/blue/%2Ffoo|/semi,%3B,dot,.,comma,%2C|/semi=%3B/dot=./comma=%2C', ['uri' => '{/who}|{/who,who}|{/half,who}|{/who,dub}|{/var}|{/var,empty}|{/var,undef}|{/var,x}/here|{/var:1,var}|{/list}|{/list*}|{/list*,path:4}|{/keys}|{/keys*}', 'params' => $params]], + # Path-Style Parameter Expansion: {;var} + [';who=fred|;half=50%25|;empty|;v=6;empty;who=fred|;v=6;who=fred|;x=1024;y=768|;x=1024;y=768;empty|;x=1024;y=768|;hello=Hello|;list=red,green,blue|;list=red;list=green;list=blue|;keys=semi,%3B,dot,.,comma,%2C|;semi=%3B;dot=.;comma=%2C', ['uri' => '{;who}|{;half}|{;empty}|{;v,empty,who}|{;v,bar,who}|{;x,y}|{;x,y,empty}|{;x,y,undef}|{;hello:5}|{;list}|{;list*}|{;keys}|{;keys*}', 'params' => $params]], + # Form-Style Query Expansion: {?var} + ['?who=fred|?half=50%25|?x=1024&y=768|?x=1024&y=768&empty=|?x=1024&y=768|?var=val|?list=red,green,blue|?list=red&list=green&list=blue|?keys=semi,%3B,dot,.,comma,%2C|?semi=%3B&dot=.&comma=%2C|?list_with_empty=|?john=', ['uri' => '{?who}|{?half}|{?x,y}|{?x,y,empty}|{?x,y,undef}|{?var:3}|{?list}|{?list*}|{?keys}|{?keys*}|{?list_with_empty*}|{?keys_with_empty*}', 'params' => $params]], + # Form-Style Query Continuation: {&var} + ['&who=fred|&half=50%25|?fixed=yes&x=1024|&x=1024&y=768&empty=|&x=1024&y=768|&var=val|&list=red,green,blue|&list=red&list=green&list=blue|&keys=semi,%3B,dot,.,comma,%2C|&semi=%3B&dot=.&comma=%2C', ['uri' => '{&who}|{&half}|?fixed=yes{&x}|{&x,y,empty}|{&x,y,undef}|{&var:3}|{&list}|{&list*}|{&keys}|{&keys*}', 'params' => $params]], + # Test empty values + ['|||', ['uri' => '{empty}|{empty*}|{?empty}|{?empty*}', 'params' => ['empty' => []]]], + ]; + } + + public static function dataExpandWithArrayModifier() + { + return [ + # List + [ + # '?choices[]=a&choices[]=b&choices[]=c', + '?choices%5B%5D=a&choices%5B%5D=b&choices%5B%5D=c', + ['uri' => '{?choices%}', 'params' => ['choices' => ['a', 'b', 'c']]], + ], + # Keys + [ + # '?choices[a]=1&choices[b]=2&choices[c][test]=3', + '?choices%5Ba%5D=1&choices%5Bb%5D=2&choices%5Bc%5D%5Btest%5D=3', + ['uri' => '{?choices%}', 'params' => ['choices' => ['a' => 1, 'b' => 2, 'c' => ['test' => 3]]]], + ], + # Mixed + [ + # '?list[]=a&list[]=b&keys[a]=1&keys[b]=2', + '?list%5B%5D=a&list%5B%5D=b&keys%5Ba%5D=1&keys%5Bb%5D=2', + ['uri' => '{?list%,keys%}', 'params' => ['list' => ['a', 'b'], 'keys' => ['a' => 1, 'b' => 2]]], + ], + ]; + } + + public static function dataBaseTemplate() + { + return [ + [ + 'http://google.com/api/1/users/1', + # base uri + ['uri' => '{+host}/api/{v}', 'params' => ['host' => 'http://google.com', 'v' => 1]], + # other uri + ['uri' => '/{resource}/{id}', 'params' => ['resource' => 'users', 'id' => 1]], + ], + # test override base params + [ + 'http://github.com/api/1/users/1', + # base uri + ['uri' => '{+host}/api/{v}', 'params' => ['host' => 'http://google.com', 'v' => 1]], + # other uri + ['uri' => '/{resource}/{id}', 'params' => ['host' => 'http://github.com', 'resource' => 'users', 'id' => 1]], + ], + ]; + } + + public static function dataExtraction() + { + return [['/no/{term:1}/random/foo{?query,list%,keys%}', '/no/j/random/foo?query=1,2,3&list%5B%5D=a&list%5B%5D=b&keys%5Ba%5D=1&keys%5Bb%5D=2&keys%5Bc%5D%5Btest%5D%5Btest%5D=1', ['term:1' => 'j', 'query' => [1, 2, 3], 'list' => ['a', 'b'], 'keys' => ['a' => 1, 'b' => 2, 'c' => ['test' => ['test' => 1]]]]], ['/no/{term:1}/random/{term}/{test*}/foo{?query,number}', '/no/j/random/john/a,b,c/foo?query=1,2,3&number=10', ['term:1' => 'j', 'term' => 'john', 'test' => ['a', 'b', 'c'], 'query' => [1, 2, 3], 'number' => 10]], ['/search/{term:1}/{term}/{?q*,limit}', '/search/j/john/?a=1&b=2&limit=10', ['term:1' => 'j', 'term' => 'john', 'q' => ['a' => 1, 'b' => 2], 'limit' => 10]], ['http://www.example.com/foo{?query,number}', 'http://www.example.com/foo?query=5', ['query' => 5, 'number' => null]], ['{count}|{count*}|{/count}|{/count*}|{;count}|{;count*}|{?count}|{?count*}|{&count*}', 'one,two,three|one,two,three|/one,two,three|/one/two/three|;count=one,two,three|;count=one;count=two;count=three|?count=one,two,three|?count=one&count=two&count=three|&count=one&count=two&count=three', ['count' => ['one', 'two', 'three']]], ['http://example.com/{term:1}/{term}/search{?q*,lang}', 'http://example.com/j/john/search?q=Hello%20World%21&q=3&lang=th,jp,en', ['q' => ['Hello World!', 3], 'lang' => ['th', 'jp', 'en'], 'term' => 'john', 'term:1' => 'j']], ['/foo/bar/{number}', '/foo/bar/0', ['number' => 0]], ['/some/{path}{?ref}', '/some/foo', ['path' => 'foo', 'ref' => null]]]; + } + + /** + * @dataProvider dataExpansion + */ + public function testExpansion($expected, $input) + { + $service = $this->service(); + $result = $service->expand($input['uri'], $input['params']); + + $this->assertEquals($expected, $result); + } + + /** + * @dataProvider dataExpandWithArrayModifier + */ + public function testExpandWithArrayModifier($expected, $input) + { + $service = $this->service(); + $result = $service->expand($input['uri'], $input['params']); + + $this->assertEquals($expected, $result); + } + + /** + * @dataProvider dataBaseTemplate + */ + public function testBaseTemplate($expected, $base, $other) + { + $service = $this->service($base['uri'], $base['params']); + $result = $service->expand($other['uri'], $other['params']); + + $this->assertEquals($expected, $result); + } + + /** + * @dataProvider dataExtraction + */ + public function testExtract($template, $uri, $expected) + { + $service = $this->service(); + $actual = $service->extract($template, $uri); + + $this->assertEquals($expected, $actual); + } + + public function testExpandFromFixture() + { + $dir = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'fixtures' . DIRECTORY_SEPARATOR; + $files = ['spec-examples.json', 'spec-examples-by-section.json', 'extended-tests.json']; + $service = $this->service(); + + foreach ($files as $file) { + $content = json_decode(file_get_contents($dir . $file), $array = true); + + # iterate through each fixture + foreach ($content as $fixture) { + $vars = $fixture['variables']; + + # assert each test cases + foreach ($fixture['testcases'] as $case) { + [$uri, $expected] = $case; + + $actual = $service->expand($uri, $vars); + + if (is_array($expected)) { + $expected = current(array_filter($expected, fn($input) => $actual === $input)); + } + + $this->assertEquals($expected, $actual); + } + } + } + } + + public static function dataExtractStrictMode() + { + $dataTest = [['/search/{term:1}/{term}/{?q*,limit}', '/search/j/john/?a=1&b=2&limit=10', ['term:1' => 'j', 'term' => 'john', 'limit' => '10', 'q' => ['a' => '1', 'b' => '2']]], ['http://example.com/{term:1}/{term}/search{?q*,lang}', 'http://example.com/j/john/search?q=Hello%20World%21&q=3&lang=th,jp,en', ['term:1' => 'j', 'term' => 'john', 'lang' => ['th', 'jp', 'en'], 'q' => ['Hello World!', '3']]], ['/foo/bar/{number}', '/foo/bar/0', ['number' => 0]], ['/', '/', []]]; + + $rfc3986AllowedPathCharacters = ['-', '.', '_', '~', '!', '$', '&', "'", '(', ')', '*', '+', ',', ';', '=', ':', '@']; + + foreach ($rfc3986AllowedPathCharacters as $char) { + $title = "RFC3986 path character ($char)"; + $title = str_replace("'", 'single quote', $title); // PhpStorm workaround + if ($char === ',') { // , means array on RFC6570 + $params = ['term' => ['foo', 'baz']]; + } else { + $params = ['term' => "foo{$char}baz"]; + } + + $data = ['/search/{term}', "/search/foo{$char}baz", $params]; + + $dataTest[$title] = $data; + $data = ['/search/{;term}', "/search/;term=foo{$char}baz", $params]; + $dataTest['Named ' . $title] = $data; + } + + $rfc3986AllowedQueryCharacters = $rfc3986AllowedPathCharacters; + $rfc3986AllowedQueryCharacters[] = '/'; + $rfc3986AllowedQueryCharacters[] = '?'; + unset($rfc3986AllowedQueryCharacters[array_search('&', $rfc3986AllowedQueryCharacters, true)]); + + foreach ($rfc3986AllowedQueryCharacters as $char) { + $title = "RFC3986 query character ($char)"; + $title = str_replace("'", 'single quote', $title); // PhpStorm workaround + if ($char === ',') { // , means array on RFC6570 + $params = ['term' => ['foo', 'baz']]; + } else { + $params = ['term' => "foo{$char}baz"]; + } + + $data = ['/search/{?term}', "/search/?term=foo{$char}baz", $params]; + $dataTest['Named ' . $title] = $data; + } + + return $dataTest; + } + + public static function extractStrictModeNotMatchProvider() + { + return [['/', '/a'], ['/{test}', '/a/'], ['/search/{term:1}/{term}/{?q*,limit}', '/search/j/?a=1&b=2&limit=10'], ['http://www.example.com/foo{?query,number}', 'http://www.example.com/foo?query=5'], ['http://www.example.com/foo{?query,number}', 'http://www.example.com/foo'], ['http://example.com/{term:1}/{term}/search{?q*,lang}', 'http://example.com/j/john/search?q=']]; + } + + #[DataProvider('dataExtractStrictMode')] + public function testExtractStrictMode(string $template, string $uri, array $expectedParams) + { + $service = $this->service(); + $params = $service->extract($template, $uri, true); + + $this->assertTrue(isset($params)); + $this->assertEquals($expectedParams, $params); + } + + #[DataProvider('extractStrictModeNotMatchProvider')] + public function testExtractStrictModeNotMatch(string $template, string $uri) + { + $service = $this->service(); + $actual = $service->extract($template, $uri, true); + + $this->assertFalse(isset($actual)); + } +} diff --git a/test/fixtures/README.md b/tests/fixtures/README.md similarity index 100% rename from test/fixtures/README.md rename to tests/fixtures/README.md diff --git a/test/fixtures/extended-tests.json b/tests/fixtures/extended-tests.json similarity index 100% rename from test/fixtures/extended-tests.json rename to tests/fixtures/extended-tests.json diff --git a/test/fixtures/json2xml.xslt b/tests/fixtures/json2xml.xslt similarity index 100% rename from test/fixtures/json2xml.xslt rename to tests/fixtures/json2xml.xslt diff --git a/test/fixtures/negative-tests.json b/tests/fixtures/negative-tests.json similarity index 100% rename from test/fixtures/negative-tests.json rename to tests/fixtures/negative-tests.json diff --git a/test/fixtures/spec-examples-by-section.json b/tests/fixtures/spec-examples-by-section.json similarity index 100% rename from test/fixtures/spec-examples-by-section.json rename to tests/fixtures/spec-examples-by-section.json diff --git a/test/fixtures/spec-examples.json b/tests/fixtures/spec-examples.json similarity index 100% rename from test/fixtures/spec-examples.json rename to tests/fixtures/spec-examples.json diff --git a/test/fixtures/transform-json-tests.xslt b/tests/fixtures/transform-json-tests.xslt similarity index 100% rename from test/fixtures/transform-json-tests.xslt rename to tests/fixtures/transform-json-tests.xslt