Chris@0: ['prefix' => '', 'joiner' => ',', 'query' => false], Chris@0: '+' => ['prefix' => '', 'joiner' => ',', 'query' => false], Chris@0: '#' => ['prefix' => '#', 'joiner' => ',', 'query' => false], Chris@0: '.' => ['prefix' => '.', 'joiner' => '.', 'query' => false], Chris@0: '/' => ['prefix' => '/', 'joiner' => '/', 'query' => false], Chris@0: ';' => ['prefix' => ';', 'joiner' => ';', 'query' => true], Chris@0: '?' => ['prefix' => '?', 'joiner' => '&', 'query' => true], Chris@0: '&' => ['prefix' => '&', 'joiner' => '&', 'query' => true] Chris@0: ]; Chris@0: Chris@0: /** @var array Delimiters */ Chris@0: private static $delims = [':', '/', '?', '#', '[', ']', '@', '!', '$', Chris@0: '&', '\'', '(', ')', '*', '+', ',', ';', '=']; Chris@0: Chris@0: /** @var array Percent encoded delimiters */ Chris@0: private static $delimsPct = ['%3A', '%2F', '%3F', '%23', '%5B', '%5D', Chris@0: '%40', '%21', '%24', '%26', '%27', '%28', '%29', '%2A', '%2B', '%2C', Chris@0: '%3B', '%3D']; Chris@0: Chris@0: public function expand($template, array $variables) Chris@0: { Chris@0: if (false === strpos($template, '{')) { Chris@0: return $template; Chris@0: } Chris@0: Chris@0: $this->template = $template; Chris@0: $this->variables = $variables; Chris@0: Chris@0: return preg_replace_callback( Chris@0: '/\{([^\}]+)\}/', Chris@0: [$this, 'expandMatch'], Chris@0: $this->template Chris@0: ); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Parse an expression into parts Chris@0: * Chris@0: * @param string $expression Expression to parse Chris@0: * Chris@0: * @return array Returns an associative array of parts Chris@0: */ Chris@0: private function parseExpression($expression) Chris@0: { Chris@0: $result = []; Chris@0: Chris@0: if (isset(self::$operatorHash[$expression[0]])) { Chris@0: $result['operator'] = $expression[0]; Chris@0: $expression = substr($expression, 1); Chris@0: } else { Chris@0: $result['operator'] = ''; Chris@0: } Chris@0: Chris@0: foreach (explode(',', $expression) as $value) { Chris@0: $value = trim($value); Chris@0: $varspec = []; Chris@0: if ($colonPos = strpos($value, ':')) { Chris@0: $varspec['value'] = substr($value, 0, $colonPos); Chris@0: $varspec['modifier'] = ':'; Chris@0: $varspec['position'] = (int) substr($value, $colonPos + 1); Chris@0: } elseif (substr($value, -1) === '*') { Chris@0: $varspec['modifier'] = '*'; Chris@0: $varspec['value'] = substr($value, 0, -1); Chris@0: } else { Chris@0: $varspec['value'] = (string) $value; Chris@0: $varspec['modifier'] = ''; Chris@0: } Chris@0: $result['values'][] = $varspec; Chris@0: } Chris@0: Chris@0: return $result; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Process an expansion Chris@0: * Chris@0: * @param array $matches Matches met in the preg_replace_callback Chris@0: * Chris@0: * @return string Returns the replacement string Chris@0: */ Chris@0: private function expandMatch(array $matches) Chris@0: { Chris@0: static $rfc1738to3986 = ['+' => '%20', '%7e' => '~']; Chris@0: Chris@0: $replacements = []; Chris@0: $parsed = self::parseExpression($matches[1]); Chris@0: $prefix = self::$operatorHash[$parsed['operator']]['prefix']; Chris@0: $joiner = self::$operatorHash[$parsed['operator']]['joiner']; Chris@0: $useQuery = self::$operatorHash[$parsed['operator']]['query']; Chris@0: Chris@0: foreach ($parsed['values'] as $value) { Chris@0: if (!isset($this->variables[$value['value']])) { Chris@0: continue; Chris@0: } Chris@0: Chris@0: $variable = $this->variables[$value['value']]; Chris@0: $actuallyUseQuery = $useQuery; Chris@0: $expanded = ''; Chris@0: Chris@0: if (is_array($variable)) { Chris@0: $isAssoc = $this->isAssoc($variable); Chris@0: $kvp = []; Chris@0: foreach ($variable as $key => $var) { Chris@0: if ($isAssoc) { Chris@0: $key = rawurlencode($key); Chris@0: $isNestedArray = is_array($var); Chris@0: } else { Chris@0: $isNestedArray = false; Chris@0: } Chris@0: Chris@0: if (!$isNestedArray) { Chris@0: $var = rawurlencode($var); Chris@0: if ($parsed['operator'] === '+' || Chris@0: $parsed['operator'] === '#' Chris@0: ) { Chris@0: $var = $this->decodeReserved($var); Chris@0: } Chris@0: } Chris@0: Chris@0: if ($value['modifier'] === '*') { Chris@0: if ($isAssoc) { Chris@0: if ($isNestedArray) { Chris@0: // Nested arrays must allow for deeply nested Chris@0: // structures. Chris@0: $var = strtr( Chris@0: http_build_query([$key => $var]), Chris@0: $rfc1738to3986 Chris@0: ); Chris@0: } else { Chris@0: $var = $key . '=' . $var; Chris@0: } Chris@0: } elseif ($key > 0 && $actuallyUseQuery) { Chris@0: $var = $value['value'] . '=' . $var; Chris@0: } Chris@0: } Chris@0: Chris@0: $kvp[$key] = $var; Chris@0: } Chris@0: Chris@0: if (empty($variable)) { Chris@0: $actuallyUseQuery = false; Chris@0: } elseif ($value['modifier'] === '*') { Chris@0: $expanded = implode($joiner, $kvp); Chris@0: if ($isAssoc) { Chris@0: // Don't prepend the value name when using the explode Chris@0: // modifier with an associative array. Chris@0: $actuallyUseQuery = false; Chris@0: } Chris@0: } else { Chris@0: if ($isAssoc) { Chris@0: // When an associative array is encountered and the Chris@0: // explode modifier is not set, then the result must be Chris@0: // a comma separated list of keys followed by their Chris@0: // respective values. Chris@0: foreach ($kvp as $k => &$v) { Chris@0: $v = $k . ',' . $v; Chris@0: } Chris@0: } Chris@0: $expanded = implode(',', $kvp); Chris@0: } Chris@0: } else { Chris@0: if ($value['modifier'] === ':') { Chris@0: $variable = substr($variable, 0, $value['position']); Chris@0: } Chris@0: $expanded = rawurlencode($variable); Chris@0: if ($parsed['operator'] === '+' || $parsed['operator'] === '#') { Chris@0: $expanded = $this->decodeReserved($expanded); Chris@0: } Chris@0: } Chris@0: Chris@0: if ($actuallyUseQuery) { Chris@0: if (!$expanded && $joiner !== '&') { Chris@0: $expanded = $value['value']; Chris@0: } else { Chris@0: $expanded = $value['value'] . '=' . $expanded; Chris@0: } Chris@0: } Chris@0: Chris@0: $replacements[] = $expanded; Chris@0: } Chris@0: Chris@0: $ret = implode($joiner, $replacements); Chris@0: if ($ret && $prefix) { Chris@0: return $prefix . $ret; Chris@0: } Chris@0: Chris@0: return $ret; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Determines if an array is associative. Chris@0: * Chris@0: * This makes the assumption that input arrays are sequences or hashes. Chris@0: * This assumption is a tradeoff for accuracy in favor of speed, but it Chris@0: * should work in almost every case where input is supplied for a URI Chris@0: * template. Chris@0: * Chris@0: * @param array $array Array to check Chris@0: * Chris@0: * @return bool Chris@0: */ Chris@0: private function isAssoc(array $array) Chris@0: { Chris@0: return $array && array_keys($array)[0] !== 0; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Removes percent encoding on reserved characters (used with + and # Chris@0: * modifiers). Chris@0: * Chris@0: * @param string $string String to fix Chris@0: * Chris@0: * @return string Chris@0: */ Chris@0: private function decodeReserved($string) Chris@0: { Chris@0: return str_replace(self::$delimsPct, self::$delims, $string); Chris@0: } Chris@0: }