a tiny mvc framework for php using php-activerecord
1<?php
2/*
3 URL router
4*/
5
6namespace HalfMoon;
7
8use Closure;
9
10class Router extends Singleton {
11 private $routes = array();
12 private $rootRoutes = array();
13
14 static $DEFAULT_ACTION = "index";
15
16 public static function initialize(Closure $initializer) {
17 $initializer(parent::instance());
18 }
19
20 public static function addRoute($route) {
21 if (!is_array($route))
22 throw new HalfMoonException("invalid route of "
23 . var_export($route));
24
25 array_push(parent::instance()->routes, $route);
26 }
27
28 public static function addRootRoute($route) {
29 if (!is_array($route))
30 throw new HalfMoonException("invalid root route of "
31 . var_export($route, true));
32
33 /* only one root route can have no conditions, and any additional root
34 * routes must have different conditions */
35 $c = array_key_exists("conditions", $route) ? $route["conditions"] :
36 array();
37 foreach (parent::instance()->rootRoutes as $rr) {
38 $rrc = array_key_exists("conditions", $rr) ? $rr["conditions"] :
39 array();
40
41 if ((empty($c) && empty($rrc)) ||
42 (!empty($c) && !empty($rrc) && !array_diff_assoc($c, $rrc)))
43 throw new HalfMoonException("cannot add second root route "
44 . "with no conditions: " . var_export($route, true)
45 . " with existing root route: "
46 . var_export($rr, true) . ")");
47 }
48
49 array_push(parent::instance()->rootRoutes, $route);
50 }
51
52 public static function clearRoutes() {
53 parent::instance()->routes = array();
54 parent::instance()->rootRoutes = array();
55 }
56
57 public static function getRoutes() {
58 return parent::instance()->routes;
59 }
60
61 public static function getRootRoutes() {
62 return parent::instance()->rootRoutes;
63 }
64
65 public static function routeRequest($request) {
66 $path_pieces = array_map(function($piece) {
67 return urldecode($piece);
68 }, explode("/", $request->path));
69
70 $chosen_route = null;
71
72 /* find and take the first matching route, storing route components in
73 * $params */
74 if ($request->path == "") {
75 if (empty(parent::instance()->rootRoutes))
76 throw new RoutingException("no root route defined");
77
78 foreach (parent::instance()->rootRoutes as $route) {
79 /* verify virtual host matches if there's a condition on it */
80 if (isset($route["conditions"]["hostname"]))
81 if (!Utils::strcasecmp_or_preg_match(
82 $route["conditions"]["hostname"], $request->host))
83 continue;
84
85 $chosen_route = $route;
86 break;
87 }
88 } else {
89 foreach (parent::instance()->routes as $route) {
90 /* verify virtual host matches if there's a condition on it */
91 if (isset($route["conditions"]["hostname"]))
92 if (!Utils::strcasecmp_or_preg_match(
93 $route["conditions"]["hostname"], $request->host))
94 continue;
95
96 /* trim slashes from route definition and bust it up into
97 * components */
98 $route_pieces = explode("/", trim(preg_replace("/^\/*/", "",
99 preg_replace("/\/$/", "", trim($route["url"])))));
100
101 $match = true;
102 $did_glob = false;
103 for ($x = 0; $x < count($route_pieces); $x++) {
104 /* look for a condition */
105 if (preg_match("/^:(.+)$/", $route_pieces[$x], $m)) {
106 $regex_or_string = isset($route["conditions"]) ?
107 @Utils::A((array)$route["conditions"], $m[1]) :
108 NULL;
109
110 /* if the corresponding path piece isn't there and
111 * there is either no condition, or a condition that
112 * matches against a blank string, it's ok. this lets
113 * a route of "controller/:action/:id" match when the
114 * path is just "controller", assigning $DEFAULT_ACTION
115 * to :action and nothing to :id */
116
117 if ($regex_or_string == NULL ||
118 Utils::strcasecmp_or_preg_match($regex_or_string,
119 Utils::is_blank($path_pieces[$x]) ? "" :
120 $path_pieces[$x])) {
121 if (isset($route[$m[1]]) &&
122 preg_match("/^(.*):(.+)$/", $route[$m[1]], $n))
123 /* route has a set parameter, but it wants to
124 * include the matching piece from the path in
125 * its parameter */
126 $route[$m[1]] = $n[1] .
127 (isset($path_pieces[$x]) ?
128 $path_pieces[$x] :
129 static::$DEFAULT_ACTION);
130 else
131 /* store this named parameter (e.g. "/:blah"
132 * route on a path of "/hi" defines
133 * $route["blah"] to be "hi") */
134 $route[$m[1]] = @$path_pieces[$x];
135 } else
136 $match = false;
137 }
138
139 /* look for a glob condition */
140 elseif (preg_match("/^\*(.+)$/", $route_pieces[$x], $m)) {
141 $regex_or_string = isset($route["conditions"]) ?
142 @Utils::A((array)$route["conditions"], $m[1]) :
143 NULL;
144
145 /* concatenate the rest of the path as this one param */
146 $u = "";
147 for ($j = $x; $j < count($path_pieces); $j++)
148 $u .= ($u == "" ? "" : "/") . $path_pieces[$j];
149
150 if ($regex_or_string == NULL ||
151 Utils::strcasecmp_or_preg_match($regex_or_string, $u)) {
152 $route[$m[1]] = $u;
153 $did_glob = true;
154 }
155 else
156 $match = false;
157
158 break;
159 }
160
161 /* else it must match exactly (case-insensitively) */
162 elseif (@strcasecmp($route_pieces[$x], $path_pieces[$x])
163 != 0)
164 $match = false;
165
166 if (!$match)
167 break;
168 }
169
170 if ($match && !$did_glob && count($path_pieces) >
171 count($route_pieces)) {
172 /* path was /controller/action/id/something/else but this
173 * route only matched /controller/action/id, consider this
174 * a non-match */
175 $match = false;
176 }
177
178 if ($match) {
179 /* we need at least a valid controller */
180 if ($route["controller"] == "")
181 continue;
182
183 /* note that we pass the action to the controller even if it
184 * doesn't exist, that way at least the backtrace will show
185 * what controller we resolved it to */
186
187 $chosen_route = $route;
188 break;
189 }
190 }
191 }
192
193 if (!$chosen_route)
194 throw new RoutingException("no route for url \"" . $request->path
195 . "\"");
196
197 /* we need at least a controller */
198 if ($chosen_route["controller"] == "")
199 throw new RoutingException("no controller specified");
200
201 /* and a valid controller name */
202 if (!preg_match("/^[a-zA-Z_][a-zA-Z0-9_]*$/",
203 $chosen_route["controller"]))
204 throw new RoutingException("invalid controller matched");
205
206 /* but we can deal with no action by calling the index action */
207 if (!isset($chosen_route["action"]) || $chosen_route["action"] == "")
208 $chosen_route["action"] = static::$DEFAULT_ACTION;
209
210 return $chosen_route;
211 }
212
213 public static function takeRouteForRequest($request) {
214 $route = parent::instance()->routeRequest($request);
215 return static::takeRoute($route, $request);
216 }
217
218 public static function takeRoute($route, $request) {
219 /* store the parameters named in the route with data from the url,
220 * overriding anything passed by the user as get/post */
221 foreach ($route as $k => $v)
222 $request->params[$k] = $v;
223
224 /* camel_case -> CamelCaseController */
225 $c = str_replace(" ", "",
226 ucwords(str_replace("_", " ", $route["controller"])))
227 . "Controller";
228
229 if (!class_exists($c))
230 throw new RoutingException("controller " . $c . " does not exist");
231
232 /* log some basic information */
233 if (Config::log_level_at_least("full"))
234 Log::info("Processing " . $c . "::" . $route["action"] . " (for "
235 . $request->remote_ip() . ") [" . $request->request_method()
236 . "]");
237
238 $request->start_times["app"] = microtime(true);
239
240 $controller = new $c($request);
241 $controller->render_action($route["action"]);
242 }
243}
244
245?>