AutoRoutes

dantleech edited this page Mar 9, 2013 · 19 revisions
Clone this wiki locally

AutoRoutes and Annotations

This is a page for discussing the direction of https://github.com/symfony-cmf/RoutingExtraBundle/pull/69

Some history

When I started writing the AutoRoute functionality I wanted to make the integration as seamless as possible by copying the precedent of the dynamic routing stuff. @dbu then suggested that we integrate the work of @sjopet, which uses annotations for mapping. Or something like that, I can't remember :)

As far as I know this is the first time annotations would be used within the CMF, so it seemed odd to do this just for the AutoRoute functionality, however, I thought if we could implement Annotations (or more generally Mapping) for the existing routing stuff, then I would feel less bad about creating annotations/mapping for one feature.

Another interesting thing that has surfaced is the question of "how to generate the slugs?". I think there are three ideas on how to do this, and I think that we are almost in accord on which to use, but the architecture is still an open question.

dbu: i think we should not call this thing slug but "route path". (its the phpcr-odm id of the route). we just most likely use a slugifier at some point to transform a title into a url-safe string.

The aim of this page is to help decide which way we should go forward in terms of mapping, and what to offer some propositions for the slug generation architecture.

Notes

For simplicity I will assume that AutoRoutes are integrated into RoutingExtraBundle but we can also create a new bundle for this functionality. dbu says: Regarding the location of the code, we should keep RoutingExtraBundle as simple as possible as its used in quite a few projects by now. Bigger optional things should go into other bundles. We could create a SymfonyCmfAnnotationRoutingBundle or maybe we come up with other ideas what to annotate than the routing and call it SymfonyCmfAnnotationsBundle.

Also when I refer to Annotations I mean "Mapping" and you can replace "Annotation" with XML, YAML, PHP mapping etc.

Auto Route Configuration

The "traditional" way

The dynamic router maps controlls to classes (or classes to controllers) in the DI configuration (e.g. config.yml). I propose here that we add auto_route as another node under the root node:

symfony_cmf_routing_extra:
    route_basepath: /cms/routes
    dynamic:
        enabled: true
        controllers_by_class:
            Sandbox\MainBundle\Document\DemoClassContent: sandbox_main.controller:classAction
            ....
        templates_by_class:
            Symfony\Cmf\Bundle\ContentBundle\Document\MultilangStaticContent: SandboxMainBundle:MultilangStaticContent:index.html.twig
            ....
    auto_route:
        enabled: true
        auto_routes_by_class:
            Symfony\Cmf\Bundle\BlogBundle\Document\Post:
               base_path: /foo/bar # overwrite symfony_cmf_routing_extra>route_basepath

So we configure the auto route functionality in exactly the same way as we configure the dynamic router. I like this method because it follows the rule of least suprise.

The annotation way

This is an example from the current unit test document for the AutoRoute PR on RoutingExtraBundle.

/**
 * @PHPCR\Document(
 *      referenceable=true
 * )
 *
 * @CMFRouting\AutoRoute(
 *     basePath="/test/routing",
 * )
 */
class Post

So this does the same thing as the previous YAML config.yml configuration, except:

  • Configuration is FIXED to the class, i.e. it can only be changed by extending the class. I like this because it means you can design things so that they will "just work".
  • When using annotations (as opposed to YAML/XML) for mapping, it is simply VERY QUICK :)

I like this method too, however, I don't like it if it doesn't work in the same way as the existing stuff. This is what currently happens in the AutoRoute PR.

Which to use?

Personally I think that if we use annotations for AutoRoute we should allow the use of Annotations also for RoutingExtraBundle in general (e.g. for the dynamic router class mappings) as described below in "Annotations for all". But, I think initially we can forget all about annotations and simply do it the "traditional" way with the possiblity of adding annotations at a later date, for everything.

Annotations for all

At some later date I think this would be cool...

/**
 * @PHPCR\Document(
 *      referenceable=true
 * )
 *
 * @CMFRouting\AutoRoute(
 *     basePath="/test/routing",
 * @CMFRouting\Controller("symfony_cmf_blog.blog_controller:viewPostAction")
 * @CMFRouting\Template("ThisTemplate.html.twig")
 */
class Post

Values mapped in the "traditional" (DI config) way would override values here. This would be in addition to the base functionality.

