Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

New (and generic) web application routers. #1259

Merged
merged 4 commits into from

7 participants

@LouisLandry

I still need to write some documentation, but hopefully the code is simple and clean enough to understand clearly. There are three new classes: JApplicationWebRouter, JApplicationWebRouterBase and JApplicationWebRouterRest. These are not meant to be one size fits all. They are meant to provide a solid foundation for what is hopefully the majority of use cases in URL routing.

JApplicationWebRouter

The foundational web router class is abstract. The premise of the class is that some sort of route string would be passed into the execute method, that string would be parsed within the parseRoute() method to determine the name of the appropriate controller to execute, then fetchController() would be called to actually instantiate the controller object and return it. Once that is done then the controller is simply executed.

I've purposefully not provided an implementation of parseRoute() in the base class because there are infinite different ways a route can be examined to determine how to proceed. Other features of the class include the ability to set a default controller name if an empty route was given, and also the ability to set a controller class prefix that would be prepended to any controller name returned from parseRoute().

JApplicationWebRouterBase

The basic concrete implementation of JApplicationWebRouter provides a very simple algorithm to parse routes based on a pattern => controller mapping. The premise is that if an incoming route matches the given pattern than the associated controller will be returned and ultimately executed. It is important to note that for this implementation the pattern is not a regular expression. The route and patterns are broken down into segments split over / and evaluated segment by segment. There must be an exact match for all segments to constitute the route matching the pattern.

For example if we have pattern continents/europe:

  • The route continents/europe is a match. [Every segment matches]
  • The route continents/asia is NOT a match. [The second segment asia does not match europe]
  • The route continents/europe/germany is also NOT a match. [The final segment germany is not in the pattern]

Variables

Along with standard string comparison for route segments, patterns can include variables by prefixing the segment with a :. To continue with our previous example let's make the actual continent a variable.

The new pattern is continents/:continent:

  • The route continents/europe is a match. [Every segment matches because europe is in a variable segment]
  • The route continents/asia is ALSO a match. [Again, asia is in a variable segment]
  • The route continents/europe/germany does NOT a match. [The final segment germany is still not in the pattern even though the europe segment works since it is in a variable segment]

A side effect of using variables is that the variable name (everything in the segment except the opening :) is added to the application input object with the value found in a successful route match. Using the same example as above; pattern continents/:continent:

  • The route continents/europe matches and the application input object will have europe set for input variable continent.
  • The route continents/asia matches and the application input object will have asia set for input variable continent.

Note: If you need to start a segment of your pattern with a : and do not want the segment to be considered a variable simply escape the colon like \:.

Splats

In addition to the standard per-segment variables you can use the asterisk * symbol to indicate a match across segments. For example the pattern continents/*/berlin would match a route continents/europe/germany/berlin. It would not match the route continents/europe/germany/frankfurt since it is looking for the last segments to be berlin.

If you want to capture the part of the route that matches the splat then you simply need to name the segment. For example the pattern continents/*cont_nation/berlin would still match the route continents/europe/germany/berlin however this time the input object will have europe/germany set for the input variable cont_nation.

You can also mix and match the splats and variables such as the pattern continents/*/:city. That will match the route continents/europe/germany/berlin and berlin will be the value for the input variable city.

Note: Similar to variables if you need to start a segment of your pattern with a * and do not want the segment to be considered a splat simply escape the asterisk like \*.

JApplicationWebRouterRest

The generic RESTful router extends JApplicationWebRouterBase and inherits the same pattern matching with variables as discussed above. The only addition is that JApplicationWebRouterRest also maintains a map of HTTP Methods [GET, POST, PUT, etc.] to controller class suffixes. This is used so that you can namespace your controllers based on the type of action being performed on the resource. As an example if we have an application that manages resources called "articles" we would only need two pattern rules: one for with an article ID and one for without.

  • articles => articles - If no ID is present.
  • articles/:article_id => articles - If an article ID is present.

Let's also set the controller prefix to MyController since that happens to be my application's prefix for finding controllers.

