anewt-developers team mailing list archive
-
anewt-developers team
-
Mailing list archive
-
Message #00135
[Branch ~uws/anewt/anewt.uws] Rev 1729: [urldispatcher] Completely refactor (total rewrite!)
------------------------------------------------------------
revno: 1729
committer: Wouter Bolsterlee <uws@xxxxxxxxx>
branch nick: anewt.uws
timestamp: Sun 2009-08-02 22:09:24 +0200
message:
[urldispatcher] Completely refactor (total rewrite!)
Many architectural changes have been made, e.g. usage of
real exceptions and the url matching logic that now
supportes both url parts and regular expressions. The url
detection logic has been improved to handle non-.htaccess
cases by default.
This module is very well documented: the API docs contain
both descriptions of the API and lots of text on how to use
AnewtURLDispatcher, including various examples.
added:
urldispatcher/urldispatcher.test.php
modified:
urldispatcher/urldispatcher.lib.php
--
lp:anewt
https://code.launchpad.net/~uws/anewt/anewt.uws
Your team Anewt developers is subscribed to branch lp:anewt.
To unsubscribe from this branch go to https://code.launchpad.net/~uws/anewt/anewt.uws/+edit-subscription.
=== modified file 'urldispatcher/urldispatcher.lib.php'
--- urldispatcher/urldispatcher.lib.php 2008-11-10 15:50:11 +0000
+++ urldispatcher/urldispatcher.lib.php 2009-08-02 20:09:24 +0000
@@ -8,536 +8,735 @@
*/
-define('REQUIRE_INTEGER', 1);
-
-
-/**
- * Dispatch URL requests to request handler methods.
- */
-class URLDispatcher extends Container
-{
- var $urlmaps; /**< \private URL maps */
-
- /**
- * Initalizes a new URLDispatcher instance. Make sure you call this method
- * from derived classes, if you decide to override the constructor.
- */
- function URLDispatcher() {
- /* Initialize the url maps */
- $this->urlmaps = array();
- $this->initialize_urlmaps();
- }
-
- /**
- * Initializes the url maps. Override this method to add your own maps by
- * calling add_urlmap() for any URL you like to handle. Although you can add
- * url maps from other parts of your code, this method provides a consistent
- * way to define them. This method does nothing by default, which means
- * that only the default mappings work: command_foo for the /foo url and so
- * on.
- *
- * \see
- * add_urlmap
- */
- function initialize_urlmaps() {}
-
- /**
- * Adds a mapping to the list of url mappings.
- *
- * \param $command_name
- * The name of the command to invoke when this url mapping matches. This
- * should be a simple string, eg. "archive". This string will be prefixed
- * with "command_" and called if the url matches, eg. the method
- * command_archive() will be called. This callback method needs to handle
- * the url.
- *
- * \param $url
- * The url pattern to match. Variable url parts should have a colon
- * prefix. Example: /news/:year/:month/:slug/comments. The year, month and
- * slug variables will be passed in an array to the method handling the
- * request if the url matches.
- *
- * \param $requirements
- * Optional parameter with regular expressions to match against the
- * variables in the url. Only variables matching the regular expression
- * will be handled by this mapping. This way you can be sure your method
- * will always be called with valid parameters (so you don't need to
- * repeat the input checking in your handler methods). Example:
- * array('year' => '/^\\d{4}$/', 'month' => '/^\\d{2}$/')
- *
- */
- function add_urlmap($command_name, $url, $requirements=null)
- {
- /* Requirements are optional */
- if (is_null($requirements))
- $requirements = array();
-
- /* Sanity checks */
- assert('is_string($command_name) && strlen($command_name)');
- assert('is_assoc_array($requirements)');
-
- /* Split the url into smaller pieces. We split on the forward slash
- * character and put the parts in a list after stripping of leading and
- * trailing slashes. Eg. /foo/bar/baz/ is split in (foo, bar, baz). */
- if (is_string($url)) {
- $url = str_strip_prefix($url, '/');
- $url = str_strip_suffix($url, '/');
-
- /* Special case for top level urls */
- if (strlen($url) == 0) {
- $parts = array(); // no parts
- } else {
- $parts = split('/', $url);
- }
- } else {
- assert('is_array($url)');
- $parts = array_trim_strings($url, '/');;
- }
-
- /* Parse the pieces and parameters */
- $map = array();
- foreach ($parts as $part) {
-
- /* Handle variables */
- if (str_has_prefix($part, ':')) {
- $part = substr($part, 1);
- $requirement = array_get_default($requirements, $part, null);
- $map[] = array($part, $requirement);
-
- /* No variable */
- } else {
- $map[] = $part;
- }
- }
-
- /* Add the url map to the list of registered url maps */
- $urlmap = array($command_name, $map);
- $this->urlmaps[] = $urlmap;
- }
-
- /**
- * Sets the default command. This command will be called if none of the
- * other url maps (both explicit and implicit) match the url requested. Use
- * this method to supply default functionality, but do not handle errors
- * here; use handle_error_not_found() instead.
- *
- * \param $method_name
- * The name of the method to use as a default command. The method
- * specified must exist.
- */
- function set_default_command($method_name)
- {
- assert('is_string($method_name)');
- if (!method_exists($this, 'command_' . $method_name)) {
- trigger_error(sprintf('URLDispatcher::set_default_command():
- Supplied method (%s) does not exist.', $method_name),
- E_USER_ERROR);
- }
- $this->_set('default-command', $method_name);
- }
-
- /**
- * \private
- *
- * Matches an input value against a given pattern.
- *
- * \param $input
- * The input value
- *
- * \param $pattern
- * The pattern to match against
- *
- * \return
- * True if the value matches the pattern, false otherwise.
- *
- * \see URLDispatcher::_match_inputs_with_patterns
- */
- function _match_input_with_pattern($input, $pattern)
- {
- /* Literal strings must match */
- if (is_string($pattern)) {
- if ($pattern == $input) {
- return true;
- }
+mkenum(
+ 'ANEWT_URL_DISPATCHER_ROUTE_TYPE_URL_PARTS',
+ 'ANEWT_URL_DISPATCHER_ROUTE_TYPE_REGEX'
+);
+
+
+/**
+ * HTTP exception.
+ */
+final class AnewtHTTPException extends AnewtException
+{
+ /**
+ * Construct a new AnewtException.
+ *
+ * \param $status_code
+ * The HTTP status code for this error. This should be one of the \c
+ * HTTP_STATUS_* constants.
+ * \param $fmt
+ * A sprintf-like format string (optional)
+ * \param $args
+ * The arguments for the format string placeholders
+ *
+ * \see AnewtException
+ */
+ public function __construct($status_code, $fmt=null, $args=null)
+ {
+ $args = func_get_args();
+
+ $status_code = array_shift($args);
+
+ assert('is_int($status_code);');
+ $this->status_code = $status_code;
+
+ $fmt = array_shift($args);
+ if (is_null($fmt))
+ $fmt = http_status_to_string($status_code);
+
+ assert('is_string($fmt);');
+
+ parent::__construct($status_code, vsprintf($fmt, $args));
+ }
+}
+
+
+/**
+ * Dispatcher that routes requests to request handler methods.
+ *
+ * Well-designed web applications use a clean URL scheme for all pages and
+ * resources in the application. Read â<a
+ * href="http://www.w3.org/Provider/Style/URI">Cool URIs don't change</a>â to
+ * find out why this is important. Clean web applicatiosn do not need ugly
+ * <code>.php</code> extensions or weird HTTP GET parameters with cryptic
+ * numbers or strange identifers. Instead, you are encouraged to use clean URLs,
+ * e.g. <code>/user/USERNAME</code> for a user page.
+ *
+ * AnewtURLDispatcher offers the basic functionality to implement web
+ * applications that use clean URLs. AnewtURLDispatcher processes incoming
+ * requests and looks at the URL of the request to decide which piece of code
+ * should be invoked to handle that request. This means that AnewtURLDispatcher
+ * is at the heart of your application: it is the main entry point for all the
+ * functionality your application offers.
+ *
+ * AnewtURLDispatcher is based around two main concepts:
+ * <strong>commands</strong> and <strong>routes</strong>.
+ *
+ * \section commands Commands
+ *
+ * Commands are methods that do the actual work, like building an output page.
+ * Commands are regular methods on your AnewtURLDispatcher subclass, and look
+ * like \c command_xyz(), where \c xyz is the name of the command. What you
+ * would normally create a separate PHP file for, e.g. <code>users.php</code>,
+ * you can now write as a simple method, e.g. <code>command_users()</code>.
+ * Other related pages are just additional methods on the same class.
+ * AnewtURLDispatcher allows you to group related functionality together in one
+ * file, e.g. a user page, an âedit userâ page, or a ânew userâ page. (Note that
+ * if you insist you can still <code>require_once('latest-news.php')</code> in
+ * your command method, though this is not how AnewtURLDispatcher is intended to
+ * be used.
+ *
+ * \section routes Routes
+ *
+ * Routes define how URLs map to commands. This is where you define your URL
+ * scheme and say which command should be executed when AnewtURLDispatcher
+ * processes an incoming request. The section on routes below explains how
+ * routes work in more detail.
+ *
+ * In addition to explicit routes that map URLs to commands, you can also add
+ * a default command that will be invoked if none of the routes match. The
+ * default command will be called if none of the provided URL routes (both
+ * explicit and implicit) match the request URL. This method can be used to
+ * supply default functionality. Note that this is not the right way to handle
+ * errors; use handle_error_not_found() or one of the other error callbacks
+ * instead.
+ *
+ *
+ * \section getting-started Getting Started
+ *
+ * Getting started with AnewtURLDispatcher is not hard. You define the URLs you
+ * want your application to respond to, and implement the corresponding
+ * commands. It is really straight-forward:
+ *
+ * - Subclass AnewtURLDispatcher
+ * - Change some settings if you're not happy with the defaults
+ * - Add routes to map URLs to commands
+ * - Implement commands to actually make your application do something useful,
+ * just like would do otherwise.
+ *
+ * Setting up clean URLs for the Apache web server can be done using this \c
+ * .htaccess snippet. This instructs Apache to invoke \c dispatch.php for all
+ * URLs that do not point to an existing file (such as a static HTML page or
+ * image files, which are not served through the dispatcher).
+ *
+ * \code
+ * RewriteEngine On
+ * RewriteBase /the/base/url/of/your/application
+ *
+ * RewriteRule ^$ dispatch.php [L,QSA]
+ *
+ * RewriteCond %{REQUEST_FILENAME} !-f
+ * RewriteCond %{REQUEST_FILENAME} !-d
+ * RewriteRule ^(.*) dispatch.php [L,QSA]
+ * \endcode
+ *
+ * Since AnewtURLDispatcher takes care of the complete request, the only thing
+ * the \c dispatch.php file should do is something like this:
+ *
+ * \code
+ * // Load the relevant classes here...
+ *
+ * $d = new YourDispatcher();
+ * $d->dispatch();
+ * \endcode
+ *
+ *
+ * \section handling-errors Handling Errors
+ *
+ * If no command to execute can be found, e.g. because none of the routes match,
+ * an AnewtHTTPException will be thrown. Additionally, if you want to raise an
+ * error from your command, e.g. because a requested database record does not
+ * exists, you can throw an AnewtHTTPException yourself. This will cause a âHTTP
+ * 404 Not Foundâ error page to be shown. Example:
+ *
+ * \code
+ * if ($something_could_not_be_found)
+ * throw AnewtHTTPException(
+ * HTTP_STATUS_NOT_FOUND,
+ * 'The requested news item does not exists.');
+ * \endcode
+ *
+ * To create your own error handlers, override the error handling methods, e.g.
+ * handle_error_not_found(). See below for more information.
+ *
+ *
+ * \section properties Properties
+ *
+ * The following properties influence how AnewtURLDispatcher behaves:
+ *
+ * - \c force-trailing-slash indicates whether a trailing slash should be
+ * enforced on URLs that do not have one, by redirecting the request to the
+ * correct URL. This only applies to GET requests with URLs that do not
+ * contain a filename extension. This property is \c true by default.
+ * - \c use-automatic-commands indicates whether the first part of the url
+ * should be used to find a matching command in case none of the explicitly
+ * added routes match, e.g. if the url \c /foo/bar/baz does not match any
+ * route, but the dispatcher has a \c command_foo() method, that is
+ * automatically invoked. This property is \c false by default, since it may
+ * have unwanted side effects when used in combination with explicit routes.
+ * Furthermore, it is often desirable to design your application's URLs
+ * explicitly instead of relying on automatic behaviour.
+ * - \c default-command defines the default command that is invoked if no other
+ * routes the current URL. This fallback feature is disabled by default
+ * (defaults to <code>null</code>).
+ *
+ * When a command is invoked, you can use the following properties to get the
+ * URLs of the current request:
+ *
+ * - \c url-relative contains the relative URL rooted at the dispatcher root
+ * - \c url-prefix is the prefix where the dispatcher resides
+ * - \c url-full is the complete URL, i.e. both \c url-prefix and \c url-relative
+ *
+ * For routes based on URL parts, constraints that should apply to all routes
+ * (instead of to just one route) can be configured:
+ *
+ * - \c constraints is an associative array that maps parameter names to regular
+ * expressions. See the section on routes below for more information.
+ */
+abstract class AnewtURLDispatcher extends AnewtContainer
+{
+ /**
+ * URL routes
+ */
+ private $routes = array();
+
+ /**
+ * Constraints for URL parts.
+ *
+ * This only applies to routes added using add_route_url_parts().
+ */
+ private $url_part_constraints = array();
+
+ /**
+ * Construct a new AnewtURLDispatcher instance.
+ *
+ * Make sure you call this method from derived classes!
+ */
+ public function __construct()
+ {
+ $this->_seed(array(
+
+ 'force-trailing-slash' => true,
+
+ 'use-automatic-commands' => false,
+ 'default-command' => null,
+
+ /* URLs available to commands */
+ 'url-relative' => null,
+ 'url-prefix' => null,
+ 'url-full' => null,
+ ));
+ }
+
+ /** \{
+ * \name Route Methods
+ *
+ * <strong>Routes</strong> define how URLs map to commands. Whenever a route
+ * matches the request, the corresponding command is called to handle the
+ * request. Two types of routes can be used (read on for an explanation on
+ * both):
+ *
+ * -# using regular expressions
+ * -# using URL parts
+ *
+ * Incoming requests will be matched against all defined routes, until
+ * a route matches the current request. The routes are tried in the order
+ * they are added to the dispatcher. Therefore you should add more specific
+ * routes before more general routes. If a route matches, the corresponding
+ * command is invoked with the \c $parameters argument containing all
+ * matches parameters (either regular expression matches, or named
+ * parameters, depending on the type of URL route).
+ *
+ * <strong>Regular expression routes</strong> use a regular expression to
+ * match an URL, and can be added using
+ * AnewtURLDispatcher::add_route_regex(). The URL that is used for matching
+ * does not contain a leading <code>/</code>, so you should not specify this
+ * in the regular expression.
+ *
+ * To extract parameters from the URL, the \c $parameters array passed to
+ * the command contains the matches from \c preg_match(), i.e. the
+ * parenthesized expressions. Parameters can have names (instead of numbers)
+ * by using named matches, e.g. <code>(?P<year>\\d{4})</code>. See below for
+ * more information about how regular expressions are used.
+ *
+ * Examples for routes using regular expressions:
+ *
+ * \code
+ * $this->add_route_regex('latest_news', '#^news$#');
+ * $this->add_route_regex('month_archive', '#^news/(\\d{4})$#');
+ * $this->add_route_regex('month_archive', '#^news/(?P<year>\\d{4})/(?P<month>\\d{1,2})$#');
+ * \endcode
+ *
+ * <strong>Routes based on URL parts</strong> are the other way to define
+ * URL routes. These routes can be added using
+ * AnewtURLDispatcher::add_route_url_parts(). To define a route, you can
+ * provide either a URL string, or an array of URL parts. An URL part
+ * corresponds to a "path component" in the URL, e.g. the URL
+ * <code>/news/2009/06/</code> consists of three parts.
+ *
+ * For routes based on URL parts, parameters can be specified using a
+ * \c : character before the parameter name, e.g. \c :year The constraints
+ * used for the parameters are regular expressions that will be matched
+ * using \c preg_match(). If no constraint is specified for a parameter, no
+ * checking is performed (any value is accepted). See below for more
+ * information about how regular expressions are used.
+ *
+ * Examples for routes using URL parts:
+ *
+ * \code
+ * $this->constraints = array('year' => '#^\\d{4}$#', 'month' => '#^\\d{1,2}$#');
+ * $this->add_route_url_parts('latest_news', 'news');
+ * $this->add_route_url_parts('month_archive', 'news/:year/:month/');
+ * $this->add_route_url_parts('month_archive', array('news', ':year', ':month'));
+ * \endcode
+ *
+ * Constraints should be provided as an associative array that maps
+ * parameter names to regular expressions. The constraints in the \c
+ * constraints property apply to all routes using URL parts, but can be
+ * overridden by specifying additional constraints when calling
+ * AnewtURLDispatcher::add_route_url_parts().
+ *
+ * Note about the regular expressions used for URL and parameter matching:
+ * the pattern is passed directly to <code>preg_match()</code>. Make sure to
+ * include <code>^</code> and <code>$</code> appropriately to match the full
+ * string, since otherwise you might match URLs that you do not want to
+ * match! Hint: use a <code>#</code> character for the regular expression
+ * delimiter, since the often-used <code>/</code> is confusing because it is
+ * a common character in URLs. (A literal <code>#</code> cannot occur in
+ * URLs anyway since it is used for the client-side fragment specifier.)
+ */
+
+ /**
+ * Add a route based on a regular expression.
+ *
+ * \param $command
+ * The command to execute when this route matches.
+ * \param $regex
+ * The regular expression to match against the URL.
+ *
+ * \see add_route_url_parts
+ */
+ public function add_route_regex($command, $regex)
+ {
+ $method = sprintf('command_%s', $command);
+ if (!method_exists($this, $method))
+ throw new AnewtHTTPException(HTTP_STATUS_INTERNAL_SERVER_ERROR, 'Dispatcher method "%s" is not defined.', $method);
+
+ $this->routes[] = array(
+ ANEWT_URL_DISPATCHER_ROUTE_TYPE_REGEX,
+ $command, $regex);
+ }
+
+ /**
+ * Add a route based on URL parts.
+ *
+ * \param $command
+ * The command to execute when this route matches.
+ * \param $url_parts
+ * Array with the URL parts, or a URL string. Variable parts (parameters)
+ * can be specified using a \c : character, e.g. \c :year
+ * \param $additional_constraints
+ * Associative array with constraints specific to this route. These are
+ * applied on top of the default constraints that can be set using the \c
+ * constraints property on the AnewtURLDispatcher instance.
+ *
+ * \see add_route_regex
+ */
+ public function add_route_url_parts($command, $url_parts, $additional_constraints=null)
+ {
+ $method = sprintf('command_%s', $command);
+ if (!method_exists($this, $method))
+ throw new AnewtHTTPException(HTTP_STATUS_INTERNAL_SERVER_ERROR, 'Dispatcher method "%s" is not defined.', $method);
+
+ if (is_null($additional_constraints))
+ $additional_constraints = array();
+
+ $this->routes[] = array(
+ ANEWT_URL_DISPATCHER_ROUTE_TYPE_URL_PARTS,
+ $command, $url_parts, $additional_constraints);
+ }
+
+ /** \} */
+
+
+ /** \{
+ * \name Dispatching Methods
+ */
+
+ /**
+ * Dispatch an URL to the correct handlers.
+ *
+ * \param $url
+ * The URL to dispatch (optional, defaults to null). In almost all cases
+ * you should not provide this URL to let the dispatcher figure out the
+ * current request URL and prefix.
+ *
+ * \param $prefix
+ * The prefix of the URL that should not be taken into account to match
+ * URL routes. The automatic detection works fine in almost all case, so
+ * you should likely omit this parameter.
+ *
+ * \see AnewtURLDispatcher::real_dispatch
+ */
+ public function dispatch($url=null, $prefix=null)
+ {
+ try
+ {
+ $this->real_dispatch($url, $prefix);
+ }
+ catch (Exception $e)
+ {
+ $http_status = ($e instanceof AnewtHTTPException)
+ ? $e->getCode()
+ : HTTP_STATUS_INTERNAL_SERVER_ERROR;
+
+ /* Send the HTTP response header... */
+ header(sprintf('HTTP/1.1 %03d', $http_status));;
+
+ /* ...and call an error handling method */
+ switch ($http_status)
+ {
+ case HTTP_STATUS_NOT_FOUND:
+ $this->handle_error_not_found($e);
+ break;
+
+ case HTTP_STATUS_FORBIDDEN:
+ $this->handle_error_forbidden($e);
+ break;
+
+ default:
+ $this->handle_error($e);
+ break;
+ }
+ }
+ }
+
+ /**
+ * (Really) dispatch an URL to the correct handlers.
+ *
+ * This method does the actual magic, such as URL parsing, matching and
+ * command invocation. You can optionally provide a custom URL and tell the
+ * dispatcher that some parts of the URL should be skipped when handling
+ * this request.
+ *
+ * \param $url
+ * \param $prefix
+ * \see AnewtURLDispatcher::dispatch
+ */
+ private function real_dispatch($url=null, $prefix=null)
+ {
+ /* Use the current URL if no explicit url was given */
- } else {
- assert('is_array($pattern)');
- list($parameter, $req) = $pattern;
-
- /* null values mean there should be no validation */
- if (is_null($req)) {
- return array($parameter, $input);
- }
-
- /* Special data types */
- elseif (is_int($req)) {
-
- switch ($req) {
- case REQUIRE_INTEGER:
- if (preg_match('/^\d+$/', $input)) {
- return array($parameter, (int) $input);
- }
-
- default:
- return false;
- }
-
- /* Try regular expression matching */
- } else {
- assert('is_string($req)');
- if (preg_match($req, $input)) {
- return array($parameter, $input);
- }
- }
- }
-
- return false;
- }
-
- /**
- * \private
- *
- * Matches input values against patterns.
- *
- * \param $inputs
- * The input values
- *
- * \param $patterns
- * The patterns to match against.
- *
- * \return
- * True if the values match the patterns, false otherwise.
- *
- * \see URLDispatcher::_match_input_with_pattern
- */
- function _match_inputs_with_patterns($inputs, $patterns)
- {
- /* The number of parameters must match */
- if (count($inputs) != count($patterns))
- return false;
-
- $parameters = array();
- while ($patterns) {
- $pattern = array_shift($patterns);
- $input = array_shift($inputs);
-
- $result = $this->_match_input_with_pattern($input, $pattern);
-
- if ($result === false) {
- return false;
- } elseif ($result === true) {
- // do nothing, this was a simple string match
- } else {
- list ($name, $value) = $result;
- $parameters[$name] = $value;
- }
- }
- return $parameters;
- }
-
- /**
- * Dispatch an URL to the correct handlers. This method does the actual
- * magic, such as url parsing, matching and command invocation. You can
- * optionally provide a custom url and tell the dispatcher that some parts
- * of the url should be skipped when handling this request.
- *
- * \param $url
- * The url to dispatch (optional, defaults to null). Omit this value (or
- * provide null) to use the current request url.
- *
- * \param $skip_path_components
- * The number of path components to skip when handling this request
- * (optional, defaults to null). This is useful if you want to skip some
- * prefix present in all urls, such as ~username. If you don't specify
- * this parameter the value of <code>$_SERVER['PHP_SELF']</code> will be
- * used to figure out how many components to skip.
- */
- function dispatch($url=null, $skip_path_components=null)
- {
- if (is_null($skip_path_components))
+ if (is_null($url))
+ $url = AnewtRequest::relative_url();
+
+ /* Figure out the right base location if no prefix was given. If the URL
+ * starts with the PHP script name, we assume no htaccess file has been
+ * setup to beautify the website URLs. In this case the relevant parts
+ * of the URL are added after the PHP script name. Example URL of such
+ * a setup is http://.../dispatch.php/a/b/c/. Otherwise, it is quite
+ * likely a htaccess file is used to point all requests to a script that
+ * invokes the dispatcher. We assume this script is placed in the
+ * toplevel directory, so we use that directory as the prefix. */
+ if (is_null($prefix))
{
- /* Figure out the current script location. This is most likely
- * the script living in the document root calling this method. Use
- * the directory path component of this file to figure out how many
- * path components should be skipped. */
- $dir_url = dirname($_SERVER['PHP_SELF']);
-
- if ($dir_url == DIRECTORY_SEPARATOR)
- $skip_path_components = 0;
+ if (str_has_prefix($url, $_SERVER['SCRIPT_NAME']))
+ $prefix = $_SERVER['SCRIPT_NAME'];
else
- $skip_path_components = count(explode('/', str_strip_prefix($dir_url, '/')));
+ $prefix = dirname($_SERVER['SCRIPT_NAME']);
}
- /* Initialization */
+ assert('is_string($url)');
+ assert('is_string($prefix)');
+
+
+ /* Strip off the GET parameters from the URL */
+
$get_params = '';
-
- /* Use the current url if no explicit url was given */
- if (is_null($url)) {
- $url = Request::relative_url();
- }
-
- /* We need to strip off the GET parameters */
$question_mark_pos = strpos($url, '?');
if ($question_mark_pos !== false) {
$get_params = substr($url, $question_mark_pos);
$url = substr($url, 0, $question_mark_pos);
}
- /* Sanity checks */
- assert('is_int($skip_path_components) && $skip_path_components >= 0');
- assert('is_string($url)');
-
- /* Force trailing slash for GET requests? */
- if (
- // only rewrite GET requests:
- Request::is_get() &&
-
- // only if enabled:
- $this->_getdefault('force-trailing-slash', true) &&
-
- // do not rewrite the toplevel url:
- ($url != '/') &&
-
- // only rewrite if there is no slash at the end:
- !str_has_suffix($url, '/') &&
-
- // only if the last part doesn't contain a . character (file extension):
- preg_match('/^.*\/[^.\/]+$/', $url)
- ) {
- redirect($url . '/' . $get_params, HTTP_STATUS_MOVED_PERMANENTLY);
+
+ /* Redirect GET requests when trailing slash is required but missing */
+
+ if (!str_has_suffix($url, '/') /* Only if there is no slash at the end */
+ && $this->force_trailing_slash /* Only if enabled */
+ && AnewtRequest::is_get() /* Only for GET requests */
+ && !preg_match('#^.*\.[^\/]*$#', $url) /* Only if the last part doesn't contain a . character (file extension) */
+ )
+ {
+ redirect(
+ sprintf('%s/%s', $url, $get_params),
+ HTTP_STATUS_MOVED_PERMANENTLY);
}
- /* Store the url so that it can be used later */
- $this->_set('url', $url);
-
- /* Split the url into smaller pieces */
+
+ /* Strip off prefix and slashes */
+
+ $this->request_url_full = $url;
+ $url = str_strip_prefix($url, $prefix);
$url = str_strip_prefix($url, '/');
$url = str_strip_suffix($url, '/');
- $components = split('/', $url);
-
- /* Skip some components if requested, and store the cut-off part in the
- * 'base-url' property. */
- if (count($components) < $skip_path_components) {
- $this->_handle_result(HTTP_STATUS_INTERNAL_SERVER_ERROR);
- }
- $base_url = sprintf('/%s/', join('/', array_slice($components, 0, $skip_path_components)));
- $this->_set('base-url', $base_url);
- $components = array_slice($components, $skip_path_components);
-
- /* Special case for top level urls */
- if ((count($components) == 1) && (strlen($components[0]) == 0)) {
- $components = array();
- }
-
-
- /* Try all URL maps and see if they match the input url */
- $found_map = false;
+ $this->request_url = $url;
+
+
+ /* Try to find a matching route and extract the parameters */
+
+ $found_route = false;
$command_name = null;
$parameters = array();
- foreach ($this->urlmaps as $urlmap) {
-
- list ($command_name, $patterns) = $urlmap;
-
- /* Check for valid parameters */
- $match = $this->_match_inputs_with_patterns($components, $patterns);
-
- /* This urlmap didn't match, try next one */
- if ($match === false) {
- continue;
-
- /* This urlmap matched! */
- } else {
- $parameters = $match;
- $found_map = true;
+ $url_parts = strlen($url) > 0
+ ? explode('/', $url)
+ : array();
+
+ foreach ($this->routes as $route)
+ {
+ $route_type = array_shift($route);
+ $route_command_name = array_shift($route);
+ $route_parameters = array();
+
+
+ /* Type I: Routes using regular expression */
+
+ if ($route_type == ANEWT_URL_DISPATCHER_ROUTE_TYPE_REGEX)
+ {
+ list ($pattern) = $route;
+
+ /* Try both with and without trailing slash */
+ if (
+ preg_match($pattern, $url, $route_parameters)
+ || preg_match($pattern, sprintf('%s/', $url), $route_parameters)
+ )
+ {
+ /* We don't care about $parameters[0] (it contains the full match) */
+ array_shift($route_parameters);
+
+ $command_name = $route_command_name;
+ $parameters = $route_parameters;
+ $found_route = true;
+ break;
+ }
+ }
+
+
+ /* Type II: Routes using URL parts */
+
+ elseif ($route_type == ANEWT_URL_DISPATCHER_ROUTE_TYPE_URL_PARTS)
+ {
+ list ($route_url, $additional_constraints) = $route;
+
+
+ /* Route URL can be a string or an array */
+
+ if (is_string($route_url))
+ {
+ $route_url = str_strip_prefix($route_url, '/');
+ $route_url = str_strip_suffix($route_url, '/');
+
+ $route_url_parts = strlen($route_url) > 0
+ ? explode('/', $route_url)
+ : array();
+ }
+ elseif (is_numeric_array($route_url))
+ $route_url_parts = $route_url;
+ else
+ throw new AnewtException('Invalid url route: %s', $route_url);
+
+
+ /* Match the URL parts against the route URL parts */
+
+ if (count($url_parts) != count($route_url_parts))
+ continue;
+
+ $constraints = array_merge($this->url_part_constraints, $additional_constraints);
+
+ for ($i = 0; $i < count($url_parts); $i++)
+ {
+ /* If the URL starts with a ':' character it is
+ * a parameter... */
+
+ if ($route_url_parts[$i]{0} === ':')
+ {
+ $parameter_name = substr($route_url_parts[$i], 1);
+ $parameter_value = $url_parts[$i];
+
+ /* If there is a constraint for this parameter, the
+ * value must match the constraint. If not, this route
+ * cannot be used. */
+ if (array_key_exists($parameter_name, $constraints))
+ {
+ $pattern = $constraints[$parameter_name];
+
+ if (!preg_match($pattern, $parameter_value))
+ continue 2;
+ }
+
+ $route_parameters[$parameter_name] = $parameter_value;
+ }
+
+
+ /* ...otherwise, it is a fixed value */
+
+ elseif ($url_parts[$i] !== $route_url_parts[$i])
+ continue 2;
+ }
+
+
+ /* If this code is reached, we found a matching route with all
+ * the constraints on the URL parts satisfied. */
+
+ $command_name = $route_command_name;
+ $parameters = $route_parameters;
+ $found_route = true;
break;
}
- }
-
- /* No explicit map found, try an implicit map */
- if (!$found_map && $this->_getdefault('use-implicit-commands', true)) {
- $command_name = join('_', $components);
- $command_name = str_replace('-', '_', $command_name);
- /* The method must exist */
- $command = 'command_' . $command_name;
- $found_map = method_exists($this, $command);
- }
-
- /* As a last resort try the default handler, if one was set. There's no
- * need to check the availability of the method; set_default_command()
- * already did that. */
- if (!$found_map && $this->_isset('default-command')) {
- $command_name = $this->_get('default-command');
- $found_map = true;
- }
-
- /* Sanity check: is the method available? */
- $command = 'command_' . $command_name;
- if (!method_exists($this, $command)) {
- /* FIXME: it's not clear if this is desirable */
- /* We found a handler name but the method doesn't exist... */
- /* Trying the default handler, but remember which command
- * we wanted to access. */
- if (!$this->_isset('default_command')) {
- /* We give up. */
- $found_map = false;
- } else {
- $command = 'command_' . $this->_get('default-command');
- }
-
+ else
+ {
+ assert('false; // not reached');
+ }
+ }
+
+
+ /* If no route matches, try an automatic route. Only the first URL part
+ * is considered for this. */
+
+ if (!$found_route && $this->use_automatic_commands)
+ {
+ $url_parts = explode('/', $url, 2);
+ if ($url_parts)
+ {
+ $command_name = $url_parts[0];
+ $found_route = method_exists($this, sprintf('command_%s', $command_name));
+ }
+ }
+
+
+ /* As a last resort try the default handler, if one was set. */
+
+ $default_command = $this->default_command;
+ if (!($found_route || is_null($default_command)))
+ {
+ if (!method_exists($this, sprintf('command_%s', $default_command)))
+ throw new AnewtHTTPException(HTTP_STATUS_INTERNAL_SERVER_ERROR,
+ 'Default command "%s" cannot be found.', $default_command);
+
+ $command_name = $default_command;
+ $found_route = true;
}
/* If we still don't have a command, we give up. Too bad... not found */
- if (!$found_map) {
- $this->_handle_result(HTTP_STATUS_NOT_FOUND);
- return false;
- }
-
- /* Store the command name for use by _handle_result() and possibly
- * pre_command(). */
- $this->_set('command', $command_name);
-
- /* If this piece of code is reached, a valid command has been found. Run
- * the pre-command hook, call the appropriate method and handle the
- * result. The pre_command() method may return HTTP_STATUS_NOT_FOUND or
- * HTTP_STATUS_FORBIDDEN as well, so we need to handle the status
- * carefully. */
-
- $status = $this->pre_command($parameters);
-
- /* The pre_command() method is not required to have a return value. If
- * it doesn't return anything, $status will be null at this point. If
- * that's the case we assume everything is alright. */
- if (is_null($status)) {
- $status = HTTP_STATUS_OK;
- }
-
- if ($status == HTTP_STATUS_OK) {
- /* The pre_command() method succeeded. Run the actual command and
- * keep track of the status. */
- $status = $this->$command($parameters);
- }
- /* Regardless of whether the actual command has been executed, the
- * result handler is invoked. Note: The post_command() is only invoked
- * if both the pre_command() and the actual command method return
- * HTTP_STATUS_OK (there's no danger of calling post_command() if no
- * real command has been executed). */
- $this->_handle_result($status);
- }
-
-
- /**
- * \private
- *
- * Handles the result of a command invocation. Don't invoke this method from
- * the outside.
- *
- * \param $status
- * The status code of the result.
- */
- function _handle_result($status)
- {
- /* Handle some special status codes */
- if (is_null($status)) {
- trigger_error(sprintf(
- 'Command \'command_%s\' did not return a status code',
- $this->_get('command')),
- E_USER_ERROR);
- }
-
- if (is_bool($status)) {
- $status = $status
- ? HTTP_STATUS_OK
- : HTTP_STATUS_INTERNAL_SERVER_ERROR;
- }
-
- /* Status must be an integer once this code is reached */
- if (!is_int($status)) {
- $status = HTTP_STATUS_INTERNAL_SERVER_ERROR;
- }
-
- /* Redirection cannot be done using status codes */
- if (($status >= 300) && ($status < 400)) {
- $status = HTTP_STATUS_INTERNAL_SERVER_ERROR;
- }
-
- /* Command completed successfully */
- if (($status >= 200) && ($status < 300)) {
-
- /* Post-dispatch hook */
- $this->post_command();
-
- return true;
-
-
- /* Command did not complete successfully */
- } else {
-
- $this->_handle_error($status);
-
- return false;
- }
- }
-
- /** \{
- * \name Error handling methods
- */
-
- /**
- * Handles errors. Override this method for custom error handling.
- *
- * \param $http_status_code
- * The error code to be handled.
- */
- function handle_error($http_status_code)
- {
- $this->_show_error_page($http_status_code);
- }
-
- /**
- * Handles 'not found' errors. Override this method for custom error handling.
- */
- function handle_error_not_found()
- {
- $this->_show_error_page(HTTP_STATUS_NOT_FOUND);
- }
-
- /**
- * Handles 'forbidden' errors. Override this method for custom error handling.
- */
- function handle_error_forbidden()
- {
- $this->_show_error_page(HTTP_STATUS_FORBIDDEN);
- }
-
- /**
- * \private
- *
- * Handle command errors. This method sends the correct HTTP headers and
- * calls another function to do the actual error handling. Do not override
- * this function!
- *
- * \param $status
- * The status code returned by the command
- *
- * \see URLDispatcher::handle_error
- * \see URLDispatcher::handle_error_not_found
- * \see URLDispatcher::handle_error_forbidden
- */
- function _handle_error($status) {
- /* Send the HTTP repsonse header */
- header(sprintf('HTTP/1.1 %03d', $status));
-
- /* Handle common errors (not found and forbidden) */
- if ($status == HTTP_STATUS_NOT_FOUND) {
- $this->handle_error_not_found();
-
- } elseif ($status == HTTP_STATUS_FORBIDDEN) {
- $this->handle_error_forbidden();
-
- /* Fallback to general error handler for other status codes */
- } else {
- $this->handle_error($status);
- }
+
+ if (!$found_route)
+ throw new AnewtHTTPException(HTTP_STATUS_NOT_FOUND);
+
+ /* Run the command. We know the method exists since that is already
+ * checked in the add_route_*() methods. */
+
+ $method = sprintf('command_%s', $command_name);
+ $this->pre_command($parameters);
+ $this->$method($parameters);
+ $this->post_command($parameters);
+ }
+
+ /** \} */
+
+
+ /** \{
+ * \name Callback Methods
+ */
+
+ /**
+ * Called before the actual command is invoked.
+ *
+ * This method does nothing by default.
+ *
+ * \param $parameters
+ * The parameters array, as passed to the command.
+ */
+ protected function pre_command($parameters) {}
+
+ /**
+ * Called after the command has completed succesfully.
+ *
+ * Note that this method may or may not be called depending on the
+ * succcesful completion of pre_command() and real command method.
+ *
+ * This method does nothing by default.
+ *
+ * \param $parameters
+ * The parameters array, as passed to the command.
+ */
+ protected function post_command($parameters) {}
+
+ /** \} */
+
+
+ /** \{
+ * \name Error Handling Methods
+ *
+ * A command may throw an AnewtHTTPException (or another exception) to
+ * indicate something went wrong during the request. In this case,
+ * AnewtURLDispatcher::dispatch() will invoke the
+ * AnewtURLDispatcher::handle_error() method with the exception that was
+ * thrown.
+ *
+ * The common "HTTP 404 Not Found" and "HTTP 403 Forbidden" cases have their
+ * own convenience error handler methods that you can override:
+ * AnewtURLDispatcher::handle_error_not_found() and
+ * AnewtURLDispatcher::handle_error_forbidden(). All other errors are passed
+ * directly to AnewtURLDispatcher::handle_error().
+ */
+
+ /**
+ * Handle dispatcher errors.
+ *
+ * Override this method for custom error handling.
+ *
+ * \param $exception
+ * The exception to be handled.
+ *
+ * \see handle_error_not_found
+ * \see handle_error_forbidden
+ */
+ protected function handle_error($exception)
+ {
+ $this->show_error_page($exception);
+ }
+
+ /**
+ * Handle 'Not Found' errors.
+ *
+ * Override this method for custom error handling. The default
+ * implementation just propagates the error by calling
+ * AnewtURLDispatcher::handle_error().
+ *
+ * \param $exception
+ *
+ * \see handle_error
+ */
+ protected function handle_error_not_found($exception)
+ {
+ $this->handle_error($exception);
+ }
+
+ /**
+ * Handle 'Forbidden' errors.
+ *
+ * Override this method for custom error handling. The default
+ * implementation just propagates the error by calling
+ * AnewtURLDispatcher::handle_error().
+ *
+ * \param $exception
+ *
+ * \see handle_error
+ */
+ protected function handle_error_forbidden($exception)
+ {
+ $this->handle_error($exception);
}
/**
@@ -545,63 +744,72 @@
*
* Show a simple error page.
*
- * \param $http_status_code
- * The http status code.
+ * \param $exception
+ * The AnewtHTTPException instance
*/
- function _show_error_page($http_status_code)
+ protected function show_error_page($exception)
{
+ assert('$exception instanceof Exception;');
+
anewt_include('page');
- $title = sprintf(
- '%d - %s',
- $http_status_code,
- http_status_to_string($http_status_code));
-
$p = new AnewtPage();
- $p->set('show-dublin-core', false);
- $p->set('title', $title);
-
+ $p->show_dublin_core = false;
+
+
+ /* Title */
+
+ if ($exception instanceof AnewtHTTPException)
+ $title = sprintf(
+ '%d - %s',
+ $exception->getCode(),
+ http_status_to_string($exception->getCode()));
+ else
+ $title = sprintf(
+ '%d - %s',
+ HTTP_STATUS_INTERNAL_SERVER_ERROR,
+ http_status_to_string(HTTP_STATUS_INTERNAL_SERVER_ERROR));
+
+ $p->title = $title;
$p->append(ax_h1($title));
- switch ($http_status_code)
- {
- case HTTP_STATUS_NOT_FOUND:
- $p->append(ax_p('The requested resource cannot be found.'));
- break;
-
- case HTTP_STATUS_FORBIDDEN:
- $p->append(ax_p('Access to the requested resource was denied.'));
- break;
-
- default:
- /* No explanation (just a title) */
- break;
- }
+
+ /* Add default explanation (instead of just a title) for some exceptions. */
+
+ $message = $exception->getMessage();
+ if ($message)
+ {
+ $p->append(ax_p($message));
+ }
+ else
+ {
+ switch ($exception->getCode())
+ {
+ case HTTP_STATUS_NOT_FOUND:
+ $p->append(ax_p('The requested resource cannot be found.'));
+ break;
+
+ case HTTP_STATUS_FORBIDDEN:
+ $p->append(ax_p('Access to the requested resource was denied.'));
+ break;
+
+ case HTTP_STATUS_INTERNAL_SERVER_ERROR:
+ $p->append(ax_p('An internal server error occurred while processing your request.'));
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ /* Backtrace */
+
+ $p->append(ax_pre($exception->getTraceAsString()));
$p->flush();
}
/** \} */
-
- /** \{
- * \name Callback methods
- */
-
- /**
- * Called before the actual command is invoked. Does nothing by default.
- *
- * \param $parameters
- * The parameters array, as passed to the command itself as well.
- */
- function pre_command($parameters) {}
-
- /**
- * Called after the command has completed succesfully. Does nothing by
- * default.
- */
- function post_command() {}
-
- /** \} */
}
?>
=== added file 'urldispatcher/urldispatcher.test.php'
--- urldispatcher/urldispatcher.test.php 1970-01-01 00:00:00 +0000
+++ urldispatcher/urldispatcher.test.php 2009-08-02 20:09:24 +0000
@@ -0,0 +1,42 @@
+<?php
+
+error_reporting(E_ALL | E_STRICT);
+
+require_once(dirname(__FILE__) . '/../anewt.lib.php');
+
+anewt_include('urldispatcher');
+
+
+class TestDispatcher extends AnewtURLDispatcher
+{
+ function __construct()
+ {
+ parent::__construct();
+
+ $this->add_route_regex('test', '#^regex$#');
+ $this->add_route_regex('test', '#^regex/(\d)$#');
+ $this->add_route_regex('test', '#^regex/(?P<first>\d+)/(?P<second>\d+)$#');
+
+ $this->constraints = array(
+ 'first' => '/^1$/',
+ 'second' => '/^\d+$/',
+ );
+
+ $this->add_route_url_parts('test', 'parts/foo/:bar');
+ $this->add_route_url_parts('test', '/parts/:first/:second/');
+ $this->add_route_url_parts('test', array('parts', ':first', ':second', ':third'), array('third' => '#^three$#'));
+
+ $this->add_route_url_parts('test', '/');
+ }
+
+ function command_test($parameters)
+ {
+ print_r($parameters);
+ }
+}
+
+
+$d = new TestDispatcher();
+$d->dispatch();
+
+?>