1<?php
2/*
3 * Copyright 2013 Google Inc.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 *     http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18namespace Google\Utils;
19
20/**
21 * Implementation of levels 1-3 of the URI Template spec.
22 * @see http://tools.ietf.org/html/rfc6570
23 */
24class UriTemplate
25{
26  const TYPE_MAP = "1";
27  const TYPE_LIST = "2";
28  const TYPE_SCALAR = "4";
29
30  /**
31   * @var $operators array
32   * These are valid at the start of a template block to
33   * modify the way in which the variables inside are
34   * processed.
35   */
36  private $operators = array(
37      "+" => "reserved",
38      "/" => "segments",
39      "." => "dotprefix",
40      "#" => "fragment",
41      ";" => "semicolon",
42      "?" => "form",
43      "&" => "continuation"
44  );
45
46  /**
47   * @var reserved array
48   * These are the characters which should not be URL encoded in reserved
49   * strings.
50   */
51  private $reserved = array(
52      "=", ",", "!", "@", "|", ":", "/", "?", "#",
53      "[", "]",'$', "&", "'", "(", ")", "*", "+", ";"
54  );
55  private $reservedEncoded = array(
56    "%3D", "%2C", "%21", "%40", "%7C", "%3A", "%2F", "%3F",
57    "%23", "%5B", "%5D", "%24", "%26", "%27", "%28", "%29",
58    "%2A", "%2B", "%3B"
59  );
60
61  public function parse($string, array $parameters)
62  {
63    return $this->resolveNextSection($string, $parameters);
64  }
65
66  /**
67   * This function finds the first matching {...} block and
68   * executes the replacement. It then calls itself to find
69   * subsequent blocks, if any.
70   */
71  private function resolveNextSection($string, $parameters)
72  {
73    $start = strpos($string, "{");
74    if ($start === false) {
75      return $string;
76    }
77    $end = strpos($string, "}");
78    if ($end === false) {
79      return $string;
80    }
81    $string = $this->replace($string, $start, $end, $parameters);
82    return $this->resolveNextSection($string, $parameters);
83  }
84
85  private function replace($string, $start, $end, $parameters)
86  {
87    // We know a data block will have {} round it, so we can strip that.
88    $data = substr($string, $start + 1, $end - $start - 1);
89
90    // If the first character is one of the reserved operators, it effects
91    // the processing of the stream.
92    if (isset($this->operators[$data[0]])) {
93      $op = $this->operators[$data[0]];
94      $data = substr($data, 1);
95      $prefix = "";
96      $prefix_on_missing = false;
97
98      switch ($op) {
99        case "reserved":
100          // Reserved means certain characters should not be URL encoded
101          $data = $this->replaceVars($data, $parameters, ",", null, true);
102          break;
103        case "fragment":
104          // Comma separated with fragment prefix. Bare values only.
105          $prefix = "#";
106          $prefix_on_missing = true;
107          $data = $this->replaceVars($data, $parameters, ",", null, true);
108          break;
109        case "segments":
110          // Slash separated data. Bare values only.
111          $prefix = "/";
112          $data =$this->replaceVars($data, $parameters, "/");
113          break;
114        case "dotprefix":
115          // Dot separated data. Bare values only.
116          $prefix = ".";
117          $prefix_on_missing = true;
118          $data = $this->replaceVars($data, $parameters, ".");
119          break;
120        case "semicolon":
121          // Semicolon prefixed and separated. Uses the key name
122          $prefix = ";";
123          $data = $this->replaceVars($data, $parameters, ";", "=", false, true, false);
124          break;
125        case "form":
126          // Standard URL format. Uses the key name
127          $prefix = "?";
128          $data = $this->replaceVars($data, $parameters, "&", "=");
129          break;
130        case "continuation":
131          // Standard URL, but with leading ampersand. Uses key name.
132          $prefix = "&";
133          $data = $this->replaceVars($data, $parameters, "&", "=");
134          break;
135      }
136
137      // Add the initial prefix character if data is valid.
138      if ($data || ($data !== false && $prefix_on_missing)) {
139        $data = $prefix . $data;
140      }
141
142    } else {
143      // If no operator we replace with the defaults.
144      $data = $this->replaceVars($data, $parameters);
145    }
146    // This is chops out the {...} and replaces with the new section.
147    return substr($string, 0, $start) . $data . substr($string, $end + 1);
148  }
149
150  private function replaceVars(
151      $section,
152      $parameters,
153      $sep = ",",
154      $combine = null,
155      $reserved = false,
156      $tag_empty = false,
157      $combine_on_empty = true
158  ) {
159    if (strpos($section, ",") === false) {
160      // If we only have a single value, we can immediately process.
161      return $this->combine(
162          $section,
163          $parameters,
164          $sep,
165          $combine,
166          $reserved,
167          $tag_empty,
168          $combine_on_empty
169      );
170    } else {
171      // If we have multiple values, we need to split and loop over them.
172      // Each is treated individually, then glued together with the
173      // separator character.
174      $vars = explode(",", $section);
175      return $this->combineList(
176          $vars,
177          $sep,
178          $parameters,
179          $combine,
180          $reserved,
181          false, // Never emit empty strings in multi-param replacements
182          $combine_on_empty
183      );
184    }
185  }
186
187  public function combine(
188      $key,
189      $parameters,
190      $sep,
191      $combine,
192      $reserved,
193      $tag_empty,
194      $combine_on_empty
195  ) {
196    $length = false;
197    $explode = false;
198    $skip_final_combine = false;
199    $value = false;
200
201    // Check for length restriction.
202    if (strpos($key, ":") !== false) {
203      list($key, $length) = explode(":", $key);
204    }
205
206    // Check for explode parameter.
207    if ($key[strlen($key) - 1] == "*") {
208      $explode = true;
209      $key = substr($key, 0, -1);
210      $skip_final_combine = true;
211    }
212
213    // Define the list separator.
214    $list_sep = $explode ? $sep : ",";
215
216    if (isset($parameters[$key])) {
217      $data_type = $this->getDataType($parameters[$key]);
218      switch ($data_type) {
219        case self::TYPE_SCALAR:
220          $value = $this->getValue($parameters[$key], $length);
221          break;
222        case self::TYPE_LIST:
223          $values = array();
224          foreach ($parameters[$key] as $pkey => $pvalue) {
225            $pvalue = $this->getValue($pvalue, $length);
226            if ($combine && $explode) {
227              $values[$pkey] = $key . $combine . $pvalue;
228            } else {
229              $values[$pkey] = $pvalue;
230            }
231          }
232          $value = implode($list_sep, $values);
233          if ($value == '') {
234            return '';
235          }
236          break;
237        case self::TYPE_MAP:
238          $values = array();
239          foreach ($parameters[$key] as $pkey => $pvalue) {
240            $pvalue = $this->getValue($pvalue, $length);
241            if ($explode) {
242              $pkey = $this->getValue($pkey, $length);
243              $values[] = $pkey . "=" . $pvalue; // Explode triggers = combine.
244            } else {
245              $values[] = $pkey;
246              $values[] = $pvalue;
247            }
248          }
249          $value = implode($list_sep, $values);
250          if ($value == '') {
251            return false;
252          }
253          break;
254      }
255    } else if ($tag_empty) {
256      // If we are just indicating empty values with their key name, return that.
257      return $key;
258    } else {
259      // Otherwise we can skip this variable due to not being defined.
260      return false;
261    }
262
263    if ($reserved) {
264      $value = str_replace($this->reservedEncoded, $this->reserved, $value);
265    }
266
267    // If we do not need to include the key name, we just return the raw
268    // value.
269    if (!$combine || $skip_final_combine) {
270      return $value;
271    }
272
273    // Else we combine the key name: foo=bar, if value is not the empty string.
274    return $key . ($value != '' || $combine_on_empty ? $combine . $value : '');
275  }
276
277  /**
278   * Return the type of a passed in value
279   */
280  private function getDataType($data)
281  {
282    if (is_array($data)) {
283      reset($data);
284      if (key($data) !== 0) {
285        return self::TYPE_MAP;
286      }
287      return self::TYPE_LIST;
288    }
289    return self::TYPE_SCALAR;
290  }
291
292  /**
293   * Utility function that merges multiple combine calls
294   * for multi-key templates.
295   */
296  private function combineList(
297      $vars,
298      $sep,
299      $parameters,
300      $combine,
301      $reserved,
302      $tag_empty,
303      $combine_on_empty
304  ) {
305    $ret = array();
306    foreach ($vars as $var) {
307      $response = $this->combine(
308          $var,
309          $parameters,
310          $sep,
311          $combine,
312          $reserved,
313          $tag_empty,
314          $combine_on_empty
315      );
316      if ($response === false) {
317        continue;
318      }
319      $ret[] = $response;
320    }
321    return implode($sep, $ret);
322  }
323
324  /**
325   * Utility function to encode and trim values
326   */
327  private function getValue($value, $length)
328  {
329    if ($length) {
330      $value = substr($value, 0, $length);
331    }
332    $value = rawurlencode($value);
333    return $value;
334  }
335}
336