David says: I agree that annotations make sense for project specific code that is not intended to be shared and customized. The implementation of the annotations should not store the data in the database, but use RouteEnhancer services that read the metadata of the documents found in the route parameters, so that a metadata change is picked up immediately. And as the current PR already does, using a Metadata library that makes it easy to have XML and YML mappings too.

Dan says: Re. not storing stuff in the DB - absolutely, that makes perfect sense.

Slugging (dbu: determine the path for the route)

I think we have the same ideas here and the two "methods" below might actually be complementary, with the second being able to encompass the first.

Note that "Slugger" is just the best name I can come up with right now. The term "Slugifier" should only refer to instances of "SlugifierInterface" which is only concerned with the slugification of simple strings whereas here we are concerned with the slugification of objects.

dbu says: i think they should not just build a slug, but the full path. imagine i have content and add a child content. probably i would want the path to the path of the parent content + the slug for this document. or a news entry could have a url like year/month/day/. but this depends on what type of content it is and all. having a RoutePathStrategyInterface would allow handling all kinds of scenarios. having yet another step for that elsewhere would make things really complicated imo.

dan says: Yeah, that sounds good :) So f.e. you could have a route path strategy that which enables the content object to provide parent routes (e.g. function getParentRoutes() { return $this->blog->getRoutes() }) or something similar.

The factory method

I propose a factory design. The user specifies which "strategy" they would like to use in the mapping and they can also pass options to the strategy class.

symfony_cmf_routing_extra_bundle:
    auto_route:
        ...
        auto_routes_by_class:
            Symfony\Cmf\Bundle\BlogBundle\Document\Post: 
                suffix_strategy: 
                    name: from_object_method
                    options:
                        method: getTitle
                prefix_strategy:
                    name: specified
                    options:
                        path: /home/daniel/foobar
                        auto_create: true
                     

So here method_slugger is an alias for a DIC service:

class MethodSlugger extends SluggerInterface
{
     public function slugThatObject($object, $options)
     {
        $method = $options['method'];
        // .. generate a slugified string
        return $profit;
     }
}

I like this method because it is explicit and simple.

One disadvantage is that this implementation does not allow "chaining" of multiple sluggers/filters/transformers.

dbu says: well we do have multiple document to route-path transformers here. they are just explicitly mapped by class (we should use instanceof when handling this, to also allow interfaces and base classes)

The handler method

This is the method that @dbu has suggested in the pull request. I think that I understand what he is talking about, so will try and explain what I understand with my own terminology, but @dbu can feel free to change or replace this section :) - dbu says: thanks, i agree. cleaned up the examples a bit to make them more straightforward.

So the idea here is that there that for each document persisted an AutoRouteManager will iterate over all registered "handlers" and ask each if they "support" the document being persisted.

Below are some example handlers which determine the slug based on a given method name for the given object:


// 1. Interface handler

class MethodNameHandler implements AutoRoute..Handler
{
    public function __construct(SlugifierInterface $slugifier)
    {
        $this->slugifier = $slugifier;
    }

    public function supports($object)
    {
        if ($object instanceof SluggableInterface) {
            return true;
        }
    }

    public function getSlug(SluggableInterface $object)
    {
        return $this->slugifier->slugify($object->getSluggableName());
    }
}

// 2. or using the DI configuration

// symfony_cmf_routing_extra_bundle
//     auto_route:
//         handlers:
//             method_name_handler_by_class:
//                 Symfony\Cmf\Bundle\BlogBundle\Document\Post: 
//                     method_name: getTitle

class MethodNameHandler implements AutoRoute..Handler
{
    public function __construct(SlugifierInterface $slugifier, $config)
    {
        $this->slugifier = $slugifier;
        $this->config = $config;
    }

    public function supports($object)
    {
        // dbu: maybe this should instead cycle the map and check with instanceof to handle doctrine proxies or extending classes (that will still have the method of the base class)
        if (isset($this->config[get_class($object)])) {
            return true;
        }
    }

    public function getSlug($object)
    {
        $config = $this->config[get_class($object)];
        $methodName = $config['method_name'];
        return $this->slugifier->slugify($object->$methodName());
    }
}

// 3. or wrapping the factory method
// dbu: i don't really understand this one, it seems a more complicated variant of 1

class FactoryHandler implements AutoRoute...Handler
{
    public function supports($object)
    {
        if ($object instanceof AutoRouteFactoryStrategyInterface) {
            return true;
        }
       
        return false;
    }
 