Now all we need are the following classes since the JApplicationWebRouterRest router is going to take care of the HTTP Method mapping for us.

  • MyControllerArticlesCreate - POST /articles - Create a new article.
  • MyControllerArticlesDelete - DELETE /articles/42 - Delete article 42.
  • MyControllerArticlesGet -GET /articles/7` - Get article 7.
  • MyControllerArticlesUpdate - PUT /articles/7 - Save an update to article 7.
@chdemko chdemko commented on the diff
libraries/joomla/application/web/router.php
((66 lines not shown))
+ *
+ * @since 12.3
+ * @throws InvalidArgumentException
+ * @throws RuntimeException
+ */
+ public function execute($route)
+ {
+ // Get the controller name based on the route patterns and requested route.
+ $name = $this->parseRoute($route);
+
+ // Get the controller object by name.
+ $controller = $this->fetchController($name);
+
+ // Execute the controller.
+ $controller->execute();
+ }
@chdemko
chdemko added a note

Why not returning $this for chaining?

I just didn't see a need to offer chaining here. By all accounts execute() would be the last link in any chain. After the router has been executed there is really nothing else left to do. Do you really think that's useful?

@chdemko
chdemko added a note

+1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
libraries/joomla/application/web/router/base.php
((38 lines not shown))
+ {
+ $this->maps[(string) $pattern] = (string) $controller;
+
+ return $this;
+ }
+
+ /**
+ * Add a route map to the router. If the pattern already exists it will be overwritten.
+ *
+ * @param array $maps A list of route maps to add to the router as $pattern => $controller.
+ *
+ * @return JApplicationWebRouter This object for method chaining.
+ *
+ * @since 12.3
+ */
+ public function addMaps(array $maps)
@chdemko
chdemko added a note

Forcing to array will not allow to use iterators here

Solid point. I'll adjust that.

Fixed in 4d777d2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
libraries/joomla/application/web/router/base.php
((70 lines not shown))
+ * @since 12.3
+ * @throws InvalidArgumentException
+ */
+ protected function parseRoute($route)
+ {
+ // Initialize variables.
+ $controller = false;
+
+ // Sanitize and explode the route.
+ $route = explode('/', trim(parse_url($route, PHP_URL_PATH), ' /'));
+
+ // Cache the route length so we don't have to calculate this on every iteration through the pattern loop.
+ $routeLength = count($route);
+
+ // If the route is empty then simply return the default route. No parsing necessary.
+ if (($routeLength == 1) && ($route[0] == ''))
@chdemko
chdemko added a note

useless double parenthesis here

It isn't strictly necessary but I think it makes it more readable. I'm happy to adjust if we are standardizing one way or another.

@chdemko
chdemko added a note

For me, it's more readable without the double parenthesis, but it's my point of view.

@pasamio
pasamio added a note

I'd probably err with Christophe and agree the double parenthesis on simple operators like this is a tad excessive.

I changed it in 4d777d2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
libraries/joomla/application/web/router/rest.php
((33 lines not shown))
+ );
+
+ /**
+ * Set a controller class suffix for a given HTTP method.
+ *
+ * @param string $method The HTTP method for which to set the class suffix.
+ * @param string $suffix The class suffix to use when fetching the controller name for a given request.
+ *
+ * @return JApplicationWebRouter This object for method chaining.
+ *
+ * @since 12.3
+ */
+ public function setHttpMethodSuffix($method, $suffix)
+ {
+ $this->suffixMap[strtoupper((string) $method)] = (string) $suffix;
+ }
@chdemko
chdemko added a note

Why not returning $this for chaining?

On that note you just discovered a bug. :-) The docs even show that there should be a return $this.

Fixed in 4d777d2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
libraries/joomla/application/web/router/base.php
((103 lines not shown))
+ }
+
+ // Iterate through all of the segments of the pattern to validate static and variable segments.
+ foreach ($pattern as $i => $segment)
+ {
+ // If we are looking at a variable segment then save the value.
+ if (strpos($segment, ':') === 0)
+ {
+ $vars[substr($segment, 1)] = $route[$i];
+ }
+ // If we are looking at a static segment and the value doesn't match the route segment then the pattern doesn't match.
+ elseif ($segment != $route[$i])
+ {
+ continue 2;
+ }
+ }
@chdemko
chdemko added a note

This code forbids to use segments really starting with ':' and it search for ':' which is useless. Could we consider a route beginning with a double ':' for this case:

<?php

if ($segment[0] == ':')
{
    if ($segment[1] == ':')
    {
        if (substr($segment, 1) != $route[$i])
        {
            continue 2;
        }
    }
    else
    {
        $vars[substr($segment, 1)] = $route[$i];
    }
}
elseif ($segment != $route[$i])
{
    continue 2;
}

That's an intersting point. I think a better solution would be to allow escaping of the : if you intend it to be used in a non-variable capacity. I'll have a play with that. I'd rather not use double colons if I can avoid it.

@chdemko
chdemko added a note

to escape it or to double it, but we have to allow segment starting by ':'

@pasamio
pasamio added a note

Given that the URI spec limits the use of colons in path parts, I'd suggest supporting them shouldn't be a high priority or even be a requirement.

Solid point Sam. I had forgotten that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@chdemko

Except these comments, excellent idea to use variable segment to identify routes

@piotr-cz

Thanks, this is great initiative!
Some questions/ideas:

  • What about operating on multiple resources,
    like getting multiple articles: GET articles/7,42 ? I know that it should be a new resource but this quite explicit

  • Isn't better to map HTTP methods to class methods (MyControllerArticles::Create, MyControllerArticles::Delete)
    instead of having a class for each (MyControllerArticlesCreate, MyControllerArticlesDelete)?

  • Nested tasks, where doesn it map? GET users/62/vcard

  • Filtering a collection of items, GET articles/?filter_user=[7,42]&filter_since=1339106644

  • Support for variables in HTTP methods other than GET and POST parse_str(file_get_contents('php://input'));

@LouisLandry

Good questions.

Firstly I'd reiterate that these routers aren't meant to be all things to all people so they aren't really intended to solve for all of these various options necessarily, but I'll comment on your examples... they are good ones.

Multiple Resources.

Example: GET articles/7,42

This is actually a great question and while I think it could be handled in the router if you wanted it to, that isn't how I've written these foundational routers. It wouldn't be difficult to do for sure, but it would also be just as easy in your controller to do the following:

<?php
// Get a list of article ids from the request input.
$ids = explode(',', $this->input->get('article_id'));

This would allow the controller to handle translating the actual request input into action which is just my personal preference. It could be that in some cases you want to support a syntax like that where in others you don't. Simply depends on the implementation. For foundational platform classes I'm always in favor of writing to the least common denominator.

HTTP Methods -> Class methods

This is also an option. It isn't how I've been running with the MVC structures I'm writing against the new classes because I'm much more interested in controllers handling relatively atomic tasks. It makes it ultimately easier to chain them and execute them in a nested fashion. That being said, if it was something you wanted to support it would require simply overriding two quick methods in the router to achieve it. I'd guess somewhere on the order of 20 lines of code.

Nested Tasks.

I'm not sure that I understand the question here. There are several different ways to think about that route. I'll try to illustrate two examples of how you might want to structure it.

Router map users/:user_id/vcard => MyControllerUsersVcard
This would be where you use the router as is to map to a particular controller to handle that exact task. One advantage of this approach would be the verbose control, and also the fact that your controller is designed to just do one thing and do it well. It could be re-used very simply in various flows and use-cases with minimal setup.

Router map users/:user_id/:format => MyControllerUsers
Taking this approach would mean that the users controller looks at the format variable from the URL to determine how it reacts. This is also valid, supportable without changing the router. It can decide whether or not it understands format => vcard and go from there. It could even decide to instantiate and execute a subcontroller based on the format.

Obviously those are not the only two ways to solve that problem, but they are the first two that come to mind.

Filtering Options (Query string variables).

I firmly believe this is something that could/should be handled by the controller itself. You could easily have a base controller, MyControllerFilterable that has a helper method to process filters and set input values based on those, or setup a state object for a model, or whatever other things need to happen. Then all of your page or service controllers would extend MyControllerFilterable and inherit that functionality. Again, that's off the top of my head. I'd certainly be open to adding a few such things to the platform if we could agree on some guiding (and broad) principles and conventions that would be supported.

Input variable support for extended HTTP Method set.

This is actually just a deficiency in JInput. Essentially it needs to understand what the situation is with respect to the HTTP method that it is wrapping input for, and run it do pretty much exactly what you put here to populate that array.

Incidentally I have another JInput class that I've been using called JInputJson which does exactly that except it parses JSON input using json_decode. I would love it if you (or others) could put together a pull request to fix that issue elegantly.

@piotr-cz

@LouisLandry

Thank you for answers!
I must admit, that I have no real experience with building REST API, just using the existing ones.
I've tried to build one and thus have been researching options to utilize existing JRouter to fit my current needs, but at this point it is not critical for my project.

I hope there will be good discussion on this PR to craft well the package. I mean, this is something quite important for webapps built on Joomla Platform.

You may consider extending patterns with asterisk * so it will be possible to catch continents/*place (see http://documentcloud.github.com/backbone/#Router-routes),

Multiple/ Aggregated Resources

A full example in my case would be GET articles/7,42/authors to get authors of selected articles or DELETE articles/7,42 to remove articles in fashion like JControllerAdmin::delete does.

I guess you are right, if this is handled in the controller developer can count with all the different scenarios and base class stays clean. I just wanted to make sure that segments won't have too many restrictions, like at the moment ID / cid in JControllerAdmin is restricted to an integer/array of integers.

HTTP Methods -> Class methods

I'm in favor of having one subcontroller handling multiple CRUD tasks because:

  • It's compatible with established current philosophy of CMS components (I've mapped HTTP methods to subcontroller tasks: GET => display, POST => save, DELETE => delete and PUT => save2new)
  • It's very convenient for me to navigate trough code, less subcontroller files, don't have to switch thinking CMS/Platform?
  • I can may share protected methods (helpers) for these subtasks in scope of one subcontroller

But of course curious about experience of fellow devs.

Nested tasks (subsubtasks)

I'll give an example: in my prototype GET my/users/:user_id/vcard is executing MyControllerUsers::displayVcard, outputting JSON, class and method built like:

ComponentName 'Controller'. SubcontrollerName::mapped_HTTP_method . ucfirst( subsubtask ). This is how I do it at the moment but yeah, probably this shouldn't be in the base but this case is not uncommon at all.

If there would be 1 controller per task, I'd have to have 4 MyControllerUsers CRUD controllers and 4 MyControllerUsersVcard CRUD controllers

Filtering Options (Query string variables).

Thanks, this sounds fine!

Maybe there should be an abstract class in the package for this, not reaaly sure.
When inspecting Joomla Admin code (JModelList), I came to the conclusion that I'll count with all url variables starting with filter_ as these are related to current controller list state.

Input variable support for extended HTTP Method set.

True, this should be done by JInput. But I wasn't sure if making such PR won't make it drift away from JRequest too far

Formats

I'm outputting everything in JSON and actually haven't been thinking yet how to request other resources formats. At the moment, JP is determining format based on format url var, but I think that for REST API proper thing should be to count in with HTTP Accept header.

@chdemko

For router patterns, it could be interesting to allow some special segments:

  • * means any number of segments (i.e. segment-1/*/:var/segment-final will match segment-1/segment-2/segment-3/segment-4/segment-final and var will be assigned to segment-4)
  • : means any segment (i.e. segment-1/:/:var/segment-final will match segment-1/segment-2/segment-3/segment-final and var will be assigned to segment-3)

See https://gist.github.com/2895611 for new code

@LouisLandry

@piotr-cz,

Have a look at https://gist.github.com/2896715. There is a readme at the bottom that will help explain my thoughts there. One my major design goals for these routers is for them to determine the controller and then get out of the way. I don't want them to do anything fancy in evaluating input because invariably that stuff just becomes limitations down the road.

I've certainly looked at backbone.js as well as Ruby/Merb and many others when it comes to routers. I tend to be a little obsessive in researching what others do when designing something like this. I absolutely want to introduce splats (*) at some point, but there are several ways that they can be used and I wanted to get a solid baseline then iterate. The introduction of something like this doesn't mean it cannot be improved, but the simpler we start the easier it'll be to reach consensus and move forward.

I believe that regardless of our approach here the CMS will need to extend the router(s) if they are to be used at all moving forward. It could be that the CMS takes an entirely different approach. I'm trying to design and build towards that lowest common denominator first and then we can build on it if appropriate.

@chdemko,

I like the way you did the matching in your gist. I'd be interested to see some sort of performance characteristics just out of curiosity. I purposefully didn't involve regular expressions in my first iteration here because it seemed like an overhead that wasn't necessary given my initial goals. I'd want to break out a couple of helper functions so that we can more easily unit test the process, but love the direction there.

@chdemko

@LouisLandry the preg_match test is about 3~4 times faster on my machine. BTW, I've updated the code in the gist

@pasamio

@chdemko I didn't say that : is illegal just that it is explicitly limited in the relevant specification. Even in your example there it is not the first character. Check out RFC 3986 on details about the limitations of the colon character in the path component.

@LouisLandry LouisLandry Adustments to base router algorithm to use regular expressions for more
advanced route pattern matching.  Thx to Christophe Demko.
cd9d941
@LouisLandry

OK, the router has been updated to leverage the work that Christophe did. It now handles a much wider set of route patterns, but is just a touch slower in adding maps. The tradeoff seems worth it, especially given that if needed the pre-compiled pattern regular expressions could be cached.

I've updated the original pull request description with information about how to use splats.

@ianmacl

The added flexibility is a plus. It seems that most/all of the issues brought forward with this have been satisfied. Pulling. Thanks. Notes from this pull request will be brought into the manual at a later date.

@ianmacl ianmacl merged commit 8390cef into from
@piotr-cz

@LouisLandry
Thanks for preparing the examples, this is very helpful.
At the moment I'm most comfortable with 'one.pattern.monolithic' pattern, but I guess that the default 'multiple.patterns' is best match for new simplified MVC

@bweston92

I can see this being used in Joomla 3 in the libraries I am currently using Joomla 3.1 is there any tutorial on how to use this new way of routing? At the minute I am using the 2 functions in router.php one for building and one for parsing but this new method looks more efficient.

Any help much appreciated.

@elinw

@pweston92 since the CMS is at this moment still using JApplication not JApplicationWeb that is somewhat tricky, but there is discussion of using JApplicationWeb in CMS 3.2 and a branch to test. If you'd like to help with testing that it would be great and much appreciated. Then even if the CMS core doesn't use these you can.
https://github.com/joomla-projects/joomla-cms/tree/feature-app
I think these notes are pretty much it on documentation, although there are a number of examples of applications using JApplicationWeb (or the new framework name for it) that might be useful to you).

https://github.com/elinw/jwc2012-tasks/blob/master/src/demo/application/web.php#L127 shows a very simple use case.

https://github.com/LouisLandry/tester/blob/master/src/classes/application/web.php#L97 uses REST .

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Jun 7, 2012
  1. @LouisLandry
  2. @LouisLandry
Commits on Jun 8, 2012
  1. @LouisLandry
Commits on Jun 10, 2012
  1. @LouisLandry

    Adustments to base router algorithm to use regular expressions for more

    LouisLandry authored
    advanced route pattern matching.  Thx to Christophe Demko.
Something went wrong with that request. Please try again.