# 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