Home | History | Annotate | Download | only in webapp2_extras
      1 # -*- coding: utf-8 -*-
      2 """
      3     webapp2_extras.routes
      4     =====================
      5 
      6     Extra route classes for webapp2.
      7 
      8     :copyright: 2011 by tipfy.org.
      9     :license: Apache Sotware License, see LICENSE for details.
     10 """
     11 import re
     12 import urllib
     13 
     14 from webob import exc
     15 
     16 import webapp2
     17 
     18 
     19 class MultiRoute(object):
     20     """Base class for routes with nested routes."""
     21 
     22     routes = None
     23     children = None
     24     match_children = None
     25     build_children = None
     26 
     27     def __init__(self, routes):
     28         self.routes = routes
     29 
     30     def get_children(self):
     31         if self.children is None:
     32             self.children = []
     33             for route in self.routes:
     34                 for r in route.get_routes():
     35                     self.children.append(r)
     36 
     37         for rv in self.children:
     38             yield rv
     39 
     40     def get_match_children(self):
     41         if self.match_children is None:
     42             self.match_children = []
     43             for route in self.get_children():
     44                 for r in route.get_match_routes():
     45                     self.match_children.append(r)
     46 
     47         for rv in self.match_children:
     48             yield rv
     49 
     50     def get_build_children(self):
     51         if self.build_children is None:
     52             self.build_children = {}
     53             for route in self.get_children():
     54                 for n, r in route.get_build_routes():
     55                     self.build_children[n] = r
     56 
     57         for rv in self.build_children.iteritems():
     58             yield rv
     59 
     60     get_routes = get_children
     61     get_match_routes = get_match_children
     62     get_build_routes = get_build_children
     63 
     64 
     65 class DomainRoute(MultiRoute):
     66     """A route used to restrict route matches to a given domain or subdomain.
     67 
     68     For example, to restrict routes to a subdomain of the appspot domain::
     69 
     70         app = WSGIApplication([
     71             DomainRoute('<subdomain>.app-id.appspot.com', [
     72                 Route('/foo', 'FooHandler', 'subdomain-thing'),
     73             ]),
     74             Route('/bar', 'BarHandler', 'normal-thing'),
     75         ])
     76 
     77     The template follows the same syntax used by :class:`webapp2.Route` and
     78     must define named groups if any value must be added to the match results.
     79     In the example above, an extra `subdomain` keyword is passed to the
     80     handler, but if the regex didn't define any named groups, nothing would
     81     be added.
     82     """
     83 
     84     def __init__(self, template, routes):
     85         """Initializes a URL route.
     86 
     87         :param template:
     88             A route template to match against ``environ['SERVER_NAME']``.
     89             See a syntax description in :meth:`webapp2.Route.__init__`.
     90         :param routes:
     91             A list of :class:`webapp2.Route` instances.
     92         """
     93         super(DomainRoute, self).__init__(routes)
     94         self.template = template
     95 
     96     def get_match_routes(self):
     97         # This route will do pre-matching before matching the nested routes!
     98         yield self
     99 
    100     def match(self, request):
    101         # Use SERVER_NAME to ignore port number that comes with request.host?
    102         # host_match = self.regex.match(request.host.split(':', 1)[0])
    103         host_match = self.regex.match(request.environ['SERVER_NAME'])
    104 
    105         if host_match:
    106             args, kwargs = webapp2._get_route_variables(host_match)
    107             return _match_routes(self.get_match_children, request, None,
    108                                  kwargs)
    109 
    110     @webapp2.cached_property
    111     def regex(self):
    112         regex, reverse_template, args_count, kwargs_count, variables = \
    113             webapp2._parse_route_template(self.template,
    114                                           default_sufix='[^\.]+')
    115         return regex
    116 
    117 
    118 class NamePrefixRoute(MultiRoute):
    119     """The idea of this route is to set a base name for other routes::
    120 
    121         app = WSGIApplication([
    122             NamePrefixRoute('user-', [
    123                 Route('/users/<user:\w+>/', UserOverviewHandler, 'overview'),
    124                 Route('/users/<user:\w+>/profile', UserProfileHandler,
    125                       'profile'),
    126                 Route('/users/<user:\w+>/projects', UserProjectsHandler,
    127                       'projects'),
    128             ]),
    129         ])
    130 
    131     The example above is the same as setting the following routes, just more
    132     convenient as you can reuse the name prefix::
    133 
    134         app = WSGIApplication([
    135             Route('/users/<user:\w+>/', UserOverviewHandler, 'user-overview'),
    136             Route('/users/<user:\w+>/profile', UserProfileHandler,
    137                   'user-profile'),
    138             Route('/users/<user:\w+>/projects', UserProjectsHandler,
    139                   'user-projects'),
    140         ])
    141     """
    142 
    143     _attr = 'name'
    144 
    145     def __init__(self, prefix, routes):
    146         """Initializes a URL route.
    147 
    148         :param prefix:
    149             The prefix to be prepended.
    150         :param routes:
    151             A list of :class:`webapp2.Route` instances.
    152         """
    153         super(NamePrefixRoute, self).__init__(routes)
    154         self.prefix = prefix
    155         # Prepend a prefix to a route attribute.
    156         for route in self.get_routes():
    157             setattr(route, self._attr, prefix + getattr(route, self._attr))
    158 
    159 
    160 class HandlerPrefixRoute(NamePrefixRoute):
    161     """Same as :class:`NamePrefixRoute`, but prefixes the route handler."""
    162 
    163     _attr = 'handler'
    164 
    165 
    166 class PathPrefixRoute(NamePrefixRoute):
    167     """Same as :class:`NamePrefixRoute`, but prefixes the route path.
    168 
    169     For example, imagine we have these routes::
    170 
    171         app = WSGIApplication([
    172             Route('/users/<user:\w+>/', UserOverviewHandler,
    173                   'user-overview'),
    174             Route('/users/<user:\w+>/profile', UserProfileHandler,
    175                   'user-profile'),
    176             Route('/users/<user:\w+>/projects', UserProjectsHandler,
    177                   'user-projects'),
    178         ])
    179 
    180     We could refactor them to reuse the common path prefix::
    181 
    182         app = WSGIApplication([
    183             PathPrefixRoute('/users/<user:\w+>', [
    184                 Route('/', UserOverviewHandler, 'user-overview'),
    185                 Route('/profile', UserProfileHandler, 'user-profile'),
    186                 Route('/projects', UserProjectsHandler, 'user-projects'),
    187             ]),
    188         ])
    189 
    190     This is not only convenient, but also performs better: the nested routes
    191     will only be tested if the path prefix matches.
    192     """
    193 
    194     _attr = 'template'
    195 
    196     def __init__(self, prefix, routes):
    197         """Initializes a URL route.
    198 
    199         :param prefix:
    200             The prefix to be prepended. It must start with a slash but not
    201             end with a slash.
    202         :param routes:
    203             A list of :class:`webapp2.Route` instances.
    204         """
    205         assert prefix.startswith('/') and not prefix.endswith('/'), \
    206             'Path prefixes must start with a slash but not end with a slash.'
    207         super(PathPrefixRoute, self).__init__(prefix, routes)
    208 
    209     def get_match_routes(self):
    210         # This route will do pre-matching before matching the nested routes!
    211         yield self
    212 
    213     def match(self, request):
    214         if not self.regex.match(urllib.unquote(request.path)):
    215             return None
    216 
    217         return _match_routes(self.get_match_children, request)
    218 
    219     @webapp2.cached_property
    220     def regex(self):
    221         regex, reverse_template, args_count, kwargs_count, variables = \
    222             webapp2._parse_route_template(self.prefix + '<:/.*>')
    223         return regex
    224 
    225 
    226 class RedirectRoute(webapp2.Route):
    227     """A convenience route class for easy redirects.
    228 
    229     It adds redirect_to, redirect_to_name and strict_slash options to
    230     :class:`webapp2.Route`.
    231     """
    232 
    233     def __init__(self, template, handler=None, name=None, defaults=None,
    234                  build_only=False, handler_method=None, methods=None,
    235                  schemes=None, redirect_to=None, redirect_to_name=None,
    236                  strict_slash=False):
    237         """Initializes a URL route. Extra arguments compared to
    238         :meth:`webapp2.Route.__init__`:
    239 
    240         :param redirect_to:
    241             A URL string or a callable that returns a URL. If set, this route
    242             is used to redirect to it. The callable is called passing
    243             ``(handler, *args, **kwargs)`` as arguments. This is a
    244             convenience to use :class:`RedirectHandler`. These two are
    245             equivalent::
    246 
    247                 route = Route('/foo', handler=webapp2.RedirectHandler,
    248                               defaults={'_uri': '/bar'})
    249                 route = Route('/foo', redirect_to='/bar')
    250 
    251         :param redirect_to_name:
    252             Same as `redirect_to`, but the value is the name of a route to
    253             redirect to. In the example below, accessing '/hello-again' will
    254             redirect to the route named 'hello'::
    255 
    256                 route = Route('/hello', handler=HelloHandler, name='hello')
    257                 route = Route('/hello-again', redirect_to_name='hello')
    258 
    259         :param strict_slash:
    260             If True, redirects access to the same URL with different trailing
    261             slash to the strict path defined in the route. For example, take
    262             these routes::
    263 
    264                 route = Route('/foo', FooHandler, strict_slash=True)
    265                 route = Route('/bar/', BarHandler, strict_slash=True)
    266 
    267             Because **strict_slash** is True, this is what will happen:
    268 
    269             - Access to ``/foo`` will execute ``FooHandler`` normally.
    270             - Access to ``/bar/`` will execute ``BarHandler`` normally.
    271             - Access to ``/foo/`` will redirect to ``/foo``.
    272             - Access to ``/bar`` will redirect to ``/bar/``.
    273         """
    274         super(RedirectRoute, self).__init__(
    275             template, handler=handler, name=name, defaults=defaults,
    276             build_only=build_only, handler_method=handler_method,
    277             methods=methods, schemes=schemes)
    278 
    279         if strict_slash and not name:
    280             raise ValueError('Routes with strict_slash must have a name.')
    281 
    282         self.strict_slash = strict_slash
    283         self.redirect_to_name = redirect_to_name
    284 
    285         if redirect_to is not None:
    286             assert redirect_to_name is None
    287             self.handler = webapp2.RedirectHandler
    288             self.defaults['_uri'] = redirect_to
    289 
    290     def get_match_routes(self):
    291         """Generator to get all routes that can be matched from a route.
    292 
    293         :yields:
    294             This route or all nested routes that can be matched.
    295         """
    296         if self.redirect_to_name:
    297             main_route = self._get_redirect_route(name=self.redirect_to_name)
    298         else:
    299             main_route = self
    300 
    301         if not self.build_only:
    302             if self.strict_slash is True:
    303                 if self.template.endswith('/'):
    304                     template = self.template[:-1]
    305                 else:
    306                     template = self.template + '/'
    307 
    308                 yield main_route
    309                 yield self._get_redirect_route(template=template)
    310             else:
    311                 yield main_route
    312 
    313     def _get_redirect_route(self, template=None, name=None):
    314         template = template or self.template
    315         name = name or self.name
    316         defaults = self.defaults.copy()
    317         defaults.update({
    318             '_uri': self._redirect,
    319             '_name': name,
    320         })
    321         new_route = webapp2.Route(template, webapp2.RedirectHandler,
    322                                   defaults=defaults)
    323         return new_route
    324 
    325     def _redirect(self, handler, *args, **kwargs):
    326         # Get from request because args is empty if named routes are set?
    327         # args, kwargs = (handler.request.route_args,
    328         #                 handler.request.route_kwargs)
    329         kwargs.pop('_uri', None)
    330         kwargs.pop('_code', None)
    331         return handler.uri_for(kwargs.pop('_name'), *args, **kwargs)
    332 
    333 
    334 def _match_routes(iter_func, request, extra_args=None, extra_kwargs=None):
    335     """Tries to match a route given an iterator."""
    336     method_not_allowed = False
    337     for route in iter_func():
    338         try:
    339             match = route.match(request)
    340             if match:
    341                 route, args, kwargs = match
    342                 if extra_args:
    343                     args += extra_args
    344 
    345                 if extra_kwargs:
    346                     kwargs.update(extra_kwargs)
    347 
    348                 return route, args, kwargs
    349         except exc.HTTPMethodNotAllowed:
    350             method_not_allowed = True
    351 
    352     if method_not_allowed:
    353         raise exc.HTTPMethodNotAllowed()
    354