Source

restontrac / trac-dev / restontrac / tracrest / util.py

#!/usr/bin/env python
# -*- coding: UTF-8 -*-

# Copyright 2009-2011 Olemis Lang <olemis at gmail.com>
#
#   Licensed under the Apache License, Version 2.0 (the "License");
#   you may not use this file except in compliance with the License.
#   You may obtain a copy of the License at
#
#       http://www.apache.org/licenses/LICENSE-2.0
#
#   Unless required by applicable law or agreed to in writing, software
#   distributed under the License is distributed on an "AS IS" BASIS,
#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#   See the License for the specific language governing permissions and
#   limitations under the License.


r"""Publish Trac data via a REST-ful interface

RESTful interface built on top of Trac RpcPlugin

Copyright 2009-2011 Olemis Lang <olemis at gmail.com>
Licensed under the Apache License
"""
from routes.mapper import Mapper
from routes.mapper import strip_slashes
from routes import request_config

class RestMapper(Mapper):
    """ Specialization of route.Mapper to allow
        a more customizable behaviour of the resource()
        operation used to automatically configure the set
        of url mappings generally allowed in for a
        collection/member resource
    """
    #TODO document the objective of this re-writing, evaluate the creation of a fork of routes in bb instead
    def resource(self, member_name, collection_name, **kwargs):
        """Generate routes for a controller resource

        The member_name name should be the appropriate singular version
        of the resource given your locale and used with members of the
        collection. The collection_name name will be used to refer to
        the resource collection methods and should be a plural version
        of the member_name argument. By default, the member_name name
        will also be assumed to map to a controller you create.

        The concept of a web resource maps somewhat directly to 'CRUD'
        operations. The overlying things to keep in mind is that
        mapping a resource is about handling creating, viewing, and
        editing that resource.

        All keyword arguments are optional.

        ``controller``
            If specified in the keyword args, the controller will be
            the actual controller used, but the rest of the naming
            conventions used for the route names and URL paths are
            unchanged.

        ``collection``
            Additional action mappings used to manipulate/view the
            entire set of resources provided by the controller.

            Example::

                map.resource('message', 'messages', collection={'rss':'GET'})
                # GET /message/rss (maps to the rss action)
                # also adds named route "rss_message"

        ``member``
            Additional action mappings used to access an individual
            'member' of this controllers resources.

            Example::

                map.resource('message', 'messages', member={'mark':'POST'})
                # POST /message/1/mark (maps to the mark action)
                # also adds named route "mark_message"

        ``new``
            Action mappings that involve dealing with a new member in
            the controller resources.

            Example::

                map.resource('message', 'messages', new={'preview':'POST'})
                # POST /message/new/preview (maps to the preview action)
                # also adds a url named "preview_new_message"

        ``path_prefix``
            Prepends the URL path for the Route with the path_prefix
            given. This is most useful for cases where you want to mix
            resources or relations between resources.

        ``name_prefix``
            Perpends the route names that are generated with the
            name_prefix given. Combined with the path_prefix option,
            it's easy to generate route names and paths that represent
            resources that are in relations.

            Example::

                map.resource('message', 'messages', controller='categories',
                    path_prefix='/category/:category_id',
                    name_prefix="category_")
                # GET /category/7/message/1
                # has named route "category_message"

        ``parent_resource``
            A ``dict`` containing information about the parent
            resource, for creating a nested resource. It should contain
            the ``member_name`` and ``collection_name`` of the parent
            resource. This ``dict`` will
            be available via the associated ``Route`` object which can
            be accessed during a request via
            ``request.environ['routes.route']``

            If ``parent_resource`` is supplied and ``path_prefix``
            isn't, ``path_prefix`` will be generated from
            ``parent_resource`` as
            "<parent collection name>/:<parent member name>_id".

            If ``parent_resource`` is supplied and ``name_prefix``
            isn't, ``name_prefix`` will be generated from
            ``parent_resource`` as  "<parent member name>_".

            Example::

                >>> from routes.util import url_for
                >>> m = Mapper()
                >>> m.resource('location', 'locations',
                ...            parent_resource=dict(member_name='region',
                ...                                 collection_name='regions'))
                >>> # path_prefix is "regions/:region_id"
                >>> # name prefix is "region_"
                >>> url_for('region_locations', region_id=13)
                '/regions/13/locations'
                >>> url_for('region_new_location', region_id=13)
                '/regions/13/locations/new'
                >>> url_for('region_location', region_id=13, id=60)
                '/regions/13/locations/60'
                >>> url_for('region_edit_location', region_id=13, id=60)
                '/regions/13/locations/60/edit'

            Overriding generated ``path_prefix``::

                >>> m = Mapper()
                >>> m.resource('location', 'locations',
                ...            parent_resource=dict(member_name='region',
                ...                                 collection_name='regions'),
                ...            path_prefix='areas/:area_id')
                >>> # name prefix is "region_"
                >>> url_for('region_locations', area_id=51)
                '/areas/51/locations'

            Overriding generated ``name_prefix``::

                >>> m = Mapper()
                >>> m.resource('location', 'locations',
                ...            parent_resource=dict(member_name='region',
                ...                                 collection_name='regions'),
                ...            name_prefix='')
                >>> # path_prefix is "regions/:region_id"
                >>> url_for('locations', region_id=51)
                '/regions/51/locations'

        """
        collection = kwargs.pop('collection', {})
        member = kwargs.pop('member', {})
        exclude_edit = kwargs.pop('member', {}).get('edit',"") == 'IGNORE'
        exclude_new = kwargs.pop('new', {}) == 'IGNORE'
        new = kwargs.pop('new', {})
        path_prefix = kwargs.pop('path_prefix', None)
        name_prefix = kwargs.pop('name_prefix', None)
        parent_resource = kwargs.pop('parent_resource', None)

        # Generate ``path_prefix`` if ``path_prefix`` wasn't specified and
        # ``parent_resource`` was. Likewise for ``name_prefix``. Make sure
        # that ``path_prefix`` and ``name_prefix`` *always* take precedence if
        # they are specified--in particular, we need to be careful when they
        # are explicitly set to "".
        if parent_resource is not None:
            if path_prefix is None:
                path_prefix = '%s/:%s_id' % (parent_resource['collection_name'],
                                             parent_resource['member_name'])
            if name_prefix is None:
                name_prefix = '%s_' % parent_resource['member_name']
        else:
            if path_prefix is None: path_prefix = ''
            if name_prefix is None: name_prefix = ''

        # Ensure the edit and new actions are in and GET
        if not exclude_edit:
            member['edit'] = 'GET'
        if not exclude_new:
            new.update({'new': 'GET'})

        # Make new dict's based off the old, except the old values become keys,
        # and the old keys become items in a list as the value
        def swap(dct, newdct):
            """Swap the keys and values in the dict, and uppercase the values
            from the dict during the swap."""
            for key, val in dct.iteritems():
                newdct.setdefault(val.upper(), []).append(key)
            return newdct
        collection_methods = swap(collection, {})
        member_methods = swap(member, {})
        new_methods = swap(new, {})

        # Insert create, update, and destroy methods
        collection_methods.setdefault('POST', []).insert(0, 'create')
        member_methods.setdefault('PUT', []).insert(0, 'update')
        member_methods.setdefault('DELETE', []).insert(0, 'delete')

        # If there's a path prefix option, use it with the controller
        controller = strip_slashes(collection_name)
        path_prefix = strip_slashes(path_prefix)
        path_prefix = '/' + path_prefix
        if path_prefix and path_prefix != '/':
            path = path_prefix + '/' + controller
        else:
            path = '/' + controller
        collection_path = path
        new_path = path + "/new"
        member_path = path + "/:(id)"

        options = {
            'controller': kwargs.get('controller', controller),
            '_member_name': member_name,
            '_collection_name': collection_name,
            '_parent_resource': parent_resource,
            '_filter': kwargs.get('_filter')
        }

        def requirements_for(meth):
            """Returns a new dict to be used for all route creation as the
            route options"""
            opts = options.copy()
            if method != 'any':
                opts['conditions'] = {'method':[meth.upper()]}
            return opts

        # Add the routes for handling collection methods
        for method, lst in collection_methods.iteritems():
            primary = (method != 'GET' and lst.pop(0)) or None
            route_options = requirements_for(method)
            for action in lst:
                route_options['action'] = action
                route_name = "%s%s_%s" % (name_prefix, action, collection_name)
                self.connect("formatted_" + route_name, "%s/%s.:(format)" %\
                                                        (collection_path, action), **route_options)
                self.connect(route_name, "%s/%s" % (collection_path, action),
                    **route_options)
            if primary:
                route_options['action'] = primary
                self.connect("%s.:(format)" % collection_path, **route_options)
                self.connect(collection_path, **route_options)

        # Specifically add in the built-in 'index' collection method and its
        # formatted version
        collection_getter_action = kwargs.pop('collection_getter_action','index')
        self.connect("formatted_" + name_prefix + collection_name,
            collection_path + ".:(format)", action=collection_getter_action,
            conditions={'method':['GET']}, **options)
        self.connect(name_prefix + collection_name, collection_path,
            action=collection_getter_action, conditions={'method':['GET']}, **options)

        if not exclude_new:
            # Add the routes that deal with new resource methods
            for method, lst in new_methods.iteritems():
                route_options = requirements_for(method)
                for action in lst:
                    path = (action == 'new' and new_path) or "%s/%s" % (new_path,
                                                                        action)
                    name = "new_" + member_name
                    if action != 'new':
                        name = action + "_" + name
                    route_options['action'] = action
                    formatted_path = (action == 'new' and new_path + '.:(format)') or\
                                     "%s/%s.:(format)" % (new_path, action)
                    self.connect("formatted_" + name_prefix + name, formatted_path,
                        **route_options)
                    self.connect(name_prefix + name, path, **route_options)

        requirements_regexp = '[^\/]+(?<!\\\)'

        # Add the routes that deal with member methods of a resource
        for method, lst in member_methods.iteritems():
            route_options = requirements_for(method)
            route_options['requirements'] = {'id':requirements_regexp}
            if method not in ['POST', 'GET', 'any']:
                primary = lst.pop(0)
            else:
                primary = None
            for action in lst:
                route_options['action'] = action
                self.connect("formatted_%s%s_%s" % (name_prefix, action,
                                                    member_name),
                    "%s/%s.:(format)" % (member_path, action), **route_options)
                self.connect("%s%s_%s" % (name_prefix, action, member_name),
                    "%s/%s" % (member_path, action), **route_options)
            if primary:
                route_options['action'] = primary
                self.connect("%s.:(format)" % member_path, **route_options)
                self.connect(member_path, **route_options)

        # Specifically add the member 'show' method
        route_options = requirements_for('GET')
        route_options['action'] = kwargs.pop('member_getter_action','show')
        route_options['requirements'] = {'id':requirements_regexp}
        self.connect("formatted_" + name_prefix + member_name,
            member_path + ".:(format)", **route_options)
        self.connect(name_prefix + member_name, member_path, **route_options)

