Commits

Anonymous committed d5c4aff

Initial implementation containing:
- API interfaces defined
- Routes integration implemented for url mapping
- Base resource implementation class containin url mapping for general cases
- Test cases for different variants of url mappings implemented

  • Participants
  • Parent commits f8144d5

Comments (0)

Files changed (7)

trac-dev/restontrac/tests/__init__.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
+"""
+__author__ = 'Olemis Lang'
+
+# Ignore errors to avoid Internal Server Errors
+from trac.core import TracError
+TracError.__str__ = lambda self: unicode(self).encode('ascii', 'ignore')
+
+try:
+    from restontrac.tracrest import *
+    msg = 'Ok'
+except Exception, exc:
+#    raise
+    msg = "Exception %s raised: '%s'" % (exc.__class__.__name__, str(exc))

trac-dev/restontrac/tests/test_resource_routes.py

+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you 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.
+
+"""Tests for url mappings to IRestResource instances """
+
+import unittest, tempfile
+from trac.test import EnvironmentStub
+#TODO absolute imports
+from restontrac.tracrest.api import RestSystem
+from restontrac.tracrest.web_ui import RestModule
+from restontrac.tracrest.resources import RestResourceBase
+
+class NestedResource(RestResourceBase):
+    """ Sample implementation for validating the routing of
+        nested resources like ticket->attachments
+        Or in the future Product -> Tickets
+    """
+    def __init__(self):
+        self.setup("attachments","attachment",
+                    resource_name='project_attachments',
+                    parent_collection="/projects",
+                    parent_resource="project")
+
+class AutoconfigResource(RestResourceBase):
+    """ Sample implementation for validating the routing of
+        autoconfigurable resources as the case of Tickets
+        Wich will have a collection and a member resource
+        providing a standard API and implementation for this
+        combination of Collection/member resources
+    """
+    def __init__(self):
+        self.setup("tickets","ticket")
+
+class CustomMappingResource(RestResourceBase):
+    """ Sample implementation for validating the routing of
+        some custom special resources, that won't provide the
+        standard set of functionalities, but could rather
+        publish some single Http method instead of the complete
+        list
+        Verifies the mapping of a custom /prototype url since the
+        internal mapping here does not take place using
+        routes.Mapper.resource() but util.map_resource_path()
+    """
+    def __init__(self):
+        self.setup("milestones","milestone")
+
+    def supported_paths(self):
+        collection_url = "".join(["/",self.collection_name])
+        return {collection_url:('GET','POST'),
+                collection_url + "/{id}":('GET'),
+                collection_url + "/prototype":('GET')
+               }
+    def get_custom_actions_map(self):
+        return {(self.collection_name + "/prototype",'GET'): "prototype"}
+
+
+class ResourceRouteTests(unittest.TestCase):
+    """Unit tests covering the Product model"""
+    def setUp(self):
+        self.env = EnvironmentStub(enable=['trac.*', 'tracrest.*','api.*','resources','util',
+                                           'web_ui.*','NestedResource','AutoconfigResource',
+                                           'CustomMappingResource','RestModule','RestSystem'])
+        self.env.enable_component(NestedResource)
+        self.env.enable_component(AutoconfigResource)
+        self.env.enable_component(CustomMappingResource)
+        self.env.enable_component(RestModule)
+        self.env.enable_component(RestSystem)
+        self.rest_module = RestModule(self.env)
+        self.mapper = RestModule(self.env)._mapper
+        self.env.path = tempfile.mkdtemp('tracrest-tempenv')
+        #For the moment there is database dependency within these test cases
+        #self.env.reset_db()
+
+    def _verify_match(self, path, match_expected, method, action=None, id=None):
+        """Utility operation for the execution of the tests
+           and verification of results
+        """
+        match = self.mapper.match(path, {"REQUEST_METHOD": method})
+        route = self.mapper.routematch(path, {"REQUEST_METHOD": method})
+        call_type = ""
+        if route:
+            call_type = 'COLLECTION CALL' if ':(id)' in route[1].routepath else 'MEMBER CALL'
+        match_data = match or 'NOT SUPPORTED'
+        #TODO log this line
+        print "%s %s-> %s %s" % (call_type, method, path, str(match_data))
+        if match_expected:
+            self.assertTrue(match)
+            if action:
+                self.assertEquals(match.get('action'),action)
+            if id:
+                self.assertEqual(match.get('id'),id)
+        else:
+            self.assertFalse(match)
+
+        return match
+
+    def test_nested_resources(self):
+        """Test case for the functioning of the mapping of nested resources
+           Configurations tested in this test case were provided
+           by NestedResource test component within the present module
+        """
+        #TODO document expected results within comments
+        route_vars = self._verify_match("/projects/26/attachments/51",True,'GET')
+        self.assertEquals(route_vars.get('controller'),'project_attachments')
+        self.assertEquals(route_vars.get('project_id'),'26')
+        self.assertEquals(route_vars.get('id'),'51')
+
+        self._verify_match("/projects/",False,'GET')
+
+        self._verify_match("/projects/26/attachments",True,'POST')
+        self._verify_match("/projects/26/attachments",True,'GET')
+        self._verify_match("/projects/26/attachments/45",True,'PUT')
+        self._verify_match("/projects/26/attachments",False,'PUT')
+
+    def test_resource_autoconfig(self):
+        """Test case to verify the expected functioning of resources url
+           auto-configuration.
+           Configurations tested in this test case were provided
+           by AutoconfigResource test component within the present module
+        """
+        #verifying the reading of the collection, by default operation will be called
+        # 'list' for auto-configured resources
+        self._verify_match("/tickets", True, "GET",action='list')
+        # Since no "new" resource url is supported, in a call to "resource/new"
+        # 'new' must be interpreted as the member id matching resource/{id}
+        match = self._verify_match("/tickets/new", True, "GET",action='read', id='new')
+        self.assertTrue(match.get("id") == 'new')
+        #No post to the member allowed, only GET, PUT, DELETE
+        self._verify_match("/tickets/new", False, "POST")
+        #verifying the route mapping for create operation in the collection
+        self._verify_match("/tickets", True, "POST",action='create')
+        #No post allowed to the member resource
+        self._verify_match("/tickets/25", False, "POST")
+        #route mapping for to read the member
+        match = self._verify_match("/tickets/25", True, "GET", action='read',id='25')
+        #route mapping for update operations on the member
+        self._verify_match("/tickets/25", True, "PUT", action='update',id='25')
+        #verifying delete method on the member
+        self._verify_match("/tickets/25", True, "DELETE", action='delete',id='25')
+        #No update allowed for the collection
+        self._verify_match("/tickets", False, "PUT")
+        #No delete allowed for the collection
+        self._verify_match("/tickets", False, "DELETE")
+
+        # for the moment will allow to specify the format as extensions
+        # although request header content-type will be supported as well
+        match = self._verify_match("/tickets.xml", True, "GET")
+        self.assertTrue(match.get('format') == 'xml')
+
+        #checking the format does not interfere with id extraction for read paths
+        match = self._verify_match("/tickets/25.xml", True, "GET",id='25')
+
+        #checking the format does not interfere with id extraction for update paths
+        match = self._verify_match("/tickets/25.xml", True, "PUT", id='25')
+
+        # checking that auto-configured resources will have the
+        # /prototype resource within the collection resource to obtain
+        # a copyable instance of the member resource initialized with
+        # default values to be used in create operations.
+        # /prototype is a symbolic resource that one should GET, to have
+        # default values already filled in, to change only desired values
+        # and send it back to the server within a POST to the collection
+        match = self._verify_match("/tickets/prototype", True, "GET", action='prototype')
+        #/prototype shouldn't be interpreted as an id but as a particular resource
+        self.assertFalse(match.get('id',None))
+
+    def test_resource_supported_paths(self):
+        """Special test case to verify the functioning of
+           custom paths configuration that do not take place automatically
+           by RestResourceBase.map_resources().
+           When the resource does not accomplish the
+           standard interface of a collection/member resource
+           then it must provide a list of supported paths allong with
+           the supported http methods for each one
+           Additional the resource might implement get_custom_actions_map()
+           to override the default mapping from http methods to actions so
+           it's possible to indicate that a call to GET in some url should
+           be forwarded to an operation 'do_something()' instead of 'read()'
+           Configurations tested in this test case were provided
+           by CustomMappingResource test component within the present module
+        """
+        self._verify_match("/milestones", True, "GET")
+        #a call to resource/new must be interpreted as /resource{id}
+        #with 'new' as the id value
+        self._verify_match("/milestones/new", True, "GET",id='new')
+        #No post to the member configured
+        self._verify_match("/milestones/new", False, "POST")
+        #POST was configured for the collection, must be allowed
+        self._verify_match("/milestones", True, "POST")
+        #No post to the member configured
+        self._verify_match("/milestones/25", False, "POST")
+        #GET was configured, must be allowed
+        self._verify_match("/milestones/25", True, "GET")
+        #No put allowed for the configuration provided
+        self._verify_match("/milestones/25", False, "PUT")
+        #No delete allowed for the configuration provided
+        self._verify_match("/milestones/25", False, "DELETE")
+        #No put allowed for the configuration provided
+        self._verify_match("/milestones", False, "PUT")
+        #No delete allowed for the configuration provided
+        self._verify_match("/milestones", False, "DELETE")
+        #No configuration of format provided
+        self._verify_match("/milestones.xml", False, "GET")
+        #As configured this resource allows no format so any extension will
+        # be interpreted as part if the id
+        self._verify_match("/milestones/25.xml", True, "GET", id='25.xml')
+        #PUT wasn't included in supported_paths()
+        self._verify_match("/milestones/25.xml", False, "PUT")
+
+        # verifying 'prototype' is not interpreted as an id,
+        # since it was provided as a supported path instead
+        match = self._verify_match("/milestones/prototype", True, "GET")
+        self.assertFalse(match.get('id',None))
+
+        #The custom action 'prototype' was configured through
+        # get_custom_actions_map() of CustomMappingResource
+        self.assertTrue(match.get('action','prototype'))
+
+        # only GET configured in this test case,
+        # '/prototype' is not a member but a special resource
+        # to copy it before creating a new member
+        self._verify_match("/milestones/prototype", False, "PUT")
+
+    def tearDown(self):
+        pass
+

trac-dev/restontrac/tracrest/__init__.py

 TracError.__str__ = lambda self: unicode(self).encode('ascii', 'ignore')
 
 try:
-    from tracrest import *
+    from restontrac.tracrest import *
     msg = 'Ok'
 except Exception, exc:
 #    raise

trac-dev/restontrac/tracrest/api.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 trac.core import *
+from trac.perm import IPermissionRequestor
+
+__all__ = ['IRestResource', 'IFormatter', 'IRoutesResourceMapper', 'RestSystem', 'RespCodes']
+
+class RespCodes():
+    """ Container of Response Codes that migh
+        arise when a request is processed
+    """
+    #TODO evaluate, maybe it is possible to use something already done within trac.web.api
+    OK = ('OK', 200)
+    CREATED = ('Created', 201)
+    DELETED = ('', 204) # 204 says "Don't send a body!"
+    BAD_REQUEST = ('Bad Request', 400)
+    FORBIDDEN = ('Forbidden', 401)
+    NOT_FOUND = ('Not Found', 404)
+    DUPLICATE_ENTRY = ('Conflict/Duplicate', 409)
+    NOT_HERE = ('Gone', 410)
+    INTERNAL_ERROR = ('Internal Error', 500)
+    NOT_IMPLEMENTED = ('Not Implemented', 501)
+    THROTTLED = ('Throttled', 503)
+
+class IFormatter(Interface):
+    """ Specifies the contract of a formatter instance
+        to be used during request parsing and response
+        writing
+    """
+    def parse(self, request):
+        """Used when a request is received
+           In correspondance with the content-type specified
+           the corresponding Formatter instance is used calling
+           parse(), to decode the received data
+        """
+    def format(self, request):
+        """Used when a response is received
+           In correspondance with the content-type specified
+           the corresponding Formatter instance is used calling
+           parse(), to encode the returned data
+        """
+    def supported_ct(self):
+        """Returns the supported content-type to wich the current
+           Formatter responds
+        """
+
+class IRestResource(Interface):
+    """Interface that defines the protocol of a REST
+       resource inside trac's architecture
+    """
+    def get_name(self):
+        """Indicating the name of the resource instance, for indexing
+           purposes, possibly for delegating requests to the corresponding
+           method
+        """
+    def supported_paths(self):
+        """To indicate the paths that the current resource supports
+           along with its provided http methods
+           returns a dict of tuples (path, http_methods)
+           where path is a route path to be used in
+           routes.mapper.Mapper.connect(route_path)
+           and http_methods is an iterable with some of the
+           following values['GET','POST','PUT','DELETE']
+           the OPTIONS http method doesn't need to be provided
+           since its expected to be always present.
+           Consecuently it will be handled by reusable mechanisms
+        """
+
+    def extract_extra_params(self, request):
+        """Before the resource is used to process the concrete
+           request, it is given the oportunity to parse any additional
+           params that migh be relevant for the behaviour of the
+           resource
+           Returns a dictionary param name -> param value
+        """
+
+    def available_operations(self, request):
+        """Method to return available operations
+           to perform with the current resource
+        """
+
+    def read(self, request, *args, **kwargs):
+        """Method to handle Http GET requests
+           According to the type of resource this will have a different meaning
+           If the resource is a path base resource, this will return
+           the list of links to the nested resources.
+           If the resource is a concrete one, this will return the resource's data
+           as it would be done in a READ operation in CRUD terms
+        """
+
+    def create(self, request, *args, **kwargs):
+        """Method to handle Http POST requests
+           This will in general by translated into
+           a CREATE routine in CRUD terms
+        """
+
+    def update(self, request, *args, **kwargs):
+        """Method to handle Http POST requests
+           This will in general by translated into
+           an UPDATE routine in CRUD terms
+        """
+
+    def update_diff(self, request, *args, **kwargs):
+        """Method to handle Http PATCH requests
+           This will in general by translated into
+           an UPDATE routine in CRUD terms
+           but is expected to update only values that
+           were received in the request, leaving all
+           other values untouched
+        """
+        #TODO evaluate if this operation must be supported
+
+    def delete(self, request, *args, **kwargs):
+        """Method to handle Http DELETE requests
+           This will in general by translated into
+           a DELETE routine in CRUD terms
+        """
+
+    def get_formatter(self, content_type):
+        """Giving the resource a chance to specify its
+           own special formatter to be used for request
+           input parsing and output formatting
+           Returns an instance of IFormatter
+        """
+
+class IRoutesResourceMapper(Interface):
+    """
+       Extension point interface to externalize the mapping of
+       resources to urls using a Routes Mapper
+       There could be a possible use of this extension point
+       to have a single component implementing the logic of
+       resource mapping instead of having every resource
+       handling this logic
+    """
+    def map_resources(self, mapper):
+        """Performs the mapping logic using the routes.mapper.Mapper
+           instance received as argument
+        """
+
+class RestSystem(Component):
+    """ Core of the RPC system. """
+    implements(IPermissionRequestor)
+
+    #instances of resources to handle REST requests
+    resources = ExtensionPoint(IRestResource)
+
+    def __init__(self):
+        super(RestSystem, self).__init__()
+
+    def resource_names(self):
+        return [r.get_name() for r in self.resources]
+
+    def resource_path_dict(self):
+        """Returns a map of every configured path with
+           its corresponding resource handler instance
+        """
+        return dict([(path, resource) for resource in self.resources
+                     for path in resource.supported_paths().iterkeys()])
+
+    def getAPIVersion(self, req):
+        #TODO unfinished work, do not review this
+        pass
+
+    def call_resource(self, req, resource, method, args, kwargs):
+        #TODO unfinished work, do not review this
+        if method in resource.supported_operations():
+            callable = resource.__getattr__(method)
+            result = callable(req, args, kwargs)
+            #if isinstance(result, GeneratorType):
+            #    result = list(result)
+

trac-dev/restontrac/tracrest/resources.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 trac.core import implements
+from trac.core import Component
+#TODO absolute imports
+from api import IRestResource, IRoutesResourceMapper
+
+#default http methods supported by collections
+def_coll_methods = ['GET','POST', 'OPTIONS']
+#default http methods supported by members
+def_member_methods = ['GET', 'PUT', 'DELETE', 'PATCH','OPTIONS']
+
+class RestResourceBase(Component):
+    """ Reusable implementation of IRestResource that provides
+        canned implementation of the most commom behaviour of
+        resources acting like collection/member pairs
+        Handles auto-configuration of routes using provided data
+        Specializations might be created to handle specific reusable
+        implementations of CRUD operations or to directly implement
+        a specific isolated resource just to reuse the url mapping
+        infrastructure
+    """
+    abstract = True
+    implements(IRestResource, IRoutesResourceMapper)
+
+    def setup(self, collection_name, member_name, resource_name = None,
+                 coll_meths=def_coll_methods, member_meths = def_member_methods,
+                 parent_collection=None, parent_resource=None):
+        """
+           To configure the resource's data that migh be used for the configuration
+           of url mappings.
+           Operation to be called within the init method of classes extending
+           this base class. See the test cases for a reference.
+        """
+        if resource_name:
+            self.name = resource_name
+        elif collection_name:
+            self.name = collection_name
+        assert self.name, "Resource name or collection name must be provided"
+        self.collection_name = collection_name
+        self.member_name = member_name
+        self.collection_methods = coll_meths
+        self.member_methods = member_meths
+        #If custom paths,methods are provided, auto_map is turned off
+        self.auto_map = len(self.supported_paths()) == 0
+        self.parent_collection = parent_collection
+        self.parent_resource = parent_resource
+
+    #Implementation of IRoutesResourceMapper
+    def map_resources(self, mapper):
+        """
+            Implementation of IRoutesResourceMapper's logic
+            By default is auto_config aware, meaning that if an explicit
+            set of paths is returned by self.supported_paths() the present routine
+            has no effect
+            If overriden in specializations, this logic gets overriden as well
+            so the routine will always have effect
+            Specializations might want to override this method to perform
+            a free numer of Mapper.resource() or Mapper.connect() calls
+        """
+        if self.auto_map:
+            parent_res = None
+            if self.parent_collection and self.parent_resource:
+                parent_res = {'collection_name':self.parent_collection,
+                               'member_name': self.parent_resource}
+            mapper.resource(self.member_name, self.collection_name,
+                            collection={'prototype':'GET'},
+                            controller = self.name, member={'edit':'IGNORE'},
+                            new='IGNORE', parent_resource = parent_res,
+                            member_getter_action='read',
+                            collection_getter_action='list')
+
+    #Implementation of IRestResource
+    def get_name(self):
+        """Indicating the name of the resource instance, for indexing
+           purposes
+           defaults to the resource's collection_name
+        """
+        #TODO verify if the object will be created without the init method
+        #thus causing self.name to be likely to have None value
+        if not self.name:
+            return self.collection_name
+        return self.name
+
+    def supported_paths(self):
+        """To indicate the list of supported paths that the current resource
+           will be handling
+           returns a dict {path: http_methods}
+        """
+        return {}
+
+    def get_custom_actions_map(self):
+        """Returns an action map indicating
+         for a pair path,HttpMethod which specific action
+         should be mapped if there is one
+         Used allong with supported_paths() to make customized
+         configurations that do not respond to the standard
+         expected ones
+        """
+        return {}
+
+    def available_operations(self, request):
+        """Method to return available operations
+           to perform with the current resource
+           by default assumes the request has been pre-processed
+           and the rest request type has been published in the request scope
+        """
+        req_type = request.args.get('REST_REQUEST','COLLECTION')
+        if 'COLLECTION' == req_type:
+            return self.collection_methods
+        elif 'MEMBER' == req_type:
+            return self.member_methods
+        else:
+            return [] #nothing supported
+
+    def read(self, request, *args, **kwargs):
+        """Method to handle Http GET requests
+           According to the type of resource this will have a different meaning
+           If the resource is a path base resource, this will return
+           the list of links to the nested resources.
+           If the resource is a concrete one, this will return the resource's data
+           as it would be done in a READ operation in CRUD terms
+        """
+        NotImplementedError()
+
+    def create(self, request, *args, **kwargs):
+        """Method to handle Http POST requests
+           This will in general by translated into
+           a CREATE routine in CRUD terms
+           Specializations must implement specific logic
+           if not, unsupported opperation http error
+           will be returned to the client
+        """
+        NotImplementedError()
+
+    def update(self, request, *args, **kwargs):
+        """Method to handle Http POST requests
+           This will in general by translated into
+           an UPDATE routine in CRUD terms
+           Specializations must implement specific logic
+           if not, unsupported opperation http error
+           will be returned to the client
+        """
+        NotImplementedError()
+
+    def update_diff(self, request, *args, **kwargs):
+        """Method to handle Http PATCH requests
+           This will in general by translated into
+           an UPDATE routine in CRUD terms
+        """
+        #TODO evaluate if this operation must be supported
+
+    def delete(self, request, *args, **kwargs):
+        """Method to handle Http DELETE requests
+           This will in general by translated into
+           a DELETE routine in CRUD terms
+           Specializations must implement specific logic
+           if not, unsupported opperation http error
+           will be returned to the client
+        """
+        NotImplementedError()
+
+    def get_formatter(self, content_type):
+        """Specializations willing to have a custom
+           formatter used during received calls, shoud
+           return here an instance of IFormatter
+           by default no formatter is specified
+        """
+        return None

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)

trac-dev/restontrac/tracrest/web_ui.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
+"""
+import sys
+from trac.core import *
+from trac.perm import PermissionError
+from trac.resource import ResourceNotFound
+from trac.util.text import to_unicode
+from trac.web.main import IRequestHandler
+from trac.web.api import RequestDone, HTTPUnsupportedMediaType,\
+    HTTPInternalError
+#TODO absolute imports
+from api import IRoutesResourceMapper, IFormatter, RestSystem, RespCodes
+from util import setup_routes_config, map_resource_path, RestMapper
+import re
+__all__ = ['RestModule']
+
+class RestModule(Component):
+    """ Handles REST calls from HTTP clients"""
+
+    implements(IRequestHandler)
+
+    #providing an extension point for external components that might
+    #serve as glue to configure resource instances with their corresponding
+    #urls.
+    # They should somehow have access to both, the corresponding resource
+    # and its intended url schema
+    resource_mappers = ExtensionPoint(IRoutesResourceMapper)
+
+    #ortogonal formatters to be used across all web requests
+    formatters = ExtensionPoint(IFormatter)
+
+    def __init__(self):
+        self._mapper = RestMapper(controller_scan=RestSystem(self.env).resource_names())
+        self.setup()
+
+    def setup(self):
+        #mapping resorces to their corresponding paths
+        #First resources with custom mappings
+        #There is a need to sort the paths to avoid name clashing
+        # between resource names like collection/{id} and collection/prototype
+        sorted_paths = sorted(RestSystem(self.env).resource_path_dict().iteritems())
+        for path, res in sorted_paths:
+            #TODO evaluate if this default value 'GET' is OK
+            http_methods =  res.supported_paths().get(path, ('GET',))
+            map_resource_path(self._mapper, path, res.get_name(),
+                     http_methods, res.get_custom_actions_map())
+        #Then auto configured resources
+        for r_mapper in self.resource_mappers:
+            r_mapper.map_resources(self._mapper)
+
+
+        # IRequestHandler methods
+
+    def formatters_dict(self):
+        if not self._formatters and len(self.formatters) > 0:
+            self._formatters =  dict([(fmt.supported_ct, fmt)
+                                      for fmt in self.formatters])
+        return self._formatters
+
+    def _extract_request_method(self, req):
+        """
+           Not all clients support all http methods, that's why
+           using a request parameter named "_method"  is a
+           generally accepted convention as a workaround
+           The present method attempts to find the _request param
+           and return it to be used in the REST request
+        """
+        method = req.method
+        #TODO implement the magic like Route's middleware does
+        return method
+
+    def match_request(self, req):
+        """ Look for available protocols serving at requested path and
+            content-type. """
+        match = re.match(r'^/api/([^/]+)$', req.path_info)
+        if not match:
+            #TODO check if "/login/api" accomplishes REST guidelines
+            match = re.match(r'^/login/api/([^/]+)$', req.path_info)
+        if match:
+            #TODO evaluate passing this logic as a first step in process_request()
+            #will attempt to find the requested resource
+            method = self._extract_request_method(req)
+            r_data = self._mapper.routematch(req.path_info,
+                environ={'method':method})
+            if r_data:
+                r_params = r_data[0]
+                #publishing the supported methods on the request
+                req.args['REST_REQUEST'] = \
+                        'COLLECTION' if ':(id)' in r_data[1].routepath else 'MEMBER'
+                res_name = r_params.get('controller')
+                resource = self.resources_dict.get(res_name)
+                #publishing in the request the params relative to the resource
+                req.args.expand(r_params)
+                #publishing the corresponding resource
+                req.args['resource'] = resource
+                #Giving the resource a chance to make any request params pre-processing
+                extra_args = resource.extract_extra_params(req) or {}
+                req.args.expand(extra_args)
+                #Setting up the route's request config to enable the use of url_for()
+                setup_routes_config(self._mapper, req, r_params)
+                return True
+        #TODO evaluate if there is a need for docs, since REST is expected to be self-explanatory
+        return False
+
+    def process_request(self, req):
+        res = req.args.get('resource', None)
+        content_type = req.get_header('Content-Type') or 'text/html'
+        if res:
+            if req.method not in res.available_operations():
+                self._send_unsupported_error(req)
+
+            # Perform the method call
+            self.log.debug("REST incoming request of content type '%s' " \
+                    "dispatched to %s" % (content_type, repr(res)))
+
+            formatter = res.get_formatter(content_type)
+            if not formatter:
+                formatter = self.formatters_dict(content_type, None)
+            if not formatter:
+                # Attempted to make a REST call for an unsupported content type
+                body = "Requested Content-Type  unsupported ('%s') at path '%s'." % \
+                           (content_type, req.path_info)
+                self.log.error(body)
+                req.send_error(None, template='', content_type='text/plain',
+                    status=HTTPUnsupportedMediaType.code, env=None, data=body)
+
+            self._process_rest_call(req, res, formatter, req.method)
+
+        #TODO verify handling of headers ContentType and Accept
+
+
+    def _process_rest_call(self, req, resource, formatter):
+        """Process incoming REST request and finalize response."""
+        method = req.method.lower
+
+        try :
+            self.log.debug("REST(%s) call by '%s'", repr(resource), req.authname)
+            #attemp to use the formatter specified by the resource if there is one
+
+            rest_req = req.rest = formatter.parse(req)
+            #TODO check if necessary
+            rest_req['mimetype'] = content_type = formatter.supported_ct()
+            # will need permissions over REST
+            req.perm.require('REST')
+
+            args = rest_req.get('params') or []
+            kwargs = rest_req.get('kwargs') or {}
+            self.log.debug("REST(%s) call by '%s' %s", repr(resource), \
+                                              req.authname, method)
+            try :
+                result = RestSystem(self.env).call_resource(req, resource, method, args, kwargs)
+                result = formatter.format(result)
+                self._send_response(req, result, content_type)
+
+            except ( PermissionError, ResourceNotFound), e:
+                raise
+            except NotImplementedError:
+                self._send_unknown_error()
+            except Exception:
+                e, tb = sys.exc_info()[-2:]
+                raise #ServiceException(e), None, tb
+        except RequestDone :
+            #it means the resource raised the exception indicating it has processed the request completely
+            raise
+        except (PermissionError, ResourceNotFound), e:
+            self.log.exception("REST(%s) Error", resource.name)
+            try :
+                #The error message might be in a content type different to the requested one
+                message, cont_type = self._prepare_error_message(e, content_type)
+                formatter = self.formatters_dict()[cont_type]
+                formatter.format(message)
+                self._send_response(req, result, cont_type)
+            except RequestDone :
+                raise
+            except Exception, e :
+                self.log.exception("REST(%s) Unhandled protocol error", resource.get_name())
+                self._send_unknown_error(req, e)
+        except Exception, e :
+            self.log.exception("REST(%s) Unhandled protocol error", resource.get_name())
+            self._send_unknown_error(req, e)
+
+    def _send_response(self, req, response, content_type='application/json'):
+        self.log.debug("RPC(json) encoded response: %s" % response)
+        response = to_unicode(response).encode("utf-8")
+        req.send_response(200)
+        req.send_header('Content-Type', content_type)
+        req.send_header('Content-Length', len(response))
+        req.end_headers()
+        req.write(response)
+        raise RequestDone()
+
+
+    def _send_unknown_error(self, req, e):
+        """Last recourse if protocol cannot handle the RPC request | error"""
+        method_name = req.rpc and req.rpc.get('method') or '(undefined)'
+        body = "Unhandled protocol error calling '%s': %s" % (
+                                        method_name, to_unicode(e))
+        req.send_error(None, template='', content_type='text/plain',
+                            env=None, data=body, status=HTTPInternalError.code)
+
+    def _send_unsupported_error(self, req, e):
+        """In case of a call to an unsupported operation"""
+        body = "Operation not supported error calling '%s': %s" % (
+            req.method, to_unicode(e))
+        req.send_error(None, template='', content_type='text/plain',
+            env=None, data=body, status=RespCodes.NOT_IMPLEMENTED[1])
+
+    def _send_not_acceptable_error(self, req, e):
+        """In case of a call to an unsupported operation"""
+        method_name = req.rpc and req.rpc.get('method')
+        body = "Operation not supported error calling '%s': %s" % (
+            method_name, to_unicode(e))
+        req.send_error(None, template='', content_type='text/plain',
+            env=None, data=body, status=RespCodes.NOT_IMPLEMENTED[1])
+
+
+