Introduce FrontController as the replacement for RootController.
[hoplite.git] / http / url_map.php
1 <?php
2 // Hoplite
3 // Copyright (c) 2011 Blue Static
4 //
5 // This program is free software: you can redistribute it and/or modify it
6 // under the terms of the GNU General Public License as published by the Free
7 // Software Foundation, either version 3 of the License, or any later version.
8 //
9 // This program is distributed in the hope that it will be useful, but WITHOUT
10 // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11 // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
12 // more details.
13 //
14 // You should have received a copy of the GNU General Public License along with
15 // this program. If not, see <http://www.gnu.org/licenses/>.
16
17 namespace hoplite\http;
18
19 require_once HOPLITE_ROOT . '/base/functions.php';
20
21 /*!
22 A UrlMap will translate a raw HTTP request into a http\Request object. It does
23 so by matching the incoming URL to a pattern. For information on the format of
24 patterns @see ::set_map().
25 */
26 class UrlMap
27 {
28 /*! @var FrontController */
29 private $controller;
30
31 /*! @var array The map of URLs to actions. */
32 private $map = array();
33
34 /*! @var Closure(string) Loads the file for the appropriate action class name. */
35 private $file_loader = NULL;
36
37 /*!
38 Constructs the object with a reference to the FrontController.
39 @param FrontController
40 */
41 public function __construct(FrontController $controller)
42 {
43 $this->controller = $controller;
44 }
45
46 /*! Accessor for the FrontController */
47 public function controller() { return $this->controller; }
48
49 /*! Gets the URL map */
50 public function map() { return $this->map; }
51 /*! @brief Sets the URL map.
52 The URL map is an associative array of URL prefix patterns to Actions or
53 file paths containing actions.
54
55 The keys can be either a URL prefix or a regular expression.
56
57 For URL prefixes, the pattern is matched relative to the root of the entry-
58 point as specified by the FrontController. Patterns should not begin with a
59 slash. Path fragment parameter extraction can be performed as well. For
60 example, the pattern 'user/view/{id}' will match a URL like
61 http://example.com/webapp/user/view/42
62 And the http\Request's data will have a member called 'id' with value 42.
63
64 Typically prefix matching will be done for the above patterns. This may be
65 unwanted if a more general path needs to execute before a more descendent
66 one. Using strict matching, invoked by ending a pattern with two slashes,
67 pattern matching becomes exact in that all path components must match.
68
69 Regular expression matching is not limited to prefix patterns and can match
70 any part of the URL (though prefix matching can be enforced using the
71 the standard regex '^' character). Regex patterns must begin and end with
72 '/' characters. During evaluation, if any pattern groups are matched, the
73 resulting matches will be placed in Request data via the 'url_pattern' key.
74 The following will match the same URL as above:
75 '/^user\/view\/([0-9]+)/'
76
77 Values can be a class name or a relative path to a file that contains an
78 Action class by the same name, or just the name of an Action class. The
79 conventions for each are governed by ::LookupAction().
80
81 @see ::Evaluate()
82 @see ::LookupAction()
83 */
84 public function set_map(array $map) { $this->map = $map; }
85
86 /*! @brief The file loading helper function.
87 This function is called in ::LookupAction() and will load the necessary file
88 for this class so that it can be instantiated. Should return the class name
89 to instantiate. This can either be the first argument passed to it
90 unaltered, or modified for example with a namespace.
91 Closure(string $class_name, string $map_value) -> string $new_class_name
92 */
93 public function set_file_loader(\Closure $fn) { $this->file_loader = $fn; }
94
95 /*! @brief Evalutes the URL map and finds a match.
96 This will take the incoming URL from the request and will match it against
97 the patterns in the internal map.
98
99 Matching occurs in a linear scan of the URL map, so precedence can be
100 enforced by ordering rules appropriately. Regular expressions are matched
101 and URL parameter extraction occurs each time a different rule is evaluated.
102
103 This may mutate the request with extracted data if a match is made and
104 returned.
105 @see ::set_map() for more information.
106
107 If a match is made, this will return the corresponding value for the matched
108 key in the map. To get an Action object from this, use ::LookupAction().
109
110 @return string|NULL A matched value in the ::map() or NULL if no match.
111 */
112 public function Evaluate(Request $request)
113 {
114 $fragments = explode('/', $request->url);
115 $path_length = strlen($request->url);
116
117 foreach ($this->map as $rule => $action) {
118 // First check if this is the empty rule.
119 if (empty($rule)) {
120 if (empty($request->url))
121 return $action;
122 else
123 continue;
124 }
125
126 // Check if this is a regular expression rule and match it.
127 if ($rule[0] == '/' && substr($rule, -1) == '/') {
128 $matches = array();
129 if (preg_match($rule, $request->url, $matches)) {
130 // This pattern matched, so fill out the request and return.
131 $request->data['url_pattern'] = $matches;
132 return $action;
133 }
134 }
135 // Otherwise, this is just a normal string match.
136 else {
137 // Patterns that end with two slashes are exact.
138 $is_strict = substr($rule, -2) == '//';
139 if ($is_strict)
140 $rule = substr($rule, 0, -2);
141
142 // Set up some variables for the loop.
143 $is_match = TRUE;
144 $rule_fragments = explode('/', $rule);
145 $count_rule_fragments = count($rule_fragments);
146 $count_fragments = count($fragments);
147 $extractions = array();
148
149 // If this is a strict matcher, then do a quick test based on fragments.
150 if ($is_strict && $count_rule_fragments != $count_fragments)
151 continue;
152
153 // Loop over the pieces of the rule, matching the fragments to that of
154 // the request.
155 foreach ($rule_fragments as $i => $rule_frag) {
156 // Don't iterate past the length of the request. Prefix matching means
157 // that this can still be a match.
158 if ($i >= $count_fragments)
159 break;
160
161 // If this fragment is a key to be extracted, do so into a temporary
162 // array.
163 if (strlen($rule_frag) && $rule_frag[0] == '{' && substr($rule_frag, -1) == '}') {
164 $key = substr($rule_frag, 1, -1);
165 $extractions[$key] = $fragments[$i];
166 }
167 // Otherwise, the path components mutch match.
168 else if ($rule_frag != $fragments[$i]) {
169 $is_match = FALSE;
170 break;
171 }
172 }
173
174 // If no match was made, try the next rule.
175 if (!$is_match)
176 continue;
177
178 // A match was made, so merge the path components that were extracted by
179 // key and return the match.
180 $request->data = array_merge($extractions, $request->data);
181 return $action;
182 }
183 }
184 }
185
186 /*! @brief Takes a value from the map and returns an Action object.
187 The values in the map are either an Action class name or a relative path to
188 a file containing an Action class.
189
190 Mapping to a class requires that the value start with an upper-case letter.
191
192 Paths start with a lower-case letter. The last path component will be
193 transformed into a class name via ::_ClassNameFromFileName(). Note that if
194 the file extension is not included in the path, .php will be automatically
195 appended.
196
197 @return Action|NULL The loaded action, or NULL on error.
198 */
199 public function LookupAction($map_value)
200 {
201 // If the first character is uppercase or a namespaced class, simply return
202 // the value.
203 $first_char = $map_value[0];
204 if ($first_char == '\\' || ctype_upper($first_char)) {
205 $class = $map_value;
206 } else {
207 // Otherwise this is a path. Check if an extension is present, and if not,
208 // add one.
209 $pathinfo = pathinfo($map_value);
210 if (!isset($pathinfo['extension'])) {
211 $map_value .= '.php';
212 $pathinfo['extension'] = 'php';
213 }
214
215 $class = $this->_ClassNameFromFileName($pathinfo);
216 }
217
218 if ($this->file_loader) {
219 $loader = $this->file_loader;
220 $class = $loader($class, $map_value);
221 }
222
223 return new $class($this->controller());
224 }
225
226 /*!
227 Takes a file name and returns the name of an Action class. This uses an
228 under_score to CamelCase transformation with an 'Action' suffix:
229 lost_password -> LostPasswordAction
230
231 This can be overridden to provide a custom transformation.
232
233 @param array Result of a pathinfo() call
234
235 @return string Action class name.
236 */
237 protected function _ClassNameFromFileName($pathinfo)
238 {
239 $filename = $pathinfo['filename'];
240 return \hoplite\base\UnderscoreToCamelCase($filename);
241 }
242 }