diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..45a28b4
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,30 @@
+name: MAIN
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ tests-php8:
+ runs-on: ubuntu-20.04
+
+ strategy:
+ fail-fast: true
+ matrix:
+ php-versions: ['8.1', '8.2', '8.3']
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-versions }}
+
+ - name: Install dependencies
+ run: composer require -W phpunit/phpunit
+
+ - name: Run tests
+ run: composer run-script test
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..116f35f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+vendor
+composer.lock
+.idea
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4fc5ba5
--- /dev/null
+++ b/README.md
@@ -0,0 +1,208 @@
+# PHP URI Template
+
+This is a URI Template implementation in PHP based on [RFC 6570 URI Template](http://tools.ietf.org/html/rfc6570). In addition to URI expansion, it also supports URI extraction.
+
+* This repository is strictly typed, cleaner version of [rize/UriTemplate](https://github.com/rize/UriTemplate)
+* To ensure that the package functions properly, tests are duplicated exactly as they are.
+
+## Installation
+
+Installation via `composer`:
+
+```
+composer require ozdemirrulass/uri-template
+```
+
+
+## Usage
+
+### Expansion
+
+A very simple usage (string expansion).
+
+```php
+expand('/{username}/profile', ['username' => 'john']);
+
+>> '/john/profile'
+```
+
+`Ozdemirrulass\UriTemplate` supports all `Expression Types` and `Levels` specified by RFC6570.
+
+```php
+expand('/search/{term:1}/{term}/{?q*,limit}', [
+ 'term' => 'john',
+ 'q' => ['a', 'b'],
+ 'limit' => 10,
+])
+
+>> '/search/j/john/?q=a&q=b&limit=10'
+```
+
+#### `/` Path segment expansion
+
+```php
+expand('http://{host}{/segments*}/{file}{.extensions*}', [
+ 'host' => 'www.host.com',
+ 'segments' => ['path', 'to', 'a'],
+ 'file' => 'file',
+ 'extensions' => ['x', 'y'],
+]);
+
+>> 'http://www.host.com/path/to/a/file.x.y'
+```
+
+`Ozdemirrulass\UriTemplate` accepts `base-uri` as a 1st argument and `default params` as a 2nd argument. This is very useful when you're working with API endpoint.
+
+Take a look at real world example.
+
+```php
+ 1.1]);
+$uri->expand('/statuses/show/{id}.json', ['id' => '210462857140252672']);
+
+>> https://api.twitter.com/1.1/statuses/show/210462857140252672.json
+```
+
+### Extraction
+
+It also supports URI Extraction (extract all variables from URI). Let's take a look at the example.
+
+```php
+ 1.1]);
+
+$params = $uri->extract('/search/{term:1}/{term}/{?q*,limit}', '/search/j/john/?q=a&q=b&limit=10');
+
+>> print_r($params);
+(
+ [term:1] => j
+ [term] => john
+ [q] => Array
+ (
+ [0] => a
+ [1] => b
+ )
+
+ [limit] => 10
+)
+```
+
+Note that in the example above, result returned by `extract` method has an extra keys named `term:1` for `prefix` modifier. This key was added just for our convenience to access prefix data.
+
+#### `strict` mode
+
+```php
+extract($template, $uri, $strict = false)
+```
+
+Normally `extract` method will try to extract vars from a uri even if it's partially matched. For example
+
+```php
+extract('/{?a,b}', '/?a=1')
+
+>> print_r($params);
+(
+ [a] => 1
+ [b] => null
+)
+```
+
+With `strict mode`, it will allow you to extract uri only when variables in template are fully matched with given uri.
+
+Which is useful when you want to determine whether the given uri is matched against your template or not (in case you want to use it as routing service).
+
+```php
+extract('/{?a,b}', '/?a=1', true);
+
+>>> null
+
+// Now we give `b` some value
+$params = $uri->extract('/{?a,b}', '/?a=1&b=2', true);
+
+>>> print_r($params)
+(
+ [a] => 1
+ [b] => 2
+)
+```
+
+#### Array modifier `%`
+
+By default, RFC 6570 only has 2 types of operators `:` and `*`. This `%` array operator was added to the library because current spec can't handle array style query e.g. `list[]=a` or `key[user]=john`.
+
+Example usage for `%` modifier
+
+```php
+expand('{?list%,keys%}', [
+ 'list' => [
+ 'a', 'b',
+ ),
+ 'keys' => [
+ 'a' => 1,
+ 'b' => 2,
+ ),
+]);
+
+// '?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'
+
+// [] get encoded to %5B%5D i.e. '?list[]=a&list[]=b&keys[a]=1&keys[b]=2'
+$params = $uri->extract('{?list%,keys%}', '?list%5B%5D=a&list%5B%5D=b&keys%5Ba%5D=1&keys%5Bb%5D=2', )
+
+>> print_r($params);
+(
+ [list] => Array
+ (
+ [0] => a
+ [1] => b
+ )
+
+ [keys] => Array
+ (
+ [a] => 1
+ [b] => 2
+ )
+)
+```
+
+
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..6a78b20
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,28 @@
+{
+ "name": "ozdemirrulass/uri-template",
+ "description": "Enhanced PHP URI Template (RFC 6570) supports both expansion & extraction",
+ "keywords": ["URI", "Template", "RFC 6570"],
+ "type": "library",
+ "license": "MIT",
+ "autoload": {
+ "psr-4": {
+ "Ozdemirrulass\\": "src/Ozdemirrulass/"
+ }
+ },
+ "authors": [
+ {
+ "name": "ozdemirrulass",
+ "email": "ozdemirrulass@gmail.com"
+ }
+ ],
+ "scripts": {
+ "test": "vendor/bin/phpunit test/"
+ },
+ "minimum-stability": "stable",
+ "require": {
+ "php": "^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.2@dev"
+ }
+}
diff --git a/src/Ozdemirrulass/UriTemplate.php b/src/Ozdemirrulass/UriTemplate.php
new file mode 100644
index 0000000..3364da8
--- /dev/null
+++ b/src/Ozdemirrulass/UriTemplate.php
@@ -0,0 +1,61 @@
+params;
+ $uri = $this->base_uri.$uri;
+ $result = array();
+
+ if ((strpos($uri, '{')) === false) {
+ return $uri;
+ }
+
+ $parser = $this->parser;
+ $nodes = $parser->parse($uri);
+
+ foreach ($nodes as $node) {
+ $result[] = $node->expand($params);
+ }
+
+ return implode('', $result);
+ }
+
+ public function extract(string $template, string $uri, bool $strict = false)
+ {
+ $params = array();
+ $nodes = $this->parser->parse($template);
+
+ foreach ($nodes as $node) {
+ if ($strict && !strlen((string)$uri)) {
+ return null;
+ }
+
+ $match = $node->match($this->parser, $uri, $params, $strict);
+
+ list($uri, $params) = $match;
+ }
+
+ if ($strict && strlen((string)$uri)) {
+ return null;
+ }
+
+ return $params;
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/Ozdemirrulass/UriTemplate/Node/Abstraction.php b/src/Ozdemirrulass/UriTemplate/Node/Abstraction.php
new file mode 100644
index 0000000..67ca084
--- /dev/null
+++ b/src/Ozdemirrulass/UriTemplate/Node/Abstraction.php
@@ -0,0 +1,36 @@
+token;
+ }
+
+ public function match(Parser $parser, string $uri, array $params = array(), bool $strict = false): ?array
+ {
+ $length = strlen($this->token);
+ if (substr($uri, 0, $length) === $this->token) {
+ $uri = substr($uri, $length);
+ } elseif ($strict) {
+ return null;
+ }
+
+ return array($uri, $params);
+ }
+
+ public function getToken(): string
+ {
+ return $this->token;
+ }
+}
diff --git a/src/Ozdemirrulass/UriTemplate/Node/Expression.php b/src/Ozdemirrulass/UriTemplate/Node/Expression.php
new file mode 100644
index 0000000..db5867a
--- /dev/null
+++ b/src/Ozdemirrulass/UriTemplate/Node/Expression.php
@@ -0,0 +1,106 @@
+operator;
+ }
+
+ public function getVariables(): ?array
+ {
+ return $this->variables;
+ }
+
+ public function getForwardLookupSeparator(): ?string
+ {
+ return $this->forwardLookupSeparator;
+ }
+
+ public function setForwardLookupSeparator(string $forwardLookupSeparator): void
+ {
+ $this->forwardLookupSeparator = $forwardLookupSeparator;
+ }
+
+ public function expand(array $params = array()): ?string
+ {
+ $data = array();
+ $op = $this->operator;
+
+ foreach ($this->variables as $var) {
+ $val = $op->expand($var, $params);
+
+ if (!is_null($val)) {
+ $data[] = $val;
+ }
+ }
+
+ return $data ? $op->first.implode($op->sep, $data) : null;
+ }
+
+ public function match(Parser $parser, string $uri, array $params = array(), bool $strict = false): ?array
+ {
+ $op = $this->operator;
+
+ if ($op->id && isset($uri[0]) && $uri[0] !== $op->id) {
+ return array($uri, $params);
+ }
+
+ if ($op->id) {
+ $uri = substr($uri, 1);
+ }
+
+ foreach ($this->sortVariables($this->variables) as $var) {
+ $regex = '#'.$op->toRegex($var).'#';
+ $val = null;
+
+ $remainingUri = '';
+ $preparedUri = $uri;
+ if ($this->forwardLookupSeparator) {
+ $lastOccurrenceOfSeparator = stripos($uri, $this->forwardLookupSeparator);
+ $preparedUri = substr($uri, 0, $lastOccurrenceOfSeparator);
+ $remainingUri = substr($uri, $lastOccurrenceOfSeparator);
+ }
+
+ if (preg_match($regex, $preparedUri, $match)) {
+ $preparedUri = preg_replace($regex, '', $preparedUri, $limit = 1);
+ $val = $op->extract($var, $match[0]);
+ } elseif ($strict) {
+ return null;
+ }
+
+ $uri = $preparedUri.$remainingUri;
+
+ $params[$var->getToken()] = $val;
+ }
+
+ return array($uri, $params);
+ }
+
+ protected function sortVariables(array $vars): array
+ {
+ usort($vars, function ($a, $b) {
+ return $a->options['modifier'] >= $b->options['modifier'] ? 1 : -1;
+ });
+
+ return $vars;
+ }
+}
diff --git a/src/Ozdemirrulass/UriTemplate/Node/Literal.php b/src/Ozdemirrulass/UriTemplate/Node/Literal.php
new file mode 100644
index 0000000..992f52f
--- /dev/null
+++ b/src/Ozdemirrulass/UriTemplate/Node/Literal.php
@@ -0,0 +1,10 @@
+ null,
+ 'value' => null,
+ );
+
+ public function __construct(string $token, array $options = array())
+ {
+ parent::__construct($token);
+ $this->options = $options + $this->options;
+
+ $name = $token;
+ if ($options['modifier'] === ':') {
+ $name = substr($name, 0, strpos($name, $options['modifier']));
+ }
+
+ $this->name = $name;
+ }
+}
diff --git a/src/Ozdemirrulass/UriTemplate/Operator/Abstraction.php b/src/Ozdemirrulass/UriTemplate/Operator/Abstraction.php
new file mode 100644
index 0000000..a696b14
--- /dev/null
+++ b/src/Ozdemirrulass/UriTemplate/Operator/Abstraction.php
@@ -0,0 +1,284 @@
+ [
+ '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' => '#',
+ ],
+ ];
+
+ public const RESERVED_CHARS = [
+ '%3A' => ':',
+ '%2F' => '/',
+ '%3F' => '?',
+ '%23' => '#',
+ '%5B' => '[',
+ '%5D' => ']',
+ '%40' => '@',
+ '%21' => '!',
+ '%24' => '$',
+ '%26' => '&',
+ '%27' => "'",
+ '%28' => '(',
+ '%29' => ')',
+ '%2A' => '*',
+ '%2B' => '+',
+ '%2C' => ',',
+ '%3B' => ';',
+ '%3D' => '=',
+ ];
+ protected const PATH_REGEX = '(?:[a-zA-Z0-9\-\._~!\$&\'\(\)\*\+,;=%:@]+|%(?![A-Fa-f0-9]{2}))';
+ protected const QUERY_REGEX = '(?:[a-zA-Z0-9\-\._~!\$\'\(\)\*\+,;=%:@\/\?]+|%(?![A-Fa-f0-9]{2}))';
+
+ public function __construct(
+ public string $id,
+ public bool $named,
+ public string $sep,
+ public string $empty,
+ public bool $reserved,
+ public ?int $start,
+ public ?string $first
+ ) {
+ }
+
+ abstract public function toRegex(Variable $var);
+
+ public function expand(Variable $var, array $params = array()): ?string
+ {
+ $options = $var->options;
+ $name = $var->name;
+ $is_explode = in_array($options['modifier'], array('*', '%'));
+
+ if (!isset($params[$name])) {
+ return null;
+ }
+
+ $val = $params[$name];
+
+ if (!is_array($val)) {
+ return $this->expandString($var, $val);
+ } elseif (!$is_explode) {
+ return $this->expandNonExplode($var, $val);
+ } else {
+ return $this->expandExplode($var, $val);
+ }
+ }
+
+ public function expandExplode(Variable $var, array $val): ?string
+ {
+ if (empty($val)) {
+ return null;
+ }
+
+ return $this->encode($var, $val);
+ }
+
+ public function expandString(Variable $var, $val): string
+ {
+ $val = (string)$val;
+ $options = $var->options;
+
+ if ($options['modifier'] === ':') {
+ $val = substr($val, 0, (int)$options['value']);
+ }
+
+ return $this->encode($var, $val);
+ }
+
+ public function expandNonExplode(Variable $var, array $val): ?string
+ {
+ if (empty($val)) {
+ return null;
+ }
+
+ return $this->encode($var, $val);
+ }
+
+
+ public function encode(Variable $var, $values): string
+ {
+ $values = (array)$values;
+ $list = isset($values[0]);
+ $reserved = $this->reserved;
+ $maps = static::RESERVED_CHARS;
+ $sep = $this->sep;
+ $assoc_sep = '=';
+
+ if ($var->options['modifier'] !== '*') {
+ $assoc_sep = $sep = ',';
+ }
+
+ array_walk($values, function (&$v, $k) use ($assoc_sep, $reserved, $list, $maps) {
+ $encoded = rawurlencode($v);
+
+ if (!$list) {
+ $encoded = rawurlencode($k).$assoc_sep.$encoded;
+ }
+
+ if (!$reserved) {
+ $v = $encoded;
+ } else {
+ $v = str_replace(
+ array_keys($maps),
+ $maps,
+ $encoded
+ );
+ }
+ });
+
+ return implode($sep, $values);
+ }
+
+ public function decode($values)
+ {
+ $single = !is_array($values);
+ $values = (array)$values;
+
+ array_walk($values, function (&$v) {
+ $v = rawurldecode($v);
+ });
+
+ return $single ? reset($values) : $values;
+ }
+
+ public function extract(Variable $var, $data)
+ {
+ $value = $data;
+ $vals = array_filter(explode($this->sep, $data));
+ $options = $var->options;
+
+ switch ($options['modifier']) {
+ case '*':
+ $data = array();
+ foreach ($vals as $val) {
+ if (str_contains($val, '=')) {
+ list($k, $v) = explode('=', $val);
+ $data[$k] = $v;
+ } else {
+ $data[] = $val;
+ }
+ }
+
+ break;
+ case ':':
+ break;
+ default:
+ $data = str_contains($data, $this->sep) ? $vals : $value;
+ }
+
+ return $this->decode($data);
+ }
+
+ public static function createById($id)
+ {
+ if (!isset(static::TYPES[$id])) {
+ throw new Exception("Invalid operator [$id]");
+ }
+
+ if (isset(static::$loaded[$id])) {
+ return static::$loaded[$id];
+ }
+
+ $op = static::TYPES[$id];
+ $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): bool
+ {
+ return isset(static::TYPES[$id]);
+ }
+
+ /**
+ * @return string
+ */
+ protected function getRegex(): string
+ {
+ return match ($this->id) {
+ '?', '&', '#' => self::QUERY_REGEX,
+ default => self::PATH_REGEX,
+ };
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/Ozdemirrulass/UriTemplate/Operator/Named.php b/src/Ozdemirrulass/UriTemplate/Operator/Named.php
new file mode 100644
index 0000000..9ecdf35
--- /dev/null
+++ b/src/Ozdemirrulass/UriTemplate/Operator/Named.php
@@ -0,0 +1,163 @@
+name;
+ $value = $this->getRegex();
+ $options = $var->options;
+
+ if ($options['modifier']) {
+ switch ($options['modifier']) {
+ case '*':
+ $regex = "{$name}+=(?:{$value}+(?:{$this->sep}{$name}+={$value}*)*)"
+ ."|{$value}+=(?:{$value}+(?:{$this->sep}{$value}+={$value}*)*)";
+ break;
+ case ':':
+ $regex = "{$value}\{0,{$options['value']}\}";
+ break;
+
+ case '%':
+ $name = $name.'+(?:%5B|\[)[^=]*=';
+ $regex = "{$name}(?:{$value}+(?:{$this->sep}{$name}{$value}*)*)";
+ break;
+ default:
+ throw new \Exception("Unknown modifier `{$options['modifier']}`");
+ }
+ } else {
+ $regex = "{$name}=(?:{$value}+(?:,{$value}+)*)*";
+ }
+
+ return '(?:&)?'.$regex;
+ }
+
+ public function expandString(Variable $var, $val): string
+ {
+ $val = (string)$val;
+ $options = $var->options;
+ $result = $this->encode($var, $var->name);
+
+ if ($val === '') {
+ return $result.$this->empty;
+ } else {
+ $result .= '=';
+ }
+
+ if ($options['modifier'] === ':') {
+ $val = mb_substr($val, 0, (int)$options['value']);
+ }
+
+ return $result.$this->encode($var, $val);
+ }
+
+ public function expandNonExplode(Variable $var, array $val): ?string
+ {
+ if (empty($val)) {
+ return null;
+ }
+
+ $result = $this->encode($var, $var->name);
+
+ $result .= '=';
+
+ return $result.$this->encode($var, $val);
+ }
+
+ public function expandExplode(Variable $var, array $val): ?string
+ {
+ if (empty($val)) {
+ return null;
+ }
+ $list = isset($val[0]);
+ $data = array();
+ foreach ($val as $k => $v) {
+ $key = $list ? $var->name : $k;
+ if ($list) {
+ $data[$key][] = $v;
+ } else {
+ $data[$key] = $v;
+ }
+ }
+
+ if (!$list and $var->options['modifier'] === '%') {
+ $data = array($var->name => $data);
+ }
+
+ return $this->encodeExplodeVars($var, $data);
+ }
+
+ public function extract(Variable $var, $data)
+ {
+ if ($data[0] === '&') {
+ $data = substr($data, 1);
+ }
+
+ $value = $data;
+ $vals = explode($this->sep, $data);
+ $options = $var->options;
+
+ switch ($options['modifier']) {
+ case '%':
+ parse_str($data, $query);
+
+ return $query[$var->name];
+
+ case '*':
+ $data = array();
+
+ foreach ($vals as $val) {
+ list($k, $v) = explode('=', $val);
+
+ // 2
+ if ($k === $var->getToken()) {
+ $data[] = $v;
+ } // 4
+ else {
+ $data[$k] = $v;
+ }
+ }
+
+ break;
+ case ':':
+ break;
+ default:
+ $value = str_replace($var->getToken().'=', '', $value);
+ $data = explode(',', $value);
+
+ if (sizeof($data) === 1) {
+ $data = current($data);
+ }
+ }
+
+ return $this->decode($data);
+ }
+
+ public function encodeExplodeVars(Variable $var, $data): array|string|null
+ {
+ $query = http_build_query($data, '', $this->sep);
+ $query = str_replace('+', '%20', $query);
+
+ if ($var->options['modifier'] === '%') {
+ $query = preg_replace('#%5B\d+%5D#', '%5B%5D', $query);
+ } else {
+ $query = preg_replace('#%5B\d+%5D#', '', $query);
+ }
+
+ if ($this->reserved) {
+ $query = str_replace(
+ array_keys(static::RESERVED_CHARS),
+ static::RESERVED_CHARS,
+ $query
+ );
+ }
+
+ return $query;
+ }
+}
diff --git a/src/Ozdemirrulass/UriTemplate/Operator/UnNamed.php b/src/Ozdemirrulass/UriTemplate/Operator/UnNamed.php
new file mode 100644
index 0000000..ddb9655
--- /dev/null
+++ b/src/Ozdemirrulass/UriTemplate/Operator/UnNamed.php
@@ -0,0 +1,35 @@
+getRegex();
+ $options = $var->options;
+
+ if ($options['modifier']) {
+ switch ($options['modifier']) {
+ case '*':
+ $regex = "{$value}+(?:{$this->sep}{$value}+)*";
+ break;
+ case ':':
+ $regex = $value.'{0,'.$options['value'].'}';
+ break;
+ case '%':
+ throw new \Exception("% (array) modifier only works with Named type operators e.g. ;,?,&");
+ default:
+ throw new \Exception("Unknown modifier `{$options['modifier']}`");
+ }
+ } else {
+ $regex = "{$value}*(?:,{$value}+)*";
+ }
+
+ return $regex;
+ }
+}
\ No newline at end of file
diff --git a/src/Ozdemirrulass/UriTemplate/Parser.php b/src/Ozdemirrulass/UriTemplate/Parser.php
new file mode 100644
index 0000000..94bac56
--- /dev/null
+++ b/src/Ozdemirrulass/UriTemplate/Parser.php
@@ -0,0 +1,141 @@
+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 ($previousNode instanceof Expression && $previousNode->getOperator() instanceof UnNamed) {
+ $previousNode->setForwardLookupSeparator($node->getOperator()->id);
+ }
+ }
+ }
+
+ $nodes[] = $node;
+ }
+
+ return $nodes;
+ }
+
+ private function createNode(string $token): Literal|Expression
+ {
+ if ($token[0] !== '{') {
+ $node = $this->createLiteralNode($token);
+ } else {
+ $node = $this->parseExpression(substr($token, 1, -1));
+ }
+
+ return $node;
+ }
+
+ protected function createLiteralNode(string $token): Literal
+ {
+ return new Literal($token);
+ }
+
+ protected function parseExpression(string $expression): Node\Expression
+ {
+ $token = $expression;
+ $prefix = $token[0];
+
+ if (!Operator\Abstraction::isValid($prefix)) {
+ if (!preg_match('#'.self::REGEX_VARNAME.'#', $token)) {
+ throw new \Exception("Invalid operator [$prefix] found at {$token}");
+ }
+
+ $prefix = null;
+ }
+
+ if ($prefix) {
+ $token = substr($token, 1);
+ }
+
+ $vars = array();
+ foreach (explode(',', $token) as $var) {
+ $vars[] = $this->parseVariable($var);
+ }
+
+ return $this->createExpressionNode(
+ $token,
+ $this->createOperatorNode((string)$prefix),
+ $vars
+ );
+ }
+
+ protected function createOperatorNode(string $token)
+ {
+ return Operator\Abstraction::createById($token);
+ }
+
+ protected function createExpressionNode(
+ $token,
+ Operator\Abstraction $operator = null,
+ array $vars = array()
+ ): Node\Expression {
+ return new Node\Expression($token, $operator, $vars);
+ }
+
+ protected function parseVariable($var): Variable
+ {
+ $var = trim($var);
+ $val = null;
+ $modifier = null;
+
+ if (str_contains($var, ':')) {
+ $modifier = ':';
+ list($varname, $val) = explode(':', $var);
+
+ if (!is_numeric($val)) {
+ throw new \Exception("Value for `:` modifier must be numeric value [$varname:$val]");
+ }
+ }
+
+ switch ($last = substr($var, -1)) {
+ case '*':
+ case '%':
+
+ if ($modifier) {
+ throw new \Exception("Multiple modifiers per variable are not allowed [$var]");
+ }
+
+ $modifier = $last;
+ $var = substr($var, 0, -1);
+ break;
+ }
+
+ return $this->createVariableNode(
+ $var,
+ array(
+ 'modifier' => $modifier,
+ 'value' => $val,
+ )
+ );
+ }
+
+ protected function createVariableNode(string $token, array $options = array()): Variable
+ {
+ return new Variable($token, $options);
+ }
+
+}
\ No newline at end of file
diff --git a/test/Rize/Uri/Node/ParserTest.php b/test/Rize/Uri/Node/ParserTest.php
new file mode 100644
index 0000000..58a5f24
--- /dev/null
+++ b/test/Rize/Uri/Node/ParserTest.php
@@ -0,0 +1,131 @@
+ ':',
+ '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
new file mode 100644
index 0000000..d97b93a
--- /dev/null
+++ b/test/Rize/UriTemplateTest.php
@@ -0,0 +1,688 @@
+ 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/test/fixtures/README.md b/test/fixtures/README.md
new file mode 100644
index 0000000..3eb519d
--- /dev/null
+++ b/test/fixtures/README.md
@@ -0,0 +1,90 @@
+
+URI Template Tests
+==================
+
+This is a set of tests for implementations of
+[RFC6570](http://tools.ietf.org/html/rfc6570) - URI Template. It is designed
+to be reused by any implementation, to improve interoperability and
+implementation quality.
+
+If your project uses Git for version control, you can make uritemplate-tests into a [submodule](http://help.github.com/submodules/).
+
+Test Format
+-----------
+
+Each test file is a [JSON](http://tools.ietf.org/html/RFC6627) document
+containing an object whose properties are groups of related tests.
+Alternatively, all tests are available in XML as well, with the XML files
+being generated by transform-json-tests.xslt which uses json2xml.xslt as a
+general-purpose JSON-to-XML parsing library.
+
+Each group, in turn, is an object with three children:
+
+* level - the level of the tests covered, as per the RFC (optional; if absent,
+ assume level 4).
+* variables - an object representing the variables that are available to the
+ tests in the suite
+* testcases - a list of testcases, where each case is a two-member list, the
+ first being the template, the second being the result of expanding the
+ template with the provided variables.
+
+Note that the result string can be a few different things:
+
+* string - if the second member is a string, the result of expansion is
+ expected to match it, character-for-character.
+* list - if the second member is a list of strings, the result of expansion
+ is expected to match one of them; this allows for templates that can
+ expand into different, equally-acceptable URIs.
+* false - if the second member is boolean false, expansion is expected to
+ fail (i.e., the template was invalid).
+
+For example:
+
+ {
+ "Level 1 Examples" :
+ {
+ "level": 1,
+ "variables": {
+ "var" : "value",
+ "hello" : "Hello World!"
+ },
+ "testcases" : [
+ ["{var}", "value"],
+ ["{hello}", "Hello%20World%21"]
+ ]
+ }
+ }
+
+
+Tests Included
+--------------
+
+The following test files are included:
+
+* spec-examples.json - The complete set of example templates from the RFC
+* spec-examples-by-section.json - The examples, section by section
+* extended-tests.json - more complex test cases
+* negative-tests.json - invalid templates
+
+For all these test files, XML versions with the names *.xml can be
+generated with the transform-json-tests.xslt XSLT stylesheet. The XSLT
+contains the names of the above test files as a parameter, and can be
+started with any XML as input (i.e., the XML input is ignored).
+
+License
+-------
+
+ Copyright 2011-2012 The Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
diff --git a/test/fixtures/extended-tests.json b/test/fixtures/extended-tests.json
new file mode 100644
index 0000000..8ae8134
--- /dev/null
+++ b/test/fixtures/extended-tests.json
@@ -0,0 +1,118 @@
+{
+ "Additional Examples 1":{
+ "level":4,
+ "variables":{
+ "id" : "person",
+ "token" : "12345",
+ "fields" : ["id", "name", "picture"],
+ "format" : "json",
+ "q" : "URI Templates",
+ "page" : "5",
+ "lang" : "en",
+ "geocode" : ["37.76","-122.427"],
+ "first_name" : "John",
+ "last.name" : "Doe",
+ "Some%20Thing" : "foo",
+ "number" : 6,
+ "long" : 37.76,
+ "lat" : -122.427,
+ "group_id" : "12345",
+ "query" : "PREFIX dc: SELECT ?book ?who WHERE { ?book dc:creator ?who }",
+ "uri" : "http://example.org/?uri=http%3A%2F%2Fexample.org%2F",
+ "word" : "drücken",
+ "Stra%C3%9Fe" : "Grüner Weg",
+ "random" : "šö䟜ñꀣ¥‡ÑÒÓÔÕÖ×ØÙÚàáâãäåæçÿ",
+ "assoc_special_chars" :
+ { "šö䟜ñꀣ¥‡ÑÒÓÔÕ" : "Ö×ØÙÚàáâãäåæçÿ" }
+ },
+ "testcases":[
+
+ [ "{/id*}" , "/person" ],
+ [ "{/id*}{?fields,first_name,last.name,token}" , [
+ "/person?fields=id,name,picture&first_name=John&last.name=Doe&token=12345",
+ "/person?fields=id,picture,name&first_name=John&last.name=Doe&token=12345",
+ "/person?fields=picture,name,id&first_name=John&last.name=Doe&token=12345",
+ "/person?fields=picture,id,name&first_name=John&last.name=Doe&token=12345",
+ "/person?fields=name,picture,id&first_name=John&last.name=Doe&token=12345",
+ "/person?fields=name,id,picture&first_name=John&last.name=Doe&token=12345"]
+ ],
+ ["/search.{format}{?q,geocode,lang,locale,page,result_type}",
+ [ "/search.json?q=URI%20Templates&geocode=37.76,-122.427&lang=en&page=5",
+ "/search.json?q=URI%20Templates&geocode=-122.427,37.76&lang=en&page=5"]
+ ],
+ ["/test{/Some%20Thing}", "/test/foo" ],
+ ["/set{?number}", "/set?number=6"],
+ ["/loc{?long,lat}" , "/loc?long=37.76&lat=-122.427"],
+ ["/base{/group_id,first_name}/pages{/page,lang}{?format,q}","/base/12345/John/pages/5/en?format=json&q=URI%20Templates"],
+ ["/sparql{?query}", "/sparql?query=PREFIX%20dc%3A%20%3Chttp%3A%2F%2Fpurl.org%2Fdc%2Felements%2F1.1%2F%3E%20SELECT%20%3Fbook%20%3Fwho%20WHERE%20%7B%20%3Fbook%20dc%3Acreator%20%3Fwho%20%7D"],
+ ["/go{?uri}", "/go?uri=http%3A%2F%2Fexample.org%2F%3Furi%3Dhttp%253A%252F%252Fexample.org%252F"],
+ ["/service{?word}", "/service?word=dr%C3%BCcken"],
+ ["/lookup{?Stra%C3%9Fe}", "/lookup?Stra%25C3%259Fe=Gr%C3%BCner%20Weg"],
+ ["{random}" , "%C5%A1%C3%B6%C3%A4%C5%B8%C5%93%C3%B1%C3%AA%E2%82%AC%C2%A3%C2%A5%E2%80%A1%C3%91%C3%92%C3%93%C3%94%C3%95%C3%96%C3%97%C3%98%C3%99%C3%9A%C3%A0%C3%A1%C3%A2%C3%A3%C3%A4%C3%A5%C3%A6%C3%A7%C3%BF"],
+ ["{?assoc_special_chars*}", "?%C5%A1%C3%B6%C3%A4%C5%B8%C5%93%C3%B1%C3%AA%E2%82%AC%C2%A3%C2%A5%E2%80%A1%C3%91%C3%92%C3%93%C3%94%C3%95=%C3%96%C3%97%C3%98%C3%99%C3%9A%C3%A0%C3%A1%C3%A2%C3%A3%C3%A4%C3%A5%C3%A6%C3%A7%C3%BF"]
+ ]
+ },
+ "Additional Examples 2":{
+ "level":4,
+ "variables":{
+ "id" : ["person","albums"],
+ "token" : "12345",
+ "fields" : ["id", "name", "picture"],
+ "format" : "atom",
+ "q" : "URI Templates",
+ "page" : "10",
+ "start" : "5",
+ "lang" : "en",
+ "geocode" : ["37.76","-122.427"]
+ },
+ "testcases":[
+
+ [ "{/id*}" , ["/person/albums","/albums/person"] ],
+ [ "{/id*}{?fields,token}" , [
+ "/person/albums?fields=id,name,picture&token=12345",
+ "/person/albums?fields=id,picture,name&token=12345",
+ "/person/albums?fields=picture,name,id&token=12345",
+ "/person/albums?fields=picture,id,name&token=12345",
+ "/person/albums?fields=name,picture,id&token=12345",
+ "/person/albums?fields=name,id,picture&token=12345",
+ "/albums/person?fields=id,name,picture&token=12345",
+ "/albums/person?fields=id,picture,name&token=12345",
+ "/albums/person?fields=picture,name,id&token=12345",
+ "/albums/person?fields=picture,id,name&token=12345",
+ "/albums/person?fields=name,picture,id&token=12345",
+ "/albums/person?fields=name,id,picture&token=12345"]
+ ]
+ ]
+ },
+ "Additional Examples 3: Empty Variables":{
+ "variables" : {
+ "empty_list" : [],
+ "empty_assoc" : {}
+ },
+ "testcases":[
+ [ "{/empty_list}", [ "" ] ],
+ [ "{/empty_list*}", [ "" ] ],
+ [ "{?empty_list}", [ ""] ],
+ [ "{?empty_list*}", [ "" ] ],
+ [ "{?empty_assoc}", [ "" ] ],
+ [ "{?empty_assoc*}", [ "" ] ]
+ ]
+ },
+ "Additional Examples 4: Numeric Keys":{
+ "variables" : {
+ "42" : "The Answer to the Ultimate Question of Life, the Universe, and Everything",
+ "1337" : ["leet", "as","it", "can","be"],
+ "german" : {
+ "11": "elf",
+ "12": "zwölf"
+ }
+ },
+ "testcases":[
+ [ "{42}", "The%20Answer%20to%20the%20Ultimate%20Question%20of%20Life%2C%20the%20Universe%2C%20and%20Everything"],
+ [ "{?42}", "?42=The%20Answer%20to%20the%20Ultimate%20Question%20of%20Life%2C%20the%20Universe%2C%20and%20Everything"],
+ [ "{1337}", "leet,as,it,can,be"],
+ [ "{?1337*}", "?1337=leet&1337=as&1337=it&1337=can&1337=be"],
+ [ "{?german*}", [ "?11=elf&12=zw%C3%B6lf", "?12=zw%C3%B6lf&11=elf"] ]
+ ]
+ }
+}
diff --git a/test/fixtures/json2xml.xslt b/test/fixtures/json2xml.xslt
new file mode 100644
index 0000000..59b3548
--- /dev/null
+++ b/test/fixtures/json2xml.xslt
@@ -0,0 +1,201 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ \b
+
+
+
+
+
+
+
+
+
+
+ \v
+
+
+
+
+ \f
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/fixtures/negative-tests.json b/test/fixtures/negative-tests.json
new file mode 100644
index 0000000..552a6bf
--- /dev/null
+++ b/test/fixtures/negative-tests.json
@@ -0,0 +1,57 @@
+{
+ "Failure Tests":{
+ "level":4,
+ "variables":{
+ "id" : "thing",
+ "var" : "value",
+ "hello" : "Hello World!",
+ "with space" : "fail",
+ " leading_space" : "Hi!",
+ "trailing_space " : "Bye!",
+ "empty" : "",
+ "path" : "/foo/bar",
+ "x" : "1024",
+ "y" : "768",
+ "list" : ["red", "green", "blue"],
+ "keys" : { "semi" : ";", "dot" : ".", "comma" : ","},
+ "example" : "red",
+ "searchTerms" : "uri templates",
+ "~thing" : "some-user",
+ "default-graph-uri" : ["http://www.example/book/","http://www.example/papers/"],
+ "query" : "PREFIX dc: SELECT ?book ?who WHERE { ?book dc:creator ?who }"
+
+ },
+ "testcases":[
+ [ "{/id*", false ],
+ [ "/id*}", false ],
+ [ "{/?id}", false ],
+ [ "{var:prefix}", false ],
+ [ "{hello:2*}", false ] ,
+ [ "{??hello}", false ] ,
+ [ "{!hello}", false ] ,
+ [ "{with space}", false],
+ [ "{ leading_space}", false],
+ [ "{trailing_space }", false],
+ [ "{=path}", false ] ,
+ [ "{$var}", false ],
+ [ "{|var*}", false ],
+ [ "{*keys?}", false ],
+ [ "{?empty=default,var}", false ],
+ [ "{var}{-prefix|/-/|var}" , false ],
+ [ "?q={searchTerms}&c={example:color?}" , false ],
+ [ "x{?empty|foo=none}" , false ],
+ [ "/h{#hello+}" , false ],
+ [ "/h#{hello+}" , false ],
+ [ "{keys:1}", false ],
+ [ "{+keys:1}", false ],
+ [ "{;keys:1*}", false ],
+ [ "?{-join|&|var,list}" , false ],
+ [ "/people/{~thing}", false],
+ [ "/{default-graph-uri}", false ],
+ [ "/sparql{?query,default-graph-uri}", false ],
+ [ "/sparql{?query){&default-graph-uri*}", false ],
+ [ "/resolution{?x, y}" , false ]
+
+ ]
+ }
+}
\ No newline at end of file
diff --git a/test/fixtures/spec-examples-by-section.json b/test/fixtures/spec-examples-by-section.json
new file mode 100644
index 0000000..5aef182
--- /dev/null
+++ b/test/fixtures/spec-examples-by-section.json
@@ -0,0 +1,439 @@
+{
+ "3.2.1 Variable Expansion" :
+ {
+ "variables": {
+ "count" : ["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" : ","},
+ "v" : "6",
+ "x" : "1024",
+ "y" : "768",
+ "empty" : "",
+ "empty_keys" : [],
+ "undef" : null
+ },
+ "testcases" : [
+ ["{count}", "one,two,three"],
+ ["{count*}", "one,two,three"],
+ ["{/count}", "/one,two,three"],
+ ["{/count*}", "/one/two/three"],
+ ["{;count}", ";count=one,two,three"],
+ ["{;count*}", ";count=one;count=two;count=three"],
+ ["{?count}", "?count=one,two,three"],
+ ["{?count*}", "?count=one&count=two&count=three"],
+ ["{&count*}", "&count=one&count=two&count=three"]
+ ]
+ },
+ "3.2.2 Simple String Expansion" :
+ {
+ "variables": {
+ "count" : ["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" : ","},
+ "v" : "6",
+ "x" : "1024",
+ "y" : "768",
+ "empty" : "",
+ "empty_keys" : [],
+ "undef" : null
+ },
+ "testcases" : [
+ ["{var}", "value"],
+ ["{hello}", "Hello%20World%21"],
+ ["{half}", "50%25"],
+ ["O{empty}X", "OX"],
+ ["O{undef}X", "OX"],
+ ["{x,y}", "1024,768"],
+ ["{x,hello,y}", "1024,Hello%20World%21,768"],
+ ["?{x,empty}", "?1024,"],
+ ["?{x,undef}", "?1024"],
+ ["?{undef,y}", "?768"],
+ ["{var:3}", "val"],
+ ["{var:30}", "value"],
+ ["{list}", "red,green,blue"],
+ ["{list*}", "red,green,blue"],
+ ["{keys}", [
+ "comma,%2C,dot,.,semi,%3B",
+ "comma,%2C,semi,%3B,dot,.",
+ "dot,.,comma,%2C,semi,%3B",
+ "dot,.,semi,%3B,comma,%2C",
+ "semi,%3B,comma,%2C,dot,.",
+ "semi,%3B,dot,.,comma,%2C"
+ ]],
+ ["{keys*}", [
+ "comma=%2C,dot=.,semi=%3B",
+ "comma=%2C,semi=%3B,dot=.",
+ "dot=.,comma=%2C,semi=%3B",
+ "dot=.,semi=%3B,comma=%2C",
+ "semi=%3B,comma=%2C,dot=.",
+ "semi=%3B,dot=.,comma=%2C"
+ ]]
+ ]
+ },
+ "3.2.3 Reserved Expansion" :
+ {
+ "variables": {
+ "count" : ["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" : ","},
+ "v" : "6",
+ "x" : "1024",
+ "y" : "768",
+ "empty" : "",
+ "empty_keys" : [],
+ "undef" : null
+ },
+ "testcases" : [
+ ["{+var}", "value"],
+ ["{/var,empty}", "/value/"],
+ ["{/var,undef}", "/value"],
+ ["{+hello}", "Hello%20World!"],
+ ["{+half}", "50%25"],
+ ["{base}index", "http%3A%2F%2Fexample.com%2Fhome%2Findex"],
+ ["{+base}index", "http://example.com/home/index"],
+ ["O{+empty}X", "OX"],
+ ["O{+undef}X", "OX"],
+ ["{+path}/here", "/foo/bar/here"],
+ ["{+path:6}/here", "/foo/b/here"],
+ ["here?ref={+path}", "here?ref=/foo/bar"],
+ ["up{+path}{var}/here", "up/foo/barvalue/here"],
+ ["{+x,hello,y}", "1024,Hello%20World!,768"],
+ ["{+path,x}/here", "/foo/bar,1024/here"],
+ ["{+list}", "red,green,blue"],
+ ["{+list*}", "red,green,blue"],
+ ["{+keys}", [
+ "comma,,,dot,.,semi,;",
+ "comma,,,semi,;,dot,.",
+ "dot,.,comma,,,semi,;",
+ "dot,.,semi,;,comma,,",
+ "semi,;,comma,,,dot,.",
+ "semi,;,dot,.,comma,,"
+ ]],
+ ["{+keys*}", [
+ "comma=,,dot=.,semi=;",
+ "comma=,,semi=;,dot=.",
+ "dot=.,comma=,,semi=;",
+ "dot=.,semi=;,comma=,",
+ "semi=;,comma=,,dot=.",
+ "semi=;,dot=.,comma=,"
+ ]]
+ ]
+ },
+ "3.2.4 Fragment Expansion" :
+ {
+ "variables": {
+ "count" : ["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" : ","},
+ "v" : "6",
+ "x" : "1024",
+ "y" : "768",
+ "empty" : "",
+ "empty_keys" : [],
+ "undef" : null
+ },
+ "testcases" : [
+ ["{#var}", "#value"],
+ ["{#hello}", "#Hello%20World!"],
+ ["{#half}", "#50%25"],
+ ["foo{#empty}", "foo#"],
+ ["foo{#undef}", "foo"],
+ ["{#x,hello,y}", "#1024,Hello%20World!,768"],
+ ["{#path,x}/here", "#/foo/bar,1024/here"],
+ ["{#path:6}/here", "#/foo/b/here"],
+ ["{#list}", "#red,green,blue"],
+ ["{#list*}", "#red,green,blue"],
+ ["{#keys}", [
+ "#comma,,,dot,.,semi,;",
+ "#comma,,,semi,;,dot,.",
+ "#dot,.,comma,,,semi,;",
+ "#dot,.,semi,;,comma,,",
+ "#semi,;,comma,,,dot,.",
+ "#semi,;,dot,.,comma,,"
+ ]]
+ ]
+ },
+ "3.2.5 Label Expansion with Dot-Prefix" :
+ {
+ "variables": {
+ "count" : ["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" : ","},
+ "v" : "6",
+ "x" : "1024",
+ "y" : "768",
+ "empty" : "",
+ "empty_keys" : [],
+ "undef" : null
+ },
+ "testcases" : [
+ ["{.who}", ".fred"],
+ ["{.who,who}", ".fred.fred"],
+ ["{.half,who}", ".50%25.fred"],
+ ["www{.dom*}", "www.example.com"],
+ ["X{.var}", "X.value"],
+ ["X{.var:3}", "X.val"],
+ ["X{.empty}", "X."],
+ ["X{.undef}", "X"],
+ ["X{.list}", "X.red,green,blue"],
+ ["X{.list*}", "X.red.green.blue"],
+ ["{#keys}", [
+ "#comma,,,dot,.,semi,;",
+ "#comma,,,semi,;,dot,.",
+ "#dot,.,comma,,,semi,;",
+ "#dot,.,semi,;,comma,,",
+ "#semi,;,comma,,,dot,.",
+ "#semi,;,dot,.,comma,,"
+ ]],
+ ["{#keys*}", [
+ "#comma=,,dot=.,semi=;",
+ "#comma=,,semi=;,dot=.",
+ "#dot=.,comma=,,semi=;",
+ "#dot=.,semi=;,comma=,",
+ "#semi=;,comma=,,dot=.",
+ "#semi=;,dot=.,comma=,"
+ ]],
+ ["X{.empty_keys}", "X"],
+ ["X{.empty_keys*}", "X"]
+ ]
+ },
+ "3.2.6 Path Segment Expansion" :
+ {
+ "variables": {
+ "count" : ["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" : ","},
+ "v" : "6",
+ "x" : "1024",
+ "y" : "768",
+ "empty" : "",
+ "empty_keys" : [],
+ "undef" : null
+ },
+ "testcases" : [
+ ["{/who}", "/fred"],
+ ["{/who,who}", "/fred/fred"],
+ ["{/half,who}", "/50%25/fred"],
+ ["{/who,dub}", "/fred/me%2Ftoo"],
+ ["{/var}", "/value"],
+ ["{/var,empty}", "/value/"],
+ ["{/var,undef}", "/value"],
+ ["{/var,x}/here", "/value/1024/here"],
+ ["{/var:1,var}", "/v/value"],
+ ["{/list}", "/red,green,blue"],
+ ["{/list*}", "/red/green/blue"],
+ ["{/list*,path:4}", "/red/green/blue/%2Ffoo"],
+ ["{/keys}", [
+ "/comma,%2C,dot,.,semi,%3B",
+ "/comma,%2C,semi,%3B,dot,.",
+ "/dot,.,comma,%2C,semi,%3B",
+ "/dot,.,semi,%3B,comma,%2C",
+ "/semi,%3B,comma,%2C,dot,.",
+ "/semi,%3B,dot,.,comma,%2C"
+ ]],
+ ["{/keys*}", [
+ "/comma=%2C/dot=./semi=%3B",
+ "/comma=%2C/semi=%3B/dot=.",
+ "/dot=./comma=%2C/semi=%3B",
+ "/dot=./semi=%3B/comma=%2C",
+ "/semi=%3B/comma=%2C/dot=.",
+ "/semi=%3B/dot=./comma=%2C"
+ ]]
+ ]
+ },
+ "3.2.7 Path-Style Parameter Expansion" :
+ {
+ "variables": {
+ "count" : ["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" : ","},
+ "v" : "6",
+ "x" : "1024",
+ "y" : "768",
+ "empty" : "",
+ "empty_keys" : [],
+ "undef" : null
+ },
+ "testcases" : [
+ ["{;who}", ";who=fred"],
+ ["{;half}", ";half=50%25"],
+ ["{;empty}", ";empty"],
+ ["{;hello:5}", ";hello=Hello"],
+ ["{;v,empty,who}", ";v=6;empty;who=fred"],
+ ["{;v,bar,who}", ";v=6;who=fred"],
+ ["{;x,y}", ";x=1024;y=768"],
+ ["{;x,y,empty}", ";x=1024;y=768;empty"],
+ ["{;x,y,undef}", ";x=1024;y=768"],
+ ["{;list}", ";list=red,green,blue"],
+ ["{;list*}", ";list=red;list=green;list=blue"],
+ ["{;keys}", [
+ ";keys=comma,%2C,dot,.,semi,%3B",
+ ";keys=comma,%2C,semi,%3B,dot,.",
+ ";keys=dot,.,comma,%2C,semi,%3B",
+ ";keys=dot,.,semi,%3B,comma,%2C",
+ ";keys=semi,%3B,comma,%2C,dot,.",
+ ";keys=semi,%3B,dot,.,comma,%2C"
+ ]],
+ ["{;keys*}", [
+ ";comma=%2C;dot=.;semi=%3B",
+ ";comma=%2C;semi=%3B;dot=.",
+ ";dot=.;comma=%2C;semi=%3B",
+ ";dot=.;semi=%3B;comma=%2C",
+ ";semi=%3B;comma=%2C;dot=.",
+ ";semi=%3B;dot=.;comma=%2C"
+ ]]
+ ]
+ },
+ "3.2.8 Form-Style Query Expansion" :
+ {
+ "variables": {
+ "count" : ["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" : ","},
+ "v" : "6",
+ "x" : "1024",
+ "y" : "768",
+ "empty" : "",
+ "empty_keys" : [],
+ "undef" : null
+ },
+ "testcases" : [
+ ["{?who}", "?who=fred"],
+ ["{?half}", "?half=50%25"],
+ ["{?x,y}", "?x=1024&y=768"],
+ ["{?x,y,empty}", "?x=1024&y=768&empty="],
+ ["{?x,y,undef}", "?x=1024&y=768"],
+ ["{?var:3}", "?var=val"],
+ ["{?list}", "?list=red,green,blue"],
+ ["{?list*}", "?list=red&list=green&list=blue"],
+ ["{?keys}", [
+ "?keys=comma,%2C,dot,.,semi,%3B",
+ "?keys=comma,%2C,semi,%3B,dot,.",
+ "?keys=dot,.,comma,%2C,semi,%3B",
+ "?keys=dot,.,semi,%3B,comma,%2C",
+ "?keys=semi,%3B,comma,%2C,dot,.",
+ "?keys=semi,%3B,dot,.,comma,%2C"
+ ]],
+ ["{?keys*}", [
+ "?comma=%2C&dot=.&semi=%3B",
+ "?comma=%2C&semi=%3B&dot=.",
+ "?dot=.&comma=%2C&semi=%3B",
+ "?dot=.&semi=%3B&comma=%2C",
+ "?semi=%3B&comma=%2C&dot=.",
+ "?semi=%3B&dot=.&comma=%2C"
+ ]]
+ ]
+ },
+ "3.2.9 Form-Style Query Continuation" :
+ {
+ "variables": {
+ "count" : ["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" : ","},
+ "v" : "6",
+ "x" : "1024",
+ "y" : "768",
+ "empty" : "",
+ "empty_keys" : [],
+ "undef" : null
+ },
+ "testcases" : [
+ ["{&who}", "&who=fred"],
+ ["{&half}", "&half=50%25"],
+ ["?fixed=yes{&x}", "?fixed=yes&x=1024"],
+ ["{&var:3}", "&var=val"],
+ ["{&x,y,empty}", "&x=1024&y=768&empty="],
+ ["{&x,y,undef}", "&x=1024&y=768"],
+ ["{&list}", "&list=red,green,blue"],
+ ["{&list*}", "&list=red&list=green&list=blue"],
+ ["{&keys}", [
+ "&keys=comma,%2C,dot,.,semi,%3B",
+ "&keys=comma,%2C,semi,%3B,dot,.",
+ "&keys=dot,.,comma,%2C,semi,%3B",
+ "&keys=dot,.,semi,%3B,comma,%2C",
+ "&keys=semi,%3B,comma,%2C,dot,.",
+ "&keys=semi,%3B,dot,.,comma,%2C"
+ ]],
+ ["{&keys*}", [
+ "&comma=%2C&dot=.&semi=%3B",
+ "&comma=%2C&semi=%3B&dot=.",
+ "&dot=.&comma=%2C&semi=%3B",
+ "&dot=.&semi=%3B&comma=%2C",
+ "&semi=%3B&comma=%2C&dot=.",
+ "&semi=%3B&dot=.&comma=%2C"
+ ]]
+ ]
+ }
+}
diff --git a/test/fixtures/spec-examples.json b/test/fixtures/spec-examples.json
new file mode 100644
index 0000000..2e8e942
--- /dev/null
+++ b/test/fixtures/spec-examples.json
@@ -0,0 +1,218 @@
+{
+ "Level 1 Examples" :
+ {
+ "level": 1,
+ "variables": {
+ "var" : "value",
+ "hello" : "Hello World!"
+ },
+ "testcases" : [
+ ["{var}", "value"],
+ ["{hello}", "Hello%20World%21"]
+ ]
+ },
+ "Level 2 Examples" :
+ {
+ "level": 2,
+ "variables": {
+ "var" : "value",
+ "hello" : "Hello World!",
+ "path" : "/foo/bar"
+ },
+ "testcases" : [
+ ["{+var}", "value"],
+ ["{+hello}", "Hello%20World!"],
+ ["{+path}/here", "/foo/bar/here"],
+ ["here?ref={+path}", "here?ref=/foo/bar"]
+ ]
+ },
+ "Level 3 Examples" :
+ {
+ "level": 3,
+ "variables": {
+ "var" : "value",
+ "hello" : "Hello World!",
+ "empty" : "",
+ "path" : "/foo/bar",
+ "x" : "1024",
+ "y" : "768"
+ },
+ "testcases" : [
+ ["map?{x,y}", "map?1024,768"],
+ ["{x,hello,y}", "1024,Hello%20World%21,768"],
+ ["{+x,hello,y}", "1024,Hello%20World!,768"],
+ ["{+path,x}/here", "/foo/bar,1024/here"],
+ ["{#x,hello,y}", "#1024,Hello%20World!,768"],
+ ["{#path,x}/here", "#/foo/bar,1024/here"],
+ ["X{.var}", "X.value"],
+ ["X{.x,y}", "X.1024.768"],
+ ["{/var}", "/value"],
+ ["{/var,x}/here", "/value/1024/here"],
+ ["{;x,y}", ";x=1024;y=768"],
+ ["{;x,y,empty}", ";x=1024;y=768;empty"],
+ ["{?x,y}", "?x=1024&y=768"],
+ ["{?x,y,empty}", "?x=1024&y=768&empty="],
+ ["?fixed=yes{&x}", "?fixed=yes&x=1024"],
+ ["{&x,y,empty}", "&x=1024&y=768&empty="]
+ ]
+ },
+ "Level 4 Examples" :
+ {
+ "level": 4,
+ "variables": {
+ "var": "value",
+ "hello": "Hello World!",
+ "path": "/foo/bar",
+ "list": ["red", "green", "blue"],
+ "keys": {"semi": ";", "dot": ".", "comma":","}
+ },
+ "testcases": [
+ ["{var:3}", "val"],
+ ["{var:30}", "value"],
+ ["{list}", "red,green,blue"],
+ ["{list*}", "red,green,blue"],
+ ["{keys}", [
+ "comma,%2C,dot,.,semi,%3B",
+ "comma,%2C,semi,%3B,dot,.",
+ "dot,.,comma,%2C,semi,%3B",
+ "dot,.,semi,%3B,comma,%2C",
+ "semi,%3B,comma,%2C,dot,.",
+ "semi,%3B,dot,.,comma,%2C"
+ ]],
+ ["{keys*}", [
+ "comma=%2C,dot=.,semi=%3B",
+ "comma=%2C,semi=%3B,dot=.",
+ "dot=.,comma=%2C,semi=%3B",
+ "dot=.,semi=%3B,comma=%2C",
+ "semi=%3B,comma=%2C,dot=.",
+ "semi=%3B,dot=.,comma=%2C"
+ ]],
+ ["{+path:6}/here", "/foo/b/here"],
+ ["{+list}", "red,green,blue"],
+ ["{+list*}", "red,green,blue"],
+ ["{+keys}", [
+ "comma,,,dot,.,semi,;",
+ "comma,,,semi,;,dot,.",
+ "dot,.,comma,,,semi,;",
+ "dot,.,semi,;,comma,,",
+ "semi,;,comma,,,dot,.",
+ "semi,;,dot,.,comma,,"
+ ]],
+ ["{+keys*}", [
+ "comma=,,dot=.,semi=;",
+ "comma=,,semi=;,dot=.",
+ "dot=.,comma=,,semi=;",
+ "dot=.,semi=;,comma=,",
+ "semi=;,comma=,,dot=.",
+ "semi=;,dot=.,comma=,"
+ ]],
+ ["{#path:6}/here", "#/foo/b/here"],
+ ["{#list}", "#red,green,blue"],
+ ["{#list*}", "#red,green,blue"],
+ ["{#keys}", [
+ "#comma,,,dot,.,semi,;",
+ "#comma,,,semi,;,dot,.",
+ "#dot,.,comma,,,semi,;",
+ "#dot,.,semi,;,comma,,",
+ "#semi,;,comma,,,dot,.",
+ "#semi,;,dot,.,comma,,"
+ ]],
+ ["{#keys*}", [
+ "#comma=,,dot=.,semi=;",
+ "#comma=,,semi=;,dot=.",
+ "#dot=.,comma=,,semi=;",
+ "#dot=.,semi=;,comma=,",
+ "#semi=;,comma=,,dot=.",
+ "#semi=;,dot=.,comma=,"
+ ]],
+ ["X{.var:3}", "X.val"],
+ ["X{.list}", "X.red,green,blue"],
+ ["X{.list*}", "X.red.green.blue"],
+ ["X{.keys}", [
+ "X.comma,%2C,dot,.,semi,%3B",
+ "X.comma,%2C,semi,%3B,dot,.",
+ "X.dot,.,comma,%2C,semi,%3B",
+ "X.dot,.,semi,%3B,comma,%2C",
+ "X.semi,%3B,comma,%2C,dot,.",
+ "X.semi,%3B,dot,.,comma,%2C"
+ ]],
+ ["{/var:1,var}", "/v/value"],
+ ["{/list}", "/red,green,blue"],
+ ["{/list*}", "/red/green/blue"],
+ ["{/list*,path:4}", "/red/green/blue/%2Ffoo"],
+ ["{/keys}", [
+ "/comma,%2C,dot,.,semi,%3B",
+ "/comma,%2C,semi,%3B,dot,.",
+ "/dot,.,comma,%2C,semi,%3B",
+ "/dot,.,semi,%3B,comma,%2C",
+ "/semi,%3B,comma,%2C,dot,.",
+ "/semi,%3B,dot,.,comma,%2C"
+ ]],
+ ["{/keys*}", [
+ "/comma=%2C/dot=./semi=%3B",
+ "/comma=%2C/semi=%3B/dot=.",
+ "/dot=./comma=%2C/semi=%3B",
+ "/dot=./semi=%3B/comma=%2C",
+ "/semi=%3B/comma=%2C/dot=.",
+ "/semi=%3B/dot=./comma=%2C"
+ ]],
+ ["{;hello:5}", ";hello=Hello"],
+ ["{;list}", ";list=red,green,blue"],
+ ["{;list*}", ";list=red;list=green;list=blue"],
+ ["{;keys}", [
+ ";keys=comma,%2C,dot,.,semi,%3B",
+ ";keys=comma,%2C,semi,%3B,dot,.",
+ ";keys=dot,.,comma,%2C,semi,%3B",
+ ";keys=dot,.,semi,%3B,comma,%2C",
+ ";keys=semi,%3B,comma,%2C,dot,.",
+ ";keys=semi,%3B,dot,.,comma,%2C"
+ ]],
+ ["{;keys*}", [
+ ";comma=%2C;dot=.;semi=%3B",
+ ";comma=%2C;semi=%3B;dot=.",
+ ";dot=.;comma=%2C;semi=%3B",
+ ";dot=.;semi=%3B;comma=%2C",
+ ";semi=%3B;comma=%2C;dot=.",
+ ";semi=%3B;dot=.;comma=%2C"
+ ]],
+ ["{?var:3}", "?var=val"],
+ ["{?list}", "?list=red,green,blue"],
+ ["{?list*}", "?list=red&list=green&list=blue"],
+ ["{?keys}", [
+ "?keys=comma,%2C,dot,.,semi,%3B",
+ "?keys=comma,%2C,semi,%3B,dot,.",
+ "?keys=dot,.,comma,%2C,semi,%3B",
+ "?keys=dot,.,semi,%3B,comma,%2C",
+ "?keys=semi,%3B,comma,%2C,dot,.",
+ "?keys=semi,%3B,dot,.,comma,%2C"
+ ]],
+ ["{?keys*}", [
+ "?comma=%2C&dot=.&semi=%3B",
+ "?comma=%2C&semi=%3B&dot=.",
+ "?dot=.&comma=%2C&semi=%3B",
+ "?dot=.&semi=%3B&comma=%2C",
+ "?semi=%3B&comma=%2C&dot=.",
+ "?semi=%3B&dot=.&comma=%2C"
+ ]],
+ ["{&var:3}", "&var=val"],
+ ["{&list}", "&list=red,green,blue"],
+ ["{&list*}", "&list=red&list=green&list=blue"],
+ ["{&keys}", [
+ "&keys=comma,%2C,dot,.,semi,%3B",
+ "&keys=comma,%2C,semi,%3B,dot,.",
+ "&keys=dot,.,comma,%2C,semi,%3B",
+ "&keys=dot,.,semi,%3B,comma,%2C",
+ "&keys=semi,%3B,comma,%2C,dot,.",
+ "&keys=semi,%3B,dot,.,comma,%2C"
+ ]],
+ ["{&keys*}", [
+ "&comma=%2C&dot=.&semi=%3B",
+ "&comma=%2C&semi=%3B&dot=.",
+ "&dot=.&comma=%2C&semi=%3B",
+ "&dot=.&semi=%3B&comma=%2C",
+ "&semi=%3B&comma=%2C&dot=.",
+ "&semi=%3B&dot=.&comma=%2C"
+ ]]
+ ]
+ }
+}
diff --git a/test/fixtures/transform-json-tests.xslt b/test/fixtures/transform-json-tests.xslt
new file mode 100644
index 0000000..d956b6b
--- /dev/null
+++ b/test/fixtures/transform-json-tests.xslt
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file