Source code for pyfarm.agent.http.core.resource

# No shebang line, this module is meant to be imported
#
# Copyright 2013 Oliver Palmer
#
# 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.

"""
Resource
--------

Base resources which can be used to build top leve
documents, pages, or other types of data for the web.
"""

from json import loads

try:
    from httplib import (
        responses, NOT_FOUND, BAD_REQUEST, UNSUPPORTED_MEDIA_TYPE,
        METHOD_NOT_ALLOWED, INTERNAL_SERVER_ERROR, OK, NOT_ACCEPTABLE)
except ImportError:  # pragma: no cover
    from http.client import (
        responses, NOT_FOUND, BAD_REQUEST, UNSUPPORTED_MEDIA_TYPE,
        METHOD_NOT_ALLOWED, INTERNAL_SERVER_ERROR, OK, NOT_ACCEPTABLE)

from twisted.internet.defer import Deferred, inlineCallbacks
from twisted.web.server import NOT_DONE_YET
from twisted.web.resource import Resource as _Resource
from twisted.web.static import File
from twisted.web.http import Request
from voluptuous import Invalid, Schema

from pyfarm.core.enums import STRING_TYPES
from pyfarm.agent.http.core import template
from pyfarm.agent.logger import getLogger
from pyfarm.agent.utility import dumps

logger = getLogger("agent.http.resource")