DEFAULT_ACTION_MAP = {'GET':'read','POST':'create','PUT':'update',
                      'DELETE':'delete','OPTIONS':'available_operations',
                      'PATCH':'update_diff'}

def map_resource_path(mapper, path, controller, methods, actions_map={}):
    """To mapp the path using a routes mapper
       methods can be a string like 'GET' or
       a comma sepparated one like 'GET,POST,PUT'
       or a list like ('GET','POST')
       actions_map is a dict with the structure {(path,HttpMethod) : actionName}
       that allows to specify custom actions mappings instead of default ones
       indicated in DEFAULT_ACTION_MAP
    """
    #ensuring methods become iterable no matter how the values were passed
    if isinstance(methods, basestring):
        if "," in methods:
            methods = methods.split(",")
        else:
            methods = (methods,)
    #Provided http methods must be valid
    for http_method in methods:
        assert http_method in DEFAULT_ACTION_MAP.keys(), \
            "Configuration Error: Unsupported http method %s" % http_method
    actions_map = actions_map or {}
    for http_method in methods:
        # allowing custom action mappings to take place
        # if no action_map is provided, will use DEFAULT_ACTION_MAP
        action = actions_map.get((path, http_method),None)
        if not action:
            action = DEFAULT_ACTION_MAP.get(http_method, None)
        #doing the mapping once data is ready
        mapper.connect(path,controller=controller,action=action,
                           conditions={'method':http_method})

def setup_routes_config(mapper, request, match_dict):
    """Setting up routes to enable the use
       of url_for() function when generating hyperlinks
       between resources
    """
    config = request_config()
    config.mapper = mapper
    config.mapper_dict = match_dict
    config.host = request.server_name
    config.protocol = request.scheme
    #TODO check if OK
    config.redirect = lambda url: request.redirect(url)