Source

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

Full commit
#!/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])