[docs]class Resource(_Resource): """ Basic subclass of :class:`._Resource` for passing requests to specific methods. Unlike :class:`._Resource` however this will will also handle: * Templates * Content type discovery and validation * Handling of deferred responses * Validation of POST/PUT data against a schema :cvar string TEMPLATE: The name of the template this class will use when rendering an html view. :type SCHEMAS: dict :cvar SCHEMAS: A dictionary of schemas to validate the data of an incoming request against. The structure of this dictionary is:: {http method: <instance of voluptuous.Schema>} If the schema validation fails the request will be rejected with ``400 BAD REQUEST``. :type ALLOWED_CONTENT_TYPE: frozenset :cvar ALLOWED_CONTENT_TYPE: An instance of :class:`frozenset` which describes what this resource is going to allow in the ``Content-Type`` header. The request and this instance must share at least on entry in common. If not, the request will be rejected with ``415 UNSUPPORTED MEDIA TYPE``. **This must be defined in subclass** :type ALLOWED_ACCEPT: frozenset :cvar ALLOWED_ACCEPT: An instance of :class:`frozenset` which describes what this resource is going to allow in the ``Accept`` header. The request and this instance must share at least one entry in common. If not, the request will be rejected with ``406 NOT ACCEPTABLE``. **This must be defined in subclass** :type DEFAULT_ACCEPT: frozenset :cvar DEFAULT_ACCEPT: If ``Accept`` header is not present in the request, use this as the value instead. This defaults to ``frozenset(["*/*"])`` :type DEFAULT_CONTENT_TYPE: frozenset :cvar DEFAULT_CONTENT_TYPE: If ``Content-Type`` header is not present in the request, use this as the value instead. This defaults to ``frozenset([""])`` """ TEMPLATE = NotImplemented SCHEMAS = {} # These must be set in a subclass and # should contain the full range of headers # allowed for Accept and Content-Type. ALLOWED_ACCEPT = NotImplemented ALLOWED_CONTENT_TYPE = NotImplemented # Default values if certain headers # are not present. DEFAULT_ACCEPT = frozenset(["*/*"]) DEFAULT_CONTENT_TYPE = frozenset([None]) @property def template(self): """ Loads the template provided but the partial path in ``TEMPLATE`` on the class. """ if self.TEMPLATE is NotImplemented: raise NotImplementedError("You must set `TEMPLATE` first") return template.load(self.TEMPLATE)
[docs] def methods(self): """ Returns a tuple of methods which an instance of this class implements """ methods = [] for method_name in ("get", "put", "post", "delete", "head"): method = getattr(self, method_name, None) if method is not None and callable(method): methods.append(method_name) return tuple(methods)
[docs] def get_content_type(self, request): """ Return the ``Content-Type`` header(s) in the request or ``DEFAULT_CONTENT_TYPE`` if the header is not set. """ header = request.requestHeaders.getRawHeaders("Content-Type") if not header: return self.DEFAULT_CONTENT_TYPE content_type = set() for value in header: # Split out the various parts of the header and return them. We # ignore the q parameter here for the moment. content_type.update( entry.split(";")[0] for entry in value.split(",")) return content_type
[docs] def get_accept(self, request): """ Return the ``Accept`` header(s) in the request or ``DEFAULT_ACCEPT`` if the header is not set. """ header = request.requestHeaders.getRawHeaders("Accept") if not header: return self.DEFAULT_ACCEPT accept = set() for value in header: # Split out the various parts of the header and return them. We # ignore the q parameter here for the moment. accept.update(entry.split(";")[0] for entry in value.split(",")) return frozenset(accept)
[docs] def putChild(self, path, child): """ Overrides the builtin putChild() so we can return the results for each call and use them externally. """ assert isinstance(path, STRING_TYPES) assert isinstance(child, (Resource, File)) _Resource.putChild(self, path, child) return child
[docs] def error(self, request, code, message): """ Writes the proper out an error response message depending on the content type in the request """ response_types = self.get_accept(request) logger.error(message) if "text/html" in response_types: request.setResponseCode(code) html_error = template.load("error.html") result = html_error.render( code=code, code_msg=responses[code], message=message) request.write(result.encode()) elif "application/json" in response_types: request.setResponseCode(code) request.write(dumps({"error": message})) else: request.setResponseCode(UNSUPPORTED_MEDIA_TYPE) error = dumps( {"error": "Can only handle one of %s here" % self.ALLOWED_ACCEPT}) request.write(error) request.finish()
[docs] def set_response_code_if_not_set(self, request, code): """Sets the response code if one has not already been set""" if request.code == OK: request.setResponseCode(code)
[docs] def render_tuple(self, request, response): """ Takes a response tuple of ``(body, code, headers)`` or ``(body, code)`` and renders the resulting data onto the request. """ assert isinstance(response, (list, tuple)), type(response) if len(response) == 3: body, code, headers = response if isinstance(headers, dict): for header, value in headers.items(): if isinstance(value, STRING_TYPES): value = [value] request.responseHeaders.setRawHeaders(header, value) if not request.responseHeaders.hasHeader("Content-Type"): request.responseHeaders.setRawHeaders( "Content-Type", list(self.DEFAULT_CONTENT_TYPE) ) # Don't use set_response_code_if_not_set, always honor the return # value from the function. request.setResponseCode(code) # Cast to str, otherwise Twisted responds # TypeError: Data must not be unicode request.write(str(body)) request.finish() elif len(response) == 2: body, code = response # Set Content-Type if it has not already been set if not request.responseHeaders.hasHeader("Content-Type"): request.responseHeaders.setRawHeaders( "Content-Type", list(self.DEFAULT_CONTENT_TYPE) ) # Don't use set_response_code_if_not_set, always honor the return # value from the function. request.setResponseCode(code) # Cast to str, otherwise Twisted responds # TypeError: Data must not be unicode request.write(str(body)) request.finish() else: self.error( request, INTERNAL_SERVER_ERROR, "Expected two or three length tuple for response" )
[docs] @inlineCallbacks def render_deferred(self, request, deferred): """ An inline callback used to unpack a deferred response object. """ assert isinstance(deferred, Deferred) response = yield deferred self.render_tuple(request, response)
[docs] def render(self, request): try: handler_method = getattr(self, request.method.lower()) except AttributeError: self.error( request, METHOD_NOT_ALLOWED, "Method %s is not supported" % request.method) return NOT_DONE_YET assert isinstance(self.ALLOWED_CONTENT_TYPE, (set, frozenset)) content = request.content.read().strip() shared_content_types = \ self.get_content_type(request) & self.ALLOWED_CONTENT_TYPE # Ensure we can handle the content of the request if content and not shared_content_types: self.error( request, UNSUPPORTED_MEDIA_TYPE, "Can only support content " "type(s) %s" % self.ALLOWED_CONTENT_TYPE) return NOT_DONE_YET # Determine if we'll be able to produce a response for the request assert isinstance(self.ALLOWED_ACCEPT, (set, frozenset)) response_types = self.get_accept(request) & self.ALLOWED_ACCEPT if not response_types: self.error( request, NOT_ACCEPTABLE, "Can only respond with %s" % self.ALLOWED_ACCEPT) return NOT_DONE_YET # Keywords to pass into `handler_method` below kwargs = dict(request=request) # Attempt to load the data for the incoming request if appropriate if content and "application/json" in shared_content_types: try: data = loads(content) except ValueError as e: self.error( request, BAD_REQUEST, "Failed to decode json data: %r" % e) return NOT_DONE_YET # We have data, check to see if we have a schema # and if we do does it validate. schema = self.SCHEMAS.get(request.method) if isinstance(schema, Schema): try: schema(data) except Invalid as e: self.error( request, BAD_REQUEST, "Failed to validate the request data " "against the schema: %s" % e) return NOT_DONE_YET kwargs.update(data=data) try: response = handler_method(**kwargs) except Exception as error: self.error( request, INTERNAL_SERVER_ERROR, "Unhandled error while rendering response: %s" % error ) return NOT_DONE_YET # The handler_method is going to handle everything if response == NOT_DONE_YET: return NOT_DONE_YET # Flask style response elif isinstance(response, tuple): self.render_tuple(request, response) return NOT_DONE_YET # handler_method() is returns a Deferred which means # we have to handle writing the response ourselves elif isinstance(response, Deferred): self.render_deferred(request, response) return NOT_DONE_YET elif isinstance(response, STRING_TYPES): # Set Content-Type if it has not already been set if not request.responseHeaders.hasHeader("Content-Type"): request.responseHeaders.setRawHeaders( "Content-Type", list(self.DEFAULT_CONTENT_TYPE) ) self.set_response_code_if_not_set(request, OK) request.write(response) request.finish() return NOT_DONE_YET else: self.error( request, INTERNAL_SERVER_ERROR, "Unhandled %r in response" % response ) return NOT_DONE_YET