    public function getSlug($object)
    {
        list($strategyAlias, $options) = $object->getAutoRouteStrategy();
        $strategyClass = // .. get strategy class from alias map
                         // pretend that this strategy is the "methodName" strategy
        $strategy = new $strategyClass($object, $options);
        return $strategy->getSlug();
    }
}

Handlers can determine if they support a given document by, for example, checking to see if it has a certain interface or if it has metadata associated with it either via Annotations (mapping) or DI configuration, or indeed, whatever.

If the handler supports the given object it will return the slug to the handler.

  • This is actually a level above the "Factory method" and could encompass it.
  • It would allow users to override the filter used for a given object, but they would need to specify a priority.
  • @dbu How would chaining work? => see for example how the ChainRouter uses its chained routers to generate a route: https://github.com/symfony-cmf/Routing/blob/master/ChainRouter.php#L197

Which to use?

I don't think that the two methods are equal, the second method could indeed encompass the first, but then the first would define lots of strategies. Strategies at the level of the "factory method" are more or less equal to "Handlers" at the level of the "handler method".

The problem with the handler method is that, in the case of having lots of handlers, to have feature parity with the factory method we must, for example, define an interface for each "strategy".

interface AutoRouteSluggableByMethodNameInterface
interface AutoRouteSluggableByPropertiesInterface
interface AutoRouteSluggableByFooBarInterface
interface AutoRouteSluggableFromMetadataUsingFactory

If given a choice I would use the "factory method", in my opinion it is equal in flexibility to the "handler method" if the user is willing to define their own strategies.

dbu: the main drawback of the factory method is that a bundle can not automatically "just work" by providing the handlers as services. you need a central configuration that lists all (base)classes or interfaces that should be handled. which was what you wanted to avoid when you had the metadata mapping scenario. but apart from that it is indeed simpler. anything forcing interfaces on the documents is a no-go imo. allowing interfaces is ok, but things should work with third party documents that can not be made to have such an interface.

dan: well, you are correct, the bundle wouldn't "just work" because of the central configuration - but - nor does the existing dynamic router stuff. Here I have intentionally reused the same configuration method as the dynamic router, as that is following "The Rule of Least Suprise" and it fits more neatly into the paradigm of the RoutingExtraBundle. Also it doesn't need an interface (not sure if you are implying that it would). With regards to making things "just work" I would see this as a future addition which would make everything "mappable/annotationable" like you say, maybe as an "AnnotationBundle" (or ... why not make RoutingExtraBundle into RoutingBundle and then RoutingExtraBundle could have all the annotation stuff, the auto routing, and well, just "Extra" stuff really, after all "RoutingBundle" doesn't exist, does it?)

Existing path resolution

The route handlers will provide the slug, but what if a node at the propsed path already exists?

After we have assertained the slug from the handler the AutoRouteManager will check to see if a node with the proposed path exists, if it does it should invoke a class that can provide an alternative path. For example by adding an incrementing number to the slug, or by prepending the current date, or whatever. e.g. something like the following in the AutoRouteManager where the "Resolver" class is defined firstly by the DI configuration and secondly by the previous handler invocation.

  $slug = 'foo-bar';
  $basePath = '/this/is/an/example/'; // that already exists in the database

  $existingPathResolverStrategy = new NumberResolver;
  // wonder if we would need something here to avoid endless loops - but then that would be a broken strategy.
  while (true) {
    $path = $basePath.$slug;
    if (false === $this->pathExists($path)) {
      break;
    }
    $slug = $existingPathResolverStrategy->findAlternative($slug);
  }

  $dm->persist($document);

More

Factory chain method

symfony_cmf_routing_auto_route:
    auto_routes:

        # e.g. /cms/auto-route/blog/my-blogs-title
        Symfony\Cmf\Bundle\RoutingAutoRouteBundle\Tests\Functional\app\Document\Blog:
            - finder: specified
              options:
                  path: /cms/auto-route/blog

            - builder: slugify_object_method
                  method: getTitle
                      slugger: symfony_cmf_routing_auto_route.default_slugger

        # e.g. /cms/auto-route/blog/my-blogs-title/2013-04-09/my-post-title
        Symfony\Cmf\Bundle\RoutingAutoRouteBundle\Tests\Functional\app\Document\Post:
            - finder: from_object_method, 
              options: 
                  from_method: getBlogRoute

            - builder: date
              options:
                  format: yyyy-mm-dd
                  from_method: getPublishedAt

            - builder: slugify_object_method
                  options:
                      from_method: getTitle
                      slugger: symfony_cmf_routing_auto_route.default_